Compare commits

...

10 Commits

Author SHA1 Message Date
Julien Calixte
9aecbfbbf9 docs(tasks): mark T11 done 2026-05-28 01:51:46 +02:00
Julien Calixte
7c755740b9 feat(push): notify on defect filed without delaying the response
Send fire-and-forget via event.waitUntil after the row is created, so
filing latency is unaffected. (T11/F7)
2026-05-28 01:51:41 +02:00
Julien Calixte
b56577ddc2 feat(push): fan out web push to subscribed devices on defect filed
Prune endpoints the push service rejects with 404/410; log and skip
transient failures so one bad device never blocks the rest. (T11/F7)
2026-05-28 01:51:36 +02:00
Julien Calixte
53349ca530 docs(deploy): add Coolify secret-handling and stale-deploy troubleshooting 2026-05-28 01:44:04 +02:00
Julien Calixte
d32c108f23 docs(tasks): mark T10 done; record owner-only push decision 2026-05-28 01:23:22 +02:00
Julien Calixte
0745ad14f0 feat(push): owner enrolment UI, service worker and VAPID keygen (T10)
EnableNotifications subscribes the owner's device with the public VAPID
key and stores it; sw.js receives pushes and opens /defects on click;
`pnpm vapid:keys` mints the key pair.
2026-05-28 01:23:10 +02:00
Julien Calixte
ea7384591c feat(push): owner-only subscribe/unsubscribe endpoints + VAPID config (T10)
POST/DELETE /api/subscriptions gated to the configured owner; runtime
config carries the public VAPID key, owner email and the signing secret.
2026-05-28 01:20:20 +02:00
Julien Calixte
5016c5cd47 feat(push): owner-only gate and subscription body validation (T10)
isOwner compares the session email to the configured owner; the body
matches the browser PushSubscription.toJSON() shape, reporter resolved
server-side.
2026-05-28 01:19:19 +02:00
Julien Calixte
9caebca20d feat(push): subscription store keyed by endpoint (T10)
Idempotent upsert per device endpoint, list for fan-out, and
delete-by-endpoint for the 410 prune path.
2026-05-28 01:17:58 +02:00
Julien Calixte
82ac540909 feat(auth): Google OAuth, sealed session and default-deny gating (T9)
Gate the app behind Google sign-in restricted to verified @theodo.com
identities, and record the real reporter on filed defects (F9).

- nuxt-auth-utils for sealed cookie sessions + the Google OAuth handler,
  mounted at /auth/google/callback to match the registered redirect URI.
- isAllowedGoogleUser re-derives the domain from Google's verified email;
  the spoofable `hd` claim is deliberately ignored (DESIGN T9).
- Default-deny: server middleware 401s unauthenticated /api calls (health
  and the session endpoint excepted); a global route middleware redirects
  unauthenticated page navigations to /login.
- getReporter() now reads the session email instead of the dev stub.
- Env contract moves to nuxt-auth-utils names (NUXT_SESSION_PASSWORD,
  NUXT_OAUTH_GOOGLE_*); .env.example, compose and README updated.

Unit-tested: domain check (incl. hd-spoof + look-alike) and public-path
matching. Live OAuth round-trip pending manual verification.
2026-05-28 01:09:48 +02:00
34 changed files with 1255 additions and 32 deletions

View File

@@ -1,5 +1,5 @@
# App
NUXT_SESSION_SECRET=change-me
# App — session sealing key for nuxt-auth-utils (must be ≥32 chars)
NUXT_SESSION_PASSWORD=change-me-to-a-long-random-string-min-32-chars
# Database (Postgres) — Coolify-managed in prod, docker-compose in dev
DATABASE_URL=postgres://andon:andon@localhost:5432/andon
@@ -9,12 +9,21 @@ POSTGRES_USER=andon
POSTGRES_PASSWORD=andon
POSTGRES_DB=andon
# Google OAuth (Task 9) — restricted to the theodo.com hosted domain
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=
# Google OAuth (Task 9) — nuxt-auth-utils reads these exact names. Access is
# restricted server-side to verified @theodo.com emails (the `hd` claim is not
# trusted on its own). REDIRECT_URL must match an Authorized redirect URI in the
# Google Cloud console (defaults to <origin>/auth/google/callback if unset).
NUXT_OAUTH_GOOGLE_CLIENT_ID=
NUXT_OAUTH_GOOGLE_CLIENT_SECRET=
NUXT_OAUTH_GOOGLE_REDIRECT_URL=
# Web Push / VAPID (Task 10)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:example@theodo.com
# Web Push / VAPID (Task 10) — Nuxt binds these to runtimeConfig at runtime.
# Generate the key pair with `pnpm vapid:keys`. The public key is sent to the
# browser; the private key signs pushes server-side (T11) and must stay secret.
NUXT_PUBLIC_VAPID_PUBLIC_KEY=
NUXT_VAPID_PRIVATE_KEY=
NUXT_VAPID_SUBJECT=mailto:example@theodo.com
# Push notifications go to this single owner only (G3). Not secret — also gates
# the client-side "Enable notifications" button.
NUXT_PUBLIC_OWNER_EMAIL=

BIN
.pnpm-store/v11/index.db Normal file

Binary file not shown.

View File

@@ -61,9 +61,37 @@ migrate step is needed.
1. Point Coolify at this repo; it builds the production `Dockerfile`.
2. Create a **managed Postgres** in Coolify (automatic backups — [ADR 0003](./docs/adr/0003-use-coolify-managed-postgres-over-sqlite.md))
and set `DATABASE_URL` to it. _(Alternatively deploy `docker-compose.yml`, which bundles a Postgres service.)_
3. Set the env vars from [`.env.example`](./.env.example) (`NUXT_SESSION_SECRET`,
Google OAuth, VAPID) and route `andon.apoena.dev` to the service.
3. Set the env vars from [`.env.example`](./.env.example) (`NUXT_SESSION_PASSWORD`,
`NUXT_OAUTH_GOOGLE_*`, `NUXT_*VAPID*`, `NUXT_PUBLIC_OWNER_EMAIL`) and route
`andon.apoena.dev` to the service. Add
`https://andon.apoena.dev/auth/google/callback` as an Authorized redirect URI
in the Google Cloud OAuth client.
4. **Enable Coolify's autodeploy** so it builds and deploys on every push to
`main`. Protect `main` with required PR review + CI so only vetted commits
reach production.
5. The OAuth `hd` claim is spoofable — the app re-checks the email domain server-side (T9).
### Secrets
Mark the secret vars (`NUXT_SESSION_PASSWORD`, `NUXT_OAUTH_GOOGLE_CLIENT_SECRET`,
`NUXT_VAPID_PRIVATE_KEY`, the Postgres password) as **runtime-only** in Coolify —
**not** build-time variables. The app reads them from runtime config at boot, so
the build never needs them. Marking them as build variables passes them as Docker
`ARG`s, which leaks them into build logs and image layers (Docker warns
`SecretsUsedInArgOrEnv`). Only `NUXT_PUBLIC_*` values are non-secret. If a secret
is ever exposed, **rotate it** and redeploy (rotating `NUXT_SESSION_PASSWORD`
invalidates all existing sessions).
### Troubleshooting: a deploy that looks stale
If the live site shows old UI after a successful deploy, check in this order —
it's almost never the build:
1. **Confirm what's actually live.** `git log -1 origin/main` is the source of
truth; a local-only commit can't deploy. The Coolify deploy log shows the
built commit, a non-cached `RUN pnpm build`, and a healthy rolling update.
2. **Client cache.** Hard-refresh (`Cmd+Shift+R`) or open a private window. A
browser-cached HTML document referencing old chunk hashes is the usual cause.
3. **CDN/proxy cache.** If a CDN (e.g. Cloudflare) fronts the domain, purge it.
4. **Build cache (last resort).** Redeploy in Coolify with cache disabled / force
rebuild.

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
// Owner-only Web Push enrolment (T10/F8). Renders nothing unless the signed-in
// user is the configured owner. Registers the service worker, subscribes the
// device with the public VAPID key, and stores the subscription server-side;
// turning it off unsubscribes and prunes the row.
const { user } = useUserSession()
const config = useRuntimeConfig()
const ownerEmail = config.public.ownerEmail
const vapidPublicKey = config.public.vapidPublicKey
const isOwner = computed(
() =>
!!user.value?.email &&
!!ownerEmail &&
user.value.email.toLowerCase() === ownerEmail.toLowerCase(),
)
type State = 'loading' | 'idle' | 'subscribed' | 'denied' | 'unsupported' | 'misconfigured'
const state = ref<State>('loading')
const busy = ref(false)
const supported = () =>
import.meta.client &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
onMounted(async () => {
if (!isOwner.value) return
if (!supported()) return void (state.value = 'unsupported')
if (!vapidPublicKey) return void (state.value = 'misconfigured')
if (Notification.permission === 'denied') return void (state.value = 'denied')
const reg = await navigator.serviceWorker.getRegistration()
const existing = await reg?.pushManager.getSubscription()
state.value = existing ? 'subscribed' : 'idle'
})
async function enable() {
busy.value = true
try {
const reg = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
state.value = permission === 'denied' ? 'denied' : 'idle'
return
}
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
})
await $fetch('/api/subscriptions', { method: 'POST', body: sub.toJSON() })
state.value = 'subscribed'
} finally {
busy.value = false
}
}
async function disable() {
busy.value = true
try {
const reg = await navigator.serviceWorker.getRegistration()
const sub = await reg?.pushManager.getSubscription()
if (sub) {
await $fetch('/api/subscriptions', { method: 'DELETE', body: { endpoint: sub.endpoint } })
await sub.unsubscribe()
}
state.value = 'idle'
} finally {
busy.value = false
}
}
// VAPID public keys are URL-safe base64; PushManager wants the raw bytes,
// backed by a concrete ArrayBuffer to satisfy the BufferSource type.
function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const normalised = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(normalised)
const bytes = new Uint8Array(new ArrayBuffer(raw.length))
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i)
return bytes
}
</script>
<template>
<div v-if="isOwner" class="text-sm">
<div v-if="state === 'subscribed'" class="flex items-center gap-2">
<span class="opacity-70">🔔 Notifications on</span>
<button type="button" class="btn btn-ghost btn-xs" :disabled="busy" @click="disable">
Turn off
</button>
</div>
<button
v-else-if="state === 'idle'"
type="button"
class="btn btn-outline btn-sm"
:disabled="busy"
@click="enable"
>
Enable notifications
</button>
<p v-else-if="state === 'denied'" class="opacity-60">
Notifications are blocked in your browser settings.
</p>
<p v-else-if="state === 'unsupported'" class="opacity-60">
This browser cant receive push notifications.
</p>
<p v-else-if="state === 'misconfigured'" class="opacity-60">
Push notifications arent configured.
</p>
</div>
</template>

View File

@@ -1,6 +1,12 @@
<script setup lang="ts">
// Single domain (ADR 0004): report at `/`, view all defects at `/defects`,
// with a persistent header to move between them.
const { loggedIn, user, clear } = useUserSession()
async function signOut() {
await clear()
await navigateTo('/login')
}
</script>
<template>
@@ -25,6 +31,12 @@
</NuxtLink>
</li>
</ul>
<div v-if="loggedIn" class="flex items-center gap-3 pl-2">
<span class="hidden text-sm opacity-70 sm:inline">{{ user?.email }}</span>
<button type="button" class="btn btn-ghost btn-sm" @click="signOut">
Sign out
</button>
</div>
</nav>
</header>

View File

@@ -0,0 +1,13 @@
// Redirect unauthenticated visitors to the login page, and bounce already
// logged-in users away from it (F9). Runs on both SSR and client navigations;
// the server data API is gated independently in server/middleware/auth.ts.
export default defineNuxtRouteMiddleware((to) => {
const { loggedIn } = useUserSession()
if (!loggedIn.value && to.path !== '/login') {
return navigateTo('/login')
}
if (loggedIn.value && to.path === '/login') {
return navigateTo('/')
}
})

View File

@@ -29,9 +29,10 @@ const openSection = ref<string | null>(null)
<template>
<main class="mx-auto flex max-w-5xl flex-col items-center gap-6 p-6">
<header class="text-center">
<header class="flex flex-col items-center gap-2 text-center">
<h1>Andon weak points</h1>
<p class="opacity-70">Every defect filed on the board.</p>
<EnableNotifications />
</header>
<DotMap :defects="defects" :counts="counts" @select="openSection = $event" />

32
app/pages/login.vue Normal file
View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
// Login gate (F9). Standalone (no app header — its nav points at gated routes).
// The button is a real anchor so it hits the server OAuth route directly.
definePageMeta({ layout: false })
const route = useRoute()
const error = computed(() => {
if (route.query.error === 'domain') return 'Sign in with your @theodo.com Google account.'
if (route.query.error === 'oauth') return 'Sign-in failed — please try again.'
return null
})
</script>
<template>
<main class="flex min-h-screen items-center justify-center p-6">
<div class="card bg-base-100 border-base-300 w-full max-w-sm border shadow-sm">
<div class="card-body items-center text-center gap-4">
<h1 class="flex items-center gap-2 text-2xl font-semibold">
<span class="size-2.5 rounded-full bg-[#e11d48]" aria-hidden="true" />
Project Board Andon
</h1>
<p class="opacity-70">Sign in to report and view board defects.</p>
<p v-if="error" role="alert" class="alert alert-error text-sm">{{ error }}</p>
<a href="/auth/google/callback" class="btn btn-primary w-full">
Sign in with Google
</a>
</div>
</div>
</main>
</template>

View File

@@ -13,10 +13,10 @@ services:
- "3000:3000"
environment:
DATABASE_URL: ${DATABASE_URL:-postgres://andon:andon@db:5432/andon}
NUXT_SESSION_SECRET: ${NUXT_SESSION_SECRET:-change-me}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-}
NUXT_SESSION_PASSWORD: ${NUXT_SESSION_PASSWORD:-}
NUXT_OAUTH_GOOGLE_CLIENT_ID: ${NUXT_OAUTH_GOOGLE_CLIENT_ID:-}
NUXT_OAUTH_GOOGLE_CLIENT_SECRET: ${NUXT_OAUTH_GOOGLE_CLIENT_SECRET:-}
NUXT_OAUTH_GOOGLE_REDIRECT_URL: ${NUXT_OAUTH_GOOGLE_REDIRECT_URL:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:owner@theodo.com}

View File

@@ -4,8 +4,20 @@ import tailwindcss from '@tailwindcss/vite'
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['@nuxt/eslint', '@nuxt/test-utils/module'],
modules: ['@nuxt/eslint', '@nuxt/test-utils/module', 'nuxt-auth-utils'],
css: ['~/assets/css/main.css'],
runtimeConfig: {
// VAPID private key + subject for signing Web Push (server-only, T11).
vapidPrivateKey: '', // NUXT_VAPID_PRIVATE_KEY
vapidSubject: '', // NUXT_VAPID_SUBJECT
public: {
// VAPID public key the browser subscribes with (T10).
vapidPublicKey: '', // NUXT_PUBLIC_VAPID_PUBLIC_KEY
// The single owner allowed to enrol for push (G3). Not secret; gates both
// the server enrol endpoint and the client-side "Enable" button.
ownerEmail: '', // NUXT_PUBLIC_OWNER_EMAIL
},
},
// Tailwind v4 integrates as a Vite plugin (no @nuxtjs/tailwindcss module).
vite: {
plugins: [tailwindcss()],

View File

@@ -17,6 +17,7 @@
"db:migrate": "tsx server/db/migrate.ts",
"db:seed": "tsx server/db/seed.ts",
"db:verify": "tsx scripts/verify-db.ts",
"vapid:keys": "tsx scripts/generate-vapid.ts",
"docker:dev": "docker compose -f docker-compose.dev.yml up",
"docker:dev:build": "docker compose -f docker-compose.dev.yml up --build",
"docker:dev:down": "docker compose -f docker-compose.dev.yml down"
@@ -25,15 +26,18 @@
"arktype": "^2.2.0",
"drizzle-orm": "^0.45.2",
"nuxt": "^4.4.6",
"nuxt-auth-utils": "^0.5.29",
"pg": "^8.21.0",
"vue": "^3.5.34",
"vue-router": "^5.0.7"
"vue-router": "^5.0.7",
"web-push": "^3.6.7"
},
"devDependencies": {
"@nuxt/eslint": "^1.15.2",
"@nuxt/test-utils": "^4.0.3",
"@tailwindcss/vite": "^4.3.0",
"@types/pg": "^8.20.0",
"@types/web-push": "^3.6.4",
"@vue/test-utils": "^2.4.10",
"daisyui": "^5.5.20",
"drizzle-kit": "^0.31.10",

235
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
nuxt:
specifier: ^4.4.6
version: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.21.0)))(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.21.0))(eslint@10.4.0(jiti@2.7.0))(ioredis@5.11.0)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0)
nuxt-auth-utils:
specifier: ^0.5.29
version: 0.5.29(magicast@0.5.3)
pg:
specifier: ^8.21.0
version: 8.21.0
@@ -26,6 +29,9 @@ importers:
vue-router:
specifier: ^5.0.7
version: 5.0.7(@vue/compiler-sfc@3.5.34)(vue@3.5.34(typescript@6.0.3))
web-push:
specifier: ^3.6.7
version: 3.6.7
devDependencies:
'@nuxt/eslint':
specifier: ^1.15.2
@@ -39,6 +45,9 @@ importers:
'@types/pg':
specifier: ^8.20.0
version: 8.20.0
'@types/web-push':
specifier: ^3.6.4
version: 3.6.4
'@vue/test-utils':
specifier: ^2.4.10
version: 2.4.10(@vue/compiler-dom@3.5.34)(@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
@@ -69,6 +78,18 @@ importers:
packages:
'@adonisjs/hash@9.1.1':
resolution: {integrity: sha512-ZkRguwjAp4skKvKDdRAfdJ2oqQ0N7p9l3sioyXO1E8o0WcsyDgEpsTQtuVNoIdMiw4sn4gJlmL3nyF4BcK1ZDQ==}
engines: {node: '>=20.6.0'}
peerDependencies:
argon2: ^0.31.2 || ^0.41.0 || ^0.43.0
bcrypt: ^5.1.1 || ^6.0.0
peerDependenciesMeta:
argon2:
optional: true
bcrypt:
optional: true
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
@@ -1667,6 +1688,10 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -1683,6 +1708,17 @@ packages:
'@poppinss/exception@1.2.3':
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
'@poppinss/object-builder@1.1.0':
resolution: {integrity: sha512-FOrOq52l7u8goR5yncX14+k+Ewi5djnrt1JwXeS/FvnwAPOiveFhiczCDuvXdssAwamtrV2hp5Rw9v+n2T7hQg==}
engines: {node: '>=20.6.0'}
'@poppinss/string@1.7.2':
resolution: {integrity: sha512-A182GLDfi36iDCbhDrHB0xzrPM1fO3GHnhCDIdadf8C6eycgct4m7zusbLwEh6GPaj2Pz5BVos7XK16w7tZ7wQ==}
'@poppinss/utils@6.10.1':
resolution: {integrity: sha512-da+MMyeXhBaKtxQiWPfy7+056wk3lVIhioJnXHXkJ2/OHDaZfFcyKHNl1R06sdYO8lIRXcXdoZ6LO2ARmkAREA==}
engines: {node: '>=18.16.0'}
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
@@ -2050,9 +2086,15 @@ packages:
'@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
'@types/pluralize@0.0.33':
resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==}
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@types/web-push@3.6.4':
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
@@ -2448,6 +2490,9 @@ packages:
arktype@2.2.0:
resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==}
asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -2546,6 +2591,9 @@ packages:
birpc@4.0.0:
resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==}
bn.js@4.12.3:
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
@@ -2569,6 +2617,9 @@ packages:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -2611,6 +2662,10 @@ packages:
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
case-anything@3.1.2:
resolution: {integrity: sha512-wljhAjDDIv/hM2FzgJnYQg90AWmZMNtESCjTeLH680qTzdo0nErlCxOmgzgX4ZsZAtIvqHyD87ES8QyriXB+BQ==}
engines: {node: '>=18'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
@@ -2994,6 +3049,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
editorconfig@1.0.7:
resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
engines: {node: '>=14'}
@@ -3332,6 +3390,10 @@ packages:
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
flattie@1.1.1:
resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
engines: {node: '>=8'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
@@ -3462,6 +3524,10 @@ packages:
resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
http_ece@1.2.0:
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
engines: {node: '>=16'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
@@ -3596,6 +3662,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
js-beautify@1.15.4:
resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==}
engines: {node: '>=14'}
@@ -3641,6 +3710,12 @@ packages:
engines: {node: '>=6'}
hasBin: true
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -3830,6 +3905,9 @@ packages:
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
engines: {node: '>=12'}
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@@ -3842,6 +3920,9 @@ packages:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -3947,6 +4028,23 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
nuxt-auth-utils@0.5.29:
resolution: {integrity: sha512-aQ9oD8QR51jUCe2BFEsO/G/E0K+XUy8Skjn3hYcNLiqZ9XE4Y3uT/ozBCmAQ+TR3WW6prj8vUR0wQes8W1N0PA==}
peerDependencies:
'@atproto/api': ^0.13.15
'@atproto/oauth-client-node': ^0.2.0
'@simplewebauthn/browser': ^11.0.0
'@simplewebauthn/server': ^11.0.0
peerDependenciesMeta:
'@atproto/api':
optional: true
'@atproto/oauth-client-node':
optional: true
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nuxt@4.4.6:
resolution: {integrity: sha512-QAApJpAx3yGf3pYudALkInuBfv0WkHCiol6ntTvh/lwKwYrcU/MRI1nLNGt0QNyUCgBWdOQukd3z67VJ2xGd0Q==}
engines: {node: ^22.12.0 || ^24.11.0 || >=26.0.0}
@@ -3965,6 +4063,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
oauth4webapi@3.8.6:
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
object-deep-merge@2.0.1:
resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==}
@@ -4000,6 +4101,9 @@ packages:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
openid-client@6.8.4:
resolution: {integrity: sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -4480,6 +4584,13 @@ packages:
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
@@ -4491,6 +4602,9 @@ packages:
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
secure-json-parse@4.1.0:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -4558,6 +4672,10 @@ packages:
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
engines: {node: '>=14.16'}
slugify@1.6.9:
resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==}
engines: {node: '>=8.0.0'}
smob@1.6.2:
resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==}
engines: {node: '>=20.0.0'}
@@ -5118,6 +5236,11 @@ packages:
typescript:
optional: true
web-push@3.6.7:
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
engines: {node: '>= 16'}
hasBin: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -5234,6 +5357,11 @@ packages:
snapshots:
'@adonisjs/hash@9.1.1':
dependencies:
'@phc/format': 1.0.0
'@poppinss/utils': 6.10.1
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.6.0
@@ -6654,6 +6782,8 @@ snapshots:
'@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6
'@phc/format@1.0.0': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -6671,6 +6801,24 @@ snapshots:
'@poppinss/exception@1.2.3': {}
'@poppinss/object-builder@1.1.0': {}
'@poppinss/string@1.7.2':
dependencies:
'@types/pluralize': 0.0.33
case-anything: 3.1.2
pluralize: 8.0.0
slugify: 1.6.9
'@poppinss/utils@6.10.1':
dependencies:
'@poppinss/exception': 1.2.3
'@poppinss/object-builder': 1.1.0
'@poppinss/string': 1.7.2
flattie: 1.1.1
safe-stable-stringify: 2.5.0
secure-json-parse: 4.1.0
'@rolldown/pluginutils@1.0.1': {}
'@rollup/plugin-alias@6.0.0(rollup@4.60.4)':
@@ -6937,8 +7085,14 @@ snapshots:
pg-protocol: 1.14.0
pg-types: 2.2.0
'@types/pluralize@0.0.33': {}
'@types/resolve@1.20.2': {}
'@types/web-push@3.6.4':
dependencies:
'@types/node': 25.9.1
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1':
@@ -7393,6 +7547,13 @@ snapshots:
'@ark/util': 0.56.0
arkregex: 0.0.5
asn1.js@5.4.1:
dependencies:
bn.js: 4.12.3
inherits: 2.0.4
minimalistic-assert: 1.0.1
safer-buffer: 2.1.2
assertion-error@2.0.1: {}
ast-kit@2.2.0:
@@ -7468,6 +7629,8 @@ snapshots:
birpc@4.0.0: {}
bn.js@4.12.3: {}
boolbase@1.0.0: {}
brace-expansion@2.1.1:
@@ -7492,6 +7655,8 @@ snapshots:
buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
buffer@6.0.3:
@@ -7540,6 +7705,8 @@ snapshots:
caniuse-lite@1.0.30001793: {}
case-anything@3.1.2: {}
chai@6.2.2: {}
change-case@5.4.4: {}
@@ -7798,6 +7965,10 @@ snapshots:
eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
editorconfig@1.0.7:
dependencies:
'@one-ini/wasm': 0.1.1
@@ -8260,6 +8431,8 @@ snapshots:
flatted@3.4.2: {}
flattie@1.1.1: {}
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
@@ -8391,6 +8564,8 @@ snapshots:
http-shutdown@1.2.2: {}
http_ece@1.2.0: {}
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@@ -8503,6 +8678,8 @@ snapshots:
jiti@2.7.0: {}
jose@6.2.3: {}
js-beautify@1.15.4:
dependencies:
config-chain: 1.1.13
@@ -8538,6 +8715,17 @@ snapshots:
json5@2.2.3: {}
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -8710,6 +8898,8 @@ snapshots:
mimic-fn@4.0.0: {}
minimalistic-assert@1.0.1: {}
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.6
@@ -8722,6 +8912,8 @@ snapshots:
dependencies:
brace-expansion: 2.1.1
minimist@1.2.8: {}
minipass@7.1.3: {}
minizlib@3.1.0:
@@ -8895,6 +9087,24 @@ snapshots:
dependencies:
boolbase: 1.0.0
nuxt-auth-utils@0.5.29(magicast@0.5.3):
dependencies:
'@adonisjs/hash': 9.1.1
'@nuxt/kit': 4.4.6(magicast@0.5.3)
defu: 6.1.7
h3: 1.15.11
hookable: 6.1.1
jose: 6.2.3
ofetch: 1.5.1
openid-client: 6.8.4
pathe: 2.0.3
scule: 1.3.0
uncrypto: 0.1.3
transitivePeerDependencies:
- argon2
- bcrypt
- magicast
nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.21.0)))(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.21.0))(eslint@10.4.0(jiti@2.7.0))(ioredis@5.11.0)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0):
dependencies:
'@dxup/nuxt': 0.4.1(magicast@0.5.3)(typescript@6.0.3)
@@ -9031,6 +9241,8 @@ snapshots:
pathe: 2.0.3
tinyexec: 1.2.2
oauth4webapi@3.8.6: {}
object-deep-merge@2.0.1: {}
obug@2.1.1: {}
@@ -9071,6 +9283,11 @@ snapshots:
powershell-utils: 0.1.0
wsl-utils: 0.3.1
openid-client@6.8.4:
dependencies:
jose: 6.2.3
oauth4webapi: 3.8.6
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -9587,6 +9804,10 @@ snapshots:
safe-buffer@5.2.1: {}
safe-stable-stringify@2.5.0: {}
safer-buffer@2.1.2: {}
sax@1.6.0: {}
scslre@0.3.0:
@@ -9597,6 +9818,8 @@ snapshots:
scule@1.3.0: {}
secure-json-parse@4.1.0: {}
semver@6.3.1: {}
semver@7.8.1: {}
@@ -9670,6 +9893,8 @@ snapshots:
slash@5.1.0: {}
slugify@1.6.9: {}
smob@1.6.2: {}
source-map-js@1.2.1: {}
@@ -10236,6 +10461,16 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
web-push@3.6.7:
dependencies:
asn1.js: 5.4.1
http_ece: 1.2.0
https-proxy-agent: 7.0.6
jws: 4.0.1
minimist: 1.2.8
transitivePeerDependencies:
- supports-color
webidl-conversions@3.0.1: {}
webpack-virtual-modules@0.6.2: {}

33
public/sw.js Normal file
View File

@@ -0,0 +1,33 @@
// Web Push service worker (T10). Registered from EnableNotifications.vue and
// served at the site root so it controls the whole origin. Its job is to
// receive pushes the server sends (T11) and surface them as OS notifications.
self.addEventListener('push', (event) => {
let payload = {}
try {
payload = event.data ? event.data.json() : {}
} catch {
payload = {}
}
const title = payload.title || 'New defect filed'
event.waitUntil(
self.registration.showNotification(title, {
body: payload.body || '',
tag: payload.tag,
data: { url: payload.url || '/defects' },
}),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const target = (event.notification.data && event.notification.data.url) || '/defects'
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if (client.url.includes(target) && 'focus' in client) return client.focus()
}
return self.clients.openWindow(target)
}),
)
})

10
scripts/generate-vapid.ts Normal file
View File

@@ -0,0 +1,10 @@
// One-off helper to mint a VAPID key pair for Web Push (T10).
// Run with `pnpm vapid:keys`, then paste the values into your .env:
// NUXT_PUBLIC_VAPID_PUBLIC_KEY (sent to the browser)
// NUXT_VAPID_PRIVATE_KEY (server secret — never expose)
import webpush from 'web-push'
const { publicKey, privateKey } = webpush.generateVAPIDKeys()
console.log('NUXT_PUBLIC_VAPID_PUBLIC_KEY=' + publicKey)
console.log('NUXT_VAPID_PRIVATE_KEY=' + privateKey)

View File

@@ -3,6 +3,7 @@ import { getDb } from '../db/client'
import { createDefect } from '../db/repositories/defects'
import { DefectInput } from '../utils/defectInput'
import { getReporter } from '../utils/getReporter'
import { sendPushOnDefectFiled } from '../utils/sendPush'
// File a defect against a board section (F2). Reporter comes from the seam,
// not the client; the timestamp defaults in the DB.
@@ -11,5 +12,24 @@ export default defineEventHandler(async (event) => {
if (input instanceof type.errors) {
throw createError({ statusCode: 400, statusMessage: input.summary })
}
return createDefect(getDb(), { ...input, reporterEmail: getReporter(event) })
const db = getDb()
const defect = await createDefect(db, { ...input, reporterEmail: await getReporter(event) })
// Notify subscribed devices (F7) — fire-and-forget so filing stays fast.
// `waitUntil` keeps the runtime alive for the send without delaying the response.
const config = useRuntimeConfig(event)
event.waitUntil(
sendPushOnDefectFiled(
db,
{
subject: config.vapidSubject,
publicKey: config.public.vapidPublicKey,
privateKey: config.vapidPrivateKey,
},
{ sectionId: defect.sectionId, projectId: defect.projectId, verbatim: defect.verbatim },
),
)
return defect
})

View File

@@ -0,0 +1,23 @@
import { getDb } from '../db/client'
import { deleteSubscriptionByEndpoint } from '../db/repositories/pushSubscriptions'
import { getReporter } from '../utils/getReporter'
import { isOwner } from '../utils/isOwner'
// Unenrol a device — when the owner turns notifications off, the client sends
// the endpoint it is dropping. Owner-only, like enrolment.
export default defineEventHandler(async (event) => {
const email = await getReporter(event)
const ownerEmail = useRuntimeConfig(event).public.ownerEmail
if (!isOwner(email, ownerEmail)) {
throw createError({ statusCode: 403, statusMessage: 'Notifications are restricted to the owner' })
}
const body = await readBody<{ endpoint?: unknown }>(event)
const endpoint = typeof body?.endpoint === 'string' ? body.endpoint : ''
if (!endpoint) {
throw createError({ statusCode: 400, statusMessage: 'endpoint is required' })
}
const removed = await deleteSubscriptionByEndpoint(getDb(), endpoint)
return { removed }
})

View File

@@ -0,0 +1,29 @@
import { type } from 'arktype'
import { getDb } from '../db/client'
import { saveSubscription } from '../db/repositories/pushSubscriptions'
import { PushSubscriptionInput } from '../utils/pushSubscriptionInput'
import { getReporter } from '../utils/getReporter'
import { isOwner } from '../utils/isOwner'
// Enrol a device for Web Push (F8). Owner-only (G3): the session email must
// match the configured owner. The auth middleware already requires a session.
export default defineEventHandler(async (event) => {
const email = await getReporter(event)
const ownerEmail = useRuntimeConfig(event).public.ownerEmail
if (!isOwner(email, ownerEmail)) {
throw createError({ statusCode: 403, statusMessage: 'Notifications are restricted to the owner' })
}
const input = PushSubscriptionInput(await readBody(event))
if (input instanceof type.errors) {
throw createError({ statusCode: 400, statusMessage: input.summary })
}
await saveSubscription(getDb(), {
endpoint: input.endpoint,
p256dh: input.keys.p256dh,
auth: input.keys.auth,
reporterEmail: email,
})
return { ok: true }
})

View File

@@ -15,7 +15,7 @@ export interface NewDefect {
/** Persist a filed defect and return the stored row. */
export async function createDefect(db: Db, input: NewDefect) {
const [row] = await db.insert(defects).values(input).returning()
return row
return row!
}
/** Recent defects, newest first — the dashboard feed (F5/F6). */

View File

@@ -1,4 +1,4 @@
import { asc, sql } from 'drizzle-orm'
import { asc, eq, sql } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import type * as schema from '../schema'
import { projects } from '../schema'
@@ -34,6 +34,16 @@ export async function createProject(db: Db, rawName: string): Promise<Project> {
}
}
/** A project's name by id — used to label the push notification on file (T11). */
export async function getProjectName(db: Db, id: string): Promise<string | undefined> {
const [row] = await db
.select({ name: projects.name })
.from(projects)
.where(eq(projects.id, id))
.limit(1)
return row?.name
}
async function findByName(db: Db, name: string): Promise<Project | undefined> {
const [row] = await db
.select()

View File

@@ -0,0 +1,48 @@
import { eq } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import type * as schema from '../schema'
import { pushSubscriptions } from '../schema'
type Db = NodePgDatabase<typeof schema>
export interface NewPushSubscription {
endpoint: string
p256dh: string
auth: string
reporterEmail: string
}
/**
* Store (or refresh) a device's push subscription (F8). Keyed by `endpoint`:
* re-subscribing the same device rotates its keys in place, while a different
* device is kept as a separate row so the owner can enrol several phones.
*/
export async function saveSubscription(db: Db, sub: NewPushSubscription) {
const [row] = await db
.insert(pushSubscriptions)
.values(sub)
.onConflictDoUpdate({
target: pushSubscriptions.endpoint,
set: { p256dh: sub.p256dh, auth: sub.auth, reporterEmail: sub.reporterEmail },
})
.returning()
return row!
}
/** All stored subscriptions — the fan-out target when a defect is filed (T11). */
export async function listSubscriptions(db: Db) {
return db.select().from(pushSubscriptions)
}
/**
* Remove a subscription by endpoint and report how many rows went. Called when
* the push service rejects an endpoint with HTTP 410 (Gone), pruning the stale
* device so we stop sending to it.
*/
export async function deleteSubscriptionByEndpoint(db: Db, endpoint: string): Promise<number> {
const deleted = await db
.delete(pushSubscriptions)
.where(eq(pushSubscriptions.endpoint, endpoint))
.returning({ id: pushSubscriptions.id })
return deleted.length
}

15
server/middleware/auth.ts Normal file
View File

@@ -0,0 +1,15 @@
import { isPublicApiPath } from '../utils/authPaths'
// Default-deny gate for the data API (F9). Every `/api` route requires an
// authenticated session except the explicitly public ones (health probe, the
// session endpoint). Pages are guarded separately by app/middleware/auth.global
// so static assets and the OAuth route stay reachable while unauthenticated.
export default defineEventHandler(async (event) => {
if (!event.path.startsWith('/api/')) return
if (isPublicApiPath(event.path)) return
const { user } = await getUserSession(event)
if (!user) {
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
}
})

View File

@@ -0,0 +1,25 @@
import { isAllowedGoogleUser, type GoogleUserInfo } from '../../../utils/allowedGoogleUser'
// Google OAuth entry + callback (F9). nuxt-auth-utils uses one handler for
// both legs: with no `?code` it redirects to Google; on return it exchanges the
// code and hands us the verified userinfo. We then re-check the email domain
// server-side (the `hd` claim is not trusted) before minting a session.
export default defineOAuthGoogleEventHandler({
config: {},
async onSuccess(event, { user }) {
const info = user as GoogleUserInfo
if (!isAllowedGoogleUser(info)) {
await clearUserSession(event)
return sendRedirect(event, '/login?error=domain')
}
await setUserSession(event, {
// isAllowedGoogleUser guarantees a present email; narrow for the compiler.
user: { email: info.email!, name: user.name, picture: user.picture },
})
return sendRedirect(event, '/')
},
onError(event, error) {
console.error('Google OAuth error:', error)
return sendRedirect(event, '/login?error=oauth')
},
})

View File

@@ -0,0 +1,21 @@
// Authorisation gate for Google identities (F9, DESIGN T9).
//
// Access is restricted to the company's verified email domain. We deliberately
// ignore Google's `hd` (hosted-domain) claim as the source of truth: a personal
// account can be made to present an `hd`, so we re-derive the domain from the
// email Google itself reports as verified.
const ALLOWED_EMAIL_DOMAIN = 'theodo.com'
export interface GoogleUserInfo {
email?: string
email_verified?: boolean | string
// `hd` is intentionally not consulted — see the module comment.
hd?: string
}
export function isAllowedGoogleUser(user: GoogleUserInfo): boolean {
const verified = user.email_verified === true || user.email_verified === 'true'
if (!verified || !user.email) return false
const domain = user.email.split('@')[1]?.toLowerCase()
return domain === ALLOWED_EMAIL_DOMAIN
}

12
server/utils/authPaths.ts Normal file
View File

@@ -0,0 +1,12 @@
// Which `/api` routes bypass the default-deny auth middleware (F9).
//
// `/api/health` must answer for the orchestrator's liveness probe before any
// session exists; `/api/_auth/*` is nuxt-auth-utils' own session endpoint that
// the client uses to read/clear the session. Everything else under `/api`
// requires an authenticated session.
const PUBLIC_API_PREFIXES = ['/api/health', '/api/_auth/']
export function isPublicApiPath(path: string): boolean {
const pathname = path.split('?')[0]!
return PUBLIC_API_PREFIXES.some((prefix) => pathname.startsWith(prefix))
}

View File

@@ -2,9 +2,14 @@ import type { H3Event } from 'h3'
/**
* Resolves the reporter's email — the one seam between filing and auth (ADR
* 0002). For now it returns a fixed dev identity so the filing slice works
* before OAuth exists; Task 9 swaps this to read the verified session email.
* 0002). Backed by the authenticated Google session (Task 9) so defects record
* the true reporter. The auth middleware already gates the filing endpoint, so
* a missing session here is an unexpected 401, not a normal path.
*/
export function getReporter(_event: H3Event): string {
return process.env.DEV_REPORTER_EMAIL ?? 'dev@theodo.com'
export async function getReporter(event: H3Event): Promise<string> {
const { user } = await getUserSession(event)
if (!user?.email) {
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
}
return user.email
}

7
server/utils/isOwner.ts Normal file
View File

@@ -0,0 +1,7 @@
// Owner-only gate for Web Push (T10). v1 notifies a single configured owner
// (DESIGN G3); the owner email comes from runtime config server-side. If no
// owner is configured, nobody qualifies — push enrolment stays closed.
export function isOwner(email: string | undefined, ownerEmail: string | undefined): boolean {
if (!email || !ownerEmail) return false
return email.toLowerCase() === ownerEmail.toLowerCase()
}

View File

@@ -0,0 +1,16 @@
import { type } from 'arktype'
// Validated push-subscription body — the browser's PushSubscription.toJSON()
// shape (T10/F8). Never trusted from the client beyond these constraints; the
// reporter is resolved server-side from the session, not sent here.
const nonEmptyKey = type('string').narrow((s, ctx) => s.length > 0 || ctx.reject('a non-empty key'))
export const PushSubscriptionInput = type({
endpoint: 'string.url',
keys: {
p256dh: nonEmptyKey,
auth: nonEmptyKey,
},
})
export type PushSubscriptionInputData = typeof PushSubscriptionInput.infer

87
server/utils/sendPush.ts Normal file
View File

@@ -0,0 +1,87 @@
import webpush from 'web-push'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import type * as schema from '../db/schema'
import {
listSubscriptions,
deleteSubscriptionByEndpoint,
} from '../db/repositories/pushSubscriptions'
import { getProjectName } from '../db/repositories/projects'
import { sections } from '../../app/board/definition'
type Db = NodePgDatabase<typeof schema>
const sectionLabels = new Map(sections.map((s) => [s.id, s.label]))
const SNIPPET_LIMIT = 140
export interface VapidConfig {
subject: string
publicKey: string
privateKey: string
}
export interface FiledDefect {
sectionId: string
projectId: string
verbatim: string
}
/** The notification body the service worker (T10) expects: `{ title, body, tag, url }`. */
export function buildPayload(input: { sectionLabel: string; projectName: string; verbatim: string }) {
const snippet =
input.verbatim.length > SNIPPET_LIMIT
? input.verbatim.slice(0, SNIPPET_LIMIT - 1).trimEnd() + '…'
: input.verbatim
return {
title: `New defect — ${input.sectionLabel}`,
body: `${input.projectName}: ${snippet}`,
tag: 'defect-filed',
url: '/defects',
}
}
/**
* Fan a Web Push out to every enrolled device after a defect is filed (F7).
* Called fire-and-forget from the file-defect handler (via `event.waitUntil`)
* so filing latency is unaffected. Never throws: a stale endpoint (404/410) is
* pruned, any other failure is logged and the remaining devices still get sent.
*/
export async function sendPushOnDefectFiled(
db: Db,
vapid: VapidConfig,
defect: FiledDefect,
): Promise<void> {
// Without keys there is nothing to sign with (dev / preview); stay silent.
if (!vapid.publicKey || !vapid.privateKey) return
const subscriptions = await listSubscriptions(db)
if (subscriptions.length === 0) return
const projectName = (await getProjectName(db, defect.projectId)) ?? 'Unknown project'
const sectionLabel = sectionLabels.get(defect.sectionId) ?? defect.sectionId
const payload = JSON.stringify(buildPayload({ sectionLabel, projectName, verbatim: defect.verbatim }))
webpush.setVapidDetails(vapid.subject, vapid.publicKey, vapid.privateKey)
await Promise.all(
subscriptions.map((sub) =>
webpush
.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
payload,
)
.catch(async (err: unknown) => {
if (
err instanceof webpush.WebPushError &&
(err.statusCode === 404 || err.statusCode === 410)
) {
// The device is gone — drop it so we stop sending (the 410 path).
await deleteSubscriptionByEndpoint(db, sub.endpoint)
} else {
// Transient or unexpected: keep the device, log, and move on (no retry queue in v1).
console.error('[push] send failed for', sub.endpoint, err)
}
}),
),
)
}

12
shared/auth.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
// Shape of the authenticated session for nuxt-auth-utils (Task 9, F9).
// Lives in shared/ so both the app and server TS projects pick up the
// #auth-utils augmentation.
declare module '#auth-utils' {
interface User {
email: string
name?: string
picture?: string
}
}
export {}

View File

@@ -19,15 +19,15 @@ review.
- [x] **T6** Defects read API: feed + per-section counts (C7) — F6 (M) — _deps: T3_
- [x] **T7** Dashboard DotMap (C5) — F4 (M) — _deps: T2, T6_
- [x] **T8** VerbatimModal + Feed (C6) — F5 (M) — _deps: T6, T7_
- [ ]**Checkpoint:** dashboard dot map + feed + modal work, ≤350ms @2k, human review
- [x]**Checkpoint:** dashboard dot map + feed + modal work, ≤350ms @2k, human review
## Phase 4 — Authentication (G4)
- [ ] **T9** Google OAuth + session + domain-check middleware (C9) — F9 (M) — _deps: T1, retrofits T5_
- [x] **T9** Google OAuth + session + domain-check middleware (C9) — F9 (M) — _deps: T1, retrofits T5_
- [ ]**Checkpoint:** both views gated to @theodo.com, reporters real, human review
## Phase 5 — Notifications (G3)
- [ ] **T10** Web Push subscribe + service worker (C10) — F8 (M) — _deps: T3, T9_
- [ ] **T11** Send push on defect filed (fire-and-forget) — F7 (S) — _deps: T5, T10_
- [x] **T10** Web Push subscribe + service worker (C10) — F8 (M) — _deps: T3, T9_
- [x] **T11** Send push on defect filed (fire-and-forget) — F7 (S) — _deps: T5, T10_
- [ ]**Checkpoint:** file → push arrives ≤10s, filing latency unaffected, human review
## Phase 6 — Ship (F10)
@@ -35,7 +35,7 @@ review.
- [ ]**Checkpoint:** data survives redeploy, both subdomains live, ready for review
## Blocked on open questions (see plan.md)
- [ ] Google OAuth client provisioning (blocks T9)
- [x] Google OAuth client provisioning (blocks T9) — client provided; redirect URI `…/auth/google/callback`
- [ ] Confirm pnpm + Vitest + Playwright vs Theodo template (affects T1)
- [ ] Coolify two-domain routing model (affects T12)
- [ ] "Owner only" vs any subscribed user for push (affects T10/T11)
- [x] "Owner only" vs any subscribed user for push (affects T10/T11) — owner-only, gated by `NUXT_PUBLIC_OWNER_EMAIL`

52
tests/auth.spec.ts Normal file
View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import { isAllowedGoogleUser } from '../server/utils/allowedGoogleUser'
import { isPublicApiPath } from '../server/utils/authPaths'
// F9: access is restricted to verified @theodo.com identities. The Google
// `hd` (hosted-domain) claim is NOT trusted on its own — we re-derive the
// domain from the verified email server-side (DESIGN T9).
describe('isAllowedGoogleUser', () => {
it('allows a verified @theodo.com email', () => {
expect(isAllowedGoogleUser({ email: 'jane@theodo.com', email_verified: true })).toBe(true)
})
it('denies a non-theodo verified email even when hd claims theodo.com', () => {
expect(
isAllowedGoogleUser({ email: 'attacker@gmail.com', email_verified: true, hd: 'theodo.com' }),
).toBe(false)
})
it('denies a theodo email that Google has not verified', () => {
expect(isAllowedGoogleUser({ email: 'jane@theodo.com', email_verified: false })).toBe(false)
})
it('matches the domain case-insensitively', () => {
expect(isAllowedGoogleUser({ email: 'jane@Theodo.COM', email_verified: true })).toBe(true)
})
it('denies when the email is missing', () => {
expect(isAllowedGoogleUser({ email_verified: true, hd: 'theodo.com' })).toBe(false)
})
it('denies a look-alike domain that merely ends in theodo.com', () => {
expect(isAllowedGoogleUser({ email: 'eve@nottheodo.com', email_verified: true })).toBe(false)
})
})
describe('isPublicApiPath', () => {
it('treats the health check as public', () => {
expect(isPublicApiPath('/api/health')).toBe(true)
})
it('treats the session endpoint as public', () => {
expect(isPublicApiPath('/api/_auth/session')).toBe(true)
})
it('gates the defects API', () => {
expect(isPublicApiPath('/api/defects')).toBe(false)
})
it('ignores the query string when matching', () => {
expect(isPublicApiPath('/api/health?probe=1')).toBe(true)
})
})

192
tests/push-send.spec.ts Normal file
View File

@@ -0,0 +1,192 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'
import webpush from 'web-push'
import { setupTestDb, truncateAll, type TestDb } from './helpers/db'
import { createProject } from '../server/db/repositories/projects'
import { saveSubscription, listSubscriptions } from '../server/db/repositories/pushSubscriptions'
import { buildPayload, sendPushOnDefectFiled } from '../server/utils/sendPush'
// web-push is mocked so the send never leaves the process: we assert on the
// payload it would deliver and simulate the push service's 410/404 responses.
const { sendNotification, setVapidDetails } = vi.hoisted(() => ({
sendNotification: vi.fn(),
setVapidDetails: vi.fn(),
}))
vi.mock('web-push', () => {
class WebPushError extends Error {
statusCode: number
constructor(message: string, statusCode: number) {
super(message)
this.statusCode = statusCode
}
}
return { default: { sendNotification, setVapidDetails, WebPushError } }
})
const vapid = { subject: 'mailto:owner@theodo.com', publicKey: 'pub-key', privateKey: 'priv-key' }
describe('buildPayload', () => {
it('renders the section label, project and verbatim into the SW payload shape', () => {
const payload = buildPayload({
sectionLabel: 'Macroplan',
projectName: 'Acme',
verbatim: 'builds are flaky',
})
expect(payload).toMatchObject({
title: expect.stringContaining('Macroplan'),
body: expect.stringContaining('Acme'),
url: '/defects',
})
expect(payload.body).toContain('builds are flaky')
})
it('truncates a long verbatim to a snippet', () => {
const payload = buildPayload({
sectionLabel: 'Macroplan',
projectName: 'Acme',
verbatim: 'x'.repeat(300),
})
expect(payload.body.length).toBeLessThan(200)
expect(payload.body).toContain('…')
})
})
describe('sendPushOnDefectFiled', () => {
let db: TestDb
let pool: { end: () => Promise<void> }
beforeAll(async () => {
const setup = await setupTestDb()
db = setup.db
pool = setup.pool
})
afterAll(async () => {
await pool.end()
})
beforeEach(async () => {
await truncateAll(db)
sendNotification.mockReset().mockResolvedValue(undefined)
setVapidDetails.mockReset()
})
async function seedTwoDevices() {
await saveSubscription(db, {
endpoint: 'https://push.example/device-a',
p256dh: 'p256dh-a',
auth: 'auth-a',
reporterEmail: 'owner@theodo.com',
})
await saveSubscription(db, {
endpoint: 'https://push.example/device-b',
p256dh: 'p256dh-b',
auth: 'auth-b',
reporterEmail: 'owner@theodo.com',
})
}
it('sets VAPID details and pushes the defect to every subscribed device', async () => {
const project = await createProject(db, 'Acme')
await seedTwoDevices()
await sendPushOnDefectFiled(db, vapid, {
sectionId: 'macroplan',
projectId: project.id,
verbatim: 'login is flaky',
})
expect(setVapidDetails).toHaveBeenCalledWith(vapid.subject, vapid.publicKey, vapid.privateKey)
expect(sendNotification).toHaveBeenCalledTimes(2)
const [sub, payload] = sendNotification.mock.calls[0]!
expect(sub).toMatchObject({ endpoint: expect.stringContaining('device-'), keys: expect.any(Object) })
const decoded = JSON.parse(payload as string)
expect(decoded.title).toContain('Macroplan')
expect(decoded.body).toContain('Acme')
expect(decoded.body).toContain('login is flaky')
})
it('prunes a subscription when the push service returns 410 Gone', async () => {
const project = await createProject(db, 'Acme')
await seedTwoDevices()
sendNotification.mockImplementation((sub: webpush.PushSubscription) => {
if (sub.endpoint.endsWith('device-a')) {
return Promise.reject(new webpush.WebPushError('gone', 410))
}
return Promise.resolve(undefined)
})
await sendPushOnDefectFiled(db, vapid, {
sectionId: 'macroplan',
projectId: project.id,
verbatim: 'x',
})
const remaining = (await listSubscriptions(db)).map((s) => s.endpoint)
expect(remaining).toEqual(['https://push.example/device-b'])
})
it('prunes a subscription when the push service returns 404 Not Found', async () => {
const project = await createProject(db, 'Acme')
await seedTwoDevices()
sendNotification.mockImplementation((sub: webpush.PushSubscription) =>
sub.endpoint.endsWith('device-a')
? Promise.reject(new webpush.WebPushError('not found', 404))
: Promise.resolve(undefined),
)
await sendPushOnDefectFiled(db, vapid, {
sectionId: 'macroplan',
projectId: project.id,
verbatim: 'x',
})
const remaining = (await listSubscriptions(db)).map((s) => s.endpoint)
expect(remaining).toEqual(['https://push.example/device-b'])
})
it('keeps the subscription and does not throw on a transient send error', async () => {
const project = await createProject(db, 'Acme')
await seedTwoDevices()
sendNotification.mockImplementation((sub: webpush.PushSubscription) =>
sub.endpoint.endsWith('device-a')
? Promise.reject(new webpush.WebPushError('server error', 500))
: Promise.resolve(undefined),
)
await expect(
sendPushOnDefectFiled(db, vapid, {
sectionId: 'macroplan',
projectId: project.id,
verbatim: 'x',
}),
).resolves.toBeUndefined()
// A 500 is transient — both devices are kept for the next defect.
expect((await listSubscriptions(db)).length).toBe(2)
})
it('does nothing when there are no subscriptions', async () => {
const project = await createProject(db, 'Acme')
await sendPushOnDefectFiled(db, vapid, {
sectionId: 'macroplan',
projectId: project.id,
verbatim: 'x',
})
expect(sendNotification).not.toHaveBeenCalled()
})
it('is a no-op when VAPID keys are not configured', async () => {
const project = await createProject(db, 'Acme')
await seedTwoDevices()
await sendPushOnDefectFiled(
db,
{ subject: 'mailto:owner@theodo.com', publicKey: '', privateKey: '' },
{ sectionId: 'macroplan', projectId: project.id, verbatim: 'x' },
)
expect(setVapidDetails).not.toHaveBeenCalled()
expect(sendNotification).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'
import { setupTestDb, truncateAll, type TestDb } from './helpers/db'
import {
saveSubscription,
listSubscriptions,
deleteSubscriptionByEndpoint,
} from '../server/db/repositories/pushSubscriptions'
// T10/F8: the owner enrols one or more devices for Web Push. Subscriptions are
// keyed by their push endpoint so re-subscribing the same device is idempotent,
// distinct devices are kept side by side, and a 410 from the push service
// prunes the stale one (the pruning itself happens when T11 sends).
describe('push subscriptions repository', () => {
let db: TestDb
let pool: { end: () => Promise<void> }
beforeAll(async () => {
const setup = await setupTestDb()
db = setup.db
pool = setup.pool
})
afterAll(async () => {
await pool.end()
})
beforeEach(async () => {
await truncateAll(db)
})
const subA = {
endpoint: 'https://push.example/device-a',
p256dh: 'p256dh-a',
auth: 'auth-a',
reporterEmail: 'owner@theodo.com',
}
const subB = {
endpoint: 'https://push.example/device-b',
p256dh: 'p256dh-b',
auth: 'auth-b',
reporterEmail: 'owner@theodo.com',
}
it('stores a subscription', async () => {
await saveSubscription(db, subA)
const all = await listSubscriptions(db)
expect(all).toHaveLength(1)
expect(all[0]).toMatchObject({
endpoint: subA.endpoint,
p256dh: subA.p256dh,
auth: subA.auth,
reporterEmail: subA.reporterEmail,
})
})
it('keeps a second device as a separate subscription', async () => {
await saveSubscription(db, subA)
await saveSubscription(db, subB)
const endpoints = (await listSubscriptions(db)).map((s) => s.endpoint).sort()
expect(endpoints).toEqual([subA.endpoint, subB.endpoint])
})
it('re-subscribing the same endpoint updates in place rather than duplicating', async () => {
await saveSubscription(db, subA)
await saveSubscription(db, { ...subA, p256dh: 'rotated-key', auth: 'rotated-auth' })
const all = await listSubscriptions(db)
expect(all).toHaveLength(1)
expect(all[0]).toMatchObject({ p256dh: 'rotated-key', auth: 'rotated-auth' })
})
it('prunes a subscription by endpoint (the 410 path)', async () => {
await saveSubscription(db, subA)
await saveSubscription(db, subB)
const removed = await deleteSubscriptionByEndpoint(db, subA.endpoint)
expect(removed).toBe(1)
expect((await listSubscriptions(db)).map((s) => s.endpoint)).toEqual([subB.endpoint])
})
it('pruning an unknown endpoint is a no-op', async () => {
await saveSubscription(db, subA)
const removed = await deleteSubscriptionByEndpoint(db, 'https://push.example/gone')
expect(removed).toBe(0)
expect(await listSubscriptions(db)).toHaveLength(1)
})
})

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import { type } from 'arktype'
import { isOwner } from '../server/utils/isOwner'
import { PushSubscriptionInput } from '../server/utils/pushSubscriptionInput'
// T10: only the configured owner may enrol for push (decided owner-only).
describe('isOwner', () => {
it('recognises the configured owner, case-insensitively', () => {
expect(isOwner('Owner@Theodo.com', 'owner@theodo.com')).toBe(true)
})
it('denies anyone who is not the owner', () => {
expect(isOwner('someone@theodo.com', 'owner@theodo.com')).toBe(false)
})
it('denies everyone when no owner is configured', () => {
expect(isOwner('owner@theodo.com', '')).toBe(false)
expect(isOwner('owner@theodo.com', undefined)).toBe(false)
})
it('denies when the email is missing', () => {
expect(isOwner(undefined, 'owner@theodo.com')).toBe(false)
})
})
// The body is the browser's PushSubscription.toJSON() shape, untrusted.
describe('PushSubscriptionInput', () => {
const valid = {
endpoint: 'https://fcm.googleapis.com/fcm/send/abc123',
keys: { p256dh: 'BNc...key', auth: 'tok' },
}
it('accepts a well-formed browser subscription', () => {
expect(PushSubscriptionInput(valid)).toMatchObject(valid)
})
it('rejects a non-url endpoint', () => {
expect(PushSubscriptionInput({ ...valid, endpoint: 'not-a-url' }) instanceof type.errors).toBe(
true,
)
})
it('rejects a missing keys object', () => {
expect(PushSubscriptionInput({ endpoint: valid.endpoint }) instanceof type.errors).toBe(true)
})
it('rejects an empty key', () => {
expect(
PushSubscriptionInput({ ...valid, keys: { p256dh: '', auth: 'tok' } }) instanceof type.errors,
).toBe(true)
})
})