@openlfcp/storage 0.1.0-rc.1
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/LICENSE +201 -0
- package/README.md +27 -0
- package/dist/contract.d.ts +30 -0
- package/dist/contract.js +513 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/memory.d.ts +69 -0
- package/dist/memory.js +367 -0
- package/dist/secrets.d.ts +53 -0
- package/dist/secrets.js +60 -0
- package/dist/sequence.d.ts +55 -0
- package/dist/sequence.js +55 -0
- package/dist/snapshot-sequence.d.ts +39 -0
- package/dist/snapshot-sequence.js +34 -0
- package/dist/store.d.ts +424 -0
- package/dist/store.js +2 -0
- package/package.json +50 -0
package/dist/memory.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { bytesEqual, LfcpError, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { InMemoryActorSequenceReservation } from "./sequence.js";
|
|
3
|
+
import { InMemorySnapshotSequenceReservation, } from "./snapshot-sequence.js";
|
|
4
|
+
/** A deep copy: every Uint8Array copied, everything frozen. Nothing stored aliases a caller's buffer. */
|
|
5
|
+
function own(value) {
|
|
6
|
+
if (value instanceof Uint8Array)
|
|
7
|
+
return Uint8Array.from(value);
|
|
8
|
+
if (Array.isArray(value))
|
|
9
|
+
return Object.freeze(value.map(own));
|
|
10
|
+
if (value !== null && typeof value === "object")
|
|
11
|
+
return Object.freeze(Object.fromEntries(Object.entries(value).map(([k, v]) => [k, own(v)])));
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
const hex = toHex;
|
|
15
|
+
const byBytes = (a, b) => {
|
|
16
|
+
const [x, y] = [hex(a), hex(b)];
|
|
17
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
18
|
+
};
|
|
19
|
+
const tuple = (resource, actor, seq) => `${hex(resource)}:${hex(actor)}:${seq}`;
|
|
20
|
+
const emptyState = () => ({
|
|
21
|
+
records: new Map(),
|
|
22
|
+
heads: new Map(),
|
|
23
|
+
conflicts: new Map(),
|
|
24
|
+
epochs: new Map(),
|
|
25
|
+
units: new Map(),
|
|
26
|
+
keyPackages: new Map(),
|
|
27
|
+
snapshots: new Map(),
|
|
28
|
+
resources: new Map(),
|
|
29
|
+
routes: new Map(),
|
|
30
|
+
outbound: new Map(),
|
|
31
|
+
checkpoints: new Map(),
|
|
32
|
+
syncStates: new Map(),
|
|
33
|
+
marks: new Map(),
|
|
34
|
+
});
|
|
35
|
+
/** Rows are immutable, so a staged copy of the maps is enough for all-or-nothing batches. */
|
|
36
|
+
const stage = (s) => ({
|
|
37
|
+
records: new Map(s.records),
|
|
38
|
+
heads: new Map(s.heads),
|
|
39
|
+
conflicts: new Map(s.conflicts),
|
|
40
|
+
epochs: new Map([...s.epochs].map(([k, v]) => [k, new Map(v)])),
|
|
41
|
+
units: new Map(s.units),
|
|
42
|
+
keyPackages: new Map(s.keyPackages),
|
|
43
|
+
snapshots: new Map(s.snapshots),
|
|
44
|
+
resources: new Map(s.resources),
|
|
45
|
+
routes: new Map(s.routes),
|
|
46
|
+
outbound: new Map(s.outbound),
|
|
47
|
+
checkpoints: new Map(s.checkpoints),
|
|
48
|
+
syncStates: new Map(s.syncStates),
|
|
49
|
+
marks: new Map(s.marks),
|
|
50
|
+
});
|
|
51
|
+
/** Stores an immutable object under its ID; the same ID with other bytes is refused. */
|
|
52
|
+
function putImmutable(map, id, row, what) {
|
|
53
|
+
const old = map.get(hex(id));
|
|
54
|
+
if (old !== undefined) {
|
|
55
|
+
if (!bytesEqual(old.bytes, row.bytes))
|
|
56
|
+
throw new LfcpError("INVALID_STRUCTURE", `${what} ${hex(id)} is stored with other bytes`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
map.set(hex(id), own(row));
|
|
60
|
+
}
|
|
61
|
+
function apply(s, w) {
|
|
62
|
+
switch (w.op) {
|
|
63
|
+
case "put-control-records":
|
|
64
|
+
for (const r of w.records)
|
|
65
|
+
putImmutable(s.records, r.recordId, r, "Control Record");
|
|
66
|
+
return;
|
|
67
|
+
case "set-control-head":
|
|
68
|
+
s.heads.set(hex(w.resourceId), own(w.head));
|
|
69
|
+
return;
|
|
70
|
+
case "set-control-conflict":
|
|
71
|
+
if (w.conflict === null)
|
|
72
|
+
s.conflicts.delete(hex(w.resourceId));
|
|
73
|
+
else
|
|
74
|
+
s.conflicts.set(hex(w.resourceId), own(w.conflict));
|
|
75
|
+
return;
|
|
76
|
+
case "put-epoch": {
|
|
77
|
+
const key = hex(w.resourceId);
|
|
78
|
+
const epochs = s.epochs.get(key) ?? new Map();
|
|
79
|
+
const stored = epochs.get(BigInt(w.epoch.epoch));
|
|
80
|
+
epochs.set(BigInt(w.epoch.epoch), own({
|
|
81
|
+
...w.epoch,
|
|
82
|
+
closedBy: w.epoch.closedBy ?? stored?.closedBy ?? null,
|
|
83
|
+
dekRef: w.epoch.dekRef ?? stored?.dekRef ?? null,
|
|
84
|
+
}));
|
|
85
|
+
s.epochs.set(key, epochs);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
case "expect-previous-unit":
|
|
89
|
+
return; // a precondition, checked by commit()
|
|
90
|
+
case "put-data-unit": {
|
|
91
|
+
const old = s.units.get(hex(w.unit.unitId));
|
|
92
|
+
if (old !== undefined && !bytesEqual(old.bytes, w.unit.bytes))
|
|
93
|
+
throw new LfcpError("INVALID_STRUCTURE", `Data Unit ${hex(w.unit.unitId)} is stored with other bytes`);
|
|
94
|
+
const base = old ?? w.unit;
|
|
95
|
+
s.units.set(hex(w.unit.unitId), own({
|
|
96
|
+
...pickUnit(base),
|
|
97
|
+
status: w.status,
|
|
98
|
+
detail: w.detail ?? null,
|
|
99
|
+
accepted: w.accepted ?? old?.accepted ?? false,
|
|
100
|
+
}));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
case "set-data-unit-status": {
|
|
104
|
+
const old = known(s, w.unitId);
|
|
105
|
+
s.units.set(hex(w.unitId), own({ ...old, status: w.status, detail: w.detail ?? null }));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
case "set-accepted": {
|
|
109
|
+
const old = known(s, w.unitId);
|
|
110
|
+
s.units.set(hex(w.unitId), own({ ...old, accepted: w.accepted }));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
case "put-key-package":
|
|
114
|
+
putImmutable(s.keyPackages, w.row.packageId, w.row, "Key Package");
|
|
115
|
+
return;
|
|
116
|
+
case "delete-snapshot":
|
|
117
|
+
s.snapshots.delete(hex(w.snapshotId));
|
|
118
|
+
return;
|
|
119
|
+
case "put-snapshot":
|
|
120
|
+
putImmutable(s.snapshots, w.row.snapshotId, w.row, "Snapshot");
|
|
121
|
+
return;
|
|
122
|
+
case "put-resource":
|
|
123
|
+
s.resources.set(hex(w.row.resourceId), own(w.row));
|
|
124
|
+
return;
|
|
125
|
+
case "put-route":
|
|
126
|
+
s.routes.set(hex(w.resourceId), own(w.route));
|
|
127
|
+
return;
|
|
128
|
+
case "enqueue":
|
|
129
|
+
putImmutable(s.outbound, w.item.itemId, w.item, "outbound item");
|
|
130
|
+
return;
|
|
131
|
+
case "update-outbound": {
|
|
132
|
+
const old = s.outbound.get(hex(w.itemId));
|
|
133
|
+
if (old === undefined)
|
|
134
|
+
throw new LfcpError("INVALID_STRUCTURE", `no outbound item ${hex(w.itemId)}`);
|
|
135
|
+
s.outbound.set(hex(w.itemId), own({
|
|
136
|
+
...old,
|
|
137
|
+
...(w.attempts === undefined ? {} : { attempts: w.attempts }),
|
|
138
|
+
...(w.lastAttempt === undefined ? {} : { lastAttempt: w.lastAttempt }),
|
|
139
|
+
...(w.nextAttempt === undefined ? {} : { nextAttempt: w.nextAttempt }),
|
|
140
|
+
...(w.blocked === undefined ? {} : { blocked: w.blocked }),
|
|
141
|
+
}));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
case "dequeue":
|
|
145
|
+
s.outbound.delete(hex(w.itemId));
|
|
146
|
+
return;
|
|
147
|
+
case "put-profile-checkpoint":
|
|
148
|
+
s.checkpoints.set(hex(w.checkpoint.resourceId), own(w.checkpoint));
|
|
149
|
+
return;
|
|
150
|
+
case "put-sync-state":
|
|
151
|
+
s.syncStates.set(hex(w.row.resourceId), own(w.row));
|
|
152
|
+
return;
|
|
153
|
+
case "put-local-mark":
|
|
154
|
+
if (w.value === null)
|
|
155
|
+
s.marks.delete(w.key);
|
|
156
|
+
else
|
|
157
|
+
s.marks.set(w.key, w.value);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const pickUnit = (u) => ({
|
|
162
|
+
unitId: u.unitId,
|
|
163
|
+
resourceId: u.resourceId,
|
|
164
|
+
dataEpoch: u.dataEpoch,
|
|
165
|
+
actor: u.actor,
|
|
166
|
+
actorSeq: u.actorSeq,
|
|
167
|
+
prevDataUnitId: u.prevDataUnitId,
|
|
168
|
+
controlHead: u.controlHead,
|
|
169
|
+
bytes: u.bytes,
|
|
170
|
+
});
|
|
171
|
+
function known(s, unitId) {
|
|
172
|
+
const u = s.units.get(hex(unitId));
|
|
173
|
+
if (u === undefined)
|
|
174
|
+
throw new LfcpError("INVALID_STRUCTURE", `no Data Unit ${hex(unitId)}`);
|
|
175
|
+
return u;
|
|
176
|
+
}
|
|
177
|
+
const ACCEPTED_ORDER = (a, b) => byBytes(a.actor, b.actor) ||
|
|
178
|
+
(a.actorSeq < b.actorSeq ? -1 : a.actorSeq > b.actorSeq ? 1 : 0) ||
|
|
179
|
+
byBytes(a.unitId, b.unitId);
|
|
180
|
+
/**
|
|
181
|
+
* FOR TESTS AND DEVELOPMENT ONLY: an LfcpStorage in process memory, lost on
|
|
182
|
+
* restart, with the same semantic contracts as a durable adapter (exact
|
|
183
|
+
* bytes copied in and out, immutable objects, all-or-nothing batches with
|
|
184
|
+
* their preconditions). Deterministic: no clock, no randomness, ordered
|
|
185
|
+
* results.
|
|
186
|
+
*/
|
|
187
|
+
export class InMemoryLfcpStorage {
|
|
188
|
+
#state = emptyState();
|
|
189
|
+
#actorCounter = new InMemoryActorSequenceReservation();
|
|
190
|
+
#snapshotCounter = new InMemorySnapshotSequenceReservation();
|
|
191
|
+
/** Reservations fail closed when the counter is behind this Principal's own stored objects (§9, §29). */
|
|
192
|
+
actorSequences = {
|
|
193
|
+
reserveNext: async (resource, principal) => {
|
|
194
|
+
const next = await this.#actorCounter.reserveNext(resource, principal);
|
|
195
|
+
const max = [...this.#state.units.values()]
|
|
196
|
+
.filter((u) => bytesEqual(u.resourceId, resource) && bytesEqual(u.actor, principal))
|
|
197
|
+
.reduce((m, u) => (u.actorSeq > m ? BigInt(u.actorSeq) : m), 0n);
|
|
198
|
+
if (next <= max)
|
|
199
|
+
throw new LfcpError("SEQUENCE_REUSE", `the actor sequence state is behind the stored units of this Principal (${max}); refusing to reserve (§9)`);
|
|
200
|
+
return next;
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
snapshotSequences = {
|
|
204
|
+
reserveNext: async (resource, epoch, publisher) => {
|
|
205
|
+
const next = await this.#snapshotCounter.reserveNext(resource, epoch, publisher);
|
|
206
|
+
const max = [...this.#state.snapshots.values()]
|
|
207
|
+
.filter((x) => bytesEqual(x.resourceId, resource) &&
|
|
208
|
+
x.dataEpoch === epoch &&
|
|
209
|
+
bytesEqual(x.publisher, publisher))
|
|
210
|
+
.reduce((m, x) => (x.snapshotSeq > m ? x.snapshotSeq : m), 0n);
|
|
211
|
+
if (next <= max)
|
|
212
|
+
throw new LfcpError("SEQUENCE_REUSE", `the Snapshot Sequence state is behind the stored Snapshots of this publisher (${max}); refusing to reserve (§29)`);
|
|
213
|
+
return next;
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
commit(writes) {
|
|
217
|
+
const next = stage(this.#state);
|
|
218
|
+
for (const w of writes) {
|
|
219
|
+
if (w.op !== "set-control-head")
|
|
220
|
+
continue;
|
|
221
|
+
const current = next.heads.get(hex(w.resourceId))?.head ?? null;
|
|
222
|
+
const matches = current === null
|
|
223
|
+
? w.expected === null
|
|
224
|
+
: w.expected !== null && bytesEqual(current, w.expected);
|
|
225
|
+
if (!matches)
|
|
226
|
+
return Promise.resolve(Object.freeze({
|
|
227
|
+
ok: false,
|
|
228
|
+
reason: "CONTROL_HEAD_MISMATCH",
|
|
229
|
+
resourceId: own(w.resourceId),
|
|
230
|
+
current: current === null ? null : own(current),
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
for (const w of writes) {
|
|
234
|
+
if (w.op !== "expect-previous-unit")
|
|
235
|
+
continue;
|
|
236
|
+
let latest;
|
|
237
|
+
for (const u of next.units.values())
|
|
238
|
+
if (u.accepted &&
|
|
239
|
+
bytesEqual(u.resourceId, w.resourceId) &&
|
|
240
|
+
bytesEqual(u.actor, w.actor) &&
|
|
241
|
+
(latest === undefined || u.actorSeq > latest.actorSeq))
|
|
242
|
+
latest = u;
|
|
243
|
+
const current = latest?.unitId ?? null;
|
|
244
|
+
const matches = current === null
|
|
245
|
+
? w.previous === null
|
|
246
|
+
: w.previous !== null && bytesEqual(current, w.previous);
|
|
247
|
+
if (!matches)
|
|
248
|
+
return Promise.resolve(Object.freeze({
|
|
249
|
+
ok: false,
|
|
250
|
+
reason: "PREVIOUS_UNIT_MISMATCH",
|
|
251
|
+
resourceId: own(w.resourceId),
|
|
252
|
+
actor: own(w.actor),
|
|
253
|
+
current: current === null ? null : own(current),
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
for (const w of writes)
|
|
258
|
+
apply(next, w);
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
return Promise.reject(e);
|
|
262
|
+
}
|
|
263
|
+
this.#state = next;
|
|
264
|
+
return Promise.resolve(Object.freeze({ ok: true }));
|
|
265
|
+
}
|
|
266
|
+
control = {
|
|
267
|
+
record: (id) => Promise.resolve(copyOf(this.#state.records.get(hex(id)))),
|
|
268
|
+
records: (resource) => Promise.resolve([...this.#state.records.values()]
|
|
269
|
+
.filter((r) => bytesEqual(r.resourceId, resource))
|
|
270
|
+
.sort((a, b) => a.controlSeq < b.controlSeq
|
|
271
|
+
? -1
|
|
272
|
+
: a.controlSeq > b.controlSeq
|
|
273
|
+
? 1
|
|
274
|
+
: byBytes(a.recordId, b.recordId))
|
|
275
|
+
.map(own)),
|
|
276
|
+
head: (resource) => Promise.resolve(copyOf(this.#state.heads.get(hex(resource)))),
|
|
277
|
+
conflict: (resource) => Promise.resolve(copyOf(this.#state.conflicts.get(hex(resource)))),
|
|
278
|
+
epochs: (resource) => Promise.resolve([...(this.#state.epochs.get(hex(resource))?.values() ?? [])]
|
|
279
|
+
.sort((a, b) => (a.epoch < b.epoch ? -1 : a.epoch > b.epoch ? 1 : 0))
|
|
280
|
+
.map(own)),
|
|
281
|
+
};
|
|
282
|
+
dataUnits = {
|
|
283
|
+
get: (id) => Promise.resolve(copyOf(this.#state.units.get(hex(id)))),
|
|
284
|
+
at: (resource, actor, seq) => Promise.resolve(this.#at(resource, actor, seq).map(own)),
|
|
285
|
+
range: (resource, actor, from, to) => Promise.resolve(this.#units((u) => bytesEqual(u.resourceId, resource) &&
|
|
286
|
+
bytesEqual(u.actor, actor) &&
|
|
287
|
+
u.actorSeq >= from &&
|
|
288
|
+
u.actorSeq <= to)),
|
|
289
|
+
withStatus: (resource, status) => Promise.resolve(this.#units((u) => bytesEqual(u.resourceId, resource) && u.status === status)),
|
|
290
|
+
acceptedAt: (resource, actor, seq) => Promise.resolve(copyOf(this.#at(resource, actor, seq).find((u) => u.accepted)?.unitId)),
|
|
291
|
+
recordSeen: (unit) => {
|
|
292
|
+
const firstSeen = !this.#state.units.has(hex(unit.unitId));
|
|
293
|
+
if (firstSeen) {
|
|
294
|
+
const next = stage(this.#state);
|
|
295
|
+
apply(next, { op: "put-data-unit", unit, status: "seen" });
|
|
296
|
+
this.#state = next;
|
|
297
|
+
}
|
|
298
|
+
else if (!bytesEqual(this.#state.units.get(hex(unit.unitId)).bytes, unit.bytes)) {
|
|
299
|
+
return Promise.reject(new LfcpError("INVALID_STRUCTURE", `Data Unit ${hex(unit.unitId)} is stored with other bytes`));
|
|
300
|
+
}
|
|
301
|
+
const unitIds = this.#at(unit.resourceId, unit.actor, unit.actorSeq)
|
|
302
|
+
.map((u) => own(u.unitId))
|
|
303
|
+
.sort(byBytes);
|
|
304
|
+
return Promise.resolve(Object.freeze({ unitIds: Object.freeze(unitIds), firstSeen }));
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
#at(resource, actor, seq) {
|
|
308
|
+
const key = tuple(resource, actor, seq);
|
|
309
|
+
return [...this.#state.units.values()]
|
|
310
|
+
.filter((u) => tuple(u.resourceId, u.actor, u.actorSeq) === key)
|
|
311
|
+
.sort((a, b) => byBytes(a.unitId, b.unitId));
|
|
312
|
+
}
|
|
313
|
+
#units(keep) {
|
|
314
|
+
return [...this.#state.units.values()].filter(keep).sort(ACCEPTED_ORDER).map(own);
|
|
315
|
+
}
|
|
316
|
+
keyPackages = {
|
|
317
|
+
get: (id) => Promise.resolve(copyOf(this.#state.keyPackages.get(hex(id)))),
|
|
318
|
+
list: (resource, filter = {}) => Promise.resolve([...this.#state.keyPackages.values()]
|
|
319
|
+
.filter((k) => bytesEqual(k.resourceId, resource) &&
|
|
320
|
+
(filter.epoch === undefined || k.dataEpoch === filter.epoch) &&
|
|
321
|
+
(filter.recipient === undefined || bytesEqual(k.recipient, filter.recipient)))
|
|
322
|
+
.sort((a, b) => a.dataEpoch < b.dataEpoch
|
|
323
|
+
? -1
|
|
324
|
+
: a.dataEpoch > b.dataEpoch
|
|
325
|
+
? 1
|
|
326
|
+
: byBytes(a.packageId, b.packageId))
|
|
327
|
+
.map(own)),
|
|
328
|
+
};
|
|
329
|
+
snapshots = {
|
|
330
|
+
get: (id) => Promise.resolve(copyOf(this.#state.snapshots.get(hex(id)))),
|
|
331
|
+
list: (resource, filter = {}) => Promise.resolve([...this.#state.snapshots.values()]
|
|
332
|
+
.filter((s) => bytesEqual(s.resourceId, resource) &&
|
|
333
|
+
(filter.epoch === undefined || s.dataEpoch === filter.epoch))
|
|
334
|
+
.sort((a, b) => (a.dataEpoch < b.dataEpoch ? -1 : a.dataEpoch > b.dataEpoch ? 1 : 0) ||
|
|
335
|
+
byBytes(a.publisher, b.publisher) ||
|
|
336
|
+
(a.snapshotSeq < b.snapshotSeq ? -1 : a.snapshotSeq > b.snapshotSeq ? 1 : 0))
|
|
337
|
+
.map(own)),
|
|
338
|
+
};
|
|
339
|
+
resources = {
|
|
340
|
+
get: (resource) => Promise.resolve(copyOf(this.#state.resources.get(hex(resource)))),
|
|
341
|
+
list: () => Promise.resolve([...this.#state.resources.values()]
|
|
342
|
+
.sort((a, b) => byBytes(a.resourceId, b.resourceId))
|
|
343
|
+
.map(own)),
|
|
344
|
+
route: (resource) => Promise.resolve(copyOf(this.#state.routes.get(hex(resource)))),
|
|
345
|
+
};
|
|
346
|
+
outbound = {
|
|
347
|
+
list: (resource) => Promise.resolve([...this.#state.outbound.values()]
|
|
348
|
+
.filter((o) => resource === undefined || bytesEqual(o.resourceId, resource))
|
|
349
|
+
.map(own)),
|
|
350
|
+
get: (id) => Promise.resolve(copyOf(this.#state.outbound.get(hex(id)))),
|
|
351
|
+
};
|
|
352
|
+
profileState = {
|
|
353
|
+
checkpoint: (resource) => Promise.resolve(copyOf(this.#state.checkpoints.get(hex(resource)))),
|
|
354
|
+
};
|
|
355
|
+
syncState = {
|
|
356
|
+
get: (resource) => Promise.resolve(copyOf(this.#state.syncStates.get(hex(resource)))),
|
|
357
|
+
};
|
|
358
|
+
localMarks = {
|
|
359
|
+
get: (key) => Promise.resolve(this.#state.marks.get(key)),
|
|
360
|
+
list: (prefix) => Promise.resolve([...this.#state.marks]
|
|
361
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
362
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
363
|
+
.map(([key, value]) => ({ key, value }))),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const copyOf = (value) => value === undefined ? undefined : own(value);
|
|
367
|
+
//# sourceMappingURL=memory.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type DataEpoch, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
|
+
/**
|
|
3
|
+
* Client secret material (LFCP-034): Principal private keys, Resource DEKs
|
|
4
|
+
* and invitation secrets live only in a SecretStore. Public storage rows
|
|
5
|
+
* hold a SecretRef, a name that reveals nothing about the value.
|
|
6
|
+
*
|
|
7
|
+
* A SecretStore cannot be enumerated: there is no list operation, so no
|
|
8
|
+
* caller can dump its contents into a log or a diagnostic. Backends are
|
|
9
|
+
* platform specific: app.secretStorage in Obsidian (LFCP-059), a file
|
|
10
|
+
* keystore in Node (LFCP-035). No cloud KMS is required.
|
|
11
|
+
*
|
|
12
|
+
* Ordering rule, since a SecretStore is usually a different backend from
|
|
13
|
+
* the public rows and cannot join their atomic batch: write the secret
|
|
14
|
+
* first, then commit the row that references it. A reference without a
|
|
15
|
+
* value is a recoverable gap; a value without a reference is harmless.
|
|
16
|
+
*/
|
|
17
|
+
export type SecretKind = "principal-signing-key" | "principal-agreement-key" | "resource-dek" | "invitation-secret";
|
|
18
|
+
/** The name of a secret: "lfcp-secret:<kind>:<id>". It never contains secret material. */
|
|
19
|
+
export type SecretRef = string & {
|
|
20
|
+
readonly __secretRef: true;
|
|
21
|
+
};
|
|
22
|
+
/** A secret reference; `id` is a public name such as hex IDs joined with ".". */
|
|
23
|
+
export declare function secretRef(kind: SecretKind, id: string): SecretRef;
|
|
24
|
+
/** True when `text` is a well-formed secret reference. */
|
|
25
|
+
export declare function isSecretRef(text: unknown): text is SecretRef;
|
|
26
|
+
/** The reference of the DEK of `epoch` of `resource`. */
|
|
27
|
+
export declare const dekSecretRef: (resource: ResourceId, epoch: DataEpoch) => SecretRef;
|
|
28
|
+
/** The reference of a local Principal's private signing or agreement key. */
|
|
29
|
+
export declare const principalKeySecretRef: (principal: PrincipalId, which: "signing" | "agreement") => SecretRef;
|
|
30
|
+
export interface SecretStore {
|
|
31
|
+
/** Stores a copy of `value` under `ref`, replacing any previous value. Durable before it resolves. */
|
|
32
|
+
put(ref: SecretRef, value: Uint8Array): Promise<void>;
|
|
33
|
+
/** A copy of the value, or undefined. */
|
|
34
|
+
get(ref: SecretRef): Promise<Uint8Array | undefined>;
|
|
35
|
+
delete(ref: SecretRef): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
declare const INSPECT: unique symbol;
|
|
38
|
+
/**
|
|
39
|
+
* FOR TESTS AND DEVELOPMENT ONLY: secrets in plain process memory, lost on
|
|
40
|
+
* restart. Renders as "[InMemorySecretStore]" in JSON and inspection, so it
|
|
41
|
+
* cannot leak values into logs by accident.
|
|
42
|
+
*/
|
|
43
|
+
export declare class InMemorySecretStore implements SecretStore {
|
|
44
|
+
#private;
|
|
45
|
+
put(ref: SecretRef, value: Uint8Array): Promise<void>;
|
|
46
|
+
get(ref: SecretRef): Promise<Uint8Array | undefined>;
|
|
47
|
+
delete(ref: SecretRef): Promise<void>;
|
|
48
|
+
toJSON(): string;
|
|
49
|
+
toString(): string;
|
|
50
|
+
[INSPECT](): string;
|
|
51
|
+
}
|
|
52
|
+
export {};
|
|
53
|
+
//# sourceMappingURL=secrets.d.ts.map
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { dataEpoch, LfcpError, toHex, } from "@openlfcp/core";
|
|
2
|
+
const KINDS = new Set([
|
|
3
|
+
"principal-signing-key",
|
|
4
|
+
"principal-agreement-key",
|
|
5
|
+
"resource-dek",
|
|
6
|
+
"invitation-secret",
|
|
7
|
+
]);
|
|
8
|
+
const ID = /^[A-Za-z0-9._-]{1,200}$/;
|
|
9
|
+
/** A secret reference; `id` is a public name such as hex IDs joined with ".". */
|
|
10
|
+
export function secretRef(kind, id) {
|
|
11
|
+
if (!KINDS.has(kind))
|
|
12
|
+
throw new LfcpError("UNSUPPORTED_VALUE", `unknown secret kind ${kind}`);
|
|
13
|
+
if (!ID.test(id))
|
|
14
|
+
throw new LfcpError("UNSUPPORTED_VALUE", "a secret reference id is [A-Za-z0-9._-]{1,200}");
|
|
15
|
+
return `lfcp-secret:${kind}:${id}`;
|
|
16
|
+
}
|
|
17
|
+
/** True when `text` is a well-formed secret reference. */
|
|
18
|
+
export function isSecretRef(text) {
|
|
19
|
+
if (typeof text !== "string")
|
|
20
|
+
return false;
|
|
21
|
+
const m = /^lfcp-secret:([a-z-]+):(.+)$/.exec(text);
|
|
22
|
+
return m !== null && KINDS.has(m[1]) && ID.test(m[2]);
|
|
23
|
+
}
|
|
24
|
+
/** The reference of the DEK of `epoch` of `resource`. */
|
|
25
|
+
export const dekSecretRef = (resource, epoch) => secretRef("resource-dek", `${toHex(resource)}.${dataEpoch(epoch)}`);
|
|
26
|
+
/** The reference of a local Principal's private signing or agreement key. */
|
|
27
|
+
export const principalKeySecretRef = (principal, which) => secretRef(which === "signing" ? "principal-signing-key" : "principal-agreement-key", toHex(principal));
|
|
28
|
+
const INSPECT = Symbol.for("nodejs.util.inspect.custom");
|
|
29
|
+
/**
|
|
30
|
+
* FOR TESTS AND DEVELOPMENT ONLY: secrets in plain process memory, lost on
|
|
31
|
+
* restart. Renders as "[InMemorySecretStore]" in JSON and inspection, so it
|
|
32
|
+
* cannot leak values into logs by accident.
|
|
33
|
+
*/
|
|
34
|
+
export class InMemorySecretStore {
|
|
35
|
+
#values = new Map();
|
|
36
|
+
put(ref, value) {
|
|
37
|
+
if (!isSecretRef(ref))
|
|
38
|
+
return Promise.reject(new LfcpError("UNSUPPORTED_VALUE", "not a secret reference"));
|
|
39
|
+
this.#values.set(ref, Uint8Array.from(value));
|
|
40
|
+
return Promise.resolve();
|
|
41
|
+
}
|
|
42
|
+
get(ref) {
|
|
43
|
+
const v = this.#values.get(ref);
|
|
44
|
+
return Promise.resolve(v === undefined ? undefined : Uint8Array.from(v));
|
|
45
|
+
}
|
|
46
|
+
delete(ref) {
|
|
47
|
+
this.#values.delete(ref);
|
|
48
|
+
return Promise.resolve();
|
|
49
|
+
}
|
|
50
|
+
toJSON() {
|
|
51
|
+
return "[InMemorySecretStore]";
|
|
52
|
+
}
|
|
53
|
+
toString() {
|
|
54
|
+
return "[InMemorySecretStore]";
|
|
55
|
+
}
|
|
56
|
+
[INSPECT]() {
|
|
57
|
+
return "[InMemorySecretStore]";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=secrets.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type ActorSequence, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
|
+
/**
|
|
3
|
+
* Actor sequence allocation (LFCP-WIRE-01 §8, §12).
|
|
4
|
+
*
|
|
5
|
+
* A writer's Data Unit nonce is 0x00000000 || uint64_be(seq) under a key
|
|
6
|
+
* that depends on (Resource, Data Epoch, Principal). A sequence used twice
|
|
7
|
+
* for one (Resource, Principal) can reuse a nonce under the same key, so
|
|
8
|
+
* sequences are scoped to (Resource, Principal), start at 1, and never go
|
|
9
|
+
* back, not even when the Data Epoch changes.
|
|
10
|
+
*/
|
|
11
|
+
export interface ActorSequenceReservation {
|
|
12
|
+
/**
|
|
13
|
+
* Reserves the next actor sequence for `principal` writing to `resource`.
|
|
14
|
+
*
|
|
15
|
+
* Contract: the reservation is durable before the promise resolves (a
|
|
16
|
+
* crash after it resolves can never hand out the same value again), and
|
|
17
|
+
* the same (resource, principal, seq) is never returned twice, across
|
|
18
|
+
* Data Epochs, restarts and concurrent callers. A caller that cannot use
|
|
19
|
+
* a reserved sequence abandons it; it is never returned to the pool.
|
|
20
|
+
*
|
|
21
|
+
* When the state is lost and cannot be reconstructed safely, the writer
|
|
22
|
+
* must switch to a new Principal for that Resource (§8). When the space
|
|
23
|
+
* is exhausted (2^64 - 1 was handed out) it rejects with OUT_OF_RANGE.
|
|
24
|
+
*
|
|
25
|
+
* The durable implementations belong to the storage tasks: LFCP-034
|
|
26
|
+
* (storage abstraction), LFCP-035 (Node persistence adapter, "atomic
|
|
27
|
+
* enough to prevent sequence reuse") and LFCP-036 (pending outbound
|
|
28
|
+
* queue: a retry never regenerates a Data Unit with the same sequence).
|
|
29
|
+
*/
|
|
30
|
+
reserveNext(resource: ResourceId, principal: PrincipalId): Promise<ActorSequence>;
|
|
31
|
+
}
|
|
32
|
+
/** The sequence after `last` (1 when nothing was reserved yet); OUT_OF_RANGE after 2^64 - 1. */
|
|
33
|
+
export declare function nextActorSequence(last: ActorSequence | undefined): ActorSequence;
|
|
34
|
+
/**
|
|
35
|
+
* FOR TESTS AND DEVELOPMENT ONLY. NOT CRASH-SAFE: the state lives in
|
|
36
|
+
* memory, so a restart starts again at 1 and reuses nonces. Production
|
|
37
|
+
* writers need a durable ActorSequenceReservation (LFCP-034, LFCP-035).
|
|
38
|
+
*/
|
|
39
|
+
export declare class InMemoryActorSequenceReservation implements ActorSequenceReservation {
|
|
40
|
+
#private;
|
|
41
|
+
reserveNext(resource: ResourceId, principal: PrincipalId): Promise<ActorSequence>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A local check that no (resource, principal, seq) is used twice, as a
|
|
45
|
+
* second line of defence where Data Units are created. It remembers what
|
|
46
|
+
* it has seen in memory only; it does not replace a durable reservation.
|
|
47
|
+
*/
|
|
48
|
+
export declare class SequenceReuseGuard {
|
|
49
|
+
#private;
|
|
50
|
+
/** Records the tuple, or throws SEQUENCE_REUSE when it was recorded before. */
|
|
51
|
+
claim(resource: ResourceId, principal: PrincipalId, seq: ActorSequence): void;
|
|
52
|
+
/** True when the tuple was claimed before. */
|
|
53
|
+
has(resource: ResourceId, principal: PrincipalId, seq: ActorSequence): boolean;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=sequence.d.ts.map
|
package/dist/sequence.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { actorSequence, LfcpError, toHex, UINT64_MAX, } from "@openlfcp/core";
|
|
2
|
+
/** The sequence after `last` (1 when nothing was reserved yet); OUT_OF_RANGE after 2^64 - 1. */
|
|
3
|
+
export function nextActorSequence(last) {
|
|
4
|
+
if (last === undefined)
|
|
5
|
+
return actorSequence(1n);
|
|
6
|
+
if (last >= UINT64_MAX)
|
|
7
|
+
throw new LfcpError("OUT_OF_RANGE", "the actor sequence space is exhausted; use a new Principal for this Resource (§8)");
|
|
8
|
+
return actorSequence(last + 1n);
|
|
9
|
+
}
|
|
10
|
+
const tupleKey = (resource, principal) => `${toHex(resource)}:${toHex(principal)}`;
|
|
11
|
+
/**
|
|
12
|
+
* FOR TESTS AND DEVELOPMENT ONLY. NOT CRASH-SAFE: the state lives in
|
|
13
|
+
* memory, so a restart starts again at 1 and reuses nonces. Production
|
|
14
|
+
* writers need a durable ActorSequenceReservation (LFCP-034, LFCP-035).
|
|
15
|
+
*/
|
|
16
|
+
export class InMemoryActorSequenceReservation {
|
|
17
|
+
#last = new Map();
|
|
18
|
+
reserveNext(resource, principal) {
|
|
19
|
+
try {
|
|
20
|
+
const key = tupleKey(resource, principal);
|
|
21
|
+
const next = nextActorSequence(this.#last.get(key));
|
|
22
|
+
this.#last.set(key, next);
|
|
23
|
+
return Promise.resolve(next);
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
return Promise.reject(e);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A local check that no (resource, principal, seq) is used twice, as a
|
|
32
|
+
* second line of defence where Data Units are created. It remembers what
|
|
33
|
+
* it has seen in memory only; it does not replace a durable reservation.
|
|
34
|
+
*/
|
|
35
|
+
export class SequenceReuseGuard {
|
|
36
|
+
#seen = new Map();
|
|
37
|
+
/** Records the tuple, or throws SEQUENCE_REUSE when it was recorded before. */
|
|
38
|
+
claim(resource, principal, seq) {
|
|
39
|
+
const s = actorSequence(seq);
|
|
40
|
+
const key = tupleKey(resource, principal);
|
|
41
|
+
let used = this.#seen.get(key);
|
|
42
|
+
if (used === undefined) {
|
|
43
|
+
used = new Set();
|
|
44
|
+
this.#seen.set(key, used);
|
|
45
|
+
}
|
|
46
|
+
if (used.has(s))
|
|
47
|
+
throw new LfcpError("SEQUENCE_REUSE", `actor sequence ${s} was already used for this (Resource, Principal) (§8)`);
|
|
48
|
+
used.add(s);
|
|
49
|
+
}
|
|
50
|
+
/** True when the tuple was claimed before. */
|
|
51
|
+
has(resource, principal, seq) {
|
|
52
|
+
return this.#seen.get(tupleKey(resource, principal))?.has(seq) ?? false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=sequence.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type DataEpoch, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
|
+
/**
|
|
3
|
+
* Snapshot Sequence allocation (LFCP-WIRE-01 §29, §29.1.2). A Snapshot's
|
|
4
|
+
* nonce is 0x00000000 || uint64_be(snapshot_sequence) under a key that
|
|
5
|
+
* depends on (Resource, Data Epoch, publisher), so a sequence used twice
|
|
6
|
+
* for one (resource, data_epoch, publisher) can reuse a nonce. Sequences
|
|
7
|
+
* start at 1 and never go back.
|
|
8
|
+
*/
|
|
9
|
+
export interface SnapshotSequenceReservation {
|
|
10
|
+
/**
|
|
11
|
+
* Reserves the next Snapshot Sequence for `publisher` in `epoch` of
|
|
12
|
+
* `resource`. Same contract as ActorSequenceReservation: durable before
|
|
13
|
+
* the promise resolves, never the same value twice for one tuple, across
|
|
14
|
+
* restarts and concurrent callers; an unused reservation is abandoned,
|
|
15
|
+
* never returned. OUT_OF_RANGE once 2^64 - 1 was handed out. Durable
|
|
16
|
+
* implementations belong to LFCP-034 and LFCP-035.
|
|
17
|
+
*/
|
|
18
|
+
reserveNext(resource: ResourceId, epoch: DataEpoch, publisher: PrincipalId): Promise<bigint>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* FOR TESTS AND DEVELOPMENT ONLY. NOT CRASH-SAFE: a restart starts again at
|
|
22
|
+
* 1 and reuses nonces. Production publishers need a durable
|
|
23
|
+
* SnapshotSequenceReservation (LFCP-034, LFCP-035).
|
|
24
|
+
*/
|
|
25
|
+
export declare class InMemorySnapshotSequenceReservation implements SnapshotSequenceReservation {
|
|
26
|
+
#private;
|
|
27
|
+
reserveNext(resource: ResourceId, epoch: DataEpoch, publisher: PrincipalId): Promise<bigint>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A local check that no (resource, data_epoch, publisher, sequence) is used
|
|
31
|
+
* twice, as a second line of defence where Snapshots are created. In memory
|
|
32
|
+
* only; it does not replace a durable reservation.
|
|
33
|
+
*/
|
|
34
|
+
export declare class SnapshotSequenceGuard {
|
|
35
|
+
#private;
|
|
36
|
+
/** Records the tuple, or throws SEQUENCE_REUSE when it was recorded before. */
|
|
37
|
+
claim(resource: ResourceId, epoch: DataEpoch, publisher: PrincipalId, seq: bigint): void;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=snapshot-sequence.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { dataEpoch, LfcpError, toHex, UINT64_MAX, } from "@openlfcp/core";
|
|
2
|
+
const tupleKey = (resource, epoch, publisher) => `${toHex(resource)}:${dataEpoch(epoch)}:${toHex(publisher)}`;
|
|
3
|
+
/**
|
|
4
|
+
* FOR TESTS AND DEVELOPMENT ONLY. NOT CRASH-SAFE: a restart starts again at
|
|
5
|
+
* 1 and reuses nonces. Production publishers need a durable
|
|
6
|
+
* SnapshotSequenceReservation (LFCP-034, LFCP-035).
|
|
7
|
+
*/
|
|
8
|
+
export class InMemorySnapshotSequenceReservation {
|
|
9
|
+
#last = new Map();
|
|
10
|
+
reserveNext(resource, epoch, publisher) {
|
|
11
|
+
const key = tupleKey(resource, epoch, publisher);
|
|
12
|
+
const last = this.#last.get(key) ?? 0n;
|
|
13
|
+
if (last >= UINT64_MAX)
|
|
14
|
+
return Promise.reject(new LfcpError("OUT_OF_RANGE", "the Snapshot Sequence space is exhausted (§29)"));
|
|
15
|
+
this.#last.set(key, last + 1n);
|
|
16
|
+
return Promise.resolve(last + 1n);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A local check that no (resource, data_epoch, publisher, sequence) is used
|
|
21
|
+
* twice, as a second line of defence where Snapshots are created. In memory
|
|
22
|
+
* only; it does not replace a durable reservation.
|
|
23
|
+
*/
|
|
24
|
+
export class SnapshotSequenceGuard {
|
|
25
|
+
#seen = new Set();
|
|
26
|
+
/** Records the tuple, or throws SEQUENCE_REUSE when it was recorded before. */
|
|
27
|
+
claim(resource, epoch, publisher, seq) {
|
|
28
|
+
const key = `${tupleKey(resource, epoch, publisher)}:${seq}`;
|
|
29
|
+
if (this.#seen.has(key))
|
|
30
|
+
throw new LfcpError("SEQUENCE_REUSE", `Snapshot Sequence ${seq} was already used for this (Resource, epoch, publisher) (§29)`);
|
|
31
|
+
this.#seen.add(key);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=snapshot-sequence.js.map
|