mppx 0.14.1 → 0.15.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.
@@ -1,3 +1,4 @@
1
+ import type * as Challenge from '../../../Challenge.js';
1
2
  import { type SessionCredentialContext } from '../precompile/Protocol.js';
2
3
  import * as ChannelStore from './ChannelStore.js';
3
4
  import type { MeteredStreamOptions, SessionController } from './MeteredStream.js';
@@ -13,6 +14,7 @@ export type SessionRouteResult = {
13
14
  challenge: Response;
14
15
  } | {
15
16
  status: 200;
17
+ challenge?: Challenge.Challenge | undefined;
16
18
  withReceipt(response?: Response): Response | Promise<Response>;
17
19
  };
18
20
  /** HTTP route used to perform a WebSocket payment probe. */
@@ -36,14 +38,15 @@ export declare function serve(options: serve.Options): Promise<void>;
36
38
  /** Type helpers for {@link serve}. */
37
39
  export declare namespace serve {
38
40
  type Options = {
39
- /** Expected per-tick amount in raw units. When set, credentials whose
41
+ /** Expected per-tick amount in raw units. Required for custom routes that
42
+ * do not return their canonical challenge. When set, credentials whose
40
43
  * challenge `request.amount` does not match are rejected. Use this to
41
44
  * pin the price when the route is backed by `Mppx.compose()` with
42
45
  * multiple offers — otherwise a client can select the cheapest offer
43
46
  * and still receive the same stream. */
44
47
  amount?: string | undefined;
45
48
  /** Application stream. A manual stream can call `charge(amount)` with a
46
- * per-message raw-unit amount; omitting it uses the challenge tick cost. */
49
+ * per-message raw-unit amount; omitting it uses the canonical route tick cost. */
47
50
  generate: AsyncIterable<string> | ((stream: SessionController) => AsyncIterable<string>);
48
51
  /** Maximum accepted inbound WebSocket frame size in bytes. @default 65536 */
49
52
  maxIncomingMessageBytes?: number | undefined;
@@ -133,6 +133,11 @@ export async function serve(options) {
133
133
  return;
134
134
  }
135
135
  const { receipt } = authorizationResult;
136
+ const tickCost = expectedAmount !== undefined ? BigInt(expectedAmount) : authorizationResult.amount;
137
+ if (tickCost === undefined) {
138
+ await close(1008, 'WebSocket route must provide canonical pricing or an explicit amount');
139
+ return;
140
+ }
136
141
  if (payload.action === 'voucher' && runtime.streamContext) {
137
142
  runtime.streamContext.challengeId = credential.challenge.id;
138
143
  }
@@ -149,7 +154,7 @@ export async function serve(options) {
149
154
  runtime.streamContext = {
150
155
  challengeId: credential.challenge.id,
151
156
  channelId: payload.channelId,
152
- tickCost: readSessionChallengeAmount(credential.challenge),
157
+ tickCost,
153
158
  };
154
159
  // Defer the first application frame until after the client receives the
155
160
  // auth receipt and has a chance to install its own message listeners.
@@ -299,6 +304,10 @@ async function authorizePaymentFrame(parameters) {
299
304
  const receiptHeader = response.headers.get(Constants.Headers.paymentReceipt);
300
305
  if (!receiptHeader)
301
306
  throw new Error('management response missing Payment-Receipt header');
302
- return { status: 'accepted', receipt: deserializeSessionReceipt(receiptHeader) };
307
+ return {
308
+ status: 'accepted',
309
+ amount: result.challenge ? readSessionChallengeAmount(result.challenge) : undefined,
310
+ receipt: deserializeSessionReceipt(receiptHeader),
311
+ };
303
312
  }
304
313
  //# sourceMappingURL=Ws.js.map
@@ -13,9 +13,13 @@ export type SubscriptionStore = {
13
13
  /** Looks up a generated access key for a resolved request key. */
14
14
  getAccessKey(key: string): Promise<SubscriptionAccessKeyRecord | null>;
15
15
  /** Looks up a generated access key by its public access key address. */
16
- getAccessKeyByAddress(address: string): Promise<SubscriptionAccessKeyRecord | null>;
16
+ getAccessKeyByAddress(address: string, keyToken?: `0x${string}`): Promise<SubscriptionAccessKeyRecord | null>;
17
17
  /** Looks up the active subscription for a resolved request key. */
18
18
  getByKey(key: string): Promise<SubscriptionRecord | null>;
19
+ /** Creates a key sealed in a client-carried token, without retaining per-challenge state. */
20
+ createChallengeAccessKey(): Promise<SubscriptionAccessKeyRecord>;
21
+ /** Retains a key after verified payment submission for subsequent subscription billing. */
22
+ retainAccessKey(key: string, record: SubscriptionAccessKeyRecord): Promise<void>;
19
23
  /** Gets or creates the server-owned access key for a resolved request key. */
20
24
  getOrCreateAccessKey(key: string): Promise<SubscriptionAccessKeyRecord>;
21
25
  /** Upserts a subscription record and marks it as active for its lookup key. */
@@ -85,6 +89,8 @@ export declare namespace fromStore {
85
89
  type Options = {
86
90
  /** Key prefix for server-owned subscription access keys. @default `'tempo:subscription:access-key:'` */
87
91
  accessKeyPrefix?: string | undefined;
92
+ /** Namespace for the persistent anonymous-challenge sealing key. */
93
+ pendingAccessKeyPrefix?: string | undefined;
88
94
  /** Key prefix for resolved subscription activation locks. @default `'tempo:subscription:activation:'` */
89
95
  activationPrefix?: string | undefined;
90
96
  /** Milliseconds before a stuck activation lock can be replaced. @default `900000` */
@@ -1,15 +1,16 @@
1
- import { Secp256k1 } from 'ox';
1
+ import { Hex, Secp256k1 } from 'ox';
2
2
  import { Account as TempoAccount } from 'viem/tempo';
3
3
  const defaultRecordPrefix = 'tempo:subscription:record:';
4
4
  const defaultKeyPrefix = 'tempo:subscription:key:';
5
5
  const defaultActivationPrefix = 'tempo:subscription:activation:';
6
6
  const defaultAccessKeyPrefix = 'tempo:subscription:access-key:';
7
+ const defaultPendingAccessKeyPrefix = 'tempo:subscription:pending-access-key:';
7
8
  const defaultCredentialPrefix = 'tempo:subscription:credential:';
8
9
  const defaultActivationTimeoutMs = 15 * 60 * 1_000;
9
10
  const defaultRenewalTimeoutMs = 15 * 60 * 1_000;
10
11
  /** Wraps a generic key-value {@link Store.Store} with subscription-specific accessors. */
11
12
  export function fromStore(store, options) {
12
- const { accessKeyPrefix = defaultAccessKeyPrefix, activationPrefix = defaultActivationPrefix, activationTimeoutMs = defaultActivationTimeoutMs, credentialPrefix = defaultCredentialPrefix, keyPrefix = defaultKeyPrefix, recordPrefix = defaultRecordPrefix, renewalTimeoutMs = defaultRenewalTimeoutMs, } = options ?? {};
13
+ const { accessKeyPrefix = defaultAccessKeyPrefix, activationPrefix = defaultActivationPrefix, activationTimeoutMs = defaultActivationTimeoutMs, credentialPrefix = defaultCredentialPrefix, keyPrefix = defaultKeyPrefix, recordPrefix = defaultRecordPrefix, pendingAccessKeyPrefix = defaultPendingAccessKeyPrefix, renewalTimeoutMs = defaultRenewalTimeoutMs, } = options ?? {};
13
14
  function recordKey(subscriptionId) {
14
15
  return `${recordPrefix}${subscriptionId}`;
15
16
  }
@@ -61,7 +62,35 @@ export function fromStore(store, options) {
61
62
  const marker = (await store.get(activationKey(lookupKey)));
62
63
  return marker?.challengeId === challengeId;
63
64
  }
65
+ const sealingKey = `${pendingAccessKeyPrefix}sealing-key`;
66
+ // One shared secret survives across workers; anonymous issuance never grows the store.
67
+ async function getSealingKey() {
68
+ const candidate = Hex.fromBytes(crypto.getRandomValues(new Uint8Array(32)));
69
+ const secret = await store.update(sealingKey, (current) => current
70
+ ? { op: 'noop', result: current }
71
+ : { op: 'set', value: candidate, result: candidate });
72
+ return crypto.subtle.importKey('raw', new Uint8Array(Hex.toBytes(secret)), 'AES-GCM', false, [
73
+ 'encrypt',
74
+ 'decrypt',
75
+ ]);
76
+ }
64
77
  return {
78
+ async createChallengeAccessKey() {
79
+ const privateKey = Secp256k1.randomPrivateKey();
80
+ const account = TempoAccount.fromSecp256k1(privateKey);
81
+ const iv = crypto.getRandomValues(new Uint8Array(12));
82
+ const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await getSealingKey(), new Uint8Array(Hex.toBytes(privateKey))));
83
+ return {
84
+ accessKeyAddress: account.address.toLowerCase(),
85
+ keyType: account.keyType,
86
+ privateKey,
87
+ keyToken: Hex.fromBytes(new Uint8Array([...iv, ...sealed])),
88
+ };
89
+ },
90
+ async retainAccessKey(key, record) {
91
+ await store.put(accessKeyKey(key), record);
92
+ await store.put(accessKeyAddressKey(record.accessKeyAddress), record);
93
+ },
65
94
  async activate({ challengeId, create, isReusable, lookupKey }) {
66
95
  const claimed = await store.update(credentialKey(challengeId), (current) => {
67
96
  if (current)
@@ -155,8 +184,31 @@ export function fromStore(store, options) {
155
184
  async getAccessKey(key) {
156
185
  return (await store.get(accessKeyKey(key)));
157
186
  },
158
- async getAccessKeyByAddress(address) {
159
- return (await store.get(accessKeyAddressKey(address)));
187
+ async getAccessKeyByAddress(address, keyToken) {
188
+ const stored = (await store.get(accessKeyAddressKey(address)));
189
+ if (stored)
190
+ return stored;
191
+ if (!keyToken)
192
+ return null;
193
+ try {
194
+ const bytes = Hex.toBytes(keyToken);
195
+ // 12-byte nonce, 32-byte private key, and 16-byte authentication tag.
196
+ if (bytes.length !== 60)
197
+ return null;
198
+ const privateKey = Hex.fromBytes(new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: bytes.slice(0, 12) }, await getSealingKey(), bytes.slice(12))));
199
+ const account = TempoAccount.fromSecp256k1(privateKey);
200
+ if (account.address.toLowerCase() !== address.toLowerCase())
201
+ return null;
202
+ return {
203
+ accessKeyAddress: account.address.toLowerCase(),
204
+ keyType: account.keyType,
205
+ privateKey,
206
+ keyToken,
207
+ };
208
+ }
209
+ catch {
210
+ return null;
211
+ }
160
212
  },
161
213
  async getByKey(key) {
162
214
  return getByLookupKey(key);
@@ -5,6 +5,8 @@ export type SubscriptionPeriodUnit = 'dev_second' | 'day' | 'week';
5
5
  export type SubscriptionAccessKey = {
6
6
  accessKeyAddress: Address;
7
7
  keyType: 'p256' | 'secp256k1' | 'webAuthn';
8
+ /** Opaque server-sealed key for stateless anonymous setup. */
9
+ keyToken?: `0x${string}` | undefined;
8
10
  };
9
11
  /** Server-owned subscription access key persisted for automatic billing. */
10
12
  export type SubscriptionAccessKeyRecord = SubscriptionAccessKey & {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.14.1",
4
+ "version": "0.15.0",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/wevm/mppx#readme",