@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 +3 -4
- package/dist/server.js +467 -25
- package/package.json +7 -7
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
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
|
-
? ["
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
"@clearance/management": "0.1
|
|
24
|
+
"hono": "^4.12.30",
|
|
25
|
+
"@clearance/management": "0.2.1"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@types/node": "^22.
|
|
29
|
-
"tsx": "^4.
|
|
30
|
-
"typescript": "^
|
|
31
|
-
"vitest": "^4.1.
|
|
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",
|