@rebasepro/server 0.21.0 → 0.21.1

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 (56) hide show
  1. package/README.md +29 -11
  2. package/dist/api/errors.d.ts +41 -0
  3. package/dist/auth/admin-user-ops.d.ts +23 -1
  4. package/dist/auth/auth-hooks.d.ts +4 -0
  5. package/dist/auth/reset-password-admin.d.ts +4 -0
  6. package/dist/auth/token-revocation.d.ts +39 -5
  7. package/dist/{auth-gzoRjnuh.js → auth-BS4WBA10.js} +1636 -1565
  8. package/dist/auth-BS4WBA10.js.map +1 -0
  9. package/dist/backup/backup-common.d.ts +2 -1
  10. package/dist/{backup-DGu0v9Ku.js → backup-DzI9jLwc.js} +38 -10
  11. package/dist/backup-DzI9jLwc.js.map +1 -0
  12. package/dist/collections/validate-config.d.ts +4 -2
  13. package/dist/{cron-routes-BamgO0Xr.js → cron-routes-B_wlLybo.js} +3 -3
  14. package/dist/{cron-routes-BamgO0Xr.js.map → cron-routes-B_wlLybo.js.map} +1 -1
  15. package/dist/{cron-scheduler-D47tdB9T.js → cron-scheduler-COPQxlEq.js} +59 -10
  16. package/dist/cron-scheduler-COPQxlEq.js.map +1 -0
  17. package/dist/{cron-store-yXOyIEmn.js → cron-store-Dp0X7zRB.js} +2 -2
  18. package/dist/{cron-store-yXOyIEmn.js.map → cron-store-Dp0X7zRB.js.map} +1 -1
  19. package/dist/ddl-bootstrap-CfNvxMuK.js.map +1 -1
  20. package/dist/deploy/pod-contract.d.ts +14 -0
  21. package/dist/{errors-DMImyqyR.js → errors-DWsX4yTd.js} +55 -18
  22. package/dist/errors-DWsX4yTd.js.map +1 -0
  23. package/dist/{function-routes-C4nB2h0z.js → function-routes-Chet4-lB.js} +2 -2
  24. package/dist/{function-routes-C4nB2h0z.js.map → function-routes-Chet4-lB.js.map} +1 -1
  25. package/dist/functions/index.js.map +1 -1
  26. package/dist/{history-recorder-hBGuXcZc.js → history-recorder-5VKT7nss.js} +2 -2
  27. package/dist/{history-recorder-hBGuXcZc.js.map → history-recorder-5VKT7nss.js.map} +1 -1
  28. package/dist/{history-store-C9EtGtoL.js → history-store-By2R_G1b.js} +2 -2
  29. package/dist/{history-store-C9EtGtoL.js.map → history-store-By2R_G1b.js.map} +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.es.js +286 -245
  32. package/dist/index.es.js.map +1 -1
  33. package/dist/init/middlewares.d.ts +19 -1
  34. package/dist/init/storage.d.ts +17 -0
  35. package/dist/init.d.ts +6 -2
  36. package/dist/{jobs-DZ8DLLcU.js → jobs-1IkI4Lle.js} +2 -2
  37. package/dist/{jobs-DZ8DLLcU.js.map → jobs-1IkI4Lle.js.map} +1 -1
  38. package/dist/{logs-routes-DnJINsMu.js → logs-routes-3EEzPjhl.js} +2 -2
  39. package/dist/{logs-routes-DnJINsMu.js.map → logs-routes-3EEzPjhl.js.map} +1 -1
  40. package/dist/{openapi-generator-CAFKF36E.js → openapi-generator-BDpoKZQ9.js} +2 -2
  41. package/dist/{openapi-generator-CAFKF36E.js.map → openapi-generator-BDpoKZQ9.js.map} +1 -1
  42. package/dist/{query-parser-uqzkZ82i.js → query-parser-C-rl30ce.js} +3 -3
  43. package/dist/{query-parser-uqzkZ82i.js.map → query-parser-C-rl30ce.js.map} +1 -1
  44. package/dist/{request-timeout-BR-OBwES.js → request-timeout-C_4C2BeR.js} +2 -2
  45. package/dist/{request-timeout-BR-OBwES.js.map → request-timeout-C_4C2BeR.js.map} +1 -1
  46. package/dist/{schema-editor-routes-BKOmdf4M.js → schema-editor-routes-DdLihzp0.js} +2 -2
  47. package/dist/{schema-editor-routes-BKOmdf4M.js.map → schema-editor-routes-DdLihzp0.js.map} +1 -1
  48. package/dist/src-Br6ARbs6.js.map +1 -1
  49. package/dist/{src-DW1cQ_28.js → src-Caz7nREj.js} +2 -3
  50. package/dist/src-Caz7nREj.js.map +1 -0
  51. package/package.json +5 -5
  52. package/dist/auth-gzoRjnuh.js.map +0 -1
  53. package/dist/backup-DGu0v9Ku.js.map +0 -1
  54. package/dist/cron-scheduler-D47tdB9T.js.map +0 -1
  55. package/dist/errors-DMImyqyR.js.map +0 -1
  56. package/dist/src-DW1cQ_28.js.map +0 -1
@@ -3,13 +3,13 @@ import __rebaseProcess from "process";
3
3
  globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
5
  import { i as __toESM, n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
6
- import { P as revokeInternalTableSql, at as isAnonymousUid, it as ANONYMOUS_USER_ID } from "./src-DW1cQ_28.js";
6
+ import { P as revokeInternalTableSql, at as isAnonymousUid, it as ANONYMOUS_USER_ID } from "./src-Caz7nREj.js";
7
7
  import "./src-Br6ARbs6.js";
8
8
  import { n as createDdlBootstrapper, o as isSQLAdmin } from "./ddl-bootstrap-CfNvxMuK.js";
9
9
  import { t as firstSqlRow } from "./sql-rows-C6GEc2oE.js";
10
10
  import { r as logger } from "./logger-DO2PZc4i.js";
11
- import { n as errorHandler, t as ApiError } from "./errors-DMImyqyR.js";
12
- import { a as resolveListLimitParam } from "./query-parser-uqzkZ82i.js";
11
+ import { r as errorHandler, t as ApiError } from "./errors-DWsX4yTd.js";
12
+ import { a as resolveListLimitParam } from "./query-parser-C-rl30ce.js";
13
13
  import { C as require_jsonwebtoken, E as randomInt$1, O as sha256Hex, _ as verifyAccessToken, b as verifyMfaPendingToken, c as getAccessTokenExpiry, d as getRefreshTokenTtlMs, f as hasAsymmetricSigningKey, i as generateDownloadToken, l as getJwks, m as isJwtConfigured, n as configureJwt, o as generateMfaPendingToken, p as hashRefreshToken, r as generateAccessToken, s as generateRefreshToken, t as MAX_COOKIE_AGE_MS, u as getRefreshTokenExpiry, v as verifyDownloadToken, w as constantTimeEqual } from "./jwt-C4OW-DNq.js";
14
14
  import { n as hasAdministrativeRole, r as isAdministrativeRole, t as ADMINISTRATIVE_ROLES } from "./admin-roles-vYdp_Pil.js";
15
15
  import { c as tryCanonicalStorageKey, i as canonicalStorageId } from "./keys-Qfc4XieN.js";
@@ -154,1687 +154,1766 @@ function isFunctionAllowed(permissions, functionName, operation) {
154
154
  return false;
155
155
  }
156
156
  //#endregion
157
- //#region src/auth/token-revocation.ts
157
+ //#region src/email/html.ts
158
158
  /**
159
- * Has this access token been revoked?
160
- *
161
- * `logout`, `change-password`, `reset-password` and `DELETE /auth/sessions` all
162
- * stamp a `tokensValidAfter` watermark on the user. Every one of them also
163
- * deletes refresh-token rows, which is what made the gap easy to miss: the
164
- * session really is gone, and the *refresh* path really does check the
165
- * watermark — so signing out looked like it worked.
166
- *
167
- * The access token was untouched. It is a bearer credential that nothing
168
- * consulted a database about, so it stayed valid for its full lifetime after
169
- * every one of those actions. "Sign out everywhere" left the stolen token
170
- * working; so did changing your password because you thought it had leaked.
171
- *
172
- * The watermark had exactly one read in the repository, on refresh. This adds
173
- * the other one.
174
- *
175
- * ## Cost
176
- *
177
- * One indexed lookup per request, on the paths that already make one. The
178
- * adapter re-reads roles for every authenticated request; the admin routes now
179
- * do too. Both call this with the row they were already fetching in mind — a
180
- * repository that wants to serve both from one query is free to cache
181
- * internally, but nothing here assumes it.
159
+ * HTML escaping for email bodies.
182
160
  *
183
- * ## Failure
161
+ * Every default template interpolates values the server does not control — a
162
+ * `displayName` chosen at registration, an `appName` from config, a link base a
163
+ * host set — into markup that is then mailed, signed by the sending domain, to
164
+ * an address the same request chose. Unescaped, that turns `POST /auth/register`
165
+ * into a way to deliver arbitrary HTML (a heading, an anchor) from a domain
166
+ * whose SPF and DKIM check out.
184
167
  *
185
- * Fails **open**, deliberately, and this is the one place in the auth stack
186
- * where that is right. The watermark is an extra revocation signal layered over
187
- * a token that has already been verified — signature, expiry and purpose all
188
- * checked. Refusing every request when the database is unreachable would turn a
189
- * transient outage into a total sign-out of an entire deployment, and the
190
- * attacker this protects against needs to have already stolen a live token. The
191
- * failure is logged at warn so it is visible rather than silent.
168
+ * The fix is deliberately not five escaping calls at five interpolation sites:
169
+ * a sixth template would simply forget. Templates are built with the {@link html}
170
+ * tag, which escapes *every* interpolated value by default, and markup that is
171
+ * meant to pass through verbatim the static style strings, a nested fragment —
172
+ * must say so with {@link raw}. Forgetting `raw` produces visibly escaped text
173
+ * in a test; forgetting to escape is invisible until someone exploits it.
192
174
  */
193
- async function isAccessTokenRevoked(authRepo, payload) {
194
- if (typeof authRepo.getTokensValidAfter !== "function") return false;
195
- if (typeof payload.iat !== "number") return false;
196
- let validAfter;
197
- try {
198
- validAfter = await authRepo.getTokensValidAfter(payload.uid);
199
- } catch (error) {
200
- logger.warn("[Auth] Could not read the token revocation watermark; allowing the request", {
201
- uid: payload.uid,
202
- error
203
- });
204
- return false;
205
- }
206
- if (!validAfter) return false;
207
- return payload.iat < Math.floor(validAfter.getTime() / 1e3);
208
- }
209
- //#endregion
210
- //#region src/auth/rls-scope.ts
211
- var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
212
175
  /**
213
- * The identity the trusted server plane runs as: `rebase.dataAsAdmin`, and any
214
- * caller presenting the service key.
215
- *
216
- * Naming it makes the privilege legible, because it is narrower than it reads.
217
- * This is **not** an RLS bypass — a driver scoped with it is an
218
- * `AuthenticatedPostgresBackendDriver`, so every statement runs in a
219
- * transaction that has done `SET LOCAL ROLE rebase_user` with
220
- * `app.uid = 'service'`, and policies are evaluated. Two consequences worth
221
- * knowing before you write a policy:
222
- *
223
- * - It passes the default policies through their `rolesOverlap(['admin'])`
224
- * arm — the same arm an application user holding the `admin` role passes.
225
- * - `policy.serverContext()` compiles to `rebase.uid() IS NULL` and is therefore
226
- * **false** for it. A collection with `disableDefaultPolicies: true` whose
227
- * only rule is `serverContext()` denies these writes (42501) and returns
228
- * zero rows for these reads.
176
+ * Markup that has already been vetted and must be interpolated verbatim.
229
177
  *
230
- * The true bypass is `rebase.sql()`, which runs on the owner connection and
231
- * never goes near `withAuth`.
178
+ * Only ever construct this from a string literal in this package's own source
179
+ * (or from the {@link html} tag, which produces one). Wrapping user input in
180
+ * `raw()` defeats the entire mechanism.
232
181
  */
233
- var SERVICE_IDENTITY = {
234
- uid: "service",
235
- roles: ["admin"]
182
+ var RawHtml = class {
183
+ value;
184
+ constructor(value) {
185
+ this.value = value;
186
+ }
187
+ toString() {
188
+ return this.value;
189
+ }
236
190
  };
237
191
  /**
238
- * Returns true if the driver supports RLS scoping via `withAuth()`.
192
+ * Mark a static, author-controlled string as safe to interpolate unescaped.
239
193
  */
240
- function isRLSScopedDriver(driver) {
241
- return "withAuth" in driver && typeof driver.withAuth === "function";
194
+ function raw(markup) {
195
+ return new RawHtml(markup);
242
196
  }
243
197
  /**
244
- * Can this driver run a request as a specific user?
245
- *
246
- * The same question `scopeDataDriver` asks internally, exported because one
247
- * caller must act on the answer BEFORE it has a request to scope.
198
+ * Escape the five characters that can change the meaning of HTML text or of a
199
+ * quoted attribute value.
248
200
  *
249
- * `scopeDataDriver` returns the driver unscoped when the answer is no, which is
250
- * the right behaviour for `/api/data` Mongo has no row-level security and
251
- * never claimed to, and the route's own checks still apply. It is the wrong
252
- * behaviour for a surface whose entire promise is "this runs as you": there,
253
- * an unscoped driver silently means "runs as the database owner". So the MCP
254
- * surface asks this at boot and declines to mount rather than degrade.
201
+ * `&` goes first: escaping it after the others would double-escape the entities
202
+ * they just produced. `'` is included because it is a legal attribute delimiter,
203
+ * and `"` because it is the one this file uses.
255
204
  */
256
- function supportsRlsScoping(driver) {
257
- return isRLSScopedDriver(driver);
205
+ function escapeHtml(value) {
206
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
258
207
  }
259
208
  /**
260
- * Scope a DataDriver via `withAuth()` for RLS.
261
- *
262
- * SECURITY: If `withAuth()` is available but fails, the error is re-thrown
263
- * so the request is **denied** rather than proceeding with unscoped access
264
- * (fail-closed behavior).
265
- *
266
- * If the driver does not support RLS, the original driver is returned.
267
- *
268
- * @param driver - The DataDriver to scope.
269
- * @param user - The authenticated user identity for RLS.
270
- * @returns The RLS-scoped DataDriver (or the original if RLS is unsupported).
209
+ * Render one interpolated value: pass {@link RawHtml} through, drop nullish,
210
+ * escape everything else.
271
211
  */
272
- async function scopeDataDriver(driver, user) {
273
- if (isRLSScopedDriver(driver)) return await driver.withAuth(user);
274
- return driver;
212
+ function interpolate(value) {
213
+ if (value instanceof RawHtml) return value.value;
214
+ if (value === null || value === void 0) return "";
215
+ return escapeHtml(String(value));
275
216
  }
276
- //#endregion
277
- //#region src/auth/crypto-utils.ts
278
- /**
279
- * Cryptographic utility functions for auth.
280
- *
281
- * @module
282
- */
283
217
  /**
284
- * Constant-time string comparison to prevent timing attacks.
285
- *
286
- * Used for comparing service keys, tokens, and other secrets where
287
- * timing side-channels could leak information about the expected value.
218
+ * Tagged template for email markup. Every `${...}` is escaped unless it is
219
+ * {@link RawHtml}.
288
220
  *
289
- * The implementation lives in `utils/portable-crypto.ts`, which does this over
290
- * UTF-8 bytes without `node:crypto` — this function is on the request path, and
291
- * the byte-level reasoning it depends on is documented there.
221
+ * Returns `RawHtml` so fragments nest without being escaped a second time:
292
222
  *
293
- * @param a - First string to compare.
294
- * @param b - Second string to compare.
295
- * @returns `true` if the strings are identical, `false` otherwise.
223
+ * ```ts
224
+ * const button = url ? html`<a href="${url}">Open</a>` : raw("");
225
+ * const body = html`<div>${button}</div>`;
226
+ * ```
296
227
  */
297
- function safeCompare(a, b) {
298
- return constantTimeEqual(a, b);
228
+ function html(strings, ...values) {
229
+ let out = strings[0];
230
+ for (let i = 0; i < values.length; i++) out += interpolate(values[i]) + strings[i + 1];
231
+ return new RawHtml(out);
299
232
  }
300
233
  //#endregion
301
- //#region src/auth/bearer-token.ts
302
- /**
303
- * Parsing of the `Authorization: Bearer <token>` header.
304
- *
305
- * Its own module rather than a helper inside `middleware.ts`, because the
306
- * API-key middleware needs it too and `middleware.ts` already imports from
307
- * there — putting it in either one would close an import cycle.
308
- *
309
- * @module
310
- */
234
+ //#region src/email/templates.ts
311
235
  /**
312
- * Pull the token out of an `Authorization: Bearer <token>` header.
313
- *
314
- * RFC 7235 §2.1 makes the auth-scheme name case-insensitive, and plenty of
315
- * clients send `bearer` — HTTP libraries that lower-case what they emit,
316
- * hand-written curl, proxies that normalise headers. Matching `"Bearer "`
317
- * exactly turned those into a 401 that, from the caller's side, is
318
- * indistinguishable from a rejected token; people spend an afternoon
319
- * regenerating credentials that were fine. Only the scheme is folded — the
320
- * token itself is returned byte-for-byte.
236
+ * Default email templates for authentication emails.
321
237
  *
322
- * Returns `undefined` when there is no header or it names another scheme.
323
- * That is deliberately distinct from an empty token (`Bearer ` with nothing
324
- * after it), which callers still route into verification so it fails as a bad
325
- * token rather than as a missing header.
326
- */
327
- function extractBearerToken(authHeader) {
328
- if (!authHeader) return void 0;
329
- const separator = authHeader.indexOf(" ");
330
- if (separator < 0) return void 0;
331
- if (authHeader.slice(0, separator).toLowerCase() !== "bearer") return void 0;
332
- return authHeader.slice(separator + 1);
333
- }
334
- //#endregion
335
- //#region src/auth/api-keys/api-key-middleware.ts
336
- /**
337
- * Check whether a token looks like a Rebase API key.
238
+ * Every HTML body is built with the `html` tag from `./html`, which escapes each
239
+ * interpolated value unless it is explicitly marked `raw`. `displayName` reaches
240
+ * these templates straight from the registration body, and the resulting mail is
241
+ * sent signed by the sending domain — to an address the same anonymous request
242
+ * chose, so an unescaped interpolation here is a phishing primitive, not a
243
+ * rendering glitch. Escaping lives in the tag rather than at the call sites so a
244
+ * sixth template inherits it instead of having to remember it.
338
245
  */
339
- function isApiKeyToken(token) {
340
- return token.startsWith("rk_");
341
- }
342
246
  /**
343
- * Hash a plaintext API key token for database lookup.
247
+ * Get a greeting name for the user.
344
248
  *
345
- * Async because it hashes with WebCrypto rather than `node:crypto` the sole
346
- * caller was already awaiting the store lookup this feeds.
249
+ * Returns the raw value `displayName` is whatever the account was registered
250
+ * with, including markup. Escaping is the `html` tag's job, so that a template
251
+ * that interpolates the greeting into text (the plain-text bodies below) and one
252
+ * that interpolates it into markup can share this.
347
253
  */
348
- function hashToken$1(token) {
349
- return sha256Hex(token);
254
+ function getGreeting(user) {
255
+ return user.displayName || user.email.split("@")[0];
350
256
  }
351
257
  /**
352
- * Validate an API key token and populate the Hono context.
353
- *
354
- * Returns `true` if the key is valid and context has been populated,
355
- * or returns an error Response if the key is invalid.
258
+ * Common email styles.
356
259
  *
357
- * This is NOT a standalone middlewareit's called from within
358
- * `createAuthMiddleware()` when a `rk_` prefixed token is detected.
260
+ * `raw` because these are static, author-written CSS the one category of
261
+ * interpolation the `html` tag lets through unescaped. (Escaping them would
262
+ * survive an HTML parser but not every mail client's CSS parser: the
263
+ * `'Segoe UI'` quotes would arrive as entities.)
359
264
  */
360
- async function validateApiKey(c, token, options) {
361
- const { store, driver } = options;
362
- const hash = await hashToken$1(token);
363
- const apiKey = await store.findByKeyHash(hash);
364
- if (!apiKey) return c.json({ error: {
365
- message: "Invalid API key",
366
- code: "UNAUTHORIZED"
367
- } }, 401);
368
- if (apiKey.revoked_at) return c.json({ error: {
369
- message: "API key has been revoked",
370
- code: "UNAUTHORIZED"
371
- } }, 401);
372
- if (apiKey.expires_at && new Date(apiKey.expires_at) < /* @__PURE__ */ new Date()) return c.json({ error: {
373
- message: "API key has expired",
374
- code: "UNAUTHORIZED"
375
- } }, 401);
376
- const uid = `api-key:${apiKey.id}`;
377
- const roles = apiKey.admin ? ["admin", "service"] : ["service"];
378
- c.set("user", {
379
- uid,
380
- roles
381
- });
382
- const masked = {
383
- id: apiKey.id,
384
- name: apiKey.name,
385
- key_prefix: apiKey.key_prefix,
386
- permissions: apiKey.permissions,
387
- admin: apiKey.admin,
388
- rate_limit: apiKey.rate_limit,
389
- created_by: apiKey.created_by,
390
- created_at: apiKey.created_at,
391
- updated_at: apiKey.updated_at,
392
- last_used_at: apiKey.last_used_at,
393
- expires_at: apiKey.expires_at,
394
- revoked_at: apiKey.revoked_at
395
- };
396
- c.set("apiKey", masked);
397
- try {
398
- const scopedDriver = await scopeDataDriver(driver, {
399
- uid,
400
- roles
401
- });
402
- c.set("driver", scopedDriver);
403
- } catch (error) {
404
- logger.error("[AUTH] RLS scoping failed for API key", { error });
405
- return c.json({ error: {
406
- message: "Internal authentication error",
407
- code: "INTERNAL_ERROR"
408
- } }, 500);
409
- }
410
- const lastTouch = lastUsedTouchedAt.get(apiKey.id);
411
- const now = Date.now();
412
- if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
413
- lastUsedTouchedAt.set(apiKey.id, now);
414
- store.updateLastUsed(apiKey.id).catch(() => {});
415
- }
416
- return true;
417
- }
418
- /** Per-process debounce state for last_used_at touches. */
419
- var lastUsedTouchedAt = /* @__PURE__ */ new Map();
420
- var LAST_USED_DEBOUNCE_MS = 6e4;
265
+ var styles = {
266
+ container: raw(`
267
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
268
+ max-width: 600px;
269
+ margin: 0 auto;
270
+ padding: 40px 20px;
271
+ background-color: #f8fafc;
272
+ `),
273
+ card: raw(`
274
+ background-color: #ffffff;
275
+ border-radius: 12px;
276
+ padding: 40px;
277
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
278
+ `),
279
+ heading: raw(`
280
+ color: #1e293b;
281
+ font-size: 24px;
282
+ font-weight: 600;
283
+ margin: 0 0 20px 0;
284
+ `),
285
+ paragraph: raw(`
286
+ color: #475569;
287
+ font-size: 16px;
288
+ line-height: 1.6;
289
+ margin: 0 0 20px 0;
290
+ `),
291
+ button: raw(`
292
+ display: inline-block;
293
+ background-color: #3b82f6;
294
+ color: #ffffff;
295
+ font-size: 16px;
296
+ font-weight: 600;
297
+ text-decoration: none;
298
+ padding: 14px 28px;
299
+ border-radius: 8px;
300
+ margin: 20px 0;
301
+ `),
302
+ footer: raw(`
303
+ color: #94a3b8;
304
+ font-size: 14px;
305
+ margin-top: 30px;
306
+ padding-top: 20px;
307
+ border-top: 1px solid #e2e8f0;
308
+ `),
309
+ warning: raw(`
310
+ color: #64748b;
311
+ font-size: 14px;
312
+ background-color: #fef3c7;
313
+ padding: 12px 16px;
314
+ border-radius: 6px;
315
+ margin-top: 20px;
316
+ `),
317
+ /**
318
+ * The one-time code itself. Monospace so the digits line up, spaced so a
319
+ * `1` and a `7` are not read as one glyph, and large enough to be copied by
320
+ * eye from a phone onto another device — which is the entire flow.
321
+ */
322
+ code: raw(`
323
+ display: inline-block;
324
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
325
+ font-size: 34px;
326
+ font-weight: 700;
327
+ letter-spacing: 10px;
328
+ color: #1e293b;
329
+ background-color: #f1f5f9;
330
+ border-radius: 10px;
331
+ padding: 18px 20px 18px 30px;
332
+ `)
333
+ };
421
334
  /**
422
- * Shared 403 response for API-key permission denials outside the REST
423
- * generator (storage and functions guards). One envelope, one code — a
424
- * change to the error contract happens in one place.
335
+ * The app name every template falls back to when `email.appName` is unset.
425
336
  */
426
- function forbidApiKey(c, operation, resource, grantHint) {
427
- return c.json({ error: {
428
- message: `API key does not have "${operation}" permission for ${resource}. Grant it with a permission entry like { "collection": "${grantHint}", "operations": ["${operation}"] }.`,
429
- code: "API_KEY_FORBIDDEN"
430
- } }, 403);
431
- }
337
+ var DEFAULT_APP_NAME = "Rebase";
432
338
  /**
433
- * Permission guard for API-key requests to the storage router.
434
- *
435
- * Storage previously did not accept API keys at all (`rk_` tokens were
436
- * misparsed as JWTs and 401'd). Now that the pre-auth middleware
437
- * authenticates them, this guard decides what they may do: the key needs a
438
- * `"storage"` permission entry (or the global `"*"` wildcard) covering the
439
- * operation derived from the HTTP method. Requests not authenticated via an
440
- * API key pass through to the storage router's own auth gates.
339
+ * The Rebase mark, hosted. Mail clients do not render SVG (Gmail strips it
340
+ * outright), so this is the PNG rather than `logo.svg`, and it is an absolute
341
+ * URL rather than a data URI because Gmail blocks those in `<img src>` too.
342
+ */
343
+ var REBASE_LOGO_URL = "https://rebase.pro/img/logo_small.png";
344
+ /**
345
+ * Whether a configured logo can actually be fetched by a mail client.
441
346
  *
442
- * TUS resumable-upload routes (`/tus`, `/tus/:id`) are classified as `write`
443
- * for EVERY method: the protocol's offset check is a GET and its cancel is a
444
- * DELETE, but both are steps of an upload a write-scoped key must be able
445
- * to complete (and abort) its own resumable upload without also holding
446
- * `read`/`delete` on stored objects.
347
+ * A relative path, a `data:` URI or a `file:` URL all produce a broken image in
348
+ * the recipient's inbox rather than an error anyone would see, so an unusable
349
+ * value renders no logo at all instead. This is deliberately not a boot-time
350
+ * assert like `assertEmailLinkBases`: a dead link makes the mail useless, a
351
+ * missing logo only makes it plain.
447
352
  */
448
- function createStorageApiKeyGuard() {
449
- return async (c, next) => {
450
- const apiKey = c.get("apiKey");
451
- if (!apiKey) return next();
452
- const operation = /\/tus(\/|$)/.test(c.req.path) ? "write" : httpMethodToOperation(c.req.method);
453
- if (!isStorageAllowed(apiKey.permissions, operation)) return forbidApiKey(c, operation, "storage", "storage");
454
- return next();
455
- };
353
+ function isMailableImageUrl(url) {
354
+ if (!url) return false;
355
+ try {
356
+ const { protocol } = new URL(url);
357
+ return protocol === "https:" || protocol === "http:";
358
+ } catch {
359
+ return false;
360
+ }
456
361
  }
457
362
  /**
458
- * Permission guard for API-key requests to the custom-functions router.
459
- *
460
- * The collection permission guard lives in the REST generator and never sees
461
- * function routes, so before this middleware existed ANY valid API key —
462
- * however narrowly scoped — could invoke every custom function. This guard
463
- * closes that: API-key requests must hold a `"functions"`/`"functions/<name>"`
464
- * permission entry (or the global `"*"` wildcard) for the derived operation.
465
- *
466
- * Non-API-key requests (JWT, service key, anonymous) pass through untouched —
467
- * functions decide their own auth for those, as before.
363
+ * Whether this config still carries the stock app name — i.e. nobody has
364
+ * branded this install.
468
365
  *
469
- * @param mountPrefix - The path the functions router is mounted at
470
- * (e.g. `/api/functions`), used to extract the function
471
- * name from the request path.
366
+ * Testing against the *value* rather than against `undefined` is deliberate and
367
+ * load-bearing: `APP_NAME` is declared `z.string().default("Rebase")` in the
368
+ * cloud, in the demo app and in the eject template, so `appName` arrives here as
369
+ * the literal string "Rebase" even when the operator set nothing. Keying the
370
+ * fallback on `appName === undefined` would mean the logo never appeared in any
371
+ * Rebase-run product.
472
372
  */
473
- function createFunctionApiKeyGuard(mountPrefix) {
474
- return async (c, next) => {
475
- const apiKey = c.get("apiKey");
476
- if (!apiKey) return next();
477
- const path = c.req.path;
478
- const idx = path.indexOf(mountPrefix);
479
- const rawName = (idx < 0 ? "" : path.slice(idx + mountPrefix.length)).split("/").filter(Boolean)[0] ?? "";
480
- let functionName;
481
- try {
482
- functionName = decodeURIComponent(rawName);
483
- } catch {
484
- functionName = rawName;
485
- }
486
- const operation = httpMethodToOperation(c.req.method);
487
- if (!isFunctionAllowed(apiKey.permissions, functionName, operation)) return forbidApiKey(c, operation, functionName ? `function "${functionName}"` : "the functions index", `functions${functionName ? `/${functionName}` : ""}`);
488
- return next();
489
- };
373
+ function isUnbranded(appName) {
374
+ return !appName || appName === DEFAULT_APP_NAME;
490
375
  }
491
376
  /**
492
- * Standalone pre-auth middleware for `rk_` bearer tokens.
493
- *
494
- * Routers whose auth gate is JWT-based (`requireAuth` / `createRequireAuth` —
495
- * the admin surfaces: admin users/roles, api-keys management, cron, backups,
496
- * logs, schema editor) don't know about API keys. Mounting this middleware in
497
- * front of them authenticates `rk_` tokens and populates the request context;
498
- * the downstream gates then see the already-resolved user and apply their
499
- * role checks (`requireAdmin`) as usual — so an `admin: true` key passes and
500
- * a non-admin key is rejected with 403.
377
+ * Resolve the branding a template should render from the email config.
501
378
  *
502
- * Requests without an `rk_` bearer token pass through untouched. An invalid,
503
- * revoked, or expired `rk_` token is rejected here (401) rather than falling
504
- * through to be misparsed as a JWT.
379
+ * The fallback is asymmetric on purpose. `appName` falls back to "Rebase"
380
+ * because an unconfigured app has no better name to show. The *logo* is stricter:
381
+ * an app that named itself "Acme" but set no `logoUrl` gets no logo at all,
382
+ * because the alternative is mailing Acme's users a Rebase mark from Acme's
383
+ * domain. Only an install that has not renamed itself is treated as Rebase's own.
505
384
  */
506
- function createApiKeyPreAuth(options) {
507
- return async (c, next) => {
508
- if (c.get("apiKey")) return next();
509
- const token = extractBearerToken(c.req.header("authorization")) ?? "";
510
- if (!token || !isApiKeyToken(token)) return next();
511
- const result = await validateApiKey(c, token, options);
512
- if (result !== true) return result;
513
- return next();
385
+ function resolveEmailBranding(config) {
386
+ const logoUrl = config?.logoUrl ?? (isUnbranded(config?.appName) ? REBASE_LOGO_URL : void 0);
387
+ return {
388
+ appName: config?.appName || DEFAULT_APP_NAME,
389
+ logoUrl: isMailableImageUrl(logoUrl) ? logoUrl : void 0
514
390
  };
515
391
  }
516
- //#endregion
517
- //#region src/auth/middleware.ts
518
392
  /**
519
- * Answer a refusal with the canonical envelope, through the same formatter
520
- * every route uses.
393
+ * The logo block that sits above the card in every default template.
521
394
  *
522
- * These middlewares used to hand-build `c.json({ error: { message, code } },
523
- * 401)` seven near-copies of it, which is how they came to omit `requestId`
524
- * while every other error on the server carried it: the one string a bug report
525
- * quotes was missing from precisely the errors an app hits most. It also meant
526
- * a routine 401 (a client refreshing before it knows whether a session exists)
527
- * logged like an incident, because `ApiError.expected` never reached the
528
- * logger.
395
+ * `width`/`height` are attributes as well as CSS because Outlook's Word renderer
396
+ * ignores the style block. `alt` carries the app name, for screen readers and
397
+ * for the very common case of a client that blocks remote images on a first
398
+ * email from an unknown sender.
529
399
  *
530
- * `errorHandler` is *called* rather than thrown to. A throw only becomes an
531
- * envelope where an `onError` is registered, and these middlewares are mounted
532
- * by callers this package does not control`defineFunction` hands users a
533
- * Hono app and invites them to put `requireAuth` on a route of it. Making the
534
- * status depend on the mount point would turn a 401 into a 500 for exactly
535
- * those callers. Calling the formatter directly gets the envelope with none of
536
- * that.
400
+ * The type styling on the `<img>` is what that blocked case falls back to: alt
401
+ * text inherits it, and at the body's 16px "Rebase" overflows a 48px box and
402
+ * renders as "Rebas". 12px plus `height: auto`CSS the clients that block
403
+ * images do read, while Outlook still sizes from the attributes lets the name
404
+ * through intact.
537
405
  */
538
- function refuse(c, error) {
539
- return errorHandler(error, c);
406
+ function renderHeader(appName, logoUrl) {
407
+ if (!logoUrl) return raw("");
408
+ return html`
409
+ <div style="text-align: center; margin-bottom: 24px;">
410
+ <img src="${logoUrl}" width="48" height="48" alt="${appName}"
411
+ style="display: inline-block; width: 48px; height: auto; border: 0; outline: none; text-decoration: none; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 12px; color: #64748b;">
412
+ </div>
413
+ `;
540
414
  }
541
415
  /**
542
- * Hono middleware that requires a valid JWT token via Authorization header.
543
- * Returns 401 if token is missing or invalid.
544
- *
545
- * **Security:** Tokens are only accepted via the `Authorization: Bearer`
546
- * header. Query-string tokens (`?token=`) are intentionally NOT accepted
547
- * here because URLs leak into access logs, proxies, Referer headers, and
548
- * browser history. Use {@link queryTokenAuth} on routes that legitimately
549
- * need query-string tokens (e.g. storage file serving for `<img src>`).
550
- */
551
- var requireAuth = async (c, next) => {
552
- if (c.get("user")) return next();
553
- const token = extractBearerToken(c.req.header("authorization"));
554
- if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
555
- const payload = await verifyAccessToken(token);
556
- if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
557
- c.set("user", payload);
558
- return next();
559
- };
560
- /**
561
- * Factory that creates a requireAuth middleware with optional service key support.
562
- *
563
- * When `serviceKey` is provided, the middleware will check if the Bearer token
564
- * matches the service key using constant-time comparison. If it matches, the
565
- * request is authenticated as a service user with admin privileges.
566
- *
567
- * This allows admin routes (which use standalone requireAuth + requireAdmin)
568
- * to be accessed via service keys for scripts and server-to-server calls.
416
+ * Default password reset email template
569
417
  */
570
- function createRequireAuth(options) {
571
- if (!options?.serviceKey && !options?.resolveRoles && !options?.revocationRepo) return requireAuth;
572
- const key = options.serviceKey;
573
- const resolveRoles = options.resolveRoles;
574
- const revocationRepo = options.revocationRepo;
575
- return async (c, next) => {
576
- if (c.get("user")) return next();
577
- const token = extractBearerToken(c.req.header("authorization"));
578
- if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
579
- if (key !== void 0 && safeCompare(token, key)) {
580
- c.set("user", {
581
- uid: "service",
582
- roles: ["admin"]
583
- });
584
- return next();
585
- }
586
- const payload = await verifyAccessToken(token);
587
- if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
588
- if (resolveRoles || revocationRepo) try {
589
- if (revocationRepo && await isAccessTokenRevoked(revocationRepo, payload)) return refuse(c, ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED"));
590
- c.set("user", resolveRoles ? {
591
- ...payload,
592
- roles: await resolveRoles(payload.uid)
593
- } : payload);
594
- return next();
595
- } catch (error) {
596
- logger.warn("[Auth] Could not resolve roles for an admin-gated request", {
597
- uid: payload.uid,
598
- error
599
- });
600
- return c.json({ error: {
601
- message: "Could not verify your permissions. Please try again.",
602
- code: "ROLE_LOOKUP_FAILED"
603
- } }, 503);
604
- }
605
- c.set("user", payload);
606
- return next();
418
+ function getPasswordResetTemplate(resetUrl, user, appName = DEFAULT_APP_NAME, logoUrl) {
419
+ const greeting = getGreeting(user);
420
+ const subject = `Reset your ${appName} password`;
421
+ const body = html`
422
+ <!DOCTYPE html>
423
+ <html>
424
+ <head>
425
+ <meta charset="utf-8">
426
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
427
+ <title>${subject}</title>
428
+ </head>
429
+ <body style="margin: 0; padding: 0; background-color: #f8fafc;">
430
+ <div style="${styles.container}">${renderHeader(appName, logoUrl)}
431
+ <div style="${styles.card}">
432
+ <h1 style="${styles.heading}">Reset Your Password</h1>
433
+
434
+ <p style="${styles.paragraph}">
435
+ Hi ${greeting},
436
+ </p>
437
+
438
+ <p style="${styles.paragraph}">
439
+ We received a request to reset your password for your ${appName} account.
440
+ Click the button below to create a new password:
441
+ </p>
442
+
443
+ <div style="text-align: center;">
444
+ <a href="${resetUrl}" style="${styles.button}">Reset Password</a>
445
+ </div>
446
+
447
+ <p style="${styles.paragraph}">
448
+ Or copy and paste this link into your browser:
449
+ </p>
450
+ <p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
451
+ ${resetUrl}
452
+ </p>
453
+
454
+ <div style="${styles.warning}">
455
+ ⏰ This link will expire in 1 hour for security reasons.
456
+ </div>
457
+
458
+ <div style="${styles.footer}">
459
+ <p style="margin: 0;">
460
+ If you didn't request a password reset, you can safely ignore this email.
461
+ Your password will remain unchanged.
462
+ </p>
463
+ </div>
464
+ </div>
465
+ </div>
466
+ </body>
467
+ </html>
468
+ `;
469
+ const text = `
470
+ Reset Your Password
471
+
472
+ Hi ${greeting},
473
+
474
+ We received a request to reset your password for your ${appName} account.
475
+
476
+ Click this link to create a new password:
477
+ ${resetUrl}
478
+
479
+ This link will expire in 1 hour for security reasons.
480
+
481
+ If you didn't request a password reset, you can safely ignore this email.
482
+ Your password will remain unchanged.
483
+ `.trim();
484
+ return {
485
+ subject,
486
+ html: body.toString().trim(),
487
+ text
607
488
  };
608
489
  }
609
490
  /**
610
- * Middleware that requires the user to have an admin or schema-admin role.
611
- * Must be used AFTER requireAuth or on a route where user is guaranteed.
612
- */
613
- var requireAdmin = async (c, next) => {
614
- const user = c.get("user");
615
- if (!user) return refuse(c, ApiError.unauthorized("User not authenticated. requireAuth middleware is missing?"));
616
- if (!hasAdministrativeRole(typeof user === "object" && user !== null && "roles" in user ? user.roles || [] : [])) return refuse(c, ApiError.forbidden("Admin privileges required for this operation"));
617
- return next();
618
- };
619
- /**
620
- * Middleware that optionally extracts user from JWT via Authorization header.
621
- * Does not return 401 if token is missing — allows anonymous access.
622
- *
623
- * Query-string tokens are NOT accepted here. Use {@link queryTokenAuth}
624
- * on routes that need them.
491
+ * Default email verification template
625
492
  */
626
- var optionalAuth = async (c, next) => {
627
- if (c.get("user")) return next();
628
- const token = extractBearerToken(c.req.header("authorization"));
629
- if (token !== void 0 && isJwtConfigured()) {
630
- const payload = await verifyAccessToken(token);
631
- if (payload) c.set("user", payload);
632
- }
633
- return next();
634
- };
493
+ function getEmailVerificationTemplate(verifyUrl, user, appName = DEFAULT_APP_NAME, logoUrl) {
494
+ const greeting = getGreeting(user);
495
+ const subject = `Verify your ${appName} email address`;
496
+ const body = html`
497
+ <!DOCTYPE html>
498
+ <html>
499
+ <head>
500
+ <meta charset="utf-8">
501
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
502
+ <title>${subject}</title>
503
+ </head>
504
+ <body style="margin: 0; padding: 0; background-color: #f8fafc;">
505
+ <div style="${styles.container}">${renderHeader(appName, logoUrl)}
506
+ <div style="${styles.card}">
507
+ <h1 style="${styles.heading}">Verify Your Email</h1>
508
+
509
+ <p style="${styles.paragraph}">
510
+ Hi ${greeting},
511
+ </p>
512
+
513
+ <p style="${styles.paragraph}">
514
+ Thanks for signing up for ${appName}! Please verify your email address
515
+ by clicking the button below:
516
+ </p>
517
+
518
+ <div style="text-align: center;">
519
+ <a href="${verifyUrl}" style="${styles.button}">Verify Email Address</a>
520
+ </div>
521
+
522
+ <p style="${styles.paragraph}">
523
+ Or copy and paste this link into your browser:
524
+ </p>
525
+ <p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
526
+ ${verifyUrl}
527
+ </p>
528
+
529
+ <div style="${styles.footer}">
530
+ <p style="margin: 0;">
531
+ If you didn't create an account with ${appName}, you can safely ignore this email.
532
+ </p>
533
+ </div>
534
+ </div>
535
+ </div>
536
+ </body>
537
+ </html>
538
+ `;
539
+ const text = `
540
+ Verify Your Email
541
+
542
+ Hi ${greeting},
543
+
544
+ Thanks for signing up for ${appName}! Please verify your email address by clicking this link:
545
+ ${verifyUrl}
546
+
547
+ If you didn't create an account with ${appName}, you can safely ignore this email.
548
+ `.trim();
549
+ return {
550
+ subject,
551
+ html: body.toString().trim(),
552
+ text
553
+ };
554
+ }
635
555
  /**
636
- * Extract user from token - for WebSocket authentication
556
+ * Default user invitation email template
557
+ * Sent when an admin creates a new user account
637
558
  */
638
- async function extractUserFromToken(token) {
639
- if (!isJwtConfigured()) return null;
640
- return verifyAccessToken(token);
559
+ function getUserInvitationTemplate(setPasswordUrl, user, appName = DEFAULT_APP_NAME, logoUrl) {
560
+ const greeting = getGreeting(user);
561
+ const subject = `You've been invited to ${appName}`;
562
+ const body = html`
563
+ <!DOCTYPE html>
564
+ <html>
565
+ <head>
566
+ <meta charset="utf-8">
567
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
568
+ <title>${subject}</title>
569
+ </head>
570
+ <body style="margin: 0; padding: 0; background-color: #f8fafc;">
571
+ <div style="${styles.container}">${renderHeader(appName, logoUrl)}
572
+ <div style="${styles.card}">
573
+ <h1 style="${styles.heading}">Welcome to ${appName}!</h1>
574
+
575
+ <p style="${styles.paragraph}">
576
+ Hi ${greeting},
577
+ </p>
578
+
579
+ <p style="${styles.paragraph}">
580
+ An account has been created for you on ${appName}.
581
+ Click the button below to set your password and get started:
582
+ </p>
583
+
584
+ <div style="text-align: center;">
585
+ <a href="${setPasswordUrl}" style="${styles.button}">Set Your Password</a>
586
+ </div>
587
+
588
+ <p style="${styles.paragraph}">
589
+ Or copy and paste this link into your browser:
590
+ </p>
591
+ <p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
592
+ ${setPasswordUrl}
593
+ </p>
594
+
595
+ <div style="${styles.warning}">
596
+ ⏰ This link will expire in 1 hour for security reasons.
597
+ </div>
598
+
599
+ <div style="${styles.footer}">
600
+ <p style="margin: 0;">
601
+ If you weren't expecting this invitation, you can safely ignore this email.
602
+ </p>
603
+ </div>
604
+ </div>
605
+ </div>
606
+ </body>
607
+ </html>
608
+ `;
609
+ const text = `
610
+ Welcome to ${appName}!
611
+
612
+ Hi ${greeting},
613
+
614
+ An account has been created for you on ${appName}.
615
+
616
+ Click this link to set your password and get started:
617
+ ${setPasswordUrl}
618
+
619
+ This link will expire in 1 hour for security reasons.
620
+
621
+ If you weren't expecting this invitation, you can safely ignore this email.
622
+ `.trim();
623
+ return {
624
+ subject,
625
+ html: body.toString().trim(),
626
+ text
627
+ };
641
628
  }
642
629
  /**
643
- * Create a configurable auth middleware that handles:
644
- * 1. Token extraction (via custom validator or JWT Bearer token)
645
- * 2. RLS-scoped DataDriver via withAuth()
646
- * 3. Enforcement (401 when requireAuth is true and no user)
647
- *
648
- * **Secure by default:** `requireAuth` defaults to `true`. Anonymous
649
- * access is only allowed when the developer explicitly opts out by
650
- * setting `requireAuth: false`, indicating that Postgres RLS policies
651
- * fully control access.
652
- *
653
- * **Fail-closed:** The raw unscoped driver is never placed in the
654
- * request context. Every code path either scopes via `withAuth()` or
655
- * rejects the request. This prevents silent RLS bypass.
656
- *
657
- * This is the single source of truth for HTTP auth in Rebase.
658
- * Use this instead of manually parsing tokens in route handlers.
630
+ * Default welcome email template
631
+ * Sent automatically when a new user registers
659
632
  */
660
- function createAuthMiddleware(options) {
661
- const { driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
662
- return async (c, next) => {
663
- const driver = resolveDriver ? resolveDriver(c) : baseDriver;
664
- if (validator) try {
665
- const authResult = await validator(c);
666
- if (authResult && typeof authResult === "object") {
667
- const id = "uid" in authResult ? authResult.uid : void 0;
668
- if (id) {
669
- const roles = authResult.roles || [];
670
- c.set("user", {
671
- uid: id,
672
- roles
673
- });
674
- const user = {
675
- uid: id,
676
- roles,
677
- ...authResult
678
- };
679
- c.set("driver", await scopeDataDriver(driver, user));
680
- } else c.set("driver", await scopeDataDriver(driver, {
681
- uid: ANONYMOUS_USER_ID,
682
- roles: ["anon"]
683
- }));
684
- } else if (authResult === true) {
685
- c.set("user", {
686
- uid: "default",
687
- roles: []
688
- });
689
- c.set("driver", await scopeDataDriver(driver, {
690
- uid: "default",
691
- roles: []
692
- }));
693
- } else c.set("driver", await scopeDataDriver(driver, {
694
- uid: ANONYMOUS_USER_ID,
695
- roles: ["anon"]
696
- }));
697
- } catch (error) {
698
- return refuse(c, ApiError.unauthenticated("Unauthorized"));
699
- }
700
- else {
701
- const token = extractBearerToken(c.req.header("authorization"));
702
- if (token !== void 0) if (serviceKey && safeCompare(token, serviceKey)) {
703
- c.set("user", {
704
- uid: "service",
705
- roles: ["admin"]
706
- });
707
- try {
708
- c.set("driver", await scopeDataDriver(driver, {
709
- uid: "service",
710
- roles: ["admin"]
711
- }));
712
- } catch (error) {
713
- logger.error("[AUTH] RLS scoping failed for service key", { error });
714
- return c.json({ error: {
715
- message: "Internal authentication error",
716
- code: "INTERNAL_ERROR"
717
- } }, 500);
718
- }
719
- } else if (apiKeyStore && isApiKeyToken(token)) {
720
- const result = await validateApiKey(c, token, {
721
- store: apiKeyStore,
722
- driver
723
- });
724
- if (result !== true) return result;
725
- } else {
726
- const payload = await extractUserFromToken(token);
727
- if (payload) {
728
- c.set("user", payload);
729
- try {
730
- const user = {
731
- uid: payload.uid,
732
- roles: payload.roles,
733
- isAnonymous: payload.isAnonymous === true,
734
- claims: payload.claims
735
- };
736
- c.set("driver", await scopeDataDriver(driver, user));
737
- } catch (error) {
738
- logger.error("[AUTH] RLS scoping failed for authenticated user", { error });
739
- return c.json({ error: {
740
- message: "Internal authentication error",
741
- code: "INTERNAL_ERROR"
742
- } }, 500);
743
- }
744
- } else return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
745
- }
746
- else try {
747
- c.set("driver", await scopeDataDriver(driver, {
748
- uid: ANONYMOUS_USER_ID,
749
- roles: ["anon"]
750
- }));
751
- } catch (error) {
752
- logger.error("[AUTH] Failed to create anon-scoped driver", { error });
753
- return c.json({ error: {
754
- message: "Server configuration error",
755
- code: "INTERNAL_ERROR"
756
- } }, 500);
757
- }
758
- }
759
- if (enforceAuth && !c.get("user")) return refuse(c, ApiError.unauthenticated("Unauthorized: Authentication required. This is the API-level gate, not a row rule — a collection granting public reads still needs a caller. Set AUTH_REQUIRE=false (or `auth.requireAuth: false`) to let RLS alone decide."));
760
- return next();
633
+ function getWelcomeEmailTemplate(user, appName = DEFAULT_APP_NAME, loginUrl, logoUrl) {
634
+ const greeting = getGreeting(user);
635
+ const url = loginUrl || "";
636
+ const subject = `Welcome to ${appName}`;
637
+ const body = html`
638
+ <!DOCTYPE html>
639
+ <html>
640
+ <head>
641
+ <meta charset="utf-8">
642
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
643
+ <title>${subject}</title>
644
+ </head>
645
+ <body style="margin: 0; padding: 0; background-color: #f8fafc;">
646
+ <div style="${styles.container}">${renderHeader(appName, logoUrl)}
647
+ <div style="${styles.card}">
648
+ <h1 style="${styles.heading}">Welcome to ${appName}</h1>
649
+
650
+ <p style="${styles.paragraph}">
651
+ Hi ${greeting},
652
+ </p>
653
+
654
+ <p style="${styles.paragraph}">
655
+ Your ${appName} account is ready.
656
+ </p>
657
+
658
+ ${url ? html`
659
+ <div style="text-align: center;">
660
+ <a href="${url}" style="${styles.button}">Open ${appName}</a>
661
+ </div>
662
+ ` : raw("")}
663
+
664
+ <p style="${styles.paragraph}">
665
+ If you have any questions, just reply to this email.
666
+ </p>
667
+
668
+ <div style="${styles.footer}">
669
+ <p style="margin: 0;">
670
+ You received this because an account was created with this email address at ${appName}.
671
+ </p>
672
+ </div>
673
+ </div>
674
+ </div>
675
+ </body>
676
+ </html>
677
+ `;
678
+ const text = `
679
+ Welcome to ${appName}
680
+
681
+ Hi ${greeting},
682
+
683
+ Your ${appName} account is ready.
684
+
685
+ ${url ? `Open ${appName}: ${url}` : ""}
686
+
687
+ If you have any questions, just reply to this email.
688
+ `.trim();
689
+ return {
690
+ subject,
691
+ html: body.toString().trim(),
692
+ text
761
693
  };
762
694
  }
763
695
  /**
764
- * Middleware that authenticates via a `?token=` query parameter.
765
- *
766
- * **Use sparingly.** Tokens in URLs leak into access logs, proxy logs,
767
- * Referer headers, and browser history. This middleware exists solely for
768
- * routes where the consumer cannot set HTTP headers — e.g. `<img src>`,
769
- * `<a href>` for file downloads, or similar browser-native requests.
770
- *
771
- * Apply it **before** `requireAuth` or `optionalAuth` on the specific
772
- * route that needs it. Those middlewares will see the user context this
773
- * middleware sets and skip their own 401 check.
696
+ * Default one-time sign-in code email.
774
697
  *
775
- * @example
776
- * ```ts
777
- * router.get("/file/*", queryTokenAuth, readAuthMiddleware, handler);
778
- * ```
698
+ * The code is the entire content, so it is set large and repeated in the
699
+ * plain-text part: somebody is about to copy it by eye onto another device,
700
+ * which is the whole reason this flow exists rather than a link.
779
701
  */
780
- var queryTokenAuth = async (c, next) => {
781
- if (extractBearerToken(c.req.header("authorization")) !== void 0) return next();
782
- const queryToken = c.req.query("token");
783
- if (queryToken && isJwtConfigured()) {
784
- const payload = await verifyAccessToken(queryToken);
785
- if (payload) c.set("user", payload);
786
- }
787
- return next();
788
- };
702
+ function getEmailOtpTemplate(code, user, appName = DEFAULT_APP_NAME, logoUrl) {
703
+ const greeting = getGreeting(user);
704
+ const subject = `${code} is your ${appName} sign-in code`;
705
+ const body = html`
706
+ <!DOCTYPE html>
707
+ <html>
708
+ <head>
709
+ <meta charset="utf-8">
710
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
711
+ <title>${subject}</title>
712
+ </head>
713
+ <body style="margin: 0; padding: 0; background-color: #f8fafc;">
714
+ <div style="${styles.container}">${renderHeader(appName, logoUrl)}
715
+ <div style="${styles.card}">
716
+ <h1 style="${styles.heading}">Your sign-in code</h1>
717
+
718
+ <p style="${styles.paragraph}">
719
+ Hi ${greeting},
720
+ </p>
721
+
722
+ <p style="${styles.paragraph}">
723
+ Enter this code to finish signing in to ${appName}:
724
+ </p>
725
+
726
+ <div style="text-align: center; margin: 32px 0;">
727
+ <span style="${styles.code}">${code}</span>
728
+ </div>
729
+
730
+ <div style="${styles.warning}">
731
+ ⏰ This code expires in 10 minutes and can only be used once.
732
+ </div>
733
+
734
+ <div style="${styles.footer}">
735
+ <p style="margin: 0;">
736
+ If you didn't try to sign in, you can safely ignore this email.
737
+ We will never ask you for this code.
738
+ </p>
739
+ </div>
740
+ </div>
741
+ </div>
742
+ </body>
743
+ </html>
744
+ `;
745
+ const text = `
746
+ Your ${appName} sign-in code
747
+
748
+ Hi ${greeting},
749
+
750
+ Enter this code to finish signing in:
751
+
752
+ ${code}
753
+
754
+ This code expires in 10 minutes and can only be used once.
755
+
756
+ If you didn't try to sign in, you can safely ignore this email. We will never
757
+ ask you for this code.
758
+ `.trim();
759
+ return {
760
+ subject,
761
+ html: body.toString().trim(),
762
+ text
763
+ };
764
+ }
789
765
  /**
790
- * Authorizes anonymous access to **public** storage objects (those under the
791
- * public prefix), which are served token-less via stable, permanent URLs.
792
- *
793
- * Runs on the storage `/file/*` and `/metadata/*` routes, *after* the token
794
- * middleware and *before* the read-auth gate. If the request is already
795
- * authenticated (Bearer or scoped token), it does nothing. Otherwise, when the
796
- * requested object path is public, it sets a minimal "public" principal so the
797
- * downstream `requireAuth` gate lets the read through. Private paths are left
798
- * untouched, so they still require a valid token.
766
+ * Default magic link email template
799
767
  */
800
- var publicObjectAuth = async (c, next) => {
801
- if (c.get("user")) return next();
802
- const prefix = c.req.routePath.replace("/*", "");
803
- const fullPath = c.req.path;
804
- const idx = fullPath.indexOf(prefix);
805
- const rawPath = idx < 0 ? "" : fullPath.substring(idx + prefix.length + 1);
806
- if (rawPath && isPublicStoragePath(decodeURIComponent(rawPath))) c.set("user", {
807
- uid: "public",
808
- roles: ["public"]
809
- });
810
- return next();
811
- };
768
+ function getMagicLinkTemplate(magicLinkUrl, user, appName = DEFAULT_APP_NAME, logoUrl) {
769
+ const greeting = getGreeting(user);
770
+ const subject = `Sign in to ${appName}`;
771
+ const body = html`
772
+ <!DOCTYPE html>
773
+ <html>
774
+ <head>
775
+ <meta charset="utf-8">
776
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
777
+ <title>${subject}</title>
778
+ </head>
779
+ <body style="margin: 0; padding: 0; background-color: #f8fafc;">
780
+ <div style="${styles.container}">${renderHeader(appName, logoUrl)}
781
+ <div style="${styles.card}">
782
+ <h1 style="${styles.heading}">Sign In to ${appName}</h1>
783
+
784
+ <p style="${styles.paragraph}">
785
+ Hi ${greeting},
786
+ </p>
787
+
788
+ <p style="${styles.paragraph}">
789
+ We received a request to sign in to your ${appName} account.
790
+ Click the button below to log in:
791
+ </p>
792
+
793
+ <div style="text-align: center;">
794
+ <a href="${magicLinkUrl}" style="${styles.button}">Sign In</a>
795
+ </div>
796
+
797
+ <p style="${styles.paragraph}">
798
+ Or copy and paste this link into your browser:
799
+ </p>
800
+ <p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
801
+ ${magicLinkUrl}
802
+ </p>
803
+
804
+ <div style="${styles.warning}">
805
+ ⏰ This link will expire in 15 minutes for security reasons and can only be used once.
806
+ </div>
807
+
808
+ <div style="${styles.footer}">
809
+ <p style="margin: 0;">
810
+ If you didn't request this sign-in link, you can safely ignore this email.
811
+ No action is needed.
812
+ </p>
813
+ </div>
814
+ </div>
815
+ </div>
816
+ </body>
817
+ </html>
818
+ `;
819
+ const text = `
820
+ Sign In to ${appName}
821
+
822
+ Hi ${greeting},
823
+
824
+ We received a request to sign in to your ${appName} account.
825
+
826
+ Click this link to log in:
827
+ ${magicLinkUrl}
828
+
829
+ This link will expire in 15 minutes for security reasons and can only be used once.
830
+
831
+ If you didn't request this sign-in link, you can safely ignore this email.
832
+ No action is needed.
833
+ `.trim();
834
+ return {
835
+ subject,
836
+ html: body.toString().trim(),
837
+ text
838
+ };
839
+ }
840
+ //#endregion
841
+ //#region src/auth/admin-user-ops.ts
812
842
  /**
813
- * Helper to match paths for scoped file tokens.
814
- * Matches exact paths or prefixes (exact folder prefixes or ending with /).
843
+ * Admin User Operations
815
844
  *
816
- * The traversal check is defence in depth, not the load-bearing defence.
845
+ * Shared utilities and orchestration for admin-initiated user management
846
+ * (user creation via REST API, password reset via admin panel).
817
847
  *
818
- * A prefix comparison is a lie for any path containing `..`: `user1/../user2/x`
819
- * starts with `user1/`, so a token scoped to `user1/` would authorize reading
820
- * user2's file and the storage controller would not object, because its own
821
- * guard stops a path escaping the *bucket*, and that one resolves to `user2/x`,
822
- * comfortably inside it. The grant is what would be escaped, and this is the
823
- * only layer that knows what was granted.
848
+ * Hook resolution order:
849
+ * 1. Collection-level hook (`auth.onCreateUser` on the collection) closest to the data
850
+ * 2. Backend-level hook (`AuthHooks.onAdminCreateUser`) global override
851
+ * 3. Built-in default framework fallback
852
+ */
853
+ /**
854
+ * Generate a cryptographically secure random password that meets strength requirements.
824
855
  *
825
- * Nothing arrives here in that shape today: the URL parser resolves `..` (and
826
- * `%2e%2e`, which the WHATWG spec treats as a dot for normalization) before
827
- * Hono routes the request, so this comparison already runs on a resolved path.
828
- * But that is a guarantee of the runtime rather than of this code, and it is
829
- * one line to not depend on it. The same rule already guards the public-object
830
- * path see `isPublicStoragePath`.
856
+ * 16 characters, guaranteed at least one uppercase, one lowercase, one digit.
857
+ * Ambiguous characters (0, O, 1, l, I) are excluded.
858
+ */
859
+ function generateSecurePassword() {
860
+ const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
861
+ const lower = "abcdefghjkmnpqrstuvwxyz";
862
+ const digits = "23456789";
863
+ const all = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
864
+ const pick = (chars) => chars[randomInt(chars.length)];
865
+ const parts = [
866
+ pick(upper),
867
+ pick(lower),
868
+ pick(digits)
869
+ ];
870
+ for (let i = parts.length; i < 16; i++) parts.push(pick(all));
871
+ for (let i = parts.length - 1; i > 0; i--) {
872
+ const j = randomInt(i + 1);
873
+ [parts[i], parts[j]] = [parts[j], parts[i]];
874
+ }
875
+ return parts.join("");
876
+ }
877
+ /**
878
+ * Generate a cryptographically secure random token (80 hex characters).
831
879
  */
832
- function isPathMatch(requested, allowed) {
833
- if (requested.split("/").some((seg) => seg === "..")) return false;
834
- if (requested === allowed) return true;
835
- if (allowed.endsWith("/")) return requested.startsWith(allowed);
836
- return requested.startsWith(allowed + "/");
880
+ function generateSecureToken() {
881
+ return randomBytes(40).toString("hex");
837
882
  }
838
883
  /**
839
- * Middleware that authenticates file-serving routes using scoped download tokens.
840
- * It enforces that only scoped "file-read" tokens can access "/file/*" and "?token=" query params.
841
- * Full access JWTs are explicitly rejected on "/file/*" routes, and in the "?token=" query param.
884
+ * Hash a token for database storage using SHA-256.
842
885
  */
843
- var fileTokenAuth = async (c, next) => {
844
- const isFileRoute = c.req.path.includes("/storage/file/");
845
- const authHeader = c.req.header("authorization");
846
- const queryToken = c.req.query("token");
847
- const extractWildcard = (ctx) => {
848
- const prefix = ctx.req.routePath.replace("/*", "");
849
- const fullPath = ctx.req.path;
850
- const idx = fullPath.indexOf(prefix);
851
- if (idx < 0) return "";
852
- return fullPath.substring(idx + prefix.length + 1);
886
+ function hashToken$1(token) {
887
+ return createHash("sha256").update(token).digest("hex");
888
+ }
889
+ /**
890
+ * Build the `AuthCollectionContext` facade from server internals.
891
+ *
892
+ * This is the simplified context exposed to collection-level auth hooks,
893
+ * keeping them decoupled from internal interfaces like `AuthRepository`.
894
+ */
895
+ function buildCollectionContext(ctx) {
896
+ const isEmailConfigured = !!(ctx.emailService && ctx.emailService.isConfigured());
897
+ return {
898
+ hashPassword: (password) => ctx.resolvedHooks.hashPassword(password),
899
+ sendEmail: isEmailConfigured ? (options) => ctx.emailService.send(options) : void 0,
900
+ emailConfigured: isEmailConfigured,
901
+ appName: ctx.emailConfig?.appName || "Rebase",
902
+ resetPasswordUrl: ctx.emailConfig?.resetPasswordUrl || ""
853
903
  };
854
- /**
855
- * Must agree with `parseBucketAndPath` in `storage/routes.ts`, because the
856
- * path a token GRANTS is minted there and the path a request ASKS FOR is
857
- * derived here. `/metadata` canonicalizes before signing, so a raw
858
- * comparison here would 403 an otherwise-valid request for any key whose
859
- * URL form is not already canonical (`a//b.txt`, `a/./b.txt`).
860
- *
861
- * A key that cannot be canonicalized yields null, which matches no grant —
862
- * the same fail-closed answer `isPathMatch` gives a `..` segment.
863
- */
864
- const parseBucketPath = (filePath) => {
865
- const parts = filePath.split("/");
866
- if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
867
- bucket: "default",
868
- resolvedPath: tryCanonicalStorageKey(parts.slice(1).join("/"))
869
- };
904
+ }
905
+ /**
906
+ * Prepare user values for an admin-initiated user creation.
907
+ *
908
+ * Resolution order:
909
+ * 1. Collection-level `auth.onCreateUser` closest to the data
910
+ * 2. Backend-level `AuthHooks.onAdminCreateUser` — global override
911
+ * 3. Built-in default generate password hash normalize email
912
+ *
913
+ * The caller is responsible for persisting (via `driver.save()`).
914
+ */
915
+ async function prepareAdminUserValues(body, ctx) {
916
+ const { resolvedHooks, collectionAuthConfig } = ctx;
917
+ if (collectionAuthConfig?.onCreateUser) {
918
+ const collectionCtx = buildCollectionContext(ctx);
919
+ const hookResult = await collectionAuthConfig.onCreateUser(body, collectionCtx);
870
920
  return {
871
- bucket: "default",
872
- resolvedPath: tryCanonicalStorageKey(filePath)
921
+ values: hookResult.values,
922
+ clearPassword: hookResult.temporaryPassword,
923
+ hookHandledEmail: true,
924
+ invitationSent: hookResult.invitationSent ?? false
873
925
  };
874
- };
875
- /**
876
- * Decide what a valid download token entitles this request to.
877
- *
878
- * One function rather than a copy per token location: a Bearer token and a
879
- * `?token=` are the same grant presented two ways, and the whole failure
880
- * this guards against is two places disagreeing about what was granted.
881
- *
882
- * `"no-path"` is distinct from the two denials because the caller treats it
883
- * differently — a request with no object path at all is not a denied read,
884
- * it is a request this middleware has nothing to say about, and on
885
- * `/metadata/*` it must still reach the downstream auth gate.
886
- */
887
- const evaluateGrant = (payload) => {
888
- if (canonicalStorageId(c.req.query("storageId")) !== payload.storageId) return "deny-storage";
889
- const rawPath = extractWildcard(c);
890
- if (!rawPath) return "no-path";
891
- const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
892
- if (resolvedPath === null) return "deny-path";
893
- return isPathMatch(`${bucket}/${resolvedPath}`, payload.path) ? "grant" : "deny-path";
894
- };
895
- /**
896
- * Which half of the scope failed is named in the message. The holder of the
897
- * token already knows both values, so this reveals nothing to an attacker —
898
- * and without it, a client that forgets to forward `?storageId=` on the file
899
- * URL reports a "path mismatch" for a path that matches perfectly.
900
- */
901
- const denyMismatch = (outcome) => refuse(c, ApiError.forbidden(outcome === "deny-storage" ? "Forbidden: Scoped token storage mismatch" : "Forbidden: Scoped token path mismatch"));
902
- const bearerToken = extractBearerToken(authHeader);
903
- if (bearerToken !== void 0) {
904
- const payload = await verifyDownloadToken(bearerToken);
905
- if (payload) {
906
- const outcome = evaluateGrant(payload);
907
- if (outcome === "grant") {
908
- c.set("user", {
909
- uid: "download-token",
910
- roles: ["reader"]
911
- });
912
- return next();
913
- }
914
- if (outcome !== "no-path") return denyMismatch(outcome);
915
- }
916
- if (isFileRoute) return refuse(c, ApiError.unauthenticated("Unauthorized: Access JWT not allowed on file routes"));
917
- return next();
918
926
  }
919
- if (queryToken) {
920
- const payload = await verifyDownloadToken(queryToken);
921
- if (payload) {
922
- const outcome = evaluateGrant(payload);
923
- if (outcome === "grant") {
924
- c.set("user", {
925
- uid: "download-token",
926
- roles: ["reader"]
927
- });
928
- return next();
929
- }
930
- if (outcome !== "no-path") return denyMismatch(outcome);
931
- }
932
- return refuse(c, ApiError.unauthenticated("Unauthorized: Invalid or unauthorized token"));
927
+ if (resolvedHooks.onAdminCreateUser) {
928
+ const hookResult = await resolvedHooks.onAdminCreateUser(body, {
929
+ authRepo: ctx.authRepo,
930
+ emailService: ctx.emailService,
931
+ emailConfig: ctx.emailConfig,
932
+ hashPassword: (password) => resolvedHooks.hashPassword(password)
933
+ });
934
+ return {
935
+ values: hookResult.values,
936
+ clearPassword: hookResult.temporaryPassword,
937
+ hookHandledEmail: true,
938
+ invitationSent: hookResult.invitationSent ?? false
939
+ };
933
940
  }
934
- return next();
935
- };
936
- //#endregion
937
- //#region src/auth/adapter-middleware.ts
941
+ const password = body.password;
942
+ const clearPassword = password || generateSecurePassword();
943
+ const passwordHash = await resolvedHooks.hashPassword(clearPassword);
944
+ const values = { ...body };
945
+ values.passwordHash = passwordHash;
946
+ if (values.email) values.email = normalizeEmail(values.email);
947
+ values.emailVerified = true;
948
+ delete values.password;
949
+ return {
950
+ values,
951
+ clearPassword: password ? void 0 : clearPassword,
952
+ hookHandledEmail: false,
953
+ invitationSent: false
954
+ };
955
+ }
938
956
  /**
939
- * Create a Hono middleware that uses an `AuthAdapter` for request verification.
957
+ * Handle post-creation work for admin-created users.
958
+ *
959
+ * Sends an invitation email (password-reset link) if email is configured
960
+ * and no explicit password was provided. Falls back to returning the
961
+ * temporary password if email fails or is not configured.
940
962
  */
941
- function createAdapterAuthMiddleware(options) {
942
- const { adapter, driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, apiKeyStore } = options;
943
- return async (c, next) => {
944
- const driver = resolveDriver ? resolveDriver(c) : baseDriver;
945
- const presentedToken = extractBearerToken(c.req.header("authorization"));
946
- if (apiKeyStore) {
947
- const token = presentedToken ?? "";
948
- if (token.startsWith("rk_")) {
949
- const result = await validateApiKey(c, token, {
950
- store: apiKeyStore,
951
- driver
952
- });
953
- if (result === true) return next();
954
- return result;
955
- }
956
- }
957
- let authenticatedUser = null;
958
- try {
959
- authenticatedUser = await adapter.verifyRequest(c.req.raw);
960
- } catch (error) {
961
- return refuse(c, ApiError.unauthenticated("Unauthorized"));
962
- }
963
- if (authenticatedUser) {
964
- c.set("user", {
965
- uid: authenticatedUser.uid,
966
- email: authenticatedUser.email,
967
- roles: authenticatedUser.roles,
968
- isAnonymous: authenticatedUser.isAnonymous === true
969
- });
970
- try {
971
- c.set("driver", await scopeDataDriver(driver, {
972
- uid: authenticatedUser.uid,
973
- roles: authenticatedUser.roles,
974
- isAnonymous: authenticatedUser.isAnonymous === true
975
- }));
976
- } catch (error) {
977
- logger.error("[AUTH-ADAPTER] RLS scoping failed for authenticated user", { error });
978
- return refuse(c, ApiError.internal("Internal authentication error"));
979
- }
980
- } else {
981
- if (presentedToken !== void 0) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
982
- try {
983
- c.set("driver", await scopeDataDriver(driver, {
984
- uid: ANONYMOUS_USER_ID,
985
- roles: ["anon"]
986
- }));
987
- } catch (error) {
988
- logger.error("[AUTH-ADAPTER] Failed to create anon-scoped driver", { error });
989
- return refuse(c, ApiError.internal("Server configuration error"));
990
- }
991
- }
992
- if (enforceAuth && !c.get("user")) return refuse(c, ApiError.unauthenticated("Unauthorized: Authentication required. This is the API-level gate, not a row rule — a collection granting public reads still needs a caller. Set AUTH_REQUIRE=false (or `auth.requireAuth: false`) to let RLS alone decide."));
993
- return next();
963
+ async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
964
+ if (!clearPassword) return { invitationSent: false };
965
+ if (!!(ctx.emailService && ctx.emailService.isConfigured())) try {
966
+ const token = generateSecureToken();
967
+ const tokenHash = hashToken$1(token);
968
+ const expiresAt = new Date(Date.now() + 1440 * 60 * 1e3);
969
+ await ctx.authRepo.createPasswordResetToken(entity.id, tokenHash, expiresAt);
970
+ const setPasswordUrl = `${ctx.emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
971
+ const { appName, logoUrl } = resolveEmailBranding(ctx.emailConfig);
972
+ const templateFn = ctx.emailConfig?.templates?.userInvitation;
973
+ const emailContent = templateFn ? templateFn(setPasswordUrl, {
974
+ email: entity.values.email,
975
+ displayName: entity.values.displayName
976
+ }) : getUserInvitationTemplate(setPasswordUrl, {
977
+ email: entity.values.email,
978
+ displayName: entity.values.displayName
979
+ }, appName, logoUrl);
980
+ await ctx.emailService.send({
981
+ to: entity.values.email,
982
+ subject: emailContent.subject,
983
+ html: emailContent.html,
984
+ text: emailContent.text
985
+ });
986
+ return { invitationSent: true };
987
+ } catch (emailError) {
988
+ logger.error("Failed to send reset email", { error: emailError instanceof Error ? emailError.message : emailError });
989
+ return {
990
+ temporaryPassword: clearPassword,
991
+ invitationSent: false,
992
+ emailDeliveryFailed: true
993
+ };
994
+ }
995
+ return {
996
+ temporaryPassword: clearPassword,
997
+ invitationSent: false
994
998
  };
995
999
  }
996
- //#endregion
997
- //#region src/email/html.ts
998
1000
  /**
999
- * HTML escaping for email bodies.
1001
+ * The step after a new user's row is written: who delivers the credentials,
1002
+ * and what the create response says about it.
1000
1003
  *
1001
- * Every default template interpolates values the server does not control a
1002
- * `displayName` chosen at registration, an `appName` from config, a link base a
1003
- * host set into markup that is then mailed, signed by the sending domain, to
1004
- * an address the same request chose. Unescaped, that turns `POST /auth/register`
1005
- * into a way to deliver arbitrary HTML (a heading, an anchor) from a domain
1006
- * whose SPF and DKIM check out.
1004
+ * A create hook that ran owns delivery. What it reported is the answer: the
1005
+ * invitation it says it sent, and the temporary password it chose, which the
1006
+ * admin has to see because it is the one the user will be told. The framework
1007
+ * sends nothing of its own. Without a hook, `finalize` runs: the built-in
1008
+ * invitation, or the generated password when email is not configured or fails.
1007
1009
  *
1008
- * The fix is deliberately not five escaping calls at five interpolation sites:
1009
- * a sixth template would simply forget. Templates are built with the {@link html}
1010
- * tag, which escapes *every* interpolated value by default, and markup that is
1011
- * meant to pass through verbatim the static style strings, a nested fragment —
1012
- * must say so with {@link raw}. Forgetting `raw` produces visibly escaped text
1013
- * in a test; forgetting to escape is invisible until someone exploits it.
1014
- */
1015
- /**
1016
- * Markup that has already been vetted and must be interpolated verbatim.
1010
+ * Both doors that create a user call this, the auth collection's REST route
1011
+ * and `POST /admin/users`. The second used to call `finalizeAdminUserCreation`
1012
+ * directly and read neither `hookHandledEmail` nor `invitationSent`, so it
1013
+ * handled a hook's password as if the framework had generated it. With email
1014
+ * configured it sent its own invitation, a second one when the hook had sent
1015
+ * its own, and left the password out of the response. The decision lives here
1016
+ * so that the two doors cannot disagree about it again.
1017
1017
  *
1018
- * Only ever construct this from a string literal in this package's own source
1019
- * (or from the {@link html} tag, which produces one). Wrapping user input in
1020
- * `raw()` defeats the entire mechanism.
1021
- */
1022
- var RawHtml = class {
1023
- value;
1024
- constructor(value) {
1025
- this.value = value;
1026
- }
1027
- toString() {
1028
- return this.value;
1029
- }
1030
- };
1031
- /**
1032
- * Mark a static, author-controlled string as safe to interpolate unescaped.
1018
+ * The result has exactly the fields a create response carries, with absent
1019
+ * ones left out.
1033
1020
  */
1034
- function raw(markup) {
1035
- return new RawHtml(markup);
1021
+ async function completeUserCreation(prepared, finalize) {
1022
+ const result = prepared.hookHandledEmail ? {
1023
+ temporaryPassword: prepared.clearPassword,
1024
+ invitationSent: prepared.invitationSent
1025
+ } : finalize ? await finalize(prepared.clearPassword) : { invitationSent: false };
1026
+ return {
1027
+ invitationSent: result.invitationSent,
1028
+ ...result.temporaryPassword ? { temporaryPassword: result.temporaryPassword } : {},
1029
+ ...result.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
1030
+ };
1036
1031
  }
1032
+ //#endregion
1033
+ //#region src/auth/token-revocation.ts
1037
1034
  /**
1038
- * Escape the five characters that can change the meaning of HTML text or of a
1039
- * quoted attribute value.
1035
+ * Has this access token been revoked?
1040
1036
  *
1041
- * `&` goes first: escaping it after the others would double-escape the entities
1042
- * they just produced. `'` is included because it is a legal attribute delimiter,
1043
- * and `"` because it is the one this file uses.
1037
+ * Everything that ends every session a user holds a password change or reset
1038
+ * of any kind, and `DELETE /auth/sessions` goes through
1039
+ * {@link revokeAllSessions}, which stamps a `tokensValidAfter` watermark on the
1040
+ * user. It also deletes refresh-token rows, which is what made the gap easy to
1041
+ * miss: the session really is gone, and the *refresh* path really does check
1042
+ * the watermark — so signing out looked like it worked.
1043
+ *
1044
+ * The access token was untouched. It is a bearer credential that nothing
1045
+ * consulted a database about, so it stayed valid for its full lifetime after
1046
+ * every one of those actions. "Sign out everywhere" left the stolen token
1047
+ * working; so did changing your password because you thought it had leaked.
1048
+ *
1049
+ * The watermark had exactly one read in the repository, on refresh. This adds
1050
+ * the other one.
1051
+ *
1052
+ * ## Cost
1053
+ *
1054
+ * One indexed lookup per request, on the paths that already make one. The
1055
+ * adapter re-reads roles for every authenticated request; the admin routes now
1056
+ * do too. Both call this with the row they were already fetching in mind — a
1057
+ * repository that wants to serve both from one query is free to cache
1058
+ * internally, but nothing here assumes it.
1059
+ *
1060
+ * ## Failure
1061
+ *
1062
+ * Fails **open**, deliberately, and this is the one place in the auth stack
1063
+ * where that is right. The watermark is an extra revocation signal layered over
1064
+ * a token that has already been verified — signature, expiry and purpose all
1065
+ * checked. Refusing every request when the database is unreachable would turn a
1066
+ * transient outage into a total sign-out of an entire deployment, and the
1067
+ * attacker this protects against needs to have already stolen a live token. The
1068
+ * failure is logged at warn so it is visible rather than silent.
1044
1069
  */
1045
- function escapeHtml(value) {
1046
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1070
+ async function isAccessTokenRevoked(authRepo, payload) {
1071
+ if (typeof authRepo.getTokensValidAfter !== "function") return false;
1072
+ if (typeof payload.iat !== "number") return false;
1073
+ let validAfter;
1074
+ try {
1075
+ validAfter = await authRepo.getTokensValidAfter(payload.uid);
1076
+ } catch (error) {
1077
+ logger.warn("[Auth] Could not read the token revocation watermark; allowing the request", {
1078
+ uid: payload.uid,
1079
+ error
1080
+ });
1081
+ return false;
1082
+ }
1083
+ if (!validAfter) return false;
1084
+ return payload.iat < Math.floor(validAfter.getTime() / 1e3);
1047
1085
  }
1048
1086
  /**
1049
- * Render one interpolated value: pass {@link RawHtml} through, drop nullish,
1050
- * escape everything else.
1087
+ * End every session this user holds, on every device.
1088
+ *
1089
+ * Two writes, because each covers what the other cannot. Deleting the refresh
1090
+ * rows ends the sessions that exist at this instant, and on a repository
1091
+ * without the watermark it is the only revocation there is. The watermark
1092
+ * voids what the delete cannot see: a refresh already in flight that inserts
1093
+ * its rotated token a moment after the delete ran, and every access token
1094
+ * already handed out, which {@link isAccessTokenRevoked} refuses from here on.
1095
+ *
1096
+ * The watermark write does not fail the caller. By the time it runs the
1097
+ * credential has usually already changed and the rows are gone, so a 500 would
1098
+ * tell someone their password did not change when it did. It is logged, because
1099
+ * a failure here leaves access tokens working until they expire.
1051
1100
  */
1052
- function interpolate(value) {
1053
- if (value instanceof RawHtml) return value.value;
1054
- if (value === null || value === void 0) return "";
1055
- return escapeHtml(String(value));
1101
+ async function revokeAllSessions(authRepo, uid) {
1102
+ await authRepo.deleteAllRefreshTokensForUser(uid);
1103
+ try {
1104
+ await authRepo.setTokensValidAfter?.(uid, /* @__PURE__ */ new Date());
1105
+ } catch (error) {
1106
+ logger.warn("[Auth] Could not write the token revocation watermark; access tokens issued before now stay valid until they expire", {
1107
+ uid,
1108
+ error
1109
+ });
1110
+ }
1056
1111
  }
1057
1112
  /**
1058
- * Tagged template for email markup. Every `${...}` is escaped unless it is
1059
- * {@link RawHtml}.
1113
+ * Replace a user's password and end every session they hold.
1060
1114
  *
1061
- * Returns `RawHtml` so fragments nest without being escaped a second time:
1115
+ * The one way a password hash is written over an existing account. A new
1116
+ * password is what someone sets when they believe the old one — or a session
1117
+ * signed in with it — is in someone else's hands, so a password change that
1118
+ * leaves those sessions alive does not do the thing it was done for.
1062
1119
  *
1063
- * ```ts
1064
- * const button = url ? html`<a href="${url}">Open</a>` : raw("");
1065
- * const body = html`<div>${button}</div>`;
1066
- * ```
1120
+ * It used to be a pair of lines repeated after each `updatePassword`, and it
1121
+ * reached the two self-service routes and none of the admin ones: an
1122
+ * administrator resetting a phished account left the attacker's refresh token
1123
+ * minting access tokens for the rest of its lifetime.
1124
+ * `test/password-change-revokes-sessions.test.ts` holds every route that sets a
1125
+ * password to this, and fails if anything but this function calls
1126
+ * `updatePassword`.
1067
1127
  */
1068
- function html(strings, ...values) {
1069
- let out = strings[0];
1070
- for (let i = 0; i < values.length; i++) out += interpolate(values[i]) + strings[i + 1];
1071
- return new RawHtml(out);
1128
+ async function replaceUserPassword(authRepo, uid, passwordHash) {
1129
+ await authRepo.updatePassword(uid, passwordHash);
1130
+ await revokeAllSessions(authRepo, uid);
1072
1131
  }
1073
1132
  //#endregion
1074
- //#region src/email/templates.ts
1133
+ //#region src/auth/rls-scope.ts
1134
+ var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
1075
1135
  /**
1076
- * Default email templates for authentication emails.
1136
+ * The identity the trusted server plane runs as: `rebase.dataAsAdmin`, and any
1137
+ * caller presenting the service key.
1077
1138
  *
1078
- * Every HTML body is built with the `html` tag from `./html`, which escapes each
1079
- * interpolated value unless it is explicitly marked `raw`. `displayName` reaches
1080
- * these templates straight from the registration body, and the resulting mail is
1081
- * sent signed by the sending domain to an address the same anonymous request
1082
- * chose, so an unescaped interpolation here is a phishing primitive, not a
1083
- * rendering glitch. Escaping lives in the tag rather than at the call sites so a
1084
- * sixth template inherits it instead of having to remember it.
1139
+ * Naming it makes the privilege legible, because it is narrower than it reads.
1140
+ * This is **not** an RLS bypass a driver scoped with it is an
1141
+ * `AuthenticatedPostgresBackendDriver`, so every statement runs in a
1142
+ * transaction that has done `SET LOCAL ROLE rebase_user` with
1143
+ * `app.uid = 'service'`, and policies are evaluated. Two consequences worth
1144
+ * knowing before you write a policy:
1145
+ *
1146
+ * - It passes the default policies through their `rolesOverlap(['admin'])`
1147
+ * arm — the same arm an application user holding the `admin` role passes.
1148
+ * - `policy.serverContext()` compiles to `rebase.uid() IS NULL` and is therefore
1149
+ * **false** for it. A collection with `disableDefaultPolicies: true` whose
1150
+ * only rule is `serverContext()` denies these writes (42501) and returns
1151
+ * zero rows for these reads.
1152
+ *
1153
+ * The true bypass is `rebase.sql()`, which runs on the owner connection and
1154
+ * never goes near `withAuth`.
1085
1155
  */
1156
+ var SERVICE_IDENTITY = {
1157
+ uid: "service",
1158
+ roles: ["admin"]
1159
+ };
1086
1160
  /**
1087
- * Get a greeting name for the user.
1088
- *
1089
- * Returns the raw value — `displayName` is whatever the account was registered
1090
- * with, including markup. Escaping is the `html` tag's job, so that a template
1091
- * that interpolates the greeting into text (the plain-text bodies below) and one
1092
- * that interpolates it into markup can share this.
1161
+ * Returns true if the driver supports RLS scoping via `withAuth()`.
1093
1162
  */
1094
- function getGreeting(user) {
1095
- return user.displayName || user.email.split("@")[0];
1163
+ function isRLSScopedDriver(driver) {
1164
+ return "withAuth" in driver && typeof driver.withAuth === "function";
1096
1165
  }
1097
1166
  /**
1098
- * Common email styles.
1167
+ * Can this driver run a request as a specific user?
1099
1168
  *
1100
- * `raw` because these are static, author-written CSS — the one category of
1101
- * interpolation the `html` tag lets through unescaped. (Escaping them would
1102
- * survive an HTML parser but not every mail client's CSS parser: the
1103
- * `'Segoe UI'` quotes would arrive as entities.)
1169
+ * The same question `scopeDataDriver` asks internally, exported because one
1170
+ * caller must act on the answer BEFORE it has a request to scope.
1171
+ *
1172
+ * `scopeDataDriver` returns the driver unscoped when the answer is no, which is
1173
+ * the right behaviour for `/api/data` — Mongo has no row-level security and
1174
+ * never claimed to, and the route's own checks still apply. It is the wrong
1175
+ * behaviour for a surface whose entire promise is "this runs as you": there,
1176
+ * an unscoped driver silently means "runs as the database owner". So the MCP
1177
+ * surface asks this at boot and declines to mount rather than degrade.
1104
1178
  */
1105
- var styles = {
1106
- container: raw(`
1107
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
1108
- max-width: 600px;
1109
- margin: 0 auto;
1110
- padding: 40px 20px;
1111
- background-color: #f8fafc;
1112
- `),
1113
- card: raw(`
1114
- background-color: #ffffff;
1115
- border-radius: 12px;
1116
- padding: 40px;
1117
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
1118
- `),
1119
- heading: raw(`
1120
- color: #1e293b;
1121
- font-size: 24px;
1122
- font-weight: 600;
1123
- margin: 0 0 20px 0;
1124
- `),
1125
- paragraph: raw(`
1126
- color: #475569;
1127
- font-size: 16px;
1128
- line-height: 1.6;
1129
- margin: 0 0 20px 0;
1130
- `),
1131
- button: raw(`
1132
- display: inline-block;
1133
- background-color: #3b82f6;
1134
- color: #ffffff;
1135
- font-size: 16px;
1136
- font-weight: 600;
1137
- text-decoration: none;
1138
- padding: 14px 28px;
1139
- border-radius: 8px;
1140
- margin: 20px 0;
1141
- `),
1142
- footer: raw(`
1143
- color: #94a3b8;
1144
- font-size: 14px;
1145
- margin-top: 30px;
1146
- padding-top: 20px;
1147
- border-top: 1px solid #e2e8f0;
1148
- `),
1149
- warning: raw(`
1150
- color: #64748b;
1151
- font-size: 14px;
1152
- background-color: #fef3c7;
1153
- padding: 12px 16px;
1154
- border-radius: 6px;
1155
- margin-top: 20px;
1156
- `),
1157
- /**
1158
- * The one-time code itself. Monospace so the digits line up, spaced so a
1159
- * `1` and a `7` are not read as one glyph, and large enough to be copied by
1160
- * eye from a phone onto another device — which is the entire flow.
1161
- */
1162
- code: raw(`
1163
- display: inline-block;
1164
- font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
1165
- font-size: 34px;
1166
- font-weight: 700;
1167
- letter-spacing: 10px;
1168
- color: #1e293b;
1169
- background-color: #f1f5f9;
1170
- border-radius: 10px;
1171
- padding: 18px 20px 18px 30px;
1172
- `)
1173
- };
1179
+ function supportsRlsScoping(driver) {
1180
+ return isRLSScopedDriver(driver);
1181
+ }
1174
1182
  /**
1175
- * The app name every template falls back to when `email.appName` is unset.
1183
+ * Scope a DataDriver via `withAuth()` for RLS.
1184
+ *
1185
+ * SECURITY: If `withAuth()` is available but fails, the error is re-thrown
1186
+ * so the request is **denied** rather than proceeding with unscoped access
1187
+ * (fail-closed behavior).
1188
+ *
1189
+ * If the driver does not support RLS, the original driver is returned.
1190
+ *
1191
+ * @param driver - The DataDriver to scope.
1192
+ * @param user - The authenticated user identity for RLS.
1193
+ * @returns The RLS-scoped DataDriver (or the original if RLS is unsupported).
1176
1194
  */
1177
- var DEFAULT_APP_NAME = "Rebase";
1195
+ async function scopeDataDriver(driver, user) {
1196
+ if (isRLSScopedDriver(driver)) return await driver.withAuth(user);
1197
+ return driver;
1198
+ }
1199
+ //#endregion
1200
+ //#region src/auth/crypto-utils.ts
1178
1201
  /**
1179
- * The Rebase mark, hosted. Mail clients do not render SVG (Gmail strips it
1180
- * outright), so this is the PNG rather than `logo.svg`, and it is an absolute
1181
- * URL rather than a data URI because Gmail blocks those in `<img src>` too.
1202
+ * Cryptographic utility functions for auth.
1203
+ *
1204
+ * @module
1182
1205
  */
1183
- var REBASE_LOGO_URL = "https://rebase.pro/img/logo_small.png";
1184
1206
  /**
1185
- * Whether a configured logo can actually be fetched by a mail client.
1207
+ * Constant-time string comparison to prevent timing attacks.
1186
1208
  *
1187
- * A relative path, a `data:` URI or a `file:` URL all produce a broken image in
1188
- * the recipient's inbox rather than an error anyone would see, so an unusable
1189
- * value renders no logo at all instead. This is deliberately not a boot-time
1190
- * assert like `assertEmailLinkBases`: a dead link makes the mail useless, a
1191
- * missing logo only makes it plain.
1209
+ * Used for comparing service keys, tokens, and other secrets where
1210
+ * timing side-channels could leak information about the expected value.
1211
+ *
1212
+ * The implementation lives in `utils/portable-crypto.ts`, which does this over
1213
+ * UTF-8 bytes without `node:crypto` this function is on the request path, and
1214
+ * the byte-level reasoning it depends on is documented there.
1215
+ *
1216
+ * @param a - First string to compare.
1217
+ * @param b - Second string to compare.
1218
+ * @returns `true` if the strings are identical, `false` otherwise.
1192
1219
  */
1193
- function isMailableImageUrl(url) {
1194
- if (!url) return false;
1195
- try {
1196
- const { protocol } = new URL(url);
1197
- return protocol === "https:" || protocol === "http:";
1198
- } catch {
1199
- return false;
1200
- }
1220
+ function safeCompare(a, b) {
1221
+ return constantTimeEqual(a, b);
1201
1222
  }
1223
+ //#endregion
1224
+ //#region src/auth/bearer-token.ts
1202
1225
  /**
1203
- * Whether this config still carries the stock app name — i.e. nobody has
1204
- * branded this install.
1226
+ * Parsing of the `Authorization: Bearer <token>` header.
1205
1227
  *
1206
- * Testing against the *value* rather than against `undefined` is deliberate and
1207
- * load-bearing: `APP_NAME` is declared `z.string().default("Rebase")` in the
1208
- * cloud, in the demo app and in the eject template, so `appName` arrives here as
1209
- * the literal string "Rebase" even when the operator set nothing. Keying the
1210
- * fallback on `appName === undefined` would mean the logo never appeared in any
1211
- * Rebase-run product.
1228
+ * Its own module rather than a helper inside `middleware.ts`, because the
1229
+ * API-key middleware needs it too and `middleware.ts` already imports from
1230
+ * there putting it in either one would close an import cycle.
1231
+ *
1232
+ * @module
1212
1233
  */
1213
- function isUnbranded(appName) {
1214
- return !appName || appName === DEFAULT_APP_NAME;
1215
- }
1216
1234
  /**
1217
- * Resolve the branding a template should render from the email config.
1235
+ * Pull the token out of an `Authorization: Bearer <token>` header.
1218
1236
  *
1219
- * The fallback is asymmetric on purpose. `appName` falls back to "Rebase"
1220
- * because an unconfigured app has no better name to show. The *logo* is stricter:
1221
- * an app that named itself "Acme" but set no `logoUrl` gets no logo at all,
1222
- * because the alternative is mailing Acme's users a Rebase mark from Acme's
1223
- * domain. Only an install that has not renamed itself is treated as Rebase's own.
1237
+ * RFC 7235 §2.1 makes the auth-scheme name case-insensitive, and plenty of
1238
+ * clients send `bearer` HTTP libraries that lower-case what they emit,
1239
+ * hand-written curl, proxies that normalise headers. Matching `"Bearer "`
1240
+ * exactly turned those into a 401 that, from the caller's side, is
1241
+ * indistinguishable from a rejected token; people spend an afternoon
1242
+ * regenerating credentials that were fine. Only the scheme is folded — the
1243
+ * token itself is returned byte-for-byte.
1244
+ *
1245
+ * Returns `undefined` when there is no header or it names another scheme.
1246
+ * That is deliberately distinct from an empty token (`Bearer ` with nothing
1247
+ * after it), which callers still route into verification so it fails as a bad
1248
+ * token rather than as a missing header.
1224
1249
  */
1225
- function resolveEmailBranding(config) {
1226
- const logoUrl = config?.logoUrl ?? (isUnbranded(config?.appName) ? REBASE_LOGO_URL : void 0);
1227
- return {
1228
- appName: config?.appName || DEFAULT_APP_NAME,
1229
- logoUrl: isMailableImageUrl(logoUrl) ? logoUrl : void 0
1230
- };
1250
+ function extractBearerToken(authHeader) {
1251
+ if (!authHeader) return void 0;
1252
+ const separator = authHeader.indexOf(" ");
1253
+ if (separator < 0) return void 0;
1254
+ if (authHeader.slice(0, separator).toLowerCase() !== "bearer") return void 0;
1255
+ return authHeader.slice(separator + 1);
1231
1256
  }
1257
+ //#endregion
1258
+ //#region src/auth/api-keys/api-key-middleware.ts
1232
1259
  /**
1233
- * The logo block that sits above the card in every default template.
1234
- *
1235
- * `width`/`height` are attributes as well as CSS because Outlook's Word renderer
1236
- * ignores the style block. `alt` carries the app name, for screen readers and
1237
- * for the very common case of a client that blocks remote images on a first
1238
- * email from an unknown sender.
1260
+ * Check whether a token looks like a Rebase API key.
1261
+ */
1262
+ function isApiKeyToken(token) {
1263
+ return token.startsWith("rk_");
1264
+ }
1265
+ /**
1266
+ * Hash a plaintext API key token for database lookup.
1239
1267
  *
1240
- * The type styling on the `<img>` is what that blocked case falls back to: alt
1241
- * text inherits it, and at the body's 16px "Rebase" overflows a 48px box and
1242
- * renders as "Rebas". 12px plus `height: auto` — CSS the clients that block
1243
- * images do read, while Outlook still sizes from the attributes — lets the name
1244
- * through intact.
1268
+ * Async because it hashes with WebCrypto rather than `node:crypto` the sole
1269
+ * caller was already awaiting the store lookup this feeds.
1245
1270
  */
1246
- function renderHeader(appName, logoUrl) {
1247
- if (!logoUrl) return raw("");
1248
- return html`
1249
- <div style="text-align: center; margin-bottom: 24px;">
1250
- <img src="${logoUrl}" width="48" height="48" alt="${appName}"
1251
- style="display: inline-block; width: 48px; height: auto; border: 0; outline: none; text-decoration: none; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 12px; color: #64748b;">
1252
- </div>
1253
- `;
1271
+ function hashToken(token) {
1272
+ return sha256Hex(token);
1254
1273
  }
1255
1274
  /**
1256
- * Default password reset email template
1275
+ * Validate an API key token and populate the Hono context.
1276
+ *
1277
+ * Returns `true` if the key is valid and context has been populated,
1278
+ * or returns an error Response if the key is invalid.
1279
+ *
1280
+ * This is NOT a standalone middleware — it's called from within
1281
+ * `createAuthMiddleware()` when a `rk_` prefixed token is detected.
1257
1282
  */
1258
- function getPasswordResetTemplate(resetUrl, user, appName = DEFAULT_APP_NAME, logoUrl) {
1259
- const greeting = getGreeting(user);
1260
- const subject = `Reset your ${appName} password`;
1261
- const body = html`
1262
- <!DOCTYPE html>
1263
- <html>
1264
- <head>
1265
- <meta charset="utf-8">
1266
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1267
- <title>${subject}</title>
1268
- </head>
1269
- <body style="margin: 0; padding: 0; background-color: #f8fafc;">
1270
- <div style="${styles.container}">${renderHeader(appName, logoUrl)}
1271
- <div style="${styles.card}">
1272
- <h1 style="${styles.heading}">Reset Your Password</h1>
1273
-
1274
- <p style="${styles.paragraph}">
1275
- Hi ${greeting},
1276
- </p>
1277
-
1278
- <p style="${styles.paragraph}">
1279
- We received a request to reset your password for your ${appName} account.
1280
- Click the button below to create a new password:
1281
- </p>
1282
-
1283
- <div style="text-align: center;">
1284
- <a href="${resetUrl}" style="${styles.button}">Reset Password</a>
1285
- </div>
1286
-
1287
- <p style="${styles.paragraph}">
1288
- Or copy and paste this link into your browser:
1289
- </p>
1290
- <p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
1291
- ${resetUrl}
1292
- </p>
1293
-
1294
- <div style="${styles.warning}">
1295
- ⏰ This link will expire in 1 hour for security reasons.
1296
- </div>
1297
-
1298
- <div style="${styles.footer}">
1299
- <p style="margin: 0;">
1300
- If you didn't request a password reset, you can safely ignore this email.
1301
- Your password will remain unchanged.
1302
- </p>
1303
- </div>
1304
- </div>
1305
- </div>
1306
- </body>
1307
- </html>
1308
- `;
1309
- const text = `
1310
- Reset Your Password
1311
-
1312
- Hi ${greeting},
1313
-
1314
- We received a request to reset your password for your ${appName} account.
1315
-
1316
- Click this link to create a new password:
1317
- ${resetUrl}
1318
-
1319
- This link will expire in 1 hour for security reasons.
1320
-
1321
- If you didn't request a password reset, you can safely ignore this email.
1322
- Your password will remain unchanged.
1323
- `.trim();
1324
- return {
1325
- subject,
1326
- html: body.toString().trim(),
1327
- text
1283
+ async function validateApiKey(c, token, options) {
1284
+ const { store, driver } = options;
1285
+ const hash = await hashToken(token);
1286
+ const apiKey = await store.findByKeyHash(hash);
1287
+ if (!apiKey) return c.json({ error: {
1288
+ message: "Invalid API key",
1289
+ code: "UNAUTHORIZED"
1290
+ } }, 401);
1291
+ if (apiKey.revoked_at) return c.json({ error: {
1292
+ message: "API key has been revoked",
1293
+ code: "UNAUTHORIZED"
1294
+ } }, 401);
1295
+ if (apiKey.expires_at && new Date(apiKey.expires_at) < /* @__PURE__ */ new Date()) return c.json({ error: {
1296
+ message: "API key has expired",
1297
+ code: "UNAUTHORIZED"
1298
+ } }, 401);
1299
+ const uid = `api-key:${apiKey.id}`;
1300
+ const roles = apiKey.admin ? ["admin", "service"] : ["service"];
1301
+ c.set("user", {
1302
+ uid,
1303
+ roles
1304
+ });
1305
+ const masked = {
1306
+ id: apiKey.id,
1307
+ name: apiKey.name,
1308
+ key_prefix: apiKey.key_prefix,
1309
+ permissions: apiKey.permissions,
1310
+ admin: apiKey.admin,
1311
+ rate_limit: apiKey.rate_limit,
1312
+ created_by: apiKey.created_by,
1313
+ created_at: apiKey.created_at,
1314
+ updated_at: apiKey.updated_at,
1315
+ last_used_at: apiKey.last_used_at,
1316
+ expires_at: apiKey.expires_at,
1317
+ revoked_at: apiKey.revoked_at
1328
1318
  };
1319
+ c.set("apiKey", masked);
1320
+ try {
1321
+ const scopedDriver = await scopeDataDriver(driver, {
1322
+ uid,
1323
+ roles
1324
+ });
1325
+ c.set("driver", scopedDriver);
1326
+ } catch (error) {
1327
+ logger.error("[AUTH] RLS scoping failed for API key", { error });
1328
+ return c.json({ error: {
1329
+ message: "Internal authentication error",
1330
+ code: "INTERNAL_ERROR"
1331
+ } }, 500);
1332
+ }
1333
+ const lastTouch = lastUsedTouchedAt.get(apiKey.id);
1334
+ const now = Date.now();
1335
+ if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
1336
+ lastUsedTouchedAt.set(apiKey.id, now);
1337
+ store.updateLastUsed(apiKey.id).catch(() => {});
1338
+ }
1339
+ return true;
1329
1340
  }
1341
+ /** Per-process debounce state for last_used_at touches. */
1342
+ var lastUsedTouchedAt = /* @__PURE__ */ new Map();
1343
+ var LAST_USED_DEBOUNCE_MS = 6e4;
1330
1344
  /**
1331
- * Default email verification template
1345
+ * Shared 403 response for API-key permission denials outside the REST
1346
+ * generator (storage and functions guards). One envelope, one code — a
1347
+ * change to the error contract happens in one place.
1332
1348
  */
1333
- function getEmailVerificationTemplate(verifyUrl, user, appName = DEFAULT_APP_NAME, logoUrl) {
1334
- const greeting = getGreeting(user);
1335
- const subject = `Verify your ${appName} email address`;
1336
- const body = html`
1337
- <!DOCTYPE html>
1338
- <html>
1339
- <head>
1340
- <meta charset="utf-8">
1341
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1342
- <title>${subject}</title>
1343
- </head>
1344
- <body style="margin: 0; padding: 0; background-color: #f8fafc;">
1345
- <div style="${styles.container}">${renderHeader(appName, logoUrl)}
1346
- <div style="${styles.card}">
1347
- <h1 style="${styles.heading}">Verify Your Email</h1>
1348
-
1349
- <p style="${styles.paragraph}">
1350
- Hi ${greeting},
1351
- </p>
1352
-
1353
- <p style="${styles.paragraph}">
1354
- Thanks for signing up for ${appName}! Please verify your email address
1355
- by clicking the button below:
1356
- </p>
1357
-
1358
- <div style="text-align: center;">
1359
- <a href="${verifyUrl}" style="${styles.button}">Verify Email Address</a>
1360
- </div>
1361
-
1362
- <p style="${styles.paragraph}">
1363
- Or copy and paste this link into your browser:
1364
- </p>
1365
- <p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
1366
- ${verifyUrl}
1367
- </p>
1368
-
1369
- <div style="${styles.footer}">
1370
- <p style="margin: 0;">
1371
- If you didn't create an account with ${appName}, you can safely ignore this email.
1372
- </p>
1373
- </div>
1374
- </div>
1375
- </div>
1376
- </body>
1377
- </html>
1378
- `;
1379
- const text = `
1380
- Verify Your Email
1381
-
1382
- Hi ${greeting},
1383
-
1384
- Thanks for signing up for ${appName}! Please verify your email address by clicking this link:
1385
- ${verifyUrl}
1386
-
1387
- If you didn't create an account with ${appName}, you can safely ignore this email.
1388
- `.trim();
1389
- return {
1390
- subject,
1391
- html: body.toString().trim(),
1392
- text
1393
- };
1349
+ function forbidApiKey(c, operation, resource, grantHint) {
1350
+ return c.json({ error: {
1351
+ message: `API key does not have "${operation}" permission for ${resource}. Grant it with a permission entry like { "collection": "${grantHint}", "operations": ["${operation}"] }.`,
1352
+ code: "API_KEY_FORBIDDEN"
1353
+ } }, 403);
1394
1354
  }
1395
1355
  /**
1396
- * Default user invitation email template
1397
- * Sent when an admin creates a new user account
1356
+ * Permission guard for API-key requests to the storage router.
1357
+ *
1358
+ * Storage previously did not accept API keys at all (`rk_` tokens were
1359
+ * misparsed as JWTs and 401'd). Now that the pre-auth middleware
1360
+ * authenticates them, this guard decides what they may do: the key needs a
1361
+ * `"storage"` permission entry (or the global `"*"` wildcard) covering the
1362
+ * operation derived from the HTTP method. Requests not authenticated via an
1363
+ * API key pass through to the storage router's own auth gates.
1364
+ *
1365
+ * TUS resumable-upload routes (`/tus`, `/tus/:id`) are classified as `write`
1366
+ * for EVERY method: the protocol's offset check is a GET and its cancel is a
1367
+ * DELETE, but both are steps of an upload — a write-scoped key must be able
1368
+ * to complete (and abort) its own resumable upload without also holding
1369
+ * `read`/`delete` on stored objects.
1398
1370
  */
1399
- function getUserInvitationTemplate(setPasswordUrl, user, appName = DEFAULT_APP_NAME, logoUrl) {
1400
- const greeting = getGreeting(user);
1401
- const subject = `You've been invited to ${appName}`;
1402
- const body = html`
1403
- <!DOCTYPE html>
1404
- <html>
1405
- <head>
1406
- <meta charset="utf-8">
1407
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1408
- <title>${subject}</title>
1409
- </head>
1410
- <body style="margin: 0; padding: 0; background-color: #f8fafc;">
1411
- <div style="${styles.container}">${renderHeader(appName, logoUrl)}
1412
- <div style="${styles.card}">
1413
- <h1 style="${styles.heading}">Welcome to ${appName}!</h1>
1414
-
1415
- <p style="${styles.paragraph}">
1416
- Hi ${greeting},
1417
- </p>
1418
-
1419
- <p style="${styles.paragraph}">
1420
- An account has been created for you on ${appName}.
1421
- Click the button below to set your password and get started:
1422
- </p>
1423
-
1424
- <div style="text-align: center;">
1425
- <a href="${setPasswordUrl}" style="${styles.button}">Set Your Password</a>
1426
- </div>
1427
-
1428
- <p style="${styles.paragraph}">
1429
- Or copy and paste this link into your browser:
1430
- </p>
1431
- <p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
1432
- ${setPasswordUrl}
1433
- </p>
1434
-
1435
- <div style="${styles.warning}">
1436
- ⏰ This link will expire in 1 hour for security reasons.
1437
- </div>
1438
-
1439
- <div style="${styles.footer}">
1440
- <p style="margin: 0;">
1441
- If you weren't expecting this invitation, you can safely ignore this email.
1442
- </p>
1443
- </div>
1444
- </div>
1445
- </div>
1446
- </body>
1447
- </html>
1448
- `;
1449
- const text = `
1450
- Welcome to ${appName}!
1451
-
1452
- Hi ${greeting},
1453
-
1454
- An account has been created for you on ${appName}.
1455
-
1456
- Click this link to set your password and get started:
1457
- ${setPasswordUrl}
1458
-
1459
- This link will expire in 1 hour for security reasons.
1460
-
1461
- If you weren't expecting this invitation, you can safely ignore this email.
1462
- `.trim();
1463
- return {
1464
- subject,
1465
- html: body.toString().trim(),
1466
- text
1371
+ function createStorageApiKeyGuard() {
1372
+ return async (c, next) => {
1373
+ const apiKey = c.get("apiKey");
1374
+ if (!apiKey) return next();
1375
+ const operation = /\/tus(\/|$)/.test(c.req.path) ? "write" : httpMethodToOperation(c.req.method);
1376
+ if (!isStorageAllowed(apiKey.permissions, operation)) return forbidApiKey(c, operation, "storage", "storage");
1377
+ return next();
1467
1378
  };
1468
1379
  }
1469
1380
  /**
1470
- * Default welcome email template
1471
- * Sent automatically when a new user registers
1381
+ * Permission guard for API-key requests to the custom-functions router.
1382
+ *
1383
+ * The collection permission guard lives in the REST generator and never sees
1384
+ * function routes, so before this middleware existed ANY valid API key —
1385
+ * however narrowly scoped — could invoke every custom function. This guard
1386
+ * closes that: API-key requests must hold a `"functions"`/`"functions/<name>"`
1387
+ * permission entry (or the global `"*"` wildcard) for the derived operation.
1388
+ *
1389
+ * Non-API-key requests (JWT, service key, anonymous) pass through untouched —
1390
+ * functions decide their own auth for those, as before.
1391
+ *
1392
+ * @param mountPrefix - The path the functions router is mounted at
1393
+ * (e.g. `/api/functions`), used to extract the function
1394
+ * name from the request path.
1472
1395
  */
1473
- function getWelcomeEmailTemplate(user, appName = DEFAULT_APP_NAME, loginUrl, logoUrl) {
1474
- const greeting = getGreeting(user);
1475
- const url = loginUrl || "";
1476
- const subject = `Welcome to ${appName}`;
1477
- const body = html`
1478
- <!DOCTYPE html>
1479
- <html>
1480
- <head>
1481
- <meta charset="utf-8">
1482
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1483
- <title>${subject}</title>
1484
- </head>
1485
- <body style="margin: 0; padding: 0; background-color: #f8fafc;">
1486
- <div style="${styles.container}">${renderHeader(appName, logoUrl)}
1487
- <div style="${styles.card}">
1488
- <h1 style="${styles.heading}">Welcome to ${appName}</h1>
1489
-
1490
- <p style="${styles.paragraph}">
1491
- Hi ${greeting},
1492
- </p>
1493
-
1494
- <p style="${styles.paragraph}">
1495
- Your ${appName} account is ready.
1496
- </p>
1497
-
1498
- ${url ? html`
1499
- <div style="text-align: center;">
1500
- <a href="${url}" style="${styles.button}">Open ${appName}</a>
1501
- </div>
1502
- ` : raw("")}
1503
-
1504
- <p style="${styles.paragraph}">
1505
- If you have any questions, just reply to this email.
1506
- </p>
1507
-
1508
- <div style="${styles.footer}">
1509
- <p style="margin: 0;">
1510
- You received this because an account was created with this email address at ${appName}.
1511
- </p>
1512
- </div>
1513
- </div>
1514
- </div>
1515
- </body>
1516
- </html>
1517
- `;
1518
- const text = `
1519
- Welcome to ${appName}
1520
-
1521
- Hi ${greeting},
1522
-
1523
- Your ${appName} account is ready.
1524
-
1525
- ${url ? `Open ${appName}: ${url}` : ""}
1526
-
1527
- If you have any questions, just reply to this email.
1528
- `.trim();
1529
- return {
1530
- subject,
1531
- html: body.toString().trim(),
1532
- text
1396
+ function createFunctionApiKeyGuard(mountPrefix) {
1397
+ return async (c, next) => {
1398
+ const apiKey = c.get("apiKey");
1399
+ if (!apiKey) return next();
1400
+ const path = c.req.path;
1401
+ const idx = path.indexOf(mountPrefix);
1402
+ const rawName = (idx < 0 ? "" : path.slice(idx + mountPrefix.length)).split("/").filter(Boolean)[0] ?? "";
1403
+ let functionName;
1404
+ try {
1405
+ functionName = decodeURIComponent(rawName);
1406
+ } catch {
1407
+ functionName = rawName;
1408
+ }
1409
+ const operation = httpMethodToOperation(c.req.method);
1410
+ if (!isFunctionAllowed(apiKey.permissions, functionName, operation)) return forbidApiKey(c, operation, functionName ? `function "${functionName}"` : "the functions index", `functions${functionName ? `/${functionName}` : ""}`);
1411
+ return next();
1533
1412
  };
1534
1413
  }
1535
1414
  /**
1536
- * Default one-time sign-in code email.
1415
+ * Standalone pre-auth middleware for `rk_` bearer tokens.
1537
1416
  *
1538
- * The code is the entire content, so it is set large and repeated in the
1539
- * plain-text part: somebody is about to copy it by eye onto another device,
1540
- * which is the whole reason this flow exists rather than a link.
1417
+ * Routers whose auth gate is JWT-based (`requireAuth` / `createRequireAuth`
1418
+ * the admin surfaces: admin users/roles, api-keys management, cron, backups,
1419
+ * logs, schema editor) don't know about API keys. Mounting this middleware in
1420
+ * front of them authenticates `rk_` tokens and populates the request context;
1421
+ * the downstream gates then see the already-resolved user and apply their
1422
+ * role checks (`requireAdmin`) as usual — so an `admin: true` key passes and
1423
+ * a non-admin key is rejected with 403.
1424
+ *
1425
+ * Requests without an `rk_` bearer token pass through untouched. An invalid,
1426
+ * revoked, or expired `rk_` token is rejected here (401) rather than falling
1427
+ * through to be misparsed as a JWT.
1541
1428
  */
1542
- function getEmailOtpTemplate(code, user, appName = DEFAULT_APP_NAME, logoUrl) {
1543
- const greeting = getGreeting(user);
1544
- const subject = `${code} is your ${appName} sign-in code`;
1545
- const body = html`
1546
- <!DOCTYPE html>
1547
- <html>
1548
- <head>
1549
- <meta charset="utf-8">
1550
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1551
- <title>${subject}</title>
1552
- </head>
1553
- <body style="margin: 0; padding: 0; background-color: #f8fafc;">
1554
- <div style="${styles.container}">${renderHeader(appName, logoUrl)}
1555
- <div style="${styles.card}">
1556
- <h1 style="${styles.heading}">Your sign-in code</h1>
1557
-
1558
- <p style="${styles.paragraph}">
1559
- Hi ${greeting},
1560
- </p>
1561
-
1562
- <p style="${styles.paragraph}">
1563
- Enter this code to finish signing in to ${appName}:
1564
- </p>
1565
-
1566
- <div style="text-align: center; margin: 32px 0;">
1567
- <span style="${styles.code}">${code}</span>
1568
- </div>
1569
-
1570
- <div style="${styles.warning}">
1571
- ⏰ This code expires in 10 minutes and can only be used once.
1572
- </div>
1573
-
1574
- <div style="${styles.footer}">
1575
- <p style="margin: 0;">
1576
- If you didn't try to sign in, you can safely ignore this email.
1577
- We will never ask you for this code.
1578
- </p>
1579
- </div>
1580
- </div>
1581
- </div>
1582
- </body>
1583
- </html>
1584
- `;
1585
- const text = `
1586
- Your ${appName} sign-in code
1587
-
1588
- Hi ${greeting},
1589
-
1590
- Enter this code to finish signing in:
1591
-
1592
- ${code}
1593
-
1594
- This code expires in 10 minutes and can only be used once.
1595
-
1596
- If you didn't try to sign in, you can safely ignore this email. We will never
1597
- ask you for this code.
1598
- `.trim();
1599
- return {
1600
- subject,
1601
- html: body.toString().trim(),
1602
- text
1429
+ function createApiKeyPreAuth(options) {
1430
+ return async (c, next) => {
1431
+ if (c.get("apiKey")) return next();
1432
+ const token = extractBearerToken(c.req.header("authorization")) ?? "";
1433
+ if (!token || !isApiKeyToken(token)) return next();
1434
+ const result = await validateApiKey(c, token, options);
1435
+ if (result !== true) return result;
1436
+ return next();
1603
1437
  };
1604
1438
  }
1439
+ //#endregion
1440
+ //#region src/auth/middleware.ts
1605
1441
  /**
1606
- * Default magic link email template
1442
+ * Answer a refusal with the canonical envelope, through the same formatter
1443
+ * every route uses.
1444
+ *
1445
+ * These middlewares used to hand-build `c.json({ error: { message, code } },
1446
+ * 401)` — seven near-copies of it, which is how they came to omit `requestId`
1447
+ * while every other error on the server carried it: the one string a bug report
1448
+ * quotes was missing from precisely the errors an app hits most. It also meant
1449
+ * a routine 401 (a client refreshing before it knows whether a session exists)
1450
+ * logged like an incident, because `ApiError.expected` never reached the
1451
+ * logger.
1452
+ *
1453
+ * `errorHandler` is *called* rather than thrown to. A throw only becomes an
1454
+ * envelope where an `onError` is registered, and these middlewares are mounted
1455
+ * by callers this package does not control — `defineFunction` hands users a
1456
+ * Hono app and invites them to put `requireAuth` on a route of it. Making the
1457
+ * status depend on the mount point would turn a 401 into a 500 for exactly
1458
+ * those callers. Calling the formatter directly gets the envelope with none of
1459
+ * that.
1607
1460
  */
1608
- function getMagicLinkTemplate(magicLinkUrl, user, appName = DEFAULT_APP_NAME, logoUrl) {
1609
- const greeting = getGreeting(user);
1610
- const subject = `Sign in to ${appName}`;
1611
- const body = html`
1612
- <!DOCTYPE html>
1613
- <html>
1614
- <head>
1615
- <meta charset="utf-8">
1616
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1617
- <title>${subject}</title>
1618
- </head>
1619
- <body style="margin: 0; padding: 0; background-color: #f8fafc;">
1620
- <div style="${styles.container}">${renderHeader(appName, logoUrl)}
1621
- <div style="${styles.card}">
1622
- <h1 style="${styles.heading}">Sign In to ${appName}</h1>
1623
-
1624
- <p style="${styles.paragraph}">
1625
- Hi ${greeting},
1626
- </p>
1627
-
1628
- <p style="${styles.paragraph}">
1629
- We received a request to sign in to your ${appName} account.
1630
- Click the button below to log in:
1631
- </p>
1632
-
1633
- <div style="text-align: center;">
1634
- <a href="${magicLinkUrl}" style="${styles.button}">Sign In</a>
1635
- </div>
1636
-
1637
- <p style="${styles.paragraph}">
1638
- Or copy and paste this link into your browser:
1639
- </p>
1640
- <p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
1641
- ${magicLinkUrl}
1642
- </p>
1643
-
1644
- <div style="${styles.warning}">
1645
- ⏰ This link will expire in 15 minutes for security reasons and can only be used once.
1646
- </div>
1647
-
1648
- <div style="${styles.footer}">
1649
- <p style="margin: 0;">
1650
- If you didn't request this sign-in link, you can safely ignore this email.
1651
- No action is needed.
1652
- </p>
1653
- </div>
1654
- </div>
1655
- </div>
1656
- </body>
1657
- </html>
1658
- `;
1659
- const text = `
1660
- Sign In to ${appName}
1661
-
1662
- Hi ${greeting},
1663
-
1664
- We received a request to sign in to your ${appName} account.
1665
-
1666
- Click this link to log in:
1667
- ${magicLinkUrl}
1668
-
1669
- This link will expire in 15 minutes for security reasons and can only be used once.
1670
-
1671
- If you didn't request this sign-in link, you can safely ignore this email.
1672
- No action is needed.
1673
- `.trim();
1674
- return {
1675
- subject,
1676
- html: body.toString().trim(),
1677
- text
1678
- };
1461
+ function refuse(c, error) {
1462
+ return errorHandler(error, c);
1679
1463
  }
1680
- //#endregion
1681
- //#region src/auth/admin-user-ops.ts
1682
1464
  /**
1683
- * Admin User Operations
1465
+ * Hono middleware that requires a valid JWT token via Authorization header.
1466
+ * Returns 401 if token is missing or invalid.
1684
1467
  *
1685
- * Shared utilities and orchestration for admin-initiated user management
1686
- * (user creation via REST API, password reset via admin panel).
1468
+ * **Security:** Tokens are only accepted via the `Authorization: Bearer`
1469
+ * header. Query-string tokens (`?token=`) are intentionally NOT accepted
1470
+ * here because URLs leak into access logs, proxies, Referer headers, and
1471
+ * browser history. Use {@link queryTokenAuth} on routes that legitimately
1472
+ * need query-string tokens (e.g. storage file serving for `<img src>`).
1473
+ */
1474
+ var requireAuth = async (c, next) => {
1475
+ if (c.get("user")) return next();
1476
+ const token = extractBearerToken(c.req.header("authorization"));
1477
+ if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
1478
+ const payload = await verifyAccessToken(token);
1479
+ if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
1480
+ c.set("user", payload);
1481
+ return next();
1482
+ };
1483
+ /**
1484
+ * Factory that creates a requireAuth middleware with optional service key support.
1687
1485
  *
1688
- * Hook resolution order:
1689
- * 1. Collection-level hook (`auth.onCreateUser` on the collection) closest to the data
1690
- * 2. Backend-level hook (`AuthHooks.onAdminCreateUser`) global override
1691
- * 3. Built-in default — framework fallback
1486
+ * When `serviceKey` is provided, the middleware will check if the Bearer token
1487
+ * matches the service key using constant-time comparison. If it matches, the
1488
+ * request is authenticated as a service user with admin privileges.
1489
+ *
1490
+ * This allows admin routes (which use standalone requireAuth + requireAdmin)
1491
+ * to be accessed via service keys for scripts and server-to-server calls.
1492
+ */
1493
+ function createRequireAuth(options) {
1494
+ if (!options?.serviceKey && !options?.resolveRoles && !options?.revocationRepo) return requireAuth;
1495
+ const key = options.serviceKey;
1496
+ const resolveRoles = options.resolveRoles;
1497
+ const revocationRepo = options.revocationRepo;
1498
+ return async (c, next) => {
1499
+ if (c.get("user")) return next();
1500
+ const token = extractBearerToken(c.req.header("authorization"));
1501
+ if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
1502
+ if (key !== void 0 && safeCompare(token, key)) {
1503
+ c.set("user", {
1504
+ uid: "service",
1505
+ roles: ["admin"]
1506
+ });
1507
+ return next();
1508
+ }
1509
+ const payload = await verifyAccessToken(token);
1510
+ if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
1511
+ if (resolveRoles || revocationRepo) try {
1512
+ if (revocationRepo && await isAccessTokenRevoked(revocationRepo, payload)) return refuse(c, ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED"));
1513
+ c.set("user", resolveRoles ? {
1514
+ ...payload,
1515
+ roles: await resolveRoles(payload.uid)
1516
+ } : payload);
1517
+ return next();
1518
+ } catch (error) {
1519
+ logger.warn("[Auth] Could not resolve roles for an admin-gated request", {
1520
+ uid: payload.uid,
1521
+ error
1522
+ });
1523
+ return c.json({ error: {
1524
+ message: "Could not verify your permissions. Please try again.",
1525
+ code: "ROLE_LOOKUP_FAILED"
1526
+ } }, 503);
1527
+ }
1528
+ c.set("user", payload);
1529
+ return next();
1530
+ };
1531
+ }
1532
+ /**
1533
+ * Middleware that requires the user to have an admin or schema-admin role.
1534
+ * Must be used AFTER requireAuth or on a route where user is guaranteed.
1692
1535
  */
1536
+ var requireAdmin = async (c, next) => {
1537
+ const user = c.get("user");
1538
+ if (!user) return refuse(c, ApiError.unauthorized("User not authenticated. requireAuth middleware is missing?"));
1539
+ if (!hasAdministrativeRole(typeof user === "object" && user !== null && "roles" in user ? user.roles || [] : [])) return refuse(c, ApiError.forbidden("Admin privileges required for this operation"));
1540
+ return next();
1541
+ };
1693
1542
  /**
1694
- * Generate a cryptographically secure random password that meets strength requirements.
1543
+ * Middleware that optionally extracts user from JWT via Authorization header.
1544
+ * Does not return 401 if token is missing — allows anonymous access.
1695
1545
  *
1696
- * 16 characters, guaranteed at least one uppercase, one lowercase, one digit.
1697
- * Ambiguous characters (0, O, 1, l, I) are excluded.
1546
+ * Query-string tokens are NOT accepted here. Use {@link queryTokenAuth}
1547
+ * on routes that need them.
1698
1548
  */
1699
- function generateSecurePassword() {
1700
- const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
1701
- const lower = "abcdefghjkmnpqrstuvwxyz";
1702
- const digits = "23456789";
1703
- const all = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
1704
- const pick = (chars) => chars[randomInt(chars.length)];
1705
- const parts = [
1706
- pick(upper),
1707
- pick(lower),
1708
- pick(digits)
1709
- ];
1710
- for (let i = parts.length; i < 16; i++) parts.push(pick(all));
1711
- for (let i = parts.length - 1; i > 0; i--) {
1712
- const j = randomInt(i + 1);
1713
- [parts[i], parts[j]] = [parts[j], parts[i]];
1549
+ var optionalAuth = async (c, next) => {
1550
+ if (c.get("user")) return next();
1551
+ const token = extractBearerToken(c.req.header("authorization"));
1552
+ if (token !== void 0 && isJwtConfigured()) {
1553
+ const payload = await verifyAccessToken(token);
1554
+ if (payload) c.set("user", payload);
1714
1555
  }
1715
- return parts.join("");
1716
- }
1556
+ return next();
1557
+ };
1717
1558
  /**
1718
- * Generate a cryptographically secure random token (80 hex characters).
1559
+ * Extract user from token - for WebSocket authentication
1719
1560
  */
1720
- function generateSecureToken() {
1721
- return randomBytes(40).toString("hex");
1561
+ async function extractUserFromToken(token) {
1562
+ if (!isJwtConfigured()) return null;
1563
+ return verifyAccessToken(token);
1722
1564
  }
1723
1565
  /**
1724
- * Hash a token for database storage using SHA-256.
1566
+ * Create a configurable auth middleware that handles:
1567
+ * 1. Token extraction (via custom validator or JWT Bearer token)
1568
+ * 2. RLS-scoped DataDriver via withAuth()
1569
+ * 3. Enforcement (401 when requireAuth is true and no user)
1570
+ *
1571
+ * **Secure by default:** `requireAuth` defaults to `true`. Anonymous
1572
+ * access is only allowed when the developer explicitly opts out by
1573
+ * setting `requireAuth: false`, indicating that Postgres RLS policies
1574
+ * fully control access.
1575
+ *
1576
+ * **Fail-closed:** The raw unscoped driver is never placed in the
1577
+ * request context. Every code path either scopes via `withAuth()` or
1578
+ * rejects the request. This prevents silent RLS bypass.
1579
+ *
1580
+ * This is the single source of truth for HTTP auth in Rebase.
1581
+ * Use this instead of manually parsing tokens in route handlers.
1725
1582
  */
1726
- function hashToken(token) {
1727
- return createHash("sha256").update(token).digest("hex");
1583
+ function createAuthMiddleware(options) {
1584
+ const { driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
1585
+ return async (c, next) => {
1586
+ const driver = resolveDriver ? resolveDriver(c) : baseDriver;
1587
+ if (validator) try {
1588
+ const authResult = await validator(c);
1589
+ if (authResult && typeof authResult === "object") {
1590
+ const id = "uid" in authResult ? authResult.uid : void 0;
1591
+ if (id) {
1592
+ const roles = authResult.roles || [];
1593
+ c.set("user", {
1594
+ uid: id,
1595
+ roles
1596
+ });
1597
+ const user = {
1598
+ uid: id,
1599
+ roles,
1600
+ ...authResult
1601
+ };
1602
+ c.set("driver", await scopeDataDriver(driver, user));
1603
+ } else c.set("driver", await scopeDataDriver(driver, {
1604
+ uid: ANONYMOUS_USER_ID,
1605
+ roles: ["anon"]
1606
+ }));
1607
+ } else if (authResult === true) {
1608
+ c.set("user", {
1609
+ uid: "default",
1610
+ roles: []
1611
+ });
1612
+ c.set("driver", await scopeDataDriver(driver, {
1613
+ uid: "default",
1614
+ roles: []
1615
+ }));
1616
+ } else c.set("driver", await scopeDataDriver(driver, {
1617
+ uid: ANONYMOUS_USER_ID,
1618
+ roles: ["anon"]
1619
+ }));
1620
+ } catch (error) {
1621
+ return refuse(c, ApiError.unauthenticated("Unauthorized"));
1622
+ }
1623
+ else {
1624
+ const token = extractBearerToken(c.req.header("authorization"));
1625
+ if (token !== void 0) if (serviceKey && safeCompare(token, serviceKey)) {
1626
+ c.set("user", {
1627
+ uid: "service",
1628
+ roles: ["admin"]
1629
+ });
1630
+ try {
1631
+ c.set("driver", await scopeDataDriver(driver, {
1632
+ uid: "service",
1633
+ roles: ["admin"]
1634
+ }));
1635
+ } catch (error) {
1636
+ logger.error("[AUTH] RLS scoping failed for service key", { error });
1637
+ return c.json({ error: {
1638
+ message: "Internal authentication error",
1639
+ code: "INTERNAL_ERROR"
1640
+ } }, 500);
1641
+ }
1642
+ } else if (apiKeyStore && isApiKeyToken(token)) {
1643
+ const result = await validateApiKey(c, token, {
1644
+ store: apiKeyStore,
1645
+ driver
1646
+ });
1647
+ if (result !== true) return result;
1648
+ } else {
1649
+ const payload = await extractUserFromToken(token);
1650
+ if (payload) {
1651
+ c.set("user", payload);
1652
+ try {
1653
+ const user = {
1654
+ uid: payload.uid,
1655
+ roles: payload.roles,
1656
+ isAnonymous: payload.isAnonymous === true,
1657
+ claims: payload.claims
1658
+ };
1659
+ c.set("driver", await scopeDataDriver(driver, user));
1660
+ } catch (error) {
1661
+ logger.error("[AUTH] RLS scoping failed for authenticated user", { error });
1662
+ return c.json({ error: {
1663
+ message: "Internal authentication error",
1664
+ code: "INTERNAL_ERROR"
1665
+ } }, 500);
1666
+ }
1667
+ } else return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
1668
+ }
1669
+ else try {
1670
+ c.set("driver", await scopeDataDriver(driver, {
1671
+ uid: ANONYMOUS_USER_ID,
1672
+ roles: ["anon"]
1673
+ }));
1674
+ } catch (error) {
1675
+ logger.error("[AUTH] Failed to create anon-scoped driver", { error });
1676
+ return c.json({ error: {
1677
+ message: "Server configuration error",
1678
+ code: "INTERNAL_ERROR"
1679
+ } }, 500);
1680
+ }
1681
+ }
1682
+ if (enforceAuth && !c.get("user")) return refuse(c, ApiError.unauthenticated("Unauthorized: Authentication required. This is the API-level gate, not a row rule — a collection granting public reads still needs a caller. Set AUTH_REQUIRE=false (or `auth.requireAuth: false`) to let RLS alone decide."));
1683
+ return next();
1684
+ };
1728
1685
  }
1729
1686
  /**
1730
- * Build the `AuthCollectionContext` facade from server internals.
1687
+ * Middleware that authenticates via a `?token=` query parameter.
1731
1688
  *
1732
- * This is the simplified context exposed to collection-level auth hooks,
1733
- * keeping them decoupled from internal interfaces like `AuthRepository`.
1689
+ * **Use sparingly.** Tokens in URLs leak into access logs, proxy logs,
1690
+ * Referer headers, and browser history. This middleware exists solely for
1691
+ * routes where the consumer cannot set HTTP headers — e.g. `<img src>`,
1692
+ * `<a href>` for file downloads, or similar browser-native requests.
1693
+ *
1694
+ * Apply it **before** `requireAuth` or `optionalAuth` on the specific
1695
+ * route that needs it. Those middlewares will see the user context this
1696
+ * middleware sets and skip their own 401 check.
1697
+ *
1698
+ * @example
1699
+ * ```ts
1700
+ * router.get("/file/*", queryTokenAuth, readAuthMiddleware, handler);
1701
+ * ```
1734
1702
  */
1735
- function buildCollectionContext(ctx) {
1736
- const isEmailConfigured = !!(ctx.emailService && ctx.emailService.isConfigured());
1737
- return {
1738
- hashPassword: (password) => ctx.resolvedHooks.hashPassword(password),
1739
- sendEmail: isEmailConfigured ? (options) => ctx.emailService.send(options) : void 0,
1740
- emailConfigured: isEmailConfigured,
1741
- appName: ctx.emailConfig?.appName || "Rebase",
1742
- resetPasswordUrl: ctx.emailConfig?.resetPasswordUrl || ""
1743
- };
1744
- }
1703
+ var queryTokenAuth = async (c, next) => {
1704
+ if (extractBearerToken(c.req.header("authorization")) !== void 0) return next();
1705
+ const queryToken = c.req.query("token");
1706
+ if (queryToken && isJwtConfigured()) {
1707
+ const payload = await verifyAccessToken(queryToken);
1708
+ if (payload) c.set("user", payload);
1709
+ }
1710
+ return next();
1711
+ };
1745
1712
  /**
1746
- * Prepare user values for an admin-initiated user creation.
1713
+ * Authorizes anonymous access to **public** storage objects (those under the
1714
+ * public prefix), which are served token-less via stable, permanent URLs.
1747
1715
  *
1748
- * Resolution order:
1749
- * 1. Collection-level `auth.onCreateUser` closest to the data
1750
- * 2. Backend-level `AuthHooks.onAdminCreateUser` global override
1751
- * 3. Built-in default generate password hash normalize email
1716
+ * Runs on the storage `/file/*` and `/metadata/*` routes, *after* the token
1717
+ * middleware and *before* the read-auth gate. If the request is already
1718
+ * authenticated (Bearer or scoped token), it does nothing. Otherwise, when the
1719
+ * requested object path is public, it sets a minimal "public" principal so the
1720
+ * downstream `requireAuth` gate lets the read through. Private paths are left
1721
+ * untouched, so they still require a valid token.
1722
+ */
1723
+ var publicObjectAuth = async (c, next) => {
1724
+ if (c.get("user")) return next();
1725
+ const prefix = c.req.routePath.replace("/*", "");
1726
+ const fullPath = c.req.path;
1727
+ const idx = fullPath.indexOf(prefix);
1728
+ const rawPath = idx < 0 ? "" : fullPath.substring(idx + prefix.length + 1);
1729
+ if (rawPath && isPublicStoragePath(decodeURIComponent(rawPath))) c.set("user", {
1730
+ uid: "public",
1731
+ roles: ["public"]
1732
+ });
1733
+ return next();
1734
+ };
1735
+ /**
1736
+ * Helper to match paths for scoped file tokens.
1737
+ * Matches exact paths or prefixes (exact folder prefixes or ending with /).
1752
1738
  *
1753
- * The caller is responsible for persisting (via `driver.save()`).
1739
+ * The traversal check is defence in depth, not the load-bearing defence.
1740
+ *
1741
+ * A prefix comparison is a lie for any path containing `..`: `user1/../user2/x`
1742
+ * starts with `user1/`, so a token scoped to `user1/` would authorize reading
1743
+ * user2's file — and the storage controller would not object, because its own
1744
+ * guard stops a path escaping the *bucket*, and that one resolves to `user2/x`,
1745
+ * comfortably inside it. The grant is what would be escaped, and this is the
1746
+ * only layer that knows what was granted.
1747
+ *
1748
+ * Nothing arrives here in that shape today: the URL parser resolves `..` (and
1749
+ * `%2e%2e`, which the WHATWG spec treats as a dot for normalization) before
1750
+ * Hono routes the request, so this comparison already runs on a resolved path.
1751
+ * But that is a guarantee of the runtime rather than of this code, and it is
1752
+ * one line to not depend on it. The same rule already guards the public-object
1753
+ * path — see `isPublicStoragePath`.
1754
+ */
1755
+ function isPathMatch(requested, allowed) {
1756
+ if (requested.split("/").some((seg) => seg === "..")) return false;
1757
+ if (requested === allowed) return true;
1758
+ if (allowed.endsWith("/")) return requested.startsWith(allowed);
1759
+ return requested.startsWith(allowed + "/");
1760
+ }
1761
+ /**
1762
+ * Middleware that authenticates file-serving routes using scoped download tokens.
1763
+ * It enforces that only scoped "file-read" tokens can access "/file/*" and "?token=" query params.
1764
+ * Full access JWTs are explicitly rejected on "/file/*" routes, and in the "?token=" query param.
1754
1765
  */
1755
- async function prepareAdminUserValues(body, ctx) {
1756
- const { resolvedHooks, collectionAuthConfig } = ctx;
1757
- if (collectionAuthConfig?.onCreateUser) {
1758
- const collectionCtx = buildCollectionContext(ctx);
1759
- const hookResult = await collectionAuthConfig.onCreateUser(body, collectionCtx);
1760
- return {
1761
- values: hookResult.values,
1762
- clearPassword: hookResult.temporaryPassword,
1763
- hookHandledEmail: true,
1764
- invitationSent: hookResult.invitationSent ?? false
1766
+ var fileTokenAuth = async (c, next) => {
1767
+ const isFileRoute = c.req.path.includes("/storage/file/");
1768
+ const authHeader = c.req.header("authorization");
1769
+ const queryToken = c.req.query("token");
1770
+ const extractWildcard = (ctx) => {
1771
+ const prefix = ctx.req.routePath.replace("/*", "");
1772
+ const fullPath = ctx.req.path;
1773
+ const idx = fullPath.indexOf(prefix);
1774
+ if (idx < 0) return "";
1775
+ return fullPath.substring(idx + prefix.length + 1);
1776
+ };
1777
+ /**
1778
+ * Must agree with `parseBucketAndPath` in `storage/routes.ts`, because the
1779
+ * path a token GRANTS is minted there and the path a request ASKS FOR is
1780
+ * derived here. `/metadata` canonicalizes before signing, so a raw
1781
+ * comparison here would 403 an otherwise-valid request for any key whose
1782
+ * URL form is not already canonical (`a//b.txt`, `a/./b.txt`).
1783
+ *
1784
+ * A key that cannot be canonicalized yields null, which matches no grant —
1785
+ * the same fail-closed answer `isPathMatch` gives a `..` segment.
1786
+ */
1787
+ const parseBucketPath = (filePath) => {
1788
+ const parts = filePath.split("/");
1789
+ if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
1790
+ bucket: "default",
1791
+ resolvedPath: tryCanonicalStorageKey(parts.slice(1).join("/"))
1765
1792
  };
1766
- }
1767
- if (resolvedHooks.onAdminCreateUser) {
1768
- const hookResult = await resolvedHooks.onAdminCreateUser(body, {
1769
- authRepo: ctx.authRepo,
1770
- emailService: ctx.emailService,
1771
- emailConfig: ctx.emailConfig,
1772
- hashPassword: (password) => resolvedHooks.hashPassword(password)
1773
- });
1774
1793
  return {
1775
- values: hookResult.values,
1776
- clearPassword: hookResult.temporaryPassword,
1777
- hookHandledEmail: true,
1778
- invitationSent: hookResult.invitationSent ?? false
1794
+ bucket: "default",
1795
+ resolvedPath: tryCanonicalStorageKey(filePath)
1779
1796
  };
1780
- }
1781
- const password = body.password;
1782
- const clearPassword = password || generateSecurePassword();
1783
- const passwordHash = await resolvedHooks.hashPassword(clearPassword);
1784
- const values = { ...body };
1785
- values.passwordHash = passwordHash;
1786
- if (values.email) values.email = normalizeEmail(values.email);
1787
- values.emailVerified = true;
1788
- delete values.password;
1789
- return {
1790
- values,
1791
- clearPassword: password ? void 0 : clearPassword,
1792
- hookHandledEmail: false,
1793
- invitationSent: false
1794
1797
  };
1795
- }
1798
+ /**
1799
+ * Decide what a valid download token entitles this request to.
1800
+ *
1801
+ * One function rather than a copy per token location: a Bearer token and a
1802
+ * `?token=` are the same grant presented two ways, and the whole failure
1803
+ * this guards against is two places disagreeing about what was granted.
1804
+ *
1805
+ * `"no-path"` is distinct from the two denials because the caller treats it
1806
+ * differently — a request with no object path at all is not a denied read,
1807
+ * it is a request this middleware has nothing to say about, and on
1808
+ * `/metadata/*` it must still reach the downstream auth gate.
1809
+ */
1810
+ const evaluateGrant = (payload) => {
1811
+ if (canonicalStorageId(c.req.query("storageId")) !== payload.storageId) return "deny-storage";
1812
+ const rawPath = extractWildcard(c);
1813
+ if (!rawPath) return "no-path";
1814
+ const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
1815
+ if (resolvedPath === null) return "deny-path";
1816
+ return isPathMatch(`${bucket}/${resolvedPath}`, payload.path) ? "grant" : "deny-path";
1817
+ };
1818
+ /**
1819
+ * Which half of the scope failed is named in the message. The holder of the
1820
+ * token already knows both values, so this reveals nothing to an attacker —
1821
+ * and without it, a client that forgets to forward `?storageId=` on the file
1822
+ * URL reports a "path mismatch" for a path that matches perfectly.
1823
+ */
1824
+ const denyMismatch = (outcome) => refuse(c, ApiError.forbidden(outcome === "deny-storage" ? "Forbidden: Scoped token storage mismatch" : "Forbidden: Scoped token path mismatch"));
1825
+ const bearerToken = extractBearerToken(authHeader);
1826
+ if (bearerToken !== void 0) {
1827
+ const payload = await verifyDownloadToken(bearerToken);
1828
+ if (payload) {
1829
+ const outcome = evaluateGrant(payload);
1830
+ if (outcome === "grant") {
1831
+ c.set("user", {
1832
+ uid: "download-token",
1833
+ roles: ["reader"]
1834
+ });
1835
+ return next();
1836
+ }
1837
+ if (outcome !== "no-path") return denyMismatch(outcome);
1838
+ }
1839
+ if (isFileRoute) return refuse(c, ApiError.unauthenticated("Unauthorized: Access JWT not allowed on file routes"));
1840
+ return next();
1841
+ }
1842
+ if (queryToken) {
1843
+ const payload = await verifyDownloadToken(queryToken);
1844
+ if (payload) {
1845
+ const outcome = evaluateGrant(payload);
1846
+ if (outcome === "grant") {
1847
+ c.set("user", {
1848
+ uid: "download-token",
1849
+ roles: ["reader"]
1850
+ });
1851
+ return next();
1852
+ }
1853
+ if (outcome !== "no-path") return denyMismatch(outcome);
1854
+ }
1855
+ return refuse(c, ApiError.unauthenticated("Unauthorized: Invalid or unauthorized token"));
1856
+ }
1857
+ return next();
1858
+ };
1859
+ //#endregion
1860
+ //#region src/auth/adapter-middleware.ts
1796
1861
  /**
1797
- * Handle post-creation work for admin-created users.
1798
- *
1799
- * Sends an invitation email (password-reset link) if email is configured
1800
- * and no explicit password was provided. Falls back to returning the
1801
- * temporary password if email fails or is not configured.
1862
+ * Create a Hono middleware that uses an `AuthAdapter` for request verification.
1802
1863
  */
1803
- async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
1804
- if (!clearPassword) return { invitationSent: false };
1805
- if (!!(ctx.emailService && ctx.emailService.isConfigured())) try {
1806
- const token = generateSecureToken();
1807
- const tokenHash = hashToken(token);
1808
- const expiresAt = new Date(Date.now() + 1440 * 60 * 1e3);
1809
- await ctx.authRepo.createPasswordResetToken(entity.id, tokenHash, expiresAt);
1810
- const setPasswordUrl = `${ctx.emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
1811
- const { appName, logoUrl } = resolveEmailBranding(ctx.emailConfig);
1812
- const templateFn = ctx.emailConfig?.templates?.userInvitation;
1813
- const emailContent = templateFn ? templateFn(setPasswordUrl, {
1814
- email: entity.values.email,
1815
- displayName: entity.values.displayName
1816
- }) : getUserInvitationTemplate(setPasswordUrl, {
1817
- email: entity.values.email,
1818
- displayName: entity.values.displayName
1819
- }, appName, logoUrl);
1820
- await ctx.emailService.send({
1821
- to: entity.values.email,
1822
- subject: emailContent.subject,
1823
- html: emailContent.html,
1824
- text: emailContent.text
1825
- });
1826
- return { invitationSent: true };
1827
- } catch (emailError) {
1828
- logger.error("Failed to send reset email", { error: emailError instanceof Error ? emailError.message : emailError });
1829
- return {
1830
- temporaryPassword: clearPassword,
1831
- invitationSent: false,
1832
- emailDeliveryFailed: true
1833
- };
1834
- }
1835
- return {
1836
- temporaryPassword: clearPassword,
1837
- invitationSent: false
1864
+ function createAdapterAuthMiddleware(options) {
1865
+ const { adapter, driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, apiKeyStore } = options;
1866
+ return async (c, next) => {
1867
+ const driver = resolveDriver ? resolveDriver(c) : baseDriver;
1868
+ const presentedToken = extractBearerToken(c.req.header("authorization"));
1869
+ if (apiKeyStore) {
1870
+ const token = presentedToken ?? "";
1871
+ if (token.startsWith("rk_")) {
1872
+ const result = await validateApiKey(c, token, {
1873
+ store: apiKeyStore,
1874
+ driver
1875
+ });
1876
+ if (result === true) return next();
1877
+ return result;
1878
+ }
1879
+ }
1880
+ let authenticatedUser = null;
1881
+ try {
1882
+ authenticatedUser = await adapter.verifyRequest(c.req.raw);
1883
+ } catch (error) {
1884
+ return refuse(c, ApiError.unauthenticated("Unauthorized"));
1885
+ }
1886
+ if (authenticatedUser) {
1887
+ c.set("user", {
1888
+ uid: authenticatedUser.uid,
1889
+ email: authenticatedUser.email,
1890
+ roles: authenticatedUser.roles,
1891
+ isAnonymous: authenticatedUser.isAnonymous === true
1892
+ });
1893
+ try {
1894
+ c.set("driver", await scopeDataDriver(driver, {
1895
+ uid: authenticatedUser.uid,
1896
+ roles: authenticatedUser.roles,
1897
+ isAnonymous: authenticatedUser.isAnonymous === true
1898
+ }));
1899
+ } catch (error) {
1900
+ logger.error("[AUTH-ADAPTER] RLS scoping failed for authenticated user", { error });
1901
+ return refuse(c, ApiError.internal("Internal authentication error"));
1902
+ }
1903
+ } else {
1904
+ if (presentedToken !== void 0) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
1905
+ try {
1906
+ c.set("driver", await scopeDataDriver(driver, {
1907
+ uid: ANONYMOUS_USER_ID,
1908
+ roles: ["anon"]
1909
+ }));
1910
+ } catch (error) {
1911
+ logger.error("[AUTH-ADAPTER] Failed to create anon-scoped driver", { error });
1912
+ return refuse(c, ApiError.internal("Server configuration error"));
1913
+ }
1914
+ }
1915
+ if (enforceAuth && !c.get("user")) return refuse(c, ApiError.unauthenticated("Unauthorized: Authentication required. This is the API-level gate, not a row rule — a collection granting public reads still needs a caller. Set AUTH_REQUIRE=false (or `auth.requireAuth: false`) to let RLS alone decide."));
1916
+ return next();
1838
1917
  };
1839
1918
  }
1840
1919
  //#endregion
@@ -3601,8 +3680,7 @@ function mountSessionRoutes(opts) {
3601
3680
  router.delete("/sessions", requireLiveSession, async (c) => {
3602
3681
  const userCtx = c.get("user");
3603
3682
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
3604
- await authRepo.deleteAllRefreshTokensForUser(userCtx.uid);
3605
- await authRepo.setTokensValidAfter?.(userCtx.uid, /* @__PURE__ */ new Date()).catch(() => void 0);
3683
+ await revokeAllSessions(authRepo, userCtx.uid);
3606
3684
  return c.json({
3607
3685
  success: true,
3608
3686
  message: "All sessions revoked successfully"
@@ -3774,7 +3852,7 @@ function mountMagicLinkRoutes(deps) {
3774
3852
  if (user) {
3775
3853
  if (ops.beforeLogin) await ops.beforeLogin(email, "magic-link");
3776
3854
  const token = generateSecureToken();
3777
- const tokenHash = hashToken(token);
3855
+ const tokenHash = hashToken$1(token);
3778
3856
  const expiresAt = getMagicLinkExpiry();
3779
3857
  await authRepo.createMagicLinkToken(user.id, tokenHash, expiresAt);
3780
3858
  const magicLinkUrl = `${resolveEmailLinkBase(emailConfig, "magicLink")}/auth/magic-link?token=${token}`;
@@ -3809,7 +3887,7 @@ function mountMagicLinkRoutes(deps) {
3809
3887
  */
3810
3888
  router.post("/magic-link/verify", strictAuthLimiter, async (c) => {
3811
3889
  const { token } = parseBody(verifyMagicLinkSchema, await c.req.json());
3812
- const tokenHash = hashToken(token);
3890
+ const tokenHash = hashToken$1(token);
3813
3891
  const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
3814
3892
  if (!storedToken) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
3815
3893
  await authRepo.markMagicLinkTokenUsed(tokenHash);
@@ -3964,7 +4042,7 @@ function mountOtpRoutes(deps) {
3964
4042
  if (ops.beforeLogin) await ops.beforeLogin(email, "otp");
3965
4043
  if (user) {
3966
4044
  const code = generateOtpCode();
3967
- await authRepo.createMagicLinkToken(user.id, hashToken(otpTokenMaterial(user.email, code)), new Date(Date.now() + OTP_TTL_MS));
4045
+ await authRepo.createMagicLinkToken(user.id, hashToken$1(otpTokenMaterial(user.email, code)), new Date(Date.now() + OTP_TTL_MS));
3968
4046
  const { appName, logoUrl } = resolveEmailBranding(emailConfig);
3969
4047
  const templateFn = emailConfig?.templates?.emailOtp;
3970
4048
  const content = templateFn ? templateFn(code, {
@@ -3994,7 +4072,7 @@ function mountOtpRoutes(deps) {
3994
4072
  */
3995
4073
  router.post("/otp/verify", strictAuthLimiter, captureOtpEmail, otpVerificationLimiter, async (c) => {
3996
4074
  const { email, code } = parseBody(verifySchema, await c.req.json());
3997
- const tokenHash = hashToken(otpTokenMaterial(email, code));
4075
+ const tokenHash = hashToken$1(otpTokenMaterial(email, code));
3998
4076
  const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
3999
4077
  if (!storedToken) throw ApiError.badRequest("Invalid or expired code", "INVALID_CODE");
4000
4078
  await authRepo.markMagicLinkTokenUsed(tokenHash);
@@ -4477,7 +4555,7 @@ function createAuthRoutes(config) {
4477
4555
  const user = await authRepo.getUserByEmail(email);
4478
4556
  if (user) {
4479
4557
  const token = generateSecureToken();
4480
- const tokenHash = hashToken(token);
4558
+ const tokenHash = hashToken$1(token);
4481
4559
  const expiresAt = getPasswordResetExpiry();
4482
4560
  await authRepo.createPasswordResetToken(user.id, tokenHash, expiresAt);
4483
4561
  const resetUrl = `${resolveEmailLinkBase(emailConfig, "resetPassword")}/reset-password?token=${token}`;
@@ -4512,14 +4590,12 @@ function createAuthRoutes(config) {
4512
4590
  const { token, password } = parseBody(resetPasswordSchema, await c.req.json());
4513
4591
  const passwordValidation = ops.validatePasswordStrength(password);
4514
4592
  if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
4515
- const tokenHash = hashToken(token);
4593
+ const tokenHash = hashToken$1(token);
4516
4594
  const storedToken = await authRepo.findValidPasswordResetToken(tokenHash);
4517
4595
  if (!storedToken) throw ApiError.badRequest("Invalid or expired reset token", "INVALID_TOKEN");
4518
4596
  const passwordHash = await ops.hashPassword(password);
4519
- await authRepo.updatePassword(storedToken.uid, passwordHash);
4597
+ await replaceUserPassword(authRepo, storedToken.uid, passwordHash);
4520
4598
  await authRepo.markPasswordResetTokenUsed(tokenHash);
4521
- await authRepo.deleteAllRefreshTokensForUser(storedToken.uid);
4522
- await authRepo.setTokensValidAfter?.(storedToken.uid, /* @__PURE__ */ new Date()).catch(() => void 0);
4523
4599
  if (ops.onPasswordReset) ops.onPasswordReset(storedToken.uid).catch((err) => {
4524
4600
  logger.error("[AuthHooks] onPasswordReset error", { error: err instanceof Error ? err.message : err });
4525
4601
  });
@@ -4542,9 +4618,7 @@ function createAuthRoutes(config) {
4542
4618
  const passwordValidation = ops.validatePasswordStrength(newPassword);
4543
4619
  if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
4544
4620
  const passwordHash = await ops.hashPassword(newPassword);
4545
- await authRepo.updatePassword(user.id, passwordHash);
4546
- await authRepo.deleteAllRefreshTokensForUser(user.id);
4547
- await authRepo.setTokensValidAfter?.(user.id, /* @__PURE__ */ new Date()).catch(() => void 0);
4621
+ await replaceUserPassword(authRepo, user.id, passwordHash);
4548
4622
  return c.json({
4549
4623
  success: true,
4550
4624
  message: "Password has been changed successfully"
@@ -4569,7 +4643,7 @@ function createAuthRoutes(config) {
4569
4643
  if (!user) throw ApiError.notFound("User not found");
4570
4644
  if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
4571
4645
  const token = generateSecureToken();
4572
- await authRepo.setVerificationToken(user.id, hashToken(token));
4646
+ await authRepo.setVerificationToken(user.id, hashToken$1(token));
4573
4647
  const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
4574
4648
  const { appName, logoUrl } = resolveEmailBranding(emailConfig);
4575
4649
  const templateFn = emailConfig?.templates?.emailVerification;
@@ -4598,7 +4672,7 @@ function createAuthRoutes(config) {
4598
4672
  router.get("/verify-email", async (c) => {
4599
4673
  const token = c.req.query("token");
4600
4674
  if (!token) throw ApiError.badRequest("Verification token is required", "INVALID_INPUT");
4601
- const user = await authRepo.getUserByVerificationToken(hashToken(token));
4675
+ const user = await authRepo.getUserByVerificationToken(hashToken$1(token));
4602
4676
  if (!user) throw ApiError.badRequest("Invalid or expired verification token", "INVALID_TOKEN");
4603
4677
  await authRepo.setEmailVerified(user.id, true);
4604
4678
  return c.json({
@@ -4777,6 +4851,10 @@ function buildBuiltinAuthCapabilities(inputs) {
4777
4851
  * 1. Collection-level hook (`auth.onResetPassword` on the collection)
4778
4852
  * 2. Backend-level hook (`AuthHooks.onAdminResetPassword`)
4779
4853
  * 3. Built-in default (send reset email, or generate temp password)
4854
+ *
4855
+ * Whichever of them runs, the account's existing sessions end, and a
4856
+ * `temporaryPassword` in its result becomes the account's password before the
4857
+ * response shows it to the admin.
4780
4858
  */
4781
4859
  /**
4782
4860
  * Create a standalone admin route for resetting user passwords.
@@ -4802,14 +4880,10 @@ function createResetPasswordRoute(config) {
4802
4880
  let temporaryPassword;
4803
4881
  let emailDeliveryFailed = false;
4804
4882
  const body = await c.req.json().catch(() => ({}));
4805
- if (body.password) {
4806
- const password = body.password;
4807
- const validation = ops.validatePasswordStrength(password);
4883
+ const chosenPassword = body.password ? body.password : void 0;
4884
+ if (chosenPassword) {
4885
+ const validation = ops.validatePasswordStrength(chosenPassword);
4808
4886
  if (!validation.valid) throw ApiError.badRequest(`Password too weak: ${validation.errors.join(", ")}`);
4809
- const passwordHash = await ops.hashPassword(password);
4810
- await authRepo.updatePassword(existing.id, passwordHash);
4811
- temporaryPassword = void 0;
4812
- invitationSent = false;
4813
4887
  } else if (collectionAuthConfig?.onResetPassword) {
4814
4888
  const isEmailConfigured = !!(emailService && emailService.isConfigured());
4815
4889
  const hookResult = await collectionAuthConfig.onResetPassword(existing.id, {
@@ -4829,42 +4903,38 @@ function createResetPasswordRoute(config) {
4829
4903
  });
4830
4904
  temporaryPassword = hookResult.temporaryPassword;
4831
4905
  invitationSent = hookResult.invitationSent ?? false;
4832
- } else if (!!(emailService && emailService.isConfigured())) try {
4906
+ } else if (!!(emailService && emailService.isConfigured())) {
4833
4907
  const token = generateSecureToken();
4834
- const tokenHash = hashToken(token);
4908
+ const tokenHash = hashToken$1(token);
4835
4909
  const expiresAt = new Date(Date.now() + 3600 * 1e3);
4836
4910
  await authRepo.createPasswordResetToken(existing.id, tokenHash, expiresAt);
4837
- const setPasswordUrl = `${emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
4838
- const { appName, logoUrl } = resolveEmailBranding(emailConfig);
4839
- const templateFn = emailConfig?.templates?.passwordReset;
4840
- const emailContent = templateFn ? templateFn(setPasswordUrl, {
4841
- email: existing.email,
4842
- displayName: existing.displayName
4843
- }) : getPasswordResetTemplate(setPasswordUrl, {
4844
- email: existing.email,
4845
- displayName: existing.displayName
4846
- }, appName, logoUrl);
4847
- await emailService.send({
4848
- to: existing.email,
4849
- subject: emailContent.subject,
4850
- html: emailContent.html,
4851
- text: emailContent.text
4852
- });
4853
- invitationSent = true;
4854
- } catch (emailError) {
4855
- logger.error("Failed to send reset email", { error: emailError instanceof Error ? emailError.message : emailError });
4856
- const clearPassword = generateSecurePassword();
4857
- const passwordHash = await ops.hashPassword(clearPassword);
4858
- await authRepo.updatePassword(existing.id, passwordHash);
4859
- temporaryPassword = clearPassword;
4860
- emailDeliveryFailed = true;
4861
- }
4862
- else {
4863
- const clearPassword = generateSecurePassword();
4864
- const passwordHash = await ops.hashPassword(clearPassword);
4865
- await authRepo.updatePassword(existing.id, passwordHash);
4866
- temporaryPassword = clearPassword;
4867
- }
4911
+ try {
4912
+ const setPasswordUrl = `${emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
4913
+ const { appName, logoUrl } = resolveEmailBranding(emailConfig);
4914
+ const templateFn = emailConfig?.templates?.passwordReset;
4915
+ const emailContent = templateFn ? templateFn(setPasswordUrl, {
4916
+ email: existing.email,
4917
+ displayName: existing.displayName
4918
+ }) : getPasswordResetTemplate(setPasswordUrl, {
4919
+ email: existing.email,
4920
+ displayName: existing.displayName
4921
+ }, appName, logoUrl);
4922
+ await emailService.send({
4923
+ to: existing.email,
4924
+ subject: emailContent.subject,
4925
+ html: emailContent.html,
4926
+ text: emailContent.text
4927
+ });
4928
+ invitationSent = true;
4929
+ } catch (emailError) {
4930
+ logger.error("Failed to send reset email", { error: emailError instanceof Error ? emailError.message : emailError });
4931
+ temporaryPassword = generateSecurePassword();
4932
+ emailDeliveryFailed = true;
4933
+ }
4934
+ } else temporaryPassword = generateSecurePassword();
4935
+ const newPassword = chosenPassword ?? temporaryPassword;
4936
+ if (newPassword) await replaceUserPassword(authRepo, existing.id, await ops.hashPassword(newPassword));
4937
+ else await revokeAllSessions(authRepo, existing.id);
4868
4938
  const userRoles = await authRepo.getUserRoleIds(existing.id);
4869
4939
  return c.json({
4870
4940
  user: {
@@ -5069,22 +5139,20 @@ function createAdminUsersRoute(config) {
5069
5139
  if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
5070
5140
  const user = await authRepo.createUser(createData);
5071
5141
  if (roles && Array.isArray(roles)) await authRepo.setUserRoles(user.id, roles);
5072
- const finalizeResult = await finalizeAdminUserCreation({
5142
+ const delivery = await completeUserCreation(prepResult, (clearPassword) => finalizeAdminUserCreation({
5073
5143
  id: user.id,
5074
5144
  values: prepResult.values
5075
- }, prepResult.clearPassword, {
5145
+ }, clearPassword, {
5076
5146
  authRepo,
5077
5147
  emailService,
5078
5148
  emailConfig,
5079
5149
  resolvedHooks: ops,
5080
5150
  collectionAuthConfig
5081
- });
5151
+ }));
5082
5152
  const adminUser = toAdminUser(user, await authRepo.getUserRoleIds(user.id));
5083
5153
  return c.json({
5084
5154
  user: adminUser,
5085
- invitationSent: finalizeResult.invitationSent,
5086
- ...finalizeResult.temporaryPassword ? { temporaryPassword: finalizeResult.temporaryPassword } : {},
5087
- ...finalizeResult.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
5155
+ ...delivery
5088
5156
  }, 201);
5089
5157
  });
5090
5158
  router.put("/users/:uid", requireAdmin, async (c) => {
@@ -5094,12 +5162,14 @@ function createAdminUsersRoute(config) {
5094
5162
  const updates = {};
5095
5163
  if (email !== void 0) updates.email = normalizeEmail(email);
5096
5164
  if (displayName !== void 0) updates.displayName = displayName;
5165
+ let passwordHash;
5097
5166
  if (password) {
5098
5167
  const validation = ops.validatePasswordStrength(password);
5099
5168
  if (!validation.valid) throw ApiError.badRequest(`Password too weak: ${validation.errors.join(". ")}`);
5100
- updates.passwordHash = await ops.hashPassword(password);
5169
+ passwordHash = await ops.hashPassword(password);
5101
5170
  }
5102
5171
  if (Object.keys(updates).length > 0) await authRepo.updateUser(uid, updates);
5172
+ if (passwordHash) await replaceUserPassword(authRepo, uid, passwordHash);
5103
5173
  if (roles !== void 0 && Array.isArray(roles)) {
5104
5174
  const wasAdmin = (await authRepo.getUserRoleIds(uid)).includes("admin");
5105
5175
  const willBeAdmin = roles.includes("admin");
@@ -5366,8 +5436,9 @@ function createUserManagementFromRepo(repo, resolvedOps) {
5366
5436
  if (data.displayName !== void 0) updateData.displayName = data.displayName;
5367
5437
  if (data.photoUrl !== void 0) updateData.photoUrl = data.photoUrl;
5368
5438
  if (data.metadata !== void 0) updateData.metadata = data.metadata;
5369
- if (data.password) updateData.passwordHash = await resolvedOps.hashPassword(data.password);
5439
+ const passwordHash = data.password ? await resolvedOps.hashPassword(data.password) : void 0;
5370
5440
  const user = await repo.updateUser(id, updateData);
5441
+ if (user && passwordHash) await replaceUserPassword(repo, id, passwordHash);
5371
5442
  return user ? toAuthUserData(user) : null;
5372
5443
  },
5373
5444
  async deleteUser(id) {
@@ -7076,7 +7147,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
7076
7147
  hasAsymmetricSigningKey: () => hasAsymmetricSigningKey,
7077
7148
  hashPassword: () => hashPassword,
7078
7149
  hashRefreshToken: () => hashRefreshToken,
7079
- hashToken: () => hashToken,
7150
+ hashToken: () => hashToken$1,
7080
7151
  httpMethodToOperation: () => httpMethodToOperation,
7081
7152
  isApiKeyToken: () => isApiKeyToken,
7082
7153
  isJwtConfigured: () => isJwtConfigured,
@@ -7105,6 +7176,6 @@ var auth_exports = /* @__PURE__ */ __exportAll({
7105
7176
  verifyPassword: () => verifyPassword
7106
7177
  });
7107
7178
  //#endregion
7108
- export { RawHtml as $, defaultAuthLimiter as A, resolveEmailLinkBase as B, providerVerifiedEmail as C, httpMethodToOperation as Ct, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as D, isBootstrapWindowOpen as E, isPublicStoragePath as Et, extractLinks as F, generateSecurePassword as G, hashPassword as H, registerDevEmailSink as I, getMagicLinkTemplate as J, getEmailOtpTemplate as K, SMTPEmailService as L, activeDevEmailSink as M, clearActiveDevEmailSink as N, createDataRateLimiter as O, createDevEmailSink as P, resolveEmailBranding as Q, createEmailService as R, pkceTokenParams as S, supportsRlsScoping as St, createBuiltinAuthAdapter as T, PUBLIC_STORAGE_PREFIX as Tt, validatePasswordStrength as U, resolveAuthHooks as V, verifyPassword as W, getUserInvitationTemplate as X, getPasswordResetTemplate as Y, getWelcomeEmailTemplate as Z, verifyOidcIdToken as _, validateApiKey as _t, resolveRateLimitStoreKind as a, createRequireAuth as at, createGoogleProvider as b, SERVICE_IDENTITY as bt, createSlackProvider as c, optionalAuth as ct, createDiscordProvider as d, requireAdmin as dt, escapeHtml as et, createTwitterProvider as f, requireAuth as ft, tryVerifyOidcIdToken as g, isApiKeyToken as gt, createMicrosoftProvider as h, createStorageApiKeyGuard as ht, createApiKeyStore as i, createAuthMiddleware as it, MemoryRateLimitStore as j, createRateLimiter as k, createBitbucketProvider as l, publicObjectAuth as lt, createAppleProvider as m, createFunctionApiKeyGuard as mt, createCustomAuthAdapter as n, raw as nt, createSqlRateLimitStore as o, extractUserFromToken as ot, createFacebookProvider as p, createApiKeyPreAuth as pt, getEmailVerificationTemplate as q, createApiKeyRoutes as r, createAdapterAuthMiddleware as rt, createSpotifyProvider as s, fileTokenAuth as st, auth_exports as t, html as tt, createGitLabProvider as u, queryTokenAuth as ut, createGitHubProvider as v, extractBearerToken as vt, createJwksRoutes as w, isOperationAllowed as wt, oauthCodeFlowSchema as x, scopeDataDriver as xt, createLinkedinProvider as y, safeCompare as yt, assertEmailLinkBases as z };
7179
+ export { requireAdmin as $, defaultAuthLimiter as A, resolveEmailLinkBase as B, providerVerifiedEmail as C, raw as Ct, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as D, isPublicStoragePath as Dt, isBootstrapWindowOpen as E, PUBLIC_STORAGE_PREFIX as Et, extractLinks as F, createAdapterAuthMiddleware as G, hashPassword as H, registerDevEmailSink as I, extractUserFromToken as J, createAuthMiddleware as K, SMTPEmailService as L, activeDevEmailSink as M, clearActiveDevEmailSink as N, createDataRateLimiter as O, createDevEmailSink as P, queryTokenAuth as Q, createEmailService as R, pkceTokenParams as S, html as St, createBuiltinAuthAdapter as T, isOperationAllowed as Tt, validatePasswordStrength as U, resolveAuthHooks as V, verifyPassword as W, optionalAuth as X, fileTokenAuth as Y, publicObjectAuth as Z, verifyOidcIdToken as _, getUserInvitationTemplate as _t, resolveRateLimitStoreKind as a, validateApiKey as at, createGoogleProvider as b, RawHtml as bt, createSlackProvider as c, SERVICE_IDENTITY as ct, createDiscordProvider as d, completeUserCreation as dt, requireAuth as et, createTwitterProvider as f, generateSecurePassword as ft, tryVerifyOidcIdToken as g, getPasswordResetTemplate as gt, createMicrosoftProvider as h, getMagicLinkTemplate as ht, createApiKeyStore as i, isApiKeyToken as it, MemoryRateLimitStore as j, createRateLimiter as k, createBitbucketProvider as l, scopeDataDriver as lt, createAppleProvider as m, getEmailVerificationTemplate as mt, createCustomAuthAdapter as n, createFunctionApiKeyGuard as nt, createSqlRateLimitStore as o, extractBearerToken as ot, createFacebookProvider as p, getEmailOtpTemplate as pt, createRequireAuth as q, createApiKeyRoutes as r, createStorageApiKeyGuard as rt, createSpotifyProvider as s, safeCompare as st, auth_exports as t, createApiKeyPreAuth as tt, createGitLabProvider as u, supportsRlsScoping as ut, createGitHubProvider as v, getWelcomeEmailTemplate as vt, createJwksRoutes as w, httpMethodToOperation as wt, oauthCodeFlowSchema as x, escapeHtml as xt, createLinkedinProvider as y, resolveEmailBranding as yt, assertEmailLinkBases as z };
7109
7180
 
7110
- //# sourceMappingURL=auth-gzoRjnuh.js.map
7181
+ //# sourceMappingURL=auth-BS4WBA10.js.map