Remove subscription levels; license valid = subscribed

This commit is contained in:
NeilClack
2026-08-15 00:54:24 -05:00
parent 8ca60de647
commit f14bf3e29a
19 changed files with 136 additions and 1534 deletions
+3 -2
View File
@@ -111,8 +111,9 @@ suite and need the `RWT_LIVE_*` secrets to pass.
provisioning flags. No globals — pass `AppState&` around.
- Device identity/state lives in the SQLite `system_kv` table. Canonical keys
are in `src/db/db_keys.h`: `email_address`, `license_key`,
`subscription_level_id/name/description`, `license_last_verified` (7-day
offline grace window for a known-good license).
`license_last_verified` (7-day offline grace window for a known-good license).
A successful `validateStoredLicense` is the "subscribed" flag — the license
is either active (valid) or inactive; there are no subscription levels.
- `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires
`onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read
from it.
-575
View File
@@ -1,575 +0,0 @@
# RadiantWave Kiosk — User Manual (Reference Draft)
> **Reference edition.** Every keybind, navigation path, screen behavior, and
> status message below is derived directly from the application source code.
> Use this as the checklist when writing the polished end-user manual.
**Input device:** physical keyboard only. No mouse or touchscreen. The cursor
is hidden. Text input uses the on-screen text-entry model (SDL text input).
---
## 0. Screen Map (UX Flow)
```
Power on
└─ First-Run Walkthrough (if a prerequisite is missing)
1. Internet reachable? no → WiFi Setup
2. Email address set? no → Email Address
3. License key set? no → License Key
4. All satisfied → Home
└─ Home
├─ [Play] ────────────────────────────→ Scroller ──(ESC)──→ Home
└─ [Settings] ────────────────────────→ Settings
├─ WiFi Setup (ESC/TAB → Settings)
├─ Audio Options (ESC/TAB → Settings)
├─ Time Zone Setup (ESC/TAB → Settings)
├─ Proclamations (ESC → Settings) [license-gated]
├─ Email Address (ESC/TAB → Settings)
├─ License Key (ESC/TAB → Settings)
├─ Date Format (ESC/TAB → Settings)
├─ View System Logs (ESC/TAB → Settings)
└─ Back to Home → Home
```
When **Play** or **Proclamations** is selected but the license cannot be
verified, a **License Blocked** screen appears instead (ESC → Home).
The **Home** screen is the root of the whole UI. **TAB** is intended to return
to Home from anywhere (see the caveat in §12).
---
## 1. Global Controls
These binds are registered at startup (`main.cpp`) and are the only binds not
owned by a specific screen.
| Key | Action | Where it actually works |
| :--- | :--- | :--- |
| **`TAB`** | Return to the Home screen (pops every page on the stack and pushes a fresh Home). | Home; most screens also handle TAB locally (see per-screen tables) |
| **`UP` / `DOWN`** | Move the Home menu selection. **Both keys increment the same counter** — there is no separate "down" behavior; either key cycles Play ↔ Settings. | Home |
> **No key quits the application.** There is intentionally no quit keybind;
> the app is meant to run as a kiosk. (The underlying SDL window/system close
> path still exists, but no keyboard key triggers it.)
**Per-screen binds are checked before the screen sees key events.** A keybind
registered on the current screen is consumed by the keybind system; keys not
bound there fall through to the screen's own event handler.
---
## 2. Boot & First-Run Walkthrough
On every boot the app checks three prerequisites in a fixed order and shows the
**first** missing one (`provisioning.h`):
| # | Prerequisite | Screen shown if missing |
| :--- | :--- | :--- |
| 1 | Internet reachable (real connectivity probe — Ethernet, WiFi, or any OS link) | WiFi Setup |
| 2 | Email address saved | Email Address |
| 3 | License key saved | License Key |
| 4 | — | Home |
Walkthrough screens call "advance" when they finish or are backed out of, which
pops the current step and re-evaluates. Completing the WiFi page does **not**
re-run the internet probe (cached); the other steps only read the DB.
- A brand-new device (no internet, no email, no license) also raises an
internal `firstRunWalkthrough` flag (a future welcome walkthrough; today it
is only set and logged).
- If the database failed to open, the walkthrough is skipped entirely and the
app goes straight to Home.
- On the WiFi page during the walkthrough, once the internet becomes reachable
the page waits ~1.5 s ("Internet connected. Continuing setup...") and then
advances automatically.
- On the Email and License pages, after a successful save the page waits ~1 s
and then advances automatically.
---
## 3. Home
**Reached from:** app start, TAB, ESC on License Blocked, "Back to Home" in
Settings, ESC on the Scroller.
**Screen contents:**
- Centered "Radiant Wave" logo title.
- Two menu items: **Play** and **Settings** (white underline marks the
selection).
- Bottom-right status footer (see §11).
**Keybinds:**
| Key | Action |
| :--- | :--- |
| **`UP`** | Cycle menu selection (Play → Settings → Play). |
| **`DOWN`** | Same as UP — cycles the selection the same way. |
| **`RETURN`** / **`ENTER`** | Activate the highlighted item. |
| **`TAB`** | Re-push Home (no visible change) (until a page is pushed on top of Home). |
> The **UP** and **DOWN** keys are wired identically (both increment the
> selection), so either arrow moves the highlight the same way; there is no
> dedicated "down" behavior. There is **no quit key** on Home.
**Behavior:**
- **Play** (item 0) with a confirmed-valid license → **Scroller**.
- **Play** with an invalid/unverifiable license → **License Blocked**.
- **Settings** (item 1) → replaces Home with **Settings**.
- The internet status refreshes automatically every 5 seconds while Home is
shown. Play only unlocks when the license is valid (validated in the
background; result shown in the footer).
---
## 4. Scroller (Play)
**Reached from:** Home → Play (license-gated).
**Screen contents:** full-screen, vertically scrolling proclamation text.
Proclamation text is never rendered as readable type — each glyph is permuted
into a scrambled tile by the Fibonacci affine map, so letterforms are
destroyed on screen. Lines follow the Fibonacci line-length pattern; colors
come from per-frequency palettes; audio crossfades between streams.
**Keybinds:**
| Key | Action |
| :--- | :--- |
| **`ESC`** | Exit back to Home. |
No other keys do anything on this screen. Scrolling speed ramps automatically
(up and down) with pauses at each extreme; there is no user speed control.
**Behavior:**
- Plays audio with a 5 s fade-in; on exit fades out over ~5 s.
- Dynamic Audio mode: the current stream's solfeggio-frequency track is
crossfaded as proclamations change. Fixed-track mode: the configured track
plays and never switches.
- If a stream's audio file is not on disk yet, playback falls back to the
manual track rather than silence.
---
## 5. Settings
**Reached from:** Home → Settings. (Home is replaced, not stacked.)
**Screen contents:** title "Settings" and a 9-item menu:
| # | Item | Opens | License-gated? |
| :--- | :--- | :--- | :--- |
| 0 | WiFi Setup | WiFi Setup screen | no |
| 1 | Audio Options | Audio Options screen | no |
| 2 | Time Zone Setup | Time Zone Setup screen | no |
| 3 | Proclamations | Proclamation Options | **yes** (else License Blocked) |
| 4 | Email Address | Email Address screen | no |
| 5 | License Key | License Key screen | no |
| 6 | Date Format | Date Format screen | no |
| 7 | View System Logs | System Log Viewer | no |
| 8 | Back to Home | Home | — |
**Keybinds:**
| Key | Action |
| :--- | :--- |
| **`UP`** | Move selection up (wraps around). |
| **`DOWN`** | Move selection down (wraps around). |
| **`RETURN`** / **`ENTER`** | Open the highlighted item. |
| **`ESC`** | Return to Home (replaces Settings). |
| **`TAB`** | Return to Home. |
---
## 6. WiFi Setup
**Reached from:** Settings → WiFi Setup, or the First-Run walkthrough.
**Screen contents:**
- Title "WiFi Configuration".
- Status row: **WiFi: Connected/Disconnected** (green/red), **Network:
\<SSID\>/None**, **Internet: Reachable/Unreachable** (green/red).
- Status message banner (blue-bordered): "Scanning for available
networks...", "Connecting to \<SSID\>...", "Connected successfully!",
"Connection failed: \<reason\>", "Password entry cancelled", etc.
- Network list (50 px rows). Color coding:
- 🟢 **GREEN** — currently connected network.
- 🟡 **YELLOW** — previously saved/remembered network.
- ⚪ **WHITE** — new/unknown network.
- Each row also shows a security badge `[Open]/[WPA/...]` and a signal
strength percentage.
- Empty state: "No WiFi networks found. Press F5 to scan."
- Footer: `[UP/DOWN] Select [ENTER] Connect [F5] Rescan Networks [ESC/TAB] Back`.
**Keybinds (normal mode):**
| Key | Action |
| :--- | :--- |
| **`UP`** | Select previous network. |
| **`DOWN`** | Select next network. |
| **`RETURN`** / **`ENTER`** | Connect. Open network → connects immediately. Secured network → opens the password box. |
| **`F5`** | Re-scan for networks. |
| **`ESC`** | Back (Settings, or walkthrough advance). |
| **`TAB`** | Back (same as ESC). |
**Password box (modal):**
| Key | Action |
| :--- | :--- |
| **type any keys** | Type the password (shown in plain text, unmasked, with a cursor). |
| **`BACKSPACE`** | Delete the last character. |
| **`RETURN`** / **`ENTER`** | Submit password and connect. |
| **`ESC`** | Cancel password entry (returns to the network list). |
Password modal footer: `[ENTER] Submit Password [ESC] Cancel`.
**Behavior:**
- Connect is asynchronous. On completion: "Connected successfully!" or
"Connection failed: \<error\>" (falls back to "Authentication or signal
error").
- While a connection is in progress the list is locked.
- **Walkthrough only:** once internet is reachable, auto-advances after ~1.5 s.
---
## 7. Audio Options
**Reached from:** Settings → Audio Options.
**Screen contents:** title "Audio Configuration"; status row **Audio:
Enabled/Disabled** (green/red) and **Device: \<name\>**; status banner; 5-item
menu:
| # | Item | Interaction |
| :--- | :--- | :--- |
| 0 | Volume: `[▓▓▓▓▓▓▓▓░░] 80%` | LEFT/RIGHT step −/+ 5% (0–100, clamps). |
| 1 | Output Device: `[\<name\>]` | ENTER opens the Device selector modal. |
| 2 | Audio Track: `[\<name\>]` | ENTER opens the Track selector modal. |
| 3 | Audio Status: `[ON]`/`[OFF]` | ENTER toggles audio system-wide on/off. |
| 4 | Back to Settings | ENTER pops back to Settings. |
Footer: `[UP/DOWN] Select [LEFT/RIGHT] Volume [ENTER] Confirm [ESC/TAB] Back`.
**Keybinds (main menu):**
| Key | Action |
| :--- | :--- |
| **`UP`** | Select previous item. |
| **`DOWN`** | Select next item. |
| **`LEFT`** | Volume −5% (only when Volume is highlighted). |
| **`RIGHT`** | Volume +5% (only when Volume is highlighted). |
| **`RETURN`** / **`ENTER`** | Open device/track modal, toggle audio, or Back. |
| **`ESC`** | Back to Settings (closes a modal first if one is open). |
| **`TAB`** | Back to Settings (only when no modal is open). |
**Device selector modal** — "Select Output Device":
| Key | Action |
| :--- | :--- |
| **`UP`** / **`DOWN`** | Move selection. |
| **`PAGE UP`** / **`PAGE DOWN`** | Jump selection by 8. |
| **`RETURN`** / **`ENTER`** | Switch to the highlighted device. |
| **`ESC`** | Cancel (no change). |
**Track selector modal** — "Select Audio Track":
| Key | Action |
| :--- | :--- |
| **`UP`** / **`DOWN`** | Move selection. |
| **`PAGE UP`** / **`PAGE DOWN`** | Jump selection by 8. |
| **`RETURN`** / **`ENTER`** | Select the highlighted option. |
| **`ESC`** | Cancel (no change). |
Modal footer: `[UP/DOWN] Select [ENTER] Confirm [ESC] Cancel`.
**Behavior:**
- The track list always leads with **Dynamic Audio** (cyan; "audio follows the
displayed proclamation"), then **Original Audio** pinned first, then every
other track sorted alphabetically. Up to 8 rows visible; the list scrolls
and stays centered on the selection.
- Choosing a fixed track turns Dynamic Audio **off**. Choosing Dynamic Audio
turns it on (status: "Dynamic Audio mode enabled - audio follows the
displayed proclamation"). Choosing a track shows "Fixed track selected".
Playback itself starts when the Scroller plays, not here.
- Device and track lists refresh automatically every 1 second.
---
## 8. Time Zone Setup
**Reached from:** Settings → Time Zone Setup.
**Screen contents:** title "Time Zone Configuration"; **Configured Timezone:
\<code\>** (yellow) and a live **Local Time:** sample (green); status banner;
then either a **country list** or a **timezone list** for the chosen country.
Green rows mark the currently configured country/zone; the white underline
marks the selection.
**Country step:**
| Key | Action |
| :--- | :--- |
| **`UP`** / **`DOWN`** | Select country/region. |
| **`RETURN`** / **`ENTER`** | Show that country's timezone list ("Select timezone for \<Country\>"). |
| **`ESC`** / **`TAB`** / **`BACKSPACE`** | Back to Settings. |
**Timezone step:**
| Key | Action |
| :--- | :--- |
| **`UP`** / **`DOWN`** | Select a timezone. |
| **`RETURN`** / **`ENTER`** | Save the timezone (status "Timezone set to \<code\>") and return to the country list. |
| **`ESC`** / **`BACKSPACE`** / **`TAB`** | Back to the country list. |
Footers:
- Country: `[UP/DOWN] Select Country [ENTER] Choose [ESC/TAB] Back to Settings`
- Timezone: `[UP/DOWN] Select Timezone [ENTER] Apply Timezone [ESC/BACKSPACE] Back to Countries`
**Behavior:** the saved timezone is written to the live config immediately;
logs and the Home clock use it right away.
---
## 9. Proclamations
**Reached from:** Settings → Proclamations (license-gated; else License
Blocked).
**Screen contents:** title "Proclamation Options"; status line (sync result +
"Selected: N"); an optional **search bar** while searching; a collapsible
category tree (or a flat search-result list); footer hints.
**Colors:** categories blue; selected proclamations orange; the always-on
top-level proclamation orange; locked (above your subscription) rows flash
**red**; search text yellow.
**Keybinds (browse mode):**
| Key | Action |
| :--- | :--- |
| **`UP`** / **`DOWN`** | Move highlight. |
| **`RETURN`** / **`ENTER`** | Toggle the highlighted row: expand/collapse a category, or select/deselect a proclamation. |
| **`LEFT`** | Collapse the highlighted category; if the row is a sub-item, jump to its parent. |
| **`RIGHT`** | Expand the highlighted category. |
| **`PAGE UP`** / **`PAGE DOWN`** | Scroll by a page. |
| **`CTRL` + `D`** | Clear all selections (the top-level proclamation stays on). |
| **type any printable character** | Enter search mode. |
| **`ESC`** | Back to Settings. |
**Search mode:**
| Key | Action |
| :--- | :--- |
| **type any printable character** | Add to the search query (word-prefix match on title + content). |
| **`BACKSPACE`** | Delete the last query character; when the query empties, search mode closes. |
| **`UP`** / **`DOWN`** | Move highlight. |
| **`RETURN`** / **`ENTER`** | Toggle the highlighted result. |
| **`ESC`** | Cancel search and return to the browse tree. |
Footers:
- Browse: `[UP/DOWN] Move [ENTER] Toggle [Type to Search] [LEFT/RIGHT] Expand/Collapse [CTRL+D] Clear All [ESC] Back`
- Search: `[UP/DOWN] Move [ENTER] Select [BACKSPACE] Edit [ESC] Cancel Search`
**Behavior:**
- On open the app syncs the list with the server: "Synced with server" or
"Offline - showing saved proclamations". The "Selected: N" count on the
status line excludes the always-on top-level proclamation.
- The top-level proclamation ("Synchronize, Harmonize & Energize") is **always
on** and cannot be deselected ("The top-level proclamation is always on").
- Selecting a proclamation whose required subscription level is above the one
attached to your license key is rejected: the row flashes red and a status
line explains it's "available on \<Level\> systems".
- A bare **SPACE** does not open search and never selects anything; in search
mode SPACE types a space into the query.
- **TAB is NOT handled on this screen** — use ESC to go back.
---
## 10. Email Address, License Key, Date Format
### 10.1 Email Address
**Reached from:** Settings → Email Address, or the walkthrough.
**Screen contents:** title "Email Address"; "Saved: \<address\>" (gray = not
set, yellow = set); input box with cursor; status line; footer.
**Keybinds:**
| Key | Action |
| :--- | :--- |
| **type any keys** | Type/edit the email (up to 254 chars). |
| **`BACKSPACE`** | Delete the last character. |
| **`RETURN`** / **`ENTER`** | Save. Empty input clears the saved address ("Cleared"). Invalid address shows "Invalid email address". Valid save shows "Saved". |
| **`ESC`** | Back without saving (Settings, or walkthrough advance). |
| **`TAB`** | Back (same as ESC). |
- After a successful save/clear, the page auto-exits after ~1 s.
- Footer: `[Type] Enter email [ENTER] Save [BACKSPACE] Delete [ESC/TAB] Back`.
### 10.2 License Key
**Reached from:** Settings → License Key, or the walkthrough.
**Screen contents:** title "License Key"; "Saved: \<key\>" (gray/yellow); input
box showing the key formatted `XXXX-XXXX-XXXX-XXXX`; status line; footer.
**Keybinds:**
| Key | Action |
| :--- | :--- |
| **type any keys** | Type the key. Only alphanumerics are accepted (uppercased); max 16 characters. Dashes are added automatically for display. |
| **`BACKSPACE`** | Delete the last character. |
| **`RETURN`** / **`ENTER`** | Save and validate. |
| **`ESC`** | Back without saving (Settings, or walkthrough advance). |
| **`TAB`** | Back (same as ESC). |
**Validation statuses on ENTER:**
- Fewer than 16 characters → "License key must be 16 characters
(XXXX-XXXX-XXXX-XXXX)".
- Save failed → "Failed to save license key".
- Server validated → "License validated (\<level\>)".
- Missing email → "Saved - add your email address to validate the license".
- Invalid → "License check failed: \<reason\>".
- No network → "License saved, but the server could not be reached to
validate".
- Auto-exits after ~1 s once saved.
- Footer: `[Type] Enter key (dashes added automatically) [ENTER] Save [BACKSPACE] Delete [ESC/TAB] Back`.
### 10.3 Date Format
**Reached from:** Settings → Date Format.
**Screen contents:** title "Date Format"; "Today looks like: \<example\>"
(yellow); 7 format options each with a live sample date; status line; footer.
**Format options:** Month/Day/Year, Day/Month/Year, Year-Month-Day,
Month.Day.Year, Day.Month.Year, "Month Day, Year", "Day Month Year".
> The option currently in use is shown in yellow, the others in white; every
> option's live sample date is gray.
**Keybinds:**
| Key | Action |
| :--- | :--- |
| **`UP`** / **`DOWN`** | Choose a format. |
| **`RETURN`** / **`ENTER`** | Apply the format and return to Settings ("Date format saved"). |
| **`ESC`** | Back without saving. |
| **`TAB`** | Back (same as ESC). |
- Footer: `[UP/DOWN] Choose [ENTER] Apply & Back [ESC/TAB] Back`.
---
## 11. System Log Viewer
**Reached from:** Settings → View System Logs.
**Screen contents:** title "System Event Log Viewer"; header bar showing
**Filter: \<level\> | Sort: Newest/Oldest First | Entries: N | TZ: \<code\>**;
word-wrapped log lines (color-coded by severity) with local-time timestamps;
footer.
**Log line colors:** DEBUG gray, INFO white, WARNING yellow, ERROR red,
FATAL magenta/pink.
**Keybinds:**
| Key | Action |
| :--- | :--- |
| **`UP`** / **`DOWN`** | Scroll one line. |
| **`PAGE UP`** / **`PAGE DOWN`** | Scroll 10 lines. |
| **`L`** or **`F2`** or **`RIGHT`** | Cycle the filter level forward: ALL → DEBUG → INFO → WARNING → ERROR → FATAL. |
| **`LEFT`** | Cycle the filter level backward. |
| **`S`** or **`F3`** | Toggle sort order: Newest First ↔ Oldest First. |
| **`C`** | Clear all log history. |
| **`ESC`** | Back to Settings. |
| **`TAB`** | Back (same as ESC). |
- Footer: `[UP/DOWN] Scroll [L/F2] Filter Level [S/F3] Sort Order [C] Clear Logs [ESC/TAB] Back`.
- The log list auto-refreshes every 0.25 s while the screen is open.
---
## 12. License Blocked Screen
**Reached from:** Play or Settings → Proclamations when the license cannot be
verified.
**Screen contents:** centered message "License could not be verified",
instruction text ("Please check that you are connected to the internet, and
double check your license key and email address."), and "Press ESC to return."
**Keybinds:**
| Key | Action |
| :--- | :--- |
| **`ESC`** | Return to Home (pops every page and pushes a fresh Home). |
---
## 13. Home Status Footer (legend)
The bottom-right corner of Home shows a two-column readout:
| Field | Meaning / values |
| :--- | :--- |
| **License:** | `OK` (green) · `Invalid` (red, bold) · `Checking...` · `Unknown` |
| **Key:** | saved license key, or `Not set` |
| **Email:** | saved email, or `Not set` |
| **Subscription:** | subscription level name from the license, or `Not set` |
| **WiFi:** | current SSID, or `Not connected` |
| **Internet:** | `Connected` / `Offline` |
| **Audio:** | audio-downloader status string, or `Idle` |
| **Date:** | formatted per the selected date format |
| **Time:** | `HH:MM:SS` (24-hour) |
Internet status refreshes every 5 s while Home is shown; the license check
re-runs in the background each time Home appears.
---
## 14. Data & Configuration (background)
- **`~/.local/radiantwave/config.yaml`** — settings (audio volume, output
device, current track, dynamic audio, timezone, proclamation font size).
Saved automatically. **Hot-reloaded live**: editing it while the app runs
applies immediately without a restart.
- **`~/.local/radiantwave/radiantwave.db`** (SQLite, WAL) — email, license key,
subscription level, license last-verified timestamp, proclamation/category
data, audio file records, system log history.
- **Audio assets** (`.wav` files) are downloaded in the background on first
connect and stored under `~/.local/radiantwave/audio/`; they are not part of
the app install.
- License checks run online; a known-good license stays valid for a **7-day
offline grace window**.
---
## 15. Implementation Notes / Caveats (for the manual author)
These are behaviors visible in the source that differ from the "intended"
controls, so you can decide how to document or fix them:
1. **TAB (Home) is registered once at startup and is removed whenever any page
pauses** (every page calls `keybinds.unbindAll()` in `onPause`, and Home
only re-binds UP/DOWN in `onResume`). In practice **TAB returns Home
reliably only on the Home screen, and only until you first push a page on
top of it — popping back to Home does not restore it**. Screens that handle
TAB locally (Settings, WiFi, Audio, Email, License, Date Format, Log
Viewer) keep working; the Scroller, Proclamations, and License Blocked do
not respond to TAB.
2. **Home's UP and DOWN both move the selection the same direction** — both
call `(x+1)%2`. There is no down-only arrow behavior.
3. **Proclamation page and Scroller have no TAB handler** — only ESC returns
from the Proclamation page / Scroller.
4. **TAB on the Time Zone page behaves differently per step**: in the
country list it exits to Settings; in the timezone list it returns to the
country list.
5. The walkthrough's WiFi page auto-advances as soon as internet is reachable
(≈1.5 s), and the Email/License pages auto-advance ≈1 s after a successful
save — the walkthrough can never be completed by pressing Enter alone on a
connected device.
+75 -640
View File
@@ -1,262 +1,21 @@
# RadiantWave Portal API v2 Schema
# Portal API v2 — current schema
This document defines the **required** API surface for the RadiantWave kiosk
application and serves as the implementation contract for the portal
(`../portal`).
The kiosk talks to `https://portal.radiantwavetech.com/api/v2`. This document
describes the current contract after the move to an **active/inactive license
model**. There are no subscription levels: a license is either **valid**
(subscribed) or not, and the kiosk treats a successful `POST /license`
validation as the device's "subscribed" flag.
It supersedes `payloads.md` and `ApiUpdates.md`, which described a v1 API that
does not meet the application's requirements.
Base URL: `https://portal.radiantwavetech.com/api/v2`
Authentication: `Authorization: Bearer sk_<hex>` on every request (the API key
is baked into the kiosk binary at build time).
---
## Background and constraints
## `POST /license`
- 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.
Validates a license key / email pair.
Request:
@@ -278,34 +37,18 @@ 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 }
"email_address": "user@example.com"
}
```
Contract (kiosk `parseLicenseJson`):
- `key`: string, **required non-empty**.
- `key`: string, **required non-empty** (empty key ⇒ parse failure).
- `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).
- Any other fields (e.g. the legacy `subscription_level`) are ignored. A 2xx
response means the license is valid and the subscription is **active**; the
kiosk stores no subscription state other than the `license_last_verified`
stamp that drives its 7-day offline grace window.
Errors:
@@ -320,101 +63,89 @@ Errors:
| 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`
## `GET /proclamations`
Lists the subscription levels.
Request:
```
GET /api/v2/subscription-levels
Authorization: Bearer sk_<hex>
```
Lists every proclamation. There is **no `subscriptionLevelId`** — any validated
license may access any proclamation.
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"]
},
{
"id": "b40fdcb9-703c-420e-af32-a081964c6f08",
"name": "Peace & Relaxation",
"content": "<p>The text content, HTML formatted</p>",
"categoryId": 5,
"solfeggioFrequencyId": 5,
"colors": ["#32CD32", "#3CB371", "#90EE90", "#98FB98", "#ADFFA9", "#C8FFD2", "#F0FFF0"]
"colorPalette": ["#32CD32", "#3CB371", "#90EE90", "#98FB98", "#ADFFA9", "#C8FFD2", "#F0FFF0"]
}
]
```
Contract (kiosk `parseColorPalettesJson`):
Contract (kiosk `parseProclamationsJson` + `parseProclamationPalettesJson`):
- `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.
- `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).
- `categoryId`: int, maps to a `/categories` id.
- `solfeggioFrequencyId`: int, **must be a canonical id (1–9)**.
- `colorPalette` (optional): array of exactly 7 `"#RRGGBB"` strings. When
absent, the kiosk falls back to `/color-palettes`, then to its own 528 Hz
seed.
- Legacy `subscriptionLevelId` fields in the payload are ignored.
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | array of `{solfeggioFrequencyId, colors}` | Success |
| 200 | array of proclamation records | Success |
| 400 | `{"message": "not-authorized"}` | Entitlement headers supplied but key or email does not exist |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
### 7. `POST /api/v2/automations/create/account`
## `GET /categories`
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.
Lists the proclamation categories.
Response (200):
```json
[
{
"id": 4,
"name": "Spiritual",
"parentId": 0,
"sortOrder": 0
}
]
```
Contract (kiosk `parseCategoriesJson`):
- `id`: int, **required non-zero** (zero ids are skipped).
- `name`: string.
- `parentId`: int, `null` maps to 0 (root).
- `sortOrder`: int.
---
## `POST /automations/create/account`
Creates (or reuses) a user account and issues a license key. **No
`subscription_level` is sent** — every created license is a standard active
license.
Request:
```
POST /api/v2/automations/create/account
Content-Type: application/json
Authorization: Bearer sk_<hex>
X-Timestamp: <unix seconds>
X-Nonce: <random uuid>
X-Signature: <hex HMAC-SHA256 of canonical request with the onboarding secret>
@@ -423,15 +154,12 @@ X-Signature: <hex HMAC-SHA256 of canonical request with the onboarding secret>
```json
{
"email": "user@example.com",
"name": "Optional Display Name",
"subscription_level": "Basic"
"name": "Optional Display Name"
}
```
- `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):
@@ -449,308 +177,15 @@ Errors:
|------|------|---------|
| 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)
## Readiness routes (not yet wired into the app)
| 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` |
- `GET /color-palettes` → `[{solfeggioFrequencyId, colors[7]}]`
- `GET /frequencies` → canonical id 1–9 + `value` in Hz
---
## 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.
These exist for future sync steps; the kiosk does not call them yet.
+12 -14
View File
@@ -132,7 +132,6 @@ bool DatabaseManager::runMigrations() {
selected INTEGER DEFAULT 0,
category_id INTEGER DEFAULT 0,
solfeggio_frequency_id INTEGER DEFAULT 0,
required_level_id INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -184,8 +183,11 @@ bool DatabaseManager::runMigrations() {
ensureColumn("proclamations", "selected", "selected INTEGER DEFAULT 0");
ensureColumn("proclamations", "category_id", "category_id INTEGER DEFAULT 0");
ensureColumn("proclamations", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
ensureColumn("proclamations", "required_level_id", "required_level_id INTEGER DEFAULT 1");
ensureColumn("audio_files", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
// Clean up the pre-active/inactive-license subscription level keys.
// Existing devices may still carry them; nothing reads them anymore.
executeSql("DELETE FROM system_kv WHERE key IN "
"('subscription_level_id','subscription_level_name','subscription_level_description');");
}
if (ok) {
seedColorPalettes();
@@ -336,7 +338,6 @@ void DatabaseManager::bindProclamationRecord(sqlite3_stmt* stmt, const Proclamat
sqlite3_bind_int(stmt, 7, record.selected ? 1 : 0);
sqlite3_bind_int(stmt, 8, record.category_id);
sqlite3_bind_int(stmt, 9, record.solfeggio_frequency_id);
sqlite3_bind_int(stmt, 10, record.required_level_id);
}
bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
@@ -344,13 +345,12 @@ bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
if (!db_) return false;
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
"is_active, selected, category_id, solfeggio_frequency_id, required_level_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"is_active, selected, category_id, solfeggio_frequency_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
"is_active=excluded.is_active, selected=excluded.selected, "
"category_id=excluded.category_id, solfeggio_frequency_id=excluded.solfeggio_frequency_id, "
"required_level_id=excluded.required_level_id;";
"category_id=excluded.category_id, solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -375,7 +375,6 @@ static ProclamationRecord readProclamationRecord(sqlite3_stmt* stmt, int firstCo
rec.selected = (sqlite3_column_int(stmt, firstCol + 6) != 0);
rec.category_id = sqlite3_column_int(stmt, firstCol + 7);
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, firstCol + 8);
rec.required_level_id = sqlite3_column_int(stmt, firstCol + 9);
return rec;
}
@@ -385,7 +384,7 @@ std::vector<ProclamationRecord> DatabaseManager::getActiveProclamations() {
if (!db_) return result;
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
"selected, category_id, solfeggio_frequency_id, required_level_id "
"selected, category_id, solfeggio_frequency_id "
"FROM proclamations WHERE is_active = 1;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -500,13 +499,12 @@ bool DatabaseManager::syncProclamations(const std::vector<ProclamationRecord>& r
// Upsert each proclamation, preserving the stashed selection state.
if (ok) {
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
"is_active, selected, category_id, solfeggio_frequency_id, required_level_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"is_active, selected, category_id, solfeggio_frequency_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
"is_active=excluded.is_active, category_id=excluded.category_id, "
"solfeggio_frequency_id=excluded.solfeggio_frequency_id, "
"required_level_id=excluded.required_level_id;";
"solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -557,7 +555,7 @@ std::vector<ProclamationRecord> DatabaseManager::getAllProclamations() {
if (!db_) return result;
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
"selected, category_id, solfeggio_frequency_id, required_level_id FROM proclamations;";
"selected, category_id, solfeggio_frequency_id FROM proclamations;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return result;
-1
View File
@@ -17,7 +17,6 @@ struct ProclamationRecord {
bool selected = false;
int category_id = 0; // maps to ProclamationCategoryRecord::id
int solfeggio_frequency_id = 0; // maps to the solfeggio frequency preset
int required_level_id = 1; // minimum SubscriptionLevel id to unlock it
};
struct ProclamationCategoryRecord {
-3
View File
@@ -6,9 +6,6 @@ namespace radiantwave::db {
// device configuration lives in the local database.
constexpr const char* KV_EMAIL_ADDRESS = "email_address";
constexpr const char* KV_LICENSE_KEY = "license_key";
constexpr const char* KV_SUBSCRIPTION_LEVEL_ID = "subscription_level_id";
constexpr const char* KV_SUBSCRIPTION_LEVEL_NAME = "subscription_level_name";
constexpr const char* KV_SUBSCRIPTION_LEVEL_DESCRIPTION = "subscription_level_description";
// Unix epoch seconds of the last successful license validation against the
// portal. Used to grant a 7-day offline grace window so transient network
-11
View File
@@ -7,7 +7,6 @@
#include "date_format.h"
#include "audio/audio_downloader.h"
#include "db/db_keys.h"
#include "network/license_service.h"
#include "network/proclamation_sync.h"
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
@@ -218,20 +217,12 @@ void Home::render(SDL_Renderer *renderer)
break;
}
int subscriptionLevelId = state_
? radiantwave::license::getStoredSubscriptionLevelId(state_->db)
: 0;
std::string subscriptionValue = subscriptionLevelId > 0
? radiantwave::license::subscriptionLevelName(subscriptionLevelId)
: std::string("Not set");
time_t now = std::time(nullptr);
std::vector<std::string> names = {
"Version:",
"License:",
"Key:",
"Email:",
"Subscription:",
"WiFi:",
"Internet:",
"Audio:",
@@ -243,7 +234,6 @@ void Home::render(SDL_Renderer *renderer)
licenseValue,
license_.empty() ? std::string("Not set") : license_,
email_.empty() ? std::string("Not set") : email_,
subscriptionValue,
ssid_.empty() ? std::string("Not connected") : ssid_,
internet_ ? std::string("Connected") : std::string("Offline"),
audioStatus(),
@@ -377,7 +367,6 @@ void Home::createTextures(AppState &state)
"License:",
"Key:",
"Email:",
"Subscription:",
"WiFi:",
"Internet:",
"Audio:",
+2 -3
View File
@@ -95,14 +95,13 @@ void LicenseKeyPage::submit()
// Both the email address and the license key are required to validate.
// When the email is missing the key is saved but the check is skipped.
std::string levelName;
std::string errorMessage;
const radiantwave::license::Result result = radiantwave::license::validateStoredLicense(
state_->db, "", &levelName, &errorMessage);
state_->db, "", &errorMessage);
switch (result) {
case radiantwave::license::Result::Ok:
statusMessage_ = "License validated (" + levelName + ")";
statusMessage_ = "License validated";
break;
case radiantwave::license::Result::NotConfigured:
statusMessage_ = "Saved - add your email address to validate the license";
+6 -24
View File
@@ -189,27 +189,9 @@ bool parseLicenseJson(const std::string& body, LicenseInfo& out) {
out.key = root.stringField("key");
out.email_address = root.stringField("email_address");
// subscription_level is an object {id,name,description} in the updated API
// and a bare level-name string in the current one. Tolerate both, plus a
// flat {subscription_level, subscription_level_id, subscription_level_description}.
const radiantwave::json::JsonValue* level = root.find("subscription_level");
if (level && level->type == radiantwave::json::JsonValue::Type::Object) {
out.subscription_level.id = static_cast<int>(level->numberField("id"));
out.subscription_level.name = level->stringField("name");
out.subscription_level.description = level->stringField("description");
} else if (level && level->type == radiantwave::json::JsonValue::Type::String) {
out.subscription_level.name = level->str;
}
if (out.subscription_level.name.empty()) {
out.subscription_level.name = root.stringField("subscription_level");
}
if (out.subscription_level.id == 0) {
out.subscription_level.id = static_cast<int>(root.numberField("subscription_level_id"));
}
if (out.subscription_level.description.empty()) {
out.subscription_level.description = root.stringField("subscription_level_description");
}
// Any other fields (e.g. the legacy `subscription_level`) are ignored. A
// successful validation itself is the "subscribed" flag; the kiosk treats
// a 2xx response as an active license.
return !out.key.empty();
}
@@ -251,12 +233,12 @@ bool parseCreateAccountJson(const std::string& body, AccountBody& out) {
return true;
}
AccountResult createAccount(const std::string& email, const std::string& subscription_level,
const std::string& name, const std::string& apiKey) {
AccountResult createAccount(const std::string& email, const std::string& name,
const std::string& apiKey) {
AccountResult result;
std::string jsonBody =
"{\"email\":\"" + email + "\",\"subscription_level\":\"" + subscription_level + "\"";
"{\"email\":\"" + email + "\"";
if (!name.empty()) {
jsonBody += ",\"name\":\"" + name + "\"";
}
+3 -12
View File
@@ -79,19 +79,9 @@ bool fetchColorPalettes(std::vector<ColorPalette>& out, const std::string& apiKe
// ─── POST /license ─────────────────────────────────────────────────────
// A subscription level as described by the portal's `SubscriptionLevel`
// table. `name` is always populated; `id`/`description` may be 0/empty when
// the API only reported the level name.
struct SubscriptionLevel {
int id = 0;
std::string name;
std::string description;
};
struct LicenseInfo {
std::string key;
std::string email_address;
SubscriptionLevel subscription_level;
};
// Parse the 200 JSON payload returned by the license endpoint.
@@ -107,6 +97,7 @@ struct LicenseResult {
// Validate a license key / email pair against the portal. Never throws; failures
// (network, HTTP error status) are reported through the returned LicenseResult.
// A 2xx response means the license is valid and the subscription is active.
LicenseResult validateLicense(const std::string& key, const std::string& email,
const std::string& apiKey = "");
@@ -131,7 +122,7 @@ struct AccountResult {
// Creates (or reuses) a user account and issues a license key. The key is emailed
// to the user and is NOT part of the response. `name` may be empty.
AccountResult createAccount(const std::string& email, const std::string& subscription_level,
const std::string& name = "", const std::string& apiKey = "");
AccountResult createAccount(const std::string& email, const std::string& name = "",
const std::string& apiKey = "");
} // namespace radiantwave::api
+4 -47
View File
@@ -2,9 +2,8 @@
#include "db/db_keys.h"
#include "network/api_client.h"
#include <cctype>
#include <cstdlib>
#include <chrono>
#include <cstdlib>
namespace radiantwave::license {
@@ -19,9 +18,7 @@ long long nowEpochSeconds() {
Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
const std::string& apiKey,
std::string* outLevelName,
std::string* outErrorMessage) {
if (outLevelName) outLevelName->clear();
if (outErrorMessage) outErrorMessage->clear();
const std::string email = db.getKV(radiantwave::db::KV_EMAIL_ADDRESS);
@@ -42,21 +39,10 @@ Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
return Result::Invalid;
}
const auto& level = result.info.subscription_level;
int levelId = level.id;
if (levelId <= 0) {
// The portal may only report the level name (e.g. "Elite"). Derive the
// numeric id from the name so the Home footer and proclamation gating
// can rank the level even when the response omits the id.
levelId = subscriptionLevelIdForName(level.name);
}
db.setKV(radiantwave::db::KV_SUBSCRIPTION_LEVEL_ID,
levelId > 0 ? std::to_string(levelId) : std::string());
db.setKV(radiantwave::db::KV_SUBSCRIPTION_LEVEL_NAME, level.name);
db.setKV(radiantwave::db::KV_SUBSCRIPTION_LEVEL_DESCRIPTION, level.description);
// A successful validation is the "subscribed" flag: the license is valid
// and the subscription is active. Only the "last verified" stamp is stored
// (it drives the offline grace window).
markLicenseVerified(db);
if (outLevelName) *outLevelName = level.name;
return Result::Ok;
}
@@ -76,33 +62,4 @@ bool isLicenseWithinGracePeriod(const radiantwave::db::DatabaseManager& db) {
return nowEpochSeconds() - stamp < GRACE_PERIOD_SECONDS;
}
int subscriptionLevelIdForName(const std::string& name) {
std::string lower;
lower.reserve(name.size());
for (char c : name) {
lower.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
}
if (lower == "basic") return 1;
if (lower == "advanced") return 2;
if (lower == "elite") return 3;
return 0;
}
int getStoredSubscriptionLevelId(const radiantwave::db::DatabaseManager& db) {
const std::string raw = db.getKV(radiantwave::db::KV_SUBSCRIPTION_LEVEL_ID);
if (raw.empty()) return 0;
char* end = nullptr;
const long id = std::strtol(raw.c_str(), &end, 10);
return (end && *end == '\0' && id > 0) ? static_cast<int>(id) : 0;
}
std::string subscriptionLevelName(int levelId) {
switch (levelId) {
case 1: return "Basic";
case 2: return "Advanced";
case 3: return "Elite";
default: return "Subscription Level " + std::to_string(levelId);
}
}
} // namespace radiantwave::license
+4 -20
View File
@@ -5,11 +5,8 @@
namespace radiantwave::license {
// NOTE: the system_kv keys where the validated subscription level is persisted
// live in db/db_keys.h (KV_SUBSCRIPTION_LEVEL_*).
enum class Result {
Ok, // license valid; subscription level persisted to the DB
Ok, // license valid; subscription active
NotConfigured, // email and/or license key missing from the DB
Invalid, // API rejected the pair (bad key, wrong user, inactive, ...)
NetworkError, // the request never completed
@@ -22,29 +19,16 @@ enum class Result {
constexpr long long GRACE_PERIOD_SECONDS = 7LL * 24 * 60 * 60;
// Validates the stored license key against the stored email address via the
// portal API. On success the subscription level (id/name/description) is
// written to the DB; on failure the stored level is left untouched so a
// portal API. A successful validation is the device's "subscribed" flag — no
// separate subscription state is tracked. On failure nothing is written, so a
// previous good value survives a transient outage. Returns NotConfigured
// without touching the network when either value is missing. Never throws.
//
// `outLevelName` / `outErrorMessage` are optional out-params.
// `outErrorMessage` is an optional out-param.
Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
const std::string& apiKey = "",
std::string* outLevelName = nullptr,
std::string* outErrorMessage = nullptr);
// Returns the subscription level id currently stored after a successful
// license validation. Returns 0 when no validated license is on record.
int getStoredSubscriptionLevelId(const radiantwave::db::DatabaseManager& db);
// Human-readable name for a subscription level id (Basic / Advanced / Elite).
// Falls back to a generic label for unknown ids.
std::string subscriptionLevelName(int levelId);
// Numeric id for a subscription level name (case-insensitive match against
// Basic / Advanced / Elite). Returns 0 for an unknown or empty name.
int subscriptionLevelIdForName(const std::string& name);
// Records the current time as the "last verified" stamp of the license. Called
// after every successful server validation so the offline grace window starts
// (or is extended) from the most recent confirmation.
-2
View File
@@ -67,8 +67,6 @@ bool parseProclamationsJson(const std::string& body,
rec.content = stripHtml(entry.stringField("content"));
rec.category_id = static_cast<int>(entry.numberField("categoryId"));
rec.solfeggio_frequency_id = static_cast<int>(entry.numberField("solfeggioFrequencyId"));
rec.required_level_id = static_cast<int>(entry.numberField("subscriptionLevelId"));
if (rec.required_level_id <= 0) rec.required_level_id = 1;
out.push_back(std::move(rec));
}
return true;
+5 -53
View File
@@ -1,6 +1,5 @@
#include "proclamation_options_page.h"
#include "app_state.h"
#include "network/license_service.h"
#include "network/proclamation_sync.h"
#include <SDL3/SDL.h>
#include <algorithm>
@@ -9,8 +8,6 @@
namespace {
constexpr uint64_t kFlashDurationMs = 700;
// Case-insensitive word-prefix match: true when any word in `text` (split on
// non-alphanumeric boundaries) starts with `query`. Both must already be
// lowercase. Mirrors the old Go browser's matching behavior.
@@ -45,9 +42,6 @@ void ProclamationOptionsPage::onPush(AppState& state)
createFonts();
SDL_StartTextInput(state.window);
int level = radiantwave::license::getStoredSubscriptionLevelId(state.db);
subscriptionLevelId_ = level > 0 ? level : 1;
// TODO(portal #4/#5): once the portal exposes color palettes, sync them
// alongside proclamations here (sync::syncColorPalettesFromApi after this
// succeeds) so palette updates reach the scroller on every manual refresh.
@@ -83,9 +77,6 @@ void ProclamationOptionsPage::onResume(AppState& state)
createFonts();
SDL_StartTextInput(state.window);
int level = radiantwave::license::getStoredSubscriptionLevelId(state.db);
subscriptionLevelId_ = level > 0 ? level : 1;
loadFromDb();
rebuildItems();
rebuildVisible();
@@ -302,27 +293,6 @@ void ProclamationOptionsPage::toggleItem()
return;
}
// Selecting a proclamation whose required subscription level is above the
// one attached to the stored license key is rejected: the name flashes red
// and a hint is shown up top.
if (!item.selected) {
int requiredLevel = 1;
for (const auto& p : proclamations_) {
if (p.id == item.id) {
requiredLevel = p.required_level_id;
break;
}
}
if (requiredLevel > subscriptionLevelId_) {
flashItemIndex_ = static_cast<int>(visible_[highlightIndex_]);
flashUntilMs_ = SDL_GetTicks() + kFlashDurationMs;
statusMessage_ = "This proclamation is available on " +
radiantwave::license::subscriptionLevelName(requiredLevel) +
" systems";
return;
}
}
item.selected = !item.selected;
for (auto& p : proclamations_) {
if (p.id == item.id) {
@@ -390,9 +360,9 @@ int ProclamationOptionsPage::activeListSize() const
}
// Toggles the highlighted search result. Selection rules mirror toggleItem():
// the always-on top-level proclamation is not togglable, and locked
// proclamations flash red. Selection state is synced back into items_ so the
// browse tree stays consistent when search mode ends.
// the always-on top-level proclamation is not togglable. Selection state is
// synced back into items_ so the browse tree stays consistent when search mode
// ends.
void ProclamationOptionsPage::toggleSearchItem()
{
if (searchResults_.empty()) return;
@@ -403,17 +373,6 @@ void ProclamationOptionsPage::toggleSearchItem()
return;
}
if (!p.selected) {
if (p.required_level_id > subscriptionLevelId_) {
flashItemIndex_ = static_cast<int>(searchResults_[highlightIndex_]);
flashUntilMs_ = SDL_GetTicks() + kFlashDurationMs;
statusMessage_ = "This proclamation is available on " +
radiantwave::license::subscriptionLevelName(p.required_level_id) +
" systems";
return;
}
}
p.selected = !p.selected;
for (auto& item : items_) {
if (item.type == FlatItem::Type::Proclamation && item.id == p.id) {
@@ -609,9 +568,6 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
SDL_Color gray = {170, 170, 170, 255};
SDL_Color categoryBlue = {100, 180, 255, 255};
SDL_Color selectedOrange = {255, 170, 60, 255};
SDL_Color lockedRed = {235, 70, 70, 255};
bool flashing = SDL_GetTicks() < flashUntilMs_;
float titleY = 50.0f;
float titleH = 0.0f;
@@ -718,9 +674,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
float y = listTop + rowIndex * rowPitch;
SDL_Color color = white;
if (flashing && searchResults_[i] == static_cast<size_t>(flashItemIndex_)) {
color = lockedRed;
} else if (p.selected) {
if (p.selected) {
color = selectedOrange;
}
@@ -771,9 +725,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
}
SDL_Color color = white;
if (flashing && static_cast<int>(visible_[i]) == flashItemIndex_) {
color = lockedRed;
} else if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
color = categoryBlue;
} else if (item.selected || item.isDefault) {
color = selectedOrange;
-4
View File
@@ -79,10 +79,6 @@ private:
bool searchActive_ = false;
std::string searchQuery_;
int subscriptionLevelId_ = 1; // stored license level; 1 (Basic) without one
int flashItemIndex_ = -1; // items_ index flashing red (locked attempt)
uint64_t flashUntilMs_ = 0; // SDL_GetTicks() deadline for the flash
TTF_Font* fontTitle_ = nullptr;
TTF_Font* fontItem_ = nullptr;
TTF_Font* fontSmall_ = nullptr;
+8 -40
View File
@@ -72,19 +72,19 @@ TEST(ApiClientTest, ParseFrequenciesJsonSkipsMissingId) {
EXPECT_EQ(out[0].id, 3);
}
TEST(ApiClientTest, ParseLicenseJsonLegacyStringLevel) {
TEST(ApiClientTest, ParseLicenseJson) {
const std::string body =
R"({"key":"ABCD-EFGH-IJKL-MNOP","email_address":"user@example.com","subscription_level":"Basic"})";
R"({"key":"ABCD-EFGH-IJKL-MNOP","email_address":"user@example.com"})";
LicenseInfo info;
EXPECT_TRUE(parseLicenseJson(body, info));
EXPECT_EQ(info.key, "ABCD-EFGH-IJKL-MNOP");
EXPECT_EQ(info.email_address, "user@example.com");
EXPECT_EQ(info.subscription_level.name, "Basic");
EXPECT_EQ(info.subscription_level.id, 0);
}
TEST(ApiClientTest, ParseLicenseJsonObjectLevel) {
TEST(ApiClientTest, ParseLicenseJsonIgnoresSubscriptionFields) {
// The active/inactive model has no subscription level. Legacy responses
// that still carry subscription_level must parse; the field is ignored.
const std::string body =
R"({"key":"ABCD-EFGH-IJKL-MNOP","email_address":"user@example.com",)"
R"("subscription_level":{"id":2,"name":"Advanced","description":"General + Specific"}})";
@@ -93,22 +93,6 @@ TEST(ApiClientTest, ParseLicenseJsonObjectLevel) {
EXPECT_TRUE(parseLicenseJson(body, info));
EXPECT_EQ(info.key, "ABCD-EFGH-IJKL-MNOP");
EXPECT_EQ(info.email_address, "user@example.com");
EXPECT_EQ(info.subscription_level.id, 2);
EXPECT_EQ(info.subscription_level.name, "Advanced");
EXPECT_EQ(info.subscription_level.description, "General + Specific");
}
TEST(ApiClientTest, ParseLicenseJsonFlatLevel) {
const std::string body =
R"({"key":"ABCD-EFGH-IJKL-MNOP","email_address":"user@example.com",)"
R"("subscription_level":"Elite","subscription_level_id":3,)"
R"("subscription_level_description":"General + Specific + Precise"})";
LicenseInfo info;
EXPECT_TRUE(parseLicenseJson(body, info));
EXPECT_EQ(info.subscription_level.id, 3);
EXPECT_EQ(info.subscription_level.name, "Elite");
EXPECT_EQ(info.subscription_level.description, "General + Specific + Precise");
}
TEST(ApiClientTest, ParseLicenseJsonRejectsMalformed) {
@@ -147,7 +131,7 @@ TEST(ApiClientTest, ExtractErrorMessage) {
// ─── Offline: real-world payload shapes ────────────────────────────────
TEST(ApiClientTest, ParseProclamationsHandlesSubscriptionLevelIdNull) {
TEST(ApiClientTest, ParseProclamationsToleratesLegacyLevelField) {
const std::string body =
R"([{"id":"a","name":"Peace","content":"<p>I am peace.</p>","categoryId":5,
"solfeggioFrequencyId":5,"subscriptionLevelId":null},
@@ -256,33 +240,17 @@ TEST(ApiClientLiveTest, ValidateLicenseSuccessLive) {
<< " error=" << result.error_message;
EXPECT_EQ(result.info.key, key);
EXPECT_EQ(result.info.email_address, email);
EXPECT_FALSE(result.info.subscription_level.name.empty());
}
// ─── Live: POST /automations/create/account error paths (harmless) ─────
TEST(ApiClientLiveTest, CreateAccountMissingEmailLive) {
AccountResult result = createAccount("", "Basic", "", liveApiKey());
AccountResult result = createAccount("", "", liveApiKey());
ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "email is required");
}
TEST(ApiClientLiveTest, CreateAccountMissingLevelLive) {
AccountResult result = createAccount("test@example.com", "", "", liveApiKey());
ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "subscription_level is required");
}
TEST(ApiClientLiveTest, CreateAccountInvalidLevelLive) {
AccountResult result =
createAccount("test@example.com", "NoSuchLevel123", "", liveApiKey());
ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400);
EXPECT_NE(result.error_message.find("not found"), std::string::npos);
}
// ─── Live: POST /automations/create/account success (creates a real
// ─── account + emails the user; requires RW_LIVE_ACCOUNT_EMAIL) ────────
@@ -292,7 +260,7 @@ TEST(ApiClientLiveTest, CreateAccountSuccessLive) {
GTEST_SKIP() << "RW_LIVE_ACCOUNT_EMAIL not set";
}
AccountResult result = createAccount(email, "Basic", "RadiantWave Test", liveApiKey());
AccountResult result = createAccount(email, "RadiantWave Test", liveApiKey());
ASSERT_TRUE(result.ok()) << "status=" << result.status
<< " error=" << result.error_message;
EXPECT_TRUE(result.body.success);
-26
View File
@@ -142,32 +142,6 @@ TEST(DatabaseManagerTest, SyncProclamationsPreservesSelectionAndDeletesRemoved)
EXPECT_TRUE(all[0].selected);
}
TEST(DatabaseManagerTest, RequiredLevelIdRoundTripsThroughSync) {
const std::string defId = "default-1";
DbFixture fx("test_rw_db_level.db");
ProclamationRecord def = makeProc(defId, "Default", 1);
def.required_level_id = 1;
ProclamationRecord elite = makeProc("elite", "Elite Only", 2);
elite.required_level_id = 3;
ASSERT_TRUE(fx.db.syncProclamations({def, elite}, defId));
auto all = fx.db.getAllProclamations();
ASSERT_EQ(all.size(), 2u);
for (const auto& p : all) {
EXPECT_EQ(p.required_level_id, p.id == "elite" ? 3 : 1);
}
// A re-sync with the same records keeps the level (and selection).
ASSERT_TRUE(fx.db.syncProclamations({def, elite}, defId));
auto again = fx.db.getAllProclamations();
ASSERT_EQ(again.size(), 2u);
for (const auto& p : again) {
EXPECT_EQ(p.required_level_id, p.id == "elite" ? 3 : 1);
}
}
TEST(DatabaseManagerTest, SyncForcesDefaultProclamationSelected) {
const std::string defId = "default-1";
DbFixture fx("test_rw_db_def.db");
+9 -50
View File
@@ -47,23 +47,18 @@ TEST(LicenseServiceTest, NotConfiguredWhenNoEmail) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP");
std::string levelName = "stale";
std::string errorMessage = "stale";
EXPECT_EQ(validateStoredLicense(tmp.db, "", &levelName, &errorMessage),
EXPECT_EQ(validateStoredLicense(tmp.db, "", &errorMessage),
Result::NotConfigured);
EXPECT_EQ(levelName, "");
EXPECT_EQ(errorMessage, "");
EXPECT_FALSE(tmp.db.hasKV(KV_SUBSCRIPTION_LEVEL_NAME));
}
TEST(LicenseServiceTest, NotConfiguredWhenNoLicense) {
TempDb tmp;
tmp.db.setKV(KV_EMAIL_ADDRESS, "user@example.com");
std::string levelName = "stale";
EXPECT_EQ(validateStoredLicense(tmp.db, "", &levelName, nullptr),
EXPECT_EQ(validateStoredLicense(tmp.db, "", nullptr),
Result::NotConfigured);
EXPECT_EQ(levelName, "");
}
TEST(LicenseServiceTest, NotConfiguredWhenBothMissing) {
@@ -71,35 +66,6 @@ TEST(LicenseServiceTest, NotConfiguredWhenBothMissing) {
EXPECT_EQ(validateStoredLicense(tmp.db), Result::NotConfigured);
}
// ─── Level helpers (no network) ─────────────────────────────────────────
TEST(LicenseServiceTest, StoredLevelIdReadsPersistedValue) {
TempDb tmp;
EXPECT_EQ(getStoredSubscriptionLevelId(tmp.db), 0);
tmp.db.setKV(KV_SUBSCRIPTION_LEVEL_ID, "2");
EXPECT_EQ(getStoredSubscriptionLevelId(tmp.db), 2);
tmp.db.setKV(KV_SUBSCRIPTION_LEVEL_ID, "not-a-number");
EXPECT_EQ(getStoredSubscriptionLevelId(tmp.db), 0);
}
TEST(LicenseServiceTest, LevelNames) {
EXPECT_EQ(subscriptionLevelName(1), "Basic");
EXPECT_EQ(subscriptionLevelName(2), "Advanced");
EXPECT_EQ(subscriptionLevelName(3), "Elite");
EXPECT_EQ(subscriptionLevelName(4), "Subscription Level 4");
}
TEST(LicenseServiceTest, LevelIdForName) {
EXPECT_EQ(subscriptionLevelIdForName("Basic"), 1);
EXPECT_EQ(subscriptionLevelIdForName("advanced"), 2);
EXPECT_EQ(subscriptionLevelIdForName("ELITE"), 3);
EXPECT_EQ(subscriptionLevelIdForName(" Elite "), 0); // unknown/whitespace
EXPECT_EQ(subscriptionLevelIdForName("Enterprise"), 0);
EXPECT_EQ(subscriptionLevelIdForName(""), 0);
}
// ─── Offline grace period ───────────────────────────────────────────────
long long epochSeconds(long long offsetSeconds = 0) {
@@ -139,7 +105,7 @@ TEST(LicenseServiceTest, GracePeriodIgnoresCorruptStamp) {
// ─── Live: full validation + persistence (requires env credentials) ─────
TEST(LicenseServiceLiveTest, ValidatesAndStoresSubscriptionLevel) {
TEST(LicenseServiceLiveTest, ValidatesAndGrantsSubscribedState) {
const std::string key = env("RW_LIVE_LICENSE_KEY");
const std::string email = env("RW_LIVE_LICENSE_EMAIL");
if (key.empty() || email.empty()) {
@@ -150,20 +116,14 @@ TEST(LicenseServiceLiveTest, ValidatesAndStoresSubscriptionLevel) {
tmp.db.setKV(KV_LICENSE_KEY, key);
tmp.db.setKV(KV_EMAIL_ADDRESS, email);
std::string levelName;
std::string errorMessage;
const Result result = validateStoredLicense(tmp.db, "", &levelName, &errorMessage);
const Result result = validateStoredLicense(tmp.db, "", &errorMessage);
EXPECT_EQ(result, Result::Ok) << "error=" << errorMessage;
// The subscription level name (and id/description when available) must be
// persisted to the local database.
EXPECT_EQ(levelName, tmp.db.getKV(KV_SUBSCRIPTION_LEVEL_NAME));
EXPECT_FALSE(tmp.db.getKV(KV_SUBSCRIPTION_LEVEL_NAME).empty());
ASSERT_TRUE(tmp.db.hasKV(KV_SUBSCRIPTION_LEVEL_ID));
EXPECT_GT(std::atoi(tmp.db.getKV(KV_SUBSCRIPTION_LEVEL_ID).c_str()), 0);
// A successful validation must record the "last verified" stamp that drives
// the offline grace period.
// A successful validation is the "subscribed" flag; no subscription-level
// state is persisted. Only the "last verified" stamp that drives the
// offline grace window is written.
EXPECT_FALSE(tmp.db.hasKV("subscription_level_name"));
EXPECT_TRUE(tmp.db.hasKV(KV_LICENSE_LAST_VERIFIED));
EXPECT_TRUE(isLicenseWithinGracePeriod(tmp.db));
}
@@ -174,8 +134,7 @@ TEST(LicenseServiceLiveTest, InvalidPairReportsInvalid) {
tmp.db.setKV(KV_EMAIL_ADDRESS, "nobody@example.com");
std::string errorMessage;
EXPECT_EQ(validateStoredLicense(tmp.db, "", nullptr, &errorMessage),
EXPECT_EQ(validateStoredLicense(tmp.db, "", &errorMessage),
Result::Invalid);
EXPECT_FALSE(errorMessage.empty());
EXPECT_FALSE(tmp.db.hasKV(KV_SUBSCRIPTION_LEVEL_NAME));
}
+5 -7
View File
@@ -6,7 +6,7 @@ using namespace radiantwave::sync;
TEST(ProclamationSyncTest, ParseProclamationsJson) {
const std::string body =
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"solfeggioFrequencyId":2,"subscriptionLevelId":3},
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"solfeggioFrequencyId":2},
{"id":"p2","name":"Joy &amp; Calm","content":"text","categoryId":0,"solfeggioFrequencyId":0},
{"notAnObject":true}])";
@@ -19,24 +19,22 @@ TEST(ProclamationSyncTest, ParseProclamationsJson) {
EXPECT_EQ(out[0].content, "I am peace.");
EXPECT_EQ(out[0].category_id, 3);
EXPECT_EQ(out[0].solfeggio_frequency_id, 2);
EXPECT_EQ(out[0].required_level_id, 3);
EXPECT_EQ(out[1].id, "p2");
EXPECT_EQ(out[1].title, "Joy & Calm");
EXPECT_EQ(out[1].content, "text");
EXPECT_EQ(out[1].category_id, 0);
// Missing subscriptionLevelId defaults to the least privileged level.
EXPECT_EQ(out[1].required_level_id, 1);
EXPECT_EQ(out[1].solfeggio_frequency_id, 0);
}
TEST(ProclamationSyncTest, ParseLevelIdDefaultsToBasic) {
TEST(ProclamationSyncTest, ParseIgnoresLegacySubscriptionLevelField) {
std::vector<ProclamationRecord> out;
EXPECT_TRUE(parseProclamationsJson(
R"([{"id":"a","name":"A","subscriptionLevelId":0},
{"id":"b","name":"B","subscriptionLevelId":2}])", out));
ASSERT_EQ(out.size(), 2u);
EXPECT_EQ(out[0].required_level_id, 1);
EXPECT_EQ(out[1].required_level_id, 2);
EXPECT_EQ(out[0].id, "a");
EXPECT_EQ(out[1].id, "b");
}
TEST(ProclamationSyncTest, ParseCategoriesJson) {