@voltro/cli 0.27.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +513 -0
- package/THIRD-PARTY-NOTICES.md +88 -2
- package/dist/{apiBuild-D22_EpoR.js → apiBuild-BESZbTjA.js} +4 -4
- package/dist/apiBuild-C8d74T6g.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-BPAtyWs8.js +2420 -0
- package/dist/checkCommand-DBvZvCh1.js +2 -0
- package/dist/cliRuntime-Oh517vCV.js +96 -0
- package/dist/{commands-jBX8no1I.js → commands-82BDEktg.js} +6476 -7672
- package/dist/dbCommand-CaIVTp-a.js +2 -0
- package/dist/{dbCommand-uuNCrFAb.js → dbCommand-bCBbk6tz.js} +3 -3
- package/dist/{dev-DcbIJrWg.js → dev-Cg4BKLmi.js} +3034 -2619
- package/dist/dev-DeEJj5T4.js +3 -0
- package/dist/fileConventions-Cof68_BL.js +33 -0
- package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-4Db40V23.js} +2 -2
- package/dist/frameworkTableAssembly-BTNirAAk.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspect-CUCCzw2I.js → inspect-BMvQpwqy.js} +211 -190
- package/dist/inspect-DHeaMZak.js +2 -0
- package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-DHYygE4s.js} +999 -961
- package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-C4R1EdHn.js} +1 -1
- package/dist/manifestBuild-Ck_90gZy.js +2 -0
- package/dist/sdkgen-CW5NbYF0.js +582 -0
- package/dist/{seedRunner-D6eu-u5U.js → seedRunner-Bqxgp7HZ.js} +60 -59
- package/dist/serveCommand-BXl8mhZD.js +1489 -0
- package/dist/serveEntry.js +3 -3
- package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +2 -1
- package/templates/agent-docs/_index.md +2 -1
- package/templates/agent-docs/_manifest.json +10 -1
- package/templates/agent-docs/ai.md +135 -0
- package/templates/agent-docs/cli.md +124 -6
- package/templates/agent-docs/configuration.md +32 -0
- package/templates/agent-docs/data.md +282 -0
- package/templates/agent-docs/database/migrations.md +47 -0
- package/templates/agent-docs/database/misc.md +57 -0
- package/templates/agent-docs/database/schema.md +3 -1
- package/templates/agent-docs/deployment.md +56 -0
- package/templates/agent-docs/internationalization.md +161 -1
- package/templates/agent-docs/local-first-mobile.md +414 -0
- package/templates/agent-docs/routing.md +93 -2
- package/templates/agent-docs/schema-driven-ui.md +12 -0
- package/templates/agent-docs/templates/apibackends.md +1 -1
- package/templates/agent-docs/testing.md +42 -0
- package/templates/agent-docs/whats-new.md +165 -131
- package/templates/agent-docs/workflows.md +11 -0
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/.env.example +19 -0
- package/templates/apps/api-backend-sqlite/README.md +38 -0
- package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
- package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-sqlite/package.json +30 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-sqlite/template.json +6 -0
- package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/README.md +104 -0
- package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
- package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
- package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
- package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
- package/templates/apps/api-cms/actions/me.action.ts +16 -0
- package/templates/apps/api-cms/app.config.ts +61 -0
- package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
- package/templates/apps/api-cms/content/index.ts +18 -0
- package/templates/apps/api-cms/content/page.contentType.ts +24 -0
- package/templates/apps/api-cms/database/schema.ts +64 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
- package/templates/apps/api-cms/package.json +32 -0
- package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
- package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
- package/templates/apps/api-cms/template.json +6 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
- package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
- package/templates/apps/api-cms/tsconfig.json +5 -0
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/README.md +103 -0
- package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
- package/templates/apps/api-saas-starter/app.config.ts +87 -0
- package/templates/apps/api-saas-starter/database/schema.ts +57 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/package.json +32 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
- package/templates/apps/api-saas-starter/template.json +6 -0
- package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
- package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
- package/templates/apps/api-saas-starter/tsconfig.json +5 -0
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/README.md +88 -0
- package/templates/apps/api-status/app.config.ts +36 -0
- package/templates/apps/api-status/authz.ts +33 -0
- package/templates/apps/api-status/database/schema.ts +70 -0
- package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
- package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
- package/templates/apps/api-status/package.json +30 -0
- package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/components.list.query.ts +13 -0
- package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
- package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
- package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
- package/templates/apps/api-status/template.json +6 -0
- package/templates/apps/api-status/tests/status.test.ts +70 -0
- package/templates/apps/api-status/tsconfig.json +5 -0
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-auth/README.md +78 -0
- package/templates/apps/frontend-auth/app.config.ts +34 -0
- package/templates/apps/frontend-auth/package.json +32 -0
- package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
- package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
- package/templates/apps/frontend-auth/src/config.ts +11 -0
- package/templates/apps/frontend-auth/src/globals.css +105 -0
- package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
- package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
- package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
- package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
- package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
- package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
- package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
- package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
- package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
- package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
- package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
- package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
- package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
- package/templates/apps/frontend-auth/template.json +6 -0
- package/templates/apps/frontend-auth/tsconfig.json +5 -0
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/README.md +47 -0
- package/templates/apps/frontend-cms/app.config.ts +37 -0
- package/templates/apps/frontend-cms/package.json +33 -0
- package/templates/apps/frontend-cms/src/config.ts +8 -0
- package/templates/apps/frontend-cms/src/globals.css +105 -0
- package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
- package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
- package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
- package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
- package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
- package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
- package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
- package/templates/apps/frontend-cms/template.json +6 -0
- package/templates/apps/frontend-cms/tsconfig.json +5 -0
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/README.md +71 -0
- package/templates/apps/frontend-portal/app.config.ts +37 -0
- package/templates/apps/frontend-portal/package.json +32 -0
- package/templates/apps/frontend-portal/src/config.ts +8 -0
- package/templates/apps/frontend-portal/src/globals.css +93 -0
- package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
- package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
- package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
- package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
- package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
- package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
- package/templates/apps/frontend-portal/template.json +6 -0
- package/templates/apps/frontend-portal/tsconfig.json +5 -0
- package/templates/apps/frontend-saas/README.md +73 -0
- package/templates/apps/frontend-saas/app.config.ts +46 -0
- package/templates/apps/frontend-saas/package.json +32 -0
- package/templates/apps/frontend-saas/src/config.ts +8 -0
- package/templates/apps/frontend-saas/src/globals.css +85 -0
- package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
- package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
- package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
- package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
- package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
- package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-saas/template.json +6 -0
- package/templates/apps/frontend-saas/tsconfig.json +5 -0
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/README.md +51 -0
- package/templates/apps/frontend-status/app.config.ts +39 -0
- package/templates/apps/frontend-status/package.json +32 -0
- package/templates/apps/frontend-status/src/config.ts +7 -0
- package/templates/apps/frontend-status/src/globals.css +70 -0
- package/templates/apps/frontend-status/src/globals.d.ts +6 -0
- package/templates/apps/frontend-status/src/lib/status.ts +81 -0
- package/templates/apps/frontend-status/src/locales/de.ts +41 -0
- package/templates/apps/frontend-status/src/locales/en.ts +45 -0
- package/templates/apps/frontend-status/src/locales/index.ts +13 -0
- package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
- package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
- package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
- package/templates/apps/frontend-status/template.json +6 -0
- package/templates/apps/frontend-status/tsconfig.json +11 -0
- package/templates/baselines/compose/docker-compose.prod.yml +15 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
- package/dist/apiBuild-BrjrVJJh.js +0 -2
- package/dist/dbCommand-DrzXimKf.js +0 -2
- package/dist/dev-DNkso403.js +0 -3
- package/dist/fileConventions-3bffWssN.js +0 -30
- package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
- package/dist/inspect-gt8bq-Tz.js +0 -2
- package/dist/manifestBuild-ifczArzr.js +0 -2
- package/dist/serveCommand-DfkisVWP.js +0 -1310
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The SaaS backend for the {{projectName}} project — the WHOLE loop in one api.
|
|
2
|
+
// Read by `voltro dev`.
|
|
3
|
+
//
|
|
4
|
+
// It composes two shipped plugins that are normally wired separately, plus a
|
|
5
|
+
// small tenant-scoped domain (projects + invites), so that signup → paywall →
|
|
6
|
+
// app works out of the box:
|
|
7
|
+
//
|
|
8
|
+
// • @voltro/plugin-auth — password sign-up/in/out over an HttpOnly session
|
|
9
|
+
// cookie, and a strategy that resolves that cookie into a typed `Subject`
|
|
10
|
+
// on every rpc/ws call (so handlers read `ctx.request.subject`).
|
|
11
|
+
// • @voltro/plugin-billing — subscriptions + ENTITLEMENTS over a pluggable
|
|
12
|
+
// provider. `plans` is the single source of tier→quota truth. The handlers
|
|
13
|
+
// call `requireEntitlement(...)`, which consumes the tenant's metered
|
|
14
|
+
// quota and fails typed `EntitlementExceeded` when it's exhausted — that
|
|
15
|
+
// typed failure IS the paywall signal the frontend upgrades on.
|
|
16
|
+
//
|
|
17
|
+
// The domain then ties them together: `projects.create` spends a `projects`
|
|
18
|
+
// quota (free = 3), and `invites.create` spends a `seats` quota (free = 1) — so
|
|
19
|
+
// the free plan runs out and upgrading to `pro` (via `billing.startCheckout`)
|
|
20
|
+
// lifts the caps. Boots ZERO-infra (`store: 'memory'`, `mock` billing provider,
|
|
21
|
+
// `memoryUserStore`); the README shows the production swaps.
|
|
22
|
+
import { defineEnv, envVar } from '@voltro/env'
|
|
23
|
+
import { authRoutesPlugin, memoryUserStore, voltroPasswordStrategy } from '@voltro/plugin-auth'
|
|
24
|
+
import { billingPlugin } from '@voltro/plugin-billing'
|
|
25
|
+
|
|
26
|
+
export const env = defineEnv({
|
|
27
|
+
LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
|
|
28
|
+
// HMAC key that signs + verifies the session cookie.
|
|
29
|
+
//
|
|
30
|
+
// `generate` means this project mints its OWN key: `voltro dev` writes a
|
|
31
|
+
// unique value into a gitignored `.env.local` on first boot. No value ships
|
|
32
|
+
// with the template — a shipped placeholder would be a signing key published
|
|
33
|
+
// to everyone who downloads it, and every session in every deployment built
|
|
34
|
+
// from it would be forgeable. Your DEPLOYMENT mints its own the same way (a
|
|
35
|
+
// missing secret is a hard boot failure in `serve`/`build`/`start`).
|
|
36
|
+
VOLTRO_SESSION_SECRET: envVar.secret({
|
|
37
|
+
generate: 'base64url',
|
|
38
|
+
description: 'HMAC key that signs and verifies session cookies.',
|
|
39
|
+
}),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// In-process user store — zero infra. `postgresUserStore({ sql })` for durable
|
|
43
|
+
// accounts (it manages its own `_voltro_auth_*` tables, auto-migrated).
|
|
44
|
+
const userStore = memoryUserStore()
|
|
45
|
+
|
|
46
|
+
export default {
|
|
47
|
+
type: 'api' as const,
|
|
48
|
+
name: '{{capProjectName}}{{capAppName}}',
|
|
49
|
+
store: 'memory' as const,
|
|
50
|
+
env,
|
|
51
|
+
plugins: [
|
|
52
|
+
// ── Auth ─────────────────────────────────────────────────────────────
|
|
53
|
+
authRoutesPlugin({
|
|
54
|
+
store: userStore,
|
|
55
|
+
// New sign-ups land in this tenant. Every table below carries `tenant()`,
|
|
56
|
+
// so the runtime AND-merges `tenantId = subject.tenantId` into reads —
|
|
57
|
+
// one tenant never sees another's projects or invites.
|
|
58
|
+
defaultTenantId: 'acme',
|
|
59
|
+
successRedirect: '/',
|
|
60
|
+
// `Secure` cookies are HTTPS-only, so OFF in dev (a localhost http page
|
|
61
|
+
// would silently drop the cookie) and ON in production — this is what
|
|
62
|
+
// makes the sign-in → session → authenticated-call loop work locally.
|
|
63
|
+
cookieSecure: process.env.NODE_ENV === 'production',
|
|
64
|
+
}),
|
|
65
|
+
// ── Billing ──────────────────────────────────────────────────────────
|
|
66
|
+
// `mock` is the in-memory provider (no Stripe key needed) — great for dev.
|
|
67
|
+
// `plans` is the single source of tier→quota truth: `free` caps projects at
|
|
68
|
+
// 3 and seats at 1; `pro` lifts both. Swap `provider: 'stripe'` (+
|
|
69
|
+
// STRIPE_SECRET_KEY) and real `priceId`s for production checkout.
|
|
70
|
+
billingPlugin({
|
|
71
|
+
provider: 'mock',
|
|
72
|
+
plans: {
|
|
73
|
+
free: { entitlements: { projects: 3, seats: 1 } },
|
|
74
|
+
pro: {
|
|
75
|
+
priceId: 'price_demo_pro',
|
|
76
|
+
entitlements: { projects: 'unlimited', seats: 10 },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
],
|
|
81
|
+
auth: {
|
|
82
|
+
// Resolves the session cookie the /auth routes mint into the request's
|
|
83
|
+
// Subject. Reads the SAME VOLTRO_SESSION_SECRET the routes sign with, so
|
|
84
|
+
// the signing and verifying sides cannot drift.
|
|
85
|
+
strategies: [voltroPasswordStrategy()],
|
|
86
|
+
},
|
|
87
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Schema for the {{projectName}} SaaS backend.
|
|
2
|
+
//
|
|
3
|
+
// Two tenant-scoped domain tables — `projects` and `invites`. The plugins
|
|
4
|
+
// contribute their OWN tables automatically (auth uses the in-process
|
|
5
|
+
// `memoryUserStore`; billing adds `_voltro_billing_*`), so you don't declare
|
|
6
|
+
// those here. `actors` + `tenants` are the framework's core tables the audit /
|
|
7
|
+
// tenant mixins reference.
|
|
8
|
+
import {
|
|
9
|
+
databaseHandle,
|
|
10
|
+
id,
|
|
11
|
+
table,
|
|
12
|
+
text,
|
|
13
|
+
timestamp,
|
|
14
|
+
type InferRow,
|
|
15
|
+
} from '@voltro/database'
|
|
16
|
+
import { tenant } from '@voltro/plugin-multitenancy'
|
|
17
|
+
|
|
18
|
+
// ---------- Core tables (required by the audit / tenant mixins) ----------
|
|
19
|
+
|
|
20
|
+
export const actors = table('actors', {
|
|
21
|
+
id: id(),
|
|
22
|
+
kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
|
|
23
|
+
displayName: text().nullable(),
|
|
24
|
+
createdAt: timestamp().default('now'),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const tenants = table('tenants', {
|
|
28
|
+
id: id(),
|
|
29
|
+
name: text(),
|
|
30
|
+
createdAt: timestamp().default('now'),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// ---------- Application tables ----------
|
|
34
|
+
|
|
35
|
+
// Creating a project spends the billing `projects` entitlement (free = 3) —
|
|
36
|
+
// see mutations/projects.create.
|
|
37
|
+
export const projects = table('projects', {
|
|
38
|
+
id: id({ prefix: 'proj' }),
|
|
39
|
+
name: text(),
|
|
40
|
+
})
|
|
41
|
+
// tenant() pulls audit() transitively → tenantId + createdAt / updatedAt /
|
|
42
|
+
// createdBy / updatedBy (auto-stamped by the runtime).
|
|
43
|
+
.with(tenant())
|
|
44
|
+
|
|
45
|
+
// Inviting a teammate spends the billing `seats` entitlement (free = 1) — see
|
|
46
|
+
// mutations/invites.create.
|
|
47
|
+
export const invites = table('invites', {
|
|
48
|
+
id: id({ prefix: 'inv' }),
|
|
49
|
+
email: text(),
|
|
50
|
+
status: text().oneOf(['pending', 'accepted', 'revoked']),
|
|
51
|
+
})
|
|
52
|
+
.with(tenant())
|
|
53
|
+
|
|
54
|
+
export type Project = InferRow<typeof projects>
|
|
55
|
+
export type Invite = InferRow<typeof invites>
|
|
56
|
+
|
|
57
|
+
export const database = databaseHandle({ actors, tenants, projects, invites })
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Effect-mode: gate on the `seats` entitlement (free = 1), then write the
|
|
2
|
+
// pending invite. Because the free plan grants a single seat, the FIRST invite
|
|
3
|
+
// after sign-up already trips `EntitlementExceeded` unless the tenant has
|
|
4
|
+
// upgraded / bought seats — which is exactly the seat-management moment the
|
|
5
|
+
// frontend's team page surfaces.
|
|
6
|
+
import { Effect } from 'effect'
|
|
7
|
+
import type { AppContext } from '@voltro/runtime'
|
|
8
|
+
import { EffectStore } from '@voltro/runtime'
|
|
9
|
+
import { requireEntitlement } from '@voltro/plugin-billing'
|
|
10
|
+
|
|
11
|
+
const execute = (input: { email: string }, ctx: AppContext) =>
|
|
12
|
+
Effect.gen(function* () {
|
|
13
|
+
yield* requireEntitlement(ctx, 'seats', 1)
|
|
14
|
+
|
|
15
|
+
const store = yield* EffectStore
|
|
16
|
+
const row = yield* store.insert('invites', { email: input.email, status: 'pending' })
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
id: row['id'] as string,
|
|
20
|
+
email: row['email'] as string,
|
|
21
|
+
status: row['status'] as string,
|
|
22
|
+
tenantId: row['tenantId'] as string,
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export default execute
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Invite a teammate — spends a `seats` entitlement. Same shape as
|
|
2
|
+
// projects.create: the billing plugin merges `EntitlementExceeded` into the
|
|
3
|
+
// wire-error union, so running out of seats surfaces typed and the frontend
|
|
4
|
+
// shows "buy more seats" (billing.changeSeats) instead of a generic error.
|
|
5
|
+
import { defineMutation } from '@voltro/protocol'
|
|
6
|
+
import { Schema } from 'effect'
|
|
7
|
+
|
|
8
|
+
export const createInvite = defineMutation({
|
|
9
|
+
name: 'invites.create',
|
|
10
|
+
target: { table: 'invites', op: 'insert' },
|
|
11
|
+
input: Schema.Struct({ email: Schema.NonEmptyString }),
|
|
12
|
+
output: Schema.Struct({
|
|
13
|
+
id: Schema.String,
|
|
14
|
+
email: Schema.String,
|
|
15
|
+
status: Schema.String,
|
|
16
|
+
tenantId: Schema.String,
|
|
17
|
+
}),
|
|
18
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Effect-mode so it can `yield*` the billing service the framework provides in
|
|
2
|
+
// the per-request stack. `requireEntitlement` consumes the tenant's metered
|
|
3
|
+
// `projects` quota (free = 3) and fails typed `EntitlementExceeded` when it's
|
|
4
|
+
// exhausted — BEFORE the row is written, so an over-quota create never leaves a
|
|
5
|
+
// partial write. Upgrading to `pro` (via `billing.startCheckout`) lifts the cap
|
|
6
|
+
// to `unlimited` and the same handler starts succeeding again.
|
|
7
|
+
import { Effect } from 'effect'
|
|
8
|
+
import type { AppContext } from '@voltro/runtime'
|
|
9
|
+
import { EffectStore } from '@voltro/runtime'
|
|
10
|
+
import { requireEntitlement } from '@voltro/plugin-billing'
|
|
11
|
+
|
|
12
|
+
const execute = (input: { name: string }, ctx: AppContext) =>
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
// 1. Billing gate — fails closed (typed EntitlementExceeded) when the
|
|
15
|
+
// tenant has no `projects` quota left. Runs BEFORE the write.
|
|
16
|
+
yield* requireEntitlement(ctx, 'projects', 1)
|
|
17
|
+
|
|
18
|
+
// 2. Write the row. `tenant()` auto-stamps tenantId + audit columns.
|
|
19
|
+
const store = yield* EffectStore
|
|
20
|
+
const row = yield* store.insert('projects', { name: input.name })
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
id: row['id'] as string,
|
|
24
|
+
name: row['name'] as string,
|
|
25
|
+
tenantId: row['tenantId'] as string,
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export default execute
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Create a project — the paywall in ONE handler. The billing plugin auto-merges
|
|
2
|
+
// its typed `EntitlementExceeded` (+ `BillingError`) into this procedure's
|
|
3
|
+
// wire-error union, so the client decodes the over-quota failure typed WITHOUT
|
|
4
|
+
// a manual `error:` declaration here. That typed failure is the paywall signal:
|
|
5
|
+
// the frontend catches `_tag === 'EntitlementExceeded'` and shows the upgrade CTA.
|
|
6
|
+
import { defineMutation } from '@voltro/protocol'
|
|
7
|
+
import { Schema } from 'effect'
|
|
8
|
+
|
|
9
|
+
export const createProject = defineMutation({
|
|
10
|
+
name: 'projects.create',
|
|
11
|
+
target: { table: 'projects', op: 'insert' },
|
|
12
|
+
input: Schema.Struct({ name: Schema.NonEmptyString }),
|
|
13
|
+
output: Schema.Struct({
|
|
14
|
+
id: Schema.String,
|
|
15
|
+
name: Schema.String,
|
|
16
|
+
tenantId: Schema.String,
|
|
17
|
+
}),
|
|
18
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@{{projectName}}/{{appName}}",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "voltro dev .",
|
|
8
|
+
"lint": "voltro doctor .",
|
|
9
|
+
"migrate": "voltro migrate",
|
|
10
|
+
"test": "voltro test",
|
|
11
|
+
"typecheck": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@effect/platform": "^0.97.0",
|
|
15
|
+
"@effect/rpc": "^0.76.0",
|
|
16
|
+
"@voltro/cli": "0.29.0",
|
|
17
|
+
"@voltro/database": "0.29.0",
|
|
18
|
+
"@voltro/env": "0.29.0",
|
|
19
|
+
"@voltro/plugin-auth": "0.29.0",
|
|
20
|
+
"@voltro/plugin-billing": "0.29.0",
|
|
21
|
+
"@voltro/plugin-multitenancy": "0.29.0",
|
|
22
|
+
"@voltro/protocol": "0.29.0",
|
|
23
|
+
"@voltro/runtime": "0.29.0",
|
|
24
|
+
"@voltro/sql-postgres": "0.29.0",
|
|
25
|
+
"effect": "^3.22.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@voltro/testing": "0.29.0",
|
|
29
|
+
"typescript": "^6.0.3",
|
|
30
|
+
"vitest": "^4.1.10"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AppContext } from '@voltro/runtime'
|
|
2
|
+
|
|
3
|
+
const execute = (_input: Record<string, never>, _ctx: AppContext) => ({
|
|
4
|
+
descriptor: {
|
|
5
|
+
table: 'invites' as const,
|
|
6
|
+
predicate: undefined,
|
|
7
|
+
order: [{ column: 'createdAt' as const, direction: 'desc' as const }],
|
|
8
|
+
take: 100,
|
|
9
|
+
skip: undefined,
|
|
10
|
+
projection: undefined,
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export default execute
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Streaming subscription of the tenant's teammate invites. Tenant-scoped like
|
|
2
|
+
// projects.list — read live from the frontend's team page with
|
|
3
|
+
// `useSubscription('app', 'invites.list')`.
|
|
4
|
+
import { defineQuery } from '@voltro/protocol'
|
|
5
|
+
import { Schema } from 'effect'
|
|
6
|
+
|
|
7
|
+
export const listInvites = defineQuery({
|
|
8
|
+
name: 'invites.list',
|
|
9
|
+
input: Schema.Struct({}),
|
|
10
|
+
output: Schema.Struct({
|
|
11
|
+
id: Schema.String,
|
|
12
|
+
email: Schema.String,
|
|
13
|
+
status: Schema.String,
|
|
14
|
+
tenantId: Schema.String,
|
|
15
|
+
createdAt: Schema.Date,
|
|
16
|
+
}),
|
|
17
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AppContext } from '@voltro/runtime'
|
|
2
|
+
|
|
3
|
+
const execute = (_input: Record<string, never>, _ctx: AppContext) => ({
|
|
4
|
+
descriptor: {
|
|
5
|
+
table: 'projects' as const,
|
|
6
|
+
predicate: undefined,
|
|
7
|
+
order: [{ column: 'createdAt' as const, direction: 'desc' as const }],
|
|
8
|
+
take: 100,
|
|
9
|
+
skip: undefined,
|
|
10
|
+
projection: undefined,
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export default execute
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Streaming subscription of the tenant's projects. Every change to `projects`
|
|
2
|
+
// for the caller's tenant lands as a delta — the frontend upgrades its first
|
|
3
|
+
// paint to live with `useSubscription('app', 'projects.list')`. The runtime
|
|
4
|
+
// AND-merges the tenant predicate (the table carries `tenant()`), so this never
|
|
5
|
+
// leaks across tenants — no manual `eq('tenantId', ...)` needed.
|
|
6
|
+
import { defineQuery } from '@voltro/protocol'
|
|
7
|
+
import { Schema } from 'effect'
|
|
8
|
+
|
|
9
|
+
export const listProjects = defineQuery({
|
|
10
|
+
name: 'projects.list',
|
|
11
|
+
input: Schema.Struct({}),
|
|
12
|
+
output: Schema.Struct({
|
|
13
|
+
id: Schema.String,
|
|
14
|
+
name: Schema.String,
|
|
15
|
+
tenantId: Schema.String,
|
|
16
|
+
createdAt: Schema.Date,
|
|
17
|
+
}),
|
|
18
|
+
})
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "api-saas-starter",
|
|
3
|
+
"kind": "api",
|
|
4
|
+
"summary": "The whole SaaS loop wired end to end: @voltro/plugin-auth (sign-up/in → session cookie → typed Subject) + @voltro/plugin-billing (free/pro plans, entitlement paywall, seats, checkout) + a tenant-scoped projects + invites backend. projects.create is gated by the billing `projects` quota (4th trips EntitlementExceeded); invites.create consumes a `seats` entitlement. Pair with the frontend-saas web template for signup → paywall → app. Boots zero-infra.",
|
|
5
|
+
"tags": ["api", "saas", "auth", "billing", "entitlements", "paywall", "seats", "invites", "starter"]
|
|
6
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Unit tests with `@voltro/testing` — no database, no running server, no
|
|
2
|
+
// billing backend. The create executors run in Effect mode and `yield*` the
|
|
3
|
+
// billing service (`requireEntitlement`), which lives in the per-request plugin
|
|
4
|
+
// layer the unit harness does NOT wire — so we do not fake it. Instead we PIN
|
|
5
|
+
// each descriptor's wire contract (the shape the client codegen + the paywall
|
|
6
|
+
// branch depend on) and prove the `tenant()` scoping that keeps one SaaS
|
|
7
|
+
// tenant's rows invisible to another — the headline isolation guarantee.
|
|
8
|
+
// Run with `voltro test` (vitest).
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from 'vitest'
|
|
11
|
+
import { Schema } from 'effect'
|
|
12
|
+
import { makeTestContext, mockStore } from '@voltro/testing'
|
|
13
|
+
import { database } from '../database/schema' // registers projects / invites / actors / tenants
|
|
14
|
+
import { createProject } from '../mutations/projects.create.mutation'
|
|
15
|
+
import { createInvite } from '../mutations/invites.create.mutation'
|
|
16
|
+
|
|
17
|
+
describe('projects.create descriptor', () => {
|
|
18
|
+
it('decodes a valid input and rejects a blank name', () => {
|
|
19
|
+
const decode = Schema.decodeUnknownSync(createProject.input)
|
|
20
|
+
expect(decode({ name: 'Acme App' })).toEqual({ name: 'Acme App' })
|
|
21
|
+
// `name: Schema.NonEmptyString` — an empty string fails the wire decode.
|
|
22
|
+
expect(() => decode({ name: '' })).toThrow()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('encodes the output shape the client expects', () => {
|
|
26
|
+
const encode = Schema.encodeUnknownSync(createProject.output)
|
|
27
|
+
const wire = encode({ id: 'proj_1', name: 'Acme App', tenantId: 'acme' })
|
|
28
|
+
expect(wire).toEqual({ id: 'proj_1', name: 'Acme App', tenantId: 'acme' })
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('invites.create descriptor', () => {
|
|
33
|
+
it('decodes an email and rejects a blank one', () => {
|
|
34
|
+
const decode = Schema.decodeUnknownSync(createInvite.input)
|
|
35
|
+
expect(decode({ email: 'ada@example.com' })).toEqual({ email: 'ada@example.com' })
|
|
36
|
+
expect(() => decode({ email: '' })).toThrow()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('encodes the invite output shape', () => {
|
|
40
|
+
const encode = Schema.encodeUnknownSync(createInvite.output)
|
|
41
|
+
const wire = encode({ id: 'inv_1', email: 'ada@example.com', status: 'pending', tenantId: 'acme' })
|
|
42
|
+
expect(wire).toEqual({ id: 'inv_1', email: 'ada@example.com', status: 'pending', tenantId: 'acme' })
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('tenant isolation (REAL tenant scoping)', () => {
|
|
47
|
+
it('t2 never sees t1 projects', async () => {
|
|
48
|
+
const ctx = makeTestContext({
|
|
49
|
+
subject: { type: 'user', id: 'u1', tenantId: 't1' },
|
|
50
|
+
store: mockStore({ projects: [], invites: [] }),
|
|
51
|
+
})
|
|
52
|
+
// Raw insert through the mixin store — tenant() auto-stamps tenantId=t1.
|
|
53
|
+
const row = await ctx.store.insert('projects', { name: 'secret' })
|
|
54
|
+
expect(row['tenantId']).toBe('t1')
|
|
55
|
+
|
|
56
|
+
const seenByT2 = await ctx.withTenant('t2', (c) => c.store.query(database.projects.descriptor))
|
|
57
|
+
expect(seenByT2).toHaveLength(0)
|
|
58
|
+
// …and t1 still sees its own row.
|
|
59
|
+
const seenByT1 = await ctx.store.query(database.projects.descriptor)
|
|
60
|
+
expect(seenByT1).toHaveLength(1)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// `session.me` returns the caller's resolved identity — the proof the auth loop
|
|
2
|
+
// threads the Subject through to handlers, and the value the frontend's SSR gate
|
|
3
|
+
// reads to decide redirect-to-login vs render. `makeTestContext({ subject })`
|
|
4
|
+
// sets `ctx.request.subject` to exactly what the AuthMiddleware would resolve at
|
|
5
|
+
// runtime, so the executor is fully runnable with no cookie, no session store,
|
|
6
|
+
// no running server. Run with `voltro test` (vitest).
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from 'vitest'
|
|
9
|
+
import { makeTestContext } from '@voltro/testing'
|
|
10
|
+
import '../database/schema' // registers actors / tenants
|
|
11
|
+
import me from '../actions/me.action.server'
|
|
12
|
+
|
|
13
|
+
describe('session.me', () => {
|
|
14
|
+
it('reflects a signed-in user Subject', async () => {
|
|
15
|
+
const ctx = makeTestContext({
|
|
16
|
+
subject: { type: 'user', id: 'user_1', tenantId: 'acme' },
|
|
17
|
+
})
|
|
18
|
+
const identity = await me({}, ctx)
|
|
19
|
+
|
|
20
|
+
expect(identity.type).toBe('user')
|
|
21
|
+
expect(identity.id).toBe('user_1')
|
|
22
|
+
expect(identity.tenantId).toBe('acme')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns an anonymous identity with null id/tenant when no session is present', async () => {
|
|
26
|
+
const ctx = makeTestContext()
|
|
27
|
+
const identity = await me({}, ctx)
|
|
28
|
+
|
|
29
|
+
expect(identity.type).toBe('anonymous')
|
|
30
|
+
expect(identity.id).toBeNull()
|
|
31
|
+
expect(identity.tenantId).toBeNull()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -13,17 +13,17 @@
|
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@effect/platform": "^0.97.0",
|
|
15
15
|
"@effect/rpc": "^0.76.0",
|
|
16
|
-
"@voltro/cli": "0.
|
|
17
|
-
"@voltro/database": "0.
|
|
18
|
-
"@voltro/env": "0.
|
|
19
|
-
"@voltro/plugin-multitenancy": "0.
|
|
20
|
-
"@voltro/plugin-search": "0.
|
|
21
|
-
"@voltro/protocol": "0.
|
|
22
|
-
"@voltro/runtime": "0.
|
|
16
|
+
"@voltro/cli": "0.29.0",
|
|
17
|
+
"@voltro/database": "0.29.0",
|
|
18
|
+
"@voltro/env": "0.29.0",
|
|
19
|
+
"@voltro/plugin-multitenancy": "0.29.0",
|
|
20
|
+
"@voltro/plugin-search": "0.29.0",
|
|
21
|
+
"@voltro/protocol": "0.29.0",
|
|
22
|
+
"@voltro/runtime": "0.29.0",
|
|
23
23
|
"effect": "^3.22.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@voltro/testing": "0.
|
|
26
|
+
"@voltro/testing": "0.29.0",
|
|
27
27
|
"typescript": "^6.0.3",
|
|
28
28
|
"vitest": "^4.1.10"
|
|
29
29
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# {{capProjectName}} {{capAppName}} — status-page backend
|
|
2
|
+
|
|
3
|
+
The backend for a public **status page**: live queries anyone can subscribe to,
|
|
4
|
+
plus operator-only mutations to declare and resolve incidents. Boots
|
|
5
|
+
**zero-infra** (`store: 'memory'`). Pair it with the **frontend-status** web
|
|
6
|
+
template:
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
voltro init my-status --api=api-status --web=frontend-status
|
|
10
|
+
voltro dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## The asymmetry it encodes
|
|
14
|
+
|
|
15
|
+
A status page is read by everyone and written by a few — so:
|
|
16
|
+
|
|
17
|
+
- **Reads are PUBLIC.** `incidents.live`, `updates.list`, `components.list`
|
|
18
|
+
carry **no guard**, so an anonymous browser can `useSubscription('app',
|
|
19
|
+
'incidents.live')` and the timeline updates live.
|
|
20
|
+
- **Writes are OPERATOR-ONLY.** Every mutation declares
|
|
21
|
+
`guards: [{ scope: 'status:write' }]`, enforced by `@voltro/plugin-rbac` in the
|
|
22
|
+
dispatch spine before the executor. The demo resolver (`authz.ts`) grants the
|
|
23
|
+
`operator` role to the `ops` tenant; everyone else — anonymous included — is
|
|
24
|
+
denied.
|
|
25
|
+
|
|
26
|
+
## Surface
|
|
27
|
+
|
|
28
|
+
| Tag | Kind | Guard |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `incidents.live` | query (stream) | — public |
|
|
31
|
+
| `updates.list` | query (stream) | — public |
|
|
32
|
+
| `components.list` | query (stream) | — public |
|
|
33
|
+
| `incidents.create` | mutation | `status:write` |
|
|
34
|
+
| `incidents.update` | mutation | `status:write` |
|
|
35
|
+
| `incidents.resolve` | mutation | `status:write` |
|
|
36
|
+
| `components.create` | mutation | `status:write` |
|
|
37
|
+
|
|
38
|
+
## Try it (curl)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
voltro dev .
|
|
42
|
+
|
|
43
|
+
# A public read — no auth (anyone can watch)
|
|
44
|
+
curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
|
|
45
|
+
-H 'content-type: application/json' \
|
|
46
|
+
-d '{"tag":"incidents.live","input":{}}'
|
|
47
|
+
|
|
48
|
+
# An operator write — needs the `ops` tenant (demo resolver → operator role)
|
|
49
|
+
curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
|
|
50
|
+
-H 'content-type: application/json' -H 'x-tenant: ops' \
|
|
51
|
+
-d '{"tag":"incidents.create","input":{"title":"API degraded","impact":"major"}}'
|
|
52
|
+
|
|
53
|
+
# The SAME write without the operator tenant → typed ScopeError
|
|
54
|
+
curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
|
|
55
|
+
-H 'content-type: application/json' \
|
|
56
|
+
-d '{"tag":"incidents.create","input":{"title":"nope","impact":"minor"}}'
|
|
57
|
+
# → { "ok": false, "error": { "_tag": "ScopeError", "required": "status:write" } }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Files
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
app.config.ts rbacPlugin (roles + demo resolver)
|
|
64
|
+
authz.ts the role→scope map (status:write) — one source of truth
|
|
65
|
+
database/schema.ts components / incidents / incident_updates (PUBLIC, not tenant-scoped)
|
|
66
|
+
queries/ incidents.live / updates.list / components.list — ungated streams
|
|
67
|
+
mutations/ incidents.create|update|resolve, components.create — status:write
|
|
68
|
+
tests/ the public-read / operator-write asymmetry + descriptor pins
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Going to production
|
|
72
|
+
|
|
73
|
+
- **Real operator auth.** Replace the demo `resolveRoles` with subject metadata
|
|
74
|
+
or a DB lookup, and pair this api with `api-auth` / `api-saas-starter` so
|
|
75
|
+
operators actually sign in. The rbac gate is already in place — you only swap
|
|
76
|
+
where the roles come from.
|
|
77
|
+
- **Durable store.** `store: 'postgres'` (or `sqlite`) so incident history
|
|
78
|
+
survives a restart.
|
|
79
|
+
- **Notify subscribers** on open/resolve with `@voltro/plugin-mail` (opt-in).
|
|
80
|
+
|
|
81
|
+
## Anti-patterns
|
|
82
|
+
|
|
83
|
+
- **Tenant-scoping the public tables.** Adding `tenant()` to `incidents` would
|
|
84
|
+
make an anonymous visitor (no tenant) see nothing. Public reads stay
|
|
85
|
+
unscoped; the rbac guard on the writes is the boundary.
|
|
86
|
+
- **A write-only authz model.** These reads are intentionally ungated because
|
|
87
|
+
they're public — but if you ever gate a read, gate it with `guards:` on the
|
|
88
|
+
query (enforced on open AND every delivery), not a check inside the handler.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Backend for the {{projectName}} public status page. Read by `voltro dev`.
|
|
2
|
+
//
|
|
3
|
+
// The shape of a status page is asymmetric, and this config encodes it:
|
|
4
|
+
// • READS are PUBLIC — `incidents.live` / `updates.list` / `components.list`
|
|
5
|
+
// carry NO guard, so an anonymous visitor's browser can subscribe and see
|
|
6
|
+
// the live timeline. (A status page that requires a login is useless during
|
|
7
|
+
// an outage.)
|
|
8
|
+
// • WRITES are OPERATOR-ONLY — every mutation declares a `status:write` guard.
|
|
9
|
+
// `rbacPlugin` resolves each caller's ROLES to SCOPES and the framework
|
|
10
|
+
// enforces the guard in the dispatch spine, before the executor. `voltro
|
|
11
|
+
// check` can see the guard statically.
|
|
12
|
+
//
|
|
13
|
+
// The role map lives in `authz.ts`. Config-only — no external infra.
|
|
14
|
+
import { defineEnv, envVar } from '@voltro/env'
|
|
15
|
+
import { rbacPlugin } from '@voltro/plugin-rbac'
|
|
16
|
+
import { demoRolesForTenant, roles } from './authz'
|
|
17
|
+
|
|
18
|
+
export const env = defineEnv({
|
|
19
|
+
LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
type: 'api' as const,
|
|
24
|
+
name: '{{capProjectName}}{{capAppName}}',
|
|
25
|
+
store: 'memory' as const,
|
|
26
|
+
plugins: [
|
|
27
|
+
rbacPlugin({
|
|
28
|
+
roles,
|
|
29
|
+
// Demo: `ops` tenant → operator. Swap for a real resolver (subject
|
|
30
|
+
// metadata / a DB lookup) when you wire operator auth — pair this api
|
|
31
|
+
// with the api-auth / api-saas-starter auth backend.
|
|
32
|
+
resolveRoles: (subject) => demoRolesForTenant(subject.tenantId),
|
|
33
|
+
}),
|
|
34
|
+
],
|
|
35
|
+
env,
|
|
36
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The status page's authorization vocabulary, in ONE place — split out of
|
|
2
|
+
// app.config.ts so the tests build the real plugin from the real role map (a
|
|
3
|
+
// test that redeclares the roles only proves its own copy is self-consistent).
|
|
4
|
+
//
|
|
5
|
+
// A status page has exactly two kinds of caller: the PUBLIC (anonymous
|
|
6
|
+
// visitors, who only READ — the live queries carry no guard) and OPERATORS
|
|
7
|
+
// (who post incidents). So there is one write scope, `status:write`, held by
|
|
8
|
+
// the `operator` role; `admin` gets the wildcard bypass.
|
|
9
|
+
//
|
|
10
|
+
// This map is ALSO the app's declared scope vocabulary: `rbacPlugin` publishes
|
|
11
|
+
// its union, and `voltro check` errors on any descriptor guard requiring a
|
|
12
|
+
// scope that appears nowhere here — so a guard and its grant can't silently
|
|
13
|
+
// drift out of sync.
|
|
14
|
+
export const roles = {
|
|
15
|
+
operator: ['status:write'],
|
|
16
|
+
admin: ['*'],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* DEMO role resolver — grants `operator` to callers in the `ops` tenant so
|
|
21
|
+
* every operator flow is reachable with an `x-tenant: ops` header and zero auth
|
|
22
|
+
* setup. Everyone else (including anonymous visitors) gets NO role, so the
|
|
23
|
+
* ungated read queries still work but every `status:write` mutation is denied.
|
|
24
|
+
*
|
|
25
|
+
* PRODUCTION reads the caller's REAL roles: the default resolver reads
|
|
26
|
+
* `subject.metadata.roles` (set by your auth strategy — pair this with the
|
|
27
|
+
* api-auth / api-saas-starter auth wiring), or do a DB lookup here. If this
|
|
28
|
+
* throws, rbac degrades to the subject's own scopes — it never grants on
|
|
29
|
+
* failure.
|
|
30
|
+
*/
|
|
31
|
+
export const demoRolesForTenant = (
|
|
32
|
+
tenantId: string | null | undefined,
|
|
33
|
+
): ReadonlyArray<string> => (tenantId === 'ops' ? ['operator'] : [])
|