From 5016c5cd473413e159ec6d2493d786f9e9fe5e57 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Thu, 28 May 2026 01:19:19 +0200 Subject: [PATCH] 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. --- server/utils/isOwner.ts | 7 ++++ server/utils/pushSubscriptionInput.ts | 16 +++++++++ tests/subscriptions.spec.ts | 52 +++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 server/utils/isOwner.ts create mode 100644 server/utils/pushSubscriptionInput.ts create mode 100644 tests/subscriptions.spec.ts diff --git a/server/utils/isOwner.ts b/server/utils/isOwner.ts new file mode 100644 index 0000000..4bbcd81 --- /dev/null +++ b/server/utils/isOwner.ts @@ -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() +} diff --git a/server/utils/pushSubscriptionInput.ts b/server/utils/pushSubscriptionInput.ts new file mode 100644 index 0000000..d0e32e1 --- /dev/null +++ b/server/utils/pushSubscriptionInput.ts @@ -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 diff --git a/tests/subscriptions.spec.ts b/tests/subscriptions.spec.ts new file mode 100644 index 0000000..3455eba --- /dev/null +++ b/tests/subscriptions.spec.ts @@ -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) + }) +})