diff --git a/.github/workflows/infra-compose-validate-compose.yml b/.github/workflows/infra-compose-validate-compose.yml index 5eab47d9..37626bcf 100644 --- a/.github/workflows/infra-compose-validate-compose.yml +++ b/.github/workflows/infra-compose-validate-compose.yml @@ -11,6 +11,11 @@ on: 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] @@ -183,14 +188,10 @@ jobs: 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' diff --git a/apps/api/.env.example b/apps/api/.env.example index 103c4a69..93722c2e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/AGENT_CONTRACT.md b/apps/api/AGENT_CONTRACT.md index bd232f40..dafd026c 100644 --- a/apps/api/AGENT_CONTRACT.md +++ b/apps/api/AGENT_CONTRACT.md @@ -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 -- ` | 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 -- ` | scaffold a resource | +| `bun run db:generate` / `db:migrate` | Drizzle | ## Resource layout (`src/api//`) diff --git a/apps/api/SECURITY.md b/apps/api/SECURITY.md index e48def33..98b85152 100644 --- a/apps/api/SECURITY.md +++ b/apps/api/SECURITY.md @@ -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. 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/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.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/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/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/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/src/api/accounts/invitations.service.ts b/apps/api/src/api/accounts/invitations.service.ts index bb70bb3c..e832a978 100644 --- a/apps/api/src/api/accounts/invitations.service.ts +++ b/apps/api/src/api/accounts/invitations.service.ts @@ -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, @@ -137,6 +138,7 @@ export class InvitationsService { userId: actorUserId, action: AUDIT_ACTIONS.MEMBERSHIP_INVITED, resource: `invitation:${updated.id}`, + targetAccountId: accountId, metadata: { accountId, resend: true }, }); @@ -176,6 +178,7 @@ export class InvitationsService { userId: actorUserId, action: AUDIT_ACTIONS.MEMBERSHIP_REVOKED, resource: `invitation:${invitationId}`, + targetAccountId: accountId, metadata: { accountId }, }); } @@ -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, diff --git a/apps/api/src/api/accounts/join-requests.service.ts b/apps/api/src/api/accounts/join-requests.service.ts index a1cbf697..c2cb2fef 100644 --- a/apps/api/src/api/accounts/join-requests.service.ts +++ b/apps/api/src/api/accounts/join-requests.service.ts @@ -62,6 +62,7 @@ export class JoinRequestsService { userId: input.userId, action: AUDIT_ACTIONS.ACCOUNT_JOIN_REQUEST_CREATED, resource: `join_request:${inserted.id}`, + targetAccountId: input.accountId, metadata: { accountId: input.accountId }, }); @@ -159,6 +160,7 @@ export class JoinRequestsService { .where( and( eq(accountJoinRequests.id, requestId), + eq(accountJoinRequests.accountId, accountId), eq(accountJoinRequests.status, JOIN_REQUEST_STATUS.pending) ) ) @@ -172,6 +174,7 @@ export class JoinRequestsService { userId: deciderUserId, action: AUDIT_ACTIONS.ACCOUNT_JOIN_REQUEST_APPROVED, resource: `join_request:${requestId}`, + targetAccountId: accountId, metadata: { accountId, requesterId: request.userId }, }); @@ -208,6 +211,7 @@ export class JoinRequestsService { userId: deciderUserId, action: AUDIT_ACTIONS.ACCOUNT_JOIN_REQUEST_DENIED, resource: `join_request:${requestId}`, + targetAccountId: accountId, metadata: { accountId, requesterId: updated.userId }, }); diff --git a/apps/api/src/api/billing/billing.service.ts b/apps/api/src/api/billing/billing.service.ts index e8fe290c..5494b26b 100644 --- a/apps/api/src/api/billing/billing.service.ts +++ b/apps/api/src/api/billing/billing.service.ts @@ -230,6 +230,7 @@ export class BillingService { void auditLogService.record({ userId: actorUserId, action: AUDIT_ACTIONS.BILLING_CHECKOUT_SESSION_CREATED, + targetAccountId: accountId, metadata: { planId: plan.id, sessionId: session.id, accountId }, }); @@ -395,6 +396,19 @@ export class BillingService { accountId, planId, }); + + void auditLogService.record({ + userId: null, + action: AUDIT_ACTIONS.STRIPE_RECONCILED, + resource: `account:${accountId}`, + targetAccountId: accountId, + metadata: { + eventId: details.eventId, + eventType: details.eventType, + planId, + status: "active", + }, + }); } private async handleSubscriptionUpsert( @@ -456,6 +470,19 @@ export class BillingService { lastStripeEventId: details.eventId, lastStripeEventAt: stripeEventOccurredAt(details.eventCreated), }); + + void auditLogService.record({ + userId: null, + action: AUDIT_ACTIONS.STRIPE_RECONCILED, + resource: `account:${account.id}`, + targetAccountId: account.id, + metadata: { + eventId: details.eventId, + eventType: details.eventType, + planId: newPlan.id, + status: mapStripeStatus(subscription.status), + }, + }); } private async handleSubscriptionDeleted( @@ -493,6 +520,18 @@ export class BillingService { isNull(accountPlans.revokedAt) ) ); + + void auditLogService.record({ + userId: null, + action: AUDIT_ACTIONS.STRIPE_RECONCILED, + resource: `account:${account.id}`, + targetAccountId: account.id, + metadata: { + eventId: details.eventId, + eventType: details.eventType, + status: "canceled", + }, + }); } async createPortalSession( @@ -523,6 +562,7 @@ export class BillingService { void auditLogService.record({ userId: actorUserId, action: AUDIT_ACTIONS.BILLING_PORTAL_SESSION_CREATED, + targetAccountId: accountId, metadata: { sessionId: session.id, accountId }, }); diff --git a/apps/api/src/config/env/schema.ts b/apps/api/src/config/env/schema.ts index 91a2072d..87c9ecde 100644 --- a/apps/api/src/config/env/schema.ts +++ b/apps/api/src/config/env/schema.ts @@ -26,6 +26,16 @@ export const envSchema = t.Object({ DATABASE_SSL_REJECT_UNAUTHORIZED: t.Boolean({ default: true }), DATABASE_SSL_CA: t.String({ default: "" }), JWT_SECRET: t.String({ minLength: 32 }), + /* + * Behavior of JWT revocation checks (jti blocklist, user revoke-before + * cutoff) when the cache is unreachable. `false` (default) fails open: + * a cache outage never blocks authentication, at the cost of honoring + * revoked tokens until the cache returns or they expire (bounded by + * the 15-minute JWT TTL). `true` fails closed: cache errors reject + * every authenticated request — strict revocation semantics for + * deployments that prefer an auth outage over a revocation gap. + */ + JWT_REVOCATION_FAIL_CLOSED: t.Boolean({ default: false }), /* * AES-256-GCM key used to encrypt TOTP secrets at rest. Base64-encoded * 32 random bytes. Generate with `openssl rand -base64 32`. Required diff --git a/apps/api/src/config/env/validate.ts b/apps/api/src/config/env/validate.ts index 6785c9ce..46a1c54d 100644 --- a/apps/api/src/config/env/validate.ts +++ b/apps/api/src/config/env/validate.ts @@ -161,6 +161,11 @@ const readAuth = (source: EnvSource) => ({ ? "test-only-jwt-secret-padded-to-thirty-two-chars" : "" ), + JWT_REVOCATION_FAIL_CLOSED: toBoolWithDefault( + source.JWT_REVOCATION_FAIL_CLOSED, + false, + "JWT_REVOCATION_FAIL_CLOSED" + ), /* * Deterministic test-only key so MFA round-trip tests don't need an * env file. 32 bytes base64 = 44 chars. Production deploys must set @@ -685,6 +690,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 +769,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/src/lib/audit-log/audit-log.service.ts b/apps/api/src/lib/audit-log/audit-log.service.ts index 8f602198..6c6e2e03 100644 --- a/apps/api/src/lib/audit-log/audit-log.service.ts +++ b/apps/api/src/lib/audit-log/audit-log.service.ts @@ -28,6 +28,7 @@ export class AuditLogService { userId: event.userId, action: event.action, resource: event.resource ?? null, + targetAccountId: event.targetAccountId ?? null, metadata: event.metadata ?? {}, ip: event.ip ?? null, userAgent: event.userAgent ?? null, diff --git a/apps/api/src/lib/audit-log/audit-log.types.ts b/apps/api/src/lib/audit-log/audit-log.types.ts index 9e0168e1..d94e4bf2 100644 --- a/apps/api/src/lib/audit-log/audit-log.types.ts +++ b/apps/api/src/lib/audit-log/audit-log.types.ts @@ -15,6 +15,13 @@ export interface IAuditEventInput { action: AuditAction; /** Optional resource identifier — e.g. `"user:7c3..."`. */ resource?: string; + /** + * Tenant scope for `listForAccount`. Set on every account-scoped event + * whose `resource` is not already `account:{id}` — entity resources + * (`invitation:…`, `join_request:…`) are invisible to the account + * audit trail without it. + */ + targetAccountId?: string; /** Small structured payload. Avoid storing PII or secrets here. */ metadata?: Record; /** Originating IP, when the call site has access to the request. */ diff --git a/apps/api/src/lib/jwt/jwt-revocation.ts b/apps/api/src/lib/jwt/jwt-revocation.ts index 7f2488d7..3502f949 100644 --- a/apps/api/src/lib/jwt/jwt-revocation.ts +++ b/apps/api/src/lib/jwt/jwt-revocation.ts @@ -1,9 +1,21 @@ +import { env } from "../../config/env"; import { logger } from "../../config/logger"; import { cacheService } from "../cache"; import { getErrorMessage } from "../errors"; import { JWT_TTL_SECONDS } from "./jwt.constants"; import { nowMs } from "../time/now"; +/* + * Revocation checks consult the cache on every authenticated request, so + * a cache outage forces a policy choice. The default fails open: a cache + * blip never turns into a global auth outage, and the exposure window is + * bounded by the 15-minute JWT TTL. Deployments that prefer strict + * revocation semantics set JWT_REVOCATION_FAIL_CLOSED=true and accept + * that cache errors reject every authenticated request instead. + */ +const revocationFailMode = (): "closed" | "open" => + env.JWT_REVOCATION_FAIL_CLOSED ? "closed" : "open"; + const JTI_KEY_PREFIX = "jwt:revoked:"; const USER_KEY_PREFIX = "jwt:user:"; const USER_KEY_SUFFIX = ":revoked-before"; @@ -69,27 +81,28 @@ const revokeAllForUser = async (userId: string): Promise => { }; /** - * Whether the given JTI has been blocklisted. Falls back to "not - * revoked" on cache errors — see module comment on fail-open intent. + * Whether the given JTI has been blocklisted. On cache errors the + * result follows JWT_REVOCATION_FAIL_CLOSED — see module comment. */ const isJtiRevoked = async (jti: string): Promise => { try { return await cacheService.has(jtiKey(jti)); } catch (error: unknown) { - logger.warn("JWT revocation jti check failed (failing open)", { + logger.warn("JWT revocation jti check failed", { event: "auth.jwt.revoke_check_failed", jti, + failMode: revocationFailMode(), error: getErrorMessage(error), }); - return false; + return env.JWT_REVOCATION_FAIL_CLOSED; } }; /** * Whether the token (identified by its `iat`) was issued before the - * user's revoke-before cutoff. Falls back to "not revoked" on cache - * errors. + * user's revoke-before cutoff. On cache errors the result follows + * JWT_REVOCATION_FAIL_CLOSED — see module comment. */ const isUserRevokedSince = async ( userId: string, @@ -104,13 +117,14 @@ const isUserRevokedSince = async ( return issuedAtSeconds < cutoff; } catch (error: unknown) { - logger.warn("JWT revocation user check failed (failing open)", { + logger.warn("JWT revocation user check failed", { event: "auth.jwt.revoke_user_check_failed", userId, + failMode: revocationFailMode(), error: getErrorMessage(error), }); - return false; + return env.JWT_REVOCATION_FAIL_CLOSED; } }; diff --git a/apps/api/src/queues/queue-manager.ts b/apps/api/src/queues/queue-manager.ts index 57aef737..d9f452ac 100644 --- a/apps/api/src/queues/queue-manager.ts +++ b/apps/api/src/queues/queue-manager.ts @@ -1,50 +1,52 @@ -import type { Queue } from "bullmq"; +import type { JobsOptions, JobType } from "bullmq"; import { logger } from "../config/logger"; -import type { - AccountMaintenanceWorker, - IAccountMaintenanceJobData, -} from "./account-maintenance"; -import type { - EmailDeliveryWorker, - IEmailDeliveryJobData, -} from "./email-delivery"; +import type { IEmailDeliveryJobData } from "./email-delivery"; import { EMAIL_DELIVERY_DEFAULTS, EMAIL_DELIVERY_JOB_NAME, } from "./email-delivery/email-delivery.constants"; -import type { - INotificationDispatchJobData, - NotificationDispatchWorker, -} from "./notification-dispatch"; +import type { INotificationDispatchJobData } from "./notification-dispatch"; import { NOTIFICATION_DISPATCH_DEFAULTS, NOTIFICATION_DISPATCH_JOB_NAME, } from "./notification-dispatch/notification-dispatch.constants"; -import type { - INotificationMaintenanceJobData, - NotificationMaintenanceWorker, -} from "./notification-maintenance"; import type { IQueueCounts, IQueueStats } from "./queue-stats.types"; -import type { - IWebPushDeliveryJobData, - WebPushDeliveryWorker, -} from "./web-push-delivery"; +import type { IWebPushDeliveryJobData } from "./web-push-delivery"; import { WEB_PUSH_DELIVERY_DEFAULTS, WEB_PUSH_DELIVERY_JOB_NAME, } from "./web-push-delivery/web-push-delivery.constants"; +/* + * Structural views of the BullMQ surface the manager actually touches. + * Real `Queue` / worker-class instances satisfy these implicitly; tests + * satisfy them with plain stubs — no Valkey connection required. + */ +export interface IManagedWorker { + close: () => Promise; +} + +export interface IManagedQueue { + readonly name: string; + getJobCounts: (...states: JobType[]) => Promise>; + close: () => Promise; +} + +export interface IEnqueueableQueue extends IManagedQueue { + add: (name: string, data: TData, opts?: JobsOptions) => Promise; +} + interface IQueueManagerInput { - accountMaintenanceQueue: Queue; - accountMaintenanceWorker: AccountMaintenanceWorker; - emailDeliveryQueue: Queue; - emailDeliveryWorker: EmailDeliveryWorker; - notificationDispatchQueue: Queue; - notificationDispatchWorker: NotificationDispatchWorker; - notificationMaintenanceQueue: Queue; - notificationMaintenanceWorker: NotificationMaintenanceWorker; - webPushDeliveryQueue: Queue | null; - webPushDeliveryWorker: WebPushDeliveryWorker | null; + accountMaintenanceQueue: IManagedQueue; + accountMaintenanceWorker: IManagedWorker; + emailDeliveryQueue: IEnqueueableQueue; + emailDeliveryWorker: IManagedWorker; + notificationDispatchQueue: IEnqueueableQueue; + notificationDispatchWorker: IManagedWorker; + notificationMaintenanceQueue: IManagedQueue; + notificationMaintenanceWorker: IManagedWorker; + webPushDeliveryQueue: IEnqueueableQueue | null; + webPushDeliveryWorker: IManagedWorker | null; } const QUEUE_COUNT_STATES = [ @@ -56,7 +58,9 @@ const QUEUE_COUNT_STATES = [ "paused", ] as const; -const fetchQueueCounts = async (queue: Queue): Promise => { +const fetchQueueCounts = async ( + queue: IManagedQueue +): Promise => { const counts = await queue.getJobCounts(...QUEUE_COUNT_STATES); return { @@ -79,16 +83,16 @@ const fetchQueueCounts = async (queue: Queue): Promise => { * by treating Web Push as a no-op (see `enqueueWebPushDelivery`). */ export class QueueManager { - private readonly accountMaintenanceQueue: Queue; - private readonly accountMaintenanceWorker: AccountMaintenanceWorker; - private readonly emailDeliveryQueue: Queue; - private readonly emailDeliveryWorker: EmailDeliveryWorker; - private readonly notificationDispatchQueue: Queue; - private readonly notificationDispatchWorker: NotificationDispatchWorker; - private readonly notificationMaintenanceQueue: Queue; - private readonly notificationMaintenanceWorker: NotificationMaintenanceWorker; - private readonly webPushDeliveryQueue: Queue | null; - private readonly webPushDeliveryWorker: WebPushDeliveryWorker | null; + private readonly accountMaintenanceQueue: IManagedQueue; + private readonly accountMaintenanceWorker: IManagedWorker; + private readonly emailDeliveryQueue: IEnqueueableQueue; + private readonly emailDeliveryWorker: IManagedWorker; + private readonly notificationDispatchQueue: IEnqueueableQueue; + private readonly notificationDispatchWorker: IManagedWorker; + private readonly notificationMaintenanceQueue: IManagedQueue; + private readonly notificationMaintenanceWorker: IManagedWorker; + private readonly webPushDeliveryQueue: IEnqueueableQueue | null; + private readonly webPushDeliveryWorker: IManagedWorker | null; constructor(input: IQueueManagerInput) { this.accountMaintenanceQueue = input.accountMaintenanceQueue; @@ -171,7 +175,7 @@ export class QueueManager { * `/admin/queues` endpoint. Pure Valkey reads, safe from a request handler. */ async getStats(): Promise { - const queues: { name: string; queue: Queue }[] = [ + const queues: { name: string; queue: IManagedQueue }[] = [ { name: this.accountMaintenanceQueue.name, queue: this.accountMaintenanceQueue, 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; diff --git a/apps/api/tests/api/auth/mfa.routes.test.ts b/apps/api/tests/api/auth/mfa.routes.test.ts index 2a41e393..99a6582e 100644 --- a/apps/api/tests/api/auth/mfa.routes.test.ts +++ b/apps/api/tests/api/auth/mfa.routes.test.ts @@ -3,6 +3,7 @@ import { Secret, TOTP } from "otpauth"; import { MFA_CACHE_KEYS, + MFA_MAX_CHALLENGE_ATTEMPTS, MFA_TOTP_DIGITS, MFA_TOTP_STEP_SECONDS, } from "../../../src/api/auth/mfa.constants"; @@ -20,6 +21,8 @@ import { cleanDatabase, db, eq, requireDb, users } from "../../helpers/db"; const PASSWORD = "Hunter2Strong!"; const JSON_HEADERS = { "content-type": "application/json" } as const; const LOGIN_URL = "http://localhost/api/v1/auth/login"; +const SET_COOKIE_HEADER = "set-cookie"; +const NOT_MFA_REQUIRED_ERROR = "login response was not an mfaRequired envelope"; const uniqueEmail = (prefix: string): string => `${prefix}-${crypto.randomUUID()}@example.com`; @@ -123,12 +126,12 @@ describe("MFA routes", () => { ); expect(res.status).toBe(200); - expect(res.headers.get("set-cookie")).toBeNull(); + expect(res.headers.get(SET_COOKIE_HEADER)).toBeNull(); const body: unknown = await res.json(); if (!isMfaRequired(body)) { - throw new Error("login response was not an mfaRequired envelope"); + throw new Error(NOT_MFA_REQUIRED_ERROR); } expect(body.data.mfaRequired).toBe(true); @@ -155,7 +158,7 @@ describe("MFA routes", () => { const loginBody: unknown = await loginRes.json(); if (!isMfaRequired(loginBody)) { - throw new Error("login response was not an mfaRequired envelope"); + throw new Error(NOT_MFA_REQUIRED_ERROR); } const verifyRes = await app.handle( @@ -171,7 +174,7 @@ describe("MFA routes", () => { expect(verifyRes.status).toBe(200); - const setCookie = verifyRes.headers.get("set-cookie") ?? ""; + const setCookie = verifyRes.headers.get(SET_COOKIE_HEADER) ?? ""; expect(setCookie).toContain(AUTH_COOKIE_NAME); expect(setCookie).toContain(REFRESH_COOKIE_NAME); @@ -210,7 +213,7 @@ describe("MFA routes", () => { const loginBody: unknown = await loginRes.json(); if (!isMfaRequired(loginBody)) { - throw new Error("login response was not an mfaRequired envelope"); + throw new Error(NOT_MFA_REQUIRED_ERROR); } const verifyRes = await app.handle( @@ -225,7 +228,7 @@ describe("MFA routes", () => { ); expect(verifyRes.status).toBe(200); - expect(verifyRes.headers.get("set-cookie") ?? "").toContain( + expect(verifyRes.headers.get(SET_COOKIE_HEADER) ?? "").toContain( AUTH_COOKIE_NAME ); }); @@ -251,7 +254,7 @@ describe("MFA routes", () => { const loginBody: unknown = await loginRes.json(); if (!isMfaRequired(loginBody)) { - throw new Error("login response was not an mfaRequired envelope"); + throw new Error(NOT_MFA_REQUIRED_ERROR); } const verifyRes = await app.handle( @@ -268,6 +271,112 @@ describe("MFA routes", () => { expect(verifyRes.status).toBe(401); }); + test("verify-login locks out after the max failed attempts", async () => { + if (!(await requireDb())) { + return; + } + + const email = uniqueEmail("mfa-lockout"); + const { user } = await seedVerifiedUser({ email, password: PASSWORD }); + + await enrollViaService(user.id); + + const app = createApp(); + const loginRes = await app.handle( + new Request(LOGIN_URL, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ email, password: PASSWORD }), + }) + ); + const loginBody: unknown = await loginRes.json(); + + if (!isMfaRequired(loginBody)) { + throw new Error(NOT_MFA_REQUIRED_ERROR); + } + + const verifyWithWrongCode = async (): Promise => + app.handle( + new Request("http://localhost/api/v1/auth/mfa/verify-login", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + challengeToken: loginBody.data.challengeToken, + code: "000000", + }), + }) + ); + + for (let attempt = 1; attempt < MFA_MAX_CHALLENGE_ATTEMPTS; attempt++) { + const res = await verifyWithWrongCode(); + + expect(res.status).toBe(401); + expect(await res.text()).toContain("Invalid code"); + } + + const lockedRes = await verifyWithWrongCode(); + + expect(lockedRes.status).toBe(401); + expect(lockedRes.headers.get(SET_COOKIE_HEADER)).toBeNull(); + expect(await lockedRes.text()).toContain("Too many failed attempts"); + + // The challenge is consumed on lockout — retrying reports expiry. + const afterLockout = await verifyWithWrongCode(); + + expect(afterLockout.status).toBe(401); + expect(await afterLockout.text()).toContain("expired"); + }); + + test("verify-recovery locks out after the max failed attempts", async () => { + if (!(await requireDb())) { + return; + } + + const email = uniqueEmail("mfa-rec-lockout"); + const { user } = await seedVerifiedUser({ email, password: PASSWORD }); + + await enrollViaService(user.id); + + const app = createApp(); + const loginRes = await app.handle( + new Request(LOGIN_URL, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ email, password: PASSWORD }), + }) + ); + const loginBody: unknown = await loginRes.json(); + + if (!isMfaRequired(loginBody)) { + throw new Error(NOT_MFA_REQUIRED_ERROR); + } + + const verifyWithWrongRecoveryCode = async (): Promise => + app.handle( + new Request("http://localhost/api/v1/auth/mfa/verify-recovery", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + challengeToken: loginBody.data.challengeToken, + code: "0000000000", + }), + }) + ); + + for (let attempt = 1; attempt < MFA_MAX_CHALLENGE_ATTEMPTS; attempt++) { + const res = await verifyWithWrongRecoveryCode(); + + expect(res.status).toBe(401); + expect(await res.text()).toContain("Invalid recovery code"); + } + + const lockedRes = await verifyWithWrongRecoveryCode(); + + expect(lockedRes.status).toBe(401); + expect(lockedRes.headers.get(SET_COOKIE_HEADER)).toBeNull(); + expect(await lockedRes.text()).toContain("Too many failed attempts"); + }); + test("login without MFA enabled still issues cookies directly", async () => { if (!(await requireDb())) { return; @@ -287,7 +396,9 @@ describe("MFA routes", () => { ); expect(res.status).toBe(200); - expect(res.headers.get("set-cookie") ?? "").toContain(AUTH_COOKIE_NAME); + expect(res.headers.get(SET_COOKIE_HEADER) ?? "").toContain( + AUTH_COOKIE_NAME + ); }); }); diff --git a/apps/api/tests/api/billing/billing.service.test.ts b/apps/api/tests/api/billing/billing.service.test.ts index 10e1b1fc..ed1b9209 100644 --- a/apps/api/tests/api/billing/billing.service.test.ts +++ b/apps/api/tests/api/billing/billing.service.test.ts @@ -2,12 +2,14 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { getBillingService } from "../../../src/api/billing/billing.service"; import { env } from "../../../src/config/env"; +import { AUDIT_ACTIONS } from "../../../src/lib/audit-log"; import { ApiError } from "../../../src/lib/errors/api-error"; import { seedVerifiedUser } from "../../helpers/auth"; import { accountPlans, accounts, and, + auditLog, cleanDatabase, db, eq, @@ -247,6 +249,58 @@ describe("billingService.handleWebhookEvent", () => { expect(row?.source).toBe("stripe"); }); + test("checkout.session.completed records a stripe.reconciled audit row for the account", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, customerId, proPlanId } = + await seedAccountWithStripeCustomer(); + + await getBillingService().handleWebhookEvent( + await checkoutSessionCompletedEvent("evt_checkout_audit", { + customer: customerId, + metadata: { accountId, planId: String(proPlanId) }, + }) + ); + + /* + * record() is fire-and-forget (void), so the insert can land after + * handleWebhookEvent resolves — poll briefly instead of asserting + * immediately (see tests/helpers/db.ts header). + */ + const resource = `account:${accountId}`; + let rows: (typeof auditLog.$inferSelect)[] = []; + + for (let attempt = 0; attempt < 20; attempt++) { + rows = await db + .select() + .from(auditLog) + .where( + and( + eq(auditLog.resource, resource), + eq(auditLog.action, AUDIT_ACTIONS.STRIPE_RECONCILED) + ) + ); + + if (rows.length > 0) { + break; + } + + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + expect(rows).toHaveLength(1); + expect(rows[0]?.action).toBe(AUDIT_ACTIONS.STRIPE_RECONCILED); + expect(rows[0]?.userId).toBeNull(); + expect(rows[0]?.metadata).toEqual({ + eventId: "evt_checkout_audit", + eventType: "checkout.session.completed", + planId: proPlanId, + status: "active", + }); + }); + test("checkout.session.completed with missing metadata is a no-op (no throw)", async () => { if (!(await requireDb())) { return; diff --git a/apps/api/tests/config/env/validate.test.ts b/apps/api/tests/config/env/validate.test.ts index df385dd4..6c64dd75 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(() => { @@ -230,6 +232,13 @@ describe("validateEnv", () => { expect(() => validateEnv(testEnv)).toThrow(/JWT_SECRET/); }); + it("JWT_REVOCATION_FAIL_CLOSED defaults to false and parses true", () => { + expect(validateEnv(testEnv).JWT_REVOCATION_FAIL_CLOSED).toBe(false); + + testEnv.JWT_REVOCATION_FAIL_CLOSED = "true"; + expect(validateEnv(testEnv).JWT_REVOCATION_FAIL_CLOSED).toBe(true); + }); + it("accepts production with empty ALLOWED_ORIGINS (same-origin deployment)", () => { testEnv.NODE_ENV = "production"; testEnv.ALLOWED_ORIGINS = ""; @@ -240,6 +249,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/apps/api/tests/config/setup/setup-queues.test.ts b/apps/api/tests/config/setup/setup-queues.test.ts new file mode 100644 index 00000000..3969b6ec --- /dev/null +++ b/apps/api/tests/config/setup/setup-queues.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; + +import { getQueueManager } from "../../../src/config/setup/setup-queues"; + +/* + * setupQueues() itself constructs real BullMQ queues + workers against + * Valkey and registers repeatable jobs — that boot path is exercised + * end-to-end by infra/compose full-stack-smoke. What unit tests can pin + * down is the accessor contract callers rely on for inline fallback. + */ +describe("getQueueManager", () => { + test("returns null before setupQueues() has run (inline-fallback contract)", () => { + expect(getQueueManager()).toBe(null); + }); +}); diff --git a/apps/api/tests/lib/audit-log/audit-log.service.test.ts b/apps/api/tests/lib/audit-log/audit-log.service.test.ts index b7f80fca..79284760 100644 --- a/apps/api/tests/lib/audit-log/audit-log.service.test.ts +++ b/apps/api/tests/lib/audit-log/audit-log.service.test.ts @@ -138,4 +138,92 @@ describe("AuditLogService.record", () => { expect(rows).toHaveLength(1); expect(rows[0]?.resource).toBe("notification:abc-123"); }); + + test("persists targetAccountId when provided", async () => { + if (!(await requireDb())) { + return; + } + + const userId = await insertTestUser(); + const accountId = crypto.randomUUID(); + + await auditLogService.record({ + userId, + action: AUDIT_ACTIONS.BILLING_CHECKOUT_SESSION_CREATED, + targetAccountId: accountId, + metadata: { accountId }, + }); + + const rows = await db + .select() + .from(auditLog) + .where(eq(auditLog.userId, userId)); + + expect(rows).toHaveLength(1); + expect(rows[0]?.targetAccountId).toBe(accountId); + }); +}); + +describe("AuditLogService.listForAccount", () => { + beforeEach(async () => { + if (!(await requireDb())) { + return; + } + + await cleanDatabase(); + }); + + test("returns events matched by targetAccountId without an account resource", async () => { + if (!(await requireDb())) { + return; + } + + const userId = await insertTestUser(); + const accountId = crypto.randomUUID(); + + /* + * The shape billing checkout/portal events write: an entity-free + * record whose only tenant link is targetAccountId. Before that + * column was persisted, these events were invisible to the + * account audit trail. + */ + await auditLogService.record({ + userId, + action: AUDIT_ACTIONS.BILLING_CHECKOUT_SESSION_CREATED, + targetAccountId: accountId, + metadata: { accountId }, + }); + + await auditLogService.record({ + userId, + action: AUDIT_ACTIONS.AUTH_LOGIN_SUCCESS, + }); + + const { entries } = await auditLogService.listForAccount({ accountId }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.action).toBe( + AUDIT_ACTIONS.BILLING_CHECKOUT_SESSION_CREATED + ); + }); + + test("still returns events matched by the account:{id} resource convention", async () => { + if (!(await requireDb())) { + return; + } + + const userId = await insertTestUser(); + const accountId = crypto.randomUUID(); + + await auditLogService.record({ + userId, + action: AUDIT_ACTIONS.ACCOUNT_UPDATED, + resource: `account:${accountId}`, + }); + + const { entries } = await auditLogService.listForAccount({ accountId }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.action).toBe(AUDIT_ACTIONS.ACCOUNT_UPDATED); + }); }); diff --git a/apps/api/tests/lib/jwt/jwt-revocation.test.ts b/apps/api/tests/lib/jwt/jwt-revocation.test.ts index b56d93b5..4e0f8a15 100644 --- a/apps/api/tests/lib/jwt/jwt-revocation.test.ts +++ b/apps/api/tests/lib/jwt/jwt-revocation.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { cacheService } from "../../../src/lib/cache"; import { jwtRevocationService } from "../../../src/lib/jwt"; @@ -85,4 +85,41 @@ describe("jwtRevocationService", () => { ).toBe(true); }); }); + + describe("cache failure policy", () => { + /* + * Default policy is fail-open (JWT_REVOCATION_FAIL_CLOSED=false): + * a cache outage must not turn into a global auth outage. The + * fail-closed branch returns env.JWT_REVOCATION_FAIL_CLOSED + * directly; its env wiring is covered by validate.test.ts (env is + * frozen, so the flag cannot be flipped inside this process). + */ + test("isJtiRevoked fails open when the cache check throws", async () => { + const hasSpy = spyOn(cacheService, "has").mockRejectedValueOnce( + new Error("cache down") + ); + + try { + expect(await jwtRevocationService.isJtiRevoked("test-jti")).toBe(false); + } finally { + hasSpy.mockRestore(); + } + }); + + test("isUserRevokedSince fails open when the cache check throws", async () => { + const getSpy = spyOn(cacheService, "get").mockRejectedValueOnce( + new Error("cache down") + ); + + try { + const iat = Math.floor(Date.now() / 1000) - 100; + + expect( + await jwtRevocationService.isUserRevokedSince("test-user", iat) + ).toBe(false); + } finally { + getSpy.mockRestore(); + } + }); + }); }); 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 0ac2b097..36be7565 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", () => { @@ -49,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") @@ -170,6 +184,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( @@ -604,7 +666,7 @@ describe("checkNoDirectProcessEnv", () => { 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 +685,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 +705,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 +731,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/api/tests/queues/queue-manager.test.ts b/apps/api/tests/queues/queue-manager.test.ts new file mode 100644 index 00000000..2c32eb18 --- /dev/null +++ b/apps/api/tests/queues/queue-manager.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, test } from "bun:test"; +import type { JobsOptions, JobType } from "bullmq"; + +import type { IEmailDeliveryJobData } from "../../src/queues/email-delivery"; +import { + EMAIL_DELIVERY_DEFAULTS, + EMAIL_DELIVERY_JOB_NAME, +} from "../../src/queues/email-delivery/email-delivery.constants"; +import type { INotificationDispatchJobData } from "../../src/queues/notification-dispatch"; +import { + NOTIFICATION_DISPATCH_DEFAULTS, + NOTIFICATION_DISPATCH_JOB_NAME, +} from "../../src/queues/notification-dispatch/notification-dispatch.constants"; +import { QueueManager } from "../../src/queues/queue-manager"; +import type { IWebPushDeliveryJobData } from "../../src/queues/web-push-delivery"; +import { + WEB_PUSH_DELIVERY_DEFAULTS, + WEB_PUSH_DELIVERY_JOB_NAME, +} from "../../src/queues/web-push-delivery/web-push-delivery.constants"; + +/* + * QueueManager only sees the structural surface it declares + * (IManagedQueue / IEnqueueableQueue / IManagedWorker), so plain stubs + * exercise the full lifecycle without a Valkey connection — these tests + * run everywhere, including coverage runs with no local stack. + */ + +interface IAddCall { + jobName: string; + data: unknown; + opts: JobsOptions | undefined; +} + +interface IQueueStub { + readonly name: string; + add: (jobName: string, data: TData, opts?: JobsOptions) => Promise; + getJobCounts: (...states: JobType[]) => Promise>; + close: () => Promise; +} + +const makeQueueStub = ( + name: string, + counts: Record = {} +): { + stub: IQueueStub; + addCalls: IAddCall[]; + wasClosed: () => boolean; +} => { + const addCalls: IAddCall[] = []; + let closed = false; + + return { + stub: { + name, + add: (jobName: string, data: TData, opts?: JobsOptions) => { + addCalls.push({ jobName, data, opts }); + + return Promise.resolve(undefined); + }, + getJobCounts: () => Promise.resolve(counts), + close: () => { + closed = true; + + return Promise.resolve(); + }, + }, + addCalls, + wasClosed: () => closed, + }; +}; + +const makeWorkerStub = (): { + stub: { close: () => Promise }; + wasClosed: () => boolean; +} => { + let closed = false; + + return { + stub: { + close: () => { + closed = true; + + return Promise.resolve(); + }, + }, + wasClosed: () => closed, + }; +}; + +const EMAIL_JOB: IEmailDeliveryJobData = { + to: "user@example.test", + subject: "Hello", + templatePath: "welcome", +}; + +const DISPATCH_JOB: INotificationDispatchJobData = { + eventType: "account.invited", + recipientUserId: "user-1", + payload: { accountId: "acc-1" }, +}; + +const WEB_PUSH_JOB: IWebPushDeliveryJobData = { + recipientUserId: "user-1", + notificationDeliveryId: "delivery-1", + title: "Hi", + body: "There", + url: null, +}; + +const buildFixture = (withWebPush: boolean) => { + const accountMaintenance = makeQueueStub("account-maintenance"); + const emailDelivery = makeQueueStub("email-delivery", { + waiting: 2, + failed: 1, + }); + const notificationDispatch = makeQueueStub( + "notification-dispatch" + ); + const notificationMaintenance = makeQueueStub("notification-maintenance"); + const webPushDelivery = + makeQueueStub("web-push-delivery"); + const workers = { + accountMaintenance: makeWorkerStub(), + emailDelivery: makeWorkerStub(), + notificationDispatch: makeWorkerStub(), + notificationMaintenance: makeWorkerStub(), + webPushDelivery: makeWorkerStub(), + }; + + const manager = new QueueManager({ + accountMaintenanceQueue: accountMaintenance.stub, + accountMaintenanceWorker: workers.accountMaintenance.stub, + emailDeliveryQueue: emailDelivery.stub, + emailDeliveryWorker: workers.emailDelivery.stub, + notificationDispatchQueue: notificationDispatch.stub, + notificationDispatchWorker: workers.notificationDispatch.stub, + notificationMaintenanceQueue: notificationMaintenance.stub, + notificationMaintenanceWorker: workers.notificationMaintenance.stub, + webPushDeliveryQueue: withWebPush ? webPushDelivery.stub : null, + webPushDeliveryWorker: withWebPush ? workers.webPushDelivery.stub : null, + }); + + return { + manager, + accountMaintenance, + emailDelivery, + notificationDispatch, + notificationMaintenance, + webPushDelivery, + workers, + }; +}; + +describe("QueueManager.enqueueEmailDelivery", () => { + test("adds the job with the retry envelope from the queue defaults", async () => { + const fixture = buildFixture(false); + + await fixture.manager.enqueueEmailDelivery(EMAIL_JOB); + + expect(fixture.emailDelivery.addCalls).toHaveLength(1); + + const call = fixture.emailDelivery.addCalls[0]; + + expect(call?.jobName).toBe(EMAIL_DELIVERY_JOB_NAME); + expect(call?.data).toEqual(EMAIL_JOB); + expect(call?.opts).toEqual({ + attempts: EMAIL_DELIVERY_DEFAULTS.attempts, + backoff: { + type: "exponential", + delay: EMAIL_DELIVERY_DEFAULTS.backoffDelayMs, + }, + removeOnComplete: { + age: EMAIL_DELIVERY_DEFAULTS.removeOnCompleteAge, + count: EMAIL_DELIVERY_DEFAULTS.removeOnCompleteCount, + }, + removeOnFail: false, + }); + }); +}); + +describe("QueueManager.enqueueNotificationDispatch", () => { + test("adds the job with the retry envelope from the queue defaults", async () => { + const fixture = buildFixture(false); + + await fixture.manager.enqueueNotificationDispatch(DISPATCH_JOB); + + expect(fixture.notificationDispatch.addCalls).toHaveLength(1); + + const call = fixture.notificationDispatch.addCalls[0]; + + expect(call?.jobName).toBe(NOTIFICATION_DISPATCH_JOB_NAME); + expect(call?.opts?.attempts).toBe(NOTIFICATION_DISPATCH_DEFAULTS.attempts); + }); +}); + +describe("QueueManager.enqueueWebPushDelivery", () => { + test("is a logged no-op when web push is not configured", async () => { + const fixture = buildFixture(false); + + await fixture.manager.enqueueWebPushDelivery(WEB_PUSH_JOB); + + expect(fixture.webPushDelivery.addCalls).toHaveLength(0); + }); + + test("adds the job when the web push queue exists", async () => { + const fixture = buildFixture(true); + + await fixture.manager.enqueueWebPushDelivery(WEB_PUSH_JOB); + + expect(fixture.webPushDelivery.addCalls).toHaveLength(1); + + const call = fixture.webPushDelivery.addCalls[0]; + + expect(call?.jobName).toBe(WEB_PUSH_DELIVERY_JOB_NAME); + expect(call?.opts?.attempts).toBe(WEB_PUSH_DELIVERY_DEFAULTS.attempts); + }); +}); + +describe("QueueManager.getStats", () => { + test("reports the four core queues, defaulting missing counts to 0", async () => { + const fixture = buildFixture(false); + + const stats = await fixture.manager.getStats(); + + expect(stats.map((row) => row.name)).toEqual([ + "account-maintenance", + "email-delivery", + "notification-dispatch", + "notification-maintenance", + ]); + + const email = stats.find((row) => row.name === "email-delivery"); + + expect(email?.counts).toEqual({ + waiting: 2, + active: 0, + completed: 0, + failed: 1, + delayed: 0, + paused: 0, + }); + }); + + test("includes web-push-delivery when configured", async () => { + const fixture = buildFixture(true); + + const stats = await fixture.manager.getStats(); + + expect(stats.map((row) => row.name)).toContain("web-push-delivery"); + expect(stats).toHaveLength(5); + }); +}); + +describe("QueueManager.close", () => { + test("closes every core queue and worker", async () => { + const fixture = buildFixture(false); + + await fixture.manager.close(); + + expect(fixture.accountMaintenance.wasClosed()).toBe(true); + expect(fixture.emailDelivery.wasClosed()).toBe(true); + expect(fixture.notificationDispatch.wasClosed()).toBe(true); + expect(fixture.notificationMaintenance.wasClosed()).toBe(true); + expect(fixture.workers.accountMaintenance.wasClosed()).toBe(true); + expect(fixture.workers.emailDelivery.wasClosed()).toBe(true); + expect(fixture.workers.notificationDispatch.wasClosed()).toBe(true); + expect(fixture.workers.notificationMaintenance.wasClosed()).toBe(true); + // not configured — must not be touched + expect(fixture.webPushDelivery.wasClosed()).toBe(false); + expect(fixture.workers.webPushDelivery.wasClosed()).toBe(false); + }); + + test("also closes the web push pair when configured", async () => { + const fixture = buildFixture(true); + + await fixture.manager.close(); + + expect(fixture.webPushDelivery.wasClosed()).toBe(true); + expect(fixture.workers.webPushDelivery.wasClosed()).toBe(true); + }); +}); 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..2d5a2224 100644 --- a/apps/docs/DEPLOY.md +++ b/apps/docs/DEPLOY.md @@ -20,10 +20,10 @@ This file documents the wire-up so future-you (or a teammate) can rebuild it fro | ---------------------- | ------------------- | | Production branch | `main` | | Framework preset | Astro | - | Build command | `bun run build` | + | Build command | `bun run build:ci` | | Build output directory | `dist` | | Root directory | `apps/docs` | - | Node version | `22` (via `.nvmrc`) | + | Node version | `24` (via `.nvmrc`) | | Environment variables | _(none)_ | 4. **Custom domain.** @@ -47,7 +47,7 @@ The build runs Pagefind automatically (Starlight bundles it), so search works on ## How deploys work -- Push to `main` → Cloudflare auto-builds with `bun run build` (Astro only; committed JSON in `src/data/` is the catalog source of truth). +- Push to `main` → Cloudflare auto-builds with `bun run build:ci` (docs-data freshness check + build + fragment-link gate). The committed JSON in `src/data/` is the catalog source of truth; the check fails the deploy when it drifts from `apps/api` / `apps/ui`, instead of silently serving stale catalogs. From the monorepo checkout the sibling defaults resolve without extra env vars. - Manual production deploy: `bun run deploy` runs `build:ci` (docs-data check + build) before `wrangler deploy`. From the monorepo, defaults use `apps/ui` and `apps/api` (override with `BORINGSTACK_UI_DIR` / `BORINGSTACK_API_DIR`). - Pushes to other branches → preview deployment at `.boringstack-docs.pages.dev`. - PRs from forks get preview deployments too (CF Pages comments the URL on the PR). 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.