@voltro/cli 0.33.0 → 0.34.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 +1801 -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-BICVGnEC.js +2 -0
- package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DZF_f0_I.js} +46 -46
- 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-CE7Ca9gm.js +711 -0
- package/dist/cacheCommand-DA4OH9xt.js +42 -0
- package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
- package/dist/checkCommand-Dv8r9tGO.js +231 -0
- package/dist/checkCommand-YZDmkAGd.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-CNXKlcvD.js +54 -0
- package/dist/codegen-CYM3Zqrf.js +605 -0
- package/dist/codegen-ChBi_hVa.js +2 -0
- package/dist/codegenCommand-DC6w2tNZ.js +30 -0
- package/dist/codemodRunner-DRRqXR74.js +5243 -0
- package/dist/commandRunner-BLAEFLjp.js +47 -0
- package/dist/commands-gutsz-Ac.js +808 -0
- package/dist/connectionConfig-UFlIEiys.js +66 -0
- package/dist/dashboardCommand-3YG8p-UA.js +25 -0
- package/dist/dataCommand-qL0r7fPO.js +535 -0
- package/dist/dataProfile-dW-PsfLB.js +15 -0
- package/dist/{dbCommand-DTLKAfbA.js → dbCommand-B6X0FZbq.js} +551 -401
- package/dist/dbCommand-CpdKLeQq.js +2 -0
- package/dist/{dev-C_P8FLSx.js → dev-CerMd0mW.js} +3122 -3129
- package/dist/dev-CoG-ZPx8.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-BvqGBwNG.js +2 -0
- package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-CnDXQxfa.js} +520 -1278
- package/dist/dormancyCommand-Dszo57d6.js +69 -0
- package/dist/e2eCmd-BRabZww-.js +147 -0
- package/dist/embeddingsCommand-C0sKVRo1.js +73 -0
- package/dist/envCommand-DPgdV1Bq.js +60 -0
- package/dist/evalCommand-6RUfPen4.js +118 -0
- package/dist/evolveCommand-DHpkgjgH.js +281 -0
- package/dist/fileTaxonomy-CJfgOllU.js +457 -0
- package/dist/frameworkTableAssembly-BGHmck-x.js +2 -0
- package/dist/{frameworkTableAssembly-BwIrO5nv.js → frameworkTableAssembly-DkkP6BgC.js} +184 -148
- package/dist/generateCommand-oibemh97.js +147 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +4 -3
- package/dist/infoCommand-BJw9nLUR.js +60 -0
- package/dist/{inspect-BUUjt773.js → inspect-CBqFtAKA.js} +82 -40
- package/dist/inspect-C_T_WGvl.js +2 -0
- package/dist/inspectCmd-Bppy-GGw.js +224 -0
- package/dist/inspectFetch-Cm8_wVvp.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-CCca3KRZ.js +260 -0
- package/dist/manifestBuild-ChsKAhmn.js +2 -0
- package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-sxpwdKY1.js} +1 -1
- package/dist/metaCommands-7MJfZ5cf.js +196 -0
- package/dist/migrate-CBwOt_iV.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/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-CRxalXTs.js +91 -0
- package/dist/scheduleCmd--jksTrf6.js +69 -0
- package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
- package/dist/schemaIr-UJybUUZW.js +103 -0
- package/dist/{sdkgen-C81QIkiL.js → sdkgen-CYJscZC7.js} +111 -209
- package/dist/seedRunner-TFHHiToI.js +329 -0
- package/dist/serveCommand-B_isw7q4.js +1647 -0
- package/dist/serveCommand-DOvbgRnQ.js +2 -0
- package/dist/serveEntry.js +5 -5
- package/dist/serverlessCommand-CfJZy6dS.js +482 -0
- package/dist/start-9LiUOfES.js +1087 -0
- package/dist/start-B-9Nsp-S.js +3 -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-CkEZQrtt.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-Bkptutss.js +585 -0
- package/dist/updateCommand-us1_hdIC.js +2 -0
- package/dist/{inspectMetrics-1xzTKAFx.js → webDev-CBYvPqQr.js} +975 -1559
- package/dist/webDev-Cg-fFiyd2.js +2 -0
- package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
- package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
- package/dist/webhooksCommand-CID96Rga.js +267 -0
- package/dist/workflowsCmd-D1VTmLMY.js +608 -0
- package/package.json +179 -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 +22 -5
- package/templates/agent-docs/ai.md +370 -0
- package/templates/agent-docs/authentication.md +265 -31
- package/templates/agent-docs/caching.md +6 -0
- package/templates/agent-docs/cli.md +794 -50
- package/templates/agent-docs/data.md +550 -11
- package/templates/agent-docs/database/migrations.md +174 -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 +69 -3
- package/templates/agent-docs/introduction.md +87 -16
- package/templates/agent-docs/local-first-mobile.md +79 -4
- package/templates/agent-docs/multi-tenancy.md +95 -20
- package/templates/agent-docs/observability.md +58 -3
- package/templates/agent-docs/plugins/ai-flows.md +161 -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 +1 -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 +157 -6
- package/templates/agent-docs/plugins/sso-saml.md +47 -8
- package/templates/agent-docs/plugins/webhooks.md +105 -0
- package/templates/agent-docs/plugins.md +150 -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 +10 -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 +1700 -104
- package/templates/agent-docs/workflows.md +130 -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/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-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
|
@@ -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.34.0",
|
|
17
|
+
"@voltro/database": "0.34.0",
|
|
18
|
+
"@voltro/env": "0.34.0",
|
|
19
|
+
"@voltro/local-first": "0.34.0",
|
|
20
|
+
"@voltro/plugin-multitenancy": "0.34.0",
|
|
21
|
+
"@voltro/protocol": "0.34.0",
|
|
22
|
+
"@voltro/runtime": "0.34.0",
|
|
23
23
|
"effect": "^3.22.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@voltro/testing": "0.
|
|
26
|
+
"@voltro/testing": "0.34.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.34.0",
|
|
16
|
+
"@voltro/database": "0.34.0",
|
|
17
|
+
"@voltro/env": "0.34.0",
|
|
18
|
+
"@voltro/plugin-governance": "0.34.0",
|
|
19
|
+
"@voltro/plugin-multitenancy": "0.34.0",
|
|
20
|
+
"@voltro/protocol": "0.34.0",
|
|
21
|
+
"@voltro/runtime": "0.34.0",
|
|
22
22
|
"effect": "^3.22.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.
|
|
25
|
+
"@voltro/testing": "0.34.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.34.0",
|
|
16
|
+
"@voltro/database": "0.34.0",
|
|
17
|
+
"@voltro/env": "0.34.0",
|
|
18
|
+
"@voltro/plugin-multitenancy": "0.34.0",
|
|
19
|
+
"@voltro/protocol": "0.34.0",
|
|
20
|
+
"@voltro/runtime": "0.34.0",
|
|
21
|
+
"@voltro/workflow": "0.34.0",
|
|
22
22
|
"effect": "^3.22.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.
|
|
25
|
+
"@voltro/testing": "0.34.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.34.0",
|
|
17
|
+
"@voltro/database": "0.34.0",
|
|
18
|
+
"@voltro/env": "0.34.0",
|
|
19
|
+
"@voltro/plugin-flags": "0.34.0",
|
|
20
|
+
"@voltro/plugin-multitenancy": "0.34.0",
|
|
21
|
+
"@voltro/protocol": "0.34.0",
|
|
22
|
+
"@voltro/runtime": "0.34.0",
|
|
23
|
+
"@voltro/sql-postgres": "0.34.0",
|
|
24
24
|
"effect": "^3.22.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@voltro/testing": "0.
|
|
27
|
+
"@voltro/testing": "0.34.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.
|
|
@@ -1,7 +1,22 @@
|
|
|
1
|
+
// Executor for `profiles.get`.
|
|
2
|
+
//
|
|
3
|
+
// `p['ssn']` is PLAINTEXT here — the store middleware decrypted the
|
|
4
|
+
// `.encrypted()` column on the way out, and the handler never sees the
|
|
5
|
+
// `enc:v1:…` ciphertext. That is the round-trip this template exists to show,
|
|
6
|
+
// and the last-four projection below is what proves it happened.
|
|
7
|
+
//
|
|
8
|
+
// The projection is not decoration: the column is `.serverOnly()`, so the full
|
|
9
|
+
// value must not reach a client. Deriving the suffix HERE — rather than sending
|
|
10
|
+
// the whole string and trimming it in the browser — is the difference between a
|
|
11
|
+
// value that never left the server and one that did.
|
|
1
12
|
import type { AppContext } from '@voltro/runtime'
|
|
2
13
|
import { eq } from '@voltro/database'
|
|
3
14
|
import { database } from '../database/schema'
|
|
4
15
|
|
|
16
|
+
/** Last four characters, or '' when there is nothing to show. Never throws on a
|
|
17
|
+
* short or empty value — a masked field is not a place to fail a read. */
|
|
18
|
+
const last4 = (value: string): string => (value.length >= 4 ? value.slice(-4) : '')
|
|
19
|
+
|
|
5
20
|
const execute = async (input: { id: string }, ctx: AppContext) => {
|
|
6
21
|
const rows = await ctx.store.query(database.profiles.where(eq('id', input.id)).descriptor)
|
|
7
22
|
const p = rows[0]
|
|
@@ -10,7 +25,8 @@ const execute = async (input: { id: string }, ctx: AppContext) => {
|
|
|
10
25
|
id: p['id'] as string,
|
|
11
26
|
name: p['name'] as string,
|
|
12
27
|
email: p['email'] as string,
|
|
13
|
-
|
|
28
|
+
// Decrypted by the store middleware, then narrowed to what may leave.
|
|
29
|
+
ssnLast4: last4(p['ssn'] as string),
|
|
14
30
|
}
|
|
15
31
|
}
|
|
16
32
|
|
|
@@ -1,17 +1,42 @@
|
|
|
1
|
-
// Reads a profile back
|
|
2
|
-
//
|
|
3
|
-
// `enc:v1:…` ciphertext
|
|
1
|
+
// Reads a profile back. The store middleware DECRYPTS the `.encrypted()` column
|
|
2
|
+
// on read, so the handler holds `ssn` as plaintext and never touches the
|
|
3
|
+
// `enc:v1:…` ciphertext — that is the round-trip this template exists to show.
|
|
4
|
+
//
|
|
5
|
+
// What crosses the WIRE is a SEPARATE decision, and the schema makes it: `ssn`
|
|
6
|
+
// is also `.serverOnly()`, so the full value may not leave the server. The
|
|
7
|
+
// executor proves the decryption worked by returning the last four digits it
|
|
8
|
+
// derived from the plaintext — which is what a support UI would actually
|
|
9
|
+
// render. Encryption at rest is not an exposure marker; `.serverOnly()` is.
|
|
4
10
|
|
|
5
11
|
import { defineAction } from '@voltro/protocol'
|
|
6
12
|
import { Schema } from 'effect'
|
|
7
13
|
|
|
8
14
|
export const getProfile = defineAction({
|
|
9
15
|
name: 'profiles.get',
|
|
16
|
+
// Open, and the reason is a claim you can check against the two files beside
|
|
17
|
+
// this one: the full `ssn` is `.serverOnly()` in `database/schema.ts` and is
|
|
18
|
+
// not named in the output below, so the sensitive value cannot cross the wire
|
|
19
|
+
// at all. What is left — name, email, four digits — is the row this demo's
|
|
20
|
+
// own `profiles.create` put there; the template ships no seed and no other
|
|
21
|
+
// writer.
|
|
22
|
+
//
|
|
23
|
+
// A `guards: [{ scope: 'profiles:read' }]` is what a real deployment wants
|
|
24
|
+
// here, and it is unsatisfiable in THIS app: no auth strategy and no rbac are
|
|
25
|
+
// configured, so every caller resolves to an anonymous Subject holding no
|
|
26
|
+
// scopes and the guard would deny 100% of traffic — an outage, not security.
|
|
27
|
+
// Add an identity (see `api-auth` / `api-rbac`), then the guard.
|
|
28
|
+
openAccess:
|
|
29
|
+
'returns name, email and the last four digits derived server-side from the decrypted '
|
|
30
|
+
+ '`ssn`; the full value is `.serverOnly()` and is absent from this output, so it cannot '
|
|
31
|
+
+ 'cross the wire. Rows come only from this demo\'s own `profiles.create` — no seed, no '
|
|
32
|
+
+ 'other writer.',
|
|
10
33
|
input: Schema.Struct({ id: Schema.String }),
|
|
11
34
|
output: Schema.NullOr(Schema.Struct({
|
|
12
35
|
id: Schema.String,
|
|
13
36
|
name: Schema.String,
|
|
14
37
|
email: Schema.String,
|
|
15
|
-
|
|
38
|
+
// Proof the cipher decrypted the column, without publishing it. Empty when
|
|
39
|
+
// the stored value is shorter than four characters.
|
|
40
|
+
ssnLast4: Schema.String,
|
|
16
41
|
})),
|
|
17
42
|
})
|
|
@@ -28,10 +28,22 @@ export const profiles = table('profiles', {
|
|
|
28
28
|
id: id({ prefix: 'prof' }),
|
|
29
29
|
name: text(),
|
|
30
30
|
email: text(),
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
31
|
+
// TWO markers, because they answer two different questions — say both when
|
|
32
|
+
// you mean both:
|
|
33
|
+
//
|
|
34
|
+
// .encrypted() → AES-256-GCM AT REST. The handler always sees plaintext;
|
|
35
|
+
// the stored value is an opaque `enc:v1:…` string. Encrypt
|
|
36
|
+
// only what you read back WHOLE (you can't filter/sort on
|
|
37
|
+
// it in SQL — it's ciphertext on disk).
|
|
38
|
+
// .serverOnly() → may this value leave the server AT ALL. It may not.
|
|
39
|
+
//
|
|
40
|
+
// Reading `.encrypted()` as "safe to expose" is a category error and a
|
|
41
|
+
// tempting one: the runtime DECRYPTS for the handler, so an encrypted column
|
|
42
|
+
// flows to a client exactly like any other unless it is also `.serverOnly()`.
|
|
43
|
+
// `crud.*` strips serverOnly columns for you; a hand-written procedure must
|
|
44
|
+
// simply not name it in its output — see `actions/profiles.get.action.ts`,
|
|
45
|
+
// which returns the last four digits it derived server-side instead.
|
|
46
|
+
ssn: text().encrypted().serverOnly(),
|
|
35
47
|
})
|
|
36
48
|
.with(audit()) // createdAt drives the retention sweep
|
|
37
49
|
|