Add 'Set Recap License' StartOS action + s/Keysat license/Recap license/
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'.
This commit is contained in:
+1
-1
@@ -1628,7 +1628,7 @@
|
|||||||
<p class="activation-sub">
|
<p class="activation-sub">
|
||||||
${loading
|
${loading
|
||||||
? "Checking license…"
|
? "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)."
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
${loading ? "" : `
|
${loading ? "" : `
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ const VALIDATE_INTERVAL_MS = parseInt(
|
|||||||
10
|
10
|
||||||
);
|
);
|
||||||
const ACTIVATE_VALIDATE_TIMEOUT_MS = 8000;
|
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 ──────────────────────────────────────────────────────────
|
// ── Online refresh ──────────────────────────────────────────────────────────
|
||||||
// Calls the licensing server (with the network-error grace logic in
|
// Calls the licensing server (with the network-error grace logic in
|
||||||
@@ -59,6 +65,28 @@ export function startLicenseRefresh() {
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
refreshLicenseOnline("scheduled").catch(() => {});
|
refreshLicenseOnline("scheduled").catch(() => {});
|
||||||
}, VALIDATE_INTERVAL_MS);
|
}, 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 ───────────────────────────────────────────────
|
// ── Free-tier slot management ───────────────────────────────────────────────
|
||||||
@@ -139,7 +167,7 @@ export function setupLicenseMiddleware(app) {
|
|||||||
message:
|
message:
|
||||||
LIC.state === "licensed"
|
LIC.state === "licensed"
|
||||||
? "Your license is missing the 'core' entitlement. Contact the seller."
|
? "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,
|
state: LIC.state,
|
||||||
reason: LIC.reason,
|
reason: LIC.reason,
|
||||||
activate_url: "/#activate",
|
activate_url: "/#activate",
|
||||||
|
|||||||
+17
-3
@@ -40,6 +40,12 @@ export const LICENSE_PATH =
|
|||||||
process.env.RECAP_LICENSE_KEY_PATH ||
|
process.env.RECAP_LICENSE_KEY_PATH ||
|
||||||
path.join(DATA_DIR, "license.txt");
|
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
|
// Grace ceiling for network errors. As long as we successfully validated
|
||||||
// against Keysat within this window, we keep the license live even if
|
// against Keysat within this window, we keep the license live even if
|
||||||
// subsequent online checks fail (Keysat down, customer offline, etc.).
|
// subsequent online checks fail (Keysat down, customer offline, etc.).
|
||||||
@@ -73,16 +79,24 @@ function getOnlineClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── 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() {
|
function readLicenseString() {
|
||||||
const fromEnv = (process.env.RECAP_LICENSE_KEY || "").trim();
|
const fromEnv = (process.env.RECAP_LICENSE_KEY || "").trim();
|
||||||
if (fromEnv) return fromEnv;
|
if (fromEnv) return fromEnv;
|
||||||
try {
|
try {
|
||||||
const s = fs.readFileSync(LICENSE_PATH, "utf8").trim();
|
const s = fs.readFileSync(LICENSE_PATH, "utf8").trim();
|
||||||
return s || null;
|
if (s) return s;
|
||||||
} catch {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function readPersistedState() {
|
function readPersistedState() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { sdk } from '../sdk'
|
import { sdk } from '../sdk'
|
||||||
import { setApiKey } from './setApiKey'
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -11,5 +11,6 @@ export const configFile = FileHelper.json(
|
|||||||
},
|
},
|
||||||
z.object({
|
z.object({
|
||||||
gemini_api_key: z.string().default(''),
|
gemini_api_key: z.string().default(''),
|
||||||
|
recap_license_key: z.string().default(''),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user