cursedbelt 2.6.1 → 2.7.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.
Files changed (65) hide show
  1. package/dist/server/sync/alarm.d.ts +35 -0
  2. package/dist/server/sync/alarm.d.ts.map +1 -0
  3. package/dist/server/sync/alarm.js +92 -0
  4. package/dist/server/sync/alarm.js.map +1 -0
  5. package/dist/server/sync/commands.d.ts +88 -0
  6. package/dist/server/sync/commands.d.ts.map +1 -0
  7. package/dist/server/sync/commands.js +242 -0
  8. package/dist/server/sync/commands.js.map +1 -0
  9. package/dist/server/sync/engine.d.ts +63 -0
  10. package/dist/server/sync/engine.d.ts.map +1 -0
  11. package/dist/server/sync/engine.js +185 -0
  12. package/dist/server/sync/engine.js.map +1 -0
  13. package/dist/server/sync/http.d.ts +107 -0
  14. package/dist/server/sync/http.d.ts.map +1 -0
  15. package/dist/server/sync/http.js +244 -0
  16. package/dist/server/sync/http.js.map +1 -0
  17. package/dist/server/sync/index.d.ts +42 -0
  18. package/dist/server/sync/index.d.ts.map +1 -0
  19. package/dist/server/sync/index.js +42 -0
  20. package/dist/server/sync/index.js.map +1 -0
  21. package/dist/server/sync/opLog.d.ts +62 -0
  22. package/dist/server/sync/opLog.d.ts.map +1 -0
  23. package/dist/server/sync/opLog.js +97 -0
  24. package/dist/server/sync/opLog.js.map +1 -0
  25. package/dist/server/sync/planner.d.ts +32 -0
  26. package/dist/server/sync/planner.d.ts.map +1 -0
  27. package/dist/server/sync/planner.js +31 -0
  28. package/dist/server/sync/planner.js.map +1 -0
  29. package/dist/server/sync/status.d.ts +64 -0
  30. package/dist/server/sync/status.d.ts.map +1 -0
  31. package/dist/server/sync/status.js +50 -0
  32. package/dist/server/sync/status.js.map +1 -0
  33. package/dist/server/sync/timer.d.ts +53 -0
  34. package/dist/server/sync/timer.d.ts.map +1 -0
  35. package/dist/server/sync/timer.js +171 -0
  36. package/dist/server/sync/timer.js.map +1 -0
  37. package/dist/server/sync/tokens.d.ts +26 -0
  38. package/dist/server/sync/tokens.d.ts.map +1 -0
  39. package/dist/server/sync/tokens.js +52 -0
  40. package/dist/server/sync/tokens.js.map +1 -0
  41. package/dist/server/sync/types.d.ts +102 -0
  42. package/dist/server/sync/types.d.ts.map +1 -0
  43. package/dist/server/sync/types.js +30 -0
  44. package/dist/server/sync/types.js.map +1 -0
  45. package/package.json +19 -13
  46. package/src/server/sync/alarm.spec.ts +149 -0
  47. package/src/server/sync/alarm.ts +137 -0
  48. package/src/server/sync/commands.spec.ts +145 -0
  49. package/src/server/sync/commands.ts +361 -0
  50. package/src/server/sync/engine.spec.ts +496 -0
  51. package/src/server/sync/engine.ts +255 -0
  52. package/src/server/sync/http.ts +316 -0
  53. package/src/server/sync/httpRemote.spec.ts +158 -0
  54. package/src/server/sync/index.ts +90 -0
  55. package/src/server/sync/opLog.spec.ts +110 -0
  56. package/src/server/sync/opLog.ts +189 -0
  57. package/src/server/sync/planner.spec.ts +53 -0
  58. package/src/server/sync/planner.ts +62 -0
  59. package/src/server/sync/status.spec.ts +92 -0
  60. package/src/server/sync/status.ts +108 -0
  61. package/src/server/sync/timer.spec.ts +150 -0
  62. package/src/server/sync/timer.ts +207 -0
  63. package/src/server/sync/tokens.spec.ts +38 -0
  64. package/src/server/sync/tokens.ts +94 -0
  65. package/src/server/sync/types.ts +108 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The status reporter's ONE durable field.
3
+ *
4
+ * These specs exist because of a live defect: prod's vault badge read "Awaiting
5
+ * first sync" over a vault the owner had used for weeks, because the peer-contact
6
+ * stamp lived in the server process and prod restarts on every deploy and sleeps
7
+ * nightly. The failure path — restart, and the fact is gone — is what is pinned.
8
+ */
9
+ import { describe, expect, test } from "bun:test";
10
+ import { PEER_CONTACT_WRITE_MS, createSyncStatusReporter, type SyncStatusStore } from "./status";
11
+
12
+ function fakeStore(initial: number | null = null): SyncStatusStore & { saved: number[] } {
13
+ let value = initial;
14
+ const saved: number[] = [];
15
+ return {
16
+ saved,
17
+ loadPeerContactAt: () => value,
18
+ savePeerContactAt: (at) => {
19
+ value = at;
20
+ saved.push(at);
21
+ },
22
+ };
23
+ }
24
+
25
+ describe("createSyncStatusReporter", () => {
26
+ test("with no store it is exactly the in-memory reporter it always was", () => {
27
+ const s = createSyncStatusReporter();
28
+ expect(s.read().lastPeerContactAt).toBeNull();
29
+ s.update({ lastPeerContactAt: 1000 });
30
+ expect(s.read().lastPeerContactAt).toBe(1000);
31
+ });
32
+
33
+ test("🔴 a peer contact SURVIVES the restart that used to erase it", () => {
34
+ const store = fakeStore();
35
+ const before = createSyncStatusReporter({ role: "receiver" }, store);
36
+ before.update({ lastPeerContactAt: 1_700_000_000_000 });
37
+
38
+ // The deploy / the nightly sleep / a crash — a brand-new process, same database.
39
+ const after = createSyncStatusReporter({ role: "receiver" }, store);
40
+ expect(after.read().lastPeerContactAt).toBe(1_700_000_000_000);
41
+ expect(after.read().role).toBe("receiver");
42
+ });
43
+
44
+ test("only the contact stamp is restored — the rest describes THIS process", () => {
45
+ // A restored `consecutiveFailures: 3` would claim failures that never happened.
46
+ const store = fakeStore(1_700_000_000_000);
47
+ const s = createSyncStatusReporter(undefined, store);
48
+ const status = s.read();
49
+ expect(status.lastPeerContactAt).toBe(1_700_000_000_000);
50
+ expect(status.consecutiveFailures).toBe(0);
51
+ expect(status.lastAttemptAt).toBeNull();
52
+ expect(status.lastOutcome).toBeNull();
53
+ });
54
+
55
+ test("an explicit `initial` still wins over the stored value", () => {
56
+ const s = createSyncStatusReporter({ lastPeerContactAt: 5 }, fakeStore(9));
57
+ expect(s.read().lastPeerContactAt).toBe(5);
58
+ });
59
+
60
+ test("a sync run's several requests cost ONE durable write", () => {
61
+ const store = fakeStore();
62
+ const s = createSyncStatusReporter(undefined, store);
63
+ const t0 = 1_700_000_000_000;
64
+ for (const offset of [0, 100, 250, 900]) s.update({ lastPeerContactAt: t0 + offset });
65
+ expect(store.saved).toEqual([t0]);
66
+ // …and the next run, past the window, writes again.
67
+ s.update({ lastPeerContactAt: t0 + PEER_CONTACT_WRITE_MS });
68
+ expect(store.saved).toEqual([t0, t0 + PEER_CONTACT_WRITE_MS]);
69
+ // The in-memory value is always current regardless of what was written.
70
+ expect(s.read().lastPeerContactAt).toBe(t0 + PEER_CONTACT_WRITE_MS);
71
+ });
72
+
73
+ test("a failing store can never fail the sync request it rode in on", () => {
74
+ const store: SyncStatusStore = {
75
+ loadPeerContactAt: () => null,
76
+ savePeerContactAt: () => {
77
+ throw new Error("disk full");
78
+ },
79
+ };
80
+ const s = createSyncStatusReporter(undefined, store);
81
+ expect(() => s.update({ lastPeerContactAt: 42 })).not.toThrow();
82
+ expect(s.read().lastPeerContactAt).toBe(42);
83
+ });
84
+
85
+ test("a corrupt or absent stored value reads as 'never', not as NaN", () => {
86
+ const s = createSyncStatusReporter(undefined, {
87
+ loadPeerContactAt: () => null,
88
+ savePeerContactAt: () => {},
89
+ });
90
+ expect(s.read().lastPeerContactAt).toBeNull();
91
+ });
92
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The status surface — vault's `SyncStatus` generalized verbatim, because its shape
3
+ * is the product of a real defect: `enabled: false` alone was read as "not syncing"
4
+ * on a receiving half that was in step the whole time. `role` is what tells the
5
+ * receiving half apart from a scratch instance, and anything reasoning about "is
6
+ * this instance syncing" must read `role`, not `enabled`.
7
+ */
8
+
9
+ export interface SyncStatus {
10
+ /** Does THIS instance dial out at all? */
11
+ enabled: boolean;
12
+ /** dialer — runs the loop · receiver — answers the sync surface for a dialer ·
13
+ * none — neither (a dev shell on its own scratch database). */
14
+ role: "dialer" | "receiver" | "none";
15
+ /** The peer's ORIGIN — never the bearer token. Null when unlinked. */
16
+ peer: string | null;
17
+ lastAttemptAt: number | null;
18
+ lastSuccessAt: number | null;
19
+ lastOutcome: "ok" | "unreachable" | "paused" | "error" | null;
20
+ /** Set while the push is version-skew paused (`blockedBy`) — names the op kind the
21
+ * peer cannot take yet, i.e. "the peer needs deploying", the true statement. */
22
+ pausedOn: string | null;
23
+ consecutiveFailures: number;
24
+ /** Local writes the peer has not acknowledged yet — 0 means "fully in step". */
25
+ pending: number;
26
+ /** RECEIVER: when a dialing peer last authenticated against the sync surface. */
27
+ lastPeerContactAt: number | null;
28
+ /** RECEIVER: when someone last pressed "Sync now" here (the dialer polls it). */
29
+ syncRequestedAt: number | null;
30
+ }
31
+
32
+ export const OFFLINE_STATUS: SyncStatus = {
33
+ enabled: false,
34
+ role: "none",
35
+ peer: null,
36
+ lastAttemptAt: null,
37
+ lastSuccessAt: null,
38
+ lastOutcome: null,
39
+ pausedOn: null,
40
+ consecutiveFailures: 0,
41
+ pending: 0,
42
+ lastPeerContactAt: null,
43
+ syncRequestedAt: null,
44
+ };
45
+
46
+ export interface SyncStatusReporter {
47
+ read(): SyncStatus;
48
+ /** The timer's / receiver hooks' writer half — nothing else should call it. */
49
+ update(patch: Partial<SyncStatus>): void;
50
+ }
51
+
52
+ /**
53
+ * Somewhere to keep the ONE field that is a fact about the DATA rather than about
54
+ * this process.
55
+ *
56
+ * 🔴 Why only `lastPeerContactAt`. Every other field describes the live process and
57
+ * SHOULD reset with it: a dialer that has not attempted anything since boot has
58
+ * genuinely not attempted anything, and a restored `consecutiveFailures: 3` would be
59
+ * a claim about failures that did not happen. But "a peer authenticated here" is
60
+ * something that happened to the database, and forgetting it produced the owner's
61
+ * 2026-08-16 report — prod saying *"Awaiting first sync"* over a vault he had used
62
+ * for weeks. The badge's own words are "your Mac has not dialled since this server
63
+ * last restarted", which is true and which nobody reads as anything but "this has
64
+ * never synced". A receiver restarts on every deploy, and the dialer runs every 5
65
+ * minutes only while the Mac is awake — so the window in which a perfectly converged
66
+ * pair reads as never-synced is wide. It was wider still when the receiver was the
67
+ * `prod-ec2` box (decommissioned since), which also slept 12am–10am CT: most of a
68
+ * morning, every morning. In-memory was the wrong home for it.
69
+ */
70
+ export interface SyncStatusStore {
71
+ /** The stored contact stamp, or null if a peer has genuinely never called. */
72
+ loadPeerContactAt(): number | null;
73
+ /** Persist a newer stamp. Called at most once per {@link PEER_CONTACT_WRITE_MS}. */
74
+ savePeerContactAt(at: number): void;
75
+ }
76
+
77
+ /** A dialer makes several requests per run; one durable write per this window is
78
+ * enough to answer "when did a peer last call" to the nearest half-minute. */
79
+ export const PEER_CONTACT_WRITE_MS = 30_000;
80
+
81
+ export function createSyncStatusReporter(
82
+ initial?: Partial<SyncStatus>,
83
+ store?: SyncStatusStore,
84
+ ): SyncStatusReporter {
85
+ const restored = store?.loadPeerContactAt() ?? null;
86
+ let current: SyncStatus = {
87
+ ...OFFLINE_STATUS,
88
+ ...(restored === null ? {} : { lastPeerContactAt: restored }),
89
+ ...initial,
90
+ };
91
+ let lastWrittenAt = restored ?? 0;
92
+ return {
93
+ read: () => ({ ...current }),
94
+ update: (patch) => {
95
+ current = { ...current, ...patch };
96
+ const at = patch.lastPeerContactAt;
97
+ if (store && typeof at === "number" && at - lastWrittenAt >= PEER_CONTACT_WRITE_MS) {
98
+ lastWrittenAt = at;
99
+ // A status write must never be able to fail the sync request it rode in on.
100
+ try {
101
+ store.savePeerContactAt(at);
102
+ } catch {
103
+ /* the in-memory value still answers; the next contact retries the write */
104
+ }
105
+ }
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Fake-clock tests: capture scheduled callbacks, fire them by hand. The timer's
3
+ * contract is vault's — single-flight, unreachable-is-quiet, sync-now fast lane,
4
+ * peer re-resolved every tick.
5
+ */
6
+ import { describe, expect, test } from "bun:test";
7
+ import { createSyncStatusReporter } from "./status";
8
+ import { startSyncTimer, type SyncTimerDeps } from "./timer";
9
+ import type { SyncSummary } from "./types";
10
+
11
+ interface Scheduled {
12
+ fn: () => void;
13
+ ms: number;
14
+ }
15
+
16
+ function harness(overrides: Partial<SyncTimerDeps> = {}) {
17
+ const scheduled: Scheduled[] = [];
18
+ // Start the clock at a realistic epoch: the timer's `lastAttempt: 0` initial state
19
+ // means "never ran" and must read as ancient, exactly as it does under Date.now().
20
+ let now = 1_000_000_000;
21
+ const runs: number[] = [];
22
+ let outcome: () => Promise<SyncSummary> = async () => ({
23
+ remoteId: "peer",
24
+ pushed: 1,
25
+ pulled: 0,
26
+ conflicts: 0,
27
+ held: 0,
28
+ });
29
+ const status = createSyncStatusReporter();
30
+ const deps: SyncTimerDeps = {
31
+ resolvePeer: () => ({ basePath: "http://peer/api/sync", token: "t" }),
32
+ sync: () => {
33
+ runs.push(now);
34
+ return outcome();
35
+ },
36
+ head: () => 0,
37
+ status,
38
+ requestPollMs: 0, // the fast lane has its own test
39
+ now: () => now,
40
+ setTimer: (fn, ms) => {
41
+ const entry = { fn: fn as () => void, ms };
42
+ scheduled.push(entry);
43
+ return entry;
44
+ },
45
+ clearTimer: (handle) => {
46
+ const i = scheduled.indexOf(handle as Scheduled);
47
+ if (i >= 0) scheduled.splice(i, 1);
48
+ },
49
+ log: () => {},
50
+ warn: () => {},
51
+ ...overrides,
52
+ };
53
+ const handle = startSyncTimer(deps);
54
+ /** Fire the most recently scheduled callback, advancing the clock by its delay. */
55
+ const fire = async () => {
56
+ const entry = scheduled.pop();
57
+ if (!entry) throw new Error("nothing scheduled");
58
+ now += entry.ms;
59
+ entry.fn();
60
+ // Let the sync promise chain settle.
61
+ await new Promise((resolve) => setTimeout(resolve, 0));
62
+ await new Promise((resolve) => setTimeout(resolve, 0));
63
+ };
64
+ return {
65
+ scheduled,
66
+ runs,
67
+ status,
68
+ handle,
69
+ fire,
70
+ setOutcome: (fn: typeof outcome) => {
71
+ outcome = fn;
72
+ },
73
+ clock: () => now,
74
+ };
75
+ }
76
+
77
+ describe("sync timer", () => {
78
+ test("kickoff runs shortly after start and reports ok", async () => {
79
+ const h = harness();
80
+ expect(h.scheduled).toHaveLength(1);
81
+ await h.fire();
82
+ expect(h.runs).toHaveLength(1);
83
+ expect(h.status.read().lastOutcome).toBe("ok");
84
+ expect(h.status.read().role).toBe("dialer");
85
+ // The next steady tick is booked.
86
+ expect(h.scheduled).toHaveLength(1);
87
+ });
88
+
89
+ test("a failure backs off and reports unreachable quietly", async () => {
90
+ const h = harness();
91
+ h.setOutcome(async () => {
92
+ throw new Error("fetch failed");
93
+ });
94
+ await h.fire(); // kickoff → fails
95
+ expect(h.status.read().lastOutcome).toBe("unreachable");
96
+ expect(h.status.read().consecutiveFailures).toBe(1);
97
+ await h.fire(); // backoff wait tick → plan says run (clock advanced past step)
98
+ expect(h.status.read().consecutiveFailures).toBe(2);
99
+ });
100
+
101
+ test("a version-skew pause reports pausedOn without counting as a failure", async () => {
102
+ const h = harness();
103
+ h.setOutcome(async () => ({
104
+ remoteId: "peer",
105
+ pushed: 0,
106
+ pulled: 0,
107
+ conflicts: 0,
108
+ held: 0,
109
+ blockedBy: "doc.set",
110
+ }));
111
+ await h.fire();
112
+ const s = h.status.read();
113
+ expect(s.lastOutcome).toBe("paused");
114
+ expect(s.pausedOn).toBe("doc.set");
115
+ expect(s.consecutiveFailures).toBe(0);
116
+ });
117
+
118
+ test("syncNow forces an immediate run", async () => {
119
+ const h = harness();
120
+ await h.fire(); // kickoff
121
+ expect(h.runs).toHaveLength(1);
122
+ h.handle.syncNow();
123
+ await h.fire();
124
+ expect(h.runs).toHaveLength(2);
125
+ });
126
+
127
+ test("an unlinked peer is a quiet no-op re-checked next tick", async () => {
128
+ const h = harness({ resolvePeer: () => null });
129
+ await h.fire();
130
+ expect(h.runs).toHaveLength(0);
131
+ expect(h.scheduled).toHaveLength(1); // re-check booked
132
+ });
133
+
134
+ test("markDirty schedules a prompt run (debounced by the planner)", async () => {
135
+ let head = 0;
136
+ const h = harness({ head: () => head });
137
+ await h.fire(); // kickoff
138
+ head = 3;
139
+ h.handle.markDirty();
140
+ await h.fire(); // the dirty tick — planner may debounce once
141
+ if (h.runs.length === 1) await h.fire();
142
+ expect(h.runs.length).toBeGreaterThanOrEqual(2);
143
+ });
144
+
145
+ test("stop() cancels everything", async () => {
146
+ const h = harness();
147
+ h.handle.stop();
148
+ expect(h.scheduled).toHaveLength(0);
149
+ });
150
+ });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The in-process dialer loop — vault's `syncTimer` generalized. Runs on the dialing
3
+ * half only; a failure NEVER takes the host daemon down (an unsynced instance still
4
+ * serves and still accepts writes — that is the point of two independently-writable
5
+ * instances). The timer is `unref`'d so it never holds the process open.
6
+ *
7
+ * The peer is re-resolved on EVERY tick, so linking (or revoking) a peer takes
8
+ * effect without a restart. The "Sync now" fast lane polls the receiver's request
9
+ * stamp every ~20s while healthy, so a button press on the far side means seconds,
10
+ * not "sometime in the next interval".
11
+ */
12
+ import {
13
+ isUnreachable,
14
+ DEFAULT_LOOP,
15
+ planNextSync,
16
+ type SyncLoopConfig,
17
+ type SyncLoopState,
18
+ } from "./planner";
19
+ import type { SyncStatusReporter } from "./status";
20
+ import type { SyncSummary } from "./types";
21
+
22
+ export const REQUEST_POLL_MS = 20_000;
23
+ const KICKOFF_MS = 5_000;
24
+
25
+ export interface SyncTimerDeps {
26
+ /** Re-read every tick: null = not linked (quiet no-op, re-checked next tick). */
27
+ resolvePeer: () => { basePath: string; token: string } | null;
28
+ /** Run one full reconciliation against the resolved peer. */
29
+ sync: (peer: { basePath: string; token: string }) => Promise<SyncSummary>;
30
+ /** The local op-log head — a head beyond the last synced one marks dirty. */
31
+ head: () => number;
32
+ /** Ask the peer whether its "Sync now" was pressed; null on any failure. */
33
+ checkRequest?: (peer: { basePath: string; token: string }) => Promise<number | null>;
34
+ status?: SyncStatusReporter;
35
+ loop?: SyncLoopConfig;
36
+ requestPollMs?: number;
37
+ now?: () => number;
38
+ setTimer?: (fn: () => void, ms: number) => unknown;
39
+ clearTimer?: (handle: unknown) => void;
40
+ log?: (msg: string) => void;
41
+ warn?: (msg: string) => void;
42
+ /** Display origin for the status line; defaults to URL origin parsing. */
43
+ peerLabel?: (basePath: string) => string | null;
44
+ }
45
+
46
+ export interface SyncTimerHandle {
47
+ stop(): void;
48
+ /** Run the next tick immediately (the "Sync now" button on the dialing half). */
49
+ syncNow(): void;
50
+ /** Mark local writes pending (call after any local op) — debounces a burst. */
51
+ markDirty(): void;
52
+ }
53
+
54
+ function defaultPeerLabel(basePath: string): string | null {
55
+ try {
56
+ return new URL(basePath).origin;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ export function startSyncTimer(deps: SyncTimerDeps): SyncTimerHandle {
63
+ const now = deps.now ?? Date.now;
64
+ const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
65
+ const clearTimer = deps.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
66
+ const loop = deps.loop ?? DEFAULT_LOOP;
67
+ const log = deps.log ?? ((m: string) => console.log(m));
68
+ const warn = deps.warn ?? ((m: string) => console.error(m));
69
+ const status = deps.status;
70
+ const peerLabel = deps.peerLabel ?? defaultPeerLabel;
71
+ const requestPollMs = deps.requestPollMs ?? REQUEST_POLL_MS;
72
+ status?.update({ enabled: true, role: "dialer" });
73
+
74
+ const state: SyncLoopState = { lastAttempt: 0, consecutiveFailures: 0, dirtySince: null };
75
+ let syncedHead = deps.head();
76
+ let stopped = false;
77
+ let handle: unknown = null;
78
+ let reportedUnreachable = false;
79
+ let forceRun = false;
80
+ let handledRequestAt = 0;
81
+ let probing = false;
82
+ let inFlight = false;
83
+
84
+ const schedule = (ms: number): void => {
85
+ if (stopped) return;
86
+ if (handle !== null) clearTimer(handle);
87
+ handle = setTimer(tick, ms);
88
+ (handle as { unref?: () => void })?.unref?.();
89
+ };
90
+
91
+ const sleepFor = (waitMs: number): number => {
92
+ const healthy = state.consecutiveFailures === 0 && requestPollMs > 0;
93
+ return Math.max(1, healthy ? Math.min(waitMs, requestPollMs) : waitMs);
94
+ };
95
+
96
+ const probeRequest = (peer: { basePath: string; token: string }): void => {
97
+ if (probing || requestPollMs === 0 || !deps.checkRequest) return;
98
+ probing = true;
99
+ void deps
100
+ .checkRequest(peer)
101
+ .then((requestedAt) => {
102
+ if (stopped || requestedAt === null || requestedAt <= handledRequestAt) return;
103
+ handledRequestAt = requestedAt;
104
+ log("[sync] sync requested from the peer — running now.");
105
+ forceRun = true;
106
+ schedule(1);
107
+ })
108
+ .finally(() => {
109
+ probing = false;
110
+ });
111
+ };
112
+
113
+ const tick = (): void => {
114
+ if (stopped) return;
115
+ if (inFlight) return; // two concurrent runs share cursors and would double-push
116
+ const head = deps.head();
117
+ if (head > syncedHead && state.dirtySince === null) state.dirtySince = now();
118
+
119
+ const peer = deps.resolvePeer();
120
+ status?.update({
121
+ peer: peer === null ? null : peerLabel(peer.basePath),
122
+ pending: Math.max(0, head - syncedHead),
123
+ });
124
+ if (peer === null) {
125
+ schedule(loop.intervalMs);
126
+ return;
127
+ }
128
+
129
+ const plan = planNextSync(state, now(), loop);
130
+ if (!plan.runNow && !forceRun) {
131
+ const linkedAndHealthy = state.consecutiveFailures === 0 && requestPollMs > 0;
132
+ schedule(sleepFor(plan.waitMs));
133
+ if (linkedAndHealthy) probeRequest(peer);
134
+ return;
135
+ }
136
+
137
+ forceRun = false;
138
+ state.lastAttempt = now();
139
+ status?.update({ lastAttemptAt: state.lastAttempt });
140
+ const headAtAttempt = head;
141
+ inFlight = true;
142
+ deps
143
+ .sync(peer)
144
+ .then((s) => {
145
+ state.consecutiveFailures = 0;
146
+ state.dirtySince = null;
147
+ syncedHead = headAtAttempt;
148
+ reportedUnreachable = false;
149
+ status?.update({
150
+ lastSuccessAt: now(),
151
+ lastOutcome: s.blockedBy ? "paused" : "ok",
152
+ pausedOn: s.blockedBy ?? null,
153
+ consecutiveFailures: 0,
154
+ pending: Math.max(0, deps.head() - syncedHead),
155
+ });
156
+ if (s.pushed > 0 || s.pulled > 0 || s.conflicts > 0) {
157
+ log(
158
+ `[sync] ↑${s.pushed} ↓${s.pulled}` +
159
+ (s.conflicts > 0 ? ` (${s.conflicts} conflicts)` : ""),
160
+ );
161
+ }
162
+ if (s.blockedBy) {
163
+ log(
164
+ `[sync] push paused before "${s.blockedBy}" — the peer cannot take it yet (deploy pending?).`,
165
+ );
166
+ }
167
+ })
168
+ .catch((err) => {
169
+ state.consecutiveFailures += 1;
170
+ status?.update({
171
+ lastOutcome: isUnreachable(err) ? "unreachable" : "error",
172
+ consecutiveFailures: state.consecutiveFailures,
173
+ });
174
+ if (isUnreachable(err)) {
175
+ if (!reportedUnreachable) {
176
+ log(`[sync] peer unreachable — will retry. This is normal (nightly window / offline).`);
177
+ reportedUnreachable = true;
178
+ }
179
+ } else {
180
+ warn(`[sync] failed (ignored): ${err instanceof Error ? err.message : err}`);
181
+ }
182
+ })
183
+ .finally(() => {
184
+ inFlight = false;
185
+ if (forceRun) schedule(1);
186
+ else schedule(sleepFor(Math.max(1_000, planNextSync(state, now(), loop).waitMs)));
187
+ });
188
+ };
189
+
190
+ schedule(KICKOFF_MS);
191
+ return {
192
+ stop: () => {
193
+ stopped = true;
194
+ if (handle !== null) clearTimer(handle);
195
+ },
196
+ syncNow: () => {
197
+ if (stopped) return;
198
+ forceRun = true;
199
+ schedule(1);
200
+ },
201
+ markDirty: () => {
202
+ if (stopped) return;
203
+ if (state.dirtySince === null) state.dirtySince = now();
204
+ schedule(1);
205
+ },
206
+ };
207
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { createTokenStore } from "./tokens";
4
+
5
+ describe("device tokens", () => {
6
+ test("mint → verify (scope-checked) → revoke", () => {
7
+ const tokens = createTokenStore(new Database(":memory:"));
8
+ const plaintext = tokens.mint("mac", "sync");
9
+ expect(plaintext.length).toBeGreaterThan(30);
10
+ expect(tokens.verify(plaintext, "sync")?.name).toBe("mac");
11
+ expect(tokens.verify(plaintext, "other-scope")).toBeNull();
12
+ expect(tokens.verify("nonsense", "sync")).toBeNull();
13
+ expect(tokens.verify("", "sync")).toBeNull();
14
+ expect(tokens.revoke("mac")).toBe(true);
15
+ expect(tokens.verify(plaintext, "sync")).toBeNull();
16
+ expect(tokens.revoke("mac")).toBe(false); // already revoked
17
+ });
18
+
19
+ test("re-minting a name rotates: the old plaintext dies with the new mint", () => {
20
+ const tokens = createTokenStore(new Database(":memory:"));
21
+ const first = tokens.mint("mac", "sync");
22
+ const second = tokens.mint("mac", "sync");
23
+ expect(tokens.verify(first, "sync")).toBeNull();
24
+ expect(tokens.verify(second, "sync")?.name).toBe("mac");
25
+ expect(tokens.list().filter((t) => t.name === "mac")).toHaveLength(1);
26
+ });
27
+
28
+ test("only sha256 digests are at rest — the table never holds the plaintext", () => {
29
+ const db = new Database(":memory:");
30
+ const tokens = createTokenStore(db);
31
+ const plaintext = tokens.mint("mac", "sync");
32
+ const rows = db.prepare("SELECT token_sha256 FROM sync_tokens").all() as {
33
+ token_sha256: Uint8Array;
34
+ }[];
35
+ const stored = Buffer.from(rows[0]?.token_sha256 ?? new Uint8Array()).toString("utf8");
36
+ expect(stored).not.toContain(plaintext);
37
+ });
38
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Scoped device tokens — notes' mint/verify/revoke generalized (vault runs a single
3
+ * static env secret; the token TABLE is the better half: revocable per device, scoped
4
+ * per capability, sha256 at rest so a database read never yields a usable credential).
5
+ *
6
+ * The plaintext token is returned exactly once, at mint. Verification compares sha256
7
+ * digests via `timingSafeEqual` — length-independent, no oracle.
8
+ */
9
+ import type { Database } from "bun:sqlite";
10
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
11
+
12
+ export interface DeviceToken {
13
+ name: string;
14
+ scope: string;
15
+ createdAt: number;
16
+ revokedAt: number | null;
17
+ }
18
+
19
+ export interface TokenStore {
20
+ /** Mint a token for `name`+`scope`; returns the plaintext ONCE. Re-minting a name
21
+ * revokes the previous token of that name (one live credential per device name). */
22
+ mint(name: string, scope: string): string;
23
+ /** Null when unknown, revoked, or out of scope. */
24
+ verify(presented: string, scope: string): DeviceToken | null;
25
+ revoke(name: string): boolean;
26
+ list(): DeviceToken[];
27
+ }
28
+
29
+ const sha256 = (value: string): Buffer => createHash("sha256").update(value).digest();
30
+
31
+ export function createTokenStore(db: Database): TokenStore {
32
+ db.exec(`
33
+ CREATE TABLE IF NOT EXISTS sync_tokens (
34
+ name TEXT PRIMARY KEY,
35
+ scope TEXT NOT NULL,
36
+ token_sha256 BLOB NOT NULL,
37
+ created_at INTEGER NOT NULL,
38
+ revoked_at INTEGER
39
+ );
40
+ `);
41
+ const upsert = db.prepare(
42
+ "INSERT INTO sync_tokens (name, scope, token_sha256, created_at, revoked_at) VALUES (?, ?, ?, ?, NULL) ON CONFLICT(name) DO UPDATE SET scope = excluded.scope, token_sha256 = excluded.token_sha256, created_at = excluded.created_at, revoked_at = NULL",
43
+ );
44
+ const allLive = db.prepare(
45
+ "SELECT name, scope, token_sha256, created_at, revoked_at FROM sync_tokens WHERE revoked_at IS NULL",
46
+ );
47
+ const all = db.prepare(
48
+ "SELECT name, scope, token_sha256, created_at, revoked_at FROM sync_tokens ORDER BY created_at DESC",
49
+ );
50
+ const revokeStmt = db.prepare(
51
+ "UPDATE sync_tokens SET revoked_at = ? WHERE name = ? AND revoked_at IS NULL",
52
+ );
53
+
54
+ interface Row {
55
+ name: string;
56
+ scope: string;
57
+ token_sha256: Uint8Array;
58
+ created_at: number;
59
+ revoked_at: number | null;
60
+ }
61
+ const toToken = (r: Row): DeviceToken => ({
62
+ name: r.name,
63
+ scope: r.scope,
64
+ createdAt: r.created_at,
65
+ revokedAt: r.revoked_at,
66
+ });
67
+
68
+ return {
69
+ mint(name, scope) {
70
+ const token = randomBytes(32).toString("base64url");
71
+ upsert.run(name, scope, sha256(token), Date.now());
72
+ return token;
73
+ },
74
+ verify(presented, scope) {
75
+ if (!presented) return null;
76
+ const got = sha256(presented);
77
+ // Scan live rows and compare digests constant-time each. The fleet holds a
78
+ // handful of devices; a table scan is simpler than an index that would make
79
+ // the lookup timing depend on the presented value.
80
+ for (const raw of allLive.all() as Row[]) {
81
+ const want = Buffer.from(raw.token_sha256);
82
+ if (want.length === got.length && timingSafeEqual(want, got)) {
83
+ if (raw.scope !== scope) return null;
84
+ return toToken(raw);
85
+ }
86
+ }
87
+ return null;
88
+ },
89
+ revoke(name) {
90
+ return revokeStmt.run(Date.now(), name).changes > 0;
91
+ },
92
+ list: () => (all.all() as Row[]).map(toToken),
93
+ };
94
+ }