Files
recap/server/license.js
T
Keysat 574a16d9fa Save in-progress keysat integration and StartOS 0.4 work
Snapshot of the working tree before cleanup. Captures:
- Keysat licensing: server/license.js, /api/license/* endpoints in
  server/index.js, activation modal in public/index.html, embedded
  Ed25519 issuer key (assets/issuer.pub).
- StartOS 0.4 expansion: setApiKey action, version files v0.1.1
  through v0.1.15, file-models/config.json.ts, manifest updates.
- Self-hosted registry server (startos-registry/).
- Build/deploy scripts (bin/bump-version.sh, bin/deploy.sh, vendored
  yt-dlp binary), .gitignore, .deploy.env.example.
- Recent design docs (KEYSAT_INTEGRATION.md, UPGRADE-DESIGN.md) —
  retained here so they remain recoverable when removed in the
  follow-up cleanup commit.
2026-05-08 09:39:17 -05:00

146 lines
5.6 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 } from "@keysat/licensing-client";
export const PRODUCT_SLUG = "youtube-summarizer";
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.YT_SUMMARIZER_LICENSE_KEY_PATH ||
path.join(DATA_DIR, "license.txt");
// ── 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}`);
}
// ── Helpers ───────────────────────────────────────────────────────────────
function readLicenseString() {
const fromEnv = (process.env.YT_SUMMARIZER_LICENSE_KEY || "").trim();
if (fromEnv) return fromEnv;
try {
const s = fs.readFileSync(LICENSE_PATH, "utf8").trim();
return s || null;
} catch {
return null;
}
}
function emptyState(extra = {}) {
return {
state: "unlicensed",
reason: null,
licenseId: null,
entitlements: new Set(),
expiresAt: null,
isTrial: false,
...extra,
};
}
// ── Public API ────────────────────────────────────────────────────────────
//
// checkLicense() — read + verify; returns a frozen-ish state object.
// Callers can re-invoke after activation to refresh.
export function checkLicense() {
if (verifierError) {
return emptyState({ state: "invalid", reason: `bad embedded key: ${verifierError}` });
}
const raw = readLicenseString();
if (!raw) return emptyState();
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" });
}
return {
state: "licensed",
reason: null,
licenseId: payload.licenseId || 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" });
}
}
// 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.
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);
return checkLicense();
}
// 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,
};
}
// has(state, entitlement) — convenience wrapper for feature gates.
export function has(state, entitlement) {
return state && state.entitlements && state.entitlements.has(entitlement);
}