Files
recap-relay/server/identity.js
T

110 lines
4.4 KiB
JavaScript

// 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:<id>`, 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();
}