diff --git a/src/lib/server/v2-serializers.ts b/src/lib/server/v2-serializers.ts index 2ebaf32..1325e0c 100644 --- a/src/lib/server/v2-serializers.ts +++ b/src/lib/server/v2-serializers.ts @@ -1,3 +1,5 @@ +import { toHm } from '$lib/schedule-utils'; + // Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()` // map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these // canonical ids (1-9), never the portal DB autoincrement ids, because @@ -138,3 +140,33 @@ export function serializeColorPalette(palette: { }): V2ColorPalette { return { solfeggioFrequencyId: palette.solfeggioFrequencyId, colors: palette.colors }; } + +export type V2ScheduleBlock = { + id: string; + startTime: string; + startMinutes: number; + endTime: string; + endMinutes: number; + proclamationIds: string[]; +}; + +export type V2Schedule = { + email: string; + blocks: V2ScheduleBlock[]; +}; + +/** Build a schedule Block for the kiosk. Only the selected Proclamation IDs are + * returned; the kiosk fetches proclamation content separately. */ +export function serializeScheduleBlock( + block: { id: string; startMinutes: number; endMinutes: number }, + proclamationIds: string[] +): V2ScheduleBlock { + return { + id: block.id, + startTime: toHm(block.startMinutes), + startMinutes: block.startMinutes, + endTime: toHm(block.endMinutes), + endMinutes: block.endMinutes, + proclamationIds + }; +} diff --git a/src/routes/api/v2/schedule/+server.ts b/src/routes/api/v2/schedule/+server.ts new file mode 100644 index 0000000..da4d212 --- /dev/null +++ b/src/routes/api/v2/schedule/+server.ts @@ -0,0 +1,63 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { prisma } from '$lib/db'; +import { clientIp, jsonOk, rateLimited } from '$lib/server/v2-helpers'; +import { rateLimit } from '$lib/server/v2-rate-limit'; +import { listBlocks } from '$lib/server/schedule'; +import { serializeScheduleBlock } from '$lib/server/v2-serializers'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates with + * `email` + `licenseKey` query parameters and validates only that the pair + * belong together — license active/entitlement is deliberately not checked + * here, since an invalid license would already lock the kiosk out. + */ +export const GET: RequestHandler = async ({ request, url }) => { + const ip = clientIp(request); + const rl = rateLimit({ key: `schedule:${ip}`, limit: 10, windowMs: 60_000 }); + if (!rl.ok) { + return rateLimited(rl.retryAfterMs); + } + + const email = (url.searchParams.get('email') ?? '').trim().toLowerCase(); + const licenseKey = ( + url.searchParams.get('licenseKey') ?? + url.searchParams.get('key') ?? + '' + ).trim(); + + if (!email || !licenseKey || email.length > 254 || !EMAIL_RE.test(email)) { + throw error(400, 'not-authorized'); + } + + const storedKey = await prisma.licenseKey.findUnique({ where: { key: licenseKey } }); + if (!storedKey) { + throw error(400, 'not-authorized'); + } + + const user = await prisma.user.findUnique({ where: { email } }); + if (!user) { + throw error(400, 'not-authorized'); + } + + // Licenses created before accounts were linked may carry only an email. + const belongs = + storedKey.userId === user.id || + (storedKey.userId === null && storedKey.email?.toLowerCase() === email); + if (!belongs) { + throw error(400, "this key doesn't appear to belong to you"); + } + + const blocks = await listBlocks(user.id); + + const serialized = blocks.map((block) => + serializeScheduleBlock( + block, + block.proclamations.map((entry) => entry.proclamation.id) + ) + ); + + return jsonOk({ email, blocks: serialized }); +}; diff --git a/src/routes/api/v2/schedule/schedule.spec.ts b/src/routes/api/v2/schedule/schedule.spec.ts new file mode 100644 index 0000000..76870cc --- /dev/null +++ b/src/routes/api/v2/schedule/schedule.spec.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { _clearRateLimits } from '$lib/server/v2-rate-limit'; + +vi.mock('$lib/db', () => ({ + prisma: { + schedule: { findUnique: vi.fn(), create: vi.fn() }, + block: { findMany: vi.fn() }, + licenseKey: { findUnique: vi.fn() }, + user: { findUnique: vi.fn() } + } +})); + +import { prisma } from '$lib/db'; +import { GET } from './+server'; + +const licenseKeyFindUnique = prisma.licenseKey.findUnique as ReturnType; +const userFindUnique = prisma.user.findUnique as ReturnType; +const blockFindMany = prisma.block.findMany as ReturnType; + +function call(params: Record) { + const url = new URL('http://localhost/api/v2/schedule'); + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); + const request = new Request(url); + return GET({ request, url } as Parameters[0]); +} + +const KEY = 'AAAA-BBBB-CCCC-DDDD'; + +beforeEach(() => { + vi.clearAllMocks(); + _clearRateLimits(); + blockFindMany.mockResolvedValue([]); +}); + +describe('GET /api/v2/schedule auth', () => { + it('rejects a request missing parameters', async () => { + await expect(call({})).rejects.toMatchObject({ + status: 400, + body: { message: 'not-authorized' } + }); + }); + + it('rejects an unknown license key', async () => { + licenseKeyFindUnique.mockResolvedValue(null); + + await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({ + status: 400, + body: { message: 'not-authorized' } + }); + }); + + it('rejects an unknown user', async () => { + licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null }); + userFindUnique.mockResolvedValue(null); + + await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({ + status: 400, + body: { message: 'not-authorized' } + }); + }); + + it('rejects a key that does not belong to the email account', async () => { + licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'someone-else', email: null }); + userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' }); + + await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({ + status: 400, + body: { message: "this key doesn't appear to belong to you" } + }); + }); + + it('rate limits after ten requests from the same IP', async () => { + licenseKeyFindUnique.mockResolvedValue(null); + for (let i = 0; i < 10; i++) { + try { + await call({}); + } catch { + // Expected: each early call rejects with a 400. + } + } + + const response = await call({}); + + expect(response.status).toBe(429); + }); +}); + +describe('GET /api/v2/schedule success', () => { + it('returns only proclamation IDs even when the key is inactive', async () => { + licenseKeyFindUnique.mockResolvedValue({ + key: KEY, + userId: 'user-1', + email: null, + active: false + }); + userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' }); + blockFindMany.mockResolvedValue([ + { + id: 'block-1', + startMinutes: 360, + endMinutes: 480, + proclamations: [{ proclamation: { id: 'p1' } }, { proclamation: { id: 'p2' } }] + } + ]); + + const response = await call({ email: 'jane@example.com', licenseKey: KEY }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + email: 'jane@example.com', + blocks: [ + { + id: 'block-1', + startTime: '06:00', + startMinutes: 360, + endTime: '08:00', + endMinutes: 480, + proclamationIds: ['p1', 'p2'] + } + ] + }); + }); + + it('returns IDs for every selected proclamation regardless of frequency', async () => { + licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null }); + userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' }); + blockFindMany.mockResolvedValue([ + { + id: 'block-1', + startMinutes: 0, + endMinutes: 60, + proclamations: [{ proclamation: { id: 'p1' } }] + } + ]); + + const response = await call({ email: 'jane@example.com', licenseKey: KEY }); + const body = (await response.json()) as { blocks: { proclamationIds: string[] }[] }; + + expect(body.blocks[0].proclamationIds).toEqual(['p1']); + }); + + it('accepts the legacy `key` query alias and an email-only license', async () => { + licenseKeyFindUnique.mockResolvedValue({ + key: KEY, + userId: null, + email: 'jane@example.com' + }); + userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' }); + + const response = await call({ email: 'jane@example.com', key: KEY }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ email: 'jane@example.com', blocks: [] }); + }); +});