Compare commits

...

43 Commits

Author SHA1 Message Date
Yaojia Wang
1dbed54581 docs(progress): log the zero-touch enrollment + control-panel session
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Host track live+verified (h7fd8, auto-renew), phone track server-verified + iOS/
Android code-complete, control-panel deployed (panel.terminal.yaojia.wang),
Android app run on emulator. ~14 real-deploy bugs fixed. Branch merged to develop.
2026-07-23 06:28:59 +02:00
Yaojia Wang
675de771c7 feat(control-panel): web admin UI for the zero-touch tunnel
Loopback Fastify auth-broker + esbuild SPA. Operator password login (constant-time,
signed HttpOnly session cookie, per-forwarded-IP rate-limit) → session-gated proxy
that mints a fresh 60s manage capability token per call to the control-plane admin
API: list hosts, mint pairing codes (with QR + pair command), revoke hosts. Security
headers + CSP, CP_URL pinned loopback (anti-SSRF), hostId dot-segment guard. 55 tests
pass; security-reviewed. Deployed behind nginx panel.terminal.yaojia.wang.
2026-07-19 19:47:51 +02:00
Yaojia Wang
7c1d43376d Merge feat/zero-touch-enrollment: zero-touch tunnel enrollment (host + phone)
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Host track LIVE + verified end-to-end (pair --install → CP native enroll →
launchd base-app+agent → frpc tunnel → h7fd8.terminal.yaojia.wang, auto-renew
proven). Phone track: CP login + device-enroll verified via curl; iOS wired,
Android wired. New CP production native-CA wiring, /auth/login + device:enroll
bearer, nginx enroll+renew vhost. ~12 real-deploy bugs fixed. 281 CP + 281 agent
tests + android assembleDebug/kover green.
2026-07-19 09:42:47 +02:00
Yaojia Wang
0b35dc043f feat(android): wire zero-touch device enrollment + fix renew/cache (B-track)
- wire the enroll flow into the app UI (EnrollmentScreen + ViewModel + Hilt DI +
  host-menu "自动获取证书"), mirroring iOS — Android previously only had manual
  .p12 import; the enroll library was built but unreachable.
- renew is now mTLS-only ({csr}-only body, no Authorization header) matching the
  /device/:id/renew contract (the enroll bearer is minutes-lived → silent
  rotation would have thrown weeks later).
- enroll refreshes the identity-repository cache so a mid-session-enrolled cert
  is presented on the next mTLS handshake without a process restart.
gradle :app:assembleDebug + api-client/client-tls-android unit tests + koverVerify
green. On-device QA (keygen/enroll/present) is the operator's step.
2026-07-19 08:31:29 +02:00
Yaojia Wang
c98f5e6a1f fix(agent): give launchd units a log sink (StandardOut/ErrorPath)
launchd has no default log destination (unlike systemd's journald), so a unit
that fails to start (bare node, missing env) was silent — which hid the EX_CONFIG
+ loadConfig failures during this deploy. Route both units' stdout+stderr to
<stateDir>/{base-app,agent}.log. 281 tests pass.
2026-07-19 07:57:22 +02:00
Yaojia Wang
1e398c7561 fix(agent): make native cert auto-renew actually work end-to-end
Two real-deploy renew bugs (found running the live /renew):
- the /renew mTLS request pinned the enroll caChain as the server CA → TLS
  'unable to get local issuer certificate' (the LE-fronted CP is publicly
  trusted). Verify the server against SYSTEM roots (drop ca), keep the client
  cert + rejectUnauthorized:true. (TlsClientOptions.ca now optional.)
- renewCert parsed {cert, caChain:string}, but the CP returns cert=base64(DER)
  + caChain=base64(DER)[]; normalize to PEM (shared certs/pem.ts, reused by
  native enroll). Verified live: cert rotated 13:41→next-day, frpc restarted,
  tunnel stayed up. 281 tests pass.
2026-07-19 07:56:01 +02:00
Yaojia Wang
55d177e9ee fix(control-plane): accept escaped-PEM client cert on /renew
nginx has no njs deployed, so the mTLS terminator forwards the verified client
cert via $ssl_client_escaped_cert (URL-encoded PEM). headerPresentedCert now
normalizes base64-DER, PEM, and escaped-PEM alike to raw DER. 281 tests pass.
2026-07-19 07:00:48 +02:00
Yaojia Wang
9f7f5c0c54 fix(agent): native enroll + launchd install real-deploy shakedown
Fixes found deploying `pair --install` against the live control-plane:
- native enroll tolerates hostContentSecret:null and converts the base64-DER
  cert + caChain[] the CP returns into PEM for frpc/keystore
- launchd/systemd units use an absolute node (process.execPath) + a PATH with
  /usr/local/bin (bare `node` died with EX_CONFIG 78)
- the agent unit now carries ENROLL_URL/RELAY_URL/STATE_DIR/LOCAL_TARGET_URL
  (`run` loadConfig()-validates them; missing → supervisor exited 1)
281 tests pass. Follow-up: add unit tests for the native-enroll tolerance +
agent-unit env (only install node-path is covered so far).
2026-07-18 15:46:20 +02:00
Yaojia Wang
6e04eb0661 docs: re-assess R1 as a real CP native-CA wiring task (done in A2-prep) 2026-07-18 15:04:04 +02:00
Yaojia Wang
af630143de feat(control-plane): production native-CA wiring for on-disk P-256 CAs (A2-prep)
Wire the prod boot to issue frp-client + device leaves from the existing on-disk
P-256 CAs: NATIVE_* env vars, a file-backed KmsResolver (loads the PEM key,
validates prime256v1, never logs key material), buildFileBackedNativeCas threaded
into buildControlPlane via a nativeCas override. Fail-closed on partial NATIVE_*.
281 tests pass.
2026-07-18 15:04:04 +02:00
Yaojia Wang
10688b0dd1 feat(desktop): ride an existing base app on :3000 instead of duplicating (S1)
When the always-on tunnel daemon already serves :3000, the app reuses it
(close() is a no-op) rather than spawning a second server on another port, so
the GUI and the tunnel share one base app.
2026-07-18 13:32:05 +02:00
Yaojia Wang
5e427dcf98 feat(android): device enrollment library + rotation (B4)
Hardware-backed (StrongBox/TEE) key + PKCS#10 CSR + /device/enroll client in
:api-client, presented via the existing X509KeyManager; renew body {csr}-only;
DeviceKeyProvider seam makes the orchestration JVM-testable. api-client tests +
koverVerify 80% gate pass.
2026-07-18 13:32:05 +02:00
Yaojia Wang
07bcbf0c08 feat(ios): device enrollment flow + silent cert rotation (B3)
Wire the SecureEnclave enroll library into a real flow (login->bearer->CSR->
/device/enroll->keychain identity), presented on the existing mTLS path; add a
rotation scheduler. Atomic keychain replace (add-before-delete); renew body is
{csr}-only; renewal-failing surfaced in the UI. ClientTLS 48 tests pass.
2026-07-18 13:32:05 +02:00
Yaojia Wang
9a5909f672 feat(agent): auto-renew the host cert in the native run-loop (A5)
Wire createCertRotator/renewCert into superviseNative so the frp-client cert
renews silently at ~2/3 TTL over mTLS /renew and frpc restarts onto the fresh
leaf; failures retry with backoff, never crash the supervisor. mTLS transport
has a 15s timeout + 64KB response cap. 281 tests pass.
2026-07-18 13:32:05 +02:00
Yaojia Wang
fff011bb7f feat(control-plane): POST /auth/login mints device:enroll bearer (B1)
Unblocks the phone-enrollment track: an operator-password login mints a
short-lived device:enroll capability token that POST /device/enroll requires.
Constant-time (SHA-256 fixed-length) compare, per-client rate-limit, fail-closed
when unset. 260 tests pass.
2026-07-18 13:32:05 +02:00
Yaojia Wang
232ef22535 docs: zero-touch enrollment rollout & integration plan
Execution plan grounded in current VPS reality: pre-flight checks, Track A
(host auto-enroll via the agent) and Track B (phone auto-enroll), risks, and
the honest one-bootstrap-tap constraint. R1 (CA material) verified present on VPS.
2026-07-18 13:32:05 +02:00
Yaojia Wang
ca9eaa8f1f docs: mark all of Wave 5 done (fan-out, access token, android parity)
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
2026-07-13 05:59:29 +02:00
Yaojia Wang
bc31de85dd feat(android): Projects/Diff/Worktree/git-write parity with web+iOS (W5)
Brings the native Android client to parity with the web/iOS Projects + git surface,
consuming the existing server endpoints — ZERO server change. (The "SDK-gated modules"
premise was stale; they were already online.)

- :api-client (pure Kotlin/JVM): PrStatus/GitLog/GitWrite models (tolerant decoders,
  safe-error), ProjectInfo + ahead/behind/lastCommitMs; 8 new Endpoints builders
  (projectPr/projectLog read-only; createWorktree/removeWorktree/prune/gitStage/
  gitCommit/gitPush guarded w/ Origin) + ApiClient methods (PR/log degrade in body;
  writes 200→Ok / 429→RateLimited / 4xx-5xx→Rejected(safe msg)).
- :app presenters (JVM-tested): DiffViewModel base-compare + git stage/commit/push;
  new WorktreeViewModel (create/remove/prune + client branch validation + isMain block);
  ProjectDetailViewModel failure-isolated PR-chip + recent-commits.
- Compose screens wired: ProjectDetail (PR chip tappable only for https, recent commits,
  worktree create/remove-with-force/prune, view-diff), Diff (base input, per-file
  stage/unstage, commit/push bar), Projects (ahead/behind sync chip). Nav closes the
  pre-existing "no inbound link to the diff screen" gap.

Verified independently: `./gradlew :app:assembleDebug test :api-client:koverVerify`
BUILD SUCCESSFUL; 348 unit tests pass (api-client 102 / app 246); coverage gate held;
git status android-only. Compose rendering/interaction deferred to on-device QA
(android/DEVICE_QA_CHECKLIST.md), as prior Android waves did.
2026-07-13 05:58:28 +02:00
Yaojia Wang
469037cb94 feat(auth): optional WEBTERM_TOKEN access-token gate (W5)
An OPTIONAL shared token so the app can be used off-LAN (via the relay/tunnel) more
safely than "anyone who reaches the port gets a shell". Sits IN FRONT OF the existing
Origin/CSRF model — never replacing it — and is fully inert when unset.

- src/http/auth.ts (new, pure): constantTimeEqual hashes both inputs to sha256 (32
  bytes) then crypto.timingSafeEqual — no `===`, no length side-channel, never throws.
  parseCookieHeader / cookieIsAuthed / buildSetCookie / isAuthEnabled.
- WEBTERM_TOKEN in config: 16–512 URL/cookie-safe chars or the server refuses to start.
- GET /?token=<t> or POST /auth (rate-limited 10/min) validates → sets
  HttpOnly; SameSite=Strict; Secure-when-https cookie; public/login.html (no <script>).
- When enabled: the WS handshake (AFTER the Origin check, before handleUpgrade) + a
  central authGate over all remote HTTP require the cookie. Open: /login, /auth.
  Loopback bypass scoped to /hook* ONLY (tighter than the plan — gates the local
  browser too).
- Unset ⇒ isAuthEnabled false ⇒ pure passthrough (LAN zero-config unchanged).

Honest tradeoff (in code + CLAUDE.md + login page): a bar-raiser, NOT a TLS/Tailscale
substitute — on bare ws:// the token is cleartext + replayable; only hardens the
TLS-terminated relay path. Verified: typecheck + build:web clean, 2118 pass at
--test-timeout=30000 (disabled-mode regression proven; only the known tmux flake red);
auth.ts 100% line coverage.
2026-07-13 05:25:07 +02:00
Yaojia Wang
9683a16f4f feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)
Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.

- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
  groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
  "grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
  wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
  + caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
  ≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
  via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
  + extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
  One-click merge deferred (winner session stays open for a manual merge).

Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
2026-07-13 05:09:19 +02:00
Yaojia Wang
c81821b890 docs(plans): Wave 5 implementation plans (fan-out board, access token, android parity) 2026-07-13 04:37:24 +02:00
Yaojia Wang
6541246fc9 docs(roadmap): mark Wave 1-4 done (8 shipped), leave Wave 5 open 2026-07-13 04:25:16 +02:00
Yaojia Wang
a7eba2d43b docs(progress): log the Wave 1-4 roadmap batch (8 features, multi-agent plan+build+verify) 2026-07-12 22:10:57 +02:00
Yaojia Wang
19f241d7a3 feat(git): stage / commit / push from the diff viewer (W4)
The walk-away endgame — review a diff on your phone, then land it without typing
git into a mobile terminal. MVP bounded to per-file stage/unstage, commit, and
push the current branch; discard/checkout/reset deliberately deferred (nothing here
mutates working-tree file contents).

- src/http/git-ops.ts (new): stageFiles/commit/push (execFile, no shell, never
  throws). Path containment: every files[] entry realpath-contained under the repo,
  argv after `--`, capped at diffMaxFiles. Commit message: empty/over-5000 → 400,
  single -m argv. Push: current branch only — has-upstream → plain `git push`;
  no-upstream + one remote → `git push -u <remote> <branch>`; 0 remotes → 400,
  ≥2 → 409, detached HEAD → 400. Remote AND branch read from the repo, never the
  client. NEVER --force / +refspec. GIT_TERMINAL_PROMPT=0 + ssh BatchMode fail auth
  fast (401). Errors classified to fixed safe strings (raw stderr never surfaced).
- POST /projects/git/{stage,commit,push} — all behind requireAllowedOrigin +
  GIT_OPS_ENABLED kill-switch + per-IP rate limits (stage/commit 30/min, push 6/min)
  + isValidGitDir. public/diff.ts: per-file Stage/Unstage + a commit/push bar
  (all text via textContent; re-loads the diff on success).

Verified: typecheck + build:web clean, git-ops tests 213 pass (unit covers escape
rejection / empty+overlong commit / push argv upstream-vs-no-upstream / classified
errors), full suite green at --test-timeout=30000.
2026-07-12 22:09:43 +02:00
Yaojia Wang
552f35c690 feat(projects): worktree remove + prune from any device (W4)
Closes the create-only loop — delete losing worktrees and prune stale ones without
a terminal. Destructive, so guarded hard:

- src/http/worktrees.ts: removeWorktree + pruneWorktrees (execFile, no shell, never
  rm -rf; git worktree remove [--force] / git worktree prune). Safeguards:
  (1) target realpath must match an entry git itself reports in `git worktree list`
  for THIS repo → 404 otherwise (arbitrary FS paths never match, so git is never
  invoked against them); (2) reject the MAIN worktree → 400; (3) realpath
  containment on request + each list entry, operating on git's canonical path (+ --);
  (4) dirty tree without force → 409 "force required"; (5) locked → 409; (6) errors
  classified to fixed safe strings (raw stderr/paths never leaked).
- DELETE /projects/worktree + POST /projects/worktree/prune — both behind
  requireAllowedOrigin + worktreeEnabled + audit-logged, mirroring the create route.
- public/projects.ts: ✕ remove button per linked-worktree row (hidden for main/locked)
  + a prune button; force needs an explicit second confirm (no single-click data loss).

Verified: typecheck + build:web clean, worktree tests 108 pass (unit covers
reject-non-registered / reject-main / reject-escape / refuse-dirty-without-force /
clean-remove / force-remove / prune), full suite green at --test-timeout=30000.
2026-07-12 21:44:23 +02:00
Yaojia Wang
1dd12b035a feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)
Four small, high-delight features that turn passive capture into glanceable signals.

- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
  into the existing concurrent per-repo metadata pass (git rev-list --count
  --left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
  (Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
  on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
  frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
  /config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
  totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
  GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.

All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
2026-07-12 21:27:20 +02:00
Yaojia Wang
7551f8a4b2 feat(projects): PR + CI/checks status chip via gh (W3)
Per-project chip: PR state · N checks passing · mergeable — glance from the phone,
re-engage only when it's red, instead of dropping into a terminal for `gh pr checks`.

- src/http/gh.ts (new): single `gh pr view --json number,state,title,url,isDraft,
  mergeable,headRefName,baseRefName,statusCheckRollup` (execFile, no shell, cwd =
  isValidGitDir repo, timeout + maxBuffer). summarizeChecks rolls the mixed
  CheckRun/StatusContext rollup into {total,passing,failing,pending}. Never throws —
  degrades to not-installed (ENOENT) / unauthenticated / no-pr / error / disabled.
  Cache keyed by repoPath+branch (reuses projectScanTtlMs) with in-flight dedupe.
- src/types.ts: additive PrStatus/PrAvailability/PrCheckSummary; config GH_ENABLED
  (default on) + GH_TIMEOUT_MS (8s).
- GET /projects/pr?path= (read-only, isValidGitDir); public/gh-chip.ts render-only
  chip mounted in the project detail header (git repos only).

Read-only, host's own authed gh (same trust as the shell). No untrusted argv (only
the validated cwd); gh stdout/token never logged; the attacker-controllable PR title
is rendered inert via textContent (SEC-H4). Verified: typecheck + build:web clean,
1816 pass (gh tests 118). The 1 red is the known real-PTY ring-buffer timeout flake.
2026-07-12 21:01:18 +02:00
Yaojia Wang
b119c31019 feat(diff): diff a whole branch vs a base (?base=<rev>) — review before landing (W3)
The git-diff viewer can now diff the current branch against a base commit-ish
(e.g. main) — review an agent's whole branch from your phone before merging, not
just uncommitted changes. Completes the long-deferred FR-B1.9.

- src/http/diff.ts: getDiff() gains an optional base. Three-layer defense so an
  attacker-supplied base never reaches a shell or acts as a git option:
  (1) isPlausibleRev() rejects leading '-', '..'/'...' ranges, metachars, control
  chars, >250 chars; (2) git rev-parse --verify --quiet --end-of-options
  <base>^{commit} — only a resolved 7-64 hex sha is accepted, else empty result;
  (3) git diff --no-color <sha>... -- (three-dot = the branch's changes since
  divergence, PR-style). execFile, no shell, timeout + maxBuffer bound.
- src/types.ts: additive optional base on DiffResult.
- src/server.ts GET /projects/diff reads ?base (400 on !isPlausibleRev); read-only.
- public/diff.ts: a "compare base" <select> (Working tree + one option per base),
  disables Working/Staged tabs in base mode. public/projects.ts derives bases from
  the worktree branches ∪ current branch.

Verified: typecheck + build:web clean, 1763 pass (diff tests 87 green). The 1 red
in the plain full run is the pre-existing real-PTY "ring buffer" flake (times out
at default 5s under sandbox load; 27/27 at --test-timeout=30000) — unrelated.
2026-07-12 20:43:32 +02:00
Yaojia Wang
3076843e9c feat(queue): server-side PTY-inject + idle-drained follow-up queue (W2)
The walk-away primitive: queue follow-up prompts and let a session advance itself
while you're gone — "run the tests" drains, and when Claude next goes idle "open a
PR" drains. Injection reuses writeInput (byte-identical to a keystroke, broadcasts
to all mirrored devices); the byte-shuttle is untouched.

- POST/GET/DELETE /live-sessions/:id/queue — POST/DELETE behind requireAllowedOrigin
  + a per-IP rate limit (QUEUE_RATE_MAX=20/min) + SESSION_ID_RE; text non-empty,
  byte-capped (QUEUE_ITEM_MAX_BYTES=4096, 16kb body), count-capped (QUEUE_MAX_ITEMS=10).
- Bounded per-session queue in manager (enqueueFollowup/drainOne/clearQueue);
  new optional queueLength on LiveSessionInfo.
- Idle drain: the Stop/SessionEnd /hook branch scheduleDrain()s a debounced timer
  (QUEUE_SETTLE_MS=1500). It drains exactly one entry only if the session still
  exists, hasn't exited, produced no output during settle, and is idle — three
  guards (debounced single timer + pop-one + settle re-check) → one entry per idle.
- New additive ServerMessage {type:'queue',length} broadcasts the count to mirrors
  (⧗N badge); FE enqueue via the quick-reply editor + TabApp.enqueueToActive.

Injected bytes go verbatim to the PTY (never built into a shell command). Verified
independently: typecheck + build:web clean, 1737 tests pass (real-PTY integration
covers idle-drain / one-per-idle / settle-guard). Foundation for templated launches,
auto-continue, and issue-intake (docs/ROADMAP.md).
2026-07-12 20:27:37 +02:00
Yaojia Wang
e062065cd3 feat(cockpit): approval preview — command/diff above Approve/Reject (W1)
Remote one-tap approval was blind (you'd tap Approve without seeing Claude wants
to run `rm -rf` or rewrite a config). The pending tool's actual command / diff now
renders above the approval bar, on every attached device, riding the same broadcast
+ late-joiner rails as the existing `gate` field.

- src/http/approval-preview.ts (new, pure, never-throws): deriveApprovalPreview —
  Bash → command; Edit/Write/MultiEdit/NotebookEdit → a synthetic DiffFile; else null.
  Every line sanitized via sanitizeField (strips control/ANSI); caps 40 lines /
  200 chars/line / 4KB (security limits, UTF-8-safe byte clamp).
- src/types.ts: additive optional `preview?: ApprovalPreview` on the status
  ServerMessage + handleHookEvent (older clients ignore it).
- src/server.ts /hook/permission: derive preview from tool_input, store on the
  PendingApproval entry, re-send to late joiners exactly like `gate`.
- manager.ts threads it; public/tabs.ts renderApprovalPreview (command → <pre>
  textContent; diff → reused innerHTML-free renderDiffFile). Unknown tools /
  plan gates fall back to today's name-only bar.

Attacker-influenced tool input → rendered via textContent/diff-renderer only,
never innerHTML. Verified independently: typecheck + build:web clean, 1692 pass.
2026-07-12 20:04:18 +02:00
Yaojia Wang
debf47d99e feat(links): clickable URLs + file:line paths in the terminal (W1)
Terminal output URLs and file paths (e.g. src/app.ts:42) are now tappable — the
walk-away device is a phone, so this removes soft-keyboard copy gymnastics.

- public/link-paths.ts (new, pure): findPathMatches — links tokens with a '/',
  a :line suffix, or a code extension (src/app.ts:42, /abs/main.rs:10, README.md)
  while rejecting example.com / v1.2.3 / 12:34 / URL tails.
- terminal-session.ts: hardened URL handler (scheme allowlist http/https/mailto +
  window.open noopener,noreferrer, blocks javascript:/data:/file:) replacing the
  addon default; a path link provider → openPath() POSTs {file,line} to
  /open-in-editor (resolves rel paths against the OSC-7 cwd; in-flight guard).
- src/http/editor.ts: additive openFileInEditor + isGotoEditor (--goto file:line
  only for goto-capable editors; execFile, no shell). openInEditor untouched.
- src/server.ts: /open-in-editor branches on body.file vs body.path (same CSRF guard).

Deviation from the "no server change" brief: an additive openFileInEditor was
required because the existing route only opens directories, not file:line (Option A
in docs/plans/w1-clickable-links.md). Backward-compatible; src/types.ts untouched.

Verified independently: typecheck + build:web clean, 1661 tests pass (link-paths
95%+ cov). Note: xterm buffer-row indexing (getLine(n-1)/range.y=n) is asserted in
jsdom but merits a real-browser smoke check.
2026-07-12 19:44:40 +02:00
Yaojia Wang
3e49e36806 docs(plans): implementation-ready plans for roadmap Wave 1-4 features (8, parallel-generated) 2026-07-12 19:24:21 +02:00
Yaojia Wang
09134e5001 docs: add ROADMAP.md — prioritized, grounded feature backlog in build-order waves 2026-07-12 19:07:13 +02:00
Yaojia Wang
8be2b06564 fix(projects): worktree-create sent repoPath but the server reads path (400)
The 'New worktree' form POSTed { repoPath, branch } to /projects/worktree, but
server.ts:706 and the documented contract (FEATURE_WALKAWAY_WORKBENCH FR-B3.1)
read body.path — so every browser worktree-create returned 400 'path and branch
are required' and git worktree add never ran. Send 'path' instead. The existing
worktree-form test had locked in the wrong key (asserted repoPath), which is why
it stayed green while the feature was broken; corrected to assert 'path'.
2026-07-12 19:03:53 +02:00
Yaojia Wang
e7bfbe951d docs(readme): document the split-grid watch board (v0.8) + refresh test counts
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Adds a Split-grid watch board section (desktop 1×2/1×3/2×2/2×3 layouts,
click-to-focus + Ctrl+` cycle, per-quadrant inline approve / maximize / read-only
monitor, drag-to-quadrant, resizable splitters, saved presets) and updates the
stale ~470/~1470 test counts to ~1600.
2026-07-12 18:29:17 +02:00
Yaojia Wang
f8f82dce21 fix(grid): stop FitAddon double-counting pane padding (clipped last row)
The focused grid cell's last terminal row was clipped by the cell border/focus
ring at large/maximized window sizes. Root cause (measured in headless Chromium):
.term-pane is box-sizing:border-box, so getComputedStyle(pane).height — which
xterm's FitAddon reads to compute rows — returns the padding-box height and
double-counts the pane's own padding, packing one extra row that overruns the
border (overflow +6px). Fix: box-sizing:content-box on the grid pane so the height
reports the content box; FitAddon drops the phantom row and the 6px bottom padding
becomes real clearance. Verified overflow +6 → −10px (16px clear) at grid-4/grid-6,
1920×1200 (DPR 1 & 2), unchanged at 1200×800. Single mode untouched.
2026-07-12 18:14:55 +02:00
Yaojia Wang
c6d819f85f fix(desktop): mirror server runtime deps + drop dangling .bin from bundle
Two fixes required to repackage the macOS app after the split-grid frontend landed:

- The bundled node_modules is copied from desktop/node_modules, which only mirrored
  4 of the 6 server runtime deps. google-auth-library (imported at server startup by
  dist/push/fcm.js) and qrcode were missing, so the embedded server crashed on launch
  ("Cannot find package 'google-auth-library'"). Added both to desktop/package.json so
  they're installed + bundled. (The old build predated the FCM code, hiding this.)
- electron-builder died with "ENOENT .bin/asar": node_modules/.bin holds dev-tool
  symlinks (asar/tsc/esbuild) into packages the filter excludes, leaving dangling
  symlinks in the bundle that electron-builder stat()s. Excluded .bin/** from the copy
  (runtime deps load by path, not via .bin).

Verified: dist:mac builds clean (signed, DMG), installed to /Applications, embedded
server serves :3000 (HTTP 200) and the split-grid frontend loads.
2026-07-12 05:52:17 +02:00
Yaojia Wang
afc22989d6 Merge feat/split-grid-view: desktop split-grid watch board (v1–v3)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Multi-session 2×2/1×2/1×3/2×3 grid for the web/Mac client so several LIVE
terminals show at once. activeIndex stays the focused pane (keybar/voice/approval
unchanged); server + WS protocol untouched; single-pane mode unchanged.

v1  single/1×2/2×2, click-to-focus, per-quadrant inline ✓/✗, desktop gate + persist
v2  1×3/2×3, Ctrl+` focus cycle, per-quadrant maximize, drag-a-tab-to-quadrant
v3  read-only monitor quadrants (no shared-PTY shrink), resizable splitters, presets

Each phase: TDD → adversarial multi-lens review + per-finding verify → fixes.
Verified: typecheck + build:web clean, 1615 tests pass, coverage 89%/82%; the
maximize geometry fix and a full 9-flow live QA verified in real headless Chrome.
2026-07-12 04:54:59 +02:00
Yaojia Wang
733c8a8318 fix(grid): v3 review fixes — split-null crash, monitor race, drag-safe gutters
Adversarial review (4 lenses → per-finding verify) of v3 confirmed 6 issues:

- HIGH: splitForLayout({"grid-4":null}) threw a TypeError — null passed the
  `!== undefined` guard, then null.cols threw. Since splitForLayout runs on every
  grid render, one corrupt localStorage entry would brick the tab UI. Now guards
  `!== null && typeof === 'object'`.
- MED: a monitor toggled before the session finished attaching (id still null)
  showed the button active while the pane stayed live and sent a resize, and did
  not self-correct. onSessionId now reconciles a pending monitor once the id lands.
- MED: renderGutters destroyed + recreated handles on every applyLayout, dropping
  the drag listeners if a re-render fired mid-drag. A draggingGutter flag now skips
  the rebuild while dragging.
- LOW: grid-presets hardcoded the 1024px breakpoint → now uses GRID_MIN_WIDTH.

Regression tests added (null-split no-throw, attach reconcile, drag survives a
concurrent re-render). typecheck + build:web clean, 1615 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:25:04 +02:00
Yaojia Wang
007e598802 feat(grid): v3b/c — resizable splitters + saved layout presets
Resizable splitters: draggable gutters between grid tracks adjust per-layout
column/row fractions (fr units), applied as an inline grid-template and persisted
(web-terminal:grid-splits). The fraction math (adjustSplit — trades delta between
adjacent tracks, clamps to a minimum, conserves the total) is a pure, unit-tested
helper in grid-layout.ts; the drag translates pixel motion into an fr delta.

Saved presets: public/grid-presets.ts — a toolbar dropdown to save the current
layout + its split under a name, re-apply it in one click, or delete it
(web-terminal:grid-presets). Desktop-only, like the layout toggle.

- grid-layout.ts: layoutTracks, defaultSplit, adjustSplit, tracksToTemplate,
  load/saveGridSplits, splitForLayout (validates stored shape).
- tabs.ts: renderGutters/beginGutterDrag/setSplit; applyLayout sets the inline
  template; gridArrangement()/applyGridPreset() hooks.
- main.ts: mount the presets dropdown. style.css: gutters + presets menu.
- tests: splitter math + persistence + a stubbed drag repro (1.2fr/0.8fr); presets
  persistence + dropdown (save/apply/delete/close). typecheck+build clean, 1612 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:09:49 +02:00
Yaojia Wang
cd97114f87 feat(grid): v3a — read-only monitor quadrants (no shared-PTY shrink)
A quadrant can be flipped to "monitor" mode via a 👁 header toggle: instead of
the live interactive terminal, it renders the session's screen from periodic
GET /live-sessions/:id/preview snapshots into a read-only xterm. A monitored
quadrant never attaches a WS and never sends a resize, so — unlike a live
quadrant (latest-writer-wins) — it does not drive the shared PTY size. That lets
you watch a session in a small quadrant without shrinking it for another device
using it full-screen (the cross-device shrink the split-grid design flagged).

- public/cell-monitor.ts (new): mountCellMonitor(host, id) — polling read-only
  preview, scaled to fit, best-effort, disposes cleanly (no write after dispose).
- tabs.ts: per-entry monitor state; applyLayout keeps the live pane hidden and
  starts/stops the monitor; 👁 toggle in the cell header; teardown on close /
  leaving grid mode.
- style.css: monitor button + .cell-monitor container.
- tests: cell-monitor.test.ts (poll→write, dispose, late-resolve guard) + monitor
  wiring in tabs.test.ts. typecheck + build clean, 1587 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:58:47 +02:00
Yaojia Wang
5475b661ae feat(grid): split-grid v2 — 1×3/2×3, drag-to-quadrant, Ctrl+` cycle, maximize
Builds on the v1 watch board:
- Two more layouts: row-3 (1×3) and grid-6 (2×3). A single `grid` marker class on
  #term now carries the shared cell chrome so it no longer enumerates each lay-*.
- Ctrl+` cycles the focused quadrant (Ctrl+Shift+` reverses); matchFocusCycleKey
  is an exported pure helper so it's unit-tested. No-op / passthrough in single mode.
- Per-quadrant ⛶ maximize: the focused cell expands to fill the grid as an absolute
  overlay while siblings stay live behind it; follows focus, resets on layout
  change / tab close.
- Drag a tab from the tab bar onto a quadrant to assign it there (reuses the
  existing tab-drag dragIndex); grid-only.

Adversarial review (3 lenses → per-finding verify, incl. a headless-Chrome repro)
caught a HIGH: maximizing via `grid-column/row: 1/-1` shoved siblings into implicit
rows — a strip instead of fullscreen AND a spurious resize to backgrounded live
PTYs. Fixed with an absolute-overlay (`position:absolute; inset:0`), then verified
in real Chromium (Playwright): maximized cell fills #term, siblings 0px size delta.
Also: silence a covered pane's pending pulse under maximize; coarse-pointer target
for .cell-max. Verified: typecheck + build:web clean, 1579 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:49:39 +02:00
Yaojia Wang
06814ba276 feat(grid): desktop split-grid watch board — v1 (single/1×2/2×2)
When several sessions are open on a large screen (≥1024px), the terminal
area can split into 1×2 or 2×2 so multiple LIVE, interactive terminals show
at once — a monitoring convenience for vibe-coding several Claude sessions.

Design keeps activeIndex as the single focused pane, so keybar/voice/approval
routing is unchanged; a new gridLayout + derived visible-set lets several
.term-cell wrappers show together inside a CSS-grid #term. The server and WS
protocol are untouched.

- public/grid-layout.ts (new): layout types/capacity, visibleIndices,
  matchMedia desktop gate (GRID_MIN_WIDTH=1024), persistence, toolbar toggle.
- tabs.ts: pane→.term-cell wrapper (header + terminal + inline-approve footer);
  applyLayout() owns show/hide + grid class + cell order + placeholders;
  board-aware activate() (never focuses a hidden pane); setFocused/setGridLayout
  delegate to it; renderCell/renderInlineApprove; refitVisible; notification
  suppression for on-screen panes (factoring in the home overlay).
- terminal-session.ts: show({focus}) so non-focused quadrants don't steal
  keyboard focus; onFocus callback (capture-phase pointerdown).
- main.ts: mount the toggle; window-focus refit → refitVisible.
- style.css: cell/grid model (.term-pane → relative flex child), focus ring,
  pending pulse, inline approve, placeholder, toggle + coarse-pointer targets.
- tests: grid-layout.test.ts + split-grid block in tabs.test.ts (+33).

Adversarial review (4 lenses → per-finding verify) caught and fixed a HIGH:
activate() was not board-aware, so opening a tab on a full grid focused a
hidden pane (typing into an invisible session). Verified: typecheck + build:web
clean, 1566 tests pass, grid-layout 95% / tabs 94% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:19:53 +02:00
239 changed files with 30646 additions and 347 deletions

View File

@@ -65,6 +65,8 @@ npm test # unit tests (vitest, all modules)
Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST``0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1. Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST``0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1.
`WEBTERM_TOKEN` (w5-access-token, optional) — a shared access token that gates the WS handshake (alongside, not replacing, the Origin check) and every remote HTTP route. **Unset ⇒ auth disabled**, so LAN zero-config is preserved exactly as before; only when set does the gate activate. When set it must be 16512 URL/cookie-safe chars (`[A-Za-z0-9._~+/=-]`) or the server refuses to start. Deliver it once via `GET /?token=<t>` (or `POST /auth`), which sets an `HttpOnly; SameSite=Strict; Secure-when-https` cookie the browser auto-sends thereafter; loopback hook ingest (`/hook*`) is exempt so the smart-features side-channel keeps working. **Honest tradeoff:** it is a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it only meaningfully hardens the relay/tunnel (TLS-terminated) path. Never port-forward the raw port to the internet. See `src/http/auth.ts` and `docs/plans/w5-access-token.md`.
## Architecture (the parts that span files) ## Architecture (the parts that span files)
The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server. The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server.

View File

@@ -24,6 +24,14 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi
- **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view. - **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view.
- **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs. - **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs.
### Split-grid watch board (v0.8, desktop)
On a large screen (≥ 1024px) the terminal area can split into a grid so several **live, interactive** sessions show at once — built for watching multiple Claude Code runs in parallel. Desktop-only (a 2×2 of terminals is unusable on a phone); the server and wire protocol are untouched, and single-pane mode is unchanged.
- **Layouts** — a toolbar toggle cycles **single / 1×2 / 1×3 / 2×2 / 2×3**. The board shows the first N tabs (drag-reorder the tab bar, or drag a tab straight onto a quadrant, to choose which); a dashed **+ New session** tile fills any empty slot. The choice persists.
- **Click-to-focus** — the quadrant you click wears a focus ring and owns the keyboard, mobile key-bar, voice, and the approval bar; **Ctrl+`** cycles focus (⇧ reverses). Only the focused pane takes keyboard focus, so panes don't fight over it.
- **Inline approve per quadrant** — a background quadrant waiting on a tool permission glows amber and shows its own **✓ / ✗** buttons, so you can clear approvals across several sessions without switching; its OS notification is suppressed while it's on screen.
- **Maximize (⛶) / monitor (👁) per quadrant** — ⛶ expands one quadrant to fill the grid (the others stay live behind it); 👁 flips a quadrant to a **read-only preview** (polled screen snapshots — no WebSocket attach, no resize) so watching a session in a small quadrant never shrinks it for another device using it full-screen.
- **Resizable splitters + saved presets** — drag the gutters between panes to re-balance column/row sizes (persisted per layout), and save a layout + its split as a named preset to re-apply in one click.
### Claude Code cockpit ### Claude Code cockpit
- **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`. - **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`.
- **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others). - **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others).
@@ -113,7 +121,7 @@ This wires Claude Code's hooks → **live per-tab status**, the **statusLine gau
### Tests ### Tests
```bash ```bash
npm test # vitest, all modules (~1470 tests, 80% coverage gate) npm test # vitest, all modules (~1600 tests, 80% coverage gate)
npm run typecheck # tsc (backend + frontend) npm run typecheck # tsc (backend + frontend)
npm run build # compile backend to dist/ npm run build # compile backend to dist/
``` ```
@@ -200,6 +208,6 @@ The server is a **byte-shuttle, not a terminal**: `node-pty` gives the shell a r
The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session. The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session.
Tested with **vitest** (~470 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else. Tested with **vitest** (~1600 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else.
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7). Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7).

View File

@@ -0,0 +1,247 @@
/**
* Native-tunnel cert auto-renew wiring — TASK A5 (PLAN_ZERO_TOUCH_ROLLOUT).
*
* The native run-loop (`superviseNative`) used to only MONITOR the frp-client leaf's freshness; the
* leaf therefore expired at ~24h and the tunnel dropped until a manual re-pair. This module closes
* that gap by driving `createCertRotator`/`renewCert` (crypto is REUSED, never reimplemented):
*
* - `createMtlsFetch` — the injected `fetchImpl` the rotator hands to `renewCert`. It POSTs /renew
* over mTLS presenting the CURRENT keystore leaf (re-read on every call, so the first renewal
* after a rotation already authenticates with the freshly issued leaf). mTLS IS the auth — no
* token, `rejectUnauthorized` always true (INV4/INV14). The private key stays in-process.
* - `wireAutoRenew` — routes the rotator callbacks: rotated → restart frpc onto the new leaf and
* log (non-secret); revoked (403) → tear the tunnel down (INV12); error → log + let the rotator
* retry with backoff. A failed renewal NEVER crashes the supervisor.
* - `startNativeAutoRenew` — the builder `superviseNative` calls: loads the identity, builds the
* mTLS fetch + rotator at ~2/3-TTL, and starts it. Returns null (auto-renew disabled) if the host
* is not enrolled (no identity), rather than throwing into the run-loop.
*/
import { request as httpsRequest } from 'node:https'
import type { AgentConfig } from '../config/agentConfig.js'
import type { Keystore } from '../keys/keystore.js'
import type { Logger } from '../log/logger.js'
import type { TimerLike } from '../transport/seams.js'
import { createBackoff } from '../transport/backoff.js'
import { buildTlsOptions, type CertParser, type TlsClientOptions } from '../transport/dial.js'
import { DEFAULT_CERT_RENEW_WINDOW_MS } from '../health/probe.js'
import { createCertRotator, type CertRotator } from './rotation.js'
/** Non-secret message from an unknown thrown value (never serializes cert/key material). */
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
/**
* Socket-idle timeout for a /renew request. A stalled or overloaded control-plane (or a NAT that
* silently drops the connection after the TLS handshake) must NOT leave the renewal Promise pending
* forever — that would starve the rotator's backoff-retry loop and let the leaf silently expire. On
* timeout the request is destroyed and the rejection surfaces through the rotator's onError→backoff.
*/
export const RENEW_REQUEST_TIMEOUT_MS = 15_000
/**
* Hard cap on the buffered /renew response body. The reply is a small `{cert,caChain}` JSON; anything
* beyond a few KB is malformed or hostile, so we destroy the stream and reject rather than buffer it.
*/
export const MAX_RENEW_RESPONSE_BYTES = 64 * 1024
// --- mTLS fetch --------------------------------------------------------------------------------
/** A single mTLS request the fetch shim delegates to (injectable so the shim is offline-testable). */
export interface MtlsRequestInit {
readonly method: string
readonly headers: Record<string, string>
readonly body?: string
}
export interface MtlsResponse {
readonly status: number
readonly body: string
}
export type MtlsRequest = (
url: string,
tls: TlsClientOptions,
init: MtlsRequestInit,
) => Promise<MtlsResponse>
/** Default mTLS transport: a `node:https` POST presenting the client cert/key + pinned CA. */
const defaultMtlsRequest: MtlsRequest = (url, tls, init) =>
new Promise<MtlsResponse>((resolve, reject) => {
const req = httpsRequest(
url,
{
method: init.method,
headers: init.headers,
cert: tls.cert,
key: tls.key,
// ca omitted ⇒ verify the server against the system roots (LE-fronted CP). Present only when a
// private CA is pinned (not for /renew).
...(tls.ca !== undefined ? { ca: tls.ca } : {}),
rejectUnauthorized: tls.rejectUnauthorized, // always true (anti-MITM, INV14)
},
(res) => {
const chunks: Buffer[] = []
let total = 0
res.on('data', (c: Buffer) => {
total += c.length
if (total > MAX_RENEW_RESPONSE_BYTES) {
res.destroy() // MEDIUM: refuse an unbounded body — a renew reply is a few-KB JSON
reject(new Error(`renew response body exceeded ${MAX_RENEW_RESPONSE_BYTES} byte cap`))
return
}
chunks.push(c)
})
res.on('end', () =>
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
)
res.on('error', reject) // a mid-stream socket error must reject, not hang
},
)
// HIGH: bound the request so a peer that accepts the connection but never replies rejects (and the
// rotator re-enters backoff) instead of pending forever — destroy(err) emits 'error' → reject below.
req.setTimeout(RENEW_REQUEST_TIMEOUT_MS, () => {
req.destroy(new Error(`renew request timed out after ${RENEW_REQUEST_TIMEOUT_MS}ms`))
})
req.on('error', reject)
if (init.body !== undefined) req.write(init.body)
req.end()
})
function toHeaderRecord(headers: RequestInit['headers']): Record<string, string> {
if (!headers) return {}
if (headers instanceof Headers) {
const out: Record<string, string> = {}
headers.forEach((v, k) => {
out[k] = v
})
return out
}
if (Array.isArray(headers)) return Object.fromEntries(headers)
return { ...(headers as Record<string, string>) }
}
/**
* Build the `fetch`-shaped shim `renewCert` uses. Each call re-reads the CURRENT keystore leaf via
* `buildTlsOptions` (which fail-fast throws NotEnrolled/CertExpired — the rotator then logs + retries
* with backoff, never crashing) and delegates to the mTLS transport, mapping the result to a real
* `Response` (so `res.ok`/`res.status`/`res.json()` behave exactly as `renewCert` expects).
*/
export function createMtlsFetch(
ks: Keystore,
opts: { request?: MtlsRequest; certParser?: CertParser } = {},
): typeof fetch {
const request = opts.request ?? defaultMtlsRequest
const shim = async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
const url = typeof input === 'string' ? input : input.toString()
// Present the current frp-client leaf (client auth), but verify the /renew SERVER cert against the
// SYSTEM roots — its host (the LE-fronted control-plane) is publicly trusted; pinning the private
// enroll caChain here fails with "unable to get local issuer certificate". So drop `ca` (absent →
// node uses the default roots); rejectUnauthorized stays true.
const full = buildTlsOptions(ks, { ...(opts.certParser ? { certParser: opts.certParser } : {}) })
const tls: TlsClientOptions = { cert: full.cert, key: full.key, rejectUnauthorized: full.rejectUnauthorized }
const reqInit: MtlsRequestInit = {
method: init?.method ?? 'GET',
headers: toHeaderRecord(init?.headers),
...(typeof init?.body === 'string' ? { body: init.body } : {}),
}
const { status, body } = await request(url, tls, reqInit)
return new Response(body, { status })
}
return shim as typeof fetch
}
// --- rotator wiring ----------------------------------------------------------------------------
/** Non-secret identifiers logged alongside renew events (INV9). */
export interface AutoRenewLogIds {
readonly subdomain: string | null
readonly hostId: string | null
}
/** The two run-loop effects the rotator drives. */
export interface AutoRenewHooks {
/** Restart the supervised frpc so it re-reads the rotated cert (a leaf rotation only). */
restartChild(): void
/** Tear the tunnel down (host revoked ⇒ never reconnect, INV12). */
stop(): void
}
/** Handle for the wired auto-renew loop. */
export interface AutoRenewController {
stop(): void
}
/**
* Wire a rotator's callbacks to the run-loop and start it. Rotated → restart frpc; revoked → stop;
* error → log (non-secret) and let the rotator retry with backoff. Returns a controller that stops
* the rotator's scheduled timer.
*/
export function wireAutoRenew(
rotator: CertRotator,
hooks: AutoRenewHooks,
logger: Logger,
ids: AutoRenewLogIds,
): AutoRenewController {
const meta = { subdomain: ids.subdomain, hostId: ids.hostId }
rotator.onRotated(() => {
logger.log('info', 'frp-client cert rotated; restarting frpc onto the fresh leaf', meta)
hooks.restartChild()
})
rotator.onRevoked(() => {
logger.log('warn', 'frp-client cert renewal refused (host revoked); tearing down tunnel', meta)
hooks.stop()
})
rotator.onError((err) => {
logger.log('warn', 'frp-client cert renewal failed; will retry with backoff', {
...meta,
error: errorMessage(err),
})
})
rotator.start()
return { stop: () => rotator.stop() }
}
// --- builder -----------------------------------------------------------------------------------
/** Injection seams for `startNativeAutoRenew` (all optional; unset ⇒ real transport/timers). */
export interface NativeAutoRenewOpts {
readonly mtlsRequest?: MtlsRequest
readonly certParser?: CertParser
readonly timer?: TimerLike
readonly renewBeforeMs?: number
readonly retryBaseMs?: number
readonly now?: () => Date
readonly parseCert?: (pem: string) => Date
}
/**
* Build + start native cert auto-renew for `superviseNative`. Renews at ~2/3 of the leaf TTL
* (default `DEFAULT_CERT_RENEW_WINDOW_MS`, the same window the health probe alarms on). Returns null
* (auto-renew disabled, logged) when the host has no identity — an unenrolled run-loop must not throw.
*/
export function startNativeAutoRenew(
cfg: AgentConfig,
ks: Keystore,
hooks: AutoRenewHooks,
logger: Logger,
opts: NativeAutoRenewOpts = {},
): AutoRenewController | null {
const id = ks.loadIdentity()
if (id === null) {
logger.log('warn', 'no identity in keystore — cert auto-renew disabled', {})
return null
}
const fetchImpl = createMtlsFetch(ks, {
...(opts.mtlsRequest ? { request: opts.mtlsRequest } : {}),
...(opts.certParser ? { certParser: opts.certParser } : {}),
})
const rotator = createCertRotator(cfg, id, ks, {
fetchImpl,
renewBeforeMs: opts.renewBeforeMs ?? DEFAULT_CERT_RENEW_WINDOW_MS,
...(opts.timer ? { timer: opts.timer } : {}),
...(opts.now ? { now: opts.now } : {}),
...(opts.parseCert ? { parseCert: opts.parseCert } : {}),
...(opts.retryBaseMs !== undefined
? { retryBackoff: createBackoff({ baseMs: opts.retryBaseMs, jitter: false }) }
: {}),
})
return wireAutoRenew(rotator, hooks, logger, { subdomain: cfg.subdomain, hostId: cfg.hostId })
}

33
agent/src/certs/pem.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* PEM helpers shared by the native enroll (enroll/pair.ts) and renew (certs/rotation.ts) paths.
*
* The control-plane returns the frp-client leaf + CA chain as base64-encoded DER (cert as a string,
* caChain as a string[]); the keystore + frpc need PEM files. `derBase64ToPem` wraps a base64 DER body
* back into CERTIFICATE armor at 64 columns.
*/
/** base64(DER) → PEM (CERTIFICATE armor, 64-col wrapped). */
export function derBase64ToPem(derBase64: string, label = 'CERTIFICATE'): string {
const body = derBase64.replace(/\s+/g, '')
const lines = body.match(/.{1,64}/g) ?? []
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
}
/**
* Normalize a control-plane cert response ({cert: base64 DER, caChain: base64 DER[]}) to PEM strings
* for the keystore. Throws if the shape is wrong. Shared by enroll + renew so both stay in lockstep.
*/
export function certResponseToPem(cert: unknown, caChain: unknown): { certPem: string; caChainPem: string } {
if (
typeof cert !== 'string' ||
!Array.isArray(caChain) ||
caChain.length === 0 ||
!caChain.every((c) => typeof c === 'string')
) {
throw new Error('cert response missing cert/caChain')
}
return {
certPem: derBase64ToPem(cert),
caChainPem: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''),
}
}

View File

@@ -13,7 +13,9 @@ import type { AgentConfig } from '../config/agentConfig.js'
import type { AgentIdentity } from '../keys/identity.js' import type { AgentIdentity } from '../keys/identity.js'
import type { Keystore } from '../keys/keystore.js' import type { Keystore } from '../keys/keystore.js'
import type { TimerLike } from '../transport/seams.js' import type { TimerLike } from '../transport/seams.js'
import { createBackoff, type BackoffPolicy } from '../transport/backoff.js'
import { buildCsr } from '../enroll/csr.js' import { buildCsr } from '../enroll/csr.js'
import { certResponseToPem } from './pem.js'
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
@@ -22,6 +24,8 @@ export interface CertRotator {
stop(): void stop(): void
onRotated(cb: () => void): void onRotated(cb: () => void): void
onRevoked(cb: () => void): void onRevoked(cb: () => void): void
/** A renewal attempt failed (network/HTTP, NOT a 403 revoke). The rotator retries with backoff. */
onError(cb: (err: unknown) => void): void
} }
export type RenewOutcome = 'rotated' | 'revoked' export type RenewOutcome = 'rotated' | 'revoked'
@@ -61,11 +65,11 @@ export async function renewCert(
}) })
if (res.status === 403) return 'revoked' if (res.status === 403) return 'revoked'
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`) if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
const json = (await res.json()) as { cert?: string; caChain?: string } // The control-plane returns cert=base64(DER) + caChain=base64(DER)[]; normalize to PEM for the
if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') { // keystore + frpc (same shape as native enroll).
throw new Error('cert renewal response missing cert/caChain') const json = (await res.json()) as { cert?: unknown; caChain?: unknown }
} const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain)
ks.saveCert(json.cert, json.caChain) // atomic whole-file install ks.saveCert(certPem, caChainPem) // atomic whole-file install
return 'rotated' return 'rotated'
} }
@@ -79,6 +83,8 @@ export function createCertRotator(
fetchImpl?: typeof fetch fetchImpl?: typeof fetch
now?: () => Date now?: () => Date
parseCert?: (pem: string) => Date parseCert?: (pem: string) => Date
/** Backoff policy for retrying a FAILED renewal (default 1s→30s). Reset after a success. */
retryBackoff?: BackoffPolicy
} = {}, } = {},
): CertRotator { ): CertRotator {
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
@@ -91,9 +97,11 @@ export function createCertRotator(
} }
const doFetch = opts.fetchImpl ?? fetch const doFetch = opts.fetchImpl ?? fetch
const now = opts.now ?? (() => new Date()) const now = opts.now ?? (() => new Date())
const retryBackoff = opts.retryBackoff ?? createBackoff({ jitter: true })
let handle: unknown = null let handle: unknown = null
let rotatedCb: (() => void) | null = null let rotatedCb: (() => void) | null = null
let revokedCb: (() => void) | null = null let revokedCb: (() => void) | null = null
let errorCb: ((err: unknown) => void) | null = null
function schedule(): void { function schedule(): void {
const certs = ks.loadCert() const certs = ks.loadCert()
@@ -109,12 +117,16 @@ export function createCertRotator(
revokedCb?.() revokedCb?.()
return return
} }
retryBackoff.reset() // a healthy renewal clears the retry backoff for the next cycle
rotatedCb?.() rotatedCb?.()
schedule() schedule()
}) })
.catch(() => { .catch((err: unknown) => {
// network error: retry after renewBeforeMs; the tunnel stays up meanwhile. // Network/HTTP failure (never a 403 revoke): surface it (caller logs, no secret) and retry
handle = timer.setTimeout(runRenewal, renewBeforeMs) // with backoff. The cert is still valid until expiry, so the tunnel stays up meanwhile — a
// failed renewal must NEVER tear the supervisor down.
errorCb?.(err)
handle = timer.setTimeout(runRenewal, retryBackoff.nextDelayMs())
}) })
} }
@@ -132,5 +144,8 @@ export function createCertRotator(
onRevoked(cb): void { onRevoked(cb): void {
revokedCb = cb revokedCb = cb
}, },
onError(cb): void {
errorCb = cb
},
} }
} }

View File

@@ -23,6 +23,7 @@ import { runTunnel } from '../transport/runTunnel.js'
import { buildNativeFrpcToml } from '../transport/frpcToml.js' import { buildNativeFrpcToml } from '../transport/frpcToml.js'
import { superviseFrpc } from '../transport/frpSupervise.js' import { superviseFrpc } from '../transport/frpSupervise.js'
import { provisionFrpc } from '../provision/frpcBinary.js' import { provisionFrpc } from '../provision/frpcBinary.js'
import { startNativeAutoRenew } from '../certs/nativeRenew.js'
import { import {
probeLoopbackBaseApp, probeLoopbackBaseApp,
renderHealthStatus, renderHealthStatus,
@@ -98,7 +99,7 @@ async function enrollNative(
id: AgentIdentity, id: AgentIdentity,
ks: Keystore, ks: Keystore,
): Promise<NativeEnrollResult> { ): Promise<NativeEnrollResult> {
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks) const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks, { allowMissingContentSecret: true })
return { hostId: enroll.hostId, subdomain: enroll.subdomain } return { hostId: enroll.hostId, subdomain: enroll.subdomain }
} }
@@ -156,9 +157,12 @@ export function readFrpcLog(stateDir: string): string {
} }
/** /**
* Native run-loop (B4/H4): supervise the pinned frpc child with restart-on-exit backoff while a * Native run-loop (B4/H4 + A5): supervise the pinned frpc child with restart-on-exit backoff while a
* periodic health probe (frpc alive, base-app loopback reachable, proxy-started, cert-not-expiring) * periodic health probe (frpc alive, base-app loopback reachable, proxy-started, cert-not-expiring)
* logs NON-SECRET status only (INV9). Resolves when the supervisor stops (SIGTERM/SIGINT). * logs NON-SECRET status only (INV9), AND auto-renew the frp-client leaf at ~2/3 TTL so the tunnel
* never drops on cert expiry (A5): a successful renewal restarts frpc onto the fresh leaf, a 403
* revoke tears the tunnel down, and a failed renewal retries with backoff without crashing the
* supervisor. Resolves when the supervisor stops (SIGTERM/SIGINT).
*/ */
function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> { function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
const logger = createLogger('info') const logger = createLogger('info')
@@ -184,12 +188,28 @@ function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
for (const line of renderHealthStatus(ids, report)) logger.log('info', line) for (const line of renderHealthStatus(ids, report)) logger.log('info', line)
}, },
) )
// A5: silently renew the leaf before it expires. Restart frpc onto the fresh cert on rotation;
// stop the whole supervisor on a 403 revoke (INV12). Null ⇒ unenrolled (auto-renew disabled).
const autoRenew = startNativeAutoRenew(
cfg,
ks,
{
restartChild: () => handle.restartChild(),
stop: () => {
void handle.stop()
},
},
logger,
)
const onSignal = (): void => { const onSignal = (): void => {
void handle.stop() void handle.stop()
} }
process.once('SIGTERM', onSignal) process.once('SIGTERM', onSignal)
process.once('SIGINT', onSignal) process.once('SIGINT', onSignal)
return handle.done.finally(() => monitor.stop()) return handle.done.finally(() => {
monitor.stop()
autoRenew?.stop()
})
} }
/** Build the concrete CliDeps used by the real CLI entrypoint. */ /** Build the concrete CliDeps used by the real CLI entrypoint. */

View File

@@ -15,6 +15,7 @@ import type { EnrollResult } from 'relay-contracts'
import type { AgentIdentity } from '../keys/identity.js' import type { AgentIdentity } from '../keys/identity.js'
import type { Keystore } from '../keys/keystore.js' import type { Keystore } from '../keys/keystore.js'
import { buildCsr } from './csr.js' import { buildCsr } from './csr.js'
import { derBase64ToPem } from '../certs/pem.js'
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */ /** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
export type EnrollMode = 'token' | 'ed25519' export type EnrollMode = 'token' | 'ed25519'
@@ -53,6 +54,12 @@ export interface RedeemOptions {
readonly agentToken?: string readonly agentToken?: string
readonly unwrapContentSecret?: UnwrapContentSecret readonly unwrapContentSecret?: UnwrapContentSecret
readonly subject?: string readonly subject?: string
/**
* Native frp-client enroll has NO E2E content secret — the control-plane returns
* `hostContentSecret: null`. When true, tolerate its absence (skip unwrap + storage); the plain
* frpc byte tunnel needs no content key. Defaults false so the legacy relay path still requires it.
*/
readonly allowMissingContentSecret?: boolean
} }
interface EnrollResponseJson { interface EnrollResponseJson {
@@ -63,10 +70,32 @@ interface EnrollResponseJson {
hostContentSecret: string // base64url over the wire hostContentSecret: string // base64url over the wire
} }
function parseEnrollResult(json: unknown): EnrollResult { function parseEnrollResult(json: unknown, allowMissingContentSecret = false): EnrollResult {
const j = json as Partial<EnrollResponseJson> const j = json as Partial<EnrollResponseJson>
if (typeof j.hostContentSecret !== 'string') { if (typeof j.hostContentSecret !== 'string') {
throw new EnrollError('enroll response missing hostContentSecret') if (!allowMissingContentSecret) {
throw new EnrollError('enroll response missing hostContentSecret')
}
// Native frp-client enroll: cert = base64(DER) string, caChain = base64(DER) string[], no content
// key. The keystore + frpc need PEM, so convert here. Empty secret sentinel is never stored.
const caChain: unknown = j.caChain
if (
typeof j.hostId !== 'string' ||
typeof j.subdomain !== 'string' ||
typeof j.cert !== 'string' ||
!Array.isArray(caChain) ||
caChain.length === 0 ||
!caChain.every((c) => typeof c === 'string')
) {
throw new EnrollError('enroll response missing required fields')
}
return {
hostId: j.hostId,
subdomain: j.subdomain,
cert: derBase64ToPem(j.cert),
caChain: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''),
hostContentSecret: new Uint8Array(0),
}
} }
const candidate = { const candidate = {
hostId: j.hostId, hostId: j.hostId,
@@ -128,10 +157,13 @@ export async function redeemPairingCode(
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`) throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
} }
const enroll = parseEnrollResult(json) const enroll = parseEnrollResult(json, opts.allowMissingContentSecret ?? false)
ks.saveCert(enroll.cert, enroll.caChain) ks.saveCert(enroll.cert, enroll.caChain)
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored). // FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
const unwrapped = unwrap(enroll.hostContentSecret, id) // Native frp-client enroll has no content secret (empty sentinel) → nothing to unwrap/store.
ks.saveContentSecret(unwrapped) if (enroll.hostContentSecret.length > 0) {
const unwrapped = unwrap(enroll.hostContentSecret, id)
ks.saveContentSecret(unwrapped)
}
return enroll return enroll
} }

View File

@@ -11,6 +11,7 @@
* - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the * - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the
* base-app unit ONLY; the agent unit (which supervises frpc) never carries it. * base-app unit ONLY; the agent unit (which supervises frpc) never carries it.
*/ */
import { dirname, join } from 'node:path'
import type { AgentConfig } from '../config/agentConfig.js' import type { AgentConfig } from '../config/agentConfig.js'
import { import {
agentLabel, agentLabel,
@@ -202,13 +203,37 @@ export async function installService(
// so nothing is ever written for a rejected install. // so nothing is ever written for a rejected install.
const baseAppEnv = resolveBaseAppEnv(cfg, options) const baseAppEnv = resolveBaseAppEnv(cfg, options)
const bin = deps.binPath() const bin = deps.binPath()
const baseAppExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC const rawExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
// launchd/systemd start with a minimal PATH that excludes /usr/local/bin (where a nvm/brew `node`
// symlink usually lives), so a bare `node` program dies with EX_CONFIG(78). Use the absolute node
// (process.execPath) and export a PATH so the units — and the base app's tmux/node-pty/frpc
// subprocesses — resolve their tools.
const nodePath = process.execPath
const unitPath = `${dirname(nodePath)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`
const baseAppExec = rawExec[0] === 'node' ? [nodePath, ...rawExec.slice(1)] : rawExec
const baseAppEnvWithPath = { ...baseAppEnv, PATH: unitPath }
// The agent unit runs `run`, which loadConfig()-validates ENROLL_URL(https)/RELAY_URL(wss) up front
// (fail-fast). Without these in the unit env the supervisor exits 1 on every launch — so inject the
// agent's own runtime config (NOT the base-app env) alongside PATH.
const agentEnv: Record<string, string> = {
PATH: unitPath,
ENROLL_URL: cfg.enrollUrl,
RELAY_URL: cfg.relayUrl,
STATE_DIR: cfg.stateDir,
LOCAL_TARGET_URL: cfg.localTargetUrl,
}
if (platform === 'launchd') { if (platform === 'launchd') {
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel()) const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
deps.writeFile(baseAppPath, buildLaunchdPlist(baseAppExec, baseAppEnv, baseAppLabel())) deps.writeFile(
baseAppPath,
buildLaunchdPlist(baseAppExec, baseAppEnvWithPath, baseAppLabel(), join(cfg.stateDir, 'base-app.log')),
)
const agentPath = launchdPlistPath(deps.homedir(), agentLabel()) const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
deps.writeFile(agentPath, buildLaunchdPlist([bin, 'run'], {}, agentLabel())) deps.writeFile(
agentPath,
buildLaunchdPlist([nodePath, bin, 'run'], agentEnv, agentLabel(), join(cfg.stateDir, 'agent.log')),
)
for (const path of [baseAppPath, agentPath]) { for (const path of [baseAppPath, agentPath]) {
const { cmd, args } = launchdLoadCommand(path) const { cmd, args } = launchdLoadCommand(path)
await deps.runCommand(cmd, args) await deps.runCommand(cmd, args)
@@ -217,8 +242,8 @@ export async function installService(
} }
const baseAppOptions: SystemdUnitOptions = options.envFile const baseAppOptions: SystemdUnitOptions = options.envFile
? { env: baseAppEnv, envFile: options.envFile } ? { env: baseAppEnvWithPath, envFile: options.envFile }
: { env: baseAppEnv } : { env: baseAppEnvWithPath }
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName()) const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
deps.writeFile( deps.writeFile(
baseAppPath, baseAppPath,
@@ -227,7 +252,7 @@ export async function installService(
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName()) const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
deps.writeFile( deps.writeFile(
agentPath, agentPath,
buildSystemdUnit(`${bin} run`, deps.username(), {}, 'web-terminal host agent (frpc supervisor)'), buildSystemdUnit(`${nodePath} ${bin} run`, deps.username(), { env: agentEnv }, 'web-terminal host agent (frpc supervisor)'),
) )
for (const unit of [baseAppUnitName(), agentUnitName()]) { for (const unit of [baseAppUnitName(), agentUnitName()]) {
const { cmd, args } = systemdEnableCommand(unit) const { cmd, args } = systemdEnableCommand(unit)

View File

@@ -78,6 +78,7 @@ export function buildLaunchdPlist(
programArguments: readonly string[], programArguments: readonly string[],
env: ServiceEnv = {}, env: ServiceEnv = {},
label: string = AGENT_LABEL, label: string = AGENT_LABEL,
logPath?: string,
): string { ): string {
return [ return [
'<?xml version="1.0" encoding="UTF-8"?>', '<?xml version="1.0" encoding="UTF-8"?>',
@@ -91,6 +92,16 @@ export function buildLaunchdPlist(
' <true/>', ' <true/>',
' <key>KeepAlive</key>', ' <key>KeepAlive</key>',
' <true/>', ' <true/>',
// launchd's default has no log sink (unlike systemd's journald), so a crashing unit is silent.
// Route stdout+stderr to a file so `pair --install` failures are diagnosable out of the box.
...(logPath !== undefined
? [
' <key>StandardOutPath</key>',
` <string>${escapeXml(logPath)}</string>`,
' <key>StandardErrorPath</key>',
` <string>${escapeXml(logPath)}</string>`,
]
: []),
...environmentVariablesBlock(env), ...environmentVariablesBlock(env),
'</dict>', '</dict>',
'</plist>', '</plist>',

View File

@@ -25,7 +25,13 @@ export class CertExpiredError extends Error {
export interface TlsClientOptions { export interface TlsClientOptions {
readonly cert: string readonly cert: string
readonly key: string readonly key: string
readonly ca: string /**
* CA(s) to verify the SERVER cert against. Set to the private tunnel CA for the relay dial; left
* undefined for a request whose server is publicly trusted (the LE-fronted control-plane /renew) so
* Node verifies against the system roots — pinning the private CA there fails with "unable to get
* local issuer certificate".
*/
readonly ca?: string
readonly rejectUnauthorized: true readonly rejectUnauthorized: true
} }

View File

@@ -62,6 +62,12 @@ export interface FrpSuperviseHandle {
readonly done: Promise<number> readonly done: Promise<number>
/** True while a frpc child is currently running (for the health probe). */ /** True while a frpc child is currently running (for the health probe). */
isChildAlive(): boolean isChildAlive(): boolean
/**
* Kill the current child WITHOUT stopping the supervisor (A5): the restart-on-exit loop respawns a
* fresh frpc that re-reads the (now rotated) cert/key/CA files. A no-op once `stop()` was called —
* it must never resurrect a supervisor that is shutting down.
*/
restartChild(): void
} }
/** A frpc run lasting at least this long is "stable" ⇒ reset the restart backoff. */ /** A frpc run lasting at least this long is "stable" ⇒ reset the restart backoff. */
@@ -196,5 +202,8 @@ export function superviseFrpc(
}, },
done, done,
isChildAlive: () => child?.isAlive() ?? false, isChildAlive: () => child?.isAlive() ?? false,
restartChild(): void {
if (!stopped) child?.kill()
},
} }
} }

View File

@@ -145,4 +145,44 @@ describe('superviseFrpc (B4/H4 — restart-on-exit backoff)', () => {
await stopping await stopping
expect(spawn).toHaveBeenCalledTimes(1) expect(spawn).toHaveBeenCalledTimes(1)
}) })
it('restartChild() kills the running child so the loop respawns onto the fresh cert files', async () => {
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
const children = [makeChild(), makeChild()]
let n = 0
const spawn: SpawnFrpc = () => children[n++]!.child
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
now: () => 1_000_000, // fixed clock: run counts as unstable, but sleep is a no-op → respawn is immediate
})
await flush()
expect(handle.isChildAlive()).toBe(true) // child[0]
handle.restartChild() // A5: rotator calls this after a cert rotation to reload the new leaf
expect(children[0]!.killed).toBe(true)
await flush()
expect(n).toBe(2) // child[1] spawned — frpc now reads the rotated cert on disk
expect(handle.isChildAlive()).toBe(true)
await handle.stop()
})
it('restartChild() after stop is a no-op (no spawn past shutdown)', async () => {
const first = makeChild()
let n = 0
const spawn: SpawnFrpc = vi.fn(() => (n++ === 0 ? first.child : makeChild().child))
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
})
await Promise.resolve()
const stopping = handle.stop()
first.exit(0)
await stopping
handle.restartChild() // must not resurrect the supervisor
expect(spawn).toHaveBeenCalledTimes(1)
})
}) })

View File

@@ -81,8 +81,10 @@ describe('installService — two distinct units (FIX M-host-2service)', () => {
expect(d.writes).toHaveLength(2) expect(d.writes).toHaveLength(2)
const baseApp = unitWith(d, baseAppUnitName()) const baseApp = unitWith(d, baseAppUnitName())
const agent = unitWith(d, agentUnitName()) const agent = unitWith(d, agentUnitName())
// agent unit supervises frpc via `<bin> run`; base-app runs the node server (loopback) // agent unit supervises frpc via `<node> <bin> run` (absolute node so a minimal service PATH
expect(agent).toContain('ExecStart=/usr/local/bin/web-terminal-agent run') // that lacks /usr/local/bin can't fail with EX_CONFIG); base-app runs the node server (loopback)
expect(agent).toContain('/usr/local/bin/web-terminal-agent run')
expect(agent).toMatch(/ExecStart=\S*node\S* \/usr\/local\/bin\/web-terminal-agent run/)
expect(baseApp).toContain('ExecStart=') expect(baseApp).toContain('ExecStart=')
expect(baseApp).toContain('server.js') expect(baseApp).toContain('server.js')
expect(baseApp).not.toContain('web-terminal-agent run') expect(baseApp).not.toContain('web-terminal-agent run')

View File

@@ -0,0 +1,279 @@
/**
* A5 native cert auto-renew wiring — TDD.
*
* Covers the host-side glue that was the "one real host code gap": the native run-loop must actually
* RENEW the frp-client leaf (not merely monitor its freshness). Three units:
* - `createMtlsFetch` — the injected `fetchImpl` `renewCert` uses: it POSTs /renew over mTLS
* presenting the CURRENT keystore leaf (read fresh on every call, so a post-rotation renewal
* authenticates with the new leaf) and maps the transport response to a `Response`.
* - `wireAutoRenew` — routes the rotator's rotated→restartChild(+log), revoked→stop(+log),
* error→log(no secret) callbacks and starts it.
* - `startNativeAutoRenew` — the end-to-end builder `superviseNative` calls (identity + mTLS fetch
* + rotator), returning null (disabled) when unenrolled.
*/
import { describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { AgentConfig } from '../src/config/agentConfig.js'
import { generateP256Identity } from '../src/keys/identity.js'
import { openKeystore } from '../src/keys/keystore.js'
import { createLogger } from '../src/log/logger.js'
import type { CertRotator } from '../src/certs/rotation.js'
import {
createMtlsFetch,
startNativeAutoRenew,
wireAutoRenew,
type MtlsRequest,
} from '../src/certs/nativeRenew.js'
import { FakeTimer } from './fixtures/fakes.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
enrollUrl: 'https://cp.example.com/enroll',
stateDir: '/tmp/x',
localTargetUrl: 'ws://127.0.0.1:3000',
subdomain: 'host-42',
hostId: 'h-1',
}
function enrolledKs(): { dir: string; ks: ReturnType<typeof openKeystore> } {
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
const ks = openKeystore(dir)
ks.saveIdentity(generateP256Identity())
ks.saveCert('LEAFCERT', 'CACHAIN')
return { dir, ks }
}
const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) })
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
describe('createMtlsFetch (A5)', () => {
it('presents the current keystore cert/key/CA over mTLS and maps the transport response', async () => {
const { dir, ks } = enrolledKs()
const seen: Array<{ url: string; tls: Record<string, unknown>; init: Record<string, unknown> }> = []
const request: MtlsRequest = async (url, tls, init) => {
seen.push({ url, tls: tls as unknown as Record<string, unknown>, init: init as unknown as Record<string, unknown> })
return { status: 200, body: JSON.stringify({ cert: 'NEW', caChain: 'NEWCA' }) }
}
const f = createMtlsFetch(ks, { request, certParser: farFuture })
const res = await f('https://cp.example.com/renew', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"csr":"x"}',
})
expect(res.status).toBe(200)
expect(res.ok).toBe(true)
expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' })
expect(seen[0]!.url).toBe('https://cp.example.com/renew')
expect(seen[0]!.tls.cert).toBe('LEAFCERT')
// /renew verifies the LE-fronted control-plane against the SYSTEM roots, so no private CA is pinned
// (pinning the enroll caChain here fails with "unable to get local issuer certificate").
expect(seen[0]!.tls.ca).toBeUndefined()
expect(seen[0]!.tls.rejectUnauthorized).toBe(true)
expect(String(seen[0]!.tls.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key, mTLS only
expect(seen[0]!.init.method).toBe('POST')
expect(seen[0]!.init.body).toBe('{"csr":"x"}')
rmSync(dir, { recursive: true, force: true })
})
it('reads the current cert on every call so a post-rotation renewal uses the new leaf', async () => {
const { dir, ks } = enrolledKs()
const certs: string[] = []
const request: MtlsRequest = async (_url, tls) => {
certs.push(tls.cert)
return { status: 200, body: '{}' }
}
const f = createMtlsFetch(ks, { request, certParser: farFuture })
await f('https://x/renew', { method: 'POST' })
ks.saveCert('ROTATEDLEAF', 'CACHAIN') // simulate a completed rotation
await f('https://x/renew', { method: 'POST' })
expect(certs).toEqual(['LEAFCERT', 'ROTATEDLEAF'])
rmSync(dir, { recursive: true, force: true })
})
it('propagates a not-enrolled keystore as a throw (renewCert then retries with backoff)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
const ks = openKeystore(dir)
const request: MtlsRequest = async () => ({ status: 200, body: '{}' })
const f = createMtlsFetch(ks, { request, certParser: farFuture })
await expect(f('https://x/renew', { method: 'POST' })).rejects.toThrow()
rmSync(dir, { recursive: true, force: true })
})
})
describe('wireAutoRenew (A5)', () => {
function fakeRotator(): {
rotator: CertRotator
fire: { rotated?: () => void; revoked?: () => void; error?: (e: unknown) => void }
start: ReturnType<typeof vi.fn>
stop: ReturnType<typeof vi.fn>
} {
const fire: { rotated?: () => void; revoked?: () => void; error?: (e: unknown) => void } = {}
const start = vi.fn()
const stop = vi.fn()
const rotator: CertRotator = {
start,
stop,
onRotated: (cb) => {
fire.rotated = cb
},
onRevoked: (cb) => {
fire.revoked = cb
},
onError: (cb) => {
fire.error = cb
},
}
return { rotator, fire, start, stop }
}
it('routes rotated→restartChild, revoked→stop, error→log; starts and stops the rotator', () => {
const { rotator, fire, start, stop } = fakeRotator()
const restartChild = vi.fn()
const stopSupervisor = vi.fn()
const lines: string[] = []
const logger = createLogger('info', (l) => lines.push(l))
const controller = wireAutoRenew(rotator, { restartChild, stop: stopSupervisor }, logger, {
subdomain: 'host-42',
hostId: 'h-1',
})
expect(start).toHaveBeenCalledTimes(1)
fire.rotated!()
expect(restartChild).toHaveBeenCalledTimes(1)
fire.revoked!()
expect(stopSupervisor).toHaveBeenCalledTimes(1)
fire.error!(new Error('network down'))
controller.stop()
expect(stop).toHaveBeenCalledTimes(1)
const joined = lines.join('\n')
expect(joined).toContain('host-42') // non-secret identifier is logged
expect(joined).not.toContain('LEAFCERT') // never a leaf/key/CSR
})
})
describe('startNativeAutoRenew (A5 end-to-end)', () => {
it('a scheduled 200 renewal rotates the leaf on disk and restarts frpc with it', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
const request: MtlsRequest = async () => ({
status: 200,
body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }),
})
const restartChild = vi.fn()
const stop = vi.fn()
const controller = startNativeAutoRenew(
CFG,
ks,
{ restartChild, stop },
createLogger('error', () => {}),
{ mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) },
)!
expect(controller).not.toBeNull()
timer.advance(1000) // renewal fires at ~2/3 TTL
await flush()
expect(ks.loadCert()!.certPem).toContain('FRESHLEAF') // atomic persist
expect(restartChild).toHaveBeenCalledTimes(1) // frpc restarted onto the fresh leaf
expect(stop).not.toHaveBeenCalled()
controller.stop()
rmSync(dir, { recursive: true, force: true })
})
it('a scheduled 403 renewal tears the tunnel down (revoked) and never rotates', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
const request: MtlsRequest = async () => ({ status: 403, body: '' })
const restartChild = vi.fn()
const stop = vi.fn()
const controller = startNativeAutoRenew(
CFG,
ks,
{ restartChild, stop },
createLogger('error', () => {}),
{ mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) },
)!
timer.advance(1000)
await flush()
expect(stop).toHaveBeenCalledTimes(1)
expect(restartChild).not.toHaveBeenCalled()
expect(ks.loadCert()!.certPem).toBe('LEAFCERT') // untouched
controller.stop()
rmSync(dir, { recursive: true, force: true })
})
it('a failing renewal is logged (no secret) and retried without crashing, then rotates', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
let calls = 0
const request: MtlsRequest = async () => {
calls += 1
if (calls === 1) throw new Error('ECONNREFUSED')
return { status: 200, body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }) }
}
const restartChild = vi.fn()
const stop = vi.fn()
const lines: string[] = []
const controller = startNativeAutoRenew(
CFG,
ks,
{ restartChild, stop },
createLogger('warn', (l) => lines.push(l)),
{
mtlsRequest: request,
certParser: farFuture,
timer,
renewBeforeMs: 1000,
retryBaseMs: 500,
now: () => new Date(0),
parseCert: () => new Date(2000),
},
)!
timer.advance(1000) // first attempt throws
await flush()
expect(restartChild).not.toHaveBeenCalled()
expect(stop).not.toHaveBeenCalled() // a failure NEVER tears down
expect(lines.join('\n')).toContain('retry')
timer.advance(500) // backoff retry fires and succeeds
await flush()
expect(ks.loadCert()!.certPem).toContain('FRESHLEAF')
expect(restartChild).toHaveBeenCalledTimes(1)
expect(lines.join('\n')).not.toContain('LEAFCERT')
controller.stop()
rmSync(dir, { recursive: true, force: true })
})
it('returns null (auto-renew disabled) when the keystore has no identity', () => {
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
const ks = openKeystore(dir)
const lines: string[] = []
const controller = startNativeAutoRenew(
CFG,
ks,
{ restartChild: () => {}, stop: () => {} },
createLogger('warn', (l) => lines.push(l)),
{},
)
expect(controller).toBeNull()
expect(lines.join('\n')).toContain('auto-renew disabled')
rmSync(dir, { recursive: true, force: true })
})
})

View File

@@ -0,0 +1,169 @@
/**
* A5 default mTLS transport (`defaultMtlsRequest`) — the ONE seam the other nativeRenew tests inject
* past, so the real `node:https` transport had zero coverage. These tests mock `node:https` and drive
* the actual transport to prove:
* - the exact request options (rejectUnauthorized:true + client cert/key + pinned CA + method/body),
* - the HIGH fix: a request timeout is armed and a stalled peer REJECTS (never hangs forever),
* - the MEDIUM fix: an oversized response body is capped (destroyed + rejected, not buffered).
*/
import { EventEmitter } from 'node:events'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() }))
vi.mock('node:https', () => ({ request: requestMock, default: { request: requestMock } }))
import { generateP256Identity } from '../src/keys/identity.js'
import { openKeystore } from '../src/keys/keystore.js'
import {
createMtlsFetch,
RENEW_REQUEST_TIMEOUT_MS,
MAX_RENEW_RESPONSE_BYTES,
} from '../src/certs/nativeRenew.js'
const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) })
/** Fake `http.ClientRequest`: records setTimeout/write/end and emits 'error' on destroy(err). */
class FakeClientRequest extends EventEmitter {
readonly setTimeoutCalls: Array<{ ms: number; cb: () => void }> = []
readonly written: string[] = []
ended = false
destroyedWith: Error | undefined
setTimeout(ms: number, cb: () => void): this {
this.setTimeoutCalls.push({ ms, cb })
return this
}
write(chunk: string): boolean {
this.written.push(chunk)
return true
}
end(): this {
this.ended = true
return this
}
destroy(err?: Error): this {
this.destroyedWith = err
if (err) this.emit('error', err)
return this
}
}
/** Fake `http.IncomingMessage`: an EventEmitter with a statusCode and a real destroy(). */
class FakeIncomingMessage extends EventEmitter {
statusCode = 200
destroyed = false
destroy(): this {
this.destroyed = true
return this
}
}
type ReqCb = (res: FakeIncomingMessage) => void
let dirs: string[] = []
function enrolledKs(): ReturnType<typeof openKeystore> {
const dir = mkdtempSync(join(tmpdir(), 'wta-nrt-'))
dirs.push(dir)
const ks = openKeystore(dir)
ks.saveIdentity(generateP256Identity())
ks.saveCert('LEAFCERT', 'CACHAIN')
return ks
}
beforeEach(() => {
requestMock.mockReset()
})
afterEach(() => {
for (const d of dirs) rmSync(d, { recursive: true, force: true })
dirs = []
})
describe('defaultMtlsRequest transport (A5 — real node:https)', () => {
it('passes rejectUnauthorized:true + the client cert/key/CA + method/body, and arms the timeout', async () => {
const ks = enrolledKs()
let seenUrl = ''
let seenOpts: Record<string, unknown> = {}
let seenReq: FakeClientRequest | undefined
requestMock.mockImplementation((url: string, opts: Record<string, unknown>, cb: ReqCb) => {
seenUrl = url
seenOpts = opts
const req = new FakeClientRequest()
seenReq = req
queueMicrotask(() => {
const res = new FakeIncomingMessage()
res.statusCode = 200
cb(res)
res.emit('data', Buffer.from('{"cert":"NEW",'))
res.emit('data', Buffer.from('"caChain":"NEWCA"}'))
res.emit('end')
})
return req
})
const f = createMtlsFetch(ks, { certParser: farFuture }) // NO request seam → real defaultMtlsRequest
const res = await f('https://cp.example.com/renew', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"csr":"x"}',
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' })
expect(seenUrl).toBe('https://cp.example.com/renew')
expect(seenOpts.rejectUnauthorized).toBe(true) // anti-MITM (INV14)
expect(seenOpts.method).toBe('POST')
expect(seenOpts.cert).toBe('LEAFCERT')
// ca omitted ⇒ verify the LE-fronted control-plane against the system roots (not the private CA).
expect(seenOpts.ca).toBeUndefined()
expect(String(seenOpts.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key
// HIGH fix: a socket timeout is armed with the sane default so a stall can never hang forever.
expect(seenReq!.setTimeoutCalls).toHaveLength(1)
expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
expect(seenReq!.written).toEqual(['{"csr":"x"}'])
expect(seenReq!.ended).toBe(true)
})
it('HIGH: a stalled peer (accepts TLS, never responds) REJECTS via the timeout, not hangs', async () => {
const ks = enrolledKs()
let seenReq: FakeClientRequest | undefined
requestMock.mockImplementation(() => {
const req = new FakeClientRequest()
seenReq = req
return req // never invokes the response callback — a stalled control-plane
})
const f = createMtlsFetch(ks, { certParser: farFuture })
const p = f('https://cp.example.com/renew', { method: 'POST', body: '{}' })
// Fire the armed socket-timeout callback (what node does when the socket idles past the limit).
expect(seenReq!.setTimeoutCalls).toHaveLength(1)
expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
seenReq!.setTimeoutCalls[0]!.cb()
await expect(p).rejects.toThrow(/timed out/i)
expect(seenReq!.destroyedWith).toBeInstanceOf(Error) // socket was torn down
})
it('MEDIUM: an oversized response body is capped — res destroyed + promise rejected', async () => {
const ks = enrolledKs()
let seenRes: FakeIncomingMessage | undefined
requestMock.mockImplementation((_url: string, _opts: unknown, cb: ReqCb) => {
const req = new FakeClientRequest()
queueMicrotask(() => {
const res = new FakeIncomingMessage()
res.statusCode = 200
seenRes = res
cb(res)
res.emit('data', Buffer.alloc(MAX_RENEW_RESPONSE_BYTES + 1)) // one byte over the cap
// deliberately NO 'end' — a capped stream must reject on its own, not wait for end
})
return req
})
const f = createMtlsFetch(ks, { certParser: farFuture })
await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow(/cap|exceed/i)
expect(seenRes!.destroyed).toBe(true)
})
})

View File

@@ -11,6 +11,7 @@ import {
renewCert, renewCert,
renewalUrlFor, renewalUrlFor,
} from '../src/certs/rotation.js' } from '../src/certs/rotation.js'
import { createBackoff } from '../src/transport/backoff.js'
import { FakeTimer } from './fixtures/fakes.js' import { FakeTimer } from './fixtures/fakes.js'
const CFG: AgentConfig = { const CFG: AgentConfig = {
@@ -52,10 +53,10 @@ describe('renewCert (T13)', () => {
it('installs a fresh cert atomically on success (same key)', async () => { it('installs a fresh cert atomically on success (same key)', async () => {
const { dir, ks } = enrolledKs() const { dir, ks } = enrolledKs()
const before = ks.loadIdentity()!.publicKey const before = ks.loadIdentity()!.publicKey
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] }))
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch) const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
expect(out).toBe('rotated') expect(out).toBe('rotated')
expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' }) expect(ks.loadCert()!.certPem).toContain('NEWCERT'); expect(ks.loadCert()!.caChainPem).toContain('NEWCA')
// pubkey unchanged — only the cert rotated // pubkey unchanged — only the cert rotated
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true) expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
rmSync(dir, { recursive: true, force: true }) rmSync(dir, { recursive: true, force: true })
@@ -101,7 +102,7 @@ describe('createCertRotator (T13)', () => {
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, { const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer, timer,
renewBeforeMs: 1000, renewBeforeMs: 1000,
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) as unknown as typeof fetch, fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })) as unknown as typeof fetch,
now: () => new Date(0), now: () => new Date(0),
parseCert: () => new Date(2000), parseCert: () => new Date(2000),
}) })
@@ -113,7 +114,47 @@ describe('createCertRotator (T13)', () => {
timer.advance(1000) timer.advance(1000)
await flush() await flush()
expect(rotated).toBe(1) expect(rotated).toBe(1)
expect(ks.loadCert()!.certPem).toBe('NEWCERT') expect(ks.loadCert()!.certPem).toContain('NEWCERT')
rotator.stop()
rmSync(dir, { recursive: true, force: true })
})
it('invokes onError and retries with backoff (not renewBeforeMs) when a renewal throws', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
let calls = 0
const fetchImpl = (async () => {
calls += 1
if (calls === 1) throw new Error('network down')
return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })
}) as unknown as typeof fetch
const errors: unknown[] = []
let rotated = 0
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer,
renewBeforeMs: 1000,
retryBackoff: createBackoff({ baseMs: 500, jitter: false }),
fetchImpl,
now: () => new Date(0),
parseCert: () => new Date(2000), // initial renewal scheduled at ~1000ms
})
rotator.onError((e) => errors.push(e))
rotator.onRotated(() => {
rotated += 1
})
rotator.start()
timer.advance(1000) // first attempt fires → throws
await flush()
expect(errors).toHaveLength(1)
expect(rotated).toBe(0)
// The retry is armed at the 500ms backoff delay, NOT renewBeforeMs (1000): advancing only 500
// must fire it. A crash-loop never escapes here (the supervisor keeps running).
timer.advance(500)
await flush()
expect(rotated).toBe(1)
expect(ks.loadCert()!.certPem).toContain('NEWCERT')
rotator.stop() rotator.stop()
rmSync(dir, { recursive: true, force: true }) rmSync(dir, { recursive: true, force: true })
}) })

View File

@@ -66,6 +66,25 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` + - [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600). `ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
## Projects / git parity (W5 — presenters JVM-tested, Compose device-QA)
- [ ] Project card **sync chip**: `↑ahead` / `↓behind` render only when non-zero; no chip when there is
no upstream (fields absent).
- [ ] Project detail **PR chip**: `availability=ok` → tappable chip opens the PR in the browser ONLY when
the url is https (a non-https / junk url is inert, non-clickable); `no-pr` / `not-installed` /
`unauthenticated` / `disabled` / `error` each render the degraded copy inertly; check-count colour
(fail=red / pending=amber / pass=green).
- [ ] Project detail **recent commits**: list renders short-hash + subject inertly; unavailable state on a
log failure does NOT hide the rest of the detail (failure-isolated).
- [ ] **New worktree** inline form: valid `branch` (+optional `base`) → create → list refreshes; an invalid
branch name is rejected with NO network call; a disabled-403 shows the server's safe message.
- [ ] Per-worktree **remove**: the button is absent on the `main` worktree; the confirm dialog offers a
**Force** checkbox; a dirty-worktree 409 surfaces "force required" inertly; **prune** button works.
- [ ] Diff **base-rev** input: entering a rev enters base mode (Working/Staged toggle hidden, `vs <rev>`
shown, git-write controls hidden); Clear returns to working/staged; junk rev → server 400 surfaced.
- [ ] Diff **stage/unstage**: per-file button (Working→"暂存", Staged→"取消暂存") posts the file and
refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ;
**push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited).
## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md) ## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md)
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't - [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't
carry the sessionId; the gate is still visible in the terminal). MEDIUM. carry the sessionId; the gate is still visible in the terminal). MEDIUM.

View File

@@ -9,22 +9,20 @@ This directory is a **Gradle multi-module** project. The module set mirrors the
SPM package set and inherits its rule: *dependencies only flow down; nothing points SPM package set and inherits its rule: *dependencies only flow down; nothing points
upward* (ARCHITECTURE §1). upward* (ARCHITECTURE §1).
## ⚠️ No-SDK constraint (why only 5 modules build here) ## Build environment (SDK installed — all modules build)
The current build environment has **no Android SDK**. Everything that can be pure The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
**Kotlin/JVM** (`kotlin("jvm")`) is built and unit-tested now; anything that needs the alike — builds and unit-tests here. AGP 9.2.1 (built-in Kotlin) + Gradle 9.6.1 build
Android framework (`com.android.*` plugins) is **scaffolded but disabled**. against SDK 35/36.
- **Enabled now (pure Kotlin/JVM, `./gradlew test`-able):** - **Pure Kotlin/JVM (`./gradlew test`):** `:wire-protocol`, `:session-core`, `:api-client`,
`:wire-protocol`, `:session-core`, `:api-client`, `:client-tls`, `:test-support`. `:client-tls`, `:test-support`, `:transport-okhttp`.
- **Scaffolded but COMMENTED OUT** in [`settings.gradle.kts`](settings.gradle.kts) - **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
(dirs + a `build.gradle.kts` stub exist, marked `// TODO(android-sdk)`):
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`. `:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
To bring the Android modules online later: install an SDK, add Setup: `local.properties``sdk.dir=/usr/local/share/android-commandlinetools`;
`local.properties``sdk.dir`, add the Android Gradle Plugin + `google()` to `google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
`pluginManagement`, then uncomment the `include(...)` lines and the plugin blocks in `./gradlew test :app:assembleDebug koverVerify`.
each stub.
## Module map (mirror of the iOS SPM packages — plan §3) ## Module map (mirror of the iOS SPM packages — plan §3)
@@ -35,10 +33,10 @@ each stub.
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built | | APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built | | ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built | | TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ⏸ SDK-gated | | ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ✅ built |
| HostRegistry | `:host-registry` | Android (DataStore) | ⏸ SDK-gated | | HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ⏸ SDK-gated | | SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ⏸ SDK-gated | | App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport` > Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
> impls, JVM) is owned by task **A7** and will be added then. The iOS > impls, JVM) is owned by task **A7** and will be added then. The iOS
@@ -47,16 +45,16 @@ each stub.
### Dependency graph (arrows = "depends on") ### Dependency graph (arrows = "depends on")
``` ```
:app (SDK-gated) :app
┌───────────────┬───┴────┬──────────────┬───────────────┐ ┌───────────────┬───┴────┬──────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
:terminal-view :session-core :api-client :host-registry :client-tls-android :terminal-view :session-core :api-client :host-registry :client-tls-android
(SDK-gated) │ │ (SDK-gated) │ │
│ │ │ ▼ │ │ │ ▼
│ │ │ :client-tls (pure) │ │ │ :client-tls (pure)
└──────┬───────┴──────────┴──────────────┬────────────────┘ └──────┬───────┴──────────┴──────────────┬────────────────┘
▼ ▼ ▼ ▼
:wire-protocol ◀──────────── :transport-okhttp (A7, not yet) :wire-protocol ◀──────────── :transport-okhttp
└──────── :test-support → test source sets only └──────── :test-support → test source sets only
``` ```

View File

@@ -0,0 +1,172 @@
package wang.yaojia.webterm.api.enroll
/**
* B4 · Manual, canonical-DER encoder for a P-256 PKCS#10 `CertificationRequest` — a byte-for-byte
* port of the iOS `ClientTLS.CertificateSigningRequest`.
*
* Built by hand (no JCA CSR helper) so the exact bytes are under our control and the request is
* signed by the [CsrSigner] (an AndroidKeyStore hardware key in production, a software P-256 key in
* tests) via `SHA256withECDSA`. The output must satisfy the control-plane `verifyCsrPoPEc`: an EC
* P-256 `SubjectPublicKeyInfo` (`id-ecPublicKey` + `prime256v1`), an `ecdsa-with-SHA256`
* self-signature, and a valid PoP. Encoding is strictly canonical DER (minimal lengths) so the
* server's re-serialization of `CertificationRequestInfo` matches the bytes we signed.
*
* ```
* CertificationRequest ::= SEQUENCE {
* certificationRequestInfo CertificationRequestInfo,
* signatureAlgorithm AlgorithmIdentifier, -- ecdsa-with-SHA256
* signature BIT STRING } -- X9.62 DER ECDSA-Sig
*
* CertificationRequestInfo ::= SEQUENCE {
* version INTEGER { v1(0) },
* subject Name,
* subjectPKInfo SubjectPublicKeyInfo,
* attributes [0] IMPLICIT SET OF Attribute } -- empty
* ```
*/
public object CertificateSigningRequest {
/** P-256 uncompressed public point is `0x04 || X(32) || Y(32)` = 65 bytes. */
private const val UNCOMPRESSED_P256_POINT_LENGTH = 65
/**
* Build and self-sign a P-256 PKCS#10 CSR DER for [signer]'s key.
*
* @param subjectCommonName the CSR subject CN. The device leaf's identity is driven server-side
* by the ownership-verified subdomain SAN, so this is descriptive only; it must be non-empty.
* @param signer the P-256 hardware key that provides the public key and signs the
* `CertificationRequestInfo`.
* @throws CsrException.InvalidSubject on an empty CN; [CsrException.InvalidPublicKey] if the
* signer's public key is not a 65-byte X9.63 P-256 point.
*/
public fun der(subjectCommonName: String, signer: CsrSigner): ByteArray {
if (subjectCommonName.isEmpty()) throw CsrException.InvalidSubject
val publicPoint = signer.publicKeyX963()
if (publicPoint.size != UNCOMPRESSED_P256_POINT_LENGTH || publicPoint[0].toInt() != 0x04) {
throw CsrException.InvalidPublicKey
}
val requestInfo = certificationRequestInfo(subjectCommonName, publicPoint)
val signature = signer.sign(requestInfo)
return DerWriter.sequence(
listOf(
requestInfo,
ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER,
DerWriter.bitString(signature),
),
)
}
// ── CertificationRequestInfo ────────────────────────────────────────────────────────────
private fun certificationRequestInfo(subjectCommonName: String, publicPoint: ByteArray): ByteArray =
DerWriter.sequence(
listOf(
DerWriter.INTEGER_0, // version v1(0)
name(subjectCommonName),
subjectPublicKeyInfo(publicPoint),
DerWriter.EMPTY_ATTRIBUTES_CONTEXT0, // [0] IMPLICIT SET OF Attribute (empty)
),
)
/** `Name ::= SEQUENCE OF RelativeDistinguishedName` with a single CN RDN. */
private fun name(commonName: String): ByteArray {
val attribute = DerWriter.sequence(
listOf(DerWriter.oid(Oid.COMMON_NAME), DerWriter.utf8String(commonName)),
)
val rdn = DerWriter.set(listOf(attribute))
return DerWriter.sequence(listOf(rdn))
}
/**
* `SubjectPublicKeyInfo` for an EC P-256 key: `id-ecPublicKey` + `prime256v1` named curve, then
* the uncompressed point as a BIT STRING.
*/
private fun subjectPublicKeyInfo(publicPoint: ByteArray): ByteArray {
val algorithm = DerWriter.sequence(
listOf(DerWriter.oid(Oid.EC_PUBLIC_KEY), DerWriter.oid(Oid.PRIME256V1)),
)
return DerWriter.sequence(listOf(algorithm, DerWriter.bitString(publicPoint)))
}
/**
* `AlgorithmIdentifier` for `ecdsa-with-SHA256` — no parameters (absent, per RFC 5758), which is
* exactly what the server's verifier expects.
*/
private val ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER: ByteArray =
DerWriter.sequence(listOf(DerWriter.oid(Oid.ECDSA_WITH_SHA256)))
}
/** Object identifiers (DER content bytes; tag/length added by [DerWriter.oid]). */
private object Oid {
/** 1.2.840.10045.2.1 — id-ecPublicKey. */
val EC_PUBLIC_KEY = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01)
/** 1.2.840.10045.3.1.7 — prime256v1 / secp256r1. */
val PRIME256V1 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07)
/** 1.2.840.10045.4.3.2 — ecdsa-with-SHA256. */
val ECDSA_WITH_SHA256 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02)
/** 2.5.4.3 — id-at-commonName. */
val COMMON_NAME = byteArrayOf(0x55, 0x04, 0x03)
}
/**
* A tiny canonical-DER encoder. Every helper returns a fully-formed TLV so callers just concatenate
* children — canonical minimal-length encoding throughout. Internal so its byte layout is
* unit-testable in isolation.
*/
internal object DerWriter {
private const val TAG_INTEGER: Byte = 0x02
private const val TAG_BIT_STRING: Byte = 0x03
private const val TAG_OID: Byte = 0x06
private const val TAG_UTF8_STRING: Byte = 0x0C
private const val TAG_SEQUENCE: Byte = 0x30
private const val TAG_SET: Byte = 0x31
private const val TAG_CONTEXT0_CONSTRUCTED: Byte = 0xA0.toByte()
/** `INTEGER 0` — the fixed PKCS#10 version v1(0). */
val INTEGER_0: ByteArray = byteArrayOf(TAG_INTEGER, 0x01, 0x00)
/** `[0] IMPLICIT SET OF Attribute`, empty — `A0 00`. */
val EMPTY_ATTRIBUTES_CONTEXT0: ByteArray = byteArrayOf(TAG_CONTEXT0_CONSTRUCTED, 0x00)
fun sequence(children: List<ByteArray>): ByteArray = tlv(TAG_SEQUENCE, concat(children))
fun set(children: List<ByteArray>): ByteArray = tlv(TAG_SET, concat(children))
fun oid(content: ByteArray): ByteArray = tlv(TAG_OID, content)
fun utf8String(value: String): ByteArray = tlv(TAG_UTF8_STRING, value.encodeToByteArray())
/** BIT STRING with zero unused bits (all our bit strings are byte-aligned). */
fun bitString(content: ByteArray): ByteArray = tlv(TAG_BIT_STRING, byteArrayOf(0x00) + content)
/** Tag-Length-Value with canonical DER length encoding. */
private fun tlv(tag: Byte, value: ByteArray): ByteArray = byteArrayOf(tag) + length(value.size) + value
/** DER length: short form (<128) or long form (`0x80 | byteCount`, big-endian). */
private fun length(count: Int): ByteArray {
if (count < 0x80) return byteArrayOf(count.toByte())
var value = count
val bytes = ArrayDeque<Byte>()
while (value > 0) {
bytes.addFirst((value and 0xFF).toByte())
value = value ushr 8
}
return byteArrayOf((0x80 or bytes.size).toByte()) + bytes.toByteArray()
}
private fun concat(chunks: List<ByteArray>): ByteArray {
val total = chunks.sumOf { it.size }
val out = ByteArray(total)
var offset = 0
for (chunk in chunks) {
System.arraycopy(chunk, 0, out, offset, chunk.size)
offset += chunk.size
}
return out
}
}

View File

@@ -0,0 +1,37 @@
package wang.yaojia.webterm.api.enroll
/**
* B4 · The signing key abstraction the PKCS#10 CSR encoder ([CertificateSigningRequest]) drives —
* the Android analogue of iOS `P256HardwareKey`.
*
* In production this is backed by a NON-EXPORTABLE P-256 key living inside AndroidKeyStore
* (StrongBox → TEE), so `sign` runs inside secure hardware and the private key never leaves it
* (`:client-tls-android` `HardwareBackedKey`). In JVM unit tests it is backed by a software P-256
* key via the SAME `Signature("SHA256withECDSA")` path, so the CSR-encoding bytes are exercised
* identically without an emulator.
*/
public interface CsrSigner {
/**
* The public key in ANSI X9.63 uncompressed form: `0x04 || X(32) || Y(32)` (65 bytes for
* P-256). This is exactly what wraps into the CSR's `SubjectPublicKeyInfo` BIT STRING.
*/
public fun publicKeyX963(): ByteArray
/**
* ECDSA-sign `message` over SHA-256, returning the X9.62 DER signature
* (`SEQUENCE { r INTEGER, s INTEGER }`) — exactly the shape a PKCS#10 `signature` BIT STRING
* and the server's `verifyCsrPoPEc` expect. The digest is computed by the algorithm
* (`SHA256withECDSA`), so callers pass the raw message (the DER of `CertificationRequestInfo`),
* NOT a pre-hash.
*/
public fun sign(message: ByteArray): ByteArray
}
/** Structural failures building a CSR — the client refuses to emit a malformed request. */
public sealed class CsrException(message: String) : Exception(message) {
/** The signer's public key was not the expected 65-byte X9.63 uncompressed P-256 point. */
public data object InvalidPublicKey : CsrException("CSR public key is not a 65-byte X9.63 P-256 point")
/** The subject CN was empty (not encodable / rejected by the server). */
public data object InvalidSubject : CsrException("CSR subject common name must not be empty")
}

View File

@@ -0,0 +1,184 @@
package wang.yaojia.webterm.api.enroll
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
/**
* B4 · Talks to the control-plane device-enrollment API over the shared [HttpTransport] seam (the
* same seam `:transport-okhttp` implements and `:test-support` fakes), so the enroll flow rides the
* app's normal HTTP stack. Android analogue of iOS `DeviceEnrollmentClient`, extended with the login
* step (B4 pinned contract):
*
* `POST /auth/login` `{ password }` → 201 `{ enrollToken, accountId, expiresIn }`
* `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain,
* deviceName, attestation? }` → 201 `{ deviceId, cert, caChain,
* notBefore, notAfter, renewAfter }`
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The
* server schema is `.strict()`; NO keyAlg/subdomain/deviceName.
*
* Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is
* sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs
* are standard-base64 (`bytesToBase64` = Node `Buffer.toString('base64')`).
*
* Immutable: constructed once with a [baseUrl] + [http]; the short-lived enroll bearer is passed
* per-call and never held/logged (leaked-bearer blast radius).
*/
public class DeviceEnrollmentClient(
baseUrl: String,
private val http: HttpTransport,
) {
/** Base control-plane URL with any trailing slash removed, so `base + path` is well-formed. */
private val base: String = baseUrl.trim().trimEnd('/')
/**
* One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is
* rejected client-side (`InvalidRequest`) before any network I/O — never send a blank credential.
*/
public suspend fun login(password: String): LoginResult {
if (password.isEmpty()) throw DeviceEnrollmentError.InvalidRequest
val body = EnrollJson.encodeToString(LoginRequestBody.serializer(), LoginRequestBody(password))
val response = http.send(jsonRequest(HttpMethod.POST, PATH_LOGIN, body.encodeToByteArray(), bearer = null))
val dto = decodeOn201(response, LoginResponseDto.serializer())
return LoginResult(
enrollToken = dto.enrollToken,
accountId = dto.accountId,
expiresInSeconds = dto.expiresIn,
)
}
/**
* Enroll a freshly-generated hardware key: POST the [csrDer] under the enroll [bearerToken],
* receive the leaf. Required fields are validated client-side (`InvalidRequest`) before any I/O.
*/
public suspend fun enroll(
bearerToken: String,
csrDer: ByteArray,
subdomain: String,
deviceName: String,
attestation: String? = null,
): EnrollmentResult {
if (bearerToken.isEmpty() || csrDer.isEmpty() || subdomain.isEmpty() || deviceName.isEmpty()) {
throw DeviceEnrollmentError.InvalidRequest
}
val body = EnrollJson.encodeToString(
EnrollRequestBody.serializer(),
EnrollRequestBody(
csr = base64(csrDer),
keyAlg = KEY_ALG_EC_P256,
subdomain = subdomain,
deviceName = deviceName,
attestation = attestation,
),
)
val response = http.send(jsonRequest(HttpMethod.POST, PATH_ENROLL, body.encodeToByteArray(), bearerToken))
return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
}
/**
* Renew against the SAME hardware key: a fresh CSR to `/device/:id/renew` (silent-rotation seam).
*
* The renew endpoint is authenticated by the CURRENT device certificate over mTLS — the body is
* `{ csr }` ONLY and NO bearer is sent (mirrors iOS, which passes `bearerToken: nil`). [bearerToken]
* is therefore OPTIONAL and defaults to absent; the `Authorization` header is omitted when it is
* null/blank. The seam still accepts a bearer for a hypothetical bearer-authenticated renew, but the
* production caller passes none. [deviceId] and [csrDer] are validated client-side before any I/O.
*/
public suspend fun renew(deviceId: String, csrDer: ByteArray, bearerToken: String? = null): EnrollmentResult {
if (deviceId.isEmpty() || csrDer.isEmpty()) {
throw DeviceEnrollmentError.InvalidRequest
}
// Body is `{ csr }` ONLY — the renew endpoint authenticates by the presented mTLS device cert
// and its schema is `.strict()`, so any enroll-only extra (keyAlg/subdomain/deviceName) is
// rejected. Identity/key come from the current cert + registry record, never the body.
val body = EnrollJson.encodeToString(
RenewRequestBody.serializer(),
RenewRequestBody(csr = base64(csrDer)),
)
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew"
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken))
return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
}
// ── Request/response plumbing ────────────────────────────────────────────────────────────
private fun jsonRequest(
method: HttpMethod,
path: String,
jsonBody: ByteArray,
bearer: String?,
): HttpRequest {
val headers = LinkedHashMap<String, String>()
headers[HEADER_CONTENT_TYPE] = CONTENT_TYPE_JSON
// Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty
// string must never emit a bare "Bearer " header.
if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer"
return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody)
}
/** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error`
* code (never the raw body); an undecodable 201 body → [DeviceEnrollmentError.MalformedResponse]. */
private fun <T> decodeOn201(response: HttpResponse, serializer: kotlinx.serialization.KSerializer<T>): T {
if (response.status != HTTP_CREATED) {
throw DeviceEnrollmentError.Http(response.status, errorCode(response.body))
}
return runCatching { EnrollJson.decodeFromString(serializer, response.body.decodeToString()) }
.getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse
}
private fun toResult(dto: EnrollResponseDto): EnrollmentResult {
val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse
val chain = dto.caChain.map { entry ->
decodeBase64OrNull(entry) ?: throw DeviceEnrollmentError.MalformedResponse
}
return EnrollmentResult(
deviceId = dto.deviceId,
certificate = certificate,
caChain = chain,
notBefore = parseInstantOrNull(dto.notBefore),
notAfter = parseInstantOrNull(dto.notAfter),
renewAfter = parseInstantOrNull(dto.renewAfter),
)
}
private fun errorCode(body: ByteArray): String? =
runCatching { EnrollJson.decodeFromString(ErrorDto.serializer(), body.decodeToString()).error }.getOrNull()
private companion object {
const val PATH_LOGIN = "/auth/login"
const val PATH_ENROLL = "/device/enroll"
const val PATH_DEVICE = "/device"
const val KEY_ALG_EC_P256 = "ec-p256"
const val HTTP_CREATED = 201
const val HEADER_CONTENT_TYPE = "Content-Type"
const val HEADER_AUTHORIZATION = "Authorization"
const val CONTENT_TYPE_JSON = "application/json"
const val BEARER_PREFIX = "Bearer "
private val BASE64_ENCODER = java.util.Base64.getEncoder()
private val BASE64_DECODER = java.util.Base64.getDecoder()
fun base64(bytes: ByteArray): String = BASE64_ENCODER.encodeToString(bytes)
fun decodeBase64OrNull(text: String): ByteArray? =
runCatching { BASE64_DECODER.decode(text) }.getOrNull()
/** Percent-encode a `:id` path segment's non-unreserved bytes (defence: device ids are
* server-minted UUIDs, but never build a URL from an unescaped field). */
fun encodePathSegment(value: String): String {
val sb = StringBuilder()
for (byte in value.encodeToByteArray()) {
val code = byte.toInt() and 0xFF
val ch = code.toChar()
if (ch in UNRESERVED) sb.append(ch) else sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F])
}
return sb.toString()
}
private val UNRESERVED: Set<Char> =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".toSet()
private val HEX = "0123456789ABCDEF".toCharArray()
}
}

View File

@@ -0,0 +1,54 @@
package wang.yaojia.webterm.api.enroll
import java.math.BigInteger
import java.security.interfaces.ECPublicKey
/**
* B4 · Pure encoder from a JCA [ECPublicKey] to the ANSI X9.63 uncompressed point
* `0x04 || X || Y` that a P-256 `SubjectPublicKeyInfo` BIT STRING carries.
*
* Kept in the pure `:api-client` module (no Android dependency) so it is reused by BOTH the
* JVM-unit-test software signer AND the framework `HardwareBackedKey` (`:client-tls-android`),
* and so this security-load-bearing byte layout is unit-tested at JVM speed.
*/
public object EcPointEncoding {
/** P-256 field element width in bytes (256 bits). */
public const val P256_COORDINATE_BYTES: Int = 32
/** Uncompressed-point prefix (`0x04`) per SEC 1 §2.3.3. */
private const val UNCOMPRESSED_PREFIX: Byte = 0x04
/**
* Encode [publicKey]'s affine (x, y) as `0x04 || X(32) || Y(32)` (65 bytes). Each coordinate is
* an unsigned big-endian integer left-padded (or, defensively, high-byte-trimmed) to exactly
* [P256_COORDINATE_BYTES]. Throws [IllegalArgumentException] if a coordinate genuinely does not
* fit 32 bytes (i.e. the key is not on a 256-bit curve).
*/
public fun x963(publicKey: ECPublicKey): ByteArray {
val point = publicKey.w
val x = toFixedLengthUnsigned(point.affineX, P256_COORDINATE_BYTES)
val y = toFixedLengthUnsigned(point.affineY, P256_COORDINATE_BYTES)
val out = ByteArray(1 + P256_COORDINATE_BYTES * 2)
out[0] = UNCOMPRESSED_PREFIX
System.arraycopy(x, 0, out, 1, P256_COORDINATE_BYTES)
System.arraycopy(y, 0, out, 1 + P256_COORDINATE_BYTES, P256_COORDINATE_BYTES)
return out
}
/**
* Convert a non-negative [value] to a big-endian byte array of exactly [length] bytes. A
* `BigInteger` may carry a leading 0x00 sign byte (drop it) or be shorter than [length]
* (left-pad with zeros). A value that needs MORE than [length] significant bytes is rejected —
* silently truncating a coordinate would corrupt the key.
*/
internal fun toFixedLengthUnsigned(value: BigInteger, length: Int): ByteArray {
require(value.signum() >= 0) { "EC coordinate must be non-negative" }
val raw = value.toByteArray() // big-endian, possibly with a leading 0x00 sign byte
val start = if (raw.size > length && raw[0].toInt() == 0) 1 else 0
val significant = raw.size - start
require(significant <= length) { "EC coordinate does not fit $length bytes (got $significant)" }
val out = ByteArray(length)
System.arraycopy(raw, start, out, length - significant, significant)
return out
}
}

View File

@@ -0,0 +1,124 @@
package wang.yaojia.webterm.api.enroll
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.time.Instant
/**
* B4 · Typed result of the one-time operator login (`POST /auth/login`). The [enrollToken] is a
* short-lived `device:enroll` bearer — hold it ONLY for the immediately-following enroll call and
* NEVER persist or log it. [accountId] identifies the tenant the device will be scoped under.
*/
public data class LoginResult(
val enrollToken: String,
val accountId: String,
val expiresInSeconds: Long,
)
/**
* B4 · Typed result of a successful `POST /device/enroll` (or `/device/:id/renew`): the issued leaf
* plus its issuer chain and rotation timing. The private key is NOT here — it stays non-exportable
* in AndroidKeyStore. Mirrors iOS `EnrollmentResult`.
*
* NOTE: [certificate]/[caChain] are `ByteArray`, so the generated `data class` equality is by
* reference (transient DER carriers, not value-equality keys) — compare with `contentEquals`.
*/
public data class EnrollmentResult(
val deviceId: String,
/** Leaf certificate DER (decoded from the response's base64). */
val certificate: ByteArray,
/** Issuer chain DERs (device-CA etc.), leaf excluded. */
val caChain: List<ByteArray>,
val notBefore: Instant?,
val notAfter: Instant?,
/** When to renew from the same hardware key (~2/3 of the lifetime). */
val renewAfter: Instant?,
) {
/**
* The rotation seam: is the leaf due for renewal as of [now]? A missing [renewAfter] never
* triggers (fail-safe — the TLS stack is the real gate; the scheduler only pre-empts expiry).
*/
public fun isRenewalDue(now: Instant = Instant.now()): Boolean {
val due = renewAfter ?: return false
return !now.isBefore(due) // now >= renewAfter
}
}
/** Typed failures for the device-enrollment surface. Transport-level errors propagate UNWRAPPED. */
public sealed class DeviceEnrollmentError(message: String) : Exception(message) {
/**
* A non-success HTTP status with the server's uniform `{ error }` code, if any (401
* missing/rejected token, 403 subdomain-not-owned, 429 rate_limited, 400 rejected
* CSR/subdomain). Never leaks the response body.
*/
public data class Http(val status: Int, val code: String?) :
DeviceEnrollmentError("device enrollment rejected: HTTP $status" + (code?.let { " ($it)" } ?: ""))
/** A success body that did not decode to the expected shape (or an undecodable base64 cert). */
public data object MalformedResponse :
DeviceEnrollmentError("device enrollment response was not the expected shape")
/** A required request field was empty — rejected client-side BEFORE any network I/O. */
public data object InvalidRequest :
DeviceEnrollmentError("device enrollment request was missing a required field")
}
// ── Wire DTOs + JSON config (internal to the enroll package) ─────────────────────────────────────
/**
* ENCODE omits absent optionals (`encodeDefaults = false` drops the default-null `attestation`;
* `explicitNulls = false` never writes an explicit `null`) and DECODE is tolerant of unknown keys
* (the server is untrusted at this boundary). `keyAlg` carries NO default, so it is ALWAYS encoded.
*/
internal val EnrollJson: Json = Json {
encodeDefaults = false
explicitNulls = false
ignoreUnknownKeys = true
isLenient = true
}
@Serializable
internal data class LoginRequestBody(val password: String)
@Serializable
internal data class EnrollRequestBody(
val csr: String,
val keyAlg: String,
val subdomain: String,
val deviceName: String,
val attestation: String? = null,
)
/**
* The `/device/:id/renew` request body. The endpoint authenticates by the presented mTLS device cert
* and its server schema is `{ csr }` ONLY (`.strict()`), so it carries the single new CSR and NO
* enroll-only fields (keyAlg/subdomain/deviceName) — an extra key would be rejected as a 400.
*/
@Serializable
internal data class RenewRequestBody(val csr: String)
@Serializable
internal data class LoginResponseDto(
val enrollToken: String,
val accountId: String,
val expiresIn: Long,
)
@Serializable
internal data class EnrollResponseDto(
val deviceId: String,
val cert: String,
val caChain: List<String> = emptyList(),
val notBefore: String? = null,
val notAfter: String? = null,
val renewAfter: String? = null,
)
@Serializable
internal data class ErrorDto(val error: String? = null)
/** Parse an ISO-8601 instant, degrading an absent/unparseable value to null (dates are advisory). */
internal fun parseInstantOrNull(text: String?): Instant? {
if (text == null) return null
return runCatching { Instant.parse(text) }.getOrNull()
}

View File

@@ -0,0 +1,32 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
/**
* One commit from `git log` (`src/types.ts` `CommitLogEntry`). [hash] and [at] are REQUIRED — a
* commit missing either is dropped by the list-lossy [CommitLogEntryListSerializer] (its siblings
* survive). [subject] defaults to empty so a subject-less commit still decodes. `at` = `%ct * 1000`
* (epoch millis). All fields are rendered INERT (plain text; no autolink) at the screen (plan §8).
*/
@Serializable
public data class CommitLogEntry(
val hash: String,
val at: Long,
val subject: String = "",
)
/**
* `GET /projects/log` result (`src/types.ts` `GitLogResult`). [truncated] = more commits exist
* beyond the server cap. The commit list decodes lossily (drop-one-keep-rest).
*/
@Serializable
public data class GitLogResult(
@Serializable(with = CommitLogEntryListSerializer::class)
val commits: List<CommitLogEntry> = emptyList(),
val truncated: Boolean = false,
)
/** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */
internal object CommitLogEntryListSerializer :
KSerializer<List<CommitLogEntry>> by LossyListSerializer(CommitLogEntry.serializer())

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
/**
* A client result union for the six guarded git-write ops (worktree create/remove/prune, git
* stage/commit/push). It carries the server's SAFE body only — never raw git stderr (the server
* classifies + sanitizes every failure, `src/http/git-ops.ts` / `worktrees.ts`, SEC-M10):
*
* - [Ok] — a 200 with the op's route-specific payload [T].
* - [Rejected] — a 4xx/5xx with the server's inert `error` string ([message]) to display verbatim.
* 403 is OVERLOADED (Origin-guard failure AND the disabled kill-switch both 403) so the client
* cannot tell them apart by status — it surfaces [message] inertly rather than inventing a typed
* variant (plan Edge cases / Security).
* - [RateLimited] — a 429 (stage/commit share one limiter, push a tighter one). Do NOT auto-retry.
*
* Nothing here throws on a bad body: a missing/garbled payload degrades to defaults (empty sha,
* empty pruned list) rather than crashing (tolerant-decode discipline, plan §8).
*/
public sealed interface GitWriteOutcome<out T> {
/** 200 — the op succeeded; [payload] is the route-specific success body. */
public data class Ok<out T>(val payload: T) : GitWriteOutcome<T>
/** A 4xx/5xx failure carrying the server's SAFE [message] (inert; may be null if unparseable). */
public data class Rejected(val status: Int, val message: String?) : GitWriteOutcome<Nothing>
/** 429 — the server rate-limited this write. */
public data object RateLimited : GitWriteOutcome<Nothing>
}
// ── Per-op 200 payloads (all fields optional/defaulted → a garbled body degrades, never throws) ──
/** `POST /projects/git/stage` 200 → `{ ok, staged, count }`. */
@Serializable
public data class StageResult(val staged: Boolean = false, val count: Int = 0)
/** `POST /projects/git/commit` 200 → `{ ok, commit }` (short sha; may be `""` — empty is valid). */
@Serializable
public data class CommitResult(val commit: String = "")
/** `POST /projects/git/push` 200 → `{ ok, branch, remote }`. */
@Serializable
public data class PushResult(val branch: String? = null, val remote: String? = null)
/** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */
@Serializable
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null)
/** `DELETE /projects/worktree` 200 → `{ ok, path }` (git's canonical removed path). */
@Serializable
public data class RemoveWorktreeResult(val path: String? = null)
/** `POST /projects/worktree/prune` 200 → `{ ok, pruned: [...] }` (empty = nothing to prune). */
@Serializable
public data class PruneWorktreesResult(val pruned: List<String> = emptyList())
/** Shape of a failure body — worktree routes emit `{ error }`, git-ops `{ ok:false, error }`; both
* carry `error` as a SAFE string. Decoded to surface [error] inertly. */
@Serializable
internal data class GitErrorBody(val ok: Boolean = false, val error: String? = null)
/**
* Decode a guarded 200 body into [T], degrading a missing/garbled body to the payload's defaults
* (never throws — the caller already knows the status is 200).
*/
internal fun <T> decodeGitPayload(bytes: ByteArray, deserializer: kotlinx.serialization.KSerializer<T>): T =
LossyDecode.objectOrNull(bytes, deserializer) ?: ModelJson.decodeFromString(deserializer, "{}")
/** Read the SAFE `error` string from a failure body; null when the body is empty/unparseable. */
internal fun decodeGitError(bytes: ByteArray): String? =
LossyDecode.objectOrNull(bytes, GitErrorBody.serializer())?.error

View File

@@ -0,0 +1,88 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* Why a [PrStatus] has (or lacks) PR data (`src/types.ts` `PrAvailability`). Drives the detail
* chip's copy. Decoded via [PrAvailabilitySerializer]: an unknown/future value **degrades to
* [ERROR]** (never throws) — a new server availability must never make the chip crash.
*/
public enum class PrAvailability(public val wire: String) {
/** A PR exists for the current branch; the sibling fields are populated. */
OK("ok"),
/** gh works but the branch has no PR (or no remote/default repo). */
NO_PR("no-pr"),
/** `gh` binary not found on PATH (ENOENT). */
NOT_INSTALLED("not-installed"),
/** gh present but not logged in (needs `gh auth login`). */
UNAUTHENTICATED("unauthenticated"),
/** `GH_ENABLED=0` — feature off, never spawns gh. */
DISABLED("disabled"),
/** gh spawned but failed for another reason (timeout, etc.); also the unknown/missing fallback. */
ERROR("error"),
;
public companion object {
/** Map the wire string; unknown → [ERROR] (mirror of the FE never treating non-`ok` as fatal). */
public fun fromWire(wire: String): PrAvailability =
entries.firstOrNull { it.wire == wire } ?: ERROR
}
}
/**
* Decode [PrAvailability] by its `wire` value; an unknown/future value maps to [PrAvailability.ERROR]
* rather than throwing (mirror of [ClaudeStatusSerializer]). Serializes back the `wire` string.
*/
internal object PrAvailabilitySerializer : KSerializer<PrAvailability> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("PrAvailability", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): PrAvailability =
PrAvailability.fromWire(decoder.decodeString())
override fun serialize(encoder: Encoder, value: PrAvailability) =
encoder.encodeString(value.wire)
}
/** Rolled-up CI check counts from gh's statusCheckRollup (`src/types.ts` `PrCheckSummary`). */
@Serializable
public data class PrCheckSummary(
val total: Int = 0,
val passing: Int = 0,
val failing: Int = 0,
val pending: Int = 0,
)
/**
* `GET /projects/pr` result (`src/types.ts` `PrStatus`). Every field except [availability] is
* optional (present only when `availability == ok`); [availability] itself defaults to
* [PrAvailability.ERROR] so a body missing the field still decodes (never throws). `state` /
* `mergeable` are lower-cased string unions on the wire — kept as raw INERT strings here (rendered
* as plain text; no enum needed for display).
*/
@Serializable
public data class PrStatus(
@Serializable(with = PrAvailabilitySerializer::class)
val availability: PrAvailability = PrAvailability.ERROR,
val number: Int? = null,
val title: String? = null,
val url: String? = null,
val state: String? = null,
val isDraft: Boolean? = null,
val mergeable: String? = null,
val headRefName: String? = null,
val baseRefName: String? = null,
val checks: PrCheckSummary? = null,
)

View File

@@ -34,6 +34,12 @@ public data class ProjectInfo(
val dirty: Boolean? = null, val dirty: Boolean? = null,
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */ /** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
val lastActiveMs: Long? = null, val lastActiveMs: Long? = null,
/** W3 sync chip — commits on HEAD not on `@{u}` (best-effort; absent when no upstream). */
val ahead: Int? = null,
/** W3 sync chip — commits on `@{u}` not on HEAD (best-effort; absent when no upstream). */
val behind: Int? = null,
/** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */
val lastCommitMs: Long? = null,
@Serializable(with = ProjectSessionRefListSerializer::class) @Serializable(with = ProjectSessionRefListSerializer::class)
val sessions: List<ProjectSessionRef> = emptyList(), val sessions: List<ProjectSessionRef> = emptyList(),
) )

View File

@@ -1,13 +1,24 @@
package wang.yaojia.webterm.api.routes package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.HookDecision import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.LiveSessionInfo import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.LossyDecode import wang.yaojia.webterm.api.models.LossyDecode
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.SessionPreview import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.models.UiConfig import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport import wang.yaojia.webterm.wire.HttpTransport
@@ -87,6 +98,43 @@ public class ApiClient(
} }
} }
/**
* `GET /projects/pr?path=` — PR + CI status for the project's current branch. The PR *degrade*
* (gh missing / unauth / no-PR / disabled) is `availability` inside a **200** body, NOT an HTTP
* status — so every valid git dir returns 200 and the chip renders from [PrStatus.availability].
* A garbled body degrades to `availability=ERROR` (tolerant decode). 400→path invalid; 404→not a
* repo. Empty path rejected client-side before any I/O.
*/
public suspend fun projectPr(path: String): PrStatus {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.projectPr(path))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, PrStatus.serializer())
?: PrStatus() // availability defaults to ERROR — never throw on a bad PR body
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/**
* `GET /projects/log?path=[&n=]` — recent commits (list-lossy: malformed commits dropped). 400→
* path invalid; 404→not a repo; 500→[ApiClientError.GitLogUnavailable]. Empty path rejected
* client-side before any I/O; `n` is clamped in the route builder.
*/
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.projectLog(path, n))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, GitLogResult.serializer())
?: throw ApiClientError.InvalidResponseBody
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.GitLogUnavailable
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws /** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */ * `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
public suspend fun prefs(): UiPrefs { public suspend fun prefs(): UiPrefs {
@@ -132,6 +180,50 @@ public class ApiClient(
} }
} }
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
public suspend fun createWorktree(path: String, branch: String, base: String? = null): GitWriteOutcome<CreateWorktreeResult> =
gitWrite(Endpoints.createWorktree(path, branch, base), CreateWorktreeResult.serializer())
/** `DELETE /projects/worktree` — remove a worktree (409 "uncommitted" unless `force`). */
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean = false): GitWriteOutcome<RemoveWorktreeResult> =
gitWrite(Endpoints.removeWorktree(path, worktreePath, force), RemoveWorktreeResult.serializer())
/** `POST /projects/worktree/prune` — reclaim stale worktrees (idempotent). */
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> =
gitWrite(Endpoints.pruneWorktrees(path), PruneWorktreesResult.serializer())
/** `POST /projects/git/stage` — stage (`stage=true`) or unstage the given files. */
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean = true): GitWriteOutcome<StageResult> =
gitWrite(Endpoints.gitStage(path, files, stage), StageResult.serializer())
/** `POST /projects/git/commit` — commit the staged changes (empty sha possible). */
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
gitWrite(Endpoints.gitCommit(path, message), CommitResult.serializer())
/** `POST /projects/git/push` — push the current branch to its upstream (tighter rate limit). */
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
/**
* Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the
* decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]
* carrying the server's SAFE `error` string (403 is overloaded — Origin-guard AND disabled
* kill-switch both 403 — so the message, not a typed variant, is surfaced). A non-HTTP status
* (e.g. an odd 2xx/3xx) is [ApiClientError.UnexpectedStatus].
*/
private suspend fun <T> gitWrite(route: ApiRoute, serializer: kotlinx.serialization.KSerializer<T>): GitWriteOutcome<T> {
val response = perform(route)
return when (response.status) {
HttpStatus.OK -> GitWriteOutcome.Ok(decodeGitPayload(response.body, serializer))
HttpStatus.TOO_MANY_REQUESTS -> GitWriteOutcome.RateLimited
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
GitWriteOutcome.Rejected(response.status, decodeGitError(response.body))
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid /** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
* tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */ * tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
public suspend fun registerFcmToken(token: String) { public suspend fun registerFcmToken(token: String) {

View File

@@ -44,6 +44,9 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 500 from `GET /projects/detail` — the server failed reading the repo. */ /** 500 from `GET /projects/detail` — the server failed reading the repo. */
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。") public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
/** 500 from `GET /projects/log` — the server failed reading the git log. */
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")
/** Any other non-success status code. */ /** Any other non-success status code. */
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status") public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status")
} }

View File

@@ -14,6 +14,10 @@ internal object HttpStatus {
const val NOT_FOUND = 404 const val NOT_FOUND = 404
const val TOO_MANY_REQUESTS = 429 const val TOO_MANY_REQUESTS = 429
const val INTERNAL_SERVER_ERROR = 500 const val INTERNAL_SERVER_ERROR = 500
/** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
const val CLIENT_ERROR_MIN = 400
const val SERVER_ERROR_MAX = 599
} }
/** Header / content-type names (no magic strings inline). */ /** Header / content-type names (no magic strings inline). */

View File

@@ -57,6 +57,28 @@ internal object Endpoints {
fun getPrefs(): ApiRoute = fun getPrefs(): ApiRoute =
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY) ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
fun projectPr(path: String): ApiRoute =
ApiRoute(
HttpMethod.GET,
"/projects/pr",
OriginPolicy.READ_ONLY,
percentEncodedQuery = "path=${percentEncode(path)}",
)
/**
* `GET /projects/log?path=[&n=<int>]` — RO recent-commit log. `n` is clamped client-side to
* `1..GIT_LOG_MAX` (the server re-clamps regardless); a null/out-of-range `n` omits the param.
*/
fun projectLog(path: String, n: Int?): ApiRoute {
val query = StringBuilder("path=").append(percentEncode(path))
if (n != null) {
val clamped = n.coerceIn(1, GIT_LOG_MAX)
query.append("&n=").append(clamped)
}
return ApiRoute(HttpMethod.GET, "/projects/log", OriginPolicy.READ_ONLY, percentEncodedQuery = query.toString())
}
// ── G ──────────────────────────────────────────────────────────────────────────────── // ── G ────────────────────────────────────────────────────────────────────────────────
fun killSession(id: UUID): ApiRoute = fun killSession(id: UUID): ApiRoute =
@@ -92,6 +114,58 @@ internal object Endpoints {
private const val FCM_TOKEN_PATH = "/push/fcm-token" private const val FCM_TOKEN_PATH = "/push/fcm-token"
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
jsonBodyRoute(
HttpMethod.POST,
"/projects/worktree",
CreateWorktreeBody.serializer(),
CreateWorktreeBody(path, branch, base),
)
/** `DELETE /projects/worktree` — `{ path, worktreePath, force }` (DELETE **with** a JSON body). */
fun removeWorktree(path: String, worktreePath: String, force: Boolean): ApiRoute =
jsonBodyRoute(
HttpMethod.DELETE,
"/projects/worktree",
RemoveWorktreeBody.serializer(),
RemoveWorktreeBody(path, worktreePath, force),
)
/** `POST /projects/worktree/prune` — `{ path }`. */
fun pruneWorktrees(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/worktree/prune", PruneBody.serializer(), PruneBody(path))
// ── G: git write (stage / commit / push) ───────────────────────────────────────────────
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
/** `POST /projects/git/commit` — `{ path, message }`. */
fun gitCommit(path: String, message: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
/** `POST /projects/git/push` — `{ path }`. */
fun gitPush(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
private fun <T> jsonBodyRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
value: T,
): ApiRoute {
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
}
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
private const val GIT_LOG_MAX = 50
/** /**
* Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are * Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
* matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase * matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
@@ -124,4 +198,22 @@ internal object Endpoints {
@Serializable @Serializable
private data class FcmTokenBody(val token: String) private data class FcmTokenBody(val token: String)
@Serializable
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
@Serializable
private data class RemoveWorktreeBody(val path: String, val worktreePath: String, val force: Boolean)
@Serializable
private data class PruneBody(val path: String)
@Serializable
private data class StageBody(val path: String, val files: List<String>, val stage: Boolean)
@Serializable
private data class CommitBody(val path: String, val message: String)
@Serializable
private data class PushBody(val path: String)
} }

View File

@@ -0,0 +1,211 @@
package wang.yaojia.webterm.api.enroll
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.security.KeyPairGenerator
import java.security.Signature
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
/**
* B4 · Proves the manual PKCS#10 encoder produces a well-formed, self-signed P-256 CSR that the
* control-plane `verifyCsrPoPEc` (id-ecPublicKey + prime256v1 SPKI, ecdsa-with-SHA256
* self-signature) accepts. Runs headless with a SOFTWARE P-256 key via the SAME
* `Signature("SHA256withECDSA")` path the on-device AndroidKeyStore key uses — so the signing path
* is byte-identical. Real StrongBox keygen is device-only (`:client-tls-android`).
*/
class CertificateSigningRequestTest {
/** Software P-256 signer via the SAME JCA `SHA256withECDSA` path used on-device (no StrongBox). */
private class SoftwareEcSigner : CsrSigner {
val keyPair = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp256r1"))
}.generateKeyPair()
override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(keyPair.public as ECPublicKey)
override fun sign(message: ByteArray): ByteArray =
Signature.getInstance("SHA256withECDSA").apply {
initSign(keyPair.private)
update(message)
}.sign()
}
@Test
fun csrIsCanonicalPkcs10SequenceOfExactlyThreeElements() {
val signer = SoftwareEcSigner()
val der = CertificateSigningRequest.der("web-terminal-device", signer)
val outer = TestDer.read(der, 0)!!
assertEquals(0x30, outer.tag, "outer CertificationRequest is a SEQUENCE")
assertEquals(der.size, outer.end, "no trailing garbage after the CSR")
val parts = TestDer.children(der, outer)
assertEquals(3, parts.size)
assertEquals(0x30, parts[0].tag) // certificationRequestInfo
assertEquals(0x30, parts[1].tag) // signatureAlgorithm
assertEquals(0x03, parts[2].tag) // signature BIT STRING
}
@Test
fun csrSelfSignatureVerifiesAgainstTheEmbeddedP256Key() {
val signer = SoftwareEcSigner()
val der = CertificateSigningRequest.der("web-terminal-device", signer)
// Extract the exact CertificationRequestInfo bytes that were signed and the ECDSA signature
// (the same crypto check verifyCsrPoPEc's req.verify() runs).
val outer = TestDer.read(der, 0)!!
val parts = TestDer.children(der, outer)
val infoBytes = der.copyOfRange(parts[0].start, parts[0].end)
val bitString = parts[2] // BIT STRING: first content byte is unused-bits (0x00)
val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd)
val ok = Signature.getInstance("SHA256withECDSA").apply {
initVerify(signer.keyPair.public)
update(infoBytes)
}.verify(signature)
assertTrue(ok, "the CSR self-signature must verify against its own SubjectPublicKeyInfo")
}
@Test
fun csrEmbedsAP256SubjectPublicKeyInfoTheServerVerifierAccepts() {
val signer = SoftwareEcSigner()
val point = signer.publicKeyX963()
val der = CertificateSigningRequest.der("web-terminal-device", signer)
val outer = TestDer.read(der, 0)!!
val info = TestDer.children(der, outer)[0]
val infoChildren = TestDer.children(der, info)
assertEquals(4, infoChildren.size)
// version v1(0)
assertArrayEquals(byteArrayOf(0x02, 0x01, 0x00), der.copyOfRange(infoChildren[0].start, infoChildren[0].end))
assertEquals(0xA0, infoChildren[3].tag) // [0] IMPLICIT attributes
assertEquals(0, infoChildren[3].valueEnd - infoChildren[3].valueStart) // empty SET
// subjectPublicKeyInfo ::= SEQUENCE { AlgorithmIdentifier, BIT STRING point }
val spki = infoChildren[2]
val spkiChildren = TestDer.children(der, spki)
assertEquals(2, spkiChildren.size)
val algIdChildren = TestDer.children(der, spkiChildren[0])
// AlgorithmIdentifier { id-ecPublicKey, prime256v1 } — the exact OIDs verifyCsrPoPEc pins.
assertArrayEquals(
byteArrayOf(0x06, 0x07, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01),
der.copyOfRange(algIdChildren[0].start, algIdChildren[0].end),
)
assertArrayEquals(
byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07),
der.copyOfRange(algIdChildren[1].start, algIdChildren[1].end),
)
// BIT STRING content = 0x00 unused-bits + the exact 65-byte point.
val bitString = spkiChildren[1]
assertEquals(0x03, bitString.tag)
assertEquals(0x00, der[bitString.valueStart].toInt() and 0xFF)
assertArrayEquals(point, der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd))
}
@Test
fun signatureAlgorithmIsEcdsaWithSha256() {
val signer = SoftwareEcSigner()
val der = CertificateSigningRequest.der("web-terminal-device", signer)
val outer = TestDer.read(der, 0)!!
val algId = TestDer.children(der, outer)[1]
val oid = TestDer.children(der, algId)[0]
assertArrayEquals(
byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02),
der.copyOfRange(oid.start, oid.end),
)
}
@Test
fun subjectCommonNameIsEncodedAsUtf8String() {
val signer = SoftwareEcSigner()
val der = CertificateSigningRequest.der("my-pixel", signer)
val outer = TestDer.read(der, 0)!!
val info = TestDer.children(der, outer)[0]
val name = TestDer.children(der, info)[1] // subject Name
val rdn = TestDer.children(der, name)[0] // SET
val attr = TestDer.children(der, rdn)[0] // SEQUENCE { OID, value }
val attrChildren = TestDer.children(der, attr)
// OID 2.5.4.3 (commonName), then a UTF8String (tag 0x0C) carrying the CN bytes.
assertArrayEquals(byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x03), der.copyOfRange(attrChildren[0].start, attrChildren[0].end))
assertEquals(0x0C, attrChildren[1].tag)
assertArrayEquals("my-pixel".toByteArray(), der.copyOfRange(attrChildren[1].valueStart, attrChildren[1].valueEnd))
}
@Test
fun emptySubjectCommonNameIsRejected() {
val signer = SoftwareEcSigner()
assertThrows(CsrException.InvalidSubject::class.java) {
CertificateSigningRequest.der("", signer)
}
}
@Test
fun aNon65BytePublicKeyIsRejected() {
val badSigner = object : CsrSigner {
override fun publicKeyX963(): ByteArray = ByteArray(64) { 0x04 } // wrong length
override fun sign(message: ByteArray): ByteArray = ByteArray(0)
}
assertThrows(CsrException.InvalidPublicKey::class.java) {
CertificateSigningRequest.der("d", badSigner)
}
}
@Test
fun aPublicKeyWithoutTheUncompressedPrefixIsRejected() {
val badSigner = object : CsrSigner {
override fun publicKeyX963(): ByteArray = ByteArray(65) { 0x02 } // right length, wrong prefix
override fun sign(message: ByteArray): ByteArray = ByteArray(0)
}
assertThrows(CsrException.InvalidPublicKey::class.java) {
CertificateSigningRequest.der("d", badSigner)
}
}
}
/**
* A throwaway canonical-DER reader for structural assertions (the enroll path itself does no DER
* parsing — the server verifies; this mirrors the iOS `TestDER` test helper).
*/
internal object TestDer {
data class Element(val tag: Int, val start: Int, val valueStart: Int, val valueEnd: Int) {
val end: Int get() = valueEnd
}
fun read(bytes: ByteArray, start: Int): Element? {
if (start < 0 || start + 1 >= bytes.size) return null
val tag = bytes[start].toInt() and 0xFF
var index = start + 1
val first = bytes[index].toInt() and 0xFF
index += 1
var length = 0
if (first and 0x80 == 0) {
length = first
} else {
val count = first and 0x7F
if (count == 0 || count > 4 || index + count > bytes.size) return null
repeat(count) {
length = (length shl 8) or (bytes[index].toInt() and 0xFF)
index += 1
}
}
val valueEnd = index + length
if (valueEnd > bytes.size) return null
return Element(tag = tag, start = start, valueStart = index, valueEnd = valueEnd)
}
fun children(bytes: ByteArray, parent: Element): List<Element> {
val elements = mutableListOf<Element>()
var index = parent.valueStart
while (index < parent.valueEnd) {
val element = read(bytes, index) ?: break
elements.add(element)
index = element.valueEnd
}
return elements
}
}

View File

@@ -0,0 +1,268 @@
package wang.yaojia.webterm.api.enroll
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.time.Instant
import java.util.Base64
/**
* B4 · DeviceEnrollmentClient request-building + response-mapping against the pinned login/enroll
* contract, driven by the shared `FakeHttpTransport` (no network). Mirrors the iOS
* `DeviceEnrollmentClientTests`, extended with the login step.
*/
class DeviceEnrollmentClientTest {
private companion object {
const val BASE = "https://cp.terminal.yaojia.wang"
const val BEARER = "device-enroll-token-abc"
}
private val transport = FakeHttpTransport()
private val client = DeviceEnrollmentClient(BASE, transport)
private fun bodyObject(request: HttpRequest): JsonObject =
Json.parseToJsonElement(request.body!!.decodeToString()) as JsonObject
private fun enrollResponse(
deviceId: String = "dev-1",
cert: ByteArray = byteArrayOf(0x30, 0x01, 0x02),
caChain: List<ByteArray> = listOf(byteArrayOf(0x30, 0xAA.toByte())),
notBefore: String = "2026-07-08T00:00:00.000Z",
notAfter: String = "2026-10-06T00:00:00.000Z",
renewAfter: String = "2026-09-05T00:00:00.000Z",
): ByteArray {
val b64 = Base64.getEncoder()
val chainJson = caChain.joinToString(",") { "\"${b64.encodeToString(it)}\"" }
return """
{"deviceId":"$deviceId","cert":"${b64.encodeToString(cert)}","caChain":[$chainJson],
"notBefore":"$notBefore","notAfter":"$notAfter","renewAfter":"$renewAfter"}
""".trimIndent().toByteArray()
}
// ── login ────────────────────────────────────────────────────────────────────────────────
@Test
fun loginPostsPasswordAndMapsThe201Bearer() = runTest {
transport.queueSuccess(
method = HttpMethod.POST,
url = "$BASE/auth/login",
status = 201,
body = """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray(),
)
val result = client.login("hunter2")
val request = transport.recordedRequests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/auth/login", request.url)
assertEquals("application/json", request.headers["Content-Type"])
assertNull(request.headers["Authorization"], "login carries no bearer")
assertEquals("hunter2", bodyObject(request)["password"]!!.jsonPrimitive.content)
assertEquals("tok-xyz", result.enrollToken)
assertEquals("acct-1", result.accountId)
assertEquals(600L, result.expiresInSeconds)
}
@Test
fun loginRejectsAnEmptyPasswordBeforeAnyNetworkIo() = runTest {
val error = runCatching { client.login("") }.exceptionOrNull()
assertEquals(DeviceEnrollmentError.InvalidRequest, error)
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty password")
}
@Test
fun loginSurfacesA401AsHttpWithTheServerCode() = runTest {
transport.queueSuccess(
method = HttpMethod.POST,
url = "$BASE/auth/login",
status = 401,
body = """{"error":"rejected"}""".toByteArray(),
)
val error = runCatching { client.login("wrong") }.exceptionOrNull()
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
}
// ── enroll ───────────────────────────────────────────────────────────────────────────────
@Test
fun enrollBuildsABearerAuthenticatedPostWithTheContractBody() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse(),
)
val csr = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte())
client.enroll(BEARER, csr, subdomain = "alice", deviceName = "Alice Pixel")
val request = transport.recordedRequests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/device/enroll", request.url)
assertEquals("Bearer $BEARER", request.headers["Authorization"])
assertEquals("application/json", request.headers["Content-Type"])
val obj = bodyObject(request)
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
assertEquals("ec-p256", obj["keyAlg"]!!.jsonPrimitive.content)
assertEquals("alice", obj["subdomain"]!!.jsonPrimitive.content)
assertEquals("Alice Pixel", obj["deviceName"]!!.jsonPrimitive.content)
assertFalse(obj.containsKey("attestation"), "attestation is omitted when not provided")
}
@Test
fun enrollForwardsAttestationWhenProvided() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse())
client.enroll(BEARER, byteArrayOf(0x01), "a", "d", attestation = "attest-blob")
assertEquals("attest-blob", bodyObject(transport.recordedRequests.single())["attestation"]!!.jsonPrimitive.content)
}
@Test
fun enrollMapsA201IntoATypedResult() = runTest {
val cert = byteArrayOf(0x30, 0x82.toByte(), 0x01, 0x23)
val ca = byteArrayOf(0x30, 0x82.toByte(), 0x02, 0x00)
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
body = enrollResponse(deviceId = "dev-xyz", cert = cert, caChain = listOf(ca)),
)
val result = client.enroll(BEARER, byteArrayOf(0x01), "alice", "Pixel")
assertEquals("dev-xyz", result.deviceId)
assertArrayEquals(cert, result.certificate)
assertEquals(1, result.caChain.size)
assertArrayEquals(ca, result.caChain.single())
assertTrue(result.renewAfter!!.isBefore(result.notAfter))
}
@Test
fun enrollRejectsEmptyRequiredFieldsBeforeAnyNetworkIo() = runTest {
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll("", byteArrayOf(1), "a", "d") }.exceptionOrNull())
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, ByteArray(0), "a", "d") }.exceptionOrNull())
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "", "d") }.exceptionOrNull())
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "") }.exceptionOrNull())
assertTrue(transport.recordedRequests.isEmpty())
}
@Test
fun enrollSurfacesA403SubdomainNotOwnedWithTheServerCode() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 403,
body = """{"error":"rejected"}""".toByteArray(),
)
assertEquals(
DeviceEnrollmentError.Http(403, "rejected"),
runCatching { client.enroll(BEARER, byteArrayOf(1), "bob", "d") }.exceptionOrNull(),
)
}
@Test
fun enrollSurfacesA429RateLimited() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 429,
body = """{"error":"rate_limited"}""".toByteArray(),
)
assertEquals(
DeviceEnrollmentError.Http(429, "rate_limited"),
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
)
}
@Test
fun enrollThrowsMalformedResponseOnANonJson201Body() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = "not json".toByteArray())
assertEquals(
DeviceEnrollmentError.MalformedResponse,
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
)
}
@Test
fun enrollThrowsMalformedResponseWhenTheCertIsNotValidBase64() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
body = """{"deviceId":"d","cert":"@@not-base64@@","caChain":[]}""".toByteArray(),
)
assertEquals(
DeviceEnrollmentError.MalformedResponse,
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
)
}
@Test
fun enrollDegradesAbsentDatesToNull() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
body = """{"deviceId":"d","cert":"MAEC","caChain":[]}""".toByteArray(),
)
val result = client.enroll(BEARER, byteArrayOf(1), "a", "d")
assertNull(result.notAfter)
assertNull(result.renewAfter)
assertFalse(result.isRenewalDue(Instant.parse("2030-01-01T00:00:00Z")), "absent renewAfter never triggers")
}
// ── renew (silent rotation seam — mTLS-only, NO bearer) ─────────────────────────────────────
@Test
fun renewTargetsDeviceIdRenewWithTheMinimalBodyAndNoAuthorizationHeader() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"),
)
val csr = byteArrayOf(0x02)
// Production renew passes NO bearer — the endpoint authenticates by the current device cert (mTLS).
val result = client.renew("dev-9", csr)
val request = transport.recordedRequests.single()
assertEquals("$BASE/device/dev-9/renew", request.url)
assertNull(request.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header")
val obj = bodyObject(request)
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
// The server's /device/:id/renew authenticates by the presented mTLS device cert and its body
// schema is `{ csr }` ONLY (.strict()) — any enroll-only extra (keyAlg/subdomain/deviceName)
// is rejected. The renew wire body must therefore carry the single `csr` key and nothing else.
assertEquals(setOf("csr"), obj.keys, "renew body is {csr}-only — no keyAlg/subdomain/deviceName")
assertFalse(obj.containsKey("keyAlg"), "renew must not send the enroll-only keyAlg field")
assertFalse(obj.containsKey("subdomain"), "renew body carries no subdomain/deviceName")
assertEquals("dev-9", result.deviceId)
}
@Test
fun renewForwardsAnExplicitBearerWhenTheOptionalSeamIsUsed() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"),
)
// The bearer is optional/absent by default; when a caller DOES pass one it rides as a header.
client.renew("dev-9", byteArrayOf(0x02), bearerToken = BEARER)
assertEquals("Bearer $BEARER", transport.recordedRequests.single().headers["Authorization"])
}
@Test
fun renewRejectsEmptyDeviceIdOrCsrBeforeAnyNetworkIo() = runTest {
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("", byteArrayOf(1)) }.exceptionOrNull())
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("d", ByteArray(0)) }.exceptionOrNull())
assertTrue(transport.recordedRequests.isEmpty())
}
// ── isRenewalDue seam ──────────────────────────────────────────────────────────────────────
@Test
fun isRenewalDueFlipsAtRenewAfter() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse())
val result = client.enroll(BEARER, byteArrayOf(1), "a", "d")
assertFalse(result.isRenewalDue(Instant.parse("2026-09-04T00:00:00Z")))
assertTrue(result.isRenewalDue(Instant.parse("2026-09-06T00:00:00Z")))
}
private fun assertArrayEquals(expected: ByteArray, actual: ByteArray) =
org.junit.jupiter.api.Assertions.assertArrayEquals(expected, actual)
}

View File

@@ -0,0 +1,57 @@
package wang.yaojia.webterm.api.enroll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import java.math.BigInteger
import java.security.KeyPairGenerator
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
/** B4 · The X9.63 uncompressed-point encoder — the security-load-bearing SubjectPublicKeyInfo bytes. */
class EcPointEncodingTest {
@Test
fun encodesAGeneratedP256KeyAs65UncompressedBytesRoundTrippingToTheCoordinates() {
val kp = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp256r1"))
}.generateKeyPair()
val pub = kp.public as ECPublicKey
val encoded = EcPointEncoding.x963(pub)
assertEquals(65, encoded.size, "0x04 || X(32) || Y(32)")
assertEquals(0x04, encoded[0].toInt() and 0xFF, "uncompressed-point prefix")
// The 32-byte big-endian halves must be exactly the affine coordinates.
val x = BigInteger(1, encoded.copyOfRange(1, 33))
val y = BigInteger(1, encoded.copyOfRange(33, 65))
assertEquals(pub.w.affineX, x)
assertEquals(pub.w.affineY, y)
}
@Test
fun leftPadsAShortCoordinateToTheFixedWidth() {
// A small value must be left-padded with leading zeros to exactly 32 bytes.
val padded = EcPointEncoding.toFixedLengthUnsigned(BigInteger.valueOf(1), 32)
assertEquals(32, padded.size)
assertEquals(1, padded[31].toInt())
assertEquals(0, padded[0].toInt())
}
@Test
fun dropsTheBigIntegerSignByteWhenPresent() {
// A value whose top bit is set carries a leading 0x00 sign byte in BigInteger.toByteArray();
// it must be dropped, not counted toward the width.
val highBit = BigInteger(1, ByteArray(32) { 0xFF.toByte() })
val encoded = EcPointEncoding.toFixedLengthUnsigned(highBit, 32)
assertEquals(32, encoded.size)
assertEquals(0xFF, encoded[0].toInt() and 0xFF)
}
@Test
fun rejectsACoordinateThatDoesNotFit() {
val tooBig = BigInteger.ONE.shiftLeft(256) // needs 33 bytes
assertThrows(IllegalArgumentException::class.java) {
EcPointEncoding.toFixedLengthUnsigned(tooBig, 32)
}
}
}

View File

@@ -0,0 +1,64 @@
package wang.yaojia.webterm.api.models
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* GitLogResult list-lossy decode (plan Phase A.2): a well-formed `{commits,truncated}` decodes; a
* commit missing `hash`/`at` is dropped while its siblings survive; `truncated` passes through; a
* subject-less commit still decodes (subject defaults to empty).
*/
class GitLogTest {
private fun decode(json: String): GitLogResult? =
LossyDecode.objectOrNull(json.toByteArray(), GitLogResult.serializer())
@Test
fun `decodes commits and truncated`() {
val json = """
{ "truncated": true, "commits": [
{ "hash":"abc123", "at": 1710000000000, "subject":"first" },
{ "hash":"def456", "at": 1710000005000, "subject":"second" }
] }
""".trimIndent()
val result = decode(json)!!
assertTrue(result.truncated)
assertEquals(2, result.commits.size)
assertEquals("abc123", result.commits[0].hash)
assertEquals(1710000000000L, result.commits[0].at)
assertEquals("first", result.commits[0].subject)
}
@Test
fun `drops a commit missing hash or at, keeping the rest`() {
val json = """
{ "truncated": false, "commits": [
{ "at": 1, "subject":"no hash" },
{ "hash":"keep", "at": 2, "subject":"kept" },
{ "hash":"noAt", "subject":"no at" }
] }
""".trimIndent()
val result = decode(json)!!
assertFalse(result.truncated)
assertEquals(1, result.commits.size)
assertEquals("keep", result.commits.single().hash)
}
@Test
fun `a subject-less commit still decodes with an empty subject`() {
val result = decode("""{ "commits":[ { "hash":"h", "at": 5 } ] }""")!!
assertEquals(1, result.commits.size)
assertEquals("", result.commits.single().subject)
assertFalse(result.truncated) // default
}
@Test
fun `a non-object body degrades to null`() {
org.junit.jupiter.api.Assertions.assertNull(decode("[]"))
org.junit.jupiter.api.Assertions.assertNull(decode("garbage"))
}
}

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.api.models
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* GitWrite payload + error decode (plan Phase A.3): each 200 payload decodes; a failure body
* `{ok:false,error:"…"}` (git-ops) and `{error:"…"}` (worktrees) both yield the SAFE `error` string;
* a garbled 200 body degrades to the payload defaults (never throws). The empty-sha commit case is
* exercised (server can return `{ok:true, commit:""}`).
*/
class GitWriteTest {
@Test
fun `stage payload decodes staged and count`() {
val r = decodeGitPayload("""{"ok":true,"staged":true,"count":3}""".toByteArray(), StageResult.serializer())
assertEquals(StageResult(staged = true, count = 3), r)
}
@Test
fun `commit payload decodes the sha and tolerates an empty sha`() {
assertEquals("a1b2c3", decodeGitPayload("""{"ok":true,"commit":"a1b2c3"}""".toByteArray(), CommitResult.serializer()).commit)
assertEquals("", decodeGitPayload("""{"ok":true,"commit":""}""".toByteArray(), CommitResult.serializer()).commit)
}
@Test
fun `push payload decodes branch and remote`() {
val r = decodeGitPayload("""{"ok":true,"branch":"main","remote":"origin"}""".toByteArray(), PushResult.serializer())
assertEquals("main", r.branch)
assertEquals("origin", r.remote)
}
@Test
fun `worktree create and remove and prune payloads decode`() {
val create = decodeGitPayload("""{"ok":true,"path":"/wt/x","branch":"feat"}""".toByteArray(), CreateWorktreeResult.serializer())
assertEquals("/wt/x", create.path)
assertEquals("feat", create.branch)
val remove = decodeGitPayload("""{"ok":true,"path":"/wt/x"}""".toByteArray(), RemoveWorktreeResult.serializer())
assertEquals("/wt/x", remove.path)
val prune = decodeGitPayload("""{"ok":true,"pruned":["a","b"]}""".toByteArray(), PruneWorktreesResult.serializer())
assertEquals(listOf("a", "b"), prune.pruned)
}
@Test
fun `a garbled 200 body degrades to payload defaults, never throwing`() {
assertEquals(StageResult(), decodeGitPayload("not json".toByteArray(), StageResult.serializer()))
assertEquals(CommitResult(), decodeGitPayload("[]".toByteArray(), CommitResult.serializer()))
assertTrue(decodeGitPayload("{}".toByteArray(), PruneWorktreesResult.serializer()).pruned.isEmpty())
}
@Test
fun `a git-ops failure body yields the safe error string`() {
assertEquals("Nothing to commit.", decodeGitError("""{"ok":false,"error":"Nothing to commit."}""".toByteArray()))
}
@Test
fun `a worktree failure body (no ok field) still yields the error string`() {
assertEquals("Worktree creation is disabled.", decodeGitError("""{"error":"Worktree creation is disabled."}""".toByteArray()))
}
@Test
fun `an empty or errorless failure body yields null`() {
assertNull(decodeGitError(ByteArray(0)))
assertNull(decodeGitError("""{"ok":false}""".toByteArray()))
assertNull(decodeGitError("not json".toByteArray()))
}
}

View File

@@ -0,0 +1,77 @@
package wang.yaojia.webterm.api.models
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* PrStatus tolerant decode (plan Phase A.1): a full `availability:"ok"` body decodes every field; an
* unknown/missing `availability` degrades to [PrAvailability.ERROR] (never throws); `PrCheckSummary`
* counts round-trip; a non-object body degrades rather than crashing. Mirrors the FE never treating a
* non-`ok` availability as an HTTP error.
*/
class PrStatusTest {
private fun decode(json: String): PrStatus? =
LossyDecode.objectOrNull(json.toByteArray(), PrStatus.serializer())
@Test
fun `decodes a full ok body with all fields and check counts`() {
val json = """
{ "availability":"ok", "number":42, "title":"Add worktrees", "url":"https://x/pull/42",
"state":"open", "isDraft":false, "mergeable":"mergeable",
"headRefName":"feat/wt", "baseRefName":"main",
"checks": { "total":5, "passing":3, "failing":1, "pending":1 } }
""".trimIndent()
val pr = decode(json)!!
assertEquals(PrAvailability.OK, pr.availability)
assertEquals(42, pr.number)
assertEquals("Add worktrees", pr.title)
assertEquals("https://x/pull/42", pr.url)
assertEquals("open", pr.state)
assertEquals(false, pr.isDraft)
assertEquals("mergeable", pr.mergeable)
assertEquals("feat/wt", pr.headRefName)
assertEquals("main", pr.baseRefName)
assertEquals(PrCheckSummary(total = 5, passing = 3, failing = 1, pending = 1), pr.checks)
}
@Test
fun `an unknown availability degrades to ERROR, never throwing`() {
val pr = decode("""{ "availability":"quantum-flux" }""")!!
assertEquals(PrAvailability.ERROR, pr.availability)
}
@Test
fun `a body missing availability defaults to ERROR and leaves optional fields null`() {
val pr = decode("""{ "number":7 }""")!!
assertEquals(PrAvailability.ERROR, pr.availability)
assertEquals(7, pr.number)
assertNull(pr.title)
assertNull(pr.checks)
}
@Test
fun `each known availability maps from its wire value`() {
assertEquals(PrAvailability.NO_PR, PrAvailability.fromWire("no-pr"))
assertEquals(PrAvailability.NOT_INSTALLED, PrAvailability.fromWire("not-installed"))
assertEquals(PrAvailability.UNAUTHENTICATED, PrAvailability.fromWire("unauthenticated"))
assertEquals(PrAvailability.DISABLED, PrAvailability.fromWire("disabled"))
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("error"))
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("")) // empty → ERROR
}
@Test
fun `a non-object body degrades to null rather than throwing`() {
assertNull(decode("[]"))
assertNull(decode("not json"))
assertNull(LossyDecode.objectOrNull(ByteArray(0), PrStatus.serializer()))
}
@Test
fun `unknown top-level keys are ignored`() {
val pr = decode("""{ "availability":"ok", "futureField":123, "nested":{"a":1} }""")!!
assertEquals(PrAvailability.OK, pr.availability)
}
}

View File

@@ -0,0 +1,112 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
/**
* Status-code → outcome mapping for the W5 git surface (plan Phase A.5): PR 200/400/404; log
* decode + errors; each guarded write 200→Ok, 403→Rejected(body.error), 409→Rejected, 429→
* RateLimited. Also asserts the transport RECEIVED an Origin on writes and NOT on reads.
*/
class ApiClientGitTest {
private companion object {
const val BASE = "http://h:3000"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
// ── PR (RO; degrade lives in the 200 body, not the status) ───────────────────────────────
@Test
fun `projectPr decodes a 200 degrade body and maps 400 404`() = runTest {
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"not-installed"}""".toByteArray())
assertEquals(PrAvailability.NOT_INSTALLED, client.projectPr("/r").availability)
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 400)
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("/r") })
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 404)
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectPr("/r") })
// Empty path is rejected before any I/O.
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("") })
}
@Test
fun `projectPr never treats a garbled 200 body as an error (degrades to ERROR)`() = runTest {
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = "not json".toByteArray())
assertEquals(PrAvailability.ERROR, client.projectPr("/r").availability)
}
// ── log ──────────────────────────────────────────────────────────────────────────────────
@Test
fun `projectLog decodes 200 and maps 404 and 500`() = runTest {
transport.queueSuccess(
url = "$BASE/projects/log?path=%2Fr",
body = """{"commits":[{"hash":"h","at":1,"subject":"s"}],"truncated":false}""".toByteArray(),
)
assertEquals(1, client.projectLog("/r").commits.size)
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 404)
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectLog("/r") })
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 500)
assertEquals(ApiClientError.GitLogUnavailable, errorOf { client.projectLog("/r") })
}
// ── guarded writes: outcome mapping ──────────────────────────────────────────────────────
@Test
fun `a guarded write 200 yields Ok with the decoded payload`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"abc"}""".toByteArray())
val outcome = client.gitCommit("/r", "msg")
assertTrue(outcome is GitWriteOutcome.Ok)
assertEquals("abc", (outcome as GitWriteOutcome.Ok).payload.commit)
// The write stamped an Origin.
assertTrue(transport.recordedRequests.last().headers.containsKey(HeaderName.ORIGIN))
}
@Test
fun `403 disabled and 409 both surface Rejected with the safe error string`() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/projects/worktree",
status = 403, body = """{"error":"Worktree creation is disabled."}""".toByteArray(),
)
val disabled = client.createWorktree("/r", "b", null)
assertEquals(GitWriteOutcome.Rejected(403, "Worktree creation is disabled."), disabled)
transport.queueSuccess(
method = HttpMethod.DELETE, url = "$BASE/projects/worktree",
status = 409, body = """{"error":"Worktree has uncommitted changes; force required."}""".toByteArray(),
)
val dirty = client.removeWorktree("/r", "/r/x", false)
assertEquals(GitWriteOutcome.Rejected(409, "Worktree has uncommitted changes; force required."), dirty)
}
@Test
fun `429 yields RateLimited and never auto-retries`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 429, body = """{"error":"Too many requests."}""".toByteArray())
assertEquals(GitWriteOutcome.RateLimited, client.gitPush("/r"))
assertEquals(1, transport.recordedRequests.size) // exactly one attempt
}
@Test
fun `a stage 200 decodes staged and count and threads the files body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true,"staged":true,"count":2}""".toByteArray())
val outcome = client.gitStage("/r", listOf("a", "b"), stage = true)
assertTrue(outcome is GitWriteOutcome.Ok)
assertEquals(2, (outcome as GitWriteOutcome.Ok).payload.count)
assertEquals("""{"path":"/r","files":["a","b"],"stage":true}""", transport.recordedRequests.last().body?.decodeToString())
}
}

View File

@@ -0,0 +1,161 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.util.UUID
/**
* Request-shape + Origin-iff-guarded (plan §4.3 铁律) for the W5 git surface: the two NEW reads
* (`/projects/pr`, `/projects/log`) carry **no** Origin; the six writes (worktree×3, git×3) carry a
* byte-equal Origin and a JSON body — including a `DELETE /projects/worktree` that carries a body
* (the highest-risk integration gotcha). A route reclassified read↔write turns this red.
*/
class GitRouteShapeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val ORIGIN = "http://192.168.1.5:3000"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private fun last(): HttpRequest = transport.recordedRequests.last()
private fun assertGuarded(r: HttpRequest) =
assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin")
private fun assertReadOnly(r: HttpRequest) =
assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
// ── reads: no Origin, correct verb + strict-encoded query ────────────────────────────────
@Test
fun `projectPr is a read-only GET with a strict-encoded path and no Origin`() = runTest {
val path = "/home/me/my repo/a+b&c"
val url = "$BASE/projects/pr?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c"
transport.queueSuccess(url = url, body = """{"availability":"no-pr"}""".toByteArray())
client.projectPr(path)
val r = last()
assertEquals(HttpMethod.GET, r.method)
assertEquals(url, r.url)
assertReadOnly(r)
}
@Test
fun `projectLog omits n when null and appends a clamped n when set`() = runTest {
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp", body = """{"commits":[],"truncated":false}""".toByteArray())
client.projectLog("/p", n = null)
assertEquals("$BASE/projects/log?path=%2Fp", last().url)
assertReadOnly(last())
// n above GIT_LOG_MAX (50) clamps to 50; below 1 clamps to 1.
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=50", body = """{"commits":[],"truncated":false}""".toByteArray())
client.projectLog("/p", n = 999)
assertEquals("$BASE/projects/log?path=%2Fp&n=50", last().url)
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=1", body = """{"commits":[],"truncated":false}""".toByteArray())
client.projectLog("/p", n = 0)
assertEquals("$BASE/projects/log?path=%2Fp&n=1", last().url)
}
// ── writes: Origin stamped, correct verb, JSON body ──────────────────────────────────────
@Test
fun `createWorktree is a guarded POST with a path-branch-base body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
client.createWorktree("/repo", "feat/x", base = "main")
val r = last()
assertEquals(HttpMethod.POST, r.method)
assertEquals("$BASE/projects/worktree", r.url)
assertGuarded(r)
assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE])
assertEquals("""{"path":"/repo","branch":"feat/x","base":"main"}""", r.body?.decodeToString())
}
@Test
fun `createWorktree omits base when null`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
client.createWorktree("/repo", "feat/x", base = null)
assertEquals("""{"path":"/repo","branch":"feat/x"}""", last().body?.decodeToString())
}
@Test
fun `removeWorktree is a guarded DELETE that CARRIES a JSON body`() = runTest {
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
client.removeWorktree("/repo", "/repo-worktrees/x", force = true)
val r = last()
assertEquals(HttpMethod.DELETE, r.method)
assertEquals("$BASE/projects/worktree", r.url)
assertGuarded(r)
assertNotNull(r.body, "DELETE /projects/worktree MUST carry a request body")
assertEquals("""{"path":"/repo","worktreePath":"/repo-worktrees/x","force":true}""", r.body?.decodeToString())
}
@Test
fun `pruneWorktrees is a guarded POST with a path body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true,"pruned":[]}""".toByteArray())
client.pruneWorktrees("/repo")
val r = last()
assertEquals(HttpMethod.POST, r.method)
assertEquals("$BASE/projects/worktree/prune", r.url)
assertGuarded(r)
assertEquals("""{"path":"/repo"}""", r.body?.decodeToString())
}
@Test
fun `gitStage commit push are guarded POSTs with exact bodies`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
client.gitStage("/repo", listOf("a.kt", "b.kt"), stage = true)
assertEquals("$BASE/projects/git/stage", last().url)
assertGuarded(last())
assertEquals("""{"path":"/repo","files":["a.kt","b.kt"],"stage":true}""", last().body?.decodeToString())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"x"}""".toByteArray())
client.gitCommit("/repo", "a message")
assertEquals("""{"path":"/repo","message":"a message"}""", last().body?.decodeToString())
assertGuarded(last())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
client.gitPush("/repo")
assertEquals("$BASE/projects/git/push", last().url)
assertEquals("""{"path":"/repo"}""", last().body?.decodeToString())
assertGuarded(last())
}
@Test
fun `every guarded write carries Origin and every read does not (batch invariant)`() = runTest {
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"ok"}""".toByteArray())
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", body = """{"commits":[],"truncated":false}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
client.projectPr("/r"); client.projectLog("/r", null)
assertReadOnly(transport.recordedRequests[0])
assertReadOnly(transport.recordedRequests[1])
client.createWorktree("/r", "b", null)
client.removeWorktree("/r", "/r/x", false)
client.pruneWorktrees("/r")
client.gitStage("/r", listOf("f"), true)
client.gitCommit("/r", "m")
client.gitPush("/r")
assertTrue(transport.recordedRequests.drop(2).all { it.headers.containsKey(HeaderName.ORIGIN) })
}
}

View File

@@ -11,8 +11,11 @@ import okhttp3.OkHttpClient
import wang.yaojia.webterm.tlsandroid.AndroidIdentityRepository import wang.yaojia.webterm.tlsandroid.AndroidIdentityRepository
import wang.yaojia.webterm.tlsandroid.AndroidKeyStoreImporter import wang.yaojia.webterm.tlsandroid.AndroidKeyStoreImporter
import wang.yaojia.webterm.tlsandroid.CertStore import wang.yaojia.webterm.tlsandroid.CertStore
import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore
import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
import wang.yaojia.webterm.tlsandroid.IdentityRepository import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.tlsandroid.TinkCertStore import wang.yaojia.webterm.tlsandroid.TinkCertStore
import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore
import javax.inject.Singleton import javax.inject.Singleton
/** /**
@@ -41,16 +44,38 @@ public object TlsModule {
@Singleton @Singleton
public fun provideCertStore(@ApplicationContext context: Context): CertStore = TinkCertStore(context) public fun provideCertStore(@ApplicationContext context: Context): CertStore = TinkCertStore(context)
/** The Tink-AEAD-encrypted enrollment record (deviceId + key alias) the zero-`.p12` renew path reads. */
@Provides @Provides
@Singleton @Singleton
public fun provideIdentityRepository( public fun provideEnrollmentRecordStore(
@ApplicationContext context: Context,
): EnrollmentRecordStore = TinkEnrollmentRecordStore(context)
/**
* The ONE mTLS device-identity repository, provided as the concrete type so BOTH the
* [IdentityRepository] surface (import/rotate/remove/summary) and the narrow [IdentityCacheRefresher]
* seam (B4 enroll cache-freshness) resolve to the SAME singleton instance.
*/
@Provides
@Singleton
public fun provideAndroidIdentityRepository(
importer: AndroidKeyStoreImporter, importer: AndroidKeyStoreImporter,
certStore: CertStore, certStore: CertStore,
connectionPool: ConnectionPool, connectionPool: ConnectionPool,
): IdentityRepository { ): AndroidIdentityRepository {
// Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()'s // Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()/refresh's
// `connectionPool.evictAll()` clears the connections the real transports pooled (R4/§8). // `connectionPool.evictAll()` clears the connections the real transports pooled (R4/§8).
val evictClient = OkHttpClient.Builder().connectionPool(connectionPool).build() val evictClient = OkHttpClient.Builder().connectionPool(connectionPool).build()
return AndroidIdentityRepository(importer, certStore, evictClient) return AndroidIdentityRepository(importer, certStore, evictClient)
} }
@Provides
@Singleton
public fun provideIdentityRepository(repository: AndroidIdentityRepository): IdentityRepository = repository
/** FIX 3: the enroll/renew commit refreshes THIS same repository's in-memory cache (no restart). */
@Provides
@Singleton
public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher =
repository
} }

View File

@@ -67,6 +67,8 @@ public fun WebTermNavHost(
composable(NavRoutes.CERT) { ClientCertPane(env = env, onBack = { navController.popBackStack() }) } composable(NavRoutes.CERT) { ClientCertPane(env = env, onBack = { navController.popBackStack() }) }
composable(NavRoutes.ENROLL) { EnrollmentPane(env = env, onBack = { navController.popBackStack() }) }
composable( composable(
route = TERMINAL_ROUTE, route = TERMINAL_ROUTE,
arguments = listOf( arguments = listOf(
@@ -211,6 +213,9 @@ public object NavRoutes {
/** Device-certificate management (A27). */ /** Device-certificate management (A27). */
public const val CERT: String = "cert" public const val CERT: String = "cert"
/** Zero-`.p12` device enrollment (B4) — obtains a hardware-bound cert with no file. */
public const val ENROLL: String = "enroll"
/** Terminal route pattern with the required host + session path args (A21). */ /** Terminal route pattern with the required host + session path args (A21). */
public const val TERMINAL_PATTERN: String = "terminal/{${NavArg.HOST_ID}}/{${NavArg.SESSION_ID}}" public const val TERMINAL_PATTERN: String = "terminal/{${NavArg.HOST_ID}}/{${NavArg.SESSION_ID}}"

View File

@@ -20,10 +20,13 @@ import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.screens.ClientCertScreen import wang.yaojia.webterm.screens.ClientCertScreen
import wang.yaojia.webterm.screens.DiffScreen import wang.yaojia.webterm.screens.DiffScreen
import wang.yaojia.webterm.screens.EnrollmentScreen
import wang.yaojia.webterm.screens.PairingScreen import wang.yaojia.webterm.screens.PairingScreen
import wang.yaojia.webterm.screens.ProjectDetailScreen import wang.yaojia.webterm.screens.ProjectDetailScreen
import wang.yaojia.webterm.viewmodels.ApiClientGitWriteGateway
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
import wang.yaojia.webterm.viewmodels.ClientCertViewModel import wang.yaojia.webterm.viewmodels.ClientCertViewModel
import wang.yaojia.webterm.viewmodels.EnrollmentViewModel
import wang.yaojia.webterm.viewmodels.DiffViewModel import wang.yaojia.webterm.viewmodels.DiffViewModel
import wang.yaojia.webterm.viewmodels.HttpDiffFetcher import wang.yaojia.webterm.viewmodels.HttpDiffFetcher
import wang.yaojia.webterm.viewmodels.PairingViewModel import wang.yaojia.webterm.viewmodels.PairingViewModel
@@ -74,6 +77,38 @@ public fun ClientCertPane(
ClientCertScreen(viewModel = viewModel, onBack = onBack, modifier = modifier) ClientCertScreen(viewModel = viewModel, onBack = onBack, modifier = modifier)
} }
// ── Device enrollment (B4, zero-.p12 auto-cert) ─────────────────────────────────────────────────────
/**
* Hosts [EnrollmentScreen] over a fresh [EnrollmentViewModel] (mirrors iOS `AppCoordinator`'s
* `makeEnrollmentViewModel`). The enroll flow and the installed-summary read run OFF `Main`
* (`Dispatchers.IO`) — resolving the enroller resolves the lazy shared client (keystore/TLS I/O) and the
* enroll itself does hardware-keygen + network. The typed control-plane URL builds the enroller per attempt.
*/
@Composable
public fun EnrollmentPane(
env: AppEnvironment,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = remember(env) {
EnrollmentViewModel(
enrollOperation = { password, subdomain, deviceName, controlPlaneUrl ->
withContext(Dispatchers.IO) {
env.enrollmentFlowFactory.create(controlPlaneUrl).enroll(password, subdomain, deviceName)
}
},
loadSummary = {
withContext(Dispatchers.IO) {
runCatching { env.identityRepository.currentSummary() }.getOrNull()
}
},
defaultDeviceName = android.os.Build.MODEL ?: "",
)
}
EnrollmentScreen(viewModel = viewModel, onBack = onBack, modifier = modifier)
}
// ── Project detail (A23) ────────────────────────────────────────────────────────────────────────────── // ── Project detail (A23) ──────────────────────────────────────────────────────────────────────────────
/** /**
@@ -106,6 +141,7 @@ public fun ProjectDetailPane(
path = path, path = path,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) }, onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
onViewDiff = { diffPath -> navController.navigate(diffRoute(resolved.id, diffPath)) },
modifier = modifier, modifier = modifier,
) )
} }
@@ -118,9 +154,10 @@ public fun ProjectDetailContent(
onBack: () -> Unit, onBack: () -> Unit,
onOpenClaude: (String) -> Unit, onOpenClaude: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onViewDiff: (String) -> Unit = {},
) { ) {
val viewModel = remember(gateway, path) { ProjectDetailViewModel.forGateway(gateway, path) } val viewModel = remember(gateway, path) { ProjectDetailViewModel.forGateway(gateway, path) }
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier) ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier, onViewDiff = onViewDiff)
} }
// ── Diff viewer (A24) ───────────────────────────────────────────────────────────────────────────────── // ── Diff viewer (A24) ─────────────────────────────────────────────────────────────────────────────────
@@ -151,7 +188,12 @@ public fun DiffPane(
return return
} }
val viewModel = remember(resolved, path) { val viewModel = remember(resolved, path) {
DiffViewModel(fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport), path = path) DiffViewModel(
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport),
path = path,
// Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security).
writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)),
)
} }
DiffScreen(viewModel = viewModel, modifier = modifier, onBack = onBack) DiffScreen(viewModel = viewModel, modifier = modifier, onBack = onBack)
} }

View File

@@ -83,6 +83,7 @@ public fun ProjectsHome(
path = path, path = path,
onBack = { selectedPath = null }, onBack = { selectedPath = null },
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) }, onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
onViewDiff = { diffPath -> navController.navigate(diffRoute(resolved.id, diffPath)) },
) )
} else { } else {
DetailPlaceholder("选择一个项目查看详情。") DetailPlaceholder("选择一个项目查看详情。")

View File

@@ -87,6 +87,7 @@ public fun SessionsHome(
onOpenSession = openSession, onOpenSession = openSession,
onNewSession = openNewSession, onNewSession = openNewSession,
onPairHost = { navController.navigate(NavRoutes.PAIRING) }, onPairHost = { navController.navigate(NavRoutes.PAIRING) },
onEnroll = { navController.navigate(NavRoutes.ENROLL) },
onImportCert = { navController.navigate(NavRoutes.CERT) }, onImportCert = { navController.navigate(NavRoutes.CERT) },
) )
} }

View File

@@ -15,13 +15,17 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -44,15 +48,17 @@ import wang.yaojia.webterm.viewmodels.DiffPhase
import wang.yaojia.webterm.viewmodels.DiffRow import wang.yaojia.webterm.viewmodels.DiffRow
import wang.yaojia.webterm.viewmodels.DiffUiState import wang.yaojia.webterm.viewmodels.DiffUiState
import wang.yaojia.webterm.viewmodels.DiffViewModel import wang.yaojia.webterm.viewmodels.DiffViewModel
import wang.yaojia.webterm.viewmodels.DiffWriteBanner
/** /**
* # DiffScreen (A24) — the read-only staged/unstaged git-diff viewer. * # DiffScreen (A24 + W5) — the git-diff viewer with base-compare + git-write.
* *
* Renders the presenter's flattened files→hunks→lines list in a `LazyColumn`, with a Working/Staged * Renders the presenter's flattened files→hunks→lines list in a `LazyColumn`, with a Working/Staged
* toggle in the header. Every server-derived string (paths, hunk headers, code lines) is rendered as * toggle (hidden in base mode), a **base-rev** input (a third mode), per-file **Stage/Unstage** buttons
* **inert monospaced [Text]** — plain `Text`, never `ClickableText`/`LinkAnnotation`/autolink/markdown * (working/staged mode only), a **commit** message field + **Commit** / **Push** buttons, and a result
* — so a hostile diff cannot inject a tappable link or markup (plan §8). Line kinds carry the A13 * **banner**. Every server-derived string (paths, hunk headers, code lines, git error messages) is
* colour tokens (added → green, removed → red). Layout/interaction is device-QA (plan §7). * rendered as **inert [Text]** — never `ClickableText`/autolink/markdown (plan §8). Interaction is
* device-QA (plan §7).
*/ */
@Composable @Composable
public fun DiffScreen( public fun DiffScreen(
@@ -61,29 +67,37 @@ public fun DiffScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
onBack: (() -> Unit)? = null, onBack: (() -> Unit)? = null,
onSetBase: (String?) -> Unit = {},
onToggleStage: (String, Boolean) -> Unit = { _, _ -> },
onCommit: (String) -> Unit = {},
onPush: () -> Unit = {},
onDismissBanner: () -> Unit = {},
) { ) {
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
DiffHeader(staged = state.staged, onSelectStaged = onSelectStaged, onBack = onBack) DiffHeader(state = state, onSelectStaged = onSelectStaged, onBack = onBack, onSetBase = onSetBase)
if (state.truncated) { if (state.truncated) DiffNotice("Diff truncated — too large to display fully.")
DiffNotice("Diff truncated — too large to display fully.") state.writeBanner?.let { WriteBanner(it, onDismissBanner) }
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline) HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
when (state.phase) { when (state.phase) {
DiffPhase.IDLE, DiffPhase.LOADING -> CenteredContent { CircularProgressIndicator() } DiffPhase.IDLE, DiffPhase.LOADING -> CenteredContent { CircularProgressIndicator() }
DiffPhase.EMPTY -> CenteredMessage("No changes") DiffPhase.EMPTY -> CenteredMessage("No changes")
DiffPhase.ERROR -> DiffError(onRetry = onRefresh) DiffPhase.ERROR -> DiffError(onRetry = onRefresh)
DiffPhase.LOADED -> DiffList(rows = state.rows) DiffPhase.LOADED -> DiffList(rows = state.rows, writeEnabled = state.writeEnabled, staged = state.staged, onToggleStage = onToggleStage)
} }
} }
if (state.writeEnabled) {
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
CommitBar(writing = state.writing, onCommit = onCommit, onPush = onPush)
}
} }
} }
} }
/** /**
* Stateful entry point: binds [viewModel] to a lifecycle scope, collects its state, and wires the * Stateful entry point: binds [viewModel] to a lifecycle scope, collects its state, and wires the
* toggle/refresh callbacks. The nav layer supplies the already-constructed presenter (host + path). * toggle/refresh/base/git-write callbacks. The nav layer supplies the already-constructed presenter.
*/ */
@Composable @Composable
public fun DiffScreen( public fun DiffScreen(
@@ -92,7 +106,6 @@ public fun DiffScreen(
onBack: (() -> Unit)? = null, onBack: (() -> Unit)? = null,
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
// Bind to the LaunchedEffect scope (cancelled when this screen leaves composition), then load.
LaunchedEffect(viewModel) { viewModel.bind(this) } LaunchedEffect(viewModel) { viewModel.bind(this) }
DiffScreen( DiffScreen(
state = state, state = state,
@@ -100,50 +113,60 @@ public fun DiffScreen(
modifier = modifier, modifier = modifier,
onRefresh = viewModel::refresh, onRefresh = viewModel::refresh,
onBack = onBack, onBack = onBack,
onSetBase = viewModel::setBase,
onToggleStage = viewModel::toggleStage,
onCommit = viewModel::commit,
onPush = viewModel::push,
onDismissBanner = viewModel::clearWriteBanner,
) )
} }
@Composable @Composable
private fun DiffHeader( private fun DiffHeader(
staged: Boolean, state: DiffUiState,
onSelectStaged: (Boolean) -> Unit, onSelectStaged: (Boolean) -> Unit,
onBack: (() -> Unit)?, onBack: (() -> Unit)?,
onSetBase: (String?) -> Unit,
) { ) {
Row( var baseInput by remember(state.base) { mutableStateOf(state.base ?: "") }
modifier = Modifier Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8)) {
.fillMaxWidth() Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8), if (onBack != null) TextButton(onClick = onBack) { Text("Back") }
verticalAlignment = Alignment.CenterVertically, Text(text = "Diff", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground)
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), Spacer(modifier = Modifier.width(Spacing.sm8))
) { if (state.base == null) {
if (onBack != null) { // Working/Staged toggle is suppressed in base mode (server ignores staged then).
TextButton(onClick = onBack) { Text("Back") } FilterChip(selected = !state.staged, onClick = { onSelectStaged(false) }, label = { Text("Working") })
FilterChip(selected = state.staged, onClick = { onSelectStaged(true) }, label = { Text("Staged") })
} else {
Text(text = "vs ${state.base}", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(top = Spacing.xs4)) {
OutlinedTextField(
value = baseInput,
onValueChange = { baseInput = it },
label = { Text("对比基点 (base rev)") },
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedButton(onClick = { onSetBase(baseInput.takeIf { it.isNotBlank() }) }) { Text("对比") }
if (state.base != null) OutlinedButton(onClick = { baseInput = ""; onSetBase(null) }) { Text("清除") }
} }
Text(
text = "Diff",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(modifier = Modifier.width(Spacing.sm8))
FilterChip(
selected = !staged,
onClick = { onSelectStaged(false) },
label = { Text("Working") },
)
FilterChip(
selected = staged,
onClick = { onSelectStaged(true) },
label = { Text("Staged") },
)
} }
} }
@Composable @Composable
private fun DiffList(rows: List<DiffRow>) { private fun DiffList(
rows: List<DiffRow>,
writeEnabled: Boolean,
staged: Boolean,
onToggleStage: (String, Boolean) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.id }) { row -> items(items = rows, key = { it.id }) { row ->
when (row) { when (row) {
is DiffFileHeaderRow -> FileHeader(row) is DiffFileHeaderRow -> FileHeader(row, writeEnabled = writeEnabled, staged = staged, onToggleStage = onToggleStage)
is DiffHunkHeaderRow -> DiffText(row.header, MaterialTheme.colorScheme.primary) is DiffHunkHeaderRow -> DiffText(row.header, MaterialTheme.colorScheme.primary)
is DiffLineRow -> DiffText(markerFor(row.kind) + row.text, lineColor(row.kind)) is DiffLineRow -> DiffText(markerFor(row.kind) + row.text, lineColor(row.kind))
is DiffBinaryRow -> DiffText("Binary file", MaterialTheme.colorScheme.onSurfaceVariant) is DiffBinaryRow -> DiffText("Binary file", MaterialTheme.colorScheme.onSurfaceVariant)
@@ -153,11 +176,14 @@ private fun DiffList(rows: List<DiffRow>) {
} }
@Composable @Composable
private fun FileHeader(row: DiffFileHeaderRow) { private fun FileHeader(
row: DiffFileHeaderRow,
writeEnabled: Boolean,
staged: Boolean,
onToggleStage: (String, Boolean) -> Unit,
) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -172,6 +198,42 @@ private fun FileHeader(row: DiffFileHeaderRow) {
) )
Text("+${row.added}", style = WebTermType.metaMono, color = WebTermColors.statusWorking) Text("+${row.added}", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
Text("-${row.removed}", style = WebTermType.metaMono, color = WebTermColors.statusStuck) Text("-${row.removed}", style = WebTermType.metaMono, color = WebTermColors.statusStuck)
if (writeEnabled) {
// In staged view we offer Unstage; in working view we offer Stage.
TextButton(onClick = { onToggleStage(row.stagePath, !staged) }) { Text(if (staged) "取消暂存" else "暂存") }
}
}
}
@Composable
private fun CommitBar(writing: Boolean, onCommit: (String) -> Unit, onPush: () -> Unit) {
var message by remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
OutlinedTextField(
value = message,
onValueChange = { message = it },
label = { Text("提交信息") },
singleLine = true,
enabled = !writing,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(enabled = !writing, onClick = { onCommit(message); message = "" }) { Text("提交") }
OutlinedButton(enabled = !writing, onClick = onPush) { Text("推送") }
}
}
}
@Composable
private fun WriteBanner(banner: DiffWriteBanner, onDismiss: () -> Unit) {
val color = if (banner.isError) WebTermColors.statusStuck else WebTermColors.statusWorking
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(text = banner.message, style = WebTermType.metaMono, color = color, modifier = Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text("×") }
} }
} }
@@ -185,9 +247,7 @@ private fun DiffText(text: String, color: Color) {
softWrap = false, softWrap = false,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Clip, overflow = TextOverflow.Clip,
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = 1.dp),
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = 1.dp),
) )
} }
@@ -207,17 +267,13 @@ private fun DiffNotice(message: String) {
text = message, text = message,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
) )
} }
@Composable @Composable
private fun CenteredMessage(message: String) { private fun CenteredMessage(message: String) {
CenteredContent { CenteredContent { Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) }
Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} }
@Composable @Composable
@@ -246,17 +302,15 @@ private fun markerFor(kind: DiffLineKind): String = when (kind) {
@Composable @Composable
private fun DiffScreenPreview() { private fun DiffScreenPreview() {
val rows = listOf<DiffRow>( val rows = listOf<DiffRow>(
DiffFileHeaderRow(0, "src/app/Main.kt", "modified", added = 2, removed = 1), DiffFileHeaderRow(0, "src/app/Main.kt", "src/app/Main.kt", "modified", added = 2, removed = 1),
DiffHunkHeaderRow(1, "@@ -1,3 +1,4 @@"), DiffHunkHeaderRow(1, "@@ -1,3 +1,4 @@"),
DiffLineRow(2, DiffLineKind.CONTEXT, "fun main() {"), DiffLineRow(2, DiffLineKind.CONTEXT, "fun main() {"),
DiffLineRow(3, DiffLineKind.REMOVED, " println(\"old\")"), DiffLineRow(3, DiffLineKind.REMOVED, " println(\"old\")"),
DiffLineRow(4, DiffLineKind.ADDED, " println(\"new\")"), DiffLineRow(4, DiffLineKind.ADDED, " println(\"new\")"),
DiffLineRow(5, DiffLineKind.ADDED, " println(\"added\")"),
DiffLineRow(6, DiffLineKind.CONTEXT, "}"),
) )
WebTermTheme { WebTermTheme {
DiffScreen( DiffScreen(
state = DiffUiState(staged = false, phase = DiffPhase.LOADED, rows = rows, truncated = true), state = DiffUiState(staged = false, phase = DiffPhase.LOADED, rows = rows, truncated = true, canWrite = true),
onSelectStaged = {}, onSelectStaged = {},
) )
} }

View File

@@ -0,0 +1,385 @@
package wang.yaojia.webterm.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.CertSummaryView
import wang.yaojia.webterm.viewmodels.EnrollError
import wang.yaojia.webterm.viewmodels.EnrollPhase
import wang.yaojia.webterm.viewmodels.EnrollmentUiState
import wang.yaojia.webterm.viewmodels.EnrollmentViewModel
/**
* # EnrollmentScreen (B4) — zero-`.p12` device enrollment (the phone half of zero-touch).
*
* The Compose shell over [EnrollmentViewModel], reached from the session-list host menu "自动获取证书"
* (mirrors iOS `EnrollmentScreen` presented from `SessionListScreen`'s host menu). One operator login
* generates a NON-EXPORTABLE hardware key + CSR and obtains a device cert with no file at all; the cert is
* committed to the shared store and presented automatically on the existing mTLS path (cache-refreshed, so
* no restart). The manual `.p12` path ([ClientCertScreen]) remains available alongside this one.
*
* ### Secrets & trust discipline (plan §8)
* - The operator password is a masked field bound straight to the ViewModel and cleared after every
* attempt — never logged, persisted, or echoed. The private key is generated non-exportably in secure
* hardware inside the library; this screen never sees it.
* - Every cert-derived string (CNs, expiry) and every error message is inert [Text] — no autolink/markdown.
* Error copy is app-authored, never a server/exception string.
*
* The keystore/network I/O it drives is device-QA (plan §7); the state machine is JVM-tested in
* `EnrollmentViewModelTest`.
*/
@Composable
public fun EnrollmentScreen(
viewModel: EnrollmentViewModel,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(viewModel) { viewModel.bind(this) }
EnrollmentScreen(
state = state,
onControlPlaneUrlChange = viewModel::onControlPlaneUrlChange,
onSubdomainChange = viewModel::onSubdomainChange,
onDeviceNameChange = viewModel::onDeviceNameChange,
onPasswordChange = viewModel::onPasswordChange,
onEnroll = viewModel::enroll,
onDismissError = viewModel::clearError,
modifier = modifier,
onBack = onBack,
)
}
/** Stateless body — pure inputs so a [Preview] and any host can drive it without the VM machinery. */
@Composable
public fun EnrollmentScreen(
state: EnrollmentUiState,
onControlPlaneUrlChange: (String) -> Unit,
onSubdomainChange: (String) -> Unit,
onDeviceNameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onEnroll: () -> Unit,
onDismissError: () -> Unit,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
) {
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(Spacing.lg16),
verticalArrangement = Arrangement.spacedBy(Spacing.md12),
) {
Header(onBack = onBack)
if (state.phase == EnrollPhase.LOADING) {
LoadingRow()
return@Column
}
InstalledSection(summary = state.summary)
state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) }
if (state.didSucceed && state.error == null) SuccessCard()
EnrollForm(
state = state,
onControlPlaneUrlChange = onControlPlaneUrlChange,
onSubdomainChange = onSubdomainChange,
onDeviceNameChange = onDeviceNameChange,
onPasswordChange = onPasswordChange,
onEnroll = onEnroll,
)
Text(
text = EnrollCopy.FOOTER,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun Header(onBack: (() -> Unit)?) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
if (onBack != null) {
TextButton(onClick = onBack) { Text("返回") }
}
Text(text = EnrollCopy.TITLE, style = MaterialTheme.typography.headlineSmall)
}
}
/** The currently-installed identity summary (or "none" copy) — the "已安装证书" section. */
@Composable
private fun InstalledSection(summary: CertSummaryView?) {
if (summary == null) {
Text(
text = EnrollCopy.NONE_INSTALLED,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(Spacing.lg16),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
SummaryRow(label = "设备CN", value = summary.subjectCommonName)
SummaryRow(label = "签发方CN", value = summary.issuerCommonName)
SummaryRow(label = "到期", value = summary.expiry)
if (summary.isExpired) {
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Text(
text = "⚠ 证书已过期,请重新注册。",
style = MaterialTheme.typography.bodyMedium,
color = WebTermColors.statusStuck,
)
}
}
}
}
@Composable
private fun SummaryRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Spacing.md12),
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
// Cert-derived value: inert monospaced Text — no linkify/markdown (plan §8).
Text(text = value, style = WebTermType.monoTabular(13), color = MaterialTheme.colorScheme.onSurface)
}
}
@Composable
private fun EnrollForm(
state: EnrollmentUiState,
onControlPlaneUrlChange: (String) -> Unit,
onSubdomainChange: (String) -> Unit,
onDeviceNameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onEnroll: () -> Unit,
) {
val enrolling = state.phase == EnrollPhase.ENROLLING
Text(
text = if (state.summary == null) EnrollCopy.ENROLL_HEADER else EnrollCopy.ROTATE_HEADER,
style = MaterialTheme.typography.titleSmall,
)
OutlinedTextField(
value = state.controlPlaneUrl,
onValueChange = onControlPlaneUrlChange,
label = { Text(EnrollCopy.CONTROL_PLANE_URL) },
singleLine = true,
enabled = !enrolling,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.subdomain,
onValueChange = onSubdomainChange,
label = { Text(EnrollCopy.SUBDOMAIN) },
singleLine = true,
enabled = !enrolling,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.deviceName,
onValueChange = onDeviceNameChange,
label = { Text(EnrollCopy.DEVICE_NAME) },
singleLine = true,
enabled = !enrolling,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text(EnrollCopy.PASSWORD) },
singleLine = true,
enabled = !enrolling,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = onEnroll,
enabled = state.canEnroll,
modifier = Modifier.fillMaxWidth(),
) {
if (enrolling) {
CircularProgressIndicator(modifier = Modifier.padding(Spacing.xs4))
} else {
Text(if (state.summary == null) EnrollCopy.ENROLL_ACTION else EnrollCopy.ROTATE_ACTION)
}
}
}
@Composable
private fun ErrorCard(error: EnrollError, onDismiss: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(Spacing.md12),
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
Text(
text = errorCopy(error),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("知道了") }
}
}
}
@Composable
private fun SuccessCard() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Text(
text = EnrollCopy.SUCCESS,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.fillMaxWidth()
.padding(Spacing.md12),
)
}
}
@Composable
private fun LoadingRow() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
}
/** Maps the coarse [EnrollError] to inert, app-authored copy (never an exception/server string, §8). */
private fun errorCopy(error: EnrollError): String = when (error) {
EnrollError.INVALID_URL -> "控制面地址无效,请输入 https:// 开头的完整地址。"
EnrollError.MISSING_FIELDS -> "请填写子域名、设备名称和操作口令。"
EnrollError.BAD_CREDENTIAL -> "操作口令错误,请重试。"
EnrollError.SUBDOMAIN_NOT_OWNED -> "该账号未拥有此子域名,无法签发证书。"
EnrollError.RATE_LIMITED -> "请求过于频繁,请稍后再试。"
EnrollError.REJECTED -> "请求被拒绝:子域名或证书请求无效。"
EnrollError.ENROLL_FAILED -> "注册失败,请稍后重试。"
EnrollError.SERVER -> "服务器返回异常,请稍后重试。"
EnrollError.KEYGEN -> "生成硬件密钥失败(本设备可能不支持安全硬件)。"
EnrollError.UNKNOWN -> "注册失败,请重试。"
}
/** User-facing copy (Chinese), mirroring iOS `EnrollmentCopy`. */
private object EnrollCopy {
const val TITLE = "自动获取证书"
const val NONE_INSTALLED = "尚未安装设备证书。"
const val ENROLL_HEADER = "注册本设备"
const val ROTATE_HEADER = "重新注册"
const val CONTROL_PLANE_URL = "控制面地址"
const val SUBDOMAIN = "子域名(你拥有的隧道名)"
const val DEVICE_NAME = "设备名称"
const val PASSWORD = "操作口令"
const val ENROLL_ACTION = "注册本设备"
const val ROTATE_ACTION = "重新注册"
const val SUCCESS = "已注册,证书已保存到本设备安全硬件,连接隧道主机时将自动出示。"
const val FOOTER =
"首次登录一次即可本设备在安全硬件StrongBox / TEE生成不可导出的私钥向控制面申请证书并自动保存" +
"之后连接隧道主机时自动出示,无需再手动导入 .p12。"
}
@Preview(name = "EnrollmentScreen — none installed")
@Composable
private fun EnrollmentScreenNonePreview() {
WebTermTheme {
EnrollmentScreen(
state = EnrollmentUiState(
controlPlaneUrl = "https://cp.terminal.yaojia.wang",
subdomain = "alice",
deviceName = "Pixel 8",
phase = EnrollPhase.IDLE,
),
onControlPlaneUrlChange = {},
onSubdomainChange = {},
onDeviceNameChange = {},
onPasswordChange = {},
onEnroll = {},
onDismissError = {},
)
}
}
@Preview(name = "EnrollmentScreen — installed + error")
@Composable
private fun EnrollmentScreenInstalledPreview() {
WebTermTheme {
EnrollmentScreen(
state = EnrollmentUiState(
controlPlaneUrl = "https://cp.terminal.yaojia.wang",
subdomain = "alice",
deviceName = "Pixel 8",
summary = CertSummaryView("alice-pixel", "webterm-device-ca", "2027年1月8日", isExpired = false),
phase = EnrollPhase.IDLE,
error = EnrollError.SUBDOMAIN_NOT_OWNED,
),
onControlPlaneUrlChange = {},
onSubdomainChange = {},
onDeviceNameChange = {},
onPasswordChange = {},
onEnroll = {},
onDismissError = {},
)
}
}

View File

@@ -9,23 +9,36 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.models.CommitLogEntry
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectSessionRef import wang.yaojia.webterm.api.models.ProjectSessionRef
import wang.yaojia.webterm.api.models.WorktreeInfo import wang.yaojia.webterm.api.models.WorktreeInfo
@@ -36,19 +49,19 @@ import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
import wang.yaojia.webterm.viewmodels.ProjectsCopy import wang.yaojia.webterm.viewmodels.WorktreeViewModel
import java.net.URI
/** /**
* # ProjectDetailScreen (A23) — one project's detail (branch · worktrees · sessions · CLAUDE.md) plus * # ProjectDetailScreen (A23 + W5) — one project's detail (branch · worktrees · sessions · CLAUDE.md),
* "open Claude here". Mirrors web `renderProjectDetail` / iOS `ProjectDetailScreen`. * plus the W5 additions: a **PR + CI chip** (tappable only when the PR url parses as https), a
* **recent-commits** section, and guarded **worktree create / remove / prune** actions.
* *
* Every server string (name/path/branch/worktree/CLAUDE.md body) is rendered as **inert [Text]** — no * Every server string (name/path/branch/worktree/CLAUDE.md/commit subject/PR title/error) is rendered
* autolink/markdown (plan §8); the CLAUDE.md body is shown verbatim in a monospaced block. The three * as **inert [Text]** — no autolink/markdown (plan §8). The single exception is the PR chip, which is a
* failure buckets ([ProjectDetailViewModel.Failure]) map to copy + a retry action. * link ONLY when its url is a valid https URL (scheme-validated before it is made clickable). The
* * worktree actions drive [ProjectDetailViewModel.worktree]; a remove force-confirms in a dialog and a
* @param onBack pop back to the projects grid. * main worktree is never removable.
* @param onOpenClaude open a new session in the project cwd (`attach(null, cwd)`); the nav layer routes
* it through [wang.yaojia.webterm.viewmodels.ProjectsViewModel.requestOpenClaude] (path re-validated).
*/ */
@Composable @Composable
public fun ProjectDetailScreen( public fun ProjectDetailScreen(
@@ -56,8 +69,11 @@ public fun ProjectDetailScreen(
onBack: () -> Unit, onBack: () -> Unit,
onOpenClaude: (String) -> Unit, onOpenClaude: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onViewDiff: (String) -> Unit = {},
) { ) {
val phase by viewModel.phase.collectAsStateWithLifecycle() val phase by viewModel.phase.collectAsStateWithLifecycle()
val prChip by viewModel.prChip.collectAsStateWithLifecycle()
val recent by viewModel.recentCommits.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(viewModel) { viewModel.load() } LaunchedEffect(viewModel) { viewModel.load() }
@@ -71,7 +87,14 @@ public fun ProjectDetailScreen(
is ProjectDetailViewModel.Phase.Failed -> is ProjectDetailViewModel.Phase.Failed ->
Failure(current.failure, onRetry = { scope.launch { viewModel.load() } }) Failure(current.failure, onRetry = { scope.launch { viewModel.load() } })
is ProjectDetailViewModel.Phase.Loaded -> is ProjectDetailViewModel.Phase.Loaded ->
DetailBody(detail = current.detail, onOpenClaude = onOpenClaude) DetailBody(
detail = current.detail,
prChip = prChip,
recent = recent,
worktree = viewModel.worktree,
onOpenClaude = onOpenClaude,
onViewDiff = onViewDiff,
)
} }
} }
} }
@@ -90,7 +113,14 @@ private fun DetailHeaderBar(onBack: () -> Unit) {
} }
@Composable @Composable
private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) { private fun DetailBody(
detail: ProjectDetail,
prChip: ProjectDetailViewModel.PrChip,
recent: ProjectDetailViewModel.RecentCommits,
worktree: WorktreeViewModel?,
onOpenClaude: (String) -> Unit,
onViewDiff: (String) -> Unit = {},
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -113,37 +143,236 @@ private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) {
} }
Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支") PrChipRow(prChip)
if (!detail.isGit) {
EmptyLine("不是 git 仓库。") if (detail.isGit && worktree != null) {
} else if (detail.worktrees.isEmpty()) { WorktreeSection(detail = detail, worktree = worktree)
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
} else { } else {
for (worktree in detail.worktrees) WorktreeRow(worktree) SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
if (!detail.isGit) EmptyLine("不是 git 仓库。")
else if (detail.worktrees.isEmpty()) EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
else for (w in detail.worktrees) WorktreeRow(w, onRemove = null)
} }
val running = detail.sessions.filter { !it.exited } val running = detail.sessions.filter { !it.exited }
SectionTitle("运行中的会话(${running.size}") SectionTitle("运行中的会话(${running.size}")
if (running.isEmpty()) { if (running.isEmpty()) EmptyLine("没有运行中的会话 —— 在下方开一个。")
EmptyLine("没有运行中的会话 —— 在下方开一个。") else for (session in running) SessionRow(session)
} else {
for (session in running) SessionRow(session) RecentCommitsSection(recent)
}
SectionTitle("CLAUDE.md") SectionTitle("CLAUDE.md")
val claudeMd = detail.claudeMd val claudeMd = detail.claudeMd
if (detail.hasClaudeMd && claudeMd != null) { if (detail.hasClaudeMd && claudeMd != null) ClaudeMdBlock(claudeMd)
ClaudeMdBlock(claudeMd) else EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
} else {
EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
}
TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") } Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") }
if (detail.isGit) TextButton(onClick = { onViewDiff(detail.path) }) { Text("查看改动 (diff)") }
}
}
}
// ── PR + CI chip (link only when https) ──────────────────────────────────────────────────────────
@Composable
private fun PrChipRow(prChip: ProjectDetailViewModel.PrChip) {
when (prChip) {
ProjectDetailViewModel.PrChip.Hidden -> Unit
ProjectDetailViewModel.PrChip.Loading -> EmptyLine("正在读取 PR 状态…")
ProjectDetailViewModel.PrChip.Unavailable -> EmptyLine("PR 状态不可用。")
is ProjectDetailViewModel.PrChip.Loaded -> PrChipContent(prChip.status)
} }
} }
@Composable @Composable
private fun WorktreeRow(worktree: WorktreeInfo) { private fun PrChipContent(pr: PrStatus) {
val uriHandler = LocalUriHandler.current
val httpsUrl = pr.url?.let { if (isHttpsUrl(it)) it else null } // link ONLY when https (plan §Security)
val label = prChipLabel(pr)
val color = prChipColor(pr)
if (httpsUrl != null && pr.availability == PrAvailability.OK) {
AssistChip(
onClick = { runCatching { uriHandler.openUri(httpsUrl) } },
label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) },
colors = AssistChipDefaults.assistChipColors(labelColor = color),
)
} else {
// Non-ok / non-https → an INERT, non-clickable line (never make a hostile url tappable).
Text(text = label, style = WebTermType.metaMono, color = color)
}
}
private fun prChipLabel(pr: PrStatus): String = when (pr.availability) {
PrAvailability.OK -> {
val num = pr.number?.let { "#$it " } ?: ""
val checks = pr.checks?.let { " (${it.passing}/${it.total})" } ?: ""
"PR $num${pr.title ?: ""}$checks".trim()
}
PrAvailability.NO_PR -> "当前分支没有 PR"
PrAvailability.NOT_INSTALLED -> "未安装 gh无法读取 PR"
PrAvailability.UNAUTHENTICATED -> "gh 未登录,无法读取 PR"
PrAvailability.DISABLED -> "PR 集成已禁用"
PrAvailability.ERROR -> "PR 状态读取失败"
}
@Composable
private fun prChipColor(pr: PrStatus): androidx.compose.ui.graphics.Color = when {
pr.availability != PrAvailability.OK -> MaterialTheme.colorScheme.onSurfaceVariant
(pr.checks?.failing ?: 0) > 0 -> WebTermColors.statusStuck
(pr.checks?.pending ?: 0) > 0 -> WebTermColors.statusWaiting
else -> WebTermColors.statusWorking
}
private fun isHttpsUrl(url: String): Boolean =
runCatching { URI(url.trim()).scheme?.lowercase() == "https" }.getOrDefault(false)
// ── Worktree section (create / remove / prune) ────────────────────────────────────────────────────
@Composable
private fun WorktreeSection(detail: ProjectDetail, worktree: WorktreeViewModel) {
val scope = rememberCoroutineScope()
val phase by worktree.phase.collectAsStateWithLifecycle()
var branch by remember { mutableStateOf("") }
var base by remember { mutableStateOf("") }
var removeTarget by remember { mutableStateOf<WorktreeInfo?>(null) }
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
if (detail.worktrees.isEmpty()) {
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
} else {
for (w in detail.worktrees) WorktreeRow(w, onRemove = { if (!w.isMain) removeTarget = w })
}
// New-worktree inline form.
WebTermCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
OutlinedTextField(
value = branch,
onValueChange = { branch = it },
label = { Text("新工作树分支名") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = base,
onValueChange = { base = it },
label = { Text("基点(可选)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(
enabled = phase != WorktreeViewModel.Phase.Working,
onClick = { scope.launch { worktree.create(branch, base) } },
) { Text("新建工作树") }
OutlinedButton(
enabled = phase != WorktreeViewModel.Phase.Working,
onClick = { scope.launch { worktree.prune() } },
) { Text("清理") }
}
WorktreePhaseBanner(phase, onDismiss = { worktree.reset() })
}
}
val target = removeTarget
if (target != null) {
RemoveWorktreeDialog(
worktree = target,
onConfirm = { force ->
removeTarget = null
scope.launch { worktree.remove(target, force) }
},
onDismiss = { removeTarget = null },
)
}
}
@Composable
private fun WorktreePhaseBanner(phase: WorktreeViewModel.Phase, onDismiss: () -> Unit) {
when (phase) {
WorktreeViewModel.Phase.Idle -> Unit
WorktreeViewModel.Phase.Working -> Text("处理中…", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
is WorktreeViewModel.Phase.Done -> BannerLine(phase.message, WebTermColors.statusWorking, onDismiss)
is WorktreeViewModel.Phase.Failed -> BannerLine(phase.message, WebTermColors.statusStuck, onDismiss)
}
}
@Composable
private fun BannerLine(message: String, color: androidx.compose.ui.graphics.Color, onDismiss: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Text(text = message, style = WebTermType.metaMono, color = color, modifier = Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text("知道了") }
}
}
@Composable
private fun RemoveWorktreeDialog(
worktree: WorktreeInfo,
onConfirm: (force: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
var force by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("删除工作树") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurface)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = force, onCheckedChange = { force = it })
Text("强制删除(丢弃未提交改动)")
}
}
},
confirmButton = { TextButton(onClick = { onConfirm(force) }) { Text("删除") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
)
}
// ── Recent commits ────────────────────────────────────────────────────────────────────────────────
@Composable
private fun RecentCommitsSection(recent: ProjectDetailViewModel.RecentCommits) {
when (recent) {
ProjectDetailViewModel.RecentCommits.Hidden -> Unit
ProjectDetailViewModel.RecentCommits.Loading -> {
SectionTitle("最近提交"); EmptyLine("正在读取提交记录…")
}
ProjectDetailViewModel.RecentCommits.Unavailable -> {
SectionTitle("最近提交"); EmptyLine("提交记录不可用。")
}
is ProjectDetailViewModel.RecentCommits.Loaded -> {
SectionTitle("最近提交")
if (recent.result.commits.isEmpty()) EmptyLine("暂无提交。")
else for (commit in recent.result.commits) CommitRow(commit)
}
}
}
@Composable
private fun CommitRow(commit: CommitLogEntry) {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.fillMaxWidth()) {
Text(
text = commit.hash.take(7),
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = commit.subject,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
// ── Rows / helpers (reused from A23) ────────────────────────────────────────────────────────────────
@Composable
private fun WorktreeRow(worktree: WorktreeInfo, onRemove: (() -> Unit)?) {
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached" val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
WebTermCard(modifier = Modifier.fillMaxWidth()) { WebTermCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) { Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
@@ -152,6 +381,7 @@ private fun WorktreeRow(worktree: WorktreeInfo) {
if (worktree.isMain) Tag("main") if (worktree.isMain) Tag("main")
if (worktree.isCurrent) Tag("current") if (worktree.isCurrent) Tag("current")
if (worktree.locked == true) Tag("locked") if (worktree.locked == true) Tag("locked")
if (onRemove != null && !worktree.isMain) TextButton(onClick = onRemove) { Text("删除") }
} }
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
@@ -178,7 +408,6 @@ private fun SessionRow(session: ProjectSessionRef) {
@Composable @Composable
private fun ClaudeMdBlock(text: String) { private fun ClaudeMdBlock(text: String) {
WebTermCard(modifier = Modifier.fillMaxWidth()) { WebTermCard(modifier = Modifier.fillMaxWidth()) {
// Inert monospaced block — CLAUDE.md is server content; never linkify/markdown (§8).
Text(text = text, style = WebTermType.monoTabular(12), color = MaterialTheme.colorScheme.onSurface) Text(text = text, style = WebTermType.monoTabular(12), color = MaterialTheme.colorScheme.onSurface)
} }
} }
@@ -238,6 +467,17 @@ private fun ProjectDetailScreenPreview() {
claudeMd = "# CLAUDE.md\n\nProject instructions…", claudeMd = "# CLAUDE.md\n\nProject instructions…",
) )
WebTermTheme { WebTermTheme {
DetailBody(detail = detail, onOpenClaude = {}) DetailBody(
detail = detail,
prChip = ProjectDetailViewModel.PrChip.Loaded(PrStatus(availability = PrAvailability.NO_PR)),
recent = ProjectDetailViewModel.RecentCommits.Loaded(
wang.yaojia.webterm.api.models.GitLogResult(
commits = listOf(CommitLogEntry("abc1234", 1, "Initial commit")),
truncated = false,
),
),
worktree = null,
onOpenClaude = {},
)
} }
} }

View File

@@ -222,13 +222,21 @@ private fun ProjectCard(
} }
} }
project.branch?.let { branch -> project.branch?.let { branch ->
Text( Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
text = branch, Text(
style = WebTermType.metaMono, text = branch,
color = MaterialTheme.colorScheme.onSurfaceVariant, style = WebTermType.metaMono,
maxLines = 1, color = MaterialTheme.colorScheme.onSurfaceVariant,
overflow = TextOverflow.Ellipsis, maxLines = 1,
) overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
// W3 sync chip: commits ahead/behind upstream (best-effort; only shown when non-zero).
val ahead = project.ahead ?: 0
val behind = project.behind ?: 0
if (ahead > 0) Text(text = "$ahead", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
if (behind > 0) Text(text = "$behind", style = WebTermType.metaMono, color = WebTermColors.statusWaiting)
}
} }
val running = project.sessions.count { !it.exited } val running = project.sessions.count { !it.exited }
if (running > 0) { if (running > 0) {

View File

@@ -77,6 +77,7 @@ import java.util.UUID
* @param onOpenSession open the terminal (A21) for a tapped session id (its dot is cleared first). * @param onOpenSession open the terminal (A21) for a tapped session id (its dot is cleared first).
* @param onNewSession start a new session on the active host (`attach(null)`; A21 owns the terminal). * @param onNewSession start a new session on the active host (`attach(null)`; A21 owns the terminal).
* @param onPairHost host-menu **配对新主机** → the pairing flow (A19). * @param onPairHost host-menu **配对新主机** → the pairing flow (A19).
* @param onEnroll host-menu **自动获取证书** → the zero-`.p12` enrollment screen (B4).
* @param onImportCert host-menu **设备证书** → the device-cert screen (A27). * @param onImportCert host-menu **设备证书** → the device-cert screen (A27).
* @param thumbnails off-screen preview seam; production wires it to the active host's * @param thumbnails off-screen preview seam; production wires it to the active host's
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (§6.7). `null` = placeholder tiles. * [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (§6.7). `null` = placeholder tiles.
@@ -87,6 +88,7 @@ public fun SessionListScreen(
onOpenSession: (UUID) -> Unit, onOpenSession: (UUID) -> Unit,
onNewSession: () -> Unit, onNewSession: () -> Unit,
onPairHost: () -> Unit, onPairHost: () -> Unit,
onEnroll: () -> Unit,
onImportCert: () -> Unit, onImportCert: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
thumbnails: SessionThumbnails? = null, thumbnails: SessionThumbnails? = null,
@@ -110,6 +112,7 @@ public fun SessionListScreen(
onSelectHost = { id -> scope.launch { viewModel.selectHost(id) } }, onSelectHost = { id -> scope.launch { viewModel.selectHost(id) } },
onNewSession = onNewSession, onNewSession = onNewSession,
onPairHost = onPairHost, onPairHost = onPairHost,
onEnroll = onEnroll,
onImportCert = onImportCert, onImportCert = onImportCert,
) )
}, },
@@ -138,6 +141,7 @@ private fun SessionListTopBar(
onSelectHost: (String) -> Unit, onSelectHost: (String) -> Unit,
onNewSession: () -> Unit, onNewSession: () -> Unit,
onPairHost: () -> Unit, onPairHost: () -> Unit,
onEnroll: () -> Unit,
onImportCert: () -> Unit, onImportCert: () -> Unit,
) { ) {
var menuOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) }
@@ -156,6 +160,7 @@ private fun SessionListTopBar(
onDismiss = { menuOpen = false }, onDismiss = { menuOpen = false },
onSelectHost = { menuOpen = false; onSelectHost(it) }, onSelectHost = { menuOpen = false; onSelectHost(it) },
onPairHost = { menuOpen = false; onPairHost() }, onPairHost = { menuOpen = false; onPairHost() },
onEnroll = { menuOpen = false; onEnroll() },
onImportCert = { menuOpen = false; onImportCert() }, onImportCert = { menuOpen = false; onImportCert() },
) )
} }
@@ -163,7 +168,7 @@ private fun SessionListTopBar(
) )
} }
/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the two actions. */ /** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions. */
@Composable @Composable
private fun HostMenu( private fun HostMenu(
expanded: Boolean, expanded: Boolean,
@@ -171,6 +176,7 @@ private fun HostMenu(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSelectHost: (String) -> Unit, onSelectHost: (String) -> Unit,
onPairHost: () -> Unit, onPairHost: () -> Unit,
onEnroll: () -> Unit,
onImportCert: () -> Unit, onImportCert: () -> Unit,
) { ) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
@@ -184,6 +190,8 @@ private fun HostMenu(
} }
if (hosts.isNotEmpty()) HorizontalDivider() if (hosts.isNotEmpty()) HorizontalDivider()
DropdownMenuItem(text = { Text("配对新主机") }, onClick = onPairHost) DropdownMenuItem(text = { Text("配对新主机") }, onClick = onPairHost)
// 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS.
DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll)
DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert) DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert)
} }
} }

View File

@@ -13,6 +13,11 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.intOrNull
import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest import wang.yaojia.webterm.wire.HttpRequest
@@ -47,14 +52,17 @@ import java.net.URI
public class DiffViewModel( public class DiffViewModel(
private val fetcher: DiffFetcher, private val fetcher: DiffFetcher,
private val path: String, private val path: String,
/** Guarded git-write seam (stage/commit/push). Null → the diff is inert read-only (no buttons). */
private val writer: GitWriteGateway? = null,
) { ) {
private val _uiState = MutableStateFlow(DiffUiState()) private val _uiState = MutableStateFlow(DiffUiState(canWrite = writer != null))
/** The single snapshot `DiffScreen` renders from. */ /** The single snapshot `DiffScreen` renders from. */
public val uiState: StateFlow<DiffUiState> = _uiState.asStateFlow() public val uiState: StateFlow<DiffUiState> = _uiState.asStateFlow()
private var scope: CoroutineScope? = null private var scope: CoroutineScope? = null
private var job: Job? = null private var job: Job? = null
private var writeJob: Job? = null
/** Bind the scope loads launch into (the screen passes a lifecycle scope) and kick the first load. */ /** Bind the scope loads launch into (the screen passes a lifecycle scope) and kick the first load. */
public fun bind(scope: CoroutineScope) { public fun bind(scope: CoroutineScope) {
@@ -62,27 +70,46 @@ public class DiffViewModel(
reload() reload()
} }
/** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches. */ /** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches.
* No-op in base mode (the toggle is hidden there — the server ignores `staged` when `base` is set). */
public fun selectStaged(staged: Boolean) { public fun selectStaged(staged: Boolean) {
if (_uiState.value.base != null) return
if (_uiState.value.staged == staged) return if (_uiState.value.staged == staged) return
_uiState.value = _uiState.value.copy(staged = staged) _uiState.value = _uiState.value.copy(staged = staged)
reload() reload()
} }
/**
* Enter/leave base mode: a non-blank [rev] diffs HEAD against that base (staged toggle suppressed,
* git-write disabled — parity with public/diff.ts); null/blank returns to the working/staged view.
*/
public fun setBase(rev: String?) {
val next = rev?.trim()?.takeIf { it.isNotEmpty() }
if (_uiState.value.base == next) return
_uiState.value = _uiState.value.copy(base = next, writeBanner = null)
reload()
}
/** Re-fetch the current view (pull-to-refresh / retry after an error). */ /** Re-fetch the current view (pull-to-refresh / retry after an error). */
public fun refresh() { public fun refresh() {
reload() reload()
} }
/** Dismiss the git-write result banner. */
public fun clearWriteBanner() {
_uiState.value = _uiState.value.copy(writeBanner = null)
}
private fun reload() { private fun reload() {
val scope = scope ?: return val scope = scope ?: return
job?.cancel() job?.cancel()
val staged = _uiState.value.staged val staged = _uiState.value.staged
val base = _uiState.value.base
_uiState.value = _uiState.value.copy(phase = DiffPhase.LOADING) _uiState.value = _uiState.value.copy(phase = DiffPhase.LOADING)
job = scope.launch { job = scope.launch {
// Rethrow cancellation (a superseding load) so a stale fetch can't overwrite fresh state. // Rethrow cancellation (a superseding load) so a stale fetch can't overwrite fresh state.
val outcome = try { val outcome = try {
Result.success(fetcher.fetch(path, staged)) Result.success(fetcher.fetch(path, staged, base))
} catch (cancel: CancellationException) { } catch (cancel: CancellationException) {
throw cancel throw cancel
} catch (error: Throwable) { } catch (error: Throwable) {
@@ -101,6 +128,102 @@ public class DiffViewModel(
) )
} }
} }
// ── Git write (working/staged mode only — never in base mode, plan §Security/Edge cases) ──────
/** Stage (`staged=true`) or unstage a single file, then re-fetch so the view reflects the index. */
public fun toggleStage(newPath: String, staged: Boolean) {
runWrite { writer!!.gitStage(path, listOf(newPath), staged) }
}
/** Commit the staged changes with [message]. An empty message is rejected client-side (no I/O). */
public fun commit(message: String) {
if (message.isBlank()) {
_uiState.value = _uiState.value.copy(writeBanner = DiffWriteBanner(DiffCopy.COMMIT_EMPTY, isError = true))
return
}
runWrite { writer!!.gitCommit(path, message) }
}
/** Push the current branch to its upstream (tighter server-side rate limit; never auto-retried). */
public fun push() {
runWrite { writer!!.gitPush(path) }
}
/**
* Shared guarded-write runner: guards on write availability + base mode, sets [DiffUiState.writing],
* maps the [GitWriteOutcome] to a banner, and re-fetches the diff on success. Serialized via a single
* [writeJob] so a rapid double-tap never races.
*/
private fun <T> runWrite(op: suspend () -> GitWriteOutcome<T>) {
val scope = scope ?: return
if (writer == null || _uiState.value.base != null || _uiState.value.writing) return
writeJob?.cancel()
_uiState.value = _uiState.value.copy(writing = true, writeBanner = null)
writeJob = scope.launch {
val outcome = try {
Result.success(op())
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
Result.failure(error)
}
val banner = outcome.fold(
onSuccess = { bannerFor(it) },
onFailure = { DiffWriteBanner(DiffCopy.writeFailed(errorDetail(it)), isError = true) },
)
_uiState.value = _uiState.value.copy(writing = false, writeBanner = banner)
if (!banner.isError) reload() // refresh the diff after a successful write
}
}
private fun <T> bannerFor(outcome: GitWriteOutcome<T>): DiffWriteBanner = when (outcome) {
is GitWriteOutcome.Ok -> DiffWriteBanner(DiffCopy.okBanner(outcome.payload), isError = false)
is GitWriteOutcome.Rejected -> DiffWriteBanner(outcome.message ?: DiffCopy.WRITE_REJECTED, isError = true)
GitWriteOutcome.RateLimited -> DiffWriteBanner(DiffCopy.RATE_LIMITED, isError = true)
}
// A thrown ApiClientError's message IS its userMessage (super(userMessage)); transport errors carry
// their own message — so message is already the display copy.
private fun errorDetail(error: Throwable): String = error.message ?: error.toString()
}
/** The guarded git-write seam DiffViewModel drives (stage/commit/push). Prod: [ApiClientGitWriteGateway]. */
public interface GitWriteGateway {
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult>
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult>
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult>
}
/** Production [GitWriteGateway] delegating to a per-host [ApiClient] (Origin stamped in :api-client). */
public class ApiClientGitWriteGateway(private val api: ApiClient) : GitWriteGateway {
override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> =
api.gitStage(path, files, stage)
override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
api.gitCommit(path, message)
override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> = api.gitPush(path)
}
/** A one-line git-write result banner. [isError] drives the colour token (green ok / red failure). */
public data class DiffWriteBanner(val message: String, val isError: Boolean)
/** User-visible git-write copy (Chinese named constants; server strings are surfaced verbatim/inert). */
public object DiffCopy {
public const val COMMIT_EMPTY: String = "请填写提交信息。"
public const val WRITE_REJECTED: String = "操作被服务器拒绝。"
public const val RATE_LIMITED: String = "操作过于频繁,服务器已限流,请稍后再试。"
public fun writeFailed(detail: String): String = "Git 操作失败:$detail"
/** Success banner keyed off the payload type (short sha / branch→remote / staged count). */
public fun okBanner(payload: Any?): String = when (payload) {
is StageResult -> if (payload.staged) "已暂存 ${payload.count} 个文件" else "已取消暂存 ${payload.count} 个文件"
is CommitResult -> if (payload.commit.isEmpty()) "已提交" else "已提交 ${payload.commit}"
is PushResult -> "已推送 ${payload.branch ?: "分支"}${payload.remote ?: "远端"}"
else -> "操作完成"
}
} }
/** The load phase the screen renders (loading spinner / empty / error / list). */ /** The load phase the screen renders (loading spinner / empty / error / list). */
@@ -108,14 +231,25 @@ public enum class DiffPhase { IDLE, LOADING, LOADED, EMPTY, ERROR }
/** The immutable snapshot the diff screen renders. */ /** The immutable snapshot the diff screen renders. */
public data class DiffUiState( public data class DiffUiState(
/** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. */ /** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. Ignored in base mode. */
val staged: Boolean = false, val staged: Boolean = false,
/** Non-null = base mode: diff HEAD against this revision (staged toggle + git-write suppressed). */
val base: String? = null,
val phase: DiffPhase = DiffPhase.IDLE, val phase: DiffPhase = DiffPhase.IDLE,
/** files→hunks→lines flattened into one ordered list (empty until loaded). */ /** files→hunks→lines flattened into one ordered list (empty until loaded). */
val rows: List<DiffRow> = emptyList(), val rows: List<DiffRow> = emptyList(),
/** Server capped the diff (too large) — the screen shows a truncation notice. */ /** Server capped the diff (too large) — the screen shows a truncation notice. */
val truncated: Boolean = false, val truncated: Boolean = false,
) /** True once a git-write is in flight — the screen disables the write controls. */
val writing: Boolean = false,
/** The last git-write result (ok/failure), or null. Dismissed via [DiffViewModel.clearWriteBanner]. */
val writeBanner: DiffWriteBanner? = null,
/** Whether git-write controls are offered at all (a writer gateway was supplied). */
val canWrite: Boolean = false,
) {
/** Stage/commit/push are offered only in working/staged mode with a writer bound (never base mode). */
val writeEnabled: Boolean get() = canWrite && base == null
}
// ── The flattened lazy-list model (files → hunks → lines, in order) ────────────────────────────── // ── The flattened lazy-list model (files → hunks → lines, in order) ──────────────────────────────
@@ -125,10 +259,12 @@ public sealed interface DiffRow {
public val id: Long public val id: Long
} }
/** A per-file header: the display path plus its `+added/-removed` numstat and status. */ /** A per-file header: the display path plus its `+added/-removed` numstat and status. [stagePath] is the
* file's `newPath` used verbatim for `git add`/`restore` (the display [path] may be an `old → new` rename). */
public data class DiffFileHeaderRow( public data class DiffFileHeaderRow(
override val id: Long, override val id: Long,
val path: String, val path: String,
val stagePath: String,
val status: String, val status: String,
val added: Int, val added: Int,
val removed: Int, val removed: Int,
@@ -153,7 +289,7 @@ public fun flattenDiff(result: DiffResult): List<DiffRow> {
val rows = ArrayList<DiffRow>() val rows = ArrayList<DiffRow>()
var id = 0L var id = 0L
for (file in result.files) { for (file in result.files) {
rows.add(DiffFileHeaderRow(id++, headerPath(file), file.status, file.added, file.removed)) rows.add(DiffFileHeaderRow(id++, headerPath(file), file.newPath, file.status, file.added, file.removed))
if (file.binary) { if (file.binary) {
rows.add(DiffBinaryRow(id++)) rows.add(DiffBinaryRow(id++))
continue continue
@@ -212,7 +348,13 @@ public data class DiffFile(
val hunks: List<DiffHunk>, val hunks: List<DiffHunk>,
) )
public data class DiffResult(val files: List<DiffFile>, val staged: Boolean, val truncated: Boolean) public data class DiffResult(
val files: List<DiffFile>,
val staged: Boolean,
val truncated: Boolean,
/** Echoed by the server when the diff was against a base revision (`?base=<rev>`); null otherwise. */
val base: String? = null,
)
/** Tolerant JSON: unknown keys ignored, lenient — the untrusted-server config (mirror of `ModelJson`). */ /** Tolerant JSON: unknown keys ignored, lenient — the untrusted-server config (mirror of `ModelJson`). */
private val DiffJson: Json = Json { private val DiffJson: Json = Json {
@@ -230,7 +372,12 @@ internal fun decodeDiffResult(bytes: ByteArray): DiffResult {
.getOrNull() as? JsonObject .getOrNull() as? JsonObject
?: return DiffResult(emptyList(), staged = false, truncated = false) ?: return DiffResult(emptyList(), staged = false, truncated = false)
val files = (root["files"] as? JsonArray).orEmpty().mapNotNull(::decodeFile) val files = (root["files"] as? JsonArray).orEmpty().mapNotNull(::decodeFile)
return DiffResult(files = files, staged = root.bool("staged", false), truncated = root.bool("truncated", false)) return DiffResult(
files = files,
staged = root.bool("staged", false),
truncated = root.bool("truncated", false),
base = root.str("base"),
)
} }
/** A file needs a string `newPath` to be renderable; anything else drops it (keeps the rest). */ /** A file needs a string `newPath` to be renderable; anything else drops it (keeps the rest). */
@@ -278,8 +425,12 @@ private fun JsonObject.bool(key: String, default: Boolean): Boolean =
/** Fetches + decodes a diff for a project path. Seam so the presenter is driven by a fake in tests. */ /** Fetches + decodes a diff for a project path. Seam so the presenter is driven by a fake in tests. */
public interface DiffFetcher { public interface DiffFetcher {
/** @throws DiffUnavailable on a non-200 status; transport errors propagate. */ /**
public suspend fun fetch(path: String, staged: Boolean): DiffResult * @param base when non-null, diff HEAD against this base revision — the server IGNORES [staged]
* in base mode (server.ts:831), so callers omit it and the screen hides the Working/Staged toggle.
* @throws DiffUnavailable on a non-200 status; transport errors propagate.
*/
public suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult
} }
/** A non-200 from the diff route (400 bad path / 404 not a repo / 500 git failed). */ /** A non-200 from the diff route (400 bad path / 404 not a repo / 500 git failed). */
@@ -293,8 +444,8 @@ public class HttpDiffFetcher(
private val endpoint: HostEndpoint, private val endpoint: HostEndpoint,
private val http: HttpTransport, private val http: HttpTransport,
) : DiffFetcher { ) : DiffFetcher {
override suspend fun fetch(path: String, staged: Boolean): DiffResult { override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
val url = diffUrl(endpoint.baseUrl, path, staged) ?: throw DiffUnavailable(HTTP_BAD_REQUEST) val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url)) val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
if (response.status != HTTP_OK) throw DiffUnavailable(response.status) if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
return decodeDiffResult(response.body) return decodeDiffResult(response.body)
@@ -305,20 +456,28 @@ private const val HTTP_OK = 200
private const val HTTP_BAD_REQUEST = 400 private const val HTTP_BAD_REQUEST = 400
/** /**
* Build `<scheme>://host[:port]/projects/diff?path=<enc>&staged=1|0` from the dialed base URL, * Build `<scheme>://host[:port]/projects/diff?path=<enc>[&staged=1|0][&base=<enc>]` from the dialed
* keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). `staged` serializes as the * base URL, keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). In **base mode**
* literal `"1"`/`"0"` string the server matches with `=== '1'` (NOT a boolean). Returns null if the * ([base] non-null/non-blank) the server ignores `staged` (server.ts:831), so `staged` is OMITTED and
* base URL cannot be parsed. `internal` so the JVM test asserts the exact query value. * `&base=<enc>` is appended (percent-encoded; the server's `isPlausibleRev` rejects junk with a 400).
* Otherwise `staged` serializes as the literal `"1"`/`"0"` string the server matches with `=== '1'`
* (NOT a boolean). Returns null if the base URL cannot be parsed. `internal` so the JVM test asserts
* the exact query value.
*/ */
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean): String? { internal fun diffUrl(baseUrl: String, path: String, staged: Boolean, base: String? = null): String? {
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
val scheme = uri.scheme?.lowercase() ?: return null val scheme = uri.scheme?.lowercase() ?: return null
val host = uri.host ?: return null val host = uri.host ?: return null
if (host.isEmpty()) return null if (host.isEmpty()) return null
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
val portPart = if (uri.port != -1) ":${uri.port}" else "" val portPart = if (uri.port != -1) ":${uri.port}" else ""
val stagedValue = if (staged) "1" else "0" val prefix = "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}"
return "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}&staged=$stagedValue" val trimmedBase = base?.trim()
return if (!trimmedBase.isNullOrEmpty()) {
"$prefix&base=${percentEncode(trimmedBase)}" // base mode: no staged (server ignores it)
} else {
"$prefix&staged=${if (staged) "1" else "0"}"
}
} }
/** Strict RFC 3986 unreserved set — everything else percent-encoded over UTF-8 (mirror of Endpoints). */ /** Strict RFC 3986 unreserved set — everything else percent-encoded over UTF-8 (mirror of Endpoints). */

View File

@@ -0,0 +1,265 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
import wang.yaojia.webterm.clienttls.CertificateSummary
import java.net.URI
import java.security.GeneralSecurityException
import java.time.Instant
import java.time.ZoneId
/**
* # EnrollmentViewModel (B4) — the zero-`.p12` device-enrollment presenter.
*
* The Android port of iOS `EnrollmentViewModel` (`EnrollmentScreen.swift`). Drives the "自动获取证书"
* screen reached from the session-list host menu: one operator login (password) → a short-lived
* `device:enroll` bearer → a NON-EXPORTABLE hardware key + PKCS#10 CSR → `POST /device/enroll` → the
* returned leaf is committed to the shared cert store and presented AUTOMATICALLY on the existing mTLS
* path (no manual `.p12` import). Cache freshness (FIX 3) means the enrolled cert is live without a restart.
*
* A **plain presenter** (mirrors [ClientCertViewModel] / [PairingViewModel]), NOT an
* `androidx.lifecycle.ViewModel`, so it runs under `runTest` virtual time with no `Dispatchers.Main`. The
* enroll flow and the installed-summary read are injected as suspend closures ([enrollOperation] /
* [loadSummary]) so the VM is unit-testable without a keystore or network; production wires them (in the
* enrollment pane) to [wang.yaojia.webterm.wiring.EnrollmentFlowFactory] over a `Dispatchers.IO` hop.
*
* ### Secrets discipline (plan §8)
* The operator password lives only in [EnrollmentUiState] and is **cleared after every attempt** (success
* OR failure) so it never lingers in memory; it is never logged and never placed in error copy. Errors are
* a coarse [EnrollError] enum the screen maps to app-authored, inert copy — never a server/exception string.
*
* @param enrollOperation login → hardware keygen+CSR → enroll → commit, yielding the installed leaf summary.
* @param loadSummary the currently-installed device-cert summary (for the "已安装证书" section), or null.
* @param defaultControlPlaneUrl prefilled control-plane URL (the operator can edit it).
* @param defaultDeviceName prefilled device name (the Android device/model in production).
* @param zone / now formatting + expiry clock for the installed-cert summary (fixed in tests).
*/
public class EnrollmentViewModel(
private val enrollOperation: suspend (password: String, subdomain: String, deviceName: String, controlPlaneUrl: String) -> CertificateSummary,
private val loadSummary: suspend () -> CertificateSummary?,
defaultControlPlaneUrl: String = DEFAULT_CONTROL_PLANE_URL,
defaultDeviceName: String = "",
private val zone: ZoneId = ZoneId.systemDefault(),
private val now: () -> Instant = Instant::now,
) {
private val _uiState = MutableStateFlow(
EnrollmentUiState(controlPlaneUrl = defaultControlPlaneUrl, deviceName = defaultDeviceName),
)
/** The single snapshot the enrollment screen renders from. */
public val uiState: StateFlow<EnrollmentUiState> = _uiState.asStateFlow()
private var scope: CoroutineScope? = null
private var job: Job? = null
/** Bind the scope actions launch into (the screen passes a lifecycle scope) and load any installed cert. */
public fun bind(scope: CoroutineScope) {
this.scope = scope
refresh()
}
// ── Field edits (two-way bound from the Compose form) ─────────────────────────────────────────────
public fun onControlPlaneUrlChange(value: String) {
_uiState.value = _uiState.value.copy(controlPlaneUrl = value)
}
public fun onSubdomainChange(value: String) {
_uiState.value = _uiState.value.copy(subdomain = value)
}
public fun onDeviceNameChange(value: String) {
_uiState.value = _uiState.value.copy(deviceName = value)
}
public fun onPasswordChange(value: String) {
_uiState.value = _uiState.value.copy(password = value)
}
/** Dismiss the current error banner. */
public fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
private fun refresh() {
val scope = scope ?: return
job?.cancel()
_uiState.value = _uiState.value.copy(phase = EnrollPhase.LOADING)
job = scope.launch {
// loadSummary is designed to degrade to null on a storage fault (never throw); guard anyway.
val summary = runCatching { loadSummary() }.getOrNull()
_uiState.value = _uiState.value.copy(phase = EnrollPhase.IDLE, summary = summary?.toView())
}
}
/**
* Run enrollment: validate the control-plane URL (must be `https` with a host) and the fields BEFORE
* any network, then drive [enrollOperation]. The password is cleared after every attempt so it never
* lingers. A double-tap while an enroll is in flight is ignored.
*/
public fun enroll() {
val scope = scope ?: return
val snapshot = _uiState.value
if (snapshot.phase == EnrollPhase.ENROLLING) return
val url = validControlPlaneUrl(snapshot.controlPlaneUrl)
if (url == null) {
_uiState.value = snapshot.copy(error = EnrollError.INVALID_URL, didSucceed = false)
return
}
val subdomain = snapshot.subdomain.trim()
val deviceName = snapshot.deviceName.trim()
val password = snapshot.password
if (subdomain.isEmpty() || deviceName.isEmpty() || password.isEmpty()) {
_uiState.value = snapshot.copy(error = EnrollError.MISSING_FIELDS, didSucceed = false)
return
}
_uiState.value = snapshot.copy(phase = EnrollPhase.ENROLLING, error = null, didSucceed = false)
job?.cancel()
job = scope.launch {
val outcome = try {
Result.success(enrollOperation(password, subdomain, deviceName, url))
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
Result.failure(error)
}
// Clear the password whatever the outcome — never linger after an attempt.
_uiState.value = outcome.fold(
onSuccess = { summary ->
_uiState.value.copy(
phase = EnrollPhase.IDLE,
summary = summary.toView(),
password = "",
didSucceed = true,
error = null,
)
},
onFailure = { e ->
_uiState.value.copy(
phase = EnrollPhase.IDLE,
password = "",
didSucceed = false,
error = classifyEnroll(e),
)
},
)
}
}
/** Fields all present + not already enrolling → the button is enabled (deeper URL check on tap). */
private fun CertificateSummary.toView() = toSummaryView(now(), zone)
public companion object {
/** The default control-plane URL (matches iOS `EnrollmentCopy.defaultControlPlaneURL`). */
public const val DEFAULT_CONTROL_PLANE_URL: String = "https://cp.terminal.yaojia.wang"
}
}
/**
* Validate a control-plane URL exactly as iOS does before any network: it must parse, be `https`, and
* carry a non-blank host. Returns the trimmed URL on success, or null (→ [EnrollError.INVALID_URL]).
*/
internal fun validControlPlaneUrl(raw: String): String? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return null
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
if (!"https".equals(uri.scheme, ignoreCase = true)) return null
if (uri.host.isNullOrBlank()) return null
return trimmed
}
/**
* Map an enroll throwable to the coarse, non-secret [EnrollError] the screen renders inertly. Android's
* login + enroll share one [DeviceEnrollmentError.Http] type, so the HTTP status disambiguates (401 login
* vs 403 subdomain-not-owned vs 429 vs 400). Keystore/hardware faults (incl. StrongBox-unavailable, a
* [GeneralSecurityException] subclass) map to KEYGEN. Never carries the password/bearer.
*/
internal fun classifyEnroll(error: Throwable): EnrollError = when (error) {
is DeviceEnrollmentError.Http -> when (error.status) {
401 -> EnrollError.BAD_CREDENTIAL
403 -> EnrollError.SUBDOMAIN_NOT_OWNED
429 -> EnrollError.RATE_LIMITED
400 -> EnrollError.REJECTED
else -> EnrollError.ENROLL_FAILED
}
is DeviceEnrollmentError.MalformedResponse -> EnrollError.SERVER
is DeviceEnrollmentError -> EnrollError.ENROLL_FAILED // InvalidRequest etc. (post-validation, rare)
is GeneralSecurityException -> EnrollError.KEYGEN // hardware key / StrongBox / keystore fault
else -> EnrollError.UNKNOWN
}
/** The load/action phase the enrollment screen renders. */
public enum class EnrollPhase {
/** The initial installed-summary read is in flight. */
LOADING,
/** Idle — the form is editable and [EnrollmentUiState.summary] shows any installed cert. */
IDLE,
/** An enroll is in flight (login → keygen+CSR → enroll → commit). */
ENROLLING,
}
/** The coarse, non-secret enrollment failure taxonomy — the screen maps each to app-authored inert copy (§8). */
public enum class EnrollError {
/** The control-plane URL is not a valid `https://…` URL with a host (set before any network). */
INVALID_URL,
/** A required field (subdomain / device name / password) was empty (set before any network). */
MISSING_FIELDS,
/** Operator login rejected (401) — the password is wrong. */
BAD_CREDENTIAL,
/** The account does not own the requested subdomain (403) — no cert can be issued. */
SUBDOMAIN_NOT_OWNED,
/** Too many requests (429) — back off and retry. */
RATE_LIMITED,
/** The subdomain or CSR was rejected (400). */
REJECTED,
/** Any other enroll failure (server-side or transport). */
ENROLL_FAILED,
/** The server returned a malformed response. */
SERVER,
/** Hardware key generation failed (no StrongBox/TEE, or a keystore fault). */
KEYGEN,
/** An unclassified failure. */
UNKNOWN,
}
/** The immutable snapshot the enrollment screen renders. */
public data class EnrollmentUiState(
val controlPlaneUrl: String = "",
val subdomain: String = "",
val deviceName: String = "",
val password: String = "",
/** The currently-installed device identity's display summary, or `null` when none is installed. */
val summary: CertSummaryView? = null,
val phase: EnrollPhase = EnrollPhase.LOADING,
/** The last enroll failure, or `null`. Surfaced as inert, app-authored copy. */
val error: EnrollError? = null,
/** Whether the most recent enroll succeeded (drives the success affordance). */
val didSucceed: Boolean = false,
) {
/** Every field present and no enroll in flight → the submit button is enabled. */
val canEnroll: Boolean
get() = phase != EnrollPhase.ENROLLING &&
controlPlaneUrl.trim().isNotEmpty() &&
subdomain.trim().isNotEmpty() &&
deviceName.trim().isNotEmpty() &&
password.isNotEmpty()
}

View File

@@ -4,26 +4,32 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.routes.ApiClientError import wang.yaojia.webterm.api.routes.ApiClientError
/** /**
* # ProjectDetailViewModel (A23) — one project's detail (`GET /projects/detail?path=`), a phase state * # ProjectDetailViewModel (A23 + W5) — one project's detail (`GET /projects/detail?path=`), a phase
* machine (same discipline as [DiffViewModel]/iOS `ProjectDetailViewModel`). * state machine, PLUS two failure-ISOLATED side fetches: the PR + CI chip (`GET /projects/pr`) and the
* recent-commits list (`GET /projects/log`). A failure of either side fetch NEVER fails the detail load
* (each has its own StateFlow) — the chip/list simply render an unavailable state.
* *
* The [fetch] closure is injected production wraps [ProjectsGateway.projectDetail] (the builder's * The main [fetch] closure is injected (production wraps [ProjectsGateway.projectDetail]); [fetchPr] /
* percent-encoding + 400/404/500 → typed [ApiClientError] mapping lives in `:api-client`), tests inject a * [fetchLog] are optional side fetches (null → the chip/list stay [PrChip.Hidden] / [RecentCommits.Hidden]).
* fake. This VM only reduces the three user-visible outcomes: * [worktree] (when wired) drives the guarded create/remove/prune actions and re-fetches this detail on
* - success → [Phase.Loaded] (sessions/worktrees/hasClaudeMd/claudeMd passed through, rendered INERT); * success (via [load]).
* - 400 / [ApiClientError.InvalidRequest] → [Failure.PATH_INVALID];
* - 404 → [Failure.NOT_FOUND]; 500 / decode / transport → [Failure.UNAVAILABLE] — all retryable via [load].
* *
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest` with no * A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest`. The screen calls
* `Dispatchers.Main`. The screen calls [load] in a lifecycle scope; the retry action re-calls it. * [load] in a lifecycle scope; the retry action re-calls it.
*/ */
public class ProjectDetailViewModel( public class ProjectDetailViewModel(
public val path: String, public val path: String,
private val fetch: suspend () -> ProjectDetail, private val fetch: suspend () -> ProjectDetail,
private val fetchPr: (suspend () -> PrStatus)? = null,
private val fetchLog: (suspend () -> GitLogResult)? = null,
/** Guarded worktree actions bound to this project; null when the gateway isn't wired (tests). */
public val worktree: WorktreeViewModel? = null,
) { ) {
/** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */ /** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE } public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
@@ -35,12 +41,40 @@ public class ProjectDetailViewModel(
public data class Failed(val failure: Failure) : Phase public data class Failed(val failure: Failure) : Phase
} }
private val _phase = MutableStateFlow<Phase>(Phase.Loading) /** The PR + CI chip's own state (isolated from the detail load). */
public sealed interface PrChip {
public data object Hidden : PrChip
public data object Loading : PrChip
public data class Loaded(val status: PrStatus) : PrChip
public data object Unavailable : PrChip
}
/** The single snapshot `ProjectDetailScreen` renders from. */ /** The recent-commits section's own state (isolated from the detail load). */
public sealed interface RecentCommits {
public data object Hidden : RecentCommits
public data object Loading : RecentCommits
public data class Loaded(val result: GitLogResult) : RecentCommits
public data object Unavailable : RecentCommits
}
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
private val _prChip = MutableStateFlow<PrChip>(PrChip.Hidden)
private val _recentCommits = MutableStateFlow<RecentCommits>(RecentCommits.Hidden)
/** The main detail snapshot `ProjectDetailScreen` renders from. */
public val phase: StateFlow<Phase> = _phase.asStateFlow() public val phase: StateFlow<Phase> = _phase.asStateFlow()
/** Fetch and present. Also the retry path: callable again after a [Phase.Failed]. */ /** The PR chip snapshot (renders one chip from [PrStatus.availability]). */
public val prChip: StateFlow<PrChip> = _prChip.asStateFlow()
/** The recent-commits snapshot. */
public val recentCommits: StateFlow<RecentCommits> = _recentCommits.asStateFlow()
/**
* Fetch and present. Also the retry path: callable again after a [Phase.Failed]. On a successful
* detail load it runs the two side fetches, each failure-isolated (a PR/log failure does not fail
* the detail load nor each other).
*/
public suspend fun load() { public suspend fun load() {
_phase.value = Phase.Loading _phase.value = Phase.Loading
_phase.value = try { _phase.value = try {
@@ -53,6 +87,34 @@ public class ProjectDetailViewModel(
// Transport/decode etc. — a retryable catch-all. // Transport/decode etc. — a retryable catch-all.
Phase.Failed(Failure.UNAVAILABLE) Phase.Failed(Failure.UNAVAILABLE)
} }
if (_phase.value is Phase.Loaded) {
loadPr()
loadRecentCommits()
}
}
private suspend fun loadPr() {
val fetcher = fetchPr ?: return
_prChip.value = PrChip.Loading
_prChip.value = try {
PrChip.Loaded(fetcher())
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
PrChip.Unavailable // isolated: a PR fetch failure never touches the detail phase
}
}
private suspend fun loadRecentCommits() {
val fetcher = fetchLog ?: return
_recentCommits.value = RecentCommits.Loading
_recentCommits.value = try {
RecentCommits.Loaded(fetcher())
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
RecentCommits.Unavailable
}
} }
private fun failureFor(error: ApiClientError): Failure = when (error) { private fun failureFor(error: ApiClientError): Failure = when (error) {
@@ -62,8 +124,22 @@ public class ProjectDetailViewModel(
} }
public companion object { public companion object {
/** Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). */ /**
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel = * Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). Wires the
ProjectDetailViewModel(path) { gateway.projectDetail(path) } * detail + PR + log fetches and a [WorktreeViewModel] whose successes re-fetch this detail.
*/
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel {
var self: ProjectDetailViewModel? = null
val worktree = WorktreeViewModel(gateway, path, onChanged = { self?.load() })
val vm = ProjectDetailViewModel(
path = path,
fetch = { gateway.projectDetail(path) },
fetchPr = { gateway.projectPr(path) },
fetchLog = { gateway.projectLog(path, null) },
worktree = worktree,
)
self = vm
return vm
}
} }
} }

View File

@@ -5,8 +5,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.UiPrefs import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.routes.ApiClient import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.api.routes.ApiClientError import wang.yaojia.webterm.api.routes.ApiClientError
@@ -396,12 +402,26 @@ public data class ProjectsUiState(
// ── Gateway seam (abstracts ApiClient so the VM is JVM-tested against a fake) ───────────────────── // ── Gateway seam (abstracts ApiClient so the VM is JVM-tested against a fake) ─────────────────────
/** Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses. */ /**
* Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses.
* The W5 additions (PR / recent commits / worktree create-remove-prune) let the detail page and the
* [WorktreeViewModel] stay JVM-tested against a fake; the guarded worktree writes flow through the
* :api-client Origin-stamping point (plan §Security).
*/
public interface ProjectsGateway { public interface ProjectsGateway {
public suspend fun projects(): List<ProjectInfo> public suspend fun projects(): List<ProjectInfo>
public suspend fun prefs(): UiPrefs public suspend fun prefs(): UiPrefs
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs public suspend fun putPrefs(prefs: UiPrefs): UiPrefs
public suspend fun projectDetail(path: String): ProjectDetail public suspend fun projectDetail(path: String): ProjectDetail
// ── W5: read-only PR + recent commits ──────────────────────────────────────────────────
public suspend fun projectPr(path: String): PrStatus
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult
// ── W5: guarded worktree write ─────────────────────────────────────────────────────────
public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult>
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult>
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult>
} }
/** Production [ProjectsGateway] delegating to a per-host [ApiClient] over the shared mTLS transport. */ /** Production [ProjectsGateway] delegating to a per-host [ApiClient] over the shared mTLS transport. */
@@ -410,6 +430,15 @@ public class ApiClientProjectsGateway(private val api: ApiClient) : ProjectsGate
override suspend fun prefs(): UiPrefs = api.prefs() override suspend fun prefs(): UiPrefs = api.prefs()
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = api.putPrefs(prefs) override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = api.putPrefs(prefs)
override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path) override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path)
override suspend fun projectPr(path: String): PrStatus = api.projectPr(path)
override suspend fun projectLog(path: String, n: Int?): GitLogResult = api.projectLog(path, n)
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> =
api.createWorktree(path, branch, base)
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> =
api.removeWorktree(path, worktreePath, force)
override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> = api.pruneWorktrees(path)
} }
/** User-visible copy (Chinese named constants; labels are local UI text — only group KEYS are frozen). */ /** User-visible copy (Chinese named constants; labels are local UI text — only group KEYS are frozen). */

View File

@@ -0,0 +1,149 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.WorktreeInfo
/**
* # WorktreeViewModel (W5) — the guarded worktree write actions for one project.
*
* A phase machine (`Idle → Working → Done | Failed`) over the three guarded routes
* (`POST /projects/worktree`, `DELETE /projects/worktree`, `POST /projects/worktree/prune`), all
* flowing through the :api-client Origin-stamping point (plan §Security). On a successful op it invokes
* [onChanged] so the detail screen re-fetches and the worktree list refreshes.
*
* ### Defense in depth (UX, not the security boundary)
* The branch name is pre-validated client-side ([isValidBranchName], a mirror of the server's
* `validateBranchName`) so an obviously bad name fails with NO network I/O; a **main** worktree removal
* is blocked client-side ([WorktreeInfo.isMain]) — the server re-validates + realpath-contains
* regardless. Server `error` strings (disabled kill-switch, "uncommitted changes; force required") are
* surfaced INERT (plain text; never linkified).
*
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest`. The screen calls
* the suspend actions from a lifecycle scope; [reset] clears a settled banner back to [Phase.Idle].
*/
public class WorktreeViewModel(
private val gateway: ProjectsGateway,
private val repoPath: String,
/** Invoked after any successful write so the detail page re-fetches (list refresh). */
private val onChanged: suspend () -> Unit = {},
) {
/** The action phase the screen renders (idle / spinner / success banner / failure banner). */
public sealed interface Phase {
public data object Idle : Phase
public data object Working : Phase
public data class Done(val message: String) : Phase
public data class Failed(val message: String) : Phase
}
private val _phase = MutableStateFlow<Phase>(Phase.Idle)
/** The single snapshot the worktree sheet/dialog renders from. */
public val phase: StateFlow<Phase> = _phase.asStateFlow()
/** Create a worktree for [branch] (off optional [base]). Invalid branch → [Phase.Failed], no I/O. */
public suspend fun create(branch: String, base: String? = null) {
val trimmed = branch.trim()
if (!isValidBranchName(trimmed)) {
_phase.value = Phase.Failed(WorktreeCopy.INVALID_BRANCH)
return
}
if (_phase.value == Phase.Working) return
_phase.value = Phase.Working
val cleanBase = base?.trim()?.takeIf { it.isNotEmpty() }
_phase.value = runOp { gateway.createWorktree(repoPath, trimmed, cleanBase) }
}
/** Remove [worktree] ([force] to discard uncommitted changes). A **main** worktree is blocked here. */
public suspend fun remove(worktree: WorktreeInfo, force: Boolean) {
if (worktree.isMain) {
_phase.value = Phase.Failed(WorktreeCopy.CANNOT_REMOVE_MAIN)
return
}
if (_phase.value == Phase.Working) return
_phase.value = Phase.Working
_phase.value = runOp { gateway.removeWorktree(repoPath, worktree.path, force) }
}
/** Reclaim stale worktree admin dirs (idempotent). */
public suspend fun prune() {
if (_phase.value == Phase.Working) return
_phase.value = Phase.Working
_phase.value = runOp { gateway.pruneWorktrees(repoPath) }
}
/** Clear a settled banner (Done/Failed) back to Idle after the user dismisses it. */
public fun reset() {
_phase.value = Phase.Idle
}
/**
* Run one guarded write, mapping its [GitWriteOutcome] to a phase. On success it re-fetches the
* detail (via [onChanged]) BEFORE settling to [Phase.Done] so the list is fresh when the banner shows.
*/
private suspend fun <T> runOp(op: suspend () -> GitWriteOutcome<T>): Phase {
val outcome = try {
op()
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return Phase.Failed(WorktreeCopy.failed(error.message ?: error.toString()))
}
return when (outcome) {
is GitWriteOutcome.Ok -> {
runCatching { onChanged() } // a refresh failure must not turn a successful write into a failure
Phase.Done(WorktreeCopy.okMessage(outcome.payload))
}
is GitWriteOutcome.Rejected -> Phase.Failed(outcome.message ?: WorktreeCopy.REJECTED)
GitWriteOutcome.RateLimited -> Phase.Failed(WorktreeCopy.RATE_LIMITED)
}
}
public companion object {
/** Longest branch name the server accepts (`src/http/worktrees.ts` `MAX_BRANCH_LEN`). */
private const val MAX_BRANCH_LEN = 250
/** Mirror of the server's `FORBIDDEN_BRANCH_CHARS`: control/DEL, whitespace, `~^:?*[\`. */
private val FORBIDDEN_BRANCH_CHARS = Regex("[\\u0000-\\u001f\\u007f\\s~^:?*\\[\\\\]")
/**
* Client-side mirror of `validateBranchName` (worktrees.ts:95) — a fast UX pre-check ONLY; the
* server re-validates. Rejects empty/overlong, leading `-`, bad slashes, `..`, `.lock`/trailing
* `.`, `@{`, and any forbidden char.
*/
public fun isValidBranchName(branch: String): Boolean {
if (branch.isEmpty() || branch.length > MAX_BRANCH_LEN) return false
if (branch.startsWith("-")) return false
if (branch.startsWith("/") || branch.endsWith("/") || branch.contains("//")) return false
if (branch.contains("..")) return false
if (branch.endsWith(".lock") || branch.endsWith(".")) return false
if (branch.contains("@{")) return false
if (FORBIDDEN_BRANCH_CHARS.containsMatchIn(branch)) return false
return true
}
}
}
/** User-visible worktree-action copy (Chinese named constants; server strings surfaced inert). */
public object WorktreeCopy {
public const val INVALID_BRANCH: String = "分支名不合法(含非法字符或格式)。"
public const val CANNOT_REMOVE_MAIN: String = "不能删除主工作树。"
public const val REJECTED: String = "操作被服务器拒绝。"
public const val RATE_LIMITED: String = "操作过于频繁,服务器已限流,请稍后再试。"
public fun failed(detail: String): String = "工作树操作失败:$detail"
public fun okMessage(payload: Any?): String = when (payload) {
is CreateWorktreeResult -> "已创建工作树 ${payload.branch ?: ""}".trim()
is RemoveWorktreeResult -> "已删除工作树"
is PruneWorktreesResult ->
if (payload.pruned.isEmpty()) "没有可清理的工作树" else "已清理 ${payload.pruned.size} 个工作树"
else -> "操作完成"
}
}

View File

@@ -49,6 +49,11 @@ public class AppEnvironment @Inject constructor(
public val apiClientFactory: ApiClientFactory, public val apiClientFactory: ApiClientFactory,
public val sessionEngineFactory: SessionEngineFactory, public val sessionEngineFactory: SessionEngineFactory,
public val coldStartPolicy: ColdStartPolicy, public val coldStartPolicy: ColdStartPolicy,
/**
* B4 · Builds a [DeviceEnroller][wang.yaojia.webterm.tlsandroid.DeviceEnroller] per control-plane URL
* for the zero-`.p12` enrollment screen (A-enroll). App-scoped; the screen calls it off `Main`.
*/
public val enrollmentFlowFactory: EnrollmentFlowFactory,
private val identityRepositoryLazy: Lazy<IdentityRepository>, private val identityRepositoryLazy: Lazy<IdentityRepository>,
private val httpTransportLazy: Lazy<HttpTransport>, private val httpTransportLazy: Lazy<HttpTransport>,
private val termTransportLazy: Lazy<TermTransport>, private val termTransportLazy: Lazy<TermTransport>,

View File

@@ -0,0 +1,51 @@
package wang.yaojia.webterm.wiring
import dagger.Lazy
import okhttp3.OkHttpClient
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
import wang.yaojia.webterm.tlsandroid.CertStore
import wang.yaojia.webterm.tlsandroid.DeviceEnroller
import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore
import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
import wang.yaojia.webterm.wire.HttpTransport
import javax.inject.Inject
import javax.inject.Singleton
/**
* B4 · Builds a [DeviceEnroller] bound to a user-supplied control-plane URL, over the app's frozen object
* graph — the Android analogue of iOS `makeDeviceEnrollmentFlow` (`DeviceEnrollmentWiring.swift`).
*
* The control-plane base URL is a RUNTIME value (the operator types it on the enrollment screen), so the
* enroller cannot be a plain singleton; this factory is the singleton and mints one enroller per enroll
* attempt with the typed URL, wiring in the app-scoped collaborators:
* - the SAME shared [HttpTransport] / [OkHttpClient] the mTLS transports use, so the renew ride presents
* the current device cert and the pool eviction drops stale connections,
* - the shared [CertStore] + [EnrollmentRecordStore] the running [IdentityRepository] resolves from, and
* - the [IdentityCacheRefresher] (FIX 3) so a freshly enrolled leaf is presented with no restart.
*
* ### Off-`Main` discipline
* [httpTransport] and [sharedClient] are behind `dagger.Lazy` because resolving either builds the shared
* `OkHttpClient` (mTLS/keystore I/O). [create] therefore MUST be called off the UI thread — the enrollment
* ViewModel invokes it inside a `Dispatchers.IO` hop (mirroring `AppEnvironment.warmUp`).
*/
@Singleton
public class EnrollmentFlowFactory @Inject constructor(
private val httpTransport: Lazy<HttpTransport>,
private val sharedClient: Lazy<OkHttpClient>,
private val certStore: CertStore,
private val recordStore: EnrollmentRecordStore,
private val cacheRefresher: IdentityCacheRefresher,
) {
/**
* Mint a [DeviceEnroller] targeting [controlPlaneBaseUrl] (any trailing slash is trimmed by the
* client). Call OFF `Main` — resolving the lazy transport/client does keystore/TLS I/O.
*/
public fun create(controlPlaneBaseUrl: String): DeviceEnroller =
DeviceEnroller(
client = DeviceEnrollmentClient(controlPlaneBaseUrl, httpTransport.get()),
certStore = certStore,
recordStore = recordStore,
sharedClient = sharedClient.get(),
cacheRefresher = cacheRefresher,
)
}

View File

@@ -6,8 +6,14 @@ import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.StageResult
/** /**
* A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag * A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
@@ -101,8 +107,10 @@ class DiffViewModelTest {
// ── DiffViewModel phase transitions + staged re-fetch ─────────────────────────────────────── // ── DiffViewModel phase transitions + staged re-fetch ───────────────────────────────────────
private class FakeFetcher(private val result: DiffResult?, private val error: Throwable? = null) : DiffFetcher { private class FakeFetcher(private val result: DiffResult?, private val error: Throwable? = null) : DiffFetcher {
val calls = mutableListOf<Boolean>() // records the staged arg of each fetch val calls = mutableListOf<Boolean>() // records the staged arg of each fetch
override suspend fun fetch(path: String, staged: Boolean): DiffResult { val bases = mutableListOf<String?>() // records the base arg of each fetch
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
calls += staged calls += staged
bases += base
error?.let { throw it } error?.let { throw it }
return result!! return result!!
} }
@@ -154,4 +162,133 @@ class DiffViewModelTest {
assertTrue(vm.uiState.value.staged) assertTrue(vm.uiState.value.staged)
assertEquals(listOf(false, true), fetcher.calls) // exactly two fetches, not three assertEquals(listOf(false, true), fetcher.calls) // exactly two fetches, not three
} }
// ── diffUrl base mode (Phase B) ───────────────────────────────────────────────────────────────
@Test
fun `diffUrl appends staged in working mode and base (omitting staged) in base mode`() {
assertEquals(
"http://h:3000/projects/diff?path=%2Frepo&staged=1",
diffUrl("http://h:3000", "/repo", staged = true, base = null),
)
// base mode: no staged param, base percent-encoded.
assertEquals(
"http://h:3000/projects/diff?path=%2Frepo&base=feature%2Fx",
diffUrl("http://h:3000", "/repo", staged = true, base = "feature/x"),
)
// a blank base is treated as working mode.
assertEquals(
"http://h:3000/projects/diff?path=%2Frepo&staged=0",
diffUrl("http://h:3000", "/repo", staged = false, base = " "),
)
}
// ── DiffViewModel base mode (Phase B) ─────────────────────────────────────────────────────────
@Test
fun `setBase enters base mode, threads base to the fetcher, and suppresses the staged toggle`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val vm = DiffViewModel(fetcher, "/repo")
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.setBase("main"); advanceUntilIdle()
assertEquals("main", vm.uiState.value.base)
assertEquals(listOf(null, "main"), fetcher.bases) // base threaded on the re-fetch
// In base mode the staged toggle is a no-op (server ignores staged when base is set).
vm.selectStaged(true); advanceUntilIdle()
assertFalse(vm.uiState.value.staged)
assertEquals(2, fetcher.calls.size, "selectStaged must not re-fetch in base mode")
// Leaving base mode returns to the working/staged view.
vm.setBase(null); advanceUntilIdle()
assertNull(vm.uiState.value.base)
assertEquals(listOf(null, "main", null), fetcher.bases)
}
// ── DiffViewModel git-write (Phase C) ─────────────────────────────────────────────────────────
private class FakeWriter(
var stage: GitWriteOutcome<StageResult> = GitWriteOutcome.Ok(StageResult(staged = true, count = 1)),
var commit: GitWriteOutcome<CommitResult> = GitWriteOutcome.Ok(CommitResult(commit = "abc123")),
var push: GitWriteOutcome<PushResult> = GitWriteOutcome.Ok(PushResult(branch = "main", remote = "origin")),
) : GitWriteGateway {
val stageCalls = mutableListOf<Triple<String, List<String>, Boolean>>()
var commitCalls = 0; var pushCalls = 0
override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> {
stageCalls += Triple(path, files, stage); return this.stage
}
override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> { commitCalls++; return commit }
override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> { pushCalls++; return push }
}
@Test
fun `toggleStage posts the file and refreshes the diff`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter()
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.toggleStage("src/A.kt", staged = true); advanceUntilIdle()
assertEquals(Triple("/repo", listOf("src/A.kt"), true), writer.stageCalls.single())
assertEquals(2, fetcher.calls.size, "a successful stage must refresh the diff")
assertEquals(false, vm.uiState.value.writeBanner?.isError)
}
@Test
fun `commit surfaces an Ok banner and an empty message is rejected client-side with no I O`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter()
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.commit(" "); advanceUntilIdle() // blank → client-side reject
assertEquals(0, writer.commitCalls, "a blank commit message must not hit the network")
assertEquals(true, vm.uiState.value.writeBanner?.isError)
vm.commit("real message"); advanceUntilIdle()
assertEquals(1, writer.commitCalls)
assertEquals(false, vm.uiState.value.writeBanner?.isError)
assertTrue(vm.uiState.value.writeBanner!!.message.contains("abc123"))
}
@Test
fun `push maps a 409 rejection to the inert server message and does not refresh`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter(push = GitWriteOutcome.Rejected(409, "Push rejected: remote has diverged."))
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.push(); advanceUntilIdle()
assertEquals(1, writer.pushCalls)
assertEquals(true, vm.uiState.value.writeBanner?.isError)
assertEquals("Push rejected: remote has diverged.", vm.uiState.value.writeBanner?.message)
assertEquals(1, fetcher.calls.size, "a failed push must NOT refresh the diff")
}
@Test
fun `git-write is disabled in base mode`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter()
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.setBase("main"); advanceUntilIdle()
vm.toggleStage("a.kt", true); vm.commit("m"); vm.push(); advanceUntilIdle()
assertTrue(writer.stageCalls.isEmpty() && writer.commitCalls == 0 && writer.pushCalls == 0)
assertFalse(vm.uiState.value.writeEnabled)
}
@Test
fun `writeEnabled is false without a writer and true with one in working mode`() {
assertFalse(DiffUiState(canWrite = false).writeEnabled)
assertTrue(DiffUiState(canWrite = true).writeEnabled)
assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled)
}
} }

View File

@@ -0,0 +1,213 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
import wang.yaojia.webterm.clienttls.CertificateSummary
import java.security.KeyStoreException
import java.time.Instant
import java.time.ZoneId
/**
* [EnrollmentViewModel] / [validControlPlaneUrl] / [classifyEnroll] (B4) — the JVM-tested zero-`.p12`
* enrollment core, mirroring iOS `EnrollmentViewModelTests`. The enroll flow itself is covered headlessly
* in `DeviceEnrollerTest`; these cover the VM's boundary validation, success bookkeeping (summary +
* password-clear), and the error→[EnrollError] mapping — including that the password never lingers after
* an attempt. The keystore / network / Compose shell is device-QA (plan §7); THIS is the pure core.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class EnrollmentViewModelTest {
/** Records enroll invocations and replays a scripted result/throwable (mirrors iOS EnrollScript). */
private class EnrollScript(private val result: Result<CertificateSummary>) {
val calls = mutableListOf<Call>()
data class Call(val password: String, val subdomain: String, val deviceName: String, val url: String)
suspend fun run(password: String, subdomain: String, deviceName: String, url: String): CertificateSummary {
calls.add(Call(password, subdomain, deviceName, url))
return result.getOrThrow()
}
}
private val fixedNow = Instant.parse("2026-01-01T00:00:00Z")
private val utc = ZoneId.of("UTC")
private fun summary(
subject: String? = "alice-pixel",
issuer: String? = "webterm-device-ca",
notAfter: Instant? = Instant.parse("2026-10-06T00:00:00Z"),
) = CertificateSummary(subjectCommonName = subject, issuerCommonName = issuer, notAfter = notAfter)
private fun newVm(
script: EnrollScript,
installed: CertificateSummary? = null,
controlPlaneUrl: String = "https://cp.terminal.yaojia.wang",
subdomain: String = "alice",
deviceName: String = "Pixel 8",
): EnrollmentViewModel {
val vm = EnrollmentViewModel(
enrollOperation = { p, s, d, u -> script.run(p, s, d, u) },
loadSummary = { installed },
defaultControlPlaneUrl = controlPlaneUrl,
defaultDeviceName = deviceName,
zone = utc,
now = { fixedNow },
)
vm.onSubdomainChange(subdomain)
return vm
}
// ── Success ─────────────────────────────────────────────────────────────────────────────────────
@Test
fun `a successful enroll sets the summary, flags success and clears the password`() =
runTest(UnconfinedTestDispatcher()) {
val script = EnrollScript(Result.success(summary(subject = "alice-pixel")))
val vm = newVm(script)
vm.bind(backgroundScope)
vm.onPasswordChange("operator-secret")
vm.enroll()
val state = vm.uiState.value
assertEquals(1, script.calls.size)
assertEquals("operator-secret", script.calls.single().password, "the entered password reaches the flow")
assertEquals("alice-pixel", state.summary?.subjectCommonName)
assertTrue(state.didSucceed)
assertNull(state.error)
assertEquals("", state.password, "the password must never linger after a successful enroll")
assertEquals(EnrollPhase.IDLE, state.phase)
}
// ── Boundary validation (no network) ──────────────────────────────────────────────────────────────
@Test
fun `a non-https control-plane URL is rejected before any network`() =
runTest(UnconfinedTestDispatcher()) {
val script = EnrollScript(Result.success(summary()))
val vm = newVm(script, controlPlaneUrl = "http://cp.terminal.yaojia.wang")
vm.bind(backgroundScope)
vm.onPasswordChange("pw")
vm.enroll()
assertTrue(script.calls.isEmpty(), "an insecure URL must never hit the network")
assertEquals(EnrollError.INVALID_URL, vm.uiState.value.error)
assertFalse(vm.uiState.value.didSucceed)
}
@Test
fun `missing fields are rejected before any network`() = runTest(UnconfinedTestDispatcher()) {
val script = EnrollScript(Result.success(summary()))
val vm = newVm(script, subdomain = " ") // whitespace-only subdomain
vm.bind(backgroundScope)
vm.onPasswordChange("pw")
vm.enroll()
assertTrue(script.calls.isEmpty())
assertEquals(EnrollError.MISSING_FIELDS, vm.uiState.value.error)
}
// ── Error → copy mapping (password still cleared) ─────────────────────────────────────────────────
@Test
fun `a 403 subdomain-not-owned maps to actionable copy and clears the password`() =
runTest(UnconfinedTestDispatcher()) {
val script = EnrollScript(Result.failure(DeviceEnrollmentError.Http(403, "rejected")))
val vm = newVm(script)
vm.bind(backgroundScope)
vm.onPasswordChange("operator-secret")
vm.enroll()
val state = vm.uiState.value
assertEquals(EnrollError.SUBDOMAIN_NOT_OWNED, state.error)
assertFalse(state.didSucceed)
assertEquals("", state.password, "the password is cleared even after a failed enroll")
}
@Test
fun `a 401 login maps to the bad-credential copy`() = runTest(UnconfinedTestDispatcher()) {
val script = EnrollScript(Result.failure(DeviceEnrollmentError.Http(401, "rejected")))
val vm = newVm(script)
vm.bind(backgroundScope)
vm.onPasswordChange("wrong")
vm.enroll()
assertEquals(EnrollError.BAD_CREDENTIAL, vm.uiState.value.error)
}
@Test
fun `a hardware keystore fault maps to the keygen copy`() = runTest(UnconfinedTestDispatcher()) {
val script = EnrollScript(Result.failure(KeyStoreException("no StrongBox / TEE")))
val vm = newVm(script)
vm.bind(backgroundScope)
vm.onPasswordChange("operator-secret")
vm.enroll()
assertEquals(EnrollError.KEYGEN, vm.uiState.value.error)
}
// ── canEnroll gating ──────────────────────────────────────────────────────────────────────────────
@Test
fun `canEnroll requires every field including a non-empty password`() =
runTest(UnconfinedTestDispatcher()) {
val vm = newVm(EnrollScript(Result.success(summary())))
vm.bind(backgroundScope)
assertFalse(vm.uiState.value.canEnroll, "password empty → disabled")
vm.onPasswordChange("pw")
assertTrue(vm.uiState.value.canEnroll)
vm.onSubdomainChange(" ")
assertFalse(vm.uiState.value.canEnroll, "whitespace-only subdomain → disabled")
}
@Test
fun `clearError dismisses a surfaced error`() = runTest(UnconfinedTestDispatcher()) {
val vm = newVm(newFailing())
vm.bind(backgroundScope)
vm.onPasswordChange("pw")
vm.enroll()
assertEquals(EnrollError.REJECTED, vm.uiState.value.error)
vm.clearError()
assertNull(vm.uiState.value.error)
}
private fun newFailing() = EnrollScript(Result.failure(DeviceEnrollmentError.Http(400, "rejected")))
// ── Pure helpers ──────────────────────────────────────────────────────────────────────────────────
@Test
fun `validControlPlaneUrl accepts https with a host and rejects everything else`() {
assertEquals("https://cp.terminal.yaojia.wang", validControlPlaneUrl(" https://cp.terminal.yaojia.wang "))
assertNull(validControlPlaneUrl("http://cp.terminal.yaojia.wang"), "http is rejected")
assertNull(validControlPlaneUrl("https://"), "no host is rejected")
assertNull(validControlPlaneUrl("not a url"), "unparseable is rejected")
assertNull(validControlPlaneUrl(""), "empty is rejected")
}
@Test
fun `classifyEnroll maps each throwable to its coarse EnrollError`() {
assertEquals(EnrollError.BAD_CREDENTIAL, classifyEnroll(DeviceEnrollmentError.Http(401, null)))
assertEquals(EnrollError.SUBDOMAIN_NOT_OWNED, classifyEnroll(DeviceEnrollmentError.Http(403, null)))
assertEquals(EnrollError.RATE_LIMITED, classifyEnroll(DeviceEnrollmentError.Http(429, null)))
assertEquals(EnrollError.REJECTED, classifyEnroll(DeviceEnrollmentError.Http(400, null)))
assertEquals(EnrollError.ENROLL_FAILED, classifyEnroll(DeviceEnrollmentError.Http(500, null)))
assertEquals(EnrollError.SERVER, classifyEnroll(DeviceEnrollmentError.MalformedResponse))
assertEquals(EnrollError.ENROLL_FAILED, classifyEnroll(DeviceEnrollmentError.InvalidRequest))
assertEquals(EnrollError.KEYGEN, classifyEnroll(KeyStoreException("x")))
assertEquals(EnrollError.UNKNOWN, classifyEnroll(IllegalStateException("x")))
}
}

View File

@@ -0,0 +1,68 @@
package wang.yaojia.webterm.viewmodels
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.UiPrefs
/**
* A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel,
* ProjectDetailViewModel PR/log). Records the guarded-write call args and returns canned outcomes;
* PR/log return canned values or throw to exercise failure-isolation. The list-page methods
* (projects/prefs) are unused here and throw if called.
*/
class FakeWorktreeGateway(
private val detail: ProjectDetail? = null,
private val prResult: PrStatus? = null,
private val prThrows: Boolean = false,
private val logResult: GitLogResult? = null,
private val logThrows: Boolean = false,
private val createOutcome: GitWriteOutcome<CreateWorktreeResult> = GitWriteOutcome.Ok(CreateWorktreeResult()),
private val removeOutcome: GitWriteOutcome<RemoveWorktreeResult> = GitWriteOutcome.Ok(RemoveWorktreeResult()),
private val pruneOutcome: GitWriteOutcome<PruneWorktreesResult> = GitWriteOutcome.Ok(PruneWorktreesResult()),
) : ProjectsGateway {
val createCalls = mutableListOf<Triple<String, String, String?>>()
val removeCalls = mutableListOf<Triple<String, String, Boolean>>()
val pruneCalls = mutableListOf<String>()
var detailCalls = 0
private set
override suspend fun projects(): List<ProjectInfo> = throw NotImplementedError()
override suspend fun prefs(): UiPrefs = throw NotImplementedError()
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = throw NotImplementedError()
override suspend fun projectDetail(path: String): ProjectDetail {
detailCalls++
return detail ?: throw NotImplementedError("no detail configured")
}
override suspend fun projectPr(path: String): PrStatus {
if (prThrows) throw RuntimeException("pr unavailable")
return prResult ?: throw NotImplementedError("no pr configured")
}
override suspend fun projectLog(path: String, n: Int?): GitLogResult {
if (logThrows) throw RuntimeException("log unavailable")
return logResult ?: throw NotImplementedError("no log configured")
}
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> {
createCalls += Triple(path, branch, base)
return createOutcome
}
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> {
removeCalls += Triple(path, worktreePath, force)
return removeOutcome
}
override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> {
pruneCalls += path
return pruneOutcome
}
}

View File

@@ -0,0 +1,90 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.CommitLogEntry
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
/**
* W5 ProjectDetailViewModel side fetches (JVM). The PR chip and recent-commits list are failure-
* ISOLATED: a failure of either NEVER fails the detail load nor the other; a non-`ok` availability
* renders a degraded (but Loaded) chip; the commit list decodes into its own state.
*/
class ProjectDetailPrLogTest {
private val detail = ProjectDetail(name = "repo", path = "/repo", isGit = true, branch = "main")
@Test
fun `detail plus PR plus log all load`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prResult = PrStatus(availability = PrAvailability.OK, number = 7, title = "A PR"),
logResult = GitLogResult(commits = listOf(CommitLogEntry("h", 1, "s")), truncated = false),
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded)
val chip = vm.prChip.value as ProjectDetailViewModel.PrChip.Loaded
assertEquals(PrAvailability.OK, chip.status.availability)
val commits = vm.recentCommits.value as ProjectDetailViewModel.RecentCommits.Loaded
assertEquals(1, commits.result.commits.size)
}
@Test
fun `a PR fetch failure does not fail the detail load nor the log`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prThrows = true,
logResult = GitLogResult(commits = emptyList(), truncated = false),
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "detail must still load")
assertEquals(ProjectDetailViewModel.PrChip.Unavailable, vm.prChip.value)
assertTrue(vm.recentCommits.value is ProjectDetailViewModel.RecentCommits.Loaded, "log stays isolated")
}
@Test
fun `a log fetch failure isolates to the recent-commits state only`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prResult = PrStatus(availability = PrAvailability.NO_PR),
logThrows = true,
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded)
assertEquals(ProjectDetailViewModel.RecentCommits.Unavailable, vm.recentCommits.value)
// A non-ok availability is still a Loaded chip (degraded copy is a render concern).
val chip = vm.prChip.value as ProjectDetailViewModel.PrChip.Loaded
assertEquals(PrAvailability.NO_PR, chip.status.availability)
}
@Test
fun `the wired worktree VM shares the repo path and refreshes the detail on a successful create`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prResult = PrStatus(availability = PrAvailability.DISABLED),
logResult = GitLogResult(),
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
val detailCallsAfterLoad = gateway.detailCalls
vm.worktree!!.create("feat/x")
assertTrue(gateway.detailCalls > detailCallsAfterLoad, "create success re-fetches the detail")
assertEquals("/repo", gateway.createCalls.single().first)
}
}

View File

@@ -202,6 +202,11 @@ class ProjectsViewModelTest {
} }
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError() override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
override suspend fun projectPr(path: String) = throw NotImplementedError()
override suspend fun projectLog(path: String, n: Int?) = throw NotImplementedError()
override suspend fun createWorktree(path: String, branch: String, base: String?) = throw NotImplementedError()
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean) = throw NotImplementedError()
override suspend fun pruneWorktrees(path: String) = throw NotImplementedError()
} }
private fun proj( private fun proj(

View File

@@ -0,0 +1,110 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.WorktreeInfo
/**
* W5 WorktreeViewModel (JVM). The guarded worktree write phase machine: client-side branch validation
* (no I/O on a bad name), main-worktree removal blocked client-side, the force flag threaded, and the
* server's SAFE error strings (disabled 403 / 429) surfaced inertly. On success it re-fetches the detail.
*/
class WorktreeViewModelTest {
@Test
fun `an invalid branch name fails with no network I O`() = runTest {
val gateway = FakeWorktreeGateway()
val vm = WorktreeViewModel(gateway, "/repo")
vm.create("bad branch~name") // whitespace + '~' are forbidden
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Failed)
assertEquals(WorktreeCopy.INVALID_BRANCH, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
assertEquals(0, gateway.createCalls.size, "an invalid branch must never hit the network")
}
@Test
fun `a leading dash and dotdot and trailing dot are all rejected client-side`() {
assertTrue(WorktreeViewModel.isValidBranchName("feat/ok-name"))
assertTrue(WorktreeViewModel.isValidBranchName("release/1.2.x"))
listOf("-flag", "a..b", "ends.", "has space", "a~b", "a:b", "@{now}", "", "//x", "/lead", "trail/").forEach {
assertTrue(!WorktreeViewModel.isValidBranchName(it), "should reject '$it'")
}
}
@Test
fun `create success settles Done and re-fetches the detail`() = runTest {
val gateway = FakeWorktreeGateway(
createOutcome = GitWriteOutcome.Ok(CreateWorktreeResult(path = "/repo-worktrees/feat", branch = "feat/x")),
)
var refreshes = 0
val vm = WorktreeViewModel(gateway, "/repo", onChanged = { refreshes++ })
vm.create("feat/x", base = "main")
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Done)
assertEquals(1, refreshes, "a successful create must re-fetch the detail")
assertEquals(Triple("/repo", "feat/x", "main"), gateway.createCalls.single())
}
@Test
fun `removing a main worktree is blocked client-side with no I O`() = runTest {
val gateway = FakeWorktreeGateway()
val vm = WorktreeViewModel(gateway, "/repo")
vm.remove(WorktreeInfo(path = "/repo", branch = "main", isMain = true), force = false)
assertEquals(WorktreeCopy.CANNOT_REMOVE_MAIN, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
assertEquals(0, gateway.removeCalls.size)
}
@Test
fun `remove threads the force flag`() = runTest {
val gateway = FakeWorktreeGateway(removeOutcome = GitWriteOutcome.Ok(RemoveWorktreeResult(path = "/wt/x")))
val vm = WorktreeViewModel(gateway, "/repo")
vm.remove(WorktreeInfo(path = "/wt/x", branch = "feat", isMain = false), force = true)
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Done)
assertEquals(Triple("/repo", "/wt/x", true), gateway.removeCalls.single())
}
@Test
fun `a 403 disabled rejection surfaces the safe server message inertly`() = runTest {
val gateway = FakeWorktreeGateway(
createOutcome = GitWriteOutcome.Rejected(403, "Worktree creation is disabled."),
)
val vm = WorktreeViewModel(gateway, "/repo")
vm.create("feat/x")
assertEquals("Worktree creation is disabled.", (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
}
@Test
fun `a 429 rate-limit surfaces the rate-limited copy`() = runTest {
val gateway = FakeWorktreeGateway(pruneOutcome = GitWriteOutcome.RateLimited)
val vm = WorktreeViewModel(gateway, "/repo")
vm.prune()
assertEquals(WorktreeCopy.RATE_LIMITED, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
}
@Test
fun `prune with nothing to reclaim reports an empty result`() = runTest {
val gateway = FakeWorktreeGateway(pruneOutcome = GitWriteOutcome.Ok(PruneWorktreesResult(pruned = emptyList())))
val vm = WorktreeViewModel(gateway, "/repo")
vm.prune()
val done = vm.phase.value as WorktreeViewModel.Phase.Done
assertTrue(done.message.contains("没有"))
}
}

View File

@@ -29,17 +29,34 @@ android {
minSdk = 29 minSdk = 29
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
testOptions {
unitTests {
// The device-enroll orchestration commit logs via android.util.Log — let the JVM unit
// tests stub it (return 0) instead of throwing "not mocked". The security-critical paths
// (commit sequencing, error handling) run on the JVM with a software key double.
isReturnDefaultValues = true
}
}
} }
kotlin { kotlin {
jvmToolchain(17) jvmToolchain(17)
} }
// JVM (local) unit tests use JUnit 5 (matching the pure modules); AGP's testDebug/ReleaseUnitTest
// tasks are `Test` tasks, so opt them into the JUnit Platform.
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies { dependencies {
// Pure half: Pkcs12Parse (parse+validate), ClientKeyManagerLogic (alias truth table), // Pure half: Pkcs12Parse (parse+validate), ClientKeyManagerLogic (alias truth table),
// CertificateSummary(Reader). `api` so :app sees the shared ParsedClientIdentity/summary types. // CertificateSummary(Reader). `api` so :app sees the shared ParsedClientIdentity/summary types.
// (No :wire-protocol dep — nothing in src/main references wang.yaojia.webterm.wire*.) // (No :wire-protocol dep — nothing in src/main references wang.yaojia.webterm.wire*.)
api(project(":client-tls")) api(project(":client-tls"))
// B4 device-enroll: the pure CSR encoder + login/enroll/renew client + HttpTransport seam live in
// :api-client (JVM-unit-tested); the framework HardwareBackedKey/DeviceEnroller drive them.
implementation(project(":api-client"))
implementation(libs.tink.android) implementation(libs.tink.android)
implementation(libs.okhttp) implementation(libs.okhttp)
// Mutex serializes the two-store rotation commit (single-commit invariant, A11). // Mutex serializes the two-store rotation commit (single-commit invariant, A11).
@@ -50,4 +67,10 @@ dependencies {
androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.kotlinx.coroutines.core) // runBlocking for suspend mutators androidTestImplementation(libs.kotlinx.coroutines.core) // runBlocking for suspend mutators
// Local JVM unit tests (src/test) — the DeviceEnroller enroll/commit orchestration driven with a
// software P-256 key double + the shared FakeHttpTransport (no emulator, no AndroidKeyStore).
testImplementation(project(":test-support"))
testImplementation(libs.bundles.unit.test)
testRuntimeOnly(libs.junit.platform.launcher)
} }

View File

@@ -0,0 +1,138 @@
package wang.yaojia.webterm.tlsandroid
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.security.Signature
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.api.enroll.CertificateSigningRequest
/**
* B4 · Instrumented (real AndroidKeyStore — NOT Robolectric, plan §7) proof that the generated
* device key is hardware-backed, NON-EXPORTABLE, and produces a self-signed P-256 CSR the
* control-plane accepts. COMPILES in CI here; RUNS on a device/emulator during device QA
* (StrongBox availability is device-dependent — [HardwareKeyStore.generate] falls back to the TEE).
*/
@RunWith(AndroidJUnit4::class)
class HardwareBackedKeyTest {
private val alias = "test-device-enroll-key"
@Before
fun clean() = HardwareKeyStore.delete(alias)
@After
fun tearDown() = HardwareKeyStore.delete(alias)
@Test
fun generate_producesA65ByteX963PublicPoint() {
val key = HardwareKeyStore.generate(alias)
val point = key.publicKeyX963()
assertEquals(65, point.size)
assertEquals(0x04, point[0].toInt() and 0xFF)
}
@Test
fun generatedKeyIsNonExportable() {
HardwareKeyStore.generate(alias)
val loaded = HardwareKeyStore.load(alias)
assertNotNull(loaded)
// AndroidKeyStore private keys have no exportable encoding — the material never leaves HW.
assertNull("AndroidKeyStore key must expose no encoded form", loaded!!.keyHandle.encoded)
}
@Test
fun csrSignedByHardwareKeySelfVerifies() {
val key = HardwareKeyStore.generate(alias)
val der = CertificateSigningRequest.der("t1-android", key)
// Re-parse the CertificationRequestInfo + signature and verify with the embedded public key.
val outer = TestDer.read(der, 0)!!
val parts = TestDer.children(der, outer)
val info = der.copyOfRange(parts[0].start, parts[0].end)
val bitString = parts[2]
val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd)
// Rebuild a JCA public key from the X9.63 point to run the same crypto check the server does.
val point = key.publicKeyX963()
val pub = X963PublicKeys.p256(point)
val ok = Signature.getInstance("SHA256withECDSA").apply {
initVerify(pub)
update(info)
}.verify(signature)
assertTrue("hardware-signed CSR must self-verify", ok)
}
@Test
fun loadAfterGenerateReturnsAKeyWithTheSamePublicPoint() {
val generated = HardwareKeyStore.generate(alias)
val reloaded = HardwareKeyStore.load(alias)
assertNotNull(reloaded)
assertArrayEquals(generated.publicKeyX963(), reloaded!!.publicKeyX963())
}
@Test
fun loadReturnsNullWhenNoKeyExists() {
assertNull(HardwareKeyStore.load("absent-alias-xyz"))
}
}
/** Reconstruct a P-256 public key from an X9.63 uncompressed point, for on-device signature checks. */
private object X963PublicKeys {
fun p256(point: ByteArray): java.security.PublicKey {
val params = java.security.AlgorithmParameters.getInstance("EC").apply {
init(java.security.spec.ECGenParameterSpec("secp256r1"))
}
val spec = params.getParameterSpec(java.security.spec.ECParameterSpec::class.java)
val x = java.math.BigInteger(1, point.copyOfRange(1, 33))
val y = java.math.BigInteger(1, point.copyOfRange(33, 65))
val pubSpec = java.security.spec.ECPublicKeySpec(java.security.spec.ECPoint(x, y), spec)
return java.security.KeyFactory.getInstance("EC").generatePublic(pubSpec)
}
}
/** A throwaway canonical-DER reader for structural assertions (device-side mirror of the JVM test). */
private object TestDer {
data class Element(val tag: Int, val start: Int, val valueStart: Int, val valueEnd: Int) {
val end: Int get() = valueEnd
}
fun read(bytes: ByteArray, start: Int): Element? {
if (start < 0 || start + 1 >= bytes.size) return null
val tag = bytes[start].toInt() and 0xFF
var index = start + 1
val first = bytes[index].toInt() and 0xFF
index += 1
var length = 0
if (first and 0x80 == 0) {
length = first
} else {
val count = first and 0x7F
if (count == 0 || count > 4 || index + count > bytes.size) return null
repeat(count) {
length = (length shl 8) or (bytes[index].toInt() and 0xFF)
index += 1
}
}
val valueEnd = index + length
if (valueEnd > bytes.size) return null
return Element(tag, start, index, valueEnd)
}
fun children(bytes: ByteArray, parent: Element): List<Element> {
val elements = mutableListOf<Element>()
var index = parent.valueStart
while (index < parent.valueEnd) {
val element = read(bytes, index) ?: break
elements.add(element)
index = element.valueEnd
}
return elements
}
}

View File

@@ -100,6 +100,31 @@ class IdentityRepositoryTest {
assertEquals(importer.primarySlot, certStore.load()?.keyStoreAlias) assertEquals(importer.primarySlot, certStore.load()?.keyStoreAlias)
} }
/**
* FIX 3 (cache freshness): a device cert committed OUT OF BAND of a running repository (the zero-`.p12`
* [DeviceEnroller] writes the leaf straight into the shared [CertStore] + AndroidKeyStore) is picked up
* by [AndroidIdentityRepository.refreshFromStore] WITHOUT a process restart — the cached "no identity"
* flips to the freshly-committed leaf and is presented on the next handshake.
*/
@Test
fun refreshFromStore_publishesAnOutOfBandCommittedIdentity_withoutRestart() = runBlocking {
val running = newRepository()
// Touch it while nothing is installed — caches the (null) initial identity.
assertFalse(running.hasInstalledIdentity())
// Simulate DeviceEnroller committing an identity out of band (a second repo over the SAME stores).
newRepository().importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE)
// The running repo still shows its stale cache (no restart yet).
assertFalse("stale cache still reports no identity before a refresh", running.hasInstalledIdentity())
running.refreshFromStore()
// The refresh re-read the committed live-pointer → the enrolled leaf is now live.
assertTrue("refreshFromStore must publish the out-of-band committed identity", running.hasInstalledIdentity())
assertEquals(Fixtures.LEAF_SUBJECT_CN, running.currentSummary()?.subjectCommonName)
}
@Test @Test
fun rotateThenRemove_evictsPooledConnections_andClearsIdentity() = runBlocking { fun rotateThenRemove_evictsPooledConnections_andClearsIdentity() = runBlocking {
val repository = newRepository() val repository = newRepository()

View File

@@ -0,0 +1,155 @@
package wang.yaojia.webterm.tlsandroid
import android.util.Log
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import wang.yaojia.webterm.api.enroll.CertificateSigningRequest
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
import wang.yaojia.webterm.api.enroll.EnrollmentResult
import wang.yaojia.webterm.clienttls.CertificateSummary
import wang.yaojia.webterm.clienttls.CertificateSummaryReader
/**
* B4 · The Android device-enroll orchestrator — the `.p12`-free path that mirrors iOS
* `KeychainClientIdentityStore.enroll/renew`. It composes the five B4 pieces:
*
* 1. generate a NON-EXPORTABLE hardware key ([HardwareKeyStore]: StrongBox → TEE),
* 2. self-sign a P-256 PKCS#10 CSR with it ([CertificateSigningRequest], `:api-client`),
* 3. run the login → `POST /device/enroll` flow ([DeviceEnrollmentClient], `:api-client`),
* 4. store the returned leaf + issuer chain into the SAME [CertStore] + AndroidKeyStore slot the
* existing [AndroidIdentityRepository] resolves from — so it is presented on the EXISTING
* re-reading `X509KeyManager` mTLS path with no change to that module, and
* 5. expose a silent [renew] against `/device/:id/renew` using the SAME hardware key.
*
* The mutating methods are serialized by a [Mutex] so an enroll and a rotation can never interleave
* the two-store commit (cert live-pointer + enrollment record).
*
* ### The commit
* The cert-store save is THE durable live-pointer flip (identical to the import/rotation path). It is
* written LAST, after the enrollment record, so a successful cert-store save always means the mTLS
* identity is fully live; the pool is then evicted so the next handshake presents the new leaf.
*/
public class DeviceEnroller(
private val client: DeviceEnrollmentClient,
private val certStore: CertStore,
private val recordStore: EnrollmentRecordStore,
private val sharedClient: OkHttpClient,
private val keyAlias: String = AndroidKeyStoreImporter.DEFAULT_ALIAS,
private val keyProvider: DeviceKeyProvider = HardwareDeviceKeyProvider,
// FIX 3 (cache freshness): the in-memory identity cache (AndroidIdentityRepository) is refreshed
// AFTER each commit so a mid-session enroll/renew is presented on the NEXT handshake with no
// process restart. Optional so the JVM orchestration tests can construct the enroller without it.
private val cacheRefresher: IdentityCacheRefresher? = null,
) {
private val commitMutex = Mutex()
/** Raised when a state-changing enroll/renew precondition is not met. Never leaks a secret. */
public class EnrollmentStateException(message: String) : Exception(message)
/**
* One-time enrollment: login (operator password → short-lived `device:enroll` bearer) → generate
* a non-exportable hardware key → CSR → `POST /device/enroll` → store the leaf + present it.
* Returns the installed leaf's display summary. The bearer is held only for this call, never
* persisted or logged.
*/
public suspend fun enroll(
password: String,
subdomain: String,
deviceName: String,
): CertificateSummary = commitMutex.withLock {
val login = client.login(password)
// Generate the hardware key ONLY after a successful login, so a rejected credential never
// burns a fresh key slot; overwrites any stale key at the alias.
val key = keyProvider.generate(keyAlias)
try {
val csr = CertificateSigningRequest.der(deviceName, key)
val result = client.enroll(login.enrollToken, csr, subdomain, deviceName)
commitIdentity(result, deviceName, key.alias)
summaryOf(result)
} catch (e: Exception) {
// The enroll failed AFTER keygen: drop the orphan key so a retry starts clean and no
// unreferenced key lingers in secure hardware.
runCatching { keyProvider.delete(key.alias) }
throw e
}
}
/**
* Silent rotation: re-CSR from the SAME hardware key and replace the leaf via
* `POST /device/:id/renew`. The renew endpoint authenticates by the CURRENT device certificate over
* mTLS (the presented client cert), so [bearerToken] is OPTIONAL and defaults to absent — the
* production caller passes none (mirrors iOS, which renews with `bearerToken: nil`). The seam still
* accepts a bearer for a hypothetical bearer-authenticated renew, but bakes in no credential policy;
* it only re-signs and re-commits. Throws [EnrollmentStateException] if there is nothing enrolled to
* renew or the key is gone.
*/
public suspend fun renew(bearerToken: String? = null): CertificateSummary = commitMutex.withLock {
val record = recordStore.load()
?: throw EnrollmentStateException("no enrollment record — nothing to renew")
val key = keyProvider.load(record.keyStoreAlias)
?: throw EnrollmentStateException("device key missing — a fresh enroll is required")
val csr = CertificateSigningRequest.der(record.deviceName, key)
val result = client.renew(record.deviceId, csr, bearerToken)
commitIdentity(result, record.deviceName, record.keyStoreAlias)
summaryOf(result)
}
/** Remove the enrolled identity: cert pointer, enrollment record, and the hardware key. */
public suspend fun remove(): Unit = commitMutex.withLock {
certStore.clear()
recordStore.clear()
keyProvider.delete(keyAlias)
sharedClient.connectionPool.evictAll()
}
/**
* Persist the enrollment record (deviceId → renew), THEN commit the cert live-pointer (the mTLS
* flip), THEN evict pooled/resumed connections so the next handshake presents the new leaf via
* the existing re-reading `X509KeyManager`. The key already lives in AndroidKeyStore at [alias];
* the private key never enters storage.
*/
private fun commitIdentity(result: EnrollmentResult, deviceName: String, alias: String) {
val leaf = parseCertificate(result.certificate)
val issuers = result.caChain.map { parseCertificate(it) }
recordStore.save(
EnrollmentRecord(
deviceId = result.deviceId,
deviceName = deviceName,
keyStoreAlias = alias,
renewAfterEpochSeconds = result.renewAfter?.epochSecond ?: 0L,
),
)
certStore.save(
StoredIdentityMetadata(
alias = alias,
keyAlgorithm = KEY_ALGORITHM_EC,
keyStoreAlias = alias,
certificateChain = listOf(leaf) + issuers,
),
)
sharedClient.connectionPool.evictAll()
// FIX 3: re-read the just-committed live-pointer into the in-memory identity cache so the
// newly enrolled/renewed leaf is presented on the NEXT mTLS handshake without a restart. Done
// AFTER the durable commit + pool eviction so the cache can never publish an un-committed leaf.
cacheRefresher?.refreshFromStore()
Log.i(TAG, "Device identity enrolled/renewed and committed for alias '$alias'")
}
private fun summaryOf(result: EnrollmentResult): CertificateSummary =
CertificateSummaryReader.summarize(parseCertificate(result.certificate))
private fun parseCertificate(der: ByteArray): X509Certificate =
CertificateFactory.getInstance(X509).generateCertificate(der.inputStream()) as X509Certificate
private companion object {
const val TAG = "DeviceEnroller"
const val X509 = "X.509"
/** AndroidKeyStore EC keys report algorithm "EC" — matched by `ClientKeyManagerLogic`. */
const val KEY_ALGORITHM_EC = "EC"
}
}

View File

@@ -0,0 +1,33 @@
package wang.yaojia.webterm.tlsandroid
/**
* B4 · A seam over the three non-exportable hardware-key operations [DeviceEnroller]'s
* enroll/renew orchestration needs. Production wires the real AndroidKeyStore-backed
* [HardwareKeyStore] (StrongBox → TEE); a JVM unit test wires a software P-256 double, so the
* enroll/commit orchestration (request shaping, error handling, the two-store commit sequencing)
* can be exercised without an emulator. NOTHING about the hardware-key policy leaks through this
* seam beyond generate/load/delete — the key stays non-exportable in the real implementation.
*/
public interface DeviceKeyProvider {
/** Generate a fresh non-exportable key at [alias], overwriting any prior entry there. */
public fun generate(alias: String): HardwareBackedKey
/** Load a previously-generated key by [alias], or null if no entry exists (pre-enroll state). */
public fun load(alias: String): HardwareBackedKey?
/** Delete the key entry at [alias]. Idempotent (a missing alias is a no-op). */
public fun delete(alias: String)
}
/**
* The production [DeviceKeyProvider] — a thin delegate to the AndroidKeyStore-backed
* [HardwareKeyStore]. Kept as a stateless object so it can be the [DeviceEnroller] constructor
* default without any wiring.
*/
public object HardwareDeviceKeyProvider : DeviceKeyProvider {
override fun generate(alias: String): HardwareBackedKey = HardwareKeyStore.generate(alias)
override fun load(alias: String): HardwareBackedKey? = HardwareKeyStore.load(alias)
override fun delete(alias: String): Unit = HardwareKeyStore.delete(alias)
}

View File

@@ -0,0 +1,143 @@
package wang.yaojia.webterm.tlsandroid
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.RegistryConfiguration
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.integration.android.AndroidKeysetManager
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
/**
* B4 · The auxiliary enrollment record needed to drive silent rotation: the server-minted
* [deviceId] (the `/device/:id/renew` path segment), the [deviceName] re-used as the renew CSR
* subject CN, the AndroidKeyStore [keyStoreAlias] holding the SAME non-exportable key to re-sign
* with, and [renewAfterEpochSeconds] (0 = unknown) for the rotation scheduler.
*
* This is deliberately SEPARATE from [StoredIdentityMetadata] (the mTLS live-pointer): the cert
* identity is what the handshake presents; this record only exists so renew can find the device and
* its key. The private key is never here — it stays non-exportable in AndroidKeyStore.
*/
public data class EnrollmentRecord(
val deviceId: String,
val deviceName: String,
val keyStoreAlias: String,
val renewAfterEpochSeconds: Long,
) {
init {
require(deviceId.isNotBlank()) { "deviceId must not be blank" }
require(keyStoreAlias.isNotBlank()) { "keyStoreAlias must not be blank" }
}
}
/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — three UTF strings + one long). */
public object EnrollmentRecordCodec {
public fun encode(record: EnrollmentRecord): ByteArray {
val out = ByteArrayOutputStream()
DataOutputStream(out).use { data ->
data.writeUTF(record.deviceId)
data.writeUTF(record.deviceName)
data.writeUTF(record.keyStoreAlias)
data.writeLong(record.renewAfterEpochSeconds)
}
return out.toByteArray()
}
/** Decode [bytes]; any structural failure → [CorruptStoredIdentityException]. */
public fun decode(bytes: ByteArray): EnrollmentRecord =
try {
DataInputStream(ByteArrayInputStream(bytes)).use { data ->
EnrollmentRecord(
deviceId = data.readUTF(),
deviceName = data.readUTF(),
keyStoreAlias = data.readUTF(),
renewAfterEpochSeconds = data.readLong(),
)
}
} catch (e: Exception) {
throw CorruptStoredIdentityException("Stored enrollment record was truncated/malformed", e)
}
}
/**
* Storage contract for the [EnrollmentRecord] — repository pattern so [DeviceEnroller] depends on
* the operation set and a fault/blank can be injected in tests. Idempotent [clear].
*/
public interface EnrollmentRecordStore {
public fun save(record: EnrollmentRecord)
public fun load(): EnrollmentRecord?
public fun clear()
}
/**
* Tink-AEAD-encrypted [EnrollmentRecordStore] over an app-private `SharedPreferences` file, mirroring
* [TinkCertStore]'s custody model (AndroidKeystore-wrapped master key; uninstall-wiped; useless off
* this device). Kept in its own key/file namespace so it never collides with the cert live-pointer.
*/
public class TinkEnrollmentRecordStore(
context: Context,
private val keysetName: String = DEFAULT_KEYSET_NAME,
private val prefFileName: String = DEFAULT_PREF_FILE,
private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI,
) : EnrollmentRecordStore {
private val appContext: Context = context.applicationContext
private val aead: Aead by lazy { buildAead() }
override fun save(record: EnrollmentRecord) {
val ciphertext = aead.encrypt(EnrollmentRecordCodec.encode(record), ASSOCIATED_DATA)
val committed = prefs().edit()
.putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP))
.commit()
if (!committed) throw java.io.IOException("Failed to durably persist the device enrollment record")
}
override fun load(): EnrollmentRecord? {
val encoded = prefs().getString(BLOB_KEY, null) ?: return null
val ciphertext = try {
Base64.decode(encoded, Base64.NO_WRAP)
} catch (e: IllegalArgumentException) {
throw CorruptStoredIdentityException("Enrollment record blob was not valid base64", e)
}
val plaintext = try {
aead.decrypt(ciphertext, ASSOCIATED_DATA)
} catch (e: java.security.GeneralSecurityException) {
throw CorruptStoredIdentityException("Enrollment record blob failed AEAD decryption", e)
}
return EnrollmentRecordCodec.decode(plaintext)
}
override fun clear() {
val committed = prefs().edit().remove(BLOB_KEY).commit()
if (!committed) throw java.io.IOException("Failed to durably clear the device enrollment record")
}
private fun buildAead(): Aead {
AeadConfig.register()
val keysetHandle = AndroidKeysetManager.Builder()
.withSharedPref(appContext, keysetName, prefFileName)
.withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE))
.withMasterKeyUri(masterKeyUri)
.build()
.keysetHandle
return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
}
private fun prefs(): SharedPreferences =
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
public companion object {
private const val DEFAULT_KEYSET_NAME = "webterm_enroll_keyset"
private const val DEFAULT_PREF_FILE = "webterm_enroll_record_prefs"
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_enroll_master_key"
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
private const val BLOB_KEY = "enrollment_record_blob"
private val ASSOCIATED_DATA: ByteArray = "webterm.client-tls.enrollment-record".toByteArray()
}
}

View File

@@ -0,0 +1,122 @@
package wang.yaojia.webterm.tlsandroid
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.security.keystore.StrongBoxUnavailableException
import android.util.Log
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.Signature
import java.security.cert.X509Certificate
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import wang.yaojia.webterm.api.enroll.CsrSigner
import wang.yaojia.webterm.api.enroll.EcPointEncoding
/**
* B4 · A P-256 signing key that lives ENTIRELY inside AndroidKeyStore and is NON-EXPORTABLE by
* construction (AndroidKeyStore has no key-material getter). It is the Android analogue of the iOS
* `SecureEnclaveKey`: `sign` runs inside secure hardware (StrongBox → TEE) and drives the same
* `Signature("SHA256withECDSA")` path the JVM-unit-test software key uses, so [CsrSigner] callers
* (`CertificateSigningRequest`) are exercised identically.
*
* The wrapped [privateKey] is the opaque AndroidKeyStore handle — presented to the re-reading
* `X509KeyManager` for the TLS `CertificateVerify` and never exported. [publicKey] is only used to
* emit the CSR's `SubjectPublicKeyInfo`.
*/
public class HardwareBackedKey internal constructor(
public val alias: String,
private val privateKey: PrivateKey,
private val publicKey: ECPublicKey,
) : CsrSigner {
override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(publicKey)
override fun sign(message: ByteArray): ByteArray =
Signature.getInstance(SIGNATURE_ALGORITHM).apply {
initSign(privateKey)
update(message)
}.sign()
/** The opaque, non-exportable AndroidKeyStore private-key handle presented on the mTLS path. */
public val keyHandle: PrivateKey get() = privateKey
public companion object {
private const val SIGNATURE_ALGORITHM = "SHA256withECDSA"
}
}
/**
* Creates / loads / deletes the device's non-exportable P-256 key in AndroidKeyStore.
*
* Generation prefers **StrongBox** (dedicated secure element) and falls back to the **TEE** when the
* device has no StrongBox — the security posture (non-exportable, hardware-backed, silent-signing)
* is identical either way; StrongBox is a hardening bonus, not a requirement. The key is
* `PURPOSE_SIGN` only with a broad digest set so TLS 1.2/1.3 signature negotiation for the client
* `CertificateVerify` works, and NO user-authentication is required so silent enroll/renew never
* blocks on a biometric prompt.
*/
public object HardwareKeyStore {
private const val TAG = "HardwareKeyStore"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val CURVE = "secp256r1"
/**
* Generate a fresh non-exportable P-256 key at [alias], overwriting any prior entry there.
* StrongBox-backed when available, else TEE-backed. Throws the underlying keystore exception if
* BOTH paths fail (never returns a half-generated key).
*/
public fun generate(alias: String): HardwareBackedKey {
val keyPair = try {
generateKeyPair(alias, strongBox = true)
} catch (_: StrongBoxUnavailableException) {
Log.i(TAG, "StrongBox unavailable; generating a TEE-backed device key (non-exportable)")
delete(alias) // clear any partial StrongBox entry before the TEE retry
generateKeyPair(alias, strongBox = false)
}
return HardwareBackedKey(alias, keyPair.private, keyPair.public as ECPublicKey)
}
/**
* Load a previously-generated key by [alias] (renew path, after relaunch). The public key is
* recovered from the self-signed placeholder certificate AndroidKeyStore stored at generation.
* Returns null if no key entry exists (the normal pre-enroll state).
*/
public fun load(alias: String): HardwareBackedKey? {
val keyStore = androidKeyStore()
val privateKey = keyStore.getKey(alias, null) as? PrivateKey ?: return null
val publicKey = (keyStore.getCertificate(alias) as? X509Certificate)?.publicKey as? ECPublicKey
?: return null
return HardwareBackedKey(alias, privateKey, publicKey)
}
/** Delete the key entry at [alias]. Idempotent (a missing alias is a no-op). */
public fun delete(alias: String) {
val keyStore = androidKeyStore()
if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias)
}
/** Cheap existence check (does NOT read key material). */
public fun exists(alias: String): Boolean = androidKeyStore().containsAlias(alias)
private fun generateKeyPair(alias: String, strongBox: Boolean): KeyPair {
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
.setAlgorithmParameterSpec(ECGenParameterSpec(CURVE))
.setDigests(
KeyProperties.DIGEST_NONE,
KeyProperties.DIGEST_SHA256,
KeyProperties.DIGEST_SHA384,
KeyProperties.DIGEST_SHA512,
)
.setIsStrongBoxBacked(strongBox)
.build()
val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEYSTORE)
generator.initialize(spec)
return generator.generateKeyPair()
}
private fun androidKeyStore(): KeyStore =
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
}

View File

@@ -56,6 +56,18 @@ public interface IdentityRepository {
public suspend fun remove() public suspend fun remove()
} }
/**
* B4 · A narrow seam the zero-`.p12` enroll/renew commit ([DeviceEnroller]) fires so an in-memory
* identity cache re-reads the freshly-committed live-pointer and presents the new leaf on the NEXT
* mTLS handshake WITHOUT a process restart. Kept separate from [IdentityRepository] so the enroller
* depends only on this one operation (it never needs the import/rotate/remove surface). The production
* implementation is [AndroidIdentityRepository]; a JVM test uses a recording double.
*/
public fun interface IdentityCacheRefresher {
/** Reload the persisted live identity into the in-memory cache and drop stale pooled connections. */
public fun refreshFromStore()
}
/** /**
* Default [IdentityRepository] over [AndroidKeyStoreImporter] (key home) + [CertStore] (encrypted * Default [IdentityRepository] over [AndroidKeyStoreImporter] (key home) + [CertStore] (encrypted
* live-pointer at rest) + the shared [OkHttpClient] (for `connectionPool.evictAll()`). * live-pointer at rest) + the shared [OkHttpClient] (for `connectionPool.evictAll()`).
@@ -90,7 +102,7 @@ public class AndroidIdentityRepository(
private val importer: AndroidKeyStoreImporter, private val importer: AndroidKeyStoreImporter,
private val certStore: CertStore, private val certStore: CertStore,
private val sharedClient: OkHttpClient, private val sharedClient: OkHttpClient,
) : IdentityRepository { ) : IdentityRepository, IdentityCacheRefresher {
/** The live identity as tracked by the repo: the KeyManager view + which physical slot holds the key. */ /** The live identity as tracked by the repo: the KeyManager view + which physical slot holds the key. */
private class LiveIdentity(val installed: InstalledIdentity, val keyStoreAlias: String) private class LiveIdentity(val installed: InstalledIdentity, val keyStoreAlias: String)
@@ -150,6 +162,20 @@ public class AndroidIdentityRepository(
sharedClient.connectionPool.evictAll() sharedClient.connectionPool.evictAll()
} }
/**
* FIX 3 (cache freshness) · Re-read the persisted live-pointer into the in-memory cache. Used when a
* device certificate is committed OUT OF BAND of this repository — the zero-`.p12` [DeviceEnroller]
* writes the leaf straight into the shared [CertStore] + AndroidKeyStore, so without this the running
* repo would keep presenting its cached (pre-enroll) identity until process restart. Publishing the
* freshly-loaded snapshot as [liveOverride] and evicting pooled connections makes the enrolled leaf
* present on the NEXT handshake. Reloading to `null` (a fault/absent pointer) is a valid outcome and
* simply reports "no identity". Not `suspend` — the enroller already runs this off the UI thread.
*/
override fun refreshFromStore() {
liveOverride = Box(loadInstalledOrNull())
sharedClient.connectionPool.evictAll()
}
/** /**
* Single-commit install/rotation (see the class KDoc). Validation throws before any mutation; * Single-commit install/rotation (see the class KDoc). Validation throws before any mutation;
* the new key is imported into the non-live slot; the COMMIT is one atomic [CertStore.save] that * the new key is imported into the non-live slot; the COMMIT is one atomic [CertStore.save] that

View File

@@ -0,0 +1,313 @@
package wang.yaojia.webterm.tlsandroid
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HttpMethod
import java.security.KeyPairGenerator
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
/**
* B4 · JVM unit coverage for the [DeviceEnroller] enroll/renew ORCHESTRATION — the layer that runs
* the security-critical two-store commit. Driven with a software P-256 key ([DeviceKeyProvider]
* double) + the shared [FakeHttpTransport], so request shaping, error handling, and — most
* importantly — the commit SEQUENCING run without an emulator or a real AndroidKeyStore.
*
* The security-critical invariant under test: the enrollment record is persisted BEFORE the cert
* live-pointer flip (the mTLS commit), so a successful cert-store save always means the identity is
* fully live (see [DeviceEnroller.commitIdentity]).
*/
class DeviceEnrollerTest {
private companion object {
const val BASE = "https://cp.terminal.yaojia.wang"
const val ALIAS = "test-device-key"
// Real self-signed P-256 X.509 certs (base64 DER) so commitIdentity's CertificateFactory /
// CertificateSummaryReader parse them exactly as they parse a server-issued leaf.
const val LEAF_CN = "t1-device"
const val CA_CN = "webterm-device-ca"
const val LEAF_B64 =
"MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" +
"ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" +
"WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" +
"cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" +
"HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" +
"ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" +
"cdoleWDlqcMKU"
const val CA_B64 =
"MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" +
"dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" +
"YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" +
"e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" +
"4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" +
"Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" +
"YloHuEAg+kngzA33m52aWtublai4L+eybg=="
fun loginBody(): ByteArray =
"""{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray()
fun enrollBody(deviceId: String = "dev-1"): ByteArray =
"""
{"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"],
"notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z",
"renewAfter":"2026-09-05T00:00:00.000Z"}
""".trimIndent().toByteArray()
fun softwareKey(alias: String): HardwareBackedKey {
val kpg = KeyPairGenerator.getInstance("EC")
kpg.initialize(ECGenParameterSpec("secp256r1"))
val kp = kpg.generateKeyPair()
return HardwareBackedKey(alias, kp.private, kp.public as ECPublicKey)
}
}
private val events = mutableListOf<String>()
private val transport = FakeHttpTransport()
private val certStore = RecordingCertStore(events)
private val recordStore = RecordingRecordStore(events)
private val keyProvider = RecordingKeyProvider(events)
private val refresher = RecordingRefresher(events)
private fun enroller(): DeviceEnroller =
DeviceEnroller(
client = DeviceEnrollmentClient(BASE, transport),
certStore = certStore,
recordStore = recordStore,
sharedClient = OkHttpClient(),
keyAlias = ALIAS,
keyProvider = keyProvider,
cacheRefresher = refresher,
)
// ── enroll: commit sequencing (the security-critical invariant) ────────────────────────────
@Test
fun enrollPersistsTheRecordBeforeTheCertLivePointerFlip() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
val summary = enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
// Record.save strictly precedes cert.save — the mTLS pointer flip is written LAST.
assertTrue(events.contains("record.save") && events.contains("cert.save"))
assertTrue(
events.indexOf("record.save") < events.indexOf("cert.save"),
"the enrollment record must be committed BEFORE the cert live-pointer flip",
)
// Both stores received the issued identity; the stored chain is leaf + issuer (from caChain).
assertEquals("dev-1", recordStore.saved!!.deviceId)
assertEquals(ALIAS, recordStore.saved!!.keyStoreAlias)
val chain = certStore.saved!!.certificateChain
assertEquals(2, chain.size, "stored chain = leaf + one caChain issuer")
assertTrue(chain[0].subjectX500Principal.name.contains(LEAF_CN), "chain[0] is the leaf")
assertTrue(chain[1].subjectX500Principal.name.contains(CA_CN), "chain[1] is the device-CA issuer")
// The install summary is read off the leaf via the production CertificateSummaryReader.
assertEquals(LEAF_CN, summary.subjectCommonName)
}
@Test
fun enrollRefreshesTheIdentityCacheAfterTheCommit() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
// FIX 3: the in-memory identity cache is refreshed AFTER the durable cert live-pointer flip, so
// the newly enrolled leaf is presented on the next handshake with no restart.
assertTrue(events.contains("cache.refresh"), "the identity cache must be refreshed on enroll")
assertTrue(
events.indexOf("cert.save") < events.indexOf("cache.refresh"),
"the cache refresh must run AFTER the cert live-pointer commit (never publish an un-committed leaf)",
)
}
@Test
fun enrollShapesTheLoginAndEnrollRequests() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
val login = transport.recordedRequests[0]
assertEquals("$BASE/auth/login", login.url)
assertTrue(login.body!!.decodeToString().contains("\"password\":\"hunter2\""))
val enroll = transport.recordedRequests[1]
assertEquals("$BASE/device/enroll", enroll.url)
assertEquals("Bearer tok-xyz", enroll.headers["Authorization"], "enroll rides the login bearer")
val enrollBodyStr = enroll.body!!.decodeToString()
assertTrue(enrollBodyStr.contains("\"subdomain\":\"alice\""))
assertTrue(enrollBodyStr.contains("\"deviceName\":\"Alice Pixel\""))
}
// ── enroll: error handling ─────────────────────────────────────────────────────────────────
@Test
fun enrollDropsTheOrphanKeyWhenEnrollFailsAfterKeygen() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 403, body = """{"error":"rejected"}""".toByteArray())
val error = runCatching {
enroller().enroll(password = "hunter2", subdomain = "bob", deviceName = "Bob Pixel")
}.exceptionOrNull()
assertEquals(DeviceEnrollmentError.Http(403, "rejected"), error)
assertEquals(listOf(ALIAS), keyProvider.generatedAliases, "the key was generated after login")
assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the orphan key is dropped on enroll failure")
assertNull(recordStore.saved, "no record is committed when enroll fails")
assertNull(certStore.saved, "the cert live-pointer is never flipped when enroll fails")
}
@Test
fun enrollNeverBurnsAKeyWhenLoginIsRejected() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 401, body = """{"error":"rejected"}""".toByteArray())
val error = runCatching {
enroller().enroll(password = "wrong", subdomain = "alice", deviceName = "Alice Pixel")
}.exceptionOrNull()
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
assertTrue(keyProvider.generatedAliases.isEmpty(), "a rejected credential must not burn a key slot")
assertNull(recordStore.saved)
assertNull(certStore.saved)
}
// ── renew: preconditions + request shaping + commit ────────────────────────────────────────
@Test
fun renewThrowsWhenNothingIsEnrolled() = runTest {
val error = runCatching { enroller().renew() }.exceptionOrNull()
assertTrue(error is DeviceEnroller.EnrollmentStateException)
assertTrue(transport.recordedRequests.isEmpty(), "no network I/O when there is nothing to renew")
}
@Test
fun renewThrowsWhenTheDeviceKeyIsMissing() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
// keyProvider has no key at ALIAS → load() returns null.
val error = runCatching { enroller().renew() }.exceptionOrNull()
assertTrue(error is DeviceEnroller.EnrollmentStateException)
assertTrue(transport.recordedRequests.isEmpty(), "no renew call when the hardware key is gone")
}
@Test
fun renewReCsrsFromTheSameKeyOverMtlsWithNoBearerAndACsrOnlyBody() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
keyProvider.seed(ALIAS, softwareKey(ALIAS))
transport.queueSuccess(HttpMethod.POST, "$BASE/device/dev-1/renew", 201, body = enrollBody())
// FIX 2: production renew passes NO bearer — the endpoint authenticates by the current cert (mTLS).
enroller().renew()
val renew = transport.recordedRequests.single()
assertEquals("$BASE/device/dev-1/renew", renew.url)
assertNull(renew.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header")
val body = renew.body!!.decodeToString()
// The renew body is {csr}-only — the server's .strict() schema rejects any enroll-only extra.
assertTrue(body.contains("\"csr\":"), "renew sends the fresh CSR")
assertFalse(body.contains("keyAlg"), "renew must not send the enroll-only keyAlg")
assertFalse(body.contains("subdomain"), "renew must not send subdomain")
assertFalse(body.contains("deviceName"), "renew must not send deviceName")
// Same commit sequencing on the rotation path: record before cert, then cache refresh last.
assertTrue(events.indexOf("record.save") < events.indexOf("cert.save"))
assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh runs after the renew commit")
}
// ── remove: full teardown ──────────────────────────────────────────────────────────────────
@Test
fun removeClearsBothStoresAndDeletesTheKey() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
keyProvider.seed(ALIAS, softwareKey(ALIAS))
enroller().remove()
assertTrue(certStore.cleared)
assertTrue(recordStore.cleared)
assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the hardware key is deleted on remove")
}
// ── recording doubles ──────────────────────────────────────────────────────────────────────
private class RecordingCertStore(private val events: MutableList<String>) : CertStore {
var saved: StoredIdentityMetadata? = null
var cleared = false
override fun save(metadata: StoredIdentityMetadata) {
saved = metadata
events += "cert.save"
}
override fun load(): StoredIdentityMetadata? = saved
override fun clear() {
cleared = true
saved = null
events += "cert.clear"
}
}
private class RecordingRecordStore(private val events: MutableList<String>) : EnrollmentRecordStore {
var saved: EnrollmentRecord? = null
var cleared = false
private var current: EnrollmentRecord? = null
fun seed(record: EnrollmentRecord) {
current = record
}
override fun save(record: EnrollmentRecord) {
saved = record
current = record
events += "record.save"
}
override fun load(): EnrollmentRecord? = current
override fun clear() {
cleared = true
current = null
events += "record.clear"
}
}
private class RecordingRefresher(private val events: MutableList<String>) : IdentityCacheRefresher {
override fun refreshFromStore() {
events += "cache.refresh"
}
}
private class RecordingKeyProvider(private val events: MutableList<String>) : DeviceKeyProvider {
private val keys = mutableMapOf<String, HardwareBackedKey>()
val generatedAliases = mutableListOf<String>()
val deletedAliases = mutableListOf<String>()
fun seed(alias: String, key: HardwareBackedKey) {
keys[alias] = key
}
override fun generate(alias: String): HardwareBackedKey {
val key = softwareKey(alias)
keys[alias] = key
generatedAliases += alias
events += "generate:$alias"
return key
}
override fun load(alias: String): HardwareBackedKey? = keys[alias]
override fun delete(alias: String) {
keys.remove(alias)
deletedAliases += alias
events += "delete:$alias"
}
}
}

4
control-panel/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
public/build/
coverage/
*.log

37
control-panel/build.mjs Normal file
View File

@@ -0,0 +1,37 @@
/**
* Frontend build — mirrors the base app's esbuild style (vanilla TS → ESM bundle). Emits
* public/build/{app.js, index.html, styles.css}. index.html + styles.css are copied verbatim
* (index.html already references ./app.js and ./styles.css, both siblings in the build dir).
*/
import { build } from 'esbuild'
import { mkdir, copyFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const pub = resolve(here, 'public')
const out = resolve(pub, 'build')
async function main() {
await mkdir(out, { recursive: true })
await build({
entryPoints: [resolve(pub, 'app.ts')],
bundle: true,
format: 'esm',
target: 'es2022',
minify: true,
sourcemap: true,
outfile: resolve(out, 'app.js'),
logLevel: 'info',
})
await copyFile(resolve(pub, 'index.html'), resolve(out, 'index.html'))
await copyFile(resolve(pub, 'styles.css'), resolve(out, 'styles.css'))
process.stdout.write('[control-panel] frontend build → public/build\n')
}
main().catch((err) => {
process.stderr.write(`[control-panel] build failed: ${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
})

2902
control-panel/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "control-panel",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Web control panel for the zero-touch tunnel system. Loopback-only Fastify backend that gates an operator session behind a password, then proxies the control-plane admin API (list hosts / issue pairing codes / revoke hosts), minting a fresh short-TTL `manage` capability token per call. Vanilla-TS + esbuild SPA. NEVER logs secrets/tokens/passwords.",
"engines": {
"node": ">=18"
},
"main": "src/server.ts",
"scripts": {
"start": "tsx src/server.ts",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.web.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"build": "node build.mjs"
},
"dependencies": {
"fastify": "^4.28.1",
"qrcode": "^1.5.4",
"relay-auth": "file:../relay-auth",
"relay-contracts": "file:../relay-contracts",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^25.9.3",
"@types/qrcode": "^1.5.6",
"@vitest/coverage-v8": "^4.1.9",
"esbuild": "^0.28.1",
"tsx": "^4.19.2",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
}
}

View File

@@ -0,0 +1,77 @@
/**
* Typed fetch client for the panel backend. Same-origin; the session cookie rides automatically
* (HttpOnly — JS never reads it). Every call narrows the response and throws `ApiError` on failure
* so the UI can react (e.g. bounce to the login screen on 401).
*/
export interface HostView {
readonly hostId: string
readonly subdomain: string
readonly status: string
readonly lastSeen?: string
readonly createdAt?: string
readonly revokedAt?: string | null
readonly notAfter?: string
}
export interface PairingArtifacts {
readonly code: string
readonly expiresAt: string
readonly pairCommand: string
readonly qrDataUrl: string
}
export class ApiError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function request(method: string, url: string, body?: unknown): Promise<Response> {
const init: RequestInit = { method, credentials: 'same-origin', headers: {} }
if (body !== undefined) {
init.headers = { 'content-type': 'application/json' }
init.body = JSON.stringify(body)
}
return fetch(url, init)
}
async function json<T>(res: Response): Promise<T> {
if (!res.ok) throw new ApiError(res.status, `request failed (${res.status})`)
return (await res.json()) as T
}
export async function getSession(): Promise<boolean> {
const res = await request('GET', '/api/session')
const data = await json<{ authenticated: boolean }>(res)
return data.authenticated === true
}
/** Attempt login. Returns true on success; throws ApiError (status) otherwise so callers can message. */
export async function login(password: string): Promise<boolean> {
const res = await request('POST', '/login', { password })
if (res.ok) return true
throw new ApiError(res.status, `login failed (${res.status})`)
}
export async function logout(): Promise<void> {
await request('POST', '/logout')
}
export async function getHosts(): Promise<readonly HostView[]> {
const res = await request('GET', '/api/hosts')
const data = await json<{ hosts: HostView[] }>(res)
return data.hosts ?? []
}
export async function createPairingCode(): Promise<PairingArtifacts> {
const res = await request('POST', '/api/pairing-codes')
return json<PairingArtifacts>(res)
}
export async function revokeHost(hostId: string): Promise<void> {
const res = await request('DELETE', `/api/hosts/${encodeURIComponent(hostId)}`)
if (!res.ok && res.status !== 204) throw new ApiError(res.status, `revoke failed (${res.status})`)
}

114
control-panel/public/app.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* SPA bootstrap + state machine. On load: check the session → render login or dashboard. The
* dashboard polls the host list on an interval and refreshes after mutations. A 401 from any proxy
* call bounces the operator back to the login screen (session expired).
*/
import { mount } from './dom.js'
import * as api from './api.js'
import { ApiError } from './api.js'
import { renderLogin, renderDashboard, pairingModal, confirmDialog, toast, type DashboardHandlers } from './views.js'
import type { HostView } from './api.js'
const POLL_INTERVAL_MS = 15_000
function rootEl(): HTMLElement {
const root = document.getElementById('app')
if (root === null) throw new Error('#app root not found')
return root
}
let pollTimer: number | undefined
function stopPolling(): void {
if (pollTimer !== undefined) {
clearInterval(pollTimer)
pollTimer = undefined
}
}
/** True if an error is an auth failure that should bounce to login. */
function isUnauthorized(err: unknown): boolean {
return err instanceof ApiError && err.status === 401
}
async function showLogin(): Promise<void> {
stopPolling()
const root = rootEl()
const view = renderLogin(async (password) => {
const errorNode = (view as HTMLElement & { errorNode?: HTMLElement }).errorNode
try {
await api.login(password)
await showDashboard()
} catch (err) {
const msg = err instanceof ApiError && err.status === 429 ? 'Too many attempts. Wait and retry.' : err instanceof ApiError && err.status === 503 ? 'Panel not configured (no password set).' : 'Incorrect password.'
if (errorNode) errorNode.textContent = msg
}
})
mount(root, view)
}
async function loadHosts(): Promise<readonly HostView[]> {
return api.getHosts()
}
async function refreshDashboard(root: HTMLElement, handlers: DashboardHandlers): Promise<void> {
try {
const hosts = await loadHosts()
mount(root, renderDashboard(hosts, handlers))
} catch (err) {
if (isUnauthorized(err)) {
await showLogin()
return
}
toast(root, 'Failed to load hosts.', 'error')
}
}
async function showDashboard(): Promise<void> {
const root = rootEl()
const handlers: DashboardHandlers = {
onRefresh: () => void refreshDashboard(root, handlers),
onLogout: async () => {
stopPolling()
await api.logout()
await showLogin()
},
onNewCode: async () => {
try {
const artifacts = await api.createPairingCode()
document.body.append(pairingModal(artifacts))
} catch (err) {
if (isUnauthorized(err)) return void showLogin()
toast(root, 'Failed to create pairing code.', 'error')
}
},
onRevoke: async (host) => {
const ok = await confirmDialog(`Revoke host "${host.subdomain}"? This removes its tunnel access.`)
if (!ok) return
try {
await api.revokeHost(host.hostId)
toast(root, `Revoked ${host.subdomain}.`, 'info')
await refreshDashboard(root, handlers)
} catch (err) {
if (isUnauthorized(err)) return void showLogin()
toast(root, 'Failed to revoke host.', 'error')
}
},
}
await refreshDashboard(root, handlers)
stopPolling()
pollTimer = window.setInterval(() => void refreshDashboard(root, handlers), POLL_INTERVAL_MS)
}
async function boot(): Promise<void> {
try {
const authed = await api.getSession()
await (authed ? showDashboard() : showLogin())
} catch {
await showLogin()
}
}
void boot()

View File

@@ -0,0 +1,60 @@
/**
* Tiny DOM builder. ALL text and attribute values are set via `textContent` / `setAttribute` — this
* module NEVER assigns `innerHTML`, so untrusted server/host data (host subdomains, etc.) can never
* inject markup. Children passed as strings become text nodes (also safe).
*/
export type Child = Node | string
export interface ElProps {
class?: string
text?: string
type?: string
name?: string
placeholder?: string
value?: string
disabled?: boolean
autocomplete?: string
title?: string
src?: string
alt?: string
role?: string
ariaLabel?: string
onClick?: (e: MouseEvent) => void
onSubmit?: (e: SubmitEvent) => void
}
export function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
props: ElProps = {},
children: Child[] = [],
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (props.class !== undefined) node.className = props.class
if (props.text !== undefined) node.textContent = props.text
if (props.type !== undefined) node.setAttribute('type', props.type)
if (props.name !== undefined) node.setAttribute('name', props.name)
if (props.placeholder !== undefined) node.setAttribute('placeholder', props.placeholder)
if (props.value !== undefined) (node as HTMLInputElement).value = props.value
if (props.disabled) node.setAttribute('disabled', 'true')
if (props.autocomplete !== undefined) node.setAttribute('autocomplete', props.autocomplete)
if (props.title !== undefined) node.setAttribute('title', props.title)
if (props.src !== undefined) node.setAttribute('src', props.src)
if (props.alt !== undefined) node.setAttribute('alt', props.alt)
if (props.role !== undefined) node.setAttribute('role', props.role)
if (props.ariaLabel !== undefined) node.setAttribute('aria-label', props.ariaLabel)
if (props.onClick !== undefined) node.addEventListener('click', props.onClick as EventListener)
if (props.onSubmit !== undefined) node.addEventListener('submit', props.onSubmit as EventListener)
for (const child of children) node.append(child)
return node
}
/** Remove all children of a node. */
export function clear(node: Element): void {
while (node.firstChild) node.removeChild(node.firstChild)
}
/** Replace a node's content with a single new child. */
export function mount(root: Element, child: Node): void {
clear(root)
root.append(child)
}

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="robots" content="noindex, nofollow" />
<title>Tunnel Control Panel</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main id="app" class="app">
<div class="centered"><p class="muted">Loading…</p></div>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,134 @@
/* Tunnel Control Panel — themed (light/dark via prefers-color-scheme), responsive. */
:root {
--bg: #f6f7f9;
--surface: #ffffff;
--surface-2: #f0f2f5;
--text: #1b1f24;
--muted: #5b6572;
--border: #dfe3e8;
--primary: #2563eb;
--primary-text: #ffffff;
--danger: #dc2626;
--ok: #16a34a;
--warn: #d97706;
--shadow: 0 6px 24px rgba(20, 24, 33, 0.12);
--radius: 12px;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1319;
--surface: #171c24;
--surface-2: #1f2630;
--text: #e7ebf0;
--muted: #9aa5b3;
--border: #2a323d;
--primary: #3b82f6;
--danger: #ef4444;
--ok: #22c55e;
--warn: #f59e0b;
--shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.mono { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; }
.muted { color: var(--muted); }
.small { font-size: 13px; }
h1 { font-size: 22px; margin: 0; }
h2 { font-size: 17px; margin: 0 0 12px; }
.app { min-height: 100vh; }
.centered { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
/* Buttons */
.btn {
appearance: none;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
padding: 9px 14px;
border-radius: 9px;
font-size: 14px;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease, transform 0.02s ease;
}
.btn:hover { border-color: var(--primary); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-text); }
.btn.danger { background: transparent; border-color: var(--danger); color: var(--danger); }
.btn.danger:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); }
.btn.ghost { background: transparent; }
.btn.small { padding: 6px 10px; font-size: 13px; }
.icon-btn { background: none; border: none; color: var(--muted); font-size: 18px; cursor: pointer; line-height: 1; }
/* Login */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 28px;
}
.login { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 12px; }
.field-label { font-size: 13px; color: var(--muted); }
.login input {
width: 100%;
padding: 11px 12px;
border-radius: 9px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
font-size: 15px;
}
.login input:focus { outline: 2px solid var(--primary); outline-offset: 1px; }
.error { color: var(--danger); font-size: 13px; min-height: 18px; margin: 0; }
/* Dashboard */
.dashboard { max-width: 960px; margin: 0 auto; padding: 24px 20px 48px; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
.topbar .actions { display: flex; gap: 8px; flex-wrap: wrap; }
.dashboard .card { padding: 20px; }
.table-scroll { overflow-x: auto; }
table.hosts { width: 100%; border-collapse: collapse; font-size: 14px; }
table.hosts th, table.hosts td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; }
table.hosts th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
table.hosts tr:last-child td { border-bottom: none; }
.empty { color: var(--muted); padding: 24px 4px; text-align: center; }
/* Status pills */
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; border: 1px solid var(--border); }
.pill-online, .pill-active { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, transparent); background: color-mix(in srgb, var(--ok) 12%, transparent); }
.pill-offline { color: var(--muted); }
.pill-revoked, .pill-suspended { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); background: color-mix(in srgb, var(--danger) 12%, transparent); }
.pill-unknown { color: var(--warn); }
/* Modal */
.overlay { position: fixed; inset: 0; background: rgba(6, 9, 14, 0.55); display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 50; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); width: 100%; max-width: 420px; }
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 14px; align-items: center; text-align: center; }
.code-big { font-size: 26px; font-weight: 700; letter-spacing: 0.08em; padding: 8px 12px; background: var(--surface-2); border-radius: 9px; }
.qr { width: 200px; height: 200px; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 9px; }
.command-row { display: flex; gap: 8px; align-items: center; width: 100%; }
.command-row.end { justify-content: flex-end; }
.command { flex: 1; text-align: left; background: var(--surface-2); padding: 9px 11px; border-radius: 8px; font-size: 13px; overflow-x: auto; white-space: nowrap; }
/* Toasts */
.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); padding: 10px 16px; border-radius: 9px; box-shadow: var(--shadow); z-index: 60; font-size: 14px; }
.toast-info { background: var(--surface); border: 1px solid var(--border); color: var(--text); }
.toast-error { background: var(--danger); color: #fff; }
@media (max-width: 560px) {
.topbar { flex-direction: column; align-items: stretch; }
.topbar .actions { justify-content: stretch; }
.topbar .actions .btn { flex: 1; }
}

View File

@@ -0,0 +1,161 @@
/**
* View builders. Every dynamic value (host subdomain/status/dates, pairing code, command) is placed
* with `textContent` via the `el` helper — NEVER innerHTML — so host data (treated as untrusted for
* XSS even though it comes from an authenticated CP) can't inject markup. The QR is an <img> whose
* src is a data: URL our own backend generated with the `qrcode` lib.
*/
import { el, clear, type Child } from './dom.js'
import type { HostView, PairingArtifacts } from './api.js'
export interface DashboardHandlers {
onNewCode: () => void
onRevoke: (host: HostView) => void
onLogout: () => void
onRefresh: () => void
}
/** Login card with a password field; `onSubmit(password)` fires on submit. */
export function renderLogin(onSubmit: (password: string) => void): HTMLElement {
const input = el('input', { type: 'password', name: 'password', placeholder: 'Panel password', autocomplete: 'current-password' })
const error = el('p', { class: 'error', role: 'alert' })
const form = el(
'form',
{
class: 'card login',
onSubmit: (e) => {
e.preventDefault()
error.textContent = ''
onSubmit(input.value)
},
},
[
el('h1', { text: 'Tunnel Control Panel' }),
el('label', { text: 'Password', class: 'field-label' }),
input,
el('button', { type: 'submit', class: 'btn primary', text: 'Sign in' }),
error,
],
)
const wrap = el('div', { class: 'centered' }, [form])
// Expose the error node so the app can show login failures.
;(wrap as HTMLElement & { errorNode?: HTMLElement }).errorNode = error
queueMicrotask(() => input.focus())
return wrap
}
function statusPill(status: string): HTMLElement {
const known = ['online', 'offline', 'revoked', 'suspended', 'active'].includes(status)
return el('span', { class: `pill pill-${known ? status : 'unknown'}`, text: status })
}
function fmtDate(value: string | undefined | null): string {
if (value === undefined || value === null || value === '') return '—'
const t = Date.parse(value)
return Number.isNaN(t) ? value : new Date(t).toLocaleString()
}
function hostRow(host: HostView, handlers: DashboardHandlers): HTMLElement {
const revoke = el('button', {
class: 'btn danger small',
text: 'Revoke',
title: `Revoke ${host.subdomain}`,
onClick: () => handlers.onRevoke(host),
})
return el('tr', {}, [
el('td', { text: host.subdomain, class: 'mono' }),
el('td', {}, [statusPill(host.status)]),
el('td', { text: fmtDate(host.notAfter), class: 'muted' }),
el('td', { text: fmtDate(host.lastSeen), class: 'muted' }),
el('td', {}, [revoke]),
])
}
function hostsTable(hosts: readonly HostView[], handlers: DashboardHandlers): HTMLElement {
if (hosts.length === 0) {
return el('div', { class: 'empty', text: 'No hosts enrolled yet. Create a pairing code to add one.' })
}
const head = el('thead', {}, [
el('tr', {}, [
el('th', { text: 'Subdomain' }),
el('th', { text: 'Status' }),
el('th', { text: 'Cert expiry' }),
el('th', { text: 'Last seen' }),
el('th', { text: '' }),
]),
])
const body = el('tbody', {}, hosts.map((h) => hostRow(h, handlers)))
return el('div', { class: 'table-scroll' }, [el('table', { class: 'hosts' }, [head, body])])
}
/** Full dashboard: header (refresh / new-code / logout) + hosts table. */
export function renderDashboard(hosts: readonly HostView[], handlers: DashboardHandlers): HTMLElement {
const header = el('header', { class: 'topbar' }, [
el('h1', { text: 'Tunnel Control Panel' }),
el('div', { class: 'actions' }, [
el('button', { class: 'btn', text: 'Refresh', onClick: () => handlers.onRefresh() }),
el('button', { class: 'btn primary', text: 'New pairing code', onClick: () => handlers.onNewCode() }),
el('button', { class: 'btn ghost', text: 'Log out', onClick: () => handlers.onLogout() }),
]),
])
return el('div', { class: 'dashboard' }, [
header,
el('section', { class: 'card' }, [el('h2', { text: 'Hosts' }), hostsTable(hosts, handlers)]),
])
}
/** Generic modal overlay; returns { overlay, close }. Clicking the backdrop or ✕ closes it. */
function modal(title: string, children: Child[]): { overlay: HTMLElement; close: () => void } {
const close = (): void => overlay.remove()
const dialog = el('div', { class: 'modal', role: 'dialog' }, [
el('div', { class: 'modal-head' }, [el('h2', { text: title }), el('button', { class: 'icon-btn', text: '✕', ariaLabel: 'Close', onClick: close })]),
el('div', { class: 'modal-body' }, children),
])
const overlay = el('div', { class: 'overlay', onClick: (e) => { if (e.target === overlay) close() } }, [dialog])
return { overlay, close }
}
/** Pairing-code modal: the code, its QR, the copyable command, and the expiry. */
export function pairingModal(artifacts: PairingArtifacts): HTMLElement {
const command = el('code', { class: 'command', text: artifacts.pairCommand })
const copyBtn = el('button', {
class: 'btn small',
text: 'Copy command',
onClick: () => {
void navigator.clipboard?.writeText(artifacts.pairCommand).then(
() => { copyBtn.textContent = 'Copied!' },
() => { copyBtn.textContent = 'Copy failed' },
)
},
})
const { overlay } = modal('Pairing code', [
el('p', { class: 'muted', text: 'Run this on the new host, or scan the QR from the phone client. Single-use.' }),
el('div', { class: 'code-big mono', text: artifacts.code }),
el('img', { class: 'qr', src: artifacts.qrDataUrl, alt: 'Pairing code QR' }),
el('div', { class: 'command-row' }, [command, copyBtn]),
el('p', { class: 'muted small', text: `Expires: ${fmtDate(artifacts.expiresAt)}` }),
])
return overlay
}
/** Confirm dialog → resolves true (confirmed) / false (cancelled). */
export function confirmDialog(message: string): Promise<boolean> {
return new Promise((resolve) => {
const { overlay, close } = modal('Please confirm', [
el('p', { text: message }),
el('div', { class: 'command-row end' }, [
el('button', { class: 'btn ghost', text: 'Cancel', onClick: () => { close(); resolve(false) } }),
el('button', { class: 'btn danger', text: 'Revoke', onClick: () => { close(); resolve(true) } }),
]),
])
document.body.append(overlay)
})
}
/** A transient toast for errors/success (textContent only). */
export function toast(root: HTMLElement, message: string, kind: 'error' | 'info' = 'info'): void {
const t = el('div', { class: `toast toast-${kind}`, text: message, role: 'status' })
root.append(t)
setTimeout(() => t.remove(), 4000)
}
export { clear }

85
control-panel/src/app.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* Fastify app assembly. All collaborators are injected (config, CP client, token minter, static
* root, clock) so the whole surface is unit-testable via `app.inject()` with fakes and NO network.
* Route order matters: auth + api plugins register BEFORE the static wildcard so specific routes win.
*/
import Fastify, { type FastifyInstance } from 'fastify'
import type { PanelConfig } from './config.js'
import type { CpClient } from './cp-client.js'
import type { ManageTokenMinter } from './manage-token.js'
import type { RateLimiter } from './security/rate-limit.js'
import { buildAuthRoutes } from './routes/auth-routes.js'
import { buildApiRoutes } from './routes/api-routes.js'
import { buildStaticRoutes } from './static.js'
/**
* Baseline security response headers applied to EVERY response (via an onRequest hook, so they are
* present on 404/401/error paths and on streamed static files too). The CSP is tuned for this
* self-contained SPA: it loads /build/app.js (script) and /build/styles.css (stylesheet) same-origin
* and renders the pairing QR as a `data:` image — hence `img-src 'self' data:`. `style-src` allows
* inline styles defensively; everything else is locked to 'self', framing is forbidden, and
* object-src/base-uri are neutralised.
*/
const SECURITY_HEADERS: Readonly<Record<string, string>> = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy':
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
}
export interface AppDeps {
readonly config: PanelConfig
readonly cpClient: CpClient
readonly minter: ManageTokenMinter
/** Clock (ms) — injectable for tests. */
readonly now?: () => number
/** Per-IP login limiter — injectable for tests. */
readonly rateLimiter?: RateLimiter
/** Absolute path to the built SPA (public/build). `null` ⇒ skip static serving (tests). */
readonly staticRoot?: string | null
/** Server-side error log (no secrets). Defaults to a no-op. */
readonly logError?: (message: string) => void
}
export async function buildApp(deps: AppDeps): Promise<FastifyInstance> {
// Fastify's own logger is disabled: we control what is logged so no secret/token/password leaks.
//
// trustProxy: the panel sits behind nginx on loopback. The nginx vhost MUST set
// `X-Forwarded-For $remote_addr` (a clean, non-client-controllable value — NOT
// `$proxy_add_x_forwarded_for`, which would append attacker-supplied hops). Trusting the proxy makes
// `req.ip` reflect the REAL client address; without it every request looks like 127.0.0.1 and the
// /login rate limiter collapses into a single global bucket → a trivial unauthenticated lockout DoS.
const app = Fastify({ logger: false, bodyLimit: 64 * 1024, trustProxy: true })
// Defense-in-depth: stamp security headers on every response before routing (so 404/401/error
// responses and streamed static files all carry them). Registered first, applies to all child plugins.
app.addHook('onRequest', async (_req, reply) => {
for (const [name, value] of Object.entries(SECURITY_HEADERS)) reply.header(name, value)
})
// Conditional spreads keep `exactOptionalPropertyTypes` happy (never pass an explicit `undefined`).
const nowPart = deps.now !== undefined ? { now: deps.now } : {}
await app.register(
buildAuthRoutes({
config: deps.config,
...nowPart,
...(deps.rateLimiter !== undefined ? { rateLimiter: deps.rateLimiter } : {}),
}),
)
await app.register(
buildApiRoutes({
config: deps.config,
cpClient: deps.cpClient,
minter: deps.minter,
...nowPart,
...(deps.logError !== undefined ? { logError: deps.logError } : {}),
}),
)
if (deps.staticRoot != null) {
await app.register(buildStaticRoutes(deps.staticRoot))
}
return app
}

View File

@@ -0,0 +1,97 @@
/**
* Boot configuration — zod-validated at the process boundary, FAIL-CLOSED.
*
* Every field is read from the environment ONCE at startup and returned as an immutable
* `PanelConfig`. Structural problems (missing SESSION_SECRET, a non-numeric port, an empty
* OPERATOR_ACCOUNT_ID) throw here so the server never boots half-configured.
*
* `PANEL_PASSWORD` is intentionally OPTIONAL at this layer: an unset password is a valid (if
* useless) deployment state that the /login route turns into a fail-closed 503 — the panel must
* still start so an operator can see the error, rather than crash-looping. Every other secret is
* required. Secrets are NEVER logged; this module only ever returns them inside the config object.
*/
import { z } from 'zod'
/** Default control-plane admin API base (loopback — the CP admin surface must never be public). */
export const DEFAULT_CP_URL = 'http://127.0.0.1:8080'
/** Default PKCS#8 Ed25519 capability signing key (matches relay-run/mint-manage-token.ts). */
export const DEFAULT_CAPABILITY_SIGN_KEY_PATH = '/etc/relay/capability/capability-sign.key.pem'
/** Default tunnel zone used to render the `pair` command shown to the operator. */
export const DEFAULT_TUNNEL_ZONE = 'terminal.yaojia.wang'
/** Default loopback bind port for the panel HTTP server. */
export const DEFAULT_PANEL_BIND_PORT = 8090
/** SESSION_SECRET must have enough entropy to make cookie forgery infeasible. */
export const MIN_SESSION_SECRET_LEN = 16
/**
* True iff `hostname` is a loopback literal: `localhost`, `::1`, or anything in 127.0.0.0/8.
* The panel's CP admin client must ONLY ever reach the local control-plane — enforcing this at the
* config boundary is anti-SSRF (a misconfigured CP_URL pointing at a remote/internal host is refused).
*/
export function isLoopbackHost(hostname: string): boolean {
const h = hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase() // strip IPv6 brackets
if (h === 'localhost' || h === '::1') return true
const m = /^(\d{1,3})\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(h)
return m !== null && Number(m[1]) === 127
}
/** True iff `url` parses AND its host is a loopback literal. Malformed URLs fail closed (false). */
function cpUrlHostIsLoopback(url: string): boolean {
try {
return isLoopbackHost(new URL(url).hostname)
} catch {
return false
}
}
const EnvSchema = z.object({
// Optional: unset ⇒ /login returns 503 (fail-closed at the route, not at boot).
PANEL_PASSWORD: z.string().min(1).optional(),
// Required: HMAC key that signs the session cookie. No default — a predictable secret is a bug.
SESSION_SECRET: z.string().min(MIN_SESSION_SECRET_LEN, `SESSION_SECRET must be >= ${MIN_SESSION_SECRET_LEN} chars`),
// Must be loopback (127.0.0.0/8, ::1, localhost): the CP admin surface is local-only. Fail closed otherwise.
CP_URL: z
.string()
.url()
.refine(cpUrlHostIsLoopback, 'CP_URL host must be loopback (127.0.0.0/8, ::1, or localhost)')
.default(DEFAULT_CP_URL),
// `aud` of every minted manage token — MUST equal the control-plane's expectedAud (its BASE_DOMAIN).
BASE_DOMAIN: z.string().min(1),
// `sub`/accountId scoped into every manage token and admin path.
OPERATOR_ACCOUNT_ID: z.string().min(1),
CAPABILITY_SIGN_KEY_PATH: z.string().min(1).default(DEFAULT_CAPABILITY_SIGN_KEY_PATH),
TUNNEL_ZONE: z.string().min(1).default(DEFAULT_TUNNEL_ZONE),
PANEL_BIND_PORT: z.coerce.number().int().min(1).max(65535).default(DEFAULT_PANEL_BIND_PORT),
})
export interface PanelConfig {
readonly panelPassword: string | undefined
readonly sessionSecret: string
readonly cpUrl: string
readonly baseDomain: string
readonly operatorAccountId: string
readonly capabilitySignKeyPath: string
readonly tunnelZone: string
readonly panelBindPort: number
}
/** Loopback-only bind host — the admin panel must never listen on a public interface. */
export const PANEL_BIND_HOST = '127.0.0.1'
/**
* Parse + validate the environment into an immutable config. Throws a `ZodError` on any structural
* problem (fail-closed). Never logs the values it reads.
*/
export function loadConfig(env: NodeJS.ProcessEnv): PanelConfig {
const parsed = EnvSchema.parse(env)
return {
panelPassword: parsed.PANEL_PASSWORD,
sessionSecret: parsed.SESSION_SECRET,
cpUrl: parsed.CP_URL.replace(/\/+$/, ''), // normalise: strip trailing slashes for clean joins
baseDomain: parsed.BASE_DOMAIN,
operatorAccountId: parsed.OPERATOR_ACCOUNT_ID,
capabilitySignKeyPath: parsed.CAPABILITY_SIGN_KEY_PATH,
tunnelZone: parsed.TUNNEL_ZONE,
panelBindPort: parsed.PANEL_BIND_PORT,
}
}

View File

@@ -0,0 +1,127 @@
/**
* Control-plane ADMIN API client. Each method takes a freshly-minted `manage` bearer token and calls
* the loopback CP admin surface. Responses are Zod-validated at the boundary (the CP is trusted for
* auth but its payloads are still external data → validate, never `as`). Non-2xx ⇒ `CpClientError`.
*
* CP contract (control-plane/src/api/provision.ts, mounted at root, `Authorization: Bearer <token>`):
* - GET /accounts/:id/hosts → 200 [HostRecord...] (agentPubkey base64)
* - POST /accounts/:id/pairing-codes → 201 { code, expiresAt }
* - DELETE /hosts/:hostId → 204
*
* We expose only a curated host view to the SPA (never leak agentPubkey/enrollFpr).
*/
import { z } from 'zod'
export interface HostView {
readonly hostId: string
readonly subdomain: string
readonly status: string
readonly lastSeen: string | undefined
readonly createdAt: string | undefined
readonly revokedAt: string | null | undefined
/** Cert expiry, if the CP ever includes one (not in today's HostRecord) — surfaced when present. */
readonly notAfter: string | undefined
}
export interface IssuedPairing {
readonly code: string
readonly expiresAt: string
}
export interface CpClient {
listHosts(accountId: string, token: string): Promise<readonly HostView[]>
createPairingCode(accountId: string, token: string): Promise<IssuedPairing>
deleteHost(hostId: string, token: string): Promise<void>
}
/** Upstream failure — carries an HTTP-ish status for the route layer to map (kept generic; no CP body leaked). */
export class CpClientError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.name = 'CpClientError'
this.status = status
}
}
// Lenient: require the fields we render; passthrough-tolerate everything else the CP adds/removes.
const HostRecordSchema = z
.object({
hostId: z.string(),
subdomain: z.string(),
status: z.string(),
lastSeen: z.string().optional(),
createdAt: z.string().optional(),
revokedAt: z.string().nullable().optional(),
notAfter: z.string().optional(),
})
.passthrough()
const HostListSchema = z.array(HostRecordSchema)
const IssuedPairingSchema = z.object({ code: z.string().min(1), expiresAt: z.string().min(1) })
function toHostView(rec: z.infer<typeof HostRecordSchema>): HostView {
return {
hostId: rec.hostId,
subdomain: rec.subdomain,
status: rec.status,
lastSeen: rec.lastSeen,
createdAt: rec.createdAt,
revokedAt: rec.revokedAt,
notAfter: rec.notAfter,
}
}
type FetchFn = typeof fetch
interface CpClientDeps {
readonly cpUrl: string
readonly fetchFn?: FetchFn
}
async function readJson(res: Response): Promise<unknown> {
try {
return await res.json()
} catch {
throw new CpClientError(502, 'control-plane returned a non-JSON response')
}
}
export function createCpClient(deps: CpClientDeps): CpClient {
const doFetch: FetchFn = deps.fetchFn ?? fetch
const authHeaders = (token: string): Record<string, string> => ({ authorization: `Bearer ${token}`, accept: 'application/json' })
const call = async (path: string, init: RequestInit): Promise<Response> => {
try {
return await doFetch(`${deps.cpUrl}${path}`, init)
} catch {
// Network/DNS/connection failure — the CP is unreachable.
throw new CpClientError(502, 'control-plane unreachable')
}
}
return {
async listHosts(accountId, token) {
const res = await call(`/accounts/${encodeURIComponent(accountId)}/hosts`, { headers: authHeaders(token) })
if (!res.ok) throw new CpClientError(res.status, `list hosts failed (${res.status})`)
const parsed = HostListSchema.parse(await readJson(res))
return parsed.map(toHostView)
},
async createPairingCode(accountId, token) {
const res = await call(`/accounts/${encodeURIComponent(accountId)}/pairing-codes`, {
method: 'POST',
headers: { ...authHeaders(token), 'content-type': 'application/json' },
body: '{}',
})
if (!res.ok) throw new CpClientError(res.status, `issue pairing code failed (${res.status})`)
const parsed = IssuedPairingSchema.parse(await readJson(res))
return { code: parsed.code, expiresAt: parsed.expiresAt }
},
async deleteHost(hostId, token) {
const res = await call(`/hosts/${encodeURIComponent(hostId)}`, { method: 'DELETE', headers: authHeaders(token) })
if (!res.ok && res.status !== 204) throw new CpClientError(res.status, `revoke host failed (${res.status})`)
},
}
}

View File

@@ -0,0 +1,106 @@
/**
* In-process `manage` capability-token minter — the in-code equivalent of
* relay-run/scripts/mint-manage-token.ts. Every proxied admin call mints a FRESH short-TTL token so
* a leaked token's blast radius is ~60s.
*
* The token is a §4.3 PASETO v4.public capability token signed by the P5 PRIVATE Ed25519 key
* (PKCS#8 PEM at CAPABILITY_SIGN_KEY_PATH): `aud = BASE_DOMAIN`, `sub = OPERATOR_ACCOUNT_ID`,
* `rights = ['manage']`, `ttl = 60s`. `issueCapabilityToken` mandates a well-formed DPoP `cnf.jkt`,
* so we stamp one from a throwaway ephemeral key (the CP admin API verifies signature+aud+rights but
* does NOT require a live DPoP proof — see the mint script's header note).
*
* SECURITY: the signing-key material and the minted token are NEVER logged.
*/
import { readFile } from 'node:fs/promises'
import { issueCapabilityToken } from 'relay-auth'
import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js'
import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js'
/** Manage tokens are `manage`-scoped, single-account, 60s TTL. `host` is a placeholder (issue() forbids '*'/''). */
const MANAGE_TOKEN_TTL_SEC = 60
const MANAGE_HOST_PLACEHOLDER = '_manage_'
/** Raised when the signing key cannot be loaded/imported. Message is safe (no key material). */
export class ManageTokenError extends Error {
constructor(message: string) {
super(message)
this.name = 'ManageTokenError'
}
}
export interface ManageTokenMinter {
/** Mint a fresh manage token for the configured operator account. */
mint(): Promise<string>
}
export interface ManageTokenConfig {
readonly capabilitySignKeyPath: string
readonly baseDomain: string
readonly operatorAccountId: string
}
/** Import a PKCS#8 PEM Ed25519 private key into a non-extractable signing CryptoKey. */
async function importSigningKeyFromPem(pemPath: string): Promise<CryptoKey> {
let pem: string
try {
pem = await readFile(pemPath, 'utf8')
} catch {
throw new ManageTokenError(`capability signing key not readable at ${pemPath}`)
}
const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '')
if (b64.length === 0) throw new ManageTokenError('capability signing key PEM is empty')
let der: Uint8Array<ArrayBuffer>
try {
// Copy into a fresh ArrayBuffer-backed view: WebCrypto's BufferSource requires Uint8Array<ArrayBuffer>,
// which Buffer (ArrayBufferLike) does not satisfy under strict lib types.
const raw = Buffer.from(b64, 'base64')
der = new Uint8Array(new ArrayBuffer(raw.byteLength))
der.set(raw)
} catch {
throw new ManageTokenError('capability signing key PEM is not valid base64')
}
try {
return await globalThis.crypto.subtle.importKey('pkcs8', der, { name: 'Ed25519' }, false, ['sign'])
} catch {
throw new ManageTokenError('capability signing key is not a valid PKCS#8 Ed25519 key')
}
}
/**
* Build a minter that lazily loads + memoizes the signing key (so the panel boots even if the key
* file is briefly unavailable — a missing key surfaces as an upstream error on the first proxy call,
* not a boot crash). Clock is injectable for tests.
*/
export function createManageTokenMinter(config: ManageTokenConfig, now: () => number = () => Date.now()): ManageTokenMinter {
let keyPromise: Promise<CryptoKey> | undefined
const signingKey = (): Promise<CryptoKey> => {
if (keyPromise === undefined) {
keyPromise = importSigningKeyFromPem(config.capabilitySignKeyPath).catch((err: unknown) => {
keyPromise = undefined // allow a later retry after the operator fixes the key
throw err
})
}
return keyPromise
}
return {
async mint(): Promise<string> {
const key = await signingKey()
const eph = await generateEd25519KeyPair()
const cnfJkt = await jwkThumbprint(await exportEd25519PublicRaw(eph.publicKey))
return issueCapabilityToken(
{
principal: { accountId: config.operatorAccountId } as never, // runtime reads only accountId
aud: config.baseDomain,
host: MANAGE_HOST_PLACEHOLDER,
rights: ['manage'],
ttlSeconds: MANAGE_TOKEN_TTL_SEC,
cnfJkt,
},
key,
Math.floor(now() / 1000),
)
},
}
}

View File

@@ -0,0 +1,37 @@
/**
* Operator-facing pairing artifacts derived from a freshly-issued pairing code:
* - the ready-to-run `web-terminal-agent pair <code> --install --zone <zone>` command line, and
* - a QR PNG data: URL of the code (rendered with the `qrcode` dep) for phone scanning.
* Pure/deterministic given the code + zone (except the QR, which is a stable render of the code).
*/
import QRCode from 'qrcode'
/** Build the exact command an operator runs on the new host to enroll it into the tunnel. */
export function buildPairCommand(code: string, zone: string): string {
return `web-terminal-agent pair ${code} --install --zone ${zone}`
}
/** Render a QR PNG data: URL encoding the pairing code (for scanning on the phone client). */
export async function buildQrDataUrl(code: string): Promise<string> {
return QRCode.toDataURL(code, { errorCorrectionLevel: 'M', margin: 1, width: 256 })
}
export interface PairingArtifacts {
readonly code: string
readonly expiresAt: string
readonly pairCommand: string
readonly qrDataUrl: string
}
/** Combine an issued code with its operator-facing command + QR into the /api/pairing-codes payload. */
export async function buildPairingArtifacts(
issued: { readonly code: string; readonly expiresAt: string },
zone: string,
): Promise<PairingArtifacts> {
return {
code: issued.code,
expiresAt: issued.expiresAt,
pairCommand: buildPairCommand(issued.code, zone),
qrDataUrl: await buildQrDataUrl(issued.code),
}
}

View File

@@ -0,0 +1,94 @@
/**
* Session-gated proxy routes. A `preHandler` rejects any request without a valid session cookie
* (401) — nothing downstream runs unauthenticated. For EACH call we mint a FRESH `manage` token and
* hand it to the CP admin client:
* - GET /api/hosts → CP GET /accounts/{op}/hosts → curated host list
* - POST /api/pairing-codes → CP POST /accounts/{op}/pairing-codes → { code, expiresAt, pairCommand, qrDataUrl }
* - DELETE /api/hosts/:hostId → CP DELETE /hosts/:hostId → 204
*
* Upstream/mint failures map to a generic 502 (never leak CP internals or token/key material).
*/
import { z } from 'zod'
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import type { PanelConfig } from '../config.js'
import type { CpClient } from '../cp-client.js'
import { CpClientError } from '../cp-client.js'
import type { ManageTokenMinter } from '../manage-token.js'
import { buildPairingArtifacts } from '../pairing.js'
import { requestIsAuthed } from '../security/request.js'
// hostId is a server-issued UUIDv4 — accept only a conservative id charset (defence-in-depth).
// A bare `.` or `..` passes the charset but would reshape the outbound CP admin URL
// (`${cpUrl}/hosts/${hostId}`) via dot-segment normalization, so reject those two exact values.
export const HostIdSchema = z
.string()
.min(1)
.max(128)
.regex(/^[A-Za-z0-9._-]+$/)
.refine((v) => v !== '.' && v !== '..', { message: 'host id must not be a dot-segment' })
export interface ApiRoutesDeps {
readonly config: PanelConfig
readonly cpClient: CpClient
readonly minter: ManageTokenMinter
readonly now?: () => number
/** Server-side logger for upstream failures (no secrets). Defaults to a no-op. */
readonly logError?: (message: string) => void
}
function mapUpstreamError(reply: FastifyReply, err: unknown, log: (m: string) => void): FastifyReply {
if (err instanceof CpClientError) {
log(`upstream control-plane error: ${err.message}`)
return reply.code(502).send({ error: 'upstream_error' })
}
log(`proxy error: ${err instanceof Error ? err.name : 'unknown'}`)
return reply.code(502).send({ error: 'upstream_error' })
}
export function buildApiRoutes(deps: ApiRoutesDeps): FastifyPluginAsync {
const now = deps.now ?? (() => Date.now())
const log = deps.logError ?? (() => {})
const accountId = deps.config.operatorAccountId
return async (app) => {
// Deny-by-default session gate for every route in this plugin.
app.addHook('preHandler', async (req: FastifyRequest, reply: FastifyReply) => {
if (!requestIsAuthed(req, deps.config.sessionSecret, now())) {
await reply.code(401).send({ error: 'unauthenticated' })
}
})
app.get('/api/hosts', async (_req, reply) => {
try {
const token = await deps.minter.mint()
const hosts = await deps.cpClient.listHosts(accountId, token)
return reply.code(200).send({ hosts })
} catch (err) {
return mapUpstreamError(reply, err, log)
}
})
app.post('/api/pairing-codes', async (_req, reply) => {
try {
const token = await deps.minter.mint()
const issued = await deps.cpClient.createPairingCode(accountId, token)
const artifacts = await buildPairingArtifacts(issued, deps.config.tunnelZone)
return reply.code(201).send(artifacts)
} catch (err) {
return mapUpstreamError(reply, err, log)
}
})
app.delete('/api/hosts/:hostId', async (req, reply) => {
const parsed = HostIdSchema.safeParse((req.params as { hostId?: unknown }).hostId)
if (!parsed.success) return reply.code(400).send({ error: 'invalid host id' })
try {
const token = await deps.minter.mint()
await deps.cpClient.deleteHost(parsed.data, token)
return reply.code(204).send()
} catch (err) {
return mapUpstreamError(reply, err, log)
}
})
}
}

View File

@@ -0,0 +1,72 @@
/**
* Auth routes: POST /login, POST /logout, GET /api/session.
*
* SECURITY:
* - /login is RATE-LIMITED per client IP BEFORE any credential work (brute-force throttle → 429).
* - FAIL-CLOSED: unset PANEL_PASSWORD ⇒ 503 (never authenticates); wrong password ⇒ 401.
* - credential compare is CONSTANT-TIME (compare.ts SHA-256 fixed-length).
* - on success, set a signed HttpOnly; SameSite=Strict; Secure-when-https session cookie (12h).
* - the password and session token are NEVER logged; input is Zod-validated at the boundary.
*/
import { z } from 'zod'
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import type { PanelConfig } from '../config.js'
import { constantTimeEqual } from '../security/compare.js'
import { buildClearCookie, buildSetCookie } from '../security/cookies.js'
import { createSessionToken, SESSION_TTL_SEC } from '../security/session.js'
import { createSlidingWindowLimiter, DEFAULT_LOGIN_RATE_MAX, DEFAULT_LOGIN_RATE_WINDOW_MS, type RateLimiter } from '../security/rate-limit.js'
import { isSecureRequest, requestIsAuthed } from '../security/request.js'
const LoginBodySchema = z.object({ password: z.string().min(1).max(512) }).strict()
export interface AuthRoutesDeps {
readonly config: PanelConfig
readonly now?: () => number
readonly rateLimiter?: RateLimiter
readonly clientKey?: (req: FastifyRequest) => string
}
function setCookie(reply: FastifyReply, value: string): void {
reply.header('set-cookie', value)
}
export function buildAuthRoutes(deps: AuthRoutesDeps): FastifyPluginAsync {
const now = deps.now ?? (() => Date.now())
const clientKey = deps.clientKey ?? ((req: FastifyRequest) => req.ip || 'unknown')
const limiter = deps.rateLimiter ?? createSlidingWindowLimiter(DEFAULT_LOGIN_RATE_MAX, DEFAULT_LOGIN_RATE_WINDOW_MS, now)
return async (app) => {
app.post('/login', async (req, reply) => {
// Throttle FIRST so brute force is limited regardless of outcome.
if (!limiter.allow(clientKey(req))) {
return reply.code(429).send({ error: 'rate_limited' })
}
const parsed = LoginBodySchema.safeParse(req.body)
if (!parsed.success) return reply.code(400).send({ error: 'invalid request' })
// Fail-closed: an unconfigured panel authenticates no one.
const configured = deps.config.panelPassword
if (typeof configured !== 'string' || configured.length === 0) {
return reply.code(503).send({ error: 'login unavailable' })
}
if (!constantTimeEqual(parsed.data.password, configured)) {
return reply.code(401).send({ error: 'rejected' })
}
const token = createSessionToken(deps.config.sessionSecret, now())
setCookie(reply, buildSetCookie({ value: token, maxAgeSec: SESSION_TTL_SEC, secure: isSecureRequest(req) }))
return reply.code(200).send({ authenticated: true })
})
app.post('/logout', async (req, reply) => {
setCookie(reply, buildClearCookie({ secure: isSecureRequest(req) }))
return reply.code(200).send({ authenticated: false })
})
app.get('/api/session', async (req, reply) => {
return reply.code(200).send({ authenticated: requestIsAuthed(req, deps.config.sessionSecret, now()) })
})
}
}

View File

@@ -0,0 +1,23 @@
/**
* Constant-time string comparison (SECURITY-CRITICAL — never use `===` for secrets).
*
* Mirrors src/http/auth.ts in the base app: both inputs are hashed with SHA-256 to a FIXED 32
* bytes, then compared with `crypto.timingSafeEqual`. Hashing-to-fixed-length removes the length
* side-channel and sidesteps `timingSafeEqual`'s throw-on-length-mismatch. A missing/empty
* candidate short-circuits to `false` before the comparator (it is not a secret-compare oracle).
*/
import { createHash, timingSafeEqual } from 'node:crypto'
export function constantTimeEqual(a: string | undefined, b: string | undefined): boolean {
if (typeof a !== 'string' || typeof b !== 'string') return false
if (a.length === 0 || b.length === 0) return false
const ha = createHash('sha256').update(a, 'utf8').digest()
const hb = createHash('sha256').update(b, 'utf8').digest()
return timingSafeEqual(ha, hb) // both are exactly 32 bytes → never throws
}
/** Constant-time byte comparison of two equal-length buffers (false on length mismatch). */
export function constantTimeEqualBytes(a: Buffer, b: Buffer): boolean {
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}

View File

@@ -0,0 +1,53 @@
/**
* Cookie parse + Set-Cookie serialization — dependency-light (no @fastify/cookie), mirroring the
* base app's src/http/auth.ts discipline. Values are returned verbatim (NOT URL-decoded); the
* session token uses a cookie-safe charset (base64url + '.') so there is nothing to decode.
*/
/** The panel session cookie name. HttpOnly (JS can't read it → XSS can't exfiltrate it). */
export const SESSION_COOKIE_NAME = 'panel_session'
/**
* Parse a raw `Cookie:` header into a name→value map. Malformed pairs (no `=`, empty name) are
* ignored; last write wins on duplicate names.
*/
export function parseCookieHeader(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {}
if (header === undefined || header === '') return out
for (const part of header.split(';')) {
const eq = part.indexOf('=')
if (eq <= 0) continue
const name = part.slice(0, eq).trim()
if (name === '') continue
out[name] = part.slice(eq + 1).trim()
}
return out
}
interface SetCookieOptions {
readonly value: string
readonly maxAgeSec: number
readonly secure: boolean
}
/**
* Build a `Set-Cookie` value. Flags: HttpOnly (no JS read), SameSite=Strict (cross-site pages can't
* ride the cookie — CSRF/CSWSH defence), Path=/, Max-Age, and Secure ONLY when `secure` (a Secure
* cookie is never sent over http:// — forcing it would break a loopback/http operator session).
*/
export function buildSetCookie(opts: SetCookieOptions): string {
const parts = [
`${SESSION_COOKIE_NAME}=${opts.value}`,
'Path=/',
`Max-Age=${opts.maxAgeSec}`,
'HttpOnly',
'SameSite=Strict',
]
if (opts.secure) parts.push('Secure')
return parts.join('; ')
}
/** Build a `Set-Cookie` that immediately expires the session cookie (logout). */
export function buildClearCookie(opts: { secure: boolean }): string {
return buildSetCookie({ value: '', maxAgeSec: 0, secure: opts.secure })
}

View File

@@ -0,0 +1,32 @@
/**
* In-process sliding-window rate limiter keyed by an arbitrary client bucket (per-IP for /login).
* Mirrors the base app's control-plane auth-login limiter: a rejected attempt is NOT recorded, so a
* throttled client can't push its own window forward. Pure/injectable clock for tests.
*/
export interface RateLimiter {
/** Returns true and records the hit if under the limit; false (no record) when the window is full. */
allow(key: string): boolean
}
/** Default per-IP login attempts within the window. */
export const DEFAULT_LOGIN_RATE_MAX = 10
/** Default login rate window (ms): 15 minutes. */
export const DEFAULT_LOGIN_RATE_WINDOW_MS = 15 * 60 * 1000
export function createSlidingWindowLimiter(max: number, windowMs: number, now: () => number): RateLimiter {
const hits = new Map<string, number[]>()
return {
allow(key: string): boolean {
const ts = now()
const cutoff = ts - windowMs
const kept = (hits.get(key) ?? []).filter((t) => t > cutoff)
if (kept.length >= max) {
hits.set(key, kept) // persist the pruned window; do NOT record this rejected attempt
return false
}
kept.push(ts)
hits.set(key, kept)
return true
},
}
}

View File

@@ -0,0 +1,26 @@
/**
* Request-scoped security helpers shared by the route plugins: detect HTTPS (drives the Secure
* cookie flag) and read/verify the session cookie off an incoming Fastify request.
*/
import type { FastifyRequest } from 'fastify'
import { parseCookieHeader, SESSION_COOKIE_NAME } from './cookies.js'
import { verifySessionToken } from './session.js'
/**
* True iff the request arrived over HTTPS — directly (`socket.encrypted`) or via a TLS-terminating
* edge that set `x-forwarded-proto: https`. Even though the panel binds loopback, a reverse proxy
* may front it; honour XFP so the Secure flag is correct on the tunnel path.
*/
export function isSecureRequest(req: FastifyRequest): boolean {
const xfp = req.headers['x-forwarded-proto']
const proto = Array.isArray(xfp) ? xfp[0] : xfp
if (typeof proto === 'string' && proto.split(',')[0]?.trim().toLowerCase() === 'https') return true
const socket = req.raw.socket as { encrypted?: boolean } | undefined
return socket?.encrypted === true
}
/** True iff the request carries a valid, unexpired session cookie signed with `sessionSecret`. */
export function requestIsAuthed(req: FastifyRequest, sessionSecret: string, nowMs: number): boolean {
const cookies = parseCookieHeader(req.headers.cookie)
return verifySessionToken(sessionSecret, cookies[SESSION_COOKIE_NAME], nowMs)
}

View File

@@ -0,0 +1,54 @@
/**
* Signed session token — an HMAC-SHA256 MAC over a short-TTL expiry claim. The cookie value is
* `<expEpochSec>.<macBase64url>`; the MAC covers the expiry so a client cannot extend its own
* session. Verification is constant-time and rejects tampering, wrong-secret, and past-expiry
* tokens. Self-contained (node:crypto only) — no external signing dependency.
*/
import { createHmac } from 'node:crypto'
import { constantTimeEqualBytes } from './compare.js'
/** Session lifetime (seconds): 12h — long enough to avoid constant re-auth, short enough to bound replay. */
export const SESSION_TTL_SEC = 12 * 60 * 60
function macFor(secret: string, payload: string): Buffer {
return createHmac('sha256', secret).update(payload, 'utf8').digest()
}
function b64url(buf: Buffer): string {
return buf.toString('base64url')
}
/**
* Mint a session token that expires `ttlSec` after `nowMs`. The expiry is embedded and signed.
*/
export function createSessionToken(secret: string, nowMs: number, ttlSec: number = SESSION_TTL_SEC): string {
const expSec = Math.floor(nowMs / 1000) + ttlSec
const payload = String(expSec)
return `${payload}.${b64url(macFor(secret, payload))}`
}
/**
* True iff `token` is a well-formed, correctly-signed, unexpired session token.
* Any structural problem, MAC mismatch, or past-expiry ⇒ false (deny-by-default).
*/
export function verifySessionToken(secret: string, token: string | undefined, nowMs: number): boolean {
if (typeof token !== 'string' || token.length === 0) return false
const dot = token.indexOf('.')
if (dot <= 0 || dot === token.length - 1) return false
const payload = token.slice(0, dot)
const presentedMacB64 = token.slice(dot + 1)
// Expiry must be a positive integer string; reject anything else before touching crypto.
if (!/^\d+$/.test(payload)) return false
let presentedMac: Buffer
try {
presentedMac = Buffer.from(presentedMacB64, 'base64url')
} catch {
return false
}
const expectedMac = macFor(secret, payload)
if (!constantTimeEqualBytes(presentedMac, expectedMac)) return false
const expSec = Number(payload)
return Number.isSafeInteger(expSec) && expSec * 1000 > nowMs
}

View File

@@ -0,0 +1,45 @@
/**
* Process entrypoint. Loads + validates config (fail-closed), wires the REAL collaborators (CP
* client over CP_URL, lazy manage-token minter reading the PKCS#8 capability key), and binds the
* app to LOOPBACK ONLY (127.0.0.1) — the admin panel must never listen on a public interface.
*
* Logging here is deliberately minimal and secret-free: only the bind address/port and startup
* errors (never the password, session secret, key material, or any minted token) are printed.
*/
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { loadConfig, PANEL_BIND_HOST } from './config.js'
import { createCpClient } from './cp-client.js'
import { createManageTokenMinter } from './manage-token.js'
import { buildApp } from './app.js'
const HERE = dirname(fileURLToPath(import.meta.url))
/** Built SPA lives at control-panel/public/build (sibling of src/). */
const STATIC_ROOT = resolve(HERE, '..', 'public', 'build')
async function main(): Promise<void> {
const config = loadConfig(process.env)
const cpClient = createCpClient({ cpUrl: config.cpUrl })
const minter = createManageTokenMinter({
capabilitySignKeyPath: config.capabilitySignKeyPath,
baseDomain: config.baseDomain,
operatorAccountId: config.operatorAccountId,
})
const app = await buildApp({
config,
cpClient,
minter,
staticRoot: STATIC_ROOT,
// Structured, secret-free server log for upstream failures.
logError: (message: string) => process.stderr.write(`[control-panel] ${message}\n`),
})
await app.listen({ host: PANEL_BIND_HOST, port: config.panelBindPort })
process.stdout.write(`[control-panel] listening on http://${PANEL_BIND_HOST}:${config.panelBindPort}\n`)
}
main().catch((err: unknown) => {
process.stderr.write(`[control-panel] fatal: ${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
})

View File

@@ -0,0 +1,63 @@
/**
* Minimal self-contained static SPA server (no @fastify/static dependency). Serves the esbuild
* output from `root` (control-panel/public/build) with a small content-type map, path-traversal
* guard, and an index.html fallback for extension-less GETs (SPA routing). Registered LAST so the
* specific /login and /api/* routes always win in Fastify's router.
*/
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { resolve, sep, extname } from 'node:path'
import type { FastifyPluginAsync, FastifyReply } from 'fastify'
const CONTENT_TYPES: Readonly<Record<string, string>> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.webmanifest': 'application/manifest+json',
}
async function isFile(path: string): Promise<boolean> {
try {
return (await stat(path)).isFile()
} catch {
return false
}
}
async function sendFile(reply: FastifyReply, filePath: string): Promise<void> {
const type = CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream'
await reply.type(type).send(createReadStream(filePath))
}
export function buildStaticRoutes(root: string): FastifyPluginAsync {
const rootResolved = resolve(root)
const indexHtml = resolve(rootResolved, 'index.html')
return async (app) => {
app.get('/*', async (req, reply) => {
// Only GET/HEAD reach here (Fastify method routing); resolve the URL path safely.
const urlPath = decodeURIComponent(req.url.split('?')[0] ?? '/')
const candidate = resolve(rootResolved, '.' + urlPath)
// Path-traversal guard: the resolved target must stay within root.
if (candidate !== rootResolved && !candidate.startsWith(rootResolved + sep)) {
return reply.code(403).send({ error: 'forbidden' })
}
if (urlPath !== '/' && (await isFile(candidate))) {
return sendFile(reply, candidate)
}
// SPA fallback: serve index.html for '/' and any unknown extension-less route.
if (await isFile(indexHtml)) {
return sendFile(reply, indexHtml)
}
return reply.code(404).send({ error: 'not found' })
})
}
}

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest'
import { buildApp } from '../src/app.js'
import { CpClientError } from '../src/cp-client.js'
import { HostIdSchema } from '../src/routes/api-routes.js'
import { makeConfig, fakeCpClient, fakeMinter, authCookieHeader } from './helpers.js'
const AUTH = { cookie: authCookieHeader() }
describe('session gating', () => {
it('rejects every proxy route without a valid session cookie (401)', async () => {
const app = await buildApp({ config: makeConfig(), cpClient: fakeCpClient(), minter: fakeMinter(), staticRoot: null })
for (const r of [
{ method: 'GET' as const, url: '/api/hosts' },
{ method: 'POST' as const, url: '/api/pairing-codes' },
{ method: 'DELETE' as const, url: '/api/hosts/abc' },
]) {
const res = await app.inject(r)
expect(res.statusCode).toBe(401)
}
await app.close()
})
})
describe('GET /api/hosts', () => {
it('mints a token and returns the CP host list for the operator account', async () => {
const cpClient = fakeCpClient({
hosts: [{ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: 'x', createdAt: 'y', revokedAt: null, notAfter: undefined }],
})
const minter = fakeMinter('MINTED')
const app = await buildApp({ config: makeConfig({ operatorAccountId: 'acct-XYZ' }), cpClient, minter, staticRoot: null })
const res = await app.inject({ method: 'GET', url: '/api/hosts', headers: AUTH })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ hosts: [{ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: 'x', createdAt: 'y', revokedAt: null, notAfter: undefined }] })
expect(minter.calls()).toBe(1)
expect(cpClient.calls[0]).toMatchObject({ method: 'listHosts', accountIdOrHostId: 'acct-XYZ', token: 'MINTED' })
await app.close()
})
it('maps an upstream CP failure to 502', async () => {
const cpClient = fakeCpClient({ throwErr: new CpClientError(403, 'denied') })
const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter(), staticRoot: null })
const res = await app.inject({ method: 'GET', url: '/api/hosts', headers: AUTH })
expect(res.statusCode).toBe(502)
expect(res.json()).toEqual({ error: 'upstream_error' })
await app.close()
})
})
describe('POST /api/pairing-codes', () => {
it('returns the code, pair command, and a QR data URL', async () => {
const cpClient = fakeCpClient({ pairing: { code: 'ABCD-EFGH', expiresAt: '2026-03-01T00:00:00.000Z' } })
const app = await buildApp({ config: makeConfig({ tunnelZone: 'z.test' }), cpClient, minter: fakeMinter(), staticRoot: null })
const res = await app.inject({ method: 'POST', url: '/api/pairing-codes', headers: AUTH })
expect(res.statusCode).toBe(201)
const body = res.json()
expect(body.code).toBe('ABCD-EFGH')
expect(body.expiresAt).toBe('2026-03-01T00:00:00.000Z')
expect(body.pairCommand).toBe('web-terminal-agent pair ABCD-EFGH --install --zone z.test')
expect(String(body.qrDataUrl).startsWith('data:image/png;base64,')).toBe(true)
await app.close()
})
})
describe('DELETE /api/hosts/:hostId', () => {
it('revokes the host and returns 204', async () => {
const cpClient = fakeCpClient()
const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter('T'), staticRoot: null })
const res = await app.inject({ method: 'DELETE', url: '/api/hosts/host-42', headers: AUTH })
expect(res.statusCode).toBe(204)
expect(cpClient.calls[0]).toMatchObject({ method: 'deleteHost', accountIdOrHostId: 'host-42', token: 'T' })
await app.close()
})
it('rejects an invalid host id with 400', async () => {
const cpClient = fakeCpClient()
const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter(), staticRoot: null })
const res = await app.inject({ method: 'DELETE', url: '/api/hosts/' + encodeURIComponent('bad id!'), headers: AUTH })
expect(res.statusCode).toBe(400)
expect(cpClient.calls).toHaveLength(0)
await app.close()
})
})
describe('HostIdSchema dot-segment rejection', () => {
// The HTTP router already normalizes literal `.`/`..` path segments, but the outbound CP admin URL is
// built as `${cpUrl}/hosts/${hostId}` — so the schema itself must refuse dot-segments (defense-in-depth).
it('rejects a bare "." and ".."', () => {
expect(HostIdSchema.safeParse('.').success).toBe(false)
expect(HostIdSchema.safeParse('..').success).toBe(false)
})
it('accepts a UUID host id, a hyphenated id, and "..." (not a dot-segment)', () => {
expect(HostIdSchema.safeParse('550e8400-e29b-41d4-a716-446655440000').success).toBe(true)
expect(HostIdSchema.safeParse('host-42').success).toBe(true)
expect(HostIdSchema.safeParse('...').success).toBe(true)
})
})

View File

@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest'
import { buildApp } from '../src/app.js'
import { SESSION_COOKIE_NAME } from '../src/security/cookies.js'
import { createSlidingWindowLimiter, type RateLimiter } from '../src/security/rate-limit.js'
import { makeConfig, fakeCpClient, fakeMinter, authCookieHeader, TEST_SESSION_SECRET } from './helpers.js'
async function makeApp(overrides: Parameters<typeof buildApp>[0] extends infer T ? Partial<T> : never = {}) {
return buildApp({
config: makeConfig(),
cpClient: fakeCpClient(),
minter: fakeMinter(),
staticRoot: null,
...overrides,
})
}
describe('POST /login', () => {
it('returns 503 when PANEL_PASSWORD is unset (fail-closed)', async () => {
const app = await makeApp({ config: makeConfig({ panelPassword: undefined }) })
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'anything' } })
expect(res.statusCode).toBe(503)
await app.close()
})
it('returns 401 on the wrong password (no cookie set)', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'wrong' } })
expect(res.statusCode).toBe(401)
expect(res.headers['set-cookie']).toBeUndefined()
await app.close()
})
it('returns 200 and sets an HttpOnly SameSite=Strict session cookie on success', async () => {
const app = await makeApp({ config: makeConfig({ panelPassword: 'secret-pw' }) })
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'secret-pw' } })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ authenticated: true })
const cookie = String(res.headers['set-cookie'])
expect(cookie).toContain(`${SESSION_COOKIE_NAME}=`)
expect(cookie).toContain('HttpOnly')
expect(cookie).toContain('SameSite=Strict')
await app.close()
})
it('returns 429 when rate-limited (before credential work)', async () => {
const denyAll: RateLimiter = { allow: () => false }
const app = await makeApp({ rateLimiter: denyAll })
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'whatever' } })
expect(res.statusCode).toBe(429)
await app.close()
})
it('returns 400 on a malformed body', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'POST', url: '/login', payload: { notpassword: 1 } })
expect(res.statusCode).toBe(400)
await app.close()
})
// trustProxy: true (app.ts) makes req.ip read X-Forwarded-For, so the limiter buckets by REAL client
// IP. Without it every request would share the single 127.0.0.1 bucket (a global lockout DoS).
it('rate-limits per forwarded client IP: same X-Forwarded-For shares a budget, a different one is independent', async () => {
const limiter = createSlidingWindowLimiter(1, 60_000, () => 0) // one attempt per window per key
const app = await makeApp({ rateLimiter: limiter })
const login = (xff: string) =>
app.inject({ method: 'POST', url: '/login', headers: { 'x-forwarded-for': xff }, payload: { password: 'wrong' } })
// IP A, 1st attempt: budget available → reaches credential check → 401 (not throttled).
expect((await login('203.0.113.10')).statusCode).toBe(401)
// IP A, 2nd attempt: same bucket exhausted → 429.
expect((await login('203.0.113.10')).statusCode).toBe(429)
// IP B: its own bucket → 401, proving req.ip reflects the forwarded address (else it would be 429).
expect((await login('198.51.100.20')).statusCode).toBe(401)
await app.close()
})
})
describe('POST /logout', () => {
it('clears the session cookie', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'POST', url: '/logout' })
expect(res.statusCode).toBe(200)
expect(String(res.headers['set-cookie'])).toContain('Max-Age=0')
await app.close()
})
})
describe('GET /api/session', () => {
it('reports authenticated:false without a cookie', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'GET', url: '/api/session' })
expect(res.json()).toEqual({ authenticated: false })
await app.close()
})
it('reports authenticated:true with a valid session cookie', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'GET', url: '/api/session', headers: { cookie: authCookieHeader(TEST_SESSION_SECRET) } })
expect(res.json()).toEqual({ authenticated: true })
await app.close()
})
})

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { constantTimeEqual, constantTimeEqualBytes } from '../src/security/compare.js'
describe('constantTimeEqual', () => {
it('returns true for identical strings', () => {
expect(constantTimeEqual('correct-horse', 'correct-horse')).toBe(true)
})
it('returns false for different strings', () => {
expect(constantTimeEqual('correct-horse', 'battery-staple')).toBe(false)
})
it('returns false for different-length strings (no length oracle)', () => {
expect(constantTimeEqual('abc', 'abcdef')).toBe(false)
})
it('returns false when either side is empty or undefined', () => {
expect(constantTimeEqual('', 'x')).toBe(false)
expect(constantTimeEqual('x', '')).toBe(false)
expect(constantTimeEqual(undefined, 'x')).toBe(false)
expect(constantTimeEqual('x', undefined)).toBe(false)
})
})
describe('constantTimeEqualBytes', () => {
it('true for equal buffers, false for differing or mismatched length', () => {
expect(constantTimeEqualBytes(Buffer.from('aa'), Buffer.from('aa'))).toBe(true)
expect(constantTimeEqualBytes(Buffer.from('aa'), Buffer.from('ab'))).toBe(false)
expect(constantTimeEqualBytes(Buffer.from('aa'), Buffer.from('aaa'))).toBe(false)
})
})

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
import {
loadConfig,
DEFAULT_CP_URL,
DEFAULT_TUNNEL_ZONE,
DEFAULT_PANEL_BIND_PORT,
DEFAULT_CAPABILITY_SIGN_KEY_PATH,
} from '../src/config.js'
const base = {
SESSION_SECRET: 'a-sufficiently-long-secret-value',
BASE_DOMAIN: 'terminal.yaojia.wang',
OPERATOR_ACCOUNT_ID: 'acct-1',
}
describe('loadConfig', () => {
it('applies defaults for optional fields', () => {
const cfg = loadConfig({ ...base } as NodeJS.ProcessEnv)
expect(cfg.cpUrl).toBe(DEFAULT_CP_URL)
expect(cfg.tunnelZone).toBe(DEFAULT_TUNNEL_ZONE)
expect(cfg.panelBindPort).toBe(DEFAULT_PANEL_BIND_PORT)
expect(cfg.capabilitySignKeyPath).toBe(DEFAULT_CAPABILITY_SIGN_KEY_PATH)
expect(cfg.panelPassword).toBeUndefined()
})
it('reads all provided values and strips trailing slash from CP_URL', () => {
const cfg = loadConfig({
...base,
PANEL_PASSWORD: 'pw',
CP_URL: 'http://127.0.0.1:9000/',
TUNNEL_ZONE: 'z.example',
PANEL_BIND_PORT: '9999',
} as NodeJS.ProcessEnv)
expect(cfg.panelPassword).toBe('pw')
expect(cfg.cpUrl).toBe('http://127.0.0.1:9000')
expect(cfg.tunnelZone).toBe('z.example')
expect(cfg.panelBindPort).toBe(9999)
})
it('throws when SESSION_SECRET is missing (fail-closed)', () => {
const { SESSION_SECRET: _omit, ...rest } = base
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
})
it('throws when SESSION_SECRET is too short', () => {
expect(() => loadConfig({ ...base, SESSION_SECRET: 'short' } as NodeJS.ProcessEnv)).toThrow()
})
it('throws when BASE_DOMAIN is missing', () => {
const { BASE_DOMAIN: _omit, ...rest } = base
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
})
it('throws when OPERATOR_ACCOUNT_ID is missing', () => {
const { OPERATOR_ACCOUNT_ID: _omit, ...rest } = base
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
})
it('throws when PANEL_BIND_PORT is out of range', () => {
expect(() => loadConfig({ ...base, PANEL_BIND_PORT: '70000' } as NodeJS.ProcessEnv)).toThrow()
})
it('accepts loopback CP_URL hosts (127.0.0.0/8, ::1, localhost)', () => {
for (const url of ['http://127.0.0.1:8080', 'http://127.9.9.9:1', 'http://[::1]:8080', 'http://localhost:8080']) {
expect(loadConfig({ ...base, CP_URL: url } as NodeJS.ProcessEnv).cpUrl).toBe(url)
}
})
it('throws when CP_URL host is not loopback (anti-SSRF, fail-closed)', () => {
for (const url of ['http://evil.example.com:8080', 'http://169.254.169.254/', 'http://10.0.0.5:8080', 'http://8.8.8.8']) {
expect(() => loadConfig({ ...base, CP_URL: url } as NodeJS.ProcessEnv)).toThrow()
}
})
})

View File

@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest'
import { createCpClient, CpClientError } from '../src/cp-client.js'
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
}
describe('createCpClient.listHosts', () => {
it('maps the CP host records to a curated view and strips agentPubkey/enrollFpr', async () => {
const captured: { url?: string; init?: RequestInit | undefined } = {}
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
captured.url = String(url)
captured.init = init
return jsonResponse([
{
hostId: 'h1',
accountId: 'acct-1',
subdomain: 'alpha',
agentPubkey: 'BASE64PUBKEY',
enrollFpr: 'fpr',
status: 'online',
lastSeen: '2026-01-02T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
revokedAt: null,
},
])
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
const hosts = await client.listHosts('acct-1', 'TOKEN123')
expect(captured.url).toBe('http://127.0.0.1:8080/accounts/acct-1/hosts')
expect((captured.init?.headers as Record<string, string>).authorization).toBe('Bearer TOKEN123')
expect(hosts).toHaveLength(1)
expect(hosts[0]).toMatchObject({ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: '2026-01-02T00:00:00.000Z' })
expect(hosts[0]).not.toHaveProperty('agentPubkey')
expect(hosts[0]).not.toHaveProperty('enrollFpr')
})
it('throws CpClientError carrying the upstream status on non-2xx', async () => {
const fetchFn = (async () => new Response('nope', { status: 403 })) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.listHosts('acct-1', 't')).rejects.toMatchObject({ name: 'CpClientError', status: 403 })
})
it('maps a network failure to a 502 CpClientError', async () => {
const fetchFn = (async () => {
throw new Error('ECONNREFUSED')
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.listHosts('acct-1', 't')).rejects.toMatchObject({ status: 502 })
})
})
describe('createCpClient.createPairingCode', () => {
it('POSTs and maps { code, expiresAt }', async () => {
const captured: { init?: RequestInit | undefined } = {}
const fetchFn = (async (_url: string | URL | Request, init?: RequestInit) => {
captured.init = init
return jsonResponse({ code: 'ABCD-EFGH', expiresAt: '2026-02-01T00:00:00.000Z' }, 201)
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
const issued = await client.createPairingCode('acct-1', 'TOK')
expect(captured.init?.method).toBe('POST')
expect(issued).toEqual({ code: 'ABCD-EFGH', expiresAt: '2026-02-01T00:00:00.000Z' })
})
it('rejects a malformed CP pairing response', async () => {
const fetchFn = (async () => jsonResponse({ nope: true }, 201)) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.createPairingCode('acct-1', 't')).rejects.toBeInstanceOf(Error)
})
})
describe('createCpClient.deleteHost', () => {
it('DELETEs and resolves on 204', async () => {
const captured: { url?: string; init?: RequestInit | undefined } = {}
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
captured.url = String(url)
captured.init = init
return new Response(null, { status: 204 })
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await client.deleteHost('host-42', 'TOK')
expect(captured.url).toBe('http://127.0.0.1:8080/hosts/host-42')
expect(captured.init?.method).toBe('DELETE')
})
it('throws CpClientError on a non-2xx delete', async () => {
const fetchFn = (async () => new Response('no', { status: 404 })) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.deleteHost('h', 't')).rejects.toMatchObject({ status: 404 })
})
})

Some files were not shown because too many files have changed in this diff Show More