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,15 @@
-- A1 — routes table (P3-owned). Not present in 0001_init.sql: the RouteStore port is a
-- Redis-like live routing table (INV7 — NOT source of truth). This Postgres adapter needs a
-- durable backing for it. Postgres has no per-key TTL, so we store an explicit `expires_at`
-- and treat `expires_at <= now()` as ABSENT (fail-closed, INV7); expired rows are lazily
-- DELETEd on access. This mirrors `store/memory.ts` memRouteStore() semantics exactly.
--
-- No FK to hosts(host_id): routes are an ephemeral location cache keyed by host_id, decoupled
-- from the ownership source of truth (matches memory.ts, where routes live independently).
CREATE TABLE IF NOT EXISTS routes (
host_id uuid PRIMARY KEY,
relay_node_id text NOT NULL,
updated_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS routes_node_idx ON routes(relay_node_id);

View File

@@ -16,6 +16,7 @@
},
"dependencies": {
"relay-contracts": "file:../relay-contracts",
"relay-auth": "file:../relay-auth",
"fastify": "^4.28.1",
"ioredis": "^5.4.1",
"pg": "^8.12.0",

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)

View File

@@ -0,0 +1,38 @@
/**
* A3 — REAL capability verifier, backed by relay-auth's `verifyCapabilityToken` (P5). Replaces the
* fail-closed `refuseAllVerifier` stub. The frozen §4.3 verify signature is ASYNC (Ed25519 verify
* over WebCrypto) and reads its verifying key from relay-auth's startup registry (config/keys.ts),
* never as a parameter — so we (a) adapt it to the async `CapabilityVerifier` seam and (b) load the
* key at boot from the CP env's already-validated pubkey.
*
* KEY BRIDGE (INV9): the CP env var is `CAPABILITY_SIGN_PUBKEY_B64` (base64), parsed+validated in
* `env.ts` to `env.capabilitySignPubkey` (32 raw Ed25519 bytes). relay-auth's own env loader expects
* a DIFFERENT var name (`RELAY_AUTH_VERIFY_PUBKEY`, base64url), so we do NOT use it — we import the
* 32 raw bytes to a non-exportable verify-only CryptoKey via WebCrypto and call relay-auth's
* `configureVerifyKey(CryptoKey)` directly.
*/
import { verifyCapabilityToken, configureVerifyKey } from 'relay-auth'
import type { CapabilityToken } from 'relay-contracts'
import type { CapabilityVerifier } from '../api/authz.js'
/** The real verifier: delegates verbatim to P5's frozen §4.3 `verifyCapabilityToken`. */
export function createCapabilityVerifier(): CapabilityVerifier {
return {
verify(raw: string, expectedAud: string, now: number): Promise<CapabilityToken> {
return verifyCapabilityToken(raw, expectedAud, now)
},
}
}
/**
* Load the §4.3 verifying key into relay-auth's startup registry from the CP env's raw 32-byte
* Ed25519 public key (`env.capabilitySignPubkey`). Non-exportable, `verify`-only. Must run before
* the real verifier serves any request, else `verifyCapabilityToken` throws `KeyConfigError`.
*/
export async function configureCapabilityVerifyKey(pubkeyRaw: Uint8Array): Promise<void> {
// Copy into a fresh ArrayBuffer-backed view (WebCrypto's BufferSource typing / no shared pool).
const bytes = new Uint8Array(pubkeyRaw.length)
bytes.set(pubkeyRaw)
const key = await globalThis.crypto.subtle.importKey('raw', bytes, { name: 'Ed25519' }, false, ['verify'])
configureVerifyKey(key)
}

View File

@@ -0,0 +1,48 @@
/**
* A1 — migration runner. Reads every `db/migrations/*.sql` in filename order and executes it
* over the parameterized `query` wrapper (db/pool.ts). All migrations are `IF NOT EXISTS`, so
* `runMigrations` is idempotent and safe to run at every boot / test setup.
*
* `createQuery` ALWAYS routes through the extended (parameterized) protocol — even with `[]`
* params — which rejects multi-statement command strings. So each file is split into its
* top-level statements (on `;`, after stripping `-- line comments`) and each statement is run
* as its own single-command `query(stmt, [])`. Our migrations are plain IF-NOT-EXISTS DDL with
* no `;` inside string literals and no dollar-quoted bodies, so this split is safe.
*/
import { readdir, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { QueryFn } from './pool.js'
/** Absolute path to `control-plane/db/migrations`, resolved relative to this source file. */
function migrationsDir(): string {
const here = dirname(fileURLToPath(import.meta.url)) // .../control-plane/src/db
return join(here, '..', '..', 'db', 'migrations') // .../control-plane/db/migrations
}
/** Strip `-- line comments`, then split into non-empty top-level statements on `;`. */
export function splitStatements(sql: string): readonly string[] {
const withoutComments = sql
.split('\n')
.map((line) => {
const idx = line.indexOf('--')
return idx >= 0 ? line.slice(0, idx) : line
})
.join('\n')
return withoutComments
.split(';')
.map((stmt) => stmt.trim())
.filter((stmt) => stmt.length > 0)
}
/** Apply all `*.sql` migrations in filename order. Idempotent (every migration is IF NOT EXISTS). */
export async function runMigrations(query: QueryFn): Promise<void> {
const dir = migrationsDir()
const files = (await readdir(dir)).filter((f) => f.endsWith('.sql')).sort()
for (const file of files) {
const sql = await readFile(join(dir, file), 'utf8')
for (const stmt of splitStatements(sql)) {
await query(stmt, [])
}
}
}

View File

@@ -30,6 +30,7 @@ import { createDeprovisioner } from './deprovision/deprovision.js'
import { createAuthorizer, type CapabilityVerifier } from './api/authz.js'
import { buildRouter } from './api/provision.js'
import { buildCaSigner, inProcessCaSigner, type KmsResolver } from './boot/ca-wiring.js'
import { configureCapabilityVerifyKey } from './boot/verifier.js'
import type { RevocationBus } from 'relay-contracts'
export interface ControlPlaneOverrides {
@@ -40,9 +41,13 @@ export interface ControlPlaneOverrides {
readonly caChainDer?: readonly Uint8Array[]
}
/** Default fail-closed verifier — refuses everything until P5 is wired (INV6). */
/**
* Default fail-closed verifier — refuses everything until a real (P5) verifier is injected (INV6).
* Async-shaped to match `CapabilityVerifier`: an async body that throws rejects the promise, so the
* authorizer's `await` surfaces it as a 401.
*/
const refuseAllVerifier: CapabilityVerifier = {
verify() {
async verify(): Promise<never> {
throw new Error('capability verification not configured (P5 integration point)')
},
}
@@ -92,8 +97,16 @@ export async function buildControlPlane(
const deprovisioner = createDeprovisioner({ hosts, routing })
void bus // reserved for the node-coordinator / revoker wiring (drain + revoke publishers)
// A real (injected) verifier is P5's async `verifyCapabilityToken`, which reads its Ed25519 key
// from relay-auth's startup registry — so load that key from env at boot. The fail-closed default
// never reads a key, so leave relay-auth's registry untouched when nothing is injected (INV6).
const verifier = overrides.verifier ?? refuseAllVerifier
if (overrides.verifier !== undefined) {
await configureCapabilityVerifyKey(env.capabilitySignPubkey)
}
const authorizer = createAuthorizer({
verifier: overrides.verifier ?? refuseAllVerifier,
verifier,
expectedAud: env.baseDomain,
})

View File

@@ -0,0 +1,600 @@
/**
* A1 — Postgres adapter for the repository ports (store/ports.ts). Each store maps its port
* methods to PARAMETERIZED SQL over the injected `query` wrapper (db/pool.ts) — values ALWAYS
* travel as bound params, never string-interpolated (SQLi structurally impossible, §7).
*
* This adapter MUST reproduce store/memory.ts observable semantics EXACTLY:
* - INV8 versioning: `swapStatus` appends a `*_status_versions` row AND bumps the single-row
* `status`/`status_version` pointer ATOMICALLY. Postgres has real transactions, but we get
* the same all-or-nothing with a single writable-CTE statement (no explicit tx / PoolClient).
* - `insert` duplicates throw (PK / UNIQUE violation, pg code 23505 → `throw new Error`).
* - `casRedeem` is a single-winner CAS of `redeemed_at` from null.
* - `RouteStore` fails closed on TTL: `expires_at <= now()` ⇒ absent (INV7), lazily DELETEd.
* - metering/audit are append-only with inclusive `[from,to]` time-window queries.
*
* Timestamps: DB columns are `timestamptz` (node-pg returns them as JS `Date`); records use ISO
* strings. `toIso` normalizes Date|string → ISO so returned records match memory.ts string shapes.
*/
import type {
AccountRecord,
AccountStatus,
AccountStatusVersionRow,
HostRecord,
HostStatus,
HostStatusVersionRow,
MeteringSampleRow,
PairingCodeRecord,
PlanTier,
RouteEntry,
SessionRecord,
} from '../model/records.js'
import type { QueryFn } from '../db/pool.js'
import type {
AccountStore,
AuditRow,
AuditStore,
CasOutcome,
HostStore,
MeteringStore,
NodeRow,
NodeStatus,
NodeStore,
PairingRow,
PairingStore,
RouteStore,
SessionStore,
Stores,
SubdomainStore,
} from './ports.js'
// ---- helpers ----------------------------------------------------------------------------------
const toIso = (v: Date | string): string => (v instanceof Date ? v : new Date(v)).toISOString()
const toIsoN = (v: Date | string | null): string | null => (v === null ? null : toIso(v))
/** pg unique/PK violation. */
function isUniqueViolation(err: unknown): err is { code: string; constraint?: string } {
return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === '23505'
}
// ---- raw DB row shapes ------------------------------------------------------------------------
interface AccountRow {
account_id: string
plan: PlanTier
created_at: Date | string
status: AccountStatus
}
interface HostRow {
host_id: string
account_id: string
subdomain: string
agent_pubkey: Buffer
enroll_fpr: string
status: HostStatus
last_seen: Date | string
created_at: Date | string
revoked_at: Date | string | null
}
interface SessionRow {
session_id: string
host_id: string
account_id: string
created_at: Date | string
last_attach_at: Date | string
}
interface AccountVersionRow {
account_id: string
version: number
status: AccountStatus
changed_at: Date | string
changed_by: string
}
interface HostVersionRow {
host_id: string
version: number
status: HostStatus
revoked_at: Date | string | null
changed_at: Date | string
changed_by: string
}
function mapAccount(r: AccountRow): AccountRecord {
return { accountId: r.account_id, plan: r.plan, createdAt: toIso(r.created_at), status: r.status }
}
function mapHost(r: HostRow): HostRecord {
return {
hostId: r.host_id,
accountId: r.account_id,
subdomain: r.subdomain,
agentPubkey: new Uint8Array(r.agent_pubkey),
enrollFpr: r.enroll_fpr,
status: r.status,
lastSeen: toIso(r.last_seen),
createdAt: toIso(r.created_at),
revokedAt: toIsoN(r.revoked_at),
}
}
// ---- AccountStore -----------------------------------------------------------------------------
function pgAccountStore(query: QueryFn): AccountStore {
return {
async insert(rec) {
// Writable CTE: create the row AND its INV8 version-1 companion in ONE statement.
// version-1 changed_at = created_at, changed_by = 'system' (matches memory.ts).
try {
await query(
`WITH ins AS (
INSERT INTO accounts (account_id, plan, created_at, status, status_version)
VALUES ($1, $2, $3, $4, 1)
RETURNING account_id, created_at, status
)
INSERT INTO account_status_versions (account_id, version, status, changed_at, changed_by)
SELECT account_id, 1, status, created_at, 'system' FROM ins`,
[rec.accountId, rec.plan, rec.createdAt, rec.status],
)
} catch (err) {
if (isUniqueViolation(err)) throw new Error('duplicate accountId')
throw err
}
},
async get(accountId) {
const rows = await query<AccountRow>(
`SELECT account_id, plan, created_at, status FROM accounts WHERE account_id = $1`,
[accountId],
)
const row = rows[0]
return row === undefined ? null : mapAccount(row)
},
async swapStatus(accountId, status, changedBy) {
// Atomic INV8: bump pointer + append version row in one writable-CTE statement.
const rows = await query<AccountRow>(
`WITH upd AS (
UPDATE accounts SET status = $2, status_version = status_version + 1
WHERE account_id = $1
RETURNING account_id, plan, created_at, status, status_version
),
ins AS (
INSERT INTO account_status_versions (account_id, version, status, changed_by)
SELECT account_id, status_version, status, $3 FROM upd
)
SELECT account_id, plan, created_at, status FROM upd`,
[accountId, status, changedBy],
)
const row = rows[0]
if (row === undefined) throw new Error('account not found')
return mapAccount(row)
},
async versions(accountId) {
const rows = await query<AccountVersionRow>(
`SELECT account_id, version, status, changed_at, changed_by
FROM account_status_versions WHERE account_id = $1 ORDER BY version ASC`,
[accountId],
)
return rows.map(
(r): AccountStatusVersionRow => ({
accountId: r.account_id,
version: r.version,
status: r.status,
changedAt: toIso(r.changed_at),
changedBy: r.changed_by,
}),
)
},
}
}
// ---- HostStore --------------------------------------------------------------------------------
function pgHostStore(query: QueryFn): HostStore {
return {
async insert(rec) {
try {
await query(
`WITH ins AS (
INSERT INTO hosts
(host_id, account_id, subdomain, agent_pubkey, enroll_fpr, status, last_seen, created_at, revoked_at, status_version)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 1)
RETURNING host_id, created_at, status, revoked_at
)
INSERT INTO host_status_versions (host_id, version, status, revoked_at, changed_at, changed_by)
SELECT host_id, 1, status, revoked_at, created_at, 'system' FROM ins`,
[
rec.hostId,
rec.accountId,
rec.subdomain,
Buffer.from(rec.agentPubkey),
rec.enrollFpr,
rec.status,
rec.lastSeen,
rec.createdAt,
rec.revokedAt,
],
)
} catch (err) {
if (isUniqueViolation(err)) {
// subdomain UNIQUE is the subdomain single-winner substrate; PK is host_id.
throw new Error(err.constraint?.includes('subdomain') ? 'duplicate subdomain' : 'duplicate hostId')
}
throw err
}
},
async get(hostId) {
const rows = await query<HostRow>(`SELECT * FROM hosts WHERE host_id = $1`, [hostId])
const row = rows[0]
return row === undefined ? null : mapHost(row)
},
async getBySubdomain(subdomain) {
const rows = await query<HostRow>(`SELECT * FROM hosts WHERE subdomain = $1`, [subdomain])
const row = rows[0]
return row === undefined ? null : mapHost(row)
},
async listByAccount(accountId) {
const rows = await query<HostRow>(`SELECT * FROM hosts WHERE account_id = $1 ORDER BY created_at ASC`, [
accountId,
])
return rows.map(mapHost)
},
async swapStatus(hostId, status, revokedAt, changedBy) {
const rows = await query<HostRow>(
`WITH upd AS (
UPDATE hosts SET status = $2, revoked_at = $3, status_version = status_version + 1
WHERE host_id = $1
RETURNING host_id, account_id, subdomain, agent_pubkey, enroll_fpr, status, last_seen, created_at, revoked_at, status_version
),
ins AS (
INSERT INTO host_status_versions (host_id, version, status, revoked_at, changed_by)
SELECT host_id, status_version, status, revoked_at, $4 FROM upd
)
SELECT host_id, account_id, subdomain, agent_pubkey, enroll_fpr, status, last_seen, created_at, revoked_at FROM upd`,
[hostId, status, revokedAt, changedBy],
)
const row = rows[0]
if (row === undefined) throw new Error('host not found')
return mapHost(row)
},
async touchLastSeen(hostId, ts) {
// Advisory cache — NO version row (INV7). Silent no-op if absent (matches memory.ts).
await query(`UPDATE hosts SET last_seen = $2 WHERE host_id = $1`, [hostId, ts])
},
async versions(hostId) {
const rows = await query<HostVersionRow>(
`SELECT host_id, version, status, revoked_at, changed_at, changed_by
FROM host_status_versions WHERE host_id = $1 ORDER BY version ASC`,
[hostId],
)
return rows.map(
(r): HostStatusVersionRow => ({
hostId: r.host_id,
version: r.version,
status: r.status,
revokedAt: toIsoN(r.revoked_at),
changedAt: toIso(r.changed_at),
changedBy: r.changed_by,
}),
)
},
}
}
// ---- SessionStore -----------------------------------------------------------------------------
function pgSessionStore(query: QueryFn): SessionStore {
return {
async insert(rec) {
try {
await query(
`INSERT INTO sessions (session_id, host_id, account_id, created_at, last_attach_at)
VALUES ($1, $2, $3, $4, $5)`,
[rec.sessionId, rec.hostId, rec.accountId, rec.createdAt, rec.lastAttachAt],
)
} catch (err) {
if (isUniqueViolation(err)) throw new Error('duplicate sessionId')
throw err
}
},
async get(sessionId) {
const rows = await query<SessionRow>(`SELECT * FROM sessions WHERE session_id = $1`, [sessionId])
const row = rows[0]
if (row === undefined) return null
return {
sessionId: row.session_id,
hostId: row.host_id,
accountId: row.account_id,
createdAt: toIso(row.created_at),
lastAttachAt: toIso(row.last_attach_at),
}
},
}
}
// ---- SubdomainStore ---------------------------------------------------------------------------
/**
* No separate reservations table exists in the schema — the `hosts.subdomain UNIQUE` constraint
* is the single-winner substrate (the true winner is decided at host insert). So `reserve`/`isTaken`
* just report whether a host already owns the label (matches memory.ts intent). Two concurrent
* `reserve()`s can both see "free" before any host row exists; that is fine, because the actual
* single-winner is enforced when the losing insert hits the UNIQUE violation (INV1).
*/
function pgSubdomainStore(query: QueryFn): SubdomainStore {
const taken = async (subdomain: string): Promise<boolean> => {
const rows = await query<{ one: number }>(`SELECT 1 AS one FROM hosts WHERE subdomain = $1 LIMIT 1`, [
subdomain,
])
return rows.length > 0
}
return {
async reserve(subdomain) {
return !(await taken(subdomain))
},
async isTaken(subdomain) {
return taken(subdomain)
},
}
}
// ---- PairingStore -----------------------------------------------------------------------------
interface PairingCodeRow {
code_hash: string
account_id: string
expires_at: Date | string
redeemed_at: Date | string | null
redeem_attempts: number
}
function pgPairingStore(query: QueryFn): PairingStore {
return {
async insert(rec) {
try {
await query(
`INSERT INTO pairing_codes (code_hash, account_id, expires_at, redeemed_at, redeem_attempts)
VALUES ($1, $2, $3, $4, 0)`,
[rec.codeHash, rec.accountId, rec.expiresAt, rec.redeemedAt],
)
} catch (err) {
if (isUniqueViolation(err)) throw new Error('duplicate code_hash')
throw err
}
},
async get(codeHash): Promise<PairingRow | null> {
const rows = await query<PairingCodeRow>(`SELECT * FROM pairing_codes WHERE code_hash = $1`, [codeHash])
const row = rows[0]
if (row === undefined) return null
const record: PairingCodeRecord = {
codeHash: row.code_hash,
accountId: row.account_id,
expiresAt: toIso(row.expires_at),
redeemedAt: toIsoN(row.redeemed_at),
}
return { record, redeemAttempts: row.redeem_attempts }
},
async casRedeem(codeHash, nowIso): Promise<CasOutcome> {
// Single-winner CAS: only the row with redeemed_at IS NULL is updated; exactly one wins.
const won = await query<{ code_hash: string }>(
`UPDATE pairing_codes SET redeemed_at = $2
WHERE code_hash = $1 AND redeemed_at IS NULL
RETURNING code_hash`,
[codeHash, nowIso],
)
if (won.length === 1) return 'ok'
// 0 rows: either already redeemed or unknown — distinguish by existence.
const exists = await query<{ one: number }>(`SELECT 1 AS one FROM pairing_codes WHERE code_hash = $1`, [
codeHash,
])
return exists.length > 0 ? 'already_redeemed' : 'unknown'
},
async registerFailure(codeHash) {
const rows = await query<{ redeem_attempts: number }>(
`UPDATE pairing_codes SET redeem_attempts = redeem_attempts + 1
WHERE code_hash = $1
RETURNING redeem_attempts`,
[codeHash],
)
const row = rows[0]
return row === undefined ? 0 : row.redeem_attempts // 0 for unknown code (matches memory.ts)
},
}
}
// ---- RouteStore -------------------------------------------------------------------------------
interface RouteRow {
host_id: string
relay_node_id: string
updated_at: Date | string
}
function pgRouteStore(query: QueryFn): RouteStore {
const mapEntry = (r: RouteRow): RouteEntry => ({ relayNodeId: r.relay_node_id, updatedAt: toIso(r.updated_at) })
return {
async set(hostId, entry, ttlSec) {
// Postgres has no per-key TTL: store an explicit expires_at = now() + ttl (server clock).
await query(
`INSERT INTO routes (host_id, relay_node_id, updated_at, expires_at)
VALUES ($1, $2, $3, now() + make_interval(secs => $4))
ON CONFLICT (host_id) DO UPDATE SET
relay_node_id = EXCLUDED.relay_node_id,
updated_at = EXCLUDED.updated_at,
expires_at = EXCLUDED.expires_at`,
[hostId, entry.relayNodeId, entry.updatedAt, ttlSec],
)
},
async refreshTtl(hostId, ttlSec) {
// Refresh only a still-live key (fail closed on already-expired/absent, INV7).
const rows = await query<{ host_id: string }>(
`UPDATE routes SET expires_at = now() + make_interval(secs => $2)
WHERE host_id = $1 AND expires_at > now()
RETURNING host_id`,
[hostId, ttlSec],
)
return rows.length === 1
},
async get(hostId) {
// Lazily reap the expired row, then a surviving row is guaranteed live (INV7 fail-closed).
await query(`DELETE FROM routes WHERE host_id = $1 AND expires_at <= now()`, [hostId])
const rows = await query<RouteRow>(
`SELECT host_id, relay_node_id, updated_at FROM routes WHERE host_id = $1`,
[hostId],
)
const row = rows[0]
return row === undefined ? null : mapEntry(row)
},
async delete(hostId) {
await query(`DELETE FROM routes WHERE host_id = $1`, [hostId])
},
async listByNode(nodeId) {
await query(`DELETE FROM routes WHERE expires_at <= now()`, [])
const rows = await query<RouteRow>(
`SELECT host_id, relay_node_id, updated_at FROM routes
WHERE relay_node_id = $1 AND expires_at > now() ORDER BY host_id ASC`,
[nodeId],
)
return rows.map((r) => ({ hostId: r.host_id, entry: mapEntry(r) }))
},
}
}
// ---- NodeStore --------------------------------------------------------------------------------
interface NodeDbRow {
node_id: string
addr: string
status: NodeStatus
last_seen: Date | string
}
function pgNodeStore(query: QueryFn): NodeStore {
return {
async upsert(row) {
await query(
`INSERT INTO relay_nodes (node_id, addr, status, last_seen)
VALUES ($1, $2, $3, $4)
ON CONFLICT (node_id) DO UPDATE SET
addr = EXCLUDED.addr, status = EXCLUDED.status, last_seen = EXCLUDED.last_seen`,
[row.nodeId, row.addr, row.status, row.lastSeen],
)
},
async get(nodeId) {
const rows = await query<NodeDbRow>(`SELECT * FROM relay_nodes WHERE node_id = $1`, [nodeId])
const row = rows[0]
if (row === undefined) return null
return { nodeId: row.node_id, addr: row.addr, status: row.status, lastSeen: toIso(row.last_seen) }
},
async setStatus(nodeId, status) {
const rows = await query<{ node_id: string }>(
`UPDATE relay_nodes SET status = $2 WHERE node_id = $1 RETURNING node_id`,
[nodeId, status],
)
if (rows.length === 0) throw new Error('node not found')
},
async touch(nodeId, ts) {
// Silent no-op if absent (matches memory.ts).
await query(`UPDATE relay_nodes SET last_seen = $2 WHERE node_id = $1`, [nodeId, ts])
},
async delete(nodeId) {
await query(`DELETE FROM relay_nodes WHERE node_id = $1`, [nodeId])
},
}
}
// ---- MeteringStore ----------------------------------------------------------------------------
interface MeteringDbRow {
host_id: string
account_id: string
node_id: string
concurrent_viewers: number
sampled_at: Date | string
}
function pgMeteringStore(query: QueryFn): MeteringStore {
return {
async append(row) {
await query(
`INSERT INTO metering_samples (host_id, account_id, node_id, concurrent_viewers, sampled_at)
VALUES ($1, $2, $3, $4, $5)`,
[row.hostId, row.accountId, row.nodeId, row.concurrentViewers, row.sampledAt],
)
},
async query(accountId, fromIso, toIso2) {
const rows = await query<MeteringDbRow>(
`SELECT host_id, account_id, node_id, concurrent_viewers, sampled_at
FROM metering_samples
WHERE account_id = $1 AND sampled_at >= $2 AND sampled_at <= $3
ORDER BY id ASC`,
[accountId, fromIso, toIso2],
)
return rows.map(
(r): MeteringSampleRow => ({
hostId: r.host_id,
accountId: r.account_id,
nodeId: r.node_id,
concurrentViewers: r.concurrent_viewers,
sampledAt: toIso(r.sampled_at),
}),
)
},
}
}
// ---- AuditStore -------------------------------------------------------------------------------
interface AuditDbRow {
action: string
principal_id: string
account_id: string
host_id: string | null
ts: Date | string
meta: Record<string, string>
}
function pgAuditStore(query: QueryFn): AuditStore {
return {
async append(row) {
await query(
`INSERT INTO audit_log (action, principal_id, account_id, host_id, ts, meta)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
[row.action, row.principalId, row.accountId, row.hostId, row.ts, JSON.stringify(row.meta)],
)
},
async query(accountId, fromIso, toIso2) {
const rows = await query<AuditDbRow>(
`SELECT action, principal_id, account_id, host_id, ts, meta
FROM audit_log
WHERE account_id = $1 AND ts >= $2 AND ts <= $3
ORDER BY id ASC`,
[accountId, fromIso, toIso2],
)
return rows.map(
(r): AuditRow => ({
action: r.action,
principalId: r.principal_id,
accountId: r.account_id,
hostId: r.host_id,
ts: toIso(r.ts),
meta: r.meta,
}),
)
},
}
}
// ---- assembly ---------------------------------------------------------------------------------
/** Build a full Postgres-backed store set (all ports mapped to parameterized SQL over `query`). */
export function createPgStores(query: QueryFn): Stores {
return {
accounts: pgAccountStore(query),
hosts: pgHostStore(query),
sessions: pgSessionStore(query),
subdomains: pgSubdomainStore(query),
pairing: pgPairingStore(query),
routes: pgRouteStore(query),
nodes: pgNodeStore(query),
metering: pgMeteringStore(query),
audit: pgAuditStore(query),
}
}

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