@syncular/server 0.32.1 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/blob-handlers.js +3 -3
- package/dist/bucket-blob-store.d.ts +77 -0
- package/dist/bucket-blob-store.js +266 -0
- package/dist/bucket-segment-store.d.ts +77 -0
- package/dist/bucket-segment-store.js +445 -0
- package/dist/context.d.ts +7 -0
- package/dist/d1-storage.js +2 -2
- package/dist/handler.js +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/object-bucket.d.ts +78 -0
- package/dist/object-bucket.js +18 -0
- package/dist/operations-realtime.js +3 -0
- package/dist/operations.d.ts +20 -3
- package/dist/operations.js +23 -3
- package/dist/realtime.d.ts +11 -0
- package/dist/realtime.js +25 -58
- package/dist/relational-rows.d.ts +6 -3
- package/dist/relational-rows.js +8 -9
- package/dist/s3-blob-store.d.ts +9 -64
- package/dist/s3-blob-store.js +25 -359
- package/dist/s3-object-bucket.d.ts +39 -0
- package/dist/s3-object-bucket.js +164 -0
- package/dist/s3-segment-store.d.ts +9 -61
- package/dist/s3-segment-store.js +25 -503
- package/dist/sqlite-storage.js +3 -8
- package/package.json +2 -2
- package/src/blob-handlers.ts +4 -4
- package/src/bucket-blob-store.ts +351 -0
- package/src/bucket-segment-store.ts +582 -0
- package/src/context.ts +10 -0
- package/src/d1-storage.ts +3 -3
- package/src/handler.ts +11 -0
- package/src/index.ts +4 -0
- package/src/object-bucket.ts +83 -0
- package/src/operations-realtime.ts +7 -0
- package/src/operations.ts +52 -5
- package/src/realtime.ts +29 -58
- package/src/relational-rows.ts +8 -11
- package/src/s3-blob-store.ts +25 -466
- package/src/s3-object-bucket.ts +233 -0
- package/src/s3-segment-store.ts +25 -646
- package/src/sqlite-storage.ts +3 -10
package/dist/operations.js
CHANGED
|
@@ -6,7 +6,7 @@ import { processPushOperationsWithTrace } from './push.js';
|
|
|
6
6
|
import { compileSchema } from './schema.js';
|
|
7
7
|
import { authorizeWrite, renderScopeValue, } from './scopes.js';
|
|
8
8
|
import { transactionQuery } from './storage-query.js';
|
|
9
|
-
import { toValidateRow } from './validate.js';
|
|
9
|
+
import { toValidateRow, ValidationRejection, } from './validate.js';
|
|
10
10
|
function scopeAllowed(allowed, variable, value) {
|
|
11
11
|
const values = allowed[variable];
|
|
12
12
|
return (values !== undefined && (values.includes('*') || values.includes(value)));
|
|
@@ -353,6 +353,15 @@ export class RemoteOperationRegistry {
|
|
|
353
353
|
}
|
|
354
354
|
}
|
|
355
355
|
function encodeRemoteOperationError(error, fallbackCode) {
|
|
356
|
+
if (error instanceof ValidationRejection) {
|
|
357
|
+
return encodeRemoteOperationResponse({
|
|
358
|
+
revision: 1,
|
|
359
|
+
kind: 'error',
|
|
360
|
+
code: error.code,
|
|
361
|
+
message: error.message,
|
|
362
|
+
retryable: false,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
356
365
|
const sync = error instanceof SyncError
|
|
357
366
|
? error
|
|
358
367
|
: syncError(fallbackCode, fallbackCode === 'operation.invalid_request'
|
|
@@ -366,7 +375,7 @@ function encodeRemoteOperationError(error, fallbackCode) {
|
|
|
366
375
|
retryable: sync.retryable,
|
|
367
376
|
});
|
|
368
377
|
}
|
|
369
|
-
export async function handleRemoteOperation(bytes,
|
|
378
|
+
export async function handleRemoteOperation(bytes, authenticated, operations) {
|
|
370
379
|
let request;
|
|
371
380
|
try {
|
|
372
381
|
request = decodeRemoteOperationRequest(bytes);
|
|
@@ -384,6 +393,7 @@ export async function handleRemoteOperation(bytes, ctx, registry) {
|
|
|
384
393
|
catch (error) {
|
|
385
394
|
return encodeRemoteOperationError(error, 'operation.invalid_request');
|
|
386
395
|
}
|
|
396
|
+
let ctx = authenticated;
|
|
387
397
|
try {
|
|
388
398
|
await touchAuthenticatedPartition(ctx);
|
|
389
399
|
if (request.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
@@ -397,6 +407,16 @@ export async function handleRemoteOperation(bytes, ctx, registry) {
|
|
|
397
407
|
(typeof request.requestId !== 'string' || request.requestId.length === 0)) {
|
|
398
408
|
throw syncError('operation.invalid_request');
|
|
399
409
|
}
|
|
410
|
+
let registry = operations;
|
|
411
|
+
if (typeof registry === 'function') {
|
|
412
|
+
const binding = await registry(request, ctx);
|
|
413
|
+
registry = binding.registry;
|
|
414
|
+
ctx = {
|
|
415
|
+
...ctx,
|
|
416
|
+
actorId: binding.actorId ?? ctx.actorId,
|
|
417
|
+
resolveScopes: binding.resolveScopes ?? ctx.resolveScopes,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
400
420
|
const operation = registry.get(request.operationId);
|
|
401
421
|
if (operation === undefined) {
|
|
402
422
|
throw syncError('operation.unknown');
|
|
@@ -411,7 +431,7 @@ export async function handleRemoteOperation(bytes, ctx, registry) {
|
|
|
411
431
|
return encodeRemoteOperationResponse(await operation.run(ctx, request.clientId, request.requestId, request.params));
|
|
412
432
|
}
|
|
413
433
|
catch (error) {
|
|
414
|
-
if (!(error instanceof SyncError)) {
|
|
434
|
+
if (!(error instanceof SyncError || error instanceof ValidationRejection)) {
|
|
415
435
|
reportError(error, ctx.onError, 'operations');
|
|
416
436
|
}
|
|
417
437
|
return encodeRemoteOperationError(error, 'operation.execution_failed');
|
package/dist/realtime.d.ts
CHANGED
|
@@ -162,6 +162,11 @@ export declare class RealtimeSession {
|
|
|
162
162
|
* deltas immediately; it also survives a newer round-end refresh that
|
|
163
163
|
* failed, because that round applied nothing.
|
|
164
164
|
*/
|
|
165
|
+
/**
|
|
166
|
+
* A pull outside this connection changed the client's subscriptions. A
|
|
167
|
+
* round in flight on the connection reloads them at its end anyway.
|
|
168
|
+
*/
|
|
169
|
+
refreshAfterPull(): Promise<void>;
|
|
165
170
|
refreshRegistrations(failClosed: boolean): Promise<void>;
|
|
166
171
|
sendHeartbeat(): void;
|
|
167
172
|
sendWake(reason: WakeReason): void;
|
|
@@ -230,6 +235,12 @@ export declare class RealtimeHub {
|
|
|
230
235
|
disconnect(session: RealtimeSession): void;
|
|
231
236
|
/** RealtimeNotifier: fan an applied commit out to matching sessions. */
|
|
232
237
|
notifyCommit(partition: string, commit: StoredCommit): Promise<void>;
|
|
238
|
+
/**
|
|
239
|
+
* RealtimeNotifier: a pull changed a client's subscription list (§8.7).
|
|
240
|
+
* Its sockets reload their registrations with the round-end semantics, so
|
|
241
|
+
* an unreadable record keeps the previous registrations.
|
|
242
|
+
*/
|
|
243
|
+
notifySubscriptions(partition: string, clientId: string): Promise<void>;
|
|
233
244
|
/**
|
|
234
245
|
* Host-initiated, fail-closed registration refresh (§8.7). A host calls this
|
|
235
246
|
* after it changes an actor's membership or connection state, so the
|
package/dist/realtime.js
CHANGED
|
@@ -510,6 +510,15 @@ export class RealtimeSession {
|
|
|
510
510
|
* deltas immediately; it also survives a newer round-end refresh that
|
|
511
511
|
* failed, because that round applied nothing.
|
|
512
512
|
*/
|
|
513
|
+
/**
|
|
514
|
+
* A pull outside this connection changed the client's subscriptions. A
|
|
515
|
+
* round in flight on the connection reloads them at its end anyway.
|
|
516
|
+
*/
|
|
517
|
+
async refreshAfterPull() {
|
|
518
|
+
if (this.#activeRound !== undefined)
|
|
519
|
+
return;
|
|
520
|
+
await this.refreshRegistrations(false);
|
|
521
|
+
}
|
|
513
522
|
async refreshRegistrations(failClosed) {
|
|
514
523
|
const generation = ++this.#registrationGeneration;
|
|
515
524
|
let registrations;
|
|
@@ -845,67 +854,13 @@ export class RealtimeHub {
|
|
|
845
854
|
// segment store the HTTP binding uses — never a degraded round.
|
|
846
855
|
throw syncError('sync.invalid_request', 'socket sync rounds require a segment store on the realtime hub (§8.7)');
|
|
847
856
|
}
|
|
857
|
+
// The whole config, so a new `SyncServerConfig` field reaches socket and
|
|
858
|
+
// hub-served HTTP rounds without being listed here.
|
|
848
859
|
return {
|
|
860
|
+
...this.#config,
|
|
861
|
+
segments,
|
|
849
862
|
partition: identity.partition,
|
|
850
863
|
actorId: identity.actorId,
|
|
851
|
-
schema: this.#config.schema,
|
|
852
|
-
...(this.#config.schemaWindow !== undefined
|
|
853
|
-
? { schemaWindow: this.#config.schemaWindow }
|
|
854
|
-
: {}),
|
|
855
|
-
storage: this.#config.storage,
|
|
856
|
-
segments,
|
|
857
|
-
resolveScopes: this.#config.resolveScopes,
|
|
858
|
-
...(this.#config.blobs !== undefined
|
|
859
|
-
? { blobs: this.#config.blobs }
|
|
860
|
-
: {}),
|
|
861
|
-
...(this.#config.maxBlobBytes !== undefined
|
|
862
|
-
? { maxBlobBytes: this.#config.maxBlobBytes }
|
|
863
|
-
: {}),
|
|
864
|
-
...(this.#config.crdtMergers !== undefined
|
|
865
|
-
? { crdtMergers: this.#config.crdtMergers }
|
|
866
|
-
: {}),
|
|
867
|
-
...(this.#config.validators !== undefined
|
|
868
|
-
? { validators: this.#config.validators }
|
|
869
|
-
: {}),
|
|
870
|
-
...(this.#config.commitValidator !== undefined
|
|
871
|
-
? { commitValidator: this.#config.commitValidator }
|
|
872
|
-
: {}),
|
|
873
|
-
...(this.#config.reactionPlanner !== undefined
|
|
874
|
-
? { reactionPlanner: this.#config.reactionPlanner }
|
|
875
|
-
: {}),
|
|
876
|
-
...(this.#config.checkpoints !== undefined
|
|
877
|
-
? { checkpoints: this.#config.checkpoints }
|
|
878
|
-
: {}),
|
|
879
|
-
...(this.#config.clock !== undefined
|
|
880
|
-
? { clock: this.#config.clock }
|
|
881
|
-
: {}),
|
|
882
|
-
...(this.#config.limits !== undefined
|
|
883
|
-
? { limits: this.#config.limits }
|
|
884
|
-
: {}),
|
|
885
|
-
...(this.#config.signedUrls !== undefined
|
|
886
|
-
? { signedUrls: this.#config.signedUrls }
|
|
887
|
-
: {}),
|
|
888
|
-
...(this.#config.blobSignedUrls !== undefined
|
|
889
|
-
? { blobSignedUrls: this.#config.blobSignedUrls }
|
|
890
|
-
: {}),
|
|
891
|
-
...(this.#config.blobUploadUrls !== undefined
|
|
892
|
-
? { blobUploadUrls: this.#config.blobUploadUrls }
|
|
893
|
-
: {}),
|
|
894
|
-
...(this.#config.sqliteImageBuilder !== undefined
|
|
895
|
-
? { sqliteImageBuilder: this.#config.sqliteImageBuilder }
|
|
896
|
-
: {}),
|
|
897
|
-
...(this.#config.leases !== undefined
|
|
898
|
-
? { leases: this.#config.leases }
|
|
899
|
-
: {}),
|
|
900
|
-
...(this.#config.events !== undefined
|
|
901
|
-
? { events: this.#config.events }
|
|
902
|
-
: {}),
|
|
903
|
-
...(this.#config.onError !== undefined
|
|
904
|
-
? { onError: this.#config.onError }
|
|
905
|
-
: {}),
|
|
906
|
-
...(this.#config.mapError !== undefined
|
|
907
|
-
? { mapError: this.#config.mapError }
|
|
908
|
-
: {}),
|
|
909
864
|
realtime: this,
|
|
910
865
|
};
|
|
911
866
|
}
|
|
@@ -1012,6 +967,18 @@ export class RealtimeHub {
|
|
|
1012
967
|
session.deliverCommit(commit);
|
|
1013
968
|
}
|
|
1014
969
|
}
|
|
970
|
+
/**
|
|
971
|
+
* RealtimeNotifier: a pull changed a client's subscription list (§8.7).
|
|
972
|
+
* Its sockets reload their registrations with the round-end semantics, so
|
|
973
|
+
* an unreadable record keeps the previous registrations.
|
|
974
|
+
*/
|
|
975
|
+
async notifySubscriptions(partition, clientId) {
|
|
976
|
+
for (const session of [...this.#sessions]) {
|
|
977
|
+
if (session.partition !== partition || session.clientId !== clientId)
|
|
978
|
+
continue;
|
|
979
|
+
await session.refreshAfterPull();
|
|
980
|
+
}
|
|
981
|
+
}
|
|
1015
982
|
/**
|
|
1016
983
|
* Host-initiated, fail-closed registration refresh (§8.7). A host calls this
|
|
1017
984
|
* after it changes an actor's membership or connection state, so the
|
|
@@ -259,11 +259,14 @@ export declare function prefetchRowsSql(table: CompiledTable): string;
|
|
|
259
259
|
* SQLite/D1: the newest `commit_seq` in the inverted change-scope index for
|
|
260
260
|
* one variable's values, as one index-only max per value (a `(partition,
|
|
261
261
|
* tbl, var, value)` prefix), so the cost is independent of the table's
|
|
262
|
-
* history.
|
|
262
|
+
* history. The values arrive as one JSON array, so the statement has no
|
|
263
|
+
* compound SELECT and four bound parameters for any value count (D1 caps a
|
|
264
|
+
* compound SELECT at 5 terms and a statement at 100 parameters).
|
|
265
|
+
* Params: [partition, tbl, var, values as JSON array text].
|
|
263
266
|
*/
|
|
264
|
-
export declare
|
|
267
|
+
export declare const LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL = "SELECT max((SELECT max(commit_seq) FROM sync_change_scopes\n WHERE partition=? AND tbl=? AND var=? AND value=v.value)) AS seq\n FROM json_each(?) AS v";
|
|
265
268
|
/**
|
|
266
|
-
* PostgreSQL set-based form of `
|
|
269
|
+
* PostgreSQL set-based form of `LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL` for the
|
|
267
270
|
* subscriptions of one table in a pull. `$2` is a JSON array of
|
|
268
271
|
* `{i, var, vals}` records. Params: [partition, specs (JSON text), tbl].
|
|
269
272
|
*/
|
package/dist/relational-rows.js
CHANGED
|
@@ -610,17 +610,16 @@ export function prefetchRowsSql(table) {
|
|
|
610
610
|
* SQLite/D1: the newest `commit_seq` in the inverted change-scope index for
|
|
611
611
|
* one variable's values, as one index-only max per value (a `(partition,
|
|
612
612
|
* tbl, var, value)` prefix), so the cost is independent of the table's
|
|
613
|
-
* history.
|
|
613
|
+
* history. The values arrive as one JSON array, so the statement has no
|
|
614
|
+
* compound SELECT and four bound parameters for any value count (D1 caps a
|
|
615
|
+
* compound SELECT at 5 terms and a statement at 100 parameters).
|
|
616
|
+
* Params: [partition, tbl, var, values as JSON array text].
|
|
614
617
|
*/
|
|
615
|
-
export
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
perValue.push('SELECT (SELECT max(commit_seq) FROM sync_change_scopes WHERE partition=? AND tbl=? AND var=? AND value=?) AS seq');
|
|
619
|
-
}
|
|
620
|
-
return `SELECT max(seq) AS seq FROM (${perValue.join(' UNION ALL ')})`;
|
|
621
|
-
}
|
|
618
|
+
export const LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL = `SELECT max((SELECT max(commit_seq) FROM sync_change_scopes
|
|
619
|
+
WHERE partition=? AND tbl=? AND var=? AND value=v.value)) AS seq
|
|
620
|
+
FROM json_each(?) AS v`;
|
|
622
621
|
/**
|
|
623
|
-
* PostgreSQL set-based form of `
|
|
622
|
+
* PostgreSQL set-based form of `LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL` for the
|
|
624
623
|
* subscriptions of one table in a pull. `$2` is a JSON array of
|
|
625
624
|
* `{i, var, vals}` records. Params: [partition, specs (JSON text), tbl].
|
|
626
625
|
*/
|
package/dist/s3-blob-store.d.ts
CHANGED
|
@@ -1,37 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* S3-compatible blob store (SPEC.md §5.9.2 durable objects, §5.9.5 delegated
|
|
3
|
-
* presign) — AWS S3, Cloudflare R2, MinIO. The blob twin of
|
|
4
|
-
* `s3-segment-store.ts
|
|
5
|
-
* content-addressed key layout, same LIST-free-on-the-hot-path CAS stats
|
|
6
|
-
* accumulator. Zero dependencies.
|
|
3
|
+
* presign) — AWS S3, Cloudflare R2's S3 endpoint, MinIO. The blob twin of
|
|
4
|
+
* `s3-segment-store.ts`.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* client's §5.9.1 hash check passes — blobs are never re-compressed at
|
|
14
|
-
* rest, §5.8/§5.9.5). The `byteLength` + optional `mediaType` ride along
|
|
15
|
-
* as object user metadata `x-amz-meta-syncular-blob` = base64url(JSON), so
|
|
16
|
-
* `get` is a single GET. `createdAtMs` is stored there too so the orphan
|
|
17
|
-
* sweep (§5.9.2) can read the upload age without a separate index.
|
|
18
|
-
*
|
|
19
|
-
* DURABILITY vs SEGMENT TTL — the honest interface difference (§5.9.2): a
|
|
20
|
-
* blob referenced by a live row must stay downloadable INDEFINITELY. So,
|
|
21
|
-
* unlike `S3SegmentStore`, this store writes **no `expiresAtMs`, no S3
|
|
22
|
-
* lifecycle-expiration mapping, and no TTL config**. Expiry is
|
|
23
|
-
* REFERENCE-driven, not time-driven: reclamation is the host-scheduled
|
|
24
|
-
* `sweepOrphanBlobs` (blob-store.ts) deleting only objects no live row
|
|
25
|
-
* references after a grace period — never a bucket lifecycle rule. Do NOT
|
|
26
|
-
* put a lifecycle expiration on the `blob/` prefix; it would delete
|
|
27
|
-
* still-referenced attachments.
|
|
28
|
-
*
|
|
29
|
-
* The orphan sweep is the ONLY operation that LISTs: `sweepOrphans` pages
|
|
30
|
-
* `ListObjectsV2` over the `blob/` prefix (an admin/GC path, off the hot
|
|
31
|
-
* path) to find candidates, reads each object's `createdAtMs` from its
|
|
32
|
-
* metadata for the grace check, and DELETEs the unreferenced-and-old ones.
|
|
6
|
+
* The store logic (content-addressed key layout, idempotent re-put, orphan
|
|
7
|
+
* sweep, CAS stats accumulator) is `BucketBlobStore`, shared with
|
|
8
|
+
* `R2BlobStore` in `@syncular/server-workers`; this class adds the S3 REST
|
|
9
|
+
* transport (`S3ObjectBucket`) and the SigV4 presign methods, which a bucket
|
|
10
|
+
* binding cannot mint.
|
|
33
11
|
*/
|
|
34
|
-
import
|
|
12
|
+
import { BucketBlobStore } from './bucket-blob-store.js';
|
|
35
13
|
import type { BlobPresignConfig, BlobUploadPresignConfig, SegmentUrlIssue } from './signed-url.js';
|
|
36
14
|
export interface S3BlobStoreConfig {
|
|
37
15
|
/**
|
|
@@ -52,42 +30,9 @@ export interface S3BlobStoreConfig {
|
|
|
52
30
|
/** Key namespace inside the bucket, e.g. `syncular/`. Default: none. */
|
|
53
31
|
readonly keyPrefix?: string;
|
|
54
32
|
}
|
|
55
|
-
export declare class S3BlobStore
|
|
33
|
+
export declare class S3BlobStore extends BucketBlobStore {
|
|
56
34
|
#private;
|
|
57
35
|
constructor(config: S3BlobStoreConfig);
|
|
58
|
-
/**
|
|
59
|
-
* `{keyPrefix}blob/{partition}/sha256/{hex}` — the §5.9.5 "signed key embeds
|
|
60
|
-
* the blobId". Blobs are content-addressed, but download authorization and
|
|
61
|
-
* the reference index are per-partition, so the same bytes uploaded under
|
|
62
|
-
* two partitions are two objects (a partition MUST NOT read another's
|
|
63
|
-
* attachment by guessing a content address). The partition is folded into
|
|
64
|
-
* the key as a path segment.
|
|
65
|
-
*/
|
|
66
|
-
objectKeyFor(partition: string, blobId: string): string;
|
|
67
|
-
put(partition: string, blobId: string, bytes: Uint8Array, nowMs: number, mediaType?: string): Promise<BlobRecord>;
|
|
68
|
-
has(partition: string, blobId: string): Promise<boolean>;
|
|
69
|
-
get(partition: string, blobId: string): Promise<{
|
|
70
|
-
record: BlobRecord;
|
|
71
|
-
bytes: Uint8Array;
|
|
72
|
-
} | undefined>;
|
|
73
|
-
/**
|
|
74
|
-
* §5.9.2 orphan sweep — the ONLY LISTing operation. Page `ListObjectsV2`
|
|
75
|
-
* over `{prefix}blob/{partition}/`, HEAD each object for its `createdAtMs`,
|
|
76
|
-
* and DELETE the ones both older than `olderThanMs` AND absent from
|
|
77
|
-
* `referencedBlobIds`. Returns the deleted blobIds. Never deletes a
|
|
78
|
-
* referenced blob (§5.9.2). Off the hot path — a host-scheduled GC job.
|
|
79
|
-
*/
|
|
80
|
-
sweepOrphans(partition: string, olderThanMs: number, referencedBlobIds: ReadonlySet<string>): Promise<string[]>;
|
|
81
|
-
/**
|
|
82
|
-
* Store-wide counters from the pointer-object accumulator (never a LIST on
|
|
83
|
-
* the read path). `approximate: true` for the same reasons `S3SegmentStore`
|
|
84
|
-
* documents: best-effort ETag-CAS maintenance and sweep deletes that may
|
|
85
|
-
* miss the decrement. Exact within a single writer; a health gauge under
|
|
86
|
-
* concurrency. Note this is store-WIDE, not partition-scoped, so a shared
|
|
87
|
-
* bucket reports the whole bucket — the parameter is accepted for interface
|
|
88
|
-
* parity but the accumulator is one object per store.
|
|
89
|
-
*/
|
|
90
|
-
stats(_partition: string): Promise<BlobStoreStats>;
|
|
91
36
|
/**
|
|
92
37
|
* SigV4 presigned GET for a blob object (§5.9.5 delegated presign). The
|
|
93
38
|
* signed key embeds the `blobId`; `urlExpiresAtMs` is the provider-enforced
|