diff --git a/app/api/paid/_shared.ts b/app/api/paid/_shared.ts index eecd4528..5742068d 100644 --- a/app/api/paid/_shared.ts +++ b/app/api/paid/_shared.ts @@ -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 { + return noStore(await handleFirstPartyPaidGetInner(req, resource, content)); +} + +async function handleFirstPartyPaidGetInner( + req: Request, + resource: FirstPartyResource, + content: PaidContent, ): Promise { if (!env.enableX402Facilitator) { return NextResponse.json({ error: 'not_found' }, { status: 404 }); diff --git a/tests/app/api/paid-first-party.test.ts b/tests/app/api/paid-first-party.test.ts index cc30e2b5..a655d1ee 100644 --- a/tests/app/api/paid-first-party.test.ts +++ b/tests/app/api/paid-first-party.test.ts @@ -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'));