@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@robodev-ai/runtime",
3
- "version": "0.3.0",
4
- "description": "Robodev project runtime — deploy-file classification, esbuild compile, module loading, schema push, route matching, OpenAPI, handler invocation, and Robodev Auth. Shared by hosted Starbase and `robodev dev`.",
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,
@@ -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 unavailable(feature: string): never {
25
- throw new Error(`ctx.${feature} needs hosted Starbase. Run robodev deploy to use it.`);
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: { complete: () => unavailable("llm"), stream: () => unavailable("llm") },
37
+ llm: {
38
+ complete: () => notConfigured("llm_not_configured"),
39
+ stream: () => notConfigured("llm_not_configured"),
40
+ },
36
41
  agent: {
37
- createSession: () => unavailable("agent"),
38
- start: () => unavailable("agent"),
39
- events: () => unavailable("agent"),
40
- subscribe: () => unavailable("agent"),
41
- destroy: () => unavailable("agent"),
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: () => unavailable("storage"),
45
- get: () => unavailable("storage"),
46
- getUrl: () => unavailable("storage"),
47
- delete: () => unavailable("storage"),
48
- list: () => unavailable("storage"),
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: () => unavailable("push") },
71
+ push: { send: () => notConfigured("push_not_configured") },
51
72
  jobs: { enqueue: async () => ({ id: "nested" }) },
52
73
  sockets: {
53
- send: () => unavailable("sockets"),
54
- broadcast: () => unavailable("sockets"),
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.match(seen[1] ?? "", /ctx\.llm needs hosted Starbase/);
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
- return;
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
- log(` ${row.name} failed attempt ${attempts}/${JOB_MAX_ATTEMPTS}: ${shortError(error)}`);
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
+ }