@voidbase-cloud/voidbase 0.3.0 → 0.4.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 CHANGED
@@ -3,6 +3,13 @@
3
3
  Entries after 0.1.0 are compiled by release-please from the Conventional Commits merged since the previous release
4
4
  (docs/releasing.md); 0.1.0 was written by hand.
5
5
 
6
+ ## [0.4.0](https://github.com/voidbase-cloud/voidbase/compare/v0.3.0...v0.4.0) (2026-09-07)
7
+
8
+
9
+ ### Features
10
+
11
+ * **deploy:** one configuration declaration with Void's validators, tiered secret / server / public ([e5ee202](https://github.com/voidbase-cloud/voidbase/commit/e5ee20261520fbfba8e90d80ff4f7aa07bf6bc78))
12
+
6
13
  ## [0.3.0](https://github.com/voidbase-cloud/voidbase/compare/v0.2.2...v0.3.0) (2026-09-07)
7
14
 
8
15
 
package/README.md CHANGED
@@ -103,11 +103,12 @@ provider variables.
103
103
 
104
104
  `voidbase token` prints a Cloudflare dashboard link that creates `VOIDBASE_DEPLOY_CF_API_KEY` with the right
105
105
  permissions pre-selected; with that variable set, `voidbase deploy` provisions D1 and R2, generates the Void project
106
- inside the package (`node_modules/voidbase/.cloud/<name>`), stores the superuser and the secrets `pb_secrets/`
107
- declares as the Worker's secrets, and uploads the Worker. Your directory stays `pb_hooks` + `pb_migrations` +
108
- `pb_secrets` + `pb_data`, like a PocketBase folder: `pb_secrets/main.pb.js` names the secrets, the git-ignored
109
- `pb_secrets/secrets.json` holds them, and CI deploys with nothing but the deploy token once `voidbase secrets push`
110
- has stored them. See [docs/deploy.md](docs/deploy.md).
106
+ inside the package (`node_modules/voidbase/.cloud/<name>`), stores the superuser and what `pb_secrets/` declares
107
+ as the Worker's secrets and vars, and uploads the Worker. Your directory stays `pb_hooks` + `pb_migrations` +
108
+ `pb_secrets` + `pb_data`, like a PocketBase folder: `pb_secrets/main.ts` declares the configuration with Void's
109
+ validators (secret, server or public), the git-ignored `pb_secrets/secrets.json` holds the local values, and CI
110
+ deploys with nothing but the deploy token once `voidbase secrets push` has stored the secrets. See
111
+ [docs/deploy.md](docs/deploy.md).
111
112
 
112
113
  ## Continuous integration
113
114
 
package/bin/voidbase.ts CHANGED
@@ -43,8 +43,9 @@ const HELP = `voidbase - PocketBase-compatible backend: a single Bun process loc
43
43
  stores the superuser as worker secrets and runs void deploy --backend cloudflare
44
44
  deploy --void deploy to the Void platform instead (void auth login first)
45
45
  token print the Cloudflare dashboard link that creates VOIDBASE_DEPLOY_CF_API_KEY
46
- secrets [list] [--dir pb_secrets] the secrets pb_secrets/main.pb.js declares: which have a value in secrets.json (git-ignored)
47
- secrets push [--name worker] and which are on the Worker; push stores the local values as the Worker's secrets
46
+ secrets [list] [--dir pb_secrets] what pb_secrets/main.ts declares (secret / server / public), which have a value in
47
+ secrets push [--name worker] secrets.json (git-ignored) or a default, which secrets the Worker has; push stores the
48
+ local secrets on the Worker (vars are set by every deploy)
48
49
  update [--dir pb_data] [--backup] prebuilt executable only: fetch the latest GitHub release for this platform, verify
49
50
  its checksum and replace the executable (--backup zips pb_data first)
50
51
  version print the version
@@ -90,23 +91,31 @@ switch (cmd) {
90
91
  const { secretsState, putWorkerSecrets, workerSecretNames, SECRETS_DIR } = await import("../src/node/secrets");
91
92
  const { deployTarget } = await import("../src/node/deploy-cf");
92
93
  const dir = resolve(flags.dir ?? process.env.VOIDBASE_SECRETS_DIR ?? SECRETS_DIR);
93
- const state = secretsState(dir);
94
- if (!state.declaration) { console.log(`${dir}/main.pb.js does not exist: no secrets declared (voidbase init writes one)`); break; }
94
+ const state = await secretsState(dir);
95
+ if (!state.definition) { console.log(`${dir}/main.ts does not exist: nothing declared (voidbase init writes one)`); break; }
96
+ const def = state.definition; const secretNames = def.of("secret");
95
97
  if (sub === "push") {
96
98
  const { api, account, name } = await deployTarget({ name: flags.name, account: flags.account, log: () => undefined });
97
- const values: Record<string, string> = {}; for (const k of state.provided) values[k] = state.values![k]!;
98
- if (!Object.keys(values).length) { console.log(`nothing to push: none of ${state.declaration.names.join(", ")} has a value in ${dir}/secrets.json`); break; }
99
+ // only the secret tier is pushed: vars are the code's, set by every deploy
100
+ const ev = await def.evaluate({ ...(state.values ?? {}) }, ["secret"]);
101
+ if (ev.invalid.length) throw new Error(`${dir}/secrets.json: ${ev.invalid.map((i) => `${i.name}: ${i.message}`).join(", ")}`);
102
+ const values: Record<string, string> = {}; for (const k of secretNames) if (state.provided.includes(k) && ev.stored[k] !== undefined) values[k] = ev.stored[k]!;
103
+ if (!Object.keys(values).length) { console.log(`nothing to push: none of ${secretNames.join(", ") || "(no secrets declared)"} has a value in ${dir}/secrets.json`); break; }
99
104
  const before = await workerSecretNames(api, account.id, name);
100
105
  const done = await putWorkerSecrets(api, account.id, name, values).catch((e: Error) => { throw new Error(`${e.message}\n (the Worker "${name}" must exist: voidbase deploy creates it and stores the secrets itself)`); });
101
106
  console.log(`pushed ${done.length} secret(s) to worker "${name}" (account ${account.name}): ${done.map((k) => `${k}${before.includes(k) ? " (replaced)" : ""}`).join(", ")}`);
102
- if (state.unprovided.length) console.log(`no local value, left as they are: ${state.unprovided.join(", ")}`);
107
+ const left = secretNames.filter((k) => !values[k]); if (left.length) console.log(`no local value, left as they are: ${left.join(", ")}`);
103
108
  break;
104
109
  }
105
110
  // list (default): a row per declared name
106
111
  let onWorker: string[] | null = null; let worker = "";
107
112
  try { const t = await deployTarget({ name: flags.name, account: flags.account, log: () => undefined }); worker = t.name; onWorker = await workerSecretNames(t.api, t.account.id, t.name); } catch { /* no token here: local view only */ }
108
- console.log(`${dir}: ${state.declaration.names.length} declared${state.values ? `, ${state.provided.length} valued in secrets.json` : ", no secrets.json"}${onWorker ? `, worker "${worker}" has ${onWorker.filter((k) => state.declaration!.names.includes(k)).length} of them` : " (set VOIDBASE_DEPLOY_CF_API_KEY to compare with the Worker)"}`);
109
- for (const k of state.declaration.names) console.log(` ${k.padEnd(32)} ${state.provided.includes(k) ? "local value" : "no local value"}${onWorker ? ` ${onWorker.includes(k) ? "on the worker" : "NOT on the worker"}` : ""}${state.declaration.descriptions[k] ? ` ${state.declaration.descriptions[k]}` : ""}`);
113
+ console.log(`${dir}: ${def.names.length} declared (${secretNames.length} secret, ${def.of("server").length} server, ${def.of("public").length} public)${state.values ? `, ${state.provided.length} valued in secrets.json` : ", no secrets.json"}${onWorker ? `, worker "${worker}" has ${onWorker.filter((k) => secretNames.includes(k)).length} of the secrets` : " (set VOIDBASE_DEPLOY_CF_API_KEY to compare with the Worker)"}`);
114
+ for (const i of state.info) {
115
+ const where = state.provided.includes(i.name) ? "local value" : i.fallback !== undefined ? `default ${i.access === "secret" ? "(set)" : JSON.stringify(i.fallback)}` : i.optional ? "optional, unset" : "no local value";
116
+ const worker = onWorker && i.access === "secret" ? ` ${onWorker.includes(i.name) ? "on the worker" : "NOT on the worker"}` : "";
117
+ console.log(` ${i.name.padEnd(30)} ${i.access.padEnd(7)} ${where.padEnd(18)}${worker}${i.description ? ` ${i.description}` : ""}`);
118
+ }
110
119
  if (state.undeclared.length) console.log(` in secrets.json but not declared (never deployed): ${state.undeclared.join(", ")}`);
111
120
  break;
112
121
  }
@@ -158,7 +167,7 @@ switch (cmd) {
158
167
  mkdirSync(`${dir}/pb_hooks`, { recursive: true }); mkdirSync(`${dir}/pb_migrations`, { recursive: true }); mkdirSync(`${dir}/pb_secrets`, { recursive: true });
159
168
  { // pb_secrets/main.pb.js declares the secrets; secrets.json holds their values and never enters git
160
169
  const { declarationScaffold } = await import("../src/node/secrets");
161
- if (!existsSync(`${dir}/pb_secrets/main.pb.js`)) writeFileSync(`${dir}/pb_secrets/main.pb.js`, declarationScaffold());
170
+ if (!existsSync(`${dir}/pb_secrets/main.ts`)) writeFileSync(`${dir}/pb_secrets/main.ts`, declarationScaffold());
162
171
  const gi = `${dir}/.gitignore`; const have = existsSync(gi) ? await Bun.file(gi).text() : "";
163
172
  const lines = ["pb_data/", "pb_secrets/secrets.json"].filter((l) => !have.split("\n").some((x) => x.trim() === l || x.trim() === l.replace(/\/$/, "")));
164
173
  if (lines.length) writeFileSync(gi, `${have}${have && !have.endsWith("\n") ? "\n" : ""}${lines.join("\n")}\n`);
package/docs/adapter.md CHANGED
@@ -23,7 +23,7 @@ export default defineConfig({ plugins: [voidPlugin(), voidbaseAdapter()] });
23
23
  .gitignore pb_data/
24
24
  pb_hooks/ routes/, middleware/, vb_hooks/, crons/ and queues/, compiled
25
25
  pb_migrations/ the project's vb_migrations/, plus one file per Drizzle migration
26
- pb_secrets/ the project's vb_secrets/: the declaration in PocketBase's shape, the values beside it
26
+ pb_secrets/ the project's vb_secrets/: the declaration re-exported, the local values beside it
27
27
  pb_public/ the client build, served at /
28
28
  pb_data/ created on first run
29
29
  void-entry.ts what the bundler builds into pb_hooks/void-app.js
@@ -48,7 +48,7 @@ The project root is Void's, with three additions, all optional and all named for
48
48
  | --- | --- |
49
49
  | `vb_hooks/` | PocketBase's hooks, one per file, registered once when the app mounts |
50
50
  | `vb_migrations/` | PocketBase JS migrations, copied into the generated `pb_migrations/` beside the ones generated from `db/migrations` |
51
- | `vb_secrets/` | `main.ts` declares the app's secrets with `defineSecrets`; `secrets.json` (git-ignored) holds their values |
51
+ | `vb_secrets/` | `main.ts` declares the app's configuration with `defineSecrets` and Void's validators, tiered secret / server / public; `secrets.json` (git-ignored) holds the local values |
52
52
 
53
53
  They sit at the project root beside Void's own `db/`, which `vb_migrations/` is the counterpart of. Everything else
54
54
  is Void's, and means what Void means by it: `routes/`, `middleware/`, `crons/` and `queues/` are the server code,
@@ -149,30 +149,39 @@ process, so a registration made then is held and replayed, or dropped with the p
149
149
  (`pb.$app` and the rest) outside a request, a cron tick or a hook still throws.
150
150
 
151
151
 
152
- ### Secrets
152
+ ### Configuration and secrets
153
153
 
154
- The secrets the app needs are named in `vb_secrets/main.ts`, where the build can read them without running anything,
155
- and valued in `vb_secrets/secrets.json`, which stays out of git (add it to `.gitignore`; the generated app's own
156
- `.gitignore` already lists its copy):
154
+ The app's configuration is declared in `vb_secrets/main.ts`, with the validators a Void `env.ts` uses, and valued in
155
+ `vb_secrets/secrets.json`, which stays out of git (add it to `.gitignore`; the generated app's own `.gitignore`
156
+ already lists its copy):
157
157
 
158
158
  ```ts
159
159
  // vb_secrets/main.ts
160
- import { defineSecrets } from "@voidbase-cloud/voidbase/adapter";
160
+ import { defineSecrets, describe, string, number, url } from "@voidbase-cloud/voidbase/secrets";
161
161
 
162
162
  export default defineSecrets({
163
- SMTP_PASSWORD: "the mail provider's SMTP password",
164
- CF_OAUTH_CLIENT_SECRET: "the OAuth app's client secret",
163
+ SMTP_PASSWORD: describe(string().secret(), "the mail provider's SMTP password"), // the Worker's secrets
164
+ MAX_INSTANCES: number().default(5), // a Worker var
165
+ PUBLIC_API_URL: url().optional().public(), // a var the browser gets too
165
166
  });
166
167
  ```
167
168
 
168
- The adapter writes them into `.voidbase/pb_secrets/` in PocketBase's shape (`main.pb.js` with `secrets({...})`,
169
- `secrets.json` copied beside it), which is what `voidbase serve` and `voidbase deploy` read: locally the values
170
- enter the process environment, on Cloudflare they become the Worker's secrets (a deploy stores what the Worker
171
- lacks, `voidbase secrets push` replaces), and a deploy refuses to go ahead while a declared secret has no value
172
- anywhere. The app reads them like any other binding: `c.env.SMTP_PASSWORD` in
173
- a route, `pb.$os.getenv("SMTP_PASSWORD")` in a hook. A value in `secrets.json` that `main.ts` does not declare
174
- fails the build, by name: it would silently never reach the Worker. Details and the `voidbase secrets` commands:
175
- `docs/deploy.md`.
169
+ One declaration serves the three places the app runs. The Worker: `voidbase deploy` stores secrets as the Worker's
170
+ secrets and the rest as its vars, and refuses to deploy while a value is invalid or missing everywhere. The build:
171
+ every `public` key is inlined into the client as `import.meta.env.KEY`, parsed and defaulted, and no other key can
172
+ reach it. The static site therefore knows exactly what it was declared to know. Locally, `bun .voidbase/main.ts`
173
+ parses `secrets.json` and the shell into the environment, defaults included. Routes and hooks read
174
+ `c.env.SMTP_PASSWORD` or `pb.$os.getenv("SMTP_PASSWORD")`, or the typed values through the same declaration:
175
+
176
+ ```ts
177
+ import config from "../../vb_secrets/main";
178
+ const { MAX_INSTANCES } = await config.read((n) => pb.$os.getenv(n)); // a number, on both runtimes
179
+ ```
180
+
181
+ The adapter writes a re-export into `.voidbase/pb_secrets/main.ts`, where `voidbase serve` and `voidbase deploy`
182
+ look for the declaration, and copies `secrets.json` beside it. A value in `secrets.json` that `main.ts` does not
183
+ declare fails the build, by name: it would silently never reach the Worker. Details, the tiers and the `voidbase
184
+ secrets` commands: `docs/deploy.md`.
176
185
 
177
186
  ## What maps to what
178
187
 
@@ -182,7 +191,7 @@ fails the build, by name: it would silently never reach the Worker. Details and
182
191
  | `routes/**/*.ts` | `.voidbase/pb_hooks/void-app.js` | `[id]` → `:id`, `[...rest]` → catch-all, `(group)/` stripped, `_file.ts` ignored, `.dev.ts` / `.prod.ts` honoured |
183
192
  | `middleware/*.ts` | `routerUse(...)`, in file order | every request, as in Void (see below) |
184
193
  | `vb_hooks/*.ts` | the hook each file names, registered once | `onBootstrap`, `onRecordCreate`, the mailer hooks |
185
- | `vb_secrets/main.ts` + `secrets.json` | `.voidbase/pb_secrets/main.pb.js` + `secrets.json` | `voidbase deploy` stores the values as the Worker's secrets; `bun .voidbase/main.ts` loads them (see below) |
194
+ | `vb_secrets/main.ts` + `secrets.json` | `.voidbase/pb_secrets/main.ts` (a re-export) + `secrets.json` | secrets to the Worker's secrets, the rest to its vars, `public` keys into the client build (see below) |
186
195
  | `crons/*.ts` | `cronAdd(<file name>, cron, handler)` | listed by `GET /api/crons`, runnable with `POST /api/crons/<name>` |
187
196
  | `queues/*.ts` | a voidbase job per message | `void/queues` and `c.env.QUEUE_<NAME>` produce; the consumer runs on the jobs queue, or inline where there is none |
188
197
  | `db/schema.ts` + `void/db` | Drizzle over voidbase's D1 | the same database PocketBase's collections live in |
package/docs/deploy.md CHANGED
@@ -71,7 +71,7 @@ What it does, in order: resolves the account through the token, creates `<name>-
71
71
  ids and, when the directory has a `main.ts` exporting `register(app)`, composes it into the Worker; stores the
72
72
  superuser as worker secrets (from `VOIDBASE_SUPERUSER_*` / `PB_SUPERUSER_*`, or a generated
73
73
  password saved in `pb_data/.superuser-credentials`; the local dev default `changeme123` never goes live) together
74
- with the secrets `pb_secrets/` declares (below), syncs
74
+ with the secrets and vars `pb_secrets/` declares (below), syncs
75
75
  the admin panel and your frontend build into that project, and runs `void deploy --backend cloudflare`,
76
76
  which builds, applies the D1 migrations and uploads the Worker with its cron trigger. It ends with the
77
77
  `https://<name>.<your-subdomain>.workers.dev` URL and a health check. Re-running is idempotent: existing resources
@@ -104,39 +104,62 @@ account. Cloudflare still requires the account to have a workers.dev subdomain b
104
104
  | `HUB` (Durable Object `VoidbaseHub`, SQLite-backed, in this Worker) | the realtime hub: every SSE connection holds one hibernatable socket to it, writes publish to it, so events arrive in tens of milliseconds instead of the D1 poll's second, and idle apps cost nothing (the object sleeps). Free plan included | `--no-hub` / `VOIDBASE_DEPLOY_HUB=0` keeps the D1 poll |
105
105
  | Smart Placement | the Worker runs next to its D1 database | always on |
106
106
 
107
- ### Secrets: `pb_secrets/`
107
+ ### Configuration and secrets: `pb_secrets/`
108
108
 
109
- The app's own secrets (an SMTP password, OAuth client secrets, `VOIDBASE_ENCRYPTION_KEY`) have a directory, in
110
- PocketBase's naming:
109
+ The app's configuration is declared once, in code, with Void's validators, and valued in each deploy's environment
110
+ (twelve-factor III): locally a git-ignored file, on Cloudflare the Worker's own secrets and vars.
111
+
112
+ ```ts
113
+ // pb_secrets/main.ts committed
114
+ import { defineSecrets, describe, string, number, url } from "@voidbase-cloud/voidbase/secrets";
115
+
116
+ export default defineSecrets({
117
+ SMTP_PASSWORD: describe(string().secret(), "the mail provider's password"),
118
+ ADMIN_EMAILS: string().default(""),
119
+ MAX_UPLOAD_MB: number().default(10),
120
+ PUBLIC_SITE_URL: url().optional().public(),
121
+ });
122
+ ```
111
123
 
112
124
  ```
113
- pb_secrets/main.pb.js secrets({ SMTP_PASSWORD: "the mail provider's password", ... }) committed
114
- pb_secrets/secrets.json { "SMTP_PASSWORD": "..." } git-ignored
125
+ pb_secrets/secrets.json { "SMTP_PASSWORD": "...", "ADMIN_EMAILS": "me@example.com" } git-ignored
115
126
  ```
116
127
 
117
- `main.pb.js` names the secrets and is read, never run (`voidbase init` writes an empty one and the `.gitignore`
118
- lines). `secrets.json` holds the values on your machine; the shell outranks it, and it outranks the `.env` files,
119
- so a dev placeholder such as `VOIDBASE_SUPERUSER_PASSWORD=changeme123` never shadows it. `voidbase serve` loads them into the environment, so
120
- `$os.getenv("SMTP_PASSWORD")` and the app's own code see the same names locally as on Cloudflare. `voidbase deploy`
121
- stores the declared values the Worker does not have yet as the Worker's secrets (encrypted, per Worker: two
122
- instances never share one) next to the superuser, and refuses to deploy while a declared secret has neither a local
123
- value nor one already on the Worker. A value the Worker already holds is left alone by a deploy: a deploy ships
124
- code, and a checkout whose `secrets.json` carries dev values (another OAuth client, the placeholder password) must
125
- not overwrite production by deploying. Replacing is explicit:
128
+ Every key has an access tier, which decides where its value lives and who can read it:
129
+
130
+ | tier | declared as | lives in | readable by |
131
+ | --- | --- | --- | --- |
132
+ | secret | `string().secret()` (or `secret(schema)`) | the Worker's encrypted secrets | hooks and routes; never listed, never in a build |
133
+ | server | a bare validator | the Worker's plain vars | hooks and routes; never in a client build |
134
+ | public | `.public()` (or `pub(schema)`) | the Worker's vars and the client build (`import.meta.env.KEY`) | everyone, the browser included |
135
+
136
+ The validators are the ones a Void project's `env.ts` uses (`string()`, `number()`, `boolean()`, `url()`,
137
+ `email()`, `oneOf()`, `json()`, each with `.optional()` and `.default()`), and any Standard Schema validator works
138
+ inside `secret()` / `server()` / `pub()`. A value is parsed through its validator wherever it is read, so a default is
139
+ filled in, a number is a number, and a bad or missing value stops the process with the key's name, never its value.
140
+ In hooks, `$os.getenv("NAME")` (the stored string); in TypeScript, `await definition.read((n) => $os.getenv(n))`
141
+ gives the typed values.
142
+
143
+ `voidbase init` writes an empty declaration and the `.gitignore` lines. `voidbase serve` parses `secrets.json` and
144
+ the shell and puts the result, defaults included, into the environment (the shell outranks the file, the file
145
+ outranks `.env`). `voidbase deploy` stores every server and public value as the Worker's vars on every deploy (a var
146
+ is the code's to set), stores the secrets the Worker does not have yet as its secrets, and refuses to deploy while a
147
+ value is invalid or a required one is missing everywhere. A secret the Worker already holds is left alone by a
148
+ deploy: a deploy ships code, and a checkout whose `secrets.json` carries dev values (another OAuth client, the
149
+ placeholder password) must not overwrite production by deploying. Replacing is explicit:
126
150
 
127
151
  ```bash
128
- voidbase secrets # each declared name: local value or not, on the Worker or not
129
- voidbase secrets push # store the local values on the Worker (replacing), without redeploying
152
+ voidbase secrets # each key: tier, local value or default, and for secrets whether the Worker has it
153
+ voidbase secrets push # store the local secrets on the Worker (replacing), without redeploying
130
154
  ```
131
155
 
132
- That is also what makes CI simple: a checkout without `secrets.json` deploys with nothing but the deploy token,
133
- because the values were pushed once from a machine that has them. The superuser follows the same rule: a checkout
134
- without credentials of its own keeps the superuser the Worker has.
135
-
136
- A value in `secrets.json` that `main.pb.js` does not declare is never deployed (the list says so). `VOIDBASE_DEPLOY_SECRETS=A,B`
137
- still stores environment variables as secrets for a deploy driven purely by the shell. Cloudflare's account-level
138
- Secrets Store is deliberately not used: one store is shared by every Worker of the account, and its bindings are
139
- read asynchronously, which `$os.getenv` is not.
156
+ That is what makes CI simple: a checkout without `secrets.json` deploys with nothing but the deploy token, because
157
+ the secrets were pushed once from a machine that has them and the plain values come from the declared defaults or the
158
+ build's environment. The superuser follows the same rule: a checkout without credentials of its own keeps the
159
+ superuser the Worker has. A value in `secrets.json` that the declaration does not name is never deployed (the list
160
+ says so). `VOIDBASE_DEPLOY_VARS=A,B` and `VOIDBASE_DEPLOY_SECRETS=X,Y` still bake or store plain environment variables
161
+ for a deploy driven purely by the shell. Cloudflare's account-level Secrets Store is deliberately not used: one store
162
+ is shared by every Worker of the account, and its bindings are read asynchronously, which `$os.getenv` is not.
140
163
 
141
164
  ### Every instance is isolated
142
165
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidbase-cloud/voidbase",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "PocketBase-compatible backend on Cloudflare Workers (D1, R2, Queues, Durable Objects) via Void, or a single Bun process",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,7 +37,8 @@
37
37
  "./cloud": "./src/cloud/rest.ts",
38
38
  "./bundle": "./src/node/bundle.ts",
39
39
  "./adapter": "./src/adapter/runtime.ts",
40
- "./adapter/plugin": "./src/adapter/index.ts"
40
+ "./adapter/plugin": "./src/adapter/index.ts",
41
+ "./secrets": "./src/env/define.ts"
41
42
  },
42
43
  "imports": {
43
44
  "#platform/env": {
@@ -198,11 +198,10 @@ const OUT = ".voidbase";
198
198
  export const hasServerCode = (m: VoidManifest) => !!(m.routes.length || m.middleware.length || m.crons.length || m.queues.length);
199
199
 
200
200
  /** Writes the whole generated app under `<root>/.voidbase`. Everything in there is build output. */
201
- /** pb_secrets/main.pb.js: what vb_secrets/main.ts declared, as the declaration `voidbase deploy` and `voidbase serve` read. */
201
+ /** pb_secrets/main.ts: the project's declaration, re-exported where `voidbase serve` and `voidbase deploy` look for it. */
202
202
  export function generateSecretsDeclaration(d: SecretsDeclaration): string {
203
- const lines = [BANNER, "//", `// The secrets the app declares in ${d.file}. Read by voidbase serve and voidbase deploy, never run; the values`, "// live in secrets.json beside this file (git-ignored) and, once deployed, as the Worker's secrets.", "secrets({"];
204
- for (const n of d.names) lines.push(` ${n}: ${JSON.stringify(d.descriptions[n] ?? "")},`);
205
- lines.push("});", "");
203
+ const target = "../" + importPath(d.file.replace(/\.(ts|js|mjs)$/, "")); // pb_secrets/ is one level below .voidbase/
204
+ const lines = [BANNER, "//", `// The app's configuration is declared in ${d.file}: ${d.names.map((n) => `${n} (${d.access[n]})`).join(", ") || "nothing yet"}.`, "// voidbase serve and voidbase deploy import it from here; the local values are in secrets.json beside this file.", `export { default } from "${target}";`, ""];
206
205
  return lines.join("\n");
207
206
  }
208
207
 
@@ -257,10 +256,11 @@ export function writeVoidbaseApp(m: VoidManifest, opts: GenerateOptions & { migr
257
256
  }
258
257
  if (existsSync(migrationsOut) && !readdirSync(migrationsOut).length) rmSync(migrationsOut, { recursive: true, force: true });
259
258
 
260
- // pb_secrets: the declaration vb_secrets/main.ts makes, in the shape `voidbase deploy` reads, and the values beside it
259
+ // pb_secrets: the declaration vb_secrets/main.ts makes, where `voidbase serve` and `voidbase deploy` look for it
260
+ // (a re-export: the validators and their types stay the project's), and the local values beside it
261
261
  drop("pb_secrets");
262
262
  if (m.secrets) {
263
- put("pb_secrets/main.pb.js", generateSecretsDeclaration(m.secrets));
263
+ put("pb_secrets/main.ts", generateSecretsDeclaration(m.secrets));
264
264
  const values = join(m.root, m.extras.secretsDir!, "secrets.json");
265
265
  if (existsSync(values)) { cpSync(values, join(m.root, OUT, "pb_secrets/secrets.json")); written.push(`${OUT}/pb_secrets/secrets.json`); }
266
266
  }
@@ -14,7 +14,8 @@ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFile
14
14
  import { join, resolve } from "node:path";
15
15
  import { bundleHookApp } from "./bundle";
16
16
  import { generateHookWrapper, hasServerCode, writeVoidbaseApp, type GenerateOptions } from "./codegen";
17
- import { scanVoidApp, type VoidManifest } from "./scan";
17
+ import { scanVoidApp, SECRETS_DIR, type VoidManifest } from "./scan";
18
+ import { loadDefinition, readSecretsValues } from "../node/secrets";
18
19
 
19
20
  export interface AdapterOptions extends GenerateOptions {
20
21
  /** where the built site goes inside the generated app; voidbase serves it at `/` */
@@ -90,6 +91,20 @@ export function voidbaseAdapter(options: AdapterOptions = {}) {
90
91
  name: "voidbase-adapter",
91
92
  // after voidPlugin, so the manifest sees whatever it generated into .void/
92
93
  enforce: "post" as const,
94
+ // the browser's share of the configuration: every `public` key of vb_secrets/main.ts, parsed from the local
95
+ // values and the shell, inlined as import.meta.env.KEY. Secrets and server keys never enter the client build.
96
+ async config(cfg: { root?: string }) {
97
+ const projectRoot = cfg.root ? resolve(cfg.root) : root;
98
+ const loaded = await report(() => loadDefinition(join(projectRoot, SECRETS_DIR)));
99
+ if (!loaded) return undefined;
100
+ const raw: Record<string, unknown> = { ...(readSecretsValues(join(projectRoot, SECRETS_DIR)) ?? {}) };
101
+ for (const k of loaded.definition.of("public")) if (process.env[k]) raw[k] = process.env[k];
102
+ const ev = await loaded.definition.evaluate(raw, ["public"]);
103
+ if (ev.invalid.length) throw new Error(`voidbase: ${SECRETS_DIR}: ${ev.invalid.map((i) => `${i.name}: ${i.message}`).join(", ")}`);
104
+ const define: Record<string, string> = {};
105
+ for (const [k, v] of Object.entries(ev.stored)) define[`import.meta.env.${k}`] = JSON.stringify(v);
106
+ return { define };
107
+ },
93
108
  configResolved(config: { root: string; build?: { outDir?: string }; environments?: Record<string, { build?: { outDir?: string } }> }) {
94
109
  root = config.root ?? root;
95
110
  const fromEnv = config.environments?.client?.build?.outDir;
@@ -5,7 +5,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
5
5
  import { basename, extname, join, relative, resolve } from "node:path";
6
6
  import ts from "typescript";
7
7
  import { EVENT_HOOKS } from "../../hooks-plugin";
8
- import { parseSecretsDeclaration, readSecretsValues, VALUES_FILE, type SecretsDeclaration } from "../node/secrets";
8
+ import { DECLARATION_FILES, parseSecretsDeclaration, readSecretsValues, VALUES_FILE, type SecretsDeclaration } from "../node/secrets";
9
9
 
10
10
  const CODE = new Set([".ts", ".tsx", ".mts", ".js", ".jsx", ".mjs"]);
11
11
  export const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "ALL"] as const;
@@ -36,7 +36,7 @@ export interface VoidMigration { file: string; name: string }
36
36
  export interface VoidbaseExtras {
37
37
  /** vb_migrations/: PocketBase JS migrations, copied in beside the ones generated from db/migrations */
38
38
  migrationsDir?: string;
39
- /** vb_secrets/: main.ts declares the secrets (defineSecrets), secrets.json (git-ignored) holds their values */
39
+ /** vb_secrets/: main.ts declares the configuration (defineSecrets), secrets.json (git-ignored) holds the local values */
40
40
  secretsDir?: string;
41
41
  }
42
42
  /** The two directories this adapter adds to a Void app, both named for the voidbase thing they are, both sitting
@@ -71,7 +71,7 @@ export interface VoidManifest {
71
71
  crons: VoidModule[];
72
72
  queues: VoidQueue[];
73
73
  migrations: VoidMigration[];
74
- /** vb_secrets/main.ts: the secrets the app declares (their values are never part of the manifest) */
74
+ /** vb_secrets/main.ts: the configuration the app declares, names and tiers only (values are never part of the manifest) */
75
75
  secrets: SecretsDeclaration | null;
76
76
  /** the app's own voidbase side (vb_migrations/, vb_secrets/) */
77
77
  extras: VoidbaseExtras;
@@ -237,16 +237,17 @@ export function scanVoidApp(opts: ScanOptions = {}): VoidManifest {
237
237
  secretsDir: isDir(join(root, SECRETS_DIR)) ? SECRETS_DIR : undefined,
238
238
  };
239
239
 
240
- // vb_secrets/: main.ts names the secrets (`export default defineSecrets({ NAME: "what it is" })`), read without
241
- // running it; secrets.json beside it is the git-ignored values file. Values that nothing declares are a build
242
- // error: they would silently never reach the Worker.
240
+ // vb_secrets/: main.ts declares the configuration (`export default defineSecrets({ NAME: string().secret(), ... })`),
241
+ // read here without running it (names and tiers; the validators run where values are parsed); secrets.json
242
+ // beside it is the git-ignored local values file. Values that nothing declares are a build error: they would
243
+ // silently never reach the Worker.
243
244
  let secrets: SecretsDeclaration | null = null;
244
245
  if (extras.secretsDir) {
245
- const decl = ["main.ts", "main.js"].map((f) => join(root, SECRETS_DIR, f)).find((f) => existsSync(f));
246
+ const decl = DECLARATION_FILES.map((f) => join(root, SECRETS_DIR, f)).find((f) => existsSync(f));
246
247
  const values = readSecretsValues(join(root, SECRETS_DIR));
247
- if (!decl && values && Object.keys(values).length) throw new Error(`voidbase: ${SECRETS_DIR}/${VALUES_FILE} holds ${Object.keys(values).join(", ")} but ${SECRETS_DIR}/main.ts does not exist to declare them:\n export default defineSecrets({ ${Object.keys(values).map((k) => `${k}: ""`).join(", ")} })`);
248
+ if (!decl && values && Object.keys(values).length) throw new Error(`voidbase: ${SECRETS_DIR}/${VALUES_FILE} holds ${Object.keys(values).join(", ")} but ${SECRETS_DIR}/main.ts does not exist to declare them:\n export default defineSecrets({ ${Object.keys(values).map((k) => `${k}: string().secret()`).join(", ")} })`);
248
249
  if (decl) {
249
- secrets = parseSecretsDeclaration(readFileSync(decl, "utf8"), relative(root, decl), "defineSecrets");
250
+ secrets = parseSecretsDeclaration(readFileSync(decl, "utf8"), relative(root, decl));
250
251
  const undeclared = Object.keys(values ?? {}).filter((k) => !secrets!.names.includes(k));
251
252
  if (undeclared.length) throw new Error(`voidbase: ${SECRETS_DIR}/${VALUES_FILE} holds ${undeclared.join(", ")}, which ${relative(root, decl)} does not declare. Add them to defineSecrets({...}) or remove them: an undeclared value never reaches the Worker.`);
252
253
  }
@@ -0,0 +1,195 @@
1
+ // The app's configuration, declared once with Void's validators and stored in each deploy's environment
2
+ // (twelve-factor III: config in the environment, declared in code, never grouped by environment):
3
+ //
4
+ // // pb_secrets/main.ts (a Void app: vb_secrets/main.ts)
5
+ // import { defineSecrets, string, number, url, describe } from "@voidbase-cloud/voidbase/secrets";
6
+ //
7
+ // export default defineSecrets({
8
+ // SMTP_PASSWORD: string().secret(),
9
+ // ADMIN_EMAILS: describe(string().default(""), "who may open the admin pages"),
10
+ // MAX_INSTANCES: number().default(5),
11
+ // PUBLIC_API_URL: url().optional().public(),
12
+ // });
13
+ //
14
+ // Every key has an access tier, which decides where its value lives and who can read it:
15
+ //
16
+ // .secret() the Worker's encrypted secrets; read by hooks and routes; never listed, never in a build
17
+ // (plain) server configuration: the Worker's plain vars; read by hooks and routes; never in a client build
18
+ // .public() the Worker's plain vars *and* the client build (`import.meta.env.KEY`): what the browser may know
19
+ //
20
+ // The validators are Void's own (`string()`, `number()`, `boolean()`, `url()`, `email()`, `oneOf()`, `json()`,
21
+ // each with `.optional()`, `.default()`, `.secret()`, `.public()`), the same ones a Void project's env.ts uses, so
22
+ // one vocabulary serves both. Any Standard Schema validator works too (wrap it in `secret()` / `pub()` to tier it).
23
+ //
24
+ // Values come from the deploy's environment: locally `secrets.json` (git-ignored) beside the declaration, then the
25
+ // shell; on Cloudflare the Worker's secrets and vars. `voidbase serve`, `voidbase deploy` and the adapter's build
26
+ // parse them through the validators, so a default is filled in, a number is a number, and a bad or missing value
27
+ // stops the process with the key's name, never its value.
28
+ //
29
+ // This module is imported by the app's own code too (`await definition.read(c.env)` for typed access), so it must
30
+ // stay small: Void's validators and nothing else.
31
+ import { boolean, email, json, number, oneOf, string, url } from "void/env";
32
+
33
+ export { boolean, email, json, number, oneOf, string, url };
34
+
35
+ // Standard Schema V1, inlined as the spec allows
36
+ export interface StandardSchema<Output = unknown> {
37
+ readonly "~standard": {
38
+ readonly version: 1;
39
+ readonly vendor: string;
40
+ readonly validate: (value: unknown) => StandardResult<Output> | Promise<StandardResult<Output>>;
41
+ readonly types?: { readonly input: unknown; readonly output: Output } | undefined;
42
+ };
43
+ }
44
+ type StandardResult<Output> = { readonly value: Output; readonly issues?: undefined } | { readonly issues: ReadonlyArray<{ readonly message: string }> };
45
+ export type OutputOf<S> = S extends StandardSchema<infer O> ? O : never;
46
+
47
+ export type Access = "secret" | "server" | "public";
48
+
49
+ export interface Entry<S extends StandardSchema = StandardSchema> {
50
+ schema: S;
51
+ /** the tier; without one, Void's `.secret()` / `.public()` marker on the validator decides, else `server` */
52
+ access?: Access;
53
+ description?: string;
54
+ }
55
+
56
+ /** Void's marker, set by `.secret()` and `.public()` on its validators (a Symbol.for, so the same across copies). */
57
+ const VOID_MARKER = Symbol.for("void.env.secretOverride");
58
+ const markerOf = (schema: unknown): Access | undefined => {
59
+ const m = schema && typeof schema === "object" ? (schema as Record<symbol, unknown>)[VOID_MARKER] : undefined;
60
+ return m === "secret" ? "secret" : m === "public" ? "public" : undefined;
61
+ };
62
+
63
+ const ENTRY = Symbol.for("voidbase.secrets.entry");
64
+ type Marked<S extends StandardSchema> = Entry<S> & { [ENTRY]: true };
65
+ const entry = <S extends StandardSchema>(e: Entry<S>): Marked<S> => ({ ...e, [ENTRY]: true });
66
+ const isEntry = (v: unknown): v is Marked<StandardSchema> => !!v && typeof v === "object" && ENTRY in (v as object);
67
+
68
+ /** Attaches a description to a validator: shown by `voidbase secrets` and in the deploy's messages. */
69
+ export function describe<S extends StandardSchema>(schema: S, description: string): Marked<S> { return entry({ schema, description }); }
70
+ /** Tiers any Standard Schema validator as a secret (Void's own validators can say `.secret()` instead). */
71
+ export function secret<S extends StandardSchema>(schema: S, description?: string): Marked<S> { return entry({ schema, access: "secret", description }); }
72
+ /** Tiers any Standard Schema validator as server configuration: a plain Worker var, never in a client build. */
73
+ export function server<S extends StandardSchema>(schema: S, description?: string): Marked<S> { return entry({ schema, access: "server", description }); }
74
+ /** Tiers any Standard Schema validator as public: a Worker var the browser may also know (`.public()` on Void's). */
75
+ export function pub<S extends StandardSchema>(schema: S, description?: string): Marked<S> { return entry({ schema, access: "public", description }); }
76
+
77
+ export type Spec = Record<string, StandardSchema | Entry>;
78
+ type SchemaOf<E> = E extends Entry<infer S> ? S : E extends StandardSchema ? E : never;
79
+ /** The typed configuration a definition parses to. */
80
+ export type Values<T extends Spec> = { [K in keyof T]: OutputOf<SchemaOf<T[K]>> };
81
+ export type ValuesOf<D> = D extends Definition<infer T> ? Values<T> : never;
82
+
83
+ export interface KeyInfo {
84
+ name: string;
85
+ access: Access;
86
+ description?: string;
87
+ /** the validator accepts no value at all: it has a default or is optional */
88
+ optional: boolean;
89
+ /** the value the validator fills in when none is given, as it will be stored (a string), or undefined */
90
+ fallback?: string;
91
+ }
92
+
93
+ /** What parsing a set of raw values produced. Values never appear in `missing` or `invalid`. */
94
+ export interface Evaluation<T extends Spec> {
95
+ /** every key that parsed, with its typed value */
96
+ values: Partial<Values<T>>;
97
+ /** every key that parsed, as the string the environment stores (numbers and booleans stringified, objects as JSON) */
98
+ stored: Record<string, string>;
99
+ /** keys with no value and no default */
100
+ missing: string[];
101
+ /** keys whose value the validator refused: name and why */
102
+ invalid: { name: string; message: string }[];
103
+ }
104
+
105
+ const NAME = /^[A-Z][A-Z0-9_]*$/;
106
+ /** where raw values come from: an environment object, or a lookup (`(name) => $os.getenv(name)` in a hook or route) */
107
+ export type Source = Record<string, unknown> | ((name: string) => unknown);
108
+
109
+ /** A configuration value as the environment stores it: environments hold strings. */
110
+ export function toStored(v: unknown): string | undefined {
111
+ if (v === undefined || v === null) return undefined;
112
+ if (typeof v === "string") return v;
113
+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v);
114
+ if (v instanceof Date) return v.toISOString();
115
+ return JSON.stringify(v);
116
+ }
117
+
118
+ const validate = async <O>(schema: StandardSchema<O>, value: unknown): Promise<StandardResult<O>> => schema["~standard"].validate(value);
119
+
120
+ export class Definition<T extends Spec> {
121
+ readonly entries: { [K in keyof T]: Required<Pick<Entry<SchemaOf<T[K]>>, "schema" | "access">> & Pick<Entry, "description"> };
122
+ constructor(spec: T) {
123
+ const entries = {} as Definition<T>["entries"];
124
+ for (const [name, v] of Object.entries(spec)) {
125
+ if (!NAME.test(name)) throw new Error(`voidbase: "${name}" is not a configuration name (UPPER_CASE, letters, digits and underscores, like an environment variable)`);
126
+ const e: Entry = isEntry(v) ? { schema: v.schema, access: v.access, description: v.description } : { schema: v as StandardSchema };
127
+ if (!e.schema || typeof e.schema !== "object" || !("~standard" in e.schema)) throw new Error(`voidbase: ${name} needs a validator (string(), number(), url(), ... from @voidbase-cloud/voidbase/secrets, or any Standard Schema)`);
128
+ (entries as Record<string, unknown>)[name] = { schema: e.schema, access: e.access ?? markerOf(e.schema) ?? "server", description: e.description };
129
+ }
130
+ this.entries = entries;
131
+ }
132
+ /** the names, in declaration order */
133
+ get names(): (keyof T & string)[] { return Object.keys(this.entries) as (keyof T & string)[]; }
134
+ /** the names of one tier, or of several */
135
+ of(...access: Access[]): (keyof T & string)[] { return this.names.filter((n) => access.includes(this.entries[n].access)); }
136
+ /** what each key is, without any value */
137
+ async info(): Promise<KeyInfo[]> {
138
+ const out: KeyInfo[] = [];
139
+ for (const name of this.names) {
140
+ const e = this.entries[name];
141
+ const empty = await validate(e.schema, undefined);
142
+ const ok = !empty.issues;
143
+ out.push({ name, access: e.access, description: e.description, optional: ok, fallback: ok ? toStored((empty as { value: unknown }).value) : undefined });
144
+ }
145
+ return out;
146
+ }
147
+ /**
148
+ * Parses raw values (an environment: strings, or nothing) through the validators. Nothing throws: the result says
149
+ * which keys are missing and which were refused, by name, so a caller can stop with a list instead of one error.
150
+ * `only` restricts the parse to some tiers (a client build reads `public` and nothing else).
151
+ */
152
+ async evaluate(raw: Source, only?: Access[]): Promise<Evaluation<T>> {
153
+ const values: Record<string, unknown> = {}; const stored: Record<string, string> = {}; const missing: string[] = []; const invalid: { name: string; message: string }[] = [];
154
+ const get = typeof raw === "function" ? raw : (n: string) => raw[n];
155
+ for (const name of only ? this.of(...only) : this.names) {
156
+ const e = this.entries[name];
157
+ const got = get(name); const input = got === "" ? undefined : got;
158
+ const r = await validate(e.schema, input);
159
+ if (r.issues) {
160
+ if (input === undefined) { missing.push(name); continue; }
161
+ // a validator may quote what it refused (Void's url() does): the report never carries the value
162
+ const shown = typeof input === "string" ? input : String(input);
163
+ invalid.push({ name, message: r.issues.map((i) => (shown ? i.message.split(shown).join("<value>") : i.message)).join("; ") });
164
+ continue;
165
+ }
166
+ if (r.value === undefined) continue; // optional and absent: the app reads undefined, as declared
167
+ values[name] = r.value; const s = toStored(r.value); if (s !== undefined) stored[name] = s;
168
+ }
169
+ return { values: values as Partial<Values<T>>, stored, missing, invalid };
170
+ }
171
+ /**
172
+ * Typed access from the app's own code: `await definition.read((n) => pb.$os.getenv(n))` in a route or hook works
173
+ * on both runtimes; `read(c.env)` on a Worker, `read(process.env)` on Bun.
174
+ * Throws with the names of the keys that are missing or refused, never their values.
175
+ */
176
+ async read(raw: Source, only?: Access[]): Promise<Values<T>> {
177
+ const r = await this.evaluate(raw, only);
178
+ const problems = [...r.missing.map((n) => `${n}: missing`), ...r.invalid.map((i) => `${i.name}: ${i.message}`)];
179
+ if (problems.length) throw new Error(`voidbase: configuration: ${problems.join(", ")}`);
180
+ return r.values as Values<T>;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Declares the app's configuration. The result is the declaration `voidbase serve`, `voidbase deploy` and the
186
+ * adapter read, and the typed reader the app's code uses:
187
+ *
188
+ * export default defineSecrets({ SMTP_PASSWORD: string().secret(), MAX: number().default(5) });
189
+ * // elsewhere: const { MAX } = await definition.read(c.env); MAX is a number
190
+ */
191
+ export function defineSecrets<const T extends Spec>(spec: T): Definition<T> {
192
+ return new Definition(spec);
193
+ }
194
+
195
+ export const isDefinition = (v: unknown): v is Definition<Spec> => v instanceof Definition || (!!v && typeof v === "object" && typeof (v as Definition<Spec>).evaluate === "function" && !!(v as Definition<Spec>).entries);
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
8
  import { parseRedirects, writeCloudProject, type RedirectEntry } from "./cloud-init";
9
9
  import { loadEnv } from "./serve";
10
- import { loadSecrets, SECRETS_DIR, workerSecretNames } from "./secrets";
10
+ import { loadSecrets, SECRETS_DIR, workerSecretNames, type LoadedSecrets } from "./secrets";
11
11
  import { CfApi, attachCustomDomain, ensureD1, ensureQueue, ensureR2, findZone, rateLimitNamespace, resolveAccount, workersSubdomain } from "../cloud/rest";
12
12
 
13
13
  const API = (process.env.CLOUDFLARE_API_BASE ?? "https://api.cloudflare.com/client/v4").replace(/\/$/, "");
@@ -75,15 +75,16 @@ export function loadEnvFiles(files = [".env", ".env.local", "../.env", "../.env.
75
75
  }
76
76
 
77
77
  /** The environment, the token, the account and the worker name a deploy (or `voidbase secrets`) targets. */
78
- export async function deployTarget(opts: Pick<DeployOptions, "name" | "account" | "log"> = {}): Promise<{ api: CfApi; token: string; account: { id: string; name: string }; name: string; secretsDir: string; secrets: ReturnType<typeof loadSecrets> }> {
78
+ export async function deployTarget(opts: Pick<DeployOptions, "name" | "account" | "log"> = {}): Promise<{ api: CfApi; token: string; account: { id: string; name: string }; name: string; secretsDir: string; secrets: LoadedSecrets }> {
79
79
  const log = opts.log ?? ((l: string) => console.log(l));
80
80
  // pb_secrets/ first: the declared names, and on a dev machine their values, which count as environment from here
81
81
  // on. The shell outranks secrets.json, and secrets.json outranks the .env files, so a dev placeholder in .env
82
82
  // (VOIDBASE_SUPERUSER_PASSWORD=changeme123) never shadows the real value kept beside the declaration.
83
83
  const secretsDir = resolve(process.env.VOIDBASE_SECRETS_DIR || SECRETS_DIR);
84
- const secrets = loadSecrets(secretsDir);
84
+ const secrets = await loadSecrets(secretsDir);
85
+ if (secrets.invalid.length) throw new Error(`${secretsDir}: ${secrets.invalid.map((i) => `${i.name}: ${i.message}`).join(", ")}`);
85
86
  loadEnv(); const fromFiles = loadEnvFiles(); if (fromFiles.length) log(`from .env: ${fromFiles.join(", ")}`);
86
- if (secrets.state.declaration) log(`${secretsDir}: ${secrets.state.declaration.names.length} secret(s) declared, ${secrets.state.provided.length} valued here${secrets.undeclared.length ? `; in secrets.json but not declared (not deployed): ${secrets.undeclared.join(", ")}` : ""}`);
87
+ if (secrets.state.definition) { const d = secrets.state.definition; log(`${secretsDir}: ${d.names.length} declared (${d.of("secret").length} secret, ${d.of("server").length} server, ${d.of("public").length} public), ${secrets.state.provided.length} valued here${secrets.undeclared.length ? `; in secrets.json but not declared (not deployed): ${secrets.undeclared.join(", ")}` : ""}`); }
87
88
  const token = process.env[TOKEN_ENV] || process.env.CLOUDFLARE_API_TOKEN || ""; // empty means unset
88
89
  if (!token) { log(`${TOKEN_ENV} is not set.\n\n${tokenHelp()}`); throw new Error(`${TOKEN_ENV} missing`); }
89
90
  const name = slug(opts.name || process.env.VOIDBASE_DEPLOY_NAME || projectName());
@@ -157,6 +158,16 @@ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ na
157
158
  const extraVars = listed("VOIDBASE_DEPLOY_VARS"), extraSecrets = listed("VOIDBASE_DEPLOY_SECRETS");
158
159
  const baked: Record<string, string> = { VOIDBASE_WORKER_NAME: name, VOIDBASE_ACCOUNT_ID: account.id };
159
160
  for (const k of ["AUDITLOG", ...extraVars]) if (process.env[k]) baked[k] = process.env[k]!;
161
+ // the declared server and public values, parsed (defaults filled in): a var is the code's to set on every deploy
162
+ const definition = pbSecrets.state.definition;
163
+ const plainKeys = definition ? definition.of("server", "public") : [];
164
+ for (const k of plainKeys) { const v = pbSecrets.evaluation?.stored[k]; if (v !== undefined) baked[k] = v; }
165
+ const missingVars = pbSecrets.missing.filter((k) => plainKeys.includes(k));
166
+ if (missingVars.length) {
167
+ const msg = `${missingVars.length} declared value(s) have no value in ${secretsDir}/secrets.json or the environment and no default: ${missingVars.join(", ")}`;
168
+ if (opts.dryRun) log(`vars: ${msg}`); else throw new Error(msg);
169
+ }
170
+ if (plainKeys.length) log(`vars: ${plainKeys.filter((k) => baked[k] !== undefined).join(", ") || "none"}${definition!.of("public").length ? ` (public: ${definition!.of("public").join(", ")})` : ""}`);
160
171
  writeFileSync(`${cloud}/.env`, Object.entries(baked).map(([k, v]) => `${k}=${v}\n`).join(""));
161
172
  log(`project: ${cloud}`);
162
173
 
@@ -184,12 +195,12 @@ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ na
184
195
  // replaced only by `voidbase secrets push`, so a checkout whose secrets.json carries dev values (another OAuth
185
196
  // client, the placeholder password) cannot overwrite production by deploying. A declared name with no value
186
197
  // here must already be on the Worker.
187
- const declared = pbSecrets.state.declaration?.names ?? [];
198
+ const declared = definition ? definition.of("secret") : [];
188
199
  const secretMap = new Map<string, string>(keepSuperuser ? [] : [["VOIDBASE_SUPERUSER_EMAIL", email], ["VOIDBASE_SUPERUSER_PASSWORD", password]]);
189
200
  for (const k of extraSecrets) if (process.env[k]) secretMap.set(k, process.env[k]!);
190
201
  const kept: string[] = [];
191
202
  for (const k of declared) {
192
- const v = pbSecrets.state.values?.[k]; if (v === undefined) continue;
203
+ const v = pbSecrets.evaluation?.stored[k]; if (v === undefined) continue;
193
204
  if (onWorker.includes(k)) { kept.push(k); continue; }
194
205
  if (k === "VOIDBASE_SUPERUSER_PASSWORD" && v === "changeme123") { log("secrets: VOIDBASE_SUPERUSER_PASSWORD in secrets.json is the dev placeholder, not stored"); continue; }
195
206
  secretMap.set(k, v);
@@ -1,81 +1,124 @@
1
- // pb_secrets/: the app's secrets, declared where the tooling can read them and valued where git cannot see them.
1
+ // pb_secrets/: the app's configuration, declared where the tooling can read it and valued where git cannot see it.
2
2
  //
3
- // pb_secrets/main.pb.js the declaration, committed: `secrets({ NAME: "what it is", ... })`. Read, never run.
4
- // pb_secrets/secrets.json the values, git-ignored: `{ "NAME": "value", ... }`. On a dev machine only.
3
+ // pb_secrets/main.ts the declaration, committed: `export default defineSecrets({ NAME: string().secret(), ... })`
4
+ // pb_secrets/secrets.json the local values, git-ignored: `{ "NAME": "value", ... }`. On a dev machine only.
5
5
  //
6
- // `voidbase serve` loads the values into the process environment, so `$os.getenv("NAME")` and the app's own code see
7
- // them exactly as they will on Cloudflare. `voidbase deploy` stores the values as the Worker's secrets (encrypted,
8
- // per Worker, so two instances on one account never share one) and refuses to deploy while a declared secret has
9
- // neither a local value nor one already on the Worker: a CI checkout has no secrets.json, and that is the point --
10
- // the values are pushed once from a machine that has them (`voidbase secrets push`) and the pipeline needs nothing
11
- // but the deploy token. Cloudflare's account-level Secrets Store is deliberately not used: one store is shared by
12
- // every Worker of the account, and its bindings are read asynchronously, which `$os.getenv` is not.
6
+ // The declaration (src/env/define.ts) gives every key a validator and an access tier: `secret` (the Worker's
7
+ // encrypted secrets), `server` (plain Worker vars) or `public` (Worker vars the client build inlines too).
8
+ // `voidbase serve` parses the local values and the shell through the validators and puts the result into the
9
+ // process environment, so `$os.getenv("NAME")` and the app's own code see what they will see on Cloudflare.
10
+ // `voidbase deploy` stores secrets the Worker lacks as its secrets and every server/public value as its vars, and
11
+ // refuses to deploy while a value is invalid or a required one is missing everywhere: a CI checkout has no
12
+ // secrets.json, and that is the point -- the secrets are pushed once from a machine that has them (`voidbase
13
+ // secrets push`), the plain values come from the deploy's environment or the declared defaults, and the pipeline
14
+ // needs nothing but the deploy token.
15
+ //
16
+ // Cloudflare's account-level Secrets Store is deliberately not used: one store is shared by every Worker of the
17
+ // account, and its bindings are read asynchronously, which `$os.getenv` is not.
13
18
  import { existsSync, readFileSync } from "node:fs";
14
19
  import { join, resolve } from "node:path";
20
+ import { pathToFileURL } from "node:url";
15
21
  import ts from "typescript";
16
22
  import type { CfApi } from "../cloud/rest";
23
+ import { isDefinition, type Access, type Definition, type Evaluation, type KeyInfo, type Spec } from "../env/define";
17
24
 
18
25
  export const SECRETS_DIR = "pb_secrets";
19
- export const DECLARATION_FILE = "main.pb.js";
26
+ export const DECLARATION_FILES = ["main.ts", "main.js", "main.mjs"];
20
27
  export const VALUES_FILE = "secrets.json";
21
28
  const NAME = /^[A-Z][A-Z0-9_]*$/;
22
29
 
30
+ // ---- the declaration, read from the source without running it (what the adapter's scan needs) -------------------
31
+
23
32
  export interface SecretsDeclaration {
24
33
  /** the file the names came from */
25
34
  file: string;
26
35
  /** declared names, in file order */
27
36
  names: string[];
37
+ /** the tier of each name */
38
+ access: Record<string, Access>;
28
39
  /** what each one is, when the declaration says */
29
40
  descriptions: Record<string, string>;
30
41
  }
31
42
 
32
43
  /**
33
- * The names a declaration file names, without evaluating it: `secrets({ A: "what A is", B: "" })`, or
34
- * `secrets(["A", "B"])`. The same reader serves the adapter's `defineSecrets({...})` in vb_secrets/main.ts.
44
+ * The names, tiers and descriptions a declaration file declares, without evaluating it:
45
+ *
46
+ * export default defineSecrets({
47
+ * SMTP_PASSWORD: string().secret(), -> secret
48
+ * ADMIN_EMAILS: describe(string().default(""), "who..."), -> server, described
49
+ * API_URL: url().optional().public(), -> public
50
+ * OTHER: secret(zodSchema, "..."), -> secret, described
51
+ * })
35
52
  */
36
- export function parseSecretsDeclaration(code: string, file = DECLARATION_FILE, callee: string | string[] = ["secrets", "defineSecrets"]): SecretsDeclaration {
37
- const callees = new Set(Array.isArray(callee) ? callee : [callee]);
53
+ export function parseSecretsDeclaration(code: string, file = "pb_secrets/main.ts"): SecretsDeclaration {
38
54
  const sf = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true);
39
- const names: string[] = []; const descriptions: Record<string, string> = {};
40
- const text = (n: ts.Node | undefined) => (n && (ts.isStringLiteralLike(n) || ts.isIdentifier(n)) ? n.text : null);
41
- const add = (name: string | null, description: string | null) => {
55
+ const names: string[] = []; const access: Record<string, Access> = {}; const descriptions: Record<string, string> = {};
56
+ const literal = (n: ts.Node | undefined) => (n && ts.isStringLiteralLike(n) ? n.text : null);
57
+ const calleeName = (c: ts.CallExpression) => (ts.isIdentifier(c.expression) ? c.expression.text : ts.isPropertyAccessExpression(c.expression) ? c.expression.name.text : "");
58
+ // the tier and description of one value expression: wrappers first, then Void's .secret()/.public() chain
59
+ const classify = (expr: ts.Expression): { access: Access; description: string | null } => {
60
+ let description: string | null = null; let tier: Access | null = null;
61
+ let node: ts.Expression = expr;
62
+ while (ts.isCallExpression(node)) {
63
+ const fn = calleeName(node);
64
+ if (fn === "describe") { description ??= literal(node.arguments[1]); node = node.arguments[0] ?? node; if (node === expr) break; continue; }
65
+ if (fn === "secret" || fn === "server" || fn === "pub" || fn === "public") {
66
+ if (ts.isIdentifier(node.expression)) { tier ??= fn === "pub" ? "public" : (fn as Access); description ??= literal(node.arguments[1]); node = node.arguments[0] ?? node; if (!node || node === expr) break; continue; }
67
+ tier ??= fn === "secret" ? "secret" : "public"; // Void's .secret() / .public() on a validator chain
68
+ }
69
+ node = ts.isPropertyAccessExpression(node.expression) ? node.expression.expression : node.expression;
70
+ if (!ts.isCallExpression(node)) break;
71
+ }
72
+ return { access: tier ?? "server", description };
73
+ };
74
+ const add = (name: string | null, expr: ts.Expression | undefined) => {
42
75
  if (!name) return;
43
- if (!NAME.test(name)) throw new Error(`voidbase: ${file}: "${name}" is not a secret name (UPPER_CASE, letters, digits and underscores, like an environment variable)`);
76
+ if (!NAME.test(name)) throw new Error(`voidbase: ${file}: "${name}" is not a configuration name (UPPER_CASE, letters, digits and underscores, like an environment variable)`);
77
+ const c = expr ? classify(expr) : { access: "server" as Access, description: null };
44
78
  if (!names.includes(name)) names.push(name);
45
- if (description) descriptions[name] = description;
79
+ access[name] = c.access; if (c.description) descriptions[name] = c.description;
46
80
  };
81
+ let found = false;
47
82
  const visit = (node: ts.Node) => {
48
- if (ts.isCallExpression(node)) {
49
- const fn = ts.isIdentifier(node.expression) ? node.expression.text : ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : "";
83
+ if (ts.isCallExpression(node) && calleeName(node) === "defineSecrets") {
84
+ found = true;
50
85
  const arg = node.arguments[0];
51
- if (callees.has(fn) && arg) {
52
- if (ts.isObjectLiteralExpression(arg)) {
53
- for (const p of arg.properties) {
54
- if (ts.isPropertyAssignment(p)) {
55
- const init = p.initializer;
56
- const description = ts.isStringLiteralLike(init) ? init.text
57
- : ts.isObjectLiteralExpression(init) ? text(init.properties.find((q): q is ts.PropertyAssignment => ts.isPropertyAssignment(q) && text(q.name) === "description")?.initializer) : null;
58
- add(text(p.name), description);
59
- } else if (ts.isShorthandPropertyAssignment(p)) add(p.name.text, null);
60
- }
61
- } else if (ts.isArrayLiteralExpression(arg)) for (const el of arg.elements) add(text(el), null);
62
- else throw new Error(`voidbase: ${file}: ${fn}() takes an object of names ({ NAME: "what it is" }) or an array of names`);
86
+ if (!arg || !ts.isObjectLiteralExpression(arg)) throw new Error(`voidbase: ${file}: defineSecrets() takes an object literal: { NAME: string().secret(), ... }`);
87
+ for (const p of arg.properties) {
88
+ if (ts.isPropertyAssignment(p)) add(ts.isStringLiteralLike(p.name) || ts.isIdentifier(p.name) ? p.name.text : null, p.initializer);
89
+ else if (ts.isShorthandPropertyAssignment(p)) add(p.name.text, undefined);
63
90
  }
64
91
  }
65
92
  ts.forEachChild(node, visit);
66
93
  };
67
94
  visit(sf);
68
- return { file, names, descriptions };
95
+ if (!found) throw new Error(`voidbase: ${file} does not call defineSecrets(): export default defineSecrets({ ... })`);
96
+ return { file, names, access, descriptions };
69
97
  }
70
98
 
71
- /** The declaration of a pb_secrets/ directory, or null when there is none. */
99
+ /** The declaration file of a pb_secrets/ directory, or null. */
100
+ export function declarationFile(dir = SECRETS_DIR): string | null {
101
+ return DECLARATION_FILES.map((f) => join(resolve(dir), f)).find((f) => existsSync(f)) ?? null;
102
+ }
103
+
104
+ /** The declaration of a pb_secrets/ directory, read statically, or null when there is none. */
72
105
  export function readSecretsDeclaration(dir = SECRETS_DIR): SecretsDeclaration | null {
73
- const file = join(resolve(dir), DECLARATION_FILE);
74
- if (!existsSync(file)) return null;
75
- return parseSecretsDeclaration(readFileSync(file, "utf8"), file);
106
+ const file = declarationFile(dir);
107
+ return file ? parseSecretsDeclaration(readFileSync(file, "utf8"), file) : null;
76
108
  }
77
109
 
78
- /** The values of a pb_secrets/ directory (`secrets.json`), or null when the file is absent. Every value is a string. */
110
+ // ---- the declaration, imported (what serve, deploy and the build need: the validators themselves) ---------------
111
+
112
+ /** Imports the declaration module and returns its definition, or null when the directory has none. */
113
+ export async function loadDefinition(dir = SECRETS_DIR): Promise<{ file: string; definition: Definition<Spec> } | null> {
114
+ const file = declarationFile(dir);
115
+ if (!file) return null;
116
+ const mod = (await import(pathToFileURL(file).href)) as { default?: unknown };
117
+ if (!isDefinition(mod.default)) throw new Error(`voidbase: ${file} must export defineSecrets({ ... }) as its default export`);
118
+ return { file, definition: mod.default };
119
+ }
120
+
121
+ /** The local values of a pb_secrets/ directory (`secrets.json`), or null when the file is absent. Every value is a string. */
79
122
  export function readSecretsValues(dir = SECRETS_DIR): Record<string, string> | null {
80
123
  const file = join(resolve(dir), VALUES_FILE);
81
124
  if (!existsSync(file)) return null;
@@ -84,7 +127,7 @@ export function readSecretsValues(dir = SECRETS_DIR): Record<string, string> | n
84
127
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`voidbase: ${file} must be an object: { "NAME": "value" }`);
85
128
  const out: Record<string, string> = {};
86
129
  for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
87
- if (!NAME.test(k)) throw new Error(`voidbase: ${file}: "${k}" is not a secret name (UPPER_CASE, letters, digits and underscores)`);
130
+ if (!NAME.test(k)) throw new Error(`voidbase: ${file}: "${k}" is not a configuration name (UPPER_CASE, letters, digits and underscores)`);
88
131
  if (v === null || v === undefined) continue;
89
132
  out[k] = typeof v === "string" ? v : typeof v === "object" ? JSON.stringify(v) : String(v);
90
133
  }
@@ -93,7 +136,12 @@ export function readSecretsValues(dir = SECRETS_DIR): Record<string, string> | n
93
136
 
94
137
  export interface SecretsState {
95
138
  dir: string;
96
- declaration: SecretsDeclaration | null;
139
+ /** the declaration file, or null when there is none */
140
+ file: string | null;
141
+ definition: Definition<Spec> | null;
142
+ /** what each key is: tier, description, default */
143
+ info: KeyInfo[];
144
+ /** the local values, or null when there is no secrets.json */
97
145
  values: Record<string, string> | null;
98
146
  /** declared names with a local value */
99
147
  provided: string[];
@@ -103,34 +151,51 @@ export interface SecretsState {
103
151
  undeclared: string[];
104
152
  }
105
153
 
106
- /** What a pb_secrets/ directory declares and holds, and how the two compare. */
107
- export function secretsState(dir = SECRETS_DIR): SecretsState {
108
- const declaration = readSecretsDeclaration(dir);
154
+ /** What a pb_secrets/ directory declares and holds locally, and how the two compare. */
155
+ export async function secretsState(dir = SECRETS_DIR): Promise<SecretsState> {
156
+ const loaded = await loadDefinition(dir);
109
157
  const values = readSecretsValues(dir);
110
- if (!declaration && values && Object.keys(values).length) {
111
- throw new Error(`voidbase: ${join(resolve(dir), VALUES_FILE)} holds ${Object.keys(values).length} secret(s) but nothing declares them. Name them in ${join(dir, DECLARATION_FILE)}:\n secrets({ ${Object.keys(values).map((k) => `${k}: ""`).join(", ")} })`);
158
+ if (!loaded && values && Object.keys(values).length) {
159
+ throw new Error(`voidbase: ${join(resolve(dir), VALUES_FILE)} holds ${Object.keys(values).length} value(s) but nothing declares them. Name them in ${join(dir, "main.ts")}:\n export default defineSecrets({ ${Object.keys(values).map((k) => `${k}: string().secret()`).join(", ")} })`);
112
160
  }
113
- const names = declaration?.names ?? [];
161
+ const names = loaded?.definition.names ?? [];
114
162
  const have = new Set(Object.keys(values ?? {}));
115
163
  return {
116
- dir: resolve(dir), declaration, values,
164
+ dir: resolve(dir), file: loaded?.file ?? null, definition: loaded?.definition ?? null, info: loaded ? await loaded.definition.info() : [], values,
117
165
  provided: names.filter((n) => have.has(n)),
118
166
  unprovided: names.filter((n) => !have.has(n)),
119
167
  undeclared: [...have].filter((n) => !names.includes(n)),
120
168
  };
121
169
  }
122
170
 
171
+ export interface LoadedSecrets {
172
+ state: SecretsState;
173
+ /** the parse of the local values under the environment (the environment wins) */
174
+ evaluation: Evaluation<Spec> | null;
175
+ /** names this call put into the environment (from the file or a default) */
176
+ loaded: string[];
177
+ /** declared names with no value anywhere and no default */
178
+ missing: string[];
179
+ /** declared names whose value was refused, by name and reason */
180
+ invalid: { name: string; message: string }[];
181
+ /** local values that no declaration names */
182
+ undeclared: string[];
183
+ }
184
+
123
185
  /**
124
- * Puts the local values into an environment (the process's, for `voidbase serve` and `voidbase deploy`), never over
125
- * a value that is already there. Returns what to tell the user: names loaded, names still missing, names nobody
126
- * declared.
186
+ * Parses the local values and the environment through the declaration and puts every stored value (defaults
187
+ * included) into the environment, never over a value that is already there. Nothing throws for a missing or
188
+ * refused value: the caller decides (serve warns, deploy stops).
127
189
  */
128
- export function loadSecrets(dir = SECRETS_DIR, into: Record<string, string | undefined> = process.env): { loaded: string[]; missing: string[]; undeclared: string[]; state: SecretsState } {
129
- const state = secretsState(dir);
190
+ export async function loadSecrets(dir = SECRETS_DIR, into: Record<string, string | undefined> = process.env): Promise<LoadedSecrets> {
191
+ const state = await secretsState(dir);
192
+ if (!state.definition) return { state, evaluation: null, loaded: [], missing: [], invalid: [], undeclared: state.undeclared };
193
+ const raw: Record<string, unknown> = { ...(state.values ?? {}) };
194
+ for (const n of state.definition.names) if (into[n] !== undefined && into[n] !== "") raw[n] = into[n];
195
+ const evaluation = await state.definition.evaluate(raw);
130
196
  const loaded: string[] = [];
131
- for (const [k, v] of Object.entries(state.values ?? {})) { if (into[k] === undefined || into[k] === "") { into[k] = v; loaded.push(k); } }
132
- const missing = state.unprovided.filter((n) => !into[n]);
133
- return { loaded, missing, undeclared: state.undeclared, state };
197
+ for (const [k, v] of Object.entries(evaluation.stored)) if (into[k] === undefined || into[k] === "") { into[k] = v; loaded.push(k); }
198
+ return { state, evaluation, loaded, missing: evaluation.missing, invalid: evaluation.invalid, undeclared: state.undeclared };
134
199
  }
135
200
 
136
201
  // ---- the Worker's secrets, through the Workers API (what `wrangler secret put` calls) -----------------------------
@@ -152,18 +217,21 @@ export async function putWorkerSecrets(api: CfApi, account: string, worker: stri
152
217
  return done;
153
218
  }
154
219
 
155
- /** The scaffold `voidbase init` writes: a declaration with nothing in it yet, and how to fill it. */
156
- export function declarationScaffold(): string {
157
- return `/// <reference path="../pb_data/types.d.ts" />
158
- // The secrets this app needs. This file is read by \`voidbase serve\` and \`voidbase deploy\`, never run: it names the
159
- // secrets, and pb_secrets/secrets.json (git-ignored) holds their values on your machine:
220
+ /** The scaffold `voidbase init` writes: a declaration with an example of each tier, and how the values arrive. */
221
+ export function declarationScaffold(pkg = "@voidbase-cloud/voidbase"): string {
222
+ return `// The app's configuration: declared here, valued in pb_secrets/secrets.json (git-ignored) on your machine and in
223
+ // the Worker's secrets and vars once deployed. Read by \`voidbase serve\` and \`voidbase deploy\`; in hooks,
224
+ // $os.getenv("NAME"); in TypeScript, \`await definition.read(env)\` gives the typed values.
160
225
  //
161
- // { "SMTP_PASSWORD": "..." }
162
- //
163
- // \`voidbase deploy\` stores the values as this Worker's secrets; \`voidbase secrets push\` does only that. A checkout
164
- // without secrets.json (CI) deploys as long as every name below is already on the Worker. In hooks: $os.getenv("NAME").
165
- secrets({
166
- // SMTP_PASSWORD: "the mail provider's SMTP password",
226
+ // .secret() the Worker's encrypted secrets, never listed, never in a build (\`voidbase secrets push\` stores them)
227
+ // (plain) server configuration: a plain Worker var, hooks and routes only
228
+ // .public() a Worker var the browser may know too: a client build inlines it as import.meta.env.NAME
229
+ import { defineSecrets, describe, string, number } from "${pkg}/secrets";
230
+
231
+ export default defineSecrets({
232
+ // SMTP_PASSWORD: describe(string().secret(), "the mail provider's SMTP password"),
233
+ // MAX_UPLOAD_MB: number().default(10),
234
+ // PUBLIC_SITE_URL: string().optional().public(),
167
235
  });
168
236
  `;
169
237
  }
package/src/node/serve.ts CHANGED
@@ -58,14 +58,15 @@ export async function openLocal(opts: ServeOptions) {
58
58
  // the same names as on Cloudflare, where the deploy stored them as the Worker's secrets (src/node/secrets.ts):
59
59
  // the shell outranks secrets.json, which outranks a dev placeholder in .env
60
60
  process.env.VOIDBASE_SECRETS_DIR = resolve(opts.secretsDir ?? process.env.VOIDBASE_SECRETS_DIR ?? "pb_secrets");
61
- const secrets = loadSecrets(process.env.VOIDBASE_SECRETS_DIR);
61
+ const secrets = await loadSecrets(process.env.VOIDBASE_SECRETS_DIR);
62
+ if (secrets.invalid.length) throw new Error(`voidbase: ${process.env.VOIDBASE_SECRETS_DIR}: ${secrets.invalid.map((i) => `${i.name}: ${i.message}`).join(", ")}`);
62
63
  loadEnv();
63
64
  const dir = resolve(opts.dir ?? "pb_data");
64
65
  mkdirSync(dir, { recursive: true });
65
66
  process.env.VOIDBASE_HOOKS_DIR = resolve(opts.hooksDir ?? process.env.VOIDBASE_HOOKS_DIR ?? "pb_hooks");
66
67
  process.env.VOIDBASE_MIGRATIONS_DIR = resolve(opts.migrationsDir ?? process.env.VOIDBASE_MIGRATIONS_DIR ?? "pb_migrations");
67
- if (secrets.missing.length && !opts.quiet) console.warn(`voidbase: ${secrets.missing.length} declared secret(s) have no value here (${process.env.VOIDBASE_SECRETS_DIR}/secrets.json): ${secrets.missing.join(", ")}`);
68
- if (secrets.undeclared.length && !opts.quiet) console.warn(`voidbase: ${process.env.VOIDBASE_SECRETS_DIR}/secrets.json holds ${secrets.undeclared.join(", ")}, which main.pb.js does not declare; a deploy stores only declared secrets`);
68
+ if (secrets.missing.length && !opts.quiet) console.warn(`voidbase: ${secrets.missing.length} declared value(s) missing and without a default (${process.env.VOIDBASE_SECRETS_DIR}/secrets.json): ${secrets.missing.join(", ")}`);
69
+ if (secrets.undeclared.length && !opts.quiet) console.warn(`voidbase: ${process.env.VOIDBASE_SECRETS_DIR}/secrets.json holds ${secrets.undeclared.join(", ")}, which main.ts does not declare; a deploy stores only declared values`);
69
70
  // pb_data/types.d.ts for editor support in pb_hooks (PocketBase's JSVM typings); a standalone executable carries
70
71
  // the typings and the system migrations itself (src/node/embedded.ts)
71
72
  const emb = await embedded();
package/tsconfig.json CHANGED
@@ -27,6 +27,7 @@
27
27
  "exclude": [
28
28
  "bin",
29
29
  "hooks-plugin.ts",
30
+ "src/env/define.ts",
30
31
  "scripts",
31
32
  "src/adapter/codegen.ts",
32
33
  "src/adapter/index.ts",
@@ -22,6 +22,7 @@
22
22
  "src/platform/node",
23
23
  "src/server",
24
24
  "hooks-plugin.ts",
25
+ "src/env/define.ts",
25
26
  "bin",
26
27
  "src/adapter"
27
28
  ]