From 9e9cabfb2b06557b6988c9bc3997f828907e1030 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 27 Aug 2026 12:07:34 -0500 Subject: [PATCH] Update docs and formatting; remove obsolete v2 schema doc - Refresh AI guide docs (api, svelte-patterns, testing-patterns) - Add API updates notes, drop the outdated portal_api_v2_schema.md - Format package.json --- AGENTS.md | 57 ++- docs/ai/api.md | 6 +- docs/ai/svelte-patterns.md | 25 +- docs/ai/testing-patterns.md | 58 +-- docs/api_updates.md | 330 ++++++++++++++++ package.json | 2 +- portal_api_v2_schema.md | 756 ------------------------------------ 7 files changed, 402 insertions(+), 832 deletions(-) create mode 100644 docs/api_updates.md delete mode 100644 portal_api_v2_schema.md diff --git a/AGENTS.md b/AGENTS.md index 45a22ca..82131a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,9 +97,9 @@ Use narrower commands when appropriate. If validation fails: -* Diagnose the failure. -* Fix the issue. -* Run validation again. +- Diagnose the failure. +- Fix the issue. +- Run validation again. Never finish with known failures. @@ -113,15 +113,15 @@ portal.Rhemawavetech.com Stack: -* SvelteKit 2 -* Svelte 5 -* TypeScript -* Tailwind CSS v4 -* Prisma 7 -* PostgreSQL -* Better Auth -* Vitest -* Playwright +- SvelteKit 2 +- Svelte 5 +- TypeScript +- Tailwind CSS v4 +- Prisma 7 +- PostgreSQL +- Better Auth +- Vitest +- Playwright Package manager: @@ -266,9 +266,9 @@ AdminPage.svelte Registry metadata exists for: -* columns -* searches -* relations +- columns +- searches +- relations Forms are manually implemented per route. @@ -309,8 +309,8 @@ Only load that file when modifying Svelte components or Svelte logic. Testing uses: -* Vitest -* Playwright browser testing +- Vitest +- Playwright browser testing For testing patterns, read: @@ -338,10 +338,10 @@ Only load that file when working on API functionality. Prettier rules: -* tabs -* single quotes -* no trailing commas -* 80 character print width +- tabs +- single quotes +- no trailing commas +- 80 character print width Follow existing formatting. @@ -391,17 +391,14 @@ Confirm: 2. Relevant tests/checks were run. 3. Failures were fixed. 4. The final response explains: - - * what changed - * files modified - * verification performed + - what changed + - files modified + - verification performed Do not stop at: -* "I found the issue" -* "Here is a plan" -* "The next step would be" +- "I found the issue" +- "Here is a plan" +- "The next step would be" Finish the work. - - diff --git a/docs/ai/api.md b/docs/ai/api.md index 65ffc45..99397b8 100644 --- a/docs/ai/api.md +++ b/docs/ai/api.md @@ -89,8 +89,8 @@ POST: Payload: { - "license_key": "...", - "email_address": "..." +"license_key": "...", +"email_address": "..." } Requires API key. @@ -153,7 +153,6 @@ Variables: - verifyUrl - loginUrl - license-key-delivery Variables: @@ -161,7 +160,6 @@ Variables: - firstName - license_key - email-verification-email Variables: diff --git a/docs/ai/svelte-patterns.md b/docs/ai/svelte-patterns.md index 8d8a5fc..82b9215 100644 --- a/docs/ai/svelte-patterns.md +++ b/docs/ai/svelte-patterns.md @@ -36,13 +36,13 @@ Always destructure $props() with defaults. Preferred pattern: let { - record = {} as Record, - fieldErrors = {} as Record, - isSuper = false +record = {} as Record, +fieldErrors = {} as Record, +isSuper = false }: { - record?: Record; - fieldErrors?: Record; - isSuper?: boolean; +record?: Record; +fieldErrors?: Record; +isSuper?: boolean; } = $props(); Do not assume props exist. @@ -66,7 +66,7 @@ Correct: let name = $state(''); $effect(() => { - name = String(record.name ?? ''); +name = String(record.name ?? ''); }); Use $effect whenever component state needs to follow changing props. @@ -88,11 +88,11 @@ Example: let dirty = $state(false); $effect(() => { - dirty = false; +dirty = false; }); $effect(() => { - name = String(record.name ?? ''); +name = String(record.name ?? ''); }); This ensures a new record starts clean. @@ -116,9 +116,10 @@ This can fail to update correctly. Use: Use selected on options. diff --git a/docs/ai/testing-patterns.md b/docs/ai/testing-patterns.md index 42146b1..154b2aa 100644 --- a/docs/ai/testing-patterns.md +++ b/docs/ai/testing-patterns.md @@ -30,13 +30,13 @@ Tests are colocated with source files. Client tests: -*.svelte.test.ts -*.svelte.spec.ts +_.svelte.test.ts +_.svelte.spec.ts Server tests: -*.test.ts -*.spec.ts +_.test.ts +_.spec.ts --- @@ -79,7 +79,7 @@ Node. Always reset mocks: beforeEach(() => { - vi.clearAllMocks(); +vi.clearAllMocks(); }); --- @@ -109,15 +109,15 @@ Mock only the methods used. Example: vi.mock('$lib/db', () => ({ - prisma: { - proclamation: { - findMany: vi.fn(), - findUnique: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn() - } - } +prisma: { +proclamation: { +findMany: vi.fn(), +findUnique: vi.fn(), +create: vi.fn(), +update: vi.fn(), +delete: vi.fn() +} +} })); Do not create massive unused mocks. @@ -143,7 +143,7 @@ createLocals(mockUser() as any) Specific roles: createLocals( - mockUser({ roleId: 4 }) as any +mockUser({ roleId: 4 }) as any ) --- @@ -172,15 +172,15 @@ Always use exact regex names. Preferred: page.getByRole( - 'textbox', - { name: /^Name$/ } +'textbox', +{ name: /^Name$/ } ) Avoid: page.getByRole( - 'textbox', - { name: 'Name' } +'textbox', +{ name: 'Name' } ) because substring matching can create strict-mode failures. @@ -192,15 +192,15 @@ because substring matching can create strict-mode failures. Because selects use selected options instead of bind:value: const element = - await page - .getByRole( - 'combobox', - { name: /^Frequency$/ } - ) - .element(); +await page +.getByRole( +'combobox', +{ name: /^Frequency$/ } +) +.element(); expect( - (element as HTMLSelectElement).value +(element as HTMLSelectElement).value ).toBe('2'); --- @@ -213,9 +213,9 @@ Use: vi.mock('$lib/components/example.svelte', () => ({ default: ($anchor: Element) => { - const div = document.createElement('div'); - $anchor.replaceWith(div); - } +const div = document.createElement('div'); +$anchor.replaceWith(div); +} })); Imports using mocked modules must come after vi.mock(). diff --git a/docs/api_updates.md b/docs/api_updates.md new file mode 100644 index 0000000..b5816bf --- /dev/null +++ b/docs/api_updates.md @@ -0,0 +1,330 @@ +# API Updates — Category Flattening, Groups, Subscription Level Removal + +**Release:** v2 schema change (migration `20260821000000_flat_categories_groups_drop_subscription_levels`) +**Status:** Breaking changes. Clients must be updated. + +## Summary of changes + +| Endpoint | Change | +| ----------------------------------------- | ------------------------------------------------------------------------------------ | +| `GET /api/v2/categories` | **Breaking**: `parentId` removed from every record. Categories are now a flat list. | +| `GET /api/v2/groups` | **New**: returns the group list (former sub-categories). | +| `GET /api/v2/proclamations` | **Breaking**: `groupId` added; `subscriptionLevelId` removed. | +| `GET /api/v2/subscription-levels` | **Removed**: subscription levels no longer exist. | +| `POST /api/v2/license` | **Breaking**: response no longer contains `subscription_level`. | +| `POST /api/v2/automations/create/account` | **Breaking**: `subscription_level` is no longer accepted or required in the payload. | +| `POST /api/v1/license` | **Breaking**: response no longer contains `subscription_level`. | +| `POST /api/v1/automations/create/account` | **Breaking**: `subscription_level` is no longer accepted or required in the payload. | +| `GET /api/v1/categories` | Payload implicitly changed: rows no longer contain `parentId`. | + +### Data model mapping + +- Former **parent** categories (`Spiritual`, `Physical`, `Mental`, `Emotional`, `Financial`, + `Social`, `Biblical`, ...) became the flat **Category** list. +- Former **child** sub-categories (`General`, `Specific`, `Precise`, `Prayers`, `Scripture`) + became **Groups**. +- A proclamation's former child category becomes its `group`; its former parent category becomes + its `category`. A proclamation may move between categories without changing its group. +- Proclamations that had no sub-category (e.g. under `Custom (Client Specific)`) were assigned to + the `General` group. +- Every proclamation now has exactly one category and one group. + +--- + +## OpenAPI 3.0 specification + +```yaml +openapi: 3.0.3 +info: + title: Rhemawave Portal API + version: 2.1.0 + description: | + Changes in this release: + * ProclamationCategory hierarchy flattened; parentId removed everywhere. + * New ProclamationGroup resource replaces sub-categories as a movable filter. + * SubscriptionLevel resource and all level-based gating removed. + +paths: + /api/v2/categories: + get: + summary: List categories (flat) + description: > + Returns all proclamation categories as a flat, ordered list. + BREAKING: the previous version returned a hierarchical list where each + record carried a nullable parentId linking children to parents. That + field no longer exists. + tags: [Categories] + responses: + '200': + description: Flat category list. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Category' + examples: + newResponse: + summary: New response + value: + - id: 4 + name: Spiritual + sortOrder: 0 + - id: 2 + name: Physical + sortOrder: 1 + oldResponseDeprecated: + summary: Old (removed) response shape + value: + - id: 5 + name: General + sortOrder: 0 + parentId: 4 + - id: 9 + name: Specific + sortOrder: 1 + parentId: 4 + + /api/v2/groups: + get: + summary: List groups + description: > + NEW ENDPOINT. Returns all proclamation groups ordered by sortOrder. + Groups replace the former sub-categories and act as a movable filter: + a proclamation can be moved from one category to another while keeping + its group. + tags: [Groups] + responses: + '200': + description: Group list. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Group' + example: + - id: 1 + name: General + sortOrder: 0 + - id: 2 + name: Specific + sortOrder: 1 + + /api/v2/proclamations: + get: + summary: List proclamations + description: > + Returns proclamations with their canonical frequency id and optional + color palette. BREAKING: each record now carries groupId (required, + always present) and no longer carries subscriptionLevelId. When the + optional entitlement headers are supplied, only the caller's entitled + proclamations are returned; there is no longer any per-level gating. + tags: [Proclamations] + parameters: + - in: header + name: x-license-key + required: false + schema: { type: string } + description: License key. Must be paired with x-email. + - in: header + name: x-email + required: false + schema: { type: string } + description: Account email. Must be paired with x-license-key. + responses: + '200': + description: Proclamation list. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Proclamation' + examples: + newResponse: + summary: New response + value: + - id: b40fdcb9-703c-420e-af32-a081964c6f08 + name: Peace & Relaxation + content:

The text content

+ categoryId: 4 + groupId: 1 + solfeggioFrequencyId: 5 + colorPalette: + [ + '#32CD32', + '#3CB371', + '#90EE90', + '#98FB98', + '#ADFFA9', + '#C8FFD2', + '#F0FFF0' + ] + oldResponseDeprecated: + summary: Old (removed) response shape + value: + - id: b40fdcb9-703c-420e-af32-a081964c6f08 + name: Peace & Relaxation + content:

The text content

+ categoryId: 5 + solfeggioFrequencyId: 5 + subscriptionLevelId: 1 + '400': + description: Entitlement headers incomplete or invalid. + + /api/v2/subscription-levels: + get: + summary: REMOVED + deprecated: true + description: > + This endpoint was removed. Subscription levels no longer exist in the + data model; clients must stop calling it. + tags: [Removed] + responses: + '404': + description: Route no longer exists. + + /api/v2/license: + post: + summary: Validate a license key + description: > + Validates a key/email pair. BREAKING: the response no longer contains a + subscription_level object. + tags: [License] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [license_key, email_address] + properties: + license_key: { type: string, example: ABCD-EFGH-IJKL-MNOP } + email_address: { type: string, format: email } + responses: + '200': + description: Key is valid. + content: + application/json: + schema: + $ref: '#/components/schemas/LicenseResponse' + examples: + newResponse: + summary: New response + value: + key: ABCD-EFGH-IJKL-MNOP + email_address: user@example.com + oldResponseDeprecated: + summary: Old (removed) response shape + value: + key: ABCD-EFGH-IJKL-MNOP + email_address: user@example.com + subscription_level: + id: 2 + name: Advanced + description: General + Specific + '400': + description: Validation failure (missing fields, unknown key, wrong owner). + + /api/v2/automations/create/account: + post: + summary: Create account + license key (GoHighLevel automation) + description: > + Creates a portal account when needed and issues a license key. + BREAKING: subscription_level was removed from the request contract. The + field is neither required nor validated anymore; sending it is ignored. + Signature headers (x-timestamp, x-nonce, x-signature) are unchanged. + tags: [Automations] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + name: { type: string } + responses: + '200': + description: Account and license key created. + '400': + description: Missing/invalid email. + '401': + description: Invalid signature. + +components: + schemas: + Category: + type: object + required: [id, name, sortOrder] + properties: + id: { type: integer } + name: { type: string } + sortOrder: { type: integer } + + Group: + type: object + required: [id, name, sortOrder] + properties: + id: { type: integer } + name: { type: string } + sortOrder: { type: integer } + + Proclamation: + type: object + required: [id, name, content, categoryId, groupId, solfeggioFrequencyId] + properties: + id: + type: string + format: uuid + name: { type: string } + content: { type: string, description: HTML formatted content. } + categoryId: + type: integer + description: Flat category this proclamation belongs to. + groupId: + type: integer + description: > + Group membership (movable filter). Independent of categoryId; + changing the category does not change the group. + solfeggioFrequencyId: + type: integer + minimum: 1 + maximum: 9 + description: Canonical kiosk frequency id (not the DB autoincrement id). + colorPalette: + type: array + items: { type: string } + minItems: 7 + maxItems: 7 + description: Present only when a palette exists for the frequency. + + LicenseResponse: + type: object + required: [key, email_address] + properties: + key: { type: string } + email_address: { type: string, format: email } +``` + +--- + +## Client migration checklist + +1. **Kiosk / API consumers** + - Stop reading `parentId`; treat `/api/v2/categories` as flat. + - Fetch `/api/v2/groups` once at startup for the filter list. + - Read `groupId` from each proclamation record for filtering. + - Remove all references to `subscriptionLevelId` and `/api/v2/subscription-levels`. + - Remove `subscription_level` handling from license validation responses. + +2. **GoHighLevel automation** + - Remove `subscription_level` from the account-creation webhook payload + (optional but recommended; the field is now ignored). + +3. **Admin portal** + - Manage categories under Admin → Proclamation Categories (flat list, drag to reorder). + - Manage groups under Admin → Proclamation Groups (drag to reorder). + - Proclamations now select one Category and one Group independently. diff --git a/package.json b/package.json index ce96aec..d6c051e 100644 --- a/package.json +++ b/package.json @@ -67,4 +67,4 @@ "sveltekit-superforms": "^2.30.2", "zod": "^4.4.3" } -} \ No newline at end of file +} diff --git a/portal_api_v2_schema.md b/portal_api_v2_schema.md deleted file mode 100644 index fea689b..0000000 --- a/portal_api_v2_schema.md +++ /dev/null @@ -1,756 +0,0 @@ -# RadiantWave Portal API v2 Schema - -This document defines the **required** API surface for the RadiantWave kiosk -application and serves as the implementation contract for the portal -(`../portal`). - -It supersedes `payloads.md` and `ApiUpdates.md`, which described a v1 API that -does not meet the application's requirements. - ---- - -## Background and constraints - -- The kiosk application (`this repo`) talks to - `https://portal.radiantwavetech.com/api/v1`. -- The **existing v1 routes must not change**: previously shipped kiosk binaries - depend on them. The v1 routes and their exact request/response shapes stay - frozen. -- To bring the API into compliance with this application's expectations we - introduce a **new, versioned API under `/api/v2`**, and migrate the kiosk - client to call `/api/v2` instead of `/api/v1`. -- The kiosk client currently uses a single base URL - (`src/network/api_client.h` → `API_BASE_URL`). All endpoint paths are - relative to that base, so migrating the base URL is the only client-side - change required. - -Compliance means: every endpoint the kiosk parses must return exactly the -fields the kiosk expects, in the shapes documented below. Deviations break -provisioning, content sync, or rendering. - ---- - -## Versioning strategy - -| Route space | Status | Consumer | -|-------------|---------------------------|-------------------------------------------------------| -| `/api/v1/*` | Frozen, never modified | Old/shipped kiosk binaries, GoHighLevel automation | -| `/api/v2/*` | New (this document) | Current kiosk application (after base-URL migration) | - -Rules: - -1. Never change the behavior or response shape of an existing `/api/v1/*` - route. If a v1 fix is required, fix it in v2. -2. New capabilities ship under `/api/v2` only. -3. A consumer may pin to the major version it was built against. The kiosk - pins to `/api/v2`. - ---- - -## General conventions (v2) - -- Base URL: `https://portal.radiantwavetech.com/api/v2`. -- All routes require HTTPS (TLS 1.2+; TLS 1.3 recommended). -- Success responses are JSON with `content-type: application/json`. -- Error bodies use the shape `{"message": "..."}` for route-handler errors and - `{"error": "..."}` for the authentication layer (see Security). -- Any response code outside the documented success code is a failure; the - kiosk surfaces `message`/`error` text when present. -- API keys are sent as `Authorization: Bearer sk_` and are **required on - every v2 route** except the signed onboarding route (below). -- Credentials (license keys, emails) must **never** be passed in the query - string. v1 accepted `?email=&key=`; v2 must not. -- Request body sizes are capped (see Security); oversized bodies are rejected - with `413`. - ---- - -## Canonical data model: solfeggio frequencies - -The kiosk uses a fixed 9-preset frequency scheme. Frequency ids must be -**canonical** — they appear on proclamations and color palettes and must match -the kiosk's own `frequencyIdForHz()` mapping: - -| Canonical id | Hz | Frequency (kiosk label) | -|--------------|------|------------------------------| -| 1 | 174 | Grounding / Relief (285 Hz is an alias of id 1) | -| 2 | 396 | Root | -| 3 | 417 | Sacral | -| 4 | 432 | Clarity / Wellbeing | -| 5 | 528 | Solar Plexus (fallback palette) | -| 6 | 639 | Heart | -| 7 | 741 | Throat | -| 8 | 852 | 3rd Eye | -| 9 | 963 | Crown | - -**Compliance gap in v1:** the portal's `SolfeggioFrequency` table stores 7 -rows whose ids do **not** match this scheme (id 1 = 936 Hz, id 5 = 528 Hz, -id 7 = 396 Hz, etc.). Because proclamations and color palettes are keyed by -frequency id, returning portal ids would make the kiosk look up the wrong -palette (e.g. a 396 Hz proclamation would render with the 741 Hz palette). - -**v2 requirement:** every `solfeggioFrequencyId` emitted by v2 must be a -canonical id (1–9) whose `value` resolves through the kiosk map above. -Frequencies that have no canonical id (e.g. 936 Hz) must not be referenced by -any v2 proclamation or palette. See Implementation notes for the reconciling -strategy. - ---- - -## Endpoints - -### 1. `GET /api/v2/frequencies` - -Lists the canonical solfeggio frequencies. - -Request: - -``` -GET /api/v2/frequencies -Authorization: Bearer sk_ -``` - -Response (200): - -```json -[ - { "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 } -] -``` - -Contract (kiosk `parseFrequenciesJson`): - -- `id`: int, 1–9, **non-zero** (entries with id 0 are skipped). -- `name`: string. -- `value`: int, Hz. Should be a value the kiosk's `frequencyIdForHz()` map - recognizes (174, 285, 396, 417, 432, 528, 639, 741, 852, 963). - -Errors: - -| Code | Body | Meaning | -|------|------|---------| -| 200 | array of frequency objects | Success | -| 401 | `{"error": "..."}` | Missing/invalid API key | - ---- - -### 2. `GET /api/v2/categories` - -Lists all proclamation categories (identical shape to v1). - -Request: - -``` -GET /api/v2/categories -Authorization: Bearer sk_ -``` - -Response (200): - -```json -[ - { "id": 4, "name": "Spiritual", "sortOrder": 0, "parentId": null }, - { "id": 5, "name": "General", "sortOrder": 0, "parentId": 4 } -] -``` - -Contract (kiosk `parseCategoriesJson`): - -- `id`: int, **non-zero** (id 0 entries are skipped). -- `name`: string. -- `parentId`: int for children, **`null` for root** categories (kiosk maps - null → 0). -- `sortOrder`: int. - -Errors: - -| Code | Body | Meaning | -|------|------|---------| -| 200 | array of category objects | Success | -| 401 | `{"error": "..."}` | Missing/invalid API key | - ---- - -### 3. `GET /api/v2/proclamations` - -Returns the proclamation library. This is the kiosk's primary content sync. - -Request (default: full library): - -``` -GET /api/v2/proclamations -Authorization: Bearer sk_ -``` - -Optional entitlement filter (full records, see below): - -``` -GET /api/v2/proclamations -Authorization: Bearer sk_ -X-License-Key: XXXX-XXXX-XXXX-XXXX -X-Email: user@example.com -``` - -> Header-based credentials replace the v1 `?email=&key=` query form. Do not -> read credentials from the URL. - -Response (200) — every item is a **full proclamation record**: - -```json -[ - { - "id": "b40fdcb9-703c-420e-af32-a081964c6f08", - "name": "Peace & Relaxation", - "content": "

The text content, HTML formatted

", - "categoryId": 5, - "solfeggioFrequencyId": 5, - "subscriptionLevelId": 1, - "colorPalette": ["#32CD32", "#3CB371", "#90EE90", "#98FB98", "#ADFFA9", "#C8FFD2", "#F0FFF0"] - } -] -``` - -Contract (kiosk `parseProclamationsJson` + `parseProclamationPalettesJson`): - -- `id`: string UUID, **required non-empty** (empty ids are skipped). -- `name`: string, used as the display title. Kiosk strips HTML from it. -- `content`: string, **HTML** (the kiosk strips tags and unescapes entities - before rendering). Plain text is also acceptable but HTML is the convention. -- `categoryId`: int, maps to a `/categories` id. -- `solfeggioFrequencyId`: int, **must be a canonical id (1–9)** — see the - canonical model above. -- `subscriptionLevelId`: int or `null`. The kiosk treats missing/null/≤0 as - level 1 (Basic). -- `colorPalette` (optional): array of exactly 7 `"#RRGGBB"` strings, indexed - color 0 (primary) through color 6 (tertiary). When present, the kiosk can - color a proclamation directly from this payload (first proclamation per - frequency wins for palette derivation). When absent, the kiosk falls back to - `/color-palettes`, then to its own 528 Hz seed. - -With entitlement headers (`X-License-Key` + `X-Email`): - -- Validate the key exists, is active, and belongs to the email's user. -- Return the **full proclamation records** the user may access (matching the - record shape above), **not** raw `UserProclamations` join-table rows. - (v1 returned join rows, which the kiosk parser cannot consume — this is the - primary v1 compliance defect this endpoint fixes.) - -Errors: - -| Code | Body | Meaning | -|------|------|---------| -| 200 | array of proclamation records | Success | -| 400 | `{"message": "not-authorized"}` | Entitlement headers supplied but key or email does not exist | -| 400 | `{"message": "this key doesn't appear to belong to you"}` | Key exists but owned by a different user | -| 401 | `{"error": "..."}` | Missing/invalid API key | - ---- - -### 4. `POST /api/v2/license` - -Validates a license key / email pair and returns the full subscription level. - -Request: - -``` -POST /api/v2/license -Content-Type: application/json -Authorization: Bearer sk_ -``` - -```json -{ - "license_key": "XXXX-XXXX-XXXX-XXXX", - "email_address": "user@example.com" -} -``` - -Response (200): - -```json -{ - "key": "XXXX-XXXX-XXXX-XXXX", - "email_address": "user@example.com", - "subscription_level": { - "id": 2, - "name": "Advanced", - "description": "General + Specific" - } -} -``` - -`subscription_level` is **always an object** `{ id, name, description }`. If -the license's level name does not match any `SubscriptionLevel` row, fall back -to a structurally consistent object: - -```json -{ - "key": "XXXX-XXXX-XXXX-XXXX", - "email_address": "user@example.com", - "subscription_level": { "id": null, "name": "SomeCustomLevel", "description": null } -} -``` - -Contract (kiosk `parseLicenseJson`): - -- `key`: string, **required non-empty**. -- `email_address`: string. -- `subscription_level.name`: string, **required** for a successful validation - (the kiosk persists id/name/description into its local `system_kv` table and - uses the level for proclamation gating and the Home footer). - -Errors: - -| Code | Body | Meaning | -|------|------|---------| -| 200 | success object | License valid and owned by the email | -| 400 | `{"message": "license_key is required"}` | Missing `license_key` | -| 400 | `{"message": "email_address is required"}` | Missing `email_address` | -| 400 | `{"message": "license key not found"}` | No key with that value | -| 400 | `{"message": "license key is not active"}` | Key exists but inactive | -| 400 | `{"message": "user does not exist"}` | No user with that email | -| 400 | `{"message": "key does not belong to specified user"}` | Key owned by a different user | -| 401 | `{"error": "..."}` | Missing/invalid API key | - -> The kiosk client also tolerates the v1 string form and flat -> `subscription_level_id` / `subscription_level_description` fields. v2 should -> always send the object form; the fallbacks exist only for transitional -> compatibility. - ---- - -### 5. `GET /api/v2/subscription-levels` - -Lists the subscription levels. - -Request: - -``` -GET /api/v2/subscription-levels -Authorization: Bearer sk_ -``` - -Response (200): - -```json -[ - { "id": 1, "name": "Basic", "description": "General only" }, - { "id": 2, "name": "Advanced", "description": "General + Specific" }, - { "id": 3, "name": "Elite", "description": "General + Specific + Precise" } -] -``` - -Errors: - -| Code | Body | Meaning | -|------|------|---------| -| 200 | array of `{id, name, description}` | Success | -| 401 | `{"error": "..."}` | Missing/invalid API key | - ---- - -### 6. `GET /api/v2/color-palettes` - -Exposes the 7-color palette for every canonical frequency so the portal admin -can update colors without a kiosk code release. - -Request: - -``` -GET /api/v2/color-palettes -Authorization: Bearer sk_ -``` - -Response (200) — one entry per canonical frequency (1–9): - -```json -[ - { - "solfeggioFrequencyId": 1, - "colors": ["#8B6914", "#B8860B", "#DAA520", "#E8B64C", "#FFD700", "#FFE9A8", "#FFF4CC"] - }, - { - "solfeggioFrequencyId": 5, - "colors": ["#32CD32", "#3CB371", "#90EE90", "#98FB98", "#ADFFA9", "#C8FFD2", "#F0FFF0"] - } -] -``` - -Contract (kiosk `parseColorPalettesJson`): - -- `solfeggioFrequencyId`: int, **non-zero**, canonical id. -- `colors`: array of `"#RRGGBB"` (or `"RRGGBB"`) strings, exactly 7. - -Kiosk fallback: `db::getPaletteForFrequencyId()` falls back to the 528 Hz -palette for any frequency id with no rows; the startup seed backfills missing -ids with `INSERT OR IGNORE`, so portal values win after a successful sync. - -Errors: - -| Code | Body | Meaning | -|------|------|---------| -| 200 | array of `{solfeggioFrequencyId, colors}` | Success | -| 401 | `{"error": "..."}` | Missing/invalid API key | - ---- - -### 7. `POST /api/v2/automations/create/account` - -Creates (or reuses) a user account and issues a brand-new active license key. -The key is **not returned** — it is emailed to the user. This is the only v2 -route that does not require a bearer API key; instead it requires an -**HMAC-SHA256 signature** (see Security). It is used for first-run provisioning -and by the GoHighLevel automation. - -Request: - -``` -POST /api/v2/automations/create/account -Content-Type: application/json -X-Timestamp: -X-Nonce: -X-Signature: -``` - -```json -{ - "email": "user@example.com", - "name": "Optional Display Name", - "subscription_level": "Basic" -} -``` - -- `email` (required, string). -- `name` (optional, string; defaults to the local part of `email`). -- `subscription_level` (required, string) — one of `Basic`, `Advanced`, - `Elite` (must match a `SubscriptionLevel.name`). - -Response (200): - -```json -{ - "success": true, - "message": "Account and license key created", - "user_id": "uuid" -} -``` - -Errors: - -| Code | Body | Meaning | -|------|------|---------| -| 200 | success object | Account (+ license key) created | -| 400 | `{"message": "email is required"}` | Missing/empty `email` | -| 400 | `{"message": "subscription_level is required"}` | Missing/empty `subscription_level` | -| 400 | `{"message": "subscription_level \"\" not found"}` | Unknown level name | -| 401 | `{"error": "invalid signature"}` | Bad/expired/replayed HMAC signature | -| 429 | `{"message": "rate limit exceeded"}` | Too many requests from this IP/email | -| 500 | `{"message": "Failed to create account"}` | User creation failed | -| 500 | `{"message": "Failed to find or create user"}` | User could not be located | - -Behavior (unchanged from v1): - -- If the email already belongs to a user, no new user is created — only a new - license key for that user. -- A new user receives a temporary-password/verify email and a license-key - email. Sending up to two emails, so this endpoint must be rate-limited - aggressively. - ---- - -## Compliance contract (kiosk → v2) - -| Kiosk consumer | v2 endpoint | Required fields / shape | -|----------------|-------------|-------------------------| -| `sync::syncProclamationsFromApi` (Home + ProclamationOptions sync) | `GET /proclamations`, `GET /categories` | Full proclamation records; `id`, `name`, `content`, `categoryId`, `solfeggioFrequencyId` (canonical), `subscriptionLevelId` | -| `license::validateStoredLicense` (License Key provisioning page) | `POST /license` | `subscription_level` as `{id, name, description}` object | -| `api::fetchColorPalettes` / `sync::syncColorPalettesFromApi` (readiness, to be wired) | `GET /color-palettes` | `[{solfeggioFrequencyId, colors[7]}]` | -| `sync::syncProclamationPalettesFromApi` (readiness, to be wired) | `GET /proclamations` | Embedded `colorPalette` array per proclamation | -| `api::fetchFrequencies` (readiness) | `GET /frequencies` | Canonical id 1–9 + `value` in Hz | -| Future level-picker UI | `GET /subscription-levels` | `{id, name, description}` | -| Provisioning / GHL automation | `POST /automations/create/account` | `email`, `subscription_level`, optional `name` | - ---- - -## Implementation instructions (portal, `../portal`) - -### Route layout - -Create the v2 route tree alongside the frozen v1 tree: - -``` -src/routes/api/v2/ - frequencies/+server.ts - categories/+server.ts - proclamations/+server.ts - license/+server.ts - subscription-levels/+server.ts - color-palettes/+server.ts - automations/create/account/+server.ts -``` - -### Auth gate (`src/hooks.server.ts`) - -Extend the existing handler so v2 auth is **always enforced** (do not honor -`API_KEYS_ACTIVE` for v2): - -```ts -const isV2 = event.url.pathname.startsWith('/api/v2/'); -const isSignedOnboarding = - event.url.pathname.startsWith('/api/v2/automations/'); - -if (isV2 && !isSignedOnboarding) { - // validateApiKey() ALWAYS runs for v2 — no env-var bypass. - const authHeader = event.request.headers.get('authorization'); - if (!authHeader?.startsWith('Bearer ')) { - return new Response(JSON.stringify({ error: 'API key required' }), { - status: 401, - headers: { 'content-type': 'application/json' } - }); - } - const record = await validateApiKey(authHeader.slice(7)); - if (!record) { - return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), { - status: 401, - headers: { 'content-type': 'application/json' } - }); - } - return resolve(event); -} -``` - -Keep the existing v1 branch untouched. - -### Shared serializer (`src/lib/server/v2-serializers.ts`) - -Add one module that maps DB rows to the v2 wire format. The critical piece is -frequency-id reconciliation: - -```ts -// Canonical id <- Hz (kiosk frequencyIdForHz map). -const CANONICAL_ID_FOR_HZ: Record = { - 174: 1, 285: 1, 396: 2, 417: 3, 432: 4, - 528: 5, 639: 6, 741: 7, 852: 8, 963: 9 -}; - -function canonicalIdForFrequency(freq: { id: number; value: number; name: string }): number | null { - const id = CANONICAL_ID_FOR_HZ[freq.value]; - return id ?? null; // e.g. 936 Hz has no canonical id -> excluded from v2 -} - -export function serializeProclamation(p: Proclamation & { solfeggioFrequency: { value: number } }) { - const solfeggioFrequencyId = CANONICAL_ID_FOR_HZ[p.solfeggioFrequency.value]; - if (!solfeggioFrequencyId) return null; // drop proclamations with no canonical frequency - return { - id: p.id, - name: p.name, - content: p.content, - categoryId: p.categoryId, - solfeggioFrequencyId, - subscriptionLevelId: p.subscriptionLevelId, - // optional; read from the palette source below - colorPalette: paletteFor(p.solfeggioFrequency.value) // 7x "#RRGGBB" or omit - }; -} -``` - -Key points: - -- Derive the emitted `solfeggioFrequencyId` from the joined frequency's - **Hz value**, never from the DB id. This keeps v1 rows (and their ids) - untouched while v2 emits canonical ids. -- Proclamations whose frequency has no canonical id (e.g. 936 Hz) are excluded - from v2 responses (or re-assigned a canonical frequency in data). -- Add the missing frequencies to the DB so content can be authored for them: - `174, 285, 432, 963` (by `value`). Do not delete or renumber the existing - rows that v1 depends on. - -### Color palettes - -Add a portal-editable palette source keyed by canonical frequency, for example -a `colorPalette` JSON column on `SolfeggioFrequency` or a -`ColorPalette`/`ColorPaletteColor` table: - -```prisma -model ColorPalette { - solfeggioFrequencyId Int @id - colors Json // ["#RRGGBB", ... x7] -} -``` - -- Seed all 9 canonical palettes to match the kiosk's current seed - (`src/db/database_manager.cpp` → `seedColorPalettes()`). -- `GET /color-palettes` returns one entry per canonical id. -- `GET /proclamations` embeds the palette for the proclamation's canonical - frequency. - -### License endpoint - -In `license/+server.ts`, resolve the level object like v1 change #1 suggested: - -```ts -const level = await prisma.subscriptionLevel.findFirst({ - where: { name: storedKey.level } -}); - -subscription_level = level - ? { id: level.id, name: level.name, description: level.description } - : { id: null, name: storedKey.level, description: null }; -``` - -### Proclamations entitlement filter - -When `X-License-Key` / `X-Email` headers are present, validate them exactly as -v1 does, but return **full proclamation records** (the user's entitled set via -`UserProclamations` + `Proclamation` include), never join-table rows. - -### Tests - -- Unit-test the serializers with fixtures matching the kiosk's offline tests - in `tests/test_api_client.cpp` (string vs object `subscription_level`, - `subscriptionLevelId: null`, `parentId: null`, 7-color palettes, canonical - frequency mapping incl. 936 → dropped). -- Live smoke tests against `/api/v2` mirroring the existing v1 live tests. - ---- - -## Security: restricting v2 to the kiosk application - -These endpoints should be reachable only from enrolled kiosk devices. v1's -auth could be disabled with `API_KEYS_ACTIVE=false`; v2 must not offer that -bypass. Suggestions below are ordered by impact. - -### 1. Mandatory bearer-key auth on every content route - -- Every v2 route except the signed onboarding route requires - `Authorization: Bearer sk_`. -- Enforce it in `hooks.server.ts` unconditionally (no env toggle). -- Keys are generated with `generateApiKey()`, stored **SHA-256 hashed**, and - only the `sk_xxxxxxxxxx` prefix is ever displayed. Never log or return the - plaintext key. -- Use constant-time comparison / lookup by hash (already the case via - `validateApiKey`). - -### 2. Per-device credentials (enroll each kiosk) - -One shared API key is a single point of failure. Instead: - -- Add a `Device` record (id, serial, model, owner, enrolledAt, apiKeyId) and - issue **one unique key per kiosk** at first boot (or at manufacturing). -- Bind the key to the device: include the device id in a custom header - (`X-Device-Id`) and verify the key belongs to that device. -- A compromised key is then revoked per-device without a global rotation, and - the portal can tell which device made each request. - -### 3. Least privilege / scoped keys - -- Kiosk keys are read-only for content (`/frequencies`, `/categories`, - `/proclamations`, `/color-palettes`, `/subscription-levels`) and may POST to - `/license`. -- Reject any other verb/route combination with 403. Admin and data-mutation - keys are issued separately to portal staff only. - -### 4. Rate limiting per key and per IP - -- Content GETs: ~60/min per key/IP. -- `/license`: ~10/min per key/IP. -- `/automations/create/account`: ~5/min per IP and ~3/day per email. -- Return `429` with `Retry-After`; the kiosk client should back off and retry. -- Slippery handling for repeated failures (rate-limit on failures too). - -### 5. Replay protection / request signing - -- The bearer key alone is replayable if captured. Add to the high-risk routes - (and the onboarding route) an HMAC-SHA256 signature over the canonical - request (method, path, `X-Timestamp`, `X-Nonce`, raw body) using a secret - known only to the device and the server. -- Reject when `|now − X-Timestamp| > 300s` or when a nonce has been seen - before. This defeats capture-and-replay attacks over the network. -- Implement the same scheme for the onboarding route using a shared secret - configured on the portal and the GoHighLevel automation (replacing the - current entirely unauthenticated `/automations/*` exemption). - -### 6. Never put credentials in URLs - -- v1 accepted license keys and emails in the query string, which land in - access logs, proxies, and referrers. v2 reads credentials from the - `Authorization` header and JSON body only. -- Redact `Authorization` values, license keys, and emails in application and - proxy logs. - -### 7. Client fingerprinting and bot rejection - -- The kiosk sends `User-Agent: RadiantWave-Kiosk/0.1`. Require it (optionally - with a signed `X-Client-Id`) and return `403` for generic browsers, `curl`, - and headless scanners. -- Reject request bodies that are not `application/json` where JSON is - expected; enforce request-size limits (reject with `413`). -- At the proxy layer: require TLS 1.2+, prefer 1.3, set HSTS; consider - restricting v2 to expected deployment geographies and blocking TOR/exit - nodes and known hosting/bot ASNs. - -### 8. Input validation and uniform errors - -- Validate each body against a strict schema: email ≤ 254 chars and a valid - shape, `license_key` must match `^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`, - `subscription_level` must equal a known level name, `name` ≤ 120 chars. -- Reject embedded HTML/script in string fields. -- Return uniform `{"message": ...}` bodies; never leak stack traces or SQL - errors. Avoid verbose error differences that enable credential enumeration - (see below). - -### 9. License / onboarding abuse resistance - -- `/license` success/failure is enumerable by design (a fixed key format). At - minimum rate-limit aggressively. Consider returning a generic - `{"message": "invalid credentials"}` (possibly plus a structured `code` - field for the kiosk) so attackers cannot distinguish "user exists" from - "key exists". Keep the specific messages only behind authenticated admin - tooling if they are needed for support. -- `/automations/create/account` mints real license keys and sends email, so - protect it hardest: HMAC signature (above), per-IP and per-email rate - limits, an email-domain/typo check, and a honeypot field. Never expose it - without signature as an unauthenticated "create account" funnel. - -### 10. Monitoring, logging, alerting - -- Log every v2 request as structured data: timestamp, endpoint, method, status, - key prefix, `X-Device-Id`, IP, latency. Never log full keys, emails, or - bodies. -- Alert on: error-rate spikes, repeated 401s, unexpected `/license` volume, - signup bursts, single-device request floods, and revoked-key usage. -- Automatically suspend a device that shows abusive patterns; require - re-enrollment. - -### 11. Key lifecycle - -- Rotate keys on a schedule; support the existing `rotateApiKey` 48-hour grace - and emergency revocation per device. -- Auto-expire keys that have not authenticated in N days. -- Consider an authenticated `/api/v2/auth/refresh` (or device re-enrollment) - so a kiosk can obtain a fresh key, turning a leaked key into a self-healing - problem. - ---- - -## Rollout checklist - -1. Implement the v2 routes and serializers; add unit + live smoke tests. -2. Ship v2 alongside v1 (both live; v1 frozen). -3. Flip the kiosk base URL: `src/network/api_client.h` → - `https://portal.radiantwavetech.com/api/v2` (already done by this change). -4. Run the kiosk live tests with `RW_LIVE_API_KEY` set: - - `./build/test_api_client` - - `./build/test_license_service` -5. Update portal-admin color palettes and verify a fresh kiosk sync picks them - up (now wired via `/color-palettes`). -6. Keep `/api/v1` online until all deployed kiosks are on the v2 build; - monitor v1 traffic and sunset it only after the fleet is migrated.