diff --git a/.github/workflows/apps-api-security-deps.yml b/.github/workflows/apps-api-security-deps.yml index ba75d61e..ff3d1699 100644 --- a/.github/workflows/apps-api-security-deps.yml +++ b/.github/workflows/apps-api-security-deps.yml @@ -13,7 +13,7 @@ on: concurrency: group: apps-api-security-deps-security-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: read diff --git a/.github/workflows/apps-api-security-sast.yml b/.github/workflows/apps-api-security-sast.yml index 5b49492a..557c84eb 100644 --- a/.github/workflows/apps-api-security-sast.yml +++ b/.github/workflows/apps-api-security-sast.yml @@ -13,7 +13,7 @@ on: concurrency: group: apps-api-security-sast-security-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: read diff --git a/.github/workflows/apps-api-security-secrets.yml b/.github/workflows/apps-api-security-secrets.yml index 9c547fb8..4c1fdfc1 100644 --- a/.github/workflows/apps-api-security-secrets.yml +++ b/.github/workflows/apps-api-security-secrets.yml @@ -13,7 +13,7 @@ on: concurrency: group: apps-api-security-secrets-security-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: read diff --git a/.github/workflows/apps-docs-security-deps.yml b/.github/workflows/apps-docs-security-deps.yml index b46e7a78..abea68a8 100644 --- a/.github/workflows/apps-docs-security-deps.yml +++ b/.github/workflows/apps-docs-security-deps.yml @@ -13,7 +13,7 @@ on: concurrency: group: apps-docs-security-deps-security-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: read diff --git a/.github/workflows/apps-docs-security-secrets.yml b/.github/workflows/apps-docs-security-secrets.yml index 665dd23e..d5f59136 100644 --- a/.github/workflows/apps-docs-security-secrets.yml +++ b/.github/workflows/apps-docs-security-secrets.yml @@ -13,7 +13,7 @@ on: concurrency: group: apps-docs-security-secrets-security-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: read diff --git a/.github/workflows/apps-ui-security-deps.yml b/.github/workflows/apps-ui-security-deps.yml index 5d7073eb..8ec3b292 100644 --- a/.github/workflows/apps-ui-security-deps.yml +++ b/.github/workflows/apps-ui-security-deps.yml @@ -13,7 +13,7 @@ on: concurrency: group: apps-ui-security-deps-security-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: read diff --git a/.github/workflows/apps-ui-security-sast.yml b/.github/workflows/apps-ui-security-sast.yml index 00f856b5..d320dd98 100644 --- a/.github/workflows/apps-ui-security-sast.yml +++ b/.github/workflows/apps-ui-security-sast.yml @@ -13,7 +13,7 @@ on: concurrency: group: apps-ui-security-sast-security-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: read diff --git a/.github/workflows/apps-ui-security-secrets.yml b/.github/workflows/apps-ui-security-secrets.yml index f1c9c6ab..1d8a7174 100644 --- a/.github/workflows/apps-ui-security-secrets.yml +++ b/.github/workflows/apps-ui-security-secrets.yml @@ -13,7 +13,7 @@ on: concurrency: group: apps-ui-security-secrets-security-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: read diff --git a/.github/workflows/infra-bootstrap-security-deps.yml b/.github/workflows/infra-bootstrap-security-deps.yml index 1d74156f..09a1d9fb 100644 --- a/.github/workflows/infra-bootstrap-security-deps.yml +++ b/.github/workflows/infra-bootstrap-security-deps.yml @@ -15,7 +15,7 @@ on: concurrency: group: infra-bootstrap-security-deps-security-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: read diff --git a/.github/workflows/infra-bootstrap-security-secrets.yml b/.github/workflows/infra-bootstrap-security-secrets.yml index 953c0e20..8ede109d 100644 --- a/.github/workflows/infra-bootstrap-security-secrets.yml +++ b/.github/workflows/infra-bootstrap-security-secrets.yml @@ -15,7 +15,7 @@ on: concurrency: group: infra-bootstrap-security-secrets-security-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: read diff --git a/.github/workflows/infra-compose-security-secrets.yml b/.github/workflows/infra-compose-security-secrets.yml index b22133a2..56773d4c 100644 --- a/.github/workflows/infra-compose-security-secrets.yml +++ b/.github/workflows/infra-compose-security-secrets.yml @@ -13,7 +13,7 @@ on: concurrency: group: infra-compose-security-secrets-security-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: read diff --git a/apps/api/package.json b/apps/api/package.json index 932ce328..a54e3c2e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -124,6 +124,10 @@ "typescript": "6.0.3", "typescript-eslint": "8.60.1" }, + "//overrides": { + "_": "Why these transitive deps are pinned. Keep each entry in sync with the matching `overrides` key — enforced by the package-override-parity lint-meta rule.", + "@typescript-eslint/utils": "Single @typescript-eslint/utils resolution across the workspace so the shared @boring-stack-pkg ESLint plugins all load the same utils version. The UI and docs apps mirror this exact pin; a mismatch makes the custom plugins resolve divergent utils copies and fail to load." + }, "overrides": { "@typescript-eslint/utils": "8.60.1" } diff --git a/apps/api/scripts/lint-meta/RULES.md b/apps/api/scripts/lint-meta/RULES.md index 3b98d6fc..68587ab5 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -12,40 +12,42 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi ## Rules -| Rule ID | Category | CI-critical | What it guards | -| ----------------------------------------- | ------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `package-json-exact-deps` | supply-chain | no | dependencies and devDependencies must use exact versions (no ranges). | -| `no-overlapping-libs` | supply-chain | no | package.json must not list forbidden overlapping library pairs. | -| `package-override-parity` | supply-chain | no | package.json overrides must be reflected in the app's own bun.lock and mirrored by sibling apps that resolve the same package. | -| `shared-tool-version-parity` | supply-chain | no | Shared dev tooling (ESLint, TypeScript, Prettier, knip, …) must be pinned to the same version in every app that declares it. | -| `github-actions-permissions` | ci | no | GitHub Actions workflows require permissions block and SHA-pinned uses: refs. | -| `github-actions-permissions:verify` | ci | no | Pinned action SHAs resolve on github.com (lint:meta:verify only). | -| `github-actions-timeout-required` | ci | no | GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt). | -| `github-actions-bun-cache` | ci | no | Workflows running bun install must cache ~/.bun/install/cache. | -| `github-actions-concurrency-explicit` | ci | no | Workflows with a concurrency block must set cancel-in-progress explicitly. | -| `github-actions-expression-syntax` | ci | no | Every expression opener in a workflow must be a well-formed Actions expression. | -| `github-actions-service-image-digest-pin` | ci | no | Workflow service/container images must be pinned by @sha256 digest, not tag alone. | -| `pre-push-ci-parity` | ci | no | CI workflow must include every command listed in scripts/ci/pre-push.manifest.json. | -| `tofu-bootstrap-hardening` | ci | no | infra/bootstrap must keep its hardening invariants: server lifecycle guard, no world-open variable defaults, no curl-pipe-sh, version-pinned required_providers. | -| `engine-pin-parity` | ci | no | Bun version pin must stay aligned across package.json, Docker, and CI. | -| `dockerfile-base-image-sha-pin` | ci | no | Dockerfile base images must be pinned by @sha256 digest, not tag alone. | -| `env-cascade-drift` | env | no | TypeBox env schema keys must align with .env.example documentation. | -| `env-no-direct-process-env` | env | no | Single entry point for env: every source file outside validate.ts must import the typed `env` object instead of reading `process.env` directly. | -| `generated-artifact-contract` | artifacts | no | Sibling apps/ui generated ACL and OpenAPI files must carry required banner text. | -| `forbidden-text` | source-text | no | Source files must not contain inline lint/TS suppression comments. | -| `no-inline-lint-disable` | source-text | no | Inline ESLint disables are not allowed. | -| `no-ts-ignore` | source-text | no | TypeScript suppression comments are not allowed. | -| `canonical-helpers-single-home` | source-text | no | Helpers in the canonical registry must only be declared in their single source-of-truth file. | -| `docs-no-retired-credentials` | source-text | no | Documentation prose must not reference retired default credentials. | -| `external-client-timeout` | source-text | no | SDK clients (Stripe/OpenAI/Anthropic) need a timeout option; email transports (Resend/SendGrid/nodemailer) must be bounded; fetch() in src needs an AbortSignal. | -| `no-raw-role-literal` | source-text | no | Use ROLE.* from acl.constants.ts instead of raw owner/admin/member/viewer string literals. | -| `routes-require-test-sibling` | testing | no | Route modules must ship with a matching HTTP-level test under tests/api/. | -| `logic-files-require-test-sibling` | testing | no | Logic modules must ship with a matching tests/**/*.test.ts sibling. | -| `lint-meta-rules-self-covered` | testing | no | Every lint-meta rule module must re-export its check function from cli.ts and carry a describe() test block — the guardrails must themselves be guarded. | -| `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-ban-type-assertions` | config | no | ESLint must pin @typescript-eslint/consistent-type-assertions to assertionStyle "never"; disabling it requires an audited eslint-meta-allow-assertion-exemption marker. | -| `eslint-override-paths-exist` | config | no | Literal test-file paths in eslint.config.* overrides must exist on disk. | -| `tsconfig-include-paths-exist` | config | no | Literal tsconfig include/files entries must point at files that exist (globs exempt); checks this app and sibling apps. | -| `eslint-plugin-contract-parity` | config | no | Every installed @boring-stack-pkg eslint plugin must appear in AGENT_CONTRACT.md, and vice versa. | +| Rule ID | Category | CI-critical | What it guards | +| ----------------------------------------- | ------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `package-json-exact-deps` | supply-chain | no | dependencies and devDependencies must use exact versions (no ranges). | +| `no-overlapping-libs` | supply-chain | no | package.json must not list forbidden overlapping library pairs. | +| `package-override-parity` | supply-chain | no | package.json overrides must be reflected in the app's own bun.lock, mirrored by sibling apps that resolve the same package, and each carry a `//overrides` entry documenting why it is pinned. | +| `shared-tool-version-parity` | supply-chain | no | Shared dev tooling (ESLint, TypeScript, Prettier, knip, …) must be pinned to the same version in every app that declares it. | +| `github-actions-permissions` | ci | no | GitHub Actions workflows require permissions block and SHA-pinned uses: refs. | +| `github-actions-permissions:verify` | ci | no | Pinned action SHAs resolve on github.com (lint:meta:verify only). | +| `github-actions-timeout-required` | ci | no | GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt). | +| `github-actions-bun-cache` | ci | no | Workflows running bun install must cache ~/.bun/install/cache. | +| `github-actions-concurrency-explicit` | ci | no | Workflows with a concurrency block must set cancel-in-progress explicitly. | +| `github-actions-security-no-cancel` | ci | no | Security scan workflows (*-security-{sast,secrets,deps}) must set concurrency cancel-in-progress: false so no pushed ref goes unscanned. | +| `github-actions-expression-syntax` | ci | no | Every expression opener in a workflow must be a well-formed Actions expression. | +| `github-actions-service-image-digest-pin` | ci | no | Workflow service/container images must be pinned by @sha256 digest, not tag alone. | +| `pre-push-ci-parity` | ci | no | CI workflow must include every command listed in scripts/ci/pre-push.manifest.json. | +| `security-scanner-version-parity` | ci | no | All security workflows must pin a single gitleaks version and a single semgrep image so every scan runs the same ruleset. | +| `tofu-bootstrap-hardening` | ci | no | infra/bootstrap must keep its hardening invariants: server lifecycle guard, no world-open variable defaults, no curl-pipe-sh, version-pinned required_providers. | +| `engine-pin-parity` | ci | no | Bun version pin must stay aligned across package.json, Docker, and CI. | +| `dockerfile-base-image-sha-pin` | ci | no | Dockerfile base images must be pinned by @sha256 digest, not tag alone. | +| `env-cascade-drift` | env | no | TypeBox env schema keys must align with .env.example documentation. | +| `env-no-direct-process-env` | env | no | Single entry point for env: every source file outside validate.ts must import the typed `env` object instead of reading `process.env` directly. | +| `generated-artifact-contract` | artifacts | no | Sibling apps/ui generated ACL and OpenAPI files must carry required banner text. | +| `forbidden-text` | source-text | no | Source files must not contain inline lint/TS suppression comments. | +| `no-inline-lint-disable` | source-text | no | Inline ESLint disables are not allowed. | +| `no-ts-ignore` | source-text | no | TypeScript suppression comments are not allowed. | +| `canonical-helpers-single-home` | source-text | no | Helpers in the canonical registry must only be declared in their single source-of-truth file. | +| `docs-no-retired-credentials` | source-text | no | Documentation prose must not reference retired default credentials. | +| `external-client-timeout` | source-text | no | SDK clients (Stripe/OpenAI/Anthropic) need a timeout option; email transports (Resend/SendGrid/nodemailer) must be bounded; fetch() in src needs an AbortSignal. | +| `no-raw-role-literal` | source-text | no | Use ROLE.* from acl.constants.ts instead of raw owner/admin/member/viewer string literals. | +| `routes-require-test-sibling` | testing | no | Route modules must ship with a matching HTTP-level test under tests/api/. | +| `logic-files-require-test-sibling` | testing | no | Logic modules must ship with a matching tests/**/*.test.ts sibling. | +| `lint-meta-rules-self-covered` | testing | no | Every lint-meta rule module must re-export its check function from cli.ts and carry a describe() test block — the guardrails must themselves be guarded. | +| `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-ban-type-assertions` | config | no | ESLint must pin @typescript-eslint/consistent-type-assertions to assertionStyle "never"; disabling it requires an audited eslint-meta-allow-assertion-exemption marker. | +| `eslint-override-paths-exist` | config | no | Literal test-file paths in eslint.config.* overrides must exist on disk. | +| `tsconfig-include-paths-exist` | config | no | Literal tsconfig include/files entries must point at files that exist (globs exempt); checks this app and sibling apps. | +| `eslint-plugin-contract-parity` | config | no | Every installed @boring-stack-pkg eslint plugin must appear in AGENT_CONTRACT.md, and vice versa. | diff --git a/apps/api/scripts/lint-meta/cli.ts b/apps/api/scripts/lint-meta/cli.ts index 50464296..4a876b47 100644 --- a/apps/api/scripts/lint-meta/cli.ts +++ b/apps/api/scripts/lint-meta/cli.ts @@ -35,10 +35,12 @@ import { checkEnginePinParity } from "./rules/ci/engine-pin-parity"; import { checkWorkflowBunCache } from "./rules/ci/github-actions-bun-cache"; import { checkWorkflowConcurrencyExplicit } from "./rules/ci/github-actions-concurrency-explicit"; import { checkWorkflowExpressionSyntax } from "./rules/ci/github-actions-expression-syntax"; +import { checkWorkflowSecurityNoCancel } from "./rules/ci/github-actions-security-no-cancel"; import { checkWorkflowServiceImageDigestPin } from "./rules/ci/github-actions-service-image-digest-pin"; import { checkWorkflowShas } from "./rules/ci/github-actions-permissions"; import { checkWorkflowTimeouts } from "./rules/ci/github-actions-timeout-required"; import { checkPrePushParity } from "./rules/ci/pre-push-ci-parity"; +import { checkSecurityScannerVersionParity } from "./rules/ci/security-scanner-version-parity"; import { checkTofuBootstrapHardening } from "./rules/ci/tofu-bootstrap-hardening"; import { checkCanonicalHelpersSingleHome } from "./rules/source-text/canonical-helpers-single-home"; import { checkDocsNoRetiredCredentials } from "./rules/source-text/docs-no-retired-credentials"; @@ -126,6 +128,7 @@ export { checkPackageOverrideParity, checkPrePushParity, checkRouteFilesHaveTests, + checkSecurityScannerVersionParity, checkSharedToolVersionParity, checkSkippedTestsHaveTracking, checkTofuBootstrapHardening, @@ -134,6 +137,7 @@ export { checkWorkflowBunCache, checkWorkflowConcurrencyExplicit, checkWorkflowExpressionSyntax, + checkWorkflowSecurityNoCancel, checkWorkflowServiceImageDigestPin, checkWorkflowShas, checkWorkflowTimeouts, diff --git a/apps/api/scripts/lint-meta/registry.ts b/apps/api/scripts/lint-meta/registry.ts index 9291d347..fda3d087 100644 --- a/apps/api/scripts/lint-meta/registry.ts +++ b/apps/api/scripts/lint-meta/registry.ts @@ -5,9 +5,11 @@ import { githubActionsBunCacheRule } from "./rules/ci/github-actions-bun-cache"; import { githubActionsConcurrencyExplicitRule } from "./rules/ci/github-actions-concurrency-explicit"; import { githubActionsExpressionSyntaxRule } from "./rules/ci/github-actions-expression-syntax"; import { githubActionsPermissionsRule } from "./rules/ci/github-actions-permissions"; +import { githubActionsSecurityNoCancelRule } from "./rules/ci/github-actions-security-no-cancel"; import { githubActionsServiceImageDigestPinRule } from "./rules/ci/github-actions-service-image-digest-pin"; import { githubActionsTimeoutRequiredRule } from "./rules/ci/github-actions-timeout-required"; import { prePushCiParityRule } from "./rules/ci/pre-push-ci-parity"; +import { securityScannerVersionParityRule } from "./rules/ci/security-scanner-version-parity"; import { tofuBootstrapHardeningRule } from "./rules/ci/tofu-bootstrap-hardening"; import { eslintBanTypeAssertionsRule } from "./rules/config/eslint-ban-type-assertions"; import { eslintConfigNoWarnRule } from "./rules/config/eslint-config-no-warn"; @@ -41,9 +43,11 @@ export const META_RULES: readonly IMetaRule[] = [ githubActionsTimeoutRequiredRule, githubActionsBunCacheRule, githubActionsConcurrencyExplicitRule, + githubActionsSecurityNoCancelRule, githubActionsExpressionSyntaxRule, githubActionsServiceImageDigestPinRule, prePushCiParityRule, + securityScannerVersionParityRule, tofuBootstrapHardeningRule, enginePinParityRule, dockerfileBaseImageShaPinRule, diff --git a/apps/api/scripts/lint-meta/rules/ci/github-actions-security-no-cancel.ts b/apps/api/scripts/lint-meta/rules/ci/github-actions-security-no-cancel.ts new file mode 100644 index 00000000..66039e02 --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/ci/github-actions-security-no-cancel.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; + +import type { IMetaRule, IViolation } from "../../types"; + +const TOP_LEVEL_KEY_REGEX = /^\S/u; +const SECURITY_WORKFLOW_REGEX = /-security-(?:sast|secrets|deps)\.ya?ml$/u; + +/* + * Security scans (SAST, secret, dependency) must run to completion for every + * pushed ref. With `cancel-in-progress: true`, a follow-up push cancels the + * in-flight scan — the superseded commit is then neither passed nor failed, + * and a PR can read green over code that was never actually scanned. So any + * `*-security-{sast,secrets,deps}` workflow's concurrency block must set + * `cancel-in-progress: false`. Build/validate workflows are intentionally + * exempt: there, cancelling superseded runs is the desirable fast-feedback + * behaviour. + */ +export function checkWorkflowSecurityNoCancel(file: string): IViolation[] { + if (!SECURITY_WORKFLOW_REGEX.test(basename(file))) { + return []; + } + + const lines = readFileSync(file, "utf8").split("\n"); + let inConcurrency = false; + let cancelInProgress: boolean | null = null; + + for (const line of lines) { + if (/^concurrency:\s*(?:#.*)?$/u.test(line)) { + inConcurrency = true; + continue; + } + + if (!inConcurrency) { + continue; + } + + if (TOP_LEVEL_KEY_REGEX.test(line)) { + inConcurrency = false; + continue; + } + + const match = /^\s+cancel-in-progress:\s*(true|false)\s*(?:#.*)?$/u.exec( + line + ); + + if (match !== null) { + cancelInProgress = match[1] === "true"; + } + } + + if (cancelInProgress === true) { + return [ + { + file, + rule: "github-actions-security-no-cancel", + message: + "Security scan workflow sets `cancel-in-progress: true` — a follow-up push cancels the in-flight scan and the superseded commit goes unscanned. Set `cancel-in-progress: false` so every pushed ref is scanned to completion.", + }, + ]; + } + + return []; +} + +/** + * A cancelled security scan is neither a pass nor a fail, and the scanned + * commit may never be re-scanned. Security workflows must queue, not cancel. + */ +export const githubActionsSecurityNoCancelRule: IMetaRule = { + id: "github-actions-security-no-cancel", + category: "ci", + description: + "Security scan workflows (*-security-{sast,secrets,deps}) must set concurrency cancel-in-progress: false so no pushed ref goes unscanned.", + run({ workflowFiles }) { + return workflowFiles.flatMap(checkWorkflowSecurityNoCancel); + }, +}; diff --git a/apps/api/scripts/lint-meta/rules/ci/security-scanner-version-parity.ts b/apps/api/scripts/lint-meta/rules/ci/security-scanner-version-parity.ts new file mode 100644 index 00000000..0dde5516 --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/ci/security-scanner-version-parity.ts @@ -0,0 +1,113 @@ +import { readFileSync } from "node:fs"; + +import type { IMetaRule, IViolation } from "../../types"; + +/* + * Secret/SAST scanners encode their ruleset in their version: two workflows + * running different gitleaks or semgrep versions scan with different rules, + * so one can pass code the other would flag. The pinned version is duplicated + * across every `*-security-secrets`/`-sast` workflow with nothing forcing the + * copies to agree — a bump to one drifts silently from the rest. This rule + * requires a single gitleaks version and a single semgrep image pin across + * all workflows. (scripts/ci/pre-push-security.sh reads its gitleaks version + * from the workflow at runtime, so it is single-sourced by construction.) + */ + +const GITLEAKS_VERSION_REGEX = + /^\s*GITLEAKS_VERSION:\s*["']?(\d+\.\d+\.\d+)["']?\s*(?:#.*)?$/u; +const SEMGREP_IMAGE_REGEX = + /semgrep\/semgrep:(\d+\.\d+\.\d+@sha256:[a-f0-9]+)/u; + +const RULE_ID = "security-scanner-version-parity"; + +function addRef( + byValue: Map, + value: string, + file: string +): void { + const existing = byValue.get(value); + + if (existing === undefined) { + byValue.set(value, [file]); + + return; + } + + existing.push(file); +} + +function parityViolations( + tool: string, + byValue: Map +): IViolation[] { + if (byValue.size <= 1) { + return []; + } + + const values = [...byValue.keys()].sort(); + const expected = values[0]; + + if (expected === undefined) { + return []; + } + + const out: IViolation[] = []; + + for (const [value, files] of byValue) { + if (value === expected) { + continue; + } + + for (const file of files) { + out.push({ + file, + rule: RULE_ID, + message: `${tool} pinned to ${value} here but ${expected} elsewhere — single-source the ${tool} version across all security workflows so every scan runs the same ruleset.`, + }); + } + } + + return out; +} + +export function checkSecurityScannerVersionParity( + files: readonly string[] +): IViolation[] { + const gitleaks = new Map(); + const semgrep = new Map(); + + for (const file of files) { + for (const line of readFileSync(file, "utf8").split("\n")) { + const gitleaksMatch = GITLEAKS_VERSION_REGEX.exec(line); + + if (gitleaksMatch?.[1] !== undefined) { + addRef(gitleaks, gitleaksMatch[1], file); + } + + const semgrepMatch = SEMGREP_IMAGE_REGEX.exec(line); + + if (semgrepMatch?.[1] !== undefined) { + addRef(semgrep, semgrepMatch[1], file); + } + } + } + + return [ + ...parityViolations("gitleaks", gitleaks), + ...parityViolations("semgrep", semgrep), + ]; +} + +/** + * A scanner version bumped in one workflow but not its siblings silently runs + * divergent rulesets; pin every gitleaks/semgrep reference to one version. + */ +export const securityScannerVersionParityRule: IMetaRule = { + id: RULE_ID, + category: "ci", + description: + "All security workflows must pin a single gitleaks version and a single semgrep image so every scan runs the same ruleset.", + run({ workflowFiles }) { + return checkSecurityScannerVersionParity(workflowFiles); + }, +}; diff --git a/apps/api/scripts/lint-meta/rules/supply-chain/package-override-parity.ts b/apps/api/scripts/lint-meta/rules/supply-chain/package-override-parity.ts index 1917e84a..67c93493 100644 --- a/apps/api/scripts/lint-meta/rules/supply-chain/package-override-parity.ts +++ b/apps/api/scripts/lint-meta/rules/supply-chain/package-override-parity.ts @@ -15,12 +15,18 @@ import type { IMetaRule, IViolation } from "../../types"; * 2. Missing mirror — a sibling resolves the package at a different * version than the override and declares no override of its own * (e.g. a GHSA patch pinned in one app but not the others). + * 3. Undocumented override — an `overrides` key with no matching + * `//overrides` entry explaining why it is pinned. Every override + * encodes a non-obvious decision (a GHSA patch, a single-resolution + * pin); the rationale must travel with it so a future dep bump does + * not silently drop it. */ interface IAppOverrides { readonly app: string; readonly file: string; readonly overrides: Record; + readonly overridesDoc: Record; readonly lockfileText: string | null; } @@ -79,11 +85,16 @@ function readApps(appsDir: string): IAppOverrides[] { } let overridesValue: unknown; + let overridesDocValue: unknown; for (const [k, v] of Object.entries(parsed)) { if (k === "overrides") { overridesValue = v; } + + if (k === "//overrides") { + overridesDocValue = v; + } } let lockfileText: string | null; @@ -98,6 +109,7 @@ function readApps(appsDir: string): IAppOverrides[] { app: entry, file, overrides: toStringRecord(overridesValue), + overridesDoc: toStringRecord(overridesDocValue), lockfileText, }); } @@ -148,6 +160,20 @@ function checkStaleOverrides(app: IAppOverrides, report: Reporter): void { } } +function checkOverridesDocumented(app: IAppOverrides, report: Reporter): void { + for (const name of Object.keys(app.overrides)) { + const doc = app.overridesDoc[name]; + + if (doc === undefined || doc.trim() === "") { + report( + app.file, + `undocumented:${name}`, + `Override ${name} has no \`//overrides\` entry explaining why it is pinned — add a documented rationale (sibling apps document every override).` + ); + } + } +} + function checkSiblingMirror( owner: IAppOverrides, sibling: IAppOverrides, @@ -200,6 +226,7 @@ export function checkPackageOverrideParity(appsDir: string): IViolation[] { for (const app of apps) { checkStaleOverrides(app, report); + checkOverridesDocumented(app, report); } for (const owner of apps) { @@ -220,7 +247,7 @@ export const packageOverrideParityRule: IMetaRule = { id: "package-override-parity", category: "supply-chain", description: - "package.json overrides must be reflected in the app's own bun.lock and mirrored by sibling apps that resolve the same package.", + "package.json overrides must be reflected in the app's own bun.lock, mirrored by sibling apps that resolve the same package, and each carry a `//overrides` entry documenting why it is pinned.", run({ root }) { return checkPackageOverrideParity(join(root, "..")); }, diff --git a/apps/api/src/api/accounts/accounts.routes.ts b/apps/api/src/api/accounts/accounts.routes.ts index 9df33681..44784dbc 100644 --- a/apps/api/src/api/accounts/accounts.routes.ts +++ b/apps/api/src/api/accounts/accounts.routes.ts @@ -93,7 +93,7 @@ const accountsRoutes = requireAuth() throw ApiErrors.forbidden("Only an account owner or admin can invite"); } - if (!emailRateLimiter.check(body.email)) { + if (!(await emailRateLimiter.check(body.email))) { throw ApiErrors.validation( "Too many invitation emails for this address. Please wait a few minutes.", "email" @@ -145,7 +145,7 @@ const accountsRoutes = requireAuth() params.invitationId ); - if (!emailRateLimiter.check(pending.email)) { + if (!(await emailRateLimiter.check(pending.email))) { throw ApiErrors.validation( "Too many invitation emails for this address. Please wait a few minutes.", "email" diff --git a/apps/api/src/api/auth/auth.routes.ts b/apps/api/src/api/auth/auth.routes.ts index 3bb36a82..fee2581f 100644 --- a/apps/api/src/api/auth/auth.routes.ts +++ b/apps/api/src/api/auth/auth.routes.ts @@ -59,7 +59,7 @@ const credentialingRoutes = new Elysia() .post( "/register", async ({ body }) => { - if (!emailRateLimiter.check(body.email)) { + if (!(await emailRateLimiter.check(body.email))) { throw ApiErrors.validation( "Too many registration attempts for this email. Please wait a few minutes.", "email" @@ -157,7 +157,7 @@ const credentialingRoutes = new Elysia() .post( "/resend-verification", async ({ body }) => { - if (!emailRateLimiter.check(body.email)) { + if (!(await emailRateLimiter.check(body.email))) { throw ApiErrors.validation( "Too many verification emails requested. Please wait a few minutes.", "email" @@ -289,7 +289,7 @@ const credentialingRoutes = new Elysia() .post( "/forgot-password", async ({ body }) => { - if (!emailRateLimiter.check(body.email)) { + if (!(await emailRateLimiter.check(body.email))) { throw ApiErrors.validation( "Too many password reset requests. Please wait a few minutes.", "email" diff --git a/apps/api/src/lib/rate-limit/email-rate-limit.ts b/apps/api/src/lib/rate-limit/email-rate-limit.ts index 4ceae79f..676c5935 100644 --- a/apps/api/src/lib/rate-limit/email-rate-limit.ts +++ b/apps/api/src/lib/rate-limit/email-rate-limit.ts @@ -1,37 +1,47 @@ /** - * Lightweight per-email rate limiter. In-memory only — sufficient for - * single-process deployments (the default target of this template). - * For horizontal scale, swap in a Valkey-backed implementation. + * Per-email rate limiter for endpoints that trigger external email delivery + * (resend-verification, forgot-password) — caps inbox-spam attacks from + * distributed IPs. * - * Used on endpoints that trigger external email delivery (resend-verification, - * forgot-password) to prevent inbox-spam attacks from distributed IPs. + * Two backends, selected by config (mirroring `security.ts`'s rate-limit + * context choice): + * + * - **Valkey** when `CACHE_ENABLED && CACHE_PROVIDER === "valkey"`: a shared + * counter so the quota holds across replicas. A per-process limiter is + * bypassable under horizontal scale — an attacker just rotates which + * replica they hit, multiplying the real cap by the replica count. + * - **In-memory** otherwise (the single-process default of this template), + * and as the fallback when a Valkey call fails — so a cache blip degrades + * to per-process enforcement rather than no enforcement at all. */ +import { Redis } from "ioredis"; + +import { getValkeyAppClientOptions } from "../../clients/valkey"; +import { env } from "../../config/env"; +import { logger } from "../../config/logger"; +import { getErrorMessage } from "../errors"; import { nowMs } from "../time/now"; -class EmailRateLimiter { - private static readonly windowMs = 300_000; // 5 minutes - private static readonly maxAttempts = 3; - private static readonly sweepIntervalMs = 600_000; // 10 minutes +const WINDOW_MS = 300_000; // 5 minutes +const MAX_ATTEMPTS = 3; +const SWEEP_INTERVAL_MS = 600_000; // 10 minutes +const KEY_PREFIX = "erl:"; +class InMemoryEmailRateLimiter { private readonly attempts = new Map(); constructor() { setInterval(() => { this.sweep(); - }, EmailRateLimiter.sweepIntervalMs).unref(); + }, SWEEP_INTERVAL_MS).unref(); } - check(email: string): boolean { + check(key: string): boolean { const now = nowMs(); - const key = email.toLowerCase().trim(); const timestamps = this.attempts.get(key) ?? []; + const valid = timestamps.filter((timestamp) => now - timestamp < WINDOW_MS); - // Prune stale entries outside the window - const valid = timestamps.filter( - (timestamp) => now - timestamp < EmailRateLimiter.windowMs - ); - - if (valid.length >= EmailRateLimiter.maxAttempts) { + if (valid.length >= MAX_ATTEMPTS) { this.attempts.set(key, valid); return false; @@ -52,7 +62,7 @@ class EmailRateLimiter { for (const [key, timestamps] of this.attempts) { const valid = timestamps.filter( - (timestamp) => now - timestamp < EmailRateLimiter.windowMs + (timestamp) => now - timestamp < WINDOW_MS ); if (valid.length === 0) { @@ -64,4 +74,108 @@ class EmailRateLimiter { } } +class ValkeyEmailRateLimiter { + private client: Redis | null = null; + private readonly fallback: InMemoryEmailRateLimiter; + + constructor(fallback: InMemoryEmailRateLimiter) { + this.fallback = fallback; + } + + private getClient(): Redis { + if (this.client !== null) { + return this.client; + } + + const client = new Redis(getValkeyAppClientOptions()); + + client.on("error", (err: Error) => { + logger.warn("Email rate-limit Valkey client error", { + event: "cache_valkey_error", + error: err.message, + }); + }); + + this.client = client; + + return client; + } + + /** + * Fixed-window counter: INCR the key, set its TTL only on the first write + * (PEXPIRE NX), and allow while the count is within the cap. Any Valkey + * failure falls back to the in-memory limiter so enforcement never silently + * drops to nothing. + */ + async check(key: string): Promise { + const fullKey = `${KEY_PREFIX}${key}`; + + try { + const result = await this.getClient() + .multi() + .incr(fullKey) + .pexpire(fullKey, WINDOW_MS, "NX") + .exec(); + + if (result === null) { + return this.fallback.check(key); + } + + const countCmd = result[0]; + + if (!countCmd) { + return this.fallback.check(key); + } + + const [countErr, countRaw] = countCmd; + + if (countErr !== null) { + return this.fallback.check(key); + } + + const count = typeof countRaw === "number" ? countRaw : Number(countRaw); + + if (Number.isNaN(count)) { + return this.fallback.check(key); + } + + return count <= MAX_ATTEMPTS; + } catch (error: unknown) { + logger.warn( + "Email rate-limit Valkey check failed; falling back to in-memory", + { + event: "cache_valkey_error", + error: getErrorMessage(error), + } + ); + + return this.fallback.check(key); + } + } +} + +class EmailRateLimiter { + private readonly inMemory = new InMemoryEmailRateLimiter(); + private readonly valkey = new ValkeyEmailRateLimiter(this.inMemory); + + /** + * Returns `true` when the email is allowed another attempt, `false` when it + * has exhausted its window. Email is normalized (trim + lowercase) so casing + * and whitespace share one bucket. + */ + check(email: string): Promise { + const key = email.toLowerCase().trim(); + + if (env.CACHE_ENABLED && env.CACHE_PROVIDER === "valkey") { + return this.valkey.check(key); + } + + return Promise.resolve(this.inMemory.check(key)); + } + + sweep(): void { + this.inMemory.sweep(); + } +} + export const emailRateLimiter = new EmailRateLimiter(); diff --git a/apps/api/tests/lib/rate-limit/email-rate-limit.test.ts b/apps/api/tests/lib/rate-limit/email-rate-limit.test.ts index 2a8c79a1..b5562b30 100644 --- a/apps/api/tests/lib/rate-limit/email-rate-limit.test.ts +++ b/apps/api/tests/lib/rate-limit/email-rate-limit.test.ts @@ -6,44 +6,44 @@ const unique = (prefix: string): string => `${prefix}-${String(Date.now())}-${String(Math.random()).slice(2)}@example.com`; describe("emailRateLimiter.check", () => { - test("allows the first three attempts within the window", () => { + test("allows the first three attempts within the window", async () => { const email = unique("first-three"); - expect(emailRateLimiter.check(email)).toBe(true); - expect(emailRateLimiter.check(email)).toBe(true); - expect(emailRateLimiter.check(email)).toBe(true); + expect(await emailRateLimiter.check(email)).toBe(true); + expect(await emailRateLimiter.check(email)).toBe(true); + expect(await emailRateLimiter.check(email)).toBe(true); }); - test("blocks the fourth attempt", () => { + test("blocks the fourth attempt", async () => { const email = unique("fourth-blocked"); - emailRateLimiter.check(email); - emailRateLimiter.check(email); - emailRateLimiter.check(email); + await emailRateLimiter.check(email); + await emailRateLimiter.check(email); + await emailRateLimiter.check(email); - expect(emailRateLimiter.check(email)).toBe(false); + expect(await emailRateLimiter.check(email)).toBe(false); }); - test("treats trimming + casing as the same bucket", () => { + test("treats trimming + casing as the same bucket", async () => { const base = unique("normalize"); - emailRateLimiter.check(base); - emailRateLimiter.check(` ${base.toUpperCase()} `); - emailRateLimiter.check(base); + await emailRateLimiter.check(base); + await emailRateLimiter.check(` ${base.toUpperCase()} `); + await emailRateLimiter.check(base); - expect(emailRateLimiter.check(base)).toBe(false); + expect(await emailRateLimiter.check(base)).toBe(false); }); - test("isolates buckets across distinct emails", () => { + test("isolates buckets across distinct emails", async () => { const firstEmail = unique("isolate-a"); const secondEmail = unique("isolate-b"); - emailRateLimiter.check(firstEmail); - emailRateLimiter.check(firstEmail); - emailRateLimiter.check(firstEmail); + await emailRateLimiter.check(firstEmail); + await emailRateLimiter.check(firstEmail); + await emailRateLimiter.check(firstEmail); - expect(emailRateLimiter.check(firstEmail)).toBe(false); - expect(emailRateLimiter.check(secondEmail)).toBe(true); + expect(await emailRateLimiter.check(firstEmail)).toBe(false); + expect(await emailRateLimiter.check(secondEmail)).toBe(true); }); }); diff --git a/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-a/package.json b/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-a/package.json index feffa478..9ba8f751 100644 --- a/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-a/package.json +++ b/apps/api/tests/lint-meta/fixtures/override-parity-clean/app-a/package.json @@ -1,5 +1,8 @@ { "name": "fixture-app-a", + "//overrides": { + "ws": "Pin patched ws (GHSA-58qx-3vcg-4xpx)." + }, "overrides": { "ws": "8.21.0" } diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts index 03645703..69a0efef 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -29,11 +29,13 @@ import { checkLogicFilesHaveTests, checkNoDirectProcessEnv, checkRouteFilesHaveTests, + checkSecurityScannerVersionParity, checkTouchedTests, checkTsconfigIncludePathsExist, checkWorkflowBunCache, checkWorkflowConcurrencyExplicit, checkWorkflowExpressionSyntax, + checkWorkflowSecurityNoCancel, checkWorkflowServiceImageDigestPin, checkWorkflowShas, checkWorkflowTimeouts, @@ -54,6 +56,18 @@ const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "fixtures"); const GUARD_TMP_PREFIX = "lint-meta-guard-"; const RULE_SELF_COVERED = "lint-meta-rules-self-covered"; +function writeNamedWorkflow( + root: string, + name: string, + content: string +): string { + const file = join(root, name); + + writeFileSync(file, content); + + return file; +} + describe("checkSharedToolVersionParity", () => { test("flags a shared tool pinned to different versions across apps", () => { const violations = checkSharedToolVersionParity( @@ -115,6 +129,20 @@ describe("checkPackageOverrideParity", () => { ).toBe(true); }); + test("flags an override with no `//overrides` documentation entry", () => { + const violations = checkPackageOverrideParity( + join(FIXTURES, "override-parity-drift") + ); + + expect( + violations.some( + (row) => + row.file.includes("app-a") && + row.message.includes("no `//overrides` entry") + ) + ).toBe(true); + }); + test("passes when overrides are applied and siblings resolve the same version", () => { const violations = checkPackageOverrideParity( join(FIXTURES, "override-parity-clean") @@ -533,6 +561,120 @@ describe("checkWorkflowConcurrencyExplicit", () => { }); }); +describe("checkWorkflowSecurityNoCancel", () => { + test("flags a security workflow with cancel-in-progress: true", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-secnocancel-")); + + try { + const file = writeNamedWorkflow( + root, + "apps-api-security-sast.yml", + "concurrency:\n group: x-${{ github.ref }}\n cancel-in-progress: true\n\njobs: {}\n" + ); + + expect( + checkWorkflowSecurityNoCancel(file).map((row) => row.rule) + ).toEqual(["github-actions-security-no-cancel"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes a security workflow with cancel-in-progress: false", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-secnocancel-")); + + try { + const file = writeNamedWorkflow( + root, + "infra-compose-security-secrets.yml", + "concurrency:\n group: x-${{ github.ref }}\n cancel-in-progress: false\n\njobs: {}\n" + ); + + expect(checkWorkflowSecurityNoCancel(file)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("ignores non-security workflows that cancel in progress", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-secnocancel-")); + + try { + const file = writeNamedWorkflow( + root, + "apps-api-ci.yml", + "concurrency:\n group: x-${{ github.ref }}\n cancel-in-progress: true\n\njobs: {}\n" + ); + + expect(checkWorkflowSecurityNoCancel(file)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("checkSecurityScannerVersionParity", () => { + test("flags a gitleaks version that drifts between workflows", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-scanver-")); + + try { + const a = writeNamedWorkflow( + root, + "apps-api-security-secrets.yml", + 'env:\n GITLEAKS_VERSION: "8.30.1"\n' + ); + const b = writeNamedWorkflow( + root, + "apps-ui-security-secrets.yml", + 'env:\n GITLEAKS_VERSION: "8.30.0"\n' + ); + + const violations = checkSecurityScannerVersionParity([a, b]); + + expect(violations.map((row) => row.rule)).toContain( + "security-scanner-version-parity" + ); + expect(violations.some((row) => row.message.includes("gitleaks"))).toBe( + true + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes when gitleaks and semgrep pins agree across workflows", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-scanver-")); + + try { + const digest = "@sha256:" + "a".repeat(64); + const a = writeNamedWorkflow( + root, + "apps-api-security-secrets.yml", + 'env:\n GITLEAKS_VERSION: "8.30.1"\n' + ); + const b = writeNamedWorkflow( + root, + "apps-ui-security-secrets.yml", + 'env:\n GITLEAKS_VERSION: "8.30.1"\n' + ); + const c = writeNamedWorkflow( + root, + "apps-api-security-sast.yml", + `jobs:\n sast:\n container: semgrep/semgrep:1.142.0${digest}\n` + ); + const d = writeNamedWorkflow( + root, + "apps-ui-security-sast.yml", + `jobs:\n sast:\n container: semgrep/semgrep:1.142.0${digest}\n` + ); + + expect(checkSecurityScannerVersionParity([a, b, c, d])).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkWorkflowBunCache", () => { test("flags bun install without a cache step; passes cached and bun-free workflows", () => { const root = mkdtempSync(join(tmpdir(), "lint-meta-bun-cache-")); diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore index 2440e90c..66ce5c9a 100644 --- a/apps/docs/.gitignore +++ b/apps/docs/.gitignore @@ -11,3 +11,4 @@ dist/ .wrangler .dev.vars* !.dev.vars.example +worker-configuration.d.ts diff --git a/apps/docs/src/data/lint-meta-catalog.json b/apps/docs/src/data/lint-meta-catalog.json index bef311db..1db4249c 100644 --- a/apps/docs/src/data/lint-meta-catalog.json +++ b/apps/docs/src/data/lint-meta-catalog.json @@ -270,7 +270,7 @@ "id": "package-override-parity", "category": "supply-chain", "ciCritical": false, - "description": "package.json overrides must be reflected in the app's own bun.lock and mirrored by sibling apps that resolve the same package." + "description": "package.json overrides must be reflected in the app's own bun.lock, mirrored by sibling apps that resolve the same package, and each carry a `//overrides` entry documenting why it is pinned." }, { "id": "shared-tool-version-parity", @@ -308,6 +308,12 @@ "ciCritical": false, "description": "Workflows with a concurrency block must set cancel-in-progress explicitly." }, + { + "id": "github-actions-security-no-cancel", + "category": "ci", + "ciCritical": false, + "description": "Security scan workflows (*-security-{sast,secrets,deps}) must set concurrency cancel-in-progress: false so no pushed ref goes unscanned." + }, { "id": "github-actions-expression-syntax", "category": "ci", @@ -326,6 +332,12 @@ "ciCritical": false, "description": "CI workflow must include every command listed in scripts/ci/pre-push.manifest.json." }, + { + "id": "security-scanner-version-parity", + "category": "ci", + "ciCritical": false, + "description": "All security workflows must pin a single gitleaks version and a single semgrep image so every scan runs the same ruleset." + }, { "id": "tofu-bootstrap-hardening", "category": "ci", diff --git a/apps/ui/src/features/notifications/Notifications.list.queries.test.tsx b/apps/ui/src/features/notifications/Notifications.list.queries.test.tsx index 14fd9b67..4982f0f0 100644 --- a/apps/ui/src/features/notifications/Notifications.list.queries.test.tsx +++ b/apps/ui/src/features/notifications/Notifications.list.queries.test.tsx @@ -70,4 +70,37 @@ describe("useNotificationsList", () => { }); expect(result.current.data?.pages[0]?.items[0]?.id).toBe("n1"); }); + + it("sends the previous page's cursor when fetching the next page", async () => { + apiMock.GET.mockResolvedValueOnce({ + data: { items: [makeNotification({ id: "n1" })], nextCursor: "p1" }, + response: {} + }).mockResolvedValueOnce({ + data: { items: [makeNotification({ id: "n2" })], nextCursor: null }, + response: {} + }); + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useNotificationsList(), { + wrapper: Wrapper + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + await result.current.fetchNextPage(); + + await waitFor(() => { + expect(apiMock.GET).toHaveBeenCalledTimes(2); + }); + expect(apiMock.GET).toHaveBeenNthCalledWith( + 2, + "/api/v1/notifications/", + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.objectContaining({ cursor: "p1" }) + }) + }) + ); + }); }); diff --git a/apps/ui/src/features/notifications/Notifications.list.queries.ts b/apps/ui/src/features/notifications/Notifications.list.queries.ts index 2ea6e985..24ba925c 100644 --- a/apps/ui/src/features/notifications/Notifications.list.queries.ts +++ b/apps/ui/src/features/notifications/Notifications.list.queries.ts @@ -25,12 +25,13 @@ export function useNotificationsList( return useInfiniteQuery({ queryKey: [...NOTIFICATIONS_QUERY_KEYS.list, status ?? "all"] as const, initialPageParam: INITIAL_LIST_CURSOR, - queryFn: async () => { + queryFn: async ({ pageParam }) => { const { data } = await apiClient.GET("/api/v1/notifications/", { params: { query: { status, - limit: String(NOTIFICATIONS_LIST_PAGE_SIZE) + limit: String(NOTIFICATIONS_LIST_PAGE_SIZE), + cursor: pageParam } } });