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/src/auth.ts ADDED
@@ -0,0 +1,297 @@
1
+ // auth.ts — ed25519 device keys, revocable scopes, countersign threshold.
2
+ // No PKI: devices are raw public keys; an authority key signs scope grants.
3
+ import { generateKeyPairSync, randomUUID, sign, verify } from 'node:crypto';
4
+ import { canonicalOf, hashFor, type LogEvent } from './log.js';
5
+ /**
6
+ * Device identity.
7
+ *
8
+ * `deviceId` is the lowercase hex encoding of the ed25519 public key's
9
+ * SPKI DER bytes (`publicKey.export({ type: 'spki', format: 'der' })`
10
+ * rendered as hex). Registry maps, scope grants, capability tokens, and
11
+ * countersignatures key on this exact string — never the PEM. An explicit
12
+ * `deviceId` override (e.g. a named authority such as 'hq' in tests)
13
+ * bypasses the derivation and is NOT SPKI-DER hex; production devices
14
+ * always use the derived form.
15
+ */
16
+ export interface DeviceKeypair {
17
+ deviceId: string; // lowercase hex of the ed25519 public key (SPKI DER)
18
+ publicKeyPem: string;
19
+ privateKeyPem: string;
20
+ }
21
+
22
+ export function generateDeviceKey(deviceId?: string): DeviceKeypair {
23
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
24
+ const pubDer = publicKey.export({ type: 'spki', format: 'der' });
25
+ const pubPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
26
+ const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
27
+ return {
28
+ deviceId: deviceId ?? (pubDer as Buffer).toString('hex'),
29
+ publicKeyPem: pubPem,
30
+ privateKeyPem: privPem,
31
+ };
32
+ }
33
+
34
+ export function signBytes(privateKeyPem: string, data: Uint8Array | string): string {
35
+ const bytes = typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data);
36
+ return sign(null, bytes, privateKeyPem).toString('hex');
37
+ }
38
+
39
+ export function verifyBytes(publicKeyPem: string, data: Uint8Array | string, signatureHex: string): boolean {
40
+ try {
41
+ const bytes = typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data);
42
+ return verify(null, bytes, publicKeyPem, Buffer.from(signatureHex, 'hex'));
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /** Sign the event's hash-chain hash: signature covers the whole causal history. */
49
+ export function signEvent(privateKeyPem: string, ev: LogEvent): string {
50
+ return signBytes(privateKeyPem, ev.hash);
51
+ }
52
+
53
+ export function verifyEvent(publicKeyPem: string, ev: LogEvent, signatureHex: string): boolean {
54
+ const { hash } = ev;
55
+ // Recompute the chain hash so a signature can't be transplanted onto edited bytes.
56
+ // The auth envelope itself is never hashed (signature covers the hash).
57
+ const { hash: _drop, signature: _s, countersignatures: _c, ...core } = ev;
58
+ void _drop;
59
+ void _s;
60
+ void _c;
61
+ if (hashFor(core) !== hash) return false;
62
+ return verifyBytes(publicKeyPem, hash, signatureHex);
63
+ }
64
+
65
+ // Named TTLs: single source for grant/capability lifetimes. The relay path
66
+ // uses the short capability default; callers needing longer sessions pass
67
+ // an explicit ttlMs rather than forking a second magic number.
68
+ export const GRANT_TTL_MS = 24 * 3600 * 1000;
69
+ export const CAP_TOKEN_TTL_MS = 15 * 60 * 1000;
70
+
71
+ // Scopes: revocable capability grants signed by an authority key.
72
+ export interface ScopeGrant {
73
+ id: string;
74
+ deviceId: string;
75
+ scopes: string[]; // e.g. ['entries:append', 'entries:resolve']
76
+ issuedBy: string; // authority deviceId / name
77
+ issuedAt: number;
78
+ expiresAt: number;
79
+ signature?: string; // authority signature over the canonical grant
80
+ }
81
+
82
+ export function canonicalGrant(g: Omit<ScopeGrant, 'signature'>): string {
83
+ return JSON.stringify({
84
+ id: g.id,
85
+ deviceId: g.deviceId,
86
+ scopes: [...g.scopes].sort(),
87
+ issuedBy: g.issuedBy,
88
+ issuedAt: g.issuedAt,
89
+ expiresAt: g.expiresAt,
90
+ });
91
+ }
92
+
93
+ export function issueGrant(
94
+ authorityPrivatePem: string,
95
+ issuedBy: string,
96
+ deviceId: string,
97
+ scopes: string[],
98
+ ttlMs = GRANT_TTL_MS,
99
+ now = Date.now(),
100
+ ): ScopeGrant {
101
+ const grant: Omit<ScopeGrant, 'signature'> = {
102
+ id: randomUUID(),
103
+ deviceId,
104
+ scopes,
105
+ issuedBy,
106
+ issuedAt: now,
107
+ expiresAt: now + ttlMs,
108
+ };
109
+ return { ...grant, signature: signBytes(authorityPrivatePem, canonicalGrant(grant)) };
110
+ }
111
+
112
+ export class RevocationList {
113
+ private revoked = new Set<string>(); // grant ids; dynamic membership → Set
114
+ revoke(grantId: string): void {
115
+ this.revoked.add(grantId);
116
+ }
117
+ isRevoked(grantId: string): boolean {
118
+ return this.revoked.has(grantId);
119
+ }
120
+ get size(): number {
121
+ return this.revoked.size;
122
+ }
123
+ }
124
+
125
+ export function verifyGrant(
126
+ authorityPublicPem: string,
127
+ grant: ScopeGrant,
128
+ scope: string,
129
+ revocations?: RevocationList,
130
+ now = Date.now(),
131
+ ): boolean {
132
+ if (!grant.signature) return false;
133
+ if (grant.expiresAt <= grant.issuedAt) return false; // malformed lifetime: fail closed
134
+ if (now < grant.issuedAt) return false; // not-before: usable only from issuance
135
+ if (now > grant.expiresAt) return false;
136
+ if (revocations?.isRevoked(grant.id)) return false;
137
+ const { signature, ...core } = grant;
138
+ if (!verifyBytes(authorityPublicPem, canonicalGrant(core), signature)) return false;
139
+ return grant.scopes.includes(scope);
140
+ }
141
+
142
+ // Capability tokens: the device key itself signs a scope+expiry token.
143
+ // The relay holds a deviceId -> publicKey registry and verifies the
144
+ // signature + scope + expiry on every push/pull. No authority key involved.
145
+ // Self-signed limit: possession of a valid token equals the device key for
146
+ // its scopes until expiry or revocation — see docs/capability-token.md.
147
+
148
+ export interface CapToken {
149
+ id: string; // per-token id: revocation is granular, never whole-device only
150
+ deviceId: string;
151
+ scopes: string[]; // e.g. ['relay:push', 'relay:pull']
152
+ issuedAt: number;
153
+ expiresAt: number;
154
+ notBefore?: number; // optional activation floor: now < notBefore → not yet valid
155
+ signature?: string; // device signature over the canonical token
156
+ }
157
+ export function canonicalCapToken(t: Omit<CapToken, 'signature'>): string {
158
+ return JSON.stringify({
159
+ id: t.id,
160
+ deviceId: t.deviceId,
161
+ scopes: [...t.scopes].sort(),
162
+ issuedAt: t.issuedAt,
163
+ expiresAt: t.expiresAt,
164
+ ...(t.notBefore !== undefined ? { notBefore: t.notBefore } : {}),
165
+ });
166
+ }
167
+
168
+ export function mintCapToken(
169
+ privateKeyPem: string,
170
+ deviceId: string,
171
+ scopes: string[],
172
+ ttlMs = CAP_TOKEN_TTL_MS,
173
+ now = Date.now(),
174
+ ): CapToken {
175
+ const core: Omit<CapToken, 'signature'> = {
176
+ id: randomUUID(),
177
+ deviceId,
178
+ scopes,
179
+ issuedAt: now,
180
+ expiresAt: now + ttlMs,
181
+ };
182
+ return { ...core, signature: signBytes(privateKeyPem, canonicalCapToken(core)) };
183
+ }
184
+
185
+ /** Per-token-id revocation for capability tokens (granular: one token dies, siblings live). */
186
+ export class CapRevocationList {
187
+ private revoked = new Set<string>(); // token ids; dynamic membership → Set
188
+ revoke(tokenId: string): void {
189
+ this.revoked.add(tokenId);
190
+ }
191
+ isRevoked(tokenId: string): boolean {
192
+ return this.revoked.has(tokenId);
193
+ }
194
+ get size(): number {
195
+ return this.revoked.size;
196
+ }
197
+ }
198
+
199
+ export function verifyCapToken(
200
+ publicKeyPem: string,
201
+ token: CapToken,
202
+ scope: string,
203
+ revocations?: CapRevocationList,
204
+ now = Date.now(),
205
+ ): boolean {
206
+ if (!token.signature) return false;
207
+ if (!token.id) return false; // id-less legacy token: fail closed, re-mint
208
+ if (token.expiresAt <= token.issuedAt) return false;
209
+ if (token.notBefore !== undefined && now < token.notBefore) return false;
210
+ if (now > token.expiresAt) return false;
211
+ if (revocations?.isRevoked(token.id)) return false;
212
+ const { signature, ...core } = token;
213
+ if (!verifyBytes(publicKeyPem, canonicalCapToken(core), signature)) return false;
214
+ return token.scopes.includes(scope);
215
+ }
216
+
217
+ export type AuthorizeVerdict = { ok: true } | { ok: false; reason: string };
218
+
219
+ /**
220
+ * Authorize a relay op against a capability token: device tombstone first,
221
+ * then per-token-id revocation, signature, expiry, and scope — in that order
222
+ * so revoked callers never reach crypto. This is the authorize path the relay
223
+ * mirrors (see WsRelayServer.authorize in src/relay.ts, read-only here).
224
+ */
225
+ export function authorizeCapToken(opts: {
226
+ publicKeyPem: string | undefined;
227
+ token: CapToken | undefined;
228
+ scope: string;
229
+ revocations?: CapRevocationList;
230
+ revokedDevices?: Set<string> | string[];
231
+ now?: number;
232
+ }): AuthorizeVerdict {
233
+ const now = opts.now ?? Date.now();
234
+ if (!opts.token) return { ok: false, reason: 'missing capability token' };
235
+ const revoked = opts.revokedDevices instanceof Set ? opts.revokedDevices : new Set(opts.revokedDevices ?? []);
236
+ if (revoked.has(opts.token.deviceId)) return { ok: false, reason: `device revoked: ${opts.token.deviceId}` };
237
+ if (!opts.publicKeyPem) return { ok: false, reason: `unknown device: ${opts.token.deviceId}` };
238
+ if (!verifyCapToken(opts.publicKeyPem, opts.token, opts.scope, opts.revocations, now)) {
239
+ return { ok: false, reason: `capability rejected for ${opts.scope}` };
240
+ }
241
+ return { ok: true };
242
+ }
243
+
244
+ /**
245
+ * Authorize an entry-scoped op against an authority-signed grant. Wires the
246
+ * previously call-site-free verifyGrant into the authorize path so entry
247
+ * scopes (entries:append, entries:resolve) are gated per grant id, not assumed.
248
+ */
249
+ export function authorizeGrant(opts: {
250
+ authorityPublicPem: string;
251
+ grant: ScopeGrant | undefined;
252
+ scope: string;
253
+ revocations?: RevocationList;
254
+ now?: number;
255
+ }): AuthorizeVerdict {
256
+ const now = opts.now ?? Date.now();
257
+ if (!opts.grant) return { ok: false, reason: 'missing scope grant' };
258
+ if (!verifyGrant(opts.authorityPublicPem, opts.grant, opts.scope, opts.revocations, now)) {
259
+ return { ok: false, reason: `grant rejected for ${opts.scope}` };
260
+ }
261
+ return { ok: true };
262
+ }
263
+
264
+ // Countersign: high-value moves need ≥ threshold distinct authorized signatures.
265
+
266
+ export interface Countersignature {
267
+ deviceId: string;
268
+ signatureHex: string;
269
+ }
270
+
271
+ export function countersignEvent(privateKeyPem: string, deviceId: string, ev: LogEvent): Countersignature {
272
+ return { deviceId, signatureHex: signEvent(privateKeyPem, ev) };
273
+ }
274
+
275
+ export function checkThreshold(
276
+ registry: Map<string, string>, // deviceId -> publicKeyPem
277
+ ev: LogEvent,
278
+ signatures: Countersignature[],
279
+ threshold: number,
280
+ ): { valid: number; thresholdMet: boolean } {
281
+ if (!Number.isInteger(threshold) || threshold < 1 || threshold > registry.size) {
282
+ throw new RangeError(`checkThreshold: threshold ${threshold} out of range 1..${registry.size}`);
283
+ }
284
+ const seen = new Set<string>();
285
+ let valid = 0;
286
+ for (const s of signatures) {
287
+ if (seen.has(s.deviceId)) continue; // one vote per device
288
+ const pub = registry.get(s.deviceId);
289
+ if (!pub) continue; // unknown device: not a vote
290
+ if (!verifyEvent(pub, ev, s.signatureHex)) continue;
291
+ seen.add(s.deviceId);
292
+ valid += 1;
293
+ }
294
+ return { valid, thresholdMet: valid >= threshold };
295
+ }
296
+
297
+ export { canonicalOf };
package/src/cas.ts ADDED
@@ -0,0 +1,357 @@
1
+ // cas.ts — skill-14 cas-store port (stable) for fieldlog.
2
+ //
3
+ // Content-addressed blob store: the sha256 of the bytes IS the key, so the
4
+ // same attachment stored twice costs one blob plus a refcount. Refs are
5
+ // explicit (put/link add one, unlink drops one); the blob dies at zero.
6
+ // Reads re-hash and quarantine on mismatch, mirroring hashchain.ts.
7
+ //
8
+ // Layout under <dir>:
9
+ // sha/<ab>/<cdef...> blob bytes, key = ab+cdef...
10
+ // cas.json { refs: { <key>: <count> } }, atomic rename per mutation
11
+ // quarantine/<key> blobs that failed re-hash on read (forensics, never served)
12
+ import { createHash } from 'node:crypto';
13
+ import {
14
+ closeSync,
15
+ existsSync,
16
+ fsyncSync,
17
+ fstatSync,
18
+ mkdirSync,
19
+ openSync,
20
+ readFileSync,
21
+ readdirSync,
22
+ renameSync,
23
+ rmSync,
24
+ unlinkSync,
25
+ writeSync,
26
+ } from 'node:fs';
27
+ import { dirname, join } from 'node:path';
28
+
29
+ const KEY_RE = /^[0-9a-f]{64}$/;
30
+
31
+ /** sha256 hex of the bytes. The key, no registry, no counter. */
32
+ export function casKeyFor(data: Uint8Array | string): string {
33
+ return createHash('sha256').update(data).digest('hex');
34
+ }
35
+
36
+ /** Shard two hex chars deep so one directory never holds every blob. */
37
+ export function casShardFor(key: string): string {
38
+ return key.slice(0, 2);
39
+ }
40
+
41
+ /** Blob path for a key under a cas dir. */
42
+ export function casPathFor(dir: string, key: string): string {
43
+ return join(dir, 'sha', casShardFor(key), key.slice(2));
44
+ }
45
+
46
+ /** Forensic sidecar for blobs that failed re-hash on read. */
47
+ export function casQuarantinePathFor(dir: string, key: string): string {
48
+ return join(dir, 'quarantine', key);
49
+ }
50
+
51
+ function manifestPathFor(dir: string): string {
52
+ return join(dir, 'cas.json');
53
+ }
54
+
55
+ export interface CasStat {
56
+ key: string;
57
+ size: number;
58
+ refcount: number;
59
+ }
60
+
61
+ export interface CasStore {
62
+ readonly dir: string;
63
+ /** Blobs quarantined for hash mismatch since open. */
64
+ quarantined: number;
65
+ /** Store bytes, add one ref. Same bytes twice = one blob, refcount 2. */
66
+ put(data: Uint8Array | string): string;
67
+ /** Blob bytes, or null when missing/unreadable/mismatched. Never throws on data. */
68
+ get(key: string): Buffer | null;
69
+ has(key: string): boolean;
70
+ stat(key: string): CasStat | null;
71
+ /** Add one ref to a stored key. Throws on unknown key (fail fast, no phantom refs). */
72
+ link(key: string): void;
73
+ /** Drop one ref; deletes the blob at zero. True when a blob died. */
74
+ unlink(key: string): boolean;
75
+ /** Sweep crash orphans: blobs with no ref entry, entries with no blob. Returns dead keys. */
76
+ gc(): string[];
77
+ close(): void;
78
+ }
79
+
80
+ function checkKey(key: string): void {
81
+ if (!KEY_RE.test(key)) throw new Error(`bad cas key (want sha256 hex): ${key.slice(0, 32)}`);
82
+ }
83
+ /** Best-effort directory fsync so creates/renames survive a crash. Never throws. */
84
+ function fsyncDir(path: string): void {
85
+ let fd: number | undefined;
86
+ try {
87
+ fd = openSync(path, 'r');
88
+ fsyncSync(fd);
89
+ } catch {
90
+ // Best effort: some platforms refuse dir fsync; durability hint only.
91
+ } finally {
92
+ if (fd !== undefined) {
93
+ try {
94
+ closeSync(fd);
95
+ } catch {
96
+ // Ignore close errors on a durability hint.
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ /** Atomic manifest persist: write tmp + fsync + rename, same cutover as retain.ts. */
103
+ function persistManifest(dir: string, refs: Record<string, number>, quarantined: Set<string>): void {
104
+ const path = manifestPathFor(dir);
105
+ const tmp = path + '.tmp';
106
+ const fd = openSync(tmp, 'w');
107
+ try {
108
+ writeSync(fd, JSON.stringify({ refs, quarantined: [...quarantined].sort() }));
109
+ fsyncSync(fd);
110
+ } finally {
111
+ closeSync(fd);
112
+ }
113
+ renameSync(tmp, path);
114
+ fsyncDir(dir);
115
+ }
116
+
117
+ /**
118
+ * Open a cas store at `dir` (created when missing). The manifest loads once;
119
+ * every ref mutation persists it atomically, so a kill between ops loses at
120
+ * most nothing committed — refs never point at a half-written blob because
121
+ * the blob is exclusively created BEFORE the manifest names it.
122
+ */
123
+ export function openCas(dir: string): CasStore {
124
+ mkdirSync(join(dir, 'sha'), { recursive: true });
125
+ mkdirSync(join(dir, 'quarantine'), { recursive: true });
126
+ let refs: Record<string, number> = {};
127
+ const quarantined = new Set<string>();
128
+ const mpath = manifestPathFor(dir);
129
+ if (existsSync(mpath)) {
130
+ let parsed: unknown;
131
+ try {
132
+ parsed = JSON.parse(readFileSync(mpath, 'utf8'));
133
+ } catch {
134
+ throw new Error('corrupt cas manifest (not json, refusing to guess refs)');
135
+ }
136
+ if (!parsed || typeof parsed !== 'object' || !('refs' in parsed) || typeof parsed.refs !== 'object' || parsed.refs === null) {
137
+ throw new Error('corrupt cas manifest (no refs table, refusing to guess refs)');
138
+ }
139
+ for (const [k, v] of Object.entries(parsed.refs as Record<string, unknown>)) {
140
+ if (!KEY_RE.test(k) || typeof v !== 'number' || !Number.isInteger(v) || v < 0) {
141
+ throw new Error('corrupt cas manifest (bad key or count, refusing to guess refs)');
142
+ }
143
+ if (v > 0) refs[k] = v;
144
+ }
145
+ // Quarantine section: keys whose blobs failed re-hash (fail-closed evidence).
146
+ // Optional for backward compat; validated strictly when present.
147
+ const qraw = (parsed as Record<string, unknown>).quarantined;
148
+ if (qraw !== undefined) {
149
+ if (!Array.isArray(qraw) || qraw.some((k) => typeof k !== 'string' || !KEY_RE.test(k))) {
150
+ throw new Error('corrupt cas manifest (bad quarantine section, refusing to guess refs)');
151
+ }
152
+ for (const k of qraw as string[]) quarantined.add(k);
153
+ }
154
+ } else {
155
+ persistManifest(dir, refs, quarantined);
156
+ }
157
+ const store: CasStore & { quarantined: number } = {
158
+ dir,
159
+ quarantined: 0,
160
+ put(data: Uint8Array | string): string {
161
+ const key = casKeyFor(data);
162
+ const blob = casPathFor(dir, key);
163
+ mkdirSync(dirname(blob), { recursive: true });
164
+ for (let attempt = 0; ; attempt += 1) {
165
+ try {
166
+ const fd = openSync(blob, 'wx');
167
+ try {
168
+ if (typeof data === 'string') writeSync(fd, data);
169
+ else writeSync(fd, data);
170
+ fsyncSync(fd);
171
+ } finally {
172
+ closeSync(fd);
173
+ }
174
+ fsyncDir(dirname(blob));
175
+ break;
176
+ } catch (err) {
177
+ const code = (err as NodeJS.ErrnoException)?.code;
178
+ // Concurrent same-key winner already created the blob: adopt it.
179
+ // Windows reports the loser as EPERM/EACCES/EBUSY when the winner
180
+ // still holds the file, so those mean EEXIST when the blob exists.
181
+ if (code === 'EEXIST') break;
182
+ if (code === 'EPERM' || code === 'EACCES' || code === 'EBUSY') {
183
+ if (existsSync(blob)) break;
184
+ if (attempt < 100) continue;
185
+ }
186
+ throw err;
187
+ }
188
+ }
189
+ refs[key] = (refs[key] ?? 0) + 1;
190
+ quarantined.delete(key);
191
+ persistManifest(dir, refs, quarantined);
192
+ return key;
193
+ },
194
+
195
+ get(key: string): Buffer | null {
196
+ if (!KEY_RE.test(key)) return null;
197
+ if (!(key in refs)) return null;
198
+ const blob = casPathFor(dir, key);
199
+ if (!existsSync(blob)) return null;
200
+ let bytes: Buffer;
201
+ try {
202
+ bytes = readFileSync(blob);
203
+ } catch {
204
+ return null;
205
+ }
206
+ if (casKeyFor(bytes) !== key) {
207
+ const qpath = casQuarantinePathFor(dir, key);
208
+ try {
209
+ mkdirSync(dirname(qpath), { recursive: true });
210
+ renameSync(blob, qpath);
211
+ } catch (err) {
212
+ const code = (err as NodeJS.ErrnoException)?.code;
213
+ if (code === 'EEXIST' || code === 'EPERM') {
214
+ // A previous quarantine already occupies the sidecar (Windows
215
+ // rename refuses to overwrite): drop the loser copy rather than
216
+ // serving corrupt bytes or throwing on the data path.
217
+ try {
218
+ unlinkSync(qpath);
219
+ renameSync(blob, qpath);
220
+ } catch {
221
+ try {
222
+ unlinkSync(blob);
223
+ } catch {
224
+ // Blob already gone; refs cleanup below still applies.
225
+ }
226
+ }
227
+ } else if (code !== 'ENOENT') {
228
+ try {
229
+ unlinkSync(blob);
230
+ } catch {
231
+ // Blob already gone; refs cleanup below still applies.
232
+ }
233
+ }
234
+ }
235
+ fsyncDir(dirname(blob));
236
+ fsyncDir(dirname(qpath));
237
+ quarantined.add(key);
238
+ persistManifest(dir, refs, quarantined);
239
+ store.quarantined += 1;
240
+ return null;
241
+ }
242
+ return bytes;
243
+ },
244
+
245
+ has(key: string): boolean {
246
+ if (!KEY_RE.test(key)) return false;
247
+ if (!(key in refs) || quarantined.has(key)) return false;
248
+ return existsSync(casPathFor(dir, key));
249
+ },
250
+
251
+ stat(key: string): CasStat | null {
252
+ if (!KEY_RE.test(key)) return null;
253
+ const n = refs[key];
254
+ if (n === undefined) return null;
255
+ if (quarantined.has(key)) {
256
+ let size = 0;
257
+ try {
258
+ const fd = openSync(casQuarantinePathFor(dir, key), 'r');
259
+ try {
260
+ size = fstatSync(fd).size;
261
+ } finally {
262
+ try { closeSync(fd); } catch { /* ignore */ }
263
+ }
264
+ } catch { size = 0; }
265
+ return { key, size, refcount: n };
266
+ }
267
+ const blob = casPathFor(dir, key);
268
+ let fd: number | undefined;
269
+ try {
270
+ fd = openSync(blob, 'r');
271
+ const size = fstatSync(fd).size;
272
+ return { key, size, refcount: n };
273
+ } catch {
274
+ return null;
275
+ } finally {
276
+ if (fd !== undefined) {
277
+ try {
278
+ closeSync(fd);
279
+ } catch {
280
+ // Ignore close errors on a read-only stat probe.
281
+ }
282
+ }
283
+ }
284
+ },
285
+
286
+ link(key: string): void {
287
+ checkKey(key);
288
+ if (!(key in refs)) throw new Error('link of unknown cas key (put first, no phantom refs)');
289
+ refs[key] += 1;
290
+ persistManifest(dir, refs, quarantined);
291
+ },
292
+
293
+ unlink(key: string): boolean {
294
+ checkKey(key);
295
+ const n = refs[key];
296
+ if (n === undefined) throw new Error('unlink of unknown cas key (no phantom refs)');
297
+ if (n <= 1) {
298
+ delete refs[key];
299
+ quarantined.delete(key);
300
+ const blob = casPathFor(dir, key);
301
+ if (existsSync(blob)) unlinkSync(blob);
302
+ try { unlinkSync(casQuarantinePathFor(dir, key)); } catch { /* no sidecar */ }
303
+ persistManifest(dir, refs, quarantined);
304
+ return true;
305
+ }
306
+ refs[key] = n - 1;
307
+ persistManifest(dir, refs, quarantined);
308
+ return false;
309
+ },
310
+
311
+ gc(): string[] {
312
+ const dead: string[] = [];
313
+ for (const key of Object.keys(refs)) {
314
+ if (quarantined.has(key)) continue;
315
+ if (!existsSync(casPathFor(dir, key))) {
316
+ delete refs[key];
317
+ dead.push(key);
318
+ }
319
+ }
320
+ // Crash window: the blob was exclusively created but the manifest
321
+ // never named it (kill between blob write and persist). Sweep blobs
322
+ // with no ref entry so they never leak silently.
323
+ let shards: string[] = [];
324
+ try {
325
+ shards = readdirSync(join(dir, 'sha'));
326
+ } catch {
327
+ shards = [];
328
+ }
329
+ for (const shard of shards) {
330
+ let names: string[] = [];
331
+ try {
332
+ names = readdirSync(join(dir, 'sha', shard));
333
+ } catch {
334
+ continue;
335
+ }
336
+ for (const rest of names) {
337
+ const key = shard + rest;
338
+ if (!KEY_RE.test(key)) continue;
339
+ if (key in refs) continue;
340
+ try {
341
+ unlinkSync(join(dir, 'sha', shard, rest));
342
+ } catch {
343
+ continue;
344
+ }
345
+ dead.push(key);
346
+ }
347
+ }
348
+ persistManifest(dir, refs, quarantined);
349
+ return dead;
350
+ },
351
+
352
+ close(): void {
353
+ persistManifest(dir, refs, quarantined);
354
+ },
355
+ };
356
+ return store;
357
+ }