@voltro/cli 0.5.0 → 0.7.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 (87) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/bin/voltro.mjs +63 -2
  3. package/dist/apiBuild-CSM74oEZ.js +2 -0
  4. package/dist/{apiBuild-CZugTK46.js → apiBuild-deIjJKJm.js} +2 -1
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-CJheDVOr.js → commands-BHdc71DG.js} +2671 -2960
  7. package/dist/dev-BYfrYwrT.js +2 -0
  8. package/dist/dev-Bqyq62Ak.js +6492 -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/inspectMetrics-BLUBAWLb.js +3711 -0
  14. package/dist/serveCommand-Dn6--0Bv.js +1111 -0
  15. package/dist/serveEntry.js +3 -2
  16. package/dist/start-CWZW_4Et.js +941 -0
  17. package/dist/startEntry.d.ts +5 -0
  18. package/dist/startEntry.js +3 -0
  19. package/package.json +22 -17
  20. package/templates/AGENTS.core.md +36 -1
  21. package/templates/AGENTS.md +36 -1
  22. package/templates/agent-docs/_manifest.json +3 -3
  23. package/templates/agent-docs/ai.md +35 -3
  24. package/templates/agent-docs/authentication.md +264 -0
  25. package/templates/agent-docs/cli.md +126 -3
  26. package/templates/agent-docs/configuration.md +38 -0
  27. package/templates/agent-docs/data.md +616 -40
  28. package/templates/agent-docs/database/querying.md +151 -3
  29. package/templates/agent-docs/database/schema.md +1 -0
  30. package/templates/agent-docs/deployment.md +171 -0
  31. package/templates/agent-docs/internationalization.md +2 -0
  32. package/templates/agent-docs/plugins.md +4 -4
  33. package/templates/agent-docs/reference.md +56 -2
  34. package/templates/agent-docs/templates/apibackends.md +13 -2
  35. package/templates/agent-docs/testing.md +215 -3
  36. package/templates/apps/api-ai/package.json +7 -7
  37. package/templates/apps/api-auth/app.config.ts +20 -13
  38. package/templates/apps/api-auth/package.json +8 -8
  39. package/templates/apps/api-backend/package.json +7 -7
  40. package/templates/apps/api-backend-deactivation/package.json +7 -7
  41. package/templates/apps/api-backend-mail/package.json +8 -8
  42. package/templates/apps/api-backend-mariadb/.env.example +10 -4
  43. package/templates/apps/api-backend-mariadb/package.json +9 -9
  44. package/templates/apps/api-backend-storage/package.json +8 -8
  45. package/templates/apps/api-data-advanced/app.config.ts +21 -8
  46. package/templates/apps/api-data-advanced/package.json +8 -8
  47. package/templates/apps/api-durable/package.json +8 -8
  48. package/templates/apps/api-feature-flags/package.json +9 -9
  49. package/templates/apps/api-governance/app.config.ts +15 -2
  50. package/templates/apps/api-governance/package.json +8 -8
  51. package/templates/apps/api-kv/package.json +8 -8
  52. package/templates/apps/api-moderation/package.json +8 -8
  53. package/templates/apps/api-observability/package.json +8 -8
  54. package/templates/apps/api-ratelimit/package.json +8 -8
  55. package/templates/apps/api-rbac/package.json +8 -8
  56. package/templates/apps/api-rest/package.json +7 -7
  57. package/templates/apps/api-saas/package.json +11 -11
  58. package/templates/apps/api-search/package.json +8 -8
  59. package/templates/apps/api-versioning/package.json +8 -8
  60. package/templates/apps/api-webhooks/app.config.ts +20 -1
  61. package/templates/apps/api-webhooks/package.json +8 -8
  62. package/templates/apps/changelog/package.json +6 -6
  63. package/templates/apps/edge-functions/package.json +2 -2
  64. package/templates/apps/frontend-admin/package.json +8 -8
  65. package/templates/apps/frontend-app/package.json +8 -8
  66. package/templates/apps/frontend-blank/package.json +7 -7
  67. package/templates/apps/frontend-contact/package.json +7 -7
  68. package/templates/apps/frontend-dashboard/package.json +7 -7
  69. package/templates/apps/frontend-docs/package.json +7 -7
  70. package/templates/apps/frontend-i18n/package.json +6 -6
  71. package/templates/apps/frontend-landing/package.json +7 -7
  72. package/templates/apps/frontend-spa/package.json +7 -7
  73. package/templates/apps/frontend-ssr/package.json +7 -7
  74. package/templates/apps/frontend-ssr-api/package.json +8 -8
  75. package/templates/apps/frontend-static-blog/package.json +6 -6
  76. package/templates/baselines/bare/.env.example +10 -3
  77. package/templates/baselines/compose/.env.example +10 -2
  78. package/templates/baselines/compose-mariadb/.env.example +10 -2
  79. package/templates/baselines/helm/.env.example +10 -3
  80. package/dist/apiBuild-D-iBYKa3.js +0 -2
  81. package/dist/dev-B71ICpXJ.js +0 -9896
  82. package/dist/dev-DNUetyG2.js +0 -2
  83. package/dist/serveCommand-C0WFA396.js +0 -1084
  84. package/templates/apps/api-auth/.env +0 -17
  85. package/templates/apps/api-data-advanced/.env +0 -17
  86. package/templates/apps/api-governance/.env +0 -4
  87. package/templates/apps/api-webhooks/.env +0 -6
@@ -116,12 +116,14 @@ test('searchDocs returns matching rows', async () => {
116
116
  | `ai` | — | Injected AI mock (a `mockAi({...})` value). |
117
117
  | `llmResponses` | `[]` | Queued responses for the bundled `ctx.llm` (`MockLLM`). |
118
118
  | `env` | ambient `process.env` | Env values sealed into the boot snapshot so handler code reading `getSecret('X')` / `serverEnv.X` resolves under test. Merged over `process.env` (these win). |
119
+ | `relations` | — | `relations()` specs to register for this context — the boot sweep's stand-in. See [Eager loads under test](#eager-loads-under-test). |
120
+ | `rowFilter` | the registered filter | A row filter for this context only, instead of the process-global `setRowFilter(...)`. See [Row-level security under test](#row-level-security-under-test). |
119
121
 
120
122
  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
123
 
122
- ## `invoke` — run a handler through its input Schema
124
+ ## `invoke` — run a handler through its guards, its input Schema, and its transaction
123
125
 
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.
126
+ 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
127
 
126
128
  ```ts
127
129
  import { invoke, makeTestContext } from '@voltro/testing'
@@ -137,7 +139,186 @@ const note = await invoke(createNote, createNoteHandler, { title: 'hi' }, ctx)
137
139
  await expect(invoke(createNote, createNoteHandler, { title: 42 }, ctx)).rejects.toThrow()
138
140
  ```
139
141
 
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.
142
+ ### Effect-mode handlers run too
143
+
144
+ A handler may be written `async` **or** as an `Effect` — the framework's contract is "your choice, per handler", and the dispatcher runs both. `invoke` makes the same test, so an `Effect`-returning executor is *executed*, and `invoke` resolves to its success value (typed as that value, not as the `Effect`):
145
+
146
+ ```ts
147
+ export const publishNote = (input: { id: string }, ctx: AppContext) =>
148
+ Effect.gen(function* () {
149
+ const store = yield* EffectStore
150
+ yield* store.update('notes', input.id, { published: true })
151
+ return 'published'
152
+ })
153
+
154
+ const out = await invoke(publish, publishNote, { id: 'n1' }, ctx)
155
+ expect(out).toBe('published') // the value — not an un-run Effect
156
+ ```
157
+
158
+ A failure on the typed error channel rejects with **that error**, exactly as an async handler's `throw` does — so the same assertion works for either mode:
159
+
160
+ ```ts
161
+ await expect(invoke(publish, publishNote, { id: 'gone' }, ctx))
162
+ .rejects.toMatchObject({ _tag: 'NoteNotFound' })
163
+ ```
164
+
165
+ `EffectStore` and `SubjectService` are provided over the context the handler is actually given — inside a mutation that is the *transactional* one, so an Effect handler's writes roll back with everything else. Guards, the input decode, the transaction, the deadlock replay, `afterCommit` and the plugin interceptors all wrap the Effect form identically. An app's own `layers:` and the aggregate registry are **not** provided: those are boot injections the harness has no access to.
166
+
167
+ ### Guards are enforced
168
+
169
+ An unauthorized caller is refused with the typed `ScopeError` — the same error a client would receive — before the handler runs:
170
+
171
+ ```ts
172
+ // the descriptor declares `guards: [scope('notes:write')]`
173
+ const outsider = makeTestContext({
174
+ subject: { type: 'user', id: 'u2', tenantId: 't1', scopes: [] },
175
+ })
176
+
177
+ await expect(invoke(createNote, createNoteHandler, { title: 'hi' }, outsider))
178
+ .rejects.toMatchObject({ _tag: 'ScopeError' })
179
+ ```
180
+
181
+ What this covers:
182
+
183
+ - **Scope guards** — checked against `ctx.request.subject`.
184
+ - **Resource-scoped guards** — resolved through whatever `setResourceScopeResolver` the test registered.
185
+ - **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.
186
+
187
+ The full order `invoke` runs, which is production's:
188
+
189
+ ```text
190
+ decode → plugin interceptors( guards → [transaction → handler] → afterCommit )
191
+ ```
192
+
193
+ 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.
194
+
195
+ ### Mutations run in a real transaction
196
+
197
+ 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:
198
+
199
+ ```ts
200
+ // the handler inserts, then throws on the second step
201
+ await expect(invoke(createNote, failingHandler, { title: 'hi' }, ctx)).rejects.toThrow()
202
+
203
+ // nothing survived — the rollback is the store's, not a copy-aside restore
204
+ expect(await ctx.store.select('notes').all()).toHaveLength(0)
205
+ ```
206
+
207
+ 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.
208
+
209
+ 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:
210
+
211
+ - **`kind: 'mutation'`** → wrapped.
212
+ - **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.
213
+ - 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.
214
+
215
+ 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.
216
+
217
+ ### `runInStoreTransaction`
218
+
219
+ The same wrap is exported for tests that want it around something other than an `invoke` call:
220
+
221
+ ```ts
222
+ import { runInStoreTransaction, makeTestContext } from '@voltro/testing'
223
+
224
+ const ctx = makeTestContext({ store: mockStore({ notes: [] }) })
225
+
226
+ await expect(
227
+ runInStoreTransaction(ctx, async (txCtx) => {
228
+ await txCtx.store.insert('notes', { title: 'a' })
229
+ throw new Error('boom')
230
+ }),
231
+ ).rejects.toThrow()
232
+
233
+ expect(await ctx.store.select('notes').all()).toHaveLength(0)
234
+ ```
235
+
236
+ Nested calls are **not** supported — the in-memory store rejects a nested transaction, exactly as it does at runtime.
237
+
238
+ ### `afterCommit` and `ctx.outbox`
239
+
240
+ 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)`:
241
+
242
+ ```ts
243
+ await invoke(createNote, async (input, c) => {
244
+ await c.store.insert('notes', { title: input.title })
245
+ await c.outbox.enqueue('note.created', { title: input.title })
246
+ return 'ok'
247
+ }, { title: 'hi' }, ctx)
248
+
249
+ expect(outboxNudgesOf(ctx)).toHaveLength(1)
250
+ ```
251
+
252
+ ### Plugin interceptors
253
+
254
+ 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):
255
+
256
+ ```ts
257
+ const ctx = makeTestContext({ subject, plugins: [auditPlugin, rbacPlugin] })
258
+
259
+ // the plugin's interceptMutation now wraps this call
260
+ await invoke(createNote, createNoteHandler, { title: 'hi' }, ctx)
261
+ ```
262
+
263
+ 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.
264
+
265
+ 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.
266
+
267
+ Two ordering properties you can assert directly, because they are the ones that bite:
268
+
269
+ ```ts
270
+ // 1. The chain wraps the GUARDS — an interceptor sees the ScopeError.
271
+ // (This is what lets an rbac plugin publish scopes before the check.)
272
+ // 2. The chain runs OUTSIDE the transaction — an interceptor that throws
273
+ // AFTER the commit does not roll the mutation back:
274
+ const plugin = definePlugin({
275
+ name: '@acme/audit',
276
+ framework: '^1.0.0',
277
+ interceptMutation: (next) =>
278
+ next.pipe(Effect.flatMap(() => Effect.fail(new Error('audit sink down')))),
279
+ })
280
+
281
+ const ctx = makeTestContext({ subject, plugins: [plugin] })
282
+ await expect(invoke(createNote, createNoteHandler, { title: 'hi' }, ctx)).rejects.toThrow()
283
+
284
+ // the write is still there — post-only side effects must not undo the mutation
285
+ expect(await ctx.store.select('notes').all()).toHaveLength(1)
286
+ ```
287
+
288
+ 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.
289
+
290
+ ### The deadlock replay
291
+
292
+ 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:
293
+
294
+ ```ts
295
+ let attempts = 0
296
+ await invoke(createNote, async (input, c) => {
297
+ attempts++
298
+ await c.store.insert('notes', { title: input.title })
299
+ if (attempts === 1) throw Object.assign(new Error('Deadlock found'), { errno: 1213 })
300
+ return 'ok'
301
+ }, { title: 'hi' }, ctx)
302
+
303
+ expect(attempts).toBe(2)
304
+ expect(await ctx.store.select('notes').all()).toHaveLength(1) // only the surviving attempt
305
+ ```
306
+
307
+ An ordinary failure is **not** replayed — it throws on the first try, so a plain bug never runs your handler three times.
308
+
309
+ 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:
310
+
311
+ ```ts
312
+ // attempt 1 enqueues then deadlocks; attempt 2 enqueues and commits
313
+ expect(outboxNudgesOf(ctx)).toHaveLength(1) // one, not two
314
+ ```
315
+
316
+ 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.
317
+
318
+ ### What `invoke` still does NOT cover
319
+
320
+ - **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.
321
+ - **Undo capture and the metrics sample** — optional injections the serve entrypoint makes. Their absence changes nothing a handler can observe.
141
322
 
142
323
  ## Subject + tenant re-scoping
143
324
 
@@ -171,6 +352,37 @@ const raw = await ctx.store.select('notes').unscoped().withDeleted().all()
171
352
  expect(raw[0]?.deletedAt).not.toBeNull() // …but still there, tombstoned
172
353
  ```
173
354
 
355
+ ## Eager loads under test
356
+
357
+ `relations()` is **pure** — it returns a spec, it does not register one. In production `voltro dev` discovers every `*.relations.ts` and registers what it exports; a unit test runs no boot, so importing the module registers nothing and the first `.with({ … })` fails with *"no relations registered"*. Hand the specs to the context instead:
358
+
359
+ ```ts
360
+ import { teamRelations } from '../db/teams.relations'
361
+
362
+ const ctx = makeTestContext({
363
+ relations: [teamRelations],
364
+ store: mockStore({ teams: [{ id: 't1' }], members: [{ id: 'm1', teamId: 't1' }] }),
365
+ })
366
+
367
+ const rows = await ctx.store.select('teams').with({ members: true }).all()
368
+ expect(rows[0].members).toHaveLength(1)
369
+ ```
370
+
371
+ The relations registry is **process-global**, so the option *replaces* it with exactly the specs you pass rather than adding to it. That is what keeps two `makeTestContext({ relations: [...] })` calls in one file independent — additive registration would throw `duplicate relation` on a re-registered spec and would carry the first test's relations into the second. Omitting the option leaves the registry untouched.
372
+
373
+ ## Row-level security under test
374
+
375
+ `ctx.store` applies the app's [row filter](/docs/authentication/row-level-security) for the context's subject: registered with `setRowFilter(...)`, resolved once per context, AND-merged into every read. Both read paths are covered (the fluent builders and `store.query(descriptor)`), `.unscoped()` does **not** bypass it — that opts out of tenant isolation, not of authorization — and a `system` subject bypasses it, exactly as at runtime.
376
+
377
+ ```ts
378
+ const ctx = makeTestContext({ subject: alice, store: mockStore({ tickets: seed }), rowFilter: ownTickets })
379
+
380
+ const rows = await ctx.store.select('tickets').all()
381
+ expect(rows.map((r) => r.id)).not.toContain('bobs-ticket') // the rule, asserted
382
+ ```
383
+
384
+ Pass `rowFilter:` — as above — to scope a filter to **this context only**. `setRowFilter` is process-global: registered in one test it silently constrains every later test in the same worker, and a forgotten `afterEach` surfaces as a failure in an unrelated file. Either way the resolution is the runtime's own, so the retry schedule, the system bypass and the `onLoadError` policy behave identically: a filter whose `load` fails refuses the read (with `RowFilterUnavailable`, or zero rows under `onLoadError: 'deny'`) rather than quietly returning everything.
385
+
174
386
  ## The deterministic mocks
175
387
 
176
388
  ### `ctx.clock` — `MockClock`
@@ -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.5.0",
15
- "@voltro/cli": "0.5.0",
16
- "@voltro/database": "0.5.0",
17
- "@voltro/env": "0.5.0",
18
- "@voltro/protocol": "0.5.0",
19
- "@voltro/runtime": "0.5.0",
14
+ "@voltro/ai": "0.7.0",
15
+ "@voltro/cli": "0.7.0",
16
+ "@voltro/database": "0.7.0",
17
+ "@voltro/env": "0.7.0",
18
+ "@voltro/protocol": "0.7.0",
19
+ "@voltro/runtime": "0.7.0",
20
20
  "effect": "^3.21.2"
21
21
  },
22
22
  "devDependencies": {
23
- "@voltro/testing": "0.5.0",
23
+ "@voltro/testing": "0.7.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.5.0",
16
- "@voltro/database": "0.5.0",
17
- "@voltro/env": "0.5.0",
18
- "@voltro/plugin-auth": "0.5.0",
19
- "@voltro/protocol": "0.5.0",
20
- "@voltro/runtime": "0.5.0",
21
- "@voltro/sql-postgres": "0.5.0",
15
+ "@voltro/cli": "0.7.0",
16
+ "@voltro/database": "0.7.0",
17
+ "@voltro/env": "0.7.0",
18
+ "@voltro/plugin-auth": "0.7.0",
19
+ "@voltro/protocol": "0.7.0",
20
+ "@voltro/runtime": "0.7.0",
21
+ "@voltro/sql-postgres": "0.7.0",
22
22
  "effect": "^3.21.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.5.0",
25
+ "@voltro/testing": "0.7.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.5.0",
16
- "@voltro/database": "0.5.0",
17
- "@voltro/env": "0.5.0",
18
- "@voltro/plugin-multitenancy": "0.5.0",
19
- "@voltro/protocol": "0.5.0",
20
- "@voltro/runtime": "0.5.0",
15
+ "@voltro/cli": "0.7.0",
16
+ "@voltro/database": "0.7.0",
17
+ "@voltro/env": "0.7.0",
18
+ "@voltro/plugin-multitenancy": "0.7.0",
19
+ "@voltro/protocol": "0.7.0",
20
+ "@voltro/runtime": "0.7.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.5.0",
24
+ "@voltro/testing": "0.7.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.5.0",
16
- "@voltro/database": "0.5.0",
17
- "@voltro/env": "0.5.0",
18
- "@voltro/plugin-deactivation": "0.5.0",
19
- "@voltro/protocol": "0.5.0",
20
- "@voltro/runtime": "0.5.0",
15
+ "@voltro/cli": "0.7.0",
16
+ "@voltro/database": "0.7.0",
17
+ "@voltro/env": "0.7.0",
18
+ "@voltro/plugin-deactivation": "0.7.0",
19
+ "@voltro/protocol": "0.7.0",
20
+ "@voltro/runtime": "0.7.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.5.0",
24
+ "@voltro/testing": "0.7.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.5.0",
16
- "@voltro/database": "0.5.0",
17
- "@voltro/env": "0.5.0",
18
- "@voltro/plugin-mail": "0.5.0",
19
- "@voltro/plugin-multitenancy": "0.5.0",
20
- "@voltro/protocol": "0.5.0",
21
- "@voltro/runtime": "0.5.0",
15
+ "@voltro/cli": "0.7.0",
16
+ "@voltro/database": "0.7.0",
17
+ "@voltro/env": "0.7.0",
18
+ "@voltro/plugin-mail": "0.7.0",
19
+ "@voltro/plugin-multitenancy": "0.7.0",
20
+ "@voltro/protocol": "0.7.0",
21
+ "@voltro/runtime": "0.7.0",
22
22
  "effect": "^3.21.2",
23
23
  "react": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.5.0",
26
+ "@voltro/testing": "0.7.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.5.0",
16
- "@voltro/database": "0.5.0",
17
- "@voltro/env": "0.5.0",
18
- "@voltro/plugin-multitenancy": "0.5.0",
19
- "@voltro/plugin-storage": "0.5.0",
20
- "@voltro/protocol": "0.5.0",
21
- "@voltro/runtime": "0.5.0",
22
- "@voltro/sql-mysql": "0.5.0",
15
+ "@voltro/cli": "0.7.0",
16
+ "@voltro/database": "0.7.0",
17
+ "@voltro/env": "0.7.0",
18
+ "@voltro/plugin-multitenancy": "0.7.0",
19
+ "@voltro/plugin-storage": "0.7.0",
20
+ "@voltro/protocol": "0.7.0",
21
+ "@voltro/runtime": "0.7.0",
22
+ "@voltro/sql-mysql": "0.7.0",
23
23
  "effect": "^3.21.2"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.5.0",
26
+ "@voltro/testing": "0.7.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.5.0",
14
- "@voltro/database": "0.5.0",
15
- "@voltro/env": "0.5.0",
16
- "@voltro/plugin-multitenancy": "0.5.0",
17
- "@voltro/plugin-storage": "0.5.0",
18
- "@voltro/protocol": "0.5.0",
19
- "@voltro/runtime": "0.5.0",
13
+ "@voltro/cli": "0.7.0",
14
+ "@voltro/database": "0.7.0",
15
+ "@voltro/env": "0.7.0",
16
+ "@voltro/plugin-multitenancy": "0.7.0",
17
+ "@voltro/plugin-storage": "0.7.0",
18
+ "@voltro/protocol": "0.7.0",
19
+ "@voltro/runtime": "0.7.0",
20
20
  "effect": "^3.21.2"
21
21
  },
22
22
  "devDependencies": {
23
- "@voltro/testing": "0.5.0",
23
+ "@voltro/testing": "0.7.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.5.0",
15
- "@voltro/database": "0.5.0",
16
- "@voltro/env": "0.5.0",
17
- "@voltro/plugin-governance": "0.5.0",
18
- "@voltro/plugin-multitenancy": "0.5.0",
19
- "@voltro/protocol": "0.5.0",
20
- "@voltro/runtime": "0.5.0",
14
+ "@voltro/cli": "0.7.0",
15
+ "@voltro/database": "0.7.0",
16
+ "@voltro/env": "0.7.0",
17
+ "@voltro/plugin-governance": "0.7.0",
18
+ "@voltro/plugin-multitenancy": "0.7.0",
19
+ "@voltro/protocol": "0.7.0",
20
+ "@voltro/runtime": "0.7.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.5.0",
24
+ "@voltro/testing": "0.7.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.5.0",
15
- "@voltro/database": "0.5.0",
16
- "@voltro/env": "0.5.0",
17
- "@voltro/plugin-multitenancy": "0.5.0",
18
- "@voltro/protocol": "0.5.0",
19
- "@voltro/runtime": "0.5.0",
20
- "@voltro/workflow": "0.5.0",
14
+ "@voltro/cli": "0.7.0",
15
+ "@voltro/database": "0.7.0",
16
+ "@voltro/env": "0.7.0",
17
+ "@voltro/plugin-multitenancy": "0.7.0",
18
+ "@voltro/protocol": "0.7.0",
19
+ "@voltro/runtime": "0.7.0",
20
+ "@voltro/workflow": "0.7.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.5.0",
24
+ "@voltro/testing": "0.7.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.5.0",
16
- "@voltro/database": "0.5.0",
17
- "@voltro/env": "0.5.0",
18
- "@voltro/plugin-flags": "0.5.0",
19
- "@voltro/plugin-multitenancy": "0.5.0",
20
- "@voltro/protocol": "0.5.0",
21
- "@voltro/runtime": "0.5.0",
22
- "@voltro/sql-postgres": "0.5.0",
15
+ "@voltro/cli": "0.7.0",
16
+ "@voltro/database": "0.7.0",
17
+ "@voltro/env": "0.7.0",
18
+ "@voltro/plugin-flags": "0.7.0",
19
+ "@voltro/plugin-multitenancy": "0.7.0",
20
+ "@voltro/protocol": "0.7.0",
21
+ "@voltro/runtime": "0.7.0",
22
+ "@voltro/sql-postgres": "0.7.0",
23
23
  "effect": "^3.21.2"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.5.0",
26
+ "@voltro/testing": "0.7.0",
27
27
  "typescript": "^5.7.0",
28
28
  "vitest": "^3.0.0"
29
29
  }