@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
@@ -15,3 +15,11 @@
15
15
  import type { ProxyPaths } from "../types/index.js";
16
16
  export declare function resolveProxyPaths(dev: boolean): ProxyPaths;
17
17
  export declare function resolveProxyUsageStatsPath(paths: ProxyPaths): string;
18
+ export declare function resolveProxyGrantsPath(paths: ProxyPaths): string;
19
+ export declare function resolveProxyLedgerPath(paths: ProxyPaths): string;
20
+ export declare function resolveProxyPeersPath(paths: ProxyPaths): string;
21
+ export declare function resolveProxyResidentGrantsPath(paths: ProxyPaths): string;
22
+ export declare function resolveProxyShareAuditPath(paths: ProxyPaths): string;
23
+ export declare function resolveProxyProvisioningPath(paths: ProxyPaths): string;
24
+ export declare function resolveProxyReceiptsPath(paths: ProxyPaths): string;
25
+ export declare function resolveProxyNotesPath(paths: ProxyPaths): string;
@@ -14,28 +14,66 @@
14
14
  */
15
15
  import { homedir } from "node:os";
16
16
  import { join } from "node:path";
17
+ /**
18
+ * The state files, named once.
19
+ *
20
+ * Each name used to appear three times — dev branch, home branch, and the
21
+ * resolver's fallback — which is three places to keep in step and two chances
22
+ * to write a dev proxy's state into the global directory.
23
+ */
24
+ const FILE_NAMES = {
25
+ quota: "account-quotas.json",
26
+ cooldown: "account-cooldowns.json",
27
+ stats: "proxy-usage-stats.json",
28
+ grants: "proxy-grants.json",
29
+ ledger: "proxy-share-ledger.json",
30
+ peers: "proxy-peers.json",
31
+ residentGrants: "proxy-resident-grants.json",
32
+ shareAudit: "proxy-share-audit.json",
33
+ provisioning: "proxy-share-provisioning.json",
34
+ receipts: "proxy-share-receipts.json",
35
+ notes: "proxy-share-notes.json",
36
+ };
17
37
  export function resolveProxyPaths(dev) {
18
- if (dev) {
19
- const base = join(process.cwd(), ".neurolink-dev");
20
- return {
21
- stateDir: base,
22
- logsDir: join(base, "logs"),
23
- quotaFile: join(base, "account-quotas.json"),
24
- cooldownFile: join(base, "account-cooldowns.json"),
25
- statsFile: join(base, "proxy-usage-stats.json"),
26
- isDev: true,
27
- };
28
- }
29
- const base = join(homedir(), ".neurolink");
38
+ const base = dev
39
+ ? join(process.cwd(), ".neurolink-dev")
40
+ : join(homedir(), ".neurolink");
30
41
  return {
31
42
  stateDir: base,
32
43
  logsDir: join(base, "logs"),
33
- quotaFile: join(base, "account-quotas.json"),
34
- cooldownFile: join(base, "account-cooldowns.json"),
35
- statsFile: join(base, "proxy-usage-stats.json"),
36
- isDev: false,
44
+ quotaFile: join(base, FILE_NAMES.quota),
45
+ cooldownFile: join(base, FILE_NAMES.cooldown),
46
+ statsFile: join(base, FILE_NAMES.stats),
47
+ grantsFile: join(base, FILE_NAMES.grants),
48
+ ledgerFile: join(base, FILE_NAMES.ledger),
49
+ peersFile: join(base, FILE_NAMES.peers),
50
+ isDev: dev,
37
51
  };
38
52
  }
39
53
  export function resolveProxyUsageStatsPath(paths) {
40
- return paths.statsFile ?? join(paths.stateDir, "proxy-usage-stats.json");
54
+ return paths.statsFile ?? join(paths.stateDir, FILE_NAMES.stats);
55
+ }
56
+ export function resolveProxyGrantsPath(paths) {
57
+ return paths.grantsFile ?? join(paths.stateDir, FILE_NAMES.grants);
58
+ }
59
+ export function resolveProxyLedgerPath(paths) {
60
+ return paths.ledgerFile ?? join(paths.stateDir, FILE_NAMES.ledger);
61
+ }
62
+ export function resolveProxyPeersPath(paths) {
63
+ return paths.peersFile ?? join(paths.stateDir, FILE_NAMES.peers);
64
+ }
65
+ export function resolveProxyResidentGrantsPath(paths) {
66
+ return join(paths.stateDir, FILE_NAMES.residentGrants);
67
+ }
68
+ export function resolveProxyShareAuditPath(paths) {
69
+ return join(paths.stateDir, FILE_NAMES.shareAudit);
70
+ }
71
+ export function resolveProxyProvisioningPath(paths) {
72
+ return join(paths.stateDir, FILE_NAMES.provisioning);
73
+ }
74
+ export function resolveProxyReceiptsPath(paths) {
75
+ return join(paths.stateDir, FILE_NAMES.receipts);
76
+ }
77
+ export function resolveProxyNotesPath(paths) {
78
+ return join(paths.stateDir, FILE_NAMES.notes);
41
79
  }
@@ -13,6 +13,7 @@ import { writeFile } from "fs/promises";
13
13
  import { createHash } from "crypto";
14
14
  import { promisify } from "util";
15
15
  import { gzip as gzipCallback } from "zlib";
16
+ import { isBorrowedRequest } from "./shareContext.js";
16
17
  import { OtelBridge } from "../observability/otelBridge.js";
17
18
  import { SeverityNumber } from "@opentelemetry/api-logs";
18
19
  import { configureProxyLifecycleLogger } from "./proxyLifecycle.js";
@@ -558,6 +559,13 @@ export async function logBodyCapture(entry) {
558
559
  if (!logEnabled || !logDir) {
559
560
  return;
560
561
  }
562
+ // Borrowed traffic is somebody else's conversation. Capturing it would leave
563
+ // a peer's prompts and the model's replies on this machine's disk, which is
564
+ // not something a share token can be read as consenting to. The request is
565
+ // still logged; only the bodies are dropped.
566
+ if (isBorrowedRequest()) {
567
+ return;
568
+ }
561
569
  const bridge = new OtelBridge();
562
570
  const traceCtx = entry.traceId && entry.spanId
563
571
  ? { traceId: entry.traceId, spanId: entry.spanId }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Credentials a lender provisioned onto this device, and the leases that keep
3
+ * them legitimate.
4
+ *
5
+ * This is the borrower's half of **complete** sharing. The credential itself
6
+ * lives in the normal token store and is routed like any other account — that is
7
+ * the point, since it survives the lender being offline. What lives here is the
8
+ * proof that the lender still consents, and the machinery to keep it fresh.
9
+ *
10
+ * **The enforcement is cooperative and the code should say so.** A resident
11
+ * credential sits on a machine its holder controls, and the token store is
12
+ * obfuscated rather than encrypted. Someone determined to bypass this can. What
13
+ * these checks buy is that the honest path is also the correct one: a borrower
14
+ * running the shipped software stops when the lender says stop, and stops on its
15
+ * own if the lender becomes unreachable for longer than the lease allows.
16
+ *
17
+ * @module proxy/residentGrants
18
+ */
19
+ import type { ProxyResidentGrant, ProxyShareLease, ProxyShareLeaseVerdict } from "../types/index.js";
20
+ export declare function initResidentGrants(filePath: string): void;
21
+ export declare function listResidentGrants(): Promise<ProxyResidentGrant[]>;
22
+ export declare function getResidentGrantForAccount(accountKeyOrLabel: string): Promise<ProxyResidentGrant | undefined>;
23
+ export declare function saveResidentGrant(grant: ProxyResidentGrant): Promise<void>;
24
+ export declare function removeResidentGrant(accountLabel: string): Promise<boolean>;
25
+ /**
26
+ * May this resident account serve right now?
27
+ *
28
+ * Returns `undefined` for an account that is not resident at all — the node's
29
+ * own credentials, which answer to nobody.
30
+ */
31
+ export declare function evaluateResidentAccount(accountKeyOrLabel: string, now?: number): Promise<ProxyShareLeaseVerdict | undefined>;
32
+ /** Accumulate spend the borrower owes the lender an account of. */
33
+ export declare function recordResidentSpend(accountLabel: string, coins: number): Promise<void>;
34
+ /**
35
+ * Check in with a lender: report what was spent, collect a fresh lease.
36
+ *
37
+ * Reporting happens **before** the new lease is stored, and the counters are
38
+ * only drawn down once the lender has acknowledged them — a heartbeat that
39
+ * fails halfway leaves the spend to be reported again rather than losing it.
40
+ *
41
+ * A `stop` answer is honored immediately by clearing the lease's grace: the
42
+ * lender has said no, and there is nothing to wait out.
43
+ */
44
+ export declare function heartbeatResidentGrant(resident: ProxyResidentGrant, now?: number): Promise<{
45
+ ok: boolean;
46
+ stopped: boolean;
47
+ detail: string;
48
+ }>;
49
+ /**
50
+ * Check in with every lender whose heartbeat is due.
51
+ *
52
+ * Best-effort by design: a lender being unreachable is the case the offline
53
+ * grace exists for, not an error to surface.
54
+ */
55
+ export declare function heartbeatDueResidentGrants(now?: number): Promise<void>;
56
+ /** The lease a resident account is currently operating under. */
57
+ export declare function residentLease(resident: ProxyResidentGrant): ProxyShareLease;
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Credentials a lender provisioned onto this device, and the leases that keep
3
+ * them legitimate.
4
+ *
5
+ * This is the borrower's half of **complete** sharing. The credential itself
6
+ * lives in the normal token store and is routed like any other account — that is
7
+ * the point, since it survives the lender being offline. What lives here is the
8
+ * proof that the lender still consents, and the machinery to keep it fresh.
9
+ *
10
+ * **The enforcement is cooperative and the code should say so.** A resident
11
+ * credential sits on a machine its holder controls, and the token store is
12
+ * obfuscated rather than encrypted. Someone determined to bypass this can. What
13
+ * these checks buy is that the honest path is also the correct one: a borrower
14
+ * running the shipped software stops when the lender says stop, and stops on its
15
+ * own if the lender becomes unreachable for longer than the lease allows.
16
+ *
17
+ * @module proxy/residentGrants
18
+ */
19
+ import { readFile, stat } from "node:fs/promises";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { AsyncMutex } from "../utils/asyncMutex.js";
23
+ import { logger } from "../utils/logger.js";
24
+ import { evaluateLease, isHeartbeatDue, isLeaseAuthentic, } from "./shareLease.js";
25
+ import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
26
+ const RESIDENT_FILE = "proxy-resident-grants.json";
27
+ const HEARTBEAT_TIMEOUT_MS = 10_000;
28
+ /**
29
+ * How long a read may trust the cache before it stats the file again.
30
+ *
31
+ * The CLI writes this file from a separate process — `share accept`, `peer
32
+ * provision`, a manual removal — so a load-once cache would let a long-lived
33
+ * proxy keep serving from a grant the operator revoked minutes ago.
34
+ */
35
+ const RELOAD_TTL_MS = 1_000;
36
+ let customFilePath = null;
37
+ let cache = {};
38
+ let cacheLoadedAt = 0;
39
+ let cacheMtimeMs = -1;
40
+ let cacheValid = false;
41
+ const mutationMutex = new AsyncMutex();
42
+ export function initResidentGrants(filePath) {
43
+ customFilePath = filePath;
44
+ cache = {};
45
+ cacheLoadedAt = 0;
46
+ cacheMtimeMs = -1;
47
+ cacheValid = false;
48
+ }
49
+ function getFilePath() {
50
+ return customFilePath ?? join(homedir(), ".neurolink", RESIDENT_FILE);
51
+ }
52
+ function isResidentGrant(value) {
53
+ if (!value || typeof value !== "object") {
54
+ return false;
55
+ }
56
+ const candidate = value;
57
+ return (typeof candidate.accountLabel === "string" &&
58
+ typeof candidate.grantId === "string" &&
59
+ typeof candidate.leaseSecret === "string" &&
60
+ !!candidate.lease);
61
+ }
62
+ /**
63
+ * Is this error simply "the file is not there yet"?
64
+ *
65
+ * The distinction is load-bearing. An absent file genuinely is an empty map —
66
+ * nothing has been written yet. Every *other* `stat`/read failure (`EACCES`,
67
+ * `EIO`, `EMFILE`, a full descriptor table) is a failure to observe the file,
68
+ * and answering one with an empty map is how a whole store gets erased: a
69
+ * caller passing `force` is about to `persist()` the map back over the real
70
+ * contents it just failed to read.
71
+ */
72
+ function isMissingFileError(error) {
73
+ return error?.code === "ENOENT";
74
+ }
75
+ async function ensureLoaded(options = {}) {
76
+ const now = Date.now();
77
+ if (!options.force && cacheValid && now - cacheLoadedAt < RELOAD_TTL_MS) {
78
+ return;
79
+ }
80
+ const path = getFilePath();
81
+ let mtimeMs;
82
+ try {
83
+ mtimeMs = (await stat(path)).mtimeMs;
84
+ }
85
+ catch (error) {
86
+ if (!isMissingFileError(error)) {
87
+ // Not "no file" but "could not look" — see `isMissingFileError`. Let it
88
+ // out: a mutation must abort rather than persist an empty map over a
89
+ // store it never managed to read.
90
+ throw error;
91
+ }
92
+ cache = {};
93
+ cacheMtimeMs = -1;
94
+ cacheLoadedAt = now;
95
+ cacheValid = true;
96
+ return;
97
+ }
98
+ // A forced load skips this. mtime is the fast path for a read, not a
99
+ // correctness check for a write: several filesystems stamp it at one-second
100
+ // granularity, so a write landing in the same second as our last read is
101
+ // indistinguishable from no write at all — and every caller passing `force`
102
+ // is about to persist the whole map back over whatever it missed.
103
+ if (!options.force && cacheValid && mtimeMs === cacheMtimeMs) {
104
+ cacheLoadedAt = now;
105
+ return;
106
+ }
107
+ try {
108
+ const parsed = JSON.parse(await readFile(path, "utf8"));
109
+ cache = Object.fromEntries(Object.entries(parsed?.grants ?? {}).filter((entry) => isResidentGrant(entry[1])));
110
+ }
111
+ catch (error) {
112
+ if (options.force) {
113
+ // A mutation is about to write the whole map back; treating a corrupt
114
+ // file as empty here would make that write finish the corruption off.
115
+ // Abort instead and leave the file for a human. Reads stay tolerant.
116
+ throw error;
117
+ }
118
+ cache = {};
119
+ }
120
+ cacheMtimeMs = mtimeMs;
121
+ cacheLoadedAt = now;
122
+ cacheValid = true;
123
+ }
124
+ async function persist() {
125
+ const file = { schemaVersion: 1, grants: cache };
126
+ await writeJsonSnapshotAtomically(getFilePath(), file);
127
+ try {
128
+ cacheMtimeMs = (await stat(getFilePath())).mtimeMs;
129
+ }
130
+ catch {
131
+ cacheMtimeMs = -1;
132
+ }
133
+ cacheLoadedAt = Date.now();
134
+ cacheValid = true;
135
+ }
136
+ /**
137
+ * Narrow a heartbeat answer to its stopping half.
138
+ * Explicit because one build step compiles without `strictNullChecks`, where a
139
+ * boolean discriminant does not narrow.
140
+ */
141
+ function isHeartbeatStop(payload) {
142
+ return !payload.ok;
143
+ }
144
+ /**
145
+ * Is this shaped like a lease at all?
146
+ *
147
+ * Shape only — {@link isLeaseAuthentic} decides whether it is *ours*. Both run
148
+ * before a renewal is allowed to replace a lease that currently works, because
149
+ * the answer comes off the wire and an `{ ok: true }` with no usable lease
150
+ * would otherwise be persisted over the one keeping this account alive.
151
+ */
152
+ function isShareLease(value) {
153
+ if (!value || typeof value !== "object") {
154
+ return false;
155
+ }
156
+ const candidate = value;
157
+ return (typeof candidate.grantId === "string" &&
158
+ typeof candidate.signature === "string" &&
159
+ typeof candidate.issuedAt === "number" &&
160
+ typeof candidate.notAfter === "number" &&
161
+ typeof candidate.heartbeatEveryMs === "number" &&
162
+ typeof candidate.offlineGraceMs === "number" &&
163
+ !!candidate.gates);
164
+ }
165
+ /** The matching predicate for the renewing half. */
166
+ function isHeartbeatRenewal(payload) {
167
+ return payload.ok === true && isShareLease(payload.lease);
168
+ }
169
+ /** Key by the account label, since that is how the routing path addresses it. */
170
+ function keyFor(accountLabel) {
171
+ return accountLabel.toLowerCase();
172
+ }
173
+ export async function listResidentGrants() {
174
+ await ensureLoaded();
175
+ return Object.values(cache);
176
+ }
177
+ export async function getResidentGrantForAccount(accountKeyOrLabel) {
178
+ await ensureLoaded();
179
+ const value = accountKeyOrLabel.toLowerCase();
180
+ const label = value.includes(":")
181
+ ? value.slice(value.indexOf(":") + 1)
182
+ : value;
183
+ return cache[keyFor(label)];
184
+ }
185
+ export async function saveResidentGrant(grant) {
186
+ await mutationMutex.runExclusive(async () => {
187
+ // Force: `persist()` writes the whole map back, so a mutation that ran on a
188
+ // TTL-fresh snapshot would resurrect a grant the CLI removed in the window
189
+ // since this process last read the file.
190
+ await ensureLoaded({ force: true });
191
+ cache[keyFor(grant.accountLabel)] = grant;
192
+ await persist();
193
+ });
194
+ }
195
+ export async function removeResidentGrant(accountLabel) {
196
+ return mutationMutex.runExclusive(async () => {
197
+ await ensureLoaded({ force: true });
198
+ const key = keyFor(accountLabel);
199
+ if (!cache[key]) {
200
+ return false;
201
+ }
202
+ delete cache[key];
203
+ await persist();
204
+ return true;
205
+ });
206
+ }
207
+ /**
208
+ * May this resident account serve right now?
209
+ *
210
+ * Returns `undefined` for an account that is not resident at all — the node's
211
+ * own credentials, which answer to nobody.
212
+ */
213
+ export async function evaluateResidentAccount(accountKeyOrLabel, now = Date.now()) {
214
+ const resident = await getResidentGrantForAccount(accountKeyOrLabel);
215
+ if (!resident) {
216
+ return undefined;
217
+ }
218
+ return evaluateLease({
219
+ lease: resident.lease,
220
+ secret: resident.leaseSecret,
221
+ ...(resident.lastHeartbeatAt !== undefined
222
+ ? { lastHeartbeatAt: resident.lastHeartbeatAt }
223
+ : {}),
224
+ now,
225
+ });
226
+ }
227
+ /** Accumulate spend the borrower owes the lender an account of. */
228
+ export async function recordResidentSpend(accountLabel, coins) {
229
+ await mutationMutex.runExclusive(async () => {
230
+ await ensureLoaded({ force: true });
231
+ const key = keyFor(accountLabel);
232
+ const resident = cache[key];
233
+ if (!resident) {
234
+ return;
235
+ }
236
+ cache[key] = {
237
+ ...resident,
238
+ unreportedCoins: (resident.unreportedCoins ?? 0) + coins,
239
+ unreportedRequests: (resident.unreportedRequests ?? 0) + 1,
240
+ };
241
+ await persist();
242
+ });
243
+ }
244
+ /**
245
+ * Apply a heartbeat's outcome to whatever the record says **now**.
246
+ *
247
+ * The round trip takes up to {@link HEARTBEAT_TIMEOUT_MS} and requests keep
248
+ * being served throughout it, so writing back the snapshot the heartbeat
249
+ * started from would silently drop every coin {@link recordResidentSpend}
250
+ * booked in that window. The reported amounts are subtracted from the current
251
+ * counters instead of the counters being zeroed, and the record is re-read
252
+ * rather than reconstructed from the caller's copy.
253
+ */
254
+ async function settleHeartbeat(args) {
255
+ await mutationMutex.runExclusive(async () => {
256
+ await ensureLoaded({ force: true });
257
+ const key = keyFor(args.accountLabel);
258
+ const current = cache[key];
259
+ if (!current) {
260
+ return;
261
+ }
262
+ cache[key] = {
263
+ ...current,
264
+ lease: args.outcome === "stop"
265
+ ? // Force the next evaluation to fail closed without waiting out grace.
266
+ { ...current.lease, notAfter: args.now }
267
+ : args.outcome.lease,
268
+ lastHeartbeatAt: args.now,
269
+ unreportedCoins: Math.max(0, (current.unreportedCoins ?? 0) - args.reportedCoins),
270
+ unreportedRequests: Math.max(0, (current.unreportedRequests ?? 0) - args.reportedRequests),
271
+ };
272
+ await persist();
273
+ });
274
+ }
275
+ /**
276
+ * Check in with a lender: report what was spent, collect a fresh lease.
277
+ *
278
+ * Reporting happens **before** the new lease is stored, and the counters are
279
+ * only drawn down once the lender has acknowledged them — a heartbeat that
280
+ * fails halfway leaves the spend to be reported again rather than losing it.
281
+ *
282
+ * A `stop` answer is honored immediately by clearing the lease's grace: the
283
+ * lender has said no, and there is nothing to wait out.
284
+ */
285
+ export async function heartbeatResidentGrant(resident, now = Date.now()) {
286
+ const controller = new AbortController();
287
+ const timeout = setTimeout(() => controller.abort(), HEARTBEAT_TIMEOUT_MS);
288
+ // Exactly what this round trip claims to have reported, so the settle can
289
+ // subtract that and nothing more.
290
+ const reportedCoins = resident.unreportedCoins ?? 0;
291
+ const reportedRequests = resident.unreportedRequests ?? 0;
292
+ try {
293
+ const response = await fetch(`${resident.lenderUrl}/peer/heartbeat`, {
294
+ method: "POST",
295
+ headers: {
296
+ "content-type": "application/json",
297
+ "x-neurolink-share-token": resident.leaseSecret,
298
+ "x-neurolink-grant-id": resident.grantId,
299
+ },
300
+ body: JSON.stringify({
301
+ grantId: resident.grantId,
302
+ coinsSpent: reportedCoins,
303
+ requests: reportedRequests,
304
+ reportedAt: now,
305
+ }),
306
+ signal: controller.signal,
307
+ });
308
+ if (!response.ok) {
309
+ return {
310
+ ok: false,
311
+ stopped: false,
312
+ detail: `lender answered ${response.status}`,
313
+ };
314
+ }
315
+ const payload = (await response.json());
316
+ if (isHeartbeatStop(payload)) {
317
+ await settleHeartbeat({
318
+ accountLabel: resident.accountLabel,
319
+ reportedCoins,
320
+ reportedRequests,
321
+ outcome: "stop",
322
+ now,
323
+ });
324
+ logger.always(`[proxy] lender stopped resident grant ${resident.accountLabel}: ${payload.reason}`);
325
+ return { ok: true, stopped: true, detail: payload.reason };
326
+ }
327
+ if (!isHeartbeatRenewal(payload)) {
328
+ return {
329
+ ok: false,
330
+ stopped: false,
331
+ detail: "lender answered with neither a lease nor a stop",
332
+ };
333
+ }
334
+ // A renewal that is not signed by the secret we already hold, or that is
335
+ // for some other grant, is not a renewal — keep the working lease and let
336
+ // the offline grace run. Replacing it would hand any party that can answer
337
+ // this request the ability to revoke us, or to extend us indefinitely.
338
+ if (payload.lease.grantId !== resident.grantId) {
339
+ return {
340
+ ok: false,
341
+ stopped: false,
342
+ detail: "renewed lease is for a different grant",
343
+ };
344
+ }
345
+ if (!isLeaseAuthentic(payload.lease, resident.leaseSecret)) {
346
+ return {
347
+ ok: false,
348
+ stopped: false,
349
+ detail: "renewed lease is not signed by the lender's secret",
350
+ };
351
+ }
352
+ await settleHeartbeat({
353
+ accountLabel: resident.accountLabel,
354
+ reportedCoins,
355
+ reportedRequests,
356
+ outcome: { lease: payload.lease },
357
+ now,
358
+ });
359
+ return { ok: true, stopped: false, detail: "lease renewed" };
360
+ }
361
+ catch (error) {
362
+ return {
363
+ ok: false,
364
+ stopped: false,
365
+ detail: error instanceof Error ? error.message : String(error),
366
+ };
367
+ }
368
+ finally {
369
+ clearTimeout(timeout);
370
+ }
371
+ }
372
+ /**
373
+ * Check in with every lender whose heartbeat is due.
374
+ *
375
+ * Best-effort by design: a lender being unreachable is the case the offline
376
+ * grace exists for, not an error to surface.
377
+ */
378
+ export async function heartbeatDueResidentGrants(now = Date.now()) {
379
+ const grants = await listResidentGrants();
380
+ for (const resident of grants) {
381
+ if (!isHeartbeatDue(resident.lease, resident.lastHeartbeatAt, now)) {
382
+ continue;
383
+ }
384
+ const result = await heartbeatResidentGrant(resident, now);
385
+ if (!result.ok) {
386
+ logger.debug(`[proxy] heartbeat to ${resident.lenderName} failed: ${result.detail}`);
387
+ }
388
+ }
389
+ }
390
+ /** The lease a resident account is currently operating under. */
391
+ export function residentLease(resident) {
392
+ return resident.lease;
393
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Trust-but-verify for complete-mode shares.
3
+ *
4
+ * A complete-mode borrower calls the provider directly, so the lender never sees
5
+ * its requests and its reported spend is, strictly, an assertion. This module is
6
+ * what stops that assertion from being the only evidence.
7
+ *
8
+ * **The signal.** The provider reports the account's own utilization, and the
9
+ * borrower cannot influence that number. If an account's window moved between
10
+ * two heartbeats, someone spent it. If the lender's own node served nothing on
11
+ * that account in the same interval, and the borrower reported nothing either,
12
+ * then something consumed the account that neither party is accounting for —
13
+ * which is exactly the shape of a borrower that stopped reporting.
14
+ *
15
+ * **Why it is conservative.** The check only fires when the lender's own traffic
16
+ * on that account was zero for the interval. A busy lender moves the same
17
+ * numbers, and a false accusation is worse than a missed one: the remedy here is
18
+ * to pause someone's access. Everything ambiguous is reported as `attributable`
19
+ * and left alone.
20
+ *
21
+ * This detects a borrower that under-reports. It does not, and cannot, detect
22
+ * one that reports honestly and simply spends what it was lent. It is also blind
23
+ * across a window reset: utilization that falls between two check-ins reads as
24
+ * no movement, so spend timed around a rollover goes unremarked. Closing that
25
+ * would mean trusting a reset timestamp the borrower also influences, which is
26
+ * a worse trade than the gap.
27
+ *
28
+ * @module proxy/shareAudit
29
+ */
30
+ import type { ProxyShareAuditObservation, ProxyShareAuditRecord, ProxyShareDriftVerdict } from "../types/index.js";
31
+ /**
32
+ * How much of a window may move unexplained before it counts as drift.
33
+ *
34
+ * Utilization is reported coarsely and can tick from rounding or from a request
35
+ * that was in flight across the boundary, so a hair-trigger would cry wolf.
36
+ */
37
+ export declare const DRIFT_TOLERANCE_PCT = 2;
38
+ /** Consecutive drifting heartbeats tolerated before the grant is paused. */
39
+ export declare const DRIFT_STREAK_LIMIT = 3;
40
+ export declare function initShareAudit(auditFilePath: string): void;
41
+ /**
42
+ * Compare one heartbeat against the account's real movement.
43
+ *
44
+ * Pure: the caller supplies the previous observation and the current one, so the
45
+ * decision is inspectable and testable without any I/O.
46
+ */
47
+ export declare function evaluateDrift(previous: ProxyShareAuditObservation | undefined, current: ProxyShareAuditObservation, tolerancePct?: number): ProxyShareDriftVerdict;
48
+ export declare function getAuditRecord(grantId: string): Promise<ProxyShareAuditRecord | undefined>;
49
+ export declare function listAuditRecords(): Promise<ProxyShareAuditRecord[]>;
50
+ /**
51
+ * Fold a heartbeat into the audit trail.
52
+ *
53
+ * Returns whether the grant has now drifted past tolerance often enough to be
54
+ * paused — the caller owns that decision, because pausing is a policy action and
55
+ * this module only supplies evidence.
56
+ */
57
+ export declare function recordAuditObservation(args: {
58
+ grantId: string;
59
+ accountLabel: string;
60
+ observation: ProxyShareAuditObservation;
61
+ /** Lifetime lender-served request count, differenced against the stored
62
+ * total to yield this interval's `lenderRequests`. */
63
+ lenderRequestsTotal?: number;
64
+ streakLimit?: number;
65
+ tolerancePct?: number;
66
+ }): Promise<{
67
+ verdict: ProxyShareDriftVerdict;
68
+ shouldPause: boolean;
69
+ }>;
70
+ /**
71
+ * Clear a grant's drift streak and its auto-pause marker.
72
+ *
73
+ * Called when the lender resumes a grant. Without it the marker is permanent
74
+ * and `shouldPause` can never fire again, so a grant auto-paused once would
75
+ * drift freely for the rest of its life. The observation baseline is kept: it
76
+ * is the account's real utilization and is still the right thing to difference
77
+ * the next heartbeat against.
78
+ */
79
+ export declare function clearAuditDrift(grantId: string): Promise<void>;
80
+ /** Forget a grant's audit trail — used when the grant itself is deleted. */
81
+ export declare function clearAuditRecord(grantId: string): Promise<void>;