@voltro/cli 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1968 -0
- package/bin/nodeEnvironment.d.mts +30 -0
- package/bin/nodeEnvironment.mjs +158 -0
- package/bin/voltro.mjs +69 -5
- package/dist/addCommand-BNeoeSxe.js +124 -0
- package/dist/addCommand-aXSQveak.js +2 -0
- package/dist/agentsMd-BTchIZku.js +2 -0
- package/dist/agentsMd-mhQMF1bx.js +254 -0
- package/dist/apiBuild-B8aoJvuw.js +2 -0
- package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DYD_ONLD.js} +46 -46
- package/dist/{appGraph-gQ_6GkQQ.js → appGraph-KGDPTuTy.js} +1 -1
- package/dist/appGraph-zuMGKVYX.js +2 -0
- package/dist/appPort-B_HpJ_ck.js +48 -0
- package/dist/baselineCommand-C2ClWZN3.js +2 -0
- package/dist/baselineCommand-DIttzO8A.js +227 -0
- package/dist/bin.js +71 -28
- package/dist/build-CD8K4XOr.js +711 -0
- package/dist/cacheCommand-DA4OH9xt.js +42 -0
- package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
- package/dist/checkCommand-Ct9xkTrS.js +232 -0
- package/dist/checkCommand-DKpDLlqu.js +2 -0
- package/dist/{cliArgs-qdZSElM3.js → cliArgs-D4p8n7EE.js} +12 -1
- package/dist/cliError-BmdYnghb.js +10 -0
- package/dist/cliOutput-D1tSBoRM.js +15 -0
- package/dist/{cliRuntime-Oh517vCV.js → cliRuntime-Dh7UDinH.js} +20 -20
- package/dist/cloudClient-DWL-Hw_T.js +67 -0
- package/dist/cloudCmd-Cvv5HGaZ.js +364 -0
- package/dist/clusterCmd-D5wsCmA_.js +54 -0
- package/dist/codegen-CYM3Zqrf.js +605 -0
- package/dist/codegen-ChBi_hVa.js +2 -0
- package/dist/codegenCommand-C4YoQIc2.js +30 -0
- package/dist/codemodRunner-BnFq3Fgu.js +5384 -0
- package/dist/commandRunner-BLAEFLjp.js +47 -0
- package/dist/commands-BE8E7zF3.js +816 -0
- package/dist/connectionConfig-UFlIEiys.js +66 -0
- package/dist/dashboardCommand-D7SgZGaN.js +25 -0
- package/dist/dataCommand-BhYwDgg-.js +537 -0
- package/dist/dataProfile-dW-PsfLB.js +15 -0
- package/dist/dbCommand-DS4b97Is.js +2 -0
- package/dist/{dbCommand-DTLKAfbA.js → dbCommand-O8HA63s2.js} +552 -402
- package/dist/{dev-C_P8FLSx.js → dev-C7sFZq3m.js} +3670 -3169
- package/dist/dev-D2BikO7a.js +3 -0
- package/dist/devActivity-Dx_3nnGv.js +100 -0
- package/dist/devActivity.js +1 -1
- package/dist/dialectDriver-CgXnDfec.js +39 -0
- package/dist/discover-C9XKJDco.js +25 -0
- package/dist/doctorCommand-CM4Ch9C7.js +2 -0
- package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-DnimF5IM.js} +523 -1278
- package/dist/dormancyCommand-QewYug_s.js +69 -0
- package/dist/e2eCmd-BRabZww-.js +147 -0
- package/dist/embeddingsCommand-BfiLS_QI.js +73 -0
- package/dist/envCommand-CCGPRQY1.js +60 -0
- package/dist/evalCommand-6RUfPen4.js +118 -0
- package/dist/evolveCommand-CHsLCtDf.js +281 -0
- package/dist/fileTaxonomy-CJfgOllU.js +457 -0
- package/dist/frameworkTableAssembly-YyVe32Cb.js +2 -0
- package/dist/frameworkTableAssembly-oMPBKqlE.js +511 -0
- package/dist/generateCommand-DbgcUpGw.js +147 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +4 -3
- package/dist/infoCommand-DwOgK1t6.js +60 -0
- package/dist/{inspect-BUUjt773.js → inspect-CjTYzAs_.js} +113 -41
- package/dist/inspect-P4pxoMaV.js +2 -0
- package/dist/inspectCmd-EHFZ9yYu.js +224 -0
- package/dist/inspectFetch-EMuhTG_9.js +151 -0
- package/dist/inspectMetrics-CfdKLh6t.js +72 -0
- package/dist/loadEnv-D9nEOClM.js +44 -0
- package/dist/logFileSink-C_D2wRN1.js +105 -0
- package/dist/logsCmd-D36xK7Zu.js +260 -0
- package/dist/manifestBuild-COkJoyAr.js +2 -0
- package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-hpPLaGxV.js} +1 -1
- package/dist/metaCommands-7MJfZ5cf.js +196 -0
- package/dist/migrate-BV7I-ZHZ.js +83 -0
- package/dist/mssqlClusterPatch-_4cE_nun.js +44 -0
- package/dist/newCommand-COWOJ1_E.js +156 -0
- package/dist/nodeEnvironment-cGFAj1J8.js +28 -0
- package/dist/packageCommand-Cug_3Ogl.js +271 -0
- package/dist/pageConvention-cEiRxdab.js +5 -0
- package/dist/privacyCommand-C-Df56U_.js +146 -0
- package/dist/probeCommand-CZfaaUOZ.js +122 -0
- package/dist/projectScaffold-DmzEKHib.js +2 -0
- package/dist/projectScaffold-LMMtaavR.js +814 -0
- package/dist/renderModeScan-D7J1B7Kw.js +105 -0
- package/dist/renderProfile-1OWWAAtx.js +81 -0
- package/dist/runtimeRegistry-DMeKfTHP.js +81 -0
- package/dist/runtimeTrace-CH3eUiMw.js +91 -0
- package/dist/scheduleCmd-DQRu6BZC.js +149 -0
- package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
- package/dist/schemaIr-UJybUUZW.js +103 -0
- package/dist/{sdkgen-C81QIkiL.js → sdkgen-BLkvGRfX.js} +111 -209
- package/dist/seedRunner-ZmLSqNe2.js +333 -0
- package/dist/serveCommand-CbDHU6l-.js +2 -0
- package/dist/serveCommand-iwlUBNS1.js +1766 -0
- package/dist/serveEntry.js +5 -5
- package/dist/serverlessCommand-CfJZy6dS.js +482 -0
- package/dist/start-BgN62boB.js +3 -0
- package/dist/start-T4VesWiM.js +1087 -0
- package/dist/startEntry.js +2 -2
- package/dist/staticCommand-Dr2M6tpU.js +304 -0
- package/dist/storageCommand-Co6NfLqN.js +42 -0
- package/dist/templates-De8IR5-c.js +102 -0
- package/dist/test-CI6iDsYc.js +115 -0
- package/dist/tracesCmd-DStmCJPi.js +232 -0
- package/dist/tsconfigPaths-BWXBWgcl.js +107 -0
- package/dist/tsxLoader-EuXmSJ1K.js +51 -0
- package/dist/typecheckCommand-BlsWiCNq.js +61 -0
- package/dist/updateCommand-BlMZhWgO.js +2 -0
- package/dist/updateCommand-x0pI_x-B.js +585 -0
- package/dist/webDev-BcykISYQ2.js +2 -0
- package/dist/{inspectMetrics-1xzTKAFx.js → webDev-Dybxew86.js} +988 -1560
- package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
- package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
- package/dist/webhooksCommand-DlAgS2Iw.js +267 -0
- package/dist/workflowsCmd-BGF-mRZ5.js +608 -0
- package/package.json +209 -17
- package/templates/AGENTS.core.md +58 -3
- package/templates/AGENTS.md +64 -7
- package/templates/agent-docs/_index.md +6 -4
- package/templates/agent-docs/_manifest.json +23 -6
- package/templates/agent-docs/ai.md +370 -0
- package/templates/agent-docs/authentication.md +313 -31
- package/templates/agent-docs/caching.md +6 -0
- package/templates/agent-docs/cli.md +853 -50
- package/templates/agent-docs/data.md +608 -12
- package/templates/agent-docs/database/migrations.md +223 -25
- package/templates/agent-docs/database/misc.md +156 -40
- package/templates/agent-docs/database/querying.md +19 -1
- package/templates/agent-docs/database/scaling.md +60 -0
- package/templates/agent-docs/database/schema.md +1 -0
- package/templates/agent-docs/database/seedsdialects.md +208 -19
- package/templates/agent-docs/database/transactions.md +68 -0
- package/templates/agent-docs/deployment.md +284 -25
- package/templates/agent-docs/introduction.md +88 -17
- package/templates/agent-docs/local-first-mobile.md +79 -4
- package/templates/agent-docs/multi-tenancy.md +188 -42
- package/templates/agent-docs/observability.md +58 -3
- package/templates/agent-docs/plugins/ai-flows.md +247 -2
- package/templates/agent-docs/plugins/analytics-postgres.md +1 -1
- package/templates/agent-docs/plugins/audit.md +37 -1
- package/templates/agent-docs/plugins/auth-social.md +143 -0
- package/templates/agent-docs/plugins/auth-workos.md +4 -2
- package/templates/agent-docs/plugins/auth.md +131 -6
- package/templates/agent-docs/plugins/billing.md +132 -15
- package/templates/agent-docs/plugins/cdc-out.md +46 -7
- package/templates/agent-docs/plugins/clickhouse.md +32 -1
- package/templates/agent-docs/plugins/duckdb.md +1 -1
- package/templates/agent-docs/plugins/flags.md +132 -0
- package/templates/agent-docs/plugins/governance.md +105 -7
- package/templates/agent-docs/plugins/multitenancy.md +9 -4
- package/templates/agent-docs/plugins/presence.md +13 -2
- package/templates/agent-docs/plugins/ratelimit.md +9 -0
- package/templates/agent-docs/plugins/search.md +162 -8
- package/templates/agent-docs/plugins/sso-saml.md +47 -8
- package/templates/agent-docs/plugins/storage.md +11 -0
- package/templates/agent-docs/plugins/webhooks.md +105 -0
- package/templates/agent-docs/plugins.md +152 -18
- package/templates/agent-docs/reference.md +60 -3
- package/templates/agent-docs/releases.md +1117 -0
- package/templates/agent-docs/routing.md +43 -25
- package/templates/agent-docs/scheduling.md +27 -0
- package/templates/agent-docs/schema-driven-ui.md +92 -12
- package/templates/agent-docs/security.md +426 -0
- package/templates/agent-docs/templates/apibackends.md +87 -18
- package/templates/agent-docs/templates/appshells.md +32 -14
- package/templates/agent-docs/templates/overview.md +13 -8
- package/templates/agent-docs/testing.md +211 -14
- package/templates/agent-docs/whats-new.md +98 -136
- package/templates/agent-docs/workflows.md +231 -14
- package/templates/apps/api-ai/actions/summarize.action.ts +11 -0
- package/templates/apps/api-ai/package.json +8 -7
- package/templates/apps/api-auth/actions/me.action.ts +13 -0
- package/templates/apps/api-auth/package.json +9 -8
- package/templates/apps/api-backend/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend/package.json +12 -8
- package/templates/apps/api-backend/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +14 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +8 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +10 -0
- package/templates/apps/api-backend-deactivation/package.json +8 -7
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +17 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mail/package.json +9 -8
- package/templates/apps/api-backend-mail/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-mariadb/.env.example +14 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mariadb/package.json +10 -9
- package/templates/apps/api-backend-mariadb/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-sqlite/.env.example +14 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-sqlite/package.json +9 -8
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +13 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +12 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-storage/package.json +9 -8
- package/templates/apps/api-backend-storage/queries/notes.query.ts +30 -8
- package/templates/apps/api-cms/actions/content.get.action.ts +7 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +6 -0
- package/templates/apps/api-cms/actions/me.action.ts +13 -0
- package/templates/apps/api-cms/app.config.ts +19 -0
- package/templates/apps/api-cms/authz.ts +63 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +5 -0
- package/templates/apps/api-cms/package.json +11 -10
- package/templates/apps/api-cms/queries/content.list.query.ts +23 -7
- package/templates/apps/api-cms/tests/accessDecisions.test.ts +121 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +8 -4
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +8 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +13 -0
- package/templates/apps/api-collab/package.json +9 -8
- package/templates/apps/api-collab/queries/documents.query.ts +19 -7
- package/templates/apps/api-data-advanced/package.json +9 -8
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +12 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.ts +9 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +17 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.ts +9 -0
- package/templates/apps/api-durable/package.json +9 -8
- package/templates/apps/api-feature-flags/actions/notes.export.action.ts +16 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +12 -0
- package/templates/apps/api-feature-flags/package.json +10 -9
- package/templates/apps/api-governance/README.md +30 -8
- package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -1
- package/templates/apps/api-governance/actions/profiles.get.action.ts +29 -4
- package/templates/apps/api-governance/database/schema.ts +16 -4
- package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +12 -0
- package/templates/apps/api-governance/package.json +9 -8
- package/templates/apps/api-kv/actions/sync.pull.action.ts +15 -0
- package/templates/apps/api-kv/actions/sync.reset.action.ts +13 -0
- package/templates/apps/api-kv/actions/sync.status.action.ts +7 -0
- package/templates/apps/api-kv/package.json +9 -8
- package/templates/apps/api-kv/queries/events.list.query.ts +19 -8
- package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +11 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +13 -0
- package/templates/apps/api-moderation/package.json +9 -8
- package/templates/apps/api-observability/mutations/notes.create.mutation.ts +8 -0
- package/templates/apps/api-observability/package.json +9 -8
- package/templates/apps/api-observability/queries/notes.list.query.ts +13 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +14 -0
- package/templates/apps/api-ratelimit/package.json +9 -8
- package/templates/apps/api-rbac/package.json +9 -8
- package/templates/apps/api-rest/package.json +8 -7
- package/templates/apps/api-saas/mutations/projects.create.mutation.ts +13 -0
- package/templates/apps/api-saas/package.json +12 -11
- package/templates/apps/api-saas/queries/projects.list.query.ts +11 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +13 -0
- package/templates/apps/api-saas-starter/app.config.ts +19 -0
- package/templates/apps/api-saas-starter/authz.ts +75 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +10 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +9 -0
- package/templates/apps/api-saas-starter/package.json +15 -11
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +19 -8
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -7
- package/templates/apps/api-saas-starter/tests/accessDecisions.test.ts +135 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.ts +14 -0
- package/templates/apps/api-search/package.json +9 -8
- package/templates/apps/api-search/queries/articles.list.query.ts +21 -8
- package/templates/apps/api-status/README.md +10 -3
- package/templates/apps/api-status/app.config.ts +8 -3
- package/templates/apps/api-status/authz.ts +5 -3
- package/templates/apps/api-status/package.json +9 -8
- package/templates/apps/api-status/queries/components.list.query.ts +14 -6
- package/templates/apps/api-status/queries/incidents.live.query.ts +23 -10
- package/templates/apps/api-status/queries/updates.list.query.ts +16 -9
- package/templates/apps/api-status/tests/status.test.ts +9 -1
- package/templates/apps/api-versioning/actions/documents.asOf.action.ts +12 -0
- package/templates/apps/api-versioning/actions/documents.history.action.ts +13 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +9 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +12 -0
- package/templates/apps/api-versioning/package.json +9 -8
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +16 -0
- package/templates/apps/api-webhooks/package.json +10 -9
- package/templates/apps/api-webhooks/queries/orders.list.query.ts +10 -0
- package/templates/apps/changelog/package.json +8 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +10 -8
- package/templates/apps/frontend-admin/src/lib/admin.ts +20 -9
- package/templates/apps/frontend-admin/src/locales/de.ts +11 -1
- package/templates/apps/frontend-admin/src/locales/en.ts +13 -1
- package/templates/apps/frontend-admin/src/pages/admin/[entity]/page.tsx +65 -22
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +130 -23
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +3 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.test.tsx +19 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.tsx +9 -4
- package/templates/apps/frontend-app/app.config.ts +4 -3
- package/templates/apps/frontend-app/package.json +11 -8
- package/templates/apps/frontend-app/src/lib/api.ts +25 -0
- package/templates/apps/frontend-app/src/pages/page.test.tsx +130 -82
- package/templates/apps/frontend-app/src/pages/page.tsx +14 -18
- package/templates/apps/frontend-auth/package.json +10 -8
- package/templates/apps/frontend-blank/package.json +9 -7
- package/templates/apps/frontend-cms/package.json +11 -9
- package/templates/apps/frontend-collab/package.json +12 -9
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +122 -78
- package/templates/apps/frontend-contact/package.json +9 -7
- package/templates/apps/frontend-dashboard/package.json +9 -7
- package/templates/apps/frontend-docs/package.json +9 -7
- package/templates/apps/frontend-i18n/package.json +8 -6
- package/templates/apps/frontend-landing/package.json +9 -7
- package/templates/apps/frontend-portal/package.json +10 -8
- package/templates/apps/frontend-saas/app.config.ts +10 -6
- package/templates/apps/frontend-saas/package.json +10 -8
- package/templates/apps/frontend-saas/src/lib/api.ts +27 -32
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +7 -8
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +27 -3
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +4 -4
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +3 -4
- package/templates/apps/frontend-spa/package.json +9 -7
- package/templates/apps/frontend-ssr/package.json +9 -7
- package/templates/apps/frontend-ssr-api/package.json +10 -8
- package/templates/apps/frontend-static-blog/package.json +8 -6
- package/templates/apps/frontend-status/package.json +10 -8
- package/templates/apps/mobile-app/README.md +1 -0
- package/templates/apps/mobile-app/package.json +4 -2
- package/templates/apps/mobile-app/src/app/index.tsx +22 -12
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +1 -1
- package/templates/apps/mobile-app/src/lib/api.ts +34 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +4 -2
- package/templates/baselines/bare/.env.example +14 -0
- package/templates/baselines/bare/baseline.json +4 -4
- package/templates/baselines/compose/.env.example +14 -0
- package/templates/baselines/compose/README.md +1 -1
- package/templates/baselines/compose/baseline.json +5 -5
- package/templates/baselines/compose-mariadb/.env.example +14 -0
- package/templates/baselines/compose-mariadb/README.md +1 -1
- package/templates/baselines/compose-mariadb/baseline.json +5 -5
- package/templates/baselines/helm/.env.example +14 -0
- package/templates/baselines/helm/baseline.json +4 -4
- package/dist/apiBuild-C-x9YacA.js +0 -2
- package/dist/appGraph-CvQCte0z.js +0 -2
- package/dist/checkCommand-DRovTKza.js +0 -2
- package/dist/commands-CJfepbm4.js +0 -11541
- package/dist/dbCommand-b1gum4td.js +0 -2
- package/dist/dev-iiMtlkfs.js +0 -3
- package/dist/devActivity-BhIu6ncs.js +0 -159
- package/dist/frameworkTableAssembly-BwIrO5nv.js +0 -638
- package/dist/frameworkTableAssembly-D-EebUQX.js +0 -2
- package/dist/inspect-mmBuRXmy.js +0 -2
- package/dist/manifestBuild-Dj8Jjoto.js +0 -2
- package/dist/seedRunner-Bqxgp7HZ.js +0 -230
- package/dist/serveCommand-DdaM4Hup.js +0 -1608
- package/dist/start-C0koT0UO.js +0 -1084
- /package/templates/apps/api-ai/actions/{summarize.action.server.tsx → summarize.action.server.ts} +0 -0
|
@@ -41,6 +41,32 @@ import { aiFlowsPlugin } from '@voltro/plugin-ai-flows'
|
|
|
41
41
|
export default { plugins: [aiFlowsPlugin()] } // contributes _voltro_ai_flows + _voltro_ai_flow_runs
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
Three tunables, all with defaults — set them when the defaults don't match your
|
|
45
|
+
process:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// app.config.ts
|
|
49
|
+
import { aiFlowsPlugin } from '@voltro/plugin-ai-flows'
|
|
50
|
+
|
|
51
|
+
export default {
|
|
52
|
+
plugins: [
|
|
53
|
+
aiFlowsPlugin({
|
|
54
|
+
// How long a `human` step parks before the run fails. Default 7 days;
|
|
55
|
+
// `0` = wait forever. A flow's `humanTimeoutMs` and a step's own
|
|
56
|
+
// `timeoutMs` both outrank this.
|
|
57
|
+
humanReviewTimeoutMs: 14 * 24 * 60 * 60 * 1000,
|
|
58
|
+
// How long a FINISHED run is kept. Default 90 days.
|
|
59
|
+
runsTtlMs: 180 * 24 * 60 * 60 * 1000,
|
|
60
|
+
// Ceiling on rows one `/flows` or `/runs` inspect call returns.
|
|
61
|
+
inspectPageMax: 200,
|
|
62
|
+
}),
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Each also has a deploy-time env override, for an operator who can't edit source:
|
|
68
|
+
`VOLTRO_AI_FLOW_HUMAN_REVIEW_TIMEOUT_HOURS` and `VOLTRO_AI_FLOW_RUNS_TTL_HOURS`.
|
|
69
|
+
|
|
44
70
|
## Author a flow (code-first)
|
|
45
71
|
|
|
46
72
|
```ts
|
|
@@ -65,10 +91,39 @@ Each step writes `context[outputKey]`; later steps reference it as `{{outputKey}
|
|
|
65
91
|
|
|
66
92
|
- **`agent`** — delegate to a sub-agent (its system prompt + model), text-only.
|
|
67
93
|
- **`generate`** — `text` / `image` / `video` / `audio` via `@voltro/ai`.
|
|
68
|
-
- **`structured`** — a JSON object.
|
|
94
|
+
- **`structured`** — a JSON object, validated against the step's JSON Schema.
|
|
69
95
|
- **`note`** — the interpolated prompt itself is the output (no model call).
|
|
70
96
|
- **`human`** — pause for review (`approve` / `choice` / `text`).
|
|
71
97
|
|
|
98
|
+
### `structured` — the schema is real
|
|
99
|
+
|
|
100
|
+
A `structured` step's `schema` is a JSON Schema, and it is **both** what the model
|
|
101
|
+
is told to emit and what the result is validated against:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { flowStep } from '@voltro/plugin-ai-flows'
|
|
105
|
+
|
|
106
|
+
flowStep.structured({
|
|
107
|
+
prompt: 'Extract the invoice fields from: {{document}}',
|
|
108
|
+
outputKey: 'invoice',
|
|
109
|
+
schema: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
required: ['total', 'currency'],
|
|
112
|
+
properties: {
|
|
113
|
+
total: { type: 'number', description: 'Gross total' },
|
|
114
|
+
currency: { type: 'string', enum: ['EUR', 'USD'] },
|
|
115
|
+
lines: { type: 'array', items: { type: 'string' } },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Objects (with `required`), arrays, the four scalars, `enum`, `const`, both
|
|
122
|
+
nullability spellings and `anyOf` / `oneOf` unions are modelled;
|
|
123
|
+
`description` / `title` ride along as annotations. A construct the adapter does
|
|
124
|
+
not model (`$ref`, `allOf`, …) degrades to "unknown" for **that node** rather
|
|
125
|
+
than failing the step, and a step with no `schema` behaves as it always did.
|
|
126
|
+
|
|
72
127
|
## Human-in-the-loop
|
|
73
128
|
|
|
74
129
|
A `human` step sets the run to `waiting` and **parks the workflow without holding
|
|
@@ -76,10 +131,130 @@ a worker** (the durable suspend). The user answers via `respondToFlow`; a reject
|
|
|
76
131
|
ends the run cancelled, otherwise the answer lands in `context` and the run
|
|
77
132
|
resumes. Thousands of runs can wait on review for days at no runtime cost.
|
|
78
133
|
|
|
134
|
+
**Several reviews in one flow are independent.** Each human step parks on its own
|
|
135
|
+
durable signal, derived from that step's position in the plan the run's journal
|
|
136
|
+
pinned at step 0 — so the identity is the same on every replay, and answering the
|
|
137
|
+
first review cannot resolve the second. `respondToFlow` reads the parked step off
|
|
138
|
+
the run row's live timeline and addresses that step, which is why the UI never
|
|
139
|
+
has to track a step index; it returns the `stepIndex` it answered.
|
|
140
|
+
|
|
141
|
+
### The park is bounded, and the bound is yours
|
|
142
|
+
|
|
143
|
+
An approval left over a weekend used to fail the whole flow: the park inherited
|
|
144
|
+
`@voltro/workflow`'s 24-hour default, which no flow author chose or could change.
|
|
145
|
+
It resolves most-specific-first now — **step → flow → plugin option → env →
|
|
146
|
+
7 days** — and `0` at any level means *wait forever* (the park is slot-free, so
|
|
147
|
+
an unbounded wait costs no worker):
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { defineFlow, flowStep } from '@voltro/plugin-ai-flows'
|
|
151
|
+
|
|
152
|
+
defineFlow({
|
|
153
|
+
name: 'contract-review',
|
|
154
|
+
// Flow-wide default for every human step that sets none.
|
|
155
|
+
humanTimeoutMs: 30 * 24 * 60 * 60 * 1000,
|
|
156
|
+
steps: [
|
|
157
|
+
flowStep.human({ reviewMode: 'approve', prompt: 'Legal sign-off?' }),
|
|
158
|
+
// This one is the CFO's, and it waits as long as it takes.
|
|
159
|
+
flowStep.human({ reviewMode: 'approve', prompt: 'Budget sign-off?', timeoutMs: 0 }),
|
|
160
|
+
],
|
|
161
|
+
})
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
A flow authored as DATA carries the same setting in `_voltro_ai_flows.humanTimeoutMs`.
|
|
165
|
+
When the bound does expire, the run row is written `status: 'failed'` naming the
|
|
166
|
+
bound that elapsed — it no longer dies with the row still reading `waiting`.
|
|
167
|
+
|
|
168
|
+
## Conditional steps and fan-out
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
defineFlow({
|
|
172
|
+
name: 'campaign',
|
|
173
|
+
brief: [{ key: 'mode', label: 'Mode', type: 'text' }],
|
|
174
|
+
steps: [
|
|
175
|
+
flowStep.text({ prompt: 'Draft the copy', outputKey: 'copy' }),
|
|
176
|
+
|
|
177
|
+
// Runs only when the brief asked for the full treatment.
|
|
178
|
+
flowStep.text({
|
|
179
|
+
prompt: 'Write a long-form variant of {{copy}}',
|
|
180
|
+
outputKey: 'longform',
|
|
181
|
+
when: { ref: 'mode', op: 'eq', value: 'full' },
|
|
182
|
+
}),
|
|
183
|
+
|
|
184
|
+
// These two run CONCURRENTLY — same group, consecutive.
|
|
185
|
+
flowStep.media('image', { prompt: 'Hero image for {{copy}}', outputKey: 'hero', group: 'assets' }),
|
|
186
|
+
flowStep.media('image', { prompt: 'Square crop for {{copy}}', outputKey: 'square', group: 'assets' }),
|
|
187
|
+
|
|
188
|
+
// Both group outputs are available again after the group completes.
|
|
189
|
+
flowStep.note({ prompt: 'Ship {{hero}} and {{square}}', outputKey: 'summary' }),
|
|
190
|
+
],
|
|
191
|
+
})
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### `when:` — run a step only sometimes
|
|
195
|
+
|
|
196
|
+
A step with a `when:` runs only if the condition holds against the run context
|
|
197
|
+
(brief fields ⊕ earlier `outputKey`s). A step whose condition is false is
|
|
198
|
+
**skipped**, not failed: it produces no output, so anything referencing it sees
|
|
199
|
+
an absent value — which is what makes `when` compose with the dependency guard
|
|
200
|
+
instead of fighting it. The run timeline shows the step as `skipped` with the
|
|
201
|
+
rendered reason (`{{mode}} equals "full"`), so a step that vanished is never
|
|
202
|
+
indistinguishable from a step nobody declared.
|
|
203
|
+
|
|
204
|
+
| `op` | True when |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `truthy` / `falsy` | the value is present and not `false` / an empty array |
|
|
207
|
+
| `eq` / `neq` | the value equals the literal (structural for objects and arrays) |
|
|
208
|
+
| `contains` | the array contains the value, or the string contains the substring |
|
|
209
|
+
|
|
210
|
+
Two deliberate choices, because both look like bugs until you know why:
|
|
211
|
+
|
|
212
|
+
- **A condition is structured data, not an expression string.** A flow can be
|
|
213
|
+
authored as a stored row a user edits in a browser, and an expression there
|
|
214
|
+
would be an evaluator running user-authored source on your server. It also
|
|
215
|
+
lets the boot-time validator refuse a condition on a key nothing produces —
|
|
216
|
+
the alternative silently skips its step on every run, forever.
|
|
217
|
+
- **`0` and `''` are TRUTHY here.** A step gated on a generated count or string
|
|
218
|
+
means "did the producer run", not "is it non-zero". The second is `{ op:
|
|
219
|
+
'neq', value: 0 }`, which you can say when you mean it.
|
|
220
|
+
|
|
221
|
+
A compound condition needs two steps, or a `structured` step that computes the
|
|
222
|
+
boolean. That is a real limit of a parser-free design.
|
|
223
|
+
|
|
224
|
+
### `group:` — run steps concurrently
|
|
225
|
+
|
|
226
|
+
**Consecutive** steps sharing a `group` name run at the same time. Each keeps its
|
|
227
|
+
own durable step, so a replay resolves every branch from the journal exactly as
|
|
228
|
+
it would sequentially — the concurrency is in the execution, not in the
|
|
229
|
+
durability.
|
|
230
|
+
|
|
231
|
+
Three rules, all enforced at registration rather than at run time:
|
|
232
|
+
|
|
233
|
+
- Steps in one group **cannot read each other's outputs** — they have no order
|
|
234
|
+
between them. Their outputs become available to everything *after* the group.
|
|
235
|
+
- A group must be **contiguous**. A group name that stops and resumes would
|
|
236
|
+
execute as two sequential fan-outs, which is the opposite of what it reads as.
|
|
237
|
+
- A **`human` review cannot join a group** — it suspends the whole run, which
|
|
238
|
+
one branch of a fan-out cannot do.
|
|
239
|
+
|
|
240
|
+
One failing branch fails the run; the siblings that succeeded keep their
|
|
241
|
+
journaled results, so a retry does not re-pay for them.
|
|
242
|
+
|
|
79
243
|
## Chaining & cadence
|
|
80
244
|
|
|
81
245
|
- **`chainTo`** launches a follow-up flow on success (result ⊕ input → the child's
|
|
82
246
|
brief); `requireConfirmation` stages it for the user to confirm.
|
|
247
|
+
- **A chain is bounded.** `chainTo` used to carry one guard — a flow could not
|
|
248
|
+
chain to itself — so `A → B → A`, or a chain that simply ran deep, was
|
|
249
|
+
unbounded: every hop starts a child run with a fresh idempotency key, and
|
|
250
|
+
nothing was counting. A run now carries the chain that led to it, and a chain
|
|
251
|
+
is refused when the target is already in that path (a cycle) or when the depth
|
|
252
|
+
reaches `maxChainDepth` (default 5; `aiFlowsPlugin({ maxChainDepth })` or
|
|
253
|
+
`VOLTRO_AI_FLOW_MAX_CHAIN_DEPTH`). The refusal lands on the run row's
|
|
254
|
+
`chainRefusal` naming the path — a chain that silently does not fire is
|
|
255
|
+
indistinguishable from one nobody declared. The parent run still **succeeds**:
|
|
256
|
+
a refused follow-up is a configuration problem, not a reason to destroy a
|
|
257
|
+
completed result.
|
|
83
258
|
- **`cadence`** schedules runs — `weekly` (with `intervalWeeks` + `anchorDate`) or
|
|
84
259
|
`monthly` (`weekOfMonth`, incl. `'last'`). `cadenceMatches` / `nextRuns` are
|
|
85
260
|
exported so the editor's "next 3 runs" preview and the scheduler agree exactly.
|
|
@@ -90,6 +265,58 @@ resumes. Thousands of runs can wait on review for days at no runtime cost.
|
|
|
90
265
|
`costMicroUsd`, …) as it runs, and the client's subscription streams the timeline
|
|
91
266
|
over CDC — no polling, no sockets.
|
|
92
267
|
|
|
268
|
+
## Driving a flow from the UI
|
|
269
|
+
|
|
270
|
+
`@voltro/plugin-ai-flows/web` is the browser-safe half: launch → observe →
|
|
271
|
+
respond, without hand-rolling a subscription.
|
|
272
|
+
|
|
273
|
+
```tsx
|
|
274
|
+
import { useFlowReview, useLaunchFlow } from '@voltro/plugin-ai-flows/web'
|
|
275
|
+
|
|
276
|
+
export function ArticleFlow({ runId }: { runId?: string }) {
|
|
277
|
+
const { launch, pending } = useLaunchFlow()
|
|
278
|
+
const { steps, status, awaitingReview, prompt, approve, reject, done } = useFlowReview(runId)
|
|
279
|
+
|
|
280
|
+
if (runId === undefined) {
|
|
281
|
+
return (
|
|
282
|
+
<button disabled={pending} onClick={() => launch({ flowRef: 'blog-article', input: { topic: 'Effect' } })}>
|
|
283
|
+
Start
|
|
284
|
+
</button>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
return (
|
|
288
|
+
<div>
|
|
289
|
+
<ol>{steps.map((s) => <li key={s.id}>{s.title ?? s.type} — {s.status}</li>)}</ol>
|
|
290
|
+
{awaitingReview && (
|
|
291
|
+
<div>
|
|
292
|
+
<p>{prompt}</p>
|
|
293
|
+
<button onClick={() => approve(runId)}>Approve</button>
|
|
294
|
+
<button onClick={() => reject(runId)}>Reject</button>
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
{done && <p>Finished: {status}</p>}
|
|
298
|
+
</div>
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The full set: `useLaunchFlow`, `useFlowRun` (the reactive run row projected into
|
|
304
|
+
a timeline — steps, status, the pending review, `done`), `useFlowRuns`,
|
|
305
|
+
`useFlows`, `useRetryFlow`, `useCancelFlow`, `useRespondToFlow`
|
|
306
|
+
(`approve` / `reject` / `choose` / `submitText`) and `useFlowReview`, which is
|
|
307
|
+
the whole review widget in one call.
|
|
308
|
+
|
|
309
|
+
**Every hook takes the tag set.** This plugin ships no fixed RPC routes — its
|
|
310
|
+
procedures are helpers you wire into your own thin rpc files (below) — so the
|
|
311
|
+
hooks default to `aiFlows.launch` / `.run` / `.respond` / … and accept an
|
|
312
|
+
override plus an `apiName`:
|
|
313
|
+
|
|
314
|
+
```tsx
|
|
315
|
+
import { useFlowRun } from '@voltro/plugin-ai-flows/web'
|
|
316
|
+
|
|
317
|
+
const run = useFlowRun(runId, { apiName: 'admin', tags: { run: 'flows.oneRun' } })
|
|
318
|
+
```
|
|
319
|
+
|
|
93
320
|
## Wiring (once per app)
|
|
94
321
|
|
|
95
322
|
The engine runs as one durable workflow; the operations are server helpers (a
|
|
@@ -138,8 +365,26 @@ Adopt `@voltro/plugin-versioning` on `_voltro_ai_flows` for automatic edit histo
|
|
|
138
365
|
planner), so a mid-run crash re-plans (`retry` = fresh run).
|
|
139
366
|
- **Cadence + scale-to-zero**: `dormancy: 'sleep'` won't fire an in-process tick —
|
|
140
367
|
use an external waker or `trigger: 'external'`.
|
|
368
|
+
- **Run retention**: `_voltro_ai_flow_runs` is bounded by the framework sweep at
|
|
369
|
+
**90 days** (`runsTtlMs` / `VOLTRO_AI_FLOW_RUNS_TTL_HOURS`). Only **terminal**
|
|
370
|
+
runs are swept (`succeeded | failed | cancelled`) — a run parked on a human
|
|
371
|
+
review is live state, not history, and a plain time-TTL would delete pending
|
|
372
|
+
approvals. `_voltro_ai_flows` (the DEFINITIONS) is deliberately unbounded: its
|
|
373
|
+
size tracks how many flows a team writes, not traffic.
|
|
374
|
+
|
|
375
|
+
**Upgrading a live app:** the first sweep runs ~30 s after boot and deletes
|
|
376
|
+
every terminal run older than the TTL, in batches, until the backlog drains. An
|
|
377
|
+
app that has been running flows for more than 90 days loses that history at
|
|
378
|
+
once — set the env var (or `runsTtlMs`) BEFORE deploying if you need it. An
|
|
379
|
+
app's own `registerRetention` for the table also outranks the plugin's.
|
|
141
380
|
- **Media retention**: not automatic — add a `*.cron.tsx` deleting old artifacts
|
|
142
|
-
via `storage.delete(id)` (a plain retention sweep would orphan the blobs).
|
|
381
|
+
via `storage.delete(id)` (a plain retention sweep would orphan the blobs). Note
|
|
382
|
+
this interacts with the run TTL: a run's steps carry hosted URLs whose blobs
|
|
383
|
+
belong to the storage plugin, so deleting the row orphans them. Keep the run
|
|
384
|
+
TTL at or above your media-purge window, or purge by run id first.
|
|
385
|
+
- **Inspect page size**: `/flows` and `/runs` take a `?take=`, clamped by
|
|
386
|
+
`inspectPageMax` (default 200). It matters here more than usual — a run row
|
|
387
|
+
carries every step's full text output.
|
|
143
388
|
- **Media tenant on resume**: `generateMedia` receives `run.tenantId`, read from
|
|
144
389
|
the durable run row. Pin persistence to it (as above) rather than the caller
|
|
145
390
|
subject — a resumed run executes under a tenant-less system subject, so reading
|
|
@@ -53,7 +53,7 @@ plugin's `_voltro_events` table is auto-merged into your schema, so
|
|
|
53
53
|
| Option | Type | Default | Notes |
|
|
54
54
|
|---|---|---|---|
|
|
55
55
|
| `sqlClient` | `SqlClient.SqlClient` | the main DataStore's client | Override to land events in a SEPARATE database (e.g. a dedicated analytics Postgres while the app runs MySQL). You must create the events table there yourself. |
|
|
56
|
-
| `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `_voltro_mirror_<table>` (`{ id, data }`) so analytical queries can JOIN events against live user data. |
|
|
56
|
+
| `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `_voltro_mirror_<table>` (`{ id, data, version, is_deleted }`) so analytical queries can JOIN events against live user data. Deletes write a tombstone — filter `is_deleted = false`. |
|
|
57
57
|
| `mirrorPrimaryKey` | `string` | `'id'` | Primary-key column on the mirrored source rows. |
|
|
58
58
|
|
|
59
59
|
```ts
|
|
@@ -73,7 +73,7 @@ auditPlugin({
|
|
|
73
73
|
// custom function: (event: AuditEvent) => void | Promise<void> | Effect.Effect<void>
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
`'datastore'` is the production sink: it survives restarts, is shared across replicas, and is queryable via `ctx.store.select('_voltro_audit_log')`. Each row carries the flattened `tag` / `at` / `subjectId` / `tenantId` / `traceId` / `status` / `durationMs` (indexed by `tag` + `traceId`) plus the full `subject` / `input` / `outcome` as portable `json()` columns.
|
|
76
|
+
`'datastore'` is the production sink: it survives restarts, is shared across replicas, and is queryable via `ctx.store.select('_voltro_audit_log')`. Each row carries the flattened `tag` / `at` / `subjectId` / `tenantId` / `traceId` / `status` / `durationMs` (indexed by `tag` + `traceId`) plus the full `subject` / `input` / `outcome` as portable `json()` columns, plus the `chainId` / `seq` / `prevHash` / `hash` tamper-evidence columns (see [the hash chain](#tamper-evidence--the-hash-chain)).
|
|
77
77
|
|
|
78
78
|
The custom function is the escape hatch for persisting events anywhere the built-in table's schema doesn't fit — e.g. an `Effect.Effect<void>` sink that writes rows into your own audit table on top of `@effect/sql`. All return shapes are normalised by the interceptor. A sink that throws / rejects / dies is caught and swallowed, so a broken sink can never mask the mutation's real outcome.
|
|
79
79
|
|
|
@@ -176,6 +176,42 @@ const denied = await auditBySubject(ctx.store, actorId, { status: 'error', limit
|
|
|
176
176
|
|
|
177
177
|
`auditBySubject` takes `status` as a real argument rather than leaving you to filter in JS: the index is `(subjectId, status, at)`, so a filter applied after fetching would not use it.
|
|
178
178
|
|
|
179
|
+
### Tamper-evidence — the hash chain
|
|
180
|
+
|
|
181
|
+
Append-only is a convention, not a guarantee. An actor with `UPDATE` on the database could rewrite what a call did, or `DELETE` the row that recorded a refusal, and no read of the table would notice — an audit trail whose integrity rests on "nobody has database access" is exactly as trustworthy as the thing it exists to check.
|
|
182
|
+
|
|
183
|
+
Every row written by `sink: 'datastore'` therefore carries its position in a hash chain: `chainId`, `seq`, `prevHash` and `hash`, where `hash` covers the row's own content **and** the previous row's hash. Altering any row invalidates every row after it; removing one leaves a hole.
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { verifyAuditChain } from '@voltro/plugin-audit'
|
|
187
|
+
|
|
188
|
+
const verdict = await verifyAuditChain(ctx.store)
|
|
189
|
+
// { ok, rowsChecked, unchainedRows, keyed, chains: [{ chainId, from, to, tip, prunedPrefix }], issues: [...] }
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`issues` names what does not add up, and the kinds are not interchangeable:
|
|
193
|
+
|
|
194
|
+
| Kind | Meaning |
|
|
195
|
+
|---|---|
|
|
196
|
+
| `tampered` | The row's content does not hash to its stored `hash`. A column was altered. |
|
|
197
|
+
| `broken-link` | The row's `prevHash` is not the previous row's `hash`. Reordering, or a row substituted for another. |
|
|
198
|
+
| `gap` | `seq` jumped. A row was deleted, or its write failed. |
|
|
199
|
+
|
|
200
|
+
A gap at the **start** of a chain is not an issue — the retention sweep prunes oldest-first, so a pruned prefix is reported as `prunedPrefix: true`. Rows written before chaining shipped carry no `hash` at all and are counted as `unchainedRows` rather than passed over silently.
|
|
201
|
+
|
|
202
|
+
#### The chain is per WRITER, and that is the concurrency answer
|
|
203
|
+
|
|
204
|
+
One global chain would need every insert to know the current tip — a serialization point across every process writing audit rows. Two replicas racing on one chain **fork**, and a fork is indistinguishable from tampering. A chain that breaks under normal operation is worse than no chain at all, because the first false positive is what teaches everyone to ignore the verifier. A per-tenant chain has the identical problem one level down.
|
|
205
|
+
|
|
206
|
+
So each process mints its own `chainId` at boot and allocates `seq`/`prevHash`/`hash` in a synchronous, `await`-free step — atomic against any number of concurrent events. What you get in exchange is stated plainly: N replicas produce N chains, so verification attests *"every chain is intact"*, not *"the log is complete"*.
|
|
207
|
+
|
|
208
|
+
#### Read the guarantee before you quote it
|
|
209
|
+
|
|
210
|
+
Unkeyed (the default), the chain detects any change that does **not** recompute it: a hand-run `UPDATE`, a botched migration, storage corruption, a script that scrubs one row. It does **not** stop an adversary who knows the scheme and rewrites the chain forward — SHA-256 is public, so with write access they can. Two things close that, both available:
|
|
211
|
+
|
|
212
|
+
- **`VOLTRO_AUDIT_CHAIN_SECRET`** — set it and the chain is HMAC-SHA256. An actor with the database but not the key cannot forge a link. There is no default value and nothing is minted for you; keep the key where the database is not.
|
|
213
|
+
- **Anchor the tips.** `verifyAuditChain` returns each chain's `tip`. Publish it on a schedule to somewhere append-only you do not control (an object-lock bucket, a log shipper, a compliance mailbox). This is also the *only* defence against tail truncation — deleting the newest N rows of a chain is undetectable from the table alone, for any hash chain.
|
|
214
|
+
|
|
179
215
|
### Retention, and the GDPR interaction
|
|
180
216
|
|
|
181
217
|
`sink: 'datastore'` registers its own retention: **365 days by default**, tunable with `VOLTRO_AUDIT_LOG_TTL_HOURS`, drained by the boot sweep. An append-only trail with no ceiling is the one that eventually takes the database down.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Social login (Google / GitHub / Apple)
|
|
2
|
+
|
|
3
|
+
> First-party Sign in with Google / GitHub / Apple — mandatory PKCE + state, JWKS-verified ID tokens, a deliberate account-linking policy, no identity vendor.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/plugins/auth-social.md -->
|
|
10
|
+
## Social login (Google / GitHub / Apple)
|
|
11
|
+
|
|
12
|
+
_First-party Sign in with Google / GitHub / Apple — mandatory PKCE + state, JWKS-verified ID tokens, a deliberate account-linking policy, no identity vendor._
|
|
13
|
+
|
|
14
|
+
`@voltro/plugin-auth-social` is "Sign in with Google" without adopting an identity vendor. The six sibling `@voltro/plugin-auth-*` adapters are enterprise-IdP **token verifiers** — they check a JWT an IdP already issued. This one runs the whole login: it builds the authorize URL, redeems the authorization code, verifies what came back, decides what the identity means for your `users` table, and issues the **same** session cookie password sign-in issues.
|
|
15
|
+
|
|
16
|
+
Three providers ship: **Google**, **GitHub** and **Apple**.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
pnpm add @voltro/plugin-auth-social
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Wiring
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// app.config.ts
|
|
28
|
+
import { authRoutesPlugin, postgresUserStore } from '@voltro/plugin-auth'
|
|
29
|
+
import { socialAuthPlugin } from '@voltro/plugin-auth-social'
|
|
30
|
+
|
|
31
|
+
const auth = {
|
|
32
|
+
defaultTenantId: 'public',
|
|
33
|
+
appBaseUrl: 'https://app.example.com',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default {
|
|
37
|
+
type: 'api' as const,
|
|
38
|
+
name: 'myApi',
|
|
39
|
+
store: 'postgres' as const,
|
|
40
|
+
plugins: [
|
|
41
|
+
authRoutesPlugin({ store: users, ...auth }),
|
|
42
|
+
socialAuthPlugin({
|
|
43
|
+
providers: { google: {}, github: {} },
|
|
44
|
+
users,
|
|
45
|
+
auth,
|
|
46
|
+
}),
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
An empty `{}` for a provider means "take the credentials from the environment". Then link to the start route:
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
<a href="/auth/social/google">Sign in with Google</a>
|
|
55
|
+
<a href="/auth/social/github">Sign in with GitHub</a>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Two routes are mounted per provider, under `/auth/social` by default:
|
|
59
|
+
|
|
60
|
+
| Route | What it does |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `GET /auth/social/<provider>` | Mints `state` + a PKCE verifier + an OIDC nonce, stores them in a short-lived `HttpOnly` cookie, redirects to the provider |
|
|
63
|
+
| `GET` or `POST /auth/social/<provider>/callback` | Verifies `state`, redeems the code, verifies the ID token, applies the link policy, issues the session cookie |
|
|
64
|
+
|
|
65
|
+
The session is issued by `issueUserSession` from `@voltro/plugin-auth` — the same function password sign-in, magic-link, MFA and passkeys use. So a social login gets the `sessions` row (device list + server-side revocation), keyed-secret rotation, sliding-window renewal and the membership-carrying Subject automatically.
|
|
66
|
+
|
|
67
|
+
## Credentials
|
|
68
|
+
|
|
69
|
+
Nothing is ever defaulted. A missing credential fails the boot; it never falls back.
|
|
70
|
+
|
|
71
|
+
| Provider | Environment |
|
|
72
|
+
|---|---|
|
|
73
|
+
| Google | `VOLTRO_GOOGLE_CLIENT_ID`, `VOLTRO_GOOGLE_CLIENT_SECRET` |
|
|
74
|
+
| GitHub | `VOLTRO_GITHUB_CLIENT_ID`, `VOLTRO_GITHUB_CLIENT_SECRET` |
|
|
75
|
+
| Apple | `VOLTRO_APPLE_CLIENT_ID`, `VOLTRO_APPLE_TEAM_ID`, `VOLTRO_APPLE_KEY_ID`, `VOLTRO_APPLE_PRIVATE_KEY` |
|
|
76
|
+
|
|
77
|
+
Register `<appBaseUrl>/auth/social/<provider>/callback` as the redirect URI with each provider, byte for byte.
|
|
78
|
+
|
|
79
|
+
## Account linking — read this before shipping
|
|
80
|
+
|
|
81
|
+
The security decision at the heart of social login is one sentence: *Google says the person in front of you owns `ada@example.com`, and your `users` table already has a row for `ada@example.com`. Do you log them into it?*
|
|
82
|
+
|
|
83
|
+
Answering "yes, the emails match" is the classic pre-authentication account-takeover vector. A provider that does not verify an address lets an attacker register the victim's email, never confirm it, click "Sign in with X" and land inside the victim's account. So there are two policies, and the default is the strict one:
|
|
84
|
+
|
|
85
|
+
| `linkPolicy` | Behaviour |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `'never'` (default) | A social identity never attaches to a pre-existing account. Unknown email ⇒ a new user. Known email ⇒ refused, with a message telling the user to sign in the way they already can and connect the provider from account settings |
|
|
88
|
+
| `'verified-email'` | Links when the provider **asserted** the address is verified and it is not an Apple private relay. A real, bounded risk, taken deliberately |
|
|
89
|
+
|
|
90
|
+
There is deliberately no policy that links on an *unverified* email.
|
|
91
|
+
|
|
92
|
+
The linking that is always sound is not a policy at all: attaching a provider to an account whose **session you already hold**. Call `linkSocialIdentity` from an authenticated route — the proof of ownership is the session, which is the only proof that is actually sound. That is the escape hatch every app on `'never'` needs.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { linkSocialIdentity, socialCompleteLogin } from '@voltro/plugin-auth-social'
|
|
96
|
+
|
|
97
|
+
// inside an authenticated route, after socialCompleteLogin returned a profile
|
|
98
|
+
yield* linkSocialIdentity(identities, subject.id, {
|
|
99
|
+
provider: profile.provider,
|
|
100
|
+
providerAccountId: profile.providerAccountId,
|
|
101
|
+
email: profile.email,
|
|
102
|
+
emailVerified: profile.emailVerified,
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## What is verified, and what is not
|
|
107
|
+
|
|
108
|
+
- **`state` is mandatory and is never caller-supplied.** It is compared in constant time *before anything leaves the process* — a forged callback never reaches a token endpoint.
|
|
109
|
+
- **PKCE (S256) rides on every provider**, including GitHub, whose OAuth app flow ignores it. The parameter is unconditional so a future provider cannot silently land on the no-PKCE path.
|
|
110
|
+
- **ID tokens are verified against the provider's JWKS** — signature (ES256/RS256 only; HMAC algorithms are rejected), `iss`, `aud`, `exp`/`iat` — through the same verifier every IdP adapter here uses, plus a `nonce` check that makes an ID token from another login fail.
|
|
111
|
+
- **GitHub has nothing signed to verify.** Identity comes from `GET /user` plus `GET /user/emails`, and only the entry that is both `primary` and `verified` is trusted. The self-declared profile email is never used.
|
|
112
|
+
|
|
113
|
+
## Apple: three things that break naive implementations
|
|
114
|
+
|
|
115
|
+
1. **The name arrives exactly once.** Apple returns it in no token — it posts a `user` form field on the *first* authorization and never again. The profile flags this as `nameIsFirstAuthorizationOnly`; persist it then or lose it.
|
|
116
|
+
2. **The client secret is a JWT you sign yourself** (ES256, from a `.p8` key, capped at six months). This plugin does not store one at all: it mints a 15-minute secret per exchange, so there is nothing to rotate and nothing to expire in production half a year later. A TTL above Apple's cap is rejected at call time.
|
|
117
|
+
3. **The email may be a per-app private relay** (`…@privaterelay.appleid.com`, flagged by `is_private_email`). It is verified but it is not the user's address, so linking on it is refused even under `'verified-email'`.
|
|
118
|
+
|
|
119
|
+
One more Apple-specific trap: requesting the `name`/`email` scopes makes Apple **POST the callback cross-site**, and a `SameSite=Lax` cookie is not sent on a cross-site POST. The plugin writes the login-state cookie `SameSite=None; Secure` for Apple, which means **Apple needs HTTPS even in development**.
|
|
120
|
+
|
|
121
|
+
## Options
|
|
122
|
+
|
|
123
|
+
| Option | Default | Notes |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| `providers` | — | Which providers to offer; `{}` takes credentials from the environment |
|
|
126
|
+
| `users` | — | The same `UserStore` `authRoutesPlugin` runs on |
|
|
127
|
+
| `auth` | — | The same `AuthConfig`: session secret, cookie flags, default tenant, subject guards |
|
|
128
|
+
| `identities` | DataStore-backed | Where identities are recorded |
|
|
129
|
+
| `linkPolicy` | `'never'` | See above |
|
|
130
|
+
| `prefix` | `/auth/social` | Route prefix |
|
|
131
|
+
| `appBaseUrl` | `auth.appBaseUrl` | Origin the default redirect URIs are built from |
|
|
132
|
+
| `successRedirect` | `auth.successRedirect` ?? `/` | Where to send the browser after a login |
|
|
133
|
+
| `failureRedirect` | — | When set, a refusal redirects with `?social_error=<code>` instead of answering JSON |
|
|
134
|
+
| `stateTtlSeconds` | `600` | How long a started login may take to come back |
|
|
135
|
+
| `stateCookieName` | `voltro:oauth` | Name of the login-state cookie |
|
|
136
|
+
|
|
137
|
+
## Schema
|
|
138
|
+
|
|
139
|
+
The plugin contributes `_voltro_oauth_identities` (one row per user per provider account, unique on `[provider, providerAccountId]`) via `extendSchema`. It rides the declarative differ — `voltro db apply` and a `voltro dev` boot both reconcile it, on every dialect. It is deliberately **not** swept by retention: these rows *are* the credential, and a TTL that deleted them would silently un-enrol users.
|
|
140
|
+
|
|
141
|
+
## Post-authentication guards
|
|
142
|
+
|
|
143
|
+
`auth.subjectGuards` run here exactly as on every other login path, so an account blocked by `@voltro/plugin-deactivation` cannot get in through the newest door. Sign-*up* is exempt — a brand-new user no guard could yet have an opinion about.
|
|
@@ -85,8 +85,10 @@ Server-only; fails closed — a forged, expired, or wrong-audience token, or one
|
|
|
85
85
|
|
|
86
86
|
Beyond the verify-only `workosStrategy`, the package exports two primitives for the **hosted-login** flow — WorkOS logs the user in and your app mints its *own* session (an alternate front door alongside password login, not a replacement session authority):
|
|
87
87
|
|
|
88
|
-
- **`
|
|
89
|
-
- **`workosAuthenticateWithCode({ clientId, apiKey, code })`** → exchanges the callback `code` for a `WorkosProfile` (`workosUserId`, `email`, `firstName`, `lastName`, `organizationId`). Server-only (carries the API key).
|
|
88
|
+
- **`workosBeginLogin({ clientId, redirectUri })`** → `{ url, state, codeVerifier }`. The AuthKit login URL always carries a minted CSRF `state` **and** a PKCE `code_challenge` (S256); stash the other two for the callback.
|
|
89
|
+
- **`workosAuthenticateWithCode({ clientId, apiKey, code, state, expectedState, codeVerifier })`** → verifies the state in constant time (throwing `WorkosStateMismatchError` on a mismatch, before any network call), then exchanges the callback `code` for a `WorkosProfile` (`workosUserId`, `email`, `firstName`, `lastName`, `organizationId`). Server-only (carries the API key).
|
|
90
|
+
|
|
91
|
+
Neither `state` nor PKCE is optional: `state` used to be a parameter you could pass, so the default flow had no CSRF token, and the exchange verified nothing. The check now lives inside the only function that can redeem a code — a generated `state` that nothing verifies is worse than none.
|
|
90
92
|
|
|
91
93
|
Both are transport-thin (raw `fetch`, no `@workos-inc/node` dependency). Wire them into a `GET /auth/workos/login` + `GET /auth/workos/callback` route pair, find-or-provision your user in the callback, then `issueSession(...)`. Full worked example: [WorkOS SSO login](/docs/authentication/external-idp#workos-sso-login).
|
|
92
94
|
|