@voidbase-cloud/voidbase 0.1.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 (134) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +19 -0
  3. package/COMPAT.md +43 -0
  4. package/LICENSE +21 -0
  5. package/NOTICE +8 -0
  6. package/README.md +124 -0
  7. package/bin/voidbase.ts +158 -0
  8. package/crons/every-minute.ts +13 -0
  9. package/db/migrations/20260905175935_large_swarm.sql +87 -0
  10. package/db/migrations/20260905185720_wild_sunspot.sql +16 -0
  11. package/db/migrations/20260905190723_solid_toro.sql +1 -0
  12. package/db/migrations/20260905213340_remarkable_union_jack.sql +11 -0
  13. package/db/migrations/meta/20260905175935_snapshot.json +599 -0
  14. package/db/migrations/meta/20260905185720_snapshot.json +703 -0
  15. package/db/migrations/meta/20260905190723_snapshot.json +710 -0
  16. package/db/migrations/meta/20260905213340_snapshot.json +781 -0
  17. package/db/migrations/meta/_journal.json +34 -0
  18. package/db/schema.ts +130 -0
  19. package/docs/deploy.md +153 -0
  20. package/docs/differences.md +88 -0
  21. package/docs/hooks.md +84 -0
  22. package/docs/migrating.md +29 -0
  23. package/docs/perf.md +53 -0
  24. package/docs/platform.md +208 -0
  25. package/docs/releasing.md +38 -0
  26. package/env.ts +23 -0
  27. package/hooks-plugin.ts +237 -0
  28. package/package.json +134 -0
  29. package/queues/jobs.ts +13 -0
  30. package/routes/api/[...path].ts +19 -0
  31. package/scripts/bench-realtime.ts +46 -0
  32. package/scripts/bench.ts +39 -0
  33. package/scripts/ci-suites.sh +27 -0
  34. package/scripts/dev.sh +29 -0
  35. package/scripts/export.ts +70 -0
  36. package/scripts/seed-app-user.sh +14 -0
  37. package/scripts/seed-d1.ts +17 -0
  38. package/scripts/seed-reference.sh +29 -0
  39. package/scripts/starter.sh +22 -0
  40. package/scripts/sync-app.ts +22 -0
  41. package/scripts/sync-panel.ts +66 -0
  42. package/src/cloud/rest.ts +297 -0
  43. package/src/node/assets.ts +22 -0
  44. package/src/node/bundle.ts +88 -0
  45. package/src/node/cloud-init.ts +51 -0
  46. package/src/node/d1.ts +44 -0
  47. package/src/node/deploy-cf.ts +179 -0
  48. package/src/node/index.ts +5 -0
  49. package/src/node/panel.ts +21 -0
  50. package/src/node/serve.ts +125 -0
  51. package/src/node/storage.ts +51 -0
  52. package/src/platform/node/env.ts +4 -0
  53. package/src/platform/node/hooks.ts +19 -0
  54. package/src/platform/node/log.ts +7 -0
  55. package/src/platform/node/migrations.ts +5 -0
  56. package/src/platform/node/photon.ts +1 -0
  57. package/src/platform/node/sockets.ts +22 -0
  58. package/src/platform/node/sse.ts +23 -0
  59. package/src/platform/workers/env.ts +3 -0
  60. package/src/platform/workers/hooks.ts +2 -0
  61. package/src/platform/workers/log.ts +1 -0
  62. package/src/platform/workers/migrations.ts +1 -0
  63. package/src/platform/workers/photon.ts +1 -0
  64. package/src/platform/workers/sockets.ts +3 -0
  65. package/src/platform/workers/sse.ts +1 -0
  66. package/src/server/api.ts +27 -0
  67. package/src/server/app.ts +582 -0
  68. package/src/server/auth-extra.ts +113 -0
  69. package/src/server/auth-flows.ts +186 -0
  70. package/src/server/auth-response.ts +111 -0
  71. package/src/server/auth.ts +187 -0
  72. package/src/server/backups.ts +234 -0
  73. package/src/server/batch.ts +123 -0
  74. package/src/server/bootstrap.ts +71 -0
  75. package/src/server/collections/auth-option-shape.json +71 -0
  76. package/src/server/collections/ddl.ts +127 -0
  77. package/src/server/collections/fields.ts +120 -0
  78. package/src/server/collections/model.ts +185 -0
  79. package/src/server/collections/oauth2-providers.json +1 -0
  80. package/src/server/collections/scaffolds.json +210 -0
  81. package/src/server/collections/service.ts +392 -0
  82. package/src/server/collections/system.json +605 -0
  83. package/src/server/collections/system.ts +19 -0
  84. package/src/server/collections/validate.ts +239 -0
  85. package/src/server/crc32.ts +13 -0
  86. package/src/server/crons.ts +100 -0
  87. package/src/server/crypto.ts +26 -0
  88. package/src/server/db.ts +37 -0
  89. package/src/server/errors.ts +53 -0
  90. package/src/server/files-api.ts +52 -0
  91. package/src/server/filter/compile.ts +420 -0
  92. package/src/server/filter/lexer.ts +107 -0
  93. package/src/server/filter/parser.ts +49 -0
  94. package/src/server/hardening.ts +136 -0
  95. package/src/server/hooks/index.ts +147 -0
  96. package/src/server/hooks/migrations.ts +58 -0
  97. package/src/server/hooks/node-async-hooks.d.ts +7 -0
  98. package/src/server/hooks/record.ts +152 -0
  99. package/src/server/hooks/runtime.ts +344 -0
  100. package/src/server/hooks/virtual-migrations.d.ts +4 -0
  101. package/src/server/hooks/virtual.d.ts +7 -0
  102. package/src/server/hub.ts +91 -0
  103. package/src/server/ids.ts +22 -0
  104. package/src/server/jobs.ts +84 -0
  105. package/src/server/jwt.ts +61 -0
  106. package/src/server/logs.ts +144 -0
  107. package/src/server/mail/index.ts +99 -0
  108. package/src/server/mail/message.ts +43 -0
  109. package/src/server/mail/smtp.ts +82 -0
  110. package/src/server/mail/templates.ts +168 -0
  111. package/src/server/oauth2/index.ts +198 -0
  112. package/src/server/oauth2/providers.ts +153 -0
  113. package/src/server/password.ts +17 -0
  114. package/src/server/realtime/hub-client.ts +50 -0
  115. package/src/server/realtime/index.ts +239 -0
  116. package/src/server/records/expand.ts +129 -0
  117. package/src/server/records/files.ts +69 -0
  118. package/src/server/records/json.ts +23 -0
  119. package/src/server/records/picker.ts +80 -0
  120. package/src/server/records/service.ts +598 -0
  121. package/src/server/records/thumbs.ts +148 -0
  122. package/src/server/records/values.ts +295 -0
  123. package/src/server/settings-api.ts +104 -0
  124. package/src/server/settings.ts +215 -0
  125. package/src/server/sql.ts +61 -0
  126. package/src/server/static.ts +17 -0
  127. package/src/server/storage/s3.ts +118 -0
  128. package/src/server/types.ts +25 -0
  129. package/src/server/webauthn.ts +168 -0
  130. package/tsconfig.json +36 -0
  131. package/tsconfig.node.json +27 -0
  132. package/types/pb_data.d.ts +24438 -0
  133. package/vite.config.ts +10 -0
  134. package/void.json +12 -0
@@ -0,0 +1,215 @@
1
+ import { one, run } from "./db";
2
+ import { nowString } from "./ids";
3
+ import { env as voidEnv } from "#platform/env";
4
+ import { aesOpen, aesSeal } from "./crypto";
5
+
6
+ // Defaults mirror PocketBase core/settings_model.go (captured from a 0.40.2 instance).
7
+ // Stored as JSON in _params under id "settings". Secrets are stored but never returned by GET.
8
+ export function defaultSettings() {
9
+ return {
10
+ superuserIPs: [] as string[],
11
+ smtp: { enabled: false, port: 587, host: "smtp.example.com", username: "", password: "", authMethod: "", tls: false, localName: "" },
12
+ backups: { cron: "", cronMaxKeep: 3, s3: { enabled: false, bucket: "", region: "", endpoint: "", accessKey: "", secret: "", forcePathStyle: false } },
13
+ s3: { enabled: false, bucket: "", region: "", endpoint: "", accessKey: "", secret: "", forcePathStyle: false },
14
+ meta: { accentColor: "#1055c9", appName: "Acme", appURL: "http://localhost:8090", senderName: "Support", senderAddress: "support@example.com", hideControls: false },
15
+ rateLimits: {
16
+ rules: [
17
+ { label: "*:auth", audience: "", duration: 3, maxRequests: 2 },
18
+ { label: "*:create", audience: "", duration: 5, maxRequests: 20 },
19
+ { label: "/api/batch", audience: "", duration: 1, maxRequests: 3 },
20
+ { label: "/api/", audience: "", duration: 10, maxRequests: 300 },
21
+ ],
22
+ excludedIPs: [] as string[],
23
+ enabled: false,
24
+ },
25
+ trustedProxy: { headers: [] as string[], useLeftmostIP: false },
26
+ batch: { enabled: false, maxRequests: 50, timeout: 3, maxBodySize: 0 },
27
+ logs: { maxDays: 5, minLevel: 0, logIP: true, logAuthId: false },
28
+ };
29
+ }
30
+
31
+ export type Settings = ReturnType<typeof defaultSettings>;
32
+
33
+ // The public shape: same as stored minus smtp.password, s3.secret, backups.s3.secret.
34
+ export function publicSettings(s: Settings) {
35
+ const { password: _p, ...smtp } = s.smtp;
36
+ const { secret: _s, ...s3 } = s.s3;
37
+ const { secret: _b, ...backupsS3 } = s.backups.s3;
38
+ return { ...s, smtp, backups: { ...s.backups, s3: backupsS3 }, s3 };
39
+ }
40
+
41
+ let cached: { value: Settings; at: number } | null = null;
42
+ const TTL = 5_000;
43
+
44
+ export function invalidateSettings() {
45
+ cached = null;
46
+ }
47
+
48
+ // ---- encryption at rest (PocketBase --encryptionEnv): AES-GCM with the key from VOIDBASE_ENCRYPTION_KEY --------
49
+ const encryptionKey = () => { try { return String((voidEnv as Record<string, unknown>).VOIDBASE_ENCRYPTION_KEY ?? ""); } catch { return ""; } };
50
+ export const encryptSettings = (json: string, key: string): Promise<string> => aesSeal(json, key);
51
+ export const decryptSettings = (encoded: string, key: string): Promise<string> => aesOpen(encoded, key);
52
+ async function readStored(raw: string): Promise<unknown> {
53
+ if (raw.trimStart().startsWith("{")) return JSON.parse(raw);
54
+ const key = encryptionKey();
55
+ if (!key) throw new Error("settings are encrypted but VOIDBASE_ENCRYPTION_KEY is not set");
56
+ return JSON.parse(await decryptSettings(raw, key));
57
+ }
58
+
59
+ export async function loadSettings(db: D1Database): Promise<Settings> {
60
+ if (cached && Date.now() - cached.at < TTL) return cached.value;
61
+ const row = await one<{ value: string }>(db, "SELECT value FROM `_params` WHERE id = 'settings'");
62
+ const value = row?.value ? deepMerge(defaultSettings(), await readStored(row.value)) : defaultSettings();
63
+ cached = { value, at: Date.now() };
64
+ return value;
65
+ }
66
+
67
+ export async function ensureSettingsRow(db: D1Database): Promise<void> {
68
+ const row = await one(db, "SELECT id FROM `_params` WHERE id = 'settings'");
69
+ if (row) return;
70
+ const now = nowString();
71
+ await run(db, "INSERT OR IGNORE INTO `_params` (id, value, created, updated) VALUES ('settings', ?, ?, ?)", [
72
+ JSON.stringify(defaultSettings()), now, now,
73
+ ]);
74
+ }
75
+
76
+ function deepMerge<T>(base: T, patch: unknown): T {
77
+ if (patch === null || typeof patch !== "object" || Array.isArray(patch)) return (patch === undefined ? base : patch) as T;
78
+ const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
79
+ for (const [k, v] of Object.entries(patch as Record<string, unknown>)) {
80
+ const cur = out[k];
81
+ out[k] = cur !== null && typeof cur === "object" && !Array.isArray(cur) ? deepMerge(cur, v) : v;
82
+ }
83
+ return out as T;
84
+ }
85
+
86
+ // ---- update (apis/settings.go settingsSet + core/settings_model.go Validate) -----------------------
87
+ export interface FieldErr { code: string; message: string; params?: Record<string, unknown> }
88
+ export type NestedErrors = { [k: string]: FieldErr | NestedErrors };
89
+
90
+ // json.Unmarshal into a clone: nested objects merge field by field, arrays are replaced, unknown keys are dropped,
91
+ // omitted secrets keep their stored value
92
+ export function mergeSettings(current: Settings, patch: unknown): Settings {
93
+ const walk = (base: unknown, p: unknown): unknown => {
94
+ if (p === undefined) return base;
95
+ if (base !== null && typeof base === "object" && !Array.isArray(base)) {
96
+ if (p === null || typeof p !== "object" || Array.isArray(p)) return base;
97
+ const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
98
+ for (const k of Object.keys(out)) if (k in (p as Record<string, unknown>)) out[k] = walk(out[k], (p as Record<string, unknown>)[k]);
99
+ return out;
100
+ }
101
+ return p;
102
+ };
103
+ return walk(current, patch) as Settings;
104
+ }
105
+
106
+ const required: FieldErr = { code: "validation_required", message: "Cannot be blank." };
107
+ const isEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
108
+ const isURL = (v: string) => { try { const u = new URL(v); return !!u.protocol && !!u.host; } catch { return false; } };
109
+ const isHost = (v: string) => /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/.test(v) || /^(\d{1,3}\.){3}\d{1,3}$/.test(v) || /^[0-9a-fA-F:]+$/.test(v);
110
+ const isHexColor = (v: string) => /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v);
111
+ const isIPOrSubnet = (v: string) => /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/.test(v) || /^[0-9a-fA-F:]+(\/\d{1,3})?$/.test(v);
112
+ export const lengthErr = (min: number, max: number): FieldErr => (min === max ? { code: "validation_length_invalid", message: `The length must be exactly ${min}.`, params: { max, min } } : { code: "validation_length_out_of_range", message: `The length must be between ${min} and ${max}.`, params: { max, min } });
113
+ export const minErr = (n: number): FieldErr => ({ code: "validation_min_greater_equal_than_required", message: `Must be no less than ${n}.`, params: { threshold: n } });
114
+ export const maxErr = (n: number): FieldErr => ({ code: "validation_max_less_equal_than_required", message: `Must be no greater than ${n}.`, params: { threshold: n } });
115
+ const inInvalid: FieldErr = { code: "validation_in_invalid", message: "Must be a valid value." };
116
+ const MAX_SAFE = 9007199254740991;
117
+
118
+ const CRON_MACROS: Record<string, string> = { "@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *", "@weekly": "0 0 * * 0", "@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@hourly": "0 * * * *" };
119
+ const sentenize = (m: string) => { const t = m.charAt(0).toUpperCase() + m.slice(1); return /[.!?]$/.test(t) ? t : t + "."; };
120
+ const atoi = (v: string): number | string => (/^[+-]?\d+$/.test(v) ? Number(v) : `strconv.Atoi: parsing ${JSON.stringify(v)}: invalid syntax`);
121
+ // tools/cron/schedule.go NewSchedule + parseCronSegment, same checks in the same order
122
+ function cronError(expr: string): string | null {
123
+ const segments = (CRON_MACROS[expr] ?? expr).split(" ");
124
+ if (segments.length !== 5) return sentenize("invalid cron expression - must be a valid macro or to have exactly 5 space separated segments");
125
+ const bounds: [number, number][] = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 6]];
126
+ for (let i = 0; i < 5; i++) {
127
+ const [min, max] = bounds[i]!;
128
+ for (const p of segments[i]!.split(",")) {
129
+ const stepParts = p.split("/");
130
+ let step = 1;
131
+ if (stepParts.length === 2) {
132
+ const parsed = atoi(stepParts[1]!); if (typeof parsed === "string") return sentenize(parsed);
133
+ if (parsed < 1 || parsed > max) return sentenize(`invalid segment step boundary - the step must be between 1 and the ${max}`);
134
+ step = parsed;
135
+ } else if (stepParts.length > 2) return sentenize("invalid segment step format - must be in the format */n or 1-30/n");
136
+ if (stepParts[0] !== "*") {
137
+ const rangeParts = stepParts[0]!.split("-");
138
+ if (rangeParts.length === 1) {
139
+ if (step !== 1) return sentenize("invalid segment step - step > 1 could be used only with the wildcard or range format");
140
+ const parsed = atoi(rangeParts[0]!); if (typeof parsed === "string") return sentenize(parsed);
141
+ if (parsed < min || parsed > max) return sentenize("invalid segment value - must be between the min and max of the segment");
142
+ } else if (rangeParts.length === 2) {
143
+ const lo = atoi(rangeParts[0]!); if (typeof lo === "string") return sentenize(lo);
144
+ if (lo < min || lo > max) return sentenize(`invalid segment range minimum - must be between ${min} and ${max}`);
145
+ const hi = atoi(rangeParts[1]!); if (typeof hi === "string") return sentenize(hi);
146
+ if (hi < lo || hi > max) return sentenize(`invalid segment range maximum - must be between ${lo} and ${max}`);
147
+ } else return sentenize("invalid segment range format - the range must have 1 or 2 parts");
148
+ }
149
+ }
150
+ }
151
+ return null;
152
+ }
153
+ const RATE_LIMIT_LABEL = /^(\w+ \/[\w/-]*|\/[\w/-]*|\w+:\w+|\*:\w+|\w+)$/; // rateLimitRuleLabelRegex
154
+
155
+ export function validateSettings(s: Settings): NestedErrors {
156
+ const errs: NestedErrors = {};
157
+ const set = (path: string[], e: FieldErr) => { let cur = errs; for (const k of path.slice(0, -1)) cur = (cur[k] ??= {}) as NestedErrors; cur[path[path.length - 1]!] = e; };
158
+ s.superuserIPs.forEach((ip, i) => { if (!ip) set(["superuserIPs", String(i)], required); else if (!isIPOrSubnet(ip)) set(["superuserIPs", String(i)], { code: "validation_invlaid_ip_or_subnet", message: "Invalid IP or CIDR subnet." }); });
159
+ const m = s.meta;
160
+ if (m.accentColor.length !== 7) set(["meta", "accentColor"], lengthErr(7, 7)); else if (!isHexColor(m.accentColor)) set(["meta", "accentColor"], { code: "validation_is_hex_color", message: "Must be a valid hexadecimal color code." });
161
+ if (!m.appName) set(["meta", "appName"], required); else if (m.appName.length > 255) set(["meta", "appName"], lengthErr(1, 255));
162
+ if (!m.appURL) set(["meta", "appURL"], required); else if (!isURL(m.appURL)) set(["meta", "appURL"], { code: "validation_is_url", message: "Must be a valid URL." });
163
+ if (!m.senderName) set(["meta", "senderName"], required); else if (m.senderName.length > 255) set(["meta", "senderName"], lengthErr(1, 255));
164
+ if (!m.senderAddress) set(["meta", "senderAddress"], required); else if (!isEmail(m.senderAddress)) set(["meta", "senderAddress"], { code: "validation_is_email", message: "Must be a valid email address." });
165
+ const l = s.logs as Settings["logs"] & { maxDataSize?: number };
166
+ if ((l.maxDataSize ?? 0) < 0) set(["logs", "maxDataSize"], minErr(0)); else if ((l.maxDataSize ?? 0) > MAX_SAFE) set(["logs", "maxDataSize"], maxErr(MAX_SAFE));
167
+ if (l.maxDays < 0) set(["logs", "maxDays"], minErr(0)); else if (l.maxDays > MAX_SAFE) set(["logs", "maxDays"], maxErr(MAX_SAFE));
168
+ if (l.minLevel > MAX_SAFE) set(["logs", "minLevel"], maxErr(MAX_SAFE));
169
+ const smtp = s.smtp;
170
+ if (smtp.enabled && !smtp.host) set(["smtp", "host"], required); else if (smtp.host && !isHost(smtp.host)) set(["smtp", "host"], { code: "validation_is_host", message: "Must be a valid IP address or DNS name." });
171
+ if (smtp.enabled && !smtp.port) set(["smtp", "port"], required); else if (smtp.port < 0) set(["smtp", "port"], minErr(0));
172
+ if (smtp.authMethod && !["PLAIN", "LOGIN"].includes(smtp.authMethod)) set(["smtp", "authMethod"], inInvalid);
173
+ if (smtp.localName && !isHost(smtp.localName)) set(["smtp", "localName"], { code: "validation_is_host", message: "Must be a valid IP address or DNS name." });
174
+ const s3 = (cfg: Settings["s3"], path: string[]) => {
175
+ if (cfg.endpoint && !isURL(cfg.endpoint)) set([...path, "endpoint"], { code: "validation_is_url", message: "Must be a valid URL." }); else if (cfg.enabled && !cfg.endpoint) set([...path, "endpoint"], required);
176
+ for (const k of ["bucket", "region", "accessKey", "secret"] as const) if (cfg.enabled && !cfg[k]) set([...path, k], required);
177
+ };
178
+ s3(s.s3, ["s3"]); s3(s.backups.s3, ["backups", "s3"]);
179
+ const cronErr = s.backups.cron ? cronError(s.backups.cron) : null;
180
+ if (cronErr) set(["backups", "cron"], { code: "validation_invalid_cron", message: cronErr });
181
+ const b = s.batch;
182
+ if (b.enabled && !b.maxRequests) set(["batch", "maxRequests"], required); else if (b.maxRequests < 0) set(["batch", "maxRequests"], minErr(0));
183
+ if (b.enabled && !b.timeout) set(["batch", "timeout"], required); else if (b.timeout < 0) set(["batch", "timeout"], minErr(0));
184
+ if (b.maxBodySize < 0) set(["batch", "maxBodySize"], minErr(0));
185
+ let rulesValid = true;
186
+ s.rateLimits.rules.forEach((r, i) => {
187
+ const p = ["rateLimits", "rules", String(i)];
188
+ const before = JSON.stringify(errs);
189
+ if (!r.label) set([...p, "label"], required); else if (!RATE_LIMIT_LABEL.test(r.label)) set([...p, "label"], { code: "validation_match_invalid", message: "Must be in a valid format." });
190
+ if (!r.maxRequests) set([...p, "maxRequests"], required); else if (r.maxRequests < 1) set([...p, "maxRequests"], minErr(1));
191
+ if (!r.duration) set([...p, "duration"], required); else if (r.duration < 1) set([...p, "duration"], minErr(1));
192
+ if (r.audience && !["", "@guest", "@auth"].includes(r.audience)) set([...p, "audience"], inInvalid);
193
+ if (JSON.stringify(errs) !== before) rulesValid = false;
194
+ });
195
+ if (rulesValid) { // checkUniqueRuleLabel runs only when every rule validated (ozzo stops at the first failing rule)
196
+ const existing: string[] = [];
197
+ for (const [i, r] of s.rateLimits.rules.entries()) {
198
+ const fullKey = r.label + "@@" + (r.audience ?? "");
199
+ if (existing.some((k) => k.startsWith(fullKey) || fullKey.startsWith(k))) { set(["rateLimits", "rules", String(i), "label"], { code: "validation_conflicting_rate_limit_rule", message: `Rate limit rule configuration with label ${r.label} already exists or conflicts with another rule.`, params: { label: r.label } }); break; }
200
+ existing.push(fullKey);
201
+ }
202
+ }
203
+ s.rateLimits.excludedIPs.forEach((ip, i) => { if (!ip) set(["rateLimits", "excludedIPs", String(i)], required); else if (!isIPOrSubnet(ip)) set(["rateLimits", "excludedIPs", String(i)], { code: "validation_invlaid_ip_or_subnet", message: "Invalid IP or CIDR subnet." }); });
204
+ return sortNested(errs);
205
+ }
206
+ export function sortNested(e: NestedErrors): NestedErrors {
207
+ return Object.fromEntries(Object.entries(e).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([k, v]) => [k, "code" in v && typeof v.code === "string" ? v : sortNested(v as NestedErrors)])) as NestedErrors;
208
+ }
209
+
210
+ export async function saveSettings(db: D1Database, s: Settings): Promise<void> {
211
+ const key = encryptionKey();
212
+ const value = key ? await encryptSettings(JSON.stringify(s), key) : JSON.stringify(s);
213
+ await run(db, "UPDATE `_params` SET value = ?, updated = ? WHERE id = 'settings'", [value, nowString()]);
214
+ invalidateSettings();
215
+ }
@@ -0,0 +1,61 @@
1
+ // POST /api/sql (apis/sql.go, the panel's SQL console): superuser-only raw SQL against D1, at most 1000 rows.
2
+ import type { Hono } from "hono";
3
+ import { requireSuperuser } from "./auth";
4
+ import { ApiError, badRequest } from "./errors";
5
+ import type { AppEnv } from "./types";
6
+
7
+ const MAX_ROWS = 1000;
8
+ const WRITE_PREFIXES = ["INSERT", "CREATE", "UPDATE", "DELETE", "DROP", "DETACH", "ALTER", "REPLACE"];
9
+
10
+ export function mountSqlApi(app: Hono<AppEnv>) {
11
+ app.post("/api/sql", async (c) => {
12
+ requireSuperuser(c);
13
+ let body: Record<string, unknown> = {};
14
+ try { body = (await c.req.json()) ?? {}; } catch { throw badRequest("An error occurred while loading the submitted data."); }
15
+ const query = String(body.query ?? "");
16
+ if (!query) throw new ApiError(400, "An error occurred while validating the submitted data.", { query: { code: "validation_required", message: "Cannot be blank." } } as never);
17
+ if (query.length > 5000) throw new ApiError(400, "An error occurred while validating the submitted data.", { query: { code: "validation_length_too_long", message: "The length must be no more than 5000.", params: { max: 5000, min: 0 } } } as never);
18
+ const trimmed = query.trim();
19
+ const upper = trimmed.toUpperCase();
20
+ const isWrite = !upper.startsWith("SELECT") && WRITE_PREFIXES.some((p) => upper.startsWith(p));
21
+ const started = Date.now();
22
+ try {
23
+ if (isWrite) {
24
+ const res = await c.env.DB.prepare(trimmed).run();
25
+ return c.json({ execTime: Date.now() - started, affectedRows: Number(res.meta?.changes ?? 0), columns: [], rows: [] });
26
+ }
27
+ const raw = await c.env.DB.prepare(trimmed).raw({ columnNames: true }) as unknown[][];
28
+ const names = (raw[0] ?? []) as string[];
29
+ // database/sql scans every value as a string; column types come from the table declarations
30
+ const rows = raw.slice(1, 1 + MAX_ROWS).map((r) => r.map((v) => (v === null || v === undefined ? null : String(v))));
31
+ const declared = new Map<string, string>();
32
+ for (const table of [...trimmed.matchAll(/\b(?:FROM|JOIN)\s+[`"[]?([A-Za-z_][\w]*)[`"\]]?/gi)].map((m) => m[1]!)) {
33
+ try { for (const col of await c.env.DB.prepare(`PRAGMA table_info("${table.replace(/"/g, "")}")`).all<{ name: string; type: string }>().then((r) => r.results)) if (!declared.has(col.name)) declared.set(col.name, col.type); } catch { /* not a table */ }
34
+ }
35
+ // database/sql reports a declared type only for plain column references; expressions and aliases of them are ""
36
+ const sel = /^SELECT\s+(?:DISTINCT\s+)?([\s\S]*?)\s+FROM\s/i.exec(trimmed);
37
+ const plain = new Map<string, string>(); let star = false;
38
+ if (sel) {
39
+ const items: string[] = []; let depth = 0, cur = "", quote = "";
40
+ for (const ch of sel[1]!) {
41
+ if (quote) { cur += ch; if (ch === quote) quote = ""; continue; }
42
+ if (ch === "'" || ch === '"' || ch === "`") { quote = ch; cur += ch; continue; }
43
+ if (ch === "(") depth++; if (ch === ")") depth--;
44
+ if (ch === "," && depth === 0) { items.push(cur); cur = ""; } else cur += ch;
45
+ }
46
+ items.push(cur);
47
+ for (const item of items) {
48
+ const t = item.trim();
49
+ if (t === "*" || /^\w+\.\*$/.test(t)) { star = true; continue; }
50
+ const m = /^(?:[\w`"]+\.)?[`"]?(\w+)[`"]?(?:\s+(?:AS\s+)?[`"]?(\w+)[`"]?)?$/i.exec(t);
51
+ if (m) plain.set(m[2] ?? m[1]!, m[1]!);
52
+ }
53
+ }
54
+ const columns = names.map((name) => ({ name, type: plain.has(name) ? declared.get(plain.get(name)!) ?? "" : star ? declared.get(name) ?? "" : "", nullable: true }));
55
+ return c.json({ execTime: Date.now() - started, affectedRows: 0, columns, rows });
56
+ } catch (err) {
57
+ const msg = err instanceof Error ? err.message.replace(/^D1_ERROR: /, "") : String(err);
58
+ throw badRequest("Failed to execute query. Raw error:\n" + msg);
59
+ }
60
+ });
61
+ }
@@ -0,0 +1,17 @@
1
+ // PocketBase static serving for `voidbase serve` (Bun) for everything outside /api (on Cloudflare the asset layer does this, see docs/differences.md): the matching file when there is one, otherwise the app's
2
+ // index.html (200); the admin panel under /_/ falls back to its own index.html; /api keeps its JSON 404s.
3
+ const PASSTHROUGH = ["/api/", "/__void", "/cdn-cgi/"];
4
+ export interface AssetFetcher { fetch(req: Request): Promise<Response> }
5
+ export async function staticFallback(req: Request, assets: AssetFetcher | undefined): Promise<Response | null> {
6
+ const url = new URL(req.url); const path = url.pathname;
7
+ if (!assets || PASSTHROUGH.some((p) => path === p.slice(0, -1) || path.startsWith(p))) return null;
8
+ if (req.method === "GET" || req.method === "HEAD") {
9
+ const file = await assets.fetch(new Request(url, { method: req.method, headers: req.headers }));
10
+ if (file.status !== 404) return file;
11
+ }
12
+ const index = path === "/_" || path.startsWith("/_/") ? "/_/index.html" : "/index.html";
13
+ const shell = await assets.fetch(new Request(new URL(index, url), { method: "GET", headers: { accept: "text/html" } }));
14
+ if (!shell.ok) return null;
15
+ const headers = new Headers(shell.headers); headers.delete("content-length");
16
+ return new Response(shell.body, { status: 200, headers });
17
+ }
@@ -0,0 +1,118 @@
1
+ // S3-compatible file backend (settings.s3 / settings.backups.s3), the same subset of the R2Bucket API the server
2
+ // uses, over fetch with AWS Signature v4 (tools/filesystem/internal/s3blob semantics: path-style or virtual-host
3
+ // URLs, unsigned or sha256 payloads, ListObjectsV2). Chosen per request when settings.s3.enabled, so the rest of
4
+ // the code keeps talking to `STORAGE` whether it is R2 or S3.
5
+ export interface S3Config { enabled: boolean; bucket: string; region: string; endpoint: string; accessKey: string; secret: string; forcePathStyle: boolean }
6
+
7
+ const enc = new TextEncoder();
8
+ const hex = (buf: ArrayBuffer | Uint8Array) => Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
9
+ const sha256 = async (data: Uint8Array | string) => hex(await crypto.subtle.digest("SHA-256", typeof data === "string" ? enc.encode(data) : (data as unknown as ArrayBuffer)));
10
+ async function hmac(key: ArrayBuffer | Uint8Array, data: string): Promise<ArrayBuffer> {
11
+ const k = await crypto.subtle.importKey("raw", key as unknown as ArrayBuffer, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
12
+ return crypto.subtle.sign("HMAC", k, enc.encode(data));
13
+ }
14
+ // RFC 3986 encoding as the S3 canonical request wants it (space -> %20, keep unreserved)
15
+ export const rfc3986 = (s: string) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
16
+ const escapePath = (path: string) => path.split("/").map((seg) => rfc3986(decodeURIComponent(seg))).join("/");
17
+ export const amzDate = (d = new Date()) => d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
18
+
19
+ export async function signV4(req: { method: string; url: URL; headers: Record<string, string>; payloadHash: string }, cfg: { accessKey: string; secret: string; region: string }, now = new Date()): Promise<Record<string, string>> {
20
+ const headers: Record<string, string> = { ...req.headers, host: req.url.host, "x-amz-content-sha256": req.payloadHash, "x-amz-date": amzDate(now) };
21
+ const date = headers["x-amz-date"]!.slice(0, 8);
22
+ const names = Object.keys(headers).map((k) => k.toLowerCase()).sort();
23
+ const canonicalHeaders = names.map((k) => `${k}:${String(headers[Object.keys(headers).find((h) => h.toLowerCase() === k)!]).trim().replace(/\s+/g, " ")}\n`).join("");
24
+ const query = [...req.url.searchParams.entries()].map(([k, v]) => [rfc3986(k), rfc3986(v)] as const).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : 1)).map(([k, v]) => `${k}=${v}`).join("&");
25
+ const canonical = [req.method, escapePath(req.url.pathname), query, canonicalHeaders, names.join(";"), req.payloadHash].join("\n");
26
+ const scope = `${date}/${cfg.region}/s3/aws4_request`;
27
+ const toSign = ["AWS4-HMAC-SHA256", headers["x-amz-date"], scope, await sha256(canonical)].join("\n");
28
+ let key: ArrayBuffer = await hmac(enc.encode("AWS4" + cfg.secret), date);
29
+ for (const part of [cfg.region, "s3", "aws4_request"]) key = await hmac(key, part);
30
+ const signature = hex(await hmac(key, toSign));
31
+ headers.authorization = `AWS4-HMAC-SHA256 Credential=${cfg.accessKey}/${scope}, SignedHeaders=${names.join(";")}, Signature=${signature}`;
32
+ delete headers.host; // fetch sets it
33
+ return headers;
34
+ }
35
+
36
+ export class S3Error extends Error { constructor(public status: number, public code: string, message: string) { super(message); } }
37
+ const xmlText = (xml: string, tag: string) => { const m = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(xml); return m ? m[1]!.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&") : null; };
38
+
39
+ interface StoredObject { key: string; size: number; uploaded: Date; etag: string; httpMetadata: { contentType?: string } }
40
+ export class S3Bucket {
41
+ constructor(private cfg: S3Config) {}
42
+ private url(key: string, query: Record<string, string> = {}): URL {
43
+ const raw = this.cfg.endpoint.includes("://") ? this.cfg.endpoint : `https://${this.cfg.endpoint}`;
44
+ const ep = new URL(raw);
45
+ const path = key.split("/").map(rfc3986).join("/");
46
+ const u = this.cfg.forcePathStyle || !this.cfg.endpoint ? new URL(`${ep.protocol}//${ep.host}/${rfc3986(this.cfg.bucket)}${path ? "/" + path : ""}`) : new URL(`${ep.protocol}//${this.cfg.bucket}.${ep.host}/${path}`);
47
+ for (const [k, v] of Object.entries(query)) u.searchParams.set(k, v);
48
+ return u;
49
+ }
50
+ private async send(method: string, key: string, opts: { query?: Record<string, string>; headers?: Record<string, string>; body?: Uint8Array; allow404?: boolean } = {}): Promise<Response> {
51
+ const url = this.url(key, opts.query);
52
+ const payloadHash = opts.body ? await sha256(opts.body) : "UNSIGNED-PAYLOAD";
53
+ const headers = await signV4({ method, url, headers: { ...(opts.headers ?? {}) }, payloadHash }, this.cfg);
54
+ const res = await fetch(url, { method, headers, body: opts.body as unknown as BodyInit | undefined });
55
+ if (res.status >= 400 && !(opts.allow404 && res.status === 404)) {
56
+ const text = await res.text();
57
+ throw new S3Error(res.status, xmlText(text, "Code") ?? String(res.status), xmlText(text, "Message") ?? text.slice(0, 200) ?? `HTTP ${res.status}`);
58
+ }
59
+ return res;
60
+ }
61
+ private meta(key: string, res: Response): StoredObject {
62
+ const cr = /bytes \d+-\d+\/(\d+)/.exec(res.headers.get("content-range") ?? "");
63
+ return { key, size: cr ? Number(cr[1]) : Number(res.headers.get("content-length") ?? 0), uploaded: new Date(res.headers.get("last-modified") ?? Date.now()), etag: (res.headers.get("etag") ?? "").replace(/"/g, ""), httpMetadata: { contentType: res.headers.get("content-type") ?? undefined } };
64
+ }
65
+ async head(key: string): Promise<StoredObject | null> {
66
+ const res = await this.send("HEAD", key, { allow404: true });
67
+ if (res.status === 404) return null;
68
+ return this.meta(key, res);
69
+ }
70
+ async get(key: string, opts?: { range?: { offset: number; length: number } }): Promise<(StoredObject & { body: ReadableStream; arrayBuffer(): Promise<ArrayBuffer>; text(): Promise<string>; json<T>(): Promise<T> }) | null> {
71
+ const headers: Record<string, string> = {};
72
+ if (opts?.range) headers.range = `bytes=${opts.range.offset}-${opts.range.offset + opts.range.length - 1}`;
73
+ const res = await this.send("GET", key, { headers, allow404: true });
74
+ if (res.status === 404) return null;
75
+ const m = this.meta(key, res);
76
+ return { ...m, body: res.body!, arrayBuffer: () => res.arrayBuffer(), text: () => res.text(), json: <T>() => res.json() as Promise<T> };
77
+ }
78
+ async put(key: string, value: ArrayBuffer | Uint8Array | string | ReadableStream | Blob | null, opts?: { httpMetadata?: { contentType?: string } }): Promise<StoredObject> {
79
+ let bytes: Uint8Array;
80
+ if (value === null) bytes = new Uint8Array();
81
+ else if (typeof value === "string") bytes = enc.encode(value);
82
+ else if (value instanceof Uint8Array) bytes = value;
83
+ else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value);
84
+ else if (value instanceof Blob) bytes = new Uint8Array(await value.arrayBuffer());
85
+ else bytes = new Uint8Array(await new Response(value).arrayBuffer());
86
+ const headers: Record<string, string> = { "content-length": String(bytes.byteLength) };
87
+ if (opts?.httpMetadata?.contentType) headers["content-type"] = opts.httpMetadata.contentType;
88
+ const res = await this.send("PUT", key, { headers, body: bytes });
89
+ return { key, size: bytes.byteLength, uploaded: new Date(), etag: (res.headers.get("etag") ?? "").replace(/"/g, ""), httpMetadata: { contentType: opts?.httpMetadata?.contentType } };
90
+ }
91
+ async delete(keys: string | string[]): Promise<void> {
92
+ for (const key of Array.isArray(keys) ? keys : [keys]) await this.send("DELETE", key);
93
+ }
94
+ async list(opts: { prefix?: string; cursor?: string; limit?: number } = {}): Promise<{ objects: StoredObject[]; truncated: boolean; cursor?: string; delimitedPrefixes: string[] }> {
95
+ const query: Record<string, string> = { "list-type": "2", "max-keys": String(opts.limit ?? 1000) };
96
+ if (opts.prefix) query.prefix = opts.prefix;
97
+ if (opts.cursor) query["continuation-token"] = opts.cursor;
98
+ const xml = await (await this.send("GET", "", { query })).text();
99
+ const objects: StoredObject[] = [];
100
+ for (const m of xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)) {
101
+ const c = m[1]!;
102
+ objects.push({ key: xmlText(c, "Key") ?? "", size: Number(xmlText(c, "Size") ?? 0), uploaded: new Date(xmlText(c, "LastModified") ?? Date.now()), etag: (xmlText(c, "ETag") ?? "").replace(/"/g, ""), httpMetadata: {} });
103
+ }
104
+ const truncated = xmlText(xml, "IsTruncated") === "true";
105
+ return { objects, truncated, cursor: truncated ? xmlText(xml, "NextContinuationToken") ?? undefined : undefined, delimitedPrefixes: [] };
106
+ }
107
+ // forms/test_s3_filesystem.go: connect and list to prove the credentials and bucket work
108
+ async test(): Promise<void> { await this.list({ limit: 1 }); }
109
+ }
110
+
111
+ export const s3Bucket = (cfg: S3Config) => new S3Bucket(cfg) as unknown as R2Bucket;
112
+
113
+ // Outside a request (queue jobs, crons) the settings.s3 swap that the bootstrap middleware does per request
114
+ export async function withS3Storage<E extends { DB: D1Database; STORAGE: R2Bucket }>(env: E): Promise<E> {
115
+ const { loadSettings } = await import("../settings");
116
+ const s3 = (await loadSettings(env.DB)).s3;
117
+ return s3.enabled ? { ...env, STORAGE: s3Bucket(s3) } : env;
118
+ }
@@ -0,0 +1,25 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { Collection } from "./collections/model";
3
+
4
+ export type Row = Record<string, unknown>;
5
+
6
+ export interface AuthRecord {
7
+ collection: Collection;
8
+ row: Row;
9
+ }
10
+
11
+ export interface Bindings {
12
+ DB: D1Database;
13
+ STORAGE: R2Bucket;
14
+ // optional Cloudflare bindings, declared by the deploy (queues/jobs.ts, wrangler ratelimits / analytics_engine_datasets)
15
+ QUEUE_JOBS?: { send(body: unknown, options?: { delaySeconds?: number }): Promise<void> };
16
+ RATE_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> };
17
+ LOGS_ANALYTICS?: { writeDataPoint(point: { blobs?: string[]; doubles?: number[]; indexes?: string[] }): void };
18
+ HUB?: DurableObjectNamespace; // the instance's realtime hub (src/server/hub.ts)
19
+ }
20
+
21
+ export interface Variables {
22
+ auth: AuthRecord | null;
23
+ }
24
+
25
+ export type AppEnv = { Bindings: Bindings; Variables: Variables };
@@ -0,0 +1,168 @@
1
+ // Passkeys for the starter: the same four routes its Go backend (pb/webauthn/webauthn.go, go-webauthn) exposes,
2
+ // implemented with @simplewebauthn/server on Workers. Same request/response contract, same error strings.
3
+ // Credentials live in the app's `passkeys` collection (user, credential_id, credentials) exactly like the Go code;
4
+ // the pending challenge lives in _params (the Go version keeps it in memory, which a Worker cannot rely on).
5
+ import type { Context, Hono } from "hono";
6
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
7
+ import { recordAuthResponse } from "./auth-response";
8
+ import { loadCollections } from "./collections/model";
9
+ import { all, ident, one, run } from "./db";
10
+ import { nowString, randomId } from "./ids";
11
+ import { loadSettings } from "./settings";
12
+ import type { AppEnv, Row } from "./types";
13
+
14
+ const RESPONSES = {
15
+ failed: "Failed to authenticate",
16
+ reg_error: "Failed to register",
17
+ login_error: "Failed to login",
18
+ reg_success: "Successfully registered",
19
+ cred_error: "Failed to save credentials",
20
+ };
21
+ const SESSION_TTL_MS = 5 * 60 * 1000;
22
+
23
+ type Transport = "ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb";
24
+ interface StoredCredential { id: string; publicKey: string; counter: number; transports?: Transport[]; deviceType?: string; backedUp?: boolean }
25
+
26
+ const b64url = {
27
+ encode: (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""),
28
+ decode: (s: string) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "=")), (ch) => ch.charCodeAt(0)),
29
+ };
30
+ const b64std = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes));
31
+
32
+ // RP settings: PocketBase's Meta.AppURL when configured, otherwise the origin the browser is talking to.
33
+ async function relyingParty(c: Context<AppEnv>) {
34
+ const settings = await loadSettings(c.env.DB);
35
+ const meta = (settings as { meta?: { appURL?: string; appName?: string } }).meta ?? {};
36
+ const configured = meta.appURL && !/^https?:\/\/localhost:8090\/?$/.test(meta.appURL) ? meta.appURL.replace(/\/$/, "") : "";
37
+ const origin = configured || c.req.header("Origin") || new URL(c.req.url).origin;
38
+ return { origin, rpID: new URL(origin).hostname, rpName: meta.appName || "voidbase" };
39
+ }
40
+
41
+ async function findUser(db: D1Database, usernameOrEmail: string): Promise<Row | null> {
42
+ const users = (await loadCollections(db)).get("users");
43
+ if (!users) return null;
44
+ const hasUsername = users.fields.some((f) => f.name === "username");
45
+ const sql = hasUsername ? `SELECT * FROM ${ident("users")} WHERE username = ?1 OR email = ?1 LIMIT 1` : `SELECT * FROM ${ident("users")} WHERE email = ?1 LIMIT 1`;
46
+ return one<Row>(db, sql, [usernameOrEmail]);
47
+ }
48
+
49
+ async function credentialsOf(db: D1Database, userId: string): Promise<{ row: Row; cred: StoredCredential }[]> {
50
+ const rows = await all<Row>(db, `SELECT * FROM ${ident("passkeys")} WHERE user = ?`, [userId]);
51
+ const out: { row: Row; cred: StoredCredential }[] = [];
52
+ for (const row of rows) {
53
+ try {
54
+ const cred = (typeof row.credentials === "string" ? JSON.parse(row.credentials) : row.credentials) as StoredCredential;
55
+ if (cred && cred.id && cred.publicKey) out.push({ row, cred });
56
+ } catch { /* not ours */ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ async function saveCredential(db: D1Database, userId: string, cred: StoredCredential) {
62
+ const credId = b64std(b64url.decode(cred.id)); // the Go code stores base64.StdEncoding of the raw id
63
+ const existing = await one<Row>(db, `SELECT id FROM ${ident("passkeys")} WHERE credential_id = ? LIMIT 1`, [credId]);
64
+ const now = nowString();
65
+ if (existing) await run(db, `UPDATE ${ident("passkeys")} SET credentials = ?, updated = ? WHERE id = ?`, [JSON.stringify(cred), now, existing.id]);
66
+ else await run(db, `INSERT INTO ${ident("passkeys")} (id, user, label, credential_id, credentials, created, updated) VALUES (?, ?, '', ?, ?, ?, ?)`, [randomId(), userId, credId, JSON.stringify(cred), now, now]);
67
+ }
68
+
69
+ async function putSession(db: D1Database, userId: string, challenge: string) {
70
+ const now = nowString();
71
+ await run(db, "INSERT OR REPLACE INTO `_params` (id, value, created, updated) VALUES (?, ?, ?, ?)", [`webauthn:session:${userId}`, JSON.stringify({ challenge, expires: Date.now() + SESSION_TTL_MS }), now, now]);
72
+ }
73
+ async function takeSession(db: D1Database, userId: string): Promise<string | null> {
74
+ const row = await one<{ value: string }>(db, "SELECT value FROM `_params` WHERE id = ?", [`webauthn:session:${userId}`]);
75
+ if (!row) return null;
76
+ await run(db, "DELETE FROM `_params` WHERE id = ?", [`webauthn:session:${userId}`]);
77
+ const s = JSON.parse(row.value) as { challenge: string; expires: number };
78
+ return s.expires > Date.now() ? s.challenge : null;
79
+ }
80
+
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 }) {
82
+ app.get("/api/webauthn/registration-options", async (c) => {
83
+ const user = await findUser(c.env.DB, c.req.query("usernameOrEmail") ?? "");
84
+ if (!user) return c.json(RESPONSES.failed, 400);
85
+ try {
86
+ const rp = await relyingParty(c);
87
+ const existing = await credentialsOf(c.env.DB, String(user.id));
88
+ const options = await generateRegistrationOptions({
89
+ rpName: rp.rpName, rpID: rp.rpID,
90
+ userID: new TextEncoder().encode(String(user.id)),
91
+ userName: String(user.username || user.email || user.id),
92
+ userDisplayName: String(user.name ?? ""),
93
+ excludeCredentials: existing.map((e) => ({ id: e.cred.id, transports: e.cred.transports })),
94
+ authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
95
+ });
96
+ await putSession(c.env.DB, String(user.id), options.challenge);
97
+ return c.json({ publicKey: options });
98
+ } catch (err) {
99
+ console.error("voidbase: webauthn registration-options", err);
100
+ return c.json(RESPONSES.reg_error, 500);
101
+ }
102
+ });
103
+
104
+ app.post("/api/webauthn/register", async (c) => {
105
+ const body = await c.req.json().catch(() => ({})) as Record<string, unknown>;
106
+ const user = await findUser(c.env.DB, String(body.usernameOrEmail ?? ""));
107
+ if (!user) return c.json(RESPONSES.failed, 400);
108
+ try {
109
+ const rp = await relyingParty(c);
110
+ const challenge = await takeSession(c.env.DB, String(user.id));
111
+ if (!challenge) return c.json(RESPONSES.reg_error, 500);
112
+ const { usernameOrEmail: _u, ...response } = body;
113
+ const verification = await verifyRegistrationResponse({ response: response as never, expectedChallenge: challenge, expectedOrigin: rp.origin, expectedRPID: rp.rpID, requireUserVerification: false });
114
+ if (!verification.verified || !verification.registrationInfo) return c.json(RESPONSES.reg_error, 500);
115
+ const info = verification.registrationInfo;
116
+ const cred: StoredCredential = {
117
+ id: info.credential.id, publicKey: b64url.encode(info.credential.publicKey), counter: info.credential.counter,
118
+ transports: info.credential.transports as Transport[] | undefined, deviceType: info.credentialDeviceType, backedUp: info.credentialBackedUp,
119
+ };
120
+ try { await saveCredential(c.env.DB, String(user.id), cred); } catch (err) { console.error("voidbase: webauthn save", err); return c.json(RESPONSES.cred_error, 500); }
121
+ return c.json(RESPONSES.reg_success);
122
+ } catch (err) {
123
+ console.error("voidbase: webauthn register", err);
124
+ return c.json(RESPONSES.reg_error, 500);
125
+ }
126
+ });
127
+
128
+ app.get("/api/webauthn/login-options", async (c) => {
129
+ const user = await findUser(c.env.DB, c.req.query("usernameOrEmail") ?? "");
130
+ if (!user) return c.json(RESPONSES.failed, 400);
131
+ try {
132
+ const rp = await relyingParty(c);
133
+ 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 })) });
135
+ await putSession(c.env.DB, String(user.id), options.challenge);
136
+ return c.json({ publicKey: options });
137
+ } catch (err) {
138
+ console.error("voidbase: webauthn login-options", err);
139
+ return c.json(RESPONSES.login_error, 500);
140
+ }
141
+ });
142
+
143
+ app.post("/api/webauthn/login", async (c) => {
144
+ const body = await c.req.json().catch(() => ({})) as Record<string, unknown>;
145
+ const user = await findUser(c.env.DB, String(body.usernameOrEmail ?? ""));
146
+ if (!user) return c.json(RESPONSES.failed, 400);
147
+ try {
148
+ const rp = await relyingParty(c);
149
+ const challenge = await takeSession(c.env.DB, String(user.id));
150
+ if (!challenge) return c.json(RESPONSES.login_error, 500);
151
+ const { usernameOrEmail: _u, ...response } = body;
152
+ const match = (await credentialsOf(c.env.DB, String(user.id))).find((e) => e.cred.id === response.id);
153
+ if (!match) return c.json(RESPONSES.login_error, 500);
154
+ const verification = await verifyAuthenticationResponse({
155
+ response: response as never, expectedChallenge: challenge, expectedOrigin: rp.origin, expectedRPID: rp.rpID, requireUserVerification: false,
156
+ credential: { id: match.cred.id, publicKey: b64url.decode(match.cred.publicKey), counter: match.cred.counter, transports: match.cred.transports },
157
+ });
158
+ if (!verification.verified) return c.json(RESPONSES.login_error, 500);
159
+ try { await saveCredential(c.env.DB, String(user.id), { ...match.cred, counter: verification.authenticationInfo.newCounter }); } catch { return c.json(RESPONSES.cred_error, 500); }
160
+ const users = (await loadCollections(c.env.DB)).get("users")!;
161
+ const { recordContextFor } = await import("./app");
162
+ return recordAuthResponse(c, await recordContextFor(c), users, user, "passkey", { body });
163
+ } catch (err) {
164
+ console.error("voidbase: webauthn login", err);
165
+ return c.json(RESPONSES.login_error, 500);
166
+ }
167
+ });
168
+ }