@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,113 @@
1
+ // OTP (apis/record_auth_otp_request.go, record_auth_with_otp.go) and superuser impersonation.
2
+ import type { Context, Hono } from "hono";
3
+ import { newAuthToken, requireSuperuser } from "./auth";
4
+ import { recordAuthResponse } from "./auth-response";
5
+ import type { Collection } from "./collections/model";
6
+ import { all, ident, one, run, stmt } from "./db";
7
+ import { ApiError, badRequest, forbidden, notFound } from "./errors";
8
+ import { nowString, randomId } from "./ids";
9
+ import { sendRecordOTP } from "./mail";
10
+ import { hashPassword, verifyPassword } from "./password";
11
+ import { updateRecord, type RecordContext } from "./records/service";
12
+ import { requestHook } from "./hooks/runtime";
13
+ import { CollectionRef, HookRecord } from "./hooks/record";
14
+ import type { AppEnv, Row } from "./types";
15
+
16
+ type Fe = { code: string; message: string; params?: Record<string, unknown> };
17
+ const REQUIRED: Fe = { code: "validation_required", message: "Cannot be blank." };
18
+ const lengthErr = (min: number, max: number): Fe => ({ code: "validation_length_out_of_range", message: `The length must be between ${min} and ${max}.`, params: { max, min } });
19
+ const validationFailed = (errs: Record<string, Fe>) => new ApiError(400, "An error occurred while validating the submitted data.", Object.fromEntries(Object.entries(errs).sort(([a], [b]) => (a < b ? -1 : 1))) as never);
20
+ const opt = <T>(c: Collection, path: string, fallback: T): T => { let cur: unknown = c.options; for (const k of path.split(".")) { if (!cur || typeof cur !== "object") return fallback; cur = (cur as Record<string, unknown>)[k]; } return (cur === undefined || cur === null ? fallback : cur) as T; };
21
+ async function readBody(c: Context<AppEnv>): Promise<Record<string, unknown>> {
22
+ try { const v = await c.req.json(); if (!v || typeof v !== "object") throw new Error(); return v as Record<string, unknown>; }
23
+ catch { throw badRequest("An error occurred while loading the submitted data."); }
24
+ }
25
+ const createdMs = (row: Row) => Date.parse(String(row.created).replace(" ", "T"));
26
+
27
+ export function mountAuthExtra(app: Hono<AppEnv>, deps: { collection: (c: Context<AppEnv>) => Promise<Collection>; ctx: (c: Context<AppEnv>) => Promise<RecordContext> }) {
28
+ app.post("/api/collections/:collection/request-otp", async (c) => {
29
+ const collection = await deps.collection(c);
30
+ if (!opt<boolean>(collection, "otp.enabled", false)) throw forbidden("The collection is not configured to allow OTP authentication.");
31
+ const body = await readBody(c);
32
+ const email = String(body.email ?? "");
33
+ if (!email) throw validationFailed({ email: REQUIRED });
34
+ if (email.length > 255) throw validationFailed({ email: lengthErr(1, 255) });
35
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw validationFailed({ email: { code: "validation_is_email", message: "Must be a valid email address." } });
36
+ const row = await one<Row>(c.env.DB, `SELECT * FROM ${ident(collection.name)} WHERE email = ? LIMIT 1`, [email]);
37
+ const length = Number(opt<number>(collection, "otp.length", 8)) || 8;
38
+ const durationMs = (Number(opt<number>(collection, "otp.duration", 180)) || 180) * 1000;
39
+ const digits = crypto.getRandomValues(new Uint8Array(length));
40
+ const generated = [...digits].map((d) => String(d % 10)).join("");
41
+ return requestHook("onRecordRequestOTPRequest", c, collection.name, { collection: new CollectionRef(collection), record: row ? HookRecord.fromRow(collection, row) : null, password: generated }, async (ev) => {
42
+ const pass = String(ev.password ?? generated);
43
+ if (!row) return c.json({ otpId: randomId() }); // same shape for unknown emails, like PocketBase
44
+ // too many recent OTPs: reuse the newest instead of issuing another (and drop the expired ones while here)
45
+ const existing = await all<Row>(c.env.DB, "SELECT * FROM `_otps` WHERE collectionRef = ? AND recordRef = ? ORDER BY created DESC", [collection.id, String(row.id)]);
46
+ const expired = existing.filter((o) => Date.now() - createdMs(o) > durationMs);
47
+ if (expired.length) await c.env.DB.batch(expired.map((o) => stmt(c.env.DB, "DELETE FROM `_otps` WHERE id = ?", [o.id])));
48
+ const recent = existing.filter((o) => Date.now() - createdMs(o) <= durationMs);
49
+ if (recent.length > 9) return c.json({ otpId: String(existing[0]!.id) });
50
+ const id = randomId(); const now = nowString();
51
+ await run(c.env.DB, "INSERT INTO `_otps` (id, collectionRef, recordRef, password, sentTo, created, updated) VALUES (?, ?, ?, ?, '', ?, ?)", [id, collection.id, String(row.id), await hashPassword(pass), now, now]);
52
+ c.executionCtx.waitUntil((async () => {
53
+ try {
54
+ await sendRecordOTP(c.env.DB, collection, row, id, pass);
55
+ await run(c.env.DB, "UPDATE `_otps` SET sentTo = ?, updated = ? WHERE id = ? AND sentTo = ''", [String(row.email), nowString(), id]);
56
+ } catch (err) { console.error("voidbase: failed to send OTP email", err); await run(c.env.DB, "DELETE FROM `_otps` WHERE id = ?", [id]); }
57
+ })());
58
+ return c.json({ otpId: id });
59
+ });
60
+ });
61
+
62
+ app.post("/api/collections/:collection/auth-with-otp", async (c) => {
63
+ const collection = await deps.collection(c);
64
+ if (!opt<boolean>(collection, "otp.enabled", false)) throw forbidden("The collection is not configured to allow OTP authentication.");
65
+ const body = await readBody(c);
66
+ const otpId = String(body.otpId ?? ""), password = String(body.password ?? "");
67
+ const errs: Record<string, Fe> = {};
68
+ if (!otpId) errs.otpId = REQUIRED; else if (otpId.length > 255) errs.otpId = lengthErr(1, 255);
69
+ if (!password) errs.password = REQUIRED; else if (password.length > 71) errs.password = lengthErr(1, 71);
70
+ if (Object.keys(errs).length) throw validationFailed(errs);
71
+ const otp = await one<Row>(c.env.DB, "SELECT * FROM `_otps` WHERE id = ? LIMIT 1", [otpId]);
72
+ if (!otp || otp.collectionRef !== collection.id) throw badRequest("Invalid or expired OTP");
73
+ const durationMs = (Number(opt<number>(collection, "otp.duration", 180)) || 180) * 1000;
74
+ if (Date.now() - createdMs(otp) > durationMs) throw badRequest("Invalid or expired OTP");
75
+ const row = await one<Row>(c.env.DB, `SELECT * FROM ${ident(collection.name)} WHERE id = ? LIMIT 1`, [otp.recordRef]);
76
+ if (!row) throw badRequest("Invalid or expired OTP");
77
+ // 5 attempts per 180s per record (checkRateLimit "@pb_otp_<id>")
78
+ const key = `@pb_otp_${row.id}`;
79
+ const bucket = await one<{ value: string }>(c.env.DB, "SELECT value FROM `_params` WHERE id = ?", [key]);
80
+ let state = bucket ? (JSON.parse(bucket.value) as { count: number; resetAt: number }) : { count: 0, resetAt: Date.now() + 180_000 };
81
+ if (state.resetAt < Date.now()) state = { count: 0, resetAt: Date.now() + 180_000 };
82
+ state.count++;
83
+ await run(c.env.DB, "INSERT OR REPLACE INTO `_params` (id, value, created, updated) VALUES (?, ?, ?, ?)", [key, JSON.stringify(state), nowString(), nowString()]);
84
+ if (state.count > 5) throw new ApiError(429, "Too many attempts, please try again later with a new OTP.", {});
85
+ if (!(await verifyPassword(password, String(otp.password ?? "")))) throw badRequest("Invalid or expired OTP");
86
+ const ctx = await deps.ctx(c);
87
+ return requestHook("onRecordAuthWithOTPRequest", c, collection.name, { collection: new CollectionRef(collection), record: HookRecord.fromRow(collection, row), otp }, async () => {
88
+ await run(c.env.DB, "DELETE FROM `_otps` WHERE id = ?", [otpId]);
89
+ let fresh = row;
90
+ if (!row.verified && otp.sentTo && String(row.email) === String(otp.sentTo)) {
91
+ const patch: Record<string, unknown> = { verified: true };
92
+ if (!opt<boolean>(collection, "mfa.enabled", false)) { const pw = randomString30(); patch.password = pw; patch.passwordConfirm = pw; }
93
+ try { await updateRecord({ ...ctx, superuser: true, hookEvent: undefined }, collection, String(row.id), patch, {} as never); fresh = (await one<Row>(c.env.DB, `SELECT * FROM ${ident(collection.name)} WHERE id = ? LIMIT 1`, [row.id])) ?? row; }
94
+ catch (err) { console.error("voidbase: failed to update record verified state after OTP", err); }
95
+ }
96
+ return recordAuthResponse(c, { ...ctx, request: { ...ctx.request, context: "otp" } }, collection, fresh, "otp", { body });
97
+ });
98
+ });
99
+
100
+ app.post("/api/collections/:collection/impersonate/:id", async (c) => {
101
+ requireSuperuser(c); // RequireSuperuserAuth middleware: 401 anonymous, 403 for other records
102
+ const collection = await deps.collection(c);
103
+ const row = await one<Row>(c.env.DB, `SELECT * FROM ${ident(collection.name)} WHERE id = ? LIMIT 1`, [c.req.param("id") ?? ""]);
104
+ if (!row) throw notFound();
105
+ const body = await readBody(c);
106
+ const duration = Number(body.duration ?? 0);
107
+ if (duration < 0) throw validationFailed({ duration: { code: "validation_min_greater_equal_than_required", message: "Must be no less than 0.", params: { threshold: 0 } } });
108
+ const token = await newAuthToken({ collection, row }, false, duration > 0 ? duration : undefined);
109
+ const ctx = await deps.ctx(c);
110
+ return recordAuthResponse(c, ctx, collection, row, "", { token, body });
111
+ });
112
+ }
113
+ const randomString30 = () => { const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; return [...crypto.getRandomValues(new Uint8Array(30))].map((b) => chars[b % chars.length]).join(""); };
@@ -0,0 +1,186 @@
1
+ // Email-driven auth flows (apis/record_auth_{verification,password_reset,email_change}_{request,confirm}.go):
2
+ // request endpoints always answer 204 (unknown emails and resend limits are hidden, like PocketBase), confirm
3
+ // endpoints validate the signed token and apply the change through the record service so hooks and realtime fire.
4
+ import { requestHook } from "./hooks/runtime";
5
+ import { CollectionRef, HookRecord } from "./hooks/record";
6
+ import type { Context, Hono } from "hono";
7
+ import { findAuthRecordByToken } from "./auth";
8
+ import { verifyPassword } from "./password";
9
+ import type { Collection } from "./collections/model";
10
+ import { ident, one, run } from "./db";
11
+ import { ApiError, badRequest, unauthorized, forbidden } from "./errors";
12
+ import { nowString, randomString } from "./ids";
13
+ import { sendRecordChangeEmail, sendRecordPasswordReset, sendRecordVerification } from "./mail";
14
+ import { updateRecord, type RecordContext } from "./records/service";
15
+ import type { AppEnv, Row } from "./types";
16
+
17
+ type Fe = { code: string; message: string; params?: Record<string, unknown> };
18
+ const REQUIRED: Fe = { code: "validation_required", message: "Cannot be blank." };
19
+ const isEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
20
+ const validationFailed = (errs: Record<string, Fe>) => new ApiError(400, "An error occurred while validating the submitted data.", Object.fromEntries(Object.entries(errs).sort(([a], [b]) => (a < b ? -1 : 1))) as never);
21
+ const RESEND_TTL_MS = 2 * 60 * 1000;
22
+
23
+ async function readBody(c: Context<AppEnv>): Promise<Record<string, unknown>> {
24
+ try { const v = await c.req.json(); if (!v || typeof v !== "object") throw new Error(); return v as Record<string, unknown>; }
25
+ catch { throw badRequest("An error occurred while loading the submitted data."); }
26
+ }
27
+ const unverifiedClaims = (token: string): Record<string, unknown> => { try { const p = token.split(".")[1] ?? ""; return JSON.parse(atob(p.replace(/-/g, "+").replace(/_/g, "/"))) as Record<string, unknown>; } catch { return {}; } };
28
+
29
+ // PocketBase keeps the "already requested" flag in memory for 2 minutes; here it lives in _params so every isolate sees it
30
+ async function resendLimited(db: D1Database, key: string): Promise<boolean> {
31
+ const row = await one<{ value: string }>(db, "SELECT value FROM `_params` WHERE id = ?", [key]);
32
+ if (!row) return false;
33
+ if (Number(row.value) > Date.now()) return true;
34
+ await run(db, "DELETE FROM `_params` WHERE id = ?", [key]);
35
+ return false;
36
+ }
37
+ const markResend = (db: D1Database, key: string) => run(db, "INSERT OR REPLACE INTO `_params` (id, value, created, updated) VALUES (?, ?, ?, ?)", [key, String(Date.now() + RESEND_TTL_MS), nowString(), nowString()]);
38
+ const clearResend = (db: D1Database, key: string) => run(db, "DELETE FROM `_params` WHERE id = ?", [key]);
39
+
40
+ const superCtx = (ctx: RecordContext): RecordContext => ({ ...ctx, superuser: true, hookEvent: undefined, request: { ...ctx.request, context: "default" } });
41
+ const passwordEnabled = (c: Collection) => ((c.options as Record<string, unknown>).passwordAuth as { enabled?: boolean } | undefined)?.enabled !== false;
42
+ const passwordMin = (c: Collection) => { const f = c.fields.find((x) => x.name === "password") as { min?: number } | undefined; return f?.min && f.min > 0 ? f.min : 1; };
43
+
44
+ export function mountAuthFlows(app: Hono<AppEnv>, deps: { collection: (c: Context<AppEnv>) => Promise<Collection>; ctx: (c: Context<AppEnv>) => Promise<RecordContext> }) {
45
+ app.post("/api/collections/:collection/request-verification", async (c) => {
46
+ const collection = await deps.collection(c);
47
+ if (collection.name === "_superusers") throw badRequest("All superusers are verified by default.");
48
+ const body = await readBody(c);
49
+ const email = String(body.email ?? "");
50
+ if (!email) throw validationFailed({ email: REQUIRED });
51
+ if (email.length > 255) throw validationFailed({ email: { code: "validation_length_out_of_range", message: "The length must be between 1 and 255.", params: { max: 255, min: 1 } } });
52
+ if (!isEmail(email)) throw validationFailed({ email: { code: "validation_is_email", message: "Must be a valid email address." } });
53
+ const row = await one<Row>(c.env.DB, `SELECT * FROM ${ident(collection.name)} WHERE email = ? LIMIT 1`, [email]);
54
+ if (!row) return c.body(null, 204);
55
+ const key = `@limitVerificationEmail_${collection.id}${row.id}`;
56
+ if (!row.verified && (await resendLimited(c.env.DB, key))) return c.body(null, 204);
57
+ return requestHook("onRecordRequestVerificationRequest", c, collection.name, { collection: new CollectionRef(collection), record: HookRecord.fromRow(collection, row) }, async () => {
58
+ if (row.verified) return c.body(null, 204);
59
+ c.executionCtx.waitUntil((async () => {
60
+ try { await sendRecordVerification(c.env.DB, collection, row); await markResend(c.env.DB, key); } catch (err) { console.error("voidbase: failed to send verification email", err); }
61
+ })());
62
+ return c.body(null, 204);
63
+ });
64
+ });
65
+
66
+ app.post("/api/collections/:collection/confirm-verification", async (c) => {
67
+ const collection = await deps.collection(c);
68
+ if (collection.name === "_superusers") throw badRequest("All superusers are verified by default.");
69
+ const body = await readBody(c);
70
+ const token = String(body.token ?? "");
71
+ if (!token) throw validationFailed({ token: REQUIRED });
72
+ const claims = unverifiedClaims(token);
73
+ if (!claims.email) throw validationFailed({ token: { code: "validation_invalid_token_claims", message: "Missing email token claim." } });
74
+ const auth = await findAuthRecordByToken(c.env.DB, token, "verification");
75
+ if (!auth) throw validationFailed({ token: { code: "validation_invalid_token", message: "Invalid or expired token." } });
76
+ if (auth.collection.id !== collection.id) throw validationFailed({ token: { code: "validation_token_collection_mismatch", message: "The provided token is for different auth collection." } });
77
+ if (String(auth.row.email ?? "") !== String(claims.email)) throw validationFailed({ token: { code: "validation_token_email_mismatch", message: "The record email doesn't match with the requested token claims." } });
78
+ return requestHook("onRecordConfirmVerificationRequest", c, collection.name, { collection: new CollectionRef(collection), record: HookRecord.fromRow(collection, auth.row) }, async () => {
79
+ if (!auth.row.verified) {
80
+ const patch: Record<string, unknown> = { verified: true };
81
+ if (!passwordEnabled(collection)) { const pw = randomString(30); patch.password = pw; patch.passwordConfirm = pw; }
82
+ try { await updateRecord(superCtx(await deps.ctx(c)), collection, String(auth.row.id), patch, {} as never); }
83
+ catch (err) { if (err instanceof ApiError) throw new ApiError(400, "An error occurred while saving the verified state.", err.data as never); throw err; }
84
+ }
85
+ await clearResend(c.env.DB, `@limitVerificationEmail_${collection.id}${auth.row.id}`);
86
+ return c.body(null, 204);
87
+ });
88
+ });
89
+
90
+ app.post("/api/collections/:collection/request-password-reset", async (c) => {
91
+ const collection = await deps.collection(c);
92
+ if (!passwordEnabled(collection)) throw badRequest("The collection is not configured to allow password authentication.");
93
+ const body = await readBody(c);
94
+ const email = String(body.email ?? "");
95
+ if (!email) throw validationFailed({ email: REQUIRED });
96
+ if (email.length > 255) throw validationFailed({ email: { code: "validation_length_out_of_range", message: "The length must be between 1 and 255.", params: { max: 255, min: 1 } } });
97
+ if (!isEmail(email)) throw validationFailed({ email: { code: "validation_is_email", message: "Must be a valid email address." } });
98
+ const row = await one<Row>(c.env.DB, `SELECT * FROM ${ident(collection.name)} WHERE email = ? LIMIT 1`, [email]);
99
+ if (!row) return c.body(null, 204);
100
+ const key = `@limitPasswordResetEmail_${collection.id}${row.id}`;
101
+ if (await resendLimited(c.env.DB, key)) return c.body(null, 204);
102
+ return requestHook("onRecordRequestPasswordResetRequest", c, collection.name, { collection: new CollectionRef(collection), record: HookRecord.fromRow(collection, row) }, async () => {
103
+ c.executionCtx.waitUntil((async () => {
104
+ try { await sendRecordPasswordReset(c.env.DB, collection, row); await markResend(c.env.DB, key); } catch (err) { console.error("voidbase: failed to send password reset email", err); }
105
+ })());
106
+ return c.body(null, 204);
107
+ });
108
+ });
109
+
110
+ app.post("/api/collections/:collection/confirm-password-reset", async (c) => {
111
+ const collection = await deps.collection(c);
112
+ const body = await readBody(c);
113
+ const token = String(body.token ?? ""), password = String(body.password ?? ""), confirm = String(body.passwordConfirm ?? "");
114
+ const errs: Record<string, Fe> = {};
115
+ let auth: Awaited<ReturnType<typeof findAuthRecordByToken>> = null;
116
+ if (!token) errs.token = REQUIRED;
117
+ else {
118
+ auth = await findAuthRecordByToken(c.env.DB, token, "passwordReset");
119
+ if (!auth) errs.token = { code: "validation_invalid_token", message: "Invalid or expired token." };
120
+ else if (auth.collection.id !== collection.id) errs.token = { code: "validation_token_collection_mismatch", message: "The provided token is for different auth collection." };
121
+ }
122
+ const min = passwordMin(collection);
123
+ if (!password) errs.password = REQUIRED; else if (password.length < min || password.length > 255) errs.password = { code: "validation_length_out_of_range", message: `The length must be between ${min} and 255.`, params: { max: 255, min } };
124
+ if (!confirm) errs.passwordConfirm = REQUIRED; else if (confirm !== password) errs.passwordConfirm = { code: "validation_values_mismatch", message: "Values don't match." };
125
+ if (Object.keys(errs).length) throw validationFailed(errs);
126
+ const found = auth!;
127
+ return requestHook("onRecordConfirmPasswordResetRequest", c, collection.name, { collection: new CollectionRef(collection), record: HookRecord.fromRow(collection, found.row) }, async () => {
128
+ const patch: Record<string, unknown> = { password, passwordConfirm: confirm };
129
+ if (!found.row.verified && String(found.row.email ?? "") === String(unverifiedClaims(token).email ?? "")) patch.verified = true;
130
+ try { await updateRecord(superCtx(await deps.ctx(c)), collection, String(found.row.id), patch, {} as never); }
131
+ catch (err) { if (err instanceof ApiError) throw new ApiError(400, "Failed to set new password.", err.data as never); throw err; }
132
+ await clearResend(c.env.DB, `@limitPasswordResetEmail_${collection.id}${found.row.id}`);
133
+ return c.body(null, 204);
134
+ });
135
+ });
136
+
137
+ app.post("/api/collections/:collection/request-email-change", async (c) => {
138
+ const collection = await deps.collection(c);
139
+ if (collection.name === "_superusers") throw badRequest("All superusers can change their emails directly.");
140
+ const auth = c.get("auth");
141
+ if (!auth) throw unauthorized("The request requires valid record authorization token.");
142
+ if (auth.collection.id !== collection.id) throw forbidden(`The request requires auth record from ${auth.collection.name} collection.`); // RequireSameCollectionContextAuth
143
+ const body = await readBody(c);
144
+ const newEmail = String(body.newEmail ?? "");
145
+ if (!newEmail) throw validationFailed({ newEmail: REQUIRED });
146
+ if (newEmail.length > 255) throw validationFailed({ newEmail: { code: "validation_length_out_of_range", message: "The length must be between 1 and 255.", params: { max: 255, min: 1 } } });
147
+ if (!isEmail(newEmail)) throw validationFailed({ newEmail: { code: "validation_is_email", message: "Must be a valid email address." } });
148
+ if (newEmail === String(auth.row.email ?? "")) throw validationFailed({ newEmail: { code: "validation_not_in_invalid", message: "Must not be in list." } });
149
+ const taken = await one<Row>(c.env.DB, `SELECT id FROM ${ident(collection.name)} WHERE email = ? LIMIT 1`, [newEmail]);
150
+ if (taken && taken.id !== auth.row.id) throw validationFailed({ newEmail: { code: "validation_invalid_new_email", message: "Invalid new email address." } });
151
+ return requestHook("onRecordRequestEmailChangeRequest", c, collection.name, { collection: new CollectionRef(collection), record: HookRecord.fromRow(collection, auth.row), newEmail }, async (ev) => {
152
+ try { await sendRecordChangeEmail(c.env.DB, collection, auth.row, String(ev.newEmail ?? newEmail)); }
153
+ catch (err) { throw badRequest("Failed to request email change."); void err; }
154
+ return c.body(null, 204);
155
+ });
156
+ });
157
+
158
+ app.post("/api/collections/:collection/confirm-email-change", async (c) => {
159
+ const collection = await deps.collection(c);
160
+ if (collection.name === "_superusers") throw badRequest("All superusers can change their emails directly.");
161
+ const body = await readBody(c);
162
+ const token = String(body.token ?? ""), password = String(body.password ?? "");
163
+ const errs: Record<string, Fe> = {};
164
+ let auth: Awaited<ReturnType<typeof findAuthRecordByToken>> = null; let newEmail = "";
165
+ const parse = async (): Promise<Fe | null> => {
166
+ newEmail = String(unverifiedClaims(token).newEmail ?? "");
167
+ if (!newEmail) return { code: "validation_invalid_token_payload", message: "Invalid token payload - newEmail must be set." };
168
+ auth = await findAuthRecordByToken(c.env.DB, token, "emailChange");
169
+ if (!auth) return { code: "validation_invalid_token", message: "Invalid or expired token." };
170
+ if (auth.collection.id !== collection.id) return { code: "validation_token_collection_mismatch", message: "The provided token is for different auth collection." };
171
+ if (await one(c.env.DB, `SELECT id FROM ${ident(collection.name)} WHERE email = ? LIMIT 1`, [newEmail])) return { code: "validation_invalid_token_email", message: "The new email address is invalid." };
172
+ return null;
173
+ };
174
+ if (!token) errs.token = REQUIRED; else { const e = await parse(); if (e) errs.token = e; }
175
+ if (!password) errs.password = REQUIRED;
176
+ else if (password.length > 100) errs.password = { code: "validation_length_out_of_range", message: "The length must be between 1 and 100.", params: { max: 100, min: 1 } };
177
+ else if (!auth || !(await verifyPassword(password, String((auth as { row: Row }).row.password ?? "")))) errs.password = { code: "validation_invalid_password", message: "Missing or invalid auth record password." };
178
+ if (Object.keys(errs).length) throw validationFailed(errs);
179
+ const found = auth! as { row: Row };
180
+ return requestHook("onRecordConfirmEmailChangeRequest", c, collection.name, { collection: new CollectionRef(collection), record: HookRecord.fromRow(collection, found.row), newEmail }, async (ev) => {
181
+ try { await updateRecord(superCtx(await deps.ctx(c)), collection, String(found.row.id), { email: String(ev.newEmail ?? newEmail), verified: true }, {} as never); }
182
+ catch (err) { if (err instanceof ApiError) throw new ApiError(400, "Failed to confirm email change.", err.data as never); throw err; }
183
+ return c.body(null, 204);
184
+ });
185
+ });
186
+ }
@@ -0,0 +1,111 @@
1
+ // apis/record_helpers.go recordAuthResponse: every successful authentication (password, OAuth2, OTP, passkey,
2
+ // refresh, impersonate) ends here. Superuser IP allowlist, the collection's authRule, OnRecordAuthRequest, the
3
+ // MFA handshake (401 {mfaId} until a second method confirms), record export with email, expand, login alert.
4
+ import type { Context } from "hono";
5
+ import { newAuthToken } from "./auth";
6
+ import type { Collection } from "./collections/model";
7
+ import { one, run } from "./db";
8
+ import { badRequest, forbidden } from "./errors";
9
+ import { HookRecord } from "./hooks/record";
10
+ import { trigger } from "./hooks/runtime";
11
+ import { nowString, randomId } from "./ids";
12
+ import { sendRecordAuthAlert } from "./mail";
13
+ import { enrich, recordMatchesRule, type RecordContext } from "./records/service";
14
+ import { rowToValues } from "./records/values";
15
+ import { loadSettings } from "./settings";
16
+ import type { AppEnv, Row } from "./types";
17
+
18
+ const opt = <T>(c: Collection, path: string, fallback: T): T => { let cur: unknown = c.options; for (const k of path.split(".")) { if (!cur || typeof cur !== "object") return fallback; cur = (cur as Record<string, unknown>)[k]; } return (cur === undefined || cur === null ? fallback : cur) as T; };
19
+
20
+ export interface AuthResponseOptions { token?: string; body?: Record<string, unknown>; meta?: Record<string, unknown> }
21
+
22
+ export async function recordAuthResponse(c: Context<AppEnv>, ctx: RecordContext, collection: Collection, row: Row, method: string, options: AuthResponseOptions = {}): Promise<Response> {
23
+ const token = options.token ?? (await newAuthToken({ collection, row }));
24
+ const db = c.env.DB;
25
+ if (collection.name === "_superusers") {
26
+ const settings = await loadSettings(db);
27
+ if (settings.superuserIPs.length && !ipInList(settings.superuserIPs, realIPWith(settings, c))) throw forbidden();
28
+ }
29
+ // authRule: "" lets everyone in, a filter restricts, null (superusers only) blocks regular logins
30
+ const rawRule = (collection.options as Record<string, unknown>).authRule;
31
+ const authRule = rawRule === undefined ? "" : (rawRule as string | null);
32
+ if (authRule === null) { if (!ctx.superuser) throw forbidden("The request doesn't satisfy the collection requirements to authenticate."); }
33
+ else if (authRule.trim() !== "") {
34
+ const ok = await recordMatchesRule(ctx, collection, authRule, rowToValues(collection, row));
35
+ if (!ok) throw forbidden("The request doesn't satisfy the collection requirements to authenticate.");
36
+ }
37
+
38
+ const rec = HookRecord.fromRow(collection, row);
39
+ const ev = { app: undefined as unknown, collection, record: rec, token, meta: options.meta, authMethod: method, written: false, next: async () => undefined as unknown };
40
+ let response: Response | null = null;
41
+ await trigger("onRecordAuthRequest", ev, collection.name, async () => {
42
+ const mfaId = await checkMFA(c, ctx, collection, row, method, options.body ?? {});
43
+ if (mfaId) { response = c.json({ mfaId }, 401); return; }
44
+ const authCtx: RecordContext = { ...ctx, auth: { collection, row }, superuser: ctx.superuser || collection.name === "_superusers" };
45
+ const own = { ...authCtx, request: { ...authCtx.request, auth: { collection, row } } };
46
+ const [json] = await enrich(own, collection, [row], { expand: c.req.query("expand") ?? "" });
47
+ const exported = json as Record<string, unknown>;
48
+ if (!("email" in exported)) exported.email = row.email ?? ""; // IgnoreEmailVisibility(true)
49
+ if (method !== "" && opt<boolean>(collection, "authAlert.enabled", false)) {
50
+ try { await authAlert(c, collection, row); } catch (err) { console.warn("voidbase: failed to send login alert", err); }
51
+ }
52
+ const result: Record<string, unknown> = {};
53
+ if (ev.meta !== undefined && ev.meta !== null) result.meta = ev.meta;
54
+ result.record = sortKeys(exported);
55
+ result.token = ev.token;
56
+ response = c.json(result);
57
+ });
58
+ return response ?? c.body(null, 204);
59
+ }
60
+
61
+ const sortKeys = (o: Record<string, unknown>) => Object.fromEntries(Object.entries(o).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
62
+
63
+ export { realIP } from "./hardening";
64
+ import { ipInList, realIP, realIPWith } from "./hardening";
65
+
66
+ // checkMFA: first method opens an MFA session (401 {mfaId}); a *different* method within the duration closes it
67
+ async function checkMFA(c: Context<AppEnv>, ctx: RecordContext, collection: Collection, row: Row, method: string, body: Record<string, unknown>): Promise<string> {
68
+ if (!opt<boolean>(collection, "mfa.enabled", false) || method === "") return "";
69
+ const rule = opt<string>(collection, "mfa.rule", "");
70
+ if (rule.trim() !== "") {
71
+ let wants = true;
72
+ try { wants = await recordMatchesRule(ctx, collection, rule, rowToValues(collection, row)); } catch { throw badRequest("Failed to authenticate."); }
73
+ if (!wants) return "";
74
+ }
75
+ const mfaId = c.req.query("mfaId") || String(body.mfaId ?? "");
76
+ const db = c.env.DB;
77
+ if (!mfaId) {
78
+ const id = randomId(); const now = nowString();
79
+ await run(db, "INSERT INTO `_mfas` (id, collectionRef, recordRef, method, created, updated) VALUES (?, ?, ?, ?, ?, ?)", [id, collection.id, String(row.id), method, now, now]);
80
+ return id;
81
+ }
82
+ const mfa = await one<Row>(db, "SELECT * FROM `_mfas` WHERE id = ? LIMIT 1", [mfaId]);
83
+ const duration = Number(opt<number>(collection, "mfa.duration", 1800)) * 1000;
84
+ if (!mfa || Date.now() - Date.parse(String(mfa.created).replace(" ", "T")) > duration) {
85
+ if (mfa) await run(db, "DELETE FROM `_mfas` WHERE id = ?", [mfaId]);
86
+ throw badRequest("Invalid or expired MFA session.");
87
+ }
88
+ if (mfa.recordRef !== row.id || mfa.collectionRef !== collection.id) throw badRequest("Invalid MFA session.");
89
+ if (mfa.method === method) throw badRequest("A different authentication method is required.");
90
+ await run(db, "DELETE FROM `_mfas` WHERE id = ?", [mfaId]);
91
+ return "";
92
+ }
93
+
94
+ // authAlert: fingerprint = hash(ip + user agent); a new fingerprint on a record with previous origins mails the alert
95
+ async function authAlert(c: Context<AppEnv>, collection: Collection, row: Row) {
96
+ const db = c.env.DB;
97
+ const ip = await realIP(c);
98
+ let ua = c.req.header("User-Agent") ?? "";
99
+ if (ua.length > 200) ua = ua.slice(0, 200) + "...";
100
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ip + ua)));
101
+ const fingerprint = [...digest.slice(0, 16)].map((b) => b.toString(16).padStart(2, "0")).join("");
102
+ const info = `${nowString()} - ${ip} ${ua}`;
103
+ const origins = await (await import("./db")).all<Row>(db, "SELECT * FROM `_authOrigins` WHERE collectionRef = ? AND recordRef = ?", [collection.id, String(row.id)]);
104
+ const isFirstLogin = origins.length === 0;
105
+ const known = origins.some((o) => o.fingerprint === fingerprint);
106
+ if (!known) {
107
+ const now = nowString();
108
+ await run(db, "INSERT INTO `_authOrigins` (id, collectionRef, recordRef, fingerprint, created, updated) VALUES (?, ?, ?, ?, ?, ?)", [randomId(), collection.id, String(row.id), fingerprint, now, now]);
109
+ if (!isFirstLogin && row.email) await sendRecordAuthAlert(db, collection, row, info);
110
+ }
111
+ }
@@ -0,0 +1,187 @@
1
+ import { parseFields, pick } from "./records/picker";
2
+ import type { Context } from "hono";
3
+ import { findCollection, isAuth, option, SUPERUSERS, type Collection } from "./collections/model";
4
+ import { one } from "./db";
5
+ import { ident } from "./db";
6
+ import { ApiError, badRequest, forbidden, unauthorized, V, validationFailed, type FieldErrors } from "./errors";
7
+ import { decodeJWT, signJWT, verifyJWT } from "./jwt";
8
+ import { verifyPassword } from "./password";
9
+ import { recordToJSON } from "./records/json";
10
+ import { buildAuthURL, providerConfig, s256Challenge } from "./oauth2";
11
+ import catalog from "./collections/oauth2-providers.json";
12
+ import { randomString } from "./ids";
13
+ import { recordAuthResponse } from "./auth-response";
14
+ import { requestHook } from "./hooks/runtime";
15
+ import { CollectionRef, HookRecord } from "./hooks/record";
16
+ import type { AppEnv, AuthRecord, Row } from "./types";
17
+
18
+ export function tokenFromRequest(req: Request): string {
19
+ const h = req.headers.get("Authorization") ?? "";
20
+ return /^bearer /i.test(h) ? h.slice(7) : h;
21
+ }
22
+
23
+ // Resolve the auth record for a token: decode, load collection + record, verify with tokenKey + collection secret.
24
+ export async function findAuthRecordByToken(db: D1Database, token: string, type = "auth"): Promise<AuthRecord | null> {
25
+ if (!token) return null;
26
+ const claims = decodeJWT(token);
27
+ if (!claims || claims.type !== type || typeof claims.id !== "string" || typeof claims.collectionId !== "string") return null;
28
+ const collection = await findCollection(db, claims.collectionId);
29
+ if (!collection || !isAuth(collection)) return null;
30
+ const row = await one(db, `SELECT * FROM ${ident(collection.name)} WHERE id = ? LIMIT 1`, [claims.id]);
31
+ if (!row) return null;
32
+ // each token type is signed with its own collection secret (core/record_tokens.go)
33
+ const optionKey = ({ auth: "authToken", verification: "verificationToken", passwordReset: "passwordResetToken", emailChange: "emailChangeToken", file: "fileToken" } as Record<string, string>)[type] ?? "authToken";
34
+ const secret = option<string>(collection, `${optionKey}.secret`, "");
35
+ const verified = await verifyJWT(token, String(row.tokenKey ?? "") + secret);
36
+ return verified ? { collection, row } : null;
37
+ }
38
+
39
+ export async function loadAuth(c: Context<AppEnv>): Promise<AuthRecord | null> {
40
+ const token = tokenFromRequest(c.req.raw);
41
+ return token ? findAuthRecordByToken(c.env.DB, token) : null;
42
+ }
43
+
44
+ export const isSuperuser = (auth: AuthRecord | null | undefined) => !!auth && auth.collection.name === SUPERUSERS;
45
+
46
+ export function requireAuth(c: Context<AppEnv>): AuthRecord {
47
+ const auth = c.get("auth");
48
+ if (!auth) throw unauthorized("The request requires valid record authorization token.");
49
+ return auth;
50
+ }
51
+
52
+ export function requireSuperuser(c: Context<AppEnv>): AuthRecord {
53
+ const auth = c.get("auth");
54
+ if (!auth) throw unauthorized("The request requires valid record authorization token.");
55
+ if (!isSuperuser(auth)) throw forbidden("The authorized record is not allowed to perform this action.");
56
+ return auth;
57
+ }
58
+
59
+ export async function newAuthToken(auth: AuthRecord, refreshable = true, durationOverride?: number): Promise<string> {
60
+ const secret = option<string>(auth.collection, "authToken.secret", "");
61
+ const duration = durationOverride ?? option<number>(auth.collection, "authToken.duration", 604800);
62
+ const key = String(auth.row.tokenKey ?? "") + secret;
63
+ if (!key) throw new ApiError(500, "Missing signing key.");
64
+ return signJWT({ collectionId: auth.collection.id, id: String(auth.row.id), refreshable, type: "auth" }, key, duration);
65
+ }
66
+
67
+ export async function authResponse(auth: AuthRecord, meta?: unknown) {
68
+ const token = await newAuthToken(auth);
69
+ const record = recordToJSON(auth.collection, auth.row, { auth, own: true });
70
+ return meta === undefined ? { record, token } : { meta, record, token };
71
+ }
72
+
73
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
74
+
75
+ async function readBody(c: Context<AppEnv>): Promise<Record<string, unknown>> {
76
+ const ct = c.req.header("content-type") ?? "";
77
+ try {
78
+ if (ct.includes("application/json")) return (await c.req.json()) ?? {};
79
+ if (ct.includes("form")) return Object.fromEntries((await c.req.formData()).entries());
80
+ const text = await c.req.text();
81
+ return text ? JSON.parse(text) : {};
82
+ } catch {
83
+ throw badRequest("An error occurred while loading the submitted data.");
84
+ }
85
+ }
86
+
87
+ // POST /api/collections/:collection/auth-with-password
88
+ export async function authWithPassword(c: Context<AppEnv>, collection: Collection) {
89
+ if (!isAuth(collection)) throw c.get("auth") ? forbidden() : unauthorized("The request requires valid record authorization token.");
90
+ if (!option<boolean>(collection, "passwordAuth.enabled", false)) {
91
+ throw forbidden("The collection is not configured to allow password authentication.");
92
+ }
93
+ const body = await readBody(c);
94
+ const identity = String(body.identity ?? "");
95
+ const password = String(body.password ?? "");
96
+ const identityField = body.identityField ? String(body.identityField) : "";
97
+ const errors: FieldErrors = {};
98
+ if (!identity) errors.identity = V.required;
99
+ else if (identity.length > 255) errors.identity = V.length(1, 255);
100
+ if (!password) errors.password = V.required;
101
+ else if (password.length > 255) errors.password = V.length(1, 255);
102
+ const identityFields = option<string[]>(collection, "passwordAuth.identityFields", ["email"]);
103
+ if (identityField && !identityFields.includes(identityField)) {
104
+ errors.identityField = { code: "validation_in_invalid", message: "Must be a valid value." };
105
+ }
106
+ if (Object.keys(errors).length) throw validationFailed(errors);
107
+
108
+ let row: Row | null = null;
109
+ const candidates = identityField ? [identityField] : identityFields;
110
+ for (const name of candidates) {
111
+ if (name === "email" && !EMAIL_RE.test(identity)) continue;
112
+ row = await one(c.env.DB, `SELECT * FROM ${ident(collection.name)} WHERE ${ident(name)} = ? LIMIT 1`, [identity]);
113
+ if (row) break;
114
+ }
115
+ const { recordContextFor } = await import("./app");
116
+ const ctx = await recordContextFor(c);
117
+ const original = row ? HookRecord.fromRow(collection, row) : null;
118
+ return requestHook("onRecordAuthWithPasswordRequest", c, collection.name, { collection: new CollectionRef(collection), record: original, identity, password, identityField }, async (ev) => {
119
+ // hooks may swap e.record; the password is checked against whatever record they leave
120
+ let target = row;
121
+ if (ev.record && ev.record !== original) target = await one(c.env.DB, `SELECT * FROM ${ident(collection.name)} WHERE id = ? LIMIT 1`, [String((ev.record as HookRecord).id)]);
122
+ else if (!ev.record) target = null;
123
+ const ok = target ? await verifyPassword(String(ev.password ?? ""), String(target.password ?? "")) : await dummyPasswordCheck();
124
+ if (!target || !ok) throw badRequest("Failed to authenticate.");
125
+ return recordAuthResponse(c, { ...ctx, request: { ...ctx.request, context: "password" } }, collection, target, "password", { body });
126
+ });
127
+ }
128
+
129
+ // Burn roughly the same time as a real check so missing accounts are not distinguishable by timing.
130
+ async function dummyPasswordCheck(): Promise<false> {
131
+ await verifyPassword("dummy", "$2a$10$KBcKN2Yv4i5cs/Q1I.nfkeBXf1RqxyRmiPJPLHxCXDqmlt6SlP4Ea");
132
+ return false;
133
+ }
134
+
135
+ // POST /api/collections/:collection/auth-refresh
136
+ export async function authRefresh(c: Context<AppEnv>, collection: Collection) {
137
+ const auth = c.get("auth");
138
+ if (!auth) throw unauthorized("The request requires valid record authorization token.");
139
+ // RequireSameCollectionContextAuth names the token's own collection in its message
140
+ if (auth.collection.id !== collection.id) throw forbidden(`The request requires auth record from ${auth.collection.name} collection.`);
141
+ const { recordContextFor } = await import("./app");
142
+ const ctx = await recordContextFor(c);
143
+ return requestHook("onRecordAuthRefreshRequest", c, collection.name, { collection: new CollectionRef(collection), record: HookRecord.fromRow(collection, auth.row) }, () => recordAuthResponse(c, ctx, collection, auth.row, "", {}));
144
+ }
145
+
146
+ // GET /api/collections/:collection/auth-methods
147
+ const providerLogo = (name: string) => (catalog as { name: string; logo: string }[]).find((p) => p.name === name)?.logo ?? "";
148
+
149
+ export async function authMethods(c: Context<AppEnv>, collection: Collection) {
150
+ if (!isAuth(collection)) throw badRequest("The collection is not configured to allow authentication.");
151
+ const identityFields = option<string[]>(collection, "passwordAuth.identityFields", ["email"]);
152
+ const mfaEnabled = option<boolean>(collection, "mfa.enabled", false);
153
+ const otpEnabled = option<boolean>(collection, "otp.enabled", false);
154
+ const oauth2Enabled = option<boolean>(collection, "oauth2.enabled", false);
155
+ const providers: Record<string, unknown>[] = [];
156
+ if (oauth2Enabled) {
157
+ for (const cfg of option<{ name: string }[]>(collection, "oauth2.providers", [])) {
158
+ const p = providerConfig(collection, cfg.name);
159
+ if (!p || !p.authURL || !p.tokenURL) continue; // PocketBase skips providers it cannot init
160
+ const info: Record<string, unknown> = { name: p.name, displayName: p.displayName, logo: providerLogo(p.name), state: randomString(30), authURL: "", authUrl: "", codeVerifier: "", codeChallenge: "", codeChallengeMethod: "" };
161
+ const extra: Record<string, string> = {};
162
+ if (p.name === "apple") extra.response_mode = "form_post";
163
+ if (p.pkce) {
164
+ info.codeVerifier = randomString(43);
165
+ info.codeChallenge = await s256Challenge(String(info.codeVerifier));
166
+ info.codeChallengeMethod = "S256";
167
+ extra.code_challenge = String(info.codeChallenge); extra.code_challenge_method = "S256";
168
+ }
169
+ info.authURL = buildAuthURL(p, String(info.state), extra) + "&redirect_uri="; // empty redirect_uri so clients can append theirs
170
+ info.authUrl = info.authURL;
171
+ providers.push(info);
172
+ }
173
+ }
174
+ const passwordEnabled = option<boolean>(collection, "passwordAuth.enabled", false);
175
+ const body: Record<string, unknown> = {
176
+ password: { identityFields, enabled: passwordEnabled },
177
+ oauth2: { providers, enabled: oauth2Enabled },
178
+ mfa: { enabled: mfaEnabled, duration: mfaEnabled ? option<number>(collection, "mfa.duration", 0) : 0 },
179
+ otp: { enabled: otpEnabled, duration: otpEnabled ? option<number>(collection, "otp.duration", 0) : 0 },
180
+ // legacy fields PocketBase still fills (fillLegacyFields); the SDK strips them with ?fields=mfa,otp,password,oauth2
181
+ authProviders: oauth2Enabled ? providers.map((p) => ({ ...p, logo: "" })) : null,
182
+ usernamePassword: passwordEnabled && identityFields.includes("username"),
183
+ emailPassword: passwordEnabled && identityFields.includes("email"),
184
+ };
185
+ const fields = (c.req.query("fields") ?? "").trim();
186
+ return c.json((fields ? pick(body, parseFields(fields)) : body) as Record<string, unknown>);
187
+ }