From 0beb9fe252ffb4b7e2a0145d7a79b85490546d8f Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:59:40 +0200 Subject: [PATCH 1/4] fix(api): correct OpenAPI parameter formats, 404 coverage and response shapes --- .../backend/src/generated/api-v1-openapi.json | 79 ++++++++++---- apps/web/lib/api-v1-openapi.ts | 93 ++++++++++++---- apps/web/tests/unit/openapi-parity.test.ts | 100 +++++++++++++++++- packages/db/src/backend-queries.ts | 8 +- packages/db/src/types.ts | 3 +- 5 files changed, 230 insertions(+), 53 deletions(-) diff --git a/apps/backend/src/generated/api-v1-openapi.json b/apps/backend/src/generated/api-v1-openapi.json index 13c9b95c..d668adc0 100644 --- a/apps/backend/src/generated/api-v1-openapi.json +++ b/apps/backend/src/generated/api-v1-openapi.json @@ -240,7 +240,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "date" } } ], @@ -317,7 +318,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "date" } } ], @@ -501,7 +503,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -659,7 +662,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -731,7 +735,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -965,7 +970,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -1056,7 +1062,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -1436,7 +1443,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -1838,6 +1846,9 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { + "$ref": "#/components/responses/NotFound" + }, "405": { "$ref": "#/components/responses/MethodNotAllowed" }, @@ -1873,7 +1884,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -1942,7 +1954,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -2113,7 +2126,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -2185,7 +2199,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -2553,6 +2568,9 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { + "$ref": "#/components/responses/NotFound" + }, "405": { "$ref": "#/components/responses/MethodNotAllowed" }, @@ -2588,7 +2606,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -2657,7 +2676,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -2821,7 +2841,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -2878,6 +2899,7 @@ "/recipes/{id}/log": { "post": { "summary": "Log a recipe portion", + "description": "When `status` is omitted, the logged entry defaults to `planned` for a future date and `eaten` otherwise, compared in UTC.", "security": [ { "bearerAuth": [] @@ -2893,7 +2915,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -3002,8 +3025,10 @@ "name": "date", "in": "query", "required": false, + "description": "Reference date in UTC. Defaults to the current UTC date when omitted; there is no browser-timezone inference.", "schema": { - "type": "string" + "type": "string", + "format": "date" } } ], @@ -3232,7 +3257,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -3343,7 +3369,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -3553,8 +3580,10 @@ "name": "date", "in": "query", "required": false, + "description": "Reference date in UTC. Defaults to the current UTC date when omitted; there is no browser-timezone inference.", "schema": { - "type": "string" + "type": "string", + "format": "date" } } ], @@ -3624,8 +3653,10 @@ "name": "date", "in": "query", "required": false, + "description": "Reference date in UTC. Defaults to the current UTC date when omitted; there is no browser-timezone inference.", "schema": { - "type": "string" + "type": "string", + "format": "date" } } ], @@ -3692,8 +3723,10 @@ "name": "date", "in": "query", "required": false, + "description": "Reference date in UTC. Defaults to the current UTC date when omitted; there is no browser-timezone inference.", "schema": { - "type": "string" + "type": "string", + "format": "date" } } ], @@ -4819,7 +4852,7 @@ ] } }, - "description": "Percentage of days in the window that met each goal." + "description": "Percentage of qualifying tracked days in the window that met each goal. The denominator counts days with at least one eaten entry, including zero-calorie eaten days; days without an eaten entry are excluded." }, "StatsPageData": { "type": "object", diff --git a/apps/web/lib/api-v1-openapi.ts b/apps/web/lib/api-v1-openapi.ts index 856a64ff..fd12eda1 100644 --- a/apps/web/lib/api-v1-openapi.ts +++ b/apps/web/lib/api-v1-openapi.ts @@ -19,6 +19,19 @@ type ApiRequestBodyKey = | "weightEntryPatch" | "weightGoal"; +type ApiParameterLocation = "path" | "query"; + +/** Semantic formats the runtime enforces but a bare JSON Schema string cannot express. */ +type ApiParameterFormat = "date" | "uuid"; + +export type ApiParameter = { + name: string; + in: ApiParameterLocation; + required: boolean; + format?: ApiParameterFormat; + description?: string; +}; + type ApiEndpointMethod = { method: "get" | "post" | "patch" | "delete"; summary: string; @@ -27,6 +40,9 @@ type ApiEndpointMethod = { scopes: ApiScope[]; when: string; }[]; + parameters?: ApiParameter[]; + /** True when the operation, or a resource it references in its body, can answer the documented 404. */ + hasNotFoundResponse?: boolean; successStatus?: 200 | 201; requestBody?: ApiRequestBodyKey; hasConflictResponse?: boolean; @@ -41,6 +57,26 @@ const PRODUCT_ID_CONDITIONAL_SCOPES: NonNullable; requestBody?: unknown; "x-required-scopes"?: string[]; "x-conditional-required-scopes"?: { scopes: string[]; when: string }[]; }; +type Schema = { + required?: string[]; + properties?: Record; +}; + +type GeneratedContract = { + paths: Record>; + components: { schemas: Record }; +}; + async function readGeneratedContract() { const artifact = await readFile( new URL("../../../backend/src/generated/api-v1-openapi.json", import.meta.url), "utf8", ); - return JSON.parse(artifact) as { - paths: Record>; + return JSON.parse(artifact) as GeneratedContract; +} + +/** Project a generated parameter onto the docs-table shape so optional keys only exist when present. */ +function documentedParameter(parameter: NonNullable[number]): ApiParameter { + return { + name: parameter.name, + in: parameter.in as ApiParameter["in"], + required: Boolean(parameter.required), + ...(parameter.schema?.format ? { format: parameter.schema.format as ApiParameter["format"] } : {}), + ...(parameter.description ? { description: parameter.description } : {}), }; } @@ -41,7 +69,7 @@ describe("generated API v1 contract", () => { expect(generated).toEqual(documented); }); - it("agrees with the docs table on scopes, request bodies, and responses", async () => { + it("agrees with the docs table on scopes, request bodies, parameters, and responses", async () => { const { paths } = await readGeneratedContract(); for (const endpoint of API_V1_ENDPOINTS) { @@ -53,16 +81,80 @@ describe("generated API v1 contract", () => { expect(operation!["x-conditional-required-scopes"]).toEqual( method.conditionalRequiredScopes, ); + expect((operation!.parameters ?? []).map(documentedParameter)).toEqual( + method.parameters ?? [], + ); expect(Object.keys(operation!.responses)).toContain( String(method.successStatus ?? 200), ); expect(Object.keys(operation!.responses).includes("409")).toBe( Boolean(method.hasConflictResponse), ); + expect(Object.keys(operation!.responses).includes("404")).toBe( + Boolean(method.hasNotFoundResponse), + ); expect(operation!.requestBody !== undefined).toBe( method.requestBody !== undefined, ); } } }); + + it("gives every date parameter an RFC 3339 date format and every id parameter a uuid format", async () => { + const { paths } = await readGeneratedContract(); + + for (const [path, operations] of Object.entries(paths)) { + for (const [method, operation] of Object.entries(operations)) { + for (const parameter of operation.parameters ?? []) { + const label = `${method.toUpperCase()} ${path} ${parameter.name}`; + if (parameter.name === "date") { + expect(parameter.schema?.format, label).toBe("date"); + } + if (parameter.in === "path" && parameter.name === "id") { + expect(parameter.schema?.format, label).toBe("uuid"); + } + } + } + } + }); +}); + +describe("TypeScript API response shapes", () => { + // `satisfies` fails the typecheck if the Rust payload ever drops or adds a required field. + const leaderboardFixture = { + currentStreak: 0, + longestStreak: 0, + totalDaysTracked: 0, + bestCalorieDay: null, + bestProteinDay: null, + bestCarbsDay: null, + mostActiveDay: null, + } satisfies LeaderboardStats; + + const deletedActorAuditEvent = { + id: "00000000-0000-0000-0000-000000000000", + actorUserId: null, + actorEmail: null, + actorDisplayName: null, + actorRole: "admin", + action: "update", + targetType: "user", + targetId: "00000000-0000-0000-0000-000000000000", + details: {}, + createdAt: "2026-09-17T00:00:00.000Z", + } satisfies AdminAuditEvent; + + it("keeps the leaderboard type at parity with the OpenAPI required fields", async () => { + const { components } = await readGeneratedContract(); + const schema = components.schemas.LeaderboardStats; + + expect(schema.required?.slice().sort()).toEqual(Object.keys(leaderboardFixture).sort()); + expect(Object.keys(schema.properties ?? {}).sort()).toEqual( + Object.keys(leaderboardFixture).sort(), + ); + }); + + it("types an audit event whose actor was deleted as null", () => { + expect(deletedActorAuditEvent.actorUserId).toBeNull(); + }); }); diff --git a/packages/db/src/backend-queries.ts b/packages/db/src/backend-queries.ts index fcd289ec..70edd17f 100644 --- a/packages/db/src/backend-queries.ts +++ b/packages/db/src/backend-queries.ts @@ -46,11 +46,15 @@ import type { WeightPageData, } from "./types"; +/** Mirrors the Rust `leaderboard_json` payload: streaks, tracked days and the record days. */ export type LeaderboardStats = { - bestCalorieDay: StatsPageData["bestCalorieDay"]; currentStreak: number; longestStreak: number; - topLabels: StatsPageData["topLabels"]; + totalDaysTracked: number; + bestCalorieDay: StatsPageData["bestCalorieDay"]; + bestProteinDay: { date: string; proteinG: number } | null; + bestCarbsDay: { date: string; carbsG: number } | null; + mostActiveDay: { date: string; entryCount: number } | null; }; type BackendTestFault = { diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index df40c0f3..1fe4e5ae 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -451,7 +451,8 @@ export type BarcodeFoodProductInput = { export type AdminAuditEvent = { id: string; - actorUserId: string; + /** Null once the acting user is deleted: `admin_audit_events.actor_user_id` is `ON DELETE SET NULL`. */ + actorUserId: string | null; actorEmail: string | null; actorDisplayName: string | null; actorRole: AdminRole; From a2742bec05d3b4f576154bc9bf7c9eb97dd5cb49 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:59:55 +0200 Subject: [PATCH 2/4] fix(web): classify public API proxy deadline expiry as 504 timeout --- README.md | 2 +- apps/web/lib/api-v1.ts | 39 +++++++++- apps/web/tests/unit/api-v1.test.ts | 118 ++++++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d940ca88..82cf98c1 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ The production build uses Next.js standalone output. The direct Node command abo Macro Tracker API v1 is available under `/api/v1/*`. Create personal access tokens from `/settings/api`, then send them as `Authorization: Bearer `. Tokens start with `mtk_v1_`, are shown only once, store only a hash in the database, and can be scoped to read or write daily logs, foods, templates, recipes, weight, goals, and stats. -OpenAPI JSON is available at `/api/v1/openapi.json`, and the readable docs page is `/docs/api`. API responses use `{ "ok": true, "data": ... }` for success and `{ "ok": false, "error": { "code": "...", "message": "..." } }` for failures. Public API dates use `YYYY-MM-DD`. +OpenAPI JSON is available at `/api/v1/openapi.json`, and the readable docs page is `/docs/api`. API responses use `{ "ok": true, "data": ... }` for success and `{ "ok": false, "error": { "code": "...", "message": "..." } }` for failures. Public API dates use `YYYY-MM-DD`. The backend bounds one request at 30 seconds and answers a longer one with `504` and `error.code` `"timeout"`; the web proxy waits 35 seconds so that envelope normally wins, then aborts the call and answers the same `504` timeout envelope itself. Timed-out requests are never retried, so a timed-out mutation may still have been applied. Self-hosted instances need the latest database migrations so the `api_tokens` table exists before users create tokens. diff --git a/apps/web/lib/api-v1.ts b/apps/web/lib/api-v1.ts index cba3352c..f23e89cd 100644 --- a/apps/web/lib/api-v1.ts +++ b/apps/web/lib/api-v1.ts @@ -15,10 +15,33 @@ const NO_SHARED_CACHE_HEADERS = { "cache-control": "no-store", }; +// The backend bounds one `/api/v1` dispatch at 30s (`API_REQUEST_TIMEOUT`) and answers 504 with the +// `timeout` code itself. The proxy budget must outlive that so the backend envelope normally wins; +// it only fires when the backend is unreachable or its own deadline layer cannot answer. +export const API_V1_BACKEND_TIMEOUT_MS = 35_000; + +const TIMEOUT_ERROR = { + ok: false, + error: { + code: "timeout", + message: "The request took too long to complete.", + }, +}; + +/** `AbortSignal.timeout` rejects with a DOMException named `TimeoutError`; match by name across runtimes. */ +function isTimeoutFailure(error: unknown) { + return ( + typeof error === "object" && + error !== null && + (error as { name?: unknown }).name === "TimeoutError" + ); +} + export async function handleApiV1Request( request: Request, path: string[] | undefined, method = request.method, + options: { timeoutMs?: number } = {}, ) { if (method === "OPTIONS") { return new Response(null, { status: 204, headers: CORS_HEADERS }); @@ -44,11 +67,16 @@ export async function handleApiV1Request( const backendPath = `/api/v1/${encodedPath}${requestUrl.search}`; const headers = stripHopByHopHeaders(new Headers(request.headers)); - const init: RequestInit & { duplex?: "half"; attachInternalSecret?: boolean } = { + const init: RequestInit & { + duplex?: "half"; + attachInternalSecret?: boolean; + timeoutMs?: number; + } = { method, headers, // /api/v1/* is authenticated with the caller's Bearer token, not the internal secret. attachInternalSecret: false, + timeoutMs: options.timeoutMs ?? API_V1_BACKEND_TIMEOUT_MS, }; if (method !== "GET" && method !== "HEAD") { @@ -63,6 +91,15 @@ export async function handleApiV1Request( extraHeaders: NO_SHARED_CACHE_HEADERS, }); } catch (error) { + // A deadline expiry is reported the same way the backend reports its own 30s deadline, and the + // failed request is never retried: a timed-out mutation may still have been applied. + if (isTimeoutFailure(error)) { + console.error(`API v1 backend proxy deadline exceeded for ${backendPath}`, error); + return Response.json(TIMEOUT_ERROR, { + status: 504, + headers: { ...CORS_HEADERS, ...NO_SHARED_CACHE_HEADERS }, + }); + } console.error("API v1 backend proxy failure", error); return Response.json( { diff --git a/apps/web/tests/unit/api-v1.test.ts b/apps/web/tests/unit/api-v1.test.ts index 26d5d6d0..31e74841 100644 --- a/apps/web/tests/unit/api-v1.test.ts +++ b/apps/web/tests/unit/api-v1.test.ts @@ -1,3 +1,5 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; + import { completeUserOnboarding, createApiToken, @@ -15,13 +17,40 @@ import { createTestDatabase } from "@macro-tracker/db/testing"; import { eq } from "drizzle-orm"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { handleApiV1Request } from "@/lib/api-v1"; +import { API_V1_BACKEND_TIMEOUT_MS, handleApiV1Request } from "@/lib/api-v1"; import { API_V1_ENDPOINTS, formatApiV1ScopeSummary } from "@/lib/api-v1-openapi"; import * as apiV1Route from "@/app/api/v1/[[...path]]/route"; import { withBackendUrl } from "./helpers/test-env"; describe("API v1 backend proxy failures", () => { + async function withStubBackend( + handler: (request: IncomingMessage, response: ServerResponse) => void, + operation: (baseUrl: string) => Promise, + ): Promise { + const server = createServer(handler); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("stub backend failed to bind a TCP port"); + } + + try { + return await operation(`http://127.0.0.1:${address.port}`); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + } + + it("keeps the proxy budget above the backend's 30 second dispatch deadline", () => { + // `API_REQUEST_TIMEOUT` in apps/backend/src/api.rs is 30s: the proxy must outlive it so the + // backend's own documented 504 envelope reaches the client first. + expect(API_V1_BACKEND_TIMEOUT_MS).toBeGreaterThan(30_000); + }); + it("returns upstream_error with CORS headers when backendFetch rejects", async () => { const response = await withBackendUrl("http://127.0.0.1:9", () => handleApiV1Request(new Request("http://localhost/api/v1/me"), ["me"], "GET"), @@ -39,6 +68,93 @@ describe("API v1 backend proxy failures", () => { }); }); + it("classifies a proxy deadline expiry as 504 timeout without retrying the request", async () => { + let requestCount = 0; + const response = await withStubBackend( + () => { + requestCount += 1; + // Deliberately never respond; the proxy deadline must end the wait. + }, + (baseUrl) => + withBackendUrl(baseUrl, () => + handleApiV1Request(new Request("http://localhost/api/v1/me"), ["me"], "GET", { + timeoutMs: 100, + }), + ), + ); + + expect(response.status).toBe(504); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("cache-control")).toBe("no-store"); + await expect(response.json()).resolves.toEqual({ + ok: false, + error: { + code: "timeout", + message: "The request took too long to complete.", + }, + }); + expect(requestCount).toBe(1); + }); + + it("passes the backend's own 504 timeout envelope through unchanged", async () => { + const response = await withStubBackend( + (_request, stubResponse) => { + stubResponse.writeHead(504, { "content-type": "application/json" }); + stubResponse.end( + JSON.stringify({ + ok: false, + error: { + code: "timeout", + message: "The request took too long to complete.", + }, + }), + ); + }, + (baseUrl) => + withBackendUrl(baseUrl, () => + handleApiV1Request(new Request("http://localhost/api/v1/stats"), ["stats"], "GET"), + ), + ); + + expect(response.status).toBe(504); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: { code: "timeout" }, + }); + }); + + it("forwards a failing mutation exactly once", async () => { + let requestCount = 0; + const response = await withStubBackend( + (stubRequest, stubResponse) => { + requestCount += 1; + stubRequest.resume(); + stubResponse.writeHead(503, { "content-type": "application/json" }); + stubResponse.end( + JSON.stringify({ + ok: false, + error: { code: "upstream_error", message: "Backend service is unavailable." }, + }), + ); + }, + (baseUrl) => + withBackendUrl(baseUrl, () => + handleApiV1Request( + new Request("http://localhost/api/v1/goals", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ proteinG: 150 }), + }), + ["goals"], + "PATCH", + ), + ), + ); + + expect(response.status).toBe(503); + expect(requestCount).toBe(1); + }); + it("rejects dot-segment path traversal before proxying to the backend", async () => { const response = await handleApiV1Request( new Request("http://localhost/api/v1/foods/.."), From 0954ad05a454d082edf3a4cc12d4a45dd33a8167 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:59:59 +0200 Subject: [PATCH 3/4] docs(setup): correct Node engine, pool, TLS and E2E test-route contracts --- README.md | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 82cf98c1..2f8535c6 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Macro Tracker runs as two services: a Rust backend that owns database access and Requirements: -- Node.js 20+ +- Node.js 22.22.2 or newer 22.x, 24.15.0 or newer 24.x, or 26+ (the locked `jsdom@30.0.1` requires `^22.22.2 || ^24.15.0 || >=26.0.0`; CI runs Node 22) - pnpm 10+ - A PostgreSQL database - A Google sign-in flow through Shoo, which is the auth broker this app uses @@ -99,9 +99,18 @@ pnpm backend:start:release node apps/web/scripts/start-with-migrations.mjs ``` -For a deployed instance, set `APP_URL` to the public URL, `BACKEND_URL` to the backend service URL reachable from the frontend server, and use real random values for `SESSION_SECRET` and `BACKEND_INTERNAL_SECRET`. If you use remote PostgreSQL, `DATABASE_URL` uses TLS with certificate verification by default when `sslmode` is omitted or set to `verify-full`; use `sslmode=require` only when your provider requires encrypted TLS without certificate verification. +For a deployed instance, set `APP_URL` to the public URL, `BACKEND_URL` to the backend service URL reachable from the frontend server, and use real random values for `SESSION_SECRET` and `BACKEND_INTERNAL_SECRET`. Remote PostgreSQL TLS is selected by `sslmode` in `DATABASE_URL`, and the two runtimes treat `require` differently: -The production build uses Next.js standalone output. The direct Node command above runs startup migrations when explicitly enabled, then loads the generated server in the same process. Both Railway frontend configs use this command to avoid keeping pnpm and a launcher parent resident. `pnpm --filter @macro-tracker/web start` remains available locally; without standalone output the launcher loads the Next CLI. PostgreSQL pools default to 10 connections; set `POSTGRES_POOL_MAX` if you need a different cap. The old default of 3 was low enough that a burst of unauthenticated requests could exhaust it before any credential check ran. +| `sslmode` | Rust backend (`sqlx`) | JS migrations, tests and helpers (`pg`) | +| --- | --- | --- | +| omitted | TLS with full certificate verification | TLS with full certificate verification | +| `verify-full` | TLS with full certificate verification | TLS with full certificate verification | +| `require` | TLS; verifies the server certificate only when a root CA is supplied (`sslrootcert` in the URL or `PGSSLROOTCERT`), which this project does not configure | TLS with verification, except Railway private Postgres (`*.railway.internal`), whose self-signed chain is accepted | +| `disable`, `allow`, `prefer`, `no-verify` | rejected | rejected | + +Local and loopback hosts connect without TLS in both runtimes. Prefer `sslmode=verify-full` (or omit `sslmode`) with a provider CA chain the system trust store accepts: `require` is not portable between the runtimes, and because the JS tooling verifies certificates by default it must not be treated as a safe unverified mode. To verify a provider CA only in the Rust backend, set `sslmode=verify-full` together with `sslrootcert`; the JS tooling reads no `sslrootcert` parameter and relies on the system trust store. `ALLOW_UNVERIFIED_DB_TLS=true` is a deliberately loud JS-tooling-only opt-out (it prints a warning and disables certificate verification) and must never be set in a normal deployment; the Rust backend has no equivalent opt-out apart from `sslmode=require`. + +The production build uses Next.js standalone output. The direct Node command above runs startup migrations when explicitly enabled, then loads the generated server in the same process. Both Railway frontend configs use this command to avoid keeping pnpm and a launcher parent resident. `pnpm --filter @macro-tracker/web start` remains available locally; without standalone output the launcher loads the Next CLI. PostgreSQL pool settings are per runtime. The Rust backend opens up to `POSTGRES_POOL_MAX` connections (default 10, allowed 1-256): the old default of 3 was low enough that a burst of unauthenticated requests could exhaust the pool before any credential check ran. The JS migration and test pool defaults to 3 (`POSTGRES_POOL_MAX`) with a 10s idle timeout (`POSTGRES_POOL_IDLE_TIMEOUT_MS`) and a 5s connection timeout (`POSTGRES_POOL_CONNECTION_TIMEOUT_MS`); those variables do not change the Rust pool. ## API Access @@ -118,9 +127,9 @@ Useful optional environment variables: | `APP_TRUSTED_ORIGINS` | Extra comma-separated origins that are allowed during auth flows. | | `SHOO_BASE_URL` | Alternate Shoo base URL. Defaults to `https://shoo.dev`. The Content-Security-Policy is built per request in `apps/web/proxy.ts`, so the runtime value is the one that matters; `connect-src` is derived from it so the browser can reach this origin for the sign-in token exchange. | | `ADMIN_OWNER_EMAILS` | Comma-separated emails that should get owner-level admin access. | -| `POSTGRES_POOL_MAX` | Optional PostgreSQL pool cap. Defaults to `3` for small deployments. | -| `POSTGRES_POOL_IDLE_TIMEOUT_MS` | Optional idle timeout for pooled PostgreSQL clients. Defaults to `10000`. | -| `POSTGRES_POOL_CONNECTION_TIMEOUT_MS` | Optional PostgreSQL connection timeout. Defaults to `5000`. | +| `POSTGRES_POOL_MAX` | PostgreSQL pool cap. The Rust backend defaults to `10` (allowed `1`-`256`); the JS migration and test pool defaults to `3`. | +| `POSTGRES_POOL_IDLE_TIMEOUT_MS` | Optional idle timeout for pooled JS migration/test PostgreSQL clients. Defaults to `10000`. | +| `POSTGRES_POOL_CONNECTION_TIMEOUT_MS` | Optional connection timeout for pooled JS migration/test PostgreSQL clients. Defaults to `5000`. | | `NEXT_CACHE_MAX_MEMORY_MB` | Optional Next.js in-memory cache cap in MB. Defaults to `0`, which disables the in-memory data cache entirely; set a non-zero value where cached fetches are expected to hit. | | `AI_GATEWAY_URL` | OpenAI-compatible chat-completions URL that powers food-photo estimates, for example `http://cliproxyapi.railway.internal:8317/v1/chat/completions`. Must be `https` unless the host is loopback or `*.railway.internal`. See `infra/cliproxyapi/`. Food-photo analysis is unavailable without it. | | `AI_GATEWAY_API_KEY` | Backend-only bearer key for the AI gateway. Required for food-photo estimates. | @@ -178,8 +187,13 @@ pnpm --filter @macro-tracker/db exec tsc --noEmit # before Playwright so global setup, the frontend, and the Rust backend share state. DATABASE_URL="$E2E_DATABASE_URL" pnpm db:migrate -# terminal 1: keep the backend running against $E2E_DATABASE_URL -DATABASE_URL="$E2E_DATABASE_URL" pnpm backend:start +# terminal 1: keep the backend running against $E2E_DATABASE_URL. +# Playwright sets ENABLE_TEST_ROUTES, TEST_ROUTES_SECRET and BACKEND_ENABLE_TEST_ROUTES +# for the Next dev server it starts, but not for this separately started Rust backend. +# BACKEND_ENABLE_TEST_ROUTES enables the test-only role-assignment RPCs the suite calls; +# it is accepted only while APP_URL points at localhost. Leave it unset everywhere except +# this isolated test backend. +DATABASE_URL="$E2E_DATABASE_URL" BACKEND_ENABLE_TEST_ROUTES=true pnpm backend:start # terminal 2: run Playwright against that backend/database DATABASE_URL="$E2E_DATABASE_URL" pnpm test:e2e From e8a4676543a1702ca459dd2ee9dc8861e0c82466 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:24:36 +0200 Subject: [PATCH 4/4] fix(api): document tzOffsetMinutes healthkit query param for DATA-04 integration --- apps/web/lib/api-v1-openapi.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/lib/api-v1-openapi.ts b/apps/web/lib/api-v1-openapi.ts index fd12eda1..6e1c5634 100644 --- a/apps/web/lib/api-v1-openapi.ts +++ b/apps/web/lib/api-v1-openapi.ts @@ -260,6 +260,7 @@ export const API_V1_ENDPOINTS: ApiEndpoint[] = [ parameters: [ { name: "days", in: "query", required: false, description: "How many days back to look." }, { name: "limit", in: "query", required: false, description: "Maximum entries returned in one page." }, + { name: "tzOffsetMinutes", in: "query", required: false, description: "Client UTC offset in minutes (east positive); the day window and `sampleTime` clamp use this local day. Defaults to UTC when omitted." }, ], }, ],