Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Observation: MetadataProvider's seed principal collapses to @anon in the window where the bearer is absent, so two principals can share one seed scope #5746

Closed

Description

@os-zhuang

Recorded while implementing #5664 (PR #5744). Static reading, not reproduced in a browser — filed so triage can decide whether it is worth measuring, not as a claimed defect.

The reading

packages/app-shell/src/providers/MetadataProvider.tsx:

function principalScope(): string {
  let token: string | null = null;
  try { token = TokenStorage.get(); } catch { /* SSR / storage unavailable */ }
  return token ? fingerprintToken(token) : ANON_PRINCIPAL;   // '@anon'
}

principalScope() is the half of the seed key that makes a objectui:metadata: entry
unreadable by a different principal — the property pinned by
MetadataProvider.crossPrincipalSeed.test.tsx as "is unreadable by the next principal even
when the purge never ran" (#5198). When TokenStorage.get() answers null that
discrimination collapses: every such session computes the same @anon scope, so one
session's permission-filtered app list is readable by the next.

When the bearer can be absent

createAuthClient.getSession() writes TokenStorage from payload.session.token on every
boot, so for a returning browser the token is normally present. Two paths that reach
principalScope() with it absent:

  1. MetadataProvider reads the seed synchronously at mount, before getSession() has
    resolved. On a browser whose stale token was just purged by
    apps/console/src/lib/auth-preflight.ts (its documented job) there is nothing to read
    and the mount computes @anon.
  2. A deployment or sign-in path whose session is established by cookie alone, which
    auth-preflight.ts's own header calls out: "Cookie-based sign-in (better-auth sets a
    session cookie) does NOT touch this localStorage key."

What is not claimed

The window is short, and it may be unreachable in practice on every path the console
actually takes — that is the part that needs measuring rather than reasoning. Enumerating
what the app writes across a real sign-in (the method #5664 was worked with) would settle
it in one pass.

Partial mitigation already landed on #5744

PR #5744 makes a change of session user drop the previous user's client state wholesale,
including a sessionStorage sweep. That closes the cross-user half of this on any boot
where the session user actually changes. What it does not close is two sessions sharing
@anon inside a single page-load, or a browser whose session-user pointer never got
written.

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    and removed on Aug 23, 2026
  2. added theissue type on Aug 23, 2026
  3. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Triage (first-touch): promoted pm:queue, domain:ui (kept), + security, type Task — measurement card.

    The reading is static but the stake is cross-principal readability of a permission-filtered cache (objectui:metadata: seed under a shared @anon scope), which is exactly the class #5198 pinned against — so "worth measuring" is a yes, and establish-first is the queued scope:

    1. Measure the two named windows with the same enumeration method Cross-user client-state pollution: previous user's workspace shown to a new user, and the guided first-run flow suppressed #5664 used (what the app writes across a real sign-in): (a) mount after auth-preflight.ts purges a stale token, before getSession() resolves; (b) a cookie-only session (better-auth cookie set, TokenStorage never written). The question each time: does a objectui:metadata:@anon:* seed get written in that window, and can a subsequent different-principal session read it before purge?
    2. latent-not-live is a legal terminal: if neither path writes a readable seed in practice, report that with the enumeration attached and close — no speculative hardening.
    3. If reachable: the fix shape is then a real decision (defer seed read until session resolution vs. include the session-user pointer in the scope) — report the measured options on the card rather than picking silently, since fix(auth): scope the active-organization key per user, and drop the previous user's client state on a session-user change #5744's wholesale-drop already covers the cross-user-boot half and the residual is narrow.

    Size/model suggestion: S–M measurement-heavy, opus.


    Generated by Claude Code

  4. claude commented on Aug 23, 2026

    @claude
    Contributor

    Claim — domain:ui execution seat. PM round 28, session session_01EuPCi56cnGyykygi3z9w4m, branch claude/issue-5746-anon-seed-scope-measurement. Dispatching at default tier per the triage grading's own size/model note — this is a measurement card, not a fix card, and it is dispatched as one.

    The establish-first scope is the triage comment's three steps, unchanged. Two points carried into the dispatch as hard constraints so the shape cannot drift:

    • latent-not-live is a legal terminal. If neither named window writes a readable objectui:metadata:@anon:* seed in practice, the deliverable is that finding with the enumeration attached — and the card closes. ⛔ No speculative hardening of a window that was measured shut.
    • If it IS reachable, the fix is a decision, not a default. Defer the seed read until session resolution vs. include the session-user pointer in the scope — the dev reports both measured options on the card and ⛔ does not pick one silently. fix(auth): scope the active-organization key per user, and drop the previous user's client state on a session-user change #5744's wholesale-drop already covers the cross-user-boot half, so the residual is narrow and worth deciding deliberately.

    Sequencing note: PR #5816 (#5777, per-key try in purgeSignedOutClientCaches) is in the merge queue and touches the sign-out sweep of this same objectui:metadata: prefix. Different file, different half — but re-measure on a main that includes it rather than on a base that predates it.


    Generated by Claude Code

  5. added a commit that references this issue on Aug 23, 2026
  6. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Terminal: latent-not-live — both named windows measure SHUT

    Measured on origin/main @ ad404e057. Sequencing note first: PR #5816 (#5777, per-key try in purgeSignedOutClientCaches) had not landed when I started and has still not — it is an open draft, not in the merge queue, base ad404e057. So I measured on a base that does not include it. That is harmless here: #5816 touches the sign-out sweep in packages/auth/src/AuthProvider.tsx, and every reading below is about the mount half (read/write of the seed key), which #5816 does not touch. Nothing in this enumeration depends on it.

    Method

    The whole console mount path is real — the real AuthProvider (so the getSession() → TokenStorage.set → setIsLoading(false) ordering is the shipped one), the real ConnectedShell session gate, the real MetadataProvider. Only two boundaries are doubled, and they are the two a test cannot have: the auth server (an AuthClient double) and the metadata server (the adapter). Nothing writes objectui:metadata:* by hand, so the harness cannot agree with a key format the provider does not actually produce.

    Storage.prototype.{setItem,getItem,removeItem} is instrumented to record, for every objectui:metadata:* operation: the key, its principal segment, TokenStorage.get() at that instant, and whether getSession() had already answered.

    Counter-probe (S0) — and it earned its keep. The first run reported zero operations across every scenario. Not a finding: jsdom hands out sessionStorage as a Proxy, so inside a Storage.prototype method this is the unproxied target and this === sessionStorage is never true. S0 failed, which is what it is for; the instrument was repointed and S0 now records the authenticated path's non-@anon write.

    The enumeration

    ===== LEDGER: S0 authenticated boot (counter-probe) =====
    #1 get    objectui:metadata:app:org_a:163ikz60d0vmvq
            principal=163ikz60d0vmvq bearerAtOp=tok-alice getSessionResolved=true MISS
    #2 set    objectui:metadata:app:org_a:163ikz60d0vmvq
            principal=163ikz60d0vmvq bearerAtOp=tok-alice getSessionResolved=true items=2
    
    ===== LEDGER: S1 stale-token-purged boot, cookie session, getSession delayed 60ms =====
    #1 get    objectui:metadata:app:org_a:0f53rp80tub9eu
            principal=0f53rp80tub9eu bearerAtOp=tok-alice-fresh getSessionResolved=true MISS
    #2 set    objectui:metadata:app:org_a:0f53rp80tub9eu
            principal=0f53rp80tub9eu bearerAtOp=tok-alice-fresh getSessionResolved=true items=2
    S1: total metadata ops=2 writes=1 anon-writes=0 reads=1
    
    ===== LEDGER: S2 cookie-only boot (session response carries no token) =====
    #1 get    objectui:metadata:app:org_a:@anon
            principal=@anon bearerAtOp=null getSessionResolved=true MISS
    #2 set    objectui:metadata:app:org_a:@anon
            principal=@anon bearerAtOp=null getSessionResolved=true items=3
    S2: writes=1 anon-writes=1 keys=["objectui:metadata:app:org_a:@anon"]
    
    ===== LEDGER: S3 user B boots in the same tab (B fetch never lands) =====
    #3 get    objectui:metadata:app:org_a:@anon
            principal=@anon bearerAtOp=null getSessionResolved=true HIT len=54
    S3 VERDICT: B rendered apps="setup,crm,hr-secret" loading=false
                seed-reads=["objectui:metadata:app:org_a:@anon -> HIT len=54"]
                storage-now=[]
    
    ===== LEDGER: S4 auth-disabled guest boot =====
    #1 get    objectui:metadata:app:@none:@anon
            principal=@anon bearerAtOp=null getSessionResolved=false MISS
    #2 set    objectui:metadata:app:@none:@anon
            principal=@anon bearerAtOp=null getSessionResolved=false items=1
    
    ===== LEDGER: S5 preview-mode boot =====
    #1 get    objectui:metadata:app:@none:@anon
            principal=@anon bearerAtOp=null getSessionResolved=false MISS
    #2 set    objectui:metadata:app:@none:@anon
            principal=@anon bearerAtOp=null getSessionResolved=false items=1
    

    Window (a) — mount before getSession() resolves: SHUT, and the premise is false

    S1 runs the real preflightAuth() against a server that rejects the stale bearer (it purges: localStorage['auth-session-token'] → null, asserted), then boots with getSession() deliberately delayed 60 ms. Result: anon-writes=0, and both metadata operations carry bearerAtOp=tok-alice-fresh getSessionResolved=true.

    The card's premise — "MetadataProvider reads the seed synchronously at mount, before getSession() has resolved" — is not true of the console. MetadataProvider does not exist in that window. ConsoleShell.ConnectedShellInner holds the #4042 session gate:

    if (!adapter || isAuthLoading) return <LoadingFallback />;
    

    and isLoading only drops in loadSession's finally, which runs after createAuthClient.getSession() has already executed TokenStorage.set(payload.session.token). There is no ordering in which the provider mounts first. ConnectedShell is the only mount site of <MetadataProvider> in this repo.

    Window (b) — cookie-only session with TokenStorage never written: SHUT, because the premise does not hold

    This one reduces entirely to a server-side fact: does a cookie-only get-session response carry session.token? If it does, createAuthClient.getSession() writes TokenStorage on the first boot and the scope is a real fingerprint.

    I measured it directly against better-auth v1.6.28 (the pinned dependency), memory adapter, bearer() enabled — signed up to obtain a session cookie, then called get-session with only that cookie and no Authorization header:

    sign-up status: 200
    set-cookie present: true
    cookie header sent: better-auth.session_token=<redacted>
    get-session status: 200
    body.user?.id present: true
    body.session keys: createdAt,expiresAt,id,ipAddress,token,updatedAt,userAgent,userId
    >>> body.session.token present: true
    >>> body.session.token typeof: string
    

    Corroborated in library source: @better-auth/core's sessionSchema declares token: z.ZodString — required and non-nullable.

    So the auth-preflight.ts sentence the card quotes — "Cookie-based sign-in ... does NOT touch this localStorage key" — is true of the sign-in action and does not survive the first getSession(), which every boot performs before the metadata tree is allowed to mount.

    What IS live if the premise ever holds (S2 + S3) — the conditional, stated honestly

    S2/S3 model the counterfactual: a session response with no token, so TokenStorage stays empty while an authenticated, permission-filtered session is in hand. There, the disclosure is complete, not marginal:

    So the seed key's principal scope is the only thing standing between latent and live, exactly as the card reasoned. It just never collapses on a real session.

    Correction to an assumption carried in triage and dispatch

    The dispatch states that #5744's wholesale drop "already covers the cross-user-boot half". Measured, it does not cover the seed read. S3's storage-now=[] shows the sweep did run — but it ran after B had already read and rendered A's list. React runs child effects before parent effects, and MetadataProvider (child, newly mounted in the commit where isLoading flips) reads the seed before AuthProvider's SessionUserScope.adopt(user.id) effect fires purgePreviousUserClientState(). What actually covers that boot is the principal-scoped key from #5198 — not the purge. Worth recording so a future change does not retire the key on the strength of the purge.

    Residual: two synthetic-identity boots that DO write @anon (not named on the card)

    S4 (enabled={false} guest) and S5 (previewMode) both write objectui:metadata:app:@none:@anon. Both set isLoading false immediately and never touch TokenStorage, so principalScope() is @anon for the entire session.

    This is not a disclosure, and the reason is a reasoned judgement about the server, not something I measured: ConditionalAuthWrapper selects those two branches only when discovery reports auth as stub/handlerReady:false or the deployment is a marketplace preview — i.e. precisely when the server is not filtering /meta/* per user, so there is no per-principal payload to leak. I did not stand up such a deployment to confirm the server side. apps/console never takes either branch (App.tsx mounts <AuthProvider authUrl={AUTH_URL}> unconditionally), and no in-repo consumer composes ConditionalAuthWrapper with ConnectedShell — the combination is reachable only for a downstream consumer, since both are public exports of @object-ui/app-shell.

    Measured vs. reasoned — the boundary

    • Measured: everything in the ledgers above; the real preflightAuth purge; better-auth's cookie-only get-session body; the child-before-parent effect ordering (via S3's read-then-sweep); the instrument's own liveness (S0).
    • Reasoned, not measured: that the S4/S5 deployments have no per-user permission filtering server-side; that no downstream consumer mounts <MetadataProvider> outside ConnectedShell (I verified only this repo).

    Disposition

    No fix is proposed and no PR is opened — nothing here is a defect to harden against, and hardening a window measured shut is what the card scoped out. Branch claude/issue-5746-anon-seed-scope-measurement (6573e1098) carries the harness at packages/app-shell/src/console/__tests__/anonSeedScope-5746.enumeration.test.tsx as inspectable evidence only — it is deliberately not proposed for merge; if it is wanted on main as a standing pin, that is a separate call. It runs green as committed:

    pnpm exec vitest run packages/app-shell/src/console/__tests__/anonSeedScope-5746.enumeration.test.tsx --maxWorkers=2
     Test Files  1 passed (1)
          Tests  6 passed (6)
    

    node scripts/check-control-bytes.mjs ✅ · node scripts/check-changeset-presence.mjs ✅ (empty-frontmatter changeset, test-only) · eslint --no-inline-config on the harness → 0 errors, 0 warnings. All on 6573e1098.

    Generated by Claude Code


    Generated by Claude Code

  7. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 5746,
      "status": "done",
      "terminal": "latent-not-live",
      "branch": "claude/issue-5746-anon-seed-scope-measurement",
      "pr": null,
      "premise_still_valid": false,
      "summary": "Measurement card, terminal latent-not-live. Both named windows measure SHUT. Window (a) premise is FALSE: MetadataProvider does not mount before getSession() resolves — ConnectedShellInner's #4042 session gate (`if (!adapter || isAuthLoading) return <LoadingFallback/>`) withholds it, and isLoading only drops in loadSession's finally, after createAuthClient.getSession() has already run TokenStorage.set(payload.session.token). With getSession deliberately delayed 60ms on a browser whose stale token the REAL preflightAuth() had just purged: anon-writes=0, every metadata op carried bearerAtOp=tok-alice-fresh. Window (b) premise does not hold: measured against better-auth v1.6.28 (the pinned dep) with a cookie and NO bearer, get-session returns 200 with session.token present as a string (session keys: createdAt,expiresAt,id,ipAddress,token,updatedAt,userAgent,userId); @better-auth/core's sessionSchema declares token: z.ZodString, required. So a cookie-only session writes TokenStorage on the first boot, before the metadata tree is allowed to mount. The auth-preflight sentence the card quotes is true of the sign-in ACTION and does not survive the first getSession(). Landed NO hardening and opened NO PR, per the card's own scope. Two things worth carrying forward: (1) IF TokenStorage were ever empty on an authenticated session the disclosure is COMPLETE — S2 wrote objectui:metadata:app:org_a:@anon and S3 had a different user render 'setup,crm,hr-secret' (including hr-secret) with loading=false; (2) CORRECTION to an assumption in triage/dispatch: #5744's wholesale drop does NOT cover the seed read — S3 shows the sweep ran AFTER B had already read and rendered A's list, because React runs child effects (MetadataProvider's seed read) before parent effects (AuthProvider's SessionUserScope.adopt purge). What covers that boot is #5198's principal-scoped key, not the purge.",
      "tests": "All on committed head 6573e1098 (branch pushed; NO PR opened). Enumeration harness at packages/app-shell/src/console/__tests__/anonSeedScope-5746.enumeration.test.tsx renders the REAL mount path (real AuthProvider + real ConnectedShell session gate + real MetadataProvider); only the auth server and metadata server are doubled; nothing writes objectui:metadata:* by hand. Storage.prototype.{setItem,getItem,removeItem} instrumented to record key, principal segment, TokenStorage.get() at that instant, and whether getSession() had answered. COUNTER-PROBE (S0) earned its keep: the FIRST run reported zero operations in every scenario — jsdom hands out sessionStorage as a Proxy, so `this === sessionStorage` is never true inside a Storage.prototype method; the instrument was repointed at the key prefix and S0 now records the authenticated path's non-@anon write (principal=163ikz60d0vmvq, 14 base-36 chars, items=2). A second instrument defect was caught the same way: S3 installed its never-settling adapter by REASSIGNING fakeAdapter.getClient, which vi.restoreAllMocks() does not undo, so S4's first reading ran against S3's adapter and was void — replaced with a reset flag and re-measured. `pnpm exec vitest run packages/app-shell/src/console/__tests__/anonSeedScope-5746.enumeration.test.tsx --maxWorkers=2` (from repo root, under the shared verify lock) => 'Test Files 1 passed (1) / Tests 6 passed (6)'; enumeration output byte-identical to the pre-commit run. `node scripts/check-control-bytes.mjs` => '✅ check-control-bytes: OK (scanned 4844 tracked text file(s); skipped 85 binary).' `node scripts/check-changeset-presence.mjs` => '✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)' (empty frontmatter — objectui declares test-only that way; there is no skip-changeset label in this repo). `eslint --no-inline-config --format json` on the harness => files linted: 1, errors=0, warnings=0. Exit codes captured before any pipe (redirect-then-capture), and each verdict quoted from the gate's own printed line. Separate probe (run, then deleted, not committed): a real better-auth v1.6.28 instance with memoryAdapter + bearer(), signed up for a cookie, then get-session with cookie only => 'get-session status: 200 / body.session.token present: true / typeof: string'. DECLARED NARROWING: I did not run `pnpm --filter @object-ui/app-shell type-check`, which would have required building the dependency closure under a contended shared lock; the file is test-only, is not proposed for merge, lints clean and runs green. CI would run the full farm if this were ever proposed as a PR.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  8. claude commented on Aug 23, 2026

    @claude
    Contributor

    Terminal: latent-not-live. Both named windows measure SHUT. Closing per the triage ruling, which made this a legal terminal — ⛔ no speculative hardening landed, and no PR was opened.

    domain:ui execution seat, PM round 28, session session_01EuPCi56cnGyykygi3z9w4m.

    The two windows

    (a) "MetadataProvider reads the seed synchronously at mount, before getSession() resolves" — premise is FALSE. ConsoleShell.ConnectedShellInner holds the #4042 session gate (if (!adapter || isAuthLoading) return <LoadingFallback />), and isLoading only drops in loadSession's finally, which runs after createAuthClient.getSession() has already executed TokenStorage.set(payload.session.token). The metadata tree is not allowed to mount before the bearer exists. Measured with the REAL preflightAuth() purging a stale token and getSession deliberately delayed 60 ms: anon-writes = 0, and every metadata storage op carried a real bearer with getSessionResolved=true.

    (b) "a cookie-only session never writes TokenStorage" — does not hold either. Measured against better-auth v1.6.28 (the pinned dep), driven with a session cookie and no bearer: get-session answers 200 with session.token present as a string, and @better-auth/core's sessionSchema declares token: z.ZodString — required, non-nullable. So a cookie-only session writes TokenStorage on its first boot, before the metadata tree may mount. The auth-preflight.ts sentence the card quotes is true of the sign-in action and does not survive the first getSession().

    ⚠️ CORRECTION — this one matters more than the terminal

    Both the triage comment above and the dispatch brief asserted that #5744's wholesale drop covers the cross-user half. It does not cover the seed READ.

    Measured: S3's storage-now=[] shows the sweep ran — but it ran after B had already read and rendered A's list. React runs child effects before parent effects, and MetadataProvider's seed read is a child effect newly mounted in the very commit where isLoading flips, while AuthProvider's SessionUserScope.adopt → purgePreviousUserClientState is the parent effect. The purge is strictly later than the read on that boot.

    So what protects that boot is #5198's principal-scoped key, and nothing else. Recorded explicitly so that a future change does not retire the key on the strength of the purge — the purge is not a substitute for it, and the reasoning that says otherwise is now falsified in a measurement rather than merely doubted.

    The conditional is complete rather than marginal: with TokenStorage empty on an authenticated session, S2 wrote objectui:metadata:app:org_a:@anon, and S3 — a different user booting in the same tab — HIT that key and rendered setup,crm,hr-secret, including hr-secret, with loading=false. The key is the only thing standing between latent and live; it simply never collapses on a real session.

    Residual, outside this card's two windows — filed separately, not decided here

    Two synthetic-identity boots DO write @anon: a guest boot (enabled={false}) and previewMode, both writing objectui:metadata:app:@none:@anon. The dev judged these non-disclosures because ConditionalAuthWrapper selects those branches only when discovery reports an auth stub / handlerReady:false, or the deployment is a marketplace preview — i.e. when the server is not filtering /meta/* per user. ⚠️ That server-side judgement is REASONED, not measured, and was flagged as such rather than presented as a result. apps/console never takes either branch. Filed as a separate finding for triage.

    Instrument notes — the counter-probe earned its keep twice

    Recorded because both failures would have produced a false latent-not-live:

    1. jsdom hands out sessionStorage as a Proxy, so this === sessionStorage is never true inside a Storage.prototype method. The first run therefore reported zero operations in every scenario — which, without the counter-probe, reads exactly like a clean "no writes anywhere" result. Repointed at the key prefix, the probe now records the authenticated path's non-@anon write.
    2. vi.restoreAllMocks() does not undo a reassignment. S3 installed a never-settling adapter by reassigning fakeAdapter.getClient, so S4 silently ran against S3's adapter and reported writes=[]. Replaced with a reset flag and re-measured, after which S4 does write.

    The harness (packages/app-shell/src/console/__tests__/anonSeedScope-5746.enumeration.test.tsx) renders the real console mount path — real AuthProvider, real ConnectedShell session gate, real MetadataProvider; only the two servers are doubled, and nothing writes objectui:metadata:* by hand, so it cannot agree with a key format the provider does not produce. It lives on branch claude/issue-5746-anon-seed-scope-measurement, unmerged. Given the correction above — the safety property rests on ONE mechanism — landing it as a regression pin is worth considering by whoever takes the residual; that is a recommendation, not a decision taken here.

    Sequencing note

    Measured on origin/main @ ad404e057. The dispatch brief said PR #5816 was in the merge queue; at measurement time it was still an open draft, and the dev said so rather than assuming the brief was right. Harmless here — #5816 touches the sign-out sweep, every reading above is the mount half. (#5816 has since merged, 13:41:41Z.)

    Closing as completed: the card asked whether the window is reachable, and it is not.


    Generated by Claude Code

  9. added a commit that references this issue on Aug 24, 2026
  10. added a commit that references this issue on Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:queuesecurity

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions