@voltro/cli 0.33.0 → 0.35.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 +1968 -0
- package/bin/nodeEnvironment.d.mts +30 -0
- package/bin/nodeEnvironment.mjs +158 -0
- package/bin/voltro.mjs +69 -5
- package/dist/addCommand-BNeoeSxe.js +124 -0
- package/dist/addCommand-aXSQveak.js +2 -0
- package/dist/agentsMd-BTchIZku.js +2 -0
- package/dist/agentsMd-mhQMF1bx.js +254 -0
- package/dist/apiBuild-B8aoJvuw.js +2 -0
- package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DYD_ONLD.js} +46 -46
- package/dist/{appGraph-gQ_6GkQQ.js → appGraph-KGDPTuTy.js} +1 -1
- package/dist/appGraph-zuMGKVYX.js +2 -0
- package/dist/appPort-B_HpJ_ck.js +48 -0
- package/dist/baselineCommand-C2ClWZN3.js +2 -0
- package/dist/baselineCommand-DIttzO8A.js +227 -0
- package/dist/bin.js +71 -28
- package/dist/build-CD8K4XOr.js +711 -0
- package/dist/cacheCommand-DA4OH9xt.js +42 -0
- package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
- package/dist/checkCommand-Ct9xkTrS.js +232 -0
- package/dist/checkCommand-DKpDLlqu.js +2 -0
- package/dist/{cliArgs-qdZSElM3.js → cliArgs-D4p8n7EE.js} +12 -1
- package/dist/cliError-BmdYnghb.js +10 -0
- package/dist/cliOutput-D1tSBoRM.js +15 -0
- package/dist/{cliRuntime-Oh517vCV.js → cliRuntime-Dh7UDinH.js} +20 -20
- package/dist/cloudClient-DWL-Hw_T.js +67 -0
- package/dist/cloudCmd-Cvv5HGaZ.js +364 -0
- package/dist/clusterCmd-D5wsCmA_.js +54 -0
- package/dist/codegen-CYM3Zqrf.js +605 -0
- package/dist/codegen-ChBi_hVa.js +2 -0
- package/dist/codegenCommand-C4YoQIc2.js +30 -0
- package/dist/codemodRunner-BnFq3Fgu.js +5384 -0
- package/dist/commandRunner-BLAEFLjp.js +47 -0
- package/dist/commands-BE8E7zF3.js +816 -0
- package/dist/connectionConfig-UFlIEiys.js +66 -0
- package/dist/dashboardCommand-D7SgZGaN.js +25 -0
- package/dist/dataCommand-BhYwDgg-.js +537 -0
- package/dist/dataProfile-dW-PsfLB.js +15 -0
- package/dist/dbCommand-DS4b97Is.js +2 -0
- package/dist/{dbCommand-DTLKAfbA.js → dbCommand-O8HA63s2.js} +552 -402
- package/dist/{dev-C_P8FLSx.js → dev-C7sFZq3m.js} +3670 -3169
- package/dist/dev-D2BikO7a.js +3 -0
- package/dist/devActivity-Dx_3nnGv.js +100 -0
- package/dist/devActivity.js +1 -1
- package/dist/dialectDriver-CgXnDfec.js +39 -0
- package/dist/discover-C9XKJDco.js +25 -0
- package/dist/doctorCommand-CM4Ch9C7.js +2 -0
- package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-DnimF5IM.js} +523 -1278
- package/dist/dormancyCommand-QewYug_s.js +69 -0
- package/dist/e2eCmd-BRabZww-.js +147 -0
- package/dist/embeddingsCommand-BfiLS_QI.js +73 -0
- package/dist/envCommand-CCGPRQY1.js +60 -0
- package/dist/evalCommand-6RUfPen4.js +118 -0
- package/dist/evolveCommand-CHsLCtDf.js +281 -0
- package/dist/fileTaxonomy-CJfgOllU.js +457 -0
- package/dist/frameworkTableAssembly-YyVe32Cb.js +2 -0
- package/dist/frameworkTableAssembly-oMPBKqlE.js +511 -0
- package/dist/generateCommand-DbgcUpGw.js +147 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +4 -3
- package/dist/infoCommand-DwOgK1t6.js +60 -0
- package/dist/{inspect-BUUjt773.js → inspect-CjTYzAs_.js} +113 -41
- package/dist/inspect-P4pxoMaV.js +2 -0
- package/dist/inspectCmd-EHFZ9yYu.js +224 -0
- package/dist/inspectFetch-EMuhTG_9.js +151 -0
- package/dist/inspectMetrics-CfdKLh6t.js +72 -0
- package/dist/loadEnv-D9nEOClM.js +44 -0
- package/dist/logFileSink-C_D2wRN1.js +105 -0
- package/dist/logsCmd-D36xK7Zu.js +260 -0
- package/dist/manifestBuild-COkJoyAr.js +2 -0
- package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-hpPLaGxV.js} +1 -1
- package/dist/metaCommands-7MJfZ5cf.js +196 -0
- package/dist/migrate-BV7I-ZHZ.js +83 -0
- package/dist/mssqlClusterPatch-_4cE_nun.js +44 -0
- package/dist/newCommand-COWOJ1_E.js +156 -0
- package/dist/nodeEnvironment-cGFAj1J8.js +28 -0
- package/dist/packageCommand-Cug_3Ogl.js +271 -0
- package/dist/pageConvention-cEiRxdab.js +5 -0
- package/dist/privacyCommand-C-Df56U_.js +146 -0
- package/dist/probeCommand-CZfaaUOZ.js +122 -0
- package/dist/projectScaffold-DmzEKHib.js +2 -0
- package/dist/projectScaffold-LMMtaavR.js +814 -0
- package/dist/renderModeScan-D7J1B7Kw.js +105 -0
- package/dist/renderProfile-1OWWAAtx.js +81 -0
- package/dist/runtimeRegistry-DMeKfTHP.js +81 -0
- package/dist/runtimeTrace-CH3eUiMw.js +91 -0
- package/dist/scheduleCmd-DQRu6BZC.js +149 -0
- package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
- package/dist/schemaIr-UJybUUZW.js +103 -0
- package/dist/{sdkgen-C81QIkiL.js → sdkgen-BLkvGRfX.js} +111 -209
- package/dist/seedRunner-ZmLSqNe2.js +333 -0
- package/dist/serveCommand-CbDHU6l-.js +2 -0
- package/dist/serveCommand-iwlUBNS1.js +1766 -0
- package/dist/serveEntry.js +5 -5
- package/dist/serverlessCommand-CfJZy6dS.js +482 -0
- package/dist/start-BgN62boB.js +3 -0
- package/dist/start-T4VesWiM.js +1087 -0
- package/dist/startEntry.js +2 -2
- package/dist/staticCommand-Dr2M6tpU.js +304 -0
- package/dist/storageCommand-Co6NfLqN.js +42 -0
- package/dist/templates-De8IR5-c.js +102 -0
- package/dist/test-CI6iDsYc.js +115 -0
- package/dist/tracesCmd-DStmCJPi.js +232 -0
- package/dist/tsconfigPaths-BWXBWgcl.js +107 -0
- package/dist/tsxLoader-EuXmSJ1K.js +51 -0
- package/dist/typecheckCommand-BlsWiCNq.js +61 -0
- package/dist/updateCommand-BlMZhWgO.js +2 -0
- package/dist/updateCommand-x0pI_x-B.js +585 -0
- package/dist/webDev-BcykISYQ2.js +2 -0
- package/dist/{inspectMetrics-1xzTKAFx.js → webDev-Dybxew86.js} +988 -1560
- package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
- package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
- package/dist/webhooksCommand-DlAgS2Iw.js +267 -0
- package/dist/workflowsCmd-BGF-mRZ5.js +608 -0
- package/package.json +209 -17
- package/templates/AGENTS.core.md +58 -3
- package/templates/AGENTS.md +64 -7
- package/templates/agent-docs/_index.md +6 -4
- package/templates/agent-docs/_manifest.json +23 -6
- package/templates/agent-docs/ai.md +370 -0
- package/templates/agent-docs/authentication.md +313 -31
- package/templates/agent-docs/caching.md +6 -0
- package/templates/agent-docs/cli.md +853 -50
- package/templates/agent-docs/data.md +608 -12
- package/templates/agent-docs/database/migrations.md +223 -25
- package/templates/agent-docs/database/misc.md +156 -40
- package/templates/agent-docs/database/querying.md +19 -1
- package/templates/agent-docs/database/scaling.md +60 -0
- package/templates/agent-docs/database/schema.md +1 -0
- package/templates/agent-docs/database/seedsdialects.md +208 -19
- package/templates/agent-docs/database/transactions.md +68 -0
- package/templates/agent-docs/deployment.md +284 -25
- package/templates/agent-docs/introduction.md +88 -17
- package/templates/agent-docs/local-first-mobile.md +79 -4
- package/templates/agent-docs/multi-tenancy.md +188 -42
- package/templates/agent-docs/observability.md +58 -3
- package/templates/agent-docs/plugins/ai-flows.md +247 -2
- package/templates/agent-docs/plugins/analytics-postgres.md +1 -1
- package/templates/agent-docs/plugins/audit.md +37 -1
- package/templates/agent-docs/plugins/auth-social.md +143 -0
- package/templates/agent-docs/plugins/auth-workos.md +4 -2
- package/templates/agent-docs/plugins/auth.md +131 -6
- package/templates/agent-docs/plugins/billing.md +132 -15
- package/templates/agent-docs/plugins/cdc-out.md +46 -7
- package/templates/agent-docs/plugins/clickhouse.md +32 -1
- package/templates/agent-docs/plugins/duckdb.md +1 -1
- package/templates/agent-docs/plugins/flags.md +132 -0
- package/templates/agent-docs/plugins/governance.md +105 -7
- package/templates/agent-docs/plugins/multitenancy.md +9 -4
- package/templates/agent-docs/plugins/presence.md +13 -2
- package/templates/agent-docs/plugins/ratelimit.md +9 -0
- package/templates/agent-docs/plugins/search.md +162 -8
- package/templates/agent-docs/plugins/sso-saml.md +47 -8
- package/templates/agent-docs/plugins/storage.md +11 -0
- package/templates/agent-docs/plugins/webhooks.md +105 -0
- package/templates/agent-docs/plugins.md +152 -18
- package/templates/agent-docs/reference.md +60 -3
- package/templates/agent-docs/releases.md +1117 -0
- package/templates/agent-docs/routing.md +43 -25
- package/templates/agent-docs/scheduling.md +27 -0
- package/templates/agent-docs/schema-driven-ui.md +92 -12
- package/templates/agent-docs/security.md +426 -0
- package/templates/agent-docs/templates/apibackends.md +87 -18
- package/templates/agent-docs/templates/appshells.md +32 -14
- package/templates/agent-docs/templates/overview.md +13 -8
- package/templates/agent-docs/testing.md +211 -14
- package/templates/agent-docs/whats-new.md +98 -136
- package/templates/agent-docs/workflows.md +231 -14
- package/templates/apps/api-ai/actions/summarize.action.ts +11 -0
- package/templates/apps/api-ai/package.json +8 -7
- package/templates/apps/api-auth/actions/me.action.ts +13 -0
- package/templates/apps/api-auth/package.json +9 -8
- package/templates/apps/api-backend/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend/package.json +12 -8
- package/templates/apps/api-backend/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +14 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +8 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +10 -0
- package/templates/apps/api-backend-deactivation/package.json +8 -7
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +17 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mail/package.json +9 -8
- package/templates/apps/api-backend-mail/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-mariadb/.env.example +14 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mariadb/package.json +10 -9
- package/templates/apps/api-backend-mariadb/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-sqlite/.env.example +14 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-sqlite/package.json +9 -8
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +13 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +12 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-storage/package.json +9 -8
- package/templates/apps/api-backend-storage/queries/notes.query.ts +30 -8
- package/templates/apps/api-cms/actions/content.get.action.ts +7 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +6 -0
- package/templates/apps/api-cms/actions/me.action.ts +13 -0
- package/templates/apps/api-cms/app.config.ts +19 -0
- package/templates/apps/api-cms/authz.ts +63 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +5 -0
- package/templates/apps/api-cms/package.json +11 -10
- package/templates/apps/api-cms/queries/content.list.query.ts +23 -7
- package/templates/apps/api-cms/tests/accessDecisions.test.ts +121 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +8 -4
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +8 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +13 -0
- package/templates/apps/api-collab/package.json +9 -8
- package/templates/apps/api-collab/queries/documents.query.ts +19 -7
- package/templates/apps/api-data-advanced/package.json +9 -8
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +12 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.ts +9 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +17 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.ts +9 -0
- package/templates/apps/api-durable/package.json +9 -8
- package/templates/apps/api-feature-flags/actions/notes.export.action.ts +16 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +12 -0
- package/templates/apps/api-feature-flags/package.json +10 -9
- package/templates/apps/api-governance/README.md +30 -8
- package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -1
- package/templates/apps/api-governance/actions/profiles.get.action.ts +29 -4
- package/templates/apps/api-governance/database/schema.ts +16 -4
- package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +12 -0
- package/templates/apps/api-governance/package.json +9 -8
- package/templates/apps/api-kv/actions/sync.pull.action.ts +15 -0
- package/templates/apps/api-kv/actions/sync.reset.action.ts +13 -0
- package/templates/apps/api-kv/actions/sync.status.action.ts +7 -0
- package/templates/apps/api-kv/package.json +9 -8
- package/templates/apps/api-kv/queries/events.list.query.ts +19 -8
- package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +11 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +13 -0
- package/templates/apps/api-moderation/package.json +9 -8
- package/templates/apps/api-observability/mutations/notes.create.mutation.ts +8 -0
- package/templates/apps/api-observability/package.json +9 -8
- package/templates/apps/api-observability/queries/notes.list.query.ts +13 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +14 -0
- package/templates/apps/api-ratelimit/package.json +9 -8
- package/templates/apps/api-rbac/package.json +9 -8
- package/templates/apps/api-rest/package.json +8 -7
- package/templates/apps/api-saas/mutations/projects.create.mutation.ts +13 -0
- package/templates/apps/api-saas/package.json +12 -11
- package/templates/apps/api-saas/queries/projects.list.query.ts +11 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +13 -0
- package/templates/apps/api-saas-starter/app.config.ts +19 -0
- package/templates/apps/api-saas-starter/authz.ts +75 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +10 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +9 -0
- package/templates/apps/api-saas-starter/package.json +15 -11
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +19 -8
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -7
- package/templates/apps/api-saas-starter/tests/accessDecisions.test.ts +135 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.ts +14 -0
- package/templates/apps/api-search/package.json +9 -8
- package/templates/apps/api-search/queries/articles.list.query.ts +21 -8
- package/templates/apps/api-status/README.md +10 -3
- package/templates/apps/api-status/app.config.ts +8 -3
- package/templates/apps/api-status/authz.ts +5 -3
- package/templates/apps/api-status/package.json +9 -8
- package/templates/apps/api-status/queries/components.list.query.ts +14 -6
- package/templates/apps/api-status/queries/incidents.live.query.ts +23 -10
- package/templates/apps/api-status/queries/updates.list.query.ts +16 -9
- package/templates/apps/api-status/tests/status.test.ts +9 -1
- package/templates/apps/api-versioning/actions/documents.asOf.action.ts +12 -0
- package/templates/apps/api-versioning/actions/documents.history.action.ts +13 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +9 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +12 -0
- package/templates/apps/api-versioning/package.json +9 -8
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +16 -0
- package/templates/apps/api-webhooks/package.json +10 -9
- package/templates/apps/api-webhooks/queries/orders.list.query.ts +10 -0
- package/templates/apps/changelog/package.json +8 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +10 -8
- package/templates/apps/frontend-admin/src/lib/admin.ts +20 -9
- package/templates/apps/frontend-admin/src/locales/de.ts +11 -1
- package/templates/apps/frontend-admin/src/locales/en.ts +13 -1
- package/templates/apps/frontend-admin/src/pages/admin/[entity]/page.tsx +65 -22
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +130 -23
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +3 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.test.tsx +19 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.tsx +9 -4
- package/templates/apps/frontend-app/app.config.ts +4 -3
- package/templates/apps/frontend-app/package.json +11 -8
- package/templates/apps/frontend-app/src/lib/api.ts +25 -0
- package/templates/apps/frontend-app/src/pages/page.test.tsx +130 -82
- package/templates/apps/frontend-app/src/pages/page.tsx +14 -18
- package/templates/apps/frontend-auth/package.json +10 -8
- package/templates/apps/frontend-blank/package.json +9 -7
- package/templates/apps/frontend-cms/package.json +11 -9
- package/templates/apps/frontend-collab/package.json +12 -9
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +122 -78
- package/templates/apps/frontend-contact/package.json +9 -7
- package/templates/apps/frontend-dashboard/package.json +9 -7
- package/templates/apps/frontend-docs/package.json +9 -7
- package/templates/apps/frontend-i18n/package.json +8 -6
- package/templates/apps/frontend-landing/package.json +9 -7
- package/templates/apps/frontend-portal/package.json +10 -8
- package/templates/apps/frontend-saas/app.config.ts +10 -6
- package/templates/apps/frontend-saas/package.json +10 -8
- package/templates/apps/frontend-saas/src/lib/api.ts +27 -32
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +7 -8
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +27 -3
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +4 -4
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +3 -4
- package/templates/apps/frontend-spa/package.json +9 -7
- package/templates/apps/frontend-ssr/package.json +9 -7
- package/templates/apps/frontend-ssr-api/package.json +10 -8
- package/templates/apps/frontend-static-blog/package.json +8 -6
- package/templates/apps/frontend-status/package.json +10 -8
- package/templates/apps/mobile-app/README.md +1 -0
- package/templates/apps/mobile-app/package.json +4 -2
- package/templates/apps/mobile-app/src/app/index.tsx +22 -12
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +1 -1
- package/templates/apps/mobile-app/src/lib/api.ts +34 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +4 -2
- package/templates/baselines/bare/.env.example +14 -0
- package/templates/baselines/bare/baseline.json +4 -4
- package/templates/baselines/compose/.env.example +14 -0
- package/templates/baselines/compose/README.md +1 -1
- package/templates/baselines/compose/baseline.json +5 -5
- package/templates/baselines/compose-mariadb/.env.example +14 -0
- package/templates/baselines/compose-mariadb/README.md +1 -1
- package/templates/baselines/compose-mariadb/baseline.json +5 -5
- package/templates/baselines/helm/.env.example +14 -0
- package/templates/baselines/helm/baseline.json +4 -4
- package/dist/apiBuild-C-x9YacA.js +0 -2
- package/dist/appGraph-CvQCte0z.js +0 -2
- package/dist/checkCommand-DRovTKza.js +0 -2
- package/dist/commands-CJfepbm4.js +0 -11541
- package/dist/dbCommand-b1gum4td.js +0 -2
- package/dist/dev-iiMtlkfs.js +0 -3
- package/dist/devActivity-BhIu6ncs.js +0 -159
- package/dist/frameworkTableAssembly-BwIrO5nv.js +0 -638
- package/dist/frameworkTableAssembly-D-EebUQX.js +0 -2
- package/dist/inspect-mmBuRXmy.js +0 -2
- package/dist/manifestBuild-Dj8Jjoto.js +0 -2
- package/dist/seedRunner-Bqxgp7HZ.js +0 -230
- package/dist/serveCommand-DdaM4Hup.js +0 -1608
- package/dist/start-C0koT0UO.js +0 -1084
- /package/templates/apps/api-ai/actions/{summarize.action.server.tsx → summarize.action.server.ts} +0 -0
|
@@ -19,6 +19,16 @@ const Violation = Schema.Struct({
|
|
|
19
19
|
|
|
20
20
|
export const saveDraft = defineMutation({
|
|
21
21
|
name: 'content.saveDraft',
|
|
22
|
+
// The write surface the README promises is editors-only — enforced here
|
|
23
|
+
// rather than described. `guards:` runs in the dispatch spine BEFORE the
|
|
24
|
+
// executor and before the transaction opens, so an unauthorized save never
|
|
25
|
+
// touches the database.
|
|
26
|
+
//
|
|
27
|
+
// Satisfiable by this app as shipped: sign in via POST /auth/sign-in,
|
|
28
|
+
// `voltroPasswordStrategy` matches the cookie, `auth.resolveScopes`
|
|
29
|
+
// (authz.ts) grants `content:write`. No cookie → anonymous → no scopes →
|
|
30
|
+
// `ScopeError`, which is exactly the boundary this template exists to show.
|
|
31
|
+
guards: [{ scope: 'content:write' }],
|
|
22
32
|
input: Schema.Struct({
|
|
23
33
|
type: Schema.NonEmptyString,
|
|
24
34
|
// Arbitrary per-type field values (may carry `id` to update an existing
|
|
@@ -6,6 +6,11 @@ import { Schema } from 'effect'
|
|
|
6
6
|
|
|
7
7
|
export const unpublishContent = defineMutation({
|
|
8
8
|
name: 'content.unpublish',
|
|
9
|
+
// Editors only. Taking a page DOWN is as much a live-site change as putting
|
|
10
|
+
// one up — an unguarded unpublish is a denial-of-service against your own
|
|
11
|
+
// content — so it carries the same `content:write`, satisfied by a signed-in
|
|
12
|
+
// session and refused to an anonymous caller.
|
|
13
|
+
guards: [{ scope: 'content:write' }],
|
|
9
14
|
input: Schema.Struct({ type: Schema.NonEmptyString, id: Schema.NonEmptyString }),
|
|
10
15
|
output: Schema.Struct({ ok: Schema.Boolean }),
|
|
11
16
|
})
|
|
@@ -13,20 +13,21 @@
|
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@effect/platform": "^0.97.0",
|
|
15
15
|
"@effect/rpc": "^0.76.0",
|
|
16
|
-
"@voltro/cli": "0.
|
|
17
|
-
"@voltro/cms": "0.
|
|
18
|
-
"@voltro/database": "0.
|
|
19
|
-
"@voltro/env": "0.
|
|
20
|
-
"@voltro/plugin-auth": "0.
|
|
21
|
-
"@voltro/plugin-multitenancy": "0.
|
|
22
|
-
"@voltro/protocol": "0.
|
|
23
|
-
"@voltro/runtime": "0.
|
|
24
|
-
"@voltro/sql-postgres": "0.
|
|
16
|
+
"@voltro/cli": "0.35.0",
|
|
17
|
+
"@voltro/cms": "0.35.0",
|
|
18
|
+
"@voltro/database": "0.35.0",
|
|
19
|
+
"@voltro/env": "0.35.0",
|
|
20
|
+
"@voltro/plugin-auth": "0.35.0",
|
|
21
|
+
"@voltro/plugin-multitenancy": "0.35.0",
|
|
22
|
+
"@voltro/protocol": "0.35.0",
|
|
23
|
+
"@voltro/runtime": "0.35.0",
|
|
24
|
+
"@voltro/sql-postgres": "0.35.0",
|
|
25
25
|
"effect": "^3.22.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@voltro/testing": "0.
|
|
28
|
+
"@voltro/testing": "0.35.0",
|
|
29
29
|
"typescript": "^6.0.3",
|
|
30
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
30
31
|
"vitest": "^4.1.10"
|
|
31
32
|
}
|
|
32
33
|
}
|
|
@@ -13,15 +13,31 @@ import { Schema } from 'effect'
|
|
|
13
13
|
|
|
14
14
|
export const listContent = defineQuery({
|
|
15
15
|
name: 'content.list',
|
|
16
|
+
// Editors only. `guards:` is enforced in the dispatch spine — when the
|
|
17
|
+
// subscription OPENS and again on EVERY delivery — so an editor whose access
|
|
18
|
+
// is revoked mid-session stops receiving rows instead of keeping a live feed
|
|
19
|
+
// open on an expired grant. `tenant()` on the derived content tables still
|
|
20
|
+
// scopes WHICH rows; this decides WHO may ask.
|
|
21
|
+
//
|
|
22
|
+
// Satisfiable by this app's own configuration: `voltroPasswordStrategy`
|
|
23
|
+
// matches the session cookie, `auth.resolveScopes` (authz.ts) grants
|
|
24
|
+
// `content:read` to the matched subject. Anonymous → no scopes → denied,
|
|
25
|
+
// which is the point.
|
|
26
|
+
//
|
|
27
|
+
// Consumers of PUBLISHED content are a separate surface (`handleCmsRest`,
|
|
28
|
+
// api-key scoped) — see the README. They do not come through here.
|
|
29
|
+
guards: [{ scope: 'content:read' }],
|
|
16
30
|
input: Schema.Struct({
|
|
17
31
|
type: Schema.NonEmptyString,
|
|
18
32
|
status: Schema.optional(Schema.Literal('draft', 'published')),
|
|
19
33
|
}),
|
|
20
|
-
output: Schema.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
output: Schema.Array(
|
|
35
|
+
Schema.Struct({
|
|
36
|
+
id: Schema.String,
|
|
37
|
+
status: Schema.String,
|
|
38
|
+
title: Schema.optional(Schema.String),
|
|
39
|
+
slug: Schema.optional(Schema.String),
|
|
40
|
+
updatedAt: Schema.Date,
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
27
43
|
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The app's ACCESS POSTURE, asserted from BOTH sides against the real resolver.
|
|
2
|
+
//
|
|
3
|
+
// Why this file exists rather than one guard assertion per handler test: a
|
|
4
|
+
// per-procedure test hands `invoke` a subject it constructed itself, so it
|
|
5
|
+
// passes with a scope literal no real caller could ever obtain. The question it
|
|
6
|
+
// structurally cannot ask is the one that matters —
|
|
7
|
+
// **can the caller this app actually has satisfy these guards, and is there
|
|
8
|
+
// anything that still cannot?** Both halves, or the suite stays green on an app
|
|
9
|
+
// that is either wide open or completely dead.
|
|
10
|
+
//
|
|
11
|
+
// The second failure is the easy one to ship by accident. A guard nobody can
|
|
12
|
+
// satisfy is not strict security; it is a 100% outage wearing security's
|
|
13
|
+
// clothes, and every unit test in the directory keeps passing while the editor
|
|
14
|
+
// answers `ScopeError` to its own sign-in flow. So the subjects below are built
|
|
15
|
+
// the way the framework builds them (`applyScopeDecision` over this app's real
|
|
16
|
+
// `resolveScopes`), never by writing the scopes the assertions want to see.
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from 'vitest'
|
|
19
|
+
import {
|
|
20
|
+
anonymousSubject,
|
|
21
|
+
applyScopeDecision,
|
|
22
|
+
checkGuards,
|
|
23
|
+
ScopeError,
|
|
24
|
+
type Subject,
|
|
25
|
+
} from '@voltro/protocol'
|
|
26
|
+
import { EDITOR_SCOPES, resolveScopes } from '../authz'
|
|
27
|
+
|
|
28
|
+
import { listContent } from '../queries/content.list.query'
|
|
29
|
+
import { getContent } from '../actions/content.get.action'
|
|
30
|
+
import { contentTypes } from '../actions/content.types.action'
|
|
31
|
+
import { me } from '../actions/me.action'
|
|
32
|
+
import { saveDraft } from '../mutations/content.saveDraft.mutation'
|
|
33
|
+
import { publishContent } from '../mutations/content.publish.mutation'
|
|
34
|
+
import { unpublishContent } from '../mutations/content.unpublish.mutation'
|
|
35
|
+
|
|
36
|
+
/** The Subject a browser with no session cookie gets: `voltroPasswordStrategy`
|
|
37
|
+
* finds nothing to match, so the composer falls back to anonymous. */
|
|
38
|
+
const visitor = anonymousSubject('acme')
|
|
39
|
+
|
|
40
|
+
/** The Subject a signed-in editor gets. `voltroPasswordStrategy` matches the
|
|
41
|
+
* cookie into a `user` Subject carrying NO scopes (a cookie never carries
|
|
42
|
+
* authority), then the composer applies this app's own `resolveScopes` to it.
|
|
43
|
+
* Built through the framework's `applyScopeDecision` so this file cannot
|
|
44
|
+
* disagree with the runtime about how a grant lands on a subject. */
|
|
45
|
+
const editor = (): Subject => {
|
|
46
|
+
const matched: Subject = { type: 'user', id: 'usr_editor', tenantId: 'acme', scopes: [] }
|
|
47
|
+
return applyScopeDecision(matched, { kind: 'grant', scopes: resolveScopes(matched) }).subject
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Exactly what the dispatch spine asks before an executor runs. */
|
|
51
|
+
const decide = (
|
|
52
|
+
descriptor: { readonly name: string; readonly guards?: ReadonlyArray<unknown> | undefined },
|
|
53
|
+
subject: Subject,
|
|
54
|
+
): ScopeError | null =>
|
|
55
|
+
checkGuards(
|
|
56
|
+
subject,
|
|
57
|
+
descriptor.guards as Parameters<typeof checkGuards>[1],
|
|
58
|
+
{ defaultDeny: true, procedure: descriptor.name },
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const GUARDED = [listContent, getContent, contentTypes, saveDraft, publishContent, unpublishContent] as const
|
|
62
|
+
|
|
63
|
+
describe('the caller this app actually has — a signed-in editor', () => {
|
|
64
|
+
// If any of these regress the editor boots and shows nothing but denials,
|
|
65
|
+
// which no other test in this directory can see.
|
|
66
|
+
it.each(GUARDED.map((d) => [d.name, d] as const))('%s is callable', (_name, descriptor) => {
|
|
67
|
+
expect(decide(descriptor, editor())).toBeNull()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('is not just passing because the guards are empty', () => {
|
|
71
|
+
// The silent-zero shape: `checkGuards` with no guards would return null too.
|
|
72
|
+
// Assert the decisions are really there before trusting the line above.
|
|
73
|
+
for (const d of GUARDED) expect(d.guards?.length ?? 0).toBeGreaterThan(0)
|
|
74
|
+
expect(GUARDED).toHaveLength(6)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('the caller with no session — every content procedure is refused', () => {
|
|
79
|
+
it.each(GUARDED.map((d) => [d.name, d] as const))('%s denies an anonymous visitor', (_name, descriptor) => {
|
|
80
|
+
expect(decide(descriptor, visitor)).toBeInstanceOf(ScopeError)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('…and that is structural, not a resolver quirk', () => {
|
|
84
|
+
// The reason an anonymous caller can never hold `content:write` in this app,
|
|
85
|
+
// pinned so a framework change would surface here: `applyScopeDecision`
|
|
86
|
+
// returns an anonymous Subject UNTOUCHED under both decision kinds (the
|
|
87
|
+
// schema has no `scopes` field to write). `auth.resolveScopes` also never
|
|
88
|
+
// runs for one — it fires only on a MATCHED subject — so a cookie is the
|
|
89
|
+
// only way into the granted set.
|
|
90
|
+
const granted = applyScopeDecision(visitor, { kind: 'grant', scopes: [...EDITOR_SCOPES] })
|
|
91
|
+
expect(granted.subject).toEqual(visitor)
|
|
92
|
+
expect(decide(saveDraft, granted.subject)).toBeInstanceOf(ScopeError)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('session.me stays open, and that is the point', () => {
|
|
97
|
+
it('answers an anonymous caller instead of denying them', () => {
|
|
98
|
+
// A guard here would return `ScopeError` to the SSR gate, which would then
|
|
99
|
+
// have nothing to branch on and would never redirect to /login.
|
|
100
|
+
expect(decide(me, visitor)).toBeNull()
|
|
101
|
+
expect(me.openAccess).toBeTruthy()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('the vocabulary is a boundary, not a rubber stamp', () => {
|
|
106
|
+
it('grants no wildcard and no admin bypass', () => {
|
|
107
|
+
expect(EDITOR_SCOPES).not.toContain('*')
|
|
108
|
+
expect(EDITOR_SCOPES).not.toContain('admin:full')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('every guard names a scope the resolver actually grants', () => {
|
|
112
|
+
// The other half of unsatisfiability: a typo'd scope makes a procedure
|
|
113
|
+
// permanently uncallable, and nothing else in this app would notice.
|
|
114
|
+
const granted = new Set(resolveScopes(editor()))
|
|
115
|
+
for (const d of GUARDED) {
|
|
116
|
+
for (const g of (d.guards ?? []) as ReadonlyArray<{ scope?: string }>) {
|
|
117
|
+
expect(granted.has(g.scope ?? '')).toBe(true)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -30,11 +30,15 @@ describe('content.list descriptor', () => {
|
|
|
30
30
|
expect(decodeIn({ type: 'blogPost', status: 'draft' })).toEqual({ type: 'blogPost', status: 'draft' })
|
|
31
31
|
// The output carries only what a list view needs — id/status/title?/slug?/
|
|
32
32
|
// updatedAt; the runtime drops a row's other columns against this shape.
|
|
33
|
+
// It is an ARRAY schema (a query delivers rows, not a row), so encode a
|
|
34
|
+
// list and read the first element — passing a bare object here typechecked
|
|
35
|
+
// as the array type and made `wire.id` a compile error that `voltro test`
|
|
36
|
+
// never sees, because it transpiles without type-checking.
|
|
33
37
|
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
|
|
36
|
-
expect(wire
|
|
37
|
-
expect(wire
|
|
38
|
+
const [wire] = encode([{ id: 'p1', status: 'published', title: 'T', slug: 't', updatedAt: new Date(0) }])
|
|
39
|
+
expect(wire?.id).toBe('p1')
|
|
40
|
+
expect(wire?.status).toBe('published')
|
|
41
|
+
expect(wire?.title).toBe('T')
|
|
38
42
|
})
|
|
39
43
|
})
|
|
40
44
|
|
|
@@ -9,6 +9,14 @@ import { Schema } from 'effect'
|
|
|
9
9
|
|
|
10
10
|
export const createDocument = defineMutation({
|
|
11
11
|
name: 'documents.create',
|
|
12
|
+
// Open: inserts a title the caller supplied into the caller's own tenant
|
|
13
|
+
// (`assertOwnTenant` rejects a mismatched `tenantId`) and touches no existing
|
|
14
|
+
// row. With no auth strategy configured every caller is an anonymous Subject
|
|
15
|
+
// holding no scopes, so a `guards: [{ scope }]` would deny all of them.
|
|
16
|
+
openAccess:
|
|
17
|
+
'inserts a new document (title only, empty CRDT body) into the caller\'s own tenant; '
|
|
18
|
+
+ '`assertOwnTenant` rejects a mismatch and no existing row is touched. No auth strategy '
|
|
19
|
+
+ 'ships here, so there is no identity a scope guard could name.',
|
|
12
20
|
target: {
|
|
13
21
|
table: 'documents',
|
|
14
22
|
op: 'insert',
|
|
@@ -16,6 +16,19 @@ import { Schema } from 'effect'
|
|
|
16
16
|
export const setDocumentBody = defineMutation({
|
|
17
17
|
name: 'documents.setBody',
|
|
18
18
|
target: { table: 'documents', op: 'update' },
|
|
19
|
+
// Open, and note WHAT the CRDT changes about the risk: the server does not
|
|
20
|
+
// overwrite `body` with the caller's bytes, it MERGES them
|
|
21
|
+
// (`mergeCrdtStates`), so a concurrent editor's text is not destroyed by a
|
|
22
|
+
// conflicting write — the two converge. `tenant()` scopes the update, so an
|
|
23
|
+
// id from another tenant matches no row at all.
|
|
24
|
+
//
|
|
25
|
+
// What remains open is who may join an editing session, and that needs an
|
|
26
|
+
// identity this template does not ship: with no auth strategy every caller is
|
|
27
|
+
// an anonymous Subject holding no scopes, so a `guards:` would deny everyone.
|
|
28
|
+
openAccess:
|
|
29
|
+
'merges a caller-supplied CRDT update into a document of the request\'s tenant — a merge, '
|
|
30
|
+
+ 'not an overwrite, so no concurrent editor\'s text is lost, and an id outside the tenant '
|
|
31
|
+
+ 'matches no row. Guard who may edit once the app has an identity.',
|
|
19
32
|
input: Schema.Struct({
|
|
20
33
|
id: Schema.NonEmptyString,
|
|
21
34
|
update: Schema.Uint8ArrayFromBase64,
|
|
@@ -13,18 +13,19 @@
|
|
|
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/local-first": "0.
|
|
20
|
-
"@voltro/plugin-multitenancy": "0.
|
|
21
|
-
"@voltro/protocol": "0.
|
|
22
|
-
"@voltro/runtime": "0.
|
|
16
|
+
"@voltro/cli": "0.35.0",
|
|
17
|
+
"@voltro/database": "0.35.0",
|
|
18
|
+
"@voltro/env": "0.35.0",
|
|
19
|
+
"@voltro/local-first": "0.35.0",
|
|
20
|
+
"@voltro/plugin-multitenancy": "0.35.0",
|
|
21
|
+
"@voltro/protocol": "0.35.0",
|
|
22
|
+
"@voltro/runtime": "0.35.0",
|
|
23
23
|
"effect": "^3.22.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@voltro/testing": "0.
|
|
26
|
+
"@voltro/testing": "0.35.0",
|
|
27
27
|
"typescript": "^6.0.3",
|
|
28
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
28
29
|
"vitest": "^4.1.10"
|
|
29
30
|
}
|
|
30
31
|
}
|
|
@@ -12,12 +12,24 @@ import { Schema } from 'effect'
|
|
|
12
12
|
|
|
13
13
|
export const listDocuments = defineQuery({
|
|
14
14
|
name: 'documents.list',
|
|
15
|
+
// Open: `tenant()` confines every delivery to the tenantId on the request, so
|
|
16
|
+
// one tenant's CRDT state never reaches another's subscription — but this
|
|
17
|
+
// template configures no auth strategy, so that tenantId is the caller's own
|
|
18
|
+
// `x-tenant` header. It shapes the feed; it does not authorize the caller.
|
|
19
|
+
// A `guards: [{ scope }]` would be unsatisfiable here (an anonymous Subject
|
|
20
|
+
// holds no scopes) and would deny 100% of traffic instead of some of it.
|
|
21
|
+
openAccess:
|
|
22
|
+
'streams the documents of the request\'s tenant, CRDT body included (`tenant()` scopes '
|
|
23
|
+
+ 'every delivery). No auth strategy ships here, so the tenant comes from the caller\'s own '
|
|
24
|
+
+ '`x-tenant` header — add a strategy, then a `guards:`.',
|
|
15
25
|
input: Schema.Struct({}),
|
|
16
|
-
output: Schema.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
output: Schema.Array(
|
|
27
|
+
Schema.Struct({
|
|
28
|
+
id: Schema.String,
|
|
29
|
+
title: Schema.String,
|
|
30
|
+
body: Schema.NullOr(Schema.Uint8ArrayFromBase64),
|
|
31
|
+
tenantId: Schema.String,
|
|
32
|
+
createdAt: Schema.Date,
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
23
35
|
})
|
|
@@ -12,18 +12,19 @@
|
|
|
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.35.0",
|
|
16
|
+
"@voltro/database": "0.35.0",
|
|
17
|
+
"@voltro/env": "0.35.0",
|
|
18
|
+
"@voltro/plugin-governance": "0.35.0",
|
|
19
|
+
"@voltro/plugin-multitenancy": "0.35.0",
|
|
20
|
+
"@voltro/protocol": "0.35.0",
|
|
21
|
+
"@voltro/runtime": "0.35.0",
|
|
22
22
|
"effect": "^3.22.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.
|
|
25
|
+
"@voltro/testing": "0.35.0",
|
|
26
26
|
"typescript": "^6.0.3",
|
|
27
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
27
28
|
"vitest": "^4.1.10"
|
|
28
29
|
}
|
|
29
30
|
}
|
|
@@ -26,6 +26,18 @@ const Book = Schema.Struct({
|
|
|
26
26
|
export const authorsWithBooks = defineQuery({
|
|
27
27
|
name: 'authors.withBooks',
|
|
28
28
|
source: ['authors', 'books'],
|
|
29
|
+
// Open: the rows are this template's own seeded catalogue
|
|
30
|
+
// (`seeds/catalog.seed.ts` — a public book list, no personal data), and
|
|
31
|
+
// `tenant()` on both tables scopes every delivery to the request's tenant.
|
|
32
|
+
//
|
|
33
|
+
// `bio` is the `.encrypted()` column and it IS in this output — deliberately,
|
|
34
|
+
// because the lesson is that `.encrypted()` is about bytes AT REST and says
|
|
35
|
+
// nothing about the wire. If a column must not leave the server, that is a
|
|
36
|
+
// different marker (`.serverOnly()`), not this one.
|
|
37
|
+
openAccess:
|
|
38
|
+
'streams the seeded demo catalogue (authors + their books) for the request\'s tenant; the '
|
|
39
|
+
+ 'seed holds no personal data. `bio` is `.encrypted()` and still crosses the wire on '
|
|
40
|
+
+ 'purpose — encryption at rest is not an exposure marker; `.serverOnly()` is.',
|
|
29
41
|
input: Schema.Struct({}),
|
|
30
42
|
output: Schema.Struct({
|
|
31
43
|
id: Schema.String,
|
|
@@ -24,6 +24,15 @@ import { Schema } from 'effect'
|
|
|
24
24
|
export const searchBooks = defineQuery({
|
|
25
25
|
name: 'books.search',
|
|
26
26
|
source: 'books',
|
|
27
|
+
// Open: full-text search over this template's own seeded book catalogue
|
|
28
|
+
// (`seeds/catalog.seed.ts`), scoped to the request's tenant by `tenant()`.
|
|
29
|
+
// Note that `cache.scope: 'subject'` below is a SEPARATE decision from this
|
|
30
|
+
// one and stays right either way — the snapshot key includes the subject, so
|
|
31
|
+
// an open procedure still cannot serve one caller's rows to another.
|
|
32
|
+
openAccess:
|
|
33
|
+
'full-text search over the seeded demo book catalogue, tenant-scoped; the seed holds no '
|
|
34
|
+
+ 'personal data. The result cache is `scope: \'subject\'`, so an open read still cannot '
|
|
35
|
+
+ 'serve one caller a snapshot built for another.',
|
|
27
36
|
input: Schema.Struct({ q: Schema.String }),
|
|
28
37
|
output: Schema.Struct({
|
|
29
38
|
id: Schema.String,
|
|
@@ -11,6 +11,23 @@ import { Schema } from 'effect'
|
|
|
11
11
|
|
|
12
12
|
export const approveOrder = defineMutation({
|
|
13
13
|
name: 'orders.approve',
|
|
14
|
+
// This is the human-APPROVAL surface, so state plainly what "open" costs
|
|
15
|
+
// here rather than hiding it behind the word demo: whoever can call this
|
|
16
|
+
// decides whether an order ships. `assertOwnTenant` confines the signal to
|
|
17
|
+
// the caller's own tenant and the executionId is DERIVED from
|
|
18
|
+
// `{ orderId, tenantId }` (so no caller-supplied id can address another
|
|
19
|
+
// tenant's run), but nothing in this template distinguishes an approver from
|
|
20
|
+
// anyone else — it configures no auth strategy and no rbac, so an anonymous
|
|
21
|
+
// Subject with no scopes is the only caller that exists and a
|
|
22
|
+
// `guards: [{ scope: 'orders:approve' }]` would deny 100% of traffic.
|
|
23
|
+
//
|
|
24
|
+
// This is THE procedure in this template to guard first. Add an auth
|
|
25
|
+
// strategy (`api-auth`) or rbac (`api-rbac`), then that guard.
|
|
26
|
+
openAccess:
|
|
27
|
+
'injects the approval signal into the caller\'s own tenant\'s parked fulfilment run — the '
|
|
28
|
+
+ 'executionId is derived from `{ orderId, tenantId }` and `assertOwnTenant` rejects a '
|
|
29
|
+
+ 'mismatch, so no other tenant\'s run is addressable. It still decides whether an order '
|
|
30
|
+
+ 'ships: the first procedure here to grow an `orders:approve` guard once an identity exists.',
|
|
14
31
|
input: Schema.Struct({
|
|
15
32
|
orderId: Schema.String,
|
|
16
33
|
tenantId: Schema.String,
|
|
@@ -12,6 +12,15 @@ import { Schema } from 'effect'
|
|
|
12
12
|
|
|
13
13
|
export const placeOrder = defineMutation({
|
|
14
14
|
name: 'orders.place',
|
|
15
|
+
// Open: writes an order carrying only what the caller sent, into the caller's
|
|
16
|
+
// own tenant (`assertOwnTenant` rejects a mismatched `tenantId`), and touches
|
|
17
|
+
// no existing row. No auth strategy and no rbac ship in this template, so
|
|
18
|
+
// every caller resolves to an anonymous Subject with no scopes and a
|
|
19
|
+
// `guards: [{ scope }]` would deny all of them.
|
|
20
|
+
openAccess:
|
|
21
|
+
'inserts an order built only from caller-supplied fields into the caller\'s own tenant '
|
|
22
|
+
+ '(`assertOwnTenant` rejects a mismatch); reads nothing and modifies no existing row. '
|
|
23
|
+
+ 'No auth strategy ships here, so there is no identity a scope guard could name.',
|
|
15
24
|
// Auto-optimistic: every query whose `source: 'orders'` matches gets a
|
|
16
25
|
// placeholder row prepended; the server delta replaces it on commit.
|
|
17
26
|
target: {
|
|
@@ -12,18 +12,19 @@
|
|
|
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.35.0",
|
|
16
|
+
"@voltro/database": "0.35.0",
|
|
17
|
+
"@voltro/env": "0.35.0",
|
|
18
|
+
"@voltro/plugin-multitenancy": "0.35.0",
|
|
19
|
+
"@voltro/protocol": "0.35.0",
|
|
20
|
+
"@voltro/runtime": "0.35.0",
|
|
21
|
+
"@voltro/workflow": "0.35.0",
|
|
22
22
|
"effect": "^3.22.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.
|
|
25
|
+
"@voltro/testing": "0.35.0",
|
|
26
26
|
"typescript": "^6.0.3",
|
|
27
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
27
28
|
"vitest": "^4.1.10"
|
|
28
29
|
}
|
|
29
30
|
}
|
|
@@ -12,6 +12,22 @@ import { Schema } from 'effect'
|
|
|
12
12
|
|
|
13
13
|
export const exportNotes = defineAction({
|
|
14
14
|
name: 'notes.export',
|
|
15
|
+
// A flag gate is NOT an access decision, and this pair is the clearest place
|
|
16
|
+
// in the templates to see why. `gatedBy` decides WHETHER THE FEATURE IS ON —
|
|
17
|
+
// it is a release control, flipped for everyone at once (or by rollout
|
|
18
|
+
// bucket), and `betaExport: false` in app.config.ts happens to close this
|
|
19
|
+
// door today. `guards:` / `openAccess:` decide WHO MAY CALL IT. Turning the
|
|
20
|
+
// flag on must not silently also decide the second question, so the
|
|
21
|
+
// procedure declares both.
|
|
22
|
+
//
|
|
23
|
+
// Open because no auth strategy and no rbac ship here: every caller resolves
|
|
24
|
+
// to an anonymous Subject with no scopes, so a scope guard would deny all of
|
|
25
|
+
// them. What the handler returns is a COUNT of the request tenant's own notes
|
|
26
|
+
// — no bodies, no other tenant's rows (`tenant()` scopes the read).
|
|
27
|
+
openAccess:
|
|
28
|
+
'returns a COUNT of the request tenant\'s own notes — no row contents leave. The '
|
|
29
|
+
+ '`betaExport` flag gates whether the feature runs at all; that is a release control, not '
|
|
30
|
+
+ 'an access decision, which is why this line exists alongside it.',
|
|
15
31
|
input: Schema.Struct({}),
|
|
16
32
|
output: Schema.Struct({ exported: Schema.Number }),
|
|
17
33
|
error: FlagDisabled,
|
|
@@ -13,6 +13,18 @@ import { Schema } from 'effect'
|
|
|
13
13
|
export const createNote = defineMutation({
|
|
14
14
|
name: 'notes.create',
|
|
15
15
|
target: { table: 'notes', op: 'insert' },
|
|
16
|
+
// `requireFlag(ctx, 'newEditor')` in the executor answers "is this feature
|
|
17
|
+
// switched on"; this line answers "who may call it". Two different questions
|
|
18
|
+
// — a flag flipped on for everyone would otherwise silently answer both.
|
|
19
|
+
//
|
|
20
|
+
// Open because no auth strategy and no rbac ship here, so every caller is an
|
|
21
|
+
// anonymous Subject holding no scopes and a `guards: [{ scope }]` would deny
|
|
22
|
+
// all of them. The write carries only what the caller sent, into the tenant
|
|
23
|
+
// on the request.
|
|
24
|
+
openAccess:
|
|
25
|
+
'inserts a note built only from caller-supplied fields into the request\'s tenant. The '
|
|
26
|
+
+ '`newEditor` flag decides whether the feature runs; this decides who may call it — a '
|
|
27
|
+
+ 'release control is not an access decision.',
|
|
16
28
|
input: Schema.Struct({
|
|
17
29
|
tenantId: Schema.String,
|
|
18
30
|
title: Schema.NonEmptyString,
|
|
@@ -13,19 +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/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.35.0",
|
|
17
|
+
"@voltro/database": "0.35.0",
|
|
18
|
+
"@voltro/env": "0.35.0",
|
|
19
|
+
"@voltro/plugin-flags": "0.35.0",
|
|
20
|
+
"@voltro/plugin-multitenancy": "0.35.0",
|
|
21
|
+
"@voltro/protocol": "0.35.0",
|
|
22
|
+
"@voltro/runtime": "0.35.0",
|
|
23
|
+
"@voltro/sql-postgres": "0.35.0",
|
|
24
24
|
"effect": "^3.22.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@voltro/testing": "0.
|
|
27
|
+
"@voltro/testing": "0.35.0",
|
|
28
28
|
"typescript": "^6.0.3",
|
|
29
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
29
30
|
"vitest": "^4.1.10"
|
|
30
31
|
}
|
|
31
32
|
}
|
|
@@ -11,17 +11,32 @@ pnpm --filter @{{projectName}}/{{appName}} dev
|
|
|
11
11
|
# → http://localhost:4000 (store: memory — zero infra)
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
**No encryption key ships with this template** — a key committed to a template
|
|
15
|
+
is a key published to everyone who downloads it. `VOLTRO_FIELD_ENCRYPTION_KEY`
|
|
16
|
+
is declared with `generate: 'hex'`, so `voltro dev` mints a unique one into a
|
|
17
|
+
gitignored `.env.local` on first boot. Your deployment mints its own:
|
|
18
|
+
`voltro secret generate field-encryption`.
|
|
16
19
|
|
|
17
20
|
## What this shows
|
|
18
21
|
|
|
19
|
-
- **Field encryption** — `profiles.ssn` is `text().encrypted()`.
|
|
20
|
-
plaintext; the store middleware encrypts it (AES-256-GCM) on write
|
|
21
|
-
decrypts on read, so handlers always see plaintext while the column holds
|
|
22
|
-
opaque `enc:v1:…` string at rest. `governancePlugin({ fieldEncryption: true })`
|
|
22
|
+
- **Field encryption** — `profiles.ssn` is `text().encrypted().serverOnly()`.
|
|
23
|
+
You pass plaintext; the store middleware encrypts it (AES-256-GCM) on write
|
|
24
|
+
and decrypts on read, so handlers always see plaintext while the column holds
|
|
25
|
+
an opaque `enc:v1:…` string at rest. `governancePlugin({ fieldEncryption: true })`
|
|
23
26
|
registers the cipher from `VOLTRO_FIELD_ENCRYPTION_KEY`. Boot fails loud if an
|
|
24
27
|
`.encrypted()` column exists but no cipher is registered.
|
|
28
|
+
- **…and why the second marker is there.** `.encrypted()` is about bytes AT
|
|
29
|
+
REST; it says nothing about the wire, and the runtime decrypts for the
|
|
30
|
+
handler — so an encrypted column reaches a client exactly like any other
|
|
31
|
+
unless it is also `.serverOnly()`. `profiles.get` therefore returns the last
|
|
32
|
+
four digits it derived server-side, which proves the cipher ran without
|
|
33
|
+
publishing the value.
|
|
34
|
+
- **Every procedure declares an access decision.** `guards: [{ scope }]` or
|
|
35
|
+
`openAccess: '<why>'`, or the app refuses to boot (`security.defaultDeny`).
|
|
36
|
+
Both procedures here are `openAccess` with the reason in the file — this
|
|
37
|
+
template configures no auth strategy and no rbac, so every caller resolves to
|
|
38
|
+
an anonymous Subject holding no scopes and a scope guard would deny 100% of
|
|
39
|
+
traffic. Add an identity (see `api-auth` / `api-rbac`), then the guard.
|
|
25
40
|
- **GDPR** — admin-gated `governance.export` / `governance.erase` walk the
|
|
26
41
|
declared `subjectScopes` (`[{ table: 'profiles', subjectField: 'id' }]`) to
|
|
27
42
|
bundle or erase everything belonging to a subject.
|
|
@@ -33,12 +48,14 @@ The `.env` ships a DEV encryption key. Generate your own with
|
|
|
33
48
|
## Try it
|
|
34
49
|
|
|
35
50
|
```bash
|
|
36
|
-
# create a profile, then read it back —
|
|
51
|
+
# create a profile, then read it back — the cipher round-trips, the value doesn't:
|
|
37
52
|
ID=$(curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
38
53
|
-d '{"tag":"profiles.create","input":{"name":"Ada","email":"ada@acme.com","ssn":"123-45-6789"}}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["id"])')
|
|
39
54
|
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
40
55
|
-d "{\"tag\":\"profiles.get\",\"input\":{\"id\":\"$ID\"}}"
|
|
41
|
-
# → { ok:true, result:{ …,
|
|
56
|
+
# → { ok:true, result:{ …, ssnLast4:"6789" } }
|
|
57
|
+
# The handler read "123-45-6789" as plaintext (the cipher decrypted it) and
|
|
58
|
+
# published four digits, because the column is `.serverOnly()`.
|
|
42
59
|
|
|
43
60
|
# record consent (a mutation):
|
|
44
61
|
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
@@ -59,3 +76,8 @@ subject with the `admin:full` scope (see [`api-rbac`](https://voltro.cloud/docs/
|
|
|
59
76
|
whole (PII, tokens, notes).
|
|
60
77
|
- **The key is everything.** GCM fails closed on a bad key — you get an error,
|
|
61
78
|
never silent corruption. Back the key up; rotating it needs re-encrypting.
|
|
79
|
+
- **`.encrypted()` is not `.serverOnly()`.** Three orthogonal markers, three
|
|
80
|
+
questions: `.encrypted()` = encrypted at rest, `.serverOnly()` = may it leave
|
|
81
|
+
the server at all, `.sensitive()`/`.safe()` = may it appear in a
|
|
82
|
+
`voltro data export`. Using one to answer another's question is the mistake —
|
|
83
|
+
say both when you mean both.
|