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.

Sign-out leaves the cached metadata (and the active-org id) in storage — the next user in the same tab is seeded with the previous user's permission-filtered app list #5198

Closed

Description

@os-steve

Found while implementing #4486 (org-scoping the MetadataProvider seed cache). Filed separately and unassigned: it is a different boundary from that card, and #4486's fix does not close it.

What

AuthProvider.signOut clears only the bearer token:

async signOut() {
  const { error } = await betterAuth.signOut();
  TokenStorage.clear();
  ...
}

Nothing clears:

  • the objectui:metadata:* entries in sessionStorage (written by MetadataProvider), or
  • ActiveOrganizationStorage (auth-active-organization-id in localStorage).

No sign-out call site reloads the page either — AppSidebar, AppHeader, UserMenu and RemediationOverlay all just call signOut(), so the SPA keeps running and sessionStorage (which is per-tab, not per-session) survives into whatever happens next in that tab.

Why it matters

The cached app list is the per-user list: the server filters GET /api/v1/meta/:type per session (filterAppForUser, objectstack#8013), so what sits in storage is "the apps THIS user was allowed to see". If a second person signs in in the same tab — a shared or kiosk browser, a handover, a support session — MetadataProvider seeds them from that entry and they briefly read the previous user's filtered app list.

This is a cross-principal disclosure, unlike #4486, whose reader is the same signed-in user reading a workspace they are a member of. #4486 keys the seed by active organization, which is the right fix for that card and deliberately does not address this one: two users in the same org produce the same key, so the seed still hits.

The exposure is app DEFINITION metadata (name, label, icon, branding, navigation tree, required-permission names) — not record rows and not credentials. For an org-authored app the navigation tree and permission names do describe that workspace's private model, and the per-user filtering means the list itself carries "what that person had access to".

Fix shape (for whoever takes it)

Sign-out should drop the client-side caches it leaves behind — the objectui:metadata:* sessionStorage entries and ActiveOrganizationStorage — rather than relying on the tab being closed. Worth deciding at the same time whether the seed should additionally be scoped to the user/session identity, which would make the stale entry unreadable by construction instead of depending on a cleanup step being remembered on every future sign-out path. MarketplacePackagePage already has a working precedent for the purge loop (it removes every key with the objectui:metadata: prefix).

Refs #4486 (the org-keying half, in flight).

Activity

  1. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Triage: promoted to pm:queue, type Bug — cross-principal disclosure on shared-tab sign-in, distinct boundary from #4486 by the card's own measurement. Scope: the sign-out purge is the commissioned fix — clear the objectui:metadata:* sessionStorage entries and ActiveOrganizationStorage on signOut() (the MarketplacePackagePage prefix-purge loop is the in-repo precedent). Recommended and in scope if it stays client-local: additionally key the seed by user/session identity so a stale entry is unreadable by construction rather than dependent on every future sign-out path remembering the cleanup — that is the error-resistant half; if it turns out to need anything beyond the client cache key, stop and report instead of widening.

    Serial constraint (for the claiming seat, not a block): #4486 is in flight on the same MetadataProvider seed-cache code — its org-keying changes the exact key shape this card would extend. Do not run the two concurrently; claim after #4486's PR lands or coordinate the key shape explicitly in the claim comment.


    Generated by Claude Code

  2. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Claim: PM loop round 6
    Session: session_01RV6yuVCxymHYE16PL9vQkE
    Branch: claude/issue-5198-signout-cache-purge
    Worktree: objectui-issue-5198
    Domain: repo:objectui (execution seat)
    File surface: packages/auth/src/AuthProvider.tsx (the signOut callback), packages/app-shell/src/providers/MetadataProvider.tsx (the seed-cache key + read), plus tests in both packages. ⛔ OUT: packages/components/src/renderers/form/** (#4259), packages/plugin-view / plugin-list (#5177, PR5231 open), packages/plugin-grid + packages/core (#5217), packages/cli (#5127, dispatched this round), packages/types (#5186 just merged — rebase, do not re-touch).
    Container & model: M, mode:subagent, model: opus — the purge is mechanical, but the identity-keying half is a judgement call with a hard stop attached.
    Clause-②: no — this removes stale client-side cache; it changes no contract accept/reject behaviour and widens no public surface.
    Serial constraints cleared: the #4486 constraint triage attached to this card is now LIFTED — #4486 closed 13:45Z, its PR5199 (fix(app-shell): scope the metadata seed cache to the active organization) is MERGED. Nothing else open touches MetadataProvider.tsx or AuthProvider.tsx. Re-verify at your claim.

    ⛔ Premise re-check — the card's code quote does NOT match the file

    Measured on origin/main just now. The defect holds; the card's snippet does not. The card quotes:

    async signOut() {
      const { error } = await betterAuth.signOut();
      TokenStorage.clear();
      ...
    }
    

    The actual signOut in packages/auth/src/AuthProvider.tsx (~line 272) is:

    const signOut = useCallback(async () => {
      setIsLoading(true);
      try {
        await client.signOut();
        setUser(null);
        setSession(null);
        setError(null);
      } catch (err) { ... } finally { setIsLoading(false); }
    }, [client]);
    

    There is no TokenStorage.clear() line in it — whatever token clearing happens is inside client.signOut(), a layer down. Do not go hunting for the quoted line and do not conclude the card is stale when you fail to find it. What the card actually asserts is still true and I re-verified both halves:

    • ActiveOrganizationStorage.clear() appears in AuthProvider.tsx only in deleteOrganization and leaveOrganization. Not in signOut.
    • Nothing clears the objectui:metadata:* sessionStorage entries on sign-out.
    • The prefix-purge precedent is real and still there: MarketplacePackagePage.tsx (~line 288) loops keys with key.startsWith('objectui:metadata:').

    The interaction the card predates — read this before choosing the key

    #4486 landed after this card was filed and it changed the key shape underneath it. On current main, MetadataProvider.tsx uses objectui:metadata:{type}:{orgId} where the scope comes from ActiveOrganizationStorage.get() || NO_ORG_SCOPE, plus a migration that drops the pre-#4486 unscoped entry.

    That makes ordering inside your fix load-bearing, and it is easy to get subtly wrong:

    • If you clear ActiveOrganizationStorage before purging the metadata entries, the scope you would compute for a targeted purge is already gone — a scope-derived purge silently purges the wrong key (or NO_ORG_SCOPE) and leaves the real entries behind. A prefix purge does not have this failure mode. Prefer the prefix loop, and if you use anything scope-derived, say why it is safe.
    • Org-keying does not close this card, and the card is explicit about why: two users in the same org compute the same key, so the seed still hits across a sign-out. Do not let the presence of :orgId in the key persuade you the problem is already solved.

    Scope — the commissioned fix, and the recommended half with a hard stop

    Triage ruled the scope (2026-08-18, on this card):

    > the sign-out purge is the commissioned fix — clear the objectui:metadata:* sessionStorage entries and ActiveOrganizationStorage on signOut() (the MarketplacePackagePage prefix-purge loop is the in-repo precedent). Recommended and in scope if it stays client-local: additionally key the seed by user/session identity so a stale entry is unreadable by construction rather than dependent on every future sign-out path remembering the cleanup — that is the error-resistant half; if it turns out to need anything beyond the client cache key, stop and report instead of widening.

    Take the identity-keying half if it stays client-local. The moment it needs a new field from the server, a new API call, or a change to what the session endpoint returns — stop and report. That is a contract question and it is mine to escalate, not yours to decide.

    Tests

    Gates

    Derive from your diff (scripts/pm/dispatch-gates.mjs is objectstack-only — in this repo derive from .github/workflows and package.json). Expect Lint, Type Check, the shards, changeset gates, Control Byte Scan. Changeset required (@object-ui/auth and @object-ui/app-shell); patch or minor, ⛔ never major.

    ⛔ Do not background a long check and end your turn — several devs hit that trap today. Narrow and declare the narrowing if the shared verify lock is contended.

    Deliver

    Draft PR against main, first body line Fixes #5198. Push the branch early. Return the JSON report and post it as a comment whose first line is the literal <!-- os-dev-report --> marker.


    Generated by Claude Code

  3. added a commit that references this issue on Aug 18, 2026
    92558f1
  4. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator
    {
      "issue": 5198,
      "status": "done",
      "branch": "claude/issue-5198-signout-cache-purge",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5242",
      "premise_still_valid": true,
      "summary": "Both halves landed. AuthProvider.signOut now purges every `objectui:metadata:` sessionStorage key by prefix and then clears ActiveOrganizationStorage, in a finally so it also runs when the sign-out request fails (the real client clears TokenStorage before rethrowing); the prefix sweep runs before the org clear so nothing scope-derived added later computes the no-org scope against an already-cleared org id. MetadataProvider now keys each seed entry by a 64-bit non-reversible fingerprint of the session token (`objectui:metadata:` + type + org + principal), read from TokenStorage for the same reason activeOrgScope reads storage rather than context — identity resolves asynchronously after mount, so a context-derived key would miss its own entry on every boot. This stayed client-local: no new session field, no extra request, nothing beyond the cache key, so there was nothing to escalate. The pre-#4486 unscoped-entry migration generalized to dropForeignPrincipalEntries (any entry whose principal is not the one reading now is deleted on mount; the current principal's other-org entries survive). Premise re-check: the defect held on origin/main exactly as dispatched — ActiveOrganizationStorage.clear() appeared only in deleteOrganization/leaveOrganization, nothing cleared the metadata entries. One precision on the card's quote: it is NOT stale, it just cites a different file — `async signOut() { const { error } = await betterAuth.signOut(); TokenStorage.clear(); ... }` exists verbatim in packages/auth/src/createAuthClient.ts around line 449, one layer below AuthProvider. Bounded in-place addition, named in the PR body: signOut also drops the in-memory organization block (setOrganizations([]), setActiveOrganization(null)) — same defect class, correct shape already pinned by the sibling deleteOrganization/leaveOrganization declarations in that same file, and without it a surviving activeOrganization suppresses the re-resolution for the next user, since refreshOrganizations only asks the server for the active org when activeOrganization is falsy. #4486's pin file was NOT edited and is green — it was written key-shape-agnostic on purpose, which is what let the principal segment be added.",
      "tests": "All at HEAD 92558f12d (union re-run after the final commit). NEW: packages/app-shell/src/providers/__tests__/MetadataProvider.crossPrincipalSeed.test.tsx (3 pins: end-to-end sign-out purge, escaped-entry unreadability, same-principal counter-pin) and packages/auth/src/__tests__/signOut-client-cache-purge-5198.test.tsx (4 pins: both stores emptied and unrelated client state untouched, purge on the failure path, an ordering pin recording storage state at the moment the org id is cleared, and the in-memory org block). GREEN: `pnpm exec vitest run packages/auth/ packages/app-shell/src/providers/` = 'Test Files 24 passed (24) / Tests 229 passed (229)'; `pnpm exec vitest run packages/app-shell/ --shard=1/2` = '223 passed (223) / 2134 passed | 1 skipped'; `--shard=2/2` = '222 passed (222) / 2162 passed'; `pnpm --filter @object-ui/auth --filter @object-ui/app-shell run type-check` = 'Done' for both, with the script name echoed (it needed `pnpm --filter '@object-ui/app-shell^...' build` first — a fresh worktree resolves @object-ui/* through dist and the first attempt failed TS2307 'Cannot find module @object-ui/auth'); eslint on the 4 changed files = 0 errors (53 pre-existing warnings, unchanged); check-control-bytes, check-changeset-presence, check-changeset-no-major, check-changeset-fixed, check-lint-coverage, check-type-check-coverage, check-phantom-dependencies, check:self-import, check:i18n-keys, check:spec-symbols, check:action-forward-parity all green. REVERSE VERIFICATION, three legs, predicted before running, all three matched, and re-run at the final HEAD with identical results. NO REBUILD IS INVOLVED AND NONE WAS NEEDED: vitest.config.mts aliases @object-ui/auth and @object-ui/app-shell to their src, so restoring a source file is visible to the run immediately; each leg greps the restored file to prove the ablation reached the tree (0 hits for the removed marker, non-zero for the retained one) and the tree is byte-identical to HEAD afterwards (empty git status). (1) both halves removed = pre-fix source: predicted 4 auth reds + 2 app-shell reds with the counter-pin green; observed exactly that, 6 failed / 7 passed, the headline being 'expected 'setup,crm' to be '''— Alice's permission-filtered list rendered for Bob. (2) only the sign-out purge removed: predicted 4 auth reds + the end-to-end pin only, escaped-entry pin still green; observed 5 failed / 8 passed. (3) only the principal key removed: predicted exactly the escaped-entry pin red; observed 1 failed / 12 passed, 'expected 'setup,crm' to be '''. Restored: 13 passed. Anti-phantom guard worth noting: the escaped-entry test asserts the org id is UNCHANGED at the second mount, so the miss cannot be credited to #4486's tenant key — both principals sit in the same org, as the card requires. CI on the draft PR is in_progress at report time, per the dispatch contract.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #5243 (label `finding`, unassigned): on a browser's FIRST login MetadataProvider writes the seed entry under the no-org scope, because AuthProvider stamps ActiveOrganizationStorage only after getSession returns while the eager app fetch starts at mount — so every later boot looks under the real org id, misses, and the no-org entry is orphaned. Same-user data, no disclosure, self-heals after one boot; measured, not inferred (my same-principal counter-pin read empty until the org id was stamped before the render). The adjacent half noted there: that first-login /api/v1/meta/* request also goes out with no X-Tenant-ID."
      ]
    }

    Generated by Claude Code

  5. added 2 commits that reference this issue on Aug 23, 2026
    61b097c
    2dd9443
  6. added a commit that references this issue on Aug 24, 2026
  7. 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

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions