@clearance/api 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.d.ts CHANGED
@@ -37,10 +37,9 @@ declare function assertScope(store: ManagementStore, headerProject?: string | nu
37
37
  declare const app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
38
38
  /**
39
39
  * Build the response body that is safe to persist for a later replay.
40
- * Generated temporary passwords are one-time delivery credentials: retaining
41
- * one in either the Postgres companion table or the development memory backend
42
- * would extend its lifetime to the idempotency TTL. A replay still returns the
43
- * original user and status while explicitly reporting the omitted secret.
40
+ * Password-setup tokens and other one-time credentials must never enter the
41
+ * Postgres companion table or development memory backend. A replay still
42
+ * returns the original resource and status while reporting the omitted secret.
44
43
  */
45
44
  declare function idempotencyReplayBody(path: string, body: string): string | null;
46
45
  declare function readBoundedRequestBody(req: IncomingMessage): Promise<Buffer | undefined>;
package/dist/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Hono } from "hono";
2
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";
3
+ import { addMember, addMemberInAuth, applyUpgrade, executeMemberImportPlan, planMemberImport, archiveOrganization, archiveOrganizationInAuth, assertClientScopeHeaders, assertProductionCredentialKey, assertProductionSecret, ClearanceError, createManagementStore, createBackup, createPostgresBackup, createProject, createEnvironment, planProjectCreate, planEnvironmentCreate, createOrganization, createOrgInAuth, createScimConnection, createScimConnectionReal, createSsoConnection, createSsoConnectionReal, createUser, createUserInAuth, createUserWithPasswordSetupInAuth, deleteUser, deleteUserInAuth, disableScimConnection, disableScimConnectionReal, disableSsoConnection, disableSsoConnectionReal, disableUser, disableUserInAuth, ensureAuthMigrated, exportUsers, getLatestReadiness, getRuntimeSchemaStatus, initProject, inspectEnvironment, inspectMembership, inspectOrganization, inspectScimConnection, inspectSession, inspectSessionInAuth, inspectSsoConnection, inspectUser, isClearanceError, isForbiddenDefaultSecret, listEnvironments, listEventsPage, exportEvents, inspectEvent, replayDiagnosticTrace, listMembers, listOrganizations, listOrganizationsPage, listRoles, listSessionsPage, listSessionsPageInAuth, listUsers, listUsersPage, migrateRuntimeSchema, planRuntimeSchema, migrationStatus, parseLegacyFixture, planUpgrade, planMigration, previewMigration, rollbackMigrationDurable, rollbackUpgrade, runMigrationDurable, verifyMigrationDurable, verifyUpgrade, assertIdempotencyKeyValid, createIdempotencyBackend, fingerprintIdempotentRequest, idempotencyConflictError, overviewStats, promoteEnvironment, revokeSession, revokeSessionInAuth, restoreBackup, restorePostgresBackup, 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, upgradeCheck, upgradeCheckWithDb, verifyBackup, verifyPostgresBackup, syncRuntimeOrganizationToManagementDurable, } from "@clearance/management";
4
4
  import { timingSafeEqual, createHash, randomBytes, randomUUID } from "node:crypto";
5
5
  const port = Number(process.env.CLEARANCE_API_PORT ?? 3200);
6
6
  export const DEFAULT_MAX_REQUEST_BODY_BYTES = 1024 * 1024;
@@ -309,10 +309,9 @@ const IDEMPOTENT_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
309
309
  let idempotencyBackend = null;
310
310
  /**
311
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.
312
+ * Password-setup tokens and other one-time credentials must never enter the
313
+ * Postgres companion table or development memory backend. A replay still
314
+ * returns the original resource and status while reporting the omitted secret.
316
315
  */
317
316
  function idempotencyReplayBody(path, body) {
318
317
  const sensitive = path === "/v1/users" ||
@@ -327,7 +326,7 @@ function idempotencyReplayBody(path, body) {
327
326
  const parsed = JSON.parse(body);
328
327
  const omitted = [];
329
328
  for (const key of path === "/v1/users"
330
- ? ["temporaryPassword"]
329
+ ? ["passwordSetupToken"]
331
330
  : path.endsWith("/setup-links")
332
331
  ? ["token", "url"]
333
332
  : path === "/v1/keys" || path.endsWith("/rotate")
@@ -635,7 +634,7 @@ app.get("/health", async (c) => {
635
634
  return c.json({
636
635
  ok: true,
637
636
  service: "clearance-api",
638
- version: "0.1.4",
637
+ version: "0.2.1",
639
638
  });
640
639
  });
641
640
  /**
@@ -661,6 +660,16 @@ app.get("/v1/doctor", async (c) => {
661
660
  const store = await storeForRequest();
662
661
  return c.json(await runDoctor(store));
663
662
  });
663
+ app.get("/v1/dev", (c) => c.json({
664
+ commands: [
665
+ "clearance init --name my-app",
666
+ "pnpm stack:smoke",
667
+ "pnpm stack:up",
668
+ "pnpm --filter @clearance/sample-b2b dev",
669
+ "pnpm --filter @clearance/api dev",
670
+ "pnpm --filter @clearance/console dev",
671
+ ],
672
+ }));
664
673
  app.post("/v1/init", async (c) => {
665
674
  const store = await storeForRequest();
666
675
  const body = await c.req.json().catch(() => ({}));
@@ -903,28 +912,46 @@ app.post("/v1/users", async (c) => {
903
912
  }
904
913
  return c.json({ dryRun: true, email, name: body.name.trim(), scope });
905
914
  }
906
- const generatedPassword = body.password
907
- ? undefined
908
- : `Tmp!${randomBytes(18).toString("base64url")}aA1`;
909
- const user = process.env.DATABASE_URL
915
+ const provisioned = process.env.DATABASE_URL
910
916
  ? await (async () => {
911
917
  await ensureAuthMigrated();
912
- return createUserInAuth({
918
+ if (typeof body.password === "string" && body.password.length > 0) {
919
+ return {
920
+ user: await createUserInAuth({
921
+ email: body.email,
922
+ name: body.name,
923
+ password: body.password,
924
+ managementStore: store,
925
+ }),
926
+ passwordSetup: undefined,
927
+ };
928
+ }
929
+ return createUserWithPasswordSetupInAuth({
913
930
  email: body.email,
914
931
  name: body.name,
915
- password: body.password ?? generatedPassword,
916
932
  managementStore: store,
917
933
  });
918
934
  })()
919
- : createUser(store, {
920
- email: body.email,
921
- name: body.name,
922
- projectId: scope.projectId,
923
- environmentId: scope.environmentId,
924
- source: "api",
925
- });
935
+ : {
936
+ user: createUser(store, {
937
+ email: body.email,
938
+ name: body.name,
939
+ projectId: scope.projectId,
940
+ environmentId: scope.environmentId,
941
+ source: "api",
942
+ }),
943
+ passwordSetup: undefined,
944
+ };
926
945
  await store.ready();
927
- return c.json({ user, ...(generatedPassword ? { temporaryPassword: generatedPassword } : {}) }, 201);
946
+ return c.json({
947
+ user: provisioned.user,
948
+ ...(provisioned.passwordSetup
949
+ ? {
950
+ passwordSetupToken: provisioned.passwordSetup.token,
951
+ passwordSetupExpiresAt: provisioned.passwordSetup.expiresAt,
952
+ }
953
+ : {}),
954
+ }, 201);
928
955
  }
929
956
  catch (e) {
930
957
  return handleError(c, e);
@@ -1351,6 +1378,67 @@ app.post("/v1/organizations/:id/members", async (c) => {
1351
1378
  return handleError(c, e);
1352
1379
  }
1353
1380
  });
1381
+ app.post("/v1/organizations/:id/members/import", async (c) => {
1382
+ try {
1383
+ const store = await storeForRequest();
1384
+ const scope = scopeForRequest(store, c);
1385
+ const body = await c.req.json().catch(() => ({}));
1386
+ const format = body.format;
1387
+ if (format !== "json" && format !== "csv") {
1388
+ throw new ClearanceError({
1389
+ code: "MEMBER_IMPORT_FORMAT_REQUIRED",
1390
+ message: "Member import format must be json or csv",
1391
+ stage: "orgs.members.import",
1392
+ status: 400,
1393
+ remediation: "Send format as json or csv.",
1394
+ });
1395
+ }
1396
+ if (typeof body.content !== "string") {
1397
+ throw new ClearanceError({
1398
+ code: "MEMBER_IMPORT_CONTENT_REQUIRED",
1399
+ message: "Member import content is required",
1400
+ stage: "orgs.members.import",
1401
+ status: 400,
1402
+ remediation: "Send the local file contents in the authenticated request.",
1403
+ });
1404
+ }
1405
+ const plan = planMemberImport(store, {
1406
+ organizationId: c.req.param("id"),
1407
+ content: body.content,
1408
+ format,
1409
+ });
1410
+ if (body.dryRun === true || body.confirm !== true) {
1411
+ return c.json({ dryRun: true, ...plan, scope });
1412
+ }
1413
+ const result = await executeMemberImportPlan(plan, async (row) => {
1414
+ const membership = process.env.DATABASE_URL
1415
+ ? await addMemberInAuth(store, {
1416
+ organizationId: plan.organizationId,
1417
+ principalId: row.principalId,
1418
+ role: row.role,
1419
+ source: "import",
1420
+ actor: "api",
1421
+ auditSource: "import",
1422
+ scope,
1423
+ })
1424
+ : addMember(store, {
1425
+ organizationId: plan.organizationId,
1426
+ principalId: row.principalId,
1427
+ role: row.role,
1428
+ source: "import",
1429
+ actor: "api",
1430
+ auditSource: "import",
1431
+ scope,
1432
+ });
1433
+ await store.ready();
1434
+ return membership;
1435
+ });
1436
+ return c.json({ ...result, scope });
1437
+ }
1438
+ catch (e) {
1439
+ return handleError(c, e);
1440
+ }
1441
+ });
1354
1442
  app.patch("/v1/organizations/:id/members/:memberId", async (c) => {
1355
1443
  try {
1356
1444
  const store = await storeForRequest();
@@ -1456,10 +1544,14 @@ app.get("/v1/events", async (c) => {
1456
1544
  const scope = scopeForRequest(store, c);
1457
1545
  const limitRaw = c.req.query("limit");
1458
1546
  const cursor = c.req.query("cursor");
1547
+ const action = c.req.query("action");
1548
+ const organizationId = c.req.query("organizationId");
1459
1549
  const page = listEventsPage(store, {
1460
1550
  scope,
1461
1551
  ...(limitRaw !== undefined ? { limit: Number(limitRaw) } : {}),
1462
1552
  ...(cursor !== undefined ? { cursor } : {}),
1553
+ ...(action !== undefined ? { action } : {}),
1554
+ ...(organizationId !== undefined ? { organizationId } : {}),
1463
1555
  });
1464
1556
  return c.json({ events: page.events, nextCursor: page.nextCursor, scope });
1465
1557
  }
@@ -1659,6 +1751,13 @@ app.post("/v1/sessions/:id/revoke", async (c) => {
1659
1751
  try {
1660
1752
  const store = await storeForRequest();
1661
1753
  const scope = scopeForRequest(store, c);
1754
+ const body = await c.req.json().catch(() => ({}));
1755
+ if (body.dryRun === true) {
1756
+ const session = process.env.DATABASE_URL
1757
+ ? await inspectSessionInAuth(store, c.req.param("id"), { scope })
1758
+ : inspectSession(store, c.req.param("id"), { scope });
1759
+ return c.json({ dryRun: true, session, wouldChange: session.status === "active", scope });
1760
+ }
1662
1761
  const result = process.env.DATABASE_URL
1663
1762
  ? await (async () => {
1664
1763
  await ensureAuthMigrated();
@@ -1824,7 +1923,7 @@ app.post("/v1/config/validate", async (c) => {
1824
1923
  const request = await c.req.json().catch(() => ({}));
1825
1924
  const candidate = request.config ?? store.snapshot.meta.config;
1826
1925
  validateConfig(store, candidate);
1827
- return c.json({ ok: true, ...publicConfig(candidate), scope });
1926
+ return c.json({ ok: true, source: request.config === undefined ? "current" : "candidate", ...publicConfig(candidate), scope });
1828
1927
  }
1829
1928
  catch (e) {
1830
1929
  return handleError(c, e);
@@ -1877,6 +1976,19 @@ app.patch("/v1/sso/:id", async (c) => {
1877
1976
  const store = await storeForRequest();
1878
1977
  const scope = scopeForRequest(store, c);
1879
1978
  const request = await c.req.json().catch(() => ({}));
1979
+ if (request.dryRun === true) {
1980
+ const current = inspectSsoConnection(store, c.req.param("id"), { scope });
1981
+ return c.json({
1982
+ dryRun: true,
1983
+ connection: current,
1984
+ proposed: {
1985
+ issuer: request.issuer ?? current.issuer,
1986
+ audience: request.audience ?? current.audience,
1987
+ domains: request.domain ? [request.domain] : request.domains ?? current.domains,
1988
+ },
1989
+ scope,
1990
+ });
1991
+ }
1880
1992
  const connection = configureSsoConnection(store, c.req.param("id"), {
1881
1993
  issuer: request.issuer,
1882
1994
  audience: request.audience,
@@ -2108,14 +2220,17 @@ app.post("/v1/scim/traces/:traceId/replay", async (c) => {
2108
2220
  try {
2109
2221
  const store = await storeForRequest();
2110
2222
  const scope = scopeForRequest(store, c);
2223
+ const body = await c.req.json().catch(() => ({}));
2224
+ const dryRun = body.dryRun === true || body.confirm !== true;
2111
2225
  const result = replayDiagnosticTrace(store, c.req.param("traceId"), {
2112
- dryRun: false,
2113
- confirm: true,
2226
+ dryRun,
2227
+ confirm: body.confirm === true && !dryRun,
2114
2228
  actor: "api",
2115
2229
  source: "api",
2116
2230
  scope,
2117
2231
  });
2118
- await store.ready();
2232
+ if (!result.dryRun)
2233
+ await store.ready();
2119
2234
  return c.json(result);
2120
2235
  }
2121
2236
  catch (e) {
@@ -2149,6 +2264,333 @@ app.get("/v1/readiness/:orgId", async (c) => {
2149
2264
  return handleError(c, e);
2150
2265
  }
2151
2266
  });
2267
+ app.post("/v1/backups", async (c) => {
2268
+ try {
2269
+ const store = await storeForRequest();
2270
+ const body = await c.req.json().catch(() => ({}));
2271
+ if (body.dir !== undefined) {
2272
+ throw new ClearanceError({
2273
+ code: "BACKUP_DIRECTORY_SERVER_MANAGED",
2274
+ message: "Backup storage is configured by the API deployment",
2275
+ stage: "backup.create",
2276
+ status: 400,
2277
+ remediation: "Set CLEARANCE_BACKUP_DIR on the API and mount durable storage there.",
2278
+ });
2279
+ }
2280
+ const configuredDirectory = process.env.CLEARANCE_BACKUP_DIR?.trim();
2281
+ if (process.env.NODE_ENV === "production" && !configuredDirectory) {
2282
+ throw new ClearanceError({
2283
+ code: "BACKUP_DIRECTORY_NOT_CONFIGURED",
2284
+ message: "The API backup directory is not configured",
2285
+ stage: "backup.create",
2286
+ status: 503,
2287
+ remediation: "Set CLEARANCE_BACKUP_DIR and mount durable backup storage before retrying.",
2288
+ });
2289
+ }
2290
+ const backup = process.env.DATABASE_URL
2291
+ ? createPostgresBackup(store, configuredDirectory || undefined)
2292
+ : createBackup(store, configuredDirectory || undefined);
2293
+ await store.ready();
2294
+ return c.json({ backup }, 201);
2295
+ }
2296
+ catch (e) {
2297
+ return handleError(c, e);
2298
+ }
2299
+ });
2300
+ app.post("/v1/backups/:id/verify", async (c) => {
2301
+ try {
2302
+ const store = await storeForRequest();
2303
+ const backup = process.env.DATABASE_URL
2304
+ ? await verifyPostgresBackup(store, c.req.param("id"))
2305
+ : verifyBackup(store, c.req.param("id"));
2306
+ await store.ready();
2307
+ return c.json({ backup });
2308
+ }
2309
+ catch (e) {
2310
+ return handleError(c, e);
2311
+ }
2312
+ });
2313
+ app.post("/v1/backups/:id/restore", async (c) => {
2314
+ try {
2315
+ const store = await storeForRequest();
2316
+ const body = await c.req.json().catch(() => ({}));
2317
+ if (body.confirm !== true) {
2318
+ throw new ClearanceError({
2319
+ code: "BACKUP_RESTORE_CONFIRM_REQUIRED",
2320
+ message: "Backup restore requires explicit confirmation",
2321
+ stage: "backup.restore",
2322
+ status: 400,
2323
+ remediation: "Verify the backup first, then send confirm as true.",
2324
+ });
2325
+ }
2326
+ const target = typeof body.target === "string" ? body.target : undefined;
2327
+ const result = process.env.DATABASE_URL
2328
+ ? await restorePostgresBackup(store, c.req.param("id"), target)
2329
+ : (() => {
2330
+ if (!target) {
2331
+ throw new ClearanceError({
2332
+ code: "BACKUP_RESTORE_TARGET_REQUIRED",
2333
+ message: "A restore target is required for the development store",
2334
+ stage: "backup.restore",
2335
+ status: 400,
2336
+ remediation: "Send an isolated target path.",
2337
+ });
2338
+ }
2339
+ return restoreBackup(store, c.req.param("id"), target);
2340
+ })();
2341
+ await store.ready();
2342
+ return c.json(result);
2343
+ }
2344
+ catch (e) {
2345
+ return handleError(c, e);
2346
+ }
2347
+ });
2348
+ app.get("/v1/upgrades/check", async (c) => {
2349
+ try {
2350
+ const store = await storeForRequest();
2351
+ const result = process.env.DATABASE_URL
2352
+ ? await upgradeCheckWithDb(store)
2353
+ : upgradeCheck(store);
2354
+ await store.ready();
2355
+ return c.json(result);
2356
+ }
2357
+ catch (e) {
2358
+ return handleError(c, e);
2359
+ }
2360
+ });
2361
+ app.post("/v1/upgrades/plan", async (c) => {
2362
+ try {
2363
+ const body = await c.req.json().catch(() => ({}));
2364
+ return c.json(await planUpgrade({
2365
+ target: typeof body.target === "string" ? body.target : undefined,
2366
+ dir: typeof body.dir === "string" ? body.dir : undefined,
2367
+ current: typeof body.current === "string" ? body.current : undefined,
2368
+ dryRun: body.dryRun === true,
2369
+ }));
2370
+ }
2371
+ catch (e) {
2372
+ return handleError(c, e);
2373
+ }
2374
+ });
2375
+ app.post("/v1/upgrades/apply", async (c) => {
2376
+ try {
2377
+ const body = await c.req.json().catch(() => ({}));
2378
+ return c.json(await applyUpgrade({
2379
+ plan: typeof body.plan === "string" ? body.plan : undefined,
2380
+ dir: typeof body.dir === "string" ? body.dir : undefined,
2381
+ dryRun: body.dryRun === true,
2382
+ yes: body.confirm === true,
2383
+ }));
2384
+ }
2385
+ catch (e) {
2386
+ return handleError(c, e);
2387
+ }
2388
+ });
2389
+ app.post("/v1/upgrades/verify", async (c) => {
2390
+ try {
2391
+ const body = await c.req.json().catch(() => ({}));
2392
+ return c.json(await verifyUpgrade({
2393
+ plan: typeof body.plan === "string" ? body.plan : undefined,
2394
+ dir: typeof body.dir === "string" ? body.dir : undefined,
2395
+ healthUrl: typeof body.healthUrl === "string" ? body.healthUrl : undefined,
2396
+ dryRun: body.dryRun === true,
2397
+ }));
2398
+ }
2399
+ catch (e) {
2400
+ return handleError(c, e);
2401
+ }
2402
+ });
2403
+ app.post("/v1/upgrades/rollback", async (c) => {
2404
+ try {
2405
+ const body = await c.req.json().catch(() => ({}));
2406
+ return c.json(await rollbackUpgrade({
2407
+ plan: typeof body.plan === "string" ? body.plan : undefined,
2408
+ dir: typeof body.dir === "string" ? body.dir : undefined,
2409
+ dryRun: body.dryRun === true,
2410
+ yes: body.confirm === true,
2411
+ restoreActive: body.restoreActive === true,
2412
+ confirm: typeof body.activeDatabaseConfirmation === "string"
2413
+ ? body.activeDatabaseConfirmation
2414
+ : undefined,
2415
+ backupDir: typeof body.backupDir === "string" ? body.backupDir : undefined,
2416
+ }));
2417
+ }
2418
+ catch (e) {
2419
+ return handleError(c, e);
2420
+ }
2421
+ });
2422
+ app.get("/v1/schema/status", async (c) => {
2423
+ try {
2424
+ const store = await storeForRequest();
2425
+ return c.json({
2426
+ management: {
2427
+ schemaVersion: store.snapshot.meta.schemaVersion,
2428
+ releaseVersion: store.snapshot.releaseVersion,
2429
+ initializedAt: store.snapshot.meta.initializedAt,
2430
+ },
2431
+ runtime: await getRuntimeSchemaStatus(),
2432
+ });
2433
+ }
2434
+ catch (e) {
2435
+ return handleError(c, e);
2436
+ }
2437
+ });
2438
+ app.post("/v1/schema/generate", async (c) => {
2439
+ try {
2440
+ const plan = await planRuntimeSchema("schema.generate");
2441
+ return c.json({ kind: "schema.generate", ...plan });
2442
+ }
2443
+ catch (e) {
2444
+ return handleError(c, e);
2445
+ }
2446
+ });
2447
+ app.post("/v1/schema/migrate", async (c) => {
2448
+ try {
2449
+ const body = await c.req.json().catch(() => ({}));
2450
+ const dryRun = body.dryRun === true;
2451
+ if (!dryRun && body.confirm !== true) {
2452
+ throw new ClearanceError({
2453
+ code: "SCHEMA_MIGRATE_CONFIRMATION_REQUIRED",
2454
+ message: "Schema migration requires explicit confirmation",
2455
+ stage: "schema.migrate",
2456
+ status: 400,
2457
+ remediation: "Review a dry run, then send confirm as true.",
2458
+ });
2459
+ }
2460
+ return c.json(await migrateRuntimeSchema({ dryRun }));
2461
+ }
2462
+ catch (e) {
2463
+ return handleError(c, e);
2464
+ }
2465
+ });
2466
+ function migrationFixture(body) {
2467
+ if (!("fixture" in body)) {
2468
+ throw new ClearanceError({
2469
+ code: "CLEARANCE_IMPORT_FIXTURE_REQUIRED",
2470
+ message: "A legacy migration fixture is required",
2471
+ stage: "import.legacy.fixture",
2472
+ status: 400,
2473
+ remediation: "Send the validated fixture in the authenticated request body.",
2474
+ });
2475
+ }
2476
+ return parseLegacyFixture(body.fixture);
2477
+ }
2478
+ app.post("/v1/import/legacy", async (c) => {
2479
+ try {
2480
+ const store = await storeForRequest();
2481
+ const body = await c.req.json().catch(() => ({}));
2482
+ const fixture = migrationFixture(body);
2483
+ const preview = previewMigration(store, fixture);
2484
+ if (body.dryRun === true || body.confirm !== true) {
2485
+ return c.json({
2486
+ schemaVersion: "v1",
2487
+ dryRun: true,
2488
+ source: "legacy",
2489
+ preview,
2490
+ storeBackend: store.backend,
2491
+ });
2492
+ }
2493
+ const planned = planMigration(store, fixture);
2494
+ await store.ready();
2495
+ await store.refresh();
2496
+ await runMigrationDurable(store, planned.id, fixture);
2497
+ const verification = await verifyMigrationDurable(store, planned.id, fixture);
2498
+ await store.ready();
2499
+ return c.json({
2500
+ schemaVersion: "v1",
2501
+ dryRun: false,
2502
+ source: "legacy",
2503
+ migration: verification.plan,
2504
+ preview,
2505
+ verification: {
2506
+ reconciled: verification.reconciled,
2507
+ expected: verification.expected,
2508
+ actual: verification.actual,
2509
+ },
2510
+ storeBackend: store.backend,
2511
+ });
2512
+ }
2513
+ catch (e) {
2514
+ return handleError(c, e);
2515
+ }
2516
+ });
2517
+ app.post("/v1/migrations/plan", async (c) => {
2518
+ try {
2519
+ const store = await storeForRequest();
2520
+ const body = await c.req.json().catch(() => ({}));
2521
+ if (body.source !== "legacy") {
2522
+ throw new ClearanceError({
2523
+ code: "CLEARANCE_IMPORT_SOURCE_INVALID",
2524
+ message: "Only legacy imports are supported",
2525
+ stage: "migration.plan",
2526
+ status: 400,
2527
+ remediation: "Send source as legacy.",
2528
+ });
2529
+ }
2530
+ const plan = planMigration(store, migrationFixture(body));
2531
+ await store.ready();
2532
+ return c.json({ plan });
2533
+ }
2534
+ catch (e) {
2535
+ return handleError(c, e);
2536
+ }
2537
+ });
2538
+ app.post("/v1/migrations/:id/run", async (c) => {
2539
+ try {
2540
+ const store = await storeForRequest();
2541
+ const body = await c.req.json().catch(() => ({}));
2542
+ const plan = await runMigrationDurable(store, c.req.param("id"), migrationFixture(body), {
2543
+ dryRun: body.dryRun === true,
2544
+ });
2545
+ await store.ready();
2546
+ return c.json({ plan });
2547
+ }
2548
+ catch (e) {
2549
+ return handleError(c, e);
2550
+ }
2551
+ });
2552
+ app.post("/v1/migrations/:id/verify", async (c) => {
2553
+ try {
2554
+ const store = await storeForRequest();
2555
+ const body = await c.req.json().catch(() => ({}));
2556
+ const result = await verifyMigrationDurable(store, c.req.param("id"), migrationFixture(body));
2557
+ await store.ready();
2558
+ return c.json(result);
2559
+ }
2560
+ catch (e) {
2561
+ return handleError(c, e);
2562
+ }
2563
+ });
2564
+ app.post("/v1/migrations/:id/rollback", async (c) => {
2565
+ try {
2566
+ const store = await storeForRequest();
2567
+ const body = await c.req.json().catch(() => ({}));
2568
+ if (body.confirm !== true) {
2569
+ throw new ClearanceError({
2570
+ code: "MIGRATION_ROLLBACK_CONFIRM_REQUIRED",
2571
+ message: "Migration rollback requires explicit confirmation",
2572
+ stage: "migration.rollback",
2573
+ status: 400,
2574
+ remediation: "Review the plan, then send confirm as true.",
2575
+ });
2576
+ }
2577
+ const plan = await rollbackMigrationDurable(store, c.req.param("id"), migrationFixture(body));
2578
+ await store.ready();
2579
+ return c.json({ plan });
2580
+ }
2581
+ catch (e) {
2582
+ return handleError(c, e);
2583
+ }
2584
+ });
2585
+ app.get("/v1/migrations/:id", async (c) => {
2586
+ try {
2587
+ const store = await storeForRequest();
2588
+ return c.json({ plan: migrationStatus(store, c.req.param("id")) });
2589
+ }
2590
+ catch (e) {
2591
+ return handleError(c, e);
2592
+ }
2593
+ });
2152
2594
  function handleError(c, e) {
2153
2595
  if (isClearanceError(e)) {
2154
2596
  return c.json(e.toJSON(), e.status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clearance/api",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "description": "Clearance management API",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,14 +21,14 @@
21
21
  "dist"
22
22
  ],
23
23
  "dependencies": {
24
- "hono": "^4.12.25",
25
- "@clearance/management": "0.1.4"
24
+ "hono": "^4.12.30",
25
+ "@clearance/management": "0.2.1"
26
26
  },
27
27
  "devDependencies": {
28
- "@types/node": "^22.13.10",
29
- "tsx": "^4.19.3",
30
- "typescript": "^5.9.3",
31
- "vitest": "^4.1.5"
28
+ "@types/node": "^22.20.1",
29
+ "tsx": "^4.23.1",
30
+ "typescript": "^6.0.3",
31
+ "vitest": "^4.1.10"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.json",