@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,406 @@
1
+ /**
2
+ * NeuroCoin ledger and per-window consumption tracking for borrowed traffic.
3
+ *
4
+ * Two jobs, deliberately in one module because they settle from the same event:
5
+ *
6
+ * 1. **Coins.** A grant with a coin entitlement spends against a balance. The
7
+ * balance itself lives on the grant (one source of truth); this module holds
8
+ * the entries and drives the deduction.
9
+ * 2. **Window buckets.** How much of an account's 5h/7d window a grant has taken,
10
+ * keyed by that window's reset timestamp so the counter starts fresh when the
11
+ * window does. This is what makes a slice ceiling mean "a fifth of *this*
12
+ * window" rather than "a fifth, once, forever".
13
+ *
14
+ * **Why hold-then-settle.** Real usage is only known when the response finishes
15
+ * — for a stream, at `message_delta`. A balance checked at admission and
16
+ * deducted at completion lets N concurrent streams each pass the same check and
17
+ * overspend by N-1 requests. So admission opens a *hold* for an estimate, the
18
+ * available balance is `balance - Σ open holds`, and settlement replaces the
19
+ * hold with the real figure. A crash loses in-flight holds, which is the safe
20
+ * direction: the balance is only ever reduced by traffic that actually happened.
21
+ *
22
+ * @module proxy/shareLedger
23
+ */
24
+ import { randomUUID } from "node:crypto";
25
+ import { readFile } from "node:fs/promises";
26
+ import { homedir } from "node:os";
27
+ import { join } from "node:path";
28
+ import { AsyncMutex } from "../utils/asyncMutex.js";
29
+ import { logger } from "../utils/logger.js";
30
+ import { debitShareGrantCoins, updateShareGrant } from "./shareGrants.js";
31
+ import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
32
+ const LEDGER_FILE = "proxy-share-ledger.json";
33
+ /** Normalized tokens per coin. */
34
+ export const TOKENS_PER_COIN = 1000;
35
+ /**
36
+ * Output tokens cost materially more than input everywhere, and cache reads
37
+ * cost almost nothing. Weighting by that keeps a coin comparable across very
38
+ * different request shapes rather than rewarding one long prompt over many
39
+ * short ones.
40
+ */
41
+ const INPUT_WEIGHT = 1;
42
+ const OUTPUT_WEIGHT = 4;
43
+ const CACHE_CREATE_WEIGHT = 1.25;
44
+ const CACHE_READ_WEIGHT = 0.1;
45
+ /** Tier multipliers, mirroring the published price ratios. */
46
+ const MODEL_WEIGHTS = [
47
+ ["haiku", 0.25],
48
+ ["sonnet", 1],
49
+ ["opus", 5],
50
+ ["fable", 5],
51
+ ];
52
+ let customLedgerFilePath = null;
53
+ let buckets = {};
54
+ let loaded = false;
55
+ const mutationMutex = new AsyncMutex();
56
+ /** Open holds, in memory only — they are request-scoped by definition. */
57
+ const holds = new Map();
58
+ export function initShareLedger(ledgerFilePath) {
59
+ customLedgerFilePath = ledgerFilePath;
60
+ buckets = {};
61
+ loaded = false;
62
+ holds.clear();
63
+ }
64
+ function getLedgerFilePath() {
65
+ return customLedgerFilePath ?? join(homedir(), ".neurolink", LEDGER_FILE);
66
+ }
67
+ function bucketKey(grantId, accountKey) {
68
+ return `${grantId}|${accountKey}`;
69
+ }
70
+ export function modelCoinWeight(model) {
71
+ if (!model) {
72
+ return 1;
73
+ }
74
+ const normalized = model.toLowerCase();
75
+ for (const [token, weight] of MODEL_WEIGHTS) {
76
+ if (normalized.includes(token)) {
77
+ return weight;
78
+ }
79
+ }
80
+ return 1;
81
+ }
82
+ /** Convert real usage into coins. Pure — the pricing table is the whole story. */
83
+ export function usageToCoins(usage, model) {
84
+ const normalized = usage.inputTokens * INPUT_WEIGHT +
85
+ usage.outputTokens * OUTPUT_WEIGHT +
86
+ (usage.cacheCreationTokens ?? 0) * CACHE_CREATE_WEIGHT +
87
+ (usage.cacheReadTokens ?? 0) * CACHE_READ_WEIGHT;
88
+ return (normalized * modelCoinWeight(model)) / TOKENS_PER_COIN;
89
+ }
90
+ /**
91
+ * What to hold at admission, before anything is known about the response.
92
+ *
93
+ * Intentionally rough: the hold exists to stop concurrent streams from each
94
+ * spending the last coin, not to predict the bill. Settlement replaces it with
95
+ * the real figure moments later.
96
+ */
97
+ export function estimateHoldCoins(model, maxTokens) {
98
+ const assumedOutput = Math.min(Math.max(maxTokens ?? 4096, 256), 64_000);
99
+ return usageToCoins({ inputTokens: 2000, outputTokens: assumedOutput }, model);
100
+ }
101
+ function isBucket(value) {
102
+ if (!value || typeof value !== "object") {
103
+ return false;
104
+ }
105
+ const candidate = value;
106
+ return (typeof candidate.grantId === "string" &&
107
+ typeof candidate.accountKey === "string" &&
108
+ typeof candidate.sessionFraction === "number" &&
109
+ typeof candidate.weeklyFraction === "number");
110
+ }
111
+ async function ensureLoaded() {
112
+ if (loaded) {
113
+ return;
114
+ }
115
+ try {
116
+ const parsed = JSON.parse(await readFile(getLedgerFilePath(), "utf8"));
117
+ buckets = Object.fromEntries(Object.entries(parsed?.buckets ?? {}).filter((entry) => isBucket(entry[1])));
118
+ }
119
+ catch {
120
+ buckets = {};
121
+ }
122
+ loaded = true;
123
+ }
124
+ async function persist() {
125
+ const file = { schemaVersion: 1, buckets };
126
+ await writeJsonSnapshotAtomically(getLedgerFilePath(), file);
127
+ }
128
+ /** Coins currently held against a grant by in-flight requests. */
129
+ export function heldCoins(grantId) {
130
+ let total = 0;
131
+ for (const hold of holds.values()) {
132
+ if (hold.grantId === grantId) {
133
+ total += hold.coins;
134
+ }
135
+ }
136
+ return total;
137
+ }
138
+ /**
139
+ * Balance a new request may draw on: the stored balance minus what in-flight
140
+ * requests have already claimed.
141
+ */
142
+ export function availableCoins(grant) {
143
+ if (grant.entitlement.ledger !== "coins") {
144
+ return Number.POSITIVE_INFINITY;
145
+ }
146
+ return (grant.entitlement.coins ?? 0) - heldCoins(grant.id);
147
+ }
148
+ export function openShareHold(grantId, coins, now = Date.now()) {
149
+ const hold = {
150
+ id: randomUUID(),
151
+ grantId,
152
+ coins: Math.max(0, coins),
153
+ openedAt: now,
154
+ };
155
+ holds.set(hold.id, hold);
156
+ return hold;
157
+ }
158
+ /** Drop a hold without spending it — the request never reached the upstream. */
159
+ export function releaseShareHold(holdId) {
160
+ if (holdId) {
161
+ holds.delete(holdId);
162
+ }
163
+ }
164
+ /**
165
+ * Fold a utilization observation into a bucket.
166
+ *
167
+ * A reset timestamp that moved means the window rolled over, so the accumulated
168
+ * fraction is discarded rather than carried into a window it does not describe.
169
+ * A negative delta (the reset raced the response) is clamped to zero.
170
+ */
171
+ function applyWindowDelta(bucket, settlement) {
172
+ const sessionReset = settlement.sessionResetAt ?? null;
173
+ if (sessionReset !== bucket.sessionResetAt) {
174
+ bucket.sessionResetAt = sessionReset;
175
+ bucket.sessionFraction = 0;
176
+ }
177
+ const weeklyReset = settlement.weeklyResetAt ?? null;
178
+ if (weeklyReset !== bucket.weeklyResetAt) {
179
+ bucket.weeklyResetAt = weeklyReset;
180
+ bucket.weeklyFraction = 0;
181
+ }
182
+ if (settlement.sessionBefore !== null &&
183
+ settlement.sessionBefore !== undefined &&
184
+ settlement.sessionAfter !== null &&
185
+ settlement.sessionAfter !== undefined) {
186
+ bucket.sessionFraction += Math.max(0, settlement.sessionAfter - settlement.sessionBefore);
187
+ }
188
+ if (settlement.weeklyBefore !== null &&
189
+ settlement.weeklyBefore !== undefined &&
190
+ settlement.weeklyAfter !== null &&
191
+ settlement.weeklyAfter !== undefined) {
192
+ bucket.weeklyFraction += Math.max(0, settlement.weeklyAfter - settlement.weeklyBefore);
193
+ }
194
+ }
195
+ function emptyBucket(grantId, accountKey, sessionResetAt, weeklyResetAt) {
196
+ return {
197
+ grantId,
198
+ accountKey,
199
+ sessionResetAt,
200
+ weeklyResetAt,
201
+ sessionFraction: 0,
202
+ weeklyFraction: 0,
203
+ coinsSpent: 0,
204
+ requests: 0,
205
+ updatedAt: Date.now(),
206
+ };
207
+ }
208
+ /**
209
+ * Record how much of an account's windows a borrowed request consumed.
210
+ *
211
+ * Never throws: a bookkeeping failure must not turn a response the borrower has
212
+ * already received into an error.
213
+ */
214
+ export async function recordShareWindowDelta(observation) {
215
+ try {
216
+ await mutationMutex.runExclusive(async () => {
217
+ await ensureLoaded();
218
+ const key = bucketKey(observation.grantId, observation.accountKey);
219
+ const bucket = buckets[key] ??
220
+ emptyBucket(observation.grantId, observation.accountKey, observation.sessionResetAt ?? null, observation.weeklyResetAt ?? null);
221
+ applyWindowDelta(bucket, observation);
222
+ bucket.updatedAt = Date.now();
223
+ buckets[key] = bucket;
224
+ await persist();
225
+ });
226
+ }
227
+ catch (error) {
228
+ logger.always(`[proxy] share window bookkeeping failed for grant=${observation.grantId}: ${error instanceof Error ? error.message : String(error)}`);
229
+ }
230
+ }
231
+ /**
232
+ * Charge a finished borrowed request against its grant and close the hold.
233
+ *
234
+ * Never throws, for the same reason as above.
235
+ */
236
+ export async function settleShareUsage(settlement) {
237
+ const coins = usageToCoins(settlement.usage, settlement.model);
238
+ try {
239
+ await mutationMutex.runExclusive(async () => {
240
+ await ensureLoaded();
241
+ const key = bucketKey(settlement.grantId, settlement.accountKey);
242
+ const bucket = buckets[key] ??
243
+ emptyBucket(settlement.grantId, settlement.accountKey, null, null);
244
+ bucket.coinsSpent += coins;
245
+ bucket.requests += 1;
246
+ bucket.updatedAt = Date.now();
247
+ buckets[key] = bucket;
248
+ await persist();
249
+ });
250
+ // Deduction is read-modify-write under the grant store's own lock; doing it
251
+ // here would race a concurrently settling stream and lose one of the two.
252
+ const balanceAfter = await debitShareGrantCoins(settlement.grantId, coins);
253
+ // The borrower's copy of this charge. Imported here rather than at the top
254
+ // because receipts read the price table from this module, and a static
255
+ // import both ways is a cycle.
256
+ const { issueShareReceipt } = await import("./shareReceipts.js");
257
+ await issueShareReceipt({
258
+ grantId: settlement.grantId,
259
+ coins,
260
+ usage: settlement.usage,
261
+ ...(settlement.model ? { model: settlement.model } : {}),
262
+ balanceAfter: balanceAfter ?? null,
263
+ });
264
+ }
265
+ catch (error) {
266
+ logger.always(`[proxy] share settlement failed for grant=${settlement.grantId}: ${error instanceof Error ? error.message : String(error)}`);
267
+ }
268
+ finally {
269
+ releaseShareHold(settlement.holdId);
270
+ }
271
+ return coins;
272
+ }
273
+ /**
274
+ * What a grant has taken from one account's current windows.
275
+ *
276
+ * Buckets whose reset timestamp no longer matches the account's are reported as
277
+ * zero: they describe a window that has since rolled over.
278
+ */
279
+ export async function readShareWindowUsage(grantId, accountKey, currentSessionResetAt, currentWeeklyResetAt) {
280
+ await ensureLoaded();
281
+ const bucket = buckets[bucketKey(grantId, accountKey)];
282
+ if (!bucket) {
283
+ return { sessionFraction: 0, weeklyFraction: 0 };
284
+ }
285
+ return {
286
+ sessionFraction: bucket.sessionResetAt === currentSessionResetAt
287
+ ? bucket.sessionFraction
288
+ : 0,
289
+ weeklyFraction: bucket.weeklyResetAt === currentWeeklyResetAt ? bucket.weeklyFraction : 0,
290
+ };
291
+ }
292
+ /**
293
+ * What a grant has taken from the pool as a whole, normalised to one window.
294
+ *
295
+ * Summing the per-account fractions and dividing by the account count is what
296
+ * makes a ceiling mean the same thing on a one-account pool and a ten-account
297
+ * one. Without the division, `--max-slice 20` would silently grant 20% of every
298
+ * credential — 200% of a single window's worth across ten accounts.
299
+ *
300
+ * Buckets whose reset timestamp no longer matches contribute zero: they
301
+ * describe a window that has since rolled over.
302
+ */
303
+ export async function readSharePoolWindowUsage(grantId, accounts) {
304
+ await ensureLoaded();
305
+ if (accounts.length === 0) {
306
+ return { sessionFraction: 0, weeklyFraction: 0 };
307
+ }
308
+ let session = 0;
309
+ let weekly = 0;
310
+ for (const account of accounts) {
311
+ const bucket = buckets[bucketKey(grantId, account.accountKey)];
312
+ if (!bucket) {
313
+ continue;
314
+ }
315
+ if (bucket.sessionResetAt === account.sessionResetAt) {
316
+ session += bucket.sessionFraction;
317
+ }
318
+ if (bucket.weeklyResetAt === account.weeklyResetAt) {
319
+ weekly += bucket.weeklyFraction;
320
+ }
321
+ }
322
+ return {
323
+ sessionFraction: session / accounts.length,
324
+ weeklyFraction: weekly / accounts.length,
325
+ };
326
+ }
327
+ /** Per-grant rollup across accounts, for `share status`. */
328
+ export async function summarizeGrantUsage(grantId) {
329
+ await ensureLoaded();
330
+ let coinsSpent = 0;
331
+ let requests = 0;
332
+ let accounts = 0;
333
+ let lastUsedAt = null;
334
+ for (const bucket of Object.values(buckets)) {
335
+ if (bucket.grantId !== grantId) {
336
+ continue;
337
+ }
338
+ coinsSpent += bucket.coinsSpent;
339
+ requests += bucket.requests;
340
+ // A bucket exists as soon as a window movement is attributed, which happens
341
+ // before settlement. Counting those would report accounts the grant merely
342
+ // touched as accounts it drew on.
343
+ if (bucket.requests > 0) {
344
+ accounts += 1;
345
+ }
346
+ lastUsedAt = Math.max(lastUsedAt ?? 0, bucket.updatedAt) || null;
347
+ }
348
+ return { grantId, coinsSpent, requests, accounts, lastUsedAt };
349
+ }
350
+ /**
351
+ * Most periods a single catch-up may pay out.
352
+ *
353
+ * A node that was off for a month owes the borrower the periods it missed, but a
354
+ * clock that jumps — a VM restored from a snapshot, a corrected system time —
355
+ * would otherwise mint an unbounded balance in one call.
356
+ */
357
+ const MAX_REFILL_CATCHUP_PERIODS = 8;
358
+ /**
359
+ * Apply a standing allowance for every period that has elapsed.
360
+ *
361
+ * Called opportunistically at admission — there is no timer, so a node that is
362
+ * off across one or more refill boundaries pays them on its next borrowed
363
+ * request. Paying only the newest period would quietly turn "100 a week" into
364
+ * "100 whenever you next happen to ask", which is not what the operator wrote.
365
+ *
366
+ * `lastAt` advances by whole periods rather than to `now`, so the schedule stays
367
+ * anchored to when the grant was issued instead of drifting later with every
368
+ * catch-up. It advances past the periods the cap refused to pay as well — see
369
+ * {@link MAX_REFILL_CATCHUP_PERIODS}.
370
+ */
371
+ export async function applyRefillIfDue(grant, now = Date.now()) {
372
+ const refill = grant.entitlement.refill;
373
+ if (!refill || grant.entitlement.ledger !== "coins") {
374
+ return grant;
375
+ }
376
+ const periodMs = refill.per === "week" ? 604_800_000 : 18_000_000;
377
+ const lastAt = refill.lastAt ?? grant.createdAt;
378
+ const elapsed = now - lastAt;
379
+ if (elapsed < periodMs) {
380
+ return grant;
381
+ }
382
+ const owed = Math.floor(elapsed / periodMs);
383
+ const periods = Math.min(owed, MAX_REFILL_CATCHUP_PERIODS);
384
+ // The cap forfeits the excess rather than carrying it. Advancing `lastAt` by
385
+ // only what was paid would leave the same backlog waiting, and the next few
386
+ // admissions would drain it a cap at a time — which is exactly the unbounded
387
+ // mint the cap exists to prevent, just spread over more calls.
388
+ if (owed > periods) {
389
+ logger.always(`[proxy] grant ${grant.id} skipped ${owed - periods} refill periods — capped at ${MAX_REFILL_CATCHUP_PERIODS}`);
390
+ }
391
+ const topped = (grant.entitlement.coins ?? 0) + refill.amount * periods;
392
+ const updated = await updateShareGrant(grant.id, {
393
+ entitlement: {
394
+ ledger: "coins",
395
+ coins: topped,
396
+ refill: { ...refill, lastAt: lastAt + owed * periodMs },
397
+ },
398
+ });
399
+ return updated ?? grant;
400
+ }
401
+ /** Drop all ledger state. Test isolation only. */
402
+ export function resetShareLedgerForTests() {
403
+ buckets = {};
404
+ loaded = false;
405
+ holds.clear();
406
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The gate-only listener.
3
+ *
4
+ * **Why a second port exists at all.** The share gate refuses requests carrying
5
+ * no token — and the operator's own client carries none. Turning the gate on
6
+ * therefore breaks local use, which is why it started life as an env flag that
7
+ * needed a restart and an informed decision. A loopback allowlist cannot rescue
8
+ * that: cloudflared and every reverse proxy connect from `127.0.0.1`, so
9
+ * tunnelled traffic is indistinguishable from the operator's own by origin.
10
+ *
11
+ * The seam that *does* work is the listener. One port keeps today's behaviour
12
+ * for the operator's own client; a second port requires a grant on every
13
+ * request, and that is the one you expose. Which port a request arrived on is
14
+ * decided by the accepting socket, so nothing a client sends can move it from
15
+ * one side to the other.
16
+ *
17
+ * **Why it starts and stops on its own.** A port that requires a grant is
18
+ * useless with no grants issued, and issuing the first grant is exactly the
19
+ * moment an operator wants somewhere to point a peer. So the listener follows
20
+ * the grant file: it comes up when the first active grant appears and goes away
21
+ * when the last one is revoked, without a restart on either edge.
22
+ *
23
+ * @module proxy/shareListener
24
+ */
25
+ import type { ProxyShareListenerHandle } from "../types/index.js";
26
+ /** Default offset from the main port. Explicit configuration always wins. */
27
+ export declare const SHARE_PORT_OFFSET = 1;
28
+ /**
29
+ * The port the share listener should use.
30
+ *
31
+ * Precedence is the usual one — an explicit flag, then the environment, then a
32
+ * derived default. Deriving `port + 1` rather than picking any free port is
33
+ * deliberate: a peer's saved URL and a named tunnel both outlive a restart, so
34
+ * the address has to be reproducible.
35
+ */
36
+ export declare function resolveSharePort(args: {
37
+ explicit?: number;
38
+ mainPort: number;
39
+ env?: NodeJS.ProcessEnv;
40
+ }): number;
41
+ /** Is the share listener switched off entirely? */
42
+ export declare function isShareListenerDisabled(env?: NodeJS.ProcessEnv): boolean;
43
+ /** Does this node currently lend anything to anyone? */
44
+ export declare function hasActiveShareGrants(): Promise<boolean>;
45
+ /**
46
+ * Keep the share listener's existence in step with the grant file.
47
+ *
48
+ * `start` and `stop` are injected rather than imported so this stays free of the
49
+ * HTTP server: the runtime owns the adaptor, and a poll loop that owns nothing
50
+ * is the part worth testing.
51
+ */
52
+ export declare function superviseShareListener(args: {
53
+ start: () => Promise<ProxyShareListenerHandle>;
54
+ intervalMs?: number;
55
+ /** Consulted instead of the grant file. Tests only. */
56
+ hasGrants?: () => Promise<boolean>;
57
+ }): {
58
+ stop: () => Promise<void>;
59
+ poll: () => Promise<void>;
60
+ };
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The gate-only listener.
3
+ *
4
+ * **Why a second port exists at all.** The share gate refuses requests carrying
5
+ * no token — and the operator's own client carries none. Turning the gate on
6
+ * therefore breaks local use, which is why it started life as an env flag that
7
+ * needed a restart and an informed decision. A loopback allowlist cannot rescue
8
+ * that: cloudflared and every reverse proxy connect from `127.0.0.1`, so
9
+ * tunnelled traffic is indistinguishable from the operator's own by origin.
10
+ *
11
+ * The seam that *does* work is the listener. One port keeps today's behaviour
12
+ * for the operator's own client; a second port requires a grant on every
13
+ * request, and that is the one you expose. Which port a request arrived on is
14
+ * decided by the accepting socket, so nothing a client sends can move it from
15
+ * one side to the other.
16
+ *
17
+ * **Why it starts and stops on its own.** A port that requires a grant is
18
+ * useless with no grants issued, and issuing the first grant is exactly the
19
+ * moment an operator wants somewhere to point a peer. So the listener follows
20
+ * the grant file: it comes up when the first active grant appears and goes away
21
+ * when the last one is revoked, without a restart on either edge.
22
+ *
23
+ * @module proxy/shareListener
24
+ */
25
+ import { logger } from "../utils/logger.js";
26
+ import { listShareGrants } from "./shareGrants.js";
27
+ /** How often the grant file is consulted for the first or last active grant. */
28
+ const SUPERVISOR_INTERVAL_MS = 15_000;
29
+ /** Default offset from the main port. Explicit configuration always wins. */
30
+ export const SHARE_PORT_OFFSET = 1;
31
+ /**
32
+ * The port the share listener should use.
33
+ *
34
+ * Precedence is the usual one — an explicit flag, then the environment, then a
35
+ * derived default. Deriving `port + 1` rather than picking any free port is
36
+ * deliberate: a peer's saved URL and a named tunnel both outlive a restart, so
37
+ * the address has to be reproducible.
38
+ */
39
+ export function resolveSharePort(args) {
40
+ if (args.explicit !== undefined && Number.isFinite(args.explicit)) {
41
+ return args.explicit;
42
+ }
43
+ const raw = (args.env ?? process.env).NEUROLINK_PROXY_SHARE_PORT;
44
+ const parsed = Number(raw);
45
+ if (raw !== undefined && raw !== "" && Number.isInteger(parsed)) {
46
+ return parsed;
47
+ }
48
+ return args.mainPort + SHARE_PORT_OFFSET;
49
+ }
50
+ /** Is the share listener switched off entirely? */
51
+ export function isShareListenerDisabled(env = process.env) {
52
+ const raw = (env.NEUROLINK_PROXY_SHARE_LISTENER ?? "").trim().toLowerCase();
53
+ return raw === "0" || raw === "off" || raw === "false" || raw === "no";
54
+ }
55
+ /** Does this node currently lend anything to anyone? */
56
+ export async function hasActiveShareGrants() {
57
+ try {
58
+ const grants = await listShareGrants();
59
+ return grants.some((grant) => grant.state === "active");
60
+ }
61
+ catch {
62
+ // An unreadable grant file is an empty grant set everywhere else in this
63
+ // subsystem; opening a public port on a parse error would be the one place
64
+ // that guessed the other way.
65
+ return false;
66
+ }
67
+ }
68
+ /**
69
+ * Keep the share listener's existence in step with the grant file.
70
+ *
71
+ * `start` and `stop` are injected rather than imported so this stays free of the
72
+ * HTTP server: the runtime owns the adaptor, and a poll loop that owns nothing
73
+ * is the part worth testing.
74
+ */
75
+ export function superviseShareListener(args) {
76
+ const hasGrants = args.hasGrants ?? hasActiveShareGrants;
77
+ let handle;
78
+ let inFlight = false;
79
+ let stopped = false;
80
+ /** Last failure announced, so a permanent one is not re-logged every cycle. */
81
+ let lastFailure;
82
+ const poll = async () => {
83
+ if (inFlight || stopped) {
84
+ return;
85
+ }
86
+ inFlight = true;
87
+ try {
88
+ const wanted = await hasGrants();
89
+ if (wanted && !handle) {
90
+ const started = await args.start();
91
+ // `stop()` can land while the listen is still resolving. It saw no
92
+ // handle to close, so unless this re-check closes the fresh one the
93
+ // port stays bound for the life of the process.
94
+ if (stopped) {
95
+ await started.close().catch(() => {
96
+ // Shutdown is already under way; a failed close changes nothing.
97
+ });
98
+ return;
99
+ }
100
+ handle = started;
101
+ lastFailure = undefined;
102
+ logger.always(`[proxy] share listener up on port ${handle.port} — expose this one, not the main port`);
103
+ }
104
+ else if (!wanted && handle) {
105
+ const closing = handle;
106
+ handle = undefined;
107
+ await closing.close();
108
+ logger.always("[proxy] share listener down — no active grants remain");
109
+ }
110
+ }
111
+ catch (error) {
112
+ // A port already in use does not clear on its own, and the supervisor
113
+ // retries every cycle. Announce each distinct failure once so the log
114
+ // stays readable while the condition persists.
115
+ const detail = error instanceof Error ? error.message : String(error);
116
+ if (detail !== lastFailure) {
117
+ lastFailure = detail;
118
+ logger.always(`[proxy] share listener could not start: ${detail} — set --share-port to move it`);
119
+ }
120
+ }
121
+ finally {
122
+ inFlight = false;
123
+ }
124
+ };
125
+ const timer = setInterval(() => {
126
+ void poll();
127
+ }, args.intervalMs ?? SUPERVISOR_INTERVAL_MS);
128
+ timer.unref?.();
129
+ return {
130
+ poll,
131
+ stop: async () => {
132
+ stopped = true;
133
+ clearInterval(timer);
134
+ const closing = handle;
135
+ handle = undefined;
136
+ if (closing) {
137
+ await closing.close().catch(() => {
138
+ // Shutdown is already under way; a failed close changes nothing.
139
+ });
140
+ }
141
+ },
142
+ };
143
+ }