Skip to content

fix(dashboard): N50 — /setup and /setup/agent never send the API key to localhost:3200 - #368

Open
LamaSu wants to merge 39 commits into
masterfrom
fix/setup-key-origin
Open

LamaSu wants to merge 39 commits into
masterfrom
fix/setup-key-origin

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Gate A, HIGH: N50 (steward #2640; product-steward #2602). Dashboard only. Base: master. Head 74f758b answers coord-watch's DO-NOT-SHIP (#2857); see "Review round 1".

The leak

  • SetupWizardPage.tsx:53 fetched http://localhost:3200/api/capabilities with the signed-in user's Authorization: Bearer <key> on every /setup load, in every build. It then claimed "Gateway connected at localhost:3200".
  • SetupAgentPage defaulted gatewayUrl to http://localhost:3200 for its three key-bearing requests (/api/setup/scan-network, /api/ai/identify-machine, /api/setup/register-device), and its only caller (line 901) never overrode it.

In production, both handed the user's key to whatever process listens on port 3200 of their own machine.

The fix

  • lib/gateway-base.ts holds the configured gateway: same-origin in production, or VITE_PCC_URL, which the auth store already uses to validate the key. It is the only origin that may receive the key.
  • SetupWizardPage: the liveness check now hits the public /api/health with no key, and the page says "Gateway reachable".
  • SetupAgentPage: it defaults to the configured gateway. Only line 80 changes; adk owns the rest of the page (bus #2544).

Review round 1: coord-watch DO-NOT-SHIP (#2857), fixed at 74f758b

Sol found that the setup leak was fixed but the key was still attached regardless of destination. Each finding and its fix:

  1. Two credential destinations. Relative /api callers went to the dashboard origin, and gateway-base callers to VITE_PCC_URL. Fix: there is one gateway origin. fetchWithKey() (lib/gateway-base.ts) is the only code that puts a key on a request. It resolves the final URL (including //host and /..//host), refuses any other origin before making a request, and only then adds the header. It is reached through authorizedFetch() (lib/authorized-fetch.ts). These now use it:
    • lib/api.ts;
    • api/gateway.ts (every react-query hook);
    • the auth store's key check;
    • SetupAgentPage;
    • EarnFromYourWorkPage, which sent its new key to http://localhost:3200 in dev builds and to https://capability.network in production builds, even from staging.
  2. VITE_PCC_URL was not validated. Fix: it must be an absolute https: origin; http: is accepted only on loopback in a dev build. A bad value fails closed: no request carries a key, and the reason is logged once.
  3. The guard caught only literal localhost URLs. Fix:
    • installKeyEgressGuard() runs first in main.tsx. It rejects any fetch that carries a pcc_live_/pcc_test_ key, or the stored key, to another origin, whether in a header, the URL or a string body. The refusal message never includes the query string.
    • __tests__/no-direct-auth-headers.test.ts enforces "key construction only inside the wrapper" as a shrink-only ratchet. It lists 31 legacy modules with their owning lanes, and fix(dashboard): no production mock — pages show live data, say they aren't live, or say they couldn't load (Wave 0, D8) #408 removes 5 of them.
    • The literal-origin guard stays as the narrow check; EarnFromYourWorkPage has left its list.

Also: SetupAgentPage's gatewayUrl parameter is gone (product-steward, #2849).

Tests

🤖 Generated with Claude Code

https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ

LamaSu and others added 2 commits September 24, 2026 13:44
…alhost:3200 (N50)

Gate A, HIGH (steward #2640, product-steward #2602).
- SetupWizardPage.tsx:53 fetched http://localhost:3200/api/capabilities with
  the signed-in user's Authorization header on every /setup load, in every
  build, then claimed "Gateway connected at localhost:3200".
- SetupAgentPage defaulted gatewayUrl to http://localhost:3200 for its three
  key-bearing requests (scan-network, identify-machine, register-device), and
  its only caller never overrode it.
In production both handed the user's key to whatever listens on port 3200 of
their own machine.

Fix:
- lib/gateway-base.ts: the configured gateway. That is same-origin, or
  VITE_PCC_URL, the variable the auth store already uses. It is the only
  origin that gets the key.
- The wizard's liveness check hits the public /api/health, with no key, and
  says "Gateway reachable".
- SetupAgentPage defaults to the configured gateway.

Guard: __tests__/no-hardcoded-gateway-origin.test.ts fails on any
http://localhost or 127.0.0.1 literal in production code. Its shrink-only
KNOWN list holds three literals that are not key leaks: two dev-only bases
and a chain-RPC form default.

Tests:
- setup-key-origin.test.tsx renders both real pages, records every fetch, and
  fails if a request leaves the configured gateway or carries the key off it.
- 4 of the 5 new tests fail against master's pages.
- Dashboard 225/225.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
… (N50 review)

Cross-family review of #368 (sol, #2857) found that the key was attached
without regard to where a request went. This fixes each finding:

1. Two credential destinations. There is now one gateway origin
   (lib/gateway-base.ts). fetchWithKey() is the one place a key goes onto
   a request: it resolves the final URL, refuses any other origin, and only
   then adds the header. lib/api.ts, api/gateway.ts (every react-query
   hook), the auth store's key check, SetupAgentPage and
   EarnFromYourWorkPage use it. EarnFromYourWorkPage sent its new key to
   http://localhost:3200 in dev builds, and in production builds to
   https://capability.network, even when served from staging.
2. VITE_PCC_URL was not validated. It must now be an absolute https:
   origin (http: only on loopback in a dev build). A bad value fails
   closed: no request carries a key, and the reason is logged.
3. The guard was literal-localhost only. A startup egress guard
   (main.tsx) rejects any fetch that carries a PCC key or the stored key,
   in a header, the URL or a string body, to another origin.
   no-direct-auth-headers.test.ts ratchets the remaining hand-built
   headers (31 modules, each with an owning lane) out of the code.

SetupAgentPage no longer takes a gateway argument (product-steward, #2849).

Tests: dashboard 264/264 (+39). tsc and the build are clean. Negative
controls: removing the origin check fails 6 tests, accepting any scheme
fails 6, a pass-through guard fails 3, no guard at startup fails 1, and
restoring master's EarnFromYourWorkPage, lib/api.ts or api/gateway.ts
fails 3, 2 and 2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
LamaSu added a commit that referenced this pull request Sep 24, 2026
…ay client

FeedbackButton built its own Authorization header. It now calls
api.submitFeedback, so its key is attached the same way as every other
request (and, with #368, only for the configured gateway). Once #368
lands, a hand-built header is a ratchet failure. Behaviour is unchanged:
a rejected post still shows "Failed -- retry?".

Tests: FeedbackButton.test.tsx (2), which characterise the refactor
rather than a fix, so they also pass on the old code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
LamaSu added a commit that referenced this pull request Sep 25, 2026
… ratchet list, settle fix) into implementer-echo
LamaSu and others added 10 commits September 28, 2026 20:27
…Page.tsx, SettlementPage.tsx send the key only through authorizedFetch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…estratorPage.tsx, OrchestratorDetailPage.tsx send the key only through authorizedFetch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…SponsorTelemetryPage.tsx, EvidenceExplorerPage.tsx send the key only through authorizedFetch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…artPage.tsx, routes/onboard/chat/index.tsx send the key only through authorizedFetch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…st.ts KNOWN list drops the 12 files migrated to authorizedFetch

The ratchet's "KNOWN only shrinks" test failed after the n50-mig-a
migration because these 12 files no longer build an Authorization
header directly: WalletPage.tsx, TelemetryPage.tsx, SettlementPage.tsx,
ProtocolRunPage.tsx, OrchestratorPage.tsx, OrchestratorDetailPage.tsx,
SystemDashboardPage.tsx, SponsorTelemetryPage.tsx,
EvidenceExplorerPage.tsx, BatchTrackingPage.tsx, StartPage.tsx,
routes/onboard/chat/index.tsx. Removing their entries is exactly what
the test asks for; no other KNOWN entries were touched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…torDashboardPage, AgentPackagePage, OperatorA2APage, ProtocolLibraryPage send the key only through authorizedFetch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…BoardPage, SensorDashboardPage, NegotiationSessionPage send the key only through authorizedFetch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…Form, DiscoverabilityPanel, DisputeModal send the key only through authorizedFetch

Also drops these 4 files plus the pages/* migrated in the two prior
commits from the no-direct-auth-headers ratchet's KNOWN allowlist: the
test asserts KNOWN only shrinks, and after this migration none of the
13 files assigned to this lane build an Authorization header by hand
any more.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
astra's round-2 review of #368 (DO-NOT-SHIP on 74f758b) found that legacy
callers could still read the key, while a request scanner that could be
bypassed served as the boundary. The boundary is now structural:

- stores/auth-store.ts keeps the key out of zustand state, so reading it
  from the store is a type error, and getAuthHeaders() is gone. The key's one
  accessor, readApiKeyForAuthorizedFetch(), is read only by
  lib/authorized-fetch.ts.
- fetchWithKey() (lib/gateway-base.ts) is the only code that puts a key on a
  request. It resolves the target against the one validated gateway origin,
  refuses any other origin, and refuses redirects (redirect: "error").
- The page-origin fallback is validated like VITE_PCC_URL: https, or http
  only on a loopback host. Anything else fails closed.
- The egress guard is defence in depth. It reads URLSearchParams, FormData,
  Blob, binary and Request bodies, percent-decodes them, checks the stored
  key's base64 forms, and refuses any body it can't read: a stream, or an
  object it doesn't recognise, such as one from another realm.
- The special callers:
  - Analytics fetches only literal /api/analytics paths, enforced by type.
  - Orchestrator chat accepts only a gateway-relative api_base and encodes
    session ids.
  - CdpFundedKeyOnramp, agent-client, AgentChatPage and the passkey flow
    send through authorizedFetch.
- The ratchet (no-direct-auth-headers.test.ts) has no file exemptions:
  - getAuthHeaders doesn't exist;
  - the accessor is referenced only in authorized-fetch;
  - the storage slot is touched only in the store, whether by name or by
    sweeping localStorage;
  - Authorization and Bearer are built only in gateway-base, apart from
    exact reviewed display lines, each with the number of times it occurs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…words (N50)

lib/telemetry.ts turns on PostHog session recording and asked for request
and response headers and bodies through session_recording.
networkPayloadCapture. The client SDK never reads that option; it is a
remote project setting. So whether PostHog recorded headers and bodies was
up to the PostHog project's settings. Requests carry the API key, and the
response from /api/contributors/quickstart carries a newly issued key and,
from the demo wallet adapter, a recovery phrase.

The client now sets recordHeaders and recordBody to false. The project
settings can't override that: in posthog-js 1.363.1 the recorder enables
header capture only if the client's recordHeaders isn't false and the
remote config turns it on, and body capture the same way.

PostHog also records DOM text by default; it masks only inputs.
EarnFromYourWorkPage rendered the new key and the twelve recovery words as
text. Both now sit inside .ph-no-capture, PostHog's default blockClass,
which session recording and autocapture skip. Sentry Replay 8.55 masks all
text and records no network details by default, and a test pins that
initTelemetry() loosens neither.

Found while attacking the round-2 fix; astra's pack did not include
telemetry.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Sep 29, 2026
…rod-mock

This brings #368's round-3 boundary into #408: the key held outside the
store, authorizedFetch as its one reader, and the 25 migrated call sites.

Conflicts resolved:
- SponsorTelemetryPage, SystemDashboardPage, TelemetryPage and WalletPage:
  kept #408's rewrites. They read through apiGet (lib/api.ts, which sends
  with authorizedFetch) and supersede #368's mechanical migration of the
  same fetches.
- __tests__/no-direct-auth-headers.test.ts: took #368's round-3 ratchet,
  which has no exemption list, so #408's edits to the old list no longer
  apply.

tsc is clean and the dashboard passes 477/477.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Sep 29, 2026
…t-held-actions

This brings #368's round-3 boundary into #413: the key held outside the
store, authorizedFetch as its one reader, and the migrated call sites.

Conflicts resolved:
- agent/agent-client.ts and pages/AgentChatPage.tsx: kept #413's deletion.
  The held-actions chat replaces them, so #368's migration of their fetches
  no longer applies.
- routing.test.tsx and agent-chat-held-actions.test.tsx set the key with
  adoptApiKey(), because the store's state no longer holds one.

tsc is clean and the dashboard passes 320/320.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Sep 29, 2026
…ssion recording (N50)

A credential minted by a confirmed held action is shown once, in the
reply's revealed-secret panel. The panel rendered the credential as DOM
text, and PostHog session recording captures DOM text: it masks only
inputs. The panel now carries ph-no-capture, PostHog's default block class,
which session recording and autocapture both skip. #368 did the same for
the Earn page's new key and recovery words.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Sep 29, 2026
…hat-held-actions

This brings #354's round-3 fixes (astra pack 19) into #413. The spatial and
agent workspaces now own only their own address, and every sign-in and
sign-out empties the query cache. It also brings #352's round-3 honesty
fixes and #313, which #354 now stacks on.

Conflict resolved: #354's new user-switch test in routing.test.tsx set the
key with useAuthStore.setState({ apiKey }). #368's store, which #413
carries, keeps the key out of state, so the test uses adoptApiKey(). That
still flips isAuthenticated, which is what App.tsx's cache-clearing
subscription watches.

tsc is clean and the dashboard passes 387/387.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu and others added 6 commits September 29, 2026 19:21
This is the review loop's verify-before-fix step for astra's round-3
verdict on #368 (A03c, DO-NOT-SHIP at cfe3ce9). Each test fails at
cfe3ce9, which reproduces its finding:
- F1-A: an export hands out the key. readApiKeyForAuthorizedFetch() returns
  the signed-in key to any module, including one that reaches it by
  computed access.
- F1-B: sendBeacon is outside the egress guard. A key-carrying beacon to
  another origin goes out.
- F2: an operator-bound passkey registration without the authorized fetch
  does not fail closed. It makes an anonymous challenge request.

Also reproduced by trace at cfe3ce9: a production module holding both of
astra's bypass snippets type-checks, and no-direct-auth-headers passes 7/7.
The snippets are computed access to the accessor, and
localStorage.getItem("pcc-" + "api-key"), each sent with sendBeacon.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ithout the authorized fetch

astra A03c F2 (MEDIUM): runPasskeyRegistration took authorizedFetchFn as
optional. With an operatorId and no authorized fetch, the challenge went out
through the plain fetch, without the key, so the passkey would register
unbound to the operator. It now throws before any request.

Reproduced at cfe3ce9 by 3b6d07d ("an operator binding without the
authorized fetch fails closed, before any request"), which now passes. The
401 test binds an operator, so it now supplies the authorized fetch and checks
the plain fetch is never called.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s in as a string literal

The two Node scripts AgentLinkPage (/go) shows for the user to copy or
download were template literals in the page. They build the Authorization
header the user's agent sends, so the key-boundary ratchet had to exempt
those lines (DISPLAY_ONLY), and astra A03c F1 found that exemption
context-blind: the same line moved into running code would still pass.

- The scripts are now text assets (pages/agent-link/*.js.txt, imported with
  ?raw), byte-identical to what the page showed before for a plain ?q= and
  for none. The dashboard shows them and never runs them, and no dashboard
  module holds the header line, so DISPLAY_ONLY is empty. The next commit
  removes it from the ratchet.
- Found while moving them: ?q= was pasted into the script's last line as
  chat("${capability}"). A crafted link such as
  /go?q=x"); require("child_process").execSync("..."); ("
  made the script the user downloads and runs with node run code of the
  link's choosing. Reproduced by agent-link-quickstart.test.tsx against the
  old page (chat got "x", and the injected statement ran). ?q= now goes in as
  a JSON string literal, through a replacer function so $& and $1 in ?q=
  stay literal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… and the ratchet has no exemptions

astra A03c F1 (HIGH): at cfe3ce9 another production module could read the
signed-in key and send it off-origin without failing the ratchet or passing
the fetch guard. Reproduced by 3b6d07d (key-boundary-r4.test.ts), and by
probe modules holding astra's two snippets: both type-checked, and both passed
the old ratchet.

- One holder, no reader. lib/authorized-fetch.ts now owns the key: a
  module-private variable, and its localStorage slot. It exports
  setStoredApiKey, hasStoredApiKey, authorizedFetch and
  installGatewayKeyGuard, and none returns the key. The store's
  readApiKeyForAuthorizedFetch export is gone, and the store only knows
  whether a key is held.
- sendBeacon is under the egress guard. installKeyEgressGuard now wraps
  navigator.sendBeacon too, and inspects it synchronously: a string,
  URLSearchParams, FormData of strings, or binary body, and the URL. A
  key-carrying beacon to another origin is refused (returns false). A body it
  can't read synchronously (a Blob or a file) is refused, except to origins
  registered for such beacons: main.tsx registers the PostHog host, whose SDK
  sends Blob beacons on unload. A readable body is inspected whatever the
  origin.
- The ratchet (__tests__/no-direct-auth-headers.test.ts) is rewritten as
  rules over every production module, with no exemption for any page or
  line. Only the key's owner and the boundary do what the rules forbid
  elsewhere:
  - the slot name appears only in the owner, and other storage calls name
    their slot with one plain string literal;
  - there are no computed globals, eval or new Function;
  - the owner and the store are never imported whole or by a computed path;
  - only gateway-base builds Authorization or Bearer, or uses sendBeacon,
    XMLHttpRequest or WebSocket.
  The owner's and the store's export lists are pinned at run time.
  Self-tests run astra's two snippets, and the quickstart header line moved
  into code, through the rules, and all three are caught.

Negative controls on the real tree:
- the snippets as probe modules still type-check and now fail the ratchet;
- re-adding an exported reader fails two tests;
- unwrapping sendBeacon fails the beacon tests.

Still open, and parked with the operator: the key is persisted in
JavaScript-readable localStorage, so hostile script on this origin can read
it, or can replace a built-in the key passes through. Only an HttpOnly gateway
session takes the key out of JavaScript's reach. The ratchet is a lint for our
own modules, not a sandbox, and its header says so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings in master's merges since cfe3ce9's base, including #353's job-execution
read model and its onIdentityChange (review r3 of #353): the query cache is
cleared when the signed-in identity changes.

Two conflicts, both from N50 taking the key out of the auth store:
- api/gateway.ts: keeps this PR's authorizedFetch, adds master's
  JobExecutionDTO import, and drops getAuthHeaders, which N50 removed.
  Master's getJobExecution goes through fetchAPI, which already sends with
  authorizedFetch.
- stores/auth-store.ts: master's onIdentityChange compared s.apiKey, which
  this store no longer holds. The store now has keyEpoch, a counter bumped by
  every adoptApiKey() and logout(). onIdentityChange compares keyEpoch,
  address and sessionToken. adoptApiKey counts every call as an identity
  change, even with the same key: telling "same key" from "another key"
  would make it an equality test on the stored key. logout() is now one
  set(), so a sign-out is reported once, as master's test expects.

Tests that follow:
- stores/__tests__/auth-store-identity.test.ts (master's) now signs in with
  adoptApiKey instead of writing apiKey into the store, and checks that no
  key string appears in the state.
- __tests__/no-direct-auth-headers.test.ts pins the store's exports to
  adoptApiKey, onIdentityChange and useAuthStore.

Checked: tsc clean; dashboard 439/439 (24 files).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…iterals

Four N50 test files held fake keys as pcc_test_ literals:
- no-direct-auth-headers;
- gateway-base;
- key-boundary-r4;
- setup-key-origin.
The secret scanners flag that shape: the review-pack scan, and the gate the
shell lane runs before every push. That would block #408's push, which merges
this branch. The tests now build the same values at run time, for example
["pcc", "test", "r4boundary0123456789abcdef"].join("_"). Behaviour is
unchanged.

Checked: tsc clean; dashboard 439/439.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Oct 3, 2026
#408 lands after #352 and #368. This brings #368's round-4 key boundary:
- the key owner exports no reader;
- the sendBeacon guard;
- the exemption-free lint;
- the keyEpoch onIdentityChange.
#368's merge of master 753feb4 comes with it, including #353's job-execution
page and #393's AmountDisplay.

One conflict, in use-pcc-data.ts. The imports keep this PR's
authorizedFetch, which useKernel's N51 fix uses, and add master's job-execution
imports. useJobs keeps the rowsOrThrow validation.

Adapted to what the merge brings:
- lib/demo-mode.ts names its sessionStorage slot with one literal.
  #368's round-4 lint allows no other form: a const name is a computed name.
- no-production-mock's KNOWN_OFFENDERS drops JobDetailPage. #353 made it
  clean, and the ratchet requires the list to shrink. This is the change
  product-steward's #3985 asked for once #353 landed.
- Comma-tolerant money asserts in the DePIN, SWF and Wallet honesty tests:
  #393 now groups thousands ("$12,450.00"). RC2 #4101 and design #4451
  found these: a positive assert failed, and a negative one passed without
  checking anything.

Checked: tsc clean; dashboard 688/688 (49 files).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Oct 3, 2026
…mock

Brings #352's refund_pending (escrow #462). #352's merge of master
753feb4 was already here through #368. No conflicts.

Checked: tsc clean; dashboard 688/688 (49 files).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Oct 3, 2026
Brings #368's test-only change: fake API keys are built at run time, not
held as key-shaped literals, so the secret scanners stay quiet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Oct 3, 2026
…-actions

#413 carried #368's round-3 auth (cfe3ce9). This brings round 4:
- no export returns the key;
- the sendBeacon guard;
- the exemption-free lint;
- the keyEpoch onIdentityChange;
- master 753feb4, through #368's own merge.

Conflicts:
- App.tsx: master's onIdentityChange replaces the boolean-keyed cache
  clear.
- use-pcc-data.ts: useJobs keeps rowsOrThrow.

An intermediate step: routing.test.tsx still signs in with
useAuthStore.setState({ apiKey }), which #368's store no longer has (RC2
#4101 item 1), so 12 of its tests fail here. The next merge (#354 @d8ff4eb2)
brings their current versions, which the next commit adapts to #368's key
API.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Oct 3, 2026
…d-actions

Brings #354's rounds 4 and 5 of account isolation (astra 19c and 19d), with
#352 @2311bf36:
- every account-scoped store resets, and its actions are bound to the
  account epoch;
- the wallet and SIWE session end before the next account's shell mounts,
  failing closed;
- the SSE hook cancels its reconnect.

This is the first branch where #354's account boundary meets #368's key
boundary (RC2 #4101 item 1). Conflicts:
- stores/auth-store.ts: #354's onAccountChange compared s.apiKey, which
  #368's store doesn't hold. It now follows keyEpoch, which every
  adoptApiKey() and logout() bumps. #368 removed getAuthHeaders, and it
  stays out.
- App.tsx: takes #354's account boundary whole. It sits on #368's keyEpoch
  through onAccountChange and onIdentityChange.

Tests follow #368's key API:
- account-isolation-r5 signs A in with adoptApiKey("pcc_test_key"), not
  useAuthStore.setState({ apiKey }). routing.test.tsx already used
  adoptApiKey here.
- #368's ratchet pins the store's exports to adoptApiKey, onAccountChange,
  onIdentityChange and useAuthStore.

Checked: tsc clean; dashboard 591/591 (33 files). That includes #354's
isolation tests and #368's key-boundary ratchet over #354's new code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu and others added 9 commits October 2, 2026 19:33
…entity signal and the round-4 probe past the lint (fail at 564a22b)

- N1: setStoredApiKey() called directly changes the key without an identity
  change; a key cleared or set that way leaves isAuthenticated stale.
- F1: astra's probe (the global aliased, Reflect.get by a concatenated name,
  the slot by a concatenated name, sent by an Image) and fetch replaced to
  watch authorizedFetch: no rule catches any line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…int reads code, not lines (astra A03d N1, F1)

- N1 (HIGH): setStoredApiKey() reports every change to onStoredKeyChange's
  listeners, without the key. The auth store's one listener moves keyEpoch,
  follows isAuthenticated, and on sign-out clears the wallet fields in the
  same set(), so a module that sets the key directly can no longer replace
  it behind onIdentityChange. adoptApiKey and logout go through it.
- F1 (HIGH): the lint adds syntax rules read from the parsed file, so
  strings, comments and JSX text are never taken for code and a string built
  from literals is judged by what it spells. They catch every line of astra's
  round-4 probe (the global aliased; Reflect.get; "local" + "Storage";
  "pcc-" + "api-key"; new Image()) and fetch replaced to watch
  authorizedFetch, plus window handles, Function and .constructor(), string
  timers, import.meta.glob, and prototype or navigator/document writes.
  Four casts of window became named reads (HandTracker, usePasskey,
  passkey-registration). The header now says what the lint is: a check on
  our own modules, not a sandbox; the JS-readable key stays the operator's
  HttpOnly-session decision (#4459).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ported once (pins the store's key listener)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tation-API writes past the lint (fail at dc70242)

- N1: a listener registered before the auth store throws; setStoredApiKey
  changes the key but keyEpoch stays.
- F1: Object.defineProperty / defineProperties / assign / setPrototypeOf on
  Headers.prototype, Request.prototype or navigator: no rule catches them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e, and the lint sees mutation-API writes (astra A03e N1, F1)

- N1 (HIGH): setStoredApiKey tells every listener every change, each in its
  own try; the first error is rethrown once all have heard, so the auth
  store's identity change can't be skipped by an observer that failed
  first. A listener that changes the key again doesn't recurse: its change
  goes to everyone after the current round, up to 32 rounds, then an error.
- F1 (HIGH): global-write also catches Object.defineProperty /
  defineProperties / assign / setPrototypeOf, and __defineGetter__ /
  __defineSetter__, when they change a global (window, navigator, document,
  anything reached from one), a built-in a request or the key passes
  through (Headers, Request, Response, Storage, …) or any prototype.
  Object.assign({}, …) and defineProperty on an app object pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…essor definers

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…f Object, helper libraries, and any call handed a built-in's prototype (fail at 7476c23)

Self-found while writing pack A03f: the round-5 rule matched Object.<api>
only, so O.defineProperty(Headers.prototype, …) with O = Object,
_.assign, $.extend, merge(…) and patch(Headers.prototype, …) all passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ceiver, and any call handed a built-in's prototype (self-found, A03e F1's family)

- defineProperty, defineProperties, setPrototypeOf, assign, extend, merge,
  mixin and defaults are judged by the object they change, whatever they
  are called on (Object, an alias of it, a helper library).
- A call handed a built-in's prototype (Headers.prototype, …) is caught
  whatever it does with it.
- location.assign, merging app state and Array.prototype.slice.call pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… target that is no prototype (navigator)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Oct 3, 2026
#368's round-6 head: key-change listeners isolated, and the key lint's
syntax rules. #408's pages pass them as they are: dashboard 691/691, tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu and others added 12 commits October 2, 2026 23:14
…cap and a protected target through a local alias (fail at 3c2aede)

- N1: with the store registered first and a listener alternating the key,
  the cap clears a change already committed to the key and storage: the
  store stays signed in while no key is held, and keyEpoch is one short of
  the committed changes.
- F1: const p = Headers.prototype (or const nav = navigator), then
  Object.defineProperty(p, …): no rule catches it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lands, and the lint follows local aliases of protected targets (astra A03f N1, F1)

- N1 (HIGH): a change made in the last of 32 delivery rounds is refused
  before it touches the key or storage, so the key never holds a value its
  listeners weren't told of: the store and the key agree, one epoch per
  committed change. No committed change is dropped any more.
- F1 (HIGH): global-write follows local aliases of protected targets, to a
  fixed point: const p = Headers.prototype, const nav = navigator,
  const { prototype } = Headers, an alias of an alias, a plain reassignment,
  through parentheses, casts, ?:, || and ??. Such a name counts as protected
  as a mutation target and as an assignment root, and a prototype alias also
  when handed to any call. rootOf sees through casts. Aliasing document to
  read from it, and reassigning app state, pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… with an out-of-order chain and a renamed binding

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… changes it inside (fails at 695a57d)

Self-found while writing pack A03g, in A03f F1's family: patch(navigator),
an alias of navigator handed to a call, and wrap(Headers) all passed, since
only a built-in's prototype counted when handed to a call.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…y or through a local name holding it (self-found, A03f F1's family)

navigator, document and the built-ins (Headers, Request, …) can be changed
inside whatever they're handed to, so handing one to a call is caught, as a
built-in's prototype already was. Only names holding the object itself
count, not values read from one (const w = window.innerWidth stays free).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…bject property, class field, this.x, a return, an array, getPrototypeOf, __proto__ (6 of 7 fail at 00ca50c)

Self-found while writing pack A03g, in A03f F1's family. Following
aliases name by name keeps missing a form; astra's alternative is to reject
the aliasing itself. The window.navigator alias is already caught and
stays as a guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ead of chasing aliases (astra A03f F1, the whole family)

Following aliases name by name kept missing a form (an object property, a
class field, this.x, a return, an array). astra's alternative closes them
all: navigator, document, the built-ins, any of those reached from window,
and any built-in's prototype may appear only where they are read: a member
read, a call or construction, typeof, the right of instanceof or in. Held
in a variable, passed, returned, stored, spread or destructured, they are
caught, so no name can hold one to change it later. getPrototypeOf,
__proto__, .constructor and the legacy accessor definers are caught too.

The alias resolution and the mutation-API branch it needed are gone;
global-write keeps its assignment checks. No production module breaks it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…gnment check judges by root only

A { navigator } shorthand passes the object itself, and is caught. The
separate X.prototype.y = regex is gone: a built-in's prototype is already
caught by its built-in root, and app classes' own prototypes carry no key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…element access or by a read that returns it, and the constructor overbreadth (fail at c57a067)

At c57a067:
- F1 (HIGH): both of astra's bypasses pass the lint: const p =
  Headers["prototype"] and const nav = navigator.valueOf(), each then
  handed to Object.defineProperty.
- F1's family, self-found: nine more pass. They are the same guarded names
  by element access (["__proto__"], ["getPrototypeOf"], ["defaultView"],
  ["constructor"] run as code), other reads that return a protected object
  (Headers.valueOf(), Array.prototype.reverse()), and the document reached
  by a walk (documentElement.parentNode, getRootNode(), ownerDocument). A
  tenth (Storage["proto" + "type"]) is already caught, and stays as a pin.
- F2 (MEDIUM): ordinary.constructor.name, a constructor comparison and
  typeof x.constructor are all rejected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…hand back a protected object, and checks every change's target (astra A03g)

F1 (HIGH): the read-only rule assumed a read can't create an alias. Two
reads did: Headers["prototype"] (element access wasn't recognized) and
navigator.valueOf() (it returns its receiver). Three layers now:
- A literal element read counts as the member it names, in every syntax
  rule. So x["prototype"], x["__proto__"], x["getPrototypeOf"],
  x["defaultView"] and x["constructor"](...) are judged like their dotted
  forms.
- No read hands back a protected object without naming it: valueOf on one,
  a method called directly on a built-in's prototype
  (Array.prototype.reverse() returns it), or a walk to the document
  (parentNode, getRootNode, ownerDocument; production uses none).
- The mutation-target check astra asked to restore: a mutation API's
  target, a written or deleted property's object, is followed through the
  module's own names, member reads and calls that return their receiver.
  If it may be navigator, document, a built-in or the global, it fails. A
  value a call builds (createElement) or a name the module never binds is
  not followed, so ordinary DOM and object code passes.

F2 (MEDIUM): x.constructor read for its name, compared or asked its type
no longer fails.

Production passes every rule unchanged. New cases pin each layer on its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…on-target check now covers, and pin window.<object> on its own

The assignment root check (WRITE_ROOTS, BUILTINS via rootOf) flagged a
property written on navigator, document, a global or a built-in. The
mutation-target check covers the same roots, and follows aliases too, so the
root check no longer decided any case; its control stopped failing. It is
removed. fetch replaced by name stays its own check.

A one-layer case pins the read-only rule's window.<object> branch:
const nav = window.navigator; alone, with no later write.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he held rule already decides

fetch = spy puts fetch in a write position, which the read-only rule flags
as a held built-in; the separate name check never decided a case, and its
control didn't fail. Removed, so every remaining check has a control that
fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant