@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,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; publicDir?: string; quiet?: boolean }
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
- const env = { DB: d1(sqlite), STORAGE: fsBucket(`${dir}/storage`), ASSETS: assetsFetcher({ panelDir: await ensurePanelDir(), publicDir: opts.publicDir ? resolve(opts.publicDir) : undefined }) };
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 { RequestEvent } from "./hooks/runtime";
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
- getenv: (name: string) => { const v = store()?.env[name]; return v == null ? "" : String(v); },
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
- export function routerUse(..._middlewares: HookMiddleware[]) { /* global hook middleware: milestone six */ }
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
 
@@ -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>();
@@ -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
- import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
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
- export function mountWebAuthn(app: 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 }) {
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
@@ -27,7 +27,12 @@
27
27
  "exclude": [
28
28
  "bin",
29
29
  "hooks-plugin.ts",
30
+ "src/env/define.ts",
30
31
  "scripts",
32
+ "src/adapter/codegen.ts",
33
+ "src/adapter/index.ts",
34
+ "src/adapter/plugin.ts",
35
+ "src/adapter/scan.ts",
31
36
  "src/node",
32
37
  "src/platform/node",
33
38
  "surface",
@@ -22,6 +22,8 @@
22
22
  "src/platform/node",
23
23
  "src/server",
24
24
  "hooks-plugin.ts",
25
- "bin"
25
+ "src/env/define.ts",
26
+ "bin",
27
+ "src/adapter"
26
28
  ]
27
29
  }