// The SaaS backend for the {{projectName}} project — the WHOLE loop in one api. // Read by `voltro dev`. // // It composes two shipped plugins that are normally wired separately, plus a // small tenant-scoped domain (projects + invites), so that signup → paywall → // app works out of the box: // // • @voltro/plugin-auth — password sign-up/in/out over an HttpOnly session // cookie, and a strategy that resolves that cookie into a typed `Subject` // on every rpc/ws call (so handlers read `ctx.request.subject`). // • @voltro/plugin-billing — subscriptions + ENTITLEMENTS over a pluggable // provider. `plans` is the single source of tier→quota truth. The handlers // call `requireEntitlement(...)`, which consumes the tenant's metered // quota and fails typed `EntitlementExceeded` when it's exhausted — that // typed failure IS the paywall signal the frontend upgrades on. // // The domain then ties them together: `projects.create` spends a `projects` // quota (free = 3), and `invites.create` spends a `seats` quota (free = 1) — so // the free plan runs out and upgrading to `pro` (via `billing.startCheckout`) // lifts the caps. Boots ZERO-infra (`store: 'memory'`, `mock` billing provider, // `memoryUserStore`); the README shows the production swaps. import { defineEnv, envVar } from '@voltro/env' import { authRoutesPlugin, memoryUserStore, voltroPasswordStrategy } from '@voltro/plugin-auth' import { billingPlugin } from '@voltro/plugin-billing' import { resolveScopes } from './authz' export const env = defineEnv({ LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }), // HMAC key that signs + verifies the session cookie. // // `generate` means this project mints its OWN key: `voltro dev` writes a // unique value into a gitignored `.env.local` on first boot. No value ships // with the template — a shipped placeholder would be a signing key published // to everyone who downloads it, and every session in every deployment built // from it would be forgeable. Your DEPLOYMENT mints its own the same way (a // missing secret is a hard boot failure in `serve`/`build`/`start`). VOLTRO_SESSION_SECRET: envVar.secret({ generate: 'base64url', description: 'HMAC key that signs and verifies session cookies.', }), }) // In-process user store — zero infra. `postgresUserStore({ sql })` for durable // accounts (it manages its own `_voltro_auth_*` tables, auto-migrated). const userStore = memoryUserStore() export default { type: 'api' as const, name: '{{capProjectName}}{{capAppName}}', store: 'memory' as const, env, plugins: [ // ── Auth ───────────────────────────────────────────────────────────── authRoutesPlugin({ store: userStore, // New sign-ups land in this tenant. Every table below carries `tenant()`, // so the runtime AND-merges `tenantId = subject.tenantId` into reads — // one tenant never sees another's projects or invites. defaultTenantId: 'acme', successRedirect: '/', // `Secure` cookies are HTTPS-only, so OFF in dev (a localhost http page // would silently drop the cookie) and ON in production — this is what // makes the sign-in → session → authenticated-call loop work locally. cookieSecure: process.env.NODE_ENV === 'production', }), // ── Billing ────────────────────────────────────────────────────────── // `mock` is the in-memory provider (no Stripe key needed) — great for dev. // `plans` is the single source of tier→quota truth: `free` caps projects at // 3 and seats at 1; `pro` lifts both. Swap `provider: 'stripe'` (+ // STRIPE_SECRET_KEY) and real `priceId`s for production checkout. billingPlugin({ provider: 'mock', plans: { free: { entitlements: { projects: 3, seats: 1 } }, pro: { priceId: 'price_demo_pro', entitlements: { projects: 'unlimited', seats: 10 }, }, }, }), ], auth: { // Resolves the session cookie the /auth routes mint into the request's // Subject. Reads the SAME VOLTRO_SESSION_SECRET the routes sign with, so // the signing and verifying sides cannot drift. strategies: [voltroPasswordStrategy()], // …and turns that identity into AUTHORITY. A session cookie carries no // `scopes` by design (identity is settled at sign-in; authority is re-read // per request), so without this the guards on the four domain procedures // would be unsatisfiable — the dashboard would boot and answer `ScopeError` // to every caller, which is an outage, not security. // // It runs ONLY on a subject a strategy matched, never for anonymous. That // asymmetry IS what the guards enforce: signed in → member scopes; signed // out → nothing. See `authz.ts` for the vocabulary, and for why a quota and // a tenant filter are not substitutes for it. resolveScopes, }, // Point `voltro doctor` / `voltro check` at the scope vocabulary so the // unknown-scope rule is LIVE here. This app does scope-based authorization // without `@voltro/plugin-rbac`, and the plugin is what normally publishes // that set — without this pointer the rule stays dormant and a guard naming a // scope nothing grants (a typo, a rename) becomes a permanently uncallable // procedure that nothing reports. doctor: { scopeVocabulary: './authz.ts#MEMBER_SCOPES' }, }