// Request identity resolution (core-decoupling). // // Two kinds of caller authenticate to the relay: // // "cloud" — the operator's cloud Recaps server (recaps.cc) acting on // behalf of one of its signed-in users. It authenticates // ONCE with a shared operator key (X-Recap-Operator-Key) and // names the user via X-Recap-User-Id. The credit pool is // keyed by `user:`, and the tier is whatever the relay // has STORED for that user (operator-set) — NOT read from a // license. This is the path that removes the per-user Keysat // license from cloud requests. // // "license" — a license bearer (a self-hosted install using the // operator's relay, or the operator's own single-mode app). // Unchanged legacy behavior: tier + pool come from the // resolved Keysat license / install id. // // resolveIdentity(req) returns a uniform shape the routes thread into the // credits/job-credits/envelope helpers: // { kind, creditKey, userId|null, installId|null, license|null } // // A route bills against `creditKey`; for tier it uses the stored row tier // (cloud) or `license.tier` (license) — see identityTier(). import { getConfigSnapshot } from "./config.js"; import { resolveLicense } from "./keysat-client.js"; import { getCreditKey } from "./credits.js"; // user-ids come from Recaps (base64url / hex account ids). Constrain the // charset so a header can't smuggle a path-ish or oversized key into the // ledger. const USER_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; export async function resolveIdentity(req) { const userId = (req.header("X-Recap-User-Id") || "").trim(); if (userId) { // Cloud path — must present a valid operator key. const cfg = await getConfigSnapshot(); const expected = (cfg.relay_cloud_operator_key || "").trim(); const presented = (req.header("X-Recap-Operator-Key") || "").trim(); if (!expected || !presented || presented !== expected) { const e = new Error( "X-Recap-User-Id requires a valid X-Recap-Operator-Key" ); e.status = 401; throw e; } if (!USER_ID_RE.test(userId)) { const e = new Error("invalid X-Recap-User-Id"); e.status = 400; throw e; } return { kind: "cloud", creditKey: `user:${userId}`, userId, installId: null, license: null, }; } // License / install path — unchanged legacy behavior. const installId = req.header("X-Recap-Install-Id") || null; const license = await resolveLicense(req.header("Authorization")); // getCreditKey throws only when there's neither a license fingerprint // nor an installId; routes guard "missing install id" themselves, so // tolerate it here and leave creditKey null. let creditKey = null; try { creditKey = getCreditKey({ installId, license }); } catch {} return { kind: "license", creditKey, userId: null, installId, license }; } // Is this request the operator's cloud server (valid operator key)? // Used by operator-only endpoints (e.g. setting a user's tier) that // aren't tied to a specific X-Recap-User-Id. export async function verifyOperatorKey(req) { const cfg = await getConfigSnapshot(); const expected = (cfg.relay_cloud_operator_key || "").trim(); const presented = (req.header("X-Recap-Operator-Key") || "").trim(); return !!expected && !!presented && presented === expected; } // The tier to bill/quota at for a resolved identity. // cloud → the relay's stored row tier (operator-set; default "core") // license → the resolved license tier export function identityTier(identity, row) { if (identity.kind === "cloud") { // Prepaid-period enforcement (self-serve subscriptions): once the paid // period lapses, the user is effectively Core until they renew. An // operator comp grant leaves subscription_expires_at null, so this is a // no-op for those — only dated purchases expire. if (isSubscriptionExpired(row)) return "core"; return row?.tier_snapshot || "core"; } return identity.license?.tier || "core"; } // True when a cloud user's paid period has a set expiry that's in the past. // Null/absent expiry = never expires (e.g. an operator comp grant). export function isSubscriptionExpired(row) { const exp = row?.subscription_expires_at; if (!exp) return false; const t = new Date(exp).getTime(); return Number.isFinite(t) && t < Date.now(); }