@clearance/api 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,2321 @@
1
+ import { Hono } from "hono";
2
+ import { cors } from "hono/cors";
3
+ import { addMember, addMemberInAuth, archiveOrganization, archiveOrganizationInAuth, assertClientScopeHeaders, assertProductionCredentialKey, assertProductionSecret, ClearanceError, createManagementStore, createProject, createEnvironment, planProjectCreate, planEnvironmentCreate, createOrganization, createOrgInAuth, createScimConnection, createScimConnectionReal, createSsoConnection, createSsoConnectionReal, createUser, createUserInAuth, deleteUser, deleteUserInAuth, disableScimConnection, disableScimConnectionReal, disableSsoConnection, disableSsoConnectionReal, disableUser, disableUserInAuth, ensureAuthMigrated, exportUsers, getLatestReadiness, initProject, inspectEnvironment, inspectMembership, inspectOrganization, inspectScimConnection, inspectSsoConnection, inspectUser, isClearanceError, isForbiddenDefaultSecret, listEnvironments, listEventsPage, exportEvents, inspectEvent, replayDiagnosticTrace, listMembers, listOrganizations, listOrganizationsPage, listRoles, listSessionsPage, listSessionsPageInAuth, listUsers, listUsersPage, assertIdempotencyKeyValid, createIdempotencyBackend, fingerprintIdempotentRequest, idempotencyConflictError, overviewStats, promoteEnvironment, revokeSession, revokeSessionInAuth, removeMember, removeMemberInAuth, parseCorsOrigins, requireOperatorToken, resolveOperatorScope, createRole, createApiKey, listApiKeys, inspectApiKey, validateApiKeyName, normalizeAndValidateApiKeyScopes, rotateApiKey, revokeApiKey, listProjects, configureSsoConnection, listSsoConnections, listScimConnections, createSetupLink, publicConfig, setConfig, validateConfig, diffConfig, rotateScimCredential, rotateSsoCredential, updateMember, updateMemberInAuth, updateOrganization, updateOrganizationInAuth, updateRole, validateRole, updateUser, updateUserInAuth, parseUserStatusInput, reserveSetupLink, commitSetupLink, releaseSetupLink, deleteSsoProviderById, deleteScimProviderById, runDoctor, runReadinessCheck, testScimConnection, testScimConnectionReal, testScimConnectionLive, testSsoConnection, testSsoConnectionReal, testSsoConnectionLive, syncRuntimeOrganizationToManagementDurable, } from "@clearance/management";
4
+ import { timingSafeEqual, createHash, randomBytes, randomUUID } from "node:crypto";
5
+ const port = Number(process.env.CLEARANCE_API_PORT ?? 3200);
6
+ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 1024 * 1024;
7
+ export function resolveMaxRequestBodyBytes(env = process.env) {
8
+ const raw = env.CLEARANCE_API_MAX_BODY_BYTES;
9
+ if (raw === undefined || raw.trim() === "")
10
+ return DEFAULT_MAX_REQUEST_BODY_BYTES;
11
+ const value = Number(raw);
12
+ if (!Number.isSafeInteger(value) || value < 1 || value > 64 * 1024 * 1024) {
13
+ throw new Error("CLEARANCE_API_MAX_BODY_BYTES must be an integer between 1 and 67108864");
14
+ }
15
+ return value;
16
+ }
17
+ const MAX_REQUEST_BODY_BYTES = resolveMaxRequestBodyBytes();
18
+ class RequestBodyTooLargeError extends Error {
19
+ constructor() {
20
+ super(`Request body exceeds the ${MAX_REQUEST_BODY_BYTES}-byte limit`);
21
+ this.name = "RequestBodyTooLargeError";
22
+ }
23
+ }
24
+ // Production refuses default secrets at boot
25
+ assertProductionSecret(process.env.CLEARANCE_SECRET);
26
+ const strictStartup = process.env.NODE_ENV === "production" ||
27
+ process.env.CLEARANCE_STRICT_SECRETS === "1";
28
+ if (strictStartup) {
29
+ if (isForbiddenDefaultSecret(process.env.CLEARANCE_SECRET)) {
30
+ throw new Error("Clearance API refuses missing/default/weak CLEARANCE_SECRET");
31
+ }
32
+ requireOperatorToken();
33
+ assertProductionCredentialKey(process.env);
34
+ if (!process.env.DATABASE_URL?.trim()) {
35
+ throw new Error("Clearance API requires DATABASE_URL in strict/production mode");
36
+ }
37
+ }
38
+ let storePromise = null;
39
+ function getStore() {
40
+ if (!storePromise) {
41
+ const url = process.env.DATABASE_URL?.trim();
42
+ storePromise = createManagementStore({
43
+ dataPath: process.env.CLEARANCE_DATA_PATH,
44
+ databaseUrl: url || undefined,
45
+ });
46
+ }
47
+ return storePromise;
48
+ }
49
+ /**
50
+ * Long-lived API process: refresh so external CLI writes are visible before
51
+ * serving. Flushes pending local mutations first.
52
+ */
53
+ async function storeForRequest() {
54
+ const store = await getStore();
55
+ await store.refresh();
56
+ return store;
57
+ }
58
+ /** Simple in-memory rate limit (per client key) — always enabled */
59
+ const rateBuckets = new Map();
60
+ const RATE_WINDOW_MS = 60_000;
61
+ const RATE_MAX = Number(process.env.CLEARANCE_API_RATE_MAX ?? 120);
62
+ const RATE_MAX_BUCKETS = 10_000;
63
+ /**
64
+ * Rate-limit keying (FOLLOW.md P2.3.3).
65
+ *
66
+ * Default: key on the SOCKET remote address, never on client-supplied
67
+ * headers — x-forwarded-for from an untrusted client would let one attacker
68
+ * partition the limiter into unlimited buckets. The node bridge in start()
69
+ * stamps x-clearance-socket-remote from req.socket unconditionally
70
+ * (overwriting anything a client sent), so that header is server-derived.
71
+ *
72
+ * CLEARANCE_TRUSTED_PROXY=1: honor x-forwarded-for, taking the LAST hop —
73
+ * the entry appended by the trusted proxy (the console BFF), i.e. the address
74
+ * the proxy actually saw. Set this ONLY when the API is reachable exclusively
75
+ * via trusted proxies; a directly reachable API with this flag re-opens the
76
+ * spoofing hole.
77
+ */
78
+ const TRUSTED_PROXY = process.env.CLEARANCE_TRUSTED_PROXY === "1";
79
+ function rateLimitClientKey(c) {
80
+ const socketAddr = c.req.header("x-clearance-socket-remote")?.trim() || "local";
81
+ if (TRUSTED_PROXY) {
82
+ const xff = c.req.header("x-forwarded-for");
83
+ if (xff) {
84
+ const hops = xff
85
+ .split(",")
86
+ .map((hop) => hop.trim())
87
+ .filter(Boolean);
88
+ const lastUntrustedHop = hops[hops.length - 1];
89
+ if (lastUntrustedHop)
90
+ return `xff:${lastUntrustedHop}`;
91
+ }
92
+ }
93
+ return `sock:${socketAddr}`;
94
+ }
95
+ function rateLimit(ip) {
96
+ const now = Date.now();
97
+ let bucket = rateBuckets.get(ip);
98
+ if (!bucket || now >= bucket.resetAt) {
99
+ if (!bucket && rateBuckets.size >= RATE_MAX_BUCKETS) {
100
+ for (const [key, candidate] of rateBuckets) {
101
+ if (now >= candidate.resetAt)
102
+ rateBuckets.delete(key);
103
+ }
104
+ if (rateBuckets.size >= RATE_MAX_BUCKETS) {
105
+ return { ok: false, remaining: 0 };
106
+ }
107
+ }
108
+ bucket = { count: 0, resetAt: now + RATE_WINDOW_MS };
109
+ rateBuckets.set(ip, bucket);
110
+ }
111
+ bucket.count += 1;
112
+ return { ok: bucket.count <= RATE_MAX, remaining: Math.max(0, RATE_MAX - bucket.count) };
113
+ }
114
+ function safeEqualToken(a, b) {
115
+ const ha = createHash("sha256").update(a).digest();
116
+ const hb = createHash("sha256").update(b).digest();
117
+ return timingSafeEqual(ha, hb);
118
+ }
119
+ /**
120
+ * Operator principal scope (server-side only)
121
+ * -------------------------------------------
122
+ * CLEARANCE_OPERATOR_TOKEN authenticates the operator. Project/environment
123
+ * authority is derived exclusively from server configuration:
124
+ * CLEARANCE_PROJECT_ID + CLEARANCE_ENV_ID, or the store's initialized
125
+ * meta.config after `init` (local single-project profile).
126
+ *
127
+ * Client headers `X-Clearance-Project-Id` / `X-Clearance-Environment-Id` are
128
+ * never used as authority. If present they must match principal scope
129
+ * (consistency check only) — they cannot broaden or select another scope.
130
+ *
131
+ * Resource routes reject with SCOPE_REQUIRED when principal scope is absent.
132
+ * Init and health remain usable without scope (bootstrapping).
133
+ */
134
+ function principalScope(store) {
135
+ return resolveOperatorScope(store);
136
+ }
137
+ function scopeForRequest(store, c) {
138
+ const scope = principalScope(store);
139
+ assertClientScopeHeaders(scope, c.req.header("x-clearance-project-id"), c.req.header("x-clearance-environment-id"));
140
+ return scope;
141
+ }
142
+ /** @deprecated Use principalScope — kept for tests that import scopeFromStore */
143
+ function scopeFromStore(store) {
144
+ try {
145
+ return principalScope(store);
146
+ }
147
+ catch {
148
+ return {
149
+ projectId: process.env.CLEARANCE_PROJECT_ID ??
150
+ store.snapshot.meta.config.projectId ??
151
+ store.snapshot.projects[0]?.id,
152
+ environmentId: process.env.CLEARANCE_ENV_ID ??
153
+ store.snapshot.meta.config.environmentId ??
154
+ store.snapshot.environments[0]?.id,
155
+ };
156
+ }
157
+ }
158
+ /** @deprecated Prefer scopeForRequest — header check only, never authority */
159
+ function assertScope(store, headerProject, headerEnv) {
160
+ const scope = principalScope(store);
161
+ assertClientScopeHeaders(scope, headerProject, headerEnv);
162
+ return scope;
163
+ }
164
+ const app = new Hono();
165
+ const processStartedAt = Date.now();
166
+ let draining = false;
167
+ let inFlightRequests = 0;
168
+ let requestDurationSeconds = 0;
169
+ const requestCounts = new Map();
170
+ const requestLoggingEnabled = strictStartup || process.env.CLEARANCE_REQUEST_LOG === "1";
171
+ app.use("*", async (c, next) => {
172
+ const suppliedRequestId = c.req.header("x-request-id") ?? "";
173
+ const requestId = /^[A-Za-z0-9._:-]{1,128}$/.test(suppliedRequestId)
174
+ ? suppliedRequestId
175
+ : randomUUID();
176
+ const started = performance.now();
177
+ inFlightRequests += 1;
178
+ c.header("x-request-id", requestId);
179
+ try {
180
+ await next();
181
+ }
182
+ finally {
183
+ inFlightRequests -= 1;
184
+ const durationSeconds = (performance.now() - started) / 1000;
185
+ requestDurationSeconds += durationSeconds;
186
+ const key = `${c.req.method}|${c.res.status}`;
187
+ requestCounts.set(key, (requestCounts.get(key) ?? 0) + 1);
188
+ if (requestLoggingEnabled) {
189
+ console.log(JSON.stringify({
190
+ event: "http_request",
191
+ service: "clearance-api",
192
+ requestId,
193
+ method: c.req.method,
194
+ path: c.req.path,
195
+ status: c.res.status,
196
+ durationMs: Math.round(durationSeconds * 1000),
197
+ }));
198
+ }
199
+ }
200
+ });
201
+ const corsOrigins = parseCorsOrigins();
202
+ app.use("*", cors({
203
+ origin: (origin) => {
204
+ if (!origin)
205
+ return corsOrigins[0] ?? "http://localhost:3100";
206
+ return corsOrigins.includes(origin) ? origin : null;
207
+ },
208
+ allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
209
+ allowHeaders: [
210
+ "Content-Type",
211
+ "Authorization",
212
+ "X-Clearance-Project-Id",
213
+ "X-Clearance-Environment-Id",
214
+ ],
215
+ credentials: true,
216
+ maxAge: 600,
217
+ }));
218
+ app.use("*", async (c, next) => {
219
+ const { ok, remaining } = rateLimit(rateLimitClientKey(c));
220
+ c.header("X-RateLimit-Limit", String(RATE_MAX));
221
+ c.header("X-RateLimit-Remaining", String(remaining));
222
+ if (!ok) {
223
+ return c.json({
224
+ error: {
225
+ code: "RATE_LIMITED",
226
+ message: "Too many requests",
227
+ stage: "api.rate_limit",
228
+ retryable: true,
229
+ remediation: "Retry after 60s",
230
+ },
231
+ }, 429);
232
+ }
233
+ return next();
234
+ });
235
+ async function requireOperator(c, next) {
236
+ // Health is public
237
+ if (c.req.path === "/health")
238
+ return next();
239
+ let expected;
240
+ try {
241
+ expected = requireOperatorToken();
242
+ }
243
+ catch (e) {
244
+ return c.json({
245
+ error: {
246
+ code: "OPERATOR_TOKEN_UNCONFIGURED",
247
+ message: e instanceof Error ? e.message : "Operator token required",
248
+ stage: "api.auth",
249
+ retryable: false,
250
+ remediation: "Set CLEARANCE_OPERATOR_TOKEN (≥16 chars)",
251
+ },
252
+ }, 503);
253
+ }
254
+ const auth = c.req.header("authorization") ?? "";
255
+ const match = /^Bearer\s+(.+)$/i.exec(auth);
256
+ if (!match || !safeEqualToken(match[1], expected)) {
257
+ return c.json({
258
+ error: {
259
+ code: "UNAUTHORIZED",
260
+ message: "Bearer operator token required",
261
+ stage: "api.auth",
262
+ retryable: false,
263
+ remediation: "Authorization: Bearer <CLEARANCE_OPERATOR_TOKEN>",
264
+ },
265
+ }, 401);
266
+ }
267
+ return next();
268
+ }
269
+ app.use("/v1/*", requireOperator);
270
+ app.use("/v1/*", async (c, next) => {
271
+ if (!["POST", "PUT", "PATCH", "DELETE"].includes(c.req.method))
272
+ return next();
273
+ if (!(c.req.header("content-type") ?? "").toLowerCase().includes("application/json"))
274
+ return next();
275
+ const request = await c.req.raw.clone().json().catch(() => undefined);
276
+ if (!request || typeof request !== "object" || Array.isArray(request))
277
+ return next();
278
+ for (const field of ["dryRun", "confirm"]) {
279
+ if (Object.hasOwn(request, field) && typeof request[field] !== "boolean") {
280
+ return c.json({
281
+ error: {
282
+ code: "API_BOOLEAN_INVALID",
283
+ message: `${field} must be a JSON boolean.`,
284
+ stage: "api.request",
285
+ retryable: false,
286
+ remediation: `Send ${field} as true or false without quotes.`,
287
+ },
288
+ }, 400);
289
+ }
290
+ }
291
+ return next();
292
+ });
293
+ /**
294
+ * Idempotency-Key replay for /v1/* mutations (FOLLOW.md P2.3.2).
295
+ *
296
+ * Keys are scoped per method+route; the request body is fingerprinted.
297
+ * - Same key + same payload → the ORIGINAL response body and status are
298
+ * replayed byte-identically, except that one-time credentials are omitted
299
+ * from the durable replay envelope. Replays are marked with the
300
+ * Idempotency-Replayed: true response header.
301
+ * - Same key + different payload → structured 409 IDEMPOTENCY_KEY_CONFLICT.
302
+ * Responses with status >= 500 are never stored (retries must re-execute).
303
+ * Storage: Postgres companion table with TTL for PgStore; process-local
304
+ * in-memory map for the JSON dev store (see management idempotency.ts).
305
+ * Registered AFTER requireOperator so unauthenticated requests can neither
306
+ * consume nor replay keys.
307
+ */
308
+ const IDEMPOTENT_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
309
+ let idempotencyBackend = null;
310
+ /**
311
+ * Build the response body that is safe to persist for a later replay.
312
+ * Generated temporary passwords are one-time delivery credentials: retaining
313
+ * one in either the Postgres companion table or the development memory backend
314
+ * would extend its lifetime to the idempotency TTL. A replay still returns the
315
+ * original user and status while explicitly reporting the omitted secret.
316
+ */
317
+ function idempotencyReplayBody(path, body) {
318
+ const sensitive = path === "/v1/users" ||
319
+ path === "/v1/keys" ||
320
+ /^\/v1\/keys\/[^/]+\/rotate$/.test(path) ||
321
+ path === "/v1/sso/setup-links" ||
322
+ path === "/v1/scim/setup-links" ||
323
+ path === "/v1/scim";
324
+ if (!sensitive)
325
+ return body;
326
+ try {
327
+ const parsed = JSON.parse(body);
328
+ const omitted = [];
329
+ for (const key of path === "/v1/users"
330
+ ? ["temporaryPassword"]
331
+ : path.endsWith("/setup-links")
332
+ ? ["token", "url"]
333
+ : path === "/v1/keys" || path.endsWith("/rotate")
334
+ ? ["secret"]
335
+ : []) {
336
+ if (Object.hasOwn(parsed, key)) {
337
+ // biome-ignore lint/performance/noDelete: one-time credentials must not enter persistence.
338
+ delete parsed[key];
339
+ omitted.push(key);
340
+ }
341
+ }
342
+ if (path === "/v1/scim") {
343
+ const connection = parsed.connection;
344
+ if (connection && typeof connection === "object" && Object.hasOwn(connection, "bearerTokenOnce")) {
345
+ // biome-ignore lint/performance/noDelete: one-time credentials must not enter persistence.
346
+ delete connection.bearerTokenOnce;
347
+ omitted.push("connection.bearerTokenOnce");
348
+ }
349
+ }
350
+ if (omitted.length === 0)
351
+ return body;
352
+ parsed.oneTimeSecretsOmitted = omitted;
353
+ return JSON.stringify(parsed);
354
+ }
355
+ catch {
356
+ // A sensitive response that cannot be inspected may contain a generated
357
+ // credential, so it is never persisted for replay.
358
+ return null;
359
+ }
360
+ }
361
+ function idempotencyBackendFor(store) {
362
+ if (!idempotencyBackend) {
363
+ idempotencyBackend = createIdempotencyBackend(store);
364
+ }
365
+ return idempotencyBackend;
366
+ }
367
+ app.use("/v1/*", async (c, next) => {
368
+ if (!IDEMPOTENT_METHODS.has(c.req.method))
369
+ return next();
370
+ const key = c.req.header("idempotency-key");
371
+ if (key === undefined)
372
+ return next();
373
+ try {
374
+ assertIdempotencyKeyValid(key);
375
+ const store = await getStore();
376
+ const backend = idempotencyBackendFor(store);
377
+ // Scope includes the caller's credential fingerprint. The API is
378
+ // single-operator today (one bearer token), which would make this a
379
+ // no-op — but if per-operator tokens ever land, a shared key+body must
380
+ // NOT replay operator A's stored response to operator B (cross-tenant
381
+ // disclosure) or silently drop B's write (adversarial finding m3).
382
+ const authHeader = c.req.header("authorization") ?? "";
383
+ const operatorFp = createHash("sha256")
384
+ .update(authHeader)
385
+ .digest("hex")
386
+ .slice(0, 16);
387
+ const scopeKey = `${c.req.method} ${c.req.path} op:${operatorFp}`;
388
+ // Clone the raw request so the route handler's body read is untouched.
389
+ const rawBody = await c.req.raw.clone().text();
390
+ const fingerprint = fingerprintIdempotentRequest(scopeKey, rawBody);
391
+ const existing = await backend.get(scopeKey, key);
392
+ if (existing) {
393
+ if (existing.fingerprint !== fingerprint) {
394
+ throw idempotencyConflictError(scopeKey);
395
+ }
396
+ return c.newResponse(existing.body, existing.status, {
397
+ "content-type": existing.contentType,
398
+ "Idempotency-Replayed": "true",
399
+ });
400
+ }
401
+ await next();
402
+ const res = c.res;
403
+ if (res && res.status < 500) {
404
+ const responseBody = await res.clone().text();
405
+ const replayBody = idempotencyReplayBody(c.req.path, responseBody);
406
+ if (replayBody !== null) {
407
+ await backend.put({
408
+ scopeKey,
409
+ key,
410
+ fingerprint,
411
+ status: res.status,
412
+ contentType: res.headers.get("content-type") ?? "application/json",
413
+ body: replayBody,
414
+ });
415
+ }
416
+ }
417
+ return;
418
+ }
419
+ catch (e) {
420
+ return handleError(c, e);
421
+ }
422
+ });
423
+ /**
424
+ * Public capability-authorized customer setup.
425
+ * The random single-use token is the authority; operator bearer credentials are
426
+ * never exposed to the browser.
427
+ *
428
+ * Flow: reserve (durable lease + stable attempt id) → provision (runtime +
429
+ * management with deterministic ids from attempt lineage) → commit (consume once)
430
+ * | release + exact-id compensate. After process death past runtime insert,
431
+ * lease expiry allows the same capability to re-reserve the same attempt id,
432
+ * reconcile the existing provider/connection row, and complete commit.
433
+ * Replay after successful commit remains hard-failed.
434
+ */
435
+ app.post("/setup/:kind", async (c) => {
436
+ const kindParam = c.req.param("kind");
437
+ if (kindParam !== "sso" && kindParam !== "scim") {
438
+ return c.json({ error: { code: "SETUP_KIND", message: "Unknown setup kind" } }, 404);
439
+ }
440
+ const kind = kindParam;
441
+ let body;
442
+ try {
443
+ body = await c.req.json();
444
+ }
445
+ catch {
446
+ return c.json({ error: { code: "SETUP_INPUT", message: "JSON body required" } }, 400);
447
+ }
448
+ if (!body.token || !body.provider) {
449
+ return c.json({ error: { code: "SETUP_INPUT", message: "token and provider are required" } }, 400);
450
+ }
451
+ if (kind === "sso" &&
452
+ body.protocol !== "saml" &&
453
+ (!body.issuer || !body.clientId || !body.clientSecret)) {
454
+ return c.json({
455
+ error: {
456
+ code: "SETUP_INPUT",
457
+ message: "OIDC issuer, clientId, and clientSecret are required",
458
+ },
459
+ }, 400);
460
+ }
461
+ if (kind === "sso" &&
462
+ body.protocol === "saml" &&
463
+ (!body.issuer || !body.samlEntryPoint || !body.samlCertificate)) {
464
+ return c.json({
465
+ error: {
466
+ code: "SETUP_INPUT",
467
+ message: "SAML issuer, entry point, and X.509 signing certificate are required",
468
+ },
469
+ }, 400);
470
+ }
471
+ const store = await storeForRequest();
472
+ const token = body.token;
473
+ const organizationId = body.organizationId;
474
+ let reservationId;
475
+ let provisionedConnectionId;
476
+ /** True when this request inserted a new connection (not a crash-recovery reuse). */
477
+ let provisionedIsNew = false;
478
+ try {
479
+ const reserved = await reserveSetupLink(store, {
480
+ token,
481
+ kind,
482
+ organizationId,
483
+ actor: "customer-setup",
484
+ });
485
+ reservationId = reserved.reservationId;
486
+ // Attempt id is stable across re-reserves of the same capability digest.
487
+ const setupAttemptId = reserved.reservationId;
488
+ const beforeIds = new Set((kind === "sso"
489
+ ? store.snapshot.identityConnections
490
+ : store.snapshot.directoryConnections).map((c) => c.id));
491
+ const connection = kind === "sso"
492
+ ? await createSsoConnectionReal(store, {
493
+ organizationId: reserved.capability.organizationId,
494
+ provider: body.provider,
495
+ protocol: body.protocol === "saml" ? "saml" : "oidc",
496
+ issuer: body.issuer,
497
+ domain: body.domain,
498
+ clientId: body.clientId,
499
+ clientSecret: body.clientSecret,
500
+ samlEntryPoint: body.samlEntryPoint,
501
+ samlCertificate: body.samlCertificate,
502
+ actor: "customer-setup",
503
+ setupAttemptId,
504
+ })
505
+ : await createScimConnectionReal(store, {
506
+ organizationId: reserved.capability.organizationId,
507
+ provider: body.provider,
508
+ actor: "customer-setup",
509
+ setupAttemptId,
510
+ });
511
+ provisionedConnectionId = connection.id;
512
+ provisionedIsNew = !beforeIds.has(connection.id);
513
+ await commitSetupLink(store, {
514
+ token,
515
+ kind,
516
+ organizationId: reserved.capability.organizationId,
517
+ reservationId,
518
+ actor: "customer-setup",
519
+ });
520
+ await store.ready();
521
+ if (kind === "scim") {
522
+ const scimConn = connection;
523
+ const bearerTokenOnce = typeof scimConn.bearerTokenOnce === "string" ? scimConn.bearerTokenOnce : undefined;
524
+ const publicConn = { ...connection };
525
+ delete publicConn.bearerTokenOnce;
526
+ const absoluteEndpoint = absolutePublicUrl(typeof scimConn.endpoint === "string" ? scimConn.endpoint : "/api/auth/scim/v2");
527
+ const responseBody = {
528
+ ok: true,
529
+ kind,
530
+ connection: { ...publicConn, endpoint: absoluteEndpoint },
531
+ };
532
+ // One-time handoff only — never re-fetched or written to store/audit.
533
+ if (bearerTokenOnce && bearerTokenOnce.length > 0) {
534
+ responseBody.scimHandoff = {
535
+ bearerToken: bearerTokenOnce,
536
+ endpoint: absoluteEndpoint,
537
+ retrieveAgain: false,
538
+ warning: "Save and copy this SCIM bearer token and endpoint now. Clearance cannot show the token again.",
539
+ };
540
+ }
541
+ return c.json(responseBody, 201);
542
+ }
543
+ return c.json({ ok: true, kind, connection }, 201);
544
+ }
545
+ catch (error) {
546
+ // Compensate only a connection this request newly created. Snapshot-diff
547
+ // cleanup can delete an unrelated connection created concurrently for the
548
+ // same organization. Never delete a row recovered from an earlier attempt
549
+ // of the same setup capability (deterministic reuse) — leave it for the
550
+ // next retry after release. Keep the reservation held until cleanup
551
+ // completes so this capability cannot be re-reserved into the window.
552
+ if (provisionedConnectionId && provisionedIsNew) {
553
+ await compensateSetupConnection(store, {
554
+ kind,
555
+ connectionId: provisionedConnectionId,
556
+ }).catch(() => undefined);
557
+ }
558
+ if (reservationId) {
559
+ await releaseSetupLink(store, {
560
+ token,
561
+ kind,
562
+ reservationId,
563
+ actor: "customer-setup",
564
+ }).catch(() => undefined);
565
+ }
566
+ return handleError(c, error);
567
+ }
568
+ });
569
+ function absolutePublicUrl(pathOrUrl) {
570
+ if (/^https?:\/\//i.test(pathOrUrl))
571
+ return pathOrUrl;
572
+ const base = (process.env.CLEARANCE_BASE_URL ??
573
+ process.env.CLEARANCE_AUTH_URL ??
574
+ "http://localhost:3300").replace(/\/$/, "");
575
+ const path = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
576
+ return `${base}${path}`;
577
+ }
578
+ /**
579
+ * Remove the exact management + runtime connection returned to this setup
580
+ * attempt when a later commit step fails. Best-effort runtime cleanup is
581
+ * skipped when DATABASE_URL / bridge is unavailable.
582
+ */
583
+ async function compensateSetupConnection(store, opts) {
584
+ if (opts.kind === "sso") {
585
+ await store.mutateDurable((data) => {
586
+ data.identityConnections = data.identityConnections.filter((connection) => connection.id !== opts.connectionId);
587
+ });
588
+ await store.ready();
589
+ await deleteSsoProviderById(opts.connectionId).catch(() => undefined);
590
+ return;
591
+ }
592
+ await store.mutateDurable((data) => {
593
+ data.directoryConnections = data.directoryConnections.filter((connection) => connection.id !== opts.connectionId);
594
+ });
595
+ await store.ready();
596
+ await deleteScimProviderById(opts.connectionId).catch(() => undefined);
597
+ }
598
+ // --- Bootstrapping (no project scope required) ---
599
+ // Liveness is process-only: dependency failures must never create a restart
600
+ // loop. Readiness checks the durable store and goes false while SIGTERM drains.
601
+ app.get("/livez", (c) => c.json({ ok: true, service: "clearance-api", state: "live" }));
602
+ app.get("/readyz", async (c) => {
603
+ if (draining) {
604
+ return c.json({ ok: false, service: "clearance-api", state: "draining" }, 503);
605
+ }
606
+ try {
607
+ const store = await storeForRequest();
608
+ await store.ready();
609
+ return c.json({
610
+ ok: true,
611
+ service: "clearance-api",
612
+ state: "ready",
613
+ storeBackend: store.backend,
614
+ });
615
+ }
616
+ catch {
617
+ return c.json({ ok: false, service: "clearance-api", state: "dependency_unavailable" }, 503);
618
+ }
619
+ });
620
+ app.get("/metrics", (c) => {
621
+ const lines = [
622
+ "# HELP clearance_http_requests_total Total HTTP requests handled.",
623
+ "# TYPE clearance_http_requests_total counter",
624
+ ];
625
+ for (const [key, count] of [...requestCounts.entries()].sort()) {
626
+ const [method, status] = key.split("|");
627
+ lines.push(`clearance_http_requests_total{method="${method}",status="${status}"} ${count}`);
628
+ }
629
+ lines.push("# HELP clearance_http_request_duration_seconds_sum Cumulative HTTP request duration.", "# TYPE clearance_http_request_duration_seconds_sum counter", `clearance_http_request_duration_seconds_sum ${requestDurationSeconds}`, "# HELP clearance_http_requests_in_flight Current HTTP requests in flight.", "# TYPE clearance_http_requests_in_flight gauge", `clearance_http_requests_in_flight ${inFlightRequests}`, "# HELP clearance_process_uptime_seconds Process uptime.", "# TYPE clearance_process_uptime_seconds gauge", `clearance_process_uptime_seconds ${(Date.now() - processStartedAt) / 1000}`, "");
630
+ return c.body(lines.join("\n"), 200, {
631
+ "content-type": "text/plain; version=0.0.4; charset=utf-8",
632
+ });
633
+ });
634
+ app.get("/health", async (c) => {
635
+ return c.json({
636
+ ok: true,
637
+ service: "clearance-api",
638
+ version: "0.1.4",
639
+ });
640
+ });
641
+ /**
642
+ * Safe operator-session descriptor for the CLI. The bearer credential is
643
+ * verified by requireOperator and is intentionally never reflected here.
644
+ */
645
+ app.get("/v1/whoami", async (c) => {
646
+ try {
647
+ const store = await storeForRequest();
648
+ const scope = principalScope(store);
649
+ return c.json({
650
+ operator: { id: "operator", type: "operator", authenticated: true },
651
+ projectId: scope.projectId,
652
+ environmentId: scope.environmentId,
653
+ storeBackend: store.backend,
654
+ });
655
+ }
656
+ catch (error) {
657
+ return handleError(c, error);
658
+ }
659
+ });
660
+ app.get("/v1/doctor", async (c) => {
661
+ const store = await storeForRequest();
662
+ return c.json(await runDoctor(store));
663
+ });
664
+ app.post("/v1/init", async (c) => {
665
+ const store = await storeForRequest();
666
+ const body = await c.req.json().catch(() => ({}));
667
+ const result = initProject(store, {
668
+ name: body.name ?? "clearance-app",
669
+ environment: body.environment,
670
+ source: "api",
671
+ });
672
+ await store.ready();
673
+ return c.json(result);
674
+ });
675
+ // --- Resource routes (principal-derived scope enforced) ---
676
+ app.get("/v1/overview", async (c) => {
677
+ try {
678
+ const store = await storeForRequest();
679
+ const scope = scopeForRequest(store, c);
680
+ return c.json(overviewStats(store, scope));
681
+ }
682
+ catch (e) {
683
+ return handleError(c, e);
684
+ }
685
+ });
686
+ // --- Projects ---
687
+ app.get("/v1/projects", async (c) => {
688
+ try {
689
+ const store = await storeForRequest();
690
+ const scope = scopeForRequest(store, c);
691
+ return c.json({ projects: listProjects(store).filter((project) => project.id === scope.projectId), scope });
692
+ }
693
+ catch (e) {
694
+ return handleError(c, e);
695
+ }
696
+ });
697
+ app.get("/v1/projects/current", async (c) => {
698
+ try {
699
+ const store = await storeForRequest();
700
+ const scope = scopeForRequest(store, c);
701
+ const project = listProjects(store).find((candidate) => candidate.id === scope.projectId);
702
+ if (!project)
703
+ throw new ClearanceError({ code: "PROJECT_NOT_FOUND", message: "Project not found.", stage: "project.inspect", status: 404 });
704
+ return c.json({ project, overview: overviewStats(store, scope), scope });
705
+ }
706
+ catch (e) {
707
+ return handleError(c, e);
708
+ }
709
+ });
710
+ app.get("/v1/projects/:id", async (c) => {
711
+ try {
712
+ const store = await storeForRequest();
713
+ const scope = scopeForRequest(store, c);
714
+ const project = listProjects(store).find((candidate) => candidate.id === c.req.param("id") && candidate.id === scope.projectId);
715
+ if (!project)
716
+ throw new ClearanceError({ code: "PROJECT_NOT_FOUND", message: "Project not found.", stage: "project.inspect", status: 404 });
717
+ return c.json({ project, overview: overviewStats(store, scope), scope });
718
+ }
719
+ catch (e) {
720
+ return handleError(c, e);
721
+ }
722
+ });
723
+ app.post("/v1/projects", async (c) => {
724
+ try {
725
+ const store = await storeForRequest();
726
+ const body = await c.req.json();
727
+ if (body.dryRun === true) {
728
+ return c.json({ dryRun: true, project: planProjectCreate({ name: body.name }, store.snapshot.projects) });
729
+ }
730
+ const project = createProject(store, { name: body.name, actor: "api", source: "api" });
731
+ await store.ready();
732
+ return c.json({ project }, 201);
733
+ }
734
+ catch (e) {
735
+ return handleError(c, e);
736
+ }
737
+ });
738
+ // --- Environments ---
739
+ app.get("/v1/environments", async (c) => {
740
+ try {
741
+ const store = await storeForRequest();
742
+ const scope = scopeForRequest(store, c);
743
+ return c.json({
744
+ environments: listEnvironments(store, { scope }),
745
+ scope,
746
+ });
747
+ }
748
+ catch (e) {
749
+ return handleError(c, e);
750
+ }
751
+ });
752
+ app.get("/v1/environments/current", async (c) => {
753
+ try {
754
+ const store = await storeForRequest();
755
+ const scope = scopeForRequest(store, c);
756
+ return c.json(inspectEnvironment(store, undefined, { scope }));
757
+ }
758
+ catch (e) {
759
+ return handleError(c, e);
760
+ }
761
+ });
762
+ app.post("/v1/environments", async (c) => {
763
+ try {
764
+ const store = await storeForRequest();
765
+ const scope = scopeForRequest(store, c);
766
+ const body = await c.req.json();
767
+ const projectId = body.projectId ?? scope.projectId;
768
+ if (projectId !== scope.projectId)
769
+ throw new ClearanceError({ code: "SCOPE_MISMATCH", message: "Environment project is outside the operator scope.", stage: "env.create", status: 404 });
770
+ if (body.dryRun === true) {
771
+ return c.json({ dryRun: true, environment: planEnvironmentCreate(store, { projectId, name: body.name, kind: body.kind }), scope });
772
+ }
773
+ const environment = createEnvironment(store, { projectId, name: body.name, kind: body.kind, actor: "api" });
774
+ await store.ready();
775
+ return c.json({ environment, scope }, 201);
776
+ }
777
+ catch (e) {
778
+ return handleError(c, e);
779
+ }
780
+ });
781
+ app.get("/v1/environments/:id", async (c) => {
782
+ try {
783
+ const store = await storeForRequest();
784
+ const scope = scopeForRequest(store, c);
785
+ const result = inspectEnvironment(store, c.req.param("id"), { scope });
786
+ return c.json(result);
787
+ }
788
+ catch (e) {
789
+ return handleError(c, e);
790
+ }
791
+ });
792
+ /**
793
+ * Plan/apply environment promotion. Defaults to dry-run unless confirm=true.
794
+ * Mutating apply is blocked when no Deployment resource exists (structured blockers).
795
+ * Body: { to: string, from?: string, dryRun?: boolean, confirm?: boolean }
796
+ */
797
+ app.post("/v1/environments/promote", async (c) => {
798
+ try {
799
+ const store = await storeForRequest();
800
+ const scope = scopeForRequest(store, c);
801
+ const body = await c.req.json().catch(() => ({}));
802
+ const to = body && typeof body === "object" && typeof body.to === "string"
803
+ ? body.to
804
+ : "";
805
+ const from = body &&
806
+ typeof body === "object" &&
807
+ typeof body.from === "string"
808
+ ? body.from
809
+ : undefined;
810
+ const dryRun = body && typeof body === "object" && "dryRun" in body
811
+ ? body.dryRun
812
+ : undefined;
813
+ const confirm = body && typeof body === "object" && "confirm" in body
814
+ ? body.confirm
815
+ : undefined;
816
+ if (dryRun !== undefined && typeof dryRun !== "boolean") {
817
+ throw new ClearanceError({
818
+ code: "ENV_PROMOTE_INPUT_INVALID",
819
+ message: "dryRun must be a JSON boolean",
820
+ stage: "env.promote",
821
+ status: 400,
822
+ });
823
+ }
824
+ if (confirm !== undefined && typeof confirm !== "boolean") {
825
+ throw new ClearanceError({
826
+ code: "ENV_PROMOTE_INPUT_INVALID",
827
+ message: "confirm must be a JSON boolean",
828
+ stage: "env.promote",
829
+ status: 400,
830
+ });
831
+ }
832
+ const result = promoteEnvironment(store, {
833
+ to,
834
+ ...(from ? { from } : {}),
835
+ ...(dryRun !== undefined ? { dryRun } : {}),
836
+ ...(confirm !== undefined ? { confirm } : {}),
837
+ scope,
838
+ actor: "api",
839
+ source: "api",
840
+ });
841
+ if (!result.dryRun) {
842
+ await store.ready();
843
+ }
844
+ return c.json(result);
845
+ }
846
+ catch (e) {
847
+ return handleError(c, e);
848
+ }
849
+ });
850
+ /**
851
+ * List users. Without limit/cursor this is the legacy unpaginated contract.
852
+ * With ?limit= and/or ?cursor= it is keyset-paginated (createdAt+id asc,
853
+ * opaque fail-closed cursor) and the response carries nextCursor.
854
+ */
855
+ app.get("/v1/users", async (c) => {
856
+ try {
857
+ const store = await storeForRequest();
858
+ const scope = scopeForRequest(store, c);
859
+ const limitRaw = c.req.query("limit");
860
+ const cursor = c.req.query("cursor");
861
+ if (limitRaw !== undefined || cursor !== undefined) {
862
+ const page = listUsersPage(store, {
863
+ scope,
864
+ ...(limitRaw !== undefined ? { limit: Number(limitRaw) } : {}),
865
+ ...(cursor !== undefined ? { cursor } : {}),
866
+ });
867
+ return c.json({ users: page.users, nextCursor: page.nextCursor, scope });
868
+ }
869
+ const users = listUsers(store, { scope });
870
+ return c.json({ users, scope });
871
+ }
872
+ catch (e) {
873
+ return handleError(c, e);
874
+ }
875
+ });
876
+ app.get("/v1/users/:id", async (c) => {
877
+ try {
878
+ const store = await storeForRequest();
879
+ const scope = scopeForRequest(store, c);
880
+ // Cross-scope ids fail closed as USER_NOT_FOUND
881
+ const user = inspectUser(store, c.req.param("id"), scope);
882
+ return c.json({ user, scope });
883
+ }
884
+ catch (e) {
885
+ return handleError(c, e);
886
+ }
887
+ });
888
+ app.post("/v1/users", async (c) => {
889
+ try {
890
+ const store = await storeForRequest();
891
+ const scope = scopeForRequest(store, c);
892
+ const body = await c.req.json();
893
+ if (body.dryRun === true) {
894
+ if (typeof body.email !== "string" || !body.email.trim()) {
895
+ throw new ClearanceError({ code: "USER_EMAIL_REQUIRED", message: "Email is required.", stage: "users.create", status: 400 });
896
+ }
897
+ if (typeof body.name !== "string" || !body.name.trim()) {
898
+ throw new ClearanceError({ code: "USER_NAME_REQUIRED", message: "Name is required.", stage: "users.create", status: 400 });
899
+ }
900
+ const email = body.email.trim().toLowerCase();
901
+ if (listUsers(store, { scope }).some((user) => user.email.toLowerCase() === email && user.status !== "deleted")) {
902
+ throw new ClearanceError({ code: "USER_EXISTS", message: `User ${body.email} already exists`, stage: "users.create", status: 409 });
903
+ }
904
+ return c.json({ dryRun: true, email, name: body.name.trim(), scope });
905
+ }
906
+ const generatedPassword = body.password
907
+ ? undefined
908
+ : `Tmp!${randomBytes(18).toString("base64url")}aA1`;
909
+ const user = process.env.DATABASE_URL
910
+ ? await (async () => {
911
+ await ensureAuthMigrated();
912
+ return createUserInAuth({
913
+ email: body.email,
914
+ name: body.name,
915
+ password: body.password ?? generatedPassword,
916
+ managementStore: store,
917
+ });
918
+ })()
919
+ : createUser(store, {
920
+ email: body.email,
921
+ name: body.name,
922
+ projectId: scope.projectId,
923
+ environmentId: scope.environmentId,
924
+ source: "api",
925
+ });
926
+ await store.ready();
927
+ return c.json({ user, ...(generatedPassword ? { temporaryPassword: generatedPassword } : {}) }, 201);
928
+ }
929
+ catch (e) {
930
+ return handleError(c, e);
931
+ }
932
+ });
933
+ app.patch("/v1/users/:id", async (c) => {
934
+ try {
935
+ const store = await storeForRequest();
936
+ const scope = scopeForRequest(store, c);
937
+ const body = await c.req.json().catch(() => ({}));
938
+ // Fail closed: invalid status is never silently ignored when present.
939
+ const status = parseUserStatusInput("status" in body ? body.status : undefined, "users.update");
940
+ if (body.dryRun === true) {
941
+ inspectUser(store, c.req.param("id"), scope);
942
+ return c.json({ dryRun: true, id: c.req.param("id"), name: body.name, email: body.email, status, scope });
943
+ }
944
+ const user = process.env.DATABASE_URL
945
+ ? await (async () => {
946
+ await ensureAuthMigrated();
947
+ return updateUserInAuth(store, c.req.param("id"), {
948
+ name: body.name,
949
+ email: body.email,
950
+ status,
951
+ actor: "api",
952
+ source: "api",
953
+ scope,
954
+ });
955
+ })()
956
+ : updateUser(store, c.req.param("id"), {
957
+ name: body.name,
958
+ email: body.email,
959
+ status,
960
+ actor: "api",
961
+ source: "api",
962
+ scope,
963
+ });
964
+ await store.ready();
965
+ return c.json({ user, scope });
966
+ }
967
+ catch (e) {
968
+ return handleError(c, e);
969
+ }
970
+ });
971
+ app.post("/v1/users/:id/disable", async (c) => {
972
+ try {
973
+ const store = await storeForRequest();
974
+ const scope = scopeForRequest(store, c);
975
+ const body = await c.req.json().catch(() => ({}));
976
+ if (body.dryRun === true) {
977
+ return c.json({ dryRun: true, user: inspectUser(store, c.req.param("id"), scope), scope });
978
+ }
979
+ const user = process.env.DATABASE_URL
980
+ ? await (async () => {
981
+ await ensureAuthMigrated();
982
+ return disableUserInAuth(store, c.req.param("id"), {
983
+ actor: "api",
984
+ source: "api",
985
+ scope,
986
+ });
987
+ })()
988
+ : disableUser(store, c.req.param("id"), {
989
+ actor: "api",
990
+ source: "api",
991
+ scope,
992
+ });
993
+ await store.ready();
994
+ return c.json({ user, scope });
995
+ }
996
+ catch (e) {
997
+ return handleError(c, e);
998
+ }
999
+ });
1000
+ app.delete("/v1/users/:id", async (c) => {
1001
+ try {
1002
+ const store = await storeForRequest();
1003
+ const scope = scopeForRequest(store, c);
1004
+ const user = process.env.DATABASE_URL
1005
+ ? await (async () => {
1006
+ await ensureAuthMigrated();
1007
+ return deleteUserInAuth(store, c.req.param("id"), {
1008
+ actor: "api",
1009
+ source: "api",
1010
+ scope,
1011
+ });
1012
+ })()
1013
+ : deleteUser(store, c.req.param("id"), {
1014
+ actor: "api",
1015
+ source: "api",
1016
+ scope,
1017
+ });
1018
+ await store.ready();
1019
+ return c.json({ user, scope });
1020
+ }
1021
+ catch (e) {
1022
+ return handleError(c, e);
1023
+ }
1024
+ });
1025
+ /**
1026
+ * Export scoped users (bounded, redacted, deterministic).
1027
+ * File paths are CLI-only; API returns the envelope in the response body.
1028
+ * Arbitrary filesystem output paths are never accepted.
1029
+ */
1030
+ app.post("/v1/users/export", async (c) => {
1031
+ try {
1032
+ const store = await storeForRequest();
1033
+ const scope = scopeForRequest(store, c);
1034
+ const body = await c.req.json().catch(() => ({}));
1035
+ if (body &&
1036
+ typeof body === "object" &&
1037
+ ("outputPath" in body || "path" in body || "output" in body)) {
1038
+ throw new ClearanceError({
1039
+ code: "USERS_EXPORT_PATH_FORBIDDEN",
1040
+ message: "API user export does not accept filesystem output paths; the envelope is returned in the response body",
1041
+ stage: "users.export",
1042
+ status: 400,
1043
+ remediation: "Omit outputPath/path/output from the request, or use the CLI: clearance users export --output <path>",
1044
+ });
1045
+ }
1046
+ const limit = body && typeof body === "object" && "limit" in body
1047
+ ? Number(body.limit)
1048
+ : undefined;
1049
+ const format = body &&
1050
+ typeof body === "object" &&
1051
+ typeof body.format === "string"
1052
+ ? body.format
1053
+ : "json";
1054
+ const status = body &&
1055
+ typeof body === "object" &&
1056
+ typeof body.status === "string"
1057
+ ? body.status
1058
+ : undefined;
1059
+ const envelope = exportUsers(store, {
1060
+ scope,
1061
+ format,
1062
+ limit,
1063
+ ...(status ? { status } : {}),
1064
+ actor: "api",
1065
+ source: "api",
1066
+ });
1067
+ await store.ready();
1068
+ return c.json(envelope);
1069
+ }
1070
+ catch (e) {
1071
+ return handleError(c, e);
1072
+ }
1073
+ });
1074
+ /**
1075
+ * List organizations. Legacy unpaginated without params; keyset-paginated
1076
+ * (createdAt+id asc) with ?limit=/?cursor=, returning nextCursor.
1077
+ */
1078
+ app.get("/v1/organizations", async (c) => {
1079
+ try {
1080
+ const store = await storeForRequest();
1081
+ const scope = scopeForRequest(store, c);
1082
+ const limitRaw = c.req.query("limit");
1083
+ const cursor = c.req.query("cursor");
1084
+ if (limitRaw !== undefined || cursor !== undefined) {
1085
+ const page = listOrganizationsPage(store, {
1086
+ scope,
1087
+ ...(limitRaw !== undefined ? { limit: Number(limitRaw) } : {}),
1088
+ ...(cursor !== undefined ? { cursor } : {}),
1089
+ });
1090
+ return c.json({
1091
+ organizations: page.organizations,
1092
+ nextCursor: page.nextCursor,
1093
+ scope,
1094
+ });
1095
+ }
1096
+ return c.json({
1097
+ organizations: listOrganizations(store, { scope }),
1098
+ scope,
1099
+ });
1100
+ }
1101
+ catch (e) {
1102
+ return handleError(c, e);
1103
+ }
1104
+ });
1105
+ app.get("/v1/organizations/:id", async (c) => {
1106
+ try {
1107
+ const store = await storeForRequest();
1108
+ const scope = scopeForRequest(store, c);
1109
+ const organization = inspectOrganization(store, c.req.param("id"), scope);
1110
+ return c.json({ organization, scope });
1111
+ }
1112
+ catch (e) {
1113
+ return handleError(c, e);
1114
+ }
1115
+ });
1116
+ app.post("/v1/organizations", async (c) => {
1117
+ try {
1118
+ const store = await storeForRequest();
1119
+ const scope = scopeForRequest(store, c);
1120
+ const body = await c.req.json();
1121
+ let organization;
1122
+ if (process.env.DATABASE_URL) {
1123
+ const ownerUserId = body.ownerUserId ??
1124
+ store.snapshot.principals.find((p) => p.status === "active")?.id;
1125
+ if (!ownerUserId) {
1126
+ throw new Error("Create a user first or provide ownerUserId");
1127
+ }
1128
+ await ensureAuthMigrated();
1129
+ const runtimeOrg = await createOrgInAuth({
1130
+ name: body.name,
1131
+ slug: body.slug,
1132
+ userId: ownerUserId,
1133
+ });
1134
+ organization = await syncRuntimeOrganizationToManagementDurable(store, runtimeOrg, ownerUserId, { actor: "api", role: "owner" });
1135
+ }
1136
+ else {
1137
+ organization = createOrganization(store, {
1138
+ name: body.name,
1139
+ slug: body.slug,
1140
+ projectId: scope.projectId,
1141
+ environmentId: scope.environmentId,
1142
+ source: "api",
1143
+ });
1144
+ }
1145
+ await store.ready();
1146
+ return c.json({ organization }, 201);
1147
+ }
1148
+ catch (e) {
1149
+ return handleError(c, e);
1150
+ }
1151
+ });
1152
+ app.patch("/v1/organizations/:id", async (c) => {
1153
+ try {
1154
+ const store = await storeForRequest();
1155
+ const scope = scopeForRequest(store, c);
1156
+ const body = await c.req.json().catch(() => ({}));
1157
+ if (body == null || typeof body !== "object") {
1158
+ throw new ClearanceError({
1159
+ code: "ORG_UPDATE_EMPTY",
1160
+ message: "At least one of name or slug is required",
1161
+ stage: "orgs.update",
1162
+ status: 400,
1163
+ });
1164
+ }
1165
+ const unknownFields = Object.keys(body).filter((key) => key !== "name" && key !== "slug" && key !== "status" && key !== "dryRun");
1166
+ if (unknownFields.length > 0) {
1167
+ throw new ClearanceError({
1168
+ code: "ORG_UPDATE_FIELD_INVALID",
1169
+ message: `Unsupported organization update field: ${unknownFields[0]}`,
1170
+ stage: "orgs.update",
1171
+ status: 400,
1172
+ remediation: "Only name and slug are mutable; use the archive endpoint for status",
1173
+ });
1174
+ }
1175
+ // Reject non-mutable fields explicitly (status goes through archive)
1176
+ if ("status" in body) {
1177
+ throw new ClearanceError({
1178
+ code: "ORG_STATUS_IMMUTABLE",
1179
+ message: "Organization status cannot be set via update; use archive",
1180
+ stage: "orgs.update",
1181
+ status: 400,
1182
+ remediation: "POST /v1/organizations/:id/archive with confirm=true",
1183
+ });
1184
+ }
1185
+ const name = body && typeof body === "object" && "name" in body
1186
+ ? body.name
1187
+ : undefined;
1188
+ const slug = body && typeof body === "object" && "slug" in body
1189
+ ? body.slug
1190
+ : undefined;
1191
+ if (name !== undefined && typeof name !== "string") {
1192
+ throw new ClearanceError({
1193
+ code: "ORG_NAME_REQUIRED",
1194
+ message: "Name must be a string",
1195
+ stage: "orgs.update",
1196
+ status: 400,
1197
+ });
1198
+ }
1199
+ if (slug !== undefined && typeof slug !== "string") {
1200
+ throw new ClearanceError({
1201
+ code: "ORG_SLUG_INVALID",
1202
+ message: "Slug must be a string",
1203
+ stage: "orgs.update",
1204
+ status: 400,
1205
+ });
1206
+ }
1207
+ if (body.dryRun === true) {
1208
+ inspectOrganization(store, c.req.param("id"), scope);
1209
+ return c.json({ dryRun: true, id: c.req.param("id"), name, slug, scope });
1210
+ }
1211
+ const organization = process.env.DATABASE_URL
1212
+ ? await (async () => {
1213
+ await ensureAuthMigrated();
1214
+ return updateOrganizationInAuth(store, c.req.param("id"), {
1215
+ ...(name !== undefined ? { name } : {}),
1216
+ ...(slug !== undefined ? { slug } : {}),
1217
+ actor: "api",
1218
+ source: "api",
1219
+ scope,
1220
+ });
1221
+ })()
1222
+ : updateOrganization(store, c.req.param("id"), {
1223
+ ...(name !== undefined ? { name } : {}),
1224
+ ...(slug !== undefined ? { slug } : {}),
1225
+ actor: "api",
1226
+ source: "api",
1227
+ scope,
1228
+ });
1229
+ await store.ready();
1230
+ return c.json({ organization, scope });
1231
+ }
1232
+ catch (e) {
1233
+ return handleError(c, e);
1234
+ }
1235
+ });
1236
+ /**
1237
+ * Archive organization. Defaults to dry-run unless confirm=true.
1238
+ * Body: { dryRun?: boolean, confirm?: boolean }
1239
+ * When DATABASE_URL is set, uses coordinated runtime+management archive.
1240
+ */
1241
+ app.post("/v1/organizations/:id/archive", async (c) => {
1242
+ try {
1243
+ const store = await storeForRequest();
1244
+ const scope = scopeForRequest(store, c);
1245
+ const body = await c.req.json().catch(() => ({}));
1246
+ const dryRun = body && typeof body === "object" && "dryRun" in body
1247
+ ? body.dryRun
1248
+ : undefined;
1249
+ const confirm = body && typeof body === "object" && "confirm" in body
1250
+ ? body.confirm
1251
+ : undefined;
1252
+ if (dryRun !== undefined && typeof dryRun !== "boolean") {
1253
+ throw new ClearanceError({
1254
+ code: "ORG_ARCHIVE_INPUT_INVALID",
1255
+ message: "dryRun must be a JSON boolean",
1256
+ stage: "orgs.archive",
1257
+ status: 400,
1258
+ });
1259
+ }
1260
+ if (confirm !== undefined && typeof confirm !== "boolean") {
1261
+ throw new ClearanceError({
1262
+ code: "ORG_ARCHIVE_INPUT_INVALID",
1263
+ message: "confirm must be a JSON boolean",
1264
+ stage: "orgs.archive",
1265
+ status: 400,
1266
+ });
1267
+ }
1268
+ const result = process.env.DATABASE_URL
1269
+ ? await archiveOrganizationInAuth(store, c.req.param("id"), {
1270
+ ...(dryRun !== undefined ? { dryRun } : {}),
1271
+ ...(confirm !== undefined ? { confirm } : {}),
1272
+ actor: "api",
1273
+ source: "api",
1274
+ scope,
1275
+ })
1276
+ : archiveOrganization(store, c.req.param("id"), {
1277
+ ...(dryRun !== undefined ? { dryRun } : {}),
1278
+ ...(confirm !== undefined ? { confirm } : {}),
1279
+ actor: "api",
1280
+ source: "api",
1281
+ scope,
1282
+ });
1283
+ if (!result.dryRun) {
1284
+ await store.ready();
1285
+ }
1286
+ return c.json({ ...result, scope });
1287
+ }
1288
+ catch (e) {
1289
+ return handleError(c, e);
1290
+ }
1291
+ });
1292
+ app.get("/v1/organizations/:id/members", async (c) => {
1293
+ try {
1294
+ const store = await storeForRequest();
1295
+ const scope = scopeForRequest(store, c);
1296
+ const members = listMembers(store, c.req.param("id"), { scope });
1297
+ return c.json({ members, scope });
1298
+ }
1299
+ catch (e) {
1300
+ return handleError(c, e);
1301
+ }
1302
+ });
1303
+ app.post("/v1/organizations/:id/members", async (c) => {
1304
+ try {
1305
+ const store = await storeForRequest();
1306
+ const scope = scopeForRequest(store, c);
1307
+ const body = await c.req.json().catch(() => ({}));
1308
+ if (body == null ||
1309
+ typeof body !== "object" ||
1310
+ typeof body.principalId !== "string" ||
1311
+ !body.principalId.trim()) {
1312
+ throw new ClearanceError({
1313
+ code: "MEMBER_PRINCIPAL_REQUIRED",
1314
+ message: "principalId is required",
1315
+ stage: "orgs.members.add",
1316
+ status: 400,
1317
+ remediation: "Pass principalId in the request body",
1318
+ });
1319
+ }
1320
+ const principalId = String(body.principalId).trim();
1321
+ const role = body.role !== undefined ? body.role : "member";
1322
+ if (body.dryRun === true) {
1323
+ inspectOrganization(store, c.req.param("id"), scope);
1324
+ inspectUser(store, principalId, scope);
1325
+ return c.json({ dryRun: true, organizationId: c.req.param("id"), principalId, role, scope });
1326
+ }
1327
+ const membership = process.env.DATABASE_URL
1328
+ ? await (async () => {
1329
+ await ensureAuthMigrated();
1330
+ return addMemberInAuth(store, {
1331
+ organizationId: c.req.param("id"),
1332
+ principalId,
1333
+ role,
1334
+ actor: "api",
1335
+ auditSource: "api",
1336
+ scope,
1337
+ });
1338
+ })()
1339
+ : addMember(store, {
1340
+ organizationId: c.req.param("id"),
1341
+ principalId,
1342
+ role,
1343
+ actor: "api",
1344
+ auditSource: "api",
1345
+ scope,
1346
+ });
1347
+ await store.ready();
1348
+ return c.json({ membership, scope }, 201);
1349
+ }
1350
+ catch (e) {
1351
+ return handleError(c, e);
1352
+ }
1353
+ });
1354
+ app.patch("/v1/organizations/:id/members/:memberId", async (c) => {
1355
+ try {
1356
+ const store = await storeForRequest();
1357
+ const scope = scopeForRequest(store, c);
1358
+ const orgId = c.req.param("id");
1359
+ const memberId = c.req.param("memberId");
1360
+ // Ensure org is in scope (cross-scope ids indistinguishable from missing)
1361
+ inspectOrganization(store, orgId, scope);
1362
+ const existing = inspectMembership(store, memberId, scope);
1363
+ if (existing.organizationId !== orgId) {
1364
+ // Treat as missing — do not leak cross-org membership existence
1365
+ throw new ClearanceError({
1366
+ code: "MEMBER_NOT_FOUND",
1367
+ message: "Membership not found",
1368
+ stage: "orgs.members.update",
1369
+ status: 404,
1370
+ });
1371
+ }
1372
+ const body = await c.req.json().catch(() => ({}));
1373
+ if (body == null || typeof body !== "object" || body.role === undefined) {
1374
+ throw new ClearanceError({
1375
+ code: "ROLE_REQUIRED",
1376
+ message: "Role is required",
1377
+ stage: "orgs.members.update",
1378
+ status: 400,
1379
+ });
1380
+ }
1381
+ if (body.dryRun === true) {
1382
+ return c.json({ dryRun: true, organizationId: orgId, membershipId: memberId, role: body.role, scope });
1383
+ }
1384
+ const membership = process.env.DATABASE_URL
1385
+ ? await (async () => {
1386
+ await ensureAuthMigrated();
1387
+ return updateMemberInAuth(store, memberId, {
1388
+ role: body.role,
1389
+ actor: "api",
1390
+ auditSource: "api",
1391
+ scope,
1392
+ });
1393
+ })()
1394
+ : updateMember(store, memberId, {
1395
+ role: body.role,
1396
+ actor: "api",
1397
+ auditSource: "api",
1398
+ scope,
1399
+ });
1400
+ await store.ready();
1401
+ return c.json({ membership, scope });
1402
+ }
1403
+ catch (e) {
1404
+ return handleError(c, e);
1405
+ }
1406
+ });
1407
+ app.delete("/v1/organizations/:id/members/:memberId", async (c) => {
1408
+ try {
1409
+ const store = await storeForRequest();
1410
+ const scope = scopeForRequest(store, c);
1411
+ const orgId = c.req.param("id");
1412
+ const memberId = c.req.param("memberId");
1413
+ inspectOrganization(store, orgId, scope);
1414
+ const existing = inspectMembership(store, memberId, scope);
1415
+ if (existing.organizationId !== orgId) {
1416
+ throw new ClearanceError({
1417
+ code: "MEMBER_NOT_FOUND",
1418
+ message: "Membership not found",
1419
+ stage: "orgs.members.remove",
1420
+ status: 404,
1421
+ });
1422
+ }
1423
+ const body = await c.req.json().catch(() => ({}));
1424
+ if (body.dryRun === true) {
1425
+ return c.json({ dryRun: true, organizationId: orgId, membershipId: memberId, membership: existing, scope });
1426
+ }
1427
+ const membership = process.env.DATABASE_URL
1428
+ ? await (async () => {
1429
+ await ensureAuthMigrated();
1430
+ return removeMemberInAuth(store, memberId, {
1431
+ actor: "api",
1432
+ auditSource: "api",
1433
+ scope,
1434
+ });
1435
+ })()
1436
+ : removeMember(store, memberId, {
1437
+ actor: "api",
1438
+ auditSource: "api",
1439
+ scope,
1440
+ });
1441
+ await store.ready();
1442
+ return c.json({ membership, scope });
1443
+ }
1444
+ catch (e) {
1445
+ return handleError(c, e);
1446
+ }
1447
+ });
1448
+ /**
1449
+ * List audit events, keyset-paginated (createdAt+id desc, newest first).
1450
+ * limit stays the page size (default 50, fail-closed validation matching the
1451
+ * CLI's EVENTS_LIST_OPTION_INVALID); nextCursor walks older history.
1452
+ */
1453
+ app.get("/v1/events", async (c) => {
1454
+ try {
1455
+ const store = await storeForRequest();
1456
+ const scope = scopeForRequest(store, c);
1457
+ const limitRaw = c.req.query("limit");
1458
+ const cursor = c.req.query("cursor");
1459
+ const page = listEventsPage(store, {
1460
+ scope,
1461
+ ...(limitRaw !== undefined ? { limit: Number(limitRaw) } : {}),
1462
+ ...(cursor !== undefined ? { cursor } : {}),
1463
+ });
1464
+ return c.json({ events: page.events, nextCursor: page.nextCursor, scope });
1465
+ }
1466
+ catch (e) {
1467
+ return handleError(c, e);
1468
+ }
1469
+ });
1470
+ app.get("/v1/events/:id", async (c) => {
1471
+ try {
1472
+ const store = await storeForRequest();
1473
+ const scope = scopeForRequest(store, c);
1474
+ const result = inspectEvent(store, c.req.param("id"), { scope });
1475
+ return c.json(result);
1476
+ }
1477
+ catch (e) {
1478
+ return handleError(c, e);
1479
+ }
1480
+ });
1481
+ /**
1482
+ * Export scoped audit events (bounded, redacted, deterministic).
1483
+ * File paths are CLI-only; API returns the envelope in the response body.
1484
+ */
1485
+ app.post("/v1/events/export", async (c) => {
1486
+ try {
1487
+ const store = await storeForRequest();
1488
+ const scope = scopeForRequest(store, c);
1489
+ const body = await c.req.json().catch(() => ({}));
1490
+ const limit = body && typeof body === "object" && "limit" in body
1491
+ ? Number(body.limit)
1492
+ : undefined;
1493
+ const format = body && typeof body === "object" && typeof body.format === "string"
1494
+ ? body.format
1495
+ : "json";
1496
+ const action = body && typeof body === "object" && typeof body.action === "string"
1497
+ ? body.action
1498
+ : undefined;
1499
+ const organizationId = body &&
1500
+ typeof body === "object" &&
1501
+ typeof body.organizationId === "string"
1502
+ ? body.organizationId
1503
+ : undefined;
1504
+ const before = body &&
1505
+ typeof body === "object" &&
1506
+ typeof body.before === "string"
1507
+ ? body.before
1508
+ : undefined;
1509
+ const envelope = exportEvents(store, {
1510
+ scope,
1511
+ format,
1512
+ limit,
1513
+ ...(action ? { action } : {}),
1514
+ ...(organizationId ? { organizationId } : {}),
1515
+ ...(before ? { before } : {}),
1516
+ actor: "api",
1517
+ source: "api",
1518
+ });
1519
+ await store.ready();
1520
+ return c.json(envelope);
1521
+ }
1522
+ catch (e) {
1523
+ return handleError(c, e);
1524
+ }
1525
+ });
1526
+ /**
1527
+ * Replay a SCIM diagnostic trace. Defaults to dry-run unless confirm=true.
1528
+ * Body: { id: string, dryRun?: boolean, confirm?: boolean }
1529
+ */
1530
+ app.post("/v1/events/replay", async (c) => {
1531
+ try {
1532
+ const store = await storeForRequest();
1533
+ const scope = scopeForRequest(store, c);
1534
+ const body = await c.req.json().catch(() => ({}));
1535
+ const id = body && typeof body === "object" && typeof body.id === "string"
1536
+ ? body.id
1537
+ : "";
1538
+ const bodyDryRun = body && typeof body === "object" && body.dryRun === true;
1539
+ const confirm = body && typeof body === "object" && body.confirm === true;
1540
+ // Safe default: dry-run unless confirm is explicit and dryRun is not forced
1541
+ const dryRun = bodyDryRun || !confirm;
1542
+ const result = replayDiagnosticTrace(store, id, {
1543
+ scope,
1544
+ dryRun,
1545
+ confirm: confirm && !bodyDryRun,
1546
+ actor: "api",
1547
+ source: "api",
1548
+ });
1549
+ if (!result.dryRun) {
1550
+ await store.ready();
1551
+ }
1552
+ return c.json(result);
1553
+ }
1554
+ catch (e) {
1555
+ return handleError(c, e);
1556
+ }
1557
+ });
1558
+ // --- Sessions (principal-derived scope; never expose tokens) ---
1559
+ /**
1560
+ * List sessions, keyset-paginated (createdAt+id desc, newest first). limit
1561
+ * keeps the shipped SESSION_LIMIT_INVALID validation as the page size;
1562
+ * nextCursor walks older sessions. Runtime (DATABASE_URL) and JSON paths
1563
+ * share the same documented ordering and opaque cursor format.
1564
+ */
1565
+ // --- API keys ---
1566
+ app.get("/v1/keys", async (c) => {
1567
+ try {
1568
+ const store = await storeForRequest();
1569
+ const scope = scopeForRequest(store, c);
1570
+ return c.json({ apiKeys: listApiKeys(store, { scope, includeRevoked: c.req.query("includeRevoked") === "true" }), scope });
1571
+ }
1572
+ catch (e) {
1573
+ return handleError(c, e);
1574
+ }
1575
+ });
1576
+ app.post("/v1/keys", async (c) => {
1577
+ try {
1578
+ const store = await storeForRequest();
1579
+ const scope = scopeForRequest(store, c);
1580
+ const body = await c.req.json();
1581
+ if (body.dryRun === true) {
1582
+ const name = validateApiKeyName(body.name, "keys.create");
1583
+ const scopes = normalizeAndValidateApiKeyScopes(body.scopes, "keys.create");
1584
+ return c.json({ dryRun: true, apiKey: { name, scopes }, secretGenerated: false, scope });
1585
+ }
1586
+ const result = await createApiKey(store, { name: body.name, scopes: body.scopes, scope, actor: "api", source: "api" });
1587
+ await store.ready();
1588
+ return c.json({ ...result, scope }, 201);
1589
+ }
1590
+ catch (e) {
1591
+ return handleError(c, e);
1592
+ }
1593
+ });
1594
+ app.post("/v1/keys/:id/rotate", async (c) => {
1595
+ try {
1596
+ const store = await storeForRequest();
1597
+ const scope = scopeForRequest(store, c);
1598
+ const body = await c.req.json().catch(() => ({}));
1599
+ if (body.dryRun === true) {
1600
+ const apiKey = inspectApiKey(store, c.req.param("id"), { scope });
1601
+ if (apiKey.status === "revoked")
1602
+ throw new ClearanceError({ code: "API_KEY_REVOKED", message: "Revoked API keys cannot be rotated", stage: "keys.rotate", status: 409 });
1603
+ return c.json({ dryRun: true, apiKey, secretGenerated: false, scope });
1604
+ }
1605
+ const result = await rotateApiKey(store, c.req.param("id"), { scope, actor: "api", source: "api" });
1606
+ await store.ready();
1607
+ return c.json({ ...result, scope });
1608
+ }
1609
+ catch (e) {
1610
+ return handleError(c, e);
1611
+ }
1612
+ });
1613
+ app.post("/v1/keys/:id/revoke", async (c) => {
1614
+ try {
1615
+ const store = await storeForRequest();
1616
+ const scope = scopeForRequest(store, c);
1617
+ const body = await c.req.json().catch(() => ({}));
1618
+ if (body.dryRun === true) {
1619
+ const apiKey = inspectApiKey(store, c.req.param("id"), { scope });
1620
+ return c.json({ dryRun: true, apiKey, wouldChange: apiKey.status === "active", scope });
1621
+ }
1622
+ const result = await revokeApiKey(store, c.req.param("id"), { scope, actor: "api", source: "api" });
1623
+ await store.ready();
1624
+ return c.json({ ...result, scope });
1625
+ }
1626
+ catch (e) {
1627
+ return handleError(c, e);
1628
+ }
1629
+ });
1630
+ app.get("/v1/sessions", async (c) => {
1631
+ try {
1632
+ const store = await storeForRequest();
1633
+ const scope = scopeForRequest(store, c);
1634
+ const limitRaw = c.req.query("limit");
1635
+ const cursor = c.req.query("cursor");
1636
+ const limit = Number(limitRaw ?? 100);
1637
+ const page = process.env.DATABASE_URL
1638
+ ? await listSessionsPageInAuth(store, {
1639
+ scope,
1640
+ limit,
1641
+ ...(cursor !== undefined ? { cursor } : {}),
1642
+ })
1643
+ : listSessionsPage(store, {
1644
+ scope,
1645
+ limit,
1646
+ ...(cursor !== undefined ? { cursor } : {}),
1647
+ });
1648
+ return c.json({
1649
+ sessions: page.sessions,
1650
+ nextCursor: page.nextCursor,
1651
+ scope,
1652
+ });
1653
+ }
1654
+ catch (e) {
1655
+ return handleError(c, e);
1656
+ }
1657
+ });
1658
+ app.post("/v1/sessions/:id/revoke", async (c) => {
1659
+ try {
1660
+ const store = await storeForRequest();
1661
+ const scope = scopeForRequest(store, c);
1662
+ const result = process.env.DATABASE_URL
1663
+ ? await (async () => {
1664
+ await ensureAuthMigrated();
1665
+ return revokeSessionInAuth(store, c.req.param("id"), {
1666
+ actor: "api",
1667
+ source: "api",
1668
+ scope,
1669
+ });
1670
+ })()
1671
+ : revokeSession(store, c.req.param("id"), {
1672
+ actor: "api",
1673
+ source: "api",
1674
+ scope,
1675
+ });
1676
+ await store.ready();
1677
+ return c.json({ ...result, scope });
1678
+ }
1679
+ catch (e) {
1680
+ return handleError(c, e);
1681
+ }
1682
+ });
1683
+ // --- Roles (principal-derived scope; client headers never authority) ---
1684
+ app.get("/v1/roles", async (c) => {
1685
+ try {
1686
+ const store = await storeForRequest();
1687
+ const scope = scopeForRequest(store, c);
1688
+ const roles = listRoles(store, { scope });
1689
+ return c.json({ roles, scope });
1690
+ }
1691
+ catch (e) {
1692
+ return handleError(c, e);
1693
+ }
1694
+ });
1695
+ app.post("/v1/roles/validate", async (c) => {
1696
+ try {
1697
+ const store = await storeForRequest();
1698
+ const scope = scopeForRequest(store, c);
1699
+ const body = await c.req.json().catch(() => ({}));
1700
+ const result = validateRole(store, {
1701
+ name: body.name,
1702
+ slug: body.slug,
1703
+ permissions: body.permissions,
1704
+ scope,
1705
+ });
1706
+ return c.json(result);
1707
+ }
1708
+ catch (e) {
1709
+ return handleError(c, e);
1710
+ }
1711
+ });
1712
+ app.post("/v1/roles", async (c) => {
1713
+ try {
1714
+ const store = await storeForRequest();
1715
+ const scope = scopeForRequest(store, c);
1716
+ const body = await c.req.json();
1717
+ if (body.dryRun === true) {
1718
+ return c.json({ dryRun: true, validation: validateRole(store, { name: body.name, slug: body.slug, permissions: body.permissions, scope }), scope });
1719
+ }
1720
+ const role = await createRole(store, {
1721
+ name: body.name,
1722
+ slug: body.slug,
1723
+ description: body.description,
1724
+ permissions: body.permissions,
1725
+ scope,
1726
+ actor: "api",
1727
+ source: "api",
1728
+ });
1729
+ await store.ready();
1730
+ return c.json({ role, scope }, 201);
1731
+ }
1732
+ catch (e) {
1733
+ return handleError(c, e);
1734
+ }
1735
+ });
1736
+ app.patch("/v1/roles/:id", async (c) => {
1737
+ try {
1738
+ const store = await storeForRequest();
1739
+ const scope = scopeForRequest(store, c);
1740
+ const body = await c.req.json().catch(() => ({}));
1741
+ if (body.dryRun === true) {
1742
+ return c.json({ dryRun: true, id: c.req.param("id"), validation: validateRole(store, { name: body.name, permissions: body.permissions, scope }), scope });
1743
+ }
1744
+ const role = await updateRole(store, c.req.param("id"), {
1745
+ name: body.name,
1746
+ description: body.description,
1747
+ permissions: body.permissions,
1748
+ scope,
1749
+ actor: "api",
1750
+ source: "api",
1751
+ });
1752
+ await store.ready();
1753
+ return c.json({ role, scope });
1754
+ }
1755
+ catch (e) {
1756
+ return handleError(c, e);
1757
+ }
1758
+ });
1759
+ app.get("/v1/settings", async (c) => {
1760
+ try {
1761
+ const store = await storeForRequest();
1762
+ const scope = scopeForRequest(store, c);
1763
+ return c.json({
1764
+ config: store.snapshot.meta.config,
1765
+ schemaVersion: store.snapshot.meta.schemaVersion,
1766
+ releaseVersion: store.snapshot.releaseVersion,
1767
+ resourceCounts: store.resourceCounts(),
1768
+ storeBackend: store.backend,
1769
+ scope,
1770
+ /** Principal scope is server-configured; headers are not authority. */
1771
+ tokenBoundary: "principal-derived-scope",
1772
+ telemetry: { remoteSinks: [], default: "disabled" },
1773
+ auth: { mode: "bearer-operator" },
1774
+ });
1775
+ }
1776
+ catch (e) {
1777
+ return handleError(c, e);
1778
+ }
1779
+ });
1780
+ app.get("/v1/config", async (c) => {
1781
+ try {
1782
+ const store = await storeForRequest();
1783
+ const scope = scopeForRequest(store, c);
1784
+ return c.json({ ...publicConfig(store.snapshot.meta.config, c.req.query("key")), scope });
1785
+ }
1786
+ catch (e) {
1787
+ return handleError(c, e);
1788
+ }
1789
+ });
1790
+ app.patch("/v1/config/:key", async (c) => {
1791
+ try {
1792
+ const store = await storeForRequest();
1793
+ const scope = scopeForRequest(store, c);
1794
+ const request = await c.req.json();
1795
+ if (!request || typeof request !== "object" || Array.isArray(request) || typeof request.value !== "string") {
1796
+ throw new ClearanceError({
1797
+ code: "CONFIG_VALUE_INVALID",
1798
+ message: "Config values must be JSON strings.",
1799
+ stage: "config.set",
1800
+ status: 400,
1801
+ remediation: "Send an object with a string value field.",
1802
+ });
1803
+ }
1804
+ const key = c.req.param("key");
1805
+ const value = request.value;
1806
+ const candidate = { ...store.snapshot.meta.config, [key]: value };
1807
+ validateConfig(store, candidate);
1808
+ if (request.dryRun === true) {
1809
+ return c.json({ dryRun: true, changed: store.snapshot.meta.config[key] !== value, key, ...publicConfig(candidate), scope });
1810
+ }
1811
+ const result = setConfig(store, key, value);
1812
+ if (result.changed)
1813
+ await store.ready();
1814
+ return c.json({ ok: true, changed: result.changed, key, ...publicConfig(result.config), scope });
1815
+ }
1816
+ catch (e) {
1817
+ return handleError(c, e);
1818
+ }
1819
+ });
1820
+ app.post("/v1/config/validate", async (c) => {
1821
+ try {
1822
+ const store = await storeForRequest();
1823
+ const scope = scopeForRequest(store, c);
1824
+ const request = await c.req.json().catch(() => ({}));
1825
+ const candidate = request.config ?? store.snapshot.meta.config;
1826
+ validateConfig(store, candidate);
1827
+ return c.json({ ok: true, ...publicConfig(candidate), scope });
1828
+ }
1829
+ catch (e) {
1830
+ return handleError(c, e);
1831
+ }
1832
+ });
1833
+ app.post("/v1/config/diff", async (c) => {
1834
+ try {
1835
+ const store = await storeForRequest();
1836
+ const scope = scopeForRequest(store, c);
1837
+ const request = await c.req.json();
1838
+ validateConfig(store, request.config);
1839
+ return c.json({ ...diffConfig(store.snapshot.meta.config, request.config), scope });
1840
+ }
1841
+ catch (e) {
1842
+ return handleError(c, e);
1843
+ }
1844
+ });
1845
+ // --- Enterprise routes (scope enforced on org ownership inside services) ---
1846
+ app.get("/v1/sso", async (c) => {
1847
+ try {
1848
+ const store = await storeForRequest();
1849
+ const scope = scopeForRequest(store, c);
1850
+ const scopedOrgIds = new Set(listOrganizations(store, { scope }).map((org) => org.id));
1851
+ const connections = listSsoConnections(store, c.req.query("organizationId")).filter((connection) => scopedOrgIds.has(connection.organizationId));
1852
+ return c.json({ connections, scope });
1853
+ }
1854
+ catch (e) {
1855
+ return handleError(c, e);
1856
+ }
1857
+ });
1858
+ app.post("/v1/sso", async (c) => {
1859
+ try {
1860
+ const store = await storeForRequest();
1861
+ const scope = scopeForRequest(store, c);
1862
+ const body = await c.req.json();
1863
+ // Fail closed if organizationId is outside principal scope
1864
+ inspectOrganization(store, body.organizationId, scope);
1865
+ const connection = process.env.DATABASE_URL
1866
+ ? await createSsoConnectionReal(store, body)
1867
+ : createSsoConnection(store, body);
1868
+ await store.ready();
1869
+ return c.json({ connection }, 201);
1870
+ }
1871
+ catch (e) {
1872
+ return handleError(c, e);
1873
+ }
1874
+ });
1875
+ app.patch("/v1/sso/:id", async (c) => {
1876
+ try {
1877
+ const store = await storeForRequest();
1878
+ const scope = scopeForRequest(store, c);
1879
+ const request = await c.req.json().catch(() => ({}));
1880
+ const connection = configureSsoConnection(store, c.req.param("id"), {
1881
+ issuer: request.issuer,
1882
+ audience: request.audience,
1883
+ domains: request.domain ? [request.domain] : request.domains,
1884
+ }, { scope, actor: "api", source: "api" });
1885
+ await store.ready();
1886
+ return c.json({ connection, scope });
1887
+ }
1888
+ catch (e) {
1889
+ return handleError(c, e);
1890
+ }
1891
+ });
1892
+ app.post("/v1/sso/setup-links", async (c) => {
1893
+ try {
1894
+ const store = await storeForRequest();
1895
+ const scope = scopeForRequest(store, c);
1896
+ const request = await c.req.json();
1897
+ inspectOrganization(store, request.organizationId, scope);
1898
+ const link = createSetupLink(store, { organizationId: request.organizationId, kind: "sso", actor: "api" });
1899
+ await store.ready();
1900
+ return c.json({ ...link, scope }, 201);
1901
+ }
1902
+ catch (e) {
1903
+ return handleError(c, e);
1904
+ }
1905
+ });
1906
+ app.post("/v1/sso/:id/test", async (c) => {
1907
+ try {
1908
+ const store = await storeForRequest();
1909
+ const scope = scopeForRequest(store, c);
1910
+ const conn = store.snapshot.identityConnections.find((x) => x.id === c.req.param("id"));
1911
+ if (!conn) {
1912
+ return c.json({ error: { code: "SSO_NOT_FOUND", message: "SSO connection not found", stage: "sso.test" } }, 404);
1913
+ }
1914
+ inspectOrganization(store, conn.organizationId, scope);
1915
+ const body = await c.req.json().catch(() => ({}));
1916
+ const result = body.live === true
1917
+ ? await testSsoConnectionLive(store, c.req.param("id"))
1918
+ : process.env.DATABASE_URL
1919
+ ? await testSsoConnectionReal(store, c.req.param("id"), body)
1920
+ : testSsoConnection(store, c.req.param("id"), body);
1921
+ await store.ready();
1922
+ return c.json(result);
1923
+ }
1924
+ catch (e) {
1925
+ return handleError(c, e);
1926
+ }
1927
+ });
1928
+ app.post("/v1/sso/:id/rotate", async (c) => {
1929
+ try {
1930
+ const store = await storeForRequest();
1931
+ const scope = scopeForRequest(store, c);
1932
+ const body = await c.req.json().catch(() => ({}));
1933
+ // Validate scope before mutation (fail closed for missing/cross-scope).
1934
+ const current = inspectSsoConnection(store, c.req.param("id"), { scope });
1935
+ if (body.dryRun === true) {
1936
+ if (!current.hasClientSecret && !current.clientSecretFingerprint) {
1937
+ throw new ClearanceError({ code: "SSO_NO_SECRET", message: "No encrypted client secret to rotate", stage: "sso.rotate", status: 400 });
1938
+ }
1939
+ return c.json({ dryRun: true, connection: current, wouldChange: true, scope });
1940
+ }
1941
+ const connection = rotateSsoCredential(store, c.req.param("id"), {
1942
+ actor: "api",
1943
+ source: "api",
1944
+ scope,
1945
+ });
1946
+ await store.ready();
1947
+ return c.json({ connection, scope });
1948
+ }
1949
+ catch (e) {
1950
+ return handleError(c, e);
1951
+ }
1952
+ });
1953
+ app.post("/v1/sso/:id/disable", async (c) => {
1954
+ try {
1955
+ const store = await storeForRequest();
1956
+ const scope = scopeForRequest(store, c);
1957
+ const body = await c.req.json().catch(() => ({}));
1958
+ if (body.dryRun === true) {
1959
+ const connection = inspectSsoConnection(store, c.req.param("id"), { scope });
1960
+ return c.json({ dryRun: true, connection, wouldChange: connection.status !== "disabled", scope });
1961
+ }
1962
+ const result = process.env.DATABASE_URL
1963
+ ? await disableSsoConnectionReal(store, c.req.param("id"), {
1964
+ actor: "api",
1965
+ source: "api",
1966
+ scope,
1967
+ })
1968
+ : disableSsoConnection(store, c.req.param("id"), {
1969
+ actor: "api",
1970
+ source: "api",
1971
+ scope,
1972
+ });
1973
+ await store.ready();
1974
+ return c.json({ ...result, scope });
1975
+ }
1976
+ catch (e) {
1977
+ return handleError(c, e);
1978
+ }
1979
+ });
1980
+ app.post("/v1/scim", async (c) => {
1981
+ try {
1982
+ const store = await storeForRequest();
1983
+ const scope = scopeForRequest(store, c);
1984
+ const body = await c.req.json();
1985
+ inspectOrganization(store, body.organizationId, scope);
1986
+ const developmentBearerToken = process.env.DATABASE_URL
1987
+ ? undefined
1988
+ : `scimtok_${randomBytes(24).toString("base64url")}`;
1989
+ const connection = process.env.DATABASE_URL
1990
+ ? await createScimConnectionReal(store, body)
1991
+ : {
1992
+ ...createScimConnection(store, { ...body, bearerToken: developmentBearerToken }),
1993
+ bearerTokenOnce: developmentBearerToken,
1994
+ };
1995
+ await store.ready();
1996
+ return c.json({ connection }, 201);
1997
+ }
1998
+ catch (e) {
1999
+ return handleError(c, e);
2000
+ }
2001
+ });
2002
+ app.get("/v1/scim", async (c) => {
2003
+ try {
2004
+ const store = await storeForRequest();
2005
+ const scope = scopeForRequest(store, c);
2006
+ const scopedOrgIds = new Set(listOrganizations(store, { scope }).map((org) => org.id));
2007
+ const connections = listScimConnections(store, c.req.query("organizationId")).filter((connection) => scopedOrgIds.has(connection.organizationId));
2008
+ return c.json({ connections, scope });
2009
+ }
2010
+ catch (e) {
2011
+ return handleError(c, e);
2012
+ }
2013
+ });
2014
+ app.post("/v1/scim/setup-links", async (c) => {
2015
+ try {
2016
+ const store = await storeForRequest();
2017
+ const scope = scopeForRequest(store, c);
2018
+ const request = await c.req.json();
2019
+ inspectOrganization(store, request.organizationId, scope);
2020
+ const link = createSetupLink(store, { organizationId: request.organizationId, kind: "scim", actor: "api" });
2021
+ await store.ready();
2022
+ return c.json({ ...link, scope }, 201);
2023
+ }
2024
+ catch (e) {
2025
+ return handleError(c, e);
2026
+ }
2027
+ });
2028
+ app.post("/v1/scim/:id/test", async (c) => {
2029
+ try {
2030
+ const store = await storeForRequest();
2031
+ const scope = scopeForRequest(store, c);
2032
+ const conn = store.snapshot.directoryConnections.find((x) => x.id === c.req.param("id"));
2033
+ if (!conn) {
2034
+ return c.json({
2035
+ error: {
2036
+ code: "SCIM_NOT_FOUND",
2037
+ message: "SCIM connection not found",
2038
+ stage: "scim.test",
2039
+ },
2040
+ }, 404);
2041
+ }
2042
+ inspectOrganization(store, conn.organizationId, scope);
2043
+ const body = await c.req.json().catch(() => ({}));
2044
+ const result = body.live === true
2045
+ ? await testScimConnectionLive(store, c.req.param("id"))
2046
+ : process.env.DATABASE_URL
2047
+ ? await testScimConnectionReal(store, c.req.param("id"), body)
2048
+ : testScimConnection(store, c.req.param("id"), body);
2049
+ await store.ready();
2050
+ return c.json(result);
2051
+ }
2052
+ catch (e) {
2053
+ return handleError(c, e);
2054
+ }
2055
+ });
2056
+ app.post("/v1/scim/:id/rotate", async (c) => {
2057
+ try {
2058
+ const store = await storeForRequest();
2059
+ const scope = scopeForRequest(store, c);
2060
+ const body = await c.req.json().catch(() => ({}));
2061
+ const current = inspectScimConnection(store, c.req.param("id"), { scope });
2062
+ if (body.dryRun === true) {
2063
+ if (!current.hasBearerToken && !current.bearerTokenFingerprint) {
2064
+ throw new ClearanceError({ code: "SCIM_NO_TOKEN", message: "No encrypted bearer token to rotate", stage: "scim.rotate", status: 400 });
2065
+ }
2066
+ return c.json({ dryRun: true, connection: current, wouldChange: true, scope });
2067
+ }
2068
+ const connection = rotateScimCredential(store, c.req.param("id"), {
2069
+ actor: "api",
2070
+ source: "api",
2071
+ scope,
2072
+ });
2073
+ await store.ready();
2074
+ return c.json({ connection, scope });
2075
+ }
2076
+ catch (e) {
2077
+ return handleError(c, e);
2078
+ }
2079
+ });
2080
+ app.post("/v1/scim/:id/disable", async (c) => {
2081
+ try {
2082
+ const store = await storeForRequest();
2083
+ const scope = scopeForRequest(store, c);
2084
+ const body = await c.req.json().catch(() => ({}));
2085
+ if (body.dryRun === true) {
2086
+ const connection = inspectScimConnection(store, c.req.param("id"), { scope });
2087
+ return c.json({ dryRun: true, connection, wouldChange: connection.status !== "disabled", scope });
2088
+ }
2089
+ const result = process.env.DATABASE_URL
2090
+ ? await disableScimConnectionReal(store, c.req.param("id"), {
2091
+ actor: "api",
2092
+ source: "api",
2093
+ scope,
2094
+ })
2095
+ : disableScimConnection(store, c.req.param("id"), {
2096
+ actor: "api",
2097
+ source: "api",
2098
+ scope,
2099
+ });
2100
+ await store.ready();
2101
+ return c.json({ ...result, scope });
2102
+ }
2103
+ catch (e) {
2104
+ return handleError(c, e);
2105
+ }
2106
+ });
2107
+ app.post("/v1/scim/traces/:traceId/replay", async (c) => {
2108
+ try {
2109
+ const store = await storeForRequest();
2110
+ const scope = scopeForRequest(store, c);
2111
+ const result = replayDiagnosticTrace(store, c.req.param("traceId"), {
2112
+ dryRun: false,
2113
+ confirm: true,
2114
+ actor: "api",
2115
+ source: "api",
2116
+ scope,
2117
+ });
2118
+ await store.ready();
2119
+ return c.json(result);
2120
+ }
2121
+ catch (e) {
2122
+ return handleError(c, e);
2123
+ }
2124
+ });
2125
+ // --- Readiness routes (scope enforced) ---
2126
+ app.post("/v1/readiness/check", async (c) => {
2127
+ try {
2128
+ const store = await storeForRequest();
2129
+ const scope = scopeForRequest(store, c);
2130
+ const body = await c.req.json();
2131
+ inspectOrganization(store, body.organizationId, scope);
2132
+ const report = runReadinessCheck(store, body.organizationId);
2133
+ await store.ready();
2134
+ return c.json({ report });
2135
+ }
2136
+ catch (e) {
2137
+ return handleError(c, e);
2138
+ }
2139
+ });
2140
+ app.get("/v1/readiness/:orgId", async (c) => {
2141
+ try {
2142
+ const store = await storeForRequest();
2143
+ const scope = scopeForRequest(store, c);
2144
+ inspectOrganization(store, c.req.param("orgId"), scope);
2145
+ const report = getLatestReadiness(store, c.req.param("orgId"));
2146
+ return c.json({ report });
2147
+ }
2148
+ catch (e) {
2149
+ return handleError(c, e);
2150
+ }
2151
+ });
2152
+ function handleError(c, e) {
2153
+ if (isClearanceError(e)) {
2154
+ return c.json(e.toJSON(), e.status);
2155
+ }
2156
+ console.error("Unexpected Clearance API error", e);
2157
+ return c.json({
2158
+ error: {
2159
+ code: "INTERNAL",
2160
+ message: "An unexpected internal error occurred.",
2161
+ stage: "api",
2162
+ },
2163
+ }, 500);
2164
+ }
2165
+ async function readBoundedRequestBody(req) {
2166
+ if (req.method === "GET" || req.method === "HEAD")
2167
+ return undefined;
2168
+ const rawContentLength = req.headers["content-length"];
2169
+ if (typeof rawContentLength === "string") {
2170
+ const contentLength = Number(rawContentLength);
2171
+ if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BODY_BYTES) {
2172
+ throw new RequestBodyTooLargeError();
2173
+ }
2174
+ }
2175
+ const chunks = [];
2176
+ let total = 0;
2177
+ for await (const chunk of req) {
2178
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2179
+ total += buffer.length;
2180
+ if (total > MAX_REQUEST_BODY_BYTES) {
2181
+ throw new RequestBodyTooLargeError();
2182
+ }
2183
+ chunks.push(buffer);
2184
+ }
2185
+ return total > 0 ? Buffer.concat(chunks, total) : undefined;
2186
+ }
2187
+ function payloadTooLargeResponse() {
2188
+ return JSON.stringify({
2189
+ error: {
2190
+ code: "REQUEST_BODY_TOO_LARGE",
2191
+ message: `Request body exceeds the ${MAX_REQUEST_BODY_BYTES}-byte limit`,
2192
+ stage: "api.request_body",
2193
+ retryable: false,
2194
+ remediation: `Send a request body no larger than ${MAX_REQUEST_BODY_BYTES} bytes`,
2195
+ },
2196
+ });
2197
+ }
2198
+ /** Node HTTP bridge with a hard limit before Hono authentication/body parsing. */
2199
+ async function nodeRequestHandler(req, res) {
2200
+ try {
2201
+ const url = `http://localhost:${port}${req.url}`;
2202
+ const headers = new Headers();
2203
+ for (const [k, v] of Object.entries(req.headers)) {
2204
+ if (v)
2205
+ headers.set(k, Array.isArray(v) ? v.join(",") : v);
2206
+ }
2207
+ // Server-derived socket address for rate-limit keying. Unconditionally
2208
+ // overwritten so a client can never smuggle its own value (P2.3.3).
2209
+ headers.set("x-clearance-socket-remote", req.socket?.remoteAddress ?? "unknown");
2210
+ const body = await readBoundedRequestBody(req);
2211
+ const response = await app.fetch(new Request(url, {
2212
+ method: req.method,
2213
+ headers,
2214
+ body: body && body.length > 0 ? new Uint8Array(body) : undefined,
2215
+ }));
2216
+ res.statusCode = response.status;
2217
+ response.headers.forEach((value, key) => {
2218
+ res.setHeader(key, value);
2219
+ });
2220
+ const ab = await response.arrayBuffer();
2221
+ res.end(Buffer.from(ab));
2222
+ }
2223
+ catch (error) {
2224
+ if (error instanceof RequestBodyTooLargeError) {
2225
+ const body = payloadTooLargeResponse();
2226
+ res.statusCode = 413;
2227
+ res.setHeader("content-type", "application/json; charset=utf-8");
2228
+ res.setHeader("content-length", String(Buffer.byteLength(body)));
2229
+ res.setHeader("connection", "close");
2230
+ res.end(body, () => {
2231
+ if (!req.complete)
2232
+ req.destroy();
2233
+ });
2234
+ return;
2235
+ }
2236
+ if (!res.headersSent) {
2237
+ res.statusCode = 500;
2238
+ res.setHeader("content-type", "application/json; charset=utf-8");
2239
+ res.end(JSON.stringify({
2240
+ error: {
2241
+ code: "INTERNAL",
2242
+ message: "Internal server error",
2243
+ stage: "api.bridge",
2244
+ },
2245
+ }));
2246
+ }
2247
+ else {
2248
+ res.destroy();
2249
+ }
2250
+ }
2251
+ }
2252
+ function installGracefulShutdown(server, store, options = {}) {
2253
+ let shutdownPromise = null;
2254
+ const shutdown = (signal) => {
2255
+ if (shutdownPromise)
2256
+ return shutdownPromise;
2257
+ draining = true;
2258
+ console.log(JSON.stringify({ event: "shutdown_started", service: "clearance-api", signal }));
2259
+ shutdownPromise = new Promise((resolve) => {
2260
+ const timeout = setTimeout(() => {
2261
+ console.error(JSON.stringify({ event: "shutdown_timeout", service: "clearance-api" }));
2262
+ server.closeAllConnections?.();
2263
+ process.exitCode = 1;
2264
+ resolve();
2265
+ }, options.timeoutMs ?? Number(process.env.CLEARANCE_SHUTDOWN_TIMEOUT_MS ?? 25_000));
2266
+ timeout.unref();
2267
+ server.close(async (error) => {
2268
+ try {
2269
+ if (error)
2270
+ throw error;
2271
+ await store.ready();
2272
+ const destroy = store.destroy;
2273
+ if (destroy)
2274
+ await destroy.call(store);
2275
+ console.log(JSON.stringify({ event: "shutdown_completed", service: "clearance-api" }));
2276
+ }
2277
+ catch (shutdownError) {
2278
+ console.error(JSON.stringify({
2279
+ event: "shutdown_failed",
2280
+ service: "clearance-api",
2281
+ message: shutdownError instanceof Error ? shutdownError.message : String(shutdownError),
2282
+ }));
2283
+ process.exitCode = 1;
2284
+ }
2285
+ finally {
2286
+ clearTimeout(timeout);
2287
+ resolve();
2288
+ }
2289
+ });
2290
+ server.closeIdleConnections?.();
2291
+ });
2292
+ return shutdownPromise;
2293
+ };
2294
+ if (options.registerSignals !== false) {
2295
+ process.once("SIGTERM", () => void shutdown("SIGTERM"));
2296
+ process.once("SIGINT", () => void shutdown("SIGINT"));
2297
+ }
2298
+ return shutdown;
2299
+ }
2300
+ async function start() {
2301
+ // Eager store init so postgres schema exists before traffic
2302
+ const store = await getStore();
2303
+ await store.ready();
2304
+ const { createServer } = await import("node:http");
2305
+ const server = createServer(nodeRequestHandler);
2306
+ server.listen(port, () => {
2307
+ console.log(`clearance-api listening on http://localhost:${port} (store=${store.backend}, cors=${corsOrigins.join(",")})`);
2308
+ });
2309
+ installGracefulShutdown(server, store);
2310
+ return server;
2311
+ }
2312
+ const isDirectRun = typeof process.argv[1] === "string" &&
2313
+ (process.argv[1].endsWith(`${"server.ts"}`) ||
2314
+ process.argv[1].endsWith(`${"server.js"}`));
2315
+ if (isDirectRun) {
2316
+ start().catch((err) => {
2317
+ console.error(err);
2318
+ process.exit(1);
2319
+ });
2320
+ }
2321
+ export { app, getStore, storeForRequest, start, assertScope, scopeFromStore, principalScope, scopeForRequest, nodeRequestHandler, readBoundedRequestBody, idempotencyReplayBody, installGracefulShutdown, };