@rebasepro/server 0.21.0 → 0.21.1-canary.g8c5a265

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 (39) hide show
  1. package/README.md +29 -11
  2. package/dist/auth/admin-user-ops.d.ts +23 -1
  3. package/dist/{auth-gzoRjnuh.js → auth-BvBllZjy.js} +1555 -1525
  4. package/dist/auth-BvBllZjy.js.map +1 -0
  5. package/dist/backup/backup-common.d.ts +2 -1
  6. package/dist/{backup-DGu0v9Ku.js → backup-De-Xm4tg.js} +37 -9
  7. package/dist/backup-De-Xm4tg.js.map +1 -0
  8. package/dist/collections/validate-config.d.ts +4 -2
  9. package/dist/{cron-routes-BamgO0Xr.js → cron-routes-3FVef5QO.js} +2 -2
  10. package/dist/{cron-routes-BamgO0Xr.js.map → cron-routes-3FVef5QO.js.map} +1 -1
  11. package/dist/{cron-scheduler-D47tdB9T.js → cron-scheduler-COPQxlEq.js} +59 -10
  12. package/dist/cron-scheduler-COPQxlEq.js.map +1 -0
  13. package/dist/{cron-store-yXOyIEmn.js → cron-store-Dp0X7zRB.js} +2 -2
  14. package/dist/{cron-store-yXOyIEmn.js.map → cron-store-Dp0X7zRB.js.map} +1 -1
  15. package/dist/deploy/pod-contract.d.ts +14 -0
  16. package/dist/{history-recorder-hBGuXcZc.js → history-recorder-5VKT7nss.js} +2 -2
  17. package/dist/{history-recorder-hBGuXcZc.js.map → history-recorder-5VKT7nss.js.map} +1 -1
  18. package/dist/{history-store-C9EtGtoL.js → history-store-By2R_G1b.js} +2 -2
  19. package/dist/{history-store-C9EtGtoL.js.map → history-store-By2R_G1b.js.map} +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.es.js +255 -221
  22. package/dist/index.es.js.map +1 -1
  23. package/dist/init/middlewares.d.ts +19 -1
  24. package/dist/init/storage.d.ts +17 -0
  25. package/dist/init.d.ts +6 -2
  26. package/dist/{jobs-DZ8DLLcU.js → jobs-1IkI4Lle.js} +2 -2
  27. package/dist/{jobs-DZ8DLLcU.js.map → jobs-1IkI4Lle.js.map} +1 -1
  28. package/dist/{openapi-generator-CAFKF36E.js → openapi-generator-BDpoKZQ9.js} +2 -2
  29. package/dist/{openapi-generator-CAFKF36E.js.map → openapi-generator-BDpoKZQ9.js.map} +1 -1
  30. package/dist/{query-parser-uqzkZ82i.js → query-parser-DUl8d557.js} +2 -2
  31. package/dist/{query-parser-uqzkZ82i.js.map → query-parser-DUl8d557.js.map} +1 -1
  32. package/dist/src-Br6ARbs6.js.map +1 -1
  33. package/dist/{src-DW1cQ_28.js → src-Caz7nREj.js} +2 -3
  34. package/dist/src-Caz7nREj.js.map +1 -0
  35. package/package.json +5 -5
  36. package/dist/auth-gzoRjnuh.js.map +0 -1
  37. package/dist/backup-DGu0v9Ku.js.map +0 -1
  38. package/dist/cron-scheduler-D47tdB9T.js.map +0 -1
  39. 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
11
  import { n as errorHandler, t as ApiError } from "./errors-DMImyqyR.js";
12
- import { a as resolveListLimitParam } from "./query-parser-uqzkZ82i.js";
12
+ import { a as resolveListLimitParam } from "./query-parser-DUl8d557.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,1719 @@ 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 middleware — it'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.
625
- */
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
- };
635
- /**
636
- * Extract user from token - for WebSocket authentication
491
+ * Default email verification template
637
492
  */
638
- async function extractUserFromToken(token) {
639
- if (!isJwtConfigured()) return null;
640
- return verifyAccessToken(token);
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
+ };
641
554
  }
642
555
  /**
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.
556
+ * Default user invitation email template
557
+ * Sent when an admin creates a new user account
659
558
  */
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();
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
761
627
  };
762
628
  }
763
629
  /**
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.
774
- *
775
- * @example
776
- * ```ts
777
- * router.get("/file/*", queryTokenAuth, readAuthMiddleware, handler);
778
- * ```
630
+ * Default welcome email template
631
+ * Sent automatically when a new user registers
779
632
  */
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
- };
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
693
+ };
694
+ }
789
695
  /**
790
- * Authorizes anonymous access to **public** storage objects (those under the
791
- * public prefix), which are served token-less via stable, permanent URLs.
696
+ * Default one-time sign-in code email.
792
697
  *
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.
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.
799
701
  */
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
- };
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
+ }
812
765
  /**
813
- * Helper to match paths for scoped file tokens.
814
- * Matches exact paths or prefixes (exact folder prefixes or ending with /).
815
- *
816
- * The traversal check is defence in depth, not the load-bearing defence.
766
+ * Default magic link email template
767
+ */
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
842
+ /**
843
+ * Admin User Operations
817
844
  *
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.
845
+ * Shared utilities and orchestration for admin-initiated user management
846
+ * (user creation via REST API, password reset via admin panel).
824
847
  *
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`.
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.
855
+ *
856
+ * 16 characters, guaranteed at least one uppercase, one lowercase, one digit.
857
+ * Ambiguous characters (0, O, 1, l, I) are excluded.
831
858
  */
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 + "/");
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("");
837
876
  }
838
877
  /**
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.
878
+ * Generate a cryptographically secure random token (80 hex characters).
842
879
  */
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);
853
- };
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
- };
870
- return {
871
- bucket: "default",
872
- resolvedPath: tryCanonicalStorageKey(filePath)
873
- };
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
- }
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"));
933
- }
934
- return next();
935
- };
936
- //#endregion
937
- //#region src/auth/adapter-middleware.ts
880
+ function generateSecureToken() {
881
+ return randomBytes(40).toString("hex");
882
+ }
938
883
  /**
939
- * Create a Hono middleware that uses an `AuthAdapter` for request verification.
884
+ * Hash a token for database storage using SHA-256.
940
885
  */
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();
994
- };
886
+ function hashToken$1(token) {
887
+ return createHash("sha256").update(token).digest("hex");
995
888
  }
996
- //#endregion
997
- //#region src/email/html.ts
998
889
  /**
999
- * HTML escaping for email bodies.
1000
- *
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.
890
+ * Build the `AuthCollectionContext` facade from server internals.
1007
891
  *
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.
892
+ * This is the simplified context exposed to collection-level auth hooks,
893
+ * keeping them decoupled from internal interfaces like `AuthRepository`.
1014
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 || ""
903
+ };
904
+ }
1015
905
  /**
1016
- * Markup that has already been vetted and must be interpolated verbatim.
906
+ * Prepare user values for an admin-initiated user creation.
1017
907
  *
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.
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()`).
1021
914
  */
1022
- var RawHtml = class {
1023
- value;
1024
- constructor(value) {
1025
- this.value = value;
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);
920
+ return {
921
+ values: hookResult.values,
922
+ clearPassword: hookResult.temporaryPassword,
923
+ hookHandledEmail: true,
924
+ invitationSent: hookResult.invitationSent ?? false
925
+ };
1026
926
  }
1027
- toString() {
1028
- return this.value;
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
+ };
1029
940
  }
1030
- };
1031
- /**
1032
- * Mark a static, author-controlled string as safe to interpolate unescaped.
1033
- */
1034
- function raw(markup) {
1035
- return new RawHtml(markup);
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
+ };
1036
955
  }
1037
956
  /**
1038
- * Escape the five characters that can change the meaning of HTML text or of a
1039
- * quoted attribute value.
957
+ * Handle post-creation work for admin-created users.
1040
958
  *
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.
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.
1044
962
  */
1045
- function escapeHtml(value) {
1046
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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
998
+ };
1047
999
  }
1048
1000
  /**
1049
- * Render one interpolated value: pass {@link RawHtml} through, drop nullish,
1050
- * escape everything else.
1001
+ * The step after a new user's row is written: who delivers the credentials,
1002
+ * and what the create response says about it.
1003
+ *
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.
1009
+ *
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
+ *
1018
+ * The result has exactly the fields a create response carries, with absent
1019
+ * ones left out.
1051
1020
  */
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));
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
+ };
1056
1031
  }
1032
+ //#endregion
1033
+ //#region src/auth/token-revocation.ts
1057
1034
  /**
1058
- * Tagged template for email markup. Every `${...}` is escaped unless it is
1059
- * {@link RawHtml}.
1035
+ * Has this access token been revoked?
1060
1036
  *
1061
- * Returns `RawHtml` so fragments nest without being escaped a second time:
1037
+ * `logout`, `change-password`, `reset-password` and `DELETE /auth/sessions` all
1038
+ * stamp a `tokensValidAfter` watermark on the user. Every one of them also
1039
+ * deletes refresh-token rows, which is what made the gap easy to miss: the
1040
+ * session really is gone, and the *refresh* path really does check the
1041
+ * watermark — so signing out looked like it worked.
1062
1042
  *
1063
- * ```ts
1064
- * const button = url ? html`<a href="${url}">Open</a>` : raw("");
1065
- * const body = html`<div>${button}</div>`;
1066
- * ```
1043
+ * The access token was untouched. It is a bearer credential that nothing
1044
+ * consulted a database about, so it stayed valid for its full lifetime after
1045
+ * every one of those actions. "Sign out everywhere" left the stolen token
1046
+ * working; so did changing your password because you thought it had leaked.
1047
+ *
1048
+ * The watermark had exactly one read in the repository, on refresh. This adds
1049
+ * the other one.
1050
+ *
1051
+ * ## Cost
1052
+ *
1053
+ * One indexed lookup per request, on the paths that already make one. The
1054
+ * adapter re-reads roles for every authenticated request; the admin routes now
1055
+ * do too. Both call this with the row they were already fetching in mind — a
1056
+ * repository that wants to serve both from one query is free to cache
1057
+ * internally, but nothing here assumes it.
1058
+ *
1059
+ * ## Failure
1060
+ *
1061
+ * Fails **open**, deliberately, and this is the one place in the auth stack
1062
+ * where that is right. The watermark is an extra revocation signal layered over
1063
+ * a token that has already been verified — signature, expiry and purpose all
1064
+ * checked. Refusing every request when the database is unreachable would turn a
1065
+ * transient outage into a total sign-out of an entire deployment, and the
1066
+ * attacker this protects against needs to have already stolen a live token. The
1067
+ * failure is logged at warn so it is visible rather than silent.
1067
1068
  */
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);
1069
+ async function isAccessTokenRevoked(authRepo, payload) {
1070
+ if (typeof authRepo.getTokensValidAfter !== "function") return false;
1071
+ if (typeof payload.iat !== "number") return false;
1072
+ let validAfter;
1073
+ try {
1074
+ validAfter = await authRepo.getTokensValidAfter(payload.uid);
1075
+ } catch (error) {
1076
+ logger.warn("[Auth] Could not read the token revocation watermark; allowing the request", {
1077
+ uid: payload.uid,
1078
+ error
1079
+ });
1080
+ return false;
1081
+ }
1082
+ if (!validAfter) return false;
1083
+ return payload.iat < Math.floor(validAfter.getTime() / 1e3);
1072
1084
  }
1073
1085
  //#endregion
1074
- //#region src/email/templates.ts
1086
+ //#region src/auth/rls-scope.ts
1087
+ var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
1075
1088
  /**
1076
- * Default email templates for authentication emails.
1089
+ * The identity the trusted server plane runs as: `rebase.dataAsAdmin`, and any
1090
+ * caller presenting the service key.
1077
1091
  *
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.
1092
+ * Naming it makes the privilege legible, because it is narrower than it reads.
1093
+ * This is **not** an RLS bypass — a driver scoped with it is an
1094
+ * `AuthenticatedPostgresBackendDriver`, so every statement runs in a
1095
+ * transaction that has done `SET LOCAL ROLE rebase_user` with
1096
+ * `app.uid = 'service'`, and policies are evaluated. Two consequences worth
1097
+ * knowing before you write a policy:
1098
+ *
1099
+ * - It passes the default policies through their `rolesOverlap(['admin'])`
1100
+ * arm — the same arm an application user holding the `admin` role passes.
1101
+ * - `policy.serverContext()` compiles to `rebase.uid() IS NULL` and is therefore
1102
+ * **false** for it. A collection with `disableDefaultPolicies: true` whose
1103
+ * only rule is `serverContext()` denies these writes (42501) and returns
1104
+ * zero rows for these reads.
1105
+ *
1106
+ * The true bypass is `rebase.sql()`, which runs on the owner connection and
1107
+ * never goes near `withAuth`.
1085
1108
  */
1109
+ var SERVICE_IDENTITY = {
1110
+ uid: "service",
1111
+ roles: ["admin"]
1112
+ };
1086
1113
  /**
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.
1114
+ * Returns true if the driver supports RLS scoping via `withAuth()`.
1093
1115
  */
1094
- function getGreeting(user) {
1095
- return user.displayName || user.email.split("@")[0];
1116
+ function isRLSScopedDriver(driver) {
1117
+ return "withAuth" in driver && typeof driver.withAuth === "function";
1096
1118
  }
1097
1119
  /**
1098
- * Common email styles.
1120
+ * Can this driver run a request as a specific user?
1099
1121
  *
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.)
1104
- */
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
- };
1122
+ * The same question `scopeDataDriver` asks internally, exported because one
1123
+ * caller must act on the answer BEFORE it has a request to scope.
1124
+ *
1125
+ * `scopeDataDriver` returns the driver unscoped when the answer is no, which is
1126
+ * the right behaviour for `/api/data` — Mongo has no row-level security and
1127
+ * never claimed to, and the route's own checks still apply. It is the wrong
1128
+ * behaviour for a surface whose entire promise is "this runs as you": there,
1129
+ * an unscoped driver silently means "runs as the database owner". So the MCP
1130
+ * surface asks this at boot and declines to mount rather than degrade.
1131
+ */
1132
+ function supportsRlsScoping(driver) {
1133
+ return isRLSScopedDriver(driver);
1134
+ }
1174
1135
  /**
1175
- * The app name every template falls back to when `email.appName` is unset.
1136
+ * Scope a DataDriver via `withAuth()` for RLS.
1137
+ *
1138
+ * SECURITY: If `withAuth()` is available but fails, the error is re-thrown
1139
+ * so the request is **denied** rather than proceeding with unscoped access
1140
+ * (fail-closed behavior).
1141
+ *
1142
+ * If the driver does not support RLS, the original driver is returned.
1143
+ *
1144
+ * @param driver - The DataDriver to scope.
1145
+ * @param user - The authenticated user identity for RLS.
1146
+ * @returns The RLS-scoped DataDriver (or the original if RLS is unsupported).
1176
1147
  */
1177
- var DEFAULT_APP_NAME = "Rebase";
1148
+ async function scopeDataDriver(driver, user) {
1149
+ if (isRLSScopedDriver(driver)) return await driver.withAuth(user);
1150
+ return driver;
1151
+ }
1152
+ //#endregion
1153
+ //#region src/auth/crypto-utils.ts
1178
1154
  /**
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.
1155
+ * Cryptographic utility functions for auth.
1156
+ *
1157
+ * @module
1182
1158
  */
1183
- var REBASE_LOGO_URL = "https://rebase.pro/img/logo_small.png";
1184
1159
  /**
1185
- * Whether a configured logo can actually be fetched by a mail client.
1160
+ * Constant-time string comparison to prevent timing attacks.
1186
1161
  *
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.
1162
+ * Used for comparing service keys, tokens, and other secrets where
1163
+ * timing side-channels could leak information about the expected value.
1164
+ *
1165
+ * The implementation lives in `utils/portable-crypto.ts`, which does this over
1166
+ * UTF-8 bytes without `node:crypto` — this function is on the request path, and
1167
+ * the byte-level reasoning it depends on is documented there.
1168
+ *
1169
+ * @param a - First string to compare.
1170
+ * @param b - Second string to compare.
1171
+ * @returns `true` if the strings are identical, `false` otherwise.
1192
1172
  */
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
- }
1173
+ function safeCompare(a, b) {
1174
+ return constantTimeEqual(a, b);
1201
1175
  }
1176
+ //#endregion
1177
+ //#region src/auth/bearer-token.ts
1202
1178
  /**
1203
- * Whether this config still carries the stock app name — i.e. nobody has
1204
- * branded this install.
1179
+ * Parsing of the `Authorization: Bearer <token>` header.
1205
1180
  *
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.
1181
+ * Its own module rather than a helper inside `middleware.ts`, because the
1182
+ * API-key middleware needs it too and `middleware.ts` already imports from
1183
+ * there — putting it in either one would close an import cycle.
1184
+ *
1185
+ * @module
1212
1186
  */
1213
- function isUnbranded(appName) {
1214
- return !appName || appName === DEFAULT_APP_NAME;
1215
- }
1216
1187
  /**
1217
- * Resolve the branding a template should render from the email config.
1188
+ * Pull the token out of an `Authorization: Bearer <token>` header.
1218
1189
  *
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.
1190
+ * RFC 7235 §2.1 makes the auth-scheme name case-insensitive, and plenty of
1191
+ * clients send `bearer` — HTTP libraries that lower-case what they emit,
1192
+ * hand-written curl, proxies that normalise headers. Matching `"Bearer "`
1193
+ * exactly turned those into a 401 that, from the caller's side, is
1194
+ * indistinguishable from a rejected token; people spend an afternoon
1195
+ * regenerating credentials that were fine. Only the scheme is folded — the
1196
+ * token itself is returned byte-for-byte.
1197
+ *
1198
+ * Returns `undefined` when there is no header or it names another scheme.
1199
+ * That is deliberately distinct from an empty token (`Bearer ` with nothing
1200
+ * after it), which callers still route into verification so it fails as a bad
1201
+ * token rather than as a missing header.
1224
1202
  */
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
- };
1203
+ function extractBearerToken(authHeader) {
1204
+ if (!authHeader) return void 0;
1205
+ const separator = authHeader.indexOf(" ");
1206
+ if (separator < 0) return void 0;
1207
+ if (authHeader.slice(0, separator).toLowerCase() !== "bearer") return void 0;
1208
+ return authHeader.slice(separator + 1);
1231
1209
  }
1210
+ //#endregion
1211
+ //#region src/auth/api-keys/api-key-middleware.ts
1232
1212
  /**
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.
1213
+ * Check whether a token looks like a Rebase API key.
1214
+ */
1215
+ function isApiKeyToken(token) {
1216
+ return token.startsWith("rk_");
1217
+ }
1218
+ /**
1219
+ * Hash a plaintext API key token for database lookup.
1239
1220
  *
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.
1221
+ * Async because it hashes with WebCrypto rather than `node:crypto` — the sole
1222
+ * caller was already awaiting the store lookup this feeds.
1245
1223
  */
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
- `;
1224
+ function hashToken(token) {
1225
+ return sha256Hex(token);
1254
1226
  }
1255
1227
  /**
1256
- * Default password reset email template
1228
+ * Validate an API key token and populate the Hono context.
1229
+ *
1230
+ * Returns `true` if the key is valid and context has been populated,
1231
+ * or returns an error Response if the key is invalid.
1232
+ *
1233
+ * This is NOT a standalone middleware — it's called from within
1234
+ * `createAuthMiddleware()` when a `rk_` prefixed token is detected.
1257
1235
  */
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
1236
+ async function validateApiKey(c, token, options) {
1237
+ const { store, driver } = options;
1238
+ const hash = await hashToken(token);
1239
+ const apiKey = await store.findByKeyHash(hash);
1240
+ if (!apiKey) return c.json({ error: {
1241
+ message: "Invalid API key",
1242
+ code: "UNAUTHORIZED"
1243
+ } }, 401);
1244
+ if (apiKey.revoked_at) return c.json({ error: {
1245
+ message: "API key has been revoked",
1246
+ code: "UNAUTHORIZED"
1247
+ } }, 401);
1248
+ if (apiKey.expires_at && new Date(apiKey.expires_at) < /* @__PURE__ */ new Date()) return c.json({ error: {
1249
+ message: "API key has expired",
1250
+ code: "UNAUTHORIZED"
1251
+ } }, 401);
1252
+ const uid = `api-key:${apiKey.id}`;
1253
+ const roles = apiKey.admin ? ["admin", "service"] : ["service"];
1254
+ c.set("user", {
1255
+ uid,
1256
+ roles
1257
+ });
1258
+ const masked = {
1259
+ id: apiKey.id,
1260
+ name: apiKey.name,
1261
+ key_prefix: apiKey.key_prefix,
1262
+ permissions: apiKey.permissions,
1263
+ admin: apiKey.admin,
1264
+ rate_limit: apiKey.rate_limit,
1265
+ created_by: apiKey.created_by,
1266
+ created_at: apiKey.created_at,
1267
+ updated_at: apiKey.updated_at,
1268
+ last_used_at: apiKey.last_used_at,
1269
+ expires_at: apiKey.expires_at,
1270
+ revoked_at: apiKey.revoked_at
1328
1271
  };
1272
+ c.set("apiKey", masked);
1273
+ try {
1274
+ const scopedDriver = await scopeDataDriver(driver, {
1275
+ uid,
1276
+ roles
1277
+ });
1278
+ c.set("driver", scopedDriver);
1279
+ } catch (error) {
1280
+ logger.error("[AUTH] RLS scoping failed for API key", { error });
1281
+ return c.json({ error: {
1282
+ message: "Internal authentication error",
1283
+ code: "INTERNAL_ERROR"
1284
+ } }, 500);
1285
+ }
1286
+ const lastTouch = lastUsedTouchedAt.get(apiKey.id);
1287
+ const now = Date.now();
1288
+ if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
1289
+ lastUsedTouchedAt.set(apiKey.id, now);
1290
+ store.updateLastUsed(apiKey.id).catch(() => {});
1291
+ }
1292
+ return true;
1329
1293
  }
1294
+ /** Per-process debounce state for last_used_at touches. */
1295
+ var lastUsedTouchedAt = /* @__PURE__ */ new Map();
1296
+ var LAST_USED_DEBOUNCE_MS = 6e4;
1330
1297
  /**
1331
- * Default email verification template
1298
+ * Shared 403 response for API-key permission denials outside the REST
1299
+ * generator (storage and functions guards). One envelope, one code — a
1300
+ * change to the error contract happens in one place.
1332
1301
  */
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
- };
1302
+ function forbidApiKey(c, operation, resource, grantHint) {
1303
+ return c.json({ error: {
1304
+ message: `API key does not have "${operation}" permission for ${resource}. Grant it with a permission entry like { "collection": "${grantHint}", "operations": ["${operation}"] }.`,
1305
+ code: "API_KEY_FORBIDDEN"
1306
+ } }, 403);
1394
1307
  }
1395
1308
  /**
1396
- * Default user invitation email template
1397
- * Sent when an admin creates a new user account
1309
+ * Permission guard for API-key requests to the storage router.
1310
+ *
1311
+ * Storage previously did not accept API keys at all (`rk_` tokens were
1312
+ * misparsed as JWTs and 401'd). Now that the pre-auth middleware
1313
+ * authenticates them, this guard decides what they may do: the key needs a
1314
+ * `"storage"` permission entry (or the global `"*"` wildcard) covering the
1315
+ * operation derived from the HTTP method. Requests not authenticated via an
1316
+ * API key pass through to the storage router's own auth gates.
1317
+ *
1318
+ * TUS resumable-upload routes (`/tus`, `/tus/:id`) are classified as `write`
1319
+ * for EVERY method: the protocol's offset check is a GET and its cancel is a
1320
+ * DELETE, but both are steps of an upload — a write-scoped key must be able
1321
+ * to complete (and abort) its own resumable upload without also holding
1322
+ * `read`/`delete` on stored objects.
1398
1323
  */
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
1324
+ function createStorageApiKeyGuard() {
1325
+ return async (c, next) => {
1326
+ const apiKey = c.get("apiKey");
1327
+ if (!apiKey) return next();
1328
+ const operation = /\/tus(\/|$)/.test(c.req.path) ? "write" : httpMethodToOperation(c.req.method);
1329
+ if (!isStorageAllowed(apiKey.permissions, operation)) return forbidApiKey(c, operation, "storage", "storage");
1330
+ return next();
1467
1331
  };
1468
1332
  }
1469
1333
  /**
1470
- * Default welcome email template
1471
- * Sent automatically when a new user registers
1472
- */
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
1334
+ * Permission guard for API-key requests to the custom-functions router.
1335
+ *
1336
+ * The collection permission guard lives in the REST generator and never sees
1337
+ * function routes, so before this middleware existed ANY valid API key —
1338
+ * however narrowly scoped — could invoke every custom function. This guard
1339
+ * closes that: API-key requests must hold a `"functions"`/`"functions/<name>"`
1340
+ * permission entry (or the global `"*"` wildcard) for the derived operation.
1341
+ *
1342
+ * Non-API-key requests (JWT, service key, anonymous) pass through untouched —
1343
+ * functions decide their own auth for those, as before.
1344
+ *
1345
+ * @param mountPrefix - The path the functions router is mounted at
1346
+ * (e.g. `/api/functions`), used to extract the function
1347
+ * name from the request path.
1348
+ */
1349
+ function createFunctionApiKeyGuard(mountPrefix) {
1350
+ return async (c, next) => {
1351
+ const apiKey = c.get("apiKey");
1352
+ if (!apiKey) return next();
1353
+ const path = c.req.path;
1354
+ const idx = path.indexOf(mountPrefix);
1355
+ const rawName = (idx < 0 ? "" : path.slice(idx + mountPrefix.length)).split("/").filter(Boolean)[0] ?? "";
1356
+ let functionName;
1357
+ try {
1358
+ functionName = decodeURIComponent(rawName);
1359
+ } catch {
1360
+ functionName = rawName;
1361
+ }
1362
+ const operation = httpMethodToOperation(c.req.method);
1363
+ if (!isFunctionAllowed(apiKey.permissions, functionName, operation)) return forbidApiKey(c, operation, functionName ? `function "${functionName}"` : "the functions index", `functions${functionName ? `/${functionName}` : ""}`);
1364
+ return next();
1533
1365
  };
1534
1366
  }
1535
1367
  /**
1536
- * Default one-time sign-in code email.
1368
+ * Standalone pre-auth middleware for `rk_` bearer tokens.
1537
1369
  *
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.
1370
+ * Routers whose auth gate is JWT-based (`requireAuth` / `createRequireAuth` —
1371
+ * the admin surfaces: admin users/roles, api-keys management, cron, backups,
1372
+ * logs, schema editor) don't know about API keys. Mounting this middleware in
1373
+ * front of them authenticates `rk_` tokens and populates the request context;
1374
+ * the downstream gates then see the already-resolved user and apply their
1375
+ * role checks (`requireAdmin`) as usual — so an `admin: true` key passes and
1376
+ * a non-admin key is rejected with 403.
1377
+ *
1378
+ * Requests without an `rk_` bearer token pass through untouched. An invalid,
1379
+ * revoked, or expired `rk_` token is rejected here (401) rather than falling
1380
+ * through to be misparsed as a JWT.
1541
1381
  */
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
1382
+ function createApiKeyPreAuth(options) {
1383
+ return async (c, next) => {
1384
+ if (c.get("apiKey")) return next();
1385
+ const token = extractBearerToken(c.req.header("authorization")) ?? "";
1386
+ if (!token || !isApiKeyToken(token)) return next();
1387
+ const result = await validateApiKey(c, token, options);
1388
+ if (result !== true) return result;
1389
+ return next();
1603
1390
  };
1604
1391
  }
1392
+ //#endregion
1393
+ //#region src/auth/middleware.ts
1605
1394
  /**
1606
- * Default magic link email template
1395
+ * Answer a refusal with the canonical envelope, through the same formatter
1396
+ * every route uses.
1397
+ *
1398
+ * These middlewares used to hand-build `c.json({ error: { message, code } },
1399
+ * 401)` — seven near-copies of it, which is how they came to omit `requestId`
1400
+ * while every other error on the server carried it: the one string a bug report
1401
+ * quotes was missing from precisely the errors an app hits most. It also meant
1402
+ * a routine 401 (a client refreshing before it knows whether a session exists)
1403
+ * logged like an incident, because `ApiError.expected` never reached the
1404
+ * logger.
1405
+ *
1406
+ * `errorHandler` is *called* rather than thrown to. A throw only becomes an
1407
+ * envelope where an `onError` is registered, and these middlewares are mounted
1408
+ * by callers this package does not control — `defineFunction` hands users a
1409
+ * Hono app and invites them to put `requireAuth` on a route of it. Making the
1410
+ * status depend on the mount point would turn a 401 into a 500 for exactly
1411
+ * those callers. Calling the formatter directly gets the envelope with none of
1412
+ * that.
1607
1413
  */
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
1414
+ function refuse(c, error) {
1415
+ return errorHandler(error, c);
1416
+ }
1417
+ /**
1418
+ * Hono middleware that requires a valid JWT token via Authorization header.
1419
+ * Returns 401 if token is missing or invalid.
1420
+ *
1421
+ * **Security:** Tokens are only accepted via the `Authorization: Bearer`
1422
+ * header. Query-string tokens (`?token=`) are intentionally NOT accepted
1423
+ * here because URLs leak into access logs, proxies, Referer headers, and
1424
+ * browser history. Use {@link queryTokenAuth} on routes that legitimately
1425
+ * need query-string tokens (e.g. storage file serving for `<img src>`).
1426
+ */
1427
+ var requireAuth = async (c, next) => {
1428
+ if (c.get("user")) return next();
1429
+ const token = extractBearerToken(c.req.header("authorization"));
1430
+ if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
1431
+ const payload = await verifyAccessToken(token);
1432
+ if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
1433
+ c.set("user", payload);
1434
+ return next();
1435
+ };
1436
+ /**
1437
+ * Factory that creates a requireAuth middleware with optional service key support.
1438
+ *
1439
+ * When `serviceKey` is provided, the middleware will check if the Bearer token
1440
+ * matches the service key using constant-time comparison. If it matches, the
1441
+ * request is authenticated as a service user with admin privileges.
1442
+ *
1443
+ * This allows admin routes (which use standalone requireAuth + requireAdmin)
1444
+ * to be accessed via service keys for scripts and server-to-server calls.
1445
+ */
1446
+ function createRequireAuth(options) {
1447
+ if (!options?.serviceKey && !options?.resolveRoles && !options?.revocationRepo) return requireAuth;
1448
+ const key = options.serviceKey;
1449
+ const resolveRoles = options.resolveRoles;
1450
+ const revocationRepo = options.revocationRepo;
1451
+ return async (c, next) => {
1452
+ if (c.get("user")) return next();
1453
+ const token = extractBearerToken(c.req.header("authorization"));
1454
+ if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
1455
+ if (key !== void 0 && safeCompare(token, key)) {
1456
+ c.set("user", {
1457
+ uid: "service",
1458
+ roles: ["admin"]
1459
+ });
1460
+ return next();
1461
+ }
1462
+ const payload = await verifyAccessToken(token);
1463
+ if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
1464
+ if (resolveRoles || revocationRepo) try {
1465
+ if (revocationRepo && await isAccessTokenRevoked(revocationRepo, payload)) return refuse(c, ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED"));
1466
+ c.set("user", resolveRoles ? {
1467
+ ...payload,
1468
+ roles: await resolveRoles(payload.uid)
1469
+ } : payload);
1470
+ return next();
1471
+ } catch (error) {
1472
+ logger.warn("[Auth] Could not resolve roles for an admin-gated request", {
1473
+ uid: payload.uid,
1474
+ error
1475
+ });
1476
+ return c.json({ error: {
1477
+ message: "Could not verify your permissions. Please try again.",
1478
+ code: "ROLE_LOOKUP_FAILED"
1479
+ } }, 503);
1480
+ }
1481
+ c.set("user", payload);
1482
+ return next();
1678
1483
  };
1679
1484
  }
1680
- //#endregion
1681
- //#region src/auth/admin-user-ops.ts
1682
1485
  /**
1683
- * Admin User Operations
1684
- *
1685
- * Shared utilities and orchestration for admin-initiated user management
1686
- * (user creation via REST API, password reset via admin panel).
1687
- *
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
+ * Middleware that requires the user to have an admin or schema-admin role.
1487
+ * Must be used AFTER requireAuth or on a route where user is guaranteed.
1692
1488
  */
1489
+ var requireAdmin = async (c, next) => {
1490
+ const user = c.get("user");
1491
+ if (!user) return refuse(c, ApiError.unauthorized("User not authenticated. requireAuth middleware is missing?"));
1492
+ if (!hasAdministrativeRole(typeof user === "object" && user !== null && "roles" in user ? user.roles || [] : [])) return refuse(c, ApiError.forbidden("Admin privileges required for this operation"));
1493
+ return next();
1494
+ };
1693
1495
  /**
1694
- * Generate a cryptographically secure random password that meets strength requirements.
1496
+ * Middleware that optionally extracts user from JWT via Authorization header.
1497
+ * Does not return 401 if token is missing — allows anonymous access.
1695
1498
  *
1696
- * 16 characters, guaranteed at least one uppercase, one lowercase, one digit.
1697
- * Ambiguous characters (0, O, 1, l, I) are excluded.
1499
+ * Query-string tokens are NOT accepted here. Use {@link queryTokenAuth}
1500
+ * on routes that need them.
1698
1501
  */
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]];
1502
+ var optionalAuth = async (c, next) => {
1503
+ if (c.get("user")) return next();
1504
+ const token = extractBearerToken(c.req.header("authorization"));
1505
+ if (token !== void 0 && isJwtConfigured()) {
1506
+ const payload = await verifyAccessToken(token);
1507
+ if (payload) c.set("user", payload);
1714
1508
  }
1715
- return parts.join("");
1716
- }
1509
+ return next();
1510
+ };
1717
1511
  /**
1718
- * Generate a cryptographically secure random token (80 hex characters).
1512
+ * Extract user from token - for WebSocket authentication
1719
1513
  */
1720
- function generateSecureToken() {
1721
- return randomBytes(40).toString("hex");
1514
+ async function extractUserFromToken(token) {
1515
+ if (!isJwtConfigured()) return null;
1516
+ return verifyAccessToken(token);
1722
1517
  }
1723
1518
  /**
1724
- * Hash a token for database storage using SHA-256.
1519
+ * Create a configurable auth middleware that handles:
1520
+ * 1. Token extraction (via custom validator or JWT Bearer token)
1521
+ * 2. RLS-scoped DataDriver via withAuth()
1522
+ * 3. Enforcement (401 when requireAuth is true and no user)
1523
+ *
1524
+ * **Secure by default:** `requireAuth` defaults to `true`. Anonymous
1525
+ * access is only allowed when the developer explicitly opts out by
1526
+ * setting `requireAuth: false`, indicating that Postgres RLS policies
1527
+ * fully control access.
1528
+ *
1529
+ * **Fail-closed:** The raw unscoped driver is never placed in the
1530
+ * request context. Every code path either scopes via `withAuth()` or
1531
+ * rejects the request. This prevents silent RLS bypass.
1532
+ *
1533
+ * This is the single source of truth for HTTP auth in Rebase.
1534
+ * Use this instead of manually parsing tokens in route handlers.
1725
1535
  */
1726
- function hashToken(token) {
1727
- return createHash("sha256").update(token).digest("hex");
1536
+ function createAuthMiddleware(options) {
1537
+ const { driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
1538
+ return async (c, next) => {
1539
+ const driver = resolveDriver ? resolveDriver(c) : baseDriver;
1540
+ if (validator) try {
1541
+ const authResult = await validator(c);
1542
+ if (authResult && typeof authResult === "object") {
1543
+ const id = "uid" in authResult ? authResult.uid : void 0;
1544
+ if (id) {
1545
+ const roles = authResult.roles || [];
1546
+ c.set("user", {
1547
+ uid: id,
1548
+ roles
1549
+ });
1550
+ const user = {
1551
+ uid: id,
1552
+ roles,
1553
+ ...authResult
1554
+ };
1555
+ c.set("driver", await scopeDataDriver(driver, user));
1556
+ } else c.set("driver", await scopeDataDriver(driver, {
1557
+ uid: ANONYMOUS_USER_ID,
1558
+ roles: ["anon"]
1559
+ }));
1560
+ } else if (authResult === true) {
1561
+ c.set("user", {
1562
+ uid: "default",
1563
+ roles: []
1564
+ });
1565
+ c.set("driver", await scopeDataDriver(driver, {
1566
+ uid: "default",
1567
+ roles: []
1568
+ }));
1569
+ } else c.set("driver", await scopeDataDriver(driver, {
1570
+ uid: ANONYMOUS_USER_ID,
1571
+ roles: ["anon"]
1572
+ }));
1573
+ } catch (error) {
1574
+ return refuse(c, ApiError.unauthenticated("Unauthorized"));
1575
+ }
1576
+ else {
1577
+ const token = extractBearerToken(c.req.header("authorization"));
1578
+ if (token !== void 0) if (serviceKey && safeCompare(token, serviceKey)) {
1579
+ c.set("user", {
1580
+ uid: "service",
1581
+ roles: ["admin"]
1582
+ });
1583
+ try {
1584
+ c.set("driver", await scopeDataDriver(driver, {
1585
+ uid: "service",
1586
+ roles: ["admin"]
1587
+ }));
1588
+ } catch (error) {
1589
+ logger.error("[AUTH] RLS scoping failed for service key", { error });
1590
+ return c.json({ error: {
1591
+ message: "Internal authentication error",
1592
+ code: "INTERNAL_ERROR"
1593
+ } }, 500);
1594
+ }
1595
+ } else if (apiKeyStore && isApiKeyToken(token)) {
1596
+ const result = await validateApiKey(c, token, {
1597
+ store: apiKeyStore,
1598
+ driver
1599
+ });
1600
+ if (result !== true) return result;
1601
+ } else {
1602
+ const payload = await extractUserFromToken(token);
1603
+ if (payload) {
1604
+ c.set("user", payload);
1605
+ try {
1606
+ const user = {
1607
+ uid: payload.uid,
1608
+ roles: payload.roles,
1609
+ isAnonymous: payload.isAnonymous === true,
1610
+ claims: payload.claims
1611
+ };
1612
+ c.set("driver", await scopeDataDriver(driver, user));
1613
+ } catch (error) {
1614
+ logger.error("[AUTH] RLS scoping failed for authenticated user", { error });
1615
+ return c.json({ error: {
1616
+ message: "Internal authentication error",
1617
+ code: "INTERNAL_ERROR"
1618
+ } }, 500);
1619
+ }
1620
+ } else return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
1621
+ }
1622
+ else try {
1623
+ c.set("driver", await scopeDataDriver(driver, {
1624
+ uid: ANONYMOUS_USER_ID,
1625
+ roles: ["anon"]
1626
+ }));
1627
+ } catch (error) {
1628
+ logger.error("[AUTH] Failed to create anon-scoped driver", { error });
1629
+ return c.json({ error: {
1630
+ message: "Server configuration error",
1631
+ code: "INTERNAL_ERROR"
1632
+ } }, 500);
1633
+ }
1634
+ }
1635
+ 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."));
1636
+ return next();
1637
+ };
1728
1638
  }
1729
1639
  /**
1730
- * Build the `AuthCollectionContext` facade from server internals.
1640
+ * Middleware that authenticates via a `?token=` query parameter.
1641
+ *
1642
+ * **Use sparingly.** Tokens in URLs leak into access logs, proxy logs,
1643
+ * Referer headers, and browser history. This middleware exists solely for
1644
+ * routes where the consumer cannot set HTTP headers — e.g. `<img src>`,
1645
+ * `<a href>` for file downloads, or similar browser-native requests.
1646
+ *
1647
+ * Apply it **before** `requireAuth` or `optionalAuth` on the specific
1648
+ * route that needs it. Those middlewares will see the user context this
1649
+ * middleware sets and skip their own 401 check.
1650
+ *
1651
+ * @example
1652
+ * ```ts
1653
+ * router.get("/file/*", queryTokenAuth, readAuthMiddleware, handler);
1654
+ * ```
1655
+ */
1656
+ var queryTokenAuth = async (c, next) => {
1657
+ if (extractBearerToken(c.req.header("authorization")) !== void 0) return next();
1658
+ const queryToken = c.req.query("token");
1659
+ if (queryToken && isJwtConfigured()) {
1660
+ const payload = await verifyAccessToken(queryToken);
1661
+ if (payload) c.set("user", payload);
1662
+ }
1663
+ return next();
1664
+ };
1665
+ /**
1666
+ * Authorizes anonymous access to **public** storage objects (those under the
1667
+ * public prefix), which are served token-less via stable, permanent URLs.
1668
+ *
1669
+ * Runs on the storage `/file/*` and `/metadata/*` routes, *after* the token
1670
+ * middleware and *before* the read-auth gate. If the request is already
1671
+ * authenticated (Bearer or scoped token), it does nothing. Otherwise, when the
1672
+ * requested object path is public, it sets a minimal "public" principal so the
1673
+ * downstream `requireAuth` gate lets the read through. Private paths are left
1674
+ * untouched, so they still require a valid token.
1675
+ */
1676
+ var publicObjectAuth = async (c, next) => {
1677
+ if (c.get("user")) return next();
1678
+ const prefix = c.req.routePath.replace("/*", "");
1679
+ const fullPath = c.req.path;
1680
+ const idx = fullPath.indexOf(prefix);
1681
+ const rawPath = idx < 0 ? "" : fullPath.substring(idx + prefix.length + 1);
1682
+ if (rawPath && isPublicStoragePath(decodeURIComponent(rawPath))) c.set("user", {
1683
+ uid: "public",
1684
+ roles: ["public"]
1685
+ });
1686
+ return next();
1687
+ };
1688
+ /**
1689
+ * Helper to match paths for scoped file tokens.
1690
+ * Matches exact paths or prefixes (exact folder prefixes or ending with /).
1691
+ *
1692
+ * The traversal check is defence in depth, not the load-bearing defence.
1693
+ *
1694
+ * A prefix comparison is a lie for any path containing `..`: `user1/../user2/x`
1695
+ * starts with `user1/`, so a token scoped to `user1/` would authorize reading
1696
+ * user2's file — and the storage controller would not object, because its own
1697
+ * guard stops a path escaping the *bucket*, and that one resolves to `user2/x`,
1698
+ * comfortably inside it. The grant is what would be escaped, and this is the
1699
+ * only layer that knows what was granted.
1731
1700
  *
1732
- * This is the simplified context exposed to collection-level auth hooks,
1733
- * keeping them decoupled from internal interfaces like `AuthRepository`.
1701
+ * Nothing arrives here in that shape today: the URL parser resolves `..` (and
1702
+ * `%2e%2e`, which the WHATWG spec treats as a dot for normalization) before
1703
+ * Hono routes the request, so this comparison already runs on a resolved path.
1704
+ * But that is a guarantee of the runtime rather than of this code, and it is
1705
+ * one line to not depend on it. The same rule already guards the public-object
1706
+ * path — see `isPublicStoragePath`.
1734
1707
  */
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
- };
1708
+ function isPathMatch(requested, allowed) {
1709
+ if (requested.split("/").some((seg) => seg === "..")) return false;
1710
+ if (requested === allowed) return true;
1711
+ if (allowed.endsWith("/")) return requested.startsWith(allowed);
1712
+ return requested.startsWith(allowed + "/");
1744
1713
  }
1745
1714
  /**
1746
- * Prepare user values for an admin-initiated user creation.
1747
- *
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
1752
- *
1753
- * The caller is responsible for persisting (via `driver.save()`).
1715
+ * Middleware that authenticates file-serving routes using scoped download tokens.
1716
+ * It enforces that only scoped "file-read" tokens can access "/file/*" and "?token=" query params.
1717
+ * Full access JWTs are explicitly rejected on "/file/*" routes, and in the "?token=" query param.
1754
1718
  */
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
1719
+ var fileTokenAuth = async (c, next) => {
1720
+ const isFileRoute = c.req.path.includes("/storage/file/");
1721
+ const authHeader = c.req.header("authorization");
1722
+ const queryToken = c.req.query("token");
1723
+ const extractWildcard = (ctx) => {
1724
+ const prefix = ctx.req.routePath.replace("/*", "");
1725
+ const fullPath = ctx.req.path;
1726
+ const idx = fullPath.indexOf(prefix);
1727
+ if (idx < 0) return "";
1728
+ return fullPath.substring(idx + prefix.length + 1);
1729
+ };
1730
+ /**
1731
+ * Must agree with `parseBucketAndPath` in `storage/routes.ts`, because the
1732
+ * path a token GRANTS is minted there and the path a request ASKS FOR is
1733
+ * derived here. `/metadata` canonicalizes before signing, so a raw
1734
+ * comparison here would 403 an otherwise-valid request for any key whose
1735
+ * URL form is not already canonical (`a//b.txt`, `a/./b.txt`).
1736
+ *
1737
+ * A key that cannot be canonicalized yields null, which matches no grant —
1738
+ * the same fail-closed answer `isPathMatch` gives a `..` segment.
1739
+ */
1740
+ const parseBucketPath = (filePath) => {
1741
+ const parts = filePath.split("/");
1742
+ if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
1743
+ bucket: "default",
1744
+ resolvedPath: tryCanonicalStorageKey(parts.slice(1).join("/"))
1765
1745
  };
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
1746
  return {
1775
- values: hookResult.values,
1776
- clearPassword: hookResult.temporaryPassword,
1777
- hookHandledEmail: true,
1778
- invitationSent: hookResult.invitationSent ?? false
1747
+ bucket: "default",
1748
+ resolvedPath: tryCanonicalStorageKey(filePath)
1779
1749
  };
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
1750
  };
1795
- }
1751
+ /**
1752
+ * Decide what a valid download token entitles this request to.
1753
+ *
1754
+ * One function rather than a copy per token location: a Bearer token and a
1755
+ * `?token=` are the same grant presented two ways, and the whole failure
1756
+ * this guards against is two places disagreeing about what was granted.
1757
+ *
1758
+ * `"no-path"` is distinct from the two denials because the caller treats it
1759
+ * differently — a request with no object path at all is not a denied read,
1760
+ * it is a request this middleware has nothing to say about, and on
1761
+ * `/metadata/*` it must still reach the downstream auth gate.
1762
+ */
1763
+ const evaluateGrant = (payload) => {
1764
+ if (canonicalStorageId(c.req.query("storageId")) !== payload.storageId) return "deny-storage";
1765
+ const rawPath = extractWildcard(c);
1766
+ if (!rawPath) return "no-path";
1767
+ const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
1768
+ if (resolvedPath === null) return "deny-path";
1769
+ return isPathMatch(`${bucket}/${resolvedPath}`, payload.path) ? "grant" : "deny-path";
1770
+ };
1771
+ /**
1772
+ * Which half of the scope failed is named in the message. The holder of the
1773
+ * token already knows both values, so this reveals nothing to an attacker —
1774
+ * and without it, a client that forgets to forward `?storageId=` on the file
1775
+ * URL reports a "path mismatch" for a path that matches perfectly.
1776
+ */
1777
+ const denyMismatch = (outcome) => refuse(c, ApiError.forbidden(outcome === "deny-storage" ? "Forbidden: Scoped token storage mismatch" : "Forbidden: Scoped token path mismatch"));
1778
+ const bearerToken = extractBearerToken(authHeader);
1779
+ if (bearerToken !== void 0) {
1780
+ const payload = await verifyDownloadToken(bearerToken);
1781
+ if (payload) {
1782
+ const outcome = evaluateGrant(payload);
1783
+ if (outcome === "grant") {
1784
+ c.set("user", {
1785
+ uid: "download-token",
1786
+ roles: ["reader"]
1787
+ });
1788
+ return next();
1789
+ }
1790
+ if (outcome !== "no-path") return denyMismatch(outcome);
1791
+ }
1792
+ if (isFileRoute) return refuse(c, ApiError.unauthenticated("Unauthorized: Access JWT not allowed on file routes"));
1793
+ return next();
1794
+ }
1795
+ if (queryToken) {
1796
+ const payload = await verifyDownloadToken(queryToken);
1797
+ if (payload) {
1798
+ const outcome = evaluateGrant(payload);
1799
+ if (outcome === "grant") {
1800
+ c.set("user", {
1801
+ uid: "download-token",
1802
+ roles: ["reader"]
1803
+ });
1804
+ return next();
1805
+ }
1806
+ if (outcome !== "no-path") return denyMismatch(outcome);
1807
+ }
1808
+ return refuse(c, ApiError.unauthenticated("Unauthorized: Invalid or unauthorized token"));
1809
+ }
1810
+ return next();
1811
+ };
1812
+ //#endregion
1813
+ //#region src/auth/adapter-middleware.ts
1796
1814
  /**
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.
1815
+ * Create a Hono middleware that uses an `AuthAdapter` for request verification.
1802
1816
  */
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
1817
+ function createAdapterAuthMiddleware(options) {
1818
+ const { adapter, driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, apiKeyStore } = options;
1819
+ return async (c, next) => {
1820
+ const driver = resolveDriver ? resolveDriver(c) : baseDriver;
1821
+ const presentedToken = extractBearerToken(c.req.header("authorization"));
1822
+ if (apiKeyStore) {
1823
+ const token = presentedToken ?? "";
1824
+ if (token.startsWith("rk_")) {
1825
+ const result = await validateApiKey(c, token, {
1826
+ store: apiKeyStore,
1827
+ driver
1828
+ });
1829
+ if (result === true) return next();
1830
+ return result;
1831
+ }
1832
+ }
1833
+ let authenticatedUser = null;
1834
+ try {
1835
+ authenticatedUser = await adapter.verifyRequest(c.req.raw);
1836
+ } catch (error) {
1837
+ return refuse(c, ApiError.unauthenticated("Unauthorized"));
1838
+ }
1839
+ if (authenticatedUser) {
1840
+ c.set("user", {
1841
+ uid: authenticatedUser.uid,
1842
+ email: authenticatedUser.email,
1843
+ roles: authenticatedUser.roles,
1844
+ isAnonymous: authenticatedUser.isAnonymous === true
1845
+ });
1846
+ try {
1847
+ c.set("driver", await scopeDataDriver(driver, {
1848
+ uid: authenticatedUser.uid,
1849
+ roles: authenticatedUser.roles,
1850
+ isAnonymous: authenticatedUser.isAnonymous === true
1851
+ }));
1852
+ } catch (error) {
1853
+ logger.error("[AUTH-ADAPTER] RLS scoping failed for authenticated user", { error });
1854
+ return refuse(c, ApiError.internal("Internal authentication error"));
1855
+ }
1856
+ } else {
1857
+ if (presentedToken !== void 0) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
1858
+ try {
1859
+ c.set("driver", await scopeDataDriver(driver, {
1860
+ uid: ANONYMOUS_USER_ID,
1861
+ roles: ["anon"]
1862
+ }));
1863
+ } catch (error) {
1864
+ logger.error("[AUTH-ADAPTER] Failed to create anon-scoped driver", { error });
1865
+ return refuse(c, ApiError.internal("Server configuration error"));
1866
+ }
1867
+ }
1868
+ 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."));
1869
+ return next();
1838
1870
  };
1839
1871
  }
1840
1872
  //#endregion
@@ -3774,7 +3806,7 @@ function mountMagicLinkRoutes(deps) {
3774
3806
  if (user) {
3775
3807
  if (ops.beforeLogin) await ops.beforeLogin(email, "magic-link");
3776
3808
  const token = generateSecureToken();
3777
- const tokenHash = hashToken(token);
3809
+ const tokenHash = hashToken$1(token);
3778
3810
  const expiresAt = getMagicLinkExpiry();
3779
3811
  await authRepo.createMagicLinkToken(user.id, tokenHash, expiresAt);
3780
3812
  const magicLinkUrl = `${resolveEmailLinkBase(emailConfig, "magicLink")}/auth/magic-link?token=${token}`;
@@ -3809,7 +3841,7 @@ function mountMagicLinkRoutes(deps) {
3809
3841
  */
3810
3842
  router.post("/magic-link/verify", strictAuthLimiter, async (c) => {
3811
3843
  const { token } = parseBody(verifyMagicLinkSchema, await c.req.json());
3812
- const tokenHash = hashToken(token);
3844
+ const tokenHash = hashToken$1(token);
3813
3845
  const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
3814
3846
  if (!storedToken) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
3815
3847
  await authRepo.markMagicLinkTokenUsed(tokenHash);
@@ -3964,7 +3996,7 @@ function mountOtpRoutes(deps) {
3964
3996
  if (ops.beforeLogin) await ops.beforeLogin(email, "otp");
3965
3997
  if (user) {
3966
3998
  const code = generateOtpCode();
3967
- await authRepo.createMagicLinkToken(user.id, hashToken(otpTokenMaterial(user.email, code)), new Date(Date.now() + OTP_TTL_MS));
3999
+ await authRepo.createMagicLinkToken(user.id, hashToken$1(otpTokenMaterial(user.email, code)), new Date(Date.now() + OTP_TTL_MS));
3968
4000
  const { appName, logoUrl } = resolveEmailBranding(emailConfig);
3969
4001
  const templateFn = emailConfig?.templates?.emailOtp;
3970
4002
  const content = templateFn ? templateFn(code, {
@@ -3994,7 +4026,7 @@ function mountOtpRoutes(deps) {
3994
4026
  */
3995
4027
  router.post("/otp/verify", strictAuthLimiter, captureOtpEmail, otpVerificationLimiter, async (c) => {
3996
4028
  const { email, code } = parseBody(verifySchema, await c.req.json());
3997
- const tokenHash = hashToken(otpTokenMaterial(email, code));
4029
+ const tokenHash = hashToken$1(otpTokenMaterial(email, code));
3998
4030
  const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
3999
4031
  if (!storedToken) throw ApiError.badRequest("Invalid or expired code", "INVALID_CODE");
4000
4032
  await authRepo.markMagicLinkTokenUsed(tokenHash);
@@ -4477,7 +4509,7 @@ function createAuthRoutes(config) {
4477
4509
  const user = await authRepo.getUserByEmail(email);
4478
4510
  if (user) {
4479
4511
  const token = generateSecureToken();
4480
- const tokenHash = hashToken(token);
4512
+ const tokenHash = hashToken$1(token);
4481
4513
  const expiresAt = getPasswordResetExpiry();
4482
4514
  await authRepo.createPasswordResetToken(user.id, tokenHash, expiresAt);
4483
4515
  const resetUrl = `${resolveEmailLinkBase(emailConfig, "resetPassword")}/reset-password?token=${token}`;
@@ -4512,7 +4544,7 @@ function createAuthRoutes(config) {
4512
4544
  const { token, password } = parseBody(resetPasswordSchema, await c.req.json());
4513
4545
  const passwordValidation = ops.validatePasswordStrength(password);
4514
4546
  if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
4515
- const tokenHash = hashToken(token);
4547
+ const tokenHash = hashToken$1(token);
4516
4548
  const storedToken = await authRepo.findValidPasswordResetToken(tokenHash);
4517
4549
  if (!storedToken) throw ApiError.badRequest("Invalid or expired reset token", "INVALID_TOKEN");
4518
4550
  const passwordHash = await ops.hashPassword(password);
@@ -4569,7 +4601,7 @@ function createAuthRoutes(config) {
4569
4601
  if (!user) throw ApiError.notFound("User not found");
4570
4602
  if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
4571
4603
  const token = generateSecureToken();
4572
- await authRepo.setVerificationToken(user.id, hashToken(token));
4604
+ await authRepo.setVerificationToken(user.id, hashToken$1(token));
4573
4605
  const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
4574
4606
  const { appName, logoUrl } = resolveEmailBranding(emailConfig);
4575
4607
  const templateFn = emailConfig?.templates?.emailVerification;
@@ -4598,7 +4630,7 @@ function createAuthRoutes(config) {
4598
4630
  router.get("/verify-email", async (c) => {
4599
4631
  const token = c.req.query("token");
4600
4632
  if (!token) throw ApiError.badRequest("Verification token is required", "INVALID_INPUT");
4601
- const user = await authRepo.getUserByVerificationToken(hashToken(token));
4633
+ const user = await authRepo.getUserByVerificationToken(hashToken$1(token));
4602
4634
  if (!user) throw ApiError.badRequest("Invalid or expired verification token", "INVALID_TOKEN");
4603
4635
  await authRepo.setEmailVerified(user.id, true);
4604
4636
  return c.json({
@@ -4831,7 +4863,7 @@ function createResetPasswordRoute(config) {
4831
4863
  invitationSent = hookResult.invitationSent ?? false;
4832
4864
  } else if (!!(emailService && emailService.isConfigured())) try {
4833
4865
  const token = generateSecureToken();
4834
- const tokenHash = hashToken(token);
4866
+ const tokenHash = hashToken$1(token);
4835
4867
  const expiresAt = new Date(Date.now() + 3600 * 1e3);
4836
4868
  await authRepo.createPasswordResetToken(existing.id, tokenHash, expiresAt);
4837
4869
  const setPasswordUrl = `${emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
@@ -5069,22 +5101,20 @@ function createAdminUsersRoute(config) {
5069
5101
  if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
5070
5102
  const user = await authRepo.createUser(createData);
5071
5103
  if (roles && Array.isArray(roles)) await authRepo.setUserRoles(user.id, roles);
5072
- const finalizeResult = await finalizeAdminUserCreation({
5104
+ const delivery = await completeUserCreation(prepResult, (clearPassword) => finalizeAdminUserCreation({
5073
5105
  id: user.id,
5074
5106
  values: prepResult.values
5075
- }, prepResult.clearPassword, {
5107
+ }, clearPassword, {
5076
5108
  authRepo,
5077
5109
  emailService,
5078
5110
  emailConfig,
5079
5111
  resolvedHooks: ops,
5080
5112
  collectionAuthConfig
5081
- });
5113
+ }));
5082
5114
  const adminUser = toAdminUser(user, await authRepo.getUserRoleIds(user.id));
5083
5115
  return c.json({
5084
5116
  user: adminUser,
5085
- invitationSent: finalizeResult.invitationSent,
5086
- ...finalizeResult.temporaryPassword ? { temporaryPassword: finalizeResult.temporaryPassword } : {},
5087
- ...finalizeResult.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
5117
+ ...delivery
5088
5118
  }, 201);
5089
5119
  });
5090
5120
  router.put("/users/:uid", requireAdmin, async (c) => {
@@ -7076,7 +7106,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
7076
7106
  hasAsymmetricSigningKey: () => hasAsymmetricSigningKey,
7077
7107
  hashPassword: () => hashPassword,
7078
7108
  hashRefreshToken: () => hashRefreshToken,
7079
- hashToken: () => hashToken,
7109
+ hashToken: () => hashToken$1,
7080
7110
  httpMethodToOperation: () => httpMethodToOperation,
7081
7111
  isApiKeyToken: () => isApiKeyToken,
7082
7112
  isJwtConfigured: () => isJwtConfigured,
@@ -7105,6 +7135,6 @@ var auth_exports = /* @__PURE__ */ __exportAll({
7105
7135
  verifyPassword: () => verifyPassword
7106
7136
  });
7107
7137
  //#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 };
7138
+ 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
7139
 
7110
- //# sourceMappingURL=auth-gzoRjnuh.js.map
7140
+ //# sourceMappingURL=auth-BvBllZjy.js.map