@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
|
@@ -8,6 +8,18 @@ import { Schema } from 'effect'
|
|
|
8
8
|
export const updateDocument = defineMutation({
|
|
9
9
|
name: 'documents.update',
|
|
10
10
|
target: { table: 'documents', op: 'update' },
|
|
11
|
+
// Open: `tenant()` scopes the update, so a caller-supplied `id` from another
|
|
12
|
+
// tenant matches no row. Nothing is destroyed either — the versioning plugin
|
|
13
|
+
// snapshots the previous value, so every edit is recoverable through
|
|
14
|
+
// `documents.history` / `documents.asOf`.
|
|
15
|
+
//
|
|
16
|
+
// History is an audit trail, not an access control: it tells you WHAT
|
|
17
|
+
// changed, never whether the change was allowed. No auth strategy or rbac
|
|
18
|
+
// ships here, so a `guards: [{ scope }]` would deny every caller.
|
|
19
|
+
openAccess:
|
|
20
|
+
'edits a document of the request\'s tenant (an id outside it matches no row); the previous '
|
|
21
|
+
+ 'value is snapshotted, so nothing is destroyed. Version history records changes — it '
|
|
22
|
+
+ 'does not authorize them.',
|
|
11
23
|
input: Schema.Struct({
|
|
12
24
|
id: Schema.String,
|
|
13
25
|
content: Schema.String,
|
|
@@ -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-versioning": "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-versioning": "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
|
}
|
|
@@ -4,6 +4,22 @@ import { Schema } from 'effect'
|
|
|
4
4
|
export const fulfillOrder = defineMutation({
|
|
5
5
|
name: 'orders.fulfill',
|
|
6
6
|
target: { table: 'orders', op: 'insert' },
|
|
7
|
+
// Open — and unlike a plain insert, this one FANS OUT: on commit it emits
|
|
8
|
+
// `order.completed` to every subscribed target, so an unguarded call makes
|
|
9
|
+
// this server POST to third parties. Two things bound that today and both are
|
|
10
|
+
// checkable: the emit is post-commit (a rollback ships nothing), and the
|
|
11
|
+
// target list is empty until someone calls `ctx.webhooks.subscribe`, which is
|
|
12
|
+
// not reachable from this procedure.
|
|
13
|
+
//
|
|
14
|
+
// No auth strategy and no rbac ship in this template, so a
|
|
15
|
+
// `guards: [{ scope: 'orders:write' }]` would deny every caller instead of
|
|
16
|
+
// the wrong ones. Add an identity, then that guard — before real subscribers
|
|
17
|
+
// exist. (The INCOMING `orders` webhook is a different door with its own
|
|
18
|
+
// control: an HMAC signature over `<unix>.<rawBody>`, not this decision.)
|
|
19
|
+
openAccess:
|
|
20
|
+
'inserts an order from caller-supplied fields into the request\'s tenant, then emits '
|
|
21
|
+
+ '`order.completed` post-commit. The emit reaches only targets someone registered via '
|
|
22
|
+
+ '`ctx.webhooks.subscribe` — none by default — so guard this before subscribers exist.',
|
|
7
23
|
input: Schema.Struct({ sku: Schema.NonEmptyString, totalCents: Schema.Number }),
|
|
8
24
|
output: Schema.Struct({
|
|
9
25
|
id: Schema.String,
|
|
@@ -13,19 +13,20 @@
|
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@effect/platform": "^0.97.0",
|
|
15
15
|
"@effect/rpc": "^0.76.0",
|
|
16
|
-
"@voltro/cli": "0.
|
|
17
|
-
"@voltro/database": "0.
|
|
18
|
-
"@voltro/env": "0.
|
|
19
|
-
"@voltro/plugin-multitenancy": "0.
|
|
20
|
-
"@voltro/plugin-webhooks": "0.
|
|
21
|
-
"@voltro/protocol": "0.
|
|
22
|
-
"@voltro/runtime": "0.
|
|
23
|
-
"@voltro/sql-postgres": "0.
|
|
16
|
+
"@voltro/cli": "0.34.0",
|
|
17
|
+
"@voltro/database": "0.34.0",
|
|
18
|
+
"@voltro/env": "0.34.0",
|
|
19
|
+
"@voltro/plugin-multitenancy": "0.34.0",
|
|
20
|
+
"@voltro/plugin-webhooks": "0.34.0",
|
|
21
|
+
"@voltro/protocol": "0.34.0",
|
|
22
|
+
"@voltro/runtime": "0.34.0",
|
|
23
|
+
"@voltro/sql-postgres": "0.34.0",
|
|
24
24
|
"effect": "^3.22.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@voltro/testing": "0.
|
|
27
|
+
"@voltro/testing": "0.34.0",
|
|
28
28
|
"typescript": "^6.0.3",
|
|
29
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
29
30
|
"vitest": "^4.1.10"
|
|
30
31
|
}
|
|
31
32
|
}
|
|
@@ -4,6 +4,16 @@ import { Schema } from 'effect'
|
|
|
4
4
|
export const listOrders = defineQuery({
|
|
5
5
|
name: 'orders.list',
|
|
6
6
|
source: 'orders',
|
|
7
|
+
// Open: `tenant()` confines every delivery to the tenantId on the request, so
|
|
8
|
+
// one tenant's orders never reach another's subscription, and the rows carry
|
|
9
|
+
// only sku / amount / status — no customer identity. With no auth strategy
|
|
10
|
+
// configured that tenantId is the caller's own `x-tenant` header, and a
|
|
11
|
+
// `guards: [{ scope }]` would be unsatisfiable: an anonymous Subject holds no
|
|
12
|
+
// scopes, so the guard would deny 100% of traffic.
|
|
13
|
+
openAccess:
|
|
14
|
+
'lists the request tenant\'s own orders (sku, amount, status — no customer identity); '
|
|
15
|
+
+ '`tenant()` scopes every delivery. No auth strategy ships here, so the tenant comes from '
|
|
16
|
+
+ 'the caller\'s `x-tenant` header — add a strategy, then a `guards:`.',
|
|
7
17
|
input: Schema.Struct({}),
|
|
8
18
|
output: Schema.Array(Schema.Struct({
|
|
9
19
|
id: Schema.String,
|
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
"test": "voltro test"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@voltro/changelog": "0.
|
|
16
|
-
"@voltro/cli": "0.
|
|
17
|
-
"@voltro/client": "0.
|
|
18
|
-
"@voltro/env": "0.
|
|
19
|
-
"@voltro/i18n": "0.
|
|
20
|
-
"@voltro/web": "0.
|
|
15
|
+
"@voltro/changelog": "0.34.0",
|
|
16
|
+
"@voltro/cli": "0.34.0",
|
|
17
|
+
"@voltro/client": "0.34.0",
|
|
18
|
+
"@voltro/env": "0.34.0",
|
|
19
|
+
"@voltro/i18n": "0.34.0",
|
|
20
|
+
"@voltro/web": "0.34.0",
|
|
21
21
|
"react": "^19.0.0",
|
|
22
22
|
"react-dom": "^19.0.0"
|
|
23
23
|
},
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
"@types/react": "^19.0.0",
|
|
26
26
|
"@types/react-dom": "^19.0.0",
|
|
27
27
|
"tsx": "^4.22.4",
|
|
28
|
+
"jsdom": "^30.0.0",
|
|
28
29
|
"typescript": "^6.0.3",
|
|
30
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
29
31
|
"vitest": "^4.1.10"
|
|
30
32
|
}
|
|
31
33
|
}
|
|
@@ -12,21 +12,23 @@
|
|
|
12
12
|
"test": "voltro test"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@{{projectName}}/api": "0.
|
|
16
|
-
"@voltro/cli": "0.
|
|
17
|
-
"@voltro/client": "0.
|
|
18
|
-
"@voltro/env": "0.
|
|
19
|
-
"@voltro/i18n": "0.
|
|
20
|
-
"@voltro/ui-shadcn": "0.
|
|
21
|
-
"@voltro/web": "0.
|
|
15
|
+
"@{{projectName}}/api": "0.34.0",
|
|
16
|
+
"@voltro/cli": "0.34.0",
|
|
17
|
+
"@voltro/client": "0.34.0",
|
|
18
|
+
"@voltro/env": "0.34.0",
|
|
19
|
+
"@voltro/i18n": "0.34.0",
|
|
20
|
+
"@voltro/ui-shadcn": "0.34.0",
|
|
21
|
+
"@voltro/web": "0.34.0",
|
|
22
22
|
"react": "^19.0.0",
|
|
23
23
|
"react-dom": "^19.0.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@voltro/devtools": "0.
|
|
26
|
+
"@voltro/devtools": "0.34.0",
|
|
27
27
|
"@types/react": "^19.0.0",
|
|
28
28
|
"@types/react-dom": "^19.0.0",
|
|
29
|
+
"jsdom": "^30.0.0",
|
|
29
30
|
"typescript": "^6.0.3",
|
|
31
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
30
32
|
"vitest": "^4.1.10"
|
|
31
33
|
}
|
|
32
34
|
}
|
|
@@ -1,16 +1,27 @@
|
|
|
1
|
-
// The scopes the admin
|
|
2
|
-
// derives a CONVENTIONAL scope per entity action — `<table>:create`,
|
|
3
|
-
// `<table>:write`, `<table>:delete` (see deriveEntityAdmins) — and hides any
|
|
4
|
-
// action the current subject's scopes don't satisfy.
|
|
1
|
+
// The scopes the admin judges its write affordances against.
|
|
5
2
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// READ THIS BEFORE SWAPPING IT OUT — the shape changed, and the old shape had a
|
|
4
|
+
// trap. The admin no longer invents a scope per entity (`<table>:create` and
|
|
5
|
+
// friends). It reads each procedure's OWN declared access off the capability
|
|
6
|
+
// map (`spec.create.guards` — the descriptor's `guards:` / `openAccess:`) and
|
|
7
|
+
// decides with `useAccessDecision`. So these scopes are matched against what
|
|
8
|
+
// your api really declares; a name that appears in neither place gates nothing.
|
|
9
|
+
//
|
|
10
|
+
// That is the whole fix. A UI gating on an invented convention hides every write
|
|
11
|
+
// from every caller of every app that named its scopes differently — a total
|
|
12
|
+
// outage of the affordance that reads like a working permission check, and that
|
|
13
|
+
// a demo seeding `admin:full` can never surface.
|
|
14
|
+
//
|
|
15
|
+
// DEMO: we feed `admin:full` (the blanket-bypass scope) so the scaffold shows
|
|
16
|
+
// every action on first boot. In a real app, REPLACE this with the current
|
|
17
|
+
// subject's scopes from your session — e.g. a reactive query:
|
|
9
18
|
//
|
|
10
19
|
// const { data } = useSubscription<{ scopes: string[] }>('app', 'auth.session')
|
|
11
20
|
// <PermissionProvider scopes={data?.scopes ?? []}>…</PermissionProvider>
|
|
12
21
|
//
|
|
13
|
-
//
|
|
22
|
+
// useAccessDecision reads these reactively, so affordances update the instant
|
|
14
23
|
// the subject's roles change. (This is UX gating, not enforcement — the api's
|
|
15
|
-
// own
|
|
24
|
+
// own guards remain the real authorization boundary. A control shown while the
|
|
25
|
+
// decision is `unknown` is deliberate: a per-row guard is the server's to
|
|
26
|
+
// answer, and it answers with a typed ScopeError.)
|
|
16
27
|
export const DEMO_SCOPES: ReadonlyArray<string> = ['admin:full']
|
|
@@ -18,7 +18,7 @@ export default defineLocale<typeof en>()({
|
|
|
18
18
|
'lang.label': 'Sprache',
|
|
19
19
|
|
|
20
20
|
'landing.heading': 'Das Backoffice, für dich erledigt.',
|
|
21
|
-
'landing.body': 'Dieser Admin liest zur Laufzeit die <strong>Capability-Map</strong> deiner API und rendert eine live, reaktive <code>DataTable</code> + <code>AutoForm</code> für jede Entität, die sie freigibt —
|
|
21
|
+
'landing.body': 'Dieser Admin liest zur Laufzeit die <strong>Capability-Map</strong> deiner API und rendert eine live, reaktive <code>DataTable</code> + <code>AutoForm</code> für jede Entität, die sie freigibt — gegated auf den Zugriff, den jede Prozedur selbst deklariert, mit einer Herkunfts-Lade pro Zeile und universellem Undo/Redo. Richte ihn auf eine beliebige Voltro-API und er passt sich an; der Seiten-Code unter <code>src/pages/admin/</code> gehört dir zum Bearbeiten.',
|
|
22
22
|
'landing.cta': 'Beim Admin anmelden →',
|
|
23
23
|
|
|
24
24
|
'login.heading': 'Anmelden',
|
|
@@ -38,6 +38,7 @@ export default defineLocale<typeof en>()({
|
|
|
38
38
|
'overview.reactiveSuffix': ' · reaktiv',
|
|
39
39
|
'overview.readonly': 'schreibgeschützt',
|
|
40
40
|
'overview.empty': 'Diese API stellt keine Benutzer-Entitäten bereit.',
|
|
41
|
+
'overview.serverOnly': '{count, plural, one {# reine Server-Spalte, ausgeblendet} other {# reine Server-Spalten, ausgeblendet}}',
|
|
41
42
|
|
|
42
43
|
'entity.create': 'Erstellen',
|
|
43
44
|
'entity.addLabel': '{table} hinzufügen',
|
|
@@ -48,6 +49,15 @@ export default defineLocale<typeof en>()({
|
|
|
48
49
|
'entity.delete': 'Löschen',
|
|
49
50
|
'entity.unknown': 'Keine Entität <code>{entity}</code> in der Capability-Map dieser API.',
|
|
50
51
|
|
|
52
|
+
'entity.requires': 'erfordert {scopes}',
|
|
53
|
+
'entity.openAccess': 'offener Zugriff — {reason}',
|
|
54
|
+
'entity.accessUndeclared': 'keine Zugriffsentscheidung deklariert',
|
|
55
|
+
'entity.accessUnknown': 'Diese API prüft die Berechtigung pro Zeile, das kann nur sie beantworten. Das Bedienelement wird angezeigt; die API entscheidet beim Absenden.',
|
|
56
|
+
|
|
57
|
+
'entity.serverOnly': '{count, plural, one {# reine Server-Spalte verlässt} other {# reine Server-Spalten verlassen}} die API nie und {count, plural, one {kann} other {können}} nicht übermittelt werden: {names}',
|
|
58
|
+
'entity.sensitive': 'Personenbezogene Daten — in Exporten maskiert: {names}',
|
|
59
|
+
'entity.notEditable': 'Kein einzelner Primärschlüssel, daher lassen sich Zeilen nicht zum Bearbeiten oder Löschen adressieren. Nur Liste.',
|
|
60
|
+
|
|
51
61
|
'prov.title': 'Herkunft · <code>{table}</code> #{id}',
|
|
52
62
|
'prov.close': 'schließen',
|
|
53
63
|
'prov.loading': 'lädt…',
|
|
@@ -24,7 +24,7 @@ export default defineCatalog({
|
|
|
24
24
|
|
|
25
25
|
// ---- Public landing ((marketing)/index.tsx) ----
|
|
26
26
|
'landing.heading': 'The back-office, done for you.',
|
|
27
|
-
'landing.body': 'This admin reads your api’s <strong>capability map</strong> at runtime and renders a live, reactive <code>DataTable</code> + <code>AutoForm</code> for every entity it exposes —
|
|
27
|
+
'landing.body': 'This admin reads your api’s <strong>capability map</strong> at runtime and renders a live, reactive <code>DataTable</code> + <code>AutoForm</code> for every entity it exposes — gated on the access each procedure itself declares, with a per-row provenance drawer and universal undo/redo. Point it at any Voltro api and it adapts; the page code under <code>src/pages/admin/</code> is yours to edit.',
|
|
28
28
|
'landing.cta': 'Sign in to the admin →',
|
|
29
29
|
|
|
30
30
|
// ---- Public sign-in ((marketing)/login.tsx) ----
|
|
@@ -47,6 +47,7 @@ export default defineCatalog({
|
|
|
47
47
|
'overview.reactiveSuffix': ' · reactive',
|
|
48
48
|
'overview.readonly': 'read-only',
|
|
49
49
|
'overview.empty': 'No user entities exposed by this api.',
|
|
50
|
+
'overview.serverOnly': '{count, plural, one {# server-only column, hidden} other {# server-only columns, hidden}}',
|
|
50
51
|
|
|
51
52
|
// ---- Per-entity CRUD (admin/[entity].tsx) ----
|
|
52
53
|
'entity.create': 'Create',
|
|
@@ -58,6 +59,17 @@ export default defineCatalog({
|
|
|
58
59
|
'entity.delete': 'Delete',
|
|
59
60
|
'entity.unknown': 'No entity <code>{entity}</code> in this api’s capability map.',
|
|
60
61
|
|
|
62
|
+
// ---- Declared access, shown rather than guessed (admin/[entity].tsx) ----
|
|
63
|
+
'entity.requires': 'requires {scopes}',
|
|
64
|
+
'entity.openAccess': 'open access — {reason}',
|
|
65
|
+
'entity.accessUndeclared': 'no access decision declared',
|
|
66
|
+
'entity.accessUnknown': 'This api checks permission per row, so only it can answer. The control is shown; the api decides when you submit.',
|
|
67
|
+
|
|
68
|
+
// ---- Exposure axes, straight from the schema (admin/[entity].tsx) ----
|
|
69
|
+
'entity.serverOnly': '{count, plural, one {# server-only column} other {# server-only columns}} never leave the api and cannot be submitted: {names}',
|
|
70
|
+
'entity.sensitive': 'Personal data — masked in exports: {names}',
|
|
71
|
+
'entity.notEditable': 'No single primary key, so rows can’t be addressed for edit or delete. List only.',
|
|
72
|
+
|
|
61
73
|
// ---- Provenance drawer (admin/[entity].tsx) ----
|
|
62
74
|
'prov.title': 'Provenance · <code>{table}</code> #{id}',
|
|
63
75
|
'prov.close': 'close',
|
|
@@ -1,22 +1,36 @@
|
|
|
1
1
|
// Per-entity CRUD — URL `/admin/<entity>`. Everything here is DISCOVERED:
|
|
2
2
|
// useCapabilityManifest → deriveEntityAdmins gives this entity's list query +
|
|
3
|
-
// create/update/delete
|
|
4
|
-
//
|
|
5
|
-
// - <DataTable api="app" query={
|
|
6
|
-
// - <AutoForm api="app" mutation={
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
3
|
+
// create/update/delete actions + the columns. We bind the shipped schema-driven
|
|
4
|
+
// components to those tags:
|
|
5
|
+
// - <DataTable api="app" query={spec.list.tag}/> — live, reactive rows
|
|
6
|
+
// - <AutoForm api="app" mutation={spec.create.tag}/> — fields from the input schema
|
|
7
|
+
//
|
|
8
|
+
// WRITES ARE GATED ON THE ACCESS THE API DECLARES, not on a scope string this
|
|
9
|
+
// page invented. `spec.create.guards` is the procedure's own `guards:` /
|
|
10
|
+
// `openAccess:`, straight off the capability map, so this gate and the server's
|
|
11
|
+
// check read the same data and cannot drift.
|
|
12
|
+
//
|
|
13
|
+
// The decision is THREE-valued, and `unknown` is SHOWN, not hidden: a guard with
|
|
14
|
+
// a `resource` extractor is answered per ROW by the server, and a browser
|
|
15
|
+
// holding only global scopes cannot pre-compute it. Hiding those would empty the
|
|
16
|
+
// admin for exactly the multi-tenant apps whose subjects carry no global scopes.
|
|
17
|
+
// The server stays the authorization boundary and answers a typed ScopeError.
|
|
18
|
+
//
|
|
19
|
+
// Each row has a provenance ("why is this here?") drawer (08) + delete; the
|
|
20
|
+
// layout's undo bar reverts the last change (00/09). Edit this file freely —
|
|
21
|
+
// it's your template code, not a sealed runtime feature.
|
|
11
22
|
import { useState, type ReactNode } from 'react'
|
|
12
23
|
import type { PageMeta } from '@voltro/web'
|
|
13
24
|
import { useParams, AutoForm, DataTable } from '@voltro/web'
|
|
14
25
|
import {
|
|
15
26
|
useCapabilityManifest,
|
|
16
27
|
deriveEntityAdmins,
|
|
17
|
-
|
|
28
|
+
useAccessDecision,
|
|
29
|
+
requiredScopes,
|
|
30
|
+
openAccessReason,
|
|
18
31
|
useMutation,
|
|
19
32
|
useProvenance,
|
|
33
|
+
type EntityAction,
|
|
20
34
|
type EntityAdminSpec,
|
|
21
35
|
} from '@voltro/client'
|
|
22
36
|
import { T, useTFn } from '@voltro/i18n'
|
|
@@ -55,23 +69,36 @@ const ProvenanceDrawer = ({ table, id, onClose }: { readonly table: string; read
|
|
|
55
69
|
)
|
|
56
70
|
}
|
|
57
71
|
|
|
72
|
+
// What the api requires for this action, in words — so an operator who hits a
|
|
73
|
+
// ScopeError can read the scope they are missing instead of guessing at it.
|
|
74
|
+
const AccessNote = ({ action }: { readonly action: EntityAction }): ReactNode => {
|
|
75
|
+
const reason = openAccessReason(action.guards)
|
|
76
|
+
const scopes = requiredScopes(action.guards)
|
|
77
|
+
if (reason !== undefined) return <span className="meta"><T id="entity.openAccess" values={{ reason }} /></span>
|
|
78
|
+
if (scopes.length > 0) return <span className="meta"><T id="entity.requires" values={{ scopes: scopes.join(', ') }} /></span>
|
|
79
|
+
return <span className="meta"><T id="entity.accessUndeclared" /></span>
|
|
80
|
+
}
|
|
81
|
+
|
|
58
82
|
// The bound CRUD surface for one entity. A child component so its hooks
|
|
59
|
-
// (
|
|
83
|
+
// (useAccessDecision/useMutation) get the real spec, never undefined.
|
|
60
84
|
const EntityAdmin = ({ spec }: { readonly spec: EntityAdminSpec }): ReactNode => {
|
|
61
85
|
const [sel, setSel] = useState<string | null>(null)
|
|
62
86
|
const t = useTFn()
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
// Always-called; the tag is only used when
|
|
66
|
-
const del = useMutation<
|
|
87
|
+
const create = useAccessDecision(spec.create.guards)
|
|
88
|
+
const remove = useAccessDecision(spec.delete.guards)
|
|
89
|
+
// Always-called; the tag is only used when spec.delete.tag exists (gated below).
|
|
90
|
+
const del = useMutation<Record<string, string>>('app', spec.delete.tag ?? '__voltro.noop')
|
|
91
|
+
// The column the api keys rows on — NOT a hard-coded `id`. A table keyed on
|
|
92
|
+
// anything else would otherwise get an empty id sent on every delete.
|
|
93
|
+
const pk = spec.pkColumn ?? 'id'
|
|
67
94
|
|
|
68
95
|
const rowActions = (row: Row): ReactNode => {
|
|
69
|
-
const id = String(row[
|
|
96
|
+
const id = String(row[pk] ?? '')
|
|
70
97
|
return (
|
|
71
98
|
<span className="row-actions">
|
|
72
99
|
<button type="button" className="btn ghost" onClick={() => setSel(id)}><T id="entity.why" /></button>
|
|
73
|
-
{spec.
|
|
74
|
-
<button type="button" className="btn danger" onClick={() => void del.mutate({ id })}><T id="entity.delete" /></button>
|
|
100
|
+
{spec.delete.tag && spec.editable && remove !== 'denied' && id ? (
|
|
101
|
+
<button type="button" className="btn danger" onClick={() => void del.mutate({ [pk]: id })}><T id="entity.delete" /></button>
|
|
75
102
|
) : null}
|
|
76
103
|
</span>
|
|
77
104
|
)
|
|
@@ -81,17 +108,33 @@ const EntityAdmin = ({ spec }: { readonly spec: EntityAdminSpec }): ReactNode =>
|
|
|
81
108
|
<section>
|
|
82
109
|
<h1>{spec.table}</h1>
|
|
83
110
|
|
|
84
|
-
{spec.
|
|
111
|
+
{/* `.serverOnly()` columns are absent from `spec.columns` by construction —
|
|
112
|
+
they never cross the wire, and the runtime REFUSES a mutation input that
|
|
113
|
+
sets one. Saying so beats a silently shorter column list. */}
|
|
114
|
+
{spec.serverOnlyColumns.length > 0 ? (
|
|
115
|
+
<p className="meta">
|
|
116
|
+
<T id="entity.serverOnly" values={{ count: spec.serverOnlyColumns.length, names: spec.serverOnlyColumns.join(', ') }} />
|
|
117
|
+
</p>
|
|
118
|
+
) : null}
|
|
119
|
+
{spec.sensitiveColumns.length > 0 ? (
|
|
120
|
+
<p className="meta">
|
|
121
|
+
<T id="entity.sensitive" values={{ names: spec.sensitiveColumns.join(', ') }} />
|
|
122
|
+
</p>
|
|
123
|
+
) : null}
|
|
124
|
+
{!spec.editable ? <p className="meta"><T id="entity.notEditable" /></p> : null}
|
|
125
|
+
|
|
126
|
+
{spec.create.tag && create !== 'denied' ? (
|
|
85
127
|
<div className="section">
|
|
86
|
-
<h2><T id="entity.create" /></h2>
|
|
87
|
-
<
|
|
128
|
+
<h2><T id="entity.create" /> <AccessNote action={spec.create} /></h2>
|
|
129
|
+
{create === 'unknown' ? <p className="muted"><T id="entity.accessUnknown" /></p> : null}
|
|
130
|
+
<AutoForm api="app" mutation={spec.create.tag} submitLabel={t('entity.addLabel', { table: spec.table })} />
|
|
88
131
|
</div>
|
|
89
132
|
) : null}
|
|
90
133
|
|
|
91
134
|
<div className="section">
|
|
92
135
|
<h2><T id="entity.rows" /></h2>
|
|
93
|
-
{spec.
|
|
94
|
-
<DataTable api="app" query={spec.
|
|
136
|
+
{spec.list.tag ? (
|
|
137
|
+
<DataTable api="app" query={spec.list.tag} rowActions={rowActions} emptyText={t('entity.empty', { table: spec.table })} />
|
|
95
138
|
) : (
|
|
96
139
|
<p className="muted"><T id="entity.noList" /></p>
|
|
97
140
|
)}
|
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
// renders one of three observable states: a loading skeleton, an "unknown
|
|
6
6
|
// entity" banner, or the bound CRUD surface (heading + AutoForm + DataTable).
|
|
7
7
|
// We mock @voltro/web (useParams + the AutoForm/DataTable components) and
|
|
8
|
-
// @voltro/client (the discovery +
|
|
8
|
+
// @voltro/client (the discovery + access hooks).
|
|
9
|
+
//
|
|
10
|
+
// The access cases below are the point of this file. The page must gate on the
|
|
11
|
+
// DECLARED access the manifest carries, never on a scope string it invented,
|
|
12
|
+
// and it must SHOW an `unknown` decision instead of hiding it — a per-row guard
|
|
13
|
+
// is the server's to answer, and hiding it empties the admin for every
|
|
14
|
+
// multi-tenant app whose subjects hold no global scopes.
|
|
9
15
|
|
|
10
16
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
11
17
|
import { act, createElement, type ReactNode } from 'react'
|
|
@@ -19,20 +25,25 @@ vi.mock('@voltro/web', () => ({
|
|
|
19
25
|
useParams: () => params(),
|
|
20
26
|
AutoForm: ({ mutation }: { mutation: string }) =>
|
|
21
27
|
createElement('div', { 'data-testid': 'auto-form' }, mutation),
|
|
22
|
-
DataTable: ({ query }: { query: string }) =>
|
|
23
|
-
createElement('div', { 'data-testid': 'data-table' }, query),
|
|
28
|
+
DataTable: ({ query, rowActions }: { query: string; rowActions?: (r: Record<string, unknown>) => ReactNode }) =>
|
|
29
|
+
createElement('div', { 'data-testid': 'data-table' }, query, rowActions?.({ id: 'r1', entryNo: 'e1' })),
|
|
24
30
|
}))
|
|
25
31
|
|
|
26
32
|
interface ManifestState { manifest: unknown; loading: boolean }
|
|
27
33
|
const capabilityManifest = vi.fn<() => ManifestState>()
|
|
28
34
|
const entityAdmins = vi.fn<() => Array<Record<string, unknown>>>()
|
|
29
|
-
const
|
|
35
|
+
const accessDecision = vi.fn<() => 'allowed' | 'denied' | 'unknown'>()
|
|
36
|
+
const mutate = vi.fn()
|
|
30
37
|
|
|
31
38
|
vi.mock('@voltro/client', () => ({
|
|
32
39
|
useCapabilityManifest: () => capabilityManifest(),
|
|
33
40
|
deriveEntityAdmins: () => entityAdmins(),
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
useAccessDecision: () => accessDecision(),
|
|
42
|
+
requiredScopes: (guards?: ReadonlyArray<{ kind: string; scope?: ReadonlyArray<string> }>) =>
|
|
43
|
+
(guards ?? []).flatMap((g) => (g.kind === 'scope' ? [...(g.scope ?? [])] : [])),
|
|
44
|
+
openAccessReason: (guards?: ReadonlyArray<{ kind: string; reason?: string }>) =>
|
|
45
|
+
(guards ?? []).find((g) => g.kind === 'open')?.reason,
|
|
46
|
+
useMutation: () => ({ mutate }),
|
|
36
47
|
useProvenance: () => ({ data: undefined, loading: false, error: undefined }),
|
|
37
48
|
}))
|
|
38
49
|
|
|
@@ -58,12 +69,36 @@ const render = (node: ReactNode): void => {
|
|
|
58
69
|
const cell = (id: string): string | null =>
|
|
59
70
|
container.querySelector(`[data-testid="${id}"]`)?.textContent ?? null
|
|
60
71
|
|
|
72
|
+
/** A derived spec in its current shape. */
|
|
73
|
+
const spec = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
|
|
74
|
+
table: 'todos',
|
|
75
|
+
columns: [{ name: 'id' }, { name: 'title' }],
|
|
76
|
+
serverOnlyColumns: [],
|
|
77
|
+
sensitiveColumns: [],
|
|
78
|
+
reactive: true,
|
|
79
|
+
pkColumn: 'id',
|
|
80
|
+
editable: true,
|
|
81
|
+
list: { tag: 'todos.list' },
|
|
82
|
+
create: { tag: 'todos.create', guards: [{ kind: 'scope', scope: ['todos:manage'], mode: 'all', resourceScoped: false }] },
|
|
83
|
+
update: {},
|
|
84
|
+
delete: { tag: 'todos.delete' },
|
|
85
|
+
...over,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const showEntity = (over: Record<string, unknown> = {}): void => {
|
|
89
|
+
params.mockReturnValue({ entity: (over['table'] as string) ?? 'todos' })
|
|
90
|
+
capabilityManifest.mockReturnValue({ manifest: {}, loading: false })
|
|
91
|
+
entityAdmins.mockReturnValue([spec(over)])
|
|
92
|
+
render(createElement(EntityPage))
|
|
93
|
+
}
|
|
94
|
+
|
|
61
95
|
beforeEach(() => {
|
|
62
96
|
params.mockReset()
|
|
63
97
|
capabilityManifest.mockReset()
|
|
64
98
|
entityAdmins.mockReset()
|
|
65
|
-
|
|
66
|
-
|
|
99
|
+
accessDecision.mockReset()
|
|
100
|
+
mutate.mockReset()
|
|
101
|
+
accessDecision.mockReturnValue('allowed')
|
|
67
102
|
})
|
|
68
103
|
|
|
69
104
|
afterEach(() => {
|
|
@@ -98,22 +133,94 @@ describe('entity page — render states', () => {
|
|
|
98
133
|
})
|
|
99
134
|
|
|
100
135
|
test('renders the bound CRUD surface (heading + AutoForm + DataTable) for a known entity', () => {
|
|
101
|
-
|
|
102
|
-
capabilityManifest.mockReturnValue({ manifest: {}, loading: false })
|
|
103
|
-
entityAdmins.mockReturnValue([
|
|
104
|
-
{
|
|
105
|
-
table: 'todos',
|
|
106
|
-
columns: ['id', 'title'],
|
|
107
|
-
listTag: 'todos.list',
|
|
108
|
-
createTag: 'todos.create',
|
|
109
|
-
createScope: 'todos:create',
|
|
110
|
-
deleteScope: 'todos:delete',
|
|
111
|
-
deleteTag: 'todos.delete',
|
|
112
|
-
},
|
|
113
|
-
])
|
|
114
|
-
render(createElement(EntityPage))
|
|
136
|
+
showEntity()
|
|
115
137
|
expect(container.querySelector('h1')!.textContent).toBe('todos')
|
|
116
138
|
expect(cell('auto-form')).toBe('todos.create')
|
|
117
|
-
expect(cell('data-table')).
|
|
139
|
+
expect(cell('data-table')).toContain('todos.list')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('entity page — gates on the DECLARED access', () => {
|
|
144
|
+
test('shows the create form when the api says the caller may create', () => {
|
|
145
|
+
accessDecision.mockReturnValue('allowed')
|
|
146
|
+
showEntity()
|
|
147
|
+
expect(cell('auto-form')).toBe('todos.create')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('hides it on a DEFINITE denial', () => {
|
|
151
|
+
accessDecision.mockReturnValue('denied')
|
|
152
|
+
showEntity()
|
|
153
|
+
expect(container.querySelector('[data-testid="auto-form"]')).toBeNull()
|
|
154
|
+
// …and the list is still there. A denied write is not a denied read.
|
|
155
|
+
expect(cell('data-table')).toContain('todos.list')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('SHOWS it on an indeterminate decision, and says why', () => {
|
|
159
|
+
// The false-deny trap: a per-row guard cannot be pre-computed in a browser.
|
|
160
|
+
// Hiding it would empty the admin for exactly the multi-tenant apps whose
|
|
161
|
+
// subjects carry no global scopes.
|
|
162
|
+
accessDecision.mockReturnValue('unknown')
|
|
163
|
+
showEntity()
|
|
164
|
+
expect(cell('auto-form')).toBe('todos.create')
|
|
165
|
+
expect(container.textContent).toContain('checks permission per row')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('names the scope the api ACTUALLY requires, so a refusal is readable', () => {
|
|
169
|
+
showEntity()
|
|
170
|
+
expect(container.textContent).toContain('requires todos:manage')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('reports an openAccess decision with its reason', () => {
|
|
174
|
+
showEntity({ create: { tag: 'todos.create', guards: [{ kind: 'open', reason: 'demo seeding' }] } })
|
|
175
|
+
expect(container.textContent).toContain('open access — demo seeding')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('says so when a procedure declared no access at all', () => {
|
|
179
|
+
showEntity({ create: { tag: 'todos.create' } })
|
|
180
|
+
expect(container.textContent).toContain('no access decision declared')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('renders nothing to create when the api exposes no create mutation', () => {
|
|
184
|
+
showEntity({ create: {} })
|
|
185
|
+
expect(container.querySelector('[data-testid="auto-form"]')).toBeNull()
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('entity page — the three exposure axes', () => {
|
|
190
|
+
test('names the .serverOnly() columns it withheld, and why', () => {
|
|
191
|
+
showEntity({ serverOnlyColumns: ['pwHash', 'internalNote'] })
|
|
192
|
+
expect(container.textContent).toContain('2 server-only columns')
|
|
193
|
+
expect(container.textContent).toContain('pwHash, internalNote')
|
|
194
|
+
expect(container.textContent).toContain('cannot be submitted')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('flags .sensitive() columns as export-masked, NOT as hidden', () => {
|
|
198
|
+
// The category error to avoid: `.sensitive()` is the export axis. The value
|
|
199
|
+
// is real wire data the operator is meant to see.
|
|
200
|
+
showEntity({ sensitiveColumns: ['email'] })
|
|
201
|
+
expect(container.textContent).toContain('masked in exports: email')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('says nothing when no column is marked', () => {
|
|
205
|
+
showEntity()
|
|
206
|
+
expect(container.textContent).not.toContain('server-only')
|
|
207
|
+
expect(container.textContent).not.toContain('masked in exports')
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('entity page — row identity', () => {
|
|
212
|
+
test('keys the delete on the pk the api declares, not a hard-coded id', () => {
|
|
213
|
+
showEntity({ pkColumn: 'entryNo' })
|
|
214
|
+
const del = container.querySelector('button.danger')!
|
|
215
|
+
act(() => { del.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
|
216
|
+
// A hard-coded `id` would have sent `{ id: 'r1' }` — or `{ id: '' }` for a
|
|
217
|
+
// table that has no `id` column at all.
|
|
218
|
+
expect(mutate).toHaveBeenCalledWith({ entryNo: 'e1' })
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('offers no delete for a table with no addressable row', () => {
|
|
222
|
+
showEntity({ editable: false, pkColumn: undefined })
|
|
223
|
+
expect(container.querySelector('button.danger')).toBeNull()
|
|
224
|
+
expect(container.textContent).toContain('No single primary key')
|
|
118
225
|
})
|
|
119
226
|
})
|