@micha.bigler/ui-core-micha 2.41.0 → 2.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/WORK_ORDERS.md +1 -0
- package/dist/auth/apiClient.js +34 -4
- package/package.json +1 -1
- package/src/auth/apiClient.jsx +33 -4
- package/tests/apiClientPublicPaths.test.js +178 -0
- package/work-orders/AUTH-5.md +154 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -27,6 +27,7 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
27
27
|
|
|
28
28
|
| ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
|
|
29
29
|
|---|---|---|---|---|---|---|
|
|
30
|
+
| AUTH-5 | `addPublicPath` muss dynamische Routen ausdruecken koennen | `apiClient` leitet bei 401/403 hart auf `/login` um, ausser der Pfad steht auf einer Allowlist — der Mechanismus ist richtig gebaut, kann aber keine **dynamischen** Routen beschreiben: `matchesPublicPath` vergleicht mit `startsWith` (ausser `/`, das Gleichheit verlangt). Eine App mit oeffentlichen Seiten unter einem variablen Segment (`/<slug>`, `/<slug>/team`) kann ihre oeffentliche Flaeche damit nicht ausdruecken: ein konkreter Slug laesst sich eintragen, der naechste faellt wieder heraus. Folge: ein anonymer Besucher landet beim ersten 401 auf `/login` — auf einer Seite, die oeffentlich sein soll, von aussen nicht von "Seite existiert nicht" zu unterscheiden. `addPublicPath` nimmt daher zusaetzlich ein `RegExp` entgegen, geprueft ausschliesslich gegen `pathname`; die eingefrorene Builtin-Liste bleibt string-basiert und unberuehrt. | 2026-08-17 | planned | | Tier 3 (shared-core). Envelope: `work-orders/AUTH-5.md`. Kein Prototyp, keine Oberflaeche — `ui_reviewer` entfaellt, `reviewer` + `sec_reviewer` laufen. **Vorbedingung fuer `jg-ferien` PUB-7 Teil 2**, das ohne die veroeffentlichte Version nicht landen kann (jg pinnt heute `2.37.1`). Die WO ist erst `done`, wenn die Version veroeffentlicht ist. Kernrisiko: die Verantwortung wandert zum Konsumenten — ein unverankertes Muster wie `/team` trifft auch `/admin/team` und unterdrueckt die Weiterleitung dort, wo sie gebraucht wird; das gehoert in die Dokumentation am Aufruf, und die Gegenrichtung (nicht passender Pfad leitet weiterhin um) ist ein eigener Testfall. |
|
|
30
31
|
| PRIM-1 | Promote `StatTile` and `SoftChip` into the shared kit | Both already exist **twice**, independently built, and each second build is an **inline component inside a page** rather than a file — `StatTile` in `fitness-monitor/frontend/src/pages/BodyHistoryPage.jsx:554-568` beside hram's `ResearchComponents/StatTile.jsx`, `SoftChip` in `cockpit/frontend/src/cockpit/BoardView.jsx:65-73` (the lane pill) beside hram's `SoftChip.jsx`. The promotion trigger is met on evidence, not waived. The two builds diverged in seven places; all seven decided by the operator 2026-08-12 and rendered in the prototype. Full order [`work-orders/PRIM-1.md`](work-orders/PRIM-1.md) | 2026-08-12 | done | b5fff23 | Tier 3 (shared-core). `reviewer` + `ui_reviewer` mandatory and concurrent; no `sec_reviewer`. **Prototype in scope** — [`work-orders/assets/PRIM-1-dashboard-primitives.html`](work-orders/assets/PRIM-1-dashboard-primitives.html), so the rendered 375/1280 side-by-side is a commit gate; screenshots have not been legibly obtainable here, so a substitution must be **declared**, not silent. Decisions: value `h5` 18/600 · `tabular-nums` always · `flex: 1` with a `minWidth` floor · `accent` kept (fitness-monitor cannot adopt without it) · responsive padding · `status` uses **`overline`** (cockpit independently chose uppercase + .4px tracking, so this satisfies `THEME-4` with **no** baseline change and only hram's chips shift to uppercase) · dot-or-icon and optional border via props. **Token delta list has one entry:** `caveat`'s 12/500 has no baseline variant (`caption` is 12/400) — operator resolved it as keep-the-value, so it must be **exempted in `THEME-4` with that reason stated**, not flattened to 400 and not fixed by extending the baseline here. Neither `StatTile` build has a test; the promoted tests are written from scratch. Minor bump `2.38.0` → `2.39.0` (new capability area). **The three app adoptions are separate follow-up WOs** and land no app change here. Programme strand `DS-5`. **Codex skipped, dated evidence**: `.claude/codex-status.md` records `unavailable` for 2026-08-12 (`cockpit/UI-10`, out of workspace credits, the day's one allowed probe already spent) — Orchestrator implemented directly, flipping authorship. **Independent `reviewer` (Haiku, mandatory regardless of tier per the WO): no findings.** **`ui_reviewer` (Haiku, mandatory, concurrent): two P1 findings, both fixed** — (1) `StatTile`'s padding had only `px` responsive (`{xs:1, sm:1.5}`) with `py` fixed at 1, against the decided "responsive `{xs:1, sm:1.5}`" (matching fitness-monitor's original `p` shorthand, not hram's split values) — changed to a single `p: {xs:1, sm:1.5}`; (2) the `status` variant's typography was under-tested (only `textTransform` checked) — added explicit `fontSize`/`fontWeight`/`letterSpacing` assertions against `theme.typography.overline`, and separately against the `caveat` variant's raw 12/500, both now green. Re-verified 51/51 scoped tests green after fixes, `tsc -p tsconfig.build.json --noEmit` clean. **Rendered gate: declared substitution**, per the WO's own text — the 375/1280 side-by-side screenshot capture is not legibly obtainable in this repo; computed-style/DOM inspection substituted instead, via the test suite itself (fill/border/text colour per tone verified against `alpha`/`darken` of `theme.palette[tone]`; `borderRadius` 999px status vs non-pill caveat; `fontVariantNumeric: tabular-nums`; accent border width 1px vs 2px; `overline` typography values now explicit). No visual click-through was performed. Commit `b5fff23`. |
|
|
31
32
|
| THEME-3 | Add the missing "subtle surface" baseline token | Adds `palette.background.subtle` (`#F4F5F6`, fixed baseline constant alongside `default`/`paper`) to `createAppTheme` and registers it in `THEME_COMPLETENESS_SURFACES`. Found by `cockpit/UI-6`: the `UI-4` design-language sheet's canonical token table names this as a baseline-supplied "inset surface" replacing `cockpit.surface2` (16 of 195 token reads in that WO), but the value was never actually wired into the shipped package — confirmed by grep, not assumed. `cockpit/UI-6` is blocked on this landing + publishing first, per this estate's own "gap in the shared kit is a ucm WO, not a local workaround" rule. Full spec: [work-orders/THEME-3.md](work-orders/THEME-3.md). `2.33.2` (patch). | 2026-08-11 | done | 1b25117 | Tier 3 — shared-core, this repo publishes to npm on every `main` push touching `src/**`, no staging net. Codex implemented in one pass (stdin invocation), exit 0, `RESULT: DONE`, 17/17 of its own new tests green. **Independent `reviewer` (Sonnet) + `ui_reviewer` (Sonnet) ran concurrently — both clean, no findings.** Both independently re-derived the safety mechanism rather than trusting the WO's claim: `MUI_DEFAULT_THEME = createTheme()` never defines `background.subtle` (vanilla MUI only has `.default`/`.paper`), so `assertThemeComplete`'s `sameValue` check can never flag the new surface for any real adopter — the same unexempted treatment `background.default` already gets; `background.paper`'s exemption (needed because MUI's own default theme is also white) was correctly not copied. Both read `fitness-monitor`'s actual `theme.js`/`theme.test.js` directly (the one real production adopter, hard-asserting `assertThemeComplete(theme).findings` equals `[]`) and confirmed this diff cannot break it. `ui_reviewer` additionally grepped every sibling app repo under `webapps/` for `createAppTheme` — no other live consumer exists; cockpit's own adoption (`UI-6`) isn't wired yet. `reviewer` independently recomputed the contrast ratio in a throwaway script and got the same `5.3748:1` the diff claims (not the design sheet's `4.81:1`, which was computed on the sheet's own static canvas — a source discrepancy, not a functional risk; both clear AA). **Orchestrator's own verification:** built the package (`npm run build`) and attempted to run `fitness-monitor`'s real assertions against the fresh `dist/` via raw Node ESM import — hit a pre-existing (confirmed via `git show`, not introduced here) `tsc`-emitted extensionless-import quirk that only affects raw `node`, not the Vite/Vitest resolution every real consumer (incl. fitness-monitor's own suite) uses; fell back to the scoped vitest run instead. Tests: 17/17 green (`tests/createAppTheme.test.js` + `tests/themeCompleteness.test.js`), run by the Orchestrator. No visual/prototype gate — token-only change with no rendered specimen in this repo (per the Envelope); the rendered check happens downstream in `cockpit/UI-6`. Semver: patch, applied by the Orchestrator per the WO's own reasoning (additive, resolves automatically, no adopter code change needed) — confirmed rather than assumed by both reviews. Unblocks `cockpit/UI-6`, which was waiting on this landing + publish. |
|
|
32
33
|
| CHART-7 | `ChartFrame` gains a `titleVariant` prop | Adds `titleVariant` (default `'h6'`, unchanged from today's hardcoded value) to `ChartFrame`, forwarded to the title `Typography` — lets a dense panel grid (e.g. fitness-monitor's seven body-composition panels beside one hero chart) render smaller titles that recede instead of competing with the one chart that matters. One prop, not a `slotProps.title` system — deliberately minimal per the WO. Precondition of fitness-monitor's `FM-12` (DS-10). `2.32.0` (minor, additive). Full spec: [work-orders/CHART-7.md](work-orders/CHART-7.md). | 2026-08-10 | done | 14020d0 | Envelope authored by Expertenchat, no design question (short-form WO). No preconditions. Orchestrator filled a minimal Implementation map (the whole change is two lines in `ChartFrame.jsx`), ran Codex (stdin invocation). Codex added `titleVariant = 'h6'` to the prop destructure, forwarded it to the title `Typography`, wrote all 3 required tests, and bumped `2.31.1` → `2.32.0` per the WO's own stated version target. **Independent `reviewer` + `ui_reviewer` (both Haiku, proportionate to a small low-risk diff) ran concurrently — both found NO findings**, including explicit confirmation that only the prop-destructure line and the one `Typography` line changed (padding, foot row, subtitle/toolbar/slots byte-for-byte unchanged) and that the default-regression test is non-vacuous. Re-verified: 13/13 scoped vitest tests green, `tsc -p tsconfig.build.json --noEmit` clean. **Two-width rendered side-by-side (DS-1 gate)**: no existing harness specimen used `titleVariant`, so the Orchestrator added one (two `ChartFrame`s side by side, default vs. `titleVariant="subtitle2"`) to `dev/entries.jsx` — confirmed live at 375px and 1280px that the override renders visibly smaller/lighter than the unchanged h6 default. |
|
package/dist/auth/apiClient.js
CHANGED
|
@@ -26,6 +26,18 @@ const CONSUMER_PUBLIC_PATHS = new Set();
|
|
|
26
26
|
* Register an additional public path so a 401 on that route does not auto-redirect
|
|
27
27
|
* to `/login`. Typical use: a public landing on `/` in an otherwise authenticated app.
|
|
28
28
|
*
|
|
29
|
+
* Accepts a `string` (matched like the builtins: exact for "/", prefix otherwise) or
|
|
30
|
+
* a `RegExp` (AUTH-5) for consumers whose public area includes a dynamic segment —
|
|
31
|
+
* e.g. `/<slug>`, `/<slug>/team` — that no finite set of string prefixes can describe.
|
|
32
|
+
*
|
|
33
|
+
* A `RegExp` is tested via `.test()` against `window.location.pathname` ONLY — never
|
|
34
|
+
* against the query string or fragment. Anchor it (`^…$`): this library does not add
|
|
35
|
+
* anchors for you, and an unanchored pattern like `/team` also matches `/admin/team`,
|
|
36
|
+
* silently suppressing the redirect somewhere it's still needed. That failure is
|
|
37
|
+
* invisible until someone opens the over-matched page anonymously and lands on a
|
|
38
|
+
* blank error instead of `/login` — so test the non-matching direction too, not just
|
|
39
|
+
* the intended match.
|
|
40
|
+
*
|
|
29
41
|
* MUST be called before the AuthProvider mounts (i.e. before `ReactDOM.render`).
|
|
30
42
|
* Calling it later won't help the bootstrap probe which fires on AuthProvider mount.
|
|
31
43
|
*/
|
|
@@ -33,18 +45,36 @@ export function addPublicPath(path) {
|
|
|
33
45
|
if (typeof path === "string" && path) {
|
|
34
46
|
CONSUMER_PUBLIC_PATHS.add(path);
|
|
35
47
|
}
|
|
48
|
+
else if (path instanceof RegExp) {
|
|
49
|
+
CONSUMER_PUBLIC_PATHS.add(path);
|
|
50
|
+
}
|
|
36
51
|
}
|
|
37
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Remove a consumer-added public path. Library-internal paths are protected.
|
|
54
|
+
*
|
|
55
|
+
* For a `RegExp` entry, removal is by object identity (`Set.delete` uses SameValueZero
|
|
56
|
+
* equality) — pass the exact same `RegExp` instance `addPublicPath` was given, not a
|
|
57
|
+
* new literal that merely looks the same. Two `/^\/team$/` literals are not equal.
|
|
58
|
+
*/
|
|
38
59
|
export function removePublicPath(path) {
|
|
39
60
|
CONSUMER_PUBLIC_PATHS.delete(path);
|
|
40
61
|
}
|
|
41
62
|
function isPublicSitePath(pathname) {
|
|
42
63
|
return pathname === "/sites" || pathname.startsWith("/sites/");
|
|
43
64
|
}
|
|
44
|
-
// Match rule:
|
|
45
|
-
//
|
|
46
|
-
//
|
|
65
|
+
// Match rule: a RegExp entry is tested against pathname only (AUTH-5). A string entry
|
|
66
|
+
// of exactly "/" requires strict equality (avoids matching every path with
|
|
67
|
+
// startsWith); other string entries keep the looser prefix match so dynamic routes
|
|
68
|
+
// like /invite/:uid/:token still work.
|
|
47
69
|
function matchesPublicPath(pathname, entry) {
|
|
70
|
+
if (entry instanceof RegExp) {
|
|
71
|
+
// `g`/`y` flags make `.test()` stateful (`lastIndex` advances on match), which would
|
|
72
|
+
// make the same pathname alternate matched/unmatched across calls to this
|
|
73
|
+
// long-lived, module-scoped RegExp — a redirect that only fires every other time.
|
|
74
|
+
// Reset before every test so a consumer's flags can't leak state across calls.
|
|
75
|
+
entry.lastIndex = 0;
|
|
76
|
+
return entry.test(pathname);
|
|
77
|
+
}
|
|
48
78
|
if (entry === "/")
|
|
49
79
|
return pathname === "/";
|
|
50
80
|
return pathname.startsWith(entry);
|
package/package.json
CHANGED
package/src/auth/apiClient.jsx
CHANGED
|
@@ -32,16 +32,36 @@ const CONSUMER_PUBLIC_PATHS = new Set();
|
|
|
32
32
|
* Register an additional public path so a 401 on that route does not auto-redirect
|
|
33
33
|
* to `/login`. Typical use: a public landing on `/` in an otherwise authenticated app.
|
|
34
34
|
*
|
|
35
|
+
* Accepts a `string` (matched like the builtins: exact for "/", prefix otherwise) or
|
|
36
|
+
* a `RegExp` (AUTH-5) for consumers whose public area includes a dynamic segment —
|
|
37
|
+
* e.g. `/<slug>`, `/<slug>/team` — that no finite set of string prefixes can describe.
|
|
38
|
+
*
|
|
39
|
+
* A `RegExp` is tested via `.test()` against `window.location.pathname` ONLY — never
|
|
40
|
+
* against the query string or fragment. Anchor it (`^…$`): this library does not add
|
|
41
|
+
* anchors for you, and an unanchored pattern like `/team` also matches `/admin/team`,
|
|
42
|
+
* silently suppressing the redirect somewhere it's still needed. That failure is
|
|
43
|
+
* invisible until someone opens the over-matched page anonymously and lands on a
|
|
44
|
+
* blank error instead of `/login` — so test the non-matching direction too, not just
|
|
45
|
+
* the intended match.
|
|
46
|
+
*
|
|
35
47
|
* MUST be called before the AuthProvider mounts (i.e. before `ReactDOM.render`).
|
|
36
48
|
* Calling it later won't help the bootstrap probe which fires on AuthProvider mount.
|
|
37
49
|
*/
|
|
38
50
|
export function addPublicPath(path) {
|
|
39
51
|
if (typeof path === "string" && path) {
|
|
40
52
|
CONSUMER_PUBLIC_PATHS.add(path);
|
|
53
|
+
} else if (path instanceof RegExp) {
|
|
54
|
+
CONSUMER_PUBLIC_PATHS.add(path);
|
|
41
55
|
}
|
|
42
56
|
}
|
|
43
57
|
|
|
44
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Remove a consumer-added public path. Library-internal paths are protected.
|
|
60
|
+
*
|
|
61
|
+
* For a `RegExp` entry, removal is by object identity (`Set.delete` uses SameValueZero
|
|
62
|
+
* equality) — pass the exact same `RegExp` instance `addPublicPath` was given, not a
|
|
63
|
+
* new literal that merely looks the same. Two `/^\/team$/` literals are not equal.
|
|
64
|
+
*/
|
|
45
65
|
export function removePublicPath(path) {
|
|
46
66
|
CONSUMER_PUBLIC_PATHS.delete(path);
|
|
47
67
|
}
|
|
@@ -50,10 +70,19 @@ function isPublicSitePath(pathname) {
|
|
|
50
70
|
return pathname === "/sites" || pathname.startsWith("/sites/");
|
|
51
71
|
}
|
|
52
72
|
|
|
53
|
-
// Match rule:
|
|
54
|
-
//
|
|
55
|
-
//
|
|
73
|
+
// Match rule: a RegExp entry is tested against pathname only (AUTH-5). A string entry
|
|
74
|
+
// of exactly "/" requires strict equality (avoids matching every path with
|
|
75
|
+
// startsWith); other string entries keep the looser prefix match so dynamic routes
|
|
76
|
+
// like /invite/:uid/:token still work.
|
|
56
77
|
function matchesPublicPath(pathname, entry) {
|
|
78
|
+
if (entry instanceof RegExp) {
|
|
79
|
+
// `g`/`y` flags make `.test()` stateful (`lastIndex` advances on match), which would
|
|
80
|
+
// make the same pathname alternate matched/unmatched across calls to this
|
|
81
|
+
// long-lived, module-scoped RegExp — a redirect that only fires every other time.
|
|
82
|
+
// Reset before every test so a consumer's flags can't leak state across calls.
|
|
83
|
+
entry.lastIndex = 0;
|
|
84
|
+
return entry.test(pathname);
|
|
85
|
+
}
|
|
57
86
|
if (entry === "/") return pathname === "/";
|
|
58
87
|
return pathname.startsWith(entry);
|
|
59
88
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// AUTH-5: addPublicPath/matchesPublicPath/removePublicPath must accept a RegExp alongside a
|
|
3
|
+
// string, evaluated exclusively against pathname. Driven through the real response interceptor
|
|
4
|
+
// (not a reimplementation of matchesPublicPath) so these tests fail if the wiring, not just the
|
|
5
|
+
// matcher, breaks.
|
|
6
|
+
//
|
|
7
|
+
// The module keeps a `redirectingToLogin` latch that only resets on a real navigation, which
|
|
8
|
+
// never happens in jsdom — so each test re-imports a fresh module instance via
|
|
9
|
+
// vi.resetModules()/dynamic import, rather than sharing one import across the file.
|
|
10
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
|
|
12
|
+
function notAuthenticatedError(status = 401) {
|
|
13
|
+
return {
|
|
14
|
+
response: {
|
|
15
|
+
status,
|
|
16
|
+
data: { code: 'not_authenticated' },
|
|
17
|
+
},
|
|
18
|
+
config: {},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function rejectThroughInterceptor(client, error) {
|
|
23
|
+
const handler = client.interceptors.response.handlers.find(Boolean);
|
|
24
|
+
try {
|
|
25
|
+
await handler.rejected(error);
|
|
26
|
+
} catch {
|
|
27
|
+
// The interceptor always re-rejects after handling — expected, not the assertion.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// jsdom's real `window.location` ties `pathname` to `history.pushState` through internal
|
|
32
|
+
// wiring that a deleted/replaced `location` object doesn't preserve, and `assign` is
|
|
33
|
+
// non-configurable so `vi.spyOn` can't touch it directly either. Sidestep both by installing a
|
|
34
|
+
// fully synthetic `location` this file controls end to end — the interceptor only ever reads
|
|
35
|
+
// `.pathname` and calls `.assign()`, so nothing else needs to be real.
|
|
36
|
+
function installLocationMock(initialPathname = '/') {
|
|
37
|
+
const assign = vi.fn();
|
|
38
|
+
delete window.location;
|
|
39
|
+
window.location = { pathname: initialPathname, search: '', hash: '', assign };
|
|
40
|
+
return assign;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setPathname(pathname) {
|
|
44
|
+
window.location.pathname = pathname;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('apiClient public-path registry (AUTH-5)', () => {
|
|
48
|
+
let assignSpy;
|
|
49
|
+
let apiClient;
|
|
50
|
+
let addPublicPath;
|
|
51
|
+
let removePublicPath;
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
vi.resetModules();
|
|
55
|
+
({ default: apiClient, addPublicPath, removePublicPath } = await import('../src/auth/apiClient'));
|
|
56
|
+
assignSpy = installLocationMock();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('string registration still matches by prefix (regression)', async () => {
|
|
60
|
+
setPathname('/custom-public');
|
|
61
|
+
addPublicPath('/custom-public');
|
|
62
|
+
|
|
63
|
+
await rejectThroughInterceptor(apiClient, notAuthenticatedError());
|
|
64
|
+
|
|
65
|
+
expect(assignSpy).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('a bare "/" registration does not match a deeper path via startsWith (regression)', async () => {
|
|
69
|
+
addPublicPath('/');
|
|
70
|
+
setPathname('/somewhere-else');
|
|
71
|
+
|
|
72
|
+
await rejectThroughInterceptor(apiClient, notAuthenticatedError());
|
|
73
|
+
|
|
74
|
+
expect(assignSpy).toHaveBeenCalledWith('/login');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('a registered RegExp matches the intended dynamic path', async () => {
|
|
78
|
+
addPublicPath(/^\/[^/]+\/team$/);
|
|
79
|
+
setPathname('/jg-bern/team');
|
|
80
|
+
|
|
81
|
+
await rejectThroughInterceptor(apiClient, notAuthenticatedError());
|
|
82
|
+
|
|
83
|
+
expect(assignSpy).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('a non-matching path still redirects even with an unrelated RegExp registered', async () => {
|
|
87
|
+
addPublicPath(/^\/[^/]+\/team$/);
|
|
88
|
+
setPathname('/account');
|
|
89
|
+
|
|
90
|
+
await rejectThroughInterceptor(apiClient, notAuthenticatedError());
|
|
91
|
+
|
|
92
|
+
expect(assignSpy).toHaveBeenCalledWith('/login');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('the RegExp is tested against pathname, not the query string', async () => {
|
|
96
|
+
// A pattern that would only match if the query string leaked into the tested value.
|
|
97
|
+
setPathname('/blocked-page');
|
|
98
|
+
window.location.search = '?next=/public';
|
|
99
|
+
addPublicPath(/public/);
|
|
100
|
+
|
|
101
|
+
await rejectThroughInterceptor(apiClient, notAuthenticatedError());
|
|
102
|
+
|
|
103
|
+
expect(assignSpy).toHaveBeenCalledWith('/login');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('the RegExp is tested against pathname, not the fragment', async () => {
|
|
107
|
+
setPathname('/blocked-page');
|
|
108
|
+
window.location.hash = '#panel';
|
|
109
|
+
addPublicPath(/panel/);
|
|
110
|
+
|
|
111
|
+
await rejectThroughInterceptor(apiClient, notAuthenticatedError());
|
|
112
|
+
|
|
113
|
+
expect(assignSpy).toHaveBeenCalledWith('/login');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('a pathname-anchored RegExp still matches when the URL also carries a query string', async () => {
|
|
117
|
+
setPathname('/public');
|
|
118
|
+
window.location.search = '?next=/admin';
|
|
119
|
+
addPublicPath(/^\/public$/);
|
|
120
|
+
|
|
121
|
+
await rejectThroughInterceptor(apiClient, notAuthenticatedError());
|
|
122
|
+
|
|
123
|
+
expect(assignSpy).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('removePublicPath removes a RegExp entry by identity', async () => {
|
|
127
|
+
const pattern = /^\/temp$/;
|
|
128
|
+
addPublicPath(pattern);
|
|
129
|
+
removePublicPath(pattern);
|
|
130
|
+
setPathname('/temp');
|
|
131
|
+
|
|
132
|
+
await rejectThroughInterceptor(apiClient, notAuthenticatedError());
|
|
133
|
+
|
|
134
|
+
expect(assignSpy).toHaveBeenCalledWith('/login');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('removePublicPath does not remove a RegExp entry given only an equal-looking literal', async () => {
|
|
138
|
+
addPublicPath(/^\/temp$/);
|
|
139
|
+
removePublicPath(/^\/temp$/); // a different object, same source — must NOT remove the original
|
|
140
|
+
setPathname('/temp');
|
|
141
|
+
|
|
142
|
+
await rejectThroughInterceptor(apiClient, notAuthenticatedError());
|
|
143
|
+
|
|
144
|
+
expect(assignSpy).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('removePublicPath cannot remove a builtin path', async () => {
|
|
148
|
+
removePublicPath('/login');
|
|
149
|
+
setPathname('/login');
|
|
150
|
+
|
|
151
|
+
await rejectThroughInterceptor(apiClient, notAuthenticatedError());
|
|
152
|
+
|
|
153
|
+
expect(assignSpy).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('a stateful (global-flag) RegExp matches consistently across repeated calls', async () => {
|
|
157
|
+
// Without resetting lastIndex, a `g`-flagged RegExp would alternate
|
|
158
|
+
// matched/unmatched across calls since it is reused from the module-level Set.
|
|
159
|
+
addPublicPath(/^\/public$/g);
|
|
160
|
+
setPathname('/public');
|
|
161
|
+
|
|
162
|
+
await rejectThroughInterceptor(apiClient, notAuthenticatedError());
|
|
163
|
+
expect(assignSpy).not.toHaveBeenCalled();
|
|
164
|
+
|
|
165
|
+
await rejectThroughInterceptor(apiClient, notAuthenticatedError());
|
|
166
|
+
expect(assignSpy).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('a RegExp entry in the registry does not break evaluation of string entries', async () => {
|
|
170
|
+
addPublicPath(/^\/gallery\/\d+$/);
|
|
171
|
+
addPublicPath('/custom-string');
|
|
172
|
+
setPathname('/custom-string');
|
|
173
|
+
|
|
174
|
+
await rejectThroughInterceptor(apiClient, notAuthenticatedError());
|
|
175
|
+
|
|
176
|
+
expect(assignSpy).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# AUTH-5 — `addPublicPath` muss dynamische Routen ausdrücken können
|
|
2
|
+
|
|
3
|
+
**Target repo:** `ui-core-micha` (branch `main`)
|
|
4
|
+
**Tier:** 3 — shared-core. Die Änderung betrifft die Login-Weiterleitung jeder konsumierenden App.
|
|
5
|
+
|
|
6
|
+
**Kein Prototyp.** Diese Work Order hat keine Oberfläche — sie erweitert eine Registrierungs-API.
|
|
7
|
+
|
|
8
|
+
**Auslöser:** `jg-ferien` PUB-7 Teil 2. Diese WO ist dessen Vorbedingung; nach der Veröffentlichung
|
|
9
|
+
zieht jg seinen Pin von **`2.37.1`** hoch.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## A. Envelope
|
|
14
|
+
|
|
15
|
+
### Goal
|
|
16
|
+
|
|
17
|
+
`apiClient` leitet bei 401/403 per hartem `window.location.assign("/login")` um — **ausser** der
|
|
18
|
+
aktuelle Pfad steht auf einer Allowlist (`redirectToLoginOnce`, `src/auth/apiClient.jsx`). Der
|
|
19
|
+
Mechanismus ist richtig gebaut: auf einer öffentlichen Seite unterbleibt die Weiterleitung und der
|
|
20
|
+
401 wird an die Komponente durchgereicht.
|
|
21
|
+
|
|
22
|
+
**Was er nicht kann, ist dynamische Routen.** `matchesPublicPath` vergleicht mit `startsWith`, ausser
|
|
23
|
+
bei `/`, wo Gleichheit verlangt wird. Eine App mit öffentlichen Seiten unter einem **variablen
|
|
24
|
+
Segment** — `/<slug>`, `/<slug>/team` — kann ihre öffentliche Fläche damit nicht beschreiben. Ein
|
|
25
|
+
konkreter Slug lässt sich eintragen; der nächste, den jemand anlegt, fällt wieder heraus.
|
|
26
|
+
|
|
27
|
+
**Warum das mehr ist als eine Unbequemlichkeit.** Fehlt der Pfad in der Liste, landet ein anonymer
|
|
28
|
+
Besucher beim ersten 401 auf `/login` — auf einer Seite, die öffentlich sein soll. Von aussen ist das
|
|
29
|
+
nicht von „diese Seite gibt es nicht" zu unterscheiden, und der Fehler zeigt sich erst, wenn ein
|
|
30
|
+
zweites Department existiert. Er sieht dann aus wie ein Zufall, nicht wie eine Lücke.
|
|
31
|
+
|
|
32
|
+
**Erwartetes Ergebnis.** Eine konsumierende App kann ihre öffentliche Fläche vollständig beschreiben,
|
|
33
|
+
auch wenn Teile davon aus variablen Segmenten bestehen.
|
|
34
|
+
|
|
35
|
+
### Scope
|
|
36
|
+
|
|
37
|
+
- **`addPublicPath` nimmt zusätzlich ein `RegExp` entgegen** (String bleibt unverändert gültig).
|
|
38
|
+
- **`matchesPublicPath` unterscheidet nach Typ**: String wie bisher (`/` exakt, sonst `startsWith`),
|
|
39
|
+
`RegExp` per `test()` gegen **ausschliesslich `window.location.pathname`** — nie gegen Query oder
|
|
40
|
+
Fragment, damit ein Muster nicht versehentlich über den Pfad hinaus greift.
|
|
41
|
+
- **`removePublicPath` funktioniert für beide Formen.** Bei `RegExp` über Identität des übergebenen
|
|
42
|
+
Objekts; das gehört dokumentiert, weil zwei gleich aussehende Literale nicht dasselbe Objekt sind.
|
|
43
|
+
- **`BUILTIN_PUBLIC_PATHS` bleibt eingefroren und rein string-basiert.** Kein Muster in der
|
|
44
|
+
Bibliotheks-Liste — sie darf sich nicht durch eine Konsumenten-Erweiterung verschieben.
|
|
45
|
+
- **Die Dokumentation am Aufruf sagt, was der Konsument nun selbst trägt**: ein zu weites Muster
|
|
46
|
+
unterdrückt die Weiterleitung dort, wo sie gebraucht wird, und ein abgelaufener Login endet dann in
|
|
47
|
+
einer stillen Fehlerseite statt am Login. Muster gehören verankert (`^…$`).
|
|
48
|
+
|
|
49
|
+
### Non-goals / do not touch
|
|
50
|
+
|
|
51
|
+
- **Keine Änderung am Weiterleitungsverhalten selbst** — weder Ziel, noch `?next=`, noch der
|
|
52
|
+
einmalige Sperrmechanismus (`redirectingToLogin`).
|
|
53
|
+
- **Keine Änderung an der eingefrorenen Builtin-Liste** und keine an `isPublicSitePath`.
|
|
54
|
+
- **Keine Änderung am Per-Request-Opt-out** für Bootstrap-Proben.
|
|
55
|
+
- **Keine neue Prüfung gegen Query oder Fragment.**
|
|
56
|
+
|
|
57
|
+
### Risks
|
|
58
|
+
|
|
59
|
+
- **Die Verantwortung wandert zum Konsumenten.** Ein unverankertes Muster wie `/team` trifft auch
|
|
60
|
+
`/admin/team`. Das ist der Preis der Flexibilität und muss in der Dokumentation stehen, nicht nur
|
|
61
|
+
im Kopf des Autors.
|
|
62
|
+
- **Geteilter Code**: jede konsumierende App bekommt die Änderung. Deshalb rein additiv — bestehende
|
|
63
|
+
String-Registrierungen müssen sich zeichengenau gleich verhalten.
|
|
64
|
+
- **Ein Muster, das zu eng greift, ist unsichtbar**, bis jemand die betroffene Seite anonym öffnet.
|
|
65
|
+
Deshalb gehört die Gegenrichtung mitgetestet.
|
|
66
|
+
|
|
67
|
+
### Required tests to WRITE
|
|
68
|
+
|
|
69
|
+
- **Regression, nicht verhandelbar:** String-Registrierungen verhalten sich exakt wie bisher — `/`
|
|
70
|
+
nur bei Gleichheit, andere Einträge per Präfix.
|
|
71
|
+
- Ein registriertes `RegExp` trifft die passenden Pfade; ein nicht passender Pfad **löst weiterhin
|
|
72
|
+
die Weiterleitung aus**.
|
|
73
|
+
- Das Muster wird gegen `pathname` geprüft, nicht gegen Query oder Fragment.
|
|
74
|
+
- `removePublicPath` entfernt einen `RegExp`-Eintrag; ein Builtin-Pfad lässt sich weiterhin **nicht**
|
|
75
|
+
entfernen.
|
|
76
|
+
- Ein `RegExp` in `CONSUMER_PUBLIC_PATHS` bricht die bestehende Auswertung der String-Einträge nicht.
|
|
77
|
+
- **Affected set** (laufen, nicht schreiben): die vorhandenen `apiClient`-Tests.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## B. Implementation map
|
|
82
|
+
|
|
83
|
+
**Selbst umgesetzt (nicht per Codex)** — laufende Operator-Anweisung für diese Session.
|
|
84
|
+
|
|
85
|
+
### Design-Entscheidungen
|
|
86
|
+
|
|
87
|
+
- **`addPublicPath`/`removePublicPath`** akzeptieren jetzt zusätzlich `instanceof RegExp` neben
|
|
88
|
+
`string`; `CONSUMER_PUBLIC_PATHS` bleibt ein `Set`, das für `RegExp`-Objekte automatisch
|
|
89
|
+
Identitäts-Löschung liefert (`Set.delete` nutzt SameValueZero) — kein Sonderfall nötig.
|
|
90
|
+
- **`matchesPublicPath`** prüft `entry instanceof RegExp` zuerst und ruft `entry.test(pathname)` —
|
|
91
|
+
ausschliesslich gegen `window.location.pathname`, nie gegen `search`/`hash`, da die Funktion
|
|
92
|
+
ohnehin nur `pathname` als Parameter erhält (kein Risiko einer versehentlichen Konkatenation).
|
|
93
|
+
- **`BUILTIN_PUBLIC_PATHS` unverändert** — bleibt string-only und eingefroren, keine Berührung.
|
|
94
|
+
- Dokumentation an `addPublicPath`/`removePublicPath` erweitert: Anker-Pflicht (`^…$`) für
|
|
95
|
+
`RegExp`-Muster und Identitäts-Hinweis für `removePublicPath`.
|
|
96
|
+
|
|
97
|
+
### Kontextpaket
|
|
98
|
+
|
|
99
|
+
- `src/auth/apiClient.jsx` — einzige geänderte Quelldatei.
|
|
100
|
+
|
|
101
|
+
### Tests
|
|
102
|
+
|
|
103
|
+
`tests/apiClientPublicPaths.test.js` (neu, 11 Fälle) — fährt durch den echten
|
|
104
|
+
Response-Interceptor (nicht durch eine Nachbildung von `matchesPublicPath`), damit ein Fehler in
|
|
105
|
+
der Verdrahtung selbst auffällt, nicht nur ein Fehler im Matcher. Jeder Test importiert das Modul
|
|
106
|
+
frisch (`vi.resetModules()` + dynamisches `import()`), weil `redirectingToLogin` ein
|
|
107
|
+
Modul-Level-Riegel ist, der sich in jsdom nie durch echte Navigation zurücksetzt.
|
|
108
|
+
`window.location` wird durch ein rein synthetisches Objekt ersetzt (jsdoms echtes
|
|
109
|
+
`location.assign` ist nicht konfigurierbar, und `pushState` bindet nicht an ein ersetztes
|
|
110
|
+
`location`-Objekt) — deckt exakt das ab, was der Interceptor liest (`pathname`) und aufruft
|
|
111
|
+
(`assign`). Affected set: `authApiRegistration.test.js`, `AuthContext.test.jsx`,
|
|
112
|
+
`feedApi.test.js`, `messagingApi.test.js`, `notificationsApi.test.js` — alle grün.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## C. Orchestrator only
|
|
117
|
+
|
|
118
|
+
> **STOPP — wer diesen Auftrag als eigene Spezifikation liest, ist mit diesem Abschnitt nicht
|
|
119
|
+
> gemeint.** Er beschreibt dem Orchestrator, wie er dich aufruft, wie das Ergebnis geprüft und wie es
|
|
120
|
+
> verbucht wird. **Du BIST dieser Aufruf — starte kein `codex exec`, spawne keine Reviewer, pflege
|
|
121
|
+
> das Register nicht, committe nicht.** Deine Aufgabe endet beim Diff.
|
|
122
|
+
|
|
123
|
+
### Execution directive
|
|
124
|
+
|
|
125
|
+
`.claude/codex-status.md` im Workspace-Root vor dem Aufruf prüfen. Ohne Zeile für den
|
|
126
|
+
Ausführungstag Codex verwenden — direkt per Bash, nie über die `debugger`/`*_coder`-Wrapper, mit
|
|
127
|
+
BEIDEN Flags `--skip-git-repo-check` und `--dangerously-bypass-approvals-and-sandbox`. Der Rückfall
|
|
128
|
+
auf direkte Umsetzung dreht die Autorschaft und macht den unabhängigen `reviewer` zwingend.
|
|
129
|
+
|
|
130
|
+
### Review routing
|
|
131
|
+
|
|
132
|
+
Tier 3 (shared-core): **`reviewer` und `sec_reviewer`**, gleichzeitig in einem Hintergrund-Batch vor
|
|
133
|
+
dem Commit. `ui_reviewer` entfällt — es gibt keine Oberfläche. `sec_reviewer` prüft die Kernfrage:
|
|
134
|
+
kann ein registriertes Muster die Weiterleitung dort unterdrücken, wo sie gebraucht wird, und ist die
|
|
135
|
+
Auswertung auf `pathname` beschränkt.
|
|
136
|
+
|
|
137
|
+
### Verification
|
|
138
|
+
|
|
139
|
+
Kein Prototyp, kein Zwei-Breiten-Abgleich. Der Nachweis sind die Tests, insbesondere die
|
|
140
|
+
Regressionsfälle für die String-Registrierung.
|
|
141
|
+
|
|
142
|
+
Nach grünem Review: Version anheben und veröffentlichen. **Die WO ist erst abgeschlossen, wenn die
|
|
143
|
+
Version veröffentlicht ist** — jg-ferien PUB-7 hängt daran und kann ohne sie nicht landen.
|
|
144
|
+
|
|
145
|
+
### Register + commit
|
|
146
|
+
|
|
147
|
+
Zeile `AUTH-5` erreicht `done` nur mit beiden Reviewern samt Verdikt in der `Notiz` und der
|
|
148
|
+
veröffentlichten Version. Auf grün nach `main` committen und pushen.
|
|
149
|
+
|
|
150
|
+
### Mini-handover
|
|
151
|
+
|
|
152
|
+
Repo: `ui-core-micha` (`C:\Users\biglmi\Documents\webapps\ui-core-micha`), Branch `main`.
|
|
153
|
+
WO: `work-orders/AUTH-5.md`. Konsument: `jg-ferien` PUB-7 Teil 2 (Pin heute `2.37.1`).
|
|
154
|
+
`git pull`, WO lesen, dann `orchestrate-codex` folgen.
|