pyric-admin 0.1.0-alpha.11 → 0.1.0-alpha.13

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,615 @@
1
+ /**
2
+ * `pyric-admin/storage` — sandbox mirror for the Admin Storage shape.
3
+ *
4
+ * Mirrors the useful `firebase-admin/storage` shape for local and remote
5
+ * sandbox apps selected at {@link initializeApp} time. Dispatch reads the
6
+ * {@link ADMIN_APP_TARGET} brand on the `PyricAdminApp` handle.
7
+ *
8
+ * - **Remote sandbox path** — a handle branded by `@pyric/cli`'
9
+ * `connectRemoteSandbox()`/`remoteSandbox()` relays every data
10
+ * operation over the bridge to the browser-hosted SharedWorker's
11
+ * object store (admin lens pinned — rules bypass). Single bucket;
12
+ * 8 MiB per-op byte cap; `getSignedUrl` stays the local stub. See
13
+ * the remote arm section below.
14
+ *
15
+ * - **Sandbox path** — returns an in-process {@link Storage} backed
16
+ * by an in-memory `Map<bucketName, Map<path, FileEntry>>`. State
17
+ * lives on the {@link Sandbox} via a `WeakMap`, so `sandbox.reset()`
18
+ * wipes it alongside Firestore / Auth state. Multi-bucket isolation
19
+ * is real — buckets are independent maps.
20
+ *
21
+ * Supported sandbox surface (the minimum a session-archive flow
22
+ * needs):
23
+ * - `storage.bucket(name?)` → {@link Bucket}-shaped handle
24
+ * - `bucket.file(path)` → {@link File}-shaped handle
25
+ * - `file.save(data, options?)` — `Buffer | string | Uint8Array`
26
+ * - `file.download(options?)` → `[Buffer]`
27
+ * - `file.delete()` — idempotent
28
+ * - `file.exists()` → `[boolean]`
29
+ * - `file.getSignedUrl(options)` → `['pyric-sandbox-storage://…']`
30
+ *
31
+ * **Deferred in the sandbox backend** (throws `"not implemented in
32
+ * pyric-admin/storage sandbox backend"`): streaming uploads
33
+ * (`createWriteStream`), resumable uploads, signed cookies, IAM
34
+ * policies, lifecycle rules, ACLs, copy/move, notifications.
35
+ */
36
+
37
+ import {
38
+ isRemoteSandbox,
39
+ type RemoteSandbox,
40
+ type RemoteSandboxChannel,
41
+ type Sandbox,
42
+ } from 'pyric/sandbox';
43
+
44
+ import {
45
+ ADMIN_APP_TARGET,
46
+ getApp,
47
+ type PyricAdminApp,
48
+ type SandboxAdminApp,
49
+ } from '../app/index.js';
50
+ import { assertAdminAppActive } from '../app/lifecycle.js';
51
+
52
+ // ─── Public surface ─────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * `pyric-admin/storage`'s sandbox `Storage` handle. It exposes the subset
56
+ * documented in the module header.
57
+ *
58
+ * The shared `bucket(name?)` shape is the contract — consumers code
59
+ * against it without caring whether the local or remote sandbox is live.
60
+ */
61
+ export interface Storage {
62
+ /**
63
+ * Get a {@link Bucket} handle. When `name` is omitted, returns the
64
+ * sandbox default bucket (`'pyric-default'`).
65
+ */
66
+ bucket(name?: string): Bucket;
67
+ }
68
+
69
+ /**
70
+ * A storage bucket handle implemented by both local and remote sandbox
71
+ * paths. Only the documented subset is supported.
72
+ */
73
+ export interface Bucket {
74
+ /** Name of the bucket. Stable across `file()` lookups. */
75
+ readonly name: string;
76
+ /** Get a {@link File} handle for `path`. The file may or may not exist. */
77
+ file(path: string): File;
78
+ }
79
+
80
+ /**
81
+ * A file handle within a bucket. Method shapes mirror
82
+ * `@google-cloud/storage`'s `File` (return tuples for download / exists
83
+ * / getSignedUrl, etc.) so common consumer code retains the familiar shape.
84
+ *
85
+ * The sandbox backend implements the methods documented here. Any
86
+ * other `File` method from `@google-cloud/storage` (`createWriteStream`,
87
+ * `createReadStream`, `copy`, `move`, `setMetadata` beyond the basic
88
+ * `save` options, etc.) throws on the sandbox path — see module header.
89
+ */
90
+ export interface File {
91
+ /** Name (path) of the file within its bucket. */
92
+ readonly name: string;
93
+ /** Bucket the file belongs to. Same handle the `file()` call came from. */
94
+ readonly bucket: Bucket;
95
+ /**
96
+ * Persist `data` at this file's path. Replaces any existing content
97
+ * (no append semantics). `options.metadata` is stored alongside the
98
+ * bytes and surfaces on later reads via the in-memory state — the
99
+ * sandbox doesn't expose a full `Metadata` API yet, but the payload
100
+ * round-trips so future expansion is non-breaking.
101
+ */
102
+ save(data: Buffer | string | Uint8Array, options?: SaveOptions): Promise<void>;
103
+ /**
104
+ * Read the file's bytes. Returns a `[Buffer]` tuple to mirror
105
+ * `@google-cloud/storage`'s `File.download` (which returns
106
+ * `[Buffer, ...]`). Throws if the file does not exist.
107
+ */
108
+ download(options?: DownloadOptions): Promise<[Buffer]>;
109
+ /**
110
+ * Remove the file from its bucket. Idempotent — deleting a missing
111
+ * file is a no-op (matches `@google-cloud/storage`'s
112
+ * `ignoreNotFound: true`, which is the only mode the sandbox models).
113
+ */
114
+ delete(): Promise<void>;
115
+ /** `[true]` if the file exists, `[false]` otherwise. Tuple shape mirrors `@google-cloud/storage`. */
116
+ exists(): Promise<[boolean]>;
117
+ /**
118
+ * Return a stub signed URL of the form
119
+ * `pyric-sandbox-storage://${path}?expires=${expires}`. The sandbox
120
+ * does NOT serve the URL — it's a deterministic placeholder so
121
+ * agent code that round-trips signed URLs (logs, fixtures, replay)
122
+ * sees a stable shape.
123
+ */
124
+ getSignedUrl(options: GetSignedUrlOptions): Promise<[string]>;
125
+ }
126
+
127
+ /** Options bag for {@link File.save}. Subset of `@google-cloud/storage`'s `SaveOptions`. */
128
+ export interface SaveOptions {
129
+ /**
130
+ * Arbitrary metadata stored alongside the file. The sandbox stores
131
+ * it verbatim; consumers that need to round-trip `contentType`,
132
+ * `metadata.custom`, etc. get it back via internal admin tooling
133
+ * (not exposed on `File` itself yet).
134
+ */
135
+ metadata?: Record<string, unknown>;
136
+ /**
137
+ * Content type hint stored on the sandbox entry.
138
+ * Convenience shortcut for `metadata.contentType`.
139
+ */
140
+ contentType?: string;
141
+ /**
142
+ * `resumable: false` is the only mode the sandbox models (single-
143
+ * shot writes). The sandbox throws when set to `true` since resumable
144
+ * uploads are deferred.
145
+ */
146
+ resumable?: boolean;
147
+ }
148
+
149
+ /** Options bag for {@link File.download}. Subset of `@google-cloud/storage`'s `DownloadOptions`. */
150
+ export interface DownloadOptions {
151
+ /** The sandbox accepts but ignores `validation`. */
152
+ validation?: 'md5' | 'crc32c' | boolean;
153
+ }
154
+
155
+ /** Options bag for {@link File.getSignedUrl}. Mirrors `@google-cloud/storage`'s shape. */
156
+ export interface GetSignedUrlOptions {
157
+ /** `'read' | 'write' | 'delete' | 'resumable'`. Sandbox stamps it into the URL only as a hint. */
158
+ action: 'read' | 'write' | 'delete' | 'resumable';
159
+ /**
160
+ * Expiration. Accepts ms-since-epoch (number), ISO date string, or
161
+ * `Date`. Sandbox normalizes to ms-since-epoch and embeds in the
162
+ * stub URL's `expires=` query.
163
+ */
164
+ expires: number | string | Date;
165
+ }
166
+
167
+ /**
168
+ * Input accepted by {@link getStorage}. The branded `PyricAdminApp` is
169
+ * the canonical shape; calling without an argument resolves the default
170
+ * app from the `pyric-admin/app` registry (mirroring
171
+ * `firebase-admin/storage`, where `getStorage()` resolves the default App),
172
+ * and throws the captured `app/no-app` error when nothing is initialized.
173
+ */
174
+ export type StorageApp = PyricAdminApp;
175
+
176
+ /**
177
+ * Get the {@link Storage} service for the given app.
178
+ *
179
+ * Returns a sandbox-backed `Storage` whose state
180
+ * lives on the `Sandbox`. `sandbox.reset()` wipes it.
181
+ */
182
+ export function getStorage(app?: StorageApp): Storage {
183
+ // No-arg call resolves the default app; nothing initialized → captured
184
+ // `app/no-app` FirebaseAppError (see pyric-admin/app getApp).
185
+ const resolved: PyricAdminApp = app === undefined ? getApp() : (app as PyricAdminApp);
186
+ assertAdminAppActive(resolved);
187
+ if (resolved[ADMIN_APP_TARGET] === 'sandbox') {
188
+ // Remote brand checked BEFORE the local arm (same dispatch order as
189
+ // auth/database): the local arm's WeakMap state + `onEvent` reset hook
190
+ // must never touch a remote handle — local state keyed off a remote
191
+ // handle would be a private server-side store the browser never sees,
192
+ // and `onEvent` throws on remote handles by design.
193
+ if (isRemoteSandbox(resolved.sandbox)) {
194
+ return getRemoteStorage(resolved.sandbox);
195
+ }
196
+ return getSandboxStorage(resolved);
197
+ }
198
+ // Defensive: the union is closed at the type level. A runtime value
199
+ // that lands here means a caller forged a handle without going
200
+ // through `initializeApp`.
201
+ throw new TypeError(
202
+ 'pyric-admin/storage: getStorage expected a PyricAdminApp from `initializeApp`; ' +
203
+ 'received a value with no recognized ADMIN_APP_TARGET brand.',
204
+ );
205
+ }
206
+
207
+ // ─── Sandbox path ───────────────────────────────────────────────────────
208
+
209
+ /**
210
+ * Default sandbox bucket name. Matches the `pyric-default` used by the
211
+ * `pyric/storage` modular sandbox so consumers that switch between
212
+ * surfaces don't see an unexpected bucket name change.
213
+ */
214
+ const DEFAULT_SANDBOX_BUCKET = 'pyric-default';
215
+
216
+ /** A single file's bytes + opaque metadata in the in-memory store. */
217
+ interface FileEntry {
218
+ data: Uint8Array;
219
+ metadata: Record<string, unknown>;
220
+ contentType?: string;
221
+ }
222
+
223
+ /** Per-sandbox state: bucket name → (file path → entry). */
224
+ type BucketMap = Map<string, Map<string, FileEntry>>;
225
+
226
+ /**
227
+ * State + reset-handler registry keyed on `Sandbox` so a single
228
+ * Sandbox shares its storage across every `getStorage` call. The
229
+ * `WeakMap` lets a discarded `Sandbox` (and its state) be GC'd
230
+ * naturally.
231
+ */
232
+ const SANDBOX_STATE = new WeakMap<Sandbox, BucketMap>();
233
+
234
+ /**
235
+ * Reset-subscription bookkeeping. We subscribe to `sandbox.onEvent`
236
+ * once per Sandbox and re-create the bucket map on
237
+ * `session_boundary` events with `phase: 'reset'`. Without this,
238
+ * `sandbox.reset()` would wipe Firestore but leave storage untouched —
239
+ * a leak the sandbox model deliberately avoids.
240
+ */
241
+ const RESET_HOOKED = new WeakSet<Sandbox>();
242
+
243
+ function ensureBucketMap(sandbox: Sandbox): BucketMap {
244
+ let map = SANDBOX_STATE.get(sandbox);
245
+ if (!map) {
246
+ map = new Map();
247
+ SANDBOX_STATE.set(sandbox, map);
248
+ }
249
+ if (!RESET_HOOKED.has(sandbox)) {
250
+ RESET_HOOKED.add(sandbox);
251
+ sandbox.onEvent((event) => {
252
+ if (event.kind === 'session_boundary' && event.phase === 'reset') {
253
+ // Replace the map in place so existing Storage / Bucket / File
254
+ // handles keep working but observe an empty state.
255
+ const existing = SANDBOX_STATE.get(sandbox);
256
+ if (existing) existing.clear();
257
+ }
258
+ });
259
+ }
260
+ return map;
261
+ }
262
+
263
+ function getSandboxStorage(app: SandboxAdminApp): Storage {
264
+ const sandbox = app.sandbox;
265
+ const buckets = ensureBucketMap(sandbox);
266
+ return new SandboxStorage(buckets);
267
+ }
268
+
269
+ /**
270
+ * Sandbox `Storage` implementation. Holds a reference to the per-
271
+ * sandbox bucket map; each `bucket()` call returns a fresh `Bucket`
272
+ * handle bound to the same underlying map, mirroring how
273
+ * `@google-cloud/storage` returns lightweight per-call handles.
274
+ */
275
+ class SandboxStorage implements Storage {
276
+ constructor(private readonly buckets: BucketMap) {}
277
+
278
+ bucket(name?: string): Bucket {
279
+ const bucketName = name ?? DEFAULT_SANDBOX_BUCKET;
280
+ let files = this.buckets.get(bucketName);
281
+ if (!files) {
282
+ files = new Map();
283
+ this.buckets.set(bucketName, files);
284
+ }
285
+ return new SandboxBucket(bucketName, files);
286
+ }
287
+ }
288
+
289
+ class SandboxBucket implements Bucket {
290
+ constructor(
291
+ readonly name: string,
292
+ private readonly files: Map<string, FileEntry>,
293
+ ) {}
294
+
295
+ file(path: string): File {
296
+ return new SandboxFile(path, this, this.files);
297
+ }
298
+ }
299
+
300
+ class SandboxFile implements File {
301
+ constructor(
302
+ readonly name: string,
303
+ readonly bucket: Bucket,
304
+ private readonly files: Map<string, FileEntry>,
305
+ ) {}
306
+
307
+ async save(
308
+ data: Buffer | string | Uint8Array,
309
+ options: SaveOptions = {},
310
+ ): Promise<void> {
311
+ if (options.resumable === true) {
312
+ throw new Error(
313
+ 'not implemented in pyric-admin/storage sandbox backend: resumable uploads',
314
+ );
315
+ }
316
+ const bytes = toBytes(data);
317
+ const metadata = options.metadata ?? {};
318
+ const entry: FileEntry = {
319
+ data: bytes,
320
+ metadata,
321
+ ...(options.contentType !== undefined ? { contentType: options.contentType } : {}),
322
+ };
323
+ this.files.set(this.name, entry);
324
+ }
325
+
326
+ async download(_options: DownloadOptions = {}): Promise<[Buffer]> {
327
+ const entry = this.files.get(this.name);
328
+ if (!entry) {
329
+ // Mirror the gcs/firebase-admin error message shape so consumer
330
+ // catch-blocks that string-match `No such object` keep working.
331
+ throw new Error(
332
+ `No such object: ${this.bucket.name}/${this.name}`,
333
+ );
334
+ }
335
+ return [Buffer.from(entry.data)];
336
+ }
337
+
338
+ async delete(): Promise<void> {
339
+ this.files.delete(this.name);
340
+ }
341
+
342
+ async exists(): Promise<[boolean]> {
343
+ return [this.files.has(this.name)];
344
+ }
345
+
346
+ async getSignedUrl(options: GetSignedUrlOptions): Promise<[string]> {
347
+ return [stubSignedUrl(this.bucket.name, this.name, options)];
348
+ }
349
+
350
+ // ─── Deferred surface (declared so TS callers see a clear error) ────
351
+
352
+ /** @deprecated Streaming writes are deferred — see module header. */
353
+ createWriteStream(): never {
354
+ throw new Error(
355
+ 'not implemented in pyric-admin/storage sandbox backend: createWriteStream',
356
+ );
357
+ }
358
+
359
+ /** @deprecated Streaming reads are deferred — see module header. */
360
+ createReadStream(): never {
361
+ throw new Error(
362
+ 'not implemented in pyric-admin/storage sandbox backend: createReadStream',
363
+ );
364
+ }
365
+ }
366
+
367
+ // ─── Remote sandbox arm (remote sandbox, slice 2) ───────────────────────
368
+ //
369
+ // The app's `Sandbox` is a Node-side handle onto the browser-hosted
370
+ // SharedWorker sandbox. Every data operation relays over the handle's
371
+ // worker channel with `actAs: { mode: 'admin' }` pinned — firebase-admin's
372
+ // rules-bypass semantics against the ONE object store the app + Studio +
373
+ // agents share (the host resolves the lens to `pyric/storage/internal`'s
374
+ // admin plane). There is deliberately NO local state here: a `WeakMap`
375
+ // bucket map keyed off a remote handle would be private server-side data
376
+ // the browser never sees, and the local arm's `onEvent` reset hook throws
377
+ // on remote handles by design.
378
+ //
379
+ // Divergences from the local arm, all LOUD:
380
+ // - single bucket: the worker's `pyric/storage` store is single-bucket
381
+ // ("the data store is shared" — bucket names only round-trip in
382
+ // metadata), so `bucket('non-default')` throws instead of silently
383
+ // merging buckets. The default bucket name matches the local arm.
384
+ // - byte payloads are capped at 8 MiB per op (whole-object buffering
385
+ // over four relay hops; streaming stays unsupported on both sandbox arms).
386
+ // `getSignedUrl` does NOT relay: it stays the byte-identical local stub.
387
+
388
+ /** firebase-admin's rules-bypass lens, pinned on every relayed operation. */
389
+ const STORAGE_REMOTE_ADMIN_LENS = { mode: 'admin' } as const;
390
+
391
+ /**
392
+ * Raw per-op byte cap for relayed storage payloads. MUST mirror
393
+ * `@pyric/cli`' `MAX_STORAGE_OP_BYTES` (serve/worker/protocol.ts) — the
394
+ * worker host enforces the same cap on its end. Inlined (like the RTDB
395
+ * push-id generator) because `pyric-admin` deliberately does not depend on
396
+ * `@pyric/cli`.
397
+ */
398
+ const MAX_REMOTE_STORAGE_OP_BYTES = 8 * 1024 * 1024;
399
+
400
+ /** One remote `Storage` per remote handle (handles only — never data). */
401
+ const remoteStorageBySandbox = new WeakMap<Sandbox, Storage>();
402
+
403
+ function getRemoteStorage(sandbox: RemoteSandbox): Storage {
404
+ let storage = remoteStorageBySandbox.get(sandbox);
405
+ if (!storage) {
406
+ storage = new RemoteStorage(sandbox.channel);
407
+ remoteStorageBySandbox.set(sandbox, storage);
408
+ }
409
+ return storage;
410
+ }
411
+
412
+ /** Wire shape of the worker's `storage.getBytes` result. */
413
+ interface RemoteGetBytesResult {
414
+ dataB64: string;
415
+ contentType?: string;
416
+ size: number;
417
+ }
418
+
419
+ class RemoteStorage implements Storage {
420
+ constructor(private readonly channel: RemoteSandboxChannel) {}
421
+
422
+ bucket(name?: string): Bucket {
423
+ // The worker's object store is single-bucket. A non-default name can't
424
+ // be faithfully relayed — throw loudly instead of silently merging
425
+ // buckets (the local arm has REAL multi-bucket isolation; this is the
426
+ // sharpest local/remote divergence, so it must be explicit).
427
+ if (name !== undefined && name !== DEFAULT_SANDBOX_BUCKET) {
428
+ throw new Error(
429
+ `pyric-admin/storage: the remote (browser) sandbox has a single bucket — ` +
430
+ `bucket('${name}') cannot be isolated. Use bucket() (the default ` +
431
+ `'${DEFAULT_SANDBOX_BUCKET}' bucket) instead.`,
432
+ );
433
+ }
434
+ return new RemoteBucket(DEFAULT_SANDBOX_BUCKET, this.channel);
435
+ }
436
+ }
437
+
438
+ class RemoteBucket implements Bucket {
439
+ constructor(
440
+ readonly name: string,
441
+ private readonly channel: RemoteSandboxChannel,
442
+ ) {}
443
+
444
+ file(path: string): File {
445
+ return new RemoteFile(path, this, this.channel);
446
+ }
447
+ }
448
+
449
+ class RemoteFile implements File {
450
+ constructor(
451
+ readonly name: string,
452
+ readonly bucket: Bucket,
453
+ private readonly channel: RemoteSandboxChannel,
454
+ ) {}
455
+
456
+ async save(
457
+ data: Buffer | string | Uint8Array,
458
+ options: SaveOptions = {},
459
+ ): Promise<void> {
460
+ if (options.resumable === true) {
461
+ throw new Error(
462
+ 'not implemented in pyric-admin/storage remote sandbox backend: resumable uploads',
463
+ );
464
+ }
465
+ const bytes = toBytes(data);
466
+ if (bytes.byteLength > MAX_REMOTE_STORAGE_OP_BYTES) {
467
+ throw payloadTooLarge(bytes.byteLength, `save() payload for '${this.name}'`);
468
+ }
469
+ await this.channel.op({
470
+ method: 'storage.putBytes',
471
+ path: this.name,
472
+ dataB64: Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64'),
473
+ ...(options.contentType !== undefined ? { contentType: options.contentType } : {}),
474
+ ...(options.metadata !== undefined ? { metadata: options.metadata } : {}),
475
+ actAs: STORAGE_REMOTE_ADMIN_LENS,
476
+ });
477
+ }
478
+
479
+ async download(_options: DownloadOptions = {}): Promise<[Buffer]> {
480
+ let wire: RemoteGetBytesResult;
481
+ try {
482
+ wire = (await this.channel.op({
483
+ method: 'storage.getBytes',
484
+ path: this.name,
485
+ actAs: STORAGE_REMOTE_ADMIN_LENS,
486
+ })) as RemoteGetBytesResult;
487
+ } catch (err) {
488
+ if (isObjectNotFound(err)) {
489
+ // Mirror the gcs/firebase-admin (and local arm) message shape so
490
+ // consumer catch-blocks that string-match `No such object` work
491
+ // identically across arms.
492
+ throw new Error(`No such object: ${this.bucket.name}/${this.name}`);
493
+ }
494
+ throw err;
495
+ }
496
+ return [Buffer.from(wire.dataB64, 'base64')];
497
+ }
498
+
499
+ async delete(): Promise<void> {
500
+ try {
501
+ await this.channel.op({
502
+ method: 'storage.deleteObject',
503
+ path: this.name,
504
+ actAs: STORAGE_REMOTE_ADMIN_LENS,
505
+ });
506
+ } catch (err) {
507
+ // The worker store's delete is already idempotent, but swallow a
508
+ // not-found defensively so the local arm's idempotent-delete contract
509
+ // holds even if `pyric/storage` adopts stricter delete semantics later.
510
+ if (isObjectNotFound(err)) return;
511
+ throw err;
512
+ }
513
+ }
514
+
515
+ async exists(): Promise<[boolean]> {
516
+ try {
517
+ await this.channel.op({
518
+ method: 'storage.getMetadata',
519
+ path: this.name,
520
+ actAs: STORAGE_REMOTE_ADMIN_LENS,
521
+ });
522
+ return [true];
523
+ } catch (err) {
524
+ if (isObjectNotFound(err)) return [false];
525
+ throw err;
526
+ }
527
+ }
528
+
529
+ /** Local stub — byte-identical to the local arm's (no data needed, so it
530
+ * never relays). The sandbox does NOT serve the URL. */
531
+ async getSignedUrl(options: GetSignedUrlOptions): Promise<[string]> {
532
+ return [stubSignedUrl(this.bucket.name, this.name, options)];
533
+ }
534
+
535
+ // ─── Deferred surface (remediating throws, remote-flavored) ─────────
536
+
537
+ createWriteStream(): never {
538
+ throw new Error(
539
+ 'not implemented in pyric-admin/storage remote sandbox backend: createWriteStream — ' +
540
+ 'streams cannot span the bridge relay; use file.save(buffer) (≤ 8 MiB) instead.',
541
+ );
542
+ }
543
+
544
+ createReadStream(): never {
545
+ throw new Error(
546
+ 'not implemented in pyric-admin/storage remote sandbox backend: createReadStream — ' +
547
+ 'streams cannot span the bridge relay; use file.download() (≤ 8 MiB) instead.',
548
+ );
549
+ }
550
+ }
551
+
552
+ /** Is this relayed error the worker's `storage/object-not-found`? */
553
+ function isObjectNotFound(err: unknown): boolean {
554
+ return (err as { code?: unknown })?.code === 'storage/object-not-found';
555
+ }
556
+
557
+ /** Over-cap rejection (code `payload-too-large`) — mirrors the worker host's
558
+ * message shape and names the streaming gap. */
559
+ function payloadTooLarge(sizeBytes: number, what: string): Error & { code: string } {
560
+ const err = new Error(
561
+ `pyric-admin/storage: ${what} is ${sizeBytes} bytes — over the ` +
562
+ `${MAX_REMOTE_STORAGE_OP_BYTES / (1024 * 1024)} MiB remote storage op cap. ` +
563
+ 'Streaming/resumable transfers are not supported on the sandbox backend; ' +
564
+ 'split the object or keep it under the cap.',
565
+ ) as Error & { code: string };
566
+ err.code = 'payload-too-large';
567
+ return err;
568
+ }
569
+
570
+ // ─── Helpers ────────────────────────────────────────────────────────────
571
+
572
+ /**
573
+ * The deterministic sandbox signed-URL stub, shared by the local and remote
574
+ * arms so their output is byte-identical (the URL is never served — it's a
575
+ * stable placeholder for logs/fixtures/replay).
576
+ */
577
+ function stubSignedUrl(
578
+ bucketName: string,
579
+ path: string,
580
+ options: GetSignedUrlOptions,
581
+ ): string {
582
+ const expiresMs = normalizeExpires(options.expires);
583
+ return `pyric-sandbox-storage://${bucketName}/${path}?expires=${expiresMs}&action=${options.action}`;
584
+ }
585
+
586
+ /**
587
+ * Normalize `Buffer | string | Uint8Array` into a fresh `Uint8Array`.
588
+ * We copy on ingest so callers can mutate their input buffer without
589
+ * corrupting stored state — mirrors how `firebase-admin/storage` /
590
+ * `@google-cloud/storage` treat `save` inputs.
591
+ */
592
+ function toBytes(data: Buffer | string | Uint8Array): Uint8Array {
593
+ if (typeof data === 'string') {
594
+ return new TextEncoder().encode(data);
595
+ }
596
+ // Both Buffer (Node) and Uint8Array land here — copy into a new
597
+ // Uint8Array so the stored bytes are independent of the caller's
598
+ // reference. `slice()` produces a copy in both cases.
599
+ return new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
600
+ }
601
+
602
+ /**
603
+ * Normalize the `expires` field into ms-since-epoch. Mirrors the
604
+ * accepted shapes from `@google-cloud/storage`'s `GetSignedUrlOptions`.
605
+ * The sandbox doesn't enforce expiration — the value is only embedded
606
+ * in the stub URL so consumers can round-trip it.
607
+ */
608
+ function normalizeExpires(expires: number | string | Date): number {
609
+ if (typeof expires === 'number') return expires;
610
+ if (expires instanceof Date) return expires.getTime();
611
+ // String form — accept anything `Date` parses. Bogus input becomes
612
+ // `NaN`, which is still embeddable in the URL; we don't enforce
613
+ // strictness because the value only feeds the deterministic sandbox stub.
614
+ return new Date(expires).getTime();
615
+ }