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.
- package/CHANGELOG.md +7 -0
- package/dist/Challenge.d.ts +2 -2
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +1 -1
- package/dist/Challenge.js.map +1 -1
- package/dist/Method.d.ts +34 -0
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js +3 -1
- package/dist/Method.js.map +1 -1
- package/dist/Receipt.d.ts +1 -0
- package/dist/Receipt.d.ts.map +1 -1
- package/dist/Receipt.js +2 -0
- package/dist/Receipt.js.map +1 -1
- package/dist/client/Methods.d.ts +1 -0
- package/dist/client/Methods.d.ts.map +1 -1
- package/dist/client/Methods.js +1 -0
- package/dist/client/Methods.js.map +1 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +14 -0
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +1 -2
- package/dist/middlewares/express.js.map +1 -1
- package/dist/middlewares/hono.d.ts.map +1 -1
- package/dist/middlewares/hono.js +14 -0
- package/dist/middlewares/hono.js.map +1 -1
- package/dist/middlewares/nextjs.d.ts.map +1 -1
- package/dist/middlewares/nextjs.js +14 -0
- package/dist/middlewares/nextjs.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +2 -2
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/Service.d.ts.map +1 -1
- package/dist/proxy/Service.js +1 -1
- package/dist/proxy/Service.js.map +1 -1
- package/dist/server/Mppx.d.ts +15 -3
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +190 -40
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Methods.d.ts +96 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +97 -0
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +3 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/client/Methods.js +3 -0
- package/dist/tempo/client/Methods.js.map +1 -1
- package/dist/tempo/client/Subscription.d.ts +114 -0
- package/dist/tempo/client/Subscription.d.ts.map +1 -0
- package/dist/tempo/client/Subscription.js +100 -0
- package/dist/tempo/client/Subscription.js.map +1 -0
- package/dist/tempo/client/index.d.ts +1 -0
- package/dist/tempo/client/index.d.ts.map +1 -1
- package/dist/tempo/client/index.js +1 -0
- package/dist/tempo/client/index.js.map +1 -1
- package/dist/tempo/index.d.ts +1 -0
- package/dist/tempo/index.d.ts.map +1 -1
- package/dist/tempo/index.js +1 -0
- package/dist/tempo/index.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +5 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +5 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Subscription.d.ts +221 -0
- package/dist/tempo/server/Subscription.d.ts.map +1 -0
- package/dist/tempo/server/Subscription.js +637 -0
- package/dist/tempo/server/Subscription.js.map +1 -0
- package/dist/tempo/server/index.d.ts +1 -0
- package/dist/tempo/server/index.d.ts.map +1 -1
- package/dist/tempo/server/index.js +1 -0
- package/dist/tempo/server/index.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/subscription/KeyAuthorization.d.ts +282 -0
- package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -0
- package/dist/tempo/subscription/KeyAuthorization.js +297 -0
- package/dist/tempo/subscription/KeyAuthorization.js.map +1 -0
- package/dist/tempo/subscription/Receipt.d.ts +10 -0
- package/dist/tempo/subscription/Receipt.d.ts.map +1 -0
- package/dist/tempo/subscription/Receipt.js +16 -0
- package/dist/tempo/subscription/Receipt.js.map +1 -0
- package/dist/tempo/subscription/Store.d.ts +99 -0
- package/dist/tempo/subscription/Store.d.ts.map +1 -0
- package/dist/tempo/subscription/Store.js +292 -0
- package/dist/tempo/subscription/Store.js.map +1 -0
- package/dist/tempo/subscription/Types.d.ts +65 -0
- package/dist/tempo/subscription/Types.d.ts.map +1 -0
- package/dist/tempo/subscription/Types.js +2 -0
- package/dist/tempo/subscription/Types.js.map +1 -0
- package/dist/tempo/subscription/index.d.ts +6 -0
- package/dist/tempo/subscription/index.d.ts.map +1 -0
- package/dist/tempo/subscription/index.js +4 -0
- package/dist/tempo/subscription/index.js.map +1 -0
- package/dist/zod.d.ts +7 -0
- package/dist/zod.d.ts.map +1 -1
- package/dist/zod.js +18 -0
- package/dist/zod.js.map +1 -1
- package/package.json +3 -3
- package/src/Challenge.test.ts +13 -0
- package/src/Challenge.ts +3 -3
- package/src/Method.ts +46 -1
- package/src/Receipt.ts +2 -0
- package/src/client/Methods.ts +1 -0
- package/src/middlewares/elysia.test.ts +31 -1
- package/src/middlewares/elysia.ts +13 -0
- package/src/middlewares/express.ts +1 -5
- package/src/middlewares/hono.test.ts +30 -1
- package/src/middlewares/hono.ts +13 -0
- package/src/middlewares/nextjs.test.ts +28 -1
- package/src/middlewares/nextjs.ts +13 -0
- package/src/proxy/Proxy.ts +2 -5
- package/src/proxy/Service.test.ts +34 -0
- package/src/proxy/Service.ts +7 -0
- package/src/server/Mppx.authorize.test.ts +210 -0
- package/src/server/Mppx.test-d.ts +23 -1
- package/src/server/Mppx.test.ts +73 -3
- package/src/server/Mppx.ts +291 -58
- package/src/stripe/server/internal/html/package.json +1 -1
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Methods.test.ts +131 -0
- package/src/tempo/Methods.ts +136 -0
- package/src/tempo/Subscription.integration.test.ts +591 -0
- package/src/tempo/client/Methods.ts +3 -0
- package/src/tempo/client/Subscription.test.ts +131 -0
- package/src/tempo/client/Subscription.ts +155 -0
- package/src/tempo/client/index.ts +1 -0
- package/src/tempo/index.ts +1 -0
- package/src/tempo/server/Methods.ts +5 -0
- package/src/tempo/server/Subscription.test.ts +1410 -0
- package/src/tempo/server/Subscription.ts +1014 -0
- package/src/tempo/server/index.ts +1 -0
- package/src/tempo/server/internal/html/package.json +1 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/subscription/KeyAuthorization.test.ts +204 -0
- package/src/tempo/subscription/KeyAuthorization.ts +394 -0
- package/src/tempo/subscription/Receipt.ts +28 -0
- package/src/tempo/subscription/Store.test.ts +554 -0
- package/src/tempo/subscription/Store.ts +431 -0
- package/src/tempo/subscription/Types.ts +68 -0
- package/src/tempo/subscription/index.ts +23 -0
- package/src/zod.test.ts +23 -1
- 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. */
|
package/src/client/Methods.ts
CHANGED
|
@@ -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',
|
package/src/middlewares/hono.ts
CHANGED
|
@@ -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
|
}
|
package/src/proxy/Proxy.ts
CHANGED
|
@@ -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', () => {
|
package/src/proxy/Service.ts
CHANGED
|
@@ -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
|
})
|