@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,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split-PKCE provisioning — minting a resident credential without ever holding
|
|
3
|
+
* one.
|
|
4
|
+
*
|
|
5
|
+
* The earlier design had the lender complete an authorization and hand the
|
|
6
|
+
* borrower a file containing a live access token and a refresh token. That file
|
|
7
|
+
* is copyable, re-sharable, and exists on disk in plaintext at both ends for as
|
|
8
|
+
* long as anyone forgets to delete it. Nothing about the lease constrains a
|
|
9
|
+
* credential someone else already copied.
|
|
10
|
+
*
|
|
11
|
+
* This splits the flow along the seam PKCE was designed for:
|
|
12
|
+
*
|
|
13
|
+
* 1. The **borrower** generates a verifier and sends only its SHA-256
|
|
14
|
+
* challenge, over its authenticated grant.
|
|
15
|
+
* 2. The **lender** authorizes in their own browser, against their own account,
|
|
16
|
+
* with the borrower's challenge in the URL.
|
|
17
|
+
* 3. The **authorization code** comes back to the lender and is relayed to the
|
|
18
|
+
* borrower.
|
|
19
|
+
* 4. The **borrower** exchanges code + verifier for tokens, on its own machine.
|
|
20
|
+
*
|
|
21
|
+
* The code is worthless to anyone who intercepts it: the token endpoint will not
|
|
22
|
+
* exchange it without the verifier, which never left the borrower. The lender
|
|
23
|
+
* never possesses a token for the credential it just authorized, so there is
|
|
24
|
+
* nothing for it to leak, re-send, or forget to delete.
|
|
25
|
+
*
|
|
26
|
+
* **What this module owns** is the lender's side of that conversation: the
|
|
27
|
+
* pending request, its binding to one grant, its expiry, and the rule that a
|
|
28
|
+
* code is handed over exactly once.
|
|
29
|
+
*
|
|
30
|
+
* @module proxy/shareProvisioning
|
|
31
|
+
*/
|
|
32
|
+
import { randomBytes } from "node:crypto";
|
|
33
|
+
import { readFile } from "node:fs/promises";
|
|
34
|
+
import { homedir } from "node:os";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { AsyncMutex } from "../utils/asyncMutex.js";
|
|
37
|
+
import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
|
|
38
|
+
const PROVISION_FILE = "proxy-share-provisioning.json";
|
|
39
|
+
/**
|
|
40
|
+
* How long a challenge stays claimable.
|
|
41
|
+
*
|
|
42
|
+
* Long enough for a lender to notice a request and finish a browser login,
|
|
43
|
+
* short enough that a challenge left lying around is not a standing invitation.
|
|
44
|
+
*/
|
|
45
|
+
export const PROVISION_REQUEST_TTL_MS = 900_000;
|
|
46
|
+
/** A base64url SHA-256 digest is always 43 characters, unpadded. */
|
|
47
|
+
const CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
48
|
+
/** State is ours to choose; bound the shape so a peer cannot smuggle anything. */
|
|
49
|
+
const STATE_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
|
|
50
|
+
let customFilePath = null;
|
|
51
|
+
let cache = {};
|
|
52
|
+
let loaded = false;
|
|
53
|
+
const mutationMutex = new AsyncMutex();
|
|
54
|
+
export function initShareProvisioning(filePath) {
|
|
55
|
+
customFilePath = filePath;
|
|
56
|
+
cache = {};
|
|
57
|
+
loaded = false;
|
|
58
|
+
}
|
|
59
|
+
function getFilePath() {
|
|
60
|
+
return customFilePath ?? join(homedir(), ".neurolink", PROVISION_FILE);
|
|
61
|
+
}
|
|
62
|
+
function isRequest(value) {
|
|
63
|
+
if (!value || typeof value !== "object") {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const candidate = value;
|
|
67
|
+
return (typeof candidate.grantId === "string" &&
|
|
68
|
+
typeof candidate.codeChallenge === "string" &&
|
|
69
|
+
typeof candidate.state === "string" &&
|
|
70
|
+
typeof candidate.expiresAt === "number" &&
|
|
71
|
+
typeof candidate.status === "string");
|
|
72
|
+
}
|
|
73
|
+
async function ensureLoaded(options = {}) {
|
|
74
|
+
if (loaded && !options.force) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(await readFile(getFilePath(), "utf8"));
|
|
79
|
+
cache = Object.fromEntries(Object.entries(parsed?.requests ?? {}).filter((entry) => isRequest(entry[1])));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
cache = {};
|
|
83
|
+
}
|
|
84
|
+
loaded = true;
|
|
85
|
+
}
|
|
86
|
+
async function persist() {
|
|
87
|
+
const file = { schemaVersion: 1, requests: cache };
|
|
88
|
+
await writeJsonSnapshotAtomically(getFilePath(), file);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Narrow an open/authorize outcome to its failing half.
|
|
92
|
+
*
|
|
93
|
+
* One of the package's build steps compiles without `strictNullChecks`, where a
|
|
94
|
+
* boolean discriminant does not narrow. Callers reading `.reason` need this.
|
|
95
|
+
*/
|
|
96
|
+
export function isProvisionFailure(outcome) {
|
|
97
|
+
return !outcome.ok;
|
|
98
|
+
}
|
|
99
|
+
/** Is this request still live? Expiry and consumption both retire it. */
|
|
100
|
+
export function isProvisionRequestOpen(request, now = Date.now()) {
|
|
101
|
+
return request.status !== "consumed" && request.expiresAt > now;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Record a borrower's challenge.
|
|
105
|
+
*
|
|
106
|
+
* One open request per grant: a second one replaces the first rather than
|
|
107
|
+
* queueing, because the borrower that just asked is the one waiting, and an
|
|
108
|
+
* abandoned challenge should not be claimable later by whoever finds it.
|
|
109
|
+
*
|
|
110
|
+
* Rejects anything that is not a well-formed S256 challenge. The lender is about
|
|
111
|
+
* to put this string into an authorization URL against its own account, so it
|
|
112
|
+
* is not the place to be relaxed about input.
|
|
113
|
+
*/
|
|
114
|
+
export async function openProvisionRequest(args) {
|
|
115
|
+
if (!CHALLENGE_PATTERN.test(args.codeChallenge)) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
reason: "code_challenge must be a base64url S256 digest",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (!STATE_PATTERN.test(args.state)) {
|
|
122
|
+
return { ok: false, reason: "state must be 16-128 base64url characters" };
|
|
123
|
+
}
|
|
124
|
+
const now = args.now ?? Date.now();
|
|
125
|
+
return mutationMutex.runExclusive(async () => {
|
|
126
|
+
await ensureLoaded({ force: true });
|
|
127
|
+
const request = {
|
|
128
|
+
schemaVersion: 1,
|
|
129
|
+
grantId: args.grantId,
|
|
130
|
+
codeChallenge: args.codeChallenge,
|
|
131
|
+
challengeMethod: "S256",
|
|
132
|
+
state: args.state,
|
|
133
|
+
requestedAt: now,
|
|
134
|
+
expiresAt: now + PROVISION_REQUEST_TTL_MS,
|
|
135
|
+
status: "pending",
|
|
136
|
+
};
|
|
137
|
+
cache[args.grantId] = request;
|
|
138
|
+
await persist();
|
|
139
|
+
return { ok: true, request };
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** The open request for a grant, if there is one. */
|
|
143
|
+
export async function getProvisionRequest(grantId, now = Date.now()) {
|
|
144
|
+
await ensureLoaded({ force: true });
|
|
145
|
+
const request = cache[grantId];
|
|
146
|
+
if (!request || !isProvisionRequestOpen(request, now)) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
return request;
|
|
150
|
+
}
|
|
151
|
+
/** Every open request, for a lender deciding which to authorize. */
|
|
152
|
+
export async function listProvisionRequests(now = Date.now()) {
|
|
153
|
+
await ensureLoaded({ force: true });
|
|
154
|
+
return Object.values(cache).filter((request) => isProvisionRequestOpen(request, now));
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Attach the authorization code the lender's browser produced.
|
|
158
|
+
*
|
|
159
|
+
* The code is bound to the grant that asked for it and to the account the lender
|
|
160
|
+
* authorized against — the same account the drift audit will later reconcile —
|
|
161
|
+
* so a code cannot be redirected to a different grant after the fact.
|
|
162
|
+
*/
|
|
163
|
+
export async function authorizeProvisionRequest(args) {
|
|
164
|
+
const now = args.now ?? Date.now();
|
|
165
|
+
const code = args.code.trim();
|
|
166
|
+
if (!code) {
|
|
167
|
+
return { ok: false, reason: "no authorization code" };
|
|
168
|
+
}
|
|
169
|
+
return mutationMutex.runExclusive(async () => {
|
|
170
|
+
await ensureLoaded({ force: true });
|
|
171
|
+
const request = cache[args.grantId];
|
|
172
|
+
if (!request) {
|
|
173
|
+
return { ok: false, reason: "no provisioning request" };
|
|
174
|
+
}
|
|
175
|
+
if (!isProvisionRequestOpen(request, now)) {
|
|
176
|
+
return { ok: false, reason: "the request has expired" };
|
|
177
|
+
}
|
|
178
|
+
const authorized = {
|
|
179
|
+
...request,
|
|
180
|
+
status: "authorized",
|
|
181
|
+
code,
|
|
182
|
+
authorizedAt: now,
|
|
183
|
+
...(args.accountLabel ? { accountLabel: args.accountLabel } : {}),
|
|
184
|
+
};
|
|
185
|
+
cache[args.grantId] = authorized;
|
|
186
|
+
await persist();
|
|
187
|
+
return { ok: true, request: authorized };
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Hand the code to the borrower — once.
|
|
192
|
+
*
|
|
193
|
+
* Single-use is the whole binding: an authorization code that could be claimed
|
|
194
|
+
* twice would let anyone who replayed the call mint a second credential on the
|
|
195
|
+
* lender's account. Consumption is recorded before the value is returned.
|
|
196
|
+
*/
|
|
197
|
+
export async function claimProvisionRequest(grantId, now = Date.now()) {
|
|
198
|
+
return mutationMutex.runExclusive(async () => {
|
|
199
|
+
await ensureLoaded({ force: true });
|
|
200
|
+
const request = cache[grantId];
|
|
201
|
+
if (!request || !isProvisionRequestOpen(request, now)) {
|
|
202
|
+
return { status: "none" };
|
|
203
|
+
}
|
|
204
|
+
if (request.status !== "authorized" || !request.code) {
|
|
205
|
+
return { status: "pending" };
|
|
206
|
+
}
|
|
207
|
+
// Drop the code rather than carrying it through the spread: it has been
|
|
208
|
+
// handed over, it is single-use, and `persist()` would otherwise write a
|
|
209
|
+
// live authorization code to disk for the life of the record.
|
|
210
|
+
const { code: _claimed, ...spent } = request;
|
|
211
|
+
cache[grantId] = { ...spent, status: "consumed", claimedAt: now };
|
|
212
|
+
await persist();
|
|
213
|
+
return {
|
|
214
|
+
status: "ready",
|
|
215
|
+
code: request.code,
|
|
216
|
+
state: request.state,
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/** Drop a grant's request — used when the grant itself goes away. */
|
|
221
|
+
export async function clearProvisionRequest(grantId) {
|
|
222
|
+
await mutationMutex.runExclusive(async () => {
|
|
223
|
+
await ensureLoaded({ force: true });
|
|
224
|
+
if (cache[grantId]) {
|
|
225
|
+
delete cache[grantId];
|
|
226
|
+
await persist();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* A state value for a borrower to send alongside its challenge.
|
|
232
|
+
*
|
|
233
|
+
* Kept here so both ends agree on the shape `STATE_PATTERN` will accept.
|
|
234
|
+
*/
|
|
235
|
+
export function generateProvisionState() {
|
|
236
|
+
return randomBytes(24).toString("base64url");
|
|
237
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receipts, and the reciprocal netting built on them.
|
|
3
|
+
*
|
|
4
|
+
* **The problem receipts solve.** Until now the lender's word was the only
|
|
5
|
+
* record of what a borrowed request cost. The lender settles from usage the
|
|
6
|
+
* borrower never sees a ledger of, and a complete-mode borrower self-reports
|
|
7
|
+
* spend the lender cannot see at all. Each side simply believed the other.
|
|
8
|
+
*
|
|
9
|
+
* A receipt is the lender's signed statement that one request was settled, for
|
|
10
|
+
* how much, and against what usage. It travels with the usage block, so the
|
|
11
|
+
* borrower does not have to accept the coin figure — it recomputes the charge
|
|
12
|
+
* from the response it actually received and compares. `sequence` is contiguous
|
|
13
|
+
* per grant, so a receipt withheld to hide a charge leaves a hole.
|
|
14
|
+
*
|
|
15
|
+
* What it cannot do: an HMAC proves authorship only to the holder of the key, so
|
|
16
|
+
* a receipt settles a dispute *between the two parties to it* and is worth
|
|
17
|
+
* nothing to a third. That is the deliberate trade — see `shareSigning.ts`.
|
|
18
|
+
*
|
|
19
|
+
* **Netting.** Two nodes that lend to each other otherwise accumulate two
|
|
20
|
+
* one-way debts that never meet. Netting forgives the overlap: if a borrower has
|
|
21
|
+
* consumed 300 coins of mine and I have consumed 500 of theirs, 300 cancels on
|
|
22
|
+
* both sides. It is expressed in *cumulative* totals rather than deltas, so a
|
|
23
|
+
* replayed or duplicated round nets zero rather than paying twice.
|
|
24
|
+
*
|
|
25
|
+
* @module proxy/shareReceipts
|
|
26
|
+
*/
|
|
27
|
+
import type { ProxyShareNettingResult, ProxyShareReceipt, ProxyShareStatement, ProxyShareUsage } from "../types/index.js";
|
|
28
|
+
/**
|
|
29
|
+
* Receipts kept per grant.
|
|
30
|
+
*
|
|
31
|
+
* Enough that a borrower checking in daily never misses one, bounded so a busy
|
|
32
|
+
* grant cannot grow the file without limit. A borrower that falls further behind
|
|
33
|
+
* than this sees a gap, which is the honest answer — the alternative is silently
|
|
34
|
+
* dropping the evidence and reporting a clean run.
|
|
35
|
+
*/
|
|
36
|
+
export declare const RECEIPT_HISTORY_LIMIT = 500;
|
|
37
|
+
export declare function initShareReceipts(filePath: string): void;
|
|
38
|
+
/**
|
|
39
|
+
* Record and sign a settlement.
|
|
40
|
+
*
|
|
41
|
+
* Never throws: a receipt that could not be written must not turn a response the
|
|
42
|
+
* borrower already has into an error. A grant with no receipt secret — one
|
|
43
|
+
* issued before receipts existed — is skipped rather than signed with nothing.
|
|
44
|
+
*/
|
|
45
|
+
export declare function issueShareReceipt(args: {
|
|
46
|
+
grantId: string;
|
|
47
|
+
coins: number;
|
|
48
|
+
usage: ProxyShareUsage;
|
|
49
|
+
model?: string;
|
|
50
|
+
balanceAfter: number | null;
|
|
51
|
+
settledAt?: number;
|
|
52
|
+
}): Promise<ProxyShareReceipt | undefined>;
|
|
53
|
+
/** Receipts a borrower has not collected yet. */
|
|
54
|
+
export declare function listShareReceipts(grantId: string, since?: number): Promise<ProxyShareReceipt[]>;
|
|
55
|
+
/**
|
|
56
|
+
* Check a collected run of receipts against the secret and against itself.
|
|
57
|
+
*
|
|
58
|
+
* Three independent questions, because they have different answers:
|
|
59
|
+
* a signature that does not verify means the receipt did not come from this
|
|
60
|
+
* lender; a coin figure that disagrees with its own usage block means the
|
|
61
|
+
* lender's arithmetic (or its price table) differs from ours; a missing
|
|
62
|
+
* sequence means a charge was never shown to us at all.
|
|
63
|
+
*/
|
|
64
|
+
export declare function auditShareReceipts(grantId: string, collected: readonly ProxyShareReceipt[], secret: string | undefined): ProxyShareStatement;
|
|
65
|
+
/** Cumulative coins a grant has consumed, as its receipts record it. */
|
|
66
|
+
export declare function totalReceiptedCoins(grantId: string): Promise<number>;
|
|
67
|
+
/** Cumulative coins already forgiven on a grant by netting. */
|
|
68
|
+
export declare function nettedCoinsFor(grantId: string): Promise<number>;
|
|
69
|
+
/**
|
|
70
|
+
* Settle one round of reciprocal netting, from the lender's side.
|
|
71
|
+
*
|
|
72
|
+
* Netting forgives the same amount on both sides, so the cumulative total
|
|
73
|
+
* forgiven is one number the two nodes hold a copy of each. The round is
|
|
74
|
+
* therefore the overlap that has not been forgiven yet:
|
|
75
|
+
*
|
|
76
|
+
* ```
|
|
77
|
+
* forgivable = min(coins they consumed of mine, coins I consumed of theirs)
|
|
78
|
+
* alreadyForgiven = max(my record, their record)
|
|
79
|
+
* round = max(0, forgivable - alreadyForgiven)
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* **Why the totals and not a delta.** A delta is a number the caller chooses,
|
|
83
|
+
* and a replayed round would pay it out again. Deriving the round from
|
|
84
|
+
* cumulative positions makes a replay free by construction: the second call
|
|
85
|
+
* subtracts the first one's forgiveness and lands on zero.
|
|
86
|
+
*
|
|
87
|
+
* **Why `max` over the two records.** They should agree. When they do not — a
|
|
88
|
+
* round applied on one side and lost on the other — taking the larger forgives
|
|
89
|
+
* less, which is the direction that cannot hand out coins twice.
|
|
90
|
+
*/
|
|
91
|
+
export declare function applyReciprocalNetting(args: {
|
|
92
|
+
grantId: string;
|
|
93
|
+
/** Cumulative coins this node has consumed under the *peer's* grant to it. */
|
|
94
|
+
consumedFromPeer: number;
|
|
95
|
+
/** Cumulative coins the peer says it has already forgiven on its side. */
|
|
96
|
+
peerAlreadyNetted: number;
|
|
97
|
+
}): Promise<ProxyShareNettingResult>;
|
|
98
|
+
/** Drop a grant's receipts and netting position. Used when the grant goes. */
|
|
99
|
+
export declare function clearShareReceipts(grantId: string): Promise<void>;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receipts, and the reciprocal netting built on them.
|
|
3
|
+
*
|
|
4
|
+
* **The problem receipts solve.** Until now the lender's word was the only
|
|
5
|
+
* record of what a borrowed request cost. The lender settles from usage the
|
|
6
|
+
* borrower never sees a ledger of, and a complete-mode borrower self-reports
|
|
7
|
+
* spend the lender cannot see at all. Each side simply believed the other.
|
|
8
|
+
*
|
|
9
|
+
* A receipt is the lender's signed statement that one request was settled, for
|
|
10
|
+
* how much, and against what usage. It travels with the usage block, so the
|
|
11
|
+
* borrower does not have to accept the coin figure — it recomputes the charge
|
|
12
|
+
* from the response it actually received and compares. `sequence` is contiguous
|
|
13
|
+
* per grant, so a receipt withheld to hide a charge leaves a hole.
|
|
14
|
+
*
|
|
15
|
+
* What it cannot do: an HMAC proves authorship only to the holder of the key, so
|
|
16
|
+
* a receipt settles a dispute *between the two parties to it* and is worth
|
|
17
|
+
* nothing to a third. That is the deliberate trade — see `shareSigning.ts`.
|
|
18
|
+
*
|
|
19
|
+
* **Netting.** Two nodes that lend to each other otherwise accumulate two
|
|
20
|
+
* one-way debts that never meet. Netting forgives the overlap: if a borrower has
|
|
21
|
+
* consumed 300 coins of mine and I have consumed 500 of theirs, 300 cancels on
|
|
22
|
+
* both sides. It is expressed in *cumulative* totals rather than deltas, so a
|
|
23
|
+
* replayed or duplicated round nets zero rather than paying twice.
|
|
24
|
+
*
|
|
25
|
+
* @module proxy/shareReceipts
|
|
26
|
+
*/
|
|
27
|
+
import { readFile } from "node:fs/promises";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { AsyncMutex } from "../utils/asyncMutex.js";
|
|
31
|
+
import { logger } from "../utils/logger.js";
|
|
32
|
+
import { signSharePayload, verifySharePayload } from "./shareSigning.js";
|
|
33
|
+
import { usageToCoins } from "./shareLedger.js";
|
|
34
|
+
import { creditShareGrantCoins, getShareGrant } from "./shareGrants.js";
|
|
35
|
+
const RECEIPTS_FILE = "proxy-share-receipts.json";
|
|
36
|
+
/**
|
|
37
|
+
* Receipts kept per grant.
|
|
38
|
+
*
|
|
39
|
+
* Enough that a borrower checking in daily never misses one, bounded so a busy
|
|
40
|
+
* grant cannot grow the file without limit. A borrower that falls further behind
|
|
41
|
+
* than this sees a gap, which is the honest answer — the alternative is silently
|
|
42
|
+
* dropping the evidence and reporting a clean run.
|
|
43
|
+
*/
|
|
44
|
+
export const RECEIPT_HISTORY_LIMIT = 500;
|
|
45
|
+
let customFilePath = null;
|
|
46
|
+
let receipts = {};
|
|
47
|
+
let netted = {};
|
|
48
|
+
/**
|
|
49
|
+
* Lifetime coins and highest sequence per grant.
|
|
50
|
+
*
|
|
51
|
+
* Both exist because `receipts` is trimmed to {@link RECEIPT_HISTORY_LIMIT}.
|
|
52
|
+
* Deriving either from the retained history is correct only until the first
|
|
53
|
+
* trim, after which a busy grant's cumulative position silently resets — which
|
|
54
|
+
* on the netting path would forgive coins that were already forgiven, and on
|
|
55
|
+
* the issuing path would reuse sequence numbers the borrower has already seen.
|
|
56
|
+
*/
|
|
57
|
+
let consumedTotal = {};
|
|
58
|
+
let highestSequence = {};
|
|
59
|
+
let loaded = false;
|
|
60
|
+
const mutationMutex = new AsyncMutex();
|
|
61
|
+
export function initShareReceipts(filePath) {
|
|
62
|
+
customFilePath = filePath;
|
|
63
|
+
receipts = {};
|
|
64
|
+
netted = {};
|
|
65
|
+
consumedTotal = {};
|
|
66
|
+
highestSequence = {};
|
|
67
|
+
loaded = false;
|
|
68
|
+
}
|
|
69
|
+
function getFilePath() {
|
|
70
|
+
return customFilePath ?? join(homedir(), ".neurolink", RECEIPTS_FILE);
|
|
71
|
+
}
|
|
72
|
+
async function ensureLoaded(options = {}) {
|
|
73
|
+
if (loaded && !options.force) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(await readFile(getFilePath(), "utf8"));
|
|
78
|
+
receipts = parsed?.receipts ?? {};
|
|
79
|
+
netted = parsed?.netted ?? {};
|
|
80
|
+
// A file written before these were tracked carries neither. Seeding them
|
|
81
|
+
// from the retained history is the best available answer and is exact for
|
|
82
|
+
// any grant that has not yet been trimmed.
|
|
83
|
+
consumedTotal =
|
|
84
|
+
parsed?.consumedTotal ??
|
|
85
|
+
seedFromHistory((sum, receipt) => sum + receipt.coins);
|
|
86
|
+
highestSequence =
|
|
87
|
+
parsed?.highestSequence ??
|
|
88
|
+
seedFromHistory((top, receipt) => Math.max(top, receipt.sequence));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
receipts = {};
|
|
92
|
+
netted = {};
|
|
93
|
+
consumedTotal = {};
|
|
94
|
+
highestSequence = {};
|
|
95
|
+
}
|
|
96
|
+
loaded = true;
|
|
97
|
+
}
|
|
98
|
+
/** Rebuild a per-grant total from whatever history survived trimming. */
|
|
99
|
+
function seedFromHistory(fold) {
|
|
100
|
+
return Object.fromEntries(Object.entries(receipts).map(([grantId, history]) => [
|
|
101
|
+
grantId,
|
|
102
|
+
history.reduce(fold, 0),
|
|
103
|
+
]));
|
|
104
|
+
}
|
|
105
|
+
async function persist() {
|
|
106
|
+
const { writeJsonSnapshotAtomically } = await import("./snapshotPersistence.js");
|
|
107
|
+
const file = {
|
|
108
|
+
schemaVersion: 1,
|
|
109
|
+
receipts,
|
|
110
|
+
netted,
|
|
111
|
+
consumedTotal,
|
|
112
|
+
highestSequence,
|
|
113
|
+
};
|
|
114
|
+
await writeJsonSnapshotAtomically(getFilePath(), file);
|
|
115
|
+
}
|
|
116
|
+
/** Everything the signature covers — the receipt minus the signature itself. */
|
|
117
|
+
function receiptPayload(receipt) {
|
|
118
|
+
return receipt;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Record and sign a settlement.
|
|
122
|
+
*
|
|
123
|
+
* Never throws: a receipt that could not be written must not turn a response the
|
|
124
|
+
* borrower already has into an error. A grant with no receipt secret — one
|
|
125
|
+
* issued before receipts existed — is skipped rather than signed with nothing.
|
|
126
|
+
*/
|
|
127
|
+
export async function issueShareReceipt(args) {
|
|
128
|
+
try {
|
|
129
|
+
const grant = await getShareGrant(args.grantId);
|
|
130
|
+
const secret = grant?.receiptSecret;
|
|
131
|
+
if (!secret) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
return await mutationMutex.runExclusive(async () => {
|
|
135
|
+
await ensureLoaded();
|
|
136
|
+
const history = receipts[args.grantId] ?? [];
|
|
137
|
+
// From the recorded high-water mark, not the retained tail: the tail is
|
|
138
|
+
// trimmed, and restarting the sequence would read as a replay.
|
|
139
|
+
const previous = highestSequence[args.grantId] ?? 0;
|
|
140
|
+
const unsigned = {
|
|
141
|
+
schemaVersion: 1,
|
|
142
|
+
grantId: args.grantId,
|
|
143
|
+
sequence: previous + 1,
|
|
144
|
+
settledAt: args.settledAt ?? Date.now(),
|
|
145
|
+
...(args.model ? { model: args.model } : {}),
|
|
146
|
+
usage: args.usage,
|
|
147
|
+
coins: args.coins,
|
|
148
|
+
balanceAfter: args.balanceAfter,
|
|
149
|
+
};
|
|
150
|
+
const receipt = {
|
|
151
|
+
...unsigned,
|
|
152
|
+
signature: signSharePayload(receiptPayload(unsigned), secret),
|
|
153
|
+
};
|
|
154
|
+
history.push(receipt);
|
|
155
|
+
// Oldest first, so trimming the front is trimming the oldest.
|
|
156
|
+
receipts[args.grantId] = history.slice(-RECEIPT_HISTORY_LIMIT);
|
|
157
|
+
highestSequence[args.grantId] = receipt.sequence;
|
|
158
|
+
consumedTotal[args.grantId] =
|
|
159
|
+
(consumedTotal[args.grantId] ?? 0) + args.coins;
|
|
160
|
+
await persist();
|
|
161
|
+
return receipt;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
logger.always(`[proxy] could not record a receipt for grant=${args.grantId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Receipts a borrower has not collected yet. */
|
|
170
|
+
export async function listShareReceipts(grantId, since = 0) {
|
|
171
|
+
await ensureLoaded({ force: true });
|
|
172
|
+
return (receipts[grantId] ?? []).filter((receipt) => receipt.sequence > since);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Check a collected run of receipts against the secret and against itself.
|
|
176
|
+
*
|
|
177
|
+
* Three independent questions, because they have different answers:
|
|
178
|
+
* a signature that does not verify means the receipt did not come from this
|
|
179
|
+
* lender; a coin figure that disagrees with its own usage block means the
|
|
180
|
+
* lender's arithmetic (or its price table) differs from ours; a missing
|
|
181
|
+
* sequence means a charge was never shown to us at all.
|
|
182
|
+
*/
|
|
183
|
+
export function auditShareReceipts(grantId, collected, secret) {
|
|
184
|
+
let coins = 0;
|
|
185
|
+
let unverified = 0;
|
|
186
|
+
let miscounted = 0;
|
|
187
|
+
const seen = new Set();
|
|
188
|
+
for (const receipt of collected) {
|
|
189
|
+
coins += receipt.coins;
|
|
190
|
+
seen.add(receipt.sequence);
|
|
191
|
+
const { signature, ...unsigned } = receipt;
|
|
192
|
+
if (!secret || !verifySharePayload(unsigned, signature, secret)) {
|
|
193
|
+
unverified += 1;
|
|
194
|
+
}
|
|
195
|
+
const expected = usageToCoins(receipt.usage, receipt.model);
|
|
196
|
+
// A tenth of a coin is a hundred normalized tokens — below any rounding
|
|
197
|
+
// this pipeline introduces, and far below a charge worth arguing about.
|
|
198
|
+
if (Math.abs(expected - receipt.coins) > 0.1) {
|
|
199
|
+
miscounted += 1;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const latestSequence = collected.reduce((highest, receipt) => Math.max(highest, receipt.sequence), 0);
|
|
203
|
+
// Anchored to the lowest sequence actually collected, not to 1. A borrower
|
|
204
|
+
// that collects incrementally asks for everything after the last sequence it
|
|
205
|
+
// holds, and every earlier receipt — already collected, or trimmed from the
|
|
206
|
+
// lender's bounded history — would otherwise be reported as a withheld one.
|
|
207
|
+
const earliestSequence = collected.reduce((lowest, receipt) => Math.min(lowest, receipt.sequence), latestSequence);
|
|
208
|
+
const gaps = [];
|
|
209
|
+
for (let sequence = earliestSequence; sequence <= latestSequence; sequence += 1) {
|
|
210
|
+
if (!seen.has(sequence)) {
|
|
211
|
+
gaps.push(sequence);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
grantId,
|
|
216
|
+
receipts: collected.length,
|
|
217
|
+
coins,
|
|
218
|
+
unverified,
|
|
219
|
+
miscounted,
|
|
220
|
+
gaps,
|
|
221
|
+
latestSequence,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/** Cumulative coins a grant has consumed, as its receipts record it. */
|
|
225
|
+
export async function totalReceiptedCoins(grantId) {
|
|
226
|
+
await ensureLoaded({ force: true });
|
|
227
|
+
return consumedTotal[grantId] ?? 0;
|
|
228
|
+
}
|
|
229
|
+
/** Cumulative coins already forgiven on a grant by netting. */
|
|
230
|
+
export async function nettedCoinsFor(grantId) {
|
|
231
|
+
await ensureLoaded({ force: true });
|
|
232
|
+
return netted[grantId] ?? 0;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Settle one round of reciprocal netting, from the lender's side.
|
|
236
|
+
*
|
|
237
|
+
* Netting forgives the same amount on both sides, so the cumulative total
|
|
238
|
+
* forgiven is one number the two nodes hold a copy of each. The round is
|
|
239
|
+
* therefore the overlap that has not been forgiven yet:
|
|
240
|
+
*
|
|
241
|
+
* ```
|
|
242
|
+
* forgivable = min(coins they consumed of mine, coins I consumed of theirs)
|
|
243
|
+
* alreadyForgiven = max(my record, their record)
|
|
244
|
+
* round = max(0, forgivable - alreadyForgiven)
|
|
245
|
+
* ```
|
|
246
|
+
*
|
|
247
|
+
* **Why the totals and not a delta.** A delta is a number the caller chooses,
|
|
248
|
+
* and a replayed round would pay it out again. Deriving the round from
|
|
249
|
+
* cumulative positions makes a replay free by construction: the second call
|
|
250
|
+
* subtracts the first one's forgiveness and lands on zero.
|
|
251
|
+
*
|
|
252
|
+
* **Why `max` over the two records.** They should agree. When they do not — a
|
|
253
|
+
* round applied on one side and lost on the other — taking the larger forgives
|
|
254
|
+
* less, which is the direction that cannot hand out coins twice.
|
|
255
|
+
*/
|
|
256
|
+
export async function applyReciprocalNetting(args) {
|
|
257
|
+
return mutationMutex.runExclusive(async () => {
|
|
258
|
+
await ensureLoaded({ force: true });
|
|
259
|
+
// The lifetime figure, not the retained history: netting forgives against
|
|
260
|
+
// cumulative positions, and a trimmed sum would forgive the same coins
|
|
261
|
+
// again on the next round.
|
|
262
|
+
const consumedByPeer = consumedTotal[args.grantId] ?? 0;
|
|
263
|
+
const alreadyNetted = Math.max(netted[args.grantId] ?? 0, Math.max(0, args.peerAlreadyNetted));
|
|
264
|
+
const forgivable = Math.min(consumedByPeer, Math.max(0, args.consumedFromPeer));
|
|
265
|
+
const round = Math.max(0, forgivable - alreadyNetted);
|
|
266
|
+
if (round <= 0) {
|
|
267
|
+
return {
|
|
268
|
+
netted: 0,
|
|
269
|
+
totalNetted: alreadyNetted,
|
|
270
|
+
detail: consumedByPeer <= alreadyNetted
|
|
271
|
+
? "nothing of mine left to forgive"
|
|
272
|
+
: "nothing of theirs left to offset it against",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
netted[args.grantId] = alreadyNetted + round;
|
|
276
|
+
await persist();
|
|
277
|
+
// Forgiveness is a credit: the borrower gets back what its own lending
|
|
278
|
+
// cancelled out. An unlimited grant has no balance to credit, and netting
|
|
279
|
+
// there is bookkeeping only.
|
|
280
|
+
await creditShareGrantCoins(args.grantId, round);
|
|
281
|
+
return {
|
|
282
|
+
netted: round,
|
|
283
|
+
totalNetted: netted[args.grantId],
|
|
284
|
+
detail: `forgave ${round.toFixed(1)} coins against reciprocal use`,
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
/** Drop a grant's receipts and netting position. Used when the grant goes. */
|
|
289
|
+
export async function clearShareReceipts(grantId) {
|
|
290
|
+
await mutationMutex.runExclusive(async () => {
|
|
291
|
+
await ensureLoaded({ force: true });
|
|
292
|
+
if (receipts[grantId] ||
|
|
293
|
+
netted[grantId] !== undefined ||
|
|
294
|
+
consumedTotal[grantId] !== undefined ||
|
|
295
|
+
highestSequence[grantId] !== undefined) {
|
|
296
|
+
delete receipts[grantId];
|
|
297
|
+
delete netted[grantId];
|
|
298
|
+
delete consumedTotal[grantId];
|
|
299
|
+
delete highestSequence[grantId];
|
|
300
|
+
await persist();
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one HMAC used across peer sharing.
|
|
3
|
+
*
|
|
4
|
+
* Leases, receipts and coin notes all need the same thing: a statement one node
|
|
5
|
+
* makes that another can check without being able to forge. They had grown
|
|
6
|
+
* separate copies of the same twenty lines, which is how two of them end up with
|
|
7
|
+
* different canonicalisation and a signature that verifies on one path and not
|
|
8
|
+
* the other.
|
|
9
|
+
*
|
|
10
|
+
* **Why HMAC and not a signature scheme.** The key-distribution problem that
|
|
11
|
+
* asymmetric keys solve does not exist here: every one of these statements
|
|
12
|
+
* passes between exactly two nodes that already share a secret. The package's
|
|
13
|
+
* browser bundle also stubs `node:crypto` down to a subset with no Ed25519 in
|
|
14
|
+
* it, and these modules are reachable from that build.
|
|
15
|
+
*
|
|
16
|
+
* **What that costs.** A MAC proves authorship only to someone holding the key,
|
|
17
|
+
* so a receipt is evidence between the two parties to it and not to a third.
|
|
18
|
+
* Coin notes are built around that limit rather than against it — see
|
|
19
|
+
* `shareNotes.ts`.
|
|
20
|
+
*
|
|
21
|
+
* @module proxy/shareSigning
|
|
22
|
+
*/
|
|
23
|
+
/** A secret suitable for keying any of the statements in this subsystem. */
|
|
24
|
+
export declare function generateShareSecret(): string;
|
|
25
|
+
/**
|
|
26
|
+
* Canonicalise and sign.
|
|
27
|
+
*
|
|
28
|
+
* The payload is serialized with its keys sorted, so two nodes that built the
|
|
29
|
+
* same object in a different order still agree on the bytes being signed.
|
|
30
|
+
*/
|
|
31
|
+
export declare function signSharePayload(payload: unknown, secret: string): string;
|
|
32
|
+
/** Does `signature` match what this secret would produce for this payload? */
|
|
33
|
+
export declare function verifySharePayload(payload: unknown, signature: string, secret: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Compare two strings without leaking where they diverge.
|
|
36
|
+
*
|
|
37
|
+
* Hand-rolled rather than `crypto.timingSafeEqual` because that is one of the
|
|
38
|
+
* things the browser bundle's `node:crypto` stub does not carry.
|
|
39
|
+
*/
|
|
40
|
+
export declare function secretsMatch(expected: string, presented: string): boolean;
|