@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,590 @@
1
+ /**
2
+ * Peer-sharing grant store.
3
+ *
4
+ * A grant is one lender-issued, revocable authorization for one borrower. This
5
+ * module owns the persisted grant file and the token contract; it deliberately
6
+ * knows nothing about admission policy (see `sharePolicy.ts`) or transport.
7
+ *
8
+ * **Token contract.** A share token looks like `nls_<grantId>_<secret>`. The
9
+ * grant id is carried in the token so a lookup is a map hit rather than a scan
10
+ * over every grant, and only then is the secret compared — in constant time —
11
+ * against `sha256(salt + secret)`. The raw token exists exactly once, in the
12
+ * return value of `createShareGrant`; nothing persists it.
13
+ *
14
+ * **Cross-process freshness.** The CLI mutates this file in one process while
15
+ * the proxy reads it in another, so `share pause` has to land in a running
16
+ * proxy without a restart. Reads therefore re-stat the file and reload when its
17
+ * mtime moves, bounded by a short TTL so the hot path pays one `stat` per
18
+ * second at most.
19
+ *
20
+ * @module proxy/shareGrants
21
+ */
22
+ import { createHash, randomBytes } from "node:crypto";
23
+ import { readFile, stat } from "node:fs/promises";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { AsyncMutex } from "../utils/asyncMutex.js";
27
+ import { logger } from "../utils/logger.js";
28
+ import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
29
+ const GRANTS_FILE = "proxy-grants.json";
30
+ /** Token prefix. Distinguishes a share token from a client's own credential. */
31
+ export const SHARE_TOKEN_PREFIX = "nls";
32
+ /** How long a loaded snapshot is trusted before the file is re-stat'd. */
33
+ const RELOAD_TTL_MS = 1_000;
34
+ let customGrantsFilePath = null;
35
+ let cache = {};
36
+ let cacheLoadedAt = 0;
37
+ let cacheMtimeMs = -1;
38
+ let cacheValid = false;
39
+ let cachedPublicUrl;
40
+ let cachedNoteSecret;
41
+ const mutationMutex = new AsyncMutex();
42
+ /** How often in-memory `lastUsedAt` updates are flushed to disk. */
43
+ const USAGE_FLUSH_INTERVAL_MS = 60_000;
44
+ let lastUsageFlushAt = 0;
45
+ /** `lastUsedAt` stamps awaiting a flush, kept across the reload that flush does. */
46
+ let pendingUsage = {};
47
+ /** Point the store at an explicit file. Used by dev mode and by tests. */
48
+ export function initShareGrants(grantsFilePath) {
49
+ customGrantsFilePath = grantsFilePath;
50
+ cache = {};
51
+ cacheLoadedAt = 0;
52
+ cacheMtimeMs = -1;
53
+ cacheValid = false;
54
+ cachedPublicUrl = undefined;
55
+ cachedNoteSecret = undefined;
56
+ lastUsageFlushAt = 0;
57
+ pendingUsage = {};
58
+ }
59
+ /**
60
+ * This node's stable public address.
61
+ *
62
+ * Recorded once — by `share url`, or by whatever already fronts this proxy — so
63
+ * a link can be minted without retyping the domain. Nothing here assumes the
64
+ * address came from `proxy expose`; a reverse proxy, a permanent named tunnel
65
+ * or a plain DNS record are all the same thing to a borrower.
66
+ */
67
+ export async function getNodePublicUrl() {
68
+ await ensureLoaded();
69
+ return cachedPublicUrl;
70
+ }
71
+ export async function setNodePublicUrl(url) {
72
+ await mutationMutex.runExclusive(async () => {
73
+ await ensureLoaded({ force: true });
74
+ cachedPublicUrl = url ? url.trim().replace(/\/+$/, "") : undefined;
75
+ await persist();
76
+ });
77
+ }
78
+ function getGrantsFilePath() {
79
+ return customGrantsFilePath ?? join(homedir(), ".neurolink", GRANTS_FILE);
80
+ }
81
+ function isShareGrant(value) {
82
+ if (!value || typeof value !== "object") {
83
+ return false;
84
+ }
85
+ const candidate = value;
86
+ return (typeof candidate.id === "string" &&
87
+ typeof candidate.peerLabel === "string" &&
88
+ typeof candidate.tokenHash === "string" &&
89
+ typeof candidate.tokenSalt === "string" &&
90
+ (candidate.level === "live" || candidate.level === "complete") &&
91
+ typeof candidate.state === "string" &&
92
+ typeof candidate.entitlement === "object" &&
93
+ candidate.entitlement !== null &&
94
+ typeof candidate.gates === "object" &&
95
+ candidate.gates !== null);
96
+ }
97
+ /**
98
+ * Is this error simply "the file is not there yet"?
99
+ *
100
+ * The distinction is load-bearing. An absent file genuinely is an empty map —
101
+ * nothing has been written yet. Every *other* `stat`/read failure (`EACCES`,
102
+ * `EIO`, `EMFILE`, a full descriptor table) is a failure to observe the file,
103
+ * and answering one with an empty map is how a whole store gets erased: a
104
+ * caller passing `force` is about to `persist()` the map back over the real
105
+ * contents it just failed to read.
106
+ */
107
+ function isMissingFileError(error) {
108
+ return error?.code === "ENOENT";
109
+ }
110
+ /**
111
+ * Load the grant file when the cache is cold or the file changed underneath us.
112
+ *
113
+ * A missing or unparseable file yields an empty set rather than an error: a node
114
+ * that has never shared anything is the common case, and a corrupt file must not
115
+ * take the proxy's hot path down. Corruption is announced once per load.
116
+ */
117
+ async function ensureLoaded(options = {}) {
118
+ const now = Date.now();
119
+ if (!options.force &&
120
+ !options.revalidate &&
121
+ cacheValid &&
122
+ now - cacheLoadedAt < RELOAD_TTL_MS) {
123
+ return;
124
+ }
125
+ const path = getGrantsFilePath();
126
+ let mtimeMs;
127
+ try {
128
+ mtimeMs = (await stat(path)).mtimeMs;
129
+ }
130
+ catch (error) {
131
+ if (!isMissingFileError(error)) {
132
+ // Not "no file" but "could not look" — see `isMissingFileError`. Let it
133
+ // out: a mutation must abort rather than persist an empty map over a
134
+ // store it never managed to read.
135
+ throw error;
136
+ }
137
+ // Missing file — an empty grant set, not an error.
138
+ cache = {};
139
+ cacheMtimeMs = -1;
140
+ cacheLoadedAt = now;
141
+ cacheValid = true;
142
+ return;
143
+ }
144
+ // This is where `revalidate` and `force` part company. `revalidate` wants a
145
+ // fresh answer cheaply and stops here when the file has not moved — that is
146
+ // the read path, and it must not pay for a parse per request. `force` goes
147
+ // on regardless, because mtime is the fast path for a read and not a
148
+ // correctness check for a write: several filesystems stamp it at one-second
149
+ // granularity, so a write landing in the same second as our last read is
150
+ // indistinguishable from no write at all — and every caller passing `force`
151
+ // is about to persist the whole map back over whatever it missed.
152
+ if (!options.force && cacheValid && mtimeMs === cacheMtimeMs) {
153
+ cacheLoadedAt = now;
154
+ return;
155
+ }
156
+ try {
157
+ const parsed = JSON.parse(await readFile(path, "utf8"));
158
+ const grants = parsed?.grants ?? {};
159
+ cachedPublicUrl = parsed?.publicUrl;
160
+ cachedNoteSecret = parsed?.noteSecret;
161
+ cache = Object.fromEntries(Object.entries(grants).filter((entry) => isShareGrant(entry[1])));
162
+ }
163
+ catch (error) {
164
+ if (options.force) {
165
+ // A mutation is about to write the whole map back. Treating a corrupt
166
+ // file as empty here would make that write the thing that finishes the
167
+ // corruption off — and it would take `publicUrl` and `noteSecret` with
168
+ // it, which no grant carries a second copy of. Abort and leave the file.
169
+ throw error;
170
+ }
171
+ logger.always(`[proxy] share grants unreadable, treating as empty: ${error instanceof Error ? error.message : String(error)}`);
172
+ cache = {};
173
+ cachedPublicUrl = undefined;
174
+ cachedNoteSecret = undefined;
175
+ }
176
+ cacheMtimeMs = mtimeMs;
177
+ cacheLoadedAt = now;
178
+ cacheValid = true;
179
+ }
180
+ async function persist() {
181
+ const file = {
182
+ schemaVersion: 1,
183
+ grants: cache,
184
+ ...(cachedPublicUrl ? { publicUrl: cachedPublicUrl } : {}),
185
+ ...(cachedNoteSecret ? { noteSecret: cachedNoteSecret } : {}),
186
+ };
187
+ await writeJsonSnapshotAtomically(getGrantsFilePath(), file);
188
+ try {
189
+ cacheMtimeMs = (await stat(getGrantsFilePath())).mtimeMs;
190
+ }
191
+ catch {
192
+ // A stat failure only costs one redundant reload on the next read.
193
+ cacheMtimeMs = -1;
194
+ }
195
+ cacheLoadedAt = Date.now();
196
+ cacheValid = true;
197
+ }
198
+ function hashSecret(salt, secret) {
199
+ return createHash("sha256").update(`${salt}:${secret}`).digest("hex");
200
+ }
201
+ /**
202
+ * Compare two hex digests without leaking their divergence point via timing.
203
+ *
204
+ * Hand-rolled rather than `crypto.timingSafeEqual` because the package's
205
+ * browser bundle stubs `node:crypto` down to a subset that does not include it,
206
+ * and this module is reachable from that build. Both inputs are fixed-length
207
+ * SHA-256 hex, so the length check leaks nothing about the secret.
208
+ */
209
+ function digestsMatch(left, right) {
210
+ if (left.length !== right.length || left.length === 0) {
211
+ return false;
212
+ }
213
+ let difference = 0;
214
+ for (let index = 0; index < left.length; index += 1) {
215
+ difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
216
+ }
217
+ return difference === 0;
218
+ }
219
+ /**
220
+ * Split a share token into its grant id and secret.
221
+ * Returns null for anything that is not one of our tokens — including a client's
222
+ * own Anthropic credential, which must never be mistaken for a share token.
223
+ */
224
+ export function parseShareToken(token) {
225
+ if (!token.startsWith(`${SHARE_TOKEN_PREFIX}_`)) {
226
+ return null;
227
+ }
228
+ // Split on the first two separators only. The secret is base64url, whose
229
+ // alphabet includes "_", so a naive three-way split rejects most valid
230
+ // tokens — every one that happens to contain an underscore.
231
+ const idStart = SHARE_TOKEN_PREFIX.length + 1;
232
+ const idEnd = token.indexOf("_", idStart);
233
+ if (idEnd <= idStart) {
234
+ return null;
235
+ }
236
+ const grantId = token.slice(idStart, idEnd);
237
+ const secret = token.slice(idEnd + 1);
238
+ if (!grantId || !secret) {
239
+ return null;
240
+ }
241
+ return { grantId, secret };
242
+ }
243
+ export function looksLikeShareToken(token) {
244
+ return typeof token === "string" && parseShareToken(token) !== null;
245
+ }
246
+ /** Expire-on-read: a grant past `notAfter` is expired regardless of its state. */
247
+ function withDerivedState(grant, now) {
248
+ if (grant.state === "active" &&
249
+ grant.gates.notAfter !== undefined &&
250
+ grant.gates.notAfter <= now) {
251
+ return { ...grant, state: "expired" };
252
+ }
253
+ return grant;
254
+ }
255
+ export async function listShareGrants() {
256
+ await ensureLoaded();
257
+ const now = Date.now();
258
+ return Object.values(cache)
259
+ .map((grant) => withDerivedState(grant, now))
260
+ .sort((a, b) => a.createdAt - b.createdAt);
261
+ }
262
+ export async function getShareGrant(id) {
263
+ await ensureLoaded();
264
+ const grant = cache[id];
265
+ return grant ? withDerivedState(grant, Date.now()) : undefined;
266
+ }
267
+ /** Look a grant up by peer label (case-insensitive), the CLI's addressing form. */
268
+ export async function findShareGrantByPeer(peerLabel) {
269
+ const wanted = peerLabel.trim().toLowerCase();
270
+ const grants = await listShareGrants();
271
+ return (grants.find((grant) => grant.peerLabel.toLowerCase() === wanted && grant.state !== "revoked") ??
272
+ grants.find((grant) => grant.id === peerLabel) ??
273
+ // A revoked grant is still addressable for management. Excluding it
274
+ // outright stranded it in the file: `share delete` could not find it by
275
+ // name, and only its id — which nothing prints after issue — would do.
276
+ grants.find((grant) => grant.peerLabel.toLowerCase() === wanted));
277
+ }
278
+ export async function createShareGrant(input) {
279
+ return mutationMutex.runExclusive(async () => {
280
+ // Force: `persist()` writes the whole map back, so a mutation that ran on a
281
+ // TTL-fresh snapshot would resurrect a grant the CLI deleted, or revert a
282
+ // pause it set, in the window since this process last read the file.
283
+ await ensureLoaded({ force: true });
284
+ const now = Date.now();
285
+ const id = randomBytes(6).toString("hex");
286
+ const secret = randomBytes(32).toString("base64url");
287
+ const tokenSalt = randomBytes(16).toString("hex");
288
+ const grant = {
289
+ schemaVersion: 1,
290
+ id,
291
+ peerLabel: input.peerLabel.trim(),
292
+ tokenHash: hashSecret(tokenSalt, secret),
293
+ tokenSalt,
294
+ // Keyed separately from the token so rotating the token does not
295
+ // invalidate every receipt already issued under it.
296
+ receiptSecret: randomBytes(32).toString("base64url"),
297
+ level: input.level,
298
+ state: "active",
299
+ entitlement: input.entitlement,
300
+ gates: input.gates,
301
+ createdAt: now,
302
+ updatedAt: now,
303
+ ...(input.note ? { note: input.note } : {}),
304
+ };
305
+ cache[id] = grant;
306
+ await persist();
307
+ return {
308
+ grant,
309
+ token: `${SHARE_TOKEN_PREFIX}_${id}_${secret}`,
310
+ };
311
+ });
312
+ }
313
+ /**
314
+ * Replace a grant's token, keeping every control intact.
315
+ * Used by `share revoke --rotate` and by any suspected token leak.
316
+ */
317
+ export async function rotateShareGrantToken(id) {
318
+ return mutationMutex.runExclusive(async () => {
319
+ // Force: `persist()` writes the whole map back, so a mutation that ran on a
320
+ // TTL-fresh snapshot would resurrect a grant the CLI deleted, or revert a
321
+ // pause it set, in the window since this process last read the file.
322
+ await ensureLoaded({ force: true });
323
+ const grant = cache[id];
324
+ if (!grant) {
325
+ return undefined;
326
+ }
327
+ const secret = randomBytes(32).toString("base64url");
328
+ const tokenSalt = randomBytes(16).toString("hex");
329
+ const updated = {
330
+ ...grant,
331
+ tokenSalt,
332
+ tokenHash: hashSecret(tokenSalt, secret),
333
+ updatedAt: Date.now(),
334
+ };
335
+ cache[id] = updated;
336
+ await persist();
337
+ return {
338
+ grant: updated,
339
+ token: `${SHARE_TOKEN_PREFIX}_${id}_${secret}`,
340
+ };
341
+ });
342
+ }
343
+ export async function setShareGrantState(id, state) {
344
+ return mutationMutex.runExclusive(async () => {
345
+ // Force: `persist()` writes the whole map back, so a mutation that ran on a
346
+ // TTL-fresh snapshot would resurrect a grant the CLI deleted, or revert a
347
+ // pause it set, in the window since this process last read the file.
348
+ await ensureLoaded({ force: true });
349
+ const grant = cache[id];
350
+ if (!grant) {
351
+ return undefined;
352
+ }
353
+ const updated = {
354
+ ...grant,
355
+ state,
356
+ updatedAt: Date.now(),
357
+ };
358
+ cache[id] = updated;
359
+ await persist();
360
+ return updated;
361
+ });
362
+ }
363
+ export async function updateShareGrant(id, patch) {
364
+ return mutationMutex.runExclusive(async () => {
365
+ // Force: `persist()` writes the whole map back, so a mutation that ran on a
366
+ // TTL-fresh snapshot would resurrect a grant the CLI deleted, or revert a
367
+ // pause it set, in the window since this process last read the file.
368
+ await ensureLoaded({ force: true });
369
+ const grant = cache[id];
370
+ if (!grant) {
371
+ return undefined;
372
+ }
373
+ const updated = {
374
+ ...grant,
375
+ ...(patch.level ? { level: patch.level } : {}),
376
+ ...(patch.note !== undefined ? { note: patch.note } : {}),
377
+ entitlement: patch.entitlement
378
+ ? { ...grant.entitlement, ...patch.entitlement }
379
+ : grant.entitlement,
380
+ gates: patch.gates ? { ...grant.gates, ...patch.gates } : grant.gates,
381
+ updatedAt: Date.now(),
382
+ };
383
+ cache[id] = updated;
384
+ await persist();
385
+ return updated;
386
+ });
387
+ }
388
+ /**
389
+ * Attach complete-mode lease material to a grant.
390
+ *
391
+ * Separate from `updateShareGrant` because these are not policy the operator
392
+ * edits — the secret is generated once and the borrower's copy is keyed to it,
393
+ * so overwriting it silently would invalidate every lease already in the field.
394
+ */
395
+ export async function attachLeaseMaterial(id, leaseSecret, leasePolicy, provisionedAccount) {
396
+ return mutationMutex.runExclusive(async () => {
397
+ await ensureLoaded({ force: true });
398
+ const grant = cache[id];
399
+ if (!grant) {
400
+ return undefined;
401
+ }
402
+ const updated = {
403
+ ...grant,
404
+ leaseSecret: grant.leaseSecret ?? leaseSecret,
405
+ leasePolicy,
406
+ ...(provisionedAccount
407
+ ? { provisionedAccount }
408
+ : grant.provisionedAccount
409
+ ? { provisionedAccount: grant.provisionedAccount }
410
+ : {}),
411
+ updatedAt: Date.now(),
412
+ };
413
+ cache[id] = updated;
414
+ await persist();
415
+ return updated;
416
+ });
417
+ }
418
+ /**
419
+ * Subtract settled spend from a metered grant's balance.
420
+ *
421
+ * Read-modify-write **inside** the store's mutex, because settlement is
422
+ * concurrent by nature: two streams finishing together would otherwise both read
423
+ * the same balance, and the second write would erase the first one's deduction.
424
+ * Returns the new balance, or `undefined` for a grant that is not metered.
425
+ */
426
+ export async function debitShareGrantCoins(id, coins) {
427
+ return mutationMutex.runExclusive(async () => {
428
+ // Force: `persist()` writes the whole map back, so a mutation that ran on a
429
+ // TTL-fresh snapshot would resurrect a grant the CLI deleted, or revert a
430
+ // pause it set, in the window since this process last read the file.
431
+ await ensureLoaded({ force: true });
432
+ const grant = cache[id];
433
+ if (!grant || grant.entitlement.ledger !== "coins") {
434
+ return undefined;
435
+ }
436
+ const remaining = Math.max(0, (grant.entitlement.coins ?? 0) - coins);
437
+ cache[id] = {
438
+ ...grant,
439
+ entitlement: { ...grant.entitlement, coins: remaining },
440
+ updatedAt: Date.now(),
441
+ };
442
+ await persist();
443
+ return remaining;
444
+ });
445
+ }
446
+ /**
447
+ * Add coins to a metered grant's balance.
448
+ *
449
+ * The mirror of `debitShareGrantCoins`, and under the same lock for the same
450
+ * reason. Used by reciprocal netting, by `share topup`'s successor paths, and by
451
+ * redeeming a coin note — all of which can land while requests are settling.
452
+ */
453
+ export async function creditShareGrantCoins(id, coins) {
454
+ if (!(coins > 0)) {
455
+ return undefined;
456
+ }
457
+ return mutationMutex.runExclusive(async () => {
458
+ await ensureLoaded({ force: true });
459
+ const grant = cache[id];
460
+ if (!grant || grant.entitlement.ledger !== "coins") {
461
+ return undefined;
462
+ }
463
+ const balance = (grant.entitlement.coins ?? 0) + coins;
464
+ cache[id] = {
465
+ ...grant,
466
+ entitlement: { ...grant.entitlement, coins: balance },
467
+ updatedAt: Date.now(),
468
+ };
469
+ await persist();
470
+ return balance;
471
+ });
472
+ }
473
+ /**
474
+ * This node's secret for signing coin notes.
475
+ *
476
+ * Node-level rather than per-grant: a note may be redeemed by a grant that did
477
+ * not exist when it was issued, which is the entire point of a transferable
478
+ * one. Minted on first use so a node that never issues a note never has one.
479
+ */
480
+ export async function getOrCreateNoteSecret() {
481
+ return mutationMutex.runExclusive(async () => {
482
+ await ensureLoaded({ force: true });
483
+ if (!cachedNoteSecret) {
484
+ cachedNoteSecret = randomBytes(32).toString("base64url");
485
+ await persist();
486
+ }
487
+ return cachedNoteSecret;
488
+ });
489
+ }
490
+ /** The note secret, without minting one. */
491
+ export async function getNoteSecret() {
492
+ await ensureLoaded();
493
+ return cachedNoteSecret;
494
+ }
495
+ export async function deleteShareGrant(id) {
496
+ return mutationMutex.runExclusive(async () => {
497
+ // Force: `persist()` writes the whole map back, so a mutation that ran on a
498
+ // TTL-fresh snapshot would resurrect a grant the CLI deleted, or revert a
499
+ // pause it set, in the window since this process last read the file.
500
+ await ensureLoaded({ force: true });
501
+ if (!cache[id]) {
502
+ return false;
503
+ }
504
+ delete cache[id];
505
+ await persist();
506
+ return true;
507
+ });
508
+ }
509
+ /**
510
+ * Resolve a presented token to its grant.
511
+ *
512
+ * Returns the grant whatever its state — admission is `sharePolicy`'s decision,
513
+ * and a paused grant must still be identifiable so the refusal can say *why*
514
+ * rather than "unknown token".
515
+ */
516
+ export async function resolveShareToken(token) {
517
+ const parsed = parseShareToken(token);
518
+ if (!parsed) {
519
+ return undefined;
520
+ }
521
+ await ensureLoaded();
522
+ let grant = cache[parsed.grantId];
523
+ if (!grant) {
524
+ // A token minted moments ago would otherwise read as unknown until the
525
+ // snapshot TTL lapsed, so a miss re-stats before concluding anything.
526
+ //
527
+ // `revalidate`, emphatically not `force`. This runs on the inbound path
528
+ // before anything has authenticated, and every unknown token reaches it —
529
+ // so a client repeating one invalid token must not be able to buy a
530
+ // `readFile` plus a `JSON.parse` per request. Costing one `stat` and
531
+ // stopping on an unchanged mtime is the whole point.
532
+ await ensureLoaded({ revalidate: true });
533
+ grant = cache[parsed.grantId];
534
+ }
535
+ if (!grant) {
536
+ return undefined;
537
+ }
538
+ if (!digestsMatch(grant.tokenHash, hashSecret(grant.tokenSalt, parsed.secret))) {
539
+ return undefined;
540
+ }
541
+ return withDerivedState(grant, Date.now());
542
+ }
543
+ /**
544
+ * Record that a grant served traffic.
545
+ *
546
+ * `lastUsedAt` is cosmetic, so it is held in memory and flushed at most once a
547
+ * minute. Persisting it per request would put a file write on the hot path and —
548
+ * worse — move the file's mtime constantly, forcing every reader to reload the
549
+ * snapshot it just loaded.
550
+ */
551
+ export function touchShareGrantUsage(id) {
552
+ const grant = cache[id];
553
+ if (!grant) {
554
+ return;
555
+ }
556
+ const now = Date.now();
557
+ // Patch the live map so an in-process read sees it immediately, and remember
558
+ // the stamp separately: the flush below reloads the file first, which
559
+ // replaces `cache` wholesale and would otherwise drop the patch.
560
+ cache[id] = { ...grant, lastUsedAt: now };
561
+ pendingUsage[id] = now;
562
+ if (now - lastUsageFlushAt < USAGE_FLUSH_INTERVAL_MS) {
563
+ return;
564
+ }
565
+ lastUsageFlushAt = now;
566
+ void mutationMutex
567
+ .runExclusive(async () => {
568
+ // Reload before writing. `persist()` serializes the whole map, so
569
+ // flushing a cosmetic timestamp onto a stale snapshot would resurrect a
570
+ // grant the CLI deleted in the meantime — the timestamp is not worth
571
+ // that. Stamps for grants that are gone are simply dropped.
572
+ await ensureLoaded({ force: true });
573
+ const stamps = pendingUsage;
574
+ pendingUsage = {};
575
+ let touched = false;
576
+ for (const [grantId, lastUsedAt] of Object.entries(stamps)) {
577
+ const current = cache[grantId];
578
+ if (current) {
579
+ cache[grantId] = { ...current, lastUsedAt };
580
+ touched = true;
581
+ }
582
+ }
583
+ if (touched) {
584
+ await persist();
585
+ }
586
+ })
587
+ .catch(() => {
588
+ // Cosmetic only — never surfaced to the request.
589
+ });
590
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Leases — how a lender keeps control of a credential that lives on someone
3
+ * else's machine.
4
+ *
5
+ * In **live** sharing the lender's gate is in the request path, so control is
6
+ * immediate and total. **Complete** sharing trades that away: the borrower holds
7
+ * its own credential on the lender's account and calls the upstream directly, so
8
+ * the lender is not consulted per request. What is left is this — a signed,
9
+ * time-boxed statement of consent that the borrower enforces on itself and must
10
+ * keep renewing.
11
+ *
12
+ * The design turns on one number: **how long may the borrower run without
13
+ * hearing from me.** `offlineGraceMs` is that number. Set it to zero and
14
+ * complete mode collapses into live mode's availability; set it to a day and the
15
+ * borrower keeps working through a weekend when the lender's laptop is shut, at
16
+ * the cost of a day's revocation latency. Both are legitimate; neither is free.
17
+ *
18
+ * `notAfter` is the backstop that does not depend on the borrower's cooperation
19
+ * at all — it is baked into the signed payload, so a borrower that simply never
20
+ * calls home still stops.
21
+ *
22
+ * **Signing.** HMAC-SHA256 keyed by a per-grant secret shared with the borrower
23
+ * at provisioning time. Asymmetric signatures would be tidier, but the key
24
+ * distribution problem they solve does not exist here — exactly two parties are
25
+ * involved and they already share a secret — and the package's browser bundle
26
+ * stubs `node:crypto` down to a subset with no Ed25519 in it.
27
+ *
28
+ * @module proxy/shareLease
29
+ */
30
+ import type { ProxyShareGates, ProxyShareGrant, ProxyShareLease, ProxyShareLeaseRefusal, ProxyShareLeaseVerdict, ProxyShareProvisionClaim } from "../types/index.js";
31
+ /** A week: long enough to survive a holiday, short enough to be a real bound. */
32
+ export declare const DEFAULT_LEASE_TTL_MS = 604800000;
33
+ /** Fifteen minutes — frequent enough that a pause lands the same session. */
34
+ export declare const DEFAULT_HEARTBEAT_MS = 900000;
35
+ /** Twenty-four hours of running unheard-from. The headline trade-off. */
36
+ export declare const DEFAULT_OFFLINE_GRACE_MS = 86400000;
37
+ /**
38
+ * Narrow a verdict to its refusing half.
39
+ *
40
+ * One of the package's build steps compiles without `strictNullChecks`, where
41
+ * TypeScript will not narrow a boolean discriminant. An explicit predicate holds
42
+ * in both modes — see the same pattern in `sharePolicy.isShareRefusal`.
43
+ */
44
+ export declare function isLeaseRefusal(verdict: ProxyShareLeaseVerdict): verdict is ProxyShareLeaseRefusal;
45
+ export declare function generateLeaseSecret(): string;
46
+ /**
47
+ * Issue a lease for a grant.
48
+ *
49
+ * The gates are snapshotted rather than referenced: the borrower enforces what
50
+ * the lender agreed to at issue time, and a tightened policy reaches them at the
51
+ * next heartbeat rather than silently mid-lease.
52
+ */
53
+ export declare function issueLease(grant: ProxyShareGrant, now?: number): ProxyShareLease;
54
+ /** Does this lease actually come from the lender it claims to? */
55
+ export declare function isLeaseAuthentic(lease: ProxyShareLease, secret: string): boolean;
56
+ /**
57
+ * May the borrower serve from this lease right now?
58
+ *
59
+ * Three independent stops, in the order that matters:
60
+ *
61
+ * 1. **Unsigned** — someone edited the file. Nothing else is worth checking.
62
+ * 2. **Expired** — `notAfter` passed. Immune to a borrower that never checks in.
63
+ * 3. **Grace elapsed** — the lender has been unreachable for longer than it
64
+ * agreed to be trusted for. This is the one that makes "the lender turned
65
+ * their laptop off" survivable and "the lender revoked me" eventually
66
+ * binding.
67
+ */
68
+ export declare function evaluateLease(args: {
69
+ lease: ProxyShareLease;
70
+ secret: string;
71
+ lastHeartbeatAt?: number;
72
+ now?: number;
73
+ }): ProxyShareLeaseVerdict;
74
+ /** Should the borrower check in now? */
75
+ export declare function isHeartbeatDue(lease: ProxyShareLease, lastHeartbeatAt: number | undefined, now?: number): boolean;
76
+ /** The gates a resident grant must enforce on itself, from its lease. */
77
+ export declare function leasedGates(lease: ProxyShareLease): ProxyShareGates;
78
+ /**
79
+ * Assemble what the borrower collects once the lender has authorized.
80
+ *
81
+ * Carries no token, by construction. The borrower already holds the verifier
82
+ * that turns the enclosed code into tokens, and it exchanges the two on its own
83
+ * machine — so nothing here is worth intercepting, and the lender never holds a
84
+ * credential for the account it just authorized.
85
+ *
86
+ * `accountLabel` is the lender's *suggestion*, not the key the credential ends
87
+ * up under. The borrower names the account itself — `<peer>-shared` unless
88
+ * `--label` says otherwise — and refuses to install over an existing one,
89
+ * because Anthropic quota snapshots are keyed by the bare label and two
90
+ * accounts sharing one would merge each other's windows. Derived from both
91
+ * parties' names so that a lender running `share list` and a borrower running
92
+ * `auth list` are looking at recognisably the same thing.
93
+ */
94
+ export declare function buildProvisionClaim(args: {
95
+ grant: ProxyShareGrant;
96
+ lenderName: string;
97
+ lenderUrl?: string;
98
+ code: string;
99
+ state: string;
100
+ now?: number;
101
+ }): ProxyShareProvisionClaim;