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
@@ -0,0 +1,175 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { auth } from '$lib/auth';
import { prisma } from '$lib/db';
import { sendMail } from '$lib/server/resend';
import { env } from '$env/dynamic/private';
import { clientIp, rateLimited } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit';
import { verifyRequestSignature } from '$lib/server/v2-signature';
import { MAX_JSON_BODY_BYTES } from '$lib/server/v2-helpers';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function generateLicenseKey(): string {
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
}
function unauthorized(): Response {
return new Response(JSON.stringify({ error: 'invalid signature' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
export const POST: RequestHandler = async ({ request, url }) => {
const secret = env.API_V2_ONBOARDING_SECRET;
if (!secret) {
return unauthorized();
}
const rawBody = await request.text();
if (rawBody.length === 0) {
throw error(400, 'request body is required');
}
if (rawBody.length > MAX_JSON_BODY_BYTES) {
throw error(413, 'request body too large');
}
const verification = verifyRequestSignature({
method: 'POST',
path: url.pathname,
headers: request.headers,
rawBody,
secret
});
if (!verification.ok) {
return unauthorized();
}
const ip = clientIp(request);
const ipLimit = rateLimit({ key: `onboarding:ip:${ip}`, limit: 5, windowMs: 60_000 });
if (!ipLimit.ok) {
return rateLimited(ipLimit.retryAfterMs);
}
let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
throw error(400, 'invalid JSON body');
}
const { email, name, subscription_level } = body as Record<string, unknown>;
if (typeof email !== 'string' || !email.trim()) {
error(400, 'email is required');
}
const cleanEmail = email.trim().toLowerCase();
if (cleanEmail.length > 254 || !EMAIL_RE.test(cleanEmail)) {
error(400, 'email is required');
}
if (typeof subscription_level !== 'string' || !subscription_level.trim()) {
error(400, 'subscription_level is required');
}
const emailLimit = rateLimit({
key: `onboarding:email:${cleanEmail}`,
limit: 3,
windowMs: 24 * 60 * 60 * 1000
});
if (!emailLimit.ok) {
return rateLimited(emailLimit.retryAfterMs);
}
const levelName = subscription_level.trim();
const level = await prisma.subscriptionLevel.findFirst({ where: { name: levelName } });
if (!level) {
error(400, `subscription_level "${levelName}" not found`);
}
const displayName =
typeof name === 'string' && name.trim() ? name.trim().slice(0, 120) : cleanEmail.split('@')[0];
let user = await prisma.user.findUnique({ where: { email: cleanEmail } });
if (!user) {
const tempPassword = crypto.randomUUID().replace(/-/g, '').slice(0, 20);
try {
await auth.api.signUpEmail({
body: {
email: cleanEmail,
password: tempPassword,
name: displayName
}
});
user = await prisma.user.findUnique({ where: { email: cleanEmail } });
const verification = await prisma.verification.findFirst({
where: {
identifier: cleanEmail,
expiresAt: { gt: new Date() }
},
orderBy: { createdAt: 'desc' }
});
const verifyUrl = verification
? `${env.BETTER_AUTH_URL}/api/auth/verify-email?token=${verification.value}&callbackURL=/login?verified=true`
: `${env.BETTER_AUTH_URL}/login`;
await sendMail({
to: cleanEmail,
subject: 'Your account is ready',
templateId: 'automated-user-registration',
data: {
firstName: displayName,
tempPassword,
verifyUrl,
loginUrl: `${env.BETTER_AUTH_URL}/login`
}
});
} catch (err) {
console.error('Failed to create user:', err);
error(500, 'Failed to create account');
}
}
if (!user) {
error(500, 'Failed to find or create user');
}
const key = generateLicenseKey();
await prisma.licenseKey.create({
data: {
key,
active: true,
level: levelName,
userId: user.id
}
});
try {
await sendMail({
to: cleanEmail,
subject: 'Your Rhemawave License Key',
templateId: 'license-key-delivery',
data: {
firstName: displayName,
license_key: key
}
});
} catch (e) {
console.error('Failed to send license key email:', e);
}
return json({
success: true,
message: 'Account and license key created',
user_id: user.id
});
};
@@ -0,0 +1,236 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { computeSignature, buildCanonicalRequest, _clearNonces } from '$lib/server/v2-signature';
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
const { authApi, sendMailMock } = vi.hoisted(() => ({
authApi: { signUpEmail: vi.fn() },
sendMailMock: vi.fn()
}));
vi.mock('$env/dynamic/private', () => ({
env: {
API_V2_ONBOARDING_SECRET: 'test-onboarding-secret',
BETTER_AUTH_URL: 'http://localhost:5173'
}
}));
vi.mock('$lib/auth', () => ({
auth: { api: authApi }
}));
vi.mock('$lib/server/resend', () => ({
sendMail: sendMailMock
}));
vi.mock('$lib/db', () => ({
prisma: {
subscriptionLevel: { findFirst: vi.fn() },
user: { findUnique: vi.fn() },
verification: { findFirst: vi.fn() },
licenseKey: { create: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { POST } from './+server';
type POSTEvent = Parameters<typeof POST>[0];
const SECRET = 'test-onboarding-secret';
const PATH = '/api/v2/automations/create/account';
const mockLevelFindFirst = prisma.subscriptionLevel.findFirst as ReturnType<typeof vi.fn>;
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockVerification = prisma.verification.findFirst as ReturnType<typeof vi.fn>;
const mockLicenseCreate = prisma.licenseKey.create as ReturnType<typeof vi.fn>;
function signedOnboardingRequest(body: object, secret = SECRET): Request {
const method = 'POST';
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomUUID();
const rawBody = JSON.stringify(body);
const signature = computeSignature(
secret,
buildCanonicalRequest(method, PATH, timestamp, nonce, rawBody)
);
return new Request(`http://localhost${PATH}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-timestamp': timestamp,
'x-nonce': nonce,
'x-signature': signature
},
body: rawBody
});
}
async function expectHttpError(promise: unknown, status: number, message: string) {
try {
await promise;
} catch (e) {
expect(isHttpError(e)).toBe(true);
const err = e as { status: number; body: { message: string } };
expect(err.status).toBe(status);
expect(err.body.message).toBe(message);
return;
}
throw new Error('expected handler to throw an HttpError');
}
describe('POST /api/v2/automations/create/account', () => {
beforeEach(() => {
vi.clearAllMocks();
_clearNonces();
_clearRateLimits();
});
it('creates a license key for an existing user without a new signup', async () => {
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
mockLicenseCreate.mockResolvedValue({ id: 'lic-1' });
sendMailMock.mockResolvedValue({});
const res = await POST({
request: signedOnboardingRequest({ email: 'user@example.com', subscription_level: 'Basic' }),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
success: true,
message: 'Account and license key created',
user_id: 'user-1'
});
expect(authApi.signUpEmail).not.toHaveBeenCalled();
expect(mockLicenseCreate).toHaveBeenCalledTimes(1);
expect(sendMailMock).toHaveBeenCalledTimes(1);
});
it('signs up a new user and sends registration + license emails', async () => {
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
mockUserFindUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'user-2', email: 'new@example.com' });
mockVerification.mockResolvedValue({ value: 'verify-token' });
mockLicenseCreate.mockResolvedValue({ id: 'lic-2' });
sendMailMock.mockResolvedValue({});
authApi.signUpEmail.mockResolvedValue({});
const res = await POST({
request: signedOnboardingRequest({ email: 'new@example.com', subscription_level: 'Elite' }),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
success: true,
message: 'Account and license key created',
user_id: 'user-2'
});
expect(authApi.signUpEmail).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({ email: 'new@example.com', name: 'new' })
})
);
expect(sendMailMock).toHaveBeenCalledTimes(2);
});
it('rejects a request without a valid signature', async () => {
const req = new Request(`http://localhost${PATH}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com', subscription_level: 'Basic' })
});
const res = await POST({ request: req, url: new URL(`http://localhost${PATH}`) } as POSTEvent);
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: 'invalid signature' });
});
it('rejects a request signed with the wrong secret', async () => {
const res = await POST({
request: signedOnboardingRequest(
{ email: 'user@example.com', subscription_level: 'Basic' },
'wrong-secret'
),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: 'invalid signature' });
});
it('rejects a missing email', async () => {
await expectHttpError(
POST({
request: signedOnboardingRequest({ subscription_level: 'Basic' }),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent),
400,
'email is required'
);
});
it('rejects an oversized body with 413', async () => {
const rawBody = JSON.stringify({
email: 'user@example.com',
subscription_level: 'Basic',
name: 'x'.repeat(64 * 1024 + 1)
});
expect(rawBody.length).toBeGreaterThan(64 * 1024);
const req = new Request(`http://localhost${PATH}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: rawBody
});
await expectHttpError(
POST({ request: req, url: new URL(`http://localhost${PATH}`) } as POSTEvent),
413,
'request body too large'
);
});
it('rejects an unknown subscription level', async () => {
mockLevelFindFirst.mockResolvedValue(null);
await expectHttpError(
POST({
request: signedOnboardingRequest({
email: 'user@example.com',
subscription_level: 'Titanium'
}),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent),
400,
'subscription_level "Titanium" not found'
);
});
it('rate limits repeated requests for the same email', async () => {
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
mockLicenseCreate.mockResolvedValue({ id: 'lic-1' });
sendMailMock.mockResolvedValue({});
for (let i = 0; i < 3; i++) {
const res = await POST({
request: signedOnboardingRequest({
email: 'user@example.com',
subscription_level: 'Basic'
}),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
expect(res.status).toBe(200);
}
const res = await POST({
request: signedOnboardingRequest({ email: 'user@example.com', subscription_level: 'Basic' }),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
expect(res.status).toBe(429);
expect(await res.json()).toEqual({ message: 'rate limit exceeded' });
});
});
+9
View File
@@ -0,0 +1,9 @@
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { jsonOk } from '$lib/server/v2-helpers';
import { serializeCategory } from '$lib/server/v2-serializers';
export const GET: RequestHandler = async () => {
const categories = await prisma.proclamationCategory.findMany();
return jsonOk(categories.map(serializeCategory));
};
@@ -0,0 +1,16 @@
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { jsonOk } from '$lib/server/v2-helpers';
import { CANONICAL_FREQUENCIES, CANONICAL_PALETTES } from '$lib/server/v2-serializers';
export const GET: RequestHandler = async () => {
const rows = await prisma.colorPalette.findMany();
const byId = new Map(rows.map((r) => [r.solfeggioFrequencyId, r.colors as unknown as string[]]));
const palettes = CANONICAL_FREQUENCIES.map((f) => ({
solfeggioFrequencyId: f.id,
colors: byId.get(f.id) ?? CANONICAL_PALETTES[f.id]
}));
return jsonOk(palettes);
};
+7
View File
@@ -0,0 +1,7 @@
import type { RequestHandler } from './$types';
import { jsonOk } from '$lib/server/v2-helpers';
import { CANONICAL_FREQUENCIES } from '$lib/server/v2-serializers';
export const GET: RequestHandler = async () => {
return jsonOk(CANONICAL_FREQUENCIES);
};
+65
View File
@@ -0,0 +1,65 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { clientIp, jsonOk, rateLimited, readJsonBody } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit';
import { serializeLicense } from '$lib/server/v2-serializers';
const LICENSE_KEY_RE = /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
export const POST: RequestHandler = async ({ request }) => {
const ip = clientIp(request);
const rl = rateLimit({ key: `license:${ip}`, limit: 10, windowMs: 60_000 });
if (!rl.ok) {
return rateLimited(rl.retryAfterMs);
}
const body = (await readJsonBody(request)) as Record<string, unknown>;
const licenseKey = typeof body.license_key === 'string' ? body.license_key.trim() : '';
const email =
typeof body.email_address === 'string' ? body.email_address.trim().toLowerCase() : '';
if (!licenseKey) {
throw error(400, 'license_key is required');
}
if (!email) {
throw error(400, 'email_address is required');
}
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw error(400, 'email_address is required');
}
if (!LICENSE_KEY_RE.test(licenseKey)) {
throw error(400, 'license key not found');
}
const storedKey = await prisma.licenseKey.findFirst({
where: { key: licenseKey }
});
if (!storedKey) {
throw error(400, 'license key not found');
}
if (!storedKey.active) {
throw error(400, 'license key is not active');
}
const userAccount = await prisma.user.findUnique({ where: { email } });
if (!userAccount) {
throw error(400, 'user does not exist');
}
if (storedKey.userId !== userAccount.id) {
throw error(400, 'key does not belong to specified user');
}
const level = await prisma.subscriptionLevel.findFirst({
where: { name: storedKey.level }
});
return jsonOk(
serializeLicense({
key: storedKey.key,
emailAddress: email,
levelName: storedKey.level,
level
})
);
};
+167
View File
@@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
vi.mock('$lib/db', () => ({
prisma: {
licenseKey: { findFirst: vi.fn() },
user: { findUnique: vi.fn() },
subscriptionLevel: { findFirst: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { POST } from './+server';
type POSTEvent = Parameters<typeof POST>[0];
const mockKeyFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockLevelFindFirst = prisma.subscriptionLevel.findFirst as ReturnType<typeof vi.fn>;
function licenseRequest(body: unknown): Request {
return new Request('http://localhost/api/v2/license', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
}
async function expectHttpError(promise: unknown, status: number, message: string) {
try {
await promise;
} catch (e) {
expect(isHttpError(e)).toBe(true);
const err = e as { status: number; body: { message: string } };
expect(err.status).toBe(status);
expect(err.body.message).toBe(message);
return;
}
throw new Error('expected handler to throw an HttpError');
}
const storedKey = {
id: 'key-1',
key: 'ABCD-EFGH-IJKL-MNOP',
active: true,
level: 'Advanced',
userId: 'user-1'
};
describe('POST /api/v2/license', () => {
beforeEach(() => {
vi.clearAllMocks();
_clearRateLimits();
});
it('returns the subscription_level object for a valid key', async () => {
mockKeyFindFirst.mockResolvedValue(storedKey);
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
mockLevelFindFirst.mockResolvedValue({
id: 2,
name: 'Advanced',
description: 'General + Specific'
});
const res = await POST({
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
} as POSTEvent);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
key: storedKey.key,
email_address: 'user@example.com',
subscription_level: { id: 2, name: 'Advanced', description: 'General + Specific' }
});
});
it('falls back to a structurally consistent subscription_level for an unknown level name', async () => {
mockKeyFindFirst.mockResolvedValue({ ...storedKey, level: 'SomeCustomLevel' });
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
mockLevelFindFirst.mockResolvedValue(null);
const res = await POST({
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
} as POSTEvent);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
key: storedKey.key,
email_address: 'user@example.com',
subscription_level: { id: null, name: 'SomeCustomLevel', description: null }
});
});
it('rejects a missing license_key', async () => {
await expectHttpError(
POST({ request: licenseRequest({ email_address: 'user@example.com' }) } as POSTEvent),
400,
'license_key is required'
);
});
it('rejects a missing email_address', async () => {
await expectHttpError(
POST({ request: licenseRequest({ license_key: storedKey.key }) } as POSTEvent),
400,
'email_address is required'
);
});
it('rejects an unknown key', async () => {
mockKeyFindFirst.mockResolvedValue(null);
await expectHttpError(
POST({
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
} as POSTEvent),
400,
'license key not found'
);
});
it('rejects an inactive key', async () => {
mockKeyFindFirst.mockResolvedValue({ ...storedKey, active: false });
await expectHttpError(
POST({
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
} as POSTEvent),
400,
'license key is not active'
);
});
it('rejects a nonexistent user', async () => {
mockKeyFindFirst.mockResolvedValue(storedKey);
mockUserFindUnique.mockResolvedValue(null);
await expectHttpError(
POST({
request: licenseRequest({ license_key: storedKey.key, email_address: 'nobody@example.com' })
} as POSTEvent),
400,
'user does not exist'
);
});
it('rejects a key owned by a different user', async () => {
mockKeyFindFirst.mockResolvedValue(storedKey);
mockUserFindUnique.mockResolvedValue({ id: 'user-2', email: 'other@example.com' });
await expectHttpError(
POST({
request: licenseRequest({ license_key: storedKey.key, email_address: 'other@example.com' })
} as POSTEvent),
400,
'key does not belong to specified user'
);
});
it('rejects a malformed license key format', async () => {
await expectHttpError(
POST({
request: licenseRequest({ license_key: 'not-a-key', email_address: 'user@example.com' })
} as POSTEvent),
400,
'license key not found'
);
expect(mockKeyFindFirst).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,72 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { jsonOk } from '$lib/server/v2-helpers';
import {
canonicalIdForFrequency,
serializeProclamation,
type ProclamationWithFrequency
} from '$lib/server/v2-serializers';
const PROCLAMATION_INCLUDE = { solfeggioFrequency: true } as const;
export const GET: RequestHandler = async ({ request }) => {
const licenseKey = request.headers.get('x-license-key') ?? '';
const email = request.headers.get('x-email') ?? '';
const hasEntitlementHeaders = licenseKey !== '' || email !== '';
let proclamations: ProclamationWithFrequency[];
if (hasEntitlementHeaders) {
if (licenseKey === '' || email === '') {
throw error(400, 'not-authorized');
}
const verifiedKey = await prisma.licenseKey.findUnique({
where: { key: licenseKey }
});
if (!verifiedKey) {
throw error(400, 'not-authorized');
}
const existingUser = await prisma.user.findUnique({
where: { email },
include: { licenseKeys: true }
});
if (!existingUser) {
throw error(400, 'not-authorized');
}
const owned = existingUser.licenseKeys.some((k) => k.key === verifiedKey.key);
if (!owned) {
throw error(400, "this key doesn't appear to belong to you");
}
const userProclamations = await prisma.userProclamations.findMany({
where: { userId: existingUser.id },
include: { proclamation: { include: PROCLAMATION_INCLUDE } }
});
proclamations = userProclamations.map((up) => up.proclamation);
} else {
proclamations = await prisma.proclamation.findMany({
include: PROCLAMATION_INCLUDE
});
}
const paletteRows = await prisma.colorPalette.findMany();
const palettesByCanonicalId = new Map(
paletteRows.map((p) => [p.solfeggioFrequencyId, p.colors as unknown as string[]])
);
const records = proclamations
.map((p) => {
const canonicalId = canonicalIdForFrequency(p.solfeggioFrequency);
const palette = canonicalId ? palettesByCanonicalId.get(canonicalId) : undefined;
return serializeProclamation(p, palette);
})
.filter((r) => r !== null);
return jsonOk(records);
};
@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
vi.mock('$lib/db', () => ({
prisma: {
proclamation: { findMany: vi.fn() },
licenseKey: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
userProclamations: { findMany: vi.fn() },
colorPalette: { findMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { GET } from './+server';
type GETEvent = Parameters<typeof GET>[0];
const mockProclamationFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
const mockLicenseKey = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
const mockUser = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockUserProclamations = prisma.userProclamations.findMany as ReturnType<typeof vi.fn>;
const mockColorPalette = prisma.colorPalette.findMany as ReturnType<typeof vi.fn>;
const palettes = [
{
solfeggioFrequencyId: 5,
colors: ['#32CD32', '#3CB371', '#90EE90', '#98FB98', '#ADFFA9', '#C8FFD2', '#F0FFF0']
},
{
solfeggioFrequencyId: 7,
colors: ['#00BFFF', '#1E90FF', '#4169E1', '#6495ED', '#87CEEB', '#ADD8E6', '#F0F8FF']
}
];
const peaceProclamation = {
id: 'b40fdcb9-703c-420e-af32-a081964c6f08',
name: 'Peace & Relaxation',
content: '<p>The text content</p>',
categoryId: 5,
subscriptionLevelId: 1,
solfeggioFrequencyId: 5,
solfeggioFrequency: { id: 5, name: 'Solar Plexus', value: 528 }
};
const crown936 = {
id: '936-hz-proclamation',
name: 'Crown 936',
content: '<p>no canonical id</p>',
categoryId: 4,
subscriptionLevelId: 1,
solfeggioFrequencyId: 1,
solfeggioFrequency: { id: 1, name: 'Crown', value: 936 }
};
function getRequest(headers: Record<string, string> = {}): Request {
return new Request('http://localhost/api/v2/proclamations', { headers });
}
async function expectHttpError(promise: unknown, status: number, message: string) {
try {
await promise;
} catch (e) {
expect(isHttpError(e)).toBe(true);
const err = e as { status: number; body: { message: string } };
expect(err.status).toBe(status);
expect(err.body.message).toBe(message);
return;
}
throw new Error('expected handler to throw an HttpError');
}
describe('GET /api/v2/proclamations', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns full proclamation records with canonical ids and embedded palettes', async () => {
mockProclamationFindMany.mockResolvedValue([peaceProclamation]);
mockColorPalette.mockResolvedValue(palettes);
const res = await GET({ request: getRequest() } as GETEvent);
expect(res.status).toBe(200);
expect(await res.json()).toEqual([
{
id: peaceProclamation.id,
name: peaceProclamation.name,
content: peaceProclamation.content,
categoryId: 5,
solfeggioFrequencyId: 5,
subscriptionLevelId: 1,
colorPalette: palettes[0].colors
}
]);
});
it('drops proclamations whose frequency has no canonical id (936 Hz)', async () => {
mockProclamationFindMany.mockResolvedValue([peaceProclamation, crown936]);
mockColorPalette.mockResolvedValue(palettes);
const res = await GET({ request: getRequest() } as GETEvent);
const body = (await res.json()) as { id: string }[];
expect(body).toHaveLength(1);
expect(body[0].id).toBe(peaceProclamation.id);
});
it('returns the entitled proclamations as full records when headers are supplied', async () => {
mockLicenseKey.mockResolvedValue({ id: 'key-1', key: 'ABCD-EFGH-IJKL-MNOP' });
mockUser.mockResolvedValue({
id: 'user-1',
email: 'user@example.com',
licenseKeys: [{ id: 'key-1', key: 'ABCD-EFGH-IJKL-MNOP' }]
});
mockUserProclamations.mockResolvedValue([
{ userId: 'user-1', proclamationId: peaceProclamation.id, proclamation: peaceProclamation }
]);
mockColorPalette.mockResolvedValue(palettes);
const res = await GET({
request: getRequest({
'x-license-key': 'ABCD-EFGH-IJKL-MNOP',
'x-email': 'user@example.com'
})
} as GETEvent);
expect(res.status).toBe(200);
const body = (await res.json()) as { id: string }[];
expect(body).toHaveLength(1);
expect(body[0].id).toBe(peaceProclamation.id);
expect(mockUserProclamations).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: 'user-1' } })
);
});
it('rejects when only one entitlement header is supplied', async () => {
await expectHttpError(
GET({ request: getRequest({ 'x-license-key': 'ABCD-EFGH-IJKL-MNOP' }) } as GETEvent),
400,
'not-authorized'
);
});
it('rejects an unknown license key', async () => {
mockLicenseKey.mockResolvedValue(null);
await expectHttpError(
GET({
request: getRequest({
'x-license-key': 'UNKNOWN-KEY',
'x-email': 'user@example.com'
})
} as GETEvent),
400,
'not-authorized'
);
});
it('rejects a key that does not belong to the user', async () => {
mockLicenseKey.mockResolvedValue({ id: 'key-2', key: 'OTHER-KEY' });
mockUser.mockResolvedValue({
id: 'user-1',
email: 'user@example.com',
licenseKeys: [{ id: 'key-1', key: 'ABCD-EFGH-IJKL-MNOP' }]
});
await expectHttpError(
GET({
request: getRequest({
'x-license-key': 'OTHER-KEY',
'x-email': 'user@example.com'
})
} as GETEvent),
400,
"this key doesn't appear to belong to you"
);
});
});
@@ -0,0 +1,11 @@
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { jsonOk } from '$lib/server/v2-helpers';
import { serializeSubscriptionLevel } from '$lib/server/v2-serializers';
export const GET: RequestHandler = async () => {
const levels = await prisma.subscriptionLevel.findMany({
orderBy: { id: 'asc' }
});
return jsonOk(levels.map(serializeSubscriptionLevel));
};