@robodev-ai/runtime 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/auth-core.ts +49 -0
- package/src/index.ts +73 -1
- package/src/local-auth.test.ts +262 -6
- package/src/local-auth.ts +180 -5
- package/src/local-google.test.ts +93 -0
- package/src/local-google.ts +232 -0
- package/src/local-jobs.test.ts +36 -17
- 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
|
@@ -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
|
+
}
|