@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
|
@@ -0,0 +1,280 @@
|
|
|
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 { readFile, stat } from "node:fs/promises";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { AsyncMutex } from "../utils/asyncMutex.js";
|
|
34
|
+
import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
|
|
35
|
+
const AUDIT_FILE = "proxy-share-audit.json";
|
|
36
|
+
/**
|
|
37
|
+
* How much of a window may move unexplained before it counts as drift.
|
|
38
|
+
*
|
|
39
|
+
* Utilization is reported coarsely and can tick from rounding or from a request
|
|
40
|
+
* that was in flight across the boundary, so a hair-trigger would cry wolf.
|
|
41
|
+
*/
|
|
42
|
+
export const DRIFT_TOLERANCE_PCT = 2;
|
|
43
|
+
/** Consecutive drifting heartbeats tolerated before the grant is paused. */
|
|
44
|
+
export const DRIFT_STREAK_LIMIT = 3;
|
|
45
|
+
/**
|
|
46
|
+
* How long a read may trust the cache before it stats the file again.
|
|
47
|
+
*
|
|
48
|
+
* The CLI reads and clears this trail from a separate process (`share audit`,
|
|
49
|
+
* `share resume`), so a load-once cache would let the proxy keep counting a
|
|
50
|
+
* drift streak an operator already cleared.
|
|
51
|
+
*/
|
|
52
|
+
const RELOAD_TTL_MS = 1_000;
|
|
53
|
+
let customAuditFilePath = null;
|
|
54
|
+
let cache = {};
|
|
55
|
+
let cacheLoadedAt = 0;
|
|
56
|
+
let cacheMtimeMs = -1;
|
|
57
|
+
let cacheValid = false;
|
|
58
|
+
const mutationMutex = new AsyncMutex();
|
|
59
|
+
export function initShareAudit(auditFilePath) {
|
|
60
|
+
customAuditFilePath = auditFilePath;
|
|
61
|
+
cache = {};
|
|
62
|
+
cacheLoadedAt = 0;
|
|
63
|
+
cacheMtimeMs = -1;
|
|
64
|
+
cacheValid = false;
|
|
65
|
+
}
|
|
66
|
+
function getAuditFilePath() {
|
|
67
|
+
return customAuditFilePath ?? join(homedir(), ".neurolink", AUDIT_FILE);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Is this error simply "the file is not there yet"?
|
|
71
|
+
*
|
|
72
|
+
* The distinction is load-bearing. An absent file genuinely is an empty map —
|
|
73
|
+
* nothing has been written yet. Every *other* `stat`/read failure (`EACCES`,
|
|
74
|
+
* `EIO`, `EMFILE`, a full descriptor table) is a failure to observe the file,
|
|
75
|
+
* and answering one with an empty map is how a whole store gets erased: a
|
|
76
|
+
* caller passing `force` is about to `persist()` the map back over the real
|
|
77
|
+
* contents it just failed to read.
|
|
78
|
+
*/
|
|
79
|
+
function isMissingFileError(error) {
|
|
80
|
+
return error?.code === "ENOENT";
|
|
81
|
+
}
|
|
82
|
+
async function ensureLoaded(options = {}) {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
if (!options.force && cacheValid && now - cacheLoadedAt < RELOAD_TTL_MS) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const path = getAuditFilePath();
|
|
88
|
+
let mtimeMs;
|
|
89
|
+
try {
|
|
90
|
+
mtimeMs = (await stat(path)).mtimeMs;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (!isMissingFileError(error)) {
|
|
94
|
+
// Not "no file" but "could not look" — see `isMissingFileError`. Let it
|
|
95
|
+
// out: a mutation must abort rather than persist an empty map over a
|
|
96
|
+
// store it never managed to read.
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
cache = {};
|
|
100
|
+
cacheMtimeMs = -1;
|
|
101
|
+
cacheLoadedAt = now;
|
|
102
|
+
cacheValid = true;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// A forced load skips this. mtime is the fast path for a read, not a
|
|
106
|
+
// correctness check for a write: several filesystems stamp it at one-second
|
|
107
|
+
// granularity, so a write landing in the same second as our last read is
|
|
108
|
+
// indistinguishable from no write at all — and every caller passing `force`
|
|
109
|
+
// is about to persist the whole map back over whatever it missed.
|
|
110
|
+
if (!options.force && cacheValid && mtimeMs === cacheMtimeMs) {
|
|
111
|
+
cacheLoadedAt = now;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
116
|
+
cache = parsed?.records ?? {};
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (options.force) {
|
|
120
|
+
// A mutation is about to write the whole map back; treating a corrupt
|
|
121
|
+
// file as empty here would make that write finish the corruption off.
|
|
122
|
+
// Abort instead and leave the file for a human. Reads stay tolerant.
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
cache = {};
|
|
126
|
+
}
|
|
127
|
+
cacheMtimeMs = mtimeMs;
|
|
128
|
+
cacheLoadedAt = now;
|
|
129
|
+
cacheValid = true;
|
|
130
|
+
}
|
|
131
|
+
async function persist() {
|
|
132
|
+
const file = { schemaVersion: 1, records: cache };
|
|
133
|
+
await writeJsonSnapshotAtomically(getAuditFilePath(), file);
|
|
134
|
+
try {
|
|
135
|
+
cacheMtimeMs = (await stat(getAuditFilePath())).mtimeMs;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
cacheMtimeMs = -1;
|
|
139
|
+
}
|
|
140
|
+
cacheLoadedAt = Date.now();
|
|
141
|
+
cacheValid = true;
|
|
142
|
+
}
|
|
143
|
+
/** Utilization movement between two observations, in whole percent. */
|
|
144
|
+
function movementPct(before, after) {
|
|
145
|
+
if (before === null || after === null) {
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
return Math.max(0, (after - before) * 100);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Compare one heartbeat against the account's real movement.
|
|
152
|
+
*
|
|
153
|
+
* Pure: the caller supplies the previous observation and the current one, so the
|
|
154
|
+
* decision is inspectable and testable without any I/O.
|
|
155
|
+
*/
|
|
156
|
+
export function evaluateDrift(previous, current, tolerancePct = DRIFT_TOLERANCE_PCT) {
|
|
157
|
+
if (!previous) {
|
|
158
|
+
return { drifted: false, reason: "no_baseline" };
|
|
159
|
+
}
|
|
160
|
+
// The lender's own traffic moves the same windows. With no way to split the
|
|
161
|
+
// two apart, an interval the lender also used is not evidence of anything.
|
|
162
|
+
if (current.lenderRequests > 0) {
|
|
163
|
+
return { drifted: false, reason: "attributable" };
|
|
164
|
+
}
|
|
165
|
+
const sessionMoved = movementPct(previous.sessionUsed, current.sessionUsed);
|
|
166
|
+
const weeklyMoved = movementPct(previous.weeklyUsed, current.weeklyUsed);
|
|
167
|
+
const moved = Math.max(sessionMoved, weeklyMoved);
|
|
168
|
+
if (moved <= tolerancePct) {
|
|
169
|
+
return { drifted: false, reason: "quiet" };
|
|
170
|
+
}
|
|
171
|
+
if (current.reportedCoins > 0) {
|
|
172
|
+
// The borrower owned up to spending. Whether the amount is exactly right is
|
|
173
|
+
// not knowable from utilization alone, and guessing would invent precision.
|
|
174
|
+
return { drifted: false, reason: "attributable" };
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
drifted: true,
|
|
178
|
+
unexplainedSessionPct: sessionMoved,
|
|
179
|
+
unexplainedWeeklyPct: weeklyMoved,
|
|
180
|
+
detail: `the account moved ${moved.toFixed(1)}% of a window since the last check-in ` +
|
|
181
|
+
`while this node served none of it and the borrower reported no spend`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export async function getAuditRecord(grantId) {
|
|
185
|
+
await ensureLoaded();
|
|
186
|
+
return cache[grantId];
|
|
187
|
+
}
|
|
188
|
+
export async function listAuditRecords() {
|
|
189
|
+
await ensureLoaded();
|
|
190
|
+
return Object.values(cache);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Fold a heartbeat into the audit trail.
|
|
194
|
+
*
|
|
195
|
+
* Returns whether the grant has now drifted past tolerance often enough to be
|
|
196
|
+
* paused — the caller owns that decision, because pausing is a policy action and
|
|
197
|
+
* this module only supplies evidence.
|
|
198
|
+
*/
|
|
199
|
+
export async function recordAuditObservation(args) {
|
|
200
|
+
const limit = args.streakLimit ?? DRIFT_STREAK_LIMIT;
|
|
201
|
+
return mutationMutex.runExclusive(async () => {
|
|
202
|
+
// Force: `persist()` writes the whole map back, so a mutation that ran on a
|
|
203
|
+
// TTL-fresh snapshot would resurrect a record the CLI cleared in the window
|
|
204
|
+
// since this process last read the file.
|
|
205
|
+
await ensureLoaded({ force: true });
|
|
206
|
+
const existing = cache[args.grantId];
|
|
207
|
+
// Difference the lifetime counter here so callers can hand over the raw
|
|
208
|
+
// total; an observation always carries the per-interval delta.
|
|
209
|
+
const observation = args.lenderRequestsTotal === undefined
|
|
210
|
+
? args.observation
|
|
211
|
+
: {
|
|
212
|
+
...args.observation,
|
|
213
|
+
lenderRequests: Math.max(0, args.lenderRequestsTotal - (existing?.lenderRequestsTotal ?? 0)),
|
|
214
|
+
};
|
|
215
|
+
const verdict = evaluateDrift(existing?.lastObservation, observation, args.tolerancePct);
|
|
216
|
+
const streak = verdict.drifted ? (existing?.driftStreak ?? 0) + 1 : 0;
|
|
217
|
+
const record = {
|
|
218
|
+
grantId: args.grantId,
|
|
219
|
+
accountLabel: args.accountLabel,
|
|
220
|
+
lastObservation: observation,
|
|
221
|
+
...(args.lenderRequestsTotal !== undefined
|
|
222
|
+
? { lenderRequestsTotal: args.lenderRequestsTotal }
|
|
223
|
+
: existing?.lenderRequestsTotal !== undefined
|
|
224
|
+
? { lenderRequestsTotal: existing.lenderRequestsTotal }
|
|
225
|
+
: {}),
|
|
226
|
+
driftStreak: streak,
|
|
227
|
+
...(verdict.drifted
|
|
228
|
+
? { lastDriftAt: observation.at, lastDriftDetail: verdict.detail }
|
|
229
|
+
: {
|
|
230
|
+
...(existing?.lastDriftAt !== undefined
|
|
231
|
+
? { lastDriftAt: existing.lastDriftAt }
|
|
232
|
+
: {}),
|
|
233
|
+
...(existing?.lastDriftDetail !== undefined
|
|
234
|
+
? { lastDriftDetail: existing.lastDriftDetail }
|
|
235
|
+
: {}),
|
|
236
|
+
}),
|
|
237
|
+
...(existing?.autoPausedAt !== undefined
|
|
238
|
+
? { autoPausedAt: existing.autoPausedAt }
|
|
239
|
+
: {}),
|
|
240
|
+
};
|
|
241
|
+
const shouldPause = streak >= limit && record.autoPausedAt === undefined;
|
|
242
|
+
if (shouldPause) {
|
|
243
|
+
record.autoPausedAt = observation.at;
|
|
244
|
+
}
|
|
245
|
+
cache[args.grantId] = record;
|
|
246
|
+
await persist();
|
|
247
|
+
return { verdict, shouldPause };
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Clear a grant's drift streak and its auto-pause marker.
|
|
252
|
+
*
|
|
253
|
+
* Called when the lender resumes a grant. Without it the marker is permanent
|
|
254
|
+
* and `shouldPause` can never fire again, so a grant auto-paused once would
|
|
255
|
+
* drift freely for the rest of its life. The observation baseline is kept: it
|
|
256
|
+
* is the account's real utilization and is still the right thing to difference
|
|
257
|
+
* the next heartbeat against.
|
|
258
|
+
*/
|
|
259
|
+
export async function clearAuditDrift(grantId) {
|
|
260
|
+
await mutationMutex.runExclusive(async () => {
|
|
261
|
+
await ensureLoaded({ force: true });
|
|
262
|
+
const record = cache[grantId];
|
|
263
|
+
if (!record) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const { autoPausedAt: _autoPausedAt, ...rest } = record;
|
|
267
|
+
cache[grantId] = { ...rest, driftStreak: 0 };
|
|
268
|
+
await persist();
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
/** Forget a grant's audit trail — used when the grant itself is deleted. */
|
|
272
|
+
export async function clearAuditRecord(grantId) {
|
|
273
|
+
await mutationMutex.runExclusive(async () => {
|
|
274
|
+
await ensureLoaded({ force: true });
|
|
275
|
+
if (cache[grantId]) {
|
|
276
|
+
delete cache[grantId];
|
|
277
|
+
await persist();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-scoped peer-sharing context and the counters the rate gates read.
|
|
3
|
+
*
|
|
4
|
+
* The grant serving a borrowed request has to be visible deep inside the routing
|
|
5
|
+
* path — account selection filters on it, and settlement bills against it — but
|
|
6
|
+
* threading it through every call site of a 9,000-line route module would touch
|
|
7
|
+
* everything and be wrong the first time someone added a code path. An
|
|
8
|
+
* `AsyncLocalStorage` scope is the narrower change: the gate establishes it once
|
|
9
|
+
* per request, and anything downstream in the same async chain (including the
|
|
10
|
+
* `.then()` that settles a finished stream) can ask for it.
|
|
11
|
+
*
|
|
12
|
+
* Requests without a grant — the node's own client on loopback — run with no
|
|
13
|
+
* context at all, so `getShareContext()` returning `undefined` is the normal,
|
|
14
|
+
* hot case and means "this is my own traffic".
|
|
15
|
+
*
|
|
16
|
+
* @module proxy/shareContext
|
|
17
|
+
*/
|
|
18
|
+
import type { ProxyShareRequestContext } from "../types/index.js";
|
|
19
|
+
/** Read the counters a policy evaluation needs, without mutating them. */
|
|
20
|
+
export declare function readShareCounters(grantId: string, now?: number): {
|
|
21
|
+
requestsInLastMinute: number;
|
|
22
|
+
inFlight: number;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Claim a concurrency slot and record the request against the rate window.
|
|
26
|
+
* Returns the release function; it is idempotent because a request can end more
|
|
27
|
+
* than one way (normal return, thrown error, client disconnect) and double
|
|
28
|
+
* release would let a grant exceed its concurrency ceiling.
|
|
29
|
+
*/
|
|
30
|
+
export declare function acquireShareSlot(grantId: string, now?: number): () => void;
|
|
31
|
+
/** Run `fn` with the grant visible to everything downstream. */
|
|
32
|
+
export declare function runWithShareContext<T>(context: ProxyShareRequestContext, fn: () => T): T;
|
|
33
|
+
/** The grant serving the current request, or undefined for the node's own traffic. */
|
|
34
|
+
export declare function getShareContext(): ProxyShareRequestContext | undefined;
|
|
35
|
+
/** True when the current request is borrowed rather than the node's own. */
|
|
36
|
+
export declare function isBorrowedRequest(): boolean;
|
|
37
|
+
/** Drop all counters. Test isolation only. */
|
|
38
|
+
export declare function resetShareCountersForTests(): void;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-scoped peer-sharing context and the counters the rate gates read.
|
|
3
|
+
*
|
|
4
|
+
* The grant serving a borrowed request has to be visible deep inside the routing
|
|
5
|
+
* path — account selection filters on it, and settlement bills against it — but
|
|
6
|
+
* threading it through every call site of a 9,000-line route module would touch
|
|
7
|
+
* everything and be wrong the first time someone added a code path. An
|
|
8
|
+
* `AsyncLocalStorage` scope is the narrower change: the gate establishes it once
|
|
9
|
+
* per request, and anything downstream in the same async chain (including the
|
|
10
|
+
* `.then()` that settles a finished stream) can ask for it.
|
|
11
|
+
*
|
|
12
|
+
* Requests without a grant — the node's own client on loopback — run with no
|
|
13
|
+
* context at all, so `getShareContext()` returning `undefined` is the normal,
|
|
14
|
+
* hot case and means "this is my own traffic".
|
|
15
|
+
*
|
|
16
|
+
* @module proxy/shareContext
|
|
17
|
+
*/
|
|
18
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
19
|
+
const storage = new AsyncLocalStorage();
|
|
20
|
+
const counters = new Map();
|
|
21
|
+
const RATE_WINDOW_MS = 60_000;
|
|
22
|
+
function getCounters(grantId) {
|
|
23
|
+
let entry = counters.get(grantId);
|
|
24
|
+
if (!entry) {
|
|
25
|
+
entry = { timestamps: [], inFlight: 0 };
|
|
26
|
+
counters.set(grantId, entry);
|
|
27
|
+
}
|
|
28
|
+
return entry;
|
|
29
|
+
}
|
|
30
|
+
/** Drop timestamps that have aged out of the one-minute window. */
|
|
31
|
+
function prune(entry, now) {
|
|
32
|
+
if (entry.timestamps.length === 0) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const cutoff = now - RATE_WINDOW_MS;
|
|
36
|
+
let firstLive = 0;
|
|
37
|
+
while (firstLive < entry.timestamps.length &&
|
|
38
|
+
entry.timestamps[firstLive] <= cutoff) {
|
|
39
|
+
firstLive += 1;
|
|
40
|
+
}
|
|
41
|
+
if (firstLive > 0) {
|
|
42
|
+
entry.timestamps.splice(0, firstLive);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Read the counters a policy evaluation needs, without mutating them. */
|
|
46
|
+
export function readShareCounters(grantId, now = Date.now()) {
|
|
47
|
+
const entry = counters.get(grantId);
|
|
48
|
+
if (!entry) {
|
|
49
|
+
return { requestsInLastMinute: 0, inFlight: 0 };
|
|
50
|
+
}
|
|
51
|
+
prune(entry, now);
|
|
52
|
+
return {
|
|
53
|
+
requestsInLastMinute: entry.timestamps.length,
|
|
54
|
+
inFlight: entry.inFlight,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Claim a concurrency slot and record the request against the rate window.
|
|
59
|
+
* Returns the release function; it is idempotent because a request can end more
|
|
60
|
+
* than one way (normal return, thrown error, client disconnect) and double
|
|
61
|
+
* release would let a grant exceed its concurrency ceiling.
|
|
62
|
+
*/
|
|
63
|
+
export function acquireShareSlot(grantId, now = Date.now()) {
|
|
64
|
+
const entry = getCounters(grantId);
|
|
65
|
+
prune(entry, now);
|
|
66
|
+
entry.timestamps.push(now);
|
|
67
|
+
entry.inFlight += 1;
|
|
68
|
+
let released = false;
|
|
69
|
+
return () => {
|
|
70
|
+
if (released) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
released = true;
|
|
74
|
+
entry.inFlight = Math.max(0, entry.inFlight - 1);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** Run `fn` with the grant visible to everything downstream. */
|
|
78
|
+
export function runWithShareContext(context, fn) {
|
|
79
|
+
return storage.run(context, fn);
|
|
80
|
+
}
|
|
81
|
+
/** The grant serving the current request, or undefined for the node's own traffic. */
|
|
82
|
+
export function getShareContext() {
|
|
83
|
+
return storage.getStore();
|
|
84
|
+
}
|
|
85
|
+
/** True when the current request is borrowed rather than the node's own. */
|
|
86
|
+
export function isBorrowedRequest() {
|
|
87
|
+
return storage.getStore() !== undefined;
|
|
88
|
+
}
|
|
89
|
+
/** Drop all counters. Test isolation only. */
|
|
90
|
+
export function resetShareCountersForTests() {
|
|
91
|
+
counters.clear();
|
|
92
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound gate for borrowed traffic.
|
|
3
|
+
*
|
|
4
|
+
* Transport-agnostic on purpose: it takes a header bag and returns a decision,
|
|
5
|
+
* so the same gate covers the Anthropic, OpenAI and Codex route groups from one
|
|
6
|
+
* place in the server bootstrap instead of being re-implemented per engine.
|
|
7
|
+
*
|
|
8
|
+
* **The refusal contract is the load-bearing part.** A borrower must be able to
|
|
9
|
+
* tell "you are out of credit" from "the upstream throttled me". If both arrive
|
|
10
|
+
* as a bare 429, the borrower's cooldown planner treats an exhausted grant as a
|
|
11
|
+
* transient rate limit and retries a peer that will never serve it again this
|
|
12
|
+
* week. Every refusal therefore carries `x-neurolink-grant-status` and
|
|
13
|
+
* `x-neurolink-grant-reason`, and the borrower routes on those, not the status
|
|
14
|
+
* code.
|
|
15
|
+
*
|
|
16
|
+
* @module proxy/shareGate
|
|
17
|
+
*/
|
|
18
|
+
import type { ProxyShareGateOutcome, ProxyShareGrant, ProxyShareRefusalReason, ProxyShareRefusalResponse } from "../types/index.js";
|
|
19
|
+
/** Dedicated header, so a share token never collides with a client credential. */
|
|
20
|
+
export declare const SHARE_TOKEN_HEADER = "x-neurolink-share-token";
|
|
21
|
+
export declare const GRANT_STATUS_HEADER = "x-neurolink-grant-status";
|
|
22
|
+
export declare const GRANT_REASON_HEADER = "x-neurolink-grant-reason";
|
|
23
|
+
export declare const GRANT_COINS_HEADER = "x-neurolink-grant-remaining-coins";
|
|
24
|
+
export declare const GRANT_PEER_HEADER = "x-neurolink-grant-peer";
|
|
25
|
+
/**
|
|
26
|
+
* Pull a share token out of the request.
|
|
27
|
+
*
|
|
28
|
+
* `Authorization: Bearer` is accepted only when the value carries our token
|
|
29
|
+
* prefix — a bare client forwarding its own Anthropic credential must never be
|
|
30
|
+
* mistaken for a borrower, and a borrower must never have its token confused
|
|
31
|
+
* with an upstream credential.
|
|
32
|
+
*/
|
|
33
|
+
export declare function extractShareToken(headers: Record<string, string | undefined>): string | undefined;
|
|
34
|
+
export declare function buildShareRefusal(reason: ProxyShareRefusalReason, options: {
|
|
35
|
+
status: number;
|
|
36
|
+
grant?: ProxyShareGrant;
|
|
37
|
+
retryAfterSeconds?: number;
|
|
38
|
+
message?: string;
|
|
39
|
+
}): ProxyShareRefusalResponse;
|
|
40
|
+
/**
|
|
41
|
+
* Should an untokened request be refused?
|
|
42
|
+
*
|
|
43
|
+
* Off by default so a node's own client keeps working on loopback exactly as
|
|
44
|
+
* before. It must be turned on before the listener is exposed — an exposed
|
|
45
|
+
* listener without it hands the lender's subscription to anyone who can reach
|
|
46
|
+
* the tunnel.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isGrantRequiredByEnv(): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Decide whether an inbound request may proceed.
|
|
51
|
+
*
|
|
52
|
+
* An admitted outcome owns a concurrency slot; the caller **must** call
|
|
53
|
+
* `release()` when the request finishes, including on error and on client
|
|
54
|
+
* disconnect, or the grant's concurrency ceiling leaks.
|
|
55
|
+
*/
|
|
56
|
+
export declare function admitInboundShareRequest(input: {
|
|
57
|
+
headers: Record<string, string | undefined>;
|
|
58
|
+
model?: string;
|
|
59
|
+
/** Requested output ceiling, used only to size the pre-authorization. */
|
|
60
|
+
maxTokens?: number;
|
|
61
|
+
requireGrant?: boolean;
|
|
62
|
+
coinBalanceLookup?: (grant: ProxyShareGrant) => Promise<number | undefined>;
|
|
63
|
+
now?: number;
|
|
64
|
+
}): Promise<ProxyShareGateOutcome>;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound gate for borrowed traffic.
|
|
3
|
+
*
|
|
4
|
+
* Transport-agnostic on purpose: it takes a header bag and returns a decision,
|
|
5
|
+
* so the same gate covers the Anthropic, OpenAI and Codex route groups from one
|
|
6
|
+
* place in the server bootstrap instead of being re-implemented per engine.
|
|
7
|
+
*
|
|
8
|
+
* **The refusal contract is the load-bearing part.** A borrower must be able to
|
|
9
|
+
* tell "you are out of credit" from "the upstream throttled me". If both arrive
|
|
10
|
+
* as a bare 429, the borrower's cooldown planner treats an exhausted grant as a
|
|
11
|
+
* transient rate limit and retries a peer that will never serve it again this
|
|
12
|
+
* week. Every refusal therefore carries `x-neurolink-grant-status` and
|
|
13
|
+
* `x-neurolink-grant-reason`, and the borrower routes on those, not the status
|
|
14
|
+
* code.
|
|
15
|
+
*
|
|
16
|
+
* @module proxy/shareGate
|
|
17
|
+
*/
|
|
18
|
+
import { logger } from "../utils/logger.js";
|
|
19
|
+
import { acquireShareSlot, readShareCounters } from "./shareContext.js";
|
|
20
|
+
import { looksLikeShareToken, resolveShareToken, touchShareGrantUsage, } from "./shareGrants.js";
|
|
21
|
+
import { applyRefillIfDue, availableCoins, estimateHoldCoins, openShareHold, releaseShareHold, } from "./shareLedger.js";
|
|
22
|
+
import { evaluateShareAdmission, isShareRefusal, shareRefusalMessage, } from "./sharePolicy.js";
|
|
23
|
+
/** Dedicated header, so a share token never collides with a client credential. */
|
|
24
|
+
export const SHARE_TOKEN_HEADER = "x-neurolink-share-token";
|
|
25
|
+
export const GRANT_STATUS_HEADER = "x-neurolink-grant-status";
|
|
26
|
+
export const GRANT_REASON_HEADER = "x-neurolink-grant-reason";
|
|
27
|
+
export const GRANT_COINS_HEADER = "x-neurolink-grant-remaining-coins";
|
|
28
|
+
export const GRANT_PEER_HEADER = "x-neurolink-grant-peer";
|
|
29
|
+
/**
|
|
30
|
+
* Pull a share token out of the request.
|
|
31
|
+
*
|
|
32
|
+
* `Authorization: Bearer` is accepted only when the value carries our token
|
|
33
|
+
* prefix — a bare client forwarding its own Anthropic credential must never be
|
|
34
|
+
* mistaken for a borrower, and a borrower must never have its token confused
|
|
35
|
+
* with an upstream credential.
|
|
36
|
+
*/
|
|
37
|
+
export function extractShareToken(headers) {
|
|
38
|
+
const dedicated = headers[SHARE_TOKEN_HEADER];
|
|
39
|
+
if (dedicated?.trim()) {
|
|
40
|
+
return dedicated.trim();
|
|
41
|
+
}
|
|
42
|
+
const authorization = headers.authorization ?? headers.Authorization;
|
|
43
|
+
if (!authorization) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
const trimmed = authorization.trim();
|
|
47
|
+
const bearer = trimmed.toLowerCase().startsWith("bearer ")
|
|
48
|
+
? trimmed.slice("bearer ".length).trim()
|
|
49
|
+
: trimmed;
|
|
50
|
+
return looksLikeShareToken(bearer) ? bearer : undefined;
|
|
51
|
+
}
|
|
52
|
+
/** Anthropic-shaped error type for a status, so clients parse it as usual. */
|
|
53
|
+
function errorTypeForStatus(status) {
|
|
54
|
+
if (status === 401) {
|
|
55
|
+
return "authentication_error";
|
|
56
|
+
}
|
|
57
|
+
if (status === 403) {
|
|
58
|
+
return "permission_error";
|
|
59
|
+
}
|
|
60
|
+
return "rate_limit_error";
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Grant lifecycle state as the borrower sees it. Distinct from the refusal
|
|
64
|
+
* reason: the state answers "can this grant ever serve me again", the reason
|
|
65
|
+
* answers "why not this request".
|
|
66
|
+
*/
|
|
67
|
+
function grantStatusHeaderValue(reason, grant) {
|
|
68
|
+
switch (reason) {
|
|
69
|
+
case "paused":
|
|
70
|
+
return "paused";
|
|
71
|
+
case "revoked":
|
|
72
|
+
return "revoked";
|
|
73
|
+
case "expired":
|
|
74
|
+
return "expired";
|
|
75
|
+
case "exhausted":
|
|
76
|
+
return "exhausted";
|
|
77
|
+
case "out_of_window":
|
|
78
|
+
return "out-of-window";
|
|
79
|
+
case "missing_token":
|
|
80
|
+
case "unknown_token":
|
|
81
|
+
case "malformed_token":
|
|
82
|
+
return "unauthorized";
|
|
83
|
+
default:
|
|
84
|
+
return grant?.state ?? "active";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function buildShareRefusal(reason, options) {
|
|
88
|
+
const { status, grant, retryAfterSeconds } = options;
|
|
89
|
+
const headers = {
|
|
90
|
+
[GRANT_STATUS_HEADER]: grantStatusHeaderValue(reason, grant),
|
|
91
|
+
[GRANT_REASON_HEADER]: reason,
|
|
92
|
+
};
|
|
93
|
+
if (grant) {
|
|
94
|
+
headers[GRANT_PEER_HEADER] = grant.peerLabel;
|
|
95
|
+
if (grant.entitlement.ledger === "coins") {
|
|
96
|
+
headers[GRANT_COINS_HEADER] = String(Math.max(0, Math.floor(grant.entitlement.coins ?? 0)));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (retryAfterSeconds !== undefined) {
|
|
100
|
+
headers["retry-after"] = String(Math.max(1, Math.round(retryAfterSeconds)));
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
status,
|
|
104
|
+
headers,
|
|
105
|
+
body: {
|
|
106
|
+
type: "error",
|
|
107
|
+
error: {
|
|
108
|
+
type: errorTypeForStatus(status),
|
|
109
|
+
message: options.message ?? shareRefusalMessage(reason),
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function refusalOutcome(reason, options) {
|
|
115
|
+
return { kind: "refused", response: buildShareRefusal(reason, options) };
|
|
116
|
+
}
|
|
117
|
+
function toContext(grant, extra) {
|
|
118
|
+
return {
|
|
119
|
+
grantId: grant.id,
|
|
120
|
+
peerLabel: grant.peerLabel,
|
|
121
|
+
level: grant.level,
|
|
122
|
+
gates: grant.gates,
|
|
123
|
+
ledger: grant.entitlement.ledger,
|
|
124
|
+
...(extra.holdId ? { holdId: extra.holdId } : {}),
|
|
125
|
+
...(extra.model ? { model: extra.model } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Should an untokened request be refused?
|
|
130
|
+
*
|
|
131
|
+
* Off by default so a node's own client keeps working on loopback exactly as
|
|
132
|
+
* before. It must be turned on before the listener is exposed — an exposed
|
|
133
|
+
* listener without it hands the lender's subscription to anyone who can reach
|
|
134
|
+
* the tunnel.
|
|
135
|
+
*/
|
|
136
|
+
export function isGrantRequiredByEnv() {
|
|
137
|
+
const raw = (process.env.NEUROLINK_PROXY_REQUIRE_GRANT ?? "")
|
|
138
|
+
.trim()
|
|
139
|
+
.toLowerCase();
|
|
140
|
+
return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Decide whether an inbound request may proceed.
|
|
144
|
+
*
|
|
145
|
+
* An admitted outcome owns a concurrency slot; the caller **must** call
|
|
146
|
+
* `release()` when the request finishes, including on error and on client
|
|
147
|
+
* disconnect, or the grant's concurrency ceiling leaks.
|
|
148
|
+
*/
|
|
149
|
+
export async function admitInboundShareRequest(input) {
|
|
150
|
+
const now = input.now ?? Date.now();
|
|
151
|
+
const requireGrant = input.requireGrant ?? isGrantRequiredByEnv();
|
|
152
|
+
const token = extractShareToken(input.headers);
|
|
153
|
+
if (!token) {
|
|
154
|
+
if (!requireGrant) {
|
|
155
|
+
return { kind: "local" };
|
|
156
|
+
}
|
|
157
|
+
return refusalOutcome("missing_token", {
|
|
158
|
+
status: 401,
|
|
159
|
+
message: "This proxy requires a share token. Ask the lender for one with " +
|
|
160
|
+
"`neurolink proxy share create`.",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const resolved = await resolveShareToken(token);
|
|
164
|
+
if (!resolved) {
|
|
165
|
+
return refusalOutcome("unknown_token", { status: 401 });
|
|
166
|
+
}
|
|
167
|
+
// A standing allowance tops up here rather than on a timer: a node that was
|
|
168
|
+
// asleep across the boundary still refills on the next borrowed request.
|
|
169
|
+
const grant = resolved.state === "active"
|
|
170
|
+
? await applyRefillIfDue(resolved, now)
|
|
171
|
+
: resolved;
|
|
172
|
+
const coinBalance = grant.entitlement.ledger === "coins"
|
|
173
|
+
? ((await input.coinBalanceLookup?.(grant)) ?? availableCoins(grant))
|
|
174
|
+
: undefined;
|
|
175
|
+
const admission = evaluateShareAdmission({
|
|
176
|
+
grant,
|
|
177
|
+
now,
|
|
178
|
+
model: input.model,
|
|
179
|
+
counters: readShareCounters(grant.id, now),
|
|
180
|
+
...(coinBalance !== undefined ? { coinBalance } : {}),
|
|
181
|
+
});
|
|
182
|
+
if (isShareRefusal(admission)) {
|
|
183
|
+
logger.debug(`[proxy] share refused peer=${grant.peerLabel} reason=${admission.reason}`);
|
|
184
|
+
return refusalOutcome(admission.reason, {
|
|
185
|
+
status: admission.status,
|
|
186
|
+
grant,
|
|
187
|
+
...(admission.retryAfterSeconds !== undefined
|
|
188
|
+
? { retryAfterSeconds: admission.retryAfterSeconds }
|
|
189
|
+
: {}),
|
|
190
|
+
message: admission.message,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
// Pre-authorize before the request goes upstream. Without this, concurrent
|
|
194
|
+
// streams each pass the balance check and settle afterwards, overspending by
|
|
195
|
+
// one request per stream in flight.
|
|
196
|
+
const hold = grant.entitlement.ledger === "coins"
|
|
197
|
+
? openShareHold(grant.id, estimateHoldCoins(input.model, input.maxTokens), now)
|
|
198
|
+
: undefined;
|
|
199
|
+
const releaseSlot = acquireShareSlot(grant.id, now);
|
|
200
|
+
// Cosmetic, in-memory, flushed at most once a minute — see the store.
|
|
201
|
+
touchShareGrantUsage(grant.id);
|
|
202
|
+
return {
|
|
203
|
+
kind: "admitted",
|
|
204
|
+
context: toContext(grant, {
|
|
205
|
+
...(hold ? { holdId: hold.id } : {}),
|
|
206
|
+
...(input.model ? { model: input.model } : {}),
|
|
207
|
+
}),
|
|
208
|
+
release: () => {
|
|
209
|
+
releaseSlot();
|
|
210
|
+
// Settlement normally consumes the hold; releasing here covers the paths
|
|
211
|
+
// that never settle at all — a handler that threw, a client that vanished
|
|
212
|
+
// before the upstream answered.
|
|
213
|
+
releaseShareHold(hold?.id);
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|