@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
|
@@ -9,6 +9,15 @@ import { Schema } from 'effect'
|
|
|
9
9
|
export const createProject = defineMutation({
|
|
10
10
|
name: 'projects.create',
|
|
11
11
|
target: { table: 'projects', op: 'insert' },
|
|
12
|
+
// Members only. Note the ORDER, because it is the whole reason this line
|
|
13
|
+
// exists next to `requireEntitlement`: the guard runs in the dispatch spine
|
|
14
|
+
// BEFORE the executor and before the transaction opens, so an anonymous call
|
|
15
|
+
// is refused without ever consuming a paying tenant's metered quota. A quota
|
|
16
|
+
// gate alone would let a stranger burn the free plan's three projects.
|
|
17
|
+
//
|
|
18
|
+
// Satisfiable as shipped: a matched session gets `projects:write` from
|
|
19
|
+
// `auth.resolveScopes` (authz.ts); anonymous callers hold no scopes.
|
|
20
|
+
guards: [{ scope: 'projects:write' }],
|
|
12
21
|
input: Schema.Struct({ name: Schema.NonEmptyString }),
|
|
13
22
|
output: Schema.Struct({
|
|
14
23
|
id: Schema.String,
|
|
@@ -3,30 +3,34 @@
|
|
|
3
3
|
"version": "0.0.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./rpcGroup": "./rpcGroup.generated.ts"
|
|
8
|
+
},
|
|
6
9
|
"scripts": {
|
|
7
10
|
"dev": "voltro dev .",
|
|
8
11
|
"lint": "voltro doctor .",
|
|
9
12
|
"migrate": "voltro migrate",
|
|
10
13
|
"test": "voltro test",
|
|
11
|
-
"typecheck": "tsc --noEmit"
|
|
14
|
+
"typecheck": "voltro codegen . && tsc --noEmit"
|
|
12
15
|
},
|
|
13
16
|
"dependencies": {
|
|
14
17
|
"@effect/platform": "^0.97.0",
|
|
15
18
|
"@effect/rpc": "^0.76.0",
|
|
16
|
-
"@voltro/cli": "0.
|
|
17
|
-
"@voltro/database": "0.
|
|
18
|
-
"@voltro/env": "0.
|
|
19
|
-
"@voltro/plugin-auth": "0.
|
|
20
|
-
"@voltro/plugin-billing": "0.
|
|
21
|
-
"@voltro/plugin-multitenancy": "0.
|
|
22
|
-
"@voltro/protocol": "0.
|
|
23
|
-
"@voltro/runtime": "0.
|
|
24
|
-
"@voltro/sql-postgres": "0.
|
|
19
|
+
"@voltro/cli": "0.34.0",
|
|
20
|
+
"@voltro/database": "0.34.0",
|
|
21
|
+
"@voltro/env": "0.34.0",
|
|
22
|
+
"@voltro/plugin-auth": "0.34.0",
|
|
23
|
+
"@voltro/plugin-billing": "0.34.0",
|
|
24
|
+
"@voltro/plugin-multitenancy": "0.34.0",
|
|
25
|
+
"@voltro/protocol": "0.34.0",
|
|
26
|
+
"@voltro/runtime": "0.34.0",
|
|
27
|
+
"@voltro/sql-postgres": "0.34.0",
|
|
25
28
|
"effect": "^3.22.0"
|
|
26
29
|
},
|
|
27
30
|
"devDependencies": {
|
|
28
|
-
"@voltro/testing": "0.
|
|
31
|
+
"@voltro/testing": "0.34.0",
|
|
29
32
|
"typescript": "^6.0.3",
|
|
33
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
30
34
|
"vitest": "^4.1.10"
|
|
31
35
|
}
|
|
32
36
|
}
|
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
// Streaming subscription of the tenant's teammate invites. Tenant-scoped like
|
|
2
2
|
// projects.list — read live from the frontend's team page with
|
|
3
|
-
// `useSubscription('
|
|
3
|
+
// `useSubscription('invites.list')`.
|
|
4
4
|
import { defineQuery } from '@voltro/protocol'
|
|
5
5
|
import { Schema } from 'effect'
|
|
6
6
|
|
|
7
7
|
export const listInvites = defineQuery({
|
|
8
8
|
name: 'invites.list',
|
|
9
|
+
// Members only — and of the four domain procedures this is the one whose
|
|
10
|
+
// guard is load-bearing rather than tidy: the rows are teammates' EMAIL
|
|
11
|
+
// ADDRESSES. `tenant()` keeps them inside the tenant; this keeps them away
|
|
12
|
+
// from a caller who has not signed in to that tenant at all.
|
|
13
|
+
//
|
|
14
|
+
// Satisfiable as shipped: a matched session gets `team:read` from
|
|
15
|
+
// `auth.resolveScopes` (authz.ts). Anonymous callers hold no scopes and are
|
|
16
|
+
// refused with a typed `ScopeError`.
|
|
17
|
+
guards: [{ scope: 'team:read' }],
|
|
9
18
|
input: Schema.Struct({}),
|
|
10
|
-
output: Schema.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
output: Schema.Array(
|
|
20
|
+
Schema.Struct({
|
|
21
|
+
id: Schema.String,
|
|
22
|
+
email: Schema.String,
|
|
23
|
+
status: Schema.String,
|
|
24
|
+
tenantId: Schema.String,
|
|
25
|
+
createdAt: Schema.Date,
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
17
28
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Streaming subscription of the tenant's projects. Every change to `projects`
|
|
2
2
|
// for the caller's tenant lands as a delta — the frontend upgrades its first
|
|
3
|
-
// paint to live with `useSubscription('
|
|
3
|
+
// paint to live with `useSubscription('projects.list')`. The runtime
|
|
4
4
|
// AND-merges the tenant predicate (the table carries `tenant()`), so this never
|
|
5
5
|
// leaks across tenants — no manual `eq('tenantId', ...)` needed.
|
|
6
6
|
import { defineQuery } from '@voltro/protocol'
|
|
@@ -8,11 +8,22 @@ import { Schema } from 'effect'
|
|
|
8
8
|
|
|
9
9
|
export const listProjects = defineQuery({
|
|
10
10
|
name: 'projects.list',
|
|
11
|
+
// Members only. `tenant()` decides WHICH rows; this decides WHO may open the
|
|
12
|
+
// subscription at all — and the guard is re-checked on EVERY delivery, so a
|
|
13
|
+
// member whose access is revoked mid-session stops receiving rows instead of
|
|
14
|
+
// keeping a live feed open on a grant that has expired.
|
|
15
|
+
//
|
|
16
|
+
// Satisfiable by this app as shipped: sign in → `voltroPasswordStrategy`
|
|
17
|
+
// matches the cookie → `auth.resolveScopes` (authz.ts) grants
|
|
18
|
+
// `projects:read`. Anonymous → no scopes → refused.
|
|
19
|
+
guards: [{ scope: 'projects:read' }],
|
|
11
20
|
input: Schema.Struct({}),
|
|
12
|
-
output: Schema.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
output: Schema.Array(
|
|
22
|
+
Schema.Struct({
|
|
23
|
+
id: Schema.String,
|
|
24
|
+
name: Schema.String,
|
|
25
|
+
tenantId: Schema.String,
|
|
26
|
+
createdAt: Schema.Date,
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
18
29
|
})
|
|
@@ -0,0 +1,135 @@
|
|
|
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 this directory keeps passing while the
|
|
14
|
+
// dashboard answers `ScopeError` to its own sign-in flow. So the subjects below
|
|
15
|
+
// are built the way the framework builds them (`applyScopeDecision` over this
|
|
16
|
+
// app's real `resolveScopes`), never by writing the scopes the assertions want.
|
|
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 { MEMBER_SCOPES, resolveScopes } from '../authz'
|
|
27
|
+
|
|
28
|
+
import { listProjects } from '../queries/projects.list.query'
|
|
29
|
+
import { listInvites } from '../queries/invites.list.query'
|
|
30
|
+
import { createProject } from '../mutations/projects.create.mutation'
|
|
31
|
+
import { createInvite } from '../mutations/invites.create.mutation'
|
|
32
|
+
import { me } from '../actions/me.action'
|
|
33
|
+
|
|
34
|
+
/** The Subject a browser with no session cookie gets: `voltroPasswordStrategy`
|
|
35
|
+
* finds nothing to match, so the composer falls back to anonymous. */
|
|
36
|
+
const visitor = anonymousSubject('acme')
|
|
37
|
+
|
|
38
|
+
/** The Subject a signed-in member gets. The cookie resolves to a `user` Subject
|
|
39
|
+
* carrying NO scopes (a cookie never carries authority), then the composer
|
|
40
|
+
* applies this app's own `resolveScopes`. Built through the framework's
|
|
41
|
+
* `applyScopeDecision` so this file cannot disagree with the runtime about how
|
|
42
|
+
* a grant lands on a subject. */
|
|
43
|
+
const member = (): Subject => {
|
|
44
|
+
const matched: Subject = { type: 'user', id: 'user_1', tenantId: 'acme', scopes: [] }
|
|
45
|
+
return applyScopeDecision(matched, { kind: 'grant', scopes: resolveScopes(matched) }).subject
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Exactly what the dispatch spine asks before an executor runs — and, for the
|
|
49
|
+
* two creates, before `requireEntitlement` can spend the tenant's quota. */
|
|
50
|
+
const decide = (
|
|
51
|
+
descriptor: { readonly name: string; readonly guards?: ReadonlyArray<unknown> | undefined },
|
|
52
|
+
subject: Subject,
|
|
53
|
+
): ScopeError | null =>
|
|
54
|
+
checkGuards(
|
|
55
|
+
subject,
|
|
56
|
+
descriptor.guards as Parameters<typeof checkGuards>[1],
|
|
57
|
+
{ defaultDeny: true, procedure: descriptor.name },
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const GUARDED = [listProjects, listInvites, createProject, createInvite] as const
|
|
61
|
+
|
|
62
|
+
describe('the caller this app actually has — a signed-in member', () => {
|
|
63
|
+
// If any of these regress the dashboard boots and shows nothing but denials,
|
|
64
|
+
// which no other test in this directory can see.
|
|
65
|
+
it.each(GUARDED.map((d) => [d.name, d] as const))('%s is callable', (_name, descriptor) => {
|
|
66
|
+
expect(decide(descriptor, member())).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('is not just passing because the guards are empty', () => {
|
|
70
|
+
// The silent-zero shape: `checkGuards` with no guards returns null too.
|
|
71
|
+
for (const d of GUARDED) expect(d.guards?.length ?? 0).toBeGreaterThan(0)
|
|
72
|
+
expect(GUARDED).toHaveLength(4)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('the caller with no session — every domain procedure is refused', () => {
|
|
77
|
+
it.each(GUARDED.map((d) => [d.name, d] as const))('%s denies an anonymous visitor', (_name, descriptor) => {
|
|
78
|
+
expect(decide(descriptor, visitor)).toBeInstanceOf(ScopeError)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('so a stranger cannot read teammate emails or spend a seat', () => {
|
|
82
|
+
// The two that carry real cost. `invites.list` returns email addresses;
|
|
83
|
+
// `invites.create` consumes the tenant's metered `seats` entitlement, and
|
|
84
|
+
// the guard runs BEFORE that gate — a quota is not an access control.
|
|
85
|
+
expect(decide(listInvites, visitor)).toBeInstanceOf(ScopeError)
|
|
86
|
+
expect(decide(createInvite, visitor)).toBeInstanceOf(ScopeError)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('…and that is structural, not a resolver quirk', () => {
|
|
90
|
+
// Pinned so a framework change would surface here: `applyScopeDecision`
|
|
91
|
+
// returns an anonymous Subject UNTOUCHED under both decision kinds (the
|
|
92
|
+
// schema has no `scopes` field to write). `auth.resolveScopes` also never
|
|
93
|
+
// runs for one — it fires only on a MATCHED subject — so a session cookie
|
|
94
|
+
// is the only way into the granted set.
|
|
95
|
+
const granted = applyScopeDecision(visitor, { kind: 'grant', scopes: [...MEMBER_SCOPES] })
|
|
96
|
+
expect(granted.subject).toEqual(visitor)
|
|
97
|
+
expect(decide(createProject, granted.subject)).toBeInstanceOf(ScopeError)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('session.me stays open, and that is the point', () => {
|
|
102
|
+
it('answers an anonymous caller instead of denying them', () => {
|
|
103
|
+
// A guard here would return `ScopeError` to the dashboard's SSR gate, which
|
|
104
|
+
// would then have nothing to branch on and would never redirect to /login.
|
|
105
|
+
expect(decide(me, visitor)).toBeNull()
|
|
106
|
+
expect(me.openAccess).toBeTruthy()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('the vocabulary is a boundary, not a rubber stamp', () => {
|
|
111
|
+
it('grants no wildcard and no admin bypass', () => {
|
|
112
|
+
expect(MEMBER_SCOPES).not.toContain('*')
|
|
113
|
+
expect(MEMBER_SCOPES).not.toContain('admin:full')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('separates inviting a person from creating a project', () => {
|
|
117
|
+
// Split by blast radius: one spends a seat and lets an outsider into the
|
|
118
|
+
// tenant, the other does not. Collapsing them into one scope is what makes
|
|
119
|
+
// a later `owner` role impossible to express.
|
|
120
|
+
const scopeOf = (d: { readonly guards?: ReadonlyArray<unknown> | undefined }): string =>
|
|
121
|
+
((d.guards ?? []) as ReadonlyArray<{ scope?: string }>)[0]?.scope ?? ''
|
|
122
|
+
expect(scopeOf(createInvite)).not.toBe(scopeOf(createProject))
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('every guard names a scope the resolver actually grants', () => {
|
|
126
|
+
// The other half of unsatisfiability: a typo'd scope makes a procedure
|
|
127
|
+
// permanently uncallable, and nothing else in this app would notice.
|
|
128
|
+
const granted = new Set(resolveScopes(member()))
|
|
129
|
+
for (const d of GUARDED) {
|
|
130
|
+
for (const g of (d.guards ?? []) as ReadonlyArray<{ scope?: string }>) {
|
|
131
|
+
expect(granted.has(g.scope ?? '')).toBe(true)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -8,6 +8,20 @@ import { Schema } from 'effect'
|
|
|
8
8
|
|
|
9
9
|
export const createArticle = defineMutation({
|
|
10
10
|
name: 'articles.create',
|
|
11
|
+
// Open — with one consequence worth naming because it reaches OUTSIDE the
|
|
12
|
+
// database: on commit the search plugin's ChangeEvent tap mirrors the row
|
|
13
|
+
// into the external index (Typesense / Meilisearch / Algolia). So an
|
|
14
|
+
// unguarded write here is an unguarded write there too, and the index is
|
|
15
|
+
// usually not tenant-partitioned by the same machinery your tables are.
|
|
16
|
+
//
|
|
17
|
+
// `assertOwnTenant` still rejects a `tenantId` that does not match the
|
|
18
|
+
// resolved subject, and no auth strategy or rbac ships in this template, so a
|
|
19
|
+
// `guards: [{ scope: 'articles:write' }]` would deny every caller. Add an
|
|
20
|
+
// identity, then that guard — before you point `search` at a real cluster.
|
|
21
|
+
openAccess:
|
|
22
|
+
'inserts an article carrying only caller-supplied fields into the caller\'s own tenant '
|
|
23
|
+
+ '(`assertOwnTenant` rejects a mismatch). On commit it also mirrors the row into the '
|
|
24
|
+
+ 'external search index, so guard it before pointing the plugin at a real cluster.',
|
|
11
25
|
target: {
|
|
12
26
|
table: 'articles',
|
|
13
27
|
op: 'insert',
|
|
@@ -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/plugin-multitenancy": "0.
|
|
20
|
-
"@voltro/plugin-search": "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/plugin-multitenancy": "0.34.0",
|
|
20
|
+
"@voltro/plugin-search": "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
|
}
|
|
@@ -7,13 +7,26 @@ import { Schema } from 'effect'
|
|
|
7
7
|
|
|
8
8
|
export const listArticles = defineQuery({
|
|
9
9
|
name: 'articles.list',
|
|
10
|
+
// Open: the rows are this template's own seeded demo articles
|
|
11
|
+
// (`seeds/articles.seed.ts`), and `tenant()` confines every delivery to the
|
|
12
|
+
// tenantId on the request. No auth strategy and no rbac ship here, so a
|
|
13
|
+
// `guards: [{ scope }]` would be unsatisfiable and deny 100% of traffic.
|
|
14
|
+
//
|
|
15
|
+
// The plugin's own `search.query` procedure is a SEPARATE surface with its
|
|
16
|
+
// own decision, made by the plugin — this line covers only the list.
|
|
17
|
+
openAccess:
|
|
18
|
+
'lists the request tenant\'s seeded demo articles (`tenant()` scopes every delivery); the '
|
|
19
|
+
+ 'seed holds no personal data. No auth strategy ships here, so the tenant comes from the '
|
|
20
|
+
+ 'caller\'s `x-tenant` header.',
|
|
10
21
|
input: Schema.Struct({}),
|
|
11
|
-
output: Schema.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
output: Schema.Array(
|
|
23
|
+
Schema.Struct({
|
|
24
|
+
id: Schema.String,
|
|
25
|
+
title: Schema.String,
|
|
26
|
+
body: Schema.String,
|
|
27
|
+
tag: Schema.String,
|
|
28
|
+
tenantId: Schema.String,
|
|
29
|
+
createdAt: Schema.Date,
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
19
32
|
})
|
|
@@ -14,15 +14,22 @@ voltro dev
|
|
|
14
14
|
|
|
15
15
|
A status page is read by everyone and written by a few — so:
|
|
16
16
|
|
|
17
|
-
- **Reads are PUBLIC.** `incidents.live`, `updates.list`, `components.list`
|
|
18
|
-
|
|
19
|
-
'incidents.live')` and the timeline updates live.
|
|
17
|
+
- **Reads are PUBLIC.** `incidents.live`, `updates.list`, `components.list` each
|
|
18
|
+
declare `openAccess: '<why>'`, so an anonymous browser can
|
|
19
|
+
`useSubscription('app', 'incidents.live')` and the timeline updates live.
|
|
20
20
|
- **Writes are OPERATOR-ONLY.** Every mutation declares
|
|
21
21
|
`guards: [{ scope: 'status:write' }]`, enforced by `@voltro/plugin-rbac` in the
|
|
22
22
|
dispatch spine before the executor. The demo resolver (`authz.ts`) grants the
|
|
23
23
|
`operator` role to the `ops` tenant; everyone else — anonymous included — is
|
|
24
24
|
denied.
|
|
25
25
|
|
|
26
|
+
Note that **both** of those are DECISIONS, and the framework requires one or the
|
|
27
|
+
other: under `security.defaultDeny` (on by default) a wire-exposed procedure
|
|
28
|
+
that declares neither `guards:` nor `openAccess:` refuses to boot. `openAccess`
|
|
29
|
+
is not the lax option — it is the honest spelling of an open endpoint, and the
|
|
30
|
+
reason it carries is what a reviewer reads and what `voltro doctor` prints. This
|
|
31
|
+
template is the clearest place in the library to see the two side by side.
|
|
32
|
+
|
|
26
33
|
## Surface
|
|
27
34
|
|
|
28
35
|
| Tag | Kind | Guard |
|
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The shape of a status page is asymmetric, and this config encodes it:
|
|
4
4
|
// • READS are PUBLIC — `incidents.live` / `updates.list` / `components.list`
|
|
5
|
-
//
|
|
6
|
-
// the live timeline. (A status page that requires a login
|
|
7
|
-
// an outage.)
|
|
5
|
+
// each declare `openAccess: '<why>'`, so an anonymous visitor's browser can
|
|
6
|
+
// subscribe and see the live timeline. (A status page that requires a login
|
|
7
|
+
// is useless during an outage.)
|
|
8
8
|
// • WRITES are OPERATOR-ONLY — every mutation declares a `status:write` guard.
|
|
9
|
+
//
|
|
10
|
+
// Both halves are DECISIONS, and the framework insists on one or the other:
|
|
11
|
+
// under `security.defaultDeny` a wire-exposed procedure declaring neither does
|
|
12
|
+
// not boot. `openAccess` is not the lax option — it is the honest spelling of an
|
|
13
|
+
// open endpoint, and it leaves the reason where a reviewer will read it.
|
|
9
14
|
// `rbacPlugin` resolves each caller's ROLES to SCOPES and the framework
|
|
10
15
|
// enforces the guard in the dispatch spine, before the executor. `voltro
|
|
11
16
|
// check` can see the guard statically.
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// test that redeclares the roles only proves its own copy is self-consistent).
|
|
4
4
|
//
|
|
5
5
|
// A status page has exactly two kinds of caller: the PUBLIC (anonymous
|
|
6
|
-
// visitors, who only READ — the live queries
|
|
7
|
-
// (who post incidents). So there is one write scope,
|
|
8
|
-
// the `operator` role; `admin` gets the wildcard
|
|
6
|
+
// visitors, who only READ — the live queries declare `openAccess` with their
|
|
7
|
+
// reason) and OPERATORS (who post incidents). So there is one write scope,
|
|
8
|
+
// `status:write`, held by the `operator` role; `admin` gets the wildcard
|
|
9
|
+
// bypass. Both kinds of caller are DECIDED about: `security.defaultDeny`
|
|
10
|
+
// refuses to boot a procedure that declares neither form.
|
|
9
11
|
//
|
|
10
12
|
// This map is ALSO the app's declared scope vocabulary: `rbacPlugin` publishes
|
|
11
13
|
// its union, and `voltro check` errors on any descriptor guard requiring a
|
|
@@ -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/plugin-rbac": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
22
|
-
"@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-rbac": "0.34.0",
|
|
20
|
+
"@voltro/protocol": "0.34.0",
|
|
21
|
+
"@voltro/runtime": "0.34.0",
|
|
22
|
+
"@voltro/sql-postgres": "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
|
}
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
// Live list of service components + their current status
|
|
1
|
+
// Live list of service components + their current status — public by design.
|
|
2
|
+
// `components.create` beside it is `status:write`-guarded: reads are for
|
|
3
|
+
// visitors, writes are for operators.
|
|
2
4
|
import { defineQuery } from '@voltro/protocol'
|
|
3
5
|
import { Schema } from 'effect'
|
|
4
6
|
|
|
5
7
|
export const componentsList = defineQuery({
|
|
6
8
|
name: 'components.list',
|
|
9
|
+
openAccess:
|
|
10
|
+
'the public component roster a status page exists to publish — this operator\'s own '
|
|
11
|
+
+ 'service names and their current status, nothing caller-derived and no personal data. '
|
|
12
|
+
+ 'Writing that roster is `status:write`-guarded.',
|
|
7
13
|
input: Schema.Struct({}),
|
|
8
|
-
output: Schema.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
output: Schema.Array(
|
|
15
|
+
Schema.Struct({
|
|
16
|
+
id: Schema.String,
|
|
17
|
+
name: Schema.String,
|
|
18
|
+
status: Schema.String,
|
|
19
|
+
}),
|
|
20
|
+
),
|
|
13
21
|
})
|
|
@@ -1,20 +1,33 @@
|
|
|
1
|
-
// The live incident feed — the heart of the status page.
|
|
2
|
-
// anonymous visitor's browser can subscribe: `useSubscription('app',
|
|
1
|
+
// The live incident feed — the heart of the status page. Deliberately OPEN, so
|
|
2
|
+
// an anonymous visitor's browser can subscribe: `useSubscription('app',
|
|
3
3
|
// 'incidents.live')`. Every insert/update to `incidents` lands as a delta, so a
|
|
4
4
|
// newly-declared incident (or a resolve) shows up on every open status page
|
|
5
5
|
// within seconds — no polling.
|
|
6
|
+
//
|
|
7
|
+
// This template is the clearest place in the library to see that `openAccess:`
|
|
8
|
+
// and `guards:` are two spellings of the SAME obligation, not a strict option
|
|
9
|
+
// and a lax one. The reads here are open on purpose; the four mutations beside
|
|
10
|
+
// them carry `guards: [{ scope: 'status:write' }]` and are satisfiable through
|
|
11
|
+
// the real role map in `authz.ts`. Both are decisions. What the framework
|
|
12
|
+
// refuses to boot on is neither.
|
|
6
13
|
import { defineQuery } from '@voltro/protocol'
|
|
7
14
|
import { Schema } from 'effect'
|
|
8
15
|
|
|
9
16
|
export const incidentsLive = defineQuery({
|
|
10
17
|
name: 'incidents.live',
|
|
18
|
+
openAccess:
|
|
19
|
+
'the public incident feed a status page exists to publish — title, impact, status and '
|
|
20
|
+
+ 'timestamps of THIS operator\'s own incidents, nothing caller-derived and no personal '
|
|
21
|
+
+ 'data. A status page that requires a login is useless during the outage it reports.',
|
|
11
22
|
input: Schema.Struct({}),
|
|
12
|
-
output: Schema.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
output: Schema.Array(
|
|
24
|
+
Schema.Struct({
|
|
25
|
+
id: Schema.String,
|
|
26
|
+
title: Schema.String,
|
|
27
|
+
impact: Schema.String,
|
|
28
|
+
status: Schema.String,
|
|
29
|
+
startedAt: Schema.Date,
|
|
30
|
+
resolvedAt: Schema.NullOr(Schema.Date),
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
20
33
|
})
|
|
@@ -1,17 +1,24 @@
|
|
|
1
|
-
// Live feed of incident timeline entries
|
|
1
|
+
// Live feed of incident timeline entries — public by design. The page joins
|
|
2
2
|
// these to incidents by `incidentId` client-side to render each incident's
|
|
3
|
-
// update history.
|
|
3
|
+
// update history. `incidents.update`, which writes them, is
|
|
4
|
+
// `status:write`-guarded.
|
|
4
5
|
import { defineQuery } from '@voltro/protocol'
|
|
5
6
|
import { Schema } from 'effect'
|
|
6
7
|
|
|
7
8
|
export const updatesList = defineQuery({
|
|
8
9
|
name: 'updates.list',
|
|
10
|
+
openAccess:
|
|
11
|
+
'the public incident timeline a status page exists to publish — the operator\'s own '
|
|
12
|
+
+ 'update posts, nothing caller-derived and no personal data. Posting one is '
|
|
13
|
+
+ '`status:write`-guarded.',
|
|
9
14
|
input: Schema.Struct({}),
|
|
10
|
-
output: Schema.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
output: Schema.Array(
|
|
16
|
+
Schema.Struct({
|
|
17
|
+
id: Schema.String,
|
|
18
|
+
incidentId: Schema.String,
|
|
19
|
+
body: Schema.String,
|
|
20
|
+
status: Schema.String,
|
|
21
|
+
createdAt: Schema.Date,
|
|
22
|
+
}),
|
|
23
|
+
),
|
|
17
24
|
})
|
|
@@ -54,10 +54,18 @@ describe('incidents.create — operator-gated', () => {
|
|
|
54
54
|
})
|
|
55
55
|
|
|
56
56
|
describe('incidents.live — reads are PUBLIC', () => {
|
|
57
|
-
it('an anonymous visitor may subscribe
|
|
57
|
+
it('an anonymous visitor may subscribe — the query declares `openAccess`', async () => {
|
|
58
58
|
const ctx = ctxFor(subjectFor(null, null, 'anonymous'))
|
|
59
59
|
await expect(invoke(incidentsLive, incidentsLiveHandler, {}, ctx)).resolves.toBeDefined()
|
|
60
60
|
})
|
|
61
|
+
|
|
62
|
+
it('…which is a DECISION, not an omission', () => {
|
|
63
|
+
// Before `openAccess` existed, "nobody decided" and "we decided it is open"
|
|
64
|
+
// were the same descriptor and a reviewer could not tell them apart. Assert
|
|
65
|
+
// the marker is present, or this suite would keep passing on a query whose
|
|
66
|
+
// guard someone deleted by accident.
|
|
67
|
+
expect(incidentsLive.openAccess).toBeTruthy()
|
|
68
|
+
})
|
|
61
69
|
})
|
|
62
70
|
|
|
63
71
|
describe('incidents.create — descriptor', () => {
|
|
@@ -6,6 +6,18 @@ import { Schema } from 'effect'
|
|
|
6
6
|
|
|
7
7
|
export const documentAsOf = defineAction({
|
|
8
8
|
name: 'documents.asOf',
|
|
9
|
+
// Open, with the same load-bearing detail as `documents.history`: the
|
|
10
|
+
// executor passes `ctx.request.subject.tenantId` into `rowAsOf` explicitly,
|
|
11
|
+
// because a version table is not auto-scoped by the `tenant()` mixin. That
|
|
12
|
+
// argument is what keeps a caller-supplied id from reading another tenant's
|
|
13
|
+
// past values; do not drop it.
|
|
14
|
+
//
|
|
15
|
+
// No auth strategy and no rbac ship in this template, so a scope guard would
|
|
16
|
+
// deny every caller rather than the wrong ones.
|
|
17
|
+
openAccess:
|
|
18
|
+
'returns one past value of a document in the CALLER\'S OWN tenant — `rowAsOf` is passed '
|
|
19
|
+
+ '`subject.tenantId` explicitly, so an id from another tenant returns null. Time travel '
|
|
20
|
+
+ 'reaches only where the live read already could.',
|
|
9
21
|
input: Schema.Struct({ id: Schema.String, at: Schema.Number }),
|
|
10
22
|
output: Schema.NullOr(Schema.Struct({
|
|
11
23
|
title: Schema.String,
|
|
@@ -6,6 +6,19 @@ import { Schema } from 'effect'
|
|
|
6
6
|
|
|
7
7
|
export const documentHistory = defineAction({
|
|
8
8
|
name: 'documents.history',
|
|
9
|
+
// Open — and this one needs its bound named, because a version table is a
|
|
10
|
+
// second copy of your data that the `tenant()` mixin does NOT scope for you.
|
|
11
|
+
// The executor passes `ctx.request.subject.tenantId` to `rowHistory`
|
|
12
|
+
// explicitly, so a caller-supplied id from another tenant yields nothing.
|
|
13
|
+
// Read that as the load-bearing line it is: drop the argument and this
|
|
14
|
+
// becomes a cross-tenant read of every past value.
|
|
15
|
+
//
|
|
16
|
+
// No auth strategy and no rbac ship in this template, so a
|
|
17
|
+
// `guards: [{ scope: 'documents:history' }]` would deny every caller.
|
|
18
|
+
openAccess:
|
|
19
|
+
'lists past versions of a document in the CALLER\'S OWN tenant — `rowHistory` is passed '
|
|
20
|
+
+ '`subject.tenantId` explicitly, so an id from another tenant returns nothing (a version '
|
|
21
|
+
+ 'table is not auto-scoped by `tenant()`). No auth strategy ships here to guard against.',
|
|
9
22
|
input: Schema.Struct({ id: Schema.String }),
|
|
10
23
|
output: Schema.Array(Schema.Struct({
|
|
11
24
|
version: Schema.Number,
|
|
@@ -8,6 +8,15 @@ import { Schema } from 'effect'
|
|
|
8
8
|
export const createDocument = defineMutation({
|
|
9
9
|
name: 'documents.create',
|
|
10
10
|
target: { table: 'documents', op: 'insert' },
|
|
11
|
+
// Open: writes only the title + content the caller sent, into the request's
|
|
12
|
+
// tenant (`tenant()` auto-stamps `tenantId`), and touches no existing row.
|
|
13
|
+
// No auth strategy and no rbac ship in this template, so every caller is an
|
|
14
|
+
// anonymous Subject holding no scopes and a `guards: [{ scope }]` would deny
|
|
15
|
+
// all of them rather than the wrong ones.
|
|
16
|
+
openAccess:
|
|
17
|
+
'inserts a document built only from caller-supplied fields into the request\'s tenant; '
|
|
18
|
+
+ 'reads nothing and modifies no existing row. No auth strategy ships here, so there is no '
|
|
19
|
+
+ 'identity a scope guard could name.',
|
|
11
20
|
input: Schema.Struct({
|
|
12
21
|
title: Schema.NonEmptyString,
|
|
13
22
|
content: Schema.String,
|