Skip to content

fix(billing): mint opaque pmth_ signer session per ProviderInstance#403

Merged
seanhanca merged 1 commit into
mainfrom
fix/pymthouse-per-instance-signer-mint
Jun 24, 2026
Merged

fix(billing): mint opaque pmth_ signer session per ProviderInstance#403
seanhanca merged 1 commit into
mainfrom
fix/pymthouse-per-instance-signer-mint

Conversation

@seanhanca

@seanhanca seanhanca commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Surfaced while running the multi-app billing E2E (P0–P5) on an isolated preview: the key-validation front door (POST /api/v1/keys/validate) returned HTTP 503 for both legacy seat-bound and subscription-bound naap_ keys because the signer-session mint failed.

Root cause: PymthouseAdapter.mintSignerSession called the SDK 0.4.3 PmtHouseClient.mintSignerSessionForExternalUser, which sets resource to the OIDC issuer. Current PymtHouse routes a resource-bearing token-exchange to signer-JWT exchange (returns another JWT, no opaque pmth_… session), so the front door could not produce a usable signer session.

This routes the mint through the existing opaque-session workaround (upsert user → mint user JWT → token-exchange without resource) and makes it multi-app safe: a per-ProviderInstance adapter exchanges against that app's issuer/M2M creds via a new signerExchange option, while the global-env adapter keeps using the PYMTHOUSE_* env exchange.

Changes

  • pymthouse-client.ts — extract exchangeUserJwtForOpaqueSignerSessionWith(userJwt, cfg) (explicit per-app config) and export mintOpaqueSignerSessionForExternalUser({ client, exchange, … }). mintSignerSessionForExternalUser now delegates to it with the global env client + config (behavior preserved).
  • pymthouse-adapter.ts — add signerExchange?: PymthouseSignerExchangeConfig option; mintSignerSession mints via the opaque workaround (per-instance override when a client + signerExchange are present, else global env).
  • provider-instance.ts — pass the instance's issuerUrl + M2M client id/secret as signerExchange when building a per-instance adapter.

Safety / regression

  • Additive & backward-compatible: the global path delegates to the same opaque exchange it already used; the new per-instance path only engages when an adapter is constructed with both a client override and signerExchange (i.e. a configured ProviderInstance).
  • No new env vars, no schema changes, no secrets. Gated behind the existing pymthouse_bpp_validate / native-key flags via the call sites.

Test plan

  • On preview, POST /api/v1/keys/validate now returns HTTP 200 with valid: true, capabilities: ["*"], and an opaque pmth_… signerSession for both legacy seat-bound and subscription-bound keys (previously 503).
  • UI-minted subscription key (dev-manager P3 "Create Key") validates 200 with a pmth_… session.
  • CI: typecheck + unit tests (pymthouse-adapter / billing).

Made with Cursor

Summary by CodeRabbit

  • New Features

    • Billing sign-in now supports per-instance configuration for token exchange, improving compatibility across multiple app instances.
    • Opaque signer sessions can now be created with an explicit exchange setup instead of relying on shared global settings.
  • Bug Fixes

    • Improved session minting behavior for billing providers when instance-specific credentials are used.
    • Added fallback handling so existing signer-session creation continues to work with the default configuration.

PymthouseAdapter.mintSignerSession called the SDK 0.4.3
PmtHouseClient.mintSignerSessionForExternalUser, which sets `resource` to
the OIDC issuer. Current PymtHouse routes that to signer-JWT exchange
(returns another JWT, no opaque `pmth_…` session), so the key-validation
front door failed to produce a signer session (HTTP 503) for both legacy
seat-bound and subscription-bound keys.

Route the mint through the existing opaque-session workaround
(upsert user → mint user JWT → token-exchange WITHOUT `resource`), and
make it multi-app safe: a per-ProviderInstance adapter exchanges against
THAT app's issuer/M2M creds via a new `signerExchange` option, while the
global-env adapter continues to use the `PYMTHOUSE_*` env exchange.

Additive and backward-compatible: the global path delegates to the same
opaque exchange it already used; the per-instance path only engages when
an adapter is built with both a client override and signerExchange.

- pymthouse-client.ts: extract `exchangeUserJwtForOpaqueSignerSessionWith`
  (explicit config) + export `mintOpaqueSignerSessionForExternalUser`;
  `mintSignerSessionForExternalUser` now delegates with the global env.
- pymthouse-adapter.ts: add `signerExchange` option; mint via the opaque
  workaround (per-instance override or global env).
- provider-instance.ts: pass the instance's issuer + M2M creds as
  signerExchange when building a per-instance adapter.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
naap-platform Ready Ready Preview Jun 24, 2026 10:04pm

Request Review

@github-actions github-actions Bot added size/M Medium PR (51-200 lines) scope/shell Shell app changes labels Jun 24, 2026
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds explicit per-instance signer-exchange configuration for pymthouse sessions. It introduces a signer-exchange config type and explicit exchange helper, updates signer-session minting to accept explicit client/config inputs, and passes per-instance signer-exchange settings into the billing adapter.

Changes

Pymthouse signer exchange

Layer / File(s) Summary
Explicit exchange config and mint helpers
apps/web-next/src/lib/pymthouse-client.ts
Defines PymthouseSignerExchangeConfig, resolves a global config from env, adds an explicit token-exchange helper, and introduces mintOpaqueSignerSessionForExternalUser plus the wrapper used by mintSignerSessionForExternalUser.
Adapter signer-exchange path
apps/web-next/src/lib/billing/pymthouse-adapter.ts
Imports the new client helpers, stores optional signerExchange on the adapter, and changes mintSignerSession to use the opaque-session helper when both a client override and signer-exchange config are present.
Provider-instance adapter wiring
apps/web-next/src/lib/billing/provider-instance.ts
Passes per-instance issuerUrl, m2mClientId, m2mClientSecret, and optional allowInsecureHttp into the pymthouse adapter’s signerExchange option.

Sequence Diagram(s)

sequenceDiagram
  participant buildAdapterForProviderInstance
  participant PymthouseAdapter
  participant mintOpaqueSignerSessionForExternalUser
  participant PmtHouseClient
  participant exchangeUserJwtForOpaqueSignerSessionWith
  participant AuthorizationServer

  buildAdapterForProviderInstance->>PymthouseAdapter: new PymthouseAdapter({ client, signerExchange })
  PymthouseAdapter->>mintOpaqueSignerSessionForExternalUser: mintSignerSession(...)
  mintOpaqueSignerSessionForExternalUser->>PmtHouseClient: upsertAppUser() and mintUserAccessToken()
  mintOpaqueSignerSessionForExternalUser->>exchangeUserJwtForOpaqueSignerSessionWith: exchange(accessToken, signerExchange)
  exchangeUserJwtForOpaqueSignerSessionWith->>AuthorizationServer: load issuerUrl and POST token exchange
  AuthorizationServer-->>exchangeUserJwtForOpaqueSignerSessionWith: opaque signer session
  exchangeUserJwtForOpaqueSignerSessionWith-->>mintOpaqueSignerSessionForExternalUser: SignerSessionToken
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • livepeer/naap#369: Introduces the PymthouseAdapter minting path that this PR extends with per-instance signer-exchange handling.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: minting opaque pmth_ signer sessions per ProviderInstance in billing.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pymthouse-per-instance-signer-mint

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web-next/src/lib/billing/pymthouse-adapter.ts`:
- Around line 71-74: The PymthouseAdapter currently allows a client override
without a matching signerExchange and then silently falls back to the global
mint path, which can mix provider usage with a different signer session. Update
PymthouseAdapter’s constructor and minting flow to enforce the invariant that
client and signerExchange must be provided together: either validate immediately
in the constructor or throw before the mint step when the pair is incomplete.
Use the PymthouseAdapter and its minting logic around the
clientOverride/signerExchange handling to locate the fix.

In `@apps/web-next/src/lib/pymthouse-client.ts`:
- Around line 97-99: The PmtHouse missing-configuration error in
pymthouse-client should stay classified as a server/provider availability issue
rather than a client error. Update the PmtHouseError thrown for the
PYMTHOUSE_NOT_CONFIGURED_MESSAGE path in the pymthouse-client logic to return
HTTP 503 while keeping the existing pymthouse_required code, so it matches the
behavior of the other PYMTHOUSE_* config readers and callers can handle it
consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a39474ef-0d2f-4a65-8ebc-894e2400eab8

📥 Commits

Reviewing files that changed from the base of the PR and between 36ac8a5 and 85ccb57.

📒 Files selected for processing (3)
  • apps/web-next/src/lib/billing/provider-instance.ts
  • apps/web-next/src/lib/billing/pymthouse-adapter.ts
  • apps/web-next/src/lib/pymthouse-client.ts

Comment on lines 71 to +74
constructor(options: PymthouseAdapterOptions = {}) {
this.clientOverride = options.client;
this.isConfiguredOverride = options.isConfigured;
this.signerExchange = options.signerExchange;

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 & Privacy | 🟠 Major | ⚡ Quick win

Fail closed when client and signerExchange are not paired.

The option comment says signerExchange is required with a client override, but Line 227 silently falls back to the global env mint path if it is missing. That can bind usage/spend to one provider instance while minting signer sessions against another. Validate the invariant in the constructor or fail before minting.

Proposed fix
   constructor(options: PymthouseAdapterOptions = {}) {
+    if (Boolean(options.client) !== Boolean(options.signerExchange)) {
+      throw new Error('PymthouseAdapter requires client and signerExchange to be configured together');
+    }
     this.clientOverride = options.client;
     this.isConfiguredOverride = options.isConfigured;
     this.signerExchange = options.signerExchange;
   }
     const session =
-      this.clientOverride && this.signerExchange
+      this.clientOverride
         ? await mintOpaqueSignerSessionForExternalUser({
             client: this.clientOverride,
-            exchange: this.signerExchange,
+            exchange: this.signerExchange,
             externalUserId: input.externalUserId,
             ...(input.email != null ? { email: input.email } : {}),
           })

Also applies to: 225-237

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/lib/billing/pymthouse-adapter.ts` around lines 71 - 74, The
PymthouseAdapter currently allows a client override without a matching
signerExchange and then silently falls back to the global mint path, which can
mix provider usage with a different signer session. Update PymthouseAdapter’s
constructor and minting flow to enforce the invariant that client and
signerExchange must be provided together: either validate immediately in the
constructor or throw before the mint step when the pair is incomplete. Use the
PymthouseAdapter and its minting logic around the clientOverride/signerExchange
handling to locate the fix.

Comment on lines 97 to 99
throw new PmtHouseError(PYMTHOUSE_NOT_CONFIGURED_MESSAGE, {
status: 400,
code: 'pymthouse_required',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Return 503 for missing global PymtHouse configuration.

Line 98 changes the pymthouse_required path to HTTP 400, but existing PymtHouse config-readers treat missing required PYMTHOUSE_* server config as 503. Keep this consistent so callers classify it as provider/server unavailable.

Proposed fix
     throw new PmtHouseError(PYMTHOUSE_NOT_CONFIGURED_MESSAGE, {
-      status: 400,
+      status: 503,
       code: 'pymthouse_required',
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
throw new PmtHouseError(PYMTHOUSE_NOT_CONFIGURED_MESSAGE, {
status: 400,
code: 'pymthouse_required',
throw new PmtHouseError(PYMTHOUSE_NOT_CONFIGURED_MESSAGE, {
status: 503,
code: 'pymthouse_required',
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/lib/pymthouse-client.ts` around lines 97 - 99, The PmtHouse
missing-configuration error in pymthouse-client should stay classified as a
server/provider availability issue rather than a client error. Update the
PmtHouseError thrown for the PYMTHOUSE_NOT_CONFIGURED_MESSAGE path in the
pymthouse-client logic to return HTTP 503 while keeping the existing
pymthouse_required code, so it matches the behavior of the other PYMTHOUSE_*
config readers and callers can handle it consistently.

@seanhanca
seanhanca merged commit b9e26ab into main Jun 24, 2026
23 checks passed
@seanhanca
seanhanca deleted the fix/pymthouse-per-instance-signer-mint branch June 24, 2026 22:32
seanhanca added a commit that referenced this pull request Jun 25, 2026
… error

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope/shell Shell app changes size/M Medium PR (51-200 lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant