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.
) : null}
+ {isError ? (
+
+ ) : null}
+
{isEmpty ? (
{t("notifications.empty")}
diff --git a/apps/ui/src/features/notifications/components/NotificationsPage/NotificationsPage.types.ts b/apps/ui/src/features/notifications/components/NotificationsPage/NotificationsPage.types.ts
index bff3df32..06373294 100644
--- a/apps/ui/src/features/notifications/components/NotificationsPage/NotificationsPage.types.ts
+++ b/apps/ui/src/features/notifications/components/NotificationsPage/NotificationsPage.types.ts
@@ -17,4 +17,7 @@ export interface INotificationsPageView {
readonly onMarkAllRead: () => void;
readonly isMarkingAllRead: boolean;
readonly isEmpty: boolean;
+ readonly isError: boolean;
+ readonly isRetrying: boolean;
+ readonly onRetry: () => void;
}
diff --git a/apps/ui/src/lib/api/openapi.test.ts b/apps/ui/src/lib/api/openapi.test.ts
index 5da60805..e143da60 100644
--- a/apps/ui/src/lib/api/openapi.test.ts
+++ b/apps/ui/src/lib/api/openapi.test.ts
@@ -284,6 +284,159 @@ describe("tokenRefresh middleware", () => {
});
});
+describe("tokenRefresh middleware: request bodies on retry", () => {
+ const refreshOk = () =>
+ jsonResponse(200, {
+ success: true,
+ data: {
+ user: {
+ id: "u1",
+ email: "u@example.com",
+ firstName: "U",
+ lastName: "Ser",
+ emailVerified: true
+ }
+ },
+ timestamp: "2026-06-01T00:00:00.000Z"
+ });
+
+ const subscribeBody = {
+ endpoint: "https://push.example.test/sub/1",
+ keys: { p256dh: "p256dh-key", auth: "auth-key" }
+ };
+
+ it("re-sends the original JSON body when a POST is retried after refresh", async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse(401, { message: "expired" })) // original
+ .mockResolvedValueOnce(refreshOk()) // refresh
+ .mockResolvedValueOnce(jsonResponse(200, { ok: true })); // retry
+ const client = await importClient();
+
+ await (
+ client as unknown as {
+ POST: (path: string, opts: unknown) => Promise;
+ }
+ ).POST("/api/v1/notifications/push/subscribe", { body: subscribeBody });
+
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ const [, , retry] = fetchMock.mock.calls;
+ const [retryUrl, retryInit] = retry ?? [];
+
+ const retryUrlText =
+ typeof retryUrl === "string" ? retryUrl : (retryUrl as Request).url;
+
+ expect(retryUrlText).toContain("/api/v1/notifications/push/subscribe");
+ expect(retryInit?.method).toBe("POST");
+ expect(retryInit?.body).toBe(JSON.stringify(subscribeBody));
+ });
+
+ it("still sends the original body on the first, pre-refresh attempt", async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse(401, { message: "expired" }))
+ .mockResolvedValueOnce(refreshOk())
+ .mockResolvedValueOnce(jsonResponse(200, { ok: true }));
+ const client = await importClient();
+
+ await (
+ client as unknown as {
+ POST: (path: string, opts: unknown) => Promise;
+ }
+ ).POST("/api/v1/notifications/push/subscribe", { body: subscribeBody });
+
+ const [original] = fetchMock.mock.calls[0] ?? [];
+
+ expect(original).toBeInstanceOf(Request);
+ expect(await (original as Request).text()).toBe(
+ JSON.stringify(subscribeBody)
+ );
+ });
+
+ it("retries a GET after refresh without sending a body", async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse(401, { message: "expired" }))
+ .mockResolvedValueOnce(refreshOk())
+ .mockResolvedValueOnce(jsonResponse(200, { id: "u1" }));
+ const client = await importClient();
+
+ const { data } = await (
+ client as unknown as {
+ GET: (path: string) => Promise<{ data: unknown }>;
+ }
+ ).GET("/api/v1/users/me");
+
+ expect(data).toEqual({ id: "u1" });
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ const [, retryInit] = fetchMock.mock.calls[2] ?? [];
+
+ expect(retryInit?.body).toBeNull();
+ });
+});
+
+describe("/users/me through the refresh middleware (fetchCurrentMe)", () => {
+ const refreshedUser = {
+ success: true,
+ data: {
+ user: {
+ id: "u1",
+ email: "u@example.com",
+ firstName: "U",
+ lastName: "Ser",
+ emailVerified: true
+ }
+ },
+ timestamp: "2026-06-01T00:00:00.000Z"
+ };
+
+ async function importFetchCurrentMe() {
+ const mod = await import("@/lib/session/fetchMe");
+
+ return mod.fetchCurrentMe;
+ }
+
+ it("resolves to the user when the access cookie expired and the refresh succeeds", async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse(401, { message: "expired" }))
+ .mockResolvedValueOnce(jsonResponse(200, refreshedUser))
+ .mockResolvedValueOnce(
+ jsonResponse(200, {
+ user: { id: "u1", email: "u@example.com" },
+ role: "owner"
+ })
+ );
+ const fetchCurrentMe = await importFetchCurrentMe();
+
+ const me = await fetchCurrentMe();
+
+ expect(me?.user.id).toBe("u1");
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ });
+
+ it("resolves to null (logged out) when the refresh fails, without a second refresh", async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse(401, { message: "expired" }))
+ .mockResolvedValueOnce(
+ jsonResponse(200, {
+ success: true,
+ data: { user: null },
+ timestamp: "2026-06-01T00:00:00.000Z"
+ })
+ );
+ const fetchCurrentMe = await importFetchCurrentMe();
+
+ await expect(fetchCurrentMe()).resolves.toBeNull();
+
+ // Original /me + one anonymous refresh probe; no retry, no loop.
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ const refreshCalls = fetchMock.mock.calls.filter(([req]) => {
+ const url = typeof req === "string" ? req : (req as Request).url;
+
+ return url.includes("/auth/refresh");
+ });
+
+ expect(refreshCalls).toHaveLength(1);
+ });
+});
+
describe("throwOnError middleware", () => {
it("throws ApiError with parsed body fields on a non-2xx JSON response", async () => {
fetchMock.mockResolvedValueOnce(
diff --git a/apps/ui/src/lib/api/openapi.ts b/apps/ui/src/lib/api/openapi.ts
index b387f42b..300ee28b 100644
--- a/apps/ui/src/lib/api/openapi.ts
+++ b/apps/ui/src/lib/api/openapi.ts
@@ -108,7 +108,23 @@ function isRefreshableEndpoint(url: string): boolean {
return !url.includes("/auth/refresh") && !url.includes("/auth/login");
}
+/*
+ * Serialized request bodies, keyed by the Request openapi-fetch dispatches.
+ * The body is read in `onRequest`, before dispatch, from a clone; by the time
+ * `onResponse` runs the original Request's body stream has been consumed, so
+ * it cannot be cloned or read again. Keeping the text here lets a retry
+ * re-send the same payload. A WeakMap releases each entry with its Request.
+ */
+const serializedBodies = new WeakMap();
+
const tokenRefresh: Middleware = {
+ onRequest: async ({ request }) => {
+ if (request.body !== null && !["GET", "HEAD"].includes(request.method)) {
+ serializedBodies.set(request, await request.clone().text());
+ }
+
+ return undefined;
+ },
onResponse: async ({ response, request }) => {
if (response.status !== 401) {
return response;
@@ -125,18 +141,14 @@ const tokenRefresh: Middleware = {
}
/*
- * Re-issue the original request. `Request` is single-use after its body
- * is read, but openapi-fetch hasn't consumed the body yet at this point,
- * so a clone is enough. Falling back to the original lets reads-without-
- * body (GET, DELETE) work too.
+ * Re-issue the original request with the body captured before dispatch.
+ * GET and HEAD have no body, so `body` stays null for them.
*/
const retryInit: RequestInit = {
method: request.method,
headers: request.headers,
credentials: "include",
- body: ["GET", "HEAD"].includes(request.method)
- ? null
- : await request.clone().text()
+ body: serializedBodies.get(request) ?? null
};
return fetch(request.url, retryInit);
diff --git a/apps/ui/src/lib/api/schema.d.ts b/apps/ui/src/lib/api/schema.d.ts
index e8dc9778..b1b59401 100644
--- a/apps/ui/src/lib/api/schema.d.ts
+++ b/apps/ui/src/lib/api/schema.d.ts
@@ -489,15 +489,15 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/api/v1/billing/plans": {
+ "/api/v1/billing/subscription": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- /** List available plans */
- get: operations["getApiV1BillingPlans"];
+ /** Current account subscription */
+ get: operations["getApiV1BillingSubscription"];
put?: never;
post?: never;
delete?: never;
@@ -506,24 +506,24 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/api/v1/billing/subscription": {
+ "/api/v1/billing/stripe/checkout-session": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- /** Current account subscription */
- get: operations["getApiV1BillingSubscription"];
+ get?: never;
put?: never;
- post?: never;
+ /** Create Stripe Checkout session */
+ post: operations["postApiV1BillingStripeCheckout-session"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
- "/api/v1/billing/stripe/checkout-session": {
+ "/api/v1/billing/stripe/portal-session": {
parameters: {
query?: never;
header?: never;
@@ -532,25 +532,25 @@ export interface paths {
};
get?: never;
put?: never;
- /** Create Stripe Checkout session */
- post: operations["postApiV1BillingStripeCheckout-session"];
+ /** Create Stripe Customer Portal session */
+ post: operations["postApiV1BillingStripePortal-session"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
- "/api/v1/billing/stripe/portal-session": {
+ "/api/v1/billing/plans": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- get?: never;
+ /** List available plans (public) */
+ get: operations["getApiV1BillingPlans"];
put?: never;
- /** Create Stripe Customer Portal session */
- post: operations["postApiV1BillingStripePortal-session"];
+ post?: never;
delete?: never;
options?: never;
head?: never;
@@ -2695,39 +2695,6 @@ export interface operations {
};
};
};
- getApiV1BillingPlans: {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody?: never;
- responses: {
- 200: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": {
- id: number;
- name: string;
- isDefault: boolean;
- }[];
- "multipart/form-data": {
- id: number;
- name: string;
- isDefault: boolean;
- }[];
- "text/plain": {
- id: number;
- name: string;
- isDefault: boolean;
- }[];
- };
- };
- };
- };
getApiV1BillingSubscription: {
parameters: {
query?: never;
@@ -2778,6 +2745,7 @@ export interface operations {
content: {
"application/json": {
planId: number;
+ interval?: "month" | "year";
/** Format: uri */
successUrl: string;
/** Format: uri */
@@ -2785,6 +2753,7 @@ export interface operations {
};
"multipart/form-data": {
planId: number;
+ interval?: "month" | "year";
/** Format: uri */
successUrl: string;
/** Format: uri */
@@ -2792,6 +2761,7 @@ export interface operations {
};
"text/plain": {
planId: number;
+ interval?: "month" | "year";
/** Format: uri */
successUrl: string;
/** Format: uri */
@@ -2860,6 +2830,42 @@ export interface operations {
};
};
};
+ getApiV1BillingPlans: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ id: number;
+ name: string;
+ isDefault: boolean;
+ purchasableIntervals: ("month" | "year")[];
+ }[];
+ "multipart/form-data": {
+ id: number;
+ name: string;
+ isDefault: boolean;
+ purchasableIntervals: ("month" | "year")[];
+ }[];
+ "text/plain": {
+ id: number;
+ name: string;
+ isDefault: boolean;
+ purchasableIntervals: ("month" | "year")[];
+ }[];
+ };
+ };
+ };
+ };
postApiV1BillingStripeWebhooks: {
parameters: {
query?: never;
diff --git a/apps/ui/src/lib/auth/return-to.constants.ts b/apps/ui/src/lib/auth/return-to.constants.ts
new file mode 100644
index 00000000..eb45f75f
--- /dev/null
+++ b/apps/ui/src/lib/auth/return-to.constants.ts
@@ -0,0 +1,4 @@
+export const RETURN_TO_STORAGE_KEY = "boringstack:return-to";
+export const RETURN_TO_MAX_AGE_MS = 30 * 60 * 1000;
+
+export const ORIGIN_PROBE = "https://same-origin.invalid";
diff --git a/apps/ui/src/lib/auth/return-to.test.ts b/apps/ui/src/lib/auth/return-to.test.ts
new file mode 100644
index 00000000..e29d6d52
--- /dev/null
+++ b/apps/ui/src/lib/auth/return-to.test.ts
@@ -0,0 +1,96 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import {
+ RETURN_TO_MAX_AGE_MS,
+ RETURN_TO_STORAGE_KEY,
+ clearReturnTo,
+ peekReturnTo,
+ sanitizeReturnTo,
+ storeReturnTo,
+ takeReturnTo
+} from "./return-to";
+
+describe("sanitizeReturnTo", () => {
+ it.each([
+ "/dashboard",
+ "/account/billing?checkout=success",
+ "/invitations/abc#section"
+ ])("accepts the same-origin path %s", (value) => {
+ expect(sanitizeReturnTo(value)).toBe(value);
+ });
+
+ it.each([
+ ["absolute URL", "https://evil.example/steal"],
+ ["protocol-relative", "//evil.example"],
+ ["backslash after slash", "/\\evil.example"],
+ ["tab-smuggled protocol-relative", "/\t/evil.example"],
+ ["newline-smuggled", "/\n/evil.example"],
+ ["relative without slash", "dashboard"],
+ ["javascript scheme", "javascript:alert(1)"],
+ ["backslash inside path", "/a\\b"],
+ ["empty string", ""],
+ ["non-string", 42],
+ ["null", null],
+ ["undefined", undefined]
+ ])("rejects %s", (_label, value) => {
+ expect(sanitizeReturnTo(value)).toBeNull();
+ });
+});
+
+describe("return-to storage", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ });
+
+ afterEach(() => {
+ sessionStorage.clear();
+ });
+
+ it("stores a valid path and hands it back once via takeReturnTo", () => {
+ storeReturnTo("/account/settings");
+
+ expect(peekReturnTo()).toBe("/account/settings");
+ expect(takeReturnTo()).toBe("/account/settings");
+ expect(takeReturnTo()).toBeNull();
+ });
+
+ it("ignores an unsafe path instead of storing it", () => {
+ storeReturnTo("//evil.example");
+
+ expect(sessionStorage.getItem(RETURN_TO_STORAGE_KEY)).toBeNull();
+ expect(peekReturnTo()).toBeNull();
+ });
+
+ it("drops a stored path that has expired", () => {
+ const savedAt = 1_000_000;
+
+ storeReturnTo("/account/settings", savedAt);
+
+ expect(peekReturnTo(savedAt + RETURN_TO_MAX_AGE_MS)).toBe(
+ "/account/settings"
+ );
+ expect(peekReturnTo(savedAt + RETURN_TO_MAX_AGE_MS + 1)).toBeNull();
+ });
+
+ it("rejects a tampered stored value that is not a safe path", () => {
+ sessionStorage.setItem(
+ RETURN_TO_STORAGE_KEY,
+ JSON.stringify({ path: "//evil.example", savedAt: Date.now() })
+ );
+
+ expect(peekReturnTo()).toBeNull();
+ });
+
+ it("treats malformed JSON as nothing stored", () => {
+ sessionStorage.setItem(RETURN_TO_STORAGE_KEY, "{not json");
+
+ expect(peekReturnTo()).toBeNull();
+ });
+
+ it("clearReturnTo removes the stored path", () => {
+ storeReturnTo("/dashboard");
+ clearReturnTo();
+
+ expect(peekReturnTo()).toBeNull();
+ });
+});
diff --git a/apps/ui/src/lib/auth/return-to.ts b/apps/ui/src/lib/auth/return-to.ts
new file mode 100644
index 00000000..6e1c1c46
--- /dev/null
+++ b/apps/ui/src/lib/auth/return-to.ts
@@ -0,0 +1,144 @@
+/*
+ * Return-to: the in-app path a visitor was heading to when auth stopped them.
+ *
+ * ProtectedRoute stores the path it bounced from; login, email verification
+ * and the OAuth callback read it back after the session exists. The value
+ * lives in sessionStorage (per tab) with a short lifetime, so an abandoned
+ * attempt does not redirect a much later login.
+ *
+ * Every value is validated before it is stored or used. Only same-origin
+ * absolute paths pass: they must start with a single "/", and contain no
+ * control characters or backslashes (browsers strip tabs and newlines from
+ * URLs, so "/\t/evil.example" would otherwise become protocol-relative).
+ */
+import { nowMs } from "@/lib/time/now";
+
+import {
+ ORIGIN_PROBE,
+ RETURN_TO_MAX_AGE_MS,
+ RETURN_TO_STORAGE_KEY
+} from "./return-to.constants";
+import type { IStoredReturnTo } from "./return-to.types";
+
+export {
+ RETURN_TO_MAX_AGE_MS,
+ RETURN_TO_STORAGE_KEY
+} from "./return-to.constants";
+
+function hasForbiddenCharacter(value: string): boolean {
+ for (let index = 0; index < value.length; index += 1) {
+ const code = value.charCodeAt(index);
+
+ if (code < 0x20 || code === 0x7f || value[index] === "\\") {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/** Returns the path when it is a safe same-origin relative path, else null. */
+export function sanitizeReturnTo(value: unknown): string | null {
+ if (typeof value !== "string" || !value.startsWith("/")) {
+ return null;
+ }
+
+ if (value.startsWith("//") || hasForbiddenCharacter(value)) {
+ return null;
+ }
+
+ try {
+ const resolved = new URL(value, ORIGIN_PROBE);
+
+ return resolved.origin === ORIGIN_PROBE ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+function getSessionStorage(): Storage | null {
+ try {
+ return window.sessionStorage;
+ } catch {
+ return null;
+ }
+}
+
+function isStoredReturnTo(value: unknown): value is IStoredReturnTo {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ "path" in value &&
+ typeof value.path === "string" &&
+ "savedAt" in value &&
+ typeof value.savedAt === "number"
+ );
+}
+
+/** Remembers a path for the next auth step. Invalid paths are ignored. */
+export function storeReturnTo(value: unknown, at: number = nowMs()): void {
+ const path = sanitizeReturnTo(value);
+ const storage = getSessionStorage();
+
+ if (path === null || storage === null) {
+ return;
+ }
+
+ try {
+ const entry: IStoredReturnTo = { path, savedAt: at };
+
+ storage.setItem(RETURN_TO_STORAGE_KEY, JSON.stringify(entry));
+ } catch {
+ // Storage can be full or blocked; the default post-auth path still applies.
+ }
+}
+
+function readStored(at: number): string | null {
+ const storage = getSessionStorage();
+
+ if (storage === null) {
+ return null;
+ }
+
+ try {
+ const raw = storage.getItem(RETURN_TO_STORAGE_KEY);
+
+ if (raw === null) {
+ return null;
+ }
+
+ const parsed: unknown = JSON.parse(raw);
+
+ if (!isStoredReturnTo(parsed)) {
+ return null;
+ }
+
+ const isFresh = at - parsed.savedAt <= RETURN_TO_MAX_AGE_MS;
+
+ return isFresh ? sanitizeReturnTo(parsed.path) : null;
+ } catch {
+ return null;
+ }
+}
+
+/** Reads the stored path without consuming it. */
+export function peekReturnTo(at: number = nowMs()): string | null {
+ return readStored(at);
+}
+
+/** Reads the stored path and clears it. Call once, after navigating on. */
+export function takeReturnTo(at: number = nowMs()): string | null {
+ const path = readStored(at);
+
+ clearReturnTo();
+
+ return path;
+}
+
+export function clearReturnTo(): void {
+ try {
+ getSessionStorage()?.removeItem(RETURN_TO_STORAGE_KEY);
+ } catch {
+ // Nothing to clear when storage is blocked.
+ }
+}
diff --git a/apps/ui/src/lib/auth/return-to.types.ts b/apps/ui/src/lib/auth/return-to.types.ts
new file mode 100644
index 00000000..e9cd18c4
--- /dev/null
+++ b/apps/ui/src/lib/auth/return-to.types.ts
@@ -0,0 +1,4 @@
+export interface IStoredReturnTo {
+ readonly path: string;
+ readonly savedAt: number;
+}
diff --git a/apps/ui/src/lib/i18n/locales/de/common.json b/apps/ui/src/lib/i18n/locales/de/common.json
index 72e024ef..e8e427ab 100644
--- a/apps/ui/src/lib/i18n/locales/de/common.json
+++ b/apps/ui/src/lib/i18n/locales/de/common.json
@@ -7,7 +7,8 @@
},
"common": {
"loading": "Wird geladen…",
- "close": "Schließen"
+ "close": "Schließen",
+ "retry": "Erneut versuchen"
},
"offline": {
"title": "Server nicht erreichbar",
@@ -221,6 +222,18 @@
"badges": {
"default": "Standard",
"current": "Aktuell"
+ },
+ "checkout": {
+ "pending": "Zahlung wird bestätigt…",
+ "confirmed": "Dein Tarif wurde aktualisiert.",
+ "timeout": "Die Bestätigung dauert länger als erwartet. Deine Zahlung ist sicher; prüfe es gleich noch einmal.",
+ "retry": "Erneut prüfen",
+ "cancelled": "Der Checkout wurde abgebrochen. Dein Tarif wurde nicht geändert."
+ },
+ "interval": {
+ "label": "Abrechnungszeitraum",
+ "monthly": "Monatlich",
+ "yearly": "Jährlich"
}
},
"theme": {
@@ -237,7 +250,8 @@
"boundary": {
"title": "Etwas ist schiefgelaufen",
"body": "Bitte die Seite neu laden. Falls das Problem weiterhin auftritt, kontaktiere den Support.",
- "retry": "Neu laden"
+ "retry": "Neu laden",
+ "home": "Zur Startseite"
},
"route": {
"generic": "Diese Seite hat einen Aussetzer",
@@ -480,7 +494,8 @@
"errorInvalid": "Diese Einladung ist nicht mehr gültig. Bitte um eine neue.",
"errorGeneric": "Die Einladung konnte nicht angenommen werden. Versuche es erneut oder bitte um einen neuen Link.",
"backToLogin": "Zurück zur Anmeldung"
- }
+ },
+ "loadError": "Einladungen konnten nicht geladen werden. Versuche es erneut."
},
"ownershipTransfer": {
"pageTitle": "Eigentumsübertragung annehmen",
diff --git a/apps/ui/src/lib/i18n/locales/en/common.json b/apps/ui/src/lib/i18n/locales/en/common.json
index 3fbbc318..eb84cf3f 100644
--- a/apps/ui/src/lib/i18n/locales/en/common.json
+++ b/apps/ui/src/lib/i18n/locales/en/common.json
@@ -7,7 +7,8 @@
},
"common": {
"loading": "Loading…",
- "close": "Close"
+ "close": "Close",
+ "retry": "Try again"
},
"offline": {
"title": "Can't reach the server",
@@ -221,6 +222,18 @@
"badges": {
"default": "Default",
"current": "Current"
+ },
+ "checkout": {
+ "pending": "Confirming your payment…",
+ "confirmed": "Your plan is updated.",
+ "timeout": "Confirmation is taking longer than expected. Your payment is safe; check again in a moment.",
+ "retry": "Check again",
+ "cancelled": "Checkout was cancelled. Your plan has not changed."
+ },
+ "interval": {
+ "label": "Billing interval",
+ "monthly": "Monthly",
+ "yearly": "Yearly"
}
},
"theme": {
@@ -459,7 +472,8 @@
"errorInvalid": "This invitation link is no longer valid. Ask the inviter for a fresh one.",
"errorGeneric": "Couldn't accept the invitation. Try again, or ask the inviter for a fresh link.",
"backToLogin": "Back to login"
- }
+ },
+ "loadError": "Couldn't load invitations. Try again."
},
"ownershipTransfer": {
"pageTitle": "Accept ownership transfer",
@@ -500,7 +514,8 @@
"boundary": {
"title": "Something went wrong",
"body": "Please reload the page. If the issue keeps happening, contact support.",
- "retry": "Reload"
+ "retry": "Reload",
+ "home": "Go to home"
},
"route": {
"generic": "This page hit a snag",
diff --git a/apps/ui/src/lib/session/fetchMe.test.ts b/apps/ui/src/lib/session/fetchMe.test.ts
new file mode 100644
index 00000000..558fabfb
--- /dev/null
+++ b/apps/ui/src/lib/session/fetchMe.test.ts
@@ -0,0 +1,56 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { ApiError } from "@/lib/api/ApiError";
+
+import { fetchCurrentMe } from "./fetchMe";
+
+const apiMock = vi.hoisted(() => ({ GET: vi.fn() }));
+
+vi.mock("@/lib/api/client", () => ({ apiClient: apiMock }));
+
+const AUTHED = {
+ user: {
+ id: "u1",
+ email: "u@example.com",
+ firstName: "U",
+ lastName: "Ser",
+ emailVerified: true,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z"
+ }
+};
+
+beforeEach(() => {
+ apiMock.GET.mockReset();
+});
+
+describe("fetchCurrentMe", () => {
+ it("returns the session payload for an authenticated response", async () => {
+ apiMock.GET.mockResolvedValueOnce({ data: AUTHED, response: {} });
+
+ await expect(fetchCurrentMe()).resolves.toEqual(AUTHED);
+ });
+
+ it("returns null for the anonymous 200 shape", async () => {
+ apiMock.GET.mockResolvedValueOnce({ data: { user: null }, response: {} });
+
+ await expect(fetchCurrentMe()).resolves.toBeNull();
+ });
+
+ it("returns null when /me is still 401 after the refresh middleware gave up", async () => {
+ apiMock.GET.mockRejectedValueOnce(
+ new ApiError(401, { message: "Unauthorized" })
+ );
+
+ await expect(fetchCurrentMe()).resolves.toBeNull();
+ expect(apiMock.GET).toHaveBeenCalledTimes(1);
+ });
+
+ it("rethrows non-auth failures so the offline fallback can show", async () => {
+ const outage = new ApiError(503, { message: "down" });
+
+ apiMock.GET.mockRejectedValueOnce(outage);
+
+ await expect(fetchCurrentMe()).rejects.toBe(outage);
+ });
+});
diff --git a/apps/ui/src/lib/session/fetchMe.ts b/apps/ui/src/lib/session/fetchMe.ts
new file mode 100644
index 00000000..83c744c4
--- /dev/null
+++ b/apps/ui/src/lib/session/fetchMe.ts
@@ -0,0 +1,28 @@
+import { ApiError } from "@/lib/api/ApiError";
+import { apiClient } from "@/lib/api/client";
+
+import type { IMe } from "./session.types";
+import { isAuthenticatedMe } from "./session.utils";
+
+/*
+ * Reads the current session. Returns null when the caller is logged out.
+ *
+ * Two shapes mean logged out: `200 { user: null }` (no session cookie) and a
+ * 401 that survived the refresh middleware (the access cookie expired and the
+ * refresh cookie is gone or was refused). The middleware has already tried a
+ * silent refresh and retried `/me` by the time a 401 reaches this function, so
+ * it is not retried here. Other failures (network, 5xx) still throw.
+ */
+export async function fetchCurrentMe(): Promise {
+ try {
+ const { data } = await apiClient.GET("/api/v1/users/me");
+
+ return isAuthenticatedMe(data) ? data : null;
+ } catch (error) {
+ if (error instanceof ApiError && error.isUnauthorized) {
+ return null;
+ }
+
+ throw error;
+ }
+}
diff --git a/apps/ui/src/lib/session/index.ts b/apps/ui/src/lib/session/index.ts
index 9e0926fe..c5823a2e 100644
--- a/apps/ui/src/lib/session/index.ts
+++ b/apps/ui/src/lib/session/index.ts
@@ -1,3 +1,4 @@
+export { fetchCurrentMe } from "./fetchMe";
export { useMe } from "./useMe";
export { isAuthenticatedMe } from "./session.utils";
export { SESSION_QUERY_KEYS } from "./session.constants";
diff --git a/apps/ui/src/lib/session/useMe.test.tsx b/apps/ui/src/lib/session/useMe.test.tsx
index a81afcf6..8161be89 100644
--- a/apps/ui/src/lib/session/useMe.test.tsx
+++ b/apps/ui/src/lib/session/useMe.test.tsx
@@ -45,17 +45,29 @@ beforeEach(() => {
});
describe("useMe", () => {
- it("propagates 401 as an ApiError (consumer distinguishes auth failure from anonymous)", async () => {
+ it("resolves to null when /me is still 401 after the refresh middleware gave up (logged out, not an error)", async () => {
apiMock.GET.mockRejectedValueOnce(
new ApiError(401, { message: "Unauthorized" })
);
const { result } = renderHook(() => useMe(), { wrapper: wrapper() });
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+ expect(result.current.data).toBeNull();
+ expect(result.current.error).toBeNull();
+ });
+
+ it("propagates a non-auth failure as an error so the offline fallback can show", async () => {
+ apiMock.GET.mockRejectedValueOnce(
+ new ApiError(503, { message: "Service unavailable" })
+ );
+ const { result } = renderHook(() => useMe(), { wrapper: wrapper() });
+
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
- expect(result.current.error).toBeInstanceOf(ApiError);
- expect((result.current.error as ApiError).isUnauthorized).toBe(true);
+ expect((result.current.error as ApiError).status).toBe(503);
});
it("returns the full session payload when the API responds 200 with the authenticated shape", async () => {
diff --git a/apps/ui/src/lib/session/useMe.ts b/apps/ui/src/lib/session/useMe.ts
index 9de85fb2..caf1d9d7 100644
--- a/apps/ui/src/lib/session/useMe.ts
+++ b/apps/ui/src/lib/session/useMe.ts
@@ -1,17 +1,15 @@
import { type UseQueryResult, useQuery } from "@tanstack/react-query";
-import { apiClient } from "@/lib/api/client";
-
+import { fetchCurrentMe } from "./fetchMe";
import { SESSION_QUERY_KEYS } from "./session.constants";
import type { IMe } from "./session.types";
-import { isAuthenticatedMe } from "./session.utils";
/*
* Query contract:
*
* - data: IMe → authenticated session
- * - data: null → server returned 200 + { user: null } (anonymous probe)
- * - error: ApiError(401|403) → forced logout (cookie present but invalid)
+ * - data: null → logged out: 200 + { user: null }, or a 401 that the
+ * refresh middleware could not recover (see fetchMe)
* - error: other → real failure (network, 5xx, parse). ProtectedRoute
* renders the offline fallback with a retry CTA.
*
@@ -26,11 +24,7 @@ import { isAuthenticatedMe } from "./session.utils";
export function useMe(): UseQueryResult {
return useQuery({
queryKey: SESSION_QUERY_KEYS.me,
- queryFn: async () => {
- const { data } = await apiClient.GET("/api/v1/users/me");
-
- return isAuthenticatedMe(data) ? data : null;
- },
+ queryFn: fetchCurrentMe,
staleTime: 60_000,
retry: false
});
diff --git a/apps/ui/tests/lint-meta/lint-meta.test.ts b/apps/ui/tests/lint-meta/lint-meta.test.ts
index 1f8e4d81..e57d56d6 100644
--- a/apps/ui/tests/lint-meta/lint-meta.test.ts
+++ b/apps/ui/tests/lint-meta/lint-meta.test.ts
@@ -1201,6 +1201,15 @@ describe("checkWorkflowConcurrencyExplicit", () => {
expect(checkWorkflowConcurrencyExplicit(explicit)).toEqual([]);
+ const expression = join(root, "expression.yml");
+
+ writeFileSync(
+ expression,
+ "concurrency:\n group: x-${{ github.ref }}\n cancel-in-progress: ${{ github.event_name == 'pull_request' }}\n\njobs: {}\n"
+ );
+
+ expect(checkWorkflowConcurrencyExplicit(expression)).toEqual([]);
+
const none = join(root, "none.yml");
writeFileSync(none, "jobs: {}\n");
diff --git a/apps/ui/tests/setup.ts b/apps/ui/tests/setup.ts
index d0cff734..dfa70bac 100644
--- a/apps/ui/tests/setup.ts
+++ b/apps/ui/tests/setup.ts
@@ -15,6 +15,33 @@ if (typeof globalThis.ResizeObserver === "undefined") {
vi.stubGlobal("ResizeObserver", ResizeObserverStub);
}
+/*
+ * jsdom has neither IntersectionObserver nor element scrolling. Components
+ * that lazy-load on visibility or scroll a row into view would throw on mount.
+ * The stubs never fire callbacks: tests that need visibility must drive it.
+ */
+if (typeof globalThis.IntersectionObserver === "undefined") {
+ class IntersectionObserverStub {
+ public readonly root = null;
+ public readonly rootMargin = "";
+ public readonly thresholds: readonly number[] = [];
+ public observe = vi.fn();
+ public unobserve = vi.fn();
+ public disconnect = vi.fn();
+ public takeRecords = vi.fn((): IntersectionObserverEntry[] => []);
+ }
+
+ vi.stubGlobal("IntersectionObserver", IntersectionObserverStub);
+}
+
+if (typeof Element.prototype.scrollIntoView !== "function") {
+ Object.defineProperty(Element.prototype, "scrollIntoView", {
+ configurable: true,
+ writable: true,
+ value: vi.fn()
+ });
+}
+
/*
* jsdom ships a stub `requestSubmit` that prints "Not implemented" the
* first time React Hook Form (or any Enter-to-submit form path) calls
diff --git a/docs/maintenance/product-feedback.md b/docs/maintenance/product-feedback.md
index 3be5db7a..588c4c4a 100644
--- a/docs/maintenance/product-feedback.md
+++ b/docs/maintenance/product-feedback.md
@@ -125,3 +125,32 @@ correction or a documented scope decision.
| 99 | A release was blocked by an e2e asserting heading copy. | Open: needs a product fixture before any lint or guide change. |
| 100 | Found in this review: in Compose production the API enables TLS for Postgres, but the Compose Postgres has no TLS, and postgres-js refuses a server that declines TLS. Migrations now share the runtime policy, so they fail the same way. | Open: reproduce with the `prod` profile, then either give Compose Postgres TLS or make the policy explicit per target. |
| 101 | The product built a business-metrics Grafana dashboard (sign-ups, billing, plans) from CNPG custom queries. | Implemented: aggregate queries over the template's auth, audit and billing tables, a CNPG PodMonitor and a dashboard ConfigMap. Migration 0002 grants `pg_monitor` only the counted columns; `infra/k3s/tests/business-metrics.test.ts` checks every column against the drizzle snapshot and the grants. MRR is not derived because plans carry no price. |
+
+## Report 3: template code Tinkercaster had to change, 2026-09-12 to 2026-10-09
+
+Source: a diff of every template-origin file in Tinkercaster against the
+template commit it was created from (`cdacac0`, Tinkercaster `64eb7ee3`),
+read with the commit that changed it. Product features and branding are
+excluded. Each entry was checked against current template `main` before
+it was fixed; Report 2 items are not repeated.
+
+| Report | Observation | Status / evidence |
+| ------ | ----------- | ----------------- |
+| 102 | Paid plans granted nothing: plans were seeded without `plan_features`, so a Pro subscriber resolved Free entitlements. | Implemented: one typed plan catalog in `apps/api/src/api/billing/billing.plans.ts` seeds plans and features; Pro must give every feature key a value (type check plus `billing.plans.test.ts`). |
+| 103 | An account could buy a second subscription; two clicks made two checkout sessions. | Implemented: checkout locks the account row, returns 409 for a live Stripe-backed subscription found locally or in Stripe, and reuses an open session for the same plan and price. `billing.service.test.ts`. |
+| 104 | `checkout.session.completed` activated the plan before a delayed payment cleared. | Implemented: `active` only for `paid` / `no_payment_required`, otherwise `incomplete`; `checkout.session.async_payment_succeeded` / `_failed` settle it. |
+| 105 | Checkout accepted the default (Free) plan; plans had one price; the plan list needed a session, so a public pricing page could not read it. | Implemented: Free is rejected before Stripe; optional `STRIPE_PRICE_ID_PRO_YEARLY` with a checkout `interval`; `GET /billing/plans` is public and answers with billing off. |
+| 106 | Reloading after the 15-minute access cookie expired logged the user out: `/users/me` answered `{ user: null }` and the UI only refreshes on 401. | Implemented: `/me` returns 401 when a refresh cookie is present; the UI refreshes and retries, and an unrecoverable 401 resolves to logged out (`fetchMe.ts`). |
+| 107 | A revoked refresh cookie stayed in the browser and failed every refresh. | Implemented: `/auth/refresh` clears both cookies on 401. |
+| 108 | The UI's silent-refresh retry re-read a consumed request body, so the first write after token expiry failed. | Implemented: the body is captured before dispatch and replayed (`openapi.test.ts`). |
+| 109 | After Stripe checkout the billing page showed Free until a refetch. | Implemented: `?checkout=success` polls the subscription, then refreshes the session; a timeout offers a retry. |
+| 110 | Email verification ran its single-use token twice under StrictMode and then showed "invalid link". | Implemented: the in-flight request is shared per token. |
+| 111 | nginx answered `/.well-known/*` and missing `.json/.txt/.xml` with the SPA HTML and 200. | Implemented: those paths return 404. |
+| 112 | Dev logs of validation errors carried the request body through the stack trace. | Implemented: no stack for VALIDATION / PARSE errors. |
+| 113 | Notification events imported the `lib/notifications` barrel, a cycle that can fail at module load. | Implemented: direct imports plus a guard test. |
+| 114 | New tables had to be added to the test cleanup list by hand, twice in the product. | Implemented: a test derives every table from the schema and fails when one is neither cleaned nor allowlisted. |
+| 115 | Every pull request check failed in the product's private repository: `dorny/paths-filter` needs `pull-requests: read` there, and the template is public, so it never showed. | Implemented: permission added to all 14 workflows and enforced by the lint-meta rule `github-actions-paths-filter-permissions`. |
+| 116 | `pre-push.sh` used `echo \| grep -q` under `pipefail`; with many changed paths the pipeline exits 141 and the app gate is skipped silently. Hooks also leaked `GIT_DIR` into tests that create repositories. | Implemented: here-strings everywhere and the git variables are unset. `tools/agent/pre-push-shell.test.ts`. |
+| 117 | Code in `packages/*` could not reach the images or the dev containers. | Implemented: a BuildKit `packages` build context and read-only dev mounts; `packages/README.md` keeps the directory present. |
+| 118 | CI ran the API suite twice, and the UI lacked small conveniences the product added (retry on list errors, a home action on the error page, opt-in devtools, return-to after login, jsdom stubs). | Implemented: one coverage run; the UI items as listed. |
+| 119 | The product made pre-push fast by default, skipped main runs for trees a merged PR verified, and removed the per-app release workflows. | Not adopted: the first two weaken gates by policy, and Compose still publishes through the per-app workflows. |
diff --git a/infra/compose/compose/docker-compose.mailpit.yml b/infra/compose/compose/docker-compose.mailpit.yml
index c784989b..f7ae6f4f 100644
--- a/infra/compose/compose/docker-compose.mailpit.yml
+++ b/infra/compose/compose/docker-compose.mailpit.yml
@@ -10,6 +10,16 @@
# https://mailpit.axllent.org
services:
+ # Route the dev API's mail to the catcher whenever this overlay is merged,
+ # so `docker compose -f ... -f docker-compose.mailpit.yml` behaves the same
+ # as dev.sh with WITH_MAILPIT=1. The base file keeps the cloudflare default
+ # for the smoke profile, which shares api-dev but never merges this overlay.
+ api-dev:
+ environment:
+ EMAIL_PROVIDER: ${API_DEV_EMAIL_PROVIDER:-smtp}
+ SMTP_HOST: ${API_DEV_SMTP_HOST:-mailpit}
+ SMTP_PORT: ${API_DEV_SMTP_PORT:-1025}
+
mailpit:
image: axllent/mailpit:v1.21@sha256:81370195cd4a0eab9604d17c2617a7525b0486f9365555253b6c5376c6350f1a
profiles: [mailpit]
diff --git a/infra/compose/compose/docker-compose.yml b/infra/compose/compose/docker-compose.yml
index ec629f93..48d13dac 100644
--- a/infra/compose/compose/docker-compose.yml
+++ b/infra/compose/compose/docker-compose.yml
@@ -158,6 +158,8 @@ services:
volumes:
- ../../../apps/api:/app
- api_dev_node_modules:/app/node_modules
+ # Shared code (repo packages/), read-only, at the path ../../packages resolves to from /app.
+ - ../../../packages:/packages:ro
command:
- "sh"
- "-c"
@@ -232,6 +234,8 @@ services:
volumes:
- ../../../apps/api:/app
- api_dev_node_modules:/app/node_modules
+ # Shared code (repo packages/), read-only, at the path ../../packages resolves to from /app.
+ - ../../../packages:/packages:ro
- api_dev_email_templates:/app/src/templates/email/dist
command:
[
@@ -261,10 +265,11 @@ services:
# turning it on in dev so the operator can exercise the live
# notification path without flipping a flag.
NOTIFICATIONS_SSE_ENABLED: ${API_DEV_NOTIFICATIONS_SSE_ENABLED:-true}
- # Email provider defaults are profile-aware: dev.sh exports
- # smtp + mailpit when STACK=dev (so transactional mail is caught
- # at http://localhost:8025). Smoke doesn't run mailpit, so the
- # fallback is the schema default `cloudflare` (provider noops
+ # Email provider defaults are profile-aware: the mailpit overlay
+ # (docker-compose.mailpit.yml, merged by dev.sh when WITH_MAILPIT=1)
+ # sets smtp + mailpit for api-dev, so transactional mail is caught at
+ # http://localhost:8025. Smoke never merges that overlay, so the
+ # fallback here stays the schema default `cloudflare` (provider noops
# with empty creds, which is the desired smoke behaviour).
# SMTP_PORT must be a valid integer for the env validator; the
# `25` fallback is harmless when EMAIL_PROVIDER != smtp.
@@ -359,6 +364,8 @@ services:
volumes:
- ../../../apps/ui:/app
- ui_dev_node_modules:/app/node_modules
+ # Shared code (repo packages/), read-only, at the path ../../packages resolves to from /app.
+ - ../../../packages:/packages:ro
command: ["bun", "--no-install", "./node_modules/.bin/vite"]
environment:
CI: "true"
@@ -400,6 +407,9 @@ services:
build:
context: ../../../apps/ui
dockerfile: Dockerfile.prod
+ additional_contexts:
+ # Shared code (repo packages/). Prod Dockerfiles COPY it from this named context.
+ packages: ../../../packages
# Empty VITE_API_URL = same-origin: the SPA hits relative `/api/*`
# paths and this service's nginx proxies them to api-dev.
args:
@@ -436,6 +446,9 @@ services:
build:
context: ../../../apps/api
dockerfile: Dockerfile.prod
+ additional_contexts:
+ # Shared code (repo packages/). Prod Dockerfiles COPY it from this named context.
+ packages: ../../../packages
labels:
- "wud.watch=true"
- "wud.trigger.include=docker.app,discord.1,slack.alerts"
@@ -518,6 +531,9 @@ services:
build:
context: ../../../apps/ui
dockerfile: Dockerfile.prod
+ additional_contexts:
+ # Shared code (repo packages/). Prod Dockerfiles COPY it from this named context.
+ packages: ../../../packages
args:
# Origin only, see compose/.env.example. The SPA appends /api/v1.
VITE_API_URL: ${PUBLIC_API_URL:-}
diff --git a/infra/compose/scripts/validate-guardrails.sh b/infra/compose/scripts/validate-guardrails.sh
index 02e5ee06..2a3aed6d 100755
--- a/infra/compose/scripts/validate-guardrails.sh
+++ b/infra/compose/scripts/validate-guardrails.sh
@@ -261,14 +261,14 @@ check_prod_image_tags() {
if output=$(env "${guard_env[@]}" ./dev.sh config --quiet 2>&1); then
fail "prod accepted unset image tags"
fi
- echo "$output" | grep -q "API_IMAGE_TAG" \
+ grep -q "API_IMAGE_TAG" <<< "$output" \
|| fail "error does not mention API_IMAGE_TAG: $output"
# 2. latest must be rejected explicitly.
if output=$(env "${guard_env[@]}" API_IMAGE_TAG=latest UI_IMAGE_TAG=latest ./dev.sh config --quiet 2>&1); then
fail "prod accepted latest image tags"
fi
- echo "$output" | grep -q "must be pinned in prod" \
+ grep -q "must be pinned in prod" <<< "$output" \
|| fail "latest rejection message missing: $output"
# 3. Pinned tags pass.
@@ -280,7 +280,7 @@ check_prod_image_tags() {
if output=$(env "${guard_env[@]}" API_IMAGE_TAG=v0.1.0 UI_IMAGE_TAG=v0.1.0 ./dev.sh config --quiet 2>&1); then
fail "prod accepted an unset site image tag with the site on by default"
fi
- echo "$output" | grep -q "SITE_IMAGE_TAG" \
+ grep -q "SITE_IMAGE_TAG" <<< "$output" \
|| fail "error does not mention SITE_IMAGE_TAG: $output"
# 5. WITH_SITE=0 is the explicit opt-out: no site tag or contact needed.
diff --git a/packages/README.md b/packages/README.md
new file mode 100644
index 00000000..ca53c59e
--- /dev/null
+++ b/packages/README.md
@@ -0,0 +1,10 @@
+# Shared packages
+
+Code used by more than one app lives here, one directory per package. This
+README is the only file at this level; every directory with a `package.json` is
+a package.
+
+- Guide, package shape and required scripts: [apps/ui/docs/agents/shared-packages.md](../apps/ui/docs/agents/shared-packages.md)
+- Apps reach this directory as `../../packages` from `apps/`. Compose mounts
+ it into dev containers and the prod image builds take it as the `packages`
+ named build context, so an import that works locally works in images too.
diff --git a/scripts/ci/pre-push-security.sh b/scripts/ci/pre-push-security.sh
index 523246ca..a92ea513 100755
--- a/scripts/ci/pre-push-security.sh
+++ b/scripts/ci/pre-push-security.sh
@@ -48,7 +48,7 @@ paths_match() {
if [[ -z "$CHANGED_PATHS" ]]; then
return 0
fi
- echo "$CHANGED_PATHS" | grep -qE "$pattern"
+ grep -qE "$pattern" <<< "$CHANGED_PATHS"
}
# Discover every required scanner before starting any expensive scan.
diff --git a/scripts/ci/pre-push-smoke.sh b/scripts/ci/pre-push-smoke.sh
index 38e24eea..c8e452f7 100755
--- a/scripts/ci/pre-push-smoke.sh
+++ b/scripts/ci/pre-push-smoke.sh
@@ -56,7 +56,7 @@ paths_match() {
if [[ -z "$CHANGED_PATHS" ]]; then
return 1
fi
- echo "$CHANGED_PATHS" | grep -qE "$pattern"
+ grep -qE "$pattern" <<< "$CHANGED_PATHS"
}
# ─── Decide what (if anything) needs to run ────────────────────────────────
diff --git a/scripts/ci/pre-push.sh b/scripts/ci/pre-push.sh
index a4db9d39..c6d6ccb7 100755
--- a/scripts/ci/pre-push.sh
+++ b/scripts/ci/pre-push.sh
@@ -18,6 +18,12 @@
set -euo pipefail
+# Git hooks run with GIT_DIR, GIT_INDEX_FILE and friends pointing at this
+# checkout. Fixture tests in the gates below create temporary repositories,
+# and inherited values would send their commits and index writes into the real
+# repo. Clear them before any step runs.
+unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_COMMON_DIR
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ROOT="$(cd "$ROOT/.." && pwd)"
cd "$ROOT"
@@ -49,7 +55,7 @@ app_changed() {
if [[ -z "$CHANGED_PATHS" ]]; then
return 0
fi
- if echo "$CHANGED_PATHS" | grep -qE "(^apps/${app}/|^\.github/workflows/apps-${app}-|^codecov\.yml$)"; then
+ if grep -qE "(^apps/${app}/|^\.github/workflows/apps-${app}-|^codecov\.yml$)" <<< "$CHANGED_PATHS"; then
return 0
fi
return 1
@@ -65,7 +71,7 @@ infra_compose_changed() {
if [[ -z "$CHANGED_PATHS" ]]; then
return 0
fi
- if echo "$CHANGED_PATHS" | grep -qE "(^infra/compose/|^scripts/|^\.github/workflows/infra-compose-)"; then
+ if grep -qE "(^infra/compose/|^scripts/|^\.github/workflows/infra-compose-)" <<< "$CHANGED_PATHS"; then
return 0
fi
return 1
diff --git a/tools/agent/pre-push-shell.test.ts b/tools/agent/pre-push-shell.test.ts
new file mode 100644
index 00000000..4ae5673a
--- /dev/null
+++ b/tools/agent/pre-push-shell.test.ts
@@ -0,0 +1,73 @@
+import { expect, test } from "bun:test";
+import { spawnSync } from "node:child_process";
+import { readdirSync, readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
+
+/*
+ * Hooks run under `set -o pipefail`. `echo "$BIG" | grep -q` exits as soon as
+ * grep matches, echo then takes SIGPIPE (141), and pipefail turns the whole
+ * `if` into "no match": the gate is skipped silently. A here-string has no
+ * writer process to kill, so the same input matches.
+ */
+test("grep -q on a large changed-path list does not lose matches under pipefail", () => {
+ const script = [
+ "set -euo pipefail",
+ "CHANGED_PATHS=\"$(seq 1 200000 | sed 's#^#apps/api/src/file-#')\"",
+ 'if echo "$CHANGED_PATHS" | grep -qE "^apps/api/"; then echo old-match; else echo "old-status=$?"; fi',
+ 'if grep -qE "^apps/api/" <<< "$CHANGED_PATHS"; then echo new-match; else echo new-nomatch; fi',
+ ].join("\n");
+ const result = spawnSync("/bin/bash", ["-c", script], { encoding: "utf8" });
+
+ expect(result.stdout).toContain("new-match");
+ expect(result.stdout).toContain("old-status=141");
+});
+
+function shellScripts(dir: string): string[] {
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const path = join(dir, entry.name);
+
+ if (entry.isDirectory()) {
+ return shellScripts(path);
+ }
+
+ return entry.name.endsWith(".sh") ? [path] : [];
+ });
+}
+
+test("hook and gate scripts use here-strings instead of echo | grep -q", () => {
+ const dirs = [
+ join(ROOT, "scripts"),
+ join(ROOT, "infra/compose/scripts"),
+ join(ROOT, "tools/agent"),
+ ];
+ const offenders = dirs
+ .flatMap(shellScripts)
+ .filter((file) =>
+ /(?:echo|printf)\b[^|\n]*\|\s*grep\s+-[a-zA-Z]*q/u.test(
+ readFileSync(file, "utf8")
+ )
+ );
+
+ expect(offenders).toEqual([]);
+});
+
+test("the root pre-push hook clears inherited git environment before any gate runs", () => {
+ const lines = readFileSync(
+ join(ROOT, "scripts/ci/pre-push.sh"),
+ "utf8"
+ ).split("\n");
+ const unsetIndex = lines.findIndex((line) =>
+ /^unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_COMMON_DIR$/u.test(
+ line
+ )
+ );
+ const firstGateIndex = lines.findIndex((line) =>
+ /^(bash|run_app_gate|step "Root)/u.test(line)
+ );
+
+ expect(unsetIndex).toBeGreaterThan(-1);
+ expect(unsetIndex).toBeLessThan(firstGateIndex);
+});