Build API v2
This commit is contained in:
@@ -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';
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user