Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions hub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ const titaniumAdminToken =
const magicLinkSecret = requireMinLenIfSet("MAGIC_LINK_SECRET", process.env.MAGIC_LINK_SECRET, 32);
const sessionSecret = requireMinLenIfSet("SESSION_SECRET", process.env.SESSION_SECRET, 32);
const allowLegacyLogin = parseBool(process.env.ALLOW_LEGACY_LOGIN, true);
// Phase 07-D escape hatch: when LICENSE_REQUIRED=false, requireActiveLicense
// short-circuits to permissive mode (logs a warning once at boot). Used while
// Keygen JWKS endpoint is unhealthy so identity/REST routes don't 402.
const licenseRequired = parseBool(process.env.LICENSE_REQUIRED, true);
// Optional Titanium -> hub webhook for license-state changes. Inert (route
// returns 503) until Titanium ships the webhook and the secret is provisioned.
const titaniumWebhookSecret = requireMinLenIfSet(
Expand Down Expand Up @@ -94,4 +98,5 @@ export const config = {
sessionSecret,
allowLegacyLogin,
titaniumWebhookSecret,
licenseRequired,
};
36 changes: 35 additions & 1 deletion hub/src/license-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ import {
const GRACE_DAYS_DEFAULT = 7;
const LICENSE_CHECK_FAILED_THROTTLE_MS = 60_000;

// One-shot boot warning when LICENSE_REQUIRED=false. Logged on first request,
// not on every request, to keep prod logs scannable.
let permissiveWarned = false;

// Per-user throttle of `license_check_failed` audit writes — at most one
// every 60s to keep the log scannable during a burst of mutating requests.
const lastLogged = new Map<string, number>();
Expand Down Expand Up @@ -154,7 +158,25 @@ async function refreshLicense(
};
}
if (err instanceof TitaniumVerifyError) {
// Map verify errors to a sensible state and persist.
// Transient verify failures (JWKS endpoint down / network blip surfaced
// as a verify error rather than TitaniumApiError) MUST NOT flip ACTIVE
// → INVALID. Treat 'network', 'malformed' (covers jose's
// JWKSInvalid / fetch errors), and unknown-kind verify errors as
// transient: keep cached value, log, continue with grace logic.
const transient =
err.kind === "network" ||
err.kind === "malformed" ||
/jwks/i.test(err.message);
if (transient) {
console.warn(
`[license-gate] transient verify error (${err.kind}: ${err.message}) — preserving cached status=${fields.license_status ?? "NONE"}`,
);
return {
status: fields.license_status ?? "NONE",
license_id: fields.license_id,
};
}
// Map definitive verify errors to a sensible state and persist.
const kindStatus =
err.kind === "expired" ? "EXPIRED"
: err.kind === "blocked" ? "BANNED"
Expand Down Expand Up @@ -205,6 +227,18 @@ export function requireActiveLicense(
const readOnlyOk = opts.readOnlyOk ?? false;

return async (c: Context, next: Next) => {
// Escape hatch: when LICENSE_REQUIRED=false, bypass the gate entirely.
// Used while Keygen JWKS is unhealthy so REST identity routes don't 402.
if (!config.licenseRequired) {
if (!permissiveWarned) {
permissiveWarned = true;
console.warn(
"[license-gate] LICENSE_REQUIRED=false → permissive mode (all authed requests pass)",
);
}
return next();
}

const userId = c.get("userId") as string | undefined;
if (!userId) {
// Auth missing — defer to the upstream 401 shape.
Expand Down
52 changes: 51 additions & 1 deletion hub/test/license-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
__resetDalForTesting,
} from "../src/license-gate";
import { config } from "../src/config";
import { BlockedSubjectError } from "../src/titanium-client";
import { BlockedSubjectError, TitaniumVerifyError } from "../src/titanium-client";

type Fields = {
license_status: string | null;
Expand Down Expand Up @@ -299,3 +299,53 @@ describe("requireActiveLicense — audit log on denial", () => {
expect(recordCalls.length).toBe(0);
});
});

describe("requireActiveLicense — escape hatches", () => {
test("LICENSE_REQUIRED=false → bypass gate entirely", async () => {
const orig = (config as any).licenseRequired;
(config as any).licenseRequired = false;
// No DAL stubs needed — gate must not consult them.
__setDalForTesting({
getUserLicenseFields: async () => {
throw new Error("should not be called in permissive mode");
},
});
const app = makeApp();
const res = await app.request("/", { method: "POST" });
(config as any).licenseRequired = orig;
expect(res.status).toBe(200);
});

test("JWKS-unreachable verify error → preserve cached ACTIVE (no flip to INVALID)", async () => {
const origTtl = config.titanium.licenseCacheTtlSeconds;
// Force cache stale so refreshLicense runs.
(config.titanium as any).licenseCacheTtlSeconds = 1;
__setDalForTesting({
getUserLicenseFields: async () => ({
license_status: "ACTIVE",
license_id: "lic-1",
license_checked_at: daysAgo(1), // stale
titanium_subject: SUBJECT,
}),
updateLicenseStatus: async (uid, status, lid) => {
updateCalls.push([uid, status, lid]);
},
recordAuthEvent: async (opts: any) => {
recordCalls.push(opts);
},
validateLicenseKey: async () => {
throw new TitaniumVerifyError(
"malformed",
"Unknown verify error: JWKS fetch failed: 404 Not Found",
);
},
assertNotBlocked: async () => {},
});
const app = makeApp({ readOnlyOk: true });
const res = await app.request("/", { method: "GET" });
(config.titanium as any).licenseCacheTtlSeconds = origTtl;
expect(res.status).toBe(200);
// Critical: must NOT have persisted INVALID to the DB.
expect(updateCalls).toEqual([]);
});
});
Loading