Skip to content

feat: add tempo subscriptions#331

Open
brendanjryan wants to merge 4 commits intomainfrom
brendanryan/authorize-hook-subscriptions
Open

feat: add tempo subscriptions#331
brendanjryan wants to merge 4 commits intomainfrom
brendanryan/authorize-hook-subscriptions

Conversation

@brendanjryan
Copy link
Copy Markdown
Collaborator

@brendanjryan brendanjryan commented Apr 10, 2026

Summary

Added Tempo subscription support with activation, renewal, reusable subscription authorization, adapter management-response handling, compose dispatch hardening, and subscription lifecycle race protection.

@brendanjryan brendanjryan force-pushed the brendanryan/authorize-hook-subscriptions branch from 992c30a to 313f314 Compare April 10, 2026 22:24
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 10, 2026

Open in StackBlitz

npm i https://pkg.pr.new/mppx@331

commit: 3ce842e

@brendanjryan brendanjryan force-pushed the brendanryan/authorize-hook-subscriptions branch 2 times, most recently from a09cb0e to d651f09 Compare April 20, 2026 22:42
Comment thread src/tempo/server/Subscription.ts Outdated
)
const active = matches.filter((record) => isActive(record))

const subscription = (() => {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it feels odd we have to have the client pass a subscription id in order to disambiguate, shouldn't the server be able to filter subscriptions to check if one is valid for the given route / user?

Comment thread src/tempo/subscription/Store.ts Outdated
import type { SubscriptionRecord } from './Types.js'

const recordPrefix = 'tempo:subscription:record:'
const resourcePrefix = 'tempo:subscription:resource:'
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allow these to be customized

return records.filter((record: unknown): record is SubscriptionRecord => Boolean(record))
},
async put(record) {
await store.put(recordKey(record.subscriptionId), record)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add comments about what we are doing here

Comment thread src/tempo/server/Subscription.ts Outdated
return 'minute'
case 3_600:
return 'hour'
case 86_400:
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make these constants

Comment thread src/tempo/server/Subscription.ts Outdated
}
}

function formatBillingInterval(periodSeconds: string) {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there no way to manually bill users e.g. in a cron? we should add this

@brendanjryan brendanjryan force-pushed the brendanryan/authorize-hook-subscriptions branch 2 times, most recently from 5d333a6 to e776f4e Compare April 21, 2026 02:56
@jithinraj
Copy link
Copy Markdown

Great addition, @brendanjryan.

The main thing I’d pin down early is how a later charge proves linkage to the original authorization window.

A minimal stable tuple for that would already help a lot: authorization ref, cumulative spent, remaining window, and a verifier-facing record ref.

@brendanjryan brendanjryan marked this pull request as ready for review April 26, 2026 19:52
@brendanjryan brendanjryan force-pushed the brendanryan/authorize-hook-subscriptions branch from 5929e02 to bb873c5 Compare April 26, 2026 20:40
@brendanjryan brendanjryan changed the title feat: add authorize hook and tempo subscriptions feat: tempo subscriptions Apr 26, 2026
@brendanjryan
Copy link
Copy Markdown
Collaborator Author

@jithinraj not sure I follow -- the auth lives on the subscription itself. what is the verifier in this context?

@jithinraj
Copy link
Copy Markdown

Got it, thanks @brendanjryan.

By “verifier-facing” I meant downstream audit / reconciliation, not a separate verifier path inside this PR.

My earlier framing was broader than the current scope. The useful boundary seems simpler: subscription reuse is determined by the server’s lookup key plus stableBinding().

Might be worth a short note around that boundary, mainly to distinguish when subscriptionId matters from when reuse is handled by the normal request path.

@brendanjryan brendanjryan force-pushed the brendanryan/authorize-hook-subscriptions branch from eb9dc93 to 75552f0 Compare May 6, 2026 03:23
@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 6, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updated@​typescript/​native-preview@​7.0.0-dev.20260503.1 ⏵ 7.0.0-dev.20260419.110010072 -9100100
Updatedwagmi@​3.6.5 ⏵ 3.6.9801007998 +1100
Addedvite@​8.0.8991008298100
Updated@​tanstack/​react-query@​5.100.5 ⏵ 5.100.99910088100 +1100

View full report

@brendanjryan brendanjryan force-pushed the brendanryan/authorize-hook-subscriptions branch 4 times, most recently from bb4deca to 7fea5da Compare May 7, 2026 02:27
period: toSubscriptionPeriodSeconds(request),
},
],
},
Copy link
Copy Markdown
Member

@jxom jxom May 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We require consumers to also specify scopes post-T3 (only relevant here, not for local).

Comment thread examples/subscription/src/server.ts Outdated
Comment on lines +72 to +101
activate: async ({ accessKey, request, resolved }) => {
const userId = resolved.key.split(':')[1] ?? 'anonymous'
const id = subscriptionId(userId)
const reference = chargeWithAccessKey({
accessKey,
amount: request.amount,
periodIndex: 0,
subscriptionId: id,
})
const record = {
amount: request.amount,
billingAnchor: new Date().toISOString(),
chainId: request.methodDetails?.chainId,
currency: request.currency,
lastChargedPeriod: 0,
lookupKey: resolved.key,
periodCount: request.periodCount,
periodUnit: request.periodUnit,
recipient: request.recipient,
reference,
subscriptionExpires: request.subscriptionExpires,
subscriptionId: id,
timestamp: new Date().toISOString(),
} satisfies Subscription.SubscriptionRecord

return {
receipt: Subscription.createSubscriptionReceipt(record),
subscription: record,
}
},
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought most of this could be handled internally

@brendanjryan brendanjryan force-pushed the brendanryan/authorize-hook-subscriptions branch from 7fea5da to f451b0f Compare May 7, 2026 18:01
@brendanjryan brendanjryan force-pushed the brendanryan/authorize-hook-subscriptions branch from f451b0f to ae2fd65 Compare May 7, 2026 23:59
@brendanjryan brendanjryan changed the title feat: tempo subscriptions feat: add tempo subscriptions May 8, 2026
Copy link
Copy Markdown

@tempoxyz-cyclops-bot tempoxyz-cyclops-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👁️ Cyclops Review

PR #331 adds Tempo subscription support (recurring TIP-20 payments via signed key authorizations), an authorize hook + stableBinding plumbing on Mppx, sequential compose() evaluation, an optional Receipt.subscriptionId, and a background renewSubscription helper. The implementation is well tested for single-tier happy paths, but workers verified several reachable issues in the new lifecycle. Most severe: the built-in automatic renewal path replays the activation KeyAuthorization on every renewal and is rejected by Tempo as KeyAlreadyExists, breaking recurring collection on a real node after period 0. Two distinct economic-binding gaps (cross-tier and overdue-period reuse) converge on the same root cause: authorize() and verify()/store.activate() reuse paths only check isActive(record) without comparing route binding fields or lastChargedPeriod. Recommend not merging until the inline findings tagged Critical/High are addressed.

Reviewer Callouts
  • Manual activate hook contract (src/tempo/server/Subscription.ts:333-417): Custom parameters.activate is passed the credential's accessKey and resolved.key with no built-in consistency check. Automatic mode safely fails closed via submitSubscriptionPayment's stored-vs-supplied address comparison (:732-737); manual mode does not. Document that custom activate implementations MUST verify accessKey matches the application's expected access key for resolved.key.
  • mppx.verifyCredential(credential) is side-effectful for subscription (src/server/Mppx.ts:368subscription.verifystore.activate): The standalone "verify" call consumes credentialKey, may submit an on-chain transfer, and persists a record. Most users will assume verifyCredential is read-only. Document or split into check/settle phases.
  • success() helper + SSE composition (src/server/Mppx.ts:502, src/tempo/server/internal/transport.ts:81-87): success() constructs a fake credential { challenge, payload: {} }. Currently safe because the only method shipping authorize is subscription (HTTP). A future authorize + SSE-style respondReceipt composition would throw No SSE context available. Either harden success() or document the constraint.
  • paymentOf spreads function values into proxy payment object (src/proxy/Service.ts:209-220): _stableBinding, authorize, verify, request, respond, transport end up on the returned payment object. Dropped by JSON.stringify in OpenAPI serialization, but a non-JSON serializer could leak closures over secretKey / store-private-keys. Explicitly destructure known function fields out of rest.
  • renewSubscription background helper ignores activationTimeoutMs (src/tempo/server/Subscription.ts:803): The background helper builds its own default SubscriptionStore wrapper, so cron always uses the default 15-minute timeout regardless of how the request-path subscription() was configured. Plumb timeouts into the background entrypoint.
  • compose() ordering is now load-bearing (src/server/Mppx.ts:1287-1294): No-credential branch evaluates authorize hooks sequentially; first 200 wins. Document that side effects of earlier handlers may run even when a later handler would have served the request.
  • Receipt.subscriptionId is now part of the public Payment-Receipt schema (src/Receipt.ts:23): Spec-additive, but .strict() downstream validators will reject. Worth calling out as forward-compatible.
  • assertAuthorizationScopes only requires transferSelector (src/tempo/subscription/KeyAuthorization.ts:324-359): Clients must sign both transfer and transferWithMemo; the server submits transferWithMemo, so a single-selector authorization passes verify but reverts at simulation. Self-DoS, but worth documenting.
  • request() hook can throw VerificationFailedError before HMAC is checked (src/tempo/server/Subscription.ts:163-166, called from Mppx.ts:465): A malformed credential whose challenge.request.methodDetails.accessKey is missing makes the request hook throw before Tier-1 HMAC runs. Express v4 hangs; v5/Hono/Next.js/Elysia return 500. Wrap resolveRouteChallenge in a PaymentError-aware try/catch in createMethodFn to normalize to a 402.

Copy link
Copy Markdown

@tempoxyz-cyclops-bot tempoxyz-cyclops-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👁️ Cyclops Review — wevm/mppx PR #331 (Tempo Subscriptions Hardening)

Summary

PR #331 introduces the Tempo subscription payment method (key-authorization–based recurring billing), the public authorize/stableBinding server-method hooks, dynamic SDK-managed access keys, activation replay protection, and renewal idempotency. The defensive design is broadly correct, but the activation/renewal state machines still contain multiple high-severity races and a broken verifyCredential path that re-introduce double-charge / orphaned-state outcomes after the previous "fix: address cyclops subscription findings" commit. There is also a DoS in the new compose dispatcher for any custom stableBinding. None of these break protocol-level signature/HMAC bindings, but they do break the documented billing semantics under realistic concurrency and externally reachable conditions.

Inline comments below mark each finding at the relevant changed line.

Reviewer Callouts
  • subscriptionBinding excludes chainId → compose dispatch always misses subscription candidates. src/tempo/server/Subscription.ts:83-94 does not put chainId in defaults, so _canonicalRequest.methodDetails is undefined while issued challenges always carry methodDetails.chainId. In Mppx.compose() (src/server/Mppx.ts:1316-1322) the candidate set ends up empty and dispatch silently falls through to "first handler with matching name+intent". Either include methodDetails.chainId in subscription defaults or omit chainId from subscriptionBinding.

  • Unauthenticated state mutation in the no-credential path. tempo.subscription.request() calls parameters.resolve(...) before any credential is presented and, in auto mode, unconditionally calls store.getOrCreateAccessKey(resolved.key) (src/tempo/server/Subscription.ts:153-165, src/tempo/subscription/Store.ts:207-224), persisting a fresh secp256k1 private key per unique resolve output. Following the obvious test pattern (header → resolve → key) gives a developer an unauthenticated unbounded-storage primitive. Document that resolve must authenticate, or move key generation to the credential-bearing path.

  • accessKey is intentionally excluded from subscriptionBinding. Reviewers should confirm no future schema change introduces a route where the canonical accessKey legitimately differs from the stored subscription's; silently bypassing the equality check would defeat per-credential authorization scoping.

  • Fragile withReceipt() error-string contract across 5 callsites. 'withReceipt() requires a response argument' (src/server/Mppx.ts:547) is sentinel-matched by src/middlewares/{elysia,hono,nextjs,express}.ts and src/proxy/Proxy.ts. Export a typed sentinel error and instanceof-check in adapters.

  • Mppx.toNodeListener() does not probe withReceipt() for management responses. Unlike Hono/Elysia/Next/Express, src/server/Mppx.ts:1494-1501 always calls result.withReceipt(new Response()) and only forwards the Payment-Receipt header.

  • credentialKey activation markers never expire (src/tempo/subscription/Store.ts:130-139). Every activation attempt — including failed ones — leaves a permanent entry. Consider a TTL keyed off the challenge expires.

  • Latest-period-only renewal is intentional (src/tempo/server/Subscription.ts:841-864 + integration test renews only the latest elapsed week period when multiple periods passed). After multiple missed periods, renew() charges once for the current period and advances lastChargedPeriod directly to it. A user who drains their wallet for several periods and tops up later effectively pauses billing. Make a deliberate product decision and document it.

  • Background renew() hooks can mutate stored amount/recipient/currency without detection. assertSubscriptionRequestMatch only runs when request is threaded into validateSubscriptionSettlement; background renewals do not. Worth a JSDoc note on renew() that handlers must preserve those fields.

  • Discovery (x-payment-info) leaks every field of _canonicalRequest for subscription routes including subscriptionExpires, periodCount, periodUnit, description, externalId (src/discovery/OpenApi.ts:204-208). Warn on subscription.Parameters.externalId that the value is publicly advertised.

)
if (started.status !== 'started') return { status: 'inFlight' }

const result = await create().catch(async (error) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [SECURITY] Activation TOCTOU permits two settlements for the same lookupKey

SubscriptionStore.activate() reads the active lookup pointer (getByLookupKey at line 141) before atomically claiming activationKey(lookupKey) (lines 146-163), and never re-reads the lookup after the marker is acquired. A second activation with a distinct challenge id can complete the stale lookup read while a first activation is still inside create(), wait until the first commits and deletes the marker (lines 185-190), then acquire an empty marker and run its own create() — submitting a second first-period Tempo transfer for the same lookupKey. Both calls return activated; the later one overwrites lookupRecordKey and orphans the first record.

Recommended Fix: After atomically acquiring activationKey(lookupKey), re-execute getByLookupKey(lookupKey) and short-circuit to existing when isReusable(existing) is true (releasing the marker if it is owned by this challengeId). Or model claim + active-lookup ownership as a single atomic transition.

return {
op: 'noop',
result: { status: 'inFlight' as const, subscription },
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [SECURITY] Renewal lock bypass across period boundaries causes orphaned claimMismatch charges

The in-flight guard only blocks when existing.inFlightPeriod === periodIndex. If a renewal is in flight at the end of period N and a second caller runs after getPeriodIndex() advances to N+1, the same-period check is false, the second caller overwrites the in-flight marker with inFlightPeriod=N+1, and a second on-chain payment is submitted. The first renewal's commit (lines 285-309) then sees existing.inFlightPeriod !== N, returns claimMismatch, and settleRenewal() throws VerificationFailedError (Subscription.ts:466-468) — but the first payment has already settled on-chain. Net result: user paid twice; server records only the second period.

Recommended Fix: Block any non-stale in-flight renewal regardless of period:

if (
  subscription.inFlightPeriod !== undefined &&
  !isStaleRenewal(subscription, renewalTimeoutMs)
) {
  return { op: 'noop', result: { status: 'inFlight' as const, subscription } }
}

And/or use the per-period inFlightReference as an on-chain idempotency key.

store,
subscription,
})
if (!renewal) return undefined
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [SECURITY] In-flight renewal returns 402, enabling double-charge + orphaned active subscription

When store.renew() returns { status: 'inFlight' }, settleRenewal() (lines 462-469) does not match inFlight and falls through to return null; this authorize hook converts null to undefined; Mppx then issues a fresh 402 challenge (src/server/Mppx.ts:560-595). The auto-paying client wrapper (src/client/internal/Fetch.ts:68-98) fulfills it, store.activate() sees the old record as overdue/non-reusable and submits a brand-new on-chain payment. When the original in-flight renewal later commits, Store.renew() unconditionally writes lookupRecordKey(...) = oldSubscriptionId (src/tempo/subscription/Store.ts:285-309), overwriting the freshly-activated subscription pointer and orphaning the second paid record.

Recommended Fix: Treat inFlight in settleRenewal() as a non-payment outcome — return a managed response (e.g. 409 Conflict / 503 Retry-After) rather than undefined. Throwing PaymentError is not enough; Mppx catches it and still emits a 402. Additionally, make the renewal commit at Store.ts:308 conditional on lookupRecordKey(...) still pointing at this subscription id (CAS) so a stale renewal cannot reclaim ownership of a newer activation.

Comment thread src/tempo/server/Subscription.ts Outdated

async verify({ credential, envelope, request }) {
const input = requestFromCaptured(envelope?.capturedRequest)
const parsedRequest = Methods.subscription.schema.request.parse(request)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [SECURITY] Mppx.verifyCredential(subscriptionCredential) always throws ZodError("decimals")

subscription.verify() calls Methods.subscription.schema.request.parse(request) here. The subscription request schema requires decimals: z.number() as input, and its transform consumes and drops decimals from the canonical output (src/tempo/Methods.ts:284-326). Challenges are issued in canonical form — no decimals. The documented public API Mppx.verifyCredential(credential) (src/server/Mppx.ts:285-374), which JSDoc says may "activate or renew a subscription", forwards credential.challenge.request directly when called with no route options. The parse(...) then throws before store.activate(...) runs, silently breaking subscription activation/renewal for MCP back-ends, JSON-RPC bridges, queue workers, and any out-of-band verifier.

Recommended Fix: Mirror Charge.verify/Session.verify (src/tempo/server/Charge.ts:159-165, src/tempo/server/Session.ts:191-196):

const parsed = Methods.subscription.schema.request.safeParse(request)
const parsedRequest = parsed.success
  ? parsed.data
  : (request as z.output<typeof Methods.subscription.schema.request>)

Add a regression test that calls mppx.verifyCredential(subscriptionCredential) end-to-end.

store,
subscription: record,
})
return renewal?.status === 'renewed' ? renewal.result : null
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [SECURITY] Background renewSubscription() charges superseded records and rolls back the active lookup pointer

renew() (the exported background helper, src/tempo/server/Methods.ts:34-35) loads the record by raw subscriptionId and never checks store.getByKey(record.lookupKey)?.subscriptionId === record.subscriptionId. After a user activates a replacement subscription B for the same lookupKey, the old record A is left intact (src/tempo/subscription/Store.ts:226-228 only updates the lookup pointer; A is never marked superseded). A scheduled renew(A.subscriptionId) then bills the user via the renewal hook for the superseded plan, and Store.renew() unconditionally writes lookupRecordKey(lookupKey) = A (Store.ts:308), overwriting B and orphaning the newer paid subscription.

Recommended Fix: Make active-lookup ownership a renewal precondition: in renew() (and inside Store.renew() before commit), assert store.getByKey(record.lookupKey)?.subscriptionId === record.subscriptionId and short-circuit to a superseded status otherwise. When a new activation replaces an active record for the same lookupKey, mark the old record canceledAt/revokedAt so id-based renewal APIs fail closed.

Comment thread src/server/Mppx.ts Outdated
? getRequestBindingMismatch(
getStableBinding(internal._canonicalRequest, internal._stableBinding),
getStableBinding(credReq, internal._stableBinding),
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [ISSUE] Forged credentials reach custom stableBinding before HMAC/schema validation (DoS)

Method.StableBindingFn is publicly typed as (request: z.output<schema.request>) => Record<string, unknown> (src/Method.ts:195-197), telling method authors the input is parsed/canonical. But here in compose() candidate filtering, getStableBinding(credReq, internal._stableBinding) invokes the hook on the untrusted credential.challenge.request before any HMAC, expiry, or method-schema validation — the generic Credential.deserialize only enforces that the challenge request is a record. A reasonable custom binding such as request.currency.toLowerCase() will throw on a forged credential missing required fields, escaping the dispatcher and producing a 500 instead of a normal 402 challenge. The bundled subscriptionBinding is defensive, so this is primarily a footgun for downstream method authors, but the public type contract is misleading.

Recommended Fix: (a) wrap the candidate-filter stableBinding calls in try/catch and treat throwers as non-candidates, (b) safeParse credReq against the method schema before calling the hook, or (c) document and enforce that custom stableBinding must be defensive against missing fields. Pick at least one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants