From 4255a99c9e7a012146644a38740b1617714d7594 Mon Sep 17 00:00:00 2001
From: Aleksandar Grbic
Date: Sat, 10 Oct 2026 10:39:33 +0200
Subject: [PATCH 1/6] fix(billing): grant paid plan features and stop duplicate
subscriptions
Plans were seeded without plan_features, so Pro granted nothing. One typed
catalog now seeds plans and features. Checkout locks the account row,
returns 409 for a live Stripe subscription found locally or in Stripe,
reuses an open session and rejects the Free plan. A delayed payment no
longer activates a plan. The plan list is public, and an optional yearly
price is supported.
---
apps/api/.env.example | 2 +
apps/api/docs/agents/billing.md | 79 +-
apps/api/src/api/billing/billing.constants.ts | 27 +
apps/api/src/api/billing/billing.plans.ts | 88 +++
apps/api/src/api/billing/billing.routes.ts | 33 +-
apps/api/src/api/billing/billing.schemas.ts | 8 +
apps/api/src/api/billing/billing.service.ts | 684 +++++++++++-----
apps/api/src/api/billing/billing.types.ts | 6 +
.../clients/postgres/schema/billing.schema.ts | 6 +-
apps/api/src/config/env/schema.ts | 2 +
apps/api/src/config/env/validate.ts | 15 +
.../tests/api/billing/billing.plans.test.ts | 100 +++
.../tests/api/billing/billing.routes.test.ts | 36 +-
.../tests/api/billing/billing.service.test.ts | 748 +++++++++++++++++-
apps/api/tests/config/env/validate.test.ts | 42 +-
.../tests/helpers/stripe-webhook-fixtures.ts | 204 ++++-
apps/api/tests/setup-test-env.ts | 1 +
apps/docs/src/content/docs/api/billing.mdx | 28 +-
.../src/content/docs/recipes/add-stripe.mdx | 26 +-
19 files changed, 1879 insertions(+), 256 deletions(-)
create mode 100644 apps/api/src/api/billing/billing.constants.ts
create mode 100644 apps/api/src/api/billing/billing.plans.ts
create mode 100644 apps/api/tests/api/billing/billing.plans.test.ts
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/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/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..6e8be4f7 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,
+ });
+};
+
+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");
+ }
- constructor() {
- if (env.STRIPE_SECRET_KEY === "") {
- throw ApiErrors.internal(
- "BillingService instantiated without STRIPE_SECRET_KEY"
+ 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;
+
+ constructor(options: IBillingServiceOptions = {}) {
+ this.stripeClient = options.stripe;
+ this.settings = options.settings ?? currentBillingSettings();
+ }
- const rows = await db.query.plans.findMany({ orderBy: [plans.name] });
+ private get stripe(): Stripe {
+ this.stripeClient ??= createStripeClient();
- return rows.map((plan) => ({
- id: plan.id,
- name: plan.name,
- isDefault: plan.isDefault,
- }));
+ 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;
}
+ /*
+ * Matched against the catalog, not plans.stripePriceId: the row holds the
+ * monthly price only, and either Pro price must map to Pro.
+ */
+ const planName = findPlanNameByPriceId(
+ buildBuiltInPlans(this.settings.priceIds),
+ newPriceId
+ );
+
+ if (planName === undefined) {
+ return;
+ }
+
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: 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/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/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/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/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/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/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
From 4c77c65e0090a3e31d04830baa9afea91a907fd9 Mon Sep 17 00:00:00 2001
From: Aleksandar Grbic
Date: Sat, 10 Oct 2026 10:39:45 +0200
Subject: [PATCH 4/6] fix(ci): make PR checks work in private repos and ship
packages/ in images
dorny/paths-filter lists PR files through the API, which needs
pull-requests: read in a private repository; without it every PR check
failed in the first product. A lint-meta rule now enforces it. pre-push no
longer skips gates on SIGPIPE under pipefail and unsets inherited git
variables. The API suite runs once in CI, agent verification cancels
superseded PR runs, dev mail defaults to Mailpit, and packages/ reaches
the API and UI images through a BuildKit context and dev containers
through a read-only mount.
---
.github/workflows/agent-verification.yml | 6 +
.github/workflows/apps-api-ci.yml | 14 +-
.github/workflows/apps-api-openapi-drift.yml | 1 +
.github/workflows/apps-api-release.yml | 3 +
.github/workflows/apps-api-security-deps.yml | 1 +
.github/workflows/apps-api-security-spec.yml | 1 +
.github/workflows/apps-docs-linkcheck.yml | 1 +
.github/workflows/apps-docs-security-deps.yml | 1 +
.github/workflows/apps-site-security-deps.yml | 1 +
.github/workflows/apps-site-validate.yml | 1 +
.github/workflows/apps-ui-bundle-diff.yml | 1 +
.github/workflows/apps-ui-release.yml | 3 +
.github/workflows/apps-ui-security-deps.yml | 1 +
.github/workflows/apps-ui-validate.yml | 1 +
.../workflows/infra-bootstrap-validate.yml | 1 +
.../infra-compose-full-stack-smoke.yml | 3 +
.../infra-compose-validate-compose.yml | 8 +
.github/workflows/infra-k3s-validate.yml | 1 +
.github/workflows/production-release.yml | 6 +
apps/api/Dockerfile.prod | 29 ++-
apps/api/scripts/lint-meta/RULES.md | 1 +
apps/api/scripts/lint-meta/cli.ts | 2 +
apps/api/scripts/lint-meta/registry.ts | 2 +
.../ci/github-actions-concurrency-explicit.ts | 6 +-
...github-actions-paths-filter-permissions.ts | 190 ++++++++++++++++++
apps/api/tests/lint-meta/lint-meta.test.ts | 136 +++++++++++++
apps/docs/src/data/lint-meta-catalog.json | 6 +
apps/ui/Dockerfile.prod | 17 +-
apps/ui/docs/agents/shared-packages.md | 35 ++++
.../ci/github-actions-concurrency-explicit.ts | 6 +-
apps/ui/tests/lint-meta/lint-meta.test.ts | 9 +
.../compose/docker-compose.mailpit.yml | 10 +
infra/compose/compose/docker-compose.yml | 24 ++-
infra/compose/scripts/validate-guardrails.sh | 6 +-
packages/README.md | 10 +
scripts/ci/pre-push-security.sh | 2 +-
scripts/ci/pre-push-smoke.sh | 2 +-
scripts/ci/pre-push.sh | 10 +-
tools/agent/pre-push-shell.test.ts | 73 +++++++
39 files changed, 604 insertions(+), 27 deletions(-)
create mode 100644 apps/api/scripts/lint-meta/rules/ci/github-actions-paths-filter-permissions.ts
create mode 100644 packages/README.md
create mode 100644 tools/agent/pre-push-shell.test.ts
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/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/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/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/docs/src/data/lint-meta-catalog.json b/apps/docs/src/data/lint-meta-catalog.json
index a78b311c..351b20b6 100644
--- a/apps/docs/src/data/lint-meta-catalog.json
+++ b/apps/docs/src/data/lint-meta-catalog.json
@@ -314,6 +314,12 @@
"ciCritical": false,
"description": "Workflows pairing push.paths with a dorny/paths-filter gate must keep the two path sets mutually covered."
},
+ {
+ "id": "github-actions-paths-filter-permissions",
+ "category": "ci",
+ "ciCritical": false,
+ "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."
+ },
{
"id": "github-actions-pip-install-pinned",
"category": "ci",
diff --git a/apps/ui/Dockerfile.prod b/apps/ui/Dockerfile.prod
index 469b4b18..220c96a8 100644
--- a/apps/ui/Dockerfile.prod
+++ b/apps/ui/Dockerfile.prod
@@ -1,6 +1,7 @@
-# Plain `docker build` works — no BuildKit syntax features required.
-# (If you're on BuildKit, you can add `# syntax=docker/dockerfile:1.7` at
-# the top + cache mounts for `bun install`; we keep the portable version.)
+# Needs BuildKit (the default since Docker 23) for the `packages` named context:
+# shared code under the repo's packages/ directory. Build from apps/ui with:
+# docker build --build-context packages=../../packages -f Dockerfile.prod .
+# Compose, the release workflows and the infra gate pass the same context.
# ---- Builder ---------------------------------------------------------------
FROM oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f AS builder
@@ -35,6 +36,16 @@ ARG VITE_VAPID_PUBLIC_KEY=""
ENV VITE_VAPID_PUBLIC_KEY=$VITE_VAPID_PUBLIC_KEY
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
RUN bun run build
# ---- Runner ---------------------------------------------------------------
diff --git a/apps/ui/docs/agents/shared-packages.md b/apps/ui/docs/agents/shared-packages.md
index f93bb9ce..6f5f56f6 100644
--- a/apps/ui/docs/agents/shared-packages.md
+++ b/apps/ui/docs/agents/shared-packages.md
@@ -82,6 +82,41 @@ first one is, add:
- `vite.config.ts`, `resolve.dedupe`: list every dependency that both the app and
the package declare (for example `zod`), so the bundle carries one copy.
+## Images and dev containers
+
+An app image or dev container sees only its own directory unless the packages
+are passed in. The template wires them in as follows, so a package import works
+the same in tests, dev containers and prod images.
+
+- **Layout.** Apps sit two levels below the repo root (`apps/`), so the
+ relative path `../../packages/` resolves from the app's `/app` to
+ `/packages/` inside a container. The prod images copy the packages to
+ `/packages`, and the dev containers mount them there.
+- **Prod images.** `apps/api/Dockerfile.prod` and `apps/ui/Dockerfile.prod`
+ take `packages/` as the BuildKit named context `packages`
+ (`COPY --from=packages . /packages`). Each package with a `package.json`
+ then runs `bun install --production` in its own directory, so its runtime
+ dependencies resolve from inside the package. Needs BuildKit, the default
+ since Docker 23.
+- **Build from an app directory** (for example to check an image by hand):
+
+ ```bash
+ cd apps/ui
+ docker build --build-context packages=../../packages -f Dockerfile.prod .
+ ```
+
+ Compose (`additional_contexts`), `docker/build-push-action`
+ (`build-contexts`) and the release workflows already pass this context.
+
+- **Dev containers.** `api-dev`, `api-migrate-dev` and `ui-dev` mount
+ `packages/` read-only at `/packages`. Run `bun install` inside a package that
+ has runtime dependencies, on the host, before the containers start. The
+ mount carries the package's `node_modules` with it.
+- **Triggers.** The API and UI CI, the release workflows, the compose gate and
+ the UI bundle diff all run when `packages/**` changes.
+- `packages/README.md` keeps the directory present in a fresh checkout. It is
+ a file, not a package, so it is not validated.
+
## Known gaps
- `lint:meta` scans only `src/`, `tests/`, `e2e/` and `.storybook/`
diff --git a/apps/ui/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts b/apps/ui/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts
index de144835..05db6f7b 100644
--- a/apps/ui/scripts/lint-meta/rules/ci/github-actions-concurrency-explicit.ts
+++ b/apps/ui/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/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/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);
+});
From 0b5565a60e82383f244dcd59f91807db1ec04193 Mon Sep 17 00:00:00 2001
From: Aleksandar Grbic
Date: Sat, 10 Oct 2026 10:39:45 +0200
Subject: [PATCH 5/6] docs: record the template fixes found in the first
product's code
---
apps/docs/src/content/docs/changelog.mdx | 16 +++++++++++++
docs/maintenance/product-feedback.md | 29 ++++++++++++++++++++++++
2 files changed, 45 insertions(+)
diff --git a/apps/docs/src/content/docs/changelog.mdx b/apps/docs/src/content/docs/changelog.mdx
index aea34e93..110361bc 100644
--- a/apps/docs/src/content/docs/changelog.mdx
+++ b/apps/docs/src/content/docs/changelog.mdx
@@ -7,6 +7,22 @@ A log of substantial changes to the docs, templates, and tooling. For per-commit
## 2026-10
+### Template fixes from the first product's code
+
+A diff of every template file the first product changed found bugs it had to fix.
+
+- **Billing.** Pro grants its features from one typed plan catalog. Checkout refuses a second
+ subscription and reuses an open session. A delayed payment no longer activates a plan. The
+ plan list is public, and an optional yearly price is supported. See [Billing](/api/billing/).
+- **Sessions.** Reloading after the access cookie expires refreshes the session instead of
+ logging out. A revoked refresh cookie is cleared, and the first write after a refresh keeps
+ its request body.
+- **CI in private repositories.** Every workflow that uses `dorny/paths-filter` grants
+ `pull-requests: read`, enforced by lint-meta. Without it, every pull request check fails in a
+ private repository.
+- **Shared packages in images.** `packages/*` reaches the API and UI images through a BuildKit
+ build context and the dev containers through a read-only mount.
+
### Domain split is the default
- **Split layout on Compose and OpenTofu.** The public site is on the apex (`https://`)
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. |
From 7aebb9f08968a745426c8778517dba9310c5ad74 Mon Sep 17 00:00:00 2001
From: Aleksandar Grbic
Date: Sat, 10 Oct 2026 10:43:33 +0200
Subject: [PATCH 6/6] fix(billing): keep matching subscription prices to any
plan row
Mapping prices only through the built-in catalog dropped updates for plans
a product adds outside it (the F10 security spec seeds one). Catalog
prices still map first, so the yearly Pro price resolves to Pro; any other
price matches plans.stripePriceId as before.
---
apps/api/src/api/billing/billing.service.ts | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/apps/api/src/api/billing/billing.service.ts b/apps/api/src/api/billing/billing.service.ts
index 6e8be4f7..616c5db0 100644
--- a/apps/api/src/api/billing/billing.service.ts
+++ b/apps/api/src/api/billing/billing.service.ts
@@ -818,24 +818,24 @@ export class BillingService {
}
/*
- * Matched against the catalog, not plans.stripePriceId: the row holds the
- * monthly price only, and either Pro price must map to Pro.
+ * 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
);
- if (planName === undefined) {
- return;
- }
-
const [account, newPlan] = await Promise.all([
tx.query.accounts.findFirst({
where: eq(accounts.stripeCustomerId, customerId),
}),
tx.query.plans.findFirst({
- where: eq(plans.name, planName),
+ where:
+ planName === undefined
+ ? eq(plans.stripePriceId, newPriceId)
+ : eq(plans.name, planName),
}),
]);