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

@@ -24,7 +24,7 @@ const env = loadEnv({
})
const verifier: CapabilityVerifier = {
verify(raw, expectedAud, now): CapabilityToken {
async verify(raw, expectedAud, now): Promise<CapabilityToken> {
if (raw !== 'tokenA') throw new Error('invalid token')
return { sub: ACCOUNT_A, aud: expectedAud, host: 'x', rights: ['manage'] as CapabilityRight[], iat: now, exp: now + 3600, jti: 'jti-A' }
},

View File

@@ -25,8 +25,9 @@ const env = loadEnv({
})
// Fake P5 verifier: 'tokenA'→account A (manage), 'attachA'→account A (attach only). Else reject.
// Async to match the `CapabilityVerifier` seam (real §4.3 verify is async).
const verifier: CapabilityVerifier = {
verify(raw, expectedAud, now): CapabilityToken {
async verify(raw, expectedAud, now): Promise<CapabilityToken> {
const base = { aud: expectedAud, host: 'x', iat: now, exp: now + 3600, jti: `jti-${raw}` }
if (raw === 'tokenA') return { ...base, sub: ACCOUNT_A, rights: ['manage'] as CapabilityRight[] }
if (raw === 'attachA') return { ...base, sub: ACCOUNT_A, rights: ['attach'] as CapabilityRight[] }

View File

@@ -0,0 +1,390 @@
/**
* A1 — integration tests for the Postgres store adapter (src/store/pg.ts) + migration runner
* (src/db/migrate.ts). Exercises EVERY port method and the tricky semantics that must match
* store/memory.ts: INV8 version history, casRedeem single-winner, registerFailure, subdomain
* reserve/isTaken, route TTL fail-closed + listByNode, append-only metering/audit time windows.
*
* Gated on PG_TEST_URL. If unset, a disposable postgres:16 container is started on port 5433.
*/
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { execSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { setTimeout as sleep } from 'node:timers/promises'
import { createPgPool, createQuery, type QueryFn } from '../../src/db/pool.js'
import { runMigrations } from '../../src/db/migrate.js'
import { createPgStores } from '../../src/store/pg.js'
import type { Stores } from '../../src/store/ports.js'
import type { AccountRecord, HostRecord } from '../../src/model/records.js'
const CONTAINER = 'cp_pg_a1_test'
const PORT = 5433
const READY_TIMEOUT_MS = 60_000
let pool: ReturnType<typeof createPgPool> | undefined
let query: QueryFn
let stores: Stores
let startedContainer = false
beforeAll(async () => {
let url = process.env.PG_TEST_URL
if (!url) {
try {
execSync(`docker rm -f ${CONTAINER}`, { stdio: 'ignore' })
} catch {
/* nothing to remove */
}
execSync(
`docker run -d --rm --name ${CONTAINER} -e POSTGRES_PASSWORD=test -e POSTGRES_DB=cp -p ${PORT}:5432 postgres:16`,
{ stdio: 'ignore' },
)
startedContainer = true
url = `postgres://postgres:test@127.0.0.1:${PORT}/cp`
}
pool = createPgPool(url)
query = createQuery(pool)
// Poll until the server accepts queries.
const deadline = Date.now() + READY_TIMEOUT_MS
for (;;) {
try {
await query('SELECT 1', [])
break
} catch (err) {
if (Date.now() > deadline) throw err
await sleep(500)
}
}
await runMigrations(query)
// Idempotency: a second run must not throw (all migrations are IF NOT EXISTS).
await runMigrations(query)
stores = createPgStores(query)
}, 180_000)
afterAll(async () => {
await pool?.end()
if (startedContainer) {
try {
execSync(`docker rm -f ${CONTAINER}`, { stdio: 'ignore' })
} catch {
/* best effort */
}
}
})
// ---- fixtures ---------------------------------------------------------------------------------
function newAccount(): AccountRecord {
return { accountId: randomUUID(), plan: 'free', createdAt: new Date().toISOString(), status: 'active' }
}
async function makeAccount(): Promise<AccountRecord> {
const rec = newAccount()
await stores.accounts.insert(rec)
return rec
}
async function makeHost(accountId: string, subdomain = `sub-${randomUUID().slice(0, 8)}`): Promise<HostRecord> {
const rec: HostRecord = {
hostId: randomUUID(),
accountId,
subdomain,
agentPubkey: new Uint8Array([1, 2, 3, 4]),
enrollFpr: `fpr-${subdomain}`,
status: 'offline',
lastSeen: new Date().toISOString(),
createdAt: new Date().toISOString(),
revokedAt: null,
}
await stores.hosts.insert(rec)
return rec
}
// ---- AccountStore -----------------------------------------------------------------------------
describe('AccountStore', () => {
test('insert + get round-trips the record', async () => {
const rec = await makeAccount()
expect(await stores.accounts.get(rec.accountId)).toEqual(rec)
})
test('get returns null for unknown account', async () => {
expect(await stores.accounts.get(randomUUID())).toBeNull()
})
test('duplicate insert throws', async () => {
const rec = await makeAccount()
await expect(stores.accounts.insert(rec)).rejects.toThrow('duplicate accountId')
})
test('swapStatus bumps status + appends INV8 version history atomically', async () => {
const rec = await makeAccount()
expect(await stores.accounts.versions(rec.accountId)).toEqual([
{ accountId: rec.accountId, version: 1, status: 'active', changedAt: rec.createdAt, changedBy: 'system' },
])
const swapped = await stores.accounts.swapStatus(rec.accountId, 'suspended', 'admin-1')
expect(swapped.status).toBe('suspended')
expect(swapped.accountId).toBe(rec.accountId)
expect((await stores.accounts.get(rec.accountId))?.status).toBe('suspended')
const versions = await stores.accounts.versions(rec.accountId)
expect(versions.length).toBe(2)
expect(versions[1]).toMatchObject({ version: 2, status: 'suspended', changedBy: 'admin-1' })
})
test('swapStatus on unknown account throws', async () => {
await expect(stores.accounts.swapStatus(randomUUID(), 'suspended', 'x')).rejects.toThrow('account not found')
})
})
// ---- HostStore --------------------------------------------------------------------------------
describe('HostStore', () => {
test('insert + get + getBySubdomain + listByAccount', async () => {
const acct = await makeAccount()
const host = await makeHost(acct.accountId)
expect(await stores.hosts.get(host.hostId)).toEqual(host)
expect(await stores.hosts.getBySubdomain(host.subdomain)).toEqual(host)
expect(await stores.hosts.listByAccount(acct.accountId)).toEqual([host])
})
test('get / getBySubdomain return null when absent', async () => {
expect(await stores.hosts.get(randomUUID())).toBeNull()
expect(await stores.hosts.getBySubdomain(`missing-${randomUUID()}`)).toBeNull()
})
test('duplicate hostId and duplicate subdomain throw distinctly', async () => {
const acct = await makeAccount()
const host = await makeHost(acct.accountId)
await expect(stores.hosts.insert(host)).rejects.toThrow('duplicate hostId')
const clash: HostRecord = { ...host, hostId: randomUUID() }
await expect(stores.hosts.insert(clash)).rejects.toThrow('duplicate subdomain')
})
test('swapStatus to revoked sets revokedAt + version history', async () => {
const acct = await makeAccount()
const host = await makeHost(acct.accountId)
const revokedAt = new Date().toISOString()
const swapped = await stores.hosts.swapStatus(host.hostId, 'revoked', revokedAt, 'admin-2')
expect(swapped.status).toBe('revoked')
expect(swapped.revokedAt).toBe(revokedAt)
const versions = await stores.hosts.versions(host.hostId)
expect(versions.length).toBe(2)
expect(versions[0]).toMatchObject({ version: 1, status: 'offline', revokedAt: null, changedBy: 'system' })
expect(versions[1]).toMatchObject({ version: 2, status: 'revoked', revokedAt, changedBy: 'admin-2' })
})
test('swapStatus on unknown host throws', async () => {
await expect(stores.hosts.swapStatus(randomUUID(), 'online', null, 'x')).rejects.toThrow('host not found')
})
test('touchLastSeen updates the advisory cache (no version row)', async () => {
const acct = await makeAccount()
const host = await makeHost(acct.accountId)
const ts = new Date(Date.now() + 5000).toISOString()
await stores.hosts.touchLastSeen(host.hostId, ts)
expect((await stores.hosts.get(host.hostId))?.lastSeen).toBe(ts)
expect((await stores.hosts.versions(host.hostId)).length).toBe(1) // unchanged
await stores.hosts.touchLastSeen(randomUUID(), ts) // no-op on absent host, no throw
})
})
// ---- SessionStore -----------------------------------------------------------------------------
describe('SessionStore', () => {
test('insert + get + duplicate + missing', async () => {
const acct = await makeAccount()
const host = await makeHost(acct.accountId)
const rec = {
sessionId: randomUUID(),
hostId: host.hostId,
accountId: acct.accountId,
createdAt: new Date().toISOString(),
lastAttachAt: new Date().toISOString(),
}
await stores.sessions.insert(rec)
expect(await stores.sessions.get(rec.sessionId)).toEqual(rec)
await expect(stores.sessions.insert(rec)).rejects.toThrow('duplicate sessionId')
expect(await stores.sessions.get(randomUUID())).toBeNull()
})
})
// ---- SubdomainStore ---------------------------------------------------------------------------
describe('SubdomainStore', () => {
test('reserve/isTaken reflect host ownership of the label', async () => {
const sub = `claim-${randomUUID().slice(0, 8)}`
expect(await stores.subdomains.isTaken(sub)).toBe(false)
expect(await stores.subdomains.reserve(sub)).toBe(true) // free (no host owns it yet)
const acct = await makeAccount()
await makeHost(acct.accountId, sub)
expect(await stores.subdomains.isTaken(sub)).toBe(true)
expect(await stores.subdomains.reserve(sub)).toBe(false) // a host now owns it
})
})
// ---- PairingStore -----------------------------------------------------------------------------
describe('PairingStore', () => {
test('insert + get + casRedeem single-winner + registerFailure', async () => {
const acct = await makeAccount()
const codeHash = `code-${randomUUID()}`
const rec = {
codeHash,
accountId: acct.accountId,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
redeemedAt: null,
}
await stores.pairing.insert(rec)
await expect(stores.pairing.insert(rec)).rejects.toThrow('duplicate code_hash')
const got = await stores.pairing.get(codeHash)
expect(got).toEqual({ record: rec, redeemAttempts: 0 })
const now = new Date().toISOString()
expect(await stores.pairing.casRedeem(codeHash, now)).toBe('ok')
expect(await stores.pairing.casRedeem(codeHash, now)).toBe('already_redeemed')
expect(await stores.pairing.casRedeem(`unknown-${randomUUID()}`, now)).toBe('unknown')
expect((await stores.pairing.get(codeHash))?.record.redeemedAt).toBe(now)
})
test('registerFailure increments; unknown code returns 0', async () => {
const acct = await makeAccount()
const codeHash = `code-${randomUUID()}`
await stores.pairing.insert({
codeHash,
accountId: acct.accountId,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
redeemedAt: null,
})
expect(await stores.pairing.registerFailure(codeHash)).toBe(1)
expect(await stores.pairing.registerFailure(codeHash)).toBe(2)
expect((await stores.pairing.get(codeHash))?.redeemAttempts).toBe(2)
expect(await stores.pairing.registerFailure(`unknown-${randomUUID()}`)).toBe(0)
})
test('get returns null for unknown code', async () => {
expect(await stores.pairing.get(`nope-${randomUUID()}`)).toBeNull()
})
})
// ---- RouteStore -------------------------------------------------------------------------------
describe('RouteStore', () => {
test('set → get → refreshTtl → delete', async () => {
const hostId = randomUUID()
const entry = { relayNodeId: 'node-A', updatedAt: new Date().toISOString() }
await stores.routes.set(hostId, entry, 60)
expect(await stores.routes.get(hostId)).toEqual(entry)
expect(await stores.routes.refreshTtl(hostId, 60)).toBe(true)
await stores.routes.delete(hostId)
expect(await stores.routes.get(hostId)).toBeNull()
})
test('expired route fails closed (get null, refreshTtl false)', async () => {
const hostId = randomUUID()
const entry = { relayNodeId: 'node-A', updatedAt: new Date().toISOString() }
await stores.routes.set(hostId, entry, -1) // already expired
expect(await stores.routes.get(hostId)).toBeNull()
expect(await stores.routes.refreshTtl(hostId, 60)).toBe(false)
expect(await stores.routes.refreshTtl(randomUUID(), 60)).toBe(false) // absent
})
test('listByNode returns only live routes targeting the node', async () => {
const node = `node-${randomUUID().slice(0, 8)}`
const live1 = randomUUID()
const live2 = randomUUID()
const expired = randomUUID()
const otherNode = randomUUID()
await stores.routes.set(live1, { relayNodeId: node, updatedAt: new Date().toISOString() }, 60)
await stores.routes.set(live2, { relayNodeId: node, updatedAt: new Date().toISOString() }, 60)
await stores.routes.set(expired, { relayNodeId: node, updatedAt: new Date().toISOString() }, -1)
await stores.routes.set(otherNode, { relayNodeId: 'someone-else', updatedAt: new Date().toISOString() }, 60)
const listed = await stores.routes.listByNode(node)
const hostIds = listed.map((r) => r.hostId).sort()
expect(hostIds).toEqual([live1, live2].sort())
expect(listed.every((r) => r.entry.relayNodeId === node)).toBe(true)
})
})
// ---- NodeStore --------------------------------------------------------------------------------
describe('NodeStore', () => {
test('upsert + get + setStatus + touch + delete', async () => {
const nodeId = `relay-${randomUUID().slice(0, 8)}`
const row = { nodeId, addr: '10.0.0.1:9000', status: 'active' as const, lastSeen: new Date().toISOString() }
await stores.nodes.upsert(row)
expect(await stores.nodes.get(nodeId)).toEqual(row)
await stores.nodes.upsert({ ...row, addr: '10.0.0.2:9000' }) // upsert overwrites
expect((await stores.nodes.get(nodeId))?.addr).toBe('10.0.0.2:9000')
await stores.nodes.setStatus(nodeId, 'draining')
expect((await stores.nodes.get(nodeId))?.status).toBe('draining')
const later = new Date(Date.now() + 1000).toISOString()
await stores.nodes.touch(nodeId, later)
expect((await stores.nodes.get(nodeId))?.lastSeen).toBe(later)
await stores.nodes.delete(nodeId)
expect(await stores.nodes.get(nodeId)).toBeNull()
})
test('setStatus on unknown node throws; touch is a silent no-op', async () => {
await expect(stores.nodes.setStatus(`ghost-${randomUUID()}`, 'draining')).rejects.toThrow('node not found')
await stores.nodes.touch(`ghost-${randomUUID()}`, new Date().toISOString()) // no throw
})
})
// ---- MeteringStore ----------------------------------------------------------------------------
describe('MeteringStore', () => {
test('append-only + inclusive [from,to] time-window query filtered by account', async () => {
const acct = await makeAccount()
const other = await makeAccount()
const host = await makeHost(acct.accountId)
const otherHost = await makeHost(other.accountId)
const t0 = '2026-01-01T00:00:00.000Z'
const t1 = '2026-01-01T01:00:00.000Z'
const t2 = '2026-01-01T02:00:00.000Z'
await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 3, sampledAt: t0 })
await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 5, sampledAt: t1 })
await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 9, sampledAt: t2 })
await stores.metering.append({ hostId: otherHost.hostId, accountId: other.accountId, nodeId: 'n1', concurrentViewers: 1, sampledAt: t1 })
const inWindow = await stores.metering.query(acct.accountId, t0, t1)
expect(inWindow.map((r) => r.concurrentViewers)).toEqual([3, 5]) // t2 excluded, other acct excluded
expect(inWindow[0]).toMatchObject({ hostId: host.hostId, accountId: acct.accountId, sampledAt: t0 })
const all = await stores.metering.query(acct.accountId, t0, t2)
expect(all.length).toBe(3)
})
})
// ---- AuditStore -------------------------------------------------------------------------------
describe('AuditStore', () => {
test('append-only + time-window query + null hostId + meta jsonb round-trip', async () => {
const accountId = randomUUID()
const t0 = '2026-02-01T00:00:00.000Z'
const t1 = '2026-02-01T01:00:00.000Z'
const t2 = '2026-02-01T02:00:00.000Z'
await stores.audit.append({ action: 'provision', principalId: 'p1', accountId, hostId: 'h1', ts: t0, meta: { ip: '1.2.3.4' } })
await stores.audit.append({ action: 'revoke', principalId: 'p2', accountId, hostId: null, ts: t1, meta: {} })
await stores.audit.append({ action: 'late', principalId: 'p3', accountId, hostId: null, ts: t2, meta: {} })
await stores.audit.append({ action: 'other', principalId: 'p9', accountId: randomUUID(), hostId: null, ts: t1, meta: {} })
const rows = await stores.audit.query(accountId, t0, t1)
expect(rows.map((r) => r.action)).toEqual(['provision', 'revoke']) // t2 + other account excluded
expect(rows[0]).toEqual({ action: 'provision', principalId: 'p1', accountId, hostId: 'h1', ts: t0, meta: { ip: '1.2.3.4' } })
expect(rows[1]?.hostId).toBeNull()
})
})

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()
})
})