@juspay/neurolink 11.29.2 → 11.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/auth/anthropicOAuth.d.ts +50 -0
  3. package/dist/auth/anthropicOAuth.js +78 -0
  4. package/dist/browser/neurolink.min.js +393 -393
  5. package/dist/cli/commands/proxy.d.ts +2 -0
  6. package/dist/cli/commands/proxy.js +284 -4
  7. package/dist/cli/commands/proxyExpose.d.ts +35 -0
  8. package/dist/cli/commands/proxyExpose.js +252 -0
  9. package/dist/cli/commands/proxyPeer.d.ts +29 -0
  10. package/dist/cli/commands/proxyPeer.js +738 -0
  11. package/dist/cli/commands/proxyShare.d.ts +37 -0
  12. package/dist/cli/commands/proxyShare.js +1080 -0
  13. package/dist/cli/parser.js +7 -1
  14. package/dist/proxy/peerStore.d.ts +52 -0
  15. package/dist/proxy/peerStore.js +324 -0
  16. package/dist/proxy/peerTransport.d.ts +38 -0
  17. package/dist/proxy/peerTransport.js +242 -0
  18. package/dist/proxy/proxyPaths.d.ts +8 -0
  19. package/dist/proxy/proxyPaths.js +55 -17
  20. package/dist/proxy/requestLogger.js +8 -0
  21. package/dist/proxy/residentGrants.d.ts +57 -0
  22. package/dist/proxy/residentGrants.js +393 -0
  23. package/dist/proxy/shareAudit.d.ts +81 -0
  24. package/dist/proxy/shareAudit.js +280 -0
  25. package/dist/proxy/shareContext.d.ts +38 -0
  26. package/dist/proxy/shareContext.js +92 -0
  27. package/dist/proxy/shareGate.d.ts +64 -0
  28. package/dist/proxy/shareGate.js +216 -0
  29. package/dist/proxy/shareGrants.d.ts +115 -0
  30. package/dist/proxy/shareGrants.js +590 -0
  31. package/dist/proxy/shareLease.d.ts +101 -0
  32. package/dist/proxy/shareLease.js +192 -0
  33. package/dist/proxy/shareLedger.d.ts +105 -0
  34. package/dist/proxy/shareLedger.js +406 -0
  35. package/dist/proxy/shareListener.d.ts +60 -0
  36. package/dist/proxy/shareListener.js +143 -0
  37. package/dist/proxy/shareNotes.d.ts +97 -0
  38. package/dist/proxy/shareNotes.js +234 -0
  39. package/dist/proxy/sharePolicy.d.ts +110 -0
  40. package/dist/proxy/sharePolicy.js +366 -0
  41. package/dist/proxy/shareProvisioning.d.ts +110 -0
  42. package/dist/proxy/shareProvisioning.js +237 -0
  43. package/dist/proxy/shareReceipts.d.ts +99 -0
  44. package/dist/proxy/shareReceipts.js +303 -0
  45. package/dist/proxy/shareSigning.d.ts +40 -0
  46. package/dist/proxy/shareSigning.js +78 -0
  47. package/dist/server/routes/claudeProxyRoutes.js +1066 -3
  48. package/dist/types/cli.d.ts +61 -0
  49. package/dist/types/proxy.d.ts +781 -0
  50. package/package.json +2 -1
@@ -0,0 +1,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
+ }