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

@@ -20,9 +20,12 @@ export class AuthzError extends Error {
}
}
/** P5's verifier, injected. Mirrors the frozen `verifyCapabilityToken` signature (§4.3). */
/**
* P5's verifier, injected. Mirrors the frozen `verifyCapabilityToken` signature (§4.3), which is
* ASYNC (Ed25519 verify over WebCrypto) — so this seam returns a Promise and all call sites await.
*/
export interface CapabilityVerifier {
verify(raw: string, expectedAud: string, now: number): CapabilityToken
verify(raw: string, expectedAud: string, now: number): Promise<CapabilityToken>
}
/** Minimal request shape we read for the bearer token (never a body account field). */
@@ -49,19 +52,19 @@ function extractRawToken(req: AuthRequest): string | null {
}
export interface Authorizer {
principalFromRequest(req: AuthRequest): AdminPrincipal
principalFromRequest(req: AuthRequest): Promise<AdminPrincipal>
requireRight(principal: AdminPrincipal, right: CapabilityRight): void
}
export function createAuthorizer(deps: AuthzDeps): Authorizer {
const now = deps.now ?? (() => Math.floor(Date.now() / 1000))
return {
principalFromRequest(req) {
async principalFromRequest(req) {
const raw = extractRawToken(req)
if (raw === null) throw new AuthzError(401, 'missing capability token')
let token: CapabilityToken
try {
token = deps.verifier.verify(raw, deps.expectedAud, now())
token = await deps.verifier.verify(raw, deps.expectedAud, now())
} catch (err: unknown) {
throw new AuthzError(401, `invalid capability token: ${err instanceof Error ? err.message : 'rejected'}`)
}

View File

@@ -56,12 +56,12 @@ function assertOwnAccount(principal: AdminPrincipal, pathAccountId: string): voi
export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
return async (app) => {
const principal = (req: FastifyRequest): AdminPrincipal =>
const principal = (req: FastifyRequest): Promise<AdminPrincipal> =>
deps.authorizer.principalFromRequest({ headers: req.headers })
app.post('/accounts', async (req, reply) => {
try {
const p = principal(req)
const p = await principal(req)
deps.authorizer.requireRight(p, 'manage')
const plan: PlanTier = PlanSchema.parse((req.body as { plan?: unknown })?.plan ?? 'free')
const account = await deps.accounts.createAccount(plan)
@@ -73,7 +73,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
app.post('/accounts/:id/pairing-codes', async (req, reply) => {
try {
const p = principal(req)
const p = await principal(req)
deps.authorizer.requireRight(p, 'manage')
assertOwnAccount(p, (req.params as { id: string }).id)
const issued = await deps.pairingIssuer.issuePairingCode(p.accountId)
@@ -85,7 +85,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
app.post('/accounts/:id/status', async (req, reply) => {
try {
const p = principal(req)
const p = await principal(req)
deps.authorizer.requireRight(p, 'manage')
assertOwnAccount(p, (req.params as { id: string }).id)
const { status } = StatusSchema.parse(req.body)
@@ -98,7 +98,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
app.get('/accounts/:id/hosts', async (req, reply) => {
try {
const p = principal(req)
const p = await principal(req)
assertOwnAccount(p, (req.params as { id: string }).id)
const hosts = await deps.hosts.listHosts(p.accountId) // ownership-scoped
await reply.send(hosts.map((h) => ({ ...h, agentPubkey: Buffer.from(h.agentPubkey).toString('base64') })))
@@ -109,7 +109,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
app.delete('/hosts/:hostId', async (req, reply) => {
try {
const p = principal(req)
const p = await principal(req)
deps.authorizer.requireRight(p, 'manage')
// account_id in the body is IGNORED — authz uses ONLY the token principal (INV3).
await deps.deprovisioner.deprovisionHost(p, (req.params as { hostId: string }).hostId)