@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/LICENSE +29 -0
- package/LICENSE.md +20 -0
- package/dist/server.d.ts +54 -0
- package/dist/server.js +2321 -0
- package/package.json +40 -0
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, };
|