From f14bf3e29a96f0759984968480c127e2b3933303 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Sat, 15 Aug 2026 00:54:24 -0500 Subject: [PATCH] Remove subscription levels; license valid = subscribed --- AGENTS.md | 5 +- Manual.md | 575 ------------------------ portal_api_v2_schema.md | 715 ++++-------------------------- src/db/database_manager.cpp | 26 +- src/db/database_manager.h | 1 - src/db/db_keys.h | 3 - src/home.cpp | 11 - src/license_key_page.cpp | 5 +- src/network/api_client.cpp | 30 +- src/network/api_client.h | 15 +- src/network/license_service.cpp | 51 +-- src/network/license_service.h | 24 +- src/network/proclamation_sync.cpp | 2 - src/proclamation_options_page.cpp | 58 +-- src/proclamation_options_page.h | 4 - tests/test_api_client.cpp | 48 +- tests/test_db.cpp | 26 -- tests/test_license_service.cpp | 59 +-- tests/test_proclamation_sync.cpp | 12 +- 19 files changed, 136 insertions(+), 1534 deletions(-) delete mode 100644 Manual.md diff --git a/AGENTS.md b/AGENTS.md index a8aa045..ac4e5d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Manual.md b/Manual.md deleted file mode 100644 index e07bf49..0000000 --- a/Manual.md +++ /dev/null @@ -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: - \/None**, **Internet: Reachable/Unreachable** (green/red). -- Status message banner (blue-bordered): "Scanning for available - networks...", "Connecting to \...", "Connected successfully!", - "Connection failed: \", "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: \" (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: \**; status banner; 5-item -menu: - -| # | Item | Interaction | -| :--- | :--- | :--- | -| 0 | Volume: `[▓▓▓▓▓▓▓▓░░] 80%` | LEFT/RIGHT step −/+ 5% (0–100, clamps). | -| 1 | Output Device: `[\]` | ENTER opens the Device selector modal. | -| 2 | Audio Track: `[\]` | 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: -\** (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 \"). | -| **`ESC`** / **`TAB`** / **`BACKSPACE`** | Back to Settings. | - -**Timezone step:** - -| Key | Action | -| :--- | :--- | -| **`UP`** / **`DOWN`** | Select a timezone. | -| **`RETURN`** / **`ENTER`** | Save the timezone (status "Timezone set to \") 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 \ 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: \" (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: \" (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 (\)". -- Missing email → "Saved - add your email address to validate the license". -- Invalid → "License check failed: \". -- 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: \" -(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: \ | Sort: Newest/Oldest First | Entries: N | TZ: \**; -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. diff --git a/portal_api_v2_schema.md b/portal_api_v2_schema.md index fea689b..2aba674 100644 --- a/portal_api_v2_schema.md +++ b/portal_api_v2_schema.md @@ -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_` 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_` 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_ -``` - -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_ -``` - -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_ -``` - -Optional entitlement filter (full records, see below): - -``` -GET /api/v2/proclamations -Authorization: Bearer sk_ -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": "

The text content, HTML formatted

", - "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_ -``` +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_ -``` - -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": "

The text content, HTML formatted

", + "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_ X-Timestamp: X-Nonce: X-Signature: @@ -423,15 +154,12 @@ X-Signature: ```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 \"\" 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 = { - 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_`. -- 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. diff --git a/src/db/database_manager.cpp b/src/db/database_manager.cpp index 7f33593..35bd700 100644 --- a/src/db/database_manager.cpp +++ b/src/db/database_manager.cpp @@ -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 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& 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 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; diff --git a/src/db/database_manager.h b/src/db/database_manager.h index d72a02f..616cb64 100644 --- a/src/db/database_manager.h +++ b/src/db/database_manager.h @@ -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 { diff --git a/src/db/db_keys.h b/src/db/db_keys.h index a5e6c67..7c2d91c 100644 --- a/src/db/db_keys.h +++ b/src/db/db_keys.h @@ -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 diff --git a/src/home.cpp b/src/home.cpp index 92bca36..3b30149 100644 --- a/src/home.cpp +++ b/src/home.cpp @@ -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 #include @@ -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 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:", diff --git a/src/license_key_page.cpp b/src/license_key_page.cpp index 113ebf9..f9dc98d 100644 --- a/src/license_key_page.cpp +++ b/src/license_key_page.cpp @@ -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"; diff --git a/src/network/api_client.cpp b/src/network/api_client.cpp index d3e641a..cc314ca 100644 --- a/src/network/api_client.cpp +++ b/src/network/api_client.cpp @@ -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(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(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 + "\""; } diff --git a/src/network/api_client.h b/src/network/api_client.h index b299140..c3000d7 100644 --- a/src/network/api_client.h +++ b/src/network/api_client.h @@ -79,19 +79,9 @@ bool fetchColorPalettes(std::vector& 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 diff --git a/src/network/license_service.cpp b/src/network/license_service.cpp index f8febdd..56b3d39 100644 --- a/src/network/license_service.cpp +++ b/src/network/license_service.cpp @@ -2,9 +2,8 @@ #include "db/db_keys.h" #include "network/api_client.h" -#include -#include #include +#include 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(std::tolower(static_cast(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(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 diff --git a/src/network/license_service.h b/src/network/license_service.h index 94482bb..0fa2317 100644 --- a/src/network/license_service.h +++ b/src/network/license_service.h @@ -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. diff --git a/src/network/proclamation_sync.cpp b/src/network/proclamation_sync.cpp index d8a0ffc..4cde51d 100644 --- a/src/network/proclamation_sync.cpp +++ b/src/network/proclamation_sync.cpp @@ -67,8 +67,6 @@ bool parseProclamationsJson(const std::string& body, rec.content = stripHtml(entry.stringField("content")); rec.category_id = static_cast(entry.numberField("categoryId")); rec.solfeggio_frequency_id = static_cast(entry.numberField("solfeggioFrequencyId")); - rec.required_level_id = static_cast(entry.numberField("subscriptionLevelId")); - if (rec.required_level_id <= 0) rec.required_level_id = 1; out.push_back(std::move(rec)); } return true; diff --git a/src/proclamation_options_page.cpp b/src/proclamation_options_page.cpp index 46c6498..8fe1848 100644 --- a/src/proclamation_options_page.cpp +++ b/src/proclamation_options_page.cpp @@ -1,6 +1,5 @@ #include "proclamation_options_page.h" #include "app_state.h" -#include "network/license_service.h" #include "network/proclamation_sync.h" #include #include @@ -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(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(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(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(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; diff --git a/src/proclamation_options_page.h b/src/proclamation_options_page.h index fea2d39..f71f139 100644 --- a/src/proclamation_options_page.h +++ b/src/proclamation_options_page.h @@ -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; diff --git a/tests/test_api_client.cpp b/tests/test_api_client.cpp index 630a7ee..a89f135 100644 --- a/tests/test_api_client.cpp +++ b/tests/test_api_client.cpp @@ -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":"

I am peace.

","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); diff --git a/tests/test_db.cpp b/tests/test_db.cpp index ddda341..da93694 100644 --- a/tests/test_db.cpp +++ b/tests/test_db.cpp @@ -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"); diff --git a/tests/test_license_service.cpp b/tests/test_license_service.cpp index 803d8b6..9bd9460 100644 --- a/tests/test_license_service.cpp +++ b/tests/test_license_service.cpp @@ -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)); } diff --git a/tests/test_proclamation_sync.cpp b/tests/test_proclamation_sync.cpp index 3dd753d..6c691b3 100644 --- a/tests/test_proclamation_sync.cpp +++ b/tests/test_proclamation_sync.cpp @@ -6,7 +6,7 @@ using namespace radiantwave::sync; TEST(ProclamationSyncTest, ParseProclamationsJson) { const std::string body = - R"([{"id":"p1","name":"Peace","content":"

I am peace.

","categoryId":3,"solfeggioFrequencyId":2,"subscriptionLevelId":3}, + R"([{"id":"p1","name":"Peace","content":"

I am peace.

","categoryId":3,"solfeggioFrequencyId":2}, {"id":"p2","name":"Joy & 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 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) {