From 313f3148a443fefcf174419f0280f56e99f8d49c Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Fri, 10 Apr 2026 15:09:49 -0700 Subject: [PATCH] feat: add authorize hook and tempo subscriptions --- src/Method.ts | 43 +++- src/Receipt.ts | 2 + src/client/Methods.ts | 1 + src/client/internal/Fetch.browser.test.ts | 13 +- src/client/internal/Fetch.ts | 24 +- src/middlewares/elysia.ts | 2 + src/middlewares/express.ts | 2 + src/server/Mppx.authorize.test.ts | 157 +++++++++++++ src/server/Mppx.ts | 254 ++++++++++++---------- src/server/Transport.test.ts | 19 ++ src/server/Transport.ts | 4 + src/tempo/Methods.test.ts | 44 ++++ src/tempo/Methods.ts | 43 ++++ src/tempo/client/Methods.ts | 3 + src/tempo/client/Subscription.ts | 113 ++++++++++ src/tempo/client/index.ts | 1 + src/tempo/index.ts | 1 + src/tempo/server/Methods.ts | 3 + src/tempo/server/Subscription.test.ts | 174 +++++++++++++++ src/tempo/server/Subscription.ts | 246 +++++++++++++++++++++ src/tempo/server/index.ts | 1 + src/tempo/subscription/Receipt.ts | 25 +++ src/tempo/subscription/Store.ts | 43 ++++ src/tempo/subscription/Types.ts | 42 ++++ src/tempo/subscription/index.ts | 9 + 25 files changed, 1146 insertions(+), 123 deletions(-) create mode 100644 src/server/Mppx.authorize.test.ts create mode 100644 src/tempo/client/Subscription.ts create mode 100644 src/tempo/server/Subscription.test.ts create mode 100644 src/tempo/server/Subscription.ts create mode 100644 src/tempo/subscription/Receipt.ts create mode 100644 src/tempo/subscription/Store.ts create mode 100644 src/tempo/subscription/Types.ts create mode 100644 src/tempo/subscription/index.ts diff --git a/src/Method.ts b/src/Method.ts index ede41e77..10b3fbba 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -127,10 +127,12 @@ export type Server< defaults extends ExactPartial> = {}, transportOverride = undefined, > = method & { + authorize?: AuthorizeFn | undefined defaults?: defaults | undefined html?: Html.Options | undefined request?: RequestFn | undefined respond?: RespondFn | undefined + stableBinding?: StableBindingFn | undefined transport?: transportOverride | undefined verify: VerifyFn } @@ -152,6 +154,41 @@ export type RequestFn = ( options: RequestContext, ) => MaybePromise> +/** + * Optional authorization hook for a server-side method. + * + * Called after request normalization but before the 402 challenge path. This lets + * a server grant access based on existing application state (for example, an + * active subscription) without requiring a fresh `Payment` credential. + * + * **HTTP-only.** The `input` parameter is a Fetch `Request`; non-HTTP transports + * do not invoke this hook. + */ +export type AuthorizeFn = (parameters: { + challenge: Challenge.Challenge< + z.output, + method['intent'], + method['name'] + > + input: globalThis.Request + request: z.output +}) => MaybePromise + +export type AuthorizeResult = { + receipt: Receipt.Receipt + response?: globalThis.Response | undefined +} + +/** + * Produces the stable request fields used to bind credentials to a route. + * + * Methods can override this to opt into additional request fields beyond the + * default amount/currency/recipient binding used by generic methods. + */ +export type StableBindingFn = ( + request: z.output, +) => Record + /** Verification function for a single method. */ export type VerifyFn = ( parameters: VerifyContext, @@ -246,13 +283,15 @@ export function toServer< method: method, options: toServer.Options, ): Server { - const { defaults, html, request, respond, transport, verify } = options + const { authorize, defaults, html, request, respond, stableBinding, transport, verify } = options return { ...method, + authorize, defaults, html, request, respond, + stableBinding, transport, verify, } as Server @@ -264,10 +303,12 @@ export declare namespace toServer { defaults extends RequestDefaults = {}, transportOverride extends Transport.AnyTransport | undefined = undefined, > = { + authorize?: AuthorizeFn | undefined defaults?: defaults | undefined html?: Html.Options | undefined request?: RequestFn | undefined respond?: RespondFn | undefined + stableBinding?: StableBindingFn | undefined transport?: transportOverride | undefined verify: VerifyFn } diff --git a/src/Receipt.ts b/src/Receipt.ts index 0aa2815d..db93d067 100644 --- a/src/Receipt.ts +++ b/src/Receipt.ts @@ -19,6 +19,8 @@ export const Schema = z.object({ reference: z.string(), /** Optional external reference ID echoed from the credential payload. */ externalId: z.optional(z.string()), + /** Optional server-issued subscription identifier for recurring payments. */ + subscriptionId: z.optional(z.string()), /** Payment status. Always "success" — failures use 402 + Problem Details. */ status: z.literal('success'), /** RFC 3339 settlement timestamp. */ diff --git a/src/client/Methods.ts b/src/client/Methods.ts index 725005a6..72ee6281 100644 --- a/src/client/Methods.ts +++ b/src/client/Methods.ts @@ -1,3 +1,4 @@ export { stripe } from '../stripe/client/index.js' +export { subscription } from '../tempo/client/Subscription.js' export { tempo } from '../tempo/client/index.js' export { session } from '../tempo/client/Session.js' diff --git a/src/client/internal/Fetch.browser.test.ts b/src/client/internal/Fetch.browser.test.ts index 94ae0854..e7efcc2d 100644 --- a/src/client/internal/Fetch.browser.test.ts +++ b/src/client/internal/Fetch.browser.test.ts @@ -67,13 +67,22 @@ describe('Fetch.from: browser header normalization', () => { expect(h.Authorization).toBe('credential') }) - test('replaces authorization case-insensitively', async () => { + test('preserves non-Payment authorization schemes when retrying', async () => { const { retryHeaders } = setup() const h = await retryHeaders('https://example.com', { headers: { authorization: 'Bearer stale', 'X-Custom': 'value' }, }) expect(h.authorization).toBeUndefined() - expect(h.Authorization).toBe('credential') + expect(h.Authorization).toBe('Bearer stale, credential') + expect(h['X-Custom']).toBe('value') + }) + + test('replaces stale Payment credentials while preserving bearer auth', async () => { + const { retryHeaders } = setup() + const h = await retryHeaders('https://example.com', { + headers: { Authorization: 'Bearer live, Payment stale', 'X-Custom': 'value' }, + }) + expect(h.Authorization).toBe('Bearer live, credential') expect(h['X-Custom']).toBe('value') }) diff --git a/src/client/internal/Fetch.ts b/src/client/internal/Fetch.ts index 4cd0093e..ebcfc1b6 100644 --- a/src/client/internal/Fetch.ts +++ b/src/client/internal/Fetch.ts @@ -209,12 +209,28 @@ export function normalizeHeaders(headers: unknown): Record { /** @internal */ function withAuthorizationHeader(headers: unknown, credential: string): Record { const normalized = normalizeHeaders(headers) - // Remove any existing Authorization header regardless of casing to avoid - // duplicate/conflicting credentials on retry. + const existingSchemes: string[] = [] + + // Preserve non-Payment Authorization schemes (for example Bearer identity + // tokens) while replacing any stale Payment credential on retry. for (const key of Object.keys(normalized)) { - if (key.toLowerCase() === 'authorization') delete normalized[key] + if (key.toLowerCase() !== 'authorization') continue + + const value = normalized[key] + delete normalized[key] + if (!value) continue + + existingSchemes.push( + ...value + .split(',') + .map((scheme) => scheme.trim()) + .filter((scheme) => scheme && !/^Payment\s+/i.test(scheme)), + ) } - normalized.Authorization = credential + + normalized.Authorization = existingSchemes.length + ? [...existingSchemes, credential].join(', ') + : credential return normalized } diff --git a/src/middlewares/elysia.ts b/src/middlewares/elysia.ts index 6fe42893..332e8cc1 100644 --- a/src/middlewares/elysia.ts +++ b/src/middlewares/elysia.ts @@ -67,6 +67,8 @@ export function payment( const receipt = result.withReceipt(new Response()) const header = receipt.headers.get('Payment-Receipt') if (header) set.headers['Payment-Receipt'] = header + const cacheControl = receipt.headers.get('Cache-Control') + if (cacheControl) set.headers['Cache-Control'] = cacheControl } } diff --git a/src/middlewares/express.ts b/src/middlewares/express.ts index 4985aac7..9ea2ed60 100644 --- a/src/middlewares/express.ts +++ b/src/middlewares/express.ts @@ -80,6 +80,8 @@ export function payment( res.json = (body: any) => { const wrapped = result.withReceipt(Response.json(body)) res.setHeader('Payment-Receipt', wrapped.headers.get('Payment-Receipt')!) + const cacheControl = wrapped.headers.get('Cache-Control') + if (cacheControl) res.setHeader('Cache-Control', cacheControl) return originalJson(body) } diff --git a/src/server/Mppx.authorize.test.ts b/src/server/Mppx.authorize.test.ts new file mode 100644 index 00000000..05b99c1a --- /dev/null +++ b/src/server/Mppx.authorize.test.ts @@ -0,0 +1,157 @@ +import { Challenge, Credential, Method, z } from 'mppx' +import { Mppx } from 'mppx/server' +import { describe, expect, test } from 'vp/test' + +const realm = 'api.example.com' +const secretKey = 'test-secret-key' + +function successReceipt(method = 'mock') { + return { + method, + reference: 'ref-1', + status: 'success', + timestamp: '2025-01-01T00:00:00.000Z', + } as const +} + +describe('authorize hook', () => { + test('grants access without a Payment credential', async () => { + const method = Method.toServer( + Method.from({ + name: 'mock', + intent: 'subscription', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.object({ amount: z.string() }), + }, + }), + { + async authorize() { + return { receipt: successReceipt() } + }, + async verify() { + return successReceipt() + }, + }, + ) + + const handler = Mppx.create({ methods: [method], realm, secretKey }) + const result = await handler['mock/subscription']({ amount: '1' })( + new Request('https://example.com/resource'), + ) + + expect(result.status).toBe(200) + if (result.status !== 200) throw new Error('expected authorize success') + + const response = result.withReceipt(new Response('OK')) + expect(response.headers.get('Payment-Receipt')).toBeTruthy() + }) + + test('compose evaluates authorize hooks sequentially on no-credential requests', async () => { + const calls: string[] = [] + const createMethod = (name: 'alpha' | 'beta', authorizeResult?: ReturnType) => + Method.toServer( + Method.from({ + name, + intent: 'charge', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.object({ amount: z.string() }), + }, + }), + { + async authorize() { + calls.push(`${name}:start`) + await new Promise((resolve) => setTimeout(resolve, 0)) + calls.push(`${name}:end`) + return authorizeResult ? { receipt: authorizeResult } : undefined + }, + async verify() { + return successReceipt(name) + }, + }, + ) + + const alpha = createMethod('alpha') + const beta = createMethod('beta', successReceipt('beta')) + const handler = Mppx.create({ methods: [alpha, beta], realm, secretKey }) + + const result = await handler.compose( + [alpha, { amount: '1' }], + [beta, { amount: '1' }], + )(new Request('https://example.com/resource')) + + expect(result.status).toBe(200) + expect(calls).toEqual(['alpha:start', 'alpha:end', 'beta:start', 'beta:end']) + }) + + test('stableBinding can reject mismatched subscription routes', async () => { + const method = Method.toServer( + Method.from({ + name: 'mock', + intent: 'subscription', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.object({ + amount: z.string(), + chainId: z.optional(z.number()), + currency: z.string(), + periodSeconds: z.string(), + recipient: z.string(), + subscriptionExpires: z.string(), + }), + }, + }), + { + stableBinding(request) { + return { + amount: request.amount, + chainId: request.chainId, + currency: request.currency, + periodSeconds: request.periodSeconds, + recipient: request.recipient, + subscriptionExpires: request.subscriptionExpires, + } + }, + async verify() { + return successReceipt() + }, + }, + ) + + const handler = Mppx.create({ methods: [method], realm, secretKey }) + const first = await handler['mock/subscription']({ + amount: '1', + currency: 'usd', + periodSeconds: '30', + recipient: 'alice', + subscriptionExpires: '2026-01-01T00:00:00Z', + })(new Request('https://example.com/cheap')) + + expect(first.status).toBe(402) + if (first.status !== 402) throw new Error('expected challenge') + + const credential = Credential.from({ + challenge: Challenge.fromResponse(first.challenge), + payload: { token: 'ok' }, + }) + + const second = await handler['mock/subscription']({ + amount: '1', + currency: 'usd', + periodSeconds: '60', + recipient: 'alice', + subscriptionExpires: '2026-01-01T00:00:00Z', + })( + new Request('https://example.com/expensive', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(second.status).toBe(402) + if (second.status !== 402) throw new Error('expected mismatch challenge') + + const body = (await second.challenge.json()) as { detail: string } + expect(body.detail).toContain('periodSeconds') + }) +}) diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 8be58b1c..aed88cfe 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -175,12 +175,14 @@ export function create< for (const mi of methods) { intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1 handlers[`${mi.name}/${mi.intent}`] = createMethodFn({ + authorize: mi.authorize as never, defaults: mi.defaults, method: mi, realm, request: mi.request as never, respond: mi.respond as never, secretKey, + stableBinding: mi.stableBinding as never, transport: (mi.transport ?? transport) as never, verify: mi.verify as never, }) @@ -256,7 +258,8 @@ function createMethodFn< ): createMethodFn.ReturnType // biome-ignore lint/correctness/noUnusedVariables: _ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.ReturnType { - const { defaults, method, realm, respond, secretKey, transport, verify } = parameters + const { authorize, defaults, method, realm, respond, secretKey, stableBinding, transport, verify } = + parameters return (options) => { const { description, meta, ...rest } = options @@ -314,8 +317,76 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R return { challenge: response, status: 402 } } + const success = ( + receiptData: Receipt.Receipt, + options: { + challengeId?: string | undefined + credentialForReceipt?: Credential.Credential | undefined + envelopeForReceipt?: Method.VerifiedChallengeEnvelope | undefined + managementResponse?: globalThis.Response | undefined + } = {}, + ): MethodFn.Response => { + const { + challengeId = challenge.id, + credentialForReceipt = ({ challenge, payload: {} } as Credential.Credential), + envelopeForReceipt, + managementResponse, + } = options + + return { + status: 200, + withReceipt(response?: response) { + if (managementResponse) { + return transport.respondReceipt({ + challengeId, + credential: credentialForReceipt, + ...(envelopeForReceipt ? { envelope: envelopeForReceipt } : {}), + input, + receipt: receiptData, + response: managementResponse as never, + }) as response + } + if (!response) throw new Error('withReceipt() requires a response argument') + return transport.respondReceipt({ + challengeId, + credential: credentialForReceipt, + ...(envelopeForReceipt ? { envelope: envelopeForReceipt } : {}), + input, + receipt: receiptData, + response: response as never, + }) as response + }, + } + } + // No credential provided—issue challenge if (!credential) { + if (authorize && input instanceof globalThis.Request) { + try { + const authorized = await authorize({ + challenge, + input, + request: challenge.request, + } as never) + if (authorized) { + return success(authorized.receipt, { + managementResponse: authorized.response, + }) + } + } catch (e) { + if (!(e instanceof Errors.PaymentError)) + console.error('mppx: internal authorization error', e) + const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError() + const response = await transport.respondChallenge({ + challenge, + input, + error, + html: method.html, + }) + return { challenge: response, status: 402 } + } + } + const response = await transport.respondChallenge({ challenge, input, @@ -372,7 +443,28 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R // they are set by server config (not derived from the request hook) // and are already fully covered by the HMAC binding in Tier 1. { - const mismatch = getPinnedChallengeMismatch(challenge, credential.challenge) + for (const field of ['method', 'intent', 'realm'] as const) { + if (credential.challenge[field] !== challenge[field]) { + const response = await transport.respondChallenge({ + challenge, + input, + error: new Errors.InvalidChallengeError({ + id: credential.challenge.id, + reason: `credential ${field} does not match this route's requirements`, + }), + html: method.html, + }) + return { challenge: response, status: 402 } + } + } + + const mismatch = getRequestBindingMismatch( + getStableBinding(challenge.request as Record, stableBinding as never), + getStableBinding( + credential.challenge.request as Record, + stableBinding as never, + ), + ) if (mismatch) { const response = await transport.respondChallenge({ challenge, @@ -442,30 +534,12 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R ? await respond({ credential, envelope, input, receipt: receiptData, request } as never) : undefined - return { - status: 200, - withReceipt(response?: response) { - if (managementResponse) { - return transport.respondReceipt({ - credential, - envelope, - input, - receipt: receiptData, - response: managementResponse as never, - challengeId: credential.challenge.id, - }) as response - } - if (!response) throw new Error('withReceipt() requires a response argument') - return transport.respondReceipt({ - credential, - envelope, - input, - receipt: receiptData, - response: response as never, - challengeId: credential.challenge.id, - }) as response - }, - } + return success(receiptData, { + challengeId: credential.challenge.id, + credentialForReceipt: credential, + envelopeForReceipt: envelope, + managementResponse, + }) }, { _internal: { @@ -475,6 +549,10 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R name: method.name, intent: method.intent, _canonicalRequest: PaymentRequest.fromMethod(method, merged), + _stableBinding: getStableBinding( + PaymentRequest.fromMethod(method, merged), + stableBinding as never, + ), }, }, ) @@ -494,12 +572,14 @@ declare namespace createMethodFn { transport extends Transport.AnyTransport = Transport.Http, defaults extends Record = Record, > = { + authorize?: Method.AuthorizeFn defaults?: defaults method: method realm: string | undefined request?: Method.RequestFn respond?: Method.RespondFn secretKey: string + stableBinding?: Method.StableBindingFn transport: transport verify: Method.VerifyFn } @@ -572,87 +652,35 @@ function captureRequestFromInput(input: unknown): Method.CapturedRequest { } } -const coreBindingFields = ['amount', 'currency', 'recipient'] as const -const methodBindingFields = ['chainId', 'memo', 'splits'] as const -const pinnedRequestBindingFields = [...coreBindingFields, ...methodBindingFields] as const - -type CoreBindingField = (typeof coreBindingFields)[number] -type MethodBindingField = (typeof methodBindingFields)[number] -type PinnedRequestBindingField = (typeof pinnedRequestBindingFields)[number] -type PinnedChallengeField = 'method' | 'intent' | 'realm' | PinnedRequestBindingField - -/** - * Compares only the fields that MUST be stable across request-hook transforms. - * - * This is NOT the primary integrity check — the HMAC binding (Challenge.verify) - * already covers every challenge field including opaque, digest, and the full - * serialized request. This function exists as a secondary safety net for the - * case where the `request()` hook produces credential-dependent output, causing - * the recomputed challenge to differ from the original in non-economic fields - * (e.g. `feePayer`). We only need to verify that the economically significant - * subset hasn't drifted. - */ -function getPinnedChallengeMismatch( - expectedChallenge: Challenge.Challenge, - actualChallenge: Challenge.Challenge, -): PinnedChallengeField | undefined { - for (const field of ['method', 'intent', 'realm'] as const) { - if (actualChallenge[field] !== expectedChallenge[field]) return field - } - - return getPinnedRequestBindingMismatch( - expectedChallenge.request as Record, - actualChallenge.request as Record, - ) -} +type StableBinding = Record -function getPinnedRequestBindingMismatch( - expectedRequest: Record, - actualRequest: Record, -): PinnedRequestBindingField | undefined { - const expected = getPinnedRequestBinding(expectedRequest) - const actual = getPinnedRequestBinding(actualRequest) +function getRequestBindingMismatch(expected: StableBinding, actual: StableBinding): string | undefined { + const fields = [ + ...Object.keys(expected), + ...Object.keys(actual).filter((key) => !(key in expected)), + ] - return ( - getCoreBindingMismatch(expected.coreBinding, actual.coreBinding) ?? - getMethodBindingMismatch(expected.methodBinding, actual.methodBinding) + return fields.find( + (field) => + !isDeepStrictEqual(normalizeComparable(expected[field]), normalizeComparable(actual[field])), ) } -function getCoreBindingMismatch( - expected: CoreBinding, - actual: CoreBinding, -): CoreBindingField | undefined { - return coreBindingFields.find((field) => !isDeepStrictEqual(expected[field], actual[field])) -} - -function getMethodBindingMismatch( - expected: MethodBinding, - actual: MethodBinding, -): MethodBindingField | undefined { - return methodBindingFields.find((field) => !isDeepStrictEqual(expected[field], actual[field])) -} +function getStableBinding( + request: Record, + stableBinding?: Method.StableBindingFn | undefined, +): StableBinding { + if (stableBinding) return stableBinding(request as never) -function getPinnedRequestBinding(request: Record): PinnedRequestBinding { const methodDetails = (request.methodDetails ?? {}) as Record - const amount = normalizeScalar(request.amount ?? methodDetails.amount) - const chainId = normalizeScalar(request.chainId ?? methodDetails.chainId) - const currency = normalizeScalar(request.currency ?? methodDetails.currency) - const memo = normalizeHex(methodDetails.memo) - const recipient = normalizeScalar(request.recipient ?? methodDetails.recipient) - const splits = normalizeComparable(methodDetails.splits) return { - coreBinding: { - ...(amount !== undefined ? { amount } : {}), - ...(currency !== undefined ? { currency } : {}), - ...(recipient !== undefined ? { recipient } : {}), - }, - methodBinding: { - ...(chainId !== undefined ? { chainId } : {}), - ...(memo !== undefined ? { memo } : {}), - ...(splits !== undefined ? { splits } : {}), - }, + amount: normalizeScalar(request.amount ?? methodDetails.amount), + chainId: normalizeScalar(request.chainId ?? methodDetails.chainId), + currency: normalizeScalar(request.currency ?? methodDetails.currency), + memo: normalizeHex(methodDetails.memo), + recipient: normalizeScalar(request.recipient ?? methodDetails.recipient), + splits: normalizeComparable(methodDetails.splits), } } @@ -682,19 +710,6 @@ function normalizeComparable(value: unknown): unknown { return typeof value === 'string' ? normalizeHex(value) : value } -type CoreBinding = { - [field in CoreBindingField]?: string -} - -type MethodBinding = { - [field in MethodBindingField]?: unknown -} - -type PinnedRequestBinding = { - coreBinding: CoreBinding - methodBinding: MethodBinding -} - export type MethodFn< method extends Method.Method, transport extends Transport.AnyTransport, @@ -739,6 +754,7 @@ type ConfiguredHandler = ((input: Request) => Promise + _stableBinding: StableBinding } } @@ -861,9 +877,7 @@ export function compose( const candidates = handlers.filter((h) => { const meta = (h as ConfiguredHandler)._internal if (!meta || meta.name !== credMethod || meta.intent !== credIntent) return false - const canonical = meta._canonicalRequest - if (!canonical) return true - return !getPinnedRequestBindingMismatch(canonical, credReq) + return !getRequestBindingMismatch(meta._stableBinding, getStableBinding(credReq)) }) const match = @@ -880,8 +894,14 @@ export function compose( return handlers[0]!(input) } - // No credential — call all handlers and merge 402 challenges. - const results = await Promise.all(handlers.map((h) => h(input))) + // No credential — evaluate handlers sequentially so authorize()/renewal hooks + // can safely claim the request without racing each other. + const results: MethodFn.Response[] = [] + for (const handler of handlers) { + const result = await handler(input) + if (result.status === 200) return result + results.push(result) + } // Merge WWW-Authenticate headers from all 402 responses. const mergedHeaders = new Headers() @@ -1015,6 +1035,8 @@ export function toNodeListener( } else { const wrapped = result.withReceipt(new globalThis.Response()) as globalThis.Response res.setHeader('Payment-Receipt', wrapped.headers.get('Payment-Receipt')!) + const cacheControl = wrapped.headers.get('Cache-Control') + if (cacheControl) res.setHeader('Cache-Control', cacheControl) } return result diff --git a/src/server/Transport.test.ts b/src/server/Transport.test.ts index 2a231e67..7d34402b 100644 --- a/src/server/Transport.test.ts +++ b/src/server/Transport.test.ts @@ -386,6 +386,7 @@ describe('http', () => { }).toMatchInlineSnapshot(` { "headers": { + "cache-control": "private", "content-type": "text/plain;charset=UTF-8", "payment-receipt": "eyJtZXRob2QiOiJ0ZW1wbyIsInJlZmVyZW5jZSI6IjB4dHhoYXNoIiwic3RhdHVzIjoic3VjY2VzcyIsInRpbWVzdGFtcCI6IjIwMjUtMDEtMDFUMDA6MDA6MDAuMDAwWiJ9", }, @@ -393,6 +394,24 @@ describe('http', () => { } `) }) + + test('appends private cache control when the response already has cache directives', () => { + const transport = Transport.http() + const originalResponse = new Response('OK', { + headers: { 'Cache-Control': 'no-store' }, + status: 200, + }) + + const response = transport.respondReceipt({ + credential, + input: new Request('https://example.com'), + receipt, + response: originalResponse, + challengeId: challenge.id, + }) + + expect(response.headers.get('Cache-Control')).toBe('no-store, private') + }) }) }) diff --git a/src/server/Transport.ts b/src/server/Transport.ts index a2669590..7571ca01 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -196,6 +196,10 @@ export function http(): Http { respondReceipt({ receipt, response }) { const headers = new Headers(response.headers) headers.set('Payment-Receipt', Receipt.serialize(receipt)) + const cacheControl = headers.get('Cache-Control') + if (!cacheControl) headers.set('Cache-Control', 'private') + else if (!cacheControl.split(',').some((value) => value.trim().toLowerCase() === 'private')) + headers.set('Cache-Control', `${cacheControl}, private`) return new Response(response.body, { status: response.status, statusText: response.statusText, diff --git a/src/tempo/Methods.test.ts b/src/tempo/Methods.test.ts index 2aefbc28..511184e6 100644 --- a/src/tempo/Methods.test.ts +++ b/src/tempo/Methods.test.ts @@ -209,3 +209,47 @@ describe('session', () => { expect(request.methodDetails?.minVoucherDelta).toBe('100000') }) }) + +describe('subscription', () => { + test('has correct name and intent', () => { + expect(Methods.subscription.intent).toBe('subscription') + expect(Methods.subscription.name).toBe('tempo') + }) + + test('schema: validates request and encodes amount in base units', () => { + const request = Methods.subscription.schema.request.parse({ + amount: '10', + chainId: 4217, + currency: '0x20c0000000000000000000000000000000000001', + decimals: 6, + periodSeconds: '3600', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + subscriptionExpires: '2026-01-01T00:00:00Z', + }) + + expect(request.amount).toBe('10000000') + expect(request.methodDetails?.chainId).toBe(4217) + }) + + test('schema: rejects non-numeric periodSeconds', () => { + const result = Methods.subscription.schema.request.safeParse({ + amount: '10', + currency: '0x20c0000000000000000000000000000000000001', + decimals: 6, + periodSeconds: 'month', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + subscriptionExpires: '2026-01-01T00:00:00Z', + }) + + expect(result.success).toBe(false) + }) + + test('schema: validates key authorization payload', () => { + const result = Methods.subscription.schema.credential.payload.safeParse({ + signature: '0x1234', + type: 'keyAuthorization', + }) + + expect(result.success).toBe(true) + }) +}) diff --git a/src/tempo/Methods.ts b/src/tempo/Methods.ts index cbb66a8f..be17c5be 100644 --- a/src/tempo/Methods.ts +++ b/src/tempo/Methods.ts @@ -184,3 +184,46 @@ export const session = Method.from({ ), }, }) + +/** + * Tempo subscription intent for recurring TIP-20 token transfers. + * + * Uses a signed key authorization that delegates one transfer per billing period. + */ +export const subscription = Method.from({ + name: 'tempo', + intent: 'subscription', + schema: { + credential: { + payload: z.object({ + signature: z.signature(), + type: z.literal('keyAuthorization'), + }), + }, + request: z.pipe( + z.object({ + amount: z.amount(), + chainId: z.optional(z.number()), + currency: z.string(), + decimals: z.number(), + description: z.optional(z.string()), + externalId: z.optional(z.string()), + periodSeconds: z.string().check(z.regex(/^[1-9]\d*$/, 'Invalid periodSeconds')), + recipient: z.string(), + subscriptionExpires: z.datetime(), + subscriptionId: z.optional(z.string()), + }), + z.transform(({ amount, chainId, decimals, ...rest }) => ({ + ...rest, + amount: parseUnits(amount, decimals).toString(), + ...(chainId !== undefined + ? { + methodDetails: { + chainId, + }, + } + : {}), + })), + ), + }, +}) diff --git a/src/tempo/client/Methods.ts b/src/tempo/client/Methods.ts index 4f2428dc..a46908d8 100644 --- a/src/tempo/client/Methods.ts +++ b/src/tempo/client/Methods.ts @@ -1,6 +1,7 @@ import { charge as charge_ } from './Charge.js' import { session as sessionIntent_ } from './Session.js' import { sessionManager as session_ } from './SessionManager.js' +import { subscription as subscription_ } from './Subscription.js' /** * Creates both Tempo `charge` and `session` client methods from shared parameters. @@ -25,4 +26,6 @@ export namespace tempo { export const charge = charge_ /** Creates a client-side streaming session for managing payment channels. */ export const session = session_ + /** Creates a Tempo `subscription` client method for recurring TIP-20 payments. */ + export const subscription = subscription_ } diff --git a/src/tempo/client/Subscription.ts b/src/tempo/client/Subscription.ts new file mode 100644 index 00000000..6c095827 --- /dev/null +++ b/src/tempo/client/Subscription.ts @@ -0,0 +1,113 @@ +import { KeyAuthorization } from 'ox/tempo' +import type { Address } from 'viem' +import { tempo as tempo_chain } from 'viem/chains' +import { Actions } from 'viem/tempo' + +import * as Credential from '../../Credential.js' +import * as Method from '../../Method.js' +import * as Account from '../../viem/Account.js' +import * as Client from '../../viem/Client.js' +import * as z from '../../zod.js' +import * as defaults from '../internal/defaults.js' +import * as Methods from '../Methods.js' + +type AccessKeyReference = { + accessKeyAddress: Address + keyType: 'p256' | 'secp256k1' | 'webAuthn' +} + +export const subscriptionContextSchema = z.object({ + accessKey: z.optional(z.custom()), + account: z.optional(z.custom()), +}) + +export type SubscriptionContext = z.infer + +export function subscription(parameters: subscription.Parameters = {}) { + const getClient = Client.getResolver({ + chain: tempo_chain, + getClient: parameters.getClient, + rpcUrl: defaults.rpcUrl, + }) + const getAccount = Account.getResolver({ account: parameters.account }) + + return Method.toClient(Methods.subscription, { + context: subscriptionContextSchema, + + async createCredential({ challenge, context }) { + const chainId = challenge.request.methodDetails?.chainId ?? defaults.chainId.mainnet + const client = await getClient({ chainId }) + const account = getAccount(client, context) + const accessKey = context?.accessKey ?? parameters.accessKey + if (!accessKey) { + throw new Error( + 'No `accessKey` provided. Pass `accessKey` to parameters or context so the client knows which server key to authorize.', + ) + } + + if (parameters.expectedRecipients) { + const recipient = (challenge.request.recipient as string).toLowerCase() + const allowed = parameters.expectedRecipients.map((address) => address.toLowerCase()) + if (!allowed.includes(recipient)) { + throw new Error(`Unexpected subscription recipient: ${challenge.request.recipient}`) + } + } + + const periodSeconds = Number(challenge.request.periodSeconds) + if (!Number.isSafeInteger(periodSeconds) || periodSeconds <= 0) { + throw new Error('Subscription `periodSeconds` must be a positive safe integer.') + } + + const keyAuthorization = await Actions.accessKey.signAuthorization(client, { + account, + accessKey, + chainId, + expiry: Math.floor(new Date(challenge.request.subscriptionExpires).getTime() / 1000), + limits: [ + { + token: challenge.request.currency as Address, + limit: BigInt(challenge.request.amount), + period: periodSeconds, + }, + ], + scopes: [ + { + selectorRules: [ + { + recipients: [challenge.request.recipient as Address], + selector: '0xa9059cbb', + }, + ...(parameters.allowMemo + ? [ + { + recipients: [challenge.request.recipient as Address], + selector: '0x95777d59', + }, + ] + : []), + ], + target: challenge.request.currency as Address, + }, + ], + } as never) + + return Credential.serialize({ + challenge, + payload: { + signature: KeyAuthorization.serialize(keyAuthorization as never), + type: 'keyAuthorization', + }, + source: `did:pkh:eip155:${chainId}:${account.address}`, + }) + }, + }) +} + +export declare namespace subscription { + type Parameters = Account.getResolver.Parameters & + Client.getResolver.Parameters & { + accessKey?: AccessKeyReference | undefined + allowMemo?: boolean | undefined + expectedRecipients?: readonly Address[] | undefined + } +} diff --git a/src/tempo/client/index.ts b/src/tempo/client/index.ts index 67f77821..efb18180 100644 --- a/src/tempo/client/index.ts +++ b/src/tempo/client/index.ts @@ -1,5 +1,6 @@ export { charge } from './Charge.js' export { tempo } from './Methods.js' export { session } from './Session.js' +export { subscription } from './Subscription.js' export type { PaymentResponse, SessionManager } from './SessionManager.js' export { sessionManager } from './SessionManager.js' diff --git a/src/tempo/index.ts b/src/tempo/index.ts index 928e9fe3..69a71dec 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -1,2 +1,3 @@ export * as Methods from './Methods.js' export * as Session from './session/index.js' +export * as Subscription from './subscription/index.js' diff --git a/src/tempo/server/Methods.ts b/src/tempo/server/Methods.ts index deeb7e06..bdf549ec 100644 --- a/src/tempo/server/Methods.ts +++ b/src/tempo/server/Methods.ts @@ -1,6 +1,7 @@ import * as Ws_ from '../session/Ws.js' import { charge as charge_ } from './Charge.js' import { session as session_, settle as settle_ } from './Session.js' +import { subscription as subscription_ } from './Subscription.js' /** * Creates both Tempo `charge` and `session` methods from shared parameters. @@ -28,6 +29,8 @@ export namespace tempo { export const charge = charge_ /** Creates a Tempo `session` method for session-based TIP-20 token payments. */ export const session = session_ + /** Creates a Tempo `subscription` method for recurring TIP-20 token payments. */ + export const subscription = subscription_ /** One-shot settle: reads highest voucher from storage and submits on-chain. */ export const settle = settle_ /** Experimental websocket helpers for Tempo sessions. */ diff --git a/src/tempo/server/Subscription.test.ts b/src/tempo/server/Subscription.test.ts new file mode 100644 index 00000000..399b9a94 --- /dev/null +++ b/src/tempo/server/Subscription.test.ts @@ -0,0 +1,174 @@ +import { Challenge, Credential } from 'mppx' +import { Mppx } from 'mppx/server' +import { describe, expect, test } from 'vp/test' + +import * as Store from '../../Store.js' +import { subscription } from './Subscription.js' + +const realm = 'api.example.com' +const secretKey = 'test-secret-key' +const activeBillingAnchor = new Date().toISOString() +const activeSubscriptionExpires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1_000).toISOString() + +function createReceipt(subscriptionId: string, reference = '0xreceipt') { + return { + method: 'tempo', + reference, + status: 'success', + subscriptionId, + timestamp: '2025-01-01T00:00:00.000Z', + } as const +} + +describe('tempo.subscription', () => { + test('stores an activated subscription and reuses it on later requests', async () => { + const store = Store.memory() + const method = subscription({ + activate: async ({ request, source }) => ({ + receipt: createReceipt('sub_123', '0xactivate'), + subscription: { + amount: request.amount, + billingAnchor: activeBillingAnchor, + chainId: request.methodDetails?.chainId, + currency: request.currency, + identityId: source?.address ?? 'anon', + lastChargedPeriod: 0, + periodSeconds: request.periodSeconds, + recipient: request.recipient, + reference: '0xactivate', + resourceId: 'resource:alpha', + subscriptionExpires: request.subscriptionExpires, + subscriptionId: 'sub_123', + timestamp: '2025-01-01T00:00:00.000Z', + }, + }), + amount: '10', + chainId: 4217, + currency: '0x20c0000000000000000000000000000000000001', + getIdentity: async ({ input }) => ({ id: input.headers.get('X-User') ?? 'anon' }), + getResource: async () => ({ id: 'resource:alpha' }), + periodSeconds: '3600', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + store, + subscriptionExpires: activeSubscriptionExpires, + }) + + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challengeResult = await mppx['tempo/subscription']({})( + new Request('https://example.com/resource', { headers: { 'X-User': 'user-1' } }), + ) + + expect(challengeResult.status).toBe(402) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + + const challenge = Challenge.fromResponse(challengeResult.challenge) + const credential = Credential.from({ + challenge, + payload: { signature: '0x1234', type: 'keyAuthorization' }, + source: 'did:pkh:eip155:4217:0x1234567890abcdef1234567890abcdef12345678', + }) + + const activated = await mppx['tempo/subscription']({})( + new Request('https://example.com/resource', { + headers: { + Authorization: Credential.serialize(credential), + 'X-User': '0x1234567890abcdef1234567890abcdef12345678', + }, + }), + ) + + expect(activated.status).toBe(200) + + const reused = await mppx['tempo/subscription']({})( + new Request('https://example.com/resource', { + headers: { + 'Subscription-Id': 'sub_123', + 'X-User': '0x1234567890abcdef1234567890abcdef12345678', + }, + }), + ) + + expect(reused.status).toBe(200) + if (reused.status !== 200) throw new Error('expected authorize reuse') + + const response = reused.withReceipt(new Response('OK')) + const receipt = response.headers.get('Payment-Receipt') + expect(receipt).toBeTruthy() + }) + + test('fails closed when multiple active subscriptions match without a hint', async () => { + const store = Store.memory() + const method = subscription({ + activate: async ({ request }) => ({ + receipt: createReceipt('unused'), + subscription: { + amount: request.amount, + billingAnchor: activeBillingAnchor, + chainId: request.methodDetails?.chainId, + currency: request.currency, + identityId: 'user-1', + lastChargedPeriod: 0, + periodSeconds: request.periodSeconds, + recipient: request.recipient, + reference: 'unused', + resourceId: 'resource:alpha', + subscriptionExpires: request.subscriptionExpires, + subscriptionId: 'unused', + timestamp: '2025-01-01T00:00:00.000Z', + }, + }), + amount: '10', + chainId: 4217, + currency: '0x20c0000000000000000000000000000000000001', + getIdentity: async () => ({ id: 'user-1' }), + getResource: async () => ({ id: 'resource:alpha' }), + periodSeconds: '3600', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + store, + subscriptionExpires: activeSubscriptionExpires, + }) + + await store.put('tempo:subscription:record:sub_a', { + amount: '10000000', + billingAnchor: activeBillingAnchor, + chainId: 4217, + currency: '0x20c0000000000000000000000000000000000001', + identityId: 'user-1', + lastChargedPeriod: 0, + periodSeconds: '3600', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + reference: '0xsuba', + resourceId: 'resource:alpha', + subscriptionExpires: activeSubscriptionExpires, + subscriptionId: 'sub_a', + timestamp: '2025-01-01T00:00:00.000Z', + }) + await store.put('tempo:subscription:record:sub_b', { + amount: '10000000', + billingAnchor: activeBillingAnchor, + chainId: 4217, + currency: '0x20c0000000000000000000000000000000000001', + identityId: 'user-1', + lastChargedPeriod: 0, + periodSeconds: '3600', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + reference: '0xsubb', + resourceId: 'resource:alpha', + subscriptionExpires: activeSubscriptionExpires, + subscriptionId: 'sub_b', + timestamp: '2025-01-01T00:00:00.000Z', + }) + await store.put('tempo:subscription:resource:user-1:resource:alpha', ['sub_a', 'sub_b']) + + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const result = await mppx['tempo/subscription']({})( + new Request('https://example.com/resource'), + ) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error('expected ambiguity challenge') + + const body = (await result.challenge.json()) as { detail: string } + expect(body.detail).toContain('Multiple active subscriptions match this request') + }) +}) diff --git a/src/tempo/server/Subscription.ts b/src/tempo/server/Subscription.ts new file mode 100644 index 00000000..03e3c26b --- /dev/null +++ b/src/tempo/server/Subscription.ts @@ -0,0 +1,246 @@ +import type { Address } from 'viem' + +import { PaymentRequiredError } from '../../Errors.js' +import type { LooseOmit, MaybePromise, NoExtraKeys } from '../../internal/types.js' +import * as Method from '../../Method.js' +import * as Store from '../../Store.js' +import * as Client from '../../viem/Client.js' +import * as Account from '../internal/account.js' +import * as defaults from '../internal/defaults.js' +import * as Proof from '../internal/proof.js' +import type * as types from '../internal/types.js' +import * as Methods from '../Methods.js' +import * as SubscriptionReceipt from '../subscription/Receipt.js' +import * as SubscriptionStore from '../subscription/Store.js' +import type { + SubscriptionCredentialPayload, + SubscriptionIdentity, + SubscriptionRecord, + SubscriptionReceipt as SubscriptionReceiptValue, + SubscriptionResource, +} from '../subscription/Types.js' + +export function subscription( + p: NoExtraKeys, +) { + const parameters = p as parameters + const { + amount, + currency = defaults.resolveCurrency(parameters), + decimals = defaults.decimals, + description, + externalId, + periodSeconds, + store: rawStore = Store.memory(), + subscriptionExpires, + } = parameters + + const store = SubscriptionStore.fromStore(rawStore) + const getClient = Client.getResolver({ + getClient: parameters.getClient, + rpcUrl: defaults.rpcUrl, + }) + const { recipient } = Account.resolve(parameters) + + type Defaults = subscription.DeriveDefaults + return Method.toServer(Methods.subscription, { + defaults: { + amount, + currency, + decimals, + description, + externalId, + periodSeconds, + recipient, + subscriptionExpires, + } as unknown as Defaults, + + async authorize({ input, request }) { + const identity = await parameters.getIdentity({ input, request }) + if (!identity) return undefined + + const resource = await parameters.getResource({ identity, input, request }) + const subscriptionIdHint = input.headers.get('Subscription-Id')?.trim() || undefined + const matches = (await store.listByIdentityResource(identity.id, resource.id)).filter((record) => + matchesRequest(record, request), + ) + const active = matches.filter((record) => isActive(record)) + + const subscription = (() => { + if (subscriptionIdHint) + return active.find((record) => record.subscriptionId === subscriptionIdHint) ?? null + if (active.length <= 1) return active[0] ?? null + throw new PaymentRequiredError({ + description: + 'Multiple active subscriptions match this request. Retry with the Subscription-Id header.', + }) + })() + + if (!subscription) return undefined + + const periodIndex = getPeriodIndex(subscription) + if (periodIndex > subscription.lastChargedPeriod) { + if (!parameters.renew) return undefined + const renewed = await parameters.renew({ + identity, + input, + periodIndex, + request, + resource, + subscription, + }) + await store.put(renewed.subscription) + return { + receipt: renewed.receipt, + response: renewed.response, + } + } + + return { + receipt: SubscriptionReceipt.fromRecord(subscription), + } + }, + + async request({ request }) { + const chainId = await (async () => { + if (request.chainId) return request.chainId + if (parameters.chainId) return parameters.chainId + if (parameters.testnet) return defaults.chainId.testnet + return (await getClient({})).chain?.id ?? defaults.chainId.mainnet + })() + + return { + ...request, + chainId, + } + }, + + stableBinding(request) { + return subscriptionBinding(request) + }, + + async verify({ credential, envelope, request }) { + const source = credential.source ? Proof.parseProofSource(credential.source) : null + const input = envelope + ? new Request(envelope.capturedRequest.url, { + headers: envelope.capturedRequest.headers, + method: envelope.capturedRequest.method, + }) + : new Request('https://subscription.invalid') + const activation = await parameters.activate({ + credential: credential as typeof credential & { + payload: SubscriptionCredentialPayload + }, + input, + request: Methods.subscription.schema.request.parse(request), + source, + }) + await store.put(activation.subscription) + return activation.receipt + }, + }) +} + +function getPeriodIndex(subscription: SubscriptionRecord): number { + const anchor = new Date(subscription.billingAnchor).getTime() + const expires = new Date(subscription.subscriptionExpires).getTime() + const now = Date.now() + if (!Number.isFinite(anchor) || !Number.isFinite(expires) || now >= expires) { + return Number.POSITIVE_INFINITY + } + + const periodSeconds = Number(subscription.periodSeconds) + if (!Number.isSafeInteger(periodSeconds) || periodSeconds <= 0) { + return Number.POSITIVE_INFINITY + } + + return Math.max(0, Math.floor((now - anchor) / (periodSeconds * 1_000))) +} + +function isActive(subscription: SubscriptionRecord): boolean { + if (subscription.canceledAt || subscription.revokedAt) return false + return new Date(subscription.subscriptionExpires).getTime() > Date.now() +} + +function matchesRequest( + subscription: SubscriptionRecord, + request: ReturnType, +): boolean { + const binding = subscriptionBinding(request) + return ( + String(subscription.amount) === String(binding.amount) && + String(subscription.currency).toLowerCase() === String(binding.currency).toLowerCase() && + String(subscription.recipient).toLowerCase() === String(binding.recipient).toLowerCase() && + String(subscription.periodSeconds) === String(binding.periodSeconds) && + String(subscription.subscriptionExpires) === String(binding.subscriptionExpires) && + String(subscription.chainId) === String(binding.chainId) + ) +} + +function subscriptionBinding(request: ReturnType) { + return { + amount: request.amount, + chainId: request.methodDetails?.chainId, + currency: request.currency, + periodSeconds: request.periodSeconds, + recipient: request.recipient, + subscriptionExpires: request.subscriptionExpires, + } +} + +export declare namespace subscription { + type ActivationResult = { + receipt: SubscriptionReceiptValue + response?: Response | undefined + subscription: SubscriptionRecord + } + + type RenewalResult = { + receipt: SubscriptionReceiptValue + response?: Response | undefined + subscription: SubscriptionRecord + } + + type Defaults = LooseOmit, 'recipient'> + + type Parameters = Account.resolve.Parameters & + Client.getResolver.Parameters & { + getIdentity: (parameters: { + input: Request + request: ReturnType + }) => MaybePromise + getResource: (parameters: { + identity: SubscriptionIdentity + input: Request + request: ReturnType + }) => MaybePromise + activate: (parameters: { + credential: { + payload: SubscriptionCredentialPayload + source?: string | undefined + } + input: Request + request: ReturnType + source: { address: Address; chainId: number } | null + }) => Promise + periodSeconds?: string | undefined + renew?: (parameters: { + identity: SubscriptionIdentity + input: Request + periodIndex: number + request: ReturnType + resource: SubscriptionResource + subscription: SubscriptionRecord + }) => Promise + store?: Store.Store> | undefined + testnet?: boolean | undefined + } & + Defaults + + type DeriveDefaults = types.DeriveDefaults< + parameters, + Defaults + > & { + decimals: number + } +} diff --git a/src/tempo/server/index.ts b/src/tempo/server/index.ts index 05d96e40..2e86bc2f 100644 --- a/src/tempo/server/index.ts +++ b/src/tempo/server/index.ts @@ -4,3 +4,4 @@ export * as Ws from '../session/Ws.js' export { charge } from './Charge.js' export { tempo } from './Methods.js' export { session, settle } from './Session.js' +export { subscription } from './Subscription.js' diff --git a/src/tempo/subscription/Receipt.ts b/src/tempo/subscription/Receipt.ts new file mode 100644 index 00000000..4a312631 --- /dev/null +++ b/src/tempo/subscription/Receipt.ts @@ -0,0 +1,25 @@ +import type { SubscriptionRecord, SubscriptionReceipt } from './Types.js' + +export function createSubscriptionReceipt( + parameters: createSubscriptionReceipt.Parameters, +): SubscriptionReceipt { + return { + method: 'tempo', + reference: parameters.reference, + status: 'success', + subscriptionId: parameters.subscriptionId, + timestamp: parameters.timestamp, + ...(parameters.externalId ? { externalId: parameters.externalId } : {}), + } +} + +export declare namespace createSubscriptionReceipt { + type Parameters = Pick< + SubscriptionRecord, + 'externalId' | 'reference' | 'subscriptionId' | 'timestamp' + > +} + +export function fromRecord(record: SubscriptionRecord): SubscriptionReceipt { + return createSubscriptionReceipt(record) +} diff --git a/src/tempo/subscription/Store.ts b/src/tempo/subscription/Store.ts new file mode 100644 index 00000000..bdb36aaa --- /dev/null +++ b/src/tempo/subscription/Store.ts @@ -0,0 +1,43 @@ +import * as Store from '../../Store.js' +import type { SubscriptionRecord } from './Types.js' + +const recordPrefix = 'tempo:subscription:record:' +const resourcePrefix = 'tempo:subscription:resource:' + +export type SubscriptionStore = { + get: (subscriptionId: string) => Promise + listByIdentityResource: (identityId: string, resourceId: string) => Promise + put: (record: SubscriptionRecord) => Promise +} + +export function fromStore(store: Store.Store>): SubscriptionStore { + return { + async get(subscriptionId) { + return (await store.get(recordKey(subscriptionId))) as SubscriptionRecord | null + }, + async listByIdentityResource(identityId, resourceId) { + const ids = ((await store.get(resourceKey(identityId, resourceId))) ?? []) as string[] + const records = await Promise.all( + ids.map(async (subscriptionId: string) => store.get(recordKey(subscriptionId))), + ) + return records.filter((record: unknown): record is SubscriptionRecord => Boolean(record)) + }, + async put(record) { + await store.put(recordKey(record.subscriptionId), record) + + const key = resourceKey(record.identityId, record.resourceId) + const existing = ((await store.get(key)) ?? []) as string[] + if (!existing.includes(record.subscriptionId)) { + await store.put(key, [...existing, record.subscriptionId]) + } + }, + } +} + +function recordKey(subscriptionId: string): string { + return `${recordPrefix}${subscriptionId}` +} + +function resourceKey(identityId: string, resourceId: string): string { + return `${resourcePrefix}${identityId}:${resourceId}` +} diff --git a/src/tempo/subscription/Types.ts b/src/tempo/subscription/Types.ts new file mode 100644 index 00000000..60bf1f97 --- /dev/null +++ b/src/tempo/subscription/Types.ts @@ -0,0 +1,42 @@ +import type { Address } from 'viem' + +export type SubscriptionIdentity = { + id: string +} + +export type SubscriptionResource = { + id: string +} + +export type SubscriptionRecord = { + amount: string + billingAnchor: string + chainId?: number | undefined + currency: Address | string + externalId?: string | undefined + identityId: string + lastChargedPeriod: number + periodSeconds: string + recipient: Address | string + reference: string + resourceId: string + subscriptionExpires: string + subscriptionId: string + timestamp: string + canceledAt?: string | undefined + revokedAt?: string | undefined +} + +export type SubscriptionCredentialPayload = { + signature: `0x${string}` + type: 'keyAuthorization' +} + +export type SubscriptionReceipt = { + method: 'tempo' + reference: string + status: 'success' + subscriptionId: string + timestamp: string + externalId?: string | undefined +} diff --git a/src/tempo/subscription/index.ts b/src/tempo/subscription/index.ts new file mode 100644 index 00000000..90b88774 --- /dev/null +++ b/src/tempo/subscription/index.ts @@ -0,0 +1,9 @@ +export { createSubscriptionReceipt, fromRecord } from './Receipt.js' +export { fromStore } from './Store.js' +export type { + SubscriptionCredentialPayload, + SubscriptionIdentity, + SubscriptionRecord, + SubscriptionReceipt, + SubscriptionResource, +} from './Types.js'