Pyric
Navigate

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)
getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)getStorageSandbox(sandbox) wraps a bare Sandbox with an anonymous context (auth: null)
getStorage(app, bucketUrl?) / getStorageSandbox(target, options?)getStorage(app) returns the sandbox handle selected by package resolution
entry-path:storage runs the canonical initializeAppgetStorage(app)refuploadBytes 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)
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)
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)
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
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
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
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
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"
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
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
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
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)
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/s1sessions/s1)
unit:reference.test.ts ("normalizes leading/trailing/double slashes")
ref(storage[, path]) / ref(parent, path)Path normalization: trailing slashes stripped
ref(storage[, path]) / ref(parent, path)Path normalization: repeated internal slashes collapsed (a//ba/b)
ref(storage[, path]) / ref(parent, path)ref(parent, child) joins relative to parent's fullPath
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)
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(storage[, path]) / ref(parent, path)toString() returns gs://<bucket>/<fullPath>
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)
(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
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.ts
uploadBytes(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 %%0storage/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.ts

getStream(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 + getMetadata

listAll(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)
unit:list.test.ts (root-scan example asserts configs < sessions)
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
unit:metadata.test.ts ("returns the FullMetadata uploadBytes wrote")
getMetadata(ref) / updateMetadata(ref, metadata)getMetadata(ref) throws storage/object-not-found for missing paths
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
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)
unit:metadata.test.ts ("replaces settable fields, bumps metageneration, refreshes updated")
getMetadata(ref) / updateMetadata(ref, metadata)updateMetadata bumps metageneration by 1 on each call
getMetadata(ref) / updateMetadata(ref, metadata)updateMetadata refreshes updated to the call moment; timeCreated and generation stay pinned
getMetadata(ref) / updateMetadata(ref, metadata)updateMetadata preserves the blob bytes (only metadata changes)
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)
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
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
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
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
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
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)
not modeled in pyric/storagemetadata.ts explicitly omits ref from FullMetadata

connectStorageEmulator(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.ts

Op-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/storagemetadata.ts explicitly omits ref from FullMetadata
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.ts

Reviewed 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 StringFormat
upstream:firebase/storage
storage.node-streamThe Node-stream variant is deferred — not part of the browser-shaped v1 scope yet, but not genuinely un-modelable.
getStream
registry: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.
list
registry:storage#78