Build API v2

This commit is contained in:
NeilClack
2026-08-07 14:06:20 -05:00
parent b2091deae0
commit 64f7070ad7
50 changed files with 25534 additions and 26502 deletions
+64
View File
@@ -0,0 +1,64 @@
import { error } from '@sveltejs/kit';
export const MAX_JSON_BODY_BYTES = 64 * 1024;
export function jsonOk(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'content-type': 'application/json' }
});
}
export function jsonError(
status: number,
message: string,
headers?: Record<string, string>
): Response {
return new Response(JSON.stringify({ message }), {
status,
headers: { 'content-type': 'application/json', ...headers }
});
}
/** Read and parse a JSON request body with a size cap. Throws 413 for
* oversized bodies and 400 for invalid JSON / wrong content type. */
export async function readJsonBody(request: Request): Promise<unknown> {
const contentType = request.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
throw error(400, 'content-type must be application/json');
}
const contentLength = Number(request.headers.get('content-length') ?? 0);
if (contentLength > MAX_JSON_BODY_BYTES) {
throw error(413, 'request body too large');
}
const raw = await request.text();
if (raw.length === 0) {
throw error(400, 'request body is required');
}
if (raw.length > MAX_JSON_BODY_BYTES) {
throw error(413, 'request body too large');
}
try {
return JSON.parse(raw);
} catch {
throw error(400, 'invalid JSON body');
}
}
/** Build a 429 response with Retry-After set in seconds. */
export function rateLimited(retryAfterMs: number): Response {
const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000));
return jsonError(429, 'rate limit exceeded', { 'retry-after': String(retryAfterSeconds) });
}
export function clientIp(request: Request): string {
const fwd = request.headers.get('x-forwarded-for');
if (fwd) {
const first = fwd.split(',')[0]?.trim();
if (first) return first;
}
return request.headers.get('x-real-ip') ?? 'unknown';
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { rateLimit, _clearRateLimits } from './v2-rate-limit';
describe('rateLimit', () => {
beforeEach(() => {
_clearRateLimits();
});
it('allows requests up to the limit', () => {
for (let i = 0; i < 3; i++) {
const r = rateLimit({ key: 'k', limit: 3, windowMs: 60_000, now: 1000 });
expect(r.ok).toBe(true);
}
});
it('rejects once the limit is exceeded and reports Retry-After', () => {
for (let i = 0; i < 3; i++) {
rateLimit({ key: 'k', limit: 3, windowMs: 60_000, now: 1000 });
}
const r = rateLimit({ key: 'k', limit: 3, windowMs: 60_000, now: 1000 });
expect(r.ok).toBe(false);
expect(r.retryAfterMs).toBeGreaterThan(0);
});
it('treats keys independently', () => {
rateLimit({ key: 'a', limit: 1, windowMs: 60_000, now: 1000 });
expect(rateLimit({ key: 'a', limit: 1, windowMs: 60_000, now: 1000 }).ok).toBe(false);
expect(rateLimit({ key: 'b', limit: 1, windowMs: 60_000, now: 1000 }).ok).toBe(true);
});
it('lets the window expire before allowing again', () => {
for (let i = 0; i < 2; i++) {
rateLimit({ key: 'k', limit: 2, windowMs: 10_000, now: 1000 });
}
expect(rateLimit({ key: 'k', limit: 2, windowMs: 10_000, now: 1000 }).ok).toBe(false);
expect(rateLimit({ key: 'k', limit: 2, windowMs: 10_000, now: 1000 + 10_000 + 1 }).ok).toBe(
true
);
});
});
+36
View File
@@ -0,0 +1,36 @@
// Simple in-memory sliding-window rate limiter, keyed per process. Suitable
// for per-IP / per-email limits on v2 routes; for multi-instance deployments
// this should move to shared storage (e.g. the database or Redis).
type Bucket = { timestamps: number[] };
const buckets = new Map<string, Bucket>();
export function rateLimit(opts: { key: string; limit: number; windowMs: number; now?: number }): {
ok: boolean;
retryAfterMs: number;
} {
const now = opts.now ?? Date.now();
let bucket = buckets.get(opts.key);
if (!bucket) {
bucket = { timestamps: [] };
buckets.set(opts.key, bucket);
}
bucket.timestamps = bucket.timestamps.filter((t) => now - t < opts.windowMs);
if (bucket.timestamps.length >= opts.limit) {
const oldest = bucket.timestamps[0];
const retryAfterMs = Math.max(0, oldest + opts.windowMs - now);
return { ok: false, retryAfterMs };
}
bucket.timestamps.push(now);
return { ok: true, retryAfterMs: 0 };
}
/** Test hook: clears all rate-limit buckets. */
export function _clearRateLimits(): void {
buckets.clear();
}
+188
View File
@@ -0,0 +1,188 @@
import { describe, it, expect } from 'vitest';
import {
CANONICAL_FREQUENCIES,
CANONICAL_PALETTES,
canonicalIdForFrequency,
serializeCategory,
serializeColorPalette,
serializeLicense,
serializeProclamation,
serializeSubscriptionLevel
} from './v2-serializers';
describe('canonicalIdForFrequency', () => {
it('maps Hz values to canonical ids via the kiosk frequencyIdForHz map', () => {
expect(canonicalIdForFrequency({ value: 174 })).toBe(1);
expect(canonicalIdForFrequency({ value: 285 })).toBe(1);
expect(canonicalIdForFrequency({ value: 396 })).toBe(2);
expect(canonicalIdForFrequency({ value: 417 })).toBe(3);
expect(canonicalIdForFrequency({ value: 432 })).toBe(4);
expect(canonicalIdForFrequency({ value: 528 })).toBe(5);
expect(canonicalIdForFrequency({ value: 639 })).toBe(6);
expect(canonicalIdForFrequency({ value: 741 })).toBe(7);
expect(canonicalIdForFrequency({ value: 852 })).toBe(8);
expect(canonicalIdForFrequency({ value: 963 })).toBe(9);
});
it('returns null for frequencies with no canonical id (e.g. 936 Hz)', () => {
expect(canonicalIdForFrequency({ value: 936 })).toBeNull();
});
});
describe('CANONICAL_FREQUENCIES', () => {
it('emits exactly the 9 canonical frequencies with non-zero ids', () => {
expect(CANONICAL_FREQUENCIES).toHaveLength(9);
for (const freq of CANONICAL_FREQUENCIES) {
expect(freq.id).toBeGreaterThan(0);
expect(typeof freq.name).toBe('string');
expect(canonicalIdForFrequency({ value: freq.value })).toBe(freq.id);
}
});
it('ships a 7-color palette for every canonical id', () => {
for (const freq of CANONICAL_FREQUENCIES) {
const colors = CANONICAL_PALETTES[freq.id];
expect(colors).toBeDefined();
expect(colors).toHaveLength(7);
for (const c of colors) {
expect(c).toMatch(/^#([0-9A-Fa-f]{6})$/);
}
}
});
});
describe('serializeProclamation', () => {
const root396 = { id: 7, name: 'Root', value: 396 };
const crown936 = { id: 1, name: 'Crown', value: 936 };
const solarPlexus528 = { id: 5, name: 'Solar Plexus', value: 528 };
const base = {
id: 'b40fdcb9-703c-420e-af32-a081964c6f08',
name: 'Peace & Relaxation',
content: '<p>The text content, HTML formatted</p>',
categoryId: 5,
subscriptionLevelId: 1
};
it('derives solfeggioFrequencyId from the Hz value, not the DB id', () => {
// DB row id 7 is 396 Hz -> canonical id 2.
const record = serializeProclamation({
...base,
solfeggioFrequency: root396
});
expect(record).toEqual({
...base,
solfeggioFrequencyId: 2
});
});
it('returns null and drops proclamations whose frequency has no canonical id (936 Hz)', () => {
const record = serializeProclamation({
...base,
solfeggioFrequency: crown936
});
expect(record).toBeNull();
});
it('passes through a null subscriptionLevelId (kiosk treats as level 1)', () => {
const record = serializeProclamation({
...base,
subscriptionLevelId: null,
solfeggioFrequency: solarPlexus528
});
expect(record?.subscriptionLevelId).toBeNull();
});
it('embeds a 7-color palette when provided', () => {
const palette = ['#32CD32', '#3CB371', '#90EE90', '#98FB98', '#ADFFA9', '#C8FFD2', '#F0FFF0'];
const record = serializeProclamation(
{
...base,
solfeggioFrequency: solarPlexus528
},
palette
);
expect(record?.colorPalette).toEqual(palette);
});
it('omits colorPalette when not provided', () => {
const record = serializeProclamation({
...base,
solfeggioFrequency: solarPlexus528
});
expect(record?.colorPalette).toBeUndefined();
expect(Object.keys(record ?? {})).not.toContain('colorPalette');
});
});
describe('serializeCategory', () => {
it('keeps parentId null for root categories', () => {
expect(serializeCategory({ id: 4, name: 'Spiritual', sortOrder: 0, parentId: null })).toEqual({
id: 4,
name: 'Spiritual',
sortOrder: 0,
parentId: null
});
});
it('keeps parentId int for children', () => {
expect(serializeCategory({ id: 5, name: 'General', sortOrder: 0, parentId: 4 })).toEqual({
id: 5,
name: 'General',
sortOrder: 0,
parentId: 4
});
});
});
describe('serializeLicense', () => {
it('returns subscription_level as an object when the level row exists', () => {
const result = serializeLicense({
key: 'XXXX-XXXX-XXXX-XXXX',
emailAddress: 'user@example.com',
levelName: 'Advanced',
level: { id: 2, name: 'Advanced', description: 'General + Specific' }
});
expect(result).toEqual({
key: 'XXXX-XXXX-XXXX-XXXX',
email_address: 'user@example.com',
subscription_level: { id: 2, name: 'Advanced', description: 'General + Specific' }
});
});
it('falls back to a structurally consistent object when no level row matches', () => {
const result = serializeLicense({
key: 'XXXX-XXXX-XXXX-XXXX',
emailAddress: 'user@example.com',
levelName: 'SomeCustomLevel',
level: null
});
expect(result).toEqual({
key: 'XXXX-XXXX-XXXX-XXXX',
email_address: 'user@example.com',
subscription_level: { id: null, name: 'SomeCustomLevel', description: null }
});
});
});
describe('serializeSubscriptionLevel', () => {
it('maps rows to {id, name, description}', () => {
expect(
serializeSubscriptionLevel({ id: 1, name: 'Basic', description: 'General only' })
).toEqual({
id: 1,
name: 'Basic',
description: 'General only'
});
});
});
describe('serializeColorPalette', () => {
it('maps rows to {solfeggioFrequencyId, colors}', () => {
const colors = ['#8B6914', '#B8860B', '#DAA520', '#E8B64C', '#FFD700', '#FFE9A8', '#FFF4CC'];
expect(serializeColorPalette({ solfeggioFrequencyId: 1, colors })).toEqual({
solfeggioFrequencyId: 1,
colors
});
});
});
+161
View File
@@ -0,0 +1,161 @@
// 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
// proclamations and color palettes on the kiosk are keyed by them.
export const CANONICAL_ID_FOR_HZ: Record<number, number> = {
174: 1,
285: 1,
396: 2,
417: 3,
432: 4,
528: 5,
639: 6,
741: 7,
852: 8,
963: 9
};
// The 9 canonical frequencies exactly as the kiosk expects from
// GET /frequencies (id 1-9, non-zero, `value` recognized by frequencyIdForHz).
export const CANONICAL_FREQUENCIES: { id: number; name: string; value: number }[] = [
{ id: 1, name: 'Grounding / Relief', value: 174 },
{ id: 2, name: 'Root', value: 396 },
{ id: 3, name: 'Sacral', value: 417 },
{ id: 4, name: 'Clarity / Wellbeing', value: 432 },
{ id: 5, name: 'Solar Plexus', value: 528 },
{ id: 6, name: 'Heart', value: 639 },
{ id: 7, name: 'Throat', value: 741 },
{ id: 8, name: '3rd Eye', value: 852 },
{ id: 9, name: 'Crown', value: 963 }
];
// Seed palettes per canonical id, matching the kiosk's
// DatabaseManager::seedColorPalettes(). Used to backfill /color-palettes
// when the DB has not been seeded for an id yet.
export const CANONICAL_PALETTES: Record<number, string[]> = {
1: ['#8B6914', '#B8860B', '#DAA520', '#E8B64C', '#FFD700', '#FFE9A8', '#FFF4CC'],
2: ['#8B0000', '#B22222', '#DC143C', '#FF6347', '#FF8C69', '#FFA500', '#FFDAB9'],
3: ['#FF4500', '#FF7F50', '#FF8C00', '#FFA500', '#FFC87F', '#FFDFBA', '#FFEBCD'],
4: ['#8B2252', '#CD3278', '#DB7093', '#FF69B4', '#FFB6C1', '#FFC0CB', '#FFE4E1'],
5: ['#32CD32', '#3CB371', '#90EE90', '#98FB98', '#ADFFA9', '#C8FFD2', '#F0FFF0'],
6: ['#00FF7F', '#00CED1', '#40E0D0', '#5F9EA0', '#7FFFD4', '#AFEEEE', '#E0FFFF'],
7: ['#00BFFF', '#1E90FF', '#4169E1', '#6495ED', '#87CEEB', '#ADD8E6', '#F0F8FF'],
8: ['#4B0082', '#9370DB', '#8A2BE2', '#7B68EE', '#DA70D6', '#D8BFD8', '#E6E6FA'],
9: ['#9400D3', '#BA55D3', '#C71585', '#DDA0DD', '#EE82EE', '#FFF0F5', '#FAF0FA']
};
/** Map a SolfeggioFrequency row to its canonical id, or null if it has no
* canonical id (e.g. 936 Hz). */
export function canonicalIdForFrequency(freq: { value: number }): number | null {
return CANONICAL_ID_FOR_HZ[freq.value] ?? null;
}
export type V2Proclamation = {
id: string;
name: string;
content: string;
categoryId: number;
solfeggioFrequencyId: number;
subscriptionLevelId: number | null;
colorPalette?: string[];
};
export type ProclamationWithFrequency = {
id: string;
name: string;
content: string;
categoryId: number;
subscriptionLevelId: number | null;
solfeggioFrequency: { value: number };
};
/** Serialize a DB proclamation row to the v2 wire format. Returns null when
* the proclamation's frequency has no canonical id (dropped from v2). */
export function serializeProclamation(
p: ProclamationWithFrequency,
palette?: string[]
): V2Proclamation | null {
const solfeggioFrequencyId = CANONICAL_ID_FOR_HZ[p.solfeggioFrequency.value];
if (!solfeggioFrequencyId) return null;
const record: V2Proclamation = {
id: p.id,
name: p.name,
content: p.content,
categoryId: p.categoryId,
solfeggioFrequencyId,
subscriptionLevelId: p.subscriptionLevelId
};
if (palette && palette.length > 0) {
record.colorPalette = palette;
}
return record;
}
export type V2Category = {
id: number;
name: string;
sortOrder: number;
parentId: number | null;
};
export function serializeCategory(c: {
id: number;
name: string;
sortOrder: number;
parentId: number | null;
}): V2Category {
return { id: c.id, name: c.name, sortOrder: c.sortOrder, parentId: c.parentId };
}
export type V2SubscriptionLevel = {
id: number;
name: string;
description: string | null;
};
export function serializeSubscriptionLevel(level: {
id: number;
name: string;
description: string | null;
}): V2SubscriptionLevel {
return { id: level.id, name: level.name, description: level.description };
}
export type V2License = {
key: string;
email_address: string;
subscription_level: { id: number | null; name: string; description: string | null };
};
/** Build the POST /license response. `subscription_level` is always an object;
* when no SubscriptionLevel row matches the license's level name, fall back to
* a structurally consistent object with a null id/description. */
export function serializeLicense(opts: {
key: string;
emailAddress: string;
levelName: string;
level: { id: number; name: string; description: string | null } | null;
}): V2License {
return {
key: opts.key,
email_address: opts.emailAddress,
subscription_level: opts.level
? { id: opts.level.id, name: opts.level.name, description: opts.level.description }
: { id: null, name: opts.levelName, description: null }
};
}
export type V2ColorPalette = {
solfeggioFrequencyId: number;
colors: string[];
};
export function serializeColorPalette(palette: {
solfeggioFrequencyId: number;
colors: string[];
}): V2ColorPalette {
return { solfeggioFrequencyId: palette.solfeggioFrequencyId, colors: palette.colors };
}
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
computeSignature,
buildCanonicalRequest,
verifyRequestSignature,
_clearNonces
} from './v2-signature';
const SECRET = 'test-onboarding-secret';
function signedRequest(opts: {
now?: Date;
timestampOffsetSec?: number;
tamper?: Partial<Record<'signature' | 'nonce' | 'timestamp' | 'body', string>>;
}) {
const method = 'POST';
const path = '/api/v2/automations/create/account';
const timestamp = String(Math.floor((opts.now ?? new Date()).getTime() / 1000));
const nonce = 'a48c0f2e-5f34-4b0b-9a12-3c7d6b8e1f00';
const body = '{"email":"user@example.com","subscription_level":"Basic"}';
let ts = timestamp;
let nonceValue = nonce;
if (opts.tamper?.timestamp) ts = opts.tamper.timestamp;
if (opts.tamper?.nonce) nonceValue = opts.tamper.nonce;
let sig = computeSignature(SECRET, buildCanonicalRequest(method, path, ts, nonceValue, body));
if (opts.tamper?.body)
sig = computeSignature(
SECRET,
buildCanonicalRequest(method, path, ts, nonceValue, opts.tamper.body)
);
if (opts.tamper?.signature) sig = opts.tamper.signature;
const headers = new Headers({
'content-type': 'application/json',
'x-timestamp': ts,
'x-nonce': nonceValue,
'x-signature': sig
});
return { method, path, headers, rawBody: body };
}
describe('verifyRequestSignature', () => {
beforeEach(() => {
_clearNonces();
});
it('accepts a correctly signed request', () => {
const { method, path, headers, rawBody } = signedRequest({});
const result = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET });
expect(result.ok).toBe(true);
});
it('rejects a request with a wrong signature', () => {
const { method, path, headers, rawBody } = signedRequest({
tamper: { signature: 'deadbeef'.repeat(8) }
});
const result = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe('invalid signature');
});
it('rejects a request signed with the wrong secret', () => {
const { method, path, headers, rawBody } = signedRequest({});
const result = verifyRequestSignature({
method,
path,
headers,
rawBody,
secret: 'other-secret'
});
expect(result.ok).toBe(false);
});
it('rejects missing signature headers', () => {
const { method, path, rawBody } = signedRequest({});
const result = verifyRequestSignature({
method,
path,
headers: new Headers(),
rawBody,
secret: SECRET
});
expect(result.ok).toBe(false);
});
it('rejects an expired timestamp (>300s skew)', () => {
const now = new Date();
const { method, path, headers, rawBody } = signedRequest({
tamper: { timestamp: String(Math.floor(now.getTime() / 1000) - 600) }
});
const result = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET, now });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe('expired timestamp');
});
it('accepts a timestamp within the 300s skew', () => {
const now = new Date();
const { method, path, headers, rawBody } = signedRequest({
tamper: { timestamp: String(Math.floor(now.getTime() / 1000) - 120) }
});
const result = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET, now });
expect(result.ok).toBe(true);
});
it('rejects a replayed nonce', () => {
const { method, path, headers, rawBody } = signedRequest({});
expect(verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET }).ok).toBe(
true
);
const replay = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET });
expect(replay.ok).toBe(false);
if (!replay.ok) expect(replay.reason).toBe('replayed nonce');
});
});
+96
View File
@@ -0,0 +1,96 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
export const SIGNATURE_MAX_SKEW_SECONDS = 300;
export function computeSignature(secret: string, canonicalRequest: string): string {
return createHmac('sha256', secret).update(canonicalRequest, 'utf8').digest('hex');
}
/** Canonical request string covered by the HMAC: method, path, timestamp,
* nonce, and the raw body, newline-separated. */
export function buildCanonicalRequest(
method: string,
path: string,
timestamp: string,
nonce: string,
body: string
): string {
return [method, path, timestamp, nonce, body].join('\n');
}
export function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
// In-memory nonce store. Prunes entries older than twice the allowed skew.
const seenNonces = new Map<string, number>();
export function isNonceReplayed(nonce: string, now = Date.now()): boolean {
const seenAt = seenNonces.get(nonce);
if (seenAt === undefined) return false;
// Keep treating it as replayed only while within the replay window; beyond
// that the entry is pruned and the nonce could in theory be reused, but the
// timestamp skew check still bounds it.
return now - seenAt < SIGNATURE_MAX_SKEW_SECONDS * 2 * 1000;
}
export function recordNonce(nonce: string, now = Date.now()): void {
seenNonces.set(nonce, now);
// Opportunistically prune stale entries.
const cutoff = now - SIGNATURE_MAX_SKEW_SECONDS * 2 * 1000;
for (const [key, seenAt] of seenNonces) {
if (seenAt < cutoff) seenNonces.delete(key);
}
}
export type SignatureVerification = { ok: true } | { ok: false; reason: string };
export function verifyRequestSignature(opts: {
method: string;
path: string;
headers: Headers;
rawBody: string;
secret: string;
now?: Date;
}): SignatureVerification {
const { method, path, headers, rawBody, secret } = opts;
const timestamp = headers.get('x-timestamp');
const nonce = headers.get('x-nonce');
const signature = headers.get('x-signature');
if (!timestamp || !nonce || !signature) {
return { ok: false, reason: 'missing signature headers' };
}
const ts = Number(timestamp);
if (!Number.isFinite(ts)) {
return { ok: false, reason: 'invalid timestamp' };
}
const now = (opts.now ?? new Date()).getTime() / 1000;
if (Math.abs(now - ts) > SIGNATURE_MAX_SKEW_SECONDS) {
return { ok: false, reason: 'expired timestamp' };
}
if (isNonceReplayed(nonce)) {
return { ok: false, reason: 'replayed nonce' };
}
const canonical = buildCanonicalRequest(method, path, timestamp, nonce, rawBody);
const expected = computeSignature(secret, canonical);
if (!safeEqual(expected, signature)) {
return { ok: false, reason: 'invalid signature' };
}
recordNonce(nonce);
return { ok: true };
}
/** Test hook: clears the in-memory nonce store. */
export function _clearNonces(): void {
seenNonces.clear();
}