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()`
|
// Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()`
|
||||||
// map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these
|
// map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these
|
||||||
// canonical ids (1-9), never the portal DB autoincrement ids, because
|
// canonical ids (1-9), never the portal DB autoincrement ids, because
|
||||||
@@ -138,3 +140,33 @@ export function serializeColorPalette(palette: {
|
|||||||
}): V2ColorPalette {
|
}): V2ColorPalette {
|
||||||
return { solfeggioFrequencyId: palette.solfeggioFrequencyId, colors: palette.colors };
|
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