mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Merge branch 'fix/compose-env-and-claims' into release/v1.39.3
This commit is contained in:
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 ────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()`
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user