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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,3 +18,6 @@ npm-debug.log*
|
||||
# test coverage
|
||||
coverage/
|
||||
.gstack/
|
||||
|
||||
# deploy secrets (RELAY-PHASE1) — .env.example is committed, .env is not
|
||||
deploy/.env
|
||||
|
||||
15
control-plane/db/migrations/0002_routes.sql
Normal file
15
control-plane/db/migrations/0002_routes.sql
Normal 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);
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
38
control-plane/src/boot/verifier.ts
Normal file
38
control-plane/src/boot/verifier.ts
Normal 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)
|
||||
}
|
||||
48
control-plane/src/db/migrate.ts
Normal file
48
control-plane/src/db/migrate.ts
Normal 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, [])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
600
control-plane/src/store/pg.ts
Normal file
600
control-plane/src/store/pg.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
},
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
390
control-plane/test/store/pg.test.ts
Normal file
390
control-plane/test/store/pg.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
78
control-plane/test/verifier.test.ts
Normal file
78
control-plane/test/verifier.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
43
deploy/.env.example
Normal file
43
deploy/.env.example
Normal file
@@ -0,0 +1,43 @@
|
||||
# RELAY-PHASE1 env template. Copy to deploy/.env (gitignored) and fill. NEVER commit real secrets.
|
||||
# Convert relative → absolute paths on the VPS. See docs/PLAN_RELAY_PHASE1.md §3.
|
||||
|
||||
# ── Docker Compose (deploy/docker-compose.yml) ────────────────────────────────────────────────
|
||||
POSTGRES_DB=relay
|
||||
POSTGRES_USER=relay
|
||||
POSTGRES_PASSWORD= # REQUIRED — strong random
|
||||
|
||||
# ── Shared datastores (loopback; both processes) ─────────────────────────────────────────────
|
||||
PG_URL=postgres://relay:CHANGEME@127.0.0.1:5432/relay
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
# ── Deployment identity ──────────────────────────────────────────────────────────────────────
|
||||
BASE_DOMAIN=term.example.com # your ICP-filed domain; browser hits <sub>.<BASE_DOMAIN>
|
||||
RELAY_NODE_ID=relay-1
|
||||
RELAY_TRUST_DOMAIN=relay.example.com # SPIFFE trust domain for agent certs
|
||||
|
||||
# ── P5 capability keypair — CONTROL-PLANE SIGNS, RELAY VERIFIES. Same public key on both. ────
|
||||
# Generate an Ed25519 keypair; keep the PRIVATE key only where tokens are minted (never on disk here).
|
||||
CAPABILITY_SIGN_PUBKEY_B64= # control-plane env: raw 32-byte Ed25519 pubkey, base64
|
||||
RELAY_AUTH_VERIFY_PUBKEY= # relay env: SAME key, base64url
|
||||
|
||||
# ── Control-plane (P3) ───────────────────────────────────────────────────────────────────────
|
||||
CP_BIND_HOST=127.0.0.1 # admin API stays loopback-only (front only via the relay)
|
||||
CP_BIND_PORT=8080
|
||||
CA_INTERMEDIATE_KMS_KEY_REF=dev-local # Phase 1: dev in-process signer accepts any ref (KMS → Phase 2)
|
||||
CA_INTERMEDIATE_CERT_PATH=/etc/relay/ca/intermediate.cert.pem
|
||||
NODE_MTLS_TRUST_BUNDLE_PATH=/etc/relay/ca/agent-ca.bundle.pem
|
||||
# HEARTBEAT_TTL_SEC=15 PAIRING_TTL_SEC=600 PAIRING_MAX_REDEEM_ATTEMPTS=5 (defaults)
|
||||
|
||||
# ── Relay data-plane (P1, relay-run Phase 1) ─────────────────────────────────────────────────
|
||||
BIND_HOST=0.0.0.0
|
||||
BIND_PORT=443 # browser WSS (open in the Aliyun security group)
|
||||
TLS_CERT_PATH=/etc/relay/tls/fullchain.pem # Let's Encrypt for <sub>.<BASE_DOMAIN>
|
||||
TLS_KEY_PATH=/etc/relay/tls/privkey.pem
|
||||
AGENT_BIND_PORT=8444 # agent mTLS (open in the security group)
|
||||
AGENT_CA_CERT_PATH=/etc/relay/ca/agent-ca.cert.pem # private enrollment CA — NOT Let's Encrypt
|
||||
AGENT_CA_CHAIN_PATH=/etc/relay/ca/agent-ca.bundle.pem
|
||||
|
||||
# ── Agent (P2) — runs on YOUR laptop, not the VPS. Shown here for reference. ──────────────────
|
||||
# RELAY_URL=wss://<sub>.term.example.com:8444
|
||||
# ENROLL_URL=https://<sub>.term.example.com/enroll (or the CP host)
|
||||
# HOST_ID=... SUBDOMAIN=<sub> LOCAL_TARGET_URL=ws://127.0.0.1:3000 STATE_DIR=~/.web-terminal-agent
|
||||
48
deploy/docker-compose.yml
Normal file
48
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
# RELAY-PHASE1 · E1 — Postgres + Redis for the rendezvous-relay control-plane (P3) on the VPS.
|
||||
#
|
||||
# Both services bind to 127.0.0.1 ONLY — they are the relay's private state, never exposed on the
|
||||
# public interface (INV9-adjacent; open only :443 + AGENT_PORT in the cloud security group). The
|
||||
# control-plane / relay processes reach them over loopback.
|
||||
#
|
||||
# Usage on 8.138.1.192:
|
||||
# cp deploy/.env.example deploy/.env # then fill secrets
|
||||
# docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d
|
||||
#
|
||||
# PG_URL for the app = postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}
|
||||
# REDIS_URL for the app = redis://127.0.0.1:6379
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-relay}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-relay}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432" # loopback-only
|
||||
volumes:
|
||||
- relay_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-relay} -d ${POSTGRES_DB:-relay}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379" # loopback-only
|
||||
volumes:
|
||||
- relay_redisdata:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
relay_pgdata:
|
||||
relay_redisdata:
|
||||
172
docs/PLAN_RELAY_PHASE1.md
Normal file
172
docs/PLAN_RELAY_PHASE1.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# PLAN_RELAY_PHASE1 — deploy the native rendezvous-relay to a single VPS (staging, durable)
|
||||
|
||||
> **Decision (2026-07-06):** Build **Phase 1 "1b — full durable staging"** on cloud VPS `8.138.1.192`
|
||||
> (Alibaba Cloud, mainland). Real **Postgres + Redis via Docker Compose on the VPS**; real **TLS on
|
||||
> `:443` via Let's Encrypt** against an **already ICP-filed domain**; agent runs on the operator's own
|
||||
> machine and dials OUT. This plan is the file-level execution spec derived from a full code audit of
|
||||
> the 7 relay packages. It supersedes the high-level phasing in [DEPLOY_RELAY.md](./DEPLOY_RELAY.md) §4
|
||||
> for the concrete build; that doc still holds for the *why* and the security runbook (§7).
|
||||
>
|
||||
> **Scope OUT (→ Phase 2):** real KMS custody of the CA (dev in-process signer is accepted for
|
||||
> staging), F6 recoverable-replay transport (`loadReplay` stays fail-closed — not on the terminal path),
|
||||
> WebAuthn/passkey step-up (staging uses `NO_STEPUP_POLICY` — single operator), wildcard multi-tenant
|
||||
> subdomains, metering/alerting. Single tenant, single host, single relay node.
|
||||
|
||||
---
|
||||
|
||||
## 0. Target (what "done" means)
|
||||
|
||||
From the operator's laptop (agent dialing out) and any browser:
|
||||
|
||||
1. `https://<sub>.<BASE_DOMAIN>` serves the relay-web bundle (same origin as the WSS endpoint).
|
||||
2. Operator logs in, picks the host, and gets a live shell **spliced by the real relay-node** — the
|
||||
relay only sees ciphertext (INV2); E2E is browser↔agent.
|
||||
3. Agent enrolled once via a pairing code → holds a SPIFFE mTLS cert → dials `wss://…:AGENT_PORT`.
|
||||
4. **Restart-safe:** bouncing the relay/control-plane process does NOT lose the host registration or
|
||||
kill the operator's PTY (state is in Postgres/Redis; INV7).
|
||||
5. Revoking the host tears the tunnel down within the INV12 budget (Redis `relay:revocations`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Code audit summary — what exists vs. what must be built
|
||||
|
||||
Security-critical *logic* is written and injectable across all 7 packages. Phase 1 = build the
|
||||
integration + infra layer they were designed to receive. Grounded gaps:
|
||||
|
||||
| Area | State (file evidence) | Task |
|
||||
|---|---|---|
|
||||
| P3 Postgres adapter | **absent** — `store/pg.ts` does not exist; only `db/pool.ts` (`createPgPool`/`createQuery`) + full `db/migrations/0001_init.sql`. `memory.ts` is the semantics reference (INV8 versioning, CAS). | **A1** |
|
||||
| P3 migration runner | none | **A1** |
|
||||
| P3 server entrypoint | **absent** — no `.listen()`, no `start`; only `buildControlPlane(env, overrides)` factory (`main.ts:60`). | **A2** |
|
||||
| P3 Redis revocation bus | `createRedisRevocationBus(RedisPublisher)` exists (`routing/bus.ts:42`) but no `ioredis` client instantiated; `main.ts:93` leaves `bus` inert. | **A2** |
|
||||
| P3 F2 capability verifier | throwing stub `refuseAllVerifier` (`main.ts:44`). Must inject relay-auth `verifyCapabilityToken`. **Impedance:** CP's `CapabilityVerifier.verify` is **sync** (`api/authz.ts:24`), relay-auth's is **async** → seam must go async. | **A3** |
|
||||
| relay EnforceDeps over shared store | relay-run uses its own in-RAM fakes (`relay-run/src/wiring/memory-stores.ts`), a **separate world** from the CP. Must implement `HostRegistryPort`/`SessionRegistryPort`/`RevocationStore`/`TokenBucketStore`/`AuditSink` over the **same** Postgres/Redis. | **B1** |
|
||||
| relay `MtlsVerifier` | stub returns seeded host for any cert (`relay-world.ts:149`). Must use relay-auth `verifyAgentCert(leaf, caChain, now, hosts)` against the shared host registry + pinned agent CA. | **B2** |
|
||||
| relay `RouteResolver` | one-entry in-RAM map (`data-plane.ts:110`). Must resolve subdomain→hostId from `hosts.getBySubdomain`. | **B3** |
|
||||
| relay revocation subscriber | not wired. Subscribe `relay:revocations` → `killsScope` → `closeStream`. | **B4** |
|
||||
| relay-run Phase-1 entry | `main.ts` hardcodes `127.0.0.1:8443`, `baseDomain='term.localhost'`, self-signed certs, dials no agent, `NO_STEP_UP`. | **B5** |
|
||||
| agent build | `noEmit:true`, no `build` script, `dist/cli.js` never produced. | **C1** |
|
||||
| agent runnable entry | `cli.ts` exports `parseArgs`/`runCli` but has **no `main()`/argv bootstrap, no `CliDeps` factory, no concrete `runTunnel`** (only assembled in `test/acceptance/cafeDemo.test.ts`). | **C2** |
|
||||
| relay-web static serve | `build.mjs` emits `public/build/`; **no HTTP server** ships. Must serve `public/` same-origin as WSS. | **D1** |
|
||||
| Infra | no Dockerfiles/compose/systemd anywhere. | **E** |
|
||||
|
||||
---
|
||||
|
||||
## 2. Build order (waves by dependency)
|
||||
|
||||
```
|
||||
A (P3 durable + serving) ──▶ B (relay data-plane on shared store) ──▶ E (infra + wire-up)
|
||||
└──▶ C (agent buildable + runnable) ──────────▶
|
||||
└──▶ D (relay-web served) ────────────────────▶
|
||||
```
|
||||
|
||||
A is the foundation (both planes read the same store). B/C/D can proceed once A's store contract is
|
||||
green. E stands up infra and does the end-to-end enroll→dial→click-through.
|
||||
|
||||
### Wave A — control-plane durable + serving
|
||||
- **A1 · PG store adapter + migration runner.** `Owns: control-plane/src/store/pg.ts`,
|
||||
`control-plane/src/db/migrate.ts`, `control-plane/test/store/pg.test.ts`.
|
||||
`createPgStores(query: QueryFn): Stores` implementing all 9 ports (`store/ports.ts`) with **byte-for-byte
|
||||
semantics parity with `memory.ts`**: INV8 status-version+pointer swaps in one transaction, `casRedeem`
|
||||
single-winner, `registerFailure` increment, `reserve` single-winner on the `subdomain UNIQUE`
|
||||
constraint, TTL on routes (store `expires_at`, filter on read — Postgres has no key TTL). Map
|
||||
snake_case columns ↔ camelCase records. Migration runner applies `db/migrations/*.sql` idempotently.
|
||||
**Verify:** TDD against a real Postgres (Testcontainers or a Docker `postgres:16` on
|
||||
`127.0.0.1:5432`); run the SAME behavioral suite the memory store passes.
|
||||
- **A2 · P3 server entrypoint + Redis wiring.** `Owns: control-plane/src/server.ts`,
|
||||
`control-plane/src/boot/redis.ts`, `package.json` (`start` script).
|
||||
`loadEnv(process.env)` → `createPgPool(PG_URL)`+`createQuery` → `createPgStores` → migrate →
|
||||
`ioredis` client → `createRedisRevocationBus(redis)` → `buildControlPlane(env, {stores, bus,
|
||||
verifier, caChainDer})` → `app.listen({host:'0.0.0.0', port})`. Graceful SIGTERM. **Verify:** boots
|
||||
against Docker PG+Redis; `POST /accounts` then `POST /accounts/:id/pairing-codes` round-trips.
|
||||
- **A3 · F2 capability verifier (async).** `Owns: control-plane/src/api/authz.ts` (make
|
||||
`CapabilityVerifier.verify` return `Promise`, await at call sites), `control-plane/src/boot/verifier.ts`,
|
||||
`control-plane/src/main.ts` (default + `overrides.verifier` plumb + call `loadVerifyKeyFromEnv`).
|
||||
Real verifier delegates to relay-auth `verifyCapabilityToken(raw, expectedAud, now)`; configure the
|
||||
verify key from `CAPABILITY_SIGN_PUBKEY_B64` at boot. **Verify:** a token signed by the matching key
|
||||
verifies; a foreign/expired token 401s; unblocks programmatic account/pairing seeding.
|
||||
|
||||
### Wave B — relay data-plane on the shared store (P1 via relay-run)
|
||||
- **B1 · shared-store EnforceDeps.** `Owns: relay-run/src/wiring/stores-pg.ts`,
|
||||
`relay-run/test/stores-pg.test.ts`. Implement relay-auth's `HostRegistryPort`, `SessionRegistryPort`,
|
||||
`RevocationStore`, `TokenBucketStore` (Redis token bucket), `AuditSink` (append to `audit_log`) over
|
||||
the SAME PG/Redis as P3. Replace `memory-stores.ts` in the Phase-1 path.
|
||||
- **B2 · registry-backed `MtlsVerifier`.** `Owns: relay-run/src/wiring/mtls-verifier.ts`. Wrap
|
||||
relay-auth `verifyAgentCert(leafPem, caChainPem, now, hosts)`; pin the agent CA bundle; return
|
||||
`{hostId, accountId}` only for enrolled+unrevoked hosts (INV4/INV14).
|
||||
- **B3 · store-backed `RouteResolver`.** `Owns: relay-run/src/wiring/route-resolver.ts`.
|
||||
`resolve(subdomain)` → `hosts.getBySubdomain` → hostId (or null).
|
||||
- **B4 · revocation subscriber.** `Owns: relay-run/src/wiring/revocation-subscriber.ts` (or reuse
|
||||
`term-relay/data-plane/revocation-subscriber.ts`). ioredis SUBSCRIBE `relay:revocations` → parse
|
||||
`KillSignal` → `killsScope(signal, hostAccountId, hostId)` → `node.closeStream(...)`.
|
||||
- **B5 · relay-run Phase-1 entry.** `Owns: relay-run/src/main-phase1.ts`, `relay-run/package.json`
|
||||
(`start:phase1`). Env-driven (no hardcoding, CLAUDE §Config): bind `0.0.0.0`; `BASE_DOMAIN` +
|
||||
computed `allowedOrigins` (port-less on :443, exact-match at `onUpgrade.ts:102`); real LE cert/key
|
||||
paths for browser WSS; **separate** private agent-CA bundle for mTLS; `loadVerifyKeyFromEnv` at
|
||||
startup; compose B1–B4; keep `NO_STEPUP_POLICY` (staging). Leave the Phase-0 `main.ts` untouched for dev.
|
||||
|
||||
### Wave C — agent buildable + runnable (P2)
|
||||
- **C1 · agent build.** `Owns: agent/tsconfig.build.json`, `agent/package.json` (`build`). Emit
|
||||
`dist/cli.js` (esbuild bundle or `tsc` emit; keep `src` tsconfig `noEmit` for typecheck).
|
||||
- **C2 · CLI bootstrap + `runTunnel`.** `Owns: agent/src/main.ts` (shebang + argv → `runCli`),
|
||||
`agent/src/transport/runTunnel.ts`, `agent/src/cli/deps.ts` (`CliDeps` factory). Assemble
|
||||
`dialRelay`→`holdTunnel`→`createStreamRouter`→`dialLoopback` + heartbeat/backoff, porting the proven
|
||||
wiring from `test/acceptance/cafeDemo.test.ts`. **Verify:** `web-terminal-agent pair <CODE>` then
|
||||
`web-terminal-agent run` enrolls + dials against a local Phase-1 relay.
|
||||
|
||||
### Wave D — serve relay-web (P6)
|
||||
- **D1 · same-origin static server.** `Owns: relay-run/src/servers/static-web.ts` (fold into the
|
||||
browser HTTPS server so the bundle is served from the SAME origin/cert as the WSS, keeping
|
||||
Origin/CSP aligned). Serve `relay-web/public/` (built). Add `npm --prefix relay-web run build` to the
|
||||
deploy step. `loadReplay` stays fail-closed (Phase 2).
|
||||
|
||||
### Wave E — infra + integration on 8.138.1.192
|
||||
- **E1 · Docker Compose** `Owns: deploy/docker-compose.yml`, `deploy/.env.example`. `postgres:16` +
|
||||
`redis:7`, volumes, bound to `127.0.0.1` (not public). Health checks.
|
||||
- **E2 · DNS + TLS.** A-record `<sub>.<BASE_DOMAIN>` → `8.138.1.192`; Let's Encrypt cert
|
||||
(HTTP-01 on :80 or DNS-01) for that subdomain → `TLS_CERT_PATH`/`TLS_KEY_PATH`.
|
||||
- **E3 · Enrollment CA.** Generate the private agent CA (staging may reuse the CP dev signer's CA) →
|
||||
`CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `AGENT_CA_CERT_PATH`. **Never** LE for mTLS.
|
||||
- **E4 · Env + systemd + security group.** `deploy/control-plane.env`, `deploy/relay.env`,
|
||||
`deploy/*.service`; open inbound `:443` (browser) + `AGENT_PORT` (agent mTLS) in the Aliyun security
|
||||
group; keep CP admin + PG + Redis loopback-only.
|
||||
- **E5 · End-to-end.** `POST /accounts` → `POST /accounts/:id/pairing-codes` → agent `pair`+`run` on
|
||||
the laptop → browser opens `https://<sub>.<BASE_DOMAIN>` → click through to the shell. Confirm
|
||||
restart-safety + revocation teardown.
|
||||
|
||||
---
|
||||
|
||||
## 3. Environment / config reference (Phase 1, this VPS)
|
||||
|
||||
**control-plane** (`control-plane/src/env.ts`, all required unless defaulted):
|
||||
`PG_URL`, `REDIS_URL`, `CAPABILITY_SIGN_PUBKEY_B64` (32-byte Ed25519, base64), `CA_INTERMEDIATE_KMS_KEY_REF`
|
||||
(dev signer accepts any ref), `CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `BASE_DOMAIN`;
|
||||
defaulted `HEARTBEAT_TTL_SEC`=15, `PAIRING_TTL_SEC`=600, `PAIRING_MAX_REDEEM_ATTEMPTS`=5.
|
||||
|
||||
**relay-run Phase 1** (`term-relay/data-plane/config.ts` + new): `BASE_DOMAIN`, `BIND_HOST`=0.0.0.0,
|
||||
`BIND_PORT`=443, `TLS_CERT_PATH`, `TLS_KEY_PATH`, `AGENT_BIND_PORT`, `AGENT_CA_CERT_PATH`,
|
||||
`AGENT_CA_CHAIN_PATH`, `RELAY_NODE_ID`, `RELAY_AUTH_VERIFY_PUBKEY` (= `CAPABILITY_SIGN_PUBKEY_B64`,
|
||||
base64url), `RELAY_TRUST_DOMAIN`, `PG_URL`, `REDIS_URL`.
|
||||
|
||||
**agent** (`agent/src/config/agentConfig.ts`): `RELAY_URL` (`wss://…:AGENT_PORT`), `ENROLL_URL`
|
||||
(`https://<cp-host>/enroll`), `HOST_ID`, `SUBDOMAIN`, `LOCAL_TARGET_URL` (`ws://127.0.0.1:3000` — the
|
||||
base app), `STATE_DIR` (`~/.web-terminal-agent`).
|
||||
|
||||
> **Key agreement (linchpin):** the P5 capability-signing keypair — CP signs, relay verifies. CP env
|
||||
> `CAPABILITY_SIGN_PUBKEY_B64` and relay env `RELAY_AUTH_VERIFY_PUBKEY` MUST be the SAME public key.
|
||||
> The **agent enrollment CA** (mTLS) and the **browser LE cert** are two independent trust chains.
|
||||
|
||||
---
|
||||
|
||||
## 4. Invariants to preserve (do not regress)
|
||||
INV2 opaque splice (relay sees only ciphertext) · INV3 accountId only from authenticated material ·
|
||||
INV7 PTY≠WS, relay nodes stateless/restart-safe · INV8 immutable versioned status · INV10 zero-payload
|
||||
audit · INV12 revocation teardown budget · INV14 registry-gated mTLS. Origin/CSWSH exact-match on every
|
||||
browser upgrade. Config via env only — no hardcoded hosts/ports/secrets.
|
||||
|
||||
---
|
||||
|
||||
## 5. Progress
|
||||
Tracked in [PROGRESS_LOG.md](./PROGRESS_LOG.md) under a `RELAY-PHASE1` heading. Task IDs: `A1–A3`,
|
||||
`B1–B5`, `C1–C2`, `D1`, `E1–E5`. Orchestrator appends one entry per task on completion (status, files,
|
||||
verification command+result, deviations, next).
|
||||
@@ -24,6 +24,25 @@
|
||||
|
||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||
|
||||
### 🚧 RELAY-PHASE1 — 把原生 rendezvous-relay 部署到单台 VPS(8.138.1.192,阿里云;2026-07-06)
|
||||
- **目标**: DEPLOY_RELAY §4 的 **Phase 1「1b 全量持久化 staging」**。VPS 上 Docker 自建 Postgres+Redis;已备案域名走 443 + Let's Encrypt;agent 跑在操作者本机向外拨号。完整文件级方案见 [PLAN_RELAY_PHASE1.md](./PLAN_RELAY_PHASE1.md)。
|
||||
- **进度**: 地基 Wave A 已 2/3。**A1 (PG store 适配器+迁移) DONE**、**A3 (F2 异步能力验证器) DONE**、E1 (docker-compose+env 模板) DONE。全套 `npx vitest run`(control-plane)= **15 files / 101 tests pass**(A1 的 pg 测试 + A3 的 verifier 测试并存无冲突)。
|
||||
- **下一步**: **A2** — P3 server 入口(`loadEnv→createPgStores→runMigrations→ioredis→createRedisRevocationBus→buildControlPlane→listen`)+ Redis 撤销总线接线(`main.ts:93` 现为 inert)。然后 Wave B(relay 数据面读同一持久 store)。
|
||||
- **未做/推迟到 Phase 2**: 真 KMS(dev signer 暂用)、F6 replay(`loadReplay` fail-closed)、WebAuthn step-up(staging 用 `NO_STEPUP_POLICY`)、通配多租户。
|
||||
|
||||
#### [x] A1 — Postgres store 适配器(P3)+ 迁移运行器(2026-07-06)
|
||||
- **交付**: `createPgStores(query): Stores` 把 9 个 store 端口映射为参数化 SQL(零字符串拼接);`runMigrations(query)` 按文件名顺序跑 `db/migrations/*.sql`(幂等)。
|
||||
- **文件(全在 Owns 内)**: `control-plane/src/store/pg.ts`、`src/db/migrate.ts`、`db/migrations/0002_routes.sql`(新增 — 0001 无 routes 表)、`test/store/pg.test.ts`。
|
||||
- **语义对齐 memory.ts**: INV8 accounts/hosts `swapStatus` 用单条 **writable CTE**(UPDATE 指针 + INSERT 版本行)原子完成,无显式事务;重复插入捕获 pg `23505`→`throw duplicate`;`casRedeem` 单赢家 `UPDATE...WHERE redeemed_at IS NULL RETURNING`;RouteStore 存 `expires_at`,`expires_at<=now()` 视为不存在(fail-closed INV7)+ 惰性 DELETE。
|
||||
- **验证(实测)**: `npx vitest run test/store/pg.test.ts` → **23/23 通过**(自起 `postgres:16` 一次性容器 :5433,afterAll 自动 `docker rm -f`);`pg.ts` 覆盖 **96.72% stmts / 100% funcs**。
|
||||
- **偏差**: ① `0002_routes.sql` 新增;② SubdomainStore 无独立预留表,按 `hosts.subdomain UNIQUE` 归属裁决;③ migrate 按 `;` 切分逐条执行(参数化路径拒绝多语句)。**阻塞**: 无。
|
||||
|
||||
#### [x] A3 — 真能力验证器(relay-auth verifyCapabilityToken)+ 同步→异步 seam(2026-07-06)
|
||||
- **交付**: 抛异常的 `refuseAllVerifier` stub 换成委托 P5 异步 `verifyCapabilityToken` 的真验证器;`await` 贯通整个 authorizer 路径。默认仍 fail-closed(异步化后 reject→401)。
|
||||
- **文件**: `src/api/authz.ts`(`CapabilityVerifier.verify`→`Promise`)、`src/api/provision.ts`(5 个路由 call site 改 `await principal(req)`)、`src/boot/verifier.ts`(新;`configureCapabilityVerifyKey` 把 `env.capabilitySignPubkey` 32 字节导入 WebCrypto verify key 后调 relay-auth `configureVerifyKey`,桥接 `CAPABILITY_SIGN_PUBKEY_B64` vs `RELAY_AUTH_VERIFY_PUBKEY` 命名差)、`src/main.ts`、`package.json`(加 `relay-auth: file:../relay-auth`)、`test/verifier.test.ts`(新)+ 更新 api 测试 stub 为异步。
|
||||
- **验证(实测)**: `npx tsc --noEmit` 全项目干净;`npx vitest run` **15 files / 101 tests pass**。
|
||||
- **偏差**: 手建 `control-plane/node_modules/relay-auth` 符号链接以配合新 file: 依赖(`npm install` 会重建)。**阻塞**: 无。
|
||||
|
||||
### ✅ 修复:项目面板把父文件夹当项目 & 会话点亮所有祖先项目(2026-07-06,分支 `feat/ios-client`)
|
||||
- **现象(用户截图)**: 只在 `web-terminal` 跑了一个会话,但 "Active now" 同时显示 `web-terminal`/`Documents`/`yiukai` 三张卡,且父文件夹本身被列为项目。
|
||||
- **根因(`src/http/projects.ts`)**: ① `belongsTo` 纯前缀匹配 → 会话按 cwd 归属到**每一个**祖先项目;② 历史合并(`mergeHistory`)把曾经跑过会话的 cwd(如 `~`、`~/Documents`)原样列为项目。
|
||||
|
||||
Reference in New Issue
Block a user