Licence & beta access system
The canonical spec for Helm’s licence system. If you’re an agent implementing the service, the admin UI, or an app-side integration, this is the source of truth. Handover docs at the repo root point at specific sections here.
- Beta phase: operator signs up on
/beta, admin reviews, admin issues a key, operator pastes it into the app. - Manual control: no auto-issuing, no self-serve — you (the admin) decide who gets access.
- Revocation in seconds: a revoked key stops working on the next check.
- Scoped permissions: every key carries a list of scopes (
beta,export-stems, etc.) the app reads and gates features behind. - Multi-product from day one: one user can hold keys for DJ, Cues, and Clock separately. Same infrastructure, different
product_id. - Paid-ready: schema includes
tier(beta/pro/enterprise) andexpires_atso the transition to Lemon Squeezy or direct Stripe later is additive, not a rewrite.
Architecture
Section titled “Architecture”┌────────────────────────────┐│ Helm DJ / Cues / Clock app ││ first-run key entry │└──────┬─────────────────────┘ │ POST /api/licence/check { key, product, device_id, os, app_version } ▼┌────────────────────────────┐ ┌────────────────────┐│ helm.e-labs.co.uk │────▶│ Vercel Postgres ││ Next.js App Router │ │ users ││ /api/licence/check │ │ licences ││ /api/admin/* │ │ beta_applications │└──────┬─────────────────────┘ │ activations │ │ └────────────────────┘ ▼┌────────────────────────────┐│ Admin UI ││ /account/admin ││ — review applications ││ — issue / revoke keys ││ — view activations │└────────────────────────────┘Storage — Vercel Postgres
Section titled “Storage — Vercel Postgres”One database, four tables. SQL chosen for flexibility of admin queries (filter/sort/join) and transactional guarantees on issuing/revoking.
Schema
Section titled “Schema”-- Enable case-insensitive emails.CREATE EXTENSION IF NOT EXISTS citext;
-- The Helm products that have licensing. Seeded once.CREATE TABLE products ( id text PRIMARY KEY, -- 'helm-dj', 'helm-cues', 'helm-clock' name text NOT NULL, -- 'Helm DJ' created_at timestamptz NOT NULL DEFAULT now());
INSERT INTO products (id, name) VALUES ('helm-dj', 'Helm DJ'), ('helm-cues', 'Helm Cues'), ('helm-clock', 'Helm Clock')ON CONFLICT (id) DO NOTHING;
-- A person. One row per email address, potentially holding multiple-- licences across different products.CREATE TABLE users ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), email citext NOT NULL UNIQUE, name text, role text, -- free-text job role from the beta form organization text, notes text, -- admin-only notes created_at timestamptz NOT NULL DEFAULT now());
-- A licence key scoped to one product. Opaque random string, stored-- as-is so admin can display/copy it. `scopes` drives app-side feature-- gating.CREATE TABLE licences ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, product_id text NOT NULL REFERENCES products(id), key text NOT NULL UNIQUE, scopes jsonb NOT NULL DEFAULT '[]'::jsonb, tier text NOT NULL DEFAULT 'beta', -- 'beta' | 'pro' | 'enterprise' issued_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz, -- NULL = no expiry revoked_at timestamptz, last_checked_at timestamptz);
CREATE INDEX idx_licences_user ON licences(user_id);CREATE INDEX idx_licences_product ON licences(product_id);
-- A beta signup. Submitted by the /beta form. Admin reviews and either-- approves (which creates/updates a user + issues a licence) or rejects.CREATE TABLE beta_applications ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), email citext NOT NULL, name text, role text, product_id text REFERENCES products(id), os text, -- 'macos' | 'windows' | 'both' rig text, context text, status text NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'rejected' submitted_at timestamptz NOT NULL DEFAULT now(), reviewed_at timestamptz, reviewed_by text, admin_notes text);
CREATE INDEX idx_beta_applications_status ON beta_applications(status);
-- Each time a licence is used on a new device, a row is inserted (or-- updated if the device_id+licence_id pair already exists).CREATE TABLE activations ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), licence_id uuid NOT NULL REFERENCES licences(id) ON DELETE CASCADE, device_id text NOT NULL, -- hardware ID from the app os text, app_version text, first_seen timestamptz NOT NULL DEFAULT now(), last_seen timestamptz NOT NULL DEFAULT now(), UNIQUE (licence_id, device_id));Why separate users and licences
Section titled “Why separate users and licences”When Helm Cues ships, a DJ beta user who also wants Cues access gets a new licences row keyed to the same users.id. One email, two keys, clean audit trail, no duplication.
Key format
Section titled “Key format”Opaque random string, formatted for human copy/paste:
HELM-DJ-7K2M-HF9J-3QAX-NBZ8- Prefix (
HELM-DJ) is the product slug uppercased — operators can see at a glance which product the key is for. - Four 4-character groups of Crockford base32 (no ambiguous chars: no
0,O,1,I,L,U) — 80 bits of entropy, plenty. - Generated server-side via
crypto.randomBytes(10)→ base32 encode → group.
Not a JWT, not signed. Revocation requires a server check — intentional, so revoked_at takes effect immediately. Offline grace period (below) handles the disconnected-show case.
Authentication
Section titled “Authentication”Every endpoint is authenticated:
| Endpoint | Mechanism |
|---|---|
POST /api/licence/check | X-Helm-Client-Key header plus the licence key in the body |
POST /api/updates/*, /api/downloads/* | X-Helm-Client-Key header |
POST /api/admin/login | Password matched against ADMIN_PASSWORD |
/api/admin/* (others) | admin_session cookie (set on login, HMAC-signed with SESSION_SECRET) |
POST /api/beta/subscribe | None — public signup form, relies on Vercel’s default per-IP rate limits |
Client-key header
Section titled “Client-key header”All Helm apps (DJ, Cues, Clock, and any future desktop/hardware product) send a build-time-embedded shared secret as X-Helm-Client-Key on every call to an app-facing endpoint. The server rejects missing or mismatched values with HTTP 401. Implementation is a constant-time crypto.timingSafeEqual compare.
Why it’s here: stops trivial curl probing of the check and updates endpoints, and makes rate-limit log signals cleaner (a 401 is an unauthorised caller, not a missing feature). It is not cryptographic proof of identity — anyone who extracts the key from an app binary can forge requests. The real user auth is the licence key.
Rotation. One env var, one source of truth (HELM_CLIENT_KEY). To rotate: generate a new 32-byte random string, ship an app update that embeds it, update the Vercel env, wait for all users to upgrade, retire the old value. Plan for this yearly or on suspected compromise.
Not per-product. Keeping a single shared secret is simpler than per-app keys and doesn’t meaningfully reduce security, since every distributed binary leaks its secret to anyone sufficiently motivated.
API surface
Section titled “API surface”Public — app-facing
Section titled “Public — app-facing”Every route in this section requires the X-Helm-Client-Key header.
POST /api/licence/check
Section titled “POST /api/licence/check”Called on app startup and every 24 hours thereafter.
Request:
{ "key": "HELM-DJ-7K2M-HF9J-3QAX-NBZ8", "product": "helm-dj", "device_id": "<hardware-id>", "os": "darwin-aarch64", "app_version": "0.2.1"}Response — valid key (200):
{ "valid": true, "email": "dj@example.com", "name": "Example DJ", "scopes": ["beta", "export-stems"], "tier": "beta", "expires_at": "2026-10-01T00:00:00Z"}Response — invalid key (200 with valid: false):
{ "valid": false, "reason": "unknown_key" | "wrong_product" | "revoked" | "expired"}Deliberate choice: always 200, never 401. App logic branches on valid. Avoids network-level tools masking the real reason.
Side effect: on success, inserts/updates the activations row and updates licences.last_checked_at.
Admin — authenticated
Section titled “Admin — authenticated”All admin routes require the admin_session cookie (set by POST to /api/admin/login with ADMIN_PASSWORD env var). Middleware rejects unauthenticated requests with 401.
| Route | Purpose |
|---|---|
POST /api/admin/login | Sets admin_session cookie |
POST /api/admin/logout | Clears cookie |
GET /api/admin/applications | List all beta applications (filter by status) |
POST /api/admin/applications/:id/approve | Approve app → create user + issue licence + send email |
POST /api/admin/applications/:id/reject | Mark rejected |
GET /api/admin/users | List users with their licences |
GET /api/admin/users/:id | User detail + activations |
POST /api/admin/licences | Issue new licence for existing user+product |
POST /api/admin/licences/:id/revoke | Set revoked_at = now() |
PATCH /api/admin/licences/:id | Edit scopes, expiry, tier |
Scopes
Section titled “Scopes”JSONB array of string flags. The app fetches scopes on each check and reads them to enable/disable features.
Conventions
Section titled “Conventions”| Scope | Meaning |
|---|---|
beta | Everyone in the closed beta gets this. |
export-stems | Per-deck stem export (future premium feature). |
ma2-sync | Live sync with grandMA2 from Helm Cues. |
cloud-sync | Cross-machine session sync (far future). |
unlimited-decks | Lift the free-tier deck-count limit. |
Scope names are free-form strings — you can invent new ones in admin UI without a migration. Keep them kebab-case, stable once used (apps key off them).
App-side pattern
Section titled “App-side pattern”Apps should treat scopes as additive capabilities:
// svelte store derived from licence check responseconst hasScope = (scope: string) => $licence.scopes.includes(scope);
// UI{#if hasScope('export-stems')} <ExportStemsButton />{/if}Never check for the absence of a scope to enable something — if scopes: [] is the default for beta tier, missing scopes means “feature not enabled for this user”, not “premium locked.” Clearer mental model.
Offline grace period
Section titled “Offline grace period”Operators run shows without internet. App must handle this:
- On every successful check, app stores
last_checked_at+ the response body in Tauri secure storage. - If offline, app reads the last response and uses it if
now - last_checked_at < 7 days. - If offline beyond grace, app shows a dismissable “please reconnect” banner but does not block the app — blocking mid-show would be unacceptable. When reconnected, app re-checks.
- If the server says
revokedafter a period of offline use, the revocation takes effect immediately.
Grace period is 7 days. Long enough to cover a multi-day festival, short enough to catch a stolen key within a week.
Admin UI
Section titled “Admin UI”Lives at /account/admin/* on the website. Minimal React pages, server components where possible.
| Route | What it shows |
|---|---|
/account/admin/login | Single password field. POSTs to /api/admin/login. |
/account/admin | Dashboard — pending applications count, active licences count, recent activations. |
/account/admin/applications | Table of all beta_applications. Filter by status. Click row → drawer with Approve / Reject buttons. |
/account/admin/users | Table of all users. Click row → user detail. |
/account/admin/users/:id | User profile, list of their licences with Revoke + Edit scopes buttons, list of recent activations. |
/account/admin/licences | Flat table of all licences. Filter by product/tier/revoked. |
/account/admin/licences/:id | Single licence detail + activation history. |
Approve flow
Section titled “Approve flow”- Admin clicks Approve on an application row.
- Dialog appears: confirm product, initial scopes (prefilled with
['beta']), optional expiry date. - Submit → server does, in a single transaction:
- Upsert
usersrow by email. - Insert
licencesrow with a freshly-generated key. - Update
beta_applications.status = 'approved'. - Send email via Resend with the key.
- Upsert
- UI updates the application row to
approvedin place.
For the beta phase: single admin password in ADMIN_PASSWORD env var. POST to /api/admin/login with { password }, server compares with constant-time equality, sets admin_session HTTP-only secure cookie signed with SESSION_SECRET.
This is lightweight and adequate for one admin. When you add a second admin, swap to NextAuth (Google OAuth) — schema already supports it via reviewed_by text field on applications.
Use Resend — standard Next.js pairing, free tier covers thousands of emails.
One transactional template to start: beta-approved.tsx — subject “Your Helm DJ beta access”, body includes the key and install link.
Multi-product expansion
Section titled “Multi-product expansion”Already wired in:
productstable is seeded with all three products on day one.licences.product_idis the discriminator./api/licence/checktakesproductin the body — returnswrong_productif the key is for a different product.- Admin UI filter by product everywhere.
When Cues is ready to issue keys:
- No schema migration needed.
- Cues app implements the same check protocol (see DJ handover — it’s copy-paste).
- Admin can issue
helm-cueslicences to existinghelm-djusers without creating a newusersrow.
Environment variables
Section titled “Environment variables”| Name | Where | Purpose |
|---|---|---|
POSTGRES_URL | Vercel (auto) | Set by Vercel Postgres integration |
ADMIN_PASSWORD | Vercel Project Env | Admin-UI gate (one admin, beta phase) |
SESSION_SECRET | Vercel Project Env | Signs the admin_session cookie — 32+ random bytes |
RESEND_API_KEY | Vercel Project Env | Transactional email |
GITHUB_TOKEN | Vercel Project Env | (Already set — auto-update service) |
HELM_CLIENT_KEY | Vercel Project Env and GitHub Actions Secrets | Shared secret for X-Helm-Client-Key. Server reads from Vercel env; app builds read from GH Actions env and bake in via env! at compile time. |
Local dev: mirror all of these in website/.env.local.
Migrations
Section titled “Migrations”Stored under website/db/migrations/ as numbered SQL files. Run by a simple script using @vercel/postgres:
001-initial.sql— all the tables +productsseed above.
Future changes follow 00N-description.sql convention. Run via npm run db:migrate (website-agent handover has the script).
Security notes
Section titled “Security notes”- Keys are not secrets in the cryptographic sense — they’re bearer tokens. Treat them like passwords in logs (never log the full key).
- The
/api/licence/checkendpoint is public but rate-limited by Vercel’s default per-IP limits. If abuse materializes, add deliberate rate limiting bydevice_id. - Admin session cookie:
HttpOnly,Secure,SameSite=Strict, 12-hour expiry. ADMIN_PASSWORDstored as-is in env (not hashed) — it’s a single shared credential you control. When switching to multi-admin, move to proper user records with bcrypt.- No PII beyond email, name, and role. No payment data during beta.
Handover documents
Section titled “Handover documents”Agent-specific instructions for building this out:
- HANDOVER-website-licence-system.md — for the website agent. Covers Vercel Postgres setup, migration runner, all API routes, admin UI pages, Resend integration.
- HANDOVER-dj-licence-integration.md — for the DJ app agent. Covers first-run key dialog, Tauri secure storage, background re-check, scope-gated UI, offline grace.
Both are derived from this doc. If anything here contradicts a handover, this doc wins — update the handover.