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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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("; ");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
Reference in New Issue
Block a user