From 687a5fbaaed61beb725d76caf324acb46e40e9ea Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 09:00:21 +0200 Subject: [PATCH 1/6] test(ui): enforce test-files-require-source-sibling, relocate orphan Add a lint-meta rule (complement of logic-files-require-test-sibling) that flags colocated *.test.ts/.tsx with no source sibling, handling the UI's colocated .ts+.tsx layout that the ESLint test-conventions rule can't. Relocate the orphan notifications-cta.test.ts to sit beside sw-url-sanitize.ts (the module it covers). Audit: F010 --- apps/docs/src/data/lint-meta-catalog.json | 6 ++ apps/ui/scripts/lint-meta/RULES.md | 1 + apps/ui/scripts/lint-meta/cli.ts | 1 + apps/ui/scripts/lint-meta/registry.ts | 2 + .../test-files-require-source-sibling.ts | 62 +++++++++++++++++++ .../web-push/sw-url-sanitize.test.ts} | 2 +- apps/ui/tests/lint-meta/lint-meta.test.ts | 47 ++++++++++++++ 7 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 apps/ui/scripts/lint-meta/rules/testing/test-files-require-source-sibling.ts rename apps/ui/src/{features/notifications/notifications-cta.test.ts => lib/web-push/sw-url-sanitize.test.ts} (96%) diff --git a/apps/docs/src/data/lint-meta-catalog.json b/apps/docs/src/data/lint-meta-catalog.json index d3bec88a..289d959c 100644 --- a/apps/docs/src/data/lint-meta-catalog.json +++ b/apps/docs/src/data/lint-meta-catalog.json @@ -138,6 +138,12 @@ "ciCritical": false, "description": "Logic modules must ship with a colocated *.test.ts or *.test.tsx sibling." }, + { + "id": "test-files-require-source-sibling", + "category": "testing", + "ciCritical": false, + "description": "Colocated test files must mirror a source sibling (no orphaned tests)." + }, { "id": "skipped-tests-need-tracking", "category": "testing", diff --git a/apps/ui/scripts/lint-meta/RULES.md b/apps/ui/scripts/lint-meta/RULES.md index 4a5c8550..ff08a733 100644 --- a/apps/ui/scripts/lint-meta/RULES.md +++ b/apps/ui/scripts/lint-meta/RULES.md @@ -37,6 +37,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `no-raw-fetch-scripts` | source-text | no | Scripts must not call global fetch except github-actions-permissions.ts (lint:meta --verify SHA check). | | `queries-no-silent-error-swallow` | source-text | no | *.queries.ts files must not silently swallow query errors as `null`. Let the typed error propagate so consumers can distinguish auth from outage; opt-out per-catch with `// allow-silent: ` when an explicit null is genuinely the right contract. | | `logic-files-require-test-sibling` | testing | no | Logic modules must ship with a colocated *.test.ts or *.test.tsx sibling. | +| `test-files-require-source-sibling` | testing | no | Colocated test files must mirror a source sibling (no orphaned tests). | | `skipped-tests-need-tracking` | testing | no | Skipped tests (.skip/.only/xit/xdescribe) must carry an issue URL or TODO(@owner) so the debt has a tracked owner. | | `eslint-config-no-warn` | config | no | ESLint severities must be "error" or "off", not "warn". | diff --git a/apps/ui/scripts/lint-meta/cli.ts b/apps/ui/scripts/lint-meta/cli.ts index 228ecec9..0d4f35e0 100644 --- a/apps/ui/scripts/lint-meta/cli.ts +++ b/apps/ui/scripts/lint-meta/cli.ts @@ -80,6 +80,7 @@ export { checkCanonicalHelpersSingleHome } from "./rules/source-text/canonical-h export { checkNoCrossRepoImports } from "./rules/source-text/no-cross-repo-import"; export { checkNoRawRoleLiterals } from "./rules/source-text/no-raw-role-literals"; export { checkScriptRawFetch } from "./rules/source-text/script-raw-fetch"; +export { checkTestFilesHaveSource } from "./rules/testing/test-files-require-source-sibling"; /** @param file Absolute path to the source file under test */ export function checkForbiddenText( diff --git a/apps/ui/scripts/lint-meta/registry.ts b/apps/ui/scripts/lint-meta/registry.ts index e0f69284..98b46e99 100644 --- a/apps/ui/scripts/lint-meta/registry.ts +++ b/apps/ui/scripts/lint-meta/registry.ts @@ -16,6 +16,7 @@ import { noOverlappingLibsRule } from "./rules/supply-chain/no-overlapping-libs" import { packageJsonExactDepsRule } from "./rules/supply-chain/package-json-exact-deps"; import { logicFilesRequireTestSiblingRule } from "./rules/testing/logic-files-require-test-sibling"; import { skippedTestsNeedTrackingRule } from "./rules/testing/skipped-tests-need-tracking"; +import { testFilesRequireSourceSiblingRule } from "./rules/testing/test-files-require-source-sibling"; import type { IMetaRule } from "./types"; export const META_RULES: readonly IMetaRule[] = [ @@ -41,6 +42,7 @@ export const META_RULES: readonly IMetaRule[] = [ noSilentErrorSwallowRule, // --- testing --- logicFilesRequireTestSiblingRule, + testFilesRequireSourceSiblingRule, skippedTestsNeedTrackingRule, // --- config --- eslintConfigNoWarnRule diff --git a/apps/ui/scripts/lint-meta/rules/testing/test-files-require-source-sibling.ts b/apps/ui/scripts/lint-meta/rules/testing/test-files-require-source-sibling.ts new file mode 100644 index 00000000..17c144d7 --- /dev/null +++ b/apps/ui/scripts/lint-meta/rules/testing/test-files-require-source-sibling.ts @@ -0,0 +1,62 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { collectSourceFiles } from "../../context"; +import type { IMetaRule, IViolation } from "../../types"; + +/* + * The complement of `logic-files-require-test-sibling`: every colocated + * `*.test.ts` / `*.test.tsx` under `src/` must sit next to the source file + * it covers. Catches orphaned tests left behind after a refactor or rename + * (a stray test silently keeps passing while the thing it claimed to cover + * is gone). The UI app colocates tests beside source, so this lives in + * lint-meta rather than the ESLint `test-conventions/test-file-mirrors-source` + * rule, which assumes a separate `tests/` tree and `.ts`-only sources. + * + * Tests under `tests/` (factories, lint-meta, service-worker suites) are + * intentionally not source-mirrored and are out of scope: this rule only + * walks `src/`. + */ +export function checkTestFilesHaveSource(root: string): IViolation[] { + const violations: IViolation[] = []; + const srcRoot = join(root, "src"); + + for (const file of collectSourceFiles(srcRoot)) { + let base: string | null = null; + + if (file.endsWith(".test.ts")) { + base = file.slice(0, -".test.ts".length); + } else if (file.endsWith(".test.tsx")) { + base = file.slice(0, -".test.tsx".length); + } + + if (base === null) { + continue; + } + + if (existsSync(`${base}.ts`) || existsSync(`${base}.tsx`)) { + continue; + } + + violations.push({ + file, + rule: "test-files-require-source-sibling", + message: `Orphaned test. No source sibling found — expected \`${base.slice( + root.length + 1 + )}.ts\` (or \`.tsx\`) next to this test. Rename the test to mirror the module it covers, move it beside that module, or delete it.` + }); + } + + return violations; +} + +/** Colocated *.test.ts / *.test.tsx files must mirror a source sibling. */ +export const testFilesRequireSourceSiblingRule: IMetaRule = { + id: "test-files-require-source-sibling", + category: "testing", + description: + "Colocated test files must mirror a source sibling (no orphaned tests).", + run({ root }) { + return checkTestFilesHaveSource(root); + } +}; diff --git a/apps/ui/src/features/notifications/notifications-cta.test.ts b/apps/ui/src/lib/web-push/sw-url-sanitize.test.ts similarity index 96% rename from apps/ui/src/features/notifications/notifications-cta.test.ts rename to apps/ui/src/lib/web-push/sw-url-sanitize.test.ts index 7d64ff73..8a54d024 100644 --- a/apps/ui/src/features/notifications/notifications-cta.test.ts +++ b/apps/ui/src/lib/web-push/sw-url-sanitize.test.ts @@ -12,7 +12,7 @@ import { sanitizeTargetPath } from "@/lib/web-push/sw-url-sanitize"; */ const ORIGIN = "https://app.example.test"; -describe("in-app notification CTA sanitization", () => { +describe("sanitizeTargetPath (in-app notification CTA sanitization)", () => { it("preserves a same-origin path", () => { expect(sanitizeTargetPath("/notifications/123", ORIGIN)).toBe( "/notifications/123" diff --git a/apps/ui/tests/lint-meta/lint-meta.test.ts b/apps/ui/tests/lint-meta/lint-meta.test.ts index 71224c6f..07310fc7 100644 --- a/apps/ui/tests/lint-meta/lint-meta.test.ts +++ b/apps/ui/tests/lint-meta/lint-meta.test.ts @@ -20,6 +20,7 @@ import { checkNoSilentErrorSwallow, checkPackageJson, checkScriptRawFetch, + checkTestFilesHaveSource, checkUiEnvCascadeDrift, checkWorkflow, collectSourceFiles, @@ -473,6 +474,52 @@ describe("checkCanonicalHelpersSingleHome", () => { }); }); +describe("checkTestFilesHaveSource", () => { + test("flags a colocated test with no source sibling", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-orphan-")); + + try { + mkdirSync(join(root, "src", "lib", "web-push"), { recursive: true }); + writeFileSync( + join(root, "src", "lib", "web-push", "orphan.test.ts"), + "import { it } from 'vitest';\nit('x', () => {});\n" + ); + + const violations = checkTestFilesHaveSource(root); + + expect( + violations.some( + (row) => row.rule === "test-files-require-source-sibling" + ) + ).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes when a .test.tsx mirrors a .tsx source", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-orphan-")); + + try { + mkdirSync(join(root, "src", "components"), { recursive: true }); + writeFileSync( + join(root, "src", "components", "Widget.tsx"), + "export const Widget = () => null;\n" + ); + writeFileSync( + join(root, "src", "components", "Widget.test.tsx"), + "import { it } from 'vitest';\nit('x', () => {});\n" + ); + + const violations = checkTestFilesHaveSource(root); + + expect(violations).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("RULES.md catalog", () => { test("matches generate-rules-md output", () => { const rulesPath = join( From 041f834345222f9a7a35c0a5aa8eaf6483e41c7a Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 09:02:37 +0200 Subject: [PATCH 2/6] fix(api): always set Secure on auth cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth/refresh cookies set secure unconditionally instead of only in production, so staging/preview hosts never ship tokens over plaintext. localhost remains a secure context in browsers, so dev is unaffected; sameSite stays env-conditional for dev OAuth redirects. The jwt-cookies ESLint plugin accepts any present secure value, so it cannot enforce a literal true — flagged for the cross-repo boringstack-xyz/eslint-plugins to require secure: true. Audit: F009 --- apps/api/src/lib/cookies/cookie-utils.ts | 10 ++++++++-- apps/api/tests/lib/cookies/cookie-utils.test.ts | 8 ++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/api/src/lib/cookies/cookie-utils.ts b/apps/api/src/lib/cookies/cookie-utils.ts index 5a6e5634..8bf802ca 100644 --- a/apps/api/src/lib/cookies/cookie-utils.ts +++ b/apps/api/src/lib/cookies/cookie-utils.ts @@ -6,9 +6,15 @@ export const REFRESH_COOKIE_NAME = "refresh_token"; const REFRESH_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; +/* + * `secure` is always true: browsers treat http://localhost as a secure + * context, so dev still works, while staging/preview hosts never ship auth + * cookies over plaintext. `sameSite` stays env-conditional because dev OAuth + * redirects cross ports and need `lax`. + */ export const AUTH_COOKIE_CONFIG = { httpOnly: true, - secure: env.isProduction, + secure: true, sameSite: env.isProduction ? ("strict" as const) : ("lax" as const), maxAge: JWT_TTL_SECONDS, path: "/", @@ -16,7 +22,7 @@ export const AUTH_COOKIE_CONFIG = { export const REFRESH_COOKIE_CONFIG = { httpOnly: true, - secure: env.isProduction, + secure: true, sameSite: env.isProduction ? ("strict" as const) : ("lax" as const), maxAge: REFRESH_COOKIE_MAX_AGE_SECONDS, path: "/", diff --git a/apps/api/tests/lib/cookies/cookie-utils.test.ts b/apps/api/tests/lib/cookies/cookie-utils.test.ts index a4e9ac2a..9e40336d 100644 --- a/apps/api/tests/lib/cookies/cookie-utils.test.ts +++ b/apps/api/tests/lib/cookies/cookie-utils.test.ts @@ -30,6 +30,10 @@ describe("AUTH_COOKIE_CONFIG", () => { test("sameSite is one of the safe lax/strict values", () => { expect(["lax", "strict"]).toContain(AUTH_COOKIE_CONFIG.sameSite); }); + + test("is always Secure (never shipped over plaintext)", () => { + expect(AUTH_COOKIE_CONFIG.secure).toBe(true); + }); }); describe("REFRESH_COOKIE_CONFIG", () => { @@ -49,4 +53,8 @@ describe("REFRESH_COOKIE_CONFIG", () => { AUTH_COOKIE_CONFIG.maxAge ); }); + + test("is always Secure (never shipped over plaintext)", () => { + expect(REFRESH_COOKIE_CONFIG.secure).toBe(true); + }); }); From 2f4c12ca6d2d25c7da326048cac86d0780eecc4b Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 09:08:40 +0200 Subject: [PATCH 3/6] fix(api): keep rate-limit fail-open visible to alerting When the window is non-positive, rate limiting fails open on every request. init() warns once at startup, but startup logs roll off and the open state then goes silent. Re-emit the warning from the increment path, throttled to once per 60s so a persistent misconfiguration keeps alerting lit without flooding the hot path. Adds the first unit test for the Valkey rate-limit context. Audit: F008 --- apps/api/src/lib/rate-limit/valkey-context.ts | 33 ++++++++++++ .../lib/rate-limit/valkey-context.test.ts | 50 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 apps/api/tests/lib/rate-limit/valkey-context.test.ts diff --git a/apps/api/src/lib/rate-limit/valkey-context.ts b/apps/api/src/lib/rate-limit/valkey-context.ts index 02e317f5..f68c539c 100644 --- a/apps/api/src/lib/rate-limit/valkey-context.ts +++ b/apps/api/src/lib/rate-limit/valkey-context.ts @@ -40,12 +40,23 @@ interface IIncrementResult { const RATE_LIMIT_KEY_PREFIX = "rl:"; +/* + * A non-positive window means rate limiting is failing open on every + * request. `init()` already warns once at startup, but startup logs roll + * off and the open state would then be invisible to alerting. Re-emit the + * warning from the hot increment path, throttled to once per interval so a + * persistent misconfiguration keeps an alert rule firing without flooding + * the log on every request. + */ +const DISABLED_WARN_INTERVAL_MS = 60_000; + const buildKey = (rawKey: string): string => `${RATE_LIMIT_KEY_PREFIX}${rawKey}`; export class ValkeyRateLimitContext implements RateLimitContext { private readonly client: Redis; private durationMs = 0; + private lastDisabledWarnMs = Number.NEGATIVE_INFINITY; constructor(client: Redis = new Redis(getValkeyAppClientOptions())) { this.client = client; @@ -93,6 +104,8 @@ export class ValkeyRateLimitContext implements RateLimitContext { const durationMs = duration ?? this.durationMs; if (durationMs <= 0) { + this.warnRateLimitingDisabled(now, durationMs); + return this.permissiveFallback(now, durationMs); } @@ -189,6 +202,26 @@ export class ValkeyRateLimitContext implements RateLimitContext { this.client.disconnect(); } + /* + * Surface a non-positive window from the request path, throttled so a + * persistent misconfiguration stays visible to alerting without flooding + * the log. `now` is the request clock, so the throttle is deterministic. + */ + private warnRateLimitingDisabled(now: number, durationMs: number): void { + if (now - this.lastDisabledWarnMs < DISABLED_WARN_INTERVAL_MS) { + return; + } + + this.lastDisabledWarnMs = now; + logger.warn( + "Rate-limit window is non-positive; Valkey rate limiting is failing open", + { + event: "rate_limit_misconfigured", + durationMs, + } + ); + } + /** * Return a "first request in window" shape so the caller treats the * request as allowed. We choose permissive over restrictive on diff --git a/apps/api/tests/lib/rate-limit/valkey-context.test.ts b/apps/api/tests/lib/rate-limit/valkey-context.test.ts new file mode 100644 index 00000000..659d5871 --- /dev/null +++ b/apps/api/tests/lib/rate-limit/valkey-context.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { Redis } from "ioredis"; + +import { logger } from "../../../src/config/logger"; +import { ValkeyRateLimitContext } from "../../../src/lib/rate-limit/valkey-context"; + +/* + * `lazyConnect` keeps ioredis from opening a socket: the misconfiguration + * path returns before issuing any command, so the client is never touched. + */ +const makeContext = (): { ctx: ValkeyRateLimitContext; client: Redis } => { + const client = new Redis({ lazyConnect: true }); + + return { ctx: new ValkeyRateLimitContext(client), client }; +}; + +const countMisconfigWarns = (calls: unknown[]): number => + (JSON.stringify(calls).match(/rate_limit_misconfigured/gu) ?? []).length; + +describe("ValkeyRateLimitContext non-positive window", () => { + test("fails open and warns when the window is non-positive", async () => { + const { ctx, client } = makeContext(); + const warnSpy = spyOn(logger, "warn"); + + try { + const result = await ctx.increment("user:1", 0, 1_000); + + expect(result.count).toBe(1); + expect(countMisconfigWarns(warnSpy.mock.calls)).toBe(1); + } finally { + warnSpy.mockRestore(); + client.disconnect(); + } + }); + + test("throttles the warning so the hot path does not flood the log", async () => { + const { ctx, client } = makeContext(); + const warnSpy = spyOn(logger, "warn"); + + try { + await ctx.increment("user:1", 0, 1_000); + await ctx.increment("user:1", 0, 1_500); + + expect(countMisconfigWarns(warnSpy.mock.calls)).toBe(1); + } finally { + warnSpy.mockRestore(); + client.disconnect(); + } + }); +}); From 196bb34fc0225269a31816e7279e2183b08d0e49 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 09:12:33 +0200 Subject: [PATCH 4/6] fix(ui): validate API response bodies instead of casting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two production sites cast a parsed JSON body to an inline object type (as { … }) and trusted the shape. Replace both with property-narrowing guards (no cast), matching the existing extractApiErrorBody idiom. Add a lint-meta no-inline-object-cast source-text ban scoped to production src/ (tests/e2e/ storybook still cast fixtures) — the merge bar says 'only as const' but consistent-type-assertions only bans object-literal expressions, not assertions to an inline object type. This closes that gap. Audit: F007 --- apps/docs/src/data/lint-meta-catalog.json | 6 ++++++ apps/ui/scripts/lint-meta/RULES.md | 1 + .../rules/source-text/forbidden-patterns.ts | 21 +++++++++++++++++++ .../features/auth/Auth.signup.mutations.ts | 6 +++++- apps/ui/src/lib/api/openapi.ts | 14 +++++++++---- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/apps/docs/src/data/lint-meta-catalog.json b/apps/docs/src/data/lint-meta-catalog.json index 289d959c..03ea1d4d 100644 --- a/apps/docs/src/data/lint-meta-catalog.json +++ b/apps/docs/src/data/lint-meta-catalog.json @@ -102,6 +102,12 @@ "ciCritical": false, "description": "Use the typed apiClient; raw fetch is restricted to src/lib/api/openapi." }, + { + "id": "no-inline-object-cast", + "category": "source-text", + "ciCritical": false, + "description": "Casting to an inline object type (`as { … }`) skips validation." + }, { "id": "no-dark-variant", "category": "source-text", diff --git a/apps/ui/scripts/lint-meta/RULES.md b/apps/ui/scripts/lint-meta/RULES.md index ff08a733..9df34fcc 100644 --- a/apps/ui/scripts/lint-meta/RULES.md +++ b/apps/ui/scripts/lint-meta/RULES.md @@ -31,6 +31,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `no-dangerous-html` | source-text | no | Raw HTML rendering requires a dedicated sanitizer and security review. | | `env-access` | source-text | no | Read Vite env through src/lib/env only. | | `no-raw-fetch` | source-text | no | Use the typed apiClient; raw fetch is restricted to src/lib/api/openapi. | +| `no-inline-object-cast` | source-text | no | Casting to an inline object type (`as { … }`) skips validation. | | `no-dark-variant` | source-text | no | The `dark:` Tailwind variant is banned. | | `no-cross-repo-import` | source-text | **yes** | Relative imports must stay inside apps/ui; no backend or infra source paths. | | `no-raw-role-literal` | source-text | no | Use ROLE.* from acl.types instead of raw owner/admin/member/viewer string literals. | diff --git a/apps/ui/scripts/lint-meta/rules/source-text/forbidden-patterns.ts b/apps/ui/scripts/lint-meta/rules/source-text/forbidden-patterns.ts index 8c557a58..9ef36226 100644 --- a/apps/ui/scripts/lint-meta/rules/source-text/forbidden-patterns.ts +++ b/apps/ui/scripts/lint-meta/rules/source-text/forbidden-patterns.ts @@ -64,6 +64,27 @@ export function createForbiddenTextPatterns( "Use the typed apiClient; raw fetch is restricted to src/lib/api/openapi.ts.", allow: (file) => rawFetchAllowlist.has(file) }, + { + /* + * Casting a value to an inline object type (`x as { … }`) skips + * runtime validation — the classic footgun is asserting the shape of + * a parsed JSON / API response body and trusting it. The merge bar is + * "only `as const`"; ESLint's consistent-type-assertions only bans + * object-literal *expressions* (`{} as T`), not assertions *to* an + * inline object type, so this closes that gap for production source. + * Narrow the value with a type guard instead (see + * src/lib/api/openapi.ts `extractApiErrorBody`). Tests, e2e, and + * Storybook keep casting for fixtures, so the ban is src-only and + * skips colocated `*.test.*` files. + */ + rule: "no-inline-object-cast", + pattern: /\bas\s+\{/u, + message: + "Casting to an inline object type (`as { … }`) skips validation. Narrow the value with a type guard instead.", + allow: (file) => + !file.startsWith(resolve(root, "src")) || + /\.test\.(?:ts|tsx)$/u.test(file) + }, { /* * Theme tokens in tailwind.css are the only source of truth for diff --git a/apps/ui/src/features/auth/Auth.signup.mutations.ts b/apps/ui/src/features/auth/Auth.signup.mutations.ts index f4f1510a..6577283a 100644 --- a/apps/ui/src/features/auth/Auth.signup.mutations.ts +++ b/apps/ui/src/features/auth/Auth.signup.mutations.ts @@ -35,7 +35,11 @@ export function useRegister(): UseMutationResult< throw new ApiError(0, { message: "Empty register response" }); } - const message = (data.data as { message?: string }).message; + const payload: unknown = data.data; + const message = + typeof payload === "object" && payload !== null && "message" in payload + ? Reflect.get(payload, "message") + : undefined; return typeof message === "string" ? message : ""; } diff --git a/apps/ui/src/lib/api/openapi.ts b/apps/ui/src/lib/api/openapi.ts index 3593f870..980d22b6 100644 --- a/apps/ui/src/lib/api/openapi.ts +++ b/apps/ui/src/lib/api/openapi.ts @@ -40,12 +40,18 @@ let inFlightRefresh: Promise | null = null; * reconnect loop indefinitely after logout because the anonymous probe is * also a 200. */ +function getProp(value: unknown, key: string): unknown { + if (typeof value !== "object" || value === null || !(key in value)) { + return undefined; + } + + return Reflect.get(value, key); +} + async function readSessionUserId(response: Response): Promise { try { - const body = (await response.clone().json()) as { - data?: { user?: { id?: unknown } | null } | null; - } | null; - const userId = body?.data?.user?.id; + const body: unknown = await response.clone().json(); + const userId = getProp(getProp(getProp(body, "data"), "user"), "id"); if (typeof userId === "string" && userId.length > 0) { return userId; From 0951618403ca3de44b0d09badae2621b7ea9a9ce Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 09:26:17 +0200 Subject: [PATCH 5/6] fix(infra): add healthchecks to observability + overlay services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prometheus, Alertmanager, Grafana, Loki, Promtail, Tempo, postgres-exporter, node-exporter, mailpit, and bullmq-dashboard had no healthcheck, so depends_on could not gate on readiness and operator dashboards couldn't tell running from ready. Add a wget --spider probe against each service's own health endpoint (all images ship busybox wget); GlitchTip ships none so it probes /_health/ via python3. Switch glitchtip-worker's depends_on glitchtip-web to service_healthy to close the real boot race. Verified: all services report healthy on a live boot; the GlitchTip probe returns 200 / exits 0 against the running container. Follow-up guardrail (not lint-meta — those rules are app-scoped and don't scan infra/compose): add a healthcheck-presence assertion to the infra-compose-validate-compose workflow with an allowlist for transient (api-migrate) and proxy (traefik) services. Audit: F001 --- .../compose/compose/docker-compose.bullmq.yml | 7 +++ .../compose/docker-compose.glitchtip.yml | 12 +++- .../compose/docker-compose.mailpit.yml | 7 +++ .../compose/docker-compose.observability.yml | 61 +++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/infra/compose/compose/docker-compose.bullmq.yml b/infra/compose/compose/docker-compose.bullmq.yml index 399b8ce6..cd144317 100644 --- a/infra/compose/compose/docker-compose.bullmq.yml +++ b/infra/compose/compose/docker-compose.bullmq.yml @@ -35,6 +35,13 @@ services: - frontend ports: - "7332:7332" + healthcheck: + test: + ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:7332/"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 10s labels: - "traefik.enable=true" - "traefik.docker.network=backend" diff --git a/infra/compose/compose/docker-compose.glitchtip.yml b/infra/compose/compose/docker-compose.glitchtip.yml index f6579f36..3cd35dd1 100644 --- a/infra/compose/compose/docker-compose.glitchtip.yml +++ b/infra/compose/compose/docker-compose.glitchtip.yml @@ -48,6 +48,16 @@ services: networks: - backend - frontend + # GlitchTip's image ships no wget/curl, so probe `/_health/` (Django, + # returns 200 when ready) with the bundled python3. The worker below + # waits on this via `service_healthy`. + healthcheck: + test: + ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/_health/', timeout=3)"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 30s deploy: resources: limits: @@ -74,7 +84,7 @@ services: command: ["./bin/run-celery-with-beat.sh"] depends_on: glitchtip-web: - condition: service_started + condition: service_healthy environment: *glitchtip-env networks: - backend diff --git a/infra/compose/compose/docker-compose.mailpit.yml b/infra/compose/compose/docker-compose.mailpit.yml index 61211310..5dd4b9cc 100644 --- a/infra/compose/compose/docker-compose.mailpit.yml +++ b/infra/compose/compose/docker-compose.mailpit.yml @@ -26,6 +26,13 @@ services: - "1025:1025" # SMTP networks: - backend + healthcheck: + test: + ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8025/readyz"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 10s deploy: resources: limits: diff --git a/infra/compose/compose/docker-compose.observability.yml b/infra/compose/compose/docker-compose.observability.yml index bd6ef35f..d9455ffc 100644 --- a/infra/compose/compose/docker-compose.observability.yml +++ b/infra/compose/compose/docker-compose.observability.yml @@ -10,6 +10,11 @@ # of running this in dev is so you've already used the dashboards before # prod day-one — disabling it means learning the panels during your first # real incident. +# +# Every service carries a healthcheck so `depends_on: { condition: +# service_healthy }` and operator dashboards can tell "running" from +# "ready." All images ship busybox `wget`, so the readiness probe is a +# `wget --spider` against each service's own health endpoint. services: prometheus: @@ -31,6 +36,13 @@ services: - frontend depends_on: - alertmanager + healthcheck: + test: + ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/-/healthy"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 10s deploy: resources: limits: @@ -55,6 +67,13 @@ services: - "9093:9093" networks: - backend + healthcheck: + test: + ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9093/-/healthy"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 10s deploy: resources: limits: @@ -83,6 +102,13 @@ services: - prometheus - loki - tempo + healthcheck: + test: + ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 20s deploy: resources: limits: @@ -98,6 +124,13 @@ services: - loki_data:/loki networks: - backend + healthcheck: + test: + ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3100/ready"] + interval: 10s + timeout: 3s + retries: 6 + start_period: 20s deploy: resources: limits: @@ -117,6 +150,13 @@ services: - loki networks: - backend + healthcheck: + test: + ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9080/ready"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 10s deploy: resources: limits: @@ -133,6 +173,13 @@ services: - tempo_data:/var/tempo networks: - backend + healthcheck: + test: + ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3200/ready"] + interval: 10s + timeout: 3s + retries: 6 + start_period: 15s deploy: resources: limits: @@ -150,6 +197,13 @@ services: condition: service_healthy networks: - backend + healthcheck: + test: + ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9187/"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 10s deploy: resources: limits: @@ -171,6 +225,13 @@ services: - /:/rootfs:ro networks: - backend + healthcheck: + test: + ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9100/metrics"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 10s deploy: resources: limits: From 4c824cb47c7b6b329462bec9518f1d0f1118b13b Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 09:28:22 +0200 Subject: [PATCH 6/6] fix(ci): wire infra/compose validation into root pre-push fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit infra/compose/scripts/pre-push.sh mirrors the infra-compose-validate-compose CI gate (docker compose config across all 8 overlay combos + shellcheck + yamllint), but the root pre-push fan-out never invoked it. A push touching only infra/compose ran smoke (one dev+smoke boot) but not the config matrix, so a malformed prod/glitchtip/wud overlay slipped to CI. Invoke the existing mirror when infra/compose, scripts/, or the infra-compose workflow change — the same trigger paths CI uses. Verified the gate passes (all overlay combos validate; shellcheck clean). Audit: F006 --- scripts/ci/pre-push.sh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/scripts/ci/pre-push.sh b/scripts/ci/pre-push.sh index cc24a571..2860b5f0 100755 --- a/scripts/ci/pre-push.sh +++ b/scripts/ci/pre-push.sh @@ -55,6 +55,22 @@ app_changed() { return 1 } +# infra/compose ships a CI gate (infra-compose-validate-compose.yml) that +# `docker compose config`s every overlay combination + shellchecks + yamllints. +# Its local mirror is infra/compose/scripts/pre-push.sh. Without this, a push +# that only touches infra/compose runs smoke (one dev+smoke boot) but never the +# config matrix — a malformed prod/glitchtip/wud overlay would slip to CI. Gate +# on the same paths the CI workflow triggers on. +infra_compose_changed() { + if [[ -z "$CHANGED_PATHS" ]]; then + return 0 + fi + if echo "$CHANGED_PATHS" | grep -qE "(^infra/compose/|^scripts/|^\.github/workflows/infra-compose-)"; then + return 0 + fi + return 1 +} + run_app_gate() { local app="$1" local husky="apps/${app}/.husky/pre-push" @@ -111,6 +127,13 @@ for app in api ui docs; do fi done +if infra_compose_changed; then + step "Running infra/compose pre-push gate (compose config + shellcheck + yamllint)" + bash "$ROOT/infra/compose/scripts/pre-push.sh" + ok "infra/compose gate passed" + RAN_ANY=1 +fi + if [[ "$RAN_ANY" -eq 0 ]]; then ok "No app-scoped changes — root pre-push has nothing to gate." fi