feat(relay): Phase1 foundation — P3 Postgres store + async capability verifier + compose

RELAY-PHASE1 Wave A (2/3) + E1 infra:
- A1: createPgStores() Postgres adapter for all 9 P3 store ports + runMigrations();
  writable-CTE atomic INV8 status swaps; 0002_routes.sql. 23/23 pg tests, 96.72% cov.
- A3: real capability verifier delegating to relay-auth verifyCapabilityToken; sync->async
  seam across authz/provision/main. Full CP suite 15 files/101 pass, tsc clean.
- E1: deploy/docker-compose.yml (Postgres16+Redis7, loopback-only) + .env.example.
- docs: PLAN_RELAY_PHASE1.md file-level execution spec; PROGRESS_LOG RELAY-PHASE1 section.
This commit is contained in:
Yaojia Wang
2026-07-06 14:51:37 +02:00
parent 242a4e0dc1
commit 95b9cccf07
17 changed files with 1488 additions and 16 deletions

View File

@@ -0,0 +1,78 @@
/**
* A3 — REAL capability verifier (boot/verifier.ts) backed by relay-auth's async `verifyCapabilityToken`.
* Mints tokens with the paired Ed25519 private key via P5's `issueCapabilityToken`, configures the
* matching public key, and asserts: a good token verifies; wrong `aud`, past `exp`, and a token
* signed by a NON-matching key all reject (deny-by-default, INV6).
*/
import { describe, test, expect, beforeAll } from 'vitest'
import { issueCapabilityToken } from 'relay-auth'
import type { AuthenticatedPrincipal } from 'relay-auth'
import { encodeBase64UrlBytes } from 'relay-contracts'
import { createCapabilityVerifier, configureCapabilityVerifyKey } from '../src/boot/verifier.js'
import type { CapabilityVerifier } from '../src/api/authz.js'
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
const AUD = 'term.example.com'
const NOW = 1_000_000
// A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-]; 32 bytes → 43 base64url chars.
const CNF_JKT = encodeBase64UrlBytes(new Uint8Array(32).fill(7))
const principal: AuthenticatedPrincipal = {
kind: 'human',
accountId: ACCOUNT_A,
principalId: 'cred-1',
amr: ['passkey'],
authAt: NOW - 10,
stepUpAt: null,
}
type KeyPair = { publicKey: CryptoKey; privateKey: CryptoKey }
async function genKeyPair(): Promise<KeyPair> {
// generateKey's named-algorithm overload is typed as CryptoKey; Ed25519 yields a pair at runtime.
return (await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
}
function mint(signingKey: CryptoKey, now: number): Promise<string> {
return issueCapabilityToken(
{ principal, aud: AUD, host: 'host-1', rights: ['manage'], ttlSeconds: 60, cnfJkt: CNF_JKT },
signingKey,
now,
)
}
describe('A3 real capability verifier (relay-auth verifyCapabilityToken)', () => {
let signingKey: CryptoKey
let verifier: CapabilityVerifier
beforeAll(async () => {
const pair = await genKeyPair()
signingKey = pair.privateKey
const rawPub = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', pair.publicKey))
await configureCapabilityVerifyKey(rawPub)
verifier = createCapabilityVerifier()
})
test('verifies a token signed by the configured key and returns the account principal', async () => {
const raw = await mint(signingKey, NOW)
const token = await verifier.verify(raw, AUD, NOW)
expect(token.sub).toBe(ACCOUNT_A)
expect(token.aud).toBe(AUD)
expect(token.rights).toContain('manage')
})
test('rejects a token minted for a different aud (Host-confusion guard)', async () => {
const raw = await mint(signingKey, NOW)
await expect(verifier.verify(raw, 'evil.example.com', NOW)).rejects.toThrow()
})
test('rejects an expired token', async () => {
const raw = await mint(signingKey, NOW) // exp = NOW + 60
await expect(verifier.verify(raw, AUD, NOW + 120)).rejects.toThrow()
})
test('rejects a token signed by a NON-matching key (bad signature)', async () => {
const other = await genKeyPair()
const raw = await mint(other.privateKey, NOW)
await expect(verifier.verify(raw, AUD, NOW)).rejects.toThrow()
})
})