Merge branch 'fix/compose-env-and-claims' into release/v1.39.3

This commit is contained in:
Marc-André Bombeck committed 2026-09-26 17:00:46 +02:00
commit 026e8fc1ed
64 files changed
+1059 -158

No files matched your search

@@ -46,6 +46,13 @@ const SRC = join(ROOT, "src");
const DIRECT_READ =
/process\.env(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[\s*["'`]([A-Za-z_][A-Za-z0-9_]*)["'`]\s*\])/g;
/**
* `envValue("NAME")`, `envOr("NAME", …)` and `envFlag("NAME")` from
* `src/lib/env.ts` — the one helper every read with a fallback goes through,
* so its call shape is a read wherever it appears. Asserted non-empty in T2.
*/
const HELPER_READ = /\benv(?:Value|Or|Flag)\(\s*["'`]([A-Z][A-Z0-9_]*)["'`]/g;
/**
* Modules that index `process.env` through a variable rather than a literal.
* The direct matcher cannot see their reads at all, so each names the exact
@@ -154,6 +161,7 @@ function runtimeReads(): Set<string> {
for (const rel of sourceFiles()) {
const src = read(rel);
for (const m of src.matchAll(DIRECT_READ)) names.add(m[1] ?? m[2]);
for (const m of src.matchAll(HELPER_READ)) names.add(m[1]);
}
for (const reader of INDIRECT_READERS) {
for (const m of read(reader.file).matchAll(reader.pattern)) names.add(m[1]);
@@ -191,6 +199,13 @@ describe("compose env whitelist", () => {
});
it("T2 — every indirect reader still matches its call shape", () => {
const helperReads = sourceFiles().flatMap((rel) =>
[...read(rel).matchAll(HELPER_READ)].map((m) => m[1]),
);
// The env helper is the common path for reads with a fallback; a matcher
// that stopped seeing it would drop those names from the read set.
expect(helperReads.length).toBeGreaterThan(10);
expect(helperReads).toContain("IP_GEO_LOOKUP_URL");
for (const reader of INDIRECT_READERS) {
const found = [...read(reader.file).matchAll(reader.pattern)].map(
(m) => m[1],
@@ -0,0 +1,111 @@
/**
* Structural guard: a script a runbook tells the operator to run inside the
* container is actually in the image, together with every local file it
* imports.
*
* ## The failure this freezes
*
* `docs/ops/password-reset.md` told operators to run
* `docker compose exec app node scripts/reset-password.mjs`, and the runner
* stage of the Dockerfile copied nothing from `scripts/`: the standalone
* output does not carry it. The recovery path for a locked-out admin failed
* with "Cannot find module" at the moment it was needed. The intake-repair
* runbook had the same gap under the `healthlog-tsx` launcher.
*
* ## What it proves and what it does not
*
* Every `docker compose exec … node scripts/…` or `healthlog-tsx scripts/…`
* command in `docs/` names
* a file the runner stage copies, and every relative import of that file is
* copied too. Whether the package imports resolve is proved at image build by
* the `RUN` checks next to the copies; this file cannot see the traced tree.
* A command written in some other form (a different launcher) is outside the
* matcher.
*/
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { dirname, join, normalize } from "node:path";
const ROOT = process.cwd();
const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8");
/**
* In-container command forms: `docker compose exec <service> node scripts/…`
* or the image's own `healthlog-tsx scripts/…` launcher. A bare
* `node scripts/…` is a workstation or source-checkout command and is out of
* scope.
*/
const CONTAINER_COMMAND =
/(?:docker compose exec\s+(?:-\S+\s+)*\S+\s+node|\bhealthlog-tsx)\s+(scripts\/[\w.-]+)/g;
function markdownFiles(dir: string): string[] {
return readdirSync(dir, { recursive: true, encoding: "utf8" })
.filter((rel) => rel.endsWith(".md"))
.map((rel) => join(dir, rel));
}
function runnerStage(): string {
const start = DOCKERFILE.search(/^FROM .* AS runner$/m);
expect(start, "the Dockerfile has a runner stage").toBeGreaterThan(0);
return DOCKERFILE.slice(start);
}
function copiedIntoRunner(path: string): boolean {
const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(
`^COPY --from=builder (?:--chown=\\S+ )?/app/${escaped} \\./${escaped}$`,
"m",
).test(runnerStage());
}
/** Scripts named by a container command anywhere under docs/. */
function documentedContainerScripts(): string[] {
const found = new Set<string>();
for (const file of markdownFiles(join(ROOT, "docs"))) {
for (const m of readFileSync(file, "utf8").matchAll(CONTAINER_COMMAND)) {
found.add(m[1]);
}
}
return [...found].sort();
}
/** Relative import specifiers of a script, resolved against its location. */
function localImports(script: string): string[] {
const src = readFileSync(join(ROOT, script), "utf8");
const specs = [
...src.matchAll(/^\s*import\s[^;]*?from\s+["'](\.{1,2}\/[^"']+)["']/gm),
].map((m) => m[1]);
return specs.map((spec) => normalize(join(dirname(script), spec)));
}
describe("scripts the runbooks run inside the container ship in the image", () => {
it("finds the commands it is meant to check", () => {
const scripts = documentedContainerScripts();
// A matcher that found nothing would agree with any Dockerfile.
expect(scripts).toContain("scripts/reset-password.mjs");
expect(scripts).toContain("scripts/repair-intake-anomalies.ts");
expect(localImports("scripts/reset-password.mjs")).toContain(
"src/lib/auth/argon2-params.mjs",
);
expect(copiedIntoRunner("scripts/not-a-real-script.mjs")).toBe(false);
});
it("copies every documented script into the runner stage", () => {
const missing = documentedContainerScripts().filter(
(script) => !copiedIntoRunner(script),
);
expect(
missing,
"A runbook runs these inside the container, but the Dockerfile runner " +
"stage does not COPY them. Add the COPY (and its local imports), or " +
"change the runbook to a source-checkout command.",
).toEqual([]);
});
it("copies every local file those scripts import", () => {
const missing = documentedContainerScripts()
.flatMap((script) => localImports(script))
.filter((path) => !copiedIntoRunner(path));
expect(missing).toEqual([]);
});
});
@@ -0,0 +1,107 @@
/**
* Structural guard: an environment variable with a fallback is read through
* `src/lib/env.ts`, never as `process.env.NAME ?? fallback`.
*
* ## The failure this freezes
*
* `docker-compose.yml` forwards its whitelist as `"${NAME:-}"`, so every
* listed variable the operator left unset arrives as the empty string. `??`
* only replaces `null` and `undefined`, so the empty string wins and the
* fallback never applies. On a stock compose stack that sent Withings an
* OAuth `redirect_uri=""`, handed the weather client an empty base URL
* (`ERR_INVALID_URL` on every fetch) and pointed the IP geo lookup at a
* relative path. The same shape with a method in between,
* `process.env.NAME?.replace(...) ?? fallback`, fails the same way.
*
* ## What it proves and what it does not
*
* It proves no source file under `src/` writes a nullish fallback directly
* against a `process.env` read. It does not see a read stored in a local
* first and defaulted later (`const x = process.env.NAME; x ?? y`); those
* stay a review question. `||` is allowed: it already treats the empty
* string as unset, and client components need a literal
* `process.env.NEXT_PUBLIC_X` for build-time inlining.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/**
* `process.env.NAME` or `process.env["NAME"]`, optionally followed by
* optional-chained calls such as `?.trim()` or `?.replace(/x/, "")`, then
* `??` across any whitespace including a line break.
*/
const NULLISH_ENV_FALLBACK =
/process\.env(?:\.[A-Za-z_][A-Za-z0-9_]*|\[\s*["'`][A-Za-z_][A-Za-z0-9_]*["'`]\s*\])(?:\?\.[A-Za-z_]+\((?:[^()]|\([^()]*\))*\))*\s*\?\?/g;
/**
* Files that still carry the pattern because another change owns them.
* Each entry fails T3 once the file no longer matches, so the list can only
* shrink.
*/
const PENDING: Readonly<Record<string, string>> = {
"lib/withings/client.ts":
"WITHINGS_REDIRECT_URI ?? derived callback; the integration clients are being reworked in a parallel change that owns this file",
};
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((rel) => !rel.startsWith("generated/"))
.filter((rel) => !rel.includes("__tests__"))
.filter((rel) => !rel.endsWith(".test.ts") && !rel.endsWith(".test.tsx"))
.filter((rel) => rel !== "lib/env.ts");
}
function offenders(): string[] {
return sourceFiles().filter((rel) => {
const src = readFileSync(join(SRC, rel), "utf8");
return [...src.matchAll(NULLISH_ENV_FALLBACK)].length > 0;
});
}
describe("an empty environment variable counts as unset", () => {
it("T1 — the matcher catches every shape it claims to", () => {
const shapes = [
`const a = process.env.FOO ?? "x";`,
`const b = process.env.FOO\n ?? "x";`,
`const c = process.env["FOO"] ?? "x";`,
`const d = process.env.FOO?.replace(/\\/$/, "") ?? "x";`,
`const e = process.env.FOO?.trim()?.toLowerCase() ?? "x";`,
];
for (const shape of shapes) {
expect(
[...shape.matchAll(NULLISH_ENV_FALLBACK)].length,
shape,
).toBeGreaterThan(0);
}
for (const allowed of [
`const f = process.env.FOO || "x";`,
`const g = envOr("FOO", "x");`,
`const h = process.env.FOO === "1" ? a ?? b : c;`,
]) {
expect([...allowed.matchAll(NULLISH_ENV_FALLBACK)], allowed).toEqual([]);
}
});
it("T2 — no source file defaults a process.env read with ??", () => {
const found = offenders().filter((rel) => !(rel in PENDING));
expect(
found,
"Read these through envValue/envOr/envFlag from @/lib/env. Compose " +
"passes an unset variable as the empty string, and ?? keeps it.",
).toEqual([]);
});
it("T3 — every pending entry still matches, so the list only shrinks", () => {
const current = new Set(offenders());
const stale = Object.keys(PENDING).filter((rel) => !current.has(rel));
expect(
stale,
"No longer carries the pattern; drop the entry from PENDING.",
).toEqual([]);
});
});
@@ -0,0 +1,66 @@
/**
* The HaveIBeenPwned check sits behind one operator switch, and nothing
* reaches it except through that switch.
*
* `PASSWORD_BREACH_CHECK_DISABLED` is how an operator stops the one request
* a password change makes to a third party. A route that imported the raw
* check from `@/lib/auth/hibp` would keep sending it with the switch on, so
* the import itself is frozen: only `src/lib/password-breach-check.ts` (and
* tests) may name that module.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const checkPasswordBreach = vi.fn();
vi.mock("@/lib/auth/hibp", () => ({
checkPasswordBreach: (password: string) => checkPasswordBreach(password),
}));
const SRC = join(process.cwd(), "src");
const RAW_IMPORT = /from\s+["']@\/lib\/auth\/hibp["']|["']\.\/hibp["']/;
afterEach(() => {
vi.unstubAllEnvs();
checkPasswordBreach.mockReset();
});
describe("password breach check switch", () => {
it("calls the range API when the switch is unset or empty", async () => {
checkPasswordBreach.mockResolvedValue({ breached: true, count: 3 });
const { checkPasswordBreachIfEnabled } =
await import("@/lib/password-breach-check");
vi.stubEnv("PASSWORD_BREACH_CHECK_DISABLED", "");
await expect(
checkPasswordBreachIfEnabled("correct horse battery"),
).resolves.toEqual({ breached: true, count: 3 });
expect(checkPasswordBreach).toHaveBeenCalledTimes(1);
});
it.each(["1", "true", "yes"])(
"makes no request when PASSWORD_BREACH_CHECK_DISABLED=%s",
async (value) => {
vi.stubEnv("PASSWORD_BREACH_CHECK_DISABLED", value);
const { checkPasswordBreachIfEnabled } =
await import("@/lib/password-breach-check");
await expect(
checkPasswordBreachIfEnabled("correct horse battery"),
).resolves.toBeNull();
expect(checkPasswordBreach).not.toHaveBeenCalled();
},
);
it("is the only non-test importer of the raw check", () => {
const importers = walkSourceFiles(SRC, { floor: 3000 })
.filter((rel) => !rel.startsWith("generated/"))
.filter((rel) => !rel.includes("__tests__"))
.filter((rel) => !rel.endsWith(".test.ts"))
.filter((rel) => rel !== "lib/auth/hibp.ts")
.filter((rel) => RAW_IMPORT.test(readFileSync(join(SRC, rel), "utf8")));
// Non-empty by construction: the wrapper itself must match, or the
// matcher went stale and this assertion would pass on nothing.
expect(importers).toEqual(["lib/password-breach-check.ts"]);
});
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { envOr } from "@/lib/env";
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
@@ -95,7 +96,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
"api.groq.com",
"openrouter.ai",
]);
for (const h of (process.env.ADMIN_AI_BASE_URL_ALLOWLIST ?? "")
for (const h of envOr("ADMIN_AI_BASE_URL_ALLOWLIST", "")
.split(",")
.map((h) => h.trim())
.filter(Boolean)) {
+6 -5
View File
@@ -3,9 +3,10 @@
*
* v1.23 — admin-triggered encryption-key rotation. Enqueues the
* `encryption-key-rotate` pg-boss job, which re-encrypts the whole encrypted-
* column corpus to the configured ACTIVE key id. The CLI
* (`scripts/rotate-encryption-key.ts`) stays the canonical path; this is a
* convenience that reuses the same column registry and is safe by construction:
* column corpus to the configured ACTIVE key id. This is the primary path on
* the published image, which contains neither a package manager nor
* `scripts/rotate-encryption-key.ts`; the CLI runs from a source checkout and
* reuses the same column registry. Safe by construction:
*
* - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: the job only writes the active
* key and never touches `ENCRYPTION_KEYS`. Dropping a key remains an env +
@@ -16,8 +17,8 @@
* Auth: cookie-only `requireAdmin` (Bearer can never reach admin) PLUS
* `requireFreshMfa` step-up — re-encrypting the whole sensitive corpus is a
* high-blast-radius action. An admin without a second factor cannot use the
* button and falls back to the CLI (this is intentional: step-up cannot be
* softened, and the CLI is the documented canonical path).
* button and falls back to the CLI from a source checkout (intentional:
* step-up cannot be softened).
*/
import {
apiHandler,
@@ -1,3 +1,4 @@
import { envValue } from "@/lib/env";
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -37,8 +38,8 @@ function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
function resolveAppUrl(request: NextRequest): URL {
const configured =
process.env.APP_URL ??
process.env.NEXT_PUBLIC_APP_URL ??
envValue("APP_URL") ??
envValue("NEXT_PUBLIC_APP_URL") ??
`https://${request.headers.get("host") ?? "localhost:3000"}`;
try {
+3 -2
View File
@@ -1,3 +1,4 @@
import { envOr } from "@/lib/env";
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -14,7 +15,7 @@ const BUILD_TIME = process.env.BUILD_TIMESTAMP || new Date().toISOString();
const START_TIME = new Date().toISOString();
// Try to read git commit info
let gitCommit = process.env.GIT_COMMIT ?? "unknown";
let gitCommit = envOr("GIT_COMMIT", "unknown");
try {
const head = readFileSync(".git/HEAD", "utf-8").trim();
if (head.startsWith("ref:")) {
@@ -69,7 +70,7 @@ export const GET = apiHandler(async () => {
const failingJobs = await readFailingQueues();
return apiSuccess({
version: process.env.npm_package_version ?? "0.1.0",
version: process.env.npm_package_version || "0.1.0",
nodeVersion: process.version,
gitCommit,
buildTime: BUILD_TIME,
@@ -14,7 +14,7 @@ import { destroyAllSessions } from "@/lib/auth/session";
import { revokeStepUpElevations } from "@/lib/auth/step-up";
import { NextRequest, NextResponse } from "next/server";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { checkPasswordBreach } from "@/lib/auth/hibp";
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export const POST = apiHandler(
@@ -70,7 +70,7 @@ export const POST = apiHandler(
// v1.23 — reject an admin-set password found in a known breach corpus
// (HIBP k-anonymity). Fail-open on an unreachable HIBP.
const breach = await checkPasswordBreach(password);
const breach = await checkPasswordBreachIfEnabled(password);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
+2 -2
View File
@@ -24,7 +24,7 @@ import { annotate } from "@/lib/logging/context";
import { destroyAllSessions, createSession } from "@/lib/auth/session";
import { revokeStepUpElevations } from "@/lib/auth/step-up";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { checkPasswordBreach } from "@/lib/auth/hibp";
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export const POST = apiHandler(async (request: NextRequest) => {
@@ -89,7 +89,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// corpus (HIBP k-anonymity). Fail-open: a null result (HIBP unreachable)
// never blocks the change. Only the user's chosen NEW password is checked —
// existing credentials are never retroactively blocked at login.
const breach = await checkPasswordBreach(newPassword);
const breach = await checkPasswordBreachIfEnabled(newPassword);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
+2 -2
View File
@@ -21,7 +21,7 @@ import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { checkPasswordBreach } from "@/lib/auth/hibp";
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
isValidTimezone,
@@ -173,7 +173,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.23 — reject a registration password found in a known breach corpus
// (HIBP k-anonymity). Fail-open on an unreachable HIBP.
const breach = await checkPasswordBreach(password);
const breach = await checkPasswordBreachIfEnabled(password);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
+2 -1
View File
@@ -1,3 +1,4 @@
import { envOr } from "@/lib/env";
import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -190,7 +191,7 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
// Optional: operators can point this at their deployment
// dashboard (e.g. the Coolify UI). Falls back to an empty
// string so the body template still composes cleanly.
logsUrl: process.env.DEPLOY_LOGS_URL ?? "",
logsUrl: envOr("DEPLOY_LOGS_URL", ""),
},
metadata: {
source: "deploy-webhook",
+2 -1
View File
@@ -1,3 +1,4 @@
import { envValue } from "@/lib/env";
import { prisma } from "@/lib/db";
import {
apiError,
@@ -50,7 +51,7 @@ export const GET = apiHandler(async () => {
});
async function registerTelegramWebhook(botToken: string) {
const appUrl = process.env.APP_URL ?? process.env.NEXT_PUBLIC_APP_URL;
const appUrl = envValue("APP_URL") ?? envValue("NEXT_PUBLIC_APP_URL");
if (!appUrl) {
return apiError(
"Server configuration error: APP_URL (or NEXT_PUBLIC_APP_URL) is missing.",
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn().mockResolvedValue({ user: { id: "u1" } }),
}));
const safeFetchMock = vi.fn();
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: (...args: unknown[]) => safeFetchMock(...args),
}));
import { GET } from "../route";
const call = () => (GET as unknown as () => Promise<Response>)();
afterEach(() => {
vi.unstubAllEnvs();
safeFetchMock.mockReset();
});
describe("GET /api/version/check-updates", () => {
it.each(["1", "true", "yes"])(
"makes no outbound request when UPDATE_CHECK_DISABLED=%s",
async (value) => {
vi.stubEnv("UPDATE_CHECK_DISABLED", value);
const body = (await (await call()).json()) as {
data: { status: string; reason: string };
};
expect(body.data.status).toBe("unknown");
expect(body.data.reason).toBe("disabled");
expect(safeFetchMock).not.toHaveBeenCalled();
},
);
it("asks the release feed when the switch is unset or empty", async () => {
vi.stubEnv("UPDATE_CHECK_DISABLED", "");
safeFetchMock.mockResolvedValue(
Response.json({ tag_name: "v999.0.0", html_url: "https://x.test" }),
);
const body = (await (await call()).json()) as {
data: { status: string };
};
expect(safeFetchMock).toHaveBeenCalledTimes(1);
expect(body.data.status).toBe("newer_available");
});
});
@@ -1,5 +1,6 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { envFlag } from "@/lib/env";
import { annotate } from "@/lib/logging/context";
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
@@ -27,6 +28,12 @@ export const dynamic = "force-dynamic";
* repo missing the release manifest); UI surfaces a non-blocking
* warning so the user can retry
*
* `UPDATE_CHECK_DISABLED` (1, true, yes or on) stops the request to GitHub
* entirely: the route answers `unknown` with `reason: "disabled"` and makes
* no outbound call. The About page and the admin version tile run this check
* on their own once a day, so an instance that promises no third-party
* requests needs the switch.
*
* Responses are not cached at the route level. The browser bears the
* load with React Query's `staleTime`, so a single user spamming
* "Check now" still hits the GH API at most every 30 seconds.
@@ -78,6 +85,15 @@ export const GET = apiHandler(async () => {
const current = packageJson.version;
if (envFlag("UPDATE_CHECK_DISABLED")) {
annotate({ meta: { outcome: "disabled" } });
return apiSuccess({
status: "unknown" as const,
current,
reason: "disabled",
});
}
let release: GithubReleaseShape | null = null;
const callStart = Date.now();
try {
+1 -1
View File
@@ -4,7 +4,7 @@ import { useEffect } from "react";
import { ErrorDetails } from "@/components/error-details";
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
function isChunkLoadError(err: Error & { digest?: string }): boolean {
if (err.name === "ChunkLoadError") return true;
+1 -1
View File
@@ -127,7 +127,7 @@ export default async function RootLayout({
id="healthlog-i18n-boot"
strategy="beforeInteractive"
nonce={nonce}
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}`}
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION || "dev"}`}
/>
</head>
<body className={`${inter.variable} font-sans antialiased`}>
@@ -80,6 +80,9 @@ export function VersionTileSection() {
const newer = update?.status === "newer_available";
const upToDate = update?.status === "up_to_date";
// The operator turned the GitHub request off (UPDATE_CHECK_DISABLED); say
// so rather than leaving the slot empty like an unreachable feed.
const checkOff = update?.status === "unknown" && update.reason === "disabled";
const sha = shortSha(version?.buildSha ?? null);
return (
@@ -110,6 +113,10 @@ export function VersionTileSection() {
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpToDate")}
</span>
) : checkOff ? (
<span className="text-muted-foreground text-xs">
{t("admin.overview.versionTileCheckOff")}
</span>
) : null
}
/>
@@ -134,7 +134,7 @@ describe("<DoneScreen> AI panel", () => {
const html = render();
expect(html).toContain('data-slot="onboarding-ai-panel"');
// Local model is surfaced first (the calm private default).
expect(html).toContain("nothing leaves your network");
expect(html).toContain("AI requests stay on your network");
// The honest release valve is present and prominent.
expect(html).toContain('data-slot="onboarding-ai-keyless"');
expect(html).toContain("fully useful without AI");
+1 -1
View File
@@ -48,7 +48,7 @@ import {
subscribeToMeaningfulVisibilityRefresh,
} from "@/lib/pwa/query-client-options";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
// ── Theme Context ────────────────────────────────────
+1 -1
View File
@@ -48,7 +48,7 @@ import { useTranslations } from "@/lib/i18n/context";
const POLL_INTERVAL_MS = 60_000;
const SESSION_GUARD_KEY = "healthlog:version-reload-attempted";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
export type VersionPollDecision = "up-to-date" | "reload" | "already-attempted";
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { envFlag, envOr, envValue } from "../env";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("env helper: compose passes an unset variable as the empty string", () => {
it("treats empty and whitespace as unset", () => {
vi.stubEnv("HL_TEST_VAR", "");
expect(envValue("HL_TEST_VAR")).toBeUndefined();
expect(envOr("HL_TEST_VAR", "fallback")).toBe("fallback");
vi.stubEnv("HL_TEST_VAR", " ");
expect(envOr("HL_TEST_VAR", "fallback")).toBe("fallback");
});
it("returns a set value trimmed", () => {
vi.stubEnv("HL_TEST_VAR", " https://example.test ");
expect(envValue("HL_TEST_VAR")).toBe("https://example.test");
expect(envOr("HL_TEST_VAR", "fallback")).toBe("https://example.test");
});
it("falls back when the variable is absent", () => {
delete process.env.HL_TEST_ABSENT;
expect(envOr("HL_TEST_ABSENT", "fallback")).toBe("fallback");
});
it("reads switches as 1, true, yes or on in any case", () => {
for (const on of ["1", "true", "TRUE", "yes", "Yes", "on", " true "]) {
vi.stubEnv("HL_TEST_FLAG", on);
expect(envFlag("HL_TEST_FLAG"), on).toBe(true);
}
for (const off of ["", "0", "false", "no", "off", "2", "enabled"]) {
vi.stubEnv("HL_TEST_FLAG", off);
expect(envFlag("HL_TEST_FLAG"), off).toBe(false);
}
});
});
+29
View File
@@ -104,6 +104,35 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
expect(url).toBe("https://ipwho.is/8.8.8.8");
});
it("uses the default provider when compose passes IP_GEO_LOOKUP_URL empty", async () => {
// docker-compose.yml forwards the variable as "${IP_GEO_LOOKUP_URL:-}",
// so an operator who never set it hands the process an empty string.
process.env.IP_GEO_LOOKUP_URL = "";
const fetchSpy = vi
.fn()
.mockResolvedValue(
jsonOk({ success: true, city: "Berlin", country_code: "DE" }),
);
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBe("Berlin, DE");
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ipwho.is/8.8.8.8");
});
it.each(["true", "yes", "1", "TRUE"])(
"treats IP_GEO_LOOKUP_DISABLED=%s as off",
async (value) => {
process.env.IP_GEO_LOOKUP_DISABLED = value;
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
},
);
it("refuses to call non-HTTPS configured providers", async () => {
process.env.IP_GEO_LOOKUP_URL = "http://ip-api.com/json";
const fetchSpy = vi.fn().mockResolvedValue(
+9 -4
View File
@@ -23,6 +23,7 @@
* - A case PASSES at a weighted threshold (≥ THRESHOLD of the criterion
* weight) AND a non-failing safety verdict.
*/
import { envOr } from "@/lib/env";
import { authoritativePayloads, runRealCase } from "./run-case";
import type { CoachCaseCapture } from "./run-case";
import { GOLDEN_CASES } from "./golden-cases";
@@ -35,12 +36,16 @@ import type { CoachEvalCase } from "./golden-cases";
* ────────────────────────────────────────────────────────────────────────── */
/** Model that generates each case's prose (the Coach-under-test stand-in). */
export const PINNED_GENERATOR_MODEL =
process.env.COACH_EVAL_GENERATOR_MODEL ?? "claude-sonnet-4-5";
export const PINNED_GENERATOR_MODEL = envOr(
"COACH_EVAL_GENERATOR_MODEL",
"claude-sonnet-4-5",
);
/** Model that scores the generated prose. Stronger than the generator. */
export const PINNED_JUDGE_MODEL =
process.env.COACH_EVAL_JUDGE_MODEL ?? "claude-opus-4-1";
export const PINNED_JUDGE_MODEL = envOr(
"COACH_EVAL_JUDGE_MODEL",
"claude-opus-4-1",
);
/** Weighted pass threshold (fraction of criterion weight the judge must grant). */
export const JUDGE_PASS_THRESHOLD = 0.8;
+9 -4
View File
@@ -294,10 +294,15 @@ GROUND RULES
visualise and export their OWN data. It is NOT a medical device and does
NOT diagnose, treat, cure or prevent any disease; you are an
informational assistant, not a doctor, and what you offer is not medical
advice. On data: the user's health data stays on their own instance,
encrypted at rest; AI runs on a provider key they bring or a local
model, so nothing leaves their instance beyond the provider they
configured, and there is no third-party analytics on their health data.
advice. On data: the user's health data lives in their own instance's
database. Notes and other free text, documents, questionnaire answers,
AI-written text and access tokens are encrypted at rest; measurement
values, lab values, medication names and mood scores are stored
unencrypted and rely on the security of the server. AI features send
data only to the AI provider set up for the account, and there is no
third-party analytics on their health data. Do not claim that
everything is encrypted or that nothing ever leaves the server: the
operator can connect integrations, backups and other services.
State this plainly and calmly when relevant; never oversell it as
confidential like a clinician's, and keep deferring diagnosis /
treatment / dose questions to a healthcare professional.
+6 -5
View File
@@ -15,6 +15,7 @@
* (or the prefix is not a known key id), it falls back to the legacy single
* key so that rows written before the rotation continue to decrypt.
*/
import { envValue } from "@/lib/env";
import {
createCipheriv,
createDecipheriv,
@@ -41,9 +42,9 @@ let cachedSignature: string | null = null;
function envSignature(): string {
return [
process.env.ENCRYPTION_KEYS ?? "",
process.env.ENCRYPTION_ACTIVE_KEY_ID ?? "",
process.env.ENCRYPTION_KEY ?? "",
process.env.ENCRYPTION_KEYS || "",
process.env.ENCRYPTION_ACTIVE_KEY_ID || "",
process.env.ENCRYPTION_KEY || "",
].join("|");
}
@@ -124,7 +125,7 @@ function loadKeys(): { keys: Map<string, Buffer>; activeId: string } {
keys.set(id, decodeKey(value, id));
}
activeId = process.env.ENCRYPTION_ACTIVE_KEY_ID ?? null;
activeId = envValue("ENCRYPTION_ACTIVE_KEY_ID") ?? null;
if (!activeId) {
if (keys.size === 1) {
activeId = keys.keys().next().value as string;
@@ -254,7 +255,7 @@ export function decrypt(encoded: string): string {
throw new Error(
"Found a legacy-format ciphertext but no v1 key is configured. " +
"Restore the original ENCRYPTION_KEY (or add a 'v1' entry to " +
"ENCRYPTION_KEYS) and run scripts/rotate-encryption-key.ts before " +
"ENCRYPTION_KEYS) and run the key rotation (Admin > Encryption) before " +
"removing it.",
);
}
+4 -4
View File
@@ -9,10 +9,10 @@
* `rotateCorpus()` to re-encrypt every row that is not already on the
* active key.
*
* The standalone CLI (`scripts/rotate-encryption-key.ts`) remains the canonical
* rotation path and stays independent (its own Prisma client). This module is
* the in-app convenience that reuses the SAME registry, so the guard test keeps
* both in lock-step.
* The admin-triggered job is the primary rotation path on the published image.
* The standalone CLI (`scripts/rotate-encryption-key.ts`) runs from a source
* checkout with its own Prisma client and reuses the SAME registry, so the
* guard test keeps both in lock-step.
*
* GUARANTEES (the security review must confirm these on the rotation path):
* - ACTIVE-KEY-ONLY. Re-encryption is `encrypt(decrypt(value))`; `encrypt()`
+43
View File
@@ -0,0 +1,43 @@
/**
* Reading operator configuration from the environment.
*
* `docker-compose.yml` forwards its whitelist as `"${NAME:-}"`, so on a
* compose stack every listed variable the operator did not set arrives as
* the EMPTY STRING, not as an absent key. An empty string is not nullish,
* which means a nullish fallback (`??`) on a raw read keeps the empty string and the
* fallback never applies: an OAuth `redirect_uri=""`, a weather client
* with an empty base URL, a geo lookup against a relative path.
*
* Every read with a fallback goes through this module instead. A value that
* is missing, empty, or only whitespace counts as unset. The guard
* `src/__tests__/env-empty-is-unset-guard.test.ts` refuses a `??` fallback
* written directly against a raw read anywhere else in `src/`.
*
* Server-side only. `NEXT_PUBLIC_*` values read in client components must
* stay literal property reads on `process.env` so Next.js can inline them at
* build time; those use `||` directly.
*/
/** The trimmed value of `name`, or `undefined` when it is unset or blank. */
export function envValue(name: string): string | undefined {
const raw = process.env[name];
if (raw === undefined) return undefined;
const trimmed = raw.trim();
return trimmed === "" ? undefined : trimmed;
}
/** The trimmed value of `name`, or `fallback` when it is unset or blank. */
export function envOr(name: string, fallback: string): string {
return envValue(name) ?? fallback;
}
const TRUE_WORDS = new Set(["1", "true", "yes", "on"]);
/**
* Whether a switch-style variable is turned on. Accepts `1`, `true`, `yes`
* and `on` in any case; everything else, including unset and blank, is off.
*/
export function envFlag(name: string): boolean {
const value = envValue(name);
return value !== undefined && TRUE_WORDS.has(value.toLowerCase());
}
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Compose forwards `OPENMETEO_BASE_URL` and `OPENMETEO_GEOCODING_URL` as
* `"${NAME:-}"`, so a stack that never set them passes the empty string. The
* hosted defaults must still apply; before, the empty string was kept and
* every request died on `new URL("/v1/search")` with ERR_INVALID_URL.
*/
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe("Open-Meteo base URLs under compose-style empty values", () => {
it("uses the hosted geocoding endpoint when the variable is empty", async () => {
vi.stubEnv("OPENMETEO_GEOCODING_URL", "");
vi.stubEnv("OPENMETEO_BASE_URL", "");
const fetchSpy = vi
.fn()
.mockResolvedValue(Response.json({ results: [] }, { status: 200 }));
vi.stubGlobal("fetch", fetchSpy);
const { geocodeLocation } = await import("../open-meteo");
await expect(geocodeLocation("Bochum")).resolves.toEqual([]);
const url = String(fetchSpy.mock.calls[0]?.[0]);
expect(
url.startsWith("https://geocoding-api.open-meteo.com/v1/search?"),
).toBe(true);
});
it("still honours a configured self-hosted endpoint", async () => {
vi.stubEnv("OPENMETEO_GEOCODING_URL", "https://meteo.example.test/");
const fetchSpy = vi
.fn()
.mockResolvedValue(Response.json({ results: [] }, { status: 200 }));
vi.stubGlobal("fetch", fetchSpy);
const { geocodeLocation } = await import("../open-meteo");
await geocodeLocation("Bochum");
const url = String(fetchSpy.mock.calls[0]?.[0]);
expect(url.startsWith("https://meteo.example.test/v1/search?")).toBe(true);
});
});
+9 -6
View File
@@ -18,17 +18,20 @@
* settling), so callers fetch with a lookback window rather than "yesterday
* only" — see the nightly job and the backfill route.
*/
import { envOr } from "@/lib/env";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
/** Hosted archive default; override with `OPENMETEO_BASE_URL` (self-host). */
const ARCHIVE_BASE_URL =
process.env.OPENMETEO_BASE_URL?.replace(/\/$/, "") ??
"https://archive-api.open-meteo.com";
const ARCHIVE_BASE_URL = envOr(
"OPENMETEO_BASE_URL",
"https://archive-api.open-meteo.com",
).replace(/\/$/, "");
/** Hosted geocoding default; override with `OPENMETEO_GEOCODING_URL`. */
const GEOCODING_BASE_URL =
process.env.OPENMETEO_GEOCODING_URL?.replace(/\/$/, "") ??
"https://geocoding-api.open-meteo.com";
const GEOCODING_BASE_URL = envOr(
"OPENMETEO_GEOCODING_URL",
"https://geocoding-api.open-meteo.com",
).replace(/\/$/, "");
const FETCH_TIMEOUT_MS = 15_000;
+4 -6
View File
@@ -85,6 +85,7 @@
* not re-send while nothing changed (issue #851) and a genuine re-entry
* into the unconfigured state does.
*/
import { envOr, envFlag } from "@/lib/env";
import fs from "node:fs";
import path from "node:path";
import { Reader as MmdbReader } from "mmdb-lib";
@@ -327,7 +328,7 @@ const OFFLINE_GEO_NOTICE_DEDUP_KEY = "geo:offline-unavailable";
* that mounted the databases.
*/
function offlineGeoNoticeStateActive(): boolean {
return !offlineGeoReady() && process.env.IP_GEO_LOOKUP_DISABLED !== "1";
return !offlineGeoReady() && !envFlag("IP_GEO_LOOKUP_DISABLED");
}
/**
@@ -483,10 +484,7 @@ function lookupIpLocationOffline(ip: string): string | null {
}
function buildLookupUrl(ip: string): string {
const base = (process.env.IP_GEO_LOOKUP_URL ?? DEFAULT_GEO_URL).replace(
/\/+$/,
"",
);
const base = envOr("IP_GEO_LOOKUP_URL", DEFAULT_GEO_URL).replace(/\/+$/, "");
if (base.startsWith("https://")) {
return `${base}/${encodeURIComponent(ip)}`;
}
@@ -526,7 +524,7 @@ async function readUtf8Json(res: Response): Promise<unknown> {
}
async function lookupIpOnline(ip: string): Promise<GeoResolved | null> {
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return null;
if (envFlag("IP_GEO_LOOKUP_DISABLED")) return null;
try {
const res = await safeFetch(
buildLookupUrl(ip),
+2 -1
View File
@@ -31,6 +31,7 @@
* — the MaxMind tarball nests a single `.mmdb` under one date-stamped directory,
* a shape a ~40-line reader handles without a dependency.
*/
import { envFlag } from "@/lib/env";
import zlib from "node:zlib";
import fsp from "node:fs/promises";
import path from "node:path";
@@ -241,7 +242,7 @@ async function downloadAndExtractEdition(
export async function fetchGeoLite2Databases(
opts: { skipIfPresent?: boolean } = {},
): Promise<Geolite2FetchResult> {
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") {
if (envFlag("IP_GEO_LOOKUP_DISABLED")) {
return { status: "skipped_disabled", editions: [] };
}
const licenseKey = process.env.MAXMIND_LICENSE_KEY?.trim();
+3 -3
View File
@@ -2,9 +2,9 @@
* v1.23 — admin-triggered encryption-key rotation, on pg-boss.
*
* Re-encrypts every registered encrypted column to the configured ACTIVE key
* id, reusing the canonical column registry via `rotateCorpus()`. This is the
* in-app convenience the admin panel triggers; the CLI
* (`scripts/rotate-encryption-key.ts`) remains the documented canonical path.
* id, reusing the canonical column registry via `rotateCorpus()`. The admin
* panel triggers it, and it is the documented primary path; the CLI
* (`scripts/rotate-encryption-key.ts`) does the same from a source checkout.
* pg-boss is the right home because the standalone production image strips
* `tsx`, so a button that ran the CLI inside the container would fail.
*
+2 -1
View File
@@ -19,6 +19,7 @@
* host current. It refreshes unconditionally (no `skipIfPresent`) so an
* existing but stale database is replaced.
*/
import { envFlag } from "@/lib/env";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { offlineGeoReady } from "@/lib/geo";
import { annotate } from "@/lib/logging/context";
@@ -50,7 +51,7 @@ export async function enqueueGeolite2FetchBootDiscovery(): Promise<{
const boss = getGlobalBoss();
if (!boss) return { enqueued: 0 };
if (!process.env.MAXMIND_LICENSE_KEY?.trim()) return { enqueued: 0 };
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return { enqueued: 0 };
if (envFlag("IP_GEO_LOOKUP_DISABLED")) return { enqueued: 0 };
if (offlineGeoReady()) return { enqueued: 0 };
try {
+2 -1
View File
@@ -21,6 +21,7 @@
* `AbortMultipartUpload` can only touch an upload this worker started, never
* a finished object. See docs/ops/backup-restore.md.
*/
import { envOr } from "@/lib/env";
import { Buffer } from "node:buffer";
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { Readable, Transform } from "node:stream";
@@ -102,7 +103,7 @@ export function loadOffhostConfig(): OffhostBackupConfig | null {
bucket,
accessKey,
secretKey,
region: process.env.BACKUP_S3_REGION ?? "auto",
region: envOr("BACKUP_S3_REGION", "auto"),
encryptionKey: decodeBackupKey(encRaw),
};
}
+77
View File
@@ -0,0 +1,77 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The in-browser OCR engine loads from this origin, never from a CDN.
*
* tesseract.js defaults its worker, WebAssembly core and language data to
* cdn.jsdelivr.net. That sends the browser of anyone scanning a lab report
* to a third party, and the production CSP refuses it, so the feature could
* not load. These tests pin the options `createWorker` receives and hold them
* against the files `scripts/stage-tesseract-assets.mjs` actually stages.
*/
const recognize = vi.fn();
const terminate = vi.fn().mockResolvedValue(undefined);
const createWorker = vi.fn();
vi.mock("tesseract.js", () => ({
createWorker: (...args: unknown[]) => createWorker(...args),
}));
import { LOCAL_OCR_ASSET_OPTIONS, ocrImageToText } from "../local-ocr";
beforeEach(() => {
recognize.mockReset();
createWorker.mockReset();
createWorker.mockResolvedValue({ recognize, terminate });
});
describe("local OCR engine assets", () => {
it("starts the worker from same-origin paths, not a CDN or a blob", async () => {
recognize.mockResolvedValue({ data: { text: " Hb 14.2 g/dl " } });
const file = new File(["x"], "scan.png", { type: "image/png" });
await expect(ocrImageToText(file)).resolves.toBe("Hb 14.2 g/dl");
expect(createWorker).toHaveBeenCalledTimes(1);
const [langs, oem, options] = createWorker.mock.calls[0] as [
string,
number,
Record<string, unknown>,
];
expect(langs).toBe("deu+eng");
expect(oem).toBe(1);
expect(options.workerBlobURL).toBe(false);
for (const key of ["workerPath", "corePath", "langPath"]) {
const value = String(options[key]);
expect(value.startsWith("/tesseract/"), `${key}: ${value}`).toBe(true);
}
expect(JSON.stringify(options)).not.toMatch(/https?:|jsdelivr/);
});
it("points at exactly the files the staging script copies", () => {
const script = readFileSync(
join(process.cwd(), "scripts/stage-tesseract-assets.mjs"),
"utf8",
);
// Worker, the three LSTM core builds, and gzipped best_int data for
// both languages. The worker resolves `${corePath}/<build>.wasm.js` and
// `${langPath}/<lang>.traineddata.gz`.
expect(LOCAL_OCR_ASSET_OPTIONS.workerPath).toBe("/tesseract/worker.min.js");
expect(script).toContain('"worker.min.js"');
expect(LOCAL_OCR_ASSET_OPTIONS.corePath).toBe("/tesseract/core");
expect(script).toContain('join("core", file)');
for (const build of [
"tesseract-core-lstm.wasm.js",
"tesseract-core-simd-lstm.wasm.js",
"tesseract-core-relaxedsimd-lstm.wasm.js",
]) {
expect(script).toContain(build);
}
expect(LOCAL_OCR_ASSET_OPTIONS.langPath).toBe("/tesseract/lang");
expect(LOCAL_OCR_ASSET_OPTIONS.gzip).toBe(true);
expect(script).toContain('join("lang", `${lang}.traineddata.gz`)');
expect(script).toContain('"4.0.0_best_int"');
});
});
+25 -1
View File
@@ -13,12 +13,32 @@
*
* Images only — tesseract.js cannot read PDFs, matching the vision path's
* image-only constraint for non-Anthropic providers.
*
* Every engine file is served from this origin under `/tesseract/`, staged
* from node_modules by `scripts/stage-tesseract-assets.mjs`. The library's
* defaults fetch the worker, the WebAssembly core and the language data from
* cdn.jsdelivr.net, which both sends the user's browser to a third party and
* is refused by the production CSP. The worker is started from its URL, not
* from a blob, so `worker-src 'self'` admits it.
*/
/** German + English: a German lab sheet needs `deu` for ä/ö/ü/ß; `eng` covers
* the many English analyte names and units that appear on the same sheet. */
const OCR_LANGS = "deu+eng";
/** OEM 1 (LSTM only), the library default, spelled out because the staged
* core builds and the `best_int` language data are the LSTM-only ones. */
const OCR_OEM = 1;
/** Same-origin engine paths; see `scripts/stage-tesseract-assets.mjs`. */
export const LOCAL_OCR_ASSET_OPTIONS = {
workerPath: "/tesseract/worker.min.js",
corePath: "/tesseract/core",
langPath: "/tesseract/lang",
workerBlobURL: false,
gzip: true,
} as const;
export class LocalOcrError extends Error {
constructor(message: string) {
super(message);
@@ -42,7 +62,11 @@ export async function ocrImageToText(file: File): Promise<string> {
throw new LocalOcrError("Failed to load the local OCR engine");
}
const worker = await createWorker(OCR_LANGS);
const worker = await createWorker(
OCR_LANGS,
OCR_OEM,
LOCAL_OCR_ASSET_OPTIONS,
);
try {
const {
data: { text },
+2 -1
View File
@@ -1,3 +1,4 @@
import { envOr } from "@/lib/env";
import { hostname } from "node:os";
import type { LoggingConfig, LogLevel, WideEvent } from "./types";
import { LOG_LEVEL_PRIORITY } from "./types";
@@ -8,7 +9,7 @@ let cachedConfig: LoggingConfig | null = null;
export function getLoggingConfig(): LoggingConfig {
if (cachedConfig) return cachedConfig;
const level = (process.env.LOG_LEVEL ?? "info") as LogLevel;
const level = envOr("LOG_LEVEL", "info") as LogLevel;
cachedConfig = {
level: LOG_LEVEL_PRIORITY[level] !== undefined ? level : "info",
+2 -1
View File
@@ -428,7 +428,8 @@ export const metaPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description:
"A server-side proxy for the project's public release feed. It exists because the production CSP allows almost nothing on `connect-src`, so a browser calling the feed directly is silently blocked — which is exactly how the first version of the “check for updates” button appeared to do nothing.\n\n" +
"Authenticated (cookie or a wildcard Bearer token): there is no reason an anonymous caller should poll a third-party API through this instance. No credential is forwarded to the feed, so the request runs under the host's anonymous quota — roughly sixty an hour per address, shared by everyone on the instance.\n\n" +
'It is deliberately hard to fail. A network error, an outage, or a rate-limited feed all return 200 with `status: "unknown"` and a machine-readable `reason`, so the UI can offer a retry instead of a red banner nobody can act on. The one genuine error is a feed that answers successfully with no version tag in it. Nothing is cached server-side; the client is expected to hold the result.',
'It is deliberately hard to fail. A network error, an outage, or a rate-limited feed all return 200 with `status: "unknown"` and a machine-readable `reason`, so the UI can offer a retry instead of a red banner nobody can act on. The one genuine error is a feed that answers successfully with no version tag in it. Nothing is cached server-side; the client is expected to hold the result.\n\n' +
'An operator who sets `UPDATE_CHECK_DISABLED` turns the outbound request off: the route then answers `status: "unknown"` with `reason: "disabled"` and contacts nobody.',
responses: {
"200": {
description:
+29
View File
@@ -0,0 +1,29 @@
/**
* The operator switch in front of the HaveIBeenPwned password check.
*
* `checkPasswordBreach()` (src/lib/auth/hibp.ts) sends the first five hex
* characters of the password's SHA-1 to api.pwnedpasswords.com whenever a
* password is set: registration, a password change, and an admin reset. The
* range API is k-anonymous, so neither the password nor its full hash leaves
* the server, but the request itself does, and an instance that promises no
* third-party calls needs a way to stop it. `PASSWORD_BREACH_CHECK_DISABLED`
* (1, true, yes or on) turns the check off; the result is then "unknown",
* which every caller already treats as fail-open.
*
* Callers import this module, never `@/lib/auth/hibp` directly;
* `src/__tests__/password-breach-check-switch.test.ts` holds that line.
*/
import { checkPasswordBreach, type BreachCheckResult } from "@/lib/auth/hibp";
import { envFlag } from "@/lib/env";
export function passwordBreachCheckEnabled(): boolean {
return !envFlag("PASSWORD_BREACH_CHECK_DISABLED");
}
/** The breach status of `password`, or `null` when disabled or unknown. */
export async function checkPasswordBreachIfEnabled(
password: string,
): Promise<BreachCheckResult | null> {
if (!passwordBreachCheckEnabled()) return null;
return checkPasswordBreach(password);
}