Licensing
Issue enterprise licenses to clients, monitor their lifecycle, and let trial customers self-serve activation. Distinct from promo codes (which only adjust price) — a license sets the tier itself.
🧠 Two concepts, side by side
| Entitlement license | Promo code | |
|---|---|---|
| Purpose | Provisions a paid tier with caps (seats, branches, modules, duration). | Discount on the existing plan — % off, $ off, free days/months. |
| Tenant effect | Overrides their plan; they get the entitled tier. | Plan stays the same; price/trial is adjusted. |
| UI section | Global Admin → Licensing | Global Admin → Billing → Promo codes |
| Email notifications | Activation, expiring, extended, revoked. | None. |
A signup code field accepts either. The server detects the format and routes accordingly — a signed key (FFL1.…) or redemption code (FFL-XXXX-XXXX-XXXX) is treated as an entitlement; anything else falls through to promo-code lookup.
🎟 Three ways a license enters a tenant
FFL1.…)Ed25519-signed bearer token, ~376 chars. One-time activation. Verifiable offline. Best for VIP clients you want a personalized artifact for.
FFL-XXXX-XXXX-XXXX)Short human-friendly code; configurable max-redemptions. Best for batches sent to multiple prospects or campaigns.
Operator applies the license to an existing tenant from the admin console — no client action needed.
🛠 Issue a license
- Open Global Admin → Licensing. The console lists every tenant with their current license status.
- Click 🔑 Mint key for a signed key, or 🎟 Generate codes for redeemable codes.
- Set the tier (
enterprise/ops/core), duration in days, optional seat limit, and the trial flag. - Use the 📋 Copy button on the result modal to copy the artifact, then deliver to the client through a secure channel.
Signed keys and redeem codes are bearer tokens — anyone with the value can use them. Treat them like passwords. Use one-time-use limits, set a short expiry, or bind a key to a specific tenant for higher-risk artifacts.
👤 What the client does
The client has two entry points for activating their license:
The signup wizard has a "Have a license or promo code?" field on step 4. The wizard previews what the code grants before submission. On submit, the tenant lands on the entitled tier from minute one.
An existing tenant owner opens Tenant admin → License and uses the Activate a license key textarea or Redeem a license code input. The tier flips immediately on success.
📆 Day-2 operations
| You want to… | Where | What happens |
|---|---|---|
| See every tenant + their license | Global Admin → Licensing (filter by name/email + status, sortable) | Default sort puts expiring soonest at the top. Owner email shown beneath the tenant name. |
| Filter to overdue / expiring tenants | Console toolbar status dropdown | Real-time client-side filter; matches tenant name OR owner email. |
| Extend a trial | Row → Extend | Same license row preserved; license.extend event logged; extended email fired. |
| Mark a trial as paid (out-of-band payment) | Row → 💳 Convert | Sets trial=false and optionally extends. Fires "welcome to paid" email. |
| Revoke a license | Row → Revoke | Tenant downgraded to starter; revoked email fired. |
| Re-send the welcome email | Row → 📧 Resend | Same activation template; supports an optional personal note for VIP clients. |
| See what changed recently | Console → Recent license activity (with ⬇ CSV) | Cross-tenant feed of last 30 events. Event keys recorded in the audit table: license.issue, license.extend, license.revoke, license.expire, license.trial_converted, license.key_issue, license.codes_generate, license.code_deactivate. |
| Browse / retire redeem codes | Console → 🎟 Redeem codes | Per-code status + usage counter. Deactivate stops new redemptions without unwinding existing tenant licenses. |
| Switch a client's tier | Row → Provision | Supersedes the prior license with the new one. |
| Force-fire expiry reminders now | Console → ⏰ Run expiry check | Sweeps every tenant + fires the debounced reminder for any in the 7-day window. Safe to call hourly from cron. |
| Export the licenses table | Toolbar → ⬇ CSV | Honours the active filter + sort, so you can download just one slice. |
📧 Email lifecycle
Every state transition fires a templated email to the tenant owner (best-effort; failures are logged, never blocking):
| Trigger | Notes | |
|---|---|---|
| Activation (signup, admin provision, key activate, code redeem) | "FurnFlow {tier} license is active" | One per activation. |
| Expiring soon (within 7 days, not trial) | "Your FurnFlow {tier} license expires in N days" | Debounced: at most once per 24h per license. Triggered by admin or tenant reading the license endpoint. |
| Extended | "Your FurnFlow {tier} license was extended" | Includes the new expiry date and additional days. |
| Revoked | "Your FurnFlow {tier} license was revoked" | Optional reason included. |
Transport is auto-detected from environment variables: AZURE_COMMUNICATION_CONNECTION_STRING + EMAIL_FROM_ADDRESS (preferred), or SMTP_HOST + SMTP_USER + SMTP_PASS, or console-only in dev. The same fallback chain serves password resets and collection reminders.
Wiring expiry reminders to a scheduler
The expiring email fires opportunistically on GET /api/admin/licenses and GET /api/tenant/license reads — fine for typical use, but it depends on someone opening the relevant page. For hands-off operation, hit POST /api/admin/license/run-expiry-check from any scheduler. The endpoint iterates every tenant, fires the same debounced reminder, and returns { considered, sent, skipped, errors } so you can alert on regressions. The 24h dedupe means safe to run hourly.
An "Run expiry check" button on the admin Licensing console triggers it manually. For automation, schedule a daily curl from Azure Functions, GitHub Actions, or any cron host: curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" https://your-host/api/admin/license/run-expiry-check.
🛡 What entitlement actually does
When a tenant has an active entitlement license, three things change automatically:
- Plan row (
tenant_plans.tier) → the entitled tier (overrides signup plan). - Module flags → the entitled modules (overrides plan defaults).
- Seat cap enforcement on
POST /api/tenant/usersuses the license'sseat_limit.
When the license expires or is revoked, the tenant downgrades to their fallback plan (configurable; default starter) — they aren't suspended. Existing data is preserved; only entitled-tier features go away.
🔐 Security model
- Signed keys are Ed25519-signed JWTs (
FFL1.<payload>.<sig>). The private key lives in.license-signing.json(gitignored) orLICENSE_SIGNING_PRIVATE_KEYenv. The public key is exposed atGET /api/license/public-keyfor offline verification. - Redeem codes are random 12-char strings stored in
db.license_redeem_codes. Per-codemax_redemptions+expires_atenforced on the server. - Audit: every issue / extend / revoke / activate writes to
db.audit_logsANDdb.license_events(the structured event feed surfaced in the admin Recent activity card). - Tenant isolation: a tenant can only redeem a code once;
activate-keywith a tenant-bound key whosetenant_iddoesn't match returns 403.
Multi-instance signing key footgun. If LICENSE_SIGNING_PRIVATE_KEY isn't set and the .license-signing.json file isn't shared across instances/restarts (Azure App Service scale-out, Azure Container Apps, Azure Functions, Vercel/Lambda, ephemeral container filesystems), each instance generates its own keypair — keys issued by instance A won't verify on instance B, and a redeploy wipes the on-disk file. To prevent silent corruption, license-key minting is hard-failed with HTTP 503 ephemeral_signing_key when this state is detected, and GET /api/license/health flips ok to false with signing_key.ephemeral=true. Verification still works for the current invocation. The fix: run node furnflow-app/scripts/gen-license-signing-key.js and set the printed LICENSE_SIGNING_PRIVATE_KEY in your hosting env — for Azure, that's az webapp config appsettings set --settings LICENSE_SIGNING_PRIVATE_KEY=… or Portal → App Service → Configuration → Application settings.
⚙ Environment configuration
| Variable | Default | Purpose |
|---|---|---|
LICENSE_SIGNING_PRIVATE_KEY | — | Required in production / multi-instance. Ed25519 private key (PEM or base64). Without it, each instance generates its own keypair (bad — see Security model). |
LICENSE_EXPIRING_WINDOW_DAYS | 7 | How many days before expiry the "expiring soon" status + reminder email kick in. |
LICENSE_DEFAULT_GRACE_DAYS | 7 | How many days after expiry the license stays in "expiring" (still entitled) before flipping to "expired". |
AZURE_COMMUNICATION_CONNECTION_STRING + EMAIL_FROM_ADDRESS | — | Preferred email transport. Required to actually send activation / expiring / extended / revoked / converted / collection emails. |
SMTP_HOST + SMTP_USER + SMTP_PASS (+ optional SMTP_PORT / SMTP_SECURE / SMTP_FROM) | — | Fallback email transport. Same effect as ACS — the first one configured wins. |
🔌 API reference
| Endpoint | Auth | Purpose |
|---|---|---|
GET /api/license/public-key | Public | Ed25519 public PEM for offline verification. |
GET /api/license/health | Public | Signing-key fingerprint + origin (env / file / generated) + counts. ok flips to false when the signing key is ephemeral on a serverless runtime (minting disabled). Pair with a status-page check to alert on key rotation and on ok=false. |
GET /api/public/license/preview?code=… | Public | Validate + describe what a code/key grants (signup wizard uses this). |
POST /api/admin/license-keys | Admin | Mint a signed license key. |
POST /api/admin/license-redeem-codes | Admin | Generate a batch of redeem codes. |
GET /api/admin/licenses | Admin | List all tenants + their license status. Fires expiring reminders as side-effect. |
GET /api/admin/license-activity | Admin | Cross-tenant license event feed (newest first). |
POST /api/admin/tenants/:tid/license | Admin | Provision a license directly. |
POST /api/admin/tenants/:tid/license/:lid/extend | Admin | Bump the expiry of an existing license by N days. |
POST /api/admin/tenants/:tid/license/:lid/revoke | Admin | Revoke and downgrade to starter. |
POST /api/admin/tenants/:tid/license/:lid/convert-trial | Admin | Flip trial→paid (out-of-band payment); optional duration bump. |
POST /api/admin/tenants/:tid/license/:lid/resend-email | Admin | Re-fire the activation email; optional customNote. |
POST /api/admin/license-redeem-codes/:id/deactivate | Admin | Stop accepting new redemptions for a code. |
POST /api/admin/license/run-expiry-check | Admin | Sweep all licenses + fire expiring reminders (24h debounced). Cron-friendly. |
GET /api/tenant/license | Tenant user | Read own license + last 20 events. |
POST /api/tenant/license/activate-key | Tenant admin | Activate a signed key in an existing workspace. |
POST /api/tenant/license/redeem | Tenant admin | Redeem a code in an existing workspace. |