@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 +7 -0
- package/README.md +6 -5
- package/bin/voidbase.ts +19 -10
- package/docs/adapter.md +27 -18
- package/docs/deploy.md +48 -25
- package/package.json +3 -2
- package/src/adapter/codegen.ts +6 -6
- package/src/adapter/plugin.ts +16 -1
- package/src/adapter/scan.ts +10 -9
- package/src/env/define.ts +195 -0
- package/src/node/deploy-cf.ts +17 -6
- package/src/node/secrets.ts +136 -68
- package/src/node/serve.ts +4 -3
- package/tsconfig.json +1 -0
- package/tsconfig.node.json +1 -0
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
|
|
107
|
-
|
|
108
|
-
`pb_secrets` + `pb_data`, like a PocketBase folder: `pb_secrets/main.
|
|
109
|
-
`pb_secrets/secrets.json` holds
|
|
110
|
-
has stored
|
|
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]
|
|
47
|
-
secrets push [--name worker]
|
|
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.
|
|
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
|
-
|
|
98
|
-
|
|
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 (
|
|
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}: ${
|
|
109
|
-
for (const
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
###
|
|
152
|
+
### Configuration and secrets
|
|
153
153
|
|
|
154
|
-
The
|
|
155
|
-
|
|
156
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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.
|
|
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
|
-
###
|
|
107
|
+
### Configuration and secrets: `pb_secrets/`
|
|
108
108
|
|
|
109
|
-
The app's
|
|
110
|
-
|
|
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/
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
129
|
-
voidbase secrets push # store the local
|
|
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
|
|
133
|
-
|
|
134
|
-
without credentials of its own keeps the
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
"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": {
|
package/src/adapter/codegen.ts
CHANGED
|
@@ -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.
|
|
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
|
|
204
|
-
|
|
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,
|
|
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.
|
|
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
|
}
|
package/src/adapter/plugin.ts
CHANGED
|
@@ -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;
|
package/src/adapter/scan.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
241
|
-
//
|
|
242
|
-
//
|
|
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 =
|
|
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}:
|
|
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)
|
|
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);
|
package/src/node/deploy-cf.ts
CHANGED
|
@@ -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:
|
|
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.
|
|
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 =
|
|
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.
|
|
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);
|
package/src/node/secrets.ts
CHANGED
|
@@ -1,81 +1,124 @@
|
|
|
1
|
-
// pb_secrets/: the app's
|
|
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.
|
|
4
|
-
// pb_secrets/secrets.json
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
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
|
|
34
|
-
*
|
|
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 =
|
|
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
|
|
41
|
-
const
|
|
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
|
|
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
|
-
|
|
83
|
+
if (ts.isCallExpression(node) && calleeName(node) === "defineSecrets") {
|
|
84
|
+
found = true;
|
|
50
85
|
const arg = node.arguments[0];
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (!
|
|
111
|
-
throw new Error(`voidbase: ${join(resolve(dir), VALUES_FILE)} holds ${Object.keys(values).length}
|
|
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 =
|
|
161
|
+
const names = loaded?.definition.names ?? [];
|
|
114
162
|
const have = new Set(Object.keys(values ?? {}));
|
|
115
163
|
return {
|
|
116
|
-
dir: resolve(dir),
|
|
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
|
-
*
|
|
125
|
-
* a value that is already there.
|
|
126
|
-
*
|
|
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):
|
|
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(
|
|
132
|
-
|
|
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
|
|
156
|
-
export function declarationScaffold(): string {
|
|
157
|
-
return
|
|
158
|
-
//
|
|
159
|
-
//
|
|
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
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
68
|
-
if (secrets.undeclared.length && !opts.quiet) console.warn(`voidbase: ${process.env.VOIDBASE_SECRETS_DIR}/secrets.json holds ${secrets.undeclared.join(", ")}, which main.
|
|
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