mppx 0.8.12 → 0.8.13

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 (193) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +16 -1
  3. package/dist/cli/account.d.ts +88 -0
  4. package/dist/cli/account.d.ts.map +1 -1
  5. package/dist/cli/account.js +39 -9
  6. package/dist/cli/account.js.map +1 -1
  7. package/dist/cli/cli.d.ts +2 -1
  8. package/dist/cli/cli.d.ts.map +1 -1
  9. package/dist/cli/cli.js +59 -2
  10. package/dist/cli/cli.js.map +1 -1
  11. package/dist/cli/sessions/Manager.d.ts +31 -0
  12. package/dist/cli/sessions/Manager.d.ts.map +1 -0
  13. package/dist/cli/sessions/Manager.js +56 -0
  14. package/dist/cli/sessions/Manager.js.map +1 -0
  15. package/dist/cli/sessions/commands.d.ts +36 -0
  16. package/dist/cli/sessions/commands.d.ts.map +1 -0
  17. package/dist/cli/sessions/commands.js +407 -0
  18. package/dist/cli/sessions/commands.js.map +1 -0
  19. package/dist/cli/sessions/request.d.ts +35 -0
  20. package/dist/cli/sessions/request.d.ts.map +1 -0
  21. package/dist/cli/sessions/request.js +271 -0
  22. package/dist/cli/sessions/request.js.map +1 -0
  23. package/dist/cli/sessions/store.d.ts +134 -0
  24. package/dist/cli/sessions/store.d.ts.map +1 -0
  25. package/dist/cli/sessions/store.js +723 -0
  26. package/dist/cli/sessions/store.js.map +1 -0
  27. package/dist/cli/utils.d.ts +2 -0
  28. package/dist/cli/utils.d.ts.map +1 -1
  29. package/dist/cli/utils.js +12 -0
  30. package/dist/cli/utils.js.map +1 -1
  31. package/dist/cli/validate/index.d.ts.map +1 -1
  32. package/dist/cli/validate/index.js +12 -6
  33. package/dist/cli/validate/index.js.map +1 -1
  34. package/dist/cli/validate/messages.d.ts +2 -0
  35. package/dist/cli/validate/messages.d.ts.map +1 -0
  36. package/dist/cli/validate/messages.js +8 -0
  37. package/dist/cli/validate/messages.js.map +1 -0
  38. package/dist/client/node.d.ts +29 -0
  39. package/dist/client/node.d.ts.map +1 -0
  40. package/dist/client/node.js +177 -0
  41. package/dist/client/node.js.map +1 -0
  42. package/dist/internal/types.d.ts +24 -0
  43. package/dist/internal/types.d.ts.map +1 -1
  44. package/dist/stripe/server/Charge.d.ts +8 -4
  45. package/dist/stripe/server/Charge.d.ts.map +1 -1
  46. package/dist/stripe/server/Charge.js.map +1 -1
  47. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  48. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  49. package/dist/stripe/server/internal/html.gen.js +1 -1
  50. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  51. package/dist/tempo/client/Methods.d.ts +1 -1
  52. package/dist/tempo/internal/types.d.ts +10 -1
  53. package/dist/tempo/internal/types.d.ts.map +1 -1
  54. package/dist/tempo/legacy/client/Session.d.ts +2 -2
  55. package/dist/tempo/legacy/server/Session.d.ts +3 -3
  56. package/dist/tempo/legacy/server/Session.d.ts.map +1 -1
  57. package/dist/tempo/legacy/server/Session.js.map +1 -1
  58. package/dist/tempo/server/Charge.d.ts +3 -2
  59. package/dist/tempo/server/Charge.d.ts.map +1 -1
  60. package/dist/tempo/server/Charge.js.map +1 -1
  61. package/dist/tempo/server/Methods.d.ts +2 -23
  62. package/dist/tempo/server/Methods.d.ts.map +1 -1
  63. package/dist/tempo/server/Methods.js +2 -2
  64. package/dist/tempo/server/Methods.js.map +1 -1
  65. package/dist/tempo/server/Subscription.d.ts +3 -2
  66. package/dist/tempo/server/Subscription.d.ts.map +1 -1
  67. package/dist/tempo/server/Subscription.js.map +1 -1
  68. package/dist/tempo/server/index.d.ts +1 -1
  69. package/dist/tempo/server/index.d.ts.map +1 -1
  70. package/dist/tempo/server/index.js.map +1 -1
  71. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  72. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  73. package/dist/tempo/server/internal/html.gen.js +1 -1
  74. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  75. package/dist/tempo/session/Snapshot.d.ts +9 -0
  76. package/dist/tempo/session/Snapshot.d.ts.map +1 -1
  77. package/dist/tempo/session/Snapshot.js +6 -0
  78. package/dist/tempo/session/Snapshot.js.map +1 -1
  79. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
  80. package/dist/tempo/session/client/ChannelOps.js +11 -2
  81. package/dist/tempo/session/client/ChannelOps.js.map +1 -1
  82. package/dist/tempo/session/client/CredentialState.d.ts +21 -1
  83. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
  84. package/dist/tempo/session/client/CredentialState.js +66 -1
  85. package/dist/tempo/session/client/CredentialState.js.map +1 -1
  86. package/dist/tempo/session/client/Session.d.ts +1 -1
  87. package/dist/tempo/session/client/SessionManager.d.ts +2 -2
  88. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  89. package/dist/tempo/session/client/SessionManager.js +128 -30
  90. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  91. package/dist/tempo/session/client/Transports.d.ts +11 -2
  92. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  93. package/dist/tempo/session/client/Transports.js +33 -9
  94. package/dist/tempo/session/client/Transports.js.map +1 -1
  95. package/dist/tempo/session/client/internal/SessionManager.d.ts +19 -0
  96. package/dist/tempo/session/client/internal/SessionManager.d.ts.map +1 -0
  97. package/dist/tempo/session/client/internal/SessionManager.js +13 -0
  98. package/dist/tempo/session/client/internal/SessionManager.js.map +1 -0
  99. package/dist/tempo/session/precompile/Chain.js +13 -4
  100. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  101. package/dist/tempo/session/server/CredentialVerification.d.ts +3 -0
  102. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  103. package/dist/tempo/session/server/CredentialVerification.js +14 -0
  104. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  105. package/dist/tempo/session/server/MeteredStream.d.ts +7 -1
  106. package/dist/tempo/session/server/MeteredStream.d.ts.map +1 -1
  107. package/dist/tempo/session/server/MeteredStream.js +5 -4
  108. package/dist/tempo/session/server/MeteredStream.js.map +1 -1
  109. package/dist/tempo/session/server/RequestState.d.ts +5 -1
  110. package/dist/tempo/session/server/RequestState.d.ts.map +1 -1
  111. package/dist/tempo/session/server/RequestState.js +10 -4
  112. package/dist/tempo/session/server/RequestState.js.map +1 -1
  113. package/dist/tempo/session/server/Session.d.ts +21 -6
  114. package/dist/tempo/session/server/Session.d.ts.map +1 -1
  115. package/dist/tempo/session/server/Session.js +4 -1
  116. package/dist/tempo/session/server/Session.js.map +1 -1
  117. package/dist/tempo/session/server/Settlement.d.ts +20 -0
  118. package/dist/tempo/session/server/Settlement.d.ts.map +1 -1
  119. package/dist/tempo/session/server/Settlement.js +20 -0
  120. package/dist/tempo/session/server/Settlement.js.map +1 -1
  121. package/dist/tempo/session/server/Ws.d.ts +2 -0
  122. package/dist/tempo/session/server/Ws.d.ts.map +1 -1
  123. package/dist/tempo/session/server/Ws.js.map +1 -1
  124. package/dist/tempo/session/server/index.d.ts +1 -1
  125. package/dist/tempo/session/server/index.d.ts.map +1 -1
  126. package/dist/tempo/subscription/KeyAuthorization.d.ts +21 -21
  127. package/dist/validation/core.d.ts.map +1 -1
  128. package/dist/validation/core.js +6 -2
  129. package/dist/validation/core.js.map +1 -1
  130. package/dist/viem/Client.d.ts.map +1 -1
  131. package/dist/viem/Client.js +24 -17
  132. package/dist/viem/Client.js.map +1 -1
  133. package/package.json +6 -1
  134. package/src/cli/account.ts +45 -10
  135. package/src/cli/cli.test.ts +165 -70
  136. package/src/cli/cli.ts +67 -2
  137. package/src/cli/mcp.test.ts +11 -0
  138. package/src/cli/sessions/Manager.test.ts +249 -0
  139. package/src/cli/sessions/Manager.ts +93 -0
  140. package/src/cli/sessions/commands.ts +444 -0
  141. package/src/cli/sessions/request.test.ts +51 -0
  142. package/src/cli/sessions/request.ts +353 -0
  143. package/src/cli/sessions/store.test.ts +581 -0
  144. package/src/cli/sessions/store.ts +940 -0
  145. package/src/cli/utils.test.ts +23 -0
  146. package/src/cli/utils.ts +10 -0
  147. package/src/cli/validate/index.ts +12 -14
  148. package/src/cli/validate/messages.ts +7 -0
  149. package/src/cli/validate.test.ts +38 -5
  150. package/src/client/node.test.ts +115 -0
  151. package/src/client/node.ts +247 -0
  152. package/src/internal/types.test-d.ts +21 -0
  153. package/src/internal/types.ts +33 -0
  154. package/src/server/Mppx.test-d.ts +42 -0
  155. package/src/stripe/Methods.test.ts +10 -0
  156. package/src/stripe/server/Charge.test-d.ts +66 -0
  157. package/src/stripe/server/Charge.ts +8 -5
  158. package/src/stripe/server/internal/html.gen.ts +1 -1
  159. package/src/tempo/internal/types.ts +13 -2
  160. package/src/tempo/legacy/server/Defaults.test-d.ts +9 -0
  161. package/src/tempo/legacy/server/Session.ts +6 -5
  162. package/src/tempo/server/Charge.ts +6 -4
  163. package/src/tempo/server/Methods.ts +5 -4
  164. package/src/tempo/server/Subscription.ts +6 -4
  165. package/src/tempo/server/index.ts +5 -1
  166. package/src/tempo/server/internal/html.gen.ts +1 -1
  167. package/src/tempo/session/README.md +201 -0
  168. package/src/tempo/session/Snapshot.ts +21 -0
  169. package/src/tempo/session/client/ChannelOps.test.ts +40 -0
  170. package/src/tempo/session/client/ChannelOps.ts +12 -2
  171. package/src/tempo/session/client/CredentialState.test.ts +2 -2
  172. package/src/tempo/session/client/CredentialState.ts +103 -1
  173. package/src/tempo/session/client/Session.test.ts +3 -3
  174. package/src/tempo/session/client/SessionManager.test.ts +137 -4
  175. package/src/tempo/session/client/SessionManager.ts +139 -33
  176. package/src/tempo/session/client/Transports.test.ts +150 -8
  177. package/src/tempo/session/client/Transports.ts +48 -10
  178. package/src/tempo/session/client/internal/SessionManager.ts +36 -0
  179. package/src/tempo/session/precompile/Chain.test.ts +26 -0
  180. package/src/tempo/session/precompile/Chain.ts +17 -4
  181. package/src/tempo/session/server/CredentialVerification.ts +18 -1
  182. package/src/tempo/session/server/MeteredStream.ts +12 -5
  183. package/src/tempo/session/server/RequestState.test.ts +39 -3
  184. package/src/tempo/session/server/RequestState.ts +13 -6
  185. package/src/tempo/session/server/Session.test.ts +461 -0
  186. package/src/tempo/session/server/Session.ts +31 -8
  187. package/src/tempo/session/server/Settlement.ts +45 -0
  188. package/src/tempo/session/server/Ws.test.ts +113 -0
  189. package/src/tempo/session/server/Ws.ts +2 -0
  190. package/src/tempo/session/server/index.ts +2 -0
  191. package/src/validation/core.ts +7 -1
  192. package/src/viem/Client.test.ts +44 -1
  193. package/src/viem/Client.ts +31 -17
@@ -0,0 +1,723 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import * as Challenge from '../../Challenge.js';
6
+ import { resolveEscrow } from '../../tempo/session/client/ChannelOps.js';
7
+ import { deserializeEntry, entryKey, serializeEntry, } from '../../tempo/session/client/ChannelStore.js';
8
+ import { isTempoSessionChallenge, } from '../../tempo/session/client/Transports.js';
9
+ import * as z from '../../zod.js';
10
+ const sessionStateVersion = 1;
11
+ /** Invalid, corrupt, or inconsistent persistent session state. */
12
+ export class SessionStateError extends Error {
13
+ name = 'SessionStateError';
14
+ code = 'SESSION_STATE_INVALID';
15
+ file;
16
+ constructor(message, options = {}) {
17
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
18
+ this.file = options.file;
19
+ }
20
+ }
21
+ /** A session scope currently owned by another live process. */
22
+ export class SessionBusyError extends Error {
23
+ name = 'SessionBusyError';
24
+ code = 'SESSION_BUSY';
25
+ exitCode = 75;
26
+ scope;
27
+ owner;
28
+ constructor(scope, owner) {
29
+ super(`Session scope is busy in process ${owner.pid} on ${owner.hostname}.`);
30
+ this.scope = scope;
31
+ this.owner = owner;
32
+ }
33
+ }
34
+ const storedChannelSchema = z.object({
35
+ channelId: z.hash(),
36
+ cumulativeAmount: z.string(),
37
+ deposit: z.string(),
38
+ descriptor: z.object({
39
+ payer: z.address(),
40
+ payee: z.address(),
41
+ operator: z.address(),
42
+ token: z.address(),
43
+ salt: z.hash(),
44
+ authorizedSigner: z.address(),
45
+ expiringNonceHash: z.hash(),
46
+ }),
47
+ escrow: z.address(),
48
+ chainId: z.number(),
49
+ opened: z.boolean(),
50
+ });
51
+ const accountSchema = z.object({
52
+ name: z.optional(z.string()),
53
+ address: z.address(),
54
+ });
55
+ const receiptSchema = z.object({
56
+ method: z.literal('tempo'),
57
+ intent: z.literal('session'),
58
+ status: z.literal('success'),
59
+ timestamp: z.string(),
60
+ reference: z.string(),
61
+ challengeId: z.string(),
62
+ channelId: z.hash(),
63
+ acceptedCumulative: z.string(),
64
+ spent: z.string(),
65
+ units: z.optional(z.number()),
66
+ txHash: z.optional(z.hash()),
67
+ });
68
+ const storedSessionSchema = z.object({
69
+ version: z.literal(sessionStateVersion),
70
+ method: z.literal('tempo'),
71
+ intent: z.literal('session'),
72
+ status: z.enum(['opening', 'open', 'closing', 'stale']),
73
+ channel: storedChannelSchema,
74
+ account: accountSchema,
75
+ endpoint: z.string(),
76
+ challenge: Challenge.Schema,
77
+ receipt: z.optional(receiptSchema),
78
+ spent: z.string(),
79
+ units: z.number(),
80
+ createdAt: z.string(),
81
+ updatedAt: z.string(),
82
+ });
83
+ const preferredSessionSchema = z.object({
84
+ version: z.literal(sessionStateVersion),
85
+ channelId: z.hash(),
86
+ });
87
+ const lockOwnerSchema = z.object({
88
+ version: z.literal(sessionStateVersion),
89
+ scope: z.string(),
90
+ hostname: z.string(),
91
+ pid: z.number(),
92
+ token: z.string(),
93
+ createdAt: z.string(),
94
+ });
95
+ function parseStored(schema, value, file, message) {
96
+ const parsed = schema.safeParse(value);
97
+ if (!parsed.success)
98
+ throw stateError(file, message, parsed.error);
99
+ return parsed.data;
100
+ }
101
+ /** Returns the stable payer-qualified key for a persistent session scope. */
102
+ export function sessionScopeKey(scope) {
103
+ const normalized = normalizeScope(scope);
104
+ return [
105
+ normalized.payer,
106
+ normalized.payee,
107
+ normalized.token,
108
+ normalized.escrow,
109
+ normalized.chainId,
110
+ ].join(':');
111
+ }
112
+ /** Returns the persistent payment scope for a channel. */
113
+ export function sessionScope(channel) {
114
+ return {
115
+ payer: channel.descriptor.payer,
116
+ payee: channel.descriptor.payee,
117
+ token: channel.descriptor.token,
118
+ escrow: channel.escrow,
119
+ chainId: channel.chainId,
120
+ };
121
+ }
122
+ /** Creates a filesystem-backed CLI session registry. */
123
+ export function createSessionRegistry(options = {}) {
124
+ const root = options.stateRoot ?? sessionStateRoot();
125
+ const paths = {
126
+ root,
127
+ channels: path.join(root, 'channels'),
128
+ locks: path.join(root, 'locks'),
129
+ preferred: path.join(root, 'preferred'),
130
+ };
131
+ const hostname = options.hostname ?? os.hostname();
132
+ const pid = options.pid ?? process.pid;
133
+ const now = options.now ?? (() => new Date());
134
+ const isProcessAlive = options.isProcessAlive ?? processIsAlive;
135
+ async function ensureDirectories() {
136
+ for (const directory of [paths.root, paths.channels, paths.locks, paths.preferred]) {
137
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
138
+ await fs.chmod(directory, 0o700);
139
+ }
140
+ }
141
+ async function get(channelId) {
142
+ const normalizedId = normalizeChannelId(channelId);
143
+ const file = channelFile(paths, normalizedId);
144
+ const value = await readJson(file);
145
+ if (value === undefined)
146
+ return undefined;
147
+ const record = parseStoredSession(value, file);
148
+ if (record.channel.channelId.toLowerCase() !== normalizedId)
149
+ throw stateError(file, 'Session filename does not match its channel ID.');
150
+ return deserializeSession(record);
151
+ }
152
+ async function list() {
153
+ let entries;
154
+ try {
155
+ entries = await fs.readdir(paths.channels, { withFileTypes: true });
156
+ }
157
+ catch (error) {
158
+ if (hasCode(error, 'ENOENT'))
159
+ return [];
160
+ throw stateError(paths.channels, 'Unable to list managed sessions.', error);
161
+ }
162
+ const records = [];
163
+ for (const entry of entries) {
164
+ if (!entry.isFile() || entry.name.includes('.tmp-'))
165
+ continue;
166
+ if (!entry.name.endsWith('.json'))
167
+ continue;
168
+ const channelId = entry.name.slice(0, -'.json'.length);
169
+ normalizeChannelId(channelId);
170
+ const record = await get(channelId);
171
+ if (record)
172
+ records.push(record);
173
+ }
174
+ return records.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
175
+ }
176
+ async function upsert(input) {
177
+ await ensureDirectories();
178
+ const channel = sanitizeChannel(input.channel);
179
+ const normalizedId = normalizeChannelId(channel.channelId);
180
+ const file = channelFile(paths, normalizedId);
181
+ const previousValue = await readJson(file);
182
+ const previous = previousValue === undefined ? undefined : parseStoredSession(previousValue, file);
183
+ if (previous)
184
+ assertSameSession(previous, input, file);
185
+ const challenge = parseSessionChallenge(input.challenge, file);
186
+ assertChallengeMatchesChannel(challenge, channel, file);
187
+ const receipt = input.receipt
188
+ ? sanitizeReceipt(input.receipt, channel.channelId, file)
189
+ : undefined;
190
+ const previousChannel = previous ? deserializeEntry(previous.channel) : undefined;
191
+ const cumulativeAmount = maxBigInt(channel.cumulativeAmount, previousChannel?.cumulativeAmount ?? 0n);
192
+ const spent = maxBigInt(input.spent ?? 0n, receipt ? BigInt(receipt.spent) : 0n, previous ? BigInt(previous.spent) : 0n);
193
+ if (spent > cumulativeAmount)
194
+ throw stateError(file, 'Session spend exceeds the locally authorized cumulative amount.');
195
+ const storedChannel = sanitizeStoredChannel(serializeEntry({ ...channel, cumulativeAmount }), file);
196
+ const timestamp = monotonicTimestamp(previous?.updatedAt, now(), file);
197
+ const latestReceipt = selectLatestReceipt(previous?.receipt, receipt);
198
+ const account = sanitizeAccount(input.account, file);
199
+ const record = {
200
+ version: sessionStateVersion,
201
+ method: 'tempo',
202
+ intent: 'session',
203
+ status: input.status,
204
+ channel: storedChannel,
205
+ account: {
206
+ ...(account.name !== undefined
207
+ ? { name: account.name }
208
+ : previous?.account.name !== undefined
209
+ ? { name: previous.account.name }
210
+ : {}),
211
+ address: account.address,
212
+ },
213
+ endpoint: sessionResourceUrl(input.endpoint, file),
214
+ challenge,
215
+ ...(latestReceipt !== undefined && { receipt: latestReceipt }),
216
+ spent: spent.toString(),
217
+ units: Math.max(previous?.units ?? 0, input.units ?? 0, receipt?.units ?? 0),
218
+ createdAt: previous?.createdAt ?? timestamp,
219
+ updatedAt: timestamp,
220
+ };
221
+ const parsed = parseStoredSession(record, file);
222
+ await writeJsonAtomic(file, parsed);
223
+ return deserializeSession(parsed);
224
+ }
225
+ async function getPreferred(scope) {
226
+ const file = preferredFile(paths, scope);
227
+ const value = await readJson(file);
228
+ if (value === undefined)
229
+ return undefined;
230
+ const { channelId } = parseStored(preferredSessionSchema, value, file, 'Preferred session is invalid.');
231
+ const record = await get(channelId);
232
+ if (!record)
233
+ throw stateError(file, `Preferred session ${channelId} does not exist.`);
234
+ assertChannelScope(record.channel, scope, file);
235
+ return channelId;
236
+ }
237
+ async function setPreferred(scope, channelId) {
238
+ const normalizedId = normalizeChannelId(channelId);
239
+ const record = await get(normalizedId);
240
+ const file = preferredFile(paths, scope);
241
+ if (!record)
242
+ throw stateError(file, `Session ${normalizedId} does not exist.`);
243
+ assertChannelScope(record.channel, scope, file);
244
+ await writeJsonAtomic(file, {
245
+ version: sessionStateVersion,
246
+ channelId: normalizedId,
247
+ });
248
+ }
249
+ async function clearPreferred(scope, channelId) {
250
+ const file = preferredFile(paths, scope);
251
+ const normalizedId = channelId === undefined ? undefined : normalizeChannelId(channelId);
252
+ const value = await readJson(file);
253
+ if (value === undefined)
254
+ return;
255
+ const current = parseStored(preferredSessionSchema, value, file, 'Preferred session is invalid.').channelId;
256
+ if (normalizedId && current.toLowerCase() !== normalizedId)
257
+ return;
258
+ await removeFile(file, 'Unable to clear preferred session.');
259
+ }
260
+ async function remove(channelId) {
261
+ const normalizedId = normalizeChannelId(channelId);
262
+ const record = await get(normalizedId);
263
+ if (!record)
264
+ return;
265
+ await clearPreferred(sessionScope(record.channel), normalizedId);
266
+ const file = channelFile(paths, normalizedId);
267
+ await removeFile(file, 'Unable to remove session.');
268
+ }
269
+ async function acquireKey(scope) {
270
+ await ensureDirectories();
271
+ const file = lockFile(paths, scope);
272
+ for (let attempt = 0; attempt < 3; attempt++) {
273
+ const owner = {
274
+ version: sessionStateVersion,
275
+ scope,
276
+ hostname,
277
+ pid,
278
+ token: randomUUID(),
279
+ createdAt: now().toISOString(),
280
+ };
281
+ try {
282
+ await createLock(file, owner);
283
+ if (await fileExists(deadLockClaimFile(file))) {
284
+ await removeOwnedLock(file, owner);
285
+ continue;
286
+ }
287
+ return {
288
+ async release() {
289
+ await removeOwnedLock(file, owner);
290
+ },
291
+ };
292
+ }
293
+ catch (error) {
294
+ if (!hasCode(error, 'EEXIST'))
295
+ throw error;
296
+ }
297
+ const value = await readJson(file);
298
+ if (value === undefined)
299
+ continue;
300
+ const current = parseStored(lockOwnerSchema, value, file, 'Session lock is invalid.');
301
+ if (current.scope !== scope)
302
+ throw stateError(file, 'Session lock scope is invalid.');
303
+ if (current.hostname !== hostname || isProcessAlive(current.pid))
304
+ throw new SessionBusyError(scope, current);
305
+ await removeDeadLock(file, current);
306
+ }
307
+ const value = await readJson(file);
308
+ if (value === undefined)
309
+ throw stateError(file, 'Unable to acquire session lock.');
310
+ const owner = parseStored(lockOwnerSchema, value, file, 'Session lock is invalid.');
311
+ throw new SessionBusyError(scope, owner);
312
+ }
313
+ async function acquire(scope) {
314
+ return acquireKey(sessionScopeKey(scope));
315
+ }
316
+ return {
317
+ root,
318
+ get,
319
+ list,
320
+ upsert,
321
+ remove,
322
+ getPreferred,
323
+ setPreferred,
324
+ clearPreferred,
325
+ acquire,
326
+ };
327
+ }
328
+ /** Adapts a persistent registry selection to the session manager's channel store. */
329
+ export function toChannelStore(registry, options) {
330
+ const expectedKey = scopeEntryKey(options.scope);
331
+ let selectedChannelId = options.selection === 'auto' || options.selection === 'new'
332
+ ? undefined
333
+ : normalizeChannelId(options.selection);
334
+ function assertKey(key) {
335
+ if (key.toLowerCase() !== expectedKey)
336
+ throw new SessionStateError('Session manager requested an unexpected payment scope.');
337
+ }
338
+ async function selected(reusableOnly = true) {
339
+ if (options.selection === 'new' && !selectedChannelId)
340
+ return undefined;
341
+ const channelId = selectedChannelId ?? (await registry.getPreferred(options.scope));
342
+ if (!channelId)
343
+ return undefined;
344
+ const record = await registry.get(channelId);
345
+ if (!record)
346
+ throw new SessionStateError(`Session ${channelId} does not exist.`);
347
+ assertChannelScope(record.channel, options.scope);
348
+ if (reusableOnly && (record.status !== 'open' || !record.channel.opened))
349
+ return undefined;
350
+ selectedChannelId = channelId;
351
+ return record;
352
+ }
353
+ return {
354
+ async get(key) {
355
+ assertKey(key);
356
+ return (await selected())?.channel;
357
+ },
358
+ async set(channel) {
359
+ assertKey(entryKey(channel));
360
+ assertChannelScope(channel, options.scope);
361
+ if (selectedChannelId && selectedChannelId.toLowerCase() !== channel.channelId.toLowerCase())
362
+ throw new SessionStateError(`Session manager selected ${selectedChannelId}, but attempted to store ${channel.channelId}.`);
363
+ if (!selectedChannelId)
364
+ options.onNewChannel?.(channel);
365
+ const record = await registry.upsert({ ...options.context(), channel });
366
+ selectedChannelId = record.channel.channelId;
367
+ await registry.setPreferred(options.scope, record.channel.channelId);
368
+ },
369
+ async delete(key) {
370
+ assertKey(key);
371
+ const record = await selected(false);
372
+ if (!record)
373
+ return;
374
+ const context = options.context();
375
+ await registry.upsert({
376
+ ...record,
377
+ ...context,
378
+ status: context.status === 'closing' ? 'closing' : 'stale',
379
+ channel: record.channel,
380
+ spent: context.spent ?? record.spent,
381
+ units: context.units ?? record.units,
382
+ });
383
+ if (context.status !== 'closing')
384
+ await registry.clearPreferred(options.scope, record.channel.channelId);
385
+ selectedChannelId = undefined;
386
+ },
387
+ };
388
+ }
389
+ function sessionStateRoot() {
390
+ const stateHome = process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
391
+ return path.join(stateHome, 'mppx', 'sessions', `v${sessionStateVersion}`);
392
+ }
393
+ function channelFile(paths, channelId) {
394
+ return path.join(paths.channels, `${channelId}.json`);
395
+ }
396
+ function lockFile(paths, scope) {
397
+ const digest = createHash('sha256').update(scope).digest('hex');
398
+ return path.join(paths.locks, `${digest}.lock`);
399
+ }
400
+ function preferredFile(paths, scope) {
401
+ const digest = createHash('sha256').update(sessionScopeKey(scope)).digest('hex');
402
+ return path.join(paths.preferred, `${digest}.json`);
403
+ }
404
+ function normalizeChannelId(channelId) {
405
+ return parseStored(z.hash(), channelId, undefined, `Invalid session channel ID: ${channelId}.`).toLowerCase();
406
+ }
407
+ function normalizeAddress(value, label, file) {
408
+ return parseStored(z.address(), value, file, `Invalid ${label}.`).toLowerCase();
409
+ }
410
+ function normalizeScope(scope) {
411
+ if (!Number.isSafeInteger(scope.chainId) || scope.chainId < 0)
412
+ throw new SessionStateError('Invalid session chain ID.');
413
+ return {
414
+ payer: normalizeAddress(scope.payer, 'session payer'),
415
+ payee: normalizeAddress(scope.payee, 'session payee'),
416
+ token: normalizeAddress(scope.token, 'session token'),
417
+ escrow: normalizeAddress(scope.escrow, 'session escrow'),
418
+ chainId: scope.chainId,
419
+ };
420
+ }
421
+ function scopeEntryKey(scope) {
422
+ const normalized = normalizeScope(scope);
423
+ return [normalized.payee, normalized.token, normalized.escrow, normalized.chainId].join(':');
424
+ }
425
+ /** Returns the exact HTTP resource URL persisted for session management. */
426
+ export function sessionResourceUrl(endpoint, file) {
427
+ if (typeof endpoint !== 'string')
428
+ throw stateError(file, 'Session endpoint is invalid.');
429
+ let parsed;
430
+ try {
431
+ parsed = new URL(endpoint);
432
+ }
433
+ catch (cause) {
434
+ throw stateError(file, 'Session endpoint is invalid.', cause);
435
+ }
436
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
437
+ throw stateError(file, 'Session endpoint must use HTTP or HTTPS.');
438
+ if (parsed.username || parsed.password)
439
+ throw stateError(file, 'Session endpoint must not contain credentials.');
440
+ const fragment = endpoint.indexOf('#');
441
+ return fragment === -1 ? endpoint : endpoint.slice(0, fragment);
442
+ }
443
+ function sanitizeAccount(value, file) {
444
+ return parseStored(accountSchema, value, file, 'Session account is invalid.');
445
+ }
446
+ function sanitizeChannel(channel) {
447
+ const stored = sanitizeStoredChannel(serializeEntry(channel));
448
+ return deserializeEntry(stored);
449
+ }
450
+ function sanitizeStoredChannel(value, file) {
451
+ return parseStored(storedChannelSchema, value, file, 'Stored session channel is invalid.');
452
+ }
453
+ function parseSessionChallenge(value, file) {
454
+ const parsed = Challenge.Schema.safeParse(value);
455
+ if (!parsed.success || !isTempoSessionChallenge(parsed.data))
456
+ throw stateError(file, 'Stored session challenge is invalid.');
457
+ return parsed.data;
458
+ }
459
+ function sanitizeReceipt(value, channelId, file) {
460
+ const receipt = parseStored(receiptSchema, value, file, 'Stored session receipt is invalid.');
461
+ if (receipt.channelId !== channelId.toLowerCase())
462
+ throw stateError(file, 'Stored session receipt has a different channel ID.');
463
+ if (receipt.reference.toLowerCase() !== channelId.toLowerCase())
464
+ throw stateError(file, 'Stored session receipt has a different reference.');
465
+ if (BigInt(receipt.spent) > BigInt(receipt.acceptedCumulative))
466
+ throw stateError(file, 'Stored session receipt spend exceeds its accepted amount.');
467
+ return receipt;
468
+ }
469
+ function parseStoredSession(value, file) {
470
+ const candidate = parseStored(storedSessionSchema, value, file, 'Stored session is invalid.');
471
+ const { receipt: candidateReceipt, ...rest } = candidate;
472
+ const channel = candidate.channel;
473
+ const account = candidate.account;
474
+ if (account.address.toLowerCase() !== channel.descriptor.payer.toLowerCase())
475
+ throw stateError(file, 'Stored session account does not match the channel payer.');
476
+ const receipt = candidateReceipt
477
+ ? sanitizeReceipt(candidateReceipt, channel.channelId, file)
478
+ : undefined;
479
+ const spent = candidate.spent;
480
+ const cumulativeAmount = BigInt(channel.cumulativeAmount);
481
+ if (BigInt(spent) > cumulativeAmount)
482
+ throw stateError(file, 'Stored session spend exceeds its cumulative authorization.');
483
+ if (receipt && BigInt(receipt.acceptedCumulative) > cumulativeAmount)
484
+ throw stateError(file, 'Stored receipt exceeds the cumulative authorization.');
485
+ if (Date.parse(candidate.updatedAt) < Date.parse(candidate.createdAt))
486
+ throw stateError(file, 'Stored session timestamps are not monotonic.');
487
+ const endpoint = sessionResourceUrl(candidate.endpoint, file);
488
+ if (endpoint !== candidate.endpoint)
489
+ throw stateError(file, 'Stored session endpoint contains a fragment.');
490
+ const challenge = parseSessionChallenge(candidate.challenge, file);
491
+ assertChallengeMatchesChannel(challenge, deserializeEntry(channel), file);
492
+ return {
493
+ ...rest,
494
+ account,
495
+ channel,
496
+ endpoint,
497
+ challenge,
498
+ ...(receipt && { receipt }),
499
+ };
500
+ }
501
+ function deserializeSession(record) {
502
+ return {
503
+ version: sessionStateVersion,
504
+ status: record.status,
505
+ channel: deserializeEntry(record.channel),
506
+ account: record.account,
507
+ endpoint: record.endpoint,
508
+ challenge: record.challenge,
509
+ ...(record.receipt && { receipt: record.receipt }),
510
+ spent: BigInt(record.spent),
511
+ units: record.units,
512
+ createdAt: record.createdAt,
513
+ updatedAt: record.updatedAt,
514
+ };
515
+ }
516
+ function assertSameSession(previous, input, file) {
517
+ const channel = sanitizeChannel(input.channel);
518
+ const account = sanitizeAccount(input.account, file);
519
+ const previousChannel = deserializeEntry(previous.channel);
520
+ if (JSON.stringify(channelIdentity(channel)) !== JSON.stringify(channelIdentity(previousChannel)))
521
+ throw stateError(file, 'Session update changed immutable channel identity.');
522
+ if (account.address.toLowerCase() !== previous.account.address.toLowerCase())
523
+ throw stateError(file, 'Session update changed the payer account.');
524
+ }
525
+ function channelIdentity(channel) {
526
+ return {
527
+ channelId: channel.channelId.toLowerCase(),
528
+ descriptor: Object.fromEntries(Object.entries(channel.descriptor).map(([key, value]) => [
529
+ key,
530
+ typeof value === 'string' ? value.toLowerCase() : value,
531
+ ])),
532
+ escrow: channel.escrow.toLowerCase(),
533
+ chainId: channel.chainId,
534
+ };
535
+ }
536
+ function assertChallengeMatchesChannel(challenge, channel, file) {
537
+ const payee = normalizeAddress(challenge.request.recipient, 'challenge recipient', file);
538
+ const token = normalizeAddress(challenge.request.currency, 'challenge currency', file);
539
+ if (payee !== channel.descriptor.payee.toLowerCase())
540
+ throw stateError(file, 'Session challenge payee does not match the channel.');
541
+ if (token !== channel.descriptor.token.toLowerCase())
542
+ throw stateError(file, 'Session challenge token does not match the channel.');
543
+ if (resolveEscrow(challenge).toLowerCase() !== channel.escrow.toLowerCase())
544
+ throw stateError(file, 'Session challenge escrow does not match the channel.');
545
+ if (isObject(challenge.request.methodDetails)) {
546
+ const methodDetails = challenge.request.methodDetails;
547
+ if (methodDetails.chainId !== undefined && methodDetails.chainId !== channel.chainId)
548
+ throw stateError(file, 'Session challenge chain does not match the channel.');
549
+ }
550
+ }
551
+ function assertChannelScope(channel, scope, file) {
552
+ const normalized = normalizeScope(scope);
553
+ if (channel.descriptor.payer.toLowerCase() !== normalized.payer ||
554
+ channel.descriptor.payee.toLowerCase() !== normalized.payee ||
555
+ channel.descriptor.token.toLowerCase() !== normalized.token ||
556
+ channel.escrow.toLowerCase() !== normalized.escrow ||
557
+ channel.chainId !== normalized.chainId)
558
+ throw stateError(file, 'Session channel does not match the selected payment scope.');
559
+ }
560
+ function selectLatestReceipt(previous, next) {
561
+ if (!previous)
562
+ return next;
563
+ if (!next)
564
+ return previous;
565
+ return Date.parse(next.timestamp) >= Date.parse(previous.timestamp) ? next : previous;
566
+ }
567
+ function monotonicTimestamp(previous, next, file) {
568
+ if (!Number.isFinite(next.getTime()))
569
+ throw stateError(file, 'Session clock is invalid.');
570
+ const nextTimestamp = next.toISOString();
571
+ if (!previous || Date.parse(nextTimestamp) >= Date.parse(previous))
572
+ return nextTimestamp;
573
+ return previous;
574
+ }
575
+ function maxBigInt(...values) {
576
+ return values.reduce((maximum, value) => (value > maximum ? value : maximum), 0n);
577
+ }
578
+ async function readJson(file) {
579
+ let source;
580
+ try {
581
+ source = await fs.readFile(file, 'utf8');
582
+ }
583
+ catch (error) {
584
+ if (hasCode(error, 'ENOENT'))
585
+ return undefined;
586
+ throw stateError(file, 'Unable to read session state.', error);
587
+ }
588
+ try {
589
+ const value = JSON.parse(source);
590
+ return value;
591
+ }
592
+ catch (cause) {
593
+ throw stateError(file, 'Session state contains invalid JSON.', cause);
594
+ }
595
+ }
596
+ async function writeJsonAtomic(file, value) {
597
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
598
+ await fs.chmod(path.dirname(file), 0o700);
599
+ const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`;
600
+ let handle;
601
+ try {
602
+ handle = await fs.open(temporary, 'wx', 0o600);
603
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
604
+ await handle.sync();
605
+ await handle.close();
606
+ handle = undefined;
607
+ await fs.rename(temporary, file);
608
+ await fs.chmod(file, 0o600);
609
+ await syncDirectory(path.dirname(file));
610
+ }
611
+ catch (cause) {
612
+ await handle?.close().catch(() => undefined);
613
+ await fs.unlink(temporary).catch(() => undefined);
614
+ if (cause instanceof SessionStateError)
615
+ throw cause;
616
+ throw stateError(file, 'Unable to write session state.', cause);
617
+ }
618
+ }
619
+ async function createLock(file, owner) {
620
+ let handle;
621
+ try {
622
+ handle = await fs.open(file, 'wx', 0o600);
623
+ await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8');
624
+ await handle.sync();
625
+ await handle.close();
626
+ handle = undefined;
627
+ await syncDirectory(path.dirname(file));
628
+ }
629
+ catch (error) {
630
+ await handle?.close().catch(() => undefined);
631
+ if (handle)
632
+ await fs.unlink(file).catch(() => undefined);
633
+ throw error;
634
+ }
635
+ }
636
+ async function removeDeadLock(file, expected) {
637
+ const claim = deadLockClaimFile(file);
638
+ try {
639
+ await fs.link(file, claim);
640
+ }
641
+ catch (error) {
642
+ if (hasCode(error, 'EEXIST') || hasCode(error, 'ENOENT'))
643
+ return;
644
+ throw stateError(file, 'Unable to claim dead lock.', error);
645
+ }
646
+ try {
647
+ const currentValue = await readJson(claim);
648
+ if (currentValue === undefined)
649
+ return;
650
+ const current = parseStored(lockOwnerSchema, currentValue, claim, 'Session lock is invalid.');
651
+ if (current.token !== expected.token)
652
+ return;
653
+ await removeFile(file, 'Unable to reclaim dead lock.');
654
+ }
655
+ finally {
656
+ await removeFile(claim, 'Unable to release dead lock claim.');
657
+ }
658
+ }
659
+ function deadLockClaimFile(file) {
660
+ return `${file}.reclaim`;
661
+ }
662
+ async function fileExists(file) {
663
+ try {
664
+ await fs.access(file);
665
+ return true;
666
+ }
667
+ catch (error) {
668
+ if (hasCode(error, 'ENOENT'))
669
+ return false;
670
+ throw stateError(file, 'Unable to inspect session lock.', error);
671
+ }
672
+ }
673
+ async function removeOwnedLock(file, expected) {
674
+ const currentValue = await readJson(file);
675
+ if (currentValue === undefined)
676
+ return;
677
+ const current = parseStored(lockOwnerSchema, currentValue, file, 'Session lock is invalid.');
678
+ if (current.token !== expected.token)
679
+ return;
680
+ await removeFile(file, 'Unable to release session lock.');
681
+ }
682
+ async function removeFile(file, message) {
683
+ try {
684
+ await fs.unlink(file);
685
+ await syncDirectory(path.dirname(file));
686
+ }
687
+ catch (error) {
688
+ if (!hasCode(error, 'ENOENT'))
689
+ throw stateError(file, message, error);
690
+ }
691
+ }
692
+ async function syncDirectory(directory) {
693
+ const handle = await fs.open(directory, 'r');
694
+ try {
695
+ await handle.sync();
696
+ }
697
+ finally {
698
+ await handle.close();
699
+ }
700
+ }
701
+ function processIsAlive(pid) {
702
+ try {
703
+ process.kill(pid, 0);
704
+ return true;
705
+ }
706
+ catch (error) {
707
+ if (hasCode(error, 'ESRCH'))
708
+ return false;
709
+ if (hasCode(error, 'EPERM'))
710
+ return true;
711
+ throw error;
712
+ }
713
+ }
714
+ function isObject(value) {
715
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
716
+ }
717
+ function hasCode(error, code) {
718
+ return error instanceof Error && 'code' in error && error.code === code;
719
+ }
720
+ function stateError(file, message, cause) {
721
+ return new SessionStateError(message, { ...(cause !== undefined && { cause }), file });
722
+ }
723
+ //# sourceMappingURL=store.js.map