diff --git a/.github/workflows/agent-verification.yml b/.github/workflows/agent-verification.yml index 70d9e4d3..2eef212b 100644 --- a/.github/workflows/agent-verification.yml +++ b/.github/workflows/agent-verification.yml @@ -25,6 +25,12 @@ on: permissions: contents: read pull-requests: read + +# PR runs supersede each other; main runs always finish so every merged commit is verified. +concurrency: + group: agent-verification-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: verify: name: agent verification contract diff --git a/.github/workflows/apps-api-ci.yml b/.github/workflows/apps-api-ci.yml index 98a1bb2b..5fe41c58 100644 --- a/.github/workflows/apps-api-ci.yml +++ b/.github/workflows/apps-api-ci.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - "apps/api/**" + - "packages/**" - ".github/workflows/apps-api-ci.yml" pull_request: branches: [main] @@ -16,6 +17,7 @@ concurrency: permissions: contents: read + pull-requests: read jobs: validate: @@ -85,6 +87,7 @@ jobs: filters: | code: - 'apps/api/**' + - 'packages/**' - '.github/workflows/apps-api-ci.yml' - name: Set up Bun @@ -122,13 +125,10 @@ jobs: if: steps.filter.outputs.code == 'true' run: bun run check - - name: Tests - if: steps.filter.outputs.code == 'true' - env: - REQUIRE_INTEGRATION_DB: "true" - run: bun run test - - - name: Test coverage report + # One run: test:coverage runs the same suite as `bun run test` (same + # preload, NODE_ENV, LOG_LEVEL, and the same forbidden-warning-output + # gate) and adds the coverage floor. + - name: Tests and coverage if: steps.filter.outputs.code == 'true' env: REQUIRE_INTEGRATION_DB: "true" diff --git a/.github/workflows/apps-api-openapi-drift.yml b/.github/workflows/apps-api-openapi-drift.yml index 748c617a..ae8ca511 100644 --- a/.github/workflows/apps-api-openapi-drift.yml +++ b/.github/workflows/apps-api-openapi-drift.yml @@ -16,6 +16,7 @@ concurrency: permissions: contents: read + pull-requests: read jobs: drift: diff --git a/.github/workflows/apps-api-release.yml b/.github/workflows/apps-api-release.yml index 5c52fb8f..1a2f1974 100644 --- a/.github/workflows/apps-api-release.yml +++ b/.github/workflows/apps-api-release.yml @@ -14,6 +14,7 @@ on: tags: ["v*.*.*"] paths: - "apps/api/**" + - "packages/**" - ".github/workflows/apps-api-release.yml" concurrency: @@ -72,6 +73,8 @@ jobs: with: context: apps/api file: apps/api/Dockerfile.prod + build-contexts: | + packages=packages push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/apps-api-security-deps.yml b/.github/workflows/apps-api-security-deps.yml index 53b427e2..f4fe6b16 100644 --- a/.github/workflows/apps-api-security-deps.yml +++ b/.github/workflows/apps-api-security-deps.yml @@ -17,6 +17,7 @@ concurrency: permissions: contents: read + pull-requests: read security-events: write jobs: diff --git a/.github/workflows/apps-api-security-spec.yml b/.github/workflows/apps-api-security-spec.yml index da5413d3..ee76c8ff 100644 --- a/.github/workflows/apps-api-security-spec.yml +++ b/.github/workflows/apps-api-security-spec.yml @@ -28,6 +28,7 @@ on: permissions: contents: read + pull-requests: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/apps-docs-linkcheck.yml b/.github/workflows/apps-docs-linkcheck.yml index d49caf2e..4f23cf6f 100644 --- a/.github/workflows/apps-docs-linkcheck.yml +++ b/.github/workflows/apps-docs-linkcheck.yml @@ -50,6 +50,7 @@ on: permissions: contents: read + pull-requests: read concurrency: group: apps-docs-linkcheck-docs-${{ github.ref }} diff --git a/.github/workflows/apps-docs-security-deps.yml b/.github/workflows/apps-docs-security-deps.yml index 470facb8..480ebc2f 100644 --- a/.github/workflows/apps-docs-security-deps.yml +++ b/.github/workflows/apps-docs-security-deps.yml @@ -17,6 +17,7 @@ concurrency: permissions: contents: read + pull-requests: read security-events: write jobs: diff --git a/.github/workflows/apps-site-security-deps.yml b/.github/workflows/apps-site-security-deps.yml index 35360b46..12b14e7e 100644 --- a/.github/workflows/apps-site-security-deps.yml +++ b/.github/workflows/apps-site-security-deps.yml @@ -17,6 +17,7 @@ concurrency: permissions: contents: read + pull-requests: read security-events: write jobs: diff --git a/.github/workflows/apps-site-validate.yml b/.github/workflows/apps-site-validate.yml index f14d4dac..40eb6fed 100644 --- a/.github/workflows/apps-site-validate.yml +++ b/.github/workflows/apps-site-validate.yml @@ -16,6 +16,7 @@ concurrency: permissions: contents: read + pull-requests: read jobs: validate: diff --git a/.github/workflows/apps-ui-bundle-diff.yml b/.github/workflows/apps-ui-bundle-diff.yml index c03efd5a..285b5597 100644 --- a/.github/workflows/apps-ui-bundle-diff.yml +++ b/.github/workflows/apps-ui-bundle-diff.yml @@ -45,6 +45,7 @@ jobs: filters: | ui: - 'apps/ui/src/**' + - 'packages/**' - 'apps/ui/package.json' - 'apps/ui/bun.lock' - 'apps/ui/vite.config.ts' diff --git a/.github/workflows/apps-ui-release.yml b/.github/workflows/apps-ui-release.yml index 58cb4fa7..73d26d8e 100644 --- a/.github/workflows/apps-ui-release.yml +++ b/.github/workflows/apps-ui-release.yml @@ -18,6 +18,7 @@ on: branches: [main] paths: - "apps/ui/**" + - "packages/**" - ".github/workflows/apps-ui-release.yml" concurrency: @@ -108,6 +109,8 @@ jobs: with: context: apps/ui file: apps/ui/Dockerfile.prod + build-contexts: | + packages=packages push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/apps-ui-security-deps.yml b/.github/workflows/apps-ui-security-deps.yml index c02bbf7c..5d2bdec4 100644 --- a/.github/workflows/apps-ui-security-deps.yml +++ b/.github/workflows/apps-ui-security-deps.yml @@ -17,6 +17,7 @@ concurrency: permissions: contents: read + pull-requests: read security-events: write jobs: diff --git a/.github/workflows/apps-ui-validate.yml b/.github/workflows/apps-ui-validate.yml index a9f15a84..87cf1b49 100644 --- a/.github/workflows/apps-ui-validate.yml +++ b/.github/workflows/apps-ui-validate.yml @@ -16,6 +16,7 @@ concurrency: permissions: contents: read + pull-requests: read jobs: validate: diff --git a/.github/workflows/infra-bootstrap-validate.yml b/.github/workflows/infra-bootstrap-validate.yml index 036055cd..dc08a205 100644 --- a/.github/workflows/infra-bootstrap-validate.yml +++ b/.github/workflows/infra-bootstrap-validate.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + pull-requests: read concurrency: group: infra-bootstrap-validate-${{ github.ref }} diff --git a/.github/workflows/infra-compose-full-stack-smoke.yml b/.github/workflows/infra-compose-full-stack-smoke.yml index 0f8257d4..ee7c58a8 100644 --- a/.github/workflows/infra-compose-full-stack-smoke.yml +++ b/.github/workflows/infra-compose-full-stack-smoke.yml @@ -17,6 +17,7 @@ on: - "infra/compose/**" - "apps/api/**" - "apps/ui/**" + - "packages/**" - ".github/workflows/infra-compose-full-stack-smoke.yml" # Intentionally no `paths:` filter on pull_request: this job is a required # check in branch protection, and a path-filtered workflow that never runs @@ -35,6 +36,7 @@ concurrency: permissions: contents: read + pull-requests: read jobs: smoke: @@ -58,6 +60,7 @@ jobs: code: - 'apps/api/**' - 'apps/ui/**' + - 'packages/**' - 'infra/compose/**' - '.github/workflows/infra-compose-full-stack-smoke.yml' diff --git a/.github/workflows/infra-compose-validate-compose.yml b/.github/workflows/infra-compose-validate-compose.yml index 1b912069..91b2bc07 100644 --- a/.github/workflows/infra-compose-validate-compose.yml +++ b/.github/workflows/infra-compose-validate-compose.yml @@ -25,6 +25,7 @@ on: # lockfile edits. - "apps/api/**" - "apps/ui/**" + - "packages/**" pull_request: branches: [main] @@ -34,6 +35,7 @@ concurrency: permissions: contents: read + pull-requests: read jobs: compose-config: @@ -478,8 +480,10 @@ jobs: code: # Dockerfile.prod runs COPY . .: the whole app tree is # image input, so app-only source changes must rebuild too. + # It also COPYs the packages/ named context. - 'apps/api/**' - 'apps/ui/**' + - 'packages/**' - 'infra/compose/**' - '.github/workflows/infra-compose-validate-compose.yml' @@ -493,6 +497,8 @@ jobs: with: context: apps/api file: apps/api/Dockerfile.prod + build-contexts: | + packages=packages push: false load: true tags: boringstack-api:pr-build @@ -505,6 +511,8 @@ jobs: with: context: apps/ui file: apps/ui/Dockerfile.prod + build-contexts: | + packages=packages push: false load: true tags: boringstack-ui:pr-build diff --git a/.github/workflows/infra-k3s-validate.yml b/.github/workflows/infra-k3s-validate.yml index 3944f6cb..309bf653 100644 --- a/.github/workflows/infra-k3s-validate.yml +++ b/.github/workflows/infra-k3s-validate.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + pull-requests: read concurrency: group: infra-k3s-validate-${{ github.ref }} diff --git a/.github/workflows/production-release.yml b/.github/workflows/production-release.yml index 6a1b924d..f0bf89d2 100644 --- a/.github/workflows/production-release.yml +++ b/.github/workflows/production-release.yml @@ -71,6 +71,8 @@ jobs: with: context: apps/api file: apps/api/Dockerfile.prod + build-contexts: | + packages=packages push: true tags: ghcr.io/${{ github.repository }}-api:candidate-${{ github.sha }} labels: org.opencontainers.image.revision=${{ github.sha }} @@ -82,6 +84,8 @@ jobs: with: context: apps/api file: apps/api/Dockerfile.prod + build-contexts: | + packages=packages target: migrations push: true tags: ghcr.io/${{ github.repository }}-migrations:candidate-${{ github.sha }} @@ -94,6 +98,8 @@ jobs: with: context: apps/ui file: apps/ui/Dockerfile.prod + build-contexts: | + packages=packages push: true tags: ghcr.io/${{ github.repository }}-ui:candidate-${{ github.sha }} # Same-origin API by default. Public URL, Sentry and VAPID values come diff --git a/apps/api/.env.example b/apps/api/.env.example index c1103c2d..8c1aa84d 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -182,6 +182,8 @@ STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= STRIPE_PRICE_ID_FREE= STRIPE_PRICE_ID_PRO= +# Optional yearly price for the Pro plan. Empty = yearly checkout is not offered. +STRIPE_PRICE_ID_PRO_YEARLY= # Queues (BullMQ) + cache. The VALKEY_* + provider keys are required # only when the relevant feature flag is true. QUEUES_ENABLED must be diff --git a/apps/api/Dockerfile.prod b/apps/api/Dockerfile.prod index c5ec5d4c..96cd0377 100644 --- a/apps/api/Dockerfile.prod +++ b/apps/api/Dockerfile.prod @@ -1,7 +1,8 @@ -# Multi-stage build for production. Plain `docker build` works — no BuildKit -# required. If you have BuildKit available, you can wrap the bun install -# RUN in `--mount=type=cache,target=/root/.bun/install/cache` for faster -# rebuilds; we keep the portable version here. +# Multi-stage build for production. Needs BuildKit (the default since Docker +# 23) for the `packages` named context: shared code under the repo's packages/ +# directory. Build from apps/api with: +# docker build --build-context packages=../../packages -f Dockerfile.prod . +# Compose, the release workflows and the infra gate pass the same context. FROM oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f AS builder WORKDIR /app @@ -16,6 +17,16 @@ RUN bun install --frozen-lockfile --production COPY . . +# Shared code from packages/ (BuildKit named context `packages`, see the header). +# The app reaches it as ../../packages from /app, so it lands at /packages. +COPY --from=packages . /packages +# Each shared package installs its own runtime dependencies, as +# apps/ui/docs/agents/shared-packages.md requires. Packages with none skip this. +RUN for pkg in /packages/*/; do \ + [ -f "$pkg/package.json" ] || continue; \ + (cd "$pkg" && bun install --production); \ + done + # Builds the email-template JSONs into src/templates/email/dist/, then bundles # the API into /app/dist. Both are needed at runtime. RUN bun run build @@ -48,6 +59,16 @@ COPY drizzle ./drizzle COPY scripts/db ./scripts/db COPY src ./src +# Shared code from packages/ (BuildKit named context `packages`, see the header). +# The app reaches it as ../../packages from /app, so it lands at /packages. +COPY --from=packages . /packages +# Each shared package installs its own runtime dependencies, as +# apps/ui/docs/agents/shared-packages.md requires. Packages with none skip this. +RUN for pkg in /packages/*/; do \ + [ -f "$pkg/package.json" ] || continue; \ + (cd "$pkg" && bun install --production); \ + done + # Set after install so `bun install` above still sees devDependencies. ENV NODE_ENV=production diff --git a/apps/api/docs/agents/billing.md b/apps/api/docs/agents/billing.md index bfdefb55..b6a84f59 100644 --- a/apps/api/docs/agents/billing.md +++ b/apps/api/docs/agents/billing.md @@ -15,9 +15,84 @@ The `stripe-webhooks` plugin enforces: ## Configuration -Plan IDs come from env (`STRIPE_PRICE_ID_FREE`, `STRIPE_PRICE_ID_PRO`) -— never hardcoded. +Price IDs come from env (`STRIPE_PRICE_ID_FREE`, `STRIPE_PRICE_ID_PRO`, +and the optional `STRIPE_PRICE_ID_PRO_YEARLY`), never hardcoded. An empty +yearly ID means yearly is not offered. The validator refuses a yearly ID equal +to the monthly one, since that would make the webhook's price-to-plan mapping +ambiguous. `Stripe never knows about feature keys.` The app derives feature gating from `account_plans` at request time; Stripe only triggers the plan-key update via webhook. + +## Plan catalog + +`billing.plans.ts` is the single declaration of the built-in plans: name, +default flag, the price ID per interval and the feature values. Pro's values +are typed against `FEATURES` (`PlanFeatureValues` requires a key for every +feature), so adding a feature key fails the type check until Pro gets a value. +`billing.plans.test.ts` repeats that check at runtime. `PRO_SEAT_LIMIT` (10) is +the place to change the Pro seat limit. + +Free carries no `plan_features` rows: a missing key resolves to its `FEATURES` +default. `ensureConfiguredPlans` upserts the plan rows and their feature rows on +every call, and deletes stored feature rows the catalog does not assign to a +built-in plan. Consequence: a hand-edit to Free or Pro in the database is +overwritten on the next plan read. Use `account_feature_overrides` for +per-account exceptions. + +## Public plan list + +`GET /api/v1/billing/plans` is public (no auth) so a pricing page can read it. +It only touches the database and env-derived catalog, never the Stripe client. +When `BILLING_ENABLED=false` it still answers, with `purchasableIntervals: []` +on every plan. The other billing routes keep returning 404 while billing is +disabled (`getBillingService()` gates them). + +`purchasableIntervals` lists the intervals a checkout can be started for: billing +enabled, the plan is not the default, and that interval's price is set. + +## Checkout + +`POST /billing/stripe/checkout-session` takes an optional `interval` +(`month` default, or `year`). Errors: + +- Default (Free) plan: 400 `invalidInput`, before any Stripe call. +- Interval with no configured price: 400 `invalidInput` on `interval`. + +The account row is locked (`SELECT ... FOR UPDATE`) for the whole sequence, +so two quick clicks serialize. Inside the lock: + +1. A local `account_plans` row that is not `canceled` rejects with 409 + ("Manage your existing subscription from Billing"). +2. A Stripe subscription for the customer that is not `canceled` or + `incomplete_expired` rejects with 409. This covers the window before the + webhook lands. +3. An open Checkout session for the same account, plan and price is returned + instead of creating another. The session's `metadata.priceId` carries the + price for that match. + +The Stripe client is injectable (`new BillingService({ stripe, settings })`), +so tests run against a real `Stripe` client with a fake HTTP layer +(`createFakeStripe` in `tests/helpers/stripe-webhook-fixtures.ts`). + +## Payment status + +`checkout.session.completed` sets `active` only when `payment_status` is +`paid` or `no_payment_required`. Otherwise (`unpaid`, as with bank debit) the +row is `incomplete`, which does not entitle (`isPlanEntitling`). + +- `checkout.session.async_payment_succeeded`: sets `active` on the matching row. +- `checkout.session.async_payment_failed`: sets `incomplete` on the matching row. + +When the row already belongs to the same subscription, a `completed` event only +records its event id and timestamp. The `customer.subscription.*` events stay +the authority for status. The async events set status because they are the only +signal that a delayed payment settled or failed. + +## Yearly and monthly price mapping + +`customer.subscription.created` and `.updated` map a subscription's price to a +plan through the catalog: either Pro price maps to Pro. The stored +`plans.stripe_price_id` holds the monthly price only, so the mapping must not +read that column. diff --git a/apps/api/docs/agents/testing.md b/apps/api/docs/agents/testing.md index bc2e953a..9881a848 100644 --- a/apps/api/docs/agents/testing.md +++ b/apps/api/docs/agents/testing.md @@ -23,8 +23,14 @@ to map 1:1 to a source file (catches orphan tests after refactors). ## Cleanup -`cleanDatabase()` in a `beforeEach` wipes user-data tables. Add new -tables to `TRUNCATE_TARGETS` when you create them. +`cleanDatabase()` in a `beforeEach` wipes user-data tables. Add each new +table to `CLEANUP_TARGETS` in `tests/helpers/db.ts`, or to `REFERENCE_TABLES` +if it holds seed or catalog data that tests read but never write. +`tests/clients/postgres/schema/index.test.ts` derives every table from the +Drizzle schema and fails when one is in neither list. + +DB-backed tests run only when `TEST_DATABASE_URL` is set; without it they +skip, so a green local run without it says nothing about them. CI sets it. ## Running locally diff --git a/apps/api/scripts/lint-meta/RULES.md b/apps/api/scripts/lint-meta/RULES.md index eea8692e..5521b04d 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -24,6 +24,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `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-paths-filter-parity` | ci | no | Workflows pairing push.paths with a dorny/paths-filter gate must keep the two path sets mutually covered. | +| `github-actions-paths-filter-permissions` | ci | no | Jobs running dorny/paths-filter must have pull-requests: read in effect (job-level or workflow-level), or PR checks fail in private repositories. | | `github-actions-pip-install-pinned` | ci | no | Workflow pip install steps must pin package versions with == so CI tools cannot drift with PyPI releases. | | `github-actions-runner-pinned` | ci | no | Workflows must pin runner images to an explicit OS version instead of floating *-latest labels, and guard vars.CI_RUNNER against fork pull requests. | | `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. | diff --git a/apps/api/scripts/lint-meta/cli.ts b/apps/api/scripts/lint-meta/cli.ts index 3d91caf0..74453564 100644 --- a/apps/api/scripts/lint-meta/cli.ts +++ b/apps/api/scripts/lint-meta/cli.ts @@ -36,6 +36,7 @@ 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 { checkWorkflowPathsFilterParity } from "./rules/ci/github-actions-paths-filter-parity"; +import { checkWorkflowPathsFilterPermissions } from "./rules/ci/github-actions-paths-filter-permissions"; import { checkWorkflowPipInstallPinned } from "./rules/ci/github-actions-pip-install-pinned"; import { checkWorkflowRunnerPinned } from "./rules/ci/github-actions-runner-pinned"; import { checkWorkflowSecurityNoCancel } from "./rules/ci/github-actions-security-no-cancel"; @@ -157,6 +158,7 @@ export { checkWorkflowConcurrencyExplicit, checkWorkflowExpressionSyntax, checkWorkflowPathsFilterParity, + checkWorkflowPathsFilterPermissions, checkWorkflowPipInstallPinned, checkWorkflowRunnerPinned, checkWorkflowSecurityNoCancel, diff --git a/apps/api/scripts/lint-meta/registry.ts b/apps/api/scripts/lint-meta/registry.ts index 5c2c6182..df907d07 100644 --- a/apps/api/scripts/lint-meta/registry.ts +++ b/apps/api/scripts/lint-meta/registry.ts @@ -5,6 +5,7 @@ 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 { githubActionsPathsFilterParityRule } from "./rules/ci/github-actions-paths-filter-parity"; +import { githubActionsPathsFilterPermissionsRule } from "./rules/ci/github-actions-paths-filter-permissions"; import { githubActionsPipInstallPinnedRule } from "./rules/ci/github-actions-pip-install-pinned"; import { githubActionsRunnerPinnedRule } from "./rules/ci/github-actions-runner-pinned"; import { githubActionsPermissionsRule } from "./rules/ci/github-actions-permissions"; @@ -51,6 +52,7 @@ export const META_RULES: readonly IMetaRule[] = [ githubActionsBunCacheRule, githubActionsConcurrencyExplicitRule, githubActionsPathsFilterParityRule, + githubActionsPathsFilterPermissionsRule, githubActionsPipInstallPinnedRule, githubActionsRunnerPinnedRule, githubActionsSecurityNoCancelRule, diff --git a/apps/api/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts b/apps/api/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts index 5e35cc4b..6c940dca 100644 --- a/apps/api/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts +++ b/apps/api/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts @@ -32,7 +32,11 @@ export function checkWorkflowConcurrencyExplicit(file: string): IViolation[] { continue; } - if (/^\s+cancel-in-progress:\s*(?:true|false)\s*(?:#.*)?$/u.test(line)) { + if ( + /^\s+cancel-in-progress:\s*(?:true|false|\$\{\{.+\}\})\s*(?:#.*)?$/u.test( + line + ) + ) { hasCancelKey = true; } } diff --git a/apps/api/scripts/lint-meta/rules/ci/github-actions-paths-filter-permissions.ts b/apps/api/scripts/lint-meta/rules/ci/github-actions-paths-filter-permissions.ts new file mode 100644 index 00000000..f54f4029 --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/ci/github-actions-paths-filter-permissions.ts @@ -0,0 +1,190 @@ +import { readFileSync } from "node:fs"; + +import type { IMetaRule, IViolation } from "../../types"; + +const RULE_ID = "github-actions-paths-filter-permissions"; + +const PATHS_FILTER_USES_REGEX = /uses:\s*dorny\/paths-filter@/u; +const PERMISSION_LINE_REGEX = + /^\s*([\w-]+)\s*:\s*(read|write|none)\s*(?:#.*)?$/u; +const INLINE_PERMISSIONS_REGEX = /^\s*permissions:\s*(\S.*?)\s*(?:#.*)?$/u; +const FLOW_ENTRY_REGEX = /([\w-]+)\s*:\s*(read|write|none)/gu; + +/* + * dorny/paths-filter lists PR files through the REST API on pull_request + * events. That call needs `pull-requests: read`, which a private repository + * does not grant by default, so the gate dies on its first step with + * "Resource not accessible by integration". Public repos never show it, which + * is why template workflows looked fine until a product went private. + * + * GitHub resolves permissions per job: a job-level `permissions:` block + * replaces the workflow-level one entirely (it does not merge), so the rule + * resolves the effective grant for each job that runs the filter. + */ + +type Grants = "all" | ReadonlyMap; + +function indentOf(line: string): number { + return line.length - line.trimStart().length; +} + +function isBlankOrComment(line: string): boolean { + const trimmed = line.trim(); + + return trimmed === "" || trimmed.startsWith("#"); +} + +/* + * Reads a `permissions:` key whose value is either on the key line (`read-all`, + * `write-all` or a flow mapping) or a nested block below it. + */ +function parsePermissions(lines: readonly string[], keyIndex: number): Grants { + const keyLine = lines[keyIndex] ?? ""; + const inline = INLINE_PERMISSIONS_REGEX.exec(keyLine)?.[1]; + + if (inline !== undefined) { + return inline === "read-all" || inline === "write-all" + ? "all" + : new Map( + [...inline.matchAll(FLOW_ENTRY_REGEX)].map((m) => [ + m[1] ?? "", + m[2] ?? "", + ]) + ); + } + + const baseIndent = indentOf(keyLine); + const grants = new Map(); + + for (let index = keyIndex + 1; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + + if (isBlankOrComment(line)) { + continue; + } + + if (indentOf(line) <= baseIndent) { + break; + } + + const entry = PERMISSION_LINE_REGEX.exec(line); + + if (entry !== null) { + grants.set(entry[1] ?? "", entry[2] ?? ""); + } + } + + return grants; +} + +function grantsPullRequestRead(grants: Grants): boolean { + if (grants === "all") { + return true; + } + + const level = grants.get("pull-requests"); + + return level === "read" || level === "write"; +} + +interface IJobSpan { + readonly id: string; + readonly start: number; + readonly end: number; +} + +/* + * Line-based job split (same idiom as the other github-actions rules): a job + * key is exactly two spaces under `jobs:`, and a job ends at the next one or at + * the first top-level key. + */ +function collectJobs(lines: readonly string[]): IJobSpan[] { + const jobsIndex = lines.findIndex((line) => /^jobs:\s*(?:#.*)?$/u.test(line)); + + if (jobsIndex === -1) { + return []; + } + + const starts: { id: string; index: number }[] = []; + + for (let index = jobsIndex + 1; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + + if (/^\S/u.test(line)) { + break; + } + + const match = /^ {2}([\w.-]+):\s*(?:#.*)?$/u.exec(line); + + if (match !== null) { + starts.push({ id: match[1] ?? "", index }); + } + } + + return starts.map((start, position) => { + const next = starts[position + 1]; + let end = next?.index ?? lines.length; + + for (let index = start.index + 1; index < end; index += 1) { + if (/^\S/u.test(lines[index] ?? "")) { + end = index; + break; + } + } + + return { id: start.id, start: start.index, end }; + }); +} + +export function checkWorkflowPathsFilterPermissions( + file: string +): IViolation[] { + const lines = readFileSync(file, "utf8").split("\n"); + const workflowIndex = lines.findIndex((line) => + line.startsWith("permissions:") + ); + const workflowGrants: Grants = + workflowIndex === -1 ? new Map() : parsePermissions(lines, workflowIndex); + const violations: IViolation[] = []; + + for (const job of collectJobs(lines)) { + const body = lines.slice(job.start, job.end); + const usesFilter = body.some((line) => PATHS_FILTER_USES_REGEX.test(line)); + + if (!usesFilter) { + continue; + } + + const jobPermissionIndex = body.findIndex((line) => + /^ {4}permissions:/u.test(line) + ); + const effective = + jobPermissionIndex === -1 + ? workflowGrants + : parsePermissions(lines, job.start + jobPermissionIndex); + + if (!grantsPullRequestRead(effective)) { + violations.push({ + file, + rule: RULE_ID, + message: `job '${job.id}' runs dorny/paths-filter but its effective permissions do not grant \`pull-requests: read\`. On pull_request events the filter reads PR files through the REST API, which fails with "Resource not accessible by integration" in private repositories. Add it to the job's permissions block (or the workflow's, when the job has none).`, + }); + } + } + + return violations; +} + +/** + * paths-filter needs `pull-requests: read` on pull_request events; without it + * every PR check dies on step one in a private repository while main stays green. + */ +export const githubActionsPathsFilterPermissionsRule: IMetaRule = { + id: RULE_ID, + category: "ci", + description: + "Jobs running dorny/paths-filter must have pull-requests: read in effect (job-level or workflow-level), or PR checks fail in private repositories.", + run({ workflowFiles }) { + return workflowFiles.flatMap(checkWorkflowPathsFilterPermissions); + }, +}; diff --git a/apps/api/src/api/auth/auth.routes.ts b/apps/api/src/api/auth/auth.routes.ts index 9f1ea576..bf2db6c6 100644 --- a/apps/api/src/api/auth/auth.routes.ts +++ b/apps/api/src/api/auth/auth.routes.ts @@ -12,7 +12,7 @@ import { REFRESH_COOKIE_CONFIG, REFRESH_COOKIE_NAME, } from "../../lib/cookies"; -import { ApiErrors, createSuccessResponse } from "../../lib/errors"; +import { ApiError, ApiErrors, createSuccessResponse } from "../../lib/errors"; import { buildJWTPayload, createJWTConfig, @@ -417,14 +417,27 @@ const sessionAndOAuthRoutes = new Elysia() return createSuccessResponse({ user: null }); } - const result = await sessionService.refresh(refreshValue); + /* + * A refresh cookie whose session is gone (revoked, replayed, or its + * user deleted) is removed here. Otherwise the browser keeps sending + * it and every later refresh fails the same way. + */ + const auth = cookie[AUTH_COOKIE_NAME]; + const result = await sessionService + .refresh(refreshValue) + .catch((error: unknown) => { + if (error instanceof ApiError && error.statusCode === 401) { + refresh?.remove(); + auth?.remove(); + } + + throw error; + }); const accountId = await resolveActiveAccountId(result.user.id); const token = await jwt.sign( await buildJWTPayload(result.user.id, result.user.email, accountId) ); - const auth = cookie[AUTH_COOKIE_NAME]; - auth?.set({ value: token, ...AUTH_COOKIE_CONFIG }); refresh?.set({ value: result.token, ...REFRESH_COOKIE_CONFIG }); diff --git a/apps/api/src/api/billing/billing.constants.ts b/apps/api/src/api/billing/billing.constants.ts new file mode 100644 index 00000000..69ef5896 --- /dev/null +++ b/apps/api/src/api/billing/billing.constants.ts @@ -0,0 +1,27 @@ +export const PLAN_NAME = { + free: "Free", + pro: "Pro", +} as const; + +export const BILLING_INTERVALS = ["month", "year"] as const; + +export const DEFAULT_BILLING_INTERVAL = "month" as const; + +export const SUBSCRIPTION_CONFLICT_MESSAGE = + "Manage your existing subscription from Billing"; + +/* + * `incomplete_expired` is persisted as `canceled` (see STRIPE_STATUS_MAP), so + * the local check only needs the one terminal status. Stripe's own statuses + * are compared against both values. + */ +export const TERMINAL_SUBSCRIPTION_STATUSES = [ + "canceled", + "incomplete_expired", +] as const; + +export const CHECKOUT_SESSION_MODE = "subscription" as const; + +export const CHECKOUT_SESSION_OPEN = "open" as const; + +export const STRIPE_LIST_PAGE_SIZE = 100; diff --git a/apps/api/src/api/billing/billing.plans.ts b/apps/api/src/api/billing/billing.plans.ts new file mode 100644 index 00000000..ec348aa9 --- /dev/null +++ b/apps/api/src/api/billing/billing.plans.ts @@ -0,0 +1,88 @@ +import type { FEATURES } from "../../lib/acl/acl.constants"; +import { FEATURE_KEYS } from "../../lib/acl/acl.constants"; +import type { FeatureKey } from "../../lib/acl/acl.types"; + +import { BILLING_INTERVALS, PLAN_NAME } from "./billing.constants"; +import type { BillingInterval } from "./billing.types"; + +/* + * THE place to change what each built-in plan grants. + * + * Feature values are typed against the FEATURES catalog: a boolean feature + * takes `{ bool }`, a limit takes `{ number }`. `PlanFeatureValues` requires + * a key for every feature, so adding a key to FEATURES fails the type check + * on Pro until a value is chosen for it. The unit test in + * billing.plans.test.ts repeats that check at runtime. + * + * Free carries no rows on purpose: a missing feature resolves to its FEATURES + * default, which is the free-tier behaviour. + */ +export const PRO_SEAT_LIMIT = 10; + +type FeatureJson = + (typeof FEATURES)[K]["kind"] extends "boolean" + ? { bool: boolean } + : { number: number }; + +export type PlanFeatureValues = { + readonly [K in FeatureKey]: FeatureJson; +}; + +const FREE_FEATURES: Partial = {}; + +const PRO_FEATURES = { + can_export: { bool: true }, + can_invite_team: { bool: true }, + max_seats: { number: PRO_SEAT_LIMIT }, +} satisfies PlanFeatureValues; + +export interface IPlanPriceIds { + readonly free: string; + readonly proMonthly: string; + readonly proYearly: string; +} + +export interface IBuiltInPlan { + readonly name: string; + readonly isDefault: boolean; + /** Empty string means the interval has no price configured. */ + readonly prices: Readonly>; + readonly features: Partial; +} + +export const buildBuiltInPlans = ( + priceIds: IPlanPriceIds +): readonly IBuiltInPlan[] => [ + { + name: PLAN_NAME.free, + isDefault: true, + prices: { month: priceIds.free, year: "" }, + features: FREE_FEATURES, + }, + { + name: PLAN_NAME.pro, + isDefault: false, + prices: { month: priceIds.proMonthly, year: priceIds.proYearly }, + features: PRO_FEATURES, + }, +]; + +/** Feature keys a plan does not assign a value to. Empty for a complete plan. */ +export const featureKeysWithoutValue = (plan: IBuiltInPlan): FeatureKey[] => + FEATURE_KEYS.filter((key) => plan.features[key] === undefined); + +/** + * Maps a Stripe price back to the built-in plan that sells it, for either + * interval. Empty price ids never match, so an unconfigured interval cannot + * claim a subscription that carries an empty price. + */ +export const findPlanNameByPriceId = ( + catalog: readonly IBuiltInPlan[], + priceId: string +): string | undefined => { + return priceId === "" + ? undefined + : catalog.find((plan) => + BILLING_INTERVALS.some((interval) => plan.prices[interval] === priceId) + )?.name; +}; diff --git a/apps/api/src/api/billing/billing.routes.ts b/apps/api/src/api/billing/billing.routes.ts index 79a6758f..ab9204f3 100644 --- a/apps/api/src/api/billing/billing.routes.ts +++ b/apps/api/src/api/billing/billing.routes.ts @@ -13,7 +13,8 @@ import { SubscriptionResponse, WebhookResponse, } from "./billing.schemas"; -import { getBillingService } from "./billing.service"; +import { DEFAULT_BILLING_INTERVAL } from "./billing.constants"; +import { getBillingService, listBillingPlans } from "./billing.service"; import { grantTestTeamPlan } from "./test-plan"; import { env } from "../../config/env"; import { now } from "../../lib/time/now"; @@ -69,14 +70,6 @@ const billingRoutes = new Elysia() }, } ) - .get("/plans", async () => getBillingService().listPlans(), { - response: PlanListResponse, - detail: { - tags: ["Billing"], - summary: "List available plans", - security: [{ cookieAuth: [] }], - }, - }) .get( "/subscription", async ({ accountId, user }) => { @@ -119,7 +112,8 @@ const billingRoutes = new Elysia() billingAccountId, user.id, body.successUrl, - body.cancelUrl + body.cancelUrl, + body.interval ?? DEFAULT_BILLING_INTERVAL ); }, { @@ -157,6 +151,25 @@ const billingRoutes = new Elysia() } ) ) + .use( + /* + * Public, outside requireAuth: the pricing page reads this. It never + * touches Stripe and works when BILLING_ENABLED=false (every + * purchasableIntervals is then empty). The other routes in this file keep + * returning 404 while billing is disabled, via getBillingService(). + */ + new Elysia() + .onError(({ code, error, set }) => + errorHandler({ code: String(code), error, set }) + ) + .get("/plans", async () => listBillingPlans(), { + response: PlanListResponse, + detail: { + tags: ["Billing"], + summary: "List available plans (public)", + }, + }) + ) .use( new Elysia() .onError(({ code, error, set }) => diff --git a/apps/api/src/api/billing/billing.schemas.ts b/apps/api/src/api/billing/billing.schemas.ts index c680fccd..9119377d 100644 --- a/apps/api/src/api/billing/billing.schemas.ts +++ b/apps/api/src/api/billing/billing.schemas.ts @@ -1,9 +1,15 @@ import { t } from "elysia"; +export const BillingIntervalSchema = t.Union([ + t.Literal("month"), + t.Literal("year"), +]); + export const PlanResponse = t.Object({ id: t.Number(), name: t.String(), isDefault: t.Boolean(), + purchasableIntervals: t.Array(BillingIntervalSchema), }); export const PlanListResponse = t.Array(PlanResponse); @@ -35,6 +41,8 @@ export const WebhookResponse = t.Object({ export const CreateCheckoutSessionSchema = t.Object({ planId: t.Number(), + /* Optional; omitted means "month". A yearly interval needs STRIPE_PRICE_ID_PRO_YEARLY. */ + interval: t.Optional(BillingIntervalSchema), successUrl: t.String({ format: "uri" }), cancelUrl: t.String({ format: "uri" }), }); diff --git a/apps/api/src/api/billing/billing.service.ts b/apps/api/src/api/billing/billing.service.ts index 18de998b..616c5db0 100644 --- a/apps/api/src/api/billing/billing.service.ts +++ b/apps/api/src/api/billing/billing.service.ts @@ -1,20 +1,35 @@ -import { and, eq, isNull, or } from "drizzle-orm"; +import { and, eq, isNull, notInArray, or } from "drizzle-orm"; import Stripe from "stripe"; import { db } from "../../clients/postgres"; import { accountPlans, accounts, + planFeatures, plans, stripeWebhookEvents, } from "../../clients/postgres/schema"; import { env } from "../../config/env"; import { logger } from "../../config/logger"; +import { FEATURE_KEYS } from "../../lib/acl/acl.constants"; import { AUDIT_ACTIONS, auditLogService } from "../../lib/audit-log"; import { ApiErrors, getErrorMessage } from "../../lib/errors"; import { now } from "../../lib/time/now"; +import { + BILLING_INTERVALS, + CHECKOUT_SESSION_MODE, + CHECKOUT_SESSION_OPEN, + DEFAULT_BILLING_INTERVAL, + STRIPE_LIST_PAGE_SIZE, + SUBSCRIPTION_CONFLICT_MESSAGE, + TERMINAL_SUBSCRIPTION_STATUSES, +} from "./billing.constants"; +import type { IPlanPriceIds } from "./billing.plans"; +import { buildBuiltInPlans, findPlanNameByPriceId } from "./billing.plans"; import type { + AccountPlanStatus, + BillingInterval, ICheckoutSessionResult, IPlanSummary, IPortalSessionResult, @@ -22,7 +37,7 @@ import type { } from "./billing.types"; import { assertAllowedBillingRedirectUrl } from "./billing.utils"; -const STRIPE_STATUS_MAP: Record = { +const STRIPE_STATUS_MAP: Record = { active: "active", trialing: "trialing", past_due: "past_due", @@ -33,9 +48,14 @@ const STRIPE_STATUS_MAP: Record = { incomplete_expired: "canceled", }; -const mapStripeStatus = (stripeStatus: string): string => +const mapStripeStatus = (stripeStatus: string): AccountPlanStatus => STRIPE_STATUS_MAP[stripeStatus] ?? "incomplete"; +const terminalStatuses: readonly string[] = TERMINAL_SUBSCRIPTION_STATUSES; + +const isLiveSubscriptionStatus = (status: string): boolean => + !terminalStatuses.includes(status); + type BillingTransaction = Parameters[0]>[0]; interface IStripeEventSnapshot { @@ -49,6 +69,30 @@ interface IStripeWebhookDetails { readonly eventCreated: number; } +/* + * Injected for tests. Production leaves both unset: the client is built on + * first use, so reading plans or constructing the service never needs a + * Stripe key, and settings are read from env at call time. + */ +export interface IBillingServiceOptions { + readonly stripe?: Stripe; + readonly settings?: IBillingSettings; +} + +export interface IBillingSettings { + readonly enabled: boolean; + readonly priceIds: IPlanPriceIds; +} + +const currentBillingSettings = (): IBillingSettings => ({ + enabled: env.BILLING_ENABLED, + priceIds: { + free: env.STRIPE_PRICE_ID_FREE, + proMonthly: env.STRIPE_PRICE_ID_PRO, + proYearly: env.STRIPE_PRICE_ID_PRO_YEARLY, + }, +}); + const stripeEventOccurredAt = (eventCreated: number): string => new Date(eventCreated * 1000).toISOString(); @@ -65,41 +109,180 @@ const isOlderStripeEvent = ( const STRIPE_REQUEST_TIMEOUT_MS = 10_000; -export class BillingService { - private readonly stripe: Stripe; +const createStripeClient = (): Stripe => { + if (env.STRIPE_SECRET_KEY === "") { + throw ApiErrors.internal( + "BillingService instantiated without STRIPE_SECRET_KEY" + ); + } + + /* + * Explicit budget: the SDK's implicit 80s default would hold + * checkout/portal request handlers hostage to a slow Stripe API. + * The SDK retries idempotent calls internally, so each attempt + * gets this budget. + */ + return new Stripe(env.STRIPE_SECRET_KEY, { + timeout: STRIPE_REQUEST_TIMEOUT_MS, + }); +}; - constructor() { - if (env.STRIPE_SECRET_KEY === "") { - throw ApiErrors.internal( - "BillingService instantiated without STRIPE_SECRET_KEY" +const priceForPlan = ( + settings: IBillingSettings, + planName: string, + interval: BillingInterval +): string => + buildBuiltInPlans(settings.priceIds).find((plan) => plan.name === planName) + ?.prices[interval] ?? ""; + +/* + * Seeds the built-in plan rows and their feature values. Idempotent: rows are + * upserted by natural key, and the catalog in billing.plans.ts is the source of + * truth, so a hand-edit to a built-in plan is overwritten on the next call. + */ +const ensureConfiguredPlans = async ( + settings: IBillingSettings +): Promise => { + const catalog = buildBuiltInPlans(settings.priceIds); + + await db.transaction(async (tx) => { + for (const definition of catalog) { + const [row] = await tx + .insert(plans) + .values({ + name: definition.name, + isDefault: definition.isDefault, + stripePriceId: definition.prices.month, + }) + .onConflictDoUpdate({ + target: plans.name, + set: { + isDefault: definition.isDefault, + stripePriceId: definition.prices.month, + }, + }) + .returning({ id: plans.id }); + + if (row === undefined) { + throw ApiErrors.internal("Built-in plan row could not be resolved"); + } + + const assignedKeys = FEATURE_KEYS.filter( + (featureKey) => definition.features[featureKey] !== undefined ); + + for (const featureKey of assignedKeys) { + const value = definition.features[featureKey]; + + await tx + .insert(planFeatures) + .values({ planId: row.id, featureKey, value }) + .onConflictDoUpdate({ + target: [planFeatures.planId, planFeatures.featureKey], + set: { value }, + }); + } + + /* + * Stored rows for keys the catalog does not assign are removed, so a + * built-in plan's features always match billing.plans.ts. + */ + await tx + .delete(planFeatures) + .where( + and( + eq(planFeatures.planId, row.id), + notInArray(planFeatures.featureKey, assignedKeys) + ) + ); } + }); +}; - /* - * Explicit budget: the SDK's implicit 80s default would hold - * checkout/portal request handlers hostage to a slow Stripe API. - * The SDK retries idempotent calls internally, so each attempt - * gets this budget. - */ - this.stripe = new Stripe(env.STRIPE_SECRET_KEY, { - timeout: STRIPE_REQUEST_TIMEOUT_MS, - }); +const purchasableIntervalsFor = ( + settings: IBillingSettings, + planName: string, + isDefault: boolean +): BillingInterval[] => { + const definition = buildBuiltInPlans(settings.priceIds).find( + (plan) => plan.name === planName + ); + + return !settings.enabled || isDefault || definition === undefined + ? [] + : BILLING_INTERVALS.filter( + (interval) => definition.prices[interval] !== "" + ); +}; + +/* + * Public: this is the pricing-page read. It touches only the database and the + * env-derived catalog, never the Stripe client, and it answers even when + * billing is disabled (in which case no interval is purchasable). + */ +export const listBillingPlans = async ( + settings: IBillingSettings = currentBillingSettings() +): Promise => { + await ensureConfiguredPlans(settings); + + const rows = await db.query.plans.findMany({ orderBy: [plans.name] }); + + return rows.map((plan) => ({ + id: plan.id, + name: plan.name, + isDefault: plan.isDefault, + purchasableIntervals: purchasableIntervalsFor( + settings, + plan.name, + plan.isDefault + ), + })); +}; + +const isPaidCheckout = (session: Stripe.Checkout.Session): boolean => + session.payment_status === "paid" || + session.payment_status === "no_payment_required"; + +/* + * Status a checkout event implies for the account's subscription row. + * `unpaid` (delayed payment methods such as bank debit) must not entitle + * until the async success event arrives. + */ +const checkoutStatusFor = ( + eventType: string, + session: Stripe.Checkout.Session +): AccountPlanStatus => { + switch (eventType) { + case "checkout.session.async_payment_succeeded": + return "active"; + case "checkout.session.async_payment_failed": + return "incomplete"; + default: + return isPaidCheckout(session) ? "active" : "incomplete"; } +}; - async listPlans(): Promise { - await this.ensureConfiguredPlans(); +export class BillingService { + private stripeClient: Stripe | undefined; + private readonly settings: IBillingSettings; - const rows = await db.query.plans.findMany({ orderBy: [plans.name] }); + constructor(options: IBillingServiceOptions = {}) { + this.stripeClient = options.stripe; + this.settings = options.settings ?? currentBillingSettings(); + } - return rows.map((plan) => ({ - id: plan.id, - name: plan.name, - isDefault: plan.isDefault, - })); + private get stripe(): Stripe { + this.stripeClient ??= createStripeClient(); + + return this.stripeClient; + } + + listPlans(): Promise { + return listBillingPlans(this.settings); } async getSubscription(accountId: string): Promise { - await this.ensureConfiguredPlans(); + await ensureConfiguredPlans(this.settings); const [accountPlan, defaultPlan] = await Promise.all([ /* @@ -143,141 +326,248 @@ export class BillingService { }; } + /** + * Starts (or reuses) a Stripe Checkout session for one plan and interval. + * + * The account row is locked for the whole sequence, so two quick clicks + * serialize: the second request waits, then finds the first request's + * open session on Stripe and returns its URL instead of creating another. + * A live subscription, from either the local row or Stripe itself (the + * webhook may not have landed yet), rejects the request with a conflict. + */ async createCheckoutSession( planId: number, accountId: string, actorUserId: string, successUrl: string, - cancelUrl: string + cancelUrl: string, + interval: BillingInterval = DEFAULT_BILLING_INTERVAL ): Promise { assertAllowedBillingRedirectUrl(successUrl, "successUrl"); assertAllowedBillingRedirectUrl(cancelUrl, "cancelUrl"); - await this.ensureConfiguredPlans(); + await ensureConfiguredPlans(this.settings); - const [plan, account] = await Promise.all([ - db.query.plans.findFirst({ where: eq(plans.id, planId) }), - db.query.accounts.findFirst({ - where: and(eq(accounts.id, accountId), isNull(accounts.deletedAt)), - }), - ]); + const plan = await db.query.plans.findFirst({ + where: eq(plans.id, planId), + }); if (!plan) { throw ApiErrors.notFound("Plan"); } - if (!account) { - throw ApiErrors.notFound("Account"); + if (plan.isDefault) { + throw ApiErrors.invalidInput("Choose a paid subscription plan", "planId"); } - if (plan.stripePriceId === "") { - throw ApiErrors.internal("Plan is missing a Stripe Price ID"); + const priceId = priceForPlan(this.settings, plan.name, interval); + + if (priceId === "") { + throw ApiErrors.invalidInput( + "This billing interval is not available for the plan", + "interval" + ); } - let stripeCustomerId = account.stripeCustomerId; + return db.transaction(async (tx) => { + const [account] = await tx + .select() + .from(accounts) + .where(and(eq(accounts.id, accountId), isNull(accounts.deletedAt))) + .for("update"); + + if (account === undefined) { + throw ApiErrors.notFound("Account"); + } + + const localSubscription = await tx.query.accountPlans.findFirst({ + where: and( + eq(accountPlans.accountId, accountId), + isNull(accountPlans.revokedAt) + ), + }); - if (stripeCustomerId === null || stripeCustomerId === "") { /* - * Idempotency key keyed on the account id makes Stripe collapse a - * double-click into a single customer record. Without it, two - * parallel checkout requests each see `stripeCustomerId === null`, - * each `customers.create()` returns a *different* id, and the - * second DB write wins: the orphaned customer's future webhook - * deliveries don't resolve the account and the subscription - * silently lands on the wrong tenant. Stripe holds the key for - * 24h, which covers any plausible double-submit window. + * Only a Stripe-backed row blocks checkout: an admin grant or a test + * fixture plan has no subscription to manage, and buying replaces it. */ - const customer = await this.stripe.customers.create( - { - name: account.name, - metadata: { accountId: account.id }, - }, - { idempotencyKey: `account-customer:${account.id}` } + if ( + localSubscription !== undefined && + (localSubscription.stripeSubscriptionId ?? "") !== "" && + isLiveSubscriptionStatus(localSubscription.status) + ) { + throw ApiErrors.conflict(SUBSCRIPTION_CONFLICT_MESSAGE); + } + + const stripeCustomerId = await this.ensureStripeCustomer(tx, account); + + await this.assertNoLiveStripeSubscription(stripeCustomerId); + + const reusableUrl = await this.findReusableCheckoutUrl( + stripeCustomerId, + accountId, + plan.id, + priceId ); - stripeCustomerId = customer.id; + if (reusableUrl !== null) { + return { url: reusableUrl }; + } - /* - * Conditional update: only write the customer id when the column - * is still empty. A concurrent request that beat us to the Stripe - * API may have already filled it with the same value (idempotency - * key collapse) or, in theory, raced past our row in a different - * order; either way, leaving the existing value alone keeps the - * write idempotent. - */ - await db - .update(accounts) - .set({ stripeCustomerId }) - .where( - and( - eq(accounts.id, accountId), - or( - isNull(accounts.stripeCustomerId), - eq(accounts.stripeCustomerId, "") - ) + const session = await this.stripe.checkout.sessions.create({ + customer: stripeCustomerId, + mode: CHECKOUT_SESSION_MODE, + line_items: [{ price: priceId, quantity: 1 }], + success_url: successUrl, + cancel_url: cancelUrl, + metadata: { + planId: String(plan.id), + priceId, + accountId: account.id, + }, + }); + + if (session.url === null) { + throw ApiErrors.internal("Stripe did not return a checkout URL"); + } + + void auditLogService.record({ + userId: actorUserId, + action: AUDIT_ACTIONS.BILLING_CHECKOUT_SESSION_CREATED, + targetAccountId: accountId, + metadata: { + planId: plan.id, + sessionId: session.id, + accountId, + interval, + }, + }); + + return { url: session.url }; + }); + } + + private async ensureStripeCustomer( + tx: BillingTransaction, + account: typeof accounts.$inferSelect + ): Promise { + const existing = account.stripeCustomerId; + + if (existing !== null && existing !== "") { + return existing; + } + + /* + * Idempotency key keyed on the account id makes Stripe collapse a + * double-click into a single customer record. Without it, two + * parallel checkout requests each see `stripeCustomerId === null`, + * each `customers.create()` returns a *different* id, and the + * second DB write wins: the orphaned customer's future webhook + * deliveries don't resolve the account and the subscription + * silently lands on the wrong tenant. Stripe holds the key for + * 24h, which covers any plausible double-submit window. + */ + const customer = await this.stripe.customers.create( + { + name: account.name, + metadata: { accountId: account.id }, + }, + { idempotencyKey: `account-customer:${account.id}` } + ); + + /* + * Conditional update: only write the customer id when the column is still + * empty, so an existing value is never overwritten. + */ + await tx + .update(accounts) + .set({ stripeCustomerId: customer.id }) + .where( + and( + eq(accounts.id, account.id), + or( + isNull(accounts.stripeCustomerId), + eq(accounts.stripeCustomerId, "") ) - ); + ) + ); + + return customer.id; + } + + private async assertNoLiveStripeSubscription( + stripeCustomerId: string + ): Promise { + for await (const subscription of this.stripe.subscriptions.list({ + customer: stripeCustomerId, + status: "all", + limit: STRIPE_LIST_PAGE_SIZE, + })) { + if (isLiveSubscriptionStatus(subscription.status)) { + throw ApiErrors.conflict(SUBSCRIPTION_CONFLICT_MESSAGE); + } } + } - const session = await this.stripe.checkout.sessions.create({ + private async findReusableCheckoutUrl( + stripeCustomerId: string, + accountId: string, + planId: number, + priceId: string + ): Promise { + for await (const session of this.stripe.checkout.sessions.list({ customer: stripeCustomerId, - mode: "subscription", - line_items: [{ price: plan.stripePriceId, quantity: 1 }], - success_url: successUrl, - cancel_url: cancelUrl, - metadata: { - planId: String(plan.id), - accountId: account.id, - }, + status: CHECKOUT_SESSION_OPEN, + limit: STRIPE_LIST_PAGE_SIZE, + })) { + if ( + session.mode === CHECKOUT_SESSION_MODE && + session.metadata?.accountId === accountId && + session.metadata.planId === String(planId) && + session.metadata.priceId === priceId && + session.url !== null + ) { + return session.url; + } + } + + return null; + } + + async createPortalSession( + accountId: string, + actorUserId: string, + returnUrl: string + ): Promise { + assertAllowedBillingRedirectUrl(returnUrl, "returnUrl"); + + const account = await db.query.accounts.findFirst({ + where: and(eq(accounts.id, accountId), isNull(accounts.deletedAt)), }); + const stripeCustomerId = account?.stripeCustomerId; - if (session.url === null) { - throw ApiErrors.internal("Stripe did not return a checkout URL"); + if ( + stripeCustomerId === undefined || + stripeCustomerId === null || + stripeCustomerId === "" + ) { + throw ApiErrors.notFound("Stripe customer for account"); } + const session = await this.stripe.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: returnUrl, + }); + void auditLogService.record({ userId: actorUserId, - action: AUDIT_ACTIONS.BILLING_CHECKOUT_SESSION_CREATED, + action: AUDIT_ACTIONS.BILLING_PORTAL_SESSION_CREATED, targetAccountId: accountId, - metadata: { planId: plan.id, sessionId: session.id, accountId }, + metadata: { sessionId: session.id, accountId }, }); return { url: session.url }; } - private async ensureConfiguredPlans(): Promise { - await db.transaction(async (tx) => { - await tx - .insert(plans) - .values({ - name: "Free", - isDefault: true, - stripePriceId: env.STRIPE_PRICE_ID_FREE, - }) - .onConflictDoUpdate({ - target: plans.name, - set: { - isDefault: true, - stripePriceId: env.STRIPE_PRICE_ID_FREE, - }, - }); - - await tx - .insert(plans) - .values({ - name: "Pro", - isDefault: false, - stripePriceId: env.STRIPE_PRICE_ID_PRO, - }) - .onConflictDoUpdate({ - target: plans.name, - set: { - isDefault: false, - stripePriceId: env.STRIPE_PRICE_ID_PRO, - }, - }); - }); - } - private async ensureIdempotent( tx: BillingTransaction, eventId: string, @@ -342,7 +632,21 @@ export class BillingService { return true; } - private async handleCheckoutSessionCompleted( + /* + * Handles checkout.session.completed and the two async-payment events. + * + * Completion alone does not write status when the subscription row already + * exists: `customer.subscription.*` is the authority there, and Stripe emits + * both with the same `created` second. The async events are the exception, + * since they are the only signal that a delayed payment settled or failed, + * so they set the status on a matching row. + * + * Revoking the row and inserting a replacement discards the period, and a + * row without `currentPeriodEnd` is permanently unsweepable, since the + * downgrade job filters on `isNotNull(currentPeriodEnd)`. So a new row is + * only inserted when no row exists for this subscription. + */ + private async handleCheckoutSessionEvent( tx: BillingTransaction, session: Stripe.Checkout.Session, details: IStripeWebhookDetails @@ -406,6 +710,9 @@ export class BillingService { ? session.subscription : (session.subscription?.id ?? null); + const targetStatus = checkoutStatusFor(details.eventType, session); + const isCompletion = details.eventType === "checkout.session.completed"; + const current = await tx.query.accountPlans.findFirst({ where: and( eq(accountPlans.accountId, accountId), @@ -413,25 +720,6 @@ export class BillingService { ), }); - /* - * A row for this same subscription is already materialized, so - * `customer.subscription.*` is the authority on it and checkout only - * records that it saw the event. - * - * Status and plan are deliberately NOT written here. Checkout - * completion means "the customer finished the hosted flow", which is - * not the same claim as "the subscription is active": Stripe emits - * both events with the same `created` second, so neither is skipped by - * the ordering guard and whichever lands last wins. Writing `active` - * from this side lets a checkout arriving after an `unpaid`, - * `past_due`, `canceled` or `trialing` update silently restore paid - * entitlement to a delinquent account. - * - * Revoking the row and inserting a replacement is worse still: it - * discards the period, and a row without `currentPeriodEnd` is - * permanently unsweepable, since the downgrade job filters on - * `isNotNull(currentPeriodEnd)`. - */ if ( current !== undefined && subscriptionId !== null && @@ -440,6 +728,7 @@ export class BillingService { await tx .update(accountPlans) .set({ + ...(isCompletion ? {} : { status: targetStatus }), lastStripeEventId: details.eventId, lastStripeEventAt: stripeEventOccurredAt(details.eventCreated), }) @@ -450,6 +739,10 @@ export class BillingService { ) ); + if (!isCompletion) { + this.recordReconciled(accountId, details, planId, targetStatus); + } + return; } @@ -466,7 +759,7 @@ export class BillingService { await tx.insert(accountPlans).values({ accountId, planId, - status: "active", + status: targetStatus, source: "stripe", /* * The session names its subscription. Dropping it makes @@ -486,6 +779,15 @@ export class BillingService { planId, }); + this.recordReconciled(accountId, details, planId, targetStatus); + } + + private recordReconciled( + accountId: string, + details: IStripeWebhookDetails, + planId: number | undefined, + status: AccountPlanStatus + ): void { void auditLogService.record({ userId: null, action: AUDIT_ACTIONS.STRIPE_RECONCILED, @@ -495,7 +797,7 @@ export class BillingService { eventId: details.eventId, eventType: details.eventType, planId, - status: "active", + status, }, }); } @@ -515,12 +817,25 @@ export class BillingService { return; } + /* + * The catalog maps every built-in price, including the yearly one that + * plans.stripePriceId does not hold. Any other plan row (a tier a product + * added outside the catalog) still matches on its own stripePriceId. + */ + const planName = findPlanNameByPriceId( + buildBuiltInPlans(this.settings.priceIds), + newPriceId + ); + const [account, newPlan] = await Promise.all([ tx.query.accounts.findFirst({ where: eq(accounts.stripeCustomerId, customerId), }), tx.query.plans.findFirst({ - where: eq(plans.stripePriceId, newPriceId), + where: + planName === undefined + ? eq(plans.stripePriceId, newPriceId) + : eq(plans.name, planName), }), ]); @@ -560,18 +875,12 @@ export class BillingService { 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), - }, - }); + this.recordReconciled( + account.id, + details, + newPlan.id, + mapStripeStatus(subscription.status) + ); } private async handleSubscriptionDeleted( @@ -617,52 +926,7 @@ export class BillingService { ) ); - 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( - accountId: string, - actorUserId: string, - returnUrl: string - ): Promise { - assertAllowedBillingRedirectUrl(returnUrl, "returnUrl"); - - const account = await db.query.accounts.findFirst({ - where: and(eq(accounts.id, accountId), isNull(accounts.deletedAt)), - }); - const stripeCustomerId = account?.stripeCustomerId; - - if ( - stripeCustomerId === undefined || - stripeCustomerId === null || - stripeCustomerId === "" - ) { - throw ApiErrors.notFound("Stripe customer for account"); - } - - const session = await this.stripe.billingPortal.sessions.create({ - customer: stripeCustomerId, - return_url: returnUrl, - }); - - void auditLogService.record({ - userId: actorUserId, - action: AUDIT_ACTIONS.BILLING_PORTAL_SESSION_CREATED, - targetAccountId: accountId, - metadata: { sessionId: session.id, accountId }, - }); - - return { url: session.url }; + this.recordReconciled(account.id, details, undefined, "canceled"); } async constructWebhookEvent( @@ -700,12 +964,14 @@ export class BillingService { } switch (event.type) { - case "checkout.session.completed": { - await this.handleCheckoutSessionCompleted( - tx, - event.data.object, - details - ); + case "checkout.session.completed": + + // falls through + case "checkout.session.async_payment_succeeded": + + // falls through + case "checkout.session.async_payment_failed": { + await this.handleCheckoutSessionEvent(tx, event.data.object, details); return; } diff --git a/apps/api/src/api/billing/billing.types.ts b/apps/api/src/api/billing/billing.types.ts index 25ecde03..4cc504cf 100644 --- a/apps/api/src/api/billing/billing.types.ts +++ b/apps/api/src/api/billing/billing.types.ts @@ -1,7 +1,13 @@ +import type { BILLING_INTERVALS } from "./billing.constants"; + +export type BillingInterval = (typeof BILLING_INTERVALS)[number]; + export interface IPlanSummary { id: number; name: string; isDefault: boolean; + /** Intervals a checkout can be started for right now (billing on, price set). */ + purchasableIntervals: BillingInterval[]; } export type AccountPlanStatus = diff --git a/apps/api/src/api/notifications/events/account-invitation-accepted.event.ts b/apps/api/src/api/notifications/events/account-invitation-accepted.event.ts index d012f14c..81987ce6 100644 --- a/apps/api/src/api/notifications/events/account-invitation-accepted.event.ts +++ b/apps/api/src/api/notifications/events/account-invitation-accepted.event.ts @@ -1,6 +1,6 @@ import { t } from "elysia"; -import { defineNotificationEvent } from "../../../lib/notifications"; +import { defineNotificationEvent } from "../../../lib/notifications/events/define-event"; /** * Notification event: a user just accepted an invitation into an diff --git a/apps/api/src/api/notifications/events/account-ownership-transferred.event.ts b/apps/api/src/api/notifications/events/account-ownership-transferred.event.ts index 1b404c05..4813bd79 100644 --- a/apps/api/src/api/notifications/events/account-ownership-transferred.event.ts +++ b/apps/api/src/api/notifications/events/account-ownership-transferred.event.ts @@ -1,6 +1,6 @@ import { t } from "elysia"; -import { defineNotificationEvent } from "../../../lib/notifications"; +import { defineNotificationEvent } from "../../../lib/notifications/events/define-event"; /** * Notification event: an account ownership transfer just completed. diff --git a/apps/api/src/api/notifications/events/auth-welcome.event.ts b/apps/api/src/api/notifications/events/auth-welcome.event.ts index 77349726..c8a6402c 100644 --- a/apps/api/src/api/notifications/events/auth-welcome.event.ts +++ b/apps/api/src/api/notifications/events/auth-welcome.event.ts @@ -1,6 +1,6 @@ import { t } from "elysia"; -import { defineNotificationEvent } from "../../../lib/notifications"; +import { defineNotificationEvent } from "../../../lib/notifications/events/define-event"; /** * Notification event: a user has just verified their email (or completed diff --git a/apps/api/src/api/notifications/events/index.ts b/apps/api/src/api/notifications/events/index.ts index d6d59082..6ffa7ff3 100644 --- a/apps/api/src/api/notifications/events/index.ts +++ b/apps/api/src/api/notifications/events/index.ts @@ -1,4 +1,11 @@ -import type { INotificationEventDefinition } from "../../../lib/notifications"; +/* + * The event files import the helper and the type from their own modules, not + * the `lib/notifications` barrel: that barrel reaches `config/setup`, which + * registers these very events, so importing it here is a cycle that leaves + * `defineNotificationEvent` in its temporal dead zone whenever a test loads + * the barrel first. + */ +import type { INotificationEventDefinition } from "../../../lib/notifications/notifications.types"; import { accountInvitationAcceptedEvent } from "./account-invitation-accepted.event"; import { accountOwnershipTransferredEvent } from "./account-ownership-transferred.event"; diff --git a/apps/api/src/api/notifications/events/password-reset-completed.event.ts b/apps/api/src/api/notifications/events/password-reset-completed.event.ts index 344bd933..22303627 100644 --- a/apps/api/src/api/notifications/events/password-reset-completed.event.ts +++ b/apps/api/src/api/notifications/events/password-reset-completed.event.ts @@ -1,6 +1,6 @@ import { t } from "elysia"; -import { defineNotificationEvent } from "../../../lib/notifications"; +import { defineNotificationEvent } from "../../../lib/notifications/events/define-event"; /** * Notification event: the user's password was just reset via the diff --git a/apps/api/src/api/users/users.routes.ts b/apps/api/src/api/users/users.routes.ts index 7ce66d8e..760c7926 100644 --- a/apps/api/src/api/users/users.routes.ts +++ b/apps/api/src/api/users/users.routes.ts @@ -1,5 +1,7 @@ import { Elysia } from "elysia"; +import { REFRESH_COOKIE_NAME } from "../../lib/cookies"; +import { ApiErrors } from "../../lib/errors"; import { errorHandler } from "../../middleware/error-handler"; import { requireAuth, tryAuth } from "../auth/auth.plugin"; @@ -15,8 +17,13 @@ import { usersService } from "./users.service"; * uses `tryAuth` so a missing cookie resolves to `{ user: null }` * (200) rather than a noisy 401. A cookie that's present but invalid * still throws and surfaces as 401 `invalid_session`, which the UI - * treats as a forced logout. See `docs/api/auth-contract` for the - * full anonymous-vs-unauthorized split. + * treats as a forced logout. + * + * The access cookie is short-lived (15 minutes) and the browser drops + * it on expiry, so "no access cookie" is not proof of anonymity: a + * refresh cookie that is still present means the session can be renewed. + * That case must be a 401, which makes the client refresh and retry, + * rather than a 200 `{ user: null }` that would sign the user out on reload. */ const meRoutes = tryAuth() .onError(({ code, error, set }) => @@ -24,10 +31,18 @@ const meRoutes = tryAuth() ) .get( "/me", - async ({ user, accountId }) => { - return user === null || accountId === null - ? { user: null } - : usersService.getMe(user.id, accountId); + async ({ user, accountId, cookie }) => { + if (user !== null && accountId !== null) { + return usersService.getMe(user.id, accountId); + } + + const refresh = cookie[REFRESH_COOKIE_NAME]?.value; + + if (typeof refresh === "string" && refresh !== "") { + throw ApiErrors.unauthorized("The session needs refreshing"); + } + + return { user: null }; }, { response: MeResponse, diff --git a/apps/api/src/clients/postgres/schema/billing.schema.ts b/apps/api/src/clients/postgres/schema/billing.schema.ts index 87113f1d..c811f162 100644 --- a/apps/api/src/clients/postgres/schema/billing.schema.ts +++ b/apps/api/src/clients/postgres/schema/billing.schema.ts @@ -37,8 +37,10 @@ export const plans = billing.table( ); /* - * Plan ↔ feature mapping. Per-deployment overridable: seeds at boot - * via `ensureConfiguredPlans` and operators can hand-edit rows. + * Plan ↔ feature mapping. The built-in plans' rows are written from the + * catalog in `api/billing/billing.plans.ts` on every plan read, so a hand + * edit to Free or Pro is reverted; per-account exceptions belong in + * `account_feature_overrides`. * `featureKey` is validated in code against the `FEATURES` catalog; * `value` is jsonb shaped as `{ bool: boolean }` for boolean features * and `{ number: number }` for limits. diff --git a/apps/api/src/config/env/schema.ts b/apps/api/src/config/env/schema.ts index f7971ffd..b7ec41d7 100644 --- a/apps/api/src/config/env/schema.ts +++ b/apps/api/src/config/env/schema.ts @@ -193,6 +193,8 @@ export const envSchema = t.Object({ STRIPE_WEBHOOK_SECRET: t.String({ default: "" }), STRIPE_PRICE_ID_FREE: t.String({ default: "" }), STRIPE_PRICE_ID_PRO: t.String({ default: "" }), + /* Optional. Empty means the yearly interval is not offered. */ + STRIPE_PRICE_ID_PRO_YEARLY: t.String({ default: "" }), QUEUES_ENABLED: t.Boolean({ default: true }), CACHE_ENABLED: t.Boolean({ default: true }), diff --git a/apps/api/src/config/env/validate.ts b/apps/api/src/config/env/validate.ts index f27cce85..f73fafd0 100644 --- a/apps/api/src/config/env/validate.ts +++ b/apps/api/src/config/env/validate.ts @@ -272,6 +272,7 @@ const readBilling = (source: EnvSource) => ({ STRIPE_WEBHOOK_SECRET: source.STRIPE_WEBHOOK_SECRET ?? "", STRIPE_PRICE_ID_FREE: source.STRIPE_PRICE_ID_FREE ?? "", STRIPE_PRICE_ID_PRO: source.STRIPE_PRICE_ID_PRO ?? "", + STRIPE_PRICE_ID_PRO_YEARLY: source.STRIPE_PRICE_ID_PRO_YEARLY ?? "", }); const readBackground = (source: EnvSource) => ({ @@ -621,6 +622,20 @@ const checkBilling = (env: Env): string[] => { errors.push("STRIPE_PRICE_ID_PRO required when BILLING_ENABLED=true"); } + /* + * The webhook maps a subscription's price back to a plan. A yearly price + * equal to the monthly one would make that mapping ambiguous about the + * interval, so refuse it at boot rather than at the first renewal. + */ + if ( + env.STRIPE_PRICE_ID_PRO_YEARLY !== "" && + env.STRIPE_PRICE_ID_PRO_YEARLY === env.STRIPE_PRICE_ID_PRO + ) { + errors.push( + "STRIPE_PRICE_ID_PRO_YEARLY must differ from STRIPE_PRICE_ID_PRO" + ); + } + /* * A non-empty but obviously-fake Stripe secret (copy-pasted placeholder) * passes the emptiness checks above and boots, then fails at the first diff --git a/apps/api/src/middleware/error-handler.ts b/apps/api/src/middleware/error-handler.ts index 8e5ce1ba..26eb7f90 100644 --- a/apps/api/src/middleware/error-handler.ts +++ b/apps/api/src/middleware/error-handler.ts @@ -16,8 +16,8 @@ import type { IErrorHandlerArgs } from "./error-handler.types"; * Reproducing that string in either the response OR the structured log * leaks user input to clients and observability, so we only ever * surface the field name (a schema-defined symbol the client already - * knows about). The user-facing message stays generic; the developer - * still gets the raw framework message in dev via the stack field. + * knows about). Framework stacks embed the same body, so they are omitted + * in every environment, including local development. */ const extractFieldErrors = ( error: unknown @@ -50,6 +50,23 @@ const safeMessageFor = (code: string, error: unknown): string => { return framework ?? getErrorMessage(error); }; +const isFrameworkCode = (code: string): boolean => + FRAMEWORK_SAFE_MESSAGES[code] !== undefined; + +/* + * The stack is developer detail for everything else. For framework + * VALIDATION/PARSE errors it embeds the submitted body, so it is never + * attached, in development or anywhere else. + */ +export const stackFor = ( + code: string, + error: unknown, + isDevelopment: boolean +): string | undefined => + isDevelopment && !isFrameworkCode(code) && error instanceof Error + ? error.stack + : undefined; + const isClientErrorCode = (code: string): boolean => code === ElysiaErrorCodes.NOT_FOUND || code === ElysiaErrorCodes.VALIDATION || @@ -106,8 +123,7 @@ export const errorHandler = ({ statusCode: finalStatus, message: error instanceof ApiError ? error.message : safeMessageFor(code, error), - stack: - env.isDevelopment && error instanceof Error ? error.stack : undefined, + stack: stackFor(code, error, env.isDevelopment), }; if (isClientError) { diff --git a/apps/api/tests/api/auth/auth.routes.test.ts b/apps/api/tests/api/auth/auth.routes.test.ts index 1e613f62..ee0e3584 100644 --- a/apps/api/tests/api/auth/auth.routes.test.ts +++ b/apps/api/tests/api/auth/auth.routes.test.ts @@ -1,11 +1,13 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, spyOn, test } from "bun:test"; import { createApp } from "../../../src/config/app"; import { env } from "../../../src/config/env"; +import { sessionService } from "../../../src/api/auth/services"; import { AUTH_COOKIE_NAME } from "../../../src/lib/cookies"; import { generateOpaqueToken, hashOpaqueToken } from "../../../src/lib/tokens"; import { seedPendingUser, seedVerifiedUser } from "../../helpers/auth"; import { + authSessions, cleanDatabase, db, emailVerificationTokens, @@ -32,6 +34,8 @@ import { * the integration suite). */ +const AUTH_LOGIN_URL = "http://localhost/api/v1/auth/login"; +const AUTH_REFRESH_URL = "http://localhost/api/v1/auth/refresh"; const EMAIL = "flow-test@example.com"; const PASSWORD = "Hunter2Strong!"; const uniqueEmail = (prefix: string): string => @@ -186,7 +190,7 @@ describe("auth flow — register → verify → login → me → logout", () => // 2. Login attempt with valid credentials → 403 EMAIL_NOT_VERIFIED const blockedLogin = await app.handle( - new Request("http://localhost/api/v1/auth/login", { + new Request(AUTH_LOGIN_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: EMAIL, password: PASSWORD }), @@ -239,7 +243,7 @@ describe("auth flow — register → verify → login → me → logout", () => // 4. Login (separate cookie jar, fresh Set-Cookie) const loginRes = await app.handle( - new Request("http://localhost/api/v1/auth/login", { + new Request(AUTH_LOGIN_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: EMAIL, password: PASSWORD }), @@ -257,7 +261,7 @@ describe("auth flow — register → verify → login → me → logout", () => // 5. Refresh rotates the session cookie + issues a fresh auth cookie const refreshRes = await app.handle( - new Request("http://localhost/api/v1/auth/refresh", { + new Request(AUTH_REFRESH_URL, { method: "POST", headers: { cookie: refreshCookie }, }) @@ -347,7 +351,7 @@ describe("auth flow — register → verify → login → me → logout", () => }); const loginRes = await app.handle( - new Request("http://localhost/api/v1/auth/login", { + new Request(AUTH_LOGIN_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: EMAIL, password: "wrong-password" }), @@ -451,7 +455,7 @@ describe("auth flow — register → verify → login → me → logout", () => await seedPendingUser({ email: "pending@example.com", password: PASSWORD }); const res = await app.handle( - new Request("http://localhost/api/v1/auth/login", { + new Request(AUTH_LOGIN_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -485,7 +489,7 @@ describe("POST /api/v1/auth/refresh — anonymous-vs-unauthorized contract", () const app = createApp(); const res = await app.handle( - new Request("http://localhost/api/v1/auth/refresh", { + new Request(AUTH_REFRESH_URL, { method: "POST", }) ); @@ -515,7 +519,7 @@ describe("POST /api/v1/auth/refresh — anonymous-vs-unauthorized contract", () const app = createApp(); const res = await app.handle( - new Request("http://localhost/api/v1/auth/refresh", { + new Request(AUTH_REFRESH_URL, { method: "POST", headers: { cookie: "refresh_token=this-is-not-a-real-session" }, }) @@ -523,6 +527,71 @@ describe("POST /api/v1/auth/refresh — anonymous-vs-unauthorized contract", () expect(res.status).toBe(401); }); + + test("a refresh cookie whose session was revoked is cleared, so later refreshes stop failing", async () => { + if (!(await requireDb())) { + return; + } + + const email = "revoked-refresh@example.com"; + + await seedVerifiedUser({ email, password: PASSWORD }); + + const app = createApp(); + const loginRes = await app.handle( + new Request(AUTH_LOGIN_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, password: PASSWORD }), + }) + ); + const refreshCookie = extractCookiePair( + loginRes.headers.getSetCookie().join("; "), + "refresh_token" + ); + + expect(refreshCookie).not.toBe(""); + + await db.delete(authSessions); + + const revoked = await app.handle( + new Request(AUTH_REFRESH_URL, { + method: "POST", + headers: { cookie: refreshCookie }, + }) + ); + + expect(revoked.status).toBe(401); + expect(revoked.headers.getSetCookie().join("; ")).toMatch( + /refresh_token=;.*(Max-Age=0|Expires=Thu, 01 Jan 1970)/i + ); + }); + + test("a transient refresh failure keeps the refresh cookie", async () => { + if (!(await requireDb())) { + return; + } + + const failing = spyOn(sessionService, "refresh").mockRejectedValue( + new Error("database unavailable") + ); + + try { + const res = await createApp().handle( + new Request(AUTH_REFRESH_URL, { + method: "POST", + headers: { cookie: "refresh_token=keep-this-token" }, + }) + ); + + expect(res.status).toBe(500); + expect(res.headers.getSetCookie().join(";")).not.toMatch( + /refresh_token=;/ + ); + } finally { + failing.mockRestore(); + } + }); }); const NEW_PASSWORD = "EvenStronger3!"; diff --git a/apps/api/tests/api/billing/billing.plans.test.ts b/apps/api/tests/api/billing/billing.plans.test.ts new file mode 100644 index 00000000..ccf885b0 --- /dev/null +++ b/apps/api/tests/api/billing/billing.plans.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; + +import { + PRO_SEAT_LIMIT, + buildBuiltInPlans, + featureKeysWithoutValue, + findPlanNameByPriceId, +} from "../../../src/api/billing/billing.plans"; +import { FEATURE_KEYS, FEATURES } from "../../../src/lib/acl/acl.constants"; + +const PRICES = { + free: "price_free", + proMonthly: "price_pro_monthly", + proYearly: "price_pro_yearly", +}; + +const catalog = buildBuiltInPlans(PRICES); + +const requirePlan = (name: string) => { + const plan = catalog.find((candidate) => candidate.name === name); + + if (plan === undefined) { + throw new Error(`plan ${name} missing from catalog`); + } + + return plan; +}; + +describe("built-in plan catalog", () => { + test("Pro carries a value for every feature key", () => { + expect(featureKeysWithoutValue(requirePlan("Pro"))).toEqual([]); + }); + + test("Free carries no feature rows (missing keys resolve to FEATURES defaults)", () => { + expect(Object.keys(requirePlan("Free").features)).toEqual([]); + }); + + test("every Pro value has the JSON shape its FEATURES kind requires", () => { + const pro = requirePlan("Pro"); + + for (const key of FEATURE_KEYS) { + const json = JSON.stringify(pro.features[key]); + + if (FEATURES[key].kind === "boolean") { + expect(json).toMatch(/^\{"bool":(?:true|false)\}$/u); + } else { + expect(json).toMatch(/^\{"number":\d+\}$/u); + } + } + }); + + test("Pro grants every boolean feature and the documented seat limit", () => { + const pro = requirePlan("Pro"); + + for (const key of FEATURE_KEYS) { + if (FEATURES[key].kind === "boolean") { + expect(pro.features[key]).toEqual({ bool: true }); + } + } + + expect(PRO_SEAT_LIMIT).toBe(10); + expect(pro.features.max_seats).toEqual({ number: PRO_SEAT_LIMIT }); + }); + + test("Free is the default plan and Pro is not", () => { + expect(requirePlan("Free").isDefault).toBe(true); + expect(requirePlan("Pro").isDefault).toBe(false); + }); + + test("Pro exposes a monthly and a yearly price; Free exposes no yearly price", () => { + const pro = requirePlan("Pro"); + + expect(pro.prices).toEqual({ + month: PRICES.proMonthly, + year: PRICES.proYearly, + }); + expect(requirePlan("Free").prices.year).toBe(""); + }); +}); + +describe("findPlanNameByPriceId", () => { + test("maps both Pro prices to Pro", () => { + expect(findPlanNameByPriceId(catalog, PRICES.proMonthly)).toBe("Pro"); + expect(findPlanNameByPriceId(catalog, PRICES.proYearly)).toBe("Pro"); + }); + + test("maps the Free price to Free", () => { + expect(findPlanNameByPriceId(catalog, PRICES.free)).toBe("Free"); + }); + + test("returns undefined for an unknown price", () => { + expect(findPlanNameByPriceId(catalog, "price_unknown")).toBeUndefined(); + }); + + test("never matches an empty price id, even when the yearly price is unset", () => { + const withoutYearly = buildBuiltInPlans({ ...PRICES, proYearly: "" }); + + expect(findPlanNameByPriceId(withoutYearly, "")).toBeUndefined(); + }); +}); diff --git a/apps/api/tests/api/billing/billing.routes.test.ts b/apps/api/tests/api/billing/billing.routes.test.ts index cad7022d..6d17fe9b 100644 --- a/apps/api/tests/api/billing/billing.routes.test.ts +++ b/apps/api/tests/api/billing/billing.routes.test.ts @@ -77,7 +77,7 @@ describe("GET /api/v1/billing/plans", () => { await cleanDatabase(); }); - test("401 without an auth cookie", async () => { + test("200 without an auth cookie: the pricing page reads the catalog publicly", async () => { if (!(await requireDb())) { return; } @@ -87,7 +87,7 @@ describe("GET /api/v1/billing/plans", () => { new Request("http://localhost/api/v1/billing/plans") ); - expect(res.status).toBe(401); + expect(res.status).toBe(200); }); test("200 + plans array for an authenticated user", async () => { @@ -245,6 +245,38 @@ describe("POST /api/v1/billing/stripe/checkout-session", () => { expect(res.status).toBeGreaterThanOrEqual(400); expect(res.status).toBeLessThan(500); }); + + test("rejects an interval other than month or year (TypeBox validation)", async () => { + if (!(await requireDb())) { + return; + } + + const { password } = await seedVerifiedUser({ + email: "checkout-interval@example.com", + }); + const app = createApp(); + const cookie = await loginCookie( + app, + "checkout-interval@example.com", + password + ); + + const res = await app.handle( + new Request("http://localhost/api/v1/billing/stripe/checkout-session", { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ + planId: 1, + interval: "week", + successUrl: `${env.FRONTEND_URL}/billing/success`, + cancelUrl: `${env.FRONTEND_URL}/billing/cancel`, + }), + }) + ); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); }); describe("POST /api/v1/billing/stripe/portal-session", () => { diff --git a/apps/api/tests/api/billing/billing.service.test.ts b/apps/api/tests/api/billing/billing.service.test.ts index c0afd5f4..fbec7ac7 100644 --- a/apps/api/tests/api/billing/billing.service.test.ts +++ b/apps/api/tests/api/billing/billing.service.test.ts @@ -1,7 +1,13 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { getBillingService } from "../../../src/api/billing/billing.service"; +import { + BillingService, + getBillingService, + listBillingPlans, +} from "../../../src/api/billing/billing.service"; import { env } from "../../../src/config/env"; +import { FEATURE_KEYS } from "../../../src/lib/acl/acl.constants"; +import { resolveAccountFeatures } from "../../../src/lib/acl"; import { AUDIT_ACTIONS } from "../../../src/lib/audit-log"; import { ApiError } from "../../../src/lib/errors/api-error"; import { seedVerifiedUser } from "../../helpers/auth"; @@ -14,12 +20,15 @@ import { db, eq, isNull, + planFeatures, plans, requireDb, stripeWebhookEvents, } from "../../helpers/db"; import { checkoutSessionCompletedEvent, + checkoutSessionEvent, + createFakeStripe, customerSubscriptionUpdatedEvent, } from "../../helpers/stripe-webhook-fixtures"; @@ -488,3 +497,740 @@ describe("billingService.handleWebhookEvent", () => { expect(rows).toHaveLength(0); }); }); + +const FRONTEND_SUCCESS_URL = `${env.FRONTEND_URL}/billing/success`; +const FRONTEND_CANCEL_URL = `${env.FRONTEND_URL}/billing/cancel`; +const BASE_EVENT_TIME = 1_800_000_000; + +let ownerSequence = 0; + +const seedOwner = async (): Promise<{ accountId: string; userId: string }> => { + ownerSequence += 1; + + const { account, user } = await seedVerifiedUser({ + email: `owner-${String(ownerSequence)}-${String(Date.now())}@example.com`, + }); + + return { accountId: account.id, userId: user.id }; +}; + +const planIdByName = async (name: string): Promise => { + await listBillingPlans(); + + const plan = await db.query.plans.findFirst({ where: eq(plans.name, name) }); + + if (!plan) { + throw new Error(`plan ${name} not seeded`); + } + + return plan.id; +}; + +const currentPlanRow = async (accountId: string) => + db.query.accountPlans.findFirst({ + where: and( + eq(accountPlans.accountId, accountId), + isNull(accountPlans.revokedAt) + ), + }); + +const canExport = async (accountId: string): Promise => { + const features = await resolveAccountFeatures(accountId); + + return features.can_export; +}; + +const captureApiError = async ( + run: () => Promise +): Promise => { + try { + await run(); + } catch (error: unknown) { + if (error instanceof ApiError) { + return error; + } + + throw error; + } + + throw new Error("expected the call to throw an ApiError"); +}; + +describe("billingService.createCheckoutSession", () => { + beforeEach(async () => { + if (!(await requireDb())) { + return; + } + + await cleanDatabase(); + }); + + test("rejects the default Free plan with a 400 before contacting Stripe", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const freePlanId = await planIdByName("Free"); + + const error = await captureApiError(() => + new BillingService({ stripe: fake.stripe }).createCheckoutSession( + freePlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ) + ); + + expect(error.statusCode).toBe(400); + expect(fake.requests).toHaveLength(0); + }); + + test("rejects a yearly checkout when no yearly price is configured, before contacting Stripe", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const proPlanId = await planIdByName("Pro"); + const service = new BillingService({ + stripe: fake.stripe, + settings: { + enabled: true, + priceIds: { + free: "price_free", + proMonthly: "price_pro", + proYearly: "", + }, + }, + }); + + const error = await captureApiError(() => + service.createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL, + "year" + ) + ); + + expect(error.statusCode).toBe(400); + expect(error.message).toMatch(/interval/u); + expect(fake.requests).toHaveLength(0); + }); + + test("creates the Stripe customer once and a monthly session on the monthly Pro price", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const proPlanId = await planIdByName("Pro"); + + const result = await new BillingService({ + stripe: fake.stripe, + }).createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ); + + expect(result.url).toMatch(/^https:\/\/checkout\.stripe\.test\//u); + expect(fake.sessions).toHaveLength(1); + expect(fake.sessions[0]?.metadata).toEqual({ + planId: String(proPlanId), + priceId: env.STRIPE_PRICE_ID_PRO, + accountId, + }); + + const customerCreates = fake.requests.filter( + (request) => request.path === "/v1/customers" + ); + + expect(customerCreates).toHaveLength(1); + expect(customerCreates[0]?.idempotencyKey).toBe( + `account-customer:${accountId}` + ); + + const account = await db.query.accounts.findFirst({ + where: eq(accounts.id, accountId), + }); + + expect(account?.stripeCustomerId).toMatch(/^cus_fake_/u); + }); + + test("interval year uses the yearly Pro price", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const proPlanId = await planIdByName("Pro"); + + await new BillingService({ stripe: fake.stripe }).createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL, + "year" + ); + + expect(fake.sessions[0]?.metadata.priceId).toBe( + env.STRIPE_PRICE_ID_PRO_YEARLY + ); + }); + + test("two concurrent checkouts for one account create one session and return the same URL", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const service = new BillingService({ stripe: fake.stripe }); + const proPlanId = await planIdByName("Pro"); + + const [first, second] = await Promise.all([ + service.createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ), + service.createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ), + ]); + + expect(second.url).toBe(first.url); + expect(fake.sessions).toHaveLength(1); + expect( + fake.requests.filter((request) => request.path === "/v1/customers") + ).toHaveLength(1); + }); + + test("a second checkout for the same plan and interval reuses the open session", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const service = new BillingService({ stripe: fake.stripe }); + const proPlanId = await planIdByName("Pro"); + + const first = await service.createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ); + const second = await service.createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ); + + expect(second.url).toBe(first.url); + expect(fake.sessions).toHaveLength(1); + }); + + test("a different interval is a different price, so it gets its own session", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const service = new BillingService({ stripe: fake.stripe }); + const proPlanId = await planIdByName("Pro"); + + await service.createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL, + "month" + ); + await service.createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL, + "year" + ); + + expect(fake.sessions).toHaveLength(2); + }); + + test("rejects with a 409 when the local subscription row is live, without creating a session", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const proPlanId = await planIdByName("Pro"); + + await db.insert(accountPlans).values({ + accountId, + planId: proPlanId, + status: "active", + source: "stripe", + stripeSubscriptionId: "sub_local_live", + }); + + const error = await captureApiError(() => + new BillingService({ stripe: fake.stripe }).createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ) + ); + + expect(error.statusCode).toBe(409); + expect(error.message).toBe( + "Manage your existing subscription from Billing" + ); + expect(fake.sessions).toHaveLength(0); + }); + + test("an admin-granted plan without a Stripe subscription does not block checkout", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const proPlanId = await planIdByName("Pro"); + + await db.insert(accountPlans).values({ + accountId, + planId: proPlanId, + status: "active", + source: "admin_grant", + }); + + const result = await new BillingService({ + stripe: fake.stripe, + }).createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ); + + expect(result.url).toContain("https://"); + expect(fake.sessions).toHaveLength(1); + }); + + test("rejects with a 409 when Stripe lists a live subscription the webhook has not reported yet", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const proPlanId = await planIdByName("Pro"); + + await db + .update(accounts) + .set({ stripeCustomerId: "cus_webhook_lag" }) + .where(eq(accounts.id, accountId)); + fake.subscriptions.push({ + id: "sub_webhook_lag", + customer: "cus_webhook_lag", + status: "active", + }); + + const error = await captureApiError(() => + new BillingService({ stripe: fake.stripe }).createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ) + ); + + expect(error.statusCode).toBe(409); + expect(fake.sessions).toHaveLength(0); + }); + + test("a canceled local row and a canceled Stripe subscription do not block a new checkout", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const proPlanId = await planIdByName("Pro"); + + await db.insert(accountPlans).values({ + accountId, + planId: proPlanId, + status: "canceled", + source: "stripe", + stripeSubscriptionId: "sub_local_canceled", + }); + await db + .update(accounts) + .set({ stripeCustomerId: "cus_canceled" }) + .where(eq(accounts.id, accountId)); + fake.subscriptions.push({ + id: "sub_stripe_canceled", + customer: "cus_canceled", + status: "canceled", + }); + + const result = await new BillingService({ + stripe: fake.stripe, + }).createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ); + + expect(result.url).toMatch(/^https:\/\//u); + expect(fake.sessions).toHaveLength(1); + }); +}); + +describe("billingService.listPlans (public catalog)", () => { + beforeEach(async () => { + if (!(await requireDb())) { + return; + } + + await cleanDatabase(); + }); + + test("reads without a Stripe client and reports no purchasable interval when billing is disabled", async () => { + if (!(await requireDb())) { + return; + } + + const result = await listBillingPlans({ + enabled: false, + priceIds: { + free: "price_free", + proMonthly: "price_pro", + proYearly: "price_pro_yearly", + }, + }); + + expect(result.map((plan) => plan.purchasableIntervals)).toEqual( + result.map(() => []) + ); + }); + + test("reports month and year as purchasable for Pro when both prices are configured; Free is never purchasable", async () => { + if (!(await requireDb())) { + return; + } + + const result = await listBillingPlans({ + enabled: true, + priceIds: { + free: "price_free", + proMonthly: "price_pro", + proYearly: "price_pro_yearly", + }, + }); + + expect( + result.find((plan) => plan.name === "Pro")?.purchasableIntervals + ).toEqual(["month", "year"]); + expect( + result.find((plan) => plan.name === "Free")?.purchasableIntervals + ).toEqual([]); + }); + + test("reports only month when the yearly price is unset", async () => { + if (!(await requireDb())) { + return; + } + + const result = await listBillingPlans({ + enabled: true, + priceIds: { free: "price_free", proMonthly: "price_pro", proYearly: "" }, + }); + + expect( + result.find((plan) => plan.name === "Pro")?.purchasableIntervals + ).toEqual(["month"]); + }); + + test("removes a stored feature row the catalog no longer assigns to a built-in plan", async () => { + if (!(await requireDb())) { + return; + } + + await listBillingPlans(); + + const freeId = await planIdByName("Free"); + + await db.insert(planFeatures).values({ + planId: freeId, + featureKey: "max_seats", + value: { number: 99 }, + }); + + await listBillingPlans(); + + const freeRows = await db.query.planFeatures.findMany({ + where: eq(planFeatures.planId, freeId), + }); + + expect(freeRows).toHaveLength(0); + }); + + test("seeds one feature row per key for Pro and none for Free, idempotently", async () => { + if (!(await requireDb())) { + return; + } + + await listBillingPlans(); + await listBillingPlans(); + + const proId = await planIdByName("Pro"); + const freeId = await planIdByName("Free"); + const proRows = await db.query.planFeatures.findMany({ + where: eq(planFeatures.planId, proId), + }); + const freeRows = await db.query.planFeatures.findMany({ + where: eq(planFeatures.planId, freeId), + }); + + expect(proRows.map((row) => row.featureKey).sort()).toEqual( + [...FEATURE_KEYS].sort() + ); + expect(freeRows).toHaveLength(0); + }); +}); + +describe("billingService entitlements after Stripe events", () => { + beforeEach(async () => { + if (!(await requireDb())) { + return; + } + + await cleanDatabase(); + }); + + test("a paid Pro checkout resolves Pro features; Free resolves the defaults", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, customerId, proPlanId } = + await seedAccountWithStripeCustomer(); + + expect(await resolveAccountFeatures(accountId)).toEqual({ + can_export: false, + can_invite_team: false, + max_seats: 1, + }); + + await getBillingService().handleWebhookEvent( + await checkoutSessionCompletedEvent("evt_ent_paid", { + customer: customerId, + metadata: { accountId, planId: String(proPlanId) }, + subscription: "sub_ent_paid", + }) + ); + + expect(await resolveAccountFeatures(accountId)).toEqual({ + can_export: true, + can_invite_team: true, + max_seats: 10, + }); + }); + + test("an unpaid (delayed payment) checkout is incomplete and grants no paid features", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, customerId, proPlanId } = + await seedAccountWithStripeCustomer(); + + await getBillingService().handleWebhookEvent( + await checkoutSessionCompletedEvent( + "evt_unpaid_checkout", + { + customer: customerId, + metadata: { accountId, planId: String(proPlanId) }, + subscription: "sub_unpaid", + payment_status: "unpaid", + }, + BASE_EVENT_TIME + ) + ); + + const row = await currentPlanRow(accountId); + + expect(row?.status).toBe("incomplete"); + expect(row?.stripeSubscriptionId).toBe("sub_unpaid"); + expect(await canExport(accountId)).toBe(false); + }); + + test("async_payment_succeeded activates a delayed checkout and grants Pro features", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, customerId, proPlanId } = + await seedAccountWithStripeCustomer(); + const service = getBillingService(); + const session = { + customer: customerId, + metadata: { accountId, planId: String(proPlanId) }, + subscription: "sub_delayed_ok", + }; + + await service.handleWebhookEvent( + await checkoutSessionEvent( + "checkout.session.completed", + "evt_delayed_1", + { ...session, payment_status: "unpaid" }, + BASE_EVENT_TIME + ) + ); + await service.handleWebhookEvent( + await checkoutSessionEvent( + "checkout.session.async_payment_succeeded", + "evt_delayed_2", + { ...session, payment_status: "paid" }, + BASE_EVENT_TIME + 60 + ) + ); + + const activeRow = await currentPlanRow(accountId); + + expect(activeRow?.status).toBe("active"); + expect(await canExport(accountId)).toBe(true); + }); + + test("async_payment_failed marks the subscription incomplete and removes paid features", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, customerId, proPlanId } = + await seedAccountWithStripeCustomer(); + const service = getBillingService(); + const session = { + customer: customerId, + metadata: { accountId, planId: String(proPlanId) }, + subscription: "sub_delayed_fail", + }; + + await service.handleWebhookEvent( + await checkoutSessionEvent( + "checkout.session.async_payment_succeeded", + "evt_fail_1", + { ...session, payment_status: "paid" }, + BASE_EVENT_TIME + ) + ); + expect(await canExport(accountId)).toBe(true); + + await service.handleWebhookEvent( + await checkoutSessionEvent( + "checkout.session.async_payment_failed", + "evt_fail_2", + { ...session, payment_status: "unpaid" }, + BASE_EVENT_TIME + 60 + ) + ); + + const failedRow = await currentPlanRow(accountId); + + expect(failedRow?.status).toBe("incomplete"); + expect(await canExport(accountId)).toBe(false); + }); + + test("customer.subscription.updated maps the yearly Pro price to the Pro plan", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, customerId } = await seedAccountWithStripeCustomer(); + + await getBillingService().handleWebhookEvent( + await customerSubscriptionUpdatedEvent("evt_yearly_sub", { + id: "sub_yearly", + customer: customerId, + status: "active", + created: 1_700_000_000, + items: { + data: [ + { + price: { id: env.STRIPE_PRICE_ID_PRO_YEARLY }, + current_period_end: 1_800_000_000, + }, + ], + }, + }) + ); + + const row = await currentPlanRow(accountId); + const proId = await planIdByName("Pro"); + + expect(row?.planId).toBe(proId); + expect(row?.status).toBe("active"); + }); + + test("customer.subscription.updated ignores a price that no built-in plan sells", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, customerId } = await seedAccountWithStripeCustomer(); + + await getBillingService().handleWebhookEvent( + await customerSubscriptionUpdatedEvent("evt_unknown_price", { + id: "sub_unknown_price", + customer: customerId, + status: "active", + created: 1_700_000_000, + items: { + data: [ + { + price: { id: "price_not_ours" }, + current_period_end: 1_800_000_000, + }, + ], + }, + }) + ); + + expect(await currentPlanRow(accountId)).toBeUndefined(); + }); +}); diff --git a/apps/api/tests/api/notifications/events/index.test.ts b/apps/api/tests/api/notifications/events/index.test.ts new file mode 100644 index 00000000..1719bb90 --- /dev/null +++ b/apps/api/tests/api/notifications/events/index.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +/* + * Import-cycle guard. The `lib/notifications` barrel re-exports dispatch and + * channels, which reach `config/setup`, which registers these very event + * files. An event file that imports the barrel re-enters the cycle and can + * leave `defineNotificationEvent` in its temporal dead zone. Event files must + * import the helper and types from their own modules. + */ +const EVENTS_DIR = join( + import.meta.dir, + "../../../../src/api/notifications/events" +); + +const BARREL_IMPORT = /from "[^"]*\/lib\/notifications";/; + +const sourceFiles = readdirSync(EVENTS_DIR).filter((name) => + name.endsWith(".ts") +); + +describe("notification event files", () => { + test("there are event files to check", () => { + expect( + sourceFiles.filter((name) => name.endsWith(".event.ts")).length + ).toBeGreaterThan(0); + }); + + test.each(sourceFiles)( + "%s imports from its own modules, not the lib/notifications barrel", + (name) => { + const source = readFileSync(join(EVENTS_DIR, name), "utf8"); + + expect(BARREL_IMPORT.test(source)).toBe(false); + } + ); +}); diff --git a/apps/api/tests/api/users/users.routes.test.ts b/apps/api/tests/api/users/users.routes.test.ts index 8e9f0fcc..08e11613 100644 --- a/apps/api/tests/api/users/users.routes.test.ts +++ b/apps/api/tests/api/users/users.routes.test.ts @@ -151,6 +151,72 @@ describe("GET /api/v1/users/me", () => { expect(res.status).toBe(401); }); + + test("401s when only a refresh cookie is left, so the client refreshes the session", async () => { + if (!(await requireDb())) { + return; + } + + const app = createApp(); + const res = await app.handle( + new Request(ME_URL, { + headers: { cookie: "refresh_token=an-opaque-session-token" }, + }) + ); + + /* + * The access cookie expires after 15 minutes while the refresh session + * lives for 30 days. A 200 `{ user: null }` here would sign the user out + * on every reload; the 401 lets the client refresh and retry. + */ + expect(res.status).toBe(401); + }); + + test("a refresh-only 401 recovers: the refreshed access cookie reads the profile", async () => { + if (!(await requireDb())) { + return; + } + + const email = "refresh-renewal@example.com"; + + await seedVerifiedUser({ + email, + password: PASSWORD, + firstName: "Renew", + lastName: "Me", + }); + + const app = createApp(); + const loginRes = await app.handle( + new Request("http://localhost/api/v1/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, password: PASSWORD }), + }) + ); + const refreshCookie = extractCookiePair( + loginRes.headers.get("set-cookie"), + "refresh_token" + ); + + expect(refreshCookie).not.toBe(""); + + const renewed = await app.handle( + new Request("http://localhost/api/v1/auth/refresh", { + method: "POST", + headers: { cookie: refreshCookie }, + }) + ); + const renewedAuth = extractCookiePair( + renewed.headers.get("set-cookie"), + "auth_token" + ); + const me = await app.handle( + new Request(ME_URL, { headers: { cookie: renewedAuth } }) + ); + + expect(me.status).toBe(200); + }); }); describe("PATCH /api/v1/users/me", () => { diff --git a/apps/api/tests/clients/postgres/schema/index.test.ts b/apps/api/tests/clients/postgres/schema/index.test.ts new file mode 100644 index 00000000..32f01490 --- /dev/null +++ b/apps/api/tests/clients/postgres/schema/index.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; + +import { + CLEANUP_TARGETS, + REFERENCE_TABLES, + schemaTableNames, +} from "../../../helpers/db"; + +/* + * Every table the Drizzle schema defines must be wiped between tests or + * declared as reference data. A new user-data table that is missing from + * CLEANUP_TARGETS would otherwise leak rows into later tests. + */ +const cleanupSet = new Set(CLEANUP_TARGETS); +const referenceSet = new Set(REFERENCE_TABLES); + +describe("test database cleanup coverage", () => { + const schemaTables = schemaTableNames(); + + test("the Drizzle schema exposes tables to derive from", () => { + expect(schemaTables.length).toBeGreaterThan(0); + }); + + test("every schema table is cleared between tests or listed as reference data", () => { + const unlisted = schemaTables.filter( + (table) => !cleanupSet.has(table) && !referenceSet.has(table) + ); + + expect(unlisted).toEqual([]); + }); + + test("no table is both cleared and treated as reference data", () => { + const overlap = CLEANUP_TARGETS.filter((table) => referenceSet.has(table)); + + expect(overlap).toEqual([]); + }); + + test("every cleanup and reference entry names a table that exists in the schema", () => { + const schemaSet = new Set(schemaTables); + const stale = [...CLEANUP_TARGETS, ...REFERENCE_TABLES].filter( + (table) => !schemaSet.has(table) + ); + + expect(stale).toEqual([]); + }); +}); diff --git a/apps/api/tests/config/env/validate.test.ts b/apps/api/tests/config/env/validate.test.ts index 21225946..6bd2674d 100644 --- a/apps/api/tests/config/env/validate.test.ts +++ b/apps/api/tests/config/env/validate.test.ts @@ -147,6 +147,8 @@ describe("toCsv", () => { type TestEnv = Record; +const STRIPE_WEBHOOK_TEST_SECRET = "test-stripe-webhook-secret"; + let testEnv: TestEnv; const seedValid = (): TestEnv => ({ @@ -375,7 +377,7 @@ describe("validateEnv", () => { it("rejects a placeholder Stripe secret when BILLING_ENABLED=true", () => { testEnv.BILLING_ENABLED = "true"; testEnv.STRIPE_SECRET_KEY = "your-stripe-secret-key"; - testEnv.STRIPE_WEBHOOK_SECRET = "test-stripe-webhook-secret"; + testEnv.STRIPE_WEBHOOK_SECRET = STRIPE_WEBHOOK_TEST_SECRET; testEnv.STRIPE_PRICE_ID_FREE = "price_free"; testEnv.STRIPE_PRICE_ID_PRO = "price_pro"; testEnv.RESEND_API_KEY = "rk_test"; @@ -387,13 +389,49 @@ describe("validateEnv", () => { it("accepts real-looking Stripe secrets when BILLING_ENABLED=true", () => { testEnv.BILLING_ENABLED = "true"; testEnv.STRIPE_SECRET_KEY = "sk_test_51RealKeyValue"; - testEnv.STRIPE_WEBHOOK_SECRET = "test-stripe-webhook-secret"; + testEnv.STRIPE_WEBHOOK_SECRET = STRIPE_WEBHOOK_TEST_SECRET; testEnv.STRIPE_PRICE_ID_FREE = "price_free"; testEnv.STRIPE_PRICE_ID_PRO = "price_pro"; testEnv.RESEND_API_KEY = "rk_test"; expect(() => validateEnv(testEnv)).not.toThrow(); }); + it("STRIPE_PRICE_ID_PRO_YEARLY is optional and defaults to empty (yearly not offered)", () => { + testEnv.BILLING_ENABLED = "true"; + testEnv.STRIPE_SECRET_KEY = "sk_test_51RealKeyValue"; + testEnv.STRIPE_WEBHOOK_SECRET = STRIPE_WEBHOOK_TEST_SECRET; + testEnv.STRIPE_PRICE_ID_FREE = "price_free"; + testEnv.STRIPE_PRICE_ID_PRO = "price_pro"; + testEnv.RESEND_API_KEY = "rk_test"; + expect(validateEnv(testEnv).STRIPE_PRICE_ID_PRO_YEARLY).toBe(""); + }); + + it("parses STRIPE_PRICE_ID_PRO_YEARLY when it differs from the monthly price", () => { + testEnv.BILLING_ENABLED = "true"; + testEnv.STRIPE_SECRET_KEY = "sk_test_51RealKeyValue"; + testEnv.STRIPE_WEBHOOK_SECRET = STRIPE_WEBHOOK_TEST_SECRET; + testEnv.STRIPE_PRICE_ID_FREE = "price_free"; + testEnv.STRIPE_PRICE_ID_PRO = "price_pro"; + testEnv.STRIPE_PRICE_ID_PRO_YEARLY = "price_pro_yearly"; + testEnv.RESEND_API_KEY = "rk_test"; + expect(validateEnv(testEnv).STRIPE_PRICE_ID_PRO_YEARLY).toBe( + "price_pro_yearly" + ); + }); + + it("rejects STRIPE_PRICE_ID_PRO_YEARLY equal to the monthly Pro price", () => { + testEnv.BILLING_ENABLED = "true"; + testEnv.STRIPE_SECRET_KEY = "sk_test_51RealKeyValue"; + testEnv.STRIPE_WEBHOOK_SECRET = STRIPE_WEBHOOK_TEST_SECRET; + testEnv.STRIPE_PRICE_ID_FREE = "price_free"; + testEnv.STRIPE_PRICE_ID_PRO = "price_pro"; + testEnv.STRIPE_PRICE_ID_PRO_YEARLY = "price_pro"; + testEnv.RESEND_API_KEY = "rk_test"; + expect(() => validateEnv(testEnv)).toThrow( + /STRIPE_PRICE_ID_PRO_YEARLY must differ from STRIPE_PRICE_ID_PRO/ + ); + }); + it("requires VALKEY_PASSWORD in production with QUEUES_ENABLED=true", () => { testEnv.NODE_ENV = "production"; testEnv.ALLOWED_ORIGINS = "https://example.com"; diff --git a/apps/api/tests/helpers/db.ts b/apps/api/tests/helpers/db.ts index 2f70ba35..35c62b31 100644 --- a/apps/api/tests/helpers/db.ts +++ b/apps/api/tests/helpers/db.ts @@ -11,8 +11,11 @@ * Use an isolated migrated test database, never a development database containing * product data. Run the default lane with `bun run test`, not bare `bun test`. */ -import { sql } from "drizzle-orm"; +import { is, sql } from "drizzle-orm"; +import { getTableConfig, PgTable } from "drizzle-orm/pg-core"; + import { db } from "../../src/clients/postgres"; +import * as schema from "../../src/clients/postgres/schema"; /** * True when a SELECT 1 succeeds against the configured DATABASE_URL. @@ -86,10 +89,10 @@ export const requireDb = async (): Promise => { /** * Tables to clear between tests, in child-first dependency order. - * Seed-like tables (`billing.plans`, `billing.features`) are intentionally - * omitted so fixtures referencing seeded plans keep resolving. + * Every user-data table in the Drizzle schema must appear here or in + * `REFERENCE_TABLES`; `tests/helpers/db.test.ts` fails otherwise. */ -const CLEANUP_TARGETS = [ +export const CLEANUP_TARGETS = [ "audit.audit_log", "audit.redactions", /* @@ -120,6 +123,32 @@ const CLEANUP_TARGETS = [ "auth.users", ] as const; +/** + * Seed and reference tables that are deliberately never cleared, so fixtures + * referencing seeded plans keep resolving. Adding a table here means it is + * installed by migrations or seeding and is not created by tests; a table that + * holds per-user or per-test rows belongs in `CLEANUP_TARGETS` instead. + */ +export const REFERENCE_TABLES = [ + // Plan catalogue seeded by billing migrations; fixtures assume these rows. + "billing.plans", + "billing.plan_features", +] as const; + +/** + * Fully qualified `schema.table` name for every table defined in the Drizzle + * schema module. Derived from the schema itself so a new table cannot be + * missed by a hand-maintained list. + */ +export const schemaTableNames = (): string[] => + Object.values(schema) + .filter((value) => is(value, PgTable)) + .map((table) => { + const config = getTableConfig(table); + + return `${config.schema ?? "public"}.${config.name}`; + }); + /* * Arbitrary 64-bit key for the advisory lock that serialises cleanup * calls across concurrent test workers. The value itself is meaningless; diff --git a/apps/api/tests/helpers/stripe-webhook-fixtures.ts b/apps/api/tests/helpers/stripe-webhook-fixtures.ts index 04d27c29..e93e2c0c 100644 --- a/apps/api/tests/helpers/stripe-webhook-fixtures.ts +++ b/apps/api/tests/helpers/stripe-webhook-fixtures.ts @@ -38,18 +38,41 @@ const buildTestEvent = async ( ); }; +export interface ICheckoutSessionFixture { + customer: string; + metadata?: Record; + /** The subscription the checkout created, when there is one. */ + subscription?: string; + /** Defaults to `paid`, the value a card checkout reports. */ + payment_status?: "paid" | "unpaid" | "no_payment_required"; +} + +export type CheckoutSessionEventType = + | "checkout.session.completed" + | "checkout.session.async_payment_succeeded" + | "checkout.session.async_payment_failed"; + +/** Any of the three checkout events the billing service consumes. */ +export const checkoutSessionEvent = ( + type: CheckoutSessionEventType, + id: string, + session: ICheckoutSessionFixture, + eventCreated?: number +): Promise => + buildTestEvent( + id, + type, + { payment_status: "paid", ...session }, + eventCreated + ); + /** Minimal fields the billing service reads from `checkout.session.completed`. */ export const checkoutSessionCompletedEvent = ( id: string, - session: { - customer: string; - metadata?: Record; - /** The subscription the completed checkout created, when there is one. */ - subscription?: string; - }, + session: ICheckoutSessionFixture, eventCreated?: number ): Promise => - buildTestEvent(id, "checkout.session.completed", session, eventCreated); + checkoutSessionEvent("checkout.session.completed", id, session, eventCreated); /** Minimal fields the billing service reads from `customer.subscription.updated`. */ export const customerSubscriptionUpdatedEvent = ( @@ -98,3 +121,170 @@ export const customerSubscriptionDeletedEvent = ( subscription, eventCreated ); + +/* + * A real `Stripe` client whose HTTP layer is a fake. The SDK still encodes the + * request and parses the response, so the code under test uses the same + * client type and parameter shapes as production, and nothing leaves the + * process. Only the four endpoints the billing service calls are modelled. + */ +export interface IFakeStripeRequest { + readonly method: string; + readonly path: string; + readonly params: URLSearchParams; + readonly idempotencyKey: string | null; +} + +export interface IFakeStripeSubscription { + readonly id: string; + readonly customer: string; + readonly status: string; +} + +export interface IFakeStripeSession { + readonly id: string; + readonly customer: string; + readonly url: string; + readonly mode: string; + readonly status: string; + readonly metadata: Record; +} + +export interface IFakeStripe { + readonly stripe: Stripe; + readonly requests: IFakeStripeRequest[]; + readonly subscriptions: IFakeStripeSubscription[]; + readonly sessions: IFakeStripeSession[]; +} + +const HTTP_METHOD = { get: "GET", post: "POST" } as const; + +const ENDPOINT = { + customers: "/v1/customers", + subscriptions: "/v1/subscriptions", + checkoutSessions: "/v1/checkout/sessions", +} as const; + +const SESSION_OPEN = "open" as const; + +const jsonResponse = (status: number, value: unknown): Response => + new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); + +const metadataFromParams = ( + params: URLSearchParams +): Record => { + const metadata: Record = {}; + + for (const [key, value] of params.entries()) { + const match = /^metadata\[(.+)\]$/u.exec(key); + + if (match?.[1] !== undefined) { + metadata[match[1]] = value; + } + } + + return metadata; +}; + +export const createFakeStripe = (): IFakeStripe => { + const requests: IFakeStripeRequest[] = []; + const subscriptions: IFakeStripeSubscription[] = []; + const sessions: IFakeStripeSession[] = []; + let sequence = 0; + + const nextId = (prefix: string): string => { + sequence += 1; + + return `${prefix}_fake_${String(sequence)}`; + }; + + const route = ( + method: string, + url: URL, + params: URLSearchParams + ): Response => { + const path = url.pathname; + const customer = params.get("customer"); + + if (method === HTTP_METHOD.post && path === ENDPOINT.customers) { + return jsonResponse(200, { id: nextId("cus"), object: "customer" }); + } + + if (method === HTTP_METHOD.get && path === ENDPOINT.subscriptions) { + return jsonResponse(200, { + object: "list", + has_more: false, + url: path, + data: subscriptions.filter((item) => item.customer === customer), + }); + } + + if (method === HTTP_METHOD.get && path === ENDPOINT.checkoutSessions) { + return jsonResponse(200, { + object: "list", + has_more: false, + url: path, + data: sessions.filter( + (item) => item.customer === customer && item.status === SESSION_OPEN + ), + }); + } + + if (method === HTTP_METHOD.post && path === ENDPOINT.checkoutSessions) { + const session: IFakeStripeSession = { + id: nextId("cs"), + customer: customer ?? "", + url: `https://checkout.stripe.test/${nextId("pay")}`, + mode: params.get("mode") ?? "", + status: SESSION_OPEN, + metadata: metadataFromParams(params), + }; + + sessions.push(session); + + return jsonResponse(200, { ...session, object: "checkout.session" }); + } + + return jsonResponse(404, { + error: { + type: "invalid_request_error", + message: `no fake for ${method} ${path}`, + }, + }); + }; + + const fetchImpl = ( + input: string | URL | Request, + init?: RequestInit + ): Promise => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const method = init?.method ?? HTTP_METHOD.get; + const rawForm = init?.body; + const params = + method === HTTP_METHOD.get + ? url.searchParams + : new URLSearchParams(typeof rawForm === "string" ? rawForm : ""); + + requests.push({ + method, + path: url.pathname, + params, + idempotencyKey: new Headers(init?.headers).get("idempotency-key"), + }); + + return Promise.resolve(route(method, url, params)); + }; + + /* Bun's `typeof fetch` also carries `preconnect`; the fake must match it. */ + const fakeFetch = Object.assign(fetchImpl, { preconnect: () => undefined }); + + const stripe = new Stripe("sk_test_fake_for_tests", { + httpClient: Stripe.createFetchHttpClient(fakeFetch), + maxNetworkRetries: 0, + }); + + return { stripe, requests, subscriptions, sessions }; +}; diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts index 3f750f0c..519fbf76 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -38,6 +38,7 @@ import { checkWorkflowConcurrencyExplicit, checkWorkflowExpressionSyntax, checkWorkflowPathsFilterParity, + checkWorkflowPathsFilterPermissions, checkWorkflowPipInstallPinned, checkWorkflowRunnerPinned, checkWorkflowSecurityNoCancel, @@ -663,6 +664,141 @@ describe("checkWorkflowPathsFilterParity", () => { }); }); +describe("checkWorkflowConcurrencyExplicit expressions", () => { + test("accepts a conditional cancel-in-progress expression as explicit", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-concurrency-expr-")); + + try { + const file = writeNamedWorkflow( + root, + "wf.yml", + [ + "concurrency:", + " group: x-${{ github.ref }}", + " cancel-in-progress: ${{ github.event_name == 'pull_request' }}", + "", + "jobs: {}", + "", + ].join("\n") + ); + + expect(checkWorkflowConcurrencyExplicit(file)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("checkWorkflowPathsFilterPermissions", () => { + const CONTENTS_READ = " contents: read"; + const JOBS_HEADER = "jobs:"; + const PERMISSIONS_HEADER = "permissions:"; + const FILTER_STEP = [ + " steps:", + " - uses: dorny/paths-filter@abc", + " with:", + " filters: |", + " code:", + " - 'apps/x/**'", + ]; + + const writeWorkflow = (lines: string[]): { root: string; file: string } => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-filter-perms-")); + + return { root, file: writeNamedWorkflow(root, "wf.yml", lines.join("\n")) }; + }; + + test("flags a paths-filter job without pull-requests read in effect", () => { + const { root, file } = writeWorkflow([ + PERMISSIONS_HEADER, + CONTENTS_READ, + "", + JOBS_HEADER, + " validate:", + ...FILTER_STEP, + "", + ]); + + try { + const messages = checkWorkflowPathsFilterPermissions(file); + + expect(messages).toHaveLength(1); + expect(messages[0]?.rule).toBe("github-actions-paths-filter-permissions"); + expect(messages[0]?.message).toContain("'validate'"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("accepts workflow-level, job-level, read-all and flow-style grants", () => { + const { root, file } = writeWorkflow([ + PERMISSIONS_HEADER, + CONTENTS_READ, + " pull-requests: read", + "", + JOBS_HEADER, + " workflow-level:", + ...FILTER_STEP, + " job-level:", + " permissions:", + " contents: read", + " pull-requests: read", + ...FILTER_STEP, + " read-all:", + " permissions: read-all", + ...FILTER_STEP, + " flow:", + " permissions: {contents: read, pull-requests: read}", + ...FILTER_STEP, + "", + ]); + + try { + expect(checkWorkflowPathsFilterPermissions(file)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("a job-level block replaces the workflow grant instead of merging", () => { + const { root, file } = writeWorkflow([ + PERMISSIONS_HEADER, + CONTENTS_READ, + " pull-requests: read", + "", + JOBS_HEADER, + " narrowed:", + " permissions:", + " contents: read", + ...FILTER_STEP, + " no-filter:", + " steps:", + " - run: echo hi", + "", + ]); + + try { + const messages = checkWorkflowPathsFilterPermissions(file); + + expect(messages).toHaveLength(1); + expect(messages[0]?.message).toContain("'narrowed'"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("the shipped workflows all grant pull-requests read to their filter jobs", () => { + const workflowsDir = join( + dirname(fileURLToPath(import.meta.url)), + "../../../../.github/workflows" + ); + const files = findWorkflows(workflowsDir); + const violations = files.flatMap(checkWorkflowPathsFilterPermissions); + + expect(violations).toEqual([]); + }); +}); + describe("checkWorkflowPipInstallPinned", () => { test("flags pip install without a version pin", () => { const root = mkdtempSync(join(tmpdir(), "lint-meta-pip-pin-")); diff --git a/apps/api/tests/middleware/error-handler.test.ts b/apps/api/tests/middleware/error-handler.test.ts index 89214f0f..0272b0ee 100644 --- a/apps/api/tests/middleware/error-handler.test.ts +++ b/apps/api/tests/middleware/error-handler.test.ts @@ -1,7 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { logger } from "../../src/config/logger"; -import { errorHandler } from "../../src/middleware/error-handler"; +import { errorHandler, stackFor } from "../../src/middleware/error-handler"; import { ApiError, ApiErrors } from "../../src/lib/errors"; interface ISet { @@ -327,4 +327,46 @@ describe("errorHandler", () => { warnSpy.mockRestore(); } }); + + test("VALIDATION and PARSE never carry a stack, even in development", () => { + const warnSpy = spyOn(logger, "warn"); + const leakedSecret = "SUPER_SECRET_PASSWORD_VALUE"; + + try { + const set: ISet = {}; + + errorHandler({ + code: "VALIDATION", + error: new Error(`Received: { "password": "${leakedSecret}" }`), + set, + }); + + const [, payload] = warnSpy.mock.calls[0] ?? []; + + expect(hasStack(payload)).toBe(false); + expect(stackFor("VALIDATION", new Error("x"), true)).toBeUndefined(); + expect(stackFor("PARSE", new Error("x"), true)).toBeUndefined(); + } finally { + warnSpy.mockRestore(); + } + }); + + test("other errors keep their stack in development and drop it elsewhere", () => { + const boom = new Error("boom"); + + expect(stackFor("INTERNAL_SERVER_ERROR", boom, true)).toBe(boom.stack); + expect(stackFor("INTERNAL_SERVER_ERROR", boom, false)).toBeUndefined(); + expect( + stackFor("INTERNAL_SERVER_ERROR", "not an error", true) + ).toBeUndefined(); + }); }); + +function hasStack(payload: unknown): boolean { + return ( + payload !== null && + typeof payload === "object" && + "stack" in payload && + payload.stack !== undefined + ); +} diff --git a/apps/api/tests/setup-test-env.ts b/apps/api/tests/setup-test-env.ts index 81e20982..aec93836 100644 --- a/apps/api/tests/setup-test-env.ts +++ b/apps/api/tests/setup-test-env.ts @@ -83,6 +83,7 @@ process.env.STRIPE_SECRET_KEY = ["sk", "test", "fake", "for", "tests"].join( process.env.STRIPE_WEBHOOK_SECRET = ["whsec", "fake", "for", "tests"].join("_"); process.env.STRIPE_PRICE_ID_FREE = "price_test_free"; process.env.STRIPE_PRICE_ID_PRO = "price_test_pro"; +process.env.STRIPE_PRICE_ID_PRO_YEARLY = "price_test_pro_yearly"; /* * Email webhook secrets. The Resend signing secret is a constant whsec diff --git a/apps/docs/src/content/docs/api/billing.mdx b/apps/docs/src/content/docs/api/billing.mdx index 4018233a..a1423f76 100644 --- a/apps/docs/src/content/docs/api/billing.mdx +++ b/apps/docs/src/content/docs/api/billing.mdx @@ -20,18 +20,29 @@ sequenceDiagram UI->>API: POST /api/v1/billing/stripe/checkout-session API->>API: verify auth_token cookie + resolve active account API->>API: allowlist successUrl + cancelUrl against FRONTEND_URL origin - API->>DB: upsert Free / Pro plans from STRIPE_PRICE_ID_* - API->>Stripe: create checkout session (account.stripe_customer_id) + API->>DB: upsert Free / Pro plans and Pro's features from the plan catalog + API->>DB: lock the account row (SELECT ... FOR UPDATE) + API->>Stripe: 409 if the account already has a live subscription + API->>Stripe: reuse an open checkout session, or create one API->>DB: update account.stripe_customer_id when first needed API-->>UI: { url } UI->>Stripe: redirect browser to hosted checkout ``` +Checkout refuses the default (Free) plan, and returns 409 when the account +already has a live subscription, locally or in Stripe (its webhook may not +have arrived yet). Concurrent requests are serialized on the account row, so a +double click yields one session. The body takes an optional `interval` +(`month`, the default, or `year`); `year` needs `STRIPE_PRICE_ID_PRO_YEARLY`. + +After Stripe redirects back with `?checkout=success`, the billing page polls +the subscription until the webhook has landed, then refreshes the session. + The customer portal follows the same pattern: authenticated user, `returnUrl` allowlisted against `FRONTEND_URL`, Stripe returns a hosted URL. ## Design -Billing is feature-flagged so local apps boot without Stripe credentials. Plans come from env (`STRIPE_PRICE_ID_FREE` and `STRIPE_PRICE_ID_PRO`) and upsert default plan rows without manual SQL. Customer routes (checkout, portal, plan reads) use the same cookie-auth OpenAPI contract as the rest of the API. Stripe-hosted flows may only return to the configured `FRONTEND_URL` origin. +Billing is feature-flagged so local apps boot without Stripe credentials. Plans and what they grant live in one typed catalog, `apps/api/src/api/billing/billing.plans.ts`, with Stripe price ids from env. Every plan read upserts the plan rows and their `plan_features`, so a fresh database needs no manual SQL. Pro must give every feature key a value; the type check and a unit test fail until it does. `GET /billing/plans` is public, so a pricing page can read it without a session, and it answers even with billing off. Checkout, portal and subscription routes use cookie auth. Stripe-hosted flows may only return to the configured `FRONTEND_URL` origin. The webhook route passes the exact request payload to Stripe signature verification (raw body verification). Every Stripe event ID is claimed in the same transaction as the side effect, providing Postgres-backed idempotency. @@ -55,10 +66,10 @@ 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/#feature-gates). +- `checkout.session.completed`: creates or updates `billing.account_plans` for the account and plan in session metadata. The plan is `active` only when `payment_status` is `paid` or `no_payment_required`; a delayed payment method (bank debit) leaves it `incomplete`, which grants nothing. +- `checkout.session.async_payment_succeeded` / `checkout.session.async_payment_failed`: settle a delayed payment to `active` or `incomplete`. +- `customer.subscription.updated`: maps the active Stripe price id (monthly or yearly) back to a local plan through the catalog 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`: handles deletion only for the matching subscription; an older subscription cannot revoke its replacement. Effective access follows the resulting status and period. -- `invoice.paid` / `invoice.payment_failed`: status transitions for the active plan row. Unknown event types are logged at debug level and ignored. Event identity prevents duplicate processing; ordering checks and subscription identity prevent stale deliveries from overwriting the current subscription. A deletion for an older subscription must not revoke its replacement. Same-second checkout and subscription updates preserve identity, status and period in either tested delivery order; a delayed checkout must not restore a delinquent subscription. These cases do not establish correctness for every possible provider event sequence. @@ -83,7 +94,8 @@ Sensitive subscription reads recheck membership and return 403 for a revoked mem - `BILLING_ENABLED`: Turns the route group on; false means all billing paths return 404. - `STRIPE_SECRET_KEY`: Used by the Stripe SDK for Checkout, Portal, and webhook construction. - `STRIPE_WEBHOOK_SECRET`: Used to verify Stripe-Signature on raw webhook payloads. -- `STRIPE_PRICE_ID_FREE` / `STRIPE_PRICE_ID_PRO`: Seeds and keeps the built-in plan rows aligned with Stripe. +- `STRIPE_PRICE_ID_FREE` / `STRIPE_PRICE_ID_PRO`: Seeds and keeps the built-in plan rows aligned with Stripe. `STRIPE_PRICE_ID_PRO` is the monthly price. +- `STRIPE_PRICE_ID_PRO_YEARLY` (optional): a yearly Pro price. Empty means yearly checkout is not offered.