Add v2 schedule API endpoint
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user