Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3288cca
fix(lint-meta): pre-push-ci-parity fails closed and resolves monorepo…
agjs Jun 2, 2026
bf0f7ea
fix(lint-meta): engine-pin-parity scans monorepo workflows and guards…
agjs Jun 2, 2026
752ba14
fix(api): drop stale eslint override path, add eslint-override-paths-…
agjs Jun 2, 2026
bf831f8
fix(api): scope join-request approve UPDATE by accountId
agjs Jun 2, 2026
49d42a7
fix(api): require CACHE_PROVIDER=valkey in production when cache is e…
agjs Jun 2, 2026
b8c2ca6
fix(docs): align .nvmrc with engines.node >=24
agjs Jun 2, 2026
fc8657c
fix(deps): align eslint-plugin-structured-logging at 0.1.2, guard @bo…
agjs Jun 2, 2026
485cc74
chore(docs): regen lint-meta catalog for new guardrail rules
agjs Jun 2, 2026
ae83b23
fix(docs): mount CostCalculator on why-boringstack, gate intra-site f…
agjs Jun 2, 2026
cc55945
fix(docs): Cloudflare Pages build command -> build:ci
agjs Jun 2, 2026
db91bb8
fix(api): audit Stripe webhook plan mutations and persist targetAccou…
agjs Jun 2, 2026
37b2770
feat(api): configurable JWT revocation fail mode (JWT_REVOCATION_FAIL…
agjs Jun 2, 2026
268a722
fix(infra): pre-push yamllint actually lints workflow YAML
agjs Jun 2, 2026
9f8c02e
fix(ci): prod-image-build triggers on app source changes
agjs Jun 2, 2026
fc2c87d
docs(api): AGENT_CONTRACT documents the full push gate, not just vali…
agjs Jun 2, 2026
cc1adce
test(api): cover MFA lockout branches on verify-login and verify-reco…
agjs Jun 2, 2026
4245f7b
test(api): QueueManager lifecycle tests via structural queue/worker i…
agjs Jun 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions .github/workflows/infra-compose-validate-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
paths:
- "infra/compose/**"
- ".github/workflows/infra-compose-validate-compose.yml"
# Prod Dockerfiles COPY the whole app context, so any app source
# change can break the prod image build — not just Dockerfile or
# lockfile edits.
- "apps/api/**"
- "apps/ui/**"
pull_request:
branches: [main]

Expand Down Expand Up @@ -183,14 +188,10 @@
with:
filters: |
code:
- 'apps/api/Dockerfile.prod'
- 'apps/api/.dockerignore'
- 'apps/api/package.json'
- 'apps/api/bun.lock'
- 'apps/ui/Dockerfile.prod'
- 'apps/ui/.dockerignore'
- 'apps/ui/package.json'
- 'apps/ui/bun.lock'
# Dockerfile.prod runs COPY . . — the whole app tree is
# image input, so app-only source changes must rebuild too.
- 'apps/api/**'
- 'apps/ui/**'
- 'infra/compose/**'
- '.github/workflows/infra-compose-validate-compose.yml'

Expand Down Expand Up @@ -250,4 +251,4 @@
# don't fail on style nits — only on real syntax errors.
~/.local/bin/yamllint -d "{extends: relaxed, rules: {line-length: disable}}" \
infra/compose/compose/ .github/workflows/

Check warning on line 254 in .github/workflows/infra-compose-validate-compose.yml

View workflow job for this annotation

GitHub Actions / yamllint (compose + workflows)

254:1 [empty-lines] too many blank lines (1 > 0)
6 changes: 6 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ DATABASE_SSL_CA=
# Postgres connection pool size, per API instance. Default 10.
# DATABASE_POOL_SIZE=10
JWT_SECRET=change-me-to-a-long-random-secret-at-least-32-chars
# Policy for JWT revocation checks (logout blocklist, revoke-all cutoff)
# when the cache is unreachable. false (default) fails open: a cache
# outage never blocks auth; revoked tokens are honored until the cache
# returns or they expire (15-min JWT TTL bounds the window). true fails
# closed: cache errors reject every authenticated request.
JWT_REVOCATION_FAIL_CLOSED=false
# AES-256-GCM key for MFA TOTP secret storage. REQUIRED in production —
# boot aborts if empty when NODE_ENV=production. Generate with:
# openssl rand -base64 32
Expand Down
26 changes: 16 additions & 10 deletions apps/api/AGENT_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,26 @@ Read this first. Long-form patterns are in [AGENTS.md](AGENTS.md).
## Merge bar

1. **`bun run validate`** must pass (typecheck + ESLint + tests).
2. **No** inline `eslint-disable`, no `any`, no `as` (only `as const`),
2. Push/CI additionally enforce **`bun run test:coverage`** (coverage
ratchet — needs the local stack up so integration tests run, or the
function floor fails) and the dist build. `validate` alone is the
fast local gate, not the whole story — see
`scripts/ci/pre-push.manifest.json` for the full list.
3. **No** inline `eslint-disable`, no `any`, no `as` (only `as const`),
no `!`. Fix the cause; don't bypass the rule.
3. If repo guidance conflicts with code, follow the code and flag drift.
4. If repo guidance conflicts with code, follow the code and flag drift.

## Commands

| | |
| ------------------------------------ | ----------------------------------- |
| `bun run validate` | merge gate |
| `bun run check` | typecheck + lint + lint:meta + knip |
| `bun test` | tests |
| `bun run dev` | watch server |
| `bun run new:resource -- <Name>` | scaffold a resource |
| `bun run db:generate` / `db:migrate` | Drizzle |
| | |
| ------------------------------------ | ------------------------------------- |
| `bun run validate` | fast merge gate (check + tests) |
| `bun run check` | typecheck + lint + lint:meta + knip |
| `bun test` | tests |
| `bun run test:coverage` | coverage ratchet (pre-push/CI gate) |
| `bun run dev` | watch server |
| `bun run new:resource -- <Name>` | scaffold a resource |
| `bun run db:generate` / `db:migrate` | Drizzle |

## Resource layout (`src/api/<feature>/`)

Expand Down
12 changes: 12 additions & 0 deletions apps/api/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ 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.
- JWT revocation checks **fail open by default** when the cache is
unreachable: a Valkey blip never becomes a global auth outage, and
the exposure window is bounded by the 15-minute JWT TTL. Strict
deployments set `JWT_REVOCATION_FAIL_CLOSED=true` to reject every
authenticated request on cache errors instead. Either way the
failure is logged as `auth.jwt.revoke_check_failed` /
`auth.jwt.revoke_user_check_failed` — alert on those events.
- `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.
Expand Down
1 change: 0 additions & 1 deletion apps/api/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/api/scripts/ci/pre-push.manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"ciWorkflow": ".github/workflows/ci.yml",
"ciWorkflow": ".github/workflows/apps-api-ci.yml",
"requiredCommands": [
"bun run check",
"bun run test",
Expand Down
1 change: 1 addition & 0 deletions apps/api/scripts/lint-meta/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
2 changes: 2 additions & 0 deletions apps/api/scripts/lint-meta/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -94,6 +95,7 @@ export {
checkDependencyPairs,
checkExactDependencyVersions,
checkEslintConfigNoWarn,
checkEslintOverridePathsExist,
checkEnvSchemaDrift,
checkForbiddenText,
checkGeneratedArtifactContracts,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/scripts/lint-meta/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -39,4 +40,5 @@ export const META_RULES: readonly IMetaRule[] = [
skippedTestsNeedTrackingRule,
touchTestsTooRule,
eslintConfigNoWarnRule,
eslintOverridePathsExistRule,
];
60 changes: 54 additions & 6 deletions apps/api/scripts/lint-meta/rules/ci/pre-push-ci-parity.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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).`,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>();

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");
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/api/accounts/invitations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export class InvitationsService {
userId: actorUserId,
action: AUDIT_ACTIONS.MEMBERSHIP_INVITED,
resource: `invitation:${invitation.id}`,
targetAccountId: input.accountId,
metadata: {
accountId: input.accountId,
role: input.roleToAssign,
Expand Down Expand Up @@ -137,6 +138,7 @@ export class InvitationsService {
userId: actorUserId,
action: AUDIT_ACTIONS.MEMBERSHIP_INVITED,
resource: `invitation:${updated.id}`,
targetAccountId: accountId,
metadata: { accountId, resend: true },
});

Expand Down Expand Up @@ -176,6 +178,7 @@ export class InvitationsService {
userId: actorUserId,
action: AUDIT_ACTIONS.MEMBERSHIP_REVOKED,
resource: `invitation:${invitationId}`,
targetAccountId: accountId,
metadata: { accountId },
});
}
Expand Down Expand Up @@ -280,6 +283,7 @@ export class InvitationsService {
userId,
action: AUDIT_ACTIONS.MEMBERSHIP_ACCEPTED,
resource: `invitation:${invitation.id}`,
targetAccountId: invitation.accountId,
metadata: {
accountId: invitation.accountId,
role: invitation.roleToAssign,
Expand Down
Loading
Loading