@voltro/cli 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1968 -0
- package/bin/nodeEnvironment.d.mts +30 -0
- package/bin/nodeEnvironment.mjs +158 -0
- package/bin/voltro.mjs +69 -5
- package/dist/addCommand-BNeoeSxe.js +124 -0
- package/dist/addCommand-aXSQveak.js +2 -0
- package/dist/agentsMd-BTchIZku.js +2 -0
- package/dist/agentsMd-mhQMF1bx.js +254 -0
- package/dist/apiBuild-B8aoJvuw.js +2 -0
- package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DYD_ONLD.js} +46 -46
- package/dist/{appGraph-gQ_6GkQQ.js → appGraph-KGDPTuTy.js} +1 -1
- package/dist/appGraph-zuMGKVYX.js +2 -0
- package/dist/appPort-B_HpJ_ck.js +48 -0
- package/dist/baselineCommand-C2ClWZN3.js +2 -0
- package/dist/baselineCommand-DIttzO8A.js +227 -0
- package/dist/bin.js +71 -28
- package/dist/build-CD8K4XOr.js +711 -0
- package/dist/cacheCommand-DA4OH9xt.js +42 -0
- package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
- package/dist/checkCommand-Ct9xkTrS.js +232 -0
- package/dist/checkCommand-DKpDLlqu.js +2 -0
- package/dist/{cliArgs-qdZSElM3.js → cliArgs-D4p8n7EE.js} +12 -1
- package/dist/cliError-BmdYnghb.js +10 -0
- package/dist/cliOutput-D1tSBoRM.js +15 -0
- package/dist/{cliRuntime-Oh517vCV.js → cliRuntime-Dh7UDinH.js} +20 -20
- package/dist/cloudClient-DWL-Hw_T.js +67 -0
- package/dist/cloudCmd-Cvv5HGaZ.js +364 -0
- package/dist/clusterCmd-D5wsCmA_.js +54 -0
- package/dist/codegen-CYM3Zqrf.js +605 -0
- package/dist/codegen-ChBi_hVa.js +2 -0
- package/dist/codegenCommand-C4YoQIc2.js +30 -0
- package/dist/codemodRunner-BnFq3Fgu.js +5384 -0
- package/dist/commandRunner-BLAEFLjp.js +47 -0
- package/dist/commands-BE8E7zF3.js +816 -0
- package/dist/connectionConfig-UFlIEiys.js +66 -0
- package/dist/dashboardCommand-D7SgZGaN.js +25 -0
- package/dist/dataCommand-BhYwDgg-.js +537 -0
- package/dist/dataProfile-dW-PsfLB.js +15 -0
- package/dist/dbCommand-DS4b97Is.js +2 -0
- package/dist/{dbCommand-DTLKAfbA.js → dbCommand-O8HA63s2.js} +552 -402
- package/dist/{dev-C_P8FLSx.js → dev-C7sFZq3m.js} +3670 -3169
- package/dist/dev-D2BikO7a.js +3 -0
- package/dist/devActivity-Dx_3nnGv.js +100 -0
- package/dist/devActivity.js +1 -1
- package/dist/dialectDriver-CgXnDfec.js +39 -0
- package/dist/discover-C9XKJDco.js +25 -0
- package/dist/doctorCommand-CM4Ch9C7.js +2 -0
- package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-DnimF5IM.js} +523 -1278
- package/dist/dormancyCommand-QewYug_s.js +69 -0
- package/dist/e2eCmd-BRabZww-.js +147 -0
- package/dist/embeddingsCommand-BfiLS_QI.js +73 -0
- package/dist/envCommand-CCGPRQY1.js +60 -0
- package/dist/evalCommand-6RUfPen4.js +118 -0
- package/dist/evolveCommand-CHsLCtDf.js +281 -0
- package/dist/fileTaxonomy-CJfgOllU.js +457 -0
- package/dist/frameworkTableAssembly-YyVe32Cb.js +2 -0
- package/dist/frameworkTableAssembly-oMPBKqlE.js +511 -0
- package/dist/generateCommand-DbgcUpGw.js +147 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +4 -3
- package/dist/infoCommand-DwOgK1t6.js +60 -0
- package/dist/{inspect-BUUjt773.js → inspect-CjTYzAs_.js} +113 -41
- package/dist/inspect-P4pxoMaV.js +2 -0
- package/dist/inspectCmd-EHFZ9yYu.js +224 -0
- package/dist/inspectFetch-EMuhTG_9.js +151 -0
- package/dist/inspectMetrics-CfdKLh6t.js +72 -0
- package/dist/loadEnv-D9nEOClM.js +44 -0
- package/dist/logFileSink-C_D2wRN1.js +105 -0
- package/dist/logsCmd-D36xK7Zu.js +260 -0
- package/dist/manifestBuild-COkJoyAr.js +2 -0
- package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-hpPLaGxV.js} +1 -1
- package/dist/metaCommands-7MJfZ5cf.js +196 -0
- package/dist/migrate-BV7I-ZHZ.js +83 -0
- package/dist/mssqlClusterPatch-_4cE_nun.js +44 -0
- package/dist/newCommand-COWOJ1_E.js +156 -0
- package/dist/nodeEnvironment-cGFAj1J8.js +28 -0
- package/dist/packageCommand-Cug_3Ogl.js +271 -0
- package/dist/pageConvention-cEiRxdab.js +5 -0
- package/dist/privacyCommand-C-Df56U_.js +146 -0
- package/dist/probeCommand-CZfaaUOZ.js +122 -0
- package/dist/projectScaffold-DmzEKHib.js +2 -0
- package/dist/projectScaffold-LMMtaavR.js +814 -0
- package/dist/renderModeScan-D7J1B7Kw.js +105 -0
- package/dist/renderProfile-1OWWAAtx.js +81 -0
- package/dist/runtimeRegistry-DMeKfTHP.js +81 -0
- package/dist/runtimeTrace-CH3eUiMw.js +91 -0
- package/dist/scheduleCmd-DQRu6BZC.js +149 -0
- package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
- package/dist/schemaIr-UJybUUZW.js +103 -0
- package/dist/{sdkgen-C81QIkiL.js → sdkgen-BLkvGRfX.js} +111 -209
- package/dist/seedRunner-ZmLSqNe2.js +333 -0
- package/dist/serveCommand-CbDHU6l-.js +2 -0
- package/dist/serveCommand-iwlUBNS1.js +1766 -0
- package/dist/serveEntry.js +5 -5
- package/dist/serverlessCommand-CfJZy6dS.js +482 -0
- package/dist/start-BgN62boB.js +3 -0
- package/dist/start-T4VesWiM.js +1087 -0
- package/dist/startEntry.js +2 -2
- package/dist/staticCommand-Dr2M6tpU.js +304 -0
- package/dist/storageCommand-Co6NfLqN.js +42 -0
- package/dist/templates-De8IR5-c.js +102 -0
- package/dist/test-CI6iDsYc.js +115 -0
- package/dist/tracesCmd-DStmCJPi.js +232 -0
- package/dist/tsconfigPaths-BWXBWgcl.js +107 -0
- package/dist/tsxLoader-EuXmSJ1K.js +51 -0
- package/dist/typecheckCommand-BlsWiCNq.js +61 -0
- package/dist/updateCommand-BlMZhWgO.js +2 -0
- package/dist/updateCommand-x0pI_x-B.js +585 -0
- package/dist/webDev-BcykISYQ2.js +2 -0
- package/dist/{inspectMetrics-1xzTKAFx.js → webDev-Dybxew86.js} +988 -1560
- package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
- package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
- package/dist/webhooksCommand-DlAgS2Iw.js +267 -0
- package/dist/workflowsCmd-BGF-mRZ5.js +608 -0
- package/package.json +209 -17
- package/templates/AGENTS.core.md +58 -3
- package/templates/AGENTS.md +64 -7
- package/templates/agent-docs/_index.md +6 -4
- package/templates/agent-docs/_manifest.json +23 -6
- package/templates/agent-docs/ai.md +370 -0
- package/templates/agent-docs/authentication.md +313 -31
- package/templates/agent-docs/caching.md +6 -0
- package/templates/agent-docs/cli.md +853 -50
- package/templates/agent-docs/data.md +608 -12
- package/templates/agent-docs/database/migrations.md +223 -25
- package/templates/agent-docs/database/misc.md +156 -40
- package/templates/agent-docs/database/querying.md +19 -1
- package/templates/agent-docs/database/scaling.md +60 -0
- package/templates/agent-docs/database/schema.md +1 -0
- package/templates/agent-docs/database/seedsdialects.md +208 -19
- package/templates/agent-docs/database/transactions.md +68 -0
- package/templates/agent-docs/deployment.md +284 -25
- package/templates/agent-docs/introduction.md +88 -17
- package/templates/agent-docs/local-first-mobile.md +79 -4
- package/templates/agent-docs/multi-tenancy.md +188 -42
- package/templates/agent-docs/observability.md +58 -3
- package/templates/agent-docs/plugins/ai-flows.md +247 -2
- package/templates/agent-docs/plugins/analytics-postgres.md +1 -1
- package/templates/agent-docs/plugins/audit.md +37 -1
- package/templates/agent-docs/plugins/auth-social.md +143 -0
- package/templates/agent-docs/plugins/auth-workos.md +4 -2
- package/templates/agent-docs/plugins/auth.md +131 -6
- package/templates/agent-docs/plugins/billing.md +132 -15
- package/templates/agent-docs/plugins/cdc-out.md +46 -7
- package/templates/agent-docs/plugins/clickhouse.md +32 -1
- package/templates/agent-docs/plugins/duckdb.md +1 -1
- package/templates/agent-docs/plugins/flags.md +132 -0
- package/templates/agent-docs/plugins/governance.md +105 -7
- package/templates/agent-docs/plugins/multitenancy.md +9 -4
- package/templates/agent-docs/plugins/presence.md +13 -2
- package/templates/agent-docs/plugins/ratelimit.md +9 -0
- package/templates/agent-docs/plugins/search.md +162 -8
- package/templates/agent-docs/plugins/sso-saml.md +47 -8
- package/templates/agent-docs/plugins/storage.md +11 -0
- package/templates/agent-docs/plugins/webhooks.md +105 -0
- package/templates/agent-docs/plugins.md +152 -18
- package/templates/agent-docs/reference.md +60 -3
- package/templates/agent-docs/releases.md +1117 -0
- package/templates/agent-docs/routing.md +43 -25
- package/templates/agent-docs/scheduling.md +27 -0
- package/templates/agent-docs/schema-driven-ui.md +92 -12
- package/templates/agent-docs/security.md +426 -0
- package/templates/agent-docs/templates/apibackends.md +87 -18
- package/templates/agent-docs/templates/appshells.md +32 -14
- package/templates/agent-docs/templates/overview.md +13 -8
- package/templates/agent-docs/testing.md +211 -14
- package/templates/agent-docs/whats-new.md +98 -136
- package/templates/agent-docs/workflows.md +231 -14
- package/templates/apps/api-ai/actions/summarize.action.ts +11 -0
- package/templates/apps/api-ai/package.json +8 -7
- package/templates/apps/api-auth/actions/me.action.ts +13 -0
- package/templates/apps/api-auth/package.json +9 -8
- package/templates/apps/api-backend/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend/package.json +12 -8
- package/templates/apps/api-backend/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +14 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +8 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +10 -0
- package/templates/apps/api-backend-deactivation/package.json +8 -7
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +17 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mail/package.json +9 -8
- package/templates/apps/api-backend-mail/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-mariadb/.env.example +14 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mariadb/package.json +10 -9
- package/templates/apps/api-backend-mariadb/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-sqlite/.env.example +14 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-sqlite/package.json +9 -8
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +13 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +12 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-storage/package.json +9 -8
- package/templates/apps/api-backend-storage/queries/notes.query.ts +30 -8
- package/templates/apps/api-cms/actions/content.get.action.ts +7 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +6 -0
- package/templates/apps/api-cms/actions/me.action.ts +13 -0
- package/templates/apps/api-cms/app.config.ts +19 -0
- package/templates/apps/api-cms/authz.ts +63 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +5 -0
- package/templates/apps/api-cms/package.json +11 -10
- package/templates/apps/api-cms/queries/content.list.query.ts +23 -7
- package/templates/apps/api-cms/tests/accessDecisions.test.ts +121 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +8 -4
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +8 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +13 -0
- package/templates/apps/api-collab/package.json +9 -8
- package/templates/apps/api-collab/queries/documents.query.ts +19 -7
- package/templates/apps/api-data-advanced/package.json +9 -8
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +12 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.ts +9 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +17 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.ts +9 -0
- package/templates/apps/api-durable/package.json +9 -8
- package/templates/apps/api-feature-flags/actions/notes.export.action.ts +16 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +12 -0
- package/templates/apps/api-feature-flags/package.json +10 -9
- package/templates/apps/api-governance/README.md +30 -8
- package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -1
- package/templates/apps/api-governance/actions/profiles.get.action.ts +29 -4
- package/templates/apps/api-governance/database/schema.ts +16 -4
- package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +12 -0
- package/templates/apps/api-governance/package.json +9 -8
- package/templates/apps/api-kv/actions/sync.pull.action.ts +15 -0
- package/templates/apps/api-kv/actions/sync.reset.action.ts +13 -0
- package/templates/apps/api-kv/actions/sync.status.action.ts +7 -0
- package/templates/apps/api-kv/package.json +9 -8
- package/templates/apps/api-kv/queries/events.list.query.ts +19 -8
- package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +11 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +13 -0
- package/templates/apps/api-moderation/package.json +9 -8
- package/templates/apps/api-observability/mutations/notes.create.mutation.ts +8 -0
- package/templates/apps/api-observability/package.json +9 -8
- package/templates/apps/api-observability/queries/notes.list.query.ts +13 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +14 -0
- package/templates/apps/api-ratelimit/package.json +9 -8
- package/templates/apps/api-rbac/package.json +9 -8
- package/templates/apps/api-rest/package.json +8 -7
- package/templates/apps/api-saas/mutations/projects.create.mutation.ts +13 -0
- package/templates/apps/api-saas/package.json +12 -11
- package/templates/apps/api-saas/queries/projects.list.query.ts +11 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +13 -0
- package/templates/apps/api-saas-starter/app.config.ts +19 -0
- package/templates/apps/api-saas-starter/authz.ts +75 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +10 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +9 -0
- package/templates/apps/api-saas-starter/package.json +15 -11
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +19 -8
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -7
- package/templates/apps/api-saas-starter/tests/accessDecisions.test.ts +135 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.ts +14 -0
- package/templates/apps/api-search/package.json +9 -8
- package/templates/apps/api-search/queries/articles.list.query.ts +21 -8
- package/templates/apps/api-status/README.md +10 -3
- package/templates/apps/api-status/app.config.ts +8 -3
- package/templates/apps/api-status/authz.ts +5 -3
- package/templates/apps/api-status/package.json +9 -8
- package/templates/apps/api-status/queries/components.list.query.ts +14 -6
- package/templates/apps/api-status/queries/incidents.live.query.ts +23 -10
- package/templates/apps/api-status/queries/updates.list.query.ts +16 -9
- package/templates/apps/api-status/tests/status.test.ts +9 -1
- package/templates/apps/api-versioning/actions/documents.asOf.action.ts +12 -0
- package/templates/apps/api-versioning/actions/documents.history.action.ts +13 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +9 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +12 -0
- package/templates/apps/api-versioning/package.json +9 -8
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +16 -0
- package/templates/apps/api-webhooks/package.json +10 -9
- package/templates/apps/api-webhooks/queries/orders.list.query.ts +10 -0
- package/templates/apps/changelog/package.json +8 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +10 -8
- package/templates/apps/frontend-admin/src/lib/admin.ts +20 -9
- package/templates/apps/frontend-admin/src/locales/de.ts +11 -1
- package/templates/apps/frontend-admin/src/locales/en.ts +13 -1
- package/templates/apps/frontend-admin/src/pages/admin/[entity]/page.tsx +65 -22
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +130 -23
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +3 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.test.tsx +19 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.tsx +9 -4
- package/templates/apps/frontend-app/app.config.ts +4 -3
- package/templates/apps/frontend-app/package.json +11 -8
- package/templates/apps/frontend-app/src/lib/api.ts +25 -0
- package/templates/apps/frontend-app/src/pages/page.test.tsx +130 -82
- package/templates/apps/frontend-app/src/pages/page.tsx +14 -18
- package/templates/apps/frontend-auth/package.json +10 -8
- package/templates/apps/frontend-blank/package.json +9 -7
- package/templates/apps/frontend-cms/package.json +11 -9
- package/templates/apps/frontend-collab/package.json +12 -9
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +122 -78
- package/templates/apps/frontend-contact/package.json +9 -7
- package/templates/apps/frontend-dashboard/package.json +9 -7
- package/templates/apps/frontend-docs/package.json +9 -7
- package/templates/apps/frontend-i18n/package.json +8 -6
- package/templates/apps/frontend-landing/package.json +9 -7
- package/templates/apps/frontend-portal/package.json +10 -8
- package/templates/apps/frontend-saas/app.config.ts +10 -6
- package/templates/apps/frontend-saas/package.json +10 -8
- package/templates/apps/frontend-saas/src/lib/api.ts +27 -32
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +7 -8
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +27 -3
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +4 -4
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +3 -4
- package/templates/apps/frontend-spa/package.json +9 -7
- package/templates/apps/frontend-ssr/package.json +9 -7
- package/templates/apps/frontend-ssr-api/package.json +10 -8
- package/templates/apps/frontend-static-blog/package.json +8 -6
- package/templates/apps/frontend-status/package.json +10 -8
- package/templates/apps/mobile-app/README.md +1 -0
- package/templates/apps/mobile-app/package.json +4 -2
- package/templates/apps/mobile-app/src/app/index.tsx +22 -12
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +1 -1
- package/templates/apps/mobile-app/src/lib/api.ts +34 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +4 -2
- package/templates/baselines/bare/.env.example +14 -0
- package/templates/baselines/bare/baseline.json +4 -4
- package/templates/baselines/compose/.env.example +14 -0
- package/templates/baselines/compose/README.md +1 -1
- package/templates/baselines/compose/baseline.json +5 -5
- package/templates/baselines/compose-mariadb/.env.example +14 -0
- package/templates/baselines/compose-mariadb/README.md +1 -1
- package/templates/baselines/compose-mariadb/baseline.json +5 -5
- package/templates/baselines/helm/.env.example +14 -0
- package/templates/baselines/helm/baseline.json +4 -4
- package/dist/apiBuild-C-x9YacA.js +0 -2
- package/dist/appGraph-CvQCte0z.js +0 -2
- package/dist/checkCommand-DRovTKza.js +0 -2
- package/dist/commands-CJfepbm4.js +0 -11541
- package/dist/dbCommand-b1gum4td.js +0 -2
- package/dist/dev-iiMtlkfs.js +0 -3
- package/dist/devActivity-BhIu6ncs.js +0 -159
- package/dist/frameworkTableAssembly-BwIrO5nv.js +0 -638
- package/dist/frameworkTableAssembly-D-EebUQX.js +0 -2
- package/dist/inspect-mmBuRXmy.js +0 -2
- package/dist/manifestBuild-Dj8Jjoto.js +0 -2
- package/dist/seedRunner-Bqxgp7HZ.js +0 -230
- package/dist/serveCommand-DdaM4Hup.js +0 -1608
- package/dist/start-C0koT0UO.js +0 -1084
- /package/templates/apps/api-ai/actions/{summarize.action.server.tsx → summarize.action.server.ts} +0 -0
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { defineEnv, envVar } from '@voltro/env'
|
|
23
23
|
import { authRoutesPlugin, memoryUserStore, voltroPasswordStrategy } from '@voltro/plugin-auth'
|
|
24
24
|
import { billingPlugin } from '@voltro/plugin-billing'
|
|
25
|
+
import { resolveScopes } from './authz'
|
|
25
26
|
|
|
26
27
|
export const env = defineEnv({
|
|
27
28
|
LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
|
|
@@ -83,5 +84,23 @@ export default {
|
|
|
83
84
|
// Subject. Reads the SAME VOLTRO_SESSION_SECRET the routes sign with, so
|
|
84
85
|
// the signing and verifying sides cannot drift.
|
|
85
86
|
strategies: [voltroPasswordStrategy()],
|
|
87
|
+
// …and turns that identity into AUTHORITY. A session cookie carries no
|
|
88
|
+
// `scopes` by design (identity is settled at sign-in; authority is re-read
|
|
89
|
+
// per request), so without this the guards on the four domain procedures
|
|
90
|
+
// would be unsatisfiable — the dashboard would boot and answer `ScopeError`
|
|
91
|
+
// to every caller, which is an outage, not security.
|
|
92
|
+
//
|
|
93
|
+
// It runs ONLY on a subject a strategy matched, never for anonymous. That
|
|
94
|
+
// asymmetry IS what the guards enforce: signed in → member scopes; signed
|
|
95
|
+
// out → nothing. See `authz.ts` for the vocabulary, and for why a quota and
|
|
96
|
+
// a tenant filter are not substitutes for it.
|
|
97
|
+
resolveScopes,
|
|
86
98
|
},
|
|
99
|
+
// Point `voltro doctor` / `voltro check` at the scope vocabulary so the
|
|
100
|
+
// unknown-scope rule is LIVE here. This app does scope-based authorization
|
|
101
|
+
// without `@voltro/plugin-rbac`, and the plugin is what normally publishes
|
|
102
|
+
// that set — without this pointer the rule stays dormant and a guard naming a
|
|
103
|
+
// scope nothing grants (a typo, a rename) becomes a permanently uncallable
|
|
104
|
+
// procedure that nothing reports.
|
|
105
|
+
doctor: { scopeVocabulary: './authz.ts#MEMBER_SCOPES' },
|
|
87
106
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The app's authorization vocabulary, in ONE place.
|
|
2
|
+
//
|
|
3
|
+
// Split out of `app.config.ts` for the same reason `api-rbac` splits its role
|
|
4
|
+
// map: the tests can build the real resolver from the real scopes. A test that
|
|
5
|
+
// redeclares the vocabulary it is testing proves only that its own copy is
|
|
6
|
+
// self-consistent — the copy drifts, the test stays green, and the drift is
|
|
7
|
+
// exactly the thing worth catching.
|
|
8
|
+
//
|
|
9
|
+
// ── Three gates, three different questions ─────────────────────────────────
|
|
10
|
+
//
|
|
11
|
+
// This template stacks all three, and conflating any two of them is the
|
|
12
|
+
// classic SaaS authorization bug:
|
|
13
|
+
//
|
|
14
|
+
// tenant() WHICH ROWS — the runtime AND-merges
|
|
15
|
+
// `tenantId = subject.tenantId` into every read.
|
|
16
|
+
// requireEntitlement() HOW MANY — the plan's metered quota. It refuses the
|
|
17
|
+
// fourth project and allows the first three, to anyone.
|
|
18
|
+
// guards: [{ scope }] WHO MAY CALL IT AT ALL — this file.
|
|
19
|
+
//
|
|
20
|
+
// A quota is not an access control and neither is a tenant filter. The gate
|
|
21
|
+
// that answers "may this caller do this" is the third one, and every
|
|
22
|
+
// wire-exposed procedure here declares it (or an `openAccess:` reason — there
|
|
23
|
+
// is exactly one, `session.me`).
|
|
24
|
+
|
|
25
|
+
import type { Subject } from '@voltro/protocol'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What a signed-in member of a tenant may do.
|
|
29
|
+
*
|
|
30
|
+
* **One role, on purpose.** This template has exactly one kind of account: a
|
|
31
|
+
* member of the tenant they signed up into. Inventing `owner` / `billing-admin`
|
|
32
|
+
* / `viewer` here would be a role map written to look secure rather than to
|
|
33
|
+
* describe this app — and a scope every caller already holds is a guard that
|
|
34
|
+
* reads as protection and enforces nothing.
|
|
35
|
+
*
|
|
36
|
+
* The line these guards DO draw is real: **signed in or not.** With no cookie a
|
|
37
|
+
* caller cannot list a tenant's projects, cannot read the invite roster (which
|
|
38
|
+
* carries teammates' email addresses), and cannot spend a seat.
|
|
39
|
+
*
|
|
40
|
+
* Split it by BLAST RADIUS the day your product has two kinds of account.
|
|
41
|
+
* `team:invite` is already separate from `projects:write` for that reason:
|
|
42
|
+
* inviting a person costs a seat and puts an outsider inside the tenant, which
|
|
43
|
+
* is a bigger blast than creating a project.
|
|
44
|
+
*/
|
|
45
|
+
export const MEMBER_SCOPES = [
|
|
46
|
+
'projects:read',
|
|
47
|
+
'projects:write',
|
|
48
|
+
'team:read',
|
|
49
|
+
'team:invite',
|
|
50
|
+
] as const
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Where a caller's authority comes from.
|
|
54
|
+
*
|
|
55
|
+
* `auth.resolveScopes` runs ONLY on a subject a strategy MATCHED — never for
|
|
56
|
+
* anonymous, where there is no identity to look anything up for. That is
|
|
57
|
+
* exactly what makes these guards satisfiable AND meaningful:
|
|
58
|
+
*
|
|
59
|
+
* • no session cookie → `voltroPasswordStrategy` skips → anonymous Subject,
|
|
60
|
+
* no scopes, every guard denies.
|
|
61
|
+
* • valid cookie → matched `user` Subject → this runs → member scopes.
|
|
62
|
+
*
|
|
63
|
+
* A session cookie deliberately carries no `scopes` of its own (identity is
|
|
64
|
+
* settled at sign-in; authority is re-read per request), so this function is
|
|
65
|
+
* the only thing that can grant them — which is why removing a member's access
|
|
66
|
+
* takes effect on their EXISTING session rather than when the cookie expires.
|
|
67
|
+
*
|
|
68
|
+
* PRODUCTION replaces the body with a lookup: `subject.metadata.roles` (set by
|
|
69
|
+
* your strategy) or a read against your own tables through the `store` the
|
|
70
|
+
* framework hands the resolver. Failure posture: return
|
|
71
|
+
* `{ kind: 'unavailable', reason }` if the lookup itself fails — the request
|
|
72
|
+
* then fails CLOSED with a named reason, instead of an empty array being
|
|
73
|
+
* applied as though it were a policy decision.
|
|
74
|
+
*/
|
|
75
|
+
export const resolveScopes = (_subject: Subject): ReadonlyArray<string> => [...MEMBER_SCOPES]
|
|
@@ -8,6 +8,16 @@ import { Schema } from 'effect'
|
|
|
8
8
|
export const createInvite = defineMutation({
|
|
9
9
|
name: 'invites.create',
|
|
10
10
|
target: { table: 'invites', op: 'insert' },
|
|
11
|
+
// Members only, and on its OWN scope rather than `projects:write` — the
|
|
12
|
+
// vocabulary is split by blast radius, not by convenience. Inviting a person
|
|
13
|
+
// spends a seat (real money) and puts an outsider inside the tenant; creating
|
|
14
|
+
// a project does neither. When this app grows an `owner` role, `team:invite`
|
|
15
|
+
// is the scope that moves to it and `projects:write` is the one that stays.
|
|
16
|
+
//
|
|
17
|
+
// The guard runs before `requireEntitlement`, so an anonymous call cannot
|
|
18
|
+
// spend the tenant's seat quota. Satisfiable as shipped: a matched session
|
|
19
|
+
// gets `team:invite` from `auth.resolveScopes` (authz.ts).
|
|
20
|
+
guards: [{ scope: 'team:invite' }],
|
|
11
21
|
input: Schema.Struct({ email: Schema.NonEmptyString }),
|
|
12
22
|
output: Schema.Struct({
|
|
13
23
|
id: Schema.String,
|
|
@@ -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.35.0",
|
|
20
|
+
"@voltro/database": "0.35.0",
|
|
21
|
+
"@voltro/env": "0.35.0",
|
|
22
|
+
"@voltro/plugin-auth": "0.35.0",
|
|
23
|
+
"@voltro/plugin-billing": "0.35.0",
|
|
24
|
+
"@voltro/plugin-multitenancy": "0.35.0",
|
|
25
|
+
"@voltro/protocol": "0.35.0",
|
|
26
|
+
"@voltro/runtime": "0.35.0",
|
|
27
|
+
"@voltro/sql-postgres": "0.35.0",
|
|
25
28
|
"effect": "^3.22.0"
|
|
26
29
|
},
|
|
27
30
|
"devDependencies": {
|
|
28
|
-
"@voltro/testing": "0.
|
|
31
|
+
"@voltro/testing": "0.35.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.35.0",
|
|
17
|
+
"@voltro/database": "0.35.0",
|
|
18
|
+
"@voltro/env": "0.35.0",
|
|
19
|
+
"@voltro/plugin-multitenancy": "0.35.0",
|
|
20
|
+
"@voltro/plugin-search": "0.35.0",
|
|
21
|
+
"@voltro/protocol": "0.35.0",
|
|
22
|
+
"@voltro/runtime": "0.35.0",
|
|
23
23
|
"effect": "^3.22.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@voltro/testing": "0.
|
|
26
|
+
"@voltro/testing": "0.35.0",
|
|
27
27
|
"typescript": "^6.0.3",
|
|
28
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
28
29
|
"vitest": "^4.1.10"
|
|
29
30
|
}
|
|
30
31
|
}
|
|
@@ -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.35.0",
|
|
17
|
+
"@voltro/database": "0.35.0",
|
|
18
|
+
"@voltro/env": "0.35.0",
|
|
19
|
+
"@voltro/plugin-rbac": "0.35.0",
|
|
20
|
+
"@voltro/protocol": "0.35.0",
|
|
21
|
+
"@voltro/runtime": "0.35.0",
|
|
22
|
+
"@voltro/sql-postgres": "0.35.0",
|
|
23
23
|
"effect": "^3.22.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@voltro/testing": "0.
|
|
26
|
+
"@voltro/testing": "0.35.0",
|
|
27
27
|
"typescript": "^6.0.3",
|
|
28
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
28
29
|
"vitest": "^4.1.10"
|
|
29
30
|
}
|
|
30
31
|
}
|
|
@@ -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
|
})
|