mppx 0.6.19 → 0.6.20

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 (148) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/Challenge.d.ts +2 -2
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +1 -1
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Method.d.ts +34 -0
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js +3 -1
  9. package/dist/Method.js.map +1 -1
  10. package/dist/Receipt.d.ts +1 -0
  11. package/dist/Receipt.d.ts.map +1 -1
  12. package/dist/Receipt.js +2 -0
  13. package/dist/Receipt.js.map +1 -1
  14. package/dist/client/Methods.d.ts +1 -0
  15. package/dist/client/Methods.d.ts.map +1 -1
  16. package/dist/client/Methods.js +1 -0
  17. package/dist/client/Methods.js.map +1 -1
  18. package/dist/middlewares/elysia.d.ts.map +1 -1
  19. package/dist/middlewares/elysia.js +14 -0
  20. package/dist/middlewares/elysia.js.map +1 -1
  21. package/dist/middlewares/express.d.ts.map +1 -1
  22. package/dist/middlewares/express.js +1 -2
  23. package/dist/middlewares/express.js.map +1 -1
  24. package/dist/middlewares/hono.d.ts.map +1 -1
  25. package/dist/middlewares/hono.js +14 -0
  26. package/dist/middlewares/hono.js.map +1 -1
  27. package/dist/middlewares/nextjs.d.ts.map +1 -1
  28. package/dist/middlewares/nextjs.js +14 -0
  29. package/dist/middlewares/nextjs.js.map +1 -1
  30. package/dist/proxy/Proxy.d.ts.map +1 -1
  31. package/dist/proxy/Proxy.js +2 -2
  32. package/dist/proxy/Proxy.js.map +1 -1
  33. package/dist/proxy/Service.d.ts.map +1 -1
  34. package/dist/proxy/Service.js +1 -1
  35. package/dist/proxy/Service.js.map +1 -1
  36. package/dist/server/Mppx.d.ts +15 -3
  37. package/dist/server/Mppx.d.ts.map +1 -1
  38. package/dist/server/Mppx.js +190 -40
  39. package/dist/server/Mppx.js.map +1 -1
  40. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  41. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  42. package/dist/stripe/server/internal/html.gen.js +1 -1
  43. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  44. package/dist/tempo/Methods.d.ts +96 -0
  45. package/dist/tempo/Methods.d.ts.map +1 -1
  46. package/dist/tempo/Methods.js +97 -0
  47. package/dist/tempo/Methods.js.map +1 -1
  48. package/dist/tempo/client/Methods.d.ts +3 -0
  49. package/dist/tempo/client/Methods.d.ts.map +1 -1
  50. package/dist/tempo/client/Methods.js +3 -0
  51. package/dist/tempo/client/Methods.js.map +1 -1
  52. package/dist/tempo/client/Subscription.d.ts +114 -0
  53. package/dist/tempo/client/Subscription.d.ts.map +1 -0
  54. package/dist/tempo/client/Subscription.js +100 -0
  55. package/dist/tempo/client/Subscription.js.map +1 -0
  56. package/dist/tempo/client/index.d.ts +1 -0
  57. package/dist/tempo/client/index.d.ts.map +1 -1
  58. package/dist/tempo/client/index.js +1 -0
  59. package/dist/tempo/client/index.js.map +1 -1
  60. package/dist/tempo/index.d.ts +1 -0
  61. package/dist/tempo/index.d.ts.map +1 -1
  62. package/dist/tempo/index.js +1 -0
  63. package/dist/tempo/index.js.map +1 -1
  64. package/dist/tempo/server/Methods.d.ts +5 -0
  65. package/dist/tempo/server/Methods.d.ts.map +1 -1
  66. package/dist/tempo/server/Methods.js +5 -0
  67. package/dist/tempo/server/Methods.js.map +1 -1
  68. package/dist/tempo/server/Subscription.d.ts +221 -0
  69. package/dist/tempo/server/Subscription.d.ts.map +1 -0
  70. package/dist/tempo/server/Subscription.js +637 -0
  71. package/dist/tempo/server/Subscription.js.map +1 -0
  72. package/dist/tempo/server/index.d.ts +1 -0
  73. package/dist/tempo/server/index.d.ts.map +1 -1
  74. package/dist/tempo/server/index.js +1 -0
  75. package/dist/tempo/server/index.js.map +1 -1
  76. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  77. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  78. package/dist/tempo/server/internal/html.gen.js +1 -1
  79. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  80. package/dist/tempo/subscription/KeyAuthorization.d.ts +282 -0
  81. package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -0
  82. package/dist/tempo/subscription/KeyAuthorization.js +297 -0
  83. package/dist/tempo/subscription/KeyAuthorization.js.map +1 -0
  84. package/dist/tempo/subscription/Receipt.d.ts +10 -0
  85. package/dist/tempo/subscription/Receipt.d.ts.map +1 -0
  86. package/dist/tempo/subscription/Receipt.js +16 -0
  87. package/dist/tempo/subscription/Receipt.js.map +1 -0
  88. package/dist/tempo/subscription/Store.d.ts +99 -0
  89. package/dist/tempo/subscription/Store.d.ts.map +1 -0
  90. package/dist/tempo/subscription/Store.js +292 -0
  91. package/dist/tempo/subscription/Store.js.map +1 -0
  92. package/dist/tempo/subscription/Types.d.ts +65 -0
  93. package/dist/tempo/subscription/Types.d.ts.map +1 -0
  94. package/dist/tempo/subscription/Types.js +2 -0
  95. package/dist/tempo/subscription/Types.js.map +1 -0
  96. package/dist/tempo/subscription/index.d.ts +6 -0
  97. package/dist/tempo/subscription/index.d.ts.map +1 -0
  98. package/dist/tempo/subscription/index.js +4 -0
  99. package/dist/tempo/subscription/index.js.map +1 -0
  100. package/dist/zod.d.ts +7 -0
  101. package/dist/zod.d.ts.map +1 -1
  102. package/dist/zod.js +18 -0
  103. package/dist/zod.js.map +1 -1
  104. package/package.json +3 -3
  105. package/src/Challenge.test.ts +13 -0
  106. package/src/Challenge.ts +3 -3
  107. package/src/Method.ts +46 -1
  108. package/src/Receipt.ts +2 -0
  109. package/src/client/Methods.ts +1 -0
  110. package/src/middlewares/elysia.test.ts +31 -1
  111. package/src/middlewares/elysia.ts +13 -0
  112. package/src/middlewares/express.ts +1 -5
  113. package/src/middlewares/hono.test.ts +30 -1
  114. package/src/middlewares/hono.ts +13 -0
  115. package/src/middlewares/nextjs.test.ts +28 -1
  116. package/src/middlewares/nextjs.ts +13 -0
  117. package/src/proxy/Proxy.ts +2 -5
  118. package/src/proxy/Service.test.ts +34 -0
  119. package/src/proxy/Service.ts +7 -0
  120. package/src/server/Mppx.authorize.test.ts +210 -0
  121. package/src/server/Mppx.test-d.ts +23 -1
  122. package/src/server/Mppx.test.ts +73 -3
  123. package/src/server/Mppx.ts +291 -58
  124. package/src/stripe/server/internal/html/package.json +1 -1
  125. package/src/stripe/server/internal/html.gen.ts +1 -1
  126. package/src/tempo/Methods.test.ts +131 -0
  127. package/src/tempo/Methods.ts +136 -0
  128. package/src/tempo/Subscription.integration.test.ts +591 -0
  129. package/src/tempo/client/Methods.ts +3 -0
  130. package/src/tempo/client/Subscription.test.ts +131 -0
  131. package/src/tempo/client/Subscription.ts +155 -0
  132. package/src/tempo/client/index.ts +1 -0
  133. package/src/tempo/index.ts +1 -0
  134. package/src/tempo/server/Methods.ts +5 -0
  135. package/src/tempo/server/Subscription.test.ts +1410 -0
  136. package/src/tempo/server/Subscription.ts +1014 -0
  137. package/src/tempo/server/index.ts +1 -0
  138. package/src/tempo/server/internal/html/package.json +1 -1
  139. package/src/tempo/server/internal/html.gen.ts +1 -1
  140. package/src/tempo/subscription/KeyAuthorization.test.ts +204 -0
  141. package/src/tempo/subscription/KeyAuthorization.ts +394 -0
  142. package/src/tempo/subscription/Receipt.ts +28 -0
  143. package/src/tempo/subscription/Store.test.ts +554 -0
  144. package/src/tempo/subscription/Store.ts +431 -0
  145. package/src/tempo/subscription/Types.ts +68 -0
  146. package/src/tempo/subscription/index.ts +23 -0
  147. package/src/zod.test.ts +23 -1
  148. package/src/zod.ts +24 -0
package/src/Method.ts CHANGED
@@ -130,10 +130,12 @@ export type Server<
130
130
  defaults extends ExactPartial<z.input<method['schema']['request']>> = {},
131
131
  transportOverride = undefined,
132
132
  > = method & {
133
+ authorize?: AuthorizeFn<method> | undefined
133
134
  defaults?: defaults | undefined
134
135
  html?: Html.Options | undefined
135
136
  request?: RequestFn<method> | undefined
136
137
  respond?: RespondFn<method> | undefined
138
+ stableBinding?: StableBindingFn<method> | undefined
137
139
  transport?: transportOverride | undefined
138
140
  verify: VerifyFn<method>
139
141
  }
@@ -155,6 +157,45 @@ export type RequestFn<method extends Method> = (
155
157
  options: RequestContext<method>,
156
158
  ) => MaybePromise<z.input<method['schema']['request']>>
157
159
 
160
+ /**
161
+ * Optional authorization hook for a server-side method.
162
+ *
163
+ * Called after request normalization but before the 402 challenge path. This lets
164
+ * a server grant access based on existing application state (for example, an
165
+ * active subscription) without requiring a fresh `Payment` credential.
166
+ *
167
+ * **HTTP-only.** The `input` parameter is a Fetch `Request`; non-HTTP transports
168
+ * do not invoke this hook.
169
+ *
170
+ * Transports that require credential context for `withReceipt()` should return a
171
+ * `response` from this hook so adapters can short-circuit protected handlers.
172
+ */
173
+ export type AuthorizeFn<method extends Method> = (parameters: {
174
+ challenge: Challenge.Challenge<
175
+ z.output<method['schema']['request']>,
176
+ method['intent'],
177
+ method['name']
178
+ >
179
+ input: globalThis.Request
180
+ request: z.output<method['schema']['request']>
181
+ }) => MaybePromise<AuthorizeResult | undefined>
182
+
183
+ /** Successful result returned from an {@link AuthorizeFn}. */
184
+ export type AuthorizeResult = {
185
+ receipt: Receipt.Receipt
186
+ response?: globalThis.Response | undefined
187
+ }
188
+
189
+ /**
190
+ * Produces the stable request fields used to bind credentials to a route.
191
+ *
192
+ * Methods can override this to opt into additional request fields beyond the
193
+ * default amount/currency/recipient binding used by generic methods.
194
+ */
195
+ export type StableBindingFn<method extends Method> = (
196
+ request: z.output<method['schema']['request']>,
197
+ ) => Record<string, unknown>
198
+
158
199
  /** Verification function for a single method. */
159
200
  export type VerifyFn<method extends Method> = (
160
201
  parameters: VerifyContext<method>,
@@ -251,13 +292,15 @@ export function toServer<
251
292
  method: method,
252
293
  options: toServer.Options<method, defaults, transportOverride>,
253
294
  ): Server<method, defaults, transportOverride> {
254
- const { defaults, html, request, respond, transport, verify } = options
295
+ const { authorize, defaults, html, request, respond, stableBinding, transport, verify } = options
255
296
  return {
256
297
  ...method,
298
+ authorize,
257
299
  defaults,
258
300
  html,
259
301
  request,
260
302
  respond,
303
+ stableBinding,
261
304
  transport,
262
305
  verify,
263
306
  } as Server<method, defaults, transportOverride>
@@ -269,10 +312,12 @@ export declare namespace toServer {
269
312
  defaults extends RequestDefaults<method> = {},
270
313
  transportOverride extends Transport.AnyTransport | undefined = undefined,
271
314
  > = {
315
+ authorize?: AuthorizeFn<method> | undefined
272
316
  defaults?: defaults | undefined
273
317
  html?: Html.Options | undefined
274
318
  request?: RequestFn<method> | undefined
275
319
  respond?: RespondFn<method> | undefined
320
+ stableBinding?: StableBindingFn<method> | undefined
276
321
  transport?: transportOverride | undefined
277
322
  verify: VerifyFn<method>
278
323
  }
package/src/Receipt.ts CHANGED
@@ -19,6 +19,8 @@ export const Schema = z.object({
19
19
  reference: z.string(),
20
20
  /** Optional external reference ID echoed from the credential payload. */
21
21
  externalId: z.optional(z.string()),
22
+ /** Optional server-issued subscription identifier for recurring payments. */
23
+ subscriptionId: z.optional(z.string()),
22
24
  /** Payment status. Always "success" — failures use 402 + Problem Details. */
23
25
  status: z.literal('success'),
24
26
  /** RFC 3339 settlement timestamp. */
@@ -1,3 +1,4 @@
1
1
  export { stripe } from '../stripe/client/index.js'
2
+ export { subscription } from '../tempo/client/Subscription.js'
2
3
  export { tempo } from '../tempo/client/index.js'
3
4
  export { session } from '../tempo/client/Session.js'
@@ -3,7 +3,7 @@ import * as http from 'node:http'
3
3
  import { Elysia } from 'elysia'
4
4
  import { Receipt } from 'mppx'
5
5
  import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client'
6
- import { Mppx, discovery } from 'mppx/elysia'
6
+ import { Mppx, discovery, payment } from 'mppx/elysia'
7
7
  import { tempo as tempo_server } from 'mppx/server'
8
8
  import type { Address } from 'viem'
9
9
  import { Addresses } from 'viem/tempo'
@@ -35,6 +35,36 @@ function createServer(app: Elysia<any, any, any, any, any, any, any>) {
35
35
 
36
36
  const secretKey = 'test-secret-key'
37
37
 
38
+ describe('payment', () => {
39
+ test('short-circuits management responses', async () => {
40
+ let handlerRan = false
41
+ const intent = () => async () => ({
42
+ status: 200 as const,
43
+ withReceipt: () =>
44
+ new Response(null, {
45
+ headers: { 'Payment-Receipt': 'management-receipt' },
46
+ status: 204,
47
+ }),
48
+ })
49
+
50
+ const app = new Elysia().guard({ beforeHandle: payment(intent as any, {} as any) }, (app) =>
51
+ app.get('/', () => {
52
+ handlerRan = true
53
+ return { data: 'content' }
54
+ }),
55
+ )
56
+
57
+ const server = await createServer(app)
58
+ const response = await globalThis.fetch(server.url)
59
+ expect(response.status).toBe(204)
60
+ expect(response.headers.get('Payment-Receipt')).toBe('management-receipt')
61
+ expect(await response.text()).toBe('')
62
+ expect(handlerRan).toBe(false)
63
+
64
+ server.close()
65
+ })
66
+ })
67
+
38
68
  function createChargeHarness(feePayer: boolean) {
39
69
  const mppx = Mppx.create({
40
70
  methods: [
@@ -64,12 +64,25 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
64
64
  return async ({ request, set }) => {
65
65
  const result = await intent(options)(request)
66
66
  if (result.status === 402) return result.challenge
67
+ const managementResponse = getManagementResponse(result)
68
+ if (managementResponse) return managementResponse
67
69
  const receipt = result.withReceipt(new Response())
68
70
  const header = receipt.headers.get('Payment-Receipt')
69
71
  if (header) set.headers['Payment-Receipt'] = header
70
72
  }
71
73
  }
72
74
 
75
+ function getManagementResponse(result: { withReceipt: (response?: Response) => Response }) {
76
+ try {
77
+ return result.withReceipt()
78
+ } catch (error) {
79
+ if (Mppx_core.isMissingReceiptResponseError(error)) {
80
+ return null
81
+ }
82
+ throw error
83
+ }
84
+ }
85
+
73
86
  export type DiscoveryConfig = Omit<GenerateConfig, 'routes'> & {
74
87
  path?: string
75
88
  routes?: RouteConfig[]
@@ -80,11 +80,7 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
80
80
  try {
81
81
  return (result.withReceipt as () => Response)()
82
82
  } catch (error) {
83
- if (
84
- error instanceof Error &&
85
- error.message === 'withReceipt() requires a response argument'
86
- )
87
- return null
83
+ if (Mppx_core.isMissingReceiptResponseError(error)) return null
88
84
  throw error
89
85
  }
90
86
  })()
@@ -2,7 +2,7 @@ import { serve } from '@hono/node-server'
2
2
  import { Hono } from 'hono'
3
3
  import { Challenge, Credential, Method, Receipt, z } from 'mppx'
4
4
  import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client'
5
- import { Mppx, discovery } from 'mppx/hono'
5
+ import { Mppx, discovery, payment } from 'mppx/hono'
6
6
  import { tempo as tempo_server } from 'mppx/server'
7
7
  import type { Address } from 'viem'
8
8
  import { Addresses } from 'viem/tempo'
@@ -26,6 +26,35 @@ function createServer(app: Hono) {
26
26
 
27
27
  const secretKey = 'test-secret-key'
28
28
 
29
+ describe('payment', () => {
30
+ test('short-circuits management responses', async () => {
31
+ let handlerRan = false
32
+ const intent = () => async () => ({
33
+ status: 200 as const,
34
+ withReceipt: () =>
35
+ new Response(null, {
36
+ headers: { 'Payment-Receipt': 'management-receipt' },
37
+ status: 204,
38
+ }),
39
+ })
40
+
41
+ const app = new Hono()
42
+ app.get('/', payment(intent as any, {} as any), (c) => {
43
+ handlerRan = true
44
+ return c.json({ data: 'content' })
45
+ })
46
+
47
+ const server = await createServer(app)
48
+ const response = await globalThis.fetch(server.url)
49
+ expect(response.status).toBe(204)
50
+ expect(response.headers.get('Payment-Receipt')).toBe('management-receipt')
51
+ expect(await response.text()).toBe('')
52
+ expect(handlerRan).toBe(false)
53
+
54
+ server.close()
55
+ })
56
+ })
57
+
29
58
  const scopeMethod = Method.toServer(
30
59
  Method.from({
31
60
  name: 'mock',
@@ -63,11 +63,24 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
63
63
  : c.req.raw
64
64
  const result = await intent(options)(request)
65
65
  if (result.status === 402) return result.challenge
66
+ const managementResponse = getManagementResponse(result)
67
+ if (managementResponse) return managementResponse
66
68
  await next()
67
69
  c.res = result.withReceipt(c.res)
68
70
  }
69
71
  }
70
72
 
73
+ function getManagementResponse(result: { withReceipt: (response?: Response) => Response }) {
74
+ try {
75
+ return result.withReceipt()
76
+ } catch (error) {
77
+ if (Mppx_core.isMissingReceiptResponseError(error)) {
78
+ return null
79
+ }
80
+ throw error
81
+ }
82
+ }
83
+
71
84
  export type DiscoveryConfig = Omit<GenerateConfig, 'routes'> & {
72
85
  auto?: boolean
73
86
  path?: string
@@ -2,7 +2,7 @@ import * as http from 'node:http'
2
2
 
3
3
  import { Challenge, Credential, Receipt } from 'mppx'
4
4
  import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client'
5
- import { Mppx, discovery } from 'mppx/nextjs'
5
+ import { Mppx, discovery, payment } from 'mppx/nextjs'
6
6
  import { tempo as tempo_server } from 'mppx/server'
7
7
  import type { Address } from 'viem'
8
8
  import { Addresses } from 'viem/tempo'
@@ -34,6 +34,33 @@ function createServer(handler: (request: Request) => Promise<Response> | Respons
34
34
 
35
35
  const secretKey = 'test-secret-key'
36
36
 
37
+ describe('payment', () => {
38
+ test('short-circuits management responses', async () => {
39
+ let handlerRan = false
40
+ const intent = () => async () => ({
41
+ status: 200 as const,
42
+ withReceipt: () =>
43
+ new Response(null, {
44
+ headers: { 'Payment-Receipt': 'management-receipt' },
45
+ status: 204,
46
+ }),
47
+ })
48
+ const handler = payment(intent as any, {} as any, () => {
49
+ handlerRan = true
50
+ return Response.json({ data: 'content' })
51
+ })
52
+
53
+ const server = await createServer(handler)
54
+ const response = await globalThis.fetch(server.url)
55
+ expect(response.status).toBe(204)
56
+ expect(response.headers.get('Payment-Receipt')).toBe('management-receipt')
57
+ expect(await response.text()).toBe('')
58
+ expect(handlerRan).toBe(false)
59
+
60
+ server.close()
61
+ })
62
+ })
63
+
37
64
  function createChargeHarness(feePayer: boolean) {
38
65
  const mppx = Mppx.create({
39
66
  methods: [
@@ -61,11 +61,24 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
61
61
  return async (request) => {
62
62
  const result = await intent(options)(request)
63
63
  if (result.status === 402) return result.challenge
64
+ const managementResponse = getManagementResponse(result)
65
+ if (managementResponse) return managementResponse
64
66
  const response = await handler(request)
65
67
  return result.withReceipt(response)
66
68
  }
67
69
  }
68
70
 
71
+ function getManagementResponse(result: { withReceipt: (response?: Response) => Response }) {
72
+ try {
73
+ return result.withReceipt()
74
+ } catch (error) {
75
+ if (Mppx_core.isMissingReceiptResponseError(error)) {
76
+ return null
77
+ }
78
+ throw error
79
+ }
80
+ }
81
+
69
82
  export type DiscoveryConfig = Omit<GenerateConfig, 'routes'> & {
70
83
  routes?: RouteConfig[]
71
84
  }
@@ -3,6 +3,7 @@ import type * as http from 'node:http'
3
3
  import * as Credential from '../Credential.js'
4
4
  import { generateProxy } from '../discovery/OpenApi.js'
5
5
  import * as Scope from '../server/internal/scope.js'
6
+ import * as Mppx from '../server/Mppx.js'
6
7
  import * as Request from '../server/Request.js'
7
8
  import * as Headers from './internal/Headers.js'
8
9
  import * as Route from './internal/Route.js'
@@ -146,11 +147,7 @@ export function create(config: create.Config): Proxy {
146
147
  try {
147
148
  return (result.withReceipt as () => Response)()
148
149
  } catch (error) {
149
- if (
150
- error instanceof Error &&
151
- error.message === 'withReceipt() requires a response argument'
152
- )
153
- return null
150
+ if (Mppx.isMissingReceiptResponseError(error)) return null
154
151
  throw error
155
152
  }
156
153
  })()
@@ -122,6 +122,40 @@ describe('paymentOf', () => {
122
122
  })
123
123
  expect(Service.paymentOf({ pay: handler, options: {} })).toBeNull()
124
124
  })
125
+
126
+ test('behavior: strips function internals from payment metadata', () => {
127
+ const handler = Object.assign(
128
+ async () => ({
129
+ status: 200 as const,
130
+ withReceipt: <T>(r: T) => r,
131
+ }),
132
+ {
133
+ _internal: {
134
+ _canonicalRequest: () => ({}),
135
+ _stableBinding: () => ({}),
136
+ amount: '1',
137
+ authorize: () => undefined,
138
+ decimals: 6,
139
+ defaults: {},
140
+ intent: 'charge',
141
+ name: 'mock',
142
+ request: () => ({}),
143
+ respond: () => undefined,
144
+ schema: {},
145
+ stableBinding: () => ({}),
146
+ transport: {},
147
+ verify: () => undefined,
148
+ },
149
+ },
150
+ )
151
+
152
+ expect(Service.paymentOf(handler as never)).toEqual({
153
+ amount: '1000000',
154
+ decimals: 6,
155
+ intent: 'charge',
156
+ method: 'mock',
157
+ })
158
+ })
125
159
  })
126
160
 
127
161
  describe('getOptions', () => {
@@ -216,6 +216,13 @@ export function paymentOf(endpoint: Endpoint): Record<string, unknown> | null {
216
216
  defaults: _,
217
217
  schema: _s,
218
218
  _canonicalRequest,
219
+ _stableBinding: _sb,
220
+ authorize: _a,
221
+ request: _r,
222
+ respond: _re,
223
+ stableBinding: _st,
224
+ transport: _t,
225
+ verify: _v,
219
226
  ...rest
220
227
  } = handler._internal as Record<string, unknown>
221
228
  const amount = (() => {
@@ -0,0 +1,210 @@
1
+ import { Challenge, Credential, Method, z } from 'mppx'
2
+ import { Mppx } from 'mppx/server'
3
+ import { describe, expect, test } from 'vp/test'
4
+ import * as Http from '~test/Http.js'
5
+
6
+ const realm = 'api.example.com'
7
+ const secretKey = 'test-secret-key'
8
+
9
+ function successReceipt(method = 'mock') {
10
+ return {
11
+ method,
12
+ reference: 'ref-1',
13
+ status: 'success',
14
+ timestamp: '2025-01-01T00:00:00.000Z',
15
+ } as const
16
+ }
17
+
18
+ describe('authorize hook', () => {
19
+ test('grants access without a Payment credential', async () => {
20
+ const method = Method.toServer(
21
+ Method.from({
22
+ name: 'mock',
23
+ intent: 'subscription',
24
+ schema: {
25
+ credential: { payload: z.object({ token: z.string() }) },
26
+ request: z.object({ amount: z.string() }),
27
+ },
28
+ }),
29
+ {
30
+ async authorize() {
31
+ return { receipt: successReceipt() }
32
+ },
33
+ async verify() {
34
+ return successReceipt()
35
+ },
36
+ },
37
+ )
38
+
39
+ const handler = Mppx.create({ methods: [method], realm, secretKey })
40
+ const result = await handler['mock/subscription']({ amount: '1' })(
41
+ new Request('https://example.com/resource'),
42
+ )
43
+
44
+ expect(result.status).toBe(200)
45
+ if (result.status !== 200) throw new Error('expected authorize success')
46
+
47
+ const response = result.withReceipt(new Response('OK'))
48
+ expect(response.headers.get('Payment-Receipt')).toBeTruthy()
49
+ })
50
+
51
+ test('toNodeListener forwards authorize management responses', async () => {
52
+ const method = Method.toServer(
53
+ Method.from({
54
+ name: 'mock',
55
+ intent: 'subscription',
56
+ schema: {
57
+ credential: { payload: z.object({ token: z.string() }) },
58
+ request: z.object({ amount: z.string() }),
59
+ },
60
+ }),
61
+ {
62
+ async authorize() {
63
+ return {
64
+ receipt: successReceipt(),
65
+ response: new Response('retry later', {
66
+ headers: { 'Retry-After': '1' },
67
+ status: 409,
68
+ }),
69
+ }
70
+ },
71
+ async verify() {
72
+ return successReceipt()
73
+ },
74
+ },
75
+ )
76
+
77
+ const handler = Mppx.create({ methods: [method], realm, secretKey })
78
+ const server = await Http.createServer(async (req, res) => {
79
+ const result = await Mppx.toNodeListener(handler['mock/subscription']({ amount: '1' }))(
80
+ req,
81
+ res,
82
+ )
83
+ if (result.status === 402) return
84
+ res.end('OK')
85
+ })
86
+
87
+ const response = await fetch(server.url)
88
+ expect(response.status).toBe(409)
89
+ expect(response.headers.get('Retry-After')).toBe('1')
90
+ expect(response.headers.get('Payment-Receipt')).toBeTruthy()
91
+ expect(await response.text()).toBe('retry later')
92
+
93
+ server.close()
94
+ })
95
+
96
+ test('compose evaluates authorize hooks sequentially on no-credential requests', async () => {
97
+ const calls: string[] = []
98
+ const createMethod = (
99
+ name: 'alpha' | 'beta',
100
+ authorizeResult?: ReturnType<typeof successReceipt>,
101
+ ) =>
102
+ Method.toServer(
103
+ Method.from({
104
+ name,
105
+ intent: 'charge',
106
+ schema: {
107
+ credential: { payload: z.object({ token: z.string() }) },
108
+ request: z.object({ amount: z.string() }),
109
+ },
110
+ }),
111
+ {
112
+ async authorize() {
113
+ calls.push(`${name}:start`)
114
+ await new Promise((resolve) => setTimeout(resolve, 0))
115
+ calls.push(`${name}:end`)
116
+ return authorizeResult ? { receipt: authorizeResult } : undefined
117
+ },
118
+ async verify() {
119
+ return successReceipt(name)
120
+ },
121
+ },
122
+ )
123
+
124
+ const alpha = createMethod('alpha')
125
+ const beta = createMethod('beta', successReceipt('beta'))
126
+ const handler = Mppx.create({ methods: [alpha, beta], realm, secretKey })
127
+
128
+ const result = await handler.compose(
129
+ [alpha, { amount: '1' }],
130
+ [beta, { amount: '1' }],
131
+ )(new Request('https://example.com/resource'))
132
+
133
+ expect(result.status).toBe(200)
134
+ expect(calls).toEqual(['alpha:start', 'alpha:end', 'beta:start', 'beta:end'])
135
+ })
136
+
137
+ test('stableBinding can reject mismatched subscription routes', async () => {
138
+ const method = Method.toServer(
139
+ Method.from({
140
+ name: 'mock',
141
+ intent: 'subscription',
142
+ schema: {
143
+ credential: { payload: z.object({ token: z.string() }) },
144
+ request: z.object({
145
+ amount: z.string(),
146
+ chainId: z.optional(z.number()),
147
+ currency: z.string(),
148
+ periodCount: z.string(),
149
+ periodUnit: z.enum(['day', 'week']),
150
+ recipient: z.string(),
151
+ subscriptionExpires: z.string(),
152
+ }),
153
+ },
154
+ }),
155
+ {
156
+ stableBinding(request) {
157
+ return {
158
+ amount: request.amount,
159
+ chainId: request.chainId,
160
+ currency: request.currency,
161
+ periodCount: request.periodCount,
162
+ periodUnit: request.periodUnit,
163
+ recipient: request.recipient,
164
+ subscriptionExpires: request.subscriptionExpires,
165
+ }
166
+ },
167
+ async verify() {
168
+ return successReceipt()
169
+ },
170
+ },
171
+ )
172
+
173
+ const handler = Mppx.create({ methods: [method], realm, secretKey })
174
+ const first = await handler['mock/subscription']({
175
+ amount: '1',
176
+ currency: 'usd',
177
+ periodCount: '30',
178
+ periodUnit: 'day',
179
+ recipient: 'alice',
180
+ subscriptionExpires: '2026-01-01T00:00:00Z',
181
+ })(new Request('https://example.com/cheap'))
182
+
183
+ expect(first.status).toBe(402)
184
+ if (first.status !== 402) throw new Error('expected challenge')
185
+
186
+ const credential = Credential.from({
187
+ challenge: Challenge.fromResponse(first.challenge),
188
+ payload: { token: 'ok' },
189
+ })
190
+
191
+ const second = await handler['mock/subscription']({
192
+ amount: '1',
193
+ currency: 'usd',
194
+ periodCount: '60',
195
+ periodUnit: 'day',
196
+ recipient: 'alice',
197
+ subscriptionExpires: '2026-01-01T00:00:00Z',
198
+ })(
199
+ new Request('https://example.com/expensive', {
200
+ headers: { Authorization: Credential.serialize(credential) },
201
+ }),
202
+ )
203
+
204
+ expect(second.status).toBe(402)
205
+ if (second.status !== 402) throw new Error('expected mismatch challenge')
206
+
207
+ const body = (await second.challenge.json()) as { detail: string }
208
+ expect(body.detail).toContain('periodCount')
209
+ })
210
+ })
@@ -1,5 +1,5 @@
1
1
  import { Method, z } from 'mppx'
2
- import { Mppx } from 'mppx/server'
2
+ import { Mppx, tempo } from 'mppx/server'
3
3
  import { assertType, describe, expectTypeOf, test } from 'vp/test'
4
4
 
5
5
  const mockChargeA = Method.from({
@@ -151,6 +151,7 @@ describe('Mppx type tests', () => {
151
151
  amount: '100',
152
152
  currency: '0x01',
153
153
  decimals: 6,
154
+ expires: new Date('2026-01-01T00:00:00Z'),
154
155
  recipient: '0x02',
155
156
  })
156
157
 
@@ -187,4 +188,25 @@ describe('Mppx type tests', () => {
187
188
  Promise<unknown>
188
189
  >()
189
190
  })
191
+
192
+ test('tempo subscription accepts ergonomic date and period inputs', () => {
193
+ const method = tempo.subscription({
194
+ amount: '10',
195
+ currency: '0x20c0000000000000000000000000000000000001',
196
+ periodCount: 1,
197
+ periodUnit: 'day',
198
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
199
+ resolve: async () => ({ key: 'user-1:plan:pro' }),
200
+ subscriptionExpires: new Date('2026-01-01T00:00:00Z'),
201
+ })
202
+ const mppx = Mppx.create({ methods: [method], realm, secretKey })
203
+
204
+ expectTypeOf(
205
+ mppx.tempo.subscription({
206
+ expires: new Date('2026-01-01T00:00:00Z'),
207
+ periodCount: 1n,
208
+ subscriptionExpires: new Date('2026-01-01T00:00:00Z'),
209
+ }),
210
+ ).toBeFunction()
211
+ })
190
212
  })