@tinycloud/replica 0.2.4-beta.2 → 0.3.0-beta.4

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,560 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/profile-gate/index.ts
21
+ var profile_gate_exports = {};
22
+ __export(profile_gate_exports, {
23
+ REPLICA_DENY_FLOOR_FILE: () => REPLICA_DENY_FLOOR_FILE,
24
+ advanceInactiveGate: () => advanceInactiveGate,
25
+ assertGateAccess: () => assertGateAccess,
26
+ clearGateIntent: () => clearGateIntent,
27
+ deferredGateToken: () => deferredGateToken,
28
+ denyGate: () => denyGate,
29
+ establishSessionStamp: () => establishSessionStamp,
30
+ grantInstallHashOf: () => grantInstallHashOf,
31
+ identityHashOf: () => identityHashOf,
32
+ principalHashOf: () => principalHashOf,
33
+ readFirstInstall: () => readFirstInstall,
34
+ readGateSnapshot: () => readGateSnapshot,
35
+ readLogoutGate: () => readLogoutGate,
36
+ recordFirstInstall: () => recordFirstInstall,
37
+ recordGrantInstall: () => recordGrantInstall,
38
+ restoreSessionStamp: () => restoreSessionStamp,
39
+ resumeDeferredGate: () => resumeDeferredGate
40
+ });
41
+ module.exports = __toCommonJS(profile_gate_exports);
42
+
43
+ // src/profile-gate/gateFile.ts
44
+ var import_node_crypto = require("crypto");
45
+ var import_promises = require("fs/promises");
46
+ var import_node_path = require("path");
47
+
48
+ // ../sdk-services/dist/kv/replication/contractConstants.js
49
+ var HOUR_MS = 60 * 60 * 1e3;
50
+ var DAY_MS = 24 * HOUR_MS;
51
+ var MAX_GRANT_TTL_MS = 7 * DAY_MS;
52
+ var CONSENT_WINDOW_MS = 7 * DAY_MS;
53
+ var DEFAULT_MIN_FREE_BYTES = 64 * 1024 * 1024;
54
+ var REPLICA_GATE_FILE = "replica-gate.json";
55
+
56
+ // src/errors.ts
57
+ var ReplicaErrorCode = {
58
+ /** Another process holds the sync lease. */
59
+ BUSY: "REPLICA_BUSY",
60
+ /** No replica with this name exists. */
61
+ NOT_FOUND: "REPLICA_NOT_FOUND",
62
+ /** The replica exists but its stored configuration differs from the request. */
63
+ CONFIG_MISMATCH: "REPLICA_CONFIG_MISMATCH",
64
+ /** The runtime has no supported durable store (e.g. Node < 22.13). */
65
+ RUNTIME_UNSUPPORTED: "RUNTIME_UNSUPPORTED",
66
+ /** A required open() argument is missing or malformed (e.g. `principal`). */
67
+ INVALID_ARGUMENT: "REPLICA_INVALID_ARGUMENT",
68
+ /** The local store failed (I/O, corruption, schema). */
69
+ STORAGE_ERROR: "STORAGE_ERROR",
70
+ /** The local disk is full. */
71
+ STORAGE_FULL: "STORAGE_FULL",
72
+ /** The key is outside the replica's prefix. */
73
+ NOT_COVERED: "NOT_COVERED",
74
+ /** Syncing the secrets space or the vault namespace needs an explicit opt-in. */
75
+ SECRETS_OPT_IN_REQUIRED: "SECRETS_OPT_IN_REQUIRED",
76
+ /** No grant is installed, or none covers the requested scope. */
77
+ GRANT_MISSING: "GRANT_MISSING",
78
+ /** The grant bytes are not a verifiable UCAN, or its signature is wrong. */
79
+ GRANT_INVALID: "GRANT_INVALID",
80
+ /** SIWE/CACAO and other non-UCAN grants cannot be used for a replica. */
81
+ GRANT_FORMAT_UNSUPPORTED: "GRANT_FORMAT_UNSUPPORTED",
82
+ /** The grant was issued to a different device. */
83
+ GRANT_AUDIENCE_MISMATCH: "GRANT_AUDIENCE_MISMATCH",
84
+ /** The grant lacks `tinycloud.kv/sync` or `tinycloud.kv/get` on the prefix. */
85
+ GRANT_NOT_COVERING: "GRANT_NOT_COVERING",
86
+ GRANT_NOT_YET_VALID: "GRANT_NOT_YET_VALID",
87
+ GRANT_EXPIRED: "GRANT_EXPIRED",
88
+ /** The configured node does not advertise whole-space KV sync support. */
89
+ REPLICATION_ROOT_UNSUPPORTED: "REPLICATION_ROOT_UNSUPPORTED",
90
+ GRANT_REVOKED: "GRANT_REVOKED",
91
+ /** The node refused the invocation for a reason other than revocation. */
92
+ GRANT_UNAUTHORIZED: "GRANT_UNAUTHORIZED",
93
+ /** The node refused the retention grant. */
94
+ RETENTION_GRANT_REFUSED: "RETENTION_GRANT_REFUSED",
95
+ /** The node could not be reached. */
96
+ NETWORK_ERROR: "NETWORK_ERROR",
97
+ /** The node answered with an unexpected status. */
98
+ NODE_ERROR: "NODE_ERROR",
99
+ /** The node's answer violates the wire contract. */
100
+ PROTOCOL_ERROR: "PROTOCOL_ERROR",
101
+ /** The node asked for a fresh bootstrap (410). Handled inside sync. */
102
+ RESET_REQUIRED: "RESET_REQUIRED",
103
+ /** The feed now comes from a different node than the one pinned. */
104
+ SOURCE_CHANGED: "SOURCE_CHANGED",
105
+ /** The feed delivered a key outside the replica's prefix. */
106
+ SCOPE_VIOLATION: "SCOPE_VIOLATION",
107
+ /** Fetched bytes do not hash to the ETag the node attested for them. */
108
+ CONTENT_MISMATCH: "CONTENT_MISMATCH",
109
+ /** A stored blob no longer hashes to its name. */
110
+ INTEGRITY_ERROR: "INTEGRITY_ERROR",
111
+ /** The replica handle was closed; in-flight calls settle with this code. */
112
+ CLOSED: "REPLICA_CLOSED",
113
+ /** The lifecycle gate refused the operation (shared contract `REPLICA_GATED`); `detail.gate` says why. */
114
+ GATED: "REPLICA_GATED",
115
+ /** Quota admission refused a page (`maxBytes` or the free-space floor); nothing committed. */
116
+ STORAGE_BUDGET: "STORAGE_BUDGET",
117
+ /** The consent deadline passed: the grant is not served until the user consents again. */
118
+ CONSENT_EXPIRED: "CONSENT_EXPIRED"
119
+ };
120
+ var REPLICA_ERROR = /* @__PURE__ */ Symbol.for("tinycloud.replica.error");
121
+ var ReplicaError = class extends Error {
122
+ [REPLICA_ERROR] = true;
123
+ code;
124
+ detail;
125
+ constructor(code, message, detail, options) {
126
+ super(message, options);
127
+ this.name = "ReplicaError";
128
+ this.code = code;
129
+ if (detail !== void 0) this.detail = detail;
130
+ }
131
+ };
132
+ function gatedError(gate, message) {
133
+ return Object.assign(new ReplicaError(ReplicaErrorCode.GATED, message, { gate }), { gate });
134
+ }
135
+
136
+ // src/profile-gate/saltedHash.ts
137
+ function base64UrlBytes(text) {
138
+ if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new TypeError("The replica gate salt is not base64url.");
139
+ const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4);
140
+ return Uint8Array.from(globalThis.atob(padded), (char) => char.charCodeAt(0));
141
+ }
142
+ var saltedHash = async (salt, material) => {
143
+ const key = await globalThis.crypto.subtle.importKey("raw", base64UrlBytes(salt), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
144
+ const mac = new Uint8Array(await globalThis.crypto.subtle.sign("HMAC", key, new TextEncoder().encode(material)));
145
+ let hex = "";
146
+ for (const byte of mac) hex += byte.toString(16).padStart(2, "0");
147
+ return hex;
148
+ };
149
+
150
+ // src/profile-gate/gateFile.ts
151
+ var GATE_FILE_VERSION = 1;
152
+ var REPLICA_DENY_FLOOR_FILE = "replica-deny-floor.json";
153
+ var DENY_FLOOR_FILE = REPLICA_DENY_FLOOR_FILE;
154
+ function gateRefusal(gate, message) {
155
+ return gatedError(gate, message);
156
+ }
157
+ function principalHashOf(salt, principal) {
158
+ return saltedHash(salt, principal.toLowerCase());
159
+ }
160
+ function identityHashOf(salt, identityKey) {
161
+ return saltedHash(salt, identityKey);
162
+ }
163
+ function grantInstallHashOf(salt, identityKeyHash, cid) {
164
+ return saltedHash(salt, `grantInstall\0${identityKeyHash}\0${cid}`);
165
+ }
166
+ function parseState(text) {
167
+ let raw;
168
+ try {
169
+ raw = JSON.parse(text);
170
+ } catch {
171
+ throw gateRefusal("gate_unavailable", "The replica gate file is not valid JSON.");
172
+ }
173
+ if (typeof raw !== "object" || raw === null) throw gateRefusal("gate_unavailable", "The replica gate file is malformed.");
174
+ const record = raw;
175
+ if (record.v !== GATE_FILE_VERSION) throw gateRefusal("gate_unavailable", "The replica gate file has an unknown version.");
176
+ if (typeof record.incarnation !== "string" || record.incarnation.length === 0) {
177
+ throw gateRefusal("gate_unavailable", "The replica gate file has no incarnation.");
178
+ }
179
+ if (typeof record.salt !== "string" || Buffer.from(record.salt, "base64url").toString("base64url") !== record.salt || Buffer.from(record.salt, "base64url").length < 32) {
180
+ throw gateRefusal("gate_unavailable", "The replica gate file has an invalid salt.");
181
+ }
182
+ if (record.principalHash !== null && (typeof record.principalHash !== "string" || !/^[0-9a-f]{64}$/.test(record.principalHash))) {
183
+ throw gateRefusal("gate_unavailable", "The replica gate file has an invalid principal hash.");
184
+ }
185
+ if (typeof record.lifecycleEpoch !== "number" || !Number.isSafeInteger(record.lifecycleEpoch) || record.lifecycleEpoch < 0) {
186
+ throw gateRefusal("gate_unavailable", "The replica gate file has an invalid lifecycle epoch.");
187
+ }
188
+ if (record.deniedAt !== null && (typeof record.deniedAt !== "number" || !Number.isSafeInteger(record.deniedAt) || record.deniedAt < 0)) {
189
+ throw gateRefusal("gate_unavailable", "The replica gate file has an invalid deniedAt.");
190
+ }
191
+ if (!Array.isArray(record.intents)) throw gateRefusal("gate_unavailable", "The replica gate file has no intents list.");
192
+ for (const intent of record.intents) {
193
+ if (typeof intent !== "object" || intent === null || typeof intent.id !== "string" || !/^[0-9a-f]{64}$/.test(intent.principalHash) || !Number.isSafeInteger(intent.recordedAt) || intent.recordedAt < 0 || (intent.kind === "pending" ? !/^[0-9a-f]{64}$/.test(intent.identityKeyHash) || intent.id !== `pending:${intent.identityKeyHash}` : intent.kind !== "partition" || typeof intent.partitionId !== "string" || intent.partitionId.length === 0 || intent.id !== intent.partitionId)) {
194
+ throw gateRefusal("gate_unavailable", "The replica gate file has an invalid purge intent.");
195
+ }
196
+ }
197
+ const firstInstall = record.firstInstall;
198
+ if (typeof firstInstall !== "object" || firstInstall === null || Array.isArray(firstInstall)) {
199
+ throw gateRefusal("gate_unavailable", "The replica gate file has no valid firstInstall map.");
200
+ }
201
+ for (const [hash, value] of Object.entries(firstInstall)) {
202
+ if (!/^[0-9a-f]{64}$/.test(hash) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
203
+ throw gateRefusal("gate_unavailable", "The replica gate file has an invalid firstInstall entry.");
204
+ }
205
+ }
206
+ const grantInstall = Object.prototype.hasOwnProperty.call(record, "grantInstall") ? record.grantInstall : {};
207
+ if (typeof grantInstall !== "object" || grantInstall === null || Array.isArray(grantInstall)) {
208
+ throw gateRefusal("gate_unavailable", "The replica gate has an invalid grantInstall map.");
209
+ }
210
+ for (const [hash, installedAt] of Object.entries(grantInstall)) {
211
+ if (!/^[0-9a-f]{64}$/.test(hash) || !Number.isSafeInteger(installedAt) || installedAt < 0) {
212
+ throw gateRefusal("gate_unavailable", "The replica gate has an invalid grant install time.");
213
+ }
214
+ }
215
+ const pendingGenerations = record.pendingGenerations;
216
+ if (typeof pendingGenerations !== "object" || pendingGenerations === null || Array.isArray(pendingGenerations)) {
217
+ throw gateRefusal("gate_unavailable", "The replica gate file has an invalid pending generation map.");
218
+ }
219
+ for (const [hash, generation] of Object.entries(pendingGenerations)) {
220
+ if (!/^[0-9a-f]{64}$/.test(hash) || typeof generation !== "number" || !Number.isSafeInteger(generation) || generation < 0) {
221
+ throw gateRefusal("gate_unavailable", "The replica gate file has an invalid pending generation.");
222
+ }
223
+ }
224
+ const eraseDeferred = Object.prototype.hasOwnProperty.call(record, "eraseDeferred") ? record.eraseDeferred : false;
225
+ const deferredPrincipalHash = Object.prototype.hasOwnProperty.call(record, "deferredPrincipalHash") ? record.deferredPrincipalHash : null;
226
+ if (typeof eraseDeferred !== "boolean" || deferredPrincipalHash !== null && (typeof deferredPrincipalHash !== "string" || !/^[0-9a-f]{64}$/.test(deferredPrincipalHash)) || eraseDeferred && (record.principalHash !== null || deferredPrincipalHash === null)) {
227
+ throw gateRefusal("gate_unavailable", "The replica gate has an invalid deferred erase state.");
228
+ }
229
+ return {
230
+ incarnation: record.incarnation,
231
+ salt: record.salt,
232
+ principalHash: record.principalHash,
233
+ lifecycleEpoch: record.lifecycleEpoch,
234
+ deniedAt: record.deniedAt,
235
+ intents: record.intents,
236
+ firstInstall,
237
+ grantInstall,
238
+ pendingGenerations,
239
+ eraseDeferred,
240
+ deferredPrincipalHash
241
+ };
242
+ }
243
+ function isNotFound(error) {
244
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
245
+ }
246
+ async function readStoredState(profileDir) {
247
+ let text;
248
+ try {
249
+ text = await (0, import_promises.readFile)((0, import_node_path.join)(profileDir, REPLICA_GATE_FILE), "utf8");
250
+ } catch (error) {
251
+ if (isNotFound(error)) return void 0;
252
+ throw gateRefusal("gate_unavailable", "The replica gate file cannot be read.");
253
+ }
254
+ return parseState(text);
255
+ }
256
+ async function readDenyFloor(profileDir) {
257
+ let record;
258
+ try {
259
+ record = JSON.parse(await (0, import_promises.readFile)((0, import_node_path.join)(profileDir, DENY_FLOOR_FILE), "utf8"));
260
+ } catch (error) {
261
+ if (isNotFound(error)) return void 0;
262
+ throw gateRefusal("gate_unavailable", "The replica deny floor cannot be read.");
263
+ }
264
+ if (typeof record !== "object" || record === null || Array.isArray(record) || Object.keys(record).length !== 2 || !("epoch" in record) || !("deniedAt" in record) || !Number.isSafeInteger(record.epoch) || record.epoch < 1 || !Number.isSafeInteger(record.deniedAt) || record.deniedAt < 0) {
265
+ throw gateRefusal("gate_unavailable", "The replica deny floor is malformed.");
266
+ }
267
+ return { epoch: record.epoch, deniedAt: record.deniedAt };
268
+ }
269
+ var LOST_GATE_RECOVERY = "Local replicas stay disabled for this profile; to start over, delete it (`tc profile delete <name>`; for the default profile, switch the default first with `tc profile switch <other>`) and sign in again.";
270
+ var INTERRUPTED_SIGN_OUT = "A previous sign-out was interrupted; re-running `tc auth logout` completes it.";
271
+ function behindFloor(state, floor) {
272
+ return state.lifecycleEpoch < floor.epoch || state.deniedAt === null || state.deniedAt < floor.deniedAt;
273
+ }
274
+ async function readState(profileDir) {
275
+ const state = await readStoredState(profileDir);
276
+ const floor = await readDenyFloor(profileDir);
277
+ if (floor === void 0) return state;
278
+ if (state === void 0) {
279
+ throw gateRefusal("gate_unavailable", `The replica gate is missing after a sign-out. ${LOST_GATE_RECOVERY}`);
280
+ }
281
+ if (behindFloor(state, floor)) {
282
+ throw gateRefusal("gate_unavailable", `The replica gate is behind its last sign-out. ${INTERRUPTED_SIGN_OUT}`);
283
+ }
284
+ return state;
285
+ }
286
+ function readGateSnapshot(profileDir) {
287
+ return readState(profileDir);
288
+ }
289
+ async function readLogoutGate(profileDir) {
290
+ const state = await readStoredState(profileDir);
291
+ const floor = await readDenyFloor(profileDir);
292
+ if (state === void 0) {
293
+ if (floor !== void 0) {
294
+ throw gateRefusal("gate_unavailable", `The replica gate is missing after a sign-out. ${LOST_GATE_RECOVERY}`);
295
+ }
296
+ return void 0;
297
+ }
298
+ return { state, floorLagging: floor !== void 0 && behindFloor(state, floor) };
299
+ }
300
+ async function advanceInactiveGate(profileDir) {
301
+ const state = await readStoredState(profileDir);
302
+ const floor = await readDenyFloor(profileDir);
303
+ if (state === void 0 || state.principalHash !== null || state.eraseDeferred) {
304
+ throw gateRefusal("stamp_stale", "Only an inactive replica gate can be advanced past its floor.");
305
+ }
306
+ const lifecycleEpoch = Math.max(state.lifecycleEpoch, floor?.epoch ?? 0) + 1;
307
+ const deniedAt = Math.max(Date.now(), (state.deniedAt ?? -1) + 1, (floor?.deniedAt ?? -1) + 1);
308
+ if (!Number.isSafeInteger(lifecycleEpoch) || !Number.isSafeInteger(deniedAt)) {
309
+ throw gateRefusal("gate_unavailable", "The replica deny floor cannot advance.");
310
+ }
311
+ const next = { ...state, lifecycleEpoch, deniedAt };
312
+ await writeAtomic(profileDir, DENY_FLOOR_FILE, JSON.stringify({ epoch: lifecycleEpoch, deniedAt }));
313
+ await writeState(profileDir, next);
314
+ return next;
315
+ }
316
+ async function assertGateAccess(profileDir, access, salt) {
317
+ if (access.stamp === void 0) {
318
+ throw gateRefusal("not_established", "The replica session has no stamp.");
319
+ }
320
+ const state = await readState(profileDir);
321
+ if (state === void 0 || state.salt !== salt) {
322
+ throw gateRefusal("gate_unavailable", "The replica gate is unavailable or was replaced.");
323
+ }
324
+ if (state.incarnation !== access.incarnation) {
325
+ throw gateRefusal("stamp_stale", "The replica gate was replaced.");
326
+ }
327
+ if (state.lifecycleEpoch !== access.stamp.lifecycleEpoch || state.incarnation !== access.stamp.incarnation) {
328
+ throw gateRefusal("stamp_stale", "The replica session stamp has changed.");
329
+ }
330
+ if (state.principalHash === null || state.principalHash !== access.principalHash) {
331
+ throw gateRefusal("principal_mismatch", "The replica belongs to a different account.");
332
+ }
333
+ if (state.intents.some(
334
+ (intent) => intent.kind === "partition" && intent.partitionId === access.partitionId || intent.kind === "pending" && intent.identityKeyHash === access.identityKeyHash
335
+ )) {
336
+ throw gateRefusal("purge_pending", "This replica has a pending erase.");
337
+ }
338
+ return state;
339
+ }
340
+ async function clearGateIntent(profileDir, id) {
341
+ const state = await readState(profileDir);
342
+ if (state === void 0 || !state.intents.some((intent) => intent.id === id)) return;
343
+ if (state.eraseDeferred) throw gateRefusal("purge_pending", "Deferred replica erase has not been resumed.");
344
+ await writeState(profileDir, { ...state, intents: state.intents.filter((intent) => intent.id !== id) });
345
+ }
346
+ async function writeAtomic(profileDir, name, body) {
347
+ await (0, import_promises.mkdir)(profileDir, { recursive: true });
348
+ const target = (0, import_node_path.join)(profileDir, name);
349
+ const temp = `${target}.${(0, import_node_crypto.randomUUID)()}.tmp`;
350
+ try {
351
+ const handle = await (0, import_promises.open)(temp, "w", 384);
352
+ try {
353
+ await handle.writeFile(body);
354
+ await handle.sync();
355
+ } finally {
356
+ await handle.close();
357
+ }
358
+ await (0, import_promises.rename)(temp, target);
359
+ const directory = await (0, import_promises.open)(profileDir, "r");
360
+ try {
361
+ await directory.sync();
362
+ } finally {
363
+ await directory.close();
364
+ }
365
+ } catch (error) {
366
+ await (0, import_promises.rm)(temp, { force: true });
367
+ throw error;
368
+ }
369
+ }
370
+ function writeState(profileDir, state) {
371
+ return writeAtomic(profileDir, REPLICA_GATE_FILE, JSON.stringify({ v: GATE_FILE_VERSION, ...state }));
372
+ }
373
+ function freshState() {
374
+ return {
375
+ incarnation: (0, import_node_crypto.randomUUID)(),
376
+ salt: (0, import_node_crypto.randomBytes)(32).toString("base64url"),
377
+ principalHash: null,
378
+ lifecycleEpoch: 0,
379
+ deniedAt: null,
380
+ intents: [],
381
+ firstInstall: {},
382
+ grantInstall: {},
383
+ pendingGenerations: {},
384
+ eraseDeferred: false,
385
+ deferredPrincipalHash: null
386
+ };
387
+ }
388
+ async function readGateState(profileDir) {
389
+ const existing = await readState(profileDir);
390
+ if (existing !== void 0) return existing;
391
+ const created = freshState();
392
+ await writeState(profileDir, created);
393
+ return created;
394
+ }
395
+ async function readFirstInstall(profileDir, identityKeyHash) {
396
+ const state = await readState(profileDir);
397
+ if (state?.eraseDeferred) throw gateRefusal("purge_pending", "Deferred replica erase has not been resumed.");
398
+ return state?.firstInstall[identityKeyHash];
399
+ }
400
+ async function recordFirstInstall(profileDir, identityKeyHash, atMs) {
401
+ if (!/^[0-9a-f]{64}$/.test(identityKeyHash) || !Number.isSafeInteger(atMs) || atMs < 0) {
402
+ throw gateRefusal("gate_unavailable", "A salted identity hash and finite first-install time are required.");
403
+ }
404
+ const state = await readGateState(profileDir);
405
+ if (state.eraseDeferred) throw gateRefusal("purge_pending", "Deferred replica erase has not been resumed.");
406
+ const existing = state.firstInstall[identityKeyHash];
407
+ if (existing !== void 0) return existing;
408
+ await writeState(profileDir, { ...state, firstInstall: { ...state.firstInstall, [identityKeyHash]: atMs } });
409
+ return atMs;
410
+ }
411
+ async function recordGrantInstall(profileDir, hash, atMs) {
412
+ if (!/^[0-9a-f]{64}$/.test(hash) || !Number.isSafeInteger(atMs) || atMs < 0) {
413
+ throw gateRefusal("gate_unavailable", "A salted grant hash and finite install time are required.");
414
+ }
415
+ const state = await readState(profileDir);
416
+ if (state === void 0 || state.principalHash === null) {
417
+ throw gateRefusal("gate_unavailable", "The replica account gate is not established.");
418
+ }
419
+ if (state.eraseDeferred || state.intents.length > 0) {
420
+ throw gateRefusal("purge_pending", "A purge is pending for this profile.");
421
+ }
422
+ const previous = state.grantInstall[hash];
423
+ if (previous !== void 0 && previous <= atMs) return previous;
424
+ await writeState(profileDir, { ...state, grantInstall: { ...state.grantInstall, [hash]: atMs } });
425
+ return atMs;
426
+ }
427
+ async function establishSessionStamp(profileDir, principal, o) {
428
+ if (!Number.isSafeInteger(o?.siweIssuedAt) || o.siweIssuedAt < 0) {
429
+ throw gateRefusal("not_established", "A finite signed SIWE issued-at time is required.");
430
+ }
431
+ const issuedAt = Math.min(o.siweIssuedAt, Date.now());
432
+ const state = await readGateState(profileDir);
433
+ if (state.eraseDeferred || state.intents.length > 0) throw gateRefusal("purge_pending", "A purge is pending for this profile.");
434
+ if (state.deniedAt !== null && issuedAt <= state.deniedAt) {
435
+ throw gateRefusal("stamp_stale", "The sign-in predates the last sign-out.");
436
+ }
437
+ const hash = await principalHashOf(state.salt, principal);
438
+ if (state.principalHash === null) {
439
+ await writeState(profileDir, { ...state, principalHash: hash, deferredPrincipalHash: null });
440
+ } else if (state.principalHash !== hash) {
441
+ throw gateRefusal("principal_mismatch", "The profile belongs to another account.");
442
+ }
443
+ return { incarnation: state.incarnation, lifecycleEpoch: state.lifecycleEpoch };
444
+ }
445
+ async function restoreSessionStamp(profileDir, principal, persisted) {
446
+ const state = await readState(profileDir);
447
+ if (state !== void 0 && (state.eraseDeferred || state.intents.length > 0)) {
448
+ throw gateRefusal("purge_pending", "A purge is pending for this profile.");
449
+ }
450
+ if (persisted === void 0) throw gateRefusal("not_established", "The session has no replica stamp.");
451
+ 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) {
452
+ throw gateRefusal("stamp_stale", "The persisted replica stamp is malformed.");
453
+ }
454
+ if (state === void 0) throw gateRefusal("stamp_stale", "The replica gate no longer exists.");
455
+ if (persisted.incarnation !== state.incarnation || persisted.lifecycleEpoch !== state.lifecycleEpoch) {
456
+ throw gateRefusal("stamp_stale", "The session stamp is from an earlier sign-in.");
457
+ }
458
+ if (state.principalHash !== await principalHashOf(state.salt, principal)) {
459
+ throw gateRefusal("principal_mismatch", "The profile belongs to another account.");
460
+ }
461
+ return { incarnation: persisted.incarnation, lifecycleEpoch: persisted.lifecycleEpoch };
462
+ }
463
+ async function denyGate(profileDir, input, collectIntents, precomputed, options) {
464
+ if (input.reason === "account_switch" && input.next === void 0) {
465
+ throw new TypeError("An account switch needs the incoming principal.");
466
+ }
467
+ if (options?.deferErase === true && input.reason !== "sign_out") {
468
+ throw new TypeError("Deferred erase is only available for sign-out.");
469
+ }
470
+ try {
471
+ const state = await readStoredState(profileDir);
472
+ const floor = await readDenyFloor(profileDir);
473
+ if (state === void 0 && floor !== void 0) {
474
+ throw gateRefusal("gate_unavailable", `The replica gate is missing after a sign-out. ${LOST_GATE_RECOVERY}`);
475
+ }
476
+ if (state === void 0 || state.principalHash === null) return { status: "stale" };
477
+ if (precomputed !== void 0 && state.salt !== precomputed.salt) {
478
+ throw gateRefusal("gate_unavailable", "The replica gate salt changed before denial.");
479
+ }
480
+ if (state.principalHash !== (precomputed?.principalHash ?? await principalHashOf(state.salt, input.principal))) {
481
+ return { status: "stale" };
482
+ }
483
+ const intents = await collectIntents(state.principalHash);
484
+ const known = new Set(state.intents.map((intent) => intent.id));
485
+ const added = [];
486
+ for (const intent of intents) {
487
+ if (known.has(intent.id)) continue;
488
+ known.add(intent.id);
489
+ added.push(intent);
490
+ }
491
+ const pendingGenerations = { ...state.pendingGenerations };
492
+ for (const intent of added) {
493
+ if (intent.kind !== "pending") continue;
494
+ const hash = intent.identityKeyHash;
495
+ if (!/^[0-9a-f]{64}$/.test(hash) || intent.id !== `pending:${hash}` || !Number.isSafeInteger((pendingGenerations[hash] ?? 0) + 1)) {
496
+ throw gateRefusal("gate_unavailable", "A pending purge intent has an invalid identity generation.");
497
+ }
498
+ pendingGenerations[hash] = (pendingGenerations[hash] ?? 0) + 1;
499
+ }
500
+ const merged = [...state.intents, ...added];
501
+ const lifecycleEpoch = Math.max(state.lifecycleEpoch, floor?.epoch ?? 0) + 1;
502
+ const deniedAt = Math.max(Date.now(), (state.deniedAt ?? -1) + 1, (floor?.deniedAt ?? -1) + 1);
503
+ if (!Number.isSafeInteger(lifecycleEpoch) || !Number.isSafeInteger(deniedAt)) {
504
+ throw gateRefusal("gate_unavailable", "The replica deny floor cannot advance.");
505
+ }
506
+ const next = {
507
+ ...state,
508
+ principalHash: null,
509
+ lifecycleEpoch,
510
+ deniedAt,
511
+ intents: merged,
512
+ firstInstall: {},
513
+ grantInstall: {},
514
+ pendingGenerations,
515
+ eraseDeferred: options?.deferErase === true,
516
+ deferredPrincipalHash: options?.deferErase === true ? state.principalHash : null
517
+ };
518
+ await writeAtomic(profileDir, DENY_FLOOR_FILE, JSON.stringify({ epoch: lifecycleEpoch, deniedAt }));
519
+ await writeState(profileDir, next);
520
+ return { status: "denied", lifecycleEpoch: next.lifecycleEpoch, intents: merged.length };
521
+ } catch (error) {
522
+ const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : "STORAGE_ERROR";
523
+ return { status: "unguarded", code };
524
+ }
525
+ }
526
+ async function deferredGateToken(profileDir) {
527
+ const state = await readState(profileDir);
528
+ return state?.eraseDeferred ? { deniedEpoch: state.lifecycleEpoch, principalHash: state.deferredPrincipalHash } : null;
529
+ }
530
+ async function resumeDeferredGate(profileDir, token) {
531
+ const state = await readState(profileDir);
532
+ if (state === void 0) throw gateRefusal("gate_unavailable", "The replica gate no longer exists.");
533
+ if (!Number.isSafeInteger(token?.deniedEpoch) || token.deniedEpoch < 0 || typeof token.principalHash !== "string" || !/^[0-9a-f]{64}$/.test(token.principalHash) || state.lifecycleEpoch !== token.deniedEpoch || state.deferredPrincipalHash !== token.principalHash || state.principalHash !== null) {
534
+ throw gateRefusal("stamp_stale", "The deferred erase token is stale or belongs to another account.");
535
+ }
536
+ if (!state.eraseDeferred) return false;
537
+ await writeState(profileDir, { ...state, eraseDeferred: false });
538
+ return true;
539
+ }
540
+ // Annotate the CommonJS export names for ESM import in node:
541
+ 0 && (module.exports = {
542
+ REPLICA_DENY_FLOOR_FILE,
543
+ advanceInactiveGate,
544
+ assertGateAccess,
545
+ clearGateIntent,
546
+ deferredGateToken,
547
+ denyGate,
548
+ establishSessionStamp,
549
+ grantInstallHashOf,
550
+ identityHashOf,
551
+ principalHashOf,
552
+ readFirstInstall,
553
+ readGateSnapshot,
554
+ readLogoutGate,
555
+ recordFirstInstall,
556
+ recordGrantInstall,
557
+ restoreSessionStamp,
558
+ resumeDeferredGate
559
+ });
560
+ //# sourceMappingURL=profile-gate.cjs.map