diff --git a/packages/cli/README.md b/packages/cli/README.md index 17d8ad68a..5f5a25b3d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -16,7 +16,7 @@ npx @peac/cli verify ## What It Does -`@peac/cli` provides the `peac` command for working with signed interaction receipts from the terminal. It supports verifying receipt signatures, decoding receipt contents, validating issuer configurations, managing policies, running conformance test suites, generating sample receipts, and reconciling evidence bundles. +`@peac/cli` provides the `peac` command for working with signed interaction records from the terminal. It supports verifying record signatures, decoding record contents, validating issuer configurations, managing policies, running conformance test suites, generating sample records, and reconciling evidence bundles. ## How Do I Use It? @@ -86,11 +86,11 @@ peac reconcile Merges two evidence bundles and detects conflicts using composite `(iss, jti)` keys with fallback resolution. Conflicts are surfaced for human decision; no auto-resolution. -### Generate sample receipts +### Generate sample records ```bash peac samples list # List available samples -peac samples show basic-receipt # Show a specific sample +peac samples show basic-record # Show a specific sample peac samples generate --output ./out # Generate sample files ``` diff --git a/packages/cli/src/commands/samples.ts b/packages/cli/src/commands/samples.ts index 2b04388c2..5eecb87be 100644 --- a/packages/cli/src/commands/samples.ts +++ b/packages/cli/src/commands/samples.ts @@ -1,9 +1,9 @@ /** * PEAC Samples CLI Commands (v0.10.8+) * - * Commands for working with sample receipts: - * - list: List available sample receipts - * - generate: Generate sample receipts for testing + * Commands for working with sample records: + * - list: List available sample records + * - generate: Generate sample records for testing * - show: Show details of a specific sample * * Uses specs/conformance/samples/ as canonical source when available, @@ -15,7 +15,8 @@ import { Command } from 'commander'; import * as fs from 'fs'; import * as path from 'path'; -import { sign, generateKeypair, base64urlEncode } from '@peac/crypto'; +import { sign, generateKeypair, base64urlEncode, decode } from '@peac/crypto'; +import { issue } from '@peac/protocol'; import { getSamples, getSampleById, type SampleCategory } from '../lib/samples-loader.js'; import { getVersion } from '../lib/version.js'; @@ -65,8 +66,6 @@ function applyTimeAdjustments( // Standard expiry: 1 hour const standardExpiry = 3600; - // Long expiry: 24 hours - const longExpiry = 86400; switch (sampleId) { case 'expired': @@ -79,10 +78,6 @@ function applyTimeAdjustments( adjusted.iat = now + 3600; adjusted.exp = now + 7200; break; - case 'long-expiry': - adjusted.iat = now; - adjusted.exp = now + longExpiry; - break; default: // Standard: iat now, exp in 1 hour if (adjusted.iat === 0 || !adjusted.iat) { @@ -97,7 +92,7 @@ function applyTimeAdjustments( } const samples = new Command('samples') - .description('Work with PEAC sample receipts (v0.10.8+)') + .description('Work with PEAC sample records (v0.10.8+)') .option('--json', 'Output in JSON format'); /** @@ -105,13 +100,26 @@ const samples = new Command('samples') */ samples .command('list') - .description('List available sample receipts') + .description('List available sample records') .option('-c, --category ', 'Filter by category: valid, invalid, edge') .option('--samples ', 'Path to samples directory') .action((options, cmd) => { const globalOpts = getGlobalOptions(cmd); try { + if ( + options.category !== undefined && + !['valid', 'invalid', 'edge'].includes(options.category) + ) { + outputError( + 'Unknown sample category', + { category: options.category, supported: ['valid', 'invalid', 'edge'] }, + globalOpts + ); + process.exitCode = 1; + return; + } + const allSamples = getSamples(options.samples); let filteredSamples = allSamples; @@ -133,7 +141,7 @@ samples })); console.log(JSON.stringify({ samples: data }, null, 2)); } else { - console.log('Available PEAC Sample Receipts\n'); + console.log('Available PEAC Sample Records\n'); console.log('VALID SAMPLES:'); for (const s of filteredSamples.filter((x) => x.category === 'valid')) { console.log(` ${s.id}`); @@ -197,10 +205,15 @@ samples console.log(`ID: ${sample.id}`); console.log(`Category: ${sample.category}`); console.log(`Description: ${sample.description}\n`); - console.log('Claims:'); - console.log(JSON.stringify(sample.claims, null, 2)); - if (sample.expectedError) { - console.log(`\nExpected Error: ${sample.expectedError}`); + if (sample.category === 'valid') { + console.log('Issue input (current PEAC signed interaction record):'); + console.log(JSON.stringify(sample.input, null, 2)); + } else { + console.log('Claims (legacy rejection fixture):'); + console.log(JSON.stringify(sample.claims, null, 2)); + if (sample.expectedError) { + console.log(`\nExpected Error: ${sample.expectedError}`); + } } } @@ -216,12 +229,15 @@ samples */ samples .command('generate') - .description('Generate sample receipt files') + .description('Generate sample record files') .requiredOption('-o, --output ', 'Output directory') - .option('-f, --format ', 'Output format: jws, json, bundle', 'jws') + .option('-f, --format ', 'Output format: jws, json', 'jws') .option('--category ', 'Generate only specific category') .option('--samples ', 'Path to samples directory') - .option('--now ', 'Unix timestamp for iat/exp (for deterministic generation)') + .option( + '--now ', + 'Unix event time (seconds) for valid samples; sets occurred_at (must not be in the future)' + ) .option('--kid ', 'Key ID to use') .action(async (options, cmd) => { const globalOpts = getGlobalOptions(cmd); @@ -229,7 +245,93 @@ samples try { const outputDir = options.output; - // Create output directories + // Validate ALL inputs before any filesystem side effects. + + // 1. Output format: only jws and json are sample file formats. The + // offline metadata bundle is always written under bundles/. + const allowedFormats = new Set(['jws', 'json']); + if (!allowedFormats.has(options.format)) { + outputError( + 'Unsupported sample format', + { format: options.format, supported: ['jws', 'json'] }, + globalOpts + ); + process.exitCode = 1; + return; + } + + // 2. Category enum (when provided). + const allowedCategories = new Set(['valid', 'invalid', 'edge']); + if (options.category !== undefined && !allowedCategories.has(options.category)) { + outputError( + 'Unknown sample category', + { category: options.category, supported: ['valid', 'invalid', 'edge'] }, + globalOpts + ); + process.exitCode = 1; + return; + } + + // 3. Select samples and fail if nothing matches. + let samplesToGenerate = getSamples(options.samples); + if (options.category) { + samplesToGenerate = samplesToGenerate.filter((s) => s.category === options.category); + } + if (samplesToGenerate.length === 0) { + outputError( + 'No samples selected to generate', + { category: options.category ?? null }, + globalOpts + ); + process.exitCode = 1; + return; + } + + // 4. --now is an optional integer event time (seconds) for valid samples. + let eventTime: number | undefined; + if (options.now !== undefined) { + const parsed = Number(options.now); + if (!Number.isInteger(parsed)) { + outputError( + '--now must be an integer Unix timestamp (seconds)', + { now: options.now }, + globalOpts + ); + process.exitCode = 1; + return; + } + // An integer can still be out of the supported Date range (e.g. a huge + // negative value), which would later throw when formatting occurred_at. + // Reject it here, before any filesystem writes, with a clear message. + if (!Number.isFinite(new Date(parsed * 1000).getTime())) { + outputError( + '--now is outside the supported date range', + { now: options.now }, + globalOpts + ); + process.exitCode = 1; + return; + } + eventTime = parsed; + } + + // 5. A future occurred_at would be rejected by local verification + // (E_OCCURRED_AT_FUTURE), so reject a future --now up front when + // valid samples are selected. + const generationNow = Math.floor(Date.now() / 1000); + const SKEW_SECONDS = 300; + const generatesValid = samplesToGenerate.some((s) => s.category === 'valid'); + if (eventTime !== undefined && generatesValid && eventTime > generationNow + SKEW_SECONDS) { + outputError( + '--now sets occurred_at (event time) for valid samples and must not be in the future', + { now: eventTime, wall_clock: generationNow, skew_seconds: SKEW_SECONDS }, + globalOpts + ); + process.exitCode = 1; + return; + } + + // Inputs validated. Create output directories. const validDir = path.join(outputDir, 'valid'); const invalidDir = path.join(outputDir, 'invalid'); const edgeDir = path.join(outputDir, 'edge'); @@ -240,17 +342,15 @@ samples fs.mkdirSync(edgeDir, { recursive: true }); fs.mkdirSync(bundlesDir, { recursive: true }); - // Determine timestamp - const now = options.now ? parseInt(options.now, 10) : Math.floor(Date.now() / 1000); - // Generate key pair - // Note: --seed option is documented but full determinism requires proper seed-to-key derivation const keyPair = await generateKeypair(); const publicKeyBytes = keyPair.publicKey; const privateKeyBytes = keyPair.privateKey; - // Build KID - const kid = options.kid || `sandbox-${new Date(now * 1000).toISOString().slice(0, 7)}`; + // Build KID. The default kid is derived from generation time, not the + // event time, since --now is the interaction time (occurred_at). + const kid = + options.kid || `sandbox-${new Date(generationNow * 1000).toISOString().slice(0, 7)}`; // Build JWK for the key const publicJwk = { @@ -264,12 +364,6 @@ samples const generatedFiles: string[] = []; - // Get samples to generate - let samplesToGenerate = getSamples(options.samples); - if (options.category) { - samplesToGenerate = samplesToGenerate.filter((s) => s.category === options.category); - } - for (const sample of samplesToGenerate) { const targetDir = sample.category === 'valid' @@ -280,26 +374,48 @@ samples const filename = `${sample.id}.${options.format === 'json' ? 'json' : 'jws'}`; const filepath = path.join(targetDir, filename); - // Apply time adjustments - const adjustedClaims = applyTimeAdjustments(sample.claims, sample.id, now); - - if (options.format === 'json') { - // Write as decoded JSON - const output = { - $comment: sample.description, - header: { - alg: 'EdDSA', - typ: 'interaction-record+jwt', - kid, - }, - payload: adjustedClaims, - ...(sample.expectedError ? { expected_error: sample.expectedError } : {}), - }; - fs.writeFileSync(filepath, JSON.stringify(output, null, 2)); + if (sample.category === 'valid') { + // Valid samples are current PEAC signed interaction records issued + // via issue(). iat is issuance time; --now maps to occurred_at (the + // interaction/event time), so generated bytes are not deterministic. + // The recipe `input` is untyped JSON; issue() validates it at + // runtime (throws IssueError on malformed input). Cast through + // unknown to the issue() options type. + const issueOptions = { + ...sample.input, + privateKey: privateKeyBytes, + kid, + ...(eventTime !== undefined + ? { occurred_at: new Date(eventTime * 1000).toISOString() } + : {}), + } as unknown as Parameters[0]; + const { jws } = await issue(issueOptions); + if (options.format === 'json') { + const { header, payload } = decode(jws); + fs.writeFileSync( + filepath, + JSON.stringify({ $comment: sample.description, header, payload }, null, 2) + ); + } else { + fs.writeFileSync(filepath, jws); + } } else { - // Create actual JWS using @peac/crypto sign function - const jws = await sign(adjustedClaims, privateKeyBytes, kid); - fs.writeFileSync(filepath, jws); + // Invalid / edge samples remain raw legacy claims (rejection + // fixtures), signed directly so they can carry intentionally invalid + // shapes that issue() would refuse to produce. + const adjustedClaims = applyTimeAdjustments(sample.claims, sample.id, generationNow); + if (options.format === 'json') { + const output = { + $comment: sample.description, + header: { alg: 'EdDSA', typ: 'interaction-record+jwt', kid }, + payload: adjustedClaims, + ...(sample.expectedError ? { expected_error: sample.expectedError } : {}), + }; + fs.writeFileSync(filepath, JSON.stringify(output, null, 2)); + } else { + const jws = await sign(adjustedClaims, privateKeyBytes, kid); + fs.writeFileSync(filepath, jws); + } } generatedFiles.push(filepath); @@ -316,10 +432,9 @@ samples // Write offline verification bundle const bundlePath = path.join(bundlesDir, 'offline-verification.json'); const bundle = { - $comment: 'Offline verification bundle with sample receipts and JWKS', - description: - 'Bundle for offline verification testing. Contains test JWKS and sample metadata.', - generated_at: new Date(now * 1000).toISOString(), + $comment: 'Offline verification bundle with sample records and JWKS', + description: 'Sample record metadata and the sandbox public verification keys.', + generated_at: new Date(generationNow * 1000).toISOString(), generator: { name: 'peac-cli', version: getVersion(), @@ -331,8 +446,10 @@ samples category: s.category, })), notes: [ - 'Sample timestamps are based on --now parameter or generation time.', - 'Do NOT use sandbox samples in production.', + 'Valid samples are issued through issue() and pass local verification (verifyLocal).', + 'Invalid samples are intentionally invalid rejection fixtures.', + 'For valid samples, --now sets the event time (occurred_at); iat is issuance time.', + 'Sandbox samples are for local testing and demonstration; do NOT use them in production.', ], }; fs.writeFileSync(bundlePath, JSON.stringify(bundle, null, 2)); @@ -346,7 +463,8 @@ samples output_dir: outputDir, files_generated: generatedFiles.length, files: generatedFiles, - timestamp: now, + generation_time: generationNow, + event_time: eventTime ?? null, kid, }, null, @@ -354,10 +472,15 @@ samples ) ); } else { - console.log(`Sample receipts generated successfully!\n`); + console.log(`Sample records generated successfully!\n`); console.log(`Output directory: ${outputDir}`); console.log(`Files generated: ${generatedFiles.length}`); - console.log(`Timestamp: ${now} (${new Date(now * 1000).toISOString()})`); + console.log( + `Generation time: ${generationNow} (${new Date(generationNow * 1000).toISOString()})` + ); + if (eventTime !== undefined) { + console.log(`Event time: ${eventTime} (${new Date(eventTime * 1000).toISOString()})`); + } console.log(`Key ID: ${kid}\n`); console.log('Generated files:'); for (const f of generatedFiles) { diff --git a/packages/cli/src/lib/samples-loader.ts b/packages/cli/src/lib/samples-loader.ts index 94aef88cf..6d280d4b4 100644 --- a/packages/cli/src/lib/samples-loader.ts +++ b/packages/cli/src/lib/samples-loader.ts @@ -16,109 +16,120 @@ import * as path from 'path'; export type SampleCategory = 'valid' | 'invalid' | 'edge'; /** - * Sample definition + * Valid sample: a current PEAC signed interaction record, expressed as the + * inputs passed to issue(). Generation supplies privateKey and kid. Generated + * output passes local verification (verifyLocal). */ -export interface SampleDefinition { +export interface ValidSampleDefinition { id: string; name: string; description: string; - category: SampleCategory; + category: 'valid'; + format: 'issue-options'; + /** Inputs for issue() (without privateKey/kid, which generation supplies). */ + input: Record; +} + +/** + * Invalid / edge sample: a rejection fixture expressed as raw legacy claims + * that are signed directly (not issued), so it can carry intentionally invalid + * shapes that issue() would refuse to produce. + */ +export interface LegacySampleDefinition { + id: string; + name: string; + description: string; + category: 'invalid' | 'edge'; + format: 'legacy-claims'; claims: Record; header?: Record; expectedError?: string; } +/** + * Sample definition (discriminated by category/format). + */ +export type SampleDefinition = ValidSampleDefinition | LegacySampleDefinition; + /** * Embedded fallback samples (used when specs folder not available) */ const EMBEDDED_SAMPLES: SampleDefinition[] = [ { - id: 'basic-receipt', - name: 'Basic Receipt', - description: 'Minimal valid PEAC receipt with only required fields', + id: 'basic-record', + name: 'Basic Record', + description: 'Minimal valid PEAC signed interaction record', category: 'valid', - claims: { + format: 'issue-options', + input: { iss: 'https://sandbox.peacprotocol.org', - aud: 'https://example.com', - iat: 0, // Placeholder - will be set at generation time - exp: 0, // Placeholder - will be set at generation time - rid: 'sample-basic-001', + kind: 'evidence', + type: 'org.peacprotocol/access', }, }, { - id: 'full-receipt', - name: 'Full Receipt', - description: 'PEAC receipt with all optional claims populated', + id: 'full-record', + name: 'Full Record', + description: 'Valid PEAC signed interaction record with optional fields', category: 'valid', - claims: { + format: 'issue-options', + input: { iss: 'https://sandbox.peacprotocol.org', - aud: 'https://api.example.com', - sub: 'user:demo-user', - iat: 0, - exp: 0, - rid: 'sample-full-001', - purpose_declared: ['search', 'index'], - purpose_enforced: 'search', - purpose_reason: 'allowed', + kind: 'evidence', + type: 'org.peacprotocol/access', + sub: 'agent:demo-agent', + purpose_declared: 'search', }, }, { - id: 'interaction-evidence', - name: 'Interaction Evidence', - description: 'Receipt with InteractionEvidence extension for AI agent calls', + id: 'mcp-tool-run', + name: 'Mcp Tool Run', + description: 'Valid PEAC signed interaction record for an MCP tool run', category: 'valid', - claims: { + format: 'issue-options', + input: { iss: 'https://sandbox.peacprotocol.org', - aud: 'https://agent.example.com', - sub: 'agent:demo-agent-v1', - iat: 0, - exp: 0, - rid: 'sample-ie-001', - ext: { - 'org.peacprotocol/interaction@0.1': { - version: '0.1', - interaction_id: 'int_sample_001', - started_at: '2026-01-01T00:00:00.000Z', - completed_at: '2026-01-01T00:00:01.000Z', - outcome: { kind: 'success' }, - input: { hash: 'sha256:abc123...', byte_length: 1024 }, - output: { hash: 'sha256:def456...', byte_length: 2048 }, + kind: 'evidence', + type: 'org.peacprotocol/mcp', + extensions: { + 'org.peacprotocol/mcp': { + server: 'demo', + tool: 'search', }, }, }, }, { - id: 'payment-evidence', - name: 'Payment Evidence', - description: 'Receipt with payment evidence (402 flow)', + id: 'payment-event', + name: 'Payment Event', + description: 'Valid PEAC signed interaction record for a payment event', category: 'valid', - claims: { + format: 'issue-options', + input: { iss: 'https://sandbox.peacprotocol.org', - aud: 'https://api.example.com', - iat: 0, - exp: 0, - rid: 'sample-payment-001', - amt: '100', - cur: 'USD', - payment: { - rail: 'x402', - reference: 'pay_sample_001', - amount: '100', - currency: 'USD', + kind: 'evidence', + type: 'org.peacprotocol/payment', + extensions: { + 'org.peacprotocol/commerce': { + payment_rail: 'stripe', + amount_minor: '1000', + currency: 'USD', + }, }, }, }, { - id: 'long-expiry', - name: 'Long Expiry', - description: 'Receipt with 24-hour expiration', + id: 'event-time-record', + name: 'Event Time Record', + description: + 'Valid PEAC signed interaction record with an event time. occurred_at is the interaction time; iat remains issuance time. Generation maps --now to occurred_at.', category: 'valid', - claims: { + format: 'issue-options', + input: { iss: 'https://sandbox.peacprotocol.org', - aud: 'https://example.com', - iat: 0, - exp: 0, // Will be set to iat + 86400 at generation time - rid: 'sample-long-expiry-001', + kind: 'evidence', + type: 'org.peacprotocol/access', + occurred_at: '2026-01-01T00:00:00.000Z', }, }, { @@ -126,6 +137,7 @@ const EMBEDDED_SAMPLES: SampleDefinition[] = [ name: 'Expired Receipt', description: 'Receipt that has already expired (for testing rejection)', category: 'invalid', + format: 'legacy-claims', claims: { iss: 'https://sandbox.peacprotocol.org', aud: 'https://example.com', @@ -140,6 +152,7 @@ const EMBEDDED_SAMPLES: SampleDefinition[] = [ name: 'Future IAT', description: 'Receipt with iat in the future (should be rejected)', category: 'invalid', + format: 'legacy-claims', claims: { iss: 'https://sandbox.peacprotocol.org', aud: 'https://example.com', @@ -154,6 +167,7 @@ const EMBEDDED_SAMPLES: SampleDefinition[] = [ name: 'Missing Issuer', description: 'Receipt missing required iss claim (for testing validation)', category: 'invalid', + format: 'legacy-claims', claims: { aud: 'https://example.com', iat: 0, @@ -207,19 +221,60 @@ function loadSampleFromFile( ): SampleDefinition | null { try { const content = JSON.parse(fs.readFileSync(filePath, 'utf8')); - - // Handle both formats: fixture-style (header/payload) and claims-only - const claims = content.payload ?? content.claims ?? content; const description = content.$comment ?? content.description ?? `Sample ${id}`; + const name = id + .split('-') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + if (category === 'valid') { + // Valid samples are issue() input recipes -> current PEAC signed + // interaction records that pass local verification. Require the + // explicit shape rather than silently degrading to an empty input + // (which would fail later with an opaque issue error). + if ( + content.format !== 'issue-options' || + content.input === null || + typeof content.input !== 'object' || + Array.isArray(content.input) + ) { + process.stderr.write( + `samples-loader: skipping malformed valid sample '${id}' (expected format "issue-options" with an input object)\n` + ); + return null; + } + return { + id, + name, + description, + category: 'valid', + format: 'issue-options', + input: content.input, + }; + } + + // Invalid / edge samples are raw legacy claims (rejection fixtures). They + // carry no format, or an explicit "legacy-claims"; anything else is + // surfaced and skipped rather than silently treated as claims. + if (content.format !== undefined && content.format !== 'legacy-claims') { + process.stderr.write( + `samples-loader: skipping '${id}' (unexpected format "${content.format}" for ${category} sample)\n` + ); + return null; + } + const claims = content.payload ?? content.claims ?? content; + if (claims === null || typeof claims !== 'object' || Array.isArray(claims)) { + process.stderr.write( + `samples-loader: skipping malformed ${category} sample '${id}' (claims must be an object)\n` + ); + return null; + } return { id, - name: id - .split('-') - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' '), + name, description, category, + format: 'legacy-claims', claims, header: content.header, expectedError: content.expected_error, @@ -272,22 +327,29 @@ function loadSamplesFromDir(samplesDir: string): SampleDefinition[] { } /** - * Get sample definitions + * Get sample definitions. * - * Loads from specs/conformance/samples/ when available (canonical source), - * falls back to embedded samples when running outside the repo. + * An explicit `customSamplesPath` is an authoritative source: it must exist, + * and its contents are returned as-is (even if empty after skipping malformed + * samples). It never falls back to embedded samples, so a malformed custom + * catalog is not masked. With no custom path, the repo/package samples + * directory is used when present; the embedded samples are a fallback only for + * runtime environments where that directory is unavailable. */ export function getSamples(customSamplesPath?: string): SampleDefinition[] { - const samplesDir = findSamplesDir(customSamplesPath); + if (customSamplesPath !== undefined) { + if (!fs.existsSync(customSamplesPath)) { + throw new Error(`Samples directory not found: ${customSamplesPath}`); + } + return loadSamplesFromDir(customSamplesPath); + } + const samplesDir = findSamplesDir(); if (samplesDir) { - const dirSamples = loadSamplesFromDir(samplesDir); - if (dirSamples.length > 0) { - return dirSamples; - } + return loadSamplesFromDir(samplesDir); } - // Fall back to embedded samples + // Fall back to embedded samples only when no samples directory exists. return EMBEDDED_SAMPLES; } diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index 2a6e504e4..f8b7cd924 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -68,7 +68,7 @@ describe('CLI Integration Tests', () => { it('samples list should show available samples', () => { const result = runCli(['samples', 'list']); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('basic-receipt'); + expect(result.stdout).toContain('basic-record'); expect(result.stdout).toContain('expired'); expect(result.stdout).toContain('VALID SAMPLES'); }); @@ -79,14 +79,14 @@ describe('CLI Integration Tests', () => { const data = JSON.parse(result.stdout); expect(data.samples).toBeDefined(); expect(Array.isArray(data.samples)).toBe(true); - expect(data.samples.some((s: { id: string }) => s.id === 'basic-receipt')).toBe(true); + expect(data.samples.some((s: { id: string }) => s.id === 'basic-record')).toBe(true); }); it('samples show should display sample details', () => { - const result = runCli(['samples', 'show', 'basic-receipt']); + const result = runCli(['samples', 'show', 'basic-record']); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('basic-receipt'); - expect(result.stdout).toContain('Claims:'); + expect(result.stdout).toContain('basic-record'); + expect(result.stdout).toContain('Issue input'); expect(result.stdout).toContain('iss'); }); @@ -94,7 +94,7 @@ describe('CLI Integration Tests', () => { const outputDir = join(TEST_OUTPUT_DIR, 'samples-test'); const result = runCli(['samples', 'generate', '-o', outputDir]); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('Sample receipts generated successfully'); + expect(result.stdout).toContain('Sample records generated successfully'); // Check files were created expect(existsSync(join(outputDir, 'valid'))).toBe(true); @@ -104,15 +104,29 @@ describe('CLI Integration Tests', () => { // Check valid samples exist const validFiles = readdirSync(join(outputDir, 'valid')); - expect(validFiles.some((f) => f.includes('basic-receipt'))).toBe(true); + expect(validFiles.some((f) => f.includes('basic-record'))).toBe(true); }); - it('samples generate --now should use specified timestamp', () => { + it('samples generate --now should set the event time', () => { const outputDir = join(TEST_OUTPUT_DIR, 'samples-deterministic'); - const timestamp = 1700000000; // Fixed timestamp + const timestamp = 1700000000; // Fixed historical timestamp const result = runCli(['samples', 'generate', '-o', outputDir, '--now', String(timestamp)]); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain(`Timestamp: ${timestamp}`); + expect(result.stdout).toContain(`Event time: ${timestamp}`); + }); + + it('samples generate rejects an unsupported --format', () => { + const outputDir = join(TEST_OUTPUT_DIR, 'samples-bad-format'); + const result = runCli(['samples', 'generate', '-o', outputDir, '--format', 'bundle']); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('Unsupported sample format'); + }); + + it('samples generate rejects an unknown --category', () => { + const outputDir = join(TEST_OUTPUT_DIR, 'samples-bad-category'); + const result = runCli(['samples', 'generate', '-o', outputDir, '--category', 'nope']); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('Unknown sample category'); }); it('samples generate --kid should use specified kid', () => { @@ -173,6 +187,18 @@ describe('CLI Integration Tests', () => { expect(result.stderr).toContain('Sample not found'); }); + it('samples show long-expiry (removed) should fail gracefully', () => { + const result = runCli(['samples', 'show', 'long-expiry']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Sample not found'); + }); + + it('samples list with unknown --category should fail gracefully', () => { + const result = runCli(['samples', 'list', '-c', 'nope']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Unknown sample category'); + }); + it('conformance list with invalid category should fail gracefully', () => { const result = runCli(['conformance', 'list', '-c', 'nonexistent-category']); expect(result.exitCode).toBe(1); diff --git a/packages/cli/tests/samples-verify.test.ts b/packages/cli/tests/samples-verify.test.ts new file mode 100644 index 000000000..4f47d8b6b --- /dev/null +++ b/packages/cli/tests/samples-verify.test.ts @@ -0,0 +1,253 @@ +/** + * Verifies that CLI-generated valid samples are current PEAC signed + * interaction records that pass local verification, and that invalid samples + * remain rejection fixtures. + * + * Spawns the built CLI (`samples generate`) and checks the generated records + * programmatically with verifyLocal() against the generated sandbox JWKS, so + * the check does not depend on the not-yet-merged `peac verify --public-key` + * flag. Asserts the event-time semantics (`--now` sets occurred_at, not iat), + * kid handling, and that a payload-tampered record fails. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { verifyLocal } from '@peac/protocol'; +import { jwkToPublicKeyBytes, decode } from '@peac/crypto'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI_PATH = join(__dirname, '..', 'dist', 'index.cjs'); + +const UUIDV7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function generate(args: string[]): string { + const dir = mkdtempSync(join(tmpdir(), 'peac-samples-verify-')); + execFileSync('node', [CLI_PATH, 'samples', 'generate', '-o', dir, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + }); + return dir; +} + +/** Run `samples generate` expecting it may fail; returns exit code and dir. */ +function generateExit(args: string[]): { code: number; dir: string } { + const dir = mkdtempSync(join(tmpdir(), 'peac-samples-verify-')); + try { + execFileSync('node', [CLI_PATH, 'samples', 'generate', '-o', dir, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + }); + return { code: 0, dir }; + } catch (err) { + return { code: (err as { status?: number }).status ?? -1, dir }; + } +} + +function publicKeyFrom(dir: string): Uint8Array { + const jwks = JSON.parse(readFileSync(join(dir, 'bundles', 'sandbox-jwks.json'), 'utf8')); + expect(Array.isArray(jwks.keys)).toBe(true); + expect(jwks.keys.length).toBe(1); + return jwkToPublicKeyBytes(jwks.keys[0]); +} + +describe('CLI-generated samples align with local verification', () => { + let dir: string; + + beforeAll(() => { + if (!existsSync(CLI_PATH)) { + throw new Error('CLI not built. Run "pnpm --filter @peac/cli build" first.'); + } + dir = generate([]); + }); + + it('every generated valid sample passes verifyLocal()', async () => { + const publicKey = publicKeyFrom(dir); + const validDir = join(dir, 'valid'); + const files = readdirSync(validDir).filter((f) => f.endsWith('.jws')); + expect(files.length).toBeGreaterThanOrEqual(5); + for (const f of files) { + const jws = readFileSync(join(validDir, f), 'utf8').trim(); + const result = await verifyLocal(jws, publicKey); + expect(result.valid, `${f} should verify (got ${result.valid ? 'valid' : result.code})`).toBe( + true + ); + // jti is a fresh UUIDv7 assigned by issue() (not a fixed literal). + const { payload } = decode<{ jti?: string }>(jws); + expect(typeof payload.jti, `${f} jti`).toBe('string'); + expect(UUIDV7.test(payload.jti as string), `${f} jti shape: ${payload.jti}`).toBe(true); + } + }); + + it('generates the expected valid record IDs and not the removed long-expiry', () => { + const files = readdirSync(join(dir, 'valid')); + for (const id of [ + 'basic-record', + 'full-record', + 'mcp-tool-run', + 'payment-event', + 'event-time-record', + ]) { + expect(files.includes(`${id}.jws`)).toBe(true); + } + expect(files.includes('long-expiry.jws')).toBe(false); + }); + + it('invalid samples remain rejection fixtures', async () => { + const publicKey = publicKeyFrom(dir); + const invalidDir = join(dir, 'invalid'); + const files = readdirSync(invalidDir).filter((f) => f.endsWith('.jws')); + expect(files.length).toBeGreaterThanOrEqual(1); + for (const f of files) { + const jws = readFileSync(join(invalidDir, f), 'utf8').trim(); + const result = await verifyLocal(jws, publicKey); + expect(result.valid, `${f} must be rejected`).toBe(false); + } + }); + + it('--now sets occurred_at (event time), not iat (issuance time)', () => { + const timestamp = 1700000000; + const nowDir = generate(['--now', String(timestamp)]); + const jws = readFileSync(join(nowDir, 'valid', 'basic-record.jws'), 'utf8').trim(); + const { payload } = decode<{ occurred_at?: string; iat?: number }>(jws); + expect(payload.occurred_at).toBe(new Date(timestamp * 1000).toISOString()); + expect(typeof payload.iat).toBe('number'); + expect(payload.iat).not.toBe(timestamp); + }); + + it('--kid is reflected in the generated JWS protected header and JWKS', () => { + const kid = 'test-key-001'; + const kidDir = generate(['--kid', kid]); + const jwks = JSON.parse(readFileSync(join(kidDir, 'bundles', 'sandbox-jwks.json'), 'utf8')); + expect(jwks.keys[0].kid).toBe(kid); + const jws = readFileSync(join(kidDir, 'valid', 'basic-record.jws'), 'utf8').trim(); + const { header } = decode(jws); + expect(header.kid).toBe(kid); + }); + + it('rejects a future --now before generating any valid samples', async () => { + const future = Math.floor(Date.now() / 1000) + 999_999; + const { code, dir: outDir } = generateExit(['--category', 'valid', '--now', String(future)]); + expect(code).not.toBe(0); + // No valid records that fail local verification should have been written. + const validDir = join(outDir, 'valid'); + const files = existsSync(validDir) + ? readdirSync(validDir).filter((f) => f.endsWith('.jws')) + : []; + expect(files.length).toBe(0); + }); + + it('rejects a non-integer --now', () => { + const { code } = generateExit(['--now', '1.5']); + expect(code).not.toBe(0); + }); + + it('rejects an out-of-range --now before writing any files', () => { + const { code, dir: outDir } = generateExit([ + '--category', + 'valid', + '--now', + '-999999999999999', + ]); + expect(code).not.toBe(0); + const validDir = join(outDir, 'valid'); + const files = existsSync(validDir) + ? readdirSync(validDir).filter((f) => f.endsWith('.jws')) + : []; + expect(files.length).toBe(0); + }); + + it('--now 0 succeeds and sets occurred_at to the epoch (not skipped as falsy)', () => { + const zeroDir = generate(['--category', 'valid', '--now', '0']); + const jws = readFileSync(join(zeroDir, 'valid', 'basic-record.jws'), 'utf8').trim(); + const { payload } = decode<{ occurred_at?: string; iat?: number }>(jws); + expect(payload.occurred_at).toBe(new Date(0).toISOString()); + expect(typeof payload.iat).toBe('number'); + expect(payload.iat).not.toBe(0); + }); + + function malformedSamplesDir(): string { + const root = mkdtempSync(join(tmpdir(), 'peac-bad-samples-')); + mkdirSync(join(root, 'valid'), { recursive: true }); + writeFileSync( + join(root, 'valid', 'bad-sample.json'), + JSON.stringify({ $comment: 'malformed', claims: { iss: 'x' } }) + ); + return root; + } + + it('a custom --samples dir with only a malformed valid sample yields no samples (no embedded fallback)', () => { + const out = execFileSync( + 'node', + [CLI_PATH, 'samples', 'list', '--json', '--samples', malformedSamplesDir()], + { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' } + ); + const data = JSON.parse(out) as { samples: Array<{ id: string }> }; + expect(data.samples).toEqual([]); + // Crucially, embedded defaults must NOT leak in for an explicit custom path. + expect(data.samples.some((s) => s.id === 'basic-record')).toBe(false); + }); + + it('generate against a malformed-only custom --samples dir fails (no records written)', () => { + const { code } = generateExit(['--samples', malformedSamplesDir()]); + expect(code).not.toBe(0); + }); + + it('a missing custom --samples path fails clearly', () => { + const missing = join(tmpdir(), 'peac-does-not-exist-xyz', 'nope'); + let code = 0; + let stderr = ''; + try { + execFileSync('node', [CLI_PATH, 'samples', 'list', '--samples', missing], { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + }); + } catch (err) { + const e = err as { status?: number; stderr?: Buffer | string }; + code = e.status ?? -1; + stderr = (e.stderr || '').toString(); + } + expect(code).not.toBe(0); + expect(stderr).toContain('Samples directory not found'); + }); + + it('--category valid generates only valid records and they all verify', async () => { + const onlyValid = generate(['--category', 'valid']); + const publicKey = publicKeyFrom(onlyValid); + const invalidDir = join(onlyValid, 'invalid'); + const invalidFiles = existsSync(invalidDir) + ? readdirSync(invalidDir).filter((f) => f.endsWith('.jws')) + : []; + expect(invalidFiles.length).toBe(0); + const validFiles = readdirSync(join(onlyValid, 'valid')).filter((f) => f.endsWith('.jws')); + expect(validFiles.length).toBeGreaterThanOrEqual(5); + for (const f of validFiles) { + const jws = readFileSync(join(onlyValid, 'valid', f), 'utf8').trim(); + const result = await verifyLocal(jws, publicKey); + expect(result.valid, `${f} should verify`).toBe(true); + } + }); + + it('a payload-tampered record fails verification', async () => { + const publicKey = publicKeyFrom(dir); + const jws = readFileSync(join(dir, 'valid', 'basic-record.jws'), 'utf8').trim(); + const parts = jws.split('.'); + // Mutate a character in the middle of the payload segment (changes the + // signed bytes; not the final signature char, whose low bits are unused). + const p = parts[1]; + const i = Math.floor(p.length / 2); + parts[1] = p.slice(0, i) + (p[i] === 'a' ? 'b' : 'a') + p.slice(i + 1); + const result = await verifyLocal(parts.join('.'), publicKey); + expect(result.valid).toBe(false); + }); +}); diff --git a/specs/conformance/samples/README.md b/specs/conformance/samples/README.md index a558d8d84..1dff5d8aa 100644 --- a/specs/conformance/samples/README.md +++ b/specs/conformance/samples/README.md @@ -1,28 +1,37 @@ -# PEAC Sample Receipts +# PEAC Sample Records -This directory contains sample PEAC receipts for testing and demonstration purposes. +This directory contains sample PEAC records for local testing and demonstration. +Generated valid samples are PEAC signed interaction records that pass local +verification. They are for local testing and demonstration; they do not prove +that any real-world event occurred. ## Directory Structure ``` samples/ - valid/ # Valid sample receipts - basic-receipt.json # Minimal valid receipt - full-receipt.json # All optional claims - interaction-evidence.json - payment-evidence.json - long-expiry.json - invalid/ # Invalid samples (for testing rejection) + valid/ # Valid sample records (issue() input recipes) + basic-record.json # Minimal valid record + full-record.json # Optional fields (subject, declared purpose) + mcp-tool-run.json # MCP tool run (org.peacprotocol/mcp extension) + payment-event.json # Payment event (org.peacprotocol/commerce extension) + event-time-record.json # Carries an event time (occurred_at) + invalid/ # Legacy rejection fixtures (raw claims, intentionally invalid) expired.json future-iat.json missing-iss.json bundles/ - offline-verification.json # Receipt + JWKS bundle + offline-verification.json # Generated-sample + JWKS metadata ``` +Valid samples are stored as `issue()` input recipes (`format: "issue-options"`). +The CLI issues them through the current issue path at generation time, so the +generated records are current and locally verifiable. Invalid samples are stored +as raw legacy claims so they can carry intentionally invalid shapes that the +issue path would refuse to produce. + ## Generating Signed Samples -Use the CLI to generate actual signed JWS samples: +Use the CLI to generate actual signed records: ```bash # Generate all samples @@ -35,54 +44,61 @@ peac samples generate -o ./samples --category valid peac samples generate -o ./samples -f json ``` -## Sample Categories +`peac samples generate` also writes `bundles/sandbox-jwks.json`, a single-key +JWKS holding the public verification key for the generated samples. -### Valid Samples +### Timestamps and identifiers -These receipts should pass verification when signed with a valid key: +- `--now ` sets each valid sample's **event time** (`occurred_at`), + not its issuance time. `iat` always reflects the actual issuance time produced + by the issue path. When valid samples are selected, the CLI rejects a future + `--now` before writing any sample files (so generated valid samples are never + written in a state that would fail local verification). +- Because `iat` reflects issuance time, generated valid record bytes are not + identical across runs. +- `--kid ` sets the key id in both the generated JWS protected header and + `bundles/sandbox-jwks.json`. -- **basic-receipt**: Minimal receipt with only required fields (iss, aud, iat, exp, rid) -- **full-receipt**: Receipt with all optional claims populated -- **interaction-evidence**: Receipt with InteractionEvidence extension for AI agent calls -- **payment-evidence**: Receipt with payment evidence (402 flow) -- **long-expiry**: Receipt with 24-hour expiration +## Sample Categories -### Invalid Samples +### Valid Samples -These receipts should be rejected by verifiers: +Generated valid samples are current PEAC signed interaction records that pass +local verification: -- **expired**: Receipt that has already expired -- **future-iat**: Receipt with iat in the future (clock skew violation) -- **missing-iss**: Receipt missing required issuer claim +- **basic-record**: minimal valid record +- **full-record**: record with optional fields (subject, declared purpose) +- **mcp-tool-run**: record for an MCP tool run +- **payment-event**: record for a payment event +- **event-time-record**: record carrying an event time (`occurred_at`) -## Offline Verification Bundle +### Invalid Samples -The `bundles/offline-verification.json` file contains: +These are legacy rejection fixtures that local verification rejects: -- JWKS with test public keys -- Metadata about the samples -- Can be used for offline verification testing +- **expired**: already expired +- **future-iat**: issuance time in the future (clock-skew violation) +- **missing-iss**: missing the required issuer claim ## Using Samples ```typescript -import { verifyReceipt } from '@peac/protocol'; - -// Read a sample receipt -const receipt = fs.readFileSync('valid/basic-receipt.jws', 'utf8'); - -// Verify it -const result = await verifyReceipt(receipt); -if (result.ok) { - console.log('Valid receipt:', result.claims); -} else { - console.log('Invalid:', result.reason); -} +import { readFileSync } from 'node:fs'; +import { verifyLocal } from '@peac/protocol'; +import { jwkToPublicKeyBytes } from '@peac/crypto'; + +// Load the generated public verification key (single-key JWKS). +const jwks = JSON.parse(readFileSync('bundles/sandbox-jwks.json', 'utf8')); +const publicKey = jwkToPublicKeyBytes(jwks.keys[0]); + +// Verify a generated valid sample offline (no network, no server). +const jws = readFileSync('valid/basic-record.jws', 'utf8').trim(); +const result = await verifyLocal(jws, publicKey); +console.log('valid:', result.valid); ``` ## Notes -- Sample receipts are signed with test keys (kid starts with `sandbox-`) -- Do NOT use these in production -- Expiration times are relative to when samples were generated -- Re-generate samples periodically to keep them fresh +- Sample records are signed with sandbox keys (kid starts with `sandbox-`). +- Do NOT use these in production. +- Re-generate samples to refresh them. diff --git a/specs/conformance/samples/bundles/offline-verification.json b/specs/conformance/samples/bundles/offline-verification.json index d5b89d7e4..3dc1fa673 100644 --- a/specs/conformance/samples/bundles/offline-verification.json +++ b/specs/conformance/samples/bundles/offline-verification.json @@ -1,6 +1,6 @@ { - "$comment": "Offline verification bundle with sample receipts and JWKS", - "description": "Bundle for offline verification testing. Contains test JWKS and sample metadata.", + "$comment": "Offline verification metadata for the generated sample records and the sandbox JWKS.", + "description": "Metadata describing the generated sample records and the sandbox public verification keys. Regenerated by 'peac samples generate'.", "generated_at": "2026-02-05T00:00:00.000Z", "jwks": { "keys": [ @@ -16,49 +16,50 @@ }, "samples": [ { - "id": "basic-receipt", - "description": "Minimal valid PEAC receipt with only required fields", + "id": "basic-record", + "description": "Minimal valid PEAC signed interaction record", "category": "valid" }, { - "id": "full-receipt", - "description": "Full PEAC receipt with all optional claims populated", + "id": "full-record", + "description": "Valid PEAC signed interaction record with optional fields", "category": "valid" }, { - "id": "interaction-evidence", - "description": "PEAC receipt with InteractionEvidence extension for AI agent calls", + "id": "mcp-tool-run", + "description": "Valid PEAC signed interaction record for an MCP tool run", "category": "valid" }, { - "id": "payment-evidence", - "description": "PEAC receipt with payment evidence (402 flow)", + "id": "payment-event", + "description": "Valid PEAC signed interaction record for a payment event", "category": "valid" }, { - "id": "long-expiry", - "description": "PEAC receipt with 24-hour expiration", + "id": "event-time-record", + "description": "Valid PEAC signed interaction record carrying an event time (occurred_at)", "category": "valid" }, { "id": "expired", - "description": "Expired receipt (for testing rejection)", + "description": "Legacy rejection fixture: already expired (for testing rejection)", "category": "invalid" }, { "id": "future-iat", - "description": "Receipt with iat in the future (for testing rejection)", + "description": "Legacy rejection fixture: iat in the future (for testing rejection)", "category": "invalid" }, { "id": "missing-iss", - "description": "Receipt missing required issuer claim (for testing rejection)", + "description": "Legacy rejection fixture: missing required issuer claim (for testing rejection)", "category": "invalid" } ], "notes": [ - "The JWKS contains a placeholder key. Use 'peac samples generate' to create actual signed samples.", - "Sample timestamps are examples; regenerate for current timestamps.", - "Do NOT use sandbox samples in production." + "The JWKS contains a placeholder key. Run 'peac samples generate' to create actual signed sample records and the matching sandbox JWKS.", + "Valid sample records are issued via the current issue() path and pass local verification (verifyLocal).", + "Invalid samples are intentionally invalid rejection fixtures.", + "Sample records are for local testing and demonstration; they do not prove that any real-world event occurred. Do NOT use sandbox samples in production." ] } diff --git a/specs/conformance/samples/valid/basic-receipt.json b/specs/conformance/samples/valid/basic-receipt.json deleted file mode 100644 index a8c71a2d0..000000000 --- a/specs/conformance/samples/valid/basic-receipt.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$comment": "Minimal valid PEAC receipt - only required fields. Timestamps are examples; use CLI to generate with current timestamps.", - "header": { - "alg": "EdDSA", - "typ": "peac-receipt/0.1", - "kid": "sandbox-2026-02" - }, - "payload": { - "iss": "https://sandbox.peacprotocol.org", - "aud": "https://example.com", - "iat": 1738756800, - "exp": 1738760400, - "rid": "sample-basic-001" - } -} diff --git a/specs/conformance/samples/valid/basic-record.json b/specs/conformance/samples/valid/basic-record.json new file mode 100644 index 000000000..b98124f96 --- /dev/null +++ b/specs/conformance/samples/valid/basic-record.json @@ -0,0 +1,9 @@ +{ + "$comment": "Minimal valid PEAC signed interaction record. Issued via the current issue() path; generated output passes local verification. jti is a fresh UUIDv7 assigned at generation time.", + "format": "issue-options", + "input": { + "iss": "https://sandbox.peacprotocol.org", + "kind": "evidence", + "type": "org.peacprotocol/access" + } +} diff --git a/specs/conformance/samples/valid/event-time-record.json b/specs/conformance/samples/valid/event-time-record.json new file mode 100644 index 000000000..0e929d4ad --- /dev/null +++ b/specs/conformance/samples/valid/event-time-record.json @@ -0,0 +1,10 @@ +{ + "$comment": "Valid PEAC signed interaction record that carries an event time. occurred_at is the interaction time; iat remains issuance time. `samples generate --now ` overrides occurred_at. jti is a fresh UUIDv7 assigned at generation time.", + "format": "issue-options", + "input": { + "iss": "https://sandbox.peacprotocol.org", + "kind": "evidence", + "type": "org.peacprotocol/access", + "occurred_at": "2026-01-01T00:00:00.000Z" + } +} diff --git a/specs/conformance/samples/valid/full-receipt.json b/specs/conformance/samples/valid/full-receipt.json deleted file mode 100644 index cd5afe330..000000000 --- a/specs/conformance/samples/valid/full-receipt.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$comment": "Full PEAC receipt with all optional claims populated", - "header": { - "alg": "EdDSA", - "typ": "peac-receipt/0.1", - "kid": "sandbox-2026-02" - }, - "payload": { - "iss": "https://sandbox.peacprotocol.org", - "aud": "https://api.example.com", - "sub": "user:demo-user", - "iat": 1738756800, - "exp": 1738760400, - "rid": "sample-full-001", - "purpose_declared": ["search", "index"], - "purpose_enforced": "search", - "purpose_reason": "allowed" - } -} diff --git a/specs/conformance/samples/valid/full-record.json b/specs/conformance/samples/valid/full-record.json new file mode 100644 index 000000000..a43b67e38 --- /dev/null +++ b/specs/conformance/samples/valid/full-record.json @@ -0,0 +1,11 @@ +{ + "$comment": "Valid PEAC signed interaction record with optional fields (subject, declared purpose). Issued via issue(); passes local verification. jti is a fresh UUIDv7 assigned at generation time.", + "format": "issue-options", + "input": { + "iss": "https://sandbox.peacprotocol.org", + "kind": "evidence", + "type": "org.peacprotocol/access", + "sub": "agent:demo-agent", + "purpose_declared": "search" + } +} diff --git a/specs/conformance/samples/valid/interaction-evidence.json b/specs/conformance/samples/valid/interaction-evidence.json deleted file mode 100644 index 65e43446b..000000000 --- a/specs/conformance/samples/valid/interaction-evidence.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$comment": "PEAC receipt with InteractionEvidence extension for AI agent tool calls", - "header": { - "alg": "EdDSA", - "typ": "peac-receipt/0.1", - "kid": "sandbox-2026-02" - }, - "payload": { - "iss": "https://sandbox.peacprotocol.org", - "aud": "https://agent.example.com", - "sub": "agent:demo-agent-v1", - "iat": 1738756800, - "exp": 1738760400, - "rid": "sample-ie-001", - "ext": { - "org.peacprotocol/interaction@0.1": { - "version": "0.1", - "interaction_id": "int_sample_001", - "started_at": "2026-02-05T10:00:00.000Z", - "completed_at": "2026-02-05T10:00:01.234Z", - "outcome": { - "kind": "success" - }, - "input": { - "hash": "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", - "byte_length": 1024 - }, - "output": { - "hash": "sha256:f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5", - "byte_length": 2048 - } - } - } - } -} diff --git a/specs/conformance/samples/valid/long-expiry.json b/specs/conformance/samples/valid/long-expiry.json deleted file mode 100644 index f83fe03be..000000000 --- a/specs/conformance/samples/valid/long-expiry.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$comment": "PEAC receipt with 24-hour expiration (86400 seconds)", - "header": { - "alg": "EdDSA", - "typ": "peac-receipt/0.1", - "kid": "sandbox-2026-02" - }, - "payload": { - "iss": "https://sandbox.peacprotocol.org", - "aud": "https://example.com", - "iat": 1738756800, - "exp": 1738843200, - "rid": "sample-long-expiry-001" - } -} diff --git a/specs/conformance/samples/valid/mcp-tool-run.json b/specs/conformance/samples/valid/mcp-tool-run.json new file mode 100644 index 000000000..798331d71 --- /dev/null +++ b/specs/conformance/samples/valid/mcp-tool-run.json @@ -0,0 +1,15 @@ +{ + "$comment": "Valid PEAC signed interaction record for an MCP tool run. Issued via issue() with the org.peacprotocol/mcp extension; passes local verification. jti is a fresh UUIDv7 assigned at generation time.", + "format": "issue-options", + "input": { + "iss": "https://sandbox.peacprotocol.org", + "kind": "evidence", + "type": "org.peacprotocol/mcp", + "extensions": { + "org.peacprotocol/mcp": { + "server": "demo", + "tool": "search" + } + } + } +} diff --git a/specs/conformance/samples/valid/payment-event.json b/specs/conformance/samples/valid/payment-event.json new file mode 100644 index 000000000..0fc15539d --- /dev/null +++ b/specs/conformance/samples/valid/payment-event.json @@ -0,0 +1,16 @@ +{ + "$comment": "Valid PEAC signed interaction record for a payment event. Issued via issue() with the org.peacprotocol/commerce extension; passes local verification. jti is a fresh UUIDv7 assigned at generation time.", + "format": "issue-options", + "input": { + "iss": "https://sandbox.peacprotocol.org", + "kind": "evidence", + "type": "org.peacprotocol/payment", + "extensions": { + "org.peacprotocol/commerce": { + "payment_rail": "stripe", + "amount_minor": "1000", + "currency": "USD" + } + } + } +} diff --git a/specs/conformance/samples/valid/payment-evidence.json b/specs/conformance/samples/valid/payment-evidence.json deleted file mode 100644 index 931cae1b5..000000000 --- a/specs/conformance/samples/valid/payment-evidence.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$comment": "PEAC receipt with payment evidence for 402 payment flows", - "header": { - "alg": "EdDSA", - "typ": "peac-receipt/0.1", - "kid": "sandbox-2026-02" - }, - "payload": { - "iss": "https://sandbox.peacprotocol.org", - "aud": "https://api.example.com", - "iat": 1738756800, - "exp": 1738760400, - "rid": "sample-payment-001", - "amt": "100", - "cur": "USD", - "payment": { - "rail": "x402", - "reference": "pay_sample_001", - "amount": "100", - "currency": "USD" - } - } -}