From 3288cca1143dd4e99e6e5bbd9faa3d7404235788 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 19:41:33 +0200 Subject: [PATCH 01/17] fix(lint-meta): pre-push-ci-parity fails closed and resolves monorepo workflows Both apps' rules silently no-oped: ui manifest had a stale stages-only shape and both rules required an app-local .github/workflows that does not exist in the monorepo (api pointed at ci.yml which exists nowhere). The rule now walks up to the nearest directory containing the manifest's ciWorkflow, flags malformed manifests, and flags unresolvable workflows instead of skipping. Audit: F004 --- apps/api/scripts/ci/pre-push.manifest.json | 2 +- .../lint-meta/rules/ci/pre-push-ci-parity.ts | 60 +++++++++++-- apps/api/tests/lint-meta/lint-meta.test.ts | 84 ++++++++++++++++++- apps/ui/scripts/ci/pre-push.manifest.json | 3 +- apps/ui/scripts/lint-meta/cli.ts | 1 + .../lint-meta/rules/ci/pre-push-ci-parity.ts | 60 +++++++++++-- apps/ui/tests/lint-meta/lint-meta.test.ts | 77 +++++++++++++++++ 7 files changed, 270 insertions(+), 17 deletions(-) diff --git a/apps/api/scripts/ci/pre-push.manifest.json b/apps/api/scripts/ci/pre-push.manifest.json index dd8df076..39e07ffd 100644 --- a/apps/api/scripts/ci/pre-push.manifest.json +++ b/apps/api/scripts/ci/pre-push.manifest.json @@ -1,5 +1,5 @@ { - "ciWorkflow": ".github/workflows/ci.yml", + "ciWorkflow": ".github/workflows/apps-api-ci.yml", "requiredCommands": [ "bun run check", "bun run test", diff --git a/apps/api/scripts/lint-meta/rules/ci/pre-push-ci-parity.ts b/apps/api/scripts/lint-meta/rules/ci/pre-push-ci-parity.ts index c15115ff..792c4f47 100644 --- a/apps/api/scripts/lint-meta/rules/ci/pre-push-ci-parity.ts +++ b/apps/api/scripts/lint-meta/rules/ci/pre-push-ci-parity.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import type { IMetaRule, IViolation } from "../../types"; @@ -33,27 +33,75 @@ function readPrePushManifest(manifestPath: string): { return { ciWorkflow, requiredCommands }; } +/* + * The CI workflow lives at the app root when this template is a standalone + * repo, but in a monorepo checkout it lives at the repository root. Walk up + * from the app root to the nearest directory containing the manifest's + * ciWorkflow path so the rule always compares against the workflow that + * actually runs for this code instead of silently no-oping. + */ +function resolveCiWorkflow(root: string, ciWorkflow: string): string | null { + let current = root; + + for (;;) { + const candidate = join(current, ciWorkflow); + + if (existsSync(candidate)) { + return candidate; + } + + const parent = dirname(current); + + if (parent === current) { + return null; + } + + current = parent; + } +} + export function checkPrePushParity(root: string): IViolation[] { const manifestPath = join(root, PRE_PUSH_MANIFEST); - const workflowPath = join(root, ".github", "workflows", "ci.yml"); - if (!existsSync(manifestPath) || !existsSync(workflowPath)) { + // No manifest means the consumer deliberately opted out of pre-push parity. + if (!existsSync(manifestPath)) { return []; } const manifest = readPrePushManifest(manifestPath); + // A present-but-malformed manifest must fail, not silently skip the check. if (manifest === null) { - return []; + return [ + { + file: manifestPath, + rule: "pre-push-ci-parity", + message: + "Pre-push manifest is malformed — expected `{ ciWorkflow: string, requiredCommands: string[] }`.", + }, + ]; + } + + const workflowPath = resolveCiWorkflow(root, manifest.ciWorkflow); + + // An unresolvable workflow means the parity check never ran — fail closed. + if (workflowPath === null) { + return [ + { + file: manifestPath, + rule: "pre-push-ci-parity", + message: `CI workflow \`${manifest.ciWorkflow}\` not found from the app root upward — fix \`ciWorkflow\` in scripts/ci/pre-push.manifest.json.`, + }, + ]; } - const workflow = readFileSync(join(root, manifest.ciWorkflow), "utf8"); + const workflow = readFileSync(workflowPath, "utf8"); const violations: IViolation[] = []; for (const command of manifest.requiredCommands) { if (!workflow.includes(command)) { violations.push({ - file: join(root, manifest.ciWorkflow), + file: workflowPath, rule: "pre-push-ci-parity", message: `CI workflow is missing pre-push command \`${command}\` (see scripts/ci/pre-push.manifest.json).`, }); diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts index 0ac2b097..b80036ac 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -602,9 +602,11 @@ describe("checkNoDirectProcessEnv", () => { }); }); +const GUARD_TMP_PREFIX = "lint-meta-guard-"; + describe("lint-meta guardrails", () => { test("checkNoRawRoleLiterals flags raw role strings in src", () => { - const root = mkdtempSync(join(tmpdir(), "lint-meta-guard-")); + const root = mkdtempSync(join(tmpdir(), GUARD_TMP_PREFIX)); try { mkdirSync(join(root, "src", "api"), { recursive: true }); @@ -623,7 +625,7 @@ describe("lint-meta guardrails", () => { }); test("checkGeneratedArtifactContracts flags missing banner text", () => { - const root = mkdtempSync(join(tmpdir(), "lint-meta-guard-")); + const root = mkdtempSync(join(tmpdir(), GUARD_TMP_PREFIX)); try { const artifactDir = join(root, "..", "ui", "src", "lib", "acl"); @@ -643,7 +645,7 @@ describe("lint-meta guardrails", () => { }); test("checkPrePushParity flags CI workflow missing a manifest command", () => { - const root = mkdtempSync(join(tmpdir(), "lint-meta-guard-")); + const root = mkdtempSync(join(tmpdir(), GUARD_TMP_PREFIX)); try { mkdirSync(join(root, "scripts", "ci"), { recursive: true }); @@ -669,6 +671,82 @@ describe("lint-meta guardrails", () => { rmSync(root, { recursive: true, force: true }); } }); + + test("checkPrePushParity flags a malformed manifest instead of skipping", () => { + const root = mkdtempSync(join(tmpdir(), GUARD_TMP_PREFIX)); + + try { + mkdirSync(join(root, "scripts", "ci"), { recursive: true }); + writeFileSync( + join(root, "scripts", "ci", "pre-push.manifest.json"), + JSON.stringify({ stages: ["bun run check"] }) + ); + + const violations = checkPrePushParity(root); + + expect(violations.some((row) => row.message.includes("malformed"))).toBe( + true + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("checkPrePushParity flags an unresolvable ciWorkflow instead of skipping", () => { + const root = mkdtempSync(join(tmpdir(), GUARD_TMP_PREFIX)); + + try { + mkdirSync(join(root, "scripts", "ci"), { recursive: true }); + writeFileSync( + join(root, "scripts", "ci", "pre-push.manifest.json"), + JSON.stringify({ + ciWorkflow: ".github/workflows/does-not-exist-anywhere.yml", + requiredCommands: ["bun run check"], + }) + ); + + const violations = checkPrePushParity(root); + + expect( + violations.some((row) => + row.message.includes("not found from the app root upward") + ) + ).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("checkPrePushParity resolves the ciWorkflow at the monorepo root via walk-up", () => { + const root = mkdtempSync(join(tmpdir(), GUARD_TMP_PREFIX)); + + try { + const appRoot = join(root, "apps", "api"); + + mkdirSync(join(appRoot, "scripts", "ci"), { recursive: true }); + mkdirSync(join(root, ".github", "workflows"), { recursive: true }); + writeFileSync( + join(appRoot, "scripts", "ci", "pre-push.manifest.json"), + JSON.stringify({ + ciWorkflow: ".github/workflows/ci.yml", + requiredCommands: ["bun run check", "bun run missing-gate"], + }) + ); + writeFileSync( + join(root, ".github", "workflows", "ci.yml"), + "jobs:\n test:\n steps:\n - run: bun run check\n" + ); + + const violations = checkPrePushParity(appRoot); + + expect( + violations.some((row) => row.message.includes("bun run missing-gate")) + ).toBe(true); + expect(violations).toHaveLength(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); describe("RULES.md catalog", () => { diff --git a/apps/ui/scripts/ci/pre-push.manifest.json b/apps/ui/scripts/ci/pre-push.manifest.json index cf3f01ad..0687595c 100644 --- a/apps/ui/scripts/ci/pre-push.manifest.json +++ b/apps/ui/scripts/ci/pre-push.manifest.json @@ -1,5 +1,6 @@ { - "stages": [ + "ciWorkflow": ".github/workflows/apps-ui-validate.yml", + "requiredCommands": [ "bun run check", "bun run test:ci", "bun run build", diff --git a/apps/ui/scripts/lint-meta/cli.ts b/apps/ui/scripts/lint-meta/cli.ts index 3e1de43a..25640d6f 100644 --- a/apps/ui/scripts/lint-meta/cli.ts +++ b/apps/ui/scripts/lint-meta/cli.ts @@ -74,6 +74,7 @@ export { checkDependencyPairs } from "./rules/supply-chain/no-overlapping-libs"; export { checkPackageJson } from "./rules/supply-chain/package-json-exact-deps"; export { checkWorkflow } from "./rules/ci/github-actions-permissions"; export { checkWorkflowTimeouts } from "./rules/ci/github-actions-timeout-required"; +export { checkPrePushParity } from "./rules/ci/pre-push-ci-parity"; export { checkUiEnvCascadeDrift } from "./rules/env/env-cascade-drift"; export { checkNoDirectImportMetaEnv } from "./rules/env/no-direct-import-meta-env"; export { checkNoSilentErrorSwallow } from "./rules/queries/no-silent-error-swallow"; diff --git a/apps/ui/scripts/lint-meta/rules/ci/pre-push-ci-parity.ts b/apps/ui/scripts/lint-meta/rules/ci/pre-push-ci-parity.ts index 9f1e2888..6360404f 100644 --- a/apps/ui/scripts/lint-meta/rules/ci/pre-push-ci-parity.ts +++ b/apps/ui/scripts/lint-meta/rules/ci/pre-push-ci-parity.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import type { IMetaRule, IViolation } from "../../types"; @@ -33,27 +33,75 @@ function readPrePushManifest(manifestPath: string): { return { ciWorkflow, requiredCommands }; } +/* + * The CI workflow lives at the app root when this template is a standalone + * repo, but in a monorepo checkout it lives at the repository root. Walk up + * from the app root to the nearest directory containing the manifest's + * ciWorkflow path so the rule always compares against the workflow that + * actually runs for this code instead of silently no-oping. + */ +function resolveCiWorkflow(root: string, ciWorkflow: string): string | null { + let current = root; + + for (;;) { + const candidate = join(current, ciWorkflow); + + if (existsSync(candidate)) { + return candidate; + } + + const parent = dirname(current); + + if (parent === current) { + return null; + } + + current = parent; + } +} + export function checkPrePushParity(root: string): IViolation[] { const manifestPath = join(root, PRE_PUSH_MANIFEST); - const workflowPath = join(root, ".github", "workflows", "validate.yml"); - if (!existsSync(manifestPath) || !existsSync(workflowPath)) { + // No manifest means the consumer deliberately opted out of pre-push parity. + if (!existsSync(manifestPath)) { return []; } const manifest = readPrePushManifest(manifestPath); + // A present-but-malformed manifest must fail, not silently skip the check. if (manifest === null) { - return []; + return [ + { + file: manifestPath, + rule: "pre-push-ci-parity", + message: + "Pre-push manifest is malformed — expected `{ ciWorkflow: string, requiredCommands: string[] }`." + } + ]; + } + + const workflowPath = resolveCiWorkflow(root, manifest.ciWorkflow); + + // An unresolvable workflow means the parity check never ran — fail closed. + if (workflowPath === null) { + return [ + { + file: manifestPath, + rule: "pre-push-ci-parity", + message: `CI workflow \`${manifest.ciWorkflow}\` not found from the app root upward — fix \`ciWorkflow\` in scripts/ci/pre-push.manifest.json.` + } + ]; } - const workflow = readFileSync(join(root, manifest.ciWorkflow), "utf8"); + const workflow = readFileSync(workflowPath, "utf8"); const violations: IViolation[] = []; for (const command of manifest.requiredCommands) { if (!workflow.includes(command)) { violations.push({ - file: join(root, manifest.ciWorkflow), + file: workflowPath, rule: "pre-push-ci-parity", message: `CI workflow is missing pre-push command \`${command}\` (see scripts/ci/pre-push.manifest.json).` }); diff --git a/apps/ui/tests/lint-meta/lint-meta.test.ts b/apps/ui/tests/lint-meta/lint-meta.test.ts index 1ab66062..ea4e6b2e 100644 --- a/apps/ui/tests/lint-meta/lint-meta.test.ts +++ b/apps/ui/tests/lint-meta/lint-meta.test.ts @@ -19,6 +19,7 @@ import { checkNoRawRoleLiterals, checkNoSilentErrorSwallow, checkPackageJson, + checkPrePushParity, checkScriptRawFetch, checkTestFilesHaveSource, checkUiEnvCascadeDrift, @@ -539,6 +540,82 @@ describe("checkTestFilesHaveSource", () => { }); }); +describe("checkPrePushParity", () => { + test("flags a malformed manifest instead of silently skipping", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-prepush-")); + + try { + mkdirSync(join(root, "scripts", "ci"), { recursive: true }); + writeFileSync( + join(root, "scripts", "ci", "pre-push.manifest.json"), + JSON.stringify({ stages: ["bun run check"] }) + ); + + const violations = checkPrePushParity(root); + + expect(violations.map((row) => row.message)).toContainEqual( + expect.stringContaining("malformed") + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("flags an unresolvable ciWorkflow instead of silently skipping", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-prepush-")); + + try { + mkdirSync(join(root, "scripts", "ci"), { recursive: true }); + writeFileSync( + join(root, "scripts", "ci", "pre-push.manifest.json"), + JSON.stringify({ + ciWorkflow: ".github/workflows/does-not-exist-anywhere.yml", + requiredCommands: ["bun run check"] + }) + ); + + const violations = checkPrePushParity(root); + + expect(violations.map((row) => row.message)).toContainEqual( + expect.stringContaining("not found from the app root upward") + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("resolves the ciWorkflow at the monorepo root via walk-up", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-prepush-")); + + try { + const appRoot = join(root, "apps", "ui"); + + mkdirSync(join(appRoot, "scripts", "ci"), { recursive: true }); + mkdirSync(join(root, ".github", "workflows"), { recursive: true }); + writeFileSync( + join(appRoot, "scripts", "ci", "pre-push.manifest.json"), + JSON.stringify({ + ciWorkflow: ".github/workflows/validate.yml", + requiredCommands: ["bun run check", "bun run missing-gate"] + }) + ); + writeFileSync( + join(root, ".github", "workflows", "validate.yml"), + "jobs:\n validate:\n steps:\n - run: bun run check\n" + ); + + const violations = checkPrePushParity(appRoot); + + expect(violations.map((row) => row.message)).toContainEqual( + expect.stringContaining("bun run missing-gate") + ); + expect(violations).toHaveLength(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("RULES.md catalog", () => { test("matches generate-rules-md output", () => { const rulesPath = join( From bf0f7ea27572527a226bc5dece605ce2223dbe93 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 19:44:05 +0200 Subject: [PATCH 02/17] fix(lint-meta): engine-pin-parity scans monorepo workflows and guards bun-version pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow pin check only looked at the app-local .github/workflows, which is absent in a monorepo checkout, so it never scanned the workflows that actually run. It now resolves the nearest .github/workflows via resolveWorkflowsDir and additionally verifies every workflow bun-version pin against package.json packageManager — previously 10 unguarded pins. Note: the audit's claimed node-pin drift was refuted (no setup-node usage in any workflow); the bun-pin gap was the real exposure. Audit: F014 --- apps/ui/scripts/lint-meta/cli.ts | 1 + .../lint-meta/rules/ci/engine-pin-parity.ts | 58 ++++++++++++++++--- apps/ui/tests/lint-meta/lint-meta.test.ts | 54 +++++++++++++++++ 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/apps/ui/scripts/lint-meta/cli.ts b/apps/ui/scripts/lint-meta/cli.ts index 25640d6f..2e04afcd 100644 --- a/apps/ui/scripts/lint-meta/cli.ts +++ b/apps/ui/scripts/lint-meta/cli.ts @@ -72,6 +72,7 @@ export { collectSourceFiles, findWorkflows } from "./context"; export { parseDotenvKeys } from "./parsers/dotenv"; export { checkDependencyPairs } from "./rules/supply-chain/no-overlapping-libs"; export { checkPackageJson } from "./rules/supply-chain/package-json-exact-deps"; +export { checkEnginePinParity } from "./rules/ci/engine-pin-parity"; export { checkWorkflow } from "./rules/ci/github-actions-permissions"; export { checkWorkflowTimeouts } from "./rules/ci/github-actions-timeout-required"; export { checkPrePushParity } from "./rules/ci/pre-push-ci-parity"; diff --git a/apps/ui/scripts/lint-meta/rules/ci/engine-pin-parity.ts b/apps/ui/scripts/lint-meta/rules/ci/engine-pin-parity.ts index b6af9b72..fc42052b 100644 --- a/apps/ui/scripts/lint-meta/rules/ci/engine-pin-parity.ts +++ b/apps/ui/scripts/lint-meta/rules/ci/engine-pin-parity.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; +import { resolveWorkflowsDir } from "../../context"; import { readUiPackageJson } from "../../parsers/package-json"; import type { IMetaRule, IViolation } from "../../types"; @@ -66,21 +67,28 @@ function checkDockerNodePins(root: string, nodeMajor: string): IViolation[] { return violations; } -function checkWorkflowNodePins(root: string, nodeMajor: string): IViolation[] { - const workflowDir = join(root, ".github", "workflows"); +/* + * Workflows live at the app root in a standalone checkout but at the + * repository root in a monorepo (see resolveWorkflowsDir). Scanning the + * resolved directory keeps the pin checks honest in both layouts instead of + * silently no-oping when the app-local .github/workflows is absent. + */ +function listWorkflowFiles(root: string): string[] { + const workflowDir = resolveWorkflowsDir(root); if (!existsSync(workflowDir)) { return []; } - const violations: IViolation[] = []; + return readdirSync(workflowDir) + .filter((file) => file.endsWith(".yml") || file.endsWith(".yaml")) + .map((file) => join(workflowDir, file)); +} - for (const file of readdirSync(workflowDir)) { - if (!file.endsWith(".yml") && !file.endsWith(".yaml")) { - continue; - } +function checkWorkflowNodePins(root: string, nodeMajor: string): IViolation[] { + const violations: IViolation[] = []; - const workflowPath = join(workflowDir, file); + for (const workflowPath of listWorkflowFiles(root)) { const content = readFileSync(workflowPath, "utf8"); if ( @@ -100,6 +108,39 @@ function checkWorkflowNodePins(root: string, nodeMajor: string): IViolation[] { return violations; } +function checkWorkflowBunPins( + root: string, + pkg: ReturnType +): IViolation[] { + const packageManager = pkg?.packageManager ?? ""; + const bunMatch = /^bun@([^+]+)/u.exec(packageManager); + const bunVersion = bunMatch?.[1]; + + if (bunVersion === undefined) { + return []; + } + + const violations: IViolation[] = []; + + for (const workflowPath of listWorkflowFiles(root)) { + const content = readFileSync(workflowPath, "utf8"); + + for (const match of content.matchAll(/bun-version:\s*(\S+)/gu)) { + const pinned = match[1]; + + if (pinned !== undefined && pinned !== bunVersion) { + violations.push({ + file: workflowPath, + rule: "engine-pin-parity", + message: `Workflow pins bun-version: ${pinned} but package.json packageManager declares bun@${bunVersion}.` + }); + } + } + } + + return violations; +} + function checkDockerBunPin( root: string, pkg: ReturnType @@ -160,6 +201,7 @@ export function checkEnginePinParity(root: string): IViolation[] { ...checkPackageJsonNodeEngine(root, nodeMajor, pkg), ...checkDockerNodePins(root, nodeMajor), ...checkWorkflowNodePins(root, nodeMajor), + ...checkWorkflowBunPins(root, pkg), ...checkDockerBunPin(root, pkg) ]; } diff --git a/apps/ui/tests/lint-meta/lint-meta.test.ts b/apps/ui/tests/lint-meta/lint-meta.test.ts index ea4e6b2e..031e3923 100644 --- a/apps/ui/tests/lint-meta/lint-meta.test.ts +++ b/apps/ui/tests/lint-meta/lint-meta.test.ts @@ -13,6 +13,7 @@ import { describe, expect, test } from "vitest"; import { checkCanonicalHelpersSingleHome, checkDependencyPairs, + checkEnginePinParity, checkForbiddenText, checkNoCrossRepoImports, checkNoDirectImportMetaEnv, @@ -540,6 +541,59 @@ describe("checkTestFilesHaveSource", () => { }); }); +describe("checkEnginePinParity", () => { + function writeEnginePinFixture(root: string, bunWorkflowPin: string): string { + const appRoot = join(root, "apps", "ui"); + + mkdirSync(appRoot, { recursive: true }); + mkdirSync(join(root, ".github", "workflows"), { recursive: true }); + writeFileSync(join(appRoot, ".nvmrc"), "24\n"); + writeFileSync( + join(appRoot, "package.json"), + JSON.stringify({ + engines: { node: ">=24.0.0" }, + packageManager: "bun@1.3.14" + }) + ); + writeFileSync( + join(root, ".github", "workflows", "validate.yml"), + `jobs:\n validate:\n steps:\n - uses: oven-sh/setup-bun@abc\n with:\n bun-version: ${bunWorkflowPin}\n` + ); + + return appRoot; + } + + test("flags a workflow bun-version pin that drifts from packageManager", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-engine-")); + + try { + const appRoot = writeEnginePinFixture(root, "1.2.0"); + + const violations = checkEnginePinParity(appRoot); + + expect(violations.map((row) => row.message)).toContainEqual( + expect.stringContaining("bun-version: 1.2.0") + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes when the workflow bun-version matches packageManager", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-engine-")); + + try { + const appRoot = writeEnginePinFixture(root, "1.3.14"); + + const violations = checkEnginePinParity(appRoot); + + expect(violations).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkPrePushParity", () => { test("flags a malformed manifest instead of silently skipping", () => { const root = mkdtempSync(join(tmpdir(), "lint-meta-prepush-")); From 752ba14241366f014e8bb62bc393f8ed9e43f86f Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 19:48:01 +0200 Subject: [PATCH 03/17] fix(api): drop stale eslint override path, add eslint-override-paths-exist rule tests/auth/role-schema-parity.test.ts was referenced by the test-file-mirrors-source override but does not exist. New lint-meta config rule fails when any literal test path quoted in eslint.config.* is missing on disk, so dead overrides can no longer accumulate. Audit: F022 --- apps/api/eslint.config.js | 1 - apps/api/scripts/lint-meta/RULES.md | 1 + apps/api/scripts/lint-meta/cli.ts | 2 + apps/api/scripts/lint-meta/registry.ts | 2 + .../config/eslint-override-paths-exist.ts | 68 +++++++++++++++++++ apps/api/tests/lint-meta/lint-meta.test.ts | 52 +++++++++++++- 6 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 apps/api/scripts/lint-meta/rules/config/eslint-override-paths-exist.ts diff --git a/apps/api/eslint.config.js b/apps/api/eslint.config.js index da290028..3618b053 100644 --- a/apps/api/eslint.config.js +++ b/apps/api/eslint.config.js @@ -1054,7 +1054,6 @@ export default tseslint.config( // single source file by design — they verify invariants that span // multiple modules. files: [ - "tests/auth/role-schema-parity.test.ts", "tests/health.test.ts", // The next three test specific concerns inside a multi-function // utils file (retry / validation in email.utils.ts; the diff --git a/apps/api/scripts/lint-meta/RULES.md b/apps/api/scripts/lint-meta/RULES.md index a48db2fc..18d7d0c6 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -36,3 +36,4 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `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. | | `touch-tests-too` | testing | no | Modified logic/route files must include a matching test change (opt-in via LINT_META_TOUCHED_BASE). | | `eslint-config-no-warn` | config | no | ESLint severities must be "error" or "off", not "warn". | +| `eslint-override-paths-exist` | config | no | Literal test-file paths in eslint.config.* overrides must exist on disk. | diff --git a/apps/api/scripts/lint-meta/cli.ts b/apps/api/scripts/lint-meta/cli.ts index 4de907f8..698ab05c 100644 --- a/apps/api/scripts/lint-meta/cli.ts +++ b/apps/api/scripts/lint-meta/cli.ts @@ -23,6 +23,7 @@ import { checkExactDependencyVersions } from "./rules/supply-chain/package-json- import { checkPackageOverrideParity } from "./rules/supply-chain/package-override-parity"; import { checkSharedToolVersionParity } from "./rules/supply-chain/shared-tool-version-parity"; import { checkEslintConfigNoWarn } from "./rules/config/eslint-config-no-warn"; +import { checkEslintOverridePathsExist } from "./rules/config/eslint-override-paths-exist"; import { checkEnvSchemaDrift } from "./rules/env/env-cascade-drift"; import { checkNoDirectProcessEnv } from "./rules/env/no-direct-process-env"; import { checkGeneratedArtifactContracts } from "./rules/artifacts/generated-artifact-contract"; @@ -94,6 +95,7 @@ export { checkDependencyPairs, checkExactDependencyVersions, checkEslintConfigNoWarn, + checkEslintOverridePathsExist, checkEnvSchemaDrift, checkForbiddenText, checkGeneratedArtifactContracts, diff --git a/apps/api/scripts/lint-meta/registry.ts b/apps/api/scripts/lint-meta/registry.ts index 1102f8a7..044e19c0 100644 --- a/apps/api/scripts/lint-meta/registry.ts +++ b/apps/api/scripts/lint-meta/registry.ts @@ -4,6 +4,7 @@ import { githubActionsPermissionsRule } from "./rules/ci/github-actions-permissi import { githubActionsTimeoutRequiredRule } from "./rules/ci/github-actions-timeout-required"; import { prePushCiParityRule } from "./rules/ci/pre-push-ci-parity"; import { eslintConfigNoWarnRule } from "./rules/config/eslint-config-no-warn"; +import { eslintOverridePathsExistRule } from "./rules/config/eslint-override-paths-exist"; import { envCascadeDriftRule } from "./rules/env/env-cascade-drift"; import { noDirectProcessEnvRule } from "./rules/env/no-direct-process-env"; import { canonicalHelpersSingleHomeRule } from "./rules/source-text/canonical-helpers-single-home"; @@ -39,4 +40,5 @@ export const META_RULES: readonly IMetaRule[] = [ skippedTestsNeedTrackingRule, touchTestsTooRule, eslintConfigNoWarnRule, + eslintOverridePathsExistRule, ]; diff --git a/apps/api/scripts/lint-meta/rules/config/eslint-override-paths-exist.ts b/apps/api/scripts/lint-meta/rules/config/eslint-override-paths-exist.ts new file mode 100644 index 00000000..0d1a6ab0 --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/config/eslint-override-paths-exist.ts @@ -0,0 +1,68 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { IMetaRule, IViolation } from "../../types"; + +const ESLINT_CONFIG_NAMES = [ + "eslint.config.mjs", + "eslint.config.js", + "eslint.config.mts", + "eslint.config.cjs", +]; + +/* + * Literal (non-glob) test-file paths quoted in eslint.config.* — the shape + * used by per-file rule overrides. Glob patterns are skipped; they match + * zero-or-more files by design. + */ +const TEST_PATH_LITERAL = + /["']((?:tests|src|scripts|e2e)\/[^"'*?{}]+\.test\.tsx?)["']/gu; + +export function checkEslintOverridePathsExist(root: string): IViolation[] { + const violations: IViolation[] = []; + + for (const name of ESLINT_CONFIG_NAMES) { + const full = join(root, name); + + if (!existsSync(full)) { + continue; + } + + const lines = readFileSync(full, "utf8").split("\n"); + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + + if (raw === undefined) { + continue; + } + + const noLineComment = raw.replace(/\/\/.*$/u, ""); + + for (const match of noLineComment.matchAll(TEST_PATH_LITERAL)) { + const relPath = match[1]; + + if (relPath !== undefined && !existsSync(join(root, relPath))) { + violations.push({ + file: full, + rule: "eslint-override-paths-exist", + message: `Line ${String(i + 1)}: override references \`${relPath}\`, which does not exist — remove the stale entry or restore the file.`, + }); + } + } + } + } + + return violations; +} + +/** Literal test paths in eslint.config.* overrides must resolve to real files. */ +export const eslintOverridePathsExistRule: IMetaRule = { + id: "eslint-override-paths-exist", + category: "config", + description: + "Literal test-file paths in eslint.config.* overrides must exist on disk.", + run({ root }) { + return checkEslintOverridePathsExist(root); + }, +}; diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts index b80036ac..7a0c3966 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -18,6 +18,7 @@ import { checkDependencyPairs, checkEnvSchemaDrift, checkEslintConfigNoWarn, + checkEslintOverridePathsExist, checkExactDependencyVersions, checkForbiddenText, checkLogicFilesHaveTests, @@ -36,6 +37,7 @@ import { } from "../../scripts/lint-meta/cli"; const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "fixtures"); +const GUARD_TMP_PREFIX = "lint-meta-guard-"; describe("checkSharedToolVersionParity", () => { test("flags a shared tool pinned to different versions across apps", () => { @@ -170,6 +172,54 @@ describe("checkEslintConfigNoWarn", () => { }); }); +describe("checkEslintOverridePathsExist", () => { + test("flags a literal override path that does not exist, ignores globs", () => { + const root = mkdtempSync(join(tmpdir(), GUARD_TMP_PREFIX)); + + try { + mkdirSync(join(root, "tests"), { recursive: true }); + writeFileSync(join(root, "tests", "real.test.ts"), "// real\n"); + writeFileSync( + join(root, "eslint.config.js"), + [ + "export default [", + " {", + ' files: ["tests/real.test.ts", "tests/missing.test.ts", "tests/**/*.test.ts"],', + " },", + "];", + "", + ].join("\n") + ); + + const violations = checkEslintOverridePathsExist(root); + + expect(violations).toHaveLength(1); + expect(violations[0]?.message).toContain("tests/missing.test.ts"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes when every literal override path exists", () => { + const root = mkdtempSync(join(tmpdir(), GUARD_TMP_PREFIX)); + + try { + mkdirSync(join(root, "tests"), { recursive: true }); + writeFileSync(join(root, "tests", "real.test.ts"), "// real\n"); + writeFileSync( + join(root, "eslint.config.js"), + 'export default [{ files: ["tests/real.test.ts"] }];\n' + ); + + const violations = checkEslintOverridePathsExist(root); + + expect(violations).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkDependencyPairs", () => { test("flags forbidden overlapping libs (react-hot-toast + sonner)", () => { const violations = checkDependencyPairs( @@ -602,8 +652,6 @@ describe("checkNoDirectProcessEnv", () => { }); }); -const GUARD_TMP_PREFIX = "lint-meta-guard-"; - describe("lint-meta guardrails", () => { test("checkNoRawRoleLiterals flags raw role strings in src", () => { const root = mkdtempSync(join(tmpdir(), GUARD_TMP_PREFIX)); From bf831f83cd364f43167df8e60a9dabe67677ecde Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 19:52:18 +0200 Subject: [PATCH 04/17] fix(api): scope join-request approve UPDATE by accountId approve()'s UPDATE filtered only id+pending while deny() also filtered accountId. The transaction's SELECT is accountId-scoped so this was not exploitable, but the UPDATE now matches deny() for defense-in-depth. Adds a cross-account approve test asserting notFound and that the row stays pending. Audit: F003 --- .../src/api/accounts/join-requests.service.ts | 1 + .../accounts/join-requests.service.test.ts | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/apps/api/src/api/accounts/join-requests.service.ts b/apps/api/src/api/accounts/join-requests.service.ts index a1cbf697..873587d7 100644 --- a/apps/api/src/api/accounts/join-requests.service.ts +++ b/apps/api/src/api/accounts/join-requests.service.ts @@ -159,6 +159,7 @@ export class JoinRequestsService { .where( and( eq(accountJoinRequests.id, requestId), + eq(accountJoinRequests.accountId, accountId), eq(accountJoinRequests.status, JOIN_REQUEST_STATUS.pending) ) ) diff --git a/apps/api/tests/api/accounts/join-requests.service.test.ts b/apps/api/tests/api/accounts/join-requests.service.test.ts index 2b195d0b..e68d6345 100644 --- a/apps/api/tests/api/accounts/join-requests.service.test.ts +++ b/apps/api/tests/api/accounts/join-requests.service.test.ts @@ -222,6 +222,38 @@ describe("JoinRequestsService", () => { ); }); + test("404s when approving another account's pending request", async () => { + if (!(await requireDb())) { + return; + } + + const owner = await seedUserAndAccount(OWNER_EMAIL); + const otherOwner = await seedUserAndAccount(OTHER_OWNER_EMAIL); + const requester = await seedRequesterUser(REQUESTER_EMAIL); + + const created = await createPendingFor( + owner.accountId, + requester, + REQUESTER_EMAIL + ); + + await expectRejects( + joinRequestsService.approve( + otherOwner.accountId, + created.id, + otherOwner.userId + ) + ); + + const [row] = await db + .select() + .from(accountJoinRequests) + .where(eq(accountJoinRequests.id, created.id)) + .limit(1); + + expect(row?.status).toBe("pending"); + }); + test("a second approve of the same request fails", async () => { if (!(await requireDb())) { return; From 49d42a71f62521eedfd2a23bc09f0c6d39525287 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 19:56:38 +0200 Subject: [PATCH 05/17] fix(api): require CACHE_PROVIDER=valkey in production when cache is enabled CACHE_PROVIDER silently defaulted to the per-process memory provider, splitting JWT revocation state (logout, password-reset session kill, per-jti blocklist) across replicas and losing it on restart. New env invariant fails boot loud; prod compose api service now pins CACHE_PROVIDER=valkey; SECURITY.md documents the requirement. Explicit accept/reject tests added; CACHE_ENABLED=false remains a valid single-replica opt-out. Audit: F002 --- apps/api/SECURITY.md | 5 +++++ apps/api/src/config/env/validate.ts | 20 +++++++++++++++++++ apps/api/tests/config/env/validate.test.ts | 23 ++++++++++++++++++++++ infra/compose/compose/docker-compose.yml | 4 ++++ 4 files changed, 52 insertions(+) diff --git a/apps/api/SECURITY.md b/apps/api/SECURITY.md index e48def33..391775da 100644 --- a/apps/api/SECURITY.md +++ b/apps/api/SECURITY.md @@ -16,6 +16,11 @@ and the production checklist. envelope, so a provider blip silently drops the message. - Production Valkey-backed features (queues, Valkey cache, SSE, OAuth state) require `VALKEY_PASSWORD`. +- Production with `CACHE_ENABLED=true` (the default) requires + `CACHE_PROVIDER=valkey`. JWT revocation (logout, password-reset + session kill, per-jti blocklist) stores its state in the cache; the + in-memory provider is per-process, so revocations would vanish on + restart and never propagate across replicas. - `ALLOWED_ORIGINS` is **optional**. Empty = same-origin deployment (BoringStack's default) and CORS is not mounted. When set in production, every entry must be HTTPS with no wildcards. diff --git a/apps/api/src/config/env/validate.ts b/apps/api/src/config/env/validate.ts index 6785c9ce..f5d8d693 100644 --- a/apps/api/src/config/env/validate.ts +++ b/apps/api/src/config/env/validate.ts @@ -685,6 +685,25 @@ const checkQueuesEnabledInProd = (env: Env): string[] => { ]; }; +/** + * Production must back the cache with Valkey when caching is enabled. + * JWT revocation (logout, password-reset session kill, per-jti blocklist) + * keeps its state in cacheService; the in-memory provider is per-process, + * so revocations vanish on restart and never propagate across replicas — + * a logout on one instance would leave the token valid on every other. + */ +const checkCacheProviderInProd = (env: Env): string[] => { + if (env.NODE_ENV !== "production" || !env.CACHE_ENABLED) { + return []; + } + + return env.CACHE_PROVIDER === "valkey" + ? [] + : [ + "CACHE_PROVIDER must be valkey in production when CACHE_ENABLED=true so JWT revocation state survives restarts and is shared across replicas", + ]; +}; + const checkValkeyPassword = (env: Env): string[] => { if (env.NODE_ENV !== "production" || env.VALKEY_PASSWORD !== "") { return []; @@ -745,6 +764,7 @@ const checkInvariants = (env: Env): string[] => [ ...checkBilling(env), ...checkOAuth(env), ...checkQueuesEnabledInProd(env), + ...checkCacheProviderInProd(env), ...checkValkeyPassword(env), ...checkWebPushVapid(env), ...checkPlaceholderSecrets(env), diff --git a/apps/api/tests/config/env/validate.test.ts b/apps/api/tests/config/env/validate.test.ts index df385dd4..1cbab3c5 100644 --- a/apps/api/tests/config/env/validate.test.ts +++ b/apps/api/tests/config/env/validate.test.ts @@ -176,6 +176,7 @@ const applyProdDefaults = (env: TestEnv): void => { env.FRONTEND_URL = "https://app.example.test"; env.PUBLIC_API_URL = "https://api.example.test"; env.MFA_ENCRYPTION_KEY = REAL_MFA_KEY; + env.CACHE_PROVIDER = "valkey"; }; /* @@ -195,6 +196,7 @@ const seedProd = (): TestEnv => ({ EMAIL_FROM: "noreply@app.example.test", RESEND_API_KEY: "rk_test", VALKEY_PASSWORD: "secret", + CACHE_PROVIDER: "valkey", }); beforeEach(() => { @@ -240,6 +242,27 @@ describe("validateEnv", () => { expect(() => validateEnv(testEnv)).not.toThrow(); }); + it("rejects production CACHE_ENABLED with the in-memory cache provider", () => { + testEnv.NODE_ENV = "production"; + testEnv.EMAIL_PROVIDER = "resend"; + testEnv.RESEND_API_KEY = "rk_test"; + testEnv.VALKEY_PASSWORD = "secret"; + applyProdDefaults(testEnv); + testEnv.CACHE_PROVIDER = "memory"; + expect(() => validateEnv(testEnv)).toThrow(/CACHE_PROVIDER must be valkey/); + }); + + it("accepts production with CACHE_ENABLED=false and the memory provider", () => { + testEnv.NODE_ENV = "production"; + testEnv.EMAIL_PROVIDER = "resend"; + testEnv.RESEND_API_KEY = "rk_test"; + testEnv.VALKEY_PASSWORD = "secret"; + applyProdDefaults(testEnv); + testEnv.CACHE_PROVIDER = "memory"; + testEnv.CACHE_ENABLED = "false"; + expect(() => validateEnv(testEnv)).not.toThrow(); + }); + it("rejects production ALLOWED_ORIGINS that aren't HTTPS", () => { testEnv.NODE_ENV = "production"; testEnv.ALLOWED_ORIGINS = "http://example.com"; diff --git a/infra/compose/compose/docker-compose.yml b/infra/compose/compose/docker-compose.yml index 438f7a87..799846e7 100644 --- a/infra/compose/compose/docker-compose.yml +++ b/infra/compose/compose/docker-compose.yml @@ -437,6 +437,10 @@ services: VALKEY_HOST: valkey VALKEY_PORT: "6379" VALKEY_PASSWORD: ${VALKEY_PASSWORD:-} + # JWT revocation state (logout, per-jti blocklist) lives in the + # cache; the env validator rejects production CACHE_ENABLED with + # the per-process memory provider, so pin the shared Valkey here. + CACHE_PROVIDER: ${CACHE_PROVIDER:-valkey} NODE_ENV: production BUN_ENV: production PORT: "7330" From b8c2ca61801859cb23c8905e3329039058c450ac Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 19:57:23 +0200 Subject: [PATCH 06/17] fix(docs): align .nvmrc with engines.node >=24 apps/docs/.nvmrc pinned Node 22 while package.json engines requires >=24 (matching api/ui). Cloudflare Pages reads .nvmrc, so production builds ran on a Node major the repo does not support. DEPLOY.md Node row updated. Audit: F019 --- apps/docs/.nvmrc | 2 +- apps/docs/DEPLOY.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/.nvmrc b/apps/docs/.nvmrc index 2bd5a0a9..a45fd52c 100644 --- a/apps/docs/.nvmrc +++ b/apps/docs/.nvmrc @@ -1 +1 @@ -22 +24 diff --git a/apps/docs/DEPLOY.md b/apps/docs/DEPLOY.md index 6550da5c..6670e73f 100644 --- a/apps/docs/DEPLOY.md +++ b/apps/docs/DEPLOY.md @@ -23,7 +23,7 @@ This file documents the wire-up so future-you (or a teammate) can rebuild it fro | Build command | `bun run build` | | Build output directory | `dist` | | Root directory | `apps/docs` | - | Node version | `22` (via `.nvmrc`) | + | Node version | `24` (via `.nvmrc`) | | Environment variables | _(none)_ | 4. **Custom domain.** From fc8657cf2331f433e82f9cebe310a88cf278ac99 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 19:59:32 +0200 Subject: [PATCH 07/17] fix(deps): align eslint-plugin-structured-logging at 0.1.2, guard @boring-stack-pkg parity ui pinned 0.1.1 while api pinned 0.1.2, so the two apps enforced structured logging from different plugin releases. shared-tool-version-parity now also matches the @boring-stack-pkg/ scope by prefix, so any first-party plugin declared in two apps must stay in lockstep. Rule surfaced the drift (RED) before the bump (GREEN); ui bun.lock refreshed. Audit: F020 --- .../shared-tool-version-parity.ts | 22 ++++++++++++++++++- .../shared-tools-drift/app-a/package.json | 3 ++- .../shared-tools-drift/app-b/package.json | 3 ++- apps/api/tests/lint-meta/lint-meta.test.ts | 12 ++++++++++ apps/ui/bun.lock | 18 ++------------- apps/ui/package.json | 2 +- 6 files changed, 40 insertions(+), 20 deletions(-) diff --git a/apps/api/scripts/lint-meta/rules/supply-chain/shared-tool-version-parity.ts b/apps/api/scripts/lint-meta/rules/supply-chain/shared-tool-version-parity.ts index 3fc2f2cd..1356344d 100644 --- a/apps/api/scripts/lint-meta/rules/supply-chain/shared-tool-version-parity.ts +++ b/apps/api/scripts/lint-meta/rules/supply-chain/shared-tool-version-parity.ts @@ -26,6 +26,14 @@ const SHARED_TOOLS = [ "husky", ] as const; +/* + * First-party plugin scopes are shared tooling by definition: every app + * that declares one must lint with the same release. Matched by prefix so + * new plugins are covered the moment a second app adopts them, without + * editing this list. + */ +const SHARED_TOOL_PREFIXES = ["@boring-stack-pkg/"] as const; + interface IAppDeps { readonly app: string; readonly file: string; @@ -92,7 +100,19 @@ export function checkSharedToolVersionParity(appsDir: string): IViolation[] { const violations: IViolation[] = []; const apps = readApps(appsDir); - for (const tool of SHARED_TOOLS) { + const prefixTools = new Set(); + + for (const app of apps) { + for (const dep of Object.keys(app.deps)) { + if (SHARED_TOOL_PREFIXES.some((prefix) => dep.startsWith(prefix))) { + prefixTools.add(dep); + } + } + } + + const tools = [...SHARED_TOOLS, ...[...prefixTools].sort()]; + + for (const tool of tools) { const declarers: IDeclarer[] = apps .map((app) => ({ app: app.app, file: app.file, version: app.deps[tool] })) .filter((entry): entry is IDeclarer => typeof entry.version === "string"); diff --git a/apps/api/tests/lint-meta/fixtures/shared-tools-drift/app-a/package.json b/apps/api/tests/lint-meta/fixtures/shared-tools-drift/app-a/package.json index 5848989b..8884e061 100644 --- a/apps/api/tests/lint-meta/fixtures/shared-tools-drift/app-a/package.json +++ b/apps/api/tests/lint-meta/fixtures/shared-tools-drift/app-a/package.json @@ -2,6 +2,7 @@ "name": "fixture-app-a", "devDependencies": { "eslint": "10.4.0", - "typescript": "6.0.3" + "typescript": "6.0.3", + "@boring-stack-pkg/eslint-plugin-demo": "0.2.0" } } diff --git a/apps/api/tests/lint-meta/fixtures/shared-tools-drift/app-b/package.json b/apps/api/tests/lint-meta/fixtures/shared-tools-drift/app-b/package.json index a01591db..c5151a95 100644 --- a/apps/api/tests/lint-meta/fixtures/shared-tools-drift/app-b/package.json +++ b/apps/api/tests/lint-meta/fixtures/shared-tools-drift/app-b/package.json @@ -2,6 +2,7 @@ "name": "fixture-app-b", "devDependencies": { "eslint": "10.3.0", - "typescript": "6.0.3" + "typescript": "6.0.3", + "@boring-stack-pkg/eslint-plugin-demo": "0.1.0" } } diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts index 7a0c3966..36be7565 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -51,6 +51,18 @@ describe("checkSharedToolVersionParity", () => { expect(violations.some((row) => row.message.includes("eslint"))).toBe(true); }); + test("flags drift in prefix-matched @boring-stack-pkg plugins", () => { + const violations = checkSharedToolVersionParity( + join(FIXTURES, "shared-tools-drift") + ); + + expect( + violations.some((row) => + row.message.includes("@boring-stack-pkg/eslint-plugin-demo") + ) + ).toBe(true); + }); + test("passes when every app pins shared tools to the same version", () => { const violations = checkSharedToolVersionParity( join(FIXTURES, "shared-tools-clean") diff --git a/apps/ui/bun.lock b/apps/ui/bun.lock index 5570ac5f..9e9f6519 100644 --- a/apps/ui/bun.lock +++ b/apps/ui/bun.lock @@ -38,7 +38,7 @@ "@boring-stack-pkg/eslint-plugin-i18n-keys": "0.1.2", "@boring-stack-pkg/eslint-plugin-module-boundaries": "0.1.1", "@boring-stack-pkg/eslint-plugin-react-component-architecture": "0.3.0", - "@boring-stack-pkg/eslint-plugin-structured-logging": "0.1.1", + "@boring-stack-pkg/eslint-plugin-structured-logging": "0.1.2", "@boring-stack-pkg/eslint-plugin-tanstack-query-cache": "0.2.0", "@boring-stack-pkg/eslint-plugin-test-conventions": "0.1.2", "@changesets/cli": "2.31.0", @@ -162,7 +162,7 @@ "@boring-stack-pkg/eslint-plugin-react-component-architecture": ["@boring-stack-pkg/eslint-plugin-react-component-architecture@0.3.0", "", { "dependencies": { "@typescript-eslint/utils": "8.0.0", "yaml": "2.4.1" }, "peerDependencies": { "@typescript-eslint/parser": ">=8.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=5.0.0" } }, "sha512-DZJnqa97Fyzge7uhMDwpXaz8YWvzobhcR8d3VcaU4VBcUaprJBlW3TkSDP5gWhfVdPrrLTpOzdkdTYaz8Ks/Yw=="], - "@boring-stack-pkg/eslint-plugin-structured-logging": ["@boring-stack-pkg/eslint-plugin-structured-logging@0.1.1", "", { "dependencies": { "@typescript-eslint/utils": "8.0.0" }, "peerDependencies": { "@typescript-eslint/parser": "8.0.0", "eslint": "9.39.4", "typescript": "6.0.3" } }, "sha512-9aZ3GEqAozaLDRwJDtvH3fFdml4xg/oH0xKz0d+y5bI208TDuXlelL3aMMpr2Sc23oiRZK/8jgKJ5QgOBcmnqA=="], + "@boring-stack-pkg/eslint-plugin-structured-logging": ["@boring-stack-pkg/eslint-plugin-structured-logging@0.1.2", "", { "dependencies": { "@typescript-eslint/utils": "8.0.0" }, "peerDependencies": { "@typescript-eslint/parser": ">=8.0.0", "eslint": "8.57.0 || ^9.0.0", "typescript": ">=5.0.0" } }, "sha512-P9MuL88AUsHzqLWJ1bmiyJojv8kejXn8GlkDE79cwY8nN/fODU6/ZwrIWsH4kRw2LH2E+4p1NBhat/OJO8mkzw=="], "@boring-stack-pkg/eslint-plugin-tanstack-query-cache": ["@boring-stack-pkg/eslint-plugin-tanstack-query-cache@0.2.0", "", { "dependencies": { "@typescript-eslint/utils": "8.0.0" }, "peerDependencies": { "@typescript-eslint/parser": ">=8.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=5.0.0" } }, "sha512-xBlnJEr5YbV+2LKQaicDJ8WPzwWQL1JZjujCTThdTrvO4IHXLIFetUukjkOGbG9n+6BTmpaq6EmjMmXxrq2rJQ=="], @@ -2264,8 +2264,6 @@ "@boring-stack-pkg/eslint-plugin-react-component-architecture/yaml": ["yaml@2.4.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg=="], - "@boring-stack-pkg/eslint-plugin-structured-logging/@typescript-eslint/parser": ["@typescript-eslint/parser@8.0.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.0.0", "@typescript-eslint/types": "8.0.0", "@typescript-eslint/typescript-estree": "8.0.0", "@typescript-eslint/visitor-keys": "8.0.0", "debug": "4.4.3" }, "optionalDependencies": { "typescript": "6.0.3" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-pS1hdZ+vnrpDIxuFXYQpLTILglTjSYJ9MbetZctrUawogUsPdz31DIIRZ9+rab0LhYNTsk88w4fIzVheiTbWOQ=="], - "@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], "@changesets/config/micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "3.0.3", "picomatch": "2.3.2" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], @@ -2568,14 +2566,6 @@ "@boring-stack-pkg/eslint-plugin-module-boundaries/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.0.0", "", { "dependencies": { "@typescript-eslint/types": "8.0.0", "eslint-visitor-keys": "3.4.3" } }, "sha512-oN0K4nkHuOyF3PVMyETbpP5zp6wfyOvm7tWhTMfoqxSSsPmJIh6JNASuZDlODE8eE+0EB9uar+6+vxr9DBTYOA=="], - "@boring-stack-pkg/eslint-plugin-structured-logging/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.0.0", "", { "dependencies": { "@typescript-eslint/types": "8.0.0", "@typescript-eslint/visitor-keys": "8.0.0" } }, "sha512-V0aa9Csx/ZWWv2IPgTfY7T4agYwJyILESu/PVqFtTFz9RIS823mAze+NbnBI8xiwdX3iqeQbcTYlvB04G9wyQw=="], - - "@boring-stack-pkg/eslint-plugin-structured-logging/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.0.0", "", {}, "sha512-wgdSGs9BTMWQ7ooeHtu5quddKKs5Z5dS+fHLbrQI+ID0XWJLODGMHRfhwImiHoeO2S5Wir2yXuadJN6/l4JRxw=="], - - "@boring-stack-pkg/eslint-plugin-structured-logging/@typescript-eslint/parser/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.0.0", "", { "dependencies": { "@typescript-eslint/types": "8.0.0", "@typescript-eslint/visitor-keys": "8.0.0", "debug": "4.4.3", "globby": "11.1.0", "is-glob": "4.0.3", "minimatch": "9.0.9", "semver": "7.8.0", "ts-api-utils": "1.4.3" }, "optionalDependencies": { "typescript": "6.0.3" } }, "sha512-5b97WpKMX+Y43YKi4zVcCVLtK5F98dFls3Oxui8LbnmRsseKenbbDinmvxrWegKDMmlkIq/XHuyy0UGLtpCDKg=="], - - "@boring-stack-pkg/eslint-plugin-structured-logging/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.0.0", "", { "dependencies": { "@typescript-eslint/types": "8.0.0", "eslint-visitor-keys": "3.4.3" } }, "sha512-oN0K4nkHuOyF3PVMyETbpP5zp6wfyOvm7tWhTMfoqxSSsPmJIh6JNASuZDlODE8eE+0EB9uar+6+vxr9DBTYOA=="], - "@changesets/config/micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@changesets/git/micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -2872,10 +2862,6 @@ "@boring-stack-pkg/eslint-plugin-module-boundaries/@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@boring-stack-pkg/eslint-plugin-structured-logging/@typescript-eslint/parser/@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@1.4.3", "", { "peerDependencies": { "typescript": "6.0.3" } }, "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw=="], - - "@boring-stack-pkg/eslint-plugin-structured-logging/@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@eslint/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "@eslint/js/eslint/@eslint/config-array/@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], diff --git a/apps/ui/package.json b/apps/ui/package.json index 992d9c40..bbb4b8f9 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -91,7 +91,7 @@ "@boring-stack-pkg/eslint-plugin-i18n-keys": "0.1.2", "@boring-stack-pkg/eslint-plugin-module-boundaries": "0.1.1", "@boring-stack-pkg/eslint-plugin-react-component-architecture": "0.3.0", - "@boring-stack-pkg/eslint-plugin-structured-logging": "0.1.1", + "@boring-stack-pkg/eslint-plugin-structured-logging": "0.1.2", "@boring-stack-pkg/eslint-plugin-tanstack-query-cache": "0.2.0", "@boring-stack-pkg/eslint-plugin-test-conventions": "0.1.2", "@changesets/cli": "2.31.0", From 485cc740e5a47b9cdb89009891b35aab92755497 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 21:06:50 +0200 Subject: [PATCH 08/17] chore(docs): regen lint-meta catalog for new guardrail rules Audit: F022, F014, F020 --- apps/docs/src/data/lint-meta-catalog.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/docs/src/data/lint-meta-catalog.json b/apps/docs/src/data/lint-meta-catalog.json index 29b34fb2..ce6688bc 100644 --- a/apps/docs/src/data/lint-meta-catalog.json +++ b/apps/docs/src/data/lint-meta-catalog.json @@ -301,6 +301,12 @@ "category": "config", "ciCritical": false, "description": "ESLint severities must be \"error\" or \"off\", not \"warn\"." + }, + { + "id": "eslint-override-paths-exist", + "category": "config", + "ciCritical": false, + "description": "Literal test-file paths in eslint.config.* overrides must exist on disk." } ] } From ae83b23dbdc53dbeb674e939c5406ecc8040c618 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Tue, 2 Jun 2026 21:06:50 +0200 Subject: [PATCH 09/17] fix(docs): mount CostCalculator on why-boringstack, gate intra-site fragments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CostCalculator was defined but rendered nowhere while the changelog and cost-methodology copy advertised it on Why BoringStack — mounted in Running costs (client:visible), so /architecture/why-boringstack/#cost-calc-title resolves. New scripts/check-fragments.mjs verifies every intra-site #fragment against built ids (lychee --include-fragments false-positives on pretty-URL directory links) and runs as the last build:ci step; it also caught and fixed a second dead anchor: /api/acl/#status-driven-features -> #feature-gates. Audit: F005 --- apps/docs/package.json | 3 +- apps/docs/scripts/check-fragments.mjs | 116 ++++++++++++++++++ apps/docs/src/content/docs/api/billing.mdx | 2 +- .../docs/architecture/why-boringstack.mdx | 4 + .../docs/reference/cost-methodology.mdx | 2 +- 5 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 apps/docs/scripts/check-fragments.mjs diff --git a/apps/docs/package.json b/apps/docs/package.json index ca567a09..e3536488 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -19,9 +19,10 @@ "check:lint-meta-docs": "node scripts/generate-lint-meta-docs.mjs --check", "check:scripts-docs": "node scripts/generate-scripts-docs.mjs --check", "check:docs-data": "bun run check:lint-meta-docs && bun run check:scripts-docs", + "check:fragments": "node scripts/check-fragments.mjs", "build:site": "bun run generate:og-image && astro build", "build": "bun run generate:og-image && astro build", - "build:ci": "bun run check:docs-data && bun run generate:og-image && astro build", + "build:ci": "bun run check:docs-data && bun run generate:og-image && astro build && bun run check:fragments", "preview": "bun run build:site && wrangler dev", "astro": "astro", "deploy": "bun run build:ci && wrangler deploy", diff --git a/apps/docs/scripts/check-fragments.mjs b/apps/docs/scripts/check-fragments.mjs new file mode 100644 index 00000000..c52a55de --- /dev/null +++ b/apps/docs/scripts/check-fragments.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/* + * Verify every intra-site fragment link in the built docs resolves to a + * real element id. lychee's --include-fragments cannot do this: it does + * not apply the directory -> index.html fallback that pretty URLs use, + * so it false-positives on essentially every internal docs anchor. + * + * Usage: node scripts/check-fragments.mjs (after `astro build`) + */ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DIST = resolve(dirname(fileURLToPath(import.meta.url)), "..", "dist"); + +/* Anchors browsers/Starlight resolve without a matching id. */ +const FRAGMENT_ALLOWLIST = new Set(["", "_top"]); + +function walkHtml(dir) { + const out = []; + + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + + if (statSync(full).isDirectory()) { + out.push(...walkHtml(full)); + } else if (entry.endsWith(".html")) { + out.push(full); + } + } + + return out; +} + +/** Resolve a site-absolute path ("/api/acl/") to its built HTML file. */ +function resolveTarget(sitePath) { + const rel = sitePath.replace(/^\//, "").replace(/\/$/, ""); + const candidates = + rel === "" + ? [join(DIST, "index.html")] + : [join(DIST, rel, "index.html"), join(DIST, `${rel}.html`), join(DIST, rel)]; + + return candidates.find((file) => existsSync(file) && statSync(file).isFile()); +} + +const idCache = new Map(); + +function idsOf(file) { + if (!idCache.has(file)) { + const ids = new Set(); + + for (const match of readFileSync(file, "utf8").matchAll( + /\bid="([^"]+)"/gu + )) { + ids.add(match[1]); + } + + idCache.set(file, ids); + } + + return idCache.get(file); +} + +const errors = []; + +for (const file of walkHtml(DIST)) { + const html = readFileSync(file, "utf8"); + + for (const match of html.matchAll(/\bhref="([^"]+)"/gu)) { + const href = match[1]; + + let sitePath; + let fragment; + + if (href.startsWith("#")) { + sitePath = null; + fragment = href.slice(1); + } else if (href.startsWith("/") && href.includes("#")) { + const [path, frag] = href.split("#", 2); + + sitePath = path; + fragment = frag; + } else { + continue; + } + + fragment = decodeURIComponent(fragment); + + if (FRAGMENT_ALLOWLIST.has(fragment)) { + continue; + } + + const target = sitePath === null ? file : resolveTarget(sitePath); + + if (target === undefined) { + errors.push(`${file}: link target not found for \`${href}\``); + continue; + } + + if (!idsOf(target).has(fragment)) { + errors.push(`${file}: dead fragment \`${href}\` (no id="${fragment}")`); + } + } +} + +if (errors.length > 0) { + console.error(`[check-fragments] ${errors.length} dead fragment link(s):`); + + for (const error of errors) { + console.error(` ${error.replace(DIST, "dist")}`); + } + + process.exit(1); +} + +console.log("[check-fragments] all intra-site fragment links resolve."); diff --git a/apps/docs/src/content/docs/api/billing.mdx b/apps/docs/src/content/docs/api/billing.mdx index 34276e2c..30a6d247 100644 --- a/apps/docs/src/content/docs/api/billing.mdx +++ b/apps/docs/src/content/docs/api/billing.mdx @@ -56,7 +56,7 @@ sequenceDiagram Handled events: - `checkout.session.completed`: creates or updates `billing.account_plans` for the account and plan in session metadata. -- `customer.subscription.updated`: maps the active Stripe price id back to a local plan and updates the account plan; also tracks `past_due`, `unpaid`, `paused`, `canceled`, `incomplete`, `trialing`, and `active` for the [feature resolver](/api/acl/#status-driven-features). +- `customer.subscription.updated`: maps the active Stripe price id back to a local plan and updates the account plan; also tracks `past_due`, `unpaid`, `paused`, `canceled`, `incomplete`, `trialing`, and `active` for the [feature resolver](/api/acl/#feature-gates). - `customer.subscription.deleted`: marks the row revoked so the resolver falls back to the Free plan. - `invoice.paid` / `invoice.payment_failed`: status transitions for the active plan row. diff --git a/apps/docs/src/content/docs/architecture/why-boringstack.mdx b/apps/docs/src/content/docs/architecture/why-boringstack.mdx index 02b432a7..48f4246b 100644 --- a/apps/docs/src/content/docs/architecture/why-boringstack.mdx +++ b/apps/docs/src/content/docs/architecture/why-boringstack.mdx @@ -3,6 +3,8 @@ title: Why BoringStack description: Production-grade product infrastructure on day one. Start on your idea, not on auth and billing for the hundredth time. --- +import CostCalculator from "../../../components/landing/CostCalculator"; + Build the product that solves a problem. Skip rebuilding the infrastructure every SaaS needs from scratch. ## What is BoringStack @@ -45,6 +47,8 @@ Postgres and Valkey on one VPS. Bill tracks server size, not per-request meters. Most products under 50k MAU fit on a CPX31 (4 vCPU, 8 GB, around 11 EUR/month). GHCR images are free if public. Hetzner storage backups are a few euros per month. Stripe and email providers charge on volume, not fixed monthly fees. + + See [Cost methodology](/reference/cost-methodology/) for the full breakdown. ## Related diff --git a/apps/docs/src/content/docs/reference/cost-methodology.mdx b/apps/docs/src/content/docs/reference/cost-methodology.mdx index 8c0d8653..86c6cc89 100644 --- a/apps/docs/src/content/docs/reference/cost-methodology.mdx +++ b/apps/docs/src/content/docs/reference/cost-methodology.mdx @@ -6,7 +6,7 @@ verifiedOn: "2026-05" import { Aside } from "@astrojs/starlight/components"; -The [cost calculator](/architecture/why-boringstack/) compares three solution categories at four usage stages. Prices reflect public list rate cards as of 2026-05. Numbers are rounded up on purpose so the columns sit on the same scale, not to predict your bill to the cent. +The [cost calculator](/architecture/why-boringstack/#cost-calc-title) compares three solution categories at four usage stages. Prices reflect public list rate cards as of 2026-05. Numbers are rounded up on purpose so the columns sit on the same scale, not to predict your bill to the cent.