Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { contentSecurityPolicy, resolveApiOrigin } from "./csp.ts";
describe("resolveApiOrigin", () => {
it("returns null for empty or same-origin API URL", () => {
assert.equal(resolveApiOrigin("", "http://localhost:5174"), null);
assert.equal(resolveApiOrigin("http://localhost:5174", "http://localhost:5174"), null);
assert.equal(resolveApiOrigin("http://localhost:5174/", "http://localhost:5174"), null);
});
it("returns cross-origin API origin", () => {
assert.equal(
resolveApiOrigin("http://localhost:8080", "http://localhost:5174"),
"http://localhost:8080"
);
assert.equal(
resolveApiOrigin("https://api.example.com/v1", "https://app.example.com"),
"https://api.example.com"
);
});
it("returns null for invalid URLs", () => {
assert.equal(resolveApiOrigin("not a url", "http://localhost:5174"), null);
});
});
describe("contentSecurityPolicy", () => {
it("production policy omits Google hosts when GTM id is unset", () => {
const csp = contentSecurityPolicy({ dev: false });
assert.match(csp, /default-src 'self'/);
assert.match(csp, /frame-ancestors 'none'/);
assert.match(csp, /object-src 'none'/);
assert.match(csp, /script-src 'self' 'unsafe-inline'/);
assert.doesNotMatch(csp, /googletagmanager\.com/);
assert.doesNotMatch(csp, /google-analytics\.com/);
assert.doesNotMatch(csp, /analytics\.google\.com/);
assert.doesNotMatch(csp, /'unsafe-eval'/);
assert.doesNotMatch(csp, /\bws:/);
assert.match(csp, /connect-src 'self' blob:/);
assert.match(csp, /font-src 'self' data:/);
assert.doesNotMatch(csp, /scalar\.com/);
});
it("production policy allowlists Google hosts when GTM id is valid", () => {
const csp = contentSecurityPolicy({ dev: false, gtmId: "GTM-ABC123" });
assert.match(csp, /script-src 'self' 'unsafe-inline' https:\/\/www\.googletagmanager\.com/);
assert.match(csp, /https:\/\/www\.google-analytics\.com/);
assert.match(csp, /frame-src 'self' https:\/\/www\.googletagmanager\.com/);
assert.match(csp, /connect-src 'self' blob: https:\/\/www\.googletagmanager\.com/);
});
it("invalid or empty gtmId does not allowlist Google hosts", () => {
for (const gtmId of ["", " ", "G-XXXX", "gtm-bad!", null, undefined]) {
const csp = contentSecurityPolicy({ dev: false, gtmId });
assert.doesNotMatch(csp, /googletagmanager\.com/);
assert.doesNotMatch(csp, /google-analytics\.com/);
}
});
it("production connect-src includes cross-origin API without GTM hosts by default", () => {
const csp = contentSecurityPolicy({
dev: false,
apiOrigin: "http://localhost:8080"
});
assert.match(csp, /connect-src 'self' blob: http:\/\/localhost:8080/);
assert.doesNotMatch(csp, /googletagmanager\.com/);
assert.doesNotMatch(csp, /scalar\.com/);
});
it("production connect-src includes API and GTM hosts when gtmId is valid", () => {
const csp = contentSecurityPolicy({
dev: false,
apiOrigin: "http://localhost:8080",
gtmId: "GTM-ABC123"
});
assert.match(
csp,
/connect-src 'self' blob: https:\/\/www\.googletagmanager\.com .*http:\/\/localhost:8080/
);
assert.doesNotMatch(csp, /scalar\.com/);
});
it("development policy allows Vite HMR eval and websockets", () => {
const csp = contentSecurityPolicy({ dev: true });
assert.match(csp, /'unsafe-eval'/);
assert.match(csp, /\bws:/);
assert.match(csp, /\bwss:/);
assert.match(csp, /http:\/\/localhost:\*/);
assert.match(csp, /http:\/\/127\.0\.0\.1:\*/);
assert.match(csp, /connect-src 'self' blob:/);
assert.match(csp, /font-src 'self' data:/);
assert.doesNotMatch(csp, /googletagmanager\.com/);
assert.doesNotMatch(csp, /scalar\.com/);
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Content-Security-Policy builders for the SvelteKit app.
* Kept free of $app/$env imports so unit tests can run under node:test.
*/
import { resolveGtmId } from "../analytics/gtm-id.ts";
import { alignLoopbackApiBase } from "../loopback-api.ts";
export type ContentSecurityPolicyOptions = {
/** Vite HMR needs 'unsafe-eval' and websocket connect-src. */
dev: boolean;
/** Absolute API origin when the SPA talks cross-origin (empty PUBLIC_API_URL = same-origin). */
apiOrigin?: string | null;
/** Raw PUBLIC_GTM_ID - Google hosts are allowlisted only when this resolves to a valid GTM id. */
gtmId?: string | null;
};
/** Parse PUBLIC_API_URL into an origin, omitting same-origin / empty / invalid values. */
export function resolveApiOrigin(apiUrl: string, pageOrigin: string): string | null {
const trimmed = apiUrl.trim().replace(/\/$/, "");
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return null;
}
let pageHost = "";
try {
pageHost = new URL(pageOrigin).hostname;
} catch {
pageHost = "";
}
const origin = pageHost ? alignLoopbackApiBase(parsed.origin, pageHost) : parsed.origin;
if (origin === pageOrigin) return null;
return origin;
} catch {
return null;
}
}
/**
* Build an enforced Content-Security-Policy for HTML responses.
* Development relaxes script-src/connect-src for Vite HMR; production stays tighter.
* /docs uses vendored RapiDoc (same-origin only) — no third-party docs CDN connect-src.
*/
/** Hosts required for Google Tag Manager + GA4 (only when a valid PUBLIC_GTM_ID is configured). */
const GTM_SCRIPT_SRC = ["https://www.googletagmanager.com"];
const GTM_IMG_SRC = [
"https://www.googletagmanager.com",
"https://www.google-analytics.com"
];
const GTM_CONNECT_SRC = [
"https://www.googletagmanager.com",
"https://www.google-analytics.com",
"https://analytics.google.com",
"https://region1.google-analytics.com",
"https://stats.g.doubleclick.net"
];
const GTM_FRAME_SRC = ["https://www.googletagmanager.com"];
function joinSrc(base: string, hosts: string[]): string {
return hosts.length > 0 ? `${base} ${hosts.join(" ")}` : base;
}
export function contentSecurityPolicy(opts: ContentSecurityPolicyOptions): string {
const gtmEnabled = resolveGtmId(opts.gtmId) !== null;
const gtmScript = gtmEnabled ? GTM_SCRIPT_SRC : [];
const gtmImg = gtmEnabled ? GTM_IMG_SRC : [];
const gtmConnect = gtmEnabled ? GTM_CONNECT_SRC : [];
const gtmFrame = gtmEnabled ? GTM_FRAME_SRC : [];
// blob: — /docs loads OpenAPI YAML via a short-lived blob URL into RapiDoc.
const connect = ["'self'", "blob:", ...gtmConnect];
if (opts.apiOrigin) {
connect.push(opts.apiOrigin);
}
const fontSrc = "font-src 'self' data:";
const imgSrc = joinSrc("img-src 'self' data: blob:", gtmImg);
const frameSrc = joinSrc("frame-src 'self'", gtmFrame);
if (opts.dev) {
// Vite HMR: eval for the client, ws/wss (+ loopback HTTP) for the overlay socket.
connect.push("ws:", "wss:", "http://localhost:*", "http://127.0.0.1:*");
return [
"default-src 'self'",
"base-uri 'self'",
"frame-ancestors 'none'",
"object-src 'none'",
joinSrc("script-src 'self' 'unsafe-inline' 'unsafe-eval'", gtmScript),
"style-src 'self' 'unsafe-inline'",
imgSrc,
fontSrc,
frameSrc,
`connect-src ${connect.join(" ")}`,
"form-action 'self'"
].join("; ");
}
return [
"default-src 'self'",
"base-uri 'self'",
"frame-ancestors 'none'",
"object-src 'none'",
// 'unsafe-inline': marketing FOUC guard in app.html (inline script).
joinSrc("script-src 'self' 'unsafe-inline'", gtmScript),
"style-src 'self' 'unsafe-inline'",
imgSrc,
fontSrc,
frameSrc,
`connect-src ${connect.join(" ")}`,
"form-action 'self'"
].join("; ");
}
+293
View File
@@ -0,0 +1,293 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { DEFAULT_UI_LOCALE, UI_LOCALES, isUILocale } from "$lib/i18n/locales";
import type { MessageDict } from "$lib/i18n/messages/types";
const KEY_RE = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/i;
const MAX_KEY_LEN = 160;
const MAX_VALUE_LEN = 4000;
const MAX_UPDATES = 500;
/** Raw PATCH body cap (envelope + max updates × key/value). */
export const MAX_PATCH_BODY_BYTES =
16 * 1024 + MAX_UPDATES * (MAX_KEY_LEN + MAX_VALUE_LEN + 24);
const RESERVED_FILES = new Set(["catalog.ts", "types.ts", "index.ts"]);
export function messagesDir(): string {
return path.resolve(process.cwd(), "src/lib/i18n/messages");
}
export function localeFilePath(locale: string): string {
if (!isUILocale(locale)) {
throw new Error("Unsupported locale.");
}
const base = messagesDir();
const resolved = path.resolve(base, `${locale}.ts`);
const rel = path.relative(base, resolved);
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
throw new Error("Invalid locale path.");
}
return resolved;
}
export function isValidMessageKey(key: string): boolean {
return key.length > 0 && key.length <= MAX_KEY_LEN && KEY_RE.test(key);
}
export function sanitizeUpdates(raw: unknown): MessageDict {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error("updates must be an object of string values.");
}
const entries = Object.entries(raw as Record<string, unknown>);
if (entries.length === 0) {
throw new Error("No updates provided.");
}
if (entries.length > MAX_UPDATES) {
throw new Error(`Too many updates (max ${MAX_UPDATES}).`);
}
const out: MessageDict = {};
for (const [key, value] of entries) {
if (!isValidMessageKey(key)) {
throw new Error(`Invalid message key: ${key}`);
}
if (typeof value !== "string") {
throw new Error(`Value for ${key} must be a string.`);
}
if (value.length > MAX_VALUE_LEN) {
throw new Error(`Value for ${key} exceeds ${MAX_VALUE_LEN} characters.`);
}
if (value.includes("\0")) {
throw new Error(`Value for ${key} contains invalid characters.`);
}
out[key] = value;
}
return out;
}
function serializeMessagePack(locale: string, dict: MessageDict): string {
const keys = Object.keys(dict).sort((a, b) => a.localeCompare(b));
const body =
keys.length === 0
? ""
: keys.map((k) => `\t${JSON.stringify(k)}: ${JSON.stringify(dict[k])}`).join(",\n") + "\n";
return (
`import type { MessageDict } from "./types.ts";\n\n` +
`/** UI strings for locale \`${locale}\`. File-based source of truth — edit here or via /admin/translations. */\n` +
`export const ${locale}: MessageDict = {\n` +
body +
`};\n`
);
}
function serializeCatalog(locales: string[]): string {
const codes = locales.includes(DEFAULT_UI_LOCALE)
? locales
: [DEFAULT_UI_LOCALE, ...locales];
const loaderEntries = codes
.map((code) =>
code === DEFAULT_UI_LOCALE
? `\t${code}: async () => ({ ${code} })`
: `\t${code}: () => import("./${code}.ts")`
)
.join(",\n");
return (
`import type { MessageDict } from "./types.ts";\n` +
`import { en } from "./en.ts";\n\n` +
`type LocaleModule = Record<string, MessageDict>;\n\n` +
`/**\n` +
` * Per-locale dynamic import factories. Only \`en\` is statically resident\n` +
` * (SSR default + missing-key fallback). Other packs load on demand.\n` +
` * Managed by /admin/translations or by editing these modules directly.\n` +
` */\n` +
`const LOCALE_LOADERS: Record<string, () => Promise<LocaleModule>> = {\n` +
`${loaderEntries}\n` +
`};\n\n` +
`/**\n` +
` * Sync cache of packs that have been loaded. English is always present.\n` +
` * Missing keys still fall back to \`en\` via resolveMessage / i18n.t.\n` +
` */\n` +
`export const MESSAGE_CATALOG: Record<string, MessageDict> = {\n` +
`\ten\n` +
`};\n\n` +
`export function isRegisteredCatalogLocale(locale: string): boolean {\n` +
`\treturn Object.prototype.hasOwnProperty.call(LOCALE_LOADERS, locale);\n` +
`}\n\n` +
`export function messagesFor(locale: string): MessageDict {\n` +
`\treturn MESSAGE_CATALOG[locale] ?? {};\n` +
`}\n\n` +
`/** Load (and cache) one locale pack. Idempotent; safe on SSR and client. */\n` +
`export async function loadMessages(locale: string): Promise<MessageDict> {\n` +
`\tconst cached = MESSAGE_CATALOG[locale];\n` +
`\tif (cached) return cached;\n` +
`\tconst loader = LOCALE_LOADERS[locale];\n` +
`\tif (!loader) return {};\n` +
`\tconst mod = await loader();\n` +
`\tconst pack = mod[locale] ?? {};\n` +
`\tMESSAGE_CATALOG[locale] = pack;\n` +
`\treturn pack;\n` +
`}\n\n` +
`/** Load every registered locale (tests / offline coverage tools). */\n` +
`export async function loadAllMessages(): Promise<Record<string, MessageDict>> {\n` +
`\tawait Promise.all(Object.keys(LOCALE_LOADERS).map((code) => loadMessages(code)));\n` +
`\treturn { ...MESSAGE_CATALOG };\n` +
`}\n`
);
}
async function listLocalePackCodes(): Promise<string[]> {
const dir = messagesDir();
const names = await fs.readdir(dir);
const allowed = new Set(UI_LOCALES.map((l) => l.code));
const codes: string[] = [];
for (const name of names) {
if (!name.endsWith(".ts") || RESERVED_FILES.has(name)) continue;
const code = name.slice(0, -3);
if (!allowed.has(code)) continue;
codes.push(code);
}
if (!codes.includes(DEFAULT_UI_LOCALE)) {
codes.push(DEFAULT_UI_LOCALE);
}
codes.sort((a, b) => {
if (a === DEFAULT_UI_LOCALE) return -1;
if (b === DEFAULT_UI_LOCALE) return 1;
return a.localeCompare(b);
});
return codes;
}
async function readLocaleDict(locale: string): Promise<MessageDict> {
const file = localeFilePath(locale);
try {
const raw = await fs.readFile(file, "utf8");
return parseMessageDictFromTs(raw, locale);
} catch (err) {
const code = (err as NodeJS.ErrnoException)?.code;
if (code === "ENOENT") return {};
throw err;
}
}
export type DiskLocaleCoverage = {
code: string;
label: string;
total: number;
translated: number;
missing: number;
missing_keys: string[];
registered: boolean;
};
/** Live disk snapshot — avoids stale Vite/module cache after admin saves. */
export async function loadCatalogFromDisk(): Promise<{
keys: string[];
catalog: Record<string, MessageDict>;
coverage: DiskLocaleCoverage[];
registered: string[];
}> {
const registered = await listLocalePackCodes();
const registeredSet = new Set(registered);
const base = await readLocaleDict(DEFAULT_UI_LOCALE);
const keys = Object.keys(base).sort((a, b) => a.localeCompare(b));
const catalog: Record<string, MessageDict> = {};
const coverage: DiskLocaleCoverage[] = [];
for (const locale of UI_LOCALES) {
const pack = locale.code === DEFAULT_UI_LOCALE ? base : await readLocaleDict(locale.code);
catalog[locale.code] = pack;
const missing_keys: string[] = [];
for (const key of keys) {
const value = pack[key];
if (typeof value !== "string" || !value.trim()) missing_keys.push(key);
}
coverage.push({
code: locale.code,
label: locale.label,
total: keys.length,
translated: keys.length - missing_keys.length,
missing: missing_keys.length,
missing_keys,
registered: registeredSet.has(locale.code)
});
}
return { keys, catalog, coverage, registered };
}
/**
* Extract flat string entries from `export const {locale}: MessageDict = { ... }`.
* Only accepts JSON-style quoted keys/values (as written by serializeMessagePack).
*/
export function parseMessageDictFromTs(source: string, locale: string): MessageDict {
const marker = `export const ${locale}`;
const start = source.indexOf(marker);
if (start < 0) return {};
const brace = source.indexOf("{", start);
if (brace < 0) return {};
let depth = 0;
let end = -1;
for (let i = brace; i < source.length; i++) {
const ch = source[i];
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
if (end < 0) return {};
const objectLiteral = source.slice(brace + 1, end);
const out: MessageDict = {};
const entryRe = /"((?:\\.|[^"\\])*)"\s*:\s*"((?:\\.|[^"\\])*)"/g;
let match: RegExpExecArray | null;
while ((match = entryRe.exec(objectLiteral)) !== null) {
const key = JSON.parse(`"${match[1]}"`) as string;
const value = JSON.parse(`"${match[2]}"`) as string;
if (isValidMessageKey(key)) out[key] = value;
}
return out;
}
async function writeAtomic(filePath: string, contents: string): Promise<void> {
const tmp = `${filePath}.${process.pid}.tmp`;
await fs.writeFile(tmp, contents, "utf8");
await fs.rename(tmp, filePath);
}
async function rewriteCatalog(): Promise<void> {
const codes = await listLocalePackCodes();
const catalogPath = path.join(messagesDir(), "catalog.ts");
await writeAtomic(catalogPath, serializeCatalog(codes));
}
/**
* Merge updates into a locale pack on disk and refresh catalog.ts registration.
* Empty values remove keys (non-en) so English fallback applies.
*/
export async function applyLocaleUpdates(locale: string, updates: MessageDict): Promise<MessageDict> {
if (!isUILocale(locale)) {
throw new Error("Unsupported locale.");
}
const current = await readLocaleDict(locale);
const next: MessageDict = { ...current };
for (const [key, value] of Object.entries(updates)) {
const trimmed = value.trim();
if (!trimmed) {
if (locale === DEFAULT_UI_LOCALE) {
throw new Error(`English key "${key}" cannot be cleared.`);
}
delete next[key];
continue;
}
next[key] = value;
}
await writeAtomic(localeFilePath(locale), serializeMessagePack(locale, next));
await rewriteCatalog();
return next;
}
export function sourceOfTruthNote(): string {
return "File-based source of truth: apps/web/src/lib/i18n/messages/{locale}.ts (registered in catalog.ts). Commit changes after editing.";
}
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readLimitedJsonBody } from "./read-limited-json-body.ts";
const MAX = 64;
function jsonRequest(body: string, headers: Record<string, string> = {}): Request {
return new Request("http://localhost:28472/admin/translations/catalog", {
method: "PATCH",
headers: {
"content-type": "application/json",
...headers
},
body
});
}
describe("readLimitedJsonBody", () => {
it("parses a small JSON body", async () => {
const result = await readLimitedJsonBody(
jsonRequest(JSON.stringify({ locale: "nl", updates: { a: "b" } })),
MAX
);
assert.equal(result.ok, true);
if (result.ok) {
assert.deepEqual(result.body, { locale: "nl", updates: { a: "b" } });
}
});
it("returns 413 kind when Content-Length exceeds max", async () => {
const result = await readLimitedJsonBody(
jsonRequest("{}", { "content-length": String(MAX + 1) }),
MAX
);
assert.deepEqual(result, { ok: false, kind: "payload_too_large" });
});
it("returns 413 kind when Content-Length is non-finite", async () => {
const result = await readLimitedJsonBody(
jsonRequest("{}", { "content-length": "nope" }),
MAX
);
assert.deepEqual(result, { ok: false, kind: "payload_too_large" });
});
it("returns 413 kind when streamed body exceeds max without Content-Length", async () => {
const oversized = "x".repeat(MAX + 8);
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
}
});
const request = new Request("http://localhost:28472/admin/translations/catalog", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: stream,
duplex: "half"
} as RequestInit & { duplex: "half" });
const result = await readLimitedJsonBody(request, MAX);
assert.deepEqual(result, { ok: false, kind: "payload_too_large" });
});
it("returns invalid_json for empty body", async () => {
const result = await readLimitedJsonBody(jsonRequest(""), MAX);
assert.deepEqual(result, { ok: false, kind: "invalid_json" });
});
it("returns invalid_json for malformed JSON", async () => {
const result = await readLimitedJsonBody(jsonRequest("{not-json"), MAX);
assert.deepEqual(result, { ok: false, kind: "invalid_json" });
});
});
@@ -0,0 +1,67 @@
/**
* Size-capped JSON body reader for mutating admin endpoints.
* Kept free of $app/$env imports so unit tests can run under node:test.
*/
export type ReadLimitedJsonResult =
| { ok: true; body: unknown }
| { ok: false; kind: "payload_too_large" | "invalid_json" };
/**
* Read a JSON request body, rejecting early when Content-Length or streamed
* bytes exceed `maxBytes`.
*/
export async function readLimitedJsonBody(
request: Request,
maxBytes: number
): Promise<ReadLimitedJsonResult> {
const contentLength = request.headers.get("content-length");
if (contentLength !== null) {
const n = Number(contentLength);
if (!Number.isFinite(n) || n < 0 || n > maxBytes) {
await request.body?.cancel().catch(() => undefined);
return { ok: false, kind: "payload_too_large" };
}
}
const reader = request.body?.getReader();
if (!reader) {
return { ok: false, kind: "invalid_json" };
}
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!value?.byteLength) continue;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel().catch(() => undefined);
return { ok: false, kind: "payload_too_large" };
}
chunks.push(value);
}
} catch {
return { ok: false, kind: "invalid_json" };
}
if (total === 0) {
return { ok: false, kind: "invalid_json" };
}
const buf = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
buf.set(chunk, offset);
offset += chunk.byteLength;
}
try {
const text = new TextDecoder("utf-8", { fatal: true }).decode(buf);
return { ok: true, body: JSON.parse(text) as unknown };
} catch {
return { ok: false, kind: "invalid_json" };
}
}
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { isHttpError } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import {
assertSameOrigin,
isFullPlatformAdmin,
requirePlatformAdminServer
} from "./require-platform-admin.ts";
function makeEvent(opts: {
url?: string;
headers?: Record<string, string>;
fetchImpl?: RequestEvent["fetch"];
}): RequestEvent {
const url = new URL(opts.url ?? "http://localhost:28472/admin/translations/catalog");
const headers = new Headers(opts.headers);
return {
url,
request: new Request(url, { method: "PATCH", headers }),
fetch: opts.fetchImpl ?? globalThis.fetch.bind(globalThis)
} as unknown as RequestEvent;
}
function expectHttp(status: number, run: () => unknown): void {
try {
run();
assert.fail(`expected HttpError ${status}`);
} catch (err) {
assert.equal(isHttpError(err), true);
assert.equal((err as { status: number }).status, status);
}
}
async function expectHttpAsync(status: number, run: () => Promise<unknown>): Promise<void> {
try {
await run();
assert.fail(`expected HttpError ${status}`);
} catch (err) {
assert.equal(isHttpError(err), true);
assert.equal((err as { status: number }).status, status);
}
}
describe("isFullPlatformAdmin", () => {
it("allows staff_access.full_admin", () => {
assert.equal(isFullPlatformAdmin({ staff_access: { full_admin: true } }), true);
});
it("denies support_staff even when legacy is_platform_admin is set", () => {
assert.equal(
isFullPlatformAdmin({
staff_access: { full_admin: false },
user: { is_platform_admin: true }
}),
false
);
});
it("denies when staff_access is present without full_admin", () => {
assert.equal(isFullPlatformAdmin({ staff_access: {} }), false);
});
it("legacy: allows is_platform_admin when staff_access is absent", () => {
assert.equal(isFullPlatformAdmin({ user: { is_platform_admin: true } }), true);
});
it("legacy: denies when neither staff_access nor is_platform_admin", () => {
assert.equal(isFullPlatformAdmin({ user: { is_platform_admin: false } }), false);
assert.equal(isFullPlatformAdmin({}), false);
});
it("treats null staff_access as legacy path", () => {
assert.equal(
isFullPlatformAdmin({ staff_access: null, user: { is_platform_admin: true } }),
true
);
});
});
describe("requirePlatformAdminServer", () => {
it("returns me for full_admin", async () => {
const me = { staff_access: { full_admin: true }, user: { is_platform_admin: true } };
const event = makeEvent({
fetchImpl: async () =>
new Response(JSON.stringify(me), {
status: 200,
headers: { "content-type": "application/json" }
})
});
assert.deepEqual(await requirePlatformAdminServer(event), me);
});
it("403 for support_staff with legacy is_platform_admin", async () => {
const me = { staff_access: { full_admin: false }, user: { is_platform_admin: true } };
const event = makeEvent({
fetchImpl: async () =>
new Response(JSON.stringify(me), {
status: 200,
headers: { "content-type": "application/json" }
})
});
await expectHttpAsync(403, () => requirePlatformAdminServer(event));
});
it("401 when /me is unauthorized", async () => {
const event = makeEvent({
fetchImpl: async () => new Response("{}", { status: 401 })
});
await expectHttpAsync(401, () => requirePlatformAdminServer(event));
});
it("legacy /me without staff_access still requires is_platform_admin", async () => {
const event = makeEvent({
fetchImpl: async () =>
new Response(JSON.stringify({ user: { is_platform_admin: false } }), {
status: 200,
headers: { "content-type": "application/json" }
})
});
await expectHttpAsync(403, () => requirePlatformAdminServer(event));
});
});
describe("assertSameOrigin", () => {
it("allows matching Origin", () => {
assertSameOrigin(
makeEvent({ headers: { origin: "http://localhost:28472" } })
);
});
it("rejects mismatched Origin", () => {
expectHttp(403, () =>
assertSameOrigin(
makeEvent({ headers: { origin: "https://evil.example" } })
)
);
});
it("rejects Sec-Fetch-Site cross-site when Origin absent", () => {
expectHttp(403, () =>
assertSameOrigin(
makeEvent({ headers: { "sec-fetch-site": "cross-site" } })
)
);
});
it("allows Sec-Fetch-Site same-origin when Origin absent", () => {
assertSameOrigin(
makeEvent({ headers: { "sec-fetch-site": "same-origin" } })
);
});
it("allows Sec-Fetch-Site none when Origin absent", () => {
assertSameOrigin(makeEvent({ headers: { "sec-fetch-site": "none" } }));
});
it("allows matching Referer when Origin and Sec-Fetch-Site absent", () => {
assertSameOrigin(
makeEvent({
headers: { referer: "http://localhost:28472/admin/translations" }
})
);
});
it("fail-closed: rejects when Origin, Sec-Fetch-Site, and Referer are all absent", () => {
expectHttp(403, () => assertSameOrigin(makeEvent({ headers: {} })));
});
it("fail-closed: rejects mismatched Referer when Origin absent", () => {
expectHttp(403, () =>
assertSameOrigin(
makeEvent({ headers: { referer: "https://evil.example/x" } })
)
);
});
it("fail-closed: rejects same-site without Origin or matching Referer", () => {
expectHttp(403, () =>
assertSameOrigin(
makeEvent({ headers: { "sec-fetch-site": "same-site" } })
)
);
});
});
@@ -0,0 +1,85 @@
/**
* Platform-admin gate for SvelteKit server endpoints.
* Kept free of $app/$env imports so unit tests can run under node:test.
* PUBLIC_API_URL is read from process.env (Vite + adapter-node inject PUBLIC_*).
*/
import { error } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import {
isFullPlatformAdmin,
type StaffAccessMe
} from "../staff-access.ts";
export { isFullPlatformAdmin } from "../staff-access.ts";
type MeStaff = StaffAccessMe;
/** Absolute /api/auth/me when PUBLIC_API_URL is set (Compose/adapter-node); else Vite same-origin proxy. */
function authMeUrl(): string {
const base = (process.env.PUBLIC_API_URL ?? "").replace(/\/$/, "");
return base ? `${base}/api/auth/me` : "/api/auth/me";
}
/**
* Server-side platform-admin gate for SvelteKit endpoints.
* Uses event.fetch so session cookies reach the Go /api/auth/me (Vite proxy or PUBLIC_API_URL).
*/
export async function requirePlatformAdminServer(event: RequestEvent): Promise<MeStaff> {
const headers: Record<string, string> = { Accept: "application/json" };
const cookie = event.request.headers.get("cookie");
if (cookie) headers.cookie = cookie;
const res = await event.fetch(authMeUrl(), { headers });
if (res.status === 401) {
error(401, "Authentication required.");
}
if (!res.ok) {
error(res.status === 403 ? 403 : 502, "Failed to verify admin access.");
}
const me = (await res.json()) as MeStaff;
if (!isFullPlatformAdmin(me)) {
error(403, "Platform admin required.");
}
return me;
}
function originOf(url: string): string | null {
try {
return new URL(url).origin;
} catch {
return null;
}
}
/**
* Same-origin check for mutating admin endpoints (CSRF defense).
* Fail closed when Origin is absent: require Sec-Fetch-Site same-origin/none
* or a matching Referer. Never allow cross-site.
*/
export function assertSameOrigin(event: RequestEvent): void {
const expected = event.url.origin;
const origin = event.request.headers.get("origin");
if (origin) {
if (origin !== expected) {
error(403, "Forbidden origin.");
}
return;
}
const site = (event.request.headers.get("sec-fetch-site") ?? "").toLowerCase();
if (site === "cross-site") {
error(403, "Forbidden origin.");
}
if (site === "same-origin" || site === "none") {
return;
}
const referer = event.request.headers.get("referer");
if (referer) {
const refOrigin = originOf(referer);
if (refOrigin && refOrigin === expected) {
return;
}
}
error(403, "Forbidden origin.");
}