Remove subscription levels; license valid = subscribed
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 + "\"";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 & 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) {
|
||||
|
||||
Reference in New Issue
Block a user