mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user