@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,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;