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:
@@ -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'}`)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user