@voltro/cli 0.5.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.
- package/CHANGELOG.md +48 -1
- package/bin/voltro.mjs +54 -2
- package/dist/apiBuild-Bk4JBt-i.js +2 -0
- package/dist/{apiBuild-CZugTK46.js → apiBuild-BxY44VGj.js} +20 -20
- package/dist/bin.js +2 -2
- package/dist/{commands-CJheDVOr.js → commands-My_YlUJV.js} +2656 -2322
- package/dist/{dev-B71ICpXJ.js → dev-Bjq-nIvH.js} +2540 -2250
- package/dist/dev-D6b74iXI.js +2 -0
- package/dist/devActivity-1WtIVyHc.js +153 -0
- package/dist/devActivity.d.ts +46 -0
- package/dist/devActivity.js +2 -0
- package/dist/index.js +1 -1
- package/dist/serveCommand-C-XQ89NJ.js +1110 -0
- package/dist/serveEntry.js +2 -2
- package/package.json +22 -17
- package/templates/AGENTS.core.md +36 -1
- package/templates/AGENTS.md +36 -1
- package/templates/agent-docs/_manifest.json +3 -3
- package/templates/agent-docs/ai.md +35 -3
- package/templates/agent-docs/authentication.md +169 -0
- package/templates/agent-docs/cli.md +67 -1
- package/templates/agent-docs/configuration.md +38 -0
- package/templates/agent-docs/data.md +572 -40
- package/templates/agent-docs/database/querying.md +151 -3
- package/templates/agent-docs/database/schema.md +1 -0
- package/templates/agent-docs/deployment.md +171 -0
- package/templates/agent-docs/internationalization.md +2 -0
- package/templates/agent-docs/plugins.md +4 -4
- package/templates/agent-docs/reference.md +56 -2
- package/templates/agent-docs/templates/apibackends.md +13 -2
- package/templates/agent-docs/testing.md +157 -3
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/app.config.ts +20 -13
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/.env.example +10 -4
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/app.config.ts +21 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/app.config.ts +15 -2
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/app.config.ts +20 -1
- package/templates/apps/api-webhooks/package.json +8 -8
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/baselines/bare/.env.example +10 -3
- package/templates/baselines/compose/.env.example +10 -2
- package/templates/baselines/compose-mariadb/.env.example +10 -2
- package/templates/baselines/helm/.env.example +10 -3
- package/dist/apiBuild-D-iBYKa3.js +0 -2
- package/dist/dev-DNUetyG2.js +0 -2
- package/dist/serveCommand-C0WFA396.js +0 -1084
- package/templates/apps/api-auth/.env +0 -17
- package/templates/apps/api-data-advanced/.env +0 -17
- package/templates/apps/api-governance/.env +0 -4
- package/templates/apps/api-webhooks/.env +0 -6
|
@@ -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
|
|
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
|
-
|
|
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
|
|
|
@@ -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.
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/protocol": "0.
|
|
19
|
-
"@voltro/runtime": "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.
|
|
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.
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
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
|
|
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(
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-auth": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
21
|
-
"@voltro/sql-postgres": "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.
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "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.
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-deactivation": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "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.
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-mail": "0.
|
|
19
|
-
"@voltro/plugin-multitenancy": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "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.
|
|
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
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/plugin-storage": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
22
|
-
"@voltro/sql-mysql": "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.
|
|
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.
|
|
14
|
-
"@voltro/database": "0.
|
|
15
|
-
"@voltro/env": "0.
|
|
16
|
-
"@voltro/plugin-multitenancy": "0.
|
|
17
|
-
"@voltro/plugin-storage": "0.
|
|
18
|
-
"@voltro/protocol": "0.
|
|
19
|
-
"@voltro/runtime": "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.
|
|
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.
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
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.
|
|
15
|
-
"@voltro/database": "0.
|
|
16
|
-
"@voltro/env": "0.
|
|
17
|
-
"@voltro/plugin-governance": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "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.
|
|
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.
|
|
15
|
-
"@voltro/database": "0.
|
|
16
|
-
"@voltro/env": "0.
|
|
17
|
-
"@voltro/plugin-multitenancy": "0.
|
|
18
|
-
"@voltro/protocol": "0.
|
|
19
|
-
"@voltro/runtime": "0.
|
|
20
|
-
"@voltro/workflow": "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.
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-flags": "0.
|
|
19
|
-
"@voltro/plugin-multitenancy": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
22
|
-
"@voltro/sql-postgres": "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.
|
|
26
|
+
"@voltro/testing": "0.6.0",
|
|
27
27
|
"typescript": "^5.7.0",
|
|
28
28
|
"vitest": "^3.0.0"
|
|
29
29
|
}
|
|
@@ -14,16 +14,29 @@ import { governancePlugin } from '@voltro/plugin-governance'
|
|
|
14
14
|
|
|
15
15
|
export const env = defineEnv({
|
|
16
16
|
LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
|
|
17
|
+
// Declared so the boot gate can see it — governancePlugin reads the key
|
|
18
|
+
// through the Secrets-Resolver (`process.env`), and an undeclared key would
|
|
19
|
+
// fail at the first `.encrypted()` write instead of at boot.
|
|
20
|
+
//
|
|
21
|
+
// `generate: 'hex'` mints a project-local key into a gitignored `.env.local`
|
|
22
|
+
// on first `voltro dev`, so nothing ships with the template. Your deployment
|
|
23
|
+
// needs its own: `voltro secret generate field-encryption`.
|
|
24
|
+
VOLTRO_FIELD_ENCRYPTION_KEY: envVar.secret({
|
|
25
|
+
generate: 'hex',
|
|
26
|
+
minLength: 64,
|
|
27
|
+
description: 'AES-256 key (64 hex chars) for `.encrypted()` columns.',
|
|
28
|
+
}),
|
|
17
29
|
})
|
|
18
30
|
|
|
19
31
|
export default {
|
|
20
32
|
type: 'api' as const,
|
|
21
33
|
name: '{{capProjectName}}{{capAppName}}',
|
|
22
34
|
store: 'memory' as const,
|
|
35
|
+
env,
|
|
23
36
|
plugins: [
|
|
24
37
|
governancePlugin({
|
|
25
|
-
//
|
|
26
|
-
// LOSE THE DATA — GCM fails closed, it never silently corrupts.
|
|
38
|
+
// Key from the secret VOLTRO_FIELD_ENCRYPTION_KEY, declared above. LOSE
|
|
39
|
+
// THE KEY, LOSE THE DATA — GCM fails closed, it never silently corrupts.
|
|
27
40
|
fieldEncryption: true,
|
|
28
41
|
// GDPR export/erase walk these (table + the column holding the subject id).
|
|
29
42
|
subjectScopes: [{ table: 'profiles', subjectField: 'id' }],
|
|
@@ -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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-audit": "0.
|
|
19
|
-
"@voltro/plugin-governance": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/cli": "0.6.0",
|
|
16
|
+
"@voltro/database": "0.6.0",
|
|
17
|
+
"@voltro/env": "0.6.0",
|
|
18
|
+
"@voltro/plugin-audit": "0.6.0",
|
|
19
|
+
"@voltro/plugin-governance": "0.6.0",
|
|
20
|
+
"@voltro/protocol": "0.6.0",
|
|
21
|
+
"@voltro/runtime": "0.6.0",
|
|
22
22
|
"effect": "^3.21.2"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.
|
|
25
|
+
"@voltro/testing": "0.6.0",
|
|
26
26
|
"typescript": "^5.7.0",
|
|
27
27
|
"vitest": "^3.0.0"
|
|
28
28
|
}
|