@tinycloud/replica 0.2.4-beta.3 → 0.3.0-beta.5

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,2587 @@
1
+ // ../bootstrap/dist/index.js
2
+ var CAPABILITIES = [
3
+ { urn: "tinycloud.kv/get", service: "tinycloud.kv", status: "active" },
4
+ { urn: "tinycloud.kv/list", service: "tinycloud.kv", status: "active" },
5
+ { urn: "tinycloud.kv/metadata", service: "tinycloud.kv", status: "active" },
6
+ { urn: "tinycloud.kv/put", service: "tinycloud.kv", status: "active" },
7
+ { urn: "tinycloud.kv/del", service: "tinycloud.kv", status: "active" },
8
+ { urn: "tinycloud.kv/delete", service: "tinycloud.kv", status: "deprecated-alias", aliasOf: "tinycloud.kv/del" },
9
+ { urn: "tinycloud.kv/sync", service: "tinycloud.kv", status: "active" },
10
+ { urn: "tinycloud.kv/retain", service: "tinycloud.kv", status: "active" },
11
+ { urn: "tinycloud.sql/read", service: "tinycloud.sql", status: "active" },
12
+ { urn: "tinycloud.sql/select", service: "tinycloud.sql", status: "deprecated-alias", aliasOf: "tinycloud.sql/read" },
13
+ { urn: "tinycloud.sql/write", service: "tinycloud.sql", status: "active" },
14
+ { urn: "tinycloud.sql/schema", service: "tinycloud.sql", status: "active" },
15
+ { urn: "tinycloud.sql/admin", service: "tinycloud.sql", status: "active", implies: ["tinycloud.sql/schema"] },
16
+ { urn: "tinycloud.sql/*", service: "tinycloud.sql", status: "active", implies: ["tinycloud.sql/read", "tinycloud.sql/write", "tinycloud.sql/schema", "tinycloud.sql/admin"] },
17
+ { urn: "tinycloud.duckdb/read", service: "tinycloud.duckdb", status: "active" },
18
+ { urn: "tinycloud.duckdb/write", service: "tinycloud.duckdb", status: "active" },
19
+ { urn: "tinycloud.duckdb/admin", service: "tinycloud.duckdb", status: "active" },
20
+ { urn: "tinycloud.duckdb/import", service: "tinycloud.duckdb", status: "active" },
21
+ { urn: "tinycloud.duckdb/export", service: "tinycloud.duckdb", status: "active" },
22
+ { urn: "tinycloud.duckdb/select", service: "tinycloud.duckdb", status: "deprecated-alias", aliasOf: "tinycloud.duckdb/read" },
23
+ { urn: "tinycloud.duckdb/*", service: "tinycloud.duckdb", status: "active", implies: ["tinycloud.duckdb/read", "tinycloud.duckdb/write", "tinycloud.duckdb/admin", "tinycloud.duckdb/import", "tinycloud.duckdb/export"] },
24
+ { urn: "tinycloud.capabilities/read", service: "tinycloud.capabilities", status: "active" },
25
+ { urn: "tinycloud.delegation/status", service: "tinycloud.delegation", status: "active" },
26
+ { urn: "tinycloud.delegation/list", service: "tinycloud.delegation", status: "active" },
27
+ { urn: "tinycloud.hooks/subscribe", service: "tinycloud.hooks", status: "active" },
28
+ { urn: "tinycloud.hooks/register", service: "tinycloud.hooks", status: "active" },
29
+ { urn: "tinycloud.hooks/list", service: "tinycloud.hooks", status: "active" },
30
+ { urn: "tinycloud.hooks/unregister", service: "tinycloud.hooks", status: "active" },
31
+ { urn: "tinycloud.encryption/decrypt", service: "tinycloud.encryption", status: "active" },
32
+ { urn: "tinycloud.encryption/network.create", service: "tinycloud.encryption", status: "active" },
33
+ { urn: "tinycloud.encryption/network.revoke", service: "tinycloud.encryption", status: "active" },
34
+ { urn: "tinycloud.space/host", service: "tinycloud.space", status: "active" },
35
+ { urn: "tinycloud.space/create", service: "tinycloud.space", status: "active" },
36
+ { urn: "tinycloud.space/list", service: "tinycloud.space", status: "active" },
37
+ { urn: "tinycloud.space/info", service: "tinycloud.space", status: "active" },
38
+ { urn: "tinycloud.vfs/get", service: "tinycloud.vfs", status: "reserved" },
39
+ { urn: "tinycloud.vfs/list", service: "tinycloud.vfs", status: "reserved" },
40
+ { urn: "tinycloud.vfs/metadata", service: "tinycloud.vfs", status: "reserved" },
41
+ { urn: "tinycloud.vfs/put", service: "tinycloud.vfs", status: "reserved" },
42
+ { urn: "tinycloud.vfs/delete", service: "tinycloud.vfs", status: "reserved" }
43
+ ];
44
+ function actionKey(action) {
45
+ if (action === "*") return "ALL";
46
+ return action.toUpperCase().replace(/[^A-Z0-9]/g, "_");
47
+ }
48
+ function serviceOf(urn) {
49
+ return urn.slice("tinycloud.".length, urn.indexOf("/"));
50
+ }
51
+ function actionOf(urn) {
52
+ return urn.slice(urn.indexOf("/") + 1);
53
+ }
54
+ function deriveServiceConstants(service) {
55
+ const out = {};
56
+ for (const entry of CAPABILITIES) {
57
+ if (serviceOf(entry.urn) !== service) continue;
58
+ out[actionKey(actionOf(entry.urn))] = entry.urn;
59
+ }
60
+ return out;
61
+ }
62
+ var KV = deriveServiceConstants("kv");
63
+ var SQL = {
64
+ ...deriveServiceConstants("sql"),
65
+ EXECUTE: "tinycloud.sql/execute",
66
+ EXPORT: "tinycloud.sql/export"
67
+ };
68
+ var DUCKDB = {
69
+ ...deriveServiceConstants("duckdb"),
70
+ DESCRIBE: "tinycloud.duckdb/describe",
71
+ EXECUTE: "tinycloud.duckdb/execute"
72
+ };
73
+ var CAPABILITIES2 = deriveServiceConstants("capabilities");
74
+ var HOOKS = deriveServiceConstants("hooks");
75
+ var ENCRYPTION = deriveServiceConstants("encryption");
76
+ var SPACE = deriveServiceConstants("space");
77
+ var CAPABILITY_REGISTRY = CAPABILITIES;
78
+ var DEFAULT_MANIFEST_SPACE = "applications";
79
+ var ACCOUNT_REGISTRY_SPACE = "account";
80
+ var ACCOUNT_REGISTRY_PATH = "applications/";
81
+ var SECRETS_SPACE = "secrets";
82
+ var BOOTSTRAP_DEFAULT_SPACE = "default";
83
+ var BOOTSTRAP_PUBLIC_SPACE = "public";
84
+ var AGENTS_SPACE = "agents";
85
+ var BOOTSTRAP_ENCRYPTION_NETWORK_NAME = "default";
86
+ var BOOTSTRAP_ENCRYPTION_NETWORK_RESOURCE_TEMPLATE = `urn:tinycloud:encryption:{ownerDid}:${BOOTSTRAP_ENCRYPTION_NETWORK_NAME}`;
87
+ var DEFAULT_EXPIRY_MS = 30 * 24 * 60 * 60 * 1e3;
88
+ var VAULT_PERMISSION_SERVICE = "tinycloud.vault";
89
+ var ENCRYPTION_PERMISSION_SERVICE = "tinycloud.encryption";
90
+ var ENCRYPTION_MANIFEST_SPACE = "encryption";
91
+ var NETWORK_CREATE_ACTION = ENCRYPTION.NETWORK_CREATE;
92
+ var BOOTSTRAP_SPACE_NAMES = [
93
+ BOOTSTRAP_DEFAULT_SPACE,
94
+ DEFAULT_MANIFEST_SPACE,
95
+ ACCOUNT_REGISTRY_SPACE,
96
+ SECRETS_SPACE,
97
+ BOOTSTRAP_PUBLIC_SPACE,
98
+ AGENTS_SPACE
99
+ ];
100
+ var TINYCLOUD_DEFAULT_SPACE_MANIFEST = {
101
+ app_id: "xyz.tinycloud.default",
102
+ name: "TinyCloud Default Space",
103
+ space: BOOTSTRAP_DEFAULT_SPACE,
104
+ prefix: "",
105
+ defaults: false,
106
+ includePublicSpace: false,
107
+ permissions: [
108
+ {
109
+ service: "tinycloud.kv",
110
+ space: BOOTSTRAP_DEFAULT_SPACE,
111
+ // Empty path = whole service on this space. Do NOT use "/": the recap
112
+ // encoder joins it as `<space>/<service>//`, which the node's byte-prefix
113
+ // resource matching can never extend (real paths start `<service>/x…`).
114
+ path: "",
115
+ actions: ["get", "put", "del", "list", "metadata"]
116
+ },
117
+ {
118
+ service: "tinycloud.sql",
119
+ space: BOOTSTRAP_DEFAULT_SPACE,
120
+ path: "",
121
+ actions: ["read", "write"]
122
+ }
123
+ ]
124
+ };
125
+ var TINYCLOUD_APPLICATIONS_SPACE_MANIFEST = {
126
+ app_id: "xyz.tinycloud.applications",
127
+ name: "TinyCloud Applications Space",
128
+ space: DEFAULT_MANIFEST_SPACE,
129
+ prefix: "",
130
+ defaults: false,
131
+ includePublicSpace: false,
132
+ permissions: [
133
+ {
134
+ service: "tinycloud.kv",
135
+ space: DEFAULT_MANIFEST_SPACE,
136
+ // Empty path = whole service on this space. Do NOT use "/": the recap
137
+ // encoder joins it as `<space>/<service>//`, which the node's byte-prefix
138
+ // resource matching can never extend (real paths start `<service>/x…`).
139
+ path: "",
140
+ actions: ["get", "put", "del", "list", "metadata"]
141
+ },
142
+ {
143
+ service: "tinycloud.sql",
144
+ space: DEFAULT_MANIFEST_SPACE,
145
+ path: "",
146
+ actions: ["read", "write"]
147
+ }
148
+ ]
149
+ };
150
+ var TINYCLOUD_ACCOUNT_SPACE_MANIFEST = {
151
+ app_id: "xyz.tinycloud.account",
152
+ name: "TinyCloud Account Registry",
153
+ space: ACCOUNT_REGISTRY_SPACE,
154
+ prefix: "",
155
+ defaults: false,
156
+ includePublicSpace: false,
157
+ permissions: [
158
+ {
159
+ service: "tinycloud.kv",
160
+ space: ACCOUNT_REGISTRY_SPACE,
161
+ path: "applications/",
162
+ actions: ["get", "put", "list"]
163
+ },
164
+ {
165
+ service: "tinycloud.kv",
166
+ space: ACCOUNT_REGISTRY_SPACE,
167
+ path: "spaces/",
168
+ actions: ["get", "put", "list"]
169
+ },
170
+ {
171
+ service: "tinycloud.sql",
172
+ space: ACCOUNT_REGISTRY_SPACE,
173
+ path: "account",
174
+ actions: ["read", "write", "schema"]
175
+ }
176
+ ]
177
+ };
178
+ var TINYCLOUD_SECRETS_BOOTSTRAP_MANIFEST = {
179
+ app_id: "xyz.tinycloud.secrets",
180
+ name: "TinyCloud Secrets",
181
+ space: SECRETS_SPACE,
182
+ prefix: "",
183
+ defaults: false,
184
+ includePublicSpace: false,
185
+ permissions: [
186
+ {
187
+ service: "tinycloud.vault",
188
+ space: SECRETS_SPACE,
189
+ path: "secrets/",
190
+ actions: ["read"],
191
+ skipPrefix: true
192
+ },
193
+ {
194
+ service: "tinycloud.kv",
195
+ space: SECRETS_SPACE,
196
+ path: "variables",
197
+ actions: ["list", "metadata"],
198
+ skipPrefix: true
199
+ },
200
+ {
201
+ service: "tinycloud.kv",
202
+ space: SECRETS_SPACE,
203
+ path: "variables/",
204
+ actions: ["get", "put", "del", "list", "metadata"],
205
+ skipPrefix: true
206
+ },
207
+ {
208
+ service: "tinycloud.sql",
209
+ space: SECRETS_SPACE,
210
+ path: "default",
211
+ actions: ["read", "write", "schema"],
212
+ skipPrefix: true
213
+ },
214
+ {
215
+ service: "tinycloud.capabilities",
216
+ space: SECRETS_SPACE,
217
+ path: "",
218
+ actions: ["read"],
219
+ skipPrefix: true
220
+ }
221
+ ]
222
+ };
223
+ var TINYCLOUD_PUBLIC_SPACE_MANIFEST = {
224
+ app_id: "xyz.tinycloud.public",
225
+ name: "TinyCloud Public Space",
226
+ space: BOOTSTRAP_PUBLIC_SPACE,
227
+ prefix: "",
228
+ defaults: false,
229
+ includePublicSpace: false,
230
+ permissions: [
231
+ {
232
+ service: "tinycloud.kv",
233
+ space: BOOTSTRAP_PUBLIC_SPACE,
234
+ path: "",
235
+ actions: ["get", "list", "metadata"]
236
+ }
237
+ ]
238
+ };
239
+ var TINYCLOUD_AGENTS_SPACE_MANIFEST = {
240
+ app_id: "xyz.tinycloud.agents",
241
+ name: "TinyCloud Agents Space",
242
+ space: AGENTS_SPACE,
243
+ prefix: "",
244
+ defaults: false,
245
+ includePublicSpace: false,
246
+ permissions: [
247
+ {
248
+ service: "tinycloud.kv",
249
+ space: AGENTS_SPACE,
250
+ path: "",
251
+ actions: ["get", "put", "del", "list", "metadata"]
252
+ }
253
+ ]
254
+ };
255
+ var BOOTSTRAP_SPACE_MANIFESTS = {
256
+ [BOOTSTRAP_DEFAULT_SPACE]: TINYCLOUD_DEFAULT_SPACE_MANIFEST,
257
+ [DEFAULT_MANIFEST_SPACE]: TINYCLOUD_APPLICATIONS_SPACE_MANIFEST,
258
+ [ACCOUNT_REGISTRY_SPACE]: TINYCLOUD_ACCOUNT_SPACE_MANIFEST,
259
+ [SECRETS_SPACE]: TINYCLOUD_SECRETS_BOOTSTRAP_MANIFEST,
260
+ [BOOTSTRAP_PUBLIC_SPACE]: TINYCLOUD_PUBLIC_SPACE_MANIFEST,
261
+ [AGENTS_SPACE]: TINYCLOUD_AGENTS_SPACE_MANIFEST
262
+ };
263
+ var BOOTSTRAP_PERSISTED_APPLICATION_MANIFESTS = [
264
+ TINYCLOUD_SECRETS_BOOTSTRAP_MANIFEST
265
+ ];
266
+ var ACCOUNT_INDEX_SCHEMA = [
267
+ `CREATE TABLE IF NOT EXISTS applications (
268
+ app_id TEXT PRIMARY KEY,
269
+ name TEXT,
270
+ description TEXT,
271
+ updated_at TEXT,
272
+ manifest_json TEXT NOT NULL
273
+ )`,
274
+ `CREATE TABLE IF NOT EXISTS application_state (
275
+ app_id TEXT PRIMARY KEY,
276
+ manifest_hash TEXT NOT NULL,
277
+ indexed_at TEXT NOT NULL
278
+ )`,
279
+ `CREATE TABLE IF NOT EXISTS spaces (
280
+ space_id TEXT PRIMARY KEY,
281
+ name TEXT NOT NULL,
282
+ owner_did TEXT NOT NULL,
283
+ type TEXT NOT NULL,
284
+ permissions_json TEXT NOT NULL,
285
+ status TEXT NOT NULL,
286
+ registered_at TEXT,
287
+ updated_at TEXT NOT NULL,
288
+ expires_at TEXT
289
+ )`,
290
+ `CREATE TABLE IF NOT EXISTS delegations (
291
+ cid TEXT PRIMARY KEY,
292
+ direction TEXT NOT NULL,
293
+ space_id TEXT NOT NULL,
294
+ space_name TEXT,
295
+ counterparty_did TEXT NOT NULL,
296
+ delegate_did TEXT NOT NULL,
297
+ delegator_did TEXT,
298
+ path TEXT NOT NULL,
299
+ actions_json TEXT NOT NULL,
300
+ expiry TEXT NOT NULL,
301
+ status TEXT NOT NULL,
302
+ created_at TEXT,
303
+ updated_at TEXT NOT NULL
304
+ )`,
305
+ `CREATE TABLE IF NOT EXISTS sync_state (
306
+ source TEXT PRIMARY KEY,
307
+ synced_at TEXT NOT NULL,
308
+ count INTEGER NOT NULL
309
+ )`,
310
+ "CREATE INDEX IF NOT EXISTS idx_delegations_direction ON delegations(direction)",
311
+ "CREATE INDEX IF NOT EXISTS idx_delegations_space ON delegations(space_id)",
312
+ "CREATE INDEX IF NOT EXISTS idx_delegations_counterparty ON delegations(counterparty_did)",
313
+ "CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_did)",
314
+ "CREATE INDEX IF NOT EXISTS idx_spaces_type ON spaces(type)"
315
+ ];
316
+ var SECRET_RECORDS_SCHEMA = [
317
+ `CREATE TABLE IF NOT EXISTS secret_records (
318
+ scope TEXT NOT NULL,
319
+ name TEXT NOT NULL,
320
+ provider_id TEXT,
321
+ custom_note TEXT,
322
+ created_at TEXT NOT NULL,
323
+ updated_at TEXT NOT NULL,
324
+ last_tested TEXT,
325
+ test_status TEXT,
326
+ test_message TEXT,
327
+ PRIMARY KEY(scope, name)
328
+ )`
329
+ ];
330
+ var BOOTSTRAP_MANIFEST = {
331
+ spaces: BOOTSTRAP_SPACE_NAMES.map((name) => ({
332
+ name,
333
+ manifest: BOOTSTRAP_SPACE_MANIFESTS[name],
334
+ persistedAsApplication: name === SECRETS_SPACE
335
+ })),
336
+ applications: BOOTSTRAP_PERSISTED_APPLICATION_MANIFESTS,
337
+ accountIndexSchema: ACCOUNT_INDEX_SCHEMA,
338
+ secretRecordsSchema: SECRET_RECORDS_SCHEMA,
339
+ encryptionNetwork: {
340
+ name: BOOTSTRAP_ENCRYPTION_NETWORK_NAME
341
+ }
342
+ };
343
+ function cloneManifest(manifest) {
344
+ return {
345
+ ...manifest,
346
+ permissions: manifest.permissions?.map((permission) => ({
347
+ ...permission,
348
+ actions: [...permission.actions]
349
+ }))
350
+ };
351
+ }
352
+ function actionUrn(service, action) {
353
+ return action.includes("/") ? action : `${service}/${action}`;
354
+ }
355
+ function applyPrefix(prefix, path, skipPrefix) {
356
+ if (skipPrefix === true || prefix === "" || path === "") {
357
+ return path;
358
+ }
359
+ if (path === "/") {
360
+ return `${prefix}/`;
361
+ }
362
+ const trimmedPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
363
+ const trimmedPath = path.startsWith("/") ? path.slice(1) : path;
364
+ return `${trimmedPrefix}/${trimmedPath}`;
365
+ }
366
+ function expandVaultPermission(entry) {
367
+ return entry.actions.map((action) => {
368
+ const normalized = action.startsWith("tinycloud.vault/") ? action.slice("tinycloud.vault/".length) : action.startsWith("tinycloud.kv/") ? action.slice("tinycloud.kv/".length) : action;
369
+ const mapped = normalized === "read" || normalized === "get" ? KV.GET : normalized === "write" || normalized === "put" ? KV.PUT : normalized === "delete" || normalized === "del" ? KV.DEL : normalized === "list" ? KV.LIST : normalized === "metadata" ? KV.METADATA : void 0;
370
+ if (mapped === void 0) {
371
+ throw new Error(`unknown vault action ${JSON.stringify(action)}`);
372
+ }
373
+ const normalizedPath = entry.path.startsWith("/") ? entry.path.slice(1) : entry.path;
374
+ return {
375
+ ...entry,
376
+ service: "tinycloud.kv",
377
+ path: `vault/${normalizedPath}`,
378
+ actions: [mapped],
379
+ skipPrefix: true
380
+ };
381
+ });
382
+ }
383
+ function expandPermissionEntry(entry, prefix, inheritedSpace) {
384
+ if (entry.service === VAULT_PERMISSION_SERVICE) {
385
+ return expandVaultPermission(entry).flatMap(
386
+ (expanded) => expandPermissionEntry(expanded, prefix, inheritedSpace)
387
+ );
388
+ }
389
+ const skipPrefix = entry.skipPrefix === true || entry.service === ENCRYPTION_PERMISSION_SERVICE;
390
+ return [
391
+ {
392
+ service: entry.service,
393
+ space: entry.service === ENCRYPTION_PERMISSION_SERVICE ? ENCRYPTION_MANIFEST_SPACE : entry.space ?? inheritedSpace,
394
+ path: applyPrefix(prefix, entry.path, skipPrefix),
395
+ actions: entry.actions.map((action) => actionUrn(entry.service, action)),
396
+ ...entry.description !== void 0 ? { description: entry.description } : {}
397
+ }
398
+ ];
399
+ }
400
+ function dedupeResources(resources) {
401
+ const byKey = /* @__PURE__ */ new Map();
402
+ for (const resource of resources) {
403
+ const key = `${resource.service}\0${resource.space}\0${resource.path}`;
404
+ const existing = byKey.get(key);
405
+ if (!existing) {
406
+ byKey.set(key, { ...resource, actions: [...resource.actions] });
407
+ continue;
408
+ }
409
+ for (const action of resource.actions) {
410
+ if (!existing.actions.includes(action)) {
411
+ existing.actions.push(action);
412
+ }
413
+ }
414
+ }
415
+ return [...byKey.values()];
416
+ }
417
+ function withCapabilitiesReadForSpaces(resources) {
418
+ const spaces = new Set(
419
+ resources.filter((resource) => resource.service !== ENCRYPTION_PERMISSION_SERVICE).map((resource) => resource.space)
420
+ );
421
+ return dedupeResources([
422
+ ...resources,
423
+ ...[...spaces].map((space) => ({
424
+ service: "tinycloud.capabilities",
425
+ space,
426
+ path: "",
427
+ actions: [CAPABILITIES2.READ]
428
+ }))
429
+ ]);
430
+ }
431
+ function composeManifestRequest(inputs, options = {}) {
432
+ if (!Array.isArray(inputs) || inputs.length === 0) {
433
+ throw new Error("composeManifestRequest requires at least one manifest");
434
+ }
435
+ const includeAccountRegistryPermissions = options.includeAccountRegistryPermissions ?? true;
436
+ const manifests = inputs.map(cloneManifest);
437
+ const resources = manifests.flatMap((manifest) => {
438
+ const prefix = manifest.prefix ?? manifest.app_id;
439
+ const space = manifest.space ?? DEFAULT_MANIFEST_SPACE;
440
+ const explicit = manifest.permissions ?? [];
441
+ return explicit.flatMap((entry) => expandPermissionEntry(entry, prefix, space));
442
+ });
443
+ if (includeAccountRegistryPermissions) {
444
+ resources.push(
445
+ ...ACCOUNT_MANIFEST_PERMISSIONS.map((resource) => ({
446
+ ...resource,
447
+ actions: [...resource.actions]
448
+ }))
449
+ );
450
+ }
451
+ const manifestsByAppId = /* @__PURE__ */ new Map();
452
+ for (const manifest of manifests) {
453
+ const current = manifestsByAppId.get(manifest.app_id) ?? [];
454
+ current.push(cloneManifest(manifest));
455
+ manifestsByAppId.set(manifest.app_id, current);
456
+ }
457
+ return {
458
+ manifests,
459
+ resources: withCapabilitiesReadForSpaces(resources),
460
+ delegationTargets: [],
461
+ registryRecords: includeAccountRegistryPermissions ? [...manifestsByAppId.entries()].map(([app_id, appManifests]) => ({
462
+ key: `${ACCOUNT_REGISTRY_PATH}${app_id}`,
463
+ app_id,
464
+ manifests: appManifests
465
+ })) : [],
466
+ expiryMs: DEFAULT_EXPIRY_MS,
467
+ includePublicSpace: manifests.some(
468
+ (manifest) => manifest.includePublicSpace ?? true
469
+ )
470
+ };
471
+ }
472
+ function composeBootstrapSpaceManifest(space) {
473
+ return composeManifestRequest([BOOTSTRAP_SPACE_MANIFESTS[space]], {
474
+ includeAccountRegistryPermissions: false
475
+ });
476
+ }
477
+ var ACCOUNT_MANIFEST_PERMISSIONS = Object.freeze([
478
+ {
479
+ service: "tinycloud.kv",
480
+ space: ACCOUNT_REGISTRY_SPACE,
481
+ path: ACCOUNT_REGISTRY_PATH,
482
+ actions: [KV.GET, KV.PUT, KV.LIST]
483
+ },
484
+ {
485
+ service: "tinycloud.kv",
486
+ space: ACCOUNT_REGISTRY_SPACE,
487
+ path: "spaces/",
488
+ actions: [KV.GET, KV.PUT, KV.LIST]
489
+ },
490
+ {
491
+ service: "tinycloud.kv",
492
+ space: ACCOUNT_REGISTRY_SPACE,
493
+ path: "system/bootstrap/complete",
494
+ actions: [KV.GET, KV.PUT]
495
+ },
496
+ {
497
+ service: "tinycloud.delegation",
498
+ space: ACCOUNT_REGISTRY_SPACE,
499
+ path: "",
500
+ actions: ["tinycloud.delegation/list"]
501
+ },
502
+ {
503
+ service: "tinycloud.sql",
504
+ space: ACCOUNT_REGISTRY_SPACE,
505
+ path: "account",
506
+ actions: [SQL.READ, SQL.WRITE, SQL.SCHEMA]
507
+ },
508
+ {
509
+ service: "tinycloud.capabilities",
510
+ space: ACCOUNT_REGISTRY_SPACE,
511
+ path: "",
512
+ actions: [CAPABILITIES2.READ]
513
+ }
514
+ ]);
515
+ var BOOTSTRAP_DEFAULT_ONLY_SESSION_REQUEST = composeBootstrapSpaceManifest(BOOTSTRAP_DEFAULT_SPACE);
516
+ var BOOTSTRAP_WIDENED_DEFAULT_SESSION_REQUEST = {
517
+ ...BOOTSTRAP_DEFAULT_ONLY_SESSION_REQUEST,
518
+ resources: [
519
+ ...BOOTSTRAP_DEFAULT_ONLY_SESSION_REQUEST.resources,
520
+ ...ACCOUNT_MANIFEST_PERMISSIONS.map((resource) => ({
521
+ ...resource,
522
+ actions: [...resource.actions]
523
+ }))
524
+ ]
525
+ };
526
+ var BOOTSTRAP_SESSION_REQUESTS = Object.freeze(
527
+ Object.fromEntries(
528
+ BOOTSTRAP_SPACE_NAMES.map((space) => [
529
+ space,
530
+ space === BOOTSTRAP_DEFAULT_SPACE ? BOOTSTRAP_WIDENED_DEFAULT_SESSION_REQUEST : composeBootstrapSpaceManifest(space)
531
+ ])
532
+ )
533
+ );
534
+ var ACCOUNT_SESSION_RAW_ALLOWLIST = Object.freeze([
535
+ {
536
+ service: "tinycloud.encryption",
537
+ resource: BOOTSTRAP_ENCRYPTION_NETWORK_RESOURCE_TEMPLATE,
538
+ actions: [NETWORK_CREATE_ACTION]
539
+ }
540
+ ]);
541
+ var BOOTSTRAP_ALLOWLIST = Object.freeze(
542
+ BOOTSTRAP_SPACE_NAMES.flatMap((space) => [
543
+ {
544
+ kind: "session",
545
+ service: "tinycloud.session",
546
+ space,
547
+ actions: ["siwe"],
548
+ resources: BOOTSTRAP_SESSION_REQUESTS[space].resources,
549
+ ...space === ACCOUNT_REGISTRY_SPACE ? { rawAbilities: ACCOUNT_SESSION_RAW_ALLOWLIST } : {}
550
+ },
551
+ {
552
+ kind: "space/host",
553
+ service: "tinycloud.space",
554
+ space,
555
+ actions: [SPACE.HOST]
556
+ }
557
+ ])
558
+ );
559
+
560
+ // ../sdk-services/dist/kv/replication/index.js
561
+ var SPACE_ID = /^(tinycloud:pkh:eip155:\d+:)([^:]+)(:.+)$/;
562
+ var PRINCIPAL_DID = /^(did:pkh:eip155:\d+:)([^:]+)$/;
563
+ function canonicalReplicationIdentity(raw) {
564
+ if (!raw || typeof raw.host !== "string" || typeof raw.space !== "string" || typeof raw.principal !== "string") {
565
+ throw new TypeError(
566
+ "Replication identity host, space, and principal must be strings"
567
+ );
568
+ }
569
+ const authority = /^https?:\/\/([^/?#]*)/i.exec(raw.host)?.[1];
570
+ let url;
571
+ try {
572
+ url = new URL(raw.host);
573
+ } catch {
574
+ throw new TypeError(
575
+ "Replication identity host must be an absolute HTTP(S) URL"
576
+ );
577
+ }
578
+ if (!authority || authority.includes("@") || raw.host.includes("?") || raw.host.includes("#") || url.username || url.password) {
579
+ throw new TypeError(
580
+ "Replication identity host must be HTTP(S) without credentials, query, or fragment"
581
+ );
582
+ }
583
+ const space = SPACE_ID.exec(raw.space);
584
+ const principal = PRINCIPAL_DID.exec(raw.principal);
585
+ if (!space || !principal) {
586
+ throw new TypeError(
587
+ "Replication identity requires a full TinyCloud space id and a pkh principal DID"
588
+ );
589
+ }
590
+ return {
591
+ host: `${url.origin}${url.pathname.replace(/\/+$/, "")}`,
592
+ space: `${space[1]}${space[2].toLowerCase()}${space[3]}`,
593
+ principal: `${principal[1]}${principal[2].toLowerCase()}`
594
+ };
595
+ }
596
+ function replicationIdentityKey(id) {
597
+ const canonical = canonicalReplicationIdentity(id);
598
+ return `tc-replication/v1
599
+ ${canonical.host}
600
+ ${canonical.space}
601
+ ${canonical.principal}`;
602
+ }
603
+ var ErrorCodes = {
604
+ // Common errors
605
+ NOT_FOUND: "NOT_FOUND",
606
+ AUTH_EXPIRED: "AUTH_EXPIRED",
607
+ AUTH_REQUIRED: "AUTH_REQUIRED",
608
+ AUTH_UNAUTHORIZED: "AUTH_UNAUTHORIZED",
609
+ /** HTTP 401: the delegation the invocation cites was revoked. */
610
+ AUTH_DELEGATION_REVOKED: "AUTH_DELEGATION_REVOKED",
611
+ /** HTTP 401: a delegation the cited one rests on was revoked. */
612
+ AUTH_DELEGATION_ANCESTOR_REVOKED: "AUTH_DELEGATION_ANCESTOR_REVOKED",
613
+ NETWORK_ERROR: "NETWORK_ERROR",
614
+ TIMEOUT: "TIMEOUT",
615
+ ABORTED: "ABORTED",
616
+ INVALID_INPUT: "INVALID_INPUT",
617
+ PERMISSION_DENIED: "PERMISSION_DENIED",
618
+ // Node retry signals (tinycloud-node #347). `meta.retryable` says whether a
619
+ // retry is safe; `meta.retryAfterMs` carries the node's Retry-After.
620
+ /** HTTP 503 `sql_busy:<stage>` / `unavailable_retryable`: nothing was applied; retry after `meta.retryAfterMs`. */
621
+ NODE_BUSY: "NODE_BUSY",
622
+ /** HTTP 503 `request_timeout`: the node's deadline passed before any write began; nothing was applied. */
623
+ REQUEST_TIMEOUT: "REQUEST_TIMEOUT",
624
+ /** HTTP 500 `outcome_unknown`: a write may have been applied; read back before retrying a non-idempotent operation. */
625
+ OUTCOME_UNKNOWN: "OUTCOME_UNKNOWN",
626
+ // KV-specific errors
627
+ KV_NOT_FOUND: "KV_NOT_FOUND",
628
+ KV_WRITE_FAILED: "KV_WRITE_FAILED",
629
+ KV_PRECONDITION_FAILED: "KV_PRECONDITION_FAILED",
630
+ KV_CONFLICT: "KV_CONFLICT",
631
+ KV_RESPONSE_TOO_LARGE: "KV_RESPONSE_TOO_LARGE",
632
+ /** HTTP 410 from the change feed: discard sync state and restart without a cursor; `meta.reason` says why. */
633
+ KV_SYNC_RESET_REQUIRED: "KV_SYNC_RESET_REQUIRED",
634
+ /** HTTP 403 from the change feed: the retention grant was refused; `meta.reason` says why. */
635
+ KV_RETENTION_GRANT_REFUSED: "KV_RETENTION_GRANT_REFUSED",
636
+ // SQL-specific errors
637
+ SQL_ERROR: "SQL_ERROR",
638
+ SQL_PERMISSION_DENIED: "SQL_PERMISSION_DENIED",
639
+ SQL_DATABASE_NOT_FOUND: "SQL_DATABASE_NOT_FOUND",
640
+ SQL_RESPONSE_TOO_LARGE: "SQL_RESPONSE_TOO_LARGE",
641
+ SQL_QUOTA_EXCEEDED: "SQL_QUOTA_EXCEEDED",
642
+ SQL_INVALID_STATEMENT: "SQL_INVALID_STATEMENT",
643
+ SQL_SCHEMA_ERROR: "SQL_SCHEMA_ERROR",
644
+ SQL_READONLY_VIOLATION: "SQL_READONLY_VIOLATION",
645
+ // Storage quota errors
646
+ STORAGE_QUOTA_EXCEEDED: "STORAGE_QUOTA_EXCEEDED",
647
+ STORAGE_LIMIT_REACHED: "STORAGE_LIMIT_REACHED",
648
+ /** The node answered the usage read without usage numbers (it predates the read). */
649
+ STORAGE_STATUS_UNAVAILABLE: "STORAGE_STATUS_UNAVAILABLE",
650
+ // DuckDB-specific errors
651
+ DUCKDB_ERROR: "DUCKDB_ERROR",
652
+ DUCKDB_PERMISSION_DENIED: "DUCKDB_PERMISSION_DENIED",
653
+ DUCKDB_DATABASE_NOT_FOUND: "DUCKDB_DATABASE_NOT_FOUND",
654
+ DUCKDB_RESPONSE_TOO_LARGE: "DUCKDB_RESPONSE_TOO_LARGE",
655
+ DUCKDB_QUOTA_EXCEEDED: "DUCKDB_QUOTA_EXCEEDED",
656
+ DUCKDB_INVALID_STATEMENT: "DUCKDB_INVALID_STATEMENT",
657
+ DUCKDB_SCHEMA_ERROR: "DUCKDB_SCHEMA_ERROR",
658
+ DUCKDB_READONLY_VIOLATION: "DUCKDB_READONLY_VIOLATION"
659
+ };
660
+ var defaultRetryPolicy = {
661
+ maxAttempts: 3,
662
+ backoff: "exponential",
663
+ baseDelayMs: 1e3,
664
+ maxDelayMs: 1e4,
665
+ retryableErrors: [ErrorCodes.NETWORK_ERROR, ErrorCodes.TIMEOUT]
666
+ };
667
+ var grantableActions = new Set(
668
+ CAPABILITY_REGISTRY.map(({ urn }) => urn).filter((urn) => !urn.endsWith("/*"))
669
+ );
670
+ var capabilityServices = new Set(
671
+ CAPABILITY_REGISTRY.map(({ service }) => service.slice("tinycloud.".length))
672
+ );
673
+ var DEFINITIVE_FAILURES = /* @__PURE__ */ new Set([
674
+ ErrorCodes.AUTH_REQUIRED,
675
+ ErrorCodes.INVALID_INPUT,
676
+ ErrorCodes.AUTH_UNAUTHORIZED,
677
+ ErrorCodes.KV_PRECONDITION_FAILED,
678
+ ErrorCodes.STORAGE_QUOTA_EXCEEDED,
679
+ ErrorCodes.STORAGE_LIMIT_REACHED
680
+ ]);
681
+ var encoder = new TextEncoder();
682
+ var decoder = new TextDecoder("utf-8", { fatal: true });
683
+ var HOUR_MS = 60 * 60 * 1e3;
684
+ var DAY_MS = 24 * HOUR_MS;
685
+ var MAX_GRANT_TTL_MS = 7 * DAY_MS;
686
+ var CONSENT_WINDOW_MS = 7 * DAY_MS;
687
+ var EXPIRED = Number.NEGATIVE_INFINITY;
688
+
689
+ // src/errors.ts
690
+ var ReplicaErrorCode = {
691
+ /** Another process holds the sync lease. */
692
+ BUSY: "REPLICA_BUSY",
693
+ /** No replica with this name exists. */
694
+ NOT_FOUND: "REPLICA_NOT_FOUND",
695
+ /** The replica exists but its stored configuration differs from the request. */
696
+ CONFIG_MISMATCH: "REPLICA_CONFIG_MISMATCH",
697
+ /** The runtime has no supported durable store (e.g. Node < 22.13). */
698
+ RUNTIME_UNSUPPORTED: "RUNTIME_UNSUPPORTED",
699
+ /** A required open() argument is missing or malformed (e.g. `principal`). */
700
+ INVALID_ARGUMENT: "REPLICA_INVALID_ARGUMENT",
701
+ /** The local store failed (I/O, corruption, schema). */
702
+ STORAGE_ERROR: "STORAGE_ERROR",
703
+ /** The local disk is full. */
704
+ STORAGE_FULL: "STORAGE_FULL",
705
+ /** The key is outside the replica's prefix. */
706
+ NOT_COVERED: "NOT_COVERED",
707
+ /** Syncing the secrets space or the vault namespace needs an explicit opt-in. */
708
+ SECRETS_OPT_IN_REQUIRED: "SECRETS_OPT_IN_REQUIRED",
709
+ /** No grant is installed, or none covers the requested scope. */
710
+ GRANT_MISSING: "GRANT_MISSING",
711
+ /** The grant bytes are not a verifiable UCAN, or its signature is wrong. */
712
+ GRANT_INVALID: "GRANT_INVALID",
713
+ /** SIWE/CACAO and other non-UCAN grants cannot be used for a replica. */
714
+ GRANT_FORMAT_UNSUPPORTED: "GRANT_FORMAT_UNSUPPORTED",
715
+ /** The grant was issued to a different device. */
716
+ GRANT_AUDIENCE_MISMATCH: "GRANT_AUDIENCE_MISMATCH",
717
+ /** The grant lacks `tinycloud.kv/sync` or `tinycloud.kv/get` on the prefix. */
718
+ GRANT_NOT_COVERING: "GRANT_NOT_COVERING",
719
+ GRANT_NOT_YET_VALID: "GRANT_NOT_YET_VALID",
720
+ GRANT_EXPIRED: "GRANT_EXPIRED",
721
+ /** The configured node does not advertise whole-space KV sync support. */
722
+ REPLICATION_ROOT_UNSUPPORTED: "REPLICATION_ROOT_UNSUPPORTED",
723
+ GRANT_REVOKED: "GRANT_REVOKED",
724
+ /** The node refused the invocation for a reason other than revocation. */
725
+ GRANT_UNAUTHORIZED: "GRANT_UNAUTHORIZED",
726
+ /** The node refused the retention grant. */
727
+ RETENTION_GRANT_REFUSED: "RETENTION_GRANT_REFUSED",
728
+ /** The node could not be reached. */
729
+ NETWORK_ERROR: "NETWORK_ERROR",
730
+ /** The node answered with an unexpected status. */
731
+ NODE_ERROR: "NODE_ERROR",
732
+ /** The node's answer violates the wire contract. */
733
+ PROTOCOL_ERROR: "PROTOCOL_ERROR",
734
+ /** The node asked for a fresh bootstrap (410). Handled inside sync. */
735
+ RESET_REQUIRED: "RESET_REQUIRED",
736
+ /** The feed now comes from a different node than the one pinned. */
737
+ SOURCE_CHANGED: "SOURCE_CHANGED",
738
+ /** The feed delivered a key outside the replica's prefix. */
739
+ SCOPE_VIOLATION: "SCOPE_VIOLATION",
740
+ /** Fetched bytes do not hash to the ETag the node attested for them. */
741
+ CONTENT_MISMATCH: "CONTENT_MISMATCH",
742
+ /** A stored blob no longer hashes to its name. */
743
+ INTEGRITY_ERROR: "INTEGRITY_ERROR",
744
+ /** The replica handle was closed; in-flight calls settle with this code. */
745
+ CLOSED: "REPLICA_CLOSED",
746
+ /** The lifecycle gate refused the operation (shared contract `REPLICA_GATED`); `detail.gate` says why. */
747
+ GATED: "REPLICA_GATED",
748
+ /** Quota admission refused a page (`maxBytes` or the free-space floor); nothing committed. */
749
+ STORAGE_BUDGET: "STORAGE_BUDGET",
750
+ /** The consent deadline passed: the grant is not served until the user consents again. */
751
+ CONSENT_EXPIRED: "CONSENT_EXPIRED"
752
+ };
753
+ var REPLICA_ERROR = /* @__PURE__ */ Symbol.for("tinycloud.replica.error");
754
+ var ReplicaError = class extends Error {
755
+ [REPLICA_ERROR] = true;
756
+ code;
757
+ detail;
758
+ constructor(code, message, detail, options) {
759
+ super(message, options);
760
+ this.name = "ReplicaError";
761
+ this.code = code;
762
+ if (detail !== void 0) this.detail = detail;
763
+ }
764
+ };
765
+ function isReplicaError(error, code) {
766
+ return typeof error === "object" && error !== null && error[REPLICA_ERROR] === true && (code === void 0 || error.code === code);
767
+ }
768
+ function gatedError(gate, message) {
769
+ return Object.assign(new ReplicaError(ReplicaErrorCode.GATED, message, { gate }), { gate });
770
+ }
771
+
772
+ // ../sdk-services/dist/kv/replication/prefix.js
773
+ function assertFolderPrefix(prefix) {
774
+ const validFolder = typeof prefix === "string" && prefix.length > 0 && !prefix.startsWith("/") && !prefix.includes("//") && prefix.endsWith("/");
775
+ if (prefix !== "/" && !validFolder) {
776
+ const folderHint = typeof prefix === "string" ? prefix.replace(/^\/+/, "").replace(/\/+$/, "") : "";
777
+ const suggestion = folderHint.length > 0 && !folderHint.includes("//") ? ` Use ${JSON.stringify(`${folderHint}/`)}.` : ' Use "/" or a slash-terminated folder path such as "notes/".';
778
+ throw new TypeError(`Replication prefix must be "/" or a non-empty slash-terminated folder path without empty segments.${suggestion}`);
779
+ }
780
+ }
781
+
782
+ // src/scope.ts
783
+ function assertFolderPrefix2(prefix) {
784
+ try {
785
+ assertFolderPrefix(prefix);
786
+ } catch {
787
+ const folderHint = typeof prefix === "string" ? prefix.replace(/^\/+/, "").replace(/\/+$/, "") : "";
788
+ const suggestion = prefix === "" ? 'use "/" for the whole space' : folderHint.length > 0 && !folderHint.includes("//") ? `use ${JSON.stringify(`${folderHint}/`)}` : 'use "/" for the whole space or a folder path such as "notes/"';
789
+ throw new ReplicaError(
790
+ ReplicaErrorCode.INVALID_ARGUMENT,
791
+ `Replication prefix ${JSON.stringify(prefix)} must be "/" or a non-empty folder path ending in / without empty segments; ${suggestion}. Run \`tc replica reset --replica <name> --purge\` and recreate it with the folder prefix.`
792
+ );
793
+ }
794
+ }
795
+
796
+ // src/browser/idb.ts
797
+ function requestAsPromise(request) {
798
+ const { promise, resolve, reject } = Promise.withResolvers();
799
+ request.onsuccess = () => resolve(request.result);
800
+ request.onerror = () => reject(request.error ?? new DOMException("IndexedDB request failed", "UnknownError"));
801
+ return promise;
802
+ }
803
+ function transactionDone(tx) {
804
+ const { promise, resolve, reject } = Promise.withResolvers();
805
+ tx.addEventListener("complete", () => resolve(), { once: true });
806
+ tx.addEventListener("abort", () => reject(tx.error ?? new DOMException("Transaction aborted", "AbortError")), { once: true });
807
+ tx.addEventListener("error", () => reject(tx.error ?? new DOMException("Transaction failed", "UnknownError")), { once: true });
808
+ return promise;
809
+ }
810
+ var byteEncoder = new TextEncoder();
811
+ function hasName(error) {
812
+ return typeof error === "object" && error !== null && "name" in error && typeof error.name === "string";
813
+ }
814
+ function idbError(error, action) {
815
+ if (error instanceof ReplicaError) return error;
816
+ const message = error instanceof Error ? error.message : String(error);
817
+ if (hasName(error) && error.name === "QuotaExceededError") {
818
+ return new ReplicaError(ReplicaErrorCode.STORAGE_FULL, `${action}: the browser's storage quota is exceeded.`, void 0, {
819
+ cause: error
820
+ });
821
+ }
822
+ return new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, `${action}: ${message}`, void 0, { cause: error });
823
+ }
824
+
825
+ // src/kv-storage/gateKeys.ts
826
+ var GATE_HMAC_KEY = "gateHmac";
827
+ var ACCOUNT_KEY = "accountKey";
828
+ var HEX = "0123456789abcdef";
829
+ var ENCODER = new TextEncoder();
830
+ function hex(bytes) {
831
+ let value = "";
832
+ for (const byte of bytes) value += HEX.charAt(byte >>> 4) + HEX.charAt(byte & 15);
833
+ return value;
834
+ }
835
+ function keyId() {
836
+ return hex(crypto.getRandomValues(new Uint8Array(16)));
837
+ }
838
+ async function newGateHmac(incarnation) {
839
+ const hmac = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256", length: 256 }, false, ["sign", "verify"]);
840
+ return { v: 1, incarnation, keyId: keyId(), hmac };
841
+ }
842
+ async function newAccountKey(incarnation, lifecycleEpoch, principalHash) {
843
+ const [aesGcm, hmac] = await Promise.all([
844
+ crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]),
845
+ crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256", length: 256 }, false, ["sign", "verify"])
846
+ ]);
847
+ return { v: 1, incarnation, lifecycleEpoch, principalHash, keyId: keyId(), aesGcm, hmac };
848
+ }
849
+ async function gateHash(key, domain, material) {
850
+ const bytes = ENCODER.encode(`${domain}\0${material}`);
851
+ return hex(new Uint8Array(await crypto.subtle.sign("HMAC", key, bytes)));
852
+ }
853
+ function isHmac(key) {
854
+ if (!(key instanceof CryptoKey) || key.extractable || key.algorithm.name !== "HMAC") return false;
855
+ const algorithm = key.algorithm;
856
+ return "hash" in algorithm && algorithm.hash !== null && typeof algorithm.hash === "object" && "name" in algorithm.hash && algorithm.hash.name === "SHA-256" && key.usages.length === 2 && key.usages.includes("sign") && key.usages.includes("verify");
857
+ }
858
+ function isAesGcm(key) {
859
+ if (!(key instanceof CryptoKey) || key.extractable || key.algorithm.name !== "AES-GCM") return false;
860
+ return "length" in key.algorithm && key.algorithm.length === 256 && key.usages.length === 2 && key.usages.includes("encrypt") && key.usages.includes("decrypt");
861
+ }
862
+ function validGateHmac(record, gate) {
863
+ return record?.v === 1 && record.incarnation === gate.incarnation && record.keyId === gate.keyId && /^[0-9a-f]{32}$/.test(record.keyId) && isHmac(record.hmac);
864
+ }
865
+ function validAccountKey(record, gate) {
866
+ return record?.v === 1 && record.incarnation === gate.incarnation && record.lifecycleEpoch === gate.lifecycleEpoch && record.principalHash === gate.principalHash && record.keyId === gate.accountKeyId && /^[0-9a-f]{32}$/.test(record.keyId) && isAesGcm(record.aesGcm) && isHmac(record.hmac);
867
+ }
868
+
869
+ // ../sdk-services/dist/kv/replication/contractConstants.js
870
+ var HOUR_MS2 = 60 * 60 * 1e3;
871
+ var DAY_MS2 = 24 * HOUR_MS2;
872
+ var MAX_GRANT_TTL_MS2 = 7 * DAY_MS2;
873
+ var CONSENT_WINDOW_MS2 = 7 * DAY_MS2;
874
+ var DEFAULT_MIN_FREE_BYTES = 64 * 1024 * 1024;
875
+ var REPLICA_KV_DATABASE = "tinycloud-replica-kv";
876
+ var REPLICA_KV_STORES = {
877
+ /** Data: partition meta (opaque fences, sealed config/grants), private gate-HMAC partition id. */
878
+ meta: "meta",
879
+ /** Data: sealed KV entries, key `[privatePartitionId, accountHmac(path)]`. */
880
+ entries: "entries",
881
+ /** Data: AES-GCM content blobs, key `[privatePartitionId, accountHmac(hash)]`. */
882
+ blobs: "blobs",
883
+ /** Data: AES-sealed pending state, key private gate-HMAC `identityKeyHash`. */
884
+ pending: "pending",
885
+ /** Data: sealed owner device, private nonextractable key records and opaque install anchors. */
886
+ device: "device",
887
+ /** Control: the account gate, singleton key `"account"`, value `ReplicaAccountGate`. */
888
+ gate: "gate",
889
+ /** Control: partition index keyed by private `partitionId`; nonunique `byLookupId` index. */
890
+ partitions: "partitions",
891
+ /** Control: purge intents, key `ReplicaPurgeIntent.id`, value `ReplicaPurgeIntent`. */
892
+ purgeIntents: "purgeIntents",
893
+ /** Control: quota ledger, keys owned by the ledger (S5). */
894
+ quota: "quota"
895
+ };
896
+ var REPLICA_LOGOUT_MARKER = "tinycloud-replica:logout-pending";
897
+ var REPLICA_DENY_TOMBSTONE = "tinycloud-replica:deny-tombstone";
898
+ var REPLICA_PENDING_LOCK_PREFIX = "tinycloud-replica-kv:pending:";
899
+
900
+ // src/did.ts
901
+ function principalOf(did) {
902
+ return did.split("#", 1)[0];
903
+ }
904
+
905
+ // src/kv-storage/sealed.ts
906
+ import { bytesToHex } from "@noble/hashes/utils";
907
+ var encoder2 = new TextEncoder();
908
+ var NONCE_LENGTH = 12;
909
+ var TAG_LENGTH = 16;
910
+ function aad(context) {
911
+ return encoder2.encode(`tinycloud-replica-kv:v1\0${context}`);
912
+ }
913
+ async function seal(key, plaintext, context) {
914
+ const iv = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
915
+ const bytes = new Uint8Array(await crypto.subtle.encrypt(
916
+ { name: "AES-GCM", iv, additionalData: aad(context) },
917
+ key,
918
+ plaintext
919
+ ));
920
+ return { v: 1, iv, bytes };
921
+ }
922
+ async function openSealed(key, record, context) {
923
+ if (record === null || typeof record !== "object" || !("v" in record) || record.v !== 1 || !("iv" in record) || !(record.iv instanceof Uint8Array) || record.iv.length !== NONCE_LENGTH || !("bytes" in record) || !(record.bytes instanceof Uint8Array) || record.bytes.length < TAG_LENGTH) {
924
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica record is unsealed or has an unknown ciphertext version.");
925
+ }
926
+ try {
927
+ return new Uint8Array(await crypto.subtle.decrypt(
928
+ { name: "AES-GCM", iv: record.iv, additionalData: aad(context) },
929
+ key,
930
+ record.bytes
931
+ ));
932
+ } catch (cause) {
933
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica record failed authentication.", void 0, { cause });
934
+ }
935
+ }
936
+ function utf8Length(value) {
937
+ let bytes = 0;
938
+ for (let i = 0; i < value.length; i += 1) {
939
+ const code = value.charCodeAt(i);
940
+ if (code < 128) bytes += 1;
941
+ else if (code < 2048) bytes += 2;
942
+ else if (code >= 55296 && code <= 56319 && i + 1 < value.length && value.charCodeAt(i + 1) >= 56320 && value.charCodeAt(i + 1) <= 57343) {
943
+ bytes += 4;
944
+ i += 1;
945
+ } else bytes += 3;
946
+ }
947
+ return bytes;
948
+ }
949
+ function measureSealedRow(key, row) {
950
+ if (row.purged === true ? row.v !== void 0 || row.iv !== void 0 || row.bytes !== void 0 : row.v !== 1 || !(row.iv instanceof Uint8Array) || !(row.bytes instanceof Uint8Array)) {
951
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has an invalid sealed envelope.");
952
+ }
953
+ const primaryKey = JSON.stringify(key);
954
+ if (typeof primaryKey !== "string") {
955
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has an invalid primary key.");
956
+ }
957
+ let total = 2 + 4 + utf8Length(primaryKey);
958
+ let hasSize = false;
959
+ for (const name in row) {
960
+ if (!Object.hasOwn(row, name)) continue;
961
+ const value = Reflect.get(row, name);
962
+ total += 1 + name.length;
963
+ switch (name) {
964
+ case "pid":
965
+ case "hashId":
966
+ if (typeof value !== "string") throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has an invalid identifier.");
967
+ total += 4 + utf8Length(value);
968
+ break;
969
+ case "v":
970
+ if (value !== 1) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has an unknown version.");
971
+ total += 1;
972
+ break;
973
+ case "coverageCode":
974
+ if (value !== 0 && value !== 1 && value !== 2) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has invalid coverage.");
975
+ total += 1;
976
+ break;
977
+ case "purged":
978
+ if (typeof value !== "boolean") throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has invalid purge state.");
979
+ total += 1;
980
+ break;
981
+ case "revision":
982
+ case "generation":
983
+ case "syncedThroughEpoch":
984
+ case "keys":
985
+ case "contentMissing":
986
+ case "tombstones":
987
+ case "contentBytes":
988
+ case "size":
989
+ if (typeof value !== "number" || !Number.isFinite(value)) {
990
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has an invalid numeric field.");
991
+ }
992
+ if (name === "size") hasSize = true;
993
+ total += 8;
994
+ break;
995
+ case "iv":
996
+ case "bytes":
997
+ if (!(value instanceof Uint8Array)) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has invalid ciphertext.");
998
+ total += 4 + value.byteLength;
999
+ break;
1000
+ default:
1001
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has an unknown persisted field.");
1002
+ }
1003
+ }
1004
+ if (!hasSize) total += 1 + "size".length + 8;
1005
+ return total;
1006
+ }
1007
+
1008
+ // src/kv-storage/schema.ts
1009
+ var KV_DATABASE = REPLICA_KV_DATABASE;
1010
+ var KV_DATABASE_VERSION = 2;
1011
+ var KV_STORE = REPLICA_KV_STORES;
1012
+ var FIRST_INSTALL_KEY_PREFIX = "firstInstall:";
1013
+ var ERASE_STORES = [KV_STORE.meta, KV_STORE.entries, KV_STORE.blobs, KV_STORE.pending];
1014
+ var READ_SCOPE = [KV_STORE.gate, KV_STORE.purgeIntents];
1015
+ var WRITE_SCOPE = [KV_STORE.gate, KV_STORE.partitions, KV_STORE.purgeIntents, KV_STORE.quota, KV_STORE.device];
1016
+ function createStores(db) {
1017
+ db.createObjectStore(KV_STORE.meta);
1018
+ const entries = db.createObjectStore(KV_STORE.entries);
1019
+ entries.createIndex("byHash", ["pid", "hash"], { unique: false });
1020
+ const blobs = db.createObjectStore(KV_STORE.blobs);
1021
+ blobs.createIndex("bySize", ["pid", "size"], { unique: false });
1022
+ const partitions = db.createObjectStore(KV_STORE.partitions);
1023
+ partitions.createIndex("byLookupId", "lookupId", { unique: false });
1024
+ for (const name of [KV_STORE.pending, KV_STORE.device, KV_STORE.gate, KV_STORE.purgeIntents, KV_STORE.quota]) {
1025
+ db.createObjectStore(name);
1026
+ }
1027
+ }
1028
+ function openKvDatabase(options) {
1029
+ if (typeof indexedDB === "undefined") {
1030
+ return Promise.reject(
1031
+ new ReplicaError(
1032
+ ReplicaErrorCode.RUNTIME_UNSUPPORTED,
1033
+ "IndexedDB is unavailable (private browsing or a non-browser runtime); the replica cannot persist."
1034
+ )
1035
+ );
1036
+ }
1037
+ const { promise, resolve, reject } = Promise.withResolvers();
1038
+ const request = options.create ? indexedDB.open(KV_DATABASE, KV_DATABASE_VERSION) : indexedDB.open(KV_DATABASE);
1039
+ let refused = false;
1040
+ request.onupgradeneeded = (event) => {
1041
+ if (!options.create && event.oldVersion === 0) {
1042
+ refused = true;
1043
+ request.transaction?.abort();
1044
+ return;
1045
+ }
1046
+ if (event.oldVersion > 0) {
1047
+ for (const name of Array.from(request.result.objectStoreNames)) request.result.deleteObjectStore(name);
1048
+ }
1049
+ createStores(request.result);
1050
+ };
1051
+ request.onsuccess = () => {
1052
+ const db = request.result;
1053
+ if (db.version !== KV_DATABASE_VERSION) {
1054
+ db.close();
1055
+ resolve(null);
1056
+ return;
1057
+ }
1058
+ db.onversionchange = () => db.close();
1059
+ resolve(db);
1060
+ };
1061
+ request.onerror = (event) => {
1062
+ if (refused) {
1063
+ event.preventDefault();
1064
+ resolve(null);
1065
+ return;
1066
+ }
1067
+ reject(request.error ?? new DOMException(`Opening ${KV_DATABASE} failed`, "UnknownError"));
1068
+ };
1069
+ request.onblocked = () => reject(new ReplicaError(ReplicaErrorCode.BUSY, `Opening ${KV_DATABASE} is blocked by an older connection in another tab.`));
1070
+ return promise;
1071
+ }
1072
+ function partitionIdOf(input, gateHmac) {
1073
+ return gateHash(gateHmac, "partition", `${replicationIdentityKey(input.identity)}
1074
+ ${input.prefix}
1075
+ ${principalOf(input.deviceDid)}`);
1076
+ }
1077
+ function eraseSealedRows(tx, name, partitionId) {
1078
+ const { promise, resolve, reject } = Promise.withResolvers();
1079
+ const range = IDBKeyRange.bound([partitionId], [partitionId, []]);
1080
+ const request = tx.objectStore(name).openCursor(range);
1081
+ let removed = 0;
1082
+ request.onsuccess = () => {
1083
+ const cursor = request.result;
1084
+ if (cursor === null) return resolve(removed);
1085
+ try {
1086
+ const row = cursor.value;
1087
+ if (row === null || typeof row !== "object" || !("pid" in row) || row.pid !== partitionId || !("size" in row) || typeof row.size !== "number") {
1088
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The encrypted replica row has an invalid partition or byte measure.");
1089
+ }
1090
+ const measured2 = measureSealedRow(cursor.primaryKey, row);
1091
+ if (row.size !== measured2 || !Number.isSafeInteger(removed + measured2)) {
1092
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The encrypted replica row has an invalid byte measure.");
1093
+ }
1094
+ removed += measured2;
1095
+ cursor.delete();
1096
+ cursor.continue();
1097
+ } catch (error) {
1098
+ reject(error);
1099
+ }
1100
+ };
1101
+ request.onerror = () => reject(request.error);
1102
+ return promise;
1103
+ }
1104
+ var kvPartitionEraser = {
1105
+ async erasePartition(tx, partitionId) {
1106
+ const meta = tx.objectStore(KV_STORE.meta);
1107
+ const old = await requestAsPromise(meta.get(partitionId));
1108
+ if (old !== void 0 && (old === null || typeof old !== "object" || !("pid" in old) || old.pid !== partitionId || !("revision" in old) || typeof old.revision !== "number" || !Number.isSafeInteger(old.revision) || old.revision < 0 || !("generation" in old) || typeof old.generation !== "number" || !Number.isSafeInteger(old.generation) || old.generation < 0 || !("size" in old) || typeof old.size !== "number" || old.size !== measureSealedRow(partitionId, old))) {
1109
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The encrypted replica metadata is invalid.");
1110
+ }
1111
+ const [entries, blobs] = await Promise.all([
1112
+ eraseSealedRows(tx, KV_STORE.entries, partitionId),
1113
+ eraseSealedRows(tx, KV_STORE.blobs, partitionId)
1114
+ ]);
1115
+ if (old === void 0) return { bytes: entries + blobs };
1116
+ const current = old;
1117
+ if (current.purged === true) {
1118
+ if (entries !== 0 || blobs !== 0) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "An erased replica still owns ciphertext rows.");
1119
+ return { bytes: 0 };
1120
+ }
1121
+ const tombstone = {
1122
+ pid: partitionId,
1123
+ revision: current.revision + 1,
1124
+ generation: current.generation + 1,
1125
+ coverageCode: 0,
1126
+ syncedThroughEpoch: 0,
1127
+ keys: 0,
1128
+ contentMissing: 0,
1129
+ tombstones: 0,
1130
+ contentBytes: 0,
1131
+ purged: true,
1132
+ size: 0
1133
+ };
1134
+ tombstone.size = measureSealedRow(partitionId, tombstone);
1135
+ const bytes = current.size + entries + blobs - tombstone.size;
1136
+ if (!Number.isSafeInteger(bytes) || bytes < 0) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The erased replica has an invalid byte delta.");
1137
+ meta.put(tombstone, partitionId);
1138
+ return { bytes };
1139
+ },
1140
+ async eraseIdentityPending(tx, identityKeyHash) {
1141
+ tx.objectStore(KV_STORE.pending).delete(identityKeyHash);
1142
+ }
1143
+ };
1144
+
1145
+ // src/kv-storage/ledger.ts
1146
+ var TOTAL = "total";
1147
+ var DEVICE_QUOTA_PARTITION_ID = "device";
1148
+ function partitionKey(partitionId) {
1149
+ return `partition:${partitionId}`;
1150
+ }
1151
+ function measured(row) {
1152
+ const bytes = row?.bytes ?? 0;
1153
+ if (!Number.isSafeInteger(bytes) || bytes < 0) {
1154
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica quota ledger has invalid byte accounting.");
1155
+ }
1156
+ return bytes;
1157
+ }
1158
+ function checkedDelta(delta) {
1159
+ if (!Number.isSafeInteger(delta)) {
1160
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica quota delta is not a safe integer.");
1161
+ }
1162
+ return delta;
1163
+ }
1164
+ function ledgerFor(quota) {
1165
+ const maxBytes = quota?.maxBytes;
1166
+ const minFreeBytes = quota?.minFreeBytes ?? DEFAULT_MIN_FREE_BYTES;
1167
+ async function admit(tx, delta, freeBytes) {
1168
+ if (delta <= 0) return;
1169
+ const total = measured(await requestAsPromise(tx.objectStore(KV_STORE.quota).get(TOTAL)));
1170
+ if (maxBytes !== void 0 && total + delta > maxBytes) {
1171
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_BUDGET, "The replica page exceeds the origin's byte budget.");
1172
+ }
1173
+ if (freeBytes !== null && freeBytes - delta < minFreeBytes) {
1174
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_BUDGET, "The replica page would cross the browser's free-space floor.");
1175
+ }
1176
+ }
1177
+ const ledger = {
1178
+ async freeBytes() {
1179
+ if (typeof navigator === "undefined" || navigator.storage?.estimate === void 0) return null;
1180
+ const { quota: capacity, usage } = await navigator.storage.estimate();
1181
+ return typeof capacity === "number" && typeof usage === "number" && Number.isFinite(capacity) && Number.isFinite(usage) ? Math.max(0, capacity - usage) : null;
1182
+ },
1183
+ async admitPage(tx, page) {
1184
+ await admit(tx, checkedDelta(page.deltaBytes), page.freeBytes);
1185
+ },
1186
+ async admitRows(tx, { partitionDeltaBytes, deviceDeltaBytes, freeBytes }) {
1187
+ const delta = checkedDelta(checkedDelta(partitionDeltaBytes) + checkedDelta(deviceDeltaBytes));
1188
+ await admit(tx, delta, freeBytes);
1189
+ },
1190
+ async accountCommit(tx, { partitionId, deltaBytes }) {
1191
+ await ledger.accountRows(tx, { partitionId, partitionDeltaBytes: deltaBytes, deviceDeltaBytes: 0 });
1192
+ },
1193
+ async accountRows(tx, { partitionId, partitionDeltaBytes, deviceDeltaBytes }) {
1194
+ const partitionDelta = checkedDelta(partitionDeltaBytes);
1195
+ const deviceDelta = checkedDelta(deviceDeltaBytes);
1196
+ if (partitionDelta === 0 && deviceDelta === 0) return;
1197
+ if (partitionDelta !== 0 && partitionId === DEVICE_QUOTA_PARTITION_ID) {
1198
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The device quota bucket cannot contain partition rows.");
1199
+ }
1200
+ const combined = checkedDelta(partitionDelta + deviceDelta);
1201
+ const store = tx.objectStore(KV_STORE.quota);
1202
+ const totalRequest = combined === 0 ? void 0 : requestAsPromise(store.get(TOTAL));
1203
+ const partitionRequest = partitionDelta === 0 ? void 0 : requestAsPromise(store.get(partitionKey(partitionId)));
1204
+ const deviceRequest = deviceDelta === 0 ? void 0 : requestAsPromise(store.get(partitionKey(DEVICE_QUOTA_PARTITION_ID)));
1205
+ const total = totalRequest === void 0 ? 0 : measured(await totalRequest) + combined;
1206
+ const partition = partitionRequest === void 0 ? 0 : measured(await partitionRequest) + partitionDelta;
1207
+ const device = deviceRequest === void 0 ? 0 : measured(await deviceRequest) + deviceDelta;
1208
+ if (totalRequest !== void 0 && (!Number.isSafeInteger(total) || total < 0) || partitionRequest !== void 0 && (!Number.isSafeInteger(partition) || partition < 0) || deviceRequest !== void 0 && (!Number.isSafeInteger(device) || device < 0)) {
1209
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica quota ledger would become negative or imprecise.");
1210
+ }
1211
+ if (totalRequest !== void 0) store.put({ bytes: total }, TOTAL);
1212
+ if (partitionRequest !== void 0) {
1213
+ if (partition === 0) store.delete(partitionKey(partitionId));
1214
+ else store.put({ bytes: partition }, partitionKey(partitionId));
1215
+ }
1216
+ if (deviceRequest !== void 0) {
1217
+ if (device === 0) store.delete(partitionKey(DEVICE_QUOTA_PARTITION_ID));
1218
+ else store.put({ bytes: device }, partitionKey(DEVICE_QUOTA_PARTITION_ID));
1219
+ }
1220
+ },
1221
+ async accountErase(tx, { partitionId, bytes }) {
1222
+ const removed = checkedDelta(bytes);
1223
+ if (removed < 0) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica erase has negative bytes.");
1224
+ const store = tx.objectStore(KV_STORE.quota);
1225
+ const partition = await requestAsPromise(store.get(partitionKey(partitionId)));
1226
+ if (partition === void 0) return;
1227
+ const remaining = measured(partition) - removed;
1228
+ if (remaining < 0) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica erase exceeds its accounted bytes.");
1229
+ const total = measured(await requestAsPromise(store.get(TOTAL))) - removed;
1230
+ if (total < 0) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica erase exceeds origin usage.");
1231
+ store.put({ bytes: total }, TOTAL);
1232
+ if (remaining === 0) store.delete(partitionKey(partitionId));
1233
+ else store.put({ bytes: remaining }, partitionKey(partitionId));
1234
+ }
1235
+ };
1236
+ return ledger;
1237
+ }
1238
+
1239
+ // src/kv-storage/gateTx.ts
1240
+ var ACCOUNT = "account";
1241
+ var CONTROL = [KV_STORE.gate, KV_STORE.partitions, KV_STORE.purgeIntents, KV_STORE.quota, KV_STORE.device];
1242
+ function refusal(reason) {
1243
+ return gatedError(reason, `The replica gate refused: ${reason}.`);
1244
+ }
1245
+ async function withDatabase(open, create, use) {
1246
+ const db = await open(create);
1247
+ if (db === null) return null;
1248
+ try {
1249
+ return await use(db);
1250
+ } finally {
1251
+ db.close();
1252
+ }
1253
+ }
1254
+ function createIndexedDbGateTx(open, options) {
1255
+ if (typeof options?.onConsentExpired !== "function") throw refusal("gate_unavailable");
1256
+ const now = options.now ?? Date.now;
1257
+ function checkedNow() {
1258
+ const at = now();
1259
+ if (!Number.isSafeInteger(at) || at < 0) throw refusal("gate_unavailable");
1260
+ return at;
1261
+ }
1262
+ let expiryFlight;
1263
+ async function snapshot() {
1264
+ return withDatabase(open, false, async (db) => {
1265
+ const tx = db.transaction([KV_STORE.gate, KV_STORE.device]);
1266
+ const gate = await requestAsPromise(tx.objectStore(KV_STORE.gate).get(ACCOUNT));
1267
+ if (gate === void 0 || gate.principalHash === null) return null;
1268
+ if (typeof gate.consentDeadline !== "number" || !Number.isSafeInteger(gate.consentDeadline) || gate.consentDeadline < 0) throw refusal("gate_unavailable");
1269
+ const hash = await requestAsPromise(tx.objectStore(KV_STORE.device).get(GATE_HMAC_KEY));
1270
+ if (!validGateHmac(hash, gate)) throw refusal("gate_unavailable");
1271
+ return { incarnation: gate.incarnation, salt: gate.salt, lifecycleEpoch: gate.lifecycleEpoch, keyId: hash.keyId, accountKeyId: gate.accountKeyId, gateHmac: hash.hmac };
1272
+ });
1273
+ }
1274
+ async function expire(access) {
1275
+ let flight;
1276
+ try {
1277
+ flight = expiryFlight ?? options.onConsentExpired();
1278
+ expiryFlight = flight;
1279
+ const outcome = await flight;
1280
+ if (outcome.status !== "denied" && outcome.status !== "deleted" && outcome.status !== "stale") throw refusal("gate_unavailable");
1281
+ const gate = await withDatabase(
1282
+ open,
1283
+ false,
1284
+ (db) => requestAsPromise(db.transaction(KV_STORE.gate).objectStore(KV_STORE.gate).get(ACCOUNT))
1285
+ );
1286
+ if (gate !== null && (gate === void 0 || gate.incarnation === access.incarnation && (gate.lifecycleEpoch <= access.stamp.lifecycleEpoch || gate.principalHash !== null))) {
1287
+ throw refusal("gate_unavailable");
1288
+ }
1289
+ } catch {
1290
+ throw refusal("gate_unavailable");
1291
+ } finally {
1292
+ if (flight !== void 0 && expiryFlight === flight) expiryFlight = void 0;
1293
+ }
1294
+ }
1295
+ async function runChecked(scope, access, fn) {
1296
+ if (access === null || typeof access !== "object" || access.stamp === null || typeof access.stamp !== "object") {
1297
+ throw refusal("not_established");
1298
+ }
1299
+ if (!("principalHash" in access) || typeof access.principalHash !== "string" || !("keyId" in access) || typeof access.keyId !== "string" || !("accountKeyId" in access) || typeof access.accountKeyId !== "string") throw refusal("gate_unavailable");
1300
+ try {
1301
+ const result = await withDatabase(open, false, async (db) => {
1302
+ const tx = db.transaction([...scope.stores, ...CONTROL], scope.mode, { durability: "strict" });
1303
+ const done = transactionDone(tx);
1304
+ try {
1305
+ const gate = await requestAsPromise(tx.objectStore(KV_STORE.gate).get(ACCOUNT));
1306
+ if (gate === void 0) throw refusal("not_established");
1307
+ if (gate.incarnation !== access.incarnation || gate.incarnation !== access.stamp.incarnation || gate.lifecycleEpoch !== access.stamp.lifecycleEpoch || gate.principalHash === null) throw refusal("stamp_stale");
1308
+ if (gate.keyId !== access.keyId || gate.accountKeyId !== access.accountKeyId) throw refusal("stamp_stale");
1309
+ if (gate.principalHash !== access.principalHash) throw refusal("principal_mismatch");
1310
+ if (typeof gate.consentDeadline !== "number" || !Number.isSafeInteger(gate.consentDeadline) || gate.consentDeadline < 0) throw refusal("gate_unavailable");
1311
+ const device = tx.objectStore(KV_STORE.device);
1312
+ const hash = await requestAsPromise(device.get(GATE_HMAC_KEY));
1313
+ const account = await requestAsPromise(device.get(ACCOUNT_KEY));
1314
+ if (!validGateHmac(hash, gate) || !validAccountKey(account, gate)) throw refusal("gate_unavailable");
1315
+ const intents = tx.objectStore(KV_STORE.purgeIntents);
1316
+ if (access.partitionId !== void 0 && await requestAsPromise(intents.count(access.partitionId)) > 0) throw refusal("purge_pending");
1317
+ if (access.identityKeyHash !== void 0 && await requestAsPromise(intents.count(`pending:${access.identityKeyHash}`)) > 0) throw refusal("purge_pending");
1318
+ if (checkedNow() >= gate.consentDeadline) throw new ReplicaError(ReplicaErrorCode.CONSENT_EXPIRED, "The account consent has expired.");
1319
+ const value = await fn(tx, account);
1320
+ await done;
1321
+ return { value };
1322
+ } catch (error) {
1323
+ try {
1324
+ tx.abort();
1325
+ } catch {
1326
+ }
1327
+ await done.catch(() => void 0);
1328
+ throw error;
1329
+ }
1330
+ });
1331
+ if (result === null) throw refusal("gate_unavailable");
1332
+ return result.value;
1333
+ } catch (error) {
1334
+ if (error !== null && typeof error === "object" && "code" in error && error.code === ReplicaErrorCode.CONSENT_EXPIRED) {
1335
+ await expire(access);
1336
+ }
1337
+ throw error;
1338
+ }
1339
+ }
1340
+ return {
1341
+ snapshot,
1342
+ run: (scope, access, fn) => runChecked(scope, access, (tx) => fn(tx)),
1343
+ readAccountKey: (access) => runChecked({ stores: [KV_STORE.device], mode: "readonly" }, access, async (_tx, account) => account),
1344
+ async indexPartition(tx, entry) {
1345
+ tx.objectStore(KV_STORE.partitions).put(entry, entry.partitionId);
1346
+ },
1347
+ async unindexPartition(tx, partitionId) {
1348
+ tx.objectStore(KV_STORE.partitions).delete(partitionId);
1349
+ },
1350
+ async clearIntent(tx, id) {
1351
+ tx.objectStore(KV_STORE.purgeIntents).delete(id);
1352
+ }
1353
+ };
1354
+ }
1355
+
1356
+ // src/kv-storage/lifecycle.ts
1357
+ var ACCOUNT2 = "account";
1358
+ var DEVICE_KEY = "device";
1359
+ var GRANT_INSTALL_KEY_PREFIX = "grantInstall:";
1360
+ var GATE_LOCK = "tinycloud-replica-kv:gate";
1361
+ var DELETE_BLOCKED = "DELETE_BLOCKED";
1362
+ var denyFaults = {};
1363
+ function takeFault(point) {
1364
+ const fault = denyFaults[point];
1365
+ if (fault === void 0) return false;
1366
+ if (fault.once) delete denyFaults[point];
1367
+ return true;
1368
+ }
1369
+ function billedDeviceBytes(row) {
1370
+ if (row === void 0 || typeof row === "number" && Number.isSafeInteger(row) && row >= 0) return 0;
1371
+ if (row !== null && typeof row === "object" && "size" in row && typeof row.size === "number" && Number.isSafeInteger(row.size) && row.size >= 0) {
1372
+ return row.size;
1373
+ }
1374
+ throw gatedError("gate_unavailable", "A device quota row has no valid encoded size.");
1375
+ }
1376
+ function openKvStore(create) {
1377
+ return openKvDatabase({ create });
1378
+ }
1379
+ async function readLegacyGateFloor() {
1380
+ const { promise, resolve, reject } = Promise.withResolvers();
1381
+ const request = indexedDB.open(KV_DATABASE);
1382
+ let absent = false;
1383
+ request.onupgradeneeded = () => {
1384
+ absent = true;
1385
+ request.transaction?.abort();
1386
+ };
1387
+ request.onsuccess = async () => {
1388
+ const db = request.result;
1389
+ try {
1390
+ if (db.version !== 1) return resolve(null);
1391
+ if (!db.objectStoreNames.contains(KV_STORE.gate)) return resolve({ epoch: 0, deniedAt: null });
1392
+ const value = await requestAsPromise(db.transaction(KV_STORE.gate).objectStore(KV_STORE.gate).get(ACCOUNT2));
1393
+ if (value === void 0) return resolve({ epoch: 0, deniedAt: null });
1394
+ if (value === null || typeof value !== "object" || !("lifecycleEpoch" in value) || !("deniedAt" in value) || typeof value.lifecycleEpoch !== "number" || !Number.isSafeInteger(value.lifecycleEpoch) || value.lifecycleEpoch < 0 || value.deniedAt !== null && (typeof value.deniedAt !== "number" || !Number.isSafeInteger(value.deniedAt) || value.deniedAt < 0) || value.lifecycleEpoch === 0 !== (value.deniedAt === null)) {
1395
+ throw gatedError("gate_unavailable", "The legacy gate deny floor is invalid.");
1396
+ }
1397
+ resolve({ epoch: value.lifecycleEpoch, deniedAt: value.deniedAt });
1398
+ } catch (error) {
1399
+ reject(error);
1400
+ } finally {
1401
+ db.close();
1402
+ }
1403
+ };
1404
+ request.onerror = (event) => {
1405
+ if (absent) {
1406
+ event.preventDefault();
1407
+ resolve(null);
1408
+ } else {
1409
+ reject(gatedError("gate_unavailable", "The legacy gate deny floor cannot be read."));
1410
+ }
1411
+ };
1412
+ request.onblocked = () => reject(gatedError("gate_unavailable", "The legacy gate read is blocked."));
1413
+ return promise;
1414
+ }
1415
+ function preserveLegacyFloor(legacy, tombstone) {
1416
+ if (legacy?.deniedAt === null || legacy === null) return { tombstone, advanced: false };
1417
+ const floor = {
1418
+ epoch: Math.max(legacy.epoch, tombstone?.epoch ?? 0),
1419
+ deniedAt: Math.max(legacy.deniedAt, tombstone?.deniedAt ?? 0)
1420
+ };
1421
+ if (tombstone !== null && tombstone.epoch >= floor.epoch && tombstone.deniedAt >= floor.deniedAt) {
1422
+ return { tombstone, advanced: false };
1423
+ }
1424
+ writeTombstone(floor);
1425
+ return { tombstone: floor, advanced: true };
1426
+ }
1427
+ function codeOf(error) {
1428
+ if (error !== null && typeof error === "object" && "code" in error && error.code === "REPLICA_GATED" && "gate" in error && typeof error.gate === "string") return error.gate;
1429
+ return error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "STORAGE_ERROR";
1430
+ }
1431
+ function logoutMarkerSet() {
1432
+ try {
1433
+ return localStorage.getItem(REPLICA_LOGOUT_MARKER) !== null;
1434
+ } catch {
1435
+ return true;
1436
+ }
1437
+ }
1438
+ function readTombstone() {
1439
+ try {
1440
+ const stored = localStorage.getItem(REPLICA_DENY_TOMBSTONE);
1441
+ if (stored === null) return null;
1442
+ const value = JSON.parse(stored);
1443
+ if (value === null || typeof value !== "object" || !("epoch" in value) || typeof value.epoch !== "number" || !Number.isSafeInteger(value.epoch) || value.epoch < 1 || !("deniedAt" in value) || typeof value.deniedAt !== "number" || !Number.isSafeInteger(value.deniedAt) || value.deniedAt < 0 || Object.keys(value).length !== 2) throw new Error("Invalid deny tombstone.");
1444
+ return value;
1445
+ } catch {
1446
+ throw gatedError("gate_unavailable", "The origin deny tombstone cannot be read.");
1447
+ }
1448
+ }
1449
+ function writeTombstone(value) {
1450
+ try {
1451
+ const serialized = JSON.stringify(value);
1452
+ localStorage.setItem(REPLICA_DENY_TOMBSTONE, serialized);
1453
+ if (localStorage.getItem(REPLICA_DENY_TOMBSTONE) !== serialized) throw new Error("The deny tombstone was not persisted.");
1454
+ } catch {
1455
+ throw gatedError("gate_unavailable", "The origin deny tombstone cannot be written.");
1456
+ }
1457
+ }
1458
+ function assertTombstoneWritable() {
1459
+ try {
1460
+ const stored = localStorage.getItem(REPLICA_DENY_TOMBSTONE);
1461
+ if (stored === null) {
1462
+ const probe = `${REPLICA_DENY_TOMBSTONE}:probe`;
1463
+ localStorage.setItem(probe, "1");
1464
+ if (localStorage.getItem(probe) !== "1") throw new Error("The deny tombstone probe was not persisted.");
1465
+ localStorage.removeItem(probe);
1466
+ } else {
1467
+ localStorage.setItem(REPLICA_DENY_TOMBSTONE, stored);
1468
+ if (localStorage.getItem(REPLICA_DENY_TOMBSTONE) !== stored) throw new Error("The deny tombstone was not persisted.");
1469
+ }
1470
+ } catch {
1471
+ throw gatedError("gate_unavailable", "The origin deny tombstone cannot be written.");
1472
+ }
1473
+ }
1474
+ function denyFloor(gate, tombstone) {
1475
+ if (gate !== void 0 && tombstone === null && (gate.lifecycleEpoch > 0 || gate.deniedAt !== null)) {
1476
+ throw gatedError("gate_unavailable", "A denied gate has no origin deny tombstone.");
1477
+ }
1478
+ if (gate !== void 0 && tombstone !== null && gate.lifecycleEpoch < tombstone.epoch) {
1479
+ throw gatedError("gate_unavailable", "The gate is behind the origin deny tombstone.");
1480
+ }
1481
+ return {
1482
+ epoch: Math.max(gate?.lifecycleEpoch ?? 0, tombstone?.epoch ?? 0),
1483
+ deniedAt: gate?.deniedAt === null || gate?.deniedAt === void 0 ? tombstone?.deniedAt ?? null : Math.max(gate.deniedAt, tombstone?.deniedAt ?? 0)
1484
+ };
1485
+ }
1486
+ function exclusive(name, section, signal) {
1487
+ if (typeof navigator === "undefined" || typeof navigator.locks?.request !== "function") {
1488
+ return Promise.reject(gatedError("gate_unavailable", "The origin Web Lock is unavailable."));
1489
+ }
1490
+ return signal === void 0 ? navigator.locks.request(name, section) : navigator.locks.request(name, { signal }, section);
1491
+ }
1492
+ function deleteKvDatabase() {
1493
+ const { promise, resolve } = Promise.withResolvers();
1494
+ if (takeFault("delete_database")) {
1495
+ resolve({ status: "failed", code: "STORAGE_ERROR" });
1496
+ return promise;
1497
+ }
1498
+ const request = indexedDB.deleteDatabase(KV_DATABASE);
1499
+ request.onsuccess = () => resolve({ status: "deleted" });
1500
+ request.onerror = () => resolve({ status: "failed", code: "STORAGE_ERROR" });
1501
+ request.onblocked = () => resolve({ status: "failed", code: DELETE_BLOCKED });
1502
+ return promise;
1503
+ }
1504
+ function setLogoutMarker() {
1505
+ try {
1506
+ const marker = String(Date.now());
1507
+ localStorage.setItem(REPLICA_LOGOUT_MARKER, marker);
1508
+ return localStorage.getItem(REPLICA_LOGOUT_MARKER) === marker;
1509
+ } catch {
1510
+ return false;
1511
+ }
1512
+ }
1513
+ async function transact(db, stores, mode, use) {
1514
+ const tx = db.transaction(stores, mode, { durability: "strict" });
1515
+ const done = transactionDone(tx);
1516
+ let value;
1517
+ try {
1518
+ value = await use(tx);
1519
+ } catch (error) {
1520
+ try {
1521
+ tx.abort();
1522
+ } catch {
1523
+ }
1524
+ await done.catch(() => void 0);
1525
+ throw error;
1526
+ }
1527
+ await done;
1528
+ return value;
1529
+ }
1530
+ function createIndexedDbLifecycle(options = {}) {
1531
+ const eraser = options.eraser ?? kvPartitionEraser;
1532
+ const ledger = options.ledger;
1533
+ const now = options.now ?? Date.now;
1534
+ function checkedNow() {
1535
+ const at = now();
1536
+ if (!Number.isSafeInteger(at) || at < 0) throw gatedError("gate_unavailable", "The replica clock is unavailable.");
1537
+ return at;
1538
+ }
1539
+ function deadlineOf(gate) {
1540
+ const deadline = gate.consentDeadline;
1541
+ if (typeof deadline !== "number" || !Number.isSafeInteger(deadline) || deadline < 0) {
1542
+ throw gatedError("gate_unavailable", "The account gate has no valid consent deadline.");
1543
+ }
1544
+ return deadline;
1545
+ }
1546
+ const gateTx = createIndexedDbGateTx(openKvStore, { now, onConsentExpired: expireConsent });
1547
+ let view;
1548
+ let expiryTimer;
1549
+ const expiryObservers = /* @__PURE__ */ new Set();
1550
+ function adopt(gate, principal) {
1551
+ const stamp = { incarnation: gate.incarnation, lifecycleEpoch: gate.lifecycleEpoch };
1552
+ view = { ...stamp, consentDeadline: deadlineOf(gate), principal: principal.toLowerCase() };
1553
+ clearTimeout(expiryTimer);
1554
+ armExpiryTimer();
1555
+ return stamp;
1556
+ }
1557
+ function armExpiryTimer() {
1558
+ if (view === void 0) return;
1559
+ const delay = Math.max(0, Math.min(view.consentDeadline - checkedNow(), 2147483647));
1560
+ expiryTimer = setTimeout(() => {
1561
+ expiryTimer = void 0;
1562
+ if (view === void 0) return;
1563
+ try {
1564
+ if (checkedNow() < view.consentDeadline) armExpiryTimer();
1565
+ else void expireConsent();
1566
+ } catch {
1567
+ view = void 0;
1568
+ }
1569
+ }, delay);
1570
+ }
1571
+ async function readGate() {
1572
+ const found = await withDatabase(
1573
+ openKvStore,
1574
+ false,
1575
+ (db) => requestAsPromise(db.transaction(KV_STORE.gate).objectStore(KV_STORE.gate).get(ACCOUNT2))
1576
+ );
1577
+ return found ?? void 0;
1578
+ }
1579
+ async function activeKeys(gate) {
1580
+ const records = await withDatabase(openKvStore, false, async (db) => {
1581
+ const device = db.transaction(KV_STORE.device).objectStore(KV_STORE.device);
1582
+ const hash = await requestAsPromise(device.get(GATE_HMAC_KEY));
1583
+ const account = await requestAsPromise(device.get(ACCOUNT_KEY));
1584
+ return { hash, account };
1585
+ });
1586
+ if (records === null || !validGateHmac(records.hash, gate) || !validAccountKey(records.account, gate)) {
1587
+ throw gatedError("gate_unavailable", "The account gate encryption keys are missing or mismatched.");
1588
+ }
1589
+ return { hash: records.hash, account: records.account };
1590
+ }
1591
+ async function readIntents() {
1592
+ const found = await withDatabase(
1593
+ openKvStore,
1594
+ false,
1595
+ (db) => requestAsPromise(db.transaction(KV_STORE.purgeIntents).objectStore(KV_STORE.purgeIntents).getAll())
1596
+ );
1597
+ return found ?? [];
1598
+ }
1599
+ async function writeGate(gate, previous, keys) {
1600
+ const db = await openKvStore(true);
1601
+ if (db === null) throw gatedError("gate_unavailable", "The origin database could not be created.");
1602
+ try {
1603
+ await transact(db, [KV_STORE.gate, KV_STORE.device], "readwrite", async (tx) => {
1604
+ const current = await requestAsPromise(tx.objectStore(KV_STORE.gate).get(ACCOUNT2));
1605
+ if (current?.incarnation !== previous?.incarnation || current?.lifecycleEpoch !== previous?.lifecycleEpoch || current?.principalHash !== previous?.principalHash) {
1606
+ throw gatedError("stamp_stale", "The origin gate changed during establishment.");
1607
+ }
1608
+ const device = tx.objectStore(KV_STORE.device);
1609
+ device.put(keys.hash, GATE_HMAC_KEY);
1610
+ device.put(keys.account, ACCOUNT_KEY);
1611
+ tx.objectStore(KV_STORE.gate).put(gate, ACCOUNT2);
1612
+ });
1613
+ } finally {
1614
+ db.close();
1615
+ }
1616
+ }
1617
+ async function establishSessionStamp(principal, o) {
1618
+ return exclusive(GATE_LOCK, async () => {
1619
+ const tombstone = readTombstone();
1620
+ if (logoutMarkerSet()) throw gatedError("logout_pending", "A logout is still pending on this origin.");
1621
+ const anchor = o.consentAnchor;
1622
+ if (anchor === void 0 || anchor.source !== "siwe" && anchor.source !== "delegation_iat" && anchor.source !== "delegation_nbf" || !Number.isSafeInteger(anchor.at) || anchor.at < 0) {
1623
+ throw gatedError("not_established", "A verified finite consent anchor is required.");
1624
+ }
1625
+ const issuedAt = Math.min(anchor.at, checkedNow());
1626
+ if ((await readIntents()).length > 0) throw gatedError("purge_pending", "A purge is still pending on this origin.");
1627
+ const current = await readGate();
1628
+ const legacy = current === void 0 ? await readLegacyGateFloor() : null;
1629
+ const preserved = preserveLegacyFloor(legacy, tombstone);
1630
+ const floor = denyFloor(current, preserved.tombstone);
1631
+ if (preserved.advanced) throw gatedError("stamp_stale", "The legacy deny floor was preserved; a new consent proof is required.");
1632
+ if (floor.deniedAt !== null && issuedAt <= floor.deniedAt) {
1633
+ throw gatedError("stamp_stale", "This sign-in predates the last sign-out.");
1634
+ }
1635
+ assertTombstoneWritable();
1636
+ if (current === void 0 || current.principalHash === null) {
1637
+ const incarnation = crypto.randomUUID();
1638
+ const hash = await newGateHmac(incarnation);
1639
+ const principalHash2 = await gateHash(hash.hmac, "principal", principal.toLowerCase());
1640
+ const account = await newAccountKey(incarnation, floor.epoch, principalHash2);
1641
+ const created = {
1642
+ incarnation,
1643
+ salt: crypto.randomUUID(),
1644
+ principalHash: principalHash2,
1645
+ lifecycleEpoch: floor.epoch,
1646
+ deniedAt: floor.deniedAt,
1647
+ keyId: hash.keyId,
1648
+ accountKeyId: account.keyId,
1649
+ consentDeadline: issuedAt + CONSENT_WINDOW_MS2
1650
+ };
1651
+ if (!Number.isSafeInteger(created.consentDeadline)) throw gatedError("not_established", "The consent deadline is invalid.");
1652
+ await writeGate(created, current, { hash, account });
1653
+ return adopt(created, principal);
1654
+ }
1655
+ const keys = await activeKeys(current);
1656
+ const principalHash = await gateHash(keys.hash.hmac, "principal", principal.toLowerCase());
1657
+ if (current.principalHash !== principalHash) throw gatedError("principal_mismatch", "The origin is bound to another account.");
1658
+ if (checkedNow() >= deadlineOf(current)) {
1659
+ await expireConsentLocked();
1660
+ throw new ReplicaError(ReplicaErrorCode.CONSENT_EXPIRED, "The account consent has expired.");
1661
+ }
1662
+ return adopt(current, principal);
1663
+ });
1664
+ }
1665
+ async function restoreSessionStamp(principal, persisted) {
1666
+ const gate = await readGate();
1667
+ denyFloor(gate, readTombstone());
1668
+ if (gate === void 0 || persisted === void 0) throw gatedError("not_established", "No session stamp is established for this origin.");
1669
+ if (typeof persisted !== "object" || persisted === null || Array.isArray(persisted) || Object.keys(persisted).length !== 2 || !Object.prototype.hasOwnProperty.call(persisted, "incarnation") || !Object.prototype.hasOwnProperty.call(persisted, "lifecycleEpoch") || typeof persisted.incarnation !== "string" || persisted.incarnation.length === 0 || !Number.isSafeInteger(persisted.lifecycleEpoch) || persisted.lifecycleEpoch < 0) {
1670
+ throw gatedError("stamp_stale", "The persisted replica stamp is malformed.");
1671
+ }
1672
+ if (persisted.incarnation !== gate.incarnation || persisted.lifecycleEpoch !== gate.lifecycleEpoch) {
1673
+ throw gatedError("stamp_stale", "The persisted session stamp is stale.");
1674
+ }
1675
+ if (gate.principalHash === null) throw gatedError("principal_mismatch", "The origin has no active account.");
1676
+ const keys = await activeKeys(gate);
1677
+ const hash = await gateHash(keys.hash.hmac, "principal", principal.toLowerCase());
1678
+ if (hash !== gate.principalHash) throw gatedError("principal_mismatch", "The origin is bound to another account.");
1679
+ if (logoutMarkerSet()) throw gatedError("logout_pending", "A logout is still pending on this origin.");
1680
+ if ((await readIntents()).length > 0) throw gatedError("purge_pending", "A purge is still pending on this origin.");
1681
+ assertTombstoneWritable();
1682
+ if (checkedNow() >= deadlineOf(gate)) {
1683
+ await expireConsent();
1684
+ throw new ReplicaError(ReplicaErrorCode.CONSENT_EXPIRED, "The account consent has expired.");
1685
+ }
1686
+ return adopt(gate, principal);
1687
+ }
1688
+ async function activationAllowed(next) {
1689
+ try {
1690
+ if (logoutMarkerSet()) return false;
1691
+ const gate = await readGate();
1692
+ denyFloor(gate, readTombstone());
1693
+ if (gate === void 0 || gate.principalHash === null) return false;
1694
+ if (checkedNow() >= deadlineOf(gate)) {
1695
+ await expireConsent();
1696
+ return false;
1697
+ }
1698
+ const keys = await activeKeys(gate);
1699
+ const hash = await gateHash(keys.hash.hmac, "principal", next.toLowerCase());
1700
+ if (hash !== gate.principalHash) return false;
1701
+ return (await readIntents()).every((intent) => intent.principalHash === hash);
1702
+ } catch {
1703
+ return false;
1704
+ }
1705
+ }
1706
+ function checkDelivery(stamp) {
1707
+ if (stamp === void 0) return { status: "gated", gate: "not_established" };
1708
+ if (view === void 0) return { status: "gated", gate: "stamp_stale" };
1709
+ if (view.incarnation !== stamp.incarnation || view.lifecycleEpoch !== stamp.lifecycleEpoch) {
1710
+ return { status: "gated", gate: "stamp_stale" };
1711
+ }
1712
+ try {
1713
+ const tombstone = readTombstone();
1714
+ if (logoutMarkerSet()) return { status: "gated", gate: "logout_pending" };
1715
+ if (tombstone === null && stamp.lifecycleEpoch !== 0) return { status: "gated", gate: "gate_unavailable" };
1716
+ if (tombstone !== null && stamp.lifecycleEpoch < tombstone.epoch) return { status: "gated", gate: "stamp_stale" };
1717
+ if (checkedNow() >= view.consentDeadline) return { status: "consent_expired", code: "CONSENT_EXPIRED" };
1718
+ return { status: "allow" };
1719
+ } catch {
1720
+ return { status: "gated", gate: "gate_unavailable" };
1721
+ }
1722
+ }
1723
+ function consentDeadline(stamp) {
1724
+ return view !== void 0 && checkDelivery(stamp).status === "allow" ? view.consentDeadline : null;
1725
+ }
1726
+ function deliverable(stamp) {
1727
+ return checkDelivery(stamp).status === "allow";
1728
+ }
1729
+ async function commitDeny(plan) {
1730
+ if (takeFault("deny_tx")) throw new Error("injected deny_tx failure");
1731
+ const db = await openKvStore(false);
1732
+ if (db === null) return { status: "stale" };
1733
+ try {
1734
+ return await transact(
1735
+ db,
1736
+ [KV_STORE.gate, KV_STORE.partitions, KV_STORE.purgeIntents, KV_STORE.device, KV_STORE.pending, KV_STORE.quota],
1737
+ "readwrite",
1738
+ async (tx) => {
1739
+ const current = await requestAsPromise(tx.objectStore(KV_STORE.gate).get(ACCOUNT2));
1740
+ if (current === void 0 || current.incarnation !== plan.gate.incarnation || current.principalHash !== plan.callerHash || current.lifecycleEpoch !== plan.gate.lifecycleEpoch || current.keyId !== plan.gate.keyId || current.accountKeyId !== plan.gate.accountKeyId) {
1741
+ return { status: "stale" };
1742
+ }
1743
+ const now2 = plan.tombstone.deniedAt;
1744
+ const partitions = await requestAsPromise(tx.objectStore(KV_STORE.partitions).getAll());
1745
+ const pendingKeys = await requestAsPromise(tx.objectStore(KV_STORE.pending).getAllKeys());
1746
+ const intents = [];
1747
+ const identities = /* @__PURE__ */ new Set();
1748
+ for (const entry of partitions) {
1749
+ if (entry.principalHash !== plan.callerHash) continue;
1750
+ intents.push({ id: entry.partitionId, kind: "partition", partitionId: entry.partitionId, principalHash: plan.callerHash, recordedAt: now2 });
1751
+ identities.add(entry.identityKeyHash);
1752
+ }
1753
+ for (const key of pendingKeys) {
1754
+ if (typeof key === "string") identities.add(key);
1755
+ }
1756
+ for (const identityKeyHash of identities) {
1757
+ intents.push({ id: `pending:${identityKeyHash}`, kind: "pending", identityKeyHash, principalHash: plan.callerHash, recordedAt: now2 });
1758
+ }
1759
+ const purge = tx.objectStore(KV_STORE.purgeIntents);
1760
+ for (const intent of intents) purge.put(intent, intent.id);
1761
+ const device = tx.objectStore(KV_STORE.device);
1762
+ let deviceBytes = billedDeviceBytes(await requestAsPromise(device.get(DEVICE_KEY)));
1763
+ device.delete(DEVICE_KEY);
1764
+ device.delete(ACCOUNT_KEY);
1765
+ device.delete(GATE_HMAC_KEY);
1766
+ for (const prefix of [FIRST_INSTALL_KEY_PREFIX, GRANT_INSTALL_KEY_PREFIX]) {
1767
+ const keys = await requestAsPromise(device.getAllKeys(IDBKeyRange.bound(prefix, `${prefix}\uFFFF`)));
1768
+ for (const key of keys) {
1769
+ deviceBytes += billedDeviceBytes(await requestAsPromise(device.get(key)));
1770
+ if (!Number.isSafeInteger(deviceBytes)) throw gatedError("gate_unavailable", "Device quota bytes overflow.");
1771
+ device.delete(key);
1772
+ }
1773
+ }
1774
+ await ledger?.accountErase(tx, { partitionId: "device", bytes: deviceBytes });
1775
+ const next = {
1776
+ ...current,
1777
+ principalHash: null,
1778
+ consentDeadline: null,
1779
+ lifecycleEpoch: plan.tombstone.epoch,
1780
+ deniedAt: plan.tombstone.deniedAt
1781
+ };
1782
+ tx.objectStore(KV_STORE.gate).put(next, ACCOUNT2);
1783
+ return { status: "denied", lifecycleEpoch: next.lifecycleEpoch, intents: intents.length };
1784
+ }
1785
+ );
1786
+ } finally {
1787
+ db.close();
1788
+ }
1789
+ }
1790
+ async function wipe() {
1791
+ const deleted = await deleteKvDatabase();
1792
+ if (deleted.status === "deleted") return { status: "deleted" };
1793
+ return setLogoutMarker() ? { status: "marker", code: deleted.code } : { status: "unguarded", code: deleted.code };
1794
+ }
1795
+ async function advanceDeny(gate, floor) {
1796
+ const callerHash = gate.principalHash;
1797
+ if (callerHash === null) return { status: "stale" };
1798
+ const tombstone = { epoch: floor.epoch + 1, deniedAt: Math.max(checkedNow(), (floor.deniedAt ?? -1) + 1) };
1799
+ if (!Number.isSafeInteger(tombstone.epoch) || !Number.isSafeInteger(tombstone.deniedAt)) {
1800
+ throw gatedError("gate_unavailable", "The origin deny tombstone cannot advance.");
1801
+ }
1802
+ writeTombstone(tombstone);
1803
+ try {
1804
+ return await commitDeny({ gate, callerHash, tombstone });
1805
+ } catch {
1806
+ return wipe();
1807
+ }
1808
+ }
1809
+ async function denyLocked(input) {
1810
+ const gate = await readGate();
1811
+ const floor = denyFloor(gate, readTombstone());
1812
+ if (gate === void 0 || gate.principalHash === null) return { status: "stale" };
1813
+ const keys = await activeKeys(gate);
1814
+ const callerHash = await gateHash(keys.hash.hmac, "principal", input.principal.toLowerCase());
1815
+ if (callerHash !== gate.principalHash) return { status: "stale" };
1816
+ if (input.reason === "account_switch" && input.next === void 0) {
1817
+ throw new Error("account_switch requires the next principal");
1818
+ }
1819
+ return advanceDeny(gate, floor);
1820
+ }
1821
+ async function expireConsentLocked() {
1822
+ const gate = await readGate();
1823
+ const floor = denyFloor(gate, readTombstone());
1824
+ if (gate === void 0 || gate.principalHash === null) return { status: "stale" };
1825
+ if (checkedNow() < deadlineOf(gate)) return { status: "stale" };
1826
+ const stamp = { incarnation: gate.incarnation, lifecycleEpoch: gate.lifecycleEpoch };
1827
+ view = void 0;
1828
+ clearTimeout(expiryTimer);
1829
+ const result = await advanceDeny(gate, floor);
1830
+ if (result.status === "denied" || result.status === "deleted") {
1831
+ try {
1832
+ options.onConsentExpired?.(stamp);
1833
+ } catch {
1834
+ }
1835
+ for (const observer of expiryObservers) {
1836
+ try {
1837
+ observer(stamp);
1838
+ } catch {
1839
+ }
1840
+ }
1841
+ }
1842
+ return result;
1843
+ }
1844
+ async function expireConsent() {
1845
+ try {
1846
+ return await exclusive(GATE_LOCK, expireConsentLocked);
1847
+ } catch (error) {
1848
+ return { status: "unguarded", code: codeOf(error) };
1849
+ }
1850
+ }
1851
+ async function deny(input) {
1852
+ if (view?.principal === input.principal.toLowerCase()) {
1853
+ view = void 0;
1854
+ clearTimeout(expiryTimer);
1855
+ }
1856
+ try {
1857
+ return await exclusive(GATE_LOCK, () => denyLocked(input));
1858
+ } catch (error) {
1859
+ return { status: "unguarded", code: codeOf(error) };
1860
+ }
1861
+ }
1862
+ async function eraseIntent(intent) {
1863
+ const db = await openKvStore(false);
1864
+ if (db === null) return;
1865
+ try {
1866
+ await transact(db, [...ERASE_STORES, KV_STORE.partitions, KV_STORE.purgeIntents, KV_STORE.quota], "readwrite", async (tx) => {
1867
+ if (intent.kind === "partition") {
1868
+ const { bytes } = await eraser.erasePartition(tx, intent.partitionId);
1869
+ await gateTx.unindexPartition(tx, intent.partitionId);
1870
+ await ledger?.accountErase(tx, { partitionId: intent.partitionId, bytes });
1871
+ } else {
1872
+ await eraser.eraseIdentityPending(tx, intent.identityKeyHash);
1873
+ }
1874
+ await gateTx.clearIntent(tx, intent.id);
1875
+ });
1876
+ } finally {
1877
+ db.close();
1878
+ }
1879
+ }
1880
+ async function erasePendingLocked({ signal }) {
1881
+ const erased = [];
1882
+ const failed = [];
1883
+ for (const intent of await readIntents()) {
1884
+ if (signal.aborted) break;
1885
+ try {
1886
+ await eraseIntent(intent);
1887
+ erased.push(intent.id);
1888
+ } catch (error) {
1889
+ failed.push({ id: intent.id, code: codeOf(error) });
1890
+ }
1891
+ }
1892
+ return { erased, failed, remaining: (await readIntents()).length };
1893
+ }
1894
+ function erasePending(o) {
1895
+ return exclusive(GATE_LOCK, () => erasePendingLocked(o));
1896
+ }
1897
+ async function recover({ signal }) {
1898
+ try {
1899
+ return await exclusive(GATE_LOCK, async () => {
1900
+ const failed = [];
1901
+ if (logoutMarkerSet()) {
1902
+ if (readTombstone() === null) throw gatedError("gate_unavailable", "The pending logout has no deny tombstone.");
1903
+ const deleted = await deleteKvDatabase();
1904
+ if (deleted.status === "deleted") {
1905
+ try {
1906
+ localStorage.removeItem(REPLICA_LOGOUT_MARKER);
1907
+ if (localStorage.getItem(REPLICA_LOGOUT_MARKER) !== null) throw new Error("The logout marker was not cleared.");
1908
+ } catch {
1909
+ failed.push({ id: "marker", code: "STORAGE_ERROR" });
1910
+ }
1911
+ } else {
1912
+ failed.push({ id: "database", code: deleted.code });
1913
+ }
1914
+ }
1915
+ if (failed.length > 0) return { erased: [], failed, remaining: 1 };
1916
+ const gate = await readGate();
1917
+ const tombstone = readTombstone();
1918
+ if (gate !== void 0 && tombstone !== null && gate.lifecycleEpoch < tombstone.epoch) {
1919
+ const wiped = await wipe();
1920
+ if (wiped.status !== "deleted") {
1921
+ return { erased: [], failed: [{ id: "database", code: wiped.code }], remaining: 1 };
1922
+ }
1923
+ }
1924
+ const legacy = await readLegacyGateFloor();
1925
+ if (legacy !== null) {
1926
+ const tombstone2 = readTombstone();
1927
+ preserveLegacyFloor(legacy, tombstone2);
1928
+ assertTombstoneWritable();
1929
+ const db = await openKvStore(true);
1930
+ if (db === null) throw gatedError("gate_unavailable", "The legacy origin could not be migrated.");
1931
+ db.close();
1932
+ }
1933
+ const expiry = await expireConsentLocked();
1934
+ if (expiry.status === "marker" || expiry.status === "unguarded") {
1935
+ return { erased: [], failed: [{ id: "expiry", code: expiry.code }], remaining: 1 };
1936
+ }
1937
+ const pass = await erasePendingLocked({ signal });
1938
+ return { erased: pass.erased, failed: [...failed, ...pass.failed], remaining: pass.remaining, consentExpired: expiry.status === "denied" || expiry.status === "deleted" };
1939
+ }, signal);
1940
+ } catch (error) {
1941
+ return { erased: [], failed: [{ id: "recover", code: codeOf(error) }], remaining: 1 };
1942
+ }
1943
+ }
1944
+ function subscribeConsentExpired(listener) {
1945
+ expiryObservers.add(listener);
1946
+ return () => {
1947
+ expiryObservers.delete(listener);
1948
+ };
1949
+ }
1950
+ return {
1951
+ kind: "indexeddb",
1952
+ recover,
1953
+ expireConsent,
1954
+ checkDelivery,
1955
+ consentDeadline,
1956
+ subscribeConsentExpired,
1957
+ establishSessionStamp,
1958
+ restoreSessionStamp,
1959
+ activationAllowed,
1960
+ deliverable,
1961
+ deny,
1962
+ erasePending,
1963
+ pendingPurge: readIntents
1964
+ };
1965
+ }
1966
+
1967
+ // src/kv-storage/codec.ts
1968
+ import { bytesToHex as bytesToHex2, hexToBytes } from "@noble/hashes/utils";
1969
+ var encoder3 = new TextEncoder();
1970
+ var decoder2 = new TextDecoder("utf-8", { fatal: true });
1971
+ function encodeValue(value) {
1972
+ if (value === void 0) return ["undefined"];
1973
+ if (value instanceof Uint8Array) return ["bytes", bytesToHex2(value)];
1974
+ if (Array.isArray(value)) return ["array", value.map(encodeValue)];
1975
+ if (value !== null && typeof value === "object") {
1976
+ return ["object", Object.entries(value).map(([key, item]) => [key, encodeValue(item)])];
1977
+ }
1978
+ if (typeof value === "number" && !Number.isFinite(value)) {
1979
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "A replica record contains a non-finite number.");
1980
+ }
1981
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
1982
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "A replica record contains an unsupported value.");
1983
+ }
1984
+ function decodeValue(value) {
1985
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return value;
1986
+ if (!Array.isArray(value) || typeof value[0] !== "string") throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica record has an invalid payload variant.");
1987
+ if (value[0] === "undefined" && value.length === 1) return void 0;
1988
+ if (value[0] === "bytes" && value.length === 2 && typeof value[1] === "string" && /^(?:[0-9a-f]{2})*$/.test(value[1])) return hexToBytes(value[1]);
1989
+ if (value[0] === "array" && value.length === 2 && Array.isArray(value[1])) return value[1].map(decodeValue);
1990
+ if (value[0] === "object" && value.length === 2 && Array.isArray(value[1])) {
1991
+ const seen = /* @__PURE__ */ new Set();
1992
+ const fields = [];
1993
+ for (const pair of value[1]) {
1994
+ if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string" || seen.has(pair[0])) {
1995
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica record has an invalid object field.");
1996
+ }
1997
+ seen.add(pair[0]);
1998
+ fields.push([pair[0], decodeValue(pair[1])]);
1999
+ }
2000
+ return Object.fromEntries(fields);
2001
+ }
2002
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica record has an invalid payload variant.");
2003
+ }
2004
+ function encodeJson(value) {
2005
+ return encoder3.encode(JSON.stringify(["tinycloud-replica-kv", 1, encodeValue(value)]));
2006
+ }
2007
+ function decodeJson(bytes) {
2008
+ try {
2009
+ const value = JSON.parse(decoder2.decode(bytes));
2010
+ if (!Array.isArray(value) || value.length !== 3 || value[0] !== "tinycloud-replica-kv" || value[1] !== 1) {
2011
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica record has an unknown payload version.");
2012
+ }
2013
+ return decodeValue(value[2]);
2014
+ } catch (cause) {
2015
+ if (cause instanceof ReplicaError) throw cause;
2016
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica record payload is corrupt.", void 0, { cause });
2017
+ }
2018
+ }
2019
+
2020
+ // src/kv-storage/pending.ts
2021
+ function corrupt(message) {
2022
+ return new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, message);
2023
+ }
2024
+ var IndexedDbPendingWriteStore = class {
2025
+ durable = true;
2026
+ identity;
2027
+ #identityKey;
2028
+ #gateTx;
2029
+ #stamp;
2030
+ #locks;
2031
+ constructor(identity, options = {}) {
2032
+ this.identity = canonicalReplicationIdentity(identity);
2033
+ this.#identityKey = replicationIdentityKey(this.identity);
2034
+ this.#gateTx = options.gateTx;
2035
+ this.#stamp = options.stamp;
2036
+ this.#locks = options.locks ?? (typeof navigator === "undefined" ? void 0 : navigator.locks);
2037
+ }
2038
+ #empty() {
2039
+ return { v: 2, identity: this.identity, committedEpoch: 0, seq: 0, records: [] };
2040
+ }
2041
+ /** Validate a stored record: the v2 shape, and this store's identity (never another's). */
2042
+ #parse(value) {
2043
+ if (value === null || typeof value !== "object" || !("v" in value) || value.v !== 2 || !("identity" in value) || !("committedEpoch" in value) || typeof value.committedEpoch !== "number" || !("seq" in value) || typeof value.seq !== "number" || !("records" in value) || !Array.isArray(value.records)) {
2044
+ throw corrupt("The pending-write state is corrupt.");
2045
+ }
2046
+ let storedKey;
2047
+ try {
2048
+ storedKey = replicationIdentityKey(value.identity);
2049
+ } catch {
2050
+ storedKey = void 0;
2051
+ }
2052
+ if (storedKey !== this.#identityKey) throw corrupt("The pending-write state belongs to a different identity.");
2053
+ return value;
2054
+ }
2055
+ async #access() {
2056
+ const gateTx = this.#gateTx;
2057
+ if (gateTx === void 0) throw gatedError("gate_unavailable", "No lifecycle gate is wired into the pending-write store.");
2058
+ const stamp = this.#stamp;
2059
+ if (stamp === void 0) throw gatedError("not_established", "The pending-write store has no session stamp.");
2060
+ const snapshot = await gateTx.snapshot();
2061
+ if (snapshot === null) throw gatedError("not_established", "No replica gate exists on this origin yet.");
2062
+ const identityKeyHash = await gateHash(snapshot.gateHmac, "identity", this.#identityKey);
2063
+ const principalHash = await gateHash(snapshot.gateHmac, "principal", this.identity.principal.toLowerCase());
2064
+ return {
2065
+ gateTx,
2066
+ id: identityKeyHash,
2067
+ access: {
2068
+ stamp,
2069
+ incarnation: snapshot.incarnation,
2070
+ keyId: snapshot.keyId,
2071
+ accountKeyId: snapshot.accountKeyId,
2072
+ principalHash,
2073
+ identityKeyHash
2074
+ }
2075
+ };
2076
+ }
2077
+ async #stored() {
2078
+ const gated = await this.#access();
2079
+ const { gateTx, access, id } = gated;
2080
+ const row = await gateTx.run({ stores: [KV_STORE.pending], mode: "readonly" }, access, (tx) => requestAsPromise(tx.objectStore(KV_STORE.pending).get(id)));
2081
+ if (row === void 0) return void 0;
2082
+ const account = await gateTx.readAccountKey(access);
2083
+ return this.#parse(decodeJson(await openSealed(account.aesGcm, row, `pending\0${id}`)));
2084
+ }
2085
+ async read() {
2086
+ return await this.#stored() ?? this.#empty();
2087
+ }
2088
+ async readExisting() {
2089
+ return this.#stored();
2090
+ }
2091
+ async update(mutate) {
2092
+ const gated = await this.#access();
2093
+ const { gateTx, access, id } = gated;
2094
+ if (this.#locks === void 0) throw gatedError("gate_unavailable", "Web Locks are required for pending-write mutations.");
2095
+ const section = async () => {
2096
+ const old = await gateTx.run({ stores: [KV_STORE.pending], mode: "readonly" }, access, (tx) => requestAsPromise(tx.objectStore(KV_STORE.pending).get(id)));
2097
+ const account = await gateTx.readAccountKey(access);
2098
+ const state = old === void 0 ? this.#empty() : this.#parse(decodeJson(await openSealed(account.aesGcm, old, `pending\0${id}`)));
2099
+ const result = mutate(state);
2100
+ const sealed = await seal(account.aesGcm, encodeJson(state), `pending\0${id}`);
2101
+ const row = { ...sealed, size: 0 };
2102
+ row.size = measureSealedRow(id, row);
2103
+ await gateTx.run({ stores: [KV_STORE.pending], mode: "readwrite" }, access, async (tx) => {
2104
+ const pending = tx.objectStore(KV_STORE.pending);
2105
+ const current = await requestAsPromise(pending.get(id));
2106
+ if (old === void 0 !== (current === void 0) || old !== void 0 && current !== void 0 && (old.iv.length !== current.iv.length || old.iv.some((byte, index) => byte !== current.iv[index]))) {
2107
+ throw new ReplicaError(ReplicaErrorCode.BUSY, "The pending-write state changed during its sealed mutation.");
2108
+ }
2109
+ pending.put(row, id);
2110
+ });
2111
+ return result;
2112
+ };
2113
+ return this.#locks.request(`${REPLICA_PENDING_LOCK_PREFIX}${id}`, section);
2114
+ }
2115
+ };
2116
+
2117
+ // src/kv-storage/client.ts
2118
+ function replyError(err) {
2119
+ const error = new ReplicaError(err.code, err.message, err.detail);
2120
+ if (err.code === ReplicaErrorCode.GATED && typeof err.detail?.gate === "string")
2121
+ error.gate = err.detail.gate;
2122
+ return error;
2123
+ }
2124
+ function sameStamp(a, b) {
2125
+ return a !== void 0 && a.incarnation === b.incarnation && a.lifecycleEpoch === b.lifecycleEpoch;
2126
+ }
2127
+ function spawn(source) {
2128
+ if (source !== void 0) {
2129
+ if (typeof Worker !== "undefined" && source instanceof Worker) return source;
2130
+ if (source instanceof URL || typeof source === "string") return new Worker(source, { type: "module" });
2131
+ if (typeof source === "function") return source();
2132
+ }
2133
+ return new Worker(new URL("./kv-storage.worker.67e46cebbda277040312a770449123633491901d6cceacea95754a843e01e9c9.js", import.meta.url), { type: "module" });
2134
+ }
2135
+ var WorkerConnection = class {
2136
+ ready;
2137
+ dead = false;
2138
+ #worker;
2139
+ #pending = /* @__PURE__ */ new Map();
2140
+ #onEvent;
2141
+ #nextId = 1;
2142
+ #readyResolve;
2143
+ #readyReject;
2144
+ constructor(worker, onEvent) {
2145
+ this.#worker = worker;
2146
+ this.#onEvent = onEvent;
2147
+ const ready = Promise.withResolvers();
2148
+ this.ready = ready.promise;
2149
+ ready.promise.catch(() => void 0);
2150
+ this.#readyResolve = ready.resolve;
2151
+ this.#readyReject = ready.reject;
2152
+ worker.onmessage = (message) => this.#receive(message.data);
2153
+ worker.onerror = (event) => this.kill(new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, event.message || "The replica worker failed."));
2154
+ }
2155
+ #receive(data) {
2156
+ if ("event" in data) {
2157
+ if (data.event.event === "ready") this.#readyResolve();
2158
+ else this.#onEvent(data.event);
2159
+ return;
2160
+ }
2161
+ const pending = this.#pending.get(data.id);
2162
+ if (pending === void 0) return;
2163
+ this.#pending.delete(data.id);
2164
+ if (data.ok) pending.resolve({ result: data.result, stamp: data.stamp });
2165
+ else pending.reject(replyError(data.err));
2166
+ }
2167
+ /** Post `request`; `onId` learns the request id before the reply (cancel needs it). */
2168
+ async call(request, onId) {
2169
+ await this.ready;
2170
+ if (this.dead) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica worker stopped.");
2171
+ const id = this.#nextId++;
2172
+ const { promise, resolve, reject } = Promise.withResolvers();
2173
+ this.#pending.set(id, { resolve, reject });
2174
+ onId?.(id);
2175
+ this.#worker.postMessage({ id, ...request });
2176
+ return promise;
2177
+ }
2178
+ /** Fire-and-forget control message: cancel or the durable expiry result. */
2179
+ notify(request) {
2180
+ if (this.dead) return;
2181
+ this.#worker.postMessage({ id: this.#nextId++, ...request });
2182
+ }
2183
+ kill(error) {
2184
+ if (this.dead) return;
2185
+ this.dead = true;
2186
+ this.#worker.terminate();
2187
+ this.#readyReject(error);
2188
+ for (const pending of this.#pending.values()) pending.reject(error);
2189
+ this.#pending.clear();
2190
+ }
2191
+ };
2192
+ var IndexedDbReplicaHandle = class {
2193
+ spec;
2194
+ deviceDid;
2195
+ replicaId;
2196
+ #storage;
2197
+ #lifecycle;
2198
+ #stamp;
2199
+ #wire;
2200
+ #connection;
2201
+ #remote;
2202
+ #closed = false;
2203
+ #syncs = /* @__PURE__ */ new Set();
2204
+ constructor(storage, gate, spec, wire, connection, opened) {
2205
+ this.#storage = storage;
2206
+ this.#lifecycle = gate.lifecycle;
2207
+ this.#stamp = gate.stamp;
2208
+ this.spec = spec;
2209
+ this.#wire = wire;
2210
+ this.#connection = connection;
2211
+ this.#remote = opened.handle;
2212
+ this.deviceDid = opened.deviceDid;
2213
+ this.replicaId = opened.partitionId;
2214
+ }
2215
+ acceptsPeerCommit(connection, handle) {
2216
+ return !this.#closed && this.#connection === connection && this.#remote === handle && this.#lifecycle.deliverable(this.#stamp);
2217
+ }
2218
+ /** Before every request that reaches the worker (and every delivery): the gate must still honour the stamp. */
2219
+ #assertDeliverable() {
2220
+ if (!this.#lifecycle.deliverable(this.#stamp)) throw gatedError("stamp_stale", "The replica session ended.");
2221
+ }
2222
+ /** The live worker-side handle; after a worker restart the spec is reopened on the new worker. */
2223
+ async #live() {
2224
+ if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica handle is closed.");
2225
+ this.#assertDeliverable();
2226
+ if (this.#connection.dead) {
2227
+ const connection = this.#storage.connection();
2228
+ const reply = await connection.call({ op: "open", spec: this.#wire });
2229
+ if (this.#closed) {
2230
+ await connection.call({ op: "close", handle: reply.result.handle }).catch(() => void 0);
2231
+ throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica handle is closed.");
2232
+ }
2233
+ const opened = this.#deliver(reply);
2234
+ this.#connection = connection;
2235
+ this.#remote = opened.handle;
2236
+ }
2237
+ return { connection: this.#connection, handle: this.#remote };
2238
+ }
2239
+ /** Delivery check (CONTRACT §2): drop a reply that ran under another (or no) stamp, or that the gate no longer honours. */
2240
+ #deliver(reply) {
2241
+ if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica handle is closed.");
2242
+ if (!sameStamp(reply.stamp, this.#stamp)) throw gatedError("stamp_stale", "The replica reply ran under another session; it was not delivered.");
2243
+ this.#assertDeliverable();
2244
+ return reply.result;
2245
+ }
2246
+ async get(key) {
2247
+ const { connection, handle } = await this.#live();
2248
+ const result = this.#deliver(await connection.call({ op: "get", handle, key }));
2249
+ return result.status === "present" ? { ...result, value: new Uint8Array(result.value) } : result;
2250
+ }
2251
+ async list(options) {
2252
+ const { connection, handle } = await this.#live();
2253
+ return this.#deliver(await connection.call({ op: "list", handle, ...options }));
2254
+ }
2255
+ async grant() {
2256
+ const { connection, handle } = await this.#live();
2257
+ return this.#deliver(await connection.call({ op: "grant", handle }));
2258
+ }
2259
+ async installGrant(ucan) {
2260
+ const { connection, handle } = await this.#live();
2261
+ return this.#deliver(await connection.call({ op: "installGrant", handle, ucan }));
2262
+ }
2263
+ sync(options) {
2264
+ const running = this.#sync(options);
2265
+ const settled = running.then(() => void 0, () => void 0);
2266
+ this.#syncs.add(settled);
2267
+ void settled.then(() => this.#syncs.delete(settled));
2268
+ return running;
2269
+ }
2270
+ async #sync(options) {
2271
+ options.signal.throwIfAborted();
2272
+ const { connection, handle } = await this.#live();
2273
+ if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica handle is closed.");
2274
+ let requestId;
2275
+ const cancel = () => {
2276
+ if (requestId !== void 0) connection.notify({ op: "cancel", target: requestId });
2277
+ };
2278
+ options.signal.addEventListener("abort", cancel, { once: true });
2279
+ try {
2280
+ const running = connection.call({ op: "sync", handle, syncStartEpoch: options.syncStartEpoch }, (id) => {
2281
+ requestId = id;
2282
+ if (options.signal.aborted) cancel();
2283
+ });
2284
+ return this.#deliver(await running);
2285
+ } finally {
2286
+ options.signal.removeEventListener("abort", cancel);
2287
+ }
2288
+ }
2289
+ async status() {
2290
+ const { connection, handle } = await this.#live();
2291
+ const status = this.#deliver(await connection.call({ op: "status", handle }));
2292
+ return { ...status, persisted: await this.#storage.persisted(), purgePending: await purgePendingFor(this.#lifecycle, this.replicaId, null) };
2293
+ }
2294
+ async close() {
2295
+ if (this.#closed) return;
2296
+ this.#closed = true;
2297
+ this.#storage.forget(this);
2298
+ if (!this.#connection.dead) await this.#connection.call({ op: "close", handle: this.#remote }).catch(() => void 0);
2299
+ await Promise.allSettled([...this.#syncs]);
2300
+ }
2301
+ };
2302
+ async function purgePendingFor(lifecycle, partitionId, identityKeyHash) {
2303
+ return (await lifecycle.pendingPurge()).some(
2304
+ (intent) => intent.kind === "partition" ? intent.partitionId === partitionId : intent.identityKeyHash === identityKeyHash
2305
+ );
2306
+ }
2307
+ var IndexedDbReplicaStorage = class {
2308
+ kind = "indexeddb";
2309
+ lifecycle;
2310
+ #options;
2311
+ #handles = /* @__PURE__ */ new Set();
2312
+ #connection;
2313
+ #persisted;
2314
+ #recovery;
2315
+ constructor(options) {
2316
+ this.#options = { ...options, ledger: options.ledger ?? ledgerFor() };
2317
+ this.lifecycle = options.lifecycle;
2318
+ }
2319
+ connection(peek) {
2320
+ if (peek) return this.#connection;
2321
+ if (this.#connection === void 0 || this.#connection.dead) {
2322
+ const connection = new WorkerConnection(spawn(this.#options.worker), (event) => this.#event(event, connection));
2323
+ this.#connection = connection;
2324
+ }
2325
+ return this.#connection;
2326
+ }
2327
+ #event(event, connection) {
2328
+ if (event.event === "consentExpiry") {
2329
+ const lifecycle = this.lifecycle;
2330
+ void Promise.resolve().then(() => {
2331
+ if (typeof lifecycle?.expireConsent !== "function") throw gatedError("gate_unavailable", "The lifecycle cannot expire account consent.");
2332
+ return lifecycle.expireConsent();
2333
+ }).then(
2334
+ (outcome) => connection.notify({ op: "consentExpiryAck", requestId: event.requestId, ok: true, outcome }),
2335
+ () => connection.notify({ op: "consentExpiryAck", requestId: event.requestId, ok: false, message: "Consent expiry failed." })
2336
+ );
2337
+ return;
2338
+ }
2339
+ if (event.event !== "peerCommit") return;
2340
+ for (const handle of this.#handles) {
2341
+ if (!handle.acceptsPeerCommit(connection, event.handle)) continue;
2342
+ try {
2343
+ handle.spec.onPeerCommit?.(event.info);
2344
+ } catch {
2345
+ }
2346
+ }
2347
+ }
2348
+ forget(handle) {
2349
+ this.#handles.delete(handle);
2350
+ }
2351
+ /** `navigator.storage.persist()`, asked once per storage instance. */
2352
+ persisted() {
2353
+ this.#persisted ??= typeof navigator !== "undefined" && typeof navigator.storage?.persist === "function" ? navigator.storage.persist().catch(() => null) : Promise.resolve(null);
2354
+ return this.#persisted;
2355
+ }
2356
+ #gateTx() {
2357
+ const gateTx = this.#options.gateTx;
2358
+ if (gateTx === void 0) throw gatedError("gate_unavailable", "No lifecycle gate is wired into the replica storage.");
2359
+ return gateTx;
2360
+ }
2361
+ async #recoverBeforeOpen(lifecycle) {
2362
+ this.#recovery ??= (async () => {
2363
+ const controller = new AbortController();
2364
+ const timer = setTimeout(() => controller.abort(), 3e3);
2365
+ const timedOut = new Promise((_resolve, reject) => controller.signal.addEventListener("abort", () => reject(gatedError("gate_unavailable", "Replica startup recovery timed out; local reads remain unavailable.")), { once: true }));
2366
+ try {
2367
+ const report = await Promise.race([lifecycle.recover({ signal: controller.signal }), timedOut]);
2368
+ if (report.failed.length > 0 || report.remaining > 0) {
2369
+ throw gatedError("purge_pending", "Replica startup recovery did not finish; local reads remain unavailable.");
2370
+ }
2371
+ } finally {
2372
+ clearTimeout(timer);
2373
+ }
2374
+ })().catch((error) => {
2375
+ this.#recovery = void 0;
2376
+ throw error;
2377
+ });
2378
+ return this.#recovery;
2379
+ }
2380
+ async open(spec) {
2381
+ assertFolderPrefix2(spec.prefix);
2382
+ const identity = canonicalReplicationIdentity(spec.identity);
2383
+ const lifecycle = this.lifecycle;
2384
+ const stamp = spec.stamp;
2385
+ if (stamp === void 0) throw gatedError("not_established", "The session has no replica stamp.");
2386
+ await this.#recoverBeforeOpen(lifecycle);
2387
+ if (!lifecycle.deliverable(stamp)) throw gatedError("stamp_stale", "The replica session ended.");
2388
+ void this.persisted();
2389
+ const wire = {
2390
+ identity,
2391
+ space: spec.space,
2392
+ prefix: spec.prefix,
2393
+ allowSecrets: spec.allowSecrets,
2394
+ stamp: { incarnation: stamp.incarnation, lifecycleEpoch: stamp.lifecycleEpoch },
2395
+ ...spec.device === void 0 ? {} : { device: { did: spec.device.did, jwk: spec.device.jwk } },
2396
+ grantPolicy: structuredClone(spec.grantPolicy),
2397
+ ...spec.quota === void 0 ? {} : { quota: { ...spec.quota } }
2398
+ };
2399
+ const connection = this.connection();
2400
+ const reply = await connection.call({ op: "open", spec: wire });
2401
+ if (!sameStamp(reply.stamp, stamp) || !lifecycle.deliverable(stamp)) {
2402
+ await connection.call({ op: "close", handle: reply.result.handle }).catch(() => void 0);
2403
+ throw gatedError("stamp_stale", "The replica session ended while the replica opened.");
2404
+ }
2405
+ const handle = new IndexedDbReplicaHandle(this, { lifecycle, stamp }, spec, wire, connection, reply.result);
2406
+ this.#handles.add(handle);
2407
+ return handle;
2408
+ }
2409
+ /** The private lookup index names the stored owner partition without decrypting its device. */
2410
+ async #partitions(db, snapshot, identity, prefix, sessionDeviceDid) {
2411
+ const lookupId = await gateHash(snapshot.gateHmac, "lookup", `${replicationIdentityKey(identity)}
2412
+ ${prefix}`);
2413
+ const indexed = await requestAsPromise(
2414
+ db.transaction(KV_STORE.partitions).objectStore(KV_STORE.partitions).index("byLookupId").getAll(lookupId)
2415
+ );
2416
+ const owners = indexed.filter((item) => item.lookupId === lookupId && item.ownerDevice === true);
2417
+ if (owners.length > 1) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica has multiple owner partitions.");
2418
+ const sessionId = sessionDeviceDid === void 0 ? void 0 : await partitionIdOf({ identity, prefix, deviceDid: sessionDeviceDid }, snapshot.gateHmac);
2419
+ return [...new Set([owners[0]?.partitionId, sessionId].filter((id) => id !== void 0))];
2420
+ }
2421
+ async inspectStatus(spec, { signal }) {
2422
+ signal.throwIfAborted();
2423
+ const controller = new AbortController();
2424
+ const onAbort = () => controller.abort(signal.reason);
2425
+ signal.addEventListener("abort", onAbort, { once: true });
2426
+ const timer = setTimeout(() => controller.abort(new DOMException("Replica status inspection timed out.", "TimeoutError")), 3e3);
2427
+ const aborted = new Promise((_resolve, reject) => {
2428
+ controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true });
2429
+ });
2430
+ try {
2431
+ if (signal.aborted) onAbort();
2432
+ return await Promise.race([this.#inspectRedacted(spec, controller.signal), aborted]);
2433
+ } finally {
2434
+ clearTimeout(timer);
2435
+ signal.removeEventListener("abort", onAbort);
2436
+ }
2437
+ }
2438
+ async #inspectRedacted(spec, signal) {
2439
+ signal.throwIfAborted();
2440
+ const identity = canonicalReplicationIdentity(spec.identity);
2441
+ const snapshot = await this.#gateTx().snapshot();
2442
+ if (snapshot === null) return void 0;
2443
+ const identityKey = replicationIdentityKey(identity);
2444
+ const lookupId = await gateHash(snapshot.gateHmac, "lookup", `${identityKey}
2445
+ ${spec.prefix}`);
2446
+ const principalHash = await gateHash(snapshot.gateHmac, "principal", identity.principal.toLowerCase());
2447
+ const identityKeyHash = await gateHash(snapshot.gateHmac, "identity", identityKey);
2448
+ const injectedId = spec.device === void 0 ? void 0 : await partitionIdOf({ identity, prefix: spec.prefix, deviceDid: spec.device.did }, snapshot.gateHmac);
2449
+ signal.throwIfAborted();
2450
+ const db = await openKvDatabase({ create: false });
2451
+ if (db === null) return void 0;
2452
+ try {
2453
+ const tx = db.transaction([KV_STORE.gate, KV_STORE.device, KV_STORE.partitions, KV_STORE.meta, KV_STORE.purgeIntents], "readonly");
2454
+ const gate = await requestAsPromise(tx.objectStore(KV_STORE.gate).get("account"));
2455
+ if (gate === void 0 || gate.incarnation !== snapshot.incarnation || gate.lifecycleEpoch !== snapshot.lifecycleEpoch || gate.keyId !== snapshot.keyId || gate.accountKeyId !== snapshot.accountKeyId || gate.principalHash !== principalHash) return void 0;
2456
+ const hmac = await requestAsPromise(tx.objectStore(KV_STORE.device).get(GATE_HMAC_KEY));
2457
+ if (!validGateHmac(hmac, gate)) return void 0;
2458
+ signal.throwIfAborted();
2459
+ let partitionId = injectedId;
2460
+ if (partitionId === void 0) {
2461
+ const matches = await requestAsPromise(
2462
+ tx.objectStore(KV_STORE.partitions).index("byLookupId").getAll(lookupId, 64)
2463
+ );
2464
+ const owners = matches.filter((item) => item.ownerDevice === true && item.lookupId === lookupId);
2465
+ if (owners.length > 1) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica has multiple owner partitions.");
2466
+ if (matches.length === 64) {
2467
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The bounded replica owner lookup is inconclusive.");
2468
+ }
2469
+ partitionId = owners[0]?.partitionId;
2470
+ }
2471
+ if (partitionId === void 0) return void 0;
2472
+ const row = await requestAsPromise(tx.objectStore(KV_STORE.meta).get(partitionId));
2473
+ if (row === void 0 || row.purged) return void 0;
2474
+ if (row.pid !== partitionId || ![0, 1, 2].includes(row.coverageCode) || ![row.syncedThroughEpoch, row.keys, row.contentMissing, row.tombstones, row.contentBytes].every((value) => Number.isSafeInteger(value) && value >= 0)) {
2475
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The redacted replica status controls are invalid.");
2476
+ }
2477
+ const intents = tx.objectStore(KV_STORE.purgeIntents);
2478
+ const [partitionIntent, identityIntent] = await Promise.all([
2479
+ requestAsPromise(intents.count(partitionId)),
2480
+ requestAsPromise(intents.count(`pending:${identityKeyHash}`))
2481
+ ]);
2482
+ signal.throwIfAborted();
2483
+ const persisted = typeof navigator.storage?.persisted === "function" ? await navigator.storage.persisted().catch(() => null) : null;
2484
+ signal.throwIfAborted();
2485
+ return {
2486
+ redacted: true,
2487
+ coverage: row.coverageCode === 0 ? "empty" : row.coverageCode === 1 ? "bootstrapping" : "complete",
2488
+ syncedThroughEpoch: row.syncedThroughEpoch,
2489
+ counts: { keys: row.keys, contentMissing: row.contentMissing, tombstones: row.tombstones },
2490
+ bytes: row.contentBytes,
2491
+ persisted,
2492
+ purgePending: partitionIntent > 0 || identityIntent > 0
2493
+ };
2494
+ } finally {
2495
+ db.close();
2496
+ }
2497
+ }
2498
+ #assertPurgeGate(gate, snapshot, principalHash) {
2499
+ if (gate === void 0 || gate.principalHash === null || gate.incarnation !== snapshot.incarnation || gate.lifecycleEpoch !== snapshot.lifecycleEpoch || gate.keyId !== snapshot.keyId || gate.accountKeyId !== snapshot.accountKeyId) {
2500
+ throw gatedError("stamp_stale", "The replica gate moved before purge.");
2501
+ }
2502
+ if (gate.principalHash !== principalHash) throw gatedError("principal_mismatch", "Purge belongs to another account.");
2503
+ }
2504
+ /**
2505
+ * Erase the identity's replica of `target.prefix` under the stored device
2506
+ * and the session device (deduplicated by principal), each in its own
2507
+ * exempt erase transaction that also unindexes the partition and releases
2508
+ * its quota. Never opens, mints or syncs, and never creates the database.
2509
+ * Handles opened earlier fail RESET_REQUIRED on their next call. With no
2510
+ * gate on the origin it rejects REPLICA_GATED `not_established` rather than
2511
+ * reporting an erase it could not address.
2512
+ */
2513
+ async purge(target) {
2514
+ const gateTx = this.#gateTx();
2515
+ const identity = canonicalReplicationIdentity(target.identity);
2516
+ const snapshot = await gateTx.snapshot();
2517
+ if (snapshot === null) throw gatedError("not_established", "No replica gate exists on this origin; there is nothing purge can address.");
2518
+ const principalHash = await gateHash(snapshot.gateHmac, "principal", identity.principal.toLowerCase());
2519
+ let db;
2520
+ try {
2521
+ db = await openKvDatabase({ create: false });
2522
+ } catch (error) {
2523
+ throw idbError(error, "Opening the replica database");
2524
+ }
2525
+ if (db === null) throw gatedError("gate_unavailable", "The replica database disappeared before purge.");
2526
+ try {
2527
+ this.#assertPurgeGate(await requestAsPromise(
2528
+ db.transaction(KV_STORE.gate).objectStore(KV_STORE.gate).get("account")
2529
+ ), snapshot, principalHash);
2530
+ const partitions = await this.#partitions(db, snapshot, identity, target.prefix, target.sessionDeviceDid);
2531
+ let firstError;
2532
+ for (const partitionId of partitions) {
2533
+ try {
2534
+ const tx = db.transaction([...ERASE_STORES, KV_STORE.gate, KV_STORE.partitions, KV_STORE.quota], "readwrite", { durability: "strict" });
2535
+ const done = transactionDone(tx);
2536
+ try {
2537
+ this.#assertPurgeGate(await requestAsPromise(
2538
+ tx.objectStore(KV_STORE.gate).get("account")
2539
+ ), snapshot, principalHash);
2540
+ const { bytes } = await kvPartitionEraser.erasePartition(tx, partitionId);
2541
+ await gateTx.unindexPartition(tx, partitionId);
2542
+ await this.#options.ledger?.accountErase(tx, { partitionId, bytes });
2543
+ await done;
2544
+ } catch (error) {
2545
+ try {
2546
+ tx.abort();
2547
+ } catch {
2548
+ }
2549
+ await done.catch(() => void 0);
2550
+ throw error;
2551
+ }
2552
+ } catch (error) {
2553
+ firstError ??= idbError(error, "Purging the replica");
2554
+ }
2555
+ }
2556
+ if (firstError !== void 0) throw firstError;
2557
+ } finally {
2558
+ db.close();
2559
+ }
2560
+ }
2561
+ pendingWrites(identity, options = {}) {
2562
+ return new IndexedDbPendingWriteStore(identity, {
2563
+ ...this.#options.gateTx === void 0 ? {} : { gateTx: this.#options.gateTx },
2564
+ ...options.stamp === void 0 ? {} : { stamp: options.stamp }
2565
+ });
2566
+ }
2567
+ };
2568
+ function indexedDbReplicaStorage(options = {}) {
2569
+ const ledger = options.ledger ?? ledgerFor();
2570
+ const lifecycle = options.lifecycle ?? createIndexedDbLifecycle({ ledger });
2571
+ const gateTx = options.gateTx ?? createIndexedDbGateTx((create) => openKvDatabase({ create }), {
2572
+ onConsentExpired: () => {
2573
+ if (!("expireConsent" in lifecycle) || typeof lifecycle.expireConsent !== "function") {
2574
+ throw gatedError("gate_unavailable", "The lifecycle cannot expire account consent.");
2575
+ }
2576
+ return lifecycle.expireConsent();
2577
+ }
2578
+ });
2579
+ return new IndexedDbReplicaStorage({ ...options, ledger, gateTx, lifecycle });
2580
+ }
2581
+ export {
2582
+ ReplicaError,
2583
+ ReplicaErrorCode,
2584
+ indexedDbReplicaStorage,
2585
+ isReplicaError
2586
+ };
2587
+ //# sourceMappingURL=kv-storage.js.map