pyric/storage compatibility matrix
77.1% of the public API supported
27 of 35 public API
Status legend
Conforming — sandbox matches prod, locked by a passing probe
Diverged (documented) — intentional difference with a written reason
Not implemented yet — deliberately or pending
getStorage(app, bucketUrl?) / getStorageSandbox(target, options?) — initializer
getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)getStorageSandbox(ctx) returns a tagged sandbox-target handle (frozen identity)
/ getStorageSandbox(target, options?)getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)getStorageSandbox(sandbox) wraps a bare Sandbox with an anonymous context (auth: null)
/ getStorageSandbox(target, options?)getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)getStorage(app) returns the sandbox handle selected by package resolution
/ getStorageSandbox(target, options?)entry-path:storage runs the canonical initializeApp → getStorage(app) → ref → uploadBytes flow (Structured evidence: storage.ts)getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)Two getStorageSandbox(ctx) calls on the same context return the SAME wrapper (identity-stable)
/ getStorageSandbox(target, options?)unit:service.test.ts ("returns the same handle for repeated calls on the same context")getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)Two getStorageSandbox(sandbox) calls on a bare Sandbox return the SAME wrapper (identity-stable)
/ getStorageSandbox(target, options?)ST-B3 fixed:
withAuth(null) mints a fresh context per call, so the per-context cache missed and bare-Sandbox calls returned different handles. A Sandbox-keyed cache makes the convenience path stable, matching the docstring. Probe: unit:service.test.ts ("ST-B3: returns the same handle for repeated bare-Sandbox calls").getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)Two different SandboxContexts on the same Sandbox get DIFFERENT handles but share the underlying StorageService (IDB)
/ getStorageSandbox(target, options?)unit:service.test.ts ("shares the underlying StorageService across contexts on the same sandbox")getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)options.bucket round-trips on metadata records; v1 has a single implicit bucket but the field is preserved
/ getStorageSandbox(target, options?)unit:service.test.ts ("records the bucket value on the handle")getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)options.dbName honored on the FIRST call per Sandbox; second-call overrides ignored
/ getStorageSandbox(target, options?)unit:service.test.ts ("dbName only takes effect on the sandbox's first getStorage call")getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)options.rules parsed eagerly — malformed rules throw SyntaxError at config time
/ getStorageSandbox(target, options?)unit:storage/sandbox/rules.test.ts (parse errors propagate from parseStorageRules)getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)The TARGET_SYMBOL brand keeps each handle bound to its owning sandbox service and identity
/ getStorageSandbox(target, options?)unit:service.test.ts (distinct contexts share one service while retaining distinct handles)getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)Unrecognized handle (not produced by a factory) → TypeError "not a FirebaseStorage handle"
/ getStorageSandbox(target, options?)unit:service.test.ts ("rejects an object that was not produced by a factory")getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)getStorage(app, bucketUrl?) accepts Firebase's bucket argument; the sandbox remains bound to its configured single bucket
/ getStorageSandbox(target, options?)Package resolution owns production selection. The sandbox accepts the canonical argument but does not model production multi-bucket routing;
unit:service.test.ts pins the configured pyric-default bucket.getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)The served firebase/storage entry accepts bare getStorage() and returns the page's shared sandbox handle
/ getStorageSandbox(target, options?)The canonical served entry supplies the page sandbox when the app argument is omitted; the entry-path and bundler suites execute the public package shape. (Structured evidence:
storage.ts, bundler.test.ts)ref(storage[, path]) / ref(parent, path) — reference constructor
ref(storage[, path]) / ref(parent, path)ref(storage) returns the root ref — fullPath === '', name === '', parent === null, root === self
/ ref(parent, path)unit:reference.test.ts ("root reference has empty fullPath, null parent, and equal root")ref(storage[, path]) / ref(parent, path)ref(storage, 'sessions/s1.json') populates fullPath, name (last segment), parent (path without last segment)
/ ref(parent, path)unit:reference.test.ts ("ref(storage, path) populates fullPath and name from the last segment")ref(storage[, path]) / ref(parent, path)Path normalization: leading slashes stripped (/sessions/s1 → sessions/s1)
/ ref(parent, path)unit:reference.test.ts ("normalizes leading/trailing/double slashes")ref(storage[, path]) / ref(parent, path)Path normalization: trailing slashes stripped
/ ref(parent, path)ref(storage[, path]) / ref(parent, path)Path normalization: repeated internal slashes collapsed (a//b → a/b)
/ ref(parent, path)ref(storage[, path]) / ref(parent, path)ref(parent, child) joins relative to parent's fullPath
/ ref(parent, path)unit:reference.test.ts ("ref(parent, child) joins relative to the parent")ref(storage[, path]) / ref(parent, path)parent chain walks back to root (each .parent strips one segment until empty, then null)
/ ref(parent, path)unit:reference.test.ts ("parent traversal walks back to root")ref(storage[, path]) / ref(parent, path)root accessor returns the bucket-root ref regardless of starting depth
/ ref(parent, path)ref(storage[, path]) / ref(parent, path)toString() returns gs://<bucket>/<fullPath>
/ ref(parent, path)unit:reference.test.ts ("toString returns gs://bucket/path")ref(storage[, path]) / ref(parent, path)Reference identity: two ref(s, 'a/b') calls are equal-by-toString but NOT === (value objects, not interned)
/ ref(parent, path)(implicit in
unit:reference.test.ts parent-chain test — each .parent returns a fresh object)ref(storage[, path]) / ref(parent, path)References are mirror-owned value objects; parent and root preserve the same storage handle and path semantics
/ ref(parent, path)unit:reference.test.ts pins parent traversal, root identity, bucket, and path behavior through the public reference interface.uploadBytes(ref, data, metadata?) — write blob
uploadBytes(ref, data, metadata?)Accepts Blob payload; returns UploadResult with populated metadata
unit:reference.test.ts ("accepts a Blob and round-trips through getBlob")uploadBytes(ref, data, metadata?)Accepts Uint8Array payload
unit:reference.test.ts ("accepts a Uint8Array")uploadBytes(ref, data, metadata?)Accepts ArrayBuffer payload
unit:reference.test.ts ("accepts an ArrayBuffer")uploadBytes(ref, data, metadata?)ContentType precedence: caller's metadata.contentType > Blob.type > application/octet-stream
unit:reference.test.ts ("metadata.contentType overrides the Blob's intrinsic type" + "falls back to application/octet-stream when no type is supplied")uploadBytes(ref, data, metadata?)Blob.type === '' (no intrinsic type) falls through to application/octet-stream, NOT to ''
unit:reference.test.ts ("falls back to application/octet-stream when no type is supplied")uploadBytes(ref, data, metadata?)customMetadata round-trips through the upload pipeline
unit:reference.test.ts ("round-trips customMetadata") + unit:metadata.test.tsuploadBytes(ref, data, metadata?)Empty Blob.type rewrap: when caller hint differs from Blob.type, the blob is re-wrapped with the caller's type (same bytes)
implicit in
unit:reference.test.ts ("metadata.contentType overrides the Blob's intrinsic type")uploadBytes(ref, data, metadata?)Throws storage/invalid-root-operation when called on the root reference
unit:reference.test.ts ("throws on root reference")uploadBytes(ref, data, metadata?)Returned metadata.fullPath matches the ref's fullPath
uploadBytes(ref, data, metadata?)Returned metadata.size matches the input blob's byte length
uploadBytes(ref, data, metadata?)Returned metadata.bucket matches the storage handle's bucket
uploadBytes(ref, data, metadata?)Replaces any existing object at the path (overwrite, not append)
unit:upstream-storage-probes.test.ts ("second uploadBytes at the same path replaces bytes and metadata")uploadBytes(ref, data, metadata?)Prod: round-trips uploaded bytes through getDownloadURL + fetch (byte-for-byte equality)
oracle:
packages/conformance/observations/storage/storage-upload-bytes-roundtrip.json (against blockingfun, fb-js-sdk 12.13.0: 6-byte payload → uploadBytes → getDownloadURL → HTTPS fetch → bytesMatch: true, urlIsHttps: true, bodyLen === payloadLen === 6). This row records the production answer key; row #51 compares the sandbox's page-local URL behavior against it.(prod-only)
uploadBytes(ref, data, metadata?)Returned metadata.contentType matches what the caller hinted (when set)
unit:reference.test.ts + oracle: packages/conformance/observations/storage/storage-upload-then-getmetadata.json (contentType: 'application/octet-stream' round-trip against blockingfun, fb-js-sdk 12.13.0; contentTypeMatches: true)uploadBytes(ref, data, metadata?)Returned metadata.generation / metageneration are stringified counters ('1' after fresh upload)
uploadString(ref, value, format?, metadata?) — write string-form
uploadString(ref, value, format?, metadata?)format='raw' (default): UTF-8 encodes the string; contentType defaults to text/plain;charset=utf-8
unit:reference.test.ts ("raw format encodes UTF-8 and defaults contentType to text/plain")uploadString(ref, value, format?, metadata?)format='base64': decodes payload bytes from standard base64
unit:reference.test.ts ("base64 format decodes payload bytes")uploadString(ref, value, format?, metadata?)Sandbox: format='base64url' (or any unknown format) rejected with storage/invalid-format naming the bad format. Prod: base64url is ACCEPTED (upload succeeds); a genuinely-unrecognized format throws storage/unknown
divergence, both halves oracle-locked by
packages/conformance/observations/storage/storage-uploadstring-unknown-format.json: prod accepts base64url (base64urlOk: true) and throws storage/unknown for an unrecognized format — not storage/invalid-format. The v1 sandbox ships only raw/base64/data_url (matches StringFormat) and throws storage/invalid-format for anything else (ST-B3 replaced the old mis-parse-as-data_url behavior). Both sides pinned in oracle-conformance.test.ts; sandbox code path documented in upload.ts's decodeString. Implementing base64url decoding is still one line in decodeString. (Structured evidence: error-codes.test.ts)uploadString(ref, value, format?, metadata?)format='data_url': parses data:<mime>;base64,<payload>, infers contentType from prefix
unit:reference.test.ts ("data_url format infers contentType from the prefix")uploadString(ref, value, format?, metadata?)format='data_url' with non-base64 payload: percent-decodes the body
unit:upstream-storage-probes.test.ts ("non-base64 data_url percent-decodes the body"; malformed %%0 → storage/invalid-format)uploadString(ref, value, format?, metadata?)Caller's metadata.contentType beats data_url inference
unit:reference.test.ts ("caller metadata.contentType beats data_url inference")uploadString(ref, value, format?, metadata?)Malformed data_url (no comma / doesn't start with data:) throws TypeError with "data_url format" message
unit:reference.test.ts ("throws on malformed data_url")uploadString(ref, value, format?, metadata?)uploadString(ref, value, 'base64') round-trips via getDownloadURL + fetch across in-page sandbox and SharedWorker mode
oracle:
packages/conformance/observations/storage/storage-uploadstring-base64-roundtrip.json ('aGVsbG8=' → 'hello' against blockingfun, fb-js-sdk 12.13.0; textMatches: true). Sandbox oracle replay matches production behavior in-page, and client↔host integration proves SharedWorker mode decodes and transfers string payloads faithfully over the worker port. (Structured evidence: oracle-conformance.test.ts, integration.test.ts)uploadBytesResumable(ref, data, metadata?) — resumable upload + task observers
uploadBytesResumable(ref, data, metadata?)Exported by firebase/storage; returns an UploadTask with pause() / resume() / cancel()
unit:resumable-upload.test.ts confirms uploadBytesResumable(ref, data) returns an UploadTask supporting pause(), resume(), and cancel() over synthetic microtask steps.uploadBytesResumable(ref, data, metadata?)task.on('state_changed', next, error, complete) fires next with {bytesTransferred, totalBytes, state} snapshots
unit:resumable-upload.test.ts confirms synthetic progress snapshots are emitted during task execution without requiring network transfer.uploadBytesResumable(ref, data, metadata?)task.pause() flips state to 'paused'; task.resume() continues
unit:resumable-upload.test.ts confirms calling task.pause() and task.resume() transitions through 'paused' and 'running' states deterministically.uploadBytesResumable(ref, data, metadata?)task.cancel() rejects the upload with storage/canceled
unit:resumable-upload.test.ts confirms cancellation rejects with storage/canceled and leaves no falsely completed object in storage.getDownloadURL(ref) — read URL
getDownloadURL(ref)Exported by firebase/storage; returns a token-signed HTTPS URL that fetches the blob
Implemented with a two-sided pin. Production observation
storage-upload-bytes-roundtrip records urlIsHttps: true and a byte-identical fetch. The sandbox oracle replay now calls the same public getDownloadURL + fetch path and proves byte-identical content, while explicitly asserting its URL starts with blob:. The client↔host integration proves SharedWorker mode creates that URL in the calling page after the rules-checked Blob crosses the port. The remaining divergence is URL identity and lifetime: the sandbox URL is a page-local snapshot, not token-signed HTTPS and not shareable outside that page. (Structured evidence: integration.test.ts)getDownloadURL(ref)Throws storage/object-not-found for missing objects
Production observation
storage-delete-then-get-throws records getDownloadURL throwing storage/object-not-found after deletion. The sandbox oracle replay now invokes getDownloadURL itself and matches that code; the public error-code suite also pins the never-existing-object case. (Structured evidence: error-codes.test.ts, oracle-conformance.test.ts)getBytes(ref, maxDownloadSize?) — read as ArrayBuffer
getBytes(ref, maxDownloadSize?)Returns the blob's contents as an ArrayBuffer
unit:reference.test.ts ("accepts a Uint8Array" round-trip via getBytes)getBytes(ref, maxDownloadSize?)Throws storage/object-not-found when no object exists at the path
unit:reference.test.ts ("throws storage/object-not-found for missing paths") + oracle: packages/conformance/observations/storage/storage-delete-then-get-throws.json (against blockingfun, fb-js-sdk 12.13.0: upload → delete → getDownloadURL on the deleted ref throws FirebaseError with code: 'storage/object-not-found')getBytes(ref, maxDownloadSize?)When the object exceeds maxDownloadSize, returns a truncated prefix of that byte length (does not throw)
unit:upstream-storage-probes.test.ts ("getBytes / getBlob return a truncated prefix when the object exceeds the cap"). Matches upstream getBytesInternal / getBlobInternal post-fetch slice (GCS may ignore Range on small files). Prior COMPAT claim that the cap throws was wrong.getBytes(ref, maxDownloadSize?)Just-under-cap reads succeed and return the full byte length
unit:upstream-storage-probes.test.ts ("just-under-cap reads return the full object") + unit:reference.test.ts ("honors maxDownloadSizeBytes when the blob is too large")getBytes(ref, maxDownloadSize?)Throws storage/invalid-root-operation when called on the root reference
unit:reference.test.ts ("throws invalid-root-operation on root reads")getBlob(ref, maxDownloadSize?) — read as Blob
getBlob(ref, maxDownloadSize?)Returns the stored bytes wrapped as a Blob (with .type from metadata)
unit:reference.test.ts ("accepts a Blob and round-trips through getBlob")getBlob(ref, maxDownloadSize?)Throws storage/object-not-found for missing paths
unit:reference.test.ts ("throws storage/object-not-found for missing paths")getBlob(ref, maxDownloadSize?)Honors maxDownloadSize same as getBytes
unit:upstream-storage-probes.test.ts ("getBytes / getBlob return a truncated prefix when the object exceeds the cap"; shared fetchBlob helper in download.ts)getBlob(ref, maxDownloadSize?)Root-ref read throws storage/invalid-root-operation
shared via
guardNonRoot in download.tsgetStream(ref, maxDownloadSize?) — Node-specific
getStream(ref, maxDownloadSize?)Exported by firebase/storage (Node entry only); returns a Node Readable
No
pyric/storage implementation or behavioral probe exists; availability is classified by the owning surface contract.deleteObject(ref) — delete
deleteObject(ref)Removes both the blob AND the metadata atomically (post-delete getBlob throws object-not-found)
unit:reference.test.ts ("removes both blob and metadata")deleteObject(ref)Sandbox: no-op on missing path (does NOT throw)
divergence: sandbox is no-op via
persistence.ts's delete. Prod's deleteObject on a missing path throws storage/object-not-found. Oracle-locked: packages/conformance/observations/storage/storage-delete-missing-throws.json (code: 'storage/object-not-found', name: 'FirebaseError' against blockingfun, fb-js-sdk 12.13.0). Both sides pinned in oracle-conformance.test.ts; documented in download.ts.deleteObject(ref)Throws storage/invalid-root-operation on the root reference
unit:reference.test.ts ("throws invalid-root-operation on root")deleteObject(ref)Prod: a successful deleteObject followed by getDownloadURL on the same ref throws storage/object-not-found
oracle:
packages/conformance/observations/storage/storage-delete-then-get-throws.json (against blockingfun, fb-js-sdk 12.13.0: upload + delete succeed, then getDownloadURL throws code: 'storage/object-not-found', message "Firebase Storage: Object '…' does not exist.", isFirebaseError: true)(prod-only)
deleteObject(ref)Sandbox: writes-then-delete leaves no metadata (post-delete getMetadata throws object-not-found)
follows from #63 +
getMetadatalistAll(ref) — list all children under a ref
listAll(ref)Returns ListResult with items (direct child files) + prefixes (sub-folder refs) + nextPageToken: undefined
listAll(ref)Empty bucket → both arrays empty, nextPageToken: undefined
unit:list.test.ts ("returns empty arrays on an empty bucket")listAll(ref)Direct children only — does NOT recurse into grandchildren as items
unit:list.test.ts ("does not recurse into grandchildren as items")listAll(ref)Sub-folders surface as prefixes and are deduplicated (many files under one folder → ONE prefix entry)
unit:list.test.ts ("promotes sub-folders into prefixes (deduplicated)")listAll(ref)items sorted by path (IDB key order, lexicographic)
unit:list.test.ts ("lists direct children of a folder")listAll(ref)prefixes sorted lexicographically by fullPath (for determinism)
listAll(ref)The scanned ref itself is NEVER included in items (even when an object exists at the exact prefix path)
unit:list.test.ts ("does not include the scanned ref itself")listAll(ref)listAll(ref(storage)) (root) scans the entire bucket
unit:list.test.ts ("listAll on the root scans the entire bucket")listAll(ref)Items expose the full StorageReference shape (storage, bucket, name, parent)
unit:list.test.ts ("items expose the StorageReference shape")listAll(ref)Prod: items + prefixes shape matches sandbox after N uploads under a directory
oracle:
packages/conformance/observations/storage/storage-listall-shape.json (against blockingfun, fb-js-sdk 12.13.0: 3 direct children + 1 grandchild → items has all 3 direct children sorted, prefixes has the single sub-folder, itemCount: 3, prefixCount: 1, threeDirectChildren: true, oneSubPrefix: true)listAll(ref)listAll enforces rules: read permission on the scanned prefix path governs list (Firebase: read covers download AND list), denied prefix → storage/unauthorized
ST-B2 fixed:
list.ts now calls enforceRules with method: 'read' on the listed prefix (was a silent bypass — a denied tree was still fully enumerable). With no rules configured the check is a no-op. Probe: unit:list-rules.test.ts ("denies an anonymous listAll of a tree the rules protect" / "allows an authed listAll"). Note: a read rule scoped to match /sessions/{id} does NOT grant list on /sessions — the folder needs its own read rule, matching prod; the session-archive demo ruleset adds match /sessions { allow read }.list(ref, options?) — paginated list
list(ref, options?)Exported by firebase/storage; accepts { maxResults, pageToken }, returns a ListResult with nextPageToken set when more pages remain
No paginated
list implementation or behavioral probe exists; availability is classified by the owning surface contract. ListResult.nextPageToken remains optional for forward-compatible consumer code.getMetadata(ref) / updateMetadata(ref, metadata) — metadata ops
getMetadata(ref) / updateMetadata(ref, metadata)getMetadata(ref) returns the same FullMetadata shape uploadBytes produced
/ updateMetadata(ref, metadata)unit:metadata.test.ts ("returns the FullMetadata uploadBytes wrote")getMetadata(ref) / updateMetadata(ref, metadata)getMetadata(ref) throws storage/object-not-found for missing paths
/ updateMetadata(ref, metadata)unit:metadata.test.ts ("throws object-not-found for missing paths")getMetadata(ref) / updateMetadata(ref, metadata)getMetadata(ref) throws storage/invalid-root-operation on the root
/ updateMetadata(ref, metadata)unit:metadata.test.ts ("throws invalid-root-operation on the root reference")getMetadata(ref) / updateMetadata(ref, metadata)updateMetadata(ref, patch) replaces the listed client-settable fields wholesale (per Firebase semantics)
/ updateMetadata(ref, metadata)unit:metadata.test.ts ("replaces settable fields, bumps metageneration, refreshes updated")getMetadata(ref) / updateMetadata(ref, metadata)updateMetadata bumps metageneration by 1 on each call
/ updateMetadata(ref, metadata)getMetadata(ref) / updateMetadata(ref, metadata)updateMetadata refreshes updated to the call moment; timeCreated and generation stay pinned
/ updateMetadata(ref, metadata)getMetadata(ref) / updateMetadata(ref, metadata)updateMetadata preserves the blob bytes (only metadata changes)
/ updateMetadata(ref, metadata)unit:metadata.test.ts ("leaves the blob content untouched")getMetadata(ref) / updateMetadata(ref, metadata)updateMetadata with undefined field values preserves the prior value (does NOT clear it)
/ updateMetadata(ref, metadata)divergence: prod accepts
null to explicitly clear a field. Sandbox doesn't model null-clear (per metadata.ts doc comment). Documented; not probe-locked.getMetadata(ref) / updateMetadata(ref, metadata)updateMetadata throws storage/object-not-found for missing paths
/ updateMetadata(ref, metadata)unit:metadata.test.ts ("throws object-not-found when the path is missing")getMetadata(ref) / updateMetadata(ref, metadata)updateMetadata throws storage/invalid-root-operation on the root
/ updateMetadata(ref, metadata)unit:metadata.test.ts ("throws invalid-root-operation on the root reference")getMetadata(ref) / updateMetadata(ref, metadata)Prod: getMetadata after uploadBytes returns contentType and size matching what was uploaded
/ updateMetadata(ref, metadata)oracle:
packages/conformance/observations/storage/storage-upload-then-getmetadata.json (against blockingfun, fb-js-sdk 12.13.0: upload 128-byte payload with contentType: 'application/octet-stream', getMetadata returns metadataSize: 128, metadataContentType: 'application/octet-stream', metadataBucket: 'blockingfun.firebasestorage.app', metadataMetageneration: '1', fullPathMatches: true)(prod-only)
getMetadata(ref) / updateMetadata(ref, metadata)Prod: updateMetadata({customMetadata: {...}}) round-trips through a follow-up getMetadata
/ updateMetadata(ref, metadata)oracle:
packages/conformance/observations/storage/storage-update-metadata-roundtrip.json (against blockingfun, fb-js-sdk 12.13.0: post-update getMetadata returns the exact customMetadata object, metageneration bumps '1' → '2', customSurvived: true, metagenerationBumped: true)(prod-only)
getMetadata(ref) / updateMetadata(ref, metadata)FullMetadata.md5Hash populated on uploads
/ updateMetadata(ref, metadata)divergence: sandbox does NOT compute
md5Hash. Oracle-locked: packages/conformance/observations/storage/storage-upload-then-getmetadata.json confirms prod sets md5Hash (hasMd5Hash: true after a vanilla uploadBytes). Both sides pinned in oracle-conformance.test.ts. Aligning the sandbox is a one-spot fix in upload.ts's buildStoredMetadata.getMetadata(ref) / updateMetadata(ref, metadata)FullMetadata.ref lazy population (prod populates lazily)
/ updateMetadata(ref, metadata)not modeled in
pyric/storage — metadata.ts explicitly omits ref from FullMetadataconnectStorageEmulator(storage, host, port) — emulator hook
connectStorageEmulator(storage, host, port)Exported by firebase/storage; reroutes a FirebaseStorage handle to a local emulator
not implemented in
pyric/storage — the sandbox IS the local-target alternative; emulator parity is out of scope per index.tsOp-level rules enforcement — a denied op throws storage/unauthorized
These are Storage SDK behaviors: how an upload / read / metadata / delete op
surfaces a rules deny verdict. The rules-engine fidelity rows
(parseStorageRules / evaluateStorageRules vs the production Rules Test
API) moved to the native storage-rules surface (docs/rules/COMPAT.md).
Rules enforcementOp-level enforcement: uploadBytes against a denied path throws storage/unauthorized on sandbox / storage/unauthorized on prod, .code exposed on both
ST-B1 fixed: sandbox now throws a
StorageError (see src/storage/errors.ts) whose .code === 'storage/unauthorized' — matching prod's FirebaseError.code. Probe: unit:error-codes.test.ts ("unauthorized when rules deny the operation"). Residual divergence (documented, not a .code gap): the sandbox StorageError.name is 'StorageError' (plain Error subclass, same shape as Firestore's SandboxError) where prod reports name: 'FirebaseError' / isFirebaseError: true, and the message wording differs (sandbox embeds the matched-rule reason chain). Oracle-locked: packages/conformance/observations/storage/storage-rules-denied-error-code.json (against blockingfun, fb-js-sdk 12.13.0: code: 'storage/unauthorized', message "Firebase Storage: User does not have permission to access '<path>'.", name: 'FirebaseError', isFirebaseError: true).Rules enforcementgetMetadata against a denied path throws storage/unauthorized
unit:storage/enforce.test.ts (operation-integration section)Rules enforcementupdateMetadata against a denied path throws storage/unauthorized
Rules enforcementdeleteObject against a denied path throws storage/unauthorized
Rules enforcementOrdinary SDK object paths are evaluated under Firebase Storage's canonical /b/{bucket}/o/{object} rules namespace while metadata preserves the ordinary object path
unit:storage/enforce.test.ts ("maps ordinary SDK object paths into the canonical bucket rules namespace"). Production's canonical /b/{bucket}/o/... namespace is independently captured on the storage-rules surface; this adapter mapping seam is unit-backed rather than presented as a production observation.Current gaps
Documented divergences
Known differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.
getStorage(app, bucketUrl?)` / `getStorageSandbox(target, options?)getStorage(app, bucketUrl?) accepts Firebase's bucket argument; the sandbox remains bound to its configured single bucket
Package resolution owns production selection. The sandbox accepts the canonical argument but does not model production multi-bucket routing;
unit:service.test.ts pins the configured pyric-default bucket.uploadString(ref, value, format?, metadata?)Sandbox: format='base64url' (or any unknown format) rejected with storage/invalid-format naming the bad format. Prod: base64url is ACCEPTED (upload succeeds); a genuinely-unrecognized format throws storage/unknown
divergence, both halves oracle-locked by
packages/conformance/observations/storage/storage-uploadstring-unknown-format.json: prod accepts base64url (base64urlOk: true) and throws storage/unknown for an unrecognized format — not storage/invalid-format. The v1 sandbox ships only raw/base64/data_url (matches StringFormat) and throws storage/invalid-format for anything else (ST-B3 replaced the old mis-parse-as-data_url behavior). Both sides pinned in oracle-conformance.test.ts; sandbox code path documented in upload.ts's decodeString. Implementing base64url decoding is still one line in decodeString. (Structured evidence: error-codes.test.ts)uploadBytesResumable(ref, data, metadata?)Exported by firebase/storage; returns an UploadTask with pause() / resume() / cancel()
unit:resumable-upload.test.ts confirms uploadBytesResumable(ref, data) returns an UploadTask supporting pause(), resume(), and cancel() over synthetic microtask steps.uploadBytesResumable(ref, data, metadata?)task.on('state_changed', next, error, complete) fires next with {bytesTransferred, totalBytes, state} snapshots
unit:resumable-upload.test.ts confirms synthetic progress snapshots are emitted during task execution without requiring network transfer.uploadBytesResumable(ref, data, metadata?)task.pause() flips state to 'paused'; task.resume() continues
unit:resumable-upload.test.ts confirms calling task.pause() and task.resume() transitions through 'paused' and 'running' states deterministically.uploadBytesResumable(ref, data, metadata?)task.cancel() rejects the upload with storage/canceled
unit:resumable-upload.test.ts confirms cancellation rejects with storage/canceled and leaves no falsely completed object in storage.getDownloadURL(ref)Exported by firebase/storage; returns a token-signed HTTPS URL that fetches the blob
Implemented with a two-sided pin. Production observation
storage-upload-bytes-roundtrip records urlIsHttps: true and a byte-identical fetch. The sandbox oracle replay now calls the same public getDownloadURL + fetch path and proves byte-identical content, while explicitly asserting its URL starts with blob:. The client↔host integration proves SharedWorker mode creates that URL in the calling page after the rules-checked Blob crosses the port. The remaining divergence is URL identity and lifetime: the sandbox URL is a page-local snapshot, not token-signed HTTPS and not shareable outside that page. (Structured evidence: integration.test.ts)deleteObject(ref)Sandbox: no-op on missing path (does NOT throw)
divergence: sandbox is no-op via
persistence.ts's delete. Prod's deleteObject on a missing path throws storage/object-not-found. Oracle-locked: packages/conformance/observations/storage/storage-delete-missing-throws.json (code: 'storage/object-not-found', name: 'FirebaseError' against blockingfun, fb-js-sdk 12.13.0). Both sides pinned in oracle-conformance.test.ts; documented in download.ts.getMetadata(ref)` / `updateMetadata(ref, metadata)updateMetadata with undefined field values preserves the prior value (does NOT clear it)
divergence: prod accepts
null to explicitly clear a field. Sandbox doesn't model null-clear (per metadata.ts doc comment). Documented; not probe-locked.getMetadata(ref)` / `updateMetadata(ref, metadata)FullMetadata.md5Hash populated on uploads
divergence: sandbox does NOT compute
md5Hash. Oracle-locked: packages/conformance/observations/storage/storage-upload-then-getmetadata.json confirms prod sets md5Hash (hasMd5Hash: true after a vanilla uploadBytes). Both sides pinned in oracle-conformance.test.ts. Aligning the sandbox is a one-spot fix in upload.ts's buildStoredMetadata.Not implemented yet
Tracked behavior that is not implemented in the current contract.
getStream(ref, maxDownloadSize?)Exported by firebase/storage (Node entry only); returns a Node Readable
No
pyric/storage implementation or behavioral probe exists; availability is classified by the owning surface contract.list(ref, options?)Exported by firebase/storage; accepts { maxResults, pageToken }, returns a ListResult with nextPageToken set when more pages remain
No paginated
list implementation or behavioral probe exists; availability is classified by the owning surface contract. ListResult.nextPageToken remains optional for forward-compatible consumer code.getMetadata(ref)` / `updateMetadata(ref, metadata)FullMetadata.ref lazy population (prod populates lazily)
not modeled in
pyric/storage — metadata.ts explicitly omits ref from FullMetadataconnectStorageEmulator(storage, host, port)Exported by firebase/storage; reroutes a FirebaseStorage handle to a local emulator
not implemented in
pyric/storage — the sandbox IS the local-target alternative; emulator parity is out of scope per index.tsReviewed public-runtime gaps
storage.runtime-enum-valuesStorageErrorCode and StringFormat are mirrored as TypeScript types but not as Firebase-compatible runtime enum objects, so value imports remain unavailable.
StorageErrorCode StringFormatupstream:firebase/storage
storage.node-streamThe Node-stream variant is deferred — not part of the browser-shaped v1 scope yet, but not genuinely un-modelable.
getStreamregistry:storage#62
storage.paginated-listingPaginated listing is deferred — listAll covers the v1 scope; pagination needs a stable pageToken shape, which is unbuilt design work rather than a sandbox limitation.
listregistry:storage#78