@opengeni/api-router 0.23.1 → 0.30.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/dist/app.d.ts +3 -2
- package/dist/app.js +5 -3
- package/dist/auth/canonical-human-session-admission.d.ts +27 -0
- package/dist/browser-auth-broker.d.ts +20 -0
- package/dist/browser-controller-authority.d.ts +61 -0
- package/dist/browser-network-route.d.ts +8 -0
- package/dist/browser-state-authority.d.ts +27 -0
- package/dist/{chunk-T4T2PGU4.js → chunk-2PQMSRCB.js} +37891 -17463
- package/dist/chunk-2PQMSRCB.js.map +1 -0
- package/dist/codemode.d.ts +23 -0
- package/dist/controller-data-plane.d.ts +12 -0
- package/dist/editable-artifact-live-hints.d.ts +11 -0
- package/dist/editable-artifact-native-kernel.d.ts +37 -0
- package/dist/editable-artifact-office-import.d.ts +22 -0
- package/dist/editable-artifact-production.d.ts +29 -0
- package/dist/editable-artifact-websocket.d.ts +49 -0
- package/dist/editable-artifact-workspace-files.d.ts +23 -0
- package/dist/github-browser-flow.d.ts +6 -0
- package/dist/http/auth.d.ts +1 -1
- package/dist/http/cors.d.ts +1 -0
- package/dist/http/sse.d.ts +18 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1796 -22
- package/dist/index.js.map +1 -1
- package/dist/integrations/api-integrations.d.ts +24 -0
- package/dist/integrations/atlassian.d.ts +176 -0
- package/dist/integrations/fiken.d.ts +233 -0
- package/dist/integrations/github-skill-source.d.ts +5 -0
- package/dist/integrations/google-drive.d.ts +85 -0
- package/dist/integrations/oauth-client.d.ts +22 -1
- package/dist/integrations/provider-oauth.d.ts +17 -0
- package/dist/integrations/slack-bot.d.ts +175 -1
- package/dist/integrations/slack-interactions.d.ts +24 -6
- package/dist/integrations/social-api.d.ts +2 -1
- package/dist/interaction-holder-heartbeat.d.ts +17 -0
- package/dist/interaction-metrics.d.ts +11 -0
- package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
- package/dist/mcp/editable-artifacts.d.ts +13 -0
- package/dist/mcp/scheduled-task-view.d.ts +168 -0
- package/dist/mcp/server.d.ts +16 -4
- package/dist/memory-slack-delivery.d.ts +9 -0
- package/dist/model-catalog.d.ts +1 -0
- package/dist/routes/api-integrations.d.ts +8 -0
- package/dist/routes/browser-identities.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +6 -0
- package/dist/routes/canonical-human-identities.d.ts +3 -0
- package/dist/routes/channels.d.ts +3 -0
- package/dist/routes/company-profile.d.ts +3 -0
- package/dist/routes/computer-sessions.d.ts +6 -0
- package/dist/routes/editable-artifacts.d.ts +44 -0
- package/dist/routes/integration-facets.d.ts +3 -0
- package/dist/routes/interaction-resources.d.ts +5 -0
- package/dist/routes/memory-slack-publications.d.ts +6 -0
- package/dist/routes/organization-memberships.d.ts +3 -0
- package/dist/routes/plugins.d.ts +8 -0
- package/dist/routes/sessions.d.ts +18 -2
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/slack-task-policy.d.ts +3 -0
- package/dist/routes/supergrok.d.ts +3 -0
- package/dist/routes/video-generation.d.ts +3 -0
- package/dist/sandbox/channel-a.d.ts +62 -2
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/metrics-ingestion.d.ts +6 -1
- package/dist/sandbox/viewer.d.ts +7 -3
- package/dist/slack-reaction-files.d.ts +27 -0
- package/dist/temporal-schedule-cleanup.d.ts +26 -0
- package/dist/transcription/providers/xai-subscription.d.ts +8 -0
- package/dist/xai-realtime.d.ts +26 -0
- package/dist/xai-subscription-auth.d.ts +23 -0
- package/package.json +20 -14
- package/src/app.ts +604 -64
- package/src/auth/canonical-human-session-admission.ts +116 -0
- package/src/auth/managed-auth.ts +101 -0
- package/src/browser-auth-broker.ts +155 -0
- package/src/browser-controller-authority.ts +186 -0
- package/src/browser-network-route.ts +34 -0
- package/src/browser-state-authority.ts +236 -0
- package/src/codemode.ts +186 -0
- package/src/controller-data-plane.ts +35 -0
- package/src/editable-artifact-live-hints.ts +64 -0
- package/src/editable-artifact-native-kernel.ts +659 -0
- package/src/editable-artifact-office-import.ts +230 -0
- package/src/editable-artifact-production.ts +419 -0
- package/src/editable-artifact-websocket.ts +311 -0
- package/src/editable-artifact-workspace-files.ts +186 -0
- package/src/github-browser-flow.ts +35 -6
- package/src/http/auth.ts +29 -9
- package/src/http/cors.ts +3 -0
- package/src/http/sse.ts +229 -5
- package/src/index.ts +119 -20
- package/src/integrations/api-integrations.ts +365 -0
- package/src/integrations/atlassian.ts +1621 -0
- package/src/integrations/fiken.ts +1230 -0
- package/src/integrations/github-skill-source.ts +142 -0
- package/src/integrations/google-drive.ts +1005 -64
- package/src/integrations/oauth-client.ts +123 -33
- package/src/integrations/provider-oauth.ts +756 -0
- package/src/integrations/slack-bot.ts +669 -11
- package/src/integrations/slack-interactions.ts +2063 -93
- package/src/integrations/social-api.ts +11 -0
- package/src/interaction-holder-heartbeat.ts +95 -0
- package/src/interaction-metrics.ts +159 -0
- package/src/mcp/documents.ts +146 -6
- package/src/mcp/editable-artifact-query-schema.ts +236 -0
- package/src/mcp/editable-artifacts.ts +448 -0
- package/src/mcp/scheduled-task-view.ts +34 -0
- package/src/mcp/server.ts +1756 -355
- package/src/memory-slack-delivery.ts +209 -0
- package/src/model-catalog.ts +19 -6
- package/src/routes/api-integrations.ts +410 -0
- package/src/routes/browser-identities.ts +160 -0
- package/src/routes/browser-sessions.ts +4482 -0
- package/src/routes/canonical-human-identities.ts +156 -0
- package/src/routes/channels.ts +90 -0
- package/src/routes/company-profile.ts +255 -0
- package/src/routes/computer-sessions.ts +1602 -0
- package/src/routes/connections.ts +421 -99
- package/src/routes/documents.ts +22 -3
- package/src/routes/editable-artifacts.ts +1159 -0
- package/src/routes/files.ts +106 -4
- package/src/routes/github.ts +18 -2
- package/src/routes/install.ts +7 -1
- package/src/routes/integration-facets.ts +258 -0
- package/src/routes/interaction-resources.ts +595 -0
- package/src/routes/memory-slack-publications.ts +216 -0
- package/src/routes/organization-memberships.ts +53 -0
- package/src/routes/packs.ts +437 -7
- package/src/routes/plugins.ts +752 -0
- package/src/routes/rigs.ts +77 -20
- package/src/routes/scheduled-tasks.ts +67 -40
- package/src/routes/sessions.ts +830 -278
- package/src/routes/skills.ts +256 -0
- package/src/routes/slack-task-policy.ts +115 -0
- package/src/routes/supergrok.ts +717 -0
- package/src/routes/transcription-recordings.ts +9 -2
- package/src/routes/transcriptions.ts +2 -1
- package/src/routes/video-generation.ts +162 -0
- package/src/routes/workspaces.ts +68 -28
- package/src/sandbox/channel-a.ts +750 -116
- package/src/sandbox/machines.ts +5 -5
- package/src/sandbox/metrics-ingestion.ts +121 -3
- package/src/sandbox/rematerialize.ts +35 -47
- package/src/sandbox/viewer.ts +39 -12
- package/src/slack-reaction-files.ts +181 -0
- package/src/temporal-schedule-cleanup.ts +135 -0
- package/src/transcription/providers/xai-subscription.ts +110 -0
- package/src/transcription/service.ts +17 -2
- package/src/xai-realtime.ts +216 -0
- package/src/xai-subscription-auth.ts +132 -0
- package/dist/chunk-T4T2PGU4.js.map +0 -1
- package/dist/mcp/toolspace.d.ts +0 -71
- package/src/mcp/toolspace.ts +0 -1190
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
import type { AccessGrant } from "@opengeni/contracts";
|
|
2
|
+
import { EDITABLE_ARTIFACT_KERNEL_VERSION_MAX_BYTES } from "@opengeni/contracts/editable-artifacts";
|
|
3
|
+
import {
|
|
4
|
+
EditableArtifactCompatibilityError,
|
|
5
|
+
EditableArtifactDurableExportError,
|
|
6
|
+
EditableArtifactDomainError,
|
|
7
|
+
EditableArtifactOfficeImportError,
|
|
8
|
+
editableArtifactClientTransactionId,
|
|
9
|
+
editableArtifactId,
|
|
10
|
+
editableArtifactReplicaId,
|
|
11
|
+
editableArtifactScope,
|
|
12
|
+
hasPermission,
|
|
13
|
+
requireAccessGrant,
|
|
14
|
+
requireSessionAuthorization,
|
|
15
|
+
SessionAuthorizationDeniedError,
|
|
16
|
+
SessionAuthorizationUnavailableError,
|
|
17
|
+
type AccessDeps,
|
|
18
|
+
type EditableArtifact,
|
|
19
|
+
type EditableArtifactActor,
|
|
20
|
+
type EditableArtifactApplicationPort,
|
|
21
|
+
type EditableArtifactDurableExportService,
|
|
22
|
+
type EditableArtifactMaterializationDownload,
|
|
23
|
+
type EditableArtifactMaterializationJob,
|
|
24
|
+
type EditableArtifactOfficeImportPort,
|
|
25
|
+
type EditableArtifactPinnedVersion,
|
|
26
|
+
type EditableArtifactModality,
|
|
27
|
+
} from "@opengeni/core";
|
|
28
|
+
import { listEditableArtifactIdsForSession } from "@opengeni/db";
|
|
29
|
+
import type { Context, Hono } from "hono";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
|
|
32
|
+
import { ApiHttpError } from "../http/api-error";
|
|
33
|
+
|
|
34
|
+
export const EDITABLE_ARTIFACT_HTTP_REQUEST_MAX_BYTES = 4 * 1024;
|
|
35
|
+
export const EDITABLE_ARTIFACT_EXPORT_REQUEST_MAX_BYTES = 260 * 1024;
|
|
36
|
+
export const EDITABLE_ARTIFACT_LIVE_TICKET_REQUEST_MAX_BYTES =
|
|
37
|
+
EDITABLE_ARTIFACT_HTTP_REQUEST_MAX_BYTES;
|
|
38
|
+
|
|
39
|
+
const portableId = /^[0-9a-f]+$/;
|
|
40
|
+
const liveTicketToken = /^[A-Za-z0-9._~-]+$/;
|
|
41
|
+
|
|
42
|
+
const ArtifactId = z
|
|
43
|
+
.string()
|
|
44
|
+
.length(32)
|
|
45
|
+
.regex(portableId)
|
|
46
|
+
.refine((value) => !/^0+$/.test(value));
|
|
47
|
+
const ReplicaId = z
|
|
48
|
+
.string()
|
|
49
|
+
.length(16)
|
|
50
|
+
.regex(portableId)
|
|
51
|
+
.refine((value) => !/^0+$/.test(value));
|
|
52
|
+
const BoundedIdentity = z
|
|
53
|
+
.string()
|
|
54
|
+
.min(1)
|
|
55
|
+
.max(256)
|
|
56
|
+
.refine((value) => value.trim() === value);
|
|
57
|
+
const PositiveProtocolVersion = z.number().int().positive().max(2_147_483_647);
|
|
58
|
+
const PositiveModelSchemaVersion = z.number().int().positive().max(65_535);
|
|
59
|
+
const VersionName = z
|
|
60
|
+
.string()
|
|
61
|
+
.min(1)
|
|
62
|
+
.refine(
|
|
63
|
+
(value) =>
|
|
64
|
+
utf8ByteLength(value) <= EDITABLE_ARTIFACT_KERNEL_VERSION_MAX_BYTES &&
|
|
65
|
+
value.trim() === value &&
|
|
66
|
+
isWellFormedPersistedText(value),
|
|
67
|
+
);
|
|
68
|
+
const AttemptId = z.string().uuid();
|
|
69
|
+
const SessionId = z.string().uuid();
|
|
70
|
+
const AttemptGeneration = z.number().int().positive().max(2_147_483_647);
|
|
71
|
+
const ListEditableArtifactsQuery = z
|
|
72
|
+
.object({
|
|
73
|
+
sourceSessionId: SessionId,
|
|
74
|
+
replicaId: ReplicaId,
|
|
75
|
+
})
|
|
76
|
+
.strict();
|
|
77
|
+
|
|
78
|
+
const MintEditableArtifactLiveTicketRequest = z
|
|
79
|
+
.object({
|
|
80
|
+
replicaId: ReplicaId,
|
|
81
|
+
protocolVersion: PositiveProtocolVersion,
|
|
82
|
+
kernelVersion: VersionName,
|
|
83
|
+
modelSchemaVersion: PositiveModelSchemaVersion,
|
|
84
|
+
})
|
|
85
|
+
.strict();
|
|
86
|
+
|
|
87
|
+
const CreateEditableArtifactRequest = z
|
|
88
|
+
.object({
|
|
89
|
+
replicaId: ReplicaId,
|
|
90
|
+
idempotencyKey: z
|
|
91
|
+
.string()
|
|
92
|
+
.min(1)
|
|
93
|
+
.max(200)
|
|
94
|
+
.regex(/^[A-Za-z0-9._:-]+$/u),
|
|
95
|
+
modality: z.enum(["document", "spreadsheet", "presentation"]),
|
|
96
|
+
title: z.string().refine(isValidArtifactTitle),
|
|
97
|
+
})
|
|
98
|
+
.strict();
|
|
99
|
+
|
|
100
|
+
const ImportEditableArtifactRequest = z
|
|
101
|
+
.object({
|
|
102
|
+
replicaId: ReplicaId,
|
|
103
|
+
idempotencyKey: z
|
|
104
|
+
.string()
|
|
105
|
+
.min(1)
|
|
106
|
+
.max(200)
|
|
107
|
+
.regex(/^[A-Za-z0-9._:-]+$/u),
|
|
108
|
+
modality: z.enum(["document", "spreadsheet", "presentation"]),
|
|
109
|
+
title: z.string().refine(isValidArtifactTitle),
|
|
110
|
+
sourceFileId: z
|
|
111
|
+
.string()
|
|
112
|
+
.uuid()
|
|
113
|
+
.refine((value) => value === value.toLowerCase()),
|
|
114
|
+
})
|
|
115
|
+
.strict();
|
|
116
|
+
|
|
117
|
+
const PinEditableArtifactVersionRequest = z
|
|
118
|
+
.object({
|
|
119
|
+
replicaId: ReplicaId,
|
|
120
|
+
idempotencyKey: z
|
|
121
|
+
.string()
|
|
122
|
+
.min(1)
|
|
123
|
+
.max(200)
|
|
124
|
+
.regex(/^[A-Za-z0-9._:-]+$/u),
|
|
125
|
+
name: z
|
|
126
|
+
.string()
|
|
127
|
+
.refine(
|
|
128
|
+
(value) =>
|
|
129
|
+
value.trim() === value &&
|
|
130
|
+
!/[\u0000-\u001f\u007f]/u.test(value) &&
|
|
131
|
+
utf8ByteLength(value) >= 1 &&
|
|
132
|
+
utf8ByteLength(value) <= 256 &&
|
|
133
|
+
isWellFormedPersistedText(value),
|
|
134
|
+
),
|
|
135
|
+
})
|
|
136
|
+
.strict();
|
|
137
|
+
|
|
138
|
+
const EnqueueEditableArtifactMaterializationRequest = z
|
|
139
|
+
.object({
|
|
140
|
+
replicaId: ReplicaId,
|
|
141
|
+
idempotencyKey: z
|
|
142
|
+
.string()
|
|
143
|
+
.min(1)
|
|
144
|
+
.max(200)
|
|
145
|
+
.regex(/^[A-Za-z0-9._:-]+$/u),
|
|
146
|
+
versionId: ArtifactId,
|
|
147
|
+
format: z.enum(["xlsx", "pptx", "docx", "pdf", "png", "webp"]),
|
|
148
|
+
options: z.record(z.string(), z.unknown()).optional(),
|
|
149
|
+
})
|
|
150
|
+
.strict();
|
|
151
|
+
|
|
152
|
+
const MintEditableArtifactLiveTicketResult = z
|
|
153
|
+
.object({
|
|
154
|
+
artifactId: ArtifactId,
|
|
155
|
+
modality: z.enum(["document", "spreadsheet", "presentation"]),
|
|
156
|
+
replicaId: ReplicaId,
|
|
157
|
+
token: z.string().min(1).max(4_096).regex(liveTicketToken),
|
|
158
|
+
expiresAt: z.string().datetime({ offset: true }),
|
|
159
|
+
protocolVersion: PositiveProtocolVersion,
|
|
160
|
+
})
|
|
161
|
+
.strict();
|
|
162
|
+
|
|
163
|
+
const EditableArtifactResult = z
|
|
164
|
+
.object({
|
|
165
|
+
id: ArtifactId,
|
|
166
|
+
modality: z.enum(["document", "spreadsheet", "presentation"]),
|
|
167
|
+
title: z.string().min(1).max(512),
|
|
168
|
+
lifecycle: z.enum(["active", "archived"]),
|
|
169
|
+
headSequence: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
|
170
|
+
stateHash: z.string().regex(/^sha256:[0-9a-f]{64}$/u),
|
|
171
|
+
createdAt: z.string().datetime({ offset: true }),
|
|
172
|
+
updatedAt: z.string().datetime({ offset: true }),
|
|
173
|
+
})
|
|
174
|
+
.strict();
|
|
175
|
+
|
|
176
|
+
const EditableArtifactListResult = z
|
|
177
|
+
.object({ artifacts: z.array(EditableArtifactResult).max(64) })
|
|
178
|
+
.strict();
|
|
179
|
+
|
|
180
|
+
const PinnedVersionResult = z
|
|
181
|
+
.object({
|
|
182
|
+
id: ArtifactId,
|
|
183
|
+
artifactId: ArtifactId,
|
|
184
|
+
modality: z.enum(["document", "spreadsheet", "presentation"]),
|
|
185
|
+
snapshotId: ArtifactId,
|
|
186
|
+
headSequence: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
|
187
|
+
causalFrontier: z
|
|
188
|
+
.array(
|
|
189
|
+
z
|
|
190
|
+
.object({
|
|
191
|
+
replicaId: ReplicaId,
|
|
192
|
+
counter: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
193
|
+
})
|
|
194
|
+
.strict(),
|
|
195
|
+
)
|
|
196
|
+
.nullable(),
|
|
197
|
+
nativeRevision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).nullable(),
|
|
198
|
+
stateHash: z.string().regex(/^sha256:[0-9a-f]{64}$/u),
|
|
199
|
+
name: z.string().min(1),
|
|
200
|
+
pinned: z.literal(true),
|
|
201
|
+
createdAt: z.string().datetime({ offset: true }),
|
|
202
|
+
replayed: z.boolean(),
|
|
203
|
+
})
|
|
204
|
+
.strict();
|
|
205
|
+
|
|
206
|
+
const MaterializationResult = z
|
|
207
|
+
.object({
|
|
208
|
+
id: ArtifactId,
|
|
209
|
+
byteSize: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
210
|
+
contentHash: z.string().regex(/^sha256:[0-9a-f]{64}$/u),
|
|
211
|
+
mimeType: z.enum([
|
|
212
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
213
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
214
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
215
|
+
"application/pdf",
|
|
216
|
+
"image/png",
|
|
217
|
+
"image/webp",
|
|
218
|
+
]),
|
|
219
|
+
verifiedAt: z.string().datetime({ offset: true }),
|
|
220
|
+
createdAt: z.string().datetime({ offset: true }),
|
|
221
|
+
})
|
|
222
|
+
.strict();
|
|
223
|
+
|
|
224
|
+
const MaterializationJobResult = z
|
|
225
|
+
.object({
|
|
226
|
+
id: ArtifactId,
|
|
227
|
+
artifactId: ArtifactId,
|
|
228
|
+
versionId: ArtifactId,
|
|
229
|
+
inputSnapshotId: ArtifactId,
|
|
230
|
+
targetHeadSequence: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
|
231
|
+
stateHash: z.string().regex(/^sha256:[0-9a-f]{64}$/u),
|
|
232
|
+
format: z.enum(["xlsx", "pptx", "docx", "pdf", "png", "webp"]),
|
|
233
|
+
state: z.enum(["pending", "running", "succeeded", "failed"]),
|
|
234
|
+
attemptCount: z.number().int().nonnegative().max(1_000),
|
|
235
|
+
errorCode: z.string().min(1).max(256).nullable(),
|
|
236
|
+
createdAt: z.string().datetime({ offset: true }),
|
|
237
|
+
startedAt: z.string().datetime({ offset: true }).nullable(),
|
|
238
|
+
completedAt: z.string().datetime({ offset: true }).nullable(),
|
|
239
|
+
result: MaterializationResult.nullable(),
|
|
240
|
+
replayed: z.boolean().optional(),
|
|
241
|
+
})
|
|
242
|
+
.strict();
|
|
243
|
+
|
|
244
|
+
function utf8ByteLength(value: string): number {
|
|
245
|
+
return new TextEncoder().encode(value).byteLength;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isWellFormedPersistedText(value: string): boolean {
|
|
249
|
+
if (value.includes("\0")) return false;
|
|
250
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
251
|
+
const codeUnit = value.charCodeAt(index);
|
|
252
|
+
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
|
253
|
+
const next = value.charCodeAt(index + 1);
|
|
254
|
+
if (!Number.isFinite(next) || next < 0xdc00 || next > 0xdfff) return false;
|
|
255
|
+
index += 1;
|
|
256
|
+
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isValidArtifactTitle(value: string): boolean {
|
|
264
|
+
const bytes = utf8ByteLength(value);
|
|
265
|
+
return bytes >= 1 && bytes <= 512 && value.trim() === value && isWellFormedPersistedText(value);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export type EditableArtifactRouteScope = Readonly<{
|
|
269
|
+
accountId: string;
|
|
270
|
+
workspaceId: string;
|
|
271
|
+
}>;
|
|
272
|
+
|
|
273
|
+
export type EditableArtifactRouteActor = EditableArtifactActor;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Authenticated transport context. Until a distinct editable-artifact
|
|
277
|
+
* capability is introduced, the existing workspace `artifacts:read` and
|
|
278
|
+
* `artifacts:publish` capabilities intentionally gate read and write here too;
|
|
279
|
+
* the application still performs the durable per-artifact authorization.
|
|
280
|
+
*/
|
|
281
|
+
export type EditableArtifactRouteAuthority = Readonly<{
|
|
282
|
+
scope: EditableArtifactRouteScope;
|
|
283
|
+
actor: EditableArtifactRouteActor;
|
|
284
|
+
}>;
|
|
285
|
+
|
|
286
|
+
export type MintEditableArtifactLiveTicketOutput = Readonly<{
|
|
287
|
+
artifactId: string;
|
|
288
|
+
modality: EditableArtifactModality;
|
|
289
|
+
replicaId: string;
|
|
290
|
+
token: string;
|
|
291
|
+
expiresAt: string;
|
|
292
|
+
protocolVersion: number;
|
|
293
|
+
}>;
|
|
294
|
+
|
|
295
|
+
export type EditableArtifactRouteDependencies = AccessDeps &
|
|
296
|
+
Readonly<{
|
|
297
|
+
editableArtifacts?: EditableArtifactApplicationPort;
|
|
298
|
+
editableArtifactExports?: EditableArtifactDurableExportService;
|
|
299
|
+
editableArtifactOfficeImports?: EditableArtifactOfficeImportPort;
|
|
300
|
+
/** Test/embedding seam; returned ids are still authorized one by one. */
|
|
301
|
+
editableArtifactSessionArtifactIds?: (
|
|
302
|
+
scope: EditableArtifactRouteScope,
|
|
303
|
+
sourceSessionId: string,
|
|
304
|
+
limit: number,
|
|
305
|
+
) => Promise<readonly string[]>;
|
|
306
|
+
}>;
|
|
307
|
+
|
|
308
|
+
export type EditableArtifactApplicationErrorCode =
|
|
309
|
+
| "not_found"
|
|
310
|
+
| "forbidden"
|
|
311
|
+
| "conflict"
|
|
312
|
+
| "unsupported_protocol"
|
|
313
|
+
| "limit_exceeded"
|
|
314
|
+
| "unavailable";
|
|
315
|
+
|
|
316
|
+
/** A bounded, deliberate failure emitted by the injected application boundary. */
|
|
317
|
+
export class EditableArtifactApplicationError extends Error {
|
|
318
|
+
constructor(readonly code: EditableArtifactApplicationErrorCode) {
|
|
319
|
+
super(code);
|
|
320
|
+
this.name = "EditableArtifactApplicationError";
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function registerEditableArtifactRoutes(
|
|
325
|
+
app: Hono,
|
|
326
|
+
deps: EditableArtifactRouteDependencies,
|
|
327
|
+
): void {
|
|
328
|
+
const collection = "/v1/workspaces/:workspaceId/editable-artifacts";
|
|
329
|
+
const item = `${collection}/:artifactId`;
|
|
330
|
+
|
|
331
|
+
app.get(collection, async (context) => {
|
|
332
|
+
const workspaceId = context.req.param("workspaceId");
|
|
333
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
|
|
334
|
+
const query = ListEditableArtifactsQuery.safeParse({
|
|
335
|
+
sourceSessionId: context.req.query("sourceSessionId"),
|
|
336
|
+
replicaId: context.req.query("replicaId"),
|
|
337
|
+
});
|
|
338
|
+
if (!query.success) {
|
|
339
|
+
throw new ApiHttpError(422, {
|
|
340
|
+
code: "validation_failed",
|
|
341
|
+
message: "Invalid editable artifact list query.",
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
await authorizeSourceSession(deps, grant, query.data.sourceSessionId);
|
|
345
|
+
const scope = editableArtifactScope({ accountId: grant.accountId, workspaceId });
|
|
346
|
+
const actor = editableArtifactActorForGrant(grant, query.data.replicaId);
|
|
347
|
+
const candidateIds = await (deps.editableArtifactSessionArtifactIds
|
|
348
|
+
? deps.editableArtifactSessionArtifactIds(scope, query.data.sourceSessionId, 64)
|
|
349
|
+
: listEditableArtifactIdsForSession(deps.db, scope, query.data.sourceSessionId, 64));
|
|
350
|
+
const artifactIds = normalizeArtifactIdList(candidateIds);
|
|
351
|
+
const application = requireEditableArtifactApplication(deps);
|
|
352
|
+
const artifacts: z.infer<typeof EditableArtifactResult>[] = [];
|
|
353
|
+
for (const artifactId of artifactIds) {
|
|
354
|
+
try {
|
|
355
|
+
const artifact = await application.readArtifact({
|
|
356
|
+
scope,
|
|
357
|
+
actor,
|
|
358
|
+
artifactId: editableArtifactId(artifactId),
|
|
359
|
+
});
|
|
360
|
+
artifacts.push(projectArtifact(artifact, scope));
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (isInvisibleListCandidate(error)) continue;
|
|
363
|
+
throw editableArtifactHttpError(error);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const result = EditableArtifactListResult.parse({ artifacts });
|
|
367
|
+
context.header("cache-control", "private, no-store");
|
|
368
|
+
return context.json(result, 200);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
app.post(collection, async (context) => {
|
|
372
|
+
const workspaceId = context.req.param("workspaceId");
|
|
373
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:publish");
|
|
374
|
+
const body = await parseBoundedCreateRequest(context);
|
|
375
|
+
const application = requireEditableArtifactApplication(deps);
|
|
376
|
+
const scope = editableArtifactScope({
|
|
377
|
+
accountId: grant.accountId,
|
|
378
|
+
workspaceId,
|
|
379
|
+
});
|
|
380
|
+
const actor = editableArtifactActorForGrant(grant, body.replicaId);
|
|
381
|
+
let artifact: EditableArtifact;
|
|
382
|
+
try {
|
|
383
|
+
artifact = await application.createArtifact({
|
|
384
|
+
scope,
|
|
385
|
+
actor,
|
|
386
|
+
idempotencyKey: editableArtifactClientTransactionId(body.idempotencyKey),
|
|
387
|
+
modality: body.modality as EditableArtifactModality,
|
|
388
|
+
title: body.title,
|
|
389
|
+
signal: context.req.raw.signal,
|
|
390
|
+
});
|
|
391
|
+
} catch (error) {
|
|
392
|
+
throw editableArtifactHttpError(error);
|
|
393
|
+
}
|
|
394
|
+
const result = projectArtifact(artifact, scope);
|
|
395
|
+
context.header("cache-control", "private, no-store");
|
|
396
|
+
return context.json(result, 201);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
app.post(`${collection}/imports`, async (context) => {
|
|
400
|
+
const workspaceId = context.req.param("workspaceId");
|
|
401
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:publish");
|
|
402
|
+
if (!hasPermission(grant.permissions, "files:read")) {
|
|
403
|
+
throw applicationHttpError("forbidden");
|
|
404
|
+
}
|
|
405
|
+
const body = await parseBoundedImportRequest(context);
|
|
406
|
+
const scope = editableArtifactScope({ accountId: grant.accountId, workspaceId });
|
|
407
|
+
const actor = editableArtifactActorForGrant(grant, body.replicaId);
|
|
408
|
+
try {
|
|
409
|
+
const prepared = await requireEditableArtifactOfficeImports(deps).prepare({
|
|
410
|
+
scope,
|
|
411
|
+
actor,
|
|
412
|
+
fileId: body.sourceFileId,
|
|
413
|
+
modality: body.modality,
|
|
414
|
+
signal: context.req.raw.signal,
|
|
415
|
+
});
|
|
416
|
+
const artifact = await requireEditableArtifactApplication(deps).importArtifact({
|
|
417
|
+
scope,
|
|
418
|
+
actor,
|
|
419
|
+
idempotencyKey: editableArtifactClientTransactionId(body.idempotencyKey),
|
|
420
|
+
modality: body.modality,
|
|
421
|
+
title: body.title,
|
|
422
|
+
originalImport: prepared.originalImport,
|
|
423
|
+
snapshot: prepared.snapshot,
|
|
424
|
+
signal: context.req.raw.signal,
|
|
425
|
+
});
|
|
426
|
+
context.header("cache-control", "private, no-store");
|
|
427
|
+
return context.json(projectArtifact(artifact, scope), 201);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (error instanceof EditableArtifactOfficeImportError) {
|
|
430
|
+
throw new ApiHttpError(error.code === "source_changed" ? 409 : 422, {
|
|
431
|
+
code: error.code === "source_changed" ? "conflict" : "validation_failed",
|
|
432
|
+
message:
|
|
433
|
+
error.code === "source_changed"
|
|
434
|
+
? "The workspace file changed during import."
|
|
435
|
+
: "The workspace file cannot be imported as this artifact modality.",
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
throw editableArtifactHttpError(error);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
app.get(item, async (context) => {
|
|
443
|
+
const workspaceId = context.req.param("workspaceId");
|
|
444
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
|
|
445
|
+
const artifactId = editableArtifactId(parseArtifactId(context.req.param("artifactId")));
|
|
446
|
+
const actor = editableArtifactActorForGrant(grant, context.req.query("replicaId") ?? "");
|
|
447
|
+
const scope = editableArtifactScope({
|
|
448
|
+
accountId: grant.accountId,
|
|
449
|
+
workspaceId,
|
|
450
|
+
});
|
|
451
|
+
let artifact: EditableArtifact;
|
|
452
|
+
try {
|
|
453
|
+
artifact = await requireEditableArtifactApplication(deps).readArtifact({
|
|
454
|
+
scope,
|
|
455
|
+
actor,
|
|
456
|
+
artifactId,
|
|
457
|
+
});
|
|
458
|
+
} catch (error) {
|
|
459
|
+
throw editableArtifactHttpError(error);
|
|
460
|
+
}
|
|
461
|
+
context.header("cache-control", "private, no-store");
|
|
462
|
+
return context.json(projectArtifact(artifact, scope), 200);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
app.post(`${item}/live-ticket`, async (context) => {
|
|
466
|
+
const workspaceId = context.req.param("workspaceId");
|
|
467
|
+
|
|
468
|
+
// Workspace authentication precedes path/body parsing. Exact artifact
|
|
469
|
+
// permission is intentionally enforced inside the injected application.
|
|
470
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
|
|
471
|
+
const artifactId = editableArtifactId(parseArtifactId(context.req.param("artifactId")));
|
|
472
|
+
const body = await parseBoundedTicketRequest(context);
|
|
473
|
+
const actor = editableArtifactActorForGrant(grant, body.replicaId);
|
|
474
|
+
const scope = editableArtifactScope({
|
|
475
|
+
accountId: grant.accountId,
|
|
476
|
+
workspaceId,
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
let rawTicket: MintEditableArtifactLiveTicketOutput;
|
|
480
|
+
try {
|
|
481
|
+
rawTicket = await requireEditableArtifactApplication(deps).mintLiveTicket({
|
|
482
|
+
scope,
|
|
483
|
+
actor,
|
|
484
|
+
artifactId,
|
|
485
|
+
protocolVersion: body.protocolVersion,
|
|
486
|
+
kernelVersion: body.kernelVersion,
|
|
487
|
+
modelSchemaVersion: body.modelSchemaVersion,
|
|
488
|
+
allowEdit: hasPermission(grant.permissions, "artifacts:publish"),
|
|
489
|
+
});
|
|
490
|
+
} catch (error) {
|
|
491
|
+
throw editableArtifactHttpError(error);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const parsedTicket = MintEditableArtifactLiveTicketResult.safeParse(rawTicket);
|
|
495
|
+
if (
|
|
496
|
+
!parsedTicket.success ||
|
|
497
|
+
parsedTicket.data.artifactId !== artifactId ||
|
|
498
|
+
parsedTicket.data.replicaId !== actor.replicaId ||
|
|
499
|
+
parsedTicket.data.protocolVersion !== body.protocolVersion ||
|
|
500
|
+
Date.parse(parsedTicket.data.expiresAt) <= Date.now()
|
|
501
|
+
) {
|
|
502
|
+
throw new Error("Editable artifact application returned an invalid live ticket");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
context.header("cache-control", "private, no-store");
|
|
506
|
+
return context.json(parsedTicket.data, 201);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
app.post(`${item}/versions`, async (context) => {
|
|
510
|
+
const workspaceId = context.req.param("workspaceId");
|
|
511
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
|
|
512
|
+
const artifactId = editableArtifactId(parseArtifactId(context.req.param("artifactId")));
|
|
513
|
+
const body = await parseBoundedVersionRequest(context);
|
|
514
|
+
const scope = editableArtifactScope({ accountId: grant.accountId, workspaceId });
|
|
515
|
+
const actor = editableArtifactActorForGrant(grant, body.replicaId);
|
|
516
|
+
try {
|
|
517
|
+
const result = await requireEditableArtifactExports(deps).pinVersion({
|
|
518
|
+
scope,
|
|
519
|
+
actor,
|
|
520
|
+
artifactId,
|
|
521
|
+
idempotencyKey: body.idempotencyKey,
|
|
522
|
+
name: body.name,
|
|
523
|
+
signal: context.req.raw.signal,
|
|
524
|
+
});
|
|
525
|
+
context.header("cache-control", "private, no-store");
|
|
526
|
+
return context.json(
|
|
527
|
+
projectPinnedVersion(result.version, scope, artifactId, result.replayed),
|
|
528
|
+
201,
|
|
529
|
+
);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
throw editableArtifactHttpError(error);
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
app.post(`${item}/materializations`, async (context) => {
|
|
536
|
+
const workspaceId = context.req.param("workspaceId");
|
|
537
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
|
|
538
|
+
const artifactId = editableArtifactId(parseArtifactId(context.req.param("artifactId")));
|
|
539
|
+
const body = await parseBoundedMaterializationRequest(context);
|
|
540
|
+
const scope = editableArtifactScope({ accountId: grant.accountId, workspaceId });
|
|
541
|
+
const actor = editableArtifactActorForGrant(grant, body.replicaId);
|
|
542
|
+
try {
|
|
543
|
+
const result = await requireEditableArtifactExports(deps).enqueueMaterialization({
|
|
544
|
+
scope,
|
|
545
|
+
actor,
|
|
546
|
+
artifactId,
|
|
547
|
+
idempotencyKey: body.idempotencyKey,
|
|
548
|
+
versionId: body.versionId,
|
|
549
|
+
format: body.format,
|
|
550
|
+
...(body.options ? { options: body.options } : {}),
|
|
551
|
+
});
|
|
552
|
+
context.header("cache-control", "private, no-store");
|
|
553
|
+
return context.json(
|
|
554
|
+
projectMaterializationJob(result.job, scope, artifactId, result.replayed),
|
|
555
|
+
201,
|
|
556
|
+
);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
throw editableArtifactHttpError(error);
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
app.get(`${item}/materializations/:jobId`, async (context) => {
|
|
563
|
+
const workspaceId = context.req.param("workspaceId");
|
|
564
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
|
|
565
|
+
const artifactId = editableArtifactId(parseArtifactId(context.req.param("artifactId")));
|
|
566
|
+
const jobId = parseArtifactId(context.req.param("jobId"));
|
|
567
|
+
const actor = editableArtifactActorForGrant(grant, context.req.query("replicaId") ?? "");
|
|
568
|
+
const scope = editableArtifactScope({ accountId: grant.accountId, workspaceId });
|
|
569
|
+
try {
|
|
570
|
+
const job = await requireEditableArtifactExports(deps).getMaterialization({
|
|
571
|
+
scope,
|
|
572
|
+
actor,
|
|
573
|
+
artifactId,
|
|
574
|
+
jobId,
|
|
575
|
+
});
|
|
576
|
+
context.header("cache-control", "private, no-store");
|
|
577
|
+
return context.json(projectMaterializationJob(job, scope, artifactId), 200);
|
|
578
|
+
} catch (error) {
|
|
579
|
+
throw editableArtifactHttpError(error);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
app.get(`${item}/materializations/:jobId/download`, async (context) => {
|
|
584
|
+
const workspaceId = context.req.param("workspaceId");
|
|
585
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
|
|
586
|
+
const artifactId = editableArtifactId(parseArtifactId(context.req.param("artifactId")));
|
|
587
|
+
const jobId = parseArtifactId(context.req.param("jobId"));
|
|
588
|
+
const actor = editableArtifactActorForGrant(grant, context.req.query("replicaId") ?? "");
|
|
589
|
+
const scope = editableArtifactScope({ accountId: grant.accountId, workspaceId });
|
|
590
|
+
let download: EditableArtifactMaterializationDownload;
|
|
591
|
+
try {
|
|
592
|
+
download = await requireEditableArtifactExports(deps).openMaterializationDownload({
|
|
593
|
+
scope,
|
|
594
|
+
actor,
|
|
595
|
+
artifactId,
|
|
596
|
+
jobId,
|
|
597
|
+
signal: context.req.raw.signal,
|
|
598
|
+
});
|
|
599
|
+
} catch (error) {
|
|
600
|
+
throw editableArtifactHttpError(error);
|
|
601
|
+
}
|
|
602
|
+
const stream = materializationDownloadStream(download, context.req.raw.signal);
|
|
603
|
+
return new Response(stream, {
|
|
604
|
+
status: 200,
|
|
605
|
+
headers: {
|
|
606
|
+
"cache-control": "private, no-store",
|
|
607
|
+
"content-disposition": `attachment; filename="artifact-${artifactId}.${download.format}"`,
|
|
608
|
+
"content-length": String(download.byteSize),
|
|
609
|
+
"content-type": download.mimeType,
|
|
610
|
+
etag: `"${download.contentHash}"`,
|
|
611
|
+
"x-content-type-options": "nosniff",
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function authorizeSourceSession(
|
|
618
|
+
deps: EditableArtifactRouteDependencies,
|
|
619
|
+
grant: AccessGrant,
|
|
620
|
+
sessionId: string,
|
|
621
|
+
): Promise<void> {
|
|
622
|
+
try {
|
|
623
|
+
await requireSessionAuthorization(deps, grant, {
|
|
624
|
+
sessionId,
|
|
625
|
+
operation: "session.read",
|
|
626
|
+
surface: "http",
|
|
627
|
+
});
|
|
628
|
+
} catch (error) {
|
|
629
|
+
if (error instanceof SessionAuthorizationDeniedError) {
|
|
630
|
+
throw new ApiHttpError(404, {
|
|
631
|
+
code: "not_found",
|
|
632
|
+
message: "Session not found.",
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
if (error instanceof SessionAuthorizationUnavailableError) {
|
|
636
|
+
throw new ApiHttpError(503, {
|
|
637
|
+
code: "upstream_unavailable",
|
|
638
|
+
message: "Session authorization is temporarily unavailable.",
|
|
639
|
+
retryable: true,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
throw error;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function requireEditableArtifactApplication(
|
|
647
|
+
deps: EditableArtifactRouteDependencies,
|
|
648
|
+
): EditableArtifactApplicationPort {
|
|
649
|
+
if (!deps.editableArtifacts) {
|
|
650
|
+
throw editableArtifactHttpError(new EditableArtifactApplicationError("unavailable"));
|
|
651
|
+
}
|
|
652
|
+
return deps.editableArtifacts;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function requireEditableArtifactExports(
|
|
656
|
+
deps: EditableArtifactRouteDependencies,
|
|
657
|
+
): EditableArtifactDurableExportService {
|
|
658
|
+
if (!deps.editableArtifactExports) {
|
|
659
|
+
throw editableArtifactHttpError(new EditableArtifactApplicationError("unavailable"));
|
|
660
|
+
}
|
|
661
|
+
return deps.editableArtifactExports;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function requireEditableArtifactOfficeImports(
|
|
665
|
+
deps: EditableArtifactRouteDependencies,
|
|
666
|
+
): EditableArtifactOfficeImportPort {
|
|
667
|
+
if (!deps.editableArtifactOfficeImports) {
|
|
668
|
+
throw editableArtifactHttpError(new EditableArtifactApplicationError("unavailable"));
|
|
669
|
+
}
|
|
670
|
+
return deps.editableArtifactOfficeImports;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function projectArtifact(
|
|
674
|
+
artifact: EditableArtifact,
|
|
675
|
+
expectedScope: EditableArtifactRouteScope,
|
|
676
|
+
): z.infer<typeof EditableArtifactResult> {
|
|
677
|
+
if (
|
|
678
|
+
artifact.scope.accountId !== expectedScope.accountId ||
|
|
679
|
+
artifact.scope.workspaceId !== expectedScope.workspaceId
|
|
680
|
+
) {
|
|
681
|
+
throw new Error("Editable artifact application returned cross-tenant metadata");
|
|
682
|
+
}
|
|
683
|
+
const parsed = EditableArtifactResult.safeParse({
|
|
684
|
+
id: artifact.id,
|
|
685
|
+
modality: artifact.modality,
|
|
686
|
+
title: artifact.title,
|
|
687
|
+
lifecycle: artifact.lifecycle,
|
|
688
|
+
headSequence: artifact.headSequence,
|
|
689
|
+
stateHash: artifact.stateHash,
|
|
690
|
+
createdAt: artifact.createdAt,
|
|
691
|
+
updatedAt: artifact.updatedAt,
|
|
692
|
+
});
|
|
693
|
+
if (!parsed.success) throw new Error("Editable artifact application returned invalid metadata");
|
|
694
|
+
return parsed.data;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function projectPinnedVersion(
|
|
698
|
+
version: EditableArtifactPinnedVersion,
|
|
699
|
+
expectedScope: EditableArtifactRouteScope,
|
|
700
|
+
expectedArtifactId: string,
|
|
701
|
+
replayed: boolean,
|
|
702
|
+
): z.infer<typeof PinnedVersionResult> {
|
|
703
|
+
if (
|
|
704
|
+
version.scope.accountId !== expectedScope.accountId ||
|
|
705
|
+
version.scope.workspaceId !== expectedScope.workspaceId ||
|
|
706
|
+
version.artifactId !== expectedArtifactId
|
|
707
|
+
) {
|
|
708
|
+
throw new Error("Editable artifact export returned cross-tenant version metadata");
|
|
709
|
+
}
|
|
710
|
+
const parsed = PinnedVersionResult.safeParse({
|
|
711
|
+
id: version.id,
|
|
712
|
+
artifactId: version.artifactId,
|
|
713
|
+
modality: version.modality,
|
|
714
|
+
snapshotId: version.snapshotId,
|
|
715
|
+
headSequence: version.headSequence,
|
|
716
|
+
causalFrontier: version.causalFrontier,
|
|
717
|
+
nativeRevision: version.nativeRevision,
|
|
718
|
+
stateHash: version.stateHash,
|
|
719
|
+
name: version.name,
|
|
720
|
+
pinned: version.pinned,
|
|
721
|
+
createdAt: version.createdAt,
|
|
722
|
+
replayed,
|
|
723
|
+
});
|
|
724
|
+
if (!parsed.success)
|
|
725
|
+
throw new Error("Editable artifact export returned invalid version metadata");
|
|
726
|
+
return parsed.data;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function projectMaterializationJob(
|
|
730
|
+
job: EditableArtifactMaterializationJob,
|
|
731
|
+
expectedScope: EditableArtifactRouteScope,
|
|
732
|
+
expectedArtifactId: string,
|
|
733
|
+
replayed?: boolean,
|
|
734
|
+
): z.infer<typeof MaterializationJobResult> {
|
|
735
|
+
if (
|
|
736
|
+
job.scope.accountId !== expectedScope.accountId ||
|
|
737
|
+
job.scope.workspaceId !== expectedScope.workspaceId ||
|
|
738
|
+
job.artifactId !== expectedArtifactId
|
|
739
|
+
) {
|
|
740
|
+
throw new Error("Editable artifact export returned cross-tenant job metadata");
|
|
741
|
+
}
|
|
742
|
+
const parsed = MaterializationJobResult.safeParse({
|
|
743
|
+
id: job.id,
|
|
744
|
+
artifactId: job.artifactId,
|
|
745
|
+
versionId: job.versionId,
|
|
746
|
+
inputSnapshotId: job.inputSnapshotId,
|
|
747
|
+
targetHeadSequence: job.targetHeadSequence,
|
|
748
|
+
stateHash: job.stateHash,
|
|
749
|
+
format: job.format,
|
|
750
|
+
state: job.state,
|
|
751
|
+
attemptCount: job.attemptCount,
|
|
752
|
+
errorCode: job.errorCode,
|
|
753
|
+
createdAt: job.createdAt,
|
|
754
|
+
startedAt: job.startedAt,
|
|
755
|
+
completedAt: job.completedAt,
|
|
756
|
+
result: job.result,
|
|
757
|
+
...(replayed === undefined ? {} : { replayed }),
|
|
758
|
+
});
|
|
759
|
+
if (!parsed.success) throw new Error("Editable artifact export returned invalid job metadata");
|
|
760
|
+
return parsed.data;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function materializationDownloadStream(
|
|
764
|
+
download: EditableArtifactMaterializationDownload,
|
|
765
|
+
signal: AbortSignal,
|
|
766
|
+
): ReadableStream<Uint8Array> {
|
|
767
|
+
const iterator = download.chunks({ signal })[Symbol.asyncIterator]();
|
|
768
|
+
let closed = false;
|
|
769
|
+
const close = async (): Promise<void> => {
|
|
770
|
+
if (closed) return;
|
|
771
|
+
closed = true;
|
|
772
|
+
await download.close();
|
|
773
|
+
};
|
|
774
|
+
return new ReadableStream<Uint8Array>({
|
|
775
|
+
async pull(controller) {
|
|
776
|
+
try {
|
|
777
|
+
const next = await iterator.next();
|
|
778
|
+
if (!next.done) {
|
|
779
|
+
controller.enqueue(next.value);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
await download.assertUnchanged(signal);
|
|
783
|
+
await close();
|
|
784
|
+
controller.close();
|
|
785
|
+
} catch {
|
|
786
|
+
await close();
|
|
787
|
+
controller.error(new Error("Editable artifact materialization download failed"));
|
|
788
|
+
}
|
|
789
|
+
},
|
|
790
|
+
async cancel() {
|
|
791
|
+
try {
|
|
792
|
+
await iterator.return?.();
|
|
793
|
+
} finally {
|
|
794
|
+
await close();
|
|
795
|
+
}
|
|
796
|
+
},
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
export function editableArtifactActorForGrant(
|
|
801
|
+
grant: AccessGrant,
|
|
802
|
+
rawReplicaId: string,
|
|
803
|
+
): EditableArtifactRouteActor {
|
|
804
|
+
const replicaId = editableArtifactReplicaId(parseReplicaId(rawReplicaId));
|
|
805
|
+
const subject = BoundedIdentity.safeParse(grant.subjectId);
|
|
806
|
+
if (!subject.success) throw invalidMachineAuthority("Invalid artifact actor subject");
|
|
807
|
+
|
|
808
|
+
const attemptValues = {
|
|
809
|
+
sessionId: grant.metadata?.["sessionId"],
|
|
810
|
+
turnId: grant.metadata?.["turnId"],
|
|
811
|
+
attemptId: grant.metadata?.["attemptId"],
|
|
812
|
+
generation: grant.metadata?.["executionGeneration"],
|
|
813
|
+
};
|
|
814
|
+
const hasAnyAttemptClaim = Object.values(attemptValues).some((value) => value !== undefined);
|
|
815
|
+
|
|
816
|
+
if (grant.principalKind === "human_session") {
|
|
817
|
+
if (hasAnyAttemptClaim || grant.serviceInitiator || grant.serviceInitiatorContext) {
|
|
818
|
+
throw invalidMachineAuthority("Human artifact authority contains machine claims");
|
|
819
|
+
}
|
|
820
|
+
return Object.freeze({ kind: "human", subjectId: subject.data, replicaId });
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (grant.principalKind === "agent_attempt") {
|
|
824
|
+
if (grant.serviceInitiator || grant.serviceInitiatorContext) {
|
|
825
|
+
throw invalidMachineAuthority("Agent artifact authority contains service claims");
|
|
826
|
+
}
|
|
827
|
+
const parsedAttempt = z
|
|
828
|
+
.object({
|
|
829
|
+
sessionId: AttemptId,
|
|
830
|
+
turnId: AttemptId,
|
|
831
|
+
attemptId: AttemptId,
|
|
832
|
+
generation: AttemptGeneration,
|
|
833
|
+
})
|
|
834
|
+
.strict()
|
|
835
|
+
.safeParse(attemptValues);
|
|
836
|
+
if (!parsedAttempt.success) {
|
|
837
|
+
throw invalidMachineAuthority("Agent artifact authority is incomplete");
|
|
838
|
+
}
|
|
839
|
+
return Object.freeze({
|
|
840
|
+
kind: "agent",
|
|
841
|
+
subjectId: subject.data,
|
|
842
|
+
replicaId,
|
|
843
|
+
...parsedAttempt.data,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (grant.principalKind === "service") {
|
|
848
|
+
if (hasAnyAttemptClaim || (grant.serviceInitiatorContext && !grant.serviceInitiator)) {
|
|
849
|
+
throw invalidMachineAuthority("Service artifact authority contains invalid claims");
|
|
850
|
+
}
|
|
851
|
+
// Keep delegated service provenance distinct from database-backed API keys
|
|
852
|
+
// and configured subjects with durable workspace memberships.
|
|
853
|
+
return Object.freeze({
|
|
854
|
+
kind: "service",
|
|
855
|
+
subjectId: subject.data,
|
|
856
|
+
replicaId,
|
|
857
|
+
service: "delegated_service",
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (grant.principalKind === "api_key" || grant.principalKind === "configured_key") {
|
|
862
|
+
if (hasAnyAttemptClaim || grant.serviceInitiator || grant.serviceInitiatorContext) {
|
|
863
|
+
throw invalidMachineAuthority("Key artifact authority contains machine claims");
|
|
864
|
+
}
|
|
865
|
+
return Object.freeze({
|
|
866
|
+
kind: "service",
|
|
867
|
+
subjectId: subject.data,
|
|
868
|
+
replicaId,
|
|
869
|
+
service: grant.principalKind,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
throw invalidMachineAuthority("Artifact authority has no trusted principal kind");
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function parseArtifactId(value: string): string {
|
|
877
|
+
const parsed = ArtifactId.safeParse(value);
|
|
878
|
+
if (!parsed.success) {
|
|
879
|
+
throw new ApiHttpError(422, {
|
|
880
|
+
code: "validation_failed",
|
|
881
|
+
message: "Invalid editable artifact id.",
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
return parsed.data;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function normalizeArtifactIdList(value: readonly string[]): readonly string[] {
|
|
888
|
+
if (!Array.isArray(value) || value.length > 64) {
|
|
889
|
+
throw new Error("Editable artifact list adapter returned too many candidates");
|
|
890
|
+
}
|
|
891
|
+
const ids = value.map((candidate) => {
|
|
892
|
+
const parsed = ArtifactId.safeParse(candidate);
|
|
893
|
+
if (!parsed.success) {
|
|
894
|
+
throw new Error("Editable artifact list adapter returned an invalid candidate");
|
|
895
|
+
}
|
|
896
|
+
return parsed.data;
|
|
897
|
+
});
|
|
898
|
+
if (new Set(ids).size !== ids.length) {
|
|
899
|
+
throw new Error("Editable artifact list adapter returned duplicate candidates");
|
|
900
|
+
}
|
|
901
|
+
return ids;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function isInvisibleListCandidate(error: unknown): boolean {
|
|
905
|
+
return (
|
|
906
|
+
(error instanceof EditableArtifactDomainError &&
|
|
907
|
+
(error.code === "forbidden" || error.code === "not_found")) ||
|
|
908
|
+
(error instanceof EditableArtifactApplicationError &&
|
|
909
|
+
(error.code === "forbidden" || error.code === "not_found"))
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function parseReplicaId(value: string): string {
|
|
914
|
+
const parsed = ReplicaId.safeParse(value);
|
|
915
|
+
if (!parsed.success) {
|
|
916
|
+
throw new ApiHttpError(422, {
|
|
917
|
+
code: "validation_failed",
|
|
918
|
+
message: "Invalid editable artifact replica id.",
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
return parsed.data;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function parseBoundedTicketRequest(
|
|
925
|
+
context: Context,
|
|
926
|
+
): Promise<z.infer<typeof MintEditableArtifactLiveTicketRequest>> {
|
|
927
|
+
const value = await readBoundedJson(
|
|
928
|
+
context.req.raw,
|
|
929
|
+
EDITABLE_ARTIFACT_LIVE_TICKET_REQUEST_MAX_BYTES,
|
|
930
|
+
);
|
|
931
|
+
const parsed = MintEditableArtifactLiveTicketRequest.safeParse(value);
|
|
932
|
+
if (!parsed.success) {
|
|
933
|
+
throw new ApiHttpError(422, {
|
|
934
|
+
code: "validation_failed",
|
|
935
|
+
message: "Invalid editable artifact live-ticket request.",
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
return parsed.data;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function parseBoundedCreateRequest(
|
|
942
|
+
context: Context,
|
|
943
|
+
): Promise<z.infer<typeof CreateEditableArtifactRequest>> {
|
|
944
|
+
const value = await readBoundedJson(context.req.raw, EDITABLE_ARTIFACT_HTTP_REQUEST_MAX_BYTES);
|
|
945
|
+
const parsed = CreateEditableArtifactRequest.safeParse(value);
|
|
946
|
+
if (!parsed.success) {
|
|
947
|
+
throw new ApiHttpError(422, {
|
|
948
|
+
code: "validation_failed",
|
|
949
|
+
message: "Invalid editable artifact create request.",
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
return parsed.data;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
async function parseBoundedImportRequest(
|
|
956
|
+
context: Context,
|
|
957
|
+
): Promise<z.infer<typeof ImportEditableArtifactRequest>> {
|
|
958
|
+
const value = await readBoundedJson(context.req.raw, EDITABLE_ARTIFACT_HTTP_REQUEST_MAX_BYTES);
|
|
959
|
+
const parsed = ImportEditableArtifactRequest.safeParse(value);
|
|
960
|
+
if (!parsed.success) {
|
|
961
|
+
throw new ApiHttpError(422, {
|
|
962
|
+
code: "validation_failed",
|
|
963
|
+
message: "Invalid editable artifact import request.",
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
return parsed.data;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function parseBoundedVersionRequest(
|
|
970
|
+
context: Context,
|
|
971
|
+
): Promise<z.infer<typeof PinEditableArtifactVersionRequest>> {
|
|
972
|
+
const value = await readBoundedJson(context.req.raw, EDITABLE_ARTIFACT_HTTP_REQUEST_MAX_BYTES);
|
|
973
|
+
const parsed = PinEditableArtifactVersionRequest.safeParse(value);
|
|
974
|
+
if (!parsed.success) {
|
|
975
|
+
throw new ApiHttpError(422, {
|
|
976
|
+
code: "validation_failed",
|
|
977
|
+
message: "Invalid editable artifact version request.",
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
return parsed.data;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function parseBoundedMaterializationRequest(
|
|
984
|
+
context: Context,
|
|
985
|
+
): Promise<z.infer<typeof EnqueueEditableArtifactMaterializationRequest>> {
|
|
986
|
+
const value = await readBoundedJson(context.req.raw, EDITABLE_ARTIFACT_EXPORT_REQUEST_MAX_BYTES);
|
|
987
|
+
const parsed = EnqueueEditableArtifactMaterializationRequest.safeParse(value);
|
|
988
|
+
if (!parsed.success) {
|
|
989
|
+
throw new ApiHttpError(422, {
|
|
990
|
+
code: "validation_failed",
|
|
991
|
+
message: "Invalid editable artifact materialization request.",
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
return parsed.data;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
async function readBoundedJson(request: Request, maxBytes: number): Promise<unknown> {
|
|
998
|
+
const declaredLength = request.headers.get("content-length");
|
|
999
|
+
if (declaredLength !== null) {
|
|
1000
|
+
const parsedLength = Number(declaredLength);
|
|
1001
|
+
if (!Number.isSafeInteger(parsedLength) || parsedLength < 0) {
|
|
1002
|
+
throw invalidTicketBody();
|
|
1003
|
+
}
|
|
1004
|
+
if (parsedLength > maxBytes) throw ticketBodyTooLarge();
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (!request.body) throw invalidTicketBody();
|
|
1008
|
+
const reader = request.body.getReader();
|
|
1009
|
+
const chunks: Uint8Array[] = [];
|
|
1010
|
+
let total = 0;
|
|
1011
|
+
// Bun's server-request reader currently throws from releaseLock(). The
|
|
1012
|
+
// bounded reader is discarded after complete consumption or cancellation,
|
|
1013
|
+
// so application code does not retain the lock.
|
|
1014
|
+
for (;;) {
|
|
1015
|
+
const { done, value } = await reader.read();
|
|
1016
|
+
if (done) break;
|
|
1017
|
+
total += value.byteLength;
|
|
1018
|
+
if (total > maxBytes) {
|
|
1019
|
+
await reader.cancel().catch(() => undefined);
|
|
1020
|
+
throw ticketBodyTooLarge();
|
|
1021
|
+
}
|
|
1022
|
+
chunks.push(value);
|
|
1023
|
+
}
|
|
1024
|
+
if (total === 0) throw invalidTicketBody();
|
|
1025
|
+
|
|
1026
|
+
const bytes = new Uint8Array(total);
|
|
1027
|
+
let offset = 0;
|
|
1028
|
+
for (const chunk of chunks) {
|
|
1029
|
+
bytes.set(chunk, offset);
|
|
1030
|
+
offset += chunk.byteLength;
|
|
1031
|
+
}
|
|
1032
|
+
let text: string;
|
|
1033
|
+
try {
|
|
1034
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1035
|
+
} catch {
|
|
1036
|
+
throw invalidTicketBody();
|
|
1037
|
+
}
|
|
1038
|
+
try {
|
|
1039
|
+
return JSON.parse(text) as unknown;
|
|
1040
|
+
} catch {
|
|
1041
|
+
throw invalidTicketBody();
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function editableArtifactHttpError(error: unknown): unknown {
|
|
1046
|
+
if (error instanceof EditableArtifactDurableExportError) {
|
|
1047
|
+
switch (error.code) {
|
|
1048
|
+
case "invalid_request":
|
|
1049
|
+
return new ApiHttpError(422, {
|
|
1050
|
+
code: "validation_failed",
|
|
1051
|
+
message: "Invalid editable artifact export request.",
|
|
1052
|
+
});
|
|
1053
|
+
case "not_found":
|
|
1054
|
+
return applicationHttpError("not_found");
|
|
1055
|
+
case "forbidden":
|
|
1056
|
+
return applicationHttpError("forbidden");
|
|
1057
|
+
case "conflict":
|
|
1058
|
+
return applicationHttpError("conflict");
|
|
1059
|
+
case "unsupported_format":
|
|
1060
|
+
return new ApiHttpError(422, {
|
|
1061
|
+
code: "validation_failed",
|
|
1062
|
+
message: "Editable artifact format is not available for this artifact.",
|
|
1063
|
+
});
|
|
1064
|
+
case "not_ready":
|
|
1065
|
+
return new ApiHttpError(409, {
|
|
1066
|
+
code: "conflict",
|
|
1067
|
+
message: "Editable artifact materialization is not ready.",
|
|
1068
|
+
retryable: true,
|
|
1069
|
+
});
|
|
1070
|
+
case "unavailable":
|
|
1071
|
+
return applicationHttpError("unavailable");
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (error instanceof EditableArtifactCompatibilityError) {
|
|
1075
|
+
return applicationHttpError(error.code);
|
|
1076
|
+
}
|
|
1077
|
+
if (error instanceof EditableArtifactDomainError) {
|
|
1078
|
+
switch (error.code) {
|
|
1079
|
+
case "not_found":
|
|
1080
|
+
return applicationHttpError("not_found");
|
|
1081
|
+
case "forbidden":
|
|
1082
|
+
return applicationHttpError("forbidden");
|
|
1083
|
+
case "idempotency_conflict":
|
|
1084
|
+
case "causal_future":
|
|
1085
|
+
case "causal_chain_conflict":
|
|
1086
|
+
case "retryable_conflict":
|
|
1087
|
+
case "snapshot_conflict":
|
|
1088
|
+
case "not_editable":
|
|
1089
|
+
return applicationHttpError("conflict");
|
|
1090
|
+
case "invalid_request":
|
|
1091
|
+
case "request_hash_mismatch":
|
|
1092
|
+
case "invalid_undo_target":
|
|
1093
|
+
return new ApiHttpError(422, {
|
|
1094
|
+
code: "validation_failed",
|
|
1095
|
+
message: "Invalid editable artifact request.",
|
|
1096
|
+
});
|
|
1097
|
+
case "kernel_contract_violation":
|
|
1098
|
+
case "outbox_lease_conflict":
|
|
1099
|
+
return applicationHttpError("unavailable");
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (!(error instanceof EditableArtifactApplicationError)) return error;
|
|
1103
|
+
return applicationHttpError(error.code);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function applicationHttpError(code: EditableArtifactApplicationErrorCode): ApiHttpError {
|
|
1107
|
+
switch (code) {
|
|
1108
|
+
case "not_found":
|
|
1109
|
+
return new ApiHttpError(404, {
|
|
1110
|
+
code: "not_found",
|
|
1111
|
+
message: "Editable artifact not found.",
|
|
1112
|
+
});
|
|
1113
|
+
case "forbidden":
|
|
1114
|
+
return new ApiHttpError(403, {
|
|
1115
|
+
code: "forbidden",
|
|
1116
|
+
message: "Editable artifact access denied.",
|
|
1117
|
+
});
|
|
1118
|
+
case "conflict":
|
|
1119
|
+
return new ApiHttpError(409, {
|
|
1120
|
+
code: "conflict",
|
|
1121
|
+
message: "Editable artifact state changed. Retry from current state.",
|
|
1122
|
+
});
|
|
1123
|
+
case "unsupported_protocol":
|
|
1124
|
+
return new ApiHttpError(409, {
|
|
1125
|
+
code: "conflict",
|
|
1126
|
+
message: "Editable artifact protocol is not supported.",
|
|
1127
|
+
});
|
|
1128
|
+
case "limit_exceeded":
|
|
1129
|
+
return new ApiHttpError(429, {
|
|
1130
|
+
code: "limit_exceeded",
|
|
1131
|
+
message: "Editable artifact live-session limit exceeded.",
|
|
1132
|
+
retryable: true,
|
|
1133
|
+
});
|
|
1134
|
+
case "unavailable":
|
|
1135
|
+
return new ApiHttpError(503, {
|
|
1136
|
+
code: "upstream_unavailable",
|
|
1137
|
+
message: "Editable artifact live service is temporarily unavailable.",
|
|
1138
|
+
retryable: true,
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function invalidMachineAuthority(message: string): ApiHttpError {
|
|
1144
|
+
return new ApiHttpError(403, { code: "forbidden", message });
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function invalidTicketBody(): ApiHttpError {
|
|
1148
|
+
return new ApiHttpError(422, {
|
|
1149
|
+
code: "validation_failed",
|
|
1150
|
+
message: "Invalid editable artifact live-ticket request.",
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function ticketBodyTooLarge(): ApiHttpError {
|
|
1155
|
+
return new ApiHttpError(413, {
|
|
1156
|
+
code: "validation_failed",
|
|
1157
|
+
message: "Editable artifact live-ticket request is too large.",
|
|
1158
|
+
});
|
|
1159
|
+
}
|