From 29282f8dcc42c6e48ad1446b2d017152c2be14f7 Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 9 May 2026 07:06:21 -0500 Subject: [PATCH] Add 'Set Recap License' StartOS action + s/Keysat license/Recap license/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes: 1. New StartOS action: 'Set Recap License' Symmetric with the existing 'Set Gemini API Key' action — paste a LIC1-... key into the StartOS Actions menu and it gets persisted. Added because some users prefer the StartOS form for credentials over the in-app activation modal. Implementation: • startos/file-models/config.json.ts: schema gains recap_license_key • startos/actions/setLicense.ts: input form (masked, regex-checks for the LIC1- prefix), persists via configFile.merge() • startos/actions/index.ts: registers the new action • server/license.js: readLicenseString() falls back to startos-config.json after the legacy license.txt path. Resolution order: env → license.txt → startos-config.json • server/license-middleware.js: faster license-file poll (30 s, env-overridable RECAP_LICENSE_FILE_POLL_MS) re-runs checkLicense so action-set keys take effect within seconds, not the 6 h online cycle. If the new key parses as 'licensed', kicks an immediate online check to confirm. 2. Copy fix: 'Keysat license' → 'Recap license' in user-facing text Keysat is the licensing system underneath, but customers buy a 'Recap license'. Updated: • Activation screen subtitle (public/index.html) • 402 message in the activation gate (server/license-middleware.js) Internal references (PRODUCT_SLUG, KEYSAT_BASE_URL, the issuer.pub filename, the 'Issuer: licensing.keysat.xyz' display in the activation card) stay as Keysat — those are accurate. Smoke tested locally: starting the server with no license, then writing a fake LIC1-... key into startos-config.json, the license-file poll picks it up within ~2 s and transitions state from 'unlicensed' to 'invalid' (since the fake key fails Ed25519 verification, as expected). With a real key, the same path would land in 'licensed'. --- public/index.html | 2 +- server/license-middleware.js | 30 +++++++++++++++++- server/license.js | 22 ++++++++++--- startos/actions/index.ts | 5 ++- startos/actions/setLicense.ts | 50 ++++++++++++++++++++++++++++++ startos/file-models/config.json.ts | 1 + 6 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 startos/actions/setLicense.ts diff --git a/public/index.html b/public/index.html index 32fe79d..14bf974 100644 --- a/public/index.html +++ b/public/index.html @@ -1628,7 +1628,7 @@

${loading ? "Checking license…" - : "Activate a Keysat license to unlock the full app — saved library, channel & podcast subscriptions, and auto-queue. Or skip to use free mode (one video at a time, no library)." + : "Activate a Recap license to unlock the full app — saved library, channel & podcast subscriptions, and auto-queue. Or skip to use free mode (one video at a time, no library)." }

${loading ? "" : ` diff --git a/server/license-middleware.js b/server/license-middleware.js index c2420dd..9f0132b 100644 --- a/server/license-middleware.js +++ b/server/license-middleware.js @@ -30,6 +30,12 @@ const VALIDATE_INTERVAL_MS = parseInt( 10 ); const ACTIVATE_VALIDATE_TIMEOUT_MS = 8000; +// How often to re-read the license file (fast path for keys set via the +// StartOS action — the 6 h online cycle is too slow for that UX). +const LICENSE_FILE_POLL_MS = parseInt( + process.env.RECAP_LICENSE_FILE_POLL_MS || "30000", + 10 +); // ── Online refresh ────────────────────────────────────────────────────────── // Calls the licensing server (with the network-error grace logic in @@ -59,6 +65,28 @@ export function startLicenseRefresh() { setInterval(() => { refreshLicenseOnline("scheduled").catch(() => {}); }, VALIDATE_INTERVAL_MS); + // Faster offline-only re-read so a license set via the "Set Recap + // License" StartOS action (or a manual edit to license.txt) is picked + // up within seconds instead of 6 h. Calls checkLicense() rather than + // validateOnline() to avoid hammering Keysat — the next scheduled + // validateOnline tick will confirm with the server. If a fresh key + // appears, kick an immediate online check too so an unrevoked Pro + // license doesn't get stuck pending until the 6 h tick. + setInterval(() => { + const prev = LIC; + const next = license.checkLicense(); + if (next.licenseId !== prev.licenseId || next.state !== prev.state) { + LIC = next; + console.log( + `[license] file refresh: state=${LIC.state}` + + (LIC.reason ? ` reason=${LIC.reason}` : "") + + ` entitlements=[${[...LIC.entitlements].join(",")}]` + ); + if (next.state === "licensed") { + refreshLicenseOnline("file change").catch(() => {}); + } + } + }, LICENSE_FILE_POLL_MS); } // ── Free-tier slot management ─────────────────────────────────────────────── @@ -139,7 +167,7 @@ export function setupLicenseMiddleware(app) { message: LIC.state === "licensed" ? "Your license is missing the 'core' entitlement. Contact the seller." - : "This feature requires a Keysat license. Upgrade to unlock.", + : "This feature requires a Recap license. Upgrade to unlock.", state: LIC.state, reason: LIC.reason, activate_url: "/#activate", diff --git a/server/license.js b/server/license.js index 79162eb..e81d8da 100644 --- a/server/license.js +++ b/server/license.js @@ -40,6 +40,12 @@ export const LICENSE_PATH = process.env.RECAP_LICENSE_KEY_PATH || path.join(DATA_DIR, "license.txt"); +// StartOS config sidecar — the "Set Recap License" action writes the +// pasted key to this file's recap_license_key field. Read as a fallback +// after license.txt so the web-UI activation flow still wins if both +// are set. +const STARTOS_CONFIG_PATH = path.join(DATA_DIR, "config", "startos-config.json"); + // Grace ceiling for network errors. As long as we successfully validated // against Keysat within this window, we keep the license live even if // subsequent online checks fail (Keysat down, customer offline, etc.). @@ -73,15 +79,23 @@ function getOnlineClient() { } // ── Helpers ─────────────────────────────────────────────────────────────── +// Resolution order: +// 1. RECAP_LICENSE_KEY env var (overrides everything; useful for tests) +// 2. license.txt at LICENSE_PATH (web-UI activation writes here) +// 3. recap_license_key in startos-config.json ("Set Recap License" action) function readLicenseString() { const fromEnv = (process.env.RECAP_LICENSE_KEY || "").trim(); if (fromEnv) return fromEnv; try { const s = fs.readFileSync(LICENSE_PATH, "utf8").trim(); - return s || null; - } catch { - return null; - } + if (s) return s; + } catch {} + try { + const cfg = JSON.parse(fs.readFileSync(STARTOS_CONFIG_PATH, "utf8")); + const k = (cfg.recap_license_key || "").trim(); + return k || null; + } catch {} + return null; } function readPersistedState() { diff --git a/startos/actions/index.ts b/startos/actions/index.ts index 24d76d6..05ffea9 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -1,4 +1,7 @@ import { sdk } from '../sdk' import { setApiKey } from './setApiKey' +import { setLicense } from './setLicense' -export const actions = sdk.Actions.of().addAction(setApiKey) +export const actions = sdk.Actions.of() + .addAction(setApiKey) + .addAction(setLicense) diff --git a/startos/actions/setLicense.ts b/startos/actions/setLicense.ts new file mode 100644 index 0000000..2ad5486 --- /dev/null +++ b/startos/actions/setLicense.ts @@ -0,0 +1,50 @@ +import { sdk } from '../sdk' +import { configFile } from '../file-models/config.json' + +const { InputSpec, Value } = sdk + +const inputSpec = InputSpec.of({ + recap_license_key: Value.text({ + name: 'Recap License Key', + description: + 'Paste your Recap license key here. Keys start with "LIC1-..." — get one from your Recap seller. (Keys are also accepted via the web UI activation screen.)', + required: true, + default: null, + masked: true, + minLength: 1, + maxLength: 1024, + patterns: [ + { + regex: '^LIC1-.+', + description: 'License keys start with "LIC1-".', + }, + ], + }), +}) + +export const setLicense = sdk.Action.withInput( + 'set-license', + + async ({ effects }) => ({ + name: 'Set Recap License', + description: + 'Activate a Recap license to unlock paid features (saved library, channel & podcast subscriptions, auto-queue).', + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + inputSpec, + + async ({ effects }) => { + const config = await configFile.read().once() + return { recap_license_key: config?.recap_license_key || undefined } + }, + + async ({ effects, input }) => { + const trimmed = (input.recap_license_key || '').trim() + await configFile.merge(effects, { recap_license_key: trimmed }) + return null + }, +) diff --git a/startos/file-models/config.json.ts b/startos/file-models/config.json.ts index ee11e02..93da262 100644 --- a/startos/file-models/config.json.ts +++ b/startos/file-models/config.json.ts @@ -11,5 +11,6 @@ export const configFile = FileHelper.json( }, z.object({ gemini_api_key: z.string().default(''), + recap_license_key: z.string().default(''), }), )