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
This commit is contained in:
@@ -97,9 +97,9 @@ Use narrower commands when appropriate.
|
|||||||
|
|
||||||
If validation fails:
|
If validation fails:
|
||||||
|
|
||||||
* Diagnose the failure.
|
- Diagnose the failure.
|
||||||
* Fix the issue.
|
- Fix the issue.
|
||||||
* Run validation again.
|
- Run validation again.
|
||||||
|
|
||||||
Never finish with known failures.
|
Never finish with known failures.
|
||||||
|
|
||||||
@@ -113,15 +113,15 @@ portal.Rhemawavetech.com
|
|||||||
|
|
||||||
Stack:
|
Stack:
|
||||||
|
|
||||||
* SvelteKit 2
|
- SvelteKit 2
|
||||||
* Svelte 5
|
- Svelte 5
|
||||||
* TypeScript
|
- TypeScript
|
||||||
* Tailwind CSS v4
|
- Tailwind CSS v4
|
||||||
* Prisma 7
|
- Prisma 7
|
||||||
* PostgreSQL
|
- PostgreSQL
|
||||||
* Better Auth
|
- Better Auth
|
||||||
* Vitest
|
- Vitest
|
||||||
* Playwright
|
- Playwright
|
||||||
|
|
||||||
Package manager:
|
Package manager:
|
||||||
|
|
||||||
@@ -266,9 +266,9 @@ AdminPage.svelte
|
|||||||
|
|
||||||
Registry metadata exists for:
|
Registry metadata exists for:
|
||||||
|
|
||||||
* columns
|
- columns
|
||||||
* searches
|
- searches
|
||||||
* relations
|
- relations
|
||||||
|
|
||||||
Forms are manually implemented per route.
|
Forms are manually implemented per route.
|
||||||
|
|
||||||
@@ -309,8 +309,8 @@ Only load that file when modifying Svelte components or Svelte logic.
|
|||||||
|
|
||||||
Testing uses:
|
Testing uses:
|
||||||
|
|
||||||
* Vitest
|
- Vitest
|
||||||
* Playwright browser testing
|
- Playwright browser testing
|
||||||
|
|
||||||
For testing patterns, read:
|
For testing patterns, read:
|
||||||
|
|
||||||
@@ -338,10 +338,10 @@ Only load that file when working on API functionality.
|
|||||||
|
|
||||||
Prettier rules:
|
Prettier rules:
|
||||||
|
|
||||||
* tabs
|
- tabs
|
||||||
* single quotes
|
- single quotes
|
||||||
* no trailing commas
|
- no trailing commas
|
||||||
* 80 character print width
|
- 80 character print width
|
||||||
|
|
||||||
Follow existing formatting.
|
Follow existing formatting.
|
||||||
|
|
||||||
@@ -391,17 +391,14 @@ Confirm:
|
|||||||
2. Relevant tests/checks were run.
|
2. Relevant tests/checks were run.
|
||||||
3. Failures were fixed.
|
3. Failures were fixed.
|
||||||
4. The final response explains:
|
4. The final response explains:
|
||||||
|
- what changed
|
||||||
* what changed
|
- files modified
|
||||||
* files modified
|
- verification performed
|
||||||
* verification performed
|
|
||||||
|
|
||||||
Do not stop at:
|
Do not stop at:
|
||||||
|
|
||||||
* "I found the issue"
|
- "I found the issue"
|
||||||
* "Here is a plan"
|
- "Here is a plan"
|
||||||
* "The next step would be"
|
- "The next step would be"
|
||||||
|
|
||||||
Finish the work.
|
Finish the work.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ Variables:
|
|||||||
- verifyUrl
|
- verifyUrl
|
||||||
- loginUrl
|
- loginUrl
|
||||||
|
|
||||||
|
|
||||||
license-key-delivery
|
license-key-delivery
|
||||||
|
|
||||||
Variables:
|
Variables:
|
||||||
@@ -161,7 +160,6 @@ Variables:
|
|||||||
- firstName
|
- firstName
|
||||||
- license_key
|
- license_key
|
||||||
|
|
||||||
|
|
||||||
email-verification-email
|
email-verification-email
|
||||||
|
|
||||||
Variables:
|
Variables:
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ This can fail to update correctly.
|
|||||||
Use:
|
Use:
|
||||||
|
|
||||||
<select onchange={(e) => frequency = e.currentTarget.value}>
|
<select onchange={(e) => frequency = e.currentTarget.value}>
|
||||||
|
|
||||||
<option value="1" selected={frequency === '1'}>
|
<option value="1" selected={frequency === '1'}>
|
||||||
One
|
One
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ Tests are colocated with source files.
|
|||||||
|
|
||||||
Client tests:
|
Client tests:
|
||||||
|
|
||||||
*.svelte.test.ts
|
_.svelte.test.ts
|
||||||
*.svelte.spec.ts
|
_.svelte.spec.ts
|
||||||
|
|
||||||
Server tests:
|
Server tests:
|
||||||
|
|
||||||
*.test.ts
|
_.test.ts
|
||||||
*.spec.ts
|
_.spec.ts
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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: <p>The text content</p>
|
||||||
|
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: <p>The text content</p>
|
||||||
|
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.
|
||||||
@@ -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_<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