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
28 changes: 28 additions & 0 deletions app/api/paid/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,38 @@ function paymentChallenge(
}
}

/**
* x402 の有料エンドポイントは共有キャッシュに載せない。
*
* 402 チャレンジは支払い条件 (payTo / 金額 / forwarder / commitVersion) を、200 は購入者だけが
* 受け取るべき有料コンテンツを含む。Next.js 既定の `public, max-age=0, must-revalidate` は
* **共有キャッシュ (CDN / プロキシ) への保存を許可**してしまう (本番実測 2026-07-27)。
*
* 何の波及を断つ防御か (掟13): ①条件変更後も古い支払い条件が配布され、買い手が旧 payTo /
* 旧価格で払う ②URL だけを鍵にしたキャッシュが、未払いリクエストへ有料コンテンツを返す。
* `Vary` に X-PAYMENT が無い以上、no-store で塞ぐのが確実。
*/
function noStore(res: NextResponse): NextResponse {
res.headers.set('Cache-Control', 'no-store');
return res;
}

/**
* ハンドラの出口で一律 no-store を被せる。分岐が 20 以上あり、個々の return に付けると
* 新しい分岐を足したときに漏れるため、境界 1 箇所で保証する。
*/
export async function handleFirstPartyPaidGet(
req: Request,
resource: FirstPartyResource,
content: PaidContent,
): Promise<NextResponse> {
return noStore(await handleFirstPartyPaidGetInner(req, resource, content));
}

async function handleFirstPartyPaidGetInner(
req: Request,
resource: FirstPartyResource,
content: PaidContent,
): Promise<NextResponse> {
if (!env.enableX402Facilitator) {
return NextResponse.json({ error: 'not_found' }, { status: 404 });
Expand Down
25 changes: 25 additions & 0 deletions tests/app/api/paid-first-party.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,31 @@ describe('first-party paid x402 routes', () => {
expect(routeMocks.settle).not.toHaveBeenCalled();
});

// 402 は支払い条件 (payTo/金額/forwarder)、200 は購入者だけが受け取る有料コンテンツを含む。
// Next.js 既定は `public, max-age=0, must-revalidate` で共有キャッシュへの保存を許可して
// しまうため、ハンドラ出口で no-store を被せている。分岐追加時の付け忘れをここで固定する。
it('402 / 200 とも Cache-Control: no-store (共有キャッシュへ載せない)', async () => {
const { demo } = await load();
const challenge = await demo.GET(req('/api/paid/demo'));
expect(challenge.status).toBe(402);
expect(challenge.headers.get('cache-control')).toBe('no-store');

routeMocks.verify.mockResolvedValue(
NextResponse.json({ isValid: true, payer: PAYER }),
);
routeMocks.settle.mockImplementation(async () =>
NextResponse.json({
success: true,
transaction: TX_HASH,
network: 'eip155:80002',
payer: PAYER,
}),
);
const paid = await demo.GET(req('/api/paid/demo', paymentHeader()));
expect(paid.status).toBe(200);
expect(paid.headers.get('cache-control')).toBe('no-store');
});

it('不正 X-PAYMENT → 402', async () => {
const { demo } = await load();
const res = await demo.GET(req('/api/paid/demo', 'not-json'));
Expand Down
Loading