@saadnvd1/agent-os 0.3.697 → 0.3.700

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 (88) hide show
  1. package/app/api/bus/send/route.ts +11 -1
  2. package/app/api/bus/wait/route.ts +22 -0
  3. package/app/api/peer/hello/reveal/route.ts +6 -0
  4. package/app/api/peer/hello/route.ts +9 -0
  5. package/app/api/peer/hello/status/route.ts +6 -0
  6. package/app/api/peer/ping/route.ts +10 -0
  7. package/app/api/peers/account/route.ts +9 -0
  8. package/app/api/peers/gate.test.ts +111 -0
  9. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  10. package/app/api/peers/link/[id]/route.ts +20 -0
  11. package/app/api/peers/link/route.ts +23 -0
  12. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  13. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  14. package/app/api/peers/pending/route.ts +7 -0
  15. package/app/api/sessions/route.ts +18 -1
  16. package/app/page.tsx +2 -0
  17. package/components/Hosts/AccountMachines.tsx +180 -0
  18. package/components/Hosts/HostRow.tsx +44 -35
  19. package/components/Hosts/HostsDialog.tsx +49 -37
  20. package/components/Hosts/IncomingLinkSheet.tsx +228 -0
  21. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  22. package/components/Hosts/sheet.ts +3 -0
  23. package/components/SessionList/RowSubtitle.tsx +11 -1
  24. package/components/Welcome/ConnectBlock.tsx +1 -1
  25. package/data/peers/index.ts +2 -0
  26. package/data/peers/keys.ts +6 -0
  27. package/data/peers/queries.ts +141 -0
  28. package/lib/__fixtures__/connect-transport.ts +93 -0
  29. package/lib/__fixtures__/peer-machine.ts +45 -0
  30. package/lib/__fixtures__/peer-rpc.ts +224 -0
  31. package/lib/agents/brief.ts +3 -1
  32. package/lib/bus/format.ts +25 -4
  33. package/lib/bus/index.ts +113 -106
  34. package/lib/bus/resolve.ts +16 -2
  35. package/lib/bus/targets.ts +122 -0
  36. package/lib/bus/waits/kick.ts +12 -0
  37. package/lib/bus/waits/lines.ts +69 -0
  38. package/lib/bus/waits/register.ts +83 -0
  39. package/lib/bus/waits/store.ts +196 -0
  40. package/lib/bus/waits/tick.ts +192 -0
  41. package/lib/connect/control-api.ts +108 -2
  42. package/lib/connect/heartbeat.ts +11 -3
  43. package/lib/connect/serve.ts +2 -0
  44. package/lib/db/migrations/20261020T1258_peer_links.ts +49 -0
  45. package/lib/db/migrations/20261020T1345_peer_nonces.ts +18 -0
  46. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  47. package/lib/db/types.ts +6 -0
  48. package/lib/done/archive.ts +2 -0
  49. package/lib/hosts/index.ts +28 -7
  50. package/lib/hosts/peer-socket.ts +3 -1
  51. package/lib/hosts/read-capped-bytes.ts +23 -0
  52. package/lib/hosts/remote-api.ts +112 -41
  53. package/lib/orchestrator/describe.ts +13 -0
  54. package/lib/orchestrator/facts.ts +17 -0
  55. package/lib/peer/account.ts +198 -0
  56. package/lib/peer/allowlist.ts +136 -0
  57. package/lib/peer/audit.ts +144 -0
  58. package/lib/peer/cli.ts +24 -0
  59. package/lib/peer/client.ts +188 -0
  60. package/lib/peer/code.ts +3 -0
  61. package/lib/peer/envelope.ts +424 -0
  62. package/lib/peer/hello-route.ts +32 -0
  63. package/lib/peer/hello.ts +346 -0
  64. package/lib/peer/internal.ts +22 -0
  65. package/lib/peer/keys.ts +122 -0
  66. package/lib/peer/link-cli.ts +233 -0
  67. package/lib/peer/links.ts +96 -0
  68. package/lib/peer/machines.ts +72 -0
  69. package/lib/peer/nonce-store.ts +23 -0
  70. package/lib/peer/notify.ts +32 -0
  71. package/lib/peer/pairing-out.ts +382 -0
  72. package/lib/peer/pairing.ts +403 -0
  73. package/lib/peer/pin.ts +55 -0
  74. package/lib/peer/replay.ts +81 -0
  75. package/lib/peer/rpc-intake.ts +258 -0
  76. package/lib/peer/rpc-server.ts +369 -0
  77. package/lib/schedules/when.ts +1 -1
  78. package/lib/security/auth.ts +51 -6
  79. package/lib/security/gate.ts +38 -12
  80. package/lib/security/route-guard.ts +3 -1
  81. package/lib/session-artifacts/peer.ts +12 -1
  82. package/lib/sessions/end.ts +3 -0
  83. package/lib/tasks/contract/authorize.ts +4 -0
  84. package/lib/worktree-sweep/testTemp.ts +2 -0
  85. package/package.json +1 -1
  86. package/scripts/agent-os +2 -0
  87. package/scripts/lib/commands.sh +2 -0
  88. package/server.ts +21 -0
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Pairing, on the machine being asked (B). A hello is taken only from a key
3
+ * the Connect account lists for its sender, and only while the plan
4
+ * includes linking; it then waits, in memory, for the sender to reveal the
5
+ * nonce it committed to, and then for a person here to type the code the
6
+ * sender shows. Nothing is trusted until that code matches: B pins A then
7
+ * and shows a second code, which the person types on A before A pins B.
8
+ * A machine linked here already keeps its keys: one that comes back with
9
+ * other keys is refused until a person unlinks it.
10
+ *
11
+ * Limits: hellos 5 per sender and 20 in all per 10 minutes, at most 3
12
+ * requests open and one per sender (a newer one replaces it), each for 5
13
+ * minutes, and 3 wrong codes void a request.
14
+ * A server restart forgets every open request (the sender sees "expired").
15
+ */
16
+
17
+ import { currentIdentity, loadBoxKey, type Identity } from "./keys";
18
+ import { linkingAllowed, listedPeer, type AccountPeer } from "./account";
19
+ import {
20
+ commitTo,
21
+ deriveCode,
22
+ parseHello,
23
+ parseReveal,
24
+ parseStatusAsk,
25
+ randomNonce,
26
+ sameCode,
27
+ signAck,
28
+ signAnswer,
29
+ typedCode,
30
+ verifyHello,
31
+ verifyReveal,
32
+ verifyStatusAsk,
33
+ withinSkew,
34
+ SKEW_MS,
35
+ type LinkState,
36
+ } from "./hello";
37
+ import { KEY_CHANGED, keysChanged, pinPeer } from "./pin";
38
+ import { notifyLinked, notifyLinkRequest } from "./notify";
39
+
40
+ export const REQUEST_TTL_MS = 5 * 60_000;
41
+ export const MAX_OPEN = 3;
42
+ export const MAX_WRONG = 3;
43
+ const WINDOW_MS = 10 * 60_000;
44
+ const PER_SENDER = 5;
45
+ const GLOBAL = 20;
46
+ // Hellos that get as far as the account list, from anyone: bounds the
47
+ // signature checks and list fetches a stranger can cause. One address (an
48
+ // IPv6 /64) is held to a share of it first, so it can't use it all up.
49
+ const UNPROVEN = 120;
50
+ const PER_SOURCE = 20;
51
+
52
+ interface Incoming {
53
+ id: string;
54
+ peer: AccountPeer;
55
+ aSign: string;
56
+ aBox: string;
57
+ commit: string;
58
+ bNonce: string;
59
+ code: string | null;
60
+ confirm: string | null;
61
+ state: "revealing" | LinkState;
62
+ why: string | null;
63
+ wrong: number;
64
+ createdAt: number;
65
+ }
66
+
67
+ export interface PairingDeps {
68
+ identity: () => Identity | null;
69
+ boxPub: () => string;
70
+ listed: (machineId: string) => Promise<AccountPeer | null>;
71
+ allowed: () => Promise<boolean | null>;
72
+ pin: (p: AccountPeer & { boxPub: string }) => string;
73
+ onRequest: (name: string) => void;
74
+ onLinked: (name: string) => void;
75
+ now: () => number;
76
+ }
77
+
78
+ interface Store {
79
+ requests: Map<string, Incoming>;
80
+ hellos: { at: number; from: string | null; source: string }[];
81
+ seen: Map<string, number>;
82
+ }
83
+
84
+ const g = globalThis as unknown as { __agentosPairingIn?: Store };
85
+ const store = (): Store =>
86
+ (g.__agentosPairingIn ??= {
87
+ requests: new Map(),
88
+ hellos: [],
89
+ seen: new Map(),
90
+ });
91
+
92
+ /** Tests: forget every request and every limit. */
93
+ export const resetIncoming = () => {
94
+ g.__agentosPairingIn = undefined;
95
+ };
96
+
97
+ let box: string | null = null;
98
+ export const realPairingDeps = (): PairingDeps => ({
99
+ identity: () => currentIdentity(),
100
+ boxPub: () => (box ??= loadBoxKey().publicRaw),
101
+ listed: listedPeer,
102
+ allowed: linkingAllowed,
103
+ pin: pinPeer,
104
+ onRequest: notifyLinkRequest,
105
+ onLinked: notifyLinked,
106
+ now: Date.now,
107
+ });
108
+
109
+ export interface Reply {
110
+ status: number;
111
+ body: Record<string, unknown>;
112
+ }
113
+
114
+ const no = (status: number, error: string): Reply => ({
115
+ status,
116
+ body: { error },
117
+ });
118
+
119
+ function sweep(s: Store, now: number) {
120
+ for (const r of s.requests.values()) {
121
+ if (r.state !== "accepted" && now - r.createdAt > REQUEST_TTL_MS) {
122
+ if (r.state === "revealing" || r.state === "pending") r.state = "expired";
123
+ }
124
+ // Finished ones are kept a little so the sender can learn how it ended.
125
+ if (now - r.createdAt > 2 * REQUEST_TTL_MS) s.requests.delete(r.id);
126
+ }
127
+ s.hellos = s.hellos.filter((h) => now - h.at < WINDOW_MS);
128
+ for (const [k, until] of s.seen) if (until < now) s.seen.delete(k);
129
+ }
130
+
131
+ const open = (r: Incoming) => r.state === "revealing" || r.state === "pending";
132
+
133
+ /** A nonce, once per sender, for as long as its timestamp would pass. */
134
+ function fresh(s: Store, from: string, nonce: string, ts: number): boolean {
135
+ const k = `${from}:${nonce}`;
136
+ if (s.seen.has(k)) return false;
137
+ s.seen.set(k, ts + SKEW_MS);
138
+ return true;
139
+ }
140
+
141
+ /** POST /api/peer/hello */
142
+ export async function receiveHello(
143
+ body: unknown,
144
+ deps: PairingDeps = realPairingDeps(),
145
+ source = "unknown"
146
+ ): Promise<Reply> {
147
+ const s = store();
148
+ const now = deps.now();
149
+ sweep(s, now);
150
+ const hello = parseHello(body);
151
+ if (!hello) return no(400, "bad hello");
152
+ const self = deps.identity();
153
+ if (!self || hello.to !== self.machineId)
154
+ return no(403, "this isn't the machine you meant");
155
+ if (!withinSkew(hello.ts, now))
156
+ return no(401, "stale: the clocks on the two machines differ");
157
+ if (
158
+ s.hellos.filter((h) => h.source === source).length >= PER_SOURCE ||
159
+ s.hellos.length >= UNPROVEN
160
+ )
161
+ return no(429, "too many requests");
162
+ s.hellos.push({ at: now, from: null, source });
163
+ const allowed = await deps.allowed();
164
+ if (allowed !== true)
165
+ return no(402, "Linking machines needs AgentOS Connect Pro");
166
+ const listed = await deps.listed(hello.from);
167
+ // Only a key this machine's own account lists for that machine may ask.
168
+ if (!listed || listed.signPub !== hello.sign_pub)
169
+ return no(403, "that machine isn't on this account");
170
+ if (!verifyHello(hello)) return no(401, "bad signature");
171
+ if (!fresh(s, hello.from, hello.nonce, hello.ts)) return no(401, "replay");
172
+ if (
173
+ keysChanged(hello.from, { signPub: hello.sign_pub, boxPub: hello.box_pub })
174
+ )
175
+ return no(409, KEY_CHANGED);
176
+ const proven = s.hellos.filter((h) => h.from !== null);
177
+ if (
178
+ proven.length >= GLOBAL ||
179
+ proven.filter((h) => h.from === hello.from).length >= PER_SENDER
180
+ )
181
+ return no(429, "too many link requests; wait a few minutes");
182
+ s.hellos[s.hellos.length - 1].from = hello.from;
183
+ for (const old of s.requests.values())
184
+ if (old.peer.id === hello.from && open(old)) {
185
+ old.state = "declined";
186
+ old.why = "replaced by a newer request";
187
+ }
188
+ if ([...s.requests.values()].filter(open).length >= MAX_OPEN)
189
+ return no(429, "too many link requests waiting here");
190
+
191
+ const r: Incoming = {
192
+ id: randomNonce(16),
193
+ peer: listed,
194
+ aSign: hello.sign_pub,
195
+ aBox: hello.box_pub,
196
+ commit: hello.commit,
197
+ bNonce: randomNonce(32),
198
+ code: null,
199
+ confirm: null,
200
+ state: "revealing",
201
+ why: null,
202
+ wrong: 0,
203
+ createdAt: now,
204
+ };
205
+ s.requests.set(r.id, r);
206
+ const ack = signAck(
207
+ self.signKey,
208
+ {
209
+ request_id: r.id,
210
+ sign_pub: self.signPub,
211
+ box_pub: deps.boxPub(),
212
+ b_nonce: r.bNonce,
213
+ },
214
+ hello
215
+ );
216
+ return { status: 200, body: { ...ack } };
217
+ }
218
+
219
+ /** Finds the request a signed reveal or status call is about. */
220
+ function askedAbout(
221
+ s: Store,
222
+ ask: {
223
+ from: string;
224
+ to: string;
225
+ ts: number;
226
+ nonce: string;
227
+ request_id: string;
228
+ },
229
+ self: Identity | null,
230
+ now: number
231
+ ): Incoming | Reply {
232
+ if (!self || ask.to !== self.machineId)
233
+ return no(403, "this isn't the machine you meant");
234
+ if (!withinSkew(ask.ts, now)) return no(401, "stale");
235
+ const r = s.requests.get(ask.request_id);
236
+ if (!r || r.peer.id !== ask.from) return no(404, "no such request");
237
+ return r;
238
+ }
239
+
240
+ /** POST /api/peer/hello/reveal */
241
+ export function receiveReveal(
242
+ body: unknown,
243
+ deps: PairingDeps = realPairingDeps()
244
+ ): Reply {
245
+ const s = store();
246
+ const now = deps.now();
247
+ sweep(s, now);
248
+ const reveal = parseReveal(body);
249
+ if (!reveal) return no(400, "bad reveal");
250
+ const self = deps.identity();
251
+ const r = askedAbout(s, reveal, self, now);
252
+ if (!("id" in r)) return r;
253
+ if (!verifyReveal(reveal, r.aSign)) return no(401, "bad signature");
254
+ if (!fresh(s, reveal.from, reveal.nonce, reveal.ts)) return no(401, "replay");
255
+ if (r.state !== "revealing")
256
+ return {
257
+ status: 200,
258
+ body: { ...signAnswer(self!.signKey, r.state, reveal) },
259
+ };
260
+ if (commitTo(r.aBox, r.aSign, reveal.a_nonce) !== r.commit) {
261
+ // Whoever sent this chose its keys after seeing ours: void it.
262
+ r.state = "declined";
263
+ r.why = "its keys didn't match what it committed to";
264
+ return no(403, "that doesn't match the hello");
265
+ }
266
+ const transcript = {
267
+ from: r.peer.id,
268
+ to: self!.machineId,
269
+ aSign: r.aSign,
270
+ aBox: r.aBox,
271
+ bSign: self!.signPub,
272
+ bBox: deps.boxPub(),
273
+ aNonce: reveal.a_nonce,
274
+ bNonce: r.bNonce,
275
+ };
276
+ r.code = deriveCode(transcript);
277
+ r.confirm = deriveCode(transcript, "confirm");
278
+ r.state = "pending";
279
+ deps.onRequest(r.peer.name);
280
+ return {
281
+ status: 200,
282
+ body: { ...signAnswer(self!.signKey, "pending", reveal) },
283
+ };
284
+ }
285
+
286
+ /** POST /api/peer/hello/status */
287
+ export function receiveStatus(
288
+ body: unknown,
289
+ deps: PairingDeps = realPairingDeps()
290
+ ): Reply {
291
+ const s = store();
292
+ const now = deps.now();
293
+ sweep(s, now);
294
+ const ask = parseStatusAsk(body);
295
+ if (!ask) return no(400, "bad status request");
296
+ const self = deps.identity();
297
+ const r = askedAbout(s, ask, self, now);
298
+ if (!("id" in r)) return r;
299
+ if (!verifyStatusAsk(ask, r.aSign)) return no(401, "bad signature");
300
+ if (!fresh(s, ask.from, ask.nonce, ask.ts)) return no(401, "replay");
301
+ const state: LinkState = r.state === "revealing" ? "pending" : r.state;
302
+ return { status: 200, body: { ...signAnswer(self!.signKey, state, ask) } };
303
+ }
304
+
305
+ export interface PendingView {
306
+ id: string;
307
+ machineId: string;
308
+ name: string;
309
+ lastSeenAt: string | null;
310
+ expiresAt: number;
311
+ attemptsLeft: number;
312
+ }
313
+
314
+ /** Requests waiting for a person here to type the code. */
315
+ export function pendingRequests(now = Date.now()): PendingView[] {
316
+ const s = store();
317
+ sweep(s, now);
318
+ return [...s.requests.values()]
319
+ .filter((r) => r.state === "pending")
320
+ .map((r) => ({
321
+ id: r.id,
322
+ machineId: r.peer.id,
323
+ name: r.peer.name,
324
+ lastSeenAt: r.peer.lastSeenAt,
325
+ expiresAt: r.createdAt + REQUEST_TTL_MS,
326
+ attemptsLeft: MAX_WRONG - r.wrong,
327
+ }));
328
+ }
329
+
330
+ export type AcceptResult =
331
+ | { ok: true; hostId: string; name: string; confirmCode: string }
332
+ | { ok: false; status: number; error: string; attemptsLeft?: number };
333
+
334
+ /** The code a person typed here, from a trusted device (the route checks). */
335
+ export async function acceptRequest(
336
+ id: string,
337
+ input: unknown,
338
+ deps: PairingDeps = realPairingDeps()
339
+ ): Promise<AcceptResult> {
340
+ const s = store();
341
+ sweep(s, deps.now());
342
+ const r = s.requests.get(id);
343
+ if (!r || r.state !== "pending" || !r.code || !r.confirm)
344
+ return {
345
+ ok: false,
346
+ status: 404,
347
+ error: "That request has ended. Start again from the other machine.",
348
+ };
349
+ const code = typedCode(input);
350
+ if (!code)
351
+ return {
352
+ ok: false,
353
+ status: 400,
354
+ error: "Type the 6 digits the other machine shows.",
355
+ };
356
+ if (!sameCode(code, r.code)) {
357
+ r.wrong += 1;
358
+ if (r.wrong >= MAX_WRONG) {
359
+ r.state = "declined";
360
+ r.why = "wrong code 3 times";
361
+ return {
362
+ ok: false,
363
+ status: 403,
364
+ error:
365
+ "Wrong code 3 times: the request is void. Start again from the other machine.",
366
+ attemptsLeft: 0,
367
+ };
368
+ }
369
+ return {
370
+ ok: false,
371
+ status: 403,
372
+ error: "That code doesn't match.",
373
+ attemptsLeft: MAX_WRONG - r.wrong,
374
+ };
375
+ }
376
+ if ((await deps.allowed()) !== true)
377
+ return {
378
+ ok: false,
379
+ status: 402,
380
+ error: "Linking machines needs AgentOS Connect Pro.",
381
+ };
382
+ let hostId: string;
383
+ try {
384
+ hostId = deps.pin({ ...r.peer, signPub: r.aSign, boxPub: r.aBox });
385
+ } catch (err) {
386
+ r.state = "declined";
387
+ r.why = "it couldn't be linked here";
388
+ const error = err instanceof Error ? err.message : String(err);
389
+ return { ok: false, status: 409, error };
390
+ }
391
+ r.state = "accepted";
392
+ deps.onLinked(r.peer.name);
393
+ // The person types this on the other machine, which pins this one then.
394
+ return { ok: true, hostId, name: r.peer.name, confirmCode: r.confirm };
395
+ }
396
+
397
+ export function declineRequest(id: string): boolean {
398
+ const r = store().requests.get(id);
399
+ if (!r || !open(r)) return false;
400
+ r.state = "declined";
401
+ r.why = "declined here";
402
+ return true;
403
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Pins a machine after pairing: its keys on a Connect host of its own, the
3
+ * one it already has if it paired before. A re-pair never changes the keys
4
+ * of a pin: a machine whose keys changed is unlinked first, by a person.
5
+ */
6
+
7
+ import { createConnectHost, listRemoteHosts } from "../hosts";
8
+ import { pinForMachine, savePin } from "./links";
9
+
10
+ export interface Paired {
11
+ id: string;
12
+ name: string;
13
+ hostname: string;
14
+ signPub: string;
15
+ boxPub: string;
16
+ }
17
+
18
+ /** A name no other machine here has, so `aos move <name>` stays clear. */
19
+ function freeName(name: string): string {
20
+ const taken = new Set(listRemoteHosts().map((h) => h.name.toLowerCase()));
21
+ if (!taken.has(name.toLowerCase()) && name.toLowerCase() !== "local")
22
+ return name;
23
+ for (let n = 2; ; n++)
24
+ if (!taken.has(`${name} (${n})`.toLowerCase())) return `${name} (${n})`;
25
+ }
26
+
27
+ export const KEY_CHANGED =
28
+ "This machine's keys changed since it was linked. Unlink it here first, then link it again.";
29
+
30
+ /** Whether a machine already linked here holds other keys than these. */
31
+ export function keysChanged(
32
+ machineId: string,
33
+ keys: { signPub: string; boxPub?: string }
34
+ ): boolean {
35
+ const pin = pinForMachine(machineId);
36
+ return (
37
+ !!pin &&
38
+ (pin.signPub !== keys.signPub ||
39
+ (keys.boxPub !== undefined && pin.boxPub !== keys.boxPub))
40
+ );
41
+ }
42
+
43
+ export function pinPeer(p: Paired): string {
44
+ const pinned = pinForMachine(p.id);
45
+ if (keysChanged(p.id, p)) throw new Error(KEY_CHANGED);
46
+ const hostId = pinned?.hostId ?? createConnectHost(freeName(p.name)).id;
47
+ savePin({
48
+ hostId,
49
+ machineId: p.id,
50
+ hostname: p.hostname,
51
+ signPub: p.signPub,
52
+ boxPub: p.boxPub,
53
+ });
54
+ return hostId;
55
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Replay protection for peer envelopes: a timestamp within ±120 s, and each
3
+ * peer's nonces remembered for as long as their timestamp would still pass.
4
+ * A nonce is only ever forgotten once it's outside that window; a peer that
5
+ * fills its cache inside the window is told it's busy rather than letting an
6
+ * older nonce be replayed.
7
+ *
8
+ * The server's guard also writes each nonce to a NonceStore (SQLite,
9
+ * nonce-store.ts) before the call runs, so a restart doesn't forget the
10
+ * nonces still inside the window. Memory is only a cache in front of it; if
11
+ * the store can't be written the call is refused as busy.
12
+ */
13
+
14
+ import { SKEW_MS } from "./envelope";
15
+
16
+ export type Freshness = "ok" | "stale" | "replay" | "busy";
17
+
18
+ export interface NonceStore {
19
+ /** Records the nonce until `until`; false if it's already recorded. */
20
+ claim(peer: string, nonce: string, until: number, now: number): boolean;
21
+ /** Forgets nonces whose time is up. */
22
+ prune(now: number): void;
23
+ }
24
+
25
+ export interface ReplayGuard {
26
+ check(peer: string, nonce: string, ts: number, now?: number): Freshness;
27
+ }
28
+
29
+ export function createReplayGuard(
30
+ opts: { windowMs?: number; maxPerPeer?: number; store?: NonceStore } = {}
31
+ ): ReplayGuard {
32
+ const { store } = opts;
33
+ const windowMs = opts.windowMs ?? SKEW_MS;
34
+ const maxPerPeer = opts.maxPerPeer ?? 50_000;
35
+ // nonce → the time after which its timestamp is stale anyway.
36
+ const seen = new Map<string, Map<string, number>>();
37
+ let sweptAt = 0;
38
+
39
+ const sweep = (nonces: Map<string, number>, now: number) => {
40
+ for (const [nonce, until] of nonces) if (until < now) nonces.delete(nonce);
41
+ };
42
+
43
+ return {
44
+ check(peer, nonce, ts, now = Date.now()) {
45
+ if (!Number.isFinite(ts) || Math.abs(now - ts) > windowMs) return "stale";
46
+ if (now - sweptAt > windowMs) {
47
+ sweptAt = now;
48
+ for (const nonces of seen.values()) sweep(nonces, now);
49
+ try {
50
+ store?.prune(now);
51
+ } catch {
52
+ // pruned again next window
53
+ }
54
+ }
55
+ let nonces = seen.get(peer);
56
+ if (!nonces) seen.set(peer, (nonces = new Map()));
57
+ const until = nonces.get(nonce);
58
+ if (until !== undefined && until >= now) return "replay";
59
+ if (nonces.size >= maxPerPeer) {
60
+ sweep(nonces, now);
61
+ if (nonces.size >= maxPerPeer) return "busy";
62
+ }
63
+ // Past ts + window the timestamp check refuses it on its own.
64
+ const expires = ts + windowMs;
65
+ if (store) {
66
+ let fresh: boolean;
67
+ try {
68
+ fresh = store.claim(peer, nonce, expires, now);
69
+ } catch {
70
+ return "busy";
71
+ }
72
+ if (!fresh) {
73
+ nonces.set(nonce, expires);
74
+ return "replay";
75
+ }
76
+ }
77
+ nonces.set(nonce, expires);
78
+ return "ok";
79
+ },
80
+ };
81
+ }