@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.
@@ -0,0 +1,93 @@
1
+ import assert from "node:assert/strict";
2
+ import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose";
3
+ import { test } from "node:test";
4
+ import {
5
+ LOCAL_DEV_IDENTITY_AUD,
6
+ LOCAL_DEV_IDENTITY_TYP,
7
+ resetLocalDevAssertionReplayForTests,
8
+ verifyLocalDevIdentityAssertion,
9
+ } from "./local-google.js";
10
+
11
+ async function signedAssertion(input: {
12
+ typ?: string;
13
+ iss?: string;
14
+ aud?: string;
15
+ callback?: string;
16
+ redirect?: string;
17
+ jti?: string;
18
+ }) {
19
+ const { privateKey, publicKey } = await generateKeyPair("EdDSA", { crv: "Ed25519" });
20
+ const jwk = await exportJWK(publicKey);
21
+ const now = Math.floor(Date.now() / 1000);
22
+ const token = await new SignJWT({
23
+ typ: input.typ ?? LOCAL_DEV_IDENTITY_TYP,
24
+ email: "a@b.com",
25
+ name: "A",
26
+ google_subject: "sub",
27
+ callback_uri: input.callback ?? "http://localhost:4000/api/user/auth/google/callback",
28
+ redirect_uri: input.redirect ?? "http://localhost:5173/",
29
+ })
30
+ .setProtectedHeader({ alg: "EdDSA", typ: "JWT", kid: "k" })
31
+ .setIssuer(input.iss ?? "https://robodev.povio.dev")
32
+ .setAudience(input.aud ?? LOCAL_DEV_IDENTITY_AUD)
33
+ .setJti(input.jti ?? "jti-a")
34
+ .setIssuedAt(now)
35
+ .setExpirationTime(now + 120)
36
+ .sign(privateKey);
37
+ return {
38
+ token,
39
+ jwks: createLocalJWKSet({ keys: [{ ...jwk, kid: "k", use: "sig", alg: "EdDSA" }] }),
40
+ };
41
+ }
42
+
43
+ test("verifyLocalDevIdentityAssertion rejects wrong aud, iss, typ, and replay", async () => {
44
+ resetLocalDevAssertionReplayForTests();
45
+ const ok = await signedAssertion({});
46
+ const identity = await verifyLocalDevIdentityAssertion(ok.token, {
47
+ brokerBaseUrl: "https://robodev.povio.dev",
48
+ callbackUri: "http://localhost:4000/api/user/auth/google/callback",
49
+ jwks: ok.jwks,
50
+ });
51
+ assert.equal(identity.email, "a@b.com");
52
+
53
+ await assert.rejects(
54
+ () =>
55
+ verifyLocalDevIdentityAssertion(ok.token, {
56
+ brokerBaseUrl: "https://robodev.povio.dev",
57
+ callbackUri: "http://localhost:4000/api/user/auth/google/callback",
58
+ jwks: ok.jwks,
59
+ }),
60
+ /invalid_assertion/,
61
+ );
62
+
63
+ resetLocalDevAssertionReplayForTests();
64
+ const badAud = await signedAssertion({ aud: "someone-else" });
65
+ await assert.rejects(() =>
66
+ verifyLocalDevIdentityAssertion(badAud.token, {
67
+ brokerBaseUrl: "https://robodev.povio.dev",
68
+ callbackUri: "http://localhost:4000/api/user/auth/google/callback",
69
+ jwks: badAud.jwks,
70
+ }),
71
+ );
72
+
73
+ const badIss = await signedAssertion({ iss: "https://evil.example" });
74
+ await assert.rejects(() =>
75
+ verifyLocalDevIdentityAssertion(badIss.token, {
76
+ brokerBaseUrl: "https://robodev.povio.dev",
77
+ callbackUri: "http://localhost:4000/api/user/auth/google/callback",
78
+ jwks: badIss.jwks,
79
+ }),
80
+ );
81
+
82
+ const badTyp = await signedAssertion({ typ: "not_identity" });
83
+ await assert.rejects(
84
+ () =>
85
+ verifyLocalDevIdentityAssertion(badTyp.token, {
86
+ brokerBaseUrl: "https://robodev.povio.dev",
87
+ callbackUri: "http://localhost:4000/api/user/auth/google/callback",
88
+ jwks: badTyp.jwks,
89
+ }),
90
+ /invalid_assertion/,
91
+ );
92
+ resetLocalDevAssertionReplayForTests();
93
+ });
@@ -0,0 +1,232 @@
1
+ import { createRemoteJWKSet, jwtVerify, SignJWT, type JWTVerifyGetKey } from "jose";
2
+
3
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
4
+ const LOCAL_STATE_TYP = "local_google_oauth_state";
5
+ export const LOCAL_DEV_IDENTITY_TYP = "robodev_local_dev_identity";
6
+ export const LOCAL_DEV_IDENTITY_AUD = "robodev-local-dev";
7
+ const JTI_TTL_MS = 3 * 60 * 1000;
8
+
9
+ const usedJti = new Map<string, number>();
10
+
11
+ function invalidRedirect(message: string): Error {
12
+ return Object.assign(new Error(message), {
13
+ statusCode: 400,
14
+ code: "invalid_redirect_uri",
15
+ });
16
+ }
17
+
18
+ function invalidAssertion(message = "invalid_assertion"): Error {
19
+ return Object.assign(new Error(message), {
20
+ statusCode: 400,
21
+ code: "invalid_assertion",
22
+ });
23
+ }
24
+
25
+ function parseLoopbackUrl(raw: string): URL {
26
+ let url: URL;
27
+ try {
28
+ url = new URL(raw);
29
+ } catch {
30
+ throw invalidRedirect("redirect_uri is not a valid URL");
31
+ }
32
+ if (url.username || url.password) {
33
+ throw invalidRedirect("redirect_uri must not include credentials");
34
+ }
35
+ if (!LOOPBACK_HOSTS.has(url.hostname)) {
36
+ throw invalidRedirect("redirect_uri must be a loopback host");
37
+ }
38
+ url.hash = "";
39
+ return url;
40
+ }
41
+
42
+ /** http-only loopback URL. Starbase bounce target. */
43
+ export function assertLocalBrokerRedirectUri(raw: string): string {
44
+ const url = parseLoopbackUrl(raw);
45
+ if (url.protocol !== "http:") {
46
+ throw invalidRedirect("callback_uri must be http on a loopback host");
47
+ }
48
+ return url.toString();
49
+ }
50
+
51
+ /** Loopback http or https. Local app `redirect_uri` (Vite may be https). */
52
+ export function assertLocalAppRedirectUri(raw: string): string {
53
+ const url = parseLoopbackUrl(raw);
54
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
55
+ throw invalidRedirect("redirect_uri must be http or https on a loopback host");
56
+ }
57
+ return url.toString();
58
+ }
59
+
60
+ export function localGoogleCallbackUri(listenOrigin: string): string {
61
+ return `${listenOrigin.replace(/\/$/, "")}/api/user/auth/google/callback`;
62
+ }
63
+
64
+ export function projectGoogleOverrideMode(
65
+ clientId?: string,
66
+ clientSecret?: string,
67
+ ): "both" | "one" | "none" {
68
+ const id = clientId?.trim() ?? "";
69
+ const secret = clientSecret?.trim() ?? "";
70
+ if (id && secret) return "both";
71
+ if (id || secret) return "one";
72
+ return "none";
73
+ }
74
+
75
+ export function brokerDevStartUrl(
76
+ brokerBaseUrl: string,
77
+ input: { callbackUri: string; redirectUri: string },
78
+ ): string {
79
+ const url = new URL(`${brokerBaseUrl.replace(/\/$/, "")}/internal/auth/google/dev`);
80
+ url.searchParams.set("callback_uri", input.callbackUri);
81
+ url.searchParams.set("redirect_uri", input.redirectUri);
82
+ return url.toString();
83
+ }
84
+
85
+ export function redirectWithQuery(redirectUri: string, params: Record<string, string>): string {
86
+ const url = new URL(redirectUri);
87
+ for (const [key, value] of Object.entries(params)) {
88
+ url.searchParams.set(key, value);
89
+ }
90
+ return url.toString();
91
+ }
92
+
93
+ /** Google authorize URL with a caller-supplied redirect (local override). */
94
+ export function localGoogleAuthorizeUrl(
95
+ state: string,
96
+ creds: { clientId: string; redirectUri: string },
97
+ ): string {
98
+ const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
99
+ url.searchParams.set("client_id", creds.clientId);
100
+ url.searchParams.set("redirect_uri", creds.redirectUri);
101
+ url.searchParams.set("response_type", "code");
102
+ url.searchParams.set("scope", "openid email profile");
103
+ url.searchParams.set("state", state);
104
+ url.searchParams.set("prompt", "select_account");
105
+ return url.toString();
106
+ }
107
+
108
+ export async function exchangeLocalGoogleCode(
109
+ code: string,
110
+ creds: { clientId: string; clientSecret: string; redirectUri: string },
111
+ ): Promise<{ email: string; name: string | null; subject: string }> {
112
+ const body = new URLSearchParams({
113
+ code,
114
+ client_id: creds.clientId,
115
+ client_secret: creds.clientSecret,
116
+ redirect_uri: creds.redirectUri,
117
+ grant_type: "authorization_code",
118
+ });
119
+ const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
120
+ method: "POST",
121
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
122
+ body,
123
+ });
124
+ if (!tokenRes.ok) {
125
+ throw new Error("google token exchange failed");
126
+ }
127
+ const tokens = (await tokenRes.json()) as { access_token?: string };
128
+ if (!tokens.access_token) {
129
+ throw new Error("google token exchange failed");
130
+ }
131
+ const profileRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
132
+ headers: { Authorization: `Bearer ${tokens.access_token}` },
133
+ });
134
+ if (!profileRes.ok) {
135
+ throw new Error("google profile failed");
136
+ }
137
+ const profile = (await profileRes.json()) as {
138
+ id?: string;
139
+ email?: string;
140
+ name?: string;
141
+ };
142
+ if (!profile.email || !profile.id) {
143
+ throw new Error("google profile missing email");
144
+ }
145
+ return {
146
+ email: profile.email,
147
+ name: profile.name?.trim() || null,
148
+ subject: profile.id,
149
+ };
150
+ }
151
+
152
+ export async function signLocalGoogleState(
153
+ jwtSecret: string,
154
+ redirectUri: string,
155
+ ): Promise<string> {
156
+ return new SignJWT({ typ: LOCAL_STATE_TYP, redirectUri })
157
+ .setProtectedHeader({ alg: "HS256" })
158
+ .setIssuedAt()
159
+ .setExpirationTime("10m")
160
+ .sign(new TextEncoder().encode(jwtSecret));
161
+ }
162
+
163
+ export async function verifyLocalGoogleState(
164
+ jwtSecret: string,
165
+ token: string,
166
+ ): Promise<{ redirectUri: string }> {
167
+ const { payload } = await jwtVerify(token, new TextEncoder().encode(jwtSecret));
168
+ if (payload.typ !== LOCAL_STATE_TYP || typeof payload.redirectUri !== "string") {
169
+ throw new Error("invalid local google oauth state");
170
+ }
171
+ return { redirectUri: payload.redirectUri };
172
+ }
173
+
174
+ function pruneUsedJti(now: number): void {
175
+ for (const [jti, expires] of usedJti) {
176
+ if (expires <= now) usedJti.delete(jti);
177
+ }
178
+ }
179
+
180
+ export function resetLocalDevAssertionReplayForTests(): void {
181
+ usedJti.clear();
182
+ }
183
+
184
+ export async function verifyLocalDevIdentityAssertion(
185
+ assertion: string,
186
+ options: {
187
+ brokerBaseUrl: string;
188
+ callbackUri: string;
189
+ jwks?: JWTVerifyGetKey;
190
+ },
191
+ ): Promise<{
192
+ email: string;
193
+ name: string | null;
194
+ googleSubject: string;
195
+ redirectUri: string;
196
+ }> {
197
+ const issuer = options.brokerBaseUrl.replace(/\/$/, "");
198
+ const jwks = options.jwks ?? createRemoteJWKSet(new URL(`${issuer}/internal/auth/jwks.json`));
199
+ const { payload } = await jwtVerify(assertion, jwks, {
200
+ issuer,
201
+ audience: LOCAL_DEV_IDENTITY_AUD,
202
+ });
203
+ if (payload.typ !== LOCAL_DEV_IDENTITY_TYP) {
204
+ throw invalidAssertion();
205
+ }
206
+ if (payload.callback_uri !== options.callbackUri) {
207
+ throw invalidAssertion();
208
+ }
209
+ if (typeof payload.email !== "string" || typeof payload.google_subject !== "string") {
210
+ throw invalidAssertion();
211
+ }
212
+ if (typeof payload.redirect_uri !== "string") {
213
+ throw invalidAssertion();
214
+ }
215
+ const redirectUri = assertLocalAppRedirectUri(payload.redirect_uri);
216
+ const jti = payload.jti;
217
+ if (typeof jti !== "string" || !jti) {
218
+ throw invalidAssertion();
219
+ }
220
+ const now = Date.now();
221
+ pruneUsedJti(now);
222
+ if (usedJti.has(jti)) {
223
+ throw invalidAssertion();
224
+ }
225
+ usedJti.set(jti, now + JTI_TTL_MS);
226
+ return {
227
+ email: payload.email.toLowerCase(),
228
+ name: typeof payload.name === "string" ? payload.name : null,
229
+ googleSubject: payload.google_subject,
230
+ redirectUri,
231
+ };
232
+ }
@@ -21,8 +21,8 @@ function jobDef(
21
21
  return { _kind: JOB_KIND, handler, ...extras };
22
22
  }
23
23
 
24
- function unavailable(feature: string): never {
25
- throw new Error(`ctx.${feature} needs hosted Starbase. Run robodev deploy to use it.`);
24
+ function notConfigured(code: string): never {
25
+ throw Object.assign(new Error(code), { statusCode: 400, code });
26
26
  }
27
27
 
28
28
  function mockClients(): RouteClients {
@@ -32,26 +32,45 @@ function mockClients(): RouteClients {
32
32
  return { id: "local_mail", status: "sent" };
33
33
  },
34
34
  },
35
- llm: { complete: () => unavailable("llm"), stream: () => unavailable("llm") },
35
+ llm: {
36
+ complete: () => notConfigured("llm_not_configured"),
37
+ stream: () => notConfigured("llm_not_configured"),
38
+ },
36
39
  agent: {
37
- createSession: () => unavailable("agent"),
38
- start: () => unavailable("agent"),
39
- events: () => unavailable("agent"),
40
- subscribe: () => unavailable("agent"),
41
- destroy: () => unavailable("agent"),
40
+ createSession: () => notConfigured("llm_not_configured"),
41
+ start: () => notConfigured("llm_not_configured"),
42
+ events: () => notConfigured("llm_not_configured"),
43
+ subscribe: () => notConfigured("llm_not_configured"),
44
+ destroy: () => notConfigured("llm_not_configured"),
42
45
  },
43
46
  storage: {
44
- upload: () => unavailable("storage"),
45
- get: () => unavailable("storage"),
46
- getUrl: () => unavailable("storage"),
47
- delete: () => unavailable("storage"),
48
- list: () => unavailable("storage"),
47
+ async upload() {
48
+ return {
49
+ key: "x",
50
+ public: false,
51
+ size: 0,
52
+ contentType: "application/octet-stream",
53
+ url: "",
54
+ };
55
+ },
56
+ async get() {
57
+ return { body: Buffer.alloc(0), contentType: "application/octet-stream", public: false };
58
+ },
59
+ async getUrl() {
60
+ return "";
61
+ },
62
+ async delete() {
63
+ return undefined;
64
+ },
65
+ async list() {
66
+ return { objects: [], total: 0 };
67
+ },
49
68
  },
50
- push: { send: () => unavailable("push") },
69
+ push: { send: () => notConfigured("push_not_configured") },
51
70
  jobs: { enqueue: async () => ({ id: "nested" }) },
52
71
  sockets: {
53
- send: () => unavailable("sockets"),
54
- broadcast: () => unavailable("sockets"),
72
+ send: () => undefined,
73
+ broadcast: () => undefined,
55
74
  },
56
75
  env: {},
57
76
  };
@@ -418,7 +437,7 @@ test("hosted-only clients still throw in job handlers; email is real", async ()
418
437
  await engine.tickJobs();
419
438
  await waitUntil(() => store.jobs[0]?.status === "succeeded");
420
439
  assert.equal(seen[0], "email");
421
- assert.match(seen[1] ?? "", /ctx\.llm needs hosted Starbase/);
440
+ assert.equal(seen[1], "llm_not_configured");
422
441
  });
423
442
 
424
443
  test("start resets running rows; stop clears timers", async () => {
@@ -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
+ });