initial commit
This commit is contained in:
@@ -0,0 +1,756 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user