@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.
- package/CHANGELOG.md +54 -0
- package/README.md +16 -7
- package/bin/voidbase.ts +67 -6
- package/docs/adapter.md +280 -0
- package/docs/ci.md +195 -0
- package/docs/deploy.md +83 -5
- package/docs/releasing.md +39 -31
- package/hooks-plugin.ts +15 -4
- package/package.json +10 -4
- package/routes/api/[...path].ts +0 -5
- package/scripts/cf-builds.ts +228 -0
- package/scripts/ci-browser.sh +60 -0
- package/scripts/ci-cache.sh +40 -0
- package/scripts/ci-lib.sh +40 -0
- package/scripts/ci-oracles.sh +19 -0
- package/scripts/ci-plan.ts +270 -0
- package/scripts/ci-status.ts +126 -0
- package/scripts/ci-suites.sh +11 -1
- package/scripts/ci.sh +188 -0
- package/scripts/gh-release.ts +48 -0
- package/scripts/release.sh +104 -0
- package/scripts/seed-reference.sh +7 -2
- package/scripts/sync-app.ts +1 -0
- package/src/adapter/bundle.ts +130 -0
- package/src/adapter/codegen.ts +269 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/plugin.ts +132 -0
- package/src/adapter/runtime.ts +325 -0
- package/src/adapter/scan.ts +277 -0
- package/src/cloud/rest.ts +10 -2
- package/src/env/define.ts +195 -0
- package/src/node/assets.ts +7 -1
- package/src/node/cloud-init.ts +14 -0
- package/src/node/deploy-cf.ts +124 -16
- package/src/node/secrets.ts +237 -0
- package/src/node/serve.ts +16 -2
- package/src/server/api.ts +7 -2
- package/src/server/app.ts +6 -1
- package/src/server/hooks/index.ts +27 -1
- package/src/server/hooks/migrations.ts +4 -1
- package/src/server/hooks/runtime.ts +10 -2
- package/src/server/jobs.ts +3 -1
- package/src/server/webauthn.ts +23 -6
- package/tsconfig.json +5 -0
- package/tsconfig.node.json +3 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// pb_secrets/: the app's configuration, declared where the tooling can read it and valued where git cannot see it.
|
|
2
|
+
//
|
|
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
|
+
//
|
|
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.
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { join, resolve } from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
import ts from "typescript";
|
|
22
|
+
import type { CfApi } from "../cloud/rest";
|
|
23
|
+
import { isDefinition, type Access, type Definition, type Evaluation, type KeyInfo, type Spec } from "../env/define";
|
|
24
|
+
|
|
25
|
+
export const SECRETS_DIR = "pb_secrets";
|
|
26
|
+
export const DECLARATION_FILES = ["main.ts", "main.js", "main.mjs"];
|
|
27
|
+
export const VALUES_FILE = "secrets.json";
|
|
28
|
+
const NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
29
|
+
|
|
30
|
+
// ---- the declaration, read from the source without running it (what the adapter's scan needs) -------------------
|
|
31
|
+
|
|
32
|
+
export interface SecretsDeclaration {
|
|
33
|
+
/** the file the names came from */
|
|
34
|
+
file: string;
|
|
35
|
+
/** declared names, in file order */
|
|
36
|
+
names: string[];
|
|
37
|
+
/** the tier of each name */
|
|
38
|
+
access: Record<string, Access>;
|
|
39
|
+
/** what each one is, when the declaration says */
|
|
40
|
+
descriptions: Record<string, string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
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
|
+
* })
|
|
52
|
+
*/
|
|
53
|
+
export function parseSecretsDeclaration(code: string, file = "pb_secrets/main.ts"): SecretsDeclaration {
|
|
54
|
+
const sf = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true);
|
|
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) => {
|
|
75
|
+
if (!name) return;
|
|
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 };
|
|
78
|
+
if (!names.includes(name)) names.push(name);
|
|
79
|
+
access[name] = c.access; if (c.description) descriptions[name] = c.description;
|
|
80
|
+
};
|
|
81
|
+
let found = false;
|
|
82
|
+
const visit = (node: ts.Node) => {
|
|
83
|
+
if (ts.isCallExpression(node) && calleeName(node) === "defineSecrets") {
|
|
84
|
+
found = true;
|
|
85
|
+
const arg = node.arguments[0];
|
|
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);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
ts.forEachChild(node, visit);
|
|
93
|
+
};
|
|
94
|
+
visit(sf);
|
|
95
|
+
if (!found) throw new Error(`voidbase: ${file} does not call defineSecrets(): export default defineSecrets({ ... })`);
|
|
96
|
+
return { file, names, access, descriptions };
|
|
97
|
+
}
|
|
98
|
+
|
|
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. */
|
|
105
|
+
export function readSecretsDeclaration(dir = SECRETS_DIR): SecretsDeclaration | null {
|
|
106
|
+
const file = declarationFile(dir);
|
|
107
|
+
return file ? parseSecretsDeclaration(readFileSync(file, "utf8"), file) : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
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. */
|
|
122
|
+
export function readSecretsValues(dir = SECRETS_DIR): Record<string, string> | null {
|
|
123
|
+
const file = join(resolve(dir), VALUES_FILE);
|
|
124
|
+
if (!existsSync(file)) return null;
|
|
125
|
+
let parsed: unknown;
|
|
126
|
+
try { parsed = JSON.parse(readFileSync(file, "utf8")); } catch (e) { throw new Error(`voidbase: ${file} is not JSON: ${e instanceof Error ? e.message : String(e)}`); }
|
|
127
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`voidbase: ${file} must be an object: { "NAME": "value" }`);
|
|
128
|
+
const out: Record<string, string> = {};
|
|
129
|
+
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
|
130
|
+
if (!NAME.test(k)) throw new Error(`voidbase: ${file}: "${k}" is not a configuration name (UPPER_CASE, letters, digits and underscores)`);
|
|
131
|
+
if (v === null || v === undefined) continue;
|
|
132
|
+
out[k] = typeof v === "string" ? v : typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface SecretsState {
|
|
138
|
+
dir: string;
|
|
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 */
|
|
145
|
+
values: Record<string, string> | null;
|
|
146
|
+
/** declared names with a local value */
|
|
147
|
+
provided: string[];
|
|
148
|
+
/** declared names without a local value */
|
|
149
|
+
unprovided: string[];
|
|
150
|
+
/** local values that no declaration names */
|
|
151
|
+
undeclared: string[];
|
|
152
|
+
}
|
|
153
|
+
|
|
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);
|
|
157
|
+
const values = readSecretsValues(dir);
|
|
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(", ")} })`);
|
|
160
|
+
}
|
|
161
|
+
const names = loaded?.definition.names ?? [];
|
|
162
|
+
const have = new Set(Object.keys(values ?? {}));
|
|
163
|
+
return {
|
|
164
|
+
dir: resolve(dir), file: loaded?.file ?? null, definition: loaded?.definition ?? null, info: loaded ? await loaded.definition.info() : [], values,
|
|
165
|
+
provided: names.filter((n) => have.has(n)),
|
|
166
|
+
unprovided: names.filter((n) => !have.has(n)),
|
|
167
|
+
undeclared: [...have].filter((n) => !names.includes(n)),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
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
|
+
|
|
185
|
+
/**
|
|
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).
|
|
189
|
+
*/
|
|
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);
|
|
196
|
+
const loaded: string[] = [];
|
|
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 };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- the Worker's secrets, through the Workers API (what `wrangler secret put` calls) -----------------------------
|
|
202
|
+
|
|
203
|
+
/** The names of the secrets a Worker has; an empty list when the Worker does not exist yet. */
|
|
204
|
+
export async function workerSecretNames(api: CfApi, account: string, worker: string): Promise<string[]> {
|
|
205
|
+
// 10007: no such script yet (the first deploy creates it); CfApi tolerates error codes, not HTTP statuses
|
|
206
|
+
const res = await api.json<{ name: string; type: string }[]>("GET", `/accounts/${account}/workers/scripts/${encodeURIComponent(worker)}/secrets`, undefined, [10007]);
|
|
207
|
+
return (res.result ?? []).map((s) => s.name);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Stores secrets on a Worker, one call each (each becomes the current version's binding). The Worker must exist. */
|
|
211
|
+
export async function putWorkerSecrets(api: CfApi, account: string, worker: string, secrets: Record<string, string>): Promise<string[]> {
|
|
212
|
+
const done: string[] = [];
|
|
213
|
+
for (const [name, text] of Object.entries(secrets)) {
|
|
214
|
+
await api.json("PUT", `/accounts/${account}/workers/scripts/${encodeURIComponent(worker)}/secrets`, { name, text, type: "secret_text" });
|
|
215
|
+
done.push(name);
|
|
216
|
+
}
|
|
217
|
+
return done;
|
|
218
|
+
}
|
|
219
|
+
|
|
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.
|
|
225
|
+
//
|
|
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(),
|
|
235
|
+
});
|
|
236
|
+
`;
|
|
237
|
+
}
|
package/src/node/serve.ts
CHANGED
|
@@ -9,7 +9,9 @@ import { assetsFetcher } from "./assets";
|
|
|
9
9
|
import { ensurePanelDir } from "./panel";
|
|
10
10
|
import { embedded } from "./embedded";
|
|
11
11
|
|
|
12
|
-
export interface ServeOptions { http?: string; dir?: string; hooksDir?: string; migrationsDir?: string;
|
|
12
|
+
export interface ServeOptions { http?: string; dir?: string; hooksDir?: string; migrationsDir?: string;
|
|
13
|
+
/** pb_secrets/: the declaration and the git-ignored values (VOIDBASE_SECRETS_DIR) */
|
|
14
|
+
secretsDir?: string; publicDir?: string; quiet?: boolean }
|
|
13
15
|
const PKG = resolve(import.meta.dir, "../..");
|
|
14
16
|
|
|
15
17
|
// system tables: the same SQL migrations Void applies on Cloudflare
|
|
@@ -37,6 +39,8 @@ export function applySystemMigrations(db: ReturnType<typeof openDatabase>, migra
|
|
|
37
39
|
// Bun loads ./.env itself; a project that keeps its environment one level up (the SvelteKit starter) gets that too.
|
|
38
40
|
// PB_* names from the PocketBase starter convention are accepted as aliases of the VOIDBASE_* ones.
|
|
39
41
|
const ENV_ALIASES: Record<string, string> = { PB_SUPERUSER_EMAIL: "VOIDBASE_SUPERUSER_EMAIL", PB_SUPERUSER_PASSWORD: "VOIDBASE_SUPERUSER_PASSWORD", PB_USER_EMAIL: "VOIDBASE_USER_EMAIL", PB_USER_PASSWORD: "VOIDBASE_USER_PASSWORD", PB_ENCRYPTION_KEY: "VOIDBASE_ENCRYPTION_KEY" };
|
|
42
|
+
import { loadSecrets } from "./secrets";
|
|
43
|
+
|
|
40
44
|
export function loadEnv(files = [".env", ".env.local", "../.env", "../.env.local"]): void {
|
|
41
45
|
for (const f of files) {
|
|
42
46
|
if (!existsSync(f)) continue;
|
|
@@ -50,18 +54,28 @@ export function loadEnv(files = [".env", ".env.local", "../.env", "../.env.local
|
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
export async function openLocal(opts: ServeOptions) {
|
|
57
|
+
// pb_secrets/secrets.json (git-ignored) into the environment before the .env files, so $os.getenv and the app see
|
|
58
|
+
// the same names as on Cloudflare, where the deploy stored them as the Worker's secrets (src/node/secrets.ts):
|
|
59
|
+
// the shell outranks secrets.json, which outranks a dev placeholder in .env
|
|
60
|
+
process.env.VOIDBASE_SECRETS_DIR = resolve(opts.secretsDir ?? process.env.VOIDBASE_SECRETS_DIR ?? "pb_secrets");
|
|
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(", ")}`);
|
|
53
63
|
loadEnv();
|
|
54
64
|
const dir = resolve(opts.dir ?? "pb_data");
|
|
55
65
|
mkdirSync(dir, { recursive: true });
|
|
56
66
|
process.env.VOIDBASE_HOOKS_DIR = resolve(opts.hooksDir ?? process.env.VOIDBASE_HOOKS_DIR ?? "pb_hooks");
|
|
57
67
|
process.env.VOIDBASE_MIGRATIONS_DIR = resolve(opts.migrationsDir ?? process.env.VOIDBASE_MIGRATIONS_DIR ?? "pb_migrations");
|
|
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`);
|
|
58
70
|
// pb_data/types.d.ts for editor support in pb_hooks (PocketBase's JSVM typings); a standalone executable carries
|
|
59
71
|
// the typings and the system migrations itself (src/node/embedded.ts)
|
|
60
72
|
const emb = await embedded();
|
|
61
73
|
try { if (!existsSync(`${dir}/types.d.ts`)) writeFileSync(`${dir}/types.d.ts`, emb?.typesDts ?? readFileSync(`${PKG}/types/pb_data.d.ts`, "utf8")); } catch { /* optional */ }
|
|
62
74
|
const sqlite = openDatabase(`${dir}/data.db`);
|
|
63
75
|
applySystemMigrations(sqlite, emb?.migrations ?? readSystemMigrations());
|
|
64
|
-
|
|
76
|
+
// PocketBase serves ./pb_public at / when the directory exists (--publicDir); a build there is a full static host
|
|
77
|
+
const publicDir = opts.publicDir ? resolve(opts.publicDir) : existsSync(resolve("pb_public")) ? resolve("pb_public") : undefined;
|
|
78
|
+
const env = { DB: d1(sqlite), STORAGE: fsBucket(`${dir}/storage`), ASSETS: assetsFetcher({ panelDir: await ensurePanelDir(), publicDir }) };
|
|
65
79
|
return { dir, sqlite, env };
|
|
66
80
|
}
|
|
67
81
|
|
package/src/server/api.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
import type { Context, Hono } from "hono";
|
|
5
5
|
import { app } from "./app";
|
|
6
6
|
import { hookGlobals } from "./hooks";
|
|
7
|
-
import type
|
|
8
|
-
import type { AppEnv } from "./types";
|
|
7
|
+
import { hookStore, type RequestEvent } from "./hooks/runtime";
|
|
8
|
+
import type { AppEnv, Bindings } from "./types";
|
|
9
9
|
|
|
10
10
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
11
|
export type HookGlobals = Record<string, any>;
|
|
@@ -20,6 +20,11 @@ export interface VoidbaseApp {
|
|
|
20
20
|
/** $app, $apis, $os, $security, routerAdd, routerUse, cronAdd, cronRemove, on* event registrations, Record, Collection, ... */
|
|
21
21
|
hooks: HookGlobals;
|
|
22
22
|
}
|
|
23
|
+
/** The bindings of the request, cron tick or migration currently running (the hook store's env), or undefined
|
|
24
|
+
* outside one. Extensions that are handed no context (a cron callback, a queue consumer) read them here. */
|
|
25
|
+
export function currentBindings(): Bindings | undefined {
|
|
26
|
+
return hookStore.getStore()?.env as Bindings | undefined;
|
|
27
|
+
}
|
|
23
28
|
export function appApi(): VoidbaseApp {
|
|
24
29
|
const hooks = hookGlobals() as HookGlobals;
|
|
25
30
|
const late = (method: string): LateRegister => (path, handler) => hooks.routerAdd(method, path, (e: RequestEvent) => handler(e.c));
|
package/src/server/app.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { Field } from "./collections/fields";
|
|
|
7
7
|
import { createRecord, deleteRecord, listRecords, updateRecord, viewRecord, type ListQuery, type RecordContext } from "./records/service";
|
|
8
8
|
import { fromColumn, toColumn } from "./records/values";
|
|
9
9
|
import { expandRecords } from "./records/expand";
|
|
10
|
-
import { hookGlobals, hookMiddleware, loadHooks, mountHookRoutes } from "./hooks";
|
|
10
|
+
import { globalHookMiddleware, hookGlobals, hookMiddleware, loadHooks, mountHookRoutes } from "./hooks";
|
|
11
11
|
import { requestHook, requestHookResult, trigger } from "./hooks/runtime";
|
|
12
12
|
import { logger } from "#platform/log";
|
|
13
13
|
import { env as voidEnv } from "#platform/env";
|
|
@@ -46,6 +46,7 @@ import { ApiError, badRequest, forbidden, notFound } from "./errors";
|
|
|
46
46
|
import { randomIdSuffix, randomString } from "./ids";
|
|
47
47
|
import { createCollection, deleteCollection, importCollections, inferViewFields, truncateCollection, updateCollection } from "./collections/service";
|
|
48
48
|
import { loadSettings, publicSettings } from "./settings";
|
|
49
|
+
import { mountWebAuthn } from "./webauthn";
|
|
49
50
|
import type { AppEnv, Row } from "./types";
|
|
50
51
|
|
|
51
52
|
export const app = new Hono<AppEnv>();
|
|
@@ -79,6 +80,8 @@ app.use("*", requestLogger());
|
|
|
79
80
|
app.use("*", bodyLimitMiddleware());
|
|
80
81
|
app.use("*", rateLimitMiddleware());
|
|
81
82
|
app.use("*", hookMiddleware() as never);
|
|
83
|
+
// PocketBase's routerUse: the app's own global middleware, around every request (see src/adapter for Void's middleware/)
|
|
84
|
+
app.use("*", globalHookMiddleware() as never);
|
|
82
85
|
|
|
83
86
|
app.onError((err, c) => {
|
|
84
87
|
if (err instanceof ApiError) return err.response();
|
|
@@ -471,6 +474,8 @@ function sortBy<T extends object>(items: T[], sort: string, allowed: string[]):
|
|
|
471
474
|
}
|
|
472
475
|
|
|
473
476
|
// --- passkeys (the starter's Go webauthn routes, native here) ---------------
|
|
477
|
+
// mounted for every app; the routes answer only where a `passkeys` collection exists
|
|
478
|
+
mountWebAuthn(app);
|
|
474
479
|
mountOAuth2Redirect(app);
|
|
475
480
|
mountSettingsApi(app);
|
|
476
481
|
const authDeps = {
|
|
@@ -3,12 +3,13 @@ import { logger } from "#platform/log";
|
|
|
3
3
|
import type { Hono, MiddlewareHandler } from "hono";
|
|
4
4
|
import { files, hooks, hooksDir, modules } from "#platform/hooks";
|
|
5
5
|
import { loadCollections } from "../collections/model";
|
|
6
|
+
import { dispatch, registerJobHandler, type Job } from "../jobs";
|
|
6
7
|
import { loadSettings } from "../settings";
|
|
7
8
|
import type { AppEnv } from "../types";
|
|
8
9
|
import { CollectionRef, HookRecord } from "./record";
|
|
9
10
|
import {
|
|
10
11
|
$apis, $app, $dbx, $filesystem, $http, $security, BadRequestError, ForbiddenError, InternalServerError, MailerMessage, NotFoundError,
|
|
11
|
-
RecordUpsertFormFactory, RequestEvent, UnauthorizedError, ValidationError, authToHookRecord, cronAdd, cronRemove, hookStore,
|
|
12
|
+
RecordUpsertFormFactory, RequestEvent, UnauthorizedError, ValidationError, authToHookRecord, cronAdd, cronRemove, globalMiddlewares, hookStore,
|
|
12
13
|
crons, eventHooks, makeOs, onEvent, routerAdd, routerUse, routes, type HookMiddleware,
|
|
13
14
|
} from "./runtime";
|
|
14
15
|
import { ApiError } from "../errors";
|
|
@@ -28,6 +29,10 @@ function buildGlobals(): Record<string, unknown> {
|
|
|
28
29
|
const g: Record<string, unknown> = {
|
|
29
30
|
$app, $apis, $http, $os, $filesystem, $security,
|
|
30
31
|
$mails: {}, $template: { loadFiles: () => ({ render: () => "" }) }, $dbx,
|
|
32
|
+
// voidbase extensions a hook cannot get at otherwise: the Cloudflare bindings of the request, cron tick or
|
|
33
|
+
// migration running now, and the background jobs queue
|
|
34
|
+
$env: () => (hookStore.getStore()?.env ?? {}) as Record<string, unknown>,
|
|
35
|
+
$jobs: { queueJob: (job: Job) => dispatch(job), onJob: (type: Job["type"], fn: Parameters<typeof registerJobHandler>[1]) => registerJobHandler(type, fn) },
|
|
31
36
|
routerAdd, routerUse, cronAdd, cronRemove,
|
|
32
37
|
migrate: () => { /* migrations are applied by the migrations runner, not at hook load */ },
|
|
33
38
|
Record: class Record extends HookRecord { constructor(collection: CollectionRef, data?: { [k: string]: unknown }) { super(collection, data ?? {}); } },
|
|
@@ -136,6 +141,27 @@ export function mountHookRoutes(app: Hono<AppEnv>) {
|
|
|
136
141
|
});
|
|
137
142
|
}
|
|
138
143
|
|
|
144
|
+
/** PocketBase's routerUse middleware, around every request voidbase serves (its own endpoints included). */
|
|
145
|
+
export function globalHookMiddleware(): MiddlewareHandler<AppEnv> {
|
|
146
|
+
return async (c, next) => {
|
|
147
|
+
if (!globalMiddlewares.length) return next();
|
|
148
|
+
const ev = new RequestEvent(c, authToHookRecord(c.get("auth")));
|
|
149
|
+
let i = 0, reachedRoute = false;
|
|
150
|
+
const step = async (): Promise<unknown> => {
|
|
151
|
+
const mw = globalMiddlewares[i++];
|
|
152
|
+
if (!mw) { reachedRoute = true; await next(); return undefined; }
|
|
153
|
+
return (typeof mw === "function" ? mw : mw.func)(ev);
|
|
154
|
+
};
|
|
155
|
+
ev.next = step;
|
|
156
|
+
const result = await step();
|
|
157
|
+
if (result instanceof Response) return result;
|
|
158
|
+
if (ev.written) return ev.written;
|
|
159
|
+
// a middleware that stopped the chain without answering: an empty 204, as a hook route in the same state gets
|
|
160
|
+
if (!reachedRoute) return c.body(null, 204);
|
|
161
|
+
return undefined;
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
139
165
|
// Per-request state for $app and friends.
|
|
140
166
|
export function hookMiddleware(): MiddlewareHandler<AppEnv> {
|
|
141
167
|
return async (c, next) => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { migrations } from "#platform/migrations";
|
|
6
6
|
import { invalidateCollections, loadCollections } from "../collections/model";
|
|
7
7
|
import { importCollections } from "../collections/service";
|
|
8
|
-
import { all, stmt } from "../db";
|
|
8
|
+
import { all, run, stmt } from "../db";
|
|
9
9
|
import { loadSettings } from "../settings";
|
|
10
10
|
import type { RecordContext } from "../records/service";
|
|
11
11
|
import type { AppEnv } from "../types";
|
|
@@ -34,6 +34,9 @@ async function runPending(db: D1Database, globals: Record<string, unknown>): Pro
|
|
|
34
34
|
const applied = new Set((await all<{ file: string }>(db, "SELECT file FROM `_pbMigrations`")).map((r) => r.file));
|
|
35
35
|
const own: Record<string, unknown> = {
|
|
36
36
|
importCollections: (list: Record<string, unknown>[], deleteMissing = false) => importCollections(db, list, deleteMissing),
|
|
37
|
+
// raw DDL/DML for migrations that are not about collections (a Void app's Drizzle migrations, see src/adapter):
|
|
38
|
+
// voidbase-specific, PocketBase spells this app.db().newQuery(sql).execute()
|
|
39
|
+
execSQL: (sql: string, params: unknown[] = []) => run(db, sql, params),
|
|
37
40
|
};
|
|
38
41
|
const $app = (globals.$app ?? {}) as Record<string, unknown>;
|
|
39
42
|
const app = new Proxy(own, { get: (t, k) => (k in t ? t[k as string] : $app[k as string]), has: (t, k) => k in t || k in $app });
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// The JSVM-compatible global API for pb_hooks files, plus the registries their calls populate.
|
|
2
2
|
// Per-request state ($app's database, the request) is carried by AsyncLocalStorage.
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
// A generated pb_hooks bundle (src/adapter) cannot import anything: the hook sandbox resolves only its sibling
|
|
5
|
+
// files. Void's runtime needs AsyncLocalStorage, so the bundle reads it here, from the one place both runtimes
|
|
6
|
+
// already have it (Bun natively, Workers through nodejs_compat).
|
|
7
|
+
(globalThis as { AsyncLocalStorage?: unknown }).AsyncLocalStorage ??= AsyncLocalStorage;
|
|
4
8
|
import type { Context } from "hono";
|
|
5
9
|
import type { Collection } from "../collections/model";
|
|
6
10
|
import { ApiError } from "../errors";
|
|
@@ -299,7 +303,9 @@ export const $security = {
|
|
|
299
303
|
|
|
300
304
|
export function makeOs(files: Record<string, string>, hooksDir: string) {
|
|
301
305
|
return {
|
|
302
|
-
|
|
306
|
+
// the bindings first (a Worker's vars and secrets), then the process environment (Bun; a Worker has none
|
|
307
|
+
// without nodejs_compat), which is what PocketBase's $os.getenv reads
|
|
308
|
+
getenv: (name: string) => { const v = store()?.env[name]; if (v != null) return String(v); const p = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.[name]; return p == null ? "" : String(p); },
|
|
303
309
|
readFile: (path: string) => {
|
|
304
310
|
const rel = path.startsWith(hooksDir) ? path.slice(hooksDir.length).replace(/^\/+/, "") : path;
|
|
305
311
|
const text = files[rel];
|
|
@@ -316,7 +322,9 @@ export function makeOs(files: Record<string, string>, hooksDir: string) {
|
|
|
316
322
|
export function routerAdd(method: string, path: string, handler: HookFn, ...middlewares: HookMiddleware[]) {
|
|
317
323
|
routes.push({ method: method.toUpperCase() === "ANY" ? "ALL" : method.toUpperCase(), path: toHonoPath(path), handler, middlewares });
|
|
318
324
|
}
|
|
319
|
-
|
|
325
|
+
/** Global middleware, PocketBase's `routerUse`: runs on every request, before the route that answers it. */
|
|
326
|
+
export const globalMiddlewares: HookMiddleware[] = [];
|
|
327
|
+
export function routerUse(...middlewares: HookMiddleware[]) { globalMiddlewares.push(...middlewares); }
|
|
320
328
|
export function cronAdd(id: string, expr: string, fn: () => unknown) { crons.set(id, { expr, fn }); }
|
|
321
329
|
export function cronRemove(id: string) { crons.delete(id); }
|
|
322
330
|
|
package/src/server/jobs.ts
CHANGED
|
@@ -8,7 +8,9 @@ import type { Bindings } from "./types";
|
|
|
8
8
|
|
|
9
9
|
export type Job =
|
|
10
10
|
| { type: "mail"; message: MailMessage; text: string }
|
|
11
|
-
| { type: "backup"; name: string }
|
|
11
|
+
| { type: "backup"; name: string }
|
|
12
|
+
// a message for one of the app's own queues (a Void app's queues/<name>.ts, mounted by src/adapter)
|
|
13
|
+
| { type: "queue"; queue: string; body: unknown };
|
|
12
14
|
export type JobHandler<T extends Job = Job> = (env: Bindings, job: T) => Promise<void>;
|
|
13
15
|
|
|
14
16
|
const handlers = new Map<Job["type"], JobHandler>();
|
package/src/server/webauthn.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// Credentials live in the app's `passkeys` collection (user, credential_id, credentials) exactly like the Go code;
|
|
4
4
|
// the pending challenge lives in _params (the Go version keeps it in memory, which a Worker cannot rely on).
|
|
5
5
|
import type { Context, Hono } from "hono";
|
|
6
|
-
|
|
6
|
+
// loaded on the first passkey request rather than at startup: @simplewebauthn/server is half a megabyte, and most
|
|
7
|
+
// apps have no `passkeys` collection and never reach these routes
|
|
8
|
+
const webauthn = () => import("@simplewebauthn/server");
|
|
7
9
|
import { recordAuthResponse } from "./auth-response";
|
|
8
10
|
import { loadCollections } from "./collections/model";
|
|
9
11
|
import { all, ident, one, run } from "./db";
|
|
@@ -78,14 +80,29 @@ async function takeSession(db: D1Database, userId: string): Promise<string | nul
|
|
|
78
80
|
return s.expires > Date.now() ? s.challenge : null;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
type Router = Pick<Hono<AppEnv>, "get" | "post"> | { get: (p: string, h: (c: Context<AppEnv>) => Promise<Response>) => void; post: (p: string, h: (c: Context<AppEnv>) => Promise<Response>) => void };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Mounts the four passkey endpoints. voidbase's own app mounts them, so an app gets them for free; the export is
|
|
87
|
+
* for anyone putting them on a router of their own.
|
|
88
|
+
*
|
|
89
|
+
* They answer only when the app has a `passkeys` collection. Without one there is nowhere to keep a credential, so
|
|
90
|
+
* the feature is off and the endpoints are not there.
|
|
91
|
+
*/
|
|
92
|
+
export function mountWebAuthn(router: Router) {
|
|
93
|
+
const gated = (h: (c: Context<AppEnv>) => Promise<Response>) => async (c: Context<AppEnv>) => {
|
|
94
|
+
if (!(await loadCollections(c.env.DB)).has("passkeys")) return c.json({ status: 404, message: "Not Found.", data: {} }, 404);
|
|
95
|
+
return h(c);
|
|
96
|
+
};
|
|
97
|
+
const app = { get: (p: string, h: (c: Context<AppEnv>) => Promise<Response>) => router.get(p, gated(h) as never), post: (p: string, h: (c: Context<AppEnv>) => Promise<Response>) => router.post(p, gated(h) as never) };
|
|
98
|
+
|
|
82
99
|
app.get("/api/webauthn/registration-options", async (c) => {
|
|
83
100
|
const user = await findUser(c.env.DB, c.req.query("usernameOrEmail") ?? "");
|
|
84
101
|
if (!user) return c.json(RESPONSES.failed, 400);
|
|
85
102
|
try {
|
|
86
103
|
const rp = await relyingParty(c);
|
|
87
104
|
const existing = await credentialsOf(c.env.DB, String(user.id));
|
|
88
|
-
const options = await generateRegistrationOptions({
|
|
105
|
+
const options = await (await webauthn()).generateRegistrationOptions({
|
|
89
106
|
rpName: rp.rpName, rpID: rp.rpID,
|
|
90
107
|
userID: new TextEncoder().encode(String(user.id)),
|
|
91
108
|
userName: String(user.username || user.email || user.id),
|
|
@@ -110,7 +127,7 @@ export function mountWebAuthn(app: Pick<Hono<AppEnv>, "get" | "post"> | { get: (
|
|
|
110
127
|
const challenge = await takeSession(c.env.DB, String(user.id));
|
|
111
128
|
if (!challenge) return c.json(RESPONSES.reg_error, 500);
|
|
112
129
|
const { usernameOrEmail: _u, ...response } = body;
|
|
113
|
-
const verification = await verifyRegistrationResponse({ response: response as never, expectedChallenge: challenge, expectedOrigin: rp.origin, expectedRPID: rp.rpID, requireUserVerification: false });
|
|
130
|
+
const verification = await (await webauthn()).verifyRegistrationResponse({ response: response as never, expectedChallenge: challenge, expectedOrigin: rp.origin, expectedRPID: rp.rpID, requireUserVerification: false });
|
|
114
131
|
if (!verification.verified || !verification.registrationInfo) return c.json(RESPONSES.reg_error, 500);
|
|
115
132
|
const info = verification.registrationInfo;
|
|
116
133
|
const cred: StoredCredential = {
|
|
@@ -131,7 +148,7 @@ export function mountWebAuthn(app: Pick<Hono<AppEnv>, "get" | "post"> | { get: (
|
|
|
131
148
|
try {
|
|
132
149
|
const rp = await relyingParty(c);
|
|
133
150
|
const creds = await credentialsOf(c.env.DB, String(user.id));
|
|
134
|
-
const options = await generateAuthenticationOptions({ rpID: rp.rpID, userVerification: "preferred", allowCredentials: creds.map((e) => ({ id: e.cred.id, transports: e.cred.transports })) });
|
|
151
|
+
const options = await (await webauthn()).generateAuthenticationOptions({ rpID: rp.rpID, userVerification: "preferred", allowCredentials: creds.map((e) => ({ id: e.cred.id, transports: e.cred.transports })) });
|
|
135
152
|
await putSession(c.env.DB, String(user.id), options.challenge);
|
|
136
153
|
return c.json({ publicKey: options });
|
|
137
154
|
} catch (err) {
|
|
@@ -151,7 +168,7 @@ export function mountWebAuthn(app: Pick<Hono<AppEnv>, "get" | "post"> | { get: (
|
|
|
151
168
|
const { usernameOrEmail: _u, ...response } = body;
|
|
152
169
|
const match = (await credentialsOf(c.env.DB, String(user.id))).find((e) => e.cred.id === response.id);
|
|
153
170
|
if (!match) return c.json(RESPONSES.login_error, 500);
|
|
154
|
-
const verification = await verifyAuthenticationResponse({
|
|
171
|
+
const verification = await (await webauthn()).verifyAuthenticationResponse({
|
|
155
172
|
response: response as never, expectedChallenge: challenge, expectedOrigin: rp.origin, expectedRPID: rp.rpID, requireUserVerification: false,
|
|
156
173
|
credential: { id: match.cred.id, publicKey: b64url.decode(match.cred.publicKey), counter: match.cred.counter, transports: match.cred.transports },
|
|
157
174
|
});
|
package/tsconfig.json
CHANGED