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.
This commit is contained in:
7
server/utils/isOwner.ts
Normal file
7
server/utils/isOwner.ts
Normal 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()
|
||||||
|
}
|
||||||
16
server/utils/pushSubscriptionInput.ts
Normal file
16
server/utils/pushSubscriptionInput.ts
Normal 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
|
||||||
52
tests/subscriptions.spec.ts
Normal file
52
tests/subscriptions.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user