# Audit report

> IMD Ember World (https://imdember.com) - re-audit of wallet sign-in, sessions and home authorization after the fixes for audit 8c3aea2e (World only)
>
> Please read this first: this repository contains NO Solidity and no smart contract. It is a TypeScript Cloudflare Worker (the server) and the TypeScript/React client code for wallet sign-in (SIWE, EIP-4361). The site never asks a wallet for a transaction, a token/NFT approval, a Permit/Permit2 or typed-data signature; the only signature is personal_sign of a server-built SIWE text. The team states there is no "loss of funds" path by design (please verify rather than assume it). Rate findings by what an attacker could do to a player through this code: sign in as an address without its key, keep or revive a session after revocation, end another address's sessions, get owner rights for seats or a house that are not theirs, make the page ask the wallet to sign anything other than the site's own sign-in text, leak data, or deny sign-in or the owner's house (availability is in scope). If your checklist is Solidity-only, say which parts you could not apply rather than forcing them.
>
> Repository: the public review snapshot pinned at the commit shown when "Read" was clicked: the newest commit, whose parent is ae1d41a30363ad04711083465501680469400d2f (the version audit 8c3aea2e reviewed). Code is in source/. Root docs are in Traditional Chinese and are the team's claims; the code is the reference. README.md maps each finding of audit 8c3aea2e (N-1..N-7) to what changed and what remains; none of these fix statuses has been re-reviewed.
>
> Facts you cannot check without network (team statements, from the deploy record and read-only GETs on 2026-10-01):
> - Live: Cloudflare Worker "imd-world" version bbf24001-7eec-4f93-b312-a22e299ab275, built from private commit 2e4e830b367f651e3c880587c1a4b465d1bfcd91 (source/ comes from a later commit that differs only in two docs and one test, plus one added evidence page).
> - Rebuilding the Worker from source/ alone (wrangler deploy --dry-run) gives SHA-256 018df7b35117bf612cd9311a800de75964b07f9d74f2c2f1ae545b26894cf62c (280,605 bytes), equal to the deploy record (manifests/).
> - D1 migrations 0001-0005 applied (0005_lanes_and_subnets before this code); rate-limit bindings as in source/wrangler.jsonc; an edge rule blocks an IP sending over 20 /api/ requests in 10 s.
>
> Entry points: source/worker/app.ts routes /api/* to server/auth.ts handleAccountApi (:571), then server/world-api.ts, else static assets.
> - POST /api/auth/challenge (challenge :423; INSERT_CHALLENGE :169), POST /api/auth/verify (verify :452; verifySignature :374), POST /api/auth/logout (:536) and logout-all (:549), GET /api/auth/session (:529, now says expired:true for a cookie whose session ran out; readSession :410).
> - GET /api/me/home (owner data from the session's address only; server/ownership.ts class Ownership :202), GET /api/wallet/:address/assets (public), GET /api/world/* (public data; shared Cache API copy, worker/app.ts edgeCopy :51).
> - Cron: server/presence.ts (prunes challenges, sessions, index_candidates and the new index_lanes).
> - Client: src/world/auth.ts (AuthClient; statusOf :62; personal_sign :278 after siwe.ts checkSignInMessage :16), homeEntry.ts (enterGate :11), walletView.ts, WalletPanel.tsx.
>
> What changed since ae1d41a (please verify each fix, then look for what the fix itself broke):
> - N-1: session reads carry a sequence (auth.ts sessionReads :121, :189); a superseded read's answer, error or parse failure changes nothing; a click waits for the newest read (:244). Any ordering left where an older read clears or restores a session?
> - N-2: after the challenge body, gen, the active provider and account are re-checked before the prompt (auth.ts :266-271). Can a cancelled, switched or torn-down flow still reach personal_sign or verify?
> - N-3: past the 256-candidate cap the rank uses the counting rule incl. owner-bound 24 h sightings (ownership.ts counts/rank :143-148). Can an attacker still push the one counting seat out, or make the extra read unbounded?
> - N-4: claims record called_via 'pool' or 'lane' (CLAIM_CONTRACT :197, CLAIM_LANE :212; migration 0005). Can the lane now be used beyond its stated budget, or the owner still be held out cheaply?
> - N-5: IPv6 shares nest: each /64 at most a /24's share, each /48 at most NET6_SCALE=2 (:158; worker/app.ts networkKey :74, subnetKey :82; login_challenges.sub). Can /64 rotation exceed the stated bounds? Does code deployed before 0005 fall back safely (the *_0004 statements)?
> - N-6: a refused first NFT-index discovery takes the network's discovery lane (INDEX_LANE :234, key chain:index:lane; ownership.ts :283-287). Does the lane open an unbounded upstream cost, or a seat granted without ownerOf?
> - N-7: the end of a session is 'expired', 'revoked' or 'signed-out' (auth.ts :204, :227; walletView.ts endedText :59). Does the session route's new expired flag leak anything?
>
> Please also re-check, as before: signature verification (message re-read from D1 only; domain, URI, chain id 1, version, statement, Issued At, Expiration Time equal to the stored row; ERC-6492 refused; ERC-1271 needs code and exactly the magic word; one check per challenge), session issuance and revocation (one session per nonce, token stored as SHA-256, __Host- cookies, 7-day expiry, logout-all only by a live session), limiters failing closed (permit :323), ownership only from ownerOf and the session address, and the page-side check (only this site's exact 11-line message for this account and nonce reaches personal_sign; it does not stop injected script or a phishing page: known limit).
>
> Tests: source/tests (node --test; TESTS/README.md: copy source/ into its own git repo, npm ci, add the two stubs from TESTS/stubs). Real handler, migrations on node:sqlite, synthetic keys; only npm ci needs network. Snapshot run: 216 tests, 212 pass, 4 fail (three need withheld house geometry or interior code, one needs the team's git history); the N-1..N-7 tests (node --test --test-name-pattern="^N-") 43/43. Recorded outputs are in TESTS/.
>
> Out of scope: Genesis Mint (no Mint code here; reviewed separately; see source/docs/security/MINT_BOUNDARY.md), withheld files (3D world, art, music, house placement, interior rendering, WorldApp.tsx; listed by hash in manifests/).
>
> Report each finding with severity, file:line, the attacker's preconditions and the impact on a player, and a reproduction or a clear argument; say which earlier finding it relates to, and list what you could not check. This is a code review record, not a certification: please do not call the site safe, secure, audited or certified.

| | |
|---|---|
| Repository | https://github.com/tungweb3/imd-ember-world-review.git |
| Commit | `8cad017fad58bac89d88fa72d530d3c56160009b` |
| Job | `1ef8e8a6-4297-4ff8-b869-2d9b91445d82` |
| Judged | 2026-10-01 09:15 UTC |
| Findings | 3 low · 6 info |

Four agents audited the code as it is at `8cad017`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Low: N-6 lane rebuild: a failed NFT-index read on the lane answers 503 and drops the seats ownerOf had just proven, where the same request without the lane answers 200 "limited"; the network's lane is spen

`source/server/ownership.ts:288`

```
      proof=await this.proof(a,world.owners,world.agents,{...req,budget:async()=>true},fresh,true);seen=await this.sightings(proof.ids,a,req.db);}
```

Relates to N-6 (what the fix itself changed) and to A-2. Merged from three specialist reports (audit_flow 63e71c66, audit_permissions 8ae81175, audit_economics bd226365): one mechanism, one fix. home() first builds a proof under the location's chain:index budget. When the budget refuses, that proof is "refused": it still lists the roster-named seats that ownerOf proved, and the route answers 200 with recheck:"limited". If none of those seats counts, home() takes the network's lane and rebuilds the proof with budget forced to true (this line). The rebuild is not guarded. Inside proof() the index loader now really calls Alchemy; if that call fails (non-2xx, timeout, malformed body) and no earlier answer exists for the address in this isolate or in index_candidates, ownership.ts:251 rethrows OwnershipUnavailable, home() lets it through and the route answers 503 OWNERSHIP_UNAVAILABLE (logged as auth_refused). The first proof, already paid for, is discarded for this request. The index_lanes row and the chain:index:lane unit are already spent, so the next request in that minute cannot try again (it is served the cached refused proof: 200 limited). Attacker preconditions: chain:index refused at the player's Cloudflare location (the N-6 premise: one IP with throwaway sessions can do it), the player's answer counts no seat and nothing is kept for the address (a new buyer, or a holder whose agents were last seen more than 24 h ago), the lane admitted, and one failing NFT-index call (the attacker cannot force that; Alchemy erroring or rate-limiting the key does it). Impact on a player: availability/display only. Instead of the seat list with its reasons and "the on-chain check could not be completed", the panel shows "can't confirm seats" (home:"unavailable") for that read, and the one lane of the player's network for the minute is gone. No seat is granted or lost, owner mode is unaffected (nothing counted either way), and the "never owns nothing" rule holds (503, not an empty house). Expected: a lane read that fails degrades like every refused read (200 limited on the first proof). Fix, keeping the design: catch OwnershipUnavailable around the rebuild and keep the refused proof, e.g. try{proof=await this.proof(...,true);seen=...}catch(e){if(!(e instanceof OwnershipUnavailable))throw e;} ; optionally release the lane row when the read failed.

**Reproduction**

Harness for every reproduction below: a copy of source/ with npm ci and the two TESTS/stubs, the real Worker handler (createWorker) over node:sqlite with migrations 0001-0005, the tests' fakeImd/fakeChain, synthetic keys, an injected clock (tests/wallet-harness.mjs); client cases use the real AuthClient wired to that handler through the harness cookie jar. World: seat #100 registered (agent 50100), offline; IMD roster owners[100]=V and chain ownerOf(100)=V; seat_presence row (100, V, START-30 h); nothing in index_candidates. CHAIN_LIMITER refuses only key "chain:index". V signs in from 198.51.100.20 (ECDSA, 200). Then the fake Alchemy NFT API answers 502 (chain.state.fail="index"; ownerOf still works). GET /api/me/home with V's cookie. Expected (and what the two controls answer): 200 {seats:[{tokenId:"100",reason:"offline-24h"}],eligible:0,recheck:"limited"}. Actual: 503 {"error":"OWNERSHIP_UNAVAILABLE"}; log line {"evt":"auth_refused","route":"/api/me/home","status":503,...}; "chain:index:lane" asked once, 1 row in index_lanes, 1 NFT-index call. A second GET in the same minute with Alchemy healthy again: 200, seat 100, recheck "limited", no further index read (lane spent). Control 1 (limiter also refuses "chain:index:lane"): 200 with seat 100, recheck "limited". Control 2 (database with migrations 0001-0004 only, so no lane): 200 with seat 100, recheck "limited". My probe output: P1 laneAllowed first=[503,"OWNERSHIP_UNAVAILABLE",null,null,laneKey 1,laneRows 1,indexReads 1] second=[200,null,[["100","offline-24h"]],"limited"]; laneRefused first=[200,null,[["100","offline-24h"]],"limited"]; before0005 first=[200,null,[["100","offline-24h"]],"limited"].

### 2. Low: N-6 lane: the index_lanes row is written (and counted site-wide) before the location key is asked, so a refused key spends the network's lane for the minute, and unread rows from one location can fill

`source/server/auth.ts:622`

```
      try{if((await db.prepare(INDEX_LANE).bind(net,deps.sub??null,t,t-NETWORK_WINDOW_MS,netScale(net),t-INDEX_LANE_WINDOW_MS,INDEX_LANE_BUDGET).run()).meta.changes!==1)return false;}
```

Relates to N-6. From audit_economics 98cfbde2, reproduced and extended. The lane closure runs INDEX_LANE first (one row per /24 a minute, two per /48, 60 per 6 s site-wide) and only then asks the per-location key "chain:index:lane" (20 a minute, fails closed). When the key refuses, or its binding throws, the row stays although no index read was made. Two consequences: (1) INDEX_LANE refuses that network for the rest of the minute even when the key has room again seconds later, so a new buyer whose request met a saturated key waits up to one more minute (the comment at :618 says the D1 count comes first so refused claims never spend the key; the reverse cost, a refused key spending the lane, is not handled; the team's test at tests/ownership.test.mjs:537-538 pins the row being written). (2) Rows that bought no read still count toward the site-wide ceiling. The key bounds reads at 20 a minute per location, but not rows: 60 networks at ONE location, each with a throwaway session, write 60 rows in a 6 s slice (only 20 reads happen) and the lane is refused in D1 for a buyer at ANY other location in that slice, without that location's key being asked. Sustaining it takes 600 network-minutes (IPv6: 300 /48s using two /64s each), which README lists as the site-wide residual, but that residual is stated in lanes; here it needs no upstream capacity and one location instead of thirty. Attacker preconditions: chain:index spent at the victim's location (N-6 premise) plus either the location's lane key saturated for part of a minute (20 networks, the documented residual) or 60 network slots per 6 s anywhere. Impact on a player: availability only (first discovery of a newly bought seat, and so "Enter my home", delayed); no ownership is granted, nothing leaks, upstream cost is not increased. Fix: keep the D1 count as the first guard but do not leave a row for a read that was not made: delete the row when permit() returns false or throws (DELETE FROM index_lanes WHERE net=?1 AND sub IS ?2 AND at=?3), or write the row only after the key admitted the read (a SELECT of the two counts, then the key, then the INSERT that re-checks them).

**Reproduction**

Harness for every reproduction below: a copy of source/ with npm ci and the two TESTS/stubs, the real Worker handler (createWorker) over node:sqlite with migrations 0001-0005, the tests' fakeImd/fakeChain, synthetic keys, an injected clock (tests/wallet-harness.mjs); client cases use the real AuthClient wired to that handler through the harness cookie jar. Case 1 (lane spent by a refused key): seat #361 registered and online; chain and NFT index say it belongs to new buyer V; IMD roster still names the seller; nothing kept. CHAIN_LIMITER: "chain:index" always refuses; "chain:index:lane" refuses at t=0 and allows from t=31 s. V signs in from 198.51.100.20. t=0 GET /api/me/home: 200 {seats:[],recheck:"limited"}, index_lanes rows 1, NFT-index reads 0, lane key asked 1. t=31 s GET /api/me/home?fresh=1: expected the lane (the key now allows); actual 200 {seats:[],recheck:"limited"}, lane key asked 0 times (INDEX_LANE refused in D1), still 0 index reads. t=60 s+1 ms: seats ["361"], no recheck. Probe output: P2 t0=[200,[],"limited",rows 1,reads 0,key 1] t31=[200,[],"limited",1,0,0] t60=[200,["361"],null,2,1,1]. Case 2 (site-wide ceiling filled by unread rows): limiter models two locations A and B, each with its own 20-a-minute "chain:index:lane" key; "chain:index" refused at both. 60 throwaway sessions from 60 /24s (100.64.k.1) read /api/me/home at A within one 6 s slice: all answer 200, index_lanes has 60 rows, only 20 NFT-index reads were made (key asked 60 times, 40 refused). Buyer V (198.51.100.20) then reads at B in that slice. Expected: V's lane (B's key is unused and only 20 lane reads happened anywhere). Actual: 200 {seats:[],recheck:"limited"}, B's key asked 0 times. Control with 20 flood networks: V gets seats ["361"], no recheck. Probe output: PA afterFlood={laneRows:60,indexReads:20,keyAskedA:60} buyer=[[],"limited"] keyAskedB=0; PA-control buyer=[["361"],null] keyAskedB=1.

### 3. Low: N-4 / A-1: an ERC-1271 contract check the location key then refuses stays claimed in D1, so a smart-wallet owner's own retries at a saturated location use up the address's two shared checks and the la

`source/server/auth.ts:391`

```
  const c=await gate.contract();if(!c||!await gate.budget(known,c==='lane'))return 'busy';   // LimiterMissing propagates (503)
```

Relates to N-4 (whose fix I verified: a lane check is no longer used up by the network's own earlier pool check) and to A-1 / F-3. From audit_economics cc8aaeb1, reproduced. verifySignature claims the contract check in D1 first (gate.contract: CLAIM_CONTRACT sets called_at and called_via="pool", else CLAIM_LANE) and then asks the per-location key (chain:erc1271, :known or :lane). When the key refuses, the answer is 429 CHAIN_BUSY and the challenge is burnt, but called_at stays: the address's share (ERC1271_ADDRESS_SHARE, 2 a minute over all networks), the network's share and the /24's "own" count are spent by a check that never reached the chain. So while "chain:erc1271" refuses at the owner's location, the owner's first two attempts each record a pool claim with no eth_call; from the third the address share refuses and CLAIM_LANE refuses too (total(own)=2: "a /24 that made both of the address's shared checks itself takes none"), logged as reason "address". When the key has room again seconds later the owner is still refused until those claims leave the one-minute window. Attacker preconditions: the location key refusing for part of a minute (an attacker needs >= 7 /24s at >= 10 contract addresses per the stated costs; honest load of 20 first-time smart-wallet checks a minute also does it) and a victim using a contract wallet (ERC-1271). Impact on a player: availability only: sign-in delayed up to the rest of the minute beyond the key's own refusal, made worse by the owner's own retries. No sign-in without a valid signature, no session revived. ECDSA wallets are not affected. Fix: release the claim when the key refuses (in the busy branch: UPDATE login_challenges SET called_at=NULL,called_via=NULL WHERE nonce=?), or record it with a called_via value the three counts exclude.

**Reproduction**

Harness for every reproduction below: a copy of source/ with npm ci and the two TESTS/stubs, the real Worker handler (createWorker) over node:sqlite with migrations 0001-0005, the tests' fakeImd/fakeChain, synthetic keys, an injected clock (tests/wallet-harness.mjs); client cases use the real AuthClient wired to that handler through the harness cookie jar. X=0x5a5a...5a has code and its isValidSignature answers the magic word (fake chain). CHAIN_LIMITER refuses key "chain:erc1271" and allows every other key. The owner signs in as X from 198.51.100.20 (a fresh challenge each time, signature by the owner key, so ECDSA does not match X and ERC-1271 is needed). Attempts 1-3 at t=0: 429 CHAIN_BUSY each, log reasons "budget","budget","address"; login_challenges then holds 2 rows with called_via="pool"; eth_call count 0. The key is then set to allow and the owner retries at t=5 s. Expected: 200 (the key has room; no check of X ever reached the chain). Actual: 429 CHAIN_BUSY, reason "address", still 0 eth_call. At t=60 s+1 ms: 200, the first eth_call. Probe output: P3 res=[[429,"CHAIN_BUSY"],[429,"CHAIN_BUSY"],[429,"CHAIN_BUSY"],[429,"CHAIN_BUSY"],[200,null]] logs=["budget","budget","address","address"] rows=[{called_via:"pool",n:2}] ethCallBeforeMinute=0 ethCallAfter=1.

### 4. Info: N-1: a session read begun while this page's sign-out is on its way is applied after the sign-out, so the page shows the revoked session again ("signed out" becomes "no longer signed in", or stays sign

`source/src/world/auth.ts:189`

```
    const g=this.gen,q=++this.sessionReads,stale=()=>g!==this.gen||q!==this.sessionReads;this.sessionAt=this.now();
```

Relates to N-1 (the ordering question in the brief: yes, one ordering is left where an older answer restores a session in the page). New in this review. stale() drops a session read when gen or sessionReads moved after it began. signOut() bumps gen once, before its POST (auth.ts:300), and applies the result at :307 without bumping anything. A session read that begins while the sign-out is in flight (another tab's channel message at :158, the tab becoming visible at :146, the house-mismatch re-read at :220) therefore carries the current gen and the newest sequence; if the server answered it before the revocation and its response lands after the sign-out was applied, readSession() writes session A back (:197: session set, the hint stored again, ended:null) and reads the house. That house read is 401 AUTH_REQUIRED, so the page ends at ended:"revoked" ("You are no longer signed in") instead of "Signed out."; if that house read fails (lost connection: home:"unavailable"; a 429 gives the same session state) the page keeps showing session A with status ownershipUnavailable and the hint for A, until some later read. The same window exists for the logout sent by accountChanged (:321) and revokeAbandoned (:325). Attacker preconditions: none usable by another party; it needs the player's own second read racing their own sign-out, and the two answers arriving in the other order. Impact on a player: display only. The server session is revoked and the cookie cleared in every case; owner mode is not restored (home is reset to null and only a successful /api/me/home can set it; it answers 401); no wallet prompt results. On a shared computer the page can look signed in after "Log out this device" until the next read. Fix: when a sign-out (or the switch/abandon logout) is applied, bump sessionReads (or gen) so a read begun before that moment is stale, as the sign-in success path does at :288.

**Reproduction**

Harness for every reproduction below: a copy of source/ with npm ci and the two TESTS/stubs, the real Worker handler (createWorker) over node:sqlite with migrations 0001-0005, the tests' fakeImd/fakeChain, synthetic keys, an injected clock (tests/wallet-harness.mjs); client cases use the real AuthClient wired to that handler through the harness cookie jar. A signed in on tab T (owner of #361). The harness holds T's POST /api/auth/logout before it reaches the Worker and holds the reply of GET /api/auth/session before T sees it. Steps: T.client.signOut() (gen bumped, leaving:true, POST held); T.client.restore() (the Worker answers signedIn:true for A; reply held); release the POST: the Worker revokes the session and clears the cookie, signOut() completes with session:null, ended:"signed-out", cookie gone, 0 live session rows; release the held reply. Expected: the older "signed in" answer changes nothing. Actual: state goes to session A (status "verifying"), then after the house read's 401 to session:null, ended:"revoked", status "connected". Variant with GET /api/me/home failing (fetch throws): final state session A, home:"unavailable", ended:null, status "ownershipUnavailable", hint = A, with no cookie and no live session at the server. Probe output: P8 dropHome=false transitions=[["A","verifying",null],["A","verifying",null],[null,"connected","revoked"]]; dropHome=true final={session:"A",home:"unavailable",ended:null,status:"ownershipUnavailable",hint:"A"}.

### 5. Info: After a house read answered for another address (the cookie was switched by another tab) and the session re-read failed, the page keeps the previous session, its house, owner status and checking:true

`source/src/world/auth.ts:220`

```
        if(this.s.session&&home.address.toLowerCase()!==this.s.session.address){await this.restore();return;}  // another tab switched the cookie
```

Relates to N-1 and CORR-05. From audit_flow 6e47debf, reproduced. When GET /api/me/home answers 200 for an address other than the held session, refreshHome() hands over to restore() and returns without touching home, homeOkAt or checking. If that session read then fails (429 from the per-IP api bucket, 503, a lost connection), readSession() only sets sessionKnown:false and a notice. The page still holds session A, A's last house and checking:true, and statusOf() stays "owner" for A, although the server has just answered for B and the browser's only session cookie is B's. CORR-05's OWNER_STALE_MS rule is not applied on this path, and "Check again" stays disabled while checking is true. It heals on the next successful read (in my run the next refreshHome 60 s later gave session B, status "mismatch"). Attacker preconditions: none for another party; it needs the player's own second tab signing in with another wallet and one failed session read. Impact: display only: owner mode here is a local view of A's own house with A's wallet connected; every server answer is for B. Fix: in this branch clear the kept house and the flag before the re-read (set({home:null,checking:false})), or drop them when the re-read does not confirm the held session.

**Reproduction**

Harness for every reproduction below: a copy of source/ with npm ci and the two TESTS/stubs, the real Worker handler (createWorker) over node:sqlite with migrations 0001-0005, the tests' fakeImd/fakeChain, synthetic keys, an injected clock (tests/wallet-harness.mjs); client cases use the real AuthClient wired to that handler through the harness cookie jar. Tab T signed in as A (owner of #361, status "owner"). The same cookie jar then signs in as B (b.signIn(B): 200), so the jar holds B's session. The harness rewrites the next GET /api/auth/session answer to 429 {error:"RATE_LIMITED"}. Action: T.client.refreshHome(true). Expected: the page no longer claims A's session or house and checking is false. Actual: {session:A, home.address:A, checking:true, sessionKnown:false, notice:"rate-limited"}, statusOf="owner", while GET /api/auth/session with that jar answers address B. Probe output: P4 {"session":"A","homeAddr":"A","checking":true,"sessionKnown":false,"notice":"rate-limited","status":"owner","serverSessionIs":"B"}; after the next unforced read: {"session":"B","status":"mismatch","checking":false}.

### 6. Info: Read routes clear the session cookie for a dead cookie (GET /api/auth/session, GET /api/me/home 401), so a slow read sent with the dead cookie that lands after another tab's sign-in deletes the fresh

`source/server/auth.ts:532`

```
  if(typeof s==='string')return reply(200,s==='SESSION_EXPIRED'?{signedIn:false,expired:true}:{signedIn:false},[clearSession()]);
```

Relates to the session issuance/revocation re-check and N-7. From audit_flow a64840fb, reproduced. N-7's new expired flag itself leaks nothing: it is answered only to the holder of an unrevoked, expired session's token, and /api/me/home and logout-all already answered SESSION_EXPIRED for the same cookie. But this answer, the 401 of /api/me/home (auth.ts:615) and of logout-all (:552) carry Set-Cookie __Host-imd_session=; Max-Age=0. A browser applies Set-Cookie in arrival order and by name only, so if such a response arrives after POST /api/auth/verify from another tab of the same profile set a fresh cookie, the fresh cookie is deleted: both tabs are signed out on their next request and the new session row stays live at the server for up to 7 days with no holder. Preconditions: a dead cookie (revoked from another device, or expired) and a request sent with it that is still in flight while another tab completes a whole sign-in (seconds: a stalled connection). Not triggerable by another party. Impact on a player: availability only (sign in again); the orphaned session is unreachable without its token. Fix: do not clear the cookie on read routes (a dead cookie is harmless and the client already treats the answer as signed out), or clear only on logout.

**Reproduction**

Harness for every reproduction below: a copy of source/ with npm ci and the two TESTS/stubs, the real Worker handler (createWorker) over node:sqlite with migrations 0001-0005, the tests' fakeImd/fakeChain, synthetic keys, an injected clock (tests/wallet-harness.mjs); client cases use the real AuthClient wired to that handler through the harness cookie jar. A signs in (jar holds the cookie); UPDATE sessions SET revoked_at=1 (a logout-all from elsewhere). GET /api/auth/session and GET /api/me/home are sent with the dead cookie and their responses are not yet applied to the jar. The same jar then signs in as A again (verify 200: fresh cookie in the jar). The held session response is then applied, as a browser would on arrival. Expected: the fresh cookie survives. Actual: the jar no longer has __Host-imd_session; one live session row remains in D1. Probe output: P5 sessionReply={"signedIn":false} setCookie=["__Host-imd_session=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0"] homeStatus=401 (same Set-Cookie) hadFresh=true afterLate=false liveRows=1.

### 7. Info: N-7: "Log out all devices" on a session the server reports as expired leaves expired:false and ended:null, so that expiry is shown as neither expired, revoked nor signed-out

`source/src/world/auth.ts:307`

```
    this.hint.set(null);this.set({session:null,home:null,expired:false,checking:false,leaving:false,sessionKnown:true,notice:ok==='stale'?'signout-all-stale':null,ended:ok==='stale'?null:'signed-out'});
```

Relates to N-7. From audit_flow e7470f99, reproduced. The server's logout-all 401 tells SESSION_EXPIRED from AUTH_REQUIRED (server/auth.ts:552), but logoutAllRequest() (:329) maps any 401 to "stale" without reading the code, and this line then writes expired:false and ended:null. SessionEnd is documented (:41) as null only while signed in, on a fresh page and once a sign-in begins. The notice "signout-all-stale" is accurate (other devices were not signed out), but the status line shows "connected"/"visitor" rather than the N-7 expiry sentence, and the hint is cleared so a later read cannot recover the cause. Reachable when the page clock is behind the server's (otherwise the W-1 timer ends the session first). Display only; no security effect. Fix: read the 401 body in logoutAllRequest() and set expired/ended as refreshHome's 401 branch does (:226-227).

**Reproduction**

Harness for every reproduction below: a copy of source/ with npm ci and the two TESTS/stubs, the real Worker handler (createWorker) over node:sqlite with migrations 0001-0005, the tests' fakeImd/fakeChain, synthetic keys, an injected clock (tests/wallet-harness.mjs); client cases use the real AuthClient wired to that handler through the harness cookie jar. A signs in on tab T (owner). The Worker clock is advanced 7 days + 1 ms; the page clock is left where it was (a slow device clock). POST /api/auth/logout-all with that cookie answers 401 {"error":"SESSION_EXPIRED"}. Action: T.client.signOut(true). Expected per N-7: expired:true, ended:"expired" (status "expired"). Actual: {session:null, expired:false, ended:null, notice:"signout-all-stale"}, statusOf="connected". Probe output: P6 {"serverAnswer":[401,"SESSION_EXPIRED"],"session":null,"expired":false,"ended":null,"notice":"signout-all-stale","status":"connected"}.

### 8. Info: rateLimitKey: the IPv4 test is an unanchored trailing dotted quad and hex IPv4-mapped forms are not recognised, so non-canonical address text is keyed as an unrelated /24 or into one shared /64 (harde

`source/worker/app.ts:64`

```
  const v4=/(\d{1,3}(?:\.\d{1,3}){3})$/.exec(ip);
```

Relates to N-5 (every share, the challenge row's sub and the index lane are derived from this text). From audit_math 4470cc92, reproduced. (1) Any IPv6 text ending in a dotted quad that is not IPv4-mapped (64:ff9b::192.0.2.33, ::192.0.2.33, 2001:db8::1.2.3.4) is keyed as that IPv4 and its /24, not as its own /64 and /48. (2) ::ffff:cb00:7101 (an IPv4-mapped address written in hex), ::1 and :: all collapse to ip6:0:0:0:0::/64 and net6:0:0:0::/48, which as a net6 key also gets the doubled NET6_SCALE share. (3) 1.2.3.4.5 becomes ip:2.3.4.5. The N-5 keys for canonical input are correct (2001:db8:7:1::1 gives ip6:2001:db8:7:1::/64, net6:2001:db8:7::/48, net6:2001:db8:7:1::/64), and I separately confirmed the N-5 bounds on the real handler: 45 challenges from 45 /64s of one /48, each verified with a garbage signature from yet another /48, made exactly 20 eth_getCode and 6 eth_call (4 when aimed at one contract). Preconditions: Cloudflare would have to present cf-connecting-ip in one of these forms; it sends plain dotted IPv4 and compressed hex IPv6, so I could not construct a production request that reaches this. Impact if it did: a shared or misattributed rate-limit share (availability), never a sign-in as another address. Fix: anchor the IPv4 form to the whole string (/^(?:::ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/i), map ::ffff:xxxx:xxxx to its IPv4, and key anything that does not expand to 8 groups as ip:unknown. tests/worker.test.mjs:231-245 cover only canonical forms.

**Reproduction**

Evaluate rateLimitKey / networkKey / subnetKey from source/worker/app.ts (node, type stripping): "64:ff9b::192.0.2.33" -> ["ip:192.0.2.33","net:192.0.2.0/24",null] (expected an ip6 /64 and net6 /48 of 64:ff9b::); "2001:db8::1.2.3.4" -> ["ip:1.2.3.4","net:1.2.3.0/24",null]; "::ffff:cb00:7101" -> ["ip6:0:0:0:0::/64","net6:0:0:0::/48","net6:0:0:0:0::/64"], identical to "::1" and "::" (expected the keys of 203.0.113.1, which "::ffff:203.0.113.1" does give: ["ip:203.0.113.1","net:203.0.113.0/24",null]); "1.2.3.4.5" -> ["ip:2.3.4.5","net:2.3.4.0/24",null]. Probe P7 printed exactly these.

### 9. Info: Review record (not a defect): what was verified for N-1..N-7 and the standing checks, what the Solidity checklists could not be applied to, and what could not be checked

`source/server/auth.ts:571`

```
export async function handleAccountApi(request:Request,deps:AccountDeps):Promise<Response|null>{
```

This entry is the coverage statement the brief asks for; it reports no defect and is not a certification. Verified by reading the code and by runs on a copy of source/: N-1 (session reads are sequenced; a superseded read's answer, error or body changes nothing; a click waits for the newest read) except the sign-out ordering reported separately. N-2 (after the challenge body, gen, provider and account are re-checked with no await before personal_sign; teardown, switch and sign-out bump gen; an abandoned flow's late verify success is revoked): I found no path from a cancelled, switched or torn-down flow to personal_sign or verify. The only wallet methods in the published client are eth_accounts, eth_requestAccounts and personal_sign of the checked 11-line message. N-3 (counts/rank share one predicate; the sightings read is one query bounded by the roster and at most 500 index ids, made only when a cut is needed). N-4 (called_via; a /24 makes at most 2 checks of one address a minute, pool plus lane; my rotation run gave 4 at one contract for a /48). N-5 (the nesting holds under /64 rotation with verifies sent from other networks: 20 code reads and 6 contract checks per /48 a minute; the *_0004 fallbacks trigger only on the missing-column error and bind the 0004 limits). N-6 (the lane is bounded per network and site-wide, adds at most one index read a minute per network, and every seat still comes from ownerOf; the two lane defects are reported separately). N-7 (the expired flag is answered only for an unrevoked, expired session's own token). Standing checks confirmed in code: verify re-reads the message from D1 only and compares domain, URI, chain id 1, version, statement, Issued At and Expiration Time with the row; ERC-6492 suffix refused; ERC-1271 only for an address with code (or known) and only the exact 32-byte magic word; one ERC-1271 check per challenge; every failed check burns the challenge; one session per nonce (atomic consume, sessions.nonce UNIQUE); the token is stored as SHA-256; __Host- cookies with Secure, HttpOnly, Path=/, no Domain; 7-day absolute expiry from issue; logout-all needs a live session and only ends that session's address; permit() fails closed for auth, verify, home, chain and code, and a missing binding is 503; /api/me/home takes the address from the session only. I found no path to sign in as an address without its key or a contract's own approval, to revive a revoked session at the server, to end another address's sessions, to gain owner rights for another address's seats, or to move funds (the code holds no transaction, approval or typed-data request). Not applicable: the repository has no Solidity, so the Solidity checklists (reentrancy, token accounting, rounding, upgradeability, oracle and AMM items, deployment and initialiser checks, Slither) could not be applied and no Foundry proof exists for any finding; from them I used the signature items (replay, nonce consumption, ERC-1271 trust, EIP-4361 fields), the entry-point and access inventory, and the unbounded-work and denial-of-service items. Could not check: the live deployment (Worker version, bundle hash, bindings, D1 migration state, the edge WAF rule) and the wrangler dry-run hash; real Cloudflare limiter behaviour (windows, per-location counts, the exact form of cf-connecting-ip); real D1 (node:sqlite stood in); real Alchemy and real wallets or browsers (cookie handling was modelled by the harness jar); the withheld client files (WorldApp.tsx, the 3D world, interior) and so how watchOwner and the panels are mounted; the team's private git history; Genesis Mint.

**Reproduction**

Runs on a copy of source/ (git init, npm ci, the two TESTS/stubs; Node v24.21.0): npm test gives tests 216, pass 212, fail 4 (the same four the team lists: three need withheld geometry or interior code, one needs the team's git history); node --test --test-name-pattern="^N-" over the five named files gives 43/43. N-5 rotation probe on the real handler: 45 challenges from 2001:db8:7:<k>::/64 (k=1..45, clock advanced 6.1 s twice for the valve), each verified with a random 65-byte signature from 2001:db8:<900+k>:1::1: distinct contracts with code -> eth_getCode 20, eth_call 6 (6 x 401, 39 x 429 CHAIN_BUSY); one contract -> eth_getCode 20, eth_call 4; addresses without code -> eth_getCode 20, eth_call 0.

---

Judge's submission `a6e287d8833f87ab4dc2dda80167175ee9aae09b0dcecf28a58ca65d8a5d095d`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
