@lightninglabs/pi-402 0.0.0-stage → 0.0.1

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/src/store.ts ADDED
@@ -0,0 +1,387 @@
1
+ // Durable local state for the extension: the paid L402 token cache and the
2
+ // payment receipt ledger. Both live under the pi-402 data directory
3
+ // (PI402_DATA_DIR, defaulting to ~/.pi402), so paid bundles survive process
4
+ // restarts instead of being re-purchased, and every settled payment leaves an
5
+ // auditable record. Both files are shared by concurrent pi sessions, so every
6
+ // operation reads fresh and writes without clobbering another session's
7
+ // state.
8
+
9
+ import { randomUUID } from "node:crypto";
10
+ import { appendFile, chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import type {
14
+ CachedCredential,
15
+ CachedToken,
16
+ ClientReceipt,
17
+ CredentialStore,
18
+ TokenStore,
19
+ } from "@lightninglabs/aperture-client";
20
+
21
+ // dataDir resolves the directory durable state lives in. The directory is
22
+ // created lazily by the stores on first write.
23
+ export function dataDir(): string {
24
+ return process.env.PI402_DATA_DIR ?? join(homedir(), ".pi402");
25
+ }
26
+
27
+ // tokenStoreState is the JSON serialization of the token store file.
28
+ interface TokenStoreState {
29
+ version: 1;
30
+ tokens: Record<string, CachedToken>;
31
+ }
32
+
33
+ // isExpired reports whether a cached token is past its expiry. An empty
34
+ // expiry means the challenge carried none, so the token does not expire; a
35
+ // non-empty expiry that fails to parse counts as expired, since an immortal
36
+ // bearer credential is the wrong failure mode for garbage input.
37
+ function isExpired(token: CachedToken): boolean {
38
+ if (!token.expiresAt) {
39
+ return false;
40
+ }
41
+ const expiresMs = new Date(token.expiresAt).getTime();
42
+ return Number.isNaN(expiresMs) || Date.now() > expiresMs;
43
+ }
44
+
45
+ // FileTokenStore is a TokenStore backed by a JSON file. A cached token is a
46
+ // live bearer credential for the bundle's unspent balance (macaroon plus
47
+ // preimage), so the file is written with owner-only permissions and replaced
48
+ // atomically: a crash mid-write must not corrupt the previous state.
49
+ //
50
+ // The file is shared by concurrent sessions: reads are always fresh, and a
51
+ // write re-reads the file and merges before rewriting, so one session's paid
52
+ // token cannot be clobbered by another session persisting its own. The
53
+ // read-merge-write is not atomic across processes; two puts racing in the
54
+ // same instant can still lose one token, a window that is tiny and whose
55
+ // worst case is re-purchasing one bundle.
56
+ export class FileTokenStore implements TokenStore {
57
+ private readonly path: string;
58
+
59
+ constructor(path: string) {
60
+ this.path = path;
61
+ }
62
+
63
+ // readState reads and parses the state file fresh, dropping expired
64
+ // entries. A missing file is a fresh start. A corrupt file is set aside
65
+ // for postmortem rather than deleted, and the read yields empty: a
66
+ // broken cache must never block payments, the worst case is
67
+ // re-purchasing a bundle.
68
+ private async readState(): Promise<Map<string, CachedToken>> {
69
+ let raw: string;
70
+ try {
71
+ raw = await readFile(this.path, "utf8");
72
+ } catch (err) {
73
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
74
+ return new Map();
75
+ }
76
+ // Any other read failure propagates. It is tempting to treat
77
+ // this like corruption and yield empty, but the two are not
78
+ // alike: a corrupt file has nothing left to lose, whereas an
79
+ // intact file that merely failed to open still holds every
80
+ // other session's paid tokens, and put() rewrites the file
81
+ // from whatever this returns. Yielding empty here would let
82
+ // one transient read error replace the whole file with a
83
+ // single token. get() absorbs the throw instead, so a
84
+ // failed read still cannot block a payment.
85
+ throw err;
86
+ }
87
+
88
+ try {
89
+ const state = JSON.parse(raw) as TokenStoreState;
90
+ const tokens = new Map<string, CachedToken>();
91
+ for (const [key, token] of Object.entries(state.tokens ?? {})) {
92
+ if (token && typeof token === "object" && !isExpired(token)) {
93
+ tokens.set(key, token);
94
+ }
95
+ }
96
+ return tokens;
97
+ } catch {
98
+ // Quarantining a paid-token file is worth a loud line: the
99
+ // bounded cost is re-purchasing whatever bundles it held.
100
+ console.warn(`pi-402: token store ${this.path} is corrupt, setting it aside`);
101
+ try {
102
+ await rename(this.path, `${this.path}.${Date.now()}.corrupt`);
103
+ } catch {
104
+ // Another session already set it aside; losing that race
105
+ // is fine.
106
+ }
107
+ return new Map();
108
+ }
109
+ }
110
+
111
+ async get(key: string): Promise<CachedToken | null> {
112
+ // A read that fails here means we cannot tell whether a paid token
113
+ // exists, which is a reason to buy one, not a reason to fail the
114
+ // request: a broken cache must never block a payment. The worst
115
+ // case is re-purchasing a bundle.
116
+ let tokens: Map<string, CachedToken>;
117
+ try {
118
+ tokens = await this.readState();
119
+ } catch (err) {
120
+ console.warn(`pi-402: token store ${this.path} is unreadable, continuing without it:`, err);
121
+ return null;
122
+ }
123
+
124
+ return tokens.get(key) ?? null;
125
+ }
126
+
127
+ async put(key: string, token: CachedToken): Promise<void> {
128
+ // Merge on write: re-read the file so tokens persisted by other
129
+ // sessions since our last read survive this rewrite. Expired
130
+ // entries fall out here, so the file cannot grow without bound.
131
+ const tokens = await this.readState();
132
+ tokens.set(key, token);
133
+
134
+ const state: TokenStoreState = {
135
+ version: 1,
136
+ tokens: Object.fromEntries(tokens),
137
+ };
138
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
139
+
140
+ // The tmp name is unique per write, so concurrent sessions cannot
141
+ // truncate each other's in-flight tmp file before the rename.
142
+ const tmpPath = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
143
+ await writeFile(tmpPath, JSON.stringify(state, null, "\t"), { mode: 0o600 });
144
+ await rename(tmpPath, this.path);
145
+ }
146
+ }
147
+
148
+ // credentialStoreState is the JSON serialization of the credential store file.
149
+ interface CredentialStoreState {
150
+ version: 1;
151
+ credentials: Record<string, CachedCredential>;
152
+ }
153
+
154
+ // isCredentialExpired reports whether a cached credential is past its expiry.
155
+ // The rule matches the token store's: no expiry means the challenge carried
156
+ // none, and an expiry that fails to parse counts as expired rather than
157
+ // immortal.
158
+ function isCredentialExpired(credential: CachedCredential): boolean {
159
+ if (!credential.expiresAt) {
160
+ return false;
161
+ }
162
+ const expiresMs = Date.parse(credential.expiresAt);
163
+ return Number.isNaN(expiresMs) || Date.now() > expiresMs;
164
+ }
165
+
166
+ // FileCredentialStore is a CredentialStore backed by a JSON file, the Payment
167
+ // scheme's counterpart to FileTokenStore.
168
+ //
169
+ // What it protects against is narrow but expensive: the paid retry throws
170
+ // after the money moved, the caller retries, and without the credential on
171
+ // disk the client fetches a fresh challenge and pays for the same thing a
172
+ // second time. A stored credential turns that into at worst one refused
173
+ // request. It holds an Authorization header for a settled payment, so the
174
+ // file gets the same owner-only permissions and atomic replacement the token
175
+ // store gets.
176
+ //
177
+ // The concurrency story also matches the token store's, and is worth
178
+ // restating here because parallel model calls make racing puts likely: the
179
+ // file is shared by concurrent sessions, reads are always fresh, and a write
180
+ // re-reads and merges before rewriting, so one session's paid credential is
181
+ // not clobbered by another persisting its own. The read-merge-write is not
182
+ // atomic, though, across processes or across concurrent puts within one, so
183
+ // two writes racing in the same instant can still lose a credential. That
184
+ // window is tiny, and its worst case is bounded: the retry the lost
185
+ // credential would have served re-purchases one request's worth.
186
+ export class FileCredentialStore implements CredentialStore {
187
+ private readonly path: string;
188
+
189
+ constructor(path: string) {
190
+ this.path = path;
191
+ }
192
+
193
+ // readState reads and parses the state file fresh, dropping expired
194
+ // entries. The failure handling matches FileTokenStore for the same
195
+ // reasons: a missing file is a fresh start, a corrupt file is set aside
196
+ // rather than deleted, and any other read failure propagates so that one
197
+ // transient error cannot rewrite the file down to a single entry.
198
+ private async readState(): Promise<Map<string, CachedCredential>> {
199
+ let raw: string;
200
+ try {
201
+ raw = await readFile(this.path, "utf8");
202
+ } catch (err) {
203
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
204
+ return new Map();
205
+ }
206
+ throw err;
207
+ }
208
+
209
+ try {
210
+ const state = JSON.parse(raw) as CredentialStoreState;
211
+ const credentials = new Map<string, CachedCredential>();
212
+ for (const [key, credential] of Object.entries(state.credentials ?? {})) {
213
+ if (
214
+ credential &&
215
+ typeof credential === "object" &&
216
+ typeof credential.authorization === "string" &&
217
+ !isCredentialExpired(credential)
218
+ ) {
219
+ credentials.set(key, credential);
220
+ }
221
+ }
222
+ return credentials;
223
+ } catch {
224
+ console.warn(`pi-402: credential store ${this.path} is corrupt, setting it aside`);
225
+ try {
226
+ await rename(this.path, `${this.path}.${Date.now()}.corrupt`);
227
+ } catch {
228
+ // Another session already set it aside; losing that race is
229
+ // fine.
230
+ }
231
+ return new Map();
232
+ }
233
+ }
234
+
235
+ // write replaces the file with the given entries, merging happening in the
236
+ // callers so that put and delete share one atomic rewrite.
237
+ private async write(credentials: Map<string, CachedCredential>): Promise<void> {
238
+ const state: CredentialStoreState = {
239
+ version: 1,
240
+ credentials: Object.fromEntries(credentials),
241
+ };
242
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
243
+
244
+ const tmpPath = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
245
+ await writeFile(tmpPath, JSON.stringify(state, null, "\t"), { mode: 0o600 });
246
+ await rename(tmpPath, this.path);
247
+ }
248
+
249
+ async get(key: string): Promise<CachedCredential | null> {
250
+ // An unreadable store is a reason to buy, not a reason to fail: the
251
+ // worst case is re-purchasing one credential, which is exactly where
252
+ // the caller would be with no store at all.
253
+ try {
254
+ return (await this.readState()).get(key) ?? null;
255
+ } catch (err) {
256
+ console.warn(`pi-402: credential store ${this.path} is unreadable, continuing without it:`, err);
257
+ return null;
258
+ }
259
+ }
260
+
261
+ async put(key: string, credential: CachedCredential): Promise<void> {
262
+ const credentials = await this.readState();
263
+ credentials.set(key, credential);
264
+ await this.write(credentials);
265
+ }
266
+
267
+ async delete(key: string): Promise<void> {
268
+ // A delete means the server refused this credential, so dropping it
269
+ // matters more than preserving it. An unreadable store leaves nothing
270
+ // to delete from, and throwing here would fail a request whose
271
+ // credential is already known bad.
272
+ let credentials: Map<string, CachedCredential>;
273
+ try {
274
+ credentials = await this.readState();
275
+ } catch (err) {
276
+ console.warn(`pi-402: credential store ${this.path} is unreadable, cannot drop ${key}:`, err);
277
+ return;
278
+ }
279
+
280
+ if (!credentials.delete(key)) {
281
+ return;
282
+ }
283
+ await this.write(credentials);
284
+ }
285
+ }
286
+
287
+ // ReceiptRecord is one line of the receipt ledger: the client receipt from the
288
+ // buyer loop, stamped with the model the payment bought inference for.
289
+ export interface ReceiptRecord extends ClientReceipt {
290
+ model?: string;
291
+ }
292
+
293
+ // receiptPaidSat extracts the paid amount of a receipt: the payer-reported
294
+ // outflow (paidSat, fees included) when usable, the invoice face value as
295
+ // the fallback, zero otherwise. Only finite positive values count: one
296
+ // non-numeric string in a shared file would NaN-poison the day total (NaN
297
+ // loses every cap comparison, silently disabling the daily ceiling), and a
298
+ // negative value would subtract from it, eroding the cap the same way.
299
+ export function receiptPaidSat(receipt: ReceiptRecord): number {
300
+ for (const value of [receipt.paidSat, receipt.amountSat]) {
301
+ const paid = Number(value ?? Number.NaN);
302
+ if (Number.isFinite(paid) && paid > 0) {
303
+ return paid;
304
+ }
305
+ }
306
+ return 0;
307
+ }
308
+
309
+ // FileReceiptLog is an append-only newline-delimited JSON ledger of settled
310
+ // payments. Appends are single writes of one line, so concurrent sessions
311
+ // interleave whole records rather than corrupting each other.
312
+ export class FileReceiptLog {
313
+ private readonly path: string;
314
+
315
+ constructor(path: string) {
316
+ this.path = path;
317
+ }
318
+
319
+ async store(receipt: ReceiptRecord): Promise<void> {
320
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
321
+ await appendFile(this.path, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
322
+
323
+ // appendFile's mode only applies when it creates the file; receipts
324
+ // hold preimages and invoices, so tighten a pre-existing file too.
325
+ await chmod(this.path, 0o600);
326
+ }
327
+
328
+ // readAll returns every well-formed record in the ledger, oldest first. A
329
+ // trailing partial line (a crash mid-append) and any line that parses to
330
+ // something other than a receipt-shaped object are skipped rather than
331
+ // poisoning the whole read; a spend total must never be derailed by one
332
+ // bad line.
333
+ async readAll(): Promise<ReceiptRecord[]> {
334
+ let raw: string;
335
+ try {
336
+ raw = await readFile(this.path, "utf8");
337
+ } catch (err) {
338
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
339
+ return [];
340
+ }
341
+ throw err;
342
+ }
343
+
344
+ const records: ReceiptRecord[] = [];
345
+ for (const line of raw.split("\n")) {
346
+ if (!line.trim()) {
347
+ continue;
348
+ }
349
+ try {
350
+ const parsed = JSON.parse(line) as unknown;
351
+ if (
352
+ parsed !== null &&
353
+ typeof parsed === "object" &&
354
+ typeof (parsed as ReceiptRecord).paymentHash === "string" &&
355
+ // The timestamp must be a string, not merely present.
356
+ // Every reader slices it to get the day, and optional
357
+ // chaining does not guard a non-nullish non-string:
358
+ // (123)?.slice(0, 10) throws. One such line would make
359
+ // spentTodaySat throw on every assertion, degrading the
360
+ // daily cap to the in-memory counter for the rest of the
361
+ // process.
362
+ typeof (parsed as ReceiptRecord).timestamp === "string"
363
+ ) {
364
+ records.push(parsed as ReceiptRecord);
365
+ }
366
+ } catch {
367
+ // Skip unparseable lines; the ledger stays readable.
368
+ }
369
+ }
370
+ return records;
371
+ }
372
+
373
+ // spentTodaySat sums the paid amounts of receipts recorded on the current
374
+ // UTC calendar day. The budget policy reads this on every assertion, so
375
+ // concurrent sessions see each other's settled spends.
376
+ async spentTodaySat(now: Date = new Date()): Promise<number> {
377
+ const day = now.toISOString().slice(0, 10);
378
+
379
+ let total = 0;
380
+ for (const receipt of await this.readAll()) {
381
+ if (receipt.timestamp?.slice(0, 10) === day) {
382
+ total += receiptPaidSat(receipt);
383
+ }
384
+ }
385
+ return total;
386
+ }
387
+ }