Skip to content

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) and expires_at so the transition to Lemon Squeezy or direct Stripe later is additive, not a rewrite.
┌────────────────────────────┐
│ 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 │
└────────────────────────────┘

One database, four tables. SQL chosen for flexibility of admin queries (filter/sort/join) and transactional guarantees on issuing/revoking.

-- 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)
);

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.

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.

Every endpoint is authenticated:

EndpointMechanism
POST /api/licence/checkX-Helm-Client-Key header plus the licence key in the body
POST /api/updates/*, /api/downloads/*X-Helm-Client-Key header
POST /api/admin/loginPassword matched against ADMIN_PASSWORD
/api/admin/* (others)admin_session cookie (set on login, HMAC-signed with SESSION_SECRET)
POST /api/beta/subscribeNone — public signup form, relies on Vercel’s default per-IP rate limits

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.

Every route in this section requires the X-Helm-Client-Key header.

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.

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.

RoutePurpose
POST /api/admin/loginSets admin_session cookie
POST /api/admin/logoutClears cookie
GET /api/admin/applicationsList all beta applications (filter by status)
POST /api/admin/applications/:id/approveApprove app → create user + issue licence + send email
POST /api/admin/applications/:id/rejectMark rejected
GET /api/admin/usersList users with their licences
GET /api/admin/users/:idUser detail + activations
POST /api/admin/licencesIssue new licence for existing user+product
POST /api/admin/licences/:id/revokeSet revoked_at = now()
PATCH /api/admin/licences/:idEdit scopes, expiry, tier

JSONB array of string flags. The app fetches scopes on each check and reads them to enable/disable features.

ScopeMeaning
betaEveryone in the closed beta gets this.
export-stemsPer-deck stem export (future premium feature).
ma2-syncLive sync with grandMA2 from Helm Cues.
cloud-syncCross-machine session sync (far future).
unlimited-decksLift 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).

Apps should treat scopes as additive capabilities:

// svelte store derived from licence check response
const 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.

Operators run shows without internet. App must handle this:

  1. On every successful check, app stores last_checked_at + the response body in Tauri secure storage.
  2. If offline, app reads the last response and uses it if now - last_checked_at < 7 days.
  3. 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.
  4. If the server says revoked after 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.

Lives at /account/admin/* on the website. Minimal React pages, server components where possible.

RouteWhat it shows
/account/admin/loginSingle password field. POSTs to /api/admin/login.
/account/adminDashboard — pending applications count, active licences count, recent activations.
/account/admin/applicationsTable of all beta_applications. Filter by status. Click row → drawer with Approve / Reject buttons.
/account/admin/usersTable of all users. Click row → user detail.
/account/admin/users/:idUser profile, list of their licences with Revoke + Edit scopes buttons, list of recent activations.
/account/admin/licencesFlat table of all licences. Filter by product/tier/revoked.
/account/admin/licences/:idSingle licence detail + activation history.
  1. Admin clicks Approve on an application row.
  2. Dialog appears: confirm product, initial scopes (prefilled with ['beta']), optional expiry date.
  3. Submit → server does, in a single transaction:
    • Upsert users row by email.
    • Insert licences row with a freshly-generated key.
    • Update beta_applications.status = 'approved'.
    • Send email via Resend with the key.
  4. UI updates the application row to approved in 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.

Already wired in:

  • products table is seeded with all three products on day one.
  • licences.product_id is the discriminator.
  • /api/licence/check takes product in the body — returns wrong_product if the key is for a different product.
  • Admin UI filter by product everywhere.

When Cues is ready to issue keys:

  1. No schema migration needed.
  2. Cues app implements the same check protocol (see DJ handover — it’s copy-paste).
  3. Admin can issue helm-cues licences to existing helm-dj users without creating a new users row.
NameWherePurpose
POSTGRES_URLVercel (auto)Set by Vercel Postgres integration
ADMIN_PASSWORDVercel Project EnvAdmin-UI gate (one admin, beta phase)
SESSION_SECRETVercel Project EnvSigns the admin_session cookie — 32+ random bytes
RESEND_API_KEYVercel Project EnvTransactional email
GITHUB_TOKENVercel Project Env(Already set — auto-update service)
HELM_CLIENT_KEYVercel Project Env and GitHub Actions SecretsShared 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.

Stored under website/db/migrations/ as numbered SQL files. Run by a simple script using @vercel/postgres:

  • 001-initial.sql — all the tables + products seed above.

Future changes follow 00N-description.sql convention. Run via npm run db:migrate (website-agent handover has the script).

  • 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/check endpoint is public but rate-limited by Vercel’s default per-IP limits. If abuse materializes, add deliberate rate limiting by device_id.
  • Admin session cookie: HttpOnly, Secure, SameSite=Strict, 12-hour expiry.
  • ADMIN_PASSWORD stored 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.

Agent-specific instructions for building this out:

Both are derived from this doc. If anything here contradicts a handover, this doc wins — update the handover.