# PLAN_RELAY_AUTH_ISOLATION — Auth & Tenant Isolation (P5)

> **Plan P5 of 6** for the Rendezvous-Relay Service. Source of intent:
> [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) (§0 LOCKED DECISIONS are **CLOSED**).
> Coordination point & frozen contracts: [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) — this plan
> **cites §3 (invariants) and §4 (FROZEN CONTRACTS) by name and MUST NOT redefine any of them locally.**
> Conventions follow [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md):
> stable task IDs grouped into dependency waves, an `Owns:` disjoint-file list per task,
> function-signature-level contracts, TDD (tests FIRST), explicit test cases (incl. negative/security),
> per-task security notes. Progress logged in [`PROGRESS_LOG.md`](./PROGRESS_LOG.md) — **orchestrator-only
> writer** (subagents return a ready-to-paste entry, G1).

---

## 0. Scope

**This plan owns the "who are you / may you reach this host / prove it repeatedly" spine of the relay.**
It is the *decision layer* — the byte/ciphertext path (P1), the accounts registry storage (P3), the crypto
core (P4), and the browser UI (P6) call **into** P5 for every authorization decision but P5 never touches
the byte path itself.

**In scope (this plan, no overlap with the other five):**
1. **Human auth** — Passkey/WebAuthn **primary**, TOTP **fallback**, **NEVER SMS**, OIDC SSO for teams;
   **step-up auth before opening a session** (not just at login).
2. **Agent auth** — per-host **mTLS with SPIFFE-style short-lived, auto-rotating certs** (verification +
   rotation **policy**; DB stores only public keys, §4.2 `agent_pubkey`, INV4). *P3 owns the signing
   endpoint; P5 owns the cert profile, SPIFFE-ID scheme, verification, and rotation controller.*
3. **Capability tokens** on the WS upgrade — issuance + verification of §4.3 `CapabilityToken` (host + rights
   scope, INV15).
4. **Deny-by-default tenant authz** — `account_id` derived from the authenticated principal, **NEVER
   client-supplied** (INV3), enforced on **every connect AND reattach** (INV6).
5. **The hard cross-tenant isolation invariant** (INV1: A can never reach B) + a **permanent CI tripwire
   test** (device A → host B = **403**).
6. **Per-tenant rate-limits / quotas** (sessions, bytes-signalled, enrollments, connect attempts).
7. **Global + per-host revocation** that kills live tunnels in **seconds** (INV12).
8. **Immutable audit log with ZERO payload** (INV10) + cross-tenant-crossing alerts.

**Explicitly NOT in scope (owned elsewhere — do not touch):** the mux frame format & data-plane forwarding
(P1, §4.1); accounts/hosts/sessions/pairing_codes **table storage + issuance** and the mTLS **signing**
endpoint (P3, §4.2/§4.5); E2E handshake & AEAD envelope (P4, §4.4); terminal bytes / any ANSI parsing
(never — INV2/INV11); browser UI / SubtleCrypto / client-side preview render (P6). The base app `src/` and
`public/` stay **byte-for-byte unchanged** (only P3's install step touches `ALLOWED_ORIGINS`).

### SECURITY INVARIANTS this plan enforces (from INDEX §3)

Primary owner (ships the test): **INV1** (cross-tenant isolation + CI tripwire), **INV3** (`account_id` from
authenticated principal, never client-supplied), **INV6** (deny-by-default on connect AND reattach),
**INV10** (immutable zero-payload audit + cross-tenant alert), **INV12** (fast global/per-host revocation),
**INV15** (capability token required on WS upgrade). Co-owner: **INV4** (per-host asymmetric identity — with
P2/P3), **INV14** (mTLS SPIFFE short-lived rotating certs — with P2/P3). Consumes/relies on: **INV9**
(secrets in env/manager, validated at startup, never logged) applies to every task.

---

## 1. Files & packages owned

**All P5 runtime lives in a NEW top-level package `relay-auth/`** — a dependency-light authorization library
(no `ws`, no terminal parser, no DOM) that P1's data plane and P3's control plane import read-only, exactly
as `src/types.ts` is imported in the base app. It imports **`relay-contracts/` (§4) read-only** and never
redefines a frozen contract. P5-local types that are NOT in §4 (WebAuthn credential, TOTP secret storage,
OIDC identity, audit event, rate-limit policy, SPIFFE-ID) live here because §4 explicitly delegates the
`webauthn_credentials` / `oidc_identities` tables and the audit/revocation mechanics to "P5-owned" (§4.2).

| Path | Owner task | Role |
|---|---|---|
| `relay-auth/package.json`, `relay-auth/tsconfig.json`, `relay-auth/vitest.config.ts` | T1 | package scaffold |
| `relay-auth/src/types.ts` | T1 | P5-local Zod schemas + TS types (auth records, audit event, rate policy, SPIFFE-ID) — **frozen after T1**, changed only via T1 |
| `relay-auth/src/capability/issue.ts`, `relay-auth/src/capability/verify.ts`, `relay-auth/src/capability/device-proof.ts` | T2 | §4.3 `CapabilityToken` issue + verify (Ed25519 PASETO/JWS) incl. DPoP PoP + single-use; §4.4 `deviceAuthProof` sign/verify |
| `relay-auth/src/authz/principal.ts`, `relay-auth/src/authz/decide.ts` | T3 | deny-by-default authz core (INV1/INV3/INV6) |
| `relay-auth/src/audit/log.ts`, `relay-auth/src/audit/redact.ts` | T4 | immutable zero-payload audit sink + alert emitter |
| `relay-auth/src/human/webauthn/register.ts`, `.../authenticate.ts` | T5 | Passkey/WebAuthn ceremonies |
| `relay-auth/src/human/totp/totp.ts` | T6 | TOTP fallback (RFC 6238) |
| `relay-auth/src/human/oidc/oidc.ts` | T7 | OIDC SSO (auth-code + PKCE) |
| `relay-auth/src/human/stepup/stepup.ts` | T8 | step-up assertion before session open |
| `relay-auth/src/agent/spiffe.ts`, `relay-auth/src/agent/verify-mtls.ts`, `relay-auth/src/agent/rotate.ts` | T9 | SPIFFE-ID scheme, cert-chain verification, rotation controller |
| `relay-auth/src/revocation/revoke.ts`, `relay-auth/src/revocation/check.ts` | T10 | global + per-host revocation, live-tunnel kill signal |
| `relay-auth/src/ratelimit/quota.ts` | T11 | per-tenant token-bucket + quota checks |
| `relay-auth/src/enforce/onUpgrade.ts`, `relay-auth/src/enforce/onReattach.ts` | T12 | the two enforcement entry points P1/P3 call |
| `relay-auth/test/**` | each task co-owns its own `*.test.ts` | unit/negative tests (TDD, tests FIRST) |
| `relay-auth/test/tripwire/cross-tenant.test.ts`, `.github/workflows/relay-tripwire.yml` | T13 | **permanent CI tripwire** (INV1) |

**Cross-plan storage/transport ports (interfaces, not tables):** P5 does **not** own Postgres/Redis storage
or sockets — it defines narrow port interfaces (`HostRegistryPort`, `SessionRegistryPort`, `RevocationStore`
incl. `consumeOnce`, `AuditSink`, `CredentialStore`, `TokenBucketStore`, and the push-only `RevocationBus`)
that P3/P1 implement against §4.2 storage and the `relay:revocations` pub/sub channel. This keeps P5
pure/testable, keeps the immutable-record + atomic-swap ownership (INV8) inside P3, and keeps P5 **socket-free**
(the live-tunnel teardown is P1 injecting a §4.1 frame in response to a published `KillSignal`, Finding-2).

---

## 2. Dependency waves

```
Wave A (foundations — parallel, file-disjoint)
  T1 relay-auth scaffold + P5-local types/schemas   (v0.8: contracts only)
        │
        ▼
Wave B (mechanisms — parallel after T1)
  T2 capability token   T3 deny-by-default authz   T4 audit log
  T9 mTLS/SPIFFE verify+rotate   T10 revocation   T11 rate-limit/quota   (v0.9 core)
  T5 WebAuthn   T6 TOTP   T7 OIDC   T8 step-up                            (v0.10 human auth)
        │
        ▼
Wave C (enforcement + tripwire — converge)
  T12 enforce onUpgrade/onReattach  ← T2,T3,T10,T11   (v0.9 CORE ships here)
                                    + T4,T8            (v0.10 AUGMENTATION: audit emit + step-up gate)
  T13 CI cross-tenant tripwire       ← T12 (v0.9 unit variant = 403 only)
                                     + T4,T14 (v0.10 full-stack variant = audit+alert asserts)
  T14 audit-alert wiring             ← T4,T12          (v0.10)
```

**Phasing note (Finding-8 reconciliation):** T12's v0.9 **core** depends only on T2/T3/T10/T11 (all v0.9),
so it ships in v0.9. Its dependency on T4 (audit) and T8 (step-up) is a v0.10 **augmentation** wired through
injected deps (`audit`, `stepUpPolicyFor`) that are no-op-safe in v0.9 — a v0.9 task never blocks on a v0.10
task. T13's v0.9 unit variant asserts only the 403 outcomes; the "audit event + alert fired" assertions are
the v0.10 full-stack variant (needs T4 + T14). The wave diagram and every task `Depends:` line agree on this.

**Cross-plan dependencies (reference other plans' task IDs where their plans define them):**
- **P-INDEX §4** (`relay-contracts/`) is W0 for the whole program — frozen before P5's T1.
- **T3/T12 depend on P3's** `HostRegistry`/`SessionRegistry` (the §4.2 tables) to look up `host.account_id`
  / `session.account_id`. P5 codes against the port interfaces; P3 supplies the impl.
- **T9 depends on P3's mTLS signing endpoint** (§4.5 step 4 "sign short-lived cert") — P3 signs, P5 defines
  the profile it signs to and verifies + rotates it; and on **P2's** agent-side rotation client.
- **T2/T12 are consumed by P1's WS upgrade** (§4.1 OPEN carries `capabilityTokenRef`) and by **P6's**
  token-request path (login → token).
- **T10 publishes a `KillSignal` on the `RevocationBus`** (Redis pub/sub channel `relay:revocations`, §4.2);
  **P1** subscribes and injects a §4.1 `CLOSE`+RST (per stream) or `GOAWAY` (global) to tear the **live** mux
  tunnel down within `REVOCATION_PUSH_BUDGET_MS`; **P3** backs the store (`revoked:{jti}`, flip
  `hosts.status='revoked'`, set `revoked_at`) and the bus. P5 never touches a socket (Finding-2).
- **T2 mints/verifies §4.4 `deviceAuthProof`** (`signDeviceAuthProof` / host-side `verifyDeviceProof`, bound to
  **`{ clientEphPub, clientNonce }`** per INDEX §6b) consumed by **P4's** E2E handshake as an **injected dep**
  (P4's `DeviceAuthProofProvider` + `createHostHandshake({ verifyDeviceProof, … })`), so P4 binds a device to
  an account via P5's `AuthenticatedPrincipal`, not its own ad-hoc derivation, and never imports crypto
  identity from `relay-auth` directly (Finding-7 / §6b).
- **`hostContentSecret` delivery to browser devices (§4.5 / FIX 3).** P5 does **NOT** mint or wrap
  `hostContentSecret` — **P3 mints + wraps it at BIND, sealed to the host's enrolled Ed25519 `agent_pubkey`**
  (§4.5 `EnrollResult.hostContentSecret`), and **P2 unwraps it locally**. P5's role is the **authorized
  browser-device delivery gate**: an authorized device obtains `hostContentSecret` **via P5, unwrapped only
  after auth + step-up (T8)**, so it can re-derive P4's §4.4 `K_content` (`deriveContentKey`) and decrypt
  replayed ciphertext after a reload. Revoking the host/device (T10) invalidates the wrap → the secret can no
  longer be unwrapped for that host going forward (INV12). See T8.
- **INV9** (secrets validated at startup, never logged) is enforced in every task's config loader.

---

## Wave A

### T1 · `relay-auth` scaffold + P5-local types/schemas · **v0.8 (contracts only)**
- **Owns**: `relay-auth/package.json`, `relay-auth/tsconfig.json`, `relay-auth/vitest.config.ts`,
  `relay-auth/src/types.ts`, `relay-auth/test/types.test.ts`
- **Depends**: `relay-contracts/` §4 frozen. **Parallel-safe**: root of all P5 tasks (freeze after T1, like
  `src/types.ts`).
- **Why v0.8**: INDEX §1 v0.8 — "P4/P5 **write their contracts** (so v0.9/v0.10 don't reshape data) but ship
  no runtime." T1 is that contract freeze; no auth logic runs in v0.8 (the shared `clientToken` gate is P6's).
- **Contracts** (P5-local — NOT in §4; §4 types are `import`ed from `relay-contracts`, never re-declared):
  ```ts
  // Re-export the frozen §4 types for P5 consumers (import, never redefine):
  export type { CapabilityToken, CapabilityRight } from 'relay-contracts' // §4.3
  export type { HostRecord, HostStatus, PlanTier } from 'relay-contracts' // §4.2

  // P5-local principal — the ONLY source of account_id in an authz decision (INV3):
  export type PrincipalKind = 'human' | 'agent' | 'share-grant'
  export interface AuthenticatedPrincipal {
    readonly kind: PrincipalKind
    readonly accountId: string      // DERIVED from verified credential/cert — never from client input
    readonly principalId: string    // device/credential id (WebAuthn credId, SPIFFE-ID, or grant jti)
    readonly amr: readonly AuthMethod[] // methods satisfied this session (for step-up, T8)
    readonly authAt: number         // epoch seconds of last successful auth (login freshness)
    readonly stepUpAt: number | null // epoch seconds of last successful step-up (T8 freshness); null = never
  }
  export type AuthMethod = 'passkey' | 'totp' | 'oidc' | 'mtls' | 'stepup'

  // FROZEN P5 CONVENTION for the §4.3 `CapabilityToken.sub` field (resolves former open-Q #3):
  // §4.3 defines `sub` as "principal id (account/device)". P5 (the token's sole issuer, T2) BINDS
  // `sub` to `principal.accountId` at issuance — the account is the authoritative unit for the
  // cross-tenant gate (INV1/INV3). Device identity is NOT carried in the token (it is not needed for
  // the tenant gate; step-up presence lives on the session `AuthenticatedPrincipal`, T8). This is a
  // semantic choice about the VALUE P5 writes into an existing frozen field — it does NOT redefine
  // §4.3. The single reader is T3's `accountIdFromToken` resolver (below).
  export const CAP_TOKEN_SUB_IS_ACCOUNT_ID = true as const

  // Step-up policy is a P5-local type (not in §4); T8 evaluates it, T12 enforces it:
  export interface StepUpPolicy { readonly maxAgeSeconds: number; readonly requiredMethod: AuthMethod }

  export interface WebAuthnCredential {         // §4.2 "webauthn_credentials (P5-owned)"
    readonly credentialId: string; readonly accountId: string
    readonly publicKey: Uint8Array; readonly signCount: number
    readonly transports: readonly string[]; readonly createdAt: string
  }
  export interface OidcIdentity {               // §4.2 "oidc_identities (P5-owned)"
    readonly issuer: string; readonly subject: string; readonly accountId: string
  }
  export interface TotpSecretRecord {           // stored ENCRYPTED at rest (INV5); never SMS
    readonly accountId: string; readonly encSecret: Uint8Array; readonly confirmedAt: string | null
  }
  export interface AuditEvent {                 // INV10 — metadata only, ZERO payload
    readonly ts: string; readonly action: AuditAction
    readonly principalId: string; readonly accountId: string
    readonly hostId: string | null; readonly sessionId: string | null
    readonly jti: string | null; readonly outcome: 'allow' | 'deny'
    readonly reason: string; readonly remoteAddrHash: string // salted, §4.1 remoteAddrHash
    // COMPILE-TIME GUARD: no field may carry keystrokes/output. Enforced by redact.ts (T4) + lint.
  }
  export type AuditAction =
    | 'attach' | 'reattach' | 'manage' | 'kill' | 'enroll'
    | 'revoke' | 'login' | 'stepup' | 'token-issue' | 'cross-tenant-attempt'

  export type SpiffeId = string  // 'spiffe://relay.<domain>/account/<accountId>/host/<hostId>'
  export interface RateLimitPolicy {
    readonly connectPerMin: number; readonly enrollPerHour: number
    readonly maxConcurrentSessions: number; readonly maxPairedHosts: number // by PlanTier
    readonly preAuthPerMinPerIp: number   // pre-principal throttle keyed on remoteAddrHash (T11, INV-DoS)
    readonly totpMaxFailsPerWindow: number // TOTP brute-force lockout (T6)
    readonly totpLockoutWindowSec: number
  }
  // Revocation kill-signal — FROZEN in relay-contracts §4.2 (FIX 4), NOT redefined here. The teardown
  // channel + signal shape were promoted OUT of relay-auth into relay-contracts so P1 can subscribe without
  // importing P5 (keeps the DAG acyclic). P5 (T10) only PUBLISHES; re-export the frozen types + constants:
  export type { RevocationScope, KillSignal, RevocationBus } from 'relay-contracts' // §4.2, FIX 4 — never redeclared
  export { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS } from 'relay-contracts' // 'relay:revocations' · 2000ms
  // Read-only ports P3 implements (P5 stays storage-free & pure):
  export interface HostRegistryPort { getById(hostId: string): Promise<HostRecord | null> }
  export interface SessionRegistryPort { getById(sessionId: string): Promise<{ hostId: string; accountId: string } | null> }
  export interface RevocationStore {
    isRevoked(jti: string): Promise<boolean>
    revokeJti(jti: string, exp: number): Promise<void>
    // single-use consumption of a connect-scoped token (T2/T4 leak-blast-radius control):
    consumeOnce(jti: string, exp: number): Promise<boolean>   // true = first use (proceed); false = replay (deny)
  }
  export interface AuditSink { append(e: AuditEvent): Promise<void> }
  export interface TokenBucketStore {                          // P3 Redis; keys are OPAQUE to P5
    take(key: string, refillPerSec: number, burst: number, now: number): Promise<boolean> // false = throttled
  }
  // Revocation push bus — `RevocationBus` is FROZEN in relay-contracts §4.2 (FIX 4), re-exported above, NOT
  // redeclared. P5 (T10) only PUBLISHES immutable `KillSignal`s on `RELAY_REVOCATIONS_CHANNEL`; it never
  // touches sockets. P3/P1 implement the transport (Redis pub/sub channel `relay:revocations`, §4.2). See T10.
  //   (frozen shape, for reference: interface RevocationBus { publish(signal: KillSignal): Promise<void> })
  ```
  Every exported interface has a matching **Zod schema** (`WebAuthnCredentialSchema`, `AuditEventSchema`, …)
  for boundary validation (`coding-style.md` "Input Validation").
- **TDD steps**: (RED) `test/types.test.ts` asserts each Zod schema round-trips a valid record and **rejects**
  a forged one (extra `accountId` field on a client body is stripped; `AuditEvent` rejects any unknown key →
  zero-payload guard). (GREEN) write `types.ts`. (REFACTOR) file < 400 lines; split schemas into
  `types.ts` + `schemas.ts` if it crosses 400.
- **Test cases**: valid `AuthenticatedPrincipal` parses; `AuditEvent` with an added `data`/`bytes` key →
  **throws** (INV10 tripwire at the type layer); `SpiffeId` regex accepts the account/host form and rejects a
  path-traversal (`../`) or wildcard; `CAP_TOKEN_SUB_IS_ACCOUNT_ID === true` (compile+runtime guard that the
  `sub`-binding convention is stated in exactly one place, consumed by T2 issuance and T3 `accountIdFromToken`).
- **Security**: this task **defines** that `accountId` originates only inside `AuthenticatedPrincipal`
  (INV3) and that `CapabilityToken.sub` carries **that same `accountId`** (frozen P5 convention above) —
  so T3's cross-tenant gate compares two account ids derived from authenticated material, never a client
  field. No schema anywhere in P5 accepts a client-supplied `accountId`/`tenant_id` as authoritative.

---

## Wave B

### T2 · Capability token issue + verify (§4.3) + proof-of-possession + device-auth proof · **v0.9**
- **Owns**: `relay-auth/src/capability/issue.ts`, `relay-auth/src/capability/verify.ts`,
  `relay-auth/src/capability/device-proof.ts`, `relay-auth/test/capability.test.ts`,
  `relay-auth/test/device-proof.test.ts`
- **Depends**: T1. **Consumed by**: P1 WS upgrade (INV15), P6 login→token, T10 revocation, T12 enforce,
  **P4 E2E handshake** (host-side `verifyDeviceProof(proof, { clientEphPub, clientNonce })` validates §4.4
  `ClientHello.deviceAuthProof`, injected into P4 — INDEX §6b / Finding-7 fix).
- **Contract** — implements §4.3 verbatim (`CapabilityToken`, `CapabilityRight`, `verifyCapabilityToken`):
  ```ts
  import type { CapabilityToken, CapabilityRight } from 'relay-contracts' // §4.3, NOT redefined
  export interface IssueArgs {
    readonly principal: AuthenticatedPrincipal   // sub := principal.accountId (INV3, T1 convention)
    readonly aud: string                         // subdomain (Host-confusion guard, INV1)
    readonly host: string                        // single host_id, verified owned by principal.accountId
    readonly rights: readonly CapabilityRight[]  // least-privilege subset
    readonly ttlSeconds: number                  // CONNECT-scoped: clamped to [CONNECT_TOKEN_MIN_TTL_SEC=30,
                                                 //   CONNECT_TOKEN_MAX_TTL_SEC=60] — see TTL note below
    readonly cnfJkt: string                      // proof-of-possession: base64url SHA-256 of the client's
                                                 //   ephemeral public JWK (DPoP-style channel binding, Finding-4)
  }
  // NOTE: `cnfJkt` is embedded as an ADDITIONAL signed claim (`cnf.jkt`, RFC 7800) in the PASETO/JWS token
  // body. It ADDS a claim; it does NOT redefine the frozen §4.3 `CapabilityToken` interface (which stays the
  // authoritative semantic shape). Because it adds bytes that cross into P1/P6, it needs the same one-line
  // INDEX §4.3 confirmation as the token-format pick (§5 open-Q #1). Verifiers read it via `readCnfJkt()`
  // below, never by mutating the frozen type. Until confirmed, the shipped mitigations (short TTL + single-use
  // + memory-only client storage) already bound a leak; PoP is the belt-and-suspenders layer.
  export function readCnfJkt(token: CapabilityToken): string  // reads the additive cnf.jkt claim off the verified token
  export function issueCapabilityToken(a: IssueArgs, signingKey: CryptoKey, now: number): Promise<string>
  // §4.3 signature — kept VERBATIM (no added params → no contract drift). Verifies token sig/exp/aud only:
  export function verifyCapabilityToken(raw: string, expectedAud: string, now: number): Promise<CapabilityToken>
  // DPoP proof-of-possession is a SEPARATE P5-local check (does NOT alter the §4.3 function). The presenting
  // connection signs (htm,htu,jti,now) with the ephemeral key whose JWK thumbprint == token `cnf.jkt`:
  export interface DpopContext { readonly proofJws: string; readonly htu: string; readonly htm: string }
  export function verifyDpopProof(token: CapabilityToken, dpop: DpopContext, now: number): Promise<boolean>
    // false if cnf.jkt ≠ thumbprint(dpop signer) OR dpop replayed OR htu/htm mismatch
  export function subAccountId(token: CapabilityToken): string  // returns token.sub (the accountId, T1 convention)
  ```
  **TTL & single-use policy (Finding-4 — leaked-token blast-radius):** connect-scoped tokens are
  **short-TTL 30–60 s** (`issueCapabilityToken` clamps `ttlSeconds` to that range and rejects longer values)
  and **single-use**: T12 calls `RevocationStore.consumeOnce(jti, exp)` on the FIRST successful upgrade, so a
  replayed token (captured in a proxy/log before E2E ships, or a copy-pasted link) is refused on the 2nd use.
  Longer-lived reuse is obtained only by re-issuing a fresh token via the authenticated login path (P6),
  never by minting a long TTL. Tokens carry a **`cnf.jkt`** proof-of-possession binding (DPoP-style): the
  presenting connection must sign a DPoP proof with the ephemeral key it generated, so a bearer copy without
  that private key cannot upgrade. **P6 MUST hold the token + ephemeral key memory-only (never `localStorage`)**;
  residual XSS risk is documented in §3.
  **Device-auth proof (Finding-7 / INDEX §6b — SOLE issuer+verifier of §4.4 `deviceAuthProof`; P4 consumes as
  an injected dep and never re-derives account binding):** the binding param is **unified across P2/P4/P5 to
  `{ clientEphPub, clientNonce }`** (frozen §4.4 / INDEX §6b) — a **per-handshake, non-replayable** binding, NOT
  `transcriptHash` and NOT a static bearer token, so a captured proof cannot be replayed into a different
  freshly-keyed `client_hello`. P5 mints from the authenticated `AuthenticatedPrincipal` (the proof asserts
  `principal.accountId`); the tenant gate itself (INV1) is the capability-token cross-tenant check at
  `onUpgrade` (T3/T12), *upstream* of the handshake.
  ```ts
  // `relay-auth/src/capability/device-proof.ts` — the SOLE issuer/verifier of §4.4 ClientHello.deviceAuthProof.
  export type DeviceProofBinding = { readonly clientEphPub: Uint8Array; readonly clientNonce: Uint8Array } // == frozen §4.4 shape
  // Client-side minting — backs the §4.4 `DeviceAuthProofProvider.proofFor(hostId, binding)` P4 injects:
  export function signDeviceAuthProof(
    principal: AuthenticatedPrincipal, binding: DeviceProofBinding, signingKey: CryptoKey, now: number
  ): Promise<string>   // binds principal.accountId to THIS handshake's clientEphPub‖clientNonce (per-handshake, non-replayable)
  // Host-side verification — the injected `verifyDeviceProof` P4's createHostHandshake calls (§4.4 / §6b):
  export function verifyDeviceProof(
    proof: string, binding: DeviceProofBinding, now: number
  ): Promise<boolean>  // true iff P5-signed AND bound to THIS clientEphPub/clientNonce; a static/unbound or mis-bound proof → false
  ```
- **TDD (tests first)**: (RED) `capability.test.ts`, `device-proof.test.ts`. (GREEN) implement with Ed25519
  PASETO v4.public / JWS EdDSA. (REFACTOR) no `console.log`; typed errors (`CapabilityError` machine reason);
  split `issue.ts`/`verify.ts`/`device-proof.ts` each < 200 lines.
- **Test cases (incl. negative/security)**:
  - round-trip: issue→verify returns the same `host`, `rights`, `jti`; `subAccountId(token) === principal.accountId`.
  - **`sub` binding**: `issueCapabilityToken` sets `token.sub = principal.accountId` (never `principalId`,
    never a client value) — assert directly (feeds T3's cross-tenant gate).
  - **expired** (`exp < now`) → reject; **not-yet-valid** (`iat > now+skew`) → reject.
  - **over-long TTL**: `ttlSeconds > 60` → **reject at issue** (clamp/refuse; no long-lived reusable token).
  - **single-use**: 2nd `verify` after `consumeOnce` has marked the `jti` → reject (replay).
  - **proof-of-possession**: valid token + DPoP proof from a **different** key (thumbprint ≠ `cnf.jkt`) → reject;
    replayed DPoP proof (same jti+now reused) → reject.
  - **wrong `aud`** (token for `alice`, presented at `bob.term.<domain>`) → reject (INV1 Host-confusion).
  - **tampered payload** (flip a rights bit) → signature fails → reject.
  - **wildcard `host`** (`'*'`) → reject at issue time (single host only).
  - **rights escalation**: a token with `rights:['attach']` — `hasRight(token,'kill')` is `false` (INV15).
  - forged token signed by a different key → reject.
  - **device-auth proof (binding to `{clientEphPub, clientNonce}`, §6b)**: a proof minted for handshake A (its
    `clientEphPub`/`clientNonce`) replayed into handshake B with a **different** `clientEphPub` →
    `verifyDeviceProof(proof, bindingB, now)` returns **`false`** (per-handshake binding defeats replay); a
    static/unbound bearer proof → `false`; a proof tampered after signing → `false`.
- **Security**: `sub`/`host`/`account` are taken from `IssueArgs.principal`, never from a client field
  (INV3). Token is short-TTL (30–60 s) + single-use + `jti`-revocable (INV12, via T10) + PoP-bound (`cnf.jkt`).
  `deviceAuthProof` is minted/verified **only here** from P5's `AuthenticatedPrincipal`, bound to
  **`{ clientEphPub, clientNonce }`** (§6b), so P4's handshake consumes P5's binding rather than inventing its
  own (Finding-7). Signing key loaded from secret manager,
  validated at startup, never logged (INV9).

### T3 · Deny-by-default tenant authz core (INV1/INV3/INV6) · **v0.9**
- **Owns**: `relay-auth/src/authz/principal.ts`, `relay-auth/src/authz/decide.ts`,
  `relay-auth/test/authz.test.ts`
- **Depends**: T1, T2; **P3** `HostRegistryPort`/`SessionRegistryPort` (§4.2).
- **Contract**:
  ```ts
  export type AuthzOutcome =
    // `jti` = the jti of the VERIFIED capability token (resolves reconcile OQ5): P1's thin
    // adapter no longer verifies the token, so P5 surfaces it here for MuxOpen.capabilityTokenRef
    // (T10's per-jti splice index / single-device closeStream). P1 consumes read-only; never redefines.
    | { readonly ok: true; readonly principal: AuthenticatedPrincipal; readonly hostId: string; readonly jti: string }
    | { readonly ok: false; readonly status: 401 | 403; readonly reason: string }

  export interface ConnectRequest {
    readonly capabilityRaw: string          // token from the WS upgrade (INV15)
    readonly expectedAud: string            // subdomain the request arrived on
    readonly requestedHostId: string        // client NAMES a host_id it claims to own — NOT account_id (INV3)
    readonly requiredRight: CapabilityRight
  }
  export interface ReattachRequest extends ConnectRequest { readonly sessionId: string }

  import type { DpopContext } from '../capability/verify' // T2 — proof-of-possession material (Finding-4), NOT redeclared

  // T3-LOCAL resolver — the SINGLE point that maps a verified token → its authoritative accountId.
  // Per the frozen T1 convention (CAP_TOKEN_SUB_IS_ACCOUNT_ID), `sub` IS the accountId; this is the
  // real field the cross-tenant gate compares (resolves former open-Q #3; replaces the undefined
  // `token.accountBoundFromSub`). Kept as a named function so the mapping is testable & greppable.
  export function accountIdFromToken(token: CapabilityToken): string  // returns token.sub (validated non-empty)

  // The ONLY authorization path (INV1: no host resolved by raw addr/hostname):
  export function authorizeConnect(
    req: ConnectRequest, dpop: DpopContext, hosts: HostRegistryPort, revocation: RevocationStore, now: number
  ): Promise<AuthzOutcome>
  export function authorizeReattach(
    req: ReattachRequest, dpop: DpopContext, hosts: HostRegistryPort, sessions: SessionRegistryPort,
    revocation: RevocationStore, now: number
  ): Promise<AuthzOutcome>
  ```
  **Decision algorithm (deny-by-default, early-return):**
  ```
  token = await verifyCapabilityToken(req.capabilityRaw, req.expectedAud, now) // fail (sig/exp/aud) → 401
  if (!(await verifyDpopProof(token, dpop, now))) return 401                // proof-of-possession (Finding-4)
  if (await revocation.isRevoked(token.jti)) return 403                     // INV12
  if (!token.rights.includes(req.requiredRight)) return 403                 // INV15 least-priv
  if (token.host !== req.requestedHostId) return 403                        // token scopes ONE host (INV1)
  host = await hosts.getById(req.requestedHostId); if (!host) return 403
  if (host.status === 'revoked') return 403                                 // INV12
  // account_id comes from the token's authenticated `sub`, host.account_id from the registry —
  // NEVER from a client field (INV3). accountIdFromToken() is the sole resolver (T1 convention):
  tokenAccountId = accountIdFromToken(token)                                // == token.sub == principal.accountId
  if (host.accountId !== tokenAccountId) return 403                         // THE cross-tenant gate (INV1)
  // reattach ADDS: session must exist AND belong to same account (INV6 re-validate):
  if (reattach) { s = await sessions.getById(req.sessionId);
                  if (!s || s.accountId !== host.accountId) return 403 }
  return { ok:true, principal, hostId: host.hostId }
  ```
  There is **no `allow-if-unspecified` branch** — the function returns 403 unless every check passes.
  Note: single-use consumption (`RevocationStore.consumeOnce`) is done by T12 **after** a full allow, so a
  denied attempt does not burn the token; T3 stays a pure decision function.
- **TDD (tests first)**: RED `authz.test.ts` with a fake `HostRegistryPort` seeded with host B owned by
  account B. GREEN implement. REFACTOR: `decide.ts` < 200 lines; each function < 50 lines; no nesting > 4.
- **Test cases (security-critical)**:
  - **INV1 cross-tenant (THE tripwire, against the real `sub` field)**: a **validly signed** token whose
    `sub` (= account A) does **not** resolve to `host.accountId` (host owned by B) → `accountIdFromToken(token)`
    returns A, `host.accountId` is B, gate fails → **403**. This exercises the concrete field, not a placeholder.
  - **`accountIdFromToken` unit**: returns `token.sub`; throws on empty/malformed `sub` (deny-by-default input guard).
  - **INV6 reattach cross-tenant**: valid host-A token but `sessionId` owned by B → **403**.
  - **INV3 forged account**: a `ConnectRequest` shape carrying an extra `account_id` field is *type-impossible*
    (not in schema) → cannot influence the decision; assert the decision uses only `host.accountId` vs
    `accountIdFromToken(token)`, never any client-supplied value.
  - missing/invalid token → **401**; failing DPoP proof (PoP mismatch) → **401**; revoked `jti` → **403**;
    `requiredRight='kill'` with `attach`-only token → **403**; `token.host !== requestedHostId` → **403**
    (defeats IDOR by swapping the path host).
  - happy path: account A + own host + `attach` right + live host → `ok:true`.
- **Security**: this is the single deny-by-default gate (INV1/INV3/INV6). Every branch that isn't an explicit
  allow is a deny. Never resolve a host by anything except a registry lookup keyed on the token-scoped
  `host_id`.

### T4 · Immutable zero-payload audit log (INV10) · **v0.10**
- **Owns**: `relay-auth/src/audit/log.ts`, `relay-auth/src/audit/redact.ts`,
  `relay-auth/test/audit.test.ts`
- **Depends**: T1; **P3** `AuditSink` (append-only Postgres table / WORM store).
- **Contract**:
  ```ts
  export function buildAuditEvent(p: {
    action: AuditAction; principal: AuthenticatedPrincipal | null; hostId: string | null
    sessionId: string | null; jti: string | null; outcome: 'allow' | 'deny'
    reason: string; remoteAddrHash: string; now: number
  }): AuditEvent
  export function assertZeroPayload(e: AuditEvent): void  // throws if any value looks like terminal bytes
  export async function audit(sink: AuditSink, e: AuditEvent): Promise<void> // append-only; never update/delete
  ```
- **TDD**: RED `audit.test.ts`. GREEN implement `redact.ts` (`assertZeroPayload` rejects any string field >
  a metadata length cap or containing control/ESC bytes `\x1b`). REFACTOR keep < 200 lines.
- **Test cases**: a well-formed `attach` allow event appends; a `deny` event records `reason`; **payload
  guard** — an event whose `reason` contains `\x1b[` or a >256-char blob → `assertZeroPayload` **throws**
  (INV10); the sink is append-only (a test double rejects `update`/`delete`).
- **Security**: audit entries are metadata only — principal, host_id, action, ts, outcome (INV10). Terminal
  payload is *structurally* excluded (no field carries it). Immutable/append-only (INV8-adjacent).

### T5 · Passkey / WebAuthn (primary) · **v0.10**
- **Owns**: `relay-auth/src/human/webauthn/register.ts`, `.../authenticate.ts`,
  `relay-auth/test/webauthn.test.ts`
- **Depends**: T1; **P3** `CredentialStore` (persists `WebAuthnCredential`, §4.2 P5-owned table). Uses
  `@simplewebauthn/server` (battle-tested, per `development-workflow.md` reuse rule).
- **Contract**:
  ```ts
  export function startRegistration(accountId: string, rpId: string): Promise<RegistrationOptions>
  export function finishRegistration(accountId: string, resp: RegistrationResponse, expectedChallenge: string,
    rpId: string, origin: string): Promise<WebAuthnCredential>          // verifies attestation
  export function startAuthentication(accountId: string, rpId: string): Promise<AuthenticationOptions>
  export function finishAuthentication(cred: WebAuthnCredential, resp: AuthenticationResponse,
    expectedChallenge: string, rpId: string, origin: string): Promise<AuthenticatedPrincipal>
  ```
- **TDD / test cases**: challenge is single-use (replayed challenge → reject); **origin mismatch** (assertion
  from `evil.com`) → reject; **signCount regression** (counter goes backwards → cloned-authenticator signal)
  → reject; unknown `credentialId` → reject; happy path yields a principal with `amr:['passkey']`.
- **Security**: Passkey is **primary** (phishing-resistant; the payload is a root-capable shell). `rpId` bound
  to the tenant subdomain. Never SMS. Challenges from CSPRNG, short TTL, never logged (INV9).

### T6 · TOTP fallback (RFC 6238, never SMS) · **v0.10**
- **Owns**: `relay-auth/src/human/totp/totp.ts`, `relay-auth/test/totp.test.ts`
- **Depends**: T1; T11 `TokenBucketStore` (attempt-lockout counter); `TotpSecretRecord` stored **encrypted**
  at rest (INV5).
- **Contract**:
  ```ts
  export function generateTotpSecret(): { secret: Uint8Array; otpauthUri: string }
  export function verifyTotp(secret: Uint8Array, code: string, now: number, window?: number): boolean // ±1 step
  // Brute-force lockout (Finding-6): a 6-digit code has only 10^6 space; without throttling it is
  // guessable within one 30 s step. Called BEFORE verifyTotp; records failures; locks per-account.
  export function checkTotpAttemptRate(
    accountId: string, policy: RateLimitPolicy, store: TokenBucketStore, now: number
  ): Promise<boolean>   // false = locked out (deny without even checking the code)
  export function recordTotpFailure(accountId: string, store: TokenBucketStore, now: number): Promise<void>
  ```
- **TDD / test cases**: known RFC-6238 vector passes; code from the previous 30 s step within `window` passes;
  two steps stale → fail; **replay guard** — the caller (T8) marks a consumed `(secret, step)` used, so the
  same code inside the window can't be reused; malformed code (non-numeric / wrong length) → `false`;
  **lockout (Finding-6)** — after `policy.totpMaxFailsPerWindow` (e.g. 5) consecutive bad codes within
  `policy.totpLockoutWindowSec`, `checkTotpAttemptRate` returns `false` (further attempts denied without
  hitting `verifyTotp`) and unlocks only after the window elapses (escalating backoff); a correct code
  after a failure below the threshold still succeeds.
- **Security**: TOTP is a *fallback*, mandatory-second-factor style, **never SMS** (SIM-swap → shell =
  catastrophic, EXPLORE §4a.2). Secret encrypted at rest, decrypted only in-process (INV5/INV9). The
  per-account failed-attempt lockout (via T11's `TokenBucketStore`, keyed on the authenticated `accountId`,
  INV3) closes the brute-force hole — a 6-digit code is otherwise trivially guessable in one step window.

### T7 · OIDC SSO for teams · **v0.10**
- **Owns**: `relay-auth/src/human/oidc/oidc.ts`, `relay-auth/test/oidc.test.ts`
- **Depends**: T1; `OidcIdentity` store (P3). Auth-code flow **+ PKCE**, `state`/`nonce` validation.
- **Contract**:
  ```ts
  export function buildAuthUrl(cfg: OidcConfig, state: string, nonce: string, codeChallenge: string): string
  export function exchangeCode(cfg: OidcConfig, code: string, codeVerifier: string): Promise<OidcTokenSet>
  export function verifyIdToken(cfg: OidcConfig, idToken: string, expectedNonce: string, now: number)
    : Promise<OidcIdentity>   // links issuer+subject → accountId (team mapping)
  ```
- **TDD / test cases**: `state` mismatch → reject (CSRF); `nonce` mismatch → reject (replay); expired/bad-`iss`
  id_token → reject; unknown issuer not in the allow-list → reject; happy path returns `OidcIdentity` mapped
  to the team account.
- **Security**: PKCE mandatory; issuer allow-list from config (validated at startup, INV9); `account_id`
  derived from the verified `(iss, sub)` mapping, never from a client claim (INV3).

### T8 · Step-up auth before opening a session · **v0.10**
- **Owns**: `relay-auth/src/human/stepup/stepup.ts`, `relay-auth/test/stepup.test.ts`
- **Depends**: T1 (`StepUpPolicy` type lives there, imported not redefined); T5 (preferred) / T6;
  T2 (a session-open capability token is only issued after step-up passes). **Consumed by T12** (the
  enforcement path calls `needsStepUp` before returning `ok:true` — see T12 v0.10 augmentation).
- **Contract** (`StepUpPolicy` imported from `relay-auth/src/types.ts`, T1 — NOT redefined here):
  ```ts
  import type { StepUpPolicy } from '../../types' // T1
  export function policyForHost(host: HostRecord): StepUpPolicy   // per-host step-up freshness requirement
  export function needsStepUp(principal: AuthenticatedPrincipal, policy: StepUpPolicy, now: number): boolean
  export function recordStepUp(principal: AuthenticatedPrincipal, method: AuthMethod, now: number): AuthenticatedPrincipal // immutable copy
  ```
  `needsStepUp` is true when `principal.stepUpAt` is null OR `now - principal.stepUpAt > policy.maxAgeSeconds`
  OR `policy.requiredMethod ∉ principal.amr` — i.e. a *fresh login is not sufficient*; a recent step-up is
  required. `recordStepUp` returns a new principal with `stepUpAt = now` and `method` added to `amr`.
- **TDD / test cases**: a principal freshly logged in (`authAt = now`) but with `stepUpAt = null` →
  `needsStepUp` **true** (login ≠ step-up — this is the exact case T12 must refuse, Finding-3); a principal
  whose `stepUpAt` is older than `maxAgeSeconds` → true; fresh passkey step-up (`stepUpAt = now`,
  requiredMethod in `amr`) → false; `recordStepUp` returns a **new** principal (immutability rule), original
  untouched; a token to **open a session** is refused by T12 unless `needsStepUp` is false.
- **Security**: step-up gates **opening a session**, not just login (EXPLORE §4a.2 / INDEX §1 v0.10). This is
  the "before you drop into a live root-capable shell, re-assert presence" control. It is enforced in T12's
  `onUpgrade`/`onReattach` sequence (v0.10 augmentation), **not** merely asserted in prose (Finding-3).
- **`hostContentSecret` browser-delivery gate (§4.5 / FIX 3).** A fresh step-up here is **also** the gate for
  releasing the §4.5 `hostContentSecret` to an authorized browser device: it is **unwrapped for the device only
  after `needsStepUp` is false**, so the device can re-derive P4's §4.4 `K_content` (`deriveContentKey`) and
  decrypt replayed ciphertext. **P5 does not mint/wrap it — P3 mints+wraps at BIND (sealed to `agent_pubkey`),
  P2 unwraps for the agent** — P5 only performs the step-up-gated release to browser devices; a revoked
  host/device (T10) can no longer obtain it (INV12).

### T9 · mTLS SPIFFE cert profile, verification & rotation (INV14/INV4) · **v0.9**
- **Owns**: `relay-auth/src/agent/spiffe.ts`, `relay-auth/src/agent/verify-mtls.ts`,
  `relay-auth/src/agent/rotate.ts`, `relay-auth/test/mtls.test.ts`
- **Depends**: T1; **P3** signing endpoint (§4.5 step 4 signs the cert P5 profiles); **P2** agent-side
  rotation client. P5 owns the **profile + verification + rotation policy**, not the CA private key (P3).
- **Contract**:
  ```ts
  export function spiffeIdFor(accountId: string, hostId: string): SpiffeId // 'spiffe://relay.<domain>/account/<a>/host/<h>'
  export function parseSpiffeId(uri: SpiffeId): { accountId: string; hostId: string }
  export interface MtlsVerifyResult { readonly ok: boolean; readonly hostId?: string; readonly accountId?: string; readonly reason?: string }
  // Verify the presented client cert chain at the agent tunnel handshake:
  export function verifyAgentCert(leafPem: string, caChainPem: string, now: number,
    hosts: HostRegistryPort): Promise<MtlsVerifyResult>  // SPIFFE-ID SAN must match a host in registry
  export interface RotationPlan { readonly renewAtSeconds: number; readonly certTtlSeconds: number } // TTL short (e.g. 1h), renew at ~50%
  export function shouldRotate(certNotAfter: number, now: number, plan: RotationPlan): boolean
  ```
- **TDD / test cases**: cert whose SPIFFE-ID SAN account/host **is not in the registry** → `ok:false` (CA
  never validates a pubkey not enrolled, INV4); **expired** leaf → refuse; **SPIFFE-ID mismatch** (cert for
  host X presented on host Y's tunnel) → refuse; `shouldRotate` true at ~50% TTL and true after expiry, false
  when fresh; rotation mid-tunnel is seamless (a new valid cert supersedes without dropping — asserted via the
  rotation-plan boundary).
- **Security**: agents authenticate with a **per-host Ed25519 key + short-TTL rotating cert** (INV14); DB
  holds only the **public** key (INV4). Revocation = "stop renewing + kill tunnel" (T10) — no CRL/OCSP
  nightmare. mismatched/expired/foreign certs are refused deny-by-default.

### T10 · Global + per-host revocation, kills live tunnels in seconds (INV12) · **v0.9 (fast-push: v0.10)**
- **Owns**: `relay-auth/src/revocation/revoke.ts`, `relay-auth/src/revocation/check.ts`,
  `relay-auth/test/revocation.test.ts`
- **Depends**: T1 (`RevocationScope`/`KillSignal` types), T2; **P3** `RevocationStore` (Redis `revoked:{jti}`,
  §4.2; flip `hosts.status='revoked'`, set `revoked_at`) + `RevocationBus` (Redis pub/sub); **P1** subscribes
  to the bus and force-closes the live streams.
- **Contract** (`RevocationScope`/`KillSignal` imported from T1 `types.ts`, NOT redefined here):
  ```ts
  // RevocationScope/KillSignal/RevocationBus + RELAY_REVOCATIONS_CHANNEL/REVOCATION_PUSH_BUDGET_MS are FROZEN
  // in relay-contracts §4.2 (FIX 4), re-exported by T1 types.ts — imported here, NEVER redefined:
  import type { RevocationScope, KillSignal, RevocationBus } from '../types' // T1 re-export of relay-contracts §4.2
  import { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS } from '../types' // 'relay:revocations' · 2000ms
  export function revoke(
    scope: RevocationScope, store: RevocationStore, hosts: HostRegistryPort, bus: RevocationBus, now: number
  ): Promise<KillSignal>
  // Steps (ordered): 1) mark revoked in store (revoked:{jti} / hosts.status='revoked', revoked_at)
  //                  2) stop cert renewal (T9 shouldRotate sees revoked → never renews)
  //                  3) PUBLISH the KillSignal on RevocationBus  ← the push channel (Finding-2)
  //                  4) return the KillSignal
  export function isTokenRevoked(jti: string, store: RevocationStore): Promise<boolean>
  export function killsScope(signal: KillSignal, hostAccountId: string, hostId: string): boolean // does this signal cover this stream?
  ```
  **Live-tunnel teardown transport (resolves former open-Q #4, Finding-2; contract FROZEN in §4.2 by FIX 4):**
  revocation must tear down an **already-open** stream, not merely refuse the next connect. P5 stays socket-free:
  `revoke()` **publishes** an immutable `KillSignal` on the injected `RevocationBus`, whose transport is the
  **frozen `RELAY_REVOCATIONS_CHANNEL = 'relay:revocations'`** Redis pub/sub channel (relay-contracts §4.2, FIX 4 —
  P3/P1 back it). Every P1 relay node subscribes; on receipt it injects a **§4.1 control frame** for each affected
  stream — a per-stream `CLOSE` with `flags.RST` (abnormal close) for host/account scope, or a connection-level
  `GOAWAY` (streamId 0) for global drain — using the **existing frozen §4.1 frame format** (no new wire type, no
  P5↔socket coupling). `killsScope()` is the pure predicate P1 uses to decide which streams a signal covers (host
  match, account match, or global). Bounded latency target: **teardown within the frozen
  `REVOCATION_PUSH_BUDGET_MS` (= 2000 ms, §4.2)** of `revoke()` returning. **P3 OQ4's "control→node drain channel"
  reconciles to this exact channel** (FIX 4), so P3 and P5 publish to one named bus.
- **TDD / test cases**: revoking a host → its capability `jti`s stop validating (T2 verify + T3 authz both
  now 403); **live-tunnel teardown (Finding-2)** — with a fake `RevocationBus` + a fake P1 subscriber holding
  an already-authorized open stream, `revoke()` publishes a `KillSignal` and the subscriber force-closes that
  stream (asserted via `killsScope` selecting it and an injected RST) **within `REVOCATION_PUSH_BUDGET_MS`** —
  i.e. the running shell is dropped, not left alive until the client happens to reconnect; a **subsequent
  reconnect is also refused** (INV12); global revocation covers all hosts (GOAWAY); account revocation covers
  only that account's hosts and no other tenant's (`killsScope` false for a foreign account); revocation is
  idempotent (re-publish is a no-op teardown).
- **Security**: one control to kill all tunnels + stop cert issuance (INV12). Fast **push** path: Redis
  `revoked:{jti}` short-TTL keys (for the connect-time gate) **plus** a `relay:revocations` pub/sub `KillSignal`
  that the data plane (P1) turns into a §4.1 RST/GOAWAY, so an active session's socket drops in seconds — the
  live shell of a revoked account cannot keep streaming until token expiry.

### T11 · Rate-limits / quotas (pre-auth IP + per-tenant account) · **v0.9**
- **Owns**: `relay-auth/src/ratelimit/quota.ts`, `relay-auth/test/quota.test.ts`
- **Depends**: T1; `RateLimitPolicy` by `PlanTier` (§4.2); a `TokenBucketStore` port (P3 Redis, T1).
- **Contract**:
  ```ts
  export function policyForPlan(plan: PlanTier): RateLimitPolicy
  export const PRE_AUTH_POLICY: RateLimitPolicy // fixed, plan-independent (no account known yet)
  // PRE-AUTH throttle (Finding-5): applied in T12 onUpgrade BEFORE token verification, keyed on the
  // salted client-IP hash (§4.1 remoteAddrHash) — the ONLY identifier available before a principal exists.
  // Blunts subdomain enumeration, capability-token-verifier brute-force, and WebAuthn/TOTP guessing DoS.
  export function checkPreAuthRate(remoteAddrHash: string, store: TokenBucketStore, now: number): Promise<boolean>
  // PER-TENANT quotas (keyed on the authenticated accountId, INV3):
  export function checkConnectRate(accountId: string, policy: RateLimitPolicy, store: TokenBucketStore, now: number): Promise<boolean>
  export function checkConcurrentSessions(accountId: string, active: number, policy: RateLimitPolicy): boolean
  export function checkEnrollRate(accountId: string, policy: RateLimitPolicy, store: TokenBucketStore, now: number): Promise<boolean>
  ```
- **TDD / test cases**: **pre-auth (Finding-5)** — a single `remoteAddrHash` hammering the upgrade endpoint
  across **many distinct subdomains / `host_id`s with invalid or forged tokens** is throttled by
  `checkPreAuthRate` after `PRE_AUTH_POLICY.preAuthPerMinPerIp`, **independent of any account** (there is none
  yet), and the throttle refills after its window; connects beyond `connectPerMin` → `false` (429 at the
  caller); at limit → allowed, over → denied, refills after the window; concurrent sessions over
  `maxConcurrentSessions` → denied; enroll spam over `enrollPerHour` → denied; per-account limits are
  **per `accountId`** — account A hitting its limit does not affect account B; **cross-key isolation** — a
  pre-auth IP throttle and an account quota use disjoint key namespaces (no bleed).
- **Security**: two layers. The **pre-auth IP throttle** stops enumeration/brute-force/DoS *before* a
  principal is established (Finding-5) — without it, an unauthenticated attacker could hammer the token
  verifier and WebAuthn/TOTP ceremonies unthrottled. The **per-tenant quotas** blunt authenticated
  DoS/enrollment abuse (EXPLORE §4e HIGH), keyed strictly on the authenticated `accountId` (INV3), never a
  client-supplied identifier. Pre-auth keys are the salted `remoteAddrHash` only (no raw IP at rest, INV5/INV9).

---

## Wave C

### T12 · Enforcement entry points (`onUpgrade` / `onReattach`) · **v0.9 core + v0.10 augmentation**
- **Owns**: `relay-auth/src/enforce/onUpgrade.ts`, `relay-auth/src/enforce/onReattach.ts`,
  `relay-auth/test/enforce.test.ts`
- **Depends**: **v0.9 core** → T2, T3, T10, T11 (this matches the Wave-C DAG; reconciled per Finding-8).
  **v0.10 augmentation** → T4 (audit emission) + T8 (step-up gate). The v0.9 build ships and passes with
  the audit/step-up hooks as **no-op-safe injected deps** (an `AuditSink` that appends and a step-up policy
  that returns "not required" when no policy is configured); v0.10 supplies the real audit sink and per-host
  step-up policy. No v0.9 task blocks on a v0.10 task. **Consumed by**: **P1** (calls `onUpgrade` on the WS
  handshake, INV15), **P3** (calls on connect/reattach). These are the *only* two functions the byte plane invokes.
  **Sole owner of the WS-upgrade authorization DECISION (INDEX §6a).** The full decision — Origin/CSWSH retained
  + capability verify + **DPoP proof-of-possession** + single-use `jti` burn + pre-auth throttle + step-up gate +
  per-tenant rate + deny-by-default cross-tenant gate + audit — lives **only** in P5 `onUpgrade`/`onReattach`.
  **P1 T8 `authorizeUpgrade` is a THIN ADAPTER that DELEGATES here** (§6a): it parses the upgrade (Origin,
  subprotocol token via §4.3 FIX 5, subdomain), builds the `UpgradeContext` — **including `dpop: DpopContext`
  and `activeSessionCount`** — calls P5, and maps the returned `AuthzOutcome` to a §4.1 `MuxOpen` or a 401/403
  close. P1 holds **no** independent authz logic; P5 is the injected authorizer.
- **Contract**:
  ```ts
  export interface UpgradeContext {
    readonly capabilityRaw: string; readonly originHeader: string; readonly expectedAud: string
    readonly requestedHostId: string; readonly requiredRight: CapabilityRight; readonly remoteAddrHash: string
    readonly activeSessionCount: number
    readonly dpop: DpopContext              // proof-of-possession material (T2/T3, Finding-4)
    readonly principal: AuthenticatedPrincipal | null // session principal for the step-up freshness check (T8)
  }
  export interface EnforceDeps {
    readonly hosts: HostRegistryPort; readonly sessions: SessionRegistryPort
    readonly revocation: RevocationStore; readonly buckets: TokenBucketStore; readonly audit: AuditSink
    readonly stepUpPolicyFor: (host: HostRecord) => StepUpPolicy // v0.10; v0.9 injects a "never required" policy
  }
  // Retain Origin/CSWSH check AND require capability token (INV15) + deny-by-default authz (INV6):
  export function onUpgrade(ctx: UpgradeContext, deps: EnforceDeps, allowedOrigins: readonly string[], now: number): Promise<AuthzOutcome>
  export function onReattach(ctx: UpgradeContext & { sessionId: string }, deps: EnforceDeps, allowedOrigins: readonly string[], now: number): Promise<AuthzOutcome>
  ```
  **Sequence (deny-by-default, audited), in order:**
  1. **Origin/CSWSH**: `originHeader ∈ allowedOrigins` else **401** (retain base-app check, INV15).
  2. **Pre-auth throttle (Finding-5)**: `checkPreAuthRate(ctx.remoteAddrHash, …)` **before any token work**
     — keyed on the IP hash, no principal yet; over limit → **429-equiv deny**. Blunts enumeration/brute-force.
  3. **Per-tenant rate**: after a principal is known, `checkConnectRate(accountId, …)` (T11) → deny if over.
  4. **Authz (deny-by-default)**: `authorizeConnect`/`authorizeReattach(req, ctx.dpop, …)` (T3) — verifies the
     capability token incl. **DPoP proof-of-possession**, then the INV1 cross-tenant gate. Fail → 401/403.
  5. **Step-up gate (Finding-3, v0.10 augmentation)**: on authz **allow**, before returning `ok:true`, call
     `needsStepUp(ctx.principal, deps.stepUpPolicyFor(host), now)` (T8). If true → **403** + a
     `AuditEvent(action:'stepup', outcome:'deny')`. This is the exact guarantee T8 claims:
     fresh-login-but-stale-step-up is refused at `onUpgrade`, before a PTY is reachable.
  6. **Single-use consume (Finding-4)**: on a fully-allowed connect, `revocation.consumeOnce(token.jti, exp)` —
     if it returns false (already used) → **403** (replayed token). (Reattach reuses the same session-scoped
     token semantics; connect-scoped tokens are burned here.)
  7. **Audit + return**: on **deny** emit exactly one `deny` `AuditEvent` (T4; `cross-tenant-attempt` action
     when `host.accountId !== accountIdFromToken(token)`) and return the 401/403; on **allow** emit an
     `attach`/`reattach` allow event and return `ok:true`. No allow path writes payload (INV10).

  **Session-open boundary (resolves former open-Q #2, Finding-3):** the step-up gate and the whole authz
  decision run at the **WS upgrade** (`onUpgrade`), i.e. **before** the relay dials `ws://127.0.0.1:3000` and
  before any `attach` frame can reach a PTY — the base app's `attach`-first-frame is *downstream* of a stream
  that P1 only opens after `onUpgrade` returns `ok:true`. So "opening a session" is gated at the upgrade, not
  left to the attach frame; a stale-step-up principal never gets a live stream.
- **TDD / test cases**:
  - **v0.9 core**: foreign Origin → **401** even with a valid token (base-app behavior retained, INV15);
    valid Origin, no token → 401; failing DPoP proof → 401 (Finding-4); valid Origin + token for host B while
    authed as A → **403** (INV1); pre-auth: one IP hammering many subdomains with bad tokens → throttled
    **before** token verification, independent of account (Finding-5); per-account rate-limited → 429-equiv
    deny; reattach to a foreign session → 403; replayed single-use token (2nd upgrade) → 403; happy path →
    `ok:true`.
  - **v0.10 augmentation**: valid Origin + valid capability token + authz allow, but principal with
    `stepUpAt=null` (fresh login only) and a host policy requiring step-up → **403** at `onUpgrade`
    (Finding-3, matches T8's claimed guarantee); after `recordStepUp`, same request → `ok:true`; every deny
    path writes exactly one `AuditEvent` (incl. a `cross-tenant-attempt` on A→B and a `stepup` deny on stale
    step-up); no allow path writes payload.
- **Security**: this is where Origin + pre-auth throttle + capability token (PoP + single-use) +
  deny-by-default authz + step-up + per-tenant rate + audit compose. Origin check is **retained AND
  augmented** (INV15) — never weakened (INDEX §2 rule 6). The step-up check is on the enforcement path, not
  prose (Finding-3); the pre-auth throttle precedes token work (Finding-5).

### T13 · Permanent CI cross-tenant tripwire (INV1) · **v0.9**
- **Owns**: `relay-auth/test/tripwire/cross-tenant.test.ts`, `.github/workflows/relay-tripwire.yml`
- **Depends**: T12 (and, for the full-stack variant, P1's data plane + P3's registry — the unit variant uses
  in-memory port fakes so the tripwire runs even before P1/P3 integrate).
- **Contract**: a self-contained test that seeds account A (host A) and account B (host B), authenticates as A,
  and asserts **every** A→B path returns **403**. Two variants (reconciled per Finding-8 phasing):
  - **v0.9 unit variant (403 outcomes only — the required CI check):** uses in-memory port fakes; asserts
    the 403s. Depends only on T12 core (T2/T3/T10/T11):
    - `onUpgrade` with A's token but `requestedHostId = hostB` → **403**.
    - `onReattach` with A's token but a `sessionId` owned by B → **403**.
    - fuzz: 1000 random UUIDv4 `host_id`s not owned by A → all **403** (guessing/reuse defense, INV1).
    - `aud` confusion: A's token replayed at `bob.term.<domain>` → **403**.
  - **v0.10 full-stack variant (audit + alert):** once T4 (audit) and T14 (alert) ship, additionally assert a
    `cross-tenant-attempt` audit event **and** an alert fired for each A→B attempt. These assertions are
    **deferred to v0.10** so the v0.9 unit tripwire does not depend on v0.10 tasks (Finding-8).
- **CI**: `.github/workflows/relay-tripwire.yml` runs this on **every push/PR** and is a **required check** —
  a green build is impossible if cross-tenant isolation regresses. This is the permanent tripwire from
  EXPLORE §4b / INV1; it must never be deleted or skipped (a comment in the file states this).
- **Security**: this is the load-bearing regression guard for the single non-negotiable invariant (INV1).
  If this test ever goes red, **block merge** (CRITICAL, `code-review.md`).

### T14 · Audit-alert wiring (cross-tenant crossing) · **v0.10**
- **Owns**: `relay-auth/src/audit/alert.ts`, `relay-auth/test/alert.test.ts`
- **Depends**: T4, T12. **Consumed by**: an ops alerting channel (P3/infra).
- **Contract**:
  ```ts
  export interface Alerter { fire(kind: 'cross-tenant' | 'revocation' | 'auth-anomaly', e: AuditEvent): Promise<void> }
  export function onAuditEvent(e: AuditEvent, alerter: Alerter): Promise<void> // fires on action==='cross-tenant-attempt'
  ```
- **TDD / test cases**: a `cross-tenant-attempt` event fires a `cross-tenant` alert; an ordinary `attach`
  allow does **not** alert; alert payload carries metadata only (no terminal bytes, INV10).
- **Security**: turns the INV1 tripwire into a **runtime** detector — an A→B attempt in production raises an
  operator alert immediately (EXPLORE §4e HIGH, INV10).

---

## 3. SECURITY (per-plan summary)

| Control | Task(s) | Invariant |
|---|---|---|
| `account_id` only ever from the authenticated principal; no client field trusted | T1, T3, T12 | **INV3** |
| Deny-by-default authz on connect **and** reattach (no allow-if-unspecified branch) | T3, T12 | **INV6** |
| Cross-tenant isolation A↛B + fuzz + `aud`-confusion guard + **permanent CI tripwire** | T3, T12, **T13** | **INV1** |
| Capability token required on WS upgrade; Origin/CSWSH retained AND augmented; least-privilege rights | T2, T12 | **INV15** |
| Capability tokens: **short-TTL 30–60 s**, **single-use** (`consumeOnce`), **DPoP proof-of-possession** (`cnf.jkt`) so a leaked bearer copy can't upgrade; token memory-only in P6 | T2, T12 | **INV15** (leak blast-radius) |
| §4.4 `deviceAuthProof` minted/verified only by P5 from `AuthenticatedPrincipal` (P4 consumes, never re-derives account binding) | T2 (→P4) | **INV3** |
| **Pre-auth** IP/`remoteAddrHash`-keyed throttle before token verification (enumeration/brute-force/DoS) **plus** per-tenant quotas | T11, T12 | (DoS/abuse, §4e HIGH) |
| TOTP failed-attempt **lockout/backoff** (per-account, via T11 bucket) closes the 10^6 brute-force hole | T6, T11 | (second-factor integrity) |
| Live-tunnel **push teardown**: `revoke()` → `RevocationBus` → P1 injects §4.1 RST/GOAWAY within `REVOCATION_PUSH_BUDGET_MS`; not just next-connect refusal | T10 (→P1) | **INV12** |
| Per-host asymmetric identity; DB stores only public keys; private key never leaves host | T9 (with P2/P3) | **INV4** |
| mTLS SPIFFE short-TTL auto-rotating certs; CA never signs a non-enrolled pubkey | T9 (with P2/P3) | **INV14** |
| Fast global/per-host revocation kills live tunnels in seconds | T10 | **INV12** |
| Immutable, append-only, ZERO-payload audit + cross-tenant alert | T4, T12, T14 | **INV10** |
| Passkey primary (phishing-resistant), TOTP fallback, **never SMS**, OIDC PKCE, step-up before session open | T5–T8 | (auth model, LOCKED §0.5) |
| Per-tenant rate-limits/quotas keyed on authenticated `accountId` | T11 | (DoS/abuse, §4e HIGH) |
| Secrets in env/secret-manager, validated at startup, never logged | all (config loaders) | **INV9** |

**Residual risk — capability-token exfiltration (Finding-4, documented not hidden):** the payload behind a
successful upgrade is a full, potentially root-capable shell. P5 shrinks the blast radius of a leaked token to
near-zero — **short 30–60 s TTL**, **single-use** (`consumeOnce` burns the `jti` on first upgrade), and a
**DPoP `cnf.jkt` proof-of-possession** so a bearer copy without the client's ephemeral private key cannot
upgrade. The one residual vector is a browser **XSS** that both reads the in-memory token *and* uses the
in-memory ephemeral key within the 30–60 s window; P6 MUST therefore hold both **memory-only (never
`localStorage`/`sessionStorage`)** and P4's E2E (v0.10) further removes the plaintext-shell reward. This
residual is accepted and tracked, not silently ignored.

**Deliberate non-goals (owned elsewhere / structural):** P5 never touches terminal bytes → INV2/INV11 hold
**by construction** (the package has no `ws`, no xterm, no ANSI parser). Immutable storage records + atomic
snapshot swap (INV8) live in P3; P5 consumes read-only ports and returns immutable copies (e.g. `recordStepUp`).

**Phasing recap (reconciled per Finding-8):** **v0.8** = T1 only (contracts frozen, no runtime — per INDEX
§1). **v0.9** = T2 (capability tokens incl. PoP + single-use + `deviceAuthProof`), T3, T9, T10 (revoke +
`RevocationBus` publish; **live push teardown** is available here, only the tighter latency SLO is tuned in
v0.10), T11 (pre-auth IP throttle **+** per-tenant quotas), **T12 core** (Origin + capability verify +
deny-by-default authz + pre-auth/rate limit), **T13 v0.9 unit variant** (A→B = 403 only) — per INDEX §1
v0.9. **v0.10** = T4, T5, T6 (TOTP + lockout), T7, T8 (step-up), T14, **T12 augmentation** (audit emit +
step-up gate), **T13 v0.10 full-stack variant** (audit+alert asserts) (Passkey/WebAuthn primary + step-up +
fast-revocation SLO + immutable zero-payload audit — per INDEX §1 v0.10). **No v0.9 task depends on a v0.10
task** — v0.10 items are injected-dep augmentations of already-shipping v0.9 code.

---

## 4. VERIFICATION

```bash
# Unit + negative/security tests (TDD, tests FIRST), per task:
npx vitest run capability      # T2: sub:=accountId, expiry/aud/tamper/wildcard/rights-escalation, TTL≤60s clamp, single-use, DPoP PoP
npx vitest run device-proof    # T2: §4.4 deviceAuthProof bound to {clientEphPub,clientNonce}; replay-into-other-handshake/unbound/tampered → false (§6b/Finding-7)
npx vitest run authz           # T3: INV1 cross-tenant 403 via accountIdFromToken, INV6 reattach 403, INV3 no client account_id
npx vitest run audit           # T4: append-only + zero-payload guard (INV10)
npx vitest run webauthn totp oidc stepup   # T5–T8: origin/challenge/replay/TOTP lockout/step-up (stepUpAt) freshness
npx vitest run mtls            # T9: SPIFFE mismatch/expiry/non-enrolled → refuse (INV4/INV14)
npx vitest run revocation      # T10: revoke → token invalid + LIVE-TUNNEL push teardown (RevocationBus) within budget + reconnect refused (INV12)
npx vitest run quota           # T11: pre-auth IP throttle (no account) + per-tenant limits, no cross-tenant bleed
npx vitest run enforce         # T12: Origin+preAuth+token(PoP,single-use)+authz+step-up+rate+audit compose; foreign Origin 401 (INV15)

# THE permanent tripwire — must be a REQUIRED CI check (INV1):
npx vitest run tripwire/cross-tenant   # A→B = 403 on connect AND reattach + fuzz + aud-confusion
#   (also runs in .github/workflows/relay-tripwire.yml on every push/PR — never skip/delete)

# Whole-package gates:
npx vitest run --coverage      # 80%+ across relay-auth (testing.md)
npm run typecheck              # relay-auth strict tsc; relay-contracts (§4) imported read-only, unchanged
npm run lint                   # asserts no console.log; no import of ws/xterm/ANSI parser (INV2/INV11)

# Static tripwires (grep-level, run in CI):
#  - no schema/decision reads a client-supplied account_id/tenant_id as authoritative (INV3)
#  - no at-rest store holds a private key or raw token (INV4/INV5); DB has agent_pubkey only
#  - no secret material in any log line (INV9)
```

**Base-app regression obligation:** P5 adds **zero** code to `src/` / `public/` — the base app's 212 tests
stay green untouched; the only relay-side config touch (`ALLOWED_ORIGINS` += subdomain) is P3's install step,
not P5's. P5's Origin check in T12 *mirrors* the base-app CSWSH check at the relay edge (INV15) and must never
weaken it.

**Manual / integration verification (post-P1/P3 integration, v0.9+):** enroll two accounts on a single relay
node; from a browser authenticated as A, attempt to open `bob.term.<domain>` and to reattach B's `sessionId`
via A's token → both **403** with a `cross-tenant-attempt` alert in the audit log; revoke A's host → its live
tunnel drops within seconds and reconnect is refused; let A's cert reach ~50% TTL → seamless rotation, no
tunnel drop.

---

## 5. Open questions (for the orchestrator — subagents STOP + return `[!] BLOCKED`, never guess)

**Resolved in this revision (were blocking; now closed in-plan — no longer subagent blockers):**

- ~~**(#2) Session-open step-up boundary.**~~ **RESOLVED**: the authz decision and step-up gate run at the
  **WS upgrade** (`onUpgrade`, T12), *before* P1 dials `ws://127.0.0.1:3000` and before any `attach` frame can
  reach a PTY. See T12 "Session-open boundary". (Fixes Finding-3's boundary dependency.)
- ~~**(#3) `CapabilityToken.sub` → `accountId` mapping.**~~ **RESOLVED**: the frozen §4.3 `sub` field carries
  the **`accountId`** by P5 convention (`CAP_TOKEN_SUB_IS_ACCOUNT_ID`, T1); T2 issuance sets `sub :=
  principal.accountId`; T3's cross-tenant gate reads it via the sole resolver `accountIdFromToken(token)`. The
  former undefined `token.accountBoundFromSub` is deleted. This does **not** redefine §4.3 (it fixes the
  *value* P5 writes into an existing field). (Fixes Finding-1.)
- ~~**(#4) Revocation propagation transport.**~~ **RESOLVED**: `revoke()` publishes a `KillSignal` on the
  injected `RevocationBus` (Redis pub/sub `relay:revocations`, §4.2); P1 injects a §4.1 `CLOSE`+RST / `GOAWAY`
  (existing frozen frame types) to tear down the **live** stream within `REVOCATION_PUSH_BUDGET_MS`. P5 stays
  socket-free. (Fixes Finding-2.)

**Still open (need the orchestrator / cross-plan confirmation — do NOT guess):**

1. **Capability-token format**: §4.3 says "Ed25519-signed **PASETO/JWS**" — pick one concretely
   (recommend **PASETO v4.public**: no alg-confusion, no `alg:none` foot-gun). Needs a one-line INDEX §4.3
   confirmation before T2 codes, since the token bytes cross into P1/P6. (The `cnf.jkt` PoP claim + DPoP proof
   added in T2 must be representable in whichever format is chosen — confirm alongside.)
2. **OIDC team→account mapping** (T7): how does a verified `(iss, sub)` map to an existing `accountId` (JIT
   provisioning vs pre-linked)? This is a P3 account-model question surfaced by P5.
