From 1347de2ffda6675297f215cf44ee1edefa36cd76 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 14:31:08 -0400 Subject: [PATCH 01/19] Add hosted plan tiers + entitlements config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines Free/Starter($29)/Pro($99)/Enterprise tiers with seat + location limits and the premium feature set (agent, SMS, advanced reporting, API, multi-location, integrations). Pro+ unlock all; Starter is the full core PIMS. Open-source posture: gating is hosted-only and OFF by default. When HOSTED_BILLING_ENABLED is unset (self-host), isEntitled/limits always pass, so the OSS edition is never crippled — we monetize hosting/support/usage, not by locking the open core. 8 tests. Co-Authored-By: Claude Opus 4.8 --- apps/web/lib/billing/__tests__/plans.test.ts | 67 +++++++++ apps/web/lib/billing/plans.ts | 150 +++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 apps/web/lib/billing/__tests__/plans.test.ts create mode 100644 apps/web/lib/billing/plans.ts diff --git a/apps/web/lib/billing/__tests__/plans.test.ts b/apps/web/lib/billing/__tests__/plans.test.ts new file mode 100644 index 0000000..1454f97 --- /dev/null +++ b/apps/web/lib/billing/__tests__/plans.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { + PLANS, + getPlan, + planHasFeature, + isEntitled, + withinSeatLimit, + withinLocationLimit, + ALL_FEATURES, +} from "../plans"; + +describe("getPlan", () => { + it("returns the matching plan and falls back to free", () => { + expect(getPlan("pro").tier).toBe("pro"); + expect(getPlan(null).tier).toBe("free"); + expect(getPlan("nonsense").tier).toBe("free"); + }); +}); + +describe("planHasFeature", () => { + it("pro includes all premium features; starter and free include none", () => { + for (const f of ALL_FEATURES) { + expect(planHasFeature("pro", f)).toBe(true); + expect(planHasFeature("enterprise", f)).toBe(true); + expect(planHasFeature("starter", f)).toBe(false); + expect(planHasFeature("free", f)).toBe(false); + } + }); +}); + +describe("isEntitled", () => { + it("self-host (not enforced) unlocks everything regardless of tier", () => { + expect(isEntitled("free", "agent", false)).toBe(true); + expect(isEntitled(null, "sms", false)).toBe(true); + }); + it("hosted (enforced) gates by tier", () => { + expect(isEntitled("free", "agent", true)).toBe(false); + expect(isEntitled("starter", "agent", true)).toBe(false); + expect(isEntitled("pro", "agent", true)).toBe(true); + expect(isEntitled("enterprise", "apiAccess", true)).toBe(true); + }); +}); + +describe("seat + location limits", () => { + it("not enforced always passes", () => { + expect(withinSeatLimit("free", 999, false)).toBe(true); + expect(withinLocationLimit("starter", 999, false)).toBe(true); + }); + it("enforced respects the tier limit (current < limit to add another)", () => { + expect(withinSeatLimit("starter", 4, true)).toBe(true); // 4 < 5 + expect(withinSeatLimit("starter", 5, true)).toBe(false); // at limit + expect(withinLocationLimit("starter", 1, true)).toBe(false); // limit 1 + expect(withinLocationLimit("pro", 50, true)).toBe(true); // pro locations unlimited + }); + it("enterprise/unlimited seats always pass", () => { + expect(withinSeatLimit("enterprise", 100000, true)).toBe(true); + }); +}); + +describe("PLANS pricing", () => { + it("matches the agreed value tiers", () => { + expect(PLANS.free.priceMonthlyUsd).toBe(0); + expect(PLANS.starter.priceMonthlyUsd).toBe(29); + expect(PLANS.pro.priceMonthlyUsd).toBe(99); + expect(PLANS.enterprise.priceMonthlyUsd).toBeNull(); + }); +}); diff --git a/apps/web/lib/billing/plans.ts b/apps/web/lib/billing/plans.ts new file mode 100644 index 0000000..a970e1a --- /dev/null +++ b/apps/web/lib/billing/plans.ts @@ -0,0 +1,150 @@ +/** + * Hosted plan tiers + feature entitlements. + * + * IMPORTANT — open-source posture: the OSS / self-host edition is NEVER + * crippled. Feature gating only applies to our managed hosted service, and is + * OFF by default (`HOSTED_BILLING_ENABLED` unset). When billing is not + * enforced, every feature is entitled — self-host gets the full product. We + * monetize hosting, support, and usage, not by locking the open core. + */ + +export type PlanTier = "free" | "starter" | "pro" | "enterprise"; + +/** Premium capabilities that the hosted tiers gate. */ +export type Feature = + | "agent" // OpenVPM Agent (AI) + | "sms" // SMS sending + | "advancedReporting" + | "apiAccess" // public /api/v1 + webhooks + | "multiLocation" + | "integrations"; + +export const ALL_FEATURES: Feature[] = [ + "agent", + "sms", + "advancedReporting", + "apiAccess", + "multiLocation", + "integrations", +]; + +export interface PlanDefinition { + tier: PlanTier; + name: string; + /** Monthly price in USD. null = custom / contact sales. */ + priceMonthlyUsd: number | null; + blurb: string; + /** Max staff seats. null = unlimited. */ + seatLimit: number | null; + /** Max locations. null = unlimited. */ + locationLimit: number | null; + /** Premium features included in this tier. */ + features: Feature[]; + /** Env var holding the Stripe Price ID for this tier (hosted only). */ + stripePriceEnv?: string; + /** Whether this tier is self-serve purchasable (vs. contact sales). */ + selfServe: boolean; +} + +export const PLANS: Record = { + free: { + tier: "free", + name: "Free", + priceMonthlyUsd: 0, + blurb: "Core PIMS for trying it out. Self-host is free forever.", + seatLimit: 2, + locationLimit: 1, + features: [], + selfServe: true, + }, + starter: { + tier: "starter", + name: "Starter", + priceMonthlyUsd: 29, + blurb: + "Managed hosting, the full PIMS, email reminders, client portal, and data export.", + seatLimit: 5, + locationLimit: 1, + features: [], + stripePriceEnv: "STRIPE_PRICE_STARTER", + selfServe: true, + }, + pro: { + tier: "pro", + name: "Pro", + priceMonthlyUsd: 99, + blurb: + "Adds the OpenVPM Agent, SMS, advanced reporting, API access + webhooks, multi-location, and integrations.", + seatLimit: 25, + locationLimit: null, + features: [...ALL_FEATURES], + stripePriceEnv: "STRIPE_PRICE_PRO", + selfServe: true, + }, + enterprise: { + tier: "enterprise", + name: "Enterprise", + priceMonthlyUsd: null, + blurb: + "Single-tenant or in-region dedicated instance, SSO, DPA/compliance, and priority support.", + seatLimit: null, + locationLimit: null, + features: [...ALL_FEATURES], + selfServe: false, + }, +}; + +export const PLAN_ORDER: PlanTier[] = ["free", "starter", "pro", "enterprise"]; + +export function getPlan(tier?: string | null): PlanDefinition { + const t = (tier ?? "free") as PlanTier; + return PLANS[t] ?? PLANS.free; +} + +/** Pure: does this tier include this premium feature? */ +export function planHasFeature(tier: string | null | undefined, feature: Feature): boolean { + return getPlan(tier).features.includes(feature); +} + +/** + * Whether hosted billing is enforced (i.e. we're the managed service). Off by + * default so self-host / OSS runs with everything unlocked. + */ +export function billingEnforced(): boolean { + return process.env.HOSTED_BILLING_ENABLED === "true"; +} + +/** + * Effective entitlement check. When billing is not enforced (self-host), every + * feature is entitled. `enforced` is injectable for testing. + */ +export function isEntitled( + tier: string | null | undefined, + feature: Feature, + enforced: boolean = billingEnforced() +): boolean { + if (!enforced) return true; + return planHasFeature(tier, feature); +} + +/** Seat-limit check. Unlimited (null) or self-host (not enforced) always passes. */ +export function withinSeatLimit( + tier: string | null | undefined, + currentSeats: number, + enforced: boolean = billingEnforced() +): boolean { + if (!enforced) return true; + const limit = getPlan(tier).seatLimit; + return limit === null || currentSeats < limit; +} + +/** Location-limit check. Unlimited (null) or self-host (not enforced) always passes. */ +export function withinLocationLimit( + tier: string | null | undefined, + currentLocations: number, + enforced: boolean = billingEnforced() +): boolean { + if (!enforced) return true; + const limit = getPlan(tier).locationLimit; + return limit === null || currentLocations < limit; +} From ab2e0f04fbe4cd6a510b010e5d7c491b3a7ea667 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 14:31:57 -0400 Subject: [PATCH 02/19] Add hosted subscription fields to practices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Stripe linkage + billing lifecycle to the practices table (additive): stripeCustomerId, stripeSubscriptionId, billingStatus (none|trialing|active| past_due|canceled), trialEndsAt. Reuses the existing subscriptionTier column as the canonical plan tier (now NOT NULL default 'free'). Self-host ignores these unless HOSTED_BILLING_ENABLED. Additive — run pnpm db:push on deploy. Co-Authored-By: Claude Opus 4.8 --- packages/db/schema/practices.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/db/schema/practices.ts b/packages/db/schema/practices.ts index a0d8ec4..8b38838 100644 --- a/packages/db/schema/practices.ts +++ b/packages/db/schema/practices.ts @@ -21,7 +21,14 @@ export const practices = pgTable("practices", { timezone: varchar("timezone", { length: 64 }).notNull().default("America/New_York"), logoUrl: varchar("logo_url", { length: 512 }), settings: jsonb("settings").default({}), - subscriptionTier: varchar("subscription_tier", { length: 32 }).default("free"), + // Hosted-SaaS subscription (ignored by self-host unless HOSTED_BILLING_ENABLED). + // subscriptionTier is the canonical plan tier: free | starter | pro | enterprise. + subscriptionTier: varchar("subscription_tier", { length: 32 }).notNull().default("free"), + stripeCustomerId: varchar("stripe_customer_id", { length: 64 }), + stripeSubscriptionId: varchar("stripe_subscription_id", { length: 64 }), + // Stripe-style billing lifecycle: none | trialing | active | past_due | canceled. + billingStatus: varchar("billing_status", { length: 24 }).notNull().default("none"), + trialEndsAt: timestamp("trial_ends_at", { withTimezone: true }), // Region/locale — gates currency, tax, formatting, and (later) regulatory // behavior. Defaults keep existing US practices working unchanged. country: varchar("country", { length: 2 }).notNull().default("US"), // ISO 3166-1 alpha-2 From 4aa3d53ab9e1ab4bf5f40e8ca994ccb5730ecf7a Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 14:35:11 -0400 Subject: [PATCH 03/19] Add requireFeature plan gating + apply to agent, reports, API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New requireFeature(feature) tRPC middleware gates premium capabilities by the practice's plan tier. Applied to the OpenVPM Agent (run), advanced reporting (all report queries), and the public /api/v1 (apiAccess scope check in api-auth). No-op on self-host: when HOSTED_BILLING_ENABLED is unset, billingEnforced() is false and the middleware allows everything and skips the DB lookup — the OSS edition stays fully unlocked. Only the managed hosted service enforces tiers. Co-Authored-By: Claude Opus 4.8 --- apps/web/lib/api-auth.ts | 15 +++++++++++- apps/web/server/routers/agent.ts | 9 +++---- apps/web/server/routers/reports.ts | 13 ++++++---- apps/web/server/trpc.ts | 38 ++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts index ea2d771..1972164 100644 --- a/apps/web/lib/api-auth.ts +++ b/apps/web/lib/api-auth.ts @@ -3,8 +3,9 @@ import bcrypt from "bcryptjs"; import { eq, and, isNull } from "drizzle-orm"; import { NextResponse } from "next/server"; import { db } from "@openpims/db/client"; -import { apiKeys } from "@openpims/db"; +import { apiKeys, practices } from "@openpims/db"; import { rateLimit } from "@/lib/rate-limit"; +import { billingEnforced, isEntitled } from "@/lib/billing/plans"; /** Public prefix for every issued key. Also used as the human-visible label. */ export const API_KEY_PREFIX = "ovpm_"; @@ -107,6 +108,18 @@ export async function authenticateApiKey( return err(`API key missing required scope: ${requiredScope}`, 403); } + // Public API access is a Pro feature on the hosted service (no-op on self-host). + if (billingEnforced()) { + const [practice] = await db + .select({ tier: practices.subscriptionTier }) + .from(practices) + .where(eq(practices.id, matched.practiceId)) + .limit(1); + if (!isEntitled(practice?.tier, "apiAccess", true)) { + return err("API access is not included in your plan. Upgrade to Pro to use the API.", 403); + } + } + const { success, remaining } = rateLimit({ key: `apikey:${matched.id}`, limit: RATE_LIMIT, diff --git a/apps/web/server/routers/agent.ts b/apps/web/server/routers/agent.ts index c67ea71..ccc6320 100644 --- a/apps/web/server/routers/agent.ts +++ b/apps/web/server/routers/agent.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { createRouter, protectedProcedure, requireRole } from "../trpc"; +import { createRouter, protectedProcedure, requireRole, requireFeature } from "../trpc"; import { runAgent, isAgentConfigured, @@ -8,9 +8,10 @@ import { AgentNotConfiguredError, } from "@/lib/agent"; -const agentProcedure = protectedProcedure.use( - requireRole("admin", "veterinarian") -); +// The OpenVPM Agent is a Pro feature on hosted; unrestricted on self-host. +const agentProcedure = protectedProcedure + .use(requireRole("admin", "veterinarian")) + .use(requireFeature("agent")); export const agentRouter = createRouter({ /** Whether the agent is enabled (API key present) and what it can do. */ diff --git a/apps/web/server/routers/reports.ts b/apps/web/server/routers/reports.ts index beeb01d..f15042b 100644 --- a/apps/web/server/routers/reports.ts +++ b/apps/web/server/routers/reports.ts @@ -1,5 +1,5 @@ import { eq, and, gte, lte, isNull, sql, desc } from "drizzle-orm"; -import { createRouter, protectedProcedure } from "../trpc"; +import { createRouter, protectedProcedure, requireFeature } from "../trpc"; import { invoices, invoiceItems, @@ -9,8 +9,11 @@ import { users, } from "@openpims/db"; +// Advanced reporting is a Pro feature on hosted; unrestricted on self-host. +const reportProcedure = protectedProcedure.use(requireFeature("advancedReporting")); + export const reportsRouter = createRouter({ - revenue: protectedProcedure.query(async ({ ctx }) => { + revenue: reportProcedure.query(async ({ ctx }) => { const now = new Date(); const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); @@ -70,7 +73,7 @@ export const reportsRouter = createRouter({ }; }), - appointments: protectedProcedure.query(async ({ ctx }) => { + appointments: reportProcedure.query(async ({ ctx }) => { const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); @@ -136,7 +139,7 @@ export const reportsRouter = createRouter({ }; }), - topServices: protectedProcedure.query(async ({ ctx }) => { + topServices: reportProcedure.query(async ({ ctx }) => { const rows = await ctx.db .select({ name: sql`coalesce(${services.name}, ${invoiceItems.description})`, @@ -170,7 +173,7 @@ export const reportsRouter = createRouter({ })); }), - inventoryAlerts: protectedProcedure.query(async ({ ctx }) => { + inventoryAlerts: reportProcedure.query(async ({ ctx }) => { const now = new Date(); const ninetyDaysFromNow = new Date(now); ninetyDaysFromNow.setDate(ninetyDaysFromNow.getDate() + 90); diff --git a/apps/web/server/trpc.ts b/apps/web/server/trpc.ts index 9c22462..2ec9565 100644 --- a/apps/web/server/trpc.ts +++ b/apps/web/server/trpc.ts @@ -3,10 +3,13 @@ import type { Session } from "next-auth"; import { getServerSession } from "next-auth"; import superjson from "superjson"; import { ZodError } from "zod"; +import { eq } from "drizzle-orm"; import { authOptions } from "@/lib/auth"; import { recordAuditLog } from "@/lib/audit"; import { db } from "@openpims/db/client"; import type { Database } from "@openpims/db/client"; +import { practices } from "@openpims/db"; +import { billingEnforced, isEntitled, type Feature } from "@/lib/billing/plans"; type UserRole = | "admin" @@ -100,6 +103,41 @@ export const protectedProcedure = t.procedure.use( } ); +/** + * Requires the practice's plan to include a premium feature. + * + * No-op on self-host: when HOSTED_BILLING_ENABLED is unset, billingEnforced() + * is false and this allows everything (and skips the DB lookup entirely), so + * the OSS edition is never gated. Only the managed hosted service enforces it. + */ +export function requireFeature(feature: Feature) { + return t.middleware(async ({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + if (billingEnforced()) { + const [practice] = await ctx.db + .select({ tier: practices.subscriptionTier }) + .from(practices) + .where(eq(practices.id, ctx.session.user.practiceId)) + .limit(1); + if (!isEntitled(practice?.tier, feature, true)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Your plan doesn't include this feature. Upgrade to unlock it.`, + }); + } + } + return next({ + ctx: { + session: ctx.session, + user: ctx.session.user, + practiceId: ctx.session.user.practiceId, + }, + }); + }); +} + /** Requires specific roles */ export function requireRole(...roles: UserRole[]) { return t.middleware(async ({ ctx, next }) => { From 103bb356a5309728a62b1e156a68566b7d53ec70 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 14:38:19 -0400 Subject: [PATCH 04/19] Add subscription router + Stripe billing portal New subscription tRPC router (admin-only): get current plan/status + the plan catalog, createCheckout for self-serve Starter/Pro (Stripe subscription mode, practiceId stamped in metadata), and openBillingPortal to manage/cancel. Stripe helpers: createSubscriptionCheckoutSession, createBillingPortalSession, and constructSubscriptionWebhookEvent (dedicated webhook secret). Documents the new hosted-billing env vars (HOSTED_BILLING_ENABLED, STRIPE_PRICE_*, STRIPE_SUBSCRIPTION_WEBHOOK_SECRET, NEXT_PUBLIC_APP_URL). Co-Authored-By: Claude Opus 4.8 --- .env.example | 13 ++- apps/web/lib/stripe.ts | 60 +++++++++++ apps/web/server/routers/_app.ts | 2 + apps/web/server/routers/subscription.ts | 138 ++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 apps/web/server/routers/subscription.ts diff --git a/.env.example b/.env.example index 15ab490..50b899d 100644 --- a/.env.example +++ b/.env.example @@ -20,10 +20,21 @@ TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= TWILIO_PHONE_NUMBER= -# Stripe +# Stripe (client invoicing — client portal payments) STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= +# Hosted SaaS subscriptions (managed service only — leave HOSTED_BILLING_ENABLED +# unset for self-host so the OSS edition runs fully unlocked). When enabled, +# plan tiers are enforced and these must be set. +HOSTED_BILLING_ENABLED= +STRIPE_PRICE_STARTER= +STRIPE_PRICE_PRO= +# Separate webhook endpoint secret for customer.subscription.* events. +STRIPE_SUBSCRIPTION_WEBHOOK_SECRET= +# Public base URL used to build Stripe success/return URLs (falls back to NEXTAUTH_URL). +NEXT_PUBLIC_APP_URL="http://localhost:3000" + # Cron job authentication CRON_SECRET= diff --git a/apps/web/lib/stripe.ts b/apps/web/lib/stripe.ts index ee6d0f3..9039261 100644 --- a/apps/web/lib/stripe.ts +++ b/apps/web/lib/stripe.ts @@ -49,4 +49,64 @@ export async function constructWebhookEvent( ); } +// ── Hosted-SaaS subscriptions (separate surface from client invoicing) ────── + +/** + * Create a Checkout Session for a recurring plan subscription. The practiceId is + * stamped on both the session and the subscription metadata so the webhook can + * map the resulting subscription back to a practice. + */ +export async function createSubscriptionCheckoutSession(data: { + priceId: string; + practiceId: string; + customerId?: string | null; + customerEmail?: string | null; + successUrl: string; + cancelUrl: string; +}): Promise<{ url: string | null } | null> { + if (!stripe) { + console.log("[Stripe] No API key configured, skipping subscription checkout"); + return null; + } + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + line_items: [{ price: data.priceId, quantity: 1 }], + ...(data.customerId + ? { customer: data.customerId } + : { customer_email: data.customerEmail ?? undefined }), + client_reference_id: data.practiceId, + metadata: { practiceId: data.practiceId }, + subscription_data: { metadata: { practiceId: data.practiceId } }, + success_url: data.successUrl, + cancel_url: data.cancelUrl, + }); + return { url: session.url }; +} + +/** Create a Stripe Billing Portal session so a practice can manage its plan. */ +export async function createBillingPortalSession(data: { + customerId: string; + returnUrl: string; +}): Promise<{ url: string } | null> { + if (!stripe) return null; + const session = await stripe.billingPortal.sessions.create({ + customer: data.customerId, + return_url: data.returnUrl, + }); + return { url: session.url }; +} + +/** Verify a subscription-webhook signature using its dedicated endpoint secret. */ +export async function constructSubscriptionWebhookEvent( + body: string, + signature: string, +): Promise { + if (!stripe) return null; + return stripe.webhooks.constructEvent( + body, + signature, + process.env.STRIPE_SUBSCRIPTION_WEBHOOK_SECRET!, + ); +} + export { stripe }; diff --git a/apps/web/server/routers/_app.ts b/apps/web/server/routers/_app.ts index e818bf8..fc54075 100644 --- a/apps/web/server/routers/_app.ts +++ b/apps/web/server/routers/_app.ts @@ -26,6 +26,7 @@ import { agentRouter } from "./agent"; import { treatmentPlansRouter } from "./treatment-plans"; import { wellnessRouter } from "./wellness"; import { waitlistRouter } from "./waitlist"; +import { subscriptionRouter } from "./subscription"; export const appRouter = createRouter({ auth: authRouter, @@ -55,6 +56,7 @@ export const appRouter = createRouter({ treatmentPlans: treatmentPlansRouter, wellness: wellnessRouter, waitlist: waitlistRouter, + subscription: subscriptionRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/web/server/routers/subscription.ts b/apps/web/server/routers/subscription.ts new file mode 100644 index 0000000..940334f --- /dev/null +++ b/apps/web/server/routers/subscription.ts @@ -0,0 +1,138 @@ +import { z } from "zod"; +import { eq } from "drizzle-orm"; +import { TRPCError } from "@trpc/server"; +import { createRouter, protectedProcedure, requireRole } from "../trpc"; +import { practices } from "@openpims/db"; +import { + createSubscriptionCheckoutSession, + createBillingPortalSession, +} from "@/lib/stripe"; +import { + PLANS, + PLAN_ORDER, + billingEnforced, +} from "@/lib/billing/plans"; + +const adminProcedure = protectedProcedure.use(requireRole("admin")); + +function appBaseUrl(): string { + return ( + process.env.NEXT_PUBLIC_APP_URL ?? + process.env.NEXTAUTH_URL ?? + "http://localhost:3000" + ); +} + +/** Whether a tier can be bought self-serve (Stripe price configured). */ +function purchasable(tier: keyof typeof PLANS): boolean { + const env = PLANS[tier].stripePriceEnv; + return !!(env && process.env[env]); +} + +export const subscriptionRouter = createRouter({ + /** Current plan + status, plus the catalog for display. */ + get: adminProcedure.query(async ({ ctx }) => { + const [practice] = await ctx.db + .select({ + tier: practices.subscriptionTier, + billingStatus: practices.billingStatus, + trialEndsAt: practices.trialEndsAt, + stripeCustomerId: practices.stripeCustomerId, + }) + .from(practices) + .where(eq(practices.id, ctx.practiceId)) + .limit(1); + + return { + tier: practice?.tier ?? "free", + billingStatus: practice?.billingStatus ?? "none", + trialEndsAt: practice?.trialEndsAt ?? null, + hasBillingAccount: !!practice?.stripeCustomerId, + billingEnforced: billingEnforced(), + plans: PLAN_ORDER.map((t) => { + const p = PLANS[t]; + return { + tier: p.tier, + name: p.name, + priceMonthlyUsd: p.priceMonthlyUsd, + blurb: p.blurb, + features: p.features, + seatLimit: p.seatLimit, + locationLimit: p.locationLimit, + selfServe: p.selfServe, + purchasable: purchasable(t), + }; + }), + }; + }), + + /** Start a Stripe Checkout for a self-serve plan (Starter/Pro). */ + createCheckout: adminProcedure + .input(z.object({ tier: z.enum(["starter", "pro"]) })) + .mutation(async ({ ctx, input }) => { + const plan = PLANS[input.tier]; + const priceId = plan.stripePriceEnv + ? process.env[plan.stripePriceEnv] + : undefined; + if (!priceId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "This plan isn't available for checkout yet.", + }); + } + + const [practice] = await ctx.db + .select({ + stripeCustomerId: practices.stripeCustomerId, + email: practices.email, + }) + .from(practices) + .where(eq(practices.id, ctx.practiceId)) + .limit(1); + + const base = appBaseUrl(); + const result = await createSubscriptionCheckoutSession({ + priceId, + practiceId: ctx.practiceId, + customerId: practice?.stripeCustomerId ?? undefined, + customerEmail: practice?.email ?? ctx.session.user.email, + successUrl: `${base}/settings?tab=billing&checkout=success`, + cancelUrl: `${base}/settings?tab=billing&checkout=cancelled`, + }); + if (!result) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Billing is not configured on this server.", + }); + } + return { url: result.url }; + }), + + /** Open the Stripe Billing Portal to manage/cancel an existing subscription. */ + openBillingPortal: adminProcedure.mutation(async ({ ctx }) => { + const [practice] = await ctx.db + .select({ stripeCustomerId: practices.stripeCustomerId }) + .from(practices) + .where(eq(practices.id, ctx.practiceId)) + .limit(1); + + if (!practice?.stripeCustomerId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "No billing account yet — start a plan first.", + }); + } + + const result = await createBillingPortalSession({ + customerId: practice.stripeCustomerId, + returnUrl: `${appBaseUrl()}/settings?tab=billing`, + }); + if (!result) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Billing is not configured on this server.", + }); + } + return { url: result.url }; + }), +}); From 5546c72dc56f2137ca4b82d45d4ff008de13b6f6 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 14:40:17 -0400 Subject: [PATCH 05/19] Add Stripe subscription webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate webhook endpoint (own signing secret) for hosted-SaaS subscriptions, isolated from the client-invoice webhook. Handles: - checkout.session.completed → store stripeCustomerId/subscriptionId on practice - customer.subscription.created/updated → set tier (from price), billingStatus, trialEndsAt - customer.subscription.deleted → revert to free/canceled - invoice.payment_failed → mark past_due + alert ops Adds tierForStripePrice() and normalizeBillingStatus() helpers. Maps each subscription to its practice via metadata.practiceId stamped at checkout. Co-Authored-By: Claude Opus 4.8 --- .../api/webhooks/stripe-subscription/route.ts | 133 ++++++++++++++++++ apps/web/lib/billing/plans.ts | 28 ++++ 2 files changed, 161 insertions(+) create mode 100644 apps/web/app/api/webhooks/stripe-subscription/route.ts diff --git a/apps/web/app/api/webhooks/stripe-subscription/route.ts b/apps/web/app/api/webhooks/stripe-subscription/route.ts new file mode 100644 index 0000000..a688794 --- /dev/null +++ b/apps/web/app/api/webhooks/stripe-subscription/route.ts @@ -0,0 +1,133 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import type Stripe from "stripe"; +import { db } from "@openpims/db/client"; +import { practices } from "@openpims/db"; +import { constructSubscriptionWebhookEvent } from "@/lib/stripe"; +import { tierForStripePrice, normalizeBillingStatus } from "@/lib/billing/plans"; +import { alertOps } from "@/lib/alerts"; + +/** + * Stripe webhook for hosted-SaaS subscriptions — a SEPARATE endpoint from the + * client-invoice webhook (different signing secret). Keeps the two Stripe + * surfaces isolated so neither can spoof the other. + */ +export async function POST(req: NextRequest) { + const body = await req.text(); + const signature = req.headers.get("stripe-signature"); + if (!signature) { + return NextResponse.json({ error: "Missing stripe-signature header" }, { status: 400 }); + } + + let event: Stripe.Event | null; + try { + event = await constructSubscriptionWebhookEvent(body, signature); + } catch (err) { + console.error("[Stripe Subscription Webhook] signature verification failed:", err); + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); + } + if (!event) { + return NextResponse.json( + { error: "Webhook verification failed or Stripe not configured" }, + { status: 400 }, + ); + } + + try { + switch (event.type) { + case "checkout.session.completed": { + const s = event.data.object as Stripe.Checkout.Session; + const practiceId = s.client_reference_id ?? s.metadata?.practiceId ?? null; + if (practiceId && s.customer) { + await db + .update(practices) + .set({ + stripeCustomerId: + typeof s.customer === "string" ? s.customer : s.customer.id, + stripeSubscriptionId: + typeof s.subscription === "string" + ? s.subscription + : (s.subscription?.id ?? null), + }) + .where(eq(practices.id, practiceId)); + } + break; + } + + case "customer.subscription.created": + case "customer.subscription.updated": { + await applySubscription(event.data.object as Stripe.Subscription); + break; + } + + case "customer.subscription.deleted": { + const sub = event.data.object as Stripe.Subscription; + const practiceId = sub.metadata?.practiceId; + if (practiceId) { + await db + .update(practices) + .set({ + subscriptionTier: "free", + billingStatus: "canceled", + stripeSubscriptionId: null, + }) + .where(eq(practices.id, practiceId)); + } + break; + } + + case "invoice.payment_failed": { + const inv = event.data.object as Stripe.Invoice; + const customerId = + typeof inv.customer === "string" ? inv.customer : inv.customer?.id; + if (customerId) { + await db + .update(practices) + .set({ billingStatus: "past_due" }) + .where(eq(practices.stripeCustomerId, customerId)); + await alertOps( + "Subscription payment failed", + `Stripe customer ${customerId} had a failed subscription payment; marked past_due.`, + ); + } + break; + } + + default: + // Ignore other event types. + break; + } + + return NextResponse.json({ received: true }); + } catch (err) { + console.error("[Stripe Subscription Webhook] handler error:", err); + await alertOps( + "Subscription webhook handler error", + `Event ${event.type} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return NextResponse.json({ error: "Handler error" }, { status: 500 }); + } +} + +/** Apply a subscription's tier/status/trial to its practice (via metadata.practiceId). */ +async function applySubscription(sub: Stripe.Subscription) { + const practiceId = sub.metadata?.practiceId; + if (!practiceId) { + console.warn("[Stripe Subscription Webhook] subscription without practiceId metadata:", sub.id); + return; + } + const priceId = sub.items?.data?.[0]?.price?.id ?? null; + const tier = tierForStripePrice(priceId); + const customerId = typeof sub.customer === "string" ? sub.customer : sub.customer?.id; + + await db + .update(practices) + .set({ + ...(tier ? { subscriptionTier: tier } : {}), + billingStatus: normalizeBillingStatus(sub.status), + stripeSubscriptionId: sub.id, + ...(customerId ? { stripeCustomerId: customerId } : {}), + trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1000) : null, + }) + .where(eq(practices.id, practiceId)); +} diff --git a/apps/web/lib/billing/plans.ts b/apps/web/lib/billing/plans.ts index a970e1a..6a6fac6 100644 --- a/apps/web/lib/billing/plans.ts +++ b/apps/web/lib/billing/plans.ts @@ -101,6 +101,34 @@ export function getPlan(tier?: string | null): PlanDefinition { return PLANS[t] ?? PLANS.free; } +/** Map a Stripe Price ID back to a plan tier (via the configured env vars). */ +export function tierForStripePrice(priceId: string | null | undefined): PlanTier | null { + if (!priceId) return null; + for (const t of PLAN_ORDER) { + const env = PLANS[t].stripePriceEnv; + if (env && process.env[env] === priceId) return t; + } + return null; +} + +/** Normalize a Stripe subscription status to our billingStatus values. */ +export function normalizeBillingStatus(status: string | null | undefined): string { + switch (status) { + case "trialing": + return "trialing"; + case "active": + return "active"; + case "past_due": + case "unpaid": + return "past_due"; + case "canceled": + case "incomplete_expired": + return "canceled"; + default: + return status ?? "none"; + } +} + /** Pure: does this tier include this premium feature? */ export function planHasFeature(tier: string | null | undefined, feature: Feature): boolean { return getPlan(tier).features.includes(feature); From 3d499df66554350c2c8f920e68ef36842826c25c Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 14:43:04 -0400 Subject: [PATCH 06/19] Provision a 14-day full-featured trial on hosted signup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New practices on the hosted service start in a 14-day trial (billingStatus trialing + trialEndsAt) that grants Pro-level access, then auto-reverts to free when it lapses — no Stripe needed to start. Self-host is unaffected (trial only set when HOSTED_BILLING_ENABLED). effectiveTier()/isTrialActive() compute the entitlement tier from trial state; requireFeature and the API gate both honor an active trial. +3 tests. Co-Authored-By: Claude Opus 4.8 --- apps/web/lib/api-auth.ts | 15 ++++++++--- apps/web/lib/billing/__tests__/plans.test.ts | 26 +++++++++++++++++++ apps/web/lib/billing/plans.ts | 27 ++++++++++++++++++++ apps/web/server/routers/auth.ts | 11 ++++++++ apps/web/server/trpc.ts | 20 ++++++++++++--- 5 files changed, 93 insertions(+), 6 deletions(-) diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts index 1972164..ea61672 100644 --- a/apps/web/lib/api-auth.ts +++ b/apps/web/lib/api-auth.ts @@ -5,7 +5,7 @@ import { NextResponse } from "next/server"; import { db } from "@openpims/db/client"; import { apiKeys, practices } from "@openpims/db"; import { rateLimit } from "@/lib/rate-limit"; -import { billingEnforced, isEntitled } from "@/lib/billing/plans"; +import { billingEnforced, isEntitled, effectiveTier } from "@/lib/billing/plans"; /** Public prefix for every issued key. Also used as the human-visible label. */ export const API_KEY_PREFIX = "ovpm_"; @@ -111,11 +111,20 @@ export async function authenticateApiKey( // Public API access is a Pro feature on the hosted service (no-op on self-host). if (billingEnforced()) { const [practice] = await db - .select({ tier: practices.subscriptionTier }) + .select({ + tier: practices.subscriptionTier, + billingStatus: practices.billingStatus, + trialEndsAt: practices.trialEndsAt, + }) .from(practices) .where(eq(practices.id, matched.practiceId)) .limit(1); - if (!isEntitled(practice?.tier, "apiAccess", true)) { + const tier = effectiveTier( + practice?.tier, + practice?.billingStatus, + practice?.trialEndsAt + ); + if (!isEntitled(tier, "apiAccess", true)) { return err("API access is not included in your plan. Upgrade to Pro to use the API.", 403); } } diff --git a/apps/web/lib/billing/__tests__/plans.test.ts b/apps/web/lib/billing/__tests__/plans.test.ts index 1454f97..bfd19f5 100644 --- a/apps/web/lib/billing/__tests__/plans.test.ts +++ b/apps/web/lib/billing/__tests__/plans.test.ts @@ -6,6 +6,8 @@ import { isEntitled, withinSeatLimit, withinLocationLimit, + isTrialActive, + effectiveTier, ALL_FEATURES, } from "../plans"; @@ -57,6 +59,30 @@ describe("seat + location limits", () => { }); }); +describe("trials", () => { + const now = new Date("2026-06-07T00:00:00Z"); + const future = new Date("2026-06-20T00:00:00Z"); + const past = new Date("2026-06-01T00:00:00Z"); + + it("isTrialActive only when status=trialing and not expired", () => { + expect(isTrialActive("trialing", future, now)).toBe(true); + expect(isTrialActive("trialing", past, now)).toBe(false); + expect(isTrialActive("active", future, now)).toBe(false); + expect(isTrialActive("trialing", null, now)).toBe(false); + }); + + it("effectiveTier grants pro during an active trial, then reverts", () => { + expect(effectiveTier("free", "trialing", future, now)).toBe("pro"); + expect(effectiveTier("free", "trialing", past, now)).toBe("free"); + expect(effectiveTier("starter", "active", future, now)).toBe("starter"); + }); + + it("an active trial unlocks gated features even on the free tier", () => { + const tier = effectiveTier("free", "trialing", future, now); + expect(isEntitled(tier, "agent", true)).toBe(true); + }); +}); + describe("PLANS pricing", () => { it("matches the agreed value tiers", () => { expect(PLANS.free.priceMonthlyUsd).toBe(0); diff --git a/apps/web/lib/billing/plans.ts b/apps/web/lib/billing/plans.ts index 6a6fac6..5cf23bf 100644 --- a/apps/web/lib/billing/plans.ts +++ b/apps/web/lib/billing/plans.ts @@ -134,6 +134,33 @@ export function planHasFeature(tier: string | null | undefined, feature: Feature return getPlan(tier).features.includes(feature); } +/** Length of the new-practice free trial on the hosted service. */ +export const TRIAL_DAYS = 14; + +/** Whether a practice is in an unexpired trial window. */ +export function isTrialActive( + billingStatus: string | null | undefined, + trialEndsAt: Date | string | null | undefined, + now: Date = new Date() +): boolean { + if (billingStatus !== "trialing" || !trialEndsAt) return false; + return new Date(trialEndsAt).getTime() > now.getTime(); +} + +/** + * The tier whose entitlements actually apply right now. An active trial grants + * full (Pro) access; once it lapses we fall back to the stored tier. + */ +export function effectiveTier( + tier: string | null | undefined, + billingStatus: string | null | undefined, + trialEndsAt: Date | string | null | undefined, + now: Date = new Date() +): PlanTier { + if (isTrialActive(billingStatus, trialEndsAt, now)) return "pro"; + return (tier ?? "free") as PlanTier; +} + /** * Whether hosted billing is enforced (i.e. we're the managed service). Off by * default so self-host / OSS runs with everything unlocked. diff --git a/apps/web/server/routers/auth.ts b/apps/web/server/routers/auth.ts index 92af189..cafcc2f 100644 --- a/apps/web/server/routers/auth.ts +++ b/apps/web/server/routers/auth.ts @@ -6,6 +6,7 @@ import { createRouter, publicProcedure, protectedProcedure } from "../trpc"; import { users, practices, locations } from "@openpims/db"; import { rateLimit } from "@/lib/rate-limit"; import { seedPractice } from "@/lib/onboarding/defaults"; +import { billingEnforced, TRIAL_DAYS } from "@/lib/billing/plans"; export const authRouter = createRouter({ register: publicProcedure @@ -45,11 +46,21 @@ export const authRouter = createRouter({ const passwordHash = await hash(input.password, 10); + // On the hosted service, start a full-featured trial so the new practice + // is immediately usable before entering payment. Self-host ignores this. + const trial = billingEnforced() + ? { + billingStatus: "trialing" as const, + trialEndsAt: new Date(Date.now() + TRIAL_DAYS * 24 * 60 * 60 * 1000), + } + : {}; + // Create practice const [practice] = await ctx.db .insert(practices) .values({ name: input.practiceName, + ...trial, }) .returning(); diff --git a/apps/web/server/trpc.ts b/apps/web/server/trpc.ts index 2ec9565..a78a32e 100644 --- a/apps/web/server/trpc.ts +++ b/apps/web/server/trpc.ts @@ -9,7 +9,12 @@ import { recordAuditLog } from "@/lib/audit"; import { db } from "@openpims/db/client"; import type { Database } from "@openpims/db/client"; import { practices } from "@openpims/db"; -import { billingEnforced, isEntitled, type Feature } from "@/lib/billing/plans"; +import { + billingEnforced, + isEntitled, + effectiveTier, + type Feature, +} from "@/lib/billing/plans"; type UserRole = | "admin" @@ -117,11 +122,20 @@ export function requireFeature(feature: Feature) { } if (billingEnforced()) { const [practice] = await ctx.db - .select({ tier: practices.subscriptionTier }) + .select({ + tier: practices.subscriptionTier, + billingStatus: practices.billingStatus, + trialEndsAt: practices.trialEndsAt, + }) .from(practices) .where(eq(practices.id, ctx.session.user.practiceId)) .limit(1); - if (!isEntitled(practice?.tier, feature, true)) { + const tier = effectiveTier( + practice?.tier, + practice?.billingStatus, + practice?.trialEndsAt + ); + if (!isEntitled(tier, feature, true)) { throw new TRPCError({ code: "FORBIDDEN", message: `Your plan doesn't include this feature. Upgrade to unlock it.`, From 54b557e8ef7095542b3eb0b9af501e436e1650e5 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 14:46:36 -0400 Subject: [PATCH 07/19] Add Plan & Billing settings tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tab showing current plan, billing/trial status (with days-left countdown), a plan-comparison grid, self-serve upgrade buttons (Stripe Checkout), and a Manage Billing button (Stripe Portal). Reads ?tab=billing so the Stripe redirect lands back on this tab; wrapped in Suspense for useSearchParams. On self-host it shows an 'all features unlocked, free forever' card instead of upgrade prompts — the catalog is shown only for reference. Co-Authored-By: Claude Opus 4.8 --- apps/web/app/(dashboard)/settings/page.tsx | 252 ++++++++++++++++++++- 1 file changed, 249 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index 64cb477..b7230a4 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useRef, Suspense } from "react"; import { useSession } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; import { Settings, Users, @@ -20,6 +21,7 @@ import { FileSpreadsheet, Check, Layers, + CreditCard, } from "lucide-react"; import { trpc } from "@/lib/trpc"; import { Button } from "@/components/ui/button"; @@ -30,7 +32,7 @@ import { regionDefaults } from "@/lib/locale/format"; import { useCurrencyFormatter } from "@/lib/locale/useCurrency"; // ── Types ─────────────────────────────────────────────────── -type Tab = "practice" | "staff" | "appointmentTypes" | "rooms" | "data" | "templates"; +type Tab = "practice" | "staff" | "appointmentTypes" | "rooms" | "data" | "templates" | "billing"; const tabs: { id: Tab; label: string; icon: React.ElementType }[] = [ { id: "practice", label: "Practice Info", icon: Settings }, @@ -39,6 +41,7 @@ const tabs: { id: Tab; label: string; icon: React.ElementType }[] = [ { id: "rooms", label: "Rooms", icon: DoorOpen }, { id: "data", label: "Data", icon: Database }, { id: "templates", label: "Templates", icon: Layers }, + { id: "billing", label: "Plan & Billing", icon: CreditCard }, ]; const TIMEZONES = [ @@ -88,8 +91,26 @@ const ROOM_TYPES = ["exam", "surgery", "treatment", "boarding"] as const; // ── Main Page ─────────────────────────────────────────────── export default function SettingsPage() { + return ( + + + + } + > + + + ); +} + +function SettingsPageInner() { const { data: session } = useSession(); - const [activeTab, setActiveTab] = useState("practice"); + const searchParams = useSearchParams(); + const initialTab = (searchParams.get("tab") as Tab) || "practice"; + const [activeTab, setActiveTab] = useState( + tabs.some((t) => t.id === initialTab) ? initialTab : "practice" + ); if (session?.user?.role !== "admin") { return ( @@ -144,6 +165,7 @@ export default function SettingsPage() { {activeTab === "rooms" && } {activeTab === "data" && } {activeTab === "templates" && } + {activeTab === "billing" && } ); @@ -349,6 +371,230 @@ function PracticeInfoTab() { ); } +// ── Plan & Billing ────────────────────────────────────────── +const FEATURE_LABELS: Record = { + agent: "OpenVPM Agent (AI)", + sms: "SMS sending", + advancedReporting: "Advanced reporting", + apiAccess: "API access + webhooks", + multiLocation: "Multi-location", + integrations: "Integrations", +}; + +function BillingTab() { + const { data, isLoading } = trpc.subscription.get.useQuery(); + const checkout = trpc.subscription.createCheckout.useMutation({ + onSuccess: (r) => { + if (r.url) window.location.href = r.url; + }, + onError: (e) => toast.error(e.message), + }); + const portal = trpc.subscription.openBillingPortal.useMutation({ + onSuccess: (r) => { + if (r.url) window.location.href = r.url; + }, + onError: (e) => toast.error(e.message), + }); + + if (isLoading || !data) { + return ( +
+ +
+ ); + } + + // Self-host: nothing to buy — everything is unlocked. + if (!data.billingEnforced) { + return ( +
+
+
+ +

+ Self-hosted — all features unlocked +

+
+

+ You're running OpenVPM on your own infrastructure. Every feature + is available and there's no subscription — free forever. Plans + below are how the managed OpenVPM Cloud is priced, for reference. +

+
+ {}} busyTier={null} /> +
+ ); + } + + const trialEnds = data.trialEndsAt ? new Date(data.trialEndsAt) : null; + const daysLeft = trialEnds + ? Math.max(0, Math.ceil((trialEnds.getTime() - Date.now()) / (24 * 60 * 60 * 1000))) + : 0; + const currentPlan = data.plans.find((p) => p.tier === data.tier); + + return ( +
+ {/* Current status */} +
+
+
+

Current plan

+

+ {currentPlan?.name ?? data.tier} +

+
+ + {data.billingStatus.replace("_", " ")} + +
+ {data.billingStatus === "trialing" && ( +

+ You're on a free trial with full Pro access —{" "} + {daysLeft} days left. Pick a plan + below to keep your features after it ends. +

+ )} + {data.billingStatus === "past_due" && ( +

+ Your last payment failed. Update your payment method to avoid losing access. +

+ )} + {data.hasBillingAccount && ( + + )} +
+ + checkout.mutate({ tier })} + busyTier={checkout.isPending ? checkout.variables?.tier ?? null : null} + /> +
+ ); +} + +function PlanGrid({ + plans, + currentTier, + enforced, + onChoose, + busyTier, +}: { + plans: Array<{ + tier: string; + name: string; + priceMonthlyUsd: number | null; + blurb: string; + features: string[]; + seatLimit: number | null; + locationLimit: number | null; + selfServe: boolean; + purchasable: boolean; + }>; + currentTier: string; + enforced: boolean; + onChoose: (tier: "starter" | "pro") => void; + busyTier: string | null; +}) { + return ( +
+ {plans.map((p) => { + const isCurrent = p.tier === currentTier; + const canBuy = + enforced && p.purchasable && (p.tier === "starter" || p.tier === "pro") && !isCurrent; + return ( +
+

{p.name}

+

+ {p.priceMonthlyUsd === null ? ( + "Custom" + ) : ( + <> + ${p.priceMonthlyUsd} + /mo + + )} +

+

{p.blurb}

+
    +
  • + {p.seatLimit === null ? "Unlimited" : p.seatLimit} staff seats +
  • +
  • + {p.locationLimit === null ? "Unlimited" : p.locationLimit} location + {p.locationLimit === 1 ? "" : "s"} +
  • + {p.features.length > 0 ? ( + p.features.map((f) => ( +
  • + + {FEATURE_LABELS[f] ?? f} +
  • + )) + ) : ( +
  • Full core PIMS
  • + )} +
+
+ {isCurrent ? ( + Current plan + ) : canBuy ? ( + + ) : !p.selfServe ? ( + + Contact sales + + ) : null} +
+
+ ); + })} +
+ ); +} + // ── Staff ─────────────────────────────────────────────────── function StaffTab() { const utils = trpc.useUtils(); From 9a533c87b14bc178280c47c5f1f2a749cea9284b Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 14:49:25 -0400 Subject: [PATCH 08/19] Add internal platform-admin dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-tenant operations view at /admin: KPI cards (practices, est. MRR, on trial, active, past due) and a practices table with plan/status/trial/usage counts. MRR sums list price of practices on an active paid subscription. Gated by a PLATFORM_ADMIN_EMAILS allowlist (operator-only, separate from a practice's own admin role) — non-operators get Access Denied. admin.overview deliberately crosses tenant boundaries for the operator. Co-Authored-By: Claude Opus 4.8 --- .env.example | 3 + apps/web/app/(dashboard)/admin/page.tsx | 140 ++++++++++++++++++++++++ apps/web/lib/platform-admin.ts | 17 +++ apps/web/server/routers/_app.ts | 2 + apps/web/server/routers/admin.ts | 96 ++++++++++++++++ 5 files changed, 258 insertions(+) create mode 100644 apps/web/app/(dashboard)/admin/page.tsx create mode 100644 apps/web/lib/platform-admin.ts create mode 100644 apps/web/server/routers/admin.ts diff --git a/.env.example b/.env.example index 50b899d..32e7755 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,9 @@ STRIPE_PRICE_PRO= STRIPE_SUBSCRIPTION_WEBHOOK_SECRET= # Public base URL used to build Stripe success/return URLs (falls back to NEXTAUTH_URL). NEXT_PUBLIC_APP_URL="http://localhost:3000" +# Comma-separated emails allowed into the cross-tenant platform admin dashboard +# (/admin). OpenVPM operators only — separate from a practice's own admin role. +PLATFORM_ADMIN_EMAILS= # Cron job authentication CRON_SECRET= diff --git a/apps/web/app/(dashboard)/admin/page.tsx b/apps/web/app/(dashboard)/admin/page.tsx new file mode 100644 index 0000000..f2440cf --- /dev/null +++ b/apps/web/app/(dashboard)/admin/page.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { ShieldAlert, Loader2, Building2, DollarSign, Clock, CheckCircle, AlertTriangle } from "lucide-react"; +import { trpc } from "@/lib/trpc"; + +function formatUsd(n: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(n); +} + +function formatDate(d: Date | string | null) { + if (!d) return "—"; + return new Date(d).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +const statusStyles: Record = { + active: "bg-green-100 text-green-700", + trialing: "bg-blue-100 text-blue-700", + past_due: "bg-red-100 text-red-700", + canceled: "bg-gray-100 text-gray-500", + none: "bg-gray-100 text-gray-500", +}; + +export default function AdminPage() { + const { data, isLoading, error } = trpc.admin.overview.useQuery(undefined, { + retry: false, + }); + + if (error) { + return ( +
+ +

Access Denied

+

+ This area is for OpenVPM platform operators only. +

+
+ ); + } + + if (isLoading || !data) { + return ( +
+ +
+ ); + } + + const kpis = [ + { label: "Practices", value: String(data.totals.practices), icon: Building2 }, + { label: "Est. MRR", value: formatUsd(data.totals.estimatedMrr), icon: DollarSign }, + { label: "On trial", value: String(data.totals.trialing), icon: Clock }, + { label: "Active", value: String(data.totals.active), icon: CheckCircle }, + { label: "Past due", value: String(data.totals.pastDue), icon: AlertTriangle }, + ]; + + return ( +
+
+

Platform Admin

+

+ Cross-tenant operations overview +

+
+ + {/* KPIs */} +
+ {kpis.map((k) => { + const Icon = k.icon; + return ( +
+
+ + {k.label} +
+

{k.value}

+
+ ); + })} +
+ + {/* Practices table */} +
+ + + + + + + + + + + + + + + + {data.practices.map((p) => ( + + + + + + + + + + + + ))} + {data.practices.length === 0 && ( + + + + )} + +
PracticePlanStatusTrial endsStaffClientsPatientsCountryJoined
{p.name}{p.tier} + + {p.billingStatus.replace("_", " ")} + + {formatDate(p.trialEndsAt)}{p.userCount}{p.clientCount}{p.patientCount}{p.country}{formatDate(p.createdAt)}
+ No practices yet. +
+
+
+ ); +} diff --git a/apps/web/lib/platform-admin.ts b/apps/web/lib/platform-admin.ts new file mode 100644 index 0000000..3592e97 --- /dev/null +++ b/apps/web/lib/platform-admin.ts @@ -0,0 +1,17 @@ +/** + * Platform (operator) admin — distinct from a practice's own "admin" role. These + * are OpenVPM staff who can see the cross-tenant operations dashboard. Gated by + * an env allowlist so there's no in-app way to escalate into it. + */ + +export function platformAdminEmails(): string[] { + return (process.env.PLATFORM_ADMIN_EMAILS ?? "") + .split(",") + .map((e) => e.trim().toLowerCase()) + .filter(Boolean); +} + +export function isPlatformAdmin(email?: string | null): boolean { + if (!email) return false; + return platformAdminEmails().includes(email.toLowerCase()); +} diff --git a/apps/web/server/routers/_app.ts b/apps/web/server/routers/_app.ts index fc54075..4ab0355 100644 --- a/apps/web/server/routers/_app.ts +++ b/apps/web/server/routers/_app.ts @@ -27,6 +27,7 @@ import { treatmentPlansRouter } from "./treatment-plans"; import { wellnessRouter } from "./wellness"; import { waitlistRouter } from "./waitlist"; import { subscriptionRouter } from "./subscription"; +import { adminRouter } from "./admin"; export const appRouter = createRouter({ auth: authRouter, @@ -57,6 +58,7 @@ export const appRouter = createRouter({ wellness: wellnessRouter, waitlist: waitlistRouter, subscription: subscriptionRouter, + admin: adminRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/web/server/routers/admin.ts b/apps/web/server/routers/admin.ts new file mode 100644 index 0000000..dc2fb8b --- /dev/null +++ b/apps/web/server/routers/admin.ts @@ -0,0 +1,96 @@ +import { eq, isNull, sql, desc } from "drizzle-orm"; +import { TRPCError } from "@trpc/server"; +import { createRouter, protectedProcedure } from "../trpc"; +import { practices, users, clients, patients } from "@openpims/db"; +import { isPlatformAdmin } from "@/lib/platform-admin"; +import { getPlan, type PlanTier } from "@/lib/billing/plans"; + +/** + * Platform-operator only. Crosses tenant boundaries deliberately, so it is + * gated by the PLATFORM_ADMIN_EMAILS allowlist (not the practice "admin" role). + */ +const platformAdminProcedure = protectedProcedure.use(async ({ ctx, next }) => { + if (!isPlatformAdmin(ctx.session?.user?.email)) { + throw new TRPCError({ code: "FORBIDDEN", message: "Platform admin access only." }); + } + return next(); +}); + +export const adminRouter = createRouter({ + /** Am I a platform admin? (drives whether the /admin nav shows.) */ + isPlatformAdmin: protectedProcedure.query(({ ctx }) => { + return isPlatformAdmin(ctx.session?.user?.email); + }), + + /** Cross-tenant operations overview: practices, plans, status, usage, MRR. */ + overview: platformAdminProcedure.query(async ({ ctx }) => { + const rows = await ctx.db + .select({ + id: practices.id, + name: practices.name, + tier: practices.subscriptionTier, + billingStatus: practices.billingStatus, + trialEndsAt: practices.trialEndsAt, + country: practices.country, + createdAt: practices.createdAt, + }) + .from(practices) + .where(isNull(practices.deletedAt)) + .orderBy(desc(practices.createdAt)); + + const countBy = async ( + table: typeof users | typeof clients | typeof patients + ) => { + const res = await ctx.db + .select({ + practiceId: table.practiceId, + c: sql`count(*)::int`, + }) + .from(table) + .where(isNull(table.deletedAt)) + .groupBy(table.practiceId); + return new Map(res.map((r) => [r.practiceId, Number(r.c)])); + }; + + const [userCounts, clientCounts, patientCounts] = await Promise.all([ + countBy(users), + countBy(clients), + countBy(patients), + ]); + + const practiceRows = rows.map((p) => ({ + ...p, + userCount: userCounts.get(p.id) ?? 0, + clientCount: clientCounts.get(p.id) ?? 0, + patientCount: patientCounts.get(p.id) ?? 0, + })); + + // MRR: sum list price of practices on a paid, active subscription. + const estimatedMrr = practiceRows + .filter((p) => p.billingStatus === "active") + .reduce((sum, p) => sum + (getPlan(p.tier).priceMonthlyUsd ?? 0), 0); + + const byTier: Record = { + free: 0, + starter: 0, + pro: 0, + enterprise: 0, + }; + for (const p of practiceRows) { + const t = getPlan(p.tier).tier; + byTier[t] += 1; + } + + return { + practices: practiceRows, + totals: { + practices: practiceRows.length, + estimatedMrr, + byTier, + trialing: practiceRows.filter((p) => p.billingStatus === "trialing").length, + active: practiceRows.filter((p) => p.billingStatus === "active").length, + pastDue: practiceRows.filter((p) => p.billingStatus === "past_due").length, + }, + }; + }), +}); From 0927013cef5078962aed6555ae20de68e27198b1 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 14:57:16 -0400 Subject: [PATCH 09/19] Run authenticated requests in a tenant DB context (RLS plumbing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds withTenant/withSystem helpers that set a per-transaction Postgres GUC (app.current_practice_id / app.rls_bypass). protectedProcedure now runs each request inside withTenant so RLS can scope every query to the practice; the mutation audit log runs in its own withSystem tx (independent of the request lifecycle). Behavioral no-op until the RLS policies land and the app connects as the least-privilege role — on the owner connection (dev/self-host) the GUC is ignored. 199 tests green. Co-Authored-By: Claude Opus 4.8 --- apps/web/lib/tenant-db.ts | 43 ++++++++++++++++++++++++ apps/web/server/__tests__/guards.test.ts | 10 +++++- apps/web/server/trpc.ts | 42 +++++++++++++---------- 3 files changed, 77 insertions(+), 18 deletions(-) create mode 100644 apps/web/lib/tenant-db.ts diff --git a/apps/web/lib/tenant-db.ts b/apps/web/lib/tenant-db.ts new file mode 100644 index 0000000..bb5c42d --- /dev/null +++ b/apps/web/lib/tenant-db.ts @@ -0,0 +1,43 @@ +import { sql } from "drizzle-orm"; +import type { Database } from "@openpims/db/client"; + +/** + * Tenant database context for Postgres Row-Level Security (defense-in-depth + * behind the app-layer practiceId filters). + * + * RLS policies key off the `app.current_practice_id` GUC. Because postgres-js + * pools connections, the GUC must be set on the SAME connection that runs the + * queries — so we set it inside a transaction (set_config(..., true) = local to + * the tx) and hand the transaction handle to the callback. + * + * On the owner DB role (used in dev/self-host) RLS is bypassed, so these + * wrappers are a harmless no-op there. Enforcement activates when the app + * connects as the least-privilege role (see packages/db/rls/enable-rls.sql). + */ +export async function withTenant( + db: Database, + practiceId: string, + fn: (tx: Database) => Promise +): Promise { + return db.transaction(async (tx) => { + await tx.execute( + sql`select set_config('app.current_practice_id', ${practiceId}, true)` + ); + return fn(tx as unknown as Database); + }); +} + +/** + * System context that bypasses tenant RLS — for cross-tenant operations + * (platform admin, cron sweeps) and pre-tenant flows (registration, login, + * subscription webhooks that look up a practice by Stripe id). + */ +export async function withSystem( + db: Database, + fn: (tx: Database) => Promise +): Promise { + return db.transaction(async (tx) => { + await tx.execute(sql`select set_config('app.rls_bypass', 'on', true)`); + return fn(tx as unknown as Database); + }); +} diff --git a/apps/web/server/__tests__/guards.test.ts b/apps/web/server/__tests__/guards.test.ts index 25b7841..f2389fa 100644 --- a/apps/web/server/__tests__/guards.test.ts +++ b/apps/web/server/__tests__/guards.test.ts @@ -15,7 +15,15 @@ function callerFor(role: string) { practiceId: "00000000-0000-0000-0000-0000000000aa", }, }; - return appRouter.createCaller({ db: {} as never, session } as never); + // Minimal db mock: transaction() runs its callback with the same object and + // execute() is a no-op (for the RLS set_config call). Any real table access + // (.select/.insert/...) is undefined → throws, so a db-free resolver passing + // proves the guard let it through, and FORBIDDEN proves it short-circuited. + const db: Record = { + transaction: async (fn: (tx: unknown) => unknown) => fn(db), + execute: async () => undefined, + }; + return appRouter.createCaller({ db, session } as never); } describe("viewer read-only guard", () => { diff --git a/apps/web/server/trpc.ts b/apps/web/server/trpc.ts index a78a32e..a46821f 100644 --- a/apps/web/server/trpc.ts +++ b/apps/web/server/trpc.ts @@ -8,6 +8,7 @@ import { authOptions } from "@/lib/auth"; import { recordAuditLog } from "@/lib/audit"; import { db } from "@openpims/db/client"; import type { Database } from "@openpims/db/client"; +import { withTenant, withSystem } from "@/lib/tenant-db"; import { practices } from "@openpims/db"; import { billingEnforced, @@ -86,25 +87,32 @@ export const protectedProcedure = t.procedure.use( } const user = ctx.session.user; - const result = await next({ - ctx: { session: ctx.session, user, practiceId: user.practiceId }, - }); - - // Audit every successful mutation: who changed what, when, from where. - // Fire-and-forget — never block or fail the request on the audit write. - if (type === "mutation" && result.ok) { - const rawInput = await getRawInput().catch(() => undefined); - void recordAuditLog(ctx.db, { - practiceId: user.practiceId, - userId: user.id, - ip: ctx.ip, - path, - rawInput, - resultData: (result as { data?: unknown }).data, + // Run the whole request in a tenant DB context so Postgres RLS scopes every + // query to this practice (defense-in-depth behind the app-layer filters). + return withTenant(ctx.db, user.practiceId, async (tx) => { + const result = await next({ + ctx: { session: ctx.session, user, practiceId: user.practiceId, db: tx }, }); - } - return result; + // Audit every successful mutation: who changed what, when, from where. + // Runs in its own system-context tx so it's independent of this request's + // transaction lifecycle and never blocks or fails the request. + if (type === "mutation" && result.ok) { + const rawInput = await getRawInput().catch(() => undefined); + void withSystem(db, (sysTx) => + recordAuditLog(sysTx, { + practiceId: user.practiceId, + userId: user.id, + ip: ctx.ip, + path, + rawInput, + resultData: (result as { data?: unknown }).data, + }) + ).catch(() => {}); + } + + return result; + }); } ); From 2355824c690fea2bb5911515ec26b86ebaf110f0 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 14:59:58 -0400 Subject: [PATCH 10/19] Add Postgres Row-Level Security migration + live isolation test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enable-rls.sql turns on RLS with a tenant_isolation policy on all 36 practice-scoped tables plus practices, keyed on the app.current_practice_id GUC with an app.rls_bypass escape for system/cron/admin. Creates a least-privilege openpims_app role; the owner role bypasses RLS so dev/self-host and migrations are unaffected — enforcement turns on when production connects as openpims_app. pnpm db:rls applies it; pnpm db:rls:test proves isolation against a live DB as the restricted role. Verified: tenant A can't see B's rows, cross-tenant INSERT is rejected by WITH CHECK, no-context queries return nothing, and system bypass sees all. Co-Authored-By: Claude Opus 4.8 --- package.json | 1 + packages/db/apply-rls.ts | 28 +++++++++++ packages/db/package.json | 2 + packages/db/rls/enable-rls.sql | 73 ++++++++++++++++++++++++++++ packages/db/test-rls.ts | 89 ++++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+) create mode 100644 packages/db/apply-rls.ts create mode 100644 packages/db/rls/enable-rls.sql create mode 100644 packages/db/test-rls.ts diff --git a/package.json b/package.json index 20fd147..78a091f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "db:migrate": "pnpm --filter @openpims/db db:migrate", "db:seed": "pnpm --filter @openpims/db db:seed", "db:reset": "pnpm --filter @openpims/db db:reset", + "db:rls": "pnpm --filter @openpims/db db:rls", "db:studio": "pnpm --filter @openpims/db db:studio", "clean": "turbo clean", "test:e2e": "playwright test", diff --git a/packages/db/apply-rls.ts b/packages/db/apply-rls.ts new file mode 100644 index 0000000..d13c060 --- /dev/null +++ b/packages/db/apply-rls.ts @@ -0,0 +1,28 @@ +import { config } from "dotenv"; +config({ path: "../../.env" }); + +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import postgres from "postgres"; + +const url = process.env.DATABASE_URL; +if (!url) { + console.error("DATABASE_URL not set"); + process.exit(1); +} + +const here = dirname(fileURLToPath(import.meta.url)); +const sqlText = readFileSync(join(here, "rls", "enable-rls.sql"), "utf8"); + +const sql = postgres(url, { max: 1 }); +try { + // Simple protocol so the multi-statement / DO-block migration runs as one. + await sql.unsafe(sqlText).simple(); + console.log("✓ RLS policies applied (run as the DB owner)."); +} catch (err) { + console.error("✗ Failed to apply RLS policies:", err); + process.exitCode = 1; +} finally { + await sql.end(); +} diff --git a/packages/db/package.json b/packages/db/package.json index 9e2e3b6..98b0d6d 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -13,6 +13,8 @@ "db:migrate": "drizzle-kit migrate", "db:seed": "tsx seed.ts", "db:reset": "tsx reset.ts", + "db:rls": "tsx apply-rls.ts", + "db:rls:test": "tsx test-rls.ts", "db:studio": "drizzle-kit studio" }, "dependencies": { diff --git a/packages/db/rls/enable-rls.sql b/packages/db/rls/enable-rls.sql new file mode 100644 index 0000000..cd96773 --- /dev/null +++ b/packages/db/rls/enable-rls.sql @@ -0,0 +1,73 @@ +-- OpenVPM — Postgres Row-Level Security (defense-in-depth multi-tenant isolation) +-- ============================================================================ +-- These policies are a SECOND guard behind the app-layer practiceId filters. +-- They key off the `app.current_practice_id` GUC the app sets per request +-- (see apps/web/lib/tenant-db.ts: withTenant / withSystem). +-- +-- The table OWNER bypasses RLS (we do NOT use FORCE), so: +-- • Migrations + dev/self-host on the owner connection are unaffected. +-- • Enforcement activates when the app connects as the least-privilege role +-- `openpims_app` created below. +-- +-- Apply with: pnpm db:rls (idempotent — safe to re-run after schema changes) +-- ============================================================================ + +-- 1) Least-privilege application role. CHANGE THE PASSWORD IN PRODUCTION +-- (e.g. ALTER ROLE openpims_app PASSWORD '...'), then point the hosted +-- DATABASE_URL at this role to turn RLS enforcement on. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'openpims_app') THEN + CREATE ROLE openpims_app LOGIN PASSWORD 'openpims_app'; + END IF; +END$$; + +GRANT USAGE ON SCHEMA public TO openpims_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO openpims_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO openpims_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO openpims_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO openpims_app; + +-- 2) Context helpers (NULL/false when the GUC is unset → deny by default). +CREATE OR REPLACE FUNCTION app_current_practice_id() RETURNS uuid + LANGUAGE sql STABLE AS +$fn$ SELECT nullif(current_setting('app.current_practice_id', true), '')::uuid $fn$; + +CREATE OR REPLACE FUNCTION app_rls_bypass() RETURNS boolean + LANGUAGE sql STABLE AS +$fn$ SELECT coalesce(current_setting('app.rls_bypass', true), '') = 'on' $fn$; + +-- 3) The practices root table is keyed on its own id. +ALTER TABLE practices ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON practices; +CREATE POLICY tenant_isolation ON practices + USING (app_rls_bypass() OR id = app_current_practice_id()) + WITH CHECK (app_rls_bypass() OR id = app_current_practice_id()); + +-- 4) Every practice_id-scoped table gets the same policy. +DO $$ +DECLARE + t text; + tbls text[] := array[ + 'api_keys','appointment_types','appointment_waitlist','appointments','audit_log', + 'cases','clients','clinical_notes','communications','controlled_substance_log', + 'files','insurance_claims','insurance_policies','invoices','lab_results','locations', + 'patients','prescriptions','problem_list','procedures','products','purchase_orders', + 'recurring_series','rooms','services','soap_notes','staff_schedules','suppliers', + 'treatment_plans','treatment_templates','users','vaccination_records','vital_signs', + 'webhooks','wellness_enrollments','wellness_plans' + ]; +BEGIN + FOREACH t IN ARRAY tbls LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON %I', t); + EXECUTE format( + 'CREATE POLICY tenant_isolation ON %I ' + 'USING (app_rls_bypass() OR practice_id = app_current_practice_id()) ' + 'WITH CHECK (app_rls_bypass() OR practice_id = app_current_practice_id())', + t + ); + END LOOP; +END$$; diff --git a/packages/db/test-rls.ts b/packages/db/test-rls.ts new file mode 100644 index 0000000..fea63cc --- /dev/null +++ b/packages/db/test-rls.ts @@ -0,0 +1,89 @@ +/** + * Live Row-Level Security verification. Connects as the least-privilege + * `openpims_app` role and proves tenant isolation against a real database. + * + * Run with: pnpm db:rls:test (requires the DB up + pnpm db:rls applied) + */ +import { config } from "dotenv"; +config({ path: "../../.env" }); + +import postgres from "postgres"; +import { randomUUID } from "crypto"; + +const ownerUrl = process.env.DATABASE_URL; +if (!ownerUrl) { + console.error("DATABASE_URL not set"); + process.exit(1); +} +// Derive the restricted-role URL by swapping the credentials. +const appUrl = ownerUrl.replace(/\/\/[^:]+:[^@]+@/, "//openpims_app:openpims_app@"); + +const owner = postgres(ownerUrl, { max: 1 }); +const app = postgres(appUrl, { max: 1 }); + +const aId = randomUUID(); +const bId = randomUUID(); +let failures = 0; +function check(name: string, ok: boolean) { + console.log(` ${ok ? "✓" : "✗"} ${name}`); + if (!ok) failures++; +} + +try { + // Arrange (as owner — bypasses RLS). + await owner`insert into practices (id, name) values (${aId}, 'RLS Test A'), (${bId}, 'RLS Test B')`; + await owner`insert into clients (practice_id, first_name, last_name) values + (${aId}, 'Alice', 'A'), (${bId}, 'Bob', 'B')`; + + // Tenant A context sees only A's rows. + const aRows = await app.begin(async (tx) => { + await tx`select set_config('app.current_practice_id', ${aId}, true)`; + return tx`select practice_id from clients where practice_id in (${aId}, ${bId})`; + }); + check("tenant A sees only A's clients", aRows.length === 1 && aRows[0]!.practice_id === aId); + + // Tenant B context sees only B's rows. + const bRows = await app.begin(async (tx) => { + await tx`select set_config('app.current_practice_id', ${bId}, true)`; + return tx`select practice_id from clients where practice_id in (${aId}, ${bId})`; + }); + check("tenant B sees only B's clients", bRows.length === 1 && bRows[0]!.practice_id === bId); + + // Cross-tenant WRITE is rejected by the WITH CHECK clause. + let writeBlocked = false; + try { + await app.begin(async (tx) => { + await tx`select set_config('app.current_practice_id', ${aId}, true)`; + await tx`insert into clients (practice_id, first_name, last_name) values (${bId}, 'Evil', 'X')`; + }); + } catch { + writeBlocked = true; + } + check("cross-tenant INSERT is blocked", writeBlocked); + + // No tenant context → deny by default. + const noneRows = await app`select practice_id from clients where practice_id in (${aId}, ${bId})`; + check("no tenant context → zero rows", noneRows.length === 0); + + // System bypass sees both (for cron / platform admin). + const allRows = await app.begin(async (tx) => { + await tx`select set_config('app.rls_bypass', 'on', true)`; + return tx`select practice_id from clients where practice_id in (${aId}, ${bId})`; + }); + check("system bypass sees both practices", allRows.length === 2); +} catch (err) { + console.error("Unexpected error:", err); + failures++; +} finally { + // Cleanup (as owner). + await owner`delete from clients where practice_id in (${aId}, ${bId})`; + await owner`delete from practices where id in (${aId}, ${bId})`; + await owner.end(); + await app.end(); +} + +if (failures > 0) { + console.error(`\n✗ ${failures} RLS check(s) FAILED`); + process.exit(1); +} +console.log("\n✓ All RLS isolation checks passed."); From d26962a4df7d8d124c99820f6fe2c01d0c3effb2 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 15:07:19 -0400 Subject: [PATCH 11/19] Wire system context for auth/public/admin paths + RLS docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes RLS context for the identity + tRPC layer so it runs correctly under the restricted role: - publicProcedure (registration, client portal) → withSystem - NextAuth login lookup → withSystem - API-key auth lookups (key + plan + lastUsedAt) → withSystem - platform admin overview → withSystem (legitimately cross-tenant) Adds docs/security/row-level-security.md documenting the owner-bypass model, how to apply/verify (db:rls, db:rls:test), production activation steps, and the remaining non-tRPC entrypoints (api/v1 data, cron, webhooks, upload, checkout) to wrap before switching DATABASE_URL to the enforcing role. All no-ops on the owner connection (dev/self-host). 199 tests green. Co-Authored-By: Claude Opus 4.8 --- apps/web/lib/api-auth.ts | 43 +++++++++------- apps/web/lib/auth.ts | 14 +++-- apps/web/server/routers/admin.ts | 15 ++++-- apps/web/server/trpc.ts | 12 ++++- docs/security/row-level-security.md | 80 +++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 29 deletions(-) create mode 100644 docs/security/row-level-security.md diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts index ea61672..2fbee45 100644 --- a/apps/web/lib/api-auth.ts +++ b/apps/web/lib/api-auth.ts @@ -6,6 +6,7 @@ import { db } from "@openpims/db/client"; import { apiKeys, practices } from "@openpims/db"; import { rateLimit } from "@/lib/rate-limit"; import { billingEnforced, isEntitled, effectiveTier } from "@/lib/billing/plans"; +import { withSystem } from "@/lib/tenant-db"; /** Public prefix for every issued key. Also used as the human-visible label. */ export const API_KEY_PREFIX = "ovpm_"; @@ -82,10 +83,13 @@ export async function authenticateApiKey( let candidates; try { - candidates = await db - .select() - .from(apiKeys) - .where(and(eq(apiKeys.keyPrefix, prefix), isNull(apiKeys.deletedAt))); + // Key lookup spans tenants (we don't know the practice yet) → system context. + candidates = await withSystem(db, (tx) => + tx + .select() + .from(apiKeys) + .where(and(eq(apiKeys.keyPrefix, prefix), isNull(apiKeys.deletedAt))) + ); } catch (e) { console.error("[api-auth] key lookup failed:", e); return err("Internal error", 500); @@ -110,15 +114,17 @@ export async function authenticateApiKey( // Public API access is a Pro feature on the hosted service (no-op on self-host). if (billingEnforced()) { - const [practice] = await db - .select({ - tier: practices.subscriptionTier, - billingStatus: practices.billingStatus, - trialEndsAt: practices.trialEndsAt, - }) - .from(practices) - .where(eq(practices.id, matched.practiceId)) - .limit(1); + const [practice] = await withSystem(db, (tx) => + tx + .select({ + tier: practices.subscriptionTier, + billingStatus: practices.billingStatus, + trialEndsAt: practices.trialEndsAt, + }) + .from(practices) + .where(eq(practices.id, matched.practiceId)) + .limit(1) + ); const tier = effectiveTier( practice?.tier, practice?.billingStatus, @@ -152,11 +158,12 @@ export async function authenticateApiKey( } // Audit trail — non-blocking, never fail the request on this. - void db - .update(apiKeys) - .set({ lastUsedAt: new Date() }) - .where(eq(apiKeys.id, matched.id)) - .catch((e) => console.error("[api-auth] lastUsedAt update failed:", e)); + void withSystem(db, (tx) => + tx + .update(apiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(apiKeys.id, matched.id)) + ).catch((e) => console.error("[api-auth] lastUsedAt update failed:", e)); return { ok: true, diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts index 06bd32d..2deb595 100644 --- a/apps/web/lib/auth.ts +++ b/apps/web/lib/auth.ts @@ -4,6 +4,7 @@ import { compare } from "bcryptjs"; import { db } from "@openpims/db/client"; import { users } from "@openpims/db"; import { eq } from "drizzle-orm"; +import { withSystem } from "@/lib/tenant-db"; declare module "next-auth" { interface Session { @@ -45,11 +46,14 @@ export const authOptions: NextAuthOptions = { return null; } - const [user] = await db - .select() - .from(users) - .where(eq(users.email, credentials.email)) - .limit(1); + // Login looks up by email with no tenant context yet → system context. + const [user] = await withSystem(db, (tx) => + tx + .select() + .from(users) + .where(eq(users.email, credentials.email)) + .limit(1) + ); if (!user) return null; diff --git a/apps/web/server/routers/admin.ts b/apps/web/server/routers/admin.ts index dc2fb8b..dc0949b 100644 --- a/apps/web/server/routers/admin.ts +++ b/apps/web/server/routers/admin.ts @@ -1,9 +1,11 @@ -import { eq, isNull, sql, desc } from "drizzle-orm"; +import { isNull, sql, desc } from "drizzle-orm"; import { TRPCError } from "@trpc/server"; import { createRouter, protectedProcedure } from "../trpc"; +import { db } from "@openpims/db/client"; import { practices, users, clients, patients } from "@openpims/db"; import { isPlatformAdmin } from "@/lib/platform-admin"; import { getPlan, type PlanTier } from "@/lib/billing/plans"; +import { withSystem } from "@/lib/tenant-db"; /** * Platform-operator only. Crosses tenant boundaries deliberately, so it is @@ -23,8 +25,10 @@ export const adminRouter = createRouter({ }), /** Cross-tenant operations overview: practices, plans, status, usage, MRR. */ - overview: platformAdminProcedure.query(async ({ ctx }) => { - const rows = await ctx.db + overview: platformAdminProcedure.query(async () => + // Bypass tenant RLS — this view legitimately spans all practices. + withSystem(db, async (tx) => { + const rows = await tx .select({ id: practices.id, name: practices.name, @@ -41,7 +45,7 @@ export const adminRouter = createRouter({ const countBy = async ( table: typeof users | typeof clients | typeof patients ) => { - const res = await ctx.db + const res = await tx .select({ practiceId: table.practiceId, c: sql`count(*)::int`, @@ -92,5 +96,6 @@ export const adminRouter = createRouter({ pastDue: practiceRows.filter((p) => p.billingStatus === "past_due").length, }, }; - }), + }) + ), }); diff --git a/apps/web/server/trpc.ts b/apps/web/server/trpc.ts index a46821f..56d3041 100644 --- a/apps/web/server/trpc.ts +++ b/apps/web/server/trpc.ts @@ -69,7 +69,17 @@ const t = initTRPC.context().create({ }); export const createRouter = t.router; -export const publicProcedure = t.procedure; + +/** + * Public / pre-auth endpoints (registration, the token-based client portal). + * They have no tenant session and do their own scoping (tokens, email, rate + * limits), so they run in a system DB context that bypasses tenant RLS. + */ +export const publicProcedure = t.procedure.use(async ({ ctx, next }) => { + return withSystem(ctx.db, (tx) => + next({ ctx: { ...ctx, db: tx } }) + ); +}); /** Requires an authenticated session */ export const protectedProcedure = t.procedure.use( diff --git a/docs/security/row-level-security.md b/docs/security/row-level-security.md new file mode 100644 index 0000000..904baeb --- /dev/null +++ b/docs/security/row-level-security.md @@ -0,0 +1,80 @@ +# Row-Level Security (RLS) + +OpenVPM enforces multi-tenant isolation in two layers: + +1. **Application layer** — every query is scoped by `ctx.practiceId` (tRPC) or + the API key's practice. This is the primary guard and works on any database. +2. **Database layer (RLS)** — Postgres policies that independently reject any row + whose `practice_id` doesn't match the active tenant context. Defense in depth: + even a query that forgets its `practiceId` filter returns nothing. + +## How it works + +Policies key off a per-transaction GUC, `app.current_practice_id`, set by the +app (`apps/web/lib/tenant-db.ts`): + +- `withTenant(db, practiceId, fn)` — opens a transaction, sets the tenant GUC, + runs `fn` on that transaction. Used by `protectedProcedure` so the whole + authenticated request is tenant-scoped. +- `withSystem(db, fn)` — sets `app.rls_bypass = on` for legitimately + cross-tenant or pre-tenant work (login, registration, the client portal, + platform admin, the API-key lookup). + +The policy on every tenant table is: + +```sql +USING (app_rls_bypass() OR practice_id = app_current_practice_id()) +WITH CHECK (app_rls_bypass() OR practice_id = app_current_practice_id()) +``` + +With no context set, the GUC is NULL → the policy denies by default. + +## The owner-bypass model (why dev/self-host is unaffected) + +We deliberately do **not** use `FORCE ROW LEVEL SECURITY`. The table **owner** +bypasses RLS, so: + +- Migrations, `pnpm db:push`, `pnpm db:seed`, and **dev / self-host** (which + connect as the owner `openpims`) are completely unaffected — RLS is a no-op. +- Enforcement turns on only when the app connects as the **least-privilege role** + `openpims_app` (created by the migration), which is subject to RLS. + +This means RLS is safe to ship: it adds protection for the restricted role +without any risk to the default configuration. + +## Applying it + +```bash +pnpm db:rls # apply policies + create the openpims_app role (run as owner) +pnpm db:rls:test # live verification: proves cross-tenant isolation +``` + +`db:rls:test` checks, against a real database as `openpims_app`: a tenant sees +only its own rows, cross-tenant INSERT is rejected, no-context queries return +nothing, and the system bypass sees everything. + +## Activating enforcement in production + +1. `pnpm db:rls` against the production database. +2. `ALTER ROLE openpims_app PASSWORD '';` (the migration sets a + dev placeholder). +3. Point the hosted `DATABASE_URL` at `openpims_app`. + +### Before switching the role: finish wiring these entrypoints + +Everything that runs through tRPC is already tenant/system-scoped +(`protectedProcedure` → `withTenant`; `publicProcedure` and login → `withSystem`; +platform admin → `withSystem`; API-key auth lookup → `withSystem`). Still using +the global connection and therefore needing a `withTenant`/`withSystem` wrapper +before running under `openpims_app`: + +- `app/api/v1/*` data queries → `withTenant(auth.ctx.practiceId, ...)` +- `app/api/cron/*` (reminders, backup) → `withSystem` +- `app/api/webhooks/*` (and `lib/webhook-dispatcher.ts`) → `withSystem` +- `app/api/upload/route.ts` → `withTenant(session.practiceId, ...)` +- `app/api/portal/checkout/route.ts` → `withSystem` +- shared helpers they call (`lib/backup/export.ts`, email senders) thread the + same transaction handle. + +Until these are wired, keep the app on the owner connection (the default), where +RLS is a verified, ready safety net that does not yet gate these paths. From 1214e945fd886e2cd5e95ff6b0bdeef42956edf0 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 15:55:34 -0400 Subject: [PATCH 12/19] Re-tune plan model to one Cloud tier ($99/location) + feature parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplifies hosted pricing to a single self-serve Cloud tier at $99/mo per location with ALL features included (parity with self-host), generous included SMS/AI allowances, and metered overage; Enterprise stays custom. Collapses the old starter/pro tiers (aliased to cloud for back-compat with existing rows + prices). free now carries no entitlements so a lapsed/unpaid hosted practice is correctly gated; self-host stays fully unlocked via billingEnforced()=false. Adds pricePerLocation + includedSms/AiPerMonth + overage price env vars (STRIPE_PRICE_CLOUD/SMS_OVERAGE/AI_OVERAGE). createCheckout → cloud; admin MRR byTier updated. 11 tests green. Co-Authored-By: Claude Opus 4.8 --- .env.example | 7 +- apps/web/app/(dashboard)/settings/page.tsx | 9 +- apps/web/lib/billing/__tests__/plans.test.ts | 45 ++++---- apps/web/lib/billing/plans.ts | 104 +++++++++++-------- apps/web/server/routers/admin.ts | 3 +- apps/web/server/routers/subscription.ts | 4 +- 6 files changed, 97 insertions(+), 75 deletions(-) diff --git a/.env.example b/.env.example index 32e7755..ed492ce 100644 --- a/.env.example +++ b/.env.example @@ -28,8 +28,11 @@ STRIPE_WEBHOOK_SECRET= # unset for self-host so the OSS edition runs fully unlocked). When enabled, # plan tiers are enforced and these must be set. HOSTED_BILLING_ENABLED= -STRIPE_PRICE_STARTER= -STRIPE_PRICE_PRO= +# Cloud plan: $99/mo recurring Price, charged per location (Stripe quantity). +STRIPE_PRICE_CLOUD= +# Metered overage Prices for usage beyond the included monthly allowances. +STRIPE_PRICE_SMS_OVERAGE= +STRIPE_PRICE_AI_OVERAGE= # Separate webhook endpoint secret for customer.subscription.* events. STRIPE_SUBSCRIPTION_WEBHOOK_SECRET= # Public base URL used to build Stripe success/return URLs (falls back to NEXTAUTH_URL). diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index b7230a4..81583c3 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -516,15 +516,14 @@ function PlanGrid({ }>; currentTier: string; enforced: boolean; - onChoose: (tier: "starter" | "pro") => void; + onChoose: (tier: "cloud") => void; busyTier: string | null; }) { return ( -
+
{plans.map((p) => { const isCurrent = p.tier === currentTier; - const canBuy = - enforced && p.purchasable && (p.tier === "starter" || p.tier === "pro") && !isCurrent; + const canBuy = enforced && p.purchasable && p.tier === "cloud" && !isCurrent; return (
onChoose(p.tier as "starter" | "pro")} + onClick={() => onChoose(p.tier as "cloud")} > {busyTier === p.tier ? ( diff --git a/apps/web/lib/billing/__tests__/plans.test.ts b/apps/web/lib/billing/__tests__/plans.test.ts index bfd19f5..85ac2b3 100644 --- a/apps/web/lib/billing/__tests__/plans.test.ts +++ b/apps/web/lib/billing/__tests__/plans.test.ts @@ -13,18 +13,22 @@ import { describe("getPlan", () => { it("returns the matching plan and falls back to free", () => { - expect(getPlan("pro").tier).toBe("pro"); + expect(getPlan("cloud").tier).toBe("cloud"); expect(getPlan(null).tier).toBe("free"); expect(getPlan("nonsense").tier).toBe("free"); }); + it("maps legacy starter/pro tiers onto cloud", () => { + expect(getPlan("starter").tier).toBe("cloud"); + expect(getPlan("pro").tier).toBe("cloud"); + }); }); -describe("planHasFeature", () => { - it("pro includes all premium features; starter and free include none", () => { +describe("planHasFeature (parity)", () => { + it("cloud and enterprise include every feature; free (lapsed/unpaid) includes none", () => { for (const f of ALL_FEATURES) { - expect(planHasFeature("pro", f)).toBe(true); + expect(planHasFeature("cloud", f)).toBe(true); expect(planHasFeature("enterprise", f)).toBe(true); - expect(planHasFeature("starter", f)).toBe(false); + expect(planHasFeature("pro", f)).toBe(true); // legacy → cloud expect(planHasFeature("free", f)).toBe(false); } }); @@ -35,10 +39,9 @@ describe("isEntitled", () => { expect(isEntitled("free", "agent", false)).toBe(true); expect(isEntitled(null, "sms", false)).toBe(true); }); - it("hosted (enforced) gates by tier", () => { + it("hosted (enforced) gates free/lapsed but allows cloud + enterprise", () => { expect(isEntitled("free", "agent", true)).toBe(false); - expect(isEntitled("starter", "agent", true)).toBe(false); - expect(isEntitled("pro", "agent", true)).toBe(true); + expect(isEntitled("cloud", "agent", true)).toBe(true); expect(isEntitled("enterprise", "apiAccess", true)).toBe(true); }); }); @@ -46,16 +49,11 @@ describe("isEntitled", () => { describe("seat + location limits", () => { it("not enforced always passes", () => { expect(withinSeatLimit("free", 999, false)).toBe(true); - expect(withinLocationLimit("starter", 999, false)).toBe(true); - }); - it("enforced respects the tier limit (current < limit to add another)", () => { - expect(withinSeatLimit("starter", 4, true)).toBe(true); // 4 < 5 - expect(withinSeatLimit("starter", 5, true)).toBe(false); // at limit - expect(withinLocationLimit("starter", 1, true)).toBe(false); // limit 1 - expect(withinLocationLimit("pro", 50, true)).toBe(true); // pro locations unlimited + expect(withinLocationLimit("cloud", 999, false)).toBe(true); }); - it("enterprise/unlimited seats always pass", () => { - expect(withinSeatLimit("enterprise", 100000, true)).toBe(true); + it("cloud has unlimited seats + locations (billed by quantity)", () => { + expect(withinSeatLimit("cloud", 100000, true)).toBe(true); + expect(withinLocationLimit("cloud", 50, true)).toBe(true); }); }); @@ -71,10 +69,11 @@ describe("trials", () => { expect(isTrialActive("trialing", null, now)).toBe(false); }); - it("effectiveTier grants pro during an active trial, then reverts", () => { - expect(effectiveTier("free", "trialing", future, now)).toBe("pro"); + it("effectiveTier grants cloud during an active trial, then reverts to stored tier", () => { + expect(effectiveTier("free", "trialing", future, now)).toBe("cloud"); expect(effectiveTier("free", "trialing", past, now)).toBe("free"); - expect(effectiveTier("starter", "active", future, now)).toBe("starter"); + expect(effectiveTier("cloud", "active", future, now)).toBe("cloud"); + expect(effectiveTier("pro", "active", future, now)).toBe("cloud"); // legacy → cloud }); it("an active trial unlocks gated features even on the free tier", () => { @@ -84,10 +83,10 @@ describe("trials", () => { }); describe("PLANS pricing", () => { - it("matches the agreed value tiers", () => { + it("is one simple Cloud tier at $99/location, free self-host, enterprise custom", () => { expect(PLANS.free.priceMonthlyUsd).toBe(0); - expect(PLANS.starter.priceMonthlyUsd).toBe(29); - expect(PLANS.pro.priceMonthlyUsd).toBe(99); + expect(PLANS.cloud.priceMonthlyUsd).toBe(99); + expect(PLANS.cloud.pricePerLocation).toBe(true); expect(PLANS.enterprise.priceMonthlyUsd).toBeNull(); }); }); diff --git a/apps/web/lib/billing/plans.ts b/apps/web/lib/billing/plans.ts index 5cf23bf..df72cb2 100644 --- a/apps/web/lib/billing/plans.ts +++ b/apps/web/lib/billing/plans.ts @@ -1,16 +1,25 @@ /** - * Hosted plan tiers + feature entitlements. + * Hosted plan model + entitlements. * - * IMPORTANT — open-source posture: the OSS / self-host edition is NEVER - * crippled. Feature gating only applies to our managed hosted service, and is - * OFF by default (`HOSTED_BILLING_ENABLED` unset). When billing is not - * enforced, every feature is entitled — self-host gets the full product. We - * monetize hosting, support, and usage, not by locking the open core. + * Open-source posture: the OSS / self-host edition is NEVER crippled. Billing is + * OFF by default (`HOSTED_BILLING_ENABLED` unset) — self-host gets the full + * product. We monetize hosting + scale (locations) + heavy usage, not by locking + * the open core. + * + * Pricing (managed Cloud): ONE simple self-serve tier — $99/mo per location, + * ALL features included (feature parity with self-host), with metered overage + * for SMS / AI usage beyond generous monthly allowances. Enterprise = custom. */ -export type PlanTier = "free" | "starter" | "pro" | "enterprise"; +export type PlanTier = "free" | "cloud" | "enterprise"; + +/** + * Legacy tier strings (from the earlier starter/pro model) map onto `cloud`, so + * existing practice rows and any old Stripe prices keep resolving cleanly. + */ +const LEGACY_CLOUD_TIERS = new Set(["starter", "pro"]); -/** Premium capabilities that the hosted tiers gate. */ +/** Capabilities. With feature parity these are unlocked on every paid tier. */ export type Feature = | "agent" // OpenVPM Agent (AI) | "sms" // SMS sending @@ -28,18 +37,28 @@ export const ALL_FEATURES: Feature[] = [ "integrations", ]; +/** Env vars holding Stripe Price IDs for metered overage (hosted only, PRIVATE). */ +export const STRIPE_PRICE_SMS_OVERAGE_ENV = "STRIPE_PRICE_SMS_OVERAGE"; +export const STRIPE_PRICE_AI_OVERAGE_ENV = "STRIPE_PRICE_AI_OVERAGE"; + export interface PlanDefinition { tier: PlanTier; name: string; - /** Monthly price in USD. null = custom / contact sales. */ + /** Monthly price in USD (per location for `cloud`). null = custom / contact sales. */ priceMonthlyUsd: number | null; + /** Whether the monthly price is charged per location (Stripe subscription quantity). */ + pricePerLocation: boolean; blurb: string; /** Max staff seats. null = unlimited. */ seatLimit: number | null; - /** Max locations. null = unlimited. */ + /** Max locations. null = unlimited (billed by quantity). */ locationLimit: number | null; /** Premium features included in this tier. */ features: Feature[]; + /** Included monthly SMS before metered overage. null = unlimited/custom. */ + includedSmsPerMonth: number | null; + /** Included monthly AI agent runs before metered overage. null = unlimited/custom. */ + includedAiRunsPerMonth: number | null; /** Env var holding the Stripe Price ID for this tier (hosted only). */ stripePriceEnv?: string; /** Whether this tier is self-serve purchasable (vs. contact sales). */ @@ -48,57 +67,58 @@ export interface PlanDefinition { export const PLANS: Record = { free: { + // Self-host (free, full product) gets everything via billingEnforced()=false, + // NOT via this feature list. On hosted, `free` is also the lapsed/unpaid + // fallback tier — so its entitlements MUST be empty to gate non-payers. tier: "free", - name: "Free", + name: "Free (self-host)", priceMonthlyUsd: 0, - blurb: "Core PIMS for trying it out. Self-host is free forever.", - seatLimit: 2, - locationLimit: 1, - features: [], - selfServe: true, - }, - starter: { - tier: "starter", - name: "Starter", - priceMonthlyUsd: 29, - blurb: - "Managed hosting, the full PIMS, email reminders, client portal, and data export.", - seatLimit: 5, - locationLimit: 1, + pricePerLocation: false, + blurb: "The full product, on your own infrastructure. Free forever, no lock-in.", + seatLimit: null, + locationLimit: null, features: [], - stripePriceEnv: "STRIPE_PRICE_STARTER", + includedSmsPerMonth: 0, + includedAiRunsPerMonth: 0, selfServe: true, }, - pro: { - tier: "pro", - name: "Pro", + cloud: { + tier: "cloud", + name: "Cloud", priceMonthlyUsd: 99, + pricePerLocation: true, blurb: - "Adds the OpenVPM Agent, SMS, advanced reporting, API access + webhooks, multi-location, and integrations.", - seatLimit: 25, - locationLimit: null, + "We host it: the full PIMS, managed, with every feature — agent, SMS, reporting, API, multi-location, integrations. $99/mo per location.", + seatLimit: null, // unlimited staff included + locationLimit: null, // billed by quantity, not capped features: [...ALL_FEATURES], - stripePriceEnv: "STRIPE_PRICE_PRO", + includedSmsPerMonth: 500, + includedAiRunsPerMonth: 200, + stripePriceEnv: "STRIPE_PRICE_CLOUD", selfServe: true, }, enterprise: { tier: "enterprise", name: "Enterprise", priceMonthlyUsd: null, + pricePerLocation: false, blurb: - "Single-tenant or in-region dedicated instance, SSO, DPA/compliance, and priority support.", + "Dedicated or in-region instance, SSO, BAA/DPA compliance, white-glove migration, and priority support.", seatLimit: null, locationLimit: null, features: [...ALL_FEATURES], + includedSmsPerMonth: null, + includedAiRunsPerMonth: null, selfServe: false, }, }; -export const PLAN_ORDER: PlanTier[] = ["free", "starter", "pro", "enterprise"]; +export const PLAN_ORDER: PlanTier[] = ["free", "cloud", "enterprise"]; export function getPlan(tier?: string | null): PlanDefinition { - const t = (tier ?? "free") as PlanTier; - return PLANS[t] ?? PLANS.free; + const t = tier ?? "free"; + if (LEGACY_CLOUD_TIERS.has(t)) return PLANS.cloud; + return PLANS[t as PlanTier] ?? PLANS.free; } /** Map a Stripe Price ID back to a plan tier (via the configured env vars). */ @@ -129,7 +149,7 @@ export function normalizeBillingStatus(status: string | null | undefined): strin } } -/** Pure: does this tier include this premium feature? */ +/** Pure: does this tier include this feature? (With parity, every paid tier does.) */ export function planHasFeature(tier: string | null | undefined, feature: Feature): boolean { return getPlan(tier).features.includes(feature); } @@ -149,7 +169,7 @@ export function isTrialActive( /** * The tier whose entitlements actually apply right now. An active trial grants - * full (Pro) access; once it lapses we fall back to the stored tier. + * full Cloud access; once it lapses we fall back to the stored tier. */ export function effectiveTier( tier: string | null | undefined, @@ -157,8 +177,10 @@ export function effectiveTier( trialEndsAt: Date | string | null | undefined, now: Date = new Date() ): PlanTier { - if (isTrialActive(billingStatus, trialEndsAt, now)) return "pro"; - return (tier ?? "free") as PlanTier; + if (isTrialActive(billingStatus, trialEndsAt, now)) return "cloud"; + const t = tier ?? "free"; + if (LEGACY_CLOUD_TIERS.has(t)) return "cloud"; + return (PLANS[t as PlanTier] ? (t as PlanTier) : "free"); } /** diff --git a/apps/web/server/routers/admin.ts b/apps/web/server/routers/admin.ts index dc0949b..8525f43 100644 --- a/apps/web/server/routers/admin.ts +++ b/apps/web/server/routers/admin.ts @@ -76,8 +76,7 @@ export const adminRouter = createRouter({ const byTier: Record = { free: 0, - starter: 0, - pro: 0, + cloud: 0, enterprise: 0, }; for (const p of practiceRows) { diff --git a/apps/web/server/routers/subscription.ts b/apps/web/server/routers/subscription.ts index 940334f..3f4cc40 100644 --- a/apps/web/server/routers/subscription.ts +++ b/apps/web/server/routers/subscription.ts @@ -66,9 +66,9 @@ export const subscriptionRouter = createRouter({ }; }), - /** Start a Stripe Checkout for a self-serve plan (Starter/Pro). */ + /** Start a Stripe Checkout for the self-serve Cloud plan. */ createCheckout: adminProcedure - .input(z.object({ tier: z.enum(["starter", "pro"]) })) + .input(z.object({ tier: z.enum(["cloud"]).default("cloud") })) .mutation(async ({ ctx, input }) => { const plan = PLANS[input.tier]; const priceId = plan.stripePriceEnv From 0967372047e7aeee9b9df81093ae386382972a56 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 16:00:18 -0400 Subject: [PATCH 13/19] Add usage metering for SMS + AI agent runs New usage_records table (practice-scoped, RLS-covered) + recordUsage()/ usageForPractice() helpers. Meters AI agent runs at runAgent and SMS at the sendSms chokepoint (sendSms gains an optional practiceId). No-op on self-host (billingEnforced()=false); writes go through withSystem so they work in any context. Foundation for charging overage beyond a plan's included allowance. +1 test; additive schema (db:push); RLS isolation still verified. Co-Authored-By: Claude Opus 4.8 --- apps/web/lib/agent/runner.ts | 4 ++ apps/web/lib/billing/__tests__/usage.test.ts | 10 ++++ apps/web/lib/billing/usage.ts | 58 ++++++++++++++++++++ apps/web/lib/sms.ts | 8 +++ packages/db/rls/enable-rls.sql | 4 +- packages/db/schema/index.ts | 1 + packages/db/schema/usage.ts | 30 ++++++++++ 7 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 apps/web/lib/billing/__tests__/usage.test.ts create mode 100644 apps/web/lib/billing/usage.ts create mode 100644 packages/db/schema/usage.ts diff --git a/apps/web/lib/agent/runner.ts b/apps/web/lib/agent/runner.ts index a2fc8c7..00e7745 100644 --- a/apps/web/lib/agent/runner.ts +++ b/apps/web/lib/agent/runner.ts @@ -5,6 +5,7 @@ import { getTool, type AgentToolContext, } from "./tools"; +import { recordUsage } from "@/lib/billing/usage"; const DEFAULT_MODEL = process.env.AGENT_MODEL || "claude-sonnet-4-6"; const MAX_ITERATIONS = 8; @@ -63,6 +64,9 @@ export async function runAgent(opts: { const client = new Anthropic({ apiKey }); const allowWrites = opts.allowWrites ?? false; + // Meter the agent run for hosted billing (no-op on self-host). + void recordUsage({ practiceId: opts.context.practiceId, kind: "ai_run" }); + // Prompt caching: cache the (static) system prompt and tool defs across the // loop's turns so only the growing message tail is re-billed at full rate. const system: Anthropic.TextBlockParam[] = [ diff --git a/apps/web/lib/billing/__tests__/usage.test.ts b/apps/web/lib/billing/__tests__/usage.test.ts new file mode 100644 index 0000000..a8f305e --- /dev/null +++ b/apps/web/lib/billing/__tests__/usage.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from "vitest"; +import { currentPeriodMonth } from "../usage"; + +describe("currentPeriodMonth", () => { + it("formats the billing period as YYYY-MM (UTC)", () => { + expect(currentPeriodMonth(new Date("2026-06-07T12:00:00Z"))).toBe("2026-06"); + expect(currentPeriodMonth(new Date("2026-01-31T23:59:59Z"))).toBe("2026-01"); + expect(currentPeriodMonth(new Date("2026-12-01T00:00:00Z"))).toBe("2026-12"); + }); +}); diff --git a/apps/web/lib/billing/usage.ts b/apps/web/lib/billing/usage.ts new file mode 100644 index 0000000..11fc1b9 --- /dev/null +++ b/apps/web/lib/billing/usage.ts @@ -0,0 +1,58 @@ +import { and, eq, sql } from "drizzle-orm"; +import { db } from "@openpims/db/client"; +import { usageRecords } from "@openpims/db"; +import { withSystem } from "@/lib/tenant-db"; +import { billingEnforced } from "./plans"; + +export type UsageKind = "sms" | "ai_run"; + +/** Current billing period as YYYY-MM (UTC). */ +export function currentPeriodMonth(now: Date = new Date()): string { + return now.toISOString().slice(0, 7); +} + +/** + * Record a metered usage event. No-op on self-host (billing not enforced). + * Fire-and-forget safe: never throws into the caller. + */ +export async function recordUsage(opts: { + practiceId: string; + kind: UsageKind; + quantity?: number; + now?: Date; +}): Promise { + if (!billingEnforced()) return; // self-host never meters + try { + await withSystem(db, (tx) => + tx.insert(usageRecords).values({ + practiceId: opts.practiceId, + kind: opts.kind, + quantity: opts.quantity ?? 1, + periodMonth: currentPeriodMonth(opts.now), + }) + ); + } catch (e) { + console.error("[usage] failed to record", opts.kind, e); + } +} + +/** Sum a practice's usage of one kind in a period (defaults to current month). */ +export async function usageForPractice( + practiceId: string, + kind: UsageKind, + periodMonth: string = currentPeriodMonth() +): Promise { + const [row] = await withSystem(db, (tx) => + tx + .select({ total: sql`coalesce(sum(${usageRecords.quantity}), 0)::int` }) + .from(usageRecords) + .where( + and( + eq(usageRecords.practiceId, practiceId), + eq(usageRecords.kind, kind), + eq(usageRecords.periodMonth, periodMonth) + ) + ) + ); + return Number(row?.total ?? 0); +} diff --git a/apps/web/lib/sms.ts b/apps/web/lib/sms.ts index 9bd7d91..ffc0a82 100644 --- a/apps/web/lib/sms.ts +++ b/apps/web/lib/sms.ts @@ -1,4 +1,5 @@ import Twilio from "twilio"; +import { recordUsage } from "@/lib/billing/usage"; // --------------------------------------------------------------------------- // Twilio client – initialised lazily so the module can be imported even when @@ -27,6 +28,8 @@ function getFromNumber(): string { export async function sendSms(options: { to: string; body: string; + /** When set (and a real send occurs), meters the SMS for hosted billing. */ + practiceId?: string; }): Promise<{ success: boolean; sid?: string; error?: string }> { const client = getTwilio(); @@ -47,6 +50,11 @@ export async function sendSms(options: { body: options.body, }); + // Meter the real send for hosted billing (no-op on self-host). + if (options.practiceId) { + void recordUsage({ practiceId: options.practiceId, kind: "sms" }); + } + return { success: true, sid: message.sid }; } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown SMS error"; diff --git a/packages/db/rls/enable-rls.sql b/packages/db/rls/enable-rls.sql index cd96773..9a59fb1 100644 --- a/packages/db/rls/enable-rls.sql +++ b/packages/db/rls/enable-rls.sql @@ -56,8 +56,8 @@ DECLARE 'files','insurance_claims','insurance_policies','invoices','lab_results','locations', 'patients','prescriptions','problem_list','procedures','products','purchase_orders', 'recurring_series','rooms','services','soap_notes','staff_schedules','suppliers', - 'treatment_plans','treatment_templates','users','vaccination_records','vital_signs', - 'webhooks','wellness_enrollments','wellness_plans' + 'treatment_plans','treatment_templates','usage_records','users','vaccination_records', + 'vital_signs','webhooks','wellness_enrollments','wellness_plans' ]; BEGIN FOREACH t IN ARRAY tbls LOOP diff --git a/packages/db/schema/index.ts b/packages/db/schema/index.ts index 4279504..caef249 100644 --- a/packages/db/schema/index.ts +++ b/packages/db/schema/index.ts @@ -13,3 +13,4 @@ export * from "./files"; export * from "./templates"; export * from "./insurance"; export * from "./wellness"; +export * from "./usage"; diff --git a/packages/db/schema/usage.ts b/packages/db/schema/usage.ts new file mode 100644 index 0000000..b245335 --- /dev/null +++ b/packages/db/schema/usage.ts @@ -0,0 +1,30 @@ +import { pgTable, uuid, varchar, integer, index } from "drizzle-orm/pg-core"; +import { baseColumns } from "./common"; +import { practices } from "./practices"; + +/** + * Metered usage events for the hosted service (SMS sends, AI agent runs). Rolled + * up per billing period to charge overage beyond a plan's included allowance. + * Practice-scoped → covered by RLS (see packages/db/rls/enable-rls.sql). + * Only written when HOSTED_BILLING_ENABLED — self-host never meters. + */ +export const usageRecords = pgTable( + "usage_records", + { + ...baseColumns(), + practiceId: uuid("practice_id") + .notNull() + .references(() => practices.id), + /** "sms" | "ai_run" */ + kind: varchar("kind", { length: 16 }).notNull(), + quantity: integer("quantity").notNull().default(1), + /** Billing period as YYYY-MM (UTC). */ + periodMonth: varchar("period_month", { length: 7 }).notNull(), + }, + (t) => ({ + practicePeriodIdx: index("usage_practice_period_idx").on( + t.practiceId, + t.periodMonth + ), + }) +); From 29c3d3ec87ac1a720303e348d22a1bc007ba755e Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 16:03:42 -0400 Subject: [PATCH 14/19] Bill Cloud per location + show usage in billing UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createCheckout now sets the Stripe subscription quantity to the practice's active location count (createSubscriptionCheckoutSession gains a quantity param). subscription.get returns locationCount + current-period SMS/AI usage + plan allowances. BillingTab shows locations × $99 = total and this month's metered usage vs included; plan cards show '/mo / location' and included allowances. 200 tests green. Co-Authored-By: Claude Opus 4.8 --- apps/web/app/(dashboard)/settings/page.tsx | 51 +++++++++++++++++++--- apps/web/lib/stripe.ts | 4 +- apps/web/server/routers/subscription.ts | 38 ++++++++++++++-- 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index 81583c3..87e11fe 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -458,8 +458,8 @@ function BillingTab() {
{data.billingStatus === "trialing" && (

- You're on a free trial with full Pro access —{" "} - {daysLeft} days left. Pick a plan + You're on a free trial with full Cloud access —{" "} + {daysLeft} days left. Subscribe below to keep your features after it ends.

)} @@ -468,6 +468,34 @@ function BillingTab() { Your last payment failed. Update your payment method to avoid losing access.

)} + {/* Billing summary: locations × price + this month's metered usage */} +
+
+

Locations

+

+ {data.locationCount} × ${currentPlan?.priceMonthlyUsd ?? 99}/mo ={" "} + ${data.locationCount * (currentPlan?.priceMonthlyUsd ?? 99)}/mo +

+
+
+

SMS this month

+

+ {data.usage.sms} + {currentPlan?.includedSmsPerMonth != null + ? ` / ${currentPlan.includedSmsPerMonth} included` + : ""} +

+
+
+

AI runs this month

+

+ {data.usage.aiRuns} + {currentPlan?.includedAiRunsPerMonth != null + ? ` / ${currentPlan.includedAiRunsPerMonth} included` + : ""} +

+
+
{data.hasBillingAccount && ( + + )} + +

+ + Back to sign in + +

+
+
+ ); +} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 7baf303..3be4530 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -177,6 +177,11 @@ export default function LoginPage() {

+ + Forgot your password? + +

+

Don't have an account?{" "} Register your practice diff --git a/apps/web/app/(auth)/register/page.tsx b/apps/web/app/(auth)/register/page.tsx index 7ec99f1..1406f78 100644 --- a/apps/web/app/(auth)/register/page.tsx +++ b/apps/web/app/(auth)/register/page.tsx @@ -16,16 +16,23 @@ export default function RegisterPage() { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [verifySent, setVerifySent] = useState(false); + const registerMutation = trpc.auth.register.useMutation({ - onSuccess: async () => { + onSuccess: async (data) => { + // Hosted: email verification required → show a "check your inbox" notice. + if (data?.verificationRequired) { + setVerifySent(true); + setLoading(false); + return; + } + // Self-host: auto-sign in after registration. toast.success("Account created! Redirecting..."); - // Auto-sign in after registration const result = await signIn("credentials", { email, password, redirect: false, }); - if (result?.ok) { router.push("/"); router.refresh(); @@ -38,6 +45,23 @@ export default function RegisterPage() { }, }); + if (verifySent) { + return ( +

+
+

Check your email

+

+ We sent a verification link to {email}. Click it to activate + your account and start your 14-day free trial. +

+ + Back to sign in + +
+
+ ); + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); @@ -55,6 +79,9 @@ export default function RegisterPage() {

Register your practice

+

+ 14-day free trial · no credit card required +

diff --git a/apps/web/app/(auth)/reset-password/page.tsx b/apps/web/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..d800335 --- /dev/null +++ b/apps/web/app/(auth)/reset-password/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { Suspense, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; + +function ResetPasswordInner() { + const params = useSearchParams(); + const token = params.get("token") ?? ""; + const [password, setPassword] = useState(""); + const [done, setDone] = useState(false); + + const reset = trpc.auth.resetPassword.useMutation({ + onSuccess: () => setDone(true), + onError: (err) => toast.error(err.message), + }); + + return ( +
+
+
+

OpenVPM

+

Choose a new password

+
+ + {done ? ( +
+

Your password has been reset.

+ + Sign in + +
+ ) : !token ? ( +

+ This reset link is invalid. Request a new one from the sign-in page. +

+ ) : ( + { + e.preventDefault(); + reset.mutate({ token, password }); + }} + className="space-y-4" + > +
+ + setPassword(e.target.value)} + required + minLength={8} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + placeholder="At least 8 characters" + /> +
+ + + )} +
+
+ ); +} + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/apps/web/app/(auth)/verify-email/page.tsx b/apps/web/app/(auth)/verify-email/page.tsx new file mode 100644 index 0000000..d653eff --- /dev/null +++ b/apps/web/app/(auth)/verify-email/page.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Suspense, useEffect, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { trpc } from "@/lib/trpc"; + +function VerifyEmailInner() { + const params = useSearchParams(); + const token = params.get("token") ?? ""; + const [status, setStatus] = useState<"verifying" | "ok" | "error">("verifying"); + const ran = useRef(false); + + const verify = trpc.auth.verifyEmail.useMutation({ + onSuccess: () => setStatus("ok"), + onError: () => setStatus("error"), + }); + + useEffect(() => { + if (ran.current) return; + ran.current = true; + if (!token) { + setStatus("error"); + return; + } + verify.mutate({ token }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + return ( +
+
+

OpenVPM

+ {status === "verifying" && ( +

Verifying your email…

+ )} + {status === "ok" && ( + <> +

+ Your email is verified. You can now sign in and start your free trial. +

+ + Sign in + + + )} + {status === "error" && ( + <> +

+ This verification link is invalid or has expired. +

+ + Back to sign in + + + )} +
+
+ ); +} + +export default function VerifyEmailPage() { + return ( + + + + ); +} diff --git a/apps/web/lib/auth-tokens.ts b/apps/web/lib/auth-tokens.ts new file mode 100644 index 0000000..efa1acf --- /dev/null +++ b/apps/web/lib/auth-tokens.ts @@ -0,0 +1,72 @@ +import { randomBytes, createHash } from "crypto"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import { db } from "@openpims/db/client"; +import { authTokens } from "@openpims/db"; +import { withSystem } from "@/lib/tenant-db"; + +export type AuthTokenType = "email_verify" | "password_reset"; + +const TTL_MS: Record = { + email_verify: 24 * 60 * 60 * 1000, // 24h + password_reset: 60 * 60 * 1000, // 1h +}; + +function hashToken(raw: string): string { + return createHash("sha256").update(raw).digest("hex"); +} + +/** + * Issue a single-use token. Returns the RAW token (emailed to the user); only + * its hash is persisted. Pre-tenant flow → system context. + */ +export async function createAuthToken(opts: { + userId: string; + email: string; + type: AuthTokenType; + now?: Date; +}): Promise { + const raw = randomBytes(32).toString("hex"); + const now = opts.now ?? new Date(); + await withSystem(db, (tx) => + tx.insert(authTokens).values({ + userId: opts.userId, + email: opts.email.toLowerCase(), + tokenHash: hashToken(raw), + type: opts.type, + expiresAt: new Date(now.getTime() + TTL_MS[opts.type]), + }) + ); + return raw; +} + +/** + * Validate + consume a token. Returns the matching record (userId/email) if it's + * the right type, unused, and unexpired — and marks it used. Otherwise null. + */ +export async function consumeAuthToken( + raw: string, + type: AuthTokenType, + now: Date = new Date() +): Promise<{ userId: string; email: string } | null> { + const tokenHash = hashToken(raw); + return withSystem(db, async (tx) => { + const [row] = await tx + .select() + .from(authTokens) + .where( + and( + eq(authTokens.tokenHash, tokenHash), + eq(authTokens.type, type), + isNull(authTokens.usedAt), + gt(authTokens.expiresAt, now) + ) + ) + .limit(1); + if (!row) return null; + await tx + .update(authTokens) + .set({ usedAt: now }) + .where(eq(authTokens.id, row.id)); + return { userId: row.userId, email: row.email }; + }); +} diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts index 2deb595..5ff9c6f 100644 --- a/apps/web/lib/auth.ts +++ b/apps/web/lib/auth.ts @@ -5,6 +5,7 @@ import { db } from "@openpims/db/client"; import { users } from "@openpims/db"; import { eq } from "drizzle-orm"; import { withSystem } from "@/lib/tenant-db"; +import { billingEnforced } from "@/lib/billing/plans"; declare module "next-auth" { interface Session { @@ -60,6 +61,11 @@ export const authOptions: NextAuthOptions = { const isValid = await compare(credentials.password, user.passwordHash); if (!isValid) return null; + // On the hosted service, require a verified email before login. + if (billingEnforced() && !user.emailVerifiedAt) { + throw new Error("Please verify your email before signing in."); + } + return { id: user.id, email: user.email, diff --git a/apps/web/lib/email.ts b/apps/web/lib/email.ts index 110a886..3662e7f 100644 --- a/apps/web/lib/email.ts +++ b/apps/web/lib/email.ts @@ -271,3 +271,47 @@ export async function sendInvoiceEmail(data: { return { success: result.success }; } + +// --------------------------------------------------------------------------- +// Account: email verification + password reset (hosted auth) +// --------------------------------------------------------------------------- + +export async function sendVerificationEmail(data: { + to: string; + name: string; + verifyUrl: string; +}): Promise<{ success: boolean }> { + const body = ` +

Hi ${data.name},

+

Welcome to OpenVPM! Please confirm your email address to activate your account and start your free trial.

+ ${ctaButton("Verify my email", data.verifyUrl)} +

This link expires in 24 hours. If you didn't create an OpenVPM account, you can ignore this email.

+ `; + const html = emailLayout("OpenVPM", body); + const result = await sendEmail({ + to: data.to, + subject: "Verify your OpenVPM email", + html, + }); + return { success: result.success }; +} + +export async function sendPasswordResetEmail(data: { + to: string; + name: string; + resetUrl: string; +}): Promise<{ success: boolean }> { + const body = ` +

Hi ${data.name},

+

We received a request to reset your OpenVPM password. Click below to choose a new one.

+ ${ctaButton("Reset my password", data.resetUrl)} +

This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email — your password won't change.

+ `; + const html = emailLayout("OpenVPM", body); + const result = await sendEmail({ + to: data.to, + subject: "Reset your OpenVPM password", + html, + }); + return { success: result.success }; +} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 98f1b1e..fc3bfd9 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -30,6 +30,6 @@ export async function middleware(request: NextRequest) { export const config = { matcher: [ - "/((?!login|register|api/auth|_next|favicon.ico|api/trpc|portal|api-docs|api/portal|api/webhooks|api/cron).*)", + "/((?!login|register|verify-email|forgot-password|reset-password|api/auth|_next|favicon.ico|api/trpc|portal|api-docs|api/portal|api/webhooks|api/cron|api/health).*)", ], }; diff --git a/apps/web/server/routers/auth.ts b/apps/web/server/routers/auth.ts index cafcc2f..0ff2662 100644 --- a/apps/web/server/routers/auth.ts +++ b/apps/web/server/routers/auth.ts @@ -7,6 +7,16 @@ import { users, practices, locations } from "@openpims/db"; import { rateLimit } from "@/lib/rate-limit"; import { seedPractice } from "@/lib/onboarding/defaults"; import { billingEnforced, TRIAL_DAYS } from "@/lib/billing/plans"; +import { createAuthToken, consumeAuthToken } from "@/lib/auth-tokens"; +import { sendVerificationEmail, sendPasswordResetEmail } from "@/lib/email"; + +function appBaseUrl(): string { + return ( + process.env.NEXT_PUBLIC_APP_URL ?? + process.env.NEXTAUTH_URL ?? + "http://localhost:3000" + ); +} export const authRouter = createRouter({ register: publicProcedure @@ -98,7 +108,113 @@ export const authRouter = createRouter({ console.error("[register] practice seeding failed:", err); } - return { id: user!.id, email: user!.email }; + // On the hosted service, require email verification before login. Issue a + // token + send the email. Non-fatal: signup still succeeds. Self-host + // skips this (frictionless). + let verificationRequired = false; + if (billingEnforced()) { + verificationRequired = true; + try { + const token = await createAuthToken({ + userId: user!.id, + email: user!.email, + type: "email_verify", + }); + await sendVerificationEmail({ + to: user!.email, + name: user!.name, + verifyUrl: `${appBaseUrl()}/verify-email?token=${token}`, + }); + } catch (err) { + console.error("[register] verification email failed:", err); + } + } + + return { id: user!.id, email: user!.email, verificationRequired }; + }), + + /** Confirm an email-verification token (hosted). */ + verifyEmail: publicProcedure + .input(z.object({ token: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const result = await consumeAuthToken(input.token, "email_verify"); + if (!result) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This verification link is invalid or has expired.", + }); + } + await ctx.db + .update(users) + .set({ emailVerifiedAt: new Date() }) + .where(eq(users.id, result.userId)); + return { ok: true }; + }), + + /** Request a password-reset email. Always succeeds (no account enumeration). */ + requestPasswordReset: publicProcedure + .input(z.object({ email: z.string().email() })) + .mutation(async ({ ctx, input }) => { + const { success } = rateLimit({ + key: `pwreset:${input.email}`, + limit: 5, + windowMs: 3600000, + }); + if (!success) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Too many requests. Please try again later.", + }); + } + + const [user] = await ctx.db + .select({ id: users.id, email: users.email, name: users.name }) + .from(users) + .where(eq(users.email, input.email)) + .limit(1); + + if (user) { + try { + const token = await createAuthToken({ + userId: user.id, + email: user.email, + type: "password_reset", + }); + await sendPasswordResetEmail({ + to: user.email, + name: user.name, + resetUrl: `${appBaseUrl()}/reset-password?token=${token}`, + }); + } catch (err) { + console.error("[requestPasswordReset] email failed:", err); + } + } + // Generic response regardless of whether the email exists. + return { ok: true }; + }), + + /** Complete a password reset with a valid token. */ + resetPassword: publicProcedure + .input( + z.object({ + token: z.string().min(1), + password: z.string().min(8), + }) + ) + .mutation(async ({ ctx, input }) => { + const result = await consumeAuthToken(input.token, "password_reset"); + if (!result) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This reset link is invalid or has expired.", + }); + } + const passwordHash = await hash(input.password, 10); + await ctx.db + .update(users) + .set({ passwordHash }) + .where(eq(users.id, result.userId)); + return { ok: true }; }), me: protectedProcedure.query(async ({ ctx }) => { diff --git a/packages/db/schema/auth-tokens.ts b/packages/db/schema/auth-tokens.ts new file mode 100644 index 0000000..0b7dfa5 --- /dev/null +++ b/packages/db/schema/auth-tokens.ts @@ -0,0 +1,29 @@ +import { pgTable, uuid, varchar, timestamp, index } from "drizzle-orm/pg-core"; +import { baseColumns } from "./common"; +import { users } from "./users"; + +/** + * Single-use, expiring tokens for email verification + password reset. Only the + * SHA-256 hash of the token is stored (never the raw value). These are + * pre-tenant / cross-tenant by nature (a user may be locked out), so they are + * NOT under RLS and are accessed via withSystem. + */ +export const authTokens = pgTable( + "auth_tokens", + { + ...baseColumns(), + userId: uuid("user_id") + .notNull() + .references(() => users.id), + email: varchar("email", { length: 255 }).notNull(), + /** SHA-256 hex of the raw token. */ + tokenHash: varchar("token_hash", { length: 64 }).notNull(), + /** "email_verify" | "password_reset" */ + type: varchar("type", { length: 24 }).notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + usedAt: timestamp("used_at", { withTimezone: true }), + }, + (t) => ({ + tokenHashIdx: index("auth_tokens_hash_idx").on(t.tokenHash), + }) +); diff --git a/packages/db/schema/index.ts b/packages/db/schema/index.ts index caef249..ed1f239 100644 --- a/packages/db/schema/index.ts +++ b/packages/db/schema/index.ts @@ -14,3 +14,4 @@ export * from "./templates"; export * from "./insurance"; export * from "./wellness"; export * from "./usage"; +export * from "./auth-tokens"; From 0e58b7d91d00b913c5825cede1af9fd652cd4468 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 16:41:38 -0400 Subject: [PATCH 17/19] Add demo-data seeding + first-run onboarding wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seedDemoData seeds 3 clients/patients + 2 upcoming appointments so a hosted trial lands on a lively dashboard, not empty states. Register stores the demo IDs + onboarding state in practices.settings (hosted only, non-fatal). New /onboarding wizard (confirm practice → invite team → import → clear demo → finish) with settings.onboardingStatus/completeOnboarding/clearDemoData (clearDemoData soft-deletes exactly the seeded rows). Dashboard shows a 'finish setup' banner until onboarding is complete (admin-only). 200 tests + build green. Co-Authored-By: Claude Opus 4.8 --- apps/web/app/(dashboard)/onboarding/page.tsx | 137 +++++++++++++++++++ apps/web/app/(dashboard)/page.tsx | 28 +++- apps/web/lib/onboarding/defaults.ts | 67 ++++++++- apps/web/server/routers/auth.ts | 18 ++- apps/web/server/routers/settings.ts | 95 ++++++++++++- 5 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 apps/web/app/(dashboard)/onboarding/page.tsx diff --git a/apps/web/app/(dashboard)/onboarding/page.tsx b/apps/web/app/(dashboard)/onboarding/page.tsx new file mode 100644 index 0000000..e5784c3 --- /dev/null +++ b/apps/web/app/(dashboard)/onboarding/page.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Loader2, Check, Building2, Users, Database, Rocket } from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; + +const STEPS = [ + { + icon: Building2, + title: "Confirm your practice details", + body: "Set your practice name, country, currency, tax rate, and timezone.", + href: "/settings?tab=practice", + cta: "Open practice settings", + }, + { + icon: Users, + title: "Invite your team", + body: "Add veterinarians, technicians, and front-desk staff so everyone can log in.", + href: "/settings?tab=staff", + cta: "Manage staff", + }, + { + icon: Database, + title: "Bring in your data", + body: "Import clients and patients from your current system, or start fresh.", + href: "/settings?tab=data", + cta: "Import data", + }, +]; + +export default function OnboardingPage() { + const router = useRouter(); + const utils = trpc.useUtils(); + const status = trpc.settings.onboardingStatus.useQuery(undefined, { retry: false }); + + const clearDemo = trpc.settings.clearDemoData.useMutation({ + onSuccess: () => { + utils.settings.onboardingStatus.invalidate(); + toast.success("Demo data cleared"); + }, + onError: (e) => toast.error(e.message), + }); + const complete = trpc.settings.completeOnboarding.useMutation({ + onSuccess: () => { + toast.success("You're all set!"); + router.push("/"); + router.refresh(); + }, + onError: (e) => toast.error(e.message), + }); + + if (status.isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +

Welcome to OpenVPM

+

+ A few quick steps to get your practice running. Your dashboard is + pre-filled with demo data so you can explore right away. +

+
+ +
+ {STEPS.map((s) => { + const Icon = s.icon; + return ( +
+
+ +
+
+

{s.title}

+

{s.body}

+
+ + + +
+ ); + })} +
+ + {status.data?.hasDemoData && ( +
+
+

Clear the demo data

+

+ Remove the sample clients, patients, and appointments when you're + ready to go live with real data. +

+
+ +
+ )} + +
+ + + + +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/page.tsx b/apps/web/app/(dashboard)/page.tsx index be9c9ae..2a3cfba 100644 --- a/apps/web/app/(dashboard)/page.tsx +++ b/apps/web/app/(dashboard)/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { Calendar, PawPrint, DollarSign, FileText, Clock } from "lucide-react"; +import Link from "next/link"; +import { Calendar, PawPrint, DollarSign, FileText, Clock, Rocket } from "lucide-react"; import { cn } from "@/lib/utils"; import { trpc } from "@/lib/trpc"; import { formatCurrency, localeForCountry } from "@/lib/locale/format"; @@ -138,6 +139,30 @@ function PieLabel({ ); } +function OnboardingBanner() { + // Admin-only; non-admins get FORBIDDEN → no banner. + const { data } = trpc.settings.onboardingStatus.useQuery(undefined, { + retry: false, + staleTime: 5 * 60 * 1000, + }); + if (!data || data.completedAt) return null; + return ( + + +
+

Finish setting up your practice

+

+ Confirm your details, invite your team, and clear the demo data when you're ready. +

+
+ Continue → + + ); +} + export default function DashboardPage() { const stats = trpc.dashboard.getStats.useQuery(); const charts = trpc.dashboard.getCharts.useQuery(); @@ -174,6 +199,7 @@ export default function DashboardPage() { return (
+ {/* KPI Cards */}
{stats.isLoading diff --git a/apps/web/lib/onboarding/defaults.ts b/apps/web/lib/onboarding/defaults.ts index 75406fa..2d8f4c4 100644 --- a/apps/web/lib/onboarding/defaults.ts +++ b/apps/web/lib/onboarding/defaults.ts @@ -1,5 +1,12 @@ import type { Database } from "@openpims/db/client"; -import { appointmentTypes, rooms, services } from "@openpims/db"; +import { + appointmentTypes, + rooms, + services, + clients, + patients, + appointments, +} from "@openpims/db"; /** * Sensible defaults seeded for a brand-new practice so it's usable immediately @@ -96,3 +103,61 @@ export async function seedPractice( })) ); } + +export interface DemoDataIds { + clientIds: string[]; + patientIds: string[]; + appointmentIds: string[]; +} + +/** + * Seed a small set of demo clients/patients/appointments so a hosted trial + * lands on a lively dashboard instead of empty states. The returned IDs are + * stored on the practice so the onboarding wizard can clear them with one click. + * Call only on hosted trials; non-fatal. + */ +export async function seedDemoData( + db: Database, + opts: { practiceId: string } +): Promise { + const insertedClients = await db + .insert(clients) + .values([ + { practiceId: opts.practiceId, firstName: "Jordan", lastName: "Avery", email: "jordan.avery@example.com", phone: "(555) 200-1001" }, + { practiceId: opts.practiceId, firstName: "Sam", lastName: "Rivera", email: "sam.rivera@example.com", phone: "(555) 200-1002" }, + { practiceId: opts.practiceId, firstName: "Taylor", lastName: "Brooks", email: "taylor.brooks@example.com", phone: "(555) 200-1003" }, + ]) + .returning({ id: clients.id }); + + const insertedPatients = await db + .insert(patients) + .values([ + { practiceId: opts.practiceId, clientId: insertedClients[0]!.id, name: "Biscuit", species: "canine" as const, sex: "male_neutered" as const, breed: "Golden Retriever" }, + { practiceId: opts.practiceId, clientId: insertedClients[1]!.id, name: "Luna", species: "feline" as const, sex: "female_spayed" as const, breed: "Domestic Shorthair" }, + { practiceId: opts.practiceId, clientId: insertedClients[2]!.id, name: "Mango", species: "avian" as const, breed: "Sun Conure" }, + ]) + .returning({ id: patients.id }); + + const now = Date.now(); + const mkAppt = (clientIdx: number, patientIdx: number, offsetHours: number) => { + const start = new Date(now + offsetHours * 60 * 60 * 1000); + const end = new Date(start.getTime() + 30 * 60 * 1000); + return { + practiceId: opts.practiceId, + clientId: insertedClients[clientIdx]!.id, + patientId: insertedPatients[patientIdx]!.id, + startTime: start, + endTime: end, + }; + }; + const insertedAppts = await db + .insert(appointments) + .values([mkAppt(0, 0, 26), mkAppt(1, 1, 50)]) + .returning({ id: appointments.id }); + + return { + clientIds: insertedClients.map((c) => c.id), + patientIds: insertedPatients.map((p) => p.id), + appointmentIds: insertedAppts.map((a) => a.id), + }; +} diff --git a/apps/web/server/routers/auth.ts b/apps/web/server/routers/auth.ts index 0ff2662..8bc7aa9 100644 --- a/apps/web/server/routers/auth.ts +++ b/apps/web/server/routers/auth.ts @@ -5,7 +5,7 @@ import { TRPCError } from "@trpc/server"; import { createRouter, publicProcedure, protectedProcedure } from "../trpc"; import { users, practices, locations } from "@openpims/db"; import { rateLimit } from "@/lib/rate-limit"; -import { seedPractice } from "@/lib/onboarding/defaults"; +import { seedPractice, seedDemoData } from "@/lib/onboarding/defaults"; import { billingEnforced, TRIAL_DAYS } from "@/lib/billing/plans"; import { createAuthToken, consumeAuthToken } from "@/lib/auth-tokens"; import { sendVerificationEmail, sendPasswordResetEmail } from "@/lib/email"; @@ -108,6 +108,22 @@ export const authRouter = createRouter({ console.error("[register] practice seeding failed:", err); } + // On the hosted service, seed demo data + start onboarding so the trial + // lands on a lively dashboard. Non-fatal. Self-host skips this. + if (billingEnforced()) { + try { + const demoData = await seedDemoData(ctx.db, { practiceId: practice!.id }); + await ctx.db + .update(practices) + .set({ + settings: { demoData, onboardingCompletedAt: null }, + }) + .where(eq(practices.id, practice!.id)); + } catch (err) { + console.error("[register] demo seeding failed:", err); + } + } + // On the hosted service, require email verification before login. Issue a // token + send the email. Non-fatal: signup still succeeds. Self-host // skips this (frictionless). diff --git a/apps/web/server/routers/settings.ts b/apps/web/server/routers/settings.ts index 4536d44..1dab4be 100644 --- a/apps/web/server/routers/settings.ts +++ b/apps/web/server/routers/settings.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { eq, and, isNull } from "drizzle-orm"; +import { eq, and, isNull, inArray } from "drizzle-orm"; import { hash } from "bcryptjs"; import { createRouter, protectedProcedure, requireRole } from "../trpc"; import { @@ -7,11 +7,20 @@ import { users, appointmentTypes, rooms, + clients, + patients, + appointments, } from "@openpims/db"; import { regionDefaults } from "@/lib/locale/format"; const adminProcedure = protectedProcedure.use(requireRole("admin")); +interface PracticeSettings { + onboardingCompletedAt?: string | null; + demoData?: { clientIds: string[]; patientIds: string[]; appointmentIds: string[] }; + [k: string]: unknown; +} + export const settingsRouter = createRouter({ // ── Practice ────────────────────────────────────────────── @@ -66,6 +75,90 @@ export const settingsRouter = createRouter({ return updated!; }), + // ── Onboarding ──────────────────────────────────────────── + + /** Onboarding state for the first-run wizard / dashboard banner. */ + onboardingStatus: adminProcedure.query(async ({ ctx }) => { + const [practice] = await ctx.db + .select({ settings: practices.settings }) + .from(practices) + .where(eq(practices.id, ctx.practiceId)) + .limit(1); + const settings = (practice?.settings ?? {}) as PracticeSettings; + return { + completedAt: settings.onboardingCompletedAt ?? null, + hasDemoData: !!settings.demoData, + }; + }), + + /** Mark onboarding complete. */ + completeOnboarding: adminProcedure.mutation(async ({ ctx }) => { + const [practice] = await ctx.db + .select({ settings: practices.settings }) + .from(practices) + .where(eq(practices.id, ctx.practiceId)) + .limit(1); + const settings = (practice?.settings ?? {}) as PracticeSettings; + await ctx.db + .update(practices) + .set({ settings: { ...settings, onboardingCompletedAt: new Date().toISOString() } }) + .where(eq(practices.id, ctx.practiceId)); + return { ok: true }; + }), + + /** Remove the seeded demo clients/patients/appointments (soft delete). */ + clearDemoData: adminProcedure.mutation(async ({ ctx }) => { + const [practice] = await ctx.db + .select({ settings: practices.settings }) + .from(practices) + .where(eq(practices.id, ctx.practiceId)) + .limit(1); + const settings = (practice?.settings ?? {}) as PracticeSettings; + const demo = settings.demoData; + if (demo) { + const now = new Date(); + if (demo.appointmentIds?.length) { + await ctx.db + .update(appointments) + .set({ deletedAt: now }) + .where( + and( + eq(appointments.practiceId, ctx.practiceId), + inArray(appointments.id, demo.appointmentIds) + ) + ); + } + if (demo.patientIds?.length) { + await ctx.db + .update(patients) + .set({ deletedAt: now }) + .where( + and( + eq(patients.practiceId, ctx.practiceId), + inArray(patients.id, demo.patientIds) + ) + ); + } + if (demo.clientIds?.length) { + await ctx.db + .update(clients) + .set({ deletedAt: now }) + .where( + and( + eq(clients.practiceId, ctx.practiceId), + inArray(clients.id, demo.clientIds) + ) + ); + } + } + const { demoData: _omit, ...rest } = settings; + await ctx.db + .update(practices) + .set({ settings: rest }) + .where(eq(practices.id, ctx.practiceId)); + return { ok: true }; + }), + // ── Staff / Users ───────────────────────────────────────── listUsers: adminProcedure.query(async ({ ctx }) => { From 751955fd8f37586db302749f7b39e990375041ed Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 22:09:02 -0400 Subject: [PATCH 18/19] chore: gitignore local MCP config (.mcp.json) Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 272fee7..229ea4f 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ docs/screenshots/audit/ test-results/ CAVSG_AI_Innovator_Survey_COMPLETED.docx package-lock.json + +# Local MCP server config (not for the public repo) +.mcp.json From 9eacf1bdb70269cb516be5387b7b555458a2347d Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 22:20:12 -0400 Subject: [PATCH 19/19] Remove hardcoded credential from RLS script (env-driven role) The enable-rls.sql no longer ships a role password; apply-rls.ts creates/rotates the openpims_app role from OPENPIMS_APP_DB_PASSWORD (or expects the operator to create it). No credential lives in the public repo. Docs updated. Co-Authored-By: Claude Opus 4.8 --- docs/security/row-level-security.md | 16 +++++++++------- packages/db/apply-rls.ts | 21 +++++++++++++++++++++ packages/db/rls/enable-rls.sql | 18 +++++++----------- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/docs/security/row-level-security.md b/docs/security/row-level-security.md index 43bde53..8581989 100644 --- a/docs/security/row-level-security.md +++ b/docs/security/row-level-security.md @@ -45,7 +45,9 @@ without any risk to the default configuration. ## Applying it ```bash -pnpm db:rls # apply policies + create the openpims_app role (run as owner) +# Set a strong password for the app role; the script creates the role if missing +# (no credential is stored in the repo) and applies the policies + grants. +OPENPIMS_APP_DB_PASSWORD='' pnpm db:rls # run as the DB owner pnpm db:rls:test # live verification: proves cross-tenant isolation ``` @@ -55,10 +57,10 @@ nothing, and the system bypass sees everything. ## Activating enforcement in production -1. `pnpm db:rls` against the production database. -2. `ALTER ROLE openpims_app PASSWORD '';` (the migration sets a - dev placeholder). -3. Point the hosted `DATABASE_URL` at `openpims_app`. +1. `OPENPIMS_APP_DB_PASSWORD='' pnpm db:rls` against the production database + (creates the `openpims_app` role with that password — or rotates it if it + already exists — then applies policies + grants). +2. Point the hosted `DATABASE_URL` at the `openpims_app` role. ### Entrypoint coverage (all wired) @@ -76,6 +78,6 @@ correctly under the enforcing `openpims_app` role: `app/api/portal/checkout` → `withSystem`. These are no-ops on the owner connection (dev/self-host). To activate enforcement -in production: run `pnpm db:rls`, set a real `openpims_app` password, and point -`DATABASE_URL` at that role (Phase 5 infra). Re-run `pnpm db:rls:test` against +in production: run `OPENPIMS_APP_DB_PASSWORD='' pnpm db:rls` and point +`DATABASE_URL` at the `openpims_app` role (Phase 5 infra). Re-run `pnpm db:rls:test` against staging to confirm isolation under the restricted role. diff --git a/packages/db/apply-rls.ts b/packages/db/apply-rls.ts index d13c060..4382a3c 100644 --- a/packages/db/apply-rls.ts +++ b/packages/db/apply-rls.ts @@ -17,6 +17,27 @@ const sqlText = readFileSync(join(here, "rls", "enable-rls.sql"), "utf8"); const sql = postgres(url, { max: 1 }); try { + // Ensure the least-privilege app role exists BEFORE the grants run. No + // credential is committed: the password comes from OPENPIMS_APP_DB_PASSWORD. + const [exists] = await sql`select 1 from pg_roles where rolname = 'openpims_app'`; + const appPw = process.env.OPENPIMS_APP_DB_PASSWORD; + if (!exists) { + if (!appPw) { + console.error( + "openpims_app role does not exist. Create it with a strong password, " + + "or set OPENPIMS_APP_DB_PASSWORD and re-run `pnpm db:rls`." + ); + process.exit(1); + } + const q = appPw.replace(/'/g, "''"); // escape single quotes + await sql.unsafe(`CREATE ROLE openpims_app LOGIN PASSWORD '${q}'`); + console.log("✓ created openpims_app role"); + } else if (appPw) { + const q = appPw.replace(/'/g, "''"); + await sql.unsafe(`ALTER ROLE openpims_app PASSWORD '${q}'`); + console.log("✓ rotated openpims_app password"); + } + // Simple protocol so the multi-statement / DO-block migration runs as one. await sql.unsafe(sqlText).simple(); console.log("✓ RLS policies applied (run as the DB owner)."); diff --git a/packages/db/rls/enable-rls.sql b/packages/db/rls/enable-rls.sql index 9a59fb1..29d5c7a 100644 --- a/packages/db/rls/enable-rls.sql +++ b/packages/db/rls/enable-rls.sql @@ -7,21 +7,17 @@ -- The table OWNER bypasses RLS (we do NOT use FORCE), so: -- • Migrations + dev/self-host on the owner connection are unaffected. -- • Enforcement activates when the app connects as the least-privilege role --- `openpims_app` created below. +-- `openpims_app`, which you point the hosted DATABASE_URL at. -- -- Apply with: pnpm db:rls (idempotent — safe to re-run after schema changes) +-- +-- ROLE CREATION: this file contains NO credentials. The `openpims_app` role is +-- created/managed by the apply script (packages/db/apply-rls.ts) using the +-- OPENPIMS_APP_DB_PASSWORD env var, or you create the role yourself beforehand. +-- The grants below assume the role already exists. -- ============================================================================ --- 1) Least-privilege application role. CHANGE THE PASSWORD IN PRODUCTION --- (e.g. ALTER ROLE openpims_app PASSWORD '...'), then point the hosted --- DATABASE_URL at this role to turn RLS enforcement on. -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'openpims_app') THEN - CREATE ROLE openpims_app LOGIN PASSWORD 'openpims_app'; - END IF; -END$$; - +-- 1) Grants for the least-privilege application role (must already exist). GRANT USAGE ON SCHEMA public TO openpims_app; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO openpims_app; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO openpims_app;