{"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":"f3e7cfc7-0b43-473a-9c0f-6931cf278c56","kind":"audit","nodes":[{"acceptedSubmissionHash":"aff255cf0071d9e2b81fa42a049adc6f1baf26cf686ce4fa1f7d763dacf26a66","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"0e3196e00ec629b22da53a79a71b3df7a202b21962c324f619d7449133f246f9","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"1abb3575e441b60f4e5bc27de84ef1fa88856dfcda5b1c47709283d108f0a5cc","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"e937fb6a25ec56f94f2f961fa1b8a35b82e309b88e177d1a35a56009d2a7369d","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"c61a367aba54d3f174ab37b5dbce5d3974ccf39a83a099ab910749b560192971","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"IMD Ember World (https://imdember.com) - re-audit after Audit 1ef8e8a6/Report dcf922ca, plus first review of member layer M1 (World only)\n\nPlease read this first: this is an unofficial community project. This repository contains NO Solidity or smart contract. TypeScript Cloudflare Worker and TypeScript/React SIWE (EIP-4361) client. The team claims the World site asks only eth_accounts, eth_requestAccounts and personal_sign of server-built SIWE text: no transaction, token/NFT approval, Permit/Permit2 or typed-data signature. Verify this, including changed client code. Rate by attacker preconditions/player impact: impersonation, session revival or cross-address logout, false house rights, unintended prompts, disclosure/poisoning, and availability. Verify the claimed absence of fund-loss paths. Identify inapplicable Solidity checks.\n\nRepository: https://github.com/tungweb3/imd-ember-world-review at 6e307dea76e763936fc4ac86e54c9f5d558f58c4, as shown by READ. Its parent must be 8cad017fad58bac89d88fa72d530d3c56160009b (Audit 1ef8e8a6, Report dcf922ca). Code is in source/. Traditional Chinese root docs are team claims; code is the reference. README maps R3-R1 and AUD3-01..09 to changes and residuals. These fixes have NOT been externally re-reviewed. The member layer M1 is new and has NEVER been reviewed by Swarm.\n\nDeployment facts (team claims; Audit has no network):\n- Live Worker imd-world: acdbb2bd-8add-4b15-bfa6-a31266c83520, deployed from ddb10e28a867998323164e7585635efedfcf7788. source/ is from main c491ff3c9edf9d0eb39a9233ccfff101a7c8133c: only one status document and one added evidence page differ; neither enters a build.\n- Rebuild from sanitized source/ alone: expected Worker SHA-256 cf720c698417726ce75cd3b4740314489ed816ba98a763e74d8118b8be136518, 303,128 bytes. See DEPLOYMENT_MATCH.md/manifests.\n- D1 migrations 0001-0006, including new 0006_members.sql; sessions schema unchanged. Bindings as in source/wrangler.jsonc. Stated edge rule: over 20 /api/ requests from an IP in 10 s are blocked.\n- Recorded GET date: 2026-10-03T13:01:16Z-13:01:40Z. Report must use curl/browser User-Agent: Python-urllib got 403 last time (deployment match partial). No other block bypass; Audit stays code-only.\n\nEntry points (all paths below are inside source/):\n- worker/app.ts handleMemberApi dispatch :139 precedes server/auth.ts handleAccountApi :639, then server/world-api.ts and static assets.\n- server/auth.ts: POST /api/auth/challenge :482, verify :511, logout :603, logout-all :617; GET /api/auth/session :596; GET /api/me/home :683 (session address only; server/ownership.ts :280).\n- Public GET /api/wallet/:address/assets and /api/world/*; shared cache. Client: src/world/auth.ts signIn :280, siwe.ts checkSignInMessage, homeEntry.ts enterGate, WalletPanel.tsx, member.ts and MemberPanel.tsx.\n\nChanges since 8cad017: check each against your own expected result and look for regressions.\n- R3-R1: src/world/auth.ts accountEvents :396 guards connect/eth_accounts and wallet changes during personal_sign. Can a late answer restore, prompt or verify an older account? Only synthetic wallet ordering was tested; a wallet returning a stale account without accountsChanged is a stated limit.\n- AUD3-01: server/ownership.ts :292 preserves the first proof when lane rebuilding fails, returning limited data. Any remaining 503 or seat granted without ownerOf?\n- AUD3-02 (team: partly fixed): server/auth.ts INDEX_LANE_RELEASE :279 releases refused claims (30 s retry; at most 20 releases per 6 s globally). Stated residual: about 80 claims in one 6 s slice at one location still fill the global ceiling. Probe locally.\n- AUD3-03: server/auth.ts RELEASE_CONTRACT :297 releases a refused ERC-1271 claim. Can that buy an extra eth_call or revive a burnt challenge? AUD3-02/03 rely on refused Cloudflare limiter calls costing nothing; this is unconfirmed.\n- AUD3-04: src/world/auth.ts loggedOut :426 invalidates reads begun before this page's confirmed logout.\n- AUD3-05 (team: partly fixed): src/world/auth.ts :256 drops a mismatched house; the prior session remains displayed without owner mode until a session read succeeds. Probe this residual.\n- AUD3-06: server/auth.ts session reads, home 401 and refused logout-all send no Set-Cookie (:596). src/world/auth.ts :290 waits at most 5 s for this page's logouts before a wallet prompt. Cross-tab late explicit logout can still clear a newer cookie.\n- AUD3-07: src/world/auth.ts logoutAllRequest :434 distinguishes expired/stale and re-reads a refused logout-all, including after a newer flow.\n- AUD3-08: worker/app.ts rateLimitKey :82 parses full IPv4/IPv6, maps IPv4-mapped addresses to IPv4 and other input to ip:unknown.\n- Follow-up: src/world/auth.ts revokeAbandoned :411 logs out a late session on that verify response's headers. Can waiting/abandonment/re-read paths be held open, skipped or end in the wrong account?\n\nNew, NEVER Swarm-reviewed: server/member.ts handleMemberApi :81; migration 0006. POST /api/me/bootstrap creates the session address's member; GET/PUT /api/me/profile reads/sets its name; public GET /api/world/names/:address returns name/null. Writes: DB availability, Origin, member limiter, body/session, actor context. AUTH_LIMITER member:+rateLimitKey: 20/min/IP/location, closed on error; missing binding 503. Can an unsigned/different address write, any route set/clear cookies, or M1 weaken sign-in/spend another budget? GET profile's hourly last_login_at write uses a fail-open read limiter; early PUT refusals are outside the recorded 5/member/min cap. Probe race/idempotency/version/cooldown/name claims and budget effects. node:sqlite does not verify production D1 batches. Address-to-name disclosure is intentional; what else is exposed?\n\nRe-check prior findings, stored SIWE-field equality, ERC-6492 refusal, ERC-1271 code/magic word/one check per challenge, nonce/session issuance, hashed tokens, __Host-/7-day cookies, live-session logout-all, closed write limiters, ownerOf/session address and exact client SIWE gate. That gate cannot stop injected script/phishing.\n\nTests: follow TESTS/README.md (isolated source/ git repo, npm ci, two documented stubs). Real handler, node:sqlite, synthetic in-memory keys. New snapshot result: no stubs 161 run/157 pass/4 fail; with stubs 341/337/4 (3 withheld UI/geometry, 1 history-dependent deploy-evidence check). Focused R3-R1/AUD3/ADV: 90/90; M1 server/client 33/33; N tests: 43/43; Enter gate: group 5 3/3. Dependency check: npm audit: 0 production, 0 all vulnerabilities. Distinguish package omissions from defects. Public client imports withheld World/layout/interior code: this is NOT a complete reproducible UI or full application build.\n\nOut of scope: Genesis Mint, Coin E1/0007/check-in/economy routes, withheld 3D/art/music/placement/interior/WorldApp (hashes only). M1 zero economy/life fields are placeholders, not Coin. Fixtures/static scans do not establish complete UI or real-wallet behavior.\n\nFor each finding give severity, file:line, preconditions, player impact, reproduction/argument, prior finding link, and what you could not check. AUD3-09 is a review-limit record, not a fix. This is a code review record, not a certification: do not call the site safe, secure, audited or certified.","parentJobId":null,"planHash":"8f7fac32dfdf8f2db17da92fc11899c086f1f1a292314a297b535e27fbbf7566","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"f3e7cfc7-0b43-473a-9c0f-6931cf278c56","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51018","feedbackHash":"6ba0e6cbf0c474d18ea78d5d4cf2612b13924db17e5b28ed497c834e386b46f8","nodeKey":"audit_economics","submissionHash":"aff255cf0071d9e2b81fa42a049adc6f1baf26cf686ce4fa1f7d763dacf26a66","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50955","feedbackHash":"9ade75916d87f573b8534aef152b0a953374c0bc01bcfaddb4e400795150e3fb","nodeKey":"audit_flow","submissionHash":"0e3196e00ec629b22da53a79a71b3df7a202b21962c324f619d7449133f246f9","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50971","feedbackHash":"2ad66a8ccf009fd41f330644500ab6bdfdd9b9dabb8fcd36986b6e8e361be684","nodeKey":"audit_judge","submissionHash":"1abb3575e441b60f4e5bc27de84ef1fa88856dfcda5b1c47709283d108f0a5cc","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50957","feedbackHash":"8af38906cbf377f664c43e1e33683e3e213d985ff9bfa25739564110fd6bbf03","nodeKey":"audit_math","submissionHash":"e937fb6a25ec56f94f2f961fa1b8a35b82e309b88e177d1a35a56009d2a7369d","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50974","feedbackHash":"db86905d5e6b711da8137b0977e7971c8bd059968643a051a5453309ca7aa936","nodeKey":"audit_permissions","submissionHash":"c61a367aba54d3f174ab37b5dbce5d3974ccf39a83a099ab910749b560192971","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"99fdd6db2d6361bc978a2d08a0e180851a86737cc2961a711c64fd63ce46856b","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3c7630b22a73c1fb","findings":[{"citation":"resolved","description":"Every PUT /api/me/profile that reaches the database records one profile_requests row (success or refusal: record() at :181-182, the 'ok' row at :205). The only DELETE of expired rows (:222) sits inside the success batch, and the cron (server/presence.ts recordPresence) prunes login_challenges, sessions, index_candidates and index_lanes but never profile_requests, profile_history or expired 'reserved' nickname_claims. A member that is refused (NAME_CHANGE_COOLDOWN for 7 days after any name, NAME_UNAVAILABLE, PROFILE_VERSION_CONFLICT, PROFILE_LOCKED) can therefore never trigger the prune, so the migration's own statement 'Kept a day (expires_at), pruned by the member's next write' (0006_members.sql:84-85) and DATA_SCHEMA.md ('profile_requests 期限為 1 天') do not hold; a PROFILE_LOCKED member can never prune at all. Preconditions: one signed-in wallet (any throwaway key signs in, no seat needed) and the documented 5 database-reaching writes a minute per member; the per-IP member limiter (20/min/IP/location) bounds it per IP, not per member. Player impact: none on sign-in, sessions or house rights; D1 storage and billed row writes grow without bound (each refused write is 1 row + 1 index entry), while the per-member 5/min count query stays bounded by its LIMIT so reads do not degrade. Expired 'reserved' nickname_claims rows have the same shape (deleted only when that exact key is claimed again) at a far lower rate (one per rename, 7 days apart). Prior finding: none (M1 is new). Not checked: production D1 billing or any out-of-band prune the team may run; node:sqlite was used, not production D1.","line":222,"path":"source/server/member.ts","reproduction":"In the public harness (tests/wallet-harness.mjs over node:sqlite with migrations 0001-0006): sign in, POST /api/me/bootstrap, PUT a first name (200: the member is now in the 7-day cooldown), advance a minute, then each simulated minute send 5 PUTs with fresh requestIds and new displayNames (each 409 NAME_CHANGE_COOLDOWN; a 6th would be 429) for 3 days, re-signing in daily. Expected per 0006_members.sql:84-85: at most about a day of rows (~7,200). Actual: SELECT count(*) FROM profile_requests = 21,601; running recordPresence (the cron) leaves 21,601; SELECT count(*) FROM profile_requests WHERE expires_at<=now = 14,406 rows past their own expiry still kept. Fix shape: prune profile_requests (and profile_history, expired reserved claims) by expires_at in the cron, or delete expired rows of the member on every write including refusals.","severity":"low","snippet":"    db.prepare('DELETE FROM profile_requests WHERE member_id=?1 AND expires_at<=?2').bind(member,now),","title":"M1: refused name writes accumulate in profile_requests with no prune path except the member's next successful write"},{"citation":"resolved","description":"Prior finding: Swarm audit 1ef8e8a6 #2 (AUD3-02), which the team reports as partly fixed. The release of a lane claim the location key refused (INDEX_LANE_RELEASE, :279-280) is capped at INDEX_LANE_RELEASES=20 per 6 s site-wide, so at a location whose chain:index key is already refusing, claims beyond the 20 the location's chain:index:lane key admits plus the 20 released stay counted in the site-wide 60-per-6-s ceiling of INDEX_LANE (:263-265). Preconditions: an attacker with about 80 throwaway sessions (any key signs in; no seat needed) on 80 distinct IPv4 /24s (IPv6: 40 /48s using two /64s each) at one Cloudflare location, each reading GET /api/me/home once inside the same 6 s window, after that location's chain:index key (20/min) is spent; sustaining it for a minute takes roughly 700 /24s by the team's arithmetic, since a kept claim holds its network for a minute. Player impact: availability only. A genuine buyer at any other location whose roster listing is missing (so only the NFT index names the seat) gets recheck:'limited' with no index read and no seat counted, and so no owner mode, Enter or Move, for that 6 s slice and for as long as the slices are kept full; sign-in itself is unaffected. This matches the team's own statement (AUDIT_REMEDIATION_STATUS.md 'about 80 claims in one 6 s slice'), so it is recorded as confirmed, not new. Not checked: whether a refused Cloudflare rate-limit call is free (the team's stated open assumption), production D1 RETURNING behaviour, and real per-location limiter consistency.","line":278,"path":"source/server/auth.ts","reproduction":"Public harness, one Worker over node:sqlite. Location A's CHAIN_LIMITER: chain:index always refuses, chain:index:lane admits 20 per minute (windowLimiter), other keys allow. Create N throwaway sessions on N distinct /24s (10.x.y.5), advance the clock a minute, then have each read GET /api/me/home with the clock frozen (one 6 s slice). Then swap in location B's limiter (everything allowed except chain:index), sign in a fresh /24 (172.16.5.9) whose address the fake NFT index lists, and read GET /api/me/home. Observed: N=40 -> 20 index_lanes rows in the slice, 20 released; B's read makes the index read (recheck undefined). N=60 -> 40 rows kept; B still reads. N=80 -> 60 rows kept (20 read + 20 released + 40 held past the cap); B's answer is recheck:'limited' and NO index fetch reaches the fake Alchemy. N=100 -> same as 80. Expected by the ceiling's intent: a buyer at B whose location has room gets its index read; actual at >=80: refused in D1 with B's key never asked.","severity":"low","snippet":"export const INDEX_LANE_RETRY_MS=30_000,INDEX_LANE_RELEASES=20;","title":"AUD3-02 residual confirmed: about 80 lane claims in one 6 s slice at one location still close the site-wide index-lane ceiling for buyers at every other location"},{"citation":"resolved","description":"Prior finding: Swarm audit 1ef8e8a6 #5 (AUD3-05), reported as partly fixed. The fix drops the house and ends the check when GET /api/me/home answers for an address other than the held session, then re-reads the session; if that read fails (429, 503, network) the page still holds session A (address and expiry shown as 'Signed in · until ...' in WalletPanel.tsx:106) although the browser's cookie and the server session are B's. Preconditions: two tabs of one browser profile, tab 2 signs in with another wallet, tab 1's house re-check happens while /api/auth/session is refused or failing. Player impact: display only, and only to the player themself: owner mode, Enter and Move are all off (ownerAddress null, moveGate 'sign-in'), the badge names A, the member block (MemberBlock) stays mounted for A's member state while PUT /api/me/profile from it would be refused server-side with 409 ACCOUNT_CONTEXT_CHANGED (server/member.ts:177) and its GET answers for B are dropped by MemberClient.mine (src/world/member.ts:56), so no write lands on the wrong member. No impersonation, revival or cross-address effect found. Not checked: the real React panel (fixture renders only), real browsers' cookie timing.","line":256,"path":"source/src/world/auth.ts","reproduction":"Public harness: AuthClient over the real Worker with a fake wallet A holding seat 361 (owner mode reached). A second Browser sharing the same cookie jar signs in as B (the cookie is now B's). Make the page's fetch answer /api/auth/session with 429, advance 20 s, call client.refreshHome(true). Observed state: status 'ownershipUnavailable', session shown = A, home 'unavailable', checking false, notice 'rate-limited', ownerAddress null, moveGate 'sign-in'; GET /api/auth/session with the jar then answers signedIn:true for B. Expected by the audit's wording: the page no longer claims A's session; actual: A's session line stays until a session read succeeds (the team's test 'AUD3-05 guard' shows it then becomes a B mismatch).","severity":"info","snippet":"        if(this.s.session&&home.address.toLowerCase()!==this.s.session.address){this.set({home:'unavailable',checking:false});await this.restore();return;}","title":"AUD3-05 residual confirmed: after a house answer for another tab's wallet, the panel keeps showing the old session as signed in until a session read succeeds"},{"citation":"resolved","description":"Not a defect: the record the brief asks for. (1) Wallet surface: across source/src the only EIP-1193 calls are eth_accounts (auth.ts:205), eth_requestAccounts (auth.ts:307) and personal_sign of the server-built SIWE text (auth.ts:338), the last only after checkSignInMessage (siwe.ts:16-24) matched the message line for line against this origin, the connected account, the challenge nonce and the 5-minute window; no eth_sendTransaction, eth_signTypedData*, wallet_*, approve, setApprovalForAll, Permit or Permit2 string exists in src/, moves.ts drops any stored signature fields, and the CSP connect-src (public/_headers:24) allows only 'self', blob: and api.dexscreener.com, so the page cannot call an RPC node. The server verifies ECDSA (recoverMessageAddress) then ERC-1271 with the exact 32-byte magic word, refuses ERC-6492, burns a challenge on any failure, stores only token hashes, sets __Host- cookies, and the member routes never touch cookies (server/member.ts:39-41). What this cannot establish: the withheld WorldApp.tsx, interior, layout and households code is hashes only, and no static scan rules out script injected into the origin or a phishing relay of a real challenge (F-1); those remain the stated limits. (2) Solidity-oriented checks from the pinned Pashov, x-ray, Trail of Bits entry-point and ethskills references that do not apply because there is no contract here: reentrancy and checks-effects-interactions, ERC-20/721/4626 token semantics (fee-on-transfer, rounding, decimals), Multicall3 and ownerOf are only read (eth_call), overflow/unchecked arithmetic, block.timestamp/prevrandao manipulation, front-running/MEV, delegatecall/proxy/upgrade/initializer checks, selfdestruct, flash loans, oracle manipulation, Slither/Foundry. Checks that do carry over and were applied: signature replay (one-time nonce, chain id 1, domain and URI bound, expiry), ECDSA malleability (a high-s variant recovers to the same address but reuses an already consumed nonce, so no effect), ERC-1271 trust (a contract that accepts any signature can sign in as itself: F-2, stated), entry-point inventory with the guard of each route (Origin, limiter, body, session), and the state-changing-entry classification: public reads (names, assets, world), session-gated (bootstrap, profile, home, logout-all), unauthenticated-by-design (logout). (3) Team claims reproduced offline: wrangler deploy --dry-run from source/ with an empty dist produced index.js of 303,128 bytes, SHA-256 cf720c698417726ce75cd3b4740314489ed816ba98a763e74d8118b8be136518 (as stated); npm test 161/157/4 without stubs and 341/337/4 with the two stubs, the same four failures as TESTS/README.md; tsc --noEmit 16 diagnostics; npm audit 0 total with and without --omit=dev; rateLimitKey (worker/app.ts:82) answered the expected key for 24 edge inputs including ::ffff:1.2.3.4, 64:ff9b::, zone ids, leading zeros and ::1 (ip:unknown). The ERC-1271 release (AUD3-03) was probed under a limiter that counts refused calls: a released claim never produced an eth_call the key had not admitted, checked_at stays set and the burnt nonce cannot be reclaimed (409 CHALLENGE_USED); the only lock seen was the stated residual (two garbage checks from the owner's own /24 spend the address's share for the minute).","line":338,"path":"source/src/world/auth.ts","reproduction":"grep -nE 'eth_sendTransaction|signTypedData|wallet_|approve|Permit|setApprovalForAll' source/src -> only comments and the pre-sign wording; grep -n 'request(' source/src -> auth.ts:205, :307, :338 only. cd source && mkdir -p dist && npx wrangler deploy --dry-run --outdir out && sha256sum out/index.js -> cf720c69...6518, 303128 bytes. node --test tests/*.test.mjs -> 161/157/4; with TESTS/stubs copied in -> 341/337/4. These are observations of this snapshot, not a certification.","severity":"info","snippet":"      try{signature=await p.request({method:'personal_sign',params:[hexUtf8(message),account]}) as string;}","title":"Review record: no transaction, approval, Permit/Permit2 or typed-data path in the public client; Solidity checks inapplicable; team's rebuild and test claims reproduced"}],"hash":"0e3196e00ec629b22da53a79a71b3df7a202b21962c324f619d7449133f246f9","nodeId":"cf79fc6e-ab45-4f1f-b5a2-928271b27d56","outcome":"completed","summary":"Review complete. The findings file is valid, every quoted snippet matches its line in the tree, and no repository file other than `.imd-findings.json` was touched (probes ran in a `/tmp` copy).\n\n**What I found** (4 entries in `.imd-findings.json`):\n\n- **Low, new (M1):** `profile_requests` rows for refused name writes are never pruned unless that member later succeeds; the cron prunes four other tables but not this one. Probe: 3 simulated days of cooldown refusals left 21,601 rows, 14,406 of them past their own expiry, unchanged after the cron. Cost/storage only, no sign-in or house-rights impact.\n- **Low, confirmed residual (AUD3-02):** about 80 lane claims in one 6 s slice at one location still fill the site-wide ceiling. Probe: at 40 and 60 claims a buyer at another location still gets its index read; at 80 and 100 it is refused in D1 with its own key never asked. Matches the team's statement.\n- **Info, confirmed residual (AUD3-05):** after a house answer for another tab's wallet with the session re-read refused, the panel keeps showing the old session line, but owner mode, Enter and Move are off and a member write from that state is refused server-side with 409.\n- **Info, review record:** the public client calls only `eth_accounts`, `eth_requestAccounts` and `personal_sign` of the checked SIWE text; no transaction, approval, Permit or typed-data path exists in `src/`, and CSP blocks RPC calls from the page. It lists the Solidity checks that do not apply and the ones carried over, and notes the withheld UI files and injected-script/phishing relay as limits that code review cannot close.\n\n**What I verified without finding defects:** the ERC-1271 claim release never buys an un-admitted `eth_call` or revives a burnt nonce; `rateLimitKey` handles 24 edge inputs as documented; M1 writes are session-bound, cookie-free, Origin-checked and fail-closed; name uniqueness, cooldown, version and idempotency races roll back whole. The team's claims reproduced exactly: Worker rebuild hash `cf720c69…` at 303,128 bytes, tests 161/157/4 and 341/337/4, 16 tsc diagnostics, npm audit 0.\n\n**Not checked:** production D1, Cloudflare limiter semantics for refused calls, real wallets, and the withheld WorldApp/interior code.","treeHash":null,"usage":{"cachedInputTokens":4495771,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":68281,"runtime":"claude","turns":73,"wallClockMs":911480}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"35c52a5b502e847c","findings":[{"citation":"resolved","description":"The client confirmation names its held session address, but logoutAllRequest sends an empty body and the server revokes the address from the current cookie. Another tab can replace that cookie with wallet B while this page still displays A. In particular, the acknowledged AUD3-05 residual retains A after a mismatched home response if the subsequent session read fails. Logout-all then revokes B, reports success, and leaves A's other-device sessions live. This undermines the user's attempt to end a forgotten/stolen session. No outsider can choose an arbitrary address without a corresponding live cookie. Bind the requested address to the displayed session, reject mismatches before revoking anything, and reconcile the client. Returning an address after revocation alone does not prevent unwanted B revocations. Prior: F-4, AUD3-05/06/07; merges a5d0769d and the related display-only residual eadf088e. Limits: shared cookie harness, not real BroadcastChannel/browser timing. Prior Audit link: https://github.com/Identity-md/research/blob/main/jobs/1ef8e8a6-4297-4ff8-b869-2d9b91445d82/files/AUDIT.md (AUD3-05/06/07).","line":622,"path":"source/server/auth.ts","reproduction":"Real AuthClient/Worker/SQLite: tab 1 signs in A, who owns fixture seat 361 and reaches owner mode; another browser signs in A too. Have another tab of tab 1's profile sign in B, replacing the shared cookie. Return 429 for tab 1's GET /api/auth/session, advance 20 seconds, and call refreshHome(true,true). The house mismatch disables owner mode but state.session still names A and sessionKnown=false. logoutView(true,A,English) explicitly says it will log out A on every device. Call runLogout(\"all\",client,()=>{}). Actual: 200, client ended=\"signed-out\" and session=null; database has two live A sessions and one revoked B session; A's other browser GET /api/me/home still returns 200. Expected: revoke A only with matching authenticated context, or refuse and show that the cookie account changed.","severity":"low","snippet":"    db.prepare(REVOKE_ALL_SESSIONS).bind(now,s.address),","title":"Logout-all can revoke the cookie wallet while claiming to revoke the displayed wallet"},{"citation":"resolved","description":"M1 accepts every live session for profile writes, while readSession omits verification_method and wallet_type. For a contract whose isValidSignature accepts arbitrary signatures, anyone can create its member, publish a name against its address, and start the seven-day cooldown that blocks its controller from renaming it. This is conditional on the target contract having that behavior; it does not bypass a correctly restrictive ERC-1271 wallet or EOA verification. It expands the previously accepted F-2 view-only exposure into public state, contrary to the stated future-write boundary in source/docs/security/AUDIT_REMEDIATION_STATUS.md:251-262. Add a suitable authority check for contract-session writes, or restrict this feature until a contract-wallet write policy is agreed; blindly requiring an EOA signature would exclude ordinary smart wallets. Prior: F-2; specialist 4a197de6. Limits: no live contract or seat-holding target was identified, and no real wallet or production D1 was tested. House display additionally requires the target to hold a listed seat. No fund-loss path follows. Prior policy link: https://github.com/tungweb3/imd-ember-world-review/blob/6e307dea76e763936fc4ac86e54c9f5d558f58c4/source/docs/security/AUDIT_REMEDIATION_STATUS.md#smart-wallet-erc-1271-policy .","line":174,"path":"source/server/member.ts","reproduction":"Real Worker and migrations through wallet-harness.setup(): set chain.state.contracts[C]=()=>\"0x1626ba7e\" for C=0xcccccccccccccccccccccccccccccccccccccccc (the fake RPC supplies deployed code). POST challenge for C, then verify its nonce with signature=\"0x\"+\"ab\".repeat(65): 200; the session row is CONTRACT/ERC1271. POST bootstrap, then PUT profile with displayName=\"TrustedSeller\", its expectedActorPublicId, expectedProfileVersion=0, requestId=\"contract-0001\": 200/version 1. Anonymous GET /api/world/names/C publishes {\"name\":\"TrustedSeller\"}. A second browser signs in to C with another signature, reads version 1, and PUTs \"RealName\": 409 NAME_CHANGE_COOLDOWN until T+604800000. Expected under the documented F-2 boundary: this permissive sign-in alone cannot authorize a persistent public write. Actual: it does and locks subsequent renames for a week.","severity":"low","snippet":"  const now=(deps.now??Date.now)(),s=await readSession(request,db,now);\n  if(typeof s==='string')return fail(401,s==='none'?'AUTH_REQUIRED':s);\n  const m=await readMember(db,identityKey(s.address));\n  if(!m||m.public_member_id!==actor)return fail(409,'ACCOUNT_CONTEXT_CHANGED');","title":"M1 extends permissive ERC-1271 sign-in to persistent public name writes"},{"citation":"resolved","description":"The mitigation releases only 20 refused index-lane claims per six-second interval. Further claims rejected by an exhausted location remain counted against the 60-claim global ceiling even though no NFT-index request occurred. Four valid throwaway sessions making 20 home reads each from 80 distinct IPv4 /24 networks can fill it; 80 separate sessions are unnecessary. A buyer elsewhere whose location still has lane capacity but whose normal index budget is exhausted cannot discover a seat absent from roster/kept candidates. This temporarily removes owner-mode/Enter/Move availability, without granting any unproved seat or blocking EOA sign-in. Sustaining it needs further network slots and session read budget. Separate capacity for admitted reads from bounded refused-attempt accounting. Prior: acknowledged partial fix AUD3-02, Audit 1ef8e8a6 #2 (https://github.com/Identity-md/research/blob/main/jobs/1ef8e8a6-4297-4ff8-b869-2d9b91445d82/files/AUDIT.md); merges 93d64cb8 and cebd8119. Limits: production D1 RETURNING, Cloudflare location placement/consistency, WAF, and whether refused limiter calls cost budget were not tested.","line":279,"path":"source/server/auth.ts","reproduction":"In the real Worker/SQLite harness, prepare four attacker EOA sessions and one victim EOA session. Fixture chain/index says the victim owns seat 361; the swarm has its online agent but no owner listing, and there is no stored candidate. Advance 60 seconds and freeze time T=1790596860000. Set AUTH_LIMITER=windowLimiter(20,clock.now). At location A, CHAIN_LIMITER refuses chain:index and chain:index:lane. Make 80 sequential GET /api/me/home calls across distinct 10.0.i.5 /24s, round-robin over the four sessions. All 80 return limited; SQL shows 80 lane rows, 20 released and 60 retained at T. Switch to location B's limiter, which refuses chain:index but admits chain:index:lane. The victim at fresh /24 172.16.5.0/24 receives seats:[], eligible:0, recheck:\"limited\"; B's lane key is never called and there is no index fetch. Advance exactly 6000 ms and repeat: B's lane is called and seat 361 counts. Expected: A's refused reads do not consume otherwise usable B discovery capacity.","severity":"low","snippet":"export const INDEX_LANE_RELEASE=`UPDATE index_lanes SET at=?5,sub='released:'||coalesce(sub,'') WHERE net=?2 AND sub IS ?3 AND at=?4 AND (?1 IS NULL OR rowid=?1)\n AND (SELECT count(*) FROM (SELECT 1 FROM index_lanes WHERE at>?6 AND at<=?5 AND +sub GLOB 'released:*' LIMIT ?7))<?7`;","title":"AUD3-02 residual: refused discovery claims still consume the global lane ceiling"},{"citation":"resolved","description":"M1 records database-reaching refusals, but deletes expired profile_requests only in the successful name-change batch at lines 222-223. recordPresence never prunes these member tables. A signed-in member that submits only unavailable names, stale versions, or cooldown-blocked changes can keep adding persistent rows; a locked member cannot reach the success cleanup at all. This defeats the one-day retention bound and increases shared database storage and index cost without a time bound. Five recorded attempts per minute allow up to 7,200 rows/day/member before other limits; no production cost or exhaustion threshold was measured. Add bounded scheduled expiry cleanup, or cleanup on refusal paths too; apply an explicit retention policy to profile_history as well. Prior: new M1; merges specialist IDs fd71e25e, 46a0e38d, 3b975f7d and abc289d4. Limits: real handler and migrations over node:sqlite, not production D1 or any external housekeeping.","line":181,"path":"source/server/member.ts","reproduction":"Using tests/wallet-harness.mjs setup(), sign in a generated EOA and POST /api/me/bootstrap. At T=1790596800000, T+86400001, and T+172800002, PUT /api/me/profile with displayName=\"Admin\", the returned expectedActorPublicId, expectedProfileVersion=0, and distinct requestIds retention-0000 through retention-0002. Each returns 409 NAME_UNAVAILABLE. Observed total/expired profile_requests counts: 1/0, 2/1, 3/2. Call recordPresence(w.gateway,w.db,w.clock.now()); all three rows remain. Expected: expired attempt records are reclaimed according to the one-day retention policy; actual: another refused write and the cron reclaim none. No signature/session bypass is required.","severity":"low","snippet":"  const record=(outcome:string)=>db.prepare(`INSERT OR IGNORE INTO profile_requests(member_id,request_id,payload_hash,outcome,result_version,created_at,expires_at)\n    VALUES(?1,?2,?3,?4,NULL,?5,?6)`).bind(member,requestId,hash,outcome,now,now+REQUEST_KEPT_MS).run().catch(()=>{});","title":"Refused profile writes retain expired request rows indefinitely"},{"citation":"resolved","description":"The per-member attempt count and insertion of the refusal record are separate database operations. Concurrent authenticated requests can all read a count below five and each write a distinct refusal row. Version and name-uniqueness guards in the success batch do not enforce this budget. This increases database work beyond the stated 5/member/min cap, though the independent 20/IP/min member limiter and edge limits still constrain each IP. Reserve/check the member attempt budget atomically for both successful and refused outcomes. Prior: new M1, specialist 57f523f6. Limits: node:sqlite with controlled asynchronous scheduling; real Cloudflare D1 scheduling was not tested. No change to identity, house rights, or funds was observed.","line":190,"path":"source/server/member.ts","reproduction":"Bootstrap one signed-in EOA at version 0, with no recent profile_requests. Configure the harness AUTH_LIMITER=windowLimiter(20,w.clock.now). Send six concurrent same-origin PUT /api/me/profile requests, each displayName=\"Admin\", the member public ID, expectedProfileVersion=0, and requestIds race-0000 through race-0005. Wrap D1 statement.first() to hold completion of the recent-count SELECT until all six queries have returned n=0, then release all completions; SQL and results are unchanged. Actual: all six return 409 NAME_UNAVAILABLE and six distinct rows are inserted. Expected: only five attempts recorded, with the sixth refused as 429 NAME_RATE_LIMITED. Six requests fit the stated edge threshold.","severity":"low","snippet":"  const recent=await db.prepare('SELECT count(*) n FROM (SELECT 1 FROM profile_requests WHERE member_id=?1 AND created_at>?2 LIMIT ?3)')\n    .bind(member,now-60_000,PROFILE_WRITES_PER_MINUTE).first<{n:number}>();\n  if((recent?.n??0)>=PROFILE_WRITES_PER_MINUTE)return fail(429,'NAME_RATE_LIMITED',{retryAfterSeconds:60},{'Retry-After':'60'});","title":"Concurrent refusals exceed the five-attempt member write budget"},{"citation":"resolved","description":"After successful verify headers set the session cookie, a response-body failure falls through to the generic catch at line 357. It releases the unsettled hold without invalidating sessionKnown from the earlier signed-out read. The page shows sign-in failure with session=null even though its cookie authenticates; the next click skips session reconciliation and asks for another personal_sign. An ordinary interrupted HTTP response suffices. Treat uncertain verify completion as unknown session state and reconcile before another wallet prompt, retaining abandoned-flow cleanup. Prior: CORR-02 and ADV-3/RC-1 recovery; specialist 29080397. This is distinct from the fixed R3-R1 account-event ordering. Limits: synthetic response stream, real handler/SQLite and synthetic signer; no actual browser transport or wallet tested. No fund-loss path observed. Prior Report link: https://github.com/Identity-md/research/blob/main/jobs/dcf922ca-68de-4cc5-bfbc-8b226008b0bf/files/artifacts/report.md .","line":351,"path":"source/src/world/auth.ts","reproduction":"With a fresh Browser jar and AuthClient wired to the real Worker, call signIn(). Let GET session return signedIn:false, then let challenge, personal_sign, and verify succeed. Apply Browser.keep(realVerifyResponse) to retain its Set-Cookie, but return HTTP 200 with those headers and a ReadableStream that errors with TypeError(\"Network body truncated\"). Actual: signIn ends with session=null, sessionKnown=true, notice=\"failed\"; direct GET session reports signedIn:true. Call signIn again with normal responses. Observed request sequence: session, challenge, verify, challenge, verify, home; two personal_sign calls and two unrevoked sessions. Expected: reconcile and reuse the live cookie session without a second signature.","severity":"low","snippet":"      const s=await v.json() as {address:string;expiresAt:number},session={address:String(s.address).toLowerCase(),expiresAt:s.expiresAt};","title":"An unreadable verify response leaves an undisclosed live session and permits another signature prompt"},{"citation":"resolved","description":"MemberClient.save retries failures of fetch itself, but consumes a successful response body outside that recovery block. If the Worker commits the name and the response stream then fails, save rejects without clearing saving or rereading the profile. Every later save immediately returns false, and the form keeps its controls disabled. This requires only a transport failure for an ordinary signed-in player. Include body consumption in uncertain-completion recovery using the same requestId, and always release saving for the current generation on terminal failure. Prior: new M1, specialist 15198e46. Limits: real Worker/SQLite with a synthetic failed stream; production transport and withheld full UI integration were not tested.","line":89,"path":"source/src/world/member.ts","reproduction":"Bootstrap a signed-in EOA at profile version 0 and start MemberClient on that session. Call save(\"EmberCat\"). Let the real PUT /api/me/profile return 200 after committing version 1, then substitute a Response with the same status/headers whose ReadableStream errors with TypeError(\"Network body truncated\"). Actual: save rejects, client remains saving=true/error=null/version=0, while a direct GET profile returns EmberCat/version=1. A subsequent save(\"EmberMoon\") returns false without another PUT (total PUT count=1). Expected: retry/reconcile the committed operation or show a recoverable error and enable the form. State reset/page reload currently restores use.","severity":"low","snippet":"      const next=await r.json() as MemberView;if(!this.mine(gen,next))return false;","title":"A failed profile response body leaves the naming form stuck in saving state"},{"citation":"resolved","description":"MemberBlock treats any non-null nextNameChangeAt as an active cooldown. The server clears the field only in a fresh response, while MemberClient follows address changes and does not schedule a deadline refresh. Thus an open page keeps disabling rename after the seven-day deadline, including when the panel is rendered again. This affects an ordinary user who loads the panel shortly before expiry; it does not require keeping a session open for a week. Refresh at the deadline or use a server-adjusted clock and scheduled rerender, retaining server enforcement. Prior: new M1, specialist a3ae2d41. Limits: real handler/client and component fixture render, not the withheld WorldApp lifecycle; reloading recovers.","line":92,"path":"source/src/world/MemberPanel.tsx","reproduction":"At T=1790596800000, sign in, bootstrap, and PUT \"EmberCat\" at version 0. At T+604799000 (one second before the seven-day deadline), obtain a fresh session and load MemberClient. It holds nextNameChangeAt=1791201600000. Advance two seconds. A direct GET /api/me/profile now returns nextNameChangeAt=null, but the existing client still holds the timestamp. Render the actual MemberBlock using tests/fixtures/member-panel.mjs with that state and now=1791201601000. Actual markup includes <button class=\"secondary\" disabled=\"\">Change name</button>. Expected: rename is available once the server cooldown has elapsed.","severity":"low","snippet":"  const cooling=v.nextNameChangeAt!==null;","title":"The rename button stays disabled after its cooldown expires"}],"hash":"1abb3575e441b60f4e5bc27de84ef1fa88856dfcda5b1c47709283d108f0a5cc","nodeId":"84c18aa5-cfc6-4c41-93aa-50409ff74218","outcome":"completed","summary":"Wrote **8 reproduced, deduplicated low-severity findings** to [.imd-findings.json](/home/identitymd5/.identitymd/work/f3e7cfc7-0b43-473a-9c0f-6931cf278c56/84c18aa5-cfc6-4c41-93aa-50409ff74218/.imd-findings.json). Application and configuration files are unchanged.\n\nThe findings cover:\n\n- Logout-all revoking a different wallet than the confirmation names.\n- Permissive ERC-1271 sessions gaining persistent public-name writes.\n- Expired refusal records accumulating indefinitely.\n- Concurrent requests exceeding the member attempt cap.\n- Failed verify bodies causing another signature prompt.\n- Failed profile bodies leaving the form stuck saving.\n- Rename remaining disabled after cooldown expiry.\n- The acknowledged AUD3-02 global discovery-lane exhaustion residual.\n\nReproduced checks matched the supplied results: **157/161 without stubs**, **337/341 with stubs**, including all **90 focused regression cases**. The four failures and 16 TypeScript diagnostics match the documented package omissions/history dependency. The Worker rebuilt to **303,128 bytes** with the exact supplied SHA-256.\n\nThe published client requests only `eth_accounts`, `eth_requestAccounts`, and `personal_sign` behind the SIWE message check. **No transaction, approval, Permit/Permit2, typed-data, or fund-loss path was found in the provided code.** This conclusion excludes withheld code, injected scripts, phishing, and live deployment behavior.\n\nSolidity reentrancy, token/vault accounting, MEV, proxy/storage-layout, Solidity arithmetic, and Foundry/Slither checks are inapplicable. Signature replay, authorization, expiry, and asynchronous ordering checks remain applicable.\n\nLive wallets, production D1/Cloudflare behavior, and the complete UI remain unverified. HEAD and parent metadata matched; unavailable historical Git objects prevented a complete parent diff. Dependency vulnerability counts were not independently refreshed.","treeHash":null,"usage":{"cachedInputTokens":3438720,"inputTokens":221684,"model":"gpt-6-astra","outputTokens":21201,"runtime":"codex","turns":7,"wallClockMs":934781}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"30a6c1a419ef4f9c","findings":[{"citation":"resolved","description":"isReservedName compares the skeleton of the whole key against exact 1-3 part combinations of BRAND/AUTHORITY/COMBO words, after stripping underscores and trailing digits only. A single trailing letter, such as an English plural 's', makes the remainder unmatched, so names that read as site staff are accepted by both the client form and the server (PUT /api/me/profile) and are then served publicly by GET /api/world/names/:address and shown on that wallet's house panel as '<name> 的家'. The database 'system' rows (migration 0006) hold only the seven exact keys, so they do not catch these either. Preconditions: any signed-in wallet (one SIWE sign-in), one name write. Player impact: impersonation of site staff or moderators in the public world; the owner's moderation path is manual SQL after the fact (scripts/member-moderate.mjs). Prior finding link: none (M1 is new; related design note in memberName.ts 'compared whole'). Not checked: what names a real moderator would consider staff-like beyond the samples below; the rule is heuristic by design, so this is a coverage gap, not a bypass of a stated guarantee. Suggested minimal fix: also test the skeleton with a trailing 's'/'es' removed (as trailing digits are), or add those plural keys to RESERVED_NAMES and the 0006 system rows.","line":75,"path":"source/src/world/memberName.ts","reproduction":"Run through the real handler (tests/wallet-harness.mjs setup, a signed-in browser, POST /api/me/bootstrap, then PUT /api/me/profile with the member's publicMemberId, version 0 and a fresh requestId). displayName 'Admins' -> 200, member.displayName 'Admins'; GET /api/world/names/<that address> -> {\"name\":\"Admins\"}. Same for 'Moderators', 'IMD_Admins', 'Officials', 'Support_Staffs' and 'Admin1_Team' (all 200). Expected: 409 NAME_UNAVAILABLE as for 'Admin', 'Adm1n', 'IMD_Staff_Team' (which are refused). Unit level: isReservedName(checkName('Admins').key) returns false; isReservedName('admin') returns true.","severity":"low","snippet":"  for(const f of forms)if(f&&parts(f,3,false))return true;","title":"M1 reserved-name rule lets plural and suffixed staff names through (Admins, Moderators, IMD_Admins, Officials)"},{"citation":"resolved","description":"Every refusal that reaches the database (PROFILE_LOCKED, PROFILE_VERSION_CONFLICT, NAME_CHANGE_COOLDOWN, NAME_UNAVAILABLE) writes a profile_requests row via record() at line 181, but the only DELETE of expired rows is inside the success batch of putProfile. GET /api/me/profile, POST /api/me/bootstrap and the cron (server/presence.ts recordPresence, which prunes challenges, sessions, index_candidates and index_lanes only) delete nothing from profile_requests or profile_history. The migration comment 'Kept a day (expires_at), pruned by the member's next write' therefore only holds for a member whose next write succeeds. A member inside its 7-day cooldown (every member for 7 days after its first name), or a locked member, can only ever be refused, so its rows accumulate at the per-member cap (5 per minute, minus the success row) indefinitely: about 5,760 rows a day per such member, bounded only by the per-IP member limiter (20/min) and the number of wallets the sender signs in with. Preconditions: one or more signed-in wallets with a name already set. Player impact: availability/cost only (D1 storage and rows written grow without bound; no sign-in or ownership effect). The per-member count read (LIMIT 5 on the (member_id, created_at) index) stays bounded, so reads do not degrade. Prior finding link: none (M1 is new). Not checked: production D1 billing thresholds; whether the team runs any out-of-band cleanup.","line":222,"path":"source/server/member.ts","reproduction":"Through the real handler: sign in, bootstrap, PUT a first name (cooldown now 7 days). Then every 15 s send a PUT with a new displayName ('Other'+i) and a fresh requestId for 24 h of fixture clock: 5,760 responses are 409 NAME_CHANGE_COOLDOWN. SELECT count(*) FROM profile_requests -> 5761. Run recordPresence(gateway, db, now) (the cron) -> still 5761; 2 rows already have expires_at <= now and remain. A following GET /api/me/profile and POST /api/me/bootstrap -> still 5761. Expected: rows past expires_at removed within a day as the migration comment states.","severity":"low","snippet":"    db.prepare('DELETE FROM profile_requests WHERE member_id=?1 AND expires_at<=?2').bind(member,now),","title":"M1 profile_requests rows written for refused name writes are never pruned unless the same member later succeeds; cron prunes nothing of migration 0006"},{"citation":"resolved","description":"The moderation SQL turns only the claim_type='active' row into 'quarantined' and sets the profile to needs_rename with next_name_change_at NULL. A member who renamed before moderation still holds its previous names as 'reserved' rows (OLD_NAME_KEPT_MS 30 days), and server/member.ts claimable() treats the member's own reservation as free, while the needs_rename state skips the cooldown check (member.ts:198 applies only to 'ready'). The member can therefore switch straight back to an earlier name the moderator did not quarantine, immediately and without the 7-day cooldown, and that name is public again at once. Preconditions: a moderated member that had renamed at least once in the last 30 days. Player impact: a moderation action can be partly undone by the member in one request (the earlier name may be the one that drew complaints, e.g. a near-staff name); the moderator must act again. Prior finding link: none (M1 is new). Not checked: the owner's intended moderation policy for former names; the script is run by hand, so operators may handle this case manually.","line":26,"path":"source/scripts/member-moderate.mjs","reproduction":"Sign in, bootstrap, PUT 'FirstBad' (200). Advance 7 days, sign in again, PUT 'SecondBad' (200; 'firstbad' is now claim_type 'reserved' for the member). Run moderationSql('rename', publicMemberId, 'offensive', now) against the database: profile is needs_rename, 'secondbad' is quarantined, 'firstbad' stays reserved. Immediately PUT 'FirstBad' with the new version -> 200, displayName 'FirstBad', profileState 'ready'; GET /api/world/names/<address> -> {\"name\":\"FirstBad\"}. Expected per the script's intent ('an unsuitable name is handled'): the member's reserved names are also quarantined, or at least the normal cooldown applies.","severity":"info","snippet":"      `UPDATE nickname_claims SET claim_type='quarantined',reserved_until=NULL,reason='${reason}',updated_at=${now}\n  WHERE claim_type='active' AND member_id=${m};`,","title":"Moderation 'rename' quarantines only the active name; the member's reserved former names stay reclaimable at once, with no cooldown"},{"citation":"resolved","description":"The uniqueness key is the NFKC display string with ASCII letters lowercased. The look-alike folding (skeleton: i/l/1 -> l, 0 -> o, rn -> m, ...) is applied to the reserved-name check only, as the comment at line 58 states. Since names are shown on house panels next to only a shortened address, a second member can register a name visually identical to an existing player's and be mistaken for them. Preconditions: a signed-in wallet and one name write. Player impact: player-to-player impersonation (not staff: that is the separate finding on plurals); no house right or session effect. Prior finding link: none (M1 is new; the choice is documented in memberName.ts, so this records the consequence rather than a violated rule). Not checked: how prominent the full address is in the live house panel (WorldApp.tsx is withheld).","line":44,"path":"source/src/world/memberName.ts","reproduction":"checkName('Alice').key -> 'alice'; checkName('AIice').key -> 'aiice'; checkName('A1ice').key -> 'a1ice'; checkName('0xAlice').key -> '0xalice'; checkName('OxAlice').key -> 'oxalice'. Two members PUT 'Alice' and 'AIice' respectively -> both 200 and both served by GET /api/world/names/:address. Expected under a look-alike rule: the second is 409 NAME_UNAVAILABLE as 'alice' vs 'ALICE' already is.","severity":"info","snippet":"  return {ok:true,display,key:display.replace(/[A-Z]/g,c=>c.toLowerCase())};","title":"Player name uniqueness folds only ASCII case, so look-alike names (Alice / AIice / A1ice, 0x / Ox) are distinct public names"},{"citation":"resolved","description":"publicName answers a name only for profile_state 'ready'. scripts/member-moderate.mjs 'lock' sets profile_state 'locked' and documents it as 'no name change at all until unlock (PROFILE_LOCKED); the current name stays', and MemberBlock still shows the name to the member. The public route, however, returns null for a locked profile, so the house panel drops the name for as long as the lock lasts. Preconditions: a moderator runs 'lock'. Player impact: a locked (not renamed) member's public name disappears; if locking is meant as 'freeze the name', this contradicts it; if it is meant as 'hide', the script text and the member's own panel disagree with it. Prior finding link: none (M1 is new). Not checked: which of the two behaviours the owner intends; ROUTES.md documents the 'ready only' answer, so this may be intended and then only the script comment and MemberBlock are misleading.","line":124,"path":"source/server/member.ts","reproduction":"Sign in, bootstrap, PUT 'FirstBad' -> 200; GET /api/world/names/<address> -> {\"name\":\"FirstBad\"}. Run moderationSql('lock', publicMemberId, 'spam', now). GET /api/me/profile -> displayName 'FirstBad', profileState 'locked'. GET /api/world/names/<address> -> {\"name\":null}. Expected per the script's wording: {\"name\":\"FirstBad\"} (or the script and panel should say the name is hidden while locked).","severity":"info","snippet":"    WHERE w.identity_key=?1 AND p.profile_state='ready'`).bind(identityKey(address)).first<{display_name:string}>();","title":"Locking a member (moderation 'lock') also removes its public name from GET /api/world/names, although the script says the current name stays"}],"hash":"aff255cf0071d9e2b81fa42a049adc6f1baf26cf686ce4fa1f7d763dacf26a66","nodeId":"fe5f3017-8530-46ed-9ed9-f2e6cbc257f3","outcome":"completed","summary":"The review is complete. The findings file holds five substantiated findings, no other repository file was changed, and the scratch copies are removed.\n\n## Outcome\n\nNothing I found touches sign-in, sessions, house rights or funds. The five findings are all in the new member layer M1 and its moderation script, rated Low and Info. They are written to `.imd-findings.json`:\n\n- **Low, reserved-name rule misses plurals.** `Admins`, `Moderators`, `IMD_Admins`, `Officials`, `Support_Staffs` and `Admin1_Team` save with 200 and are served publicly by the names route. The whole-match rule strips underscores and trailing digits only, so one trailing letter escapes it.\n- **Low, refused name writes accumulate forever.** Refusal rows in the request table are only pruned inside a later successful rename. The cron prunes nothing of migration 0006. A member in its 7-day cooldown wrote 5,761 rows in a fixture day, and the cron, a GET and a bootstrap deleted none.\n- **Info, moderation rename leaves former names reclaimable.** Only the active claim is quarantined. The member switched back to a reserved earlier name immediately, with no cooldown, and it went public at once.\n- **Info, look-alike names are distinct.** Uniqueness folds ASCII case only, so `Alice`, `AIice` and `A1ice` coexist as public names.\n- **Info, lock hides the public name.** The names route answers only for `ready` profiles, while the script says a locked member's name stays and the member's own panel still shows it.\n\n## What I verified and found sound\n\n- **Wallet methods.** The client calls exactly `eth_accounts`, `eth_requestAccounts` and `personal_sign` on a server-built SIWE text checked line by line. No transaction, approval, Permit or typed-data path exists in the public source. No fund-loss path.\n- **Prior findings.** Stored-field SIWE equality, ERC-6492 refusal, exact ERC-1271 magic word with one check per challenge, hashed tokens, `__Host-` cookies, live-session logout-all scope, closed write limiters and ownerOf-only seats all held under my probes, including racing verifies and tampered stored messages.\n- **AUD3-01 to AUD3-08 and R3-R1.** The server diff since the parent contains only the listed fixes plus the member dispatch. AUD3-03 released claims bought zero eth_call and zero eth_getCode across ten re-asks at a refusing key. AUD3-02's IPv6 release marker behaves as documented. The team's stated residuals for AUD3-02, AUD3-05 and AUD3-06 are accurate as written; I found no cheaper path than theirs.\n- **M1 trust boundary.** No member route sets or clears a cookie. The session alone picks the member. The `member:` limiter key is separate from challenge and verify keys, so M1 spends no sign-in budget.\n- **Reproducibility.** Test counts match the team's record exactly. The offline Worker dry-run rebuild produced the stated SHA-256 and byte size. tsc gives the stated 15 TS2307 and 1 TS7006. npm audit reports zero vulnerabilities.\n\n## Not checked and inapplicable\n\nI could not check the live deployment, real Cloudflare limiter counting of refused calls, production D1 batch atomicity, real wallet event ordering, or the withheld WorldApp and geometry. Solidity checks for reentrancy, rounding, oracles, delegatecall, upgradeability, flash loans and token transfer semantics do not apply: this repository contains no contract, and the only chain interaction is read-only RPC. The one ERC-1271 caveat that carries over is that a contract wallet is checked once at sign-in and then trusted for 7 days, which the team documents as F-2.","treeHash":null,"usage":{"cachedInputTokens":3996179,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":62463,"runtime":"claude","turns":58,"wallClockMs":1062301}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"98b4506bef931d13","findings":[{"citation":"resolved","description":"POST /api/auth/logout-all revokes every live session of the address named by the cookie's session (s.address) and the client (src/world/auth.ts:369, signOut(true)) treats a 200 as having ended the session the page displays (this.s.session, used for the confirm text in walletView.ts logoutView). Nothing binds the two: the request body is {} and the response is {revoked:n} with no address. One browser profile has one cookie, so when another tab of the same profile signs in with wallet B, the cookie is B's while this tab still shows A. The page normally learns of that on its next session read, but in the window before it does (BroadcastChannel message not yet processed, no BroadcastChannel, or the team's stated AUD3-05 residual where the session re-read fails with 429/503/network and the prior session stays displayed), the confirm reads \"This logs out <A> on every browser and device\", the user confirms, the server revokes all of B's sessions, and the page says \"Signed out.\" with ended:'signed-out'. A's sessions on other devices, the thing F-4 logout-all exists to end (a stolen or forgotten session), remain live, and the player is told the opposite. M1 already uses the right pattern for writes (expectedActorPublicId refuses a tab whose cookie now belongs to another wallet, server/member.ts:177); logout-all has no such guard. Prior finding links: F-4 (logout-all), AUD3-05 (mismatched house residual), AUD3-06/07 (cross-tab logout). Not checked: a real browser's BroadcastChannel timing; only the harness's shared cookie jar and a refused session read were used. Fix: send the address the page is ending in the logout-all body and have the server answer 409 (revoking nothing) when it differs from the session's address, then have the client re-read the session and show the mismatch; or at least return the revoked address and have the client refuse to report 'signed-out' when it differs from this.s.session.address.","line":622,"path":"source/server/auth.ts","reproduction":"Harness (tests/wallet-harness.mjs + AuthClient as in tests/wallet-client.test.mjs): tab 1 (AuthClient over browser profile b) signs in A and reaches 'owner'; a second profile (device 2) signs in A; then b.signIn(B) (another tab of the same profile) sets the profile's cookie to B's session. Make tab 1's /api/auth/session answer 429 and call client.refreshHome(true,true): the state still holds session A (status ownershipUnavailable, the AUD3-05 residual). logoutView(true,state.session.address) says 'This logs out 0x<A>… on every browser and device'. runLogout('all',client) → POST /api/auth/logout-all with B's cookie → 200. Expected: A's sessions revoked or the request refused. Actual (probe output): client.state.ended='signed-out', session=null; D1 sessions: A live, A live, B revoked; device 2's GET /api/me/home with A's cookie still 200.","severity":"low","snippet":"    db.prepare(REVOKE_ALL_SESSIONS).bind(now,s.address),","title":"\"Log out all devices\" acts on whatever wallet's cookie the browser holds, so the page can report a wallet logged out everywhere while its other-device sessions stay live (cross-address logout-all)"},{"citation":"resolved","description":"bootstrapMember and putProfile resolve the member from readSession's address only; neither reads sessions.verification_method/wallet_type, so a session proven by ERC-1271 is treated exactly like an ECDSA one. The team's own F-2 policy (docs/security/AUDIT_REMEDIATION_STATUS.md, 'Smart-wallet (ERC-1271) policy') states: 'Anything that later grants more than a view to a session must not rely on an ERC-1271 sign-in alone.' M1 grants a persistent public write: POST /api/me/bootstrap creates the member and PUT /api/me/profile sets the name that GET /api/world/names/:address publishes and HomePanels.tsx shows on that wallet's house. For a contract whose isValidSignature returns the magic value for any input (the F-2 case the team names: some vaults, escrows, badly written wallets), anyone can sign in with a garbage signature, set a name such as 'IMD_Vault_Official' or 'TrustedSeller' on that address, and because next_name_change_at is then now+7 days, the contract's legitimate controller signing in afterwards gets 409 NAME_CHANGE_COOLDOWN and cannot replace it for a week (only hand moderation can). Preconditions: an any-signature ERC-1271 contract exists on mainnet; for the name to appear on a house it must also hold a seat IMD's roster lists; the public names route answers for any address regardless. Player impact: impersonation/defamation via a public label on a wallet the attacker does not control, and a week-long lockout of its controller. Prior finding link: F-2 (accepted for owner mode as 'a read-only view on the player's own screen'; M1 widens it to a public write). Not checked: whether any seat-holding contract on mainnet accepts arbitrary signatures (Audit has no network). Fix options that keep the design: refuse bootstrap/PUT for sessions whose verification_method is ERC1271 (answer a distinct code the panel explains), or require an ECDSA session for the first name and renames, or mark names set from CONTRACT sessions and let moderation see them; at minimum document the acceptance.","line":141,"path":"source/server/member.ts","reproduction":"Harness: w=setup(); C='0x'+'c'.repeat(40); w.chain.state.contracts.set(C,()=>'0x1626ba7e') (code at C, isValidSignature answers magic for anything). Attacker browser: POST /api/auth/challenge {address:C}, POST /api/auth/verify {nonce, signature:'0x'+'ab'.repeat(65)} → 200, sessions row wallet_type=CONTRACT verification_method=ERC1271. POST /api/me/bootstrap → 200 needs_name for C. PUT /api/me/profile {displayName:'IMD_Vault_Official',expectedActorPublicId,expectedProfileVersion:0,requestId} → 200 profileState ready. GET /api/world/names/<C> from an anonymous browser → {name:'IMD_Vault_Official'}. The controller (another browser, also ERC-1271) bootstraps and PUTs 'RealName' with the version it read → 409 NAME_CHANGE_COOLDOWN nextNameChangeAt=+7d. Expected per the team's F-2 policy: no persistent public write from an ERC-1271-only session, or an explicit documented acceptance. Actual: the name is set and published.","severity":"low","snippet":"  const address=s.address.toLowerCase(),key=identityKey(address),found=await readMember(db,key);","title":"M1 name writes trust an ERC-1271 session alone: a contract that accepts any signature gets a public player name set by anyone, and the real controller is locked out by the 7-day cooldown"},{"citation":"resolved","description":"migrations/0006 says profile_requests rows are 'Kept a day (expires_at), pruned by the member's next write', but the only DELETE is inside putProfile's success batch (this line). Every refusal that reaches the database (PROFILE_VERSION_CONFLICT, NAME_UNAVAILABLE, NAME_CHANGE_COOLDOWN, PROFILE_LOCKED) inserts a row through record() and never deletes one, and server/presence.ts recordPresence prunes login_challenges, sessions, index_candidates and index_lanes but not profile_requests or profile_history. A member who never succeeds (deliberately sending a stale expectedProfileVersion) adds 5 rows a minute (PROFILE_WRITES_PER_MINUTE) for as long as it likes: 7,200 rows/day per member, 28,800 rows/day per IP under the 20/min member limiter, each row plus its two index entries (PK and profile_requests_recent) billed as D1 rows written, and never reclaimed. 100 IPs with throwaway sessions: about 2.9 M rows written a day and roughly 0.5 GB/day of storage growth against D1's 5 GB included, with no bound in time, unlike every other table whose worst case the code documents. The per-member read (LIMIT 5 on profile_requests_recent) stays bounded, so this is cost/storage, not latency. Preconditions: any signed-in wallet (sign-ins are cheap: 30 challenges/min per /24). Player impact: availability (D1 storage/cost exhaustion over weeks). Prior finding link: none (M1 is new); related to the D1 cost bounds in server/auth.ts's NETWORK_CHALLENGE_BUDGET comment. Not checked: production D1 billing behaviour. Fix: add 'DELETE FROM profile_requests WHERE expires_at<?1' (and the same for profile_history) to recordPresence's housekeeping, and/or prune in the refusal path too.","line":222,"path":"source/server/member.ts","reproduction":"Harness: sign in A, POST /api/me/bootstrap. Send 6 PUT /api/me/profile per minute for 3 minutes with expectedProfileVersion:99 (stale) and fresh requestIds: answers 409 ×5 then 429 NAME_RATE_LIMITED each minute. SELECT count(*) FROM profile_requests → 15. Advance the clock 3 days and run recordPresence(gateway,db,now): still 15 rows, all with expires_at<=now. GET /api/me/profile and one more refused PUT → 16 rows. Only a successful PUT (version 0, 'GoodName') prunes: 2 rows. Expected: expired rows removed within about a day by the cron, as the migration comment states. Actual: they persist until that member succeeds, which an abuser never does.","severity":"low","snippet":"    db.prepare('DELETE FROM profile_requests WHERE member_id=?1 AND expires_at<=?2').bind(member,now),","title":"profile_requests rows are pruned only by a member's next successful name write; refused writes accumulate past expires_at forever and the cron never prunes them"},{"citation":"resolved","description":"isReservedName accepts a name only when its skeleton is exactly 1 to 3 parts from BRAND/AUTHORITY/COMBO ('compared whole, never contains'). Any extra token that is not in those lists ends the recursion with false, so an authority word followed by an arbitrary word is allowed: 'Moderator_John', 'Admin_Vault', 'Official_Pepe', 'Support_Alice', 'Staff_Bob', 'GM_Tom', 'System_Account', '客服_小美', '管理員小明', 'IMD_Vault_Official' all pass checkName+isReservedName and the seven 'system' rows in nickname_claims do not catch them either. These are the usual impersonation shapes ('Moderator_<name>'), the very thing the AUTHORITY list exists for, while the documented non-matches (Badminton, Modern, IMDFan, EmberCat) are substrings, a different case. Preconditions: any signed-in wallet. Player impact: a house label and My-wallet name that reads as staff; the name is tied to a visible wallet address and there is no chat, so impact is limited to display. Not checked: the owner's intended policy (the rule's comment describes exact-combination matching as intended, so this may be accepted). Fix: treat a name as reserved when any part is in AUTHORITY (or BRAND+AUTHORITY) regardless of the remaining parts, keeping the whole-word rule for COMBO words.","line":65,"path":"source/src/world/memberName.ts","reproduction":"import {checkName,isReservedName} from 'src/world/memberName.ts'; for 'Moderator_John': checkName ok, key 'moderator_john', isReservedName → false (expected true for an authority-prefixed name). Same for 'Admin_Vault', 'Support_Alice', 'Staff_Bob', 'GM_Tom', 'System_Account', '管理員小明'. Through the route: PUT /api/me/profile {displayName:'Moderator_John',...} → 200 profileState ready; GET /api/world/names/<addr> → {name:'Moderator_John'}. Contrast: 'Admin', 'Adm1n', 'Admin01', 'IMD_Admin', '官方_客服' → NAME_UNAVAILABLE.","severity":"info","snippet":"function parts(s:string,left:number,hasStrong:boolean):boolean{","title":"The reserved-name rule is defeated by any unknown token: 'Moderator_John', 'Admin_Vault', 'Support_Alice', 'Staff_Bob', 'GM_Tom', 'System_Account' and '管理員小明' are accepted as player names"},{"citation":"resolved","description":"publicName returns display_name only for profile_state='ready'. scripts/member-moderate.mjs 'lock' sets profile_state='locked' and documents it as 'no name change at all until unlock (PROFILE_LOCKED); the current name stays'. After a lock the member's GET /api/me/profile still returns displayName with profileState 'locked' and MemberBlock renders it with 'Your name can't be changed right now', but GET /api/world/names/:address answers {name:null}, so the house label and other players' panels drop the name (useHouseName also returns null for the member's own house since it checks profileState==='ready'). If hiding is intended (name under review), the member is not told; if it is not, locked members lose their public name for the lock's duration. Either way the two views disagree. The active nickname_claims row stays, so nobody else can take the name meanwhile. Preconditions: an operator lock. Player impact: disclosure/display inconsistency only. Not checked: the owner's intent for lock. Fix: either include 'locked' in the public query's accepted states, or tell the member in the panel that the name is hidden while locked.","line":124,"path":"source/server/member.ts","reproduction":"Harness: sign in A, bootstrap, PUT 'EmberCat' → 200; GET /api/world/names/<A> → {name:'EmberCat'}. Run the SQL from moderationSql('lock',publicMemberId,'spam',now) on the database. GET /api/me/profile → profileState 'locked', displayName 'EmberCat'. GET /api/world/names/<A> → {name:null}. moderationSql('unlock',...) → the public name returns. Expected (per the script's description): the current name stays public, or the member is told it is hidden. Actual: hidden publicly, shown privately.","severity":"info","snippet":"    WHERE w.identity_key=?1 AND p.profile_state='ready'`).bind(identityKey(address)).first<{display_name:string}>();","title":"A moderation 'lock' silently removes the member's public name while the member's own panel keeps showing it, contradicting the script's 'the current name stays'"}],"hash":"c61a367aba54d3f174ab37b5dbce5d3974ccf39a83a099ab910749b560192971","nodeId":"9be69ccf-585d-4137-a2c8-6e20ea3b05a6","outcome":"completed","summary":"The review is complete. The findings file `.imd-findings.json` holds five substantiated findings and no repository file was modified. All probes ran against the real Worker handler over node:sqlite with the real migrations in a scratch copy under /tmp.\n\n**Findings (all with reproductions in the file)**\n\n- **Low, cross-address logout-all** (`server/auth.ts:622`, client `src/world/auth.ts:369`). Logout-all revokes the cookie's address, but the page reports success for the session it displays. In the team's own AUD3-05 residual state (another tab signed in B, session re-read failing), tab 1's confirm names A, the server revokes B everywhere, the page says \"Signed out.\", and A's other-device sessions stay live. M1 already guards writes with an expected actor id; logout-all has no equivalent.\n- **Low, ERC-1271 session sets a public name** (`server/member.ts:141`). Member routes never read the session's verification method. For an any-signature contract, a garbage signature yields a session, a public name on that wallet, and a 7-day cooldown that locks out the real controller. This contradicts the team's stated F-2 policy that nothing beyond a view may rely on ERC-1271 alone.\n- **Low, unbounded profile_requests growth** (`server/member.ts:222`). Only a successful name write prunes a member's rows; the cron prunes nothing in M1. Refused writes accumulate 5 rows per minute per member indefinitely. Probe: 15 expired rows survived 3 days and a cron run.\n- **Info, reserved-name rule** (`src/world/memberName.ts:65`). \"Moderator_John\", \"Admin_Vault\", \"Staff_Bob\", \"GM_Tom\", \"管理員小明\" are accepted, because any unknown token ends the exact-combination match.\n- **Info, lock hides the public name** (`server/member.ts:124`). A moderation lock makes the public lookup answer null while the member's own panel still shows the name, contrary to the script's \"the current name stays\".\n\n**Verified as claimed, no defect found**\n\n- The client calls only eth_accounts, eth_requestAccounts and personal_sign (grep of src). No transaction, approval, Permit or typed-data path exists; no fund-loss path.\n- AUD3-03: a refused budget releases the contract claim, burns the challenge (replay 409), and sends no eth_call; the nonce never revives. AUD3-02: release dates the row 30 s back, re-claim admitted at 31 s, 20 releases per slice then rows kept. AUD3-08: all edge inputs mapped as documented. AUD3-06: session reads, home 401, refused logout-all and all four M1 routes send no Set-Cookie. ERC-6492 refused with 400. Superseded challenge refused.\n- Test numbers match the team's: 90/90 focused, 33/33 M1, 341/337/4 with stubs, the four stub-related failures as described.\n\n**Not checked**: production D1 batch atomicity, Cloudflare limiter counting of refused calls (the AUD3-02/03 assumption stays open), real wallets, and the live deployment.\n\n**Inapplicable Solidity checks**: reentrancy, integer overflow and casts, ERC-4626 rounding, oracle staleness, delegatecall and upgradeability, selfdestruct, tx.origin, fallback/receive entry points, and Foundry proofs. No Solidity exists in this repository, so no finding carries a proof.","treeHash":null,"usage":{"cachedInputTokens":3189698,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":64414,"runtime":"claude","turns":48,"wallClockMs":877783}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"0256823ae36e7900","findings":[{"citation":"resolved","description":"M1 checks the recent request count separately from recording the outcome. Concurrent requests can all observe fewer than five rows and subsequently insert distinct profile_requests rows. The atomic version/name batch protects profile ownership and uniqueness, but does not enforce this per-member write budget; refused names take the non-atomic record() path too. A signed-in attacker can amplify database writes beyond the advertised 5/member/min limit while remaining within the 20/IP/min member limiter. Impact is resource consumption, not impersonation or token loss. Reserve the member budget atomically with both successful and refused outcomes. Prior finding: none; this is new M1. Verified with the real handler and migrations using node:sqlite and a barrier on count-query completion; production Cloudflare D1 scheduling was not tested.","line":190,"path":"source/server/member.ts","reproduction":"Start with a valid live session and bootstrapped member at profile version 0, no profile_requests in the last minute, and an otherwise unused member IP limiter. At the same server timestamp send six concurrent PUT /api/me/profile requests with allowed Origin and JSON {displayName:\"Admin\", expectedActorPublicId:<that member public ID>, expectedProfileVersion:0, requestId:\"race-0000\"} through requestId:\"race-0005\". Delay completion of each recent-count SELECT until all six have read n=0, then release them; this changes ordering only, not SQL or results. Actual: all six return 409 NAME_UNAVAILABLE and six distinct profile_requests rows are written. Expected: at most five recorded attempts and a 429 NAME_RATE_LIMITED for the sixth. Six requests fit the stated edge 20/10s limit.","severity":"low","snippet":"  const recent=await db.prepare('SELECT count(*) n FROM (SELECT 1 FROM profile_requests WHERE member_id=?1 AND created_at>?2 LIMIT ?3)')\n    .bind(member,now-60_000,PROFILE_WRITES_PER_MINUTE).first<{n:number}>();\n  if((recent?.n??0)>=PROFILE_WRITES_PER_MINUTE)return fail(429,'NAME_RATE_LIMITED',{retryAfterSeconds:60},{'Retry-After':'60'});\n  const refused=async(error:string)=>{await record(error);return refuse(error,m,now);};","title":"Concurrent profile refusals bypass the five-writes-per-member cap"},{"citation":"resolved","description":"After a successful verify response sets the session cookie, failure while reading v.json() at line 351 reaches this catch/finally. The client releases its unsettled verify hold but keeps sessionKnown=true from the earlier signed-out read, with session=null. A subsequent Sign in click therefore skips reconciliation and requests another personal_sign even though this browser already has a valid session. The first session also remains live while the page presents a failed sign-in. This needs an interrupted response body, not script injection or a malicious wallet. Invalidate cached session knowledge on uncertain verify completion and re-read the actual session before permitting another prompt; preserve cleanup for abandoned flows. Prior relation: additional failure case in CORR-02 and ADV-3/RC-1 recovery, not the R3-R1 accountsChanged ordering fixed in Report dcf922ca (https://github.com/Identity-md/research/blob/main/jobs/dcf922ca-68de-4cc5-bfbc-8b226008b0bf/files/artifacts/report.md). Verified with the real Worker, SQLite, synthetic signer and cookie jar; real browser transport and wallet behavior were not exercised. No fund-loss path was observed.","line":357,"path":"source/src/world/auth.ts","reproduction":"Use wallet-harness.setup(), a new in-memory account and AuthClient with that browser jar. First GET /api/auth/session returns signedIn:false. Allow challenge, personal_sign and the real verify handler to succeed; apply Browser.keep(realResponse) so the successful Set-Cookie is retained, then replace only its body with an HTTP 200 ReadableStream that errors with TypeError(\"Network body truncated\"). Await signIn(). Actual client state is session:null, sessionKnown:true, notice:\"failed\", while a direct real-handler GET session returns signedIn:true and one unrevoked session exists. Click signIn() again: recorded client requests are session, challenge, verify, challenge, verify, home, with two personal_sign calls and two unrevoked sessions. Expected: reconcile the cookie session after the uncertain response and reuse it without a second signature.","severity":"low","snippet":"    }catch{if(g===this.gen)this.set({phase:'idle',notice:'failed'});}\n    finally{decided();if(g===this.gen)this.busy=false;}","title":"A failed verify response body leaves a live session unknown and prompts for another signature"},{"citation":"resolved","description":"The AUD3-02 mitigation releases at most 20 refused claims per six-second slice. The next 60 claims refused by one location remain in index_lanes and exhaust the site-wide active ceiling, although no NFT-index request was sent. A legitimate buyer at another location with spare lane budget then cannot discover a seat absent from the roster/kept candidates. Preconditions are valid attack sessions, enough distinct network keys (80 IPv4 /24s in the reproduced schedule), and exhausted chain:index and chain:index:lane budgets at the attacking location; the victim also needs the fallback discovery lane. This is the acknowledged residual of prior AUD3-02, not a newly introduced regression. Prior finding: Audit 1ef8e8a6 #2, https://github.com/Identity-md/research/blob/main/jobs/1ef8e8a6-4297-4ff8-b869-2d9b91445d82/files/AUDIT.md. Separate refused-attempt cost accounting from capacity reserved for reads that can proceed, while retaining bounded database cost. Local real-handler/SQLite reproduction confirms the arithmetic; production D1, Cloudflare location placement, WAF and whether refused limiter calls consume budget were not verified. No seat is granted without ownerOf.","line":279,"path":"source/server/auth.ts","reproduction":"At t=1790596800000, share the D1 database between locations A and B. Prepare four attacker EOA sessions and one victim session before the slice; the victim owns seat 361 in the chain/index fixture, absent from the swarm owners and stored candidates. At A configure chain:index and chain:index:lane to refuse (their budgets spent), with the ordinary AUTH_LIMITER window of 20. Send 80 home requests from 80 distinct IPv4 /24 network keys, round-robin over the four attacker sessions; each source IP makes one request. Actual: 80 limited responses, 20 released rows and 60 unreleased rows at t. At B, let chain:index refuse but chain:index:lane admit. GET /api/me/home for the victim returns seats:[], recheck:\"limited\", and never calls B's lane limiter. Exactly at t+6000, retrying returns seat 361 and calls that limiter once. Expected: A's refused reads should not consume B's usable site-wide discovery capacity. Repeating across slices requires enough network slots/sessions to respect their minute limits.","severity":"low","snippet":"export const INDEX_LANE_RELEASE=`UPDATE index_lanes SET at=?5,sub='released:'||coalesce(sub,'') WHERE net=?2 AND sub IS ?3 AND at=?4 AND (?1 IS NULL OR rowid=?1)\n AND (SELECT count(*) FROM (SELECT 1 FROM index_lanes WHERE at>?6 AND at<=?5 AND +sub GLOB 'released:*' LIMIT ?7))<?7`;","title":"Refused discovery claims still exhaust the global lane after the release cap"},{"citation":"resolved","description":"The save retry try/catch covers fetch() only; parsing the successful response body is outside it. When the server commits a name but the response stream fails, save() rejects without clearing saving or reconciling the committed profile. Subsequent calls return false at line 78, and the naming form keeps its Saving state/buttons disabled. This is a transport-failure availability defect for an ordinary signed-in player, with no attacker privileges required and no funds or wallet-signature effect. Extend recovery to body consumption, reuse the same request ID for uncertain completion, and clear saving for the current generation on terminal failure. Prior finding: none; new M1. Reproduced with real Worker/SQLite and a synthetic failing response stream; production browser/network transport and withheld WorldApp integration were not exercised.","line":89,"path":"source/src/world/member.ts","reproduction":"Bootstrap a signed-in member at version 0 and load it into MemberClient. Call save(\"EmberCat\"). Let the real PUT /api/me/profile commit and return 200/version 1, then present its status/headers to the client with a ReadableStream that emits a JSON prefix and errors with TypeError(\"connection reset during body\"). Actual: save rejects at r.json(), database profile is EmberCat/version1, but client has saving:true, error:null and version0. Calling save(\"EmberMoon\") returns false without another HTTP request (PUT count remains 1); the form is stuck until client state is reset, for example by reloading the page. Expected: retry/re-read using the original request ID or surface a recoverable error and release the saving state.","severity":"low","snippet":"      const next=await r.json() as MemberView;if(!this.mine(gen,next))return false;\n      this.set({saving:false,view:next,phase:'ready',error:null});return true;","title":"A truncated profile-save response leaves M1 permanently in saving state"},{"citation":"resolved","description":"The panel treats a non-null nextNameChangeAt as a permanent boolean lock. memberView only changes that field to null on a new server read; MemberClient does not refresh it as time passes, and follows only address changes. The current panel therefore continues disabling rename after the server cooldown ends, even when rendered again. An ordinary player opening the panel just before expiry encounters this without any attacker or long-lived session. Refresh at the deadline or compare a server-adjusted clock and schedule a rerender; retain server enforcement. Prior finding: none; new M1. Verified against the real handler, MemberClient state and component rendering. The withheld WorldApp and full browser lifecycle were unavailable; reloading the page obtains the correct state.","line":92,"path":"source/src/world/MemberPanel.tsx","reproduction":"Set a first name at time T, so next_name_change_at=T+604800000. At T+604799000 establish a fresh seven-day session and load the member in MemberClient; its response has non-null nextNameChangeAt=T+604800000. Advance 2000 ms. Actual: a fresh direct GET /api/me/profile says nextNameChangeAt:null, but the existing client still holds the timestamp and the real MemberBlock renders <button class=\"secondary\" disabled=\"\">Change name</button>. Expected: at or after T+604800000 the button can open the rename form. Reopening/rendering the panel with the same client does not clear the lock.","severity":"low","snippet":"  const cooling=v.nextNameChangeAt!==null;","title":"The rename button remains disabled after the seven-day deadline"},{"citation":"resolved","description":"Each recorded refusal creates a profile_requests row with a one-day expiration, but the only cleanup (line 222) runs in the successful-name-change batch. A member making only refused writes never reaches it; the scheduled Worker only calls recordPresence and does not prune member requests. Authenticated callers can thus accumulate expired rows indefinitely while respecting the per-member rate limit (up to 7200 rows/day/member at five per minute). This bypasses the intended retention bound and adds persistent shared-database storage/index cost; actual production storage exhaustion was not measured. Prune expired rows on refusal paths or with bounded scheduled cleanup, and define expired-idempotency behavior consistently. Prior finding: none; new M1. Reproduced with the real handler and SQLite; production D1 storage/quota effects were not tested.","line":181,"path":"source/server/member.ts","reproduction":"Bootstrap one member at version 0 with a live seven-day session. PUT /api/me/profile with displayName:\"Admin\", its expectedActorPublicId and expectedProfileVersion:0, using request IDs retention-0000, retention-0001 and retention-0002 at T, T+86400001 and T+172800002 respectively. All requests return 409 NAME_UNAVAILABLE and are far below the rate limits. Actual profile_requests counts are 1, 2 and 3; expired-row counts are 0, 1 and 2. Repeat distinct refused requests on subsequent days: no cleanup path is reached. Expected: the next recorded write removes prior expired records as the one-day retention specifies, or an independent bounded cleanup removes them.","severity":"low","snippet":"  const record=(outcome:string)=>db.prepare(`INSERT OR IGNORE INTO profile_requests(member_id,request_id,payload_hash,outcome,result_version,created_at,expires_at)\n    VALUES(?1,?2,?3,?4,NULL,?5,?6)`).bind(member,requestId,hash,outcome,now,now+REQUEST_KEPT_MS).run().catch(()=>{});","title":"Refused name writes retain expired request rows indefinitely"}],"hash":"e937fb6a25ec56f94f2f961fa1b8a35b82e309b88e177d1a35a56009d2a7369d","nodeId":"5fb61de0-a15a-4729-99c6-af84d4d8a24a","outcome":"completed","summary":"Recorded **six low-severity findings** in [.imd-findings.json](/home/imd-worker/.identitymd/work/f3e7cfc7-0b43-473a-9c0f-6931cf278c56/5fb61de0-a15a-4729-99c6-af84d4d8a24a/.imd-findings.json), with concrete reproductions and verified snippets.\n\n**169 selected tests passed** using an adapted, in-memory Node 22 harness. Source files remain unchanged.\n\nThe supplied client exposes only account access and SIWE `personal_sign`; no fund-loss path was established. Solidity arithmetic and Foundry checks are inapplicable. Production D1, live deployment, real wallets, and withheld UI remain unverified.","treeHash":null,"usage":{"cachedInputTokens":3394560,"inputTokens":138068,"model":"gpt-6-astra","outputTokens":12305,"runtime":"codex","turns":7,"wallClockMs":903371}}],"verification":[]}