reeflog-client 0.0.0-stage → 0.7.21

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1875 -2
  3. package/index.js +597 -0
  4. package/package.json +34 -4
package/index.js ADDED
@@ -0,0 +1,597 @@
1
+ // The Node client for reeflog — the "SDKs" the README's free tier promises,
2
+ // shipped as one file with no dependencies.
3
+ //
4
+ // Why this exists (2026-11-07): reeflog's free tier claims "daemon, journal,
5
+ // CLI, SDKs, webhook fan-out …" and a third party who wanted to consume the
6
+ // mailbox from Node had to hand-roll `spawn('reeflog tail')` themselves and,
7
+ // worse, get the EXIT-CODE contract right by hand. That contract is the whole
8
+ // product:
9
+ //
10
+ // exit 0 — the batch is fully delivered, the cursor advanced;
11
+ // exit 3 — the delivery HELD at a row whose account envelope is still owed
12
+ // (tail), or the scan proves nothing (verify/repair/hydrate).
13
+ // Rows before the hold WERE delivered; nothing at or after it was
14
+ // consumed. Treating exit 3 as "failure, retry the batch" replays
15
+ // rows; treating it as success silently drops the owed row;
16
+ // exit 1 — the run refused (no journal, unknown flag, missing value).
17
+ //
18
+ // The client maps that contract into types instead of letting each consumer
19
+ // re-derive it from a blog post. It never opens the journal: every call goes
20
+ // through the `reeflog` binary, so there is exactly one implementation of the
21
+ // reader contract and no drift between a library's idea of the schema and the
22
+ // daemon's.
23
+ //
24
+ // Cursor files are the binary's (it reads and writes them); the client only
25
+ // picks a default path per channel so two consumers of the same journal do
26
+ // not share a cursor by accident. Pass your own `cursor` to identify a
27
+ // consumer.
28
+
29
+ import { spawn } from 'node:child_process';
30
+ import path from 'node:path';
31
+ import fs from 'node:fs';
32
+ import os from 'node:os';
33
+ import http from 'node:http';
34
+ import https from 'node:https';
35
+ import { createHash } from 'node:crypto';
36
+ import { gunzipSync } from 'node:zlib';
37
+
38
+ /** Exit codes the reeflog CLI uses, mirrored as constants (not guessed:
39
+ * documented in the README and pinned by tests/arg_parsing.rs). */
40
+ export const EXIT = {
41
+ OK: 0,
42
+ REFUSED: 1,
43
+ INCOMPLETE: 3,
44
+ };
45
+
46
+ /** A refusal or incomplete pass, carrying the binary's own words. */
47
+ export class ReeflogError extends Error {
48
+ constructor(message, { exitCode, stderr, args } = {}) {
49
+ super(message);
50
+ this.name = 'ReeflogError';
51
+ this.exitCode = exitCode;
52
+ this.stderr = stderr;
53
+ this.args = args;
54
+ }
55
+ }
56
+
57
+ /** The single call everything else is built on: run the binary, collect
58
+ * stdout/stderr, and hand back { code, stdout, stderr, lines } with the
59
+ * refusal already classified. Nothing is thrown on exit 3 — the caller
60
+ * decides what "held" or "incomplete" means for its own channel.
61
+ *
62
+ * CLASSIFIED runs (the typed API above) accept only the codes the product's
63
+ * contract defines: 0 delivered, 1 refused, 3 held/incomplete. Every other
64
+ * close — the journal-write failure's exit 2, an old binary's usage exit 64
65
+ * (version skew), a panic's 101, a kill by signal (close code null) — is a
66
+ * REFUSAL carrying that code and the binary's last words. Before this rule
67
+ * `run()` treated "not 1 and not 3" as success, so a run that died AFTER
68
+ * printing rows resolved as a delivered batch (held:null = "fully
69
+ * delivered"), and a SIGKILL'd child was dressed up as a forged "exit 1"
70
+ * refusal that named no signal. Both faces measured on the published
71
+ * v0.7.19 tree — research/client-exit-voice-2026-11-12.json.
72
+ *
73
+ * `unclassified` (raw() only) keeps the documented escape hatch: the code
74
+ * comes back as { exitCode } so a caller can implement semantics this file
75
+ * does not know yet. */
76
+ function run(bin, args, { allowExit3 = false, unclassified = false } = {}) {
77
+ return new Promise((resolve, reject) => {
78
+ const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
79
+ let stdout = '';
80
+ let stderr = '';
81
+ child.stdout.on('data', (d) => { stdout += d; });
82
+ child.stderr.on('data', (d) => { stderr += d; });
83
+ child.on('error', (e) => {
84
+ if (e.code === 'ENOENT') {
85
+ reject(new ReeflogError(
86
+ `reeflog binary not found at "${bin}" — get one without a Rust toolchain: \`import { bootstrap } from 'reeflog-client'; await bootstrap()\` (verifies the release's sha256), or from source: \`cargo install --git https://github.com/auroraxo/reeflog --tag ${packageVersion() || 'v0.7.0'}\`; or pass { bin: "/path/to/reeflog" }`,
87
+ { args },
88
+ ));
89
+ return;
90
+ }
91
+ reject(new ReeflogError(`reeflog failed to start: ${e.message}`, { args }));
92
+ });
93
+ child.on('close', (code, signal) => {
94
+ const exitCode = unclassified ? (code ?? 1) : code;
95
+ // A kill (SIGKILL, OOM, Ctrl-C) leaves code=null. The binary never
96
+ // refused anything — say who ended the run, with the stderr it did
97
+ // manage to write, instead of minting "exit 1".
98
+ if (!unclassified && code == null) {
99
+ reject(new ReeflogError(
100
+ `reeflog was killed by ${signal ?? 'an unknown signal'} — no verdict was produced${stderr.trim() ? `: ${stderr.trim()}` : ''}`,
101
+ { exitCode: null, stderr, args },
102
+ ));
103
+ return;
104
+ }
105
+ if (exitCode === 1) {
106
+ // The binary's own refusal, verbatim: "no journal at … — run
107
+ // `reeflog init …`" and friends are the actionable part.
108
+ const first = stderr.split('\n').find((l) => l.trim()) || `exit ${exitCode}`;
109
+ reject(new ReeflogError(first.trim(), { exitCode, stderr, args }));
110
+ return;
111
+ }
112
+ if (exitCode === 3 && !allowExit3) {
113
+ reject(new ReeflogError(
114
+ `reeflog pass INCOMPLETE: ${stderr.trim()}`,
115
+ { exitCode, stderr, args },
116
+ ));
117
+ return;
118
+ }
119
+ if (!unclassified && exitCode !== 0 && exitCode !== 3) {
120
+ // The contract knows 0/1/3. Anything else is a crashed or mismatched
121
+ // run: name the REAL code and the binary's last words, so a version
122
+ // skew (usage text) or a journal-write failure reaches the caller
123
+ // instead of JSON.parse eating it or a zero-row batch passing as
124
+ // "delivered".
125
+ const first = stderr.split('\n').find((l) => l.trim()) || stderr.trim();
126
+ const hint = exitCode === 64
127
+ ? ' (64 = usage error — this binary does not know the invocation; check for client/binary version skew)'
128
+ : '';
129
+ reject(new ReeflogError(
130
+ `reeflog exited with code ${exitCode}${hint} — refusing to call it a verdict${first ? `: ${first}` : ''}`,
131
+ { exitCode, stderr, args },
132
+ ));
133
+ return;
134
+ }
135
+ resolve({
136
+ exitCode,
137
+ stdout,
138
+ stderr,
139
+ lines: stdout.split('\n').filter((l) => l.trim()),
140
+ });
141
+ });
142
+ });
143
+ }
144
+
145
+ const parseJsonLines = (lines) => lines.map((l) => JSON.parse(l));
146
+
147
+ function cursorFor(channel, db) {
148
+ return path.resolve(`${db}.${channel}.cursor`);
149
+ }
150
+
151
+ /**
152
+ * The numeric POSITION a cursor file holds, in either form it may carry:
153
+ * a bare whole number (the operator's deliberate rewind) or the structured
154
+ * cursor reeflog writes since v0.7.13 (`{"v":1,"channel":…,"journal":…,
155
+ * "value":N}`). The client reports the position, never the encoding.
156
+ * @param {string} file cursor file path
157
+ * @returns {number} the position, 0 when there is no file
158
+ */
159
+ function cursorPosition(file) {
160
+ if (!fs.existsSync(file)) return 0;
161
+ const raw = fs.readFileSync(file, 'utf8').trim();
162
+ if (!raw) return 0;
163
+ if (raw.startsWith('{')) {
164
+ try {
165
+ const v = JSON.parse(raw);
166
+ if (Number.isInteger(v?.value)) return v.value;
167
+ } catch {
168
+ // fall through to 0 — a cursor we cannot read is no position
169
+ }
170
+ return 0;
171
+ }
172
+ const n = Number(raw);
173
+ return Number.isInteger(n) ? n : 0;
174
+ }
175
+
176
+ export class Reeflog {
177
+ /**
178
+ * @param {object} opts
179
+ * @param {string} opts.db path to the journal (created by `reeflog init`)
180
+ * @param {string} [opts.bin] path to the reeflog binary (default: `reeflog` on PATH)
181
+ */
182
+ constructor({ db, bin = 'reeflog' } = {}) {
183
+ if (!db) throw new ReeflogError('Reeflog({ db }) requires the journal path');
184
+ this.db = db;
185
+ this.bin = bin;
186
+ }
187
+
188
+ /** `reeflog status` as parsed JSON: event counts, payload/envelope debt,
189
+ * gap counters, and the exact window the journal claims. */
190
+ async status() {
191
+ const r = await run(this.bin, ['status', '--db', this.db]);
192
+ return JSON.parse(r.stdout);
193
+ }
194
+
195
+ /**
196
+ * One batch of events after the cursor — `reeflog tail`.
197
+ *
198
+ * Cursor semantics are the binary's: the file advances to the newest row
199
+ * CONSUMED (delivered, or skipped because a recorded outcome made it
200
+ * permanently undeliverable). On exit 3 the hold row and everything after
201
+ * it are NOT consumed — the next call returns them once the debt is paid
202
+ * (`reeflog hydrate --accounts`), so nothing is ever lost to a consumer
203
+ * that acted on a partial batch.
204
+ *
205
+ * @param {object} [opts]
206
+ * @param {number} [opts.limit=1000]
207
+ * @param {string} [opts.cursor] cursor file; defaults per db+consumer
208
+ * @param {string} [opts.consumer] label for the default cursor file
209
+ * @param {boolean} [opts.withAccounts] hold at a row whose account envelope is owed
210
+ * @param {boolean} [opts.noLogs] identity-only rows (no logs/accounts payload)
211
+ * @returns {Promise<{events: object[], held: object|null, cursor: string, exitCode: number}>}
212
+ * `held` is the row the delivery stopped at (exit 3) or null.
213
+ */
214
+ async tail({ limit = 1000, cursor, consumer = 'default', withAccounts = false, noLogs = false } = {}) {
215
+ const cursorFile = cursor ?? cursorFor(`tail.${consumer}`, this.db);
216
+ const args = ['tail', '--db', this.db, '--cursor', cursorFile, '--limit', String(limit)];
217
+ if (withAccounts) args.push('--with-accounts');
218
+ if (noLogs) args.push('--no-logs');
219
+
220
+ const r = await run(this.bin, args, { allowExit3: true });
221
+ const events = parseJsonLines(r.lines);
222
+
223
+ // exit 3 on tail means exactly one thing: HELD at an owed row, named in
224
+ // stderr ("HELD at seq N <sig>: …"). The rows printed above it are
225
+ // delivered and the cursor already advanced past them.
226
+ let held = null;
227
+ if (r.exitCode === EXIT.INCOMPLETE) {
228
+ // The binary names the hold as "HELD at seq <n> <signature>: <why>".
229
+ // The signature is base58, so it never carries a colon — stop the match
230
+ // before the colon instead of swallowing it.
231
+ const m = r.stderr.match(/HELD at seq (\d+) ([^\s:]+)/);
232
+ held = m
233
+ ? { seq: Number(m[1]), signature: m[2], reason: r.stderr.trim() }
234
+ : { seq: null, signature: null, reason: r.stderr.trim() };
235
+ }
236
+
237
+ return {
238
+ events,
239
+ held,
240
+ cursor: cursorFile,
241
+ cursorValue: cursorPosition(cursorFile),
242
+ exitCode: r.exitCode,
243
+ };
244
+ }
245
+
246
+ /**
247
+ * The retraction channel — `reeflog reorg tail`: events the chain no
248
+ * longer knows, each with the two independent sources that refused it.
249
+ * Separate cursor from `tail` (the channels are separate).
250
+ */
251
+ async reorgs({ limit = 1000, cursor, consumer = 'default' } = {}) {
252
+ const cursorFile = cursor ?? cursorFor(`reorg.${consumer}`, this.db);
253
+ const r = await run(this.bin, ['reorg', 'tail', '--db', this.db, '--cursor', cursorFile, '--limit', String(limit)]);
254
+ return {
255
+ retractions: parseJsonLines(r.lines),
256
+ cursor: cursorFile,
257
+ cursorValue: cursorPosition(cursorFile),
258
+ exitCode: r.exitCode,
259
+ };
260
+ }
261
+
262
+ /**
263
+ * `reeflog verify` — held against the chain. Exit 3 (incomplete scan)
264
+ * throws: an incomplete scan proves NOTHING and must never be mistaken
265
+ * for a clean one. `complete: false` therefore never returns normally.
266
+ */
267
+ async verify({ pages, sinceSlot, rpc, program, anchorSlot } = {}) {
268
+ const args = ['verify', '--db', this.db];
269
+ if (pages != null) args.push('--pages', String(pages));
270
+ if (sinceSlot != null) args.push('--since-slot', String(sinceSlot));
271
+ if (rpc) args.push('--rpc', rpc);
272
+ if (program) args.push('--program', program);
273
+ if (anchorSlot != null) args.push('--anchor-slot', String(anchorSlot));
274
+ const r = await run(this.bin, args); // exit 3 -> ReeflogError
275
+ return JSON.parse(r.stdout);
276
+ }
277
+
278
+ /** `reeflog repair` — fill holes; throws on an incomplete pass. */
279
+ async repair({ pages, sinceSlot, rpc, program, anchorSlot } = {}) {
280
+ const args = ['repair', '--db', this.db];
281
+ if (pages != null) args.push('--pages', String(pages));
282
+ if (sinceSlot != null) args.push('--since-slot', String(sinceSlot));
283
+ if (rpc) args.push('--rpc', rpc);
284
+ if (program) args.push('--program', program);
285
+ if (anchorSlot != null) args.push('--anchor-slot', String(anchorSlot));
286
+ const r = await run(this.bin, args);
287
+ return JSON.parse(r.stdout);
288
+ }
289
+
290
+ /**
291
+ * `reeflog hydrate` — pay the payload/envelope debt for owed rows.
292
+ * Throws on an incomplete pass (exit 3), because the debt it reports is
293
+ * real; the counts in the returned summary say what was paid.
294
+ */
295
+ async hydrate({ limit, rpc, accounts = false, owners = false, accountData = false } = {}) {
296
+ const args = ['hydrate', '--db', this.db];
297
+ if (limit != null) args.push('--limit', String(limit));
298
+ if (rpc) args.push('--rpc', rpc);
299
+ if (accounts) args.push('--accounts');
300
+ if (owners) args.push('--owners');
301
+ if (accountData) args.push('--account-data');
302
+ const r = await run(this.bin, args);
303
+ return JSON.parse(r.stdout);
304
+ }
305
+
306
+ /**
307
+ * Escape hatch: any documented command, raw. The exit code comes back
308
+ * instead of being classified, so a caller can implement semantics this
309
+ * file does not know yet — without re-implementing the spawn. Codes the
310
+ * typed API refuses (2/64/101/signal) are still handed back here; that
311
+ * is the hatch's whole point.
312
+ */
313
+ async raw(args) {
314
+ return run(this.bin, args, { allowExit3: true, unclassified: true });
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Minimal tar reader for ONE regular file named `reeflog` — enough for the
320
+ * release archives (`tar -czf … reeflog`, ustar/gnu, one entry). It refuses
321
+ * anything it does not understand rather than guessing: a bootstrap that
322
+ * writes an unexpected layout is worse than one that stops.
323
+ */
324
+ function extractReeflogFromTarGz(gz) {
325
+ const tar = gunzipSync(gz);
326
+ const wanted = [];
327
+ for (let off = 0; off + 512 <= tar.length; off += 512) {
328
+ const header = tar.subarray(off, off + 512);
329
+ if (header.every((b) => b === 0)) break; // end-of-archive marker
330
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/s, '');
331
+ const sizeField = header.subarray(124, 136).toString('utf8').replace(/\0.*$/s, '').trim();
332
+ const size = parseInt(sizeField || '0', 8);
333
+ const type = String.fromCharCode(header[156]);
334
+ const data = tar.subarray(off + 512, off + 512 + size);
335
+ if ((type === '0' || type === '\0' || type === '') && path.posix.basename(name) === 'reeflog') {
336
+ wanted.push(Buffer.from(data));
337
+ }
338
+ off += 512 * Math.ceil(size / 512);
339
+ }
340
+ if (wanted.length !== 1) {
341
+ throw new ReeflogError(
342
+ `release archive holds ${wanted.length} 'reeflog' binaries, expected exactly 1 — refusing to install an archive I do not understand`,
343
+ );
344
+ }
345
+ return wanted[0];
346
+ }
347
+
348
+ // The prebuilt archives the release workflow actually publishes. This list
349
+ // must name EXACTLY the assets in .github/workflows/release.yml — a client
350
+ // that maps a platform to an asset the release does not carry sends a
351
+ // stranger to a 404 instead of a binary. tests/bootstrap.test.mjs pins the
352
+ // two lists against each other.
353
+ export const RELEASE_ASSETS = {
354
+ 'linux-x64': 'reeflog-linux-amd64.tar.gz',
355
+ 'darwin-arm64': 'reeflog-macos-arm64.tar.gz',
356
+ };
357
+
358
+ /** The bound on one HTTP leg of bootstrap: the SAME no-answer class the
359
+ * daemon-side webhook refuses with `timeout_ms` (src/webhook.rs,
360
+ * MAX_TIMEOUT_MS — "a webhook whose endpoint hangs must not hang the
361
+ * delivery pass") was re-opened on the client. Measured 2026-11-11 against
362
+ * the PUBLISHED v0.7.17 tree: an endpoint that accepts the connection and
363
+ * never answers left `await bootstrap()` hanging with NO verdict (45 s
364
+ * watchdog, research/bootstrap-hang-published-v0717-2026-11-11.json), and a
365
+ * self-referential 302 was re-requested 14,937 times in 12 s with none.
366
+ *
367
+ * The rule: every leg gets a verdict inside the budget. The timer is an
368
+ * INACTIVITY bound, not a total one — it arms at headers and resets on every
369
+ * received byte — so a slow link that makes progress keeps working (guard
370
+ * G3), while silence cannot park a stranger's install forever. Redirects
371
+ * follow at most MAX_REDIRECTS hops and a loop is REFUSED BY NAME, not
372
+ * retried into the ground. */
373
+ export const DEFAULT_GET_TIMEOUT_MS = 30_000;
374
+ export const MAX_REDIRECTS = 5;
375
+
376
+ function httpGet(url, { timeoutMs = DEFAULT_GET_TIMEOUT_MS, redirectsLeft = MAX_REDIRECTS } = {}) {
377
+ const mod = url.startsWith('https:') ? https : http;
378
+ return new Promise((resolve, reject) => {
379
+ let settled = false;
380
+ const fail = (message) => {
381
+ if (settled) return;
382
+ settled = true;
383
+ reject(new ReeflogError(message));
384
+ };
385
+ const req = mod.get(url, { headers: { 'user-agent': 'reeflog-client' } }, (res) => {
386
+ // Headers arrived: the headers-phase timer has done its job. Hand the
387
+ // budget to the between-bytes timer below. (Without this, the socket
388
+ // idle timeout re-arms on the last byte and races the quiet timer at
389
+ // the same deadline, then reports a headers stall that never was.)
390
+ req.setTimeout(0);
391
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
392
+ res.resume();
393
+ if (redirectsLeft <= 0) {
394
+ // The loop face, refused. A bounded hop count turning into this
395
+ // refusal is the whole difference between "mirror hiccup" and
396
+ // "14,937 re-requests with no verdict".
397
+ fail(`download of ${url} failed: redirect chain exceeded ${MAX_REDIRECTS} hops — refusing to follow a redirect loop (this hop: ${url})`);
398
+ return;
399
+ }
400
+ let next;
401
+ try {
402
+ next = new URL(res.headers.location, url).href;
403
+ } catch {
404
+ fail(`download of ${url} failed: HTTP ${res.statusCode} with unparseable redirect target ${JSON.stringify(res.headers.location)}`);
405
+ return;
406
+ }
407
+ httpGet(next, { timeoutMs, redirectsLeft: redirectsLeft - 1 }).then(
408
+ (b) => { if (!settled) resolve(b); },
409
+ (e) => fail(e.message),
410
+ );
411
+ return;
412
+ }
413
+ if (res.statusCode !== 200) {
414
+ res.resume();
415
+ fail(`download of ${url} failed: HTTP ${res.statusCode}`);
416
+ return;
417
+ }
418
+ // Headers arrived: the budget now runs BETWEEN bytes. Any single gap
419
+ // of timeoutMs with no progress is the stall; any byte defers the
420
+ // verdict, which keeps real slow links on the working side.
421
+ let quiet = null;
422
+ const armQuiet = () => {
423
+ if (quiet) clearTimeout(quiet);
424
+ quiet = setTimeout(() => {
425
+ res.destroy();
426
+ fail(`download of ${url} stalled: no bytes for ${timeoutMs} ms after headers — refusing to wait without a verdict (pass { timeoutMs } to bootstrap() to allow slower links)`);
427
+ }, timeoutMs);
428
+ quiet.unref?.();
429
+ };
430
+ armQuiet();
431
+ const chunks = [];
432
+ res.on('data', (d) => { chunks.push(d); armQuiet(); });
433
+ res.on('end', () => {
434
+ if (quiet) clearTimeout(quiet);
435
+ if (!settled) { settled = true; resolve(Buffer.concat(chunks)); }
436
+ });
437
+ res.on('error', () => fail(`download of ${url} failed: connection reset mid-body`));
438
+ });
439
+ // No verdict at all inside the budget is the defect; destroy the socket.
440
+ // (An un-destroyed stalled request also pins the process open — the
441
+ // pre-fix ladder could not even exit its own runner.)
442
+ req.setTimeout(timeoutMs, () => {
443
+ req.destroy();
444
+ fail(`download of ${url} stalled: no response headers within ${timeoutMs} ms — refusing to wait without a verdict (pass { timeoutMs } to bootstrap() to allow slower links)`);
445
+ });
446
+ req.on('error', (e) => fail(`download of ${url} failed: ${e.message}`));
447
+ });
448
+ }
449
+
450
+ /** The version this client ships with — read from the package manifest, never
451
+ * hard-coded. A hard-coded version here is the pin-lag defect this repo has
452
+ * already measured twice (signerd v0.2.4→v0.2.6): the client would fetch
453
+ * an OLD release while the package says a newer one. test/bootstrap.test.mjs
454
+ * pins the two together. */
455
+ function packageVersion() {
456
+ try {
457
+ const pkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, 'package.json'), 'utf8'));
458
+ if (pkg && typeof pkg.version === 'string' && /^\d+\.\d+\.\d+$/.test(pkg.version)) {
459
+ return `v${pkg.version}`;
460
+ }
461
+ } catch { /* fall through to the explicit version */ }
462
+ return null;
463
+ }
464
+
465
+ /**
466
+ * Install the `reeflog` binary for this platform WITHOUT a Rust toolchain:
467
+ * take the prebuilt archive from the GitHub release, verify its bytes
468
+ * against the sha256 the release API stores for that asset, then extract.
469
+ *
470
+ * Trust: the same anchor as `cargo install --git` (this repository over
471
+ * TLS) — it does not defend against whoever can push tags here, and says
472
+ * so. Unsupported platforms and missing digests REFUSE; nothing is written
473
+ * before the digest matches. A cache hit re-hashes the installed binary
474
+ * against the sidecar's `binarySha256` — the return value's `digest` is the
475
+ * ARCHIVE hash, `binarySha256` is the hash of `bin` itself.
476
+ *
477
+ * @returns {Promise<{bin: string, asset: string, digest: string, binarySha256: string, cached: boolean}>}
478
+ */
479
+ export async function bootstrap({
480
+ version = packageVersion(),
481
+ platform = process.platform,
482
+ arch = process.arch,
483
+ dest = path.join(os.homedir(), '.reeflog'),
484
+ apiBase = 'https://api.github.com',
485
+ downloadBase = null,
486
+ timeoutMs = DEFAULT_GET_TIMEOUT_MS,
487
+ } = {}) {
488
+ const asset = RELEASE_ASSETS[`${platform}-${arch}`];
489
+ if (!asset) {
490
+ throw new ReeflogError(
491
+ `no prebuilt reeflog binary for ${platform}-${arch} (published: ${Object.keys(RELEASE_ASSETS).join(', ')}) — install from source: \`cargo install --git https://github.com/auroraxo/reeflog --tag ${version}\``,
492
+ );
493
+ }
494
+ if (!/^v\d+\.\d+\.\d+$/.test(String(version))) {
495
+ throw new ReeflogError(`bootstrap needs an explicit release tag like ${packageVersion() || 'v0.7.0'}, got ${JSON.stringify(version)}`);
496
+ }
497
+
498
+ const bin = path.join(dest, 'reeflog');
499
+ const metaFile = path.join(dest, 'install.json');
500
+ if (fs.existsSync(bin) && fs.existsSync(metaFile)) {
501
+ try {
502
+ const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
503
+ if (meta.version === version && meta.asset === asset) {
504
+ // A cache hit is a VERIFIED claim, so it has to be verified here:
505
+ // re-hash the bytes on disk and compare them to the sidecar. Before
506
+ // this check the sidecar's version+asset alone produced
507
+ // `{ cached: true, digest: <archive sha256> }` for ANY file sitting
508
+ // at `bin` — replaced, truncated, or corrupted — and `digest` names
509
+ // the ARCHIVE, never the binary (measured 2026-11-07 against the
510
+ // published v0.7.0 client: tampered binary reported as verified,
511
+ // claimed f6b36c53… / actual 6b9fd8b7…).
512
+ // A sidecar without `binarySha256` (every install made before this
513
+ // fix) proves nothing about the bytes: fall through, re-fetch once,
514
+ // upgrade the sidecar, cache again.
515
+ if (typeof meta.binarySha256 === 'string' && /^[0-9a-f]{64}$/.test(meta.binarySha256)) {
516
+ const onDisk = createHash('sha256').update(fs.readFileSync(bin)).digest('hex');
517
+ if (onDisk === meta.binarySha256) {
518
+ return { bin, asset, digest: meta.sha256, binarySha256: onDisk, cached: true };
519
+ }
520
+ }
521
+ }
522
+ } catch { /* fall through to a fresh install */ }
523
+ }
524
+
525
+ const releaseUrl = `${apiBase}/repos/auroraxo/reeflog/releases/tags/${version}`;
526
+ const rawRelease = (await httpGet(releaseUrl, { timeoutMs })).toString('utf8');
527
+ // The API leg can answer 200 with a body that is not a release: a captive
528
+ // portal's login HTML (measured 2026-11-11 against the published v0.7.18
529
+ // tree: JSON.parse threw SyntaxError straight out of bootstrap(), a crash
530
+ // no ReeflogError catch ever saw), or a rate-limit/proxy JSON whose own
531
+ // `message` was then discarded in favour of "release has no asset", a
532
+ // verdict that sends a stranger to a release page that is fine
533
+ // (research/captive-portal-2026-11-11.json). A body that is not a release
534
+ // object must refuse WITH the body's own words — never with an engine
535
+ // crash, never with a cause the endpoint did not give.
536
+ let release;
537
+ try {
538
+ release = JSON.parse(rawRelease);
539
+ } catch (e) {
540
+ const looksHtml = /^\s*</.test(rawRelease);
541
+ throw new ReeflogError(
542
+ `the release API at ${releaseUrl} did not answer JSON — `
543
+ + (looksHtml
544
+ ? 'it answered with HTML (a captive portal or an intercepting proxy fits this shape)'
545
+ : `JSON.parse failed: ${e.message}`)
546
+ + ' — refusing to install from a body I do not understand. Behind a captive portal, authenticate in the browser first; for a mirror or a different host pass { apiBase }.',
547
+ );
548
+ }
549
+ if (release === null || typeof release !== 'object' || Array.isArray(release) || !Array.isArray(release.assets)) {
550
+ const words = release && typeof release.message === 'string'
551
+ ? `its own message: ${release.message}`
552
+ : `body: ${JSON.stringify(release)?.slice(0, 300) ?? String(release)}`;
553
+ throw new ReeflogError(
554
+ `the release API at ${releaseUrl} answered without a release object (${words}) — refusing to install; nothing was written. `
555
+ + 'If this is a rate limit or a proxy, wait or bypass it; for a different host pass { apiBase }.',
556
+ );
557
+ }
558
+ const entry = (release.assets || []).find((a) => a.name === asset);
559
+ if (!entry) {
560
+ throw new ReeflogError(`release ${version} has no asset named ${asset} — nothing to install`);
561
+ }
562
+ const expected = (entry.digest || '').replace(/^sha256:/i, '');
563
+ if (!/^[0-9a-f]{64}$/.test(expected)) {
564
+ throw new ReeflogError(
565
+ `the release API reports no sha256 digest for ${asset} — refusing to install bytes I cannot verify`,
566
+ );
567
+ }
568
+
569
+ const url = downloadBase ? `${downloadBase}/${asset}` : entry.browser_download_url;
570
+ const gz = await httpGet(url, { timeoutMs });
571
+ const actual = createHash('sha256').update(gz).digest('hex');
572
+ if (actual !== expected) {
573
+ throw new ReeflogError(
574
+ `sha256 mismatch for ${asset}: release says ${expected}, download is ${actual} — refusing to install`,
575
+ );
576
+ }
577
+
578
+ const executable = extractReeflogFromTarGz(gz);
579
+ const binarySha256 = createHash('sha256').update(executable).digest('hex');
580
+ fs.mkdirSync(dest, { recursive: true });
581
+ const tmp = path.join(dest, `.reeflog-${process.pid}.tmp`);
582
+ fs.writeFileSync(tmp, executable, { mode: 0o755 });
583
+ fs.renameSync(tmp, bin); // atomic: a crash leaves the old binary or none
584
+ // `sha256` is the ARCHIVE digest (what the release API stores);
585
+ // `binarySha256` is the EXECUTABLE's own hash — the only value the cache
586
+ // may be checked against, and the one a caller can check `bin` with.
587
+ fs.writeFileSync(metaFile, `${JSON.stringify({ version, asset, sha256: actual, binarySha256, installedAt: new Date().toISOString() }, null, 2)}\n`);
588
+
589
+ return { bin, asset, digest: actual, binarySha256, cached: false };
590
+ }
591
+
592
+ /** Convenience constructor: `const box = reeflog({ db: 'reeflog.db' })`. */
593
+ export function reeflog(opts) {
594
+ return new Reeflog(opts);
595
+ }
596
+
597
+ export default reeflog;
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "reeflog-client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.7.21",
4
+ "description": "Node client for reeflog, the durable local mailbox for Solana events: cursor-based event delivery, retractions, health and repair.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18.17"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/auroraxo/reeflog.git"
22
+ },
23
+ "keywords": [
24
+ "solana",
25
+ "events",
26
+ "journal",
27
+ "cursor",
28
+ "mailbox"
29
+ ],
30
+ "scripts": {
31
+ "test": "node --test"
32
+ },
33
+ "dependencies": {
34
+ "reeflog-client": "github:auroraxo/reeflog#v0.7.20"
35
+ }
36
+ }