@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 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. |
@@ -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
- /** Remove a consumer-added public path. Library-internal paths are protected. */
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: an entry of exactly "/" requires strict equality (avoids matching
45
- // every path with startsWith). Other entries keep the looser prefix match so
46
- // dynamic routes like /invite/:uid/:token still work.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.41.0",
3
+ "version": "2.41.1",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -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
- /** Remove a consumer-added public path. Library-internal paths are protected. */
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: an entry of exactly "/" requires strict equality (avoids matching
54
- // every path with startsWith). Other entries keep the looser prefix match so
55
- // dynamic routes like /invite/:uid/:token still work.
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.