Files
portal/portal_api_v2_schema.md
T
2026-08-07 14:06:20 -05:00

26 KiB
Raw Blame History

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_<hex> 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_<hex>

Response (200):

[
  { "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_<hex>

Response (200):

[
  { "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_<hex>

Optional entitlement filter (full records, see below):

GET /api/v2/proclamations
Authorization: Bearer sk_<hex>
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:

[
  {
    "id": "b40fdcb9-703c-420e-af32-a081964c6f08",
    "name": "Peace & Relaxation",
    "content": "<p>The text content, HTML formatted</p>",
    "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_<hex>
{
  "license_key": "XXXX-XXXX-XXXX-XXXX",
  "email_address": "user@example.com"
}

Response (200):

{
  "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:

{
  "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_<hex>

Response (200):

[
  { "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_<hex>

Response (200) — one entry per canonical frequency (1–9):

[
  {
    "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: <unix seconds>
X-Nonce: <random uuid>
X-Signature: <hex HMAC-SHA256 of canonical request with the onboarding secret>
{
  "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):

{
  "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 \"<name>\" 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):

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:

// Canonical id <- Hz (kiosk frequencyIdForHz map).
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
};

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:

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:

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_<hex>.
  • 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.