9282440143
The product was always more than YouTube — it handles podcast feeds
too, and the upcoming multi-provider work makes it less Gemini-
specific. New name: Recap.
This is a coordinated identity change across:
• StartOS package id: youtube-summarizer → recap
(manifest.id; the .s9pk filename, Docker image namespace, and
install path under StartOS all derive from this automatically)
• Display name: "YouTube Summarizer" → "Recap"
(manifest title, activation screen heading, page <title>, console
log on boot, i18n strings, ABOUT.md, Dockerfile header,
docker_entrypoint banner)
• Keysat product slug: youtube-summarizer → recap
(server/license.js PRODUCT_SLUG; frontend fallback strings)
• Daemon subscription id: youtube-summarizer-sub → recap-sub
• Env var prefix: YT_SUMMARIZER_* → RECAP_*
(LICENSE_KEY, LICENSE_KEY_PATH, MAX_OFFLINE_DAYS,
VALIDATE_INTERVAL_MS)
• localStorage keys: yt-summarizer-* → recap-*
(gemini-key, activation-skipped, clips)
• Library export filename: youtube-summarizer-library.json →
recap-library.json
• npm package names: youtube-summarizer-{startos,server} → recap-*
• Deploy paths: youtube-summarizer_x86_64.s9pk → recap_x86_64.s9pk
(default values in bin/deploy.sh; .deploy.env on dev machine
needs the same update before next push)
• Self-hosted registry directory: startos-registry/packages/
youtube-summarizer → .../recap (with package.json + INSTRUCTIONS
rewritten)
What does NOT change:
• Filesystem repo path (still /Users/.../youtube-summarizer/)
• Git history / commit messages
• Existing version files in startos/versions/ (kept as-is — the
version chain belongs to the package's own history regardless of
its display name)
User-side follow-ups required:
1. Create "recap" product in Keysat admin, set up Core/Pro tier
policies (same entitlements as before), mint a fresh test
license. Old "youtube-summarizer" licenses won't activate
against the new slug.
2. Update .deploy.env (gitignored) so FILEBROWSER_PATH and
REGISTRY_PUBLIC_URL point at recap_x86_64.s9pk.
StartOS will treat this as a brand-new app on install — existing
youtube-summarizer installs will not auto-migrate (acknowledged
intentional given no real users yet).
376 lines
14 KiB
JavaScript
376 lines
14 KiB
JavaScript
// ── Keysat license verification ──────────────────────────────────────────
|
|
//
|
|
// Reads a LIC1-... key from disk (or env), verifies its Ed25519 signature
|
|
// against the operator's embedded public key, and exposes the resulting
|
|
// state + entitlement set to the rest of the server.
|
|
//
|
|
// Operator config — keep these three constants in sync with what's set in
|
|
// the Keysat admin UI:
|
|
// ISSUER_PEM → assets/issuer.pub (committed; non-secret)
|
|
// PRODUCT_SLUG → must match the product slug created in Keysat
|
|
// KEYSAT_BASE_URL → optional, only used by online validate() / purchase
|
|
//
|
|
// Tier model for this app (see KEYSAT_INTEGRATION.md §0):
|
|
// "core" — required for any business endpoint; unlocks
|
|
// summarization and BYO Gemini API key
|
|
// "history" — saved summary library: /api/history*
|
|
// "library" — bulk import/export: /api/library/*
|
|
// "subscriptions" — Pro: channel subs, auto-queue, sub-check log
|
|
// "clips" — Pro: paperclip / clip-collection panel
|
|
//
|
|
// Tier policies:
|
|
// Core → ["core", "history", "library"]
|
|
// Pro → ["core", "history", "library", "subscriptions", "clips"]
|
|
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { Verifier, PublicKey, Client } from "@keysat/licensing-client";
|
|
|
|
export const PRODUCT_SLUG = "recap";
|
|
export const KEYSAT_BASE_URL = "https://licensing.keysat.xyz";
|
|
|
|
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
|
const PEM_PATH = path.join(__dirname, "..", "assets", "issuer.pub");
|
|
const ISSUER_PEM = fs.readFileSync(PEM_PATH, "utf8");
|
|
|
|
// License file lives next to existing config/ and history/ in DATA_DIR.
|
|
// On StartOS that's /data; on local Mac dev it's the project root.
|
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "..");
|
|
export const LICENSE_PATH =
|
|
process.env.RECAP_LICENSE_KEY_PATH ||
|
|
path.join(DATA_DIR, "license.txt");
|
|
|
|
// 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.).
|
|
// Past the ceiling, we lock out — otherwise a revoked key on a permanently
|
|
// offline machine would never get caught.
|
|
const MAX_OFFLINE_DAYS = parseInt(
|
|
process.env.RECAP_MAX_OFFLINE_DAYS || "7",
|
|
10
|
|
);
|
|
const MAX_OFFLINE_MS = MAX_OFFLINE_DAYS * 24 * 60 * 60 * 1000;
|
|
|
|
// Sidecar file that tracks last successful online validation. Lets the
|
|
// grace window survive restarts.
|
|
const STATE_PATH = LICENSE_PATH + ".state.json";
|
|
|
|
// ── Verifier instance (built once at module load) ─────────────────────────
|
|
let verifier = null;
|
|
let verifierError = null;
|
|
try {
|
|
verifier = new Verifier(PublicKey.fromPem(ISSUER_PEM));
|
|
} catch (e) {
|
|
verifierError = e?.message || String(e);
|
|
console.error(`[license] failed to parse embedded public key: ${verifierError}`);
|
|
}
|
|
|
|
// Lazy HTTP client for online validation against the licensing service.
|
|
let onlineClient = null;
|
|
function getOnlineClient() {
|
|
if (!onlineClient) onlineClient = new Client(KEYSAT_BASE_URL);
|
|
return onlineClient;
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
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;
|
|
}
|
|
}
|
|
|
|
function readPersistedState() {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writePersistedState(obj) {
|
|
try {
|
|
const tmp = STATE_PATH + ".tmp";
|
|
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 0o600 });
|
|
fs.renameSync(tmp, STATE_PATH);
|
|
} catch (e) {
|
|
console.error(`[license] failed to write state: ${e?.message || e}`);
|
|
}
|
|
}
|
|
|
|
function clearPersistedState() {
|
|
try {
|
|
fs.unlinkSync(STATE_PATH);
|
|
} catch {}
|
|
}
|
|
|
|
function emptyState(extra = {}) {
|
|
return {
|
|
state: "unlicensed",
|
|
reason: null,
|
|
licenseId: null,
|
|
entitlements: new Set(),
|
|
expiresAt: null,
|
|
isTrial: false,
|
|
lastValidatedAt: null,
|
|
serverStatus: null,
|
|
graceUntil: null,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
// Definitive server reasons that should immediately invalidate the license.
|
|
// rate_limited is intentionally NOT here — that's a transient server-side
|
|
// throttle, not a verdict on the key.
|
|
const HARD_REJECTIONS = new Set([
|
|
"bad_format",
|
|
"bad_signature",
|
|
"not_found",
|
|
"revoked",
|
|
"suspended",
|
|
"expired",
|
|
"product_mismatch",
|
|
"fingerprint_mismatch",
|
|
"too_many_machines",
|
|
"invalid_state",
|
|
]);
|
|
|
|
// ── Public API ────────────────────────────────────────────────────────────
|
|
//
|
|
// checkLicense() — read + verify; returns a frozen-ish state object.
|
|
// Callers can re-invoke after activation to refresh. This is offline-only:
|
|
// it verifies the Ed25519 signature and layers on any persisted online-
|
|
// validation state. The hard online check happens in validateOnline().
|
|
export function checkLicense() {
|
|
if (verifierError) {
|
|
return emptyState({ state: "invalid", reason: `bad embedded key: ${verifierError}` });
|
|
}
|
|
const raw = readLicenseString();
|
|
if (!raw) return emptyState();
|
|
|
|
let base;
|
|
try {
|
|
const ok = verifier.verify(raw);
|
|
const payload = ok.payload || {};
|
|
// Reject keys minted for a different product (same operator, different SKU).
|
|
if (payload.productSlug && payload.productSlug !== PRODUCT_SLUG) {
|
|
return emptyState({ state: "invalid", reason: "product_mismatch" });
|
|
}
|
|
base = emptyState({
|
|
state: "licensed",
|
|
// payload.licenseId is a Uint8Array (raw 16-byte UUID); the canonical
|
|
// string form is licenseUuid. The frontend treats licenseId as a
|
|
// string (calls .slice on it), so always send the string here.
|
|
licenseId: payload.licenseUuid || null,
|
|
entitlements: new Set(payload.entitlements || []),
|
|
expiresAt: payload.expiresAt ? new Date(payload.expiresAt * 1000) : null,
|
|
isTrial: !!(payload.flags & 1),
|
|
});
|
|
} catch (e) {
|
|
return emptyState({ state: "invalid", reason: e?.message || "verify_failed" });
|
|
}
|
|
|
|
// Layer on persisted online state. If a previous online check found this
|
|
// key revoked/suspended/etc., honor that until a successful re-check.
|
|
const persisted = readPersistedState();
|
|
if (persisted) {
|
|
if (persisted.lastValidatedAt) {
|
|
base.lastValidatedAt = new Date(persisted.lastValidatedAt);
|
|
}
|
|
if (persisted.serverStatus) base.serverStatus = persisted.serverStatus;
|
|
if (persisted.graceUntil) base.graceUntil = new Date(persisted.graceUntil);
|
|
if (persisted.lastResult && persisted.lastResult !== "ok") {
|
|
// The last conclusive online check rejected this key. Stay invalid
|
|
// until a successful re-check overwrites the sidecar.
|
|
return emptyState({
|
|
state: "invalid",
|
|
reason: persisted.lastResult,
|
|
licenseId: base.licenseId,
|
|
lastValidatedAt: base.lastValidatedAt,
|
|
serverStatus: base.serverStatus,
|
|
});
|
|
}
|
|
}
|
|
return base;
|
|
}
|
|
|
|
// activate(rawKey) — write a pasted key to disk, then re-check.
|
|
// Returns the new license state. Throws on bad input format only;
|
|
// signature failures surface as state: 'invalid' with a reason.
|
|
//
|
|
// Clears any persisted online-validation state — a new key gets a fresh
|
|
// online check, untainted by what an old key was last told.
|
|
export function activate(rawKey) {
|
|
const key = (rawKey || "").trim();
|
|
if (!key.startsWith("LIC1-")) {
|
|
const err = new Error("bad_format");
|
|
err.code = "bad_format";
|
|
throw err;
|
|
}
|
|
// Write atomically-ish: write to temp file then rename.
|
|
const tmp = LICENSE_PATH + ".tmp";
|
|
fs.mkdirSync(path.dirname(LICENSE_PATH), { recursive: true });
|
|
fs.writeFileSync(tmp, key + "\n", { mode: 0o600 });
|
|
fs.renameSync(tmp, LICENSE_PATH);
|
|
clearPersistedState();
|
|
return checkLicense();
|
|
}
|
|
|
|
// deactivate() — remove the on-disk license + persisted online state.
|
|
// Idempotent. Returns the new (empty) state.
|
|
export function deactivate() {
|
|
try {
|
|
fs.unlinkSync(LICENSE_PATH);
|
|
} catch {}
|
|
clearPersistedState();
|
|
return checkLicense();
|
|
}
|
|
|
|
// validateOnline() — call licensing.keysat.xyz/v1/validate, merge the
|
|
// response into a fresh state object, and persist last-validated info.
|
|
//
|
|
// Behavior:
|
|
// • Server says ok=true → state stays/becomes "licensed", entitlements
|
|
// and expiry refreshed from server.
|
|
// • Server says ok=false with a hard reason (revoked, suspended, expired,
|
|
// not_found, product_mismatch, fingerprint_mismatch, too_many_machines)
|
|
// → state becomes "invalid"; persisted so
|
|
// checkLicense() keeps rejecting after restart.
|
|
// • Server says rate_limited → treated as a transient error; state kept.
|
|
// • Network error / timeout → state kept up to MAX_OFFLINE_DAYS since the
|
|
// last successful validate. Past the ceiling,
|
|
// state becomes "invalid" with reason
|
|
// "validation_overdue".
|
|
//
|
|
// Always returns a state object — never throws.
|
|
export async function validateOnline() {
|
|
const local = checkLicense();
|
|
// No key, or local sig failure — no point asking the server.
|
|
if (local.state !== "licensed" && local.state !== "invalid") return local;
|
|
const raw = readLicenseString();
|
|
if (!raw) return local;
|
|
|
|
let resp;
|
|
try {
|
|
resp = await getOnlineClient().validate(raw, { productSlug: PRODUCT_SLUG });
|
|
} catch (e) {
|
|
return applyNetworkErrorGrace(local, e?.code || e?.message || "network_error");
|
|
}
|
|
|
|
const now = new Date();
|
|
|
|
if (resp.ok) {
|
|
const next = emptyState({
|
|
state: "licensed",
|
|
licenseId: resp.licenseId || local.licenseId,
|
|
entitlements: new Set(resp.entitlements || [...local.entitlements]),
|
|
expiresAt: resp.expiresAt ? new Date(resp.expiresAt) : local.expiresAt,
|
|
isTrial: resp.isTrial != null ? !!resp.isTrial : local.isTrial,
|
|
lastValidatedAt: now,
|
|
serverStatus: resp.status || "active",
|
|
graceUntil: resp.graceUntil ? new Date(resp.graceUntil) : null,
|
|
});
|
|
writePersistedState({
|
|
lastValidatedAt: now.toISOString(),
|
|
serverStatus: next.serverStatus,
|
|
lastResult: "ok",
|
|
graceUntil: next.graceUntil ? next.graceUntil.toISOString() : null,
|
|
});
|
|
return next;
|
|
}
|
|
|
|
const reason = resp.reason || "rejected";
|
|
|
|
if (reason === "rate_limited") {
|
|
// Transient. Don't change state.
|
|
return applyNetworkErrorGrace(local, "rate_limited");
|
|
}
|
|
|
|
if (HARD_REJECTIONS.has(reason)) {
|
|
console.warn(`[license] online validation rejected: ${reason}`);
|
|
writePersistedState({
|
|
lastValidatedAt: now.toISOString(),
|
|
serverStatus: resp.status || reason,
|
|
lastResult: reason,
|
|
});
|
|
return emptyState({
|
|
state: "invalid",
|
|
reason,
|
|
licenseId: local.licenseId,
|
|
lastValidatedAt: now,
|
|
serverStatus: resp.status || reason,
|
|
});
|
|
}
|
|
|
|
// Unknown reason — be conservative, treat as transient so we don't lock
|
|
// out paying users on an SDK/server version mismatch.
|
|
console.warn(`[license] online validation returned unknown reason: ${reason}`);
|
|
return applyNetworkErrorGrace(local, reason);
|
|
}
|
|
|
|
function applyNetworkErrorGrace(local, errorReason) {
|
|
const persisted = readPersistedState();
|
|
const lastValidatedAt = persisted?.lastValidatedAt
|
|
? new Date(persisted.lastValidatedAt)
|
|
: null;
|
|
|
|
if (!lastValidatedAt) {
|
|
// Never successfully validated online. Allow offline state but flag it
|
|
// — the periodic poller will keep trying.
|
|
console.warn(
|
|
`[license] online validation unavailable (${errorReason}); no prior successful check yet`
|
|
);
|
|
return { ...local, lastValidatedAt: null };
|
|
}
|
|
|
|
const ageMs = Date.now() - lastValidatedAt.getTime();
|
|
if (ageMs > MAX_OFFLINE_MS) {
|
|
const ageDays = (ageMs / 86400000).toFixed(1);
|
|
console.warn(
|
|
`[license] online validation overdue: ${ageDays}d since last successful check (max ${MAX_OFFLINE_DAYS}d). Locking out.`
|
|
);
|
|
return emptyState({
|
|
state: "invalid",
|
|
reason: "validation_overdue",
|
|
licenseId: local.licenseId,
|
|
lastValidatedAt,
|
|
serverStatus: persisted?.serverStatus || null,
|
|
});
|
|
}
|
|
|
|
console.warn(
|
|
`[license] online validation skipped (${errorReason}); within ${MAX_OFFLINE_DAYS}d grace`
|
|
);
|
|
return { ...local, lastValidatedAt };
|
|
}
|
|
|
|
// publicView(state) — safe shape for /api/license-status responses.
|
|
// Never leaks the raw license key (it's a bearer credential).
|
|
export function publicView(state) {
|
|
return {
|
|
state: state.state,
|
|
reason: state.reason,
|
|
licenseId: state.licenseId,
|
|
entitlements: [...state.entitlements].sort(),
|
|
expiresAt: state.expiresAt ? state.expiresAt.toISOString() : null,
|
|
isTrial: !!state.isTrial,
|
|
productSlug: PRODUCT_SLUG,
|
|
keysatBaseUrl: KEYSAT_BASE_URL,
|
|
licensePath: LICENSE_PATH,
|
|
lastValidatedAt: state.lastValidatedAt
|
|
? state.lastValidatedAt.toISOString()
|
|
: null,
|
|
serverStatus: state.serverStatus || null,
|
|
graceUntil: state.graceUntil ? state.graceUntil.toISOString() : null,
|
|
};
|
|
}
|
|
|
|
// has(state, entitlement) — convenience wrapper for feature gates.
|
|
export function has(state, entitlement) {
|
|
return state && state.entitlements && state.entitlements.has(entitlement);
|
|
}
|