Files
recap/vendor/keysat-licensing-client/dist/index.js
T
Keysat 495b4aef36 Fix Dockerfile to copy all server/*.js modules; refresh vendor to v0.2.0
The runtime crash on v0.2.3:

  Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/server/util.js'
  imported from /app/server/index.js

happened because the Dockerfile's stage-2 COPY only listed server/
index.js + server/license.js explicitly. When I started extracting
modules in v0.2.3 (util.js, gemini-helpers.js, audio.js, ytdlp.js,
cookies.js, config.js, license-middleware.js, history.js, library.js)
I forgot to update the COPY list, so those files were never copied
into the runner image. Local 'node' tests passed because the modules
exist on disk; the .s9pk container had only the two original files
and crashed on first import.

Fix:

  COPY server/*.js ./server/

Glob picks up all top-level .js files automatically, including any
future extractions, while still skipping server/test/ and server/
node_modules/. This is the simplest forward-compatible form.

Bonus: refresh the vendored @keysat/licensing-client from 0.1.0 to
0.2.0. The new SDK adds:

  • policySlug field on StartPurchaseOptions (so we can drive Core/
    Pro tier selection programmatically from our backend)
  • client.listPublicPolicies(productSlug) for fetching the tier
    cards' data without auth

Both are prerequisites for the in-app buy flow planned in
~/.claude/plans/in-app-buy-flow.md. The vendor's own node_modules
(@noble/ed25519, @noble/hashes) is gitignored as before — Docker
builds re-install via `npm install --omit=dev --ignore-scripts` in
the vendor dir during stage 1.

Also includes the license-middleware update from earlier in the day:
a 30s license-file poll so a key set via the "Set Recap License"
StartOS action is picked up within seconds (instead of waiting for
the 6h scheduled validateOnline tick).
2026-05-09 11:57:41 -05:00

545 lines
17 KiB
JavaScript

// src/verify.ts
import * as ed from "@noble/ed25519";
import { sha512 } from "@noble/hashes/sha512";
// src/errors.ts
var LicensingError = class extends Error {
/**
* Machine-readable reason code. Common values:
* `"bad_format"`, `"bad_encoding"`, `"bad_version"`, `"bad_signature"`,
* `"expired"`, `"server_error"`, `"http_error"`, `"other"`.
*/
code;
constructor(code, message) {
super(message);
this.name = "LicensingError";
this.code = code;
}
};
// src/base32.ts
var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
var DECODE_TABLE = (() => {
const t = {};
for (let i = 0; i < ALPHABET.length; i++) t[ALPHABET[i]] = i;
return t;
})();
function decodeBase32NoPad(input) {
const up = input.toUpperCase();
const out = new Uint8Array(Math.floor(up.length * 5 / 8));
let bits = 0;
let value = 0;
let outPos = 0;
for (let i = 0; i < up.length; i++) {
const ch = up[i];
const v = DECODE_TABLE[ch];
if (v === void 0) {
throw new LicensingError("bad_encoding", `invalid base32 character '${ch}'`);
}
value = value << 5 | v;
bits += 5;
if (bits >= 8) {
bits -= 8;
out[outPos++] = value >> bits & 255;
}
}
return out.subarray(0, outPos);
}
// src/key.ts
var KEY_PREFIX = "LIC1";
var KEY_VERSION_V1 = 1;
var KEY_VERSION_V2 = 2;
var KEY_VERSION = KEY_VERSION_V2;
var FLAG_FINGERPRINT_BOUND = 1;
var FLAG_TRIAL = 2;
var PAYLOAD_V1_LEN = 74;
var PAYLOAD_V2_HEAD_LEN = 83;
var SIGNATURE_LEN = 64;
function isExpiredAt(payload, nowUnixSeconds) {
return payload.expiresAt !== 0 && nowUnixSeconds >= payload.expiresAt;
}
function hasEntitlement(payload, slug) {
return payload.entitlements.includes(slug);
}
function parseLicenseKey(raw) {
const trimmed = raw.trim();
const firstDash = trimmed.indexOf("-");
if (firstDash < 0) throw new LicensingError("bad_format", "key is missing prefix delimiter");
const prefix = trimmed.slice(0, firstDash);
if (prefix !== KEY_PREFIX) throw new LicensingError("bad_format", `unknown key prefix '${prefix}'`);
const body = trimmed.slice(firstDash + 1);
const lastDash = body.lastIndexOf("-");
if (lastDash < 0) throw new LicensingError("bad_format", "key is missing signature delimiter");
const payloadB32 = body.slice(0, lastDash);
const signatureB32 = body.slice(lastDash + 1);
const payloadBytes = decodeBase32NoPad(payloadB32);
const signature = decodeBase32NoPad(signatureB32);
if (signature.length !== SIGNATURE_LEN) {
throw new LicensingError(
"bad_format",
`signature is ${signature.length} bytes; expected ${SIGNATURE_LEN}`
);
}
if (payloadBytes.length < 1) {
throw new LicensingError("bad_format", "empty payload");
}
const version = payloadBytes[0];
let payload;
switch (version) {
case KEY_VERSION_V1:
payload = parseV1(payloadBytes);
break;
case KEY_VERSION_V2:
payload = parseV2(payloadBytes);
break;
default:
throw new LicensingError("bad_version", `unsupported key version ${version}`);
}
return {
payload,
signedBytes: payloadBytes,
signature
};
}
function parseV1(payloadBytes) {
if (payloadBytes.length !== PAYLOAD_V1_LEN) {
throw new LicensingError(
"bad_format",
`v1 payload is ${payloadBytes.length} bytes; expected ${PAYLOAD_V1_LEN}`
);
}
const flags = payloadBytes[1];
const productId = payloadBytes.slice(2, 18);
const licenseId = payloadBytes.slice(18, 34);
const issuedAt = readBigEndianI64(payloadBytes, 34);
const fingerprintHash = payloadBytes.slice(42, 74);
return {
version: KEY_VERSION_V1,
flags,
productId,
licenseId,
issuedAt,
expiresAt: 0,
fingerprintHash,
entitlements: [],
productUuid: uuidString(productId),
licenseUuid: uuidString(licenseId),
isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0,
isTrial: (flags & FLAG_TRIAL) !== 0
};
}
function parseV2(payloadBytes) {
if (payloadBytes.length < PAYLOAD_V2_HEAD_LEN) {
throw new LicensingError(
"bad_format",
`v2 payload is ${payloadBytes.length} bytes; expected >= ${PAYLOAD_V2_HEAD_LEN}`
);
}
const flags = payloadBytes[1];
const productId = payloadBytes.slice(2, 18);
const licenseId = payloadBytes.slice(18, 34);
const issuedAt = readBigEndianI64(payloadBytes, 34);
const expiresAt = readBigEndianI64(payloadBytes, 42);
const fingerprintHash = payloadBytes.slice(50, 82);
const numEntitlements = payloadBytes[82];
const entitlements = [];
let cursor = PAYLOAD_V2_HEAD_LEN;
const decoder = new TextDecoder("utf-8", { fatal: true });
for (let i = 0; i < numEntitlements; i++) {
if (cursor >= payloadBytes.length) {
throw new LicensingError("bad_format", "truncated entitlement list");
}
const len = payloadBytes[cursor];
cursor += 1;
if (cursor + len > payloadBytes.length) {
throw new LicensingError("bad_format", "truncated entitlement");
}
try {
entitlements.push(decoder.decode(payloadBytes.slice(cursor, cursor + len)));
} catch {
throw new LicensingError("bad_format", "entitlement not utf-8");
}
cursor += len;
}
if (cursor !== payloadBytes.length) {
throw new LicensingError("bad_format", "trailing bytes in payload");
}
return {
version: KEY_VERSION_V2,
flags,
productId,
licenseId,
issuedAt,
expiresAt,
fingerprintHash,
entitlements,
productUuid: uuidString(productId),
licenseUuid: uuidString(licenseId),
isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0,
isTrial: (flags & FLAG_TRIAL) !== 0
};
}
function readBigEndianI64(buf, offset) {
const view = new DataView(buf.buffer, buf.byteOffset + offset, 8);
const hi = view.getInt32(0, false);
const lo = view.getUint32(4, false);
return hi * 2 ** 32 + lo;
}
function uuidString(b) {
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
// src/fingerprint.ts
import { sha256 } from "@noble/hashes/sha256";
function hashFingerprint(raw) {
return sha256(new TextEncoder().encode(raw));
}
// src/verify.ts
ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m));
var Verifier = class {
pubkey;
constructor(pubkey) {
this.pubkey = pubkey;
}
/** Verify a license key string. Throws on any failure. */
verify(keyStr) {
const key = parseLicenseKey(keyStr);
const ok = ed.verify(key.signature, key.signedBytes, this.pubkey.raw);
if (!ok) throw new LicensingError("bad_signature", "signature did not verify");
return {
payload: key.payload,
licenseId: key.payload.licenseUuid,
productId: key.payload.productUuid
};
}
/**
* Verify AND enforce that, if the key is fingerprint-bound, the given
* fingerprint matches. If the key is not bound, the fingerprint is
* ignored. Throws on any failure.
*/
verifyWithFingerprint(keyStr, fingerprint) {
const result = this.verify(keyStr);
if (result.payload.isFingerprintBound) {
const expected = hashFingerprint(fingerprint);
const stored = result.payload.fingerprintHash;
if (!equalBytes(expected, stored)) {
throw new LicensingError("bad_signature", "fingerprint does not match bound key");
}
}
return result;
}
/**
* Verify a key and additionally reject it with an `expired` error if
* `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys
* (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is
* offline-only — no grace window logic; use `Client.validate` for that.
*/
verifyWithTime(keyStr, nowUnixSeconds) {
const result = this.verify(keyStr);
if (isExpiredAt(result.payload, nowUnixSeconds)) {
throw new LicensingError("expired", "license has expired");
}
return result;
}
};
function equalBytes(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
// src/online.ts
var Client = class {
base;
constructor(baseUrl) {
this.base = baseUrl.replace(/\/+$/, "");
}
/** The normalized base URL this client is pinned to. */
baseUrl() {
return this.base;
}
/** Fetch the server's PEM-encoded public key. */
async fetchPubkeyPem() {
const data = await this.get("/v1/pubkey");
return data.public_key_pem;
}
/**
* Server-authoritative validation. Returns the full response including
* expiry / entitlements / seat fields introduced in v2.
*
* Two-argument form kept for call-site compatibility with earlier SDK
* versions; pass an options object for the full set of fields.
*/
async validate(key, productSlugOrOptions, fingerprint) {
const opts = typeof productSlugOrOptions === "string" ? { productSlug: productSlugOrOptions, fingerprint } : productSlugOrOptions ?? {};
const raw = await this.post("/v1/validate", {
key,
product_slug: opts.productSlug,
fingerprint: opts.fingerprint,
hostname: opts.hostname,
platform: opts.platform
});
return this.toValidateResponse(raw);
}
/** Lightweight heartbeat. Server updates `last_heartbeat_at`. */
async heartbeat(key, fingerprint) {
const raw = await this.post("/v1/machines/heartbeat", {
key,
fingerprint
});
return this.toMachineResponse(raw);
}
/** Explicitly activate a seat for the given fingerprint. */
async activate(key, fingerprint, opts = {}) {
const raw = await this.post("/v1/machines/activate", {
key,
fingerprint,
hostname: opts.hostname,
platform: opts.platform
});
return this.toMachineResponse(raw);
}
/** Free a seat held by the given fingerprint. */
async deactivate(key, fingerprint, reason) {
const raw = await this.post("/v1/machines/deactivate", {
key,
fingerprint,
reason
});
return this.toMachineResponse(raw);
}
/** Start a purchase. Returns the checkout URL and invoice id. */
async startPurchase(productSlug, opts = {}) {
const raw = await this.post("/v1/purchase", {
product: productSlug,
buyer_email: opts.buyerEmail,
buyer_note: opts.buyerNote,
redirect_url: opts.redirectUrl,
code: opts.code,
policy_slug: opts.policySlug
});
return {
invoiceId: raw.invoice_id,
btcpayInvoiceId: raw.btcpay_invoice_id,
checkoutUrl: raw.checkout_url,
amountSats: raw.amount_sats,
pollUrl: raw.poll_url
};
}
/**
* List public, buyer-visible policies (tiers) for a product. No
* auth — same data the licensing service's `/buy/<slug>` page
* uses server-side. Use this to render an in-app tier picker
* that stays in sync with the operator's admin-side tier setup.
*
* Returns each policy's slug, display name, price (in the
* product's listed currency's smallest unit — sats or cents),
* entitlements, recurring/trial flags. Internal fields (id,
* tip recipients, raw metadata) are deliberately omitted.
*/
async listPublicPolicies(productSlug) {
const raw = await this.get(
`/v1/products/${encodeURIComponent(productSlug)}/policies`
);
const product = raw.product;
const policies = raw.policies ?? [];
return {
product: {
slug: product.slug,
name: product.name,
description: product.description ?? "",
basePriceSats: product.base_price_sats
},
policies: policies.map((p) => ({
slug: p.slug,
name: p.name,
description: p.description ?? "",
priceSats: p.price_sats,
durationSeconds: p.duration_seconds ?? 0,
maxMachines: p.max_machines ?? 1,
isTrial: !!p.is_trial,
entitlements: p.entitlements ?? [],
highlighted: !!p.highlighted,
isRecurring: !!p.is_recurring,
renewalPeriodDays: p.renewal_period_days ?? 0,
trialDays: p.trial_days ?? 0
}))
};
}
/**
* Redeem a `free_license` code: bypass BTCPay entirely and receive the
* signed license key directly. Throws if the code is unknown / disabled
* / expired / wrong product / not a free_license code, or if the cap
* has been reached.
*/
async redeemFreeLicense(productSlug, code, opts = {}) {
const raw = await this.post("/v1/redeem", {
product: productSlug,
code,
buyer_email: opts.buyerEmail,
buyer_note: opts.buyerNote
});
return {
licenseId: raw.license_id,
licenseKey: raw.license_key,
invoiceId: raw.invoice_id,
redemptionId: raw.redemption_id
};
}
/** Poll a purchase by its invoice id. */
async pollPurchase(invoiceId) {
const raw = await this.get(
`/v1/purchase/${encodeURIComponent(invoiceId)}`
);
return {
invoiceId: raw.invoice_id,
status: raw.status,
productId: raw.product_id,
amountSats: raw.amount_sats,
licenseKey: raw.license_key ?? void 0,
licenseId: raw.license_id ?? void 0
};
}
/**
* Convenience: open the checkout, poll until a license key is issued,
* then return it. Suitable for CLI usage or for an app UI that shows a
* spinner while the buyer pays.
*/
async waitForLicense(invoiceId, options = {}) {
const interval = options.intervalMs ?? 5e3;
const deadline = options.timeoutMs ? Date.now() + options.timeoutMs : Infinity;
while (true) {
const poll = await this.pollPurchase(invoiceId);
if (poll.licenseKey) return poll.licenseKey;
if (poll.status === "expired" || poll.status === "invalid") {
throw new LicensingError("server_error", `invoice ended in status ${poll.status}`);
}
if (Date.now() > deadline) {
throw new LicensingError("server_error", "timed out waiting for license issuance");
}
await sleep(interval);
}
}
// --- internals ---
toValidateResponse(raw) {
const entitlements = Array.isArray(raw.entitlements) ? raw.entitlements.filter((x) => typeof x === "string") : void 0;
return {
ok: !!raw.ok,
reason: raw.reason,
licenseId: raw.license_id,
productId: raw.product_id,
productSlug: raw.product_slug,
issuedAt: raw.issued_at,
expiresAt: raw.expires_at,
graceUntil: raw.grace_until,
inGracePeriod: raw.in_grace_period,
isTrial: raw.is_trial,
entitlements,
status: raw.status,
machineId: raw.machine_id,
maxMachines: raw.max_machines
};
}
toMachineResponse(raw) {
return {
ok: !!raw.ok,
reason: raw.reason,
machineId: raw.machine_id,
activeCount: raw.active_count,
maxMachines: raw.max_machines
};
}
async get(path) {
return this.request(path, { method: "GET" });
}
async post(path, body) {
return this.request(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
}
async request(path, init) {
let resp;
try {
resp = await fetch(`${this.base}${path}`, init);
} catch (e) {
throw new LicensingError("http_error", e instanceof Error ? e.message : String(e));
}
const text = await resp.text();
if (!resp.ok) {
throw new LicensingError("server_error", `HTTP ${resp.status}: ${text}`);
}
try {
return JSON.parse(text);
} catch {
throw new LicensingError("server_error", `non-JSON response: ${text}`);
}
}
};
function sleep(ms) {
return new Promise((res) => setTimeout(res, ms));
}
// src/pubkey.ts
var PublicKey = class _PublicKey {
/** Raw 32-byte Ed25519 public key material. */
raw;
constructor(raw) {
if (raw.length !== 32) {
throw new LicensingError(
"bad_format",
`public key must be 32 bytes; got ${raw.length}`
);
}
this.raw = raw;
}
/** Parse a PEM blob as emitted by the service. */
static fromPem(pem) {
const stripped = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, "");
if (!stripped) {
throw new LicensingError("bad_format", "empty PEM input");
}
const der = base64Decode(stripped);
if (der.length < 32) {
throw new LicensingError("bad_format", "PEM body too short to contain a public key");
}
const raw = der.slice(der.length - 32);
return new _PublicKey(raw);
}
/** Construct from raw bytes (no PEM envelope). */
static fromBytes(bytes) {
return new _PublicKey(bytes);
}
};
function base64Decode(b64) {
const nodeBuffer = globalThis.Buffer;
if (nodeBuffer) {
return new Uint8Array(nodeBuffer.from(b64, "base64"));
}
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
export {
Client,
FLAG_FINGERPRINT_BOUND,
FLAG_TRIAL,
KEY_PREFIX,
KEY_VERSION,
KEY_VERSION_V1,
KEY_VERSION_V2,
LicensingError,
PublicKey,
Verifier,
hasEntitlement,
hashFingerprint,
isExpiredAt,
parseLicenseKey
};