إنتقل إلى المحتوى الرئيسي

15 · Licensing & billing

Purpose: Complete design of the commercial layer of Rasd Forms — the Rasd License Token (RLT), its keys and offline verification, the SDK license state machine and enforcement policy, trial and refresh mechanics, the License Service API and data model, billing integration, plans and prices, legal texts, privacy and anti-abuse posture — with acceptance criteria and a test plan.

Audience: Engineers building @rasd/license, @rasd/server/license and the billing integration; developers at UN/NGO organisations who need to know exactly what the license layer does on their devices, what it sends, and how to buy.

TL;DR

  • One organisation buys one subscription for its apps; the SDK receives a Rasd License Token (RLT) — a compact JWS signed with EdDSA/Ed25519 — and verifies it 100 % offline against public keys embedded in @rasd/license. Production devices never contact Rasd (spine §9, INV-5 in 02 · Requirements).
  • Monthly tokens live 60 days and carry 30 days of grace; refresh happens through the customer's backend (tokenEndpoint proxy or X-Rasd-License sync header) when < 14 days remain, jittered, never blocking render — a device can be offline ~90 days without degradation.
  • Six SDK states: evaluating → trial → active → grace → limited, plus invalid. limited is soft by default (watermark "Unlicensed – Rasd Forms", builder read-only, no publish); hard blocks only new submissions. Drafts, sync and export work in every state.
  • The 7-day trial is a signup token (verified email, one per org domain) or a zero-config first-run local trial; dev origins are evaluating forever so nobody needs to reset a trial. No device fingerprinting.
  • Billing: Stripe Billing + Entitlements + Invoicing (+ Stripe Tax); MoR (Paddle/Polar) kept as a swap-in for VAT-averse regions. Suggested list: Starter $39/mo, Team $199/mo, Enterprise from $9,900/yr; 50 % humanitarian discount, invoice/PO/Net-30 path from day one (research/10).
  • Legal: @rasd/core, xlsform, testing, cli, themes are Apache-2.0; the other packages are FSL-1.1-Apache-2.0 plus Rasd Commercial Terms that require an RLT for production use after the trial.
  • The tokens are public by design; abuse resistance comes from apps[] binding, short lifetimes + refresh, and rate limits — never from obfuscation, kill switches or telemetry.

1. Business model recap

AspectDecision (normative in spine §9, §12)
Unit of saleOrganisation + apps (web origins / bundle IDs listed in apps[]). Not per submission, not per end user; seats is a soft, informational claim.
Trial7 days, free, no card. Signup trial token (recommended) or first-run local trial. Optional policy: extend to 30 days on onboarding milestones.
SubscriptionMonthly (60-day rolling tokens) or annual/invoiced (12-month tokens, also usable as an offline license file).
GatingOption A (as configured): all runtime packages are token-gated after the trial. Option B (research recommendation): renderer + storage free, gate builder/sync/enterprise. The features[] claim and per-package gating table (§4.4) make either a configuration change.
EnforcementSoft by default; never data-destructive; export always works.
Code licenseOpen core: Apache-2.0 for core/xlsform/testing/cli/themes; FSL-1.1-Apache-2.0 + Commercial Terms for the rest.

Why this shape: JS component vendors sell per-developer perpetual licences enforced softly and offline (MUI X, AG Grid, Handsontable, SurveyJS), while humanitarian SaaS meters submissions per organisation; neither maps to how an agency budgets an internal product (research/06 §1–2, research/10 §1–2). "Org + apps, monthly or annual, soft enforcement, offline verification" takes the developer-friendly parts of both.


2. Rasd License Token (RLT)

2.1 Format

An RLT is a compact JWS (RFC 7515): base64url(header) . base64url(payload) . base64url(signature), no padding, ASCII only. Signature algorithm is EdDSA over Ed25519 (RFC 8037) — 64-byte signature, 32-byte public key. The only accepted alg is EdDSA; there is no algorithm agility.

Header (fixed order, minified):

{ "alg": "EdDSA", "kid": "rlt-2026a", "typ": "RLT" }

Payload claims:

ClaimTypeExampleRules
iss"rasd""rasd"Constant; anything else ⇒ invalid.
substring"org_01J5Y2C8Q9V3ZK7M4N6P8R0T2W"Organisation id (org_ + UUID v7 base32). Never an email.
plan"trial" | "starter" | "team" | "enterprise""team"Drives dashboards and console messages only; gating uses features.
featuresstring[]["react","native","builder","sync","xlsform"]Package/feature flags (§4.4). "*" = everything.
appsstring[]["https://forms.example.org","https://*.moda.example.org","org.example.monitor"]Web origins (wildcard subdomains) and RN bundle IDs. Empty = any app. Max 50 entries.
seatsinteger (optional)5Soft, informational; shown in useLicense(), never enforced.
iatNumericDate (s)1755252000Issue time.
nbfNumericDate (s)1755252000Not before; ≤ iat + 300 s in practice.
expNumericDate (s)1760436000Token validity end (not the subscription end).
graceinteger days30Days after exp in which the SDK stays fully functional. 0–366.
enforcement"soft" | "hard""soft"Optional, default soft. Chosen by the org in the dashboard.
jtistring"rlt_01J5Y2D3…"Unique token id: rlt_ + a UUID v7 in Crockford base32 (spine §12 — UUID v7 everywhere); revocation and audit key.

Decoded example (Team plan, monthly):

{
"iss": "rasd",
"sub": "org_01J5Y2C8Q9V3ZK7M4N6P8R0T2W",
"plan": "team",
"features": ["react", "native", "element", "storage", "sync", "pwa", "media", "builder", "xlsform"],
"apps": ["https://forms.example.org", "https://*.moda.example.org", "org.example.monitor"],
"seats": 5,
"iat": 1755252000, "nbf": 1755252000, "exp": 1760436000, // exp = iat + 60 d
"grace": 30,
"enforcement": "soft",
"jti": "rlt_01J5Y2D3F6H8K0M2P4R6T8V0X2"
}

Size: header 60 chars, payload 350–500 chars, signature 86 chars ⇒ ≈ 500–650 bytes typical, always < 1 KiB. The SDK rejects tokens > 4 096 bytes before parsing (bounded work).

2.2 Encoding and distribution

The compact string is the only wire form. Ways to hand it to the SDK: createLicense({ token }); RASD_LICENSE environment variable at build time (Vite/Next/Expo public env, documented for CI); .rasdrc ({ "license": "eyJ…" }, git-ignored by the scaffold); data-license attribute on the IIFE script / license attribute on <rasd-form> (11 · PWA & embedding); an offline license file rasd-license.rlt (one line, # comments allowed) for enterprise/air-gapped builds; or at runtime via tokenEndpoint / X-Rasd-License (§7).

2.3 Lifetimes

Plan / channelexpgraceRefreshOffline tolerance
Trial (plan: "trial")7 days from issue (30 with extension)0none7 days
Monthly (Starter, Team)60 days rolling; each successful refresh issues a fresh token30 dayswhen < 14 days to exp≈ 90 days
Annual / invoiced / Enterprise12 months30 daysoptional (dashboard download or refresh)13 months; usable as offline file
Local first-run trial (no token)7 days from first launch (kv)0n/a7 days

3. Key management

  • Embedded verification keys. @rasd/license ships a static map kid → Ed25519 public key (32 bytes, base64url). At any time it contains three keys: current, previous, next (pre-published). Key IDs follow rlt-<year><letter> (rlt-2026a). Keys are constants in source, reviewed like any code change, and shipped in minor releases.
  • Rotation schedule. A new signing key every 12 months; a retired key remains embedded for 15 months after its last token could have been issued (12-month annual tokens + grace + slack). Tokens signed by a retired key remain valid until their own exp.
  • JWKS fallback (online only). Two documents at https://license.rasd.dev/.well-known/: jwks.json (plain RFC 7517, use: "sig", alg: "EdDSA", crv: "Ed25519", for standard tooling) and rlt-keys.jws — the same key set wrapped in a JWS signed by an offline root key (kid: "rasd-root-1", embedded in every SDK release, rotated only on compromise). The SDK never trusts plain JWKS: on UNKNOWN_KID it fetches rlt-keys.jws (only if online, only from dev/CI/admin contexts or when a fetcher is configured), verifies it against the root key, caches it in storage.kv (license.keys, fetchedAt), and re-verifies the token. Field devices without a fetcher simply report invalid: UNKNOWN_KID with a console hint to upgrade @rasd/license.
  • Signing key custody. Signing happens in an isolated signer service with no inbound network except the license API; the private key lives in HashiCorp Vault Transit (ed25519) or an HSM that supports EdDSA (e.g. YubiHSM 2); where a cloud KMS lacks Ed25519, the key is stored KMS-encrypted and only ever decrypted into the signer's memory. Every signature is audit-logged (jti, kid, sub, operator/service identity). The root key is generated in a two-person ceremony on an air-gapped machine, split with Shamir 3-of-5, and used only to sign rlt-keys.jws and emergency key bundles.
  • Compromise plan. Publish a new signing key in a patch release and in rlt-keys.jws; add the compromised kid to a revokedKids list embedded in the next release; re-issue tokens to all active orgs on their next refresh (forced by returning status: "rotate"); communicate via the status page. Root-key compromise ⇒ mandatory SDK upgrade (documented, accepted residual risk).
  • Verification primitive. WebCrypto Ed25519 where available (Safari 17, Firefox 129, Chrome 137 — about 79 % of users), otherwise the bundled @noble/ed25519 (~5 kB); on React Native always noble (research/06 §3.1). No custom crypto (principle P7).

4. Verification algorithm in the SDK

Verification is a pure, synchronous function of (token, embedded keys, clock, host identity) — no I/O — so it can run on the boot path before first paint and never makes rendering await anything (03 · Architecture §5.3). It runs once per boot and once per setToken; the render path only reads the derived state (§19).

4.1 Steps

  1. Bounds & shape. Reject if length > 4 096 or not exactly three non-empty base64url segments (^[A-Za-z0-9_-]+$) ⇒ invalid: MALFORMED.
  2. Header. Decode JSON; require alg === "EdDSA", typ === "RLT", kid string ≤ 32 chars. A wrong typ or any other alg (including none, HS256) ⇒ invalid: WRONG_TYP.
  3. Key lookup. kid in embedded map (or in a root-verified cached bundle) else invalid: UNKNOWN_KID; kid in revokedKidsinvalid: REVOKED.
  4. Signature. Verify the 64-byte signature over the ASCII bytes of header.payload with the selected public key. Ed25519 verification in noble/WebCrypto is constant-time with respect to key material; the SDK compares nothing secret (tokens are public), so no timing-safe string compares are needed. Failure ⇒ invalid: BAD_SIGNATURE.
  5. Claims schema. Parse payload with a strict zod schema (unknown top-level keys are ignored and preserved for forward compatibility). Wrong types ⇒ invalid: SCHEMA; iss !== "rasd"invalid: ISSUER.
  6. Time sanity. nbf > effectiveNow + 300 sinvalid: NOT_YET_VALID (also a clock-tamper signal, §8). exp and grace are not validity conditions here — they drive the state (§5).
  7. App binding. Match host identity against apps[] (§4.2). Mismatch ⇒ invalid: APP_MISMATCH.
  8. Result. { ok: true, claims, kid } or { ok: false, reason }.
// @rasd/license — verification core (simplified); reason values per [17 §10]
export type InvalidReason =
| 'MALFORMED' | 'WRONG_TYP' | 'UNKNOWN_KID' | 'REVOKED' | 'BAD_SIGNATURE' | 'NOT_YET_VALID' | 'APP_MISMATCH'
| 'SCHEMA' | 'ISSUER'; // nine values, ratified in [00 §11](00-decisions-and-conventions.md) and published in [17 §10](17-api-reference.md)
export type HostIdentity = { kind: 'web'; origin: string } | { kind: 'native'; appId: string } | { kind: 'node' };
export function verifyRlt(token: string, ctx: { keys: Record<string, Uint8Array>; revokedKids: Set<string>; now: number; host: HostIdentity; verify: Ed25519Verify }): VerifyResult;
export function matchesApp(pattern: string, host: HostIdentity): boolean;
export function isDevHost(host: HostIdentity): boolean; // localhost, 127.0.0.1, [::1], *.local, Expo dev (__DEV__)

4.2 App / origin binding rules

Frozen jointly with 11 · PWA & embedding §15:

HostIdentityPattern matching
Web (browser, PWA, <rasd-form>, iframe)location.origin = scheme + host + port of the document (inside an iframe: the frame's own origin)Exact origin match, case-insensitive host. https://*.example.org matches any depth of subdomain (a.example.org, a.b.example.org) but not the apex — list https://example.org separately. A pattern without a port matches only the scheme's default port; https://forms.example.org:* matches any port. http:// patterns are allowed but warned. WebView schemes (capacitor://localhost, ionic://) are not dev origins and must be listed literally.
React Native / ExpoBundle/application ID supplied by the host: createLicense({ appId: Application.applicationId }) (expo-application), because @rasd/license has no platform APIsCase-insensitive exact match; suffix wildcard org.example.* allowed. Missing appId on native ⇒ treated as mismatch unless apps is empty (console error explains).
Node / SSR / CI{ kind: 'node' }apps is not evaluated (there is no app); server-side rendering yields evaluating snapshots per 06 · Renderer React §14.
Anyapps: []Matches everything (used for trials, offline enterprise files by request).

Dev origins (localhost, 127.0.0.1, [::1], *.local, Expo dev client / __DEV__ === true) short-circuit to evaluating when no usable token is present; a valid token on a dev origin is still applied normally so developers can test real states.

4.3 State derivation (pure)

// `now` is always effectiveNow (§8), never the raw wall clock.
function deriveState(i: { verified?: VerifyResult; tokenPresent: boolean; now: number; host: HostIdentity; localTrialStartedAt?: number }): LicenseState {
if (i.verified?.ok) {
const c = i.verified.claims, exp = c.exp * 1000, graceEnd = exp + c.grace * 86_400_000;
if (c.plan === 'trial') return i.now < exp ? 'trial' : 'limited';
return i.now < exp ? 'active' : i.now < graceEnd ? 'grace' : 'limited';
}
if (isDevHost(i.host)) return 'evaluating'; // dev wins over an unusable token (§4.2); reason still logged
if (i.tokenPresent) return 'invalid'; // present but unusable — see matrix note
if (i.localTrialStartedAt !== undefined && i.now < i.localTrialStartedAt + 7 * 86_400_000) return 'trial';
return 'limited';
}

Order matters: a valid token is applied on every host (developers can test active/grace/limited), a broken token on a dev origin still yields evaluating with the reason on the console — that is the "only when no usable token is present" rule of §4.2 and 11 · PWA & embedding §15, and it is why invalid in the matrix of §5.2 is a production-only state.

4.4 Feature flags and package gating

features[] names map to packages/capabilities. Each package asks license.can(feature); a missing feature degrades exactly like limited for that package (never a throw in the render path).

FeatureGatesWhen absent
react, native, elementRenderers, custom elementWatermark (soft) / no new submissions (hard)
storage@rasd/storage-* (write path)Reads, drafts, export always work; watermark
sync@rasd/syncNever disabled: sync of existing data continues in all states; flag only affects console/dashboard
pwa, mediahelpers, capture adaptersAdapters still function; console warning
builder@rasd/builderRead-only, no publish (08 · Builder)
xlsformimportXlsform / exportXlsformNever throws: the result carries an extra W_UNLICENSED entry in warnings (17 §12) and the builder shows a notice; rasd convert xlsform exits 2
records, openrosaeditable records, openrosa sync transportThe transport/feature refuses to initialise (createSyncEngine keeps the rsp path); console error once
*all

Option B is implemented by marking react, native, element, storage, media, pwa as always granted in @rasd/license config (freeFeatures), leaving builder, sync-dashboarding, xlsform, records, openrosa gated. No renderer code changes.


5. License state machine

stateDiagram-v2
[*] --> evaluating: no usable token and dev host
[*] --> trial: trial token, or first-run local trial on production host
[*] --> active: paid token and now before exp
[*] --> limited: no token on production host, local trial used up
[*] --> invalid: token present but verification failed, production host only
evaluating --> active: token applied via setToken or refresh
trial --> active: paid token applied
trial --> limited: trial exp reached
active --> active: refresh ok, new exp
active --> grace: exp reached
active --> limited: refresh returned revoked, token dropped
grace --> active: refresh ok
grace --> limited: exp plus grace days reached
limited --> active: refresh ok or token applied
invalid --> active: valid token applied, e.g. after key bundle fetch
active --> invalid: re-verification at boot fails, e.g. origin changed

5.1 Transitions, timers and triggers

TriggerMechanismDetail
BootcreateLicense() reads kv: license.token, license.lastServerTime, license.lastSeenLocal, license.trial.startedAt; verifies synchronously; emits first state before first paint≤ 5 ms on a 2019 low-end Android (noble verify ≈ 1–2 ms)
TickRe-derive state every 60 s ± 10 s (jittered) and on visibilitychange/AppState active; exact one-shot timers at exp and exp + grace (chained setTimeout ≤ 2^31−1 ms)Ensures the boundary is crossed within a minute even without user activity
Refresh windowexp − effectiveNow < 14 d or state ∈ {grace, limited, invalid: UNKNOWN_KID}Runs the fetcher (§7) with an initial jitter of 0–30 s after the trigger; never awaited by render
Refresh triggersboot, online, app foreground, sync success (licenseRefreshed bridge), manual refresh()Coalesced: at most one in-flight refresh per storage namespace (Web Locks on web)
Refresh cooldownsuccess ⇒ no automatic retry for 24 h; failure ⇒ full-jitter backoff 1 min → 6 hPersisted in kv: license.refresh.nextAt
Token appliedsetToken(token, { serverTime? }) from fetcher, X-Rasd-License, or hostOnly replaces the stored token if it verifies and has exp ≥ current token's exp (never regress)
Local trialkv: license.trial.startedAt set on first boot on a production host with no tokenEnds after 7 d; not extendable locally
Clock rollbacksee §8Freezes improvement; never regresses

5.2 What each state permits

Capabilityevaluatingtrialactivegracelimited (soft)limited (hard)invalid
Render forms, start new submissions✔ + watermark✖ (message, RASD_LICENSE_EXPIRED via onError)as limited (soft)
Finish drafts, autosave
Sync (outbox, attachments, pulls)
storage.export()
Builder edit / publish✔ / ✔✔ / ✔✔ / ✔✔ / ✔ (banner)read-only / ✖read-only / ✖read-only / ✖
Watermark✔ (+ blocking message)
Consolenotice oncenotice with days leftsilentwarn once/daywarn once/sessionwarn once/sessionerror once/session with reason
rasd license check exit code0000 (warns)223

INV-3 in 02 · Requirements is the contract: no state blocks export, sync or finishing drafts.

Two reading notes on the invalid column: it is a production-only state (a token that fails verification on a dev origin yields evaluating, §4.3), and it always behaves as limited soft — the claims of a token that failed verification are never honoured, so enforcement: "hard" can never be inferred from one. A previously verified token that is later dropped (revoked, §7.3) keeps its own enforcement value.

5.3 Watermark specification (renderer, soft limited)

  • Text: "Unlicensed – Rasd Forms" (localised: ar "غير مرخّص – Rasd Forms"), rendered by the Watermark layout component (06 §5; slots.watermark and the Form watermark part may restyle/reposition it but must not remove it — 12 · Theming §5.2, EULA §15).
  • Parts follow the theming contract (spine §10): rasd-Watermark__root / rasd-Watermark__text with data-scope="rasd" data-part="root|text", so hosts can restyle it through classNames/styles without replacing the component.
  • Placement: sticky at the inline-end bottom corner of .rasd-root (RTL-aware), inside the provider root so it never covers host chrome; the root reserves 28 px bottom padding so no control is obscured.
  • Style: 12 px, --rasd-color-onSurface on --rasd-color-surfaceVariant, opacity 0.85, radius --rasd-radius-sm; role="note", aria-label = text, pointer-events: none; on native an absolutely positioned View with accessibilityRole="text", importantForAccessibility="yes".
  • Behaviour: appears within one render of the transition to limited; disappears within one render of active; not shown in evaluating (dev origins).

5.4 Builder and publish under limited

Builder becomes read-only, Publish disabled, autosave and export continue (08 · Builder §1, §4.3); <LicenseBanner> shows the state, exp, and the renew link the host configured (licenseUrl prop). Nothing already published stops working.


6. Trial mechanics

Signup trial token (recommended)First-run local trial
Hownpx rasd license trial or dashboard: work email → magic link → POST /v1/trials issues plan: "trial", exp = +7 d, grace 0, apps: [], features: ["*"]No token on a production host: SDK writes kv: license.trial.startedAt and behaves as trial for 7 days
LimitsOne per org domain (email domain, public-suffix aware; free-mail domains count per address) per 12 months; disposable-email blocklist; 5 requests/h per IPOne per storage namespace; deleting site data restarts it — accepted
PurposeReal trials with a contact, conversion emails, extensionZero-config "does it work in my app"
Extension (optional policy)Once, to 30 days total, when an onboarding milestone is reached — spine §9 names first form rendered and first sync. Devices never phone home (INV-5), so the milestone is observed server-side as the org's first successful POST /v1/tokens/refresh through its tokenEndpoint/RSP proxy (which is what a first sync produces), or self-declared by the verified owner clicking "Extend" in the dashboardNot extendable

Abuse expectations: the local trial is a UX convenience, not security — clearing storage or changing namespace restarts it, and we do not fingerprint devices to stop that. The lever against "eternal trials" is that dev origins are unlimited anyway (no reason to cheat during development) and production use without a token ends in the watermark, not in a wall. Trial length is the shortest in the peer set (SurveyCTO 15, Formbricks 14, Form.io/AG Grid 30 days) — hence the optional 30-day extension and a 30-day money-back window on the first invoice (research/10 §5).


7. Refresh protocol

7.1 Channels

// @rasd/license — full signature in [17 §10]
export type TokenFetcher = (current: string | null) => Promise<{ token: string; serverTime?: string } | null>;
export function createLicense(opts: {
token?: string; // initial / offline file
tokenEndpoint?: string; // host backend URL; built-in fetcher POSTs there
siteKey?: string; // prototyping only — direct call to license.rasd.dev
fetcher?: TokenFetcher; // custom transport (overrides tokenEndpoint/siteKey)
storage: StorageAdapter; // kv persistence
appId?: string; // RN bundle id
freeFeatures?: string[]; // always-granted features — the Option B switch (§4.4)
clock?: () => number; // tests
}): LicenseHandle; // { state$, getState(), getSnapshot(), refresh(), setToken(token, opts?), can(feature), on(event, h), dispose() }
ChannelWho calls RasdProduction?Notes
(a) tokenEndpoint on the customer backendCustomer backend, with the org secretYes (recommended)SDK: POST {tokenEndpoint} body { current, sdk: "@rasd/license/1.x", app }, credentials per host (cookies/bearer via getAuthToken when inside RasdProvider). Backend forwards to POST https://license.rasd.dev/v1/tokens/refresh with Authorization: Bearer rsk_live_…, may cache the token per org for up to 24 h and serve all devices from cache. @rasd/server/license ships tokenEndpointHandler({ orgSecret, licenseUrl?, cacheMs? }) as a Hono handler (17 §15), mountable in a Next.js Route Handler; the docs carry equivalent 20-line recipes for Express, Django and Laravel.
(b) X-Rasd-License sync headerCustomer RSP server (fetches via (a) logic on its side)YesAny RSP response may carry X-Rasd-License: <RLT> (10 · Sync protocol §1); createSyncEngine({ …, license }) forwards it to license.setToken(token, { serverTime: Date header }) and emits licenseRefreshed. Zero extra requests from devices.
(c) siteKeyThe device, directlyNo — prototypingPOST /v1/tokens/site { siteKey: "rpk_…", app }; site key is public and bound to apps; 10 req/min per app; console warning on any non-dev host: "siteKey mode contacts Rasd from end-user devices — use tokenEndpoint in production".
(d) manualHost codeYessetToken() from any source (config service, MDM push, offline file).

7.2 Request / response (host ↔ Rasd)

// POST /v1/tokens/refresh Authorization: Bearer rsk_live_… Content-Type: application/json
{ "current": "eyJhbGciOiJFZERTQSIs…", "app": "https://forms.example.org", "sdk": "@rasd/license/1.2.0" }

// 200
{ "status": "ok", "token": "eyJhbGciOiJFZERTQSIs…", "serverTime": "2026-08-15T10:00:00Z", "exp": "2026-10-14T10:00:00Z" }
// 200 — subscription lapsed beyond dunning; keep the current token, no new one
{ "status": "lapsed", "serverTime": "…", "message": "Subscription ended 2026-07-30; renew at https://…" }
// 200 — token or org revoked (fraud/chargeback); SDK drops the token
{ "status": "revoked", "serverTime": "…", "reason": "chargeback" }
// 200 — signing key rotated; a new token follows in `token`
{ "status": "rotate", "token": "…", "serverTime": "…" }
// 401 invalid secret · 403 app not in org's apps · 404 org unknown · 429 rate limited (Retry-After)

The serverTime (or the HTTP Date header) updates the clock guard (§8). Rasd's servers log sub, jti, app, sdk, timestamp — nothing else (§16).

7.3 Failure handling

FailureSDK behaviour
Offline / network error / 5xx / 429Keep current token and state; backoff 1 min → 6 h full jitter; retry on next trigger. Emits RASD_LICENSE_REFRESH_FAILED as an event (onError in dev builds only) — never thrown into render.
401 / 403 / 404Console error once (misconfiguration); backoff 24 h; state unchanged (grace still applies).
lapsedState unchanged; the existing token rides out exp + grace; console note with renewal URL once/day.
revokedDrop stored token immediately ⇒ limited (soft unless the dropped token said hard); persist kv: license.revokedJti. Only affects devices that made the call — offline devices proceed on grace.
New token fails verificationDiscard it, keep the old one, console error UNKNOWN_KID/BAD_SIGNATURE (also triggers key-bundle fetch when possible).
New token has earlier exp than currentDiscard (never regress).

7.4 Walk-through: a device offline for weeks

Team plan, monthly; enumerator's phone syncs through the customer's RSP server, which attaches X-Rasd-License.

DayEventState
0Sync ⇒ fresh token, exp = day 60, grace 30; lastServerTime = day 0active
3Phone leaves coverageactive
46exp − now < 14 d ⇒ refresh attempts start; all fail silently (offline), backoff climbs to 6 hactive
60exp reachedgrace — no visible change in the renderer; builder (if any) shows a banner; console warns once/day
90exp + 30 dlimited (soft): watermark appears; collection, drafts, autosave and outbox continue
95Back in coverage: sync pushes 92 days of submissions; RSP response carries a new tokenactive within one render; watermark gone; nothing lost
95 (variant)Subscription lapsed in June; RSP has no new token to attachstays limited; all data syncs and can be exported; org admin sees the state in the dashboard

If the phone was rebooted with a wrong clock during this period, §8 keeps the state monotonic.


8. Clock-tampering mitigations

Fully offline devices cannot be protected from clock manipulation ("what the offline device says is the time … is the time" — research/06 §3.3); low-end field phones also frequently have wrong clocks by accident. Policy: be lenient, never punish, never let time move backwards for licensing purposes.

  1. Persist kv: license.lastSeenLocal = max local time observed (updated on boot and every tick) and kv: license.lastServerTime = last serverTime/Date header from a refresh or RSP response.
  2. effectiveNow = max(localNow, lastSeenLocal, lastServerTime); all state derivation uses effectiveNow.
  3. If localNow < lastSeenLocal − 24 hclockRolledBack = true: state can still get worse as effectiveNow advances via the tick, but grace is not re-extended and lastSeenLocal is not lowered. Console warns once. useLicense().clockSuspect === true.
  4. nbf > effectiveNow + 300 sinvalid: NOT_YET_VALID (usually a clock far in the past). iat more than 5 min in the future is tolerated with a warning (accepted skew).
  5. On any successful refresh, lastSeenLocal and lastServerTime are reset to server time, which also repairs the case where a clock was set far into the future and back (the user only ever hurts themselves temporarily; grace covers the gap).
  6. Within a session, performance.now() deltas advance effectiveNow monotonically regardless of wall-clock changes.

9. Revocation

  • Primary mechanism = short life + refresh. A cancelled or lapsed subscription simply stops receiving new tokens; devices ride out exp + grace (≤ 90 days monthly, ≤ 13 months annual). This tail is a deliberate product decision, priced in.
  • Explicit revocation (POST /v1/tokens/{jti}:revoke, or org-level for fraud/chargeback) is delivered on the next refresh (status: "revoked") and via X-Rasd-License: revoked from a customer's RSP server if they choose to propagate it. Offline devices are unaffected until they refresh — accepted.
  • No client-side blocklists of jtis are shipped in the SDK (they cannot reach offline devices and bloat the bundle). revokedKids (compromised signing keys) is the only embedded list.

10. Developer UX

10.1 Console messages (once per state per session, prefixed [rasd/license])

StateMessage
evaluatingDevelopment mode on localhost — full features, no token. Production apps need a Rasd License Token: https://rasd.dev/license
trialTrial: 6 days left (org_01J5…). Add a subscription at https://rasd.dev/dashboard
graceLicense expired on 2026-10-14 and is in its 30-day grace period (14 days left). Refresh has failed 12 times: <last error>. Check tokenEndpoint.
limitedUnlicensed – Rasd Forms. Forms keep working with a watermark; builder is read-only; data export and sync are unaffected. Renew: https://…
invalidLicense token rejected (APP_MISMATCH): origin https://staging.example.org is not in apps[] ["https://forms.example.org", …]. Treated as no token.
refresh failureToken refresh failed (401 from /api/rasd/license) — check the org secret on your backend. Current token still valid until …
siteKey on non-dev hostwarning per §7.1

Messages name the state, the date and the concrete next step — most incumbent complaints are UX ("renewed key not applied", MUI #20673) not policy (research/06 §1). The docs carry a "renewed token not applied" troubleshooting page (cached token has later exp? host cache 24 h? origin mismatch on staging? env not rebuilt into bundle?).

10.2 Error codes (RasdError)

CodeWhenThrown or event
RASD_LICENSE_INVALIDtoken fails verification; details.reason: InvalidReason, details.apps, details.hostEvent on state$/onError; rasd license check exit 3; never thrown from render
RASD_LICENSE_EXPIREDhard enforcement blocks a new submission; details.exp, details.graceUntilReturned via onError and FormRenderer blocking message; drafts unaffected
RASD_LICENSE_REFRESH_FAILEDfetcher failed; details.status, causeEvent only

10.3 Hooks and components

// @rasd/react and @rasd/native (identical)
useLicense(): {
state: 'evaluating' | 'trial' | 'active' | 'grace' | 'limited' | 'invalid';
plan?: 'trial' | 'starter' | 'team' | 'enterprise';
features: string[];
expiresAt?: string; // ISO
graceUntil?: string; // ISO
enforcement: 'soft' | 'hard';
reason?: InvalidReason; // when invalid
clockSuspect: boolean;
seats?: number;
can(feature: string): boolean;
refresh(): Promise<void>; // manual, rate-limited to once per 30 s
};
<LicenseBanner variant="inline" | "toast" licenseUrl="https://…" /> // admin/builder surfaces; renders nothing in evaluating/active
<Watermark /> // layout component, auto-mounted by FormRenderer in limited

useLicense() re-renders only on state transitions (06 §4); RasdProvider without licenseevaluating on dev hosts, else limited after the local trial.

10.4 CLI and CI

rasd license check [--token|--env RASD_LICENSE|--file rasd-license.rlt] [--app https://forms.example.org] [--json] prints state, exp, grace, features, apps; exit 0 ok, 2 limited, 3 invalid. rasd license trial (signup), rasd license refresh (uses RASD_ORG_SECRET from CI secrets, writes the token to .rasdrc or stdout), rasd doctor includes the license line (03 §10). Tokens are never committed: the scaffold git-ignores .rasdrc and documents RASD_LICENSE injection.


11. License Service API (@rasd/server/license, Rasd Cloud at https://license.rasd.dev)

11.1 Endpoints

Method & pathAuthPurposeRate limit
POST /v1/trials { email, orgName?, apps? }noneStart trial: sends magic link; on verify creates org + issues trial RLT5/h per IP; 1 per email per 30 d; 1 per org domain per 12 months
POST /v1/trials:verify { code }noneReturns { token, org, secret } (secret shown once)10/h per IP
POST /v1/trials/{id}:extenddashboard sessionOne-time extension to 30 d (policy)1 per trial
POST /v1/tokens { app?, ttlDays? }org secretExchange secret → fresh RLT (bootstrap, CI, offline file). ttlDays ≤ plan max60/min per org
POST /v1/tokens/refresh { current?, app?, sdk? }org secretRefresh (§7.2). Idempotent within 60 s (returns the same token)60/min per org
POST /v1/tokens/site { siteKey, app }site key (public)Prototyping refresh from devices; apps must match10/min per app, 1 000/day per key
POST /v1/tokens/{jti}:revoke { reason }dashboard session (owner) or adminRevoke one token
GET /v1/orgs/{orgId}/tokens?cursor=dashboard sessionList issued tokens (jti, kid, iat, exp, app, revoked)60/min
GET/POST/DELETE /v1/orgs/{orgId}/appsdashboard sessionManage apps[] patterns (≤ 50) — takes effect on next refresh60/min
POST /v1/orgs/{orgId}/secrets:rotatedashboard session (owner)New rsk_live_…; old one valid 24 h5/day
POST /v1/orgs/{orgId}/enforcement { enforcement }dashboard session (owner)soft/hard for future tokens
GET /.well-known/jwks.json, GET /.well-known/rlt-keys.jwsnonePublic keys (plain / root-signed)CDN-cached 1 h
POST /v1/webhooks/stripeStripe signatureBilling events (§12)
POST /v1/admin/orgs/{orgId}/entitlements { plan, features, paidThrough, ttlDays }admin (internal IdP, 2 approvers for > 12 months)Manual "PO paid" / LTA activation; mints annual tokenaudit-logged
GET /v1/orgs/{orgId}/statusdashboard sessionPlan, features, paidThrough, dunning state, apps, last refresh per app60/min

Auth material: org secret rsk_live_<43 base64url chars> (server-side only, hashed at rest with SHA-256, shown once), site key rpk_<…> (public), dashboard sessions (magic-link/OIDC), admin via internal SSO. All endpoints return RasdError-shaped JSON { code, message, details }; 429 carries Retry-After.

11.2 Data model

erDiagram
ORG ||--o{ APP : "apps[]"
ORG ||--o{ SECRET : "org secrets"
ORG ||--o{ SUBSCRIPTION : has
SUBSCRIPTION ||--o{ ENTITLEMENT : grants
ORG ||--o{ TOKEN : issued
SIGNING_KEY ||--o{ TOKEN : signs
ORG ||--o{ TRIAL : started
ORG ||--o{ AUDIT_EVENT : logs
ORG {
string id PK
string name
string domain
string plan
string enforcement
string billingProvider
string stripeCustomerId
timestamp createdAt
}
APP {
string id PK
string orgId FK
string pattern
string kind
timestamp createdAt
}
SECRET {
string id PK
string orgId FK
string hash
timestamp createdAt
timestamp expiresAt
}
SUBSCRIPTION {
string id PK
string orgId FK
string provider
string externalId
string status
timestamp paidThrough
timestamp dunningSince
int seats
}
ENTITLEMENT {
string id PK
string subscriptionId FK
string feature
string source
}
TOKEN {
string jti PK
string orgId FK
string kid FK
string plan
json features
json apps
timestamp iat
timestamp exp
int graceDays
string enforcement
string app
string channel
boolean revoked
string revokeReason
}
SIGNING_KEY {
string kid PK
string publicKey
string custodyRef
timestamp activeFrom
timestamp retiredAt
boolean compromised
}
TRIAL {
string id PK
string orgId FK
string email
timestamp verifiedAt
timestamp extendedAt
string jti
}
AUDIT_EVENT {
string id PK
string orgId FK
string type
json data
string actor
timestamp at
}

Postgres, one schema; TOKEN rows are retained 24 months for billing disputes then purged; AUDIT_EVENT 24 months; IP addresses live only in the edge/security logs (30 days). The token issuance policy is a single pure function policy(org, subscription, now) → { issue: boolean; ttlDays; graceDays; features; reason } covered by table tests.


12. Billing integration

12.1 Provider choice

CriterionStripe Billing + Entitlements + Invoicing (+ Tax)Paddle (MoR)Polar (MoR)Lemon Squeezy
Fees (research/06 §2.2)0.7 % billing + 2.9 % + 30¢; Invoicing 0.4 % capped $25 % + 50¢Starter 5 % + 50¢; Pro $20/mo, 3.8 % + 40¢5 % + 50¢ (+0.5 % subs)
VAT/GST handledStripe Tax add-on; not MoR (Managed Payments MoR in preview Feb 2026)YesYesYes
Invoices, POs, Net-30, bank transferYes: collection_method: send_invoice, days_until_due, PO custom field, bank transfer, tax_exempt"contact us"limitedlimited
Feature entitlementsEntitlements API + entitlements.active_entitlement_summary.updatednolicense-key benefitlicense API
VerdictDefault. Best fit for UN POs and tax-exempt invoicingSwap-in for VAT-averse regionsalternative MoRavoid: mid-migration to Stripe

The license service isolates the provider behind BillingAdapter { onWebhook(raw) → BillingEvent[]; getEntitlements(orgId); createInvoice(...) } so an MoR can be added without touching token policy.

12.2 Event → token policy

Billing eventSubscription stateToken policy
checkout.session.completed / first invoice.paidactive, paidThrough = period endIssue on refresh: exp = now + 60 d (monthly) or + 12 mo (annual), grace 30. Send onboarding email with secret + tokenEndpoint recipe.
invoice.paid (renewal)active, paidThrough advancedunchanged
entitlements.active_entitlement_summary.updatedRecompute features[] from Stripe Feature lookup_keys (rasd_builder, rasd_sync, rasd_native, rasd_xlsform, rasd_records, rasd_openrosa, …); payload capped at 10 ⇒ follow entitlements.url; nightly reconcile with List Active Entitlements
invoice.payment_failed / customer.subscription.updated (past_due)past_due, dunningSinceDunning: keep issuing tokens for 21 days (Stripe Smart Retries window); emails day 0/7/14/21; dashboard banner
customer.subscription.updated (unpaid) or customer.subscription.deletedlapsedStop issuing; refresh returns status: "lapsed"; devices ride out exp + grace; org data untouched
charge.dispute.createddisputedRefresh returns revoked for that org until resolved (only fraud path that revokes)
Manual admin "PO paid" (/v1/admin/orgs/{id}/entitlements)active until paidThrough (12–36 months)Mint annual token(s) immediately; downloadable offline file
sequenceDiagram
autonumber
participant S as Stripe
participant L as License service
participant DB as Postgres
participant H as Host backend (tokenEndpoint)
participant D as Field device SDK
S->>L: invoice.paid
L->>DB: subscription.active, paidThrough
S->>L: entitlements summary updated
L->>DB: org.features = map(lookup_keys)
H->>L: POST /v1/tokens/refresh (Bearer org secret)
L->>DB: policy(org, subscription, now)
L-->>H: status ok, token exp +60 d, serverTime
D->>H: RSP sync request
H-->>D: 200 + X-Rasd-License token
D->>D: verify offline, state active

Webhooks are verified (Stripe signature, 5-min tolerance), idempotent by event id, processed from a queue with retries; a nightly reconciliation job compares Stripe subscriptions/entitlements with SUBSCRIPTION/ENTITLEMENT and alerts on drift.

12.3 Invoice / PO path (UN agencies)

Stripe Invoicing with send_invoice, days_until_due: 30 (60 on request), PO number and cost-centre custom fields, bank-transfer instructions (USD and EUR accounts), customer.tax_exempt = "exempt" for entities covered by the 1946 Convention, reverse-charge notes for EU NGOs; multi-year LTAs (up to 36 months, price locked) entered via the admin action. UNGM registration and a vendor pack (DPA, subprocessor list — none for the runtime, security questionnaire, architecture diagram) are launch prerequisites (research/10 §3.2, §5).


13. Plans and suggested price points

Suggested list prices (USD, to be validated before GA; the token makes them configuration):

StarterTeamEnterprise
Price$39/mo or $390/yr$199/mo or $1,990/yrfrom $9,900/yr, invoiced
Humanitarian price (UN, INGO/NGO, academic, government; self-attested)$19/mo · $190/yr$99/mo · $990/yrfrom $4,950/yr
Apps (apps[])1 production appup to 5unlimited
Seats (soft)210unlimited
featuresreact native element storage sync pwa media builder+ xlsform records* incl. openrosa, SSO for builder
Token60 d + 30 d grace60 d + 30 d12 months + 30 d, offline license file, custom enforcement
Supportcommunity, docsemail, 2 business dayspriority fixes, TAM, DPA, security-questionnaire support, source escrow, LTA-ready contract, EUR quotes
Money-back30 days on first invoice30 daysper contract
FreeTeam plan for local/national NGOs with annual budget < USD 1 M, organisations responding to an IASC system-wide emergency (12 months), OSS projects and DPGs, students

Justification (research/10 §1): Starter at $390/yr sits between MUI X Pro ($299/dev/yr) and SurveyJS Basic ($569 + $229 renewal) while including offline storage, sync and RN; Team at $199/mo equals ODK Cloud Standard and undercuts Kobo Teams for-profit ($309) and Form.io's Enterprise Form Builder module alone ($660/mo), and, after the humanitarian discount ($99/mo), sits just under the Kobo nonprofit band (Professional $129 – Teams $249 on annual billing); Enterprise from $9,900/yr is below CommCare Enterprise ($4,000+/mo) and Form.io builder + API server (≈ $11,880/yr) and is sized for an agency PO. Annual discount is 2 months free (≈ 17 %), inside the 10–20 % norm. The 50 % humanitarian discount is published, not negotiated, and deeper than Kobo's ~20 % nonprofit column because Rasd is a library, not a hosted cost centre.


14. Self-hosted / offline license option (Enterprise)

  • Offline license file rasd-license.rlt: a 12-month (or contract-length) RLT with grace 30, optionally apps: [], loaded via createLicense({ token }), RASD_LICENSE, or the IIFE data-license. No Rasd endpoint is ever contacted; renewal is a new file from the dashboard or by email 60 days before exp (automated reminder).
  • Self-hosted proxy: @rasd/server/license ships tokenEndpointHandler() and xRasdLicenseMiddleware() for the RSP reference server (17 §15), so an agency runs everything on its own infrastructure and only its backend talks to Rasd (or nothing does, with the offline file).
  • Perpetual fallback (contract option): a token with exp = contract end + 10 years and features frozen, escrowed with the source, so a customer is never stranded if Rasd disappears.
  • Not offered: self-signed tokens or customer keys in the SDK — signing stays with Rasd; the reference license service code is published for transparency, not for issuing RLTs.

Code licences (spine §12). @rasd/core, @rasd/xlsform, @rasd/testing, @rasd/cli, @rasd/themes: Apache-2.0 (patent grant, procurement-friendly, DPG-eligible). @rasd/react, @rasd/native, @rasd/element, @rasd/builder, @rasd/storage*, @rasd/sync, @rasd/pwa, @rasd/media, @rasd/license, @rasd/server: FSL-1.1-Apache-2.0 — source-available, each version converts to Apache-2.0 two years after release; FSL forbids "competing use" (offering Rasd Forms as a competing product/service) but otherwise permits use, so the RLT requirement lives in the accompanying Rasd Commercial Terms (LICENSE-COMMERCIAL.md in every commercial package, referenced from package.json#license as SEE LICENSE IN LICENSE.md with SPDX FSL-1.1-Apache-2.0 in headers). Neither FSL nor the Commercial Terms is OSI-approved; only the Apache-2.0 packages count towards a Digital Public Good submission (research/10 §3.1, research/06 §4). NOTICE files carry third-party attributions (SQLCipher, noble).

EULA / Commercial Terms outline (drafted by counsel; this is the engineering-relevant skeleton):

  1. Definitions — Organisation, App (origin/bundle id), Token, Trial, Production Use, Enumerator Device.
  2. Grant — non-exclusive, non-transferable right to use the Commercial Packages in the Apps listed in the Token for the subscription term; unlimited end users, submissions and devices; unlimited internal developers (seats informational).
  3. Trial — 7 days, evaluation and pilot use, no card; dev-origin evaluation unlimited.
  4. Restrictions — no removal/obscuring of the watermark or state notices in limited; no circumvention of token verification; no redistribution of the Commercial Packages as a competing forms toolkit; no self-issued tokens; FSL competing-use clause.
  5. Data — Rasd receives no form data, no beneficiary data, no device data; only license-refresh metadata (§16); DPA available; alignment with UN HLCM Personal Data Protection Principles.
  6. Fees, taxes, invoicing — tax-exempt handling for entities under the 1946 Convention; Net-30; POs; refunds (30-day first invoice).
  7. Term, dunning, termination — 21-day dunning, grace tail as designed; on termination the software degrades to limited (never data-destructive); export always permitted; source conversion under FSL unaffected.
  8. Humanitarian clause — express permission for offline, emergency and shared-device use; free tiers per §13; no clause survives that could require disabling collection mid-operation.
  9. Warranty, liability cap (12 months of fees), indemnity for IP.
  10. UN compatibility — willingness to accept an agency's General Conditions of Contract (privileges & immunities, UNCITRAL arbitration), governing law fallback otherwise.
  11. Public token statement — the Token may appear in client bundles; secrecy is not required; abuse remedies are contractual.

16. Privacy of license checks

QuestionAnswer
What does an enumerator device send to Rasd?Nothing. Verification is offline; refresh goes through the customer's backend or the RSP header (INV-5).
What does the customer backend send on refresh?current token (contains sub, jti, plan, apps — no PII), app pattern being refreshed, sdk version. HTTPS only.
What does Rasd store?TOKEN rows (24 months), audit events (24 months), the org owner's email and billing data (account lifetime; Stripe is the billing subprocessor). Request IPs only in edge security logs, 30 days.
Any form or beneficiary data?Never; the runtime has no subprocessors. Rasd's SOC 2 Type 1 scope statement covers only the license service and update distribution (research/11 §13).
Trial signupEmail (verified), optional org name; disposable-domain check; no third-party enrichment.
Local trialOnly a timestamp in the app's own storage namespace; no fingerprint.
DocumentationData-flow diagram, DPA, subprocessor list and this table ship in the vendor pack.

17. Anti-abuse posture

We do: bind tokens to apps[]; keep tokens short-lived and refreshed through an org-secret channel; hash secrets, rotate keys, rate-limit every endpoint; one trial per org domain with a disposable-email blocklist; watermark and read-only builder in limited; rasd license check failing CI; contractual remedies.

We explicitly do not: fingerprint devices; phone home from end-user devices; obfuscate or minify-away the SDK's verification (it is source-available and easy to bypass — enforcement is legal, as with every incumbent); ship kill switches or remote wipe via licensing; block export, sync or draft completion; ship jti blocklists to devices; count submissions, end users or devices; contact anyone at an org that did not sign up.

Assume the token is public. The value is refresh, support, updates and the contract — not secrecy (research/06 §3.8).


18. Failure modes and recovery

Every row obeys the same rule: a licensing failure degrades the commercial surface, never the field data.

FailureDetectionBehaviour and recovery
Rasd license service outagerefresh 5xx / timeoutDevices keep the current token and state; the customer backend serves its ≤ 24 h cache; the 30-day grace tail absorbs multi-day outages. Status page + incident note; no device action.
tokenEndpoint misconfigured (returns an HTML login page, 200 with the wrong content type, empty body)response is not JSON or has no tokenTreated as a refresh failure: token and state unchanged, console error once with the response status and the first 100 characters of the body, backoff 24 h.
Proxy returns a token for a different orgsub ≠ current token's sub (and no setToken override)Discarded with a console error; prevents a backend bug from swapping a whole fleet onto the wrong organisation.
storage.kv unavailable (private mode, quota, locked DB)adapter throws on read/writeLicense runs in memory for the session: a configured token still verifies and works; the local trial cannot be persisted, so a production host with no token goes to limited immediately rather than granting an endless 7-day trial. Console warning names the storage error.
Corrupted or truncated token in kvverification fails at bootToken dropped and the kv entry cleared, state falls back to the no-token path, a refresh is triggered on the next trigger.
Key-bundle fetch blocked (CSP, proxy, air gap)fetch rejects or fails root verificationStays invalid: UNKNOWN_KID with the "upgrade @rasd/license" hint; the plain jwks.json is never trusted as a fallback; render is never blocked.
Two tabs / two engines refresh at onceWeb Locks (web), single engine instance (native)Single-flight per storage namespace; the loser picks up the winner's token from kv on its next tick.
Webhook replay, duplicate or out-of-order deliveryprocessed-event-id tableIdempotent by event id; policy(org, subscription, now) is a pure function of current DB state, so replays converge; the nightly reconciliation job is the backstop.
Stripe outage or webhook backlogreconciliation drift alertNo customer is cut off by a billing outage: 21-day dunning plus 30-day grace means the token tail always outlives the incident.
Signing key compromisedout-of-band§3 compromise plan: new key in a patch release + rlt-keys.jws, compromised kid into revokedKids, forced status: "rotate" on refresh.
Org secret leaked (committed to a public repo)secret scanning (the rsk_live_ prefix is registered with GitHub push protection) or customer reportPOST /v1/orgs/{orgId}/secrets:rotate; old secret valid 24 h. Already-issued tokens stay valid — they are public by design, and the leak only allows minting tokens for that org's own apps[].
Enterprise offline file expires unnoticedexp reminders at 60/30/7 daysrasd license check in the customer's own CI fails (exit 2) before the watermark ever reaches a device.
Field device never refreshes again (project ends, phone retired)none neededRides out exp + grace, then limited: data already collected still syncs and exports. No remote wipe, no kill switch (§17).

19. Security, accessibility and performance considerations

Security (threat model of the license layer itself; device/data threats are in 16 · Security & data protection):

ThreatControlResidual risk
Token copied into another appapps[] binding (§4.2) + 60-day lifetime + refresh through the org secretA copy inside the same origin/bundle is undetectable by design — accepted (§17).
Forged or altered tokenEd25519 signature over header.payload; only alg: EdDSA accepted, no algorithm agility, no noneRequires the signing key (§3 custody).
Malicious token used as a DoS in the render path4 096-byte cap before parsing, bounded JSON, no regex backtracking, one verification per boot and per setToken (never per render)None material; verification is O(1) work.
Org secret exfiltrationSecret lives only on the customer backend, hashed at rest (SHA-256), rsk_live_ prefix for secret scanners, 24 h-overlap rotationA leaked secret mints tokens for that org's own apps only.
MITM on tokenEndpoint / license APIHTTPS only; the token is signed, so a tampered response fails verification and is discardedNone.
Poisoned key materialrlt-keys.jws verified against the embedded offline root key before caching; plain JWKS never trustedRoot-key compromise ⇒ mandatory SDK upgrade (§3, accepted).
License-service DoSPer-IP / per-org / per-key rate limits, CDN-cached key documents, 0–30 s refresh jitter, ≤ 24 h backend cacheDevices verify offline, so an outage cannot stop data collection.
Bypassing verification (patched SDK)Not defended technically — the package is source-availableEnforcement is contractual (§15, §17), as with every incumbent.

Accessibility (13 · i18n, RTL & accessibility is normative for the details):

  • The watermark is decorative-but-informative: role="note", pointer-events: none, never focusable, never inside the tab order, and the root reserves 28 px so it cannot overlap a control (asserted with Playwright bounding boxes, §21).
  • Contrast of watermark text on --rasd-color-surfaceVariant meets WCAG 2.2 AA (4.5:1) in all four stock themes; rasd theme check lints custom themes for the same pair.
  • <LicenseBanner> uses role="status" (polite) — a licensing state is not an error the enumerator caused, so it never uses role="alert" and never steals focus; its dismiss control is a real button with an accessible name.
  • The hard-enforcement blocking message is rendered with the renderer's error-summary pattern and receives focus, so a screen-reader user learns immediately why a new submission cannot be started, and it names the action (contact the org admin), not just a URL.
  • All license strings ship localised (en/ar at launch), take dir from the provider, and never signal state by colour alone — the state word is in the text.
  • No licensing surface animates; nothing depends on hover or pointer precision.

Performance (budgets enforced in CI, §21):

BudgetValueWhy
@rasd/license size≤ 12 kB min+gzip incl. @noble/ed25519It sits in the form-runner path (spine §12 budgets).
Verification≤ 5 ms on a 2019 low-end Android (Hermes), ≤ 1 ms desktopRuns before first paint at boot; WebCrypto path is used when Ed25519 is available.
Verifications per sessionone at boot, one per setTokenState derivation is pure and memoised; the render path never verifies.
Re-rendersonly on state transitionsuseLicense() subscribes to state$, not to the tick (06 §4).
Timersone 60 s ± 10 s tick (paused while the document is hidden; state re-derived on visibilitychange / AppState active) + two one-shot boundary timers, all cleared by dispose()No wake-ups on a backgrounded phone.
Network≤ 1 refresh in flight per namespace, ≥ 24 h between successful refreshes, 0–30 s jitter, and the customer backend caches per orgA fleet returning to coverage together cannot stampede anyone's backend.
Storage5 kv keys, < 2 KiB totalNegligible against the submission store.

20. FAQ for developers

  • Does my production app call Rasd servers? No. Verification is offline; refresh is via your backend (tokenEndpoint) or your RSP server (X-Rasd-License). Only siteKey mode calls Rasd, and it warns you outside localhost.
  • Can I commit the token? Don't; use RASD_LICENSE in CI or fetch it at runtime. It is public, but committed tokens go stale and leak into forks.
  • Why does staging show invalid: APP_MISMATCH? apps[] matches full origins; add https://staging.example.org (or https://*.example.org) in the dashboard and refresh.
  • I renewed but the watermark stays. Your backend caches tokens for up to 24 h; the SDK keeps the token with the later exp; a rebuilt bundle may still embed the old env value. rasd license check --json shows what the app actually holds.
  • What happens on day 91 offline? Watermark (soft). Nothing stops; when the phone syncs it gets a new token and the watermark disappears.
  • Can enumerators lose data because of licensing? No — INV-1/INV-3 are tested; export works in every state.
  • What is hard enforcement for? Orgs that want unlicensed builds to be visibly broken for new submissions in production (e.g. a partner deployed without a contract). Drafts, sync and export still work.
  • Localhost never expires? Correct: dev origins are evaluating indefinitely.
  • React Native — where does the bundle ID come from? You pass appId (from expo-application); the SDK has no platform dependency.
  • Air-gapped builds? Enterprise offline license file, 12 months, no network needed.
  • Which package is free? Under Option A none after the trial (all runtime packages are gated); @rasd/core, xlsform, cli, testing, themes are Apache-2.0 regardless. Option B (renderer + storage free) is a features policy flip.

21. Acceptance criteria and test plan

Tests live in packages/license/test (Vitest, fake clock from @rasd/testing) and in the cross-package suites; every scenario runs on web (Dexie kv) and native (SQLite kv).

Token & verification

  • Valid token ⇒ active; tampered payload byte ⇒ invalid: BAD_SIGNATURE; unknown kidinvalid: UNKNOWN_KID; alg: none/HS256 and typRLTinvalid: WRONG_TYP; > 4 096 bytes ⇒ invalid: MALFORMED; iss !== "rasd"invalid: ISSUER.
  • Verification is synchronous, ≤ 5 ms on a 2019 low-end Android (Hermes) and ≤ 1 ms on desktop; @rasd/license ≤ 12 kB min+gzip incl. @noble/ed25519.
  • Origin matrix: apex vs *. wildcard, port defaults, https://x:*, WebView schemes, iframe origin, RN appId exact and suffix wildcard, apps: [].
  • Root-signed key bundle: UNKNOWN_KID + fetcher ⇒ bundle fetched, verified against rasd-root-1, cached, token becomes active; a bundle with a bad root signature is ignored.

State machine & enforcement

  • Fake clock: monthly token, offline from day 3 — day 59 active, day 60 grace, day 89 grace, day 90 limited; day 95 online with X-Rasd-Licenseactive and watermark gone within one render.
  • Trial token day 7 ⇒ limited; local first-run trial ⇒ trial for 7 d then limited; dev host with no token ⇒ evaluating at day 400.
  • Dev-host precedence (§4.3): a broken token on localhostevaluating with the reason logged; a valid grace/limited token on localhost ⇒ that state, so developers can reproduce it.
  • 6 states × {export, syncNow, finish draft, autosave} succeed (INV-3); hard blocks only new submissions with RASD_LICENSE_EXPIRED via onError.
  • Watermark: present in limited, absent otherwise, text exact, RTL position, role="note", does not overlap controls (Playwright bounding boxes), 0 axe violations.
  • Builder: limited ⇒ read-only, Publish disabled, autosave and export continue; token without "builder" ⇒ notice + read-only.

Refresh & clock

  • Refresh fires only when < 14 d to exp (or grace/limited), with 0–30 s jitter, once in flight per namespace, cooldown 24 h after success, backoff 1 min → 6 h on failure.
  • tokenEndpoint 401 ⇒ console error once, state unchanged; lapsed ⇒ state unchanged; revoked ⇒ token dropped, limited; token with earlier exp ⇒ discarded.
  • Clock rollback −30 d ⇒ state and grace unchanged, clockSuspect true; rollback then refresh ⇒ guards reset to server time; nbf 1 day in future ⇒ invalid: NOT_YET_VALID.
  • Playwright/Maestro network capture: zero requests to non-host origins from a field build in every state (INV-5); siteKey on a production origin logs the warning.

Service & billing

  • policy() table tests: active/past_due ≤ 21 d issue; past_due > 21 d, unpaid, canceled ⇒ lapsed; disputed ⇒ revoked; admin PO ⇒ 12-month token.
  • Stripe webhook fixtures (invoice.paid, payment_failed, subscription.deleted, entitlement summary with > 10 items) are idempotent and reconcile with the nightly job.
  • Trial limits: second trial for the same domain within 12 months rejected with a clear message; disposable domain rejected; rate limits return 429 + Retry-After.
  • Revoked org: POST /v1/tokens/refreshrevoked; SDK drops token; offline device unaffected until it refreshes (documented, tested).
  • rasd license check exit codes 0/2/3 and --json shape are covered by CLI tests; LICENSE, LICENSE-COMMERCIAL.md, SPDX headers and NOTICE are verified by the release license scan (NFR-054).

Failure modes, accessibility, performance (§§18–19)

  • tokenEndpoint returning HTML, an empty body or a token whose sub differs ⇒ token and state unchanged, one console error, backoff applied.
  • storage.kv throwing on open ⇒ a configured token still verifies; a production host with no token goes to limited (no unpersisted endless trial); corrupted kv token ⇒ dropped, entry cleared, refresh triggered.
  • Two tabs booting together perform one refresh (Web Locks) and converge on the same token.
  • <LicenseBanner> exposes role="status", never moves focus, and its dismiss control has an accessible name; the hard blocking message receives focus and reads a concrete next step; both pass vitest-axe.
  • Watermark contrast ≥ 4.5:1 in rasd-light, rasd-dark, rasd-high-contrast, rasd-field (checked by rasd theme check fixtures).
  • Timer audit: a backgrounded document schedules no license work; dispose() leaves zero timers and zero subscriptions (fake timers).

Open questions

  • Option A vs Option B remains a founder decision (spine §9); this document is written so that flipping freeFeatures in @rasd/license implements Option B without other changes.
  • FSL-1.1 text cannot be amended; the RLT requirement therefore lives in separate Commercial Terms. Counsel must confirm that "FSL + Commercial Terms" is coherent, or whether a bespoke source-available licence (based on FSL) is preferable.
  • Exact price points, the 50 % humanitarian discount and the free tiers need validation with 3–5 design-partner NGOs before GA; the 7-day trial + 30-day extension policy should be confirmed against the peer set (14–30 days).
  • Should monthly tokens cap exp at paidThrough + 30 d (tighter tail on cancellation) instead of a flat 60 d? Current design accepts up to a 90-day tail for simplicity and offline safety.
  • Which HSM/KMS holds the signing key at launch (Vault Transit vs YubiHSM 2), and who are the root-key ceremony custodians?
  • Should X-Rasd-License: revoked be part of RSP v1 or left to hosts as an optional convention?
  • InvalidReason union sizesettled: the spine ratified the nine-value union (MALFORMED | WRONG_TYP | UNKNOWN_KID | REVOKED | BAD_SIGNATURE | NOT_YET_VALID | APP_MISMATCH | SCHEMA | ISSUER) in 00 §11; 17 §10 publishes the same nine. A bad or absent alg folds into WRONG_TYP.
  • Does the trial-extension milestone via first backend refresh create a perverse incentive to wire the proxy before evaluating? Consider making the extension purely dashboard-driven.