@celestia-island/hikari 0.41.5 → 0.41.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.41.5",
3
+ "version": "0.41.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { createAuthGuard } from "./createAuthGuard";
4
+
5
+ /** Shape the guard's router parameter accepts (a slim slice of
6
+ * vue-router's Router — the guard only calls beforeEach/onError). */
7
+ interface SlimRouter {
8
+ beforeEach: (fn: (to: GuardTo) => unknown) => void;
9
+ onError: (fn: (error: unknown, to: GuardTo) => void) => void;
10
+ }
11
+
12
+ interface GuardTo {
13
+ name?: string | symbol | null;
14
+ fullPath?: string;
15
+ meta?: Record<string, unknown>;
16
+ }
17
+
18
+ /** Minimal router double: records guards, lets tests drive them. */
19
+ function makeRouter() {
20
+ const guards: Array<(to: GuardTo) => unknown> = [];
21
+ const router: SlimRouter = {
22
+ beforeEach: (fn) => { guards.push(fn); },
23
+ onError: () => {},
24
+ };
25
+ return {
26
+ router,
27
+ run: (to: GuardTo) => {
28
+ expect(guards.length).toBeGreaterThan(0);
29
+ return guards[guards.length - 1](to);
30
+ },
31
+ };
32
+ }
33
+
34
+ function makeAuthStore(overrides: Partial<Record<string, unknown>> = {}) {
35
+ return {
36
+ isAuthenticated: false,
37
+ user: null,
38
+ tryRestoreSession: vi.fn().mockResolvedValue(undefined),
39
+ checkSetup: vi.fn().mockResolvedValue({ needs_setup: false }),
40
+ fetchUser: vi.fn().mockResolvedValue(undefined),
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ /** Permissions-store double mirroring the real store's semantics:
46
+ * `fetch()` resolves the set and flips loaded=true; a failing fetch
47
+ * leaves loaded=false (fail-open). */
48
+ function makePermStore(init: { loaded?: boolean; perms?: string[]; failFetch?: boolean } = {}) {
49
+ const state = { loaded: init.loaded ?? false, perms: init.perms ?? [] };
50
+ return {
51
+ get loaded() { return state.loaded; },
52
+ fetch: vi.fn().mockImplementation(async () => {
53
+ if (init.failFetch) return;
54
+ state.perms = init.perms ?? [];
55
+ state.loaded = true;
56
+ }),
57
+ has: (perm: string) => state.perms.includes(perm),
58
+ hasAny: (perms: string[]) => perms.some((p) => state.perms.includes(p)),
59
+ };
60
+ }
61
+
62
+ const baseOpts = {
63
+ loginRoute: "login",
64
+ homeRoute: "demiurge",
65
+ setupRoute: "setup-redirect",
66
+ };
67
+
68
+ describe("createAuthGuard requiresPermission enforcement", () => {
69
+ // Contract: routes declare `requiresPermission` (e.g. /backend with
70
+ // "system.read") and the guard is the enforcement point. The meta used
71
+ // to be declared but never consumed — any authenticated user could
72
+ // open the admin console route and only AdminLayout's own nav filtering
73
+ // hid the views (field report 2026-09).
74
+ it("bounces a user lacking the required permission to the home route", async () => {
75
+ const auth = makeAuthStore({ isAuthenticated: true, user: { username: "momoi" } });
76
+ const perms = makePermStore({ perms: [] });
77
+ const { router, run } = makeRouter();
78
+ createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth, permissions: () => perms });
79
+
80
+ const verdict = await run({
81
+ name: "admin",
82
+ fullPath: "/backend",
83
+ meta: { requiresAuth: true, requiresPermission: "system.read" },
84
+ });
85
+ expect(verdict).toEqual({ name: "demiurge" });
86
+ expect(perms.fetch).toHaveBeenCalledOnce();
87
+ });
88
+
89
+ it("admits a user holding the required permission", async () => {
90
+ const auth = makeAuthStore({ isAuthenticated: true, user: { username: "demiurge" } });
91
+ const perms = makePermStore({ loaded: true, perms: ["system.read"] });
92
+ const { router, run } = makeRouter();
93
+ createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth, permissions: () => perms });
94
+
95
+ const verdict = await run({
96
+ name: "admin",
97
+ fullPath: "/backend",
98
+ meta: { requiresAuth: true, requiresPermission: "system.read" },
99
+ });
100
+ expect(verdict).toBeUndefined();
101
+ // Already-loaded set must not be refetched on every navigation.
102
+ expect(perms.fetch).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it("accepts any-of lists via hasAny", async () => {
106
+ const auth = makeAuthStore({ isAuthenticated: true, user: { username: "u" } });
107
+ const perms = makePermStore({ loaded: true, perms: ["industrial.write"] });
108
+ const { router, run } = makeRouter();
109
+ createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth, permissions: () => perms });
110
+
111
+ const verdict = await run({
112
+ name: "writer",
113
+ fullPath: "/somewhere",
114
+ meta: { requiresPermission: ["system.read", "industrial.write"] },
115
+ });
116
+ expect(verdict).toBeUndefined();
117
+ });
118
+
119
+ it("fails open while the permission set is unloaded (store outage must not brick navigation)", async () => {
120
+ const auth = makeAuthStore({ isAuthenticated: true, user: { username: "u" } });
121
+ const perms = makePermStore({ failFetch: true });
122
+ const { router, run } = makeRouter();
123
+ createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth, permissions: () => perms });
124
+
125
+ const verdict = await run({
126
+ name: "admin",
127
+ fullPath: "/backend",
128
+ meta: { requiresAuth: true, requiresPermission: "system.read" },
129
+ });
130
+ expect(verdict).toBeUndefined();
131
+ });
132
+
133
+ it("ignores routes without the meta and unwired permission stores", async () => {
134
+ const auth = makeAuthStore({ isAuthenticated: true, user: { username: "u" } });
135
+ const { router, run } = makeRouter();
136
+ createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth });
137
+
138
+ const verdict = await run({ name: "demiurge", fullPath: "/@ws", meta: { requiresAuth: true } });
139
+ expect(verdict).toBeUndefined();
140
+ });
141
+ });
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { createAuthGuard } from "./createAuthGuard";
4
+
5
+ /** Shape the guard's router parameter accepts (a slim slice of
6
+ * vue-router's Router — the guard only calls beforeEach/onError). */
7
+ interface SlimRouter {
8
+ beforeEach: (fn: (to: GuardTo) => unknown) => void;
9
+ onError: (fn: (error: unknown, to: GuardTo) => void) => void;
10
+ }
11
+
12
+ interface GuardTo {
13
+ name?: string | symbol | null;
14
+ fullPath?: string;
15
+ meta?: Record<string, unknown>;
16
+ }
17
+
18
+ /** Minimal router double: records guards, lets tests drive them. */
19
+ function makeRouter() {
20
+ const guards: Array<(to: GuardTo) => unknown> = [];
21
+ const router: SlimRouter = {
22
+ beforeEach: (fn) => { guards.push(fn); },
23
+ onError: () => {},
24
+ };
25
+ return {
26
+ router,
27
+ run: (to: GuardTo) => {
28
+ expect(guards.length).toBeGreaterThan(0);
29
+ return guards[guards.length - 1](to);
30
+ },
31
+ };
32
+ }
33
+
34
+ function makeAuthStore(overrides: Partial<Record<string, unknown>> = {}) {
35
+ return {
36
+ isAuthenticated: false,
37
+ user: null,
38
+ tryRestoreSession: vi.fn().mockResolvedValue(undefined),
39
+ checkSetup: vi.fn().mockResolvedValue({ needs_setup: false }),
40
+ fetchUser: vi.fn().mockResolvedValue(undefined),
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ const baseOpts = {
46
+ loginRoute: "login",
47
+ homeRoute: "demiurge",
48
+ setupRoute: "setup-redirect",
49
+ };
50
+
51
+ /** The lagged-login contract: an identity fetch that fails verification
52
+ * must redirect to the login route (preserving the redirect target),
53
+ * never abort the navigation with an unhandled rejection — the old
54
+ * behavior fed the rejection into router.onError where the lazy-load
55
+ * retry handler misclassified it as a chunk failure. */
56
+ describe("createAuthGuard identity hydration", () => {
57
+ it("redirects to login when the identity fetch fails verification", async () => {
58
+ const auth = makeAuthStore({ isAuthenticated: true });
59
+ // A failed verification logs out inside the real fetchUser — the
60
+ // guard reads the post-fetch flag, so flip it in the mock too.
61
+ auth.fetchUser = vi.fn().mockImplementation(async () => {
62
+ auth.isAuthenticated = false;
63
+ throw new Error("no usable identity");
64
+ });
65
+ const { router, run } = makeRouter();
66
+ createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth });
67
+
68
+ const verdict = await run({ name: "demiurge", fullPath: "/@ws123", meta: { requiresAuth: true } });
69
+ expect(verdict).toEqual({ name: "login", query: { redirect: "/@ws123" } });
70
+ expect(auth.fetchUser).toHaveBeenCalledOnce();
71
+ });
72
+
73
+ it("keeps the route when the fetch fails but the session stays authenticated", async () => {
74
+ // Transient transport failure: the shell's own safety net retries —
75
+ // the guard must not bounce the user on a blip.
76
+ const auth = makeAuthStore({
77
+ isAuthenticated: true,
78
+ fetchUser: vi.fn().mockRejectedValue(new Error("timed out")),
79
+ });
80
+ const { router, run } = makeRouter();
81
+ createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth });
82
+
83
+ const verdict = await run({ name: "demiurge", fullPath: "/@ws123", meta: { requiresAuth: true } });
84
+ expect(verdict).toBeUndefined();
85
+ });
86
+
87
+ it("lets a hydrated identity through without refetching", async () => {
88
+ const auth = makeAuthStore({
89
+ isAuthenticated: true,
90
+ user: { username: "demiurge" },
91
+ fetchUser: vi.fn(),
92
+ });
93
+ const { router, run } = makeRouter();
94
+ createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth });
95
+
96
+ const verdict = await run({ name: "demiurge", fullPath: "/@ws123", meta: { requiresAuth: true } });
97
+ expect(verdict).toBeUndefined();
98
+ expect(auth.fetchUser).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it("retries session restore after a failed attempt instead of memoizing it", async () => {
102
+ // Regression (user report 2026-09-07): a failed restore used to stay
103
+ // memoized for the SPA lifetime, so once the guard's first
104
+ // tryRestoreSession raced a network blip, the only way back in was a
105
+ // manual reload. The memo must reset on failure so the next
106
+ // navigation retries — with valid cookies the retry recovers.
107
+ const auth = makeAuthStore();
108
+ auth.tryRestoreSession = vi
109
+ .fn<() => Promise<void>>()
110
+ .mockImplementationOnce(async () => {
111
+ throw new Error("restore raced a blip");
112
+ })
113
+ .mockImplementationOnce(async () => {
114
+ auth.isAuthenticated = true;
115
+ });
116
+ const { router, run } = makeRouter();
117
+ createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth });
118
+
119
+ // First navigation: restore fails → bounced to the login route.
120
+ const first = await run({ name: "demiurge", fullPath: "/@ws123", meta: { requiresAuth: true } });
121
+ expect(first).toEqual({ name: "login", query: { redirect: "/@ws123" } });
122
+
123
+ // Second navigation: the restore retries and succeeds → route passes.
124
+ const second = await run({ name: "demiurge", fullPath: "/@ws123", meta: { requiresAuth: true } });
125
+ expect(auth.tryRestoreSession).toHaveBeenCalledTimes(2);
126
+ expect(second).toBeUndefined();
127
+ });
128
+ });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Shared auth route guard (family runtime).
3
+ *
4
+ * Upstreamed from shittim-chest (its copy was the most evolved form:
5
+ * setupWizardRoute split, permissions-store factory, session-restore
6
+ * retry, fetchUser failure semantics) to replace the per-repo forks —
7
+ * easy-hydro-erp carried an older vendored copy under
8
+ * `src/vendor/plana-ui/`. Structurally typed: no vue-router or pinia
9
+ * dependency, so hikari stays UI-library-scoped and any router-shaped
10
+ * object works.
11
+ */
12
+
13
+ export interface AuthGuardOptions {
14
+ loginRoute: string;
15
+ homeRoute: string;
16
+ setupRoute: string;
17
+ /**
18
+ * Optional distinct wizard route that actually performs initial setup.
19
+ * Some apps split the flow into a redirect/landing page (`setupRoute`,
20
+ * e.g. locale detection) and the wizard itself (`setupWizardRoute`). When
21
+ * `needs_setup` is true the guard must allow BOTH, otherwise the landing
22
+ * page can never navigate into the wizard (infinite redirect loop).
23
+ */
24
+ setupWizardRoute?: string;
25
+ registerRoute?: string;
26
+ useAuthStore: () => {
27
+ isAuthenticated: boolean;
28
+ user: unknown;
29
+ tryRestoreSession: () => Promise<void>;
30
+ checkSetup: () => Promise<{ needs_setup: boolean; registration_enabled?: boolean }>;
31
+ // The guard only awaits the call — the store's `User` result is unused.
32
+ fetchUser: () => Promise<unknown>;
33
+ };
34
+ onLazyLoadError?: (error: Error, target: string) => void;
35
+ /**
36
+ * Permissions store factory; optional — skipped when omitted. A factory
37
+ * (not an instance) because pinia is installed after this module
38
+ * evaluates: the router is created at module scope, so an eager
39
+ * `usePermissionsStore()` here would run without an active pinia.
40
+ */
41
+ permissions?: () => {
42
+ loaded: boolean;
43
+ fetch: () => Promise<void>;
44
+ has: (perm: string) => boolean;
45
+ hasAny: (perms: string[]) => boolean;
46
+ } | undefined;
47
+ }
48
+
49
+ interface GuardRoute {
50
+ name?: string | symbol | null;
51
+ fullPath?: string;
52
+ meta?: Record<string, unknown>;
53
+ }
54
+
55
+ interface GuardRouter {
56
+ // Accepts any guard signature (vue-router's NavigationGuard takes extra
57
+ // args; slimmer routers take fewer) — the callback just inspects `to`.
58
+ // The guard callback receives the route; vue-router also passes from/next
59
+ // which we ignore. `never`-parameter variance keeps both sides assignable.
60
+ beforeEach: (fn: (to: GuardRoute, ...rest: unknown[]) => unknown) => void;
61
+ onError?: (fn: (error: unknown, to: GuardRoute) => void) => void;
62
+ }
63
+
64
+ export function createAuthGuard(router: GuardRouter, opts: AuthGuardOptions) {
65
+ let sessionRestorePromise: Promise<void> | null = null;
66
+ let setupChecked = false;
67
+
68
+ router.beforeEach(async (to: GuardRoute) => {
69
+ const auth = opts.useAuthStore();
70
+ const permStore = opts.permissions?.();
71
+
72
+ if (!auth.isAuthenticated) {
73
+ if (!sessionRestorePromise) {
74
+ sessionRestorePromise = auth.tryRestoreSession().catch(() => {
75
+ // A failed restore must not stay memoized for the SPA
76
+ // lifetime: the cookies may be perfectly valid and the first
77
+ // attempt may just have raced a network blip. Nulling here
78
+ // lets the next navigation (e.g. the login page's silent
79
+ // restore, or simply clicking another route) retry.
80
+ sessionRestorePromise = null;
81
+ });
82
+ }
83
+ await sessionRestorePromise;
84
+ }
85
+
86
+ if (!setupChecked && !auth.isAuthenticated) {
87
+ setupChecked = true;
88
+ try {
89
+ const result = await auth.checkSetup();
90
+ const setupAllowed = [opts.setupRoute, opts.setupWizardRoute].filter(Boolean);
91
+ if (result.needs_setup && !setupAllowed.includes(to.name as string)) {
92
+ return { name: opts.setupRoute };
93
+ }
94
+ if (!result.needs_setup && to.name === opts.setupRoute) {
95
+ return { name: opts.loginRoute };
96
+ }
97
+ if (
98
+ !result.registration_enabled &&
99
+ opts.registerRoute &&
100
+ to.name === opts.registerRoute
101
+ ) {
102
+ return { name: opts.loginRoute };
103
+ }
104
+ } catch {
105
+ // ignore
106
+ }
107
+ }
108
+
109
+ if (to.meta?.requiresAuth !== false && !auth.isAuthenticated) {
110
+ const redirect = to.fullPath && to.fullPath !== "/" ? to.fullPath : undefined;
111
+ return { name: opts.loginRoute, query: redirect ? { redirect } : undefined };
112
+ }
113
+
114
+ if (auth.isAuthenticated && permStore && !permStore.loaded) {
115
+ await permStore.fetch();
116
+ }
117
+
118
+ const publicRoutes = [opts.loginRoute, opts.setupRoute];
119
+ if (opts.registerRoute) publicRoutes.push(opts.registerRoute);
120
+ if (publicRoutes.includes(to.name as string) && auth.isAuthenticated) {
121
+ return "/";
122
+ }
123
+
124
+ if (auth.isAuthenticated && !auth.user) {
125
+ // Identity hydration on a session-restored client. The fetch's
126
+ // own failure path already clears the local session (see
127
+ // useAuthBase — deliberately NOT a cookie-clearing logout), so the
128
+ // only job here is to not let the rejection kill the navigation
129
+ // — an aborted guard is reported to router.onError, where it was
130
+ // misclassified as a lazy-load chunk failure and retried in a
131
+ // loop. Redirect to the login page instead; if the fetch
132
+ // succeeded the route renders with a real identity.
133
+ try {
134
+ await auth.fetchUser();
135
+ } catch {
136
+ if (!auth.isAuthenticated) {
137
+ const redirect = to.fullPath && to.fullPath !== "/" ? to.fullPath : undefined;
138
+ return { name: opts.loginRoute, query: redirect ? { redirect } : undefined };
139
+ }
140
+ // Transient failure while still authenticated: let the route
141
+ // through — the shell's own authed-watch retries hydration.
142
+ }
143
+ }
144
+
145
+ // Enforce meta-declared route permissions (`requiresPermission`). The
146
+ // meta has been declared since the admin console landed (/backend with
147
+ // "system.read") but was never consumed — AdminLayout hides its own
148
+ // nav for callers lacking the permission, yet the route itself rendered
149
+ // for ANY authenticated user (field report 2026-09: a non-admin opened
150
+ // the console route from the user menu and got a broken half-page).
151
+ // Deny only when a permission set is actually loaded; a store outage
152
+ // (fetch failed, loaded=false) fails open exactly like before instead
153
+ // of bricking navigation.
154
+ const required = to.meta?.requiresPermission as string | string[] | undefined;
155
+ if (
156
+ auth.isAuthenticated &&
157
+ permStore &&
158
+ required !== undefined &&
159
+ permStore.loaded &&
160
+ !(Array.isArray(required) ? permStore.hasAny(required) : permStore.has(required))
161
+ ) {
162
+ return { name: opts.homeRoute };
163
+ }
164
+ });
165
+
166
+ if (opts.onLazyLoadError) {
167
+ router.onError?.((error: unknown, to: GuardRoute) => {
168
+ opts.onLazyLoadError!(error as Error, to.fullPath ?? "");
169
+ });
170
+ }
171
+ }
@@ -0,0 +1,139 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { installHistorySafetyNet, sanitizeHistoryUrl } from "./historySafetyNet";
4
+
5
+ /**
6
+ * The field bug this net closes (chest #754 lineage): some producer
7
+ * hands the history layer a URL that resolves off-origin — classically
8
+ * `origin + "undefined"` — pushState throws SecurityError, and
9
+ * vue-router's fallback performs a full-page location.assign onto
10
+ * "https://demo.dev.celestia.worldundefined/". The net must fold every
11
+ * off-origin / poisoned target while leaving legitimate same-origin
12
+ * URLs bit-for-bit untouched.
13
+ */
14
+
15
+ describe("sanitizeHistoryUrl", () => {
16
+ it("passes through nullish and empty (no-op calls stay no-ops)", () => {
17
+ expect(sanitizeHistoryUrl(null)).toBe(null);
18
+ expect(sanitizeHistoryUrl(undefined)).toBe(undefined);
19
+ expect(sanitizeHistoryUrl("")).toBe("");
20
+ });
21
+
22
+ it("passes through same-origin absolute URLs untouched", () => {
23
+ const origin = window.location.origin;
24
+ expect(sanitizeHistoryUrl(`${origin}/backend#providers`)).toBe(`${origin}/backend#providers`);
25
+ expect(sanitizeHistoryUrl(`${origin}/@ws?box=b#think`)).toBe(`${origin}/@ws?box=b#think`);
26
+ });
27
+
28
+ it("passes through in-app relative targets untouched", () => {
29
+ expect(sanitizeHistoryUrl("/backend")).toBe("/backend");
30
+ expect(sanitizeHistoryUrl("/@uuid?mode=reports#think")).toBe("/@uuid?mode=reports#think");
31
+ expect(sanitizeHistoryUrl("#think")).toBe("#think");
32
+ expect(sanitizeHistoryUrl("?redirect=/backend")).toBe("?redirect=/backend");
33
+ });
34
+
35
+ it("folds the cross-origin concatenated-undefined class onto the fallback", () => {
36
+ expect(sanitizeHistoryUrl("https://demo.dev.celestia.worldundefined")).toBe("/");
37
+ expect(sanitizeHistoryUrl("https://demo.dev.celestia.worldundefined/")).toBe("/");
38
+ expect(sanitizeHistoryUrl("https://evil.example/steal")).toBe("/");
39
+ expect(sanitizeHistoryUrl("//evil.example/x")).toBe("/");
40
+ });
41
+
42
+ it("honors a custom fallback (apps whose landing route is not /)", () => {
43
+ expect(sanitizeHistoryUrl("https://evil.example/x", "/panel/home")).toBe("/panel/home");
44
+ expect(sanitizeHistoryUrl("undefined", "/panel/home")).toBe("/panel/home");
45
+ });
46
+
47
+ it("folds bare poisoned literals onto the fallback", () => {
48
+ expect(sanitizeHistoryUrl("undefined")).toBe("/");
49
+ expect(sanitizeHistoryUrl("null")).toBe("/");
50
+ expect(sanitizeHistoryUrl("NaN")).toBe("/");
51
+ });
52
+
53
+ it("folds unparseable garbage onto the fallback", () => {
54
+ expect(sanitizeHistoryUrl("http://")).toBe("/");
55
+ });
56
+ });
57
+
58
+ describe("installHistorySafetyNet", () => {
59
+ const nativePush = History.prototype.pushState;
60
+ const nativeReplace = History.prototype.replaceState;
61
+
62
+ afterEach(() => {
63
+ History.prototype.pushState = nativePush;
64
+ History.prototype.replaceState = nativeReplace;
65
+ sessionStorage.clear();
66
+ // The installer detects the restored (unpatched) prototype and
67
+ // re-patches on the next call — that self-healing path is exercised
68
+ // by every test after the first restore.
69
+ });
70
+
71
+ it("coerces a cross-origin pushState target instead of throwing", () => {
72
+ installHistorySafetyNet();
73
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
74
+ expect(() => history.pushState(null, "", "https://demo.dev.celestia.worldundefined/")).not.toThrow();
75
+ expect(window.location.pathname).toBe("/");
76
+ expect(warn).toHaveBeenCalled();
77
+ warn.mockRestore();
78
+ });
79
+
80
+ it("leaves same-origin pushState untouched", () => {
81
+ installHistorySafetyNet();
82
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
83
+ history.pushState(null, "", "/backend");
84
+ expect(window.location.pathname).toBe("/backend");
85
+ expect(warn).not.toHaveBeenCalled();
86
+ warn.mockRestore();
87
+ });
88
+
89
+ it("passes the 2-argument call shape through untouched (backStack / vue-router beforeUnload)", () => {
90
+ // Production shape: window.history.pushState(state, "") — no url
91
+ // argument at all. A wrapper-level regression that folded undefined
92
+ // here would break every modal/back-guard push, so pin it at the
93
+ // wrapper level, not just the pure function.
94
+ installHistorySafetyNet();
95
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
96
+ expect(() => window.history.pushState({ marker: 1 }, "")).not.toThrow();
97
+ expect(window.history.state).toEqual({ marker: 1 });
98
+ expect(() => window.history.replaceState(null, "")).not.toThrow();
99
+ expect(window.history.state).toBeNull();
100
+ expect(warn).not.toHaveBeenCalled();
101
+ warn.mockRestore();
102
+ });
103
+
104
+ it("passes vue-router's real production shape (same-origin ABSOLUTE url) untouched", () => {
105
+ // changeLocation always composes protocol+'//'+host+base+to — pin
106
+ // that exact shape so an over-eager origin comparison cannot ever
107
+ // fold a legitimate router push.
108
+ installHistorySafetyNet();
109
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
110
+ const abs = `${window.location.origin}/@ws?mode=reports#think`;
111
+ history.pushState(null, "", abs);
112
+ expect(window.location.href).toBe(abs);
113
+ expect(warn).not.toHaveBeenCalled();
114
+ warn.mockRestore();
115
+ });
116
+
117
+ it("records a bounded sessionStorage evidence trail", () => {
118
+ installHistorySafetyNet();
119
+ vi.spyOn(console, "warn").mockImplementation(() => {});
120
+ for (let i = 0; i < 12; i++) {
121
+ history.replaceState(null, "", `https://evil.example/${i}`);
122
+ }
123
+ const trail = JSON.parse(sessionStorage.getItem("hikari:historyNet") || "[]") as string[];
124
+ expect(trail.length).toBeLessThanOrEqual(8);
125
+ expect(trail[trail.length - 1]).toContain("evil.example/11");
126
+ vi.restoreAllMocks();
127
+ });
128
+
129
+ it("reports through the onCoercion sink and honors evidenceKey: false", () => {
130
+ const seen: string[] = [];
131
+ installHistorySafetyNet({ evidenceKey: false, onCoercion: (info) => seen.push(info.raw) });
132
+ vi.spyOn(console, "warn").mockImplementation(() => {});
133
+ history.pushState(null, "", "https://evil.example/a");
134
+ history.pushState(null, "", "https://evil.example/b");
135
+ expect(seen).toEqual(["https://evil.example/a", "https://evil.example/b"]);
136
+ expect(sessionStorage.getItem("hikari:historyNet")).toBeNull();
137
+ vi.restoreAllMocks();
138
+ });
139
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Last-line safety net over the History API (family runtime).
3
+ *
4
+ * Upstreamed from shittim-chest #754, where the field class was first
5
+ * sealed app-locally: a navigation producer whose target stringifies
6
+ * without a leading "/" (the classic being an interpolated `undefined`)
7
+ * makes vue-router's HTML5 history compose `origin + base + to` into a
8
+ * CROSS-ORIGIN URL; the native pushState then throws a SecurityError
9
+ * and vue-router's catch branch performs a FULL-PAGE
10
+ * `location.assign(url)` onto the broken host — observed in the field
11
+ * as `https://<host>undefined/`. Guards ABOVE the router only protect
12
+ * navigations that flow through them; a stale-tab mixed module graph,
13
+ * a future call site, or any direct `history.pushState(garbage)` all
14
+ * bypass them. This net sits BELOW everything: it patches
15
+ * `History.prototype.pushState/replaceState` so an off-origin URL (or
16
+ * a bare poisoned literal, or unparseable garbage) is folded onto the
17
+ * app landing BEFORE the native call — with the throw gone, the
18
+ * full-page fallback for this class is structurally unreachable.
19
+ *
20
+ * Every coercion logs a console warning carrying the producer stack
21
+ * trace and appends to a bounded sessionStorage evidence trail so the
22
+ * next field occurrence can be attributed.
23
+ */
24
+
25
+ /** Bare literals that read as a producer bug, never a real target. */
26
+ const POISONED_LITERALS = new Set(["undefined", "null", "nan"]);
27
+
28
+ /** sessionStorage evidence budget: keep the newest N coerced targets. */
29
+ const EVIDENCE_MAX = 8;
30
+ const DEFAULT_EVIDENCE_KEY = "hikari:historyNet";
31
+
32
+ export interface HistorySafetyNetOptions {
33
+ /** Where coerced targets fold to. Must be an in-app absolute path.
34
+ * Defaults to `"/"`. */
35
+ fallback?: string;
36
+ /** sessionStorage key for the evidence trail. Pass `false` to
37
+ * disable persistence (the console trail always stays). */
38
+ evidenceKey?: string | false;
39
+ /** Extra sink for coercions (app telemetry, tests). */
40
+ onCoercion?: (info: HistoryCoercion) => void;
41
+ }
42
+
43
+ export interface HistoryCoercion {
44
+ method: "pushState" | "replaceState";
45
+ raw: string;
46
+ foldedTo: string;
47
+ }
48
+
49
+ /** Coerce a would-be history URL onto a safe same-origin target.
50
+ *
51
+ * - `null`-ish / empty passes through untouched (no-ops stay no-ops);
52
+ * - same-origin resolutions pass through untouched;
53
+ * - anything that resolves off-origin (the `origin + "undefined"`
54
+ * concatenation class, absolute foreign URLs) folds to the
55
+ * fallback;
56
+ * - bare poisoned literals ("undefined") fold too — as relative URLs
57
+ * they would stay same-origin, but they are always producer bugs
58
+ * and deserve the same hard stop.
59
+ */
60
+ export function sanitizeHistoryUrl(
61
+ raw: string | URL | null | undefined,
62
+ fallback = "/",
63
+ ): string | URL | null {
64
+ if (raw == null || raw === "") return raw as string | URL | null;
65
+ const asUrl = raw instanceof URL ? raw : null;
66
+ const s = asUrl ? asUrl.href : String(raw);
67
+ const trimmed = s.trim();
68
+ if (POISONED_LITERALS.has(trimmed.toLowerCase())) return fallback;
69
+ let resolved: URL;
70
+ try {
71
+ resolved = new URL(s, window.location.href);
72
+ } catch {
73
+ return fallback;
74
+ }
75
+ return resolved.origin === window.location.origin ? (raw as string | URL) : fallback;
76
+ }
77
+
78
+ /** Marker stamped on patched methods so re-install stays a no-op while
79
+ * a genuine overwrite (another lib restoring the prototype, or a test
80
+ * harness) is detected and re-patched. */
81
+ const PATCH_MARKER = "__hikariHistoryNet";
82
+
83
+ /** Patch History.prototype. Safe to call multiple times: already-
84
+ * patched methods are left alone, but a method overwritten back to a
85
+ * foreign implementation is patched again (self-healing). */
86
+ export function installHistorySafetyNet(options: HistorySafetyNetOptions = {}): void {
87
+ const fallback = options.fallback ?? "/";
88
+ const evidenceKey = options.evidenceKey === undefined ? DEFAULT_EVIDENCE_KEY : options.evidenceKey;
89
+ for (const method of ["pushState", "replaceState"] as const) {
90
+ const current: unknown = (History.prototype as unknown as Record<string, unknown>)[method];
91
+ if (
92
+ typeof current === "function" &&
93
+ (current as { [PATCH_MARKER]?: boolean })[PATCH_MARKER] === true
94
+ ) {
95
+ continue;
96
+ }
97
+ const native = current as (this: History, state: unknown, title: string, url?: string | URL | null) => unknown;
98
+ if (typeof native !== "function") continue;
99
+ const patched = function (this: History, state: unknown, title: string, url?: string | URL | null) {
100
+ const safe = sanitizeHistoryUrl(url, fallback);
101
+ if (safe !== url) {
102
+ const info: HistoryCoercion = { method, raw: String(url ?? ""), foldedTo: fallback };
103
+ reportCoercion(info, evidenceKey, options);
104
+ }
105
+ return native.call(this, state as never, title, safe as never);
106
+ };
107
+ (patched as { [PATCH_MARKER]?: boolean })[PATCH_MARKER] = true;
108
+ try {
109
+ Object.defineProperty(History.prototype, method, {
110
+ value: patched,
111
+ writable: true,
112
+ // Match the WebIDL shape of native prototype operations
113
+ // (enumerable) so Object.keys/for...in keep seeing the methods.
114
+ enumerable: true,
115
+ configurable: true,
116
+ });
117
+ } catch {
118
+ // Non-configurable prototype (locked-down environment) — the
119
+ // layers above (router guard, sanitized producers) still hold.
120
+ }
121
+ }
122
+ }
123
+
124
+ /** Log loudly and keep a bounded sessionStorage trail for attribution. */
125
+ function reportCoercion(
126
+ info: HistoryCoercion,
127
+ evidenceKey: string | false,
128
+ options: HistorySafetyNetOptions,
129
+ ): void {
130
+ console.warn(
131
+ `[HistoryNet] Blocked off-origin history ${info.method} target ${JSON.stringify(info.raw)} — folded to ${JSON.stringify(info.foldedTo)}`,
132
+ new Error("producer stack").stack,
133
+ );
134
+ options.onCoercion?.(info);
135
+ if (evidenceKey === false) return;
136
+ const entry = `${new Date().toISOString()} ${info.method}: ${JSON.stringify(info.raw)} -> ${info.foldedTo}`;
137
+ try {
138
+ const prev = JSON.parse(sessionStorage.getItem(evidenceKey) || "[]");
139
+ // A corrupted/non-array payload must not kill the trail forever —
140
+ // start a fresh list instead of skipping every future write.
141
+ const list = Array.isArray(prev) ? prev : [];
142
+ list.push(entry);
143
+ sessionStorage.setItem(evidenceKey, JSON.stringify(list.slice(-EVIDENCE_MAX)));
144
+ } catch { /* storage unavailable — the console trail is enough */ }
145
+ }
@@ -3,6 +3,20 @@ export * from "./cronBus";
3
3
  export * from "./intervalBus";
4
4
  export * from "./pageLifecycle";
5
5
  export * from "./mobileViewport";
6
+ export {
7
+ sanitizeHistoryUrl,
8
+ installHistorySafetyNet,
9
+ type HistorySafetyNetOptions,
10
+ type HistoryCoercion,
11
+ } from "./historySafetyNet";
12
+ export {
13
+ createPoisonedLocationGuard,
14
+ installNavigationSafetyNet,
15
+ type NavigationSafetyNetOptions,
16
+ type GuardRouter,
17
+ type GuardRoute,
18
+ } from "./navigationGuard";
19
+ export { createAuthGuard, type AuthGuardOptions } from "./createAuthGuard";
6
20
  export {
7
21
  createBackGuard,
8
22
  BACK_GUARD_MARKER,
@@ -0,0 +1,107 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ createPoisonedLocationGuard,
5
+ installNavigationSafetyNet,
6
+ type GuardRoute,
7
+ } from "./navigationGuard";
8
+
9
+ /** Minimal router double: records guards, lets tests drive them. */
10
+ function makeRouter() {
11
+ const guards: Array<(to: GuardRoute) => unknown> = [];
12
+ const router = {
13
+ beforeEach: (fn: (to: GuardRoute) => unknown) => { guards.push(fn); },
14
+ };
15
+ return {
16
+ router,
17
+ run: (to: GuardRoute) => {
18
+ expect(guards.length).toBeGreaterThan(0);
19
+ return guards[guards.length - 1](to);
20
+ },
21
+ };
22
+ }
23
+
24
+ describe("createPoisonedLocationGuard", () => {
25
+ it("allows in-app absolute paths through", () => {
26
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
27
+ const r = makeRouter();
28
+ createPoisonedLocationGuard(r.router);
29
+ expect(r.run({ path: "/backend", fullPath: "/backend#x" })).toBe(true);
30
+ expect(r.run({ path: "/@ws", fullPath: "/@ws" })).toBe(true);
31
+ expect(warn).not.toHaveBeenCalled();
32
+ warn.mockRestore();
33
+ });
34
+
35
+ it("folds non-slash-rooted paths (the stringified-undefined class)", () => {
36
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
37
+ const r = makeRouter();
38
+ createPoisonedLocationGuard(r.router);
39
+ expect(r.run({ path: "undefined", fullPath: "undefined" })).toBe("/");
40
+ expect(r.run({ path: "https://evil.example/x", fullPath: "https://evil.example/x" })).toBe("/");
41
+ // A "//evil" PATH is not the open-redirect form once the router
42
+ // composes it (origin + "//evil…" = a same-origin double-slash
43
+ // path); the open-redirect threat lives at the location.assign
44
+ // layer, where safeRedirect-style producers reject it.
45
+ expect(r.run({ path: "//evil.example/x", fullPath: "//evil.example/x" })).toBe(true);
46
+ expect(warn).toHaveBeenCalled();
47
+ warn.mockRestore();
48
+ });
49
+
50
+ it("folds the bare poisoned literals but keeps deeper paths", () => {
51
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
52
+ const r = makeRouter();
53
+ createPoisonedLocationGuard(r.router);
54
+ expect(r.run({ path: "undefined" })).toBe("/");
55
+ expect(r.run({ path: "null" })).toBe("/");
56
+ expect(r.run({ path: "nan" })).toBe("/");
57
+ // "/undefined" stays a valid (matched-later) in-app path — the
58
+ // catch-all route owns it, same contract as the chest guard.
59
+ expect(r.run({ path: "/undefined" })).toBe(true);
60
+ warn.mockRestore();
61
+ });
62
+
63
+ it("honors a custom fallback", () => {
64
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
65
+ const r = makeRouter();
66
+ createPoisonedLocationGuard(r.router, "/panel/home");
67
+ expect(r.run({ path: "undefined" })).toBe("/panel/home");
68
+ warn.mockRestore();
69
+ });
70
+
71
+ it("treats a missing path as poisoned (never trust undefined)", () => {
72
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
73
+ const r = makeRouter();
74
+ createPoisonedLocationGuard(r.router);
75
+ expect(r.run({ path: undefined as unknown as string, fullPath: "x" })).toBe("/");
76
+ warn.mockRestore();
77
+ });
78
+ });
79
+
80
+ describe("installNavigationSafetyNet", () => {
81
+ const nativePush = History.prototype.pushState;
82
+
83
+ afterEach(() => {
84
+ History.prototype.pushState = nativePush;
85
+ sessionStorage.clear();
86
+ });
87
+
88
+ it("installs both layers: history patch + router guard", () => {
89
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
90
+ const r = makeRouter();
91
+ installNavigationSafetyNet({ router: r.router });
92
+ // Router layer folds the poisoned target before history sees it.
93
+ expect(r.run({ path: "undefined" })).toBe("/");
94
+ // History layer still coerces direct off-origin producers.
95
+ expect(() => history.pushState(null, "", "https://evil.example/x")).not.toThrow();
96
+ expect(window.location.pathname).toBe("/");
97
+ warn.mockRestore();
98
+ });
99
+
100
+ it("works without a router (history-only install)", () => {
101
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
102
+ expect(() => installNavigationSafetyNet({})).not.toThrow();
103
+ expect(() => history.pushState(null, "", "https://evil.example/y")).not.toThrow();
104
+ expect(window.location.pathname).toBe("/");
105
+ warn.mockRestore();
106
+ });
107
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Poisoned-location router guard (family runtime).
3
+ *
4
+ * Companion to {@link installHistorySafetyNet}: that net folds what
5
+ * REACHES the history layer; this guard folds what reaches the ROUTER
6
+ * first, so a poisoned target dies as an in-app redirect (landing
7
+ * route) instead of ever composing a URL at all. Both layers share the
8
+ * same literal set — defense in depth, with the guard giving the nicer
9
+ * UX (no address-bar flicker) and the net giving the structural
10
+ * guarantee (covers producers that bypass the router entirely).
11
+ *
12
+ * Upstreamed from shittim-chest #622/#754.
13
+ */
14
+
15
+ import { installHistorySafetyNet, type HistorySafetyNetOptions } from "./historySafetyNet";
16
+
17
+ /** Bare literals that read as a producer bug, never a real target. */
18
+ const POISONED_LITERALS = new Set(["undefined", "null", "nan"]);
19
+
20
+ /** Structural slice of vue-router's Router — the guard only hooks
21
+ * beforeEach, so any router-shaped object works (no vue-router dep:
22
+ * hikari stays UI-library-scoped). */
23
+ export interface GuardRouter {
24
+ beforeEach: (fn: (to: GuardRoute, ...rest: unknown[]) => unknown) => void;
25
+ }
26
+
27
+ export interface GuardRoute {
28
+ path?: string | null;
29
+ fullPath?: string;
30
+ }
31
+
32
+ export interface NavigationSafetyNetOptions extends HistorySafetyNetOptions {
33
+ /** Route shape the guard registers against. The History patch
34
+ * installs even without a router (direct pushState producers). */
35
+ router?: GuardRouter;
36
+ }
37
+
38
+ /** Register the beforeEach poisoned-target fold on a router. */
39
+ export function createPoisonedLocationGuard(
40
+ router: GuardRouter,
41
+ fallback = "/",
42
+ ): void {
43
+ router.beforeEach((to: GuardRoute) => {
44
+ const path = to.path ?? "";
45
+ if (!path.startsWith("/") || POISONED_LITERALS.has(path)) {
46
+ console.warn(
47
+ `[Router] Blocked poisoned navigation target "${path}" (fullPath "${to.fullPath}") — redirecting to "${fallback}"`,
48
+ );
49
+ return fallback;
50
+ }
51
+ return true;
52
+ });
53
+ }
54
+
55
+ /** One-call navigation safety: the History-prototype net plus the
56
+ * router-level poisoned-target guard. Install before the first
57
+ * navigation (module scope of the app's router setup is ideal). */
58
+ export function installNavigationSafetyNet(options: NavigationSafetyNetOptions = {}): void {
59
+ installHistorySafetyNet(options);
60
+ if (options.router) createPoisonedLocationGuard(options.router, options.fallback ?? "/");
61
+ }