@robodev-ai/runtime 0.3.0 → 0.4.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/package.json +2 -2
- package/src/index.ts +55 -1
- package/src/local-jobs.test.ts +110 -17
- package/src/local-jobs.ts +44 -13
- package/src/local-storage.test.ts +217 -0
- package/src/local-storage.ts +213 -0
- package/src/sockets.test.ts +112 -0
- package/src/sockets.ts +293 -0
- package/src/storage-rules.test.ts +110 -0
- package/src/storage-rules.ts +80 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robodev-ai/runtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Robodev project runtime — deploy-file classification, esbuild compile, module loading, schema push, route matching, OpenAPI, handler invocation, and
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Robodev project runtime — deploy-file classification, esbuild compile, module loading, schema push, route matching, OpenAPI, handler invocation, Robodev Auth, the socket engine, and local file storage. Shared by hosted Starbase and `robodev dev`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `@robodev-ai/runtime` — the project runtime shared by hosted Starbase deploys and the
|
|
3
3
|
* offline `robodev dev` loop: deploy-file classification, the esbuild compile, module
|
|
4
4
|
* loading, schema push, route matching, OpenAPI, handler invocation, Robodev Auth,
|
|
5
|
-
* and the offline `robodev dev` jobs engine.
|
|
5
|
+
* the socket engine, local file storage, and the offline `robodev dev` jobs engine.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export {
|
|
@@ -218,6 +218,7 @@ export {
|
|
|
218
218
|
ensureJobsSchema,
|
|
219
219
|
type JobsQueryable,
|
|
220
220
|
type LocalCronRow,
|
|
221
|
+
type LocalJobFinishedEvent,
|
|
221
222
|
type LocalJobRow,
|
|
222
223
|
type LocalJobStatus,
|
|
223
224
|
type LocalJobsEngine,
|
|
@@ -225,6 +226,59 @@ export {
|
|
|
225
226
|
type LocalJobsGeneration,
|
|
226
227
|
} from "./local-jobs.js";
|
|
227
228
|
|
|
229
|
+
export {
|
|
230
|
+
MAX_PROJECT_SOCKETS,
|
|
231
|
+
MAX_SOCKET_PAYLOAD_BYTES,
|
|
232
|
+
SOCKET_CLOSE_INVALID,
|
|
233
|
+
SOCKET_CLOSE_POLICY,
|
|
234
|
+
SOCKET_CLOSE_RESTART,
|
|
235
|
+
SOCKET_CLOSE_TOO_BIG,
|
|
236
|
+
SOCKET_IDLE_PING_MS,
|
|
237
|
+
assertKnownSocket,
|
|
238
|
+
attachProjectSocket,
|
|
239
|
+
bindSocketSession,
|
|
240
|
+
closeProjectSockets,
|
|
241
|
+
createProjectSocketsClient,
|
|
242
|
+
detachProjectSocket,
|
|
243
|
+
dispatchSocketsSend,
|
|
244
|
+
joinSocketRoom,
|
|
245
|
+
leaveSocketRoom,
|
|
246
|
+
parseSocketMessage,
|
|
247
|
+
projectSocketCount,
|
|
248
|
+
sendToSocketName,
|
|
249
|
+
sendToSocketRoom,
|
|
250
|
+
setProjectSocketNames,
|
|
251
|
+
socketAuthDisplay,
|
|
252
|
+
stringifySocketPayload,
|
|
253
|
+
type BindSocketSessionInput,
|
|
254
|
+
type LiveConn,
|
|
255
|
+
type SocketSessionHelpers,
|
|
256
|
+
type SocketWire,
|
|
257
|
+
} from "./sockets.js";
|
|
258
|
+
|
|
259
|
+
export {
|
|
260
|
+
DEFAULT_LIST_LIMIT,
|
|
261
|
+
MAX_LIST_LIMIT,
|
|
262
|
+
MAX_OBJECT_BYTES,
|
|
263
|
+
PRESIGN_GET_DEFAULT,
|
|
264
|
+
PRESIGN_GET_MAX,
|
|
265
|
+
assertObjectSize,
|
|
266
|
+
clampStorageExpiresIn,
|
|
267
|
+
mintStorageServeToken,
|
|
268
|
+
parseLogicalKey,
|
|
269
|
+
storageListQuerySchema,
|
|
270
|
+
verifyStorageServeToken,
|
|
271
|
+
} from "./storage-rules.js";
|
|
272
|
+
|
|
273
|
+
export {
|
|
274
|
+
createLocalStorageClient,
|
|
275
|
+
ensureStorageSchema,
|
|
276
|
+
resolveLocalObject,
|
|
277
|
+
type LocalObjectRow,
|
|
278
|
+
type LocalStorageClientOptions,
|
|
279
|
+
type StorageQueryable,
|
|
280
|
+
} from "./local-storage.js";
|
|
281
|
+
|
|
228
282
|
export {
|
|
229
283
|
LOCAL_DEV_IDENTITY_AUD,
|
|
230
284
|
LOCAL_DEV_IDENTITY_TYP,
|
package/src/local-jobs.test.ts
CHANGED
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
ensureJobsSchema,
|
|
11
11
|
type JobsQueryable,
|
|
12
12
|
type LocalCronRow,
|
|
13
|
+
type LocalJobFinishedEvent,
|
|
13
14
|
type LocalJobRow,
|
|
15
|
+
type LocalJobsEngineOptions,
|
|
14
16
|
type LocalJobsGeneration,
|
|
15
17
|
} from "./local-jobs.js";
|
|
16
18
|
|
|
@@ -21,8 +23,8 @@ function jobDef(
|
|
|
21
23
|
return { _kind: JOB_KIND, handler, ...extras };
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
function
|
|
25
|
-
throw new Error(
|
|
26
|
+
function notConfigured(code: string): never {
|
|
27
|
+
throw Object.assign(new Error(code), { statusCode: 400, code });
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
function mockClients(): RouteClients {
|
|
@@ -32,26 +34,45 @@ function mockClients(): RouteClients {
|
|
|
32
34
|
return { id: "local_mail", status: "sent" };
|
|
33
35
|
},
|
|
34
36
|
},
|
|
35
|
-
llm: {
|
|
37
|
+
llm: {
|
|
38
|
+
complete: () => notConfigured("llm_not_configured"),
|
|
39
|
+
stream: () => notConfigured("llm_not_configured"),
|
|
40
|
+
},
|
|
36
41
|
agent: {
|
|
37
|
-
createSession: () =>
|
|
38
|
-
start: () =>
|
|
39
|
-
events: () =>
|
|
40
|
-
subscribe: () =>
|
|
41
|
-
destroy: () =>
|
|
42
|
+
createSession: () => notConfigured("llm_not_configured"),
|
|
43
|
+
start: () => notConfigured("llm_not_configured"),
|
|
44
|
+
events: () => notConfigured("llm_not_configured"),
|
|
45
|
+
subscribe: () => notConfigured("llm_not_configured"),
|
|
46
|
+
destroy: () => notConfigured("llm_not_configured"),
|
|
42
47
|
},
|
|
43
48
|
storage: {
|
|
44
|
-
upload
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
async upload() {
|
|
50
|
+
return {
|
|
51
|
+
key: "x",
|
|
52
|
+
public: false,
|
|
53
|
+
size: 0,
|
|
54
|
+
contentType: "application/octet-stream",
|
|
55
|
+
url: "",
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
async get() {
|
|
59
|
+
return { body: Buffer.alloc(0), contentType: "application/octet-stream", public: false };
|
|
60
|
+
},
|
|
61
|
+
async getUrl() {
|
|
62
|
+
return "";
|
|
63
|
+
},
|
|
64
|
+
async delete() {
|
|
65
|
+
return undefined;
|
|
66
|
+
},
|
|
67
|
+
async list() {
|
|
68
|
+
return { objects: [], total: 0 };
|
|
69
|
+
},
|
|
49
70
|
},
|
|
50
|
-
push: { send: () =>
|
|
71
|
+
push: { send: () => notConfigured("push_not_configured") },
|
|
51
72
|
jobs: { enqueue: async () => ({ id: "nested" }) },
|
|
52
73
|
sockets: {
|
|
53
|
-
send: () =>
|
|
54
|
-
broadcast: () =>
|
|
74
|
+
send: () => undefined,
|
|
75
|
+
broadcast: () => undefined,
|
|
55
76
|
},
|
|
56
77
|
env: {},
|
|
57
78
|
};
|
|
@@ -189,6 +210,7 @@ function engineFor(
|
|
|
189
210
|
store: ReturnType<typeof memoryJobs>,
|
|
190
211
|
jobs: LocalJobsGeneration["jobs"],
|
|
191
212
|
logs: string[] = [],
|
|
213
|
+
onJobFinished?: LocalJobsEngineOptions["onJobFinished"],
|
|
192
214
|
) {
|
|
193
215
|
const generation: LocalJobsGeneration = { db: {} as RobodevDb, jobs };
|
|
194
216
|
return createLocalJobsEngine({
|
|
@@ -196,6 +218,7 @@ function engineFor(
|
|
|
196
218
|
getGeneration: () => generation,
|
|
197
219
|
log: (line) => logs.push(line),
|
|
198
220
|
clients: mockClients,
|
|
221
|
+
onJobFinished,
|
|
199
222
|
});
|
|
200
223
|
}
|
|
201
224
|
|
|
@@ -418,7 +441,7 @@ test("hosted-only clients still throw in job handlers; email is real", async ()
|
|
|
418
441
|
await engine.tickJobs();
|
|
419
442
|
await waitUntil(() => store.jobs[0]?.status === "succeeded");
|
|
420
443
|
assert.equal(seen[0], "email");
|
|
421
|
-
assert.
|
|
444
|
+
assert.equal(seen[1], "llm_not_configured");
|
|
422
445
|
});
|
|
423
446
|
|
|
424
447
|
test("start resets running rows; stop clears timers", async () => {
|
|
@@ -446,3 +469,73 @@ test("start resets running rows; stop clears timers", async () => {
|
|
|
446
469
|
assert.equal(engine.running, false);
|
|
447
470
|
await engine.stop();
|
|
448
471
|
});
|
|
472
|
+
|
|
473
|
+
test("onJobFinished fires once per finishJob for succeeded, failed-retry, and dead", async () => {
|
|
474
|
+
const succeeded: LocalJobFinishedEvent[] = [];
|
|
475
|
+
const okStore = memoryJobs();
|
|
476
|
+
const ok = engineFor(okStore, [{ name: "digest", def: jobDef(() => undefined) }], [], (event) =>
|
|
477
|
+
succeeded.push(event),
|
|
478
|
+
);
|
|
479
|
+
await ok.enqueue({ name: "digest", payload: { n: 1 } });
|
|
480
|
+
await ok.tickJobs();
|
|
481
|
+
await waitUntil(() => succeeded.length === 1);
|
|
482
|
+
assert.equal(succeeded.length, 1);
|
|
483
|
+
assert.equal(succeeded[0]?.outcome, "succeeded");
|
|
484
|
+
assert.equal(succeeded[0]?.engineStatus, "succeeded");
|
|
485
|
+
assert.equal(succeeded[0]?.error, null);
|
|
486
|
+
assert.equal(succeeded[0]?.name, "digest");
|
|
487
|
+
assert.deepEqual(succeeded[0]?.payload, { n: 1 });
|
|
488
|
+
|
|
489
|
+
const retried: LocalJobFinishedEvent[] = [];
|
|
490
|
+
const retryStore = memoryJobs();
|
|
491
|
+
const retry = engineFor(
|
|
492
|
+
retryStore,
|
|
493
|
+
[
|
|
494
|
+
{
|
|
495
|
+
name: "digest",
|
|
496
|
+
def: jobDef(() => {
|
|
497
|
+
throw new Error("boom");
|
|
498
|
+
}),
|
|
499
|
+
},
|
|
500
|
+
],
|
|
501
|
+
[],
|
|
502
|
+
(event) => retried.push(event),
|
|
503
|
+
);
|
|
504
|
+
await retry.enqueue({ name: "digest", payload: { n: 2 } });
|
|
505
|
+
await retry.tickJobs();
|
|
506
|
+
await waitUntil(() => retried.length === 1);
|
|
507
|
+
assert.equal(retried.length, 1);
|
|
508
|
+
assert.equal(retried[0]?.outcome, "failed");
|
|
509
|
+
assert.equal(retried[0]?.engineStatus, "queued");
|
|
510
|
+
assert.equal(retried[0]?.error, "boom");
|
|
511
|
+
assert.equal(retried[0]?.attempts, 1);
|
|
512
|
+
assert.deepEqual(retried[0]?.payload, { n: 2 });
|
|
513
|
+
|
|
514
|
+
const finished: LocalJobFinishedEvent[] = [];
|
|
515
|
+
const deadStore = memoryJobs();
|
|
516
|
+
const dying = engineFor(
|
|
517
|
+
deadStore,
|
|
518
|
+
[
|
|
519
|
+
{
|
|
520
|
+
name: "digest",
|
|
521
|
+
def: jobDef(() => {
|
|
522
|
+
throw new Error("boom");
|
|
523
|
+
}),
|
|
524
|
+
},
|
|
525
|
+
],
|
|
526
|
+
[],
|
|
527
|
+
(event) => finished.push(event),
|
|
528
|
+
);
|
|
529
|
+
await dying.enqueue({ name: "digest" });
|
|
530
|
+
for (let attempt = 1; attempt <= JOB_MAX_ATTEMPTS; attempt++) {
|
|
531
|
+
deadStore.jobs[0]!.run_at = new Date(0);
|
|
532
|
+
deadStore.jobs[0]!.status = "queued";
|
|
533
|
+
await dying.tickJobs();
|
|
534
|
+
await waitUntil(() => finished.length === attempt);
|
|
535
|
+
}
|
|
536
|
+
assert.equal(finished.length, JOB_MAX_ATTEMPTS);
|
|
537
|
+
assert.equal(finished.at(-1)?.outcome, "failed");
|
|
538
|
+
assert.equal(finished.at(-1)?.engineStatus, "dead");
|
|
539
|
+
assert.equal(finished.at(-1)?.attempts, JOB_MAX_ATTEMPTS);
|
|
540
|
+
assert.equal(finished.at(-1)?.error, "boom");
|
|
541
|
+
});
|
package/src/local-jobs.ts
CHANGED
|
@@ -49,11 +49,23 @@ export type LocalJobsGeneration = {
|
|
|
49
49
|
jobs: readonly { name: string; file?: string; def: JobDefinition }[];
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
export type LocalJobFinishedEvent = {
|
|
53
|
+
id: string;
|
|
54
|
+
name: string;
|
|
55
|
+
payload: unknown;
|
|
56
|
+
outcome: "succeeded" | "failed";
|
|
57
|
+
engineStatus: "succeeded" | "queued" | "dead";
|
|
58
|
+
attempts: number;
|
|
59
|
+
error: string | null;
|
|
60
|
+
};
|
|
61
|
+
|
|
52
62
|
export type LocalJobsEngineOptions = {
|
|
53
63
|
query: JobsQueryable["query"];
|
|
54
64
|
getGeneration: () => LocalJobsGeneration | null;
|
|
55
65
|
log?: (line: string) => void;
|
|
56
66
|
clients: () => RouteClients;
|
|
67
|
+
/** Invoked after the job row UPDATE. Failed-retry (`queued`) and `dead` both report `failed`. */
|
|
68
|
+
onJobFinished?: (event: LocalJobFinishedEvent) => void;
|
|
57
69
|
};
|
|
58
70
|
|
|
59
71
|
export type LocalJobsEngine = {
|
|
@@ -221,6 +233,8 @@ export function createLocalJobsEngine(options: LocalJobsEngineOptions): LocalJob
|
|
|
221
233
|
}
|
|
222
234
|
|
|
223
235
|
async function finishJob(row: LocalJobRow, error: string | null): Promise<void> {
|
|
236
|
+
let engineStatus: LocalJobFinishedEvent["engineStatus"];
|
|
237
|
+
let attempts = row.attempts;
|
|
224
238
|
if (!error) {
|
|
225
239
|
await query(
|
|
226
240
|
`UPDATE robodev_jobs.jobs
|
|
@@ -229,21 +243,38 @@ export function createLocalJobsEngine(options: LocalJobsEngineOptions): LocalJob
|
|
|
229
243
|
[row.id],
|
|
230
244
|
);
|
|
231
245
|
log(` ${row.name} succeeded`);
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
const attempts = row.attempts + 1;
|
|
235
|
-
const next = nextJobFailureState(attempts);
|
|
236
|
-
await query(
|
|
237
|
-
`UPDATE robodev_jobs.jobs
|
|
238
|
-
SET status = $2, attempts = $3, last_error = $4, run_at = COALESCE($5, run_at), updated_at = now()
|
|
239
|
-
WHERE id = $1`,
|
|
240
|
-
[row.id, next.status, attempts, error, next.runAt],
|
|
241
|
-
);
|
|
242
|
-
if (next.status === "dead") {
|
|
243
|
-
log(` ${row.name} dead: ${shortError(error)}`);
|
|
246
|
+
engineStatus = "succeeded";
|
|
244
247
|
} else {
|
|
245
|
-
|
|
248
|
+
attempts = row.attempts + 1;
|
|
249
|
+
const next = nextJobFailureState(attempts);
|
|
250
|
+
await query(
|
|
251
|
+
`UPDATE robodev_jobs.jobs
|
|
252
|
+
SET status = $2, attempts = $3, last_error = $4, run_at = COALESCE($5, run_at), updated_at = now()
|
|
253
|
+
WHERE id = $1`,
|
|
254
|
+
[row.id, next.status, attempts, error, next.runAt],
|
|
255
|
+
);
|
|
256
|
+
engineStatus = next.status;
|
|
257
|
+
if (next.status === "dead") {
|
|
258
|
+
log(` ${row.name} dead: ${shortError(error)}`);
|
|
259
|
+
} else {
|
|
260
|
+
log(` ${row.name} failed attempt ${attempts}/${JOB_MAX_ATTEMPTS}: ${shortError(error)}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
let payload: unknown = null;
|
|
264
|
+
try {
|
|
265
|
+
payload = JSON.parse(row.payload_json) as unknown;
|
|
266
|
+
} catch {
|
|
267
|
+
payload = null;
|
|
246
268
|
}
|
|
269
|
+
options.onJobFinished?.({
|
|
270
|
+
id: row.id,
|
|
271
|
+
name: row.name,
|
|
272
|
+
payload,
|
|
273
|
+
outcome: error ? "failed" : "succeeded",
|
|
274
|
+
engineStatus,
|
|
275
|
+
attempts,
|
|
276
|
+
error,
|
|
277
|
+
});
|
|
247
278
|
}
|
|
248
279
|
|
|
249
280
|
async function runClaimedJob(
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
createLocalStorageClient,
|
|
8
|
+
ensureStorageSchema,
|
|
9
|
+
resolveLocalObject,
|
|
10
|
+
type LocalObjectRow,
|
|
11
|
+
type StorageQueryable,
|
|
12
|
+
} from "./local-storage.js";
|
|
13
|
+
import { MAX_OBJECT_BYTES, verifyStorageServeToken } from "./storage-rules.js";
|
|
14
|
+
|
|
15
|
+
const SECRET = "robodev-dev-local-secret";
|
|
16
|
+
const PROJECT_ID = "prj-local";
|
|
17
|
+
const BASE = "http://localhost:4000";
|
|
18
|
+
|
|
19
|
+
function memoryStorage() {
|
|
20
|
+
const objects: LocalObjectRow[] = [];
|
|
21
|
+
|
|
22
|
+
const query: StorageQueryable["query"] = async (text, values = []) => {
|
|
23
|
+
const sql = text.replace(/\s+/g, " ").trim();
|
|
24
|
+
if (/^(CREATE SCHEMA|CREATE TABLE)/.test(sql)) return { rows: [] };
|
|
25
|
+
|
|
26
|
+
if (sql.includes("INSERT INTO robodev_storage.objects")) {
|
|
27
|
+
const key = String(values[0]);
|
|
28
|
+
const existing = objects.find((row) => row.key === key);
|
|
29
|
+
const created = existing?.created_at ?? new Date();
|
|
30
|
+
const row: LocalObjectRow = {
|
|
31
|
+
key,
|
|
32
|
+
public: Boolean(values[1]),
|
|
33
|
+
size_bytes: Number(values[2]),
|
|
34
|
+
content_type: String(values[3]),
|
|
35
|
+
created_at: created,
|
|
36
|
+
};
|
|
37
|
+
if (existing) {
|
|
38
|
+
existing.public = row.public;
|
|
39
|
+
existing.size_bytes = row.size_bytes;
|
|
40
|
+
existing.content_type = row.content_type;
|
|
41
|
+
return { rows: [existing] };
|
|
42
|
+
}
|
|
43
|
+
objects.push(row);
|
|
44
|
+
return { rows: [row] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (sql.includes("FROM robodev_storage.objects WHERE key =")) {
|
|
48
|
+
const row = objects.find((entry) => entry.key === values[0]);
|
|
49
|
+
return { rows: row ? [row] : [] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (sql.includes("DELETE FROM robodev_storage.objects")) {
|
|
53
|
+
const index = objects.findIndex((entry) => entry.key === values[0]);
|
|
54
|
+
if (index >= 0) objects.splice(index, 1);
|
|
55
|
+
return { rows: [] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (sql.includes("SELECT count(*)")) {
|
|
59
|
+
const like = String(values[0]).replace(/%$/, "");
|
|
60
|
+
const prefix = like.replace(/\\([\\%_])/g, "$1");
|
|
61
|
+
const matched = objects.filter((row) => row.key.startsWith(prefix));
|
|
62
|
+
return { rows: [{ count: String(matched.length) }] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (sql.includes("FROM robodev_storage.objects") && sql.includes("ORDER BY created_at")) {
|
|
66
|
+
const like = String(values[0]).replace(/%$/, "");
|
|
67
|
+
const prefix = like.replace(/\\([\\%_])/g, "$1");
|
|
68
|
+
const limit = Number(values[1]);
|
|
69
|
+
const offset = Number(values[2]);
|
|
70
|
+
const matched = objects
|
|
71
|
+
.filter((row) => row.key.startsWith(prefix))
|
|
72
|
+
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime())
|
|
73
|
+
.slice(offset, offset + limit);
|
|
74
|
+
return { rows: matched };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
throw new Error(`unhandled sql: ${sql}`);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return { objects, query };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function withClient(
|
|
84
|
+
run: (
|
|
85
|
+
client: ReturnType<typeof createLocalStorageClient>,
|
|
86
|
+
objectsDir: string,
|
|
87
|
+
query: StorageQueryable["query"],
|
|
88
|
+
) => Promise<void>,
|
|
89
|
+
) {
|
|
90
|
+
const objectsDir = await mkdtemp(join(tmpdir(), "robodev-storage-"));
|
|
91
|
+
const store = memoryStorage();
|
|
92
|
+
const client = createLocalStorageClient({
|
|
93
|
+
query: store.query,
|
|
94
|
+
objectsDir,
|
|
95
|
+
publicBaseUrl: BASE,
|
|
96
|
+
projectId: PROJECT_ID,
|
|
97
|
+
jwtSecret: SECRET,
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
await ensureStorageSchema({ query: store.query });
|
|
101
|
+
await run(client, objectsDir, store.query);
|
|
102
|
+
} finally {
|
|
103
|
+
await rm(objectsDir, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
test("upload get getUrl delete and list", async () => {
|
|
108
|
+
await withClient(async (client) => {
|
|
109
|
+
const uploaded = await client.upload("notes/hello.txt", "hello", {
|
|
110
|
+
public: true,
|
|
111
|
+
contentType: "text/plain",
|
|
112
|
+
});
|
|
113
|
+
assert.equal(uploaded.key, "notes/hello.txt");
|
|
114
|
+
assert.equal(uploaded.public, true);
|
|
115
|
+
assert.equal(uploaded.size, 5);
|
|
116
|
+
assert.equal(uploaded.contentType, "text/plain");
|
|
117
|
+
assert.equal(uploaded.url, `${BASE}/storage/objects/notes/hello.txt`);
|
|
118
|
+
|
|
119
|
+
const got = await client.get("notes/hello.txt");
|
|
120
|
+
assert.equal(got.body.toString("utf8"), "hello");
|
|
121
|
+
assert.equal(got.contentType, "text/plain");
|
|
122
|
+
assert.equal(got.public, true);
|
|
123
|
+
|
|
124
|
+
const listed = await client.list({ prefix: "notes/" });
|
|
125
|
+
assert.equal(listed.total, 1);
|
|
126
|
+
assert.equal(listed.objects[0]?.key, "notes/hello.txt");
|
|
127
|
+
|
|
128
|
+
await client.delete("notes/hello.txt");
|
|
129
|
+
await assert.rejects(
|
|
130
|
+
() => client.get("notes/hello.txt"),
|
|
131
|
+
(err: Error) => err.message === "Storage object not found: notes/hello.txt",
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("public URL has no query; private URL has exp and sig", async () => {
|
|
137
|
+
await withClient(async (client) => {
|
|
138
|
+
await client.upload("pub.bin", "a", { public: true });
|
|
139
|
+
await client.upload("priv.bin", "b", { public: false });
|
|
140
|
+
const publicUrl = await client.getUrl("pub.bin");
|
|
141
|
+
const privateUrl = await client.getUrl("priv.bin");
|
|
142
|
+
assert.equal(publicUrl, `${BASE}/storage/objects/pub.bin`);
|
|
143
|
+
assert.equal(publicUrl.includes("?"), false);
|
|
144
|
+
const parsed = new URL(privateUrl);
|
|
145
|
+
assert.equal(parsed.origin + parsed.pathname, `${BASE}/storage/objects/priv.bin`);
|
|
146
|
+
assert.ok(parsed.searchParams.get("exp"));
|
|
147
|
+
assert.ok(parsed.searchParams.get("sig"));
|
|
148
|
+
assert.equal(
|
|
149
|
+
verifyStorageServeToken(SECRET, PROJECT_ID, "priv.bin", {
|
|
150
|
+
exp: parsed.searchParams.get("exp"),
|
|
151
|
+
sig: parsed.searchParams.get("sig"),
|
|
152
|
+
}),
|
|
153
|
+
true,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("overwrite updates metadata and keeps created_at", async () => {
|
|
159
|
+
await withClient(async (client, objectsDir, query) => {
|
|
160
|
+
await client.upload("same.txt", "one", { public: false, contentType: "text/plain" });
|
|
161
|
+
const first = await resolveLocalObject({ query, objectsDir, key: "same.txt" });
|
|
162
|
+
assert.ok(first);
|
|
163
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
164
|
+
const second = await client.upload("same.txt", "two-two", {
|
|
165
|
+
public: true,
|
|
166
|
+
contentType: "text/html",
|
|
167
|
+
});
|
|
168
|
+
assert.equal(second.public, true);
|
|
169
|
+
assert.equal(second.size, 7);
|
|
170
|
+
assert.equal(second.contentType, "text/html");
|
|
171
|
+
const after = await resolveLocalObject({ query, objectsDir, key: "same.txt" });
|
|
172
|
+
assert.ok(after);
|
|
173
|
+
assert.equal(after.row.created_at.getTime(), first.row.created_at.getTime());
|
|
174
|
+
assert.equal(after.body.toString("utf8"), "two-two");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("missing key throws Storage object not found", async () => {
|
|
179
|
+
await withClient(async (client) => {
|
|
180
|
+
await assert.rejects(
|
|
181
|
+
() => client.get("missing.txt"),
|
|
182
|
+
(err: Error & { code?: string; statusCode?: number }) =>
|
|
183
|
+
err.message === "Storage object not found: missing.txt" &&
|
|
184
|
+
err.code === "not_found" &&
|
|
185
|
+
err.statusCode === 404,
|
|
186
|
+
);
|
|
187
|
+
await assert.rejects(
|
|
188
|
+
() => client.delete("missing.txt"),
|
|
189
|
+
(err: Error) => err.message === "Storage object not found: missing.txt",
|
|
190
|
+
);
|
|
191
|
+
await assert.rejects(
|
|
192
|
+
() => client.getUrl("missing.txt"),
|
|
193
|
+
(err: Error) => err.message === "Storage object not found: missing.txt",
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("object over 25MB is rejected", async () => {
|
|
199
|
+
await withClient(async (client) => {
|
|
200
|
+
await assert.rejects(
|
|
201
|
+
() => client.upload("big.bin", Buffer.alloc(MAX_OBJECT_BYTES + 1)),
|
|
202
|
+
(err: { code?: string; statusCode?: number }) =>
|
|
203
|
+
err.code === "object_too_large" && err.statusCode === 400,
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("list defaults to 50", async () => {
|
|
209
|
+
await withClient(async (client) => {
|
|
210
|
+
for (let i = 0; i < 52; i++) {
|
|
211
|
+
await client.upload(`n${String(i).padStart(2, "0")}.txt`, "x");
|
|
212
|
+
}
|
|
213
|
+
const listed = await client.list();
|
|
214
|
+
assert.equal(listed.objects.length, 50);
|
|
215
|
+
assert.equal(listed.total, 52);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { StorageClient, StorageObject, StorageUploadOptions } from "@robodev-ai/sdk";
|
|
5
|
+
import {
|
|
6
|
+
assertObjectSize,
|
|
7
|
+
mintStorageServeToken,
|
|
8
|
+
parseLogicalKey,
|
|
9
|
+
storageListQuerySchema,
|
|
10
|
+
} from "./storage-rules.js";
|
|
11
|
+
|
|
12
|
+
export type StorageQueryable = {
|
|
13
|
+
query: <T extends Record<string, unknown> = Record<string, unknown>>(
|
|
14
|
+
sql: string,
|
|
15
|
+
values?: unknown[],
|
|
16
|
+
) => Promise<{ rows: T[] }>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type LocalObjectRow = {
|
|
20
|
+
key: string;
|
|
21
|
+
public: boolean;
|
|
22
|
+
size_bytes: string | number;
|
|
23
|
+
content_type: string;
|
|
24
|
+
created_at: Date;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type LocalStorageClientOptions = {
|
|
28
|
+
query: StorageQueryable["query"];
|
|
29
|
+
objectsDir: string;
|
|
30
|
+
publicBaseUrl: string;
|
|
31
|
+
projectId: string;
|
|
32
|
+
jwtSecret: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function notFound(key: string): never {
|
|
36
|
+
throw Object.assign(new Error(`Storage object not found: ${key}`), {
|
|
37
|
+
statusCode: 404,
|
|
38
|
+
code: "not_found",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function escapeLike(value: string): string {
|
|
43
|
+
return value.replace(/[\\%_]/g, "\\$&");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function objectPath(objectsDir: string, key: string): string {
|
|
47
|
+
return join(objectsDir, ...key.split("/"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stableObjectUrl(publicBaseUrl: string, key: string): string {
|
|
51
|
+
return `${publicBaseUrl.replace(/\/$/, "")}/storage/objects/${key}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toStorageObject(publicBaseUrl: string, row: LocalObjectRow): StorageObject {
|
|
55
|
+
return {
|
|
56
|
+
key: row.key,
|
|
57
|
+
public: row.public,
|
|
58
|
+
size: Number(row.size_bytes),
|
|
59
|
+
contentType: row.content_type,
|
|
60
|
+
url: stableObjectUrl(publicBaseUrl, row.key),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function loadRow(
|
|
65
|
+
query: StorageQueryable["query"],
|
|
66
|
+
key: string,
|
|
67
|
+
): Promise<LocalObjectRow | null> {
|
|
68
|
+
const result = await query<LocalObjectRow>(
|
|
69
|
+
`SELECT key, public, size_bytes, content_type, created_at
|
|
70
|
+
FROM robodev_storage.objects WHERE key = $1`,
|
|
71
|
+
[key],
|
|
72
|
+
);
|
|
73
|
+
return result.rows[0] ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readObjectFile(objectsDir: string, key: string): Promise<Buffer | null> {
|
|
77
|
+
try {
|
|
78
|
+
return await readFile(objectPath(objectsDir, key));
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Creates the reserved `robodev_storage` schema. Idempotent, not drizzle, not public. */
|
|
85
|
+
export async function ensureStorageSchema(db: StorageQueryable): Promise<void> {
|
|
86
|
+
await db.query(`CREATE SCHEMA IF NOT EXISTS robodev_storage`);
|
|
87
|
+
await db.query(`
|
|
88
|
+
CREATE TABLE IF NOT EXISTS robodev_storage.objects (
|
|
89
|
+
key TEXT PRIMARY KEY,
|
|
90
|
+
public BOOLEAN NOT NULL,
|
|
91
|
+
size_bytes INTEGER NOT NULL,
|
|
92
|
+
content_type TEXT NOT NULL,
|
|
93
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
94
|
+
)
|
|
95
|
+
`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function resolveLocalObject(input: {
|
|
99
|
+
query: StorageQueryable["query"];
|
|
100
|
+
objectsDir: string;
|
|
101
|
+
key: string;
|
|
102
|
+
}): Promise<{ row: LocalObjectRow; body: Buffer } | null> {
|
|
103
|
+
const logicalKey = parseLogicalKey(input.key);
|
|
104
|
+
const row = await loadRow(input.query, logicalKey);
|
|
105
|
+
if (!row) return null;
|
|
106
|
+
const body = await readObjectFile(input.objectsDir, logicalKey);
|
|
107
|
+
if (!body) return null;
|
|
108
|
+
return { row, body };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createLocalStorageClient(options: LocalStorageClientOptions): StorageClient {
|
|
112
|
+
const query = options.query;
|
|
113
|
+
const objectsDir = options.objectsDir;
|
|
114
|
+
const publicBaseUrl = options.publicBaseUrl;
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
async upload(key, data, upload?: StorageUploadOptions) {
|
|
118
|
+
const logicalKey = parseLogicalKey(key);
|
|
119
|
+
const contentType = upload?.contentType?.trim() || "application/octet-stream";
|
|
120
|
+
const isPublic = Boolean(upload?.public);
|
|
121
|
+
const buffer = typeof data === "string" ? Buffer.from(data) : Buffer.from(data);
|
|
122
|
+
assertObjectSize(buffer.byteLength);
|
|
123
|
+
|
|
124
|
+
await mkdir(objectsDir, { recursive: true });
|
|
125
|
+
const dest = objectPath(objectsDir, logicalKey);
|
|
126
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
127
|
+
const tmp = join(objectsDir, `.tmp-${randomBytes(8).toString("hex")}`);
|
|
128
|
+
await writeFile(tmp, buffer);
|
|
129
|
+
await rename(tmp, dest);
|
|
130
|
+
|
|
131
|
+
const result = await query<LocalObjectRow>(
|
|
132
|
+
`INSERT INTO robodev_storage.objects (key, public, size_bytes, content_type)
|
|
133
|
+
VALUES ($1, $2, $3, $4)
|
|
134
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
135
|
+
public = EXCLUDED.public,
|
|
136
|
+
size_bytes = EXCLUDED.size_bytes,
|
|
137
|
+
content_type = EXCLUDED.content_type
|
|
138
|
+
RETURNING key, public, size_bytes, content_type, created_at`,
|
|
139
|
+
[logicalKey, isPublic, buffer.byteLength, contentType],
|
|
140
|
+
);
|
|
141
|
+
const row = result.rows[0];
|
|
142
|
+
if (!row) throw new Error("Failed to upsert storage metadata");
|
|
143
|
+
return toStorageObject(publicBaseUrl, row);
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
async get(key) {
|
|
147
|
+
const logicalKey = parseLogicalKey(key);
|
|
148
|
+
const resolved = await resolveLocalObject({ query, objectsDir, key: logicalKey });
|
|
149
|
+
if (!resolved) notFound(logicalKey);
|
|
150
|
+
return {
|
|
151
|
+
body: resolved.body,
|
|
152
|
+
contentType: resolved.row.content_type,
|
|
153
|
+
public: resolved.row.public,
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async getUrl(key, urlOptions) {
|
|
158
|
+
const logicalKey = parseLogicalKey(key);
|
|
159
|
+
const row = await loadRow(query, logicalKey);
|
|
160
|
+
if (!row) notFound(logicalKey);
|
|
161
|
+
const body = await readObjectFile(objectsDir, logicalKey);
|
|
162
|
+
if (!body) notFound(logicalKey);
|
|
163
|
+
if (row.public) return stableObjectUrl(publicBaseUrl, logicalKey);
|
|
164
|
+
const { exp, sig } = mintStorageServeToken(
|
|
165
|
+
options.jwtSecret,
|
|
166
|
+
options.projectId,
|
|
167
|
+
logicalKey,
|
|
168
|
+
urlOptions?.expiresIn,
|
|
169
|
+
);
|
|
170
|
+
return `${stableObjectUrl(publicBaseUrl, logicalKey)}?exp=${exp}&sig=${sig}`;
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async delete(key) {
|
|
174
|
+
const logicalKey = parseLogicalKey(key);
|
|
175
|
+
const row = await loadRow(query, logicalKey);
|
|
176
|
+
if (!row) notFound(logicalKey);
|
|
177
|
+
try {
|
|
178
|
+
await unlink(objectPath(objectsDir, logicalKey));
|
|
179
|
+
} catch {
|
|
180
|
+
/* ignore missing file */
|
|
181
|
+
}
|
|
182
|
+
await query(`DELETE FROM robodev_storage.objects WHERE key = $1`, [logicalKey]);
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async list(listOptions) {
|
|
186
|
+
const parsed = storageListQuerySchema.parse({
|
|
187
|
+
prefix: listOptions?.prefix,
|
|
188
|
+
limit: listOptions?.limit,
|
|
189
|
+
offset: listOptions?.offset,
|
|
190
|
+
});
|
|
191
|
+
const prefix = parsed.prefix ?? "";
|
|
192
|
+
if (prefix) parseLogicalKey(prefix.replace(/\/+$/, "") || prefix);
|
|
193
|
+
const like = prefix ? `${escapeLike(prefix)}%` : "%";
|
|
194
|
+
const count = await query<{ count: string }>(
|
|
195
|
+
`SELECT count(*)::text AS count FROM robodev_storage.objects
|
|
196
|
+
WHERE key LIKE $1 ESCAPE E'\\\\'`,
|
|
197
|
+
[like],
|
|
198
|
+
);
|
|
199
|
+
const result = await query<LocalObjectRow>(
|
|
200
|
+
`SELECT key, public, size_bytes, content_type, created_at
|
|
201
|
+
FROM robodev_storage.objects
|
|
202
|
+
WHERE key LIKE $1 ESCAPE E'\\\\'
|
|
203
|
+
ORDER BY created_at DESC
|
|
204
|
+
LIMIT $2 OFFSET $3`,
|
|
205
|
+
[like, parsed.limit, parsed.offset],
|
|
206
|
+
);
|
|
207
|
+
return {
|
|
208
|
+
objects: result.rows.map((row) => toStorageObject(publicBaseUrl, row)),
|
|
209
|
+
total: Number(count.rows[0]?.count ?? 0),
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
MAX_PROJECT_SOCKETS,
|
|
5
|
+
MAX_SOCKET_PAYLOAD_BYTES,
|
|
6
|
+
SOCKET_CLOSE_INVALID,
|
|
7
|
+
SOCKET_CLOSE_POLICY,
|
|
8
|
+
SOCKET_CLOSE_TOO_BIG,
|
|
9
|
+
attachProjectSocket,
|
|
10
|
+
closeProjectSockets,
|
|
11
|
+
dispatchSocketsSend,
|
|
12
|
+
joinSocketRoom,
|
|
13
|
+
parseSocketMessage,
|
|
14
|
+
projectSocketCount,
|
|
15
|
+
sendToSocketRoom,
|
|
16
|
+
setProjectSocketNames,
|
|
17
|
+
stringifySocketPayload,
|
|
18
|
+
} from "./sockets.js";
|
|
19
|
+
|
|
20
|
+
function mockSocket() {
|
|
21
|
+
const sent: string[] = [];
|
|
22
|
+
let readyState = 1;
|
|
23
|
+
return {
|
|
24
|
+
sent,
|
|
25
|
+
get readyState() {
|
|
26
|
+
return readyState;
|
|
27
|
+
},
|
|
28
|
+
send(data: string) {
|
|
29
|
+
sent.push(data);
|
|
30
|
+
},
|
|
31
|
+
close() {
|
|
32
|
+
readyState = 3;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test("51st connection is rejected with 1008", () => {
|
|
38
|
+
const projectId = "sock-limit";
|
|
39
|
+
closeProjectSockets(projectId);
|
|
40
|
+
for (let i = 0; i < MAX_PROJECT_SOCKETS; i++) {
|
|
41
|
+
const attached = attachProjectSocket({
|
|
42
|
+
projectId,
|
|
43
|
+
name: "chat",
|
|
44
|
+
socket: mockSocket(),
|
|
45
|
+
});
|
|
46
|
+
assert.equal(attached.ok, true);
|
|
47
|
+
}
|
|
48
|
+
assert.equal(projectSocketCount(projectId), MAX_PROJECT_SOCKETS);
|
|
49
|
+
const extra = attachProjectSocket({
|
|
50
|
+
projectId,
|
|
51
|
+
name: "chat",
|
|
52
|
+
socket: mockSocket(),
|
|
53
|
+
});
|
|
54
|
+
assert.equal(extra.ok, false);
|
|
55
|
+
if (!extra.ok) {
|
|
56
|
+
assert.equal(extra.code, SOCKET_CLOSE_POLICY);
|
|
57
|
+
}
|
|
58
|
+
closeProjectSockets(projectId);
|
|
59
|
+
assert.equal(projectSocketCount(projectId), 0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("unknown socket name throws unknown_socket", () => {
|
|
63
|
+
setProjectSocketNames("sock-known", ["chat"]);
|
|
64
|
+
assert.throws(
|
|
65
|
+
() => dispatchSocketsSend("sock-known", ["chat"], { name: "missing", payload: { ok: true } }),
|
|
66
|
+
(err: { message?: string; code?: string }) =>
|
|
67
|
+
err.message === "unknown_socket" && err.code === "unknown_socket",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("room broadcast stays on the same socket name", () => {
|
|
72
|
+
const projectId = "sock-rooms";
|
|
73
|
+
closeProjectSockets(projectId);
|
|
74
|
+
setProjectSocketNames(projectId, ["chat", "alerts"]);
|
|
75
|
+
const chatA = mockSocket();
|
|
76
|
+
const chatB = mockSocket();
|
|
77
|
+
const alerts = mockSocket();
|
|
78
|
+
const a = attachProjectSocket({ projectId, name: "chat", socket: chatA });
|
|
79
|
+
const b = attachProjectSocket({ projectId, name: "chat", socket: chatB });
|
|
80
|
+
const c = attachProjectSocket({ projectId, name: "alerts", socket: alerts });
|
|
81
|
+
assert.ok(a.ok && b.ok && c.ok);
|
|
82
|
+
if (!a.ok || !b.ok || !c.ok) return;
|
|
83
|
+
joinSocketRoom(a.conn, "lobby");
|
|
84
|
+
joinSocketRoom(b.conn, "lobby");
|
|
85
|
+
joinSocketRoom(c.conn, "lobby");
|
|
86
|
+
sendToSocketRoom(projectId, "chat", "lobby", JSON.stringify({ hi: true }), a.conn);
|
|
87
|
+
assert.deepEqual(chatA.sent, []);
|
|
88
|
+
assert.deepEqual(chatB.sent, [JSON.stringify({ hi: true })]);
|
|
89
|
+
assert.deepEqual(alerts.sent, []);
|
|
90
|
+
dispatchSocketsSend(projectId, ["chat", "alerts"], {
|
|
91
|
+
name: "chat",
|
|
92
|
+
room: "missing",
|
|
93
|
+
payload: { nope: true },
|
|
94
|
+
});
|
|
95
|
+
assert.deepEqual(chatA.sent, []);
|
|
96
|
+
assert.deepEqual(chatB.sent, [JSON.stringify({ hi: true })]);
|
|
97
|
+
closeProjectSockets(projectId);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("JSON payload size is capped at 100KB", () => {
|
|
101
|
+
const ok = stringifySocketPayload({ ok: true });
|
|
102
|
+
assert.equal(ok, JSON.stringify({ ok: true }));
|
|
103
|
+
assert.throws(() => stringifySocketPayload("a".repeat(MAX_SOCKET_PAYLOAD_BYTES + 1)), /100KB/);
|
|
104
|
+
const parsed = parseSocketMessage('{"ok":true}');
|
|
105
|
+
assert.equal(parsed.ok, true);
|
|
106
|
+
const invalid = parseSocketMessage("{nope");
|
|
107
|
+
assert.equal(invalid.ok, false);
|
|
108
|
+
if (!invalid.ok) assert.equal(invalid.code, SOCKET_CLOSE_INVALID);
|
|
109
|
+
const tooBig = parseSocketMessage("a".repeat(MAX_SOCKET_PAYLOAD_BYTES + 1));
|
|
110
|
+
assert.equal(tooBig.ok, false);
|
|
111
|
+
if (!tooBig.ok) assert.equal(tooBig.code, SOCKET_CLOSE_TOO_BIG);
|
|
112
|
+
});
|
package/src/sockets.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApiAuth,
|
|
3
|
+
SocketHandlerContext,
|
|
4
|
+
SocketRoomClient,
|
|
5
|
+
SocketsClient,
|
|
6
|
+
} from "@robodev-ai/sdk";
|
|
7
|
+
import { clampTimeoutMs, withHandlerTimeout } from "./handler-timeout.js";
|
|
8
|
+
import { id } from "./ids.js";
|
|
9
|
+
|
|
10
|
+
export const MAX_PROJECT_SOCKETS = 50;
|
|
11
|
+
export const MAX_SOCKET_PAYLOAD_BYTES = 100 * 1024;
|
|
12
|
+
export const SOCKET_IDLE_PING_MS = 60_000;
|
|
13
|
+
export const SOCKET_CLOSE_POLICY = 1008;
|
|
14
|
+
export const SOCKET_CLOSE_INVALID = 1007;
|
|
15
|
+
export const SOCKET_CLOSE_TOO_BIG = 1009;
|
|
16
|
+
export const SOCKET_CLOSE_RESTART = 1012;
|
|
17
|
+
|
|
18
|
+
export type SocketWire = {
|
|
19
|
+
send: (data: string) => void;
|
|
20
|
+
close: (code?: number, reason?: string) => void;
|
|
21
|
+
readyState: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type LiveConn = {
|
|
25
|
+
id: string;
|
|
26
|
+
projectId: string;
|
|
27
|
+
name: string;
|
|
28
|
+
socket: SocketWire;
|
|
29
|
+
rooms: Set<string>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type SocketSessionHelpers = {
|
|
33
|
+
send: (payload: unknown) => void;
|
|
34
|
+
close: (code?: number, reason?: string) => void;
|
|
35
|
+
room: SocketRoomClient;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type BindSocketSessionInput = {
|
|
39
|
+
projectId: string;
|
|
40
|
+
name: string;
|
|
41
|
+
socket: SocketWire;
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
createContext: (helpers: SocketSessionHelpers) => SocketHandlerContext;
|
|
44
|
+
onConnect?: (ctx: SocketHandlerContext) => Promise<void> | void;
|
|
45
|
+
onMessage: (ctx: SocketHandlerContext, value: unknown) => Promise<void> | void;
|
|
46
|
+
onClose?: (ctx: SocketHandlerContext) => Promise<void> | void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const byProject = new Map<string, Set<LiveConn>>();
|
|
50
|
+
const knownNamesByProject = new Map<string, string[]>();
|
|
51
|
+
|
|
52
|
+
export function setProjectSocketNames(projectId: string, names: string[]): void {
|
|
53
|
+
knownNamesByProject.set(projectId, names);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function stringifySocketPayload(payload: unknown): string {
|
|
57
|
+
const json = JSON.stringify(payload);
|
|
58
|
+
if (Buffer.byteLength(json, "utf8") > MAX_SOCKET_PAYLOAD_BYTES) {
|
|
59
|
+
throw new Error("socket payload must be at most 100KB");
|
|
60
|
+
}
|
|
61
|
+
return json;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseSocketMessage(
|
|
65
|
+
raw: string,
|
|
66
|
+
): { ok: true; value: unknown } | { ok: false; code: number } {
|
|
67
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_SOCKET_PAYLOAD_BYTES) {
|
|
68
|
+
return { ok: false, code: SOCKET_CLOSE_TOO_BIG };
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return { ok: true, value: JSON.parse(raw) as unknown };
|
|
72
|
+
} catch {
|
|
73
|
+
return { ok: false, code: SOCKET_CLOSE_INVALID };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function socketAuthDisplay(auth: ApiAuth): "required" | "public" {
|
|
78
|
+
return auth === "required" || typeof auth === "function" ? "required" : "public";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function assertKnownSocket(name: string, known: Iterable<string>): void {
|
|
82
|
+
if (!new Set(known).has(name)) {
|
|
83
|
+
throw Object.assign(new Error("unknown_socket"), { code: "unknown_socket" });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function projectSocketCount(projectId: string): number {
|
|
88
|
+
return byProject.get(projectId)?.size ?? 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function closeProjectSockets(
|
|
92
|
+
projectId: string,
|
|
93
|
+
code = SOCKET_CLOSE_RESTART,
|
|
94
|
+
reason = "deployed",
|
|
95
|
+
): void {
|
|
96
|
+
const set = byProject.get(projectId);
|
|
97
|
+
if (!set) return;
|
|
98
|
+
for (const conn of [...set]) {
|
|
99
|
+
try {
|
|
100
|
+
conn.socket.close(code, reason);
|
|
101
|
+
} catch {
|
|
102
|
+
/* already closed */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
byProject.delete(projectId);
|
|
106
|
+
knownNamesByProject.delete(projectId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function attachProjectSocket(input: {
|
|
110
|
+
projectId: string;
|
|
111
|
+
name: string;
|
|
112
|
+
socket: SocketWire;
|
|
113
|
+
}): { ok: true; conn: LiveConn } | { ok: false; code: number; reason: string } {
|
|
114
|
+
const current = byProject.get(input.projectId) ?? new Set<LiveConn>();
|
|
115
|
+
if (current.size >= MAX_PROJECT_SOCKETS) {
|
|
116
|
+
return { ok: false, code: SOCKET_CLOSE_POLICY, reason: "too_many_connections" };
|
|
117
|
+
}
|
|
118
|
+
const conn: LiveConn = {
|
|
119
|
+
id: id("sock"),
|
|
120
|
+
projectId: input.projectId,
|
|
121
|
+
name: input.name,
|
|
122
|
+
socket: input.socket,
|
|
123
|
+
rooms: new Set(),
|
|
124
|
+
};
|
|
125
|
+
current.add(conn);
|
|
126
|
+
byProject.set(input.projectId, current);
|
|
127
|
+
return { ok: true, conn };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function detachProjectSocket(conn: LiveConn): void {
|
|
131
|
+
const set = byProject.get(conn.projectId);
|
|
132
|
+
if (!set) return;
|
|
133
|
+
set.delete(conn);
|
|
134
|
+
if (set.size === 0) byProject.delete(conn.projectId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function joinSocketRoom(conn: LiveConn, roomId: string): void {
|
|
138
|
+
conn.rooms.add(roomId);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function leaveSocketRoom(conn: LiveConn, roomId: string): void {
|
|
142
|
+
conn.rooms.delete(roomId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function sendRaw(socket: SocketWire, json: string): void {
|
|
146
|
+
if (socket.readyState !== 1) return;
|
|
147
|
+
socket.send(json);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function sendToSocketName(projectId: string, name: string, json: string): void {
|
|
151
|
+
const set = byProject.get(projectId);
|
|
152
|
+
if (!set) return;
|
|
153
|
+
for (const conn of set) {
|
|
154
|
+
if (conn.name === name) sendRaw(conn.socket, json);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function sendToSocketRoom(
|
|
159
|
+
projectId: string,
|
|
160
|
+
name: string,
|
|
161
|
+
roomId: string,
|
|
162
|
+
json: string,
|
|
163
|
+
except?: LiveConn,
|
|
164
|
+
): void {
|
|
165
|
+
const set = byProject.get(projectId);
|
|
166
|
+
if (!set) return;
|
|
167
|
+
for (const conn of set) {
|
|
168
|
+
if (conn === except) continue;
|
|
169
|
+
if (conn.name === name && conn.rooms.has(roomId)) sendRaw(conn.socket, json);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function dispatchSocketsSend(
|
|
174
|
+
projectId: string,
|
|
175
|
+
knownNames: Iterable<string>,
|
|
176
|
+
input: { name: string; room?: string; payload: unknown },
|
|
177
|
+
): void {
|
|
178
|
+
assertKnownSocket(input.name, knownNames);
|
|
179
|
+
const json = stringifySocketPayload(input.payload);
|
|
180
|
+
if (input.room !== undefined) {
|
|
181
|
+
sendToSocketRoom(projectId, input.name, input.room, json);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
sendToSocketName(projectId, input.name, json);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function createProjectSocketsClient(projectId: string): SocketsClient {
|
|
188
|
+
function knownNames(): string[] {
|
|
189
|
+
return knownNamesByProject.get(projectId) ?? [];
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
send(input) {
|
|
193
|
+
dispatchSocketsSend(projectId, knownNames(), input);
|
|
194
|
+
},
|
|
195
|
+
broadcast(input) {
|
|
196
|
+
dispatchSocketsSend(projectId, knownNames(), input);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function runSocketCallback(
|
|
202
|
+
timeoutMs: number | undefined,
|
|
203
|
+
run: () => Promise<void> | void,
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
await withHandlerTimeout(() => Promise.resolve(run()), clampTimeoutMs(timeoutMs));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function bindSocketSession(
|
|
209
|
+
input: BindSocketSessionInput,
|
|
210
|
+
): Promise<
|
|
211
|
+
| { ok: false; code: number; reason: string }
|
|
212
|
+
| { ok: true; onText: (raw: string) => void; onBinary: () => void; onClose: () => void }
|
|
213
|
+
> {
|
|
214
|
+
const attached = attachProjectSocket({
|
|
215
|
+
projectId: input.projectId,
|
|
216
|
+
name: input.name,
|
|
217
|
+
socket: input.socket,
|
|
218
|
+
});
|
|
219
|
+
if (!attached.ok) return attached;
|
|
220
|
+
|
|
221
|
+
const helpers: SocketSessionHelpers = {
|
|
222
|
+
send(payload) {
|
|
223
|
+
sendRaw(input.socket, stringifySocketPayload(payload));
|
|
224
|
+
},
|
|
225
|
+
close(code, reason) {
|
|
226
|
+
input.socket.close(code, reason);
|
|
227
|
+
},
|
|
228
|
+
room: {
|
|
229
|
+
join: (roomId) => joinSocketRoom(attached.conn, roomId),
|
|
230
|
+
leave: (roomId) => leaveSocketRoom(attached.conn, roomId),
|
|
231
|
+
broadcast: (roomId, payload) => {
|
|
232
|
+
sendToSocketRoom(
|
|
233
|
+
input.projectId,
|
|
234
|
+
input.name,
|
|
235
|
+
roomId,
|
|
236
|
+
stringifySocketPayload(payload),
|
|
237
|
+
attached.conn,
|
|
238
|
+
);
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const ctx = input.createContext(helpers);
|
|
244
|
+
|
|
245
|
+
let closed = false;
|
|
246
|
+
const finish = async () => {
|
|
247
|
+
if (closed) return;
|
|
248
|
+
closed = true;
|
|
249
|
+
detachProjectSocket(attached.conn);
|
|
250
|
+
if (input.onClose) {
|
|
251
|
+
try {
|
|
252
|
+
await runSocketCallback(input.timeoutMs, () => input.onClose?.(ctx));
|
|
253
|
+
} catch {
|
|
254
|
+
/* onClose must not throw to the wire */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
if (input.onConnect) {
|
|
260
|
+
try {
|
|
261
|
+
await runSocketCallback(input.timeoutMs, () => input.onConnect?.(ctx));
|
|
262
|
+
} catch {
|
|
263
|
+
input.socket.close(1011, "handler_failed");
|
|
264
|
+
await finish();
|
|
265
|
+
return { ok: false, code: 1011, reason: "handler_failed" };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
onText(raw) {
|
|
272
|
+
if (closed) return;
|
|
273
|
+
const parsed = parseSocketMessage(raw);
|
|
274
|
+
if (!parsed.ok) {
|
|
275
|
+
input.socket.close(
|
|
276
|
+
parsed.code,
|
|
277
|
+
parsed.code === SOCKET_CLOSE_TOO_BIG ? "too_big" : "invalid_json",
|
|
278
|
+
);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
void runSocketCallback(input.timeoutMs, () => input.onMessage(ctx, parsed.value)).catch(
|
|
282
|
+
() => undefined,
|
|
283
|
+
);
|
|
284
|
+
},
|
|
285
|
+
onBinary() {
|
|
286
|
+
if (closed) return;
|
|
287
|
+
input.socket.close(1003, "binary not supported");
|
|
288
|
+
},
|
|
289
|
+
onClose() {
|
|
290
|
+
void finish();
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_LIST_LIMIT,
|
|
5
|
+
MAX_LIST_LIMIT,
|
|
6
|
+
MAX_OBJECT_BYTES,
|
|
7
|
+
PRESIGN_GET_DEFAULT,
|
|
8
|
+
PRESIGN_GET_MAX,
|
|
9
|
+
assertObjectSize,
|
|
10
|
+
mintStorageServeToken,
|
|
11
|
+
parseLogicalKey,
|
|
12
|
+
storageListQuerySchema,
|
|
13
|
+
verifyStorageServeToken,
|
|
14
|
+
} from "./storage-rules.js";
|
|
15
|
+
|
|
16
|
+
const SECRET = "robodev-dev-local-secret";
|
|
17
|
+
|
|
18
|
+
test("parseLogicalKey accepts hosted-safe keys", () => {
|
|
19
|
+
assert.equal(parseLogicalKey("avatars/me.png"), "avatars/me.png");
|
|
20
|
+
assert.equal(parseLogicalKey("a"), "a");
|
|
21
|
+
assert.equal(parseLogicalKey("A-Za-z0.9_"), "A-Za-z0.9_");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("parseLogicalKey rejects empty, slash, and .. segments", () => {
|
|
25
|
+
assert.throws(
|
|
26
|
+
() => parseLogicalKey(""),
|
|
27
|
+
(err: { code?: string; statusCode?: number }) =>
|
|
28
|
+
err.code === "invalid_key" && err.statusCode === 400,
|
|
29
|
+
);
|
|
30
|
+
assert.throws(
|
|
31
|
+
() => parseLogicalKey("/abs"),
|
|
32
|
+
(err: { code?: string }) => err.code === "invalid_key",
|
|
33
|
+
);
|
|
34
|
+
assert.throws(
|
|
35
|
+
() => parseLogicalKey("../x"),
|
|
36
|
+
(err: { code?: string }) => err.code === "invalid_key",
|
|
37
|
+
);
|
|
38
|
+
assert.throws(
|
|
39
|
+
() => parseLogicalKey("a/../b"),
|
|
40
|
+
(err: { code?: string }) => err.code === "invalid_key",
|
|
41
|
+
);
|
|
42
|
+
assert.throws(
|
|
43
|
+
() => parseLogicalKey("a//b"),
|
|
44
|
+
(err: { code?: string }) => err.code === "invalid_key",
|
|
45
|
+
);
|
|
46
|
+
assert.throws(
|
|
47
|
+
() => parseLogicalKey("a/./b"),
|
|
48
|
+
(err: { code?: string }) => err.code === "invalid_key",
|
|
49
|
+
);
|
|
50
|
+
assert.throws(
|
|
51
|
+
() => parseLogicalKey("has space"),
|
|
52
|
+
(err: { code?: string }) => err.code === "invalid_key",
|
|
53
|
+
);
|
|
54
|
+
assert.throws(
|
|
55
|
+
() => parseLogicalKey("x".repeat(513)),
|
|
56
|
+
(err: { code?: string }) => err.code === "invalid_key",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("list query defaults and caps", () => {
|
|
61
|
+
const parsed = storageListQuerySchema.parse({});
|
|
62
|
+
assert.equal(parsed.limit, DEFAULT_LIST_LIMIT);
|
|
63
|
+
assert.equal(parsed.offset, 0);
|
|
64
|
+
assert.equal(parsed.prefix, undefined);
|
|
65
|
+
assert.equal(storageListQuerySchema.parse({ limit: 100 }).limit, MAX_LIST_LIMIT);
|
|
66
|
+
assert.throws(() => storageListQuerySchema.parse({ limit: 101 }));
|
|
67
|
+
assert.throws(() => storageListQuerySchema.parse({ offset: -1 }));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("assertObjectSize uses 25MB", () => {
|
|
71
|
+
assertObjectSize(MAX_OBJECT_BYTES);
|
|
72
|
+
assert.throws(
|
|
73
|
+
() => assertObjectSize(MAX_OBJECT_BYTES + 1),
|
|
74
|
+
(err: { code?: string; statusCode?: number }) =>
|
|
75
|
+
err.code === "object_too_large" && err.statusCode === 400,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("serve tokens verify until expiry", () => {
|
|
80
|
+
const { exp, sig } = mintStorageServeToken(SECRET, "prj-1", "avatars/me.png", 900);
|
|
81
|
+
assert.equal(
|
|
82
|
+
verifyStorageServeToken(SECRET, "prj-1", "avatars/me.png", { exp: String(exp), sig }),
|
|
83
|
+
true,
|
|
84
|
+
);
|
|
85
|
+
assert.equal(verifyStorageServeToken(SECRET, "prj-1", "other", { exp: String(exp), sig }), false);
|
|
86
|
+
assert.equal(
|
|
87
|
+
verifyStorageServeToken(SECRET, "prj-2", "avatars/me.png", { exp: String(exp), sig }),
|
|
88
|
+
false,
|
|
89
|
+
);
|
|
90
|
+
assert.equal(
|
|
91
|
+
verifyStorageServeToken(SECRET, "prj-1", "avatars/me.png", { exp: String(exp), sig: "nope" }),
|
|
92
|
+
false,
|
|
93
|
+
);
|
|
94
|
+
assert.equal(verifyStorageServeToken(SECRET, "prj-1", "avatars/me.png", {}), false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("expired serve tokens fail", () => {
|
|
98
|
+
const { sig } = mintStorageServeToken(SECRET, "prj-1", "file.bin", PRESIGN_GET_DEFAULT);
|
|
99
|
+
const exp = Math.floor(Date.now() / 1000) - 10;
|
|
100
|
+
assert.equal(
|
|
101
|
+
verifyStorageServeToken(SECRET, "prj-1", "file.bin", { exp: String(exp), sig }),
|
|
102
|
+
false,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("expiresIn is clamped to 1 hour", () => {
|
|
107
|
+
const before = Math.floor(Date.now() / 1000);
|
|
108
|
+
const { exp } = mintStorageServeToken(SECRET, "prj-1", "file.bin", 10_000);
|
|
109
|
+
assert.ok(exp <= before + PRESIGN_GET_MAX + 1);
|
|
110
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const MAX_OBJECT_BYTES = 25 * 1024 * 1024;
|
|
5
|
+
export const DEFAULT_LIST_LIMIT = 50;
|
|
6
|
+
export const MAX_LIST_LIMIT = 100;
|
|
7
|
+
export const PRESIGN_GET_DEFAULT = 900;
|
|
8
|
+
export const PRESIGN_GET_MAX = 3600;
|
|
9
|
+
|
|
10
|
+
const KEY_RE = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
|
|
11
|
+
|
|
12
|
+
export const storageListQuerySchema = z.object({
|
|
13
|
+
prefix: z.string().optional(),
|
|
14
|
+
limit: z.coerce.number().int().positive().max(MAX_LIST_LIMIT).default(DEFAULT_LIST_LIMIT),
|
|
15
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function codedError(statusCode: number, code: string, message: string): never {
|
|
19
|
+
throw Object.assign(new Error(message), { statusCode, code });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseLogicalKey(key: string): string {
|
|
23
|
+
if (!key || key.length > 512) {
|
|
24
|
+
codedError(400, "invalid_key", "Storage key must be 1–512 characters.");
|
|
25
|
+
}
|
|
26
|
+
if (key.startsWith("/")) {
|
|
27
|
+
codedError(400, "invalid_key", "Storage key must not start with /.");
|
|
28
|
+
}
|
|
29
|
+
const segments = key.split("/");
|
|
30
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
31
|
+
codedError(400, "invalid_key", "Storage key must not contain empty or .. segments.");
|
|
32
|
+
}
|
|
33
|
+
if (!KEY_RE.test(key)) {
|
|
34
|
+
codedError(400, "invalid_key", "Storage key may only contain A-Za-z0-9._- and /.");
|
|
35
|
+
}
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function assertObjectSize(size: number): void {
|
|
40
|
+
if (size > MAX_OBJECT_BYTES) {
|
|
41
|
+
codedError(400, "object_too_large", "File exceeds the 25MB limit.");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function clampStorageExpiresIn(expiresIn?: number): number {
|
|
46
|
+
if (!expiresIn || !Number.isFinite(expiresIn)) return PRESIGN_GET_DEFAULT;
|
|
47
|
+
return Math.min(Math.max(Math.trunc(expiresIn), 1), PRESIGN_GET_MAX);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function serveSignature(secret: string, projectId: string, key: string, exp: number): string {
|
|
51
|
+
return createHmac("sha256", secret).update(`${projectId}\n${key}\n${exp}`).digest("hex");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function mintStorageServeToken(
|
|
55
|
+
secret: string,
|
|
56
|
+
projectId: string,
|
|
57
|
+
key: string,
|
|
58
|
+
expiresIn?: number,
|
|
59
|
+
): { exp: number; sig: string } {
|
|
60
|
+
const exp = Math.floor(Date.now() / 1000) + clampStorageExpiresIn(expiresIn);
|
|
61
|
+
return { exp, sig: serveSignature(secret, projectId, key, exp) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function verifyStorageServeToken(
|
|
65
|
+
secret: string,
|
|
66
|
+
projectId: string,
|
|
67
|
+
key: string,
|
|
68
|
+
query: { exp?: unknown; sig?: unknown },
|
|
69
|
+
): boolean {
|
|
70
|
+
const expRaw = Array.isArray(query.exp) ? query.exp[0] : query.exp;
|
|
71
|
+
const sigRaw = Array.isArray(query.sig) ? query.sig[0] : query.sig;
|
|
72
|
+
if (typeof expRaw !== "string" && typeof expRaw !== "number") return false;
|
|
73
|
+
if (typeof sigRaw !== "string" || !sigRaw) return false;
|
|
74
|
+
const exp = Number(expRaw);
|
|
75
|
+
if (!Number.isFinite(exp) || exp <= Math.floor(Date.now() / 1000)) return false;
|
|
76
|
+
const expected = serveSignature(secret, projectId, key, Math.trunc(exp));
|
|
77
|
+
const given = sigRaw.toLowerCase();
|
|
78
|
+
if (expected.length !== given.length) return false;
|
|
79
|
+
return timingSafeEqual(Buffer.from(expected), Buffer.from(given));
|
|
80
|
+
}
|