From ea7384591c30fb917802fc158823e7e7d29800e0 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Thu, 28 May 2026 01:20:20 +0200 Subject: [PATCH] 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. --- .env.example | 14 ++++++++++---- nuxt.config.ts | 12 ++++++++++++ server/api/subscriptions.delete.ts | 23 +++++++++++++++++++++++ server/api/subscriptions.post.ts | 29 +++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 server/api/subscriptions.delete.ts create mode 100644 server/api/subscriptions.post.ts diff --git a/.env.example b/.env.example index 517ac38..1130af8 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,13 @@ 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= diff --git a/nuxt.config.ts b/nuxt.config.ts index edf20ca..710eea2 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -6,6 +6,18 @@ export default defineNuxtConfig({ devtools: { enabled: true }, 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()], diff --git a/server/api/subscriptions.delete.ts b/server/api/subscriptions.delete.ts new file mode 100644 index 0000000..5814110 --- /dev/null +++ b/server/api/subscriptions.delete.ts @@ -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 } +}) diff --git a/server/api/subscriptions.post.ts b/server/api/subscriptions.post.ts new file mode 100644 index 0000000..3a5ba89 --- /dev/null +++ b/server/api/subscriptions.post.ts @@ -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 } +})