Add v2 schedule API endpoint

This commit is contained in:
NeilClack
2026-09-11 18:16:04 -05:00
parent 4922e941ae
commit 545fb22910
3 changed files with 250 additions and 0 deletions
+32
View File
@@ -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
};
}
+63
View File
@@ -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 });
};
+155
View File
@@ -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<typeof vi.fn>;
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
function call(params: Record<string, string>) {
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<typeof GET>[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: [] });
});
});