@tinycloud/replica 0.2.4-beta.3 → 0.3.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/browser.d.ts +6 -0
- package/dist/browser.js +7 -1
- package/dist/browser.js.map +1 -1
- package/dist/index.cjs +20 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -3
- package/dist/index.d.ts +15 -3
- package/dist/index.js +20 -2
- package/dist/index.js.map +1 -1
- package/dist/kv-storage-testing.d.ts +83 -0
- package/dist/kv-storage-testing.js +1145 -0
- package/dist/kv-storage-testing.js.map +1 -0
- package/dist/kv-storage.d.ts +155 -0
- package/dist/kv-storage.js +2588 -0
- package/dist/kv-storage.js.map +1 -0
- package/dist/kv-storage.worker.d.ts +2 -0
- package/dist/kv-storage.worker.f6f98ad5dc547a29274c0b539fb78e25f8a2051ccfaafc9490cb307051a0e0ef.js +19004 -0
- package/dist/kv-storage.worker.js +19004 -0
- package/dist/kv-storage.worker.js.map +1 -0
- package/dist/profile-gate.cjs +560 -0
- package/dist/profile-gate.cjs.map +1 -0
- package/dist/profile-gate.d.cts +89 -0
- package/dist/profile-gate.d.ts +89 -0
- package/dist/profile-gate.js +517 -0
- package/dist/profile-gate.js.map +1 -0
- package/dist/replica.worker.js +1276 -891
- package/dist/replica.worker.js.map +1 -1
- package/dist/sqlite.cjs +920 -217
- package/dist/sqlite.cjs.map +1 -1
- package/dist/sqlite.d.cts +101 -8
- package/dist/sqlite.d.ts +101 -8
- package/dist/sqlite.js +896 -195
- package/dist/sqlite.js.map +1 -1
- package/dist/{types-f3-kYLQZ.d.cts → types-CQ61VnsP.d.cts} +1 -1
- package/dist/{types-f3-kYLQZ.d.ts → types-CQ61VnsP.d.ts} +1 -1
- package/package.json +23 -5
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var ReplicaErrorCode = {
|
|
3
|
+
/** Another process holds the sync lease. */
|
|
4
|
+
BUSY: "REPLICA_BUSY",
|
|
5
|
+
/** No replica with this name exists. */
|
|
6
|
+
NOT_FOUND: "REPLICA_NOT_FOUND",
|
|
7
|
+
/** The replica exists but its stored configuration differs from the request. */
|
|
8
|
+
CONFIG_MISMATCH: "REPLICA_CONFIG_MISMATCH",
|
|
9
|
+
/** The runtime has no supported durable store (e.g. Node < 22.13). */
|
|
10
|
+
RUNTIME_UNSUPPORTED: "RUNTIME_UNSUPPORTED",
|
|
11
|
+
/** A required open() argument is missing or malformed (e.g. `principal`). */
|
|
12
|
+
INVALID_ARGUMENT: "REPLICA_INVALID_ARGUMENT",
|
|
13
|
+
/** The local store failed (I/O, corruption, schema). */
|
|
14
|
+
STORAGE_ERROR: "STORAGE_ERROR",
|
|
15
|
+
/** The local disk is full. */
|
|
16
|
+
STORAGE_FULL: "STORAGE_FULL",
|
|
17
|
+
/** The key is outside the replica's prefix. */
|
|
18
|
+
NOT_COVERED: "NOT_COVERED",
|
|
19
|
+
/** Syncing the secrets space or the vault namespace needs an explicit opt-in. */
|
|
20
|
+
SECRETS_OPT_IN_REQUIRED: "SECRETS_OPT_IN_REQUIRED",
|
|
21
|
+
/** No grant is installed, or none covers the requested scope. */
|
|
22
|
+
GRANT_MISSING: "GRANT_MISSING",
|
|
23
|
+
/** The grant bytes are not a verifiable UCAN, or its signature is wrong. */
|
|
24
|
+
GRANT_INVALID: "GRANT_INVALID",
|
|
25
|
+
/** SIWE/CACAO and other non-UCAN grants cannot be used for a replica. */
|
|
26
|
+
GRANT_FORMAT_UNSUPPORTED: "GRANT_FORMAT_UNSUPPORTED",
|
|
27
|
+
/** The grant was issued to a different device. */
|
|
28
|
+
GRANT_AUDIENCE_MISMATCH: "GRANT_AUDIENCE_MISMATCH",
|
|
29
|
+
/** The grant lacks `tinycloud.kv/sync` or `tinycloud.kv/get` on the prefix. */
|
|
30
|
+
GRANT_NOT_COVERING: "GRANT_NOT_COVERING",
|
|
31
|
+
GRANT_NOT_YET_VALID: "GRANT_NOT_YET_VALID",
|
|
32
|
+
GRANT_EXPIRED: "GRANT_EXPIRED",
|
|
33
|
+
/** The configured node does not advertise whole-space KV sync support. */
|
|
34
|
+
REPLICATION_ROOT_UNSUPPORTED: "REPLICATION_ROOT_UNSUPPORTED",
|
|
35
|
+
GRANT_REVOKED: "GRANT_REVOKED",
|
|
36
|
+
/** The node refused the invocation for a reason other than revocation. */
|
|
37
|
+
GRANT_UNAUTHORIZED: "GRANT_UNAUTHORIZED",
|
|
38
|
+
/** The node refused the retention grant. */
|
|
39
|
+
RETENTION_GRANT_REFUSED: "RETENTION_GRANT_REFUSED",
|
|
40
|
+
/** The node could not be reached. */
|
|
41
|
+
NETWORK_ERROR: "NETWORK_ERROR",
|
|
42
|
+
/** The node answered with an unexpected status. */
|
|
43
|
+
NODE_ERROR: "NODE_ERROR",
|
|
44
|
+
/** The node's answer violates the wire contract. */
|
|
45
|
+
PROTOCOL_ERROR: "PROTOCOL_ERROR",
|
|
46
|
+
/** The node asked for a fresh bootstrap (410). Handled inside sync. */
|
|
47
|
+
RESET_REQUIRED: "RESET_REQUIRED",
|
|
48
|
+
/** The feed now comes from a different node than the one pinned. */
|
|
49
|
+
SOURCE_CHANGED: "SOURCE_CHANGED",
|
|
50
|
+
/** The feed delivered a key outside the replica's prefix. */
|
|
51
|
+
SCOPE_VIOLATION: "SCOPE_VIOLATION",
|
|
52
|
+
/** Fetched bytes do not hash to the ETag the node attested for them. */
|
|
53
|
+
CONTENT_MISMATCH: "CONTENT_MISMATCH",
|
|
54
|
+
/** A stored blob no longer hashes to its name. */
|
|
55
|
+
INTEGRITY_ERROR: "INTEGRITY_ERROR",
|
|
56
|
+
/** The replica handle was closed; in-flight calls settle with this code. */
|
|
57
|
+
CLOSED: "REPLICA_CLOSED",
|
|
58
|
+
/** The lifecycle gate refused the operation (shared contract `REPLICA_GATED`); `detail.gate` says why. */
|
|
59
|
+
GATED: "REPLICA_GATED",
|
|
60
|
+
/** Quota admission refused a page (`maxBytes` or the free-space floor); nothing committed. */
|
|
61
|
+
STORAGE_BUDGET: "STORAGE_BUDGET",
|
|
62
|
+
/** The consent deadline passed: the grant is not served until the user consents again. */
|
|
63
|
+
CONSENT_EXPIRED: "CONSENT_EXPIRED"
|
|
64
|
+
};
|
|
65
|
+
var REPLICA_ERROR = /* @__PURE__ */ Symbol.for("tinycloud.replica.error");
|
|
66
|
+
var ReplicaError = class extends Error {
|
|
67
|
+
[REPLICA_ERROR] = true;
|
|
68
|
+
code;
|
|
69
|
+
detail;
|
|
70
|
+
constructor(code, message, detail, options) {
|
|
71
|
+
super(message, options);
|
|
72
|
+
this.name = "ReplicaError";
|
|
73
|
+
this.code = code;
|
|
74
|
+
if (detail !== void 0) this.detail = detail;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
function gatedError(gate, message) {
|
|
78
|
+
return Object.assign(new ReplicaError(ReplicaErrorCode.GATED, message, { gate }), { gate });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ../sdk-services/dist/kv/replication/contractConstants.js
|
|
82
|
+
var HOUR_MS = 60 * 60 * 1e3;
|
|
83
|
+
var DAY_MS = 24 * HOUR_MS;
|
|
84
|
+
var MAX_GRANT_TTL_MS = 7 * DAY_MS;
|
|
85
|
+
var CONSENT_WINDOW_MS = 7 * DAY_MS;
|
|
86
|
+
var DEFAULT_MIN_FREE_BYTES = 64 * 1024 * 1024;
|
|
87
|
+
var REPLICA_KV_DATABASE = "tinycloud-replica-kv";
|
|
88
|
+
var REPLICA_KV_STORES = {
|
|
89
|
+
/** Data: partition meta (opaque fences, sealed config/grants), private gate-HMAC partition id. */
|
|
90
|
+
meta: "meta",
|
|
91
|
+
/** Data: sealed KV entries, key `[privatePartitionId, accountHmac(path)]`. */
|
|
92
|
+
entries: "entries",
|
|
93
|
+
/** Data: AES-GCM content blobs, key `[privatePartitionId, accountHmac(hash)]`. */
|
|
94
|
+
blobs: "blobs",
|
|
95
|
+
/** Data: AES-sealed pending state, key private gate-HMAC `identityKeyHash`. */
|
|
96
|
+
pending: "pending",
|
|
97
|
+
/** Data: sealed owner device, private nonextractable key records and opaque install anchors. */
|
|
98
|
+
device: "device",
|
|
99
|
+
/** Control: the account gate, singleton key `"account"`, value `ReplicaAccountGate`. */
|
|
100
|
+
gate: "gate",
|
|
101
|
+
/** Control: partition index keyed by private `partitionId`; nonunique `byLookupId` index. */
|
|
102
|
+
partitions: "partitions",
|
|
103
|
+
/** Control: purge intents, key `ReplicaPurgeIntent.id`, value `ReplicaPurgeIntent`. */
|
|
104
|
+
purgeIntents: "purgeIntents",
|
|
105
|
+
/** Control: quota ledger, keys owned by the ledger (S5). */
|
|
106
|
+
quota: "quota"
|
|
107
|
+
};
|
|
108
|
+
var REPLICA_LOGOUT_MARKER = "tinycloud-replica:logout-pending";
|
|
109
|
+
var REPLICA_DENY_TOMBSTONE = "tinycloud-replica:deny-tombstone";
|
|
110
|
+
|
|
111
|
+
// src/browser/idb.ts
|
|
112
|
+
function requestAsPromise(request) {
|
|
113
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
114
|
+
request.onsuccess = () => resolve(request.result);
|
|
115
|
+
request.onerror = () => reject(request.error ?? new DOMException("IndexedDB request failed", "UnknownError"));
|
|
116
|
+
return promise;
|
|
117
|
+
}
|
|
118
|
+
function transactionDone(tx) {
|
|
119
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
120
|
+
tx.addEventListener("complete", () => resolve(), { once: true });
|
|
121
|
+
tx.addEventListener("abort", () => reject(tx.error ?? new DOMException("Transaction aborted", "AbortError")), { once: true });
|
|
122
|
+
tx.addEventListener("error", () => reject(tx.error ?? new DOMException("Transaction failed", "UnknownError")), { once: true });
|
|
123
|
+
return promise;
|
|
124
|
+
}
|
|
125
|
+
var byteEncoder = new TextEncoder();
|
|
126
|
+
|
|
127
|
+
// src/kv-storage/gateKeys.ts
|
|
128
|
+
var GATE_HMAC_KEY = "gateHmac";
|
|
129
|
+
var ACCOUNT_KEY = "accountKey";
|
|
130
|
+
var HEX = "0123456789abcdef";
|
|
131
|
+
var ENCODER = new TextEncoder();
|
|
132
|
+
function hex(bytes) {
|
|
133
|
+
let value = "";
|
|
134
|
+
for (const byte of bytes) value += HEX.charAt(byte >>> 4) + HEX.charAt(byte & 15);
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
function keyId() {
|
|
138
|
+
return hex(crypto.getRandomValues(new Uint8Array(16)));
|
|
139
|
+
}
|
|
140
|
+
async function newGateHmac(incarnation) {
|
|
141
|
+
const hmac = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256", length: 256 }, false, ["sign", "verify"]);
|
|
142
|
+
return { v: 1, incarnation, keyId: keyId(), hmac };
|
|
143
|
+
}
|
|
144
|
+
async function newAccountKey(incarnation, lifecycleEpoch, principalHash) {
|
|
145
|
+
const [aesGcm, hmac] = await Promise.all([
|
|
146
|
+
crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]),
|
|
147
|
+
crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256", length: 256 }, false, ["sign", "verify"])
|
|
148
|
+
]);
|
|
149
|
+
return { v: 1, incarnation, lifecycleEpoch, principalHash, keyId: keyId(), aesGcm, hmac };
|
|
150
|
+
}
|
|
151
|
+
async function gateHash(key, domain, material) {
|
|
152
|
+
const bytes = ENCODER.encode(`${domain}\0${material}`);
|
|
153
|
+
return hex(new Uint8Array(await crypto.subtle.sign("HMAC", key, bytes)));
|
|
154
|
+
}
|
|
155
|
+
function isHmac(key) {
|
|
156
|
+
if (!(key instanceof CryptoKey) || key.extractable || key.algorithm.name !== "HMAC") return false;
|
|
157
|
+
const algorithm = key.algorithm;
|
|
158
|
+
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");
|
|
159
|
+
}
|
|
160
|
+
function isAesGcm(key) {
|
|
161
|
+
if (!(key instanceof CryptoKey) || key.extractable || key.algorithm.name !== "AES-GCM") return false;
|
|
162
|
+
return "length" in key.algorithm && key.algorithm.length === 256 && key.usages.length === 2 && key.usages.includes("encrypt") && key.usages.includes("decrypt");
|
|
163
|
+
}
|
|
164
|
+
function validGateHmac(record, gate) {
|
|
165
|
+
return record?.v === 1 && record.incarnation === gate.incarnation && record.keyId === gate.keyId && /^[0-9a-f]{32}$/.test(record.keyId) && isHmac(record.hmac);
|
|
166
|
+
}
|
|
167
|
+
function validAccountKey(record, gate) {
|
|
168
|
+
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);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/kv-storage/sealed.ts
|
|
172
|
+
import { bytesToHex } from "@noble/hashes/utils";
|
|
173
|
+
var encoder = new TextEncoder();
|
|
174
|
+
function utf8Length(value) {
|
|
175
|
+
let bytes = 0;
|
|
176
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
177
|
+
const code = value.charCodeAt(i);
|
|
178
|
+
if (code < 128) bytes += 1;
|
|
179
|
+
else if (code < 2048) bytes += 2;
|
|
180
|
+
else if (code >= 55296 && code <= 56319 && i + 1 < value.length && value.charCodeAt(i + 1) >= 56320 && value.charCodeAt(i + 1) <= 57343) {
|
|
181
|
+
bytes += 4;
|
|
182
|
+
i += 1;
|
|
183
|
+
} else bytes += 3;
|
|
184
|
+
}
|
|
185
|
+
return bytes;
|
|
186
|
+
}
|
|
187
|
+
function measureSealedRow(key, row) {
|
|
188
|
+
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)) {
|
|
189
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has an invalid sealed envelope.");
|
|
190
|
+
}
|
|
191
|
+
const primaryKey = JSON.stringify(key);
|
|
192
|
+
if (typeof primaryKey !== "string") {
|
|
193
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has an invalid primary key.");
|
|
194
|
+
}
|
|
195
|
+
let total = 2 + 4 + utf8Length(primaryKey);
|
|
196
|
+
let hasSize = false;
|
|
197
|
+
for (const name in row) {
|
|
198
|
+
if (!Object.hasOwn(row, name)) continue;
|
|
199
|
+
const value = Reflect.get(row, name);
|
|
200
|
+
total += 1 + name.length;
|
|
201
|
+
switch (name) {
|
|
202
|
+
case "pid":
|
|
203
|
+
case "hashId":
|
|
204
|
+
if (typeof value !== "string") throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has an invalid identifier.");
|
|
205
|
+
total += 4 + utf8Length(value);
|
|
206
|
+
break;
|
|
207
|
+
case "v":
|
|
208
|
+
if (value !== 1) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has an unknown version.");
|
|
209
|
+
total += 1;
|
|
210
|
+
break;
|
|
211
|
+
case "coverageCode":
|
|
212
|
+
if (value !== 0 && value !== 1 && value !== 2) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has invalid coverage.");
|
|
213
|
+
total += 1;
|
|
214
|
+
break;
|
|
215
|
+
case "purged":
|
|
216
|
+
if (typeof value !== "boolean") throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has invalid purge state.");
|
|
217
|
+
total += 1;
|
|
218
|
+
break;
|
|
219
|
+
case "revision":
|
|
220
|
+
case "generation":
|
|
221
|
+
case "syncedThroughEpoch":
|
|
222
|
+
case "keys":
|
|
223
|
+
case "contentMissing":
|
|
224
|
+
case "tombstones":
|
|
225
|
+
case "contentBytes":
|
|
226
|
+
case "size":
|
|
227
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
228
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has an invalid numeric field.");
|
|
229
|
+
}
|
|
230
|
+
if (name === "size") hasSize = true;
|
|
231
|
+
total += 8;
|
|
232
|
+
break;
|
|
233
|
+
case "iv":
|
|
234
|
+
case "bytes":
|
|
235
|
+
if (!(value instanceof Uint8Array)) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has invalid ciphertext.");
|
|
236
|
+
total += 4 + value.byteLength;
|
|
237
|
+
break;
|
|
238
|
+
default:
|
|
239
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica row has an unknown persisted field.");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (!hasSize) total += 1 + "size".length + 8;
|
|
243
|
+
return total;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/kv-storage/schema.ts
|
|
247
|
+
var KV_DATABASE = REPLICA_KV_DATABASE;
|
|
248
|
+
var KV_DATABASE_VERSION = 2;
|
|
249
|
+
var KV_STORE = REPLICA_KV_STORES;
|
|
250
|
+
var FIRST_INSTALL_KEY_PREFIX = "firstInstall:";
|
|
251
|
+
var ERASE_STORES = [KV_STORE.meta, KV_STORE.entries, KV_STORE.blobs, KV_STORE.pending];
|
|
252
|
+
var READ_SCOPE = [KV_STORE.gate, KV_STORE.purgeIntents];
|
|
253
|
+
var WRITE_SCOPE = [KV_STORE.gate, KV_STORE.partitions, KV_STORE.purgeIntents, KV_STORE.quota, KV_STORE.device];
|
|
254
|
+
function createStores(db) {
|
|
255
|
+
db.createObjectStore(KV_STORE.meta);
|
|
256
|
+
const entries = db.createObjectStore(KV_STORE.entries);
|
|
257
|
+
entries.createIndex("byHash", ["pid", "hash"], { unique: false });
|
|
258
|
+
const blobs = db.createObjectStore(KV_STORE.blobs);
|
|
259
|
+
blobs.createIndex("bySize", ["pid", "size"], { unique: false });
|
|
260
|
+
const partitions = db.createObjectStore(KV_STORE.partitions);
|
|
261
|
+
partitions.createIndex("byLookupId", "lookupId", { unique: false });
|
|
262
|
+
for (const name of [KV_STORE.pending, KV_STORE.device, KV_STORE.gate, KV_STORE.purgeIntents, KV_STORE.quota]) {
|
|
263
|
+
db.createObjectStore(name);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function openKvDatabase(options) {
|
|
267
|
+
if (typeof indexedDB === "undefined") {
|
|
268
|
+
return Promise.reject(
|
|
269
|
+
new ReplicaError(
|
|
270
|
+
ReplicaErrorCode.RUNTIME_UNSUPPORTED,
|
|
271
|
+
"IndexedDB is unavailable (private browsing or a non-browser runtime); the replica cannot persist."
|
|
272
|
+
)
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
276
|
+
const request = options.create ? indexedDB.open(KV_DATABASE, KV_DATABASE_VERSION) : indexedDB.open(KV_DATABASE);
|
|
277
|
+
let refused = false;
|
|
278
|
+
request.onupgradeneeded = (event) => {
|
|
279
|
+
if (!options.create && event.oldVersion === 0) {
|
|
280
|
+
refused = true;
|
|
281
|
+
request.transaction?.abort();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (event.oldVersion > 0) {
|
|
285
|
+
for (const name of Array.from(request.result.objectStoreNames)) request.result.deleteObjectStore(name);
|
|
286
|
+
}
|
|
287
|
+
createStores(request.result);
|
|
288
|
+
};
|
|
289
|
+
request.onsuccess = () => {
|
|
290
|
+
const db = request.result;
|
|
291
|
+
if (db.version !== KV_DATABASE_VERSION) {
|
|
292
|
+
db.close();
|
|
293
|
+
resolve(null);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
db.onversionchange = () => db.close();
|
|
297
|
+
resolve(db);
|
|
298
|
+
};
|
|
299
|
+
request.onerror = (event) => {
|
|
300
|
+
if (refused) {
|
|
301
|
+
event.preventDefault();
|
|
302
|
+
resolve(null);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
reject(request.error ?? new DOMException(`Opening ${KV_DATABASE} failed`, "UnknownError"));
|
|
306
|
+
};
|
|
307
|
+
request.onblocked = () => reject(new ReplicaError(ReplicaErrorCode.BUSY, `Opening ${KV_DATABASE} is blocked by an older connection in another tab.`));
|
|
308
|
+
return promise;
|
|
309
|
+
}
|
|
310
|
+
function eraseSealedRows(tx, name, partitionId) {
|
|
311
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
312
|
+
const range = IDBKeyRange.bound([partitionId], [partitionId, []]);
|
|
313
|
+
const request = tx.objectStore(name).openCursor(range);
|
|
314
|
+
let removed = 0;
|
|
315
|
+
request.onsuccess = () => {
|
|
316
|
+
const cursor = request.result;
|
|
317
|
+
if (cursor === null) return resolve(removed);
|
|
318
|
+
try {
|
|
319
|
+
const row = cursor.value;
|
|
320
|
+
if (row === null || typeof row !== "object" || !("pid" in row) || row.pid !== partitionId || !("size" in row) || typeof row.size !== "number") {
|
|
321
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The encrypted replica row has an invalid partition or byte measure.");
|
|
322
|
+
}
|
|
323
|
+
const measured = measureSealedRow(cursor.primaryKey, row);
|
|
324
|
+
if (row.size !== measured || !Number.isSafeInteger(removed + measured)) {
|
|
325
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The encrypted replica row has an invalid byte measure.");
|
|
326
|
+
}
|
|
327
|
+
removed += measured;
|
|
328
|
+
cursor.delete();
|
|
329
|
+
cursor.continue();
|
|
330
|
+
} catch (error) {
|
|
331
|
+
reject(error);
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
request.onerror = () => reject(request.error);
|
|
335
|
+
return promise;
|
|
336
|
+
}
|
|
337
|
+
var kvPartitionEraser = {
|
|
338
|
+
async erasePartition(tx, partitionId) {
|
|
339
|
+
const meta = tx.objectStore(KV_STORE.meta);
|
|
340
|
+
const old = await requestAsPromise(meta.get(partitionId));
|
|
341
|
+
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))) {
|
|
342
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The encrypted replica metadata is invalid.");
|
|
343
|
+
}
|
|
344
|
+
const [entries, blobs] = await Promise.all([
|
|
345
|
+
eraseSealedRows(tx, KV_STORE.entries, partitionId),
|
|
346
|
+
eraseSealedRows(tx, KV_STORE.blobs, partitionId)
|
|
347
|
+
]);
|
|
348
|
+
if (old === void 0) return { bytes: entries + blobs };
|
|
349
|
+
const current = old;
|
|
350
|
+
if (current.purged === true) {
|
|
351
|
+
if (entries !== 0 || blobs !== 0) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "An erased replica still owns ciphertext rows.");
|
|
352
|
+
return { bytes: 0 };
|
|
353
|
+
}
|
|
354
|
+
const tombstone = {
|
|
355
|
+
pid: partitionId,
|
|
356
|
+
revision: current.revision + 1,
|
|
357
|
+
generation: current.generation + 1,
|
|
358
|
+
coverageCode: 0,
|
|
359
|
+
syncedThroughEpoch: 0,
|
|
360
|
+
keys: 0,
|
|
361
|
+
contentMissing: 0,
|
|
362
|
+
tombstones: 0,
|
|
363
|
+
contentBytes: 0,
|
|
364
|
+
purged: true,
|
|
365
|
+
size: 0
|
|
366
|
+
};
|
|
367
|
+
tombstone.size = measureSealedRow(partitionId, tombstone);
|
|
368
|
+
const bytes = current.size + entries + blobs - tombstone.size;
|
|
369
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The erased replica has an invalid byte delta.");
|
|
370
|
+
meta.put(tombstone, partitionId);
|
|
371
|
+
return { bytes };
|
|
372
|
+
},
|
|
373
|
+
async eraseIdentityPending(tx, identityKeyHash) {
|
|
374
|
+
tx.objectStore(KV_STORE.pending).delete(identityKeyHash);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// src/kv-storage/gateTx.ts
|
|
379
|
+
var ACCOUNT = "account";
|
|
380
|
+
var CONTROL = [KV_STORE.gate, KV_STORE.partitions, KV_STORE.purgeIntents, KV_STORE.quota, KV_STORE.device];
|
|
381
|
+
function refusal(reason) {
|
|
382
|
+
return gatedError(reason, `The replica gate refused: ${reason}.`);
|
|
383
|
+
}
|
|
384
|
+
async function withDatabase(open, create, use) {
|
|
385
|
+
const db = await open(create);
|
|
386
|
+
if (db === null) return null;
|
|
387
|
+
try {
|
|
388
|
+
return await use(db);
|
|
389
|
+
} finally {
|
|
390
|
+
db.close();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function createIndexedDbGateTx(open, options) {
|
|
394
|
+
if (typeof options?.onConsentExpired !== "function") throw refusal("gate_unavailable");
|
|
395
|
+
const now = options.now ?? Date.now;
|
|
396
|
+
function checkedNow() {
|
|
397
|
+
const at = now();
|
|
398
|
+
if (!Number.isSafeInteger(at) || at < 0) throw refusal("gate_unavailable");
|
|
399
|
+
return at;
|
|
400
|
+
}
|
|
401
|
+
let expiryFlight;
|
|
402
|
+
async function snapshot() {
|
|
403
|
+
return withDatabase(open, false, async (db) => {
|
|
404
|
+
const tx = db.transaction([KV_STORE.gate, KV_STORE.device]);
|
|
405
|
+
const gate = await requestAsPromise(tx.objectStore(KV_STORE.gate).get(ACCOUNT));
|
|
406
|
+
if (gate === void 0 || gate.principalHash === null) return null;
|
|
407
|
+
if (typeof gate.consentDeadline !== "number" || !Number.isSafeInteger(gate.consentDeadline) || gate.consentDeadline < 0) throw refusal("gate_unavailable");
|
|
408
|
+
const hash = await requestAsPromise(tx.objectStore(KV_STORE.device).get(GATE_HMAC_KEY));
|
|
409
|
+
if (!validGateHmac(hash, gate)) throw refusal("gate_unavailable");
|
|
410
|
+
return { incarnation: gate.incarnation, salt: gate.salt, lifecycleEpoch: gate.lifecycleEpoch, keyId: hash.keyId, accountKeyId: gate.accountKeyId, gateHmac: hash.hmac };
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
async function expire(access) {
|
|
414
|
+
let flight;
|
|
415
|
+
try {
|
|
416
|
+
flight = expiryFlight ?? options.onConsentExpired();
|
|
417
|
+
expiryFlight = flight;
|
|
418
|
+
const outcome = await flight;
|
|
419
|
+
if (outcome.status !== "denied" && outcome.status !== "deleted" && outcome.status !== "stale") throw refusal("gate_unavailable");
|
|
420
|
+
const gate = await withDatabase(
|
|
421
|
+
open,
|
|
422
|
+
false,
|
|
423
|
+
(db) => requestAsPromise(db.transaction(KV_STORE.gate).objectStore(KV_STORE.gate).get(ACCOUNT))
|
|
424
|
+
);
|
|
425
|
+
if (gate !== null && (gate === void 0 || gate.incarnation === access.incarnation && (gate.lifecycleEpoch <= access.stamp.lifecycleEpoch || gate.principalHash !== null))) {
|
|
426
|
+
throw refusal("gate_unavailable");
|
|
427
|
+
}
|
|
428
|
+
} catch {
|
|
429
|
+
throw refusal("gate_unavailable");
|
|
430
|
+
} finally {
|
|
431
|
+
if (flight !== void 0 && expiryFlight === flight) expiryFlight = void 0;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
async function runChecked(scope, access, fn) {
|
|
435
|
+
if (access === null || typeof access !== "object" || access.stamp === null || typeof access.stamp !== "object") {
|
|
436
|
+
throw refusal("not_established");
|
|
437
|
+
}
|
|
438
|
+
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");
|
|
439
|
+
try {
|
|
440
|
+
const result = await withDatabase(open, false, async (db) => {
|
|
441
|
+
const tx = db.transaction([...scope.stores, ...CONTROL], scope.mode, { durability: "strict" });
|
|
442
|
+
const done = transactionDone(tx);
|
|
443
|
+
try {
|
|
444
|
+
const gate = await requestAsPromise(tx.objectStore(KV_STORE.gate).get(ACCOUNT));
|
|
445
|
+
if (gate === void 0) throw refusal("not_established");
|
|
446
|
+
if (gate.incarnation !== access.incarnation || gate.incarnation !== access.stamp.incarnation || gate.lifecycleEpoch !== access.stamp.lifecycleEpoch || gate.principalHash === null) throw refusal("stamp_stale");
|
|
447
|
+
if (gate.keyId !== access.keyId || gate.accountKeyId !== access.accountKeyId) throw refusal("stamp_stale");
|
|
448
|
+
if (gate.principalHash !== access.principalHash) throw refusal("principal_mismatch");
|
|
449
|
+
if (typeof gate.consentDeadline !== "number" || !Number.isSafeInteger(gate.consentDeadline) || gate.consentDeadline < 0) throw refusal("gate_unavailable");
|
|
450
|
+
const device = tx.objectStore(KV_STORE.device);
|
|
451
|
+
const hash = await requestAsPromise(device.get(GATE_HMAC_KEY));
|
|
452
|
+
const account = await requestAsPromise(device.get(ACCOUNT_KEY));
|
|
453
|
+
if (!validGateHmac(hash, gate) || !validAccountKey(account, gate)) throw refusal("gate_unavailable");
|
|
454
|
+
const intents = tx.objectStore(KV_STORE.purgeIntents);
|
|
455
|
+
if (access.partitionId !== void 0 && await requestAsPromise(intents.count(access.partitionId)) > 0) throw refusal("purge_pending");
|
|
456
|
+
if (access.identityKeyHash !== void 0 && await requestAsPromise(intents.count(`pending:${access.identityKeyHash}`)) > 0) throw refusal("purge_pending");
|
|
457
|
+
if (checkedNow() >= gate.consentDeadline) throw new ReplicaError(ReplicaErrorCode.CONSENT_EXPIRED, "The account consent has expired.");
|
|
458
|
+
const value = await fn(tx, account);
|
|
459
|
+
await done;
|
|
460
|
+
return { value };
|
|
461
|
+
} catch (error) {
|
|
462
|
+
try {
|
|
463
|
+
tx.abort();
|
|
464
|
+
} catch {
|
|
465
|
+
}
|
|
466
|
+
await done.catch(() => void 0);
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
if (result === null) throw refusal("gate_unavailable");
|
|
471
|
+
return result.value;
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (error !== null && typeof error === "object" && "code" in error && error.code === ReplicaErrorCode.CONSENT_EXPIRED) {
|
|
474
|
+
await expire(access);
|
|
475
|
+
}
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
snapshot,
|
|
481
|
+
run: (scope, access, fn) => runChecked(scope, access, (tx) => fn(tx)),
|
|
482
|
+
readAccountKey: (access) => runChecked({ stores: [KV_STORE.device], mode: "readonly" }, access, async (_tx, account) => account),
|
|
483
|
+
async indexPartition(tx, entry) {
|
|
484
|
+
tx.objectStore(KV_STORE.partitions).put(entry, entry.partitionId);
|
|
485
|
+
},
|
|
486
|
+
async unindexPartition(tx, partitionId) {
|
|
487
|
+
tx.objectStore(KV_STORE.partitions).delete(partitionId);
|
|
488
|
+
},
|
|
489
|
+
async clearIntent(tx, id) {
|
|
490
|
+
tx.objectStore(KV_STORE.purgeIntents).delete(id);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// src/kv-storage/lifecycle.ts
|
|
496
|
+
var ACCOUNT2 = "account";
|
|
497
|
+
var DEVICE_KEY = "device";
|
|
498
|
+
var GRANT_INSTALL_KEY_PREFIX = "grantInstall:";
|
|
499
|
+
var GATE_LOCK = "tinycloud-replica-kv:gate";
|
|
500
|
+
var DELETE_BLOCKED = "DELETE_BLOCKED";
|
|
501
|
+
var denyFaults = {};
|
|
502
|
+
function takeFault(point) {
|
|
503
|
+
const fault = denyFaults[point];
|
|
504
|
+
if (fault === void 0) return false;
|
|
505
|
+
if (fault.once) delete denyFaults[point];
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
function billedDeviceBytes(row) {
|
|
509
|
+
if (row === void 0 || typeof row === "number" && Number.isSafeInteger(row) && row >= 0) return 0;
|
|
510
|
+
if (row !== null && typeof row === "object" && "size" in row && typeof row.size === "number" && Number.isSafeInteger(row.size) && row.size >= 0) {
|
|
511
|
+
return row.size;
|
|
512
|
+
}
|
|
513
|
+
throw gatedError("gate_unavailable", "A device quota row has no valid encoded size.");
|
|
514
|
+
}
|
|
515
|
+
function openKvStore(create) {
|
|
516
|
+
return openKvDatabase({ create });
|
|
517
|
+
}
|
|
518
|
+
async function readLegacyGateFloor() {
|
|
519
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
520
|
+
const request = indexedDB.open(KV_DATABASE);
|
|
521
|
+
let absent = false;
|
|
522
|
+
request.onupgradeneeded = () => {
|
|
523
|
+
absent = true;
|
|
524
|
+
request.transaction?.abort();
|
|
525
|
+
};
|
|
526
|
+
request.onsuccess = async () => {
|
|
527
|
+
const db = request.result;
|
|
528
|
+
try {
|
|
529
|
+
if (db.version !== 1) return resolve(null);
|
|
530
|
+
if (!db.objectStoreNames.contains(KV_STORE.gate)) return resolve({ epoch: 0, deniedAt: null });
|
|
531
|
+
const value = await requestAsPromise(db.transaction(KV_STORE.gate).objectStore(KV_STORE.gate).get(ACCOUNT2));
|
|
532
|
+
if (value === void 0) return resolve({ epoch: 0, deniedAt: null });
|
|
533
|
+
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)) {
|
|
534
|
+
throw gatedError("gate_unavailable", "The legacy gate deny floor is invalid.");
|
|
535
|
+
}
|
|
536
|
+
resolve({ epoch: value.lifecycleEpoch, deniedAt: value.deniedAt });
|
|
537
|
+
} catch (error) {
|
|
538
|
+
reject(error);
|
|
539
|
+
} finally {
|
|
540
|
+
db.close();
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
request.onerror = (event) => {
|
|
544
|
+
if (absent) {
|
|
545
|
+
event.preventDefault();
|
|
546
|
+
resolve(null);
|
|
547
|
+
} else {
|
|
548
|
+
reject(gatedError("gate_unavailable", "The legacy gate deny floor cannot be read."));
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
request.onblocked = () => reject(gatedError("gate_unavailable", "The legacy gate read is blocked."));
|
|
552
|
+
return promise;
|
|
553
|
+
}
|
|
554
|
+
function preserveLegacyFloor(legacy, tombstone) {
|
|
555
|
+
if (legacy?.deniedAt === null || legacy === null) return { tombstone, advanced: false };
|
|
556
|
+
const floor = {
|
|
557
|
+
epoch: Math.max(legacy.epoch, tombstone?.epoch ?? 0),
|
|
558
|
+
deniedAt: Math.max(legacy.deniedAt, tombstone?.deniedAt ?? 0)
|
|
559
|
+
};
|
|
560
|
+
if (tombstone !== null && tombstone.epoch >= floor.epoch && tombstone.deniedAt >= floor.deniedAt) {
|
|
561
|
+
return { tombstone, advanced: false };
|
|
562
|
+
}
|
|
563
|
+
writeTombstone(floor);
|
|
564
|
+
return { tombstone: floor, advanced: true };
|
|
565
|
+
}
|
|
566
|
+
function codeOf(error) {
|
|
567
|
+
if (error !== null && typeof error === "object" && "code" in error && error.code === "REPLICA_GATED" && "gate" in error && typeof error.gate === "string") return error.gate;
|
|
568
|
+
return error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "STORAGE_ERROR";
|
|
569
|
+
}
|
|
570
|
+
function logoutMarkerSet() {
|
|
571
|
+
try {
|
|
572
|
+
return localStorage.getItem(REPLICA_LOGOUT_MARKER) !== null;
|
|
573
|
+
} catch {
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function readTombstone() {
|
|
578
|
+
try {
|
|
579
|
+
const stored = localStorage.getItem(REPLICA_DENY_TOMBSTONE);
|
|
580
|
+
if (stored === null) return null;
|
|
581
|
+
const value = JSON.parse(stored);
|
|
582
|
+
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.");
|
|
583
|
+
return value;
|
|
584
|
+
} catch {
|
|
585
|
+
throw gatedError("gate_unavailable", "The origin deny tombstone cannot be read.");
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function writeTombstone(value) {
|
|
589
|
+
try {
|
|
590
|
+
const serialized = JSON.stringify(value);
|
|
591
|
+
localStorage.setItem(REPLICA_DENY_TOMBSTONE, serialized);
|
|
592
|
+
if (localStorage.getItem(REPLICA_DENY_TOMBSTONE) !== serialized) throw new Error("The deny tombstone was not persisted.");
|
|
593
|
+
} catch {
|
|
594
|
+
throw gatedError("gate_unavailable", "The origin deny tombstone cannot be written.");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function assertTombstoneWritable() {
|
|
598
|
+
try {
|
|
599
|
+
const stored = localStorage.getItem(REPLICA_DENY_TOMBSTONE);
|
|
600
|
+
if (stored === null) {
|
|
601
|
+
const probe = `${REPLICA_DENY_TOMBSTONE}:probe`;
|
|
602
|
+
localStorage.setItem(probe, "1");
|
|
603
|
+
if (localStorage.getItem(probe) !== "1") throw new Error("The deny tombstone probe was not persisted.");
|
|
604
|
+
localStorage.removeItem(probe);
|
|
605
|
+
} else {
|
|
606
|
+
localStorage.setItem(REPLICA_DENY_TOMBSTONE, stored);
|
|
607
|
+
if (localStorage.getItem(REPLICA_DENY_TOMBSTONE) !== stored) throw new Error("The deny tombstone was not persisted.");
|
|
608
|
+
}
|
|
609
|
+
} catch {
|
|
610
|
+
throw gatedError("gate_unavailable", "The origin deny tombstone cannot be written.");
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
function denyFloor(gate, tombstone) {
|
|
614
|
+
if (gate !== void 0 && tombstone === null && (gate.lifecycleEpoch > 0 || gate.deniedAt !== null)) {
|
|
615
|
+
throw gatedError("gate_unavailable", "A denied gate has no origin deny tombstone.");
|
|
616
|
+
}
|
|
617
|
+
if (gate !== void 0 && tombstone !== null && gate.lifecycleEpoch < tombstone.epoch) {
|
|
618
|
+
throw gatedError("gate_unavailable", "The gate is behind the origin deny tombstone.");
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
epoch: Math.max(gate?.lifecycleEpoch ?? 0, tombstone?.epoch ?? 0),
|
|
622
|
+
deniedAt: gate?.deniedAt === null || gate?.deniedAt === void 0 ? tombstone?.deniedAt ?? null : Math.max(gate.deniedAt, tombstone?.deniedAt ?? 0)
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
function exclusive(name, section, signal) {
|
|
626
|
+
if (typeof navigator === "undefined" || typeof navigator.locks?.request !== "function") {
|
|
627
|
+
return Promise.reject(gatedError("gate_unavailable", "The origin Web Lock is unavailable."));
|
|
628
|
+
}
|
|
629
|
+
return signal === void 0 ? navigator.locks.request(name, section) : navigator.locks.request(name, { signal }, section);
|
|
630
|
+
}
|
|
631
|
+
function deleteKvDatabase() {
|
|
632
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
633
|
+
if (takeFault("delete_database")) {
|
|
634
|
+
resolve({ status: "failed", code: "STORAGE_ERROR" });
|
|
635
|
+
return promise;
|
|
636
|
+
}
|
|
637
|
+
const request = indexedDB.deleteDatabase(KV_DATABASE);
|
|
638
|
+
request.onsuccess = () => resolve({ status: "deleted" });
|
|
639
|
+
request.onerror = () => resolve({ status: "failed", code: "STORAGE_ERROR" });
|
|
640
|
+
request.onblocked = () => resolve({ status: "failed", code: DELETE_BLOCKED });
|
|
641
|
+
return promise;
|
|
642
|
+
}
|
|
643
|
+
function setLogoutMarker() {
|
|
644
|
+
try {
|
|
645
|
+
const marker = String(Date.now());
|
|
646
|
+
localStorage.setItem(REPLICA_LOGOUT_MARKER, marker);
|
|
647
|
+
return localStorage.getItem(REPLICA_LOGOUT_MARKER) === marker;
|
|
648
|
+
} catch {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
async function transact(db, stores, mode, use) {
|
|
653
|
+
const tx = db.transaction(stores, mode, { durability: "strict" });
|
|
654
|
+
const done = transactionDone(tx);
|
|
655
|
+
let value;
|
|
656
|
+
try {
|
|
657
|
+
value = await use(tx);
|
|
658
|
+
} catch (error) {
|
|
659
|
+
try {
|
|
660
|
+
tx.abort();
|
|
661
|
+
} catch {
|
|
662
|
+
}
|
|
663
|
+
await done.catch(() => void 0);
|
|
664
|
+
throw error;
|
|
665
|
+
}
|
|
666
|
+
await done;
|
|
667
|
+
return value;
|
|
668
|
+
}
|
|
669
|
+
function createIndexedDbLifecycle(options = {}) {
|
|
670
|
+
const eraser = options.eraser ?? kvPartitionEraser;
|
|
671
|
+
const ledger = options.ledger;
|
|
672
|
+
const now = options.now ?? Date.now;
|
|
673
|
+
function checkedNow() {
|
|
674
|
+
const at = now();
|
|
675
|
+
if (!Number.isSafeInteger(at) || at < 0) throw gatedError("gate_unavailable", "The replica clock is unavailable.");
|
|
676
|
+
return at;
|
|
677
|
+
}
|
|
678
|
+
function deadlineOf(gate) {
|
|
679
|
+
const deadline = gate.consentDeadline;
|
|
680
|
+
if (typeof deadline !== "number" || !Number.isSafeInteger(deadline) || deadline < 0) {
|
|
681
|
+
throw gatedError("gate_unavailable", "The account gate has no valid consent deadline.");
|
|
682
|
+
}
|
|
683
|
+
return deadline;
|
|
684
|
+
}
|
|
685
|
+
const gateTx = createIndexedDbGateTx(openKvStore, { now, onConsentExpired: expireConsent });
|
|
686
|
+
let view;
|
|
687
|
+
let expiryTimer;
|
|
688
|
+
const expiryObservers = /* @__PURE__ */ new Set();
|
|
689
|
+
function adopt(gate, principal) {
|
|
690
|
+
const stamp = { incarnation: gate.incarnation, lifecycleEpoch: gate.lifecycleEpoch };
|
|
691
|
+
view = { ...stamp, consentDeadline: deadlineOf(gate), principal: principal.toLowerCase() };
|
|
692
|
+
clearTimeout(expiryTimer);
|
|
693
|
+
armExpiryTimer();
|
|
694
|
+
return stamp;
|
|
695
|
+
}
|
|
696
|
+
function armExpiryTimer() {
|
|
697
|
+
if (view === void 0) return;
|
|
698
|
+
const delay = Math.max(0, Math.min(view.consentDeadline - checkedNow(), 2147483647));
|
|
699
|
+
expiryTimer = setTimeout(() => {
|
|
700
|
+
expiryTimer = void 0;
|
|
701
|
+
if (view === void 0) return;
|
|
702
|
+
try {
|
|
703
|
+
if (checkedNow() < view.consentDeadline) armExpiryTimer();
|
|
704
|
+
else void expireConsent();
|
|
705
|
+
} catch {
|
|
706
|
+
view = void 0;
|
|
707
|
+
}
|
|
708
|
+
}, delay);
|
|
709
|
+
}
|
|
710
|
+
async function readGate() {
|
|
711
|
+
const found = await withDatabase(
|
|
712
|
+
openKvStore,
|
|
713
|
+
false,
|
|
714
|
+
(db) => requestAsPromise(db.transaction(KV_STORE.gate).objectStore(KV_STORE.gate).get(ACCOUNT2))
|
|
715
|
+
);
|
|
716
|
+
return found ?? void 0;
|
|
717
|
+
}
|
|
718
|
+
async function activeKeys(gate) {
|
|
719
|
+
const records = await withDatabase(openKvStore, false, async (db) => {
|
|
720
|
+
const device = db.transaction(KV_STORE.device).objectStore(KV_STORE.device);
|
|
721
|
+
const hash = await requestAsPromise(device.get(GATE_HMAC_KEY));
|
|
722
|
+
const account = await requestAsPromise(device.get(ACCOUNT_KEY));
|
|
723
|
+
return { hash, account };
|
|
724
|
+
});
|
|
725
|
+
if (records === null || !validGateHmac(records.hash, gate) || !validAccountKey(records.account, gate)) {
|
|
726
|
+
throw gatedError("gate_unavailable", "The account gate encryption keys are missing or mismatched.");
|
|
727
|
+
}
|
|
728
|
+
return { hash: records.hash, account: records.account };
|
|
729
|
+
}
|
|
730
|
+
async function readIntents() {
|
|
731
|
+
const found = await withDatabase(
|
|
732
|
+
openKvStore,
|
|
733
|
+
false,
|
|
734
|
+
(db) => requestAsPromise(db.transaction(KV_STORE.purgeIntents).objectStore(KV_STORE.purgeIntents).getAll())
|
|
735
|
+
);
|
|
736
|
+
return found ?? [];
|
|
737
|
+
}
|
|
738
|
+
async function writeGate(gate, previous, keys) {
|
|
739
|
+
const db = await openKvStore(true);
|
|
740
|
+
if (db === null) throw gatedError("gate_unavailable", "The origin database could not be created.");
|
|
741
|
+
try {
|
|
742
|
+
await transact(db, [KV_STORE.gate, KV_STORE.device], "readwrite", async (tx) => {
|
|
743
|
+
const current = await requestAsPromise(tx.objectStore(KV_STORE.gate).get(ACCOUNT2));
|
|
744
|
+
if (current?.incarnation !== previous?.incarnation || current?.lifecycleEpoch !== previous?.lifecycleEpoch || current?.principalHash !== previous?.principalHash) {
|
|
745
|
+
throw gatedError("stamp_stale", "The origin gate changed during establishment.");
|
|
746
|
+
}
|
|
747
|
+
const device = tx.objectStore(KV_STORE.device);
|
|
748
|
+
device.put(keys.hash, GATE_HMAC_KEY);
|
|
749
|
+
device.put(keys.account, ACCOUNT_KEY);
|
|
750
|
+
tx.objectStore(KV_STORE.gate).put(gate, ACCOUNT2);
|
|
751
|
+
});
|
|
752
|
+
} finally {
|
|
753
|
+
db.close();
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
async function establishSessionStamp(principal, o) {
|
|
757
|
+
return exclusive(GATE_LOCK, async () => {
|
|
758
|
+
const tombstone = readTombstone();
|
|
759
|
+
if (logoutMarkerSet()) throw gatedError("logout_pending", "A logout is still pending on this origin.");
|
|
760
|
+
const anchor = o.consentAnchor;
|
|
761
|
+
if (anchor === void 0 || anchor.source !== "siwe" && anchor.source !== "delegation_iat" && anchor.source !== "delegation_nbf" || !Number.isSafeInteger(anchor.at) || anchor.at < 0) {
|
|
762
|
+
throw gatedError("not_established", "A verified finite consent anchor is required.");
|
|
763
|
+
}
|
|
764
|
+
const issuedAt = Math.min(anchor.at, checkedNow());
|
|
765
|
+
if ((await readIntents()).length > 0) throw gatedError("purge_pending", "A purge is still pending on this origin.");
|
|
766
|
+
const current = await readGate();
|
|
767
|
+
const legacy = current === void 0 ? await readLegacyGateFloor() : null;
|
|
768
|
+
const preserved = preserveLegacyFloor(legacy, tombstone);
|
|
769
|
+
const floor = denyFloor(current, preserved.tombstone);
|
|
770
|
+
if (preserved.advanced) throw gatedError("stamp_stale", "The legacy deny floor was preserved; a new consent proof is required.");
|
|
771
|
+
if (floor.deniedAt !== null && issuedAt <= floor.deniedAt) {
|
|
772
|
+
throw gatedError("stamp_stale", "This sign-in predates the last sign-out.");
|
|
773
|
+
}
|
|
774
|
+
assertTombstoneWritable();
|
|
775
|
+
if (current === void 0 || current.principalHash === null) {
|
|
776
|
+
const incarnation = crypto.randomUUID();
|
|
777
|
+
const hash = await newGateHmac(incarnation);
|
|
778
|
+
const principalHash2 = await gateHash(hash.hmac, "principal", principal.toLowerCase());
|
|
779
|
+
const account = await newAccountKey(incarnation, floor.epoch, principalHash2);
|
|
780
|
+
const created = {
|
|
781
|
+
incarnation,
|
|
782
|
+
salt: crypto.randomUUID(),
|
|
783
|
+
principalHash: principalHash2,
|
|
784
|
+
lifecycleEpoch: floor.epoch,
|
|
785
|
+
deniedAt: floor.deniedAt,
|
|
786
|
+
keyId: hash.keyId,
|
|
787
|
+
accountKeyId: account.keyId,
|
|
788
|
+
consentDeadline: issuedAt + CONSENT_WINDOW_MS
|
|
789
|
+
};
|
|
790
|
+
if (!Number.isSafeInteger(created.consentDeadline)) throw gatedError("not_established", "The consent deadline is invalid.");
|
|
791
|
+
await writeGate(created, current, { hash, account });
|
|
792
|
+
return adopt(created, principal);
|
|
793
|
+
}
|
|
794
|
+
const keys = await activeKeys(current);
|
|
795
|
+
const principalHash = await gateHash(keys.hash.hmac, "principal", principal.toLowerCase());
|
|
796
|
+
if (current.principalHash !== principalHash) throw gatedError("principal_mismatch", "The origin is bound to another account.");
|
|
797
|
+
if (checkedNow() >= deadlineOf(current)) {
|
|
798
|
+
await expireConsentLocked();
|
|
799
|
+
throw new ReplicaError(ReplicaErrorCode.CONSENT_EXPIRED, "The account consent has expired.");
|
|
800
|
+
}
|
|
801
|
+
return adopt(current, principal);
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
async function restoreSessionStamp(principal, persisted) {
|
|
805
|
+
const gate = await readGate();
|
|
806
|
+
denyFloor(gate, readTombstone());
|
|
807
|
+
if (gate === void 0 || persisted === void 0) throw gatedError("not_established", "No session stamp is established for this origin.");
|
|
808
|
+
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) {
|
|
809
|
+
throw gatedError("stamp_stale", "The persisted replica stamp is malformed.");
|
|
810
|
+
}
|
|
811
|
+
if (persisted.incarnation !== gate.incarnation || persisted.lifecycleEpoch !== gate.lifecycleEpoch) {
|
|
812
|
+
throw gatedError("stamp_stale", "The persisted session stamp is stale.");
|
|
813
|
+
}
|
|
814
|
+
if (gate.principalHash === null) throw gatedError("principal_mismatch", "The origin has no active account.");
|
|
815
|
+
const keys = await activeKeys(gate);
|
|
816
|
+
const hash = await gateHash(keys.hash.hmac, "principal", principal.toLowerCase());
|
|
817
|
+
if (hash !== gate.principalHash) throw gatedError("principal_mismatch", "The origin is bound to another account.");
|
|
818
|
+
if (logoutMarkerSet()) throw gatedError("logout_pending", "A logout is still pending on this origin.");
|
|
819
|
+
if ((await readIntents()).length > 0) throw gatedError("purge_pending", "A purge is still pending on this origin.");
|
|
820
|
+
assertTombstoneWritable();
|
|
821
|
+
if (checkedNow() >= deadlineOf(gate)) {
|
|
822
|
+
await expireConsent();
|
|
823
|
+
throw new ReplicaError(ReplicaErrorCode.CONSENT_EXPIRED, "The account consent has expired.");
|
|
824
|
+
}
|
|
825
|
+
return adopt(gate, principal);
|
|
826
|
+
}
|
|
827
|
+
async function activationAllowed(next) {
|
|
828
|
+
try {
|
|
829
|
+
if (logoutMarkerSet()) return false;
|
|
830
|
+
const gate = await readGate();
|
|
831
|
+
denyFloor(gate, readTombstone());
|
|
832
|
+
if (gate === void 0 || gate.principalHash === null) return false;
|
|
833
|
+
if (checkedNow() >= deadlineOf(gate)) {
|
|
834
|
+
await expireConsent();
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
const keys = await activeKeys(gate);
|
|
838
|
+
const hash = await gateHash(keys.hash.hmac, "principal", next.toLowerCase());
|
|
839
|
+
if (hash !== gate.principalHash) return false;
|
|
840
|
+
return (await readIntents()).every((intent) => intent.principalHash === hash);
|
|
841
|
+
} catch {
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function checkDelivery(stamp) {
|
|
846
|
+
if (stamp === void 0) return { status: "gated", gate: "not_established" };
|
|
847
|
+
if (view === void 0) return { status: "gated", gate: "stamp_stale" };
|
|
848
|
+
if (view.incarnation !== stamp.incarnation || view.lifecycleEpoch !== stamp.lifecycleEpoch) {
|
|
849
|
+
return { status: "gated", gate: "stamp_stale" };
|
|
850
|
+
}
|
|
851
|
+
try {
|
|
852
|
+
const tombstone = readTombstone();
|
|
853
|
+
if (logoutMarkerSet()) return { status: "gated", gate: "logout_pending" };
|
|
854
|
+
if (tombstone === null && stamp.lifecycleEpoch !== 0) return { status: "gated", gate: "gate_unavailable" };
|
|
855
|
+
if (tombstone !== null && stamp.lifecycleEpoch < tombstone.epoch) return { status: "gated", gate: "stamp_stale" };
|
|
856
|
+
if (checkedNow() >= view.consentDeadline) return { status: "consent_expired", code: "CONSENT_EXPIRED" };
|
|
857
|
+
return { status: "allow" };
|
|
858
|
+
} catch {
|
|
859
|
+
return { status: "gated", gate: "gate_unavailable" };
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
function consentDeadline(stamp) {
|
|
863
|
+
return view !== void 0 && checkDelivery(stamp).status === "allow" ? view.consentDeadline : null;
|
|
864
|
+
}
|
|
865
|
+
function deliverable(stamp) {
|
|
866
|
+
return checkDelivery(stamp).status === "allow";
|
|
867
|
+
}
|
|
868
|
+
async function commitDeny(plan) {
|
|
869
|
+
if (takeFault("deny_tx")) throw new Error("injected deny_tx failure");
|
|
870
|
+
const db = await openKvStore(false);
|
|
871
|
+
if (db === null) return { status: "stale" };
|
|
872
|
+
try {
|
|
873
|
+
return await transact(
|
|
874
|
+
db,
|
|
875
|
+
[KV_STORE.gate, KV_STORE.partitions, KV_STORE.purgeIntents, KV_STORE.device, KV_STORE.pending, KV_STORE.quota],
|
|
876
|
+
"readwrite",
|
|
877
|
+
async (tx) => {
|
|
878
|
+
const current = await requestAsPromise(tx.objectStore(KV_STORE.gate).get(ACCOUNT2));
|
|
879
|
+
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) {
|
|
880
|
+
return { status: "stale" };
|
|
881
|
+
}
|
|
882
|
+
const now2 = plan.tombstone.deniedAt;
|
|
883
|
+
const partitions = await requestAsPromise(tx.objectStore(KV_STORE.partitions).getAll());
|
|
884
|
+
const pendingKeys = await requestAsPromise(tx.objectStore(KV_STORE.pending).getAllKeys());
|
|
885
|
+
const intents = [];
|
|
886
|
+
const identities = /* @__PURE__ */ new Set();
|
|
887
|
+
for (const entry of partitions) {
|
|
888
|
+
if (entry.principalHash !== plan.callerHash) continue;
|
|
889
|
+
intents.push({ id: entry.partitionId, kind: "partition", partitionId: entry.partitionId, principalHash: plan.callerHash, recordedAt: now2 });
|
|
890
|
+
identities.add(entry.identityKeyHash);
|
|
891
|
+
}
|
|
892
|
+
for (const key of pendingKeys) {
|
|
893
|
+
if (typeof key === "string") identities.add(key);
|
|
894
|
+
}
|
|
895
|
+
for (const identityKeyHash of identities) {
|
|
896
|
+
intents.push({ id: `pending:${identityKeyHash}`, kind: "pending", identityKeyHash, principalHash: plan.callerHash, recordedAt: now2 });
|
|
897
|
+
}
|
|
898
|
+
const purge = tx.objectStore(KV_STORE.purgeIntents);
|
|
899
|
+
for (const intent of intents) purge.put(intent, intent.id);
|
|
900
|
+
const device = tx.objectStore(KV_STORE.device);
|
|
901
|
+
let deviceBytes = billedDeviceBytes(await requestAsPromise(device.get(DEVICE_KEY)));
|
|
902
|
+
device.delete(DEVICE_KEY);
|
|
903
|
+
device.delete(ACCOUNT_KEY);
|
|
904
|
+
device.delete(GATE_HMAC_KEY);
|
|
905
|
+
for (const prefix of [FIRST_INSTALL_KEY_PREFIX, GRANT_INSTALL_KEY_PREFIX]) {
|
|
906
|
+
const keys = await requestAsPromise(device.getAllKeys(IDBKeyRange.bound(prefix, `${prefix}\uFFFF`)));
|
|
907
|
+
for (const key of keys) {
|
|
908
|
+
deviceBytes += billedDeviceBytes(await requestAsPromise(device.get(key)));
|
|
909
|
+
if (!Number.isSafeInteger(deviceBytes)) throw gatedError("gate_unavailable", "Device quota bytes overflow.");
|
|
910
|
+
device.delete(key);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
await ledger?.accountErase(tx, { partitionId: "device", bytes: deviceBytes });
|
|
914
|
+
const next = {
|
|
915
|
+
...current,
|
|
916
|
+
principalHash: null,
|
|
917
|
+
consentDeadline: null,
|
|
918
|
+
lifecycleEpoch: plan.tombstone.epoch,
|
|
919
|
+
deniedAt: plan.tombstone.deniedAt
|
|
920
|
+
};
|
|
921
|
+
tx.objectStore(KV_STORE.gate).put(next, ACCOUNT2);
|
|
922
|
+
return { status: "denied", lifecycleEpoch: next.lifecycleEpoch, intents: intents.length };
|
|
923
|
+
}
|
|
924
|
+
);
|
|
925
|
+
} finally {
|
|
926
|
+
db.close();
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
async function wipe() {
|
|
930
|
+
const deleted = await deleteKvDatabase();
|
|
931
|
+
if (deleted.status === "deleted") return { status: "deleted" };
|
|
932
|
+
return setLogoutMarker() ? { status: "marker", code: deleted.code } : { status: "unguarded", code: deleted.code };
|
|
933
|
+
}
|
|
934
|
+
async function advanceDeny(gate, floor) {
|
|
935
|
+
const callerHash = gate.principalHash;
|
|
936
|
+
if (callerHash === null) return { status: "stale" };
|
|
937
|
+
const tombstone = { epoch: floor.epoch + 1, deniedAt: Math.max(checkedNow(), (floor.deniedAt ?? -1) + 1) };
|
|
938
|
+
if (!Number.isSafeInteger(tombstone.epoch) || !Number.isSafeInteger(tombstone.deniedAt)) {
|
|
939
|
+
throw gatedError("gate_unavailable", "The origin deny tombstone cannot advance.");
|
|
940
|
+
}
|
|
941
|
+
writeTombstone(tombstone);
|
|
942
|
+
try {
|
|
943
|
+
return await commitDeny({ gate, callerHash, tombstone });
|
|
944
|
+
} catch {
|
|
945
|
+
return wipe();
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
async function denyLocked(input) {
|
|
949
|
+
const gate = await readGate();
|
|
950
|
+
const floor = denyFloor(gate, readTombstone());
|
|
951
|
+
if (gate === void 0 || gate.principalHash === null) return { status: "stale" };
|
|
952
|
+
const keys = await activeKeys(gate);
|
|
953
|
+
const callerHash = await gateHash(keys.hash.hmac, "principal", input.principal.toLowerCase());
|
|
954
|
+
if (callerHash !== gate.principalHash) return { status: "stale" };
|
|
955
|
+
if (input.reason === "account_switch" && input.next === void 0) {
|
|
956
|
+
throw new Error("account_switch requires the next principal");
|
|
957
|
+
}
|
|
958
|
+
return advanceDeny(gate, floor);
|
|
959
|
+
}
|
|
960
|
+
async function expireConsentLocked() {
|
|
961
|
+
const gate = await readGate();
|
|
962
|
+
const floor = denyFloor(gate, readTombstone());
|
|
963
|
+
if (gate === void 0 || gate.principalHash === null) return { status: "stale" };
|
|
964
|
+
if (checkedNow() < deadlineOf(gate)) return { status: "stale" };
|
|
965
|
+
const stamp = { incarnation: gate.incarnation, lifecycleEpoch: gate.lifecycleEpoch };
|
|
966
|
+
view = void 0;
|
|
967
|
+
clearTimeout(expiryTimer);
|
|
968
|
+
const result = await advanceDeny(gate, floor);
|
|
969
|
+
if (result.status === "denied" || result.status === "deleted") {
|
|
970
|
+
try {
|
|
971
|
+
options.onConsentExpired?.(stamp);
|
|
972
|
+
} catch {
|
|
973
|
+
}
|
|
974
|
+
for (const observer of expiryObservers) {
|
|
975
|
+
try {
|
|
976
|
+
observer(stamp);
|
|
977
|
+
} catch {
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return result;
|
|
982
|
+
}
|
|
983
|
+
async function expireConsent() {
|
|
984
|
+
try {
|
|
985
|
+
return await exclusive(GATE_LOCK, expireConsentLocked);
|
|
986
|
+
} catch (error) {
|
|
987
|
+
return { status: "unguarded", code: codeOf(error) };
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
async function deny(input) {
|
|
991
|
+
if (view?.principal === input.principal.toLowerCase()) {
|
|
992
|
+
view = void 0;
|
|
993
|
+
clearTimeout(expiryTimer);
|
|
994
|
+
}
|
|
995
|
+
try {
|
|
996
|
+
return await exclusive(GATE_LOCK, () => denyLocked(input));
|
|
997
|
+
} catch (error) {
|
|
998
|
+
return { status: "unguarded", code: codeOf(error) };
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
async function eraseIntent(intent) {
|
|
1002
|
+
const db = await openKvStore(false);
|
|
1003
|
+
if (db === null) return;
|
|
1004
|
+
try {
|
|
1005
|
+
await transact(db, [...ERASE_STORES, KV_STORE.partitions, KV_STORE.purgeIntents, KV_STORE.quota], "readwrite", async (tx) => {
|
|
1006
|
+
if (intent.kind === "partition") {
|
|
1007
|
+
const { bytes } = await eraser.erasePartition(tx, intent.partitionId);
|
|
1008
|
+
await gateTx.unindexPartition(tx, intent.partitionId);
|
|
1009
|
+
await ledger?.accountErase(tx, { partitionId: intent.partitionId, bytes });
|
|
1010
|
+
} else {
|
|
1011
|
+
await eraser.eraseIdentityPending(tx, intent.identityKeyHash);
|
|
1012
|
+
}
|
|
1013
|
+
await gateTx.clearIntent(tx, intent.id);
|
|
1014
|
+
});
|
|
1015
|
+
} finally {
|
|
1016
|
+
db.close();
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
async function erasePendingLocked({ signal }) {
|
|
1020
|
+
const erased = [];
|
|
1021
|
+
const failed = [];
|
|
1022
|
+
for (const intent of await readIntents()) {
|
|
1023
|
+
if (signal.aborted) break;
|
|
1024
|
+
try {
|
|
1025
|
+
await eraseIntent(intent);
|
|
1026
|
+
erased.push(intent.id);
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
failed.push({ id: intent.id, code: codeOf(error) });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return { erased, failed, remaining: (await readIntents()).length };
|
|
1032
|
+
}
|
|
1033
|
+
function erasePending(o) {
|
|
1034
|
+
return exclusive(GATE_LOCK, () => erasePendingLocked(o));
|
|
1035
|
+
}
|
|
1036
|
+
async function recover({ signal }) {
|
|
1037
|
+
try {
|
|
1038
|
+
return await exclusive(GATE_LOCK, async () => {
|
|
1039
|
+
const failed = [];
|
|
1040
|
+
if (logoutMarkerSet()) {
|
|
1041
|
+
if (readTombstone() === null) throw gatedError("gate_unavailable", "The pending logout has no deny tombstone.");
|
|
1042
|
+
const deleted = await deleteKvDatabase();
|
|
1043
|
+
if (deleted.status === "deleted") {
|
|
1044
|
+
try {
|
|
1045
|
+
localStorage.removeItem(REPLICA_LOGOUT_MARKER);
|
|
1046
|
+
if (localStorage.getItem(REPLICA_LOGOUT_MARKER) !== null) throw new Error("The logout marker was not cleared.");
|
|
1047
|
+
} catch {
|
|
1048
|
+
failed.push({ id: "marker", code: "STORAGE_ERROR" });
|
|
1049
|
+
}
|
|
1050
|
+
} else {
|
|
1051
|
+
failed.push({ id: "database", code: deleted.code });
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
if (failed.length > 0) return { erased: [], failed, remaining: 1 };
|
|
1055
|
+
const gate = await readGate();
|
|
1056
|
+
const tombstone = readTombstone();
|
|
1057
|
+
if (gate !== void 0 && tombstone !== null && gate.lifecycleEpoch < tombstone.epoch) {
|
|
1058
|
+
const wiped = await wipe();
|
|
1059
|
+
if (wiped.status !== "deleted") {
|
|
1060
|
+
return { erased: [], failed: [{ id: "database", code: wiped.code }], remaining: 1 };
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
const legacy = await readLegacyGateFloor();
|
|
1064
|
+
if (legacy !== null) {
|
|
1065
|
+
const tombstone2 = readTombstone();
|
|
1066
|
+
preserveLegacyFloor(legacy, tombstone2);
|
|
1067
|
+
assertTombstoneWritable();
|
|
1068
|
+
const db = await openKvStore(true);
|
|
1069
|
+
if (db === null) throw gatedError("gate_unavailable", "The legacy origin could not be migrated.");
|
|
1070
|
+
db.close();
|
|
1071
|
+
}
|
|
1072
|
+
const expiry = await expireConsentLocked();
|
|
1073
|
+
if (expiry.status === "marker" || expiry.status === "unguarded") {
|
|
1074
|
+
return { erased: [], failed: [{ id: "expiry", code: expiry.code }], remaining: 1 };
|
|
1075
|
+
}
|
|
1076
|
+
const pass = await erasePendingLocked({ signal });
|
|
1077
|
+
return { erased: pass.erased, failed: [...failed, ...pass.failed], remaining: pass.remaining, consentExpired: expiry.status === "denied" || expiry.status === "deleted" };
|
|
1078
|
+
}, signal);
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
return { erased: [], failed: [{ id: "recover", code: codeOf(error) }], remaining: 1 };
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
function subscribeConsentExpired(listener) {
|
|
1084
|
+
expiryObservers.add(listener);
|
|
1085
|
+
return () => {
|
|
1086
|
+
expiryObservers.delete(listener);
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
return {
|
|
1090
|
+
kind: "indexeddb",
|
|
1091
|
+
recover,
|
|
1092
|
+
expireConsent,
|
|
1093
|
+
checkDelivery,
|
|
1094
|
+
consentDeadline,
|
|
1095
|
+
subscribeConsentExpired,
|
|
1096
|
+
establishSessionStamp,
|
|
1097
|
+
restoreSessionStamp,
|
|
1098
|
+
activationAllowed,
|
|
1099
|
+
deliverable,
|
|
1100
|
+
deny,
|
|
1101
|
+
erasePending,
|
|
1102
|
+
pendingPurge: readIntents
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// src/kv-storage/testing.ts
|
|
1107
|
+
function killReplicaWorker(storage) {
|
|
1108
|
+
const connection = Reflect.get(storage, "connection");
|
|
1109
|
+
if (typeof connection !== "function") throw new TypeError("killReplicaWorker needs a storage from indexedDbReplicaStorage().");
|
|
1110
|
+
const active = connection.call(storage, true);
|
|
1111
|
+
if (active === void 0) return;
|
|
1112
|
+
if (typeof active !== "object" || active === null) throw new TypeError("killReplicaWorker needs a storage from indexedDbReplicaStorage().");
|
|
1113
|
+
const kill = Reflect.get(active, "kill");
|
|
1114
|
+
if (typeof kill !== "function") throw new TypeError("killReplicaWorker needs a storage from indexedDbReplicaStorage().");
|
|
1115
|
+
kill.call(active, new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica worker was terminated."));
|
|
1116
|
+
}
|
|
1117
|
+
function injectEraseFailure(options = {}) {
|
|
1118
|
+
const base = options.base ?? kvPartitionEraser;
|
|
1119
|
+
let remaining = options.times ?? Number.POSITIVE_INFINITY;
|
|
1120
|
+
return {
|
|
1121
|
+
remaining: () => remaining,
|
|
1122
|
+
async erasePartition(tx, partitionId) {
|
|
1123
|
+
if (remaining > 0 && (options.partitionIds === void 0 || options.partitionIds.includes(partitionId))) {
|
|
1124
|
+
remaining -= 1;
|
|
1125
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, `Injected erase failure for ${partitionId}.`);
|
|
1126
|
+
}
|
|
1127
|
+
return base.erasePartition(tx, partitionId);
|
|
1128
|
+
},
|
|
1129
|
+
eraseIdentityPending: (tx, identityKeyHash) => base.eraseIdentityPending(tx, identityKeyHash)
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
function injectDenyFailure(point, options = {}) {
|
|
1133
|
+
denyFaults[point] = { once: options.once ?? true };
|
|
1134
|
+
return () => {
|
|
1135
|
+
delete denyFaults[point];
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
export {
|
|
1139
|
+
createIndexedDbLifecycle,
|
|
1140
|
+
injectDenyFailure,
|
|
1141
|
+
injectEraseFailure,
|
|
1142
|
+
killReplicaWorker,
|
|
1143
|
+
measureSealedRow
|
|
1144
|
+
};
|
|
1145
|
+
//# sourceMappingURL=kv-storage-testing.js.map
|