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.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// ── 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);
|
||||
}
|
||||
Reference in New Issue
Block a user