Compare commits

..
33 changed files with 5408 additions and 445 deletions
+13
View File
@@ -0,0 +1,13 @@
# NestQuest Admin PWA — runtime configuration (Vite env).
# Copy to .env (or .env.local) and fill in real values. NEVER commit .env.
# Authentik OIDC provider. Defaults to the issuer below when unset.
VITE_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/
# REQUIRED. OAuth2/OIDC client id of the PUBLIC (PKCE) Authentik application.
# No default: the app shows a configuration error screen when this is missing.
# Never set a client_secret — this is a browser SPA using PKCE only.
VITE_AUTHENTIK_CLIENT_ID=
# NestQuest API base URL.
VITE_API_BASE_URL=https://nestquest.cubecraftlabs.com
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#3b3ab8" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>NestQuest Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3024
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "nestquest-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"jsdom": "^30.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5",
"vitest": "^3.2.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"name": "NestQuest Admin",
"short_name": "NestQuest",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f4f4f6",
"theme_color": "#3b3ab8",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+88
View File
@@ -0,0 +1,88 @@
/* NestQuest Admin service worker — hand-written, served from the site root.
Precaches the app shell, serves static assets cache-first, and falls back
to the cached shell for navigations when offline. Only same-origin GET
requests are handled; cross-origin requests (Authentik, the API host) are
never intercepted or cached.
The build (vite-sw-plugin.ts) replaces PRECACHE_ASSETS with the hashed
bundles referenced by index.html and the revision marker with a content
hash, so each release installs a new worker with fresh caches. */
const REVISION = "__NQ_BUILD_REVISION__";
const CACHE_PREFIX = "nestquest-admin-";
const SHELL_CACHE = `${CACHE_PREFIX}shell-${REVISION}`;
const RUNTIME_CACHE = `${CACHE_PREFIX}runtime-${REVISION}`;
const CURRENT_CACHES = [SHELL_CACHE, RUNTIME_CACHE];
const PRECACHE_ASSETS = [];
const SHELL_URLS = [
...new Set([
"/",
"/index.html",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/icons/icon-maskable-512.png",
...PRECACHE_ASSETS,
]),
];
// Request destinations treated as static assets (cache-first). Anything else
// same-origin (e.g. API calls) goes straight to the network.
const STATIC_DESTINATIONS = ["script", "style", "image", "font", "manifest"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(SHELL_CACHE)
.then((cache) => cache.addAll(SHELL_URLS))
.then(() => self.skipWaiting()),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key.startsWith(CACHE_PREFIX) && !CURRENT_CACHES.includes(key))
.map((key) => caches.delete(key)),
),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
if (new URL(request.url).origin !== self.location.origin) return;
if (request.mode === "navigate") {
event.respondWith(
fetch(request).catch(() =>
caches.match("/index.html", { cacheName: SHELL_CACHE }).then(
(cached) => cached || Response.error(),
),
),
);
return;
}
if (STATIC_DESTINATIONS.includes(request.destination)) {
event.respondWith(cacheFirst(request));
}
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok && response.type === "basic") {
const cache = await caches.open(RUNTIME_CACHE);
await cache.put(request, response.clone());
}
return response;
}
+80
View File
@@ -0,0 +1,80 @@
// Generates the committed PWA icons under public/icons/ as genuine PNGs.
// Pure Node (zlib only) — no network, no image dependencies.
// Run: node scripts/generate-icons.mjs
import { writeFileSync } from "node:fs";
import { deflateSync } from "node:zlib";
import { fileURLToPath } from "node:url";
// Brand tokens (design/tokens/nestquest-admin-tokens.css).
const BLUE = [0x3b, 0x3a, 0xb8];
const PURPLE = [0xa0, 0x35, 0xcc];
const WHITE = [0xff, 0xff, 0xff];
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(buf) {
let c = 0xffffffff;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
}
function encodePng(size, pixel) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // colour type: RGB
const raw = Buffer.alloc(size * (size * 3 + 1));
for (let y = 0; y < size; y++) {
const row = y * (size * 3 + 1);
raw[row] = 0; // filter: none
for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest")
// around a white dot ("quest"). `scale` shrinks the mark for the maskable
// safe zone (inner 80% circle).
function brandMark(size, scale) {
const c = size / 2;
const outer = size * 0.34 * scale;
const inner = size * 0.24 * scale;
const dot = size * 0.1 * scale;
return (x, y) => {
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
if ((d <= outer && d >= inner) || d <= dot) return WHITE;
const t = (x + y) / (2 * (size - 1));
return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t));
};
}
const outDir = new URL("../public/icons/", import.meta.url);
const icons = [
["icon-192.png", 192, 1],
["icon-512.png", 512, 1],
["icon-maskable-512.png", 512, 0.75],
];
for (const [name, size, scale] of icons) {
const path = fileURLToPath(new URL(name, outDir));
writeFileSync(path, encodePng(size, brandMark(size, scale)));
console.log(`wrote ${path}`);
}
+117
View File
@@ -0,0 +1,117 @@
/* Shell layout — mobile-first (390x844 reference), plain CubeCraft styling. */
.admin-shell {
display: flex;
flex-direction: column;
min-height: 100%;
}
.admin-header {
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
padding: 14px 16px;
}
.admin-title {
margin: 0;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.02em;
}
.admin-subline {
margin: 2px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.admin-panel {
flex: 1;
padding: 14px 16px 92px; /* bottom padding clears the 70px tab bar */
}
.admin-placeholder {
margin: 0;
padding: 13px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: 8px;
font-size: 13.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.admin-tabbar {
position: fixed;
inset: auto 0 0;
display: flex;
min-height: calc(var(--nq-a-tabbar-height) + 12px);
padding: 8px 4px calc(12px + env(safe-area-inset-bottom));
background: var(--nq-a-surface);
border-top: 1px solid var(--nq-a-border);
}
.admin-tab {
flex: 1;
border: 0;
background: none;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
cursor: pointer;
padding: 6px 4px;
}
.admin-tab.active {
font-weight: 700;
color: var(--nq-brand-blue);
}
/* Auth screens (login, errors, refusal). */
.admin-login,
.admin-error,
.admin-refusal,
.admin-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 100%;
padding: 32px 16px;
text-align: center;
}
.admin-login-button {
margin-top: 8px;
padding: 12px 24px;
border: none;
border-radius: 8px;
background: var(--nq-a-accent, #2563eb);
color: #fff;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.admin-error,
.admin-refusal {
color: var(--nq-a-ink, inherit);
}
.admin-refusal h1,
.admin-error h1 {
margin: 0;
font-size: 20px;
}
.admin-refusal p,
.admin-error p {
margin: 0;
max-width: 34em;
font-size: 14px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
+33
View File
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import App from "./App";
describe("NestQuest Admin shell", () => {
afterEach(cleanup);
it("renders the title and all four tab labels", () => {
render(<App />);
expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
}
});
it("starts on the Today placeholder and switches on tab click", () => {
render(<App />);
expect(screen.getByRole("tabpanel").textContent).toContain(
"Today placeholder",
);
fireEvent.click(screen.getByRole("tab", { name: "History" }));
expect(screen.getByRole("tabpanel").textContent).toContain(
"History placeholder",
);
expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe(
"true",
);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { useState } from "react";
import "./App.css";
const TABS = [
{ id: "today", label: "Today" },
{ id: "tasks", label: "Tasks" },
{ id: "schedule", label: "Schedule" },
{ id: "history", label: "History" },
] as const;
type TabId = (typeof TABS)[number]["id"];
export default function App() {
const [active, setActive] = useState<TabId>("today");
return (
<div className="admin-shell">
<header className="admin-header">
<h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Admin console</p>
</header>
<main
className="admin-panel"
role="tabpanel"
aria-labelledby={`tab-${active}`}
>
<p className="admin-placeholder">
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
arrive in later tasks.
</p>
</main>
<nav className="admin-tabbar" aria-label="Admin sections">
{TABS.map((tab) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
type="button"
role="tab"
aria-selected={tab.id === active}
className={tab.id === active ? "admin-tab active" : "admin-tab"}
onClick={() => setActive(tab.id)}
>
{tab.label}
</button>
))}
</nav>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import AppRoutes from "./AppRoutes";
import { storeTokens } from "./auth/oidc";
import { config } from "./config";
// The bundled config reads real env at import time; seed the shared module's
// clientId directly so AppRoutes sees a configured app in these tests.
(config as { clientId: string | undefined }).clientId = "test-client-id";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("AppRoutes auth gating", () => {
afterEach(() => {
cleanup();
sessionStorage.clear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
window.history.replaceState({}, "", "/");
});
it("renders the login screen when unauthenticated at /", () => {
render(<AppRoutes />);
expect(screen.getByTestId("login-screen")).toBeTruthy();
expect(
screen.getByRole("button", { name: /sign in with authentik/i }),
).toBeTruthy();
});
it("renders the four-tab shell after a successful authenticated probe", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
});
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
}
});
it("renders the explicit refusal when the API answers 403", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByTestId("refusal-screen")).toBeTruthy();
});
expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy();
});
it("shows the configuration error screen when the client id is missing", () => {
const original = config.clientId;
(config as { clientId: string | undefined }).clientId = undefined;
try {
render(<AppRoutes />);
expect(screen.getByTestId("config-error-screen")).toBeTruthy();
} finally {
(config as { clientId: string | undefined }).clientId = original;
}
});
});
+176
View File
@@ -0,0 +1,176 @@
/**
* Minimal path-based routing without react-router-dom: the app only needs two
* paths (`/` shell and `/auth/callback`), so a tiny window.location switch is
* enough and keeps the bundle small. Change screens listen to popstate.
*/
import { useEffect, useState } from "react";
import { ConfigError, config } from "./config";
import {
AuthError,
buildAuthorizeUrl,
clearAuth,
handleCallback,
isAuthenticated,
} from "./auth/oidc";
import { ApiForbiddenError, apiFetch } from "./api/client";
import AdminShell from "./App";
type Screen =
| { kind: "loading" }
| { kind: "login" }
| { kind: "config-error"; message: string }
| { kind: "auth-error"; message: string }
| { kind: "refusal"; message: string }
| { kind: "app" };
function currentPath(): string {
return window.location.pathname.replace(/\/+$/, "") || "/";
}
async function probeApi(setScreen: (screen: Screen) => void) {
// A cheap authenticated probe so a 403 surfaces as an explicit refusal
// right after login instead of a silent empty shell.
try {
await apiFetch("/api/v1/children", {}, config);
setScreen({ kind: "app" });
} catch (error) {
if (error instanceof ConfigError) {
setScreen({ kind: "config-error", message: error.message });
return;
}
if (error instanceof ApiForbiddenError) {
setScreen({ kind: "refusal", message: error.message });
return;
}
// Network or other failures still allow the shell to render; API screens
// handle their own errors in later tasks.
setScreen({ kind: "app" });
}
}
function LoginScreen({ onLogin }: { onLogin: () => void }) {
return (
<div className="admin-login" data-testid="login-screen">
<h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Sign in to continue</p>
<button type="button" className="admin-login-button" onClick={onLogin}>
Sign in with Authentik
</button>
</div>
);
}
export default function AppRoutes() {
const [screen, setScreen] = useState<Screen>({ kind: "loading" });
useEffect(() => {
// Re-evaluate on navigation (popstate) — routing is path based.
const onPop = () => setScreen({ kind: "loading" });
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
useEffect(() => {
let cancelled = false;
const path = currentPath();
if (!config.clientId) {
setScreen({
kind: "config-error",
message:
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
});
return;
}
if (path === "/auth/callback") {
(async () => {
try {
await handleCallback(window.location.search, config);
if (cancelled) return;
window.history.replaceState({}, "", "/");
await probeApi((next) => !cancelled && setScreen(next));
} catch (error) {
if (cancelled) return;
const message =
error instanceof AuthError || error instanceof Error
? error.message
: "Authentication failed.";
setScreen({ kind: "auth-error", message });
}
})();
return () => {
cancelled = true;
};
}
if (isAuthenticated()) {
void probeApi((next) => !cancelled && setScreen(next));
} else {
setScreen({ kind: "login" });
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startLogin = async () => {
try {
const url = await buildAuthorizeUrl(config);
window.location.assign(url);
} catch (error) {
setScreen({
kind: "config-error",
message: error instanceof Error ? error.message : "Login failed.",
});
}
};
switch (screen.kind) {
case "loading":
return (
<div className="admin-loading" role="status" data-testid="loading-screen">
Loading…
</div>
);
case "login":
return <LoginScreen onLogin={startLogin} />;
case "config-error":
return (
<div className="admin-error" role="alert" data-testid="config-error-screen">
<h1>Configuration error</h1>
<p>{screen.message}</p>
</div>
);
case "auth-error":
return (
<div className="admin-error" role="alert" data-testid="auth-error-screen">
<h1>Sign-in failed</h1>
<p>{screen.message}</p>
<button
type="button"
onClick={() => {
clearAuth();
window.history.replaceState({}, "", "/");
setScreen({ kind: "loading" });
}}
>
Try again
</button>
</div>
);
case "refusal":
return (
<div className="admin-refusal" role="alert" data-testid="refusal-screen">
<h1>Access refused</h1>
<p>{screen.message}</p>
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
Sign out
</button>
</div>
);
case "app":
return <AdminShell />;
}
}
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ApiForbiddenError,
ApiUnauthorizedError,
apiFetch,
} from "./client";
import { storeTokens } from "../auth/oidc";
import type { AppConfig } from "../config";
const app: AppConfig = {
issuer: "https://auth.example.com/application/o/nestquest",
clientId: "test-client-id",
apiBaseUrl: "https://api.example.com",
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
tokenEndpoint: "https://auth.example.com/application/o/token/",
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
redirectUri: "http://localhost:5173/auth/callback",
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("API client", () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("attaches the Bearer token to API calls", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
await apiFetch("/api/v1/children", {}, app, fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://api.example.com/api/v1/children");
expect(init.headers.Authorization).toBe("Bearer at-123");
});
it("throws ApiForbiddenError on 403 (authenticated but refused)", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
await expect(
apiFetch("/api/v1/children", {}, app, fetchMock as any),
).rejects.toBeInstanceOf(ApiForbiddenError);
});
it("triggers a login redirect on 401 with no refresh token", async () => {
storeTokens({ access_token: "stale", expires_in: 600 });
const assign = vi.fn();
vi.stubGlobal("location", { ...window.location, assign });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
await expect(
apiFetch("/api/v1/children", {}, app, fetchMock as any),
).rejects.toBeInstanceOf(ApiUnauthorizedError);
expect(assign).toHaveBeenCalledTimes(1);
const url = new URL(assign.mock.calls[0][0] as string);
expect(url.origin + url.pathname).toBe(
"https://auth.example.com/application/o/authorize/",
);
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
});
it("preserves non-401/403 statuses on the post-refresh retry (500 is not mislabeled)", async () => {
storeTokens({ access_token: "stale", refresh_token: "rt-1", expires_in: 600 });
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("", { status: 401 })) // initial API call
.mockResolvedValueOnce(
jsonResponse({ access_token: "fresh", expires_in: 600 }), // refresh grant
)
.mockResolvedValueOnce(new Response("", { status: 500 })); // retry
const response = await apiFetch("/api/v1/children", {}, app, fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls[1][0]).toBe(app.tokenEndpoint);
expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe("Bearer fresh");
// A server failure must surface as the real status, not ApiUnauthorizedError.
expect(response.status).toBe(500);
});
});
+93
View File
@@ -0,0 +1,93 @@
/**
* Minimal fetch wrapper for the NestQuest API.
*
* - Attaches `Authorization: Bearer <access_token>` when a token is stored.
* - 401: not authenticated (or token expired) -> trigger a new login.
* - 403: authenticated but refused -> throws ApiForbiddenError so the UI can
* render an explicit refusal message.
*/
import { config, type AppConfig } from "../config";
import {
buildAuthorizeUrl,
getToken,
isExpired,
refreshAccessToken,
} from "../auth/oidc";
export class ApiForbiddenError extends Error {
constructor(message = "Your account is not in the nestquest-admins group.") {
super(message);
this.name = "ApiForbiddenError";
}
}
export class ApiUnauthorizedError extends Error {
constructor(message = "Not authenticated.") {
super(message);
this.name = "ApiUnauthorizedError";
}
}
export interface ApiRequestOptions extends Omit<RequestInit, "headers"> {
headers?: Record<string, string>;
}
/**
* Redirect the browser to Authentik to start a new login.
* Exported for testing injection.
*/
export async function triggerLogin(app: AppConfig = config): Promise<void> {
const url = await buildAuthorizeUrl(app);
window.location.assign(url);
}
export async function apiFetch(
path: string,
options: ApiRequestOptions = {},
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<Response> {
if (isExpired() && (await refreshAccessToken(app, fetchImpl))) {
// token renewed; fall through and use the fresh one
}
const token = getToken();
const headers = {
...(options.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const response = await fetchImpl(`${app.apiBaseUrl}${path}`, {
...options,
headers,
});
if (response.status === 401) {
// Not authenticated: either no token or an unusable one.
if (await refreshAccessToken(app, fetchImpl)) {
const retryToken = getToken();
const retryResponse = await fetchImpl(`${app.apiBaseUrl}${path}`, {
...options,
headers: {
...(options.headers ?? {}),
...(retryToken ? { Authorization: `Bearer ${retryToken}` } : {}),
},
});
if (retryResponse.status === 403) throw new ApiForbiddenError();
if (retryResponse.ok) return retryResponse;
// Only 401 means "unauthorized"; preserve every other status (e.g. a
// 500 from the API) so callers can react to the real response.
if (retryResponse.status === 401) throw new ApiUnauthorizedError();
return retryResponse;
}
await triggerLogin(app);
throw new ApiUnauthorizedError();
}
if (response.status === 403) {
// Authenticated, but Authentik's token does not belong to nestquest-admins.
throw new ApiForbiddenError();
}
return response;
}
+173
View File
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildAuthorizeUrl,
clearAuth,
getToken,
handleCallback,
logout,
storeTokens,
} from "./oidc";
import type { AppConfig } from "../config";
function testApp(): AppConfig {
return {
issuer: "https://auth.example.com/application/o/nestquest",
clientId: "test-client-id",
apiBaseUrl: "https://api.example.com",
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
tokenEndpoint: "https://auth.example.com/application/o/token/",
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
redirectUri: "http://localhost:5173/auth/callback",
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("OIDC flow", () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("buildAuthorizeUrl carries all required params and stores state/verifier", async () => {
const app = testApp();
const url = new URL(await buildAuthorizeUrl(app));
expect(url.origin + url.pathname).toBe(app.authorizeEndpoint);
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("client_id")).toBe("test-client-id");
expect(url.searchParams.get("redirect_uri")).toBe(app.redirectUri);
expect(url.searchParams.get("scope")).toBe("openid email profile");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(url.searchParams.get("state")).toMatch(/^[A-Za-z0-9_-]+$/);
expect(sessionStorage.getItem("nestquest.oidc_state")).toBe(
url.searchParams.get("state"),
);
expect(sessionStorage.getItem("nestquest.code_verifier")).toMatch(/^[A-Za-z0-9-._~]{43,128}$/);
});
it("handleCallback rejects a mismatched state", async () => {
await buildAuthorizeUrl(testApp());
sessionStorage.setItem("nestquest.oidc_state", "different-state");
const fetchMock = vi.fn();
await expect(
handleCallback("?code=abc&state=tampered", testApp(), fetchMock as any),
).rejects.toThrow(/state mismatch/i);
expect(fetchMock).not.toHaveBeenCalled();
expect(getToken()).toBeUndefined();
});
it("handleCallback exchanges the code with the correct form body and stores the token", async () => {
await buildAuthorizeUrl(testApp());
const verifier = sessionStorage.getItem("nestquest.code_verifier")!;
const state = sessionStorage.getItem("nestquest.oidc_state")!;
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
access_token: "at-123",
refresh_token: "rt-456",
id_token: "id-789",
expires_in: 300,
}),
);
await handleCallback(`?code=abc&state=${state}`, testApp(), fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://auth.example.com/application/o/token/");
expect(init.method).toBe("POST");
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
const body = new URLSearchParams(init.body);
expect(body.get("grant_type")).toBe("authorization_code");
expect(body.get("code")).toBe("abc");
expect(body.get("redirect_uri")).toBe(testApp().redirectUri);
expect(body.get("client_id")).toBe("test-client-id");
expect(body.get("code_verifier")).toBe(verifier);
expect(body.has("client_secret")).toBe(false);
expect(getToken()).toBe("at-123");
// one-time values consumed
expect(sessionStorage.getItem("nestquest.oidc_state")).toBeNull();
expect(sessionStorage.getItem("nestquest.code_verifier")).toBeNull();
});
it("handleCallback surfaces provider errors", async () => {
await expect(
handleCallback("?error=access_denied&error_description=nope", testApp(), vi.fn() as any),
).rejects.toThrow(/nope|access_denied/);
});
it("clearAuth removes all stored auth data", () => {
storeTokens({ access_token: "a", refresh_token: "r", expires_in: 60 });
clearAuth();
expect(getToken()).toBeUndefined();
expect(sessionStorage.length).toBe(0);
});
it("logout clears auth and redirects to the end-session endpoint", async () => {
storeTokens({ access_token: "a", id_token: "idt", expires_in: 60 });
const assign = vi.fn();
const original = window.location;
vi.stubGlobal("location", { ...original, assign, origin: "http://localhost:5173" });
await logout(testApp());
expect(assign).toHaveBeenCalledTimes(1);
const url = new URL(assign.mock.calls[0][0] as string);
expect(url.toString()).toBe(
"https://auth.example.com/application/o/nestquest/end-session/?id_token_hint=idt&client_id=test-client-id&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173",
);
expect(getToken()).toBeUndefined();
vi.unstubAllGlobals();
});
it("buildAuthorizeUrl resolves the authorize endpoint via discovery for a non-default issuer", async () => {
const issuer = "https://sso.discovery.example/application/o/discovered";
const discoveryDoc = {
issuer,
authorization_endpoint: "https://sso.discovery.example/application/o/authorize/",
token_endpoint: "https://sso.discovery.example/application/o/token/",
userinfo_endpoint: "https://sso.discovery.example/application/o/userinfo/",
end_session_endpoint: `${issuer}/end-session/`,
jwks_uri: `${issuer}/jwks/`,
};
const fetchMock = vi.fn().mockImplementation((url: string) =>
Promise.resolve(
String(url).endsWith("/.well-known/openid-configuration")
? jsonResponse(discoveryDoc)
: jsonResponse({}),
),
);
const app: AppConfig = {
issuer,
clientId: "test-client-id",
apiBaseUrl: "https://api.example.com",
redirectUri: "http://localhost:5173/auth/callback",
};
const url = new URL(await buildAuthorizeUrl(app, fetchMock as any));
// The authorize endpoint comes from the discovery document — Authentik
// serves it outside the app-specific issuer path, so it is NOT
// `${issuer}/authorize/` and must never be a hardcoded production host.
expect(url.origin + url.pathname).toBe(discoveryDoc.authorization_endpoint);
expect(url.searchParams.get("client_id")).toBe("test-client-id");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0][0])).toBe(
`${issuer}/.well-known/openid-configuration`,
);
});
});
+225
View File
@@ -0,0 +1,225 @@
/**
* OIDC authorization-code + PKCE flow for a PUBLIC client.
*
* Never sends a client_secret anywhere. Tokens are stored in sessionStorage
* (per-tab, cleared on tab close) — never localStorage.
*/
import { config, resolveEndpoints, type AppConfig, type ResolvedAppConfig } from "../config";
import {
computeCodeChallenge,
generateCodeVerifier,
generateState,
} from "./pkce";
const STORAGE_KEYS = {
accessToken: "nestquest.access_token",
refreshToken: "nestquest.refresh_token",
expiresAt: "nestquest.expires_at",
idToken: "nestquest.id_token",
state: "nestquest.oidc_state",
codeVerifier: "nestquest.code_verifier",
} as const;
export class AuthError extends Error {}
export interface TokenResponse {
access_token?: string;
refresh_token?: string;
id_token?: string;
expires_in?: number;
token_type?: string;
}
function makeScopes(): string {
return "openid email profile";
}
/**
* Build the Authentik authorization redirect URL, storing state + verifier in
* sessionStorage so the callback can validate them. Endpoints are resolved
* from the issuer's OIDC discovery document when not set on the config.
*/
export async function buildAuthorizeUrl(
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<string> {
if (!app.clientId) {
throw new AuthError(
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
);
}
const resolved = await resolveEndpoints(app, fetchImpl);
const state = generateState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = await computeCodeChallenge(codeVerifier);
sessionStorage.setItem(STORAGE_KEYS.state, state);
sessionStorage.setItem(STORAGE_KEYS.codeVerifier, codeVerifier);
const url = new URL(resolved.authorizeEndpoint);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", app.clientId);
url.searchParams.set("redirect_uri", app.redirectUri);
url.searchParams.set("scope", makeScopes());
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}
/**
* Handle the OAuth callback: validate state, exchange the code at the token
* endpoint (PKCE, form-urlencoded, no client_secret), store tokens.
*/
export async function handleCallback(
query: URLSearchParams | string = window.location.search,
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
const params =
typeof query === "string" ? new URLSearchParams(query) : query;
const providerError = params.get("error");
if (providerError) {
throw new AuthError(
params.get("error_description") || `Provider returned error: ${providerError}`,
);
}
const code = params.get("code");
const state = params.get("state");
if (!code || !state) {
throw new AuthError("Callback is missing the code or state parameter.");
}
const storedState = sessionStorage.getItem(STORAGE_KEYS.state);
const codeVerifier = sessionStorage.getItem(STORAGE_KEYS.codeVerifier);
if (!storedState || storedState !== state || !codeVerifier) {
throw new AuthError("State mismatch — possible CSRF; refusing to log in.");
}
if (!app.clientId) {
throw new AuthError(
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
);
}
const resolved: ResolvedAppConfig = await resolveEndpoints(app, fetchImpl);
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: app.redirectUri,
client_id: app.clientId,
code_verifier: codeVerifier,
});
const response = await fetchImpl(resolved.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!response.ok) {
throw new AuthError(`Token exchange failed with HTTP ${response.status}`);
}
const tokens = (await response.json()) as TokenResponse;
if (!tokens.access_token) {
throw new AuthError("Token response did not include an access token.");
}
storeTokens(tokens);
// One-time use values are consumed on success.
sessionStorage.removeItem(STORAGE_KEYS.state);
sessionStorage.removeItem(STORAGE_KEYS.codeVerifier);
}
export function storeTokens(tokens: TokenResponse): void {
sessionStorage.setItem(STORAGE_KEYS.accessToken, tokens.access_token!);
if (tokens.refresh_token) {
sessionStorage.setItem(STORAGE_KEYS.refreshToken, tokens.refresh_token);
}
if (tokens.id_token) {
sessionStorage.setItem(STORAGE_KEYS.idToken, tokens.id_token);
}
if (typeof tokens.expires_in === "number") {
sessionStorage.setItem(
STORAGE_KEYS.expiresAt,
String(Date.now() + tokens.expires_in * 1000),
);
}
}
export function getToken(): string | undefined {
return sessionStorage.getItem(STORAGE_KEYS.accessToken) ?? undefined;
}
export function getRefreshToken(): string | undefined {
return sessionStorage.getItem(STORAGE_KEYS.refreshToken) ?? undefined;
}
export function getIdToken(): string | undefined {
return sessionStorage.getItem(STORAGE_KEYS.idToken) ?? undefined;
}
export function isAuthenticated(): boolean {
return Boolean(getToken());
}
/** True once the stored access token has passed its (approximate) expiry. */
export function isExpired(): boolean {
const expiresAt = sessionStorage.getItem(STORAGE_KEYS.expiresAt);
if (!expiresAt) return false;
return Date.now() >= Number(expiresAt);
}
/**
* Refresh the access token with the refresh token grant. Returns false when
* no refresh token is stored or the grant fails (caller should re-login).
*/
export async function refreshAccessToken(
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!refreshToken || !app.clientId) return false;
const resolved = await resolveEndpoints(app, fetchImpl);
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: app.clientId,
});
const response = await fetchImpl(resolved.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!response.ok) return false;
const tokens = (await response.json()) as TokenResponse;
if (!tokens.access_token) return false;
storeTokens(tokens);
return true;
}
export function clearAuth(): void {
for (const key of Object.values(STORAGE_KEYS)) {
sessionStorage.removeItem(key);
}
}
/** Sign out: clear local auth, then send the browser to the end-session endpoint. */
export async function logout(
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
const resolved = await resolveEndpoints(app, fetchImpl);
const idTokenHint = getIdToken();
clearAuth();
const params = new URLSearchParams();
if (idTokenHint) params.set("id_token_hint", idTokenHint);
if (app.clientId) params.set("client_id", app.clientId);
params.set("post_logout_redirect_uri", window.location.origin);
window.location.assign(`${resolved.endSessionEndpoint}?${params.toString()}`);
}
+41
View File
@@ -0,0 +1,41 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
base64UrlEncode,
computeCodeChallenge,
generateCodeVerifier,
generateState,
isLikelyValidVerifier,
} from "./pkce";
describe("PKCE helpers", () => {
it("base64UrlEncode has no padding and url-safe characters", () => {
// bytes chosen to force "+" and "/" and padding in plain base64
const bytes = new Uint8Array([0xfb, 0xff, 0xbf, 0xff]);
const encoded = base64UrlEncode(bytes);
expect(encoded).not.toMatch(/[+/=]/);
});
it("computes the RFC 7636 appendix-B S256 vector", async () => {
const verifier =
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const challenge = await computeCodeChallenge(verifier);
expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
});
it("generates verifiers with valid charset and length", () => {
for (let i = 0; i < 20; i++) {
const verifier = generateCodeVerifier();
expect(isLikelyValidVerifier(verifier)).toBe(true);
}
});
it("rejects verifiers outside 43-128 chars or with reserved chars", () => {
expect(isLikelyValidVerifier("short")).toBe(false);
expect(isLikelyValidVerifier("a".repeat(129))).toBe(false);
expect(isLikelyValidVerifier("a".repeat(43) + "+")).toBe(false);
});
it("generates distinct random states", () => {
expect(generateState()).not.toBe(generateState());
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* PKCE (RFC 7636) helpers for a public SPA client.
*
* Uses the Web Crypto API, which requires a SECURE CONTEXT: the app must be
* served over https or http://localhost, otherwise crypto.subtle is
* unavailable and login cannot work.
*/
const VERIFIER_CHARSET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
/** base64url-encode bytes with NO padding. */
export function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function randomBase64Url(byteLength: number): string {
const bytes = new Uint8Array(byteLength);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
/**
* Random code_verifier: 43-128 chars, unreserved charset only.
* 32 random bytes -> 43 base64url chars, which is the minimum allowed length.
*/
export function generateCodeVerifier(): string {
return randomBase64Url(32);
}
/** S256 code_challenge = base64url(SHA-256(verifier)), no padding. */
export async function computeCodeChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
return base64UrlEncode(new Uint8Array(digest));
}
/** Random opaque state value for CSRF protection. */
export function generateState(): string {
return randomBase64Url(16);
}
export function isLikelyValidVerifier(verifier: string): boolean {
return (
verifier.length >= 43 &&
verifier.length <= 128 &&
[...verifier].every((char) => VERIFIER_CHARSET.includes(char))
);
}
+215
View File
@@ -0,0 +1,215 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
ConfigError,
discoveryUrlForIssuer,
readConfig,
resolveEndpoints,
resolveEndpointsFromDiscovery,
type AppConfig,
} from "./config";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
// Mirrors Authentik's layout: authorize/token/userinfo live OUTSIDE the
// application-specific issuer path, end-session/jwks under it.
function discoveryDocument(issuer: string) {
const normalized = issuer.replace(/\/+$/, "");
const origin = new URL(normalized).origin;
return {
issuer,
authorization_endpoint: `${origin}/application/o/authorize/`,
token_endpoint: `${origin}/application/o/token/`,
userinfo_endpoint: `${origin}/application/o/userinfo/`,
end_session_endpoint: `${normalized}/end-session/`,
jwks_uri: `${normalized}/jwks/`,
};
}
describe("config", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("readConfig keeps the production issuer as the default", () => {
const cfg = readConfig({});
expect(cfg.issuer).toBe("https://auth.cubecraftlabs.com/application/o/nestquest");
expect(cfg.clientId).toBeUndefined();
});
it("readConfig normalizes a trailing slash off a custom issuer", () => {
expect(
readConfig({ VITE_AUTHENTIK_ISSUER: "https://sso.custom.example/application/o/custom/" })
.issuer,
).toBe("https://sso.custom.example/application/o/custom");
});
it("readConfig hardcodes no OIDC endpoints — they come from discovery", () => {
const cfg = readConfig({});
expect(cfg.authorizeEndpoint).toBeUndefined();
expect(cfg.tokenEndpoint).toBeUndefined();
expect(cfg.userinfoEndpoint).toBeUndefined();
expect(cfg.endSessionEndpoint).toBeUndefined();
expect(cfg.jwksUri).toBeUndefined();
});
it("builds the discovery URL for issuers with and without a trailing slash", () => {
expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app/")).toBe(
"https://sso.example.com/application/o/app/.well-known/openid-configuration",
);
expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app")).toBe(
"https://sso.example.com/application/o/app/.well-known/openid-configuration",
);
});
it("resolves every endpoint from the discovery document of a non-default issuer", async () => {
const issuer = "https://sso.other-host.example/application/o/other-app";
const doc = discoveryDocument(issuer);
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
const app: AppConfig = {
issuer,
clientId: "client-id",
apiBaseUrl: "https://api.example.com",
redirectUri: "http://localhost:5173/auth/callback",
};
const resolved = await resolveEndpoints(app, fetchMock as unknown as typeof fetch);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://sso.other-host.example/application/o/other-app/.well-known/openid-configuration",
);
// Every endpoint comes verbatim from the (non-default) discovery document.
expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
expect(resolved.tokenEndpoint).toBe(doc.token_endpoint);
expect(resolved.userinfoEndpoint).toBe(doc.userinfo_endpoint);
expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
expect(resolved.jwksUri).toBe(doc.jwks_uri);
// Proves no hardcoded production host leaked into the resolved endpoints.
for (const endpoint of [
resolved.authorizeEndpoint,
resolved.tokenEndpoint,
resolved.userinfoEndpoint,
resolved.endSessionEndpoint,
resolved.jwksUri,
]) {
expect(endpoint).not.toMatch(/cubecraftlabs/);
}
});
it("resolves discovery for an issuer configured with a trailing slash", async () => {
const issuer = "https://sso.slashy.example/application/o/slashy/";
const doc = discoveryDocument(issuer);
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
const resolved = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
expect(fetchMock).toHaveBeenCalledWith(
"https://sso.slashy.example/application/o/slashy/.well-known/openid-configuration",
);
expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
});
it("caches discovery per issuer for the session", async () => {
const issuer = "https://sso.cached.example/application/o/cached";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
const first = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
const second = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
});
it("surfaces a clear configuration error when discovery returns an HTTP error", async () => {
const issuer = "https://sso.http-error.example/application/o/broken";
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
const error = await resolveEndpointsFromDiscovery(issuer, fetchMock as any).then(
() => null,
(error: unknown) => error,
);
expect(error).toBeInstanceOf(ConfigError);
expect((error as Error).message).toContain(issuer);
expect((error as Error).message).toContain("HTTP 500");
});
it("surfaces a clear configuration error when discovery is unreachable", async () => {
const issuer = "https://sso.unreachable.example/application/o/unreachable";
const fetchMock = vi.fn().mockRejectedValue(new TypeError("network down"));
await expect(
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
).rejects.toBeInstanceOf(ConfigError);
});
it("surfaces a clear configuration error when the discovery document is incomplete", async () => {
const issuer = "https://sso.incomplete.example/application/o/incomplete";
const doc = discoveryDocument(issuer) as Record<string, string>;
delete doc.end_session_endpoint;
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
await expect(
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
).rejects.toThrow(/end_session_endpoint/);
});
it("rejects a discovery document advertising a different issuer", async () => {
const issuer = "https://sso.mismatch.example/application/o/mismatch";
const doc = {
...discoveryDocument(issuer),
issuer: "https://sso.elsewhere.example/application/o/elsewhere",
};
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
await expect(
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
).rejects.toThrow(/issuer mismatch/i);
});
it("the bundled config resolves its endpoints from discovery at runtime", async () => {
const issuer = "https://sso.bundled.example/application/o/bundled";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
const bundled = readConfig({ VITE_AUTHENTIK_ISSUER: `${issuer}/`, VITE_AUTHENTIK_CLIENT_ID: "bundled-client" });
expect(bundled.authorizeEndpoint).toBeUndefined();
const resolved = await resolveEndpoints(bundled, fetchMock as any);
expect(resolved.authorizeEndpoint).toBe(
"https://sso.bundled.example/application/o/authorize/",
);
expect(resolved.tokenEndpoint).toBe("https://sso.bundled.example/application/o/token/");
expect(resolved.userinfoEndpoint).toBe(
"https://sso.bundled.example/application/o/userinfo/",
);
expect(resolved.endSessionEndpoint).toBe(
"https://sso.bundled.example/application/o/bundled/end-session/",
);
expect(resolved.jwksUri).toBe("https://sso.bundled.example/application/o/bundled/jwks/");
});
it("does not re-fetch discovery when the config already carries endpoints", async () => {
const app: AppConfig = {
issuer: "https://auth.example.com/application/o/nestquest",
clientId: "client-id",
apiBaseUrl: "https://api.example.com",
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
tokenEndpoint: "https://auth.example.com/application/o/token/",
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
redirectUri: "http://localhost:5173/auth/callback",
};
const fetchMock = vi.fn();
const resolved = await resolveEndpoints(app, fetchMock as any);
expect(fetchMock).not.toHaveBeenCalled();
expect(resolved).toBe(app);
});
});
+221
View File
@@ -0,0 +1,221 @@
/**
* Runtime configuration, read from Vite env (import.meta.env).
* See .env.example for the variables and their defaults.
*
* OIDC endpoints are NOT hardcoded here. They are resolved lazily from the
* issuer's OIDC discovery document (see resolveEndpoints), because Authentik
* serves authorize/token/userinfo OUTSIDE the application-specific issuer
* path (issuer is /application/o/<slug>/, authorize is /application/o/authorize/).
* Both hardcoding the production host and naively concatenating onto the
* issuer would break non-default issuers; discovery is the only correct way.
*/
export interface AppConfig {
issuer: string;
clientId: string | undefined;
apiBaseUrl: string;
/** Resolved from the issuer's OIDC discovery document — see resolveEndpoints(). */
authorizeEndpoint?: string;
tokenEndpoint?: string;
userinfoEndpoint?: string;
endSessionEndpoint?: string;
jwksUri?: string;
redirectUri: string;
}
/** AppConfig whose OIDC endpoints have been filled in by resolveEndpoints(). */
export type ResolvedAppConfig = AppConfig &
Required<
Pick<
AppConfig,
"authorizeEndpoint" | "tokenEndpoint" | "userinfoEndpoint" | "endSessionEndpoint"
>
>;
export interface DiscoveredEndpoints {
issuer: string;
authorizeEndpoint: string;
tokenEndpoint: string;
userinfoEndpoint: string;
endSessionEndpoint: string;
jwksUri: string;
}
/** Raised when OIDC discovery fails or the configuration is unusable. */
export class ConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "ConfigError";
}
}
const REQUIRED_ENDPOINT_NAMES = [
"authorizeEndpoint",
"tokenEndpoint",
"userinfoEndpoint",
"endSessionEndpoint",
] as const;
export function readConfig(env: Record<string, string | undefined> = import.meta.env as any): AppConfig {
const issuer = (
env.VITE_AUTHENTIK_ISSUER || "https://auth.cubecraftlabs.com/application/o/nestquest/"
).replace(/\/$/, "");
const clientId = env.VITE_AUTHENTIK_CLIENT_ID;
const apiBaseUrl = env.VITE_API_BASE_URL || "https://nestquest.cubecraftlabs.com";
return {
issuer,
// No default: a missing client id must surface as a configuration error
// screen, not a broken redirect to Authentik.
clientId: clientId || undefined,
apiBaseUrl,
// authorize/token/userinfo/end-session endpoints are resolved from the
// issuer's discovery document at runtime — never hardcoded here.
redirectUri: `${window.location.origin}/auth/callback`,
};
}
export const config: AppConfig = readConfig();
interface DiscoveryDocument {
issuer?: unknown;
authorization_endpoint?: unknown;
token_endpoint?: unknown;
userinfo_endpoint?: unknown;
end_session_endpoint?: unknown;
jwks_uri?: unknown;
}
function normalizeIssuer(issuer: string): string {
return issuer.replace(/\/+$/, "");
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/** Discovery document location for an issuer, tolerant of a trailing slash. */
export function discoveryUrlForIssuer(issuer: string): string {
return `${normalizeIssuer(issuer)}/.well-known/openid-configuration`;
}
async function fetchDiscoveredEndpoints(
issuer: string,
fetchImpl: typeof fetch,
): Promise<DiscoveredEndpoints> {
const normalizedIssuer = normalizeIssuer(issuer);
const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`;
let response: Response;
try {
response = await fetchImpl(discoveryUrl);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new ConfigError(
`OIDC discovery request to ${discoveryUrl} failed (${reason}). ` +
"Check VITE_AUTHENTIK_ISSUER and network reachability.",
);
}
if (!response.ok) {
throw new ConfigError(
`OIDC discovery failed: ${discoveryUrl} returned HTTP ${response.status}. ` +
"Check VITE_AUTHENTIK_ISSUER.",
);
}
let doc: DiscoveryDocument;
try {
doc = (await response.json()) as DiscoveryDocument;
} catch {
throw new ConfigError(
`OIDC discovery document at ${discoveryUrl} is not valid JSON. Check VITE_AUTHENTIK_ISSUER.`,
);
}
const authorizationEndpoint = nonEmptyString(doc.authorization_endpoint);
const tokenEndpoint = nonEmptyString(doc.token_endpoint);
const userinfoEndpoint = nonEmptyString(doc.userinfo_endpoint);
const endSessionEndpoint = nonEmptyString(doc.end_session_endpoint);
const jwksUri = nonEmptyString(doc.jwks_uri);
const discoveredIssuer = nonEmptyString(doc.issuer);
const endpointFields: Array<[string, string | undefined]> = [
["authorization_endpoint", authorizationEndpoint],
["token_endpoint", tokenEndpoint],
["userinfo_endpoint", userinfoEndpoint],
["end_session_endpoint", endSessionEndpoint],
["jwks_uri", jwksUri],
];
const missing = endpointFields.filter(([, value]) => !value).map(([name]) => name);
if (missing.length > 0) {
throw new ConfigError(
`OIDC discovery document at ${discoveryUrl} is missing: ${missing.join(", ")}. ` +
"The issuer must point at an OIDC provider that publishes these endpoints.",
);
}
if (discoveredIssuer && normalizeIssuer(discoveredIssuer) !== normalizedIssuer) {
throw new ConfigError(
`OIDC discovery issuer mismatch: ${discoveryUrl} advertises "${discoveredIssuer}" ` +
`but VITE_AUTHENTIK_ISSUER is "${normalizedIssuer}".`,
);
}
return {
issuer: normalizedIssuer,
authorizeEndpoint: authorizationEndpoint!,
tokenEndpoint: tokenEndpoint!,
userinfoEndpoint: userinfoEndpoint!,
endSessionEndpoint: endSessionEndpoint!,
jwksUri: jwksUri!,
};
}
/**
* Session cache: one discovery fetch per issuer per SPA session. Failed
* lookups are evicted so a transient network error can be retried instead of
* poisoning the whole session.
*/
const discoveryCache = new Map<string, Promise<DiscoveredEndpoints>>();
export function resolveEndpointsFromDiscovery(
issuer: string,
fetchImpl: typeof fetch = fetch,
): Promise<DiscoveredEndpoints> {
const normalizedIssuer = normalizeIssuer(issuer);
const cached = discoveryCache.get(normalizedIssuer);
if (cached) return cached;
const pending = fetchDiscoveredEndpoints(normalizedIssuer, fetchImpl);
discoveryCache.set(normalizedIssuer, pending);
pending.catch(() => {
discoveryCache.delete(normalizedIssuer);
});
return pending;
}
/**
* Return `app` with all OIDC endpoints filled in. When the config already
* carries them (pre-resolved config, tests) this is a no-op; otherwise the
* issuer's discovery document is fetched (cached for the session) and its
* authorization/token/userinfo/end-session/jwks endpoints are used.
*/
export async function resolveEndpoints(
app: AppConfig,
fetchImpl: typeof fetch = fetch,
): Promise<ResolvedAppConfig> {
const missing = REQUIRED_ENDPOINT_NAMES.filter((name) => !app[name]);
if (missing.length === 0) {
return app as ResolvedAppConfig;
}
const discovered = await resolveEndpointsFromDiscovery(app.issuer, fetchImpl);
return {
...app,
authorizeEndpoint: discovered.authorizeEndpoint,
tokenEndpoint: discovered.tokenEndpoint,
userinfoEndpoint: discovered.userinfoEndpoint,
endSessionEndpoint: discovered.endSessionEndpoint,
jwksUri: discovered.jwksUri,
};
}
+39
View File
@@ -0,0 +1,39 @@
/* NestQuest Admin — global tokens + base.
Plain CubeCraft styling, mobile-first, 390x844 reference
(design/ADMIN-SPEC.md). Values mirror design/tokens/nestquest-admin-tokens.css,
re-scoped to :root for the standalone PWA. */
:root {
--nq-brand-blue: #3b3ab8;
--nq-brand-purple: #a035cc;
--nq-a-page: #f4f4f6;
--nq-a-surface: #ffffff;
--nq-a-border: #e4e4ea;
--nq-a-divider: #ededf1;
--nq-a-ink: #18181d;
--nq-a-ink-secondary: #52525e;
--nq-a-ink-tertiary: #70707e;
--nq-a-font: Nunito, system-ui, sans-serif;
--nq-a-tabbar-height: 70px;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
height: 100%;
}
body {
font-family: var(--nq-a-font);
background: var(--nq-a-page);
color: var(--nq-a-ink);
-webkit-font-smoothing: antialiased;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import AppRoutes from "./AppRoutes";
import "./index.css";
import { registerServiceWorker } from "./pwa/register";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<AppRoutes />
</StrictMode>,
);
void registerServiceWorker();
+396
View File
@@ -0,0 +1,396 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
REVISION_MARKER,
STATIC_PRECACHE_FILES,
injectServiceWorker,
} from "../../vite-sw-plugin";
import { registerServiceWorker } from "./register";
const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const adminPath = (rel: string) => resolve(adminRoot, rel);
const publicPath = (urlPath: string) => adminPath(`public${urlPath}`);
interface ManifestIcon {
src: string;
sizes: string;
type: string;
purpose?: string;
}
const manifest = JSON.parse(readFileSync(publicPath("/manifest.webmanifest"), "utf8"));
const icons: ManifestIcon[] = manifest.icons;
const swSource = readFileSync(publicPath("/sw.js"), "utf8");
const sizeOf = (icon: ManifestIcon) => Number(icon.sizes.split("x")[0]);
const purposes = (icon: ManifestIcon) => (icon.purpose ?? "any").split(/\s+/);
describe("web app manifest", () => {
it("meets the installability criteria", () => {
expect(manifest.name).toBe("NestQuest Admin");
expect(manifest.short_name).toBe("NestQuest");
expect(manifest.start_url).toBe("/");
expect(manifest.scope).toBe("/");
expect(manifest.display).toBe("standalone");
expect(manifest.background_color).toMatch(/^#[0-9a-f]{6}$/i);
expect(manifest.theme_color).toMatch(/^#[0-9a-f]{6}$/i);
const any = icons.filter((i) => purposes(i).includes("any"));
expect(any.some((i) => sizeOf(i) >= 192)).toBe(true);
expect(any.some((i) => sizeOf(i) >= 512)).toBe(true);
expect(icons.some((i) => purposes(i).includes("maskable"))).toBe(true);
});
it("uses the admin design token colours", () => {
const css = readFileSync(adminPath("src/index.css"), "utf8");
expect(css).toContain(`--nq-brand-blue: ${manifest.theme_color};`);
expect(css).toContain(`--nq-a-page: ${manifest.background_color};`);
});
it.each(icons.map((i) => [i.src, i] as const))(
"icon %s is a real PNG of its declared size",
(_src, icon) => {
const path = publicPath(icon.src);
expect(existsSync(path)).toBe(true);
const png = readFileSync(path);
expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
expect(png.toString("ascii", 12, 16)).toBe("IHDR");
const [w, h] = icon.sizes.split("x").map(Number);
expect(png.readUInt32BE(16)).toBe(w);
expect(png.readUInt32BE(20)).toBe(h);
expect(icon.type).toBe("image/png");
},
);
});
describe("index.html and entry point", () => {
const html = readFileSync(adminPath("index.html"), "utf8");
it("links the manifest, theme colour and apple touch icon", () => {
expect(html).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
expect(html).toContain(`<meta name="theme-color" content="${manifest.theme_color}" />`);
expect(html).toContain('<link rel="apple-touch-icon" href="/icons/icon-192.png" />');
});
it("main.tsx registers the service worker", () => {
const main = readFileSync(adminPath("src/main.tsx"), "utf8");
expect(main).toMatch(/import \{ registerServiceWorker \} from "\.\/pwa\/register"/);
expect(main).toMatch(/registerServiceWorker\(\)/);
});
});
// Minimal service-worker global scope: runs public/sw.js against in-memory
// caches and a stubbed network, so handler behaviour is checked without I/O.
function loadServiceWorker(
network: (url: string) => Promise<FakeResponse>,
source: string = swSource,
) {
const listeners: Record<string, (event: any) => void> = {};
const stores = new Map<string, Map<string, FakeResponse>>();
const openStore = (name: string) => {
if (!stores.has(name)) stores.set(name, new Map());
return stores.get(name)!;
};
const keyOf = (req: string | { url: string }) =>
new URL(typeof req === "string" ? req : req.url, "https://admin.test").href;
const fetchFn = vi.fn((req: string | { url: string }) => network(keyOf(req)));
const caches = {
open: async (name: string) => {
const store = openStore(name);
return {
addAll: async (urls: string[]) => {
for (const u of urls) store.set(keyOf(u), await network(keyOf(u)));
},
put: async (req: { url: string }, res: FakeResponse) => void store.set(keyOf(req), res),
};
},
keys: async () => [...stores.keys()],
delete: async (name: string) => stores.delete(name),
match: async (req: string | { url: string }, opts?: { cacheName?: string }) => {
const names = opts?.cacheName ? [opts.cacheName] : [...stores.keys()];
for (const n of names) {
const hit = stores.get(n)?.get(keyOf(req));
if (hit) return hit;
}
return undefined;
},
};
const self = {
location: new URL("https://admin.test/"),
addEventListener: (type: string, fn: (event: any) => void) => (listeners[type] = fn),
skipWaiting: vi.fn(async () => undefined),
clients: { claim: vi.fn(async () => undefined) },
};
const ResponseStub = { error: () => new FakeResponse("", false, "error") };
new Function("self", "caches", "fetch", "Response", source)(self, caches, fetchFn, ResponseStub);
const dispatch = async (type: string, init: object = {}) => {
let pending: Promise<unknown> | undefined;
const event = {
...init,
waitUntil: (p: Promise<unknown>) => (pending = p),
respondWith: (p: Promise<unknown>) => (pending = p),
};
listeners[type](event);
return { responded: pending !== undefined, result: await pending };
};
return { listeners, stores, self, fetchFn, dispatch, openStore };
}
class FakeResponse {
constructor(
readonly body: string,
readonly ok = true,
readonly type = "basic",
) {}
clone() {
return new FakeResponse(this.body, this.ok, this.type);
}
}
const request = (url: string, extra: object = {}) => ({
url: new URL(url, "https://admin.test").href,
method: "GET",
mode: "cors",
destination: "",
...extra,
});
describe("service worker (public/sw.js)", () => {
it("is a static asset served from the site root", () => {
expect(existsSync(publicPath("/sw.js"))).toBe(true);
expect(swSource).toMatch(/const SHELL_CACHE = /);
expect(swSource).toMatch(/addEventListener\("install"/);
expect(swSource).toMatch(/addEventListener\("activate"/);
expect(swSource).toMatch(/addEventListener\("fetch"/);
});
it("precaches the shell on install, prunes stale caches on activate", async () => {
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
sw.openStore("nestquest-admin-shell-v0");
await sw.dispatch("install");
expect(sw.self.skipWaiting).toHaveBeenCalled();
const precached = [...sw.stores.values()].flatMap((s) => [...s.keys()]);
for (const path of ["/", "/index.html", "/manifest.webmanifest", ...icons.map((i) => i.src)]) {
expect(precached).toContain(`https://admin.test${path}`);
}
await sw.dispatch("activate");
expect(sw.stores.has("nestquest-admin-shell-v0")).toBe(false);
expect(sw.self.clients.claim).toHaveBeenCalled();
});
it("serves the cached shell for navigations when offline", async () => {
let online = true;
const sw = loadServiceWorker(async (url) => {
if (!online) throw new TypeError("Failed to fetch");
return new FakeResponse(`net:${url}`);
});
await sw.dispatch("install");
online = false;
const { result } = await sw.dispatch("fetch", {
request: request("/children/42", { mode: "navigate", destination: "document" }),
});
expect((result as FakeResponse).body).toBe("net:https://admin.test/index.html");
});
it("serves static assets cache-first and fills the runtime cache", async () => {
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
const asset = request("/assets/index-abc123.js", { destination: "script" });
const first = await sw.dispatch("fetch", { request: asset });
expect((first.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
expect(sw.fetchFn).toHaveBeenCalledTimes(1);
const second = await sw.dispatch("fetch", { request: asset });
expect((second.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
expect(sw.fetchFn).toHaveBeenCalledTimes(1);
});
it("never handles cross-origin, non-GET or non-static requests", async () => {
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
const cases = [
request("https://auth.example.com/logo.png", { destination: "image" }),
request("/assets/app.js", { destination: "script", method: "POST" }),
request("/api/admin/children"),
];
for (const req of cases) {
const { responded } = await sw.dispatch("fetch", { request: req });
expect(responded).toBe(false);
}
expect(sw.fetchFn).not.toHaveBeenCalled();
});
});
// A Vite-style built index.html referencing hashed bundles.
const builtIndexHtml = (hash: string) => `<!doctype html>
<html lang="en">
<head>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<script type="module" crossorigin src="/assets/index-${hash}.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-${hash}.js">
<link rel="stylesheet" crossorigin href="/assets/index-${hash}.css">
<link rel="preconnect" href="https://auth.example.com" />
</head>
<body><div id="root"></div></body>
</html>`;
// Same-origin script/link URLs of an HTML document, parsed independently of
// the build plugin so the regression check does not trust its own extractor.
const referencedAssets = (html: string) =>
[...new DOMParser().parseFromString(html, "text/html").querySelectorAll("script[src], link[href]")]
.map((el) => new URL(el.getAttribute("src") ?? el.getAttribute("href")!, "https://admin.test/"))
.filter((url) => url.origin === "https://admin.test")
.map((url) => url.href);
// The unhashed files the build hashes into the revision, read from public/.
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
path,
bytes: readFileSync(publicPath(`/${path}`)),
}));
const revisionOf = (src: string) => /const REVISION = "([0-9a-f]+)";/.exec(src)?.[1];
describe("build-time service worker injection", () => {
it("precaches every asset the cached index.html references on install (PWA-001)", async () => {
const html = builtIndexHtml("Ab12Cd34");
const built = injectServiceWorker(html, swSource, staticFiles);
const sw = loadServiceWorker(async (url) =>
new FakeResponse(url.endsWith("/index.html") ? html : `net:${url}`),
built);
await sw.dispatch("install");
const shell = [...sw.stores.entries()].find(([name]) => name.startsWith("nestquest-admin-shell-"));
expect(shell).toBeDefined();
const [, shellStore] = shell!;
const cachedHtml = shellStore.get("https://admin.test/index.html")!.body;
const assets = referencedAssets(cachedHtml);
expect(assets).toEqual(
expect.arrayContaining([
"https://admin.test/assets/index-Ab12Cd34.js",
"https://admin.test/assets/vendor-Ab12Cd34.js",
"https://admin.test/assets/index-Ab12Cd34.css",
]),
);
for (const asset of assets) expect([...shellStore.keys()]).toContain(asset);
expect([...shellStore.keys()]).not.toContain("https://auth.example.com/");
});
it("derives a new revision for different index.html content (PWA-002)", () => {
const a = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
const b = injectServiceWorker(builtIndexHtml("bbbb2222"), swSource, staticFiles);
expect(a).not.toContain(REVISION_MARKER);
expect(b).not.toContain(REVISION_MARKER);
expect(revisionOf(a)).toMatch(/^[0-9a-f]{12}$/);
expect(revisionOf(b)).toMatch(/^[0-9a-f]{12}$/);
expect(revisionOf(a)).not.toBe(revisionOf(b));
expect(injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles)).toBe(a);
});
it("derives a new revision when only a precached static file changes (PWA-002)", () => {
const html = builtIndexHtml("aaaa1111");
const base = revisionOf(injectServiceWorker(html, swSource, staticFiles));
const withChanged = (path: string) =>
staticFiles.map((f) =>
f.path === path ? { path, bytes: Buffer.concat([f.bytes, Buffer.from([0])]) } : f,
);
expect(revisionOf(injectServiceWorker(html, swSource, [...staticFiles].reverse()))).toBe(base);
for (const path of STATIC_PRECACHE_FILES) {
const changed = revisionOf(injectServiceWorker(html, swSource, withChanged(path)));
expect(changed, path).toMatch(/^[0-9a-f]{12}$/);
expect(changed, path).not.toBe(base);
}
});
it("hashes every unhashed URL sw.js precaches (PWA-002)", () => {
const shellUrls = [...swSource.matchAll(/^\s*"(\/[^"]*)",$/gm)].map((m) => m[1]);
const fixed = shellUrls.filter((url) => url !== "/" && url !== "/index.html");
expect(fixed.map((url) => url.slice(1)).sort()).toEqual([...STATIC_PRECACHE_FILES].sort());
});
it("names both caches after the build revision (PWA-002)", async () => {
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
const revision = /const REVISION = "([0-9a-f]+)";/.exec(built)![1];
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
await sw.dispatch("install");
await sw.dispatch("fetch", { request: request("/assets/late.js", { destination: "script" }) });
expect([...sw.stores.keys()].sort()).toEqual([
`nestquest-admin-runtime-${revision}`,
`nestquest-admin-shell-${revision}`,
]);
});
it("fails fast when sw.js lacks the placeholders", () => {
expect(() => injectServiceWorker(builtIndexHtml("x"), "const SHELL_URLS = [];", staticFiles)).toThrow();
});
it("activation deletes only stale nestquest-admin caches (PWA-003)", async () => {
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
for (const name of [
"nestquest-admin-shell-old",
"nestquest-admin-runtime-old",
"some-other-app-cache",
]) {
sw.openStore(name);
}
await sw.dispatch("install");
await sw.dispatch("activate");
const names = [...sw.stores.keys()];
expect(names).not.toContain("nestquest-admin-shell-old");
expect(names).not.toContain("nestquest-admin-runtime-old");
expect(names).toContain("some-other-app-cache");
expect(names.some((n) => n.startsWith("nestquest-admin-shell-") && n !== "nestquest-admin-shell-old")).toBe(true);
});
});
describe("registerServiceWorker", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const stubServiceWorker = (register: ReturnType<typeof vi.fn>) =>
vi.stubGlobal("navigator", { ...navigator, serviceWorker: { register } });
it("does not register outside production builds", async () => {
const register = vi.fn();
stubServiceWorker(register);
expect(import.meta.env.PROD).toBe(false);
await registerServiceWorker();
expect(register).not.toHaveBeenCalled();
});
it("registers /sw.js in production", async () => {
vi.stubEnv("PROD", true);
const register = vi.fn().mockResolvedValue({});
stubServiceWorker(register);
await registerServiceWorker();
expect(register).toHaveBeenCalledWith("/sw.js", { scope: "/" });
});
it("logs instead of throwing when registration fails", async () => {
vi.stubEnv("PROD", true);
stubServiceWorker(vi.fn().mockRejectedValue(new Error("boom")));
const error = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(registerServiceWorker()).resolves.toBeUndefined();
expect(error).toHaveBeenCalled();
});
it("does nothing when the browser lacks service workers", async () => {
vi.stubEnv("PROD", true);
vi.stubGlobal("navigator", {});
await expect(registerServiceWorker()).resolves.toBeUndefined();
});
});
+13
View File
@@ -0,0 +1,13 @@
/**
* Service worker registration for the installable admin PWA.
* Registers /sw.js only in production builds, so dev servers and tests never
* run a worker. Failures are logged, never thrown — the app works without it.
*/
export async function registerServiceWorker(): Promise<void> {
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
try {
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
} catch (error) {
console.error("Service worker registration failed:", error);
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"useDefineForClassFields": true
},
"include": ["src"]
}
+93
View File
@@ -0,0 +1,93 @@
/* Build-time service worker injection. After Vite writes the bundle, the
hashed asset URLs referenced by dist/index.html and a content-derived
revision are written into dist/sw.js, so the offline shell is complete right
after install and every release ships a new worker with fresh caches.
Build tooling only — never imported by the app bundle. */
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import type { Plugin } from "vite";
export const REVISION_MARKER = "__NQ_BUILD_REVISION__";
const PRECACHE_PLACEHOLDER = "const PRECACHE_ASSETS = [];";
// Unhashed files sw.js precaches by fixed URL, relative to the build output.
export const STATIC_PRECACHE_FILES = [
"manifest.webmanifest",
"icons/icon-192.png",
"icons/icon-512.png",
"icons/icon-maskable-512.png",
];
export interface PrecacheFile {
path: string;
bytes: string | Uint8Array;
}
// Every same-origin URL an index.html loads via <script src> or <link href>,
// normalised to an absolute path, in document order and without duplicates.
export function extractShellAssets(indexHtml: string): string[] {
const tags = indexHtml.match(/<(?:script|link)\b[^>]*>/gi) ?? [];
const urls = tags.flatMap((tag) => {
const attr = /\b(?:src|href)\s*=\s*["']([^"']+)["']/i.exec(tag);
if (!attr) return [];
const url = new URL(attr[1], "https://same-origin.invalid/");
return url.origin === "https://same-origin.invalid" ? [url.pathname] : [];
});
return [...new Set(urls)];
}
// Hash of every precached file, sorted by path; each entry is length-prefixed
// so no two different file sets hash the same concatenation.
export function computeRevision(files: PrecacheFile[]): string {
const hash = createHash("sha256");
for (const { path, bytes } of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
const data = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes;
hash.update(`${path}\0${data.byteLength}\0`).update(data);
}
return hash.digest("hex").slice(0, 12);
}
// Returns swSource with the precache list and the build revision filled in.
// The revision covers index.html (and so the hashed bundles it names), the
// sw.js source and the bytes of every unhashed static file it precaches.
export function injectServiceWorker(
indexHtml: string,
swSource: string,
staticFiles: PrecacheFile[],
): string {
if (!swSource.includes(PRECACHE_PLACEHOLDER) || !swSource.includes(REVISION_MARKER)) {
throw new Error("sw.js is missing the PRECACHE_ASSETS placeholder or revision marker");
}
const assets = extractShellAssets(indexHtml);
const revision = computeRevision([
{ path: "index.html", bytes: indexHtml },
{ path: "sw.js", bytes: swSource },
...staticFiles,
]);
return swSource
.replace(PRECACHE_PLACEHOLDER, `const PRECACHE_ASSETS = ${JSON.stringify(assets)};`)
.replaceAll(REVISION_MARKER, revision);
}
export function serviceWorkerInjectPlugin(): Plugin {
let outDir = "";
return {
name: "nestquest-sw-inject",
apply: "build",
configResolved(config) {
outDir = resolve(config.root, config.build.outDir);
},
writeBundle() {
const swPath = resolve(outDir, "sw.js");
const indexHtml = readFileSync(resolve(outDir, "index.html"), "utf8");
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
path,
bytes: readFileSync(resolve(outDir, path)),
}));
writeFileSync(
swPath,
injectServiceWorker(indexHtml, readFileSync(swPath, "utf8"), staticFiles),
);
},
};
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { serviceWorkerInjectPlugin } from "./vite-sw-plugin";
export default defineConfig({
plugins: [react(), serviceWorkerInjectPlugin()],
test: {
environment: "jsdom",
},
});
-117
View File
@@ -116,22 +116,6 @@ owns every rule so the two routes cannot drift:
a ``Content-Disposition`` attachment filename and the documented,
stable header row (Admin spec §5 event-row fields).
The snapshot route (task ef3f4877) is the admin-plane twin of the
panel's ``GET /api/v1/panel/snapshot`` for the admin PWA's Today tab —
the PWA is internet-facing and never holds the panel service token
(D-012), so it reads the same snapshot here:
- ``GET /snapshot`` threads ONE clock read (:func:`_local_now`) into
:func:`nestquest_core.snapshot.build_snapshot` and shapes each
child's instances through
:func:`nestquest_core.snapshot.instance_payload` with
``include_missed=True`` — admin payloads KEEP missed instances with
their ``missed`` state (D-009), where the panel omits them. The
body is the panel's documented shape (``today_iso``, ``cycle_day``,
``children`` with the per-child presence, rollup counts and
``instances``); the builder, the state derivation and the shaping
all live in the core.
The settings routes (task 2b3de7e5) follow the same pattern over
:mod:`nestquest_core.settings_store` — the API's OWN durable settings
store. The API now OWNS settings in the database (a validated JSON
@@ -270,7 +254,6 @@ from api.nestquest_core import (
core_recurrence,
core_settings,
core_settings_store,
core_snapshot,
core_sweep,
)
@@ -700,59 +683,6 @@ class AdminMissedSweepResponse(BaseModel):
fired: int
class AdminInstancePayload(BaseModel):
"""One instance in the admin snapshot payload.
Mirrors :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=True``: ``state`` is ``open`` | ``completed`` |
``missed`` (admin payloads keep missed instances, D-009),
``on_time`` passes the completion event's flag through verbatim
(``None`` unless completed), and ``completed_at`` is the event's
UTC timestamp when completed.
"""
id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_time: str | None
state: str
overdue: bool
completed_at: str | None
on_time: bool | None
class AdminChildSnapshotPayload(BaseModel):
"""One child's day: presence, rollup counts, and today's instances."""
child_id: int
child_name: str
present: bool
#: ISO date of the child's next present day while they are away;
#: ``None`` when the child is present today.
next_present: str | None
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
instances: list[AdminInstancePayload]
class AdminSnapshotResponse(BaseModel):
"""Today's whole household snapshot for the admin Today tab.
The panel snapshot's shape (``today_iso``, ``cycle_day``,
``children`` in the household's sort order), with missed instances
kept in each child's ``instances``.
"""
today_iso: str
cycle_day: int
children: list[AdminChildSnapshotPayload]
#: 404 detail for a child id the core layer reports as non-existent.
_CHILD_NOT_FOUND_DETAIL = "Child not found"
@@ -1799,51 +1729,4 @@ async def admin_run_missed_sweep(
return AdminMissedSweepResponse(fired=len(events))
@router.get(
"/snapshot",
summary="Today's household snapshot",
response_model=AdminSnapshotResponse,
)
async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
"""Return today's full household snapshot for the admin Today tab.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: ONE clock read
(:func:`_local_now`) goes into
:func:`nestquest_core.snapshot.build_snapshot` against the live
database, and each child's instances are shaped with
``instance_payload(include_missed=True)`` — missed instances are
KEPT in the admin payload (D-009) — into
:class:`AdminSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
)
return AdminSnapshotResponse(
today_iso=snapshot.today_iso,
cycle_day=snapshot.cycle_day,
children=[
AdminChildSnapshotPayload(
child_id=child.child_id,
child_name=child.child_name,
present=child.present,
next_present=child.next_present,
due_today=child.due_today,
completed_today=child.completed_today,
remaining_today=child.remaining_today,
completion_pct=child.completion_pct,
instances=[
AdminInstancePayload.model_validate(instance)
for instance in core_snapshot.instance_payload(
child.instances, include_missed=True
)
],
)
for child in snapshot.children
],
)
__all__ = ["router"]
-328
View File
@@ -1,328 +0,0 @@
"""Admin-plane snapshot route tests (task ef3f4877).
These tests exercise ``GET /api/v1/admin/snapshot`` against the ASGI
app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely
authenticates with NO network access. The household is seeded with
:func:`tests.test_api_panel._seed_household` through the SAME core
copy the app uses (``nestquest_core.*``), and both routes' single
clock read (``_local_now``) is pinned to noon so nothing straddles
midnight.
Proven per the done-condition:
- An admin JWT returns 200 with the documented shape: ``today_iso``,
``cycle_day`` and, per child, the presence fields, the rollup counts
and ``instances`` in the core ``instance_payload`` shape.
- A MISSED instance is KEPT in the admin payload (D-009) and still
OMITTED from the panel payload. The core builder lists only
instances due on ``now.date()``, and ``derive_state`` calls an
instance missed only when its due date is BEFORE that day — so a
real snapshot never carries a missed view. The test therefore
wraps the real builder to add the seeded past-due instance as a
view whose state the core's own ``derive_state`` computes
(``missed``), and reads it through both routes.
- Auth runs through the router's ONE ``require_admin`` dependency: no
credential is 401, the panel service token is refused outright
(403, the credential-type detail), and a non-admin JWT is 403.
- The handler performs exactly ONE clock read per request.
"""
from __future__ import annotations
import dataclasses
import datetime
import importlib
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api.nestquest_core import core_snapshot
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
from tests.test_api_panel import _seed_household
# The completion layer through the SAME core copy the app uses — the
# missed test derives its view's state with the core's own rule.
_completion = importlib.import_module("nestquest_core.completion")
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _panel_headers() -> dict[str, str]:
"""The Authorization header carrying the harness panel token."""
return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
@pytest.fixture
async def snapshot_household(
temp_db_path: str, monkeypatch
) -> SimpleNamespace:
"""A seeded household served by the admin-plane app, clock pinned.
``today`` comes from one host clock read; the pinned instant is
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). Both
the admin and the panel route's ``_local_now`` return it, and the
admin one counts its calls.
"""
today = datetime.datetime.now().astimezone().date()
pinned_now = datetime.datetime(
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
)
seed = await _seed_household(temp_db_path, today, pinned_now)
clock_reads: list[datetime.datetime] = []
def _admin_now() -> datetime.datetime:
clock_reads.append(pinned_now)
return pinned_now
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
client=client,
seed=seed,
today=today,
pinned_now=pinned_now,
pinned_utc_iso=pinned_now.astimezone(
datetime.timezone.utc
).isoformat(),
clock_reads=clock_reads,
)
# --- authentication: the ONE require_admin dependency ----------------------
async def test_snapshot_without_credential_is_401(
snapshot_household: SimpleNamespace,
) -> None:
"""No Authorization header is the uniform 401."""
response = await snapshot_household.client.get("/api/v1/admin/snapshot")
assert response.status_code == 401
async def test_snapshot_refuses_panel_service_token_outright(
snapshot_household: SimpleNamespace,
) -> None:
"""The panel service token is refused as a credential type (403).
403 with the credential-type detail — not the uniform 401 and not
the non-admin group detail — proves the token was refused outright,
never tried as a JWT.
"""
response = await snapshot_household.client.get(
"/api/v1/admin/snapshot", headers=_panel_headers()
)
assert response.status_code == 403
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
async def test_snapshot_refuses_non_admin_jwt(
snapshot_household: SimpleNamespace,
) -> None:
"""A valid JWT without the nestquest-admins group is 403."""
non_admin = make_token(groups=("some-other-group",))
response = await snapshot_household.client.get(
"/api/v1/admin/snapshot",
headers={"Authorization": f"Bearer {non_admin}"},
)
assert response.status_code == 403
assert response.json()["detail"] == FORBIDDEN_DETAIL
# --- the payload -----------------------------------------------------------
async def test_admin_jwt_returns_documented_snapshot(
snapshot_household: SimpleNamespace,
) -> None:
"""An admin JWT returns 200 with the documented snapshot shape.
Every field is asserted as an explicit literal, with ONE clock
read for the request. With no missed view in the real snapshot,
the admin body equals the panel body for the same household.
"""
client = snapshot_household.client
seed = snapshot_household.seed
response = await client.get(
"/api/v1/admin/snapshot", headers=_admin_headers()
)
assert response.status_code == 200
assert len(snapshot_household.clock_reads) == 1
body = response.json()
assert body == {
"today_iso": snapshot_household.today.isoformat(),
# Ada's 2-week cycle anchored yesterday => today is cycle day 2.
"cycle_day": 2,
"children": [
{
"child_id": seed.ada.id,
"child_name": "Ada",
"present": False,
"next_present": seed.returns_date.isoformat(),
"due_today": 1,
"completed_today": 0,
"remaining_today": 1,
"completion_pct": 0,
"instances": [
{
"id": seed.pack_bag_instance.id,
"definition_id": seed.pack_bag_instance.definition_id,
"child_id": seed.ada.id,
"title": "Pack bag",
"icon": None,
"window": "morning",
"due_time": "10:00",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
}
],
},
{
"child_id": seed.bo.id,
"child_name": "Bo",
"present": True,
"next_present": None,
"due_today": 1,
"completed_today": 1,
"remaining_today": 0,
"completion_pct": 100,
"instances": [
{
"id": seed.brush_teeth_instance.id,
"definition_id": (
seed.brush_teeth_instance.definition_id
),
"child_id": seed.bo.id,
"title": "Brush teeth",
"icon": None,
"window": "morning",
"due_time": "17:00",
"state": "completed",
"overdue": False,
"completed_at": snapshot_household.pinned_utc_iso,
"on_time": True,
}
],
},
],
}
panel = await client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
assert panel.json() == body
async def test_admin_keeps_missed_instance_panel_omits_it(
snapshot_household: SimpleNamespace, monkeypatch
) -> None:
"""A missed view is KEPT by the admin route, OMITTED by the panel's.
The real builder is wrapped to add the seeded past-due Stale chore
instance to Ada's views, its state derived by the core's own
``derive_state`` (no events, due yesterday => ``missed``). Both
routes read the SAME wrapped snapshot, so the only difference is
each route's ``include_missed`` flag.
"""
client = snapshot_household.client
seed = snapshot_household.seed
stale = seed.stale_instance
missed_state = _completion.derive_state(
stale, None, snapshot_household.today
)
assert missed_state == "missed"
missed_view = core_snapshot.QuestInstanceView(
instance_id=stale.id,
definition_id=stale.definition_id,
child_id=stale.child_id,
title="Stale chore",
icon=None,
window=stale.window,
due_date=stale.due_date,
due_time=stale.due_time,
state=missed_state,
overdue=False,
completed_at=None,
was_on_time=None,
)
real_build_snapshot = core_snapshot.build_snapshot
async def _with_missed_view(database, settings, now):
snapshot = await real_build_snapshot(database, settings, now)
return dataclasses.replace(
snapshot,
children=tuple(
dataclasses.replace(
child, instances=(*child.instances, missed_view)
)
if child.child_id == seed.ada.id
else child
for child in snapshot.children
),
)
monkeypatch.setattr(core_snapshot, "build_snapshot", _with_missed_view)
admin = await client.get(
"/api/v1/admin/snapshot", headers=_admin_headers()
)
assert admin.status_code == 200
admin_ada = admin.json()["children"][0]
assert admin_ada["child_id"] == seed.ada.id
assert [instance["id"] for instance in admin_ada["instances"]] == [
seed.pack_bag_instance.id,
stale.id,
]
assert admin_ada["instances"][1] == {
"id": stale.id,
"definition_id": stale.definition_id,
"child_id": seed.ada.id,
"title": "Stale chore",
"icon": None,
"window": "morning",
"due_time": "09:00",
"state": "missed",
"overdue": False,
"completed_at": None,
"on_time": None,
}
panel = await client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
panel_ada = panel.json()["children"][0]
assert [instance["id"] for instance in panel_ada["instances"]] == [
seed.pack_bag_instance.id
], "the panel payload must still omit missed instances"