@juspay/neurolink 11.29.2 → 11.30.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 (50) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/auth/anthropicOAuth.d.ts +50 -0
  3. package/dist/auth/anthropicOAuth.js +78 -0
  4. package/dist/browser/neurolink.min.js +393 -393
  5. package/dist/cli/commands/proxy.d.ts +2 -0
  6. package/dist/cli/commands/proxy.js +284 -4
  7. package/dist/cli/commands/proxyExpose.d.ts +35 -0
  8. package/dist/cli/commands/proxyExpose.js +252 -0
  9. package/dist/cli/commands/proxyPeer.d.ts +29 -0
  10. package/dist/cli/commands/proxyPeer.js +738 -0
  11. package/dist/cli/commands/proxyShare.d.ts +37 -0
  12. package/dist/cli/commands/proxyShare.js +1080 -0
  13. package/dist/cli/parser.js +7 -1
  14. package/dist/proxy/peerStore.d.ts +52 -0
  15. package/dist/proxy/peerStore.js +324 -0
  16. package/dist/proxy/peerTransport.d.ts +38 -0
  17. package/dist/proxy/peerTransport.js +242 -0
  18. package/dist/proxy/proxyPaths.d.ts +8 -0
  19. package/dist/proxy/proxyPaths.js +55 -17
  20. package/dist/proxy/requestLogger.js +8 -0
  21. package/dist/proxy/residentGrants.d.ts +57 -0
  22. package/dist/proxy/residentGrants.js +393 -0
  23. package/dist/proxy/shareAudit.d.ts +81 -0
  24. package/dist/proxy/shareAudit.js +280 -0
  25. package/dist/proxy/shareContext.d.ts +38 -0
  26. package/dist/proxy/shareContext.js +92 -0
  27. package/dist/proxy/shareGate.d.ts +64 -0
  28. package/dist/proxy/shareGate.js +216 -0
  29. package/dist/proxy/shareGrants.d.ts +115 -0
  30. package/dist/proxy/shareGrants.js +590 -0
  31. package/dist/proxy/shareLease.d.ts +101 -0
  32. package/dist/proxy/shareLease.js +192 -0
  33. package/dist/proxy/shareLedger.d.ts +105 -0
  34. package/dist/proxy/shareLedger.js +406 -0
  35. package/dist/proxy/shareListener.d.ts +60 -0
  36. package/dist/proxy/shareListener.js +143 -0
  37. package/dist/proxy/shareNotes.d.ts +97 -0
  38. package/dist/proxy/shareNotes.js +234 -0
  39. package/dist/proxy/sharePolicy.d.ts +110 -0
  40. package/dist/proxy/sharePolicy.js +366 -0
  41. package/dist/proxy/shareProvisioning.d.ts +110 -0
  42. package/dist/proxy/shareProvisioning.js +237 -0
  43. package/dist/proxy/shareReceipts.d.ts +99 -0
  44. package/dist/proxy/shareReceipts.js +303 -0
  45. package/dist/proxy/shareSigning.d.ts +40 -0
  46. package/dist/proxy/shareSigning.js +78 -0
  47. package/dist/server/routes/claudeProxyRoutes.js +1066 -3
  48. package/dist/types/cli.d.ts +61 -0
  49. package/dist/types/proxy.d.ts +781 -0
  50. package/package.json +2 -1
@@ -0,0 +1,738 @@
1
+ /**
2
+ * `neurolink proxy peer` — the borrower's controls.
3
+ *
4
+ * A peer is a lender's exposed proxy plus the share token they issued. Peers are
5
+ * only consulted after every local account is spent, so adding one can never
6
+ * make this node spend someone else's capacity while it still has its own.
7
+ *
8
+ * @module cli/commands/proxyPeer
9
+ */
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ import { addPeer, getPeer, listPeers, removePeer, setPeerEnabled, updatePeer, } from "../../proxy/peerStore.js";
12
+ import { resolveProxyPaths, resolveProxyPeersPath, } from "../../proxy/proxyPaths.js";
13
+ const ACTIONS = [
14
+ "add",
15
+ "request",
16
+ "sync",
17
+ "receipts",
18
+ "net",
19
+ "redeem",
20
+ "list",
21
+ "status",
22
+ "test",
23
+ "remove",
24
+ "pause",
25
+ "resume",
26
+ "set",
27
+ ];
28
+ /**
29
+ * Parse a share link into its parts.
30
+ *
31
+ * The token rides in the fragment so it is never sent to whatever host resolves
32
+ * the URL — fragments are not transmitted.
33
+ * Shape: `neurolink://share/<host>[/<path>]#<token>`
34
+ *
35
+ * Everything between `share/` and the fragment is the lender's address,
36
+ * **including any path**. A lender fronted at `example.com/proxy` is an ordinary
37
+ * reverse-proxy layout, and dropping the path silently produced a peer URL
38
+ * nothing answers on.
39
+ */
40
+ export function parseShareLink(link) {
41
+ const hashIndex = link.indexOf("#");
42
+ if (hashIndex < 0) {
43
+ return undefined;
44
+ }
45
+ const fragment = link.slice(hashIndex + 1).trim();
46
+ // `<token>.<receiptSecret>`. Neither half's alphabet contains a ".", and a
47
+ // link minted before receipts existed simply has no second half.
48
+ const separator = fragment.indexOf(".");
49
+ const token = separator < 0 ? fragment : fragment.slice(0, separator);
50
+ const receiptSecret = separator < 0 ? undefined : fragment.slice(separator + 1);
51
+ const withoutFragment = link.slice(0, hashIndex);
52
+ const queryIndex = withoutFragment.indexOf("?");
53
+ const query = queryIndex >= 0 ? withoutFragment.slice(queryIndex + 1) : "";
54
+ const path = queryIndex >= 0 ? withoutFragment.slice(0, queryIndex) : withoutFragment;
55
+ const match = /^neurolink:\/\/share\/(.+)$/.exec(path);
56
+ if (!match || !token) {
57
+ return undefined;
58
+ }
59
+ const host = match[1];
60
+ // https unless the link says otherwise. Guessing https for a loopback or LAN
61
+ // origin would produce a peer URL nothing answers on.
62
+ const scheme = /(^|&)scheme=http(&|$)/.test(query) ? "http" : "https";
63
+ const url = /^https?:\/\//.test(host) ? host : `${scheme}://${host}`;
64
+ return {
65
+ url: url.replace(/\/+$/, ""),
66
+ token,
67
+ ...(receiptSecret ? { receiptSecret } : {}),
68
+ };
69
+ }
70
+ function describePeer(peer, now) {
71
+ const cooling = peer.cooldownUntil && peer.cooldownUntil > now
72
+ ? `cooling ${Math.ceil((peer.cooldownUntil - now) / 1000)}s (${peer.cooldownReason ?? "unknown"})`
73
+ : "ready";
74
+ const lines = [
75
+ `${peer.name}`,
76
+ ` url ${peer.url}`,
77
+ ` priority ${peer.priority}`,
78
+ ` state ${peer.enabled ? cooling : "disabled"}`,
79
+ ];
80
+ if (peer.lastUsedAt) {
81
+ lines.push(` last served ${new Date(peer.lastUsedAt).toISOString()}`);
82
+ }
83
+ if (peer.lastObservation) {
84
+ const observation = peer.lastObservation;
85
+ const parts = [];
86
+ if (observation.grantStatus) {
87
+ parts.push(observation.grantStatus);
88
+ }
89
+ if (observation.remainingCoins !== undefined) {
90
+ parts.push(`${Math.floor(observation.remainingCoins)} coins left`);
91
+ }
92
+ if (parts.length > 0) {
93
+ lines.push(` lender says ${parts.join(", ")}`);
94
+ }
95
+ }
96
+ if (peer.note) {
97
+ lines.push(` note ${peer.note}`);
98
+ }
99
+ return lines.join("\n");
100
+ }
101
+ async function requirePeer(name) {
102
+ if (!name) {
103
+ throw new Error("--name is required for this action.");
104
+ }
105
+ const peer = await getPeer(name);
106
+ if (!peer) {
107
+ throw new Error(`No peer named "${name}".`);
108
+ }
109
+ return peer;
110
+ }
111
+ /**
112
+ * Ask a peer what our grant may still do.
113
+ *
114
+ * `/peer/handshake` and `/peer/limits` touch no account and spend nothing, so
115
+ * this is genuinely free to run. Older lenders predate those routes; for them
116
+ * the fall-back reads the gate's refusal headers off a request shaped so it
117
+ * cannot be served.
118
+ */
119
+ async function testPeer(peer) {
120
+ const controller = new AbortController();
121
+ const timeout = setTimeout(() => controller.abort(), 15_000);
122
+ try {
123
+ const handshake = await fetch(`${peer.url}/peer/handshake`, {
124
+ headers: { "x-neurolink-share-token": peer.token },
125
+ signal: controller.signal,
126
+ });
127
+ if (handshake.ok) {
128
+ const negotiated = (await handshake.json().catch(() => null));
129
+ if (negotiated?.error?.type === "authentication_error") {
130
+ return "rejected — the lender does not recognize this token";
131
+ }
132
+ if (negotiated?.ok === true) {
133
+ const state = negotiated.grant?.state ?? "active";
134
+ if (state !== "active") {
135
+ return `reachable — grant is ${state}`;
136
+ }
137
+ const limits = await fetch(`${peer.url}/peer/limits`, {
138
+ headers: { "x-neurolink-share-token": peer.token },
139
+ signal: controller.signal,
140
+ });
141
+ const view = (await limits.json().catch(() => null));
142
+ if (view?.servable === false) {
143
+ return `reachable — withheld (${view.withheldReason ?? "no capacity"})`;
144
+ }
145
+ return view?.remainingCoins !== undefined
146
+ ? `reachable — grant accepted, ${view.remainingCoins} coins left`
147
+ : "reachable — grant accepted";
148
+ }
149
+ }
150
+ // Pre-handshake lender. A deliberately invalid model exercises the gate
151
+ // without naming a real one, and the refusal headers carry the answer.
152
+ const response = await fetch(`${peer.url}/v1/messages`, {
153
+ method: "POST",
154
+ headers: {
155
+ "content-type": "application/json",
156
+ "x-neurolink-share-token": peer.token,
157
+ },
158
+ body: JSON.stringify({
159
+ model: "neurolink-share-probe",
160
+ max_tokens: 1,
161
+ messages: [{ role: "user", content: "probe" }],
162
+ }),
163
+ signal: controller.signal,
164
+ });
165
+ await response.text().catch(() => "");
166
+ const reason = response.headers.get("x-neurolink-grant-reason");
167
+ const status = response.headers.get("x-neurolink-grant-status");
168
+ if (reason === "missing_token" || reason === "unknown_token") {
169
+ return "rejected — the lender does not recognize this token";
170
+ }
171
+ if (status && status !== "active") {
172
+ return `reachable — grant is ${status}`;
173
+ }
174
+ return "reachable — grant accepted";
175
+ }
176
+ catch (error) {
177
+ return `unreachable — ${error instanceof Error ? error.message : String(error)}`;
178
+ }
179
+ finally {
180
+ clearTimeout(timeout);
181
+ }
182
+ }
183
+ /** How long a peer control-plane call may sit before it is abandoned. */
184
+ const PEER_REQUEST_TIMEOUT_MS = 15_000;
185
+ /**
186
+ * `fetch` with a deadline, for the peer control plane.
187
+ *
188
+ * Every call below reaches a machine on someone else's network, and Node's
189
+ * fetch has no default timeout — a lender that accepts the connection and then
190
+ * says nothing hangs the command forever, with no output and no exit.
191
+ *
192
+ * The body is drained inside the deadline rather than after it: each caller
193
+ * reads the payload immediately, and a timer cleared at the response headers
194
+ * would leave a stalled body just as unbounded as no timer at all. The returned
195
+ * Response is a buffered copy, so `.ok`, `.status` and `.json()` all still work.
196
+ */
197
+ async function peerFetch(url, init = {}) {
198
+ const controller = new AbortController();
199
+ const timeout = setTimeout(() => controller.abort(), PEER_REQUEST_TIMEOUT_MS);
200
+ try {
201
+ const response = await fetch(url, { ...init, signal: controller.signal });
202
+ const body = await response.text();
203
+ // 204/205/304 are null-body statuses; the Response constructor rejects a
204
+ // body on them even when it is empty.
205
+ const nullBody = response.status === 204 ||
206
+ response.status === 205 ||
207
+ response.status === 304;
208
+ return new Response(nullBody ? null : body, {
209
+ status: response.status,
210
+ statusText: response.statusText,
211
+ headers: response.headers,
212
+ });
213
+ }
214
+ finally {
215
+ clearTimeout(timeout);
216
+ }
217
+ }
218
+ async function lodgeProvisionRequest(peer) {
219
+ const { generateProvisionState } = await import("../../proxy/shareProvisioning.js");
220
+ const codeVerifier = randomBytes(32).toString("base64url");
221
+ const codeChallenge = createHash("sha256")
222
+ .update(codeVerifier)
223
+ .digest("base64url");
224
+ const state = generateProvisionState();
225
+ const response = await peerFetch(`${peer.url}/peer/provision`, {
226
+ method: "POST",
227
+ headers: {
228
+ "content-type": "application/json",
229
+ "x-neurolink-share-token": peer.token,
230
+ },
231
+ body: JSON.stringify({ codeChallenge, state }),
232
+ });
233
+ const payload = (await response.json().catch(() => null));
234
+ if (!response.ok || !payload?.ok) {
235
+ throw new Error(`${peer.name} declined the request: ${payload?.error?.message ?? `HTTP ${response.status}`}`);
236
+ }
237
+ await updatePeer(peer.name, {
238
+ pendingProvision: { codeVerifier, state, requestedAt: Date.now() },
239
+ });
240
+ console.info(`Asked ${peer.name} to provision a credential for you.`);
241
+ console.info("");
242
+ console.info(" They run:");
243
+ console.info(" neurolink proxy share provision --peer <your-label>");
244
+ console.info("");
245
+ console.info(" Then collect it with:");
246
+ console.info(` neurolink proxy peer request --name ${peer.name} --claim`);
247
+ console.info("");
248
+ console.info(" Your PKCE verifier stays on this machine — the lender never sees it,");
249
+ console.info(" and the code they relay is worthless to anyone who intercepts it.");
250
+ }
251
+ /**
252
+ * Collect an authorized code and turn it into a resident credential.
253
+ *
254
+ * The token exchange happens here, on this machine, with this machine's
255
+ * verifier. It is the only step in the whole flow that ever holds a token for
256
+ * the lender's account.
257
+ */
258
+ async function claimProvisionedCredential(peer, argv) {
259
+ const pending = peer.pendingProvision;
260
+ if (!pending) {
261
+ throw new Error(`No outstanding provisioning request for ${peer.name}. ` +
262
+ `Run \`neurolink proxy peer request --name ${peer.name}\` first.`);
263
+ }
264
+ const claimResponse = await peerFetch(`${peer.url}/peer/provision`, {
265
+ headers: { "x-neurolink-share-token": peer.token },
266
+ });
267
+ const claimPayload = (await claimResponse.json().catch(() => null));
268
+ if (!claimResponse.ok || !claimPayload?.ok) {
269
+ throw new Error(`${peer.name} declined the claim: ${claimPayload?.error?.message ?? `HTTP ${claimResponse.status}`}`);
270
+ }
271
+ if (claimPayload.status === "pending") {
272
+ console.info(`${peer.name} has not authorized yet. Try again once they have.`);
273
+ return;
274
+ }
275
+ if (claimPayload.status !== "ready" || !claimPayload.claim?.code) {
276
+ // "none" means the request expired or was never lodged; either way the
277
+ // verifier we are holding can no longer be redeemed.
278
+ await updatePeer(peer.name, { pendingProvision: null });
279
+ throw new Error(`${peer.name} has no authorization waiting — the request expired. Ask again.`);
280
+ }
281
+ const claim = claimPayload.claim;
282
+ const lease = claim.lease;
283
+ const code = claim.code;
284
+ if (!code ||
285
+ !claim.accountLabel ||
286
+ !claim.leaseSecret ||
287
+ !lease?.grantId ||
288
+ !claim.state) {
289
+ throw new Error("That claim is missing required fields.");
290
+ }
291
+ if (claim.state !== pending.state) {
292
+ // The state we generated must come back untouched. A mismatch means the
293
+ // code belongs to some other authorization, and exchanging it would bind us
294
+ // to an account nobody agreed to.
295
+ throw new Error("The lender returned a code for a different request. Nothing was installed.");
296
+ }
297
+ const { tokenStore } = await import("../../auth/tokenStore.js");
298
+ const accountLabel = argv.label ?? `${peer.name}-shared`;
299
+ const providerKey = `anthropic:${accountLabel}`;
300
+ // Label collisions are not cosmetic here: Anthropic quota snapshots are keyed
301
+ // by the bare label, so two accounts sharing one would merge each other's
302
+ // windows and route on numbers that describe neither.
303
+ const existing = await tokenStore.listByPrefix("anthropic:");
304
+ if (existing.some((key) => key.toLowerCase() === providerKey.toLowerCase())) {
305
+ throw new Error(`An account labelled "${accountLabel}" already exists here. ` +
306
+ "Re-run with --label <name> to pick another.");
307
+ }
308
+ const { exchangeSubscriptionCode } = await import("../../auth/anthropicOAuth.js");
309
+ const tokens = await exchangeSubscriptionCode({
310
+ code,
311
+ state: claim.state,
312
+ codeVerifier: pending.codeVerifier,
313
+ });
314
+ // Open the grant store before the credential lands, so a failure to reach it
315
+ // costs nothing. Past this point the two writes have to be kept together.
316
+ const { initResidentGrants, saveResidentGrant } = await import("../../proxy/residentGrants.js");
317
+ const { resolveProxyResidentGrantsPath } = await import("../../proxy/proxyPaths.js");
318
+ initResidentGrants(resolveProxyResidentGrantsPath(resolveProxyPaths(argv.dev ?? false)));
319
+ await tokenStore.saveTokens(providerKey, {
320
+ accessToken: tokens.accessToken,
321
+ ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
322
+ // Without a stated expiry, assume it is already due so the first use
323
+ // refreshes rather than sending a token the provider may have retired.
324
+ expiresAt: tokens.expiresAt ?? Date.now(),
325
+ tokenType: "Bearer",
326
+ });
327
+ try {
328
+ await saveResidentGrant({
329
+ schemaVersion: 1,
330
+ accountLabel,
331
+ grantId: lease.grantId,
332
+ lenderName: peer.name,
333
+ lenderUrl: (claim.lenderUrl ?? peer.url).replace(/\/+$/, ""),
334
+ leaseSecret: claim.leaseSecret,
335
+ lease,
336
+ });
337
+ }
338
+ catch (error) {
339
+ // The credential routes from the moment it lands in the token store, but
340
+ // it is the resident grant that carries the lease — the expiry, the
341
+ // heartbeat address, the accounting. A credential stored without one runs
342
+ // forever, renews nothing and shows no sign of having come from a peer,
343
+ // which is exactly the arrangement both sides agreed it would not be.
344
+ //
345
+ // So take it back out. The label collision check above is what makes that
346
+ // safe: this key cannot be an account that already existed here.
347
+ let rolledBack = true;
348
+ await tokenStore.clearTokens(providerKey).catch(() => {
349
+ rolledBack = false;
350
+ });
351
+ const detail = error instanceof Error ? error.message : String(error);
352
+ throw new Error(`Could not record the lease for ${accountLabel}: ${detail}. ` +
353
+ (rolledBack
354
+ ? "The credential was removed rather than left unleased — nothing was installed."
355
+ : `The credential could NOT be removed; delete "${providerKey}" by hand before retrying.`), { cause: error });
356
+ }
357
+ await updatePeer(peer.name, { pendingProvision: null });
358
+ console.info(`Provisioned ${accountLabel} from ${peer.name}.`);
359
+ console.info(" It now routes alongside your own accounts, for as long as the lease holds.");
360
+ console.info(" The lender never held this credential — you exchanged the code yourself.");
361
+ if (!claim.lenderUrl) {
362
+ console.info("");
363
+ console.info(" The claim carries no heartbeat address, so this credential cannot");
364
+ console.info(" renew — it will stop when the lease's offline grace runs out.");
365
+ }
366
+ }
367
+ /**
368
+ * Collect and check a lender's receipts.
369
+ *
370
+ * The point is not to see the charges — it is to check them. Every receipt is
371
+ * verified against the shared secret, recomputed from its own usage block, and
372
+ * the run is checked for holes, because a charge the lender simply never showed
373
+ * us is the one failure a list of charges cannot reveal.
374
+ */
375
+ async function reviewPeerReceipts(peer, json) {
376
+ const response = await peerFetch(`${peer.url}/peer/receipts?since=0`, {
377
+ headers: { "x-neurolink-share-token": peer.token },
378
+ });
379
+ const payload = (await response.json().catch(() => null));
380
+ if (!response.ok || payload?.ok !== true) {
381
+ throw new Error(`${peer.name} would not hand over receipts: ${payload?.error?.message ?? `HTTP ${response.status}`}`);
382
+ }
383
+ const collected = payload.receipts ?? [];
384
+ const { auditShareReceipts } = await import("../../proxy/shareReceipts.js");
385
+ const statement = auditShareReceipts(collected[0]?.grantId ?? peer.name, collected, peer.receiptSecret);
386
+ if (json) {
387
+ console.info(JSON.stringify({ statement, receipts: collected }, null, 2));
388
+ return;
389
+ }
390
+ if (collected.length === 0) {
391
+ console.info(`${peer.name} has charged you nothing yet.`);
392
+ return;
393
+ }
394
+ console.info(`${peer.name}: ${statement.receipts} receipt(s), ${statement.coins.toFixed(1)} coins charged`);
395
+ if (!peer.receiptSecret) {
396
+ console.info(" No receipt secret on file — nothing here has been verified.");
397
+ }
398
+ else if (statement.unverified > 0) {
399
+ console.info(` ⚠ ${statement.unverified} receipt(s) did not verify against this lender's secret`);
400
+ }
401
+ if (statement.miscounted > 0) {
402
+ console.info(` ⚠ ${statement.miscounted} receipt(s) charge more or less than their own usage implies`);
403
+ }
404
+ if (statement.gaps.length > 0) {
405
+ console.info(` ⚠ ${statement.gaps.length} charge(s) were never shown to you (missing sequence numbers)`);
406
+ }
407
+ if (statement.unverified === 0 &&
408
+ statement.miscounted === 0 &&
409
+ statement.gaps.length === 0 &&
410
+ peer.receiptSecret) {
411
+ console.info(" Every charge verified and matches its own usage.");
412
+ }
413
+ await updatePeer(peer.name, {
414
+ lastReceiptSequence: statement.latestSequence,
415
+ });
416
+ }
417
+ /**
418
+ * Settle a round of reciprocal netting with a peer we also lend to.
419
+ *
420
+ * Both sides state cumulative totals rather than a delta, so running this twice
421
+ * forgives nothing the second time instead of paying out again.
422
+ */
423
+ async function netWithPeer(peer, argv) {
424
+ if (!peer.receiptSecret) {
425
+ throw new Error(`No receipt secret on file for ${peer.name}, so a netting claim cannot be signed. ` +
426
+ "Re-add the peer from a fresh share link.");
427
+ }
428
+ // The other half of the pair: the grant *this* node issued to the same person.
429
+ const reciprocal = argv.reciprocal ?? peer.reciprocalPeer ?? peer.name;
430
+ const { initShareGrants, findShareGrantByPeer } = await import("../../proxy/shareGrants.js");
431
+ const { resolveProxyGrantsPath, resolveProxyReceiptsPath } = await import("../../proxy/proxyPaths.js");
432
+ const paths = resolveProxyPaths(argv.dev ?? false);
433
+ initShareGrants(resolveProxyGrantsPath(paths));
434
+ const ourGrant = await findShareGrantByPeer(reciprocal);
435
+ if (!ourGrant) {
436
+ throw new Error(`Netting needs a grant you issued to ${peer.name}. None is labelled "${reciprocal}" — ` +
437
+ "name it with --reciprocal <label>, or issue one with `neurolink proxy share create`.");
438
+ }
439
+ const { initShareReceipts, totalReceiptedCoins, nettedCoinsFor } = await import("../../proxy/shareReceipts.js");
440
+ initShareReceipts(resolveProxyReceiptsPath(paths));
441
+ const consumedByYou = await totalReceiptedCoins(ourGrant.id);
442
+ const alreadyNetted = await nettedCoinsFor(ourGrant.id);
443
+ const { signSharePayload } = await import("../../proxy/shareSigning.js");
444
+ const grantId = await resolvePeerGrantId(peer);
445
+ const signature = signSharePayload({ consumedByYou, alreadyNetted, grantId }, peer.receiptSecret);
446
+ const response = await peerFetch(`${peer.url}/peer/net`, {
447
+ method: "POST",
448
+ headers: {
449
+ "content-type": "application/json",
450
+ "x-neurolink-share-token": peer.token,
451
+ },
452
+ body: JSON.stringify({ consumedByYou, alreadyNetted, signature }),
453
+ });
454
+ const payload = (await response.json().catch(() => null));
455
+ if (!response.ok || payload?.ok !== true) {
456
+ throw new Error(`${peer.name} declined to net: ${payload?.error?.message ?? `HTTP ${response.status}`}`);
457
+ }
458
+ const round = payload.netted ?? 0;
459
+ if (round > 0) {
460
+ // Forgive the same amount on our own side, so the two ledgers stay level.
461
+ const { applyReciprocalNetting } = await import("../../proxy/shareReceipts.js");
462
+ await applyReciprocalNetting({
463
+ grantId: ourGrant.id,
464
+ consumedFromPeer: alreadyNetted + round,
465
+ peerAlreadyNetted: alreadyNetted,
466
+ });
467
+ }
468
+ await updatePeer(peer.name, { reciprocalPeer: reciprocal });
469
+ console.info(round > 0
470
+ ? `Netted ${round.toFixed(1)} coins with ${peer.name}. ${payload.detail ?? ""}`.trim()
471
+ : `Nothing to net with ${peer.name}: ${payload.detail ?? "positions already level"}`);
472
+ }
473
+ /**
474
+ * The grant id behind our token for this peer.
475
+ *
476
+ * A share token carries its grant id, so this needs no round trip — and the
477
+ * netting signature has to be bound to the grant it credits, or a claim signed
478
+ * for one peer could be replayed against another.
479
+ */
480
+ async function resolvePeerGrantId(peer) {
481
+ const { parseShareToken } = await import("../../proxy/shareGrants.js");
482
+ const parsed = parseShareToken(peer.token);
483
+ if (!parsed) {
484
+ throw new Error(`The token stored for ${peer.name} is not a share token.`);
485
+ }
486
+ return parsed.grantId;
487
+ }
488
+ /** Present a coin note to its issuer, to check it or to spend it. */
489
+ async function presentCoinNote(peer, argv) {
490
+ const note = argv.noteValue;
491
+ if (!note) {
492
+ throw new Error("peer redeem needs --coin-note <the note the issuer gave you>.");
493
+ }
494
+ const redeem = !argv.check;
495
+ const response = await peerFetch(`${peer.url}/peer/note`, {
496
+ method: "POST",
497
+ headers: {
498
+ "content-type": "application/json",
499
+ "x-neurolink-share-token": peer.token,
500
+ },
501
+ body: JSON.stringify({ note, redeem }),
502
+ });
503
+ const payload = (await response.json().catch(() => null));
504
+ if (payload?.ok !== true) {
505
+ throw new Error(payload?.error?.message ??
506
+ `${peer.name} would not honour that note (HTTP ${response.status})`);
507
+ }
508
+ if (!redeem) {
509
+ console.info(`${peer.name} says that note is ${payload.status}${payload.coins ? ` (${payload.coins} coins)` : ""}.`);
510
+ return;
511
+ }
512
+ console.info(`Redeemed ${payload.coins ?? 0} coins with ${peer.name}.` +
513
+ (payload.balance !== null && payload.balance !== undefined
514
+ ? ` Your balance there is now ${Math.floor(payload.balance)}.`
515
+ : ""));
516
+ }
517
+ async function runPeerCommand(argv) {
518
+ const { initPeerStore } = await import("../../proxy/peerStore.js");
519
+ initPeerStore(resolveProxyPeersPath(resolveProxyPaths(argv.dev ?? false)));
520
+ const now = Date.now();
521
+ switch (argv.action) {
522
+ case "add": {
523
+ let url = argv.url;
524
+ let token = argv.token;
525
+ let receiptSecret = argv.receiptSecret;
526
+ if (argv.link) {
527
+ const parsed = parseShareLink(argv.link);
528
+ if (!parsed) {
529
+ throw new Error("Could not read that share link. Expected neurolink://share/<host>#<token>.");
530
+ }
531
+ url = parsed.url;
532
+ token = parsed.token;
533
+ receiptSecret = receiptSecret ?? parsed.receiptSecret;
534
+ }
535
+ if (!argv.name || !url || !token) {
536
+ throw new Error("peer add needs --name plus either --link, or --url and --token.");
537
+ }
538
+ const peer = await addPeer({
539
+ name: argv.name,
540
+ url,
541
+ token,
542
+ ...(receiptSecret ? { receiptSecret } : {}),
543
+ ...(argv.priority !== undefined ? { priority: argv.priority } : {}),
544
+ ...(argv.note ? { note: argv.note } : {}),
545
+ });
546
+ console.info(describePeer(peer, now));
547
+ console.info("");
548
+ console.info(" This peer is consulted only after every local account is spent.");
549
+ if (!receiptSecret) {
550
+ console.info(" No receipt secret came with this link, so charges cannot be checked.");
551
+ console.info(" Ask the lender for one, or re-add from a link they mint fresh.");
552
+ }
553
+ return;
554
+ }
555
+ case "request": {
556
+ const peer = await requirePeer(argv.name);
557
+ if (argv.claim) {
558
+ await claimProvisionedCredential(peer, argv);
559
+ }
560
+ else {
561
+ await lodgeProvisionRequest(peer);
562
+ }
563
+ return;
564
+ }
565
+ case "receipts": {
566
+ const peer = await requirePeer(argv.name);
567
+ await reviewPeerReceipts(peer, argv.json ?? false);
568
+ return;
569
+ }
570
+ case "net": {
571
+ const peer = await requirePeer(argv.name);
572
+ await netWithPeer(peer, argv);
573
+ return;
574
+ }
575
+ case "redeem": {
576
+ const peer = await requirePeer(argv.name);
577
+ await presentCoinNote(peer, argv);
578
+ return;
579
+ }
580
+ case "sync": {
581
+ const { initResidentGrants, heartbeatResidentGrant, listResidentGrants } = await import("../../proxy/residentGrants.js");
582
+ const { resolveProxyResidentGrantsPath } = await import("../../proxy/proxyPaths.js");
583
+ initResidentGrants(resolveProxyResidentGrantsPath(resolveProxyPaths(argv.dev ?? false)));
584
+ const residents = await listResidentGrants();
585
+ if (residents.length === 0) {
586
+ console.info("No adopted credentials to sync.");
587
+ return;
588
+ }
589
+ for (const resident of residents) {
590
+ if (!resident.lenderUrl) {
591
+ console.info(`${resident.lenderName}: no heartbeat address`);
592
+ continue;
593
+ }
594
+ const result = await heartbeatResidentGrant(resident);
595
+ console.info(`${resident.lenderName}: ${result.stopped ? "stopped by lender" : result.detail}`);
596
+ }
597
+ return;
598
+ }
599
+ case "list":
600
+ case "status": {
601
+ const peers = argv.name
602
+ ? [await requirePeer(argv.name)]
603
+ : await listPeers();
604
+ if (argv.json) {
605
+ console.info(JSON.stringify(peers, null, 2));
606
+ return;
607
+ }
608
+ if (peers.length === 0) {
609
+ console.info("No peers configured.");
610
+ return;
611
+ }
612
+ for (const peer of peers) {
613
+ console.info(describePeer(peer, now));
614
+ console.info("");
615
+ }
616
+ return;
617
+ }
618
+ case "test": {
619
+ const peers = argv.name
620
+ ? [await requirePeer(argv.name)]
621
+ : await listPeers();
622
+ for (const peer of peers) {
623
+ const verdict = await testPeer(peer);
624
+ console.info(`${peer.name}: ${verdict}`);
625
+ }
626
+ return;
627
+ }
628
+ case "remove": {
629
+ const peer = await requirePeer(argv.name);
630
+ await removePeer(peer.name);
631
+ console.info(`${peer.name} removed.`);
632
+ return;
633
+ }
634
+ case "pause":
635
+ case "resume": {
636
+ const peer = await requirePeer(argv.name);
637
+ const enabled = argv.action === "resume";
638
+ await setPeerEnabled(peer.name, enabled);
639
+ console.info(`${peer.name} is now ${enabled ? "enabled" : "paused"}.`);
640
+ return;
641
+ }
642
+ case "set": {
643
+ const peer = await requirePeer(argv.name);
644
+ const updated = await updatePeer(peer.name, {
645
+ ...(argv.priority !== undefined ? { priority: argv.priority } : {}),
646
+ ...(argv.note !== undefined ? { note: argv.note } : {}),
647
+ ...(argv.url ? { url: argv.url } : {}),
648
+ ...(argv.token ? { token: argv.token } : {}),
649
+ });
650
+ console.info(updated ? describePeer(updated, now) : "No change.");
651
+ return;
652
+ }
653
+ default:
654
+ throw new Error(`Unknown peer action: ${String(argv.action)}`);
655
+ }
656
+ }
657
+ export const proxyPeerCommand = {
658
+ command: "peer <action>",
659
+ describe: "Borrow pool capacity from a peer when this node's own is spent",
660
+ builder: (yargs) => yargs
661
+ .positional("action", {
662
+ type: "string",
663
+ choices: [...ACTIONS],
664
+ describe: "Peer action",
665
+ })
666
+ .option("name", {
667
+ type: "string",
668
+ description: "Local name for the peer",
669
+ })
670
+ .option("link", {
671
+ type: "string",
672
+ description: "Share link from the lender (neurolink://share/...)",
673
+ })
674
+ .option("url", {
675
+ type: "string",
676
+ description: "Lender's exposed proxy URL",
677
+ })
678
+ .option("token", {
679
+ type: "string",
680
+ description: "Share token issued by the lender",
681
+ })
682
+ .option("priority", {
683
+ type: "number",
684
+ description: "Lower is tried first (default 100)",
685
+ })
686
+ .option("note", {
687
+ type: "string",
688
+ description: "Free-text note kept with the peer",
689
+ })
690
+ .option("claim", {
691
+ type: "boolean",
692
+ default: false,
693
+ description: "With `peer request`: collect a code the lender has authorized",
694
+ })
695
+ .option("label", {
696
+ type: "string",
697
+ description: "Local account label for the provisioned credential (default <peer>-shared)",
698
+ })
699
+ .option("receipt-secret", {
700
+ type: "string",
701
+ alias: "receiptSecret",
702
+ description: "Secret this lender signs receipts with, when adding a peer by hand",
703
+ })
704
+ .option("reciprocal", {
705
+ type: "string",
706
+ description: "With `peer net`: label of the grant you issued to the same person",
707
+ })
708
+ .option("coin-note", {
709
+ type: "string",
710
+ alias: "noteValue",
711
+ description: "With `peer redeem`: the coin note to present",
712
+ })
713
+ .option("check", {
714
+ type: "boolean",
715
+ default: false,
716
+ description: "With `peer redeem`: ask the issuer about the note without spending it",
717
+ })
718
+ .option("json", {
719
+ type: "boolean",
720
+ default: false,
721
+ description: "Emit JSON instead of formatted text",
722
+ })
723
+ .option("dev", {
724
+ type: "boolean",
725
+ default: false,
726
+ description: "Use the isolated dev-mode state directory",
727
+ }),
728
+ handler: async (argv) => {
729
+ try {
730
+ await runPeerCommand(argv);
731
+ }
732
+ catch (error) {
733
+ console.error(error instanceof Error ? error.message : String(error));
734
+ process.exitCode = 1;
735
+ }
736
+ },
737
+ };
738
+ //# sourceMappingURL=proxyPeer.js.map