@voltro/cli 0.28.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 +278 -0
- package/THIRD-PARTY-NOTICES.md +88 -2
- package/dist/{apiBuild-eUM32r1u.js → apiBuild-BESZbTjA.js} +3 -3
- 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-CSkrUI1h.js → commands-82BDEktg.js} +6803 -8191
- package/dist/dbCommand-CaIVTp-a.js +2 -0
- package/dist/{dbCommand-CpYgmSw4.js → dbCommand-bCBbk6tz.js} +2 -2
- package/dist/{dev-CEZwJhmb.js → dev-Cg4BKLmi.js} +1801 -1612
- package/dist/{dev-DlBWWnJQ.js → dev-DeEJj5T4.js} +1 -1
- 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/serveCommand-BXl8mhZD.js +1489 -0
- package/dist/serveEntry.js +2 -2
- 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/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/whats-new.md +162 -181
- 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-DgBS9ayv.js +0 -2
- package/dist/dbCommand-DvguqlzF.js +0 -2
- 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-ZTn-dPFa.js +0 -1425
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// `content.list` — a LIVE, tenant-scoped list of one content type's rows. The
|
|
2
|
+
// editor subscribes with `useSubscription('cms', 'content.list', { type })`; a
|
|
3
|
+
// publish/save wakes the subscription so the list updates without a refetch.
|
|
4
|
+
// `status` picks the table: 'draft' (the editor's working set, the default) or
|
|
5
|
+
// 'published' (what consumers read).
|
|
6
|
+
//
|
|
7
|
+
// The output pins only the columns a list view needs — the runtime drops the
|
|
8
|
+
// rest (`Schema.Struct` ignores excess), so ONE query shape serves every content
|
|
9
|
+
// type even though their full rows differ. `title`/`slug` are optional so a type
|
|
10
|
+
// without them still decodes.
|
|
11
|
+
import { defineQuery } from '@voltro/protocol'
|
|
12
|
+
import { Schema } from 'effect'
|
|
13
|
+
|
|
14
|
+
export const listContent = defineQuery({
|
|
15
|
+
name: 'content.list',
|
|
16
|
+
input: Schema.Struct({
|
|
17
|
+
type: Schema.NonEmptyString,
|
|
18
|
+
status: Schema.optional(Schema.Literal('draft', 'published')),
|
|
19
|
+
}),
|
|
20
|
+
output: Schema.Struct({
|
|
21
|
+
id: Schema.String,
|
|
22
|
+
status: Schema.String,
|
|
23
|
+
title: Schema.optional(Schema.String),
|
|
24
|
+
slug: Schema.optional(Schema.String),
|
|
25
|
+
updatedAt: Schema.Date,
|
|
26
|
+
}),
|
|
27
|
+
})
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "api-cms",
|
|
3
|
+
"kind": "api",
|
|
4
|
+
"summary": "A headless CMS backend composed from @voltro/cms: content types declared as code (defineContentType + the Schema field DSL) compile to draft/published tables, the write pipeline (saveDraft → derive+validate, publish/unpublish) drives the lifecycle, and a typed rpc surface (content.list/get/types/saveDraft/publish/unpublish) feeds an editor. Auth via @voltro/plugin-auth; tenant-scoped content. Pair with the frontend-cms editor web template. Boots zero-infra.",
|
|
5
|
+
"tags": ["api", "cms", "content", "headless", "drafts", "publishing", "editor", "auth"]
|
|
6
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Descriptor + executor contract pins — the shapes the client codegen and the
|
|
2
|
+
// editor depend on. No database, no running server. Run with `voltro test`.
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import { Schema } from 'effect'
|
|
6
|
+
import { makeTestContext } from '@voltro/testing'
|
|
7
|
+
import '../database/schema' // registers the core + derived content tables
|
|
8
|
+
import { saveDraft } from '../mutations/content.saveDraft.mutation'
|
|
9
|
+
import { listContent } from '../queries/content.list.query'
|
|
10
|
+
import contentTypesExecute from '../actions/content.types.action.server'
|
|
11
|
+
|
|
12
|
+
describe('content.saveDraft descriptor', () => {
|
|
13
|
+
it('decodes an input carrying arbitrary per-type values', () => {
|
|
14
|
+
const decode = Schema.decodeUnknownSync(saveDraft.input)
|
|
15
|
+
const input = { type: 'blogPost', values: { title: 'Hi', body: 'x' } }
|
|
16
|
+
expect(decode(input)).toEqual(input)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('encodes both arms of the result union', () => {
|
|
20
|
+
const encode = Schema.encodeUnknownSync(saveDraft.output)
|
|
21
|
+
expect(encode({ ok: true, id: 'blogPost_1', status: 'draft' })).toEqual({ ok: true, id: 'blogPost_1', status: 'draft' })
|
|
22
|
+
const fail = { ok: false, violations: [{ field: 'title', rule: 'required', message: 'missing' }] }
|
|
23
|
+
expect(encode(fail)).toEqual(fail)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('content.list descriptor', () => {
|
|
28
|
+
it('accepts a type + optional status and pins the list projection shape', () => {
|
|
29
|
+
const decodeIn = Schema.decodeUnknownSync(listContent.input)
|
|
30
|
+
expect(decodeIn({ type: 'blogPost', status: 'draft' })).toEqual({ type: 'blogPost', status: 'draft' })
|
|
31
|
+
// The output carries only what a list view needs — id/status/title?/slug?/
|
|
32
|
+
// updatedAt; the runtime drops a row's other columns against this shape.
|
|
33
|
+
const encode = Schema.encodeUnknownSync(listContent.output)
|
|
34
|
+
const wire = encode({ id: 'p1', status: 'published', title: 'T', slug: 't', updatedAt: new Date(0) })
|
|
35
|
+
expect(wire.id).toBe('p1')
|
|
36
|
+
expect(wire.status).toBe('published')
|
|
37
|
+
expect(wire.title).toBe('T')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('content.types executor', () => {
|
|
42
|
+
it('projects the registered types to a wire-safe, order-preserving descriptor', async () => {
|
|
43
|
+
const ctx = makeTestContext({ subject: { type: 'user', id: 'u1', tenantId: 'acme' } })
|
|
44
|
+
const out = await contentTypesExecute({}, ctx)
|
|
45
|
+
|
|
46
|
+
const names = out.types.map((t) => t.name)
|
|
47
|
+
expect(names).toContain('blogPost')
|
|
48
|
+
expect(names).toContain('page')
|
|
49
|
+
|
|
50
|
+
const blog = out.types.find((t) => t.name === 'blogPost')!
|
|
51
|
+
const fieldNames = blog.fields.map((f) => f.name)
|
|
52
|
+
expect(fieldNames).toEqual(['title', 'slug', 'body', 'excerpt', 'publishedAt'])
|
|
53
|
+
// The slug field is a string with a richer widget than plain text is fine —
|
|
54
|
+
// and its derived transform (a function) is NOT on the wire shape.
|
|
55
|
+
const slug = blog.fields.find((f) => f.name === 'slug')!
|
|
56
|
+
expect(slug.kind).toBe('string')
|
|
57
|
+
expect('transform' in slug).toBe(false)
|
|
58
|
+
// The Literal `layout` on `page` carries its value set for the <select>.
|
|
59
|
+
const page = out.types.find((t) => t.name === 'page')!
|
|
60
|
+
const layout = page.fields.find((f) => f.name === 'layout')!
|
|
61
|
+
expect(layout.editorHint).toBe('select')
|
|
62
|
+
expect(layout.literals).toEqual(['default', 'wide', 'landing'])
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The CMS write pipeline against the REAL runtime store spine — an
|
|
2
|
+
// InMemoryDataStore wrapped by `wrapStoreWithMixinBehaviour` with a registry
|
|
3
|
+
// built from the SAME derived tables the app registers. That's what makes these
|
|
4
|
+
// assertions honest: derivation + validation come from @voltro/cms, and
|
|
5
|
+
// tenant-scoping/stamping come from the spine, not a mock. Run with `voltro test`.
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest'
|
|
8
|
+
import {
|
|
9
|
+
InMemoryDataStore,
|
|
10
|
+
makeSchemaRegistry,
|
|
11
|
+
wrapStoreWithMixinBehaviour,
|
|
12
|
+
type RegistryTableLike,
|
|
13
|
+
} from '@voltro/runtime'
|
|
14
|
+
import { ContentValidationFailed, publish, saveDraft } from '@voltro/cms'
|
|
15
|
+
import { blogPost } from '../content'
|
|
16
|
+
import { blogEntities, pageEntities } from '../database/schema'
|
|
17
|
+
|
|
18
|
+
// Registry over the real derived tables → hasTenant/hasAudit/idScheme reflect a
|
|
19
|
+
// booted app exactly.
|
|
20
|
+
const registry = makeSchemaRegistry([
|
|
21
|
+
blogEntities.draft, blogEntities.published,
|
|
22
|
+
pageEntities.draft, pageEntities.published,
|
|
23
|
+
] as ReadonlyArray<RegistryTableLike>)
|
|
24
|
+
|
|
25
|
+
// A request-scoped store for one tenant over a shared underlying store.
|
|
26
|
+
const storeFor = (underlying: InMemoryDataStore, tenantId: string) =>
|
|
27
|
+
wrapStoreWithMixinBehaviour(underlying, {
|
|
28
|
+
subject: { type: 'user', id: 'usr_editor', tenantId },
|
|
29
|
+
schemaRegistry: registry,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const validInput = {
|
|
33
|
+
title: 'Hello World',
|
|
34
|
+
body: { type: 'doc', content: [{ type: 'paragraph' }] },
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('saveDraft → derive + validate + write', () => {
|
|
38
|
+
it('derives the slug from the title, stamps status/tenant, generates an id', async () => {
|
|
39
|
+
const store = storeFor(new InMemoryDataStore(), 'tnt_A')
|
|
40
|
+
const draft = await saveDraft(store, blogPost, validInput)
|
|
41
|
+
|
|
42
|
+
expect(draft['slug']).toBe('hello-world') // derivedFrom('title', slugify)
|
|
43
|
+
expect(draft['status']).toBe('draft')
|
|
44
|
+
expect(draft['tenantId']).toBe('tnt_A') // stamped by the spine
|
|
45
|
+
expect(typeof draft['id']).toBe('string')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('rejects an invalid row with ContentValidationFailed carrying per-field violations', async () => {
|
|
49
|
+
const store = storeFor(new InMemoryDataStore(), 'tnt_A')
|
|
50
|
+
// Missing required `title` (so the derived slug is empty → pattern fails too).
|
|
51
|
+
await expect(saveDraft(store, blogPost, { body: 'x' })).rejects.toBeInstanceOf(ContentValidationFailed)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('publish → copy draft to published', () => {
|
|
56
|
+
it('publishes a draft and the published row keeps the same id + slug', async () => {
|
|
57
|
+
const store = storeFor(new InMemoryDataStore(), 'tnt_A')
|
|
58
|
+
const draft = await saveDraft(store, blogPost, validInput)
|
|
59
|
+
|
|
60
|
+
const published = await publish(store, blogPost, draft['id'] as string)
|
|
61
|
+
expect(published['id']).toBe(draft['id'])
|
|
62
|
+
expect(published['slug']).toBe('hello-world')
|
|
63
|
+
expect(published['status']).toBe('published')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('tenant isolation (REAL scoping)', () => {
|
|
68
|
+
it("tenant B never sees tenant A's published content", async () => {
|
|
69
|
+
const underlying = new InMemoryDataStore()
|
|
70
|
+
const a = storeFor(underlying, 'tnt_A')
|
|
71
|
+
const b = storeFor(underlying, 'tnt_B')
|
|
72
|
+
|
|
73
|
+
const draft = await saveDraft(a, blogPost, validInput)
|
|
74
|
+
await publish(a, blogPost, draft['id'] as string)
|
|
75
|
+
|
|
76
|
+
const seenByA = await a.query({
|
|
77
|
+
table: 'blogPost_published', predicate: undefined, order: [], take: 10, skip: undefined, projection: undefined,
|
|
78
|
+
})
|
|
79
|
+
const seenByB = await b.query({
|
|
80
|
+
table: 'blogPost_published', predicate: undefined, order: [], take: 10, skip: undefined, projection: undefined,
|
|
81
|
+
})
|
|
82
|
+
expect(seenByA).toHaveLength(1)
|
|
83
|
+
expect(seenByB).toHaveLength(0)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -12,17 +12,17 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.97.0",
|
|
14
14
|
"@effect/rpc": "^0.76.0",
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-governance": "0.
|
|
19
|
-
"@voltro/plugin-multitenancy": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/cli": "0.29.0",
|
|
16
|
+
"@voltro/database": "0.29.0",
|
|
17
|
+
"@voltro/env": "0.29.0",
|
|
18
|
+
"@voltro/plugin-governance": "0.29.0",
|
|
19
|
+
"@voltro/plugin-multitenancy": "0.29.0",
|
|
20
|
+
"@voltro/protocol": "0.29.0",
|
|
21
|
+
"@voltro/runtime": "0.29.0",
|
|
22
22
|
"effect": "^3.22.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.
|
|
25
|
+
"@voltro/testing": "0.29.0",
|
|
26
26
|
"typescript": "^6.0.3",
|
|
27
27
|
"vitest": "^4.1.10"
|
|
28
28
|
}
|
|
@@ -12,17 +12,17 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.97.0",
|
|
14
14
|
"@effect/rpc": "^0.76.0",
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
21
|
-
"@voltro/workflow": "0.
|
|
15
|
+
"@voltro/cli": "0.29.0",
|
|
16
|
+
"@voltro/database": "0.29.0",
|
|
17
|
+
"@voltro/env": "0.29.0",
|
|
18
|
+
"@voltro/plugin-multitenancy": "0.29.0",
|
|
19
|
+
"@voltro/protocol": "0.29.0",
|
|
20
|
+
"@voltro/runtime": "0.29.0",
|
|
21
|
+
"@voltro/workflow": "0.29.0",
|
|
22
22
|
"effect": "^3.22.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.
|
|
25
|
+
"@voltro/testing": "0.29.0",
|
|
26
26
|
"typescript": "^6.0.3",
|
|
27
27
|
"vitest": "^4.1.10"
|
|
28
28
|
}
|
|
@@ -13,18 +13,18 @@
|
|
|
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-flags": "0.
|
|
20
|
-
"@voltro/plugin-multitenancy": "0.
|
|
21
|
-
"@voltro/protocol": "0.
|
|
22
|
-
"@voltro/runtime": "0.
|
|
23
|
-
"@voltro/sql-postgres": "0.
|
|
16
|
+
"@voltro/cli": "0.29.0",
|
|
17
|
+
"@voltro/database": "0.29.0",
|
|
18
|
+
"@voltro/env": "0.29.0",
|
|
19
|
+
"@voltro/plugin-flags": "0.29.0",
|
|
20
|
+
"@voltro/plugin-multitenancy": "0.29.0",
|
|
21
|
+
"@voltro/protocol": "0.29.0",
|
|
22
|
+
"@voltro/runtime": "0.29.0",
|
|
23
|
+
"@voltro/sql-postgres": "0.29.0",
|
|
24
24
|
"effect": "^3.22.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@voltro/testing": "0.
|
|
27
|
+
"@voltro/testing": "0.29.0",
|
|
28
28
|
"typescript": "^6.0.3",
|
|
29
29
|
"vitest": "^4.1.10"
|
|
30
30
|
}
|
|
@@ -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-audit": "0.
|
|
20
|
-
"@voltro/plugin-governance": "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-audit": "0.29.0",
|
|
20
|
+
"@voltro/plugin-governance": "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
|
}
|
|
@@ -12,17 +12,17 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.97.0",
|
|
14
14
|
"@effect/rpc": "^0.76.0",
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/kv": "0.
|
|
19
|
-
"@voltro/plugin-multitenancy": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/cli": "0.29.0",
|
|
16
|
+
"@voltro/database": "0.29.0",
|
|
17
|
+
"@voltro/env": "0.29.0",
|
|
18
|
+
"@voltro/kv": "0.29.0",
|
|
19
|
+
"@voltro/plugin-multitenancy": "0.29.0",
|
|
20
|
+
"@voltro/protocol": "0.29.0",
|
|
21
|
+
"@voltro/runtime": "0.29.0",
|
|
22
22
|
"effect": "^3.22.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.
|
|
25
|
+
"@voltro/testing": "0.29.0",
|
|
26
26
|
"typescript": "^6.0.3",
|
|
27
27
|
"vitest": "^4.1.10"
|
|
28
28
|
}
|
|
@@ -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-moderation": "0.
|
|
20
|
-
"@voltro/plugin-multitenancy": "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-moderation": "0.29.0",
|
|
20
|
+
"@voltro/plugin-multitenancy": "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
|
}
|
|
@@ -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/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/plugin-prometheus": "0.
|
|
20
|
-
"@voltro/plugin-sentry": "0.
|
|
21
|
-
"@voltro/protocol": "0.
|
|
22
|
-
"@voltro/runtime": "0.
|
|
16
|
+
"@voltro/cli": "0.29.0",
|
|
17
|
+
"@voltro/database": "0.29.0",
|
|
18
|
+
"@voltro/plugin-multitenancy": "0.29.0",
|
|
19
|
+
"@voltro/plugin-prometheus": "0.29.0",
|
|
20
|
+
"@voltro/plugin-sentry": "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
|
}
|
|
@@ -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-ratelimit": "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-ratelimit": "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
|
}
|
|
@@ -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-rbac": "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-rbac": "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
|
}
|
|
@@ -13,16 +13,16 @@
|
|
|
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/plugin-openapi": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
21
|
-
"@voltro/sql-postgres": "0.
|
|
16
|
+
"@voltro/cli": "0.29.0",
|
|
17
|
+
"@voltro/database": "0.29.0",
|
|
18
|
+
"@voltro/plugin-openapi": "0.29.0",
|
|
19
|
+
"@voltro/protocol": "0.29.0",
|
|
20
|
+
"@voltro/runtime": "0.29.0",
|
|
21
|
+
"@voltro/sql-postgres": "0.29.0",
|
|
22
22
|
"effect": "^3.22.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.
|
|
25
|
+
"@voltro/testing": "0.29.0",
|
|
26
26
|
"typescript": "^6.0.3",
|
|
27
27
|
"vitest": "^4.1.10"
|
|
28
28
|
}
|
|
@@ -13,20 +13,20 @@
|
|
|
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/plugin-analytics-postgres": "0.
|
|
19
|
-
"@voltro/plugin-billing": "0.
|
|
20
|
-
"@voltro/plugin-multitenancy": "0.
|
|
21
|
-
"@voltro/plugin-notifications": "0.
|
|
22
|
-
"@voltro/plugin-presence": "0.
|
|
23
|
-
"@voltro/protocol": "0.
|
|
24
|
-
"@voltro/runtime": "0.
|
|
25
|
-
"@voltro/sql-postgres": "0.
|
|
16
|
+
"@voltro/cli": "0.29.0",
|
|
17
|
+
"@voltro/database": "0.29.0",
|
|
18
|
+
"@voltro/plugin-analytics-postgres": "0.29.0",
|
|
19
|
+
"@voltro/plugin-billing": "0.29.0",
|
|
20
|
+
"@voltro/plugin-multitenancy": "0.29.0",
|
|
21
|
+
"@voltro/plugin-notifications": "0.29.0",
|
|
22
|
+
"@voltro/plugin-presence": "0.29.0",
|
|
23
|
+
"@voltro/protocol": "0.29.0",
|
|
24
|
+
"@voltro/runtime": "0.29.0",
|
|
25
|
+
"@voltro/sql-postgres": "0.29.0",
|
|
26
26
|
"effect": "^3.22.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@voltro/testing": "0.
|
|
29
|
+
"@voltro/testing": "0.29.0",
|
|
30
30
|
"typescript": "^6.0.3",
|
|
31
31
|
"vitest": "^4.1.10"
|
|
32
32
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# {{capProjectName}} {{capAppName}} — the full SaaS backend
|
|
2
|
+
|
|
3
|
+
The whole SaaS loop wired end to end in ONE api — **sign up → hit a paywall →
|
|
4
|
+
upgrade → invite a teammate** — composing two shipped plugins that are normally
|
|
5
|
+
wired separately, plus a small tenant-scoped domain. Boots **zero-infra**
|
|
6
|
+
(`store: 'memory'`, `mock` billing provider, in-process users).
|
|
7
|
+
|
|
8
|
+
Pair it with the **frontend-saas** web template for the dashboard UI:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
voltro init my-saas --api=api-saas-starter --web=frontend-saas
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## What it composes
|
|
15
|
+
|
|
16
|
+
- **Auth** ([`@voltro/plugin-auth`](https://voltro.cloud/docs/plugins/auth)) —
|
|
17
|
+
password **sign-up / sign-in / sign-out** over an HttpOnly **session cookie**,
|
|
18
|
+
and `voltroPasswordStrategy` resolving that cookie into a typed `Subject` on
|
|
19
|
+
every rpc/ws call. `session.me` returns it (the frontend's SSR gate reads it).
|
|
20
|
+
- **Billing** ([`@voltro/plugin-billing`](https://voltro.cloud/docs/plugins/overview)) —
|
|
21
|
+
subscriptions + **entitlements** over the in-memory `mock` provider. `plans`
|
|
22
|
+
is the single source of tier→quota truth: `free` caps `projects` at 3 and
|
|
23
|
+
`seats` at 1; `pro` lifts both.
|
|
24
|
+
- **Domain** — tenant-scoped `projects` + `invites`. `projects.create` spends
|
|
25
|
+
the `projects` quota; `invites.create` spends the `seats` quota. Over quota →
|
|
26
|
+
typed **`EntitlementExceeded`** (the paywall signal), the row never written.
|
|
27
|
+
|
|
28
|
+
## The loop
|
|
29
|
+
|
|
30
|
+
| Step | Call | Result |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| Sign up | `POST /auth/sign-up` | session cookie; lands in tenant `acme` on the `free` plan |
|
|
33
|
+
| Work | `projects.create` ×3 | ok |
|
|
34
|
+
| **Paywall** | `projects.create` (4th) | typed `EntitlementExceeded { entitlement: 'projects', limit: 3 }` |
|
|
35
|
+
| Upgrade | `billing.startCheckout` → `pro` | `projects` becomes `unlimited`, `seats` → 10 |
|
|
36
|
+
| Invite | `invites.create` | spends a `seats` entitlement |
|
|
37
|
+
| Buy seats | `billing.changeSeats` | raises the seat quantity |
|
|
38
|
+
|
|
39
|
+
`billing.*` (`subscription`, `startCheckout`, `changePlan`, `changeSeats`,
|
|
40
|
+
`invoices`) and `/auth/*` are contributed by the plugins — you don't author them.
|
|
41
|
+
|
|
42
|
+
## Run it
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
voltro dev .
|
|
46
|
+
# → http://localhost:4000 (rpc + /auth/* HTTP routes)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`voltro dev` mints a unique `VOLTRO_SESSION_SECRET` into a gitignored
|
|
50
|
+
`.env.local` on first boot — no secret ships with the template.
|
|
51
|
+
|
|
52
|
+
### Try the loop (curl)
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Sign up (CSRF-protected — grab a token first, keep the cookie jar)
|
|
56
|
+
curl -s -c jar.txt http://localhost:4000/auth/csrf # → { "csrfToken": "<csrf>" }
|
|
57
|
+
curl -s -b jar.txt -c jar.txt -X POST http://localhost:4000/auth/sign-up \
|
|
58
|
+
-H 'content-type: application/json' -H 'x-csrf-token: <csrf>' \
|
|
59
|
+
-d '{"email":"ada@example.com","password":"hunter2hunter2"}'
|
|
60
|
+
|
|
61
|
+
# Create projects — the free plan allows 3, the 4th trips the paywall
|
|
62
|
+
for i in 1 2 3 4; do
|
|
63
|
+
curl -s -b jar.txt -X POST http://localhost:4000/_voltro/inspect/invoke \
|
|
64
|
+
-H 'content-type: application/json' \
|
|
65
|
+
-d "{\"tag\":\"projects.create\",\"input\":{\"name\":\"Project $i\"}}"
|
|
66
|
+
done
|
|
67
|
+
# → the 4th: { "ok": false, "error": { "_tag": "EntitlementExceeded", "entitlement": "projects", "limit": 3, … } }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Files
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
app.config.ts authRoutesPlugin + voltroPasswordStrategy + billingPlugin (free/pro plans)
|
|
74
|
+
database/schema.ts core actors/tenants + tenant-scoped projects + invites
|
|
75
|
+
actions/me.action.ts(.server) session.me — the resolved Subject (SSR gate reads it)
|
|
76
|
+
queries/
|
|
77
|
+
projects.list.query.* live, tenant-scoped subscription
|
|
78
|
+
invites.list.query.* live, tenant-scoped subscription
|
|
79
|
+
mutations/
|
|
80
|
+
projects.create.* entitlement-gated (projects quota → paywall)
|
|
81
|
+
invites.create.* entitlement-gated (seats quota)
|
|
82
|
+
tests/ descriptor pins + tenant isolation + session.me
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Going to production
|
|
86
|
+
|
|
87
|
+
| Want… | Do |
|
|
88
|
+
|---|---|
|
|
89
|
+
| Durable accounts | swap `memoryUserStore()` for `postgresUserStore({ sql })` + `store: 'postgres'` |
|
|
90
|
+
| Real Stripe checkout | `billingPlugin({ provider: 'stripe', apiKey, webhookSecret, plans })` with real `priceId`s |
|
|
91
|
+
| Email flows (magic-link, reset) | pass `sendEmail: mailSender(mailService)` from `@voltro/plugin-mail` |
|
|
92
|
+
| Cookie hardening | `cookieSecure: true` (+ `cookieDomain`) — already ON in production here |
|
|
93
|
+
|
|
94
|
+
## Anti-patterns
|
|
95
|
+
|
|
96
|
+
- **Authoring your own `billing.*` / `/auth/*` procedures.** The plugins own
|
|
97
|
+
those namespaces — boot fails on a collision. Use the plugin routes + services.
|
|
98
|
+
- **Skipping the entitlement gate on a paid action.** A scope says "may you call
|
|
99
|
+
this"; an entitlement says "do you have quota left". `requireEntitlement` is
|
|
100
|
+
one line and fails closed — that's what makes the paywall real.
|
|
101
|
+
- **Reading `VOLTRO_SESSION_SECRET` at config time for a fallback.** The plugin
|
|
102
|
+
reads it per request, after the env gate mints it. A hardcoded fallback is a
|
|
103
|
+
published signing key.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Executor for `session.me`. Reads the Subject the AuthMiddleware resolved (via
|
|
2
|
+
// voltroPasswordStrategy reading the session cookie) and returns it.
|
|
3
|
+
import type { AppContext } from '@voltro/runtime'
|
|
4
|
+
|
|
5
|
+
const execute = async (_input: Record<string, never>, ctx: AppContext) => {
|
|
6
|
+
const subject = ctx.request.subject as {
|
|
7
|
+
readonly type: string
|
|
8
|
+
readonly id: string | null
|
|
9
|
+
readonly tenantId: string | null
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
type: subject.type,
|
|
13
|
+
id: subject.id ?? null,
|
|
14
|
+
tenantId: subject.tenantId ?? null,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default execute
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// `session.me` — returns the caller's resolved identity. This is the proof the
|
|
2
|
+
// auth loop works end-to-end, and the frontend's SSR auth gate reads it: call
|
|
3
|
+
// it with no session cookie and you get an anonymous Subject; sign in via POST
|
|
4
|
+
// /auth/sign-in (which sets the cookie), call it again, and
|
|
5
|
+
// `voltroPasswordStrategy` has resolved that cookie into a `user` Subject.
|
|
6
|
+
// Browser-safe descriptor — no server imports here.
|
|
7
|
+
import { defineAction } from '@voltro/protocol'
|
|
8
|
+
import { Schema } from 'effect'
|
|
9
|
+
|
|
10
|
+
export const me = defineAction({
|
|
11
|
+
name: 'session.me',
|
|
12
|
+
input: Schema.Struct({}),
|
|
13
|
+
output: Schema.Struct({
|
|
14
|
+
// 'anonymous' | 'user' | 'apiKey' | 'serviceAccount'
|
|
15
|
+
type: Schema.String,
|
|
16
|
+
// null while anonymous; the user id once signed in.
|
|
17
|
+
id: Schema.NullOr(Schema.String),
|
|
18
|
+
tenantId: Schema.NullOr(Schema.String),
|
|
19
|
+
}),
|
|
20
|
+
})
|