Skip to content

feat: add tempo subscriptions#331

Merged
brendanjryan merged 8 commits into
mainfrom
brendanryan/authorize-hook-subscriptions
May 12, 2026
Merged

feat: add tempo subscriptions#331
brendanjryan merged 8 commits into
mainfrom
brendanryan/authorize-hook-subscriptions

Conversation

@brendanjryan

@brendanjryan brendanjryan commented Apr 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Added Tempo subscription support with activation, renewal, reusable subscription authorization, adapter management-response handling, compose dispatch hardening, subscription lifecycle race protection, ergonomic Date/period inputs, attribution metadata cleanup, and typed receipt-response sentinel handling.

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

pkg-pr-new Bot commented Apr 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 8272e50

@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

socket-security Bot commented May 6, 2026

Copy link
Copy Markdown

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

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedvite@​8.0.12991008298100

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),
},
],
},

@jxom jxom May 7, 2026

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.

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 2 times, most recently 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

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

Copy link
Copy Markdown

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.

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

Copy link
Copy Markdown

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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👁️ Cyclops Review

PR #331 introduces Tempo subscription support. Challenge HMAC binding, key-authorization scope/limit/expiry/source checks, per-tx period limits, credential-key replay protection, and the new stableBinding Tier 2 checks are sound. The serious issues are concentrated in the new SubscriptionStore lifecycle: stale-recovery for both activation and renewal does not carry an ownership token through the final commit phase. Three High-severity findings and several Medium/Low items are filed inline below.

Additional finding (file not in diff)

⚠️ [ISSUE] toNodeListener discards subscription authorize management responses (src/server/Mppx.ts:1492-1507).
tempo.subscription's authorize hook can return a 409 Retry-After management response when a renewal is concurrently in-flight (src/tempo/server/Subscription.ts:125-133). The Hono / Next / Elysia / Express adapters detect a management response via getManagementResponse() and forward it; toNodeListener does not, copying only the Payment-Receipt header from the wrapped response. Concurrent renewal callers on a Node listener therefore receive 200 OK + paid content instead of the documented backoff signal, defeating the in-flight protection.
Recommended Fix: Mirror the management-response detection used in src/middlewares/express.ts (call result.withReceipt() with no argument inside a try/catch sentinel) inside toNodeListener and forward the management response via NodeListener.sendResponse.

Reviewer Callouts
  • Activation/renewal lock semantics: please double-check the stale-recovery semantics are intentional. Depending on the timing branch, the current behaviour either silently double-commits or silently drops state — a human design pass on lock ownership tokens would help.
  • paymentOf strip list (src/proxy/Service.ts:213-226) strips _stableBinding but not the same-named function stableBinding arriving via ...method. Today the discovery doc is JSON-stringified so it is silently dropped; future non-JSON consumers would surface a callable.
  • compose() candidate filter silently demotes a throwing stableBinding (src/server/Mppx.ts:1312-1327): the try/catch falls back to first-name+intent dispatch. Per-handler Tier 2 still catches mismatches today; if Tier 2 is ever weakened, this fails open.
  • success() synthetic credentialForReceipt = { challenge, payload: {} }: for any future MCP-on-authorize path, the receipt will carry a brand-new challengeId the client never saw and cannot correlate.
  • memoServerId inconsistency: activation uses realm (Subscription.ts:402), auto-renewal uses subscription.lookupKey (Subscription.ts:723). Downstream attribution tooling that filters by serverId will not find auto-renewal transfers.
  • verifyCredential has settlement side effects for subscriptions: calling mppx.verifyCredential(token) from a webhook will trigger activation (on-chain transferWithMemo + KV writes). Surface in migration notes; currently documented in JSDoc only.

const { subscription } = result
const committed = await store.update(activationKey(subscription.lookupKey), (current) => {
const marker = current as ActivationMarker | null
if (marker?.challengeId !== challengeId) return { op: 'noop', result: false }

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] Stale activation can cancel/overwrite a newer activation after both billing hooks run

This commit-ownership check passes once and then writes { ...marker, committingAt: timestamp() } while leaving the original startedAt intact. Because isStaleActivation() reads only startedAt, a second activation can immediately treat the committing marker as stale and replace it. The first activation, holding committed = true, then runs lines 196–205: it cancels the current lookup owner (now the second, paid activation), writes its own subscription record, and resets the lookup pointer back to itself — without re-checking ownership. Activation billing happens inside create() before this commit, so both attempts may have already executed irreversible payment side effects.

Recommended Fix:
Make activation commit ownership durable. When entering commit, refresh the marker's staleness timestamp (or have isStaleActivation() treat committingAt as non-stale), AND add a final compare-and-set immediately before each store.put(recordKey…) and store.put(lookupRecordKey…) write that bails out if the marker no longer contains this challengeId.

Comment thread src/tempo/subscription/Store.ts Outdated
result: true,
}
})
if (!committed) return { status: 'claimMismatch' }

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] Successful activation payments are silently dropped when the activation marker is replaced

When create() (which submits the on-chain transferWithMemo and, with the default waitForConfirmation: true, awaits the receipt) takes longer than activationTimeoutMs (default 15 minutes), the client retries with a new challenge. The stale marker is replaced (lines 159–171), and when the original payment finally confirms, this branch returns { status: 'claimMismatch' } and the subscription record is never persisted. The on-chain KeyAuthorization per-period limit has been consumed, so the retry's transaction reverts on chain — the user is charged with no subscription record and cannot retry until the next period.

Recommended Fix:
Reconcile a confirmed-but-superseded activation rather than dropping it (e.g. recover by Tempo memo Attribution.encode({ challengeId, serverId }), or persist the late-confirming subscription under a quarantine record key for the integrator to resolve). A naive "always save" patch is unsafe — it must be combined with the ownership fix in the previous comment so the late writer cannot overwrite the live activation.

Comment thread src/tempo/subscription/Store.ts Outdated

const committed = await store.update(recordKey(subscriptionId), (current) => {
const existing = current as SubscriptionRecord | null
if (!existing || existing.inFlightPeriod !== periodIndex) {

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] Stale renewal attempts can commit over (or clear) a newer renewal for the same period

The renewal commit checks only existing.inFlightPeriod === periodIndex. Two attempts for the same billing period necessarily share periodIndex, and clearRenewalState() at line 119 has the same flaw. Therefore a stale attempt A whose hook eventually returns can:

  1. Pass this check after a newer attempt B has acquired the in-flight marker, committing A's result over B and clearing B's in-flight state.
  2. Throw after B has acquired the marker, causing A's catch path (clearRenewalState(subscriptionId, periodIndex) at line 300) to clear B's marker.

The default server path uses a deterministic inFlightReference = "renewal:<subscriptionId>:<periodIndex>" (src/tempo/server/Subscription.ts:486-489), so checking inFlightReference alone would not distinguish same-period retries either.

Recommended Fix:
Use a per-attempt ownership token on both clearRenewalState() and this commit. Compare the exact inFlightStartedAt written by this caller, or add a separate per-attempt nonce distinct from the deterministic inFlightReference. A stale attempt must not clear or commit if the stored attempt token has changed.

const existing = resolved ? await store.getByKey(resolved.key) : null
const accessKey =
resolved && !credential
? await resolveChallengeAccessKey({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛡️ [DEFENSE-IN-DEPTH] Eager access-key persistence on every authenticated GET enables KV exhaustion if resolve() is lax

Every authenticated request to a subscription-protected route calls store.getOrCreateAccessKey(resolved.key), which persists a server-owned secp256k1 keypair before any payment occurs. If parameters.resolve is lax (e.g. derives the key from a query parameter without authenticating the caller), an unauthenticated attacker can pollute KV with access-key records for arbitrary keys.

Recommended Fix:
Document loudly that resolve() must perform identity / authorization before returning a key. Additionally, add a store.get precheck inside getOrCreateAccessKey so the random secp256k1 keypair is only generated when the record is actually missing (currently Secp256k1.randomPrivateKey() runs on every request).

})
if (!renew) return null

const renewal = await settleRenewal({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 [SUGGESTION] Background renew() skips request-match assertion

validateSubscriptionSettlement only runs assertSubscriptionRequestMatch when options.request is supplied. The cron-style background renew() does not pass request, so a misbehaving custom renew hook that mutates amount/currency/recipient in the returned subscription is committed without challenge — the persisted record drifts away from on-chain reality.

Recommended Fix:
Always assert the renewed record matches the prior record's economic-binding fields, even outside the request path.

@brendanjryan brendanjryan force-pushed the brendanryan/authorize-hook-subscriptions branch 2 times, most recently from 20489a8 to 22d2558 Compare May 12, 2026 16:16
@brendanjryan brendanjryan force-pushed the brendanryan/authorize-hook-subscriptions branch 7 times, most recently from 777b93f to b01c9be Compare May 12, 2026 20:32
@brendanjryan brendanjryan changed the base branch from main to brendanjryan/ci-shard-runtime-tests May 12, 2026 20:33
Base automatically changed from brendanjryan/ci-shard-runtime-tests to main May 12, 2026 20:34
An error occurred while trying to automatically change base from brendanjryan/ci-shard-runtime-tests to main May 12, 2026 20:34
@brendanjryan brendanjryan force-pushed the brendanryan/authorize-hook-subscriptions branch from b01c9be to e0ec9b9 Compare May 12, 2026 20:35
@brendanjryan brendanjryan changed the base branch from main to brendanjryan-ci-shard-runtime-tests May 12, 2026 20:37
@brendanjryan brendanjryan force-pushed the brendanryan/authorize-hook-subscriptions branch from e0ec9b9 to 8272e50 Compare May 12, 2026 20:37
@brendanjryan brendanjryan changed the base branch from brendanjryan-ci-shard-runtime-tests to main May 12, 2026 20:37
@brendanjryan brendanjryan merged commit 868c2bf into main May 12, 2026
12 checks passed
@brendanjryan brendanjryan deleted the brendanryan/authorize-hook-subscriptions branch May 12, 2026 20:45
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