@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.
- package/CHANGELOG.md +3 -3
- package/dist/auth/anthropicOAuth.d.ts +50 -0
- package/dist/auth/anthropicOAuth.js +78 -0
- package/dist/browser/neurolink.min.js +393 -393
- package/dist/cli/commands/proxy.d.ts +2 -0
- package/dist/cli/commands/proxy.js +284 -4
- package/dist/cli/commands/proxyExpose.d.ts +35 -0
- package/dist/cli/commands/proxyExpose.js +252 -0
- package/dist/cli/commands/proxyPeer.d.ts +29 -0
- package/dist/cli/commands/proxyPeer.js +738 -0
- package/dist/cli/commands/proxyShare.d.ts +37 -0
- package/dist/cli/commands/proxyShare.js +1080 -0
- package/dist/cli/parser.js +7 -1
- package/dist/proxy/peerStore.d.ts +52 -0
- package/dist/proxy/peerStore.js +324 -0
- package/dist/proxy/peerTransport.d.ts +38 -0
- package/dist/proxy/peerTransport.js +242 -0
- package/dist/proxy/proxyPaths.d.ts +8 -0
- package/dist/proxy/proxyPaths.js +55 -17
- package/dist/proxy/requestLogger.js +8 -0
- package/dist/proxy/residentGrants.d.ts +57 -0
- package/dist/proxy/residentGrants.js +393 -0
- package/dist/proxy/shareAudit.d.ts +81 -0
- package/dist/proxy/shareAudit.js +280 -0
- package/dist/proxy/shareContext.d.ts +38 -0
- package/dist/proxy/shareContext.js +92 -0
- package/dist/proxy/shareGate.d.ts +64 -0
- package/dist/proxy/shareGate.js +216 -0
- package/dist/proxy/shareGrants.d.ts +115 -0
- package/dist/proxy/shareGrants.js +590 -0
- package/dist/proxy/shareLease.d.ts +101 -0
- package/dist/proxy/shareLease.js +192 -0
- package/dist/proxy/shareLedger.d.ts +105 -0
- package/dist/proxy/shareLedger.js +406 -0
- package/dist/proxy/shareListener.d.ts +60 -0
- package/dist/proxy/shareListener.js +143 -0
- package/dist/proxy/shareNotes.d.ts +97 -0
- package/dist/proxy/shareNotes.js +234 -0
- package/dist/proxy/sharePolicy.d.ts +110 -0
- package/dist/proxy/sharePolicy.js +366 -0
- package/dist/proxy/shareProvisioning.d.ts +110 -0
- package/dist/proxy/shareProvisioning.js +237 -0
- package/dist/proxy/shareReceipts.d.ts +99 -0
- package/dist/proxy/shareReceipts.js +303 -0
- package/dist/proxy/shareSigning.d.ts +40 -0
- package/dist/proxy/shareSigning.js +78 -0
- package/dist/server/routes/claudeProxyRoutes.js +1066 -3
- package/dist/types/cli.d.ts +61 -0
- package/dist/types/proxy.d.ts +781 -0
- 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;
|
package/dist/proxy/proxyPaths.js
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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,
|
|
34
|
-
cooldownFile: join(base,
|
|
35
|
-
statsFile: join(base,
|
|
36
|
-
|
|
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,
|
|
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>;
|