test(core): pin the shell's one use of browser storage, not ban it
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 42s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m41s
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 42s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m41s
CI failed in `identity-verification-boundaries.test.ts`: its guard that a
reset token never reaches browser storage was a blanket ban on the word
`localStorage` anywhere in the shell. The sidebar preference (ADR 0058)
legitimately reads it in the entry point.
The guarantee is unchanged: the reset token stays in React state for
the life of one form. The guard is now precise:
- `localStorage` appears only in `main.tsx`, only to hand it to the
sidebar preference;
- the shell's only storage write is the sidebar's own key and a
validated mode;
- the files that ever hold the token (`auth-protocol.ts`,
`ResetPasswordForm.tsx`, `HostApp.tsx`) touch no storage API at all;
- `sessionStorage` and `document.cookie` stay banned everywhere.
A planted `localStorage.setItem("t", token)` in `auth-protocol.ts` fails
it. The runtime reset-token test in `auth-recovery.test.tsx` now runs
with the production localStorage-backed preference and asserts storage
holds nothing but the sidebar key.
It passed locally only because Turbo restored `@ekklesia/core:test` from
cache: that test reads shell source that Core's task hash does not
include.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
af31724cb4
commit
cf2f847c8c
@@ -169,13 +169,41 @@ describe("the browser shell reaches none of it", () => {
|
||||
});
|
||||
|
||||
it("never puts a reset token in browser storage", async () => {
|
||||
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
|
||||
const code = withoutComments(file.text);
|
||||
// A reset token is a bearer credential for one account; the shell holds it in React state for
|
||||
// the life of one form and nowhere else.
|
||||
expect(code, file.path).not.toContain("localStorage");
|
||||
// the life of one form and nowhere else. The shell's one use of browser storage is the desktop
|
||||
// sidebar's expanded/collapsed mode (ADR 0058), so that use is pinned exactly: `localStorage` is
|
||||
// named only by the entry point, and only to hand it to the sidebar preference, whose one write
|
||||
// is its own fixed key and a validated mode.
|
||||
const files = await readTypeScriptFiles(path.join("surfaces", "shell", "src"));
|
||||
const writes: string[] = [];
|
||||
for (const file of files) {
|
||||
const code = withoutComments(file.text);
|
||||
expect(code, file.path).not.toContain("sessionStorage");
|
||||
expect(code, file.path).not.toContain("document.cookie");
|
||||
writes.push(
|
||||
...[...code.matchAll(/\.setItem\(([^)]*)\)/gu)].map(
|
||||
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
|
||||
),
|
||||
);
|
||||
if (file.path.endsWith(path.join("browser", "main.tsx"))) {
|
||||
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
|
||||
expect(code).toMatch(
|
||||
/createLocalSidebarPreference\(\s*\(\(\) => \{\s*try \{\s*return window\.localStorage;/u,
|
||||
);
|
||||
} else {
|
||||
expect(code, file.path).not.toContain("localStorage");
|
||||
}
|
||||
}
|
||||
expect(writes).toEqual([
|
||||
`${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`,
|
||||
]);
|
||||
// The files that ever hold the token touch no storage API at all.
|
||||
for (const file of files.filter((candidate) =>
|
||||
["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) =>
|
||||
candidate.path.endsWith(name),
|
||||
),
|
||||
)) {
|
||||
expect(withoutComments(file.text), file.path).not.toMatch(/\b(?:setItem|getItem|Storage)\b/u);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -403,7 +403,10 @@ parser is exercised as the pure function it is, including that it names no desti
|
||||
no runtime assertion can make: that the identity feature names no SMTP detail, that no Ekklesia
|
||||
schema gained a token column and no identity code hashes or compares a token, that nothing
|
||||
interpolates a token, link, address, or body into a log line, that a device has no email, password,
|
||||
or verification concept, and that no browser source imports Better Auth or Core's mail.
|
||||
or verification concept, that no browser source imports Better Auth or Core's mail, and that the
|
||||
shell's only use of browser storage is the sidebar's expanded/collapsed mode — `localStorage` named
|
||||
only by the entry point, one write of one fixed key, and no storage API in any file that holds the
|
||||
reset token ([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)).
|
||||
|
||||
Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an
|
||||
injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
|
||||
|
||||
@@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
|
||||
import { HostApp } from "../src/browser/HostApp.js";
|
||||
import { createMemoryHostHistory } from "../src/browser/host-history.js";
|
||||
import {
|
||||
createLocalSidebarPreference,
|
||||
SIDEBAR_PREFERENCE_KEY,
|
||||
} from "../src/browser/sidebar-preference.js";
|
||||
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
||||
|
||||
/**
|
||||
@@ -99,6 +103,9 @@ async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding)
|
||||
<HostApp
|
||||
config={config}
|
||||
history={createMemoryHostHistory("/")}
|
||||
// The production store over this browser's real storage, so the assertions below cover
|
||||
// the shell's one use of it (ADR 0058) as well.
|
||||
sidebarPreference={createLocalSidebarPreference(window.localStorage)}
|
||||
{...(authLanding ? { authLanding } : {})}
|
||||
/>,
|
||||
);
|
||||
@@ -463,7 +470,11 @@ describe("reset password", () => {
|
||||
|
||||
// A reset token is a bearer credential for one account. It lives in React state for the life of
|
||||
// the form and nowhere else.
|
||||
expect(localStorage.length).toBe(0);
|
||||
// The only thing this browser's storage may ever hold is the sidebar's mode (ADR 0058).
|
||||
for (let index = 0; index < localStorage.length; index += 1) {
|
||||
expect(localStorage.key(index)).toBe(SIDEBAR_PREFERENCE_KEY);
|
||||
}
|
||||
expect(JSON.stringify({ ...localStorage })).not.toContain("reset-token-abc");
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
expect(text()).not.toContain("reset-token-abc");
|
||||
expect(container.innerHTML).not.toContain("reset-token-abc");
|
||||
|
||||
Reference in New Issue
Block a user