From febdf9a615c36bc194b3141b05148d9c6bbc342d Mon Sep 17 00:00:00 2001 From: reviewer Date: Thu, 8 Oct 2026 11:04:08 +0800 Subject: [PATCH] feat(web): sign in from Tailscale Serve identity headers (phase 1) Adds ENGRAM_REMOTE_WEB_AUTH=tailscale-serve: the receiver mints the existing hardened Web session from the Tailscale-User-Login header that the local tailscale serve proxy asserts, matched against exact-login viewer/editor allowlists (ENGRAM_REMOTE_WEB_VIEWERS / ENGRAM_REMOTE_WEB_EDITORS). Credential mode stays the default and is byte-for-byte unchanged. Identity mode forbids shared Web credentials, requires a loopback bind, accepts only a `{}` login body with an exact Origin, and never reads the header in credential mode. The page ships the credential form hidden, signs in silently on a 401 probe, shows the login, and re-mints once after an expired read with a five-second floor. Design: docs/superpowers/specs/2026-10-08-web-tailscale-identity-auth-design.md Ledger: "Web Reader and Editor Authority" rewritten for both modes. Also records the 2026-10-08 HQ status check and the Serve header pre-check. --- CHANGELOG.md | 101 +++++ MEMO.md | 9 + docs/followups.md | 2 +- docs/invariants.md | 6 +- ...0-08-web-tailscale-identity-auth-design.md | 422 ++++++++++++++++++ .../Core/EngramRemoteServerApp.swift | 8 +- .../Core/EngramRemoteServerConfig.swift | 22 + .../Core/EngramRemoteWebConfig.swift | 99 +++- .../Core/WebAuthRoutes.swift | 39 +- .../Core/WebAuthSessionStore.swift | 40 +- .../Core/WebRequestBoundary.swift | 9 + .../EngramRemoteServer/Core/WebUIRoutes.swift | 58 ++- .../WebAuthRouteTests.swift | 89 ++++ .../WebAuthSessionTests.swift | 68 +++ .../WebConfigTests.swift | 86 +++- .../WebServerIntegrationTests.swift | 25 ++ .../WebUIRoutesTests.swift | 34 +- tests/scripts/collector-web-ui.test.ts | 110 ++++- 18 files changed, 1195 insertions(+), 32 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-08-web-tailscale-identity-auth-design.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa941ae..9b26de4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,107 @@ All notable changes to this project will be documented in this file. Format based on [Keep a Changelog](https://keepachangelog.com/). +## HQ T+10.5h check, old local Service CPU spin, Web identity-auth design (2026-10-08) + +Read-only status pass on HQ at 23:28 UTC (07:28 CST) plus one new design doc. +No host was changed and nothing was deployed. + +**HQ new stack.** service-index PID 88127 up 10 h 24 min, 12-14% CPU at a +10-second `top` sample, 32 MB, stderr 0 bytes, checkpoints ok; receiver PID +89758 up 10 h 23 min, `/v1/health` 200 on 127.0.0.1:18787 and on +`https://macmini-hq.tail1cb16.ts.net:8443` (`tailscale serve` proxy, tailnet +only), stderr still only the two index-warm notices. Central DB: arrivals +41,529 (41,517 at T+7h), ledger index_ready 8,567 / parsed 32,390 / +quarantined 579, sessions 45,412, newest ingest 23:30:24 UTC; `origin=local` +rows 6,191 (grok 5,959, pi 232) with the newest `indexed_at` still +2026-10-07 11:20 UTC, so the role gate holds. `session_index_jobs`: embedding +pending 12,151 (expected, no provider), fts 12,558 all completed. Disk 687 GB +free. Daily collector (SSH read-only): PID 30548 up 5 d 10 h, 0% CPU at the +sample, 25 MB, no log line since 2026-10-02. M1 replica `/v1/health` 200, +`tailscale ping` 4 ms although `tailscale status` labels it offline. + +**Old HQ local stack (retires at runbook R8).** The old local-role +`EngramService` PID 30392 (`~/.engram-service/current` = +`releases/cabeeb24-build1569`, started by the system boot daemon, up 32 days, +lifetime CPU average 80%) ran at 82-94% CPU on one thread across a 10-second +sample. A 3-second `sample` shows the hot thread inside an FTS5 cursor scan +(`fts5SeekCursor`, `sqlite3BtreeNext`) on the GRDB reader connection; the +caller is unresolved because the binary is stripped. Its own WAL checkpoint +has logged "Timed out waiting for the EngramService write lock" 4,525 times +(19 in the last 300 lines of `~/.engram/logs/engram-service.boot.out.log`). +It still indexes HQ claude-code/codex into the old 1.3 GB DB (81 sessions in +24 h, 59,876 total). The old hub `100.125.101.60:8787` answers 200 on plain +HTTP, kept alive by the Daily watchdog every 2 minutes +(`~/.engram/logs/hq-boot-ensure.log`). Recommendation recorded for the owner: +bring R8 (watchdog off, old service and hub booted out) into the next +authorized window rather than fixing the old build. + +**Untracked `.grok/workflows/engram-session-status.rhai` explained.** A Grok +Build workflow script dated 2026-09-27 (read-only session replay plus cutover +inventory); its 15 siblings in the same directory are tracked. Commit or +delete; not a product file. + +**Web login design.** The owner judged the shared-credential Web login too +crude and chose Tailscale identity. New design doc +`docs/superpowers/specs/2026-10-08-web-tailscale-identity-auth-design.md`: +`ENGRAM_REMOTE_WEB_AUTH=tailscale-serve` makes the receiver mint the existing +hardened session from the `Tailscale-User-Login` header that `tailscale +serve` asserts (Tailscale KB 1312, fetched 2026-10-08: added on proxied +requests, client copies stripped, absent on Funnel and tagged devices), +matched against `ENGRAM_REMOTE_WEB_VIEWERS` / `ENGRAM_REMOTE_WEB_EDITORS` +exact-login allowlists; credential keys are forbidden in that mode and the +bind host must be loopback; cookie, exact-Host, marker-header and Origin +rules are unchanged; the UI re-mints silently so the 900-second lifetime is +invisible. Credential mode stays the default and is byte-for-byte unchanged. +Phase 2 (separate PR) carries the login through the Web write envelope as +the audit actor instead of `mcp`. Not implemented yet. + +**Serve identity-header pre-check (owner-authorized, PASS).** A loopback +header-echo server on `127.0.0.1:18799` was published with `tailscale serve +--bg --https=8444`, requested from HQ and from the Daily Mac with spoofed +`Tailscale-User-Login: attacker@example` and `Tailscale-User-Name: Mallory`. +Both echoes carried exactly one `Tailscale-User-Login: zzbhlx@gmail.com`, +the Serve-asserted name and profile picture, `Tailscale-Headers-Info`, and +`X-Forwarded-For` with the requesting node's tailnet address (so Serve does +add forwarded headers despite the KB summary); the spoofed values were +removed. A direct loopback request bypassing Serve delivered the spoofed +header unchanged (design risk R1). Mapping removed with `tailscale serve +--https=8444 off`; `tailscale serve status` back to the two pre-existing +entries, 8443 Web origin 200 throughout. Recorded in the design doc's test +plan. + +**Phase 1 implemented (branch `feat/web-tailscale-identity-auth-20261008`, +uncommitted at the time of writing; not merged, not deployed).** +`EngramRemoteWebConfig` gains `AuthMode` (`credential` with the two digests, +`tailscaleServe` with exact-login viewer/editor sets), `ENGRAM_REMOTE_WEB_AUTH` +parsing, five new `ConfigError` cases and `isWellFormedLogin`; +`EngramRemoteServerConfig` rejects identity mode on a non-loopback +`ENGRAM_REMOTE_HOST`; `WebAuthSessionStore` adds `WebIdentity`, +`login(identity:)` (no attempt window, same capacity and collision guards), +`LoginResult.forbidden`, a per-session login and `actor(sessionToken:)`, and +each store answers `.unavailable` to the other mode's entry point; +`WebRequestBoundary.tailscaleIdentity(in:)` reads exactly one well-formed +`Tailscale-User-Login` and only in identity mode; `POST /web/api/auth` in +identity mode accepts only `{}` plus exact Origin and answers 401/403/204; +the identity-mode `GET /web/api/auth` body adds `login`; credential-mode +responses are unchanged. `WebUIRoutes.mount(tailscaleServeIdentity:)` serves +the same HTML with `` and the +credential form shipped `hidden`; the script signs in silently on a 401 +probe, shows "Signed in as ", hides the form and logout, re-mints +once after an expired read with a five-second floor, and shows the +not-permitted copy on 403. Ledger entry "Web Reader and Editor Authority" +rewritten for both modes; `cutover-web-editor-hardening-1` annotated. +Tests: 13 new Swift tests across `WebConfigTests`, `WebAuthSessionTests`, +`WebAuthRouteTests`, `WebUIRoutesTests`, `WebServerIntegrationTests` +(including the forged-header-in-credential-mode guard and the +viewer-session-cannot-write check); full `EngramRemoteServerCore` 519 +passed, 0 failed after the final edit; `tests/scripts/collector-web-ui.test.ts` +gained a harness `authMode` option and 4 behavioral tests, 180 passed; the +fake DOM has no `document.body`, which caught an unguarded read in the +first draft and was the reason the identity check is defensive. +`invariants-ledger.test.ts` 12 passed; `npm run lint` exit 0. Phase 2 (audit +actor through the Web write envelope) is not started. + ## P2 and P3 merged, dependency PRs landed, HQ T+7h check (2026-10-07) Closes the 2026-10-07 session. All eight PRs opened or revived today are diff --git a/MEMO.md b/MEMO.md index 8d0d61ff..31c831aa 100644 --- a/MEMO.md +++ b/MEMO.md @@ -2,6 +2,15 @@ ## Changelog Memo +### 2026-10-08 + +- [验证] HQ T+10.5h 只读检查:service-index 与 receiver 健康、stderr 为零、摄取继续(arrivals 41,529、index_ready 8,567)、`origin=local` 行自重部署后无新增;Daily 采集器运行 5 天 10 小时、瞬时 CPU 0%;M1 副本健康 200。详见 `CHANGELOG.md`。 +- [排查] HQ 旧 local Service(build1569,系统 boot 守护进程拉起,已跑 32 天)单线程 82–94% CPU,热点在 FTS5 游标扫描,自身 WAL checkpoint 已超时 4,525 次;旧 hub :8787 仍由 Daily 看门狗维持。建议把 runbook R8 提前到下一个授权窗口,而不是修旧构建。 +- [排查] 未跟踪的 `.grok/workflows/engram-session-status.rhai` 是 9 月 27 日的 Grok Build 工作流脚本,同目录 15 个同类文件已跟踪;提交或删除即可。 +- [设计] Web 登录改用 Tailscale 身份:新设计稿 `docs/superpowers/specs/2026-10-08-web-tailscale-identity-auth-design.md`(`ENGRAM_REMOTE_WEB_AUTH=tailscale-serve`,按精确登录名的 viewer/editor 允许表,口令模式保持默认且不变,第二阶段把登录名作为审计 actor)。 +- [变更] 第一阶段已在分支 `feat/web-tailscale-identity-auth-20261008` 实现(未提交、未合并、未部署):配置模式开关与允许表、身份模式只接受 `{}` 登录体、回环绑定强制、会话记录登录名、页面静默换会话并隐藏口令表单;账本条目改写。Swift `EngramRemoteServerCore` 519 过 0 失败,TS Web UI 回归 180 过,lint 通过。详见 `CHANGELOG.md`。 +- [验证] 授权后在 HQ 做了 serve 身份头回显检查:从 HQ 与 MacBook 经 8444 临时映射请求,均收到唯一的 `Tailscale-User-Login: zzbhlx@gmail.com`,伪造头被剥掉替换;绕过 serve 直连回环则伪造头原样到达(设计风险 R1)。映射已撤,serve 配置恢复原状,8443 全程 200。 + ### 2026-10-07 - [合并] 今日 8 个 PR 全部合并到 main(`e3df52ab`):#451 codeql-action、#438 @types/node 26.6.4、#453 文档、#452 vitest 4.1.11、#455 P2 跨机精确重复隔离、#454 P3 安装工具链(审查出的 6 项缺陷已先修复)、#440 biome 2.5.15、#439 openai 7.28.0。P2 尚未部署,随 R7 的 service-index 构建上线。详见 `CHANGELOG.md`。 diff --git a/docs/followups.md b/docs/followups.md index 15817b15..dee2a7fd 100644 --- a/docs/followups.md +++ b/docs/followups.md @@ -19,7 +19,7 @@ decisions are in `docs/roadmap.md`, engineering gaps in `docs/TODO.md`. | `collector-start-cost-1` | medium/perf | `macos/EngramCollectorCore/CollectorEventCoordinator.swift` | Every process start forces a full walk of every root, and a new process tries each stale event checkpoint once before falling back to a walk. A busy root can also repeat walks if its event queue overflows during recovery. Likely contributors to the missed CPU target; not measured after the 2026-10-02 fixes. | | `collector-epoch-change-scope-1` | low | `macos/EngramCollectorCore/CollectorRuntime.swift` | A changed FSEvents epoch for one root stops the whole runtime, including uploads for other roots. Decide whether it should suspend only that root, as an identity change does. | | `cutover-install-tooling-1` | medium | `scripts/plan-headless-install.mjs`; `macos/*/Packaging/` templates | A new Mac cannot be set up as a collector from the repo alone: there is no settings-block template, identity initialization step, credentials provisioning, or apply/upgrade/rollback mode, and no repo script creates the HQ index-role job. The dry-run plan also reports every role's job as disabled with RunAtLoad/KeepAlive off, while the remote-server plist template sets RunAtLoad and KeepAlive to true. | -| `cutover-web-editor-hardening-1` | medium/security, needs triage | `macos/EngramRemoteServer/Core/WebAuthSessionStore.swift`; `WebWriteRoutes.swift` | Web login throttling is one global five-attempts window with no per-client partition, so anyone who can reach the origin can delay the editor's login. Unverified risk: an editor can change AI/embedding base URLs while stored keys stay in place. Project moves from Web are audited with actor `mcp`. `docs/invariants.md` now records Web authority; these behaviors are not guarantees there. | +| `cutover-web-editor-hardening-1` | medium/security, needs triage | `macos/EngramRemoteServer/Core/WebAuthSessionStore.swift`; `WebWriteRoutes.swift` | Web login throttling is one global five-attempts window with no per-client partition, so anyone who can reach the origin can delay the editor's login. Unverified risk: an editor can change AI/embedding base URLs while stored keys stay in place. Project moves from Web are audited with actor `mcp`. `docs/invariants.md` now records Web authority; these behaviors are not guarantees there. Since 2026-10-08 (`docs/superpowers/specs/2026-10-08-web-tailscale-identity-auth-design.md`) a deployment can set `ENGRAM_REMOTE_WEB_AUTH=tailscale-serve`, where no credential or throttle exists and the session records the tailnet login; the audit actor stays `mcp` until that design's phase 2 lands. | | `cutover-role-surface-1` | low | `macos/EngramMCP/Core/MCPToolRegistry.swift` | In `collector`/`replica`/invalid roles MCP still advertises the full tool catalog while every call returns the unavailable message. | ## HQ live ingest residuals (2026-08-24) diff --git a/docs/invariants.md b/docs/invariants.md index b2431c1f..d4dd4576 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -165,9 +165,9 @@ Invariants are properties that must survive every change; each entry names where ## Web Reader and Editor Authority -- **Statement** - Web is off unless explicitly configured; when configured, RemoteServer mounts the authenticated read routes and the write routes together. The viewer credential must differ from every server bearer credential, and the optional editor credential must differ from the viewer and server credentials and is kept only as a digest. Every authenticated session can read, but only a session minted from the editor credential can write, and that authority ends at logout or expiry. Write routes pass the same exact-Host and `X-Engram-Web: 1` API validation as reads and additionally require an exact Origin; a viewer session or a missing Origin is rejected with 403 before the write surface is called. The read client sends only allowlisted typed commands and attaches no capability token. The write client rejects unlisted commands, attaches the local service capability token only to the Unix-socket request and never to HTTP JSON, and opens no IPC when the token is missing. Login throttling is one global five-attempt window with no per-client partition. This entry does not limit what an editor may change through the allowlisted writes, and it does not isolate a compromised RemoteServer process. -- **Enforced by** - `macos/EngramRemoteServer/Core/EngramRemoteServerApp.swift`, `macos/EngramRemoteServer/Core/EngramRemoteWebConfig.swift`, `macos/EngramRemoteServer/Core/WebAuthSessionStore.swift`, `macos/EngramRemoteServer/Core/WebRequestBoundary.swift`, `macos/EngramRemoteServer/Core/WebWriteRoutes.swift`, `macos/Shared/Service/EngramServiceWebWriteClient.swift`. -- **Verified by** - `macos/EngramRemoteServerCoreTests/WebServerIntegrationTests.swift` (testWebConfigurationDefaultsOffAndExplicitValuesRoundTrip, testDefaultOffReturns404WithZeroFactoryCallsAndZeroRealSocketAccepts), `macos/EngramRemoteServerCoreTests/WebConfigTests.swift` (testViewerCredentialMustDifferFromEveryProvidedBearer, testOptionalEditorCredentialIsDistinctAndStoredOnlyAsDigest), `macos/EngramRemoteServerCoreTests/WebAuthSessionTests.swift` (testEditorAuthorityIsBoundToItsOwnSessionAndRevokedOnLogoutOrExpiry, testViewerOnlyConfigurationNeverGrantsWriteAuthority, testGlobalFiveAttemptWindowCountsBothFailedAndSuccessfulLogins), `macos/EngramRemoteServerCoreTests/WebAliasHTTPTests.swift` (testViewerPostIs403AndDoesNotCallWriteSurface, testEditorAddPreservesPathShapedAliasText), `macos/EngramRemoteServerCoreTests/WebProjectMigrationHTTPTests.swift` (testViewerCsrfAndDisabledEditorNeverReachProjectWriter), `macos/EngramRemoteServerCoreTests/WebRelationshipHTTPTests.swift` (testViewerAndMissingOriginCannotReachAnyRelationshipWriter), `macos/EngramRemoteServerCoreTests/WebAuthRouteTests.swift` (testAuthorityMismatchDuplicateHostAndForwardedSpoofingFailClosed, testAPIMarkerMustBeSingleExactOneForEveryAPIRequest), `macos/EngramRemoteServerCoreTests/WebReadClientTests.swift` (testMessagesSendsOnlyHardcodedCommandTypedDataUUIDAndNoCapability, testAllowlistRejectsExhaustiveLiveHandlerInventoryBeforeAnyIPC), `macos/EngramRemoteServerCoreTests/WebWriteClientTests.swift` (testAllowlistRejectsUnknownCommandsWithoutIPC, testAddAliasAttachesLocalTokenAndNeverPutsItOnHTTPJSON, testMissingTokenDoesNotOpenIPC). +- **Statement** - Web is off unless explicitly configured; when configured, RemoteServer mounts the authenticated read routes and the write routes together. `ENGRAM_REMOTE_WEB_AUTH` selects where a session's authority comes from. In credential mode (the default) the viewer credential must differ from every server bearer credential, and the optional editor credential must differ from the viewer and server credentials and is kept only as a digest; login throttling is one global five-attempt window with no per-client partition. In `tailscale-serve` mode no shared Web credential may be configured, the receiver must bind a loopback address, and a session is minted only from the single `Tailscale-User-Login` header the local Tailscale Serve proxy asserts, matched exactly against the explicit viewer and editor allowlists; credential mode never reads that header. In both modes a session is minted only by a `POST /web/api/auth` that passes the exact-Origin check, every authenticated session can read, only a session minted with editor authority can write, and that authority ends at logout or expiry. Write routes pass the same exact-Host and `X-Engram-Web: 1` API validation as reads and additionally require an exact Origin; a viewer session or a missing Origin is rejected with 403 before the write surface is called. The read client sends only allowlisted typed commands and attaches no capability token. The write client rejects unlisted commands, attaches the local service capability token only to the Unix-socket request and never to HTTP JSON, and opens no IPC when the token is missing. This entry does not limit what an editor may change through the allowlisted writes, and it does not isolate a compromised RemoteServer process or any process that can open the receiver's loopback port. +- **Enforced by** - `macos/EngramRemoteServer/Core/EngramRemoteServerApp.swift`, `macos/EngramRemoteServer/Core/EngramRemoteServerConfig.swift`, `macos/EngramRemoteServer/Core/EngramRemoteWebConfig.swift`, `macos/EngramRemoteServer/Core/WebAuthSessionStore.swift`, `macos/EngramRemoteServer/Core/WebAuthRoutes.swift`, `macos/EngramRemoteServer/Core/WebRequestBoundary.swift`, `macos/EngramRemoteServer/Core/WebWriteRoutes.swift`, `macos/Shared/Service/EngramServiceWebWriteClient.swift`. +- **Verified by** - `macos/EngramRemoteServerCoreTests/WebServerIntegrationTests.swift` (testWebConfigurationDefaultsOffAndExplicitValuesRoundTrip, testDefaultOffReturns404WithZeroFactoryCallsAndZeroRealSocketAccepts, testEnvironmentIdentityModeRequiresLoopbackBindAndNoCredential), `macos/EngramRemoteServerCoreTests/WebConfigTests.swift` (testViewerCredentialMustDifferFromEveryProvidedBearer, testOptionalEditorCredentialIsDistinctAndStoredOnlyAsDigest, testTailscaleServeModeForbidsSharedCredentialsAndRequiresViewers, testIdentityAllowlistEntriesMustBeExactPrintableLoginsWithoutDuplicates), `macos/EngramRemoteServerCoreTests/WebAuthSessionTests.swift` (testEditorAuthorityIsBoundToItsOwnSessionAndRevokedOnLogoutOrExpiry, testViewerOnlyConfigurationNeverGrantsWriteAuthority, testGlobalFiveAttemptWindowCountsBothFailedAndSuccessfulLogins, testIdentityLoginMintsViewerOrEditorByExactLoginAndRecordsActor, testEachStoreAnswersOnlyItsOwnModeAndCredentialSessionsHaveNoActor), `macos/EngramRemoteServerCoreTests/WebAliasHTTPTests.swift` (testViewerPostIs403AndDoesNotCallWriteSurface, testEditorAddPreservesPathShapedAliasText), `macos/EngramRemoteServerCoreTests/WebProjectMigrationHTTPTests.swift` (testViewerCsrfAndDisabledEditorNeverReachProjectWriter), `macos/EngramRemoteServerCoreTests/WebRelationshipHTTPTests.swift` (testViewerAndMissingOriginCannotReachAnyRelationshipWriter), `macos/EngramRemoteServerCoreTests/WebAuthRouteTests.swift` (testAuthorityMismatchDuplicateHostAndForwardedSpoofingFailClosed, testAPIMarkerMustBeSingleExactOneForEveryAPIRequest, testIdentityLoginMintsFromTheServeHeaderWithEmptyBodyAndExactOrigin, testIdentityLoginRejectsCredentialBodiesMissingDuplicateAndUnlistedLogins, testCredentialModeIgnoresForgedServeHeadersAndKeepsItsStatusBody, testIdentityViewerSessionCannotReachWriteRoutesEvenWithAnEditorHeader), `macos/EngramRemoteServerCoreTests/WebReadClientTests.swift` (testMessagesSendsOnlyHardcodedCommandTypedDataUUIDAndNoCapability, testAllowlistRejectsExhaustiveLiveHandlerInventoryBeforeAnyIPC), `macos/EngramRemoteServerCoreTests/WebWriteClientTests.swift` (testAllowlistRejectsUnknownCommandsWithoutIPC, testAddAliasAttachesLocalTokenAndNeverPutsItOnHTTPJSON, testMissingTokenDoesNotOpenIPC). - **Gate** - `none`. ## Optional AI Maintenance Does Not Gate Required Index Readiness diff --git a/docs/superpowers/specs/2026-10-08-web-tailscale-identity-auth-design.md b/docs/superpowers/specs/2026-10-08-web-tailscale-identity-auth-design.md new file mode 100644 index 00000000..3ea2d292 --- /dev/null +++ b/docs/superpowers/specs/2026-10-08-web-tailscale-identity-auth-design.md @@ -0,0 +1,422 @@ +# Design Doc: Web login from Tailscale Serve identity headers + +- **Status**: In review (owner chose this option on 2026-10-08; phase 1 + implemented the same day on branch + `feat/web-tailscale-identity-auth-20261008`, not yet merged or deployed) +- **Owner**: RemoteServer / Web maintainers +- **Date**: 2026-10-08 +- **Related**: `docs/superpowers/specs/2026-09-05-collector-server-web-design.md` + (Web auth rows at lines 588-600, Funnel exclusion at line 209); + `docs/invariants.md` "Web Reader and Editor Authority" (line 166); + `docs/followups.md` `cutover-web-editor-hardening-1` (line 22); + `CHANGELOG.md` 2026-10-07 (HQ Web reader proof as viewer); + Tailscale KB "Tailscale Serve", identity headers section, + https://tailscale.com/kb/1312/serve (fetched 2026-10-08). + +All `path:line` anchors are at commit `466879ff` unless marked otherwise. + +## Problem + +The Web reader on HQ is reached only through a shared static credential. The +owner's verdict on 2026-10-08 was that the login is too crude. The concrete +defects, observed on HQ the same day: + +- One viewer credential and one optional editor credential are the whole + identity model. Nothing records who logged in or who changed an alias or + moved a project; the service audits Web project moves as actor `mcp` + (`macos/EngramService/Core/EngramServiceCommandHandler.swift:2735`, + default applied by `normalizedActor` at `:3585`). +- Sessions expire 900 seconds after login with no renewal + (`macos/EngramRemoteServer/Core/WebAuthSessionStore.swift:7`), so a reader + is sent back to the form every 15 minutes. +- Login throttling is one global five-attempt window per 60 seconds + (`WebAuthSessionStore.swift:52`); any tailnet member can lock the editor + out by failing five times. +- Both credentials sit in plaintext in the receiver LaunchAgent environment + on HQ (`~/Library/LaunchAgents/com.engram.capture-core.receiver.plist`, + keys `ENGRAM_REMOTE_WEB_VIEWER_CREDENTIAL` and + `ENGRAM_REMOTE_WEB_EDITOR_CREDENTIAL`). Rotation means editing the plist + and restarting the receiver. +- The credential digest is unsalted SHA-256 (`EngramRemoteWebConfig.swift: + 60-62`). That is fine for a random high-entropy string and weak for a + human-chosen one; nothing enforces the former. + +Why now: cutover decision D5 (`2026-10-02-hq-local-collector-cutover-design.md` +§8) keeps the App off HQ, so the Web reader is the only reader on the busiest +host and the login sits on the daily path. + +The deployment already has a stronger identity source that the receiver +ignores. On HQ the receiver binds `127.0.0.1:18787` and `tailscale serve` +publishes it as `https://macmini-hq.tail1cb16.ts.net:8443` (tailnet only, +Tailscale 1.102.5). The Tailscale KB states that Serve adds +`Tailscale-User-Login`, `Tailscale-User-Name` and +`Tailscale-User-Profile-Pic` to every request it proxies to the backend, +strips client-supplied headers of those names before adding its own, and +omits them for Funnel traffic and for traffic from tagged devices. The +tailnet currently has one user; `tailscale whois` prints the login as +`zzbhlx@gmail.com` for both HQ and the Daily Mac. + +## Goals / Non-goals + +Goals: + +1. A tailnet user opens the Web origin and is signed in; no password. +2. Read and write authority come from explicit allowlists of exact Tailscale + logins, not from which shared string was typed. +3. Every existing CSRF, Host, Origin, marker-header and cookie guarantee in + `docs/invariants.md` "Web Reader and Editor Authority" stays in force. +4. Explicit opt-in. A deployment that does not set the new mode behaves + byte-for-byte as today. +5. The Web write path can name the tailnet login as the audit actor + (phase 2, separate PR; see Rollout). + +Non-goals: + +- WebAuthn, passkeys, OIDC or any browser-side dependency. The Web design + forbids external CDN and runtime Node (`2026-09-05` design line 593). +- Tailscale Funnel exposure. Identity headers are absent there and public + exposure is already excluded (`2026-09-05` design line 209). +- Per-user data scoping. Every permitted login sees the same corpus. +- Changing archive v1/v2 or MCP bearer authentication. +- Sliding sessions or per-client throttling for credential mode. Those stay + parked in `docs/followups.md` for deployments that keep credential mode. + +## Current state + +Configuration. `EngramRemoteWebConfig.fromEnvironment` +(`macos/EngramRemoteServer/Core/EngramRemoteWebConfig.swift:68-85`) turns +the Web on with `ENGRAM_REMOTE_WEB_ENABLED=1`, requires an HTTPS +`ENGRAM_REMOTE_WEB_ORIGIN` and `ENGRAM_REMOTE_WEB_VIEWER_CREDENTIAL`, and +takes an optional `ENGRAM_REMOTE_WEB_EDITOR_CREDENTIAL`. Both credentials +must differ from every server bearer token; the app re-checks that at +startup (`EngramRemoteServerApp.swift:98-103`). The cookie name is +`__Host-engram_web` outside loopback tests (`EngramRemoteWebConfig.swift:64`). +The server config reads the bind host from `ENGRAM_REMOTE_HOST`, default +loopback (`EngramRemoteServerConfig.swift:169-170`). + +Sessions. `WebAuthSessionStore.login(credential:)` +(`WebAuthSessionStore.swift:43-77`) purges expired sessions, applies the +global attempt window, compares the SHA-256 digest of the submitted string +against the viewer and editor digests in constant time (`:59-60`), caps live +sessions at 64, mints a 32-byte random token and stores only its digest with +`expiresAt` and `canWrite`. Nothing is persisted. + +Routes. `POST /web/api/auth` accepts exactly `{"credential": ""}` +(`WebAuthRoutes.swift:24, 107-124`) and answers 204 with the hardened cookie +(`:133-137`), 401, 429 with `Retry-After`, or 503. `GET /web/api/auth` +returns `{"canWrite": bool}` for a live session, else 401 with no body. +`DELETE /web/api/auth` requires a `{}` body and revokes the session. + +Boundary. `WebRequestBoundary` requires the exact configured authority in +the request target and any `Host` header (`WebRequestBoundary.swift:10-14`), +a single `X-Engram-Web: 1`, and either an exact `Origin` or, for GET only, +same-origin fetch metadata (`:16-27`). The middleware (`:168` onward) applies +those checks per path class and requires a live session for every +`/web/api/*` route except the login POST. + +Write actor. Web write routes decode the browser body straight into the +service request type and forward it (`WebWriteRoutes.swift:214`). The Web +project request models carry no actor +(`macos/Shared/Service/EngramServiceWebProjectModels.swift`); the service +side models do (`EngramServiceProjectModels.swift:9, 80, 116`) and default +to `mcp`. + +UI. The page renders a single `Credential` password field +(`WebUIRoutes.swift:34-37`); `login()` posts it (`:2376-2391`); every 401 +calls `expireSession()` (`:928, 1311, 1387, 1467, 2100, 2127, 2270, 2352`), +which shows the signed-out view. + +Packaging. The remote wrapper sources `secrets/web.env` when present +(`macos/EngramRemoteServer/Packaging/run-engram-remote.zsh.template:11-12`); +the P3 planner treats `web-env-file` as optional +(`scripts/plan-headless-install.mjs:58, 448-449`). HQ does not use that file +today; its Web environment is in the LaunchAgent plist. + +## Proposed design + +### 1. A mode switch in the Web configuration + +New environment key `ENGRAM_REMOTE_WEB_AUTH` with two values: + +- `credential` (the default when the key is absent): today's behavior, + unchanged. +- `tailscale-serve`: identity comes from the `Tailscale-User-Login` header + that the local Serve proxy adds. + +In `tailscale-serve` mode the config requires: + +- `ENGRAM_REMOTE_WEB_VIEWERS`: comma-separated exact Tailscale logins allowed + to read. Required, at least one entry. +- `ENGRAM_REMOTE_WEB_EDITORS`: optional comma-separated exact logins allowed + to write. An editor can also read; a login in both lists is an editor. +- Neither `ENGRAM_REMOTE_WEB_VIEWER_CREDENTIAL` nor + `ENGRAM_REMOTE_WEB_EDITOR_CREDENTIAL` may be set. Presence is a config + error, so no shared secret can linger next to the identity mode. +- `ENGRAM_REMOTE_HOST` must be a loopback literal. The only legitimate path + for an identity header is the Serve proxy on the same host; a receiver + bound to a tailnet or LAN address would accept a header any peer can + write. Non-loopback is a config error in this mode. +- `ENGRAM_REMOTE_WEB_ORIGIN` keeps its HTTPS requirement unchanged. + +Allowlist parsing: split on `,`, trim nothing, reject empty entries, +whitespace, control characters, entries longer than 254 bytes, and +duplicates within a list. Matching is exact and case-sensitive against the +header value; the configured string must equal what `tailscale whois` +prints. + +`EngramRemoteWebConfig` gains an `authority` enum with two cases, +`credential(viewerDigest:editorDigest:)` and +`tailscaleServe(viewers:editors:)`. The existing public initializer keeps +its signature and produces the credential case so current tests compile; a +second initializer builds the identity case. The `credentialMustBeDistinct` +re-check in `EngramRemoteServerApp` runs only for the credential case. + +### 2. Minting a session from an identity + +Sessions, cookies and the middleware stay. The cookie is what proves that +the browser went through `POST /web/api/auth` with an exact `Origin`; an +identity header alone is also present on a cross-site request the user's +browser was tricked into sending, so it must not grant authority by itself. + +`WebAuthSessionStore` gains `login(identity: WebIdentity) -> LoginResult`. +`WebIdentity` holds the login string. The method: + +- purges expired sessions exactly as the credential path does; +- does not consult the attempt window (there is no secret to guess) but + still enforces the 64-session capacity and the token-collision guard; +- resolves `canWrite` from the editor list and rejects a login in neither + list with a new `LoginResult.forbidden`; +- stores the login in the session record so `actor(sessionToken:)` can + return it (used by phase 2 and by `GET /web/api/auth`). + +The credential path is untouched. In credential mode +`login(identity:)` is never called; in identity mode `login(credential:)` +is never called. The store receives the config and refuses the wrong entry +point with `.unavailable`, which a guard test pins. + +Session lifetime stays 900 seconds absolute. The UI re-mints silently (§5), +so the limit bounds exposure without interrupting the reader. + +### 3. Extracting the identity + +`WebRequestBoundary.tailscaleIdentity(in:)` returns a `WebIdentity` only +when the request carries exactly one `Tailscale-User-Login` header whose +value is non-empty, printable ASCII without whitespace, and at most 254 +bytes. Two headers, an empty value or a malformed value yield `nil`, which +the login route turns into 401. `Tailscale-User-Name` and +`Tailscale-User-Profile-Pic` are not read. + +The boundary consults the header only when the configuration is in +`tailscale-serve` mode. In credential mode the header is never read, so a +forged header on a credential deployment has no effect; a guard test pins +that too. + +### 4. Auth routes + +`POST /web/api/auth` in identity mode: + +- the same `validateAPI(requiresOrigin: true)` as today, so the mint is + CSRF-safe; +- the body must be exactly `{}` (the logout check reused); a credential + body is 400, so a stale page that still posts a credential cannot log in; +- no identity header: 401, empty body; +- identity not in any list: 403, empty body (the lists are never echoed); +- success: 204 with the same hardened cookie. + +`GET /web/api/auth`: + +- In identity mode the 200 body becomes `{"canWrite": bool, "login": + ""}`. Credential mode keeps `{"canWrite": bool}` unchanged. +- 401 responses are unchanged in both modes. The UI learns the mode from + the document instead: identity mode serves the same HTML with + `` and the credential form shipped + `hidden`, so no request is spent on mode discovery, the form never + flashes before the script runs, and every credential-mode response stays + byte-identical. + +`DELETE /web/api/auth` is unchanged. + +### 5. UI + +- On load the page probes the session list as today. A 401 in identity + mode (read from the body attribute) posts `{}` once; 204 re-runs the + normal signed-in restore and then shows `Signed in as ` from + `GET /web/api/auth`; 403 shows "Your tailnet login is not permitted for + this Web origin"; any other failure shows the signed-out view. In + credential mode a 401 shows today's form. +- `expireSession()` in identity mode re-mints once instead of showing the + form, with a five-second floor between attempts so a mint that is + immediately rejected cannot loop; if the re-mint fails it falls back to + the signed-out view. A reader never sees the 15-minute boundary. +- The credential form and the `Log out` button are hidden in identity + mode. There is nothing to log out of; closing the tailnet session is the + logout. + +### 6. Phase 2: actor propagation (separate PR) + +Add an optional `actor` to the Web write envelope for the routes whose +service request has one (alias add/remove, project move, archive, undo, +move-batch). The RemoteServer sets it from `sessions.actor(sessionToken:)` +and never from the browser body; in credential mode it sets `web-editor`. +The service maps it through to the existing `actor` field, so audit rows +name the tailnet login instead of `mcp`. This touches IPC DTOs on both +sides and the write-client allowlist, so it gets its own tests and PR. + +### 7. Deployment shape + +HQ receiver plist environment: add `ENGRAM_REMOTE_WEB_AUTH=tailscale-serve`, +`ENGRAM_REMOTE_WEB_VIEWERS=`, `ENGRAM_REMOTE_WEB_EDITORS=`; +delete both `*_CREDENTIAL` keys. With no secret left, the plist is an +acceptable home; `secrets/web.env` remains an option for the P3 wrapper. +Nothing changes on the Serve side. + +## Invariants affected + +- **Web Reader and Editor Authority** (`docs/invariants.md:166`). The + statement is extended, not replaced: in `tailscale-serve` mode the viewer + and editor authority of a session comes from the exact + `Tailscale-User-Login` the local Serve proxy asserts, matched against + explicit allowlists; the receiver must bind loopback; no shared Web + credential may be configured; the header is never read in credential mode; + session, cookie, exact-Host, marker-header and Origin rules are unchanged. + The throttling sentence becomes credential-mode only. The sentence about + not isolating a compromised RemoteServer process gains "or any process + that can open the receiver's loopback port". Enforced-by adds no new path + unless `WebIdentity` lands in its own file; Verified-by adds the tests in + the test plan. Gate stays `none`. The ledger edit ships in the same PR. +- No other entry is touched. Archive, MCP and capture authority are not on + this path. + +## Alternatives considered + +- Keep the credential and add sliding sessions plus per-client throttling. + Fixes the interruption, not the missing identity or the plaintext secret. +- Bind the receiver to the Tailscale address and resolve the peer with + `tailscale whois`, no Serve. The receiver speaks plain HTTP, so this loses + the HTTPS origin the Web design requires. +- Read `X-Forwarded-For` through Serve and call the Tailscale LocalAPI + `whois`. Serve does add that header (verified 2026-10-08, see the test + plan), but it adds a LocalAPI socket dependency, and a locally forged + forwarded address is no harder than a locally forged login header. +- WebAuthn or OIDC. Browser-side or external dependencies the Web design + forbids, for a single-user tailnet. +- Funnel with identity. Funnel carries no identity headers and public + exposure is excluded. + +## Test plan + +Config (`WebConfigTests`): mode absent equals `credential`; unknown mode is +an error; identity mode with any `*_CREDENTIAL` key is an error; identity +mode with a non-loopback `ENGRAM_REMOTE_HOST` is an error; empty, +whitespace, control-character, over-long and duplicate allowlist entries are +errors; a credential-mode environment round-trips unchanged. + +Session (`WebAuthSessionTests`): `login(identity:)` mints a viewer for a +viewer-list login and an editor for an editor-list login; matching is +case-sensitive (`Zzbhlx@…` is not `zzbhlx@…`); a login in neither list is +`forbidden`; identity logins ignore the attempt window and respect the +64-session cap; `actor(sessionToken:)` returns the login; the credential +store answers `.unavailable` to `login(identity:)` and the identity store +answers `.unavailable` to `login(credential:)`. + +Routes (`WebAuthRouteTests`): identity POST requires `{}` and an exact +`Origin`; a credential body in identity mode is 400; no header is 401; two +headers are 401; an unlisted login is 403 with an empty body; a forged +`Tailscale-User-Login` on a credential-mode responder is ignored and the +credential path still decides; GET 401 bodies carry only `mode`; GET 200 +bodies carry `login` only in identity mode and never a credential or token. + +Integration (`WebServerIntegrationTests`): default-off still returns 404 +with zero factory calls; an identity-mode real responder sets the same +hardened cookie attributes as credential mode; a viewer identity POST to a +write route is 403 before the write surface is called (the +`testViewerPostIs403AndDoesNotCallWriteSurface` pattern). + +UI (`WebUIRoutesTests`): the bundled HTML/JS contains the silent-mint path, +hides the form and logout in identity mode, and shows the 403 copy. +Behavior (`tests/scripts/collector-web-ui.test.ts`, which runs the shipped +script in a fake DOM): a 401 restore probe in identity mode posts `{}` and +re-runs the restore, then shows `Signed in as `; a 403 shows the +not-permitted copy with the form still hidden; an expired authenticated read +re-mints once and a second 401 inside the five-second floor does not; and +credential mode never posts an identity login. + +Not tested in CI: the real Serve header injection, which needs a tailnet. +It is covered once by the manual pre-check below, recorded in this doc +before implementation, and repeated after Tailscale upgrades on HQ. + +Pre-implementation check on HQ (owner-authorized host change, reversible): +run a loopback echo server on a spare port, publish it with +`tailscale serve --bg --https=8444 http://127.0.0.1:`, request +`https://macmini-hq.tail1cb16.ts.net:8444/` from another tailnet device and +confirm the echo shows `Tailscale-User-Login: zzbhlx@gmail.com`, then +`tailscale serve --https=8444 off`. Also send a client-supplied +`Tailscale-User-Login: attacker@example` on the same request and confirm the +echo shows only the Serve-asserted value. + +Result, 2026-10-08 (owner-authorized, Tailscale 1.102.5 on HQ): **PASS.** +A Python header-echo server on `127.0.0.1:18799` was published on port 8444 +and requested twice, from HQ itself and from the Daily Mac over SSH, each +request carrying `Tailscale-User-Login: attacker@example` and +`Tailscale-User-Name: Mallory`. Both echoes showed exactly one +`Tailscale-User-Login: zzbhlx@gmail.com`, `Tailscale-User-Name: Bing Z`, +`Tailscale-User-Profile-Pic: `, `Tailscale-Headers-Info: +https://tailscale.com/s/serve-headers`, plus `X-Forwarded-For` with the +requesting node's tailnet address (`100.125.101.60` and `100.75.72.13`), +`X-Forwarded-Host: macmini-hq.tail1cb16.ts.net:8444` and +`X-Forwarded-Proto: https`. The spoofed values were removed, not appended. +A direct request to `127.0.0.1:18799` bypassing Serve delivered the spoofed +header unchanged, which is risk R1 as stated. The mapping was removed and +`tailscale serve status` returned to the two pre-existing entries; the 8443 +Web origin answered 200 throughout. + +Note on the alternatives: Serve does add `X-Forwarded-For`, contrary to the +KB summary read on 2026-10-08. The design still does not use it; the +identity header is the documented contract and the forwarded address is as +forgeable on loopback as the login. + +No `_repro` test: this is new behavior, not a bug fix. + +## Rollout + +- Phase 1 (this doc): `EngramRemoteWebConfig`, `WebAuthSessionStore`, + `WebRequestBoundary`, `WebAuthRoutes`, `WebUIRoutes`, tests, ledger edit. + RemoteServer rebuild only; no service, App, MCP or schema change. +- Phase 2: actor propagation (§6), its own design addendum and PR. +- HQ deploy, after the pre-check passes: build and package with + `macos/scripts/package-remote-server.sh`, `--verify-only`, back up the + receiver plist, apply the §7 environment, `bootout` then `bootstrap` + `com.engram.capture-core.receiver`, confirm `/v1/health` 200 on both + origins, `GET /web/api/auth` 401 body shows the identity mode, and the + Web origin opened from the MacBook signs in without a form. Each of those + is a host mutation and needs owner authorization at the time. +- Revert: restore the backed-up plist and the prior package directory and + bootstrap again. Sessions are process-local, so nothing persists across + the revert. +- Deployments that never set `ENGRAM_REMOTE_WEB_AUTH` see no change. + +## Risks and open questions + +- **R1 Same-host header forgery.** Any process that can connect to + `127.0.0.1:18787` can send a `Tailscale-User-Login` header. Likelihood + low on a single-user host; impact is Web viewer or editor authority. That + process can already read the plist credentials and the service capability + token today, so no new privilege is created. Mitigation: the loopback-bind + requirement and the explicit residual sentence in the ledger entry. +- **R2 Tailscale behavior drift.** The header contract is documented, not + versioned. Mitigation: the pre-check is repeated after Tailscale upgrades + on HQ and the KB fetch date is recorded here. +- **R3 Identity is the tailnet user, not the OS user.** A device signed into + the tailnet grants Web access to whoever is at its keyboard. Same exposure + as today's cookie; accepted. +- **R4 Tagged devices and Funnel never carry the header** and get 401 + forever. Document in the 401 copy; no fallback is planned. +- **Q1** Show `Tailscale-User-Name` next to the login? Proposed no; the + login is unambiguous and one fewer header is parsed. +- **Q2** Keep 900 seconds absolute with silent re-mint, or lengthen? + Proposed keep; the reader no longer notices it. +- **Q3** Must `ENGRAM_REMOTE_WEB_EDITORS` be a subset of + `ENGRAM_REMOTE_WEB_VIEWERS`? Proposed no; editors read implicitly. +- **Q4** The P3 planner's `web-env-file` check + (`scripts/plan-headless-install.mjs:448-449`) may assume credential keys. + Verify at implementation and add a mode-aware branch if so. diff --git a/macos/EngramRemoteServer/Core/EngramRemoteServerApp.swift b/macos/EngramRemoteServer/Core/EngramRemoteServerApp.swift index 635793b7..3d4fb505 100644 --- a/macos/EngramRemoteServer/Core/EngramRemoteServerApp.swift +++ b/macos/EngramRemoteServer/Core/EngramRemoteServerApp.swift @@ -98,8 +98,10 @@ public final class EngramRemoteServerApp: Sendable { if let web = config.web { // Config is mutable and its caller may have omitted credentials from WebConfig's constructor. let credentials = [config.bearerToken, config.archiveV2?.bearerToken, config.mcp?.bearerToken].compactMap { $0 } - guard credentials.allSatisfy({ Data(SHA256.hash(data: Data($0.utf8))) != web.credentialDigest }) else { - throw EngramRemoteWebConfig.ConfigError.credentialMustBeDistinct + if let viewerDigest = web.credentialDigest { + guard credentials.allSatisfy({ Data(SHA256.hash(data: Data($0.utf8))) != viewerDigest }) else { + throw EngramRemoteWebConfig.ConfigError.credentialMustBeDistinct + } } let socketPath = try EngramRemoteServerConfig.validatedWebServiceSocketPath(config.webServiceSocketPath) self.webReadSurface = try webReadClientFactory(socketPath) @@ -306,7 +308,7 @@ public final class EngramRemoteServerApp: Sendable { WebAuthRoutes.mount(on: router, boundary: boundary, sessions: sessions) WebReadRoutes.mount(on: router, surface: surface) WebWriteRoutes.mount(on: router, surface: writeSurface) - WebUIRoutes.mount(on: router) + WebUIRoutes.mount(on: router, tailscaleServeIdentity: configuration.usesTailscaleServeIdentity) middleware = WebRequestBoundary.Middleware(boundary: boundary, sessions: sessions) } else { middleware = nil diff --git a/macos/EngramRemoteServer/Core/EngramRemoteServerConfig.swift b/macos/EngramRemoteServer/Core/EngramRemoteServerConfig.swift index 9ae502bb..006159b2 100644 --- a/macos/EngramRemoteServer/Core/EngramRemoteServerConfig.swift +++ b/macos/EngramRemoteServer/Core/EngramRemoteServerConfig.swift @@ -267,6 +267,11 @@ public struct EngramRemoteServerConfig: Sendable { env, serverBearerCredentials: [token, env["ENGRAM_REMOTE_ARCHIVE_TOKEN"], env["ENGRAM_REMOTE_MCP_TOKEN"]].compactMap { $0 } ) + // The only legitimate source of a Tailscale identity header is the Serve + // proxy on this host; a non-loopback listener would trust any peer's header. + if let web, web.usesTailscaleServeIdentity, !Self.isLoopbackBindAddress(host) { + throw EngramRemoteWebConfig.ConfigError.identityRequiresLoopbackBind + } let webServiceSocketPath: String? if web != nil { webServiceSocketPath = try Self.validatedWebServiceSocketPath(env["ENGRAM_REMOTE_WEB_SERVICE_SOCKET"]) @@ -324,6 +329,23 @@ public struct EngramRemoteServerConfig: Sendable { return value } + private static func isLoopbackBindAddress(_ host: String) -> Bool { + guard !host.contains("%") else { return false } + + var ipv4 = in_addr() + if host.withCString({ inet_pton(AF_INET, $0, &ipv4) }) == 1 { + return withUnsafeBytes(of: &ipv4) { $0[0] == 127 } + } + + var ipv6 = in6_addr() + if host.withCString({ inet_pton(AF_INET6, $0, &ipv6) }) == 1 { + let bytes = withUnsafeBytes(of: &ipv6) { Array($0) } + return bytes.dropLast().allSatisfy { $0 == 0 } && bytes.last == 1 + } + + return false + } + private static func isAllowedArchiveBindAddress(_ host: String) -> Bool { guard !host.contains("%") else { return false } diff --git a/macos/EngramRemoteServer/Core/EngramRemoteWebConfig.swift b/macos/EngramRemoteServer/Core/EngramRemoteWebConfig.swift index f77645f4..0cd77059 100644 --- a/macos/EngramRemoteServer/Core/EngramRemoteWebConfig.swift +++ b/macos/EngramRemoteServer/Core/EngramRemoteWebConfig.swift @@ -4,13 +4,37 @@ import Foundation /// An explicitly enabled Web viewer, separate from every server bearer credential. public struct EngramRemoteWebConfig: Sendable { + /// Where a Web session's viewer/editor authority comes from + /// (docs/superpowers/specs/2026-10-08-web-tailscale-identity-auth-design.md). + public enum AuthMode: Sendable, Equatable { + /// Shared viewer/editor credentials, kept only as SHA-256 digests. + case credential(viewerDigest: Data, editorDigest: Data?) + /// The exact `Tailscale-User-Login` the local Tailscale Serve proxy asserts, + /// matched against explicit allowlists. Editors read implicitly. + case tailscaleServe(viewers: Set, editors: Set) + } + public let origin: String public let authority: String - let credentialDigest: Data - let editorCredentialDigest: Data? + let mode: AuthMode let cookieName: String let isSecure: Bool + var credentialDigest: Data? { + if case let .credential(viewerDigest, _) = mode { return viewerDigest } + return nil + } + + var editorCredentialDigest: Data? { + if case let .credential(_, editorDigest) = mode { return editorDigest } + return nil + } + + var usesTailscaleServeIdentity: Bool { + if case .tailscaleServe = mode { return true } + return false + } + public enum ConfigError: Error, Equatable, CustomStringConvertible { case invalidEnabled case missingOrigin @@ -18,6 +42,11 @@ public struct EngramRemoteWebConfig: Sendable { case missingCredential case credentialMustBeDistinct case invalidEditorCredential + case invalidAuthMode + case credentialNotAllowedWithIdentity + case missingIdentityViewers + case invalidIdentityLogin + case identityRequiresLoopbackBind public var description: String { switch self { @@ -27,6 +56,13 @@ public struct EngramRemoteWebConfig: Sendable { case .missingCredential: "Web requires a dedicated viewer credential." case .credentialMustBeDistinct: "Web viewer and server bearer credentials must be distinct." case .invalidEditorCredential: "Web editor requires a nonempty credential distinct from viewer and server credentials." + case .invalidAuthMode: "ENGRAM_REMOTE_WEB_AUTH must be credential or tailscale-serve." + case .credentialNotAllowedWithIdentity: + "ENGRAM_REMOTE_WEB_AUTH=tailscale-serve forbids ENGRAM_REMOTE_WEB_VIEWER_CREDENTIAL and ENGRAM_REMOTE_WEB_EDITOR_CREDENTIAL." + case .missingIdentityViewers: "ENGRAM_REMOTE_WEB_AUTH=tailscale-serve requires a nonempty ENGRAM_REMOTE_WEB_VIEWERS list." + case .invalidIdentityLogin: + "Web identity allowlists are comma-separated exact Tailscale logins: nonempty printable ASCII without whitespace, quotes or backslashes, at most 254 bytes, no duplicates." + case .identityRequiresLoopbackBind: "ENGRAM_REMOTE_WEB_AUTH=tailscale-serve requires ENGRAM_REMOTE_HOST to be a loopback address." } } } @@ -57,14 +93,29 @@ public struct EngramRemoteWebConfig: Sendable { editorCredential != viewerCredential, !serverBearerCredentials.contains(editorCredential) else { throw ConfigError.invalidEditorCredential } } - self.editorCredentialDigest = editorCredential.map { Data(SHA256.hash(data: Data($0.utf8))) } + self.mode = .credential( + viewerDigest: Data(SHA256.hash(data: Data(viewerCredential.utf8))), + editorDigest: editorCredential.map { Data(SHA256.hash(data: Data($0.utf8))) } + ) self.authority = try Self.canonicalAuthority(origin, loopbackHTTPForTesting: loopbackHTTPForTesting) self.origin = origin - self.credentialDigest = Data(SHA256.hash(data: Data(viewerCredential.utf8))) self.cookieName = loopbackHTTPForTesting ? "engram_web_test" : "__Host-engram_web" self.isSecure = !loopbackHTTPForTesting } + /// Identity mode: no shared credential exists; the local Tailscale Serve proxy + /// asserts the login and these exact allowlists decide the authority. + public init(origin: String, tailscaleServeViewers: String, editors: String?) throws { + let viewers = try Self.parseLoginList(tailscaleServeViewers) + guard !viewers.isEmpty else { throw ConfigError.missingIdentityViewers } + let editorLogins = try editors.map(Self.parseLoginList) ?? [] + self.mode = .tailscaleServe(viewers: viewers, editors: editorLogins) + self.authority = try Self.canonicalAuthority(origin, loopbackHTTPForTesting: false) + self.origin = origin + self.cookieName = "__Host-engram_web" + self.isSecure = true + } + public static func fromEnvironment( _ environment: [String: String], serverBearerCredentials: [String] @@ -77,11 +128,43 @@ public struct EngramRemoteWebConfig: Sendable { guard let origin = environment["ENGRAM_REMOTE_WEB_ORIGIN"], !origin.isEmpty else { throw ConfigError.missingOrigin } - guard let credential = environment["ENGRAM_REMOTE_WEB_VIEWER_CREDENTIAL"] else { - throw ConfigError.missingCredential + switch environment["ENGRAM_REMOTE_WEB_AUTH"] { + case nil, "credential": + guard let credential = environment["ENGRAM_REMOTE_WEB_VIEWER_CREDENTIAL"] else { + throw ConfigError.missingCredential + } + return try Self(origin: origin, viewerCredential: credential, serverBearerCredentials: serverBearerCredentials, + editorCredential: environment["ENGRAM_REMOTE_WEB_EDITOR_CREDENTIAL"]) + case "tailscale-serve": + guard environment["ENGRAM_REMOTE_WEB_VIEWER_CREDENTIAL"] == nil, + environment["ENGRAM_REMOTE_WEB_EDITOR_CREDENTIAL"] == nil else { + throw ConfigError.credentialNotAllowedWithIdentity + } + guard let viewers = environment["ENGRAM_REMOTE_WEB_VIEWERS"], !viewers.isEmpty else { + throw ConfigError.missingIdentityViewers + } + return try Self(origin: origin, tailscaleServeViewers: viewers, editors: environment["ENGRAM_REMOTE_WEB_EDITORS"]) + default: + throw ConfigError.invalidAuthMode + } + } + + /// Shared by the allowlists and the request header: exact, printable ASCII, + /// no whitespace, quotes or backslashes, so a login can be echoed in JSON verbatim. + static func isWellFormedLogin(_ login: String) -> Bool { + !login.isEmpty && login.utf8.count <= 254 + && login.utf8.allSatisfy { (33...126).contains($0) && $0 != 34 && $0 != 92 } + } + + private static func parseLoginList(_ text: String) throws -> Set { + var logins = Set() + for entry in text.split(separator: ",", omittingEmptySubsequences: false) { + let login = String(entry) + guard Self.isWellFormedLogin(login), logins.insert(login).inserted else { + throw ConfigError.invalidIdentityLogin + } } - return try Self(origin: origin, viewerCredential: credential, serverBearerCredentials: serverBearerCredentials, - editorCredential: environment["ENGRAM_REMOTE_WEB_EDITOR_CREDENTIAL"]) + return logins } /// Internal test-only escape hatch; no environment flag can enable HTTP. diff --git a/macos/EngramRemoteServer/Core/WebAuthRoutes.swift b/macos/EngramRemoteServer/Core/WebAuthRoutes.swift index b33363da..306f7d37 100644 --- a/macos/EngramRemoteServer/Core/WebAuthRoutes.swift +++ b/macos/EngramRemoteServer/Core/WebAuthRoutes.swift @@ -17,7 +17,13 @@ enum WebAuthRoutes { guard let token = boundary.sessionToken(in: request), await sessions.isAuthenticated(sessionToken: token) else { return boundary.decorate(Response(status: .unauthorized)) } - let body = await sessions.canWrite(sessionToken: token) ? "{\"canWrite\":true}" : "{\"canWrite\":false}" + let canWrite = await sessions.canWrite(sessionToken: token) + let body: String + if boundary.configuration.usesTailscaleServeIdentity, let login = await sessions.actor(sessionToken: token) { + body = statusBody(canWrite: canWrite, login: login) + } else { + body = canWrite ? "{\"canWrite\":true}" : "{\"canWrite\":false}" + } return boundary.decorate(Response(status: .ok, headers: [.contentType: "application/json"], body: ResponseBody(byteBuffer: ByteBuffer(string: body)))) } @@ -39,12 +45,25 @@ enum WebAuthRoutes { do { data = try await readJSONBody(request) } catch { return boundary.decorate(Response(status: (error as? AuthBodyError)?.status ?? .badRequest)) } - guard let credential = loginCredential(in: data) else { return boundary.decorate(Response(status: .badRequest)) } - switch await sessions.login(credential: credential) { + let result: WebAuthSessionStore.LoginResult + if boundary.configuration.usesTailscaleServeIdentity { + // Identity mode accepts only `{}`: a credential body can never log in here. + guard isEmptyJSONObject(data) else { return boundary.decorate(Response(status: .badRequest)) } + guard let identity = boundary.tailscaleIdentity(in: request) else { + return boundary.decorate(Response(status: .unauthorized)) + } + result = await sessions.login(identity: identity) + } else { + guard let credential = loginCredential(in: data) else { return boundary.decorate(Response(status: .badRequest)) } + result = await sessions.login(credential: credential) + } + switch result { case let .authenticated(token): return boundary.decorate(Response(status: .noContent, headers: [.setCookie: cookie(token, boundary: boundary, maxAge: WebAuthSessionStore.lifetimeSeconds)])) case .unauthorized: return boundary.decorate(Response(status: .unauthorized)) + case .forbidden: + return boundary.decorate(Response(status: .forbidden)) case let .throttled(retryAfterSeconds): return boundary.decorate(Response(status: .tooManyRequests, headers: [.retryAfter: String(retryAfterSeconds)])) case .unavailable: @@ -62,7 +81,7 @@ enum WebAuthRoutes { do { data = try await readJSONBody(request) } catch { return boundary.decorate(Response(status: (error as? AuthBodyError)?.status ?? .badRequest)) } - guard data.filter({ !isJSONWhitespace($0) }) == Data([123, 125]) else { + guard isEmptyJSONObject(data) else { return boundary.decorate(Response(status: .badRequest)) } guard let token = boundary.sessionToken(in: request), await sessions.isAuthenticated(sessionToken: token) else { @@ -130,6 +149,18 @@ enum WebAuthRoutes { byte == 32 || byte == 9 || byte == 10 || byte == 13 } + private static func isEmptyJSONObject(_ data: Data) -> Bool { + data.filter { !isJSONWhitespace($0) } == Data([123, 125]) + } + + /// Identity-mode status. The login passed `isWellFormedLogin`, so it carries no + /// quote or backslash; JSONSerialization still owns the encoding. + private static func statusBody(canWrite: Bool, login: String) -> String { + let object: [String: Any] = ["canWrite": canWrite, "login": login] + let data = (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes])) ?? Data() + return String(decoding: data, as: UTF8.self) + } + private static func cookie(_ token: String, boundary: WebRequestBoundary, maxAge: Int) -> String { var value = "\(boundary.configuration.cookieName)=\(token); Max-Age=\(maxAge); Path=/; HttpOnly; SameSite=Strict" if boundary.configuration.isSecure { value += "; Secure" } diff --git a/macos/EngramRemoteServer/Core/WebAuthSessionStore.swift b/macos/EngramRemoteServer/Core/WebAuthSessionStore.swift index b2d75625..0fad7c2f 100644 --- a/macos/EngramRemoteServer/Core/WebAuthSessionStore.swift +++ b/macos/EngramRemoteServer/Core/WebAuthSessionStore.swift @@ -2,6 +2,11 @@ import CryptoKit import Dispatch import Foundation +/// A login the local Tailscale Serve proxy asserted for the current request. +struct WebIdentity: Sendable, Equatable { + let login: String +} + /// Process-local session authority; never persists bearer session material. actor WebAuthSessionStore { static let lifetimeSeconds = 900 @@ -12,6 +17,8 @@ actor WebAuthSessionStore { enum LoginResult: Equatable, Sendable { case authenticated(sessionToken: String) case unauthorized + /// Identity mode: a well-formed login that no allowlist names. + case forbidden case throttled(retryAfterSeconds: Int) case unavailable } @@ -22,6 +29,8 @@ actor WebAuthSessionStore { private struct Session: Sendable { let expiresAt: UInt64 let canWrite: Bool + /// The tailnet login for identity-mode sessions; nil for credential sessions. + let login: String? } private var sessions: [Data: Session] = [:] private var attemptTimes: [UInt64] = [] @@ -41,6 +50,8 @@ actor WebAuthSessionStore { } func login(credential: String) -> LoginResult { + // Credential mode only; an identity-mode store never compares a secret. + guard case let .credential(viewerDigest, editorDigest) = configuration.mode else { return .unavailable } let instant = monotonicNow() purgeExpired(at: instant) let (expiresAt, overflow) = instant.addingReportingOverflow(Self.lifetimeNanoseconds) @@ -56,9 +67,27 @@ actor WebAuthSessionStore { attemptTimes.append(instant) let submittedDigest = Data(SHA256.hash(data: Data(credential.utf8))) - let isViewer = constantTimeEqual(submittedDigest, configuration.credentialDigest) - let isEditor = configuration.editorCredentialDigest.map { constantTimeEqual(submittedDigest, $0) } ?? false + let isViewer = constantTimeEqual(submittedDigest, viewerDigest) + let isEditor = editorDigest.map { constantTimeEqual(submittedDigest, $0) } ?? false guard isViewer || isEditor else { return .unauthorized } + return mint(canWrite: isEditor, login: nil, expiresAt: expiresAt) + } + + /// Identity mode only. The login was asserted by the local Tailscale Serve proxy + /// and is matched exactly against the allowlists. There is no secret to guess, + /// so the attempt window does not apply; capacity and collision guards still do. + func login(identity: WebIdentity) -> LoginResult { + guard case let .tailscaleServe(viewers, editors) = configuration.mode else { return .unavailable } + let instant = monotonicNow() + purgeExpired(at: instant) + let (expiresAt, overflow) = instant.addingReportingOverflow(Self.lifetimeNanoseconds) + guard !overflow else { return .unavailable } + let isEditor = editors.contains(identity.login) + guard isEditor || viewers.contains(identity.login) else { return .forbidden } + return mint(canWrite: isEditor, login: identity.login, expiresAt: expiresAt) + } + + private func mint(canWrite: Bool, login: String?, expiresAt: UInt64) -> LoginResult { guard sessions.count < Self.capacity else { return .unavailable } let bytes: Data do { bytes = try randomBytes() } catch { return .unavailable } @@ -69,7 +98,7 @@ actor WebAuthSessionStore { .replacingOccurrences(of: "=", with: "") let digest = Self.sessionDigest(token) guard sessions[digest] == nil else { return .unavailable } - sessions[digest] = Session(expiresAt: expiresAt, canWrite: isEditor) + sessions[digest] = Session(expiresAt: expiresAt, canWrite: canWrite, login: login) return .authenticated(sessionToken: token) } @@ -81,6 +110,11 @@ actor WebAuthSessionStore { validSession(sessionToken: sessionToken)?.canWrite ?? false } + /// The tailnet login bound to a live identity-mode session; nil otherwise. + func actor(sessionToken: String) -> String? { + validSession(sessionToken: sessionToken)?.login + } + private func validSession(sessionToken: String) -> Session? { let instant = monotonicNow() purgeExpired(at: instant) diff --git a/macos/EngramRemoteServer/Core/WebRequestBoundary.swift b/macos/EngramRemoteServer/Core/WebRequestBoundary.swift index 71c9b3c5..4cacf0e8 100644 --- a/macos/EngramRemoteServer/Core/WebRequestBoundary.swift +++ b/macos/EngramRemoteServer/Core/WebRequestBoundary.swift @@ -44,6 +44,15 @@ struct WebRequestBoundary: Sendable { return session } + /// Identity mode only: the single `Tailscale-User-Login` the local Serve proxy + /// asserts. Credential mode never reads it, so a forged header there is inert. + func tailscaleIdentity(in request: Request) -> WebIdentity? { + guard configuration.usesTailscaleServeIdentity else { return nil } + let logins = request.headers[values: HTTPField.Name("Tailscale-User-Login")!] + guard logins.count == 1, let login = logins.first, EngramRemoteWebConfig.isWellFormedLogin(login) else { return nil } + return WebIdentity(login: login) + } + func decorate(_ response: Response) -> Response { var response = response response.headers[.cacheControl] = "no-store" diff --git a/macos/EngramRemoteServer/Core/WebUIRoutes.swift b/macos/EngramRemoteServer/Core/WebUIRoutes.swift index afe3222c..ff3b629b 100644 --- a/macos/EngramRemoteServer/Core/WebUIRoutes.swift +++ b/macos/EngramRemoteServer/Core/WebUIRoutes.swift @@ -5,8 +5,9 @@ import NIOCore /// Same-origin static viewer. HTML/JS/CSS are constant strings; no inline script or style. enum WebUIRoutes { - static func mount(on router: Router) { - router.get("/web") { _, _ in asset("text/html; charset=utf-8", html) } + static func mount(on router: Router, tailscaleServeIdentity: Bool = false) { + let page = tailscaleServeIdentity ? identityHTML : html + router.get("/web") { _, _ in asset("text/html; charset=utf-8", page) } router.get("/web/app.js") { _, _ in asset("text/javascript; charset=utf-8", javascript) } router.get("/web/app.css") { _, _ in asset("text/css; charset=utf-8", css) } } @@ -18,6 +19,13 @@ enum WebUIRoutes { return Response(status: .ok, headers: headers, body: ResponseBody(byteBuffer: ByteBuffer(string: body))) } + /// Identity mode marks the document so the script signs in from the tailnet + /// identity, and ships the credential form hidden so it never flashes before + /// the script runs. The markup is otherwise identical. + static let identityHTML = html + .replacingOccurrences(of: "", with: "") + .replacingOccurrences(of: "
", with: "") + static let html = """ @@ -585,6 +593,15 @@ enum WebUIRoutes { const backNode = document.getElementById("back"); const signedOutNode = document.getElementById("signed-out"); const workspaceNode = document.getElementById("workspace"); + // Identity mode: the server marked the document; sign-in is a `{}` POST that + // the Tailscale Serve proxy authenticates, so the credential form stays hidden. + const identityMode = !!(document.body && document.body.dataset && document.body.dataset.authMode === "tailscale-serve"); + let identitySignInPending = false; + let identitySignInAt = 0; + if (identityMode) { + document.getElementById("login").hidden = true; + document.getElementById("logout").hidden = true; + } const healthContent = document.getElementById("health-content"); const healthStatus = document.getElementById("health-status"); const statsFields = [["sessionCount", "Sessions"], ["messageCount", "Messages"], ["userMessageCount", "User"], ["assistantMessageCount", "Assistant"], ["toolMessageCount", "Tool"]]; @@ -707,8 +724,8 @@ enum WebUIRoutes { } signedOutNode.hidden = signedIn; workspaceNode.hidden = !signedIn; - document.getElementById("login").hidden = signedIn; - document.getElementById("logout").hidden = !signedIn; + document.getElementById("login").hidden = signedIn || identityMode; + document.getElementById("logout").hidden = !signedIn || identityMode; document.getElementById("lede").hidden = signedIn; } function expireSession() { @@ -721,6 +738,35 @@ enum WebUIRoutes { clearMessages(); setText(statusNode, "Session expired"); bumpEpoch(); + // One silent re-mint per expiry; the 5 s floor stops a mint/401 loop. + if (identityMode && Date.now() - identitySignInAt > 5000) identitySignIn(true).catch(function () {}); + } + async function identitySignIn(restorePage) { + if (!identityMode || identitySignInPending) return false; + identitySignInPending = true; + identitySignInAt = Date.now(); + const token = bumpEpoch(); + try { + await authWrite("POST", "{}"); + } catch (error) { + identitySignInPending = false; + if (token !== requestEpoch) return false; + setText(statusNode, error.status === 403 ? "Your tailnet login is not permitted for this Web origin." : "Tailnet sign-in unavailable"); + return false; + } + if (token !== requestEpoch) { identitySignInPending = false; return false; } + try { + await restoreSession(); + } finally { + identitySignInPending = false; + } + if (token !== requestEpoch) return false; + try { + const access = await api("GET", "/web/api/auth"); + if (token === requestEpoch && access && access.login) setText(statusNode, "Signed in as " + access.login); + } catch (error) {} + if (restorePage && token === requestEpoch) await restoreRequestedPage(); + return true; } function showSessionList() { activatePage(detailReturnPage || (activePage === "search" ? "search" : "sessions")); @@ -2863,7 +2909,7 @@ enum WebUIRoutes { return; } if (token !== requestEpoch) return; - if (response.status === 401) return; + if (response.status === 401) { await identitySignIn(false); return; } if (response.status === 503) { try { response = await probe(); @@ -2872,7 +2918,7 @@ enum WebUIRoutes { return; } if (token !== requestEpoch) return; - if (response.status === 401) return; + if (response.status === 401) { await identitySignIn(false); return; } } if (!response.ok) { if (token === requestEpoch) setText(statusNode, "Temporarily unavailable"); diff --git a/macos/EngramRemoteServerCoreTests/WebAuthRouteTests.swift b/macos/EngramRemoteServerCoreTests/WebAuthRouteTests.swift index 233332e4..03a6fa63 100644 --- a/macos/EngramRemoteServerCoreTests/WebAuthRouteTests.swift +++ b/macos/EngramRemoteServerCoreTests/WebAuthRouteTests.swift @@ -265,6 +265,95 @@ final class WebAuthRouteTests: XCTestCase { } } + private func identityConfiguration() throws -> EngramRemoteWebConfig { + try EngramRemoteWebConfig(origin: origin, tailscaleServeViewers: "reader@example.com", editors: "editor@example.com") + } + + func testIdentityLoginMintsFromTheServeHeaderWithEmptyBodyAndExactOrigin() async throws { + let harness = try WebRouteHarness(configuration: identityConfiguration()) + let headers = loginHeaders + [("Tailscale-User-Login", "editor@example.com"), ("Tailscale-User-Name", "Editor")] + let login = try await harness.respond(request(.post, path: "/web/api/auth", headers: headers, body: " { } ")) + XCTAssertEqual(login.status, .noContent) + assertSecurityHeaders(login) + let cookie = login.headers[.setCookie] ?? "" + for attribute in ["__Host-engram_web=", "Secure", "HttpOnly", "SameSite=Strict", "Path=/", "Max-Age=900"] { XCTAssertTrue(cookie.contains(attribute), attribute) } + XCTAssertFalse(cookie.contains("editor@example.com")) + let pair = try XCTUnwrap(cookie.split(separator: ";").first.map(String.init)) + let status = try await harness.respond(request(path: "/web/api/auth", headers: metadataHeaders + [("Cookie", pair)])) + XCTAssertEqual(status.status, .ok) + let body = try await webResponseText(status) + XCTAssertEqual(body, "{\"canWrite\":true,\"login\":\"editor@example.com\"}") + let read = try await harness.respond(request(headers: metadataHeaders + [("Cookie", pair)])) + XCTAssertEqual(read.status, .ok) + // Fetch metadata is not an Origin: the identity mint stays CSRF-safe. + let metadataOnly = try await harness.respond(request(.post, path: "/web/api/auth", + headers: metadataHeaders + [("Content-Type", "application/json"), ("Tailscale-User-Login", "editor@example.com")], body: "{}")) + XCTAssertEqual(metadataOnly.status, .forbidden) + XCTAssertNil(metadataOnly.headers[.setCookie]) + } + + func testIdentityLoginRejectsCredentialBodiesMissingDuplicateAndUnlistedLogins() async throws { + let harness = try WebRouteHarness(configuration: identityConfiguration()) + let identity = [("Tailscale-User-Login", "reader@example.com")] + for body in [loginBody, "{\"credential\":\"\"}", "{\"login\":\"reader@example.com\"}", "[]", "", "{} {}"] { + let response = try await harness.respond(request(.post, path: "/web/api/auth", headers: loginHeaders + identity, body: body)) + XCTAssertEqual(response.status, .badRequest, body) + XCTAssertNil(response.headers[.setCookie], body) + } + for headers in [ + [], [("Tailscale-User-Login", "")], [("Tailscale-User-Login", "reader@example.com"), ("Tailscale-User-Login", "reader@example.com")], + [("Tailscale-User-Login", "reader @example.com")], [("Tailscale-User-Login", "\"reader\"@example.com")], + [("Tailscale-User-Name", "reader@example.com")], [("X-Forwarded-For", "100.64.0.2")], + ] as [[(String, String)]] { + let response = try await harness.respond(request(.post, path: "/web/api/auth", headers: loginHeaders + headers, body: "{}")) + XCTAssertEqual(response.status, .unauthorized, "\(headers)") + XCTAssertNil(response.headers[.setCookie]) + let text = try await webResponseText(response) + XCTAssertEqual(text, "") + } + for login in ["nobody@example.com", "Reader@example.com", "reader@example.com.", "reader@example.com,editor@example.com"] { + let response = try await harness.respond(request(.post, path: "/web/api/auth", headers: loginHeaders + [("Tailscale-User-Login", login)], body: "{}")) + XCTAssertEqual(response.status, .forbidden, login) + XCTAssertNil(response.headers[.setCookie]) + let text = try await webResponseText(response) + XCTAssertFalse(text.contains("reader@example.com")) + XCTAssertFalse(text.contains("editor@example.com")) + assertSecurityHeaders(response) + } + let digests = await harness.sessions.sessionDigests + XCTAssertTrue(digests.isEmpty) + } + + func testCredentialModeIgnoresForgedServeHeadersAndKeepsItsStatusBody() async throws { + let harness = try WebRouteHarness(configuration: configuration()) + let forged = [("Tailscale-User-Login", "attacker@example.com")] + let emptyBody = try await harness.respond(request(.post, path: "/web/api/auth", headers: loginHeaders + forged, body: "{}")) + XCTAssertEqual(emptyBody.status, .badRequest) + XCTAssertNil(emptyBody.headers[.setCookie]) + let wrong = try await harness.respond(request(.post, path: "/web/api/auth", headers: loginHeaders + forged, body: "{\"credential\":\"wrong\"}")) + XCTAssertEqual(wrong.status, .unauthorized) + let login = try await harness.respond(request(.post, path: "/web/api/auth", headers: loginHeaders + forged, body: loginBody)) + XCTAssertEqual(login.status, .noContent) + let pair = try XCTUnwrap(login.headers[.setCookie]?.split(separator: ";").first.map(String.init)) + let status = try await harness.respond(request(path: "/web/api/auth", headers: metadataHeaders + forged + [("Cookie", pair)])) + let body = try await webResponseText(status) + XCTAssertEqual(body, "{\"canWrite\":false}") + XCTAssertNil(WebRequestBoundary(configuration: try configuration()).tailscaleIdentity(in: request(headers: forged))) + } + + func testIdentityViewerSessionCannotReachWriteRoutesEvenWithAnEditorHeader() async throws { + let harness = try WebRouteHarness(configuration: identityConfiguration()) + let login = try await harness.respond(request(.post, path: "/web/api/auth", headers: loginHeaders + [("Tailscale-User-Login", "reader@example.com")], body: "{}")) + XCTAssertEqual(login.status, .noContent) + let pair = try XCTUnwrap(login.headers[.setCookie]?.split(separator: ";").first.map(String.init)) + let routedAfterLogin = await harness.routingCalls.count + let write = try await harness.respond(request(.post, path: "/web/api/settings/aliases", + headers: loginHeaders + [("Cookie", pair), ("Tailscale-User-Login", "editor@example.com")], body: "{}")) + XCTAssertEqual(write.status, .forbidden) + let routedAfterWrite = await harness.routingCalls.count + XCTAssertEqual(routedAfterWrite, routedAfterLogin) + } + func testGlobalRouteThrottleReturns429AndRetryAfterWithoutIPTrust() async throws { let harness = try WebRouteHarness(configuration: configuration()) for index in 0..<5 { diff --git a/macos/EngramRemoteServerCoreTests/WebAuthSessionTests.swift b/macos/EngramRemoteServerCoreTests/WebAuthSessionTests.swift index dc306817..a86d13ed 100644 --- a/macos/EngramRemoteServerCoreTests/WebAuthSessionTests.swift +++ b/macos/EngramRemoteServerCoreTests/WebAuthSessionTests.swift @@ -256,6 +256,74 @@ final class WebAuthSessionTests: XCTestCase { XCTAssertEqual(digests.count, 1) } + private func makeIdentityStore( + clock: WebTestClock = WebTestClock(), + random: WebTestRandom = WebTestRandom() + ) throws -> WebAuthSessionStore { + WebAuthSessionStore( + configuration: try EngramRemoteWebConfig( + origin: "https://viewer.example", tailscaleServeViewers: "reader@example.com", editors: "editor@example.com" + ), + now: { clock.now }, randomBytes: { try random.next() } + ) + } + + func testIdentityLoginMintsViewerOrEditorByExactLoginAndRecordsActor() async throws { + let store = try makeIdentityStore() + guard let reader = token(await store.login(identity: WebIdentity(login: "reader@example.com"))), + let editor = token(await store.login(identity: WebIdentity(login: "editor@example.com"))) else { return } + let readerCanWrite = await store.canWrite(sessionToken: reader) + let editorCanWrite = await store.canWrite(sessionToken: editor) + let editorCanRead = await store.isAuthenticated(sessionToken: editor) + XCTAssertFalse(readerCanWrite) + XCTAssertTrue(editorCanWrite) + XCTAssertTrue(editorCanRead) + let readerActor = await store.actor(sessionToken: reader) + let editorActor = await store.actor(sessionToken: editor) + XCTAssertEqual(readerActor, "reader@example.com") + XCTAssertEqual(editorActor, "editor@example.com") + for login in ["Reader@example.com", "reader@example.com ", "reader@example.co", "nobody@example.com", ""] { + let outcome = await store.login(identity: WebIdentity(login: login)) + XCTAssertEqual(outcome, .forbidden, login) + } + let digests = await store.sessionDigests + XCTAssertEqual(digests.count, 2) + await store.logout(sessionToken: editor) + let revokedActor = await store.actor(sessionToken: editor) + XCTAssertNil(revokedActor) + } + + func testIdentityLoginsIgnoreTheAttemptWindowButRespectCapacityAndExpiry() async throws { + let clock = WebTestClock() + let store = try makeIdentityStore(clock: clock) + var issued: [String] = [] + for _ in 0..<64 { + guard let token = token(await store.login(identity: WebIdentity(login: "reader@example.com"))) else { return } + issued.append(token) + } + let full = await store.login(identity: WebIdentity(login: "reader@example.com")) + XCTAssertEqual(full, .unavailable) + clock.advance(seconds: 900) + let afterExpiry = await store.login(identity: WebIdentity(login: "reader@example.com")) + XCTAssertNotNil(token(afterExpiry)) + let firstExpired = await store.isAuthenticated(sessionToken: issued[0]) + XCTAssertFalse(firstExpired) + } + + func testEachStoreAnswersOnlyItsOwnModeAndCredentialSessionsHaveNoActor() async throws { + let identityStore = try makeIdentityStore() + let credentialOutcome = await identityStore.login(credential: "reader@example.com") + XCTAssertEqual(credentialOutcome, .unavailable) + let identityDigests = await identityStore.sessionDigests + XCTAssertTrue(identityDigests.isEmpty) + let credentialStore = try makeStore() + let identityOutcome = await credentialStore.login(identity: WebIdentity(login: viewer)) + XCTAssertEqual(identityOutcome, .unavailable) + guard let session = token(await credentialStore.login(credential: viewer)) else { return } + let actor = await credentialStore.actor(sessionToken: session) + XCTAssertNil(actor) + } + func testUnrepresentableClockDeadlinesFailClosedWithoutMintingOrTrapping() async throws { for instant in [UInt64.max, UInt64.max - 899_000_000_000] { let clock = WebTestClock() diff --git a/macos/EngramRemoteServerCoreTests/WebConfigTests.swift b/macos/EngramRemoteServerCoreTests/WebConfigTests.swift index b9d94fcb..200e548a 100644 --- a/macos/EngramRemoteServerCoreTests/WebConfigTests.swift +++ b/macos/EngramRemoteServerCoreTests/WebConfigTests.swift @@ -120,8 +120,92 @@ final class WebConfigTests: XCTestCase { XCTAssertThrowsError(try EngramRemoteWebConfig.fromEnvironment(environment, serverBearerCredentials: bearers)) } + private var identityEnabled: [String: String] { + [ + "ENGRAM_REMOTE_WEB_ENABLED": "1", + "ENGRAM_REMOTE_WEB_ORIGIN": "https://viewer.example", + "ENGRAM_REMOTE_WEB_AUTH": "tailscale-serve", + "ENGRAM_REMOTE_WEB_VIEWERS": "reader@example.com,second@example.com", + "ENGRAM_REMOTE_WEB_EDITORS": "editor@example.com", + ] + } + + func testAuthModeDefaultsToCredentialAndRejectsUnknownValues() throws { + let implicit = try XCTUnwrap(EngramRemoteWebConfig.fromEnvironment(enabled, serverBearerCredentials: bearers)) + var explicit = enabled + explicit["ENGRAM_REMOTE_WEB_AUTH"] = "credential" + let named = try XCTUnwrap(EngramRemoteWebConfig.fromEnvironment(explicit, serverBearerCredentials: bearers)) + XCTAssertEqual(implicit.mode, named.mode) + XCTAssertFalse(implicit.usesTailscaleServeIdentity) + for value in ["", "Tailscale-Serve", "tailscale", "credential ", "both"] { + var environment = enabled + environment["ENGRAM_REMOTE_WEB_AUTH"] = value + XCTAssertThrowsError(try EngramRemoteWebConfig.fromEnvironment(environment, serverBearerCredentials: bearers), value) { + XCTAssertEqual($0 as? EngramRemoteWebConfig.ConfigError, .invalidAuthMode) + } + } + } + + func testTailscaleServeModeParsesExactAllowlistsAndKeepsNoCredentialDigest() throws { + let config = try XCTUnwrap(EngramRemoteWebConfig.fromEnvironment(identityEnabled, serverBearerCredentials: bearers)) + XCTAssertTrue(config.usesTailscaleServeIdentity) + XCTAssertEqual(config.mode, .tailscaleServe(viewers: ["reader@example.com", "second@example.com"], editors: ["editor@example.com"])) + XCTAssertNil(config.credentialDigest) + XCTAssertNil(config.editorCredentialDigest) + XCTAssertEqual(config.cookieName, "__Host-engram_web") + XCTAssertTrue(config.isSecure) + var viewersOnly = identityEnabled + viewersOnly["ENGRAM_REMOTE_WEB_EDITORS"] = nil + let noEditors = try XCTUnwrap(EngramRemoteWebConfig.fromEnvironment(viewersOnly, serverBearerCredentials: bearers)) + XCTAssertEqual(noEditors.mode, .tailscaleServe(viewers: ["reader@example.com", "second@example.com"], editors: [])) + } + + func testTailscaleServeModeForbidsSharedCredentialsAndRequiresViewers() throws { + for key in ["ENGRAM_REMOTE_WEB_VIEWER_CREDENTIAL", "ENGRAM_REMOTE_WEB_EDITOR_CREDENTIAL"] { + for value in ["", "fixture-secret"] { + var environment = identityEnabled + environment[key] = value + XCTAssertThrowsError(try EngramRemoteWebConfig.fromEnvironment(environment, serverBearerCredentials: bearers)) { + XCTAssertEqual($0 as? EngramRemoteWebConfig.ConfigError, .credentialNotAllowedWithIdentity) + } + } + } + for missing in [true, false] { + var environment = identityEnabled + environment["ENGRAM_REMOTE_WEB_VIEWERS"] = missing ? nil : "" + XCTAssertThrowsError(try EngramRemoteWebConfig.fromEnvironment(environment, serverBearerCredentials: bearers)) { + XCTAssertEqual($0 as? EngramRemoteWebConfig.ConfigError, .missingIdentityViewers) + } + } + var plainHTTP = identityEnabled + plainHTTP["ENGRAM_REMOTE_WEB_ORIGIN"] = "http://127.0.0.1:8787" + XCTAssertThrowsError(try EngramRemoteWebConfig.fromEnvironment(plainHTTP, serverBearerCredentials: bearers)) { + XCTAssertEqual($0 as? EngramRemoteWebConfig.ConfigError, .invalidOrigin) + } + } + + func testIdentityAllowlistEntriesMustBeExactPrintableLoginsWithoutDuplicates() throws { + for list in [",", "a@example.com,", ",a@example.com", "a@example.com,,b@example.com", " a@example.com", "a@example.com ", + "a b@example.com", "a@example.com\n", "a@example.com,a@example.com", "\"a\"@example.com", "a\\b@example.com", + "é@example.com", String(repeating: "a", count: 255)] { + for key in ["ENGRAM_REMOTE_WEB_VIEWERS", "ENGRAM_REMOTE_WEB_EDITORS"] { + var environment = identityEnabled + environment[key] = list + XCTAssertThrowsError(try EngramRemoteWebConfig.fromEnvironment(environment, serverBearerCredentials: bearers), "\(key)=\(list)") { + XCTAssertEqual($0 as? EngramRemoteWebConfig.ConfigError, .invalidIdentityLogin) + } + } + } + var environment = identityEnabled + environment["ENGRAM_REMOTE_WEB_VIEWERS"] = String(repeating: "a", count: 254) + XCTAssertNotNil(try EngramRemoteWebConfig.fromEnvironment(environment, serverBearerCredentials: bearers)) + XCTAssertTrue(EngramRemoteWebConfig.isWellFormedLogin("zzbhlx@gmail.com")) + XCTAssertFalse(EngramRemoteWebConfig.isWellFormedLogin("")) + } + func testConfigurationErrorsNeverIncludeSubmittedCredentialsOrOrigin() { - for error in [EngramRemoteWebConfig.ConfigError.invalidEnabled, .missingOrigin, .invalidOrigin, .missingCredential, .credentialMustBeDistinct] { + for error in [EngramRemoteWebConfig.ConfigError.invalidEnabled, .missingOrigin, .invalidOrigin, .missingCredential, .credentialMustBeDistinct, + .invalidAuthMode, .credentialNotAllowedWithIdentity, .missingIdentityViewers, .invalidIdentityLogin, .identityRequiresLoopbackBind] { XCTAssertFalse(error.description.contains(viewer)) for bearer in bearers { XCTAssertFalse(error.description.contains(bearer)) } XCTAssertFalse(error.description.contains("viewer.example")) diff --git a/macos/EngramRemoteServerCoreTests/WebServerIntegrationTests.swift b/macos/EngramRemoteServerCoreTests/WebServerIntegrationTests.swift index 3a541a7b..97495246 100644 --- a/macos/EngramRemoteServerCoreTests/WebServerIntegrationTests.swift +++ b/macos/EngramRemoteServerCoreTests/WebServerIntegrationTests.swift @@ -132,6 +132,31 @@ final class WebServerIntegrationTests: XCTestCase { } } + func testEnvironmentIdentityModeRequiresLoopbackBindAndNoCredential() throws { + var identity = webEnvironment + identity["ENGRAM_REMOTE_WEB_VIEWER_CREDENTIAL"] = nil + identity["ENGRAM_REMOTE_WEB_AUTH"] = "tailscale-serve" + identity["ENGRAM_REMOTE_WEB_VIEWERS"] = "reader@example.com" + let loopback = try EngramRemoteServerConfig.fromEnvironment(identity) + XCTAssertEqual(loopback.web?.usesTailscaleServeIdentity, true) + XCTAssertEqual(loopback.webServiceSocketPath, socketPath) + for host in ["127.0.0.1", "127.8.8.8", "::1"] { + identity["ENGRAM_REMOTE_HOST"] = host + XCTAssertNoThrow(try EngramRemoteServerConfig.fromEnvironment(identity), host) + } + for host in ["0.0.0.0", "100.100.100.100", "fd7a:115c:a1e0::1", "localhost", "::", "127.0.0.1%lo0"] { + identity["ENGRAM_REMOTE_HOST"] = host + XCTAssertThrowsError(try EngramRemoteServerConfig.fromEnvironment(identity), host) { + XCTAssertEqual($0 as? EngramRemoteWebConfig.ConfigError, .identityRequiresLoopbackBind) + } + } + identity["ENGRAM_REMOTE_HOST"] = nil + identity["ENGRAM_REMOTE_WEB_VIEWER_CREDENTIAL"] = Self.viewer + XCTAssertThrowsError(try EngramRemoteServerConfig.fromEnvironment(identity)) { + XCTAssertEqual($0 as? EngramRemoteWebConfig.ConfigError, .credentialNotAllowedWithIdentity) + } + } + func testEnvironmentChecksEveryProvidedBearerIncludingDisabledArchiveAndMCP() throws { for key in ["ENGRAM_REMOTE_TOKEN", "ENGRAM_REMOTE_ARCHIVE_TOKEN", "ENGRAM_REMOTE_MCP_TOKEN"] { var environment = webEnvironment diff --git a/macos/EngramRemoteServerCoreTests/WebUIRoutesTests.swift b/macos/EngramRemoteServerCoreTests/WebUIRoutesTests.swift index 27436a48..d859db8f 100644 --- a/macos/EngramRemoteServerCoreTests/WebUIRoutesTests.swift +++ b/macos/EngramRemoteServerCoreTests/WebUIRoutesTests.swift @@ -178,6 +178,34 @@ final class WebUIRoutesTests: XCTestCase { XCTAssertFalse(js.contains("const generation = detail.lastParsed")) } + func testIdentityModeMarksTheDocumentAndTheScriptSignsInSilently() async throws { + let identity = try EngramRemoteWebConfig(origin: origin, tailscaleServeViewers: "reader@example.com", editors: nil) + let marked = try await bodyText(try await UIHarness(configuration: identity, tailscaleServeIdentity: true) + .respond(request(path: "/web", headers: [("Host", "viewer.example")]))) + XCTAssertEqual(marked.components(separatedBy: "data-auth-mode=\"tailscale-serve\"").count, 2) + XCTAssertTrue(marked.contains("")) + XCTAssertFalse(marked.contains("reader@example.com")) + XCTAssertFalse(containsInlineScript(marked)) + let plain = try await bodyText(try await UIHarness(configuration: configuration()) + .respond(request(path: "/web", headers: [("Host", "viewer.example")]))) + XCTAssertFalse(plain.contains("data-auth-mode")) + XCTAssertTrue(plain.contains("")) + XCTAssertTrue(marked.contains("")) + XCTAssertEqual(plain.replacingOccurrences(of: "", with: "") + .replacingOccurrences(of: "", with: ""), marked) + let js = try await bodyText(try await UIHarness(configuration: configuration()) + .respond(request(path: "/web/app.js", headers: [("Host", "viewer.example")]))) + XCTAssertTrue(js.contains("document.body.dataset.authMode === \"tailscale-serve\"")) + XCTAssertTrue(js.contains("async function identitySignIn(restorePage)")) + XCTAssertTrue(js.contains("await authWrite(\"POST\", \"{}\")")) + XCTAssertTrue(js.contains("if (response.status === 401) { await identitySignIn(false); return; }")) + XCTAssertTrue(js.contains("identityMode && Date.now() - identitySignInAt > 5000")) + XCTAssertTrue(js.contains("Your tailnet login is not permitted for this Web origin.")) + XCTAssertTrue(js.contains("\"Signed in as \" + access.login")) + XCTAssertTrue(js.contains("hidden = signedIn || identityMode")) + XCTAssertTrue(js.contains("hidden = !signedIn || identityMode")) + } + func testWrongHostIsForbiddenAndNonGetWebUIIsRejectedWithoutWeakeningCSP() async throws { let harness = try UIHarness(configuration: configuration()) let forbidden = try await harness.respond(request(path: "/web", authority: "evil.example", headers: [("Host", "evil.example")])) @@ -238,16 +266,18 @@ final class WebUIRoutesTests: XCTestCase { private struct UIHarness: Sendable { let boundary: WebRequestBoundary let sessions: WebAuthSessionStore + let tailscaleServeIdentity: Bool - init(configuration: EngramRemoteWebConfig) throws { + init(configuration: EngramRemoteWebConfig, tailscaleServeIdentity: Bool = false) throws { boundary = WebRequestBoundary(configuration: configuration) sessions = WebAuthSessionStore(configuration: configuration) + self.tailscaleServeIdentity = tailscaleServeIdentity } func respond(_ request: Request) async throws -> Response { let router = Router(context: UITestContext.self) WebAuthRoutes.mount(on: router, boundary: boundary, sessions: sessions) - WebUIRoutes.mount(on: router) + WebUIRoutes.mount(on: router, tailscaleServeIdentity: tailscaleServeIdentity) let responder = router.buildResponder() return try await WebRequestBoundary.Middleware( boundary: boundary, sessions: sessions diff --git a/tests/scripts/collector-web-ui.test.ts b/tests/scripts/collector-web-ui.test.ts index 518d888f..aa5ed83f 100644 --- a/tests/scripts/collector-web-ui.test.ts +++ b/tests/scripts/collector-web-ui.test.ts @@ -105,7 +105,10 @@ function shipped(kind: 'html' | 'css' | 'javascript'): string { return block; } -function harness(initialAuthCookie?: string) { +function harness( + initialAuthCookie?: string, + options: { authMode?: string } = {}, +) { const nodes = new Map(); const node = (id: string) => { let value = nodes.get(id); @@ -146,6 +149,10 @@ function harness(initialAuthCookie?: string) { }, document: { getElementById: node, + // Identity mode marks ; credential mode has no body here. + body: options.authMode + ? { dataset: { authMode: options.authMode } } + : undefined, createElement: (tag?: string) => { const element = new Element(); element.tagName = String(tag || 'div').toUpperCase(); @@ -4950,3 +4957,104 @@ describe('native AI settings editing', () => { await pending; }); }); + +describe('tailnet identity sign-in (design 2026-10-08)', () => { + const ticks = async (count = 24) => { + for (let step = 0; step < count; step += 1) await Promise.resolve(); + }; + + it('signs in silently from the Serve identity when the restore probe returns 401', async () => { + const ui = harness(undefined, { authMode: 'tailscale-serve' }); + expect(ui.node('login').hidden).toBe(true); + expect(ui.node('logout').hidden).toBe(true); + const pending = ui.call('restoreSession()'); + expect(ui.requests[0].path.startsWith('/web/api/sessions')).toBe(true); + ui.requests[0].resolve(undefined, 401); + await ticks(); + expect(ui.requests[1].method).toBe('POST'); + expect(ui.requests[1].path).toBe('/web/api/auth'); + expect(ui.requests[1].body).toBe('{}'); + ui.requests[1].resolve(undefined, 204); + await ticks(); + expect(ui.requests[2].path.startsWith('/web/api/sessions')).toBe(true); + ui.requests[2].resolve({ + snapshotId: 'snapshot', + items: [{ sessionId: 's1', title: 'Resolved', source: 'codex' }], + }); + await ticks(); + expect(ui.requests[3].path).toBe('/web/api/overview?limit=2'); + ui.requests[3].resolve({}, 503); + await ticks(); + expect(ui.requests[4].method).toBe('GET'); + expect(ui.requests[4].path).toBe('/web/api/auth'); + ui.requests[4].resolve({ canWrite: false, login: 'reader@example.com' }); + await pending; + expect(ui.requests).toHaveLength(5); + expect(ui.node('status').textContent).toBe( + 'Signed in as reader@example.com', + ); + expect(ui.node('workspace').hidden).toBe(false); + expect(ui.node('login').hidden).toBe(true); + expect(ui.node('logout').hidden).toBe(true); + expect(ui.node('sessions').textContent).toContain('Resolved'); + }); + + it('shows the not-permitted message on 403 without ever showing the credential form', async () => { + const ui = harness(undefined, { authMode: 'tailscale-serve' }); + const pending = ui.call('restoreSession()'); + ui.requests[0].resolve(undefined, 401); + await ticks(); + ui.requests[1].resolve(undefined, 403); + await pending; + await ticks(); + expect(ui.requests).toHaveLength(2); + expect(ui.node('status').textContent).toBe( + 'Your tailnet login is not permitted for this Web origin.', + ); + expect(ui.node('workspace').hidden).not.toBe(false); + expect(ui.node('login').hidden).toBe(true); + }); + + it('re-mints once after an authenticated read expires and holds a five-second floor', async () => { + const ui = harness(undefined, { authMode: 'tailscale-serve' }); + const first = ui.call('api("GET", "/web/api/overview")').catch(() => {}); + ui.requests[0].resolve(undefined, 401); + await first; + await ticks(); + expect(ui.node('status').textContent).toBe('Session expired'); + expect(ui.requests[1].method).toBe('POST'); + expect(ui.requests[1].path).toBe('/web/api/auth'); + ui.requests[1].resolve(undefined, 204); + await ticks(); + expect(ui.requests[2].path.startsWith('/web/api/sessions')).toBe(true); + ui.requests[2].resolve({ snapshotId: 'snapshot', items: [] }); + await ticks(); + expect(ui.requests[3].path).toBe('/web/api/overview?limit=2'); + ui.requests[3].resolve({}, 503); + await ticks(); + expect(ui.requests[4].path).toBe('/web/api/auth'); + ui.requests[4].resolve({ canWrite: true, login: 'editor@example.com' }); + await ticks(); + expect(ui.node('status').textContent).toBe( + 'Signed in as editor@example.com', + ); + const before = ui.requests.length; + const second = ui.call('api("GET", "/web/api/overview")').catch(() => {}); + ui.requests[before].resolve(undefined, 401); + await second; + await ticks(); + expect(ui.requests).toHaveLength(before + 1); + expect(ui.node('status').textContent).toBe('Session expired'); + }); + + it('keeps the credential form in credential mode and never posts an identity login', async () => { + const ui = harness(); + const pending = ui.call('restoreSession()'); + ui.requests[0].resolve(undefined, 401); + await pending; + await ticks(); + expect(ui.requests).toHaveLength(1); + expect(ui.node('login').hidden).not.toBe(true); + expect(ui.node('status').textContent).toBe(''); + }); +});