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