@opengeni/api-router 0.23.1 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +2 -2
- package/dist/app.js +1 -1
- package/dist/browser-controller-authority.d.ts +43 -0
- package/dist/browser-state-authority.d.ts +27 -0
- package/dist/{chunk-T4T2PGU4.js → chunk-JIKNR5YL.js} +26181 -14408
- package/dist/chunk-JIKNR5YL.js.map +1 -0
- package/dist/codemode.d.ts +23 -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/cors.d.ts +1 -0
- 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/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 +19 -0
- package/dist/integrations/slack-bot.d.ts +4 -0
- package/dist/integrations/slack-interactions.d.ts +8 -2
- package/dist/integrations/social-api.d.ts +2 -1
- 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 +14 -3
- package/dist/memory-slack-delivery.d.ts +9 -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/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-features.d.ts +3 -0
- package/dist/routes/memory-slack-publications.d.ts +6 -0
- package/dist/routes/plugins.d.ts +8 -0
- package/dist/routes/sessions.d.ts +17 -2
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/video-generation.d.ts +3 -0
- package/dist/sandbox/channel-a.d.ts +55 -2
- package/dist/sandbox/metrics-ingestion.d.ts +6 -1
- package/dist/sandbox/viewer.d.ts +4 -2
- package/dist/temporal-schedule-cleanup.d.ts +26 -0
- package/package.json +19 -14
- package/src/app.ts +274 -37
- package/src/browser-controller-authority.ts +137 -0
- package/src/browser-state-authority.ts +236 -0
- package/src/codemode.ts +186 -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 +2 -0
- package/src/http/cors.ts +3 -0
- package/src/index.ts +119 -20
- package/src/integrations/api-integrations.ts +350 -0
- package/src/integrations/atlassian.ts +1621 -0
- package/src/integrations/github-skill-source.ts +142 -0
- package/src/integrations/google-drive.ts +1000 -64
- package/src/integrations/oauth-client.ts +123 -33
- package/src/integrations/provider-oauth.ts +777 -0
- package/src/integrations/slack-bot.ts +31 -2
- package/src/integrations/slack-interactions.ts +487 -27
- package/src/integrations/social-api.ts +11 -0
- package/src/mcp/documents.ts +32 -1
- 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 +923 -145
- package/src/memory-slack-delivery.ts +209 -0
- package/src/routes/api-integrations.ts +407 -0
- package/src/routes/browser-identities.ts +136 -0
- package/src/routes/browser-sessions.ts +2543 -0
- package/src/routes/company-profile.ts +255 -0
- package/src/routes/computer-sessions.ts +1247 -0
- package/src/routes/connections.ts +284 -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-features.ts +258 -0
- package/src/routes/memory-slack-publications.ts +216 -0
- package/src/routes/packs.ts +437 -7
- package/src/routes/plugins.ts +751 -0
- package/src/routes/rigs.ts +77 -20
- package/src/routes/scheduled-tasks.ts +67 -40
- package/src/routes/sessions.ts +421 -245
- package/src/routes/skills.ts +174 -0
- package/src/routes/video-generation.ts +132 -0
- package/src/routes/workspaces.ts +46 -24
- package/src/sandbox/channel-a.ts +723 -116
- package/src/sandbox/metrics-ingestion.ts +121 -3
- package/src/sandbox/rematerialize.ts +35 -47
- package/src/sandbox/viewer.ts +29 -9
- package/src/temporal-schedule-cleanup.ts +135 -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,311 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EditableArtifactDomainError,
|
|
3
|
+
EditableArtifactLiveError,
|
|
4
|
+
decodeEditableArtifactLiveClientWireFrame,
|
|
5
|
+
encodeEditableArtifactLiveServerWireFrame,
|
|
6
|
+
type EditableArtifactApplicationPort,
|
|
7
|
+
type EditableArtifactLiveClose,
|
|
8
|
+
type EditableArtifactLiveServerFrame,
|
|
9
|
+
type EditableArtifactLiveSession,
|
|
10
|
+
type EditableArtifactLiveSinkPort,
|
|
11
|
+
} from "@opengeni/core";
|
|
12
|
+
|
|
13
|
+
export const EDITABLE_ARTIFACT_LIVE_WEBSOCKET_PATH = "/v1/editable-artifacts/live";
|
|
14
|
+
export const EDITABLE_ARTIFACT_LIVE_WEBSOCKET_PROTOCOL = "opengeni-artifact-v1";
|
|
15
|
+
export const EDITABLE_ARTIFACT_LIVE_WEBSOCKET_MAX_MESSAGE_BYTES = 8 * 1024 * 1024 + 64 * 1024;
|
|
16
|
+
|
|
17
|
+
const MAX_QUEUED_MESSAGES = 64;
|
|
18
|
+
const MAX_QUEUED_BYTES = 12 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
export type EditableArtifactWebSocketLike = Readonly<{
|
|
21
|
+
data: EditableArtifactWebSocketConnection;
|
|
22
|
+
send(data: Uint8Array, compress?: boolean): number;
|
|
23
|
+
close(code?: number, reason?: string): void;
|
|
24
|
+
readonly bufferedAmount?: number;
|
|
25
|
+
getBufferedAmount?(): number;
|
|
26
|
+
}>;
|
|
27
|
+
|
|
28
|
+
export type EditableArtifactWebSocketUpgradeServer = Readonly<{
|
|
29
|
+
upgrade(
|
|
30
|
+
request: Request,
|
|
31
|
+
options: Readonly<{
|
|
32
|
+
data: EditableArtifactWebSocketConnection;
|
|
33
|
+
headers: HeadersInit;
|
|
34
|
+
}>,
|
|
35
|
+
): boolean;
|
|
36
|
+
}>;
|
|
37
|
+
|
|
38
|
+
export type EditableArtifactWebSocketHandler = Readonly<{
|
|
39
|
+
open(socket: EditableArtifactWebSocketLike): void;
|
|
40
|
+
message(socket: EditableArtifactWebSocketLike, message: string | Uint8Array | ArrayBuffer): void;
|
|
41
|
+
close(socket: EditableArtifactWebSocketLike): void;
|
|
42
|
+
}>;
|
|
43
|
+
|
|
44
|
+
export class EditableArtifactWebSocketTransport {
|
|
45
|
+
readonly websocket: EditableArtifactWebSocketHandler;
|
|
46
|
+
|
|
47
|
+
constructor(private readonly application: EditableArtifactApplicationPort | undefined) {
|
|
48
|
+
this.websocket = Object.freeze({
|
|
49
|
+
open: (socket) => socket.data.attach(socket),
|
|
50
|
+
message: (socket, message) => socket.data.receive(message),
|
|
51
|
+
close: (socket) => socket.data.transportClosed(),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
handles(request: Request): boolean {
|
|
56
|
+
return new URL(request.url).pathname === EDITABLE_ARTIFACT_LIVE_WEBSOCKET_PATH;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
upgrade(request: Request, server: EditableArtifactWebSocketUpgradeServer): Response | undefined {
|
|
60
|
+
const url = new URL(request.url);
|
|
61
|
+
if (url.pathname !== EDITABLE_ARTIFACT_LIVE_WEBSOCKET_PATH) return undefined;
|
|
62
|
+
if (request.method !== "GET") {
|
|
63
|
+
return new Response("Method Not Allowed", {
|
|
64
|
+
status: 405,
|
|
65
|
+
headers: { allow: "GET" },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (!this.application) return new Response("Service Unavailable", { status: 503 });
|
|
69
|
+
if (!offeredProtocol(request.headers.get("sec-websocket-protocol"))) {
|
|
70
|
+
return new Response("WebSocket protocol required", { status: 426 });
|
|
71
|
+
}
|
|
72
|
+
const connection = new EditableArtifactWebSocketConnection(this.application);
|
|
73
|
+
const upgraded = server.upgrade(request, {
|
|
74
|
+
data: connection,
|
|
75
|
+
headers: {
|
|
76
|
+
"sec-websocket-protocol": EDITABLE_ARTIFACT_LIVE_WEBSOCKET_PROTOCOL,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
return upgraded ? undefined : new Response("Bad Request", { status: 400 });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One socket, one consumed ticket, one stream epoch. */
|
|
84
|
+
export class EditableArtifactWebSocketConnection implements EditableArtifactLiveSinkPort {
|
|
85
|
+
private socket: EditableArtifactWebSocketLike | null = null;
|
|
86
|
+
private session: EditableArtifactLiveSession | null = null;
|
|
87
|
+
private work: Promise<void> = Promise.resolve();
|
|
88
|
+
private queuedMessages = 0;
|
|
89
|
+
private queuedBytes = 0;
|
|
90
|
+
private terminal = false;
|
|
91
|
+
|
|
92
|
+
constructor(private readonly application: EditableArtifactApplicationPort) {}
|
|
93
|
+
|
|
94
|
+
attach(socket: EditableArtifactWebSocketLike): void {
|
|
95
|
+
if (this.socket || this.terminal) {
|
|
96
|
+
socket.close(4400, "invalid connection state");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
this.socket = socket;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
receive(message: string | Uint8Array | ArrayBuffer): void {
|
|
103
|
+
if (this.terminal) return;
|
|
104
|
+
if (typeof message === "string") {
|
|
105
|
+
void this.fail("invalid_frame");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const byteLength = message.byteLength;
|
|
109
|
+
if (
|
|
110
|
+
byteLength === 0 ||
|
|
111
|
+
byteLength > EDITABLE_ARTIFACT_LIVE_WEBSOCKET_MAX_MESSAGE_BYTES ||
|
|
112
|
+
this.queuedMessages + 1 > MAX_QUEUED_MESSAGES ||
|
|
113
|
+
this.queuedBytes + byteLength > MAX_QUEUED_BYTES
|
|
114
|
+
) {
|
|
115
|
+
void this.fail(
|
|
116
|
+
byteLength > EDITABLE_ARTIFACT_LIVE_WEBSOCKET_MAX_MESSAGE_BYTES
|
|
117
|
+
? "oversized_frame"
|
|
118
|
+
: "slow_client",
|
|
119
|
+
);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const bytes =
|
|
123
|
+
message instanceof Uint8Array ? Uint8Array.from(message) : new Uint8Array(message).slice();
|
|
124
|
+
this.queuedMessages += 1;
|
|
125
|
+
this.queuedBytes += bytes.byteLength;
|
|
126
|
+
const run = this.work.then(async () => {
|
|
127
|
+
try {
|
|
128
|
+
await this.process(bytes);
|
|
129
|
+
} finally {
|
|
130
|
+
this.queuedMessages -= 1;
|
|
131
|
+
this.queuedBytes -= bytes.byteLength;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
this.work = run.catch(async (error: unknown) => {
|
|
135
|
+
await this.handleFailure(error);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async send(frame: EditableArtifactLiveServerFrame): Promise<void> {
|
|
140
|
+
if (this.terminal || !this.socket) {
|
|
141
|
+
throw new EditableArtifactLiveError("closed", "Editable artifact socket is closed");
|
|
142
|
+
}
|
|
143
|
+
const bytes = encodeEditableArtifactLiveServerWireFrame(frame);
|
|
144
|
+
if (bytes.byteLength > EDITABLE_ARTIFACT_LIVE_WEBSOCKET_MAX_MESSAGE_BYTES) {
|
|
145
|
+
throw new EditableArtifactLiveError(
|
|
146
|
+
"oversized_frame",
|
|
147
|
+
"Editable artifact server frame exceeds socket limit",
|
|
148
|
+
{ requiresSnapshot: true },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const accepted = this.socket.send(bytes, false);
|
|
152
|
+
if (!Number.isSafeInteger(accepted) || accepted <= 0) {
|
|
153
|
+
throw new EditableArtifactLiveError("closed", "Editable artifact socket rejected frame");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
bufferedBytes(): number {
|
|
158
|
+
const buffered = this.socket?.getBufferedAmount?.() ?? this.socket?.bufferedAmount ?? 0;
|
|
159
|
+
return Number.isSafeInteger(buffered) && buffered >= 0 ? buffered : Number.MAX_SAFE_INTEGER;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
close(close: EditableArtifactLiveClose): void {
|
|
163
|
+
if (this.terminal) return;
|
|
164
|
+
this.terminal = true;
|
|
165
|
+
this.socket?.close(closeCode(close.reason), close.reason);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
transportClosed(): void {
|
|
169
|
+
if (this.terminal) return;
|
|
170
|
+
this.terminal = true;
|
|
171
|
+
void this.session?.close("transport_error");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async process(bytes: Uint8Array): Promise<void> {
|
|
175
|
+
if (this.terminal) return;
|
|
176
|
+
const frame = decodeEditableArtifactLiveClientWireFrame(bytes);
|
|
177
|
+
if (!this.session) {
|
|
178
|
+
if (frame.type !== "open") {
|
|
179
|
+
throw new EditableArtifactLiveError("invalid_frame", "First socket frame must be open");
|
|
180
|
+
}
|
|
181
|
+
const session = await this.application.openLive({
|
|
182
|
+
token: frame.token,
|
|
183
|
+
artifactId: frame.artifactId,
|
|
184
|
+
protocolVersion: frame.protocolVersion,
|
|
185
|
+
resume: frame.resume,
|
|
186
|
+
sink: this,
|
|
187
|
+
});
|
|
188
|
+
if (this.terminal) {
|
|
189
|
+
await session.close("transport_error");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.session = session;
|
|
193
|
+
void session.closed.then(() => {
|
|
194
|
+
if (!this.terminal)
|
|
195
|
+
this.close({
|
|
196
|
+
reason: "closed",
|
|
197
|
+
retryable: false,
|
|
198
|
+
requiresSnapshot: false,
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (frame.type === "open") {
|
|
204
|
+
throw new EditableArtifactLiveError("invalid_frame", "Socket is already open");
|
|
205
|
+
}
|
|
206
|
+
if (
|
|
207
|
+
frame.artifactId !== this.session.artifactId ||
|
|
208
|
+
frame.streamEpoch !== this.session.streamEpoch
|
|
209
|
+
) {
|
|
210
|
+
throw new EditableArtifactLiveError("stale_epoch", "Socket frame belongs to another stream");
|
|
211
|
+
}
|
|
212
|
+
if (frame.type === "applied") {
|
|
213
|
+
await this.session.acknowledge(frame);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
let receipt: Awaited<ReturnType<EditableArtifactLiveSession["submitIntent"]>>;
|
|
217
|
+
try {
|
|
218
|
+
receipt = await this.session.submitIntent(frame);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
await this.send({
|
|
221
|
+
type: "mutationRejected",
|
|
222
|
+
protocolVersion: frame.protocolVersion,
|
|
223
|
+
artifactId: frame.artifactId,
|
|
224
|
+
streamEpoch: frame.streamEpoch,
|
|
225
|
+
requestHash: frame.requestHash,
|
|
226
|
+
...mutationFailure(error),
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
// Always return the exact durable canonical transaction evidence (OGACO
|
|
231
|
+
// or OGAST) on this request's socket, including idempotent unknown-outcome
|
|
232
|
+
// retries whose original fanout was already replayed and ACKed. Transaction
|
|
233
|
+
// first, mapping second: acceptance is never exposed without settle bytes.
|
|
234
|
+
await this.send({
|
|
235
|
+
type: "transaction",
|
|
236
|
+
protocolVersion: frame.protocolVersion,
|
|
237
|
+
artifactId: frame.artifactId,
|
|
238
|
+
streamEpoch: frame.streamEpoch,
|
|
239
|
+
transaction: receipt.transaction,
|
|
240
|
+
});
|
|
241
|
+
await this.send({
|
|
242
|
+
type: "mutationAccepted",
|
|
243
|
+
protocolVersion: frame.protocolVersion,
|
|
244
|
+
artifactId: frame.artifactId,
|
|
245
|
+
streamEpoch: frame.streamEpoch,
|
|
246
|
+
requestHash: receipt.requestHash,
|
|
247
|
+
clientTransactionId: receipt.clientTransactionId,
|
|
248
|
+
transactionId: receipt.transaction.transactionId,
|
|
249
|
+
startSequence: receipt.transaction.startSequence,
|
|
250
|
+
endSequence: receipt.transaction.endSequence,
|
|
251
|
+
stateHash: receipt.transaction.stateHash,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async handleFailure(error: unknown): Promise<void> {
|
|
256
|
+
if (this.terminal) return;
|
|
257
|
+
if (error instanceof EditableArtifactLiveError) {
|
|
258
|
+
await this.fail(error.code === "invalid_ticket" ? "transport_error" : error.code);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
await this.fail("transport_error");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private async fail(reason: Parameters<EditableArtifactLiveSession["close"]>[0]): Promise<void> {
|
|
265
|
+
if (this.terminal) return;
|
|
266
|
+
this.terminal = true;
|
|
267
|
+
await this.session?.close(reason).catch(() => undefined);
|
|
268
|
+
this.socket?.close(closeCode(reason ?? "transport_error"), reason ?? "transport_error");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function mutationFailure(error: unknown): Readonly<{
|
|
273
|
+
code: "invalid_request" | "forbidden" | "conflict" | "unsupported" | "unavailable";
|
|
274
|
+
retryable: boolean;
|
|
275
|
+
}> {
|
|
276
|
+
if (error instanceof EditableArtifactDomainError) {
|
|
277
|
+
if (error.code === "forbidden") return { code: "forbidden", retryable: false };
|
|
278
|
+
if (["invalid_request", "request_hash_mismatch", "invalid_undo_target"].includes(error.code)) {
|
|
279
|
+
return { code: "invalid_request", retryable: false };
|
|
280
|
+
}
|
|
281
|
+
if (error.code === "kernel_contract_violation") {
|
|
282
|
+
return { code: "unsupported", retryable: false };
|
|
283
|
+
}
|
|
284
|
+
if (["retryable_conflict", "outbox_lease_conflict"].includes(error.code)) {
|
|
285
|
+
return { code: "conflict", retryable: true };
|
|
286
|
+
}
|
|
287
|
+
return { code: "conflict", retryable: false };
|
|
288
|
+
}
|
|
289
|
+
if (error instanceof EditableArtifactLiveError && error.code === "permission_changed") {
|
|
290
|
+
return { code: "forbidden", retryable: false };
|
|
291
|
+
}
|
|
292
|
+
return { code: "unavailable", retryable: true };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function offeredProtocol(value: string | null): boolean {
|
|
296
|
+
return (
|
|
297
|
+
value
|
|
298
|
+
?.split(",")
|
|
299
|
+
.map((part) => part.trim())
|
|
300
|
+
.includes(EDITABLE_ARTIFACT_LIVE_WEBSOCKET_PROTOCOL) ?? false
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function closeCode(reason: string): number {
|
|
305
|
+
if (reason === "permission_changed") return 4403;
|
|
306
|
+
if (reason === "ticket_expired" || reason === "ticket_replayed") return 4401;
|
|
307
|
+
if (reason === "protocol_mismatch") return 4406;
|
|
308
|
+
if (reason === "slow_client" || reason === "oversized_frame") return 4409;
|
|
309
|
+
if (reason === "closed") return 1000;
|
|
310
|
+
return 4400;
|
|
311
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type EditableArtifactAgentWorkspaceFilePort,
|
|
5
|
+
type EditableArtifactDurableExportService,
|
|
6
|
+
} from "@opengeni/core/editable-artifacts";
|
|
7
|
+
import { completeFileUpload, prepareGeneratedWorkspaceFile, type Database } from "@opengeni/db";
|
|
8
|
+
import type { ObjectHead, ObjectStorage } from "@opengeni/storage";
|
|
9
|
+
|
|
10
|
+
const UPLOAD_INTENT_TTL_MS = 60 * 60_000;
|
|
11
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
12
|
+
|
|
13
|
+
export type EditableArtifactWorkspaceFileAdapterDependencies = Readonly<{
|
|
14
|
+
db: Database;
|
|
15
|
+
objectStorage: ObjectStorage;
|
|
16
|
+
durableExports: EditableArtifactDurableExportService;
|
|
17
|
+
now?: () => Date;
|
|
18
|
+
prepareFile?: typeof prepareGeneratedWorkspaceFile;
|
|
19
|
+
completeFile?: typeof completeFileUpload;
|
|
20
|
+
}>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Promotes one immutable materialization into the normal workspace-file domain.
|
|
24
|
+
* The returned file ID is the only artifact export handle exposed to agents.
|
|
25
|
+
*/
|
|
26
|
+
export class EditableArtifactWorkspaceFileAdapter implements EditableArtifactAgentWorkspaceFilePort {
|
|
27
|
+
private readonly now: () => Date;
|
|
28
|
+
private readonly prepareFile: typeof prepareGeneratedWorkspaceFile;
|
|
29
|
+
private readonly completeFile: typeof completeFileUpload;
|
|
30
|
+
|
|
31
|
+
constructor(private readonly dependencies: EditableArtifactWorkspaceFileAdapterDependencies) {
|
|
32
|
+
this.now = dependencies.now ?? (() => new Date());
|
|
33
|
+
this.prepareFile = dependencies.prepareFile ?? prepareGeneratedWorkspaceFile;
|
|
34
|
+
this.completeFile = dependencies.completeFile ?? completeFileUpload;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async ensureMaterializationFile(
|
|
38
|
+
input: Parameters<EditableArtifactAgentWorkspaceFilePort["ensureMaterializationFile"]>[0],
|
|
39
|
+
): ReturnType<EditableArtifactAgentWorkspaceFilePort["ensureMaterializationFile"]> {
|
|
40
|
+
throwIfAborted(input.signal);
|
|
41
|
+
const storage = this.dependencies.objectStorage;
|
|
42
|
+
if (!storage.headObject || !storage.putObjectStreamIfAbsent) {
|
|
43
|
+
throw new Error("Object storage lacks immutable streaming upload support");
|
|
44
|
+
}
|
|
45
|
+
const download = await this.dependencies.durableExports.openMaterializationDownload({
|
|
46
|
+
scope: input.scope,
|
|
47
|
+
actor: input.actor,
|
|
48
|
+
artifactId: input.artifact.id,
|
|
49
|
+
jobId: input.jobId,
|
|
50
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
const sha256 = plainSha256(download.contentHash);
|
|
54
|
+
const filename = exportFilename(input.filename, download.format);
|
|
55
|
+
const fileId = deterministicUuid(
|
|
56
|
+
`editable-artifact-export:file:${input.scope.workspaceId}:${input.artifact.id}:${input.versionId}:${input.jobId}`,
|
|
57
|
+
);
|
|
58
|
+
const uploadId = deterministicUuid(
|
|
59
|
+
`editable-artifact-export:upload:${input.scope.workspaceId}:${input.artifact.id}:${input.versionId}:${input.jobId}`,
|
|
60
|
+
);
|
|
61
|
+
const safeFilename = `artifact-${input.artifact.id}-${input.jobId}.${download.format}`;
|
|
62
|
+
const objectKey = `workspaces/${input.scope.workspaceId}/files/${fileId}/artifact-exports/${safeFilename}`;
|
|
63
|
+
const prepared = await this.prepareFile(this.dependencies.db, {
|
|
64
|
+
accountId: input.scope.accountId,
|
|
65
|
+
workspaceId: input.scope.workspaceId,
|
|
66
|
+
fileId,
|
|
67
|
+
uploadId,
|
|
68
|
+
filename,
|
|
69
|
+
safeFilename,
|
|
70
|
+
contentType: download.mimeType,
|
|
71
|
+
sizeBytes: download.byteSize,
|
|
72
|
+
sha256,
|
|
73
|
+
bucket: storage.bucket,
|
|
74
|
+
objectKey,
|
|
75
|
+
expiresAt: new Date(this.now().getTime() + UPLOAD_INTENT_TTL_MS),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let file = prepared.file;
|
|
79
|
+
if (file.status !== "ready") {
|
|
80
|
+
const existing = await storage.headObject(objectKey);
|
|
81
|
+
if (existing) {
|
|
82
|
+
assertStoredExport(existing, download.byteSize, download.mimeType, sha256);
|
|
83
|
+
} else {
|
|
84
|
+
await storage.putObjectStreamIfAbsent({
|
|
85
|
+
key: objectKey,
|
|
86
|
+
contentType: download.mimeType,
|
|
87
|
+
chunks: download.chunks(input.signal ? { signal: input.signal } : {}),
|
|
88
|
+
byteSize: download.byteSize,
|
|
89
|
+
sha256,
|
|
90
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
91
|
+
});
|
|
92
|
+
const stored = await storage.headObject(objectKey);
|
|
93
|
+
assertStoredExport(stored, download.byteSize, download.mimeType, sha256);
|
|
94
|
+
}
|
|
95
|
+
await download.assertUnchanged(input.signal);
|
|
96
|
+
throwIfAborted(input.signal);
|
|
97
|
+
file = await this.completeFile(this.dependencies.db, input.scope.workspaceId, uploadId);
|
|
98
|
+
} else {
|
|
99
|
+
assertStoredExport(
|
|
100
|
+
await storage.headObject(objectKey),
|
|
101
|
+
download.byteSize,
|
|
102
|
+
download.mimeType,
|
|
103
|
+
sha256,
|
|
104
|
+
);
|
|
105
|
+
await download.assertUnchanged(input.signal);
|
|
106
|
+
}
|
|
107
|
+
if (
|
|
108
|
+
file.id !== fileId ||
|
|
109
|
+
file.status !== "ready" ||
|
|
110
|
+
file.filename !== filename ||
|
|
111
|
+
file.contentType !== download.mimeType ||
|
|
112
|
+
file.sizeBytes !== download.byteSize ||
|
|
113
|
+
file.sha256 !== sha256
|
|
114
|
+
) {
|
|
115
|
+
throw new Error("Generated workspace file differs from its immutable export");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return Object.freeze({
|
|
119
|
+
fileId: file.id,
|
|
120
|
+
filename: file.filename,
|
|
121
|
+
contentType: file.contentType,
|
|
122
|
+
sizeBytes: file.sizeBytes,
|
|
123
|
+
sha256,
|
|
124
|
+
artifactId: input.artifact.id,
|
|
125
|
+
versionId: input.versionId,
|
|
126
|
+
materializationJobId: input.jobId,
|
|
127
|
+
sourceHeadSequence: input.sourceHeadSequence,
|
|
128
|
+
sourceStateHash: input.sourceStateHash,
|
|
129
|
+
});
|
|
130
|
+
} finally {
|
|
131
|
+
await download.close();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function assertStoredExport(
|
|
137
|
+
head: ObjectHead | null,
|
|
138
|
+
byteSize: number,
|
|
139
|
+
contentType: string,
|
|
140
|
+
sha256: string,
|
|
141
|
+
): void {
|
|
142
|
+
if (
|
|
143
|
+
!head ||
|
|
144
|
+
head.ContentLength !== byteSize ||
|
|
145
|
+
head.ContentType !== contentType ||
|
|
146
|
+
head.Metadata?.sha256 !== sha256 ||
|
|
147
|
+
typeof head.VersionToken !== "string" ||
|
|
148
|
+
head.VersionToken.length < 1
|
|
149
|
+
) {
|
|
150
|
+
throw new Error("Workspace export object differs from its durable materialization");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function exportFilename(value: string, format: string): string {
|
|
155
|
+
const expectedExtension = `.${format}`;
|
|
156
|
+
const normalized = value
|
|
157
|
+
.normalize("NFKC")
|
|
158
|
+
.replace(/[\\/:*?"<>|\u0000-\u001f\u007f]+/gu, "-")
|
|
159
|
+
.replace(/\s+/gu, " ")
|
|
160
|
+
.trim()
|
|
161
|
+
.slice(0, 200);
|
|
162
|
+
const stem = normalized.toLowerCase().endsWith(expectedExtension)
|
|
163
|
+
? normalized.slice(0, -expectedExtension.length)
|
|
164
|
+
: normalized;
|
|
165
|
+
return `${stem.replace(/[. ]+$/u, "").slice(0, 190) || "artifact"}${expectedExtension}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function plainSha256(value: string): string {
|
|
169
|
+
const match = /^sha256:([0-9a-f]{64})$/u.exec(value);
|
|
170
|
+
if (!match) throw new Error("Materialized artifact content hash is invalid");
|
|
171
|
+
return match[1]!;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function deterministicUuid(seed: string): string {
|
|
175
|
+
const bytes = createHash("sha256").update(seed, "utf8").digest().subarray(0, 16);
|
|
176
|
+
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x50;
|
|
177
|
+
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
|
|
178
|
+
const hex = bytes.toString("hex");
|
|
179
|
+
const value = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
180
|
+
if (!UUID_PATTERN.test(value)) throw new Error("Generated workspace file identity is invalid");
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
185
|
+
if (signal?.aborted) throw signal.reason ?? new Error("Artifact export aborted");
|
|
186
|
+
}
|
|
@@ -31,13 +31,42 @@ export function githubBrowserGrantClaims(
|
|
|
31
31
|
export function continuedGitHubBrowserGrantClaims(
|
|
32
32
|
payload: GitHubSignedStatePayload,
|
|
33
33
|
): Record<string, unknown> {
|
|
34
|
-
|
|
34
|
+
const browserGrant =
|
|
35
|
+
typeof payload.browserGrantSubjectId === "string" &&
|
|
35
36
|
typeof payload.browserGrantExpiresAt === "number"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
? {
|
|
38
|
+
browserGrantSubjectId: payload.browserGrantSubjectId,
|
|
39
|
+
browserGrantExpiresAt: payload.browserGrantExpiresAt,
|
|
40
|
+
}
|
|
41
|
+
: {};
|
|
42
|
+
return {
|
|
43
|
+
...browserGrant,
|
|
44
|
+
...(typeof payload.returnPath === "string" ? { returnPath: payload.returnPath } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* GitHub may return only to the session that initiated setup. This rejects
|
|
50
|
+
* absolute/protocol-relative URLs and paths for another workspace before the
|
|
51
|
+
* value is signed into provider state.
|
|
52
|
+
*/
|
|
53
|
+
export function githubSessionReturnPath(
|
|
54
|
+
value: string | undefined | null,
|
|
55
|
+
workspaceId: string,
|
|
56
|
+
): string | null {
|
|
57
|
+
if (!value || value.length > 2048 || !value.startsWith("/") || value.startsWith("//")) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const parsed = new URL(value, "https://opengeni.local");
|
|
61
|
+
const sessionPrefix = `/workspaces/${encodeURIComponent(workspaceId)}/sessions/`;
|
|
62
|
+
if (
|
|
63
|
+
parsed.origin !== "https://opengeni.local" ||
|
|
64
|
+
parsed.pathname.startsWith("//") ||
|
|
65
|
+
!parsed.pathname.startsWith(sessionPrefix)
|
|
66
|
+
) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
export function githubBrowserGrantFromState(
|
package/src/http/auth.ts
CHANGED
|
@@ -49,6 +49,8 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
49
49
|
}
|
|
50
50
|
if (
|
|
51
51
|
path === "/v1/integrations/oauth/callback" ||
|
|
52
|
+
path === "/v1/integrations/provider-oauth/callback" ||
|
|
53
|
+
path === "/v1/integrations/google-drive/callback" ||
|
|
52
54
|
path === "/v1/integrations/oauth/client-metadata.json" ||
|
|
53
55
|
path === "/v1/integrations/slack/callback" ||
|
|
54
56
|
path === "/v1/integrations/slack/events" ||
|
package/src/http/cors.ts
ADDED