diff --git a/CHANGELOG.md b/CHANGELOG.md index ba4cf3a..63ba679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ full engineering record. ## [Unreleased] +## [0.3.29] — 2026-07-31 + +- Made Realm gestures recover cleanly when a menu, record, or inactive + presentation interrupts an in-progress drag or pinch. +- Added bounded recovery for embedded Back navigation when a host traverses + history without delivering its expected event. +- Kept overflow gathering portraits visual-only while preserving one bounded, + keyboard-accessible control lane and current public keep labels. +- Tightened Farcaster token lifetime, sensitive endpoint, browser-isolation, + and access-request queue boundaries without changing admission decisions. +- Made exact Worker dispatch retries terminally idempotent after normal return, + including when the Worker has since begun a later valid assignment. +- Left castle ownership, gathering rates, balances, terrain, and persistent + world data unchanged. + ## [0.3.28] — 2026-07-31 - Brought Genesis 001 into a portrait-first Farcaster Mini App with a compact @@ -310,7 +325,8 @@ full engineering record. Lowlands, a first keep, Farcaster sign-in, and an admission-gated shared-world foundation. -[Unreleased]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.28...HEAD +[Unreleased]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.29...HEAD +[0.3.29]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.28...v0.3.29 [0.3.28]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.27...v0.3.28 [0.3.27]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.26...v0.3.27 [0.3.26]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.25...v0.3.26 diff --git a/README.md b/README.md index 1631a36..d8c2e2f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Genesis 001 is a persistent, invite-only 10,000-cell Lowlands realm with 100 permanent castle sites gathered near its founding district. Each admitted founder enters through a verified Farcaster identity, receives one durable keep, and returns to a world that remembers its coastline, twelve rivers, wintering north, sunscoured south, shaped forests, resource sites, Workers, wagons, and public keeper portraits. Four permanent Workers can gather Food / Wood / Stone / Gold, settle production automatically, and return home on command. -Alpha 0.3.28: The Realm in Hand is live in ordinary browsers and as a portrait-first Farcaster Mini App. Verified players who are still outside the gate can request access for manual review. The intended core strategy loop is not playable yet: Warpkeep is a one-person, open-source experiment—not a finished MMO or financial product. Participation earns no token reward, airdrop, financial return, guaranteed reward, or future value. +Alpha 0.3.29: The Realm Holds is live in ordinary browsers and as a portrait-first Farcaster Mini App. Verified players who are still outside the gate can request access for manual review. The intended core strategy loop is not playable yet: Warpkeep is a one-person, open-source experiment—not a finished MMO or financial product. Participation earns no token reward, airdrop, financial return, guaranteed reward, or future value. ![Development preview of Genesis 001 showing the Lowlands and an open Wheat Farm inspection panel.](docs/reference/screenshots/2026-07-22-realm-wheat-farm-preview/warpkeep-realm-wheat-farm-preview-f3b1f7e598c543d6.png) @@ -29,7 +29,7 @@ Open the local URL Vite prints; shared Alpha access stays off by default. Contri | State | Today | | --- | --- | -| ✅ Live | Alpha 0.3.28: The Realm in Hand is live and invite-only in browsers and the Farcaster Mini App. | +| ✅ Live | Alpha 0.3.29: The Realm Holds is live and invite-only in browsers and the Farcaster Mini App. | | ✅ World | Genesis 001 persists 10,000 cells, a coastline, twelve one-cell rivers, colder northern and drier southern frontiers, and 100 permanent castle sites near the founding district. Founders return to one durable keep, explore the Lowlands up to its fog, and inspect nearby founders through their public username / portrait / castle. | | ✅ Authority | FID is the durable identity; handles and portraits are bounded presentation metadata. Farcaster sign-in uses a browser-bound, least-privilege bridge. The browser presents; the server decides admission, ownership, resources, timers, and saved state. A private access request asks for manual review and grants nothing by itself. | | ✅ Resources | Each keep privately holds Food / Wood / Stone / Gold. Food, Wood, and Stone come from authoritative terrain yield and can also be gathered at Wheat Farms, Logging Camps, and Stone Quarries; Gold comes from Gold Mines. Completed yield settles without a Claim step, and occupied sites keep their gathering story in one record. The browser never invents balances. | @@ -54,7 +54,7 @@ Open the local URL Vite prints; shared Alpha access stays off by default. Contri - **Architecture:** The [technical architecture](docs/technical-architecture.md) explains what the browser shows and what the server decides. - **Roadmap:** The [roadmap](docs/design/roadmap.md) and [game direction](docs/design/warpkeep-direction.md) separate today's game from later plans. - **Authentication:** The [Farcaster integration](docs/farcaster-integration.md) guide covers sign-in, privacy, and public configuration. -- **Release:** The [Alpha 0.3.28 release notes](CHANGELOG.md#0328--2026-07-31) describe The Realm in Hand. +- **Release:** The [Alpha 0.3.29 release notes](CHANGELOG.md#0329--2026-07-31) describe The Realm Holds. - **Licensing:** [LICENSING.md](LICENSING.md) explains release rules; [asset provenance](ASSETS-LICENSE.md) records where media came from and what permissions apply. - **Contributing:** [CONTRIBUTING.md](CONTRIBUTING.md) covers checks and provenance; the [Realm Council issue forms](https://github.com/ael-dev3/Warpkeep/issues/new/choose) accept privacy-safe bugs and ideas. - **Security:** Report sensitive issues privately through [SECURITY.md](SECURITY.md), never through a public issue. diff --git a/docs/farcaster-integration.md b/docs/farcaster-integration.md index 7b3653a..ae2ca49 100644 --- a/docs/farcaster-integration.md +++ b/docs/farcaster-integration.md @@ -5,7 +5,7 @@ ordinary browsers use Sign In with Farcaster (SIWF), while a verified Farcaster Mini App host may use Quick Auth. Neither path is a wallet connection, client-owned identity, admission grant, or Terms acceptance. -Alpha 0.3.28 keeps backend protocol 3 and authentication contract v2; admission +Alpha 0.3.29 keeps backend protocol 3 and authentication contract v2; admission remains gated. Production configuration and founder identities belong in the private operator record, not this guide. diff --git a/docs/releases/versioning.md b/docs/releases/versioning.md index 4871bba..3884ccc 100644 --- a/docs/releases/versioning.md +++ b/docs/releases/versioning.md @@ -1,7 +1,7 @@ # Versioning and releases Warpkeep uses semantic versions for the product and Git commit SHAs for builds. -Alpha `0.3.28` is the live **The Realm in Hand** release. A version is +Alpha `0.3.29` is the live **The Realm Holds** release. A version is described as live only after its protected review, matching client deployment, and production smoke test all succeed. diff --git a/package-lock.json b/package-lock.json index e8382d8..b0e0dfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "warpkeep", - "version": "0.3.28", + "version": "0.3.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "warpkeep", - "version": "0.3.28", + "version": "0.3.29", "license": "Apache-2.0", "dependencies": { "@farcaster/auth-client": "0.7.1", diff --git a/package.json b/package.json index 4544ee5..2271c4f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "warpkeep", "private": true, - "version": "0.3.28", + "version": "0.3.29", "description": "A Farcaster-connected persistent strategy world in active Alpha development.", "license": "Apache-2.0", "homepage": "https://warpkeep.com", diff --git a/scripts/qa-observer/rendered-mobile-map-gesture.mjs b/scripts/qa-observer/rendered-mobile-map-gesture.mjs index ecd4b14..864321b 100644 --- a/scripts/qa-observer/rendered-mobile-map-gesture.mjs +++ b/scripts/qa-observer/rendered-mobile-map-gesture.mjs @@ -239,7 +239,6 @@ export async function applyRenderedMobileMapGestureInteraction( fixtureHost.append(fixtureControl); const visibleWorldControls = [...document.querySelectorAll( '.realm-castle-label, .realm-worker-presence-marker, ' - + '.realm-resource-occupant-presence, ' + '.realm-resource-occupant-marker' )].filter((control) => { if (!visible(control)) return false; diff --git a/scripts/qa-observer/rendered-webgl-browser-probe.d.mts b/scripts/qa-observer/rendered-webgl-browser-probe.d.mts index 71813c0..fe5cb76 100644 --- a/scripts/qa-observer/rendered-webgl-browser-probe.d.mts +++ b/scripts/qa-observer/rendered-webgl-browser-probe.d.mts @@ -571,11 +571,12 @@ export type RenderedWebglResourceOccupantEvidence = Readonly<{ markerHitTestable: true; overviewPresenceDirectHit: boolean; overviewRecordCorrect: boolean; - overviewTargetPassiveOnly: boolean; + overviewTargetControlOnly: boolean; + passivePresenceVisualOnly: boolean; presenceComputedVisible: boolean; presenceAvatarGeometryValid: boolean; presenceGeometryValid: boolean; - presenceDelegatedActivation: boolean; + overviewControlActivation: boolean; presenceHitTestable: boolean; presencePointerActivatable: boolean; presencePortraitElementPresent: boolean; diff --git a/scripts/qa-observer/rendered-webgl-browser-probe.mjs b/scripts/qa-observer/rendered-webgl-browser-probe.mjs index 36de859..92b48a6 100644 --- a/scripts/qa-observer/rendered-webgl-browser-probe.mjs +++ b/scripts/qa-observer/rendered-webgl-browser-probe.mjs @@ -1355,11 +1355,12 @@ export function parseRenderedWebglResourceOccupantEvidence(value) { 'markerHitTestable', 'overviewPresenceDirectHit', 'overviewRecordCorrect', - 'overviewTargetPassiveOnly', + 'overviewTargetControlOnly', + 'passivePresenceVisualOnly', 'presenceComputedVisible', 'presenceAvatarGeometryValid', 'presenceGeometryValid', - 'presenceDelegatedActivation', + 'overviewControlActivation', 'presenceHitTestable', 'presencePointerActivatable', 'presencePortraitElementPresent', @@ -1384,11 +1385,12 @@ export function parseRenderedWebglResourceOccupantEvidence(value) { 'cameraNeutralWhileOpen', 'overviewPresenceDirectHit', 'overviewRecordCorrect', - 'overviewTargetPassiveOnly', + 'overviewTargetControlOnly', + 'passivePresenceVisualOnly', 'presenceComputedVisible', 'presenceAvatarGeometryValid', 'presenceGeometryValid', - 'presenceDelegatedActivation', + 'overviewControlActivation', 'presenceHitTestable', 'presencePointerActivatable', 'presencePortraitElementPresent', @@ -7208,11 +7210,12 @@ export async function applyRenderedWebglResourceOccupantInteraction( markerHitTestable, overviewPresenceDirectHit: false, overviewRecordCorrect: false, - overviewTargetPassiveOnly: false, + overviewTargetControlOnly: false, + passivePresenceVisualOnly: false, presenceComputedVisible: false, presenceAvatarGeometryValid: false, presenceGeometryValid: false, - presenceDelegatedActivation: false, + overviewControlActivation: false, presenceHitTestable: false, presencePointerActivatable: false, presencePortraitElementPresent: false, @@ -7247,7 +7250,7 @@ export async function applyRenderedWebglResourceOccupantInteraction( && map.getAttribute('aria-busy') === 'false' && !map.hasAttribute('data-camera-interacting'); const projectionSnapshot = () => { - // A selected peer may legitimately move from the passive pointer lane + // A selected peer may legitimately move from the passive presentation lane // into the bounded keyboard-control lane while its record is open. // Both lanes use the same renderer-owned world anchor, so camera // neutrality must follow that canonical key rather than DOM role. @@ -7472,18 +7475,8 @@ export async function applyRenderedWebglResourceOccupantInteraction( const overviewFramed = focusedClosed && await frameRealmOverview(); let overviewPresence; - let overviewLane = 'presence'; + const overviewLane = 'control'; const overviewPresenceReady = overviewFramed && await waitFor(() => { - const passiveCandidate = overviewPreferredKeys - .map((key) => presentationForKey( - '.realm-resource-occupant-presence', - key - )) - .find((element) => ( - element instanceof HTMLElement - && element.getAttribute('data-projected-visible') === 'true' - && visible(element) - )); const controlCandidate = overviewPreferredKeys .map((key) => presentationForKey( 'button.realm-resource-occupant-marker', @@ -7494,13 +7487,11 @@ export async function applyRenderedWebglResourceOccupantInteraction( && element.getAttribute('data-projected-visible') === 'true' && visible(element) )); - // Compact viewports may truthfully have no collision-safe passive - // portrait after controls, castle labels, and safe areas are reserved. - // Exercise the same canonical record through its single bounded - // control lane instead of requiring an overlapping/clipped duplicate. - const candidate = passiveCandidate ?? controlCandidate; - if (!(candidate instanceof HTMLElement)) return false; - overviewLane = passiveCandidate ? 'presence' : 'control'; + // Passive overflow portraits are visual context only. Record activation + // must always use the bounded native-button lane; never fall through to + // an aria-hidden presentation when collision admission changes. + const candidate = controlCandidate; + if (!(candidate instanceof HTMLButtonElement)) return false; overviewTargetKey = candidate.getAttribute( 'data-resource-occupant-key' ) ?? ''; @@ -7522,16 +7513,41 @@ export async function applyRenderedWebglResourceOccupantInteraction( if (overviewProjectionSettled) { // Collision reconciliation may move the same canonical occupation // between its passive PFP and single keyboard-control lane while the - // projection settles. Reacquire that lane before measuring or - // activating it so the proof never clicks a detached stale element. - const settledPresentation = overviewPresentation(); - if (settledPresentation instanceof HTMLElement) { - overviewPresence = settledPresentation; - overviewLane = settledPresentation.matches( - '.realm-resource-occupant-presence' - ) ? 'presence' : 'control'; + // projection settles. Reacquire only the control; a passive fallback is + // deliberately not activatable. + const settledControl = presentationForKey( + 'button.realm-resource-occupant-marker', + overviewTargetKey + ); + if (settledControl instanceof HTMLButtonElement) { + overviewPresence = settledControl; + } else { + overviewPresence = undefined; } } + const passivePresence = [...document.querySelectorAll( + '.realm-resource-occupant-presence[data-projected-visible="true"]' + )].find((element) => element instanceof HTMLElement && visible(element)); + const passivePresenceBounds = passivePresence instanceof HTMLElement + ? passivePresence.getBoundingClientRect() + : undefined; + const passiveDirectHit = passivePresenceBounds + ? document.elementFromPoint( + passivePresenceBounds.left + passivePresenceBounds.width / 2, + passivePresenceBounds.top + passivePresenceBounds.height / 2 + ) + : null; + const passivePresenceVisualOnly = overviewProjectionSettled + && passivePresence instanceof HTMLElement + && presenceLayer instanceof HTMLElement + && getComputedStyle(passivePresence).pointerEvents === 'none' + && getComputedStyle(passivePresence).cursor !== 'pointer' + && presenceLayer.getAttribute('aria-hidden') === 'true' + && !(passiveDirectHit instanceof HTMLElement + && ( + passiveDirectHit === passivePresence + || passivePresence.contains(passiveDirectHit) + )); const overviewPresenceBounds = overviewPresence instanceof HTMLElement ? overviewPresence.getBoundingClientRect() : undefined; @@ -7623,7 +7639,7 @@ export async function applyRenderedWebglResourceOccupantInteraction( '.realm-resource-occupant-presence', overviewTargetKey ); - const overviewTargetPassiveOnly = overviewProjectionSettled + const overviewTargetControlOnly = overviewProjectionSettled && overviewPresenceDirectHit && ( overviewLane === 'presence' @@ -7635,7 +7651,7 @@ export async function applyRenderedWebglResourceOccupantInteraction( const beforeRenderer = rendererSnapshot(); const beforeProjection = projectionSnapshot(); const overviewPresencePrivacyBounded = subtreePrivacyBounded(overviewPresence); - if (overviewTargetPassiveOnly && overviewDirectHit instanceof HTMLElement) { + if (overviewTargetControlOnly && overviewDirectHit instanceof HTMLElement) { overviewDirectHit.click(); } const overviewPanelReady = await waitFor(() => { @@ -7702,7 +7718,7 @@ export async function applyRenderedWebglResourceOccupantInteraction( ) ) && [...overviewFacts.keys()].some((label) => label.endsWith('time left')); - const presenceDelegatedActivation = overviewTargetPassiveOnly + const overviewControlActivation = overviewTargetControlOnly && overviewPanelReady && overviewRecordCorrect; const focusedPrivacyBounded = markerPrivacyBounded @@ -7801,11 +7817,12 @@ export async function applyRenderedWebglResourceOccupantInteraction( markerHitTestable, overviewPresenceDirectHit, overviewRecordCorrect, - overviewTargetPassiveOnly, + overviewTargetControlOnly, + passivePresenceVisualOnly, presenceComputedVisible, presenceAvatarGeometryValid, presenceGeometryValid, - presenceDelegatedActivation, + overviewControlActivation, presenceHitTestable, presencePointerActivatable, presencePortraitElementPresent, diff --git a/scripts/verify-alpha-production.mjs b/scripts/verify-alpha-production.mjs index efe41fc..dbe19e5 100644 --- a/scripts/verify-alpha-production.mjs +++ b/scripts/verify-alpha-production.mjs @@ -1176,6 +1176,11 @@ async function verifyAuthV2AdminBrowserIsolation(frontend, bridge, fetchImpl) { 'access-control-request-headers': 'authorization, content-type', }), }), + Object.freeze({ + name: 'allowed-origin credential-free POST', + method: 'POST', + headers: Object.freeze({ origin: frontend }), + }), ]); for (const pathname of AUTH_V2_SERVER_ONLY_ADMIN_PATHS) { @@ -1187,8 +1192,18 @@ async function verifyAuthV2AdminBrowserIsolation(frontend, bridge, fetchImpl) { }, fetchImpl); verifyAuthV2SecurityHeaders(response, label); verifyNoCors(response, label); - const payload = await readExactJsonAtStatus(response, label, 404); - verifyExactErrorPayload(payload, 'not_found', 'Route not found.', label); + if (check.method === 'POST') { + const payload = await readExactJsonAtStatus(response, label, 403); + verifyExactErrorPayload( + payload, + 'admin_browser_forbidden', + 'This endpoint is server-only.', + label, + ); + } else { + const payload = await readExactJsonAtStatus(response, label, 404); + verifyExactErrorPayload(payload, 'not_found', 'Route not found.', label); + } } } } diff --git a/services/auth-bridge/README.md b/services/auth-bridge/README.md index a4ed58b..893006f 100644 --- a/services/auth-bridge/README.md +++ b/services/auth-bridge/README.md @@ -6,7 +6,7 @@ Warpkeep's SpacetimeDB connection. It is isolated from the static browser app: browser code never receives a signing key, admin secret, Optimism RPC URL, resolver JWT, private Hermes JWT, or Maincloud credential. -Alpha 0.3.28 uses authentication contract v2 and backend protocol 3. The +Alpha 0.3.29 uses authentication contract v2 and backend protocol 3. The checked-in configuration fails closed: `wrangler.toml` keeps `PUBLIC_AUTH_ENABLED = "false"`, while any production enablement is a separate, privately recorded operation. World generation and resource features do not @@ -207,8 +207,8 @@ one-time use and expiry remain authoritative if bytes have already arrived. caller identity or profile fields. Pinned `@farcaster/quick-auth@0.0.8` verifies the JWT against domain `warpkeep.com`. The Worker then requires exact issuer `https://auth.farcaster.xyz`, exact audience `warpkeep.com`, a positive -safe-integer decimal `sub`, and bounded valid `iat`/`exp` claims before -resolving admission. +safe-integer decimal `sub`, valid `iat`/`exp` claims, and an issuer lifetime no +longer than one hour before resolving admission. The endpoint reuses the SIWF exchange rate-limit bucket, structured admission resolver, positive auth epoch, access-token TTL/claims, and exact @@ -387,7 +387,8 @@ admin-secret authentication. Each endpoint fingerprint is lowercase hex `\0` is one NUL separator; the endpoint URLs themselves are never returned. The digest covers those values along with issuer, origins, SIWF coordinates, gameplay audience/key/Maincloud coordinates, Quick Auth -issuer/domain/browser-origin/exchange-path/verifier-package/token-size bounds, +issuer/domain/browser-origin/exchange-path/verifier-package/token-size and +one-hour issuer-lifetime bounds, observer coordinates, environment, binding and bounded lifetimes, both gates, and exact cookie attributes. Operators must compare it with the reviewed expected configuration; it is not a deployment action and reveals no secret diff --git a/services/auth-bridge/src/app.ts b/services/auth-bridge/src/app.ts index 5f3b19a..3e642ac 100644 --- a/services/auth-bridge/src/app.ts +++ b/services/auth-bridge/src/app.ts @@ -127,6 +127,7 @@ const QUICK_AUTH_DOMAIN = 'warpkeep.com' const QUICK_AUTH_BROWSER_ORIGIN = 'https://warpkeep.com' const QUICK_AUTH_ISSUER = 'https://auth.farcaster.xyz' const MAX_QUICK_AUTH_TOKEN_BYTES = 8 * 1024 +export const QUICK_AUTH_MAX_ISSUER_LIFETIME_SECONDS = 60 * 60 const QUICK_AUTH_VERIFIER_PACKAGE = '@farcaster/quick-auth@0.0.8' const ACCESS_REQUEST_FAILURE_EVENTS: @@ -256,6 +257,21 @@ function publicCorsHeaders(request: Request, config: BridgeConfig, pathname = ne : {} } +function accessControlRequestedHeaders(request: Request): string[] { + const requested = request.headers.get('access-control-request-headers') + return requested + ? requested.split(',').map(header => header.trim().toLowerCase()).filter(Boolean) + : [] +} + +function accessRequestUsesBearer(request: Request): boolean { + return request.headers.has('authorization') + || ( + request.method === 'OPTIONS' + && accessControlRequestedHeaders(request).includes('authorization') + ) +} + function routeCorsHeaders(request: Request, config: BridgeConfig, pathname = new URL(request.url).pathname): HeadersInit { if (pathname === V2_QUICK_AUTH_EXCHANGE_PATH) { const origin = request.headers.get('origin') @@ -263,7 +279,7 @@ function routeCorsHeaders(request: Request, config: BridgeConfig, pathname = new } if (isAccessRequestPath(pathname)) { const origin = request.headers.get('origin') - if (request.headers.has('authorization')) { + if (accessRequestUsesBearer(request)) { return origin === QUICK_AUTH_BROWSER_ORIGIN ? quickAuthCorsHeaders(origin) : {} } return origin && config.allowedOrigins.has(origin) ? corsHeaders(origin, true) : {} @@ -380,10 +396,7 @@ function allowedAccessRequestPreflight( request: Request, config: BridgeConfig, ): Response { - const requestHeaders = request.headers.get('access-control-request-headers') - const headers = requestHeaders - ? requestHeaders.split(',').map(header => header.trim().toLowerCase()).filter(Boolean) - : [] + const headers = accessControlRequestedHeaders(request) const usesBearer = headers.includes('authorization') const allowedHeaders = usesBearer ? new Set(['authorization', 'content-type']) @@ -558,6 +571,7 @@ function verifiedQuickAuthClaims(payload: unknown, nowSeconds: number): Readonly || !Number.isSafeInteger(claims.exp) || claims.exp <= claims.iat || claims.exp <= nowSeconds + || claims.exp - claims.iat > QUICK_AUTH_MAX_ISSUER_LIFETIME_SECONDS ) throw invalidQuickAuthCredential() return Object.freeze({ fid: canonicalFid(claims.sub), @@ -931,6 +945,7 @@ async function configurationAttestation( quickAuthExchangePath: V2_QUICK_AUTH_EXCHANGE_PATH, quickAuthVerifierPackage: QUICK_AUTH_VERIFIER_PACKAGE, quickAuthMaxTokenBytes: MAX_QUICK_AUTH_TOKEN_BYTES, + quickAuthMaxIssuerLifetimeSeconds: QUICK_AUTH_MAX_ISSUER_LIFETIME_SECONDS, accessTokenTtlSeconds: PLAYER_TOKEN_TTL_SECONDS, authEpochResolverTokenTtlSeconds: INTERNAL_AUTH_EPOCH_RESOLVER_TOKEN_TTL_SECONDS, authEpochResolverTimeoutMilliseconds: AUTH_EPOCH_RESOLVER_TIMEOUT_MILLISECONDS, @@ -1576,6 +1591,9 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri if (request.method === 'POST' && url.pathname === V2_CHALLENGE_PATH) { const origin = requireAllowedBrowserOrigin(request, config) + if (url.search) { + throw new HttpError(400, 'challenge_query_not_allowed', 'This endpoint does not accept query parameters.') + } await enforceRateLimit(request, 'challenge', env, dependencies.rateLimiter, logger) const body = await parseObjectBody(request) const browserBinding = parseChallengeRequest(body, config) @@ -1900,6 +1918,9 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri if (request.method === 'POST' && url.pathname === V2_EXCHANGE_PATH) { const origin = requireAllowedBrowserOrigin(request, config) + if (url.search) { + throw new HttpError(400, 'exchange_query_not_allowed', 'This endpoint does not accept query parameters.') + } await enforceRateLimit(request, 'exchange', env, dependencies.rateLimiter, logger) const body = await parseObjectBody(request) const input = parseExchangeRequest(body, logger) @@ -2052,6 +2073,9 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri if (request.method === 'POST' && url.pathname === V2_REFRESH_PATH) { const origin = requireAllowedBrowserOrigin(request, config) + if (url.search) { + throw new HttpError(400, 'refresh_query_not_allowed', 'This endpoint does not accept query parameters.') + } await enforceRateLimit(request, 'session-refresh', env, dependencies.rateLimiter, logger) requireExactKeys(await parseObjectBody(request), []) const cookie = await readVerifiedSessionCookie(request, config.sessionCookieKey) @@ -2136,6 +2160,9 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri if (request.method === 'POST' && url.pathname === V2_LOGOUT_PATH) { const origin = requireAllowedBrowserOrigin(request, config) + if (url.search) { + throw new HttpError(400, 'logout_query_not_allowed', 'This endpoint does not accept query parameters.') + } requireExactKeys(await parseObjectBody(request), []) const cookie = await readVerifiedSessionCookie(request, config.sessionCookieKey) if (cookie) { @@ -2243,6 +2270,7 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri quickAuthExchangePath: V2_QUICK_AUTH_EXCHANGE_PATH, quickAuthVerifierPackage: QUICK_AUTH_VERIFIER_PACKAGE, quickAuthMaxTokenBytes: MAX_QUICK_AUTH_TOKEN_BYTES, + quickAuthMaxIssuerLifetimeSeconds: QUICK_AUTH_MAX_ISSUER_LIFETIME_SECONDS, accessRequestStatusPath: V2_ACCESS_STATUS_PATH, accessRequestSubmitPath: V2_ACCESS_REQUEST_PATH, accessRequestResolverTokenTtlSeconds: INTERNAL_ACCESS_REQUEST_RESOLVER_TOKEN_TTL_SECONDS, diff --git a/services/auth-bridge/test/app.test.ts b/services/auth-bridge/test/app.test.ts index 25cc981..ddc9190 100644 --- a/services/auth-bridge/test/app.test.ts +++ b/services/auth-bridge/test/app.test.ts @@ -3,6 +3,7 @@ import { Errors as QuickAuthErrors } from '@farcaster/quick-auth' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { FARCASTER_VERIFICATION_TIMEOUT_MILLISECONDS, + QUICK_AUTH_MAX_ISSUER_LIFETIME_SECONDS, REQUEST_BODY_TIMEOUT_MILLISECONDS, createAuthBridge, farcasterRpcEndpointFingerprint, @@ -485,6 +486,38 @@ describe('Warpkeep auth bridge', () => { }, ) + it.each([ + ['/v2/farcaster/challenge', 'challenge_query_not_allowed'], + ['/v2/farcaster/exchange', 'exchange_query_not_allowed'], + ['/v2/session/refresh', 'refresh_query_not_allowed'], + ['/v2/session/logout', 'logout_query_not_allowed'], + ] as const)( + 'rejects query strings on %s before rate limiting or identity work', + async (pathname, code) => { + const check = vi.fn(async () => ({ allowed: true as const })) + const h = harness({ rateLimiter: { check } }) + const response = await h.app.fetch(request(`${pathname}?caller=value`, { + caller: 'must-not-be-parsed', + }, { + headers: { origin: ORIGIN }, + }), env()) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: { + code, + message: 'This endpoint does not accept query parameters.', + }, + }) + expect(response.headers.get('access-control-allow-origin')).toBe(ORIGIN) + expect(response.headers.get('access-control-allow-credentials')).toBe('true') + expect(check).not.toHaveBeenCalled() + expect(h.verifier.verify).not.toHaveBeenCalled() + expect(h.quickAuthVerifier.verifyJwt).not.toHaveBeenCalled() + expect(h.resolver.resolve).not.toHaveBeenCalled() + }, + ) + it('does not consume a challenge when exchange is rate-limited', async () => { const check = vi.fn() .mockResolvedValueOnce({ allowed: true }) @@ -1294,6 +1327,7 @@ describe('Warpkeep auth bridge', () => { quickAuthExchangePath: '/v2/farcaster/quick-auth/exchange', quickAuthVerifierPackage: '@farcaster/quick-auth@0.0.8', quickAuthMaxTokenBytes: 8 * 1024, + quickAuthMaxIssuerLifetimeSeconds: 60 * 60, accessRequestStatusPath: '/v2/access/status', accessRequestSubmitPath: '/v2/access/request', accessRequestResolverTokenTtlSeconds: 15, @@ -1344,6 +1378,7 @@ describe('Warpkeep auth bridge', () => { quickAuthExchangePath: '/v2/farcaster/quick-auth/exchange', quickAuthVerifierPackage: '@farcaster/quick-auth@0.0.8', quickAuthMaxTokenBytes: 8 * 1024, + quickAuthMaxIssuerLifetimeSeconds: 60 * 60, accessTokenTtlSeconds: 600, authEpochResolverTokenTtlSeconds: 15, authEpochResolverTimeoutMilliseconds: 5_000, @@ -2302,6 +2337,65 @@ describe('Warpkeep auth bridge', () => { expect(h.events).toContain('access_request_succeeded') }) + it('enforces the reviewed Quick Auth issuer-lifetime boundary before access authority', async () => { + const nowSeconds = 1_800_000_000 + const payload = (lifetimeSeconds: number) => ({ + sub: Number(FID), + iss: QUICK_AUTH_ISSUER, + aud: QUICK_AUTH_DOMAIN, + iat: nowSeconds, + exp: nowSeconds + lifetimeSeconds, + }) + + const acceptedGetStatus = vi.fn(async () => ({ status: 'not-requested' } as const)) + const accepted = harness({ + epoch: 0, + quickAuthVerifier: { + verifyJwt: vi.fn(async () => payload(QUICK_AUTH_MAX_ISSUER_LIFETIME_SECONDS)), + }, + accessRequestResolver: { + getStatus: acceptedGetStatus, + submit: vi.fn(async () => ({ status: 'already-admitted' } as const)), + }, + }) + accepted.setNow(nowSeconds * 1_000) + const acceptedResponse = await accepted.app.fetch( + accessBearerRequest(ACCESS_STATUS_PATH), + env(), + ) + + expect(acceptedResponse.status).toBe(200) + expect(accepted.resolver.resolve).toHaveBeenCalledOnce() + expect(acceptedGetStatus).toHaveBeenCalledOnce() + + const rejectedGetStatus = vi.fn(async () => ({ status: 'not-requested' } as const)) + const rejected = harness({ + epoch: 0, + quickAuthVerifier: { + verifyJwt: vi.fn(async () => payload(QUICK_AUTH_MAX_ISSUER_LIFETIME_SECONDS + 1)), + }, + accessRequestResolver: { + getStatus: rejectedGetStatus, + submit: vi.fn(async () => ({ status: 'already-admitted' } as const)), + }, + }) + rejected.setNow(nowSeconds * 1_000) + const rejectedResponse = await rejected.app.fetch( + accessBearerRequest(ACCESS_STATUS_PATH), + env(), + ) + + expect(rejectedResponse.status).toBe(401) + await expect(rejectedResponse.json()).resolves.toEqual({ + error: { + code: 'access_auth_invalid', + message: 'Farcaster authentication could not be verified.', + }, + }) + expect(rejected.resolver.resolve).not.toHaveBeenCalled() + expect(rejectedGetStatus).not.toHaveBeenCalled() + }) + it('accepts a valid pending family without rotating its generation or cookie', async () => { const backing = new MemorySessionFamilyStore() const refresh = vi.fn(( @@ -2650,6 +2744,29 @@ describe('Warpkeep auth bridge', () => { await expect(pausedResponse.json()).resolves.toMatchObject({ error: { code: 'public_auth_paused' }, }) + + const pausedBearerPreflight = await paused.app.fetch(request( + ACCESS_REQUEST_PATH, + undefined, + { + method: 'OPTIONS', + headers: { + origin: QUICK_AUTH_ORIGIN, + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'Authorization, Content-Type', + }, + }, + ), env({ PUBLIC_AUTH_ENABLED: 'false' })) + expect(pausedBearerPreflight.status).toBe(503) + await expect(pausedBearerPreflight.json()).resolves.toMatchObject({ + error: { code: 'public_auth_paused' }, + }) + expect(pausedBearerPreflight.headers.get('access-control-allow-origin')) + .toBe(QUICK_AUTH_ORIGIN) + expect(pausedBearerPreflight.headers.get('access-control-allow-headers')) + .toBe('authorization, content-type') + expect(pausedBearerPreflight.headers.has('access-control-allow-credentials')) + .toBe(false) expect(paused.quickAuthVerifier.verifyJwt).not.toHaveBeenCalled() expect(paused.resolver.resolve).not.toHaveBeenCalled() @@ -2736,6 +2853,43 @@ describe('Warpkeep auth bridge', () => { expect(h.events).not.toContain('session_created') }) + it('accepts the reviewed issuer-lifetime maximum and rejects one second longer', async () => { + const nowSeconds = 1_800_000_000 + const payload = (lifetimeSeconds: number) => ({ + sub: Number(FID), + iss: QUICK_AUTH_ISSUER, + aud: QUICK_AUTH_DOMAIN, + iat: nowSeconds, + exp: nowSeconds + lifetimeSeconds, + }) + + const accepted = harness({ + quickAuthVerifier: { + verifyJwt: vi.fn(async () => payload(QUICK_AUTH_MAX_ISSUER_LIFETIME_SECONDS)), + }, + }) + accepted.setNow(nowSeconds * 1_000) + const acceptedResponse = await accepted.app.fetch(quickAuthRequest(), env()) + expect(acceptedResponse.status).toBe(200) + expect(accepted.resolver.resolve).toHaveBeenCalledOnce() + + const rejected = harness({ + quickAuthVerifier: { + verifyJwt: vi.fn(async () => payload(QUICK_AUTH_MAX_ISSUER_LIFETIME_SECONDS + 1)), + }, + }) + rejected.setNow(nowSeconds * 1_000) + const rejectedResponse = await rejected.app.fetch(quickAuthRequest(), env()) + expect(rejectedResponse.status).toBe(401) + await expect(rejectedResponse.json()).resolves.toEqual({ + error: { + code: 'quick_auth_invalid', + message: 'Farcaster authentication could not be verified.', + }, + }) + expect(rejected.resolver.resolve).not.toHaveBeenCalled() + }) + it('returns the same cookie-free tokenless pending semantics for missing and disabled FIDs', async () => { const pending = harness({ epoch: 0 }) const pendingResponse = await pending.app.fetch(quickAuthRequest(), env()) diff --git a/spacetimedb/src/accessRequestPolicy.ts b/spacetimedb/src/accessRequestPolicy.ts new file mode 100644 index 0000000..372022e --- /dev/null +++ b/spacetimedb/src/accessRequestPolicy.ts @@ -0,0 +1,54 @@ +/** + * Invite-only Alpha containment boundary. The deployed v1 table has no + * cursor-order index, so every administrative review must remain bounded by + * this exact maximum until an additive indexed queue supersedes it. + */ +export const ACCESS_REQUEST_QUEUE_CAPACITY = 4_096; + +function assertCapacity(capacity: number): void { + if (!Number.isSafeInteger(capacity) || capacity < 1) { + throw new Error('ACCESS_REQUEST_QUEUE_CAPACITY_INVALID'); + } +} + +/** + * Existing rows may always retry or advance to a later admission cycle because + * neither operation increases storage. Only a first insert consumes capacity. + */ +export function accessRequestQueueAcceptsSubmission( + currentCount: bigint, + requestExists: boolean, + capacity = ACCESS_REQUEST_QUEUE_CAPACITY, +): boolean { + assertCapacity(capacity); + if (currentCount < 0n) { + throw new Error('ACCESS_REQUEST_QUEUE_COUNT_INVALID'); + } + return requestExists || currentCount < BigInt(capacity); +} + +/** + * Consume at most the configured capacity plus one proof-of-overflow row. + * This keeps malformed or unexpectedly oversized state from turning an + * administrative page into unbounded memory use. + */ +export function takeBoundedAccessRequestRows( + rows: Iterable, + capacity = ACCESS_REQUEST_QUEUE_CAPACITY, +): Readonly<{ rows: readonly Row[]; overflow: boolean }> { + assertCapacity(capacity); + const bounded: Row[] = []; + for (const row of rows) { + if (bounded.length >= capacity) { + return Object.freeze({ + rows: Object.freeze(bounded), + overflow: true, + }); + } + bounded.push(row); + } + return Object.freeze({ + rows: Object.freeze(bounded), + overflow: false, + }); +} diff --git a/spacetimedb/src/castleWorkerAuthority.ts b/spacetimedb/src/castleWorkerAuthority.ts index 88e07d6..c5c5634 100644 --- a/spacetimedb/src/castleWorkerAuthority.ts +++ b/spacetimedb/src/castleWorkerAuthority.ts @@ -618,7 +618,10 @@ function assertDispatchReservations( } } -export type WorkerDispatchResult = Readonly<{ assignment: AssignmentRow; idempotent: boolean }>; +export type WorkerDispatchResult = Readonly<{ + assignment: AssignmentRow | undefined; + idempotent: boolean; +}>; export function dispatchCastleWorker( ctx: WarpkeepReducerContext, @@ -634,9 +637,34 @@ export function dispatchCastleWorker( || !receiptOwnerIsCanonical(ctx, prior, input.castle.castleId) ) fail('WORKER_IDEMPOTENCY_OWNER_INVALID'); const assignment = ctx.db.workerAssignmentV1.assignmentId.find(prior.assignmentId); + const worker = ctx.db.castleWorkerV1.workerId.find(input.workerId); + if ( + worker === null + || worker.originCastleId !== input.castle.castleId + ) fail('WORKER_IDEMPOTENCY_STALE'); + if (assignment === null) { + // A normal return completion removes the private assignment and advances + // the canonical public worker revision. That is sufficient terminal + // proof for an exact lost-response retry; an unadvanced revision remains + // a fail-closed orphan-state signal. + const currentAssignment = ctx.db.workerAssignmentV1.workerId.find(worker.workerId); + const currentStateIsCanonical = worker.status === 'idle' + ? currentAssignment === null + : currentAssignment !== null + && currentAssignment.assignmentId !== prior.assignmentId + && assignmentOwnerIsCanonical(ctx, currentAssignment) + && publicWorkerMatchesAssignment(worker, currentAssignment); + if ( + worker.revision <= prior.resultRevision + || !castleWorkerPublicStateIsConsistent(worker) + || !currentStateIsCanonical + ) { + fail('WORKER_IDEMPOTENCY_STALE'); + } + return Object.freeze({ assignment: undefined, idempotent: true }); + } if ( - assignment === null - || assignment.fid !== input.fid + assignment.fid !== input.fid || assignment.workerId !== input.workerId || assignment.resourceKind !== input.resourceKind || assignment.siteId !== input.siteId @@ -644,8 +672,7 @@ export function dispatchCastleWorker( || !assignmentOwnerIsCanonical(ctx, assignment) ) fail('WORKER_IDEMPOTENCY_STALE'); assertAssignmentState(assignment); - const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); - if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) { + if (!publicWorkerMatchesAssignment(worker, assignment)) { fail('WORKER_IDEMPOTENCY_STALE'); } return Object.freeze({ assignment, idempotent: true }); diff --git a/spacetimedb/src/reducers/accessRequests.ts b/spacetimedb/src/reducers/accessRequests.ts index 4e8fa06..269fe14 100644 --- a/spacetimedb/src/reducers/accessRequests.ts +++ b/spacetimedb/src/reducers/accessRequests.ts @@ -5,6 +5,11 @@ import { resolveAuthResolverAdmission, type AuthResolverAdmission, } from '../admissionPolicy'; +import { + ACCESS_REQUEST_QUEUE_CAPACITY, + accessRequestQueueAcceptsSubmission, + takeBoundedAccessRequestRows, +} from '../accessRequestPolicy'; import { requireAccessRequestResolver, requireAdmin } from '../auth'; import { MAX_SUPPORTED_FID } from '../config'; import warpkeep from '../schema'; @@ -153,6 +158,10 @@ export const accessRequestSubmitV1 = warpkeep.procedure( throw new SenderError('ACCESS_REQUEST_STATE_INTEGRITY'); } let request = tx.db.accessRequestV1.fid.find(requestFid); + const requestCount = tx.db.accessRequestV1.count(); + if (!accessRequestQueueAcceptsSubmission(requestCount, request !== null)) { + throw new SenderError('ACCESS_REQUEST_QUEUE_FULL'); + } if (request === null) { tx.db.accessRequestV1.insert({ fid: requestFid, @@ -215,10 +224,20 @@ export const adminListAccessRequestsV1 = warpkeep.procedure( throw new SenderError('ACCESS_REQUEST_CURSOR_INVALID'); } - const rows = [...tx.db.accessRequestV1.iter()]; - if (BigInt(rows.length) !== tx.db.accessRequestV1.count()) { + const totalRequests = tx.db.accessRequestV1.count(); + if (totalRequests > BigInt(ACCESS_REQUEST_QUEUE_CAPACITY)) { + throw new SenderError('ACCESS_REQUEST_QUEUE_CAPACITY'); + } + const boundedRows = takeBoundedAccessRequestRows( + tx.db.accessRequestV1.iter(), + ); + if ( + boundedRows.overflow + || BigInt(boundedRows.rows.length) !== totalRequests + ) { throw new SenderError('ACCESS_REQUEST_STATE_INTEGRITY'); } + const rows = boundedRows.rows; let pendingRequests = 0n; const visible = rows.flatMap(row => { @@ -269,7 +288,7 @@ export const adminListAccessRequestsV1 = warpkeep.procedure( nextRequestedAtMicros: next?.requestedAtMicros, nextFid: next?.fid, hasMore, - totalRequests: BigInt(rows.length), + totalRequests, pendingRequests, }; }), diff --git a/spacetimedb/tests/accessRequestReducers.test.ts b/spacetimedb/tests/accessRequestReducers.test.ts index 11ff708..65a7427 100644 --- a/spacetimedb/tests/accessRequestReducers.test.ts +++ b/spacetimedb/tests/accessRequestReducers.test.ts @@ -2,6 +2,12 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import test from 'node:test'; +import { + ACCESS_REQUEST_QUEUE_CAPACITY, + accessRequestQueueAcceptsSubmission, + takeBoundedAccessRequestRows, +} from '../src/accessRequestPolicy'; + function source(path: string): string { return readFileSync(new URL(path, import.meta.url), 'utf8'); } @@ -103,6 +109,10 @@ test('submission is admission-cycle idempotent, database-timestamped, and mutati assert.doesNotMatch(status, /\.(?:insert|update|delete)\s*\(/); assert.match(submit, /let request = tx\.db\.accessRequestV1\.fid\.find\(requestFid\)/); + assert.match( + submit, + /accessRequestQueueAcceptsSubmission\(requestCount, request !== null\)/, + ); assert.match(submit, /if \(request === null\)[\s\S]*tx\.db\.accessRequestV1\.insert\(\{/); assert.match(submit, /fid: requestFid,[\s\S]*requestCycle,[\s\S]*requestedAt: tx\.timestamp/); assert.match( @@ -124,6 +134,11 @@ test('Hermes listing is admin-only, bounded, deterministic, and derives resoluti assert.match(admin, /requireAdmin\(tx\)/); assert.match(admin, /limit < 1[\s\S]*limit > MAX_ACCESS_REQUEST_PAGE_SIZE/); assert.match(reducer, /const MAX_ACCESS_REQUEST_PAGE_SIZE = 100/); + assert.match( + admin, + /totalRequests > BigInt\(ACCESS_REQUEST_QUEUE_CAPACITY\)/, + ); + assert.match(admin, /takeBoundedAccessRequestRows\([\s\S]*accessRequestV1\.iter\(\)/); assert.match(admin, /const allowed = tx\.db\.allowedFid\.fid\.find\(row\.fid\)/); assert.match(admin, /resolveAdmissionState\(allowed\)/); assert.match(admin, /requestState = requestCycle !== undefined && row\.requestCycle === requestCycle/); @@ -132,10 +147,29 @@ test('Hermes listing is admin-only, bounded, deterministic, and derives resoluti assert.match(admin, /left\.fid < right\.fid/); assert.match(admin, /entry\.requestedAtMicros === afterRequestedAtMicros[\s\S]*entry\.fid > afterFid/); assert.match(admin, /const hasMore = remaining\.length > entries\.length/); - assert.match(admin, /totalRequests: BigInt\(rows\.length\)/); + assert.match(admin, /totalRequests,/); assert.match(admin, /pendingRequests/); }); +test('queue capacity rejects only first inserts and bounds materialization', () => { + assert.equal(ACCESS_REQUEST_QUEUE_CAPACITY, 4_096); + assert.equal(accessRequestQueueAcceptsSubmission(1n, false, 2), true); + assert.equal(accessRequestQueueAcceptsSubmission(2n, false, 2), false); + assert.equal(accessRequestQueueAcceptsSubmission(2n, true, 2), true); + assert.equal(accessRequestQueueAcceptsSubmission(3n, true, 2), true); + + let reads = 0; + const result = takeBoundedAccessRequestRows((function* rows() { + for (let index = 0; index < 10; index += 1) { + reads += 1; + yield index; + } + }()), 2); + assert.deepEqual(result.rows, [0, 1]); + assert.equal(result.overflow, true); + assert.equal(reads, 3); +}); + test('disabled founders receive a fresh review cycle without gaining authority', () => { const reducer = source('../src/reducers/accessRequests.ts'); const cycle = section( diff --git a/spacetimedb/tests/castleWorkerLifecycleStateful.test.ts b/spacetimedb/tests/castleWorkerLifecycleStateful.test.ts index 98f431d..be3ee31 100644 --- a/spacetimedb/tests/castleWorkerLifecycleStateful.test.ts +++ b/spacetimedb/tests/castleWorkerLifecycleStateful.test.ts @@ -712,6 +712,8 @@ test('four workers share one resource across distinct nodes through replay, sche }, )); assert.ok(dispatches.every(result => !result.idempotent)); + assert.ok(dispatches.every(result => result.assignment !== undefined)); + const activeAssignments = dispatches.map(result => result.assignment!); assert.deepEqual(fixture.counts(), { assignments: 4, occupations: 4, @@ -719,11 +721,11 @@ test('four workers share one resource across distinct nodes through replay, sche receipts: 4, }); assert.equal( - new Set(dispatches.map(result => result.assignment.resourceKind)).size, + new Set(activeAssignments.map(assignment => assignment.resourceKind)).size, 1, ); assert.equal( - new Set(dispatches.map(result => result.assignment.siteId)).size, + new Set(activeAssignments.map(assignment => assignment.siteId)).size, 4, ); @@ -824,6 +826,89 @@ test('four workers share one resource across distinct nodes through replay, sche ); }); +test('an exact dispatch retry after normal completion is a terminal idempotent no-op', () => { + const fixture = makeLifecycleFixture(); + const workerId = [...fixture.workers.keys()].sort()[0]!; + const siteId = fixture.sites[0]!.siteId; + const idempotencyKey = 'lost-response-terminal-dispatch'; + const dispatched = dispatchCastleWorker(fixture.ctx, { + fid: fixture.fid, + castle: fixture.castle, + workerId, + resourceKind: 'gold', + siteId, + idempotencyKey, + }); + assert.ok(dispatched.assignment); + + fixture.runSchedule(fixture.scheduleFor(workerId, 'arrival')); + fixture.runSchedule(fixture.scheduleFor(workerId, 'gathering-expiry')); + fixture.runSchedule(fixture.scheduleFor(workerId, 'return-complete')); + assert.equal(fixture.assignments.size, 0); + assert.equal(fixture.workers.get(workerId)?.status, 'idle'); + const countsAfterCompletion = fixture.counts(); + + const replay = dispatchCastleWorker(fixture.ctx, { + fid: fixture.fid, + castle: fixture.castle, + workerId, + resourceKind: 'gold', + siteId, + idempotencyKey, + }); + assert.equal(replay.idempotent, true); + assert.equal(replay.assignment, undefined); + assert.deepEqual(fixture.counts(), countsAfterCompletion); + + const laterSiteId = fixture.sites[1]!.siteId; + const laterDispatch = dispatchCastleWorker(fixture.ctx, { + fid: fixture.fid, + castle: fixture.castle, + workerId, + resourceKind: 'gold', + siteId: laterSiteId, + idempotencyKey: 'distinct-later-worker-dispatch', + }); + assert.ok(laterDispatch.assignment); + const laterState = { + counts: fixture.counts(), + assignments: structuredClone([...fixture.assignments.values()]), + occupations: structuredClone([...fixture.occupations.values()]), + schedules: structuredClone([...fixture.schedules.values()]), + }; + + const replayDuringLaterAssignment = dispatchCastleWorker(fixture.ctx, { + fid: fixture.fid, + castle: fixture.castle, + workerId, + resourceKind: 'gold', + siteId, + idempotencyKey, + }); + assert.equal(replayDuringLaterAssignment.idempotent, true); + assert.equal(replayDuringLaterAssignment.assignment, undefined); + assert.deepEqual(fixture.counts(), laterState.counts); + assert.deepEqual([...fixture.assignments.values()], laterState.assignments); + assert.deepEqual([...fixture.occupations.values()], laterState.occupations); + assert.deepEqual([...fixture.schedules.values()], laterState.schedules); + + assert.throws( + () => dispatchCastleWorker(fixture.ctx, { + fid: fixture.fid, + castle: fixture.castle, + workerId, + resourceKind: 'gold', + siteId: laterSiteId, + idempotencyKey, + }), + /WORKER_IDEMPOTENCY_CONFLICT/, + ); + assert.deepEqual(fixture.counts(), laterState.counts); + assert.deepEqual([...fixture.assignments.values()], laterState.assignments); + assert.deepEqual([...fixture.occupations.values()], laterState.occupations); + assert.deepEqual([...fixture.schedules.values()], laterState.schedules); +}); + test('a returning worker completes after its former node is reused without touching the replacement worker', () => { const fixture = makeLifecycleFixture(); const [returningWorkerId, replacementWorkerId] = [ @@ -1439,6 +1524,7 @@ test('stateful rollout stages, deterministically backfills, drains, and activate idempotencyKey: 'dispatch-after-worker-activation', }); assert.equal(dispatched.idempotent, false); + assert.ok(dispatched.assignment); assert.equal(dispatched.assignment.workerId, workerId); assert.deepEqual(fixture.counts(), { assignments: 1, @@ -1491,6 +1577,17 @@ test('activation inspection rejects malformed roster identity and non-idle worke idempotencyKey: 'dispatch-orphan-state-01', }); missingAuthority.deleteAssignmentForWorker(workerId); + assert.throws( + () => dispatchCastleWorker(missingAuthority.ctx, { + fid: missingAuthority.fid, + castle: missingAuthority.castle, + workerId, + resourceKind: 'gold', + siteId: missingAuthority.sites[0]!.siteId, + idempotencyKey: 'dispatch-orphan-state-01', + }), + /WORKER_IDEMPOTENCY_STALE/, + ); const graph = inspectCastleWorkerGraph(missingAuthority.ctx); assert.ok(graph.assignmentPublicMismatches > 0n); }); @@ -1526,6 +1623,7 @@ test('late automatic expiry settles at server time after a preadvanced resource siteId: fixture.sites[0]!.siteId, idempotencyKey: 'late-expiry-dispatch', }); + assert.ok(result.assignment); fixture.runSchedule(fixture.scheduleFor(workerId, 'arrival')); const gatheringEndsAtMicros = result.assignment.gatheringEndsAtMicros; const preadvancedCursor = diff --git a/src/components/menu/latestPatchNotes.ts b/src/components/menu/latestPatchNotes.ts index 2d640ce..02dd9eb 100644 --- a/src/components/menu/latestPatchNotes.ts +++ b/src/components/menu/latestPatchNotes.ts @@ -6,6 +6,20 @@ export type LatestPatchNotes = Readonly<{ alphaNotice: string; }>; +const ALPHA_0_3_29_PATCH_NOTES: LatestPatchNotes = Object.freeze({ + releasedOn: '31 JUL 2026', + title: 'THE REALM HOLDS', + summary: + 'Genesis 001 now recovers more cleanly from interrupted touch and embedded navigation, while its gateway records and Worker orders hold to tighter boundaries.', + highlights: Object.freeze([ + 'Interrupted drags and pinches retire cleanly when a Realm record or menu takes focus, and embedded Back navigation recovers when a host silently drops its history event.', + 'Gathering portraits keep one bounded accessible control lane, ambient overflow portraits no longer steal taps, and selected keep names refresh when public profile details arrive.', + 'Farcaster verification and access requests now have firmer lifetime and queue bounds, while an exact Worker order can be retried after the Worker returns without sending them twice.' + ]), + alphaNotice: + 'Alpha 0.3.29 remains an invite-only, unfinished world. This continuity patch does not change admission decisions, castle ownership, gathering rates, balances, terrain, or the larger strategy features still being built.' +}); + const ALPHA_0_3_28_PATCH_NOTES: LatestPatchNotes = Object.freeze({ releasedOn: '31 JUL 2026', title: 'THE REALM IN HAND', @@ -163,6 +177,7 @@ const ALPHA_0_3_22_PATCH_NOTES: LatestPatchNotes = Object.freeze({ export const WARPKEEP_PATCH_NOTES_BY_VERSION: Readonly> = Object.freeze({ + '0.3.29': ALPHA_0_3_29_PATCH_NOTES, '0.3.28': ALPHA_0_3_28_PATCH_NOTES, '0.3.27': ALPHA_0_3_27_PATCH_NOTES, '0.3.26': ALPHA_0_3_26_PATCH_NOTES, diff --git a/src/components/realm/RealmHud.tsx b/src/components/realm/RealmHud.tsx index 4f22783..bbd50f0 100644 --- a/src/components/realm/RealmHud.tsx +++ b/src/components/realm/RealmHud.tsx @@ -1044,7 +1044,13 @@ export function RealmHud({ else setLocalSurface('closed'); }; - if (selectionAnnouncementRef.current.key !== selectionAnnouncementKey) { + if ( + selectionAnnouncementRef.current.key !== selectionAnnouncementKey + || selectionAnnouncementRef.current.copy !== selectionAnnouncementCandidate + ) { + // Public profile data can resolve after the castle was selected. Refresh + // the live-region copy at the same coordinate without state or an effect, + // so unrelated renders remain silent and cannot create an update loop. selectionAnnouncementRef.current = { key: selectionAnnouncementKey, copy: selectionAnnouncementCandidate diff --git a/src/components/realm/RealmMapScreen.css b/src/components/realm/RealmMapScreen.css index db8c531..502ba29 100644 --- a/src/components/realm/RealmMapScreen.css +++ b/src/components/realm/RealmMapScreen.css @@ -486,10 +486,8 @@ var(--realm-resource-marker-y), 0 ) translate(-50%, -100%); - pointer-events: auto; - cursor: pointer; - touch-action: none; - -webkit-touch-callout: none; + pointer-events: none; + cursor: default; user-select: none; } diff --git a/src/components/realm/RealmResourceOccupantMarkers.tsx b/src/components/realm/RealmResourceOccupantMarkers.tsx index 406dcd1..4d7011f 100644 --- a/src/components/realm/RealmResourceOccupantMarkers.tsx +++ b/src/components/realm/RealmResourceOccupantMarkers.tsx @@ -61,7 +61,7 @@ export function RealmResourceOccupantMarkers({ const seen = new Set(); return presenceMarkerKeys.flatMap((key) => { // The bounded keyboard-control lane owns its selected keys. The passive - // pointer lane fills the remainder, so one public occupation is never + // presentation lane fills the remainder, so one public occupation is never // represented by two PFPs, reservation badges, or hit targets. if (seen.has(key) || visibleKeySet.has(key)) return []; seen.add(key); @@ -123,18 +123,6 @@ export function RealmResourceOccupantMarkers({ aria-hidden="true" className="realm-resource-occupant-presences" data-resource-occupant-presences="true" - onClick={(event) => { - const target = (event.target as Element).closest( - '[data-resource-occupant-lane="presence"][data-resource-occupant-key]' - ); - if (!target || !event.currentTarget.contains(target)) return; - const marker = markersByKey.get(target.dataset.resourceOccupantKey ?? ''); - if (!marker) return; - event.preventDefault(); - event.stopPropagation(); - onSelect(marker); - }} - onPointerDown={(event) => event.stopPropagation()} > {presenceMarkers.map((marker) => { const key = realmResourceOccupantMarkerKey(marker); @@ -153,11 +141,6 @@ export function RealmResourceOccupantMarkers({ data-resource-occupant-key={key} data-resource-occupant-lane="presence" key={`presence:${key}`} - onPointerEnter={() => onHover?.(key)} - onPointerLeave={() => onHover?.(null)} - title={reservedForGenericWorker - ? `Reserved ${RESOURCE_KIND_LABELS[marker.resource]} · worker en route` - : `Open ${castleProfileLabel(marker.profile)} at ${RESOURCE_KIND_LABELS[marker.resource]}`} style={{ '--realm-resource-marker-x': '0px', '--realm-resource-marker-y': '0px' diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index 3ed92a5..6dc819e 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -243,7 +243,6 @@ const CANONICAL_KEEP_CAMERA_FOCUS_SIZE = Object.freeze({ const REALM_WORLD_CONTROL_SELECTOR = [ '.realm-castle-label', '.realm-worker-presence-marker', - '.realm-resource-occupant-presence', '.realm-resource-occupant-marker' ].join(','); const WORLD_CONTROL_COMPATIBILITY_CLICK_WINDOW_MILLISECONDS = 750; @@ -4169,8 +4168,22 @@ function initializeRealmScene( render(); }; + const cancelAllPointers = (result: RealmPointerGestureResult) => { + if (!result.accepted) return; + worldControlPointerTargets.clear(); + pointerCaptureTargets.clear(); + flushDirectGesture(); + clearWorldControlClickSuppressions(); + cancelPendingHover(); + dispatchHover(null); + syncGesturePhase(result); + }; + const handlePointerUp = (event: PointerEvent) => { - if (!sceneAcceptsInteraction()) return; + if (!sceneAcceptsInteraction()) { + cancelAllPointers(pointerGestures.blur()); + return; + } if (contextLost && ( contextLostBlocksTarget(event.target) || pointerGestures.snapshot().pointerCount > 0 @@ -4209,7 +4222,10 @@ function initializeRealmScene( }; const handlePointerCancel = (event: PointerEvent) => { - if (!sceneAcceptsInteraction()) return; + if (!sceneAcceptsInteraction()) { + cancelAllPointers(pointerGestures.blur()); + return; + } if (contextLost && ( contextLostBlocksTarget(event.target) || pointerGestures.snapshot().pointerCount > 0 @@ -4229,7 +4245,10 @@ function initializeRealmScene( }; const handleLostPointerCapture = (event: PointerEvent) => { - if (!sceneAcceptsInteraction()) return; + if (!sceneAcceptsInteraction()) { + cancelAllPointers(pointerGestures.blur()); + return; + } if (contextLost) return; const result = pointerGestures.lostCapture(event.pointerId); if (!result.accepted) return; @@ -4241,17 +4260,6 @@ function initializeRealmScene( syncGesturePhase(result); }; - const cancelAllPointers = (result: RealmPointerGestureResult) => { - if (!result.accepted) return; - worldControlPointerTargets.clear(); - pointerCaptureTargets.clear(); - flushDirectGesture(); - clearWorldControlClickSuppressions(); - cancelPendingHover(); - dispatchHover(null); - syncGesturePhase(result); - }; - const handleWindowBlur = () => { if (!sceneAcceptsInteraction()) return; cancelAllPointers(pointerGestures.blur()); @@ -5089,6 +5097,13 @@ function initializeRealmScene( dispose: disposeScene, setPresentationActive: (active) => { if (cleanup.isDisposed()) return; + if (!active) { + // A browser-history or hosted-surface transition can hide the canvas + // between pointerdown and its terminal event. Retire the gesture before + // disabling interaction so a reused pointer ID cannot be rejected, or + // paired with a stale contact as a ghost pinch, when the Realm returns. + cancelAllPointers(pointerGestures.blur()); + } presentationActive = active; options.canvas.dataset.realmPresentationActive = String(active); if (!active) { diff --git a/src/components/realm/useRealmSurfaceNavigation.ts b/src/components/realm/useRealmSurfaceNavigation.ts index f2f8235..50a5e1d 100644 --- a/src/components/realm/useRealmSurfaceNavigation.ts +++ b/src/components/realm/useRealmSurfaceNavigation.ts @@ -14,12 +14,15 @@ import { pushRealmSurfaceRoute, readRealmSurfaceHistoryState, replaceRealmSurfaceRoute, + sameRealmSurfaceRoute, type RealmSurfaceHistoryState, type RealmSurfaceRoute } from './realmSurfaceNavigation'; type BrowserHistoryRecord = Record; +export const REALM_SURFACE_HISTORY_TRAVERSAL_WATCHDOG_MILLISECONDS = 1_500; + function currentHistoryRecord(): BrowserHistoryRecord { const current = window.history.state; return current && typeof current === 'object' && !Array.isArray(current) @@ -47,6 +50,14 @@ function currentRealmUrl() { return `${window.location.pathname}${window.location.search}${window.location.hash}`; } +function sameRealmSurfaceStack( + left: readonly RealmSurfaceRoute[], + right: readonly RealmSurfaceRoute[] +) { + return left.length === right.length + && left.every((route, index) => sameRealmSurfaceRoute(route, right[index])); +} + export type RealmSurfaceNavigation = Readonly<{ stack: readonly RealmSurfaceRoute[]; current: RealmSurfaceRoute | undefined; @@ -74,11 +85,56 @@ export function useRealmSurfaceNavigation({ >('idle'); const stackRef = useRef(stack); const historyEnabledRef = useRef(historyEnabled); - const previousHistoryEnabledRef = useRef(historyEnabled); const historyTraversalPendingRef = useRef(false); + const historyTraversalWatchdogRef = useRef(undefined); stackRef.current = stack; historyEnabledRef.current = historyEnabled; + const clearHistoryTraversalPending = useCallback(() => { + historyTraversalPendingRef.current = false; + const watchdog = historyTraversalWatchdogRef.current; + historyTraversalWatchdogRef.current = undefined; + if (watchdog !== undefined) window.clearTimeout(watchdog); + }, []); + + const reconcileHistoryStack = useCallback(( + nextStack: readonly RealmSurfaceRoute[] + ) => { + const previousStack = stackRef.current; + if (sameRealmSurfaceStack(previousStack, nextStack)) return; + setMotion( + nextStack.length < previousStack.length + ? 'backward' + : nextStack.length > previousStack.length + ? 'forward' + : 'replace' + ); + stackRef.current = nextStack; + setStack(nextStack); + }, []); + + const beginHistoryTraversal = useCallback(() => { + if (historyTraversalPendingRef.current) return false; + clearHistoryTraversalPending(); + historyTraversalPendingRef.current = true; + const pendingSession = sessionRef.current; + historyTraversalWatchdogRef.current = window.setTimeout(() => { + historyTraversalWatchdogRef.current = undefined; + if (!historyTraversalPendingRef.current) return; + historyTraversalPendingRef.current = false; + if (sessionRef.current !== pendingSession) return; + const restored = readRealmSurfaceHistoryState( + currentHistoryRecord()[REALM_SURFACE_HISTORY_KEY], + pendingSession + ); + // A WebView may traverse without delivering popstate. Reconcile only an + // exact same-session envelope; a silent no-op or unrelated host state + // simply releases the latch so the player can retry safely. + if (restored) reconcileHistoryStack(restored.stack); + }, REALM_SURFACE_HISTORY_TRAVERSAL_WATCHDOG_MILLISECONDS); + return true; + }, [clearHistoryTraversalPending, reconcileHistoryStack]); + const envelopeFor = useCallback((nextStack: readonly RealmSurfaceRoute[]) => ( Object.freeze({ version: 1 as const, @@ -96,15 +152,9 @@ export function useRealmSurfaceNavigation({ }, [envelopeFor]); useLayoutEffect(() => { + clearHistoryTraversalPending(); identityGenerationRef.current += 1; sessionRef.current = `realm-${reactId}-${identityGenerationRef.current}`; - if ( - !historyEnabled - || previousHistoryEnabledRef.current !== historyEnabled - ) { - historyTraversalPendingRef.current = false; - } - previousHistoryEnabledRef.current = historyEnabled; stackRef.current = Object.freeze([]); setStack(stackRef.current); setMotion('idle'); @@ -116,7 +166,13 @@ export function useRealmSurfaceNavigation({ currentRealmUrl() ); } - }, [historyEnabled, identityKey, reactId, replaceBrowserState]); + }, [ + clearHistoryTraversalPending, + historyEnabled, + identityKey, + reactId, + replaceBrowserState + ]); useEffect(() => { if (!historyEnabled) return undefined; @@ -126,20 +182,19 @@ export function useRealmSurfaceNavigation({ : undefined; const restored = readRealmSurfaceHistoryState(candidate, sessionRef.current); const nextStack = restored?.stack ?? Object.freeze([]); - setMotion( - nextStack.length < stackRef.current.length - ? 'backward' - : nextStack.length > stackRef.current.length - ? 'forward' - : 'replace' - ); - historyTraversalPendingRef.current = false; - stackRef.current = nextStack; - setStack(nextStack); + clearHistoryTraversalPending(); + reconcileHistoryStack(nextStack); }; window.addEventListener('popstate', restore); - return () => window.removeEventListener('popstate', restore); - }, [historyEnabled]); + return () => { + window.removeEventListener('popstate', restore); + clearHistoryTraversalPending(); + }; + }, [ + clearHistoryTraversalPending, + historyEnabled, + reconcileHistoryStack + ]); const push = useCallback((route: RealmSurfaceRoute) => { if (historyTraversalPendingRef.current) return; @@ -190,30 +245,28 @@ export function useRealmSurfaceNavigation({ if (stackRef.current.length === 0) return; const nextStack = popRealmSurfaceRoute(stackRef.current); if (historyEnabledRef.current) { - if (historyTraversalPendingRef.current) return; - historyTraversalPendingRef.current = true; + if (!beginHistoryTraversal()) return; try { window.history.back(); } catch { - historyTraversalPendingRef.current = false; + clearHistoryTraversalPending(); } return; } setMotion('backward'); stackRef.current = nextStack; setStack(nextStack); - }, []); + }, [beginHistoryTraversal, clearHistoryTraversalPending]); const closeToRealm = useCallback(() => { const depth = stackRef.current.length; if (depth === 0) return; if (historyEnabledRef.current) { - if (historyTraversalPendingRef.current) return; - historyTraversalPendingRef.current = true; + if (!beginHistoryTraversal()) return; try { window.history.go(-depth); } catch { - historyTraversalPendingRef.current = false; + clearHistoryTraversalPending(); } return; } @@ -221,7 +274,7 @@ export function useRealmSurfaceNavigation({ setMotion('backward'); stackRef.current = nextStack; setStack(nextStack); - }, []); + }, [beginHistoryTraversal, clearHistoryTraversalPending]); return Object.freeze({ stack, diff --git a/tests/activationToolingSecurity.test.ts b/tests/activationToolingSecurity.test.ts index 5f0be02..1e18576 100644 --- a/tests/activationToolingSecurity.test.ts +++ b/tests/activationToolingSecurity.test.ts @@ -878,7 +878,7 @@ type AuthV2FixtureOptions = { publicRoutesPaused?: boolean; adminCorsLeak?: Readonly<{ pathname: string; - method: 'GET' | 'OPTIONS'; + method: 'GET' | 'OPTIONS' | 'POST'; origin: string; }>; }; @@ -1052,7 +1052,7 @@ function authV2BridgeFetch(options: AuthV2FixtureOptions = {}) { } if ( - (method === 'GET' || method === 'OPTIONS') + (method === 'GET' || method === 'OPTIONS' || method === 'POST') && AUTH_V2_SERVER_ONLY_ADMIN_PATHS.has(url.pathname) ) { const leak = options.adminCorsLeak; @@ -1062,6 +1062,14 @@ function authV2BridgeFetch(options: AuthV2FixtureOptions = {}) { && leak.origin === origin ? { 'access-control-allow-origin': origin } : {}; + if (method === 'POST') { + return authV2JsonResponse({ + error: { + code: 'admin_browser_forbidden', + message: 'This endpoint is server-only.', + }, + }, 403, cors, options.omitSecurityHeader); + } return authV2JsonResponse({ error: { code: 'not_found', message: 'Route not found.' }, }, 404, cors, options.omitSecurityHeader); @@ -3712,7 +3720,7 @@ describe('bounded auth-v2 production readiness verification', () => { ); }); - it('attests contained auth-v2 using only bounded GET and OPTIONS requests', async () => { + it('attests contained auth-v2 using only bounded non-mutating requests', async () => { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); const fetchImpl = authV2BridgeFetch(); @@ -3721,12 +3729,12 @@ describe('bounded auth-v2 production readiness verification', () => { fetchImpl, })).resolves.toBeUndefined(); - expect(fetchImpl).toHaveBeenCalledTimes(32); + expect(fetchImpl).toHaveBeenCalledTimes(35); for (const [input, init] of fetchImpl.mock.calls) { const url = new URL(String(input)); const headers = new Headers(init?.headers); expect(url.origin).toBe(ISSUER); - expect(init?.method ?? 'GET').toMatch(/^(?:GET|OPTIONS)$/); + expect(init?.method ?? 'GET').toMatch(/^(?:GET|OPTIONS|POST)$/); expect(init?.body).toBeUndefined(); expect(init?.redirect).toBe('manual'); expect(init?.cache).toBe('no-store'); @@ -3766,8 +3774,8 @@ describe('bounded auth-v2 production readiness verification', () => { } for (const pathname of AUTH_V2_SERVER_ONLY_ADMIN_PATHS) { const calls = fetchImpl.mock.calls.filter(([input]) => new URL(String(input)).pathname === pathname); - expect(calls).toHaveLength(3); - expect(calls.map(([, init]) => init?.method)).toEqual(['GET', 'OPTIONS', 'OPTIONS']); + expect(calls).toHaveLength(4); + expect(calls.map(([, init]) => init?.method)).toEqual(['GET', 'OPTIONS', 'OPTIONS', 'POST']); } for (const pathname of AUTH_V2_ACCESS_REQUEST_PATHS) { const calls = fetchImpl.mock.calls.filter( @@ -3809,12 +3817,12 @@ describe('bounded auth-v2 production readiness verification', () => { fetchImpl, })).resolves.toBeUndefined(); - expect(fetchImpl).toHaveBeenCalledTimes(32); + expect(fetchImpl).toHaveBeenCalledTimes(35); for (const [input, init] of fetchImpl.mock.calls) { const url = new URL(String(input)); const headers = new Headers(init?.headers); expect(url.origin).toBe(ISSUER); - expect(init?.method ?? 'GET').toMatch(/^(?:GET|OPTIONS)$/); + expect(init?.method ?? 'GET').toMatch(/^(?:GET|OPTIONS|POST)$/); expect(init?.body).toBeUndefined(); expect(init?.redirect).toBe('manual'); expect(init?.cache).toBe('no-store'); @@ -3825,8 +3833,8 @@ describe('bounded auth-v2 production readiness verification', () => { } for (const pathname of AUTH_V2_SERVER_ONLY_ADMIN_PATHS) { const calls = fetchImpl.mock.calls.filter(([input]) => new URL(String(input)).pathname === pathname); - expect(calls).toHaveLength(3); - expect(calls.map(([, init]) => init?.method)).toEqual(['GET', 'OPTIONS', 'OPTIONS']); + expect(calls).toHaveLength(4); + expect(calls.map(([, init]) => init?.method)).toEqual(['GET', 'OPTIONS', 'OPTIONS', 'POST']); } for (const pathname of AUTH_V2_ACCESS_REQUEST_PATHS) { const calls = fetchImpl.mock.calls.filter( @@ -3883,6 +3891,20 @@ describe('bounded auth-v2 production readiness verification', () => { }, ); + it.each([...AUTH_V2_SERVER_ONLY_ADMIN_PATHS])( + 'fails closed when credential-free allowed-origin POST exposes CORS on %s', + async (pathname) => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + await expect(verifyBridge(FRONTEND, ISSUER, { + requireAuthV2Enabled: true, + fetchImpl: authV2BridgeFetch({ + publicAuthEnabled: true, + adminCorsLeak: { pathname, method: 'POST', origin: FRONTEND }, + }), + })).rejects.toThrow(/exposed browser CORS/i); + }, + ); + it.each([ ...[...AUTH_V2_SERVER_ONLY_ADMIN_PATHS].map(pathname => [pathname, FRONTEND] as const), ...[...AUTH_V2_SERVER_ONLY_ADMIN_PATHS].map(pathname => [pathname, 'https://not-warpkeep.invalid'] as const), diff --git a/tests/buildInfo.test.ts b/tests/buildInfo.test.ts index c888306..5324ff4 100644 --- a/tests/buildInfo.test.ts +++ b/tests/buildInfo.test.ts @@ -24,7 +24,7 @@ describe('Warpkeep build identity', () => { ); }); - it('reads the 0.3.28 product version from package metadata', () => { + it('reads the 0.3.29 product version from package metadata', () => { const packageJson = JSON.parse( readFileSync(resolve(process.cwd(), 'package.json'), 'utf8') ) as { version?: unknown }; @@ -32,7 +32,7 @@ describe('Warpkeep build identity', () => { readFileSync(resolve(process.cwd(), 'package-lock.json'), 'utf8') ) as { version?: unknown; packages?: Record }; - expect(readWarpkeepProductVersion(packageJson.version)).toBe('0.3.28'); + expect(readWarpkeepProductVersion(packageJson.version)).toBe('0.3.29'); expect(packageLock.version).toBe(packageJson.version); expect(packageLock.packages?.['']?.version).toBe(packageJson.version); expect(readWarpkeepProductVersion('1.0.0-alpha.1+build.7')).toBe('1.0.0-alpha.1+build.7'); diff --git a/tests/latestPatchNotes.test.ts b/tests/latestPatchNotes.test.ts index bd17e7f..84135ba 100644 --- a/tests/latestPatchNotes.test.ts +++ b/tests/latestPatchNotes.test.ts @@ -18,24 +18,24 @@ describe('latest in-menu patch notes', () => { expect(Object.keys(WARPKEEP_PATCH_NOTES_BY_VERSION)).toContain(packageJson.version); expect(getLatestPatchNotes(packageJson.version)).toMatchObject({ releasedOn: '31 JUL 2026', - title: 'THE REALM IN HAND' + title: 'THE REALM HOLDS' }); expect(getLatestPatchNotes(packageJson.version)?.highlights.join(' ')).toMatch( - /compact portrait rail.*keeper portrait.*four resources.*safe areas/i + /Interrupted drags.*pinches.*menu.*embedded Back navigation/i ); expect(getLatestPatchNotes(packageJson.version)?.highlights.join(' ')).toMatch( - /Keeps.*Workers.*resources.*Water.*terrain.*Explore.*Settings.*Back/i + /Gathering portraits.*accessible control lane.*overflow portraits.*no longer steal taps/i ); expect(getLatestPatchNotes(packageJson.version)?.highlights.join(' ')).toMatch( - /server-validated Quick Auth.*memory-only.*browser sign-in.*SIWF/i + /Farcaster verification.*lifetime.*queue bounds.*Worker order.*without sending them twice/i ); expect(getLatestPatchNotes(packageJson.version)?.highlights.join(' ')).not.toMatch( /released to players|deployed to players|public balances|guaranteed rewards/i ); - expect(getLatestPatchNotes(packageJson.version)?.summary).toContain('Farcaster'); + expect(getLatestPatchNotes(packageJson.version)?.summary).toContain('Genesis 001'); expect(getLatestPatchNotes(packageJson.version)?.alphaNotice).toContain('unfinished'); expect(getLatestPatchNotes(packageJson.version)?.alphaNotice).toContain( - 'not admission; it creates no castle and changes no ownership, balance, Worker authority, terrain, or persistent world rule' + 'does not change admission decisions, castle ownership, gathering rates, balances, terrain' ); const alpha0324 = getLatestPatchNotes('0.3.24'); diff --git a/tests/menuFarcasterAuthIntegration.test.tsx b/tests/menuFarcasterAuthIntegration.test.tsx index 88ab73f..2387850 100644 --- a/tests/menuFarcasterAuthIntegration.test.tsx +++ b/tests/menuFarcasterAuthIntegration.test.tsx @@ -1112,7 +1112,7 @@ describe('WarpkeepMainMenu Farcaster authentication integration', () => { 'CREDITS' ]); expect(screen.getByRole('button', { - name: 'Open patch notes for Warpkeep ALPHA 0.3.28' + name: 'Open patch notes for Warpkeep ALPHA 0.3.29' })).not.toBeNull(); expect(screen.queryByRole('button', { name: 'CONTINUE' })).toBeNull(); diff --git a/tests/menuMainMenu.test.tsx b/tests/menuMainMenu.test.tsx index 776556f..edb2955 100644 --- a/tests/menuMainMenu.test.tsx +++ b/tests/menuMainMenu.test.tsx @@ -22,7 +22,7 @@ function installMotionPreference(matches = false) { function getPatchNotesTrigger(options: { hidden?: boolean } = {}) { return screen.getByRole('button', { ...options, - name: 'Open patch notes for Warpkeep ALPHA 0.3.28' + name: 'Open patch notes for Warpkeep ALPHA 0.3.29' }); } @@ -131,11 +131,11 @@ describe('WarpkeepMainMenu', () => { act(() => patchNotes.focus()); expect(screen.getByRole('status').textContent).toContain('living frontier'); - expect(screen.queryByRole('region', { name: 'THE REALM IN HAND' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'THE REALM HOLDS' })).toBeNull(); fireEvent.click(patchNotes, { detail: 0 }); expect(screen.queryByRole('status')).toBeNull(); - expect(screen.getByRole('region', { name: 'THE REALM IN HAND' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'THE REALM HOLDS' })).not.toBeNull(); expect(document.activeElement).toBe(patchNotes); }); @@ -239,25 +239,25 @@ describe('WarpkeepMainMenu', () => { expect(patchNotes.getAttribute('aria-expanded')).toBe('false'); expect(patchNotes.getAttribute('aria-controls')).toBe('warpkeep-latest-patch-notes'); - expect(screen.queryByRole('region', { name: 'THE REALM IN HAND' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'THE REALM HOLDS' })).toBeNull(); act(() => patchNotes.focus()); - expect(screen.queryByRole('region', { name: 'THE REALM IN HAND' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'THE REALM HOLDS' })).toBeNull(); fireEvent.click(patchNotes, { detail: 0 }); - const notes = screen.getByRole('region', { name: 'THE REALM IN HAND' }); + const notes = screen.getByRole('region', { name: 'THE REALM HOLDS' }); expect(patchNotes.getAttribute('aria-expanded')).toBe('true'); - expect(notes.textContent).toContain('LATEST PATCH · ALPHA 0.3.28'); - expect(notes.textContent).toContain('fits naturally in Farcaster'); - expect(notes.textContent).toContain('compact portrait rail'); - expect(notes.textContent).toContain('server-validated Quick Auth'); + expect(notes.textContent).toContain('LATEST PATCH · ALPHA 0.3.29'); + expect(notes.textContent).toContain('recovers more cleanly from interrupted touch'); + expect(notes.textContent).toContain('one bounded accessible control lane'); + expect(notes.textContent).toContain('firmer lifetime and queue bounds'); expect(notes.getAttribute('tabindex')).toBe('0'); expect(within(notes).queryByRole('link')).toBeNull(); act(() => notes.focus()); expect(document.activeElement).toBe(notes); fireEvent.keyDown(document, { key: 'Escape' }); - expect(screen.queryByRole('region', { name: 'THE REALM IN HAND' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'THE REALM HOLDS' })).toBeNull(); expect(document.activeElement).toBe(patchNotes); expect(onRequestReturn).not.toHaveBeenCalled(); @@ -270,19 +270,19 @@ describe('WarpkeepMainMenu', () => { const patchNotes = getPatchNotesTrigger(); fireEvent.pointerEnter(patchNotes, { pointerType: 'mouse' }); - expect(screen.getByRole('region', { name: 'THE REALM IN HAND' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'THE REALM HOLDS' })).not.toBeNull(); fireEvent.pointerDown(document.body, { pointerType: 'mouse' }); - expect(screen.queryByRole('region', { name: 'THE REALM IN HAND' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'THE REALM HOLDS' })).toBeNull(); fireEvent.pointerDown(patchNotes, { pointerType: 'touch' }); patchNotes.focus(); fireEvent.click(patchNotes); - expect(screen.getByRole('region', { name: 'THE REALM IN HAND' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'THE REALM HOLDS' })).not.toBeNull(); fireEvent.pointerDown(patchNotes, { pointerType: 'touch' }); fireEvent.click(patchNotes); - expect(screen.queryByRole('region', { name: 'THE REALM IN HAND' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'THE REALM HOLDS' })).toBeNull(); }); it('keeps hover notes reachable across the anchor gap and toggles by activation', () => { @@ -291,21 +291,21 @@ describe('WarpkeepMainMenu', () => { const patchNotes = getPatchNotesTrigger(); fireEvent.pointerEnter(patchNotes, { pointerType: 'mouse' }); - expect(screen.getByRole('region', { name: 'THE REALM IN HAND' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'THE REALM HOLDS' })).not.toBeNull(); fireEvent.pointerLeave(patchNotes, { pointerType: 'mouse' }); act(() => vi.advanceTimersByTime(250)); - const panel = screen.getByRole('region', { name: 'THE REALM IN HAND' }); + const panel = screen.getByRole('region', { name: 'THE REALM HOLDS' }); fireEvent.pointerEnter(panel, { pointerType: 'mouse' }); act(() => vi.advanceTimersByTime(200)); - expect(screen.getByRole('region', { name: 'THE REALM IN HAND' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'THE REALM HOLDS' })).not.toBeNull(); fireEvent.click(patchNotes); fireEvent.pointerLeave(patchNotes, { pointerType: 'mouse' }); act(() => vi.advanceTimersByTime(500)); - expect(screen.getByRole('region', { name: 'THE REALM IN HAND' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'THE REALM HOLDS' })).not.toBeNull(); fireEvent.click(patchNotes); - expect(screen.queryByRole('region', { name: 'THE REALM IN HAND' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'THE REALM HOLDS' })).toBeNull(); }); it('keeps inactive menu controls hidden, inert, and outside the tab order', () => { diff --git a/tests/realmCastleCssContract.test.ts b/tests/realmCastleCssContract.test.ts index 958254d..3cfe733 100644 --- a/tests/realmCastleCssContract.test.ts +++ b/tests/realmCastleCssContract.test.ts @@ -426,7 +426,7 @@ describe('compact Realm CSS contract', () => { } }); - it('keeps resource occupants camera-stable, embedded, and touch accessible', () => { + it('keeps resource occupants camera-stable with passive overflow and accessible controls', () => { const presenceLayer = block(MAP, '.realm-resource-occupant-presences {'); const presence = block(MAP, '.realm-resource-occupant-presence {'); const marker = block(MAP, '.realm-resource-occupant-marker {'); @@ -444,10 +444,10 @@ describe('compact Realm CSS contract', () => { expect(presence).toContain('--realm-resource-presence-hit-size: 44px;'); expect(presence).toContain('width: var(--realm-resource-presence-hit-size);'); expect(presence).toContain('height: var(--realm-resource-presence-hit-size);'); - expect(presence).toContain('pointer-events: auto;'); - expect(presence).toContain('cursor: pointer;'); - expect(presence).toContain('touch-action: none;'); - expect(presence).toContain('-webkit-touch-callout: none;'); + expect(presence).toContain('pointer-events: none;'); + expect(presence).toContain('cursor: default;'); + expect(presence).not.toContain('touch-action: none;'); + expect(presence).not.toContain('-webkit-touch-callout: none;'); expect(marker).toContain('touch-action: none;'); expect(presence).not.toMatch(/transition:[^;]*transform/); expect(marker).toContain('--realm-resource-marker-size: 44px;'); diff --git a/tests/realmHud.test.tsx b/tests/realmHud.test.tsx index 9e035f2..188dd29 100644 --- a/tests/realmHud.test.tsx +++ b/tests/realmHud.test.tsx @@ -420,6 +420,34 @@ describe('RealmHud', () => { .toBe('@peerkeeper, Peer Watch. Selected castle.'); expect(document.body.textContent).not.toMatch(/Activate its record|Level 3 castle|movement cost|generation/i); + rerender( + + ); + expect(announcement.textContent) + .toBe('@peerkeeper-renamed, Peer Watch. Selected castle.'); + + rerender( + + ); + expect(announcement.textContent) + .toBe('@peerkeeper-renamed, Peer Citadel. Selected castle.'); + rerender( { - it('represents every public occupation exactly once across the bounded control and pointer lanes', () => { + it('represents every public occupation once and keeps overflow presences passive', () => { const markers = Array.from({ length: 40 }, (_, index) => marker( `genesis-001:wood:${String(index + 1).padStart(4, '0')}`, { nodeCoord: { q: index, r: -index } } @@ -61,11 +61,13 @@ describe('resource occupant marker surface', () => { const presenceMarkerKeys = markers.map((entry) => `wood:${entry.siteId}`).reverse(); const controlKeys = presenceMarkerKeys.slice(0, 24); const select = vi.fn(); + const hover = vi.fn(); const { container } = render( undefined} onSelect={select} /> @@ -85,8 +87,10 @@ describe('resource occupant marker surface', () => { )); expect(passivePresence).toBeTruthy(); fireEvent.click(passivePresence!); - expect(select).toHaveBeenCalledOnce(); - expect(select).toHaveBeenCalledWith(markers[0]); + fireEvent.pointerEnter(passivePresence!); + expect(select).not.toHaveBeenCalled(); + expect(hover).not.toHaveBeenCalled(); + expect(passivePresence?.hasAttribute('title')).toBe(false); }); it('mounts only projected-visible controls', () => { diff --git a/tests/realmSceneCleanup.test.ts b/tests/realmSceneCleanup.test.ts index 08ae087..c1e2abe 100644 --- a/tests/realmSceneCleanup.test.ts +++ b/tests/realmSceneCleanup.test.ts @@ -1987,6 +1987,85 @@ describe('realm scene setup cleanup', () => { scene.dispose(); }); + it('retires active and inactive-terminal pointers across presentation boundaries', () => { + vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1); + const canvas = document.createElement('canvas'); + Object.defineProperties(canvas, { + setPointerCapture: { configurable: true, value: vi.fn() }, + releasePointerCapture: { configurable: true, value: vi.fn() }, + hasPointerCapture: { configurable: true, value: vi.fn(() => true) } + }); + const scene = createRealmScene(createOptions(canvas)); + + dispatchPointer(canvas, 'pointerdown', { + pointerId: 81, + clientX: 100, + clientY: 100, + pointerType: 'touch' + }); + dispatchPointer(canvas, 'pointermove', { + pointerId: 81, + clientX: 140, + clientY: 100, + pointerType: 'touch' + }); + expect(canvas.dataset.dragging).toBe('true'); + + scene.setPresentationActive(false); + expect(canvas.dataset.dragging).toBeUndefined(); + dispatchPointer(canvas, 'pointerup', { + pointerId: 81, + clientX: 140, + clientY: 100, + pointerType: 'touch' + }); + scene.setPresentationActive(true); + + const freshAfterPresentation = dispatchPointer(canvas, 'pointerdown', { + pointerId: 81, + clientX: 160, + clientY: 120, + pointerType: 'touch' + }); + expect(freshAfterPresentation.defaultPrevented).toBe(true); + dispatchPointer(canvas, 'pointerup', { + pointerId: 81, + clientX: 160, + clientY: 120, + pointerType: 'touch' + }); + + dispatchPointer(canvas, 'pointerdown', { + pointerId: 82, + clientX: 180, + clientY: 140, + pointerType: 'touch' + }); + canvas.dataset.realmCanvasActive = 'false'; + dispatchPointer(canvas, 'pointerup', { + pointerId: 82, + clientX: 180, + clientY: 140, + pointerType: 'touch' + }); + canvas.dataset.realmCanvasActive = 'true'; + const freshAfterInactiveTerminal = dispatchPointer(canvas, 'pointerdown', { + pointerId: 82, + clientX: 200, + clientY: 160, + pointerType: 'touch' + }); + expect(freshAfterInactiveTerminal.defaultPrevented).toBe(true); + dispatchPointer(canvas, 'pointerup', { + pointerId: 82, + clientX: 200, + clientY: 160, + pointerType: 'touch' + }); + + scene.dispose(); + }); + it('clears stale castle hover before wheel-driven camera motion', () => { const canvas = document.createElement('canvas'); const onHover = vi.fn(); @@ -3328,7 +3407,7 @@ describe('realm scene setup cleanup', () => { root.remove(); }); - it('shares touch tap, pan, and pinch ownership with every map-world control', () => { + it('shares touch tap, pan, and pinch ownership with every interactive map-world control', () => { const root = document.createElement('main'); root.className = 'realm-map-screen'; const canvas = document.createElement('canvas'); @@ -3336,7 +3415,6 @@ describe('realm scene setup cleanup', () => { const controls = [ 'realm-castle-label', 'realm-worker-presence-marker', - 'realm-resource-occupant-presence', 'realm-resource-occupant-marker' ].map((className) => { const control = document.createElement('button'); diff --git a/tests/renderedWebglBrowserProbe.test.ts b/tests/renderedWebglBrowserProbe.test.ts index a5d65ef..6be6ce7 100644 --- a/tests/renderedWebglBrowserProbe.test.ts +++ b/tests/renderedWebglBrowserProbe.test.ts @@ -910,11 +910,12 @@ describe('rendered WebGL headless browser probe contract', () => { markerHitTestable: true, overviewPresenceDirectHit: true, overviewRecordCorrect: true, - overviewTargetPassiveOnly: true, + overviewTargetControlOnly: true, + passivePresenceVisualOnly: true, presenceComputedVisible: true, presenceAvatarGeometryValid: true, presenceGeometryValid: true, - presenceDelegatedActivation: true, + overviewControlActivation: true, presenceHitTestable: true, presencePointerActivatable: true, presencePortraitElementPresent: true, @@ -939,11 +940,12 @@ describe('rendered WebGL headless browser probe contract', () => { compactOverviewCullingValid: true, overviewPresenceDirectHit: false, overviewRecordCorrect: false, - overviewTargetPassiveOnly: false, + overviewTargetControlOnly: false, + passivePresenceVisualOnly: false, presenceComputedVisible: false, presenceAvatarGeometryValid: false, presenceGeometryValid: false, - presenceDelegatedActivation: false, + overviewControlActivation: false, presenceHitTestable: false, presencePointerActivatable: false, presencePortraitElementPresent: false, @@ -1011,6 +1013,11 @@ describe('rendered WebGL headless browser probe contract', () => { expect(expression).toContain( 'currentToken !== previousToken' ); + expect(expression).toContain('const candidate = controlCandidate;'); + expect(expression).toContain( + "getComputedStyle(passivePresence).pointerEvents === 'none'" + ); + expect(expression).not.toContain('controlCandidate ?? passiveCandidate'); expect(expression).toContain("resource: 'gold'"); expect(expression).toContain("resource: 'food'"); expect(expression).toContain("resource: 'wood'"); diff --git a/tests/useRealmSurfaceNavigation.test.tsx b/tests/useRealmSurfaceNavigation.test.tsx index 012920b..1b442f5 100644 --- a/tests/useRealmSurfaceNavigation.test.tsx +++ b/tests/useRealmSurfaceNavigation.test.tsx @@ -7,6 +7,7 @@ import { type RealmSurfaceRoute } from '../src/components/realm/realmSurfaceNavigation'; import { + REALM_SURFACE_HISTORY_TRAVERSAL_WATCHDOG_MILLISECONDS, useRealmSurfaceNavigation, type RealmSurfaceNavigation } from '../src/components/realm/useRealmSurfaceNavigation'; @@ -76,6 +77,7 @@ beforeEach(() => { afterEach(() => { cleanup(); latestNavigation = undefined; + vi.useRealTimers(); vi.restoreAllMocks(); window.history.replaceState({}, '', '/'); }); @@ -168,6 +170,73 @@ describe('useRealmSurfaceNavigation serialized browser history', () => { expectStack([]); }); + it('releases a silent WebView traversal after a bounded same-session check', () => { + vi.useFakeTimers(); + const back = vi.spyOn(window.history, 'back').mockImplementation(() => {}); + render(); + + act(() => navigation().push(COMMANDS)); + act(() => navigation().push(SETTINGS)); + act(() => navigation().back()); + expect(back).toHaveBeenCalledOnce(); + + act(() => navigation().push(EXPLORE)); + expectStack([COMMANDS, SETTINGS]); + + act(() => { + vi.advanceTimersByTime( + REALM_SURFACE_HISTORY_TRAVERSAL_WATCHDOG_MILLISECONDS + ); + }); + expectStack([COMMANDS, SETTINGS]); + + act(() => navigation().push(EXPLORE)); + expectStack([COMMANDS, SETTINGS, EXPLORE]); + }); + + it('reconciles same-session history when a WebView loses popstate', () => { + vi.useFakeTimers(); + let traversedState: Record | undefined; + vi.spyOn(window.history, 'back').mockImplementation(() => { + if (traversedState) { + window.history.replaceState( + traversedState, + '', + '/realm-navigation-test?miniApp=true#realm' + ); + } + }); + render(); + + act(() => navigation().push(COMMANDS)); + traversedState = currentHistoryState(); + act(() => navigation().push(SETTINGS)); + expectStack([COMMANDS, SETTINGS]); + act(() => navigation().back()); + expectStack([COMMANDS, SETTINGS]); + + act(() => { + vi.advanceTimersByTime( + REALM_SURFACE_HISTORY_TRAVERSAL_WATCHDOG_MILLISECONDS + ); + }); + expectStack([COMMANDS]); + expect(navigation().motion).toBe('backward'); + }); + + it('cancels a pending traversal watchdog when the navigation owner unmounts', () => { + vi.useFakeTimers(); + vi.spyOn(window.history, 'back').mockImplementation(() => {}); + const view = render(); + + act(() => navigation().push(COMMANDS)); + act(() => navigation().back()); + expect(vi.getTimerCount()).toBe(1); + + view.unmount(); + expect(vi.getTimerCount()).toBe(0); + }); + it('fails malformed and wrong-session popstates to root and releases pending traversal', () => { const back = vi.spyOn(window.history, 'back').mockImplementation(() => {}); render(); @@ -201,7 +270,7 @@ describe('useRealmSurfaceNavigation serialized browser history', () => { expect(back).toHaveBeenCalledTimes(2); }); - it('keeps an old pending Back serialized across identity reset until its late state is rejected', () => { + it('clears an old pending Back across identity reset and rejects its late state', () => { const back = vi.spyOn(window.history, 'back').mockImplementation(() => {}); const { rerender } = render(); @@ -219,7 +288,7 @@ describe('useRealmSurfaceNavigation serialized browser history', () => { expectStack([]); act(() => navigation().push(SETTINGS)); - expectStack([]); + expectStack([SETTINGS]); dispatchPopState(oldState); expectStack([]);