@antasphere/chassis-contract 0.0.0-stage → 1.0.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.
Files changed (124) hide show
  1. package/LICENSE +82 -0
  2. package/dist/define.d.ts +177 -0
  3. package/dist/define.d.ts.map +1 -0
  4. package/dist/define.js +35 -0
  5. package/dist/define.js.map +1 -0
  6. package/dist/entitlements.d.ts +265 -0
  7. package/dist/entitlements.d.ts.map +1 -0
  8. package/dist/entitlements.js +155 -0
  9. package/dist/entitlements.js.map +1 -0
  10. package/dist/identity.d.ts +56 -0
  11. package/dist/identity.d.ts.map +1 -0
  12. package/dist/identity.js +2 -0
  13. package/dist/identity.js.map +1 -0
  14. package/dist/index.d.ts +22 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +28 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/routes/index.d.ts +5403 -0
  19. package/dist/routes/index.d.ts.map +1 -0
  20. package/dist/routes/index.js +940 -0
  21. package/dist/routes/index.js.map +1 -0
  22. package/dist/schemas/api-keys.d.ts +54 -0
  23. package/dist/schemas/api-keys.d.ts.map +1 -0
  24. package/dist/schemas/api-keys.js +48 -0
  25. package/dist/schemas/api-keys.js.map +1 -0
  26. package/dist/schemas/audit.d.ts +74 -0
  27. package/dist/schemas/audit.d.ts.map +1 -0
  28. package/dist/schemas/audit.js +65 -0
  29. package/dist/schemas/audit.js.map +1 -0
  30. package/dist/schemas/break-glass.d.ts +42 -0
  31. package/dist/schemas/break-glass.d.ts.map +1 -0
  32. package/dist/schemas/break-glass.js +38 -0
  33. package/dist/schemas/break-glass.js.map +1 -0
  34. package/dist/schemas/cli-auth.d.ts +59 -0
  35. package/dist/schemas/cli-auth.d.ts.map +1 -0
  36. package/dist/schemas/cli-auth.js +68 -0
  37. package/dist/schemas/cli-auth.js.map +1 -0
  38. package/dist/schemas/common.d.ts +96 -0
  39. package/dist/schemas/common.d.ts.map +1 -0
  40. package/dist/schemas/common.js +205 -0
  41. package/dist/schemas/common.js.map +1 -0
  42. package/dist/schemas/demo-passes.d.ts +90 -0
  43. package/dist/schemas/demo-passes.d.ts.map +1 -0
  44. package/dist/schemas/demo-passes.js +60 -0
  45. package/dist/schemas/demo-passes.js.map +1 -0
  46. package/dist/schemas/files.d.ts +39 -0
  47. package/dist/schemas/files.d.ts.map +1 -0
  48. package/dist/schemas/files.js +26 -0
  49. package/dist/schemas/files.js.map +1 -0
  50. package/dist/schemas/instance.d.ts +58 -0
  51. package/dist/schemas/instance.d.ts.map +1 -0
  52. package/dist/schemas/instance.js +70 -0
  53. package/dist/schemas/instance.js.map +1 -0
  54. package/dist/schemas/invitations.d.ts +100 -0
  55. package/dist/schemas/invitations.d.ts.map +1 -0
  56. package/dist/schemas/invitations.js +55 -0
  57. package/dist/schemas/invitations.js.map +1 -0
  58. package/dist/schemas/me.d.ts +81 -0
  59. package/dist/schemas/me.d.ts.map +1 -0
  60. package/dist/schemas/me.js +178 -0
  61. package/dist/schemas/me.js.map +1 -0
  62. package/dist/schemas/members.d.ts +60 -0
  63. package/dist/schemas/members.d.ts.map +1 -0
  64. package/dist/schemas/members.js +43 -0
  65. package/dist/schemas/members.js.map +1 -0
  66. package/dist/schemas/projects.d.ts +232 -0
  67. package/dist/schemas/projects.d.ts.map +1 -0
  68. package/dist/schemas/projects.js +140 -0
  69. package/dist/schemas/projects.js.map +1 -0
  70. package/dist/schemas/scope.d.ts +14 -0
  71. package/dist/schemas/scope.d.ts.map +1 -0
  72. package/dist/schemas/scope.js +5 -0
  73. package/dist/schemas/scope.js.map +1 -0
  74. package/dist/schemas/setup.d.ts +28 -0
  75. package/dist/schemas/setup.d.ts.map +1 -0
  76. package/dist/schemas/setup.js +28 -0
  77. package/dist/schemas/setup.js.map +1 -0
  78. package/dist/schemas/sso-connect.d.ts +30 -0
  79. package/dist/schemas/sso-connect.d.ts.map +1 -0
  80. package/dist/schemas/sso-connect.js +38 -0
  81. package/dist/schemas/sso-connect.js.map +1 -0
  82. package/dist/schemas/sso-logout.d.ts +17 -0
  83. package/dist/schemas/sso-logout.d.ts.map +1 -0
  84. package/dist/schemas/sso-logout.js +16 -0
  85. package/dist/schemas/sso-logout.js.map +1 -0
  86. package/dist/schemas/teams.d.ts +100 -0
  87. package/dist/schemas/teams.d.ts.map +1 -0
  88. package/dist/schemas/teams.js +84 -0
  89. package/dist/schemas/teams.js.map +1 -0
  90. package/dist/schemas/workspaces.d.ts +99 -0
  91. package/dist/schemas/workspaces.d.ts.map +1 -0
  92. package/dist/schemas/workspaces.js +96 -0
  93. package/dist/schemas/workspaces.js.map +1 -0
  94. package/dist/seams.d.ts +185 -0
  95. package/dist/seams.d.ts.map +1 -0
  96. package/dist/seams.js +37 -0
  97. package/dist/seams.js.map +1 -0
  98. package/package.json +41 -3
  99. package/src/define.ts +64 -0
  100. package/src/entitlements.ts +340 -0
  101. package/src/identity.ts +49 -0
  102. package/src/index.ts +37 -0
  103. package/src/routes/index.ts +1146 -0
  104. package/src/schemas/api-keys.ts +53 -0
  105. package/src/schemas/audit.ts +74 -0
  106. package/src/schemas/break-glass.ts +46 -0
  107. package/src/schemas/cli-auth.ts +78 -0
  108. package/src/schemas/common.ts +206 -0
  109. package/src/schemas/demo-passes.ts +73 -0
  110. package/src/schemas/files.ts +30 -0
  111. package/src/schemas/instance.ts +71 -0
  112. package/src/schemas/invitations.ts +66 -0
  113. package/src/schemas/me.ts +184 -0
  114. package/src/schemas/members.ts +53 -0
  115. package/src/schemas/projects.ts +174 -0
  116. package/src/schemas/scope.ts +18 -0
  117. package/src/schemas/setup.ts +31 -0
  118. package/src/schemas/sso-connect.ts +40 -0
  119. package/src/schemas/sso-logout.ts +17 -0
  120. package/src/schemas/teams.ts +101 -0
  121. package/src/schemas/workspaces.ts +109 -0
  122. package/src/seams.ts +202 -0
  123. package/test/define.test.ts +59 -0
  124. package/README.md +0 -3
@@ -0,0 +1,940 @@
1
+ import { createRoute, z } from '@hono/zod-openapi';
2
+ import { apiErrorSchema, cursorPageQuerySchema } from '../schemas/common.js';
3
+ import { instanceInfoSchema } from '../schemas/instance.js';
4
+ import { setupRequestSchema, setupResponseSchema } from '../schemas/setup.js';
5
+ import { onboardingDismissedSchema } from '../schemas/me.js';
6
+ import { defaultWorkspaceSchema, defaultWorkspaceSetSchema, workspaceCreatedSchema, workspaceCreateSchema, workspaceUpdatedSchema, workspaceUpdateSchema } from '../schemas/workspaces.js';
7
+ import { memberChangeEmailLinkRequestSchema, memberChangeEmailLinkSchema, memberResetLinkSchema, membersListSchema, memberSchema, memberUpdateSchema } from '../schemas/members.js';
8
+ import { invitationAcceptedSchema, invitationAcceptSchema, invitationCreatedSchema, invitationCreateSchema, invitationLookupSchema, invitationsListSchema } from '../schemas/invitations.js';
9
+ import { auditListQuerySchema, auditListSchema } from '../schemas/audit.js';
10
+ import { cliAuthCompleteSchema, cliAuthRequestedSchema, cliAuthRequestSchema, cliAuthRevokedSchema } from '../schemas/cli-auth.js';
11
+ import { ssoCliConnectSchema } from '../schemas/sso-connect.js';
12
+ import { ssoLogoutResponseSchema } from '../schemas/sso-logout.js';
13
+ import { breakGlassClaimOwnershipRequestSchema, breakGlassClaimOwnershipSchema, breakGlassResetTwoFactorRequestSchema, breakGlassResetTwoFactorSchema } from '../schemas/break-glass.js';
14
+ import { fileSchema, filesListSchema, fileUploadedSchema, fileUploadQuerySchema } from '../schemas/files.js';
15
+ import { demoPassesListSchema, demoPassMintedSchema, demoPassMintSchema, demoPassSchema } from '../schemas/demo-passes.js';
16
+ import { projectCreateSchema, projectMemberAddSchema, projectMemberParamsSchema, projectMemberRoleSchema, projectMemberSchema, projectMembersListSchema, projectPersonMemberSchema, projectSchema, projectsListQuerySchema, projectsListSchema, projectTeamMemberSchema, projectTeamParamsSchema, projectUpdateSchema } from '../schemas/projects.js';
17
+ import { teamCreateSchema, teamMemberAddSchema, teamMemberParamsSchema, teamMemberSchema, teamMembersListSchema, teamSchema, teamsListQuerySchema, teamsListSchema, teamUpdateSchema } from '../schemas/teams.js';
18
+ /**
19
+ * Server-only entry: route contracts for @hono/zod-openapi. Importing this
20
+ * path pulls Hono — the dashboard and SDK must import the package root
21
+ * instead. The OpenAPI document at /api/v1/openapi.json is generated from
22
+ * these definitions; they are the single source of truth for the API shape.
23
+ */
24
+ export const jsonBody = (schema, description) => ({
25
+ content: { 'application/json': { schema } },
26
+ description
27
+ });
28
+ /**
29
+ * JSON REQUEST bodies are marked `required` so @hono/zod-openapi always runs
30
+ * the body validator. Without the flag, a request with a missing/non-JSON
31
+ * content-type SKIPS validation entirely and hands the handler `{}` cast as
32
+ * the body type — a guaranteed TypeError → 500 on every pre-auth body route
33
+ * (the schema-mismatch 400 path never runs). Responses keep plain jsonBody
34
+ * (`required` is not a response-object field).
35
+ */
36
+ export const jsonRequestBody = (schema, description) => ({
37
+ ...jsonBody(schema, description),
38
+ required: true
39
+ });
40
+ export const errorResponses = {
41
+ 400: jsonBody(apiErrorSchema, 'Validation error'),
42
+ 401: jsonBody(apiErrorSchema, 'Not authenticated'),
43
+ 403: jsonBody(apiErrorSchema, 'Not authorized'),
44
+ 404: jsonBody(apiErrorSchema, 'Not found'),
45
+ 410: jsonBody(apiErrorSchema, 'Gone'),
46
+ 429: jsonBody(apiErrorSchema, 'Rate limited'),
47
+ 500: jsonBody(apiErrorSchema, 'Internal error')
48
+ };
49
+ /** Opt-in retry-safe creates: replay-or-409 semantics per docs/security/security.md. */
50
+ export const idempotencyHeaders = z.object({
51
+ 'idempotency-key': z.string().min(1).max(200).optional()
52
+ });
53
+ /**
54
+ * Every `{id}` path param is a UUID, validated in the contract: a non-UUID
55
+ * id answers a clean 400 validation_error instead of reaching Postgres and
56
+ * surfacing its uuid-cast error as a sanitized 500 (see LESSONS.md).
57
+ */
58
+ export const uuidParams = z.object({ id: z.uuid() });
59
+ export const instanceRoute = createRoute({
60
+ method: 'get',
61
+ path: '/instance',
62
+ tags: ['instance'],
63
+ summary: 'Instance discovery (unauthenticated, cacheable)',
64
+ responses: {
65
+ 200: jsonBody(instanceInfoSchema, 'Instance descriptor')
66
+ }
67
+ });
68
+ export const setupRoute = createRoute({
69
+ method: 'post',
70
+ path: '/setup',
71
+ tags: ['instance'],
72
+ summary: 'One-shot first-boot setup: create the owner and the workspace',
73
+ request: {
74
+ body: jsonRequestBody(setupRequestSchema, 'Setup payload')
75
+ },
76
+ responses: {
77
+ 201: jsonBody(setupResponseSchema, 'Instance initialized'),
78
+ 400: errorResponses[400],
79
+ 403: errorResponses[403],
80
+ 410: errorResponses[410],
81
+ 500: errorResponses[500]
82
+ }
83
+ });
84
+ // ── Onboarding dismiss (cloud edition only) ─────────────────────────────────
85
+ // SESSION-ONLY like /sso/logout: deliberately UNLISTED in the machine scope
86
+ // allowlist (fail-closed 403 for keys/tokens); the handler resolves the
87
+ // session route-locally so zero-membership sessions can dismiss too. The
88
+ // route exists ONLY on EDITION=cloud; oss answers the JSON 404 terminator.
89
+ export const onboardingDismissRoute = createRoute({
90
+ method: 'post',
91
+ path: '/me/onboarding/dismiss',
92
+ tags: ['auth'],
93
+ summary: 'Dismiss the first-run welcome (cloud edition; sessions only; idempotent)',
94
+ responses: {
95
+ 200: jsonBody(onboardingDismissedSchema, 'Dismissal recorded — firstRunPending is now false'),
96
+ 401: errorResponses[401],
97
+ 403: errorResponses[403]
98
+ }
99
+ });
100
+ // ── Workspace creation (PRDCT-2444 / PRDCT-2443) ────────────────────────────
101
+ // SESSION-ONLY: deliberately UNLISTED in the machine scope allowlist
102
+ // (fail-closed 403 for keys/tokens) and re-checked in the handler. One route
103
+ // for both editions: local creation on self-hosted, creation at the hub AS
104
+ // THE CALLER + immediate local projection on cloud.
105
+ export const workspaceCreateRoute = createRoute({
106
+ method: 'post',
107
+ path: '/workspaces',
108
+ tags: ['workspaces'],
109
+ summary: 'Create another workspace with the caller as its owner (sessions only)',
110
+ request: { body: jsonRequestBody(workspaceCreateSchema, 'The new workspace'), headers: idempotencyHeaders },
111
+ responses: {
112
+ 201: jsonBody(workspaceCreatedSchema, 'The created workspace (its LOCAL id)'),
113
+ 400: errorResponses[400],
114
+ 401: jsonBody(apiErrorSchema, 'Not authenticated; hub_grant_expired or hub_reauth_required on cloud — both healed by signing in again'),
115
+ 403: jsonBody(apiErrorSchema, 'session_required, guest_forbidden, workspace_creation_disabled, workspace_limit_reached, hub_link_required, hub_unavailable, hub_refused'),
116
+ 409: jsonBody(apiErrorSchema, 'Idempotency conflict'),
117
+ 429: errorResponses[429]
118
+ }
119
+ });
120
+ // ── The person's default workspace (PRDCT-2815) ─────────────────────────────
121
+ // A setting of the PERSON, not of a workspace: the route names no workspace
122
+ // in its path and is open to every credential of the person (the machine
123
+ // scope allowlist lists it under the write scope, so the CLI sets it), a key
124
+ // pinned to one workspace excepted.
125
+ export const defaultWorkspaceSetRoute = createRoute({
126
+ method: 'put',
127
+ path: '/me/default-workspace',
128
+ tags: ['workspaces'],
129
+ summary: 'Choose the workspace a request naming none resolves to, or clear the choice (self-hosted)',
130
+ request: {
131
+ body: jsonRequestBody(defaultWorkspaceSetSchema, 'A workspace the caller is an active member of, or null')
132
+ },
133
+ responses: {
134
+ 200: jsonBody(defaultWorkspaceSchema, 'The default as it now is'),
135
+ 400: errorResponses[400],
136
+ 401: errorResponses[401],
137
+ 403: jsonBody(apiErrorSchema, 'hub_managed (cloud: set on the account site), key_pinned'),
138
+ 404: jsonBody(apiErrorSchema, 'Not a workspace the caller is an active member of')
139
+ }
140
+ });
141
+ // ── Members ──────────────────────────────────────────────────────────────────
142
+ export const workspaceUpdateRoute = createRoute({
143
+ method: 'patch',
144
+ path: '/workspace',
145
+ tags: ['workspaces'],
146
+ summary: "Change the active workspace's name or look (owner/admin, sessions only)",
147
+ request: { body: jsonRequestBody(workspaceUpdateSchema, 'The name, the look, or both') },
148
+ responses: {
149
+ 200: jsonBody(workspaceUpdatedSchema, 'The workspace as it now is'),
150
+ 400: errorResponses[400],
151
+ 401: errorResponses[401],
152
+ 403: jsonBody(apiErrorSchema, 'session_required, insufficient_role, guest_forbidden, hub_managed (a rename of a hub-origin workspace)')
153
+ }
154
+ });
155
+ export const membersListRoute = createRoute({
156
+ method: 'get',
157
+ path: '/members',
158
+ tags: ['members'],
159
+ summary: 'List workspace members (cursor-paginated)',
160
+ request: { query: cursorPageQuerySchema },
161
+ responses: { 200: jsonBody(membersListSchema, 'Members, newest first'), 401: errorResponses[401] }
162
+ });
163
+ export const memberUpdateRoute = createRoute({
164
+ method: 'patch',
165
+ path: '/members/{id}',
166
+ tags: ['members'],
167
+ summary: 'Change a member role or active state (admin+)',
168
+ request: {
169
+ params: uuidParams,
170
+ body: jsonRequestBody(memberUpdateSchema, 'Fields to change')
171
+ },
172
+ responses: {
173
+ 200: jsonBody(memberSchema, 'Updated member'),
174
+ 400: errorResponses[400],
175
+ 401: errorResponses[401],
176
+ 403: errorResponses[403],
177
+ 404: errorResponses[404]
178
+ }
179
+ });
180
+ export const memberDeleteRoute = createRoute({
181
+ method: 'delete',
182
+ path: '/members/{id}',
183
+ tags: ['members'],
184
+ summary: 'Delete a member account (admin+; sessions only — files they uploaded stay)',
185
+ request: { params: uuidParams },
186
+ responses: {
187
+ 200: jsonBody(memberSchema, 'Deleted member (final snapshot)'),
188
+ 400: errorResponses[400],
189
+ 401: errorResponses[401],
190
+ 403: errorResponses[403],
191
+ 404: errorResponses[404],
192
+ 409: jsonBody(apiErrorSchema, 'Account belongs to other workspaces (member_of_other_workspaces)')
193
+ }
194
+ });
195
+ export const memberRemoveRoute = createRoute({
196
+ method: 'post',
197
+ path: '/members/{id}/remove',
198
+ tags: ['members'],
199
+ summary: 'Remove a member from the workspace: the membership off, their project grants and team seats deleted, the account kept (admin+; sessions only)',
200
+ request: { params: uuidParams },
201
+ responses: {
202
+ 200: jsonBody(memberSchema, 'The removed member (inactive)'),
203
+ 400: jsonBody(apiErrorSchema, 'cannot_remove_self, last_owner'),
204
+ 401: errorResponses[401],
205
+ 403: jsonBody(apiErrorSchema, 'insufficient_role, forbidden (an owner is removed by an owner), hub_managed'),
206
+ 404: errorResponses[404]
207
+ }
208
+ });
209
+ export const memberResetLinkRoute = createRoute({
210
+ method: 'post',
211
+ path: '/members/{id}/reset-link',
212
+ tags: ['members'],
213
+ summary: 'Generate a one-time password reset link for a member (OWNER only)',
214
+ request: { params: uuidParams, headers: idempotencyHeaders },
215
+ responses: {
216
+ 200: jsonBody(memberResetLinkSchema, 'Copyable reset link + expiry'),
217
+ 400: errorResponses[400],
218
+ 401: errorResponses[401],
219
+ 403: errorResponses[403],
220
+ 404: errorResponses[404],
221
+ 409: jsonBody(apiErrorSchema, 'Idempotency conflict')
222
+ }
223
+ });
224
+ export const memberChangeEmailLinkRoute = createRoute({
225
+ method: 'post',
226
+ path: '/members/{id}/change-email-link',
227
+ tags: ['members'],
228
+ summary: 'Generate a one-time email change link for a member (OWNER only; the link also signs them in)',
229
+ request: {
230
+ params: uuidParams,
231
+ body: jsonRequestBody(memberChangeEmailLinkRequestSchema, 'The new email address'),
232
+ headers: idempotencyHeaders
233
+ },
234
+ responses: {
235
+ 200: jsonBody(memberChangeEmailLinkSchema, 'Copyable email change link + expiry'),
236
+ 400: errorResponses[400],
237
+ 401: errorResponses[401],
238
+ 403: errorResponses[403],
239
+ 404: errorResponses[404],
240
+ 409: jsonBody(apiErrorSchema, 'Email already in use (email_taken) or idempotency conflict')
241
+ }
242
+ });
243
+ // ── Projects ─────────────────────────────────────────────────────────────────
244
+ // A subgroup of a workspace with its own members and roles. The tiered answer
245
+ // on every route: 404 to whoever cannot read the project (its existence is
246
+ // not probeable), 403 to a proven reader who may not do the act, 409
247
+ // `project_archived` on every mutation of an archived project but unarchive.
248
+ // Project membership is LOCAL to the tool on both editions: these routes are
249
+ // never under the hub-managed gate.
250
+ const projectErrors = {
251
+ 400: errorResponses[400],
252
+ 401: errorResponses[401],
253
+ 403: jsonBody(apiErrorSchema, 'guest_forbidden, or insufficient_project_role for a proven reader'),
254
+ 404: errorResponses[404]
255
+ };
256
+ const projectArchived409 = jsonBody(apiErrorSchema, 'The project is archived (project_archived)');
257
+ export const projectsListRoute = createRoute({
258
+ method: 'get',
259
+ path: '/projects',
260
+ tags: ['projects'],
261
+ summary: 'List the projects the caller belongs to (every project for a workspace owner or admin)',
262
+ request: { query: projectsListQuerySchema },
263
+ responses: {
264
+ 200: jsonBody(projectsListSchema, 'Projects, newest first'),
265
+ 400: errorResponses[400],
266
+ 401: errorResponses[401],
267
+ 403: errorResponses[403]
268
+ }
269
+ });
270
+ export const projectCreateRoute = createRoute({
271
+ method: 'post',
272
+ path: '/projects',
273
+ tags: ['projects'],
274
+ summary: 'Create a project; the caller becomes its first manager',
275
+ request: { body: jsonRequestBody(projectCreateSchema, 'The new project'), headers: idempotencyHeaders },
276
+ responses: {
277
+ 201: jsonBody(projectSchema, 'The created project'),
278
+ 400: errorResponses[400],
279
+ 401: errorResponses[401],
280
+ 403: errorResponses[403],
281
+ 409: jsonBody(apiErrorSchema, 'Idempotency conflict')
282
+ }
283
+ });
284
+ export const projectGetRoute = createRoute({
285
+ method: 'get',
286
+ path: '/projects/{id}',
287
+ tags: ['projects'],
288
+ summary: "Read a project, with the caller's own role on it",
289
+ request: { params: uuidParams },
290
+ responses: { 200: jsonBody(projectSchema, 'The project'), ...projectErrors }
291
+ });
292
+ export const projectUpdateRoute = createRoute({
293
+ method: 'patch',
294
+ path: '/projects/{id}',
295
+ tags: ['projects'],
296
+ summary: "Change a project's name, description or metadata (manager)",
297
+ request: { params: uuidParams, body: jsonRequestBody(projectUpdateSchema, 'Fields to change') },
298
+ responses: {
299
+ 200: jsonBody(projectSchema, 'The project as it now is'),
300
+ ...projectErrors,
301
+ 409: projectArchived409
302
+ }
303
+ });
304
+ export const projectArchiveRoute = createRoute({
305
+ method: 'post',
306
+ path: '/projects/{id}/archive',
307
+ tags: ['projects'],
308
+ summary: 'Archive a project: out of the default list and read-only (manager). A project is never deleted',
309
+ request: { params: uuidParams },
310
+ responses: {
311
+ 200: jsonBody(projectSchema, 'The archived project'),
312
+ ...projectErrors,
313
+ 409: projectArchived409
314
+ }
315
+ });
316
+ export const projectUnarchiveRoute = createRoute({
317
+ method: 'post',
318
+ path: '/projects/{id}/unarchive',
319
+ tags: ['projects'],
320
+ summary: 'Bring an archived project back (manager)',
321
+ request: { params: uuidParams },
322
+ responses: {
323
+ 200: jsonBody(projectSchema, 'The project, live again'),
324
+ ...projectErrors,
325
+ 409: jsonBody(apiErrorSchema, 'The project is not archived (project_not_archived)')
326
+ }
327
+ });
328
+ export const projectMembersListRoute = createRoute({
329
+ method: 'get',
330
+ path: '/projects/{id}/members',
331
+ tags: ['projects'],
332
+ summary: "List a project's members and their roles (cursor-paginated)",
333
+ request: { params: uuidParams, query: cursorPageQuerySchema },
334
+ responses: { 200: jsonBody(projectMembersListSchema, 'Members, newest first'), ...projectErrors }
335
+ });
336
+ export const projectMemberAddRoute = createRoute({
337
+ method: 'post',
338
+ path: '/projects/{id}/members',
339
+ tags: ['projects'],
340
+ summary: "Add one of the workspace's own active members (by user id or email) or one of its teams (by id) to a project (manager)",
341
+ request: {
342
+ params: uuidParams,
343
+ body: jsonRequestBody(projectMemberAddSchema, 'Who, and with which role'),
344
+ headers: idempotencyHeaders
345
+ },
346
+ responses: {
347
+ 201: jsonBody(projectMemberSchema, 'The new project member, a person or a team'),
348
+ ...projectErrors,
349
+ 403: jsonBody(apiErrorSchema, 'guest_forbidden, insufficient_project_role, or guest_target'),
350
+ 404: jsonBody(apiErrorSchema, 'Project not found, member_not_found or team_not_found in this workspace'),
351
+ 409: jsonBody(apiErrorSchema, 'project_archived, already_member, or idempotency conflict')
352
+ }
353
+ });
354
+ export const projectMemberRoleRoute = createRoute({
355
+ method: 'patch',
356
+ path: '/projects/{id}/members/{userId}',
357
+ tags: ['projects'],
358
+ summary: "Change a project member's role (manager)",
359
+ request: {
360
+ params: projectMemberParamsSchema,
361
+ body: jsonRequestBody(projectMemberRoleSchema, 'The new role')
362
+ },
363
+ responses: {
364
+ 200: jsonBody(projectPersonMemberSchema, 'The member as it now is'),
365
+ ...projectErrors,
366
+ 409: projectArchived409
367
+ }
368
+ });
369
+ export const projectMemberRemoveRoute = createRoute({
370
+ method: 'delete',
371
+ path: '/projects/{id}/members/{userId}',
372
+ tags: ['projects'],
373
+ summary: 'Remove a member from a project (manager; a member may remove themselves)',
374
+ request: { params: projectMemberParamsSchema },
375
+ responses: {
376
+ 200: jsonBody(projectPersonMemberSchema, 'The removed member (final snapshot)'),
377
+ ...projectErrors,
378
+ 409: projectArchived409
379
+ }
380
+ });
381
+ export const projectTeamRoleRoute = createRoute({
382
+ method: 'patch',
383
+ path: '/projects/{id}/teams/{teamId}',
384
+ tags: ['projects'],
385
+ summary: "Change a team's role on a project (manager)",
386
+ request: {
387
+ params: projectTeamParamsSchema,
388
+ body: jsonRequestBody(projectMemberRoleSchema, 'The new role')
389
+ },
390
+ responses: {
391
+ 200: jsonBody(projectTeamMemberSchema, 'The team entry as it now is'),
392
+ ...projectErrors,
393
+ 409: projectArchived409
394
+ }
395
+ });
396
+ export const projectTeamRemoveRoute = createRoute({
397
+ method: 'delete',
398
+ path: '/projects/{id}/teams/{teamId}',
399
+ tags: ['projects'],
400
+ summary: 'Remove a team from a project (manager); its people keep their own entries',
401
+ request: { params: projectTeamParamsSchema },
402
+ responses: {
403
+ 200: jsonBody(projectTeamMemberSchema, 'The removed team entry (final snapshot)'),
404
+ ...projectErrors,
405
+ 409: projectArchived409
406
+ }
407
+ });
408
+ // ── Teams ────────────────────────────────────────────────────────────────────
409
+ // Named groups of the workspace's people, on both editions (PRDCT-2813). Every
410
+ // non-guest member reads them; owners and admins write, and in a hub-origin
411
+ // workspace every write answers 403 `hub_managed` (the members' pattern).
412
+ /** The refusals every team route shares (the writes add their own). */
413
+ const teamErrors = {
414
+ 401: errorResponses[401],
415
+ 403: jsonBody(apiErrorSchema, 'guest_forbidden, forbidden (not an owner or admin), or hub_managed'),
416
+ 404: errorResponses[404]
417
+ };
418
+ const slugTaken409 = jsonBody(apiErrorSchema, 'Another team of this workspace already uses that slug (slug_taken)');
419
+ export const teamsListRoute = createRoute({
420
+ method: 'get',
421
+ path: '/teams',
422
+ tags: ['teams'],
423
+ summary: "List the workspace's teams (every member; cursor-paginated)",
424
+ request: { query: teamsListQuerySchema },
425
+ responses: {
426
+ 200: jsonBody(teamsListSchema, 'Teams, newest first'),
427
+ 401: errorResponses[401],
428
+ 403: teamErrors[403]
429
+ }
430
+ });
431
+ export const teamGetRoute = createRoute({
432
+ method: 'get',
433
+ path: '/teams/{id}',
434
+ tags: ['teams'],
435
+ summary: 'Read one team of the workspace (every member)',
436
+ request: { params: uuidParams },
437
+ responses: { 200: jsonBody(teamSchema, 'The team'), ...teamErrors }
438
+ });
439
+ export const teamCreateRoute = createRoute({
440
+ method: 'post',
441
+ path: '/teams',
442
+ tags: ['teams'],
443
+ summary: 'Create a team (owner or admin; refused in a hub-origin workspace)',
444
+ request: { body: jsonRequestBody(teamCreateSchema, 'Name, optional slug'), headers: idempotencyHeaders },
445
+ responses: {
446
+ 201: jsonBody(teamSchema, 'The created team'),
447
+ 400: errorResponses[400],
448
+ ...teamErrors,
449
+ 409: jsonBody(apiErrorSchema, 'slug_taken, or idempotency conflict')
450
+ }
451
+ });
452
+ export const teamUpdateRoute = createRoute({
453
+ method: 'patch',
454
+ path: '/teams/{id}',
455
+ tags: ['teams'],
456
+ summary: 'Rename a team or change its slug (owner or admin; refused in a hub-origin workspace)',
457
+ request: { params: uuidParams, body: jsonRequestBody(teamUpdateSchema, 'Fields to change') },
458
+ responses: {
459
+ 200: jsonBody(teamSchema, 'The updated team'),
460
+ 400: errorResponses[400],
461
+ ...teamErrors,
462
+ 409: slugTaken409
463
+ }
464
+ });
465
+ export const teamDeleteRoute = createRoute({
466
+ method: 'delete',
467
+ path: '/teams/{id}',
468
+ tags: ['teams'],
469
+ summary: 'Delete a team (owner or admin; the memberships stay; refused in a hub-origin workspace)',
470
+ request: { params: uuidParams },
471
+ responses: { 200: jsonBody(teamSchema, 'The deleted team (final snapshot)'), ...teamErrors }
472
+ });
473
+ export const teamMembersListRoute = createRoute({
474
+ method: 'get',
475
+ path: '/teams/{id}/members',
476
+ tags: ['teams'],
477
+ summary: "List a team's members (every member of the workspace; cursor-paginated)",
478
+ request: { params: uuidParams, query: cursorPageQuerySchema },
479
+ responses: { 200: jsonBody(teamMembersListSchema, 'Team members, newest first'), ...teamErrors }
480
+ });
481
+ export const teamMemberAddRoute = createRoute({
482
+ method: 'post',
483
+ path: '/teams/{id}/members',
484
+ tags: ['teams'],
485
+ summary: "Seat one of the workspace's own active members in a team, by user id or email (owner or admin)",
486
+ request: {
487
+ params: uuidParams,
488
+ body: jsonRequestBody(teamMemberAddSchema, 'Who'),
489
+ headers: idempotencyHeaders
490
+ },
491
+ responses: {
492
+ 201: jsonBody(teamMemberSchema, 'The team member'),
493
+ 400: errorResponses[400],
494
+ ...teamErrors,
495
+ 404: jsonBody(apiErrorSchema, 'Team not found, or member_not_found in this workspace'),
496
+ 409: jsonBody(apiErrorSchema, 'already_member, or idempotency conflict')
497
+ }
498
+ });
499
+ export const teamMemberRemoveRoute = createRoute({
500
+ method: 'delete',
501
+ path: '/teams/{id}/members/{userId}',
502
+ tags: ['teams'],
503
+ summary: 'Unseat a member from a team (owner or admin; the membership stays)',
504
+ request: { params: teamMemberParamsSchema },
505
+ responses: { 200: jsonBody(teamMemberSchema, 'The removed team member'), ...teamErrors }
506
+ });
507
+ // ── Demo passes ──────────────────────────────────────────────────────────────
508
+ // A link an owner mints so that one member is signed in without a password,
509
+ // for a demonstration (the demo pass spec). The three routes are registered
510
+ // ONLY while DEMO_SIGN_IN is on, on the self-hosted edition: otherwise each
511
+ // path answers what any unknown path answers. OWNER and SESSION only: an
512
+ // admin or a member gets 403, a machine credential never reaches them (the
513
+ // paths are unlisted in the scope allowlist). The redeem,
514
+ // `POST /auth/demo/redeem`, is an endpoint of the sign-in library and has no
515
+ // route contract here (its shapes are `demoPassRedeemSchema` and
516
+ // `demoPassRedeemedSchema`).
517
+ const demoPassErrors = {
518
+ 401: errorResponses[401],
519
+ 403: jsonBody(apiErrorSchema, 'forbidden (owners only), or sessions_only for a machine credential')
520
+ };
521
+ export const demoPassesListRoute = createRoute({
522
+ method: 'get',
523
+ path: '/demo/passes',
524
+ tags: ['demo'],
525
+ summary: "List the workspace's demo passes, newest first (owner; never carries a secret)",
526
+ responses: { 200: jsonBody(demoPassesListSchema, 'Demo passes, newest first'), ...demoPassErrors }
527
+ });
528
+ export const demoPassMintRoute = createRoute({
529
+ method: 'post',
530
+ path: '/demo/passes',
531
+ tags: ['demo'],
532
+ summary: 'Mint a demo pass for a member (owner); the secret is in this answer only',
533
+ request: { body: jsonRequestBody(demoPassMintSchema, 'The member, the page, the lifetime') },
534
+ responses: {
535
+ 201: jsonBody(demoPassMintedSchema, 'The pass, its secret and its link, shown once'),
536
+ 400: jsonBody(apiErrorSchema, 'validation_error (a lifetime out of 1 to 10080 minutes), or invalid_demo_path: not a same-origin path'),
537
+ 401: errorResponses[401],
538
+ 403: jsonBody(apiErrorSchema, 'In this order: owner_target (another owner), demo_address_required (not a demonstration address), two_factor_enrolled, guest_target, cross_workspace_target; or forbidden (owners only), sessions_only'),
539
+ 404: jsonBody(apiErrorSchema, 'no_such_member: no member of this workspace holds that address')
540
+ }
541
+ });
542
+ export const demoPassRevokeRoute = createRoute({
543
+ method: 'delete',
544
+ path: '/demo/passes/{id}',
545
+ tags: ['demo'],
546
+ summary: 'Revoke a demo pass (owner; idempotent). The sessions it opened end with it',
547
+ request: { params: uuidParams },
548
+ responses: {
549
+ 200: jsonBody(demoPassSchema, 'The pass, revoked'),
550
+ 400: errorResponses[400],
551
+ ...demoPassErrors,
552
+ 404: jsonBody(apiErrorSchema, 'not_found: no such pass in this workspace')
553
+ }
554
+ });
555
+ // ── Invitations ──────────────────────────────────────────────────────────────
556
+ export const invitationsListRoute = createRoute({
557
+ method: 'get',
558
+ path: '/invitations',
559
+ tags: ['invitations'],
560
+ summary: 'List invitations (admin+, cursor-paginated)',
561
+ request: { query: cursorPageQuerySchema },
562
+ responses: {
563
+ 200: jsonBody(invitationsListSchema, 'Invitations, newest first'),
564
+ 401: errorResponses[401],
565
+ 403: errorResponses[403]
566
+ }
567
+ });
568
+ export const invitationCreateRoute = createRoute({
569
+ method: 'post',
570
+ path: '/invitations',
571
+ tags: ['invitations'],
572
+ summary: 'Invite by email (admin+). Always returns a copyable accept link.',
573
+ request: { body: jsonRequestBody(invitationCreateSchema, 'Invitee'), headers: idempotencyHeaders },
574
+ responses: {
575
+ 201: jsonBody(invitationCreatedSchema, 'Invitation + copyable link'),
576
+ 400: errorResponses[400],
577
+ 401: errorResponses[401],
578
+ 403: errorResponses[403],
579
+ 409: jsonBody(apiErrorSchema, 'Already a member, already invited, or idempotency conflict')
580
+ }
581
+ });
582
+ export const invitationRevokeRoute = createRoute({
583
+ method: 'delete',
584
+ path: '/invitations/{id}',
585
+ tags: ['invitations'],
586
+ summary: 'Revoke an open invitation (admin+)',
587
+ request: { params: uuidParams },
588
+ responses: {
589
+ 200: jsonBody(invitationSchemaRef(), 'Revoked invitation'),
590
+ 401: errorResponses[401],
591
+ 403: errorResponses[403],
592
+ 404: errorResponses[404]
593
+ }
594
+ });
595
+ export const invitationLookupRoute = createRoute({
596
+ method: 'get',
597
+ path: '/invitations/lookup',
598
+ tags: ['invitations'],
599
+ summary: 'Resolve an invitation token (public; drives the accept page)',
600
+ request: { query: z.object({ token: z.string().min(16) }) },
601
+ responses: {
602
+ 200: jsonBody(invitationLookupSchema, 'Invitation context'),
603
+ 404: errorResponses[404]
604
+ }
605
+ });
606
+ export const invitationAcceptRoute = createRoute({
607
+ method: 'post',
608
+ path: '/invitations/accept',
609
+ tags: ['invitations'],
610
+ summary: 'Accept an invitation (public: creates the account when needed)',
611
+ request: { body: jsonRequestBody(invitationAcceptSchema, 'Token + credentials for new accounts') },
612
+ responses: {
613
+ 200: jsonBody(invitationAcceptedSchema, 'Joined the workspace'),
614
+ 400: errorResponses[400],
615
+ 403: jsonBody(apiErrorSchema, 'Workspace membership is hub-managed (hub_managed, cloud edition)'),
616
+ 404: errorResponses[404],
617
+ 409: jsonBody(apiErrorSchema, 'Account exists — sign in to accept'),
618
+ 410: errorResponses[410]
619
+ }
620
+ });
621
+ // ── CLI auth (browserless email-OTP → API key) ──────────────────────────────
622
+ // The MINT pair is PUBLIC pre-auth like /setup: listed in PUBLIC_API_PATHS
623
+ // (middleware/auth-context.ts) and deliberately UNLISTED in the machine
624
+ // scope allowlist — a key/token presented there is pointless anyway (the flow
625
+ // EXISTS to obtain a key) and 403s fail-closed. Sign-up stays closed: the
626
+ // flow rides the emailOTP plugin's disableSignUp, so codes only sign in
627
+ // EXISTING accounts. Both endpoints are rate-limited (request: the OTP wall
628
+ // per IP + email; complete: the login wall per IP + email) and OTP
629
+ // verification is better-auth's atomic, attempt-limited (3) check. On the
630
+ // CLOUD edition both refuse outright (403 cli_otp_disabled) — hub-only login
631
+ // (D1): CLI keys are minted via `antasphere login` + /sso/cli-connect there.
632
+ // DELETE /cli/auth/key is the logout counterpart: an AUTHENTICATED route that
633
+ // revokes exactly the PRESENTING key (self-revocation — possession is the
634
+ // authority to kill itself), machine-allowed under the tool's write scope in the
635
+ // scope allowlist, and open on BOTH editions (revocation narrows access).
636
+ export const cliAuthRequestRoute = createRoute({
637
+ method: 'post',
638
+ path: '/cli/auth/request',
639
+ tags: ['cli-auth'],
640
+ summary: 'Send a sign-in code to an email (public; generic success — no account enumeration)',
641
+ request: { body: jsonRequestBody(cliAuthRequestSchema, 'The account email') },
642
+ responses: {
643
+ 200: jsonBody(cliAuthRequestedSchema, 'Code sent if the account exists'),
644
+ 400: jsonBody(apiErrorSchema, 'Validation error, or no email driver (otp_unavailable)'),
645
+ 403: jsonBody(apiErrorSchema, 'cli_otp_disabled: cloud edition — sign in with `antasphere login`'),
646
+ 429: errorResponses[429],
647
+ 500: errorResponses[500]
648
+ }
649
+ });
650
+ export const cliAuthRevokeRoute = createRoute({
651
+ method: 'delete',
652
+ path: '/cli/auth/key',
653
+ tags: ['cli-auth'],
654
+ summary: 'Revoke the PRESENTING API key (CLI logout; self-revocation only)',
655
+ responses: {
656
+ 200: jsonBody(cliAuthRevokedSchema, 'The presenting key is revoked'),
657
+ 401: errorResponses[401],
658
+ 403: jsonBody(apiErrorSchema, 'The credential is not an API key (sessions manage keys in the dashboard)')
659
+ }
660
+ });
661
+ // ── SSO logout (cloud edition only) ──────────────────────────────────────────
662
+ // SESSION-ONLY by construction: deliberately UNLISTED in the machine scope
663
+ // allowlist (middleware/scopes.ts), so API keys and OAuth bearers 403
664
+ // fail-closed before the handler runs; the handler resolves the Better Auth
665
+ // session route-locally (the /me zero-state pattern) so ZERO-MEMBERSHIP
666
+ // sessions — the cloud operator pre-break-glass, a hub user whose last org
667
+ // was removed — can still log out. The route exists ONLY on EDITION=cloud;
668
+ // oss answers the JSON 404 terminator. Order inside the handler: resolve
669
+ // session → build the hub end-session URL (any failure → null) → revoke the
670
+ // local session server-side (better-auth's cookie-clearing Set-Cookie is
671
+ // forwarded) → clear the shared hint cookie → respond. The local revoke and
672
+ // hint clear happen even when url is null.
673
+ export const ssoLogoutRoute = createRoute({
674
+ method: 'post',
675
+ path: '/sso/logout',
676
+ tags: ['sso'],
677
+ summary: 'Single logout (cloud edition; sessions only): revokes the local session, clears the SSO hint cookie, ' +
678
+ 'and returns the hub end-session URL to visit (null = local signout only)',
679
+ responses: {
680
+ 200: jsonBody(ssoLogoutResponseSchema, 'Local session revoked; url is the hub logout leg or null'),
681
+ 401: errorResponses[401],
682
+ 403: errorResponses[403]
683
+ }
684
+ });
685
+ // ── Break-glass (superadmin recovery, ADR 010) ──────────────────────────────
686
+ // Session-only by construction: both paths are deliberately UNLISTED in the
687
+ // machine scope allowlist (middleware/scopes.ts), so API keys and OAuth
688
+ // tokens 403 fail-closed. The handlers additionally resolve the Better Auth
689
+ // session directly and require a verified email on the SUPERADMIN_EMAILS
690
+ // allowlist — no membership required (the recovering operator may have none).
691
+ export const breakGlassClaimOwnershipRoute = createRoute({
692
+ method: 'post',
693
+ path: '/admin/break-glass/claim-ownership',
694
+ tags: ['admin'],
695
+ summary: 'Break-glass: make the calling superadmin (or a named existing user) an ACTIVE OWNER of a workspace (superadmin sessions only; adds an owner, never removes one; workspaceId required once the instance has several)',
696
+ request: {
697
+ body: jsonRequestBody(breakGlassClaimOwnershipRequestSchema, 'Optional target user (default: the caller) and target workspace (required with several workspaces)')
698
+ },
699
+ responses: {
700
+ 200: jsonBody(breakGlassClaimOwnershipSchema, 'The recovered owner membership'),
701
+ 400: jsonBody(apiErrorSchema, 'Validation error, or several workspaces and none named (workspace_required)'),
702
+ 401: errorResponses[401],
703
+ 403: errorResponses[403],
704
+ 404: errorResponses[404],
705
+ 409: jsonBody(apiErrorSchema, 'Instance not set up yet (not_setup)'),
706
+ 429: errorResponses[429]
707
+ }
708
+ });
709
+ export const breakGlassResetTwoFactorRoute = createRoute({
710
+ method: 'post',
711
+ path: '/admin/break-glass/reset-2fa',
712
+ tags: ['admin'],
713
+ summary: "Break-glass: clear a locked-out user's 2FA (superadmin sessions only, audited)",
714
+ request: {
715
+ body: jsonRequestBody(breakGlassResetTwoFactorRequestSchema, 'The target user')
716
+ },
717
+ responses: {
718
+ 200: jsonBody(breakGlassResetTwoFactorSchema, '2FA cleared (idempotent)'),
719
+ 400: errorResponses[400],
720
+ 401: errorResponses[401],
721
+ 403: errorResponses[403],
722
+ 404: errorResponses[404],
723
+ 429: errorResponses[429]
724
+ }
725
+ });
726
+ // ── Audit ────────────────────────────────────────────────────────────────────
727
+ export const auditListRoute = createRoute({
728
+ method: 'get',
729
+ path: '/audit',
730
+ tags: ['audit'],
731
+ summary: 'Read the audit log (admin+, cursor-paginated, filterable)',
732
+ description: 'Every filter is optional and they combine with AND: `q` (actor email or action, case-insensitive), ' +
733
+ '`action` (comma-separated; an item ending in `.` matches the family), `actorVia` (comma-separated), ' +
734
+ '`actor` (a user id, or `system`), `resourceType`, `resourceId`, `from`/`to` (ISO 8601). ' +
735
+ 'The cursor stays the last row id; the same filters must ride every page of one listing. ' +
736
+ '`total` is counted on the first page only.',
737
+ request: { query: auditListQuerySchema },
738
+ responses: {
739
+ 200: jsonBody(auditListSchema, 'Audit entries, newest first'),
740
+ 400: errorResponses[400],
741
+ 401: errorResponses[401],
742
+ 403: errorResponses[403]
743
+ }
744
+ });
745
+ // Local alias to keep the revoke route readable without a forward reference.
746
+ function invitationSchemaRef() {
747
+ return invitationsListSchema.shape.invitations.element;
748
+ }
749
+ // ── Files ────────────────────────────────────────────────────────────────────
750
+ // Every read here is per-resource authorized (ADR 013): you get the blobs you
751
+ // uploaded plus those of the tool's resources you can read; workspace admins and
752
+ // owners get the whole workspace. Anything outside that scope answers 404,
753
+ // never 403 — a blob you cannot read must not be probeable.
754
+ export const filesListRoute = createRoute({
755
+ method: 'get',
756
+ path: '/files',
757
+ tags: ['files'],
758
+ summary: 'List the files you can read (cursor-paginated)',
759
+ request: { query: cursorPageQuerySchema },
760
+ responses: { 200: jsonBody(filesListSchema, 'Files, newest first'), 401: errorResponses[401] }
761
+ });
762
+ export const fileGetRoute = createRoute({
763
+ method: 'get',
764
+ path: '/files/{id}',
765
+ tags: ['files'],
766
+ summary: 'File metadata (404 when you cannot read the blob)',
767
+ request: { params: uuidParams },
768
+ responses: {
769
+ 200: jsonBody(fileSchema, 'File'),
770
+ 401: errorResponses[401],
771
+ 404: errorResponses[404]
772
+ }
773
+ });
774
+ export const fileUploadRoute = createRoute({
775
+ method: 'post',
776
+ path: '/files',
777
+ tags: ['files'],
778
+ summary: 'Upload raw bytes (streamed; content-type header = file type)',
779
+ request: {
780
+ query: fileUploadQuerySchema,
781
+ body: {
782
+ content: { 'application/octet-stream': { schema: z.any() } },
783
+ description: 'The file bytes'
784
+ }
785
+ },
786
+ responses: {
787
+ 201: jsonBody(fileUploadedSchema, 'Stored (content-addressed, idempotent per workspace)'),
788
+ 401: errorResponses[401],
789
+ 403: errorResponses[403],
790
+ 413: jsonBody(apiErrorSchema, 'Payload exceeds the instance size cap'),
791
+ 400: errorResponses[400]
792
+ }
793
+ });
794
+ /**
795
+ * The route contracts that carry something of the TOOL: every route whose
796
+ * request or response schema transitively holds `scopeSchema`, and the routes
797
+ * whose OpenAPI text names a scope or the tool's domain (the CLI key's grant,
798
+ * the export scope, the file-in-use refusal). Built from the contract
799
+ * `defineChassisContract({ scopes })` returned, from the tool's identity and
800
+ * from its wording of the document — no module-level state, one instantiation
801
+ * per tool, and no route object that no tool serves.
802
+ */
803
+ export function defineChassisRoutes(contract, identity, copy) {
804
+ const { meResponseSchema, apiKeyCreatedSchema, apiKeyCreateSchema, apiKeySchema, apiKeysListSchema, cliAuthCompletedSchema } = contract;
805
+ const meRoute = createRoute({
806
+ method: 'get',
807
+ path: '/me',
808
+ tags: ['auth'],
809
+ summary: 'Resolved identity of the calling principal',
810
+ responses: {
811
+ 200: jsonBody(meResponseSchema, 'The caller identity'),
812
+ 401: errorResponses[401]
813
+ }
814
+ });
815
+ // ── API keys ─────────────────────────────────────────────────────────────────
816
+ const apiKeysListRoute = createRoute({
817
+ method: 'get',
818
+ path: '/api-keys',
819
+ tags: ['api-keys'],
820
+ summary: 'List YOUR API keys (keys are user credentials; cursor-paginated)',
821
+ request: { query: cursorPageQuerySchema },
822
+ responses: { 200: jsonBody(apiKeysListSchema, 'Your API keys, newest first'), 401: errorResponses[401] }
823
+ });
824
+ const apiKeyCreateRoute = createRoute({
825
+ method: 'post',
826
+ path: '/api-keys',
827
+ tags: ['api-keys'],
828
+ summary: 'Mint an API key (sessions only — a key never mints a key). User-scoped by default; pass workspaceId to pin it to one workspace',
829
+ request: {
830
+ body: jsonRequestBody(apiKeyCreateSchema, 'Key name, scopes, and the optional workspace pin'),
831
+ headers: idempotencyHeaders
832
+ },
833
+ responses: {
834
+ 201: jsonBody(apiKeyCreatedSchema, 'Created; the full key appears only here'),
835
+ 400: errorResponses[400],
836
+ 401: errorResponses[401],
837
+ 403: jsonBody(apiErrorSchema, 'sessions_only, or no_membership for the requested pin'),
838
+ 409: jsonBody(apiErrorSchema, 'Idempotency conflict')
839
+ }
840
+ });
841
+ const apiKeyRevokeRoute = createRoute({
842
+ method: 'delete',
843
+ path: '/api-keys/{id}',
844
+ tags: ['api-keys'],
845
+ summary: 'Revoke an API key (its creator only — keys are user credentials)',
846
+ request: { params: uuidParams },
847
+ responses: {
848
+ 200: jsonBody(apiKeySchema, 'Revoked key'),
849
+ 401: errorResponses[401],
850
+ 403: errorResponses[403],
851
+ 404: errorResponses[404]
852
+ }
853
+ });
854
+ const cliAuthCompleteRoute = createRoute({
855
+ method: 'post',
856
+ path: '/cli/auth/complete',
857
+ tags: ['cli-auth'],
858
+ summary: `Verify a sign-in code and mint an API key (shown once, ${identity.cliKeyScopesLabel})`,
859
+ request: { body: jsonRequestBody(cliAuthCompleteSchema, 'Email + code (+ optional key name/TTL)') },
860
+ responses: {
861
+ 201: jsonBody(cliAuthCompletedSchema, 'Key minted; the full key appears only here'),
862
+ 400: jsonBody(apiErrorSchema, 'Validation error, or no email driver (otp_unavailable)'),
863
+ 401: jsonBody(apiErrorSchema, 'invalid_otp: wrong/expired code or no such account'),
864
+ 403: jsonBody(apiErrorSchema, 'two_factor_required, no active workspace membership, or cli_otp_disabled (cloud edition)'),
865
+ 429: jsonBody(apiErrorSchema, 'Too many attempts'),
866
+ 500: errorResponses[500]
867
+ }
868
+ });
869
+ // ── CLI cross-tool connect (cloud edition only) ─────────────────────────────
870
+ // PUBLIC pre-auth endpoint like /cli/auth/* (listed in PUBLIC_API_PATHS,
871
+ // unlisted in the machine scope allowlist): the hub-minted 120 s exchange
872
+ // JWT IS the credential — verified against the hub JWKS with hard iss/aud
873
+ // pinning, `purpose: 'sso-connect'` required, and the `jti` consumed
874
+ // one-time-use (a replay inside the TTL mints nothing). On success the user
875
+ // is JIT-provisioned through the SAME projection path as an SSO login and
876
+ // an ordinary API key is minted, bound to the projected workspace. The
877
+ // route exists ONLY on EDITION=cloud; oss answers 404. Rate-limited (the
878
+ // login wall, per IP).
879
+ const ssoCliConnectRoute = createRoute({
880
+ method: 'post',
881
+ path: '/sso/cli-connect',
882
+ tags: ['sso'],
883
+ summary: 'Exchange a hub-minted connect JWT for an API key (cloud edition; shown once)',
884
+ request: { body: jsonRequestBody(ssoCliConnectSchema, 'The hub exchange token') },
885
+ responses: {
886
+ 201: jsonBody(cliAuthCompletedSchema, 'Key minted; the full key appears only here'),
887
+ 400: jsonBody(apiErrorSchema, 'Validation error'),
888
+ 401: jsonBody(apiErrorSchema, 'invalid_token: bad signature/iss/aud/purpose, expired, or replayed jti'),
889
+ 403: jsonBody(apiErrorSchema, 'Provisioning refused (identity/email conflict, link refused) or hub_grant_missing: no usable hub grant — a key is never minted born-dead'),
890
+ 429: errorResponses[429],
891
+ 500: errorResponses[500]
892
+ }
893
+ });
894
+ // ── Workspace ────────────────────────────────────────────────────────────────
895
+ // The export route names the scope a key needs in its summary, and that
896
+ // name is the tool's (`identity.scopes.dataExport`).
897
+ const workspaceExportRoute = createRoute({
898
+ method: 'get',
899
+ path: '/workspace/export',
900
+ tags: ['workspace'],
901
+ summary: `Full workspace export as a streamed zip (admin+; keys need ${identity.scopes.dataExport})`,
902
+ responses: {
903
+ // Deliberately NO `content` key on the 200: that is what lets the
904
+ // api.openapi handler legally return a plain streamed Response.
905
+ 200: { description: 'Zip archive stream (application/zip)' },
906
+ 401: errorResponses[401],
907
+ 403: errorResponses[403],
908
+ 429: errorResponses[429]
909
+ }
910
+ });
911
+ // ── Files ────────────────────────────────────────────────────────────────────
912
+ // The delete route carries the tool's wording of its 409: what references a
913
+ // blob is the tool's to name (`copy.fileInUseOpenApi`).
914
+ const fileDeleteRoute = createRoute({
915
+ method: 'delete',
916
+ path: '/files/{id}',
917
+ tags: ['files'],
918
+ summary: 'Delete a file (metadata soft-deleted, blob removed)',
919
+ request: { params: uuidParams },
920
+ responses: {
921
+ 200: jsonBody(fileSchema, 'Deleted file'),
922
+ 401: errorResponses[401],
923
+ 404: errorResponses[404],
924
+ // ADR 011 blob-delete guard: a blob the tool still references is not
925
+ // deletable through the generic files surface.
926
+ 409: jsonBody(apiErrorSchema, copy.fileInUseOpenApi)
927
+ }
928
+ });
929
+ return {
930
+ meRoute,
931
+ apiKeysListRoute,
932
+ apiKeyCreateRoute,
933
+ apiKeyRevokeRoute,
934
+ cliAuthCompleteRoute,
935
+ ssoCliConnectRoute,
936
+ workspaceExportRoute,
937
+ fileDeleteRoute
938
+ };
939
+ }
940
+ //# sourceMappingURL=index.js.map