@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,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
|
+
}
|