{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"8564b523-aa6a-450c-b1df-843c829cb15d","kind":"shape:chain","nodes":[{"acceptedSubmissionHash":"d48a6228a64986a20d3027fc0bcfde8ea369d11f53688df2c56251cb3831d083","dependsOn":["implement_component","build_website","site_content_check"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"6b037a7b6601e883cf8a906c1520c0624817d42d8310b65c2f43679204608af3","skillId":"adversarial-review","tools":[]},"key":"adversarial_review","kind":"code","role":"review","skillHash":"6b037a7b6601e883cf8a906c1520c0624817d42d8310b65c2f43679204608af3","skillId":"adversarial-review","state":"accepted"},{"acceptedSubmissionHash":"1e4a7148f988ac215da486a864989cc9a8743b8b657a7e3b6b97686dbd01089a","dependsOn":["implement_component"],"execution":{"mustProduce":["dist/index.html"],"network":true,"optionalTools":["browser"],"profile":"none","requires":["network"],"skillHash":"a60bc8b42446c13d993341179fe13c712a5addb118fc532fb2d15e71f912efc8","skillId":"build-website","tools":[]},"key":"build_website","kind":"code","role":"implement","skillHash":"a60bc8b42446c13d993341179fe13c712a5addb118fc532fb2d15e71f912efc8","skillId":"build-website","state":"accepted"},{"acceptedSubmissionHash":"f2c5f1eacc016838d48dad07d9a891db85ba7a62ca970fa71d71fcbfb0b0fc8b","dependsOn":["implement_component","build_website"],"execution":{"network":true,"optionalTools":["browser"],"profile":"none","requires":["network"],"skillHash":"fd33b6741366d364a55a4c0ba2bc4f5d329cec0b62f27a83289dc5b30ab03575","skillId":"design-critic","tools":[]},"key":"design_critic","kind":"code","role":"review","skillHash":"fd33b6741366d364a55a4c0ba2bc4f5d329cec0b62f27a83289dc5b30ab03575","skillId":"design-critic","state":"accepted"},{"acceptedSubmissionHash":"aee9d697537a77d7d288776efe7413ee61a767d8d5a04d237343bba6c1427a9a","dependsOn":[],"execution":{"network":true,"profile":"none","requires":["network"],"skillHash":"15deecb0f930dd3c37b4e551121fe447b9d43818640ca22223cf3c7951e0db98","skillId":"implement-component","tools":[]},"key":"implement_component","kind":"code","role":"implement","skillHash":"15deecb0f930dd3c37b4e551121fe447b9d43818640ca22223cf3c7951e0db98","skillId":"implement-component","state":"accepted"},{"acceptedSubmissionHash":"72dff12cfbcb984e9d66d45c982937731982ffc60478e105ca802d817981de26","dependsOn":["implement_component","build_website"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"8189a3059fc9bb774ad7dcee66b87f32e25d781025f0e9f82a4c75182fe0ef19","skillId":"site-content-check","tools":[]},"key":"site_content_check","kind":"code","role":"review","skillHash":"8189a3059fc9bb774ad7dcee66b87f32e25d781025f0e9f82a4c75182fe0ef19","skillId":"site-content-check","state":"accepted"}],"objective":"STRATOKS — FIX VISITOR AUTHENTICATION DELIVERY BEFORE PRODUCTION\n\nRepair the existing delivery from job a5886b35-7174-42da-9056-421ecd5001df. Base: https://github.com/identity-md-launches/launch-2725-stratoks-restore-wallet at 4671102884260ac798821bd32bfad7f462aacf7f. Preserve the current Expanse world and walletless viewing. This is source, frontend preview and owner-run backend delivery; do not access or deploy to production.\n\n1. RECOVERABLE SESSION CHECKS\nshared/visitor-auth-client.js currently destroys a valid session and queues logout on any refresh error. Retain the credential on 429, 5xx, offline and timeout, show a temporary unverified/unavailable state, and retry with bounded backoff/jitter and Retry-After support. Only an authoritative invalid-session response ends that session. Never represent an unconfirmed session as freshly verified. Explicit logout and account/network/provider/world changes must still clear obsolete auth immediately and revoke safely. Prevent stale responses and duplicate refreshes; preserve retry/cancel flows.\n\n2. PROXY-AWARE RATE LIMITS\nThe server keys all visitors on the Caddy connection IP; about 30 tabs exhaust the shared 60/min bucket. Add explicit owner-configured trusted proxies and correct client address extraction ONLY through that trust boundary. Ignore forged forwarded headers from untrusted peers; document Caddy header sanitization and private backend binding. Separate bounded challenge/verify abuse limits from authenticated session polling. Keep global/body/concurrency limits. Test 50 verified sessions behind a trusted proxy polling at the shipped cadence while another visitor signs in; no false expiration or unfair shared-IP lockout.\n\n3. BOUNDED PROFILES AND SESSIONS WITHOUT PERMANENT LOCKOUT\nThrowaway addresses currently fill the durable 10,000-profile store forever; one signer can consume all 2,000 sessions. Add a documented bounded retention/admission policy that recovers at capacity, with per-peer/time signup controls and expiry/eviction of inactive records as appropriate. Do not promise Sybil-proof identity. Preserve retained profiles atomically across restart and disclose retention. Bound active sessions per address and origin; new sessions cannot monopolize capacity. Test small configured limits, many distinct disposable signers, repeated one-signer sessions, expiry and restart recovery. Never silently erase world or resident memory.\n\n4. SIGNED MESSAGE AND CANCELLATION\nValidate world identity against the actual server's structured format, not an assumed UUID (existing world IDs may be world-20260101-0). Reject control characters/newlines and malformed fields in world/domain/origin/URI before opening a wallet prompt; reconstruct and verify the exact server-issued message. Keep address/chain/origin/world/nonce/expiry binding and replay protection. Publicly displayed nonce must not be a revocation credential for an established session. Use a separate unpredictable cancellation secret or an equivalently secure design to cancel late verification without introducing account-switch races. test cancellation before/during/after verification and wrong-secret/origin attempts.\n\n5. REPRODUCIBLE DEPLOYMENT\nFix Docker COPY ownership/permissions so USER node can read modules even from umask 077 checkouts. Run import checks as that runtime user; retain shared/ modules. Provide correct Expanse Caddy/compose routing on 8788, including /api/auth/*, /api/expanse/* and static assets; remove legacy 8787 guidance. Explicit AUTH_URI/origin configuration, private profile volume, backup and rollback instructions. Build AND start linux/arm64 image on native ARM or documented emulation; probe health/config and disposable-signer login/logout.\n\n6. UI AND REGRESSIONS\nRemove repeated unavailable-backend copy and duplicate screen-reader status announcements. Preserve EIP-6963 provider choice, legacy fallback, current HUD, inspector, camera and mobile controls. Separate wallet connection from server authentication. No approvals, transfers, contracts, paid gameplay or new token. Preserve population, identities, brain weights, goals, signals, terrain, schema and live configuration. Demo must remain clearly labelled.\n\nDELIVER AND PROVE\nAdd regression tests reproducing each defect before the fix and passing after. Run existing backend/client/typecheck/build suites and real HTTP crypto tests. Exercise browser login/logout, transient failures, account/network switch races, 320/390px mobile layout and anonymous viewing with disposable signers; distinguish simulated providers from actual extensions/devices. Verify copied-world load/save/restart without changing simulation behavior. Publish updated source and static preview, API/setup/rollback docs, raw test evidence and a finding-to-fix matrix with commit/file/test references. List unresolved issues explicitly; accepted task status alone is not evidence they are fixed.","parentJobId":null,"planHash":"fe40896697150466edd01de66ca6e608ce1e794b8e4149f79a1196b9072352a4","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"8564b523-aa6a-450c-b1df-843c829cb15d","publication":{"commit":null,"deliveredAt":null,"repoUrl":"https://github.com/identity-md-launches/launch-2771-stratoks-fix-visitor"},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52599","feedbackHash":"2af72c41a09ff609b5ab978d89ecc89310ebdd20888ebcf204bffa9230e8107f","nodeKey":"adversarial_review","submissionHash":"d48a6228a64986a20d3027fc0bcfde8ea369d11f53688df2c56251cb3831d083","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51482","feedbackHash":"3cfd8a812b5845a14004084805f39c8622853d193023e88167dde20c75a3172a","nodeKey":"adversarial_review","submissionHash":"0c9ced1051b5745f7369ac050dc7a80465702d214a7947087d822f2a8aca590d","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50957","feedbackHash":"750543b0b6affa06e8fa2de2bd9d48d38271565ac7f7ed6f94873e2f5c41414b","nodeKey":"build_website","submissionHash":"1e4a7148f988ac215da486a864989cc9a8743b8b657a7e3b6b97686dbd01089a","tag1":"verification:structural","tag2":"acceptance-v2","value":1},{"agentId":"51439","feedbackHash":"3696c4c6d1b30494c8bd3a92bb721c0c009bfa91f398937c1dbd40c8e86839d3","nodeKey":"build_website","submissionHash":"b356e4c8e59763c7838886f2d3b2e9c85577b73ecb78b1df3294b2f0051c7400","tag1":"verification:structural","tag2":"acceptance-v2","value":1},{"agentId":"52185","feedbackHash":"af0398c473307797a5722c8dea188d084f1843ec836cd3bfca55d543104fc509","nodeKey":"design_critic","submissionHash":"e138004c54aa59dfcaf8ba69771473ef84ac365520a739e0ef83bebea7bc73c5","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50962","feedbackHash":"6fb20e47a4e03a95862980f53188efdb53f2411e0eded908e83973089ba5b94d","nodeKey":"design_critic","submissionHash":"f2c5f1eacc016838d48dad07d9a891db85ba7a62ca970fa71d71fcbfb0b0fc8b","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51236","feedbackHash":"c47bce66309857ef4c3b46621716b52ec1cddddfdd349a9ab0825d3837d18eea","nodeKey":"implement_component","submissionHash":"792ff22d124b4fd981dae4ec3d94e34bde1afa8c08e3711f946838922b8cbf2e","tag1":"verification:structural","tag2":"acceptance-v2","value":1},{"agentId":"51880","feedbackHash":"645699e28bf1d3653b06764e9e23401de6a0dd4b4bfbb04382c642045b8bc2ce","nodeKey":"implement_component","submissionHash":"aee9d697537a77d7d288776efe7413ee61a767d8d5a04d237343bba6c1427a9a","tag1":"verification:structural","tag2":"acceptance-v2","value":1},{"agentId":"51244","feedbackHash":"096970ce78adb4a3b55c9a51a94dfe1a79160b1e3c5ca1e0649e7f32ca9dfeeb","nodeKey":"site_content_check","submissionHash":"9734c4fbc19d8c6c9aca5582f758a44d9f466a251a85c5e7271a38ef546a3371","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52422","feedbackHash":"9e0af5d8247f78d369ab1610287e84debc27cee31636c7533e42c5bb58696b02","nodeKey":"site_content_check","submissionHash":"72dff12cfbcb984e9d66d45c982937731982ffc60478e105ca802d817981de26","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"7a92a335ce7a7cfc1ad42592e6b24ae8454832545ef7430042ae7fa8112d3500","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dedd4428da8fa41e","findings":[{"citation":"resolved","description":"rate() increments the single global counter for every /api/auth/* request before (and regardless of) any per-peer check, and GET /api/auth/config, /session, /logout and OPTIONS have no per-peer limit at all (protectedRoute=false at line 244). Requests that are already per-peer rejected also count globally. So a single client IP sending 600 cheap requests per minute (10 req/s) uses the whole 600/min global budget. Every other visitor's challenge/verify gets 429 rate_limited, and every verified tab's 30 s session poll gets 429 and goes 'unavailable'. This is exactly the unfair shared lockout requirement 2 forbids, and the per-peer separation does not protect against it.","line":95,"path":"server/src/expanse/visitor-auth.js","reproduction":"new VisitorAuth({dataDir:tmp, uri:'https://world.example/'}) behind http.createServer((q,r)=>handleVisitorAuth(q,r,auth)). From 127.0.0.1 send 600x GET /api/auth/config (all 200). Then a different visitor POSTs /api/auth/nonce {address:<fresh EOA>,chainId:1} with X-Stratoks-Origin https://world.example. Expected: 200 challenge (the attacker is a different peer and the global ceiling should not be consumable by one peer). Actual: 429 {\"version\":1,\"error\":\"rate_limited\"}. Observed locally: C attacker {\"200\":600} then 'C other visitor nonce: 429 rate_limited'.","severity":"high","snippet":"    if (++this.global.count > this.cfg.globalRequests) throw fail(429, 'rate_limited');","title":"One unauthenticated peer exhausts the shared global ceiling and locks every visitor out of sign-in and session checks"},{"citation":"resolved","description":"Eviction (lines 152-156) only removes profiles whose lastVerifiedAt is older than profileRetentionMs (default 90 days). Inactive, logged-out throwaway profiles from the last 90 days are never evicted. With perPeerSignups=10/min, one IP fills the default 10,000 store in about 17 hours, or in minutes from many IPs. After that every first-time visitor gets profile_store_full for up to 90 days, with no operator action short of editing private data. Requirement 3 asks for a policy that 'recovers at capacity' with expiry/eviction of inactive records, and this one doesn't. The existing test only proves recovery with profileRetentionMs:500.","line":157,"path":"server/src/expanse/visitor-auth.js","reproduction":"let t=1e12; auth=new VisitorAuth({dataDir:tmp, uri:'https://world.example/', maxProfiles:3, now:()=>t}); log in 3 distinct random disposable signers (issue+signForTests+verify) and log each out. t += 7 days. Log in a 4th new signer. Expected: admission (oldest inactive disposable evicted or otherwise recovered). Actual: 429 profile_store_full. It still fails until t += 96 days (observed: 'profile_store_full' at 1 week, 'ok' only after 96 days).","severity":"high","snippet":"    if (!next.has(c.address) && next.size >= this.cfg.maxProfiles) throw fail(429, 'profile_store_full');","title":"Profile store at capacity stays closed to every new visitor for 90 days; retention policy never recovers"},{"citation":"resolved","description":"The usual deployment has one origin (the AUTH_URI origin), so maxSessionsPerOrigin=500 caps all sessions there, not maxSessions=2000. Sessions are never evicted before their 1 h TTL. An attacker with 167 disposable addresses x 3 sessions (about 17 min of signups from one peer at 10/min, faster from several) fills it. Every honest new login then gets session_store_full for the full hour, and the attacker can keep refilling. Requirement 3 says 'new sessions cannot monopolize capacity'; the per-origin bound adds no fairness because all honest users share that origin.","line":147,"path":"server/src/expanse/visitor-auth.js","reproduction":"let t=1e12; auth=new VisitorAuth({dataDir:tmp, uri:'https://world.example/', maxSessionsPerOrigin:6, now:()=>t}); 2 disposable signers each verify 3 sessions (6 total). A third honest signer verifies. Expected: admitted, or the attacker's oldest session evicted. Actual: 429 session_store_full; still session_store_full at t+3,599,000 ms (observed).","severity":"medium","snippet":"    if (this.sessions.size >= this.cfg.maxSessions || addressSessions >= this.cfg.maxSessionsPerAddress || originSessions >= this.cfg.maxSessionsPerOrigin) throw fail(429, 'session_store_full');","title":"Per-origin cap (500) is the real global session cap on single-origin deployments and can be held by 167 disposable addresses with no eviction"},{"citation":"resolved","description":"Per-peer request buckets and signup:<ip> buckets share one 1000-key map. Each first challenge from a new address creates two keys. Behind a trusted proxy, 500 distinct client addresses per minute fill it, which is trivial with one IPv6 /64 since addresses are keyed exactly with no prefix aggregation. Every new honest peer then gets rate_store_full for the rest of the window, so the attack can be repeated each minute. This is another shared lockout under requirement 2.","line":99,"path":"server/src/expanse/visitor-auth.js","reproduction":"auth=new VisitorAuth({..., trustedProxies:['127.0.0.1']}); send 500 POST /api/auth/nonce with X-Forwarded-For 2001:db8::0 .. 2001:db8::1f3 (one fresh EOA each). Then POST /api/auth/nonce with X-Forwarded-For 198.51.100.7. Expected: 200. Actual (observed): 429 {\"version\":1,\"error\":\"rate_store_full\"}.","severity":"medium","snippet":"      if (this.rates.size >= this.cfg.maxRateKeys) throw fail(429, 'rate_store_full');","title":"Rate-bucket store is shared and fills from 500 distinct client addresses, blocking every new peer with rate_store_full"},{"citation":"resolved","description":"rate() throws before the Access-Control-Allow-Origin block (lines 249-252), and the early auth_busy 503 (line 231) never sets it either. So for an AUTH_ALLOWED_ORIGINS preview, global 429s look like a network TypeError in the browser. No response sets Access-Control-Expose-Headers: Retry-After, so even a readable 429 (for example signup_rate_limited) hides Retry-After from shared/visitor-auth-client.js request(). The client then backs off about 1 s instead of the server's 60 s and keeps hitting an already throttled service. (Specialist finding; confirmed by source: there is no expose-headers anywhere, and CORS headers are set after rate().)","line":244,"path":"server/src/expanse/visitor-auth.js","reproduction":"Serve the shared client from an allowed origin that differs from the API. With perPeerSignups:1, POST /api/auth/nonce twice from the browser: the second is 429 signup_rate_limited but response.headers.get('retry-after') is null. Exhaust globalRequests, then fetch /api/auth/config cross-origin: Expected readable 429 with Retry-After:60. Actual: 'TypeError: Failed to fetch', with no Access-Control-Allow-Origin on the raw response. refreshSession() then records retryAfterUntil:0 and retryAt about 1 s ahead.","severity":"medium","snippet":"    auth.rate(auth.clientAddress(req), protectedRoute); // login abuse has per-peer limits; every route retains a global ceiling","title":"Allowed cross-origin clients cannot read 429/503 responses or Retry-After, so the Retry-After cooldown only works same-origin"},{"citation":"resolved","description":"Revocation runs only in React effect cleanup. A page reload or navigation does not run it, and there is no pagehide/keepalive revocation. The bearer and cancellation secret live only in memory, so the server session stays live for the 1 h TTL with no way to revoke it. The UI text at line 115 ('This session ends on expiry, reload or server restart') is false for the server side. With maxSessionsPerAddress:3, the fourth correctly signed login after three reloads is rejected with session_store_full. The UI suggests 'Wait a minute and retry' (line 15) even though the slots stay held for up to an hour, and offers no recovery for a freshly proven signer. (Specialist finding; confirmed by source: no unload handler in web/src/expanse or shared/.)","line":54,"path":"web/src/expanse/VisitorWallet.tsx","reproduction":"With the default limits, sign in with one disposable EOA, reload without Sign out, then reconnect and sign in again; repeat. Server session count goes 1, 2, 3, and the first token still validates after reload. The 4th /api/auth/verify returns 429 {version:1,error:'session_store_full'}. Expected: reload ends the session as the UI states, or the proven signer can replace its oldest session.","severity":"medium","snippet":"    return () => {","title":"Reload abandons live server sessions; after 3 reloads the same wallet cannot sign in for up to an hour, and the UI says the session ends on reload"},{"citation":"resolved","description":"--chown=node:node with mode 0755 makes the code readable for USER node, as intended, but node also owns it and can write it. Any code-execution bug in the service could then persist changes to server/ and shared/ modules inside the container. Readability only needs root:root ownership with 0755 (or a+rX), which keeps the umask-077 fix.","line":6,"path":"deploy/expanse/Dockerfile","reproduction":"docker build -f deploy/expanse/Dockerfile -t t . && docker run --rm t sh -c 'touch server/src/expanse/x && echo writable'. Expected: permission denied. Actual by Dockerfile semantics: 'writable'. Not executed here, because a Docker daemon was not used in this review.","severity":"low","snippet":"COPY --chown=node:node --chmod=0755 server/ ./server/","title":"Runtime user owns and can write the application code in the image"},{"citation":"resolved","description":"The documented same-origin block uses a bare 'reverse_proxy 127.0.0.1:8788' for /api/*. Line 55 says 'The Caddy rule above overwrites X-Forwarded-For with the TCP peer address', but only deploy/Caddyfile has header_up X-Forwarded-For {remote_host}. With AUTH_TRUSTED_PROXIES set, safety then depends on Caddy's default handling. That default stops being safe as soon as the owner configures Caddy trusted_proxies (for example behind a CDN): the leftmost client-controlled XFF entry passes through, and clientAddress() takes split(',')[0].","line":67,"path":"docs/auth/SETUP.md","reproduction":"Deploy the SETUP.md block with Caddy global trusted_proxies set to the CDN range and AUTH_TRUSTED_PROXIES set to the Caddy peer. A client sends 'X-Forwarded-For: 203.0.113.<n>' with a new n on each request. Expected: one per-peer bucket. Actual: a new bucket each time (per-peer challenge limit bypassed).","severity":"low","snippet":"```caddy","title":"SETUP.md Caddy example lacks the X-Forwarded-For overwrite that the next paragraph says it contains"},{"citation":"resolved","description":"By its own docs (SETUP.md 'ARM runtime evidence is unresolved'; FINDINGS.md Docker row is 'source review' only), the delivery has no linux/arm64 build+start, health/config probe or disposable-signer login in the container. Only simulated EIP-1193/6963 providers were used. No test exercises the 50-session trusted-proxy poll while an attacker consumes the global bucket (finding 1), or cross-origin error responses. These acceptance items stay open and should not be read as fixed.","line":55,"path":"docs/auth/SETUP.md","reproduction":null,"severity":"info","snippet":"For an owner deployment, `deploy/expanse/compose.yml` forwards `AUTH_URI`, `AUTH_ALLOWED_ORIGINS`, `AUTH_CHAINS`, and `AUTH_TRUSTED_PROXIES`. The latter is a comma-separated exact socket peer address allowlist and defaults empty. Configure it only when Caddy reaches the backend from a stable private address. The Caddy rule above overwrites `X-Forwarded-For` with the TCP peer address; never append or pass through client-supplied forwarding values. Keep the backend bound privately (compose publishes host loopback 8788). Challenge/verify use the trusted client address for their per-peer limit; session checks retain a global ceiling but do not consume the sign-in bucket.","title":"Acceptance items still unverified: ARM64 image build/start, real wallet extensions/devices, cross-origin browser tests"}],"hash":"0c9ced1051b5745f7369ac050dc7a80465702d214a7947087d822f2a8aca590d","nodeId":"79b0f88c-b1a6-42e2-9544-5db8634fcb69","outcome":"completed","summary":"I found 9 problems and wrote them to `.imd-findings.json`: 2 high, 4 medium, 2 low and 1 note on untested items. I reproduced the first four in Node against the current code, using the real HTTP handler, the real signature check and throwaway signers. The two medium findings from the earlier reviewers hold up on reading the code, but I didn't re-run their browser tests. The two low findings come from reading the code and docs only. No repository files were changed except the findings file.\n\n| # | Severity | Problem | How to trigger it | What happened |\n|---|---|---|---|---|\n| 1 | high | **One peer can lock everyone out of sign-in.** Every `/api/auth/*` request counts toward the single 600/min global limit (`visitor-auth.js:95`), and only challenge/verify have a per-peer limit. | One IP sends 600× `GET /api/auth/config`; then a different visitor asks for a sign-in challenge. | The other visitor gets 429 `rate_limited`, and every signed-in tab's 30-second session check also fails. |\n| 2 | high | **A full profile store stays closed for 90 days.** Only profiles older than 90 days can be removed (`:157`), so recently created throwaway profiles fill it. At 10 sign-ups per minute, one IP fills the default 10,000 in about 17 hours. | `maxProfiles:3`, three throwaway signers log in and out, wait 7 days, then a new signer tries. | `profile_store_full`; it only works again after 96 days. |\n| 3 | medium | **Sessions can be monopolised.** With one origin, the real limit is the 500-per-origin cap, not 2,000 (`:147`). Nothing is evicted before the 1-hour expiry. | `maxSessionsPerOrigin:6`, two addresses × 3 sessions, then an honest visitor signs in. | `session_store_full`, still the case 59m59s later. |\n| 4 | medium | **The rate-limit table fills up.** Per-peer and sign-up counters share one 1,000-entry table (`:99`). | 500 sign-in challenges behind a trusted proxy, each with a different IPv6 address, then an honest client. | 429 `rate_store_full`. |\n| 5 | medium | **Cross-origin previews can't see rate-limit responses or Retry-After** (earlier reviewer's finding, confirmed in the code). Rate checks run before the CORS headers are added (`:244`), and `Retry-After` is never exposed to the browser. | An allowed preview origin on a different port hits a rate limit. | The browser sees a network error, and the client retries after about 1 second instead of 60. |\n| 6 | medium | **Reloading the page leaves server sessions running** (earlier reviewer's finding, confirmed in the code). Sign-out only runs when the React component unmounts (`VisitorWallet.tsx:54`); there is no page-unload handler. The UI says the session \"ends on … reload\", which is wrong for the server. | Sign in and reload 3 times with the same wallet. | The 4th sign-in gets `session_store_full`, and the UI says \"Wait a minute\" although the slots stay held for up to an hour. |\n| 7 | low | **The runtime user owns and can write the app code** (`deploy/expanse/Dockerfile:6`). Making the code owned by root but readable would keep the permissions fix. | — | Not run: I didn't use Docker in this review. |\n| 8 | low | **`SETUP.md:67` has the wrong Caddy example.** It doesn't overwrite `X-Forwarded-For`, though the text says it does. If the owner sets Caddy `trusted_proxies`, a client could forge the header and dodge the per-peer limit. | — | From reading the docs and config. |\n| 9 | info | **Acceptance items still untested:** the linux/arm64 image build and start (the docs themselves say this is unresolved), real wallet extensions and devices, cross-origin browser error paths, and the 50-session polling test while an attacker uses up the global limit. | — | — |\n\nThings I checked that looked correct:\n- Server-side checks on challenge fields and the exact signed message.\n- Single-use nonces.\n- Cancellation needs the secret, and the public nonce alone can't end a session.\n- Logout and cancel are tied to the origin.\n- The client keeps the credential on 429, 5xx and timeouts, and only ends the se","treeHash":null,"usage":{"cachedInputTokens":783222,"inputTokens":33,"model":"claude-opus-5-5","outputTokens":18795,"runtime":"claude","turns":18,"wallClockMs":225651}},{"artifacts":[],"attempt":1,"bundleHash":"6da5fdba0c4bfa0d797a8b977dbe9ddef20abf8b2fe0e9dbf69d491e1a418e71","device":"0256823ae36e7900","findings":[],"hash":"1e4a7148f988ac215da486a864989cc9a8743b8b657a7e3b6b97686dbd01089a","nodeId":"80ace8cf-478e-448b-b640-0f3b7fd81ce9","outcome":"completed","summary":"Corrected reload/privacy and session-limit messages, updated documentation, and rebuilt `dist/`.\n\nPassed: 248 backend tests, 51 frontend tests, 35 HUD browser scenarios, typecheck, build, and 16/16 design checks.\n\nAutomatic reload revocation remains disputed and unimplemented; surviving sessions are now disclosed accurately. Safari and real wallets remain unverified.\n\n[Validation evidence](artifacts/visitor-recovery/revision/VALIDATION.md) · [Finding responses](.imd-responses.json)","treeHash":"0dc1faac562b24b471dd25a02ee1615d0665cda0","usage":{"cachedInputTokens":3480704,"inputTokens":111862,"model":"gpt-6.1-sol","outputTokens":23954,"runtime":"codex","turns":7,"wallClockMs":666939}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e35a3abed04af1ac","findings":[{"citation":"resolved","description":"The prior Retry-After finding is only partially fixed. Allowed-origin actual responses now expose Retry-After correctly, including signup 429s. However, auth.rate still runs before the OPTIONS handler, and auth_busy returns before that handler too. The exported client in dist/assets/index-CJb5861Y.js:9 (shared/visitor-auth-client.js:66-68) sends X-Stratoks-Origin and Authorization, requiring a CORS preflight. When that OPTIONS request receives 429/503, Chromium rejects fetch before exposing any response headers, regardless of Access-Control-Allow-Origin or Access-Control-Expose-Headers. The credential remains retained and unconfirmed, but the advertised 60-second cooldown is lost and the client retries after about one second. tests/expanse-auth.test.js only checks raw HTTP headers, which does not exercise this browser failure. Handle valid allowlisted preflights so the actual throttled response and its Retry-After can reach the client, while retaining appropriate abuse bounds.","line":273,"path":"server/src/expanse/visitor-auth.js","reproduction":"Serve the unmodified shared client from one loopback port and handleVisitorAuth/VisitorAuth from another. Configure localDemo:true, uri:<API>/, allowedOrigins:[<PREVIEW>], globalRequests:20 (all other request limits can stay default). In Chromium with no Playwright request interception and no disabled web security, instantiate VisitorAuthClient with baseUrl:<API>, uri:<API>/ and origin:<PREVIEW>; connect a simulated EIP-1193 provider backed by a disposable real EOA and call signIn(). This returns verified through real HTTP signature verification. Make 20 Node GET requests to <API>/api/auth/config with Origin:<PREVIEW> to exhaust the global bucket; then call client.refreshSession() in the browser before any successful /session preflight. Observed: the server receives OPTIONS /api/auth/session and returns 429, Access-Control-Allow-Origin:<PREVIEW>, Retry-After:60; no GET follows. Browser state is unavailable, token retained, retryAfterUntil:0, retryAt approximately 835 ms ahead. Expected: readable throttling and retry delayed at least 60 seconds. Separately setting auth.activeRequests=auth.cfg.maxConcurrentRequests causes OPTIONS /api/auth/session to return 503 auth_busy with Retry-After:1, likewise unreadable. A simple cross-origin GET without custom headers and the signup-limit actual POST both now expose Retry-After:60, confirming the partial fix.","severity":"medium","snippet":"    auth.rate(auth.clientAddress(req), protectedRoute); // login abuse has per-peer limits; every route retains a global ceiling","title":"Cross-origin cooldown remains unreadable when a required preflight is throttled"},{"citation":"resolved","description":"The reload finding remains reproducible; only its misleading wording was fixed. The shipped HUD in dist/assets/index-CJb5861Y.js:9, built from web/src/expanse/VisitorWallet.tsx, still loses its memory-only credential on navigation without revoking the server session. After three ordinary sign-in/reload cycles, this per-address check rejects a fourth correctly signed login before any admission recovery can occur. No tab retains a credential to use the suggested sign-out action, and the account remains unable to authenticate until an abandoned session expires (almost one hour at defaults) or the operator restarts the server. The revised reason/privacy text truthfully discloses this behavior and the revised error no longer promises one-minute recovery; those copy defects are settled. However, disclosure does not address the reported recurring lockout. A bounded recovery/admission mechanism for a freshly proven signer is still needed; best-effort navigation cleanup alone cannot cover offline closure.","line":157,"path":"server/src/expanse/visitor-auth.js","reproduction":"Run the shipped startHudDemo({port:0}) from deploy/auth-demo/hud.mjs with no auth-limit overrides. Open its /preview/ in Chromium at 390x844 using a simulated EIP-6963/EIP-1193 provider backed by one disposable real EOA. Connect and sign in through the actual HTTP verifier, then perform a real page.reload() without Sign out; repeat three times. Measured server session counts after each reload are 1, 2, 3; GET /api/auth/session with the first credential still returns 200 after every reload. Reconnect the same account and submit a fourth valid signed challenge: /api/auth/verify returns 429 {version:1,error:\"session_store_full\"}, UI says Connected · not signed in, and there are zero Sign out buttons. The three remaining session lifetimes measured 3,596,370–3,598,811 ms. Expected: ordinary reloads do not strand all slots, or the freshly proven same signer can recover a bounded session without waiting for the default one-hour expiry. Also ran the author’s two reload cases in tests/browser/visitor-recovery.mjs: both pass their updated disclosure assertions while explicitly confirming these live abandoned sessions and the fourth-login rejection. Their clock-advanced expiry test does recover capacity; no permanent-lockout or false-copy claim is being repeated.","severity":"medium","snippet":"    if (addressSessions >= this.cfg.maxSessionsPerAddress) throw fail(429, 'session_store_full');","title":"Reloading still exhausts the wallet session allowance without recoverable credentials"}],"hash":"72dff12cfbcb984e9d66d45c982937731982ffc60478e105ca802d817981de26","nodeId":"22585e18-6f2c-46c5-9835-9ff700b8ef75","outcome":"completed","summary":"Wrote [.imd-findings.json](/home/imd6/.identitymd/work/8564b523-aa6a-450c-b1df-843c829cb15d/22585e18-6f2c-46c5-9835-9ff700b8ef75/.imd-findings.json) with two remaining medium findings:\n\n- Throttled CORS preflights still hide `Retry-After`.\n- Reloads still exhaust the three-session allowance; corrected wording accurately discloses this.\n\nNo critical/high findings. No transaction/approval flows or public secrets found.\n\nPassed: 248 backend, 51 frontend and 35 browser checks, typecheck, and byte-identical export rebuild. Simulated wallets used real signatures. Project source unchanged; no production access.","treeHash":null,"usage":{"cachedInputTokens":4528896,"inputTokens":166750,"model":"gpt-6-astra","outputTokens":22925,"runtime":"codex","turns":8,"wallClockMs":710342}},{"artifacts":[],"attempt":1,"bundleHash":"f65e29794ebdfa8d8fb72374a3d57169c32ee012184b19acf30c11fc9298a33f","device":"08261d0cc6850daf","findings":[],"hash":"792ff22d124b4fd981dae4ec3d94e34bde1afa8c08e3711f946838922b8cbf2e","nodeId":"f2b0f3b3-639f-49ae-8dfd-9f4036728883","outcome":"completed","summary":"Implemented the assigned server/shared auth, proxy, retention, cancellation, and Docker changes. The Expanse world and anonymous viewing paths were left unchanged.\n\n- Refresh failures now retain the bearer, show an unavailable state, deduplicate checks, and retry with bounded backoff and `Retry-After` support. Only an authoritative 401 ends the session.\n- Trusted forwarding is opt-in by exact proxy address. Challenge and verify have per-peer limits; session polling uses the global limit without consuming the sign-in bucket.\n- Profiles expire after 90 days of inactivity and are evicted atomically when capacity requires it. Active sessions are bounded globally, per address, and per origin.\n- Challenge cancellation uses a separate secret; the client validates the server’s world-scope format and exact signed message.\n- Docker copies modules with readable permissions and runs import checks as `node`. Caddy routes auth and Expanse APIs to port 8788 and serves static files.\n\n**Checks:** the serial backend/client auth suite passed, 30/30; JavaScript syntax checks and `git diff --check` passed. It includes real HTTP signature tests and 50 simulated verified-session polls followed by sign-in.\n\n**Unresolved:** ARM64 build/start could not be verified because the Docker daemon socket denied access. Browser and real wallet-extension/device checks were not run. HUD copy and duplicate screen-reader announcements were outside this assignment’s file scope. The findings matrix is in [FINDINGS.md](/home/imd-worker/.identitymd/work/8564b523-aa6a-450c-b1df-843c829cb15d/f2b0f3b3-639f-49ae-8dfd-9f4036728883/docs/auth/FINDINGS.md); API and setup/rollback details are in [API-v1.md](/home/imd-worker/.identitymd/work/8564b523-aa6a-450c-b1df-843c829cb15d/f2b0f3b3-639f-49ae-8dfd-9f4036728883/docs/auth/API-v1.md) and [SETUP.md](/home/imd-worker/.identitymd/work/8564b523-aa6a-450c-b1df-843c829cb15d/f2b0f3b3-639f-49ae-8dfd-9f4036728883/docs/auth/SETUP.md).","treeHash":"9250288d0a4862d4cc1bf78b63c699551a6c344c","usage":{"cachedInputTokens":5201536,"inputTokens":117198,"model":"gpt-6-luna","outputTokens":33390,"runtime":"codex","turns":9,"wallClockMs":871681}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"73d25b5e0857cef5","findings":[{"citation":"resolved","description":"Hosted consumer: dist/assets/index-BXlBJwhz.js:9, the request/refreshSession code compiled from shared/visitor-auth-client.js. The global/per-peer rate check runs before the allowlisted-origin CORS headers are set, and the early auth_busy response also bypasses CORS. In addition, responses that do reach the CORS block never expose Retry-After. Consequently the shipped static preview, when connected to a separate allowed backend origin, sees global-limit 429s as network failures and cannot read Retry-After even on readable signup-limit 429s. shared/visitor-auth-client.js therefore retries after roughly one second instead of the requested 60 seconds, repeatedly loading an already throttled service. The credential is retained, but the advertised Retry-After behavior only works for same-origin deployments.","line":244,"path":"server/src/expanse/visitor-auth.js","reproduction":"Run handleVisitorAuth with a localDemo VisitorAuth on http://127.0.0.1:<API>, and serve the unmodified shared client from a different loopback port <PREVIEW>; allow that exact preview origin. Configure perPeerSignups:1. From Chromium on PREVIEW, POST /api/auth/nonce twice with JSON {address:<disposable EOA>,chainId:1}. The second response is 429 signup_rate_limited, but response.headers.get(\"retry-after\") is null; the same request in Node shows Retry-After:60. Next exhaust globalRequests (600 requests with the defaults), then fetch /api/auth/config from PREVIEW. Expected: an allowlisted readable 429 and accessible Retry-After:60. Actual: browser TypeError Failed to fetch; raw HTTP has no Access-Control-Allow-Origin. With an established real signed session, client.refreshSession() under this condition retained its token but returned state unavailable, retryAfterUntil:0, and retryAt only 1145 ms ahead. CORS headers must also cover allowed-origin error responses and expose Retry-After; include real cross-origin browser tests.","severity":"medium","snippet":"    auth.rate(auth.clientAddress(req), protectedRoute); // login abuse has per-peer limits; every route retains a global ceiling","title":"Cross-origin previews cannot honor the server's Retry-After cooldown"},{"citation":"resolved","description":"The hosted implementation is in dist/assets/index-BXlBJwhz.js:9. Revocation is attached only to React effect cleanup; reloading/navigating away does not unmount this React tree or invoke that cleanup. No pagehide handler or unload-capable revocation is installed. Reload therefore loses the only bearer/cancellation material while leaving its server session active, contrary to the \"session ends on ... reload\" explanation at line 115 and privacy text at line 178. With maxSessionsPerAddress:3 and the one-hour default TTL, three ordinary sign-in/reload cycles exhaust the wallet's allowance. A fourth correctly signed login is rejected with session_store_full, and the new page has neither a credential nor a sign-out/recovery control for the abandoned sessions. The UI tells the user to wait a minute, but the remaining slots can stay occupied for almost an hour. Provide bounded recovery for an already-proven signer (and truthful local-versus-server session lifetime wording); best-effort lifecycle revocation alone cannot cover offline closure.","line":54,"path":"web/src/expanse/VisitorWallet.tsx","reproduction":"Start the shipped isolated HUD/backend with node deploy/auth-demo/hud.mjs and default auth limits. In Chromium with one simulated injected EIP-1193 provider backed by a disposable real EOA, open /preview/, Connect wallet, select the provider, then Sign in with message. Reload the page online without clicking Sign out, reconnect the same account, and sign in again; repeat until three successful verifications. Expected: the promised reload termination, or a bounded way for the freshly proven same wallet to recover/admit a session. Actual: backend session counts are 1, 2 and 3 across reloads, and the first token still validates as a visitor after the first reload. On the fourth login /api/auth/verify returns HTTP 429 {version:1,error:\"session_store_full\"}; the UI says \"Connected · not signed in\" and \"Wait a minute and retry\", with zero Sign out buttons. This used the actual HTTP signature verifier; no production or real wallet extension was used.","severity":"medium","snippet":"    return () => {\n      active = false;\n      c.destroy();\n      if (client.current === c) client.current = null;\n      retired.current.add(c);\n      c.retryRevocations().finally(() => releaseRetired(c)).catch(() => {});\n    };","title":"Online reloads leave orphaned sessions and lock the wallet out after three sign-ins"}],"hash":"9734c4fbc19d8c6c9aca5582f758a44d9f466a251a85c5e7271a38ef546a3371","nodeId":"22585e18-6f2c-46c5-9835-9ff700b8ef75","outcome":"completed","summary":"Saved two medium findings in [.imd-findings.json](/home/imd-worker/.identitymd/work/8564b523-aa6a-450c-b1df-843c829cb15d/22585e18-6f2c-46c5-9835-9ff700b8ef75/.imd-findings.json):\n\n- Cross-origin responses prevent honoring `Retry-After`.\n- Reloads orphan sessions, blocking the fourth sign-in.\n\nNo critical/high hosting blockers, transaction/approval flows, or exported secrets found.\n\nPassed: 247 backend tests, 51 frontend tests, 33 Chromium scenarios, typecheck and build. Application files unchanged. Actual wallet extensions and ARM deployment remain unverified.","treeHash":null,"usage":{"cachedInputTokens":5340672,"inputTokens":216573,"model":"gpt-6-astra","outputTokens":24616,"runtime":"codex","turns":8,"wallClockMs":879508}},{"artifacts":[],"attempt":1,"bundleHash":"e1d067e1bb3401218f0ef6c55028d38fc91d2e282c130a6caff2ec62f603e854","device":"09078b7cdfb673fe","findings":[],"hash":"aee9d697537a77d7d288776efe7413ee61a767d8d5a04d237343bba6c1427a9a","nodeId":"f2b0f3b3-639f-49ae-8dfd-9f4036728883","outcome":"completed","summary":"I fixed all three severe findings and the five advisory ones I judged real. The ARM64 build, real wallet extensions and a real-browser cross-origin run are still not done.\n\nThe auth suites (`expanse-auth` and `expanse-auth-client`) pass 31 of 31. I added one regression test covering the global ceiling, profile recovery, session fairness, rate-key eviction and CORS headers. I did not run it against the old code first to confirm each case failed, so I haven't shown the fixes turn failing tests green. I did not run the other existing suites (typecheck, build, simulation).\n\n- **Global ceiling (high):** each request now passes a per-peer check before it counts toward the global budget. The routes that had no per-peer limit (config, session, logout, preflight) get their own larger bucket of 240 requests a minute per peer. One peer can no longer burn the whole 600-a-minute global budget.\n- **Profile store at capacity (high):** the least recently verified inactive profile is evicted to admit a new signer once it is past 24 hours old. The 90-day expiry stays.\n- **Retry-After across origins (high):** every auth response now carries the exact allowed-origin CORS headers and exposes `Retry-After`. That includes early 429 and 503 responses and preflights. My test checks the headers over real HTTP, but Node's `fetch` doesn't enforce CORS, so a real-browser run is still outstanding.\n- **Per-origin session cap (medium):** at the per-origin or global session cap, the address holding the most sessions loses its oldest one. The signer asking for a new session is never the one evicted.\n- **Rate-key store (medium):** when the store is full it drops its oldest bucket instead of rejecting new peers, and IPv6 clients are bucketed by /64. I changed the old `rate_store_full` test to match.\n- **Dockerfile (low):** code is now owned by `root:root` at 0755, so the `node` user can read it but not write it.\n- **SETUP.md (low):** the Caddy example now overwrites `X-Forwarded-For`, and the docs describe the new retention and eviction behaviour.\n- **Unverified acceptance items (info):** I disputed this one only in the sense that it is accurate and still open. There is no Docker daemon here, so I did not build or start the linux/arm64 image. I also did not test with real wallet extensions or devices.\n\nThe answers for every finding are in `.imd-responses.json`.","treeHash":"0100eea05881b8b0b11872db79a46f54f51bb758","usage":{"cachedInputTokens":296184,"inputTokens":18,"model":"claude-sonnet-5-5","outputTokens":9728,"runtime":"claude","turns":9,"wallClockMs":87977}},{"artifacts":[],"attempt":1,"bundleHash":"7900b1397af21e1aa8bd759a3f1a4abf95fe92d09536881b2d94b2735ff7bfb4","device":"249bc6a0e6af3475","findings":[],"hash":"b356e4c8e59763c7838886f2d3b2e9c85577b73ecb78b1df3294b2f0051c7400","nodeId":"80ace8cf-478e-448b-b640-0f3b7fd81ce9","outcome":"completed","summary":"Implemented recovery and cancellation fixes, honest unconfirmed states, and single auth announcements. Updated the static export and documentation.\n\nPassed: 247 Node tests, 51 frontend tests, 33 HUD browser scenarios, 9 demo scenarios, build/typecheck, and 16 design checks.\n\n[Evidence, fix matrix, and limitations](artifacts/visitor-recovery/VALIDATION.md). Safari, actual extensions/devices, ARM startup, and full backend saturation remain unverified. No production access occurred.","treeHash":"ddaa70a7b4c699c65f51d307f4d5875d05d98d3b","usage":{"cachedInputTokens":7943296,"inputTokens":159323,"model":"gpt-6.1-sol","outputTokens":68994,"runtime":"codex","turns":10,"wallClockMs":1734046}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a315ee4cb81c640c","findings":[{"citation":"resolved","description":"Follow-up to prior finding 367387dc (CORS/Retry-After). Actual responses now carry Access-Control-Allow-Origin and Access-Control-Expose-Headers: Retry-After (verified: a 429 signup_rate_limited and a global 429 on GET /api/auth/config are readable). But shared/visitor-auth-client.js request() always sends X-Stratoks-Origin (and Authorization), so every cross-origin call needs a CORS preflight. auth.rate() at line 273 runs before the OPTIONS branch at line 278, and the auth_busy 503 at line 259-261 also precedes it, so when the poll bucket or global ceiling is exhausted the preflight itself gets 429 (or 503). Per the Fetch standard a CORS-preflight response whose status is not ok is a network error regardless of Access-Control-* headers, so the browser fetch rejects with TypeError, the client treats it as a transient network failure, retryAfterUntil stays 0 and the next retry is ~1 s (bounded 2^n backoff capped at 30 s) instead of the advertised 60 s. Since the preflight and the actual request each consume one count from the same buckets, virtually every cross-origin throttle lands on the preflight, so for AUTH_ALLOWED_ORIGINS previews the Retry-After cooldown still never reaches the client. The comment at line 252 ('including early 429/503, so Retry-After is readable cross-origin') and SETUP.md line 55 overstate the fix. The credential is still retained and the state is still 'unavailable', so this is not a session-loss defect. Not blocking for same-origin deployments, which send no preflight. Possible remedy (owner's call): answer a syntactically valid allow-listed preflight with 204 before/independently of rate() (preflights carry no body and cannot authenticate), optionally under its own small bucket, so the throttled actual response and its Retry-After reach the page. Specialist id 6d55fadb reports the same in Chromium; Chromium could not be launched on this machine (missing libglib), so the browser step was not re-executed here; the server side was.","line":273,"path":"server/src/expanse/visitor-auth.js","reproduction":"new VisitorAuth({dataDir:tmp, uri:'https://world.example/', allowedOrigins:['https://preview.example'], globalRequests:3, perPeerSignups:1}) behind http.createServer((q,r)=>handleVisitorAuth(q,r,auth)) listening on 127.0.0.1. Make 3 requests with Origin https://preview.example (observed: POST /api/auth/nonce 200, POST /api/auth/nonce 429 signup_rate_limited with ACAO+Expose+Retry-After:60, GET /api/auth/config 200). Then send the browser's preflight: OPTIONS /api/auth/session with Origin: https://preview.example, Access-Control-Request-Method: GET, Access-Control-Request-Headers: authorization,x-stratoks-origin. Expected: 204 so that the following GET can return a readable 429 with Retry-After. Actual (observed): 429 {\"version\":1,\"error\":\"rate_limited\"} Retry-After:60 on the OPTIONS itself. Likewise with auth.activeRequests=auth.cfg.maxConcurrentRequests the same OPTIONS returns 503 auth_busy. In a browser both make fetch() reject (CORS preflight not ok), so shared/visitor-auth-client.js request() never sees r.headers and scheduleRetry() uses ~1 s backoff, not 60 s.","severity":"medium","snippet":"    auth.rate(auth.clientAddress(req), protectedRoute); // login abuse has per-peer limits; every route retains a global ceiling","title":"Cross-origin Retry-After fix is defeated for every preflighted request: a throttled or busy OPTIONS preflight returns 429/503, which browsers treat as a CORS network error"},{"citation":"resolved","description":"Settles prior finding ed185c56 (disputed by the author). The copy defects are fixed: the HUD now truthfully says reload forgets the credential and the session lasts until expiry/restart, and the session_store_full hint no longer promises one-minute recovery. The behaviour itself is unchanged and confirmed by the author: credentials are memory-only, no unload revocation exists, and verify() at server/src/expanse/visitor-auth.js:157 rejects a fourth correctly signed login once three sessions for the address are live, so a visitor who reloads three times without Sign out cannot authenticate for up to an hour. This is within the stated design (sessions bounded per address, retained to fixed expiry) and is not a permanent lockout, so it is recorded as a documented tradeoff for the requester to accept or re-scope, not as a defect reopening the work. If re-scoped, the smallest change preserving the per-address bound would be to let a freshly proven signer at its cap replace its own oldest session; best-effort pagehide revocation alone cannot cover offline closure.","line":116,"path":"web/src/expanse/VisitorWallet.tsx","reproduction":"Default limits (maxSessionsPerAddress:3). With one disposable signer: issue + sign + verify three times without logout (as happens after three HUD reloads). A fourth issue+sign+verify. Expected (if the requester wants reload recovery): admitted, replacing the signer's oldest session. Actual (observed in-process this round): verify throws 429 session_store_full; the three earlier tokens still validate; recovery only after the 3,600,000 ms TTL or server restart.","severity":"low","snippet":"    : snapshot.session ? `Verified on chain ${snapshot.session.chainId}. Reload forgets this tab’s credential. Sign out before reloading to end the server session; otherwise it lasts until expiry or server restart.`","title":"Scope decision, not a blocking defect: three sign-in/reload cycles still hold the per-address session cap for the full 1 h TTL; the misleading copy is fixed"},{"citation":"resolved","description":"Settles prior finding c84c4248 (author agrees it is open). No Docker daemon on this machine either, so the Dockerfile fix (COPY --chown=root:root --chmod=0755, lines 6-7) is confirmed only by reading it: node can read but not write server/ and shared/, which resolves prior finding 83347383 by Dockerfile semantics, not by execution. Chromium could not launch here (missing libglib), so tests/browser/* and the cross-origin preflight behaviour above were not re-run in a real browser this round. Only simulated EIP-1193/6963 providers were ever used. These items must not be read as verified. Everything else raised previously was re-executed over real HTTP this round and holds: one peer capped at 240 polls while another visitor's nonce/verify succeed and 50 trusted-proxy sessions poll twice at 30 s cadence with 100/100 200s under attack; profile store recovers after the 24 h minimum retention (still full at 23 h, admits at 25 h and at 7 d; 3 profiles survive restart with the same serviceId); two attackers x3 sessions at maxSessionsPerOrigin:6 no longer block an honest third signer; 500 distinct IPv6 /64 peers then a new IPv4 peer all return 200; SETUP.md line 71 now has header_up X-Forwarded-For {remote_host}; existing suites pass 78/78.","line":53,"path":"docs/auth/SETUP.md","reproduction":"Run sh deploy/expanse/check-auth-image.sh linux/arm64 on a host with a Docker daemon and arm64 or emulation; then docker run --rm <image> sh -c 'touch server/src/expanse/x || echo readonly' expecting 'readonly'. Neither was executed here.","severity":"info","snippet":"The intended owner check builds/loads the actual Dockerfile, starts as unprivileged `node` with disposable `/data`, checks imports, signs in with a disposable signer, checks session/logout and anonymous frames, and removes its container. Here, ARM build/start could not run: Docker's default config location was read-only; using a temporary config location then failed to connect to the Docker daemon because its socket denied access. ARM runtime evidence is unresolved and must not be inferred from Node tests.","title":"Acceptance items still unverified in this round: linux/arm64 image build/start, root-owned image permissions, real wallet extensions, real-browser cross-origin runs"}],"hash":"d48a6228a64986a20d3027fc0bcfde8ea369d11f53688df2c56251cb3831d083","nodeId":"79b0f88c-b1a6-42e2-9544-5db8634fcb69","outcome":"completed","summary":"Review complete. The findings file holds three entries, all snippet-checked against the tree, and no repository file was changed.\n\n**Settled as fixed.** I re-ran each prior finding over real HTTP against the final code, and the existing suites pass 78 of 78.\n\n- **One peer exhausting the global ceiling (high):** fixed. An attacker peer is capped at 240 polls, another visitor's challenge and verify succeed, and 50 verified sessions behind a trusted proxy polled twice at the shipped cadence with every response 200 while two attacker peers hammered polls and sign-ups.\n- **Profile store never recovering (high):** fixed. At capacity the store is still full after 23 hours, admits a new signer after 25 hours and after 7 days, and 3 retained profiles survive restart with the same service identity.\n- **Per-origin session cap held by disposable addresses (medium):** fixed. Two signers with three sessions each no longer block an honest third signer at a cap of 6.\n- **Rate-key store lockout (medium):** fixed. 500 distinct IPv6 peers followed by a new IPv4 peer all return 200.\n- **SETUP.md Caddy block (low):** fixed. The forwarded-header overwrite is now in the documented block.\n- **Runtime user owning code (low):** fixed by Dockerfile semantics only. Root ownership with mode 0755 is present, but no Docker daemon exists here to build the image.\n\n**Still open, in severity order.**\n\n1. **Medium.** The cross-origin Retry-After fix is only partial. The client always sends a custom header, so every cross-origin call needs a preflight, and rate limiting runs before the OPTIONS branch. With the bucket exhausted, the preflight itself returns 429 with Retry-After, and under concurrency pressure it returns 503. Browsers treat a non-ok preflight as a CORS network error, so the page never sees the cooldown and retries after about one second. Reproduced at the HTTP level this round. Chromium could not launch on this machine, so the browser step rests on the Fetch specification and the specialist's Chromium run.\n2. **Low, scope decision.** Three sign-in and reload cycles still hold the per-address cap for the full hour. The misleading copy is fixed and the author disputes a design change. I recorded it as a documented tradeoff for the requester, not a defect that reopens the work.\n3. **Info.** Unverified acceptance items remain: linux/arm64 build and start, executed image permissions, real wallet extensions, and real-browser cross-origin runs.\n\nThe specialist's reload finding was merged into item 2 rather than kept as a new defect. Nothing else new reproduced.","treeHash":null,"usage":{"cachedInputTokens":483194,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":20669,"runtime":"claude","turns":18,"wallClockMs":359738}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2c985fa233fefc9e","findings":[{"citation":"resolved","description":"Now: Rate/concurrency errors occur before CORS headers are set, and even CORS-readable signup errors do not expose Retry-After. An allowed static-preview origin therefore sees a network error or a null header and retries on the ordinary roughly 1-second backoff during a 60-second server cooldown.\nChange to: Set the exact allowed-origin CORS headers on every auth response, including early 429/503 paths and preflights, and expose Retry-After with Access-Control-Expose-Headers. Add a browser regression using distinct frontend/backend origins and an actual throttled response.\nWhy: The brief explicitly requires Retry-After support and exact permitted preview origins; same-origin route.fulfill tests cannot prove the cross-origin delivery works.","line":243,"path":"server/src/expanse/visitor-auth.js","reproduction":"390x844 Chromium; start deploy/auth-demo/hud.mjs via startHudDemo({port:0}), serve a separate loopback HTML origin and add that exact origin to auth.origins. From that page POST /api/auth/nonce eleven times with JSON {address:\"0x1111111111111111111111111111111111111111\",chainId:1}. The 11th response is 429 signup_rate_limited, but response.headers.get(\"retry-after\") is null; raw HTTP shows Retry-After: 60 and no Access-Control-Expose-Headers. Then set backend.auth.global.count=backend.auth.cfg.globalRequests and fetch /api/auth/config from the allowed page: browser throws \"Failed to fetch\"; raw HTTP returns 429, Retry-After: 60, Access-Control-Allow-Origin absent. Expected: allowed browser can read 429/503 and the 60-second minimum delay. Observed with real local HTTP, no production access. A separate real-browser VisitorAuthClient login succeeded against these distinct local origins; after global throttling, refreshSession retained the token and entered unavailable but set retryAfterUntil=0 and retryInMs=762, despite the server header specifying 60000 ms.","severity":"high","snippet":"    const protectedRoute = ['/api/auth/nonce', '/api/auth/verify'].includes(u.pathname);\n    auth.rate(auth.clientAddress(req), protectedRoute); // login abuse has per-peer limits; every route retains a global ceiling","title":"bug: cross-origin throttling hides Retry-After from the authentication client"},{"citation":"resolved","description":"Now: The verified reason and Privacy and wallet support both say reload ends the session. The current HUD drops the credential without revoking it, so each reload leaves a live server session; three sign-in/reload cycles reach the per-address cap. The fourth attempt says to wait a minute even though those slots remain occupied for up to the one-hour TTL.\nChange to: Say \"Reload forgets this tab’s credential. Sign out before reloading to end the server session; otherwise it lasts until expiry or server restart.\" Apply that distinction to the privacy disclosure and setup/API documentation. Give session_store_full its own recovery copy: \"This account or origin has reached its active-session limit. Sign out in another signed-in tab, or wait for existing sessions to expire.\" Keep the server bound; do not remove the per-address cap.\nWhy: Session/retry states must describe only what the app knows, and the brief requires truthful authentication delivery and bounded sessions without misleading recovery instructions.","line":115,"path":"web/src/expanse/VisitorWallet.tsx","reproduction":"390x844 Chromium, actual local startHudDemo backend with shipped maxSessionsPerAddress=3 and sessionTtlMs=3600000; EIP-6963 simulated provider with one disposable EOA and real EIP-191 signatures. Connect, sign in and reload the same page three times without signing out. Verified UI says \"This session ends on expiry, reload or server restart\", but measured backend.auth.sessions.size after each reload is 1, 2, then 3. Reconnect and sign in a fourth time: real verify returns 429 session_store_full and the HUD shows \"Wait a minute and retry\". No tab retains a token to sign out those abandoned sessions. Expected: reload and capacity explanations disclose the surviving server sessions and their actual recovery conditions; server expiry is up to one hour, not one minute.","severity":"medium","snippet":"    : snapshot.session ? `Verified on chain ${snapshot.session.chainId}. This session ends on expiry, reload or server restart.`","title":"bug: reload is described as ending sessions but leaves the account at its session cap"}],"hash":"e138004c54aa59dfcaf8ba69771473ef84ac365520a739e0ef83bebea7bc73c5","nodeId":"bb3e4187-4f57-4c5f-9002-ab1a27136776","outcome":"completed","summary":"| Area | Result |\n|---|---|\n| Requirements/checklist groups | 35 met · 4 missing · 0 faked · 1 after review · 2 need requester |\n| Broken features | 2 findings: 1 high, 1 medium |\n| Product/action on first screen, 390px | Yes |\n| taste@1 | 16/16 passed |\n| Copy findings | 1 |\n| Share card | Present; metadata checks passed |\n\nFindings saved in [.imd-findings.json](/home/imd/.identitymd/work/8564b523-aa6a-450c-b1df-843c829cb15d/bb3e4187-4f57-4c5f-9002-ab1a27136776/.imd-findings.json). Application files remain unchanged.\n\n- **High — bug: cross-origin throttling hides Retry-After.** Real browser testing found a 60-second cooldown became a 762ms retry because CORS hid the response/header.\n- **Medium — bug: reload/session-limit recovery copy is false.** Three sign-in/reload cycles leave three server sessions. The fourth sign-in fails, despite copy saying reload ends sessions and advising a one-minute wait.\n\nFresh checks passed: **247 backend, 29 auth-client, 51 frontend, 33 HUD browser and 9 standalone browser scenarios**, plus typecheck, build, five inspector viewports and taste@1. Providers were simulated; signatures and HTTP verification were real.\n\nSTRATOKS is an observer for an autonomous world, carried visually by a painted landscape and compact moss-and-gold controls.\n\n| Requirement | Status | Evidence |\n|---|---|---|\n| Preserve Expanse, walletless viewing; avoid production access/deployment | Met | `App.tsx`; isolated local test services |\n| Retain credentials on 429/5xx/offline/timeout; show temporary unconfirmed state | Met | `visitor-auth-client.js`; 14 recovery browser scenarios |\n| Bounded backoff and jitter | Met | `scheduleRetry()`; virtual-clock regression |\n| Retry-After support | **Missing** | Cross-origin reproduction in finding 1 |\n| Only authoritative invalid-session responses end authentication | Met | `refreshSession()`; auth-client regressions |\n| Never show an unconfirmed session as freshly verified | Met | `VisitorWallet.tsx`; failure-state browser assertions |\n| Logout/account/network/provider/world changes clear obsolete authentication and revoke safely | Met | Controller invalidation; HUD race tests |\n| Prevent stale responses/duplicate refreshes; preserve retry/cancel | Met | 29 auth-client tests |\n| Explicit owner-configured trusted proxies | Met | `VisitorAuth.trustedProxies`; compose configuration |\n| Ignore forwarded headers from untrusted peers | Met | `clientAddress()`; proxy-boundary test |\n| Document Caddy sanitization/private binding | Met | `deploy/Caddyfile`; `docs/auth/SETUP.md` |\n| Separate challenge/verify abuse limits from polling; retain global/body/concurrency limits | Met | `handleVisitorAuth()` |\n| Fifty verified sessions polling while another visitor signs in | Met | Real 51-signer HTTP test at virtual 30-second cadence |\n| Bounded retention/admission, signup controls and inactive expiry | Met | `visitor-auth.js`; small-capacity tests |\n| Do not promise Sybil-proof identity | Met | API/setup documentation |\n| Atomic retained-profile persistence/restart; disclose retention | Met | `persist()`; restart tests; setup documentation |\n| Bound sessions per address/origin | Met | Three/address and 500/origin admission checks |\n| Comprehensive small-limit, disposable-signer and repeated-signer regression campaign | **Missing** | Current validation explicitly identifies incomplete coverage |\n| Preserve population, identities, brains, goals, signals, terrain, schema, configuration and resident memory | Met | Backend preservation/restart tests; unchanged simulation implementation |\n| Validate actual structured world identity, including non-UUID IDs | Met | Structured-scope client regression |\n| Reject controls/newlines/malformed world/domain/origin/URI before prompting | Met | Challenge-validation tests |\n| Reconstruct exact message; retain all bindings and replay protection | Met | Shared message formatter; real HTTP crypto tests |\n| Public nonce cannot revoke established sessions; separate can","treeHash":null,"usage":{"cachedInputTokens":3069824,"inputTokens":293661,"model":"gpt-6.1-sol","outputTokens":21419,"runtime":"codex","turns":8,"wallClockMs":909951}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3f6a9bdd601cb99f","findings":[],"hash":"f2c5f1eacc016838d48dad07d9a891db85ba7a62ca970fa71d71fcbfb0b0fc8b","nodeId":"bb3e4187-4f57-4c5f-9002-ab1a27136776","outcome":"completed","summary":"Both findings from last round are fixed, and I found nothing new. `.imd-findings.json` holds an empty list, and I changed no repository files.\n\n| Area | Last round | This round |\n|---|---|---|\n| Brief requirements | 1 high open (Retry-After), 1 medium (session copy) | Both fixed; no new misses or fakes found |\n| Broken features | 1 | 0 |\n| Product on first screen at 390 | yes | not re-measured (layout untouched by these fixes) |\n| taste@1 checks passed | 16/16 by the author's run | not re-run by me this round |\n| Copy findings | 1 | 0 |\n| Share card | present | not re-checked |\n\nI only re-tested the two findings this round. I did not redo the full requirement list or the probe, so the \"not re-measured\" and \"not re-run\" cells are carried over, not checked again.\n\n**Finding 1 (high): cross-origin throttling hid Retry-After from the client. Fixed.**\n- **Code:** `server/src/expanse/visitor-auth.js:252-258` now sets the exact allowed origin, `Access-Control-Expose-Headers: Retry-After` and `Vary: Origin` at the top of `handleVisitorAuth`. That runs before the 503 busy check, the rate limiter and the preflight branch.\n- **Real-browser test:** the author had not run one, so I did. I used Chromium at 390×844, started `startHudDemo({port:0})`, served a page from a separate origin and added that origin to `auth.origins`. Then:\n  - **Repeated sign-in requests:** the 11th and 12th `POST /api/auth/nonce` returned `429 signup_rate_limited`, and the page read `retry-after = \"60\"`. Last round it read `null`.\n  - **Global limit hit:** with `global.count` forced to the limit, `fetch('/api/auth/config')` returned a readable `429` with `retry-after \"60\"` and the `rate_limited` body. Last round it failed with \"Failed to fetch\".\n- **Client side:** `shared/visitor-auth-client.js:71-75` turns the header into a delay, and lines 106-107 set `retryAfterUntil` and wait at least that long before retrying. With the header now readable, the client waits out the server's cooldown instead of retrying after about a second.\n\n**Finding 2 (medium): the copy said reload ends the session, but sessions stayed on the server and filled the account's limit. Fixed.**\n- **Wording:** `web/src/expanse/VisitorWallet.tsx:116` (the verified status) and `:179` (the privacy note) now say \"Reload forgets this tab’s credential. Sign out before reloading to end the server session; otherwise it lasts until expiry or server restart.\"\n- **Session-limit message:** `:15` gives `session_store_full` its own message before the generic `rate_limited|store_full` \"Wait a minute\" text: \"This account or origin has reached its active-session limit. Sign out in another signed-in tab, or wait for existing sessions to expire.\"\n- **Server match:** the server sends exactly that code when the per-address, per-origin or overall session limit is hit (`visitor-auth.js:157,167,168`).\n- **Built preview:** the same strings are in `dist/assets/index-CJb5861Y.js`, so the published preview matches the source.\n\n**One-sentence idea:** STRATOKS Expanse is a living, top-down simulated world you can watch without a wallet, with optional sign-in shown as a small, honest status in the HUD.","treeHash":null,"usage":{"cachedInputTokens":329562,"inputTokens":20,"model":"claude-opus-5-5","outputTokens":4969,"runtime":"claude","turns":11,"wallClockMs":54609}}],"verification":[{"checks":[],"detail":"paths and tree verified; no suite was run for this kind of work","evaluation":"structural","profile":"none","status":"accepted","submissionHash":"1e4a7148f988ac215da486a864989cc9a8743b8b657a7e3b6b97686dbd01089a","verifiedTreeHash":"0dc1faac562b24b471dd25a02ee1615d0665cda0","verifierVersion":"0.1.0+a6eb4461"},{"checks":[],"detail":"paths and tree verified; no suite was run for this kind of work","evaluation":"structural","profile":"none","status":"accepted","submissionHash":"792ff22d124b4fd981dae4ec3d94e34bde1afa8c08e3711f946838922b8cbf2e","verifiedTreeHash":"9250288d0a4862d4cc1bf78b63c699551a6c344c","verifierVersion":"0.1.0+a6eb4461"},{"checks":[],"detail":"paths and tree verified; no suite was run for this kind of work","evaluation":"structural","profile":"none","status":"accepted","submissionHash":"aee9d697537a77d7d288776efe7413ee61a767d8d5a04d237343bba6c1427a9a","verifiedTreeHash":"0100eea05881b8b0b11872db79a46f54f51bb758","verifierVersion":"0.1.0+a6eb4461"},{"checks":[],"detail":"paths and tree verified; no suite was run for this kind of work","evaluation":"structural","profile":"none","status":"accepted","submissionHash":"b356e4c8e59763c7838886f2d3b2e9c85577b73ecb78b1df3294b2f0051c7400","verifiedTreeHash":"ddaa70a7b4c699c65f51d307f4d5875d05d98d3b","verifierVersion":"0.1.0+a6eb4461"}]}