757 lines
26 KiB
Markdown
757 lines
26 KiB
Markdown
# 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):
|
||
|
||
```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_<hex>
|
||
```
|
||
|
||
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_<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**:
|
||
|
||
```json
|
||
[
|
||
{
|
||
"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>
|
||
```
|
||
|
||
```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_<hex>
|
||
```
|
||
|
||
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_<hex>
|
||
```
|
||
|
||
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: <unix seconds>
|
||
X-Nonce: <random uuid>
|
||
X-Signature: <hex HMAC-SHA256 of canonical request with the onboarding secret>
|
||
```
|
||
|
||
```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 \"<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):
|
||
|
||
```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<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:
|
||
|
||
```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_<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.
|