Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .agents/friction-log/20260820124001-pnpm-checks-abort/friction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
title: 'pnpm checks abort while attempting non-interactive dependency repair'
severity: 'minor'
---

## Expected Behavior

`pnpm check:types` runs the documented type check, or reports a non-interactive dependency problem with an actionable command.

## Current Behavior

The command invokes `pnpm install` and aborts with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`, so validation cannot start.

## Possible Solution

Avoid implicit interactive dependency repair for check commands, or provide a CI-safe fallback and clear remediation.

## Minimal Reproducible Example

Run `pnpm check:types` in a non-TTY workspace where pnpm decides the modules directory needs repair.

## Context

This blocked validation of a source change in this repository.
5 changes: 5 additions & 0 deletions .changeset/payment-authorization-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added a `requiresAuth` server option that used `Payment-Authorization` for Payment credentials.
37 changes: 36 additions & 1 deletion src/Challenge.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Challenge } from 'mppx'
import { Challenge, Constants } from 'mppx'
import { Methods } from 'mppx/tempo'
import { describe, expect, test } from 'vp/test'

Expand Down Expand Up @@ -66,6 +66,41 @@ describe('from', () => {
expect(challenge.expires).toBe('2025-01-06T12:00:00.000Z')
})

test('behavior: preserves an alternate credential header', () => {
const challenge = Challenge.from({
id: 'abc123',
realm: 'api.example.com',
method: 'tempo',
intent: 'charge',
request: { amount: '1000000' },
header: Constants.Headers.paymentAuthorization,
})

expect(Challenge.serialize(challenge)).toContain(
`header="${Constants.Headers.paymentAuthorization}"`,
)
expect(Challenge.credentialHeader(Challenge.deserialize(Challenge.serialize(challenge)))).toBe(
Constants.Headers.paymentAuthorization,
)
})

test('behavior: omits the default credential header', () => {
const parameters = {
secretKey: 'test-secret-key-test-secret-key-32',
realm: 'api.example.com',
method: 'tempo',
intent: 'charge',
request: { amount: '1000000' },
}
const challenge = Challenge.from({ ...parameters, header: Constants.Headers.authorization })
const implicitChallenge = Challenge.from(parameters)

expect(challenge.header).toBeUndefined()
expect(challenge.id).toBe(implicitChallenge.id)
expect(Challenge.serialize(challenge)).not.toContain('header=')
expect(Challenge.credentialHeader(challenge)).toBe(Constants.Headers.authorization)
})

test('error: rejects empty id', () => {
expect(() =>
Challenge.from({
Expand Down
46 changes: 41 additions & 5 deletions src/Challenge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export const Schema = z.object({
digest: z.optional(z.string().check(z.regex(/^sha-256=/, 'Invalid digest format'))),
/** Optional expiration timestamp (ISO 8601). */
expires: z.optional(z.datetime()),
/** Optional HTTP field name to carry the payment credential. When omitted, uses Authorization. */
header: z.optional(
z.string().check(z.regex(/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/, 'Invalid HTTP header name')),
),
Comment thread
raubrey-stripe marked this conversation as resolved.
/** Unique challenge identifier (HMAC-bound). */
id: z.string().check(z.minLength(1)),
/** Intent type (e.g., "charge", "session"). */
Expand Down Expand Up @@ -126,12 +130,18 @@ export function from<
digest,
meta,
method: methodName,
header: suppliedHeader,
intent,
realm,
request,
secretKey,
} = parameters

// `Authorization` is the implicit protocol default and is intentionally not
// advertised on the wire. This preserves the legacy challenge binding and
// lets servers opt into an alternate credential field explicitly.
const header = isDefaultCredentialHeader(suppliedHeader) ? undefined : suppliedHeader

const expires = parameters.expires ? z.toDatetimeString(parameters.expires) : undefined
const opaque =
parameters.opaque ?? (meta !== undefined ? PaymentRequest.serialize(meta) : undefined)
Expand All @@ -151,6 +161,7 @@ export function from<
...(description && { description }),
...(digest && { digest }),
...(expires && { expires }),
...(header !== undefined && { header }),
...(meta !== undefined && { meta }),
...(opaque !== undefined && { opaque }),
}) as from.ReturnType<parameters, methods>
Expand All @@ -177,6 +188,8 @@ export declare namespace from {
digest?: string | undefined
/** Optional expiration timestamp (ISO 8601). */
expires?: z.DatetimeInput | undefined
/** Optional HTTP field name to carry the payment credential. When omitted, uses Authorization. */
header?: string | undefined
/** Intent type (e.g., "charge", "session"). */
intent: string
/** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */
Expand Down Expand Up @@ -236,7 +249,7 @@ export function fromMethod<const method extends Method.Method>(
parameters: fromMethod.Parameters<method>,
): fromMethod.ReturnType<method> {
const { name: methodName, intent } = method
const { description, digest, expires, id, meta, realm, secretKey } = parameters
const { description, digest, expires, header, id, meta, realm, secretKey } = parameters

const request = PaymentRequest.fromMethod(method, parameters.request)

Expand All @@ -249,6 +262,7 @@ export function fromMethod<const method extends Method.Method>(
description,
digest,
expires,
header,
meta,
} as from.Parameters) as fromMethod.ReturnType<method>
}
Expand All @@ -270,6 +284,8 @@ export declare namespace fromMethod {
digest?: string | undefined
/** Optional expiration timestamp (ISO 8601). */
expires?: z.DatetimeInput | undefined
/** Optional HTTP field name to carry the payment credential. When omitted, uses Authorization. */
header?: string | undefined
/** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */
meta?: Record<string, string> | undefined
/** Server realm (e.g., hostname). */
Expand Down Expand Up @@ -308,6 +324,9 @@ export function serialize(challenge: Challenge): string {
parts.push(authParam('description', challenge.description))
if (challenge.digest !== undefined) parts.push(authParam('digest', challenge.digest))
if (challenge.expires !== undefined) parts.push(authParam('expires', challenge.expires))
const credentialHeader = challenge.header
if (credentialHeader !== undefined && !isDefaultCredentialHeader(credentialHeader))
parts.push(authParam('header', credentialHeader))
if (challenge.opaque !== undefined) parts.push(authParam('opaque', challenge.opaque))
else if (challenge.meta !== undefined)
parts.push(authParam('opaque', PaymentRequest.serialize(challenge.meta)))
Expand Down Expand Up @@ -654,22 +673,39 @@ export function meta(challenge: Challenge): Record<string, string> | undefined {
* of truth for what the challenge ID binds to — used by both `computeId()`
* (challenge creation) and `verify()` (credential verification).
*
* Slots: realm | method | intent | request | expires | digest | opaque
* Legacy slots: realm | method | intent | request | expires | digest | opaque.
* Challenges advertising a credential header insert it immediately before the
* final opaque slot.
*
* Because the HMAC covers ALL fields, the server does not need to separately
* pin opaque, digest, or expires during verification — any change to those
* fields produces a different HMAC and fails the ID comparison.
*/
function idBindingInput(challenge: Omit<Challenge, 'id'>): string {
return [
const values = [
challenge.realm,
challenge.method,
challenge.intent,
PaymentRequest.serialize(challenge.request),
challenge.expires ?? '',
challenge.digest ?? '',
challenge.opaque ?? (challenge.meta ? PaymentRequest.serialize(challenge.meta) : ''),
].join('|')
]
// Keep opaque in the final optional slot required by the Payment auth scheme.
const credentialHeader = challenge.header
if (credentialHeader !== undefined && !isDefaultCredentialHeader(credentialHeader))
values.push(credentialHeader)
values.push(challenge.opaque ?? (challenge.meta ? PaymentRequest.serialize(challenge.meta) : ''))
return values.join('|')
}

/** Returns the HTTP field name a client must use for a payment credential. */
export function credentialHeader(challenge: Challenge): string {
return challenge.header ?? Constants.Headers.authorization
}

/** Returns whether a credential header is the implicit HTTP authentication default. */
function isDefaultCredentialHeader(header: string | undefined): boolean {
return header?.toLowerCase() === Constants.Headers.authorization.toLowerCase()
}

/** @internal Computes HMAC-SHA256 challenge ID from parameters. */
Expand Down
1 change: 1 addition & 0 deletions src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
export const Headers = {
acceptPayment: 'Accept-Payment',
authorization: 'Authorization',
paymentAuthorization: 'Payment-Authorization',
paymentReceipt: 'Payment-Receipt',
paymentSession: 'Payment-Session',
paymentSessionSnapshot: 'Payment-Session-Snapshot',
Expand Down
17 changes: 16 additions & 1 deletion src/Credential.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Challenge, Credential } from 'mppx'
import { Challenge, Constants, Credential } from 'mppx'
import { Base64 } from 'ox'
import { describe, expect, test } from 'vp/test'

Expand Down Expand Up @@ -364,6 +364,21 @@ describe('fromRequest', () => {
expect(credential.payload).toEqual({ signature: '0x1234' })
})

test('behavior: extracts a credential from an alternate header', () => {
const request = new Request('https://api.example.com/resource', {
headers: {
[Constants.Headers.paymentAuthorization]: Credential.serialize(
Credential.from({ challenge, payload: {} }),
),
},
})

expect(
Credential.fromRequest(request, { header: Constants.Headers.paymentAuthorization }).challenge
.id,
).toBe(challenge.id)
})

test('error: throws for missing Authorization header', () => {
const request = new Request('https://api.example.com/resource')
expect(() => Credential.fromRequest(request)).toThrow('Missing Authorization header.')
Expand Down
26 changes: 18 additions & 8 deletions src/Credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ export type Credential<
export class MissingAuthorizationHeaderError extends Error {
override readonly name = 'MissingAuthorizationHeaderError'

constructor() {
super('Missing Authorization header.')
constructor(header: string = Constants.Headers.authorization) {
super(`Missing ${header} header.`)
}
}

Expand All @@ -44,7 +44,7 @@ export class InvalidCredentialEncodingError extends Error {
}

/**
* Deserializes an Authorization header value to a credential.
* Deserializes a Payment credential header value to a credential.
* Accepts the spec-compliant base64url `opaque` string shape and the legacy
* object-shaped `opaque` form emitted by older mppx versions.
*
Expand Down Expand Up @@ -151,7 +151,7 @@ export declare namespace from {
}

/**
* Extracts the credential from a Request's Authorization header.
* Extracts the credential from a Request's configured credential header.
*
* @param request - The HTTP request.
* @returns The deserialized credential.
Expand All @@ -163,16 +163,26 @@ export declare namespace from {
* const credential = Credential.fromRequest(request)
* ```
*/
export function fromRequest<payload = unknown>(request: Request): Credential<payload> {
const header = request.headers.get(Constants.Headers.authorization)
if (!header) throw new MissingAuthorizationHeaderError()
export function fromRequest<payload = unknown>(
request: Request,
options: fromRequest.Options = {},
): Credential<payload> {
const header = request.headers.get(options.header ?? Constants.Headers.authorization)
if (!header) throw new MissingAuthorizationHeaderError(options.header)
const payment = extractPaymentScheme(header)
if (!payment) throw new MissingPaymentSchemeError()
return deserialize<payload>(payment)
}

export declare namespace fromRequest {
type Options = {
/** HTTP field containing the Payment credential. @default 'Authorization' */
header?: string | undefined
}
}

/**
* Serializes a credential to the Authorization header format.
* Serializes a credential to the Payment credential header format.
* When present, `challenge.opaque` is emitted unchanged as the base64url string
* required by the Payment auth credential format.
*
Expand Down
2 changes: 1 addition & 1 deletion src/Html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function init<
document.getElementById(data.rootId)?.after(el)
},
root: document.getElementById(data.rootId)!,
submit: submitCredential,
submit: (credential) => submitCredential(credential, data.challenge.header),
vars,
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@ const cli = Cli.create('mppx', {
// Send credential and get response
const credentialHeaders = {
...normalizeHeaders(init.headers),
Authorization: credential,
[Challenge.credentialHeader(challenge)]: credential,
}
plugin?.prepareCredentialRequest?.({ challenge, credential, headers: credentialHeaders })

Expand Down
9 changes: 8 additions & 1 deletion src/client/Transport.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Challenge, Credential, Mcp } from 'mppx'
import { Challenge, Constants, Credential, Mcp } from 'mppx'
import { Transport } from 'mppx/client'
import { Methods } from 'mppx/tempo'
import { Header as x402_Header, Types as x402_Types, type PaymentRequired } from 'mppx/x402'
Expand Down Expand Up @@ -207,6 +207,13 @@ describe('http', () => {
expectedValue: Credential.serialize(credential),
name: 'Payment auth credential for Payment auth challenge',
},
{
challenge: { ...challenge, header: Constants.Headers.paymentAuthorization },
credential: Credential.serialize(credential),
expectedHeader: Constants.Headers.paymentAuthorization,
expectedValue: Credential.serialize(credential),
name: 'Payment auth credential for alternate credential header',
},
{
challenge,
credential: 'custom-credential',
Expand Down
2 changes: 1 addition & 1 deletion src/client/Transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export function http(): Transport<RequestInit, Response> {
const protocol = options?.challenge ? protocolForChallenge.get(options.challenge) : undefined
const fallback = protocols[0]
if (!protocol && !fallback) throw new Error('No protocol to attach the credential.')
return (protocol ?? fallback)!.setCredential(request, credential)
return (protocol ?? fallback)!.setCredential(request, credential, options)
},
})
}
Expand Down
12 changes: 9 additions & 3 deletions src/client/internal/protocols/Mpp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as Constants from '../../../Constants.js'
import type { Protocol } from './Protocol.js'
import { paymentRequiredStatus, setCredentialHeader } from './Shared.js'

/** MPP — the native HTTP scheme: a 402 carrying a `WWW-Authenticate` challenge, paid back in `Authorization`. */
/** MPP — native HTTP Payment authentication. */
export function mpp(): Protocol {
return {
getChallenges(response) {
Expand All @@ -14,8 +14,14 @@ export function mpp(): Protocol {
return []
return Challenge.fromResponseList(response)
},
setCredential(request, credential) {
return setCredentialHeader(request, Constants.Headers.authorization, credential)
setCredential(request, credential, options) {
return setCredentialHeader(
request,
options?.challenge
? Challenge.credentialHeader(options.challenge)
: Constants.Headers.authorization,
credential,
)
},
}
}
6 changes: 5 additions & 1 deletion src/client/internal/protocols/Protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@ export type Protocol = {
/** This protocol's challenges from a response; `[]` when the response isn't its concern. */
getChallenges: (response: Response, request?: RequestInit) => MaybePromise<Challenge.Challenge[]>
/** Attaches this protocol's credential to a retry request. */
setCredential: (request: RequestInit, credential: string) => RequestInit
setCredential: (
request: RequestInit,
credential: string,
options?: { challenge?: Challenge.Challenge | undefined },
) => RequestInit
}
7 changes: 6 additions & 1 deletion src/client/internal/protocols/Shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ export function setCredentialHeader(
credential: string,
): RequestInit {
const headers = new Headers(request.headers)
for (const stale of credentialHeaders) headers.delete(stale)
for (const stale of credentialHeaders) {
// Never erase ordinary application credentials from Authorization.
if (stale === Constants.Headers.authorization && !headers.get(stale)?.startsWith('Payment '))
continue
Comment thread
raubrey-stripe marked this conversation as resolved.
headers.delete(stale)
}
headers.set(header, credential)
return { ...request, headers }
}
Loading
Loading