diff --git a/docs/DEPLOY_CHECKLIST.md b/docs/DEPLOY_CHECKLIST.md index beeac845..316afd83 100644 --- a/docs/DEPLOY_CHECKLIST.md +++ b/docs/DEPLOY_CHECKLIST.md @@ -1673,7 +1673,7 @@ Arc 非対応・自前の汎用 EIP-3009 facilitator は作らない)。設計 = ローカル `next start` (`X402_NETWORK=base-sepolia`・`ENABLE_X402_ARC_GATEWAY=1`) の `/api/paid/hello` を Arc accept で購入 → 200 + PAYMENT-RESPONSE success → 売り手の Gateway 残高 (domain 26) 増加を確認。 2. Vercel Production に `ENABLE_X402_ARC_GATEWAY=1` → 開示 3 点セット同期 PR (掟 14・LP FAQ / Terms / llms.txt / - README / お知らせ・**`/openapi.json` (`lib/openapi/document.ts` の `network`/`x-payment-chains`)**) を同一リリースで + README / お知らせ・**`/openapi.json` (`lib/openapi/payment.ts` の `network`/`x-payment-chains`)**) を同一リリースで merge → deploy。 3. 本番 smoke: `node scripts/arc-gateway-buyer-smoke.mjs` → `http://localhost:4599` をウォレットのあるブラウザで開き、 接続 → (初回のみ) USDC を Gateway Wallet に deposit → `/api/paid/hello` を購入 (秘密鍵を扱わない・署名はウォレット内・ diff --git a/lib/openapi/activity.ts b/lib/openapi/activity.ts new file mode 100644 index 00000000..2099fd1d --- /dev/null +++ b/lib/openapi/activity.ts @@ -0,0 +1,89 @@ +// JPYC ネットワーク活動 (24h 集計)・送金証明 (attest)・無料 preview の operation。 + +import { + ACTIVITY_CHAINS, + USDC_JPYC_ACTIVITY, + USDC_JPYC_ATTEST, + agentUsageText, +} from '@/lib/jpyc/liveResources'; +import { JPYC_ACTIVITY_PREVIEW_SCHEMA } from '@/lib/jpyc/liveSchema'; +import { usdcPaymentChains, usdcPaymentInfo } from '@/lib/openapi/payment'; +import { JPYC_LIVE_402 } from '@/lib/openapi/schema'; + +const JPYC_ACTIVITY_400 = { + description: 'Unknown or duplicate query key, unsupported chain, empty/invalid window, or missing chain with payment. No settlement.', +}; +export const ACTIVITY_OPENAPI_PATHS = { + [USDC_JPYC_ACTIVITY.path]: { + get: { + tags: ['x402 Vanilla (USDC)', 'JPYC Live Data'], + operationId: USDC_JPYC_ACTIVITY.operationId, + summary: USDC_JPYC_ACTIVITY.summary, + description: USDC_JPYC_ACTIVITY.description + ' ' + agentUsageText(USDC_JPYC_ACTIVITY.trigger) + ' Payment: standard x402 in USDC on Base mainnet; no OpenPay fee is added.', + parameters: [ + { name: 'chain', in: 'query', required: true, schema: { type: 'string', enum: ACTIVITY_CHAINS } }, + { name: 'window', in: 'query', required: false, schema: { type: 'string', enum: ['24h'], default: '24h' } }, + ], + 'x-agent-usage': USDC_JPYC_ACTIVITY.trigger, + 'x-payment-info': usdcPaymentInfo(USDC_JPYC_ACTIVITY.priceUsd), + 'x-payment-protocol': 'x402', 'x-payment-asset': 'USDC', 'x-payment-chains': usdcPaymentChains(), + responses: { + '200': { + description: 'Complete aggregate from immutable finalized buckets after settlement; observedAt is the newest bucket timestamp and expiresAt is four hours later.', + content: { 'application/json': { + schema: USDC_JPYC_ACTIVITY.bazaar.output.schema, example: USDC_JPYC_ACTIVITY.bazaar.output.example, + } }, + }, + '400': JPYC_ACTIVITY_400, + '402': JPYC_LIVE_402, + '503': { + description: 'data_incomplete: a required bucket is missing. data_unavailable: KV is unavailable, malformed or overflowed, or the data timestamp is over 60 seconds in the future. data_stale: the newest bucket timestamp is more than four hours old. No settlement in every case.', + }, + }, + }, + }, + [USDC_JPYC_ATTEST.path]: { + get: { + tags: ['x402 Vanilla (USDC)', 'JPYC Live Data'], + operationId: USDC_JPYC_ATTEST.operationId, + summary: USDC_JPYC_ATTEST.summary, + description: USDC_JPYC_ATTEST.description + ' ' + agentUsageText(USDC_JPYC_ATTEST.trigger) + ' Payment: standard x402 in USDC on Base mainnet; no OpenPay fee is added.', + parameters: [ + { name: 'chain', in: 'query', required: true, schema: USDC_JPYC_ATTEST.bazaar.queryParamsSchema.properties.chain }, + { name: 'tx', in: 'query', required: true, schema: USDC_JPYC_ATTEST.bazaar.queryParamsSchema.properties.tx }, + ], + 'x-agent-usage': USDC_JPYC_ATTEST.trigger, + 'x-payment-info': usdcPaymentInfo(USDC_JPYC_ATTEST.priceUsd), + 'x-payment-protocol': 'x402', 'x-payment-asset': 'USDC', 'x-payment-chains': usdcPaymentChains(), + responses: { + '200': { + description: 'JPYC transfers with an optional EIP-712 signature. The signature is not a legal certification.', + content: { 'application/json': { + schema: USDC_JPYC_ATTEST.bazaar.output.schema, example: USDC_JPYC_ATTEST.bazaar.output.example, + } }, + }, + '400': { description: 'Invalid or duplicate query parameters; missing required parameters with payment. No settlement.' }, + '404': { description: 'tx_not_found: receipt not mined or absent. no_jpyc_transfer: reverted or no JPYC Transfer logs. No settlement.' }, + '402': JPYC_LIVE_402, + '503': { + description: 'RPC unavailable. No settlement.', + }, + }, + }, + }, + '/api/jpyc/activity/preview': { + get: { + tags: ['JPYC Live Data'], operationId: 'getJpycNetworkActivityPreview', + summary: 'Preview JPYC network activity availability and transfer count', + description: 'Free preview using the same finalized buckets and validity checks as the paid feed. Skip a purchase when observedAt is unchanged or expiresAt has passed. Unavailable data has a reason and no sample. No feature flag is required.', + parameters: [{ name: 'chain', in: 'query', required: false, schema: { type: 'string', enum: ACTIVITY_CHAINS, default: 'polygon' } }], + responses: { + '200': { + description: 'Stable available/unavailable envelope. Available cache freshness plus stale-while-revalidate is capped by expiresAt; unavailable responses cache for 60 seconds.', + content: { 'application/json': { schema: JPYC_ACTIVITY_PREVIEW_SCHEMA } }, + }, + '400': { description: 'Unknown or duplicate query key, or unsupported/empty chain.' }, + }, + }, + }, +} as const; diff --git a/lib/openapi/components.ts b/lib/openapi/components.ts new file mode 100644 index 00000000..53db1d6d --- /dev/null +++ b/lib/openapi/components.ts @@ -0,0 +1,281 @@ +// 基底文書 (lib/openapi/document.ts の OPENAPI_DOCUMENT) の components。schemas は Directory の +// 型と全領域共有の Error、responses は全領域が $ref する共通応答 (StorageUnavailable は +// buildOpenApiDocument が生成時に足す)。 + +export const BASE_OPENAPI_SCHEMAS = { + DirectoryEntry: { + type: 'object', + required: [ + 'slug', + 'name', + 'nameJa', + 'status', + 'sourceUrl', + 'sourceType', + 'verifiedAt', + 'updatedAt', + 'attribution', + 'facts', + 'editorial', + 'sourceCheckedAt', + 'sourceOk', + ], + properties: { + slug: { type: 'string' }, + name: { type: 'string' }, + nameJa: { type: 'string' }, + status: { type: 'string', const: 'published' }, + sourceUrl: { type: 'string', format: 'uri' }, + sourceType: { type: 'string', enum: ['official', 'manual'] }, + verifiedAt: { type: 'string', format: 'date' }, + updatedAt: { type: 'string', format: 'date' }, + sourceCheckedAt: { type: ['string', 'null'], format: 'date-time' }, + sourceOk: { + type: ['boolean', 'null'], + description: + 'Source URL reachability only; it does not establish whether the directory information is true. true = reachable (2xx/3xx), false = confirmed gone (404/410), null = indeterminate (no current result, bot protection, or transient failure).', + }, + attribution: { type: 'string' }, + facts: { + type: 'object', + required: [ + 'description', + 'category', + 'tags', + 'tokens', + 'chains', + 'languages', + 'supportsJpyc', + 'supportsUsdc', + 'supportsX402', + 'supportsMcp', + ], + properties: { + description: { type: 'string' }, + category: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } }, + tokens: { type: 'array', items: { type: 'string' } }, + chains: { type: 'array', items: { type: 'string' } }, + languages: { type: 'array', items: { type: 'string' } }, + supportsJpyc: { type: 'boolean' }, + supportsUsdc: { type: 'boolean' }, + supportsX402: { type: 'boolean' }, + supportsMcp: { type: 'boolean' }, + }, + }, + editorial: { + type: 'object', + required: ['summaryJa', 'summaryEn'], + properties: { + summaryJa: { type: 'string' }, + summaryEn: { type: 'string' }, + }, + }, + }, + }, + DirectoryLicensedEnvelope: { + type: 'object', + allOf: [ + { $ref: '#/components/schemas/DirectoryEnvelope' }, + { + type: 'object', + required: ['license', 'attestation', 'signer', 'verify'], + properties: { + license: { + type: 'object', + required: ['id', 'name', 'url', 'licensee', 'issuedAt', 'grants', 'requires', 'prohibits'], + properties: { + id: { type: 'string', const: 'openpay-directory-license-v1' }, + name: { type: 'string', const: 'OpenPay Directory Data License v1' }, + url: { type: 'string', format: 'uri' }, + licensee: { type: ['string', 'null'], pattern: '^0x[0-9a-fA-F]{40}$' }, + issuedAt: { type: 'string', format: 'date-time' }, + grants: { type: 'array', items: { type: 'string' } }, + requires: { type: 'array', items: { type: 'string' } }, + prohibits: { type: 'array', items: { type: 'string' } }, + }, + }, + attestation: { + type: ['object', 'null'], + required: ['message', 'signature'], + properties: { + message: { + type: 'object', + required: ['licensee', 'licenseId', 'contentHash', 'rows', 'issuedAt'], + properties: { + licensee: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$', description: 'Zero address when the payer is unknown.' }, + licenseId: { type: 'string', const: 'openpay-directory-license-v1' }, + contentHash: { type: 'string', pattern: '^0x[0-9a-fA-F]{64}$', description: 'keccak256 of UTF-8 JSON.stringify(items), preserving array order.' }, + rows: { type: 'integer', minimum: 0 }, + issuedAt: { type: 'integer', minimum: 0, description: 'Unix seconds.' }, + }, + }, + signature: { type: 'string', pattern: '^0x[0-9a-fA-F]{130}$' }, + }, + }, + signer: { type: ['string', 'null'], pattern: '^0x[0-9a-fA-F]{40}$' }, + verify: { + type: 'object', + required: ['method', 'domain', 'types'], + properties: { + method: { type: 'string', const: 'EIP-712 recoverTypedDataAddress' }, + domain: { + type: 'object', + required: ['name', 'version'], + additionalProperties: false, + properties: { + name: { type: 'string', const: 'OpenPay Directory License' }, + version: { type: 'string', const: '1' }, + }, + }, + types: { + type: 'object', + required: ['DirectoryLicense'], + additionalProperties: false, + properties: { + DirectoryLicense: { + type: 'array', + const: [ + { name: 'licensee', type: 'address' }, + { name: 'licenseId', type: 'string' }, + { name: 'contentHash', type: 'bytes32' }, + { name: 'rows', type: 'uint256' }, + { name: 'issuedAt', type: 'uint256' }, + ], + items: { + type: 'object', + required: ['name', 'type'], + properties: { name: { type: 'string' }, type: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + DirectoryEnvelope: { + type: 'object', + required: [ + 'schemaVersion', + 'query', + 'items', + 'total', + 'generatedAt', + 'dataFreshness', + 'licenseNotice', + 'attribution', + ], + properties: { + schemaVersion: { type: 'string', const: '1.0' }, + query: { type: 'object' }, + items: { + type: 'array', + items: { $ref: '#/components/schemas/DirectoryEntry' }, + }, + total: { type: 'integer', minimum: 0 }, + generatedAt: { type: 'string', format: 'date-time' }, + dataFreshness: { + type: 'object', + required: ['oldest', 'newestVerifiedAt', 'oldestSourceCheckedAt'], + properties: { + oldest: { type: ['string', 'null'], format: 'date' }, + newestVerifiedAt: { type: ['string', 'null'], format: 'date' }, + oldestSourceCheckedAt: { + type: ['string', 'null'], + format: 'date-time', + }, + }, + }, + licenseNotice: { + type: 'string', + description: + 'sourceOk reports source URL reachability only, not whether the information is true.', + }, + attribution: { + type: 'array', + uniqueItems: true, + items: { type: 'string' }, + }, + }, + }, + Error: { + type: 'object', + required: ['ok', 'error'], + properties: { + ok: { type: 'boolean', const: false }, + error: { + type: 'string', + enum: [ + 'invalid_query', + 'not_found', + 'rate_limited', + 'storage_unavailable', + ], + }, + }, + }, +} as const; + +export const BASE_OPENAPI_RESPONSES = { + InvalidQuery: { + description: 'A query value is outside the documented allowlist.', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + example: { ok: false, error: 'invalid_query' }, + }, + }, + }, + NotFound: { + description: 'Feature disabled, slug absent, or entry not published.', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + example: { ok: false, error: 'not_found' }, + }, + }, + }, + RateLimited: { + description: 'Best-effort per-IP request limit exceeded.', + headers: { + 'Retry-After': { schema: { type: 'integer' }, description: 'Seconds' }, + }, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + example: { ok: false, error: 'rate_limited' }, + }, + }, + }, + PaymentRequired: { + description: + 'x402 payment challenge. Amount is denominated in JPYC on Polygon or Polygon Amoy; the existing buyer-added facilitator fee is included in maxAmountRequired.', + headers: { + 'PAYMENT-REQUIRED': { + schema: { type: 'string' }, + description: 'Base64-encoded x402 v2 payment requirements.', + }, + }, + content: { + 'application/json': { + example: { + x402Version: 1, + accepts: [ + { + scheme: 'exact', + network: 'eip155:137', + resource: + 'https://open-pay.jp/api/paid/japan-web3-directory', + maxAmountRequired: '3000000000000000000', + asset: 'JPYC', + }, + ], + error: 'payment_required', + }, + }, + }, + }, +} as const; diff --git a/lib/openapi/directory.ts b/lib/openapi/directory.ts new file mode 100644 index 00000000..82f5a06a --- /dev/null +++ b/lib/openapi/directory.ts @@ -0,0 +1,353 @@ +// Japan Web3 Directory (無料 teaser と JPYC / USDC の有料一覧・検索・1 件) の operation。 +// 基底文書 (lib/openapi/document.ts の OPENAPI_DOCUMENT) の paths そのもの。 + +import { + DIRECTORY_DETAIL_PRICE_JPYC, + DIRECTORY_LIST_RESOURCE, + DIRECTORY_SEARCH_RESOURCE, +} from '@/lib/directory/paidResources'; +import { + USDC_DIRECTORY_LICENSED, + USDC_DIRECTORY_LIST, + USDC_DIRECTORY_SEARCH, +} from '@/lib/directory/usdcResource'; +import { paymentInfo, usdcPaymentChains, usdcPaymentInfo } from '@/lib/openapi/payment'; +import { ERROR_RESPONSES, PAID_RESPONSES } from '@/lib/openapi/schema'; + +const DIRECTORY_QUERY_PARAMETERS = [ + { + name: 'keyword', + in: 'query', + schema: { type: 'string', maxLength: 100 }, + }, + { + name: 'category', + in: 'query', + schema: { + type: 'string', + enum: [ + 'api', + 'bridge', + 'developer-tool', + 'exchange', + 'network', + 'payment', + 'stablecoin', + 'wallet', + ], + }, + }, + { name: 'token', in: 'query', schema: { type: 'string', enum: ['jpyc', 'usdc'] } }, + { + name: 'chain', + in: 'query', + schema: { + type: 'string', + enum: [ + 'arbitrum', + 'avalanche', + 'base', + 'ethereum', + 'kaia', + 'optimism', + 'polygon', + ], + }, + }, + { name: 'language', in: 'query', schema: { type: 'string', enum: ['en', 'ja'] } }, + { name: 'supportsJpyc', in: 'query', schema: { type: 'boolean' } }, + { name: 'supportsUsdc', in: 'query', schema: { type: 'boolean' } }, + { name: 'supportsX402', in: 'query', schema: { type: 'boolean' } }, + { name: 'supportsMcp', in: 'query', schema: { type: 'boolean' } }, + { + name: 'status', + in: 'query', + description: 'Only published entries can be returned, regardless of this filter.', + schema: { + type: 'string', + enum: ['draft', 'review', 'published', 'rejected', 'archived'], + }, + }, + { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 50 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0, maximum: 1000 } }, +] as const; + +export const DIRECTORY_OPENAPI_PATHS = { + '/api/directory': { + get: { + tags: ['Directory Free'], + summary: 'Get a free directory teaser', + description: + 'Returns full entry fields but forces limit to at most 5 and offset to 0 (any provided offset is ignored).', + parameters: DIRECTORY_QUERY_PARAMETERS, + responses: { + '200': { + description: 'Published directory entries', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/DirectoryEnvelope' }, + example: { + schemaVersion: '1.0', + query: { limit: 5, offset: 0 }, + items: [ + { + slug: 'jpyc', + name: 'JPYC', + nameJa: 'JPYC', + status: 'published', + sourceUrl: + 'https://corporate.jpyc.co.jp/news/posts/jpyc-ex-launch', + sourceType: 'official', + verifiedAt: '2026-07-13', + sourceCheckedAt: '2026-07-14T00:00:00.000Z', + sourceOk: true, + updatedAt: '2026-07-13', + attribution: 'JPYC株式会社', + facts: { + description: 'A source-verified factual summary.', + category: 'stablecoin', + tags: ['Japan', 'JPY', 'stablecoin'], + tokens: ['jpyc'], + chains: ['avalanche', 'ethereum', 'polygon'], + languages: ['ja'], + supportsJpyc: true, + supportsUsdc: false, + supportsX402: false, + supportsMcp: false, + }, + editorial: { + summaryJa: 'OpenPayが独自作成した紹介文です。', + summaryEn: 'An original editorial summary written by OpenPay.', + }, + }, + ], + total: 19, + generatedAt: '2026-07-13T00:00:00.000Z', + dataFreshness: { + oldest: '2026-07-13', + newestVerifiedAt: '2026-07-13', + oldestSourceCheckedAt: '2026-07-14T00:00:00.000Z', + }, + licenseNotice: + 'Directory metadata is informational; sourceOk is reachability only, not whether the information is true.', + attribution: ['JPYC株式会社'], + }, + }, + }, + }, + ...ERROR_RESPONSES, + }, + }, + }, + '/api/directory/categories': { + get: { + tags: ['Directory Free'], + summary: 'List published category counts', + responses: { + '200': { + description: 'Category counts', + content: { + 'application/json': { + example: { + schemaVersion: '1.0', + items: [{ category: 'wallet', count: 3 }], + total: 1, + generatedAt: '2026-07-13T00:00:00.000Z', + }, + }, + }, + }, + '404': ERROR_RESPONSES['404'], + '429': ERROR_RESPONSES['429'], + }, + }, + }, + '/api/directory/tags': { + get: { + tags: ['Directory Free'], + summary: 'List published tag counts', + responses: { + '200': { + description: 'Tag counts', + content: { + 'application/json': { + example: { + schemaVersion: '1.0', + items: [{ tag: 'x402', count: 6 }], + total: 1, + generatedAt: '2026-07-13T00:00:00.000Z', + }, + }, + }, + }, + '404': ERROR_RESPONSES['404'], + '429': ERROR_RESPONSES['429'], + }, + }, + }, + '/api/paid/japan-web3-directory': { + get: { + tags: ['Directory Paid'], + summary: 'Unlock the full published directory', + 'x-payment-info': paymentInfo(DIRECTORY_LIST_RESOURCE.priceJpyc), + 'x-price-jpyc': Number(DIRECTORY_LIST_RESOURCE.priceJpyc), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'JPYC', + 'x-payment-chains': ['Polygon', 'Polygon Amoy'], + responses: { + '200': { + description: 'Full published directory after settlement', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/DirectoryEnvelope' }, + }, + }, + }, + ...PAID_RESPONSES, + }, + }, + }, + '/api/paid/japan-web3-directory/search': { + get: { + tags: ['Directory Paid'], + summary: 'Search and unlock published directory results', + parameters: DIRECTORY_QUERY_PARAMETERS, + 'x-payment-info': paymentInfo(DIRECTORY_SEARCH_RESOURCE.priceJpyc), + 'x-price-jpyc': Number(DIRECTORY_SEARCH_RESOURCE.priceJpyc), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'JPYC', + 'x-payment-chains': ['Polygon', 'Polygon Amoy'], + responses: { + '200': { + description: 'Filtered directory envelope after settlement', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/DirectoryEnvelope' }, + }, + }, + }, + '400': ERROR_RESPONSES['400'], + ...PAID_RESPONSES, + }, + }, + }, + [USDC_DIRECTORY_LIST.path]: { + get: { + tags: ['Directory Paid'], + summary: 'Unlock the full published directory (USDC on Base)', + description: + 'Same data as /api/paid/japan-web3-directory, sold via standard x402 (exact scheme) in USDC on Base mainnet through an external facilitator. No OpenPay fee is added; the listed price is the full charge.', + 'x-payment-info': usdcPaymentInfo(USDC_DIRECTORY_LIST.priceUsd), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'USDC', + 'x-payment-chains': usdcPaymentChains(), + responses: { + '200': { + description: 'Full published directory after settlement', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/DirectoryEnvelope' }, + }, + }, + }, + '402': { + description: + 'Standard x402 payment challenge (USDC on Base, exact scheme, single transferWithAuthorization).', + }, + '404': { $ref: '#/components/responses/NotFound' }, + '503': { $ref: '#/components/responses/StorageUnavailable' }, + }, + }, + }, + [USDC_DIRECTORY_LICENSED.path]: { + get: { + tags: ['Directory Paid'], + summary: USDC_DIRECTORY_LICENSED.serviceName, + description: USDC_DIRECTORY_LICENSED.description, + 'x-payment-info': usdcPaymentInfo(USDC_DIRECTORY_LICENSED.priceUsd), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'USDC', + 'x-payment-chains': usdcPaymentChains(), + responses: { + '200': { + description: 'Full published directory after settlement', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/DirectoryLicensedEnvelope' }, + }, + }, + }, + '402': { + description: + 'Standard x402 payment challenge (USDC on Base, exact scheme, single transferWithAuthorization).', + }, + '404': { $ref: '#/components/responses/NotFound' }, + '503': { $ref: '#/components/responses/StorageUnavailable' }, + }, + }, + }, + [USDC_DIRECTORY_SEARCH.path]: { + get: { + tags: ['Directory Paid'], + summary: 'Search the published directory (USDC on Base)', + description: + 'Same filters as /api/paid/japan-web3-directory/search, sold via standard x402 (exact scheme) in USDC on Base mainnet through an external facilitator. No OpenPay fee is added; the listed price is the full charge.', + parameters: DIRECTORY_QUERY_PARAMETERS, + 'x-payment-info': usdcPaymentInfo(USDC_DIRECTORY_SEARCH.priceUsd), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'USDC', + 'x-payment-chains': usdcPaymentChains(), + responses: { + '200': { + description: 'Filtered directory envelope after settlement', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/DirectoryEnvelope' }, + }, + }, + }, + '400': { $ref: '#/components/responses/InvalidQuery' }, + '402': { + description: + 'Standard x402 payment challenge (USDC on Base, exact scheme, single transferWithAuthorization).', + }, + '404': { $ref: '#/components/responses/NotFound' }, + '503': { $ref: '#/components/responses/StorageUnavailable' }, + }, + }, + }, + '/api/paid/japan-web3-directory/{slug}': { + get: { + tags: ['Directory Paid'], + summary: 'Unlock one published directory entry', + description: + 'Unknown or non-published slugs return 404 before any payment challenge or settlement.', + parameters: [ + { + name: 'slug', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + // x-payment-info を意図的に付けない: この path は slug テンプレートなので、外部 + // インデクサが登録すると `{slug}` を実 URL として probe し必ず 404 になる。有料 + // カタログに載せるのは固定 URL の一覧/検索だけ (FIRST_PARTY_RESOURCES と同じ判断)。 + 'x-price-jpyc': Number(DIRECTORY_DETAIL_PRICE_JPYC), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'JPYC', + 'x-payment-chains': ['Polygon', 'Polygon Amoy'], + responses: { + '200': { + description: 'One-entry directory envelope after settlement', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/DirectoryEnvelope' }, + }, + }, + }, + ...PAID_RESPONSES, + }, + }, + }, +} as const; diff --git a/lib/openapi/discovery.ts b/lib/openapi/discovery.ts new file mode 100644 index 00000000..80e2f3f8 --- /dev/null +++ b/lib/openapi/discovery.ts @@ -0,0 +1,148 @@ +// x402 facilitator (JPYC) のカタログ面: /api/discovery と first-party の demo / stores。 +// 掲載は enableX402Facilitator に連動する (lib/openapi/document.ts の buildOpenApiDocument)。 + +import { USDC_STORES_BAZAAR } from '@/lib/x402/usdcStores'; +import { firstPartyPrice, paymentInfo } from '@/lib/openapi/payment'; +import { PAID_RESPONSES, schemaFromExample } from '@/lib/openapi/schema'; + +export const DISCOVERY_OPENAPI_PATHS = { + '/api/discovery/{id}': { + get: { + tags: ['x402 Catalog'], + summary: 'Get one public seller listing by exact ID', + description: 'Independent of catalog pagination. Hidden, inactive and reserved-origin listings are excluded. Seller gates must pin the ID and recipient from their own configuration.', + security: [], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', minLength: 1, maxLength: 100 } }], + responses: { + '200': { + description: 'Single public listing in the discovery item shape', + content: { 'application/json': { schema: { $ref: '#/components/schemas/DiscoveryItem' } } }, + }, + '400': { description: 'Invalid resource ID.' }, + '404': { description: 'The facilitator is disabled or the listing is not public.' }, + '429': { description: 'Too many requests from this IP address.' }, + '503': { description: 'The resource storage is temporarily unavailable.' }, + }, + }, + }, + '/api/discovery': { + get: { + tags: ['x402 Catalog'], + summary: 'List payable x402 resources for agent comparison', + responses: { + '200': { + description: 'First-party and registered resources with payable requirements', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/DiscoveryEnvelope' }, + }, + }, + }, + '404': { description: 'The x402 facilitator is disabled.' }, + '503': { description: 'The resource catalog is temporarily unavailable.' }, + }, + }, + }, + '/api/paid/demo': { + get: { + tags: ['x402 Catalog'], + summary: 'Unlock a signed hello (x402 end-to-end demo)', + description: + 'Smallest payable resource. Use it to confirm the 402 challenge, JPYC payment and unlock work end to end before wiring a real paid API.', + 'x-payment-info': paymentInfo(firstPartyPrice('/api/paid/demo')), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'JPYC', + responses: { + '200': { + description: 'Unlock greeting after settlement', + content: { + 'application/json': { + example: { + message: 'Payment verified — welcome to the x402 + JPYC rail.', + paidAt: '2026-07-28T00:00:00.000Z', + }, + }, + }, + }, + ...PAID_RESPONSES, + }, + }, + }, + '/api/paid/stores': { + get: { + tags: ['x402 Catalog'], + summary: 'Unlock the curated JPYC acceptance directory', + description: + 'Curated JSON of exchanges, dApps and bridges that accept JPYC, with attribution.', + 'x-payment-info': paymentInfo(firstPartyPrice('/api/paid/stores')), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'JPYC', + responses: { + '200': { + description: 'Curated store list after settlement', + content: { + 'application/json': { + schema: schemaFromExample(USDC_STORES_BAZAAR.output.example), + example: USDC_STORES_BAZAAR.output.example, + }, + }, + }, + ...PAID_RESPONSES, + }, + }, + }, +} as const; + +export const DISCOVERY_OPENAPI_SCHEMAS = { + DiscoveryItem: { + type: 'object', + required: [ + 'resource', + 'description', + 'category', + 'priceJpyc', + 'network', + 'accepts', + 'verifiedAt', + ], + properties: { + id: { type: 'string', description: 'Registry listing ID; present for registered seller resources.' }, + resource: { type: 'string', format: 'uri' }, + title: { type: 'string', description: 'Short display name (first-party, or seller-provided)' }, + trigger: { type: 'string', description: 'When to use this resource' }, + description: { type: 'string' }, + category: { type: 'string' }, + priceJpyc: { type: 'string', pattern: '^[1-9][0-9]*$' }, + docsUrl: { type: 'string', format: 'uri', pattern: '^https://', maxLength: 512 }, + license: { type: 'string', maxLength: 60 }, + updatedAt: { type: 'string', format: 'date-time' }, + official: { + type: 'boolean', + const: true, + description: 'Present only for first-party resources generated by OpenPay.', + }, + network: { type: 'string', description: 'CAIP-2 network identifier' }, + accepts: { + type: 'array', + description: 'Payable x402 requirements including the OpenPay fee extension.', + items: { type: 'object' }, + }, + verifiedAt: { + type: ['string', 'null'], + format: 'date-time', + description: 'Last successful OpenPay gate verification, or null when not yet verified.', + }, + }, + }, + DiscoveryEnvelope: { + type: 'object', + required: ['x402Version', 'items'], + properties: { + x402Version: { type: 'integer', const: 1 }, + items: { + type: 'array', + items: { $ref: '#/components/schemas/DiscoveryItem' }, + }, + }, + }, +} as const; diff --git a/lib/openapi/document.ts b/lib/openapi/document.ts index f85e91d9..172fb842 100644 --- a/lib/openapi/document.ts +++ b/lib/openapi/document.ts @@ -7,1195 +7,27 @@ // 判定し、無いと「有料エンドポイント」として登録されない。金額は 402 チャレンジと同じ // x402FeeBreakdown から導出する (literal を書くと掟 14 のドリフト源になるため)。 +// 領域ごとの operation / schema は同じディレクトリの module に置き (license / discovery / vanilla / +// activity / monitor / stores / shops / directory / components、共有 helper は payment / schema)、 +// このファイルは基底文書と flag による組み立て (buildOpenApiDocument) だけを持つ。組み立ての +// spread 順が paths / tags / schemas の key 順 = 公開契約のバイト列を決めるので順序を変えない。 + import { env } from '@/lib/env'; import { licenseNftEnabled } from '@/lib/license/config'; import { LICENSE_DESCRIPTOR_SCHEMA, LICENSE_VERIFY_SCHEMA } from '@/lib/license/schema'; -import { JPYC_CHAINS } from '@/lib/chains'; import { shopsApiEnabled } from '@/lib/shops/flags'; +import { ACTIVITY_OPENAPI_PATHS } from '@/lib/openapi/activity'; +import { BASE_OPENAPI_RESPONSES, BASE_OPENAPI_SCHEMAS } from '@/lib/openapi/components'; +import { DIRECTORY_OPENAPI_PATHS } from '@/lib/openapi/directory'; +import { DISCOVERY_OPENAPI_PATHS, DISCOVERY_OPENAPI_SCHEMAS } from '@/lib/openapi/discovery'; +import { LICENSE_OPENAPI_PATHS } from '@/lib/openapi/license'; import { - DIRECTORY_DETAIL_PRICE_JPYC, - DIRECTORY_LIST_RESOURCE, - DIRECTORY_SEARCH_RESOURCE, - JPYC_PAYMENTS_RESOURCE, - JPYC_SERVICES_RESOURCE, -} from '@/lib/directory/paidResources'; -import { JPYC_SHOPS_SEARCH_RESOURCE } from '@/lib/shops/paidResources'; -import { - USDC_PAYMENT_MONITOR, - USDC_PAYMENT_MONITOR_BAZAAR, - USDC_SERVICE_MONITOR, - USDC_SERVICE_MONITOR_BAZAAR, - USDC_DIRECTORY_LICENSED, - USDC_DIRECTORY_LIST, - USDC_DIRECTORY_SEARCH, -} from '@/lib/directory/usdcResource'; -import { USDC_STORES, USDC_STORES_BAZAAR } from '@/lib/x402/usdcStores'; -import { - ACTIVITY_CHAINS, - USDC_JPYC_ACTIVITY, - USDC_JPYC_ATTEST, - USDC_JPYC_BALANCE, - USDC_JPYC_SUPPLY, - USDC_JPYC_TRANSFERS, - agentUsageText, -} from '@/lib/jpyc/liveResources'; -import { JPYC_ACTIVITY_PREVIEW_SCHEMA } from '@/lib/jpyc/liveSchema'; -import { x402Config } from '@/lib/x402/config'; -import { usdPriceToAtomic } from '@/lib/x402/vanillaGate'; -import { FIRST_PARTY_RESOURCES } from '@/lib/x402/firstParty'; -import { x402FeeBreakdown } from '@/lib/x402/fee'; -import { x402FacilitatorConfig } from '@/lib/x402/facilitatorConfig'; -import { caip2ForChainId } from '@/lib/x402/network'; - -const JPYC_WEI = 10n ** 18n; - -const LICENSE_OPENAPI_PATHS = { - '/api/license/metadata/{id}': { - get: { - operationId: 'licenseMetadata', tags: ['Licenses'], security: [], - summary: 'ERC-1155 wallet metadata for a registered license product', - description: 'Public JSON with Japanese product name, image and terms. Feature OFF, invalid/unknown/unregistered products or no public seller handle return 404. Paused licenses remain resolvable.', - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', pattern: '^h_[0-9a-f]{32}$' } }], - responses: { - '200': { description: 'ERC-1155 metadata with OpenSea attributes', - headers: { 'Cache-Control': { schema: { type: 'string', const: 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' } } }, - content: { 'application/json': { schema: { - type: 'object', - properties: { - ...(schemaFromExample({ name: '利用ライセンス', description: '利用条件: https://seller.example/terms (v1)', image: 'https://open-pay.jp/og/handle?h=seller&locale=ja', external_url: 'https://open-pay.jp/@seller?product=h_4fa999236d92e95a76bb36dcd7446208' }).properties as Record), - attributes: { type: 'array', items: { type: 'object', - properties: { trait_type: { type: 'string' }, value: { type: ['string', 'integer'] } } } }, - }, - } } } }, - '404': { description: 'License metadata unavailable' }, - '503': { description: 'Product or handle storage unavailable' }, - }, - }, - }, - '/api/license/products/{id}': { - get: { - operationId: 'resolveLicense', tags: ['Licenses'], security: [], - summary: 'Resolve a license product to its immutable ERC-1155 identity', - description: 'Public HTTPS descriptor; feature OFF, unknown/digital products or no public seller handle return 404. Paused licenses remain resolvable. Stock is display-only and can be null.', - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', pattern: '^h_[0-9a-f]{32}$' } }], - responses: { - '200': { description: 'Version 1 license product descriptor', - headers: { 'Cache-Control': { schema: { type: 'string', const: 'public, s-maxage=60, stale-while-revalidate=300' } } }, - content: { 'application/json': { schema: { $ref: '#/components/schemas/LicenseDescriptor' } } } }, - '400': { description: 'Invalid product ID; rejected before IO' }, - '404': { description: 'License unavailable' }, - '429': { description: 'Trusted-IP rate limit exceeded', headers: { 'Retry-After': { schema: { type: 'string', const: '60' } } } }, - '503': { description: 'Product or handle storage unavailable' }, - }, - }, - }, - '/api/license/verify': { - get: { - operationId: 'verifyLicense', tags: ['Licenses'], security: [], - summary: 'Read purchase or holder rights for a wallet and license product', - description: 'Public HTTPS status, not authentication or signed proof. entitled:null means unknown. Feature OFF returns 404. 30 requests per trusted IP per minute; bounded RPC budget.', - parameters: [ - { name: 'address', in: 'query', required: true, schema: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' } }, - { name: 'product', in: 'query', required: true, schema: { type: 'string', pattern: '^h_[0-9a-f]{32}$' } }, - ], - responses: { - '200': { description: 'Version 1 status; unknown is entitled:null', headers: { 'Cache-Control': { schema: { type: 'string', const: 'no-store' } } }, - content: { 'application/json': { schema: { $ref: '#/components/schemas/LicenseVerification' } } } }, - '400': { description: 'Invalid or duplicated selectors; rejected before IO' }, - '404': { description: 'License unavailable' }, - '429': { description: 'Trusted-IP rate limit exceeded', headers: { 'Retry-After': { schema: { type: 'string', const: '60' } } } }, - '503': { description: 'Product storage unavailable' }, - }, - }, - }, -} as const; - -/** atomic JPYC → 小数文字列 (末尾 0 を落とす)。表示ではなく機械可読面の金額に使う。 */ -function formatJpyc(wei: bigint): string { - const int = wei / JPYC_WEI; - const frac = wei % JPYC_WEI; - if (frac === 0n) return int.toString(); - return `${int}.${frac.toString().padStart(18, '0').replace(/0+$/, '')}`; -} - -/** 価格はカタログ (FIRST_PARTY_RESOURCES) が権威。スペック側に literal を持たない。 */ -function firstPartyPrice(path: string): string { - const resource = FIRST_PARTY_RESOURCES.find((r) => r.path === path); - if (!resource) { - throw new Error(`openapi: unknown first-party resource ${path}`); - } - return resource.priceJpyc; -} - -// JPYC は 1 JPYC = 1 円のペッグなので ISO 4217 の JPY で表現できる。amount は買い手が実際に -// 署名する総額 (資源価格 + 買い手上乗せの facilitator 手数料) = 402 の maxAmountRequired と一致。 -function paymentInfo(priceJpyc: string) { - const { total } = x402FeeBreakdown(BigInt(priceJpyc) * JPYC_WEI); - return { - price: { currency: 'JPY', mode: 'fixed', amount: formatJpyc(total) }, - protocols: [ - { - x402: { - scheme: 'exact', - network: caip2ForChainId(x402FacilitatorConfig.chainId), - asset: 'JPYC', - }, - }, - ], - } as const; -} - -// vanilla x402 (USDC/Base) 直接販売用。JPYC 版と違い OpenPay 手数料が乗らないため、 -// amount は表示価格そのもの。network は本番 (servers = open-pay.jp) の Base mainnet 固定。 -/** - * 応答例から JSON Schema (型のみ) を導出する。Circle Agent Marketplace は「OpenAPI で入出力が - * 読めること」を掲載条件にするため (2026-09-11)、example だけだった 200 応答に schema を添える。 - * 例と型が食い違わないよう手書きせず example から機械的に作る (nullable/enum は付けない)。 - */ -function schemaFromExample(example: unknown): Record { - if (Array.isArray(example)) { - return { type: 'array', ...(example.length ? { items: schemaFromExample(example[0]) } : {}) }; - } - if (example !== null && typeof example === 'object') { - const properties: Record = {}; - for (const [key, value] of Object.entries(example as Record)) { - properties[key] = schemaFromExample(value); - } - return { type: 'object', properties }; - } - if (typeof example === 'number') return { type: Number.isInteger(example) ? 'integer' : 'number' }; - if (typeof example === 'boolean') return { type: 'boolean' }; - return { type: 'string' }; -} - -// Arc rail (ENABLE_X402_ARC_GATEWAY・DEPLOY_CHECKLIST §14.8) が ON のとき、first-party の USDC 有料 API は -// Arc の USDC (Circle Gateway x402 facilitator) でも払える。402 の v2 accepts と機械可読面を一致させるため、 -// flag に連動して 2 つ目の protocol と chain を載せる (OFF なら従来と 1 バイトも変わらない)。 -// network は Base と同じく本番 (servers = open-pay.jp) の mainnet 固定。 -function arcRailEnabled(): boolean { - return x402Config.arcGateway.enabled; -} - -function usdcPaymentChains(): string[] { - return arcRailEnabled() ? ['Base', 'Arc'] : ['Base']; -} - -function usdcPaymentInfo(amountUsd: string) { - return { - price: { currency: 'USD', mode: 'fixed', amount: amountUsd }, - protocols: [ - { x402: { scheme: 'exact', network: 'eip155:8453', asset: 'USDC' } }, - ...(arcRailEnabled() - ? [ - { - x402: { - scheme: 'exact', - network: 'eip155:5042', - asset: 'USDC', - facilitator: 'circle-gateway', - extra: { name: 'GatewayWalletBatched', version: '1' }, - }, - }, - ] - : []), - ], - }; -} - -// hello (vanilla demo) の価格は X402_PRICE env が権威。Money 文字列でない (polygon 配線) か -// 変換不能なら openapi に載せない (route 側も 503 に縮退するため整合する)。 -function helloUsdAmountOrNull(): string | null { - const price = x402Config.defaultPrice; - if (typeof price !== 'string') return null; - try { - usdPriceToAtomic(price); - } catch { - return null; - } - return price.replace(/^\$/, ''); -} - -const SHOPS_QUERY_PARAMETERS = [ - { - name: 'q', - in: 'query', - description: 'Case-insensitive partial match against name, tagline, or address.', - schema: { type: 'string', maxLength: 100 }, - }, - { - name: 'mode', - in: 'query', - schema: { type: 'string', enum: ['storefront', 'preorder'] }, - }, - { name: 'dineIn', in: 'query', schema: { type: 'boolean' } }, - { - name: 'acceptingNow', - in: 'query', - description: - 'When true, only definitely accepting shops are returned; indeterminate null values are excluded.', - schema: { type: 'boolean' }, - }, - { - name: 'limit', - in: 'query', - schema: { type: 'integer', minimum: 1, maximum: 20, default: 20 }, - }, - { - name: 'offset', - in: 'query', - schema: { type: 'integer', minimum: 0, maximum: 1000, default: 0 }, - }, -] as const; - -const SHOPS_FIND_QUERY_PARAMETERS = [ - { - name: 'q', - in: 'query', - description: 'Case-insensitive partial match against the shop name only.', - schema: { type: 'string', maxLength: 100 }, - }, - { - name: 'limit', - in: 'query', - schema: { type: 'integer', minimum: 1, maximum: 10, default: 10 }, - }, -] as const; - -const DIRECTORY_QUERY_PARAMETERS = [ - { - name: 'keyword', - in: 'query', - schema: { type: 'string', maxLength: 100 }, - }, - { - name: 'category', - in: 'query', - schema: { - type: 'string', - enum: [ - 'api', - 'bridge', - 'developer-tool', - 'exchange', - 'network', - 'payment', - 'stablecoin', - 'wallet', - ], - }, - }, - { name: 'token', in: 'query', schema: { type: 'string', enum: ['jpyc', 'usdc'] } }, - { - name: 'chain', - in: 'query', - schema: { - type: 'string', - enum: [ - 'arbitrum', - 'avalanche', - 'base', - 'ethereum', - 'kaia', - 'optimism', - 'polygon', - ], - }, - }, - { name: 'language', in: 'query', schema: { type: 'string', enum: ['en', 'ja'] } }, - { name: 'supportsJpyc', in: 'query', schema: { type: 'boolean' } }, - { name: 'supportsUsdc', in: 'query', schema: { type: 'boolean' } }, - { name: 'supportsX402', in: 'query', schema: { type: 'boolean' } }, - { name: 'supportsMcp', in: 'query', schema: { type: 'boolean' } }, - { - name: 'status', - in: 'query', - description: 'Only published entries can be returned, regardless of this filter.', - schema: { - type: 'string', - enum: ['draft', 'review', 'published', 'rejected', 'archived'], - }, - }, - { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 50 } }, - { name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0, maximum: 1000 } }, -] as const; - -// JPYC Service Monitor (両通貨版共通)。実装 (lib/directory/serviceMonitor.ts の -// parseServiceMonitorQuery) と一致させる — ずれるとエージェントが 400 を踏む。 -const SERVICE_MONITOR_PARAMS = [ - { - name: 'changedSince', - in: 'query', - required: false, - schema: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, - description: - 'Return only change events whose effective date max(date, collectedAt ?? date) is on/after this date (YYYY-MM-DD, inclusive). Echo the nextChangedSince value from your previous response. Omit for the full monitor snapshot (mode=snapshot).', - example: '2026-08-20', - }, - { - name: 'limit', - in: 'query', - required: false, - schema: { type: 'integer', minimum: 1, maximum: 200, default: 200 }, - description: - 'Maximum number of change events to return. In delta mode (changedSince set) the limit is rounded up to an effective-date boundary (max(date, collectedAt ?? date)): events with the same effective date are never split, even if the group exceeds limit. While hasMore is true, call again with changedSince set to nextChangedSince.', - }, -] as const; - -const ERROR_RESPONSES = { - '400': { $ref: '#/components/responses/InvalidQuery' }, - '404': { $ref: '#/components/responses/NotFound' }, - '429': { $ref: '#/components/responses/RateLimited' }, - '503': { $ref: '#/components/responses/StorageUnavailable' }, -} as const; - -const PAID_RESPONSES = { - '402': { $ref: '#/components/responses/PaymentRequired' }, - '404': { $ref: '#/components/responses/NotFound' }, - '503': { $ref: '#/components/responses/StorageUnavailable' }, -} as const; - -const DISCOVERY_OPENAPI_PATHS = { - '/api/discovery/{id}': { - get: { - tags: ['x402 Catalog'], - summary: 'Get one public seller listing by exact ID', - description: 'Independent of catalog pagination. Hidden, inactive and reserved-origin listings are excluded. Seller gates must pin the ID and recipient from their own configuration.', - security: [], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', minLength: 1, maxLength: 100 } }], - responses: { - '200': { - description: 'Single public listing in the discovery item shape', - content: { 'application/json': { schema: { $ref: '#/components/schemas/DiscoveryItem' } } }, - }, - '400': { description: 'Invalid resource ID.' }, - '404': { description: 'The facilitator is disabled or the listing is not public.' }, - '429': { description: 'Too many requests from this IP address.' }, - '503': { description: 'The resource storage is temporarily unavailable.' }, - }, - }, - }, - '/api/discovery': { - get: { - tags: ['x402 Catalog'], - summary: 'List payable x402 resources for agent comparison', - responses: { - '200': { - description: 'First-party and registered resources with payable requirements', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/DiscoveryEnvelope' }, - }, - }, - }, - '404': { description: 'The x402 facilitator is disabled.' }, - '503': { description: 'The resource catalog is temporarily unavailable.' }, - }, - }, - }, - '/api/paid/demo': { - get: { - tags: ['x402 Catalog'], - summary: 'Unlock a signed hello (x402 end-to-end demo)', - description: - 'Smallest payable resource. Use it to confirm the 402 challenge, JPYC payment and unlock work end to end before wiring a real paid API.', - 'x-payment-info': paymentInfo(firstPartyPrice('/api/paid/demo')), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'JPYC', - responses: { - '200': { - description: 'Unlock greeting after settlement', - content: { - 'application/json': { - example: { - message: 'Payment verified — welcome to the x402 + JPYC rail.', - paidAt: '2026-07-28T00:00:00.000Z', - }, - }, - }, - }, - ...PAID_RESPONSES, - }, - }, - }, - '/api/paid/stores': { - get: { - tags: ['x402 Catalog'], - summary: 'Unlock the curated JPYC acceptance directory', - description: - 'Curated JSON of exchanges, dApps and bridges that accept JPYC, with attribution.', - 'x-payment-info': paymentInfo(firstPartyPrice('/api/paid/stores')), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'JPYC', - responses: { - '200': { - description: 'Curated store list after settlement', - content: { - 'application/json': { - schema: schemaFromExample(USDC_STORES_BAZAAR.output.example), - example: USDC_STORES_BAZAAR.output.example, - }, - }, - }, - ...PAID_RESPONSES, - }, - }, - }, -} as const; - -const DISCOVERY_OPENAPI_SCHEMAS = { - DiscoveryItem: { - type: 'object', - required: [ - 'resource', - 'description', - 'category', - 'priceJpyc', - 'network', - 'accepts', - 'verifiedAt', - ], - properties: { - id: { type: 'string', description: 'Registry listing ID; present for registered seller resources.' }, - resource: { type: 'string', format: 'uri' }, - title: { type: 'string', description: 'Short display name (first-party, or seller-provided)' }, - trigger: { type: 'string', description: 'When to use this resource' }, - description: { type: 'string' }, - category: { type: 'string' }, - priceJpyc: { type: 'string', pattern: '^[1-9][0-9]*$' }, - docsUrl: { type: 'string', format: 'uri', pattern: '^https://', maxLength: 512 }, - license: { type: 'string', maxLength: 60 }, - updatedAt: { type: 'string', format: 'date-time' }, - official: { - type: 'boolean', - const: true, - description: 'Present only for first-party resources generated by OpenPay.', - }, - network: { type: 'string', description: 'CAIP-2 network identifier' }, - accepts: { - type: 'array', - description: 'Payable x402 requirements including the OpenPay fee extension.', - items: { type: 'object' }, - }, - verifiedAt: { - type: ['string', 'null'], - format: 'date-time', - description: 'Last successful OpenPay gate verification, or null when not yet verified.', - }, - }, - }, - DiscoveryEnvelope: { - type: 'object', - required: ['x402Version', 'items'], - properties: { - x402Version: { type: 'integer', const: 1 }, - items: { - type: 'array', - items: { $ref: '#/components/schemas/DiscoveryItem' }, - }, - }, - }, -} as const; - -// vanilla x402 (USDC/Base・外部 facilitator・OpenPay 手数料なし) の直接販売面。 -// JPYC facilitator の flag に依存しないため、doc が配信される限り常に載せる。 -// JPYC オンチェーン・ライブデータ (lib/jpyc/liveResources.ts が SoT・応答例も共用)。 -const JPYC_LIVE_402 = { - description: - 'Standard x402 payment challenge (USDC on Base, exact scheme, single transferWithAuthorization).', -}; -const JPYC_LIVE_503 = { - description: - 'All configured RPC endpoints failed for the requested chains. Nothing is settled; the buyer is not charged.', -}; -const JPYC_LIVE_400 = { description: 'Unknown query key, unsupported chain, or malformed address/limit.' }; -// enum は SoT (lib/chains.ts の JPYC_CHAINS) から導出する。literal で固定すると env flag -// (enableJpycAvalanche/enableJpycEthereum) 未点灯時に「宣言はあるが 400 になるチェーン」を -// 広告してしまう (E7: parseRequiredChainParam は JPYC_CHAINS にない値を拒否する)。 -const JPYC_CHAIN_LIST = [...JPYC_CHAINS]; -const JPYC_CHAIN_PARAM = { - name: 'chain', - in: 'query', - required: false, - schema: { type: 'string', enum: JPYC_CHAIN_LIST }, - description: `Chain to query. Omit to query all supported chains. Supported values: ${JPYC_CHAIN_LIST.join(', ')}.`, - example: 'polygon', -}; - -const VANILLA_OPENAPI_PATHS = { - [USDC_JPYC_SUPPLY.path]: { - get: { - tags: ['x402 Vanilla (USDC)', 'JPYC Live Data'], - operationId: USDC_JPYC_SUPPLY.operationId, - summary: USDC_JPYC_SUPPLY.summary, - description: `${USDC_JPYC_SUPPLY.description} ${agentUsageText(USDC_JPYC_SUPPLY.trigger)} Payment: standard x402 (exact scheme) in USDC on Base mainnet; no OpenPay fee is added.`, - parameters: [JPYC_CHAIN_PARAM], - 'x-agent-usage': USDC_JPYC_SUPPLY.trigger, - 'x-payment-info': usdcPaymentInfo(USDC_JPYC_SUPPLY.priceUsd), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'USDC', - 'x-payment-chains': usdcPaymentChains(), - responses: { - '200': { - description: 'Per-chain totalSupply after settlement (rows with status "error" are RPC failures on that chain only)', - content: { - 'application/json': { - schema: USDC_JPYC_SUPPLY.bazaar.output.schema, - example: USDC_JPYC_SUPPLY.bazaar.output.example, - }, - }, - }, - '400': JPYC_LIVE_400, - '402': JPYC_LIVE_402, - '503': JPYC_LIVE_503, - }, - }, - }, - [USDC_JPYC_BALANCE.path]: { - get: { - tags: ['x402 Vanilla (USDC)', 'JPYC Live Data'], - operationId: USDC_JPYC_BALANCE.operationId, - summary: USDC_JPYC_BALANCE.summary, - description: `${USDC_JPYC_BALANCE.description} ${agentUsageText(USDC_JPYC_BALANCE.trigger)} Payment: standard x402 (exact scheme) in USDC on Base mainnet; no OpenPay fee is added.`, - parameters: [ - { - name: 'address', - in: 'query', - required: true, - schema: { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' }, - description: 'EVM address to read the JPYC balance of.', - example: '0x52d4901142e2B5680027da5EB47C86CB02a3cA81', - }, - JPYC_CHAIN_PARAM, - ], - 'x-agent-usage': USDC_JPYC_BALANCE.trigger, - 'x-payment-info': usdcPaymentInfo(USDC_JPYC_BALANCE.priceUsd), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'USDC', - 'x-payment-chains': usdcPaymentChains(), - responses: { - '200': { - description: 'Per-chain balance after settlement', - content: { - 'application/json': { - schema: USDC_JPYC_BALANCE.bazaar.output.schema, - example: USDC_JPYC_BALANCE.bazaar.output.example, - }, - }, - }, - '400': JPYC_LIVE_400, - '402': JPYC_LIVE_402, - '503': JPYC_LIVE_503, - }, - }, - }, - [USDC_JPYC_TRANSFERS.path]: { - get: { - tags: ['x402 Vanilla (USDC)', 'JPYC Live Data'], - operationId: USDC_JPYC_TRANSFERS.operationId, - summary: USDC_JPYC_TRANSFERS.summary, - description: `${USDC_JPYC_TRANSFERS.description} The block window is fixed per chain (about one hour) to bound RPC cost. ${agentUsageText(USDC_JPYC_TRANSFERS.trigger)} Payment: standard x402 (exact scheme) in USDC on Base mainnet; no OpenPay fee is added.`, - parameters: [ - { - ...JPYC_CHAIN_PARAM, - required: true, - description: `Chain to scan. Supported values: ${JPYC_CHAIN_LIST.join(', ')}.`, - }, - { - name: 'limit', - in: 'query', - required: false, - schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, - description: - 'Maximum number of transfer events to return: snapshot newest first, cursor delta oldest first (1-100, default 20). This is not a page number.', - example: 20, - }, - { - name: 'address', - in: 'query', - required: false, - schema: { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' }, - description: 'Only transfers where this address is the sender or the recipient.', - }, - { - name: 'cursor', - in: 'query', - required: false, - schema: { type: 'string', pattern: '^[0-9]+:(?:-1|[0-9]+)$' }, - description: - 'Both snapshot and delta scan through max(0, raw head - depth), where depth is 64 blocks on Polygon/Ethereum; 2 blocks on Kaia/Avalanche. Deeper reorgs can invalidate cursors. The nextCursor value from a previous response (":"). Returns only transfers newer than that position, oldest first (mode=delta), returning each observed event once within the scanned window, assuming stable chain history; continue with nextCursor while hasMore is true. If the cursor is older than the scanned window, the response sets truncated=true. A cursor more than 64 blocks beyond the raw head is rejected with 400 cursor_ahead_of_head before settlement (tolerance is measured from the raw head, independently of confirmation depth). Otherwise, a cursor newer than the scan boundary returns no items and is echoed unchanged until the boundary catches up. Without a cursor (mode=snapshot) hasMore only means older events in the window were omitted; start monitoring from nextCursor.', - example: '92387695:286', - }, - ], - 'x-agent-usage': USDC_JPYC_TRANSFERS.trigger, - 'x-payment-info': usdcPaymentInfo(USDC_JPYC_TRANSFERS.priceUsd), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'USDC', - 'x-payment-chains': usdcPaymentChains(), - responses: { - '200': { - description: 'Newest-first Transfer events within the block window after settlement', - content: { - 'application/json': { - schema: USDC_JPYC_TRANSFERS.bazaar.output.schema, - example: USDC_JPYC_TRANSFERS.bazaar.output.example, - }, - }, - }, - '400': JPYC_LIVE_400, - '402': JPYC_LIVE_402, - '503': JPYC_LIVE_503, - }, - }, - }, -} as const; - -const JPYC_ACTIVITY_400 = { - description: 'Unknown or duplicate query key, unsupported chain, empty/invalid window, or missing chain with payment. No settlement.', -}; -const ACTIVITY_OPENAPI_PATHS = { - [USDC_JPYC_ACTIVITY.path]: { - get: { - tags: ['x402 Vanilla (USDC)', 'JPYC Live Data'], - operationId: USDC_JPYC_ACTIVITY.operationId, - summary: USDC_JPYC_ACTIVITY.summary, - description: USDC_JPYC_ACTIVITY.description + ' ' + agentUsageText(USDC_JPYC_ACTIVITY.trigger) + ' Payment: standard x402 in USDC on Base mainnet; no OpenPay fee is added.', - parameters: [ - { name: 'chain', in: 'query', required: true, schema: { type: 'string', enum: ACTIVITY_CHAINS } }, - { name: 'window', in: 'query', required: false, schema: { type: 'string', enum: ['24h'], default: '24h' } }, - ], - 'x-agent-usage': USDC_JPYC_ACTIVITY.trigger, - 'x-payment-info': usdcPaymentInfo(USDC_JPYC_ACTIVITY.priceUsd), - 'x-payment-protocol': 'x402', 'x-payment-asset': 'USDC', 'x-payment-chains': usdcPaymentChains(), - responses: { - '200': { - description: 'Complete aggregate from immutable finalized buckets after settlement; observedAt is the newest bucket timestamp and expiresAt is four hours later.', - content: { 'application/json': { - schema: USDC_JPYC_ACTIVITY.bazaar.output.schema, example: USDC_JPYC_ACTIVITY.bazaar.output.example, - } }, - }, - '400': JPYC_ACTIVITY_400, - '402': JPYC_LIVE_402, - '503': { - description: 'data_incomplete: a required bucket is missing. data_unavailable: KV is unavailable, malformed or overflowed, or the data timestamp is over 60 seconds in the future. data_stale: the newest bucket timestamp is more than four hours old. No settlement in every case.', - }, - }, - }, - }, - [USDC_JPYC_ATTEST.path]: { - get: { - tags: ['x402 Vanilla (USDC)', 'JPYC Live Data'], - operationId: USDC_JPYC_ATTEST.operationId, - summary: USDC_JPYC_ATTEST.summary, - description: USDC_JPYC_ATTEST.description + ' ' + agentUsageText(USDC_JPYC_ATTEST.trigger) + ' Payment: standard x402 in USDC on Base mainnet; no OpenPay fee is added.', - parameters: [ - { name: 'chain', in: 'query', required: true, schema: USDC_JPYC_ATTEST.bazaar.queryParamsSchema.properties.chain }, - { name: 'tx', in: 'query', required: true, schema: USDC_JPYC_ATTEST.bazaar.queryParamsSchema.properties.tx }, - ], - 'x-agent-usage': USDC_JPYC_ATTEST.trigger, - 'x-payment-info': usdcPaymentInfo(USDC_JPYC_ATTEST.priceUsd), - 'x-payment-protocol': 'x402', 'x-payment-asset': 'USDC', 'x-payment-chains': usdcPaymentChains(), - responses: { - '200': { - description: 'JPYC transfers with an optional EIP-712 signature. The signature is not a legal certification.', - content: { 'application/json': { - schema: USDC_JPYC_ATTEST.bazaar.output.schema, example: USDC_JPYC_ATTEST.bazaar.output.example, - } }, - }, - '400': { description: 'Invalid or duplicate query parameters; missing required parameters with payment. No settlement.' }, - '404': { description: 'tx_not_found: receipt not mined or absent. no_jpyc_transfer: reverted or no JPYC Transfer logs. No settlement.' }, - '402': JPYC_LIVE_402, - '503': { - description: 'RPC unavailable. No settlement.', - }, - }, - }, - }, - '/api/jpyc/activity/preview': { - get: { - tags: ['JPYC Live Data'], operationId: 'getJpycNetworkActivityPreview', - summary: 'Preview JPYC network activity availability and transfer count', - description: 'Free preview using the same finalized buckets and validity checks as the paid feed. Skip a purchase when observedAt is unchanged or expiresAt has passed. Unavailable data has a reason and no sample. No feature flag is required.', - parameters: [{ name: 'chain', in: 'query', required: false, schema: { type: 'string', enum: ACTIVITY_CHAINS, default: 'polygon' } }], - responses: { - '200': { - description: 'Stable available/unavailable envelope. Available cache freshness plus stale-while-revalidate is capped by expiresAt; unavailable responses cache for 60 seconds.', - content: { 'application/json': { schema: JPYC_ACTIVITY_PREVIEW_SCHEMA } }, - }, - '400': { description: 'Unknown or duplicate query key, or unsupported/empty chain.' }, - }, - }, - }, -} as const; - -// Monitor の **USDC レール**と無料 teaser 2 本は env.enableWeb3Directory だけに依存する -// (各 route の enableWeb3Directory チェックで 404 に倒れる。精算は外部 facilitator なので -// x402 facilitator flag とは無関係)。flag OFF でも掲載していると、実際には 404 する -// エンドポイントをインデクサに広告してしまう (E6)。 -// JPYC レールの 2 本は guardPaidDirectoryApi が **両 flag** を要求するため別集合 -// (JPYC_DIRECTORY_MONITOR_OPENAPI_PATHS) にする。 -const VANILLA_DIRECTORY_OPENAPI_PATHS = { - [USDC_SERVICE_MONITOR.path]: { - get: { - tags: ['x402 Vanilla (USDC)', 'Japan Web3 Directory'], - operationId: 'getJpycServiceMonitorUsdc', - summary: 'JPYC Service Monitor — weekly change feed (USDC on Base)', - description: `${USDC_SERVICE_MONITOR.description} Payment: standard x402 (exact scheme) in USDC on Base mainnet; no OpenPay fee is added.`, - parameters: SERVICE_MONITOR_PARAMS, - 'x-agent-usage': - 'Run on a weekly schedule. Before paying, GET the free /api/jpyc/services/teaser and compare latestRecordedAt (max(date, collectedAt), with date used when collectedAt is absent) with your stored nextChangedSince: if it is before that date, skip the purchase (the paid delta would be empty). Otherwise echo nextChangedSince as changedSince to pay only for deltas; dedupe by slug+date+changeType. When changes is empty, report "no significant change" — do not re-fetch the snapshot. Verify with each event sourceUrl before acting on a change.', - 'x-payment-info': usdcPaymentInfo(USDC_SERVICE_MONITOR.priceUsd), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'USDC', - 'x-payment-chains': usdcPaymentChains(), - responses: { - '200': { - description: 'Monitor snapshot or change delta after settlement', - content: { - 'application/json': { - schema: schemaFromExample(USDC_SERVICE_MONITOR_BAZAAR.output.example), - example: USDC_SERVICE_MONITOR_BAZAAR.output.example, - }, - }, - }, - '400': JPYC_LIVE_400, - '402': JPYC_LIVE_402, - '503': JPYC_LIVE_503, - }, - }, - }, - '/api/jpyc/services/teaser': { - get: { - tags: ['Japan Web3 Directory'], - summary: 'Free teaser of the JPYC Service Monitor (latest 3 change events)', - description: - 'Free, no payment: the 3 most recent change events from the JPYC Service Monitor, so an agent can inspect real output before buying — and skip the paid call when latestRecordedAt (max(date, collectedAt), with date used when collectedAt is absent) is before its stored nextChangedSince. The paid feed adds every event, the current monitor row per service, and changedSince deltas.', - responses: { - '200': { description: 'Latest 3 events plus pointers to the paid feed' }, - '404': { description: 'Feature disabled' }, - }, - }, - }, - '/api/stablecoin-payments/teaser': { - get: { - tags: ['Japan Web3 Directory'], - summary: 'Free teaser of the Japan Stablecoin Payment Monitor (latest 3 events)', - description: - 'Free, no payment: the 3 most recent payment-scope events, so an agent can inspect real output before buying — and skip the paid call when latestRecordedAt (max(date, collectedAt), with date used when collectedAt is absent) is before its stored nextChangedSince. The paid feed adds the full dated history and changedSince deltas.', - responses: { - '200': { description: 'Latest 3 events plus pointers to the paid feed' }, - '404': { description: 'Feature disabled' }, - }, - }, - }, - [USDC_PAYMENT_MONITOR.path]: { - get: { - tags: ['x402 Vanilla (USDC)', 'Japan Web3 Directory'], - operationId: 'getStablecoinPaymentMonitorUsdc', - summary: 'Japan Stablecoin Payment Monitor — weekly change feed (USDC on Base)', - description: `${USDC_PAYMENT_MONITOR.description} Payment: standard x402 (exact scheme) in USDC on Base mainnet; no OpenPay fee is added.`, - parameters: SERVICE_MONITOR_PARAMS, - 'x-agent-usage': - 'Run on a weekly schedule when monitoring Japanese stablecoin payment providers. Before paying, GET the free /api/stablecoin-payments/teaser and compare latestRecordedAt (max(date, collectedAt), with date used when collectedAt is absent) with your stored nextChangedSince: if it is before that date, skip the purchase (the paid delta would be empty). Otherwise echo nextChangedSince as changedSince; dedupe by provider+date+changeCategory. When changes is empty, report "no significant change". Verify with each event sourceUrl before acting on a change.', - 'x-payment-info': usdcPaymentInfo(USDC_PAYMENT_MONITOR.priceUsd), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'USDC', - 'x-payment-chains': usdcPaymentChains(), - responses: { - '200': { - description: 'Payment-scope change events after settlement', - content: { - 'application/json': { - schema: schemaFromExample(USDC_PAYMENT_MONITOR_BAZAAR.output.example), - example: USDC_PAYMENT_MONITOR_BAZAAR.output.example, - }, - }, - }, - '400': JPYC_LIVE_400, - '402': JPYC_LIVE_402, - '503': JPYC_LIVE_503, - }, - }, - }, -} as const; - -// JPYC レールの Monitor 2 本は guardPaidDirectoryApi (app/api/paid/japan-web3-directory/ -// _shared.ts) が **enableWeb3Directory かつ enableX402Facilitator** を要求する。directory -// flag だけで掲載すると、facilitator OFF の構成で 404 する有料エンドポイントを広告してしまう。 -const JPYC_DIRECTORY_MONITOR_OPENAPI_PATHS = { - '/api/paid/jpyc/services': { - get: { - tags: ['x402 (JPYC)', 'Japan Web3 Directory'], - operationId: 'getJpycServiceMonitor', - summary: 'JPYC Service Monitor — weekly change feed (JPYC)', - description: - 'Same data and contract as the USDC variant: dated change events (added / updated / removed / verified) for Japan-related JPYC/Web3 services, each tied to an official source URL. Pass changedSince=YYYY-MM-DD to fetch only what changed; an empty changes list explicitly means no change. Paid in JPYC via the OpenPay facilitator (buyer pays price + x402 facilitator fee).', - parameters: SERVICE_MONITOR_PARAMS, - 'x-agent-usage': - 'Run on a weekly schedule. Before paying, GET the free /api/jpyc/services/teaser and compare latestRecordedAt (max(date, collectedAt), with date used when collectedAt is absent) with your stored nextChangedSince: if it is before that date, skip the purchase (the paid delta would be empty). Otherwise echo nextChangedSince as changedSince; dedupe by slug+date+changeType. When changes is empty, report "no significant change".', - 'x-payment-info': paymentInfo(JPYC_SERVICES_RESOURCE.priceJpyc), - responses: { - '200': { - description: 'Monitor snapshot or change delta after settlement', - content: { - 'application/json': { - schema: schemaFromExample(USDC_SERVICE_MONITOR_BAZAAR.output.example), - example: USDC_SERVICE_MONITOR_BAZAAR.output.example, - }, - }, - }, - '400': JPYC_LIVE_400, - '402': { description: 'Payment required (x402 challenge with accepts)' }, - '503': JPYC_LIVE_503, - }, - }, - }, - '/api/paid/stablecoin-payments': { - get: { - tags: ['x402 (JPYC)', 'Japan Web3 Directory'], - operationId: 'getStablecoinPaymentMonitor', - summary: 'Japan Stablecoin Payment Monitor — weekly change feed (JPYC)', - description: - 'Same data and contract as the USDC variant: dated, categorized events (launches, pilots, partnerships, fee changes, closures) for stablecoin payment services in Japan, each tied to an official source URL. Pass changedSince=YYYY-MM-DD to fetch only new events; an empty changes list explicitly means no change. Paid in JPYC via the OpenPay facilitator (buyer pays price + x402 facilitator fee).', - parameters: SERVICE_MONITOR_PARAMS, - 'x-agent-usage': - 'Run on a weekly schedule when monitoring Japanese stablecoin payment providers. Before paying, GET the free /api/stablecoin-payments/teaser and compare latestRecordedAt (max(date, collectedAt), with date used when collectedAt is absent) with your stored nextChangedSince: if it is before that date, skip the purchase (the paid delta would be empty). Otherwise echo nextChangedSince as changedSince; report "no significant change" when changes is empty.', - 'x-payment-info': paymentInfo(JPYC_PAYMENTS_RESOURCE.priceJpyc), - responses: { - '200': { - description: 'Payment-scope change events after settlement', - content: { - 'application/json': { - schema: schemaFromExample(USDC_PAYMENT_MONITOR_BAZAAR.output.example), - example: USDC_PAYMENT_MONITOR_BAZAAR.output.example, - }, - }, - }, - '400': JPYC_LIVE_400, - '402': { description: 'Payment required (x402 challenge with accepts)' }, - '503': JPYC_LIVE_503, - }, - }, - }, -} as const; - -// USDC 版 stores は directory flag に依存しない (JPYC 受入先の別カタログ)。 -const VANILLA_STORES_OPENAPI_PATHS = { - [USDC_STORES.path]: { - get: { - tags: ['x402 Vanilla (USDC)'], - summary: 'Unlock the curated JPYC acceptance directory (USDC on Base)', - description: - 'Same data as /api/paid/stores, sold via standard x402 (exact scheme) in USDC on Base mainnet through an external facilitator. No OpenPay fee is added; the listed price is the full charge.', - 'x-payment-info': usdcPaymentInfo(USDC_STORES.priceUsd), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'USDC', - 'x-payment-chains': usdcPaymentChains(), - responses: { - '200': { - description: 'Curated store list after settlement', - content: { - 'application/json': { - schema: schemaFromExample(USDC_STORES_BAZAAR.output.example), - example: USDC_STORES_BAZAAR.output.example, - }, - }, - }, - '402': { - description: - 'Standard x402 payment challenge (USDC on Base, exact scheme, single transferWithAuthorization).', - }, - '503': { $ref: '#/components/responses/StorageUnavailable' }, - }, - }, - }, -} as const; - -// hello は価格が env (X402_PRICE) 由来のため、有効な USD 価格のときだけ載せる。 -function vanillaHelloPath(): Record { - const amount = helloUsdAmountOrNull(); - if (amount === null) return {}; - return { - '/api/paid/hello': { - get: { - tags: ['x402 Vanilla (USDC)'], - summary: 'Paid hello demo (USDC on Base)', - description: - 'Smallest standard-x402 payable resource: pay and unlock a hello + timestamp. Use it to confirm the 402 → pay → unlock flow end to end before wiring a real paid API.', - 'x-payment-info': usdcPaymentInfo(amount), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'USDC', - 'x-payment-chains': usdcPaymentChains(), - responses: { - '200': { - description: 'Hello + timestamp after settlement', - content: { - 'application/json': { - example: { - message: 'Hello, paid AI agent.', - timestamp: '2026-07-28T00:00:00.000Z', - }, - }, - }, - }, - '402': { - description: - 'Standard x402 payment challenge (USDC on Base, exact scheme, single transferWithAuthorization).', - }, - '503': { $ref: '#/components/responses/StorageUnavailable' }, - }, - }, - }, - }; -} - -const SHOPS_OPENAPI_PATHS = { - '/api/shops': { - get: { - tags: ['Shops Free'], - summary: 'Get the opt-in shop count and three fixed samples', - description: - 'The first three index entries are returned deterministically with name and mode only.', - responses: { - '200': { - description: 'Shop teaser envelope', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/ShopsTeaserEnvelope' }, - }, - }, - }, - '404': { $ref: '#/components/responses/NotFound' }, - '429': { $ref: '#/components/responses/RateLimited' }, - '503': { $ref: '#/components/responses/StorageUnavailable' }, - }, - }, - }, - '/api/shops/find': { - get: { - tags: ['Shops Free'], - summary: 'Find opt-in shops by name without payment', - description: - 'Returns handle, name, mode, and three-valued acceptingNow only. Address, hours, menu summary, dine-in filters, and live details remain in the paid search.', - parameters: SHOPS_FIND_QUERY_PARAMETERS, - responses: { - '200': { - description: 'Free shop discovery envelope', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/ShopsFindEnvelope' }, - }, - }, - }, - '400': { $ref: '#/components/responses/InvalidQuery' }, - '404': { $ref: '#/components/responses/NotFound' }, - '429': { $ref: '#/components/responses/RateLimited' }, - '503': { $ref: '#/components/responses/StorageUnavailable' }, - }, - }, - }, - '/api/paid/jpyc-shops/search': { - get: { - tags: ['Shops Paid'], - summary: 'Search opt-in shops and current JPYC ordering availability', - description: - 'A paid request snapshots the shop index and summaries before x402 verification and settlement. acceptingNow is true when all required checks pass, false for a definite stop condition, and null when live state or required legacy summary data is unavailable. Phone numbers are never returned.', - parameters: SHOPS_QUERY_PARAMETERS, - 'x-payment-info': paymentInfo(JPYC_SHOPS_SEARCH_RESOURCE.priceJpyc), - 'x-price-jpyc': Number(JPYC_SHOPS_SEARCH_RESOURCE.priceJpyc), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'JPYC', - 'x-payment-chains': ['Polygon', 'Polygon Amoy'], - responses: { - '200': { - description: 'Filtered shop search envelope after settlement', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/ShopsSearchEnvelope' }, - }, - }, - }, - '400': { $ref: '#/components/responses/InvalidQuery' }, - '402': { $ref: '#/components/responses/PaymentRequired' }, - '404': { $ref: '#/components/responses/NotFound' }, - '429': { $ref: '#/components/responses/RateLimited' }, - '503': { $ref: '#/components/responses/StorageUnavailable' }, - }, - }, - }, -} as const; - -const SHOPS_OPENAPI_SCHEMAS = { - ShopTeaser: { - type: 'object', - required: ['name', 'mode'], - additionalProperties: false, - properties: { - name: { type: 'string' }, - mode: { type: 'string', enum: ['storefront', 'preorder'] }, - }, - }, - ShopFindItem: { - type: 'object', - required: ['handle', 'name', 'mode', 'acceptingNow'], - additionalProperties: false, - properties: { - handle: { type: 'string' }, - name: { type: 'string' }, - mode: { type: 'string', enum: ['storefront', 'preorder'] }, - acceptingNow: { - type: ['boolean', 'null'], - description: - 'true: definitely accepting; false: a definite stop condition applies; null: live read failed or required legacy data is indeterminate.', - }, - }, - }, - ShopSearchItem: { - type: 'object', - required: [ - 'handle', - 'name', - 'mode', - 'dineIn', - 'acceptingNow', - 'menu', - 'chains', - 'pageUrl', - 'menuUrl', - 'live', - ], - properties: { - handle: { type: 'string' }, - name: { type: 'string' }, - tagline: { type: 'string' }, - address: { type: 'string' }, - mode: { type: 'string', enum: ['storefront', 'preorder'] }, - dineIn: { type: 'boolean' }, - acceptingNow: { - type: ['boolean', 'null'], - description: - 'true: definitely accepting; false: a definite stop condition applies; null: live read failed or required legacy data is indeterminate.', - }, - openFrom: { type: 'string', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }, - lastOrder: { type: 'string', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }, - minLeadMinutes: { type: 'integer', minimum: 1, maximum: 1440 }, - menu: { - type: 'object', - required: ['itemCount', 'minPrice', 'maxPrice'], - properties: { - itemCount: { type: 'integer', minimum: 1, maximum: 60 }, - minPrice: { type: 'string', description: 'JPYC decimal amount' }, - maxPrice: { type: 'string', description: 'JPYC decimal amount' }, - }, - }, - chains: { - type: 'array', - items: { - type: 'string', - enum: ['polygon', 'kaia', 'avalanche', 'ethereum'], - }, - }, - pageUrl: { type: 'string', format: 'uri' }, - menuUrl: { type: 'string' }, - live: { - oneOf: [ - { type: 'null' }, - { - type: 'object', - required: ['paused', 'soldOutCount', 'updatedAt'], - properties: { - paused: { type: 'boolean' }, - soldOutCount: { type: 'integer', minimum: 0 }, - updatedAt: { type: 'integer', minimum: 0 }, - }, - }, - ], - }, - }, - }, - ShopsTeaserEnvelope: { - allOf: [ - { $ref: '#/components/schemas/ShopsEnvelopeBase' }, - { - type: 'object', - properties: { - items: { - type: 'array', - maxItems: 3, - items: { $ref: '#/components/schemas/ShopTeaser' }, - }, - }, - }, - ], - }, - ShopsFindEnvelope: { - allOf: [ - { $ref: '#/components/schemas/ShopsEnvelopeBase' }, - { - type: 'object', - properties: { - items: { - type: 'array', - maxItems: 10, - items: { $ref: '#/components/schemas/ShopFindItem' }, - }, - }, - }, - ], - }, - ShopsSearchEnvelope: { - allOf: [ - { $ref: '#/components/schemas/ShopsEnvelopeBase' }, - { - type: 'object', - properties: { - items: { - type: 'array', - maxItems: 20, - items: { $ref: '#/components/schemas/ShopSearchItem' }, - }, - }, - }, - ], - }, - ShopsEnvelopeBase: { - type: 'object', - required: [ - 'schemaVersion', - 'query', - 'items', - 'total', - 'generatedAt', - 'dataFreshness', - 'licenseNotice', - 'attribution', - ], - properties: { - schemaVersion: { type: 'string', const: '1.0' }, - query: { type: 'object' }, - items: { type: 'array' }, - total: { type: 'integer', minimum: 0 }, - generatedAt: { type: 'string', format: 'date-time' }, - dataFreshness: { - type: 'object', - required: ['oldestUpdatedAt', 'newestUpdatedAt'], - properties: { - oldestUpdatedAt: { type: ['string', 'null'], format: 'date-time' }, - newestUpdatedAt: { type: ['string', 'null'], format: 'date-time' }, - }, - }, - licenseNotice: { - type: 'object', - required: ['ja', 'en'], - properties: { ja: { type: 'string' }, en: { type: 'string' } }, - }, - attribution: { - type: 'array', - uniqueItems: true, - items: { type: 'string' }, - }, - }, - }, -} as const; + JPYC_DIRECTORY_MONITOR_OPENAPI_PATHS, + VANILLA_DIRECTORY_OPENAPI_PATHS, +} from '@/lib/openapi/monitor'; +import { SHOPS_OPENAPI_PATHS, SHOPS_OPENAPI_SCHEMAS } from '@/lib/openapi/shops'; +import { VANILLA_STORES_OPENAPI_PATHS } from '@/lib/openapi/stores'; +import { VANILLA_OPENAPI_PATHS, vanillaHelloPath } from '@/lib/openapi/vanilla'; const OPENAPI_DOCUMENT = { openapi: '3.1.0', @@ -1210,562 +42,10 @@ const OPENAPI_DOCUMENT = { { name: 'Directory Free' }, { name: 'Directory Paid' }, ], - paths: { - '/api/directory': { - get: { - tags: ['Directory Free'], - summary: 'Get a free directory teaser', - description: - 'Returns full entry fields but forces limit to at most 5 and offset to 0 (any provided offset is ignored).', - parameters: DIRECTORY_QUERY_PARAMETERS, - responses: { - '200': { - description: 'Published directory entries', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/DirectoryEnvelope' }, - example: { - schemaVersion: '1.0', - query: { limit: 5, offset: 0 }, - items: [ - { - slug: 'jpyc', - name: 'JPYC', - nameJa: 'JPYC', - status: 'published', - sourceUrl: - 'https://corporate.jpyc.co.jp/news/posts/jpyc-ex-launch', - sourceType: 'official', - verifiedAt: '2026-07-13', - sourceCheckedAt: '2026-07-14T00:00:00.000Z', - sourceOk: true, - updatedAt: '2026-07-13', - attribution: 'JPYC株式会社', - facts: { - description: 'A source-verified factual summary.', - category: 'stablecoin', - tags: ['Japan', 'JPY', 'stablecoin'], - tokens: ['jpyc'], - chains: ['avalanche', 'ethereum', 'polygon'], - languages: ['ja'], - supportsJpyc: true, - supportsUsdc: false, - supportsX402: false, - supportsMcp: false, - }, - editorial: { - summaryJa: 'OpenPayが独自作成した紹介文です。', - summaryEn: 'An original editorial summary written by OpenPay.', - }, - }, - ], - total: 19, - generatedAt: '2026-07-13T00:00:00.000Z', - dataFreshness: { - oldest: '2026-07-13', - newestVerifiedAt: '2026-07-13', - oldestSourceCheckedAt: '2026-07-14T00:00:00.000Z', - }, - licenseNotice: - 'Directory metadata is informational; sourceOk is reachability only, not whether the information is true.', - attribution: ['JPYC株式会社'], - }, - }, - }, - }, - ...ERROR_RESPONSES, - }, - }, - }, - '/api/directory/categories': { - get: { - tags: ['Directory Free'], - summary: 'List published category counts', - responses: { - '200': { - description: 'Category counts', - content: { - 'application/json': { - example: { - schemaVersion: '1.0', - items: [{ category: 'wallet', count: 3 }], - total: 1, - generatedAt: '2026-07-13T00:00:00.000Z', - }, - }, - }, - }, - '404': ERROR_RESPONSES['404'], - '429': ERROR_RESPONSES['429'], - }, - }, - }, - '/api/directory/tags': { - get: { - tags: ['Directory Free'], - summary: 'List published tag counts', - responses: { - '200': { - description: 'Tag counts', - content: { - 'application/json': { - example: { - schemaVersion: '1.0', - items: [{ tag: 'x402', count: 6 }], - total: 1, - generatedAt: '2026-07-13T00:00:00.000Z', - }, - }, - }, - }, - '404': ERROR_RESPONSES['404'], - '429': ERROR_RESPONSES['429'], - }, - }, - }, - '/api/paid/japan-web3-directory': { - get: { - tags: ['Directory Paid'], - summary: 'Unlock the full published directory', - 'x-payment-info': paymentInfo(DIRECTORY_LIST_RESOURCE.priceJpyc), - 'x-price-jpyc': Number(DIRECTORY_LIST_RESOURCE.priceJpyc), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'JPYC', - 'x-payment-chains': ['Polygon', 'Polygon Amoy'], - responses: { - '200': { - description: 'Full published directory after settlement', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/DirectoryEnvelope' }, - }, - }, - }, - ...PAID_RESPONSES, - }, - }, - }, - '/api/paid/japan-web3-directory/search': { - get: { - tags: ['Directory Paid'], - summary: 'Search and unlock published directory results', - parameters: DIRECTORY_QUERY_PARAMETERS, - 'x-payment-info': paymentInfo(DIRECTORY_SEARCH_RESOURCE.priceJpyc), - 'x-price-jpyc': Number(DIRECTORY_SEARCH_RESOURCE.priceJpyc), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'JPYC', - 'x-payment-chains': ['Polygon', 'Polygon Amoy'], - responses: { - '200': { - description: 'Filtered directory envelope after settlement', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/DirectoryEnvelope' }, - }, - }, - }, - '400': ERROR_RESPONSES['400'], - ...PAID_RESPONSES, - }, - }, - }, - [USDC_DIRECTORY_LIST.path]: { - get: { - tags: ['Directory Paid'], - summary: 'Unlock the full published directory (USDC on Base)', - description: - 'Same data as /api/paid/japan-web3-directory, sold via standard x402 (exact scheme) in USDC on Base mainnet through an external facilitator. No OpenPay fee is added; the listed price is the full charge.', - 'x-payment-info': usdcPaymentInfo(USDC_DIRECTORY_LIST.priceUsd), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'USDC', - 'x-payment-chains': usdcPaymentChains(), - responses: { - '200': { - description: 'Full published directory after settlement', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/DirectoryEnvelope' }, - }, - }, - }, - '402': { - description: - 'Standard x402 payment challenge (USDC on Base, exact scheme, single transferWithAuthorization).', - }, - '404': { $ref: '#/components/responses/NotFound' }, - '503': { $ref: '#/components/responses/StorageUnavailable' }, - }, - }, - }, - [USDC_DIRECTORY_LICENSED.path]: { - get: { - tags: ['Directory Paid'], - summary: USDC_DIRECTORY_LICENSED.serviceName, - description: USDC_DIRECTORY_LICENSED.description, - 'x-payment-info': usdcPaymentInfo(USDC_DIRECTORY_LICENSED.priceUsd), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'USDC', - 'x-payment-chains': usdcPaymentChains(), - responses: { - '200': { - description: 'Full published directory after settlement', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/DirectoryLicensedEnvelope' }, - }, - }, - }, - '402': { - description: - 'Standard x402 payment challenge (USDC on Base, exact scheme, single transferWithAuthorization).', - }, - '404': { $ref: '#/components/responses/NotFound' }, - '503': { $ref: '#/components/responses/StorageUnavailable' }, - }, - }, - }, - [USDC_DIRECTORY_SEARCH.path]: { - get: { - tags: ['Directory Paid'], - summary: 'Search the published directory (USDC on Base)', - description: - 'Same filters as /api/paid/japan-web3-directory/search, sold via standard x402 (exact scheme) in USDC on Base mainnet through an external facilitator. No OpenPay fee is added; the listed price is the full charge.', - parameters: DIRECTORY_QUERY_PARAMETERS, - 'x-payment-info': usdcPaymentInfo(USDC_DIRECTORY_SEARCH.priceUsd), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'USDC', - 'x-payment-chains': usdcPaymentChains(), - responses: { - '200': { - description: 'Filtered directory envelope after settlement', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/DirectoryEnvelope' }, - }, - }, - }, - '400': { $ref: '#/components/responses/InvalidQuery' }, - '402': { - description: - 'Standard x402 payment challenge (USDC on Base, exact scheme, single transferWithAuthorization).', - }, - '404': { $ref: '#/components/responses/NotFound' }, - '503': { $ref: '#/components/responses/StorageUnavailable' }, - }, - }, - }, - '/api/paid/japan-web3-directory/{slug}': { - get: { - tags: ['Directory Paid'], - summary: 'Unlock one published directory entry', - description: - 'Unknown or non-published slugs return 404 before any payment challenge or settlement.', - parameters: [ - { - name: 'slug', - in: 'path', - required: true, - schema: { type: 'string' }, - }, - ], - // x-payment-info を意図的に付けない: この path は slug テンプレートなので、外部 - // インデクサが登録すると `{slug}` を実 URL として probe し必ず 404 になる。有料 - // カタログに載せるのは固定 URL の一覧/検索だけ (FIRST_PARTY_RESOURCES と同じ判断)。 - 'x-price-jpyc': Number(DIRECTORY_DETAIL_PRICE_JPYC), - 'x-payment-protocol': 'x402', - 'x-payment-asset': 'JPYC', - 'x-payment-chains': ['Polygon', 'Polygon Amoy'], - responses: { - '200': { - description: 'One-entry directory envelope after settlement', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/DirectoryEnvelope' }, - }, - }, - }, - ...PAID_RESPONSES, - }, - }, - }, - }, + paths: DIRECTORY_OPENAPI_PATHS, components: { - schemas: { - DirectoryEntry: { - type: 'object', - required: [ - 'slug', - 'name', - 'nameJa', - 'status', - 'sourceUrl', - 'sourceType', - 'verifiedAt', - 'updatedAt', - 'attribution', - 'facts', - 'editorial', - 'sourceCheckedAt', - 'sourceOk', - ], - properties: { - slug: { type: 'string' }, - name: { type: 'string' }, - nameJa: { type: 'string' }, - status: { type: 'string', const: 'published' }, - sourceUrl: { type: 'string', format: 'uri' }, - sourceType: { type: 'string', enum: ['official', 'manual'] }, - verifiedAt: { type: 'string', format: 'date' }, - updatedAt: { type: 'string', format: 'date' }, - sourceCheckedAt: { type: ['string', 'null'], format: 'date-time' }, - sourceOk: { - type: ['boolean', 'null'], - description: - 'Source URL reachability only; it does not establish whether the directory information is true. true = reachable (2xx/3xx), false = confirmed gone (404/410), null = indeterminate (no current result, bot protection, or transient failure).', - }, - attribution: { type: 'string' }, - facts: { - type: 'object', - required: [ - 'description', - 'category', - 'tags', - 'tokens', - 'chains', - 'languages', - 'supportsJpyc', - 'supportsUsdc', - 'supportsX402', - 'supportsMcp', - ], - properties: { - description: { type: 'string' }, - category: { type: 'string' }, - tags: { type: 'array', items: { type: 'string' } }, - tokens: { type: 'array', items: { type: 'string' } }, - chains: { type: 'array', items: { type: 'string' } }, - languages: { type: 'array', items: { type: 'string' } }, - supportsJpyc: { type: 'boolean' }, - supportsUsdc: { type: 'boolean' }, - supportsX402: { type: 'boolean' }, - supportsMcp: { type: 'boolean' }, - }, - }, - editorial: { - type: 'object', - required: ['summaryJa', 'summaryEn'], - properties: { - summaryJa: { type: 'string' }, - summaryEn: { type: 'string' }, - }, - }, - }, - }, - DirectoryLicensedEnvelope: { - type: 'object', - allOf: [ - { $ref: '#/components/schemas/DirectoryEnvelope' }, - { - type: 'object', - required: ['license', 'attestation', 'signer', 'verify'], - properties: { - license: { - type: 'object', - required: ['id', 'name', 'url', 'licensee', 'issuedAt', 'grants', 'requires', 'prohibits'], - properties: { - id: { type: 'string', const: 'openpay-directory-license-v1' }, - name: { type: 'string', const: 'OpenPay Directory Data License v1' }, - url: { type: 'string', format: 'uri' }, - licensee: { type: ['string', 'null'], pattern: '^0x[0-9a-fA-F]{40}$' }, - issuedAt: { type: 'string', format: 'date-time' }, - grants: { type: 'array', items: { type: 'string' } }, - requires: { type: 'array', items: { type: 'string' } }, - prohibits: { type: 'array', items: { type: 'string' } }, - }, - }, - attestation: { - type: ['object', 'null'], - required: ['message', 'signature'], - properties: { - message: { - type: 'object', - required: ['licensee', 'licenseId', 'contentHash', 'rows', 'issuedAt'], - properties: { - licensee: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$', description: 'Zero address when the payer is unknown.' }, - licenseId: { type: 'string', const: 'openpay-directory-license-v1' }, - contentHash: { type: 'string', pattern: '^0x[0-9a-fA-F]{64}$', description: 'keccak256 of UTF-8 JSON.stringify(items), preserving array order.' }, - rows: { type: 'integer', minimum: 0 }, - issuedAt: { type: 'integer', minimum: 0, description: 'Unix seconds.' }, - }, - }, - signature: { type: 'string', pattern: '^0x[0-9a-fA-F]{130}$' }, - }, - }, - signer: { type: ['string', 'null'], pattern: '^0x[0-9a-fA-F]{40}$' }, - verify: { - type: 'object', - required: ['method', 'domain', 'types'], - properties: { - method: { type: 'string', const: 'EIP-712 recoverTypedDataAddress' }, - domain: { - type: 'object', - required: ['name', 'version'], - additionalProperties: false, - properties: { - name: { type: 'string', const: 'OpenPay Directory License' }, - version: { type: 'string', const: '1' }, - }, - }, - types: { - type: 'object', - required: ['DirectoryLicense'], - additionalProperties: false, - properties: { - DirectoryLicense: { - type: 'array', - const: [ - { name: 'licensee', type: 'address' }, - { name: 'licenseId', type: 'string' }, - { name: 'contentHash', type: 'bytes32' }, - { name: 'rows', type: 'uint256' }, - { name: 'issuedAt', type: 'uint256' }, - ], - items: { - type: 'object', - required: ['name', 'type'], - properties: { name: { type: 'string' }, type: { type: 'string' } }, - }, - }, - }, - }, - }, - }, - }, - }, - ], - }, - DirectoryEnvelope: { - type: 'object', - required: [ - 'schemaVersion', - 'query', - 'items', - 'total', - 'generatedAt', - 'dataFreshness', - 'licenseNotice', - 'attribution', - ], - properties: { - schemaVersion: { type: 'string', const: '1.0' }, - query: { type: 'object' }, - items: { - type: 'array', - items: { $ref: '#/components/schemas/DirectoryEntry' }, - }, - total: { type: 'integer', minimum: 0 }, - generatedAt: { type: 'string', format: 'date-time' }, - dataFreshness: { - type: 'object', - required: ['oldest', 'newestVerifiedAt', 'oldestSourceCheckedAt'], - properties: { - oldest: { type: ['string', 'null'], format: 'date' }, - newestVerifiedAt: { type: ['string', 'null'], format: 'date' }, - oldestSourceCheckedAt: { - type: ['string', 'null'], - format: 'date-time', - }, - }, - }, - licenseNotice: { - type: 'string', - description: - 'sourceOk reports source URL reachability only, not whether the information is true.', - }, - attribution: { - type: 'array', - uniqueItems: true, - items: { type: 'string' }, - }, - }, - }, - Error: { - type: 'object', - required: ['ok', 'error'], - properties: { - ok: { type: 'boolean', const: false }, - error: { - type: 'string', - enum: [ - 'invalid_query', - 'not_found', - 'rate_limited', - 'storage_unavailable', - ], - }, - }, - }, - }, - responses: { - InvalidQuery: { - description: 'A query value is outside the documented allowlist.', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/Error' }, - example: { ok: false, error: 'invalid_query' }, - }, - }, - }, - NotFound: { - description: 'Feature disabled, slug absent, or entry not published.', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/Error' }, - example: { ok: false, error: 'not_found' }, - }, - }, - }, - RateLimited: { - description: 'Best-effort per-IP request limit exceeded.', - headers: { - 'Retry-After': { schema: { type: 'integer' }, description: 'Seconds' }, - }, - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/Error' }, - example: { ok: false, error: 'rate_limited' }, - }, - }, - }, - PaymentRequired: { - description: - 'x402 payment challenge. Amount is denominated in JPYC on Polygon or Polygon Amoy; the existing buyer-added facilitator fee is included in maxAmountRequired.', - headers: { - 'PAYMENT-REQUIRED': { - schema: { type: 'string' }, - description: 'Base64-encoded x402 v2 payment requirements.', - }, - }, - content: { - 'application/json': { - example: { - x402Version: 1, - accepts: [ - { - scheme: 'exact', - network: 'eip155:137', - resource: - 'https://open-pay.jp/api/paid/japan-web3-directory', - maxAmountRequired: '3000000000000000000', - asset: 'JPYC', - }, - ], - error: 'payment_required', - }, - }, - }, - }, - }, + schemas: BASE_OPENAPI_SCHEMAS, + responses: BASE_OPENAPI_RESPONSES, }, } as const; diff --git a/lib/openapi/license.ts b/lib/openapi/license.ts new file mode 100644 index 00000000..ca11a2f6 --- /dev/null +++ b/lib/openapi/license.ts @@ -0,0 +1,65 @@ +// 利用ライセンス NFT (ENABLE_CREATOR_STORE かつ ENABLE_LICENSE_NFT) の公開 operation。 +// 掲載可否は lib/openapi/document.ts の buildOpenApiDocument が文書生成ごとに判定する。 + +import { schemaFromExample } from '@/lib/openapi/schema'; + +export const LICENSE_OPENAPI_PATHS = { + '/api/license/metadata/{id}': { + get: { + operationId: 'licenseMetadata', tags: ['Licenses'], security: [], + summary: 'ERC-1155 wallet metadata for a registered license product', + description: 'Public JSON with Japanese product name, image and terms. Feature OFF, invalid/unknown/unregistered products or no public seller handle return 404. Paused licenses remain resolvable.', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', pattern: '^h_[0-9a-f]{32}$' } }], + responses: { + '200': { description: 'ERC-1155 metadata with OpenSea attributes', + headers: { 'Cache-Control': { schema: { type: 'string', const: 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' } } }, + content: { 'application/json': { schema: { + type: 'object', + properties: { + ...(schemaFromExample({ name: '利用ライセンス', description: '利用条件: https://seller.example/terms (v1)', image: 'https://open-pay.jp/og/handle?h=seller&locale=ja', external_url: 'https://open-pay.jp/@seller?product=h_4fa999236d92e95a76bb36dcd7446208' }).properties as Record), + attributes: { type: 'array', items: { type: 'object', + properties: { trait_type: { type: 'string' }, value: { type: ['string', 'integer'] } } } }, + }, + } } } }, + '404': { description: 'License metadata unavailable' }, + '503': { description: 'Product or handle storage unavailable' }, + }, + }, + }, + '/api/license/products/{id}': { + get: { + operationId: 'resolveLicense', tags: ['Licenses'], security: [], + summary: 'Resolve a license product to its immutable ERC-1155 identity', + description: 'Public HTTPS descriptor; feature OFF, unknown/digital products or no public seller handle return 404. Paused licenses remain resolvable. Stock is display-only and can be null.', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', pattern: '^h_[0-9a-f]{32}$' } }], + responses: { + '200': { description: 'Version 1 license product descriptor', + headers: { 'Cache-Control': { schema: { type: 'string', const: 'public, s-maxage=60, stale-while-revalidate=300' } } }, + content: { 'application/json': { schema: { $ref: '#/components/schemas/LicenseDescriptor' } } } }, + '400': { description: 'Invalid product ID; rejected before IO' }, + '404': { description: 'License unavailable' }, + '429': { description: 'Trusted-IP rate limit exceeded', headers: { 'Retry-After': { schema: { type: 'string', const: '60' } } } }, + '503': { description: 'Product or handle storage unavailable' }, + }, + }, + }, + '/api/license/verify': { + get: { + operationId: 'verifyLicense', tags: ['Licenses'], security: [], + summary: 'Read purchase or holder rights for a wallet and license product', + description: 'Public HTTPS status, not authentication or signed proof. entitled:null means unknown. Feature OFF returns 404. 30 requests per trusted IP per minute; bounded RPC budget.', + parameters: [ + { name: 'address', in: 'query', required: true, schema: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' } }, + { name: 'product', in: 'query', required: true, schema: { type: 'string', pattern: '^h_[0-9a-f]{32}$' } }, + ], + responses: { + '200': { description: 'Version 1 status; unknown is entitled:null', headers: { 'Cache-Control': { schema: { type: 'string', const: 'no-store' } } }, + content: { 'application/json': { schema: { $ref: '#/components/schemas/LicenseVerification' } } } }, + '400': { description: 'Invalid or duplicated selectors; rejected before IO' }, + '404': { description: 'License unavailable' }, + '429': { description: 'Trusted-IP rate limit exceeded', headers: { 'Retry-After': { schema: { type: 'string', const: '60' } } } }, + '503': { description: 'Product storage unavailable' }, + }, + }, + }, +} as const; diff --git a/lib/openapi/monitor.ts b/lib/openapi/monitor.ts new file mode 100644 index 00000000..d94e43cc --- /dev/null +++ b/lib/openapi/monitor.ts @@ -0,0 +1,189 @@ +// Japan Web3 Directory の Monitor 2 商品 (JPYC Service Monitor / Japan Stablecoin Payment Monitor) +// の USDC レール・JPYC レールと無料 teaser。6 本の説明 (x-agent-usage・teaser description) は +// provider / dedupe キー / 検証手順の文言がそれぞれ違うため、共通文に括り出さない。 + +import { + JPYC_PAYMENTS_RESOURCE, + JPYC_SERVICES_RESOURCE, +} from '@/lib/directory/paidResources'; +import { + USDC_PAYMENT_MONITOR, + USDC_PAYMENT_MONITOR_BAZAAR, + USDC_SERVICE_MONITOR, + USDC_SERVICE_MONITOR_BAZAAR, +} from '@/lib/directory/usdcResource'; +import { paymentInfo, usdcPaymentChains, usdcPaymentInfo } from '@/lib/openapi/payment'; +import { JPYC_LIVE_400, JPYC_LIVE_402, JPYC_LIVE_503, schemaFromExample } from '@/lib/openapi/schema'; + +// JPYC Service Monitor (両通貨版共通)。実装 (lib/directory/serviceMonitor.ts の +// parseServiceMonitorQuery) と一致させる — ずれるとエージェントが 400 を踏む。 +const SERVICE_MONITOR_PARAMS = [ + { + name: 'changedSince', + in: 'query', + required: false, + schema: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, + description: + 'Return only change events whose effective date max(date, collectedAt ?? date) is on/after this date (YYYY-MM-DD, inclusive). Echo the nextChangedSince value from your previous response. Omit for the full monitor snapshot (mode=snapshot).', + example: '2026-08-20', + }, + { + name: 'limit', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 200, default: 200 }, + description: + 'Maximum number of change events to return. In delta mode (changedSince set) the limit is rounded up to an effective-date boundary (max(date, collectedAt ?? date)): events with the same effective date are never split, even if the group exceeds limit. While hasMore is true, call again with changedSince set to nextChangedSince.', + }, +] as const; + +// Monitor の **USDC レール**と無料 teaser 2 本は env.enableWeb3Directory だけに依存する +// (各 route の enableWeb3Directory チェックで 404 に倒れる。精算は外部 facilitator なので +// x402 facilitator flag とは無関係)。flag OFF でも掲載していると、実際には 404 する +// エンドポイントをインデクサに広告してしまう (E6)。 +// JPYC レールの 2 本は guardPaidDirectoryApi が **両 flag** を要求するため別集合 +// (JPYC_DIRECTORY_MONITOR_OPENAPI_PATHS) にする。 +export const VANILLA_DIRECTORY_OPENAPI_PATHS = { + [USDC_SERVICE_MONITOR.path]: { + get: { + tags: ['x402 Vanilla (USDC)', 'Japan Web3 Directory'], + operationId: 'getJpycServiceMonitorUsdc', + summary: 'JPYC Service Monitor — weekly change feed (USDC on Base)', + description: `${USDC_SERVICE_MONITOR.description} Payment: standard x402 (exact scheme) in USDC on Base mainnet; no OpenPay fee is added.`, + parameters: SERVICE_MONITOR_PARAMS, + 'x-agent-usage': + 'Run on a weekly schedule. Before paying, GET the free /api/jpyc/services/teaser and compare latestRecordedAt (max(date, collectedAt), with date used when collectedAt is absent) with your stored nextChangedSince: if it is before that date, skip the purchase (the paid delta would be empty). Otherwise echo nextChangedSince as changedSince to pay only for deltas; dedupe by slug+date+changeType. When changes is empty, report "no significant change" — do not re-fetch the snapshot. Verify with each event sourceUrl before acting on a change.', + 'x-payment-info': usdcPaymentInfo(USDC_SERVICE_MONITOR.priceUsd), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'USDC', + 'x-payment-chains': usdcPaymentChains(), + responses: { + '200': { + description: 'Monitor snapshot or change delta after settlement', + content: { + 'application/json': { + schema: schemaFromExample(USDC_SERVICE_MONITOR_BAZAAR.output.example), + example: USDC_SERVICE_MONITOR_BAZAAR.output.example, + }, + }, + }, + '400': JPYC_LIVE_400, + '402': JPYC_LIVE_402, + '503': JPYC_LIVE_503, + }, + }, + }, + '/api/jpyc/services/teaser': { + get: { + tags: ['Japan Web3 Directory'], + summary: 'Free teaser of the JPYC Service Monitor (latest 3 change events)', + description: + 'Free, no payment: the 3 most recent change events from the JPYC Service Monitor, so an agent can inspect real output before buying — and skip the paid call when latestRecordedAt (max(date, collectedAt), with date used when collectedAt is absent) is before its stored nextChangedSince. The paid feed adds every event, the current monitor row per service, and changedSince deltas.', + responses: { + '200': { description: 'Latest 3 events plus pointers to the paid feed' }, + '404': { description: 'Feature disabled' }, + }, + }, + }, + '/api/stablecoin-payments/teaser': { + get: { + tags: ['Japan Web3 Directory'], + summary: 'Free teaser of the Japan Stablecoin Payment Monitor (latest 3 events)', + description: + 'Free, no payment: the 3 most recent payment-scope events, so an agent can inspect real output before buying — and skip the paid call when latestRecordedAt (max(date, collectedAt), with date used when collectedAt is absent) is before its stored nextChangedSince. The paid feed adds the full dated history and changedSince deltas.', + responses: { + '200': { description: 'Latest 3 events plus pointers to the paid feed' }, + '404': { description: 'Feature disabled' }, + }, + }, + }, + [USDC_PAYMENT_MONITOR.path]: { + get: { + tags: ['x402 Vanilla (USDC)', 'Japan Web3 Directory'], + operationId: 'getStablecoinPaymentMonitorUsdc', + summary: 'Japan Stablecoin Payment Monitor — weekly change feed (USDC on Base)', + description: `${USDC_PAYMENT_MONITOR.description} Payment: standard x402 (exact scheme) in USDC on Base mainnet; no OpenPay fee is added.`, + parameters: SERVICE_MONITOR_PARAMS, + 'x-agent-usage': + 'Run on a weekly schedule when monitoring Japanese stablecoin payment providers. Before paying, GET the free /api/stablecoin-payments/teaser and compare latestRecordedAt (max(date, collectedAt), with date used when collectedAt is absent) with your stored nextChangedSince: if it is before that date, skip the purchase (the paid delta would be empty). Otherwise echo nextChangedSince as changedSince; dedupe by provider+date+changeCategory. When changes is empty, report "no significant change". Verify with each event sourceUrl before acting on a change.', + 'x-payment-info': usdcPaymentInfo(USDC_PAYMENT_MONITOR.priceUsd), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'USDC', + 'x-payment-chains': usdcPaymentChains(), + responses: { + '200': { + description: 'Payment-scope change events after settlement', + content: { + 'application/json': { + schema: schemaFromExample(USDC_PAYMENT_MONITOR_BAZAAR.output.example), + example: USDC_PAYMENT_MONITOR_BAZAAR.output.example, + }, + }, + }, + '400': JPYC_LIVE_400, + '402': JPYC_LIVE_402, + '503': JPYC_LIVE_503, + }, + }, + }, +} as const; + +// JPYC レールの Monitor 2 本は guardPaidDirectoryApi (app/api/paid/japan-web3-directory/ +// _shared.ts) が **enableWeb3Directory かつ enableX402Facilitator** を要求する。directory +// flag だけで掲載すると、facilitator OFF の構成で 404 する有料エンドポイントを広告してしまう。 +export const JPYC_DIRECTORY_MONITOR_OPENAPI_PATHS = { + '/api/paid/jpyc/services': { + get: { + tags: ['x402 (JPYC)', 'Japan Web3 Directory'], + operationId: 'getJpycServiceMonitor', + summary: 'JPYC Service Monitor — weekly change feed (JPYC)', + description: + 'Same data and contract as the USDC variant: dated change events (added / updated / removed / verified) for Japan-related JPYC/Web3 services, each tied to an official source URL. Pass changedSince=YYYY-MM-DD to fetch only what changed; an empty changes list explicitly means no change. Paid in JPYC via the OpenPay facilitator (buyer pays price + x402 facilitator fee).', + parameters: SERVICE_MONITOR_PARAMS, + 'x-agent-usage': + 'Run on a weekly schedule. Before paying, GET the free /api/jpyc/services/teaser and compare latestRecordedAt (max(date, collectedAt), with date used when collectedAt is absent) with your stored nextChangedSince: if it is before that date, skip the purchase (the paid delta would be empty). Otherwise echo nextChangedSince as changedSince; dedupe by slug+date+changeType. When changes is empty, report "no significant change".', + 'x-payment-info': paymentInfo(JPYC_SERVICES_RESOURCE.priceJpyc), + responses: { + '200': { + description: 'Monitor snapshot or change delta after settlement', + content: { + 'application/json': { + schema: schemaFromExample(USDC_SERVICE_MONITOR_BAZAAR.output.example), + example: USDC_SERVICE_MONITOR_BAZAAR.output.example, + }, + }, + }, + '400': JPYC_LIVE_400, + '402': { description: 'Payment required (x402 challenge with accepts)' }, + '503': JPYC_LIVE_503, + }, + }, + }, + '/api/paid/stablecoin-payments': { + get: { + tags: ['x402 (JPYC)', 'Japan Web3 Directory'], + operationId: 'getStablecoinPaymentMonitor', + summary: 'Japan Stablecoin Payment Monitor — weekly change feed (JPYC)', + description: + 'Same data and contract as the USDC variant: dated, categorized events (launches, pilots, partnerships, fee changes, closures) for stablecoin payment services in Japan, each tied to an official source URL. Pass changedSince=YYYY-MM-DD to fetch only new events; an empty changes list explicitly means no change. Paid in JPYC via the OpenPay facilitator (buyer pays price + x402 facilitator fee).', + parameters: SERVICE_MONITOR_PARAMS, + 'x-agent-usage': + 'Run on a weekly schedule when monitoring Japanese stablecoin payment providers. Before paying, GET the free /api/stablecoin-payments/teaser and compare latestRecordedAt (max(date, collectedAt), with date used when collectedAt is absent) with your stored nextChangedSince: if it is before that date, skip the purchase (the paid delta would be empty). Otherwise echo nextChangedSince as changedSince; report "no significant change" when changes is empty.', + 'x-payment-info': paymentInfo(JPYC_PAYMENTS_RESOURCE.priceJpyc), + responses: { + '200': { + description: 'Payment-scope change events after settlement', + content: { + 'application/json': { + schema: schemaFromExample(USDC_PAYMENT_MONITOR_BAZAAR.output.example), + example: USDC_PAYMENT_MONITOR_BAZAAR.output.example, + }, + }, + }, + '400': JPYC_LIVE_400, + '402': { description: 'Payment required (x402 challenge with accepts)' }, + '503': JPYC_LIVE_503, + }, + }, + }, +} as const; diff --git a/lib/openapi/payment.ts b/lib/openapi/payment.ts new file mode 100644 index 00000000..83c10ee1 --- /dev/null +++ b/lib/openapi/payment.ts @@ -0,0 +1,86 @@ +// OpenAPI の支払い情報 (x-payment-info / x-payment-chains) を組み立てる helper。 +// 金額は 402 チャレンジと同じ SoT (FIRST_PARTY_RESOURCES・x402FeeBreakdown・x402Config) から +// 導出する (literal を書くと掟 14 のドリフト源になるため)。lib/openapi/document.ts を import しない。 +// +// 評価時点: 領域 module の定数 (xxx_OPENAPI_PATHS) から呼ばれる分は module 読み込み時に確定し、 +// vanillaHelloPath() から呼ばれる分だけが文書生成ごとに評価される (分割前と同じ)。定数を関数化 +// したり逆にしたりすると公開文書が変わる (tests/lib/openapi/documentGolden.test.ts の late-mutation)。 + +import { x402Config } from '@/lib/x402/config'; +import { FIRST_PARTY_RESOURCES } from '@/lib/x402/firstParty'; +import { x402FeeBreakdown } from '@/lib/x402/fee'; +import { x402FacilitatorConfig } from '@/lib/x402/facilitatorConfig'; +import { caip2ForChainId } from '@/lib/x402/network'; + +const JPYC_WEI = 10n ** 18n; + +/** atomic JPYC → 小数文字列 (末尾 0 を落とす)。表示ではなく機械可読面の金額に使う。 */ +function formatJpyc(wei: bigint): string { + const int = wei / JPYC_WEI; + const frac = wei % JPYC_WEI; + if (frac === 0n) return int.toString(); + return `${int}.${frac.toString().padStart(18, '0').replace(/0+$/, '')}`; +} + +/** 価格はカタログ (FIRST_PARTY_RESOURCES) が権威。スペック側に literal を持たない。 */ +export function firstPartyPrice(path: string): string { + const resource = FIRST_PARTY_RESOURCES.find((r) => r.path === path); + if (!resource) { + throw new Error(`openapi: unknown first-party resource ${path}`); + } + return resource.priceJpyc; +} + +// JPYC は 1 JPYC = 1 円のペッグなので ISO 4217 の JPY で表現できる。amount は買い手が実際に +// 署名する総額 (資源価格 + 買い手上乗せの facilitator 手数料) = 402 の maxAmountRequired と一致。 +export function paymentInfo(priceJpyc: string) { + const { total } = x402FeeBreakdown(BigInt(priceJpyc) * JPYC_WEI); + return { + price: { currency: 'JPY', mode: 'fixed', amount: formatJpyc(total) }, + protocols: [ + { + x402: { + scheme: 'exact', + network: caip2ForChainId(x402FacilitatorConfig.chainId), + asset: 'JPYC', + }, + }, + ], + } as const; +} + +// Arc rail (ENABLE_X402_ARC_GATEWAY・DEPLOY_CHECKLIST §14.8) が ON のとき、first-party の USDC 有料 API は +// Arc の USDC (Circle Gateway x402 facilitator) でも払える。402 の v2 accepts と機械可読面を一致させるため、 +// flag に連動して 2 つ目の protocol と chain を載せる (OFF なら従来と 1 バイトも変わらない)。 +// network は Base と同じく本番 (servers = open-pay.jp) の mainnet 固定。 +function arcRailEnabled(): boolean { + return x402Config.arcGateway.enabled; +} + +export function usdcPaymentChains(): string[] { + return arcRailEnabled() ? ['Base', 'Arc'] : ['Base']; +} + +// vanilla x402 (USDC/Base) 直接販売用。JPYC 版と違い OpenPay 手数料が乗らないため、 +// amount は表示価格そのもの。network は本番 (servers = open-pay.jp) の Base mainnet 固定。 +export function usdcPaymentInfo(amountUsd: string) { + return { + price: { currency: 'USD', mode: 'fixed', amount: amountUsd }, + protocols: [ + { x402: { scheme: 'exact', network: 'eip155:8453', asset: 'USDC' } }, + ...(arcRailEnabled() + ? [ + { + x402: { + scheme: 'exact', + network: 'eip155:5042', + asset: 'USDC', + facilitator: 'circle-gateway', + extra: { name: 'GatewayWalletBatched', version: '1' }, + }, + }, + ] + : []), + ], + }; +} diff --git a/lib/openapi/schema.ts b/lib/openapi/schema.ts new file mode 100644 index 00000000..f2bc7e88 --- /dev/null +++ b/lib/openapi/schema.ts @@ -0,0 +1,47 @@ +// OpenAPI の schema / 応答の共有部品。応答例から JSON Schema を導出する helper と、 +// 複数の領域 module が同じ参照を共有する応答断片を置く。lib/openapi/document.ts を import しない。 + +/** + * 応答例から JSON Schema (型のみ) を導出する。Circle Agent Marketplace は「OpenAPI で入出力が + * 読めること」を掲載条件にするため (2026-09-11)、example だけだった 200 応答に schema を添える。 + * 例と型が食い違わないよう手書きせず example から機械的に作る (nullable/enum は付けない)。 + */ +export function schemaFromExample(example: unknown): Record { + if (Array.isArray(example)) { + return { type: 'array', ...(example.length ? { items: schemaFromExample(example[0]) } : {}) }; + } + if (example !== null && typeof example === 'object') { + const properties: Record = {}; + for (const [key, value] of Object.entries(example as Record)) { + properties[key] = schemaFromExample(value); + } + return { type: 'object', properties }; + } + if (typeof example === 'number') return { type: Number.isInteger(example) ? 'integer' : 'number' }; + if (typeof example === 'boolean') return { type: 'boolean' }; + return { type: 'string' }; +} + +export const ERROR_RESPONSES = { + '400': { $ref: '#/components/responses/InvalidQuery' }, + '404': { $ref: '#/components/responses/NotFound' }, + '429': { $ref: '#/components/responses/RateLimited' }, + '503': { $ref: '#/components/responses/StorageUnavailable' }, +} as const; + +export const PAID_RESPONSES = { + '402': { $ref: '#/components/responses/PaymentRequired' }, + '404': { $ref: '#/components/responses/NotFound' }, + '503': { $ref: '#/components/responses/StorageUnavailable' }, +} as const; + +// JPYC オンチェーン・ライブデータ (lib/jpyc/liveResources.ts が SoT・応答例も共用)。 +export const JPYC_LIVE_402 = { + description: + 'Standard x402 payment challenge (USDC on Base, exact scheme, single transferWithAuthorization).', +}; +export const JPYC_LIVE_503 = { + description: + 'All configured RPC endpoints failed for the requested chains. Nothing is settled; the buyer is not charged.', +}; +export const JPYC_LIVE_400 = { description: 'Unknown query key, unsupported chain, or malformed address/limit.' }; diff --git a/lib/openapi/shops.ts b/lib/openapi/shops.ts new file mode 100644 index 00000000..c6b6cf2f --- /dev/null +++ b/lib/openapi/shops.ts @@ -0,0 +1,300 @@ +// JPYC Shops API (無料 teaser / find と有料 search) の operation と schema。 +// 掲載は shopsApiEnabled() に連動する (lib/openapi/document.ts の buildOpenApiDocument)。 + +import { JPYC_SHOPS_SEARCH_RESOURCE } from '@/lib/shops/paidResources'; +import { paymentInfo } from '@/lib/openapi/payment'; + +const SHOPS_QUERY_PARAMETERS = [ + { + name: 'q', + in: 'query', + description: 'Case-insensitive partial match against name, tagline, or address.', + schema: { type: 'string', maxLength: 100 }, + }, + { + name: 'mode', + in: 'query', + schema: { type: 'string', enum: ['storefront', 'preorder'] }, + }, + { name: 'dineIn', in: 'query', schema: { type: 'boolean' } }, + { + name: 'acceptingNow', + in: 'query', + description: + 'When true, only definitely accepting shops are returned; indeterminate null values are excluded.', + schema: { type: 'boolean' }, + }, + { + name: 'limit', + in: 'query', + schema: { type: 'integer', minimum: 1, maximum: 20, default: 20 }, + }, + { + name: 'offset', + in: 'query', + schema: { type: 'integer', minimum: 0, maximum: 1000, default: 0 }, + }, +] as const; + +const SHOPS_FIND_QUERY_PARAMETERS = [ + { + name: 'q', + in: 'query', + description: 'Case-insensitive partial match against the shop name only.', + schema: { type: 'string', maxLength: 100 }, + }, + { + name: 'limit', + in: 'query', + schema: { type: 'integer', minimum: 1, maximum: 10, default: 10 }, + }, +] as const; + +export const SHOPS_OPENAPI_PATHS = { + '/api/shops': { + get: { + tags: ['Shops Free'], + summary: 'Get the opt-in shop count and three fixed samples', + description: + 'The first three index entries are returned deterministically with name and mode only.', + responses: { + '200': { + description: 'Shop teaser envelope', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ShopsTeaserEnvelope' }, + }, + }, + }, + '404': { $ref: '#/components/responses/NotFound' }, + '429': { $ref: '#/components/responses/RateLimited' }, + '503': { $ref: '#/components/responses/StorageUnavailable' }, + }, + }, + }, + '/api/shops/find': { + get: { + tags: ['Shops Free'], + summary: 'Find opt-in shops by name without payment', + description: + 'Returns handle, name, mode, and three-valued acceptingNow only. Address, hours, menu summary, dine-in filters, and live details remain in the paid search.', + parameters: SHOPS_FIND_QUERY_PARAMETERS, + responses: { + '200': { + description: 'Free shop discovery envelope', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ShopsFindEnvelope' }, + }, + }, + }, + '400': { $ref: '#/components/responses/InvalidQuery' }, + '404': { $ref: '#/components/responses/NotFound' }, + '429': { $ref: '#/components/responses/RateLimited' }, + '503': { $ref: '#/components/responses/StorageUnavailable' }, + }, + }, + }, + '/api/paid/jpyc-shops/search': { + get: { + tags: ['Shops Paid'], + summary: 'Search opt-in shops and current JPYC ordering availability', + description: + 'A paid request snapshots the shop index and summaries before x402 verification and settlement. acceptingNow is true when all required checks pass, false for a definite stop condition, and null when live state or required legacy summary data is unavailable. Phone numbers are never returned.', + parameters: SHOPS_QUERY_PARAMETERS, + 'x-payment-info': paymentInfo(JPYC_SHOPS_SEARCH_RESOURCE.priceJpyc), + 'x-price-jpyc': Number(JPYC_SHOPS_SEARCH_RESOURCE.priceJpyc), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'JPYC', + 'x-payment-chains': ['Polygon', 'Polygon Amoy'], + responses: { + '200': { + description: 'Filtered shop search envelope after settlement', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ShopsSearchEnvelope' }, + }, + }, + }, + '400': { $ref: '#/components/responses/InvalidQuery' }, + '402': { $ref: '#/components/responses/PaymentRequired' }, + '404': { $ref: '#/components/responses/NotFound' }, + '429': { $ref: '#/components/responses/RateLimited' }, + '503': { $ref: '#/components/responses/StorageUnavailable' }, + }, + }, + }, +} as const; + +export const SHOPS_OPENAPI_SCHEMAS = { + ShopTeaser: { + type: 'object', + required: ['name', 'mode'], + additionalProperties: false, + properties: { + name: { type: 'string' }, + mode: { type: 'string', enum: ['storefront', 'preorder'] }, + }, + }, + ShopFindItem: { + type: 'object', + required: ['handle', 'name', 'mode', 'acceptingNow'], + additionalProperties: false, + properties: { + handle: { type: 'string' }, + name: { type: 'string' }, + mode: { type: 'string', enum: ['storefront', 'preorder'] }, + acceptingNow: { + type: ['boolean', 'null'], + description: + 'true: definitely accepting; false: a definite stop condition applies; null: live read failed or required legacy data is indeterminate.', + }, + }, + }, + ShopSearchItem: { + type: 'object', + required: [ + 'handle', + 'name', + 'mode', + 'dineIn', + 'acceptingNow', + 'menu', + 'chains', + 'pageUrl', + 'menuUrl', + 'live', + ], + properties: { + handle: { type: 'string' }, + name: { type: 'string' }, + tagline: { type: 'string' }, + address: { type: 'string' }, + mode: { type: 'string', enum: ['storefront', 'preorder'] }, + dineIn: { type: 'boolean' }, + acceptingNow: { + type: ['boolean', 'null'], + description: + 'true: definitely accepting; false: a definite stop condition applies; null: live read failed or required legacy data is indeterminate.', + }, + openFrom: { type: 'string', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }, + lastOrder: { type: 'string', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }, + minLeadMinutes: { type: 'integer', minimum: 1, maximum: 1440 }, + menu: { + type: 'object', + required: ['itemCount', 'minPrice', 'maxPrice'], + properties: { + itemCount: { type: 'integer', minimum: 1, maximum: 60 }, + minPrice: { type: 'string', description: 'JPYC decimal amount' }, + maxPrice: { type: 'string', description: 'JPYC decimal amount' }, + }, + }, + chains: { + type: 'array', + items: { + type: 'string', + enum: ['polygon', 'kaia', 'avalanche', 'ethereum'], + }, + }, + pageUrl: { type: 'string', format: 'uri' }, + menuUrl: { type: 'string' }, + live: { + oneOf: [ + { type: 'null' }, + { + type: 'object', + required: ['paused', 'soldOutCount', 'updatedAt'], + properties: { + paused: { type: 'boolean' }, + soldOutCount: { type: 'integer', minimum: 0 }, + updatedAt: { type: 'integer', minimum: 0 }, + }, + }, + ], + }, + }, + }, + ShopsTeaserEnvelope: { + allOf: [ + { $ref: '#/components/schemas/ShopsEnvelopeBase' }, + { + type: 'object', + properties: { + items: { + type: 'array', + maxItems: 3, + items: { $ref: '#/components/schemas/ShopTeaser' }, + }, + }, + }, + ], + }, + ShopsFindEnvelope: { + allOf: [ + { $ref: '#/components/schemas/ShopsEnvelopeBase' }, + { + type: 'object', + properties: { + items: { + type: 'array', + maxItems: 10, + items: { $ref: '#/components/schemas/ShopFindItem' }, + }, + }, + }, + ], + }, + ShopsSearchEnvelope: { + allOf: [ + { $ref: '#/components/schemas/ShopsEnvelopeBase' }, + { + type: 'object', + properties: { + items: { + type: 'array', + maxItems: 20, + items: { $ref: '#/components/schemas/ShopSearchItem' }, + }, + }, + }, + ], + }, + ShopsEnvelopeBase: { + type: 'object', + required: [ + 'schemaVersion', + 'query', + 'items', + 'total', + 'generatedAt', + 'dataFreshness', + 'licenseNotice', + 'attribution', + ], + properties: { + schemaVersion: { type: 'string', const: '1.0' }, + query: { type: 'object' }, + items: { type: 'array' }, + total: { type: 'integer', minimum: 0 }, + generatedAt: { type: 'string', format: 'date-time' }, + dataFreshness: { + type: 'object', + required: ['oldestUpdatedAt', 'newestUpdatedAt'], + properties: { + oldestUpdatedAt: { type: ['string', 'null'], format: 'date-time' }, + newestUpdatedAt: { type: ['string', 'null'], format: 'date-time' }, + }, + }, + licenseNotice: { + type: 'object', + required: ['ja', 'en'], + properties: { ja: { type: 'string' }, en: { type: 'string' } }, + }, + attribution: { + type: 'array', + uniqueItems: true, + items: { type: 'string' }, + }, + }, + }, +} as const; diff --git a/lib/openapi/stores.ts b/lib/openapi/stores.ts new file mode 100644 index 00000000..bc9309e6 --- /dev/null +++ b/lib/openapi/stores.ts @@ -0,0 +1,38 @@ +// JPYC 受入先カタログの USDC 版 (vanilla x402)。JPYC 版 /api/paid/stores は discovery.ts +// (x402 Catalog) に属し、paths 上の位置も facilitator 面の中なのでここへは移さない。 + +import { USDC_STORES, USDC_STORES_BAZAAR } from '@/lib/x402/usdcStores'; +import { usdcPaymentChains, usdcPaymentInfo } from '@/lib/openapi/payment'; +import { schemaFromExample } from '@/lib/openapi/schema'; + +// USDC 版 stores は directory flag に依存しない (JPYC 受入先の別カタログ)。 +export const VANILLA_STORES_OPENAPI_PATHS = { + [USDC_STORES.path]: { + get: { + tags: ['x402 Vanilla (USDC)'], + summary: 'Unlock the curated JPYC acceptance directory (USDC on Base)', + description: + 'Same data as /api/paid/stores, sold via standard x402 (exact scheme) in USDC on Base mainnet through an external facilitator. No OpenPay fee is added; the listed price is the full charge.', + 'x-payment-info': usdcPaymentInfo(USDC_STORES.priceUsd), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'USDC', + 'x-payment-chains': usdcPaymentChains(), + responses: { + '200': { + description: 'Curated store list after settlement', + content: { + 'application/json': { + schema: schemaFromExample(USDC_STORES_BAZAAR.output.example), + example: USDC_STORES_BAZAAR.output.example, + }, + }, + }, + '402': { + description: + 'Standard x402 payment challenge (USDC on Base, exact scheme, single transferWithAuthorization).', + }, + '503': { $ref: '#/components/responses/StorageUnavailable' }, + }, + }, + }, +} as const; diff --git a/lib/openapi/vanilla.ts b/lib/openapi/vanilla.ts new file mode 100644 index 00000000..ac893745 --- /dev/null +++ b/lib/openapi/vanilla.ts @@ -0,0 +1,207 @@ +// vanilla x402 (USDC/Base・外部 facilitator・OpenPay 手数料なし) の JPYC ライブデータ面と hello デモ。 + +import { JPYC_CHAINS } from '@/lib/chains'; +import { + USDC_JPYC_BALANCE, + USDC_JPYC_SUPPLY, + USDC_JPYC_TRANSFERS, + agentUsageText, +} from '@/lib/jpyc/liveResources'; +import { x402Config } from '@/lib/x402/config'; +import { usdPriceToAtomic } from '@/lib/x402/vanillaGate'; +import { usdcPaymentChains, usdcPaymentInfo } from '@/lib/openapi/payment'; +import { JPYC_LIVE_400, JPYC_LIVE_402, JPYC_LIVE_503 } from '@/lib/openapi/schema'; + +// enum は SoT (lib/chains.ts の JPYC_CHAINS) から導出する。literal で固定すると env flag +// (enableJpycAvalanche/enableJpycEthereum) 未点灯時に「宣言はあるが 400 になるチェーン」を +// 広告してしまう (E7: parseRequiredChainParam は JPYC_CHAINS にない値を拒否する)。 +const JPYC_CHAIN_LIST = [...JPYC_CHAINS]; +const JPYC_CHAIN_PARAM = { + name: 'chain', + in: 'query', + required: false, + schema: { type: 'string', enum: JPYC_CHAIN_LIST }, + description: `Chain to query. Omit to query all supported chains. Supported values: ${JPYC_CHAIN_LIST.join(', ')}.`, + example: 'polygon', +}; + +// vanilla x402 (USDC/Base・外部 facilitator・OpenPay 手数料なし) の直接販売面。 +// JPYC facilitator の flag に依存しないため、doc が配信される限り常に載せる。 +export const VANILLA_OPENAPI_PATHS = { + [USDC_JPYC_SUPPLY.path]: { + get: { + tags: ['x402 Vanilla (USDC)', 'JPYC Live Data'], + operationId: USDC_JPYC_SUPPLY.operationId, + summary: USDC_JPYC_SUPPLY.summary, + description: `${USDC_JPYC_SUPPLY.description} ${agentUsageText(USDC_JPYC_SUPPLY.trigger)} Payment: standard x402 (exact scheme) in USDC on Base mainnet; no OpenPay fee is added.`, + parameters: [JPYC_CHAIN_PARAM], + 'x-agent-usage': USDC_JPYC_SUPPLY.trigger, + 'x-payment-info': usdcPaymentInfo(USDC_JPYC_SUPPLY.priceUsd), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'USDC', + 'x-payment-chains': usdcPaymentChains(), + responses: { + '200': { + description: 'Per-chain totalSupply after settlement (rows with status "error" are RPC failures on that chain only)', + content: { + 'application/json': { + schema: USDC_JPYC_SUPPLY.bazaar.output.schema, + example: USDC_JPYC_SUPPLY.bazaar.output.example, + }, + }, + }, + '400': JPYC_LIVE_400, + '402': JPYC_LIVE_402, + '503': JPYC_LIVE_503, + }, + }, + }, + [USDC_JPYC_BALANCE.path]: { + get: { + tags: ['x402 Vanilla (USDC)', 'JPYC Live Data'], + operationId: USDC_JPYC_BALANCE.operationId, + summary: USDC_JPYC_BALANCE.summary, + description: `${USDC_JPYC_BALANCE.description} ${agentUsageText(USDC_JPYC_BALANCE.trigger)} Payment: standard x402 (exact scheme) in USDC on Base mainnet; no OpenPay fee is added.`, + parameters: [ + { + name: 'address', + in: 'query', + required: true, + schema: { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' }, + description: 'EVM address to read the JPYC balance of.', + example: '0x52d4901142e2B5680027da5EB47C86CB02a3cA81', + }, + JPYC_CHAIN_PARAM, + ], + 'x-agent-usage': USDC_JPYC_BALANCE.trigger, + 'x-payment-info': usdcPaymentInfo(USDC_JPYC_BALANCE.priceUsd), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'USDC', + 'x-payment-chains': usdcPaymentChains(), + responses: { + '200': { + description: 'Per-chain balance after settlement', + content: { + 'application/json': { + schema: USDC_JPYC_BALANCE.bazaar.output.schema, + example: USDC_JPYC_BALANCE.bazaar.output.example, + }, + }, + }, + '400': JPYC_LIVE_400, + '402': JPYC_LIVE_402, + '503': JPYC_LIVE_503, + }, + }, + }, + [USDC_JPYC_TRANSFERS.path]: { + get: { + tags: ['x402 Vanilla (USDC)', 'JPYC Live Data'], + operationId: USDC_JPYC_TRANSFERS.operationId, + summary: USDC_JPYC_TRANSFERS.summary, + description: `${USDC_JPYC_TRANSFERS.description} The block window is fixed per chain (about one hour) to bound RPC cost. ${agentUsageText(USDC_JPYC_TRANSFERS.trigger)} Payment: standard x402 (exact scheme) in USDC on Base mainnet; no OpenPay fee is added.`, + parameters: [ + { + ...JPYC_CHAIN_PARAM, + required: true, + description: `Chain to scan. Supported values: ${JPYC_CHAIN_LIST.join(', ')}.`, + }, + { + name: 'limit', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, + description: + 'Maximum number of transfer events to return: snapshot newest first, cursor delta oldest first (1-100, default 20). This is not a page number.', + example: 20, + }, + { + name: 'address', + in: 'query', + required: false, + schema: { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' }, + description: 'Only transfers where this address is the sender or the recipient.', + }, + { + name: 'cursor', + in: 'query', + required: false, + schema: { type: 'string', pattern: '^[0-9]+:(?:-1|[0-9]+)$' }, + description: + 'Both snapshot and delta scan through max(0, raw head - depth), where depth is 64 blocks on Polygon/Ethereum; 2 blocks on Kaia/Avalanche. Deeper reorgs can invalidate cursors. The nextCursor value from a previous response (":"). Returns only transfers newer than that position, oldest first (mode=delta), returning each observed event once within the scanned window, assuming stable chain history; continue with nextCursor while hasMore is true. If the cursor is older than the scanned window, the response sets truncated=true. A cursor more than 64 blocks beyond the raw head is rejected with 400 cursor_ahead_of_head before settlement (tolerance is measured from the raw head, independently of confirmation depth). Otherwise, a cursor newer than the scan boundary returns no items and is echoed unchanged until the boundary catches up. Without a cursor (mode=snapshot) hasMore only means older events in the window were omitted; start monitoring from nextCursor.', + example: '92387695:286', + }, + ], + 'x-agent-usage': USDC_JPYC_TRANSFERS.trigger, + 'x-payment-info': usdcPaymentInfo(USDC_JPYC_TRANSFERS.priceUsd), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'USDC', + 'x-payment-chains': usdcPaymentChains(), + responses: { + '200': { + description: 'Newest-first Transfer events within the block window after settlement', + content: { + 'application/json': { + schema: USDC_JPYC_TRANSFERS.bazaar.output.schema, + example: USDC_JPYC_TRANSFERS.bazaar.output.example, + }, + }, + }, + '400': JPYC_LIVE_400, + '402': JPYC_LIVE_402, + '503': JPYC_LIVE_503, + }, + }, + }, +} as const; + +// hello (vanilla demo) の価格は X402_PRICE env が権威。Money 文字列でない (polygon 配線) か +// 変換不能なら openapi に載せない (route 側も 503 に縮退するため整合する)。 +function helloUsdAmountOrNull(): string | null { + const price = x402Config.defaultPrice; + if (typeof price !== 'string') return null; + try { + usdPriceToAtomic(price); + } catch { + return null; + } + return price.replace(/^\$/, ''); +} + +// hello は価格が env (X402_PRICE) 由来のため、有効な USD 価格のときだけ載せる。 +export function vanillaHelloPath(): Record { + const amount = helloUsdAmountOrNull(); + if (amount === null) return {}; + return { + '/api/paid/hello': { + get: { + tags: ['x402 Vanilla (USDC)'], + summary: 'Paid hello demo (USDC on Base)', + description: + 'Smallest standard-x402 payable resource: pay and unlock a hello + timestamp. Use it to confirm the 402 → pay → unlock flow end to end before wiring a real paid API.', + 'x-payment-info': usdcPaymentInfo(amount), + 'x-payment-protocol': 'x402', + 'x-payment-asset': 'USDC', + 'x-payment-chains': usdcPaymentChains(), + responses: { + '200': { + description: 'Hello + timestamp after settlement', + content: { + 'application/json': { + example: { + message: 'Hello, paid AI agent.', + timestamp: '2026-07-28T00:00:00.000Z', + }, + }, + }, + }, + '402': { + description: + 'Standard x402 payment challenge (USDC on Base, exact scheme, single transferWithAuthorization).', + }, + '503': { $ref: '#/components/responses/StorageUnavailable' }, + }, + }, + }, + }; +} diff --git a/tests/lib/openapi/documentGolden.test.ts b/tests/lib/openapi/documentGolden.test.ts new file mode 100644 index 00000000..3e486c29 --- /dev/null +++ b/tests/lib/openapi/documentGolden.test.ts @@ -0,0 +1,292 @@ +// /openapi.json (と /api/openapi.json) の **バイト単位** の golden。 +// +// OpenAPI 文書は x402 インデクサ・支払いエージェントが機械的に読む公開契約なので、 +// lib/openapi/ の分割 (R9c) のような構造変更で key の挿入順・spread 順・flag の評価時点 +// (import 時 / 文書生成時) が 1 つでもずれると、意味が同じでも契約面が変わる。既存の +// openapi-discovery.test.ts は構造と挙動を見るだけなので、ここで未整列の raw JSON +// (JSON.stringify そのまま) の sha256 を flag 構成ごとに固定する。 +// +// 期待値は分割前 (origin/main b13da8c2) のコードで採取した。文書の文言・構造を**意図して** +// 変えた PR では、差分を目視確認したうえでこの表を更新する (採取は +// `OPENAPI_GOLDEN_CAPTURE=1 CI=true npx vitest run tests/lib/openapi/documentGolden.test.ts` で +// hash と key 順を stdout に出す)。 + +import { createHash } from 'node:crypto'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const SELLER = '0x00000000000000000000000000000000000000A1'; + +// 文書に影響する env をすべて明示する (シェル由来の env が profile に漏れないように)。 +const BASE_ENV: Record = { + NEXT_PUBLIC_NETWORK_ENV: 'testnet', + NEXT_PUBLIC_ENABLE_X402_FACILITATOR: '', + NEXT_PUBLIC_ENABLE_WEB3_DIRECTORY: '', + NEXT_PUBLIC_ENABLE_SHOPS_API: '', + NEXT_PUBLIC_ENABLE_ORDER_RELAY: '', + ENABLE_AGENT_ORDER: '', + ENABLE_CREATOR_STORE: '', + ENABLE_LICENSE_NFT: '', + NEXT_PUBLIC_ENABLE_JPYC_AVALANCHE: '', + NEXT_PUBLIC_ENABLE_JPYC_ETHEREUM: '', + ENABLE_X402_ARC_GATEWAY: '', + X402_NETWORK: '', + X402_PAY_TO_ADDRESS: SELLER, + X402_PRICE: '$0.01', + X402_FEE_BPS: '', + X402_FEE_FLOOR_JPYC: '', +}; + +const ALL_ON: Record = { + NEXT_PUBLIC_ENABLE_X402_FACILITATOR: '1', + NEXT_PUBLIC_ENABLE_WEB3_DIRECTORY: '1', + NEXT_PUBLIC_ENABLE_SHOPS_API: '1', + NEXT_PUBLIC_ENABLE_ORDER_RELAY: '1', + ENABLE_AGENT_ORDER: '1', + ENABLE_CREATOR_STORE: '1', + ENABLE_LICENSE_NFT: '1', +}; + +const SHOPS_ON: Record = { + NEXT_PUBLIC_ENABLE_SHOPS_API: '1', + NEXT_PUBLIC_ENABLE_ORDER_RELAY: '1', + ENABLE_AGENT_ORDER: '1', +}; + +type Profile = { name: string; env: Record }; + +const PROFILES: Profile[] = [ + { name: 'all-on', env: ALL_ON }, + { name: 'all-on+arc', env: { ...ALL_ON, ENABLE_X402_ARC_GATEWAY: '1' } }, + { name: 'all-on+mainnet', env: { ...ALL_ON, NEXT_PUBLIC_NETWORK_ENV: 'mainnet' } }, + { + name: 'all-on+mainnet+avalanche+ethereum', + env: { + ...ALL_ON, + NEXT_PUBLIC_NETWORK_ENV: 'mainnet', + NEXT_PUBLIC_ENABLE_JPYC_AVALANCHE: '1', + NEXT_PUBLIC_ENABLE_JPYC_ETHEREUM: '1', + }, + }, + { name: 'all-on+fee', env: { ...ALL_ON, X402_FEE_BPS: '250', X402_FEE_FLOOR_JPYC: '3' } }, + // X402_PRICE 未設定は既定 $0.001 で hello を掲載、変換不能な価格は hello を載せない。 + { name: 'all-on+default-hello-price', env: { ...ALL_ON, X402_PRICE: '' } }, + { name: 'all-on+invalid-hello-price-unlisted', env: { ...ALL_ON, X402_PRICE: 'abc' } }, + { name: 'directory-only', env: { NEXT_PUBLIC_ENABLE_WEB3_DIRECTORY: '1' } }, + { name: 'facilitator-only', env: { NEXT_PUBLIC_ENABLE_X402_FACILITATOR: '1' } }, + { name: 'facilitator+shops', env: { NEXT_PUBLIC_ENABLE_X402_FACILITATOR: '1', ...SHOPS_ON } }, + { + name: 'directory+facilitator', + env: { NEXT_PUBLIC_ENABLE_WEB3_DIRECTORY: '1', NEXT_PUBLIC_ENABLE_X402_FACILITATOR: '1' }, + }, + { name: 'license-only', env: { ENABLE_CREATOR_STORE: '1', ENABLE_LICENSE_NFT: '1' } }, + // 親 flag なしの子 flag は license を載せない (licenseNftEnabled の AND)。 + { name: 'directory+license-child-only', env: { NEXT_PUBLIC_ENABLE_WEB3_DIRECTORY: '1', ENABLE_LICENSE_NFT: '1' } }, +]; + +// 分割前のコードで採取した sha256 (JSON.stringify の生出力・key 未整列)。 +const EXPECTED_SHA256: Record = { + 'all-on': + '359c31a65bb13cfc5436c9a5ce4bd2910dc89f53568a38dbb10f84b2a4117256', + 'all-on+arc': + 'f165cb69a40c2127a743160f82760931f7d7cd79d727cfdd473092d89bdc778a', + 'all-on+mainnet': + '34a212ada04358cd078ee684a1c51c26ca1e93fd31e4e792c3b21d4667252cc7', + 'all-on+mainnet+avalanche+ethereum': + '6ddd39a0ff2a050a266e991fc55e6cfd68d108debecdab4332cab3ad36ad08bf', + 'all-on+fee': + '60b8ba31881d10617cfb8fad140959be78052cd828eae37ab8eac1d2375a6791', + 'all-on+default-hello-price': + '421c52ad7ed7c2fe63d98ccd59ca7b43b2e6b2f50a1a333d2bdcd59a54614531', + 'all-on+invalid-hello-price-unlisted': + '3916e45c364dd7495818e4c633354f136d4276c8703d2bb0ddc71161fb1d103c', + 'directory-only': + '10754367c345071fb515f8f2be481aa3172ab52240204f94de383266fbc4119d', + 'facilitator-only': + 'acece8ccd294805a317fa7c9170c684a17f5c15176e6a9124f523ae568c21741', + 'facilitator+shops': + 'd4b95911d6ceb194426ae5b5173c9a3e64f962a01c2270edc6463bfe60db418e', + 'directory+facilitator': + 'f0b6f2b57d604da9d345c17c54ba00d47ef97cda11316652ca9a95b8d2b96867', + 'license-only': + 'a53d315d1eaa8a988a54cbea9099816a3f105d4e6de2ab7385795f8ea36eed14', + 'directory+license-child-only': + '10754367c345071fb515f8f2be481aa3172ab52240204f94de383266fbc4119d', + 'late-mutation': + '92f492bd2d59d236d34341da822e3f6bb2b50229a5f147a30e05cb7cc3e5d782', + 'late-facilitator': + '700f416a61219d0e35a83fe493ad8b62e3ad1fab5ced2faaa14da6d2aaeda132', +}; + +// all-on の paths の key 順 (spread 順の固定)。 +const EXPECTED_ALL_ON_PATHS: string[] = [ + '/api/directory', + '/api/directory/categories', + '/api/directory/tags', + '/api/paid/japan-web3-directory', + '/api/paid/japan-web3-directory/search', + '/api/paid/usdc/japan-web3-directory', + '/api/paid/usdc/japan-web3-directory/licensed', + '/api/paid/usdc/japan-web3-directory/search', + '/api/paid/japan-web3-directory/{slug}', + '/api/shops', + '/api/shops/find', + '/api/paid/jpyc-shops/search', + '/api/discovery/{id}', + '/api/discovery', + '/api/paid/demo', + '/api/paid/stores', + '/api/license/metadata/{id}', + '/api/license/products/{id}', + '/api/license/verify', + '/api/paid/usdc/jpyc/supply', + '/api/paid/usdc/jpyc/balance', + '/api/paid/usdc/jpyc/transfers', + '/api/paid/usdc/jpyc/activity', + '/api/paid/usdc/jpyc/attest', + '/api/jpyc/activity/preview', + '/api/paid/usdc/jpyc/services', + '/api/jpyc/services/teaser', + '/api/stablecoin-payments/teaser', + '/api/paid/usdc/stablecoin-payments', + '/api/paid/jpyc/services', + '/api/paid/stablecoin-payments', + '/api/paid/usdc/stores', + '/api/paid/hello', +]; + +const EXPECTED_ALL_ON_SCHEMAS: string[] = [ + 'DirectoryEntry', + 'DirectoryLicensedEnvelope', + 'DirectoryEnvelope', + 'Error', + 'ShopTeaser', + 'ShopFindItem', + 'ShopSearchItem', + 'ShopsTeaserEnvelope', + 'ShopsFindEnvelope', + 'ShopsSearchEnvelope', + 'ShopsEnvelopeBase', + 'DiscoveryItem', + 'DiscoveryEnvelope', + 'LicenseDescriptor', + 'LicenseVerification', +]; + +function sha256(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +function applyEnv(env: Record): void { + for (const [key, value] of Object.entries({ ...BASE_ENV, ...env })) vi.stubEnv(key, value); +} + +type Served = { rootStatus: number; apiStatus: number; rootText: string; apiText: string; built: string }; + +async function serve(profile: Profile): Promise { + applyEnv(profile.env); + vi.resetModules(); + const root = (await import('@/app/openapi.json/route')) as { GET: () => Promise }; + const api = (await import('@/app/api/openapi.json/route')) as { GET: () => Promise }; + const { buildOpenApiDocument } = await import('@/lib/openapi/document'); + const rootRes = await root.GET(); + const apiRes = await api.GET(); + return { + rootStatus: rootRes.status, + apiStatus: apiRes.status, + rootText: await rootRes.text(), + apiText: await apiRes.text(), + built: JSON.stringify(buildOpenApiDocument()), + }; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +const CAPTURE = process.env.OPENAPI_GOLDEN_CAPTURE === '1'; +const captured: Record = {}; + +describe('OpenAPI 文書のバイト golden (R9c)', () => { + it.each(PROFILES.map((p) => [p.name, p] as const))('%s: 両 endpoint が同一バイトで sha256 が固定値', async (name, profile) => { + const served = await serve(profile); + expect(served.rootStatus).toBe(200); + expect(served.apiStatus).toBe(200); + expect(served.apiText).toBe(served.rootText); + // route は builder の結果をそのまま JSON.stringify で返す (加工しない)。 + expect(served.rootText).toBe(served.built); + const hash = sha256(served.rootText); + if (CAPTURE) { + captured[name] = hash; + if (name === 'all-on') { + const doc = JSON.parse(served.rootText) as { paths: object; components: { schemas: object } }; + captured.paths = Object.keys(doc.paths); + captured.schemas = Object.keys(doc.components.schemas); + } + return; + } + expect(hash).toBe(EXPECTED_SHA256[name]); + }); + + it('all-on の paths / components.schemas の key 順が固定値', async () => { + const served = await serve(PROFILES[0]); + const doc = JSON.parse(served.rootText) as { paths: object; components: { schemas: object } }; + if (CAPTURE) return; + expect(Object.keys(doc.paths)).toEqual(EXPECTED_ALL_ON_PATHS); + expect(Object.keys(doc.components.schemas)).toEqual(EXPECTED_ALL_ON_SCHEMAS); + }); + + it('全機能 OFF は両 endpoint とも 404 で builder は null', async () => { + const served = await serve({ name: 'all-off', env: {} }); + expect(served.rootStatus).toBe(404); + expect(served.apiStatus).toBe(404); + expect(served.built).toBe('null'); + expect(served.rootText).toBe(served.apiText); + if (CAPTURE) captured['all-off-body'] = served.rootText; + else expect(served.rootText).toBe('{"ok":false,"error":"not_found"}'); + }); + + // 評価時点の固定: module 読み込み後に設定 object を書き換え、文書生成時に読むもの + // (env の機能 flag・hello の価格と Arc 判定) だけが反映され、読み込み時に確定するもの + // (JPYC 価格の手数料・hello 以外の USDC 支払い情報) は反映されないことを hash で固定する。 + it('late-mutation: import 時 / 生成時の評価の区別が変わらない', async () => { + applyEnv(ALL_ON); + vi.resetModules(); + const { buildOpenApiDocument } = await import('@/lib/openapi/document'); + const { env } = await import('@/lib/env'); + const { x402Config } = await import('@/lib/x402/config'); + const { x402FacilitatorConfig } = await import('@/lib/x402/facilitatorConfig'); + const mutableEnv = env as unknown as Record; + mutableEnv.enableWeb3Directory = false; + mutableEnv.enableLicenseNft = false; + const mutableX402 = x402Config as unknown as Record; + mutableX402.arcGateway = { enabled: true }; + mutableX402.defaultPrice = '$0.05'; + (x402FacilitatorConfig as unknown as Record).feeBps = 9999; + const text = JSON.stringify(buildOpenApiDocument()); + const hash = sha256(text); + if (CAPTURE) { + captured['late-mutation'] = hash; + console.log(`OPENAPI_GOLDEN_CAPTURE ${JSON.stringify(captured, null, 2)}`); + return; + } + expect(hash).toBe(EXPECTED_SHA256['late-mutation']); + }); + + // facilitator の flag は shops/facilitator の path 群の有無を決める。import 時に読む実装に変わると + // ここが落ちる (レビュー S1: 上の case は enableX402Facilitator を触らず検出できなかった)。 + it('late-facilitator: enableX402Facilitator を import 後に切っても生成時に評価される', async () => { + applyEnv(ALL_ON); + vi.resetModules(); + const { buildOpenApiDocument } = await import('@/lib/openapi/document'); + const { env } = await import('@/lib/env'); + (env as unknown as Record).enableX402Facilitator = false; + const hash = sha256(JSON.stringify(buildOpenApiDocument())); + if (CAPTURE) { + captured['late-facilitator'] = hash; + console.log(`OPENAPI_GOLDEN_CAPTURE ${JSON.stringify(captured, null, 2)}`); + return; + } + expect(hash).toBe(EXPECTED_SHA256['late-facilitator']); + }); +});