fieldlog 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/bin/fieldlog.js +18 -0
- package/bin/fieldlog.ts +145 -0
- package/package.json +35 -0
- package/src/auth.ts +297 -0
- package/src/cas.ts +357 -0
- package/src/deltasync.ts +306 -0
- package/src/hashchain.ts +106 -0
- package/src/index.ts +41 -0
- package/src/kernel.ts +333 -0
- package/src/log.ts +344 -0
- package/src/quota.ts +122 -0
- package/src/relay.ts +1027 -0
- package/src/retain.ts +267 -0
- package/src/revokelog.ts +291 -0
- package/src/store.ts +706 -0
- package/src/sync.ts +828 -0
- package/src/tombstone.ts +306 -0
package/src/retain.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// retain.ts — snapshot + truncate: bound the log without losing truth.
|
|
2
|
+
// Only the acked prefix (the relay already holds it) is ever swept, and the
|
|
3
|
+
// cutover is write-new-file + atomic rename, never in-place mutation.
|
|
4
|
+
import {
|
|
5
|
+
closeSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
fsyncSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { dirname } from 'node:path';
|
|
15
|
+
import { Database } from 'bun:sqlite';
|
|
16
|
+
import { isMarker } from './log.js';
|
|
17
|
+
import type { EventStore } from './store.js';
|
|
18
|
+
|
|
19
|
+
export interface SnapshotResult {
|
|
20
|
+
snapshot: string;
|
|
21
|
+
sealedSeq: number; // acked prefix sealed into this snapshot
|
|
22
|
+
dbSeq: number; // max event seq held by the db at snapshot time
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TruncateResult {
|
|
26
|
+
removed: number;
|
|
27
|
+
kept: number;
|
|
28
|
+
sealedSeq: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function snapshotPathFor(dbPath: string): string {
|
|
32
|
+
return dbPath.replace(/\.(db|sqlite|sqlite3)$/, '') + '.snapshot.db';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Live db paths with a snapshot currently in flight. takeSnapshot is
|
|
36
|
+
* synchronous, so a present key means re-entrant or overlapping use — fail
|
|
37
|
+
* loud instead of interleaving two VACUUM INTO + stamp sequences over the
|
|
38
|
+
* same live db (the second copy would stamp live meta out from under the
|
|
39
|
+
* first, or vice versa). */
|
|
40
|
+
const snapshotsInFlight = new Set<string>();
|
|
41
|
+
|
|
42
|
+
/** Cross-process single-writer guard for takeSnapshot. The in-process set
|
|
43
|
+
* above cannot see a second OS process, so a lock file next to the live db
|
|
44
|
+
* (created O_CREAT|O_EXCL) serializes writers across processes. A holder
|
|
45
|
+
* crash can leave a stale file behind; the next writer then fails loud with
|
|
46
|
+
* ERR_SNAPSHOT_IN_FLIGHT (delete the `<db>.snapshot.lock` file once no
|
|
47
|
+
* writer is running) instead of silently interleaving two VACUUM INTO +
|
|
48
|
+
* stamp sequences. Single writer only: concurrent snapshots are rejected,
|
|
49
|
+
* never queued. */
|
|
50
|
+
export function snapshotLockPathFor(dbPath: string): string {
|
|
51
|
+
return `${dbPath}.snapshot.lock`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function acquireSnapshotLock(dbPath: string): number | null {
|
|
55
|
+
const lockPath = snapshotLockPathFor(dbPath);
|
|
56
|
+
try {
|
|
57
|
+
return openSync(lockPath, 'wx', 0o644);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function failSnapshotInFlight(dbPath: string): never {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`ERR_SNAPSHOT_IN_FLIGHT: snapshot already in progress for '${dbPath}'; ` +
|
|
66
|
+
`finish it before starting another (overlapping copies would stamp live meta out of order)`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Online full copy (VACUUM INTO) + seal stamp in both snapshot and live meta. */
|
|
71
|
+
export function takeSnapshot(
|
|
72
|
+
store: EventStore,
|
|
73
|
+
dbPath: string,
|
|
74
|
+
sealedSeq: number,
|
|
75
|
+
dest?: string,
|
|
76
|
+
): SnapshotResult {
|
|
77
|
+
if (snapshotsInFlight.has(dbPath)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`ERR_SNAPSHOT_IN_FLIGHT: snapshot already in progress for '${dbPath}'; ` +
|
|
80
|
+
`finish it before starting another (overlapping copies would stamp live meta out of order)`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const lockFd = acquireSnapshotLock(dbPath);
|
|
84
|
+
if (lockFd === null) failSnapshotInFlight(dbPath);
|
|
85
|
+
snapshotsInFlight.add(dbPath);
|
|
86
|
+
try {
|
|
87
|
+
const snapshot = dest ?? snapshotPathFor(dbPath);
|
|
88
|
+
try {
|
|
89
|
+
unlinkSync(snapshot); // VACUUM INTO refuses an existing target
|
|
90
|
+
} catch {
|
|
91
|
+
/* fresh path */
|
|
92
|
+
}
|
|
93
|
+
store.exec(`VACUUM INTO '${snapshot.replace(/'/g, "''")}'`);
|
|
94
|
+
// Checkpoint BEFORE stamping: capture the observed read-model tip first,
|
|
95
|
+
// so every stamp below describes the same state the copy was taken from.
|
|
96
|
+
// A write landing between the copy and this read can only push dbSeq
|
|
97
|
+
// above the copy — never below — so the seal stays conservative.
|
|
98
|
+
const rows = store.query<{ m: number | null }>(`SELECT MAX(seq) AS m FROM _events`);
|
|
99
|
+
const dbSeq: number = rows[0]?.m ?? 0;
|
|
100
|
+
const db = new Database(snapshot);
|
|
101
|
+
try {
|
|
102
|
+
db.exec('BEGIN IMMEDIATE');
|
|
103
|
+
try {
|
|
104
|
+
db.exec(
|
|
105
|
+
`INSERT INTO _meta(k,v) VALUES('snapshot.sealed_seq','${sealedSeq}') ` +
|
|
106
|
+
`ON CONFLICT(k) DO UPDATE SET v=excluded.v`,
|
|
107
|
+
);
|
|
108
|
+
db.exec(
|
|
109
|
+
`INSERT INTO _meta(k,v) VALUES('snapshot.at','${Date.now()}') ` +
|
|
110
|
+
`ON CONFLICT(k) DO UPDATE SET v=excluded.v`,
|
|
111
|
+
);
|
|
112
|
+
db.exec('COMMIT');
|
|
113
|
+
} catch (err) {
|
|
114
|
+
try {
|
|
115
|
+
db.exec('ROLLBACK');
|
|
116
|
+
} catch {
|
|
117
|
+
/* already torn down — report the original failure */
|
|
118
|
+
}
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
db.close();
|
|
123
|
+
}
|
|
124
|
+
// Both live stamps in one transaction: a crash must never leave
|
|
125
|
+
// snapshot.path pointing at a copy whose seal differs from
|
|
126
|
+
// snapshot.sealed_seq.
|
|
127
|
+
store.exec('BEGIN IMMEDIATE');
|
|
128
|
+
try {
|
|
129
|
+
store.setMeta('snapshot.path', snapshot);
|
|
130
|
+
store.setMeta('snapshot.sealed_seq', String(sealedSeq));
|
|
131
|
+
store.exec('COMMIT');
|
|
132
|
+
} catch (err) {
|
|
133
|
+
try {
|
|
134
|
+
store.exec('ROLLBACK');
|
|
135
|
+
} catch {
|
|
136
|
+
/* already torn down — report the original failure */
|
|
137
|
+
}
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
return { snapshot, sealedSeq, dbSeq };
|
|
141
|
+
} finally {
|
|
142
|
+
snapshotsInFlight.delete(dbPath);
|
|
143
|
+
try { closeSync(lockFd); } catch { /* already closed */ }
|
|
144
|
+
try { unlinkSync(snapshotLockPathFor(dbPath)); } catch { /* released by other means */ }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Clamp a snapshot seal to what truncate may safely sweep: at most the ack
|
|
150
|
+
* cursor (never remove unacked data), and at most below the first swept seq
|
|
151
|
+
* missing from the read-model (never remove unapplied data — a stale or
|
|
152
|
+
* over-advanced ack must not turn into permanent loss). Returns 0 when
|
|
153
|
+
* nothing is safely sweepable; the caller must treat that as a no-op.
|
|
154
|
+
*/
|
|
155
|
+
export function clampSealToStored(
|
|
156
|
+
store: EventStore,
|
|
157
|
+
logSeqs: number[],
|
|
158
|
+
sealed: number,
|
|
159
|
+
ackSeq: number,
|
|
160
|
+
): number {
|
|
161
|
+
// An empty read-model proves nothing applied: seal/ack cursors alone must
|
|
162
|
+
// never authorize a sweep, so report 0 (no-op) instead of min(sealed, ack).
|
|
163
|
+
// Same when the log holds nothing at/below the seal — there is no proven
|
|
164
|
+
// applied prefix to sweep.
|
|
165
|
+
if (logSeqs.length === 0) return 0;
|
|
166
|
+
let effective = Math.min(sealed, ackSeq);
|
|
167
|
+
if (!(effective > 0)) return 0;
|
|
168
|
+
const cands = logSeqs.filter((s) => s <= effective).sort((a, b) => a - b);
|
|
169
|
+
if (cands.length === 0) return 0;
|
|
170
|
+
const rows = store.query<{ seq: number }>(`SELECT seq FROM _events WHERE seq <= ?`, [effective]);
|
|
171
|
+
const have = new Set(rows.map((r) => r.seq));
|
|
172
|
+
for (const s of cands) {
|
|
173
|
+
if (!have.has(s)) {
|
|
174
|
+
effective = s - 1;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return effective > 0 ? effective : 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Sweep log lines with seq <= sealedSeq. New file = marker + kept lines,
|
|
183
|
+
* fsynced, then atomically renamed over the original. The rename itself is
|
|
184
|
+
* made durable with a directory fsync (`syncDir`, injectable for tests).
|
|
185
|
+
* Only successfully parsed events at/below the seal are removed; markers
|
|
186
|
+
* supersede, and anything unparseable is preserved byte-for-byte.
|
|
187
|
+
*/
|
|
188
|
+
export function sweepLogFile(
|
|
189
|
+
logPath: string,
|
|
190
|
+
sealedSeq: number,
|
|
191
|
+
syncDir: (dir: string) => void = syncDirOf,
|
|
192
|
+
): TruncateResult {
|
|
193
|
+
if (sealedSeq <= 0 || !existsSync(logPath)) return { removed: 0, kept: 0, sealedSeq: 0 };
|
|
194
|
+
const kept: string[] = [];
|
|
195
|
+
let removed = 0;
|
|
196
|
+
let tip: string | null = null;
|
|
197
|
+
let tipSeq = -1;
|
|
198
|
+
for (const line of readFileSync(logPath, 'utf8').split('\n')) {
|
|
199
|
+
const t = line.trim();
|
|
200
|
+
if (!t) continue;
|
|
201
|
+
let parsed: unknown;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(t);
|
|
204
|
+
} catch {
|
|
205
|
+
kept.push(line); // corrupt bytes stay for quarantine on next open
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (isMarker(parsed)) continue; // superseded by the new marker below
|
|
209
|
+
if (
|
|
210
|
+
parsed !== null &&
|
|
211
|
+
typeof parsed === 'object' &&
|
|
212
|
+
'seq' in parsed &&
|
|
213
|
+
typeof parsed.seq === 'number' &&
|
|
214
|
+
'hash' in parsed &&
|
|
215
|
+
typeof parsed.hash === 'string' &&
|
|
216
|
+
parsed.seq <= sealedSeq
|
|
217
|
+
) {
|
|
218
|
+
removed += 1;
|
|
219
|
+
if (parsed.seq > tipSeq) {
|
|
220
|
+
tipSeq = parsed.seq;
|
|
221
|
+
tip = parsed.hash;
|
|
222
|
+
}
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
kept.push(line);
|
|
226
|
+
}
|
|
227
|
+
if (removed === 0) return { removed: 0, kept: kept.length, sealedSeq };
|
|
228
|
+
const marker =
|
|
229
|
+
JSON.stringify({
|
|
230
|
+
v: 1,
|
|
231
|
+
marker: 'fieldlog-truncate',
|
|
232
|
+
truncated_before: sealedSeq + 1,
|
|
233
|
+
tip,
|
|
234
|
+
next_seq: sealedSeq + 1,
|
|
235
|
+
}) + '\n';
|
|
236
|
+
const tmp = logPath + '.tmp';
|
|
237
|
+
const fd = openSync(tmp, 'w');
|
|
238
|
+
try {
|
|
239
|
+
writeSync(fd, marker);
|
|
240
|
+
for (const l of kept) writeSync(fd, l + '\n');
|
|
241
|
+
fsyncSync(fd);
|
|
242
|
+
} finally {
|
|
243
|
+
closeSync(fd);
|
|
244
|
+
}
|
|
245
|
+
renameSync(tmp, logPath);
|
|
246
|
+
syncDir(dirname(logPath)); // make the rename itself durable before reporting success
|
|
247
|
+
return { removed, kept: kept.length, sealedSeq };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Best-effort directory fsync so a sweep rename survives a crash (the file
|
|
252
|
+
* fsync above only durables content, not the directory entry). Platforms
|
|
253
|
+
* without directory fsync fall through silently — content durability still
|
|
254
|
+
* holds via the file fsync.
|
|
255
|
+
*/
|
|
256
|
+
function syncDirOf(dir: string): void {
|
|
257
|
+
try {
|
|
258
|
+
const dfd = openSync(dir, 'r');
|
|
259
|
+
try {
|
|
260
|
+
fsyncSync(dfd);
|
|
261
|
+
} finally {
|
|
262
|
+
closeSync(dfd);
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
/* no durable-rename primitive here */
|
|
266
|
+
}
|
|
267
|
+
}
|
package/src/revokelog.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// revokelog.ts — authenticated revoke event-log, convergent across relays.
|
|
2
|
+
//
|
|
3
|
+
// Closes two relay-revocation leaks: (1) revokes were unauthenticated local
|
|
4
|
+
// state (relay.ts keeps a bare Set<string> plus a live 'revoked' hint any
|
|
5
|
+
// relay can claim), (2) that set never converges across relays (per-relay
|
|
6
|
+
// sidecar file, no merge). Here every revoke is an admin-signed event and
|
|
7
|
+
// replicas sync by idempotent set-union merge, so merge is commutative and
|
|
8
|
+
// snapshots converge.
|
|
9
|
+
//
|
|
10
|
+
// Event JSON: { v, tokenId, deviceId, epoch, admin, issuedAt, prev, hash, sig, id }
|
|
11
|
+
// tokenId revoked capability/grant id; '*' = whole-device revoke.
|
|
12
|
+
// deviceId token owner whose capability dies.
|
|
13
|
+
// epoch monotonic per tokenId; higher supersedes lower.
|
|
14
|
+
// admin signer deviceId; must be in the trusted admin registry.
|
|
15
|
+
// issuedAt wall clock, display only — never authoritative.
|
|
16
|
+
// prev hash-chain link: REVOKE_GENESIS or a known event hash (audit hint).
|
|
17
|
+
// hash sha256 over the canonical core; id = hash (idempotency key).
|
|
18
|
+
// sig admin ed25519 signature over hash (auth.ts signBytes).
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { signBytes, verifyBytes } from './auth.js';
|
|
21
|
+
|
|
22
|
+
export const REVOKE_GENESIS = 'REVOKE-GENESIS';
|
|
23
|
+
export const REVOKE_V = 1;
|
|
24
|
+
|
|
25
|
+
export interface RevokeEvent {
|
|
26
|
+
v: 1;
|
|
27
|
+
tokenId: string;
|
|
28
|
+
deviceId: string;
|
|
29
|
+
epoch: number;
|
|
30
|
+
admin: string;
|
|
31
|
+
issuedAt: number;
|
|
32
|
+
prev: string;
|
|
33
|
+
hash: string;
|
|
34
|
+
sig: string;
|
|
35
|
+
id: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RevokeInput {
|
|
39
|
+
tokenId: string;
|
|
40
|
+
deviceId: string;
|
|
41
|
+
epoch: number;
|
|
42
|
+
issuedAt?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type AdminRegistry = Map<string, string> | Record<string, string>;
|
|
46
|
+
|
|
47
|
+
export function normalizeRegistry(reg?: AdminRegistry): Map<string, string> {
|
|
48
|
+
if (!reg) return new Map();
|
|
49
|
+
return reg instanceof Map ? new Map(reg) : new Map(Object.entries(reg));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type RevokeCore = Omit<RevokeEvent, 'hash' | 'sig' | 'id'>;
|
|
53
|
+
|
|
54
|
+
/** Canonical bytes covered by the chain hash (hash/sig/id excluded). */
|
|
55
|
+
export function canonicalRevoke(e: RevokeCore): string {
|
|
56
|
+
return JSON.stringify({
|
|
57
|
+
v: e.v,
|
|
58
|
+
tokenId: e.tokenId,
|
|
59
|
+
deviceId: e.deviceId,
|
|
60
|
+
epoch: e.epoch,
|
|
61
|
+
admin: e.admin,
|
|
62
|
+
issuedAt: e.issuedAt,
|
|
63
|
+
prev: e.prev,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function hashRevoke(e: RevokeCore): string {
|
|
68
|
+
return createHash('sha256').update(canonicalRevoke(e), 'utf8').digest('hex');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function checkFields(e: RevokeCore): string | null {
|
|
72
|
+
if (e.v !== REVOKE_V) return `unsupported v ${e.v as number}`;
|
|
73
|
+
if (typeof e.tokenId !== 'string' || e.tokenId === '') return 'empty tokenId';
|
|
74
|
+
if (typeof e.deviceId !== 'string' || e.deviceId === '') return 'empty deviceId';
|
|
75
|
+
if (typeof e.admin !== 'string' || e.admin === '') return 'empty admin';
|
|
76
|
+
if (!Number.isInteger(e.epoch) || e.epoch < 0) return `bad epoch ${e.epoch as number}`;
|
|
77
|
+
if (typeof e.issuedAt !== 'number' || !Number.isFinite(e.issuedAt)) return 'bad issuedAt';
|
|
78
|
+
if (typeof e.prev !== 'string' || e.prev === '') return 'empty prev';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Build + admin-sign one revoke; prev threads the author's tip (audit hint). */
|
|
83
|
+
export function createRevokeEvent(
|
|
84
|
+
adminPrivatePem: string,
|
|
85
|
+
admin: string,
|
|
86
|
+
input: RevokeInput,
|
|
87
|
+
prev: string = REVOKE_GENESIS,
|
|
88
|
+
now: number = Date.now(),
|
|
89
|
+
): RevokeEvent {
|
|
90
|
+
const core: RevokeCore = {
|
|
91
|
+
v: REVOKE_V,
|
|
92
|
+
tokenId: input.tokenId,
|
|
93
|
+
deviceId: input.deviceId,
|
|
94
|
+
epoch: input.epoch,
|
|
95
|
+
admin,
|
|
96
|
+
issuedAt: input.issuedAt ?? now,
|
|
97
|
+
prev,
|
|
98
|
+
};
|
|
99
|
+
const bad = checkFields(core);
|
|
100
|
+
if (bad) throw new Error(`revoke rejected: ${bad}`);
|
|
101
|
+
const hash = hashRevoke(core);
|
|
102
|
+
return { ...core, hash, sig: signBytes(adminPrivatePem, hash), id: hash };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function verifyOne(admins: Map<string, string>, e: RevokeEvent): string | null {
|
|
106
|
+
const bad = checkFields(e);
|
|
107
|
+
if (bad) return bad;
|
|
108
|
+
if (e.id !== e.hash) return 'id/hash split';
|
|
109
|
+
const { hash, sig, id, ...core } = e;
|
|
110
|
+
void id;
|
|
111
|
+
if (hashRevoke(core) !== hash) return 'hash mismatch (tampered payload?)';
|
|
112
|
+
const pub = admins.get(e.admin);
|
|
113
|
+
if (!pub) return `unknown admin ${e.admin}`;
|
|
114
|
+
if (!verifyBytes(pub, hash, sig)) return 'bad admin signature';
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Single-event auth check: content hash + known admin + signature. */
|
|
119
|
+
export function verifyRevokeEvent(admins: AdminRegistry, e: RevokeEvent): boolean {
|
|
120
|
+
return verifyOne(normalizeRegistry(admins), e) === null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface RevokeVerify {
|
|
124
|
+
ok: boolean;
|
|
125
|
+
at?: string;
|
|
126
|
+
reason?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* DAG replay over a batch: every event authentic and every non-genesis prev
|
|
131
|
+
* pointing at a known hash. Order-free on purpose — concurrent writers fork
|
|
132
|
+
* the prev hint, so this checks linkage, not a single linear order.
|
|
133
|
+
*/
|
|
134
|
+
export function verifyRevokeChain(admins: AdminRegistry, events: Iterable<RevokeEvent>): RevokeVerify {
|
|
135
|
+
const reg = normalizeRegistry(admins);
|
|
136
|
+
const list = [...events];
|
|
137
|
+
for (const e of list) {
|
|
138
|
+
const bad = verifyOne(reg, e);
|
|
139
|
+
if (bad) return { ok: false, at: e.id, reason: bad };
|
|
140
|
+
}
|
|
141
|
+
const known = new Set(list.map((e) => e.hash));
|
|
142
|
+
for (const e of list) {
|
|
143
|
+
if (e.prev !== REVOKE_GENESIS && !known.has(e.prev)) {
|
|
144
|
+
return { ok: false, at: e.id, reason: `dangling prev ${e.prev.slice(0, 12)}` };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { ok: true };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface MergeResult {
|
|
151
|
+
added: number;
|
|
152
|
+
skipped: number;
|
|
153
|
+
rejected: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type AppendOutcome = 'added' | 'duplicate';
|
|
157
|
+
|
|
158
|
+
export class RevokeLog {
|
|
159
|
+
private admins: Map<string, string>;
|
|
160
|
+
private order: RevokeEvent[] = []; // first-seen order; diffSince slices this
|
|
161
|
+
private byHash = new Map<string, RevokeEvent>();
|
|
162
|
+
|
|
163
|
+
constructor(trustedAdmins?: AdminRegistry) {
|
|
164
|
+
this.admins = normalizeRegistry(trustedAdmins);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
get size(): number {
|
|
168
|
+
return this.order.length;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Live tip hash (REVOKE_GENESIS when empty); doubles as the next prev. */
|
|
172
|
+
get tip(): string {
|
|
173
|
+
return this.order.length > 0 ? this.order[this.order.length - 1].hash : REVOKE_GENESIS;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
addAdmin(deviceId: string, publicKeyPem: string): void {
|
|
177
|
+
this.admins.set(deviceId, publicKeyPem);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Sign + append in one step; prev threads the local tip. */
|
|
181
|
+
create(adminPrivatePem: string, admin: string, input: RevokeInput, now: number = Date.now()): RevokeEvent {
|
|
182
|
+
const e = createRevokeEvent(adminPrivatePem, admin, input, this.tip, now);
|
|
183
|
+
this.append(e);
|
|
184
|
+
return { ...e };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Verified single append; throws on forgery. Replay of a known hash is a no-op. */
|
|
188
|
+
append(e: RevokeEvent): AppendOutcome {
|
|
189
|
+
if (this.byHash.has(e.hash)) return 'duplicate';
|
|
190
|
+
const bad = verifyOne(this.admins, e);
|
|
191
|
+
if (bad) throw new Error(`revoke rejected: ${bad}`);
|
|
192
|
+
if (e.prev !== REVOKE_GENESIS && !this.byHash.has(e.prev)) {
|
|
193
|
+
throw new Error(`revoke rejected: dangling prev ${e.prev.slice(0, 12)}`);
|
|
194
|
+
}
|
|
195
|
+
const frozen = { ...e };
|
|
196
|
+
this.order.push(frozen);
|
|
197
|
+
this.byHash.set(frozen.hash, frozen);
|
|
198
|
+
return 'added';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Canonical convergent view: sorted by (epoch, hash). Byte-equal across
|
|
203
|
+
* replicas after a full bidirectional merge, regardless of arrival order.
|
|
204
|
+
*/
|
|
205
|
+
snapshot(): RevokeEvent[] {
|
|
206
|
+
return [...this.order]
|
|
207
|
+
.sort((a, b) => a.epoch - b.epoch || (a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0))
|
|
208
|
+
.map((e) => ({ ...e }));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* First-seen suffix after cursor; cursor = order.length. Only diff your own
|
|
213
|
+
* log (it is append-only, so the cursor is stable); cross-replica sync is
|
|
214
|
+
* merge(snapshot) — set union needs no cursor.
|
|
215
|
+
*/
|
|
216
|
+
diffSince(cursor: number): { events: RevokeEvent[]; cursor: number } {
|
|
217
|
+
if (!Number.isInteger(cursor) || cursor < 0 || cursor > this.order.length) {
|
|
218
|
+
throw new Error(`revoke rejected: bad cursor ${cursor as number}`);
|
|
219
|
+
}
|
|
220
|
+
return { events: this.order.slice(cursor).map((e) => ({ ...e })), cursor: this.order.length };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Idempotent set-union merge: commutative, so A.merge(B) then B.merge(A)
|
|
225
|
+
* converges. Unordered batches resolve to a fixpoint (prev may arrive
|
|
226
|
+
* later in the same batch); forged or dangling events count as rejected
|
|
227
|
+
* and are never stored.
|
|
228
|
+
*/
|
|
229
|
+
merge(remote: Iterable<RevokeEvent>): MergeResult {
|
|
230
|
+
const res: MergeResult = { added: 0, skipped: 0, rejected: 0 };
|
|
231
|
+
const pending: RevokeEvent[] = [];
|
|
232
|
+
for (const e of remote) {
|
|
233
|
+
if (this.byHash.has(e.hash)) {
|
|
234
|
+
res.skipped += 1;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (verifyOne(this.admins, e) !== null) {
|
|
238
|
+
res.rejected += 1;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
pending.push(e);
|
|
242
|
+
}
|
|
243
|
+
let progress = true;
|
|
244
|
+
while (progress && pending.length > 0) {
|
|
245
|
+
progress = false;
|
|
246
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
247
|
+
const e = pending[i];
|
|
248
|
+
if (e.prev === REVOKE_GENESIS || this.byHash.has(e.prev)) {
|
|
249
|
+
pending.splice(i, 1);
|
|
250
|
+
const frozen = { ...e };
|
|
251
|
+
this.order.push(frozen);
|
|
252
|
+
this.byHash.set(frozen.hash, frozen);
|
|
253
|
+
res.added += 1;
|
|
254
|
+
progress = true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
res.rejected += pending.length;
|
|
259
|
+
return res;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
verify(): RevokeVerify {
|
|
263
|
+
return verifyRevokeChain(this.admins, this.order);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Effective state: revoked iff some event for tokenId has epoch >= tokenEpoch. */
|
|
267
|
+
isRevoked(tokenId: string, tokenEpoch = 0): boolean {
|
|
268
|
+
for (const e of this.order) {
|
|
269
|
+
if (e.tokenId === tokenId && e.epoch >= tokenEpoch) return true;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* One row per tokenId at its max epoch, sorted by tokenId. Epoch ties
|
|
276
|
+
* break by min event hash, mirroring snapshot()'s (epoch, hash) order,
|
|
277
|
+
* so arrival order never decides the winner across replicas.
|
|
278
|
+
*/
|
|
279
|
+
revokedTokens(): Array<{ tokenId: string; deviceId: string; epoch: number }> {
|
|
280
|
+
const top = new Map<string, { tokenId: string; deviceId: string; epoch: number; hash: string }>();
|
|
281
|
+
for (const e of this.order) {
|
|
282
|
+
const cur = top.get(e.tokenId);
|
|
283
|
+
if (!cur || e.epoch > cur.epoch || (e.epoch === cur.epoch && e.hash < cur.hash)) {
|
|
284
|
+
top.set(e.tokenId, { tokenId: e.tokenId, deviceId: e.deviceId, epoch: e.epoch, hash: e.hash });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return [...top.values()]
|
|
288
|
+
.map(({ tokenId, deviceId, epoch }) => ({ tokenId, deviceId, epoch }))
|
|
289
|
+
.sort((a, b) => (a.tokenId < b.tokenId ? -1 : 1));
|
|
290
|
+
}
|
|
291
|
+
}
|