@voidbase-cloud/voidbase 0.2.2 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +16 -7
  3. package/bin/voidbase.ts +67 -6
  4. package/docs/adapter.md +280 -0
  5. package/docs/ci.md +195 -0
  6. package/docs/deploy.md +83 -5
  7. package/docs/releasing.md +39 -31
  8. package/hooks-plugin.ts +15 -4
  9. package/package.json +10 -4
  10. package/routes/api/[...path].ts +0 -5
  11. package/scripts/cf-builds.ts +228 -0
  12. package/scripts/ci-browser.sh +60 -0
  13. package/scripts/ci-cache.sh +40 -0
  14. package/scripts/ci-lib.sh +40 -0
  15. package/scripts/ci-oracles.sh +19 -0
  16. package/scripts/ci-plan.ts +270 -0
  17. package/scripts/ci-status.ts +126 -0
  18. package/scripts/ci-suites.sh +11 -1
  19. package/scripts/ci.sh +188 -0
  20. package/scripts/gh-release.ts +48 -0
  21. package/scripts/release.sh +104 -0
  22. package/scripts/seed-reference.sh +7 -2
  23. package/scripts/sync-app.ts +1 -0
  24. package/src/adapter/bundle.ts +130 -0
  25. package/src/adapter/codegen.ts +269 -0
  26. package/src/adapter/index.ts +6 -0
  27. package/src/adapter/plugin.ts +132 -0
  28. package/src/adapter/runtime.ts +325 -0
  29. package/src/adapter/scan.ts +277 -0
  30. package/src/cloud/rest.ts +10 -2
  31. package/src/env/define.ts +195 -0
  32. package/src/node/assets.ts +7 -1
  33. package/src/node/cloud-init.ts +14 -0
  34. package/src/node/deploy-cf.ts +124 -16
  35. package/src/node/secrets.ts +237 -0
  36. package/src/node/serve.ts +16 -2
  37. package/src/server/api.ts +7 -2
  38. package/src/server/app.ts +6 -1
  39. package/src/server/hooks/index.ts +27 -1
  40. package/src/server/hooks/migrations.ts +4 -1
  41. package/src/server/hooks/runtime.ts +10 -2
  42. package/src/server/jobs.ts +3 -1
  43. package/src/server/webauthn.ts +23 -6
  44. package/tsconfig.json +5 -0
  45. package/tsconfig.node.json +3 -1
@@ -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);
@@ -2,10 +2,16 @@
2
2
  import { existsSync, statSync } from "node:fs";
3
3
  import { join, normalize } from "node:path";
4
4
  export function assetsFetcher(opts: { panelDir: string; publicDir?: string }) {
5
+ // Cloudflare's asset layer resolves an extensionless path against `<path>.html` and `<path>/index.html`
6
+ // (html_handling "auto-trailing-slash"); a static site generator writes one shape or the other, so the Bun
7
+ // runtime tries both and a deep link behaves the same on either runtime.
8
+ const isFile = (p: string) => existsSync(p) && statSync(p).isFile();
5
9
  const file = (root: string, rel: string): string | null => {
6
10
  const p = normalize(join(root, rel));
7
11
  if (!p.startsWith(normalize(root))) return null;
8
- if (existsSync(p) && statSync(p).isFile()) return p;
12
+ if (isFile(p)) return p;
13
+ const html = p.replace(/\/$/, "") + ".html";
14
+ if (html !== ".html" && isFile(html)) return html;
9
15
  const index = join(p, "index.html");
10
16
  return existsSync(index) ? index : null;
11
17
  };
@@ -7,6 +7,20 @@ const ROOT = resolve(import.meta.dir, "../..");
7
7
 
8
8
  // mode "package": a visible project importing the voidbase package (voidbase cloud init).
9
9
  // mode "internal": a project inside this package at .cloud/<slug>, importing ../../src etc. (voidbase deploy).
10
+ export interface RedirectEntry { source: string; host?: string; path: string; to: string; status: number; line: number }
11
+ // Netlify/Pages-style `_redirects`: `source destination [status]`, `#` comments. A source may carry a host
12
+ // (`https://api.example.com/`), which scopes the rule to that hostname. 3xx rules only (default 302).
13
+ export function parseRedirects(text: string): RedirectEntry[] {
14
+ const out: RedirectEntry[] = [];
15
+ text.split("\n").forEach((raw, i) => {
16
+ const line = raw.replace(/#.*$/, "").trim(); if (!line) return;
17
+ const [source, to, statusRaw] = line.split(/\s+/); if (!source || !to) return;
18
+ const status = Number((statusRaw ?? "302").replace(/!$/, "")); if (![301, 302, 303, 307, 308].includes(status)) return;
19
+ const m = source.match(/^https?:\/\/([^/]+)(\/.*)?$/);
20
+ out.push({ source, host: m ? m[1]!.toLowerCase() : undefined, path: m ? m[2] || "/" : source, to, status, line: i + 1 });
21
+ });
22
+ return out;
23
+ }
10
24
  export function writeCloudProject(out: string, mode: "package" | "internal" = "package", extra: { hooksDir?: string; migrationsDir?: string; entry?: string; queue?: string | false; hub?: boolean } = {}): { files: number; out: string } {
11
25
  const parentPkg = existsSync("package.json") ? (JSON.parse(readFileSync("package.json", "utf8")) as { dependencies?: Record<string, string> }) : {};
12
26
  const spec = parentPkg.dependencies?.["@voidbase-cloud/voidbase"] ?? parentPkg.dependencies?.voidbase ?? "^0.1.0";
@@ -5,9 +5,10 @@
5
5
  // which builds, applies the D1 migrations and uploads the Worker.
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
- import { writeCloudProject } from "./cloud-init";
8
+ import { parseRedirects, writeCloudProject, type RedirectEntry } from "./cloud-init";
9
9
  import { loadEnv } from "./serve";
10
- import { CfApi, attachCustomDomain, ensureD1, ensureQueue, ensureR2, rateLimitNamespace, resolveAccount, workersSubdomain } from "../cloud/rest";
10
+ import { loadSecrets, SECRETS_DIR, workerSecretNames, type LoadedSecrets } from "./secrets";
11
+ import { CfApi, attachCustomDomain, ensureD1, ensureQueue, ensureR2, findZone, rateLimitNamespace, resolveAccount, workersSubdomain } from "../cloud/rest";
11
12
 
12
13
  const API = (process.env.CLOUDFLARE_API_BASE ?? "https://api.cloudflare.com/client/v4").replace(/\/$/, "");
13
14
  export const TOKEN_ENV = "VOIDBASE_DEPLOY_CF_API_KEY";
@@ -73,14 +74,28 @@ export function loadEnvFiles(files = [".env", ".env.local", "../.env", "../.env.
73
74
  return loaded;
74
75
  }
75
76
 
76
- export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ name: string; account: string; url: string | null; wranglerConfig: string; project: string }> {
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: LoadedSecrets }> {
77
79
  const log = opts.log ?? ((l: string) => console.log(l));
80
+ // pb_secrets/ first: the declared names, and on a dev machine their values, which count as environment from here
81
+ // on. The shell outranks secrets.json, and secrets.json outranks the .env files, so a dev placeholder in .env
82
+ // (VOIDBASE_SUPERUSER_PASSWORD=changeme123) never shadows the real value kept beside the declaration.
83
+ const secretsDir = resolve(process.env.VOIDBASE_SECRETS_DIR || SECRETS_DIR);
84
+ const secrets = await loadSecrets(secretsDir);
85
+ if (secrets.invalid.length) throw new Error(`${secretsDir}: ${secrets.invalid.map((i) => `${i.name}: ${i.message}`).join(", ")}`);
78
86
  loadEnv(); const fromFiles = loadEnvFiles(); if (fromFiles.length) log(`from .env: ${fromFiles.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(", ")}` : ""}`); }
79
88
  const token = process.env[TOKEN_ENV] || process.env.CLOUDFLARE_API_TOKEN || ""; // empty means unset
80
89
  if (!token) { log(`${TOKEN_ENV} is not set.\n\n${tokenHelp()}`); throw new Error(`${TOKEN_ENV} missing`); }
81
90
  const name = slug(opts.name || process.env.VOIDBASE_DEPLOY_NAME || projectName());
82
91
  const api = new CfApi(token, API);
83
92
  const account = await resolveAccount(api, opts.account || process.env.VOIDBASE_DEPLOY_CF_ACCOUNT_ID || undefined).catch((e: Error) => { throw new Error(`${e.message} (is it ${TOKEN_ENV} with Account Settings read?)`); });
93
+ return { api, token, account, name, secretsDir, secrets };
94
+ }
95
+
96
+ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ name: string; account: string; url: string | null; wranglerConfig: string; project: string }> {
97
+ const log = opts.log ?? ((l: string) => console.log(l));
98
+ const { api, token, account, name, secretsDir, secrets: pbSecrets } = await deployTarget(opts);
84
99
  log(`account ${account.name} (${account.id}), worker "${name}"`);
85
100
  const db = await ensureD1(api, account.id, `${name}-db`); log(`D1 ${name}-db ${db.created ? "created" : "exists"} (${db.uuid})`);
86
101
  const bucket = await ensureR2(api, account.id, `${name}-storage`); log(`R2 ${name}-storage ${bucket.created ? "created" : "exists"}`);
@@ -93,8 +108,19 @@ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ na
93
108
  // Workers Free allows 5 cron triggers per account; without the trigger PocketBase's maintenance runs lazily in requests
94
109
  const cron = opts.cron ?? !off(process.env.VOIDBASE_DEPLOY_CRON);
95
110
  // a custom domain on a zone of the account (wrangler attaches it: DNS record + certificate); workers.dev is then off
96
- const domain = String(opts.domain || process.env.VOIDBASE_DEPLOY_DOMAIN || "").trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase();
97
- if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) throw new Error(`invalid custom domain "${domain}"`);
111
+ // several hostnames may be listed (comma separated); the first is the Worker's URL, all are attached
112
+ const domains = String(opts.domain || process.env.VOIDBASE_DEPLOY_DOMAIN || "").split(",").map((d) => d.trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase()).filter(Boolean);
113
+ for (const d of domains) if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(d)) throw new Error(`invalid custom domain "${d}"`);
114
+ const domain = domains[0] ?? "";
115
+ // the static site next to the API: --public-dir, VOIDBASE_DEPLOY_PUBLIC_DIR, or ./pb_public when it exists (PocketBase's default)
116
+ const publicDir = opts.publicDir || process.env.VOIDBASE_DEPLOY_PUBLIC_DIR || (existsSync(resolve("pb_public")) ? "pb_public" : undefined);
117
+ if (publicDir && !existsSync(resolve(publicDir, "index.html"))) throw new Error(`public dir ${resolve(publicDir)} has no index.html (build the site first)`);
118
+ // <public dir>/_redirects, Netlify/Pages syntax. Path-only lines go to the assets as Cloudflare's own _redirects (it
119
+ // only accepts relative sources); host-scoped lines (`https://api.example.com/ /_/ 302`) become zone Redirect Rules
120
+ // after the upload, which is how one Worker behind several custom domains answers differently per hostname.
121
+ const redirects = publicDir && existsSync(resolve(publicDir, "_redirects")) ? parseRedirects(readFileSync(resolve(publicDir, "_redirects"), "utf8")) : [];
122
+ const hostRedirects = redirects.filter((r) => r.host), pathRedirects = redirects.filter((r) => !r.host);
123
+ if (redirects.length) log(`redirects (${resolve(publicDir!, "_redirects")}): ${redirects.map((r) => `${r.source} -> ${r.to} (${r.status})`).join(", ")}${hostRedirects.length ? `; the ${hostRedirects.length} host-scoped rule(s) become zone Redirect Rules after the upload` : ""}`);
98
124
  let queue: string | false = false;
99
125
  if (wantQueue) {
100
126
  const q = await ensureQueue(api, account.id, `${name}-jobs`);
@@ -132,6 +158,16 @@ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ na
132
158
  const extraVars = listed("VOIDBASE_DEPLOY_VARS"), extraSecrets = listed("VOIDBASE_DEPLOY_SECRETS");
133
159
  const baked: Record<string, string> = { VOIDBASE_WORKER_NAME: name, VOIDBASE_ACCOUNT_ID: account.id };
134
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(", ")})` : ""}`);
135
171
  writeFileSync(`${cloud}/.env`, Object.entries(baked).map(([k, v]) => `${k}=${v}\n`).join(""));
136
172
  log(`project: ${cloud}`);
137
173
 
@@ -143,14 +179,43 @@ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ na
143
179
  const saved = existsSync(credFile) ? (JSON.parse(readFileSync(credFile, "utf8")) as { email: string; password: string }) : null;
144
180
  const placeholder = !password || password === "changeme123"; // the local dev default never goes live
145
181
  if (placeholder && saved && (!email || email === saved.email)) { email = saved.email; password = saved.password; }
146
- if (!email) email = "admin@example.com";
147
- if (!password || password === "changeme123") { password = randomPassword(); log(`generated a superuser password for ${email} (saved in ${credFile}; change it after the first login)`); }
148
- writeFileSync(credFile, JSON.stringify({ email, password }, null, 2) + "\n", { mode: 0o600 });
182
+ // what the Worker already holds: a checkout with no credentials of its own (CI) must not replace the superuser
183
+ // the Worker has with a generated one that only this checkout would know
184
+ const onWorker = await workerSecretNames(api, account.id, name);
185
+ const keepSuperuser = placeholder && !saved && onWorker.includes("VOIDBASE_SUPERUSER_EMAIL") && onWorker.includes("VOIDBASE_SUPERUSER_PASSWORD");
186
+ if (keepSuperuser) log("superuser: no credentials in this checkout, the Worker keeps the ones it has");
187
+ else {
188
+ if (!email) email = "admin@example.com";
189
+ if (!password || password === "changeme123") { password = randomPassword(); log(`generated a superuser password for ${email} (saved in ${credFile}; change it after the first login)`); }
190
+ writeFileSync(credFile, JSON.stringify({ email, password }, null, 2) + "\n", { mode: 0o600 });
191
+ }
192
+
193
+ // the Worker's secrets: the superuser, VOIDBASE_DEPLOY_SECRETS=X,Y from the environment, and the declared
194
+ // pb_secrets/ names the Worker does not have yet. A deploy ships code; a value the Worker already holds is
195
+ // replaced only by `voidbase secrets push`, so a checkout whose secrets.json carries dev values (another OAuth
196
+ // client, the placeholder password) cannot overwrite production by deploying. A declared name with no value
197
+ // here must already be on the Worker.
198
+ const declared = definition ? definition.of("secret") : [];
199
+ const secretMap = new Map<string, string>(keepSuperuser ? [] : [["VOIDBASE_SUPERUSER_EMAIL", email], ["VOIDBASE_SUPERUSER_PASSWORD", password]]);
200
+ for (const k of extraSecrets) if (process.env[k]) secretMap.set(k, process.env[k]!);
201
+ const kept: string[] = [];
202
+ for (const k of declared) {
203
+ const v = pbSecrets.evaluation?.stored[k]; if (v === undefined) continue;
204
+ if (onWorker.includes(k)) { kept.push(k); continue; }
205
+ if (k === "VOIDBASE_SUPERUSER_PASSWORD" && v === "changeme123") { log("secrets: VOIDBASE_SUPERUSER_PASSWORD in secrets.json is the dev placeholder, not stored"); continue; }
206
+ secretMap.set(k, v);
207
+ }
208
+ const missingSecrets = declared.filter((k) => !secretMap.has(k) && !onWorker.includes(k));
209
+ if (missingSecrets.length) {
210
+ const msg = `${missingSecrets.length} declared secret(s) have no value in ${secretsDir}/secrets.json and are not on the Worker "${name}" yet: ${missingSecrets.join(", ")}. Push them once from a machine that has them: voidbase secrets push --name ${name}`;
211
+ if (opts.dryRun) log(`secrets: ${msg}`); else throw new Error(msg);
212
+ } else if (declared.length) log(`secrets: ${declared.length} declared; ${declared.filter((k) => secretMap.has(k)).length} stored from here, ${kept.length} kept as the Worker has them (voidbase secrets push replaces)`);
213
+ const secrets = [...secretMap.entries()];
149
214
 
150
215
  const url = domain ? `https://${domain}` : await workersSubdomain(api, account.id).then((s) => (s ? `https://${name}.${s}.workers.dev` : null));
151
- if (domain) log(`custom domain ${domain} (workers.dev off): attached through the Workers Custom Domains API after the upload (Cloudflare adds the DNS record and certificate)`);
216
+ if (domain) log(`custom domain${domains.length > 1 ? "s" : ""} ${domains.join(", ")} (workers.dev off): attached through the Workers Custom Domains API after the upload (Cloudflare adds the DNS record and certificate)`);
152
217
  log(`bindings: D1, R2${hub ? ", realtime hub (Durable Object)" : ""}${queue ? ", Queue" : ""}${rateLimit ? `, rate limit ceiling ${rateLimit.limit}/${rateLimit.period}s per IP` : ""}${analytics ? ", Analytics Engine (needs Analytics Engine enabled once for the account: https://dash.cloudflare.com/" + account.id + "/workers/analytics-engine)" : ""}`);
153
- if (opts.dryRun) { log(`dry run: would sync the panel${opts.publicDir ? ` and ${opts.publicDir}` : ""} into ${cloud}/public, put 2 secrets and run void deploy --backend cloudflare (${url ?? "url unknown"})`); return { name, account: account.id, url, wranglerConfig, project: cloud }; }
218
+ if (opts.dryRun) { log(`dry run: would sync the panel${publicDir ? ` and ${publicDir}` : ""} into ${cloud}/public, put ${secrets.length} secrets (${secrets.map(([k]) => k).join(", ")}) and run void deploy --backend cloudflare (${url ?? "url unknown"})`); return { name, account: account.id, url, wranglerConfig, project: cloud }; }
154
219
 
155
220
  // the toolchain comes with the voidbase package (void, and wrangler through void)
156
221
  const voidDir = resolve(Bun.resolveSync("void/package.json", PKG), "..");
@@ -158,22 +223,65 @@ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ na
158
223
  // values also exported in the shell are stripped from baked vars by the Cloudflare backend, so keep the vars file clean instead
159
224
  // the generated project has no node_modules of its own: `void deploy` shells out to `vite build`, so the package's toolchain goes on PATH
160
225
  const binDirs = [resolve(PKG, "node_modules/.bin"), resolve(voidDir, "..", ".bin")].filter((d, i, a) => a.indexOf(d) === i);
161
- const env: Record<string, string | undefined> = { ...process.env, PATH: `${binDirs.join(":")}:${process.env.PATH ?? ""}`, CLOUDFLARE_API_TOKEN: token, CLOUDFLARE_ACCOUNT_ID: account.id, VOIDBASE_SUPERUSER_EMAIL: email, VOIDBASE_SUPERUSER_PASSWORD: password };
226
+ const env: Record<string, string | undefined> = { ...process.env, PATH: `${binDirs.join(":")}:${process.env.PATH ?? ""}`, CLOUDFLARE_API_TOKEN: token, CLOUDFLARE_ACCOUNT_ID: account.id, ...(keepSuperuser ? {} : { VOIDBASE_SUPERUSER_EMAIL: email, VOIDBASE_SUPERUSER_PASSWORD: password }) };
162
227
  for (const k of Object.keys(baked)) delete env[k];
163
228
  const sh = async (cmd: string[], input?: string) => { const p = Bun.spawn(cmd, { cwd: cloud, env: env as Record<string, string>, stdin: input === undefined ? "inherit" : new TextEncoder().encode(input), stdout: "inherit", stderr: "inherit" }); const code = await p.exited; if (code !== 0) throw new Error(`${cmd.join(" ")} exited with ${code}`); };
164
229
  mkdirSync(`${cloud}/public`, { recursive: true });
165
230
  await sh(["bun", resolve(PKG, "scripts/sync-panel.ts"), "--dest", `${cloud}/public/_`]);
166
- if (opts.publicDir) await sh(["bun", resolve(PKG, "scripts/sync-app.ts"), "--dest", `${cloud}/public`], undefined).catch((e) => log(`frontend build not synced: ${e instanceof Error ? e.message : e}`));
167
- const secrets: [string, string][] = [["VOIDBASE_SUPERUSER_EMAIL", email], ["VOIDBASE_SUPERUSER_PASSWORD", password], ...extraSecrets.filter((k) => process.env[k]).map((k): [string, string] => [k, process.env[k]!])];
231
+ if (publicDir) {
232
+ env.VOIDBASE_APP_DIR = resolve(publicDir); await sh(["bun", resolve(PKG, "scripts/sync-app.ts"), "--dest", `${cloud}/public`], undefined); log(`static site ${resolve(publicDir)} served at / (the panel stays at /_/)`);
233
+ if (pathRedirects.length) writeFileSync(`${cloud}/public/_redirects`, pathRedirects.map((r) => `${r.path} ${r.to} ${r.status}`).join("\n") + "\n");
234
+ }
235
+ if (secrets.length) log(`secrets: storing ${secrets.map(([k]) => k).join(", ")} on the Worker`);
168
236
  for (const [k, v] of secrets) await sh(["bun", wrangler, "secret", "put", k, "--name", name], v + "\n");
169
237
  await sh([voidBin, "deploy", "--backend", "cloudflare"]);
170
- if (domain) {
171
- const d = await attachCustomDomain(api, account.id, { hostname: domain, service: name });
238
+ for (const host of domains) {
239
+ const d = await attachCustomDomain(api, account.id, { hostname: host, service: name });
172
240
  log(`custom domain ${d.hostname} ${d.created ? "attached" : "already attached"} (zone ${d.zone_id}); the certificate can take a minute`);
173
241
  }
242
+ if (hostRedirects.length) await applyZoneRedirects(api, account.id, name, hostRedirects, log);
174
243
  if (url) {
175
244
  const ok = await fetch(`${url}/api/health`).then((r) => r.status).catch(() => 0);
176
- log(`\nlive: ${url} (health ${ok || "not reachable yet"})\n├─ REST API: ${url}/api/\n└─ Dashboard: ${url}/_/ sign in as ${email} (password in ${credFile})`);
245
+ log(`\nlive: ${url} (health ${ok || "not reachable yet"})\n├─ REST API: ${url}/api/\n└─ Dashboard: ${url}/_/ sign in as ${keepSuperuser ? "the superuser the Worker already had" : `${email} (password in ${credFile})`}`);
177
246
  } else log("deployed; workers.dev subdomain not enabled on this account, add a route or enable it in the dashboard (or VOIDBASE_DEPLOY_DOMAIN=<host> / --domain)");
178
247
  return { name, account: account.id, url, wranglerConfig, project: cloud };
179
248
  }
249
+
250
+ // ---- host-scoped redirects as zone Redirect Rules (Rulesets API, phase http_request_dynamic_redirect) --------------
251
+ // One rule per `_redirects` line, tagged `voidbase:<worker>:` in its description so a redeploy replaces exactly its own
252
+ // rules and leaves the zone's other redirect rules alone. Needs the zone permission Single Redirect (edit) on the deploy
253
+ // token ("Dynamic URL Redirects Write" in the API's permission listing, next to the DNS permission the token may already
254
+ // carry for the zone). Without it the deploy logs the rules to create by hand and carries on.
255
+ const quote = (v: string) => JSON.stringify(v);
256
+ export function redirectRule(worker: string, r: RedirectEntry): Record<string, unknown> {
257
+ const wildcard = r.path.endsWith("/*"); const prefix = wildcard ? r.path.slice(0, -1) : r.path; // "/docs/*" -> "/docs/"
258
+ const expression = wildcard ? (prefix === "/" ? `(http.host eq ${quote(r.host!)})` : `(http.host eq ${quote(r.host!)} and starts_with(http.request.uri.path, ${quote(prefix)}))`) : `(http.host eq ${quote(r.host!)} and http.request.uri.path eq ${quote(r.path)})`;
259
+ const absolute = (to: string) => (/^https?:\/\//.test(to) ? to : `https://${r.host}${to.startsWith("/") ? "" : "/"}${to}`);
260
+ const splat = r.to.includes(":splat");
261
+ const target_url = splat
262
+ ? { expression: `concat(${quote(absolute(r.to).replace(":splat", "").replace(/\/$/, ""))}, ${prefix === "/" ? "http.request.uri.path" : `substring(http.request.uri.path, ${prefix.length - 1})`})` }
263
+ : { value: absolute(r.to) };
264
+ return { description: `voidbase:${worker}:${r.source}`, expression, action: "redirect", action_parameters: { from_value: { status_code: r.status, target_url, preserve_query_string: true } }, enabled: true };
265
+ }
266
+ export async function applyZoneRedirects(api: CfApi, account: string, worker: string, entries: RedirectEntry[], log: (l: string) => void): Promise<void> {
267
+ const byZone = new Map<string, { zone: { id: string; name: string }; rules: Record<string, unknown>[] }>();
268
+ for (const r of entries) {
269
+ const zone = await findZone(api, r.host!, account);
270
+ if (!zone) { log(`redirect ${r.source}: no zone on the account covers ${r.host}, rule skipped`); continue; }
271
+ const slot = byZone.get(zone.id) ?? { zone, rules: [] }; slot.rules.push(redirectRule(worker, r)); byZone.set(zone.id, slot);
272
+ }
273
+ for (const { zone, rules } of byZone.values()) {
274
+ const path = `/zones/${zone.id}/rulesets/phases/http_request_dynamic_redirect/entrypoint`;
275
+ try {
276
+ const cur = await api.raw("GET", path); const body = await cur.text();
277
+ if (cur.status === 403 || cur.status === 401) throw new Error("permission");
278
+ const existing = cur.ok ? ((JSON.parse(body) as { result?: { rules?: Record<string, unknown>[] } }).result?.rules ?? []) : [];
279
+ const kept = existing.filter((x) => !String(x.description ?? "").startsWith(`voidbase:${worker}:`));
280
+ await api.json("PUT", path, { rules: [...kept, ...rules] });
281
+ log(`zone ${zone.name}: ${rules.length} redirect rule(s) set (${rules.map((x) => String(x.description).split(":").slice(2).join(":")).join(", ")})`);
282
+ } catch (e) {
283
+ const why = e instanceof Error && e.message === "permission" ? "the token lacks the zone permission Single Redirect > Edit (API name: Dynamic URL Redirects Write) for the zone" : e instanceof Error ? e.message : String(e);
284
+ log(`zone ${zone.name}: redirect rules not set (${why}). Add that permission to the token and deploy again, or create them under Rules > Redirect Rules:\n${rules.map((x) => ` ${x.expression} -> ${JSON.stringify((x.action_parameters as { from_value: { target_url: unknown } }).from_value.target_url)}`).join("\n")}`);
285
+ }
286
+ }
287
+ }