@voltro/cli 0.28.0 → 0.30.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 +655 -0
- package/THIRD-PARTY-NOTICES.md +90 -4
- package/dist/{apiBuild-eUM32r1u.js → apiBuild-BceikpQf.js} +3 -3
- package/dist/apiBuild-CxvR1Dwt.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-BMr-X48w.js +2427 -0
- package/dist/checkCommand-BOk9y264.js +2 -0
- package/dist/cliRuntime-Oh517vCV.js +96 -0
- package/dist/{commands-CSkrUI1h.js → commands-D4Y1-Nlu.js} +8575 -9366
- package/dist/{dbCommand-CpYgmSw4.js → dbCommand-C7hbl5pk.js} +345 -266
- package/dist/dbCommand-D8nhyppR.js +2 -0
- package/dist/{dev-DlBWWnJQ.js → dev-Cr-teYsz.js} +1 -1
- package/dist/{dev-CEZwJhmb.js → dev-DmnkH-wr.js} +2939 -1832
- package/dist/fileConventions-Cof68_BL.js +33 -0
- package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-BzAajuUv.js} +115 -99
- package/dist/frameworkTableAssembly-DU0aOxjL.js +2 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +1 -1
- package/dist/{inspect-CUCCzw2I.js → inspect-BUUjt773.js} +349 -192
- package/dist/inspect-mmBuRXmy.js +2 -0
- package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-BeJdP_3b.js} +1068 -978
- package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-BLrVuSlM.js} +1 -1
- package/dist/manifestBuild-Dj8Jjoto.js +2 -0
- package/dist/sdkgen-CqpOq_fy.js +582 -0
- package/dist/serveCommand-Ciq_wIY8.js +1577 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-BGXIf6zT.js → start-ocqB1seX.js} +283 -270
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +50 -0
- package/templates/AGENTS.md +54 -2
- package/templates/agent-docs/_index.md +4 -2
- package/templates/agent-docs/_manifest.json +26 -5
- package/templates/agent-docs/ai.md +191 -0
- package/templates/agent-docs/cli.md +143 -6
- package/templates/agent-docs/configuration.md +32 -0
- package/templates/agent-docs/data.md +282 -0
- package/templates/agent-docs/database/migrations.md +47 -0
- package/templates/agent-docs/database/misc.md +57 -0
- package/templates/agent-docs/database/schema.md +3 -1
- package/templates/agent-docs/internationalization.md +169 -8
- package/templates/agent-docs/local-first-mobile.md +426 -0
- package/templates/agent-docs/routing.md +94 -3
- package/templates/agent-docs/schema-driven-ui.md +12 -0
- package/templates/agent-docs/security.md +17 -0
- package/templates/agent-docs/templates/apibackends.md +210 -1
- package/templates/agent-docs/templates/appshells.md +144 -1
- package/templates/agent-docs/templates/mobile.md +60 -0
- package/templates/agent-docs/templates/overview.md +17 -7
- package/templates/agent-docs/whats-new.md +238 -158
- package/templates/agent-docs/workflows.md +376 -0
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/.env.example +19 -0
- package/templates/apps/api-backend-sqlite/README.md +38 -0
- package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
- package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-sqlite/package.json +30 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-sqlite/template.json +6 -0
- package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/README.md +104 -0
- package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
- package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
- package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
- package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
- package/templates/apps/api-cms/actions/me.action.ts +16 -0
- package/templates/apps/api-cms/app.config.ts +61 -0
- package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
- package/templates/apps/api-cms/content/index.ts +18 -0
- package/templates/apps/api-cms/content/page.contentType.ts +24 -0
- package/templates/apps/api-cms/database/schema.ts +64 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
- package/templates/apps/api-cms/package.json +32 -0
- package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
- package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
- package/templates/apps/api-cms/template.json +6 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
- package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
- package/templates/apps/api-cms/tsconfig.json +5 -0
- package/templates/apps/api-collab/README.md +70 -0
- package/templates/apps/api-collab/app.config.ts +28 -0
- package/templates/apps/api-collab/database/schema.ts +63 -0
- package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +19 -0
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +36 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +20 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +30 -0
- package/templates/apps/api-collab/package.json +30 -0
- package/templates/apps/api-collab/queries/documents.query.server.ts +16 -0
- package/templates/apps/api-collab/queries/documents.query.ts +23 -0
- package/templates/apps/api-collab/template.json +6 -0
- package/templates/apps/api-collab/tests/documents.setBody.test.ts +89 -0
- package/templates/apps/api-collab/tsconfig.json +5 -0
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/README.md +103 -0
- package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
- package/templates/apps/api-saas-starter/app.config.ts +87 -0
- package/templates/apps/api-saas-starter/database/schema.ts +57 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/package.json +32 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
- package/templates/apps/api-saas-starter/template.json +6 -0
- package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
- package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
- package/templates/apps/api-saas-starter/tsconfig.json +5 -0
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/README.md +88 -0
- package/templates/apps/api-status/app.config.ts +36 -0
- package/templates/apps/api-status/authz.ts +33 -0
- package/templates/apps/api-status/database/schema.ts +70 -0
- package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
- package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
- package/templates/apps/api-status/package.json +30 -0
- package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/components.list.query.ts +13 -0
- package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
- package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
- package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
- package/templates/apps/api-status/template.json +6 -0
- package/templates/apps/api-status/tests/status.test.ts +70 -0
- package/templates/apps/api-status/tsconfig.json +5 -0
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/app.config.ts +1 -1
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-admin/src/locales/index.ts +1 -1
- package/templates/apps/frontend-app/app.config.ts +1 -1
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-app/src/locales/index.ts +1 -1
- package/templates/apps/frontend-app/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-app/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-auth/README.md +78 -0
- package/templates/apps/frontend-auth/app.config.ts +34 -0
- package/templates/apps/frontend-auth/package.json +32 -0
- package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
- package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
- package/templates/apps/frontend-auth/src/config.ts +11 -0
- package/templates/apps/frontend-auth/src/globals.css +105 -0
- package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
- package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
- package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
- package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
- package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
- package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
- package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
- package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
- package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
- package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
- package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
- package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
- package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
- package/templates/apps/frontend-auth/template.json +6 -0
- package/templates/apps/frontend-auth/tsconfig.json +5 -0
- package/templates/apps/frontend-blank/app.config.ts +1 -1
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-blank/src/locales/index.ts +1 -1
- package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-blank/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-cms/README.md +47 -0
- package/templates/apps/frontend-cms/app.config.ts +37 -0
- package/templates/apps/frontend-cms/package.json +33 -0
- package/templates/apps/frontend-cms/src/config.ts +8 -0
- package/templates/apps/frontend-cms/src/globals.css +105 -0
- package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
- package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
- package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
- package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
- package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
- package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
- package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
- package/templates/apps/frontend-cms/template.json +6 -0
- package/templates/apps/frontend-cms/tsconfig.json +5 -0
- package/templates/apps/frontend-collab/README.md +80 -0
- package/templates/apps/frontend-collab/app.config.ts +38 -0
- package/templates/apps/frontend-collab/package.json +33 -0
- package/templates/apps/frontend-collab/src/locales/de.ts +29 -0
- package/templates/apps/frontend-collab/src/locales/en.ts +31 -0
- package/templates/apps/frontend-collab/src/locales/index.ts +15 -0
- package/templates/apps/frontend-collab/src/pages/layout.tsx +39 -0
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +150 -0
- package/templates/apps/frontend-collab/src/pages/page.tsx +186 -0
- package/templates/apps/frontend-collab/template.json +6 -0
- package/templates/apps/frontend-collab/tsconfig.json +11 -0
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/app.config.ts +1 -1
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-dashboard/src/locales/index.ts +1 -1
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/README.md +71 -0
- package/templates/apps/frontend-portal/app.config.ts +37 -0
- package/templates/apps/frontend-portal/package.json +32 -0
- package/templates/apps/frontend-portal/src/config.ts +8 -0
- package/templates/apps/frontend-portal/src/globals.css +93 -0
- package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
- package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
- package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
- package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
- package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
- package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
- package/templates/apps/frontend-portal/template.json +6 -0
- package/templates/apps/frontend-portal/tsconfig.json +5 -0
- package/templates/apps/frontend-saas/README.md +73 -0
- package/templates/apps/frontend-saas/app.config.ts +46 -0
- package/templates/apps/frontend-saas/package.json +32 -0
- package/templates/apps/frontend-saas/src/config.ts +8 -0
- package/templates/apps/frontend-saas/src/globals.css +85 -0
- package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
- package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
- package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
- package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
- package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
- package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-saas/template.json +6 -0
- package/templates/apps/frontend-saas/tsconfig.json +5 -0
- package/templates/apps/frontend-spa/app.config.ts +1 -1
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-spa/src/locales/index.ts +1 -1
- package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-spa/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-ssr/app.config.ts +1 -1
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr/src/locales/index.ts +1 -1
- package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-ssr/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-ssr-api/app.config.ts +1 -1
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-ssr-api/src/locales/index.ts +1 -1
- package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/README.md +51 -0
- package/templates/apps/frontend-status/app.config.ts +39 -0
- package/templates/apps/frontend-status/package.json +32 -0
- package/templates/apps/frontend-status/src/config.ts +7 -0
- package/templates/apps/frontend-status/src/globals.css +70 -0
- package/templates/apps/frontend-status/src/globals.d.ts +6 -0
- package/templates/apps/frontend-status/src/lib/status.ts +81 -0
- package/templates/apps/frontend-status/src/locales/de.ts +41 -0
- package/templates/apps/frontend-status/src/locales/en.ts +45 -0
- package/templates/apps/frontend-status/src/locales/index.ts +13 -0
- package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
- package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
- package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
- package/templates/apps/frontend-status/template.json +6 -0
- package/templates/apps/frontend-status/tsconfig.json +11 -0
- package/templates/apps/mobile-app/README.md +58 -0
- package/templates/apps/mobile-app/app.config.ts +33 -0
- package/templates/apps/mobile-app/babel.config.js +5 -0
- package/templates/apps/mobile-app/metro.config.js +20 -0
- package/templates/apps/mobile-app/package.json +33 -0
- package/templates/apps/mobile-app/src/app/_layout.tsx +61 -0
- package/templates/apps/mobile-app/src/app/index.tsx +62 -0
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +19 -0
- package/templates/apps/mobile-app/src/app/settings.tsx +51 -0
- package/templates/apps/mobile-app/src/client.ts +74 -0
- package/templates/apps/mobile-app/src/lib/deeplinks.ts +39 -0
- package/templates/apps/mobile-app/src/lib/notifications.ts +31 -0
- package/templates/apps/mobile-app/src/lib/sync.ts +29 -0
- package/templates/apps/mobile-app/src/persistence.ts +38 -0
- package/templates/apps/mobile-app/template.json +6 -0
- package/templates/apps/mobile-app/tests/deeplinks.test.ts +32 -0
- package/templates/apps/mobile-app/tests/notifications.test.ts +26 -0
- package/templates/apps/mobile-app/tests/sync.test.ts +34 -0
- package/templates/apps/mobile-app/tsconfig.json +10 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +11 -0
- package/templates/baselines/compose/docker-compose.prod.yml +15 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
- package/templates/patches/@effect__cluster@0.60.0.patch +6 -6
- package/dist/apiBuild-DgBS9ayv.js +0 -2
- package/dist/dbCommand-DvguqlzF.js +0 -2
- package/dist/fileConventions-3bffWssN.js +0 -30
- package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
- package/dist/inspect-gt8bq-Tz.js +0 -2
- package/dist/manifestBuild-ifczArzr.js +0 -2
- package/dist/serveCommand-ZTn-dPFa.js +0 -1425
|
@@ -13,17 +13,17 @@
|
|
|
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.30.0",
|
|
17
|
+
"@voltro/database": "0.30.0",
|
|
18
|
+
"@voltro/env": "0.30.0",
|
|
19
|
+
"@voltro/plugin-multitenancy": "0.30.0",
|
|
20
|
+
"@voltro/plugin-search": "0.30.0",
|
|
21
|
+
"@voltro/protocol": "0.30.0",
|
|
22
|
+
"@voltro/runtime": "0.30.0",
|
|
23
23
|
"effect": "^3.22.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@voltro/testing": "0.
|
|
26
|
+
"@voltro/testing": "0.30.0",
|
|
27
27
|
"typescript": "^6.0.3",
|
|
28
28
|
"vitest": "^4.1.10"
|
|
29
29
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# {{capProjectName}} {{capAppName}} — status-page backend
|
|
2
|
+
|
|
3
|
+
The backend for a public **status page**: live queries anyone can subscribe to,
|
|
4
|
+
plus operator-only mutations to declare and resolve incidents. Boots
|
|
5
|
+
**zero-infra** (`store: 'memory'`). Pair it with the **frontend-status** web
|
|
6
|
+
template:
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
voltro init my-status --api=api-status --web=frontend-status
|
|
10
|
+
voltro dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## The asymmetry it encodes
|
|
14
|
+
|
|
15
|
+
A status page is read by everyone and written by a few — so:
|
|
16
|
+
|
|
17
|
+
- **Reads are PUBLIC.** `incidents.live`, `updates.list`, `components.list`
|
|
18
|
+
carry **no guard**, so an anonymous browser can `useSubscription('app',
|
|
19
|
+
'incidents.live')` and the timeline updates live.
|
|
20
|
+
- **Writes are OPERATOR-ONLY.** Every mutation declares
|
|
21
|
+
`guards: [{ scope: 'status:write' }]`, enforced by `@voltro/plugin-rbac` in the
|
|
22
|
+
dispatch spine before the executor. The demo resolver (`authz.ts`) grants the
|
|
23
|
+
`operator` role to the `ops` tenant; everyone else — anonymous included — is
|
|
24
|
+
denied.
|
|
25
|
+
|
|
26
|
+
## Surface
|
|
27
|
+
|
|
28
|
+
| Tag | Kind | Guard |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `incidents.live` | query (stream) | — public |
|
|
31
|
+
| `updates.list` | query (stream) | — public |
|
|
32
|
+
| `components.list` | query (stream) | — public |
|
|
33
|
+
| `incidents.create` | mutation | `status:write` |
|
|
34
|
+
| `incidents.update` | mutation | `status:write` |
|
|
35
|
+
| `incidents.resolve` | mutation | `status:write` |
|
|
36
|
+
| `components.create` | mutation | `status:write` |
|
|
37
|
+
|
|
38
|
+
## Try it (curl)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
voltro dev .
|
|
42
|
+
|
|
43
|
+
# A public read — no auth (anyone can watch)
|
|
44
|
+
curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
|
|
45
|
+
-H 'content-type: application/json' \
|
|
46
|
+
-d '{"tag":"incidents.live","input":{}}'
|
|
47
|
+
|
|
48
|
+
# An operator write — needs the `ops` tenant (demo resolver → operator role)
|
|
49
|
+
curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
|
|
50
|
+
-H 'content-type: application/json' -H 'x-tenant: ops' \
|
|
51
|
+
-d '{"tag":"incidents.create","input":{"title":"API degraded","impact":"major"}}'
|
|
52
|
+
|
|
53
|
+
# The SAME write without the operator tenant → typed ScopeError
|
|
54
|
+
curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
|
|
55
|
+
-H 'content-type: application/json' \
|
|
56
|
+
-d '{"tag":"incidents.create","input":{"title":"nope","impact":"minor"}}'
|
|
57
|
+
# → { "ok": false, "error": { "_tag": "ScopeError", "required": "status:write" } }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Files
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
app.config.ts rbacPlugin (roles + demo resolver)
|
|
64
|
+
authz.ts the role→scope map (status:write) — one source of truth
|
|
65
|
+
database/schema.ts components / incidents / incident_updates (PUBLIC, not tenant-scoped)
|
|
66
|
+
queries/ incidents.live / updates.list / components.list — ungated streams
|
|
67
|
+
mutations/ incidents.create|update|resolve, components.create — status:write
|
|
68
|
+
tests/ the public-read / operator-write asymmetry + descriptor pins
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Going to production
|
|
72
|
+
|
|
73
|
+
- **Real operator auth.** Replace the demo `resolveRoles` with subject metadata
|
|
74
|
+
or a DB lookup, and pair this api with `api-auth` / `api-saas-starter` so
|
|
75
|
+
operators actually sign in. The rbac gate is already in place — you only swap
|
|
76
|
+
where the roles come from.
|
|
77
|
+
- **Durable store.** `store: 'postgres'` (or `sqlite`) so incident history
|
|
78
|
+
survives a restart.
|
|
79
|
+
- **Notify subscribers** on open/resolve with `@voltro/plugin-mail` (opt-in).
|
|
80
|
+
|
|
81
|
+
## Anti-patterns
|
|
82
|
+
|
|
83
|
+
- **Tenant-scoping the public tables.** Adding `tenant()` to `incidents` would
|
|
84
|
+
make an anonymous visitor (no tenant) see nothing. Public reads stay
|
|
85
|
+
unscoped; the rbac guard on the writes is the boundary.
|
|
86
|
+
- **A write-only authz model.** These reads are intentionally ungated because
|
|
87
|
+
they're public — but if you ever gate a read, gate it with `guards:` on the
|
|
88
|
+
query (enforced on open AND every delivery), not a check inside the handler.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Backend for the {{projectName}} public status page. Read by `voltro dev`.
|
|
2
|
+
//
|
|
3
|
+
// The shape of a status page is asymmetric, and this config encodes it:
|
|
4
|
+
// • READS are PUBLIC — `incidents.live` / `updates.list` / `components.list`
|
|
5
|
+
// carry NO guard, so an anonymous visitor's browser can subscribe and see
|
|
6
|
+
// the live timeline. (A status page that requires a login is useless during
|
|
7
|
+
// an outage.)
|
|
8
|
+
// • WRITES are OPERATOR-ONLY — every mutation declares a `status:write` guard.
|
|
9
|
+
// `rbacPlugin` resolves each caller's ROLES to SCOPES and the framework
|
|
10
|
+
// enforces the guard in the dispatch spine, before the executor. `voltro
|
|
11
|
+
// check` can see the guard statically.
|
|
12
|
+
//
|
|
13
|
+
// The role map lives in `authz.ts`. Config-only — no external infra.
|
|
14
|
+
import { defineEnv, envVar } from '@voltro/env'
|
|
15
|
+
import { rbacPlugin } from '@voltro/plugin-rbac'
|
|
16
|
+
import { demoRolesForTenant, roles } from './authz'
|
|
17
|
+
|
|
18
|
+
export const env = defineEnv({
|
|
19
|
+
LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
type: 'api' as const,
|
|
24
|
+
name: '{{capProjectName}}{{capAppName}}',
|
|
25
|
+
store: 'memory' as const,
|
|
26
|
+
plugins: [
|
|
27
|
+
rbacPlugin({
|
|
28
|
+
roles,
|
|
29
|
+
// Demo: `ops` tenant → operator. Swap for a real resolver (subject
|
|
30
|
+
// metadata / a DB lookup) when you wire operator auth — pair this api
|
|
31
|
+
// with the api-auth / api-saas-starter auth backend.
|
|
32
|
+
resolveRoles: (subject) => demoRolesForTenant(subject.tenantId),
|
|
33
|
+
}),
|
|
34
|
+
],
|
|
35
|
+
env,
|
|
36
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The status page's authorization vocabulary, in ONE place — split out of
|
|
2
|
+
// app.config.ts so the tests build the real plugin from the real role map (a
|
|
3
|
+
// test that redeclares the roles only proves its own copy is self-consistent).
|
|
4
|
+
//
|
|
5
|
+
// A status page has exactly two kinds of caller: the PUBLIC (anonymous
|
|
6
|
+
// visitors, who only READ — the live queries carry no guard) and OPERATORS
|
|
7
|
+
// (who post incidents). So there is one write scope, `status:write`, held by
|
|
8
|
+
// the `operator` role; `admin` gets the wildcard bypass.
|
|
9
|
+
//
|
|
10
|
+
// This map is ALSO the app's declared scope vocabulary: `rbacPlugin` publishes
|
|
11
|
+
// its union, and `voltro check` errors on any descriptor guard requiring a
|
|
12
|
+
// scope that appears nowhere here — so a guard and its grant can't silently
|
|
13
|
+
// drift out of sync.
|
|
14
|
+
export const roles = {
|
|
15
|
+
operator: ['status:write'],
|
|
16
|
+
admin: ['*'],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* DEMO role resolver — grants `operator` to callers in the `ops` tenant so
|
|
21
|
+
* every operator flow is reachable with an `x-tenant: ops` header and zero auth
|
|
22
|
+
* setup. Everyone else (including anonymous visitors) gets NO role, so the
|
|
23
|
+
* ungated read queries still work but every `status:write` mutation is denied.
|
|
24
|
+
*
|
|
25
|
+
* PRODUCTION reads the caller's REAL roles: the default resolver reads
|
|
26
|
+
* `subject.metadata.roles` (set by your auth strategy — pair this with the
|
|
27
|
+
* api-auth / api-saas-starter auth wiring), or do a DB lookup here. If this
|
|
28
|
+
* throws, rbac degrades to the subject's own scopes — it never grants on
|
|
29
|
+
* failure.
|
|
30
|
+
*/
|
|
31
|
+
export const demoRolesForTenant = (
|
|
32
|
+
tenantId: string | null | undefined,
|
|
33
|
+
): ReadonlyArray<string> => (tenantId === 'ops' ? ['operator'] : [])
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Schema for the {{projectName}} status page.
|
|
2
|
+
//
|
|
3
|
+
// These tables are PUBLIC and NOT tenant-scoped on purpose: a status page is
|
|
4
|
+
// read by anyone, so the live queries must return rows for an anonymous caller.
|
|
5
|
+
// (Adding `tenant()` here would AND-merge `tenantId = subject.tenantId` and an
|
|
6
|
+
// anonymous visitor — tenantId null — would see nothing.) Write access is
|
|
7
|
+
// controlled by the rbac `status:write` guard on the mutations instead.
|
|
8
|
+
//
|
|
9
|
+
// `actors` + `tenants` are the framework core tables.
|
|
10
|
+
import {
|
|
11
|
+
databaseHandle,
|
|
12
|
+
id,
|
|
13
|
+
table,
|
|
14
|
+
text,
|
|
15
|
+
timestamp,
|
|
16
|
+
type InferRow,
|
|
17
|
+
} from '@voltro/database'
|
|
18
|
+
|
|
19
|
+
export const actors = table('actors', {
|
|
20
|
+
id: id(),
|
|
21
|
+
kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
|
|
22
|
+
displayName: text().nullable(),
|
|
23
|
+
createdAt: timestamp().default('now'),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export const tenants = table('tenants', {
|
|
27
|
+
id: id(),
|
|
28
|
+
name: text(),
|
|
29
|
+
createdAt: timestamp().default('now'),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// A monitored service component (API, Dashboard, Webhooks, …).
|
|
33
|
+
export const components = table('components', {
|
|
34
|
+
id: id({ prefix: 'cmp' }),
|
|
35
|
+
name: text(),
|
|
36
|
+
status: text().oneOf(['operational', 'degraded', 'partial_outage', 'major_outage']),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// An incident. `impact` drives the public banner colour; `status` walks the
|
|
40
|
+
// lifecycle investigating → identified → monitoring → resolved.
|
|
41
|
+
export const incidents = table('incidents', {
|
|
42
|
+
id: id({ prefix: 'inc' }),
|
|
43
|
+
title: text(),
|
|
44
|
+
impact: text().oneOf(['minor', 'major', 'critical']),
|
|
45
|
+
status: text().oneOf(['investigating', 'identified', 'monitoring', 'resolved']),
|
|
46
|
+
startedAt: timestamp().default('now'),
|
|
47
|
+
resolvedAt: timestamp().nullable(),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// A timeline entry posted against an incident. The public page renders these in
|
|
51
|
+
// order under each incident.
|
|
52
|
+
export const incidentUpdates = table('incident_updates', {
|
|
53
|
+
id: id({ prefix: 'iu' }),
|
|
54
|
+
incidentId: text(),
|
|
55
|
+
body: text(),
|
|
56
|
+
status: text().oneOf(['investigating', 'identified', 'monitoring', 'resolved']),
|
|
57
|
+
createdAt: timestamp().default('now'),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
export type Component = InferRow<typeof components>
|
|
61
|
+
export type Incident = InferRow<typeof incidents>
|
|
62
|
+
export type IncidentUpdate = InferRow<typeof incidentUpdates>
|
|
63
|
+
|
|
64
|
+
export const database = databaseHandle({
|
|
65
|
+
actors,
|
|
66
|
+
tenants,
|
|
67
|
+
components,
|
|
68
|
+
incidents,
|
|
69
|
+
incidentUpdates,
|
|
70
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import type { AppContext } from '@voltro/runtime'
|
|
3
|
+
import { EffectStore } from '@voltro/runtime'
|
|
4
|
+
|
|
5
|
+
const execute = (input: { name: string }, _ctx: AppContext) =>
|
|
6
|
+
Effect.gen(function* () {
|
|
7
|
+
const store = yield* EffectStore
|
|
8
|
+
const row = yield* store.insert('components', {
|
|
9
|
+
name: input.name,
|
|
10
|
+
status: 'operational',
|
|
11
|
+
})
|
|
12
|
+
return {
|
|
13
|
+
id: row['id'] as string,
|
|
14
|
+
name: row['name'] as string,
|
|
15
|
+
status: row['status'] as string,
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export default execute
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Add a monitored component — OPERATOR ONLY. Seed your components once
|
|
2
|
+
// (API, Dashboard, Webhooks, …); the public page lists them with their status.
|
|
3
|
+
import { defineMutation } from '@voltro/protocol'
|
|
4
|
+
import { Schema } from 'effect'
|
|
5
|
+
|
|
6
|
+
export const createComponent = defineMutation({
|
|
7
|
+
name: 'components.create',
|
|
8
|
+
target: { table: 'components', op: 'insert' },
|
|
9
|
+
guards: [{ scope: 'status:write' }],
|
|
10
|
+
input: Schema.Struct({ name: Schema.NonEmptyString }),
|
|
11
|
+
output: Schema.Struct({
|
|
12
|
+
id: Schema.String,
|
|
13
|
+
name: Schema.String,
|
|
14
|
+
status: Schema.String,
|
|
15
|
+
}),
|
|
16
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Authz-free here: by the time this runs the framework has already enforced the
|
|
2
|
+
// `status:write` guard from the descriptor. A new incident opens in the
|
|
3
|
+
// `investigating` state; `startedAt` is stamped by the column default.
|
|
4
|
+
import { Effect } from 'effect'
|
|
5
|
+
import type { AppContext } from '@voltro/runtime'
|
|
6
|
+
import { EffectStore } from '@voltro/runtime'
|
|
7
|
+
|
|
8
|
+
const execute = (input: { title: string; impact: string }, _ctx: AppContext) =>
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const store = yield* EffectStore
|
|
11
|
+
const row = yield* store.insert('incidents', {
|
|
12
|
+
title: input.title,
|
|
13
|
+
impact: input.impact,
|
|
14
|
+
status: 'investigating',
|
|
15
|
+
resolvedAt: null,
|
|
16
|
+
})
|
|
17
|
+
return {
|
|
18
|
+
id: row['id'] as string,
|
|
19
|
+
title: row['title'] as string,
|
|
20
|
+
impact: row['impact'] as string,
|
|
21
|
+
status: row['status'] as string,
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export default execute
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Declare an incident — OPERATOR ONLY. The `guards: [{ scope: 'status:write' }]`
|
|
2
|
+
// is enforced by the framework before the executor runs; `voltro check` sees it
|
|
3
|
+
// statically. Declaring `guards:` merges `ScopeError` into the wire-error union
|
|
4
|
+
// automatically (no `error:` needed).
|
|
5
|
+
import { defineMutation } from '@voltro/protocol'
|
|
6
|
+
import { Schema } from 'effect'
|
|
7
|
+
|
|
8
|
+
export const createIncident = defineMutation({
|
|
9
|
+
name: 'incidents.create',
|
|
10
|
+
target: { table: 'incidents', op: 'insert' },
|
|
11
|
+
guards: [{ scope: 'status:write' }],
|
|
12
|
+
input: Schema.Struct({
|
|
13
|
+
title: Schema.NonEmptyString,
|
|
14
|
+
impact: Schema.Literal('minor', 'major', 'critical'),
|
|
15
|
+
}),
|
|
16
|
+
output: Schema.Struct({
|
|
17
|
+
id: Schema.String,
|
|
18
|
+
title: Schema.String,
|
|
19
|
+
impact: Schema.String,
|
|
20
|
+
status: Schema.String,
|
|
21
|
+
}),
|
|
22
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import type { AppContext } from '@voltro/runtime'
|
|
3
|
+
import { EffectStore } from '@voltro/runtime'
|
|
4
|
+
|
|
5
|
+
const execute = (input: { incidentId: string; body: string }, _ctx: AppContext) =>
|
|
6
|
+
Effect.gen(function* () {
|
|
7
|
+
const store = yield* EffectStore
|
|
8
|
+
const row = yield* store.update('incidents', input.incidentId, {
|
|
9
|
+
status: 'resolved',
|
|
10
|
+
resolvedAt: new Date(),
|
|
11
|
+
})
|
|
12
|
+
// A closing timeline entry (default body when the operator left it blank).
|
|
13
|
+
yield* store.insert('incident_updates', {
|
|
14
|
+
incidentId: input.incidentId,
|
|
15
|
+
body: input.body === '' ? 'Resolved.' : input.body,
|
|
16
|
+
status: 'resolved',
|
|
17
|
+
})
|
|
18
|
+
return {
|
|
19
|
+
id: input.incidentId,
|
|
20
|
+
status: (row?.['status'] as string | undefined) ?? 'resolved',
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export default execute
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Resolve an incident — OPERATOR ONLY. Sets `status: 'resolved'` + stamps
|
|
2
|
+
// `resolvedAt`, and appends a final `resolved` timeline entry. The public page's
|
|
3
|
+
// banner goes green and the incident archives into the history.
|
|
4
|
+
import { defineMutation } from '@voltro/protocol'
|
|
5
|
+
import { Schema } from 'effect'
|
|
6
|
+
|
|
7
|
+
export const resolveIncident = defineMutation({
|
|
8
|
+
name: 'incidents.resolve',
|
|
9
|
+
target: { table: 'incidents', op: 'update' },
|
|
10
|
+
guards: [{ scope: 'status:write' }],
|
|
11
|
+
input: Schema.Struct({
|
|
12
|
+
incidentId: Schema.NonEmptyString,
|
|
13
|
+
body: Schema.String,
|
|
14
|
+
}),
|
|
15
|
+
output: Schema.Struct({
|
|
16
|
+
id: Schema.String,
|
|
17
|
+
status: Schema.String,
|
|
18
|
+
}),
|
|
19
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import type { AppContext } from '@voltro/runtime'
|
|
3
|
+
import { EffectStore } from '@voltro/runtime'
|
|
4
|
+
|
|
5
|
+
const execute = (
|
|
6
|
+
input: { incidentId: string; body: string; status: string },
|
|
7
|
+
_ctx: AppContext,
|
|
8
|
+
) =>
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const store = yield* EffectStore
|
|
11
|
+
// Advance the incident's own status so the banner reflects the latest note.
|
|
12
|
+
yield* store.update('incidents', input.incidentId, { status: input.status })
|
|
13
|
+
// Append the timeline entry.
|
|
14
|
+
const row = yield* store.insert('incident_updates', {
|
|
15
|
+
incidentId: input.incidentId,
|
|
16
|
+
body: input.body,
|
|
17
|
+
status: input.status,
|
|
18
|
+
})
|
|
19
|
+
return {
|
|
20
|
+
id: row['id'] as string,
|
|
21
|
+
incidentId: row['incidentId'] as string,
|
|
22
|
+
status: row['status'] as string,
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export default execute
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Post a timeline update to an open incident — OPERATOR ONLY. Writes an
|
|
2
|
+
// `incident_updates` row AND advances the incident's own `status` so the public
|
|
3
|
+
// banner + timeline move together.
|
|
4
|
+
import { defineMutation } from '@voltro/protocol'
|
|
5
|
+
import { Schema } from 'effect'
|
|
6
|
+
|
|
7
|
+
export const updateIncident = defineMutation({
|
|
8
|
+
name: 'incidents.update',
|
|
9
|
+
target: { table: 'incident_updates', op: 'insert' },
|
|
10
|
+
guards: [{ scope: 'status:write' }],
|
|
11
|
+
input: Schema.Struct({
|
|
12
|
+
incidentId: Schema.NonEmptyString,
|
|
13
|
+
body: Schema.NonEmptyString,
|
|
14
|
+
status: Schema.Literal('investigating', 'identified', 'monitoring'),
|
|
15
|
+
}),
|
|
16
|
+
output: Schema.Struct({
|
|
17
|
+
id: Schema.String,
|
|
18
|
+
incidentId: Schema.String,
|
|
19
|
+
status: Schema.String,
|
|
20
|
+
}),
|
|
21
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@{{projectName}}/{{appName}}",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "voltro dev .",
|
|
8
|
+
"lint": "voltro doctor .",
|
|
9
|
+
"migrate": "voltro migrate",
|
|
10
|
+
"test": "voltro test",
|
|
11
|
+
"typecheck": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@effect/platform": "^0.97.0",
|
|
15
|
+
"@effect/rpc": "^0.76.0",
|
|
16
|
+
"@voltro/cli": "0.30.0",
|
|
17
|
+
"@voltro/database": "0.30.0",
|
|
18
|
+
"@voltro/env": "0.30.0",
|
|
19
|
+
"@voltro/plugin-rbac": "0.30.0",
|
|
20
|
+
"@voltro/protocol": "0.30.0",
|
|
21
|
+
"@voltro/runtime": "0.30.0",
|
|
22
|
+
"@voltro/sql-postgres": "0.30.0",
|
|
23
|
+
"effect": "^3.22.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@voltro/testing": "0.30.0",
|
|
27
|
+
"typescript": "^6.0.3",
|
|
28
|
+
"vitest": "^4.1.10"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AppContext } from '@voltro/runtime'
|
|
2
|
+
|
|
3
|
+
const execute = (_input: Record<string, never>, _ctx: AppContext) => ({
|
|
4
|
+
descriptor: {
|
|
5
|
+
table: 'components' as const,
|
|
6
|
+
predicate: undefined,
|
|
7
|
+
order: [{ column: 'name' as const, direction: 'asc' as const }],
|
|
8
|
+
take: 100,
|
|
9
|
+
skip: undefined,
|
|
10
|
+
projection: undefined,
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export default execute
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Live list of service components + their current status. Public (no guard).
|
|
2
|
+
import { defineQuery } from '@voltro/protocol'
|
|
3
|
+
import { Schema } from 'effect'
|
|
4
|
+
|
|
5
|
+
export const componentsList = defineQuery({
|
|
6
|
+
name: 'components.list',
|
|
7
|
+
input: Schema.Struct({}),
|
|
8
|
+
output: Schema.Struct({
|
|
9
|
+
id: Schema.String,
|
|
10
|
+
name: Schema.String,
|
|
11
|
+
status: Schema.String,
|
|
12
|
+
}),
|
|
13
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AppContext } from '@voltro/runtime'
|
|
2
|
+
|
|
3
|
+
const execute = (_input: Record<string, never>, _ctx: AppContext) => ({
|
|
4
|
+
descriptor: {
|
|
5
|
+
table: 'incidents' as const,
|
|
6
|
+
predicate: undefined,
|
|
7
|
+
order: [{ column: 'startedAt' as const, direction: 'desc' as const }],
|
|
8
|
+
take: 100,
|
|
9
|
+
skip: undefined,
|
|
10
|
+
projection: undefined,
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export default execute
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The live incident feed — the heart of the status page. NO `guards:`, so an
|
|
2
|
+
// anonymous visitor's browser can subscribe: `useSubscription('app',
|
|
3
|
+
// 'incidents.live')`. Every insert/update to `incidents` lands as a delta, so a
|
|
4
|
+
// newly-declared incident (or a resolve) shows up on every open status page
|
|
5
|
+
// within seconds — no polling.
|
|
6
|
+
import { defineQuery } from '@voltro/protocol'
|
|
7
|
+
import { Schema } from 'effect'
|
|
8
|
+
|
|
9
|
+
export const incidentsLive = defineQuery({
|
|
10
|
+
name: 'incidents.live',
|
|
11
|
+
input: Schema.Struct({}),
|
|
12
|
+
output: Schema.Struct({
|
|
13
|
+
id: Schema.String,
|
|
14
|
+
title: Schema.String,
|
|
15
|
+
impact: Schema.String,
|
|
16
|
+
status: Schema.String,
|
|
17
|
+
startedAt: Schema.Date,
|
|
18
|
+
resolvedAt: Schema.NullOr(Schema.Date),
|
|
19
|
+
}),
|
|
20
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AppContext } from '@voltro/runtime'
|
|
2
|
+
|
|
3
|
+
const execute = (_input: Record<string, never>, _ctx: AppContext) => ({
|
|
4
|
+
descriptor: {
|
|
5
|
+
table: 'incident_updates' as const,
|
|
6
|
+
predicate: undefined,
|
|
7
|
+
order: [{ column: 'createdAt' as const, direction: 'desc' as const }],
|
|
8
|
+
take: 200,
|
|
9
|
+
skip: undefined,
|
|
10
|
+
projection: undefined,
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export default execute
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Live feed of incident timeline entries. Public (no guard). The page joins
|
|
2
|
+
// these to incidents by `incidentId` client-side to render each incident's
|
|
3
|
+
// update history.
|
|
4
|
+
import { defineQuery } from '@voltro/protocol'
|
|
5
|
+
import { Schema } from 'effect'
|
|
6
|
+
|
|
7
|
+
export const updatesList = defineQuery({
|
|
8
|
+
name: 'updates.list',
|
|
9
|
+
input: Schema.Struct({}),
|
|
10
|
+
output: Schema.Struct({
|
|
11
|
+
id: Schema.String,
|
|
12
|
+
incidentId: Schema.String,
|
|
13
|
+
body: Schema.String,
|
|
14
|
+
status: Schema.String,
|
|
15
|
+
createdAt: Schema.Date,
|
|
16
|
+
}),
|
|
17
|
+
})
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "api-status",
|
|
3
|
+
"kind": "api",
|
|
4
|
+
"summary": "The backend for a public status page: ungated live queries (incidents.live, updates.list, components.list) any visitor can subscribe to, plus operator-only mutations (create/update/resolve incidents, manage components) gated by a @voltro/plugin-rbac `status:write` scope. Reads are public so an anonymous status page works; writes need an operator. Pairs with the frontend-status web template. Boots zero-infra.",
|
|
5
|
+
"tags": ["api", "status-page", "incidents", "rbac", "live", "useSubscription", "operator"]
|
|
6
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Authorization + descriptor tests, driven through the REAL rbac plugin.
|
|
2
|
+
//
|
|
3
|
+
// `makeTestContext({ plugins: [...] })` + `invoke` composes this app's actual
|
|
4
|
+
// interceptor chain and enforces each descriptor's `guards:` with the same
|
|
5
|
+
// `checkGuardsEffect` the serve pipeline calls — so we exercise the whole path
|
|
6
|
+
// (tenant → role → compiled scope → guard), not a hand-stamped `subject.scopes`.
|
|
7
|
+
// The asymmetry that defines a status page is the thing under test: reads are
|
|
8
|
+
// public, writes are operator-only.
|
|
9
|
+
//
|
|
10
|
+
// Run with `voltro test` (vitest).
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from 'vitest'
|
|
13
|
+
import { Schema } from 'effect'
|
|
14
|
+
import { makeTestContext, mockStore, invoke } from '@voltro/testing'
|
|
15
|
+
import { rbacPlugin } from '@voltro/plugin-rbac'
|
|
16
|
+
import type { Subject } from '@voltro/protocol'
|
|
17
|
+
|
|
18
|
+
import { roles, demoRolesForTenant } from '../authz'
|
|
19
|
+
import { database } from '../database/schema' // registers incidents / components / updates / core
|
|
20
|
+
import { createIncident } from '../mutations/incidents.create.mutation'
|
|
21
|
+
import createIncidentHandler from '../mutations/incidents.create.mutation.server'
|
|
22
|
+
import { incidentsLive } from '../queries/incidents.live.query'
|
|
23
|
+
import incidentsLiveHandler from '../queries/incidents.live.query.server'
|
|
24
|
+
|
|
25
|
+
/** The app's real plugin, built from the app's real role map. */
|
|
26
|
+
const plugin = () => rbacPlugin({ roles, resolveRoles: (subject) => demoRolesForTenant(subject.tenantId) })
|
|
27
|
+
|
|
28
|
+
const subjectFor = (tenantId: string | null, id: string | null = 'u_1', type = 'user'): Subject =>
|
|
29
|
+
({ type, id, tenantId, scopes: [] }) as Subject
|
|
30
|
+
|
|
31
|
+
const ctxFor = (subject: Subject) =>
|
|
32
|
+
makeTestContext({ subject, store: mockStore({ incidents: [], components: [], incident_updates: [] }), plugins: [plugin()] })
|
|
33
|
+
|
|
34
|
+
describe('incidents.create — operator-gated', () => {
|
|
35
|
+
it('an operator (tenant `ops`) may declare an incident', async () => {
|
|
36
|
+
const ctx = ctxFor(subjectFor('ops'))
|
|
37
|
+
const inc = await invoke(createIncident, createIncidentHandler, { title: 'API degraded', impact: 'major' }, ctx)
|
|
38
|
+
expect(inc.title).toBe('API degraded')
|
|
39
|
+
expect(inc.status).toBe('investigating')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('an anonymous visitor is refused with a typed ScopeError, and nothing is written', async () => {
|
|
43
|
+
const ctx = ctxFor(subjectFor(null, null, 'anonymous'))
|
|
44
|
+
await expect(invoke(createIncident, createIncidentHandler, { title: 'nope', impact: 'minor' }, ctx))
|
|
45
|
+
.rejects.toMatchObject({ _tag: 'ScopeError', required: 'status:write' })
|
|
46
|
+
expect(await ctx.store.query(database.incidents.descriptor)).toHaveLength(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('a signed-in NON-operator is also refused', async () => {
|
|
50
|
+
const ctx = ctxFor(subjectFor('marketing'))
|
|
51
|
+
await expect(invoke(createIncident, createIncidentHandler, { title: 'nope', impact: 'minor' }, ctx))
|
|
52
|
+
.rejects.toMatchObject({ _tag: 'ScopeError', required: 'status:write' })
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('incidents.live — reads are PUBLIC', () => {
|
|
57
|
+
it('an anonymous visitor may subscribe (no guard on the query)', async () => {
|
|
58
|
+
const ctx = ctxFor(subjectFor(null, null, 'anonymous'))
|
|
59
|
+
await expect(invoke(incidentsLive, incidentsLiveHandler, {}, ctx)).resolves.toBeDefined()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('incidents.create — descriptor', () => {
|
|
64
|
+
it('accepts a valid impact and rejects an unknown one', () => {
|
|
65
|
+
const decode = Schema.decodeUnknownSync(createIncident.input)
|
|
66
|
+
expect(decode({ title: 'x', impact: 'critical' })).toEqual({ title: 'x', impact: 'critical' })
|
|
67
|
+
expect(() => decode({ title: 'x', impact: 'catastrophic' })).toThrow()
|
|
68
|
+
expect(() => decode({ title: '', impact: 'minor' })).toThrow()
|
|
69
|
+
})
|
|
70
|
+
})
|