@voltro/cli 0.4.0 → 0.6.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/bin/voltro.mjs +54 -2
  3. package/dist/apiBuild-Bk4JBt-i.js +2 -0
  4. package/dist/{apiBuild-CvtQeBMs.js → apiBuild-BxY44VGj.js} +20 -20
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-DhyBIs1O.js → commands-My_YlUJV.js} +2823 -2265
  7. package/dist/{dev-CQxbrpDz.js → dev-Bjq-nIvH.js} +2650 -2230
  8. package/dist/dev-D6b74iXI.js +2 -0
  9. package/dist/devActivity-1WtIVyHc.js +153 -0
  10. package/dist/devActivity.d.ts +46 -0
  11. package/dist/devActivity.js +2 -0
  12. package/dist/index.js +1 -1
  13. package/dist/serveCommand-C-XQ89NJ.js +1110 -0
  14. package/dist/serveEntry.js +2 -2
  15. package/package.json +22 -17
  16. package/templates/AGENTS.core.md +119 -0
  17. package/templates/AGENTS.md +119 -0
  18. package/templates/agent-docs/_manifest.json +3 -3
  19. package/templates/agent-docs/ai.md +41 -5
  20. package/templates/agent-docs/authentication.md +238 -0
  21. package/templates/agent-docs/cli.md +142 -0
  22. package/templates/agent-docs/configuration.md +38 -0
  23. package/templates/agent-docs/data.md +718 -42
  24. package/templates/agent-docs/database/advancedqueries.md +21 -0
  25. package/templates/agent-docs/database/querying.md +166 -8
  26. package/templates/agent-docs/database/schema.md +6 -2
  27. package/templates/agent-docs/deployment.md +171 -0
  28. package/templates/agent-docs/internationalization.md +2 -0
  29. package/templates/agent-docs/plugins.md +4 -4
  30. package/templates/agent-docs/reference.md +90 -2
  31. package/templates/agent-docs/templates/apibackends.md +13 -2
  32. package/templates/agent-docs/testing.md +157 -3
  33. package/templates/agent-docs/workflows.md +11 -8
  34. package/templates/apps/api-ai/package.json +7 -7
  35. package/templates/apps/api-auth/app.config.ts +20 -13
  36. package/templates/apps/api-auth/package.json +8 -8
  37. package/templates/apps/api-backend/package.json +7 -7
  38. package/templates/apps/api-backend-deactivation/package.json +7 -7
  39. package/templates/apps/api-backend-mail/package.json +8 -8
  40. package/templates/apps/api-backend-mariadb/.env.example +10 -4
  41. package/templates/apps/api-backend-mariadb/package.json +9 -9
  42. package/templates/apps/api-backend-storage/package.json +8 -8
  43. package/templates/apps/api-data-advanced/app.config.ts +21 -8
  44. package/templates/apps/api-data-advanced/package.json +8 -8
  45. package/templates/apps/api-durable/package.json +8 -8
  46. package/templates/apps/api-feature-flags/package.json +9 -9
  47. package/templates/apps/api-governance/app.config.ts +15 -2
  48. package/templates/apps/api-governance/package.json +8 -8
  49. package/templates/apps/api-kv/package.json +8 -8
  50. package/templates/apps/api-moderation/package.json +8 -8
  51. package/templates/apps/api-observability/package.json +8 -8
  52. package/templates/apps/api-ratelimit/package.json +8 -8
  53. package/templates/apps/api-rbac/package.json +8 -8
  54. package/templates/apps/api-rest/package.json +7 -7
  55. package/templates/apps/api-saas/package.json +11 -11
  56. package/templates/apps/api-search/package.json +8 -8
  57. package/templates/apps/api-versioning/package.json +8 -8
  58. package/templates/apps/api-webhooks/app.config.ts +20 -1
  59. package/templates/apps/api-webhooks/package.json +8 -8
  60. package/templates/apps/changelog/package.json +6 -6
  61. package/templates/apps/edge-functions/package.json +2 -2
  62. package/templates/apps/frontend-admin/package.json +8 -8
  63. package/templates/apps/frontend-app/package.json +8 -8
  64. package/templates/apps/frontend-blank/package.json +7 -7
  65. package/templates/apps/frontend-contact/package.json +7 -7
  66. package/templates/apps/frontend-dashboard/package.json +7 -7
  67. package/templates/apps/frontend-docs/package.json +7 -7
  68. package/templates/apps/frontend-i18n/package.json +6 -6
  69. package/templates/apps/frontend-landing/package.json +7 -7
  70. package/templates/apps/frontend-spa/package.json +7 -7
  71. package/templates/apps/frontend-ssr/package.json +7 -7
  72. package/templates/apps/frontend-ssr-api/package.json +8 -8
  73. package/templates/apps/frontend-static-blog/package.json +6 -6
  74. package/templates/baselines/bare/.env.example +10 -3
  75. package/templates/baselines/compose/.env.example +10 -2
  76. package/templates/baselines/compose-mariadb/.env.example +10 -2
  77. package/templates/baselines/helm/.env.example +10 -3
  78. package/dist/apiBuild-DQBNqNZ8.js +0 -2
  79. package/dist/dev-DYjGqPGD.js +0 -2
  80. package/dist/serveCommand-BZzUJIyo.js +0 -1077
  81. package/templates/apps/api-auth/.env +0 -17
  82. package/templates/apps/api-data-advanced/.env +0 -17
  83. package/templates/apps/api-governance/.env +0 -4
  84. package/templates/apps/api-webhooks/.env +0 -6
@@ -1126,10 +1126,21 @@ The thread tables (`agent_threads`, `agent_messages`) are auto-provided and auto
1126
1126
 
1127
1127
  ```tsx
1128
1128
  // mint a thread id, subscribe to the live feed, then send a turn
1129
+ type ThreadMessage = {
1130
+ readonly id: string
1131
+ readonly role: string
1132
+ readonly content: string
1133
+ readonly streaming: boolean
1134
+ }
1135
+
1129
1136
  const threadId = `thread_${crypto.randomUUID().replace(/-/g, '')}`
1130
- const { data: messages } = useSubscription('app', ['support.messages', { threadId }], { threadId })
1137
+ const { data: messages = [] } = useSubscription<ReadonlyArray<ThreadMessage>>(
1138
+ 'app',
1139
+ 'support.messages',
1140
+ { threadId },
1141
+ )
1131
1142
  const send = useAction('app', 'support.send')
1132
- await send.run({ threadId, order: messages?.length ?? 0, prompt: 'How do I reset my password?' })
1143
+ await send.run({ threadId, order: messages.length, prompt: 'How do I reset my password?' })
1133
1144
  ```
1134
1145
 
1135
1146
  As `support.send` writes token deltas to the streaming row, the `support.messages` subscription re-fires and the client sees each chunk — the live typewriter bubble is just a row whose `streaming` flag is `true`. No streaming RPC, no manual ws handling.
@@ -119,9 +119,9 @@ test('searchDocs returns matching rows', async () => {
119
119
 
120
120
  The returned `TestContext` carries `{ clock, email, llm, ai?, request, cache, store, withSubject, withTenant }` — read the acting subject at `ctx.request.subject` and the in-memory cache at `ctx.cache`.
121
121
 
122
- ## `invoke` — run a handler through its input Schema
122
+ ## `invoke` — run a handler through its guards, its input Schema, and its transaction
123
123
 
124
- Calling an executor directly skips the input-Schema **decode** the real dispatcher runs first, so a test can feed the handler a value the wire would reject. `invoke(descriptor, executor, rawInput, ctx)` decodes `rawInput` through `descriptor.input` (throwing a `ParseError` on invalid input the same validation that would 400 in production) and only then calls the executor with the decoded value.
124
+ Calling an executor directly skips the hops the real dispatcher runs first: the descriptor's **guards**, the input-Schema **decode**, and for a mutation the **transaction**. So a test can feed the handler a value the wire would reject, can exercise a handler the caller was never authorized to reach, and can leave half-written state behind that production would have rolled back. `invoke(descriptor, executor, rawInput, ctx)` closes them — it decodes `rawInput` through `descriptor.input`, enforces `descriptor.guards` against `ctx.request.subject`, then calls the executor with the decoded value, wrapping a mutation in a real store transaction. It also runs the hops around those: the plugin interceptor chain, the post-commit (`afterCommit`) drain, and the deadlock replay.
125
125
 
126
126
  ```ts
127
127
  import { invoke, makeTestContext } from '@voltro/testing'
@@ -137,7 +137,161 @@ const note = await invoke(createNote, createNoteHandler, { title: 'hi' }, ctx)
137
137
  await expect(invoke(createNote, createNoteHandler, { title: 42 }, ctx)).rejects.toThrow()
138
138
  ```
139
139
 
140
- `invoke` reproduces the **input-Schema validation** hop only. RBAC / tenant / auth **guards** are rpc middleware wired at dispatch time — not carried on the descriptor — so to assert a guard, run the real handler under a scoped `ctx.request.subject` (via `makeTestContext({ subject })` / `ctx.withSubject`) and let the handler's own `requireScope(...)` execute.
140
+ ### Guards are enforced
141
+
142
+ An unauthorized caller is refused with the typed `ScopeError` — the same error a client would receive — before the handler runs:
143
+
144
+ ```ts
145
+ // the descriptor declares `guards: [scope('notes:write')]`
146
+ const outsider = makeTestContext({
147
+ subject: { type: 'user', id: 'u2', tenantId: 't1', scopes: [] },
148
+ })
149
+
150
+ await expect(invoke(createNote, createNoteHandler, { title: 'hi' }, outsider))
151
+ .rejects.toMatchObject({ _tag: 'ScopeError' })
152
+ ```
153
+
154
+ What this covers:
155
+
156
+ - **Scope guards** — checked against `ctx.request.subject`.
157
+ - **Resource-scoped guards** — resolved through whatever `setResourceScopeResolver` the test registered.
158
+ - **Relationship / policy guards** — resolved through whatever tuple source the test registered. With none registered they **DENY**, exactly as in production: an authorization question nobody can answer is a refusal.
159
+
160
+ The full order `invoke` runs, which is production's:
161
+
162
+ ```text
163
+ decode → plugin interceptors( guards → [transaction → handler] → afterCommit )
164
+ ```
165
+
166
+ The **decode is first** because in production it happens at the wire (`@effect/rpc`), before any runner is reached — so a malformed payload rejects with a parse error even for a caller no guard would have let through. Guards then run before the transaction opens, and **inside** the plugin chain, so an rbac-style plugin that publishes a subject's role scopes from its interceptor has already run when the guard is checked. A `resource` extractor therefore sees the **decoded** input, exactly as on the dispatch spine.
167
+
168
+ ### Mutations run in a real transaction
169
+
170
+ A mutation invoked through `invoke` runs inside `store.transactional(...)` — the store's own method, the same one the serve pipeline calls. So "the mutation failed, therefore nothing was written" is something you can assert here rather than deferring to an e2e test:
171
+
172
+ ```ts
173
+ // the handler inserts, then throws on the second step
174
+ await expect(invoke(createNote, failingHandler, { title: 'hi' }, ctx)).rejects.toThrow()
175
+
176
+ // nothing survived — the rollback is the store's, not a copy-aside restore
177
+ expect(await ctx.store.select('notes').all()).toHaveLength(0)
178
+ ```
179
+
180
+ The rollback is real: the in-memory data store's transactional view keeps a private overlay and discards it on a throw (buffered change events drain only on commit). Nothing in the harness copies rows aside and puts them back.
181
+
182
+ Which procedures get wrapped comes off `descriptor.kind`, which every `defineQuery` / `defineMutation` / `defineAction` / `defineStream` stamps — you never declare it, and so can't declare it wrongly:
183
+
184
+ - **`kind: 'mutation'`** → wrapped.
185
+ - **Queries and actions** → **not** wrapped. That mirrors production rather than omitting something: an action deliberately runs *outside* a transaction because it performs external I/O that cannot be rolled back.
186
+ - A hand-rolled descriptor object with no `kind` → not wrapped. There is nothing to key off, and wrapping everything would give an action the wrong semantics.
187
+
188
+ The context the handler receives is re-derived over that transaction: `txCtx.store`, its `withSubject` / `withTenant` re-scopers, and its `load` / `loadMany` batcher all read and write through the **same** transaction, so a handler can't accidentally escape it mid-mutation.
189
+
190
+ ### `runInStoreTransaction`
191
+
192
+ The same wrap is exported for tests that want it around something other than an `invoke` call:
193
+
194
+ ```ts
195
+ import { runInStoreTransaction, makeTestContext } from '@voltro/testing'
196
+
197
+ const ctx = makeTestContext({ store: mockStore({ notes: [] }) })
198
+
199
+ await expect(
200
+ runInStoreTransaction(ctx, async (txCtx) => {
201
+ await txCtx.store.insert('notes', { title: 'a' })
202
+ throw new Error('boom')
203
+ }),
204
+ ).rejects.toThrow()
205
+
206
+ expect(await ctx.store.select('notes').all()).toHaveLength(0)
207
+ ```
208
+
209
+ Nested calls are **not** supported — the in-memory store rejects a nested transaction, exactly as it does at runtime.
210
+
211
+ ### `afterCommit` and `ctx.outbox`
212
+
213
+ Post-commit work runs, after the commit and **never** after a rollback — which is the entire contract of `afterCommit`: the side effect happens if and only if the write did. `ctx.outbox.enqueue(...)` schedules its delivery nudge through exactly that hook, and the enqueue itself is atomic with your domain write, so a throw loses both. Read the nudges a call produced with `outboxNudgesOf(ctx)`:
214
+
215
+ ```ts
216
+ await invoke(createNote, async (input, c) => {
217
+ await c.store.insert('notes', { title: input.title })
218
+ await c.outbox.enqueue('note.created', { title: input.title })
219
+ return 'ok'
220
+ }, { title: 'hi' }, ctx)
221
+
222
+ expect(outboxNudgesOf(ctx)).toHaveLength(1)
223
+ ```
224
+
225
+ ### Plugin interceptors
226
+
227
+ Pass the plugins to the context and their rpc interceptors wrap every `invoke` on it — the same list you declare in `app.config.ts`, composed the same way (first listed is outermost):
228
+
229
+ ```ts
230
+ const ctx = makeTestContext({ subject, plugins: [auditPlugin, rbacPlugin] })
231
+
232
+ // the plugin's interceptMutation now wraps this call
233
+ await invoke(createNote, createNoteHandler, { title: 'hi' }, ctx)
234
+ ```
235
+
236
+ The harness takes the **plugin objects**, not a bare function, so it makes the same kind-selection production does: `interceptMutation` for a mutation, `interceptQuery` for a query, `interceptAction` for an action. A hook filed under the wrong name silently never fires here — exactly as it silently never fires in production, which is the bug worth catching.
237
+
238
+ Your interceptor receives what it receives at runtime: `{ tag, kind, input, subject, traceId, spanId? }`, with `input` already decoded. It can short-circuit (return a different `Effect` and the handler never runs), transform the result, or tap the error channel. Typed errors round-trip unwrapped — a plugin in the chain does not turn your handler's `NoteNotFound` into an opaque defect.
239
+
240
+ Two ordering properties you can assert directly, because they are the ones that bite:
241
+
242
+ ```ts
243
+ // 1. The chain wraps the GUARDS — an interceptor sees the ScopeError.
244
+ // (This is what lets an rbac plugin publish scopes before the check.)
245
+ // 2. The chain runs OUTSIDE the transaction — an interceptor that throws
246
+ // AFTER the commit does not roll the mutation back:
247
+ const plugin = definePlugin({
248
+ name: '@acme/audit',
249
+ framework: '^1.0.0',
250
+ interceptMutation: (next) =>
251
+ next.pipe(Effect.flatMap(() => Effect.fail(new Error('audit sink down')))),
252
+ })
253
+
254
+ const ctx = makeTestContext({ subject, plugins: [plugin] })
255
+ await expect(invoke(createNote, createNoteHandler, { title: 'hi' }, ctx)).rejects.toThrow()
256
+
257
+ // the write is still there — post-only side effects must not undo the mutation
258
+ expect(await ctx.store.select('notes').all()).toHaveLength(1)
259
+ ```
260
+
261
+ Only the rpc interceptors are wired. Lifecycle hooks (`onActivate`), `schema`, routes and dashboard mounts are boot concerns with no meaning for a single handler call, and are ignored. `rpcInterceptorFor(ctx, 'mutation')` returns the composed chain if you want to assert on it without invoking.
262
+
263
+ ### The deadlock replay
264
+
265
+ Production replays a mutation whose transaction lost a deadlock — under concurrency the contract is "the victim retries", not "the write fails". `invoke` does the same, using the runtime's own classifier (mysql/maria errnos, the pg/mssql serialization SQLSTATEs, walked down the `cause` chain), so what counts as deadlock-shaped has one definition:
266
+
267
+ ```ts
268
+ let attempts = 0
269
+ await invoke(createNote, async (input, c) => {
270
+ attempts++
271
+ await c.store.insert('notes', { title: input.title })
272
+ if (attempts === 1) throw Object.assign(new Error('Deadlock found'), { errno: 1213 })
273
+ return 'ok'
274
+ }, { title: 'hi' }, ctx)
275
+
276
+ expect(attempts).toBe(2)
277
+ expect(await ctx.store.select('notes').all()).toHaveLength(1) // only the surviving attempt
278
+ ```
279
+
280
+ An ordinary failure is **not** replayed — it throws on the first try, so a plain bug never runs your handler three times.
281
+
282
+ The property that makes a replay safe is that each attempt starts **clean**: the rolled-back attempt's writes are gone (the transaction), and so is its queued post-commit work. Without that reset a replayed mutation would fire the outbox nudges of writes that never landed:
283
+
284
+ ```ts
285
+ // attempt 1 enqueues then deadlocks; attempt 2 enqueues and commits
286
+ expect(outboxNudgesOf(ctx)).toHaveLength(1) // one, not two
287
+ ```
288
+
289
+ Retries are always on and carry no backoff here. Production's jitter exists to de-correlate concurrent lock victims; a unit harness has neither concurrency nor a lock manager, so a sleep would only cost wall-clock (and stall a suite on fake timers). The retry semantics — attempt budget, transient classification, per-attempt reset — are what's reproduced. There is no opt-out, and it cannot mask a real failure: a handler that fails deterministically fails identically after the last attempt.
290
+
291
+ ### What `invoke` still does NOT cover
292
+
293
+ - **Transport concerns** — connection info, rate limiting, the tenant header. Those are properties of the HTTP hop, not of the procedure; faking them here would only assert against the fake.
294
+ - **Undo capture and the metrics sample** — optional injections the serve entrypoint makes. Their absence changes nothing a handler can observe.
141
295
 
142
296
  ## Subject + tenant re-scoping
143
297
 
@@ -78,17 +78,18 @@ import { step } from '@voltro/workflow'
78
78
  import { Effect, Schema } from 'effect'
79
79
  import type { AppContext } from '@voltro/runtime'
80
80
 
81
+ const NoteRow = Schema.Struct({ id: Schema.String, body: Schema.String })
82
+
81
83
  const buildExecute = (ctx: AppContext) =>
82
84
  ({ noteId }: { noteId: string }) =>
83
85
  Effect.gen(function* () {
84
86
  const note = yield* step({
85
87
  name: 'load-note',
86
88
  input: { noteId },
87
- success: Schema.Struct({ id: Schema.String, body: Schema.String }),
88
- execute: Effect.tryPromise(() => ctx.store.query({
89
- table: 'notes',
90
- predicate: { field: 'id', op: 'eq', value: noteId },
91
- } as never).then((rows) => rows[0] as never)),
89
+ success: NoteRow,
90
+ execute: Effect.tryPromise(
91
+ () => ctx.store.select('notes').where('id', noteId).one(),
92
+ ).pipe(Effect.flatMap(Schema.decodeUnknown(NoteRow)), Effect.orDie),
92
93
  })
93
94
 
94
95
  const summary = yield* step({
@@ -102,9 +103,9 @@ const buildExecute = (ctx: AppContext) =>
102
103
  name: 'save-summary',
103
104
  input: { noteId },
104
105
  success: Schema.Void,
105
- execute: Effect.tryPromise(() =>
106
- ctx.store.update('notes', noteId, { summary } as never).then(() => undefined),
107
- ),
106
+ execute: Effect.tryPromise(
107
+ () => ctx.store.update('notes', noteId, { summary }),
108
+ ).pipe(Effect.asVoid, Effect.orDie),
108
109
  })
109
110
 
110
111
  return { summary }
@@ -115,6 +116,8 @@ export default buildExecute
115
116
 
116
117
  `step({...})` is the checkpoint boundary. A plain `yield* someEffect` composes Effect logic, but it is not automatically recorded as a workflow step. Put external I/O, database writes, and expensive work inside `step`.
117
118
 
119
+ Note what the store calls do NOT do: no `as never`. Reach for the fluent builder (`ctx.store.select(...)`) rather than hand-building a query descriptor, and decode the untyped `Row` into the step's `success` schema with `Schema.decodeUnknown` instead of asserting it. `.one()` fails with the typed `NoRowFound` when the note is missing (or when more than one matches), so there is no `rows[0]` to null-check.
120
+
118
121
  ## Starting a workflow
119
122
 
120
123
  Every discovered `*.workflow.tsx` is emitted into `rpcGroup.generated.ts` as a unary RPC. The payload and error schemas come from the workflow, but the RPC success type is always a `WorkflowRunHandle`. Calling it starts durable work and returns that handle immediately:
@@ -11,16 +11,16 @@
11
11
  "dependencies": {
12
12
  "@effect/platform": "^0.96.1",
13
13
  "@effect/rpc": "^0.75.1",
14
- "@voltro/ai": "0.4.0",
15
- "@voltro/cli": "0.4.0",
16
- "@voltro/database": "0.4.0",
17
- "@voltro/env": "0.4.0",
18
- "@voltro/protocol": "0.4.0",
19
- "@voltro/runtime": "0.4.0",
14
+ "@voltro/ai": "0.6.0",
15
+ "@voltro/cli": "0.6.0",
16
+ "@voltro/database": "0.6.0",
17
+ "@voltro/env": "0.6.0",
18
+ "@voltro/protocol": "0.6.0",
19
+ "@voltro/runtime": "0.6.0",
20
20
  "effect": "^3.21.2"
21
21
  },
22
22
  "devDependencies": {
23
- "@voltro/testing": "0.4.0",
23
+ "@voltro/testing": "0.6.0",
24
24
  "typescript": "^5.7.0",
25
25
  "vitest": "^3.0.0"
26
26
  }
@@ -18,19 +18,21 @@ import { authRoutesPlugin, memoryUserStore, voltroPasswordStrategy } from '@volt
18
18
 
19
19
  export const env = defineEnv({
20
20
  LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
21
- // The HMAC key that signs + verifies session cookies. Declared here so it
22
- // shows in `voltro env` and the boot gate fails loud if it's unset. The
23
- // shipped `.env` supplies a DEV-ONLY value; generate a real one for prod
24
- // (`openssl rand -hex 32`).
25
- VOLTRO_SESSION_SECRET: envVar.string({ access: 'secret' }),
21
+ // The HMAC key that signs + verifies session cookies.
22
+ //
23
+ // `generate` means this project gets its OWN key: `voltro dev` mints one
24
+ // into a gitignored `.env.local` on first boot. That is why no value ships
25
+ // with this template — a shipped placeholder would be a signing key
26
+ // published to everyone who downloads the template, and every session in
27
+ // every deployment built from it would be forgeable.
28
+ //
29
+ // Your DEPLOYMENT still needs its own: `voltro secret generate session`.
30
+ VOLTRO_SESSION_SECRET: envVar.secret({
31
+ generate: 'base64url',
32
+ description: 'HMAC key that signs and verifies session cookies.',
33
+ }),
26
34
  })
27
35
 
28
- // Read once at config time and pass to BOTH the routes plugin (which signs
29
- // sessions) and the strategy (which verifies them) so they can never drift.
30
- // process.env is populated by the loaded `.env`; this is the documented shape
31
- // for a config-time secret (the typed `serverEnv` snapshot isn't ready yet).
32
- const SECRET = process.env.VOLTRO_SESSION_SECRET ?? 'dev-only-unsafe-session-secret-change-me'
33
-
34
36
  // In-process user store — zero infra. `postgresUserStore({ sql })` for durable.
35
37
  const userStore = memoryUserStore()
36
38
 
@@ -42,7 +44,12 @@ export default {
42
44
  plugins: [
43
45
  authRoutesPlugin({
44
46
  store: userStore,
45
- secret: SECRET,
47
+ // No `secret:` — the plugin reads VOLTRO_SESSION_SECRET when it handles a
48
+ // request. Passing one here would mean reading process.env at config
49
+ // time, which runs BEFORE the env gate and before `voltro dev` mints the
50
+ // project's key, and that is exactly what tempts an app into a hardcoded
51
+ // fallback. The strategy below resolves the same value the same way, so
52
+ // the signing and verifying sides cannot drift.
46
53
  defaultTenantId: 'acme', // tenant new sign-ups land in
47
54
  successRedirect: '/', // where the browser-form flow redirects after sign-in
48
55
  // `Secure` cookies are HTTPS-only — so OFF in dev (http://localhost would
@@ -54,6 +61,6 @@ export default {
54
61
  auth: {
55
62
  // Runs after the built-in signed-cookie strategy; resolves the session
56
63
  // cookie this app's /auth routes mint into the request's Subject.
57
- strategies: [voltroPasswordStrategy({ secret: SECRET })],
64
+ strategies: [voltroPasswordStrategy()],
58
65
  },
59
66
  }
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.4.0",
16
- "@voltro/database": "0.4.0",
17
- "@voltro/env": "0.4.0",
18
- "@voltro/plugin-auth": "0.4.0",
19
- "@voltro/protocol": "0.4.0",
20
- "@voltro/runtime": "0.4.0",
21
- "@voltro/sql-postgres": "0.4.0",
15
+ "@voltro/cli": "0.6.0",
16
+ "@voltro/database": "0.6.0",
17
+ "@voltro/env": "0.6.0",
18
+ "@voltro/plugin-auth": "0.6.0",
19
+ "@voltro/protocol": "0.6.0",
20
+ "@voltro/runtime": "0.6.0",
21
+ "@voltro/sql-postgres": "0.6.0",
22
22
  "effect": "^3.21.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.4.0",
25
+ "@voltro/testing": "0.6.0",
26
26
  "typescript": "^5.7.0",
27
27
  "vitest": "^3.0.0"
28
28
  }
@@ -12,16 +12,16 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.4.0",
16
- "@voltro/database": "0.4.0",
17
- "@voltro/env": "0.4.0",
18
- "@voltro/plugin-multitenancy": "0.4.0",
19
- "@voltro/protocol": "0.4.0",
20
- "@voltro/runtime": "0.4.0",
15
+ "@voltro/cli": "0.6.0",
16
+ "@voltro/database": "0.6.0",
17
+ "@voltro/env": "0.6.0",
18
+ "@voltro/plugin-multitenancy": "0.6.0",
19
+ "@voltro/protocol": "0.6.0",
20
+ "@voltro/runtime": "0.6.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.4.0",
24
+ "@voltro/testing": "0.6.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -12,16 +12,16 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.4.0",
16
- "@voltro/database": "0.4.0",
17
- "@voltro/env": "0.4.0",
18
- "@voltro/plugin-deactivation": "0.4.0",
19
- "@voltro/protocol": "0.4.0",
20
- "@voltro/runtime": "0.4.0",
15
+ "@voltro/cli": "0.6.0",
16
+ "@voltro/database": "0.6.0",
17
+ "@voltro/env": "0.6.0",
18
+ "@voltro/plugin-deactivation": "0.6.0",
19
+ "@voltro/protocol": "0.6.0",
20
+ "@voltro/runtime": "0.6.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.4.0",
24
+ "@voltro/testing": "0.6.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -12,18 +12,18 @@
12
12
  "dependencies": {
13
13
  "@react-email/components": "^1.0.12",
14
14
  "@react-email/render": "^1.4.0",
15
- "@voltro/cli": "0.4.0",
16
- "@voltro/database": "0.4.0",
17
- "@voltro/env": "0.4.0",
18
- "@voltro/plugin-mail": "0.4.0",
19
- "@voltro/plugin-multitenancy": "0.4.0",
20
- "@voltro/protocol": "0.4.0",
21
- "@voltro/runtime": "0.4.0",
15
+ "@voltro/cli": "0.6.0",
16
+ "@voltro/database": "0.6.0",
17
+ "@voltro/env": "0.6.0",
18
+ "@voltro/plugin-mail": "0.6.0",
19
+ "@voltro/plugin-multitenancy": "0.6.0",
20
+ "@voltro/protocol": "0.6.0",
21
+ "@voltro/runtime": "0.6.0",
22
22
  "effect": "^3.21.2",
23
23
  "react": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.4.0",
26
+ "@voltro/testing": "0.6.0",
27
27
  "typescript": "^5.7.0",
28
28
  "vitest": "^3.0.0"
29
29
  }
@@ -23,10 +23,16 @@ AI_PROVIDER=mock
23
23
  # AI_MODEL=claude-opus-4-7
24
24
  # ANTHROPIC_API_KEY=
25
25
 
26
- # ── Auth ───────────────────────────────────────────────────────────────
27
- # HMAC key for signed sessions — REQUIRED in production (refuses to boot
28
- # without it). Generate: openssl rand -hex 32
29
- VOLTRO_SESSION_SECRET=dev-only-change-me
26
+ # Session signing secret — NO VALUE SHIPS HERE ON PURPOSE.
27
+ #
28
+ # `voltro dev` mints a unique one for this project into a gitignored
29
+ # .env.local on first boot, so local development needs nothing from you.
30
+ # A shipped placeholder would be a signing key published to everyone who
31
+ # downloads this template, making every session in your deployment forgeable.
32
+ #
33
+ # Your DEPLOYMENT needs its own — `voltro serve` refuses to start without it:
34
+ # voltro secret generate session
35
+ VOLTRO_SESSION_SECRET=
30
36
 
31
37
  # ── Cluster (K8s, >1 replica) ──────────────────────────────────────────
32
38
  # The workflow runner must advertise a routable host so a workflow can
@@ -12,18 +12,18 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.4.0",
16
- "@voltro/database": "0.4.0",
17
- "@voltro/env": "0.4.0",
18
- "@voltro/plugin-multitenancy": "0.4.0",
19
- "@voltro/plugin-storage": "0.4.0",
20
- "@voltro/protocol": "0.4.0",
21
- "@voltro/runtime": "0.4.0",
22
- "@voltro/sql-mysql": "0.4.0",
15
+ "@voltro/cli": "0.6.0",
16
+ "@voltro/database": "0.6.0",
17
+ "@voltro/env": "0.6.0",
18
+ "@voltro/plugin-multitenancy": "0.6.0",
19
+ "@voltro/plugin-storage": "0.6.0",
20
+ "@voltro/protocol": "0.6.0",
21
+ "@voltro/runtime": "0.6.0",
22
+ "@voltro/sql-mysql": "0.6.0",
23
23
  "effect": "^3.21.2"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.4.0",
26
+ "@voltro/testing": "0.6.0",
27
27
  "typescript": "^5.7.0",
28
28
  "vitest": "^3.0.0"
29
29
  }
@@ -10,17 +10,17 @@
10
10
  "test": "voltro test"
11
11
  },
12
12
  "dependencies": {
13
- "@voltro/cli": "0.4.0",
14
- "@voltro/database": "0.4.0",
15
- "@voltro/env": "0.4.0",
16
- "@voltro/plugin-multitenancy": "0.4.0",
17
- "@voltro/plugin-storage": "0.4.0",
18
- "@voltro/protocol": "0.4.0",
19
- "@voltro/runtime": "0.4.0",
13
+ "@voltro/cli": "0.6.0",
14
+ "@voltro/database": "0.6.0",
15
+ "@voltro/env": "0.6.0",
16
+ "@voltro/plugin-multitenancy": "0.6.0",
17
+ "@voltro/plugin-storage": "0.6.0",
18
+ "@voltro/protocol": "0.6.0",
19
+ "@voltro/runtime": "0.6.0",
20
20
  "effect": "^3.21.2"
21
21
  },
22
22
  "devDependencies": {
23
- "@voltro/testing": "0.4.0",
23
+ "@voltro/testing": "0.6.0",
24
24
  "typescript": "^5.7.0",
25
25
  "vitest": "^3.0.0"
26
26
  }
@@ -33,14 +33,27 @@ import { governancePlugin } from '@voltro/plugin-governance'
33
33
  // bundled). Run `voltro env` to see the manifest. See the Environment docs.
34
34
  export const env = defineEnv({
35
35
  LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
36
- // The 32-byte hex key the field-encryption cipher uses. NO `default` here on
37
- // purpose: governancePlugin resolves it through the Secrets-Resolver (which
38
- // reads `process.env`), NOT this typed-env default a default would satisfy
39
- // the boot gate while the cipher still failed to resolve. The shipped `.env`
40
- // supplies a DEV-ONLY value so `voltro dev` boots; for production generate a
41
- // real key (`openssl rand -hex 32`) and set VOLTRO_FIELD_ENCRYPTION_KEY in
42
- // the environment. Lose the key lose the ciphertext (GCM fails closed).
43
- VOLTRO_FIELD_ENCRYPTION_KEY: envVar.string({ access: 'secret' }),
36
+ // The 32-byte hex key the field-encryption cipher uses.
37
+ //
38
+ // `generate: 'hex'` gives this project its OWN key: `voltro dev` mints one
39
+ // into a gitignored `.env.local` on first boot, and no value ships with the
40
+ // template. A shipped key would be published to everyone who downloads the
41
+ // template and unlike a session secret, that also means every
42
+ // `.encrypted()` column in a deployment built from it is readable.
43
+ //
44
+ // Note it is minted into the ENVIRONMENT, not declared as a typed-env
45
+ // `default`: governancePlugin resolves the key through the Secrets-Resolver
46
+ // (which reads `process.env`), so a default would satisfy the boot gate
47
+ // while the cipher still failed to resolve.
48
+ //
49
+ // Your DEPLOYMENT needs its own (`voltro secret generate field-encryption`).
50
+ // Lose the key → lose the ciphertext (GCM fails closed, never silent
51
+ // corruption). Rotating it makes existing `.encrypted()` values unreadable.
52
+ VOLTRO_FIELD_ENCRYPTION_KEY: envVar.secret({
53
+ generate: 'hex',
54
+ minLength: 64,
55
+ description: 'AES-256 key (64 hex chars) for `.encrypted()` columns.',
56
+ }),
44
57
  })
45
58
 
46
59
  export default {
@@ -11,17 +11,17 @@
11
11
  "dependencies": {
12
12
  "@effect/platform": "^0.96.1",
13
13
  "@effect/rpc": "^0.75.1",
14
- "@voltro/cli": "0.4.0",
15
- "@voltro/database": "0.4.0",
16
- "@voltro/env": "0.4.0",
17
- "@voltro/plugin-governance": "0.4.0",
18
- "@voltro/plugin-multitenancy": "0.4.0",
19
- "@voltro/protocol": "0.4.0",
20
- "@voltro/runtime": "0.4.0",
14
+ "@voltro/cli": "0.6.0",
15
+ "@voltro/database": "0.6.0",
16
+ "@voltro/env": "0.6.0",
17
+ "@voltro/plugin-governance": "0.6.0",
18
+ "@voltro/plugin-multitenancy": "0.6.0",
19
+ "@voltro/protocol": "0.6.0",
20
+ "@voltro/runtime": "0.6.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.4.0",
24
+ "@voltro/testing": "0.6.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -11,17 +11,17 @@
11
11
  "dependencies": {
12
12
  "@effect/platform": "^0.96.1",
13
13
  "@effect/rpc": "^0.75.1",
14
- "@voltro/cli": "0.4.0",
15
- "@voltro/database": "0.4.0",
16
- "@voltro/env": "0.4.0",
17
- "@voltro/plugin-multitenancy": "0.4.0",
18
- "@voltro/protocol": "0.4.0",
19
- "@voltro/runtime": "0.4.0",
20
- "@voltro/workflow": "0.4.0",
14
+ "@voltro/cli": "0.6.0",
15
+ "@voltro/database": "0.6.0",
16
+ "@voltro/env": "0.6.0",
17
+ "@voltro/plugin-multitenancy": "0.6.0",
18
+ "@voltro/protocol": "0.6.0",
19
+ "@voltro/runtime": "0.6.0",
20
+ "@voltro/workflow": "0.6.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.4.0",
24
+ "@voltro/testing": "0.6.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -12,18 +12,18 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.4.0",
16
- "@voltro/database": "0.4.0",
17
- "@voltro/env": "0.4.0",
18
- "@voltro/plugin-flags": "0.4.0",
19
- "@voltro/plugin-multitenancy": "0.4.0",
20
- "@voltro/protocol": "0.4.0",
21
- "@voltro/runtime": "0.4.0",
22
- "@voltro/sql-postgres": "0.4.0",
15
+ "@voltro/cli": "0.6.0",
16
+ "@voltro/database": "0.6.0",
17
+ "@voltro/env": "0.6.0",
18
+ "@voltro/plugin-flags": "0.6.0",
19
+ "@voltro/plugin-multitenancy": "0.6.0",
20
+ "@voltro/protocol": "0.6.0",
21
+ "@voltro/runtime": "0.6.0",
22
+ "@voltro/sql-postgres": "0.6.0",
23
23
  "effect": "^3.21.2"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.4.0",
26
+ "@voltro/testing": "0.6.0",
27
27
  "typescript": "^5.7.0",
28
28
  "vitest": "^3.0.0"
29
29
  }