@opengeni/api-router 0.5.3 → 0.5.4
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.js +1 -1
- package/dist/{chunk-3HIA43CC.js → chunk-DO2G3JSB.js} +5184 -2195
- package/dist/chunk-DO2G3JSB.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +297 -54
- package/dist/index.js.map +1 -1
- package/package.json +20 -20
- package/src/app.ts +415 -147
- package/src/auth/managed-auth.ts +32 -16
- package/src/http/auth.ts +8 -1
- package/src/http/common.ts +6 -2
- package/src/http/sse.ts +27 -6
- package/src/index.ts +196 -74
- package/src/integrations/oauth-client.ts +403 -120
- package/src/integrations/provider-domain.ts +4 -1
- package/src/mcp/documents.ts +173 -94
- package/src/mcp/server.ts +1517 -692
- package/src/mcp/session-view.ts +8 -2
- package/src/mcp/toolspace.ts +175 -84
- package/src/observability.ts +7 -1
- package/src/routes/api-keys.ts +39 -23
- package/src/routes/billing.ts +180 -65
- package/src/routes/capabilities.ts +17 -8
- package/src/routes/catalog-assets.ts +5 -2
- package/src/routes/codex.ts +244 -63
- package/src/routes/connections.ts +71 -33
- package/src/routes/documents.ts +242 -92
- package/src/routes/enrollments.ts +100 -70
- package/src/routes/environments.ts +205 -136
- package/src/routes/files.ts +164 -39
- package/src/routes/github.ts +123 -50
- package/src/routes/install.ts +9 -2
- package/src/routes/machines.ts +9 -8
- package/src/routes/packs.ts +141 -89
- package/src/routes/rigs.ts +189 -0
- package/src/routes/scheduled-tasks.ts +51 -9
- package/src/routes/sessions.ts +839 -329
- package/src/routes/social.ts +50 -38
- package/src/routes/workspace-capture.ts +238 -0
- package/src/routes/workspaces.ts +159 -13
- package/src/sandbox/access.ts +11 -3
- package/src/sandbox/auth-callout.ts +5 -1
- package/src/sandbox/channel-a.ts +104 -27
- package/src/sandbox/enrollment.ts +13 -3
- package/src/sandbox/machines.ts +68 -59
- package/src/sandbox/metrics-ingestion.ts +238 -17
- package/src/sandbox/viewer.ts +172 -46
- package/dist/chunk-3HIA43CC.js.map +0 -1
|
@@ -1,175 +1,244 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
CreateVariableSetRequest,
|
|
3
|
+
SetVariableSetVariableRequest,
|
|
4
|
+
UpdateVariableSetRequest,
|
|
5
|
+
VariableSetVariableName,
|
|
6
6
|
} from "@opengeni/contracts";
|
|
7
7
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
countActiveSessionsUsingVariableSet,
|
|
9
|
+
countScheduledTasksUsingVariableSet,
|
|
10
|
+
countVariableSets,
|
|
11
|
+
createVariableSet,
|
|
12
|
+
deleteVariableSet,
|
|
13
|
+
deleteVariableSetVariable,
|
|
14
|
+
encryptVariableSetValue,
|
|
15
|
+
getVariableSetByName,
|
|
16
|
+
listVariableSets,
|
|
17
|
+
setVariableSetVariable,
|
|
18
|
+
updateVariableSet,
|
|
19
19
|
} from "@opengeni/db";
|
|
20
20
|
import type { Hono } from "hono";
|
|
21
21
|
import { HTTPException } from "hono/http-exception";
|
|
22
22
|
import { requireAccessGrant } from "@opengeni/core";
|
|
23
23
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
24
24
|
import {
|
|
25
|
-
|
|
25
|
+
assertAllowedVariableSetVariableName,
|
|
26
26
|
MAX_ENVIRONMENTS_PER_WORKSPACE,
|
|
27
27
|
MAX_VARIABLES_PER_ENVIRONMENT,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
recordVariableSetAuditEvent,
|
|
29
|
+
requireVariableSetEncryption,
|
|
30
|
+
requireVariableSetForApi,
|
|
31
31
|
} from "@opengeni/core";
|
|
32
32
|
|
|
33
|
-
export function
|
|
33
|
+
export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
34
34
|
const { settings, db } = deps;
|
|
35
|
+
const prefixes = [
|
|
36
|
+
"/v1/workspaces/:workspaceId/variable-sets",
|
|
37
|
+
"/v1/workspaces/:workspaceId/environments",
|
|
38
|
+
];
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
for (const prefix of prefixes) {
|
|
41
|
+
app.get(`${prefix}`, async (c) => {
|
|
42
|
+
const workspaceId = c.req.param("workspaceId")!;
|
|
43
|
+
await requireAccessGrant(c, deps, workspaceId, "variable-sets:use");
|
|
44
|
+
return c.json(await listVariableSets(db, workspaceId));
|
|
45
|
+
});
|
|
41
46
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
47
|
+
app.post(`${prefix}`, async (c) => {
|
|
48
|
+
const workspaceId = c.req.param("workspaceId")!;
|
|
49
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "variable-sets:manage");
|
|
50
|
+
const key = requireVariableSetEncryption(settings);
|
|
51
|
+
const payload = CreateVariableSetRequest.parse(await c.req.json());
|
|
52
|
+
const name = trimmedVariableSetName(payload.name);
|
|
53
|
+
if (payload.variables.length > MAX_VARIABLES_PER_ENVIRONMENT) {
|
|
54
|
+
throw new HTTPException(422, {
|
|
55
|
+
message: `a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT} variables`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const variableNames = new Set<string>();
|
|
59
|
+
for (const variable of payload.variables) {
|
|
60
|
+
assertAllowedVariableSetVariableName(variable.name);
|
|
61
|
+
if (variableNames.has(variable.name)) {
|
|
62
|
+
throw new HTTPException(422, {
|
|
63
|
+
message: `duplicate variable set variable name: ${variable.name}`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
variableNames.add(variable.name);
|
|
67
|
+
}
|
|
68
|
+
if ((await countVariableSets(db, workspaceId)) >= MAX_ENVIRONMENTS_PER_WORKSPACE) {
|
|
69
|
+
throw new HTTPException(422, {
|
|
70
|
+
message: `a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE} variable sets`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (await getVariableSetByName(db, workspaceId, name)) {
|
|
74
|
+
throw new HTTPException(409, { message: `variable set name is already in use: ${name}` });
|
|
56
75
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
})),
|
|
76
|
+
// Values are encrypted up front and the variableSet plus all initial
|
|
77
|
+
// variables are written in one transaction: a failure leaves nothing.
|
|
78
|
+
const created = await createVariableSet(db, {
|
|
79
|
+
accountId: grant.accountId,
|
|
80
|
+
workspaceId,
|
|
81
|
+
name,
|
|
82
|
+
description: payload.description ?? null,
|
|
83
|
+
variables: payload.variables.map((variable) => ({
|
|
84
|
+
name: variable.name,
|
|
85
|
+
valueEncrypted: encryptVariableSetValue(key, variable.value),
|
|
86
|
+
})),
|
|
87
|
+
});
|
|
88
|
+
await recordVariableSetAuditEvent(db, {
|
|
89
|
+
grant,
|
|
90
|
+
action: "variable_set.created",
|
|
91
|
+
variableSetId: created.id,
|
|
92
|
+
});
|
|
93
|
+
return c.json(created, 201);
|
|
76
94
|
});
|
|
77
|
-
await recordEnvironmentAuditEvent(db, { grant, action: "environment.created", environmentId: created.id });
|
|
78
|
-
return c.json(created, 201);
|
|
79
|
-
});
|
|
80
95
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
96
|
+
app.get(`${prefix}/:variableSetId`, async (c) => {
|
|
97
|
+
const workspaceId = c.req.param("workspaceId")!;
|
|
98
|
+
await requireAccessGrant(c, deps, workspaceId, "variable-sets:use");
|
|
99
|
+
return c.json(await requireVariableSetForApi(db, workspaceId, c.req.param("variableSetId")!));
|
|
100
|
+
});
|
|
86
101
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
app.patch(`${prefix}/:variableSetId`, async (c) => {
|
|
103
|
+
const workspaceId = c.req.param("workspaceId")!;
|
|
104
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "variable-sets:manage");
|
|
105
|
+
const variableSet = await requireVariableSetForApi(
|
|
106
|
+
db,
|
|
107
|
+
workspaceId,
|
|
108
|
+
c.req.param("variableSetId")!,
|
|
109
|
+
);
|
|
110
|
+
const payload = UpdateVariableSetRequest.parse(await c.req.json());
|
|
111
|
+
const name = payload.name !== undefined ? trimmedVariableSetName(payload.name) : undefined;
|
|
112
|
+
if (name !== undefined && name !== variableSet.name) {
|
|
113
|
+
const existing = await getVariableSetByName(db, workspaceId, name);
|
|
114
|
+
if (existing && existing.id !== variableSet.id) {
|
|
115
|
+
throw new HTTPException(409, { message: `variable set name is already in use: ${name}` });
|
|
116
|
+
}
|
|
97
117
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
118
|
+
const updated = await updateVariableSet(db, workspaceId, variableSet.id, {
|
|
119
|
+
...(name !== undefined ? { name } : {}),
|
|
120
|
+
...(payload.description !== undefined ? { description: payload.description } : {}),
|
|
121
|
+
});
|
|
122
|
+
await recordVariableSetAuditEvent(db, {
|
|
123
|
+
grant,
|
|
124
|
+
action: "variable_set.updated",
|
|
125
|
+
variableSetId: variableSet.id,
|
|
126
|
+
});
|
|
127
|
+
return c.json(updated);
|
|
102
128
|
});
|
|
103
|
-
await recordEnvironmentAuditEvent(db, { grant, action: "environment.updated", environmentId: environment.id });
|
|
104
|
-
return c.json(updated);
|
|
105
|
-
});
|
|
106
129
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
130
|
+
app.delete(`${prefix}/:variableSetId`, async (c) => {
|
|
131
|
+
const workspaceId = c.req.param("workspaceId")!;
|
|
132
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "variable-sets:manage");
|
|
133
|
+
const variableSet = await requireVariableSetForApi(
|
|
134
|
+
db,
|
|
135
|
+
workspaceId,
|
|
136
|
+
c.req.param("variableSetId")!,
|
|
137
|
+
);
|
|
138
|
+
const attachedTasks = await countScheduledTasksUsingVariableSet(
|
|
139
|
+
db,
|
|
140
|
+
workspaceId,
|
|
141
|
+
variableSet.id,
|
|
142
|
+
);
|
|
143
|
+
if (attachedTasks > 0) {
|
|
144
|
+
throw new HTTPException(409, {
|
|
145
|
+
message: `variable set is attached to ${attachedTasks} scheduled task(s); detach first`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const activeSessions = await countActiveSessionsUsingVariableSet(
|
|
149
|
+
db,
|
|
150
|
+
workspaceId,
|
|
151
|
+
variableSet.id,
|
|
152
|
+
);
|
|
153
|
+
if (activeSessions > 0) {
|
|
154
|
+
throw new HTTPException(409, {
|
|
155
|
+
message: `variable set is attached to ${activeSessions} active session(s); wait for them to finish or cancel them first`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
await deleteVariableSet(db, workspaceId, variableSet.id);
|
|
159
|
+
await recordVariableSetAuditEvent(db, {
|
|
160
|
+
grant,
|
|
161
|
+
action: "variable_set.deleted",
|
|
162
|
+
variableSetId: variableSet.id,
|
|
163
|
+
});
|
|
164
|
+
return c.json({ ok: true });
|
|
165
|
+
});
|
|
123
166
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
167
|
+
app.put(`${prefix}/:variableSetId/variables/:name`, async (c) => {
|
|
168
|
+
const workspaceId = c.req.param("workspaceId")!;
|
|
169
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "variable-sets:manage");
|
|
170
|
+
const key = requireVariableSetEncryption(settings);
|
|
171
|
+
const name = parseVariableName(c.req.param("name")!);
|
|
172
|
+
const variableSet = await requireVariableSetForApi(
|
|
173
|
+
db,
|
|
174
|
+
workspaceId,
|
|
175
|
+
c.req.param("variableSetId")!,
|
|
176
|
+
);
|
|
177
|
+
const payload = SetVariableSetVariableRequest.parse(await c.req.json());
|
|
178
|
+
const exists = variableSet.variables.some((variable) => variable.name === name);
|
|
179
|
+
if (!exists && variableSet.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT) {
|
|
180
|
+
throw new HTTPException(422, {
|
|
181
|
+
message: `a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT} variables`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const metadata = await setVariableSetVariable(db, {
|
|
185
|
+
accountId: grant.accountId,
|
|
186
|
+
workspaceId,
|
|
187
|
+
variableSetId: variableSet.id,
|
|
188
|
+
name,
|
|
189
|
+
valueEncrypted: encryptVariableSetValue(key, payload.value),
|
|
190
|
+
});
|
|
191
|
+
await recordVariableSetAuditEvent(db, {
|
|
192
|
+
grant,
|
|
193
|
+
action: "variable_set.variable.set",
|
|
194
|
+
variableSetId: variableSet.id,
|
|
195
|
+
variableName: name,
|
|
196
|
+
});
|
|
197
|
+
return c.json(metadata);
|
|
141
198
|
});
|
|
142
|
-
await recordEnvironmentAuditEvent(db, { grant, action: "environment.variable.set", environmentId: environment.id, variableName: name });
|
|
143
|
-
return c.json(metadata);
|
|
144
|
-
});
|
|
145
199
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
200
|
+
app.delete(`${prefix}/:variableSetId/variables/:name`, async (c) => {
|
|
201
|
+
const workspaceId = c.req.param("workspaceId")!;
|
|
202
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "variable-sets:manage");
|
|
203
|
+
const name = parseVariableName(c.req.param("name")!);
|
|
204
|
+
const variableSet = await requireVariableSetForApi(
|
|
205
|
+
db,
|
|
206
|
+
workspaceId,
|
|
207
|
+
c.req.param("variableSetId")!,
|
|
208
|
+
);
|
|
209
|
+
const deleted = await deleteVariableSetVariable(db, workspaceId, variableSet.id, name);
|
|
210
|
+
if (!deleted) {
|
|
211
|
+
throw new HTTPException(404, { message: "variable set variable not found" });
|
|
212
|
+
}
|
|
213
|
+
await recordVariableSetAuditEvent(db, {
|
|
214
|
+
grant,
|
|
215
|
+
action: "variable_set.variable.deleted",
|
|
216
|
+
variableSetId: variableSet.id,
|
|
217
|
+
variableName: name,
|
|
218
|
+
});
|
|
219
|
+
return c.json({ ok: true });
|
|
220
|
+
});
|
|
221
|
+
}
|
|
158
222
|
}
|
|
159
223
|
|
|
224
|
+
/** @deprecated use registerVariableSetRoutes */
|
|
225
|
+
export const registerEnvironmentRoutes = registerVariableSetRoutes;
|
|
226
|
+
|
|
160
227
|
function parseVariableName(raw: string): string {
|
|
161
|
-
const parsed =
|
|
228
|
+
const parsed = VariableSetVariableName.safeParse(raw);
|
|
162
229
|
if (!parsed.success) {
|
|
163
|
-
throw new HTTPException(422, {
|
|
230
|
+
throw new HTTPException(422, {
|
|
231
|
+
message: "variable set variable names must match ^[A-Z][A-Z0-9_]*$",
|
|
232
|
+
});
|
|
164
233
|
}
|
|
165
|
-
|
|
234
|
+
assertAllowedVariableSetVariableName(parsed.data);
|
|
166
235
|
return parsed.data;
|
|
167
236
|
}
|
|
168
237
|
|
|
169
|
-
function
|
|
238
|
+
function trimmedVariableSetName(name: string): string {
|
|
170
239
|
const trimmed = name.trim();
|
|
171
240
|
if (!trimmed) {
|
|
172
|
-
throw new HTTPException(422, { message: "
|
|
241
|
+
throw new HTTPException(422, { message: "variable set name is required" });
|
|
173
242
|
}
|
|
174
243
|
return trimmed;
|
|
175
244
|
}
|
package/src/routes/files.ts
CHANGED
|
@@ -6,10 +6,11 @@ import {
|
|
|
6
6
|
FileDownloadUrlResponse,
|
|
7
7
|
} from "@opengeni/contracts";
|
|
8
8
|
import {
|
|
9
|
+
claimFileUploadCleanup,
|
|
10
|
+
completeFileUploadCleanup,
|
|
9
11
|
completeFileUpload,
|
|
10
12
|
createFileUpload,
|
|
11
13
|
getFileUpload,
|
|
12
|
-
markFileUploadFailed,
|
|
13
14
|
requireFile,
|
|
14
15
|
} from "@opengeni/db";
|
|
15
16
|
import type { Hono } from "hono";
|
|
@@ -28,9 +29,16 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
28
29
|
throw new HTTPException(503, { message: "object storage is not configured" });
|
|
29
30
|
}
|
|
30
31
|
const payload = CreateFileUploadRequest.parse(await c.req.json());
|
|
31
|
-
await requireLimit(deps, {
|
|
32
|
+
await requireLimit(deps, {
|
|
33
|
+
accountId: grant.accountId,
|
|
34
|
+
workspaceId,
|
|
35
|
+
action: "file:upload",
|
|
36
|
+
quantity: payload.sizeBytes,
|
|
37
|
+
});
|
|
32
38
|
if (payload.sizeBytes > objectStorage.maxSinglePutSizeBytes) {
|
|
33
|
-
throw new HTTPException(413, {
|
|
39
|
+
throw new HTTPException(413, {
|
|
40
|
+
message: `file exceeds single PUT limit of ${objectStorage.maxSinglePutSizeBytes} bytes`,
|
|
41
|
+
});
|
|
34
42
|
}
|
|
35
43
|
const fileId = crypto.randomUUID();
|
|
36
44
|
const safeFilename = sanitizeFilename(payload.filename);
|
|
@@ -53,14 +61,17 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
53
61
|
objectKey,
|
|
54
62
|
expiresAt: signed.expiresAt,
|
|
55
63
|
});
|
|
56
|
-
return c.json(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
return c.json(
|
|
65
|
+
CreateFileUploadResponse.parse({
|
|
66
|
+
fileId: upload.file.id,
|
|
67
|
+
uploadId: upload.uploadId,
|
|
68
|
+
putUrl: signed.url,
|
|
69
|
+
requiredHeaders: signed.requiredHeaders,
|
|
70
|
+
expiresAt: upload.expiresAt,
|
|
71
|
+
maxSizeBytes: objectStorage.maxSinglePutSizeBytes,
|
|
72
|
+
}),
|
|
73
|
+
201,
|
|
74
|
+
);
|
|
64
75
|
});
|
|
65
76
|
|
|
66
77
|
app.post("/v1/workspaces/:workspaceId/files/uploads/:uploadId/complete", async (c) => {
|
|
@@ -73,40 +84,144 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
73
84
|
if (!upload) {
|
|
74
85
|
throw new HTTPException(404, { message: "file upload not found" });
|
|
75
86
|
}
|
|
87
|
+
const recordUploadedFileUsage = async (file: typeof upload.file): Promise<void> => {
|
|
88
|
+
await recordWorkspaceUsage(deps, {
|
|
89
|
+
accountId: grant.accountId,
|
|
90
|
+
workspaceId,
|
|
91
|
+
subjectId: grant.subjectId,
|
|
92
|
+
eventType: "file.uploaded",
|
|
93
|
+
quantity: file.sizeBytes,
|
|
94
|
+
unit: "byte",
|
|
95
|
+
sourceResourceType: "file",
|
|
96
|
+
sourceResourceId: file.id,
|
|
97
|
+
idempotencyKey: `file.uploaded:${workspaceId}:${file.id}`,
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
const completeAndRecordUsage = async (): Promise<typeof upload.file> => {
|
|
101
|
+
let file: typeof upload.file;
|
|
102
|
+
try {
|
|
103
|
+
file = await completeFileUpload(db, workspaceId, upload.id);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
// HEAD runs outside the DB transaction. If the expiry reaper claims the
|
|
106
|
+
// row in that window, report the durable state instead of leaking an
|
|
107
|
+
// internal 500. A concurrent successful finalize remains a normal
|
|
108
|
+
// idempotent completion and still repairs usage below.
|
|
109
|
+
const current = await getFileUpload(db, workspaceId, upload.id);
|
|
110
|
+
if (current?.status === "completed" && current.file.status === "ready") {
|
|
111
|
+
file = current.file;
|
|
112
|
+
} else if (current && current.status !== "pending") {
|
|
113
|
+
throw new HTTPException(409, {
|
|
114
|
+
message: `file upload is ${publicFileUploadStatus(current.status)}`,
|
|
115
|
+
});
|
|
116
|
+
} else {
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
await recordUploadedFileUsage(file);
|
|
121
|
+
return file;
|
|
122
|
+
};
|
|
123
|
+
const rejectAndCleanObject = async (
|
|
124
|
+
status: 409 | 422,
|
|
125
|
+
message: string,
|
|
126
|
+
terminalStatus: "failed" | "expired",
|
|
127
|
+
): Promise<typeof upload.file> => {
|
|
128
|
+
const claim = await claimFileUploadCleanup(db, {
|
|
129
|
+
workspaceId,
|
|
130
|
+
uploadId: upload.id,
|
|
131
|
+
fileId: upload.file.id,
|
|
132
|
+
});
|
|
133
|
+
// A concurrent valid finalize may have committed while this request was
|
|
134
|
+
// checking expiry/provider metadata. Never delete that ready object's
|
|
135
|
+
// key; preserve normal idempotent finalize and repair usage instead.
|
|
136
|
+
if (claim.outcome === "completed") {
|
|
137
|
+
await recordUploadedFileUsage(claim.file);
|
|
138
|
+
return claim.file;
|
|
139
|
+
}
|
|
140
|
+
if (claim.outcome === "unavailable") {
|
|
141
|
+
throw new HTTPException(409, {
|
|
142
|
+
message: `file upload is ${publicFileUploadStatus(claim.status)}`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
await objectStorage.deleteObject(upload.file.objectKey);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// The row remains cleanup_pending and the file non-ready. The global
|
|
149
|
+
// reaper can reclaim the idempotent delete after its claim timeout.
|
|
150
|
+
deps.observability?.warn(
|
|
151
|
+
"file upload rejection cleanup failed; claim remains reclaimable",
|
|
152
|
+
{
|
|
153
|
+
workspaceId,
|
|
154
|
+
fileId: upload.file.id,
|
|
155
|
+
uploadId: upload.id,
|
|
156
|
+
error: error instanceof Error ? error.message : String(error),
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
throw new HTTPException(status, { message });
|
|
160
|
+
}
|
|
161
|
+
const settled = await completeFileUploadCleanup(db, {
|
|
162
|
+
accountId: grant.accountId,
|
|
163
|
+
workspaceId,
|
|
164
|
+
uploadId: upload.id,
|
|
165
|
+
fileId: upload.file.id,
|
|
166
|
+
terminalStatus,
|
|
167
|
+
});
|
|
168
|
+
if (!settled) {
|
|
169
|
+
throw new HTTPException(409, { message: "file upload cleanup claim was superseded" });
|
|
170
|
+
}
|
|
171
|
+
throw new HTTPException(status, { message });
|
|
172
|
+
};
|
|
173
|
+
// A client can lose the response after the server commits completion, or two
|
|
174
|
+
// tabs can race the same completion request. A ready object is durable and
|
|
175
|
+
// safe to return again. Re-enter the row-locked DB finalize and retry the
|
|
176
|
+
// idempotent usage write before returning: the previous request may have
|
|
177
|
+
// died after completion committed but before `file.uploaded` was recorded.
|
|
178
|
+
if (upload.status === "completed" && upload.file.status === "ready") {
|
|
179
|
+
const file = await completeAndRecordUsage();
|
|
180
|
+
return c.json(CompleteFileUploadResponse.parse({ file }));
|
|
181
|
+
}
|
|
76
182
|
if (upload.status !== "pending") {
|
|
77
|
-
throw new HTTPException(409, {
|
|
183
|
+
throw new HTTPException(409, {
|
|
184
|
+
message: `file upload is ${publicFileUploadStatus(upload.status)}`,
|
|
185
|
+
});
|
|
78
186
|
}
|
|
79
187
|
if (upload.expiresAt.getTime() < Date.now()) {
|
|
80
|
-
await
|
|
81
|
-
|
|
188
|
+
const file = await rejectAndCleanObject(409, "file upload has expired", "expired");
|
|
189
|
+
return c.json(CompleteFileUploadResponse.parse({ file }));
|
|
82
190
|
}
|
|
83
191
|
const head = await objectStorage.headFile(upload.file).catch((error) => {
|
|
84
|
-
throw new HTTPException(409, {
|
|
192
|
+
throw new HTTPException(409, {
|
|
193
|
+
message: `uploaded object is not available: ${error instanceof Error ? error.message : String(error)}`,
|
|
194
|
+
});
|
|
85
195
|
});
|
|
86
196
|
if (Number(head.ContentLength ?? -1) !== upload.file.sizeBytes) {
|
|
87
|
-
|
|
88
|
-
|
|
197
|
+
const file = await rejectAndCleanObject(
|
|
198
|
+
422,
|
|
199
|
+
"uploaded object size does not match file metadata",
|
|
200
|
+
"failed",
|
|
201
|
+
);
|
|
202
|
+
return c.json(CompleteFileUploadResponse.parse({ file }));
|
|
89
203
|
}
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
204
|
+
if (
|
|
205
|
+
upload.file.contentType &&
|
|
206
|
+
head.ContentType &&
|
|
207
|
+
head.ContentType !== upload.file.contentType
|
|
208
|
+
) {
|
|
209
|
+
const file = await rejectAndCleanObject(
|
|
210
|
+
422,
|
|
211
|
+
"uploaded object content type does not match file metadata",
|
|
212
|
+
"failed",
|
|
213
|
+
);
|
|
214
|
+
return c.json(CompleteFileUploadResponse.parse({ file }));
|
|
93
215
|
}
|
|
94
216
|
if (upload.file.sha256 && head.Metadata?.sha256 !== upload.file.sha256) {
|
|
95
|
-
|
|
96
|
-
|
|
217
|
+
const file = await rejectAndCleanObject(
|
|
218
|
+
422,
|
|
219
|
+
"uploaded object checksum metadata does not match file metadata",
|
|
220
|
+
"failed",
|
|
221
|
+
);
|
|
222
|
+
return c.json(CompleteFileUploadResponse.parse({ file }));
|
|
97
223
|
}
|
|
98
|
-
const file = await
|
|
99
|
-
await recordWorkspaceUsage(deps, {
|
|
100
|
-
accountId: grant.accountId,
|
|
101
|
-
workspaceId,
|
|
102
|
-
subjectId: grant.subjectId,
|
|
103
|
-
eventType: "file.uploaded",
|
|
104
|
-
quantity: file.sizeBytes,
|
|
105
|
-
unit: "byte",
|
|
106
|
-
sourceResourceType: "file",
|
|
107
|
-
sourceResourceId: file.id,
|
|
108
|
-
idempotencyKey: `file.uploaded:${workspaceId}:${file.id}`,
|
|
109
|
-
});
|
|
224
|
+
const file = await completeAndRecordUsage();
|
|
110
225
|
return c.json(CompleteFileUploadResponse.parse({ file }));
|
|
111
226
|
});
|
|
112
227
|
|
|
@@ -134,15 +249,25 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
134
249
|
throw new HTTPException(409, { message: `file is ${file.status}` });
|
|
135
250
|
}
|
|
136
251
|
const signed = await objectStorage.createGetUrl({ key: file.objectKey });
|
|
137
|
-
return c.json(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
252
|
+
return c.json(
|
|
253
|
+
FileDownloadUrlResponse.parse({
|
|
254
|
+
url: signed.url,
|
|
255
|
+
expiresAt: signed.expiresAt.toISOString(),
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
141
258
|
});
|
|
142
259
|
}
|
|
143
260
|
|
|
144
261
|
function sanitizeFilename(filename: string): string {
|
|
145
262
|
const trimmed = filename.trim().replace(/[/\\]/g, "_");
|
|
146
|
-
const safe = trimmed
|
|
263
|
+
const safe = trimmed
|
|
264
|
+
.replace(/[^A-Za-z0-9._ -]+/g, "_")
|
|
265
|
+
.replace(/\s+/g, " ")
|
|
266
|
+
.trim();
|
|
147
267
|
return safe || "file";
|
|
148
268
|
}
|
|
269
|
+
|
|
270
|
+
/** Keep the cleanup lease internal; clients only consume terminal upload states. */
|
|
271
|
+
function publicFileUploadStatus(status: string): string {
|
|
272
|
+
return status === "cleanup_pending" ? "failed" : status;
|
|
273
|
+
}
|