574a16d9fa
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.
146 lines
5.6 KiB
JavaScript
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);
|
|
}
|