@rebasepro/server 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/README.md +1 -1
  2. package/bin/rebase-server.js +4 -2
  3. package/dist/{GCSStorageController-CjrA4PMo.js → GCSStorageController-BSiP1c-f.js} +22 -8
  4. package/dist/GCSStorageController-BSiP1c-f.js.map +1 -0
  5. package/dist/{S3StorageController-B6pKDNVj.js → S3StorageController-CAwFRgjV.js} +19 -7
  6. package/dist/S3StorageController-CAwFRgjV.js.map +1 -0
  7. package/dist/api/ast-schema-editor.d.ts +92 -1
  8. package/dist/api/errors.d.ts +9 -0
  9. package/dist/api/live-schema-routes.d.ts +38 -8
  10. package/dist/api/logs-routes.d.ts +39 -1
  11. package/dist/api/openapi-generator.d.ts +17 -0
  12. package/dist/api/rest/api-generator.d.ts +44 -10
  13. package/dist/api/rest/write-validation.d.ts +2 -2
  14. package/dist/api/types.d.ts +17 -1
  15. package/dist/{ast-schema-editor-Mvr50v_S.js → ast-schema-editor-CWqS_sLJ.js} +309 -11
  16. package/dist/ast-schema-editor-CWqS_sLJ.js.map +1 -0
  17. package/dist/auth/access.d.ts +105 -0
  18. package/dist/auth/adapter-middleware.d.ts +2 -1
  19. package/dist/auth/address-ownership.d.ts +16 -1
  20. package/dist/auth/admin-roles-route.d.ts +4 -2
  21. package/dist/auth/admin-roles.d.ts +17 -20
  22. package/dist/auth/admin-users-route.d.ts +1 -0
  23. package/dist/auth/api-keys/api-key-middleware.d.ts +56 -55
  24. package/dist/auth/api-keys/api-key-routes.d.ts +41 -11
  25. package/dist/auth/api-keys/api-key-store.d.ts +31 -8
  26. package/dist/auth/api-keys/api-key-types.d.ts +14 -16
  27. package/dist/auth/api-keys/http-operation.d.ts +19 -0
  28. package/dist/auth/api-keys/index.d.ts +11 -11
  29. package/dist/auth/api-keys/key-grant.d.ts +41 -0
  30. package/dist/auth/api-keys/legacy-permissions.d.ts +33 -0
  31. package/dist/auth/auth-hooks.d.ts +46 -7
  32. package/dist/auth/builtin-auth-adapter.d.ts +8 -0
  33. package/dist/auth/cookie-utils.d.ts +7 -0
  34. package/dist/auth/deliverable-address.d.ts +6 -0
  35. package/dist/auth/email-change-routes.d.ts +41 -0
  36. package/dist/auth/expired-token-sweep.d.ts +67 -0
  37. package/dist/auth/impersonation.d.ts +110 -0
  38. package/dist/auth/index.d.ts +4 -2
  39. package/dist/auth/interfaces.d.ts +110 -59
  40. package/dist/auth/jwt.d.ts +49 -3
  41. package/dist/auth/magic-link-routes.d.ts +2 -6
  42. package/dist/auth/mfa-routes.d.ts +2 -9
  43. package/dist/auth/middleware.d.ts +17 -5
  44. package/dist/auth/otp-routes.d.ts +2 -6
  45. package/dist/auth/passwordless-signup.d.ts +27 -0
  46. package/dist/auth/platform-token.d.ts +122 -0
  47. package/dist/auth/rate-limiter.d.ts +41 -0
  48. package/dist/auth/routes.d.ts +45 -0
  49. package/dist/auth/scope-routes.d.ts +22 -0
  50. package/dist/auth/session-routes.d.ts +11 -6
  51. package/dist/auth/token-revocation.d.ts +50 -1
  52. package/dist/auth/verify-credential.d.ts +28 -0
  53. package/dist/{auth-B-GIMpDG.js → auth-DMLngxn_.js} +2159 -569
  54. package/dist/auth-DMLngxn_.js.map +1 -0
  55. package/dist/backend-DTAOsLQc.js.map +1 -1
  56. package/dist/backup/backup-common.d.ts +10 -0
  57. package/dist/backup/backup-routes.d.ts +24 -4
  58. package/dist/backup/backup-schedule.d.ts +33 -0
  59. package/dist/backup/backup-storage.d.ts +14 -0
  60. package/dist/backup/index.d.ts +2 -0
  61. package/dist/backup-CN0s50D2.js +444 -0
  62. package/dist/backup-CN0s50D2.js.map +1 -0
  63. package/dist/boot/bundle.d.ts +19 -0
  64. package/dist/boot/env.d.ts +49 -4
  65. package/dist/boot/security-headers.d.ts +26 -0
  66. package/dist/boot/static-routing.d.ts +56 -0
  67. package/dist/collection_patch-BRu-BvDv.js +472 -0
  68. package/dist/collection_patch-BRu-BvDv.js.map +1 -0
  69. package/dist/{contract-routes-CbFjuBwa.js → contract-routes-fz8i4pxs.js} +17 -4
  70. package/dist/contract-routes-fz8i4pxs.js.map +1 -0
  71. package/dist/cron/cron-scheduler.d.ts +25 -20
  72. package/dist/cron/cron-store.d.ts +6 -2
  73. package/dist/{cron-loader-DfTj2Hbi.js → cron-loader-CwaANlOG.js} +4 -4
  74. package/dist/cron-loader-CwaANlOG.js.map +1 -0
  75. package/dist/{cron-routes-eE8nif_b.js → cron-routes-Bc-SB0Se.js} +10 -7
  76. package/dist/cron-routes-Bc-SB0Se.js.map +1 -0
  77. package/dist/{cron-scheduler-B0pLfAix.js → cron-scheduler-CYQgco86.js} +52 -34
  78. package/dist/cron-scheduler-CYQgco86.js.map +1 -0
  79. package/dist/{cron-store-TcoGz-xS.js → cron-store-D2Q9-Aco.js} +10 -15
  80. package/dist/cron-store-D2Q9-Aco.js.map +1 -0
  81. package/dist/{ddl-bootstrap-C6mo0Kmz.js → ddl-bootstrap-BaqMSa4Y.js} +2 -2
  82. package/dist/{ddl-bootstrap-C6mo0Kmz.js.map → ddl-bootstrap-BaqMSa4Y.js.map} +1 -1
  83. package/dist/email/index.d.ts +2 -2
  84. package/dist/email/templates.d.ts +22 -0
  85. package/dist/email/types.d.ts +26 -0
  86. package/dist/env.d.ts +1 -2
  87. package/dist/{errors-DWsX4yTd.js → errors-D6_y86c5.js} +102 -8
  88. package/dist/errors-D6_y86c5.js.map +1 -0
  89. package/dist/{function-loader-xnbDAPfa.js → function-loader-D7o5Epjj.js} +2 -2
  90. package/dist/{function-loader-xnbDAPfa.js.map → function-loader-D7o5Epjj.js.map} +1 -1
  91. package/dist/{function-routes-Chet4-lB.js → function-routes-CaNG4waN.js} +24 -12
  92. package/dist/function-routes-CaNG4waN.js.map +1 -0
  93. package/dist/functions/context.d.ts +17 -6
  94. package/dist/functions/guards.d.ts +22 -5
  95. package/dist/functions/index.d.ts +2 -2
  96. package/dist/functions/index.js +90 -36
  97. package/dist/functions/index.js.map +1 -1
  98. package/dist/{history-recorder-B4MpJfJK.js → history-recorder-Nr8zLvoU.js} +4 -4
  99. package/dist/{history-recorder-B4MpJfJK.js.map → history-recorder-Nr8zLvoU.js.map} +1 -1
  100. package/dist/{history-store-BhxWOuz9.js → history-store-rcAm_xFR.js} +2 -2
  101. package/dist/{history-store-BhxWOuz9.js.map → history-store-rcAm_xFR.js.map} +1 -1
  102. package/dist/index.d.ts +8 -2
  103. package/dist/index.es.js +3084 -753
  104. package/dist/index.es.js.map +1 -1
  105. package/dist/init/health.d.ts +17 -2
  106. package/dist/init/shutdown.d.ts +10 -0
  107. package/dist/init.d.ts +54 -0
  108. package/dist/{jobs-CazMYhyy.js → jobs-DqYNfquG.js} +5 -5
  109. package/dist/{jobs-CazMYhyy.js.map → jobs-DqYNfquG.js.map} +1 -1
  110. package/dist/{jwt-DnQHNFCl.js → jwt-R6bSPMjk.js} +39 -15
  111. package/dist/{jwt-DnQHNFCl.js.map → jwt-R6bSPMjk.js.map} +1 -1
  112. package/dist/{keys-CogCQpxG.js → keys-GAVZqbqx.js} +3 -17
  113. package/dist/{keys-CogCQpxG.js.map → keys-GAVZqbqx.js.map} +1 -1
  114. package/dist/{logger-DO2PZc4i.js → logger-D-S-hO5e.js} +26 -3
  115. package/dist/logger-D-S-hO5e.js.map +1 -0
  116. package/dist/{logs-routes-Bj4TYYUl.js → logs-routes-DAdv37GI.js} +48 -8
  117. package/dist/logs-routes-DAdv37GI.js.map +1 -0
  118. package/dist/mcp/consent-page.d.ts +1 -1
  119. package/dist/mcp/mcp-routes.d.ts +7 -0
  120. package/dist/mcp/mcp-tools.d.ts +15 -9
  121. package/dist/mcp/oauth-metadata.d.ts +21 -16
  122. package/dist/mcp/oauth-routes.d.ts +7 -1
  123. package/dist/{openapi-generator-O_O24MAT.js → openapi-generator-DAq_XVDu.js} +104 -13
  124. package/dist/openapi-generator-DAq_XVDu.js.map +1 -0
  125. package/dist/{proxy-Czngl3p9.js → proxy-qRlqeUmO.js} +2 -2
  126. package/dist/{proxy-Czngl3p9.js.map → proxy-qRlqeUmO.js.map} +1 -1
  127. package/dist/{query-parser-DGRVFNM3.js → query-parser-BgiKJKvc.js} +6 -56
  128. package/dist/query-parser-BgiKJKvc.js.map +1 -0
  129. package/dist/{request-timeout-C_4C2BeR.js → request-timeout-DgH7j8qO.js} +3 -3
  130. package/dist/{request-timeout-C_4C2BeR.js.map → request-timeout-DgH7j8qO.js.map} +1 -1
  131. package/dist/schema-edit/apply-schema-change.d.ts +63 -3
  132. package/dist/schema-edit/project-root.d.ts +3 -2
  133. package/dist/schema-edit/remote-source.d.ts +9 -4
  134. package/dist/{schema-editor-routes-C5-lh_jO.js → schema-editor-routes-oIyuWl3L.js} +12 -7
  135. package/dist/schema-editor-routes-oIyuWl3L.js.map +1 -0
  136. package/dist/serve-spa.d.ts +58 -0
  137. package/dist/services/routed-realtime-service.d.ts +11 -0
  138. package/dist/soft-delete-params-BWPilMPF.js +59 -0
  139. package/dist/soft-delete-params-BWPilMPF.js.map +1 -0
  140. package/dist/{src-vkcwKXbT.js → src-CatHFUym.js} +439 -20
  141. package/dist/src-CatHFUym.js.map +1 -0
  142. package/dist/{src-pmvW7BFx.js → src-I3aG1PcY.js} +252 -70
  143. package/dist/src-I3aG1PcY.js.map +1 -0
  144. package/dist/storage/GCSStorageController.d.ts +2 -0
  145. package/dist/storage/LocalStorageController.d.ts +2 -0
  146. package/dist/storage/S3StorageController.d.ts +2 -0
  147. package/dist/storage/index.d.ts +2 -2
  148. package/dist/storage/property-limits.d.ts +41 -6
  149. package/dist/storage/request-keys.d.ts +15 -0
  150. package/dist/storage/requested-object.d.ts +74 -0
  151. package/dist/storage/routes.d.ts +36 -18
  152. package/dist/storage/tus-handler.d.ts +30 -5
  153. package/dist/storage/types.d.ts +19 -0
  154. package/dist/types-BfKcm9do.js.map +1 -1
  155. package/dist/utils/logger.d.ts +12 -0
  156. package/package.json +5 -5
  157. package/dist/GCSStorageController-CjrA4PMo.js.map +0 -1
  158. package/dist/S3StorageController-B6pKDNVj.js.map +0 -1
  159. package/dist/admin-roles-vYdp_Pil.js +0 -36
  160. package/dist/admin-roles-vYdp_Pil.js.map +0 -1
  161. package/dist/admin_block-DxKLmdiv.js +0 -206
  162. package/dist/admin_block-DxKLmdiv.js.map +0 -1
  163. package/dist/ast-schema-editor-Mvr50v_S.js.map +0 -1
  164. package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -65
  165. package/dist/auth-B-GIMpDG.js.map +0 -1
  166. package/dist/backup-D7YR94N3.js +0 -253
  167. package/dist/backup-D7YR94N3.js.map +0 -1
  168. package/dist/contract-routes-CbFjuBwa.js.map +0 -1
  169. package/dist/cron-loader-DfTj2Hbi.js.map +0 -1
  170. package/dist/cron-routes-eE8nif_b.js.map +0 -1
  171. package/dist/cron-scheduler-B0pLfAix.js.map +0 -1
  172. package/dist/cron-store-TcoGz-xS.js.map +0 -1
  173. package/dist/errors-DWsX4yTd.js.map +0 -1
  174. package/dist/function-routes-Chet4-lB.js.map +0 -1
  175. package/dist/logger-DO2PZc4i.js.map +0 -1
  176. package/dist/logs-routes-Bj4TYYUl.js.map +0 -1
  177. package/dist/openapi-generator-O_O24MAT.js.map +0 -1
  178. package/dist/query-parser-DGRVFNM3.js.map +0 -1
  179. package/dist/schema-editor-routes-C5-lh_jO.js.map +0 -1
  180. package/dist/src-pmvW7BFx.js.map +0 -1
  181. package/dist/src-vkcwKXbT.js.map +0 -1
@@ -3,17 +3,16 @@ 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 { F as revokeInternalTableSql, ct as ANONYMOUS_USER_ID, lt as isAnonymousUid } from "./src-pmvW7BFx.js";
7
- import "./src-vkcwKXbT.js";
6
+ import { P as revokeInternalTableSql, ct as isAnonymousUid, st as ANONYMOUS_USER_ID } from "./src-I3aG1PcY.js";
7
+ import { _ as scopesForRoles, a as KEY_MANAGEMENT_SCOPES, b as validateScopes, c as intersectScopes, d as isDataPlaneScope, f as knownScopes, g as scopesBeyond, h as scopeGrantsAny, i as EMPTY_ACCESS_MODEL, l as isAdminScope, m as scopeGrants, n as ADMIN_SCOPES, o as SCOPE_TARGETS, p as parseScope, r as BUILT_IN_SCOPES, s as hasAdminRole, t as ADMIN_ROLE, u as isBuiltInScope, v as summarizeRoles } from "./src-CatHFUym.js";
8
8
  import { t as isSQLAdmin } from "./backend-DTAOsLQc.js";
9
9
  import { t as firstSqlRow } from "./sql-rows-C6GEc2oE.js";
10
- import { r as logger } from "./logger-DO2PZc4i.js";
11
- import { r as errorHandler, t as ApiError } from "./errors-DWsX4yTd.js";
12
- import { a as resolveListLimitParam } from "./query-parser-DGRVFNM3.js";
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-DnQHNFCl.js";
14
- import { n as hasAdministrativeRole, r as isAdministrativeRole, t as ADMINISTRATIVE_ROLES } from "./admin-roles-vYdp_Pil.js";
15
- import { a as canonicalStorageId, l as tryCanonicalStorageKey } from "./keys-CogCQpxG.js";
16
- import { n as createDdlBootstrapper } from "./ddl-bootstrap-C6mo0Kmz.js";
10
+ import { r as logger } from "./logger-D-S-hO5e.js";
11
+ import { i as isDataException, r as errorHandler, t as ApiError } from "./errors-D6_y86c5.js";
12
+ import { a as resolveListLimitParam } from "./query-parser-BgiKJKvc.js";
13
+ import { D as constantTimeEqual, E as require_jsonwebtoken, S as verifyMfaPendingToken, a as generateAccessToken, b as verifyDownloadToken, c as generateMfaPendingToken, d as getJwks, f as getRefreshTokenExpiry, g as isJwtConfigured, h as hashRefreshToken, i as customClaimsOf, j as sha256Hex, k as randomInt$1, l as generateRefreshToken, m as hasAsymmetricSigningKey, n as configureJwt, o as generateDownloadToken, p as getRefreshTokenTtlMs, r as configuredJwtSecret, t as MAX_COOKIE_AGE_MS, u as getAccessTokenExpiry, y as verifyAccessToken } from "./jwt-R6bSPMjk.js";
14
+ import { a as canonicalStorageId, o as canonicalStorageKey, r as InvalidStorageKeyError } from "./keys-GAVZqbqx.js";
15
+ import { n as createDdlBootstrapper } from "./ddl-bootstrap-BaqMSa4Y.js";
17
16
  import { createHash, randomBytes, randomInt } from "node:crypto";
18
17
  import { Hono } from "hono";
19
18
  import { promisify } from "util";
@@ -21,6 +20,20 @@ import { createCipheriv, createDecipheriv, createHash as createHash$1, createHma
21
20
  import { getConnInfo } from "@hono/node-server/conninfo";
22
21
  import { createMiddleware } from "hono/factory";
23
22
  import { z } from "zod";
23
+ //#region ../types/src/types/auth_adapter.ts
24
+ /**
25
+ * The request header that asks for a data-plane request to run as another
26
+ * user, named by uid.
27
+ *
28
+ * The server honours it only on a signed-in administrator's own session —
29
+ * never on an API key or the service key — and only through an adapter that
30
+ * implements {@link AuthAdapter.resolveUser}. Everywhere else a request that
31
+ * carries it is refused, never run as its caller.
32
+ *
33
+ * @group Auth
34
+ */
35
+ var IMPERSONATE_HEADER = "x-rebase-impersonate";
36
+ //#endregion
24
37
  //#region ../types/src/controllers/storage.ts
25
38
  /**
26
39
  * Path prefix that marks an object as **public**. Files stored under this
@@ -32,9 +45,33 @@ import { z } from "zod";
32
45
  */
33
46
  var PUBLIC_STORAGE_PREFIX = "public/";
34
47
  /**
35
- * True when a storage key/path points at a public object (lives under
36
- * {@link PUBLIC_STORAGE_PREFIX}). The check is applied to the key *within the
37
- * bucket* — strip any `bucket/` and `scheme://` prefixes first.
48
+ * True when a storage **key** is public: it lies under
49
+ * {@link PUBLIC_STORAGE_PREFIX}.
50
+ *
51
+ * The key is read exactly as it is — the canonical key within its bucket, the
52
+ * one the server stores, serves, and hands a `storageAuthorize` hook as
53
+ * `key` — and nothing is parsed out of it. That is the difference from
54
+ * {@link isPublicStoragePath}, which reads a client-side *path* and so strips
55
+ * a `scheme://` and a leading `default/` bucket segment first. On a key both are
56
+ * wrong: `default/public/x` is a key in a folder called `default`, and a
57
+ * request path `notes://public/x` names the key `notes:/public/x`. Neither is
58
+ * public, and the server decides on this function so that it never says they
59
+ * are.
60
+ *
61
+ * @group Models
62
+ */
63
+ function isPublicStorageKey(key) {
64
+ if (!key || !key.startsWith("public/")) return false;
65
+ return !key.split(/[\\/]/).includes("..");
66
+ }
67
+ /**
68
+ * True when a storage key or client-side path points at a public object
69
+ * (lives under {@link PUBLIC_STORAGE_PREFIX}).
70
+ *
71
+ * For a path as a client holds it: a `scheme://` prefix and a single leading
72
+ * `default/` bucket segment are stripped first. The server and a
73
+ * `storageAuthorize` hook, which hold the canonical key, use
74
+ * {@link isPublicStorageKey}.
38
75
  *
39
76
  * @group Models
40
77
  */
@@ -47,6 +84,57 @@ function isPublicStoragePath(path) {
47
84
  if (p.split("/").some((seg) => seg === "..")) return false;
48
85
  return p.startsWith("public/") || p.startsWith(`default/public/`);
49
86
  }
87
+ /**
88
+ * The path the storage routes address an object by, from a key or from the
89
+ * `storageUrl` an upload returned (`<scheme>://<bucket>/<key>`, which is what a
90
+ * property with `includeBucketUrl: true` keeps in its column).
91
+ *
92
+ * The routes address a source's objects by key. On S3 and GCS the bucket in a
93
+ * `storageUrl` is always the source's own — the server refuses any other — so
94
+ * it is dropped: sent along, `s3://acme-media/products/a.png` reached the
95
+ * server as the key `acme-media/products/a.png`, the preview found nothing and
96
+ * a delete reported success while the object stayed. It worked on local disk
97
+ * only, because `local://default/<key>` happens to carry the one bucket
98
+ * segment the routes recognise — which is kept, as is any other local bucket.
99
+ *
100
+ * A bare key whose first folder is `default` is sent behind that bucket
101
+ * segment. The routes read one leading `default/` (in any case) as the bucket,
102
+ * so the key `default/photo.png` sent as it is names the root `photo.png`:
103
+ * {@link defaultBucketPath} is the spelling that names the key itself.
104
+ *
105
+ * Shared by every storage source that talks to those routes — the SDK's and
106
+ * the admin panel's `useBackendStorageSource` — so they address an object alike.
107
+ *
108
+ * @group Models
109
+ */
110
+ function storageObjectPath(keyOrUrl, bucket) {
111
+ let filePath = keyOrUrl;
112
+ let bucketQualified = false;
113
+ const scheme = /^(local|s3|gs):\/\//.exec(filePath);
114
+ if (scheme) {
115
+ filePath = filePath.substring(scheme[0].length);
116
+ if (scheme[1] !== "local") {
117
+ const slash = filePath.indexOf("/");
118
+ if (slash > 0) filePath = filePath.substring(slash + 1);
119
+ } else bucketQualified = true;
120
+ }
121
+ if (bucket && filePath) {
122
+ if (!filePath.startsWith(`${bucket}/`)) filePath = `${bucket}/${filePath}`;
123
+ bucketQualified = true;
124
+ }
125
+ return bucketQualified ? filePath : defaultBucketPath(filePath);
126
+ }
127
+ /**
128
+ * The request path of a key in the default bucket: the key itself, unless the
129
+ * routes would read its first folder as the bucket — a first folder named
130
+ * `default`, matched as the routes match it (case-insensitively, and only when
131
+ * something follows it). Then the bucket segment goes in front, so exactly the
132
+ * one segment the routes strip is the one added.
133
+ */
134
+ function defaultBucketPath(key) {
135
+ const segments = key.split("/");
136
+ return segments.length > 1 && segments[0].toLowerCase() === "default" ? `default/${key}` : key;
137
+ }
50
138
  //#endregion
51
139
  //#region ../common/src/util/email.ts
52
140
  /**
@@ -81,79 +169,26 @@ function normalizeEmail(email) {
81
169
  return typeof email === "string" ? email.trim().toLowerCase() : email;
82
170
  }
83
171
  //#endregion
84
- //#region src/auth/api-keys/api-key-permission-guard.ts
172
+ //#region src/auth/api-keys/http-operation.ts
85
173
  /**
86
- * Map an HTTP method string to an `ApiKeyOperation`.
174
+ * Map an HTTP method to the operation it performs.
87
175
  *
88
176
  * - `GET`, `HEAD`, `OPTIONS` → `"read"`
89
177
  * - `POST`, `PUT`, `PATCH` → `"write"`
90
178
  * - `DELETE` → `"delete"`
179
+ *
180
+ * Any other method is a `"write"`: a verb this does not know is not assumed
181
+ * to be harmless.
91
182
  */
92
183
  function httpMethodToOperation(method) {
93
184
  switch (method.toUpperCase()) {
94
185
  case "GET":
95
186
  case "HEAD":
96
187
  case "OPTIONS": return "read";
97
- case "POST":
98
- case "PUT":
99
- case "PATCH": return "write";
100
188
  case "DELETE": return "delete";
101
- default: return "read";
189
+ default: return "write";
102
190
  }
103
191
  }
104
- /**
105
- * Check whether the given permissions array allows `operation` on `collection`.
106
- *
107
- * Supports the `"*"` wildcard for the collection field, which matches any
108
- * collection. Returns `true` if at least one permission entry grants access.
109
- *
110
- * @param permissions - The API key's permission entries.
111
- * @param collection - The target collection slug.
112
- * @param operation - The requested operation.
113
- * @returns `true` if the operation is permitted.
114
- */
115
- function isOperationAllowed(permissions, collection, operation) {
116
- for (const perm of permissions) if ((perm.collection === "*" || perm.collection === collection) && perm.operations.includes(operation)) return true;
117
- return false;
118
- }
119
- /**
120
- * Check whether the given permissions array allows a storage operation.
121
- *
122
- * Storage, like functions, lives outside the collection namespace: an entry
123
- * with collection `"storage"` grants storage access for the listed
124
- * operations (GET routes → `read`, upload/folder/tus → `write`,
125
- * DELETE routes → `delete`), and the global `"*"` wildcard also matches.
126
- * A collection-scoped key gets no storage access at all.
127
- *
128
- * Matching is exactly {@link isOperationAllowed} with `"storage"` as the
129
- * resource name — delegated so the semantics can never drift.
130
- */
131
- function isStorageAllowed(permissions, operation) {
132
- return isOperationAllowed(permissions, "storage", operation);
133
- }
134
- /**
135
- * Check whether the given permissions array allows invoking a custom function.
136
- *
137
- * Functions live outside the collection namespace, so they have their own
138
- * resource names in the permission list:
139
- *
140
- * - `{ collection: "functions", ... }` grants every function
141
- * - `{ collection: "functions/<name>", ... }` grants a single function
142
- * - the global `"*"` wildcard (full-access keys) also matches
143
- *
144
- * The operation is derived from the HTTP method as usual (GET → read,
145
- * POST/PUT/PATCH → write, DELETE → delete). The functions index route is
146
- * checked as `functionName === ""`, which only the `"functions"` and `"*"`
147
- * entries can grant.
148
- *
149
- * A collection-scoped key (e.g. read-only on `events`) therefore can NOT
150
- * invoke functions — before this guard existed, any valid key could call
151
- * every function.
152
- */
153
- function isFunctionAllowed(permissions, functionName, operation) {
154
- for (const perm of permissions) if ((perm.collection === "*" || perm.collection === "functions" || functionName !== "" && perm.collection === `functions/${functionName}`) && perm.operations.includes(operation)) return true;
155
- return false;
156
- }
157
192
  //#endregion
158
193
  //#region src/email/html.ts
159
194
  /**
@@ -848,6 +883,138 @@ No action is needed.
848
883
  text
849
884
  };
850
885
  }
886
+ /**
887
+ * Default address-change confirmation, mailed to the NEW address.
888
+ *
889
+ * Names the account being moved — its current address — because the link
890
+ * makes the address the account's: whoever follows it hands their inbox to
891
+ * that account, and must be able to tell whether it is theirs.
892
+ */
893
+ function getEmailChangeTemplate(confirmUrl, user, newEmail, appName = DEFAULT_APP_NAME, logoUrl) {
894
+ const greeting = getGreeting(user);
895
+ const subject = `Confirm your new ${appName} email address`;
896
+ const body = html`
897
+ <!DOCTYPE html>
898
+ <html>
899
+ <head>
900
+ <meta charset="utf-8">
901
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
902
+ <title>${subject}</title>
903
+ </head>
904
+ <body style="margin: 0; padding: 0; background-color: #f8fafc;">
905
+ <div style="${styles.container}">${renderHeader(appName, logoUrl)}
906
+ <div style="${styles.card}">
907
+ <h1 style="${styles.heading}">Confirm Your New Email</h1>
908
+
909
+ <p style="${styles.paragraph}">
910
+ Hi ${greeting},
911
+ </p>
912
+
913
+ <p style="${styles.paragraph}">
914
+ The ${appName} account ${user.email} asked to use ${newEmail} from now on.
915
+ Confirm it is yours by clicking the button below:
916
+ </p>
917
+
918
+ <div style="text-align: center;">
919
+ <a href="${confirmUrl}" style="${styles.button}">Confirm New Email</a>
920
+ </div>
921
+
922
+ <p style="${styles.paragraph}">
923
+ Or copy and paste this link into your browser:
924
+ </p>
925
+ <p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
926
+ ${confirmUrl}
927
+ </p>
928
+
929
+ <div style="${styles.warning}">
930
+ ⏰ This link expires in 24 hours and can only be used once.
931
+ </div>
932
+
933
+ <div style="${styles.footer}">
934
+ <p style="margin: 0;">
935
+ If you did not ask for this, ignore this email: nothing changes until the link is followed.
936
+ </p>
937
+ </div>
938
+ </div>
939
+ </div>
940
+ </body>
941
+ </html>
942
+ `;
943
+ const text = `
944
+ Confirm Your New Email
945
+
946
+ Hi ${greeting},
947
+
948
+ The ${appName} account ${user.email} asked to use ${newEmail} from now on. Confirm it is yours by following this link:
949
+ ${confirmUrl}
950
+
951
+ This link expires in 24 hours and can only be used once.
952
+
953
+ If you did not ask for this, ignore this email: nothing changes until the link is followed.
954
+ `.trim();
955
+ return {
956
+ subject,
957
+ html: body.toString().trim(),
958
+ text
959
+ };
960
+ }
961
+ /**
962
+ * Default notice to the OLD address that a change was asked for. It carries
963
+ * no link: the old inbox can do nothing about the change except know of it,
964
+ * and a link here would be a second way to make it.
965
+ */
966
+ function getEmailChangeNoticeTemplate(user, newEmail, appName = DEFAULT_APP_NAME, logoUrl) {
967
+ const greeting = getGreeting(user);
968
+ const subject = `Your ${appName} email address is being changed`;
969
+ const body = html`
970
+ <!DOCTYPE html>
971
+ <html>
972
+ <head>
973
+ <meta charset="utf-8">
974
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
975
+ <title>${subject}</title>
976
+ </head>
977
+ <body style="margin: 0; padding: 0; background-color: #f8fafc;">
978
+ <div style="${styles.container}">${renderHeader(appName, logoUrl)}
979
+ <div style="${styles.card}">
980
+ <h1 style="${styles.heading}">Email Change Requested</h1>
981
+
982
+ <p style="${styles.paragraph}">
983
+ Hi ${greeting},
984
+ </p>
985
+
986
+ <p style="${styles.paragraph}">
987
+ Someone signed in to your ${appName} account asked to change its email address
988
+ from ${user.email} to ${newEmail}. The change happens only when the link we sent
989
+ to ${newEmail} is followed.
990
+ </p>
991
+
992
+ <div style="${styles.footer}">
993
+ <p style="margin: 0;">
994
+ If this was you, there is nothing to do here. If it was not, sign in, change your
995
+ password and sign out of every other session.
996
+ </p>
997
+ </div>
998
+ </div>
999
+ </div>
1000
+ </body>
1001
+ </html>
1002
+ `;
1003
+ const text = `
1004
+ Email Change Requested
1005
+
1006
+ Hi ${greeting},
1007
+
1008
+ Someone signed in to your ${appName} account asked to change its email address from ${user.email} to ${newEmail}. The change happens only when the link we sent to ${newEmail} is followed.
1009
+
1010
+ If this was you, there is nothing to do here. If it was not, sign in, change your password and sign out of every other session.
1011
+ `.trim();
1012
+ return {
1013
+ subject,
1014
+ html: body.toString().trim(),
1015
+ text
1016
+ };
1017
+ }
851
1018
  //#endregion
852
1019
  //#region src/email/link-base.ts
853
1020
  /**
@@ -968,7 +1135,7 @@ function generateSecureToken() {
968
1135
  /**
969
1136
  * Hash a token for database storage using SHA-256.
970
1137
  */
971
- function hashToken$1(token) {
1138
+ function hashToken(token) {
972
1139
  return createHash("sha256").update(token).digest("hex");
973
1140
  }
974
1141
  /**
@@ -1049,7 +1216,7 @@ async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
1049
1216
  if (!clearPassword) return { invitationSent: false };
1050
1217
  if (!!(ctx.emailService && ctx.emailService.isConfigured())) try {
1051
1218
  const token = generateSecureToken();
1052
- const tokenHash = hashToken$1(token);
1219
+ const tokenHash = hashToken(token);
1053
1220
  const expiresAt = new Date(Date.now() + USER_INVITATION_LINK_TTL_MS);
1054
1221
  await ctx.authRepo.createPasswordResetToken(entity.id, tokenHash, expiresAt);
1055
1222
  const setPasswordUrl = `${resolveEmailLinkBase(ctx.emailConfig, "resetPassword")}/reset-password?token=${token}`;
@@ -1247,7 +1414,75 @@ async function isAccessTokenRevoked(authRepo, payload) {
1247
1414
  });
1248
1415
  return false;
1249
1416
  }
1250
- if (!validAfter) return false;
1417
+ return issuedBefore(payload, validAfter);
1418
+ }
1419
+ /**
1420
+ * Is the account behind this verified access token still the one that may use
1421
+ * it — and with which roles?
1422
+ *
1423
+ * A verified signature says who the token was minted for, an hour ago at
1424
+ * most. This asks the database what has happened to that account since. Every
1425
+ * door that honours an access token — the data plane, the admin gates, the
1426
+ * realtime socket — asks it here, so they cannot disagree about it.
1427
+ *
1428
+ * They did. A deleted account read as "not revoked": the watermark lives on
1429
+ * the user row, so once the row was gone there was no watermark, and the
1430
+ * roles lookup answered `[]` rather than "nobody". A token its owner had
1431
+ * revoked came back to life when an administrator deleted the account, as an
1432
+ * authenticated principal with that uid, for the rest of its lifetime. MCP
1433
+ * refresh and personal API keys already treated a missing account as revoked;
1434
+ * the main door did not.
1435
+ *
1436
+ * Throws when the repository does. Unlike the watermark read on its own, this
1437
+ * one decides the roles a request runs with, so a failure is a refusal (the
1438
+ * callers answer 503) rather than a guess.
1439
+ */
1440
+ async function judgeAccessToken(authRepo, payload) {
1441
+ let roles;
1442
+ let validAfter = null;
1443
+ if (typeof authRepo.getAccountAccessState === "function") {
1444
+ const state = await authRepo.getAccountAccessState(payload.uid, payload.sid);
1445
+ if (!state) return {
1446
+ live: false,
1447
+ refusal: "account-deleted"
1448
+ };
1449
+ if (state.disabled) return {
1450
+ live: false,
1451
+ refusal: "account-disabled"
1452
+ };
1453
+ roles = state.roles;
1454
+ validAfter = state.tokensValidAfter;
1455
+ if (payload.sid && state.sessionActive === false) return {
1456
+ live: false,
1457
+ refusal: "session-revoked"
1458
+ };
1459
+ } else {
1460
+ if (typeof authRepo.getUserWithRoles === "function") {
1461
+ const account = await authRepo.getUserWithRoles(payload.uid);
1462
+ if (!account) return {
1463
+ live: false,
1464
+ refusal: "account-deleted"
1465
+ };
1466
+ if (account.user.disabled) return {
1467
+ live: false,
1468
+ refusal: "account-disabled"
1469
+ };
1470
+ roles = account.roles;
1471
+ }
1472
+ if (typeof authRepo.getTokensValidAfter === "function") validAfter = await authRepo.getTokensValidAfter(payload.uid);
1473
+ }
1474
+ if (issuedBefore(payload, validAfter)) return {
1475
+ live: false,
1476
+ refusal: "revoked"
1477
+ };
1478
+ return {
1479
+ live: true,
1480
+ roles
1481
+ };
1482
+ }
1483
+ /** The watermark comparison {@link isAccessTokenRevoked} and the judge share. */
1484
+ function issuedBefore(payload, validAfter) {
1485
+ if (!validAfter || typeof payload.iat !== "number") return false;
1251
1486
  return payload.iat < Math.floor(validAfter.getTime() / 1e3);
1252
1487
  }
1253
1488
  /**
@@ -1298,6 +1533,7 @@ async function revokeAllSessions(authRepo, uid) {
1298
1533
  async function replaceUserPassword(authRepo, uid, passwordHash) {
1299
1534
  await authRepo.updatePassword(uid, passwordHash);
1300
1535
  await revokeAllSessions(authRepo, uid);
1536
+ await authRepo.deleteAllPasswordResetTokensForUser?.(uid);
1301
1537
  }
1302
1538
  //#endregion
1303
1539
  //#region src/auth/rls-scope.ts
@@ -1425,59 +1661,221 @@ function extractBearerToken(authHeader) {
1425
1661
  return authHeader.slice(separator + 1);
1426
1662
  }
1427
1663
  //#endregion
1428
- //#region src/auth/api-keys/api-key-middleware.ts
1664
+ //#region src/auth/access.ts
1665
+ var configured = EMPTY_ACCESS_MODEL;
1666
+ var keyOwners;
1667
+ /** The access model the running backend serves. Built-ins only until boot configures it. */
1668
+ function getAccessModel() {
1669
+ return configured;
1670
+ }
1429
1671
  /**
1430
- * Check whether a token looks like a Rebase API key.
1672
+ * How a personal key finds its owner, or undefined when personal keys are
1673
+ * off — in which case every personal key, however valid, is refused.
1431
1674
  */
1432
- function isApiKeyToken(token) {
1433
- return token.startsWith("rk_");
1675
+ function getKeyOwnerResolver() {
1676
+ return keyOwners;
1677
+ }
1678
+ /**
1679
+ * Install the access model, and — when the app enables `auth.personalKeys`
1680
+ * and has an account store to read owners from — how personal keys find
1681
+ * their owner. Called once by `initializeRebaseBackend`.
1682
+ */
1683
+ function configureAccess(options) {
1684
+ configured = options.model;
1685
+ keyOwners = options.resolveKeyOwner;
1686
+ }
1687
+ /** The resource names Rebase's own scopes use; an app scope may not reuse one. */
1688
+ var BUILT_IN_RESOURCES = new Set(BUILT_IN_SCOPES.map((scope) => scope.slice(0, scope.indexOf(":"))));
1689
+ /** A declaration the access model cannot be built from. Fails the boot. */
1690
+ var AccessModelError = class extends Error {
1691
+ constructor(message) {
1692
+ super(message);
1693
+ this.name = "AccessModelError";
1694
+ }
1695
+ };
1696
+ /**
1697
+ * Build the access model from the users collection's `auth` block, refusing
1698
+ * anything that would read as a grant it is not.
1699
+ *
1700
+ * - `admin` may not be declared: it is built in and holds everything, and a
1701
+ * declaration would either repeat that or quietly narrow it.
1702
+ * - An app scope may not reuse a built-in resource name (`logs:export`): the
1703
+ * built-in resources are Rebase's, and a reader of `logs:*` should not have
1704
+ * to know which half is whose.
1705
+ * - A role may not list a data-plane scope: a person's data access is the
1706
+ * collection's `securityRules`, so `data:write` on a role would grant nothing
1707
+ * and look like it granted something.
1708
+ */
1709
+ function accessModelFromCollections(collections) {
1710
+ const authCollection = collections.find((collection) => {
1711
+ const auth = collection.auth;
1712
+ return auth === true || !!auth && typeof auth === "object" && auth.enabled === true;
1713
+ });
1714
+ const auth = authCollection && typeof authCollection.auth === "object" ? authCollection.auth : void 0;
1715
+ const declaredScopes = { ...auth?.scopes ?? {} };
1716
+ const declaredRoles = { ...auth?.roles ?? {} };
1717
+ const where = `the "${authCollection?.slug ?? "users"}" collection's auth block`;
1718
+ for (const [name, definition] of Object.entries(declaredScopes)) {
1719
+ const parsed = parseScope(name);
1720
+ if (!parsed || parsed.target !== void 0) throw new AccessModelError(`auth.scopes: "${name}" in ${where} is not a scope name. Name scopes resource:action, lower-case, without a target — for example "project:deploy".`);
1721
+ const resource = name.slice(0, name.indexOf(":"));
1722
+ if (BUILT_IN_RESOURCES.has(resource)) throw new AccessModelError(`auth.scopes: "${name}" in ${where} uses the built-in resource "${resource}". App scopes need their own resource name; the built-in ones are ${[...BUILT_IN_RESOURCES].join(", ")}.`);
1723
+ if (typeof definition?.label !== "string" || definition.label.trim() === "") throw new AccessModelError(`auth.scopes: "${name}" in ${where} needs a label — it is what a person granting it reads.`);
1724
+ }
1725
+ const model = {
1726
+ roles: declaredRoles,
1727
+ scopes: declaredScopes
1728
+ };
1729
+ for (const [role, definition] of Object.entries(declaredRoles)) {
1730
+ if (role === "admin") throw new AccessModelError(`auth.roles: "admin" in ${where} is built in — it holds every scope — and cannot be declared. Declare a role with another name for a narrower set.`);
1731
+ if (!Array.isArray(definition?.scopes)) throw new AccessModelError(`auth.roles: "${role}" in ${where} needs a scopes list.`);
1732
+ const dataPlane = definition.scopes.filter((scope) => isDataPlaneScope(parseScope(scope)?.scope ?? ""));
1733
+ if (dataPlane.length > 0) throw new AccessModelError(`auth.roles: "${role}" in ${where} lists ${dataPlane.join(", ")}. Every signed-in person already holds the data plane; what they may do with rows is the collection's securityRules. List admin-plane and app scopes only.`);
1734
+ const problems = validateScopes(definition.scopes, model);
1735
+ if (problems.length > 0) throw new AccessModelError(`auth.roles: "${role}" in ${where} lists ${problems.map((p) => `"${p.scope}" (${p.reason})`).join(", ")}. Role scopes are ${ADMIN_SCOPES.join(", ")} and the app's own auth.scopes.`);
1736
+ }
1737
+ return model;
1738
+ }
1739
+ /** Narrow a context's user to the roles it carries. */
1740
+ function rolesOf(user) {
1741
+ if (typeof user !== "object" || user === null || !("roles" in user)) return [];
1742
+ const roles = user.roles;
1743
+ return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
1434
1744
  }
1435
1745
  /**
1436
- * Hash a plaintext API key token for database lookup.
1746
+ * Everything the caller of this request may do. Empty for a request with no
1747
+ * caller.
1748
+ */
1749
+ function callerScopes(c) {
1750
+ const narrowed = c.get("scopes");
1751
+ if (narrowed) return narrowed;
1752
+ const user = c.get("user");
1753
+ if (!user) return [];
1754
+ return scopesForRoles(rolesOf(user), configured);
1755
+ }
1756
+ /** Does this request's caller hold `scope`, on `target` when one is given? */
1757
+ function hasScope(c, scope, target) {
1758
+ return scopeGrants(callerScopes(c), scope, target);
1759
+ }
1760
+ /**
1761
+ * The refusal for a missing scope, worded for whoever is holding the
1762
+ * credential. `details.requiredScope` names it for clients that branch.
1763
+ */
1764
+ function scopeMissing(c, scope, target) {
1765
+ const wanted = target !== void 0 ? `${scope}:${target}` : scope;
1766
+ return new ApiError(403, "SCOPE_MISSING", c.get("apiKey") !== void 0 ? `This API key does not hold the "${wanted}" scope. Create a key that includes it.` : c.get("scopes") ? `This token does not hold the "${wanted}" scope. Reconnect and grant it.` : `Your account does not hold the "${wanted}" scope. A role that lists it (auth.roles on the users collection) grants it; the admin role holds every scope.`, { requiredScope: wanted });
1767
+ }
1768
+ /**
1769
+ * Refuse a request whose caller does not hold `scope`.
1770
+ *
1771
+ * `target` narrows the check to one collection, bucket, function or app
1772
+ * resource, and may be read from the request. Mount after authentication: a
1773
+ * request with no caller is refused as unauthenticated, not as unscoped.
1437
1774
  *
1438
- * Async because it hashes with WebCrypto rather than `node:crypto` — the sole
1439
- * caller was already awaiting the store lookup this feeds.
1775
+ * @example
1776
+ * ```ts
1777
+ * app.post("/deploy/:project", requireScope("project:deploy", c => c.req.param("project")), handler);
1778
+ * ```
1440
1779
  */
1441
- function hashToken(token) {
1442
- return sha256Hex(token);
1780
+ function requireScope(scope, target) {
1781
+ return async (c, next) => {
1782
+ if (!c.get("user")) return refuse(c, ApiError.unauthenticated("Authentication required"));
1783
+ const resolvedTarget = typeof target === "function" ? target(c) : target;
1784
+ if (!hasScope(c, scope, resolvedTarget)) return refuse(c, scopeMissing(c, scope, resolvedTarget));
1785
+ return next();
1786
+ };
1443
1787
  }
1444
1788
  /**
1445
- * Validate an API key token and populate the Hono context.
1789
+ * Refuse a request unless its caller holds the scope for its HTTP method:
1790
+ * `read` for GET and HEAD, `write` for everything else. For an admin surface
1791
+ * whose reads and writes split exactly along the method.
1792
+ */
1793
+ function requireScopeByMethod(scopes) {
1794
+ const read = requireScope(scopes.read);
1795
+ const write = requireScope(scopes.write);
1796
+ return async (c, next) => {
1797
+ const method = c.req.method.toUpperCase();
1798
+ return (method === "GET" || method === "HEAD" ? read : write)(c, next);
1799
+ };
1800
+ }
1801
+ /**
1802
+ * What a set of roles holds beyond this request's caller — the scopes, plus
1803
+ * `admin` itself when the roles include it and the caller does not hold it.
1804
+ * Empty when the caller covers every one.
1446
1805
  *
1447
- * Returns `true` if the key is valid and context has been populated,
1448
- * or returns an error Response if the key is invalid.
1806
+ * `admin` is named separately because it is more than its scopes: it is the
1807
+ * RLS role that reads every row, so a role listing every admin-plane scope
1808
+ * still cannot hand it out.
1809
+ */
1810
+ function beyondCaller(c, roles) {
1811
+ const beyond = scopesBeyond(scopesForRoles(roles, configured), callerScopes(c));
1812
+ if (hasAdminRole(roles) && !hasAdminRole(rolesOf(c.get("user")))) beyond.unshift(ADMIN_ROLE);
1813
+ return beyond;
1814
+ }
1815
+ /**
1816
+ * Refuse to let the caller act on an account — edit it, reset its password,
1817
+ * delete it — when that account holds anything the caller does not.
1449
1818
  *
1450
- * This is NOT a standalone middleware — it's called from within
1451
- * `createAuthMiddleware()` when a `rk_` prefixed token is detected.
1819
+ * `users:write` is the scope that lets a support role manage accounts, and
1820
+ * without this it would also let that role reset an administrator's password
1821
+ * and sign in as them.
1452
1822
  */
1453
- async function validateApiKey(c, token, options) {
1454
- const { store, driver } = options;
1455
- const hash = await hashToken(token);
1456
- const apiKey = await store.findByKeyHash(hash);
1457
- if (!apiKey) return c.json({ error: {
1458
- message: "Invalid API key",
1459
- code: "UNAUTHORIZED"
1460
- } }, 401);
1461
- if (apiKey.revoked_at) return c.json({ error: {
1462
- message: "API key has been revoked",
1463
- code: "UNAUTHORIZED"
1464
- } }, 401);
1465
- if (apiKey.expires_at && new Date(apiKey.expires_at) < /* @__PURE__ */ new Date()) return c.json({ error: {
1466
- message: "API key has expired",
1467
- code: "UNAUTHORIZED"
1468
- } }, 401);
1469
- const uid = `api-key:${apiKey.id}`;
1470
- const roles = apiKey.admin ? ["admin", "service"] : ["service"];
1471
- c.set("user", {
1472
- uid,
1473
- roles
1474
- });
1823
+ function assertMayManageAccount(c, targetRoles) {
1824
+ const beyond = beyondCaller(c, targetRoles);
1825
+ if (beyond.length > 0) throw new ApiError(403, "ACCOUNT_OUTRANKS_CALLER", `This account holds ${beyond.join(", ")}, which you do not, so you cannot change it.`, { beyond });
1826
+ }
1827
+ /** Refuse to let the caller grant roles that hold anything the caller does not. */
1828
+ function assertMayGrantRoles(c, roles) {
1829
+ const beyond = beyondCaller(c, roles);
1830
+ if (beyond.length > 0) throw new ApiError(403, "ROLE_EXCEEDS_CALLER", `These roles hold ${beyond.join(", ")}, which you do not, so you cannot grant them.`, { beyond });
1831
+ }
1832
+ //#endregion
1833
+ //#region src/auth/api-keys/api-key-middleware.ts
1834
+ /**
1835
+ * Check whether a token looks like a Rebase API key.
1836
+ */
1837
+ function isApiKeyToken(token) {
1838
+ return token.startsWith("rk_");
1839
+ }
1840
+ /**
1841
+ * Verify a presented key: it exists, is live, and — for a personal key —
1842
+ * personal keys are on and its owner still exists. Records the use.
1843
+ */
1844
+ async function resolveApiKey(store, token) {
1845
+ const apiKey = await store.findByKeyHash(await sha256Hex(token));
1846
+ if (!apiKey) return { message: "Invalid API key" };
1847
+ if (apiKey.revoked_at) return { message: "API key has been revoked" };
1848
+ if (apiKey.expires_at && new Date(apiKey.expires_at) < /* @__PURE__ */ new Date()) return { message: "API key has expired" };
1849
+ let identity;
1850
+ if (apiKey.kind === "personal") {
1851
+ const resolveOwner = getKeyOwnerResolver();
1852
+ if (!resolveOwner || !apiKey.owner_uid) return { message: "Personal API keys are switched off on this backend" };
1853
+ const owner = await resolveOwner(apiKey.owner_uid);
1854
+ if (!owner) return { message: "The account this API key acts as no longer exists" };
1855
+ identity = {
1856
+ uid: apiKey.owner_uid,
1857
+ roles: owner.roles,
1858
+ scopes: intersectScopes(apiKey.scopes, scopesForRoles(owner.roles, getAccessModel()))
1859
+ };
1860
+ } else identity = {
1861
+ uid: `api-key:${apiKey.id}`,
1862
+ roles: ["service", ...apiKey.roles.filter((role) => role !== "service")],
1863
+ scopes: apiKey.scopes
1864
+ };
1865
+ const lastTouch = lastUsedTouchedAt.get(apiKey.id);
1866
+ const now = Date.now();
1867
+ if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
1868
+ lastUsedTouchedAt.set(apiKey.id, now);
1869
+ store.updateLastUsed(apiKey.id).catch(() => {});
1870
+ }
1475
1871
  const masked = {
1476
1872
  id: apiKey.id,
1477
1873
  name: apiKey.name,
1874
+ kind: apiKey.kind,
1478
1875
  key_prefix: apiKey.key_prefix,
1479
- permissions: apiKey.permissions,
1480
- admin: apiKey.admin,
1876
+ scopes: apiKey.scopes,
1877
+ roles: apiKey.roles,
1878
+ owner_uid: apiKey.owner_uid,
1481
1879
  rate_limit: apiKey.rate_limit,
1482
1880
  created_by: apiKey.created_by,
1483
1881
  created_at: apiKey.created_at,
@@ -1486,87 +1884,84 @@ async function validateApiKey(c, token, options) {
1486
1884
  expires_at: apiKey.expires_at,
1487
1885
  revoked_at: apiKey.revoked_at
1488
1886
  };
1489
- c.set("apiKey", masked);
1887
+ return {
1888
+ ...identity,
1889
+ apiKey: masked
1890
+ };
1891
+ }
1892
+ /** Per-process debounce state for last_used_at touches. */
1893
+ var lastUsedTouchedAt = /* @__PURE__ */ new Map();
1894
+ var LAST_USED_DEBOUNCE_MS = 6e4;
1895
+ /**
1896
+ * Validate an API key token and populate the Hono context: `user`, `apiKey`,
1897
+ * `scopes` and the RLS-scoped `driver`.
1898
+ *
1899
+ * Returns `true` when the context is populated, or the error Response.
1900
+ */
1901
+ async function validateApiKey(c, token, options) {
1902
+ const resolved = await resolveApiKey(options.store, token);
1903
+ if (!("uid" in resolved)) return errorHandler(ApiError.unauthenticated(resolved.message), c);
1904
+ c.set("user", {
1905
+ uid: resolved.uid,
1906
+ roles: resolved.roles
1907
+ });
1908
+ c.set("apiKey", resolved.apiKey);
1909
+ c.set("scopes", resolved.scopes);
1490
1910
  try {
1491
- const scopedDriver = await scopeDataDriver(driver, {
1492
- uid,
1493
- roles
1494
- });
1495
- c.set("driver", scopedDriver);
1911
+ c.set("driver", await scopeDataDriver(options.driver, {
1912
+ uid: resolved.uid,
1913
+ roles: resolved.roles
1914
+ }));
1496
1915
  } catch (error) {
1497
1916
  logger.error("[AUTH] RLS scoping failed for API key", { error });
1498
- return c.json({ error: {
1499
- message: "Internal authentication error",
1500
- code: "INTERNAL_ERROR"
1501
- } }, 500);
1502
- }
1503
- const lastTouch = lastUsedTouchedAt.get(apiKey.id);
1504
- const now = Date.now();
1505
- if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
1506
- lastUsedTouchedAt.set(apiKey.id, now);
1507
- store.updateLastUsed(apiKey.id).catch(() => {});
1917
+ return errorHandler(ApiError.internal("Internal authentication error"), c);
1508
1918
  }
1509
1919
  return true;
1510
1920
  }
1511
- /** Per-process debounce state for last_used_at touches. */
1512
- var lastUsedTouchedAt = /* @__PURE__ */ new Map();
1513
- var LAST_USED_DEBOUNCE_MS = 6e4;
1514
1921
  /**
1515
- * Shared 403 response for API-key permission denials outside the REST
1516
- * generator (storage and functions guards). One envelope, one code — a
1517
- * change to the error contract happens in one place.
1922
+ * The 403 for a credential that lacks a data-plane scope outside the REST
1923
+ * generator (storage and functions). Names the scope that would grant it.
1518
1924
  */
1519
- function forbidApiKey(c, operation, resource, grantHint) {
1520
- return c.json({ error: {
1521
- message: `API key does not have "${operation}" permission for ${resource}. Grant it with a permission entry like { "collection": "${grantHint}", "operations": ["${operation}"] }.`,
1522
- code: "API_KEY_FORBIDDEN"
1523
- } }, 403);
1925
+ function forbidScope(c, scope, target) {
1926
+ const wanted = target !== void 0 && target !== "" ? `${scope}:${target}` : scope;
1927
+ return errorHandler(new ApiError(403, "SCOPE_MISSING", `This credential does not hold the "${wanted}" scope.`, { requiredScope: wanted }), c);
1928
+ }
1929
+ /** Whether this request's caller holds `scope` — on `target` when given. */
1930
+ function callerHolds(c, scope, target) {
1931
+ const narrowed = c.get("scopes");
1932
+ if (!narrowed) return true;
1933
+ return target === void 0 ? scopeGrantsAny(narrowed, scope) : scopeGrants(narrowed, scope, target);
1524
1934
  }
1525
1935
  /**
1526
- * Permission guard for API-key requests to the storage router.
1936
+ * Scope guard for the resumable-upload routes (`/tus/:id`).
1527
1937
  *
1528
- * Storage previously did not accept API keys at all (`rk_` tokens were
1529
- * misparsed as JWTs and 401'd). Now that the pre-auth middleware
1530
- * authenticates them, this guard decides what they may do: the key needs a
1531
- * `"storage"` permission entry (or the global `"*"` wildcard) covering the
1532
- * operation derived from the HTTP method. Requests not authenticated via an
1533
- * API key pass through to the storage router's own auth gates.
1534
- *
1535
- * TUS resumable-upload routes (`/tus`, `/tus/:id`) are classified as `write`
1536
- * for EVERY method: the protocol's offset check is a GET and its cancel is a
1537
- * DELETE, but both are steps of an upload — a write-scoped key must be able
1538
- * to complete (and abort) its own resumable upload without also holding
1539
- * `read`/`delete` on stored objects.
1938
+ * Every step of an upload — the offset check (HEAD), the chunks (PATCH), the
1939
+ * cancel (DELETE) — is part of writing it, so all of them need
1940
+ * `storage:write`. Which source the upload writes to was checked when it was
1941
+ * created; these steps only reach an upload the same caller owns.
1540
1942
  */
1541
- function createStorageApiKeyGuard() {
1943
+ function createTusScopeGuard() {
1542
1944
  return async (c, next) => {
1543
- const apiKey = c.get("apiKey");
1544
- if (!apiKey) return next();
1545
- const operation = /\/tus(\/|$)/.test(c.req.path) ? "write" : httpMethodToOperation(c.req.method);
1546
- if (!isStorageAllowed(apiKey.permissions, operation)) return forbidApiKey(c, operation, "storage", "storage");
1945
+ if (/\/tus\/[^/]+$/.test(c.req.path) && c.get("user") && !callerHolds(c, "storage:write")) return forbidScope(c, "storage:write");
1547
1946
  return next();
1548
1947
  };
1549
1948
  }
1550
1949
  /**
1551
- * Permission guard for API-key requests to the custom-functions router.
1950
+ * Scope guard for the custom-functions router: a narrowed credential needs
1951
+ * `functions:invoke` on the function it calls. The functions index — the
1952
+ * listing at the mount point itself — needs the unqualified scope.
1552
1953
  *
1553
- * The collection permission guard lives in the REST generator and never sees
1554
- * function routes, so before this middleware existed ANY valid API key —
1555
- * however narrowly scoped — could invoke every custom function. This guard
1556
- * closes that: API-key requests must hold a `"functions"`/`"functions/<name>"`
1557
- * permission entry (or the global `"*"` wildcard) for the derived operation.
1558
- *
1559
- * Non-API-key requests (JWT, service key, anonymous) pass through untouched —
1560
- * functions decide their own auth for those, as before.
1954
+ * People pass: what a person may do inside a function is the function's own
1955
+ * business, as it always was.
1561
1956
  *
1562
1957
  * @param mountPrefix - The path the functions router is mounted at
1563
1958
  * (e.g. `/api/functions`), used to extract the function
1564
1959
  * name from the request path.
1565
1960
  */
1566
- function createFunctionApiKeyGuard(mountPrefix) {
1961
+ function createFunctionScopeGuard(mountPrefix) {
1567
1962
  return async (c, next) => {
1568
- const apiKey = c.get("apiKey");
1569
- if (!apiKey) return next();
1963
+ const narrowed = c.get("scopes");
1964
+ if (!narrowed) return next();
1570
1965
  const path = c.req.path;
1571
1966
  const idx = path.indexOf(mountPrefix);
1572
1967
  const rawName = (idx < 0 ? "" : path.slice(idx + mountPrefix.length)).split("/").filter(Boolean)[0] ?? "";
@@ -1576,21 +1971,17 @@ function createFunctionApiKeyGuard(mountPrefix) {
1576
1971
  } catch {
1577
1972
  functionName = rawName;
1578
1973
  }
1579
- const operation = httpMethodToOperation(c.req.method);
1580
- if (!isFunctionAllowed(apiKey.permissions, functionName, operation)) return forbidApiKey(c, operation, functionName ? `function "${functionName}"` : "the functions index", `functions${functionName ? `/${functionName}` : ""}`);
1974
+ if (!(functionName === "" ? scopeGrants(narrowed, "functions:invoke") : scopeGrants(narrowed, "functions:invoke", functionName))) return forbidScope(c, "functions:invoke", functionName);
1581
1975
  return next();
1582
1976
  };
1583
1977
  }
1584
1978
  /**
1585
- * Standalone pre-auth middleware for `rk_` bearer tokens.
1979
+ * Pre-auth middleware for `rk_` bearer tokens.
1586
1980
  *
1587
- * Routers whose auth gate is JWT-based (`requireAuth` / `createRequireAuth` —
1588
- * the admin surfaces: admin users/roles, api-keys management, cron, backups,
1589
- * logs, schema editor) don't know about API keys. Mounting this middleware in
1590
- * front of them authenticates `rk_` tokens and populates the request context;
1591
- * the downstream gates then see the already-resolved user and apply their
1592
- * role checks (`requireAdmin`) as usual — so an `admin: true` key passes and
1593
- * a non-admin key is rejected with 403.
1981
+ * Routers whose auth gate is JWT-based (`createRequireAuth` — the admin
1982
+ * surfaces, the key routes) don't know about API keys. Mounted in front of
1983
+ * them, this authenticates `rk_` tokens and populates the request context; the
1984
+ * downstream gates then see the already-resolved caller and check its scopes.
1594
1985
  *
1595
1986
  * Requests without an `rk_` bearer token pass through untouched. An invalid,
1596
1987
  * revoked, or expired `rk_` token is rejected here (401) rather than falling
@@ -1607,6 +1998,257 @@ function createApiKeyPreAuth(options) {
1607
1998
  };
1608
1999
  }
1609
2000
  //#endregion
2001
+ //#region src/auth/impersonation.ts
2002
+ var ADMIN_ONLY = "Only a signed-in administrator can run a request as another user.";
2003
+ var NOT_A_PERSON = {
2004
+ "api-key": {
2005
+ reason: "api-key",
2006
+ message: "An API key cannot run a request as another user. Send it with an administrator's session instead."
2007
+ },
2008
+ "service-key": {
2009
+ reason: "service-key",
2010
+ message: "The service key cannot run a request as another user. Send it with an administrator's session instead."
2011
+ },
2012
+ none: {
2013
+ reason: "anonymous",
2014
+ message: ADMIN_ONLY
2015
+ }
2016
+ };
2017
+ /**
2018
+ * Decide whether this caller may act as the user they named, and who that
2019
+ * user is now.
2020
+ *
2021
+ * The one decision both doors take — the HTTP data plane through
2022
+ * {@link applyImpersonation}, the realtime socket at `AUTHENTICATE` and before
2023
+ * every frame after it. The caller is judged before the named user is looked
2024
+ * up, so the answer to a caller who may not impersonate says nothing about
2025
+ * whether the uid exists.
2026
+ *
2027
+ * Throws when a lookup fails: that is not an answer about anyone, and each
2028
+ * door refuses it its own way.
2029
+ */
2030
+ async function decideImpersonation(request) {
2031
+ const { credential, caller, resolveUser, audit } = request;
2032
+ const targetUid = request.requestedUid.trim();
2033
+ const refuse = (reason, error) => {
2034
+ if (audit) logger.warn("[Security Audit] Refused a request to act as another user", {
2035
+ eventType: "auth.impersonation.refused",
2036
+ reason,
2037
+ callerUid: caller?.uid,
2038
+ requestedUid: targetUid,
2039
+ ...audit
2040
+ });
2041
+ return { refused: error };
2042
+ };
2043
+ if (targetUid === "") return refuse("malformed", ApiError.badRequest(`The ${IMPERSONATE_HEADER} header names no user. Send the uid of the user to act as, or leave the header out.`, "IMPERSONATION_INVALID"));
2044
+ if (credential !== "session") {
2045
+ const { reason, message } = NOT_A_PERSON[credential];
2046
+ return refuse(reason, ApiError.forbidden(message, "IMPERSONATION_FORBIDDEN"));
2047
+ }
2048
+ if (!caller || !hasAdminRole(caller.roles)) return refuse("not-admin", ApiError.forbidden(ADMIN_ONLY, "IMPERSONATION_FORBIDDEN"));
2049
+ if (!resolveUser) return refuse("unavailable", new ApiError(501, "IMPERSONATION_UNAVAILABLE", "This backend's auth cannot run a request as another user: its auth adapter does not implement resolveUser."));
2050
+ const self = await resolveUser(caller.uid);
2051
+ if (!self || !hasAdminRole(self.roles)) return refuse("not-admin", ApiError.forbidden(ADMIN_ONLY, "IMPERSONATION_FORBIDDEN"));
2052
+ const target = await resolveUser(targetUid);
2053
+ if (!target) return refuse("unknown-target", ApiError.notFound(`No active user has the id "${targetUid}", so there is nobody to run this request as.`, "IMPERSONATION_TARGET_NOT_FOUND"));
2054
+ if (audit) logger.info("[Security Audit] Ran a request as another user", {
2055
+ eventType: "auth.impersonation",
2056
+ impersonatorUid: caller.uid,
2057
+ targetUid: target.uid,
2058
+ targetRoles: target.roles,
2059
+ ...audit
2060
+ });
2061
+ return {
2062
+ granted: target,
2063
+ impersonator: caller.uid
2064
+ };
2065
+ }
2066
+ /** The caller the auth middleware put on the context, narrowed. */
2067
+ function callerOf(c) {
2068
+ const user = c.get("user");
2069
+ if (typeof user !== "object" || user === null) return void 0;
2070
+ const uid = "uid" in user && typeof user.uid === "string" && user.uid !== "" ? user.uid : void 0;
2071
+ if (uid === void 0) return void 0;
2072
+ return {
2073
+ uid,
2074
+ roles: "roles" in user && Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : []
2075
+ };
2076
+ }
2077
+ /**
2078
+ * Honour or refuse this request's {@link IMPERSONATE_HEADER}.
2079
+ *
2080
+ * Called by the data-plane auth middlewares once the caller is on the context
2081
+ * and its driver is scoped. When the header names a user the caller may act
2082
+ * as, the context's `user` and `driver` are replaced with that user's and
2083
+ * `impersonator` names the caller.
2084
+ *
2085
+ * @returns The refusal to send, or `undefined` to carry on — as the caller
2086
+ * when the header is absent, as the named user when it was granted.
2087
+ */
2088
+ async function applyImpersonation(c, { credential, driver, resolveUser }) {
2089
+ const requested = c.req.header(IMPERSONATE_HEADER);
2090
+ if (requested === void 0) return void 0;
2091
+ const caller = callerOf(c);
2092
+ let decision;
2093
+ try {
2094
+ decision = await decideImpersonation({
2095
+ requestedUid: requested,
2096
+ credential,
2097
+ caller,
2098
+ resolveUser,
2099
+ audit: {
2100
+ door: "http",
2101
+ method: c.req.method,
2102
+ path: c.req.path,
2103
+ requestId: c.get("requestId")
2104
+ }
2105
+ });
2106
+ } catch (error) {
2107
+ return errorHandler(error instanceof Error ? error : new Error(String(error)), c);
2108
+ }
2109
+ if ("refused" in decision) return errorHandler(decision.refused, c);
2110
+ const target = decision.granted;
2111
+ const isAnonymous = target.isAnonymous === true;
2112
+ c.set("user", {
2113
+ uid: target.uid,
2114
+ email: target.email,
2115
+ roles: target.roles,
2116
+ isAnonymous
2117
+ });
2118
+ c.set("impersonator", { uid: decision.impersonator });
2119
+ try {
2120
+ c.set("driver", await scopeDataDriver(driver, {
2121
+ uid: target.uid,
2122
+ roles: target.roles,
2123
+ isAnonymous,
2124
+ ...target.claims ? { claims: target.claims } : {}
2125
+ }));
2126
+ } catch (error) {
2127
+ logger.error("[AUTH] RLS scoping failed for an impersonated request", { error });
2128
+ return errorHandler(ApiError.internal("Internal authentication error"), c);
2129
+ }
2130
+ }
2131
+ /**
2132
+ * Refuse {@link IMPERSONATE_HEADER} on every route that does not honour it.
2133
+ *
2134
+ * Only the data API and custom functions run a request as another user; the
2135
+ * rest — storage, auth, the admin surfaces — authenticate the caller and
2136
+ * ignore the header. Ignored, a request asking to act as B would be answered
2137
+ * as the administrator who sent it, which is the one outcome impersonation
2138
+ * must never have. Mounted ahead of every route, so a surface added later is
2139
+ * refused until it is listed here as honouring the header.
2140
+ *
2141
+ * @param honouredMounts - The mount points whose auth middleware applies the
2142
+ * header (`/api/data`, `/api/functions`). A path is under one when it
2143
+ * is the mount itself or continues it with a `/`.
2144
+ */
2145
+ function refuseUnhonouredImpersonation(honouredMounts) {
2146
+ return async (c, next) => {
2147
+ if (c.req.header("x-rebase-impersonate") === void 0) return next();
2148
+ const path = c.req.path;
2149
+ if (honouredMounts.some((mount) => path === mount || path.startsWith(`${mount}/`))) return next();
2150
+ logger.warn("[Security Audit] Refused a request to act as another user", {
2151
+ eventType: "auth.impersonation.refused",
2152
+ reason: "unsupported-route",
2153
+ method: c.req.method,
2154
+ path,
2155
+ requestId: c.get("requestId")
2156
+ });
2157
+ return errorHandler(ApiError.badRequest(`This route cannot run as another user, so it refuses the ${IMPERSONATE_HEADER} header. Only the data API and custom functions honour it.`, "IMPERSONATION_UNSUPPORTED"), c);
2158
+ };
2159
+ }
2160
+ //#endregion
2161
+ //#region src/storage/requested-object.ts
2162
+ /**
2163
+ * Which object a storage request's URL path names.
2164
+ *
2165
+ * Three parties read the path of a `GET /file/*`, `GET /metadata/*` or
2166
+ * `DELETE /file/*`: the route, which serves or deletes an object;
2167
+ * `publicObjectAuth`, which lets an anonymous caller through when that object
2168
+ * is public; and `fileTokenAuth`, which compares it with the path a download
2169
+ * token grants. A decision is only about the object acted on if it is made on
2170
+ * the same key, so all three derive it here — the wildcard, the decoding, the
2171
+ * bucket segment and the canonical key — and none of them spells a step of its
2172
+ * own.
2173
+ *
2174
+ * They used to. `publicObjectAuth` judged the raw path with a check that strips
2175
+ * everything up to a `://`, while the route canonicalized it, folding `//` to
2176
+ * `/`: `GET /file/notes://public/secret.txt` was public to the one and served
2177
+ * the private key `notes:/public/secret.txt` from the other, with the authorize
2178
+ * hook never asked.
2179
+ *
2180
+ * Nothing here parses a scheme. A request path is a key, optionally behind the
2181
+ * one bucket segment the routes recognise; a `://` in it is a `:` followed by
2182
+ * an empty segment, and canonicalization folds the empty segment away.
2183
+ */
2184
+ /**
2185
+ * The wildcard part of a storage route's path, still percent-encoded.
2186
+ *
2187
+ * Hono's `c.req.param('*')` does not work reliably in sub-routers mounted
2188
+ * via `app.route(prefix, subRouter)`. Instead it is derived from the
2189
+ * fully-resolved `c.req.path` and `c.req.routePath`.
2190
+ *
2191
+ * For a route `/metadata/*` mounted at `/api/storage`, a request to
2192
+ * `/api/storage/metadata/default/file.jpg` yields routePath
2193
+ * `/api/storage/metadata/*`. The prefix (everything before `/*`) is stripped,
2194
+ * plus one character for the trailing `/`, to obtain `default/file.jpg`.
2195
+ */
2196
+ function storageRequestWildcard(c) {
2197
+ const prefix = c.req.routePath.replace("/*", "");
2198
+ const fullPath = c.req.path;
2199
+ const idx = fullPath.indexOf(prefix);
2200
+ if (idx < 0) return "";
2201
+ return fullPath.substring(idx + prefix.length + 1);
2202
+ }
2203
+ /**
2204
+ * The object a decoded path names: a key, behind an optional `default/` bucket
2205
+ * segment (matched case-insensitively). Throws {@link InvalidStorageKeyError}
2206
+ * when the key cannot be canonicalized.
2207
+ *
2208
+ * For a path that is already text — the folder route's JSON body. A URL path
2209
+ * goes through {@link requestedStorageObject}, which decodes it first.
2210
+ */
2211
+ function storageObjectOfPath(decodedPath) {
2212
+ const parts = decodedPath.split("/");
2213
+ return {
2214
+ bucket: "default",
2215
+ key: canonicalStorageKey(parts.length > 1 && parts[0].toLowerCase() === "default" ? parts.slice(1).join("/") : decodedPath)
2216
+ };
2217
+ }
2218
+ /**
2219
+ * The object a request's wildcard path names, or {@link InvalidStorageKeyError}.
2220
+ *
2221
+ * The wildcard arrives as Hono leaves it — `decodeURI`d, with the reserved
2222
+ * characters (`/`, `:`, `%25` among them) still escaped — so one
2223
+ * `decodeURIComponent` yields the path the client encoded. A `%` that does not
2224
+ * begin an escape names no key, and is refused like any other key that cannot be
2225
+ * canonicalized rather than thrown as a `URIError`.
2226
+ */
2227
+ function requestedStorageObject(wildcard) {
2228
+ let decoded;
2229
+ try {
2230
+ decoded = decodeURIComponent(wildcard);
2231
+ } catch {
2232
+ throw new InvalidStorageKeyError("Storage path is not valid percent-encoding: a '%' must begin an escape such as '%25' (a literal '%').");
2233
+ }
2234
+ return storageObjectOfPath(decoded);
2235
+ }
2236
+ /**
2237
+ * {@link requestedStorageObject}, or `null` when the path names no object.
2238
+ *
2239
+ * For the middleware, which must fail closed without an exception: a path that
2240
+ * names no object is neither public nor covered by any download token, and the
2241
+ * route answers it with a 400 of its own.
2242
+ */
2243
+ function tryRequestedStorageObject(wildcard) {
2244
+ try {
2245
+ return requestedStorageObject(wildcard);
2246
+ } catch (err) {
2247
+ if (err instanceof InvalidStorageKeyError) return null;
2248
+ throw err;
2249
+ }
2250
+ }
2251
+ //#endregion
1610
2252
  //#region src/auth/middleware.ts
1611
2253
  /**
1612
2254
  * Answer a refusal with the canonical envelope, through the same formatter
@@ -1679,10 +2321,15 @@ function createRequireAuth(options) {
1679
2321
  const payload = await verifyAccessToken(token);
1680
2322
  if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
1681
2323
  if (resolveRoles || revocationRepo) try {
1682
- if (revocationRepo && await isAccessTokenRevoked(revocationRepo, payload)) return refuse(c, ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED"));
2324
+ let judgedRoles;
2325
+ if (revocationRepo) {
2326
+ const verdict = await judgeAccessToken(revocationRepo, payload);
2327
+ if (!verdict.live) return refuse(c, verdict.refusal === "account-disabled" ? ApiError.unauthorized("This account has been disabled", "ACCOUNT_DISABLED") : ApiError.unauthorized(verdict.refusal === "account-deleted" ? "This account no longer exists" : "Session has been revoked", "SESSION_REVOKED"));
2328
+ judgedRoles = verdict.roles;
2329
+ }
1683
2330
  c.set("user", resolveRoles ? {
1684
2331
  ...payload,
1685
- roles: await resolveRoles(payload.uid)
2332
+ roles: judgedRoles ?? await resolveRoles(payload.uid)
1686
2333
  } : payload);
1687
2334
  return next();
1688
2335
  } catch (error) {
@@ -1700,13 +2347,17 @@ function createRequireAuth(options) {
1700
2347
  };
1701
2348
  }
1702
2349
  /**
1703
- * Middleware that requires the user to have an admin or schema-admin role.
1704
- * Must be used AFTER requireAuth or on a route where user is guaranteed.
2350
+ * Middleware that requires the user to hold the `admin` role, which holds
2351
+ * every scope. Must be used AFTER requireAuth or on a route where user is
2352
+ * guaranteed.
2353
+ *
2354
+ * Prefer `requireScope` from `./access` for anything a scope names: a scope
2355
+ * can be granted to a narrower role and to a key, and `admin` cannot.
1705
2356
  */
1706
2357
  var requireAdmin = async (c, next) => {
1707
2358
  const user = c.get("user");
1708
2359
  if (!user) return refuse(c, ApiError.unauthorized("User not authenticated. requireAuth middleware is missing?"));
1709
- if (!hasAdministrativeRole(typeof user === "object" && user !== null && "roles" in user ? user.roles || [] : [])) return refuse(c, ApiError.forbidden("Admin privileges required for this operation"));
2360
+ if (!hasAdminRole(typeof user === "object" && user !== null && "roles" in user && Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : [])) return refuse(c, ApiError.forbidden("Admin privileges required for this operation"));
1710
2361
  return next();
1711
2362
  };
1712
2363
  /**
@@ -1754,12 +2405,14 @@ function createAuthMiddleware(options) {
1754
2405
  const { driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
1755
2406
  return async (c, next) => {
1756
2407
  const driver = resolveDriver ? resolveDriver(c) : baseDriver;
2408
+ let credential = "none";
1757
2409
  if (validator) try {
1758
2410
  const authResult = await validator(c);
1759
2411
  if (authResult && typeof authResult === "object") {
1760
2412
  const id = "uid" in authResult ? authResult.uid : void 0;
1761
2413
  if (id) {
1762
2414
  const roles = authResult.roles || [];
2415
+ credential = "session";
1763
2416
  c.set("user", {
1764
2417
  uid: id,
1765
2418
  roles
@@ -1775,6 +2428,7 @@ function createAuthMiddleware(options) {
1775
2428
  roles: ["anon"]
1776
2429
  }));
1777
2430
  } else if (authResult === true) {
2431
+ credential = "session";
1778
2432
  c.set("user", {
1779
2433
  uid: "default",
1780
2434
  roles: []
@@ -1793,10 +2447,12 @@ function createAuthMiddleware(options) {
1793
2447
  else {
1794
2448
  const token = extractBearerToken(c.req.header("authorization"));
1795
2449
  if (token !== void 0) if (serviceKey && safeCompare(token, serviceKey)) {
1796
- c.set("user", {
2450
+ const serviceUser = {
1797
2451
  uid: "service",
1798
2452
  roles: ["admin"]
1799
- });
2453
+ };
2454
+ credential = "service-key";
2455
+ c.set("user", serviceUser);
1800
2456
  try {
1801
2457
  c.set("driver", await scopeDataDriver(driver, {
1802
2458
  uid: "service",
@@ -1815,9 +2471,11 @@ function createAuthMiddleware(options) {
1815
2471
  driver
1816
2472
  });
1817
2473
  if (result !== true) return result;
2474
+ credential = "api-key";
1818
2475
  } else {
1819
2476
  const payload = await extractUserFromToken(token);
1820
2477
  if (payload) {
2478
+ credential = "session";
1821
2479
  c.set("user", payload);
1822
2480
  try {
1823
2481
  const user = {
@@ -1849,6 +2507,11 @@ function createAuthMiddleware(options) {
1849
2507
  } }, 500);
1850
2508
  }
1851
2509
  }
2510
+ const refused = await applyImpersonation(c, {
2511
+ credential,
2512
+ driver
2513
+ });
2514
+ if (refused) return refused;
1852
2515
  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."));
1853
2516
  return next();
1854
2517
  };
@@ -1889,14 +2552,19 @@ var queryTokenAuth = async (c, next) => {
1889
2552
  * requested object path is public, it sets a minimal "public" principal so the
1890
2553
  * downstream `requireAuth` gate lets the read through. Private paths are left
1891
2554
  * untouched, so they still require a valid token.
2555
+ *
2556
+ * Public means the object the route will serve is public, so the decision is
2557
+ * made on that object's canonical key, derived by the route's own function
2558
+ * (`requestedStorageObject`). It used to be made on the raw path, through a
2559
+ * check that strips a `scheme://`: `notes://public/secret.txt` read as
2560
+ * `public/secret.txt` here while the route served the private
2561
+ * `notes:/public/secret.txt`, and the authorize hook — not asked about this
2562
+ * principal — never saw it.
1892
2563
  */
1893
2564
  var publicObjectAuth = async (c, next) => {
1894
2565
  if (c.get("user")) return next();
1895
- const prefix = c.req.routePath.replace("/*", "");
1896
- const fullPath = c.req.path;
1897
- const idx = fullPath.indexOf(prefix);
1898
- const rawPath = idx < 0 ? "" : fullPath.substring(idx + prefix.length + 1);
1899
- if (rawPath && isPublicStoragePath(decodeURIComponent(rawPath))) c.set("user", {
2566
+ const requested = tryRequestedStorageObject(storageRequestWildcard(c));
2567
+ if (requested && isPublicStorageKey(requested.key)) c.set("user", {
1900
2568
  uid: "public",
1901
2569
  roles: ["public"]
1902
2570
  });
@@ -1920,7 +2588,7 @@ var publicObjectAuth = async (c, next) => {
1920
2588
  * Hono routes the request, so this comparison already runs on a resolved path.
1921
2589
  * But that is a guarantee of the runtime rather than of this code, and it is
1922
2590
  * one line to not depend on it. The same rule already guards the public-object
1923
- * path — see `isPublicStoragePath`.
2591
+ * path — see `isPublicStorageKey`.
1924
2592
  */
1925
2593
  function isPathMatch(requested, allowed) {
1926
2594
  if (requested.split("/").some((seg) => seg === "..")) return false;
@@ -1937,34 +2605,6 @@ var fileTokenAuth = async (c, next) => {
1937
2605
  const isFileRoute = c.req.path.includes("/storage/file/");
1938
2606
  const authHeader = c.req.header("authorization");
1939
2607
  const queryToken = c.req.query("token");
1940
- const extractWildcard = (ctx) => {
1941
- const prefix = ctx.req.routePath.replace("/*", "");
1942
- const fullPath = ctx.req.path;
1943
- const idx = fullPath.indexOf(prefix);
1944
- if (idx < 0) return "";
1945
- return fullPath.substring(idx + prefix.length + 1);
1946
- };
1947
- /**
1948
- * Must agree with `parseBucketAndPath` in `storage/routes.ts`, because the
1949
- * path a token GRANTS is minted there and the path a request ASKS FOR is
1950
- * derived here. `/metadata` canonicalizes before signing, so a raw
1951
- * comparison here would 403 an otherwise-valid request for any key whose
1952
- * URL form is not already canonical (`a//b.txt`, `a/./b.txt`).
1953
- *
1954
- * A key that cannot be canonicalized yields null, which matches no grant —
1955
- * the same fail-closed answer `isPathMatch` gives a `..` segment.
1956
- */
1957
- const parseBucketPath = (filePath) => {
1958
- const parts = filePath.split("/");
1959
- if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
1960
- bucket: "default",
1961
- resolvedPath: tryCanonicalStorageKey(parts.slice(1).join("/"))
1962
- };
1963
- return {
1964
- bucket: "default",
1965
- resolvedPath: tryCanonicalStorageKey(filePath)
1966
- };
1967
- };
1968
2608
  /**
1969
2609
  * Decide what a valid download token entitles this request to.
1970
2610
  *
@@ -1979,11 +2619,11 @@ var fileTokenAuth = async (c, next) => {
1979
2619
  */
1980
2620
  const evaluateGrant = (payload) => {
1981
2621
  if (canonicalStorageId(c.req.query("storageId")) !== payload.storageId) return "deny-storage";
1982
- const rawPath = extractWildcard(c);
2622
+ const rawPath = storageRequestWildcard(c);
1983
2623
  if (!rawPath) return "no-path";
1984
- const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
1985
- if (resolvedPath === null) return "deny-path";
1986
- return isPathMatch(`${bucket}/${resolvedPath}`, payload.path) ? "grant" : "deny-path";
2624
+ const requested = tryRequestedStorageObject(rawPath);
2625
+ if (requested === null) return "deny-path";
2626
+ return isPathMatch(`${requested.bucket}/${requested.key}`, payload.path) ? "grant" : "deny-path";
1987
2627
  };
1988
2628
  /**
1989
2629
  * Which half of the scope failed is named in the message. The holder of the
@@ -2043,8 +2683,13 @@ function createAdapterAuthMiddleware(options) {
2043
2683
  store: apiKeyStore,
2044
2684
  driver
2045
2685
  });
2046
- if (result === true) return next();
2047
- return result;
2686
+ if (result !== true) return result;
2687
+ const refused = await applyImpersonation(c, {
2688
+ credential: "api-key",
2689
+ driver
2690
+ });
2691
+ if (refused) return refused;
2692
+ return next();
2048
2693
  }
2049
2694
  }
2050
2695
  let authenticatedUser = null;
@@ -2083,6 +2728,12 @@ function createAdapterAuthMiddleware(options) {
2083
2728
  return refuse(c, ApiError.internal("Server configuration error"));
2084
2729
  }
2085
2730
  }
2731
+ const refused = await applyImpersonation(c, {
2732
+ credential: !authenticatedUser ? "none" : authenticatedUser.uid === SERVICE_IDENTITY.uid ? "service-key" : "session",
2733
+ driver,
2734
+ resolveUser: adapter.resolveUser ? adapter.resolveUser.bind(adapter) : void 0
2735
+ });
2736
+ if (refused) return refused;
2086
2737
  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."));
2087
2738
  return next();
2088
2739
  };
@@ -2183,9 +2834,45 @@ async function verifyPassword(password, storedHash) {
2183
2834
  * This is the single point where defaults are applied — all consumers
2184
2835
  * call this once and use the resolved hooks throughout.
2185
2836
  */
2837
+ /**
2838
+ * What a hook's thrown error answers.
2839
+ *
2840
+ * The hooks that refuse (`beforeUserCreate`, `beforeLogin`,
2841
+ * `beforeUserDelete`, `beforeEmailChange`) are documented as "throw to reject", and a plain
2842
+ * `Error` is what people throw. It reached the error handler as a 500
2843
+ * "Internal Server Error", so a deployment that limits sign-ups to its own
2844
+ * domain answered an outsider with a server fault. A refusal is the caller's
2845
+ * answer: 400 `HOOK_REJECTED` with the hook's message — or the status an
2846
+ * error carries, when the hook chose one (`ApiError`, or any error with a 4xx
2847
+ * `status`/`statusCode`).
2848
+ */
2849
+ function hookRefusal(error, hook) {
2850
+ if (error instanceof ApiError) return error;
2851
+ const carried = typeof error === "object" && error !== null ? "statusCode" in error && typeof error.statusCode === "number" ? error.statusCode : "status" in error && typeof error.status === "number" ? error.status : void 0 : void 0;
2852
+ const status = carried !== void 0 && carried >= 400 && carried < 500 ? carried : 400;
2853
+ const ownCode = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" && /^[A-Z][A-Z0-9_]+$/.test(error.code) ? error.code : void 0;
2854
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : `${hook} refused the request`;
2855
+ if (status === 400 && !ownCode) return ApiError.badRequest(message, "HOOK_REJECTED", { hook });
2856
+ return new ApiError(status, ownCode ?? "HOOK_REJECTED", message, { hook });
2857
+ }
2858
+ /** A refusing hook whose throw answers as {@link hookRefusal} says. */
2859
+ function refusable(hook, fn) {
2860
+ if (!fn) return void 0;
2861
+ return async (...args) => {
2862
+ try {
2863
+ return await fn(...args);
2864
+ } catch (error) {
2865
+ throw hookRefusal(error, hook);
2866
+ }
2867
+ };
2868
+ }
2186
2869
  function resolveAuthHooks(hooks) {
2187
2870
  return {
2188
2871
  ...hooks,
2872
+ beforeUserCreate: refusable("beforeUserCreate", hooks?.beforeUserCreate?.bind(hooks)),
2873
+ beforeLogin: refusable("beforeLogin", hooks?.beforeLogin?.bind(hooks)),
2874
+ beforeUserDelete: refusable("beforeUserDelete", hooks?.beforeUserDelete?.bind(hooks)),
2875
+ beforeEmailChange: refusable("beforeEmailChange", hooks?.beforeEmailChange?.bind(hooks)),
2189
2876
  hashPassword: hooks?.hashPassword ?? hashPassword,
2190
2877
  verifyPassword: hooks?.verifyPassword ?? verifyPassword,
2191
2878
  validatePasswordStrength: hooks?.validatePasswordStrength ?? validatePasswordStrength
@@ -2643,6 +3330,17 @@ function socketAddress(c) {
2643
3330
  * resort only, and it is a single shared bucket by design: better that
2644
3331
  * anonymous callers throttle each other than that any of them throttles nobody.
2645
3332
  */
3333
+ /**
3334
+ * Where a request comes from, as the rate limiters judge it: the socket's own
3335
+ * address, or — only behind as many proxies as `TRUSTED_PROXY_HOPS` declares —
3336
+ * the address they report. What a session row records as its IP, so the
3337
+ * sessions list shows the same address the limiter counted, rather than the
3338
+ * raw `X-Forwarded-For` (whose leftmost entry the caller chooses) or
3339
+ * `"unknown"` for every direct connection.
3340
+ */
3341
+ function requestClientAddress(c) {
3342
+ return defaultKeyGenerator(c);
3343
+ }
2646
3344
  function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
2647
3345
  return clientAddress((name) => c.req.header(name), () => socketAddress(c), trustedProxyHops);
2648
3346
  }
@@ -2827,13 +3525,24 @@ function createDataRateLimiter(config = {}) {
2827
3525
  if (user?.uid) return isAnonymousUid(user.uid) ? void 0 : user.uid;
2828
3526
  if (!isJwtConfigured()) return void 0;
2829
3527
  const token = extractBearerToken(c.req.header("authorization"));
2830
- if (token === void 0) return void 0;
3528
+ if (token === void 0) {
3529
+ const queryToken = c.req.query("token");
3530
+ if (!queryToken) return void 0;
3531
+ const grant = await verifyDownloadToken(queryToken);
3532
+ return grant?.rl ? `file:${grant.rl}` : void 0;
3533
+ }
2831
3534
  const payload = await verifyAccessToken(token);
2832
3535
  if (!payload?.uid || isAnonymousUid(payload.uid)) return void 0;
2833
3536
  return payload.uid;
2834
3537
  };
3538
+ const isServiceKey = (c) => {
3539
+ if (!config.serviceKey) return false;
3540
+ const token = extractBearerToken(c.req.header("authorization"));
3541
+ return token !== void 0 && safeCompare(token, config.serviceKey);
3542
+ };
2835
3543
  const bucketOf = async (c) => {
2836
3544
  if (c.get("user")?.uid === SERVICE_IDENTITY.uid) return null;
3545
+ if (isServiceKey(c)) return null;
2837
3546
  const apiKey = c.get("apiKey");
2838
3547
  return bucketFor({
2839
3548
  apiKey,
@@ -2867,6 +3576,7 @@ function createDataRateLimitCheck(config = {}) {
2867
3576
  return async (caller) => {
2868
3577
  if (caller.uid === SERVICE_IDENTITY.uid) return null;
2869
3578
  const bucket = bucketFor({
3579
+ apiKey: caller.apiKey,
2870
3580
  uid: caller.uid !== void 0 && !isAnonymousUid(caller.uid) ? caller.uid : void 0,
2871
3581
  address: () => clientAddress(caller.header, () => caller.socketAddress, trustedProxyHops)
2872
3582
  });
@@ -3265,7 +3975,8 @@ function keyCandidates() {
3265
3975
  add(process.env.MFA_ENCRYPTION_KEY, "MFA_ENCRYPTION_KEY");
3266
3976
  for (const previous of (process.env.MFA_ENCRYPTION_KEY_PREVIOUS ?? "").split(",")) add(previous, "MFA_ENCRYPTION_KEY_PREVIOUS");
3267
3977
  add(process.env.JWT_SECRET, "JWT_SECRET");
3268
- if (candidates.length === 0) throw new Error("Cannot encrypt or decrypt TOTP secrets: none of MFA_ENCRYPTION_KEY, MFA_ENCRYPTION_KEY_PREVIOUS or JWT_SECRET is configured.");
3978
+ add(configuredJwtSecret(), "auth.jwtSecret");
3979
+ if (candidates.length === 0) throw new Error("Cannot encrypt or decrypt TOTP secrets: none of MFA_ENCRYPTION_KEY, MFA_ENCRYPTION_KEY_PREVIOUS, JWT_SECRET or auth.jwtSecret is configured.");
3269
3980
  if (!process.env.MFA_ENCRYPTION_KEY && !warnedAboutFallback) {
3270
3981
  warnedAboutFallback = true;
3271
3982
  logger.warn("[MFA-Crypto] MFA_ENCRYPTION_KEY is not set — falling back to JWT_SECRET. Set a dedicated MFA_ENCRYPTION_KEY for production; see the rotation steps in mfa-crypto.ts before you do it on a live deployment.");
@@ -3369,6 +4080,13 @@ function clearRefreshCookie(c, config) {
3369
4080
  }
3370
4081
  /**
3371
4082
  * Read the refresh token from the request — cookie first, then body fallback.
4083
+ *
4084
+ * An empty string is no token, wherever it comes from. In cookie mode every
4085
+ * response this server sends carries `refreshToken: ""` (see
4086
+ * `redactRefreshToken`), and a client that echoes it back — `@rebasepro/client`
4087
+ * did, on every refresh from a live tab — is presenting nothing. Taking the
4088
+ * body's `""` as the token, or refusing it at the schema, kept the cookie
4089
+ * beside it from ever being read.
3372
4090
  */
3373
4091
  function readRefreshToken(c, body, config) {
3374
4092
  if (config) {
@@ -3378,10 +4096,13 @@ function readRefreshToken(c, body, config) {
3378
4096
  const cookies = cookieHeader.split(";");
3379
4097
  for (const part of cookies) {
3380
4098
  const trimmed = part.trim();
3381
- if (trimmed.startsWith(prefix)) return decodeURIComponent(trimmed.slice(prefix.length));
4099
+ if (trimmed.startsWith(prefix)) {
4100
+ const fromCookie = decodeURIComponent(trimmed.slice(prefix.length));
4101
+ if (fromCookie) return fromCookie;
4102
+ }
3382
4103
  }
3383
4104
  }
3384
- return body.refreshToken;
4105
+ return body.refreshToken || void 0;
3385
4106
  }
3386
4107
  /**
3387
4108
  * Redact the refresh token from the response body when cookie mode is active.
@@ -3507,7 +4228,7 @@ function mountMfaRoutes(opts) {
3507
4228
  async function resolveLiveStepUpPrincipal(c) {
3508
4229
  const principal = await resolveStepUpPrincipal(c);
3509
4230
  if (!principal) throw ApiError.unauthorized("Not authenticated");
3510
- if (await isAccessTokenRevoked(authRepo, principal)) throw ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
4231
+ if (!(await judgeAccessToken(authRepo, principal)).live) throw ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
3511
4232
  return principal;
3512
4233
  }
3513
4234
  /**
@@ -3560,9 +4281,11 @@ function mountMfaRoutes(opts) {
3560
4281
  const { secret, uri } = generateTotpSecret(issuer, user.email);
3561
4282
  const encryptedSecret = encryptTotpSecret(secret);
3562
4283
  const factor = await authRepo.createMfaFactor(user.id, "totp", encryptedSecret, friendlyName);
3563
- const codes = generateRecoveryCodes(10);
3564
- const codeHashes = codes.map(hashRecoveryCode);
3565
- await authRepo.createRecoveryCodes(user.id, codeHashes);
4284
+ let codes = null;
4285
+ if (!await authRepo.hasVerifiedMfaFactors(user.id)) {
4286
+ codes = generateRecoveryCodes(10);
4287
+ await authRepo.createRecoveryCodes(user.id, codes.map(hashRecoveryCode));
4288
+ }
3566
4289
  return c.json({
3567
4290
  factor: {
3568
4291
  id: factor.id,
@@ -3578,6 +4301,24 @@ function mountMfaRoutes(opts) {
3578
4301
  }, 201);
3579
4302
  });
3580
4303
  /**
4304
+ * POST /auth/mfa/recovery-codes
4305
+ * Replace the account's recovery codes with ten new ones, shown once.
4306
+ *
4307
+ * The deliberate way to get new codes — after using several, or after
4308
+ * losing the printout. Needs `aal2` once a factor is verified, as every
4309
+ * factor change does: whoever holds only the password must not be able to
4310
+ * swap the codes for ones they know.
4311
+ */
4312
+ router.post("/mfa/recovery-codes", strictAuthLimiter, requireLiveSession, async (c) => {
4313
+ const userCtx = c.get("user");
4314
+ if (!userCtx) throw ApiError.unauthorized("Not authenticated");
4315
+ if (!await authRepo.hasVerifiedMfaFactors(userCtx.uid)) throw ApiError.badRequest("This account has no verified second factor, so recovery codes would recover nothing.", "MFA_NOT_ENROLLED");
4316
+ await requireStepUpForFactorChange(userCtx);
4317
+ const codes = generateRecoveryCodes(10);
4318
+ await authRepo.createRecoveryCodes(userCtx.uid, codes.map(hashRecoveryCode));
4319
+ return c.json({ recoveryCodes: codes });
4320
+ });
4321
+ /**
3581
4322
  * POST /auth/mfa/verify
3582
4323
  * Verify TOTP code to complete MFA enrollment
3583
4324
  */
@@ -3613,7 +4354,7 @@ function mountMfaRoutes(opts) {
3613
4354
  const factor = await authRepo.getMfaFactorById(factorId);
3614
4355
  if (!factor || factor.uid !== principal.uid) throw ApiError.notFound("MFA factor not found");
3615
4356
  if (!factor.verified) throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
3616
- const ipAddress = c.req.header("x-forwarded-for") || "unknown";
4357
+ const ipAddress = requestClientAddress(c);
3617
4358
  const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
3618
4359
  return c.json({
3619
4360
  challengeId: challenge.id,
@@ -3662,10 +4403,17 @@ function mountMfaRoutes(opts) {
3662
4403
  throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
3663
4404
  }
3664
4405
  await authRepo.verifyMfaChallenge(challengeId);
3665
- const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(principal.uid, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown", {
4406
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(principal.uid, c.req.header("user-agent") || "unknown", requestClientAddress(c), {
3666
4407
  skipMfaGate: true,
3667
- aal: "aal2"
4408
+ aal: "aal2",
4409
+ method: "mfa"
3668
4410
  });
4411
+ if (ops.onAuthenticated) {
4412
+ const account = await authRepo.getUserById(principal.uid);
4413
+ if (account) ops.onAuthenticated(account, "mfa").catch((err) => {
4414
+ logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
4415
+ });
4416
+ }
3669
4417
  if (ops.onMfaVerified) ops.onMfaVerified(principal.uid, factor.id).catch((err) => {
3670
4418
  logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
3671
4419
  });
@@ -3841,9 +4589,233 @@ function isAnonymousAuthOpen(policy) {
3841
4589
  return !!policy.allowAnonymous;
3842
4590
  }
3843
4591
  //#endregion
4592
+ //#region src/auth/address-ownership.ts
4593
+ /**
4594
+ * What happens when the owner of an address proves it for the first time.
4595
+ *
4596
+ * Nothing verifies the address an account is made with: `POST /auth/register`
4597
+ * takes whatever it is given, and so does a sign-in through a provider that
4598
+ * does not vouch for the address (Spotify and Facebook never do; Discord and
4599
+ * multi-tenant Microsoft sometimes do not). So anyone can make an account for
4600
+ * someone else's address and leave a way in on it — a password, or the
4601
+ * provider identity they signed up with — then wait for the owner.
4602
+ *
4603
+ * The owner turns up by proving the address: a magic link, an email code or a
4604
+ * password reset, each read out of their inbox. That proof used to mark the
4605
+ * account verified and leave the attacker's way in where it was, so the owner
4606
+ * and the attacker shared the account from then on. Now the proof also removes
4607
+ * everything on the account that nobody proved: the password, every identity
4608
+ * whose provider did not vouch for this address, and every session.
4609
+ *
4610
+ * @module
4611
+ */
4612
+ /**
4613
+ * The profile stored with a linked OAuth identity.
4614
+ *
4615
+ * `emailVerified` is recorded so that a later proof of the address can tell an
4616
+ * identity whose provider vouched for it from one whose provider did not.
4617
+ */
4618
+ function identityProfileData(profile) {
4619
+ return {
4620
+ email: profile.email,
4621
+ emailVerified: profile.emailVerified === true
4622
+ };
4623
+ }
4624
+ /**
4625
+ * Did this identity's provider vouch for `email` when it was linked?
4626
+ *
4627
+ * Only whoever controls the inbox can hold a provider account that vouches for
4628
+ * the address, so such an identity is the owner's. An identity linked before
4629
+ * the flag was recorded reads as not vouching: it is detached, and if its
4630
+ * provider does vouch, the next sign-in with it links it again.
4631
+ */
4632
+ function identityVouchesForAddress(identity, email) {
4633
+ const profile = identity.profileData;
4634
+ return profile?.emailVerified === true && typeof profile.email === "string" && normalizeEmail(profile.email) === normalizeEmail(email);
4635
+ }
4636
+ /**
4637
+ * Settle an account whose address its owner has just proven, and mark it
4638
+ * verified.
4639
+ *
4640
+ * Call it only for an account that is not yet verified, from a route that has
4641
+ * just consumed something mailed to the address. It detaches every identity
4642
+ * whose provider did not vouch for the address, sets the password to
4643
+ * `passwordHash` — the one the owner just chose on a reset, or `null` to remove
4644
+ * the one somebody else may have registered with — and ends every session, in
4645
+ * that order and before the account is marked verified.
4646
+ *
4647
+ * A repository that cannot detach an identity makes this refuse with 409 when
4648
+ * there is one to detach. Marking the account verified with the identity still
4649
+ * on it is the takeover itself, and leaving the account unverified while
4650
+ * signing the owner in would share the account all the same.
4651
+ */
4652
+ async function confirmAddressOwnership(authRepo, user, passwordHash, options) {
4653
+ if (options?.everythingProven) {
4654
+ await authRepo.setEmailVerified(user.id, true);
4655
+ return {
4656
+ removedPassword: false,
4657
+ removedProviders: []
4658
+ };
4659
+ }
4660
+ const unproven = (await authRepo.getUserIdentities(user.id)).filter((identity) => !identityVouchesForAddress(identity, user.email));
4661
+ if (unproven.length > 0) {
4662
+ if (typeof authRepo.unlinkUserIdentity !== "function") {
4663
+ logger.error("[Security Audit] Proof of address refused: the account has sign-in methods nobody proved, and this auth repository cannot remove them", {
4664
+ eventType: "auth.address_proof.refused",
4665
+ uid: user.id,
4666
+ providers: unproven.map((identity) => identity.provider)
4667
+ });
4668
+ throw ApiError.conflict("This account has sign-in methods that were added before its email address was verified, and this backend cannot remove them. Ask an administrator to review the account.", "UNVERIFIED_IDENTITIES");
4669
+ }
4670
+ for (const identity of unproven) await authRepo.unlinkUserIdentity(user.id, identity.provider, identity.providerId);
4671
+ }
4672
+ await replaceUserPassword(authRepo, user.id, passwordHash);
4673
+ await authRepo.setEmailVerified(user.id, true);
4674
+ const outcome = {
4675
+ removedPassword: Boolean(user.passwordHash) && passwordHash === null,
4676
+ removedProviders: unproven.map((identity) => identity.provider)
4677
+ };
4678
+ if (unproven.length > 0 || user.passwordHash) logger.info("[Security Audit] First proof of address removed sign-in methods nobody proved", {
4679
+ eventType: "auth.address_proof.cleared",
4680
+ uid: user.id,
4681
+ removedProviders: outcome.removedProviders,
4682
+ replacedPassword: Boolean(user.passwordHash) && passwordHash !== user.passwordHash
4683
+ });
4684
+ return outcome;
4685
+ }
4686
+ //#endregion
4687
+ //#region src/auth/deliverable-address.ts
4688
+ /**
4689
+ * Addresses no mail can reach: the synthetic ones a guest and an X (Twitter)
4690
+ * account are given, because `email` is NOT NULL. Nothing is mailed to them,
4691
+ * and no account may move onto one.
4692
+ */
4693
+ function isDeliverableAddress(email) {
4694
+ const domain = email.slice(email.lastIndexOf("@") + 1).toLowerCase();
4695
+ return domain !== "anonymous.local" && domain !== "twitter.placeholder.rebase";
4696
+ }
4697
+ //#endregion
4698
+ //#region src/auth/email-change-routes.ts
4699
+ /** How long an address-change link stays usable. */
4700
+ var EMAIL_CHANGE_TTL_MS = 1440 * 60 * 1e3;
4701
+ function mountEmailChangeRoutes(opts) {
4702
+ const { router, config, ops, parseBody, requireLiveSession } = opts;
4703
+ const authRepo = config.authRepo;
4704
+ const { emailService, emailConfig } = config;
4705
+ const changeSchema = z.object({ newEmail: z.string().email("Invalid email address").max(255) });
4706
+ const confirmSchema = z.object({ token: z.string().min(1, "Token is required") });
4707
+ /** The repository's address-change methods, or a 501 saying it has none. */
4708
+ function emailChangeStore() {
4709
+ const { setPendingEmailChange, findPendingEmailChange, applyPendingEmailChange } = authRepo;
4710
+ if (!setPendingEmailChange || !findPendingEmailChange || !applyPendingEmailChange) throw new ApiError(501, "NOT_SUPPORTED", "This backend's auth repository cannot change an account's email address.");
4711
+ return {
4712
+ setPendingEmailChange: setPendingEmailChange.bind(authRepo),
4713
+ findPendingEmailChange: findPendingEmailChange.bind(authRepo),
4714
+ applyPendingEmailChange: applyPendingEmailChange.bind(authRepo)
4715
+ };
4716
+ }
4717
+ /**
4718
+ * POST /auth/change-email { newEmail }
4719
+ *
4720
+ * Two limiters, as on `/send-verification`: per address of the caller's
4721
+ * machine, and per account, which is what bounds the mail one account can
4722
+ * have sent to addresses of its choosing.
4723
+ */
4724
+ router.post("/change-email", strictAuthLimiter, requireLiveSession, verificationEmailLimiter, async (c) => {
4725
+ const userCtx = c.get("user");
4726
+ if (!userCtx) throw ApiError.unauthorized("Not authenticated");
4727
+ const { newEmail } = parseBody(changeSchema, await c.req.json());
4728
+ const store = emailChangeStore();
4729
+ if (!emailService?.isConfigured()) throw ApiError.serviceUnavailable("Email service not configured. The confirmation link cannot be sent.", "EMAIL_NOT_CONFIGURED");
4730
+ const user = await authRepo.getUserById(userCtx.uid);
4731
+ if (!user) throw ApiError.notFound("User not found");
4732
+ if (user.isAnonymous) throw ApiError.forbidden("A guest has no address to change. Make it an account first (POST /auth/anonymous/link).", "ANONYMOUS_USER");
4733
+ if (userCtx.aal !== "aal2" && await authRepo.hasVerifiedMfaFactors(user.id)) throw ApiError.forbidden("This account has a second factor. Sign in with it before changing the email address.", "AAL2_REQUIRED");
4734
+ const email = normalizeEmail(newEmail);
4735
+ if (email === normalizeEmail(user.email)) throw ApiError.badRequest("That is already this account's email address.", "EMAIL_UNCHANGED");
4736
+ if (!isDeliverableAddress(email)) throw ApiError.conflict("No mail reaches that address.", "UNDELIVERABLE_ADDRESS");
4737
+ if (await authRepo.getUserByEmail(email)) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
4738
+ if (ops.beforeEmailChange) await ops.beforeEmailChange(user, email);
4739
+ const token = generateSecureToken();
4740
+ await store.setPendingEmailChange(user.id, {
4741
+ email,
4742
+ tokenHash: hashToken(token)
4743
+ });
4744
+ const confirmUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/confirm-email-change?token=${token}`;
4745
+ const { appName, logoUrl } = resolveEmailBranding(emailConfig);
4746
+ const recipient = {
4747
+ email: user.email,
4748
+ displayName: user.displayName
4749
+ };
4750
+ const confirmation = emailConfig?.templates?.emailChange ? emailConfig.templates.emailChange(confirmUrl, recipient, email) : getEmailChangeTemplate(confirmUrl, recipient, email, appName, logoUrl);
4751
+ await emailService.send({
4752
+ to: email,
4753
+ subject: confirmation.subject,
4754
+ html: confirmation.html,
4755
+ text: confirmation.text
4756
+ });
4757
+ if (isDeliverableAddress(user.email)) {
4758
+ const notice = emailConfig?.templates?.emailChangeNotice ? emailConfig.templates.emailChangeNotice(recipient, email) : getEmailChangeNoticeTemplate(recipient, email, appName, logoUrl);
4759
+ emailService.send({
4760
+ to: user.email,
4761
+ subject: notice.subject,
4762
+ html: notice.html,
4763
+ text: notice.text
4764
+ }).catch((err) => {
4765
+ logger.error("Failed to send the email change notice", { error: err instanceof Error ? err.message : err });
4766
+ });
4767
+ }
4768
+ logger.info("[Security Audit] Email change requested", {
4769
+ eventType: "auth.email_change.requested",
4770
+ uid: user.id
4771
+ });
4772
+ return c.json({
4773
+ success: true,
4774
+ pendingEmail: email,
4775
+ expiresAt: new Date(Date.now() + EMAIL_CHANGE_TTL_MS).toISOString()
4776
+ });
4777
+ });
4778
+ /**
4779
+ * POST /auth/confirm-email-change { token }
4780
+ *
4781
+ * No session: the link is often opened on another device, and it proves
4782
+ * the new inbox, which is all this needs.
4783
+ */
4784
+ router.post("/confirm-email-change", strictAuthLimiter, async (c) => {
4785
+ const { token } = parseBody(confirmSchema, await c.req.json());
4786
+ const store = emailChangeStore();
4787
+ const tokenHash = hashToken(token);
4788
+ const invalid = () => ApiError.badRequest("Invalid or expired email change link", "INVALID_TOKEN");
4789
+ const found = await store.findPendingEmailChange(tokenHash);
4790
+ if (!found || Date.now() - found.change.sentAt.getTime() > 864e5) throw invalid();
4791
+ const { user } = found;
4792
+ const previousEmail = user.email;
4793
+ const vouchingForOld = (await authRepo.getUserIdentities(user.id)).filter((identity) => identityVouchesForAddress(identity, previousEmail));
4794
+ if (vouchingForOld.length > 0 && typeof authRepo.unlinkUserIdentity !== "function") throw ApiError.conflict("This account signs in through a provider that vouched for its current address, and this backend cannot detach it. Ask an administrator to change the address.", "UNVERIFIED_IDENTITIES");
4795
+ const updated = await store.applyPendingEmailChange(user.id, tokenHash);
4796
+ if (!updated) throw invalid();
4797
+ for (const identity of vouchingForOld) await authRepo.unlinkUserIdentity?.(user.id, identity.provider, identity.providerId);
4798
+ await authRepo.deleteAllPasswordResetTokensForUser(user.id);
4799
+ logger.info("[Security Audit] Email address changed", {
4800
+ eventType: "auth.email_change.confirmed",
4801
+ uid: user.id,
4802
+ detachedProviders: vouchingForOld.map((identity) => identity.provider)
4803
+ });
4804
+ return c.json({
4805
+ success: true,
4806
+ user: {
4807
+ uid: updated.id,
4808
+ email: updated.email,
4809
+ emailVerified: true
4810
+ },
4811
+ removedProviders: vouchingForOld.map((identity) => identity.provider)
4812
+ });
4813
+ });
4814
+ }
4815
+ //#endregion
3844
4816
  //#region src/auth/session-routes.ts
3845
4817
  function mountSessionRoutes(opts) {
3846
- const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook, requireLiveSession, registerCaptcha } = opts;
4818
+ const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook, requireLiveSession, registerCaptcha, sendVerificationMail } = opts;
3847
4819
  const authRepo = config.authRepo;
3848
4820
  /**
3849
4821
  * Refuse the anonymous routes unless the deployment opted in.
@@ -3861,6 +4833,28 @@ function mountSessionRoutes(opts) {
3861
4833
  })) return;
3862
4834
  throw ApiError.forbidden(config.disableSelfRegistration ? "Anonymous sign-in is disabled: disableSelfRegistration blocks it." : "Anonymous sign-in is disabled. Set `auth.allowAnonymous: true` to enable it.", "ANONYMOUS_AUTH_DISABLED");
3863
4835
  }
4836
+ /**
4837
+ * How the caller's session was signed in, as `providerId` reports it.
4838
+ *
4839
+ * Read off the session's refresh tokens, which carry it from sign-in
4840
+ * through every rotation, found by the access token's `sid`. A token from
4841
+ * before `sid`, or a session whose rows say nothing, reads `"password"` —
4842
+ * what `/me` always answered.
4843
+ */
4844
+ async function sessionMethod(userCtx) {
4845
+ if (!userCtx.sid) return "password";
4846
+ return (await authRepo.listRefreshTokensForUser(userCtx.uid).catch(() => [])).find((token) => (token.sessionId ?? token.id) === userCtx.sid && token.method)?.method ?? "password";
4847
+ }
4848
+ /**
4849
+ * The address the account is moving to, while its link is still live —
4850
+ * so a settings screen opened later still says a change is waiting.
4851
+ * `null` otherwise, and on a repository that cannot change addresses.
4852
+ */
4853
+ async function pendingEmail(uid) {
4854
+ const change = await authRepo.getPendingEmailChange?.(uid).catch(() => null);
4855
+ if (!change || Date.now() - change.sentAt.getTime() > 864e5) return null;
4856
+ return change.email;
4857
+ }
3864
4858
  const logoutSchema = z.object({ refreshToken: z.string().optional() });
3865
4859
  const linkSchema = z.object({
3866
4860
  email: z.string().email("Invalid email address").max(255),
@@ -3877,18 +4871,25 @@ function mountSessionRoutes(opts) {
3877
4871
  router.post("/logout", async (c) => {
3878
4872
  const body = await c.req.json().catch(() => ({}));
3879
4873
  const refreshToken = readRefreshToken(c, parseBody(logoutSchema, body), config.cookieAuth);
4874
+ let signedOutUid;
3880
4875
  if (refreshToken) {
3881
4876
  const tokenHash = await hashRefreshToken(refreshToken);
3882
- const sessionId = (await authRepo.findRefreshTokenByHash(tokenHash).catch(() => null))?.sessionId;
4877
+ const stored = await authRepo.findRefreshTokenByHash(tokenHash).catch(() => null);
4878
+ signedOutUid = stored?.uid;
4879
+ const sessionId = stored?.sessionId;
3883
4880
  if (sessionId && authRepo.revokeRefreshTokenSession) await authRepo.revokeRefreshTokenSession(sessionId);
3884
4881
  else await authRepo.deleteRefreshToken(tokenHash);
3885
4882
  }
3886
4883
  clearRefreshCookie(c, config.cookieAuth);
3887
- const accessToken = extractBearerToken(c.req.header("authorization"));
3888
- if (ops.afterLogout && accessToken !== void 0) {
3889
- const { verifyAccessToken } = await import("./jwt-DnQHNFCl.js").then((n) => n.h);
3890
- const payload = await verifyAccessToken(accessToken);
3891
- if (payload) ops.afterLogout(payload.uid).catch((err) => {
4884
+ if (ops.afterLogout) {
4885
+ if (!signedOutUid) {
4886
+ const accessToken = extractBearerToken(c.req.header("authorization"));
4887
+ if (accessToken !== void 0) {
4888
+ const { verifyAccessToken } = await import("./jwt-R6bSPMjk.js").then((n) => n._);
4889
+ signedOutUid = (await verifyAccessToken(accessToken))?.uid;
4890
+ }
4891
+ }
4892
+ if (signedOutUid) ops.afterLogout(signedOutUid).catch((err) => {
3892
4893
  logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
3893
4894
  });
3894
4895
  }
@@ -3901,7 +4902,7 @@ function mountSessionRoutes(opts) {
3901
4902
  router.get("/sessions", requireLiveSession, async (c) => {
3902
4903
  const userCtx = c.get("user");
3903
4904
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
3904
- const currentRefreshToken = c.req.header("x-refresh-token");
4905
+ const currentRefreshToken = userCtx.sid ? void 0 : c.req.header("x-refresh-token");
3905
4906
  const currentTokenHash = currentRefreshToken ? await hashRefreshToken(currentRefreshToken) : null;
3906
4907
  const tokens = await authRepo.listRefreshTokensForUser(userCtx.uid);
3907
4908
  const bySession = /* @__PURE__ */ new Map();
@@ -3916,7 +4917,7 @@ function mountSessionRoutes(opts) {
3916
4917
  userAgent: s.userAgent,
3917
4918
  ipAddress: s.ipAddress,
3918
4919
  createdAt: s.sessionStartedAt ?? s.createdAt,
3919
- isCurrentSession: currentTokenHash ? tokens.some((t) => t.tokenHash === currentTokenHash && (t.sessionId ?? t.id) === (s.sessionId ?? s.id)) : false
4920
+ isCurrentSession: userCtx.sid ? (s.sessionId ?? s.id) === userCtx.sid : currentTokenHash ? tokens.some((t) => t.tokenHash === currentTokenHash && (t.sessionId ?? t.id) === (s.sessionId ?? s.id)) : false
3920
4921
  }));
3921
4922
  return c.json({ sessions: mappedSessions });
3922
4923
  });
@@ -3964,10 +4965,11 @@ function mountSessionRoutes(opts) {
3964
4965
  email: result.user.email,
3965
4966
  displayName: result.user.displayName,
3966
4967
  photoURL: result.user.photoUrl,
3967
- providerId: "password",
4968
+ providerId: await sessionMethod(userCtx),
3968
4969
  isAnonymous: result.user.isAnonymous ?? false,
3969
4970
  emailVerified: result.user.emailVerified,
3970
- roles: result.roles.map((r) => r.id),
4971
+ pendingEmail: await pendingEmail(userCtx.uid),
4972
+ roles: result.roles,
3971
4973
  metadata: result.user.metadata ?? {}
3972
4974
  } });
3973
4975
  });
@@ -4008,10 +5010,11 @@ function mountSessionRoutes(opts) {
4008
5010
  email: result.user.email,
4009
5011
  displayName: result.user.displayName,
4010
5012
  photoURL: result.user.photoUrl,
4011
- providerId: "password",
5013
+ providerId: await sessionMethod(userCtx),
4012
5014
  isAnonymous: result.user.isAnonymous ?? false,
4013
5015
  emailVerified: result.user.emailVerified,
4014
- roles: result.roles.map((r) => r.id),
5016
+ pendingEmail: await pendingEmail(userCtx.uid),
5017
+ roles: result.roles,
4015
5018
  metadata: result.user.metadata ?? {}
4016
5019
  } });
4017
5020
  });
@@ -4029,7 +5032,7 @@ function mountSessionRoutes(opts) {
4029
5032
  if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
4030
5033
  const user = await authRepo.createUser(createData);
4031
5034
  if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
4032
- const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
5035
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "anonymous" });
4033
5036
  if (ops.afterUserCreate) ops.afterUserCreate(user).catch((err) => {
4034
5037
  logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
4035
5038
  });
@@ -4053,6 +5056,10 @@ function mountSessionRoutes(opts) {
4053
5056
  */
4054
5057
  router.post("/anonymous/link", strictAuthLimiter, requireLiveSession, ...registerCaptcha ? [registerCaptcha] : [], async (c) => {
4055
5058
  assertAnonymousAuthOpen();
5059
+ if (!isSteadyStateRegistrationOpen({
5060
+ disableSelfRegistration: config.disableSelfRegistration,
5061
+ allowRegistration: config.allowRegistration ?? false
5062
+ })) throw ApiError.forbidden("A guest cannot become an account here: registration is disabled on this backend.", "REGISTRATION_DISABLED");
4056
5063
  const userCtx = c.get("user");
4057
5064
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
4058
5065
  const user = await authRepo.getUserById(userCtx.uid);
@@ -4074,94 +5081,61 @@ function mountSessionRoutes(opts) {
4074
5081
  isAnonymous: false
4075
5082
  });
4076
5083
  if (!updatedUser) throw ApiError.notFound("User not found");
4077
- const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
5084
+ sendVerificationMail?.(updatedUser);
5085
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "password" });
4078
5086
  const authResponse = buildAuthResponse(updatedUser, roleIds, accessToken, refreshToken, "password");
4079
5087
  const transformedResponse = await applyTransformHook(authResponse, "anonymous", c.req.raw, user.id);
4080
5088
  return c.json(redactRefreshToken(transformedResponse, c, refreshToken, config.cookieAuth));
4081
5089
  });
4082
5090
  }
4083
5091
  //#endregion
4084
- //#region src/auth/address-ownership.ts
5092
+ //#region src/auth/passwordless-signup.ts
4085
5093
  /**
4086
- * What happens when the owner of an address proves it for the first time.
4087
- *
4088
- * Nothing verifies the address an account is made with: `POST /auth/register`
4089
- * takes whatever it is given, and so does a sign-in through a provider that
4090
- * does not vouch for the address (Spotify and Facebook never do; Discord and
4091
- * multi-tenant Microsoft sometimes do not). So anyone can make an account for
4092
- * someone else's address and leave a way in on it — a password, or the
4093
- * provider identity they signed up with — then wait for the owner.
5094
+ * Sign-up by magic link or email code (`auth.magicLinkCreatesUsers`).
4094
5095
  *
4095
- * The owner turns up by proving the address: a magic link, an email code or a
4096
- * password reset, each read out of their inbox. That proof used to mark the
4097
- * account verified and leave the attacker's way in where it was, so the owner
4098
- * and the attacker shared the account from then on. Now the proof also removes
4099
- * everything on the account that nobody proved: the password, every identity
4100
- * whose provider did not vouch for this address, and every session.
5096
+ * Both passwordless doors refuse an address with no account — silently, so
5097
+ * they do not say which addresses have one. A passwordless-only app then had
5098
+ * no way to create accounts at all. With the option on, and registration open,
5099
+ * an unknown address gets an account at request time — no password, unverified
5100
+ * — and the link or code mailed to it is what proves the address and signs it
5101
+ * in, exactly as for an existing account. Supabase's `shouldCreateUser`.
4101
5102
  *
4102
5103
  * @module
4103
5104
  */
4104
5105
  /**
4105
- * The profile stored with a linked OAuth identity.
4106
- *
4107
- * `emailVerified` is recorded so that a later proof of the address can tell an
4108
- * identity whose provider vouched for it from one whose provider did not.
4109
- */
4110
- function identityProfileData(profile) {
4111
- return {
4112
- email: profile.email,
4113
- emailVerified: profile.emailVerified === true
4114
- };
4115
- }
4116
- /**
4117
- * Did this identity's provider vouch for `email` when it was linked?
4118
- *
4119
- * Only whoever controls the inbox can hold a provider account that vouches for
4120
- * the address, so such an identity is the owner's. An identity linked before
4121
- * the flag was recorded reads as not vouching: it is detached, and if its
4122
- * provider does vouch, the next sign-in with it links it again.
4123
- */
4124
- function identityVouchesForAddress(identity, email) {
4125
- const profile = identity.profileData;
4126
- return profile?.emailVerified === true && typeof profile.email === "string" && normalizeEmail(profile.email) === normalizeEmail(email);
4127
- }
4128
- /**
4129
- * Settle an account whose address its owner has just proven, and mark it
4130
- * verified.
4131
- *
4132
- * Call it only for an account that is not yet verified, from a route that has
4133
- * just consumed something mailed to the address. It detaches every identity
4134
- * whose provider did not vouch for the address, sets the password to
4135
- * `passwordHash` — the one the owner just chose on a reset, or `null` to remove
4136
- * the one somebody else may have registered with — and ends every session, in
4137
- * that order and before the account is marked verified.
5106
+ * The account a passwordless request for `email` should mail, creating it
5107
+ * when the deployment lets passwordless requests sign people up. `null` when
5108
+ * there is none and none may be made — the caller answers as for any unknown
5109
+ * address.
4138
5110
  *
4139
- * A repository that cannot detach an identity makes this refuse with 409 when
4140
- * there is one to detach. Marking the account verified with the identity still
4141
- * on it is the takeover itself, and leaving the account unverified while
4142
- * signing the owner in would share the account all the same.
5111
+ * Registration's controls hold: the kill switch, `allowRegistration` (with no
5112
+ * first-user exception: the first admin is made by registering or by the
5113
+ * operator, never by whoever asks for a link first), `beforeUserCreate`,
5114
+ * `afterUserCreate` and the default role.
4143
5115
  */
4144
- async function confirmAddressOwnership(authRepo, user, passwordHash) {
4145
- const unproven = (await authRepo.getUserIdentities(user.id)).filter((identity) => !identityVouchesForAddress(identity, user.email));
4146
- if (unproven.length > 0) {
4147
- if (typeof authRepo.unlinkUserIdentity !== "function") {
4148
- logger.error("[Security Audit] Proof of address refused: the account has sign-in methods nobody proved, and this auth repository cannot remove them", {
4149
- eventType: "auth.address_proof.refused",
4150
- uid: user.id,
4151
- providers: unproven.map((identity) => identity.provider)
4152
- });
4153
- throw ApiError.conflict("This account has sign-in methods that were added before its email address was verified, and this backend cannot remove them. Ask an administrator to review the account.", "UNVERIFIED_IDENTITIES");
4154
- }
4155
- for (const identity of unproven) await authRepo.unlinkUserIdentity(user.id, identity.provider, identity.providerId);
4156
- }
4157
- await replaceUserPassword(authRepo, user.id, passwordHash);
4158
- await authRepo.setEmailVerified(user.id, true);
4159
- if (unproven.length > 0 || user.passwordHash) logger.info("[Security Audit] First proof of address removed sign-in methods nobody proved", {
4160
- eventType: "auth.address_proof.cleared",
4161
- uid: user.id,
4162
- removedProviders: unproven.map((identity) => identity.provider),
4163
- replacedPassword: Boolean(user.passwordHash)
5116
+ async function accountForPasswordlessRequest(config, ops, email) {
5117
+ const authRepo = config.authRepo;
5118
+ const existing = await authRepo.getUserByEmail(email);
5119
+ if (existing || !config.magicLinkCreatesUsers) return existing;
5120
+ if (!isSteadyStateRegistrationOpen({
5121
+ disableSelfRegistration: config.disableSelfRegistration,
5122
+ allowRegistration: config.allowRegistration ?? false
5123
+ })) return null;
5124
+ let createData = { email: normalizeEmail(email) };
5125
+ if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
5126
+ let user;
5127
+ try {
5128
+ user = await authRepo.createUser(createData);
5129
+ } catch (error) {
5130
+ const made = await authRepo.getUserByEmail(email);
5131
+ if (made) return made;
5132
+ throw error;
5133
+ }
5134
+ if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
5135
+ if (ops.afterUserCreate) ops.afterUserCreate(user).catch((err) => {
5136
+ logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
4164
5137
  });
5138
+ return user;
4165
5139
  }
4166
5140
  //#endregion
4167
5141
  //#region src/auth/magic-link-routes.ts
@@ -4200,11 +5174,11 @@ function mountMagicLinkRoutes(deps) {
4200
5174
  const startedAt = Date.now();
4201
5175
  const { email } = parseBody(magicLinkSchema, await c.req.json());
4202
5176
  if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. Magic link login is not available.", "EMAIL_NOT_CONFIGURED");
4203
- const user = await authRepo.getUserByEmail(email);
4204
5177
  if (ops.beforeLogin) await ops.beforeLogin(email, "magic-link");
5178
+ const user = await accountForPasswordlessRequest(config, ops, email);
4205
5179
  if (user) {
4206
5180
  const token = generateSecureToken();
4207
- const tokenHash = hashToken$1(token);
5181
+ const tokenHash = hashToken(token);
4208
5182
  const expiresAt = getMagicLinkExpiry();
4209
5183
  await authRepo.createMagicLinkToken(user.id, tokenHash, expiresAt);
4210
5184
  const magicLinkUrl = `${resolveEmailLinkBase(emailConfig, "magicLink")}/auth/magic-link?token=${token}`;
@@ -4237,7 +5211,7 @@ function mountMagicLinkRoutes(deps) {
4237
5211
  */
4238
5212
  router.post("/magic-link/verify", strictAuthLimiter, async (c) => {
4239
5213
  const { token } = parseBody(verifyMagicLinkSchema, await c.req.json());
4240
- const tokenHash = hashToken$1(token);
5214
+ const tokenHash = hashToken(token);
4241
5215
  const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
4242
5216
  if (!storedToken) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
4243
5217
  await authRepo.markMagicLinkTokenUsed(tokenHash);
@@ -4251,7 +5225,7 @@ function mountMagicLinkRoutes(deps) {
4251
5225
  passwordHash: null
4252
5226
  };
4253
5227
  }
4254
- const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
5228
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "magic-link" });
4255
5229
  if (ops.onAuthenticated) ops.onAuthenticated(user, "magic-link").catch((err) => {
4256
5230
  logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
4257
5231
  });
@@ -4394,11 +5368,11 @@ function mountOtpRoutes(deps) {
4394
5368
  const startedAt = Date.now();
4395
5369
  const { email } = parseBody(requestSchema, await c.req.json());
4396
5370
  if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. One-time code login is not available.", "EMAIL_NOT_CONFIGURED");
4397
- const user = await authRepo.getUserByEmail(email);
4398
5371
  if (ops.beforeLogin) await ops.beforeLogin(email, "otp");
5372
+ const user = await accountForPasswordlessRequest(config, ops, email);
4399
5373
  if (user) {
4400
5374
  const code = generateOtpCode();
4401
- await authRepo.createMagicLinkToken(user.id, hashToken$1(otpTokenMaterial(user.email, code)), new Date(Date.now() + OTP_TTL_MS));
5375
+ await authRepo.createMagicLinkToken(user.id, hashToken(otpTokenMaterial(user.email, code)), new Date(Date.now() + OTP_TTL_MS));
4402
5376
  const { appName, logoUrl } = resolveEmailBranding(emailConfig);
4403
5377
  const templateFn = emailConfig?.templates?.emailOtp;
4404
5378
  const content = templateFn ? templateFn(code, {
@@ -4428,7 +5402,7 @@ function mountOtpRoutes(deps) {
4428
5402
  */
4429
5403
  router.post("/otp/verify", strictAuthLimiter, captureOtpEmail, otpVerificationLimiter, async (c) => {
4430
5404
  const { email, code } = parseBody(verifySchema, await c.req.json());
4431
- const tokenHash = hashToken$1(otpTokenMaterial(email, code));
5405
+ const tokenHash = hashToken(otpTokenMaterial(email, code));
4432
5406
  const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
4433
5407
  if (!storedToken) throw ApiError.badRequest("Invalid or expired code", "INVALID_CODE");
4434
5408
  await authRepo.markMagicLinkTokenUsed(tokenHash);
@@ -4442,7 +5416,7 @@ function mountOtpRoutes(deps) {
4442
5416
  passwordHash: null
4443
5417
  };
4444
5418
  }
4445
- const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
5419
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "otp" });
4446
5420
  if (ops.onAuthenticated) ops.onAuthenticated(user, "otp").catch((err) => {
4447
5421
  logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
4448
5422
  });
@@ -4519,6 +5493,7 @@ function canonicalRedirectUri(uri) {
4519
5493
  }
4520
5494
  //#endregion
4521
5495
  //#region src/auth/routes.ts
5496
+ var REFRESH_TOKEN_REUSE_POLICIES = ["reject", "revoke-session"];
4522
5497
  /**
4523
5498
  * Helper to build standard auth response output
4524
5499
  */
@@ -4549,7 +5524,13 @@ function getPasswordResetExpiry() {
4549
5524
  return new Date(Date.now() + 3600 * 1e3);
4550
5525
  }
4551
5526
  function createAuthRoutes(config) {
4552
- if (config.defaultRole && isAdministrativeRole(config.defaultRole)) throw new Error(`CRITICAL SECURITY ERROR: defaultRole cannot be '${config.defaultRole}'. Administrative privilege escalation via registration is strictly forbidden (administrative roles: ${ADMINISTRATIVE_ROLES.join(", ")}). Use the POST /admin/bootstrap endpoint to promote the initial administrator.`);
5527
+ if (config.refreshTokenReuse !== void 0 && !REFRESH_TOKEN_REUSE_POLICIES.includes(config.refreshTokenReuse)) throw new Error(`auth.refreshTokenReuse is "${String(config.refreshTokenReuse)}"; it must be one of ${REFRESH_TOKEN_REUSE_POLICIES.map((p) => `"${p}"`).join(", ")}.`);
5528
+ if (config.refreshTokenReuseIntervalSeconds !== void 0 && !(Number.isFinite(config.refreshTokenReuseIntervalSeconds) && config.refreshTokenReuseIntervalSeconds >= 0)) throw new Error(`auth.refreshTokenReuseIntervalSeconds is ${String(config.refreshTokenReuseIntervalSeconds)}; give a number of seconds, 0 or more.`);
5529
+ if (config.defaultRole) {
5530
+ const declared = getAccessModel().roles;
5531
+ const adminPlane = config.defaultRole === "admin" ? ["every scope"] : Object.prototype.hasOwnProperty.call(declared, config.defaultRole) ? declared[config.defaultRole].scopes.filter((scope) => isAdminScope(parseScope(scope)?.scope ?? "")) : [];
5532
+ if (adminPlane.length > 0) throw new Error(`CRITICAL SECURITY ERROR: defaultRole cannot be '${config.defaultRole}': every registrant would hold ${adminPlane.join(", ")}. Give the default role no admin-plane scopes, and use the POST /admin/bootstrap endpoint to promote the initial administrator.`);
5533
+ }
4553
5534
  const captcha = buildCaptchaMiddlewares(config.captcha);
4554
5535
  const router = new Hono();
4555
5536
  router.onError(errorHandler);
@@ -4602,11 +5583,16 @@ function createAuthRoutes(config) {
4602
5583
  token: z.string().min(1, "Token is required"),
4603
5584
  password: z.string().min(1, "Password is required").max(128)
4604
5585
  });
5586
+ const verifyEmailSchema = z.object({
5587
+ token: z.string().min(1, "Token is required"),
5588
+ password: z.string().min(1).max(128).optional(),
5589
+ removeUnproven: z.boolean().optional()
5590
+ });
4605
5591
  const changePasswordSchema = z.object({
4606
5592
  oldPassword: z.string().min(1, "Old password is required").max(128),
4607
5593
  newPassword: z.string().min(1, "New password is required").max(128)
4608
5594
  });
4609
- const refreshSchema = z.object({ refreshToken: z.string().min(1).optional() });
5595
+ const refreshSchema = z.object({ refreshToken: z.string().optional() });
4610
5596
  /** Parse a Zod schema against the request body, throwing ApiError on failure */
4611
5597
  function parseBody(schema, body) {
4612
5598
  const result = schema.safeParse(body);
@@ -4644,11 +5630,11 @@ function createAuthRoutes(config) {
4644
5630
  * Send welcome email to a newly registered user (fire-and-forget).
4645
5631
  */
4646
5632
  function sendWelcomeEmail(user) {
4647
- if (!isEmailConfigured()) return;
5633
+ if (!isEmailConfigured() || !isDeliverableAddress(user.email)) return;
4648
5634
  const { appName, logoUrl } = resolveEmailBranding(emailConfig);
4649
5635
  const loginUrl = resolveEmailLinkBase(emailConfig, "resetPassword");
4650
5636
  const templateFn = emailConfig?.templates?.welcomeEmail;
4651
- const emailContent = templateFn ? templateFn(user, appName) : getWelcomeEmailTemplate(user, appName, loginUrl ? `${loginUrl}/app` : void 0, logoUrl);
5637
+ const emailContent = templateFn ? templateFn(user, appName) : getWelcomeEmailTemplate(user, appName, loginUrl || void 0, logoUrl);
4652
5638
  emailService.send({
4653
5639
  to: user.email,
4654
5640
  subject: emailContent.subject,
@@ -4659,6 +5645,53 @@ function createAuthRoutes(config) {
4659
5645
  });
4660
5646
  }
4661
5647
  /**
5648
+ * Mail a verification link for `user`'s address, and record its token.
5649
+ *
5650
+ * Sent at registration as well as on request, so a self-registered
5651
+ * account proves its address on its owner's terms — and keeps the
5652
+ * password its owner chose, which a later first proof by link or code
5653
+ * would otherwise have to remove (see `confirmAddressOwnership`).
5654
+ * Returns whether a mail was handed to the email service.
5655
+ */
5656
+ async function sendVerificationMail(user) {
5657
+ if (!isEmailConfigured() || !isDeliverableAddress(user.email)) return false;
5658
+ const token = generateSecureToken();
5659
+ await authRepo.setVerificationToken(user.id, hashToken(token));
5660
+ const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
5661
+ const { appName, logoUrl } = resolveEmailBranding(emailConfig);
5662
+ const templateFn = emailConfig?.templates?.emailVerification;
5663
+ const emailContent = templateFn ? templateFn(verifyUrl, {
5664
+ email: user.email,
5665
+ displayName: user.displayName
5666
+ }) : getEmailVerificationTemplate(verifyUrl, {
5667
+ email: user.email,
5668
+ displayName: user.displayName
5669
+ }, appName, logoUrl);
5670
+ await emailService.send({
5671
+ to: user.email,
5672
+ subject: emailContent.subject,
5673
+ html: emailContent.html,
5674
+ text: emailContent.text
5675
+ });
5676
+ return true;
5677
+ }
5678
+ /** {@link sendVerificationMail}, not awaited: a failure is logged, never answered. */
5679
+ function sendVerificationMailInBackground(user) {
5680
+ sendVerificationMail(user).catch((err) => {
5681
+ logger.error("Failed to send verification email", { error: err instanceof Error ? err.message : err });
5682
+ });
5683
+ }
5684
+ /**
5685
+ * A hash of nothing anyone knows, made with this deployment's own
5686
+ * `hashPassword`, so verifying against it costs what verifying a real
5687
+ * password costs. Made once, on first use.
5688
+ */
5689
+ let dummyHash;
5690
+ function dummyPasswordHash() {
5691
+ dummyHash ??= ops.hashPassword(randomBytes$1(24).toString("hex"));
5692
+ return dummyHash;
5693
+ }
5694
+ /**
4662
5695
  * Is the account just created the only one there is?
4663
5696
  *
4664
5697
  * Two rows at most: the total says whether anyone else exists, and the
@@ -4684,11 +5717,12 @@ function createAuthRoutes(config) {
4684
5717
  * itself, which has just seen the second factor and mints at `aal2`.
4685
5718
  */
4686
5719
  async function createSessionAndTokens(uid, userAgent, ipAddress, options) {
4687
- if (!options?.skipMfaGate) await assertMfaSatisfied(authRepo, uid);
4688
- const aal = options?.aal ?? "aal1";
4689
- const roleIds = (await authRepo.getUserRoles(uid)).map((r) => r.id);
5720
+ if (!options.skipMfaGate) await assertMfaSatisfied(authRepo, uid);
5721
+ const aal = options.aal ?? "aal1";
5722
+ const roleIds = await authRepo.getUserRoleIds(uid);
4690
5723
  const sessionUser = await authRepo.getUserById(uid);
4691
5724
  const isAnonymous = sessionUser?.isAnonymous === true;
5725
+ if (sessionUser?.disabled) throw ApiError.forbidden("This account has been disabled. Contact an administrator.", "ACCOUNT_DISABLED");
4692
5726
  let customClaims;
4693
5727
  if (ops.customizeAccessToken) {
4694
5728
  const user = sessionUser;
@@ -4701,12 +5735,14 @@ function createAuthRoutes(config) {
4701
5735
  customClaims = await ops.customizeAccessToken(defaultClaims, user);
4702
5736
  }
4703
5737
  }
4704
- const accessToken = await generateAccessToken(uid, roleIds, aal, customClaims, isAnonymous);
5738
+ const sessionId = randomUUID$1();
5739
+ const accessToken = await generateAccessToken(uid, roleIds, aal, customClaims, isAnonymous, sessionId);
4705
5740
  const refreshToken = generateRefreshToken();
4706
5741
  await authRepo.createRefreshToken(uid, await hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, {
4707
- id: randomUUID$1(),
5742
+ id: sessionId,
4708
5743
  startedAt: /* @__PURE__ */ new Date(),
4709
- aal
5744
+ aal,
5745
+ method: options.method
4710
5746
  });
4711
5747
  return {
4712
5748
  roleIds,
@@ -4719,6 +5755,7 @@ function createAuthRoutes(config) {
4719
5755
  * Create a new account with email/password
4720
5756
  */
4721
5757
  router.post("/register", defaultAuthLimiter, ...captcha.register ? [captcha.register] : [], async (c) => {
5758
+ const startedAt = Date.now();
4722
5759
  const { email, password, displayName } = parseBody(registerSchema, await c.req.json());
4723
5760
  if (config.disableSelfRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
4724
5761
  let bootstrapRegistration = false;
@@ -4730,7 +5767,19 @@ function createAuthRoutes(config) {
4730
5767
  }
4731
5768
  const passwordValidation = ops.validatePasswordStrength(password);
4732
5769
  if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
4733
- if (await authRepo.getUserByEmail(email)) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
5770
+ const confirmFirst = () => notBefore$1(startedAt, 400, c.json({
5771
+ success: true,
5772
+ confirmationRequired: true,
5773
+ message: "Check your inbox: follow the link we sent to confirm your address, then sign in."
5774
+ }));
5775
+ const existingUser = await authRepo.getUserByEmail(email);
5776
+ if (existingUser) {
5777
+ if (config.requireEmailVerification) {
5778
+ if (!existingUser.emailVerified) sendVerificationMailInBackground(existingUser);
5779
+ return confirmFirst();
5780
+ }
5781
+ throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
5782
+ }
4734
5783
  const passwordHash = await ops.hashPassword(password);
4735
5784
  let createData = {
4736
5785
  email: normalizeEmail(email),
@@ -4749,11 +5798,21 @@ function createAuthRoutes(config) {
4749
5798
  if (isFirstUser) logger.warn("[Auth] First account registered in production without promotion; set REBASE_ADMIN_EMAIL/REBASE_ADMIN_PASSWORD or use the service key to assign admin.");
4750
5799
  if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
4751
5800
  }
4752
- const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
5801
+ if (config.requireEmailVerification) {
5802
+ sendVerificationMailInBackground(user);
5803
+ if (ops.afterUserCreate) try {
5804
+ await ops.afterUserCreate(user);
5805
+ } catch (err) {
5806
+ logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
5807
+ }
5808
+ return confirmFirst();
5809
+ }
5810
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "password" });
4753
5811
  sendWelcomeEmail({
4754
5812
  email: user.email,
4755
5813
  displayName: user.displayName
4756
5814
  });
5815
+ sendVerificationMailInBackground(user);
4757
5816
  if (ops.afterUserCreate) try {
4758
5817
  await ops.afterUserCreate(user);
4759
5818
  } catch (err) {
@@ -4778,8 +5837,10 @@ function createAuthRoutes(config) {
4778
5837
  if (!user) throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
4779
5838
  } else {
4780
5839
  user = await authRepo.getUserByEmail(email);
4781
- if (!user) throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
4782
- if (!user.passwordHash) throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
5840
+ if (!user || !user.passwordHash) {
5841
+ await ops.verifyPassword(password, await dummyPasswordHash());
5842
+ throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
5843
+ }
4783
5844
  if (!await ops.verifyPassword(password, user.passwordHash)) {
4784
5845
  logger.warn("[Security Audit] Auth login failure", {
4785
5846
  eventType: "auth.login.failure",
@@ -4789,7 +5850,12 @@ function createAuthRoutes(config) {
4789
5850
  throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
4790
5851
  }
4791
5852
  }
4792
- const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
5853
+ if (config.requireEmailVerification && !user.emailVerified) {
5854
+ const lastSent = user.emailVerificationSentAt?.getTime() ?? 0;
5855
+ if (Date.now() - lastSent > 6e4) sendVerificationMailInBackground(user);
5856
+ throw ApiError.forbidden("Confirm your email address first: follow the link we sent to it, then sign in.", "EMAIL_NOT_CONFIRMED");
5857
+ }
5858
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "password" });
4793
5859
  if (ops.onAuthenticated) ops.onAuthenticated(user, "login").catch((err) => {
4794
5860
  logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
4795
5861
  });
@@ -4842,6 +5908,7 @@ function createAuthRoutes(config) {
4842
5908
  router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
4843
5909
  const payload = parseBody(provider.schema, await c.req.json());
4844
5910
  const externalUser = await verifyProviderPayload(provider, payload);
5911
+ if (ops.beforeLogin) await ops.beforeLogin(normalizeEmail(externalUser.email), "oauth");
4845
5912
  let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
4846
5913
  if (!user) {
4847
5914
  user = await authRepo.getUserByEmail(externalUser.email);
@@ -4869,12 +5936,14 @@ function createAuthRoutes(config) {
4869
5936
  if (!bootstrapRegistration) throw ApiError.forbidden(noSignupsMessage, "REGISTRATION_DISABLED");
4870
5937
  if (!isBootstrapWindowOpen()) throw ApiError.forbidden(SETUP_REQUIRED_MESSAGE, "SETUP_REQUIRED");
4871
5938
  }
4872
- user = await authRepo.createUser({
5939
+ let createData = {
4873
5940
  email: normalizeEmail(externalUser.email),
4874
5941
  displayName: externalUser.displayName || void 0,
4875
5942
  photoUrl: externalUser.photoUrl || void 0,
4876
5943
  emailVerified: externalUser.emailVerified === true
4877
- });
5944
+ };
5945
+ if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
5946
+ user = await authRepo.createUser(createData);
4878
5947
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, identityProfileData(externalUser));
4879
5948
  if (ops.afterUserCreate) try {
4880
5949
  await ops.afterUserCreate(user);
@@ -4897,7 +5966,13 @@ function createAuthRoutes(config) {
4897
5966
  displayName: externalUser.displayName || user.displayName || void 0,
4898
5967
  photoUrl: externalUser.photoUrl || user.photoUrl || void 0
4899
5968
  });
4900
- const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
5969
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: provider.id });
5970
+ if (ops.onAuthenticated) {
5971
+ const signedIn = user;
5972
+ ops.onAuthenticated(signedIn, "oauth").catch((err) => {
5973
+ logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
5974
+ });
5975
+ }
4901
5976
  const finalResponse = redactRefreshToken(await applyTransformHook(buildAuthResponse(user, roleIds, accessToken, refreshToken, provider.id), "oauth", c.req.raw, user.id), c, refreshToken, config.cookieAuth);
4902
5977
  return c.json(finalResponse);
4903
5978
  });
@@ -4933,6 +6008,33 @@ function createAuthRoutes(config) {
4933
6008
  alreadyLinked: true,
4934
6009
  photoURL: externalUser.photoUrl ?? null
4935
6010
  });
6011
+ const linking = await authRepo.getUserById(userCtx.uid);
6012
+ if (linking?.isAnonymous) {
6013
+ if (!isRegistrationAllowed()) throw ApiError.forbidden("A guest cannot become an account here: registration is disabled on this backend.", "REGISTRATION_DISABLED");
6014
+ const vouchedEmail = externalUser.emailVerified === true ? normalizeEmail(externalUser.email) : void 0;
6015
+ if (vouchedEmail && await authRepo.getUserByEmail(vouchedEmail)) throw ApiError.conflict(`An account with this ${provider.id} address already exists. Sign in to it instead.`, "EMAIL_EXISTS");
6016
+ let upgrade = {
6017
+ email: vouchedEmail ?? linking.email,
6018
+ ...vouchedEmail ? { emailVerified: true } : {}
6019
+ };
6020
+ if (ops.beforeUserCreate) upgrade = await ops.beforeUserCreate(upgrade);
6021
+ await authRepo.linkUserIdentity(userCtx.uid, provider.id, externalUser.providerId, identityProfileData(externalUser));
6022
+ const upgraded = await authRepo.updateUser(userCtx.uid, {
6023
+ email: normalizeEmail(upgrade.email),
6024
+ ...upgrade.emailVerified !== void 0 ? { emailVerified: upgrade.emailVerified } : {},
6025
+ isAnonymous: false
6026
+ });
6027
+ if (!upgraded) throw ApiError.notFound("User not found");
6028
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(upgraded.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: provider.id });
6029
+ const authResponse = buildAuthResponse(upgraded, roleIds, accessToken, refreshToken, provider.id);
6030
+ return c.json({
6031
+ ...redactRefreshToken(authResponse, c, refreshToken, config.cookieAuth),
6032
+ success: true,
6033
+ provider: provider.id,
6034
+ alreadyLinked: false,
6035
+ photoURL: externalUser.photoUrl ?? null
6036
+ });
6037
+ }
4936
6038
  await authRepo.linkUserIdentity(userCtx.uid, provider.id, externalUser.providerId, identityProfileData(externalUser));
4937
6039
  return c.json({
4938
6040
  success: true,
@@ -4953,7 +6055,7 @@ function createAuthRoutes(config) {
4953
6055
  const user = await authRepo.getUserByEmail(email);
4954
6056
  if (user) {
4955
6057
  const token = generateSecureToken();
4956
- const tokenHash = hashToken$1(token);
6058
+ const tokenHash = hashToken(token);
4957
6059
  const expiresAt = getPasswordResetExpiry();
4958
6060
  await authRepo.createPasswordResetToken(user.id, tokenHash, expiresAt);
4959
6061
  const resetUrl = `${resolveEmailLinkBase(emailConfig, "resetPassword")}/reset-password?token=${token}`;
@@ -4988,7 +6090,7 @@ function createAuthRoutes(config) {
4988
6090
  const { token, password } = parseBody(resetPasswordSchema, await c.req.json());
4989
6091
  const passwordValidation = ops.validatePasswordStrength(password);
4990
6092
  if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
4991
- const tokenHash = hashToken$1(token);
6093
+ const tokenHash = hashToken(token);
4992
6094
  const storedToken = await authRepo.findValidPasswordResetToken(tokenHash);
4993
6095
  if (!storedToken) throw ApiError.badRequest("Invalid or expired reset token", "INVALID_TOKEN");
4994
6096
  const passwordHash = await ops.hashPassword(password);
@@ -4996,6 +6098,9 @@ function createAuthRoutes(config) {
4996
6098
  if (account && !account.emailVerified) await confirmAddressOwnership(authRepo, account, passwordHash);
4997
6099
  else await replaceUserPassword(authRepo, storedToken.uid, passwordHash);
4998
6100
  await authRepo.markPasswordResetTokenUsed(tokenHash);
6101
+ if (ops.onAuthenticated && account) ops.onAuthenticated(account, "password-reset").catch((err) => {
6102
+ logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
6103
+ });
4999
6104
  if (ops.onPasswordReset) ops.onPasswordReset(storedToken.uid).catch((err) => {
5000
6105
  logger.error("[AuthHooks] onPasswordReset error", { error: err instanceof Error ? err.message : err });
5001
6106
  });
@@ -5019,7 +6124,14 @@ function createAuthRoutes(config) {
5019
6124
  if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
5020
6125
  const passwordHash = await ops.hashPassword(newPassword);
5021
6126
  await replaceUserPassword(authRepo, user.id, passwordHash);
6127
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), {
6128
+ skipMfaGate: true,
6129
+ aal: userCtx.aal === "aal2" ? "aal2" : "aal1",
6130
+ method: "password"
6131
+ });
6132
+ const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken, "password");
5022
6133
  return c.json({
6134
+ ...redactRefreshToken(authResponse, c, refreshToken, config.cookieAuth),
5023
6135
  success: true,
5024
6136
  message: "Password has been changed successfully"
5025
6137
  });
@@ -5042,42 +6154,109 @@ function createAuthRoutes(config) {
5042
6154
  const user = await authRepo.getUserById(userCtx.uid);
5043
6155
  if (!user) throw ApiError.notFound("User not found");
5044
6156
  if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
5045
- const token = generateSecureToken();
5046
- await authRepo.setVerificationToken(user.id, hashToken$1(token));
5047
- const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
5048
- const { appName, logoUrl } = resolveEmailBranding(emailConfig);
5049
- const templateFn = emailConfig?.templates?.emailVerification;
5050
- const emailContent = templateFn ? templateFn(verifyUrl, {
5051
- email: user.email,
5052
- displayName: user.displayName
5053
- }) : getEmailVerificationTemplate(verifyUrl, {
5054
- email: user.email,
5055
- displayName: user.displayName
5056
- }, appName, logoUrl);
5057
- await emailService.send({
5058
- to: user.email,
5059
- subject: emailContent.subject,
5060
- html: emailContent.html,
5061
- text: emailContent.text
5062
- });
6157
+ if (!isDeliverableAddress(user.email)) throw ApiError.badRequest("This account has no address mail can reach. Set a real email address first.", "UNDELIVERABLE_ADDRESS");
6158
+ await sendVerificationMail(user);
5063
6159
  return c.json({
5064
6160
  success: true,
5065
6161
  message: "Verification email sent"
5066
6162
  });
5067
6163
  });
5068
6164
  /**
5069
- * GET /auth/verify-email
5070
- * Verify email address using token
6165
+ * Who proved what, when a verification link is followed.
6166
+ *
6167
+ * The link proves the inbox. It does not prove whoever registered the
6168
+ * address is that inbox's owner: anyone can register someone else's
6169
+ * address, leave a password on it, and ask for the link to be mailed. So a
6170
+ * first proof of address keeps only what the person following the link
6171
+ * also proved — and takes the rest off, as a magic link, an email code or
6172
+ * a reset does (`confirmAddressOwnership`):
6173
+ *
6174
+ * - `session`: the request carries a live session of this very account.
6175
+ * Only whoever put the credentials on it can hold one, and they now hold
6176
+ * the inbox too — nothing on the account is a stranger's.
6177
+ * - `password`: the request carries the account's password.
6178
+ * - `none`: the password and every identity whose provider did not vouch
6179
+ * for the address are removed, and every session ends.
6180
+ */
6181
+ async function verificationProof(c, user, password) {
6182
+ const bearer = extractBearerToken(c.req.header("authorization"));
6183
+ if (bearer !== void 0) {
6184
+ const payload = await verifyAccessToken(bearer);
6185
+ if (payload?.uid === user.id && (await judgeAccessToken(authRepo, payload)).live) return "session";
6186
+ }
6187
+ if (password !== void 0) {
6188
+ if (!user.passwordHash || !await ops.verifyPassword(password, user.passwordHash)) throw ApiError.unauthorized("That is not this account's password.", "INVALID_CREDENTIALS");
6189
+ return "password";
6190
+ }
6191
+ return "none";
6192
+ }
6193
+ /** The account a verification token names, if the token is live. */
6194
+ async function accountForVerificationToken(token) {
6195
+ const user = await authRepo.getUserByVerificationToken(hashToken(token));
6196
+ const sentAt = user?.emailVerificationSentAt?.getTime();
6197
+ if (!user || sentAt !== void 0 && Date.now() - sentAt > 864e5) throw ApiError.badRequest("Invalid or expired verification token", "INVALID_TOKEN");
6198
+ return user;
6199
+ }
6200
+ /**
6201
+ * GET /auth/verify-email?token=
6202
+ * Verify an address by its link. Keeps what the request proves — a live
6203
+ * session of the account — and removes what nobody proved. See
6204
+ * `verificationProof`; `POST` is the same with a password and a choice.
5071
6205
  */
5072
- router.get("/verify-email", async (c) => {
6206
+ router.get("/verify-email", strictAuthLimiter, async (c) => {
5073
6207
  const token = c.req.query("token");
5074
6208
  if (!token) throw ApiError.badRequest("Verification token is required", "INVALID_INPUT");
5075
- const user = await authRepo.getUserByVerificationToken(hashToken$1(token));
5076
- if (!user) throw ApiError.badRequest("Invalid or expired verification token", "INVALID_TOKEN");
5077
- await authRepo.setEmailVerified(user.id, true);
6209
+ const user = await accountForVerificationToken(token);
6210
+ const proof = await verificationProof(c, user, void 0);
6211
+ const outcome = await confirmAddressOwnership(authRepo, user, null, { everythingProven: proof === "session" });
6212
+ return c.json({
6213
+ success: true,
6214
+ message: "Email verified successfully",
6215
+ passwordRemoved: outcome.removedPassword,
6216
+ removedProviders: outcome.removedProviders
6217
+ });
6218
+ });
6219
+ /**
6220
+ * POST /auth/verify-email { token, password?, removeUnproven? }
6221
+ *
6222
+ * The same proof as `GET`, with the two things a link alone cannot say:
6223
+ *
6224
+ * - `password` proves the account's password, which is then kept, and
6225
+ * signs the caller in — they have just proven both the address and the
6226
+ * password. This is how a confirm-first registration completes.
6227
+ * - With neither a session nor a password, an account carrying a password
6228
+ * or an identity nobody proved is answered `409 PROOF_REQUIRED` and the
6229
+ * token is left unspent, so the person holding the link can choose:
6230
+ * prove the password, or `removeUnproven: true` to verify without it.
6231
+ */
6232
+ router.post("/verify-email", strictAuthLimiter, async (c) => {
6233
+ const { token, password, removeUnproven } = parseBody(verifyEmailSchema, await c.req.json());
6234
+ const user = await accountForVerificationToken(token);
6235
+ const proof = await verificationProof(c, user, password);
6236
+ if (proof === "none" && !removeUnproven) {
6237
+ const unprovenProviders = (await authRepo.getUserIdentities(user.id)).filter((identity) => !identityVouchesForAddress(identity, user.email)).map((identity) => identity.provider);
6238
+ if (user.passwordHash || unprovenProviders.length > 0) throw new ApiError(409, "PROOF_REQUIRED", "This account has a way to sign in that was set before its address was verified. Enter its password to keep it, or verify without it and it is removed.", {
6239
+ password: Boolean(user.passwordHash),
6240
+ providers: unprovenProviders
6241
+ });
6242
+ }
6243
+ const outcome = await confirmAddressOwnership(authRepo, user, proof === "password" ? user.passwordHash ?? null : null, { everythingProven: proof === "session" });
6244
+ if (proof !== "password") return c.json({
6245
+ success: true,
6246
+ message: "Email verified successfully",
6247
+ passwordRemoved: outcome.removedPassword,
6248
+ removedProviders: outcome.removedProviders
6249
+ });
6250
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "password" });
6251
+ const transformedResponse = await applyTransformHook(buildAuthResponse({
6252
+ ...user,
6253
+ emailVerified: true
6254
+ }, roleIds, accessToken, refreshToken, "password"), "login", c.req.raw, user.id);
5078
6255
  return c.json({
6256
+ ...redactRefreshToken(transformedResponse, c, refreshToken, config.cookieAuth),
5079
6257
  success: true,
5080
- message: "Email verified successfully"
6258
+ passwordRemoved: false,
6259
+ removedProviders: outcome.removedProviders
5081
6260
  });
5082
6261
  });
5083
6262
  /**
@@ -5110,15 +6289,24 @@ function createAuthRoutes(config) {
5110
6289
  const reuseWindowMs = Math.max(0, config.refreshTokenReuseIntervalSeconds ?? 10) * 1e3;
5111
6290
  const supersededAt = storedToken.rotatedAt ? new Date(storedToken.rotatedAt) : null;
5112
6291
  if (supersededAt && Date.now() - supersededAt.getTime() > reuseWindowMs) {
5113
- logger.warn("[Auth] Refresh token replayed after the reuse window", {
6292
+ const revoke = config.refreshTokenReuse === "revoke-session";
6293
+ logger.warn("[Security Audit] Refresh token replayed after the reuse window", {
6294
+ eventType: "auth.refresh.reuse",
5114
6295
  uid: storedToken.uid,
5115
6296
  sessionId: storedToken.sessionId,
5116
6297
  supersededSecondsAgo: Math.round((Date.now() - supersededAt.getTime()) / 1e3),
5117
- userAgent: c.req.header("user-agent") || "unknown"
6298
+ userAgent: c.req.header("user-agent") || "unknown",
6299
+ sessionRevoked: revoke
5118
6300
  });
6301
+ if (revoke) {
6302
+ if (storedToken.sessionId && authRepo.revokeRefreshTokenSession) await authRepo.revokeRefreshTokenSession(storedToken.sessionId);
6303
+ else await authRepo.deleteRefreshToken(tokenHash);
6304
+ clearRefreshCookie(c, config.cookieAuth);
6305
+ throw ApiError.unauthorized("Refresh token already used; this session has been ended", "SESSION_REVOKED");
6306
+ }
5119
6307
  throw ApiError.unauthorized("Refresh token already used", "TOKEN_ALREADY_USED");
5120
6308
  }
5121
- const roleIds = (await authRepo.getUserRoles(storedToken.uid)).map((r) => r.id);
6309
+ const roleIds = await authRepo.getUserRoleIds(storedToken.uid);
5122
6310
  const user = await authRepo.getUserById(storedToken.uid).catch((err) => {
5123
6311
  logger.warn("[Auth] Could not load user during token refresh; returning tokens only", {
5124
6312
  uid: storedToken.uid,
@@ -5126,7 +6314,13 @@ function createAuthRoutes(config) {
5126
6314
  });
5127
6315
  return null;
5128
6316
  });
6317
+ if (user?.disabled) {
6318
+ await authRepo.deleteRefreshToken(tokenHash);
6319
+ clearRefreshCookie(c, config.cookieAuth);
6320
+ throw ApiError.unauthorized("This account has been disabled", "ACCOUNT_DISABLED");
6321
+ }
5129
6322
  const sessionAal = storedToken.aal === "aal2" ? "aal2" : "aal1";
6323
+ const sessionMethod = storedToken.method ?? "password";
5130
6324
  let customClaims;
5131
6325
  if (ops.customizeAccessToken && user) {
5132
6326
  const defaultClaims = {
@@ -5136,14 +6330,16 @@ function createAuthRoutes(config) {
5136
6330
  };
5137
6331
  customClaims = await ops.customizeAccessToken(defaultClaims, user);
5138
6332
  }
5139
- const newAccessToken = await generateAccessToken(storedToken.uid, roleIds, sessionAal, customClaims, user?.isAnonymous === true);
6333
+ const sessionId = storedToken.sessionId ?? storedToken.id;
6334
+ const newAccessToken = await generateAccessToken(storedToken.uid, roleIds, sessionAal, customClaims, user?.isAnonymous === true, sessionId);
5140
6335
  const newRefreshToken = generateRefreshToken();
5141
6336
  const userAgent = c.req.header("user-agent") || "unknown";
5142
- const ipAddress = c.req.header("x-forwarded-for") || "unknown";
6337
+ const ipAddress = requestClientAddress(c);
5143
6338
  const session = {
5144
- id: storedToken.sessionId ?? storedToken.id,
6339
+ id: sessionId,
5145
6340
  startedAt: sessionStartedAt,
5146
- aal: sessionAal
6341
+ aal: sessionAal,
6342
+ method: sessionMethod
5147
6343
  };
5148
6344
  if (!supersededAt) if (authRepo.markRefreshTokenRotated) await authRepo.markRefreshTokenRotated(tokenHash);
5149
6345
  else await authRepo.deleteRefreshToken(tokenHash);
@@ -5165,14 +6361,24 @@ function createAuthRoutes(config) {
5165
6361
  } };
5166
6362
  let refreshResponse = tokensOnlyResponse;
5167
6363
  if (user) try {
5168
- refreshResponse = buildAuthResponse(user, roleIds, newAccessToken, newRefreshToken, "password");
6364
+ refreshResponse = buildAuthResponse(user, roleIds, newAccessToken, newRefreshToken, sessionMethod);
5169
6365
  } catch (err) {
5170
6366
  logger.warn("[Auth] Could not build enriched refresh response; returning tokens only", { error: err instanceof Error ? err.message : String(err) });
5171
6367
  refreshResponse = tokensOnlyResponse;
5172
6368
  }
6369
+ if (ops.onAuthenticated && user) ops.onAuthenticated(user, "refresh").catch((err) => {
6370
+ logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
6371
+ });
5173
6372
  const finalResponse = redactRefreshToken(await applyTransformHook(refreshResponse, "refresh", c.req.raw, storedToken.uid), c, newRefreshToken, config.cookieAuth);
5174
6373
  return c.json(finalResponse);
5175
6374
  });
6375
+ mountEmailChangeRoutes({
6376
+ router,
6377
+ config,
6378
+ ops,
6379
+ parseBody,
6380
+ requireLiveSession
6381
+ });
5176
6382
  mountSessionRoutes({
5177
6383
  router,
5178
6384
  config,
@@ -5182,7 +6388,8 @@ function createAuthRoutes(config) {
5182
6388
  createSessionAndTokens,
5183
6389
  applyTransformHook,
5184
6390
  requireLiveSession,
5185
- registerCaptcha: captcha.register
6391
+ registerCaptcha: captcha.register,
6392
+ sendVerificationMail: sendVerificationMailInBackground
5186
6393
  });
5187
6394
  mountMfaRoutes({
5188
6395
  router,
@@ -5280,10 +6487,11 @@ function createResetPasswordRoute(config) {
5280
6487
  resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
5281
6488
  revocationRepo: authRepo
5282
6489
  }));
5283
- router.post("/users/:uid/reset-password", requireAdmin, async (c) => {
6490
+ router.post("/users/:uid/reset-password", requireScope("users:write"), async (c) => {
5284
6491
  const uid = c.req.param("uid");
5285
6492
  const existing = await authRepo.getUserById(uid);
5286
6493
  if (!existing) throw ApiError.notFound("User not found");
6494
+ assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
5287
6495
  let invitationSent = false;
5288
6496
  let temporaryPassword;
5289
6497
  let emailDeliveryFailed = false;
@@ -5313,7 +6521,7 @@ function createResetPasswordRoute(config) {
5313
6521
  invitationSent = hookResult.invitationSent ?? false;
5314
6522
  } else if (!!(emailService && emailService.isConfigured())) {
5315
6523
  const token = generateSecureToken();
5316
- const tokenHash = hashToken$1(token);
6524
+ const tokenHash = hashToken(token);
5317
6525
  const expiresAt = new Date(Date.now() + 3600 * 1e3);
5318
6526
  await authRepo.createPasswordResetToken(existing.id, tokenHash, expiresAt);
5319
6527
  try {
@@ -5361,9 +6569,11 @@ function createResetPasswordRoute(config) {
5361
6569
  //#endregion
5362
6570
  //#region src/auth/admin-roles-route.ts
5363
6571
  /**
5364
- * Admin endpoint for listing all roles.
6572
+ * Admin endpoint for listing roles.
5365
6573
  *
5366
- * Mounts: GET /roles
6574
+ * Mounts: GET /roles — the built-in `admin` role and the roles the app
6575
+ * declares under `auth.roles` on the users collection, with the scopes each
6576
+ * holds. Requires `users:read`.
5367
6577
  */
5368
6578
  /**
5369
6579
  * Create a standalone admin route for listing roles.
@@ -5379,9 +6589,8 @@ function createAdminRolesRoute(config) {
5379
6589
  resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
5380
6590
  revocationRepo: authRepo
5381
6591
  }));
5382
- router.get("/roles", requireAdmin, async (c) => {
5383
- const roles = await authRepo.listRoles();
5384
- return c.json({ roles });
6592
+ router.get("/roles", requireScope("users:read"), (c) => {
6593
+ return c.json({ roles: summarizeRoles(getAccessModel()) });
5385
6594
  });
5386
6595
  return router;
5387
6596
  }
@@ -5396,6 +6605,7 @@ function createAdminRolesRoute(config) {
5396
6605
  * POST /users
5397
6606
  * PUT /users/:uid
5398
6607
  * DELETE /users/:uid
6608
+ * DELETE /users/:uid/mfa
5399
6609
  * POST /bootstrap
5400
6610
  */
5401
6611
  /** Upper bound for `GET /users?ids=…`, so one request can't fan out unbounded. */
@@ -5413,6 +6623,7 @@ function createAdminUsersRoute(config) {
5413
6623
  photoURL: u.photoUrl ?? null,
5414
6624
  providerId: "custom",
5415
6625
  roles,
6626
+ disabled: u.disabled === true,
5416
6627
  createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : u.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
5417
6628
  updatedAt: u.updatedAt instanceof Date ? u.updatedAt.toISOString() : u.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
5418
6629
  };
@@ -5481,13 +6692,13 @@ function createAdminUsersRoute(config) {
5481
6692
  }
5482
6693
  });
5483
6694
  });
5484
- router.get("/users", requireAdmin, async (c) => {
6695
+ router.get("/users", requireScope("users:read"), async (c) => {
5485
6696
  const idsParam = c.req.query("ids");
5486
6697
  if (idsParam !== void 0) {
5487
6698
  const ids = [...new Set(idsParam.split(",").map((id) => id.trim()).filter(Boolean))].slice(0, MAX_USER_IDS_PER_LOOKUP);
5488
6699
  const users = (await Promise.all(ids.map(async (id) => {
5489
6700
  const result = await authRepo.getUserWithRoles(id);
5490
- return result ? toAdminUser(result.user, result.roles.map((r) => r.id)) : void 0;
6701
+ return result ? toAdminUser(result.user, result.roles) : void 0;
5491
6702
  }))).filter((u) => u !== void 0);
5492
6703
  return c.json({
5493
6704
  users,
@@ -5521,17 +6732,18 @@ function createAdminUsersRoute(config) {
5521
6732
  offset: result.offset
5522
6733
  });
5523
6734
  });
5524
- router.get("/users/:uid", requireAdmin, async (c) => {
6735
+ router.get("/users/:uid", requireScope("users:read"), async (c) => {
5525
6736
  const uid = c.req.param("uid");
5526
6737
  const result = await authRepo.getUserWithRoles(uid);
5527
6738
  if (!result) throw ApiError.notFound("User not found");
5528
- const adminUser = toAdminUser(result.user, result.roles.map((r) => r.id));
6739
+ const adminUser = toAdminUser(result.user, result.roles);
5529
6740
  return c.json({ user: adminUser });
5530
6741
  });
5531
- router.post("/users", requireAdmin, async (c) => {
6742
+ router.post("/users", requireScope("users:write"), async (c) => {
5532
6743
  const body = await c.req.json();
5533
6744
  const { email, roles } = body;
5534
6745
  if (!email) throw ApiError.badRequest("Email is required");
6746
+ if (roles !== void 0 && Array.isArray(roles)) assertMayGrantRoles(c, roles.filter((role) => typeof role === "string"));
5535
6747
  if (await authRepo.getUserByEmail(normalizeEmail(email))) throw ApiError.conflict("A user with this email already exists");
5536
6748
  const prepResult = await prepareAdminUserValues(body, {
5537
6749
  authRepo,
@@ -5567,14 +6779,24 @@ function createAdminUsersRoute(config) {
5567
6779
  ...delivery
5568
6780
  }, 201);
5569
6781
  });
5570
- router.put("/users/:uid", requireAdmin, async (c) => {
6782
+ router.put("/users/:uid", requireScope("users:write"), async (c) => {
5571
6783
  const uid = c.req.param("uid");
5572
- const { password, email, displayName, roles } = await c.req.json();
6784
+ const { password, email, displayName, roles, disabled } = await c.req.json();
5573
6785
  if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
5574
- if (roles !== void 0 && Array.isArray(roles)) await assertRoleChangesAllowed(authRepo, [{
5575
- uid,
5576
- roles
5577
- }]);
6786
+ if (disabled !== void 0) {
6787
+ if (typeof disabled !== "boolean") throw ApiError.badRequest("`disabled` must be true or false", "INVALID_INPUT");
6788
+ const caller = c.get("user");
6789
+ if (disabled && caller?.uid === uid) throw ApiError.badRequest("Cannot disable your own account", "SELF_DISABLE");
6790
+ if (typeof authRepo.setUserDisabled !== "function") throw new ApiError(501, "NOT_SUPPORTED", "This backend's auth repository cannot disable accounts.");
6791
+ }
6792
+ assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
6793
+ if (roles !== void 0 && Array.isArray(roles)) {
6794
+ assertMayGrantRoles(c, roles.filter((role) => typeof role === "string"));
6795
+ await assertRoleChangesAllowed(authRepo, [{
6796
+ uid,
6797
+ roles
6798
+ }]);
6799
+ }
5578
6800
  const updates = {};
5579
6801
  if (email !== void 0) updates.email = await normalizeUserEmailChange(authRepo, uid, email);
5580
6802
  if (displayName !== void 0) updates.displayName = displayName;
@@ -5587,14 +6809,54 @@ function createAdminUsersRoute(config) {
5587
6809
  if (Object.keys(updates).length > 0) await authRepo.updateUser(uid, updates);
5588
6810
  if (passwordHash) await replaceUserPassword(authRepo, uid, passwordHash);
5589
6811
  if (roles !== void 0 && Array.isArray(roles)) await authRepo.setUserRoles(uid, roles);
6812
+ if (typeof disabled === "boolean" && authRepo.setUserDisabled) {
6813
+ await authRepo.setUserDisabled(uid, disabled);
6814
+ if (disabled) await revokeAllSessions(authRepo, uid);
6815
+ logger.info("[Security Audit] Account " + (disabled ? "disabled" : "re-enabled") + " by an administrator", {
6816
+ eventType: disabled ? "auth.account.disabled" : "auth.account.enabled",
6817
+ uid,
6818
+ by: c.get("user")?.uid
6819
+ });
6820
+ }
5590
6821
  const result = await authRepo.getUserWithRoles(uid);
5591
- const adminUser = toAdminUser(result.user, result.roles.map((r) => r.id));
6822
+ const adminUser = toAdminUser(result.user, result.roles);
5592
6823
  return c.json({ user: adminUser });
5593
6824
  });
5594
- router.delete("/users/:uid", requireAdmin, async (c) => {
6825
+ /**
6826
+ * DELETE /users/:uid/mfa — remove every second factor and recovery code
6827
+ * an account has, and end its sessions.
6828
+ *
6829
+ * The way back in for someone who lost their authenticator and their
6830
+ * codes, which used to be SQL. It lowers the account's protection, so it
6831
+ * is held to what a password reset is held to: `users:write`, never on an
6832
+ * account that outranks the caller, and every session ends — a session
6833
+ * that passed the old factor must not outlive its removal.
6834
+ */
6835
+ router.delete("/users/:uid/mfa", requireScope("users:write"), async (c) => {
6836
+ const uid = c.req.param("uid");
6837
+ if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
6838
+ assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
6839
+ const factors = await authRepo.getMfaFactors(uid);
6840
+ for (const factor of factors) await authRepo.deleteMfaFactor(factor.id, uid);
6841
+ await authRepo.deleteAllRecoveryCodes(uid);
6842
+ await revokeAllSessions(authRepo, uid);
6843
+ const caller = c.get("user");
6844
+ logger.info("[Security Audit] Second factors reset by an administrator", {
6845
+ eventType: "auth.mfa.admin_reset",
6846
+ uid,
6847
+ by: caller?.uid,
6848
+ removedFactors: factors.length
6849
+ });
6850
+ return c.json({
6851
+ success: true,
6852
+ removedFactors: factors.length
6853
+ });
6854
+ });
6855
+ router.delete("/users/:uid", requireScope("users:write"), async (c) => {
5595
6856
  const uid = c.req.param("uid");
5596
6857
  if (c.get("user")?.uid === uid) throw ApiError.badRequest("Cannot delete your own account", "SELF_DELETE");
5597
6858
  if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
6859
+ assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
5598
6860
  await assertUserDeletionsAllowed(authRepo, [uid]);
5599
6861
  if (ops.beforeUserDelete) await ops.beforeUserDelete(uid);
5600
6862
  await authRepo.deleteAllRefreshTokensForUser(uid);
@@ -5614,7 +6876,7 @@ function createAdminUsersRoute(config) {
5614
6876
  * when the user passes a plain `RebaseAuthConfig` object.
5615
6877
  */
5616
6878
  function createBuiltinAuthAdapter(config) {
5617
- const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowAnonymous = false, allowUserLookup = false, captcha, defaultRole, oauthProviders = [], allowedRedirectUris, serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, enableEmailOtp = false, cookieAuth } = config;
6879
+ const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowAnonymous = false, allowUserLookup = false, captcha, defaultRole, oauthProviders = [], allowedRedirectUris, serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, enableEmailOtp = false, cookieAuth, requireEmailVerification = false, refreshTokenReuseIntervalSeconds, refreshTokenReuse, magicLinkCreatesUsers = false } = config;
5618
6880
  const resolvedOps = resolveAuthHooks(authHooks);
5619
6881
  /**
5620
6882
  * The caller's roles as the database has them now.
@@ -5639,6 +6901,33 @@ function createBuiltinAuthAdapter(config) {
5639
6901
  throw new ApiError(503, "ROLE_LOOKUP_FAILED", "Could not verify your permissions. Please try again.");
5640
6902
  }
5641
6903
  }
6904
+ /**
6905
+ * The roles a verified access token runs with now, or `null` when it must
6906
+ * not be honoured at all: revoked, or its account deleted. See
6907
+ * `judgeAccessToken`, which both verify paths ask. A repository that
6908
+ * cannot answer refuses the request (503), as `resolveLiveRoles` does.
6909
+ */
6910
+ async function liveRoles(payload) {
6911
+ let verdict;
6912
+ try {
6913
+ verdict = await judgeAccessToken(authRepository, payload);
6914
+ } catch (error) {
6915
+ logger.warn("[Auth] Could not judge an access token; refusing it", {
6916
+ uid: payload.uid,
6917
+ error
6918
+ });
6919
+ throw new ApiError(503, "ROLE_LOOKUP_FAILED", "Could not verify your permissions. Please try again.");
6920
+ }
6921
+ if (!verdict.live) {
6922
+ logger.warn("[Security Audit] Refused an access token", {
6923
+ eventType: verdict.refusal === "revoked" ? "auth.token.revoked" : "auth.token.account_gone",
6924
+ refusal: verdict.refusal,
6925
+ uid: payload.uid
6926
+ });
6927
+ return null;
6928
+ }
6929
+ return verdict.roles ?? await resolveLiveRoles(payload.uid);
6930
+ }
5642
6931
  return {
5643
6932
  id: "rebase-builtin",
5644
6933
  serviceKey,
@@ -5654,15 +6943,9 @@ function createBuiltinAuthAdapter(config) {
5654
6943
  };
5655
6944
  const payload = await verifyAccessToken(token);
5656
6945
  if (!payload) return null;
5657
- if (await isAccessTokenRevoked(authRepository, payload)) {
5658
- logger.warn("[Security Audit] Refused a revoked access token", {
5659
- eventType: "auth.token.revoked",
5660
- uid: payload.uid
5661
- });
5662
- return null;
5663
- }
5664
- const roles = await resolveLiveRoles(payload.uid);
5665
- const isAdmin = hasAdministrativeRole(roles);
6946
+ const roles = await liveRoles(payload);
6947
+ if (!roles) return null;
6948
+ const isAdmin = hasAdminRole(roles);
5666
6949
  return {
5667
6950
  uid: payload.uid,
5668
6951
  email: payload.email ?? "",
@@ -5684,15 +6967,9 @@ function createBuiltinAuthAdapter(config) {
5684
6967
  };
5685
6968
  const payload = await verifyAccessToken(token);
5686
6969
  if (!payload) return null;
5687
- if (await isAccessTokenRevoked(authRepository, payload)) {
5688
- logger.warn("[Security Audit] Refused a revoked access token", {
5689
- eventType: "auth.token.revoked",
5690
- uid: payload.uid
5691
- });
5692
- return null;
5693
- }
5694
- const roles = await resolveLiveRoles(payload.uid);
5695
- const isAdmin = hasAdministrativeRole(roles);
6970
+ const roles = await liveRoles(payload);
6971
+ if (!roles) return null;
6972
+ const isAdmin = hasAdminRole(roles);
5696
6973
  return {
5697
6974
  uid: payload.uid,
5698
6975
  email: payload.email ?? "",
@@ -5704,6 +6981,34 @@ function createBuiltinAuthAdapter(config) {
5704
6981
  rawToken: token
5705
6982
  };
5706
6983
  },
6984
+ async resolveUser(uid) {
6985
+ let user;
6986
+ try {
6987
+ user = await authRepository.getUserById(uid);
6988
+ } catch (error) {
6989
+ if (isDataException(error)) return null;
6990
+ throw error;
6991
+ }
6992
+ if (!user || user.disabled) return null;
6993
+ const roles = await resolveLiveRoles(uid);
6994
+ const isAnonymous = user.isAnonymous === true;
6995
+ let claims;
6996
+ if (resolvedOps.customizeAccessToken) claims = customClaimsOf(await resolvedOps.customizeAccessToken({
6997
+ uid,
6998
+ roles,
6999
+ aal: "aal1"
7000
+ }, user));
7001
+ return {
7002
+ uid,
7003
+ email: user.email,
7004
+ displayName: user.displayName ?? null,
7005
+ photoUrl: user.photoUrl ?? null,
7006
+ roles,
7007
+ isAdmin: hasAdminRole(roles),
7008
+ isAnonymous,
7009
+ ...claims ? { claims } : {}
7010
+ };
7011
+ },
5707
7012
  userManagement: createUserManagementFromRepo(authRepository, resolvedOps),
5708
7013
  createAuthRoutes() {
5709
7014
  return createAuthRoutes({
@@ -5721,7 +7026,11 @@ function createBuiltinAuthAdapter(config) {
5721
7026
  enableMagicLink,
5722
7027
  enableEmailOtp,
5723
7028
  cookieAuth,
5724
- captcha
7029
+ captcha,
7030
+ requireEmailVerification,
7031
+ refreshTokenReuseIntervalSeconds,
7032
+ refreshTokenReuse,
7033
+ magicLinkCreatesUsers
5725
7034
  });
5726
7035
  },
5727
7036
  createAdminRoutes() {
@@ -5832,7 +7141,8 @@ function createUserManagementFromRepo(repo, resolvedOps) {
5832
7141
  passwordHash,
5833
7142
  displayName: data.displayName,
5834
7143
  photoUrl: data.photoUrl,
5835
- metadata: data.metadata
7144
+ metadata: data.metadata,
7145
+ ...data.emailVerified !== void 0 ? { emailVerified: data.emailVerified } : {}
5836
7146
  };
5837
7147
  if (resolvedOps.beforeUserCreate) createData = await resolvedOps.beforeUserCreate(createData);
5838
7148
  const user = await repo.createUser(createData);
@@ -7111,9 +8421,76 @@ function resolveRateLimitStoreKind(env) {
7111
8421
  throw new RateLimitStoreConfigurationError(`REBASE_RATE_LIMIT_STORE="${raw}" is not a rate-limit store.`, "Use \"sql\" to share counts across processes, or \"memory\" (the default) to keep them in this process. A deployment running more than one replica or more than one role wants \"sql\" — otherwise each process enforces the whole limit by itself.");
7112
8422
  }
7113
8423
  //#endregion
8424
+ //#region src/auth/api-keys/legacy-permissions.ts
8425
+ var OPERATIONS = [
8426
+ "read",
8427
+ "write",
8428
+ "delete"
8429
+ ];
8430
+ function isOperation(value) {
8431
+ return OPERATIONS.includes(value);
8432
+ }
8433
+ /** The admin surfaces an `admin: true` key reached. */
8434
+ var ADMIN_KEY_SCOPES = [
8435
+ "users:read",
8436
+ "users:write",
8437
+ "schema:read",
8438
+ "schema:write",
8439
+ "backups:read",
8440
+ "cron:read",
8441
+ "cron:write",
8442
+ "logs:read"
8443
+ ];
8444
+ /** Read a stored `permissions` value — JSON text or already-parsed — into entries. */
8445
+ function parseStoredPermissions(value) {
8446
+ let raw = value;
8447
+ if (typeof raw === "string") try {
8448
+ raw = JSON.parse(raw);
8449
+ } catch {
8450
+ return [];
8451
+ }
8452
+ if (!Array.isArray(raw)) return [];
8453
+ const entries = [];
8454
+ for (const item of raw) {
8455
+ if (typeof item !== "object" || item === null) continue;
8456
+ if (!("collection" in item) || typeof item.collection !== "string") continue;
8457
+ const operations = "operations" in item && Array.isArray(item.operations) ? item.operations.filter((op) => typeof op === "string") : [];
8458
+ entries.push({
8459
+ collection: item.collection,
8460
+ operations
8461
+ });
8462
+ }
8463
+ return entries;
8464
+ }
8465
+ /** The scopes and RLS roles a stored key now holds. Never more than it did. */
8466
+ function scopesFromStoredPermissions(permissions, admin) {
8467
+ const scopes = /* @__PURE__ */ new Set();
8468
+ for (const { collection, operations } of permissions) {
8469
+ const ops = operations.filter(isOperation);
8470
+ if (collection === "*") {
8471
+ for (const op of ops) {
8472
+ scopes.add(`data:${op}`);
8473
+ scopes.add(`storage:${op}`);
8474
+ }
8475
+ if (ops.includes("write")) scopes.add("functions:invoke");
8476
+ } else if (collection === "storage") for (const op of ops) scopes.add(`storage:${op}`);
8477
+ else if (collection === "functions") {
8478
+ if (ops.includes("write")) scopes.add("functions:invoke");
8479
+ } else if (collection.startsWith("functions/")) {
8480
+ const name = collection.slice(10);
8481
+ if (name && ops.includes("write")) scopes.add(`functions:invoke:${name}`);
8482
+ } else if (collection) for (const op of ops) scopes.add(`data:${op}:${collection}`);
8483
+ }
8484
+ if (admin) for (const scope of ADMIN_KEY_SCOPES) scopes.add(scope);
8485
+ return {
8486
+ scopes: [...scopes],
8487
+ roles: admin ? ["admin"] : []
8488
+ };
8489
+ }
8490
+ //#endregion
7114
8491
  //#region src/auth/api-keys/api-key-store.ts
7115
8492
  /**
7116
- * Database operations for Service API Keys.
8493
+ * Database operations for API keys.
7117
8494
  *
7118
8495
  * Uses the DataDriver's `admin.executeSql` capability (same pattern as
7119
8496
  * the cron-store and ensure-tables modules). All data lives in the
@@ -7149,9 +8526,11 @@ function toMasked(row) {
7149
8526
  return {
7150
8527
  id: row.id,
7151
8528
  name: row.name,
8529
+ kind: row.kind,
7152
8530
  key_prefix: row.key_prefix,
7153
- permissions: row.permissions,
7154
- admin: row.admin,
8531
+ scopes: row.scopes,
8532
+ roles: row.roles,
8533
+ owner_uid: row.owner_uid,
7155
8534
  rate_limit: row.rate_limit,
7156
8535
  created_by: row.created_by,
7157
8536
  created_at: row.created_at,
@@ -7161,30 +8540,40 @@ function toMasked(row) {
7161
8540
  revoked_at: row.revoked_at
7162
8541
  };
7163
8542
  }
8543
+ /** A JSONB string array as the driver hands it back: parsed, or JSON text. */
8544
+ function stringList(value) {
8545
+ let raw = value;
8546
+ if (typeof raw === "string") try {
8547
+ raw = JSON.parse(raw);
8548
+ } catch {
8549
+ return [];
8550
+ }
8551
+ return Array.isArray(raw) ? raw.filter((entry) => typeof entry === "string") : [];
8552
+ }
7164
8553
  /**
7165
8554
  * Parse a raw DB row into the typed `ApiKey` shape.
7166
8555
  */
7167
8556
  function rowToApiKey(row) {
7168
- let permissions = row.permissions ?? [];
7169
- if (typeof row.permissions === "string") try {
7170
- permissions = JSON.parse(row.permissions);
7171
- } catch {
7172
- permissions = [];
7173
- }
8557
+ const grant = row.scopes === null || row.scopes === void 0 ? scopesFromStoredPermissions(parseStoredPermissions(row.permissions), row.admin === true) : {
8558
+ scopes: stringList(row.scopes),
8559
+ roles: stringList(row.roles)
8560
+ };
7174
8561
  return {
7175
- id: row.id,
7176
- name: row.name,
7177
- key_prefix: row.key_prefix,
7178
- key_hash: row.key_hash,
7179
- permissions,
7180
- admin: Boolean(row.admin),
8562
+ id: String(row.id),
8563
+ name: String(row.name),
8564
+ kind: row.kind === "personal" ? "personal" : "service",
8565
+ key_prefix: String(row.key_prefix),
8566
+ key_hash: String(row.key_hash),
8567
+ scopes: grant.scopes,
8568
+ roles: grant.roles,
8569
+ owner_uid: typeof row.owner_uid === "string" ? row.owner_uid : null,
7181
8570
  rate_limit: row.rate_limit !== null && row.rate_limit !== void 0 ? Number(row.rate_limit) : null,
7182
- created_by: row.created_by,
7183
- created_at: new Date(row.created_at).toISOString(),
7184
- updated_at: new Date(row.updated_at).toISOString(),
7185
- last_used_at: row.last_used_at ? new Date(row.last_used_at).toISOString() : null,
7186
- expires_at: row.expires_at ? new Date(row.expires_at).toISOString() : null,
7187
- revoked_at: row.revoked_at ? new Date(row.revoked_at).toISOString() : null
8571
+ created_by: String(row.created_by),
8572
+ created_at: new Date(String(row.created_at)).toISOString(),
8573
+ updated_at: new Date(String(row.updated_at)).toISOString(),
8574
+ last_used_at: row.last_used_at ? new Date(String(row.last_used_at)).toISOString() : null,
8575
+ expires_at: row.expires_at ? new Date(String(row.expires_at)).toISOString() : null,
8576
+ revoked_at: row.revoked_at ? new Date(String(row.revoked_at)).toISOString() : null
7188
8577
  };
7189
8578
  }
7190
8579
  /**
@@ -7200,6 +8589,26 @@ function createApiKeyStore(driver) {
7200
8589
  }
7201
8590
  const exec = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
7202
8591
  const ddl = createDdlBootstrapper(exec, "api-key-store");
8592
+ /**
8593
+ * Give every row stored before scopes existed the scopes it now holds.
8594
+ *
8595
+ * Idempotent and race-safe: a row is only written while its `scopes` is
8596
+ * still NULL, so two instances backfilling together write the same value
8597
+ * once. The `permissions` and `admin` columns are left as they are, so a
8598
+ * runtime that predates scopes still reads its own keys after a rollback.
8599
+ */
8600
+ async function backfillScopes() {
8601
+ const rows = await exec(`SELECT id, permissions, admin FROM ${TABLE} WHERE scopes IS NULL`);
8602
+ for (const row of rows) {
8603
+ const { scopes, roles } = scopesFromStoredPermissions(parseStoredPermissions(row.permissions), row.admin === true);
8604
+ await exec(`UPDATE ${TABLE} SET scopes = $1::jsonb, roles = $2::jsonb WHERE id = $3 AND scopes IS NULL`, { params: [
8605
+ JSON.stringify(scopes),
8606
+ JSON.stringify(roles),
8607
+ row.id
8608
+ ] });
8609
+ }
8610
+ if (rows.length > 0) logger.info(`[api-key-store] Gave ${rows.length} stored API key(s) their scopes.`);
8611
+ }
7203
8612
  return {
7204
8613
  async ensureTable() {
7205
8614
  await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
@@ -7207,10 +8616,12 @@ function createApiKeyStore(driver) {
7207
8616
  CREATE TABLE IF NOT EXISTS ${TABLE} (
7208
8617
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
7209
8618
  name TEXT NOT NULL,
8619
+ kind TEXT NOT NULL DEFAULT 'service',
7210
8620
  key_prefix TEXT NOT NULL,
7211
8621
  key_hash TEXT NOT NULL UNIQUE,
7212
- permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
7213
- admin BOOLEAN NOT NULL DEFAULT FALSE,
8622
+ scopes JSONB,
8623
+ roles JSONB NOT NULL DEFAULT '[]'::jsonb,
8624
+ owner_uid TEXT,
7214
8625
  rate_limit INTEGER,
7215
8626
  created_by TEXT NOT NULL,
7216
8627
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -7228,34 +8639,49 @@ function createApiKeyStore(driver) {
7228
8639
  CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
7229
8640
  ON ${TABLE}(key_prefix)
7230
8641
  `);
7231
- await ddl.ensureObject(`Adding ${TABLE}.admin`, `
7232
- ALTER TABLE ${TABLE}
7233
- ADD COLUMN IF NOT EXISTS admin BOOLEAN NOT NULL DEFAULT FALSE
8642
+ await ddl.ensureObject(`Adding ${TABLE}.kind`, `
8643
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'service'
8644
+ `);
8645
+ await ddl.ensureObject(`Adding ${TABLE}.scopes`, `
8646
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS scopes JSONB
8647
+ `);
8648
+ await ddl.ensureObject(`Adding ${TABLE}.roles`, `
8649
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS roles JSONB NOT NULL DEFAULT '[]'::jsonb
8650
+ `);
8651
+ await ddl.ensureObject(`Adding ${TABLE}.owner_uid`, `
8652
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS owner_uid TEXT
8653
+ `);
8654
+ await ddl.ensureObject("Creating idx_api_keys_owner", `
8655
+ CREATE INDEX IF NOT EXISTS idx_api_keys_owner
8656
+ ON ${TABLE}(owner_uid) WHERE owner_uid IS NOT NULL
7234
8657
  `);
7235
8658
  if (!await ddl.isReadable(TABLE)) {
7236
8659
  logger.error(`❌ [api-key-store] ${TABLE} is unavailable — every API-key authenticated request will be rejected on this instance, and the table could not be taken back off the end-user role.`);
7237
8660
  return;
7238
8661
  }
7239
8662
  await ddl.step("Revoking end-user access to api_keys", () => exec(revokeInternalTableSql("rebase", "api_keys")));
8663
+ if ((await exec(`SELECT 1 FROM information_schema.columns
8664
+ WHERE table_schema = 'rebase' AND table_name = 'api_keys' AND column_name = 'permissions'`)).length > 0) await ddl.step("Backfilling API key scopes", backfillScopes);
7240
8665
  logger.debug("✅ API keys table ready");
7241
8666
  },
7242
- async createApiKey(request, createdBy) {
8667
+ async createApiKey(key, createdBy) {
7243
8668
  const plaintext = generateApiKey();
7244
8669
  const hash = hashKey(plaintext);
7245
8670
  const prefix = keyPrefix(plaintext);
7246
- const permissionsJson = JSON.stringify(request.permissions);
7247
8671
  return {
7248
- ...toMasked(rowToApiKey((await exec(`INSERT INTO ${TABLE} (name, key_prefix, key_hash, permissions, admin, rate_limit, created_by, expires_at)
7249
- VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8)
8672
+ ...toMasked(rowToApiKey((await exec(`INSERT INTO ${TABLE} (name, kind, key_prefix, key_hash, scopes, roles, owner_uid, rate_limit, created_by, expires_at)
8673
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10)
7250
8674
  RETURNING *`, { params: [
7251
- request.name,
8675
+ key.name,
8676
+ key.kind,
7252
8677
  prefix,
7253
8678
  hash,
7254
- permissionsJson,
7255
- request.admin ?? false,
7256
- request.rate_limit ?? null,
8679
+ JSON.stringify(key.scopes),
8680
+ JSON.stringify(key.kind === "personal" ? [] : key.roles),
8681
+ key.owner_uid,
8682
+ key.rate_limit,
7257
8683
  createdBy,
7258
- request.expires_at ?? null
8684
+ key.expires_at
7259
8685
  ] }))[0])),
7260
8686
  key: plaintext
7261
8687
  };
@@ -7267,11 +8693,14 @@ function createApiKeyStore(driver) {
7267
8693
  if (rows.length === 0) return null;
7268
8694
  return rowToApiKey(rows[0]);
7269
8695
  },
7270
- async listApiKeys() {
7271
- return (await exec(`
7272
- SELECT * FROM ${TABLE}
7273
- ORDER BY created_at DESC
7274
- `)).map((r) => toMasked(rowToApiKey(r)));
8696
+ async listApiKeys(filter) {
8697
+ return (filter.kind === "personal" ? await exec(`SELECT * FROM ${TABLE}
8698
+ WHERE kind = 'personal' AND owner_uid = $1
8699
+ ORDER BY created_at DESC`, { params: [filter.owner_uid] }) : await exec(`
8700
+ SELECT * FROM ${TABLE}
8701
+ WHERE kind = 'service'
8702
+ ORDER BY created_at DESC
8703
+ `)).map((r) => toMasked(rowToApiKey(r)));
7275
8704
  },
7276
8705
  async getApiKeyById(id) {
7277
8706
  const rows = await exec(`SELECT * FROM ${TABLE}
@@ -7288,13 +8717,13 @@ function createApiKeyStore(driver) {
7288
8717
  setClauses.push(`name = $${paramIdx++}`);
7289
8718
  params.push(updates.name);
7290
8719
  }
7291
- if (updates.permissions !== void 0) {
7292
- setClauses.push(`permissions = $${paramIdx++}::jsonb`);
7293
- params.push(JSON.stringify(updates.permissions));
8720
+ if (updates.scopes !== void 0) {
8721
+ setClauses.push(`scopes = $${paramIdx++}::jsonb`);
8722
+ params.push(JSON.stringify(updates.scopes));
7294
8723
  }
7295
- if (updates.admin !== void 0) {
7296
- setClauses.push(`admin = $${paramIdx++}`);
7297
- params.push(updates.admin);
8724
+ if (updates.roles !== void 0) {
8725
+ setClauses.push(`roles = $${paramIdx++}::jsonb`);
8726
+ params.push(JSON.stringify(updates.roles));
7298
8727
  }
7299
8728
  if (updates.rate_limit !== void 0) if (updates.rate_limit !== null) {
7300
8729
  setClauses.push(`rate_limit = $${paramIdx++}`);
@@ -7309,16 +8738,19 @@ function createApiKeyStore(driver) {
7309
8738
  params.push(id);
7310
8739
  const rows = await exec(`UPDATE ${TABLE}
7311
8740
  SET ${setClauses.join(", ")}
7312
- WHERE id = $${paramIdx}
8741
+ WHERE id = $${paramIdx} AND kind = 'service'
7313
8742
  RETURNING *`, { params });
7314
8743
  if (rows.length === 0) return null;
7315
8744
  return toMasked(rowToApiKey(rows[0]));
7316
8745
  },
7317
- async revokeApiKey(id) {
7318
- return (await exec(`UPDATE ${TABLE}
7319
- SET revoked_at = NOW(), updated_at = NOW()
7320
- WHERE id = $1 AND revoked_at IS NULL
7321
- RETURNING id`, { params: [id] })).length > 0;
8746
+ async revokeApiKey(id, owner_uid) {
8747
+ return (owner_uid === void 0 ? await exec(`UPDATE ${TABLE}
8748
+ SET revoked_at = NOW(), updated_at = NOW()
8749
+ WHERE id = $1 AND kind = 'service' AND revoked_at IS NULL
8750
+ RETURNING id`, { params: [id] }) : await exec(`UPDATE ${TABLE}
8751
+ SET revoked_at = NOW(), updated_at = NOW()
8752
+ WHERE id = $1 AND kind = 'personal' AND owner_uid = $2 AND revoked_at IS NULL
8753
+ RETURNING id`, { params: [id, owner_uid] })).length > 0;
7322
8754
  },
7323
8755
  async updateLastUsed(id) {
7324
8756
  try {
@@ -7332,62 +8764,156 @@ function createApiKeyStore(driver) {
7332
8764
  };
7333
8765
  }
7334
8766
  //#endregion
7335
- //#region src/auth/api-keys/api-key-routes.ts
8767
+ //#region src/auth/api-keys/key-grant.ts
7336
8768
  /**
7337
- * Admin routes for managing Service API Keys.
8769
+ * What a new or changed key may hold, decided against whoever asked for it.
7338
8770
  *
7339
- * Mounted under `/api/admin/api-keys` with `requireAuth` + `requireAdmin`.
7340
- * All routes return masked keys (never the hash). The full plaintext key
7341
- * is returned exactly once in the POST response.
8771
+ * One rule for every door that mints a credential: **nothing is minted with
8772
+ * more than its minter holds.** A key's scopes must be within the creator's
8773
+ * own; a service key's RLS roles must be roles the creator holds, unless the
8774
+ * creator is an admin; and `keys:*` never goes on a key at all.
7342
8775
  *
7343
8776
  * @module
7344
8777
  */
8778
+ var TARGET_LISTS = {
8779
+ collection: "collections",
8780
+ bucket: "buckets",
8781
+ function: "functions"
8782
+ };
8783
+ /** Read a `scopes` body field: a non-empty array of strings, deduplicated. */
8784
+ function readScopesField(value) {
8785
+ if (!Array.isArray(value) || value.length === 0 || value.some((entry) => typeof entry !== "string")) throw ApiError.badRequest("scopes must be a non-empty array of scope strings, e.g. [\"data:read\", \"data:write:posts\"]", "INVALID_INPUT");
8786
+ return [...new Set(value.map((entry) => entry.trim()))];
8787
+ }
8788
+ /** Read a `roles` body field: an array of non-empty strings, deduplicated, without `service`. */
8789
+ function readRolesField(value) {
8790
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim() === "")) throw ApiError.badRequest("roles must be an array of role names", "INVALID_INPUT");
8791
+ return [...new Set(value.map((entry) => entry.trim()))].filter((role) => role !== "service");
8792
+ }
7345
8793
  /**
7346
- * Validate that a permissions array is well-formed.
7347
- */
7348
- function validatePermissions(permissions) {
7349
- if (!Array.isArray(permissions)) return false;
7350
- for (const perm of permissions) {
7351
- if (typeof perm !== "object" || perm === null) return false;
7352
- if (typeof perm.collection !== "string" || perm.collection.length === 0) return false;
7353
- if (!Array.isArray(perm.operations) || perm.operations.length === 0) return false;
7354
- const validOps = /* @__PURE__ */ new Set([
7355
- "read",
7356
- "write",
7357
- "delete"
7358
- ]);
7359
- for (const op of perm.operations) if (!validOps.has(op)) return false;
8794
+ * Refuse scopes that are malformed, unknown, aimed at a target that does not
8795
+ * exist, for key management, or beyond what `minterScopes` covers.
8796
+ */
8797
+ function assertScopesGrantable(requested, minterScopes, model, targets) {
8798
+ const problems = validateScopes(requested, model);
8799
+ if (problems.length > 0) throw new ApiError(400, "INVALID_SCOPES", `Not valid scopes: ${problems.map((p) => `"${p.scope}" (${p.reason})`).join(", ")}. Valid scopes are ${knownScopes(model).join(", ")}; data, storage and function scopes take a target after a second colon, e.g. "data:read:posts".`, {
8800
+ problems,
8801
+ validScopes: knownScopes(model)
8802
+ });
8803
+ const management = requested.filter((entry) => KEY_MANAGEMENT_SCOPES.includes(parseScope(entry)?.scope ?? ""));
8804
+ if (management.length > 0) throw new ApiError(400, "KEY_MANAGEMENT_SCOPE", `${management.join(", ")} cannot go on a key: a key that manages keys could mint its own successor. Manage keys as a person, or with the service key.`, { scopes: management });
8805
+ if (targets) {
8806
+ const unknown = [];
8807
+ for (const entry of requested) {
8808
+ const parsed = parseScope(entry);
8809
+ if (!parsed?.target || !isBuiltInScope(parsed.scope)) continue;
8810
+ const kind = SCOPE_TARGETS[parsed.scope];
8811
+ if (!kind) continue;
8812
+ if (!targets[TARGET_LISTS[kind]]().includes(parsed.target)) unknown.push(entry);
8813
+ }
8814
+ if (unknown.length > 0) throw new ApiError(400, "UNKNOWN_SCOPE_TARGET", `These scopes name something this backend does not serve: ${unknown.join(", ")}. A target is a collection slug, a storage source id or a function name.`, {
8815
+ scopes: unknown,
8816
+ collections: targets.collections(),
8817
+ buckets: targets.buckets(),
8818
+ functions: targets.functions()
8819
+ });
7360
8820
  }
7361
- return true;
8821
+ const beyond = scopesBeyond(requested, minterScopes);
8822
+ if (beyond.length > 0) throw new ApiError(403, "SCOPE_EXCEEDS_CREATOR", `A key cannot hold more than the account creating it, and you do not hold ${beyond.join(", ")}.`, { scopes: beyond });
8823
+ }
8824
+ /**
8825
+ * Refuse RLS roles the minter does not hold. An admin may give any role — they
8826
+ * already read every row — and anyone else only their own.
8827
+ */
8828
+ function assertRolesGrantable(requested, minterRoles) {
8829
+ if (hasAdminRole(minterRoles)) return;
8830
+ const beyond = requested.filter((role) => !minterRoles.includes(role));
8831
+ if (beyond.length > 0) throw new ApiError(403, "ROLE_EXCEEDS_CREATOR", `A key cannot run as a role the account creating it does not hold: ${beyond.join(", ")}.`, { roles: beyond });
8832
+ }
8833
+ /** Read an optional `expires_at`: absent, null, or a future ISO-8601 instant. */
8834
+ function readExpiresAt(value, requireFuture) {
8835
+ if (value === void 0) return void 0;
8836
+ if (value === null) return null;
8837
+ const parsed = typeof value === "string" ? new Date(value) : /* @__PURE__ */ new Date(NaN);
8838
+ if (isNaN(parsed.getTime())) throw ApiError.badRequest("expires_at must be a valid ISO-8601 date", "INVALID_INPUT");
8839
+ if (requireFuture && parsed <= /* @__PURE__ */ new Date()) throw ApiError.badRequest("expires_at must be in the future", "INVALID_INPUT");
8840
+ return parsed.toISOString();
8841
+ }
8842
+ /** Read an optional `rate_limit`: absent, null, or a positive integer. */
8843
+ function readRateLimit(value) {
8844
+ if (value === void 0) return void 0;
8845
+ if (value === null) return null;
8846
+ if (typeof value !== "number" || value < 1 || !Number.isInteger(value)) throw ApiError.badRequest("rate_limit must be a positive integer or null", "INVALID_INPUT");
8847
+ return value;
8848
+ }
8849
+ /** Read a key name: a non-empty string, trimmed. */
8850
+ function readName(value) {
8851
+ if (typeof value !== "string" || value.trim().length === 0) throw ApiError.badRequest("Name is required", "INVALID_INPUT");
8852
+ return value.trim();
7362
8853
  }
8854
+ //#endregion
8855
+ //#region src/auth/api-keys/api-key-routes.ts
7363
8856
  /**
7364
- * Refuse API-key-authenticated requests to the key-management routes.
8857
+ * Routes for managing API keys.
8858
+ *
8859
+ * - {@link createApiKeyRoutes} — the project's service keys, under
8860
+ * `/api/admin/api-keys`, for holders of `keys:read` / `keys:write`.
8861
+ * - {@link createPersonalKeyRoutes} — the caller's own personal keys, under
8862
+ * `/api/auth/keys`, when the app enables `auth.personalKeys`.
7365
8863
  *
7366
- * `createApiKeyPreAuth` runs in front of every `/admin/*` router so that keys
7367
- * created with `admin: true` genuinely reach the admin surfaces — their
7368
- * documented behaviour, and the right behaviour everywhere except here.
7369
- * Applied to *this* router it means an admin key can mint a second admin key,
7370
- * widen its own grants through `PUT /:id`, or revoke the keys other
7371
- * integrations run on. Revoking the original key undoes none of that, because
7372
- * the keys it minted are ordinary rows with no link back to it.
8864
+ * All routes return masked keys (never the hash). The full plaintext key is
8865
+ * returned exactly once, in the response that creates it. No API key may call
8866
+ * either router.
8867
+ *
8868
+ * @module
8869
+ */
8870
+ /**
8871
+ * Refuse API-key-authenticated requests to the key-management routes.
7373
8872
  *
7374
- * So key management is reserved for callers that are not themselves an API
7375
- * key: a human admin's session, or the service key.
8873
+ * A key that could manage keys could mint its own successor, widen itself, or
8874
+ * revoke the keys other integrations run on — and revoking it would undo none
8875
+ * of that, because the keys it minted are ordinary rows with no link back to
8876
+ * it. So key management is for a person, or the service key. Reads are
8877
+ * refused alongside writes: a listing of every key's name, prefix and scopes
8878
+ * is reconnaissance for exactly that.
7376
8879
  *
7377
- * Reads are refused alongside writes. The list response enumerates every key's
7378
- * name, prefix, permission set and admin flag — reconnaissance for exactly the
7379
- * escalation above — and "an API key never touches API keys" is a rule with no
7380
- * edges left to get wrong.
8880
+ * `keys:*` can never be granted to a key either (`key-grant.ts`); this guard is
8881
+ * what makes the rule hold for a key minted before that, and for personal
8882
+ * keys, whose routes need no scope.
7381
8883
  */
7382
8884
  var rejectApiKeyAuth = async (c, next) => {
7383
- if (c.get("apiKey")) throw ApiError.forbidden("API keys cannot manage API keys. Authenticate as an admin user, or use the service key — REBASE_SERVICE_KEY in this server's environment, or `rebase cloud env reveal REBASE_SERVICE_KEY` on Rebase Cloud.", "API_KEY_SELF_MANAGEMENT_FORBIDDEN");
8885
+ if (c.get("apiKey")) throw ApiError.forbidden("API keys cannot manage API keys. Authenticate as a person, or use the service key — REBASE_SERVICE_KEY in this server's environment, or `rebase cloud env reveal REBASE_SERVICE_KEY` on Rebase Cloud.", "API_KEY_SELF_MANAGEMENT_FORBIDDEN");
7384
8886
  return next();
7385
8887
  };
8888
+ /** The caller's uid and roles, as the auth gate resolved them. */
8889
+ function caller(c) {
8890
+ const user = c.get("user");
8891
+ if (!user || typeof user !== "object" || !("uid" in user) || typeof user.uid !== "string") throw ApiError.unauthenticated("Authentication required");
8892
+ const roles = "roles" in user && Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : [];
8893
+ const isAnonymous = "isAnonymous" in user && user.isAnonymous === true;
8894
+ return {
8895
+ uid: user.uid,
8896
+ roles,
8897
+ isAnonymous
8898
+ };
8899
+ }
8900
+ async function readBody(c) {
8901
+ let body;
8902
+ try {
8903
+ body = await c.req.json();
8904
+ } catch {
8905
+ throw ApiError.badRequest("Request body must be JSON", "INVALID_INPUT");
8906
+ }
8907
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw ApiError.badRequest("Request body must be a JSON object", "INVALID_INPUT");
8908
+ return Object.fromEntries(Object.entries(body));
8909
+ }
7386
8910
  /**
7387
- * Create admin routes for API key management.
8911
+ * The project's service keys: `GET` needs `keys:read`, everything else
8912
+ * `keys:write`.
7388
8913
  */
7389
8914
  function createApiKeyRoutes(options) {
7390
- const { store, serviceKey, resolveRoles, revocationRepo } = options;
8915
+ const { store, serviceKey, resolveRoles, revocationRepo, targets } = options;
8916
+ const model = options.accessModel ?? getAccessModel;
7391
8917
  const router = new Hono();
7392
8918
  router.onError(errorHandler);
7393
8919
  router.use("/*", createRequireAuth({
@@ -7396,72 +8922,127 @@ function createApiKeyRoutes(options) {
7396
8922
  revocationRepo
7397
8923
  }));
7398
8924
  router.use("/*", rejectApiKeyAuth);
7399
- router.use("/*", requireAdmin);
8925
+ router.use("/*", requireScopeByMethod({
8926
+ read: "keys:read",
8927
+ write: "keys:write"
8928
+ }));
7400
8929
  router.get("/", async (c) => {
7401
- const keys = await store.listApiKeys();
8930
+ const keys = await store.listApiKeys({ kind: "service" });
7402
8931
  return c.json({ keys });
7403
8932
  });
7404
8933
  router.post("/", async (c) => {
7405
- const { name, permissions, admin, rate_limit, expires_at } = await c.req.json();
7406
- if (!name || typeof name !== "string" || name.trim().length === 0) throw ApiError.badRequest("Name is required", "INVALID_INPUT");
7407
- if (!validatePermissions(permissions)) throw ApiError.badRequest("Permissions must be an array of { collection: string, operations: ('read' | 'write' | 'delete')[] }", "INVALID_INPUT");
7408
- if (admin !== void 0 && typeof admin !== "boolean") throw ApiError.badRequest("admin must be a boolean", "INVALID_INPUT");
7409
- if (rate_limit !== void 0 && rate_limit !== null) {
7410
- if (typeof rate_limit !== "number" || rate_limit < 1 || !Number.isInteger(rate_limit)) throw ApiError.badRequest("rate_limit must be a positive integer or null", "INVALID_INPUT");
7411
- }
7412
- if (expires_at !== void 0 && expires_at !== null) {
7413
- const parsed = new Date(expires_at);
7414
- if (isNaN(parsed.getTime())) throw ApiError.badRequest("expires_at must be a valid ISO-8601 date", "INVALID_INPUT");
7415
- if (parsed <= /* @__PURE__ */ new Date()) throw ApiError.badRequest("expires_at must be in the future", "INVALID_INPUT");
7416
- }
7417
- const user = c.get("user");
7418
- const createdBy = user && typeof user === "object" && "uid" in user ? user.uid : "unknown";
7419
- const request = {
7420
- name: name.trim(),
7421
- permissions,
7422
- admin: admin ?? false,
7423
- rate_limit: rate_limit ?? null,
7424
- expires_at: expires_at ?? null
7425
- };
7426
- const keyWithSecret = await store.createApiKey(request, createdBy);
7427
- return c.json({ key: keyWithSecret }, 201);
8934
+ const body = await readBody(c);
8935
+ const name = readName(body.name);
8936
+ const scopes = readScopesField(body.scopes);
8937
+ const roles = body.roles === void 0 ? [] : readRolesField(body.roles);
8938
+ const rate_limit = readRateLimit(body.rate_limit) ?? null;
8939
+ const expires_at = readExpiresAt(body.expires_at, true) ?? null;
8940
+ const minter = caller(c);
8941
+ assertScopesGrantable(scopes, callerScopes(c), model(), targets);
8942
+ assertRolesGrantable(roles, minter.roles);
8943
+ const key = await store.createApiKey({
8944
+ name,
8945
+ kind: "service",
8946
+ scopes,
8947
+ roles,
8948
+ owner_uid: null,
8949
+ rate_limit,
8950
+ expires_at
8951
+ }, minter.uid);
8952
+ return c.json({ key }, 201);
7428
8953
  });
7429
8954
  router.get("/:id", async (c) => {
7430
- const id = c.req.param("id");
7431
- const key = await store.getApiKeyById(id);
7432
- if (!key) throw ApiError.notFound("API key not found");
8955
+ const key = await store.getApiKeyById(c.req.param("id"));
8956
+ if (!key || key.kind !== "service") throw ApiError.notFound("API key not found");
7433
8957
  return c.json({ key });
7434
8958
  });
7435
8959
  router.put("/:id", async (c) => {
7436
8960
  const id = c.req.param("id");
7437
- const { name, permissions, admin, rate_limit, expires_at } = await c.req.json();
7438
- if (name !== void 0) {
7439
- if (typeof name !== "string" || name.trim().length === 0) throw ApiError.badRequest("Name must be a non-empty string", "INVALID_INPUT");
7440
- }
7441
- if (permissions !== void 0) {
7442
- if (!validatePermissions(permissions)) throw ApiError.badRequest("Permissions must be an array of { collection: string, operations: ('read' | 'write' | 'delete')[] }", "INVALID_INPUT");
7443
- }
7444
- if (admin !== void 0 && typeof admin !== "boolean") throw ApiError.badRequest("admin must be a boolean", "INVALID_INPUT");
7445
- if (rate_limit !== void 0 && rate_limit !== null) {
7446
- if (typeof rate_limit !== "number" || rate_limit < 1 || !Number.isInteger(rate_limit)) throw ApiError.badRequest("rate_limit must be a positive integer or null", "INVALID_INPUT");
8961
+ const body = await readBody(c);
8962
+ const minter = caller(c);
8963
+ const updates = {};
8964
+ if (body.name !== void 0) updates.name = readName(body.name);
8965
+ if (body.scopes !== void 0) {
8966
+ updates.scopes = readScopesField(body.scopes);
8967
+ assertScopesGrantable(updates.scopes, callerScopes(c), model(), targets);
7447
8968
  }
7448
- if (expires_at !== void 0 && expires_at !== null) {
7449
- const parsed = new Date(expires_at);
7450
- if (isNaN(parsed.getTime())) throw ApiError.badRequest("expires_at must be a valid ISO-8601 date", "INVALID_INPUT");
8969
+ if (body.roles !== void 0) {
8970
+ updates.roles = readRolesField(body.roles);
8971
+ assertRolesGrantable(updates.roles, minter.roles);
7451
8972
  }
7452
- const updates = {};
7453
- if (name !== void 0) updates.name = name.trim();
7454
- if (permissions !== void 0) updates.permissions = permissions;
7455
- if (admin !== void 0) updates.admin = admin;
7456
- if (rate_limit !== void 0) updates.rate_limit = rate_limit;
7457
- if (expires_at !== void 0) updates.expires_at = expires_at;
8973
+ const rateLimit = readRateLimit(body.rate_limit);
8974
+ if (rateLimit !== void 0) updates.rate_limit = rateLimit;
8975
+ const expiresAt = readExpiresAt(body.expires_at, false);
8976
+ if (expiresAt !== void 0) updates.expires_at = expiresAt;
7458
8977
  const key = await store.updateApiKey(id, updates);
7459
8978
  if (!key) throw ApiError.notFound("API key not found");
7460
8979
  return c.json({ key });
7461
8980
  });
7462
8981
  router.delete("/:id", async (c) => {
7463
- const id = c.req.param("id");
7464
- if (!await store.revokeApiKey(id)) throw ApiError.notFound("API key not found or already revoked");
8982
+ if (!await store.revokeApiKey(c.req.param("id"))) throw ApiError.notFound("API key not found or already revoked");
8983
+ return c.json({ success: true });
8984
+ });
8985
+ return router;
8986
+ }
8987
+ /**
8988
+ * The caller's own keys. Each acts as the caller — their account, their roles
8989
+ * as they are when the key is used — and holds no scope the caller does not.
8990
+ *
8991
+ * For a signed-in account only: not a key, not the service key (it has no
8992
+ * account to act as) and not a guest, whose account is one sign-out from gone.
8993
+ */
8994
+ function createPersonalKeyRoutes(options) {
8995
+ const { store, enabled, serviceKey, resolveRoles, revocationRepo, targets } = options;
8996
+ const model = options.accessModel ?? getAccessModel;
8997
+ const router = new Hono();
8998
+ router.onError(errorHandler);
8999
+ if (!enabled) {
9000
+ router.all("/*", () => {
9001
+ throw ApiError.forbidden("Personal API keys are switched off on this backend. Set `personalKeys: true` in the auth block of the users collection to let accounts create them.", "PERSONAL_KEYS_DISABLED");
9002
+ });
9003
+ return router;
9004
+ }
9005
+ router.use("/*", createRequireAuth({
9006
+ serviceKey,
9007
+ resolveRoles,
9008
+ revocationRepo
9009
+ }));
9010
+ router.use("/*", rejectApiKeyAuth);
9011
+ router.use("/*", async (c, next) => {
9012
+ const me = caller(c);
9013
+ if (me.uid === "service") throw ApiError.forbidden("The service key has no account for a personal key to act as. Create a service key under /api/admin/api-keys instead.", "PERSONAL_KEY_NEEDS_ACCOUNT");
9014
+ if (me.isAnonymous) throw ApiError.forbidden("A guest session cannot create API keys. Sign in to an account first.", "PERSONAL_KEY_NEEDS_ACCOUNT");
9015
+ return next();
9016
+ });
9017
+ router.get("/", async (c) => {
9018
+ const keys = await store.listApiKeys({
9019
+ kind: "personal",
9020
+ owner_uid: caller(c).uid
9021
+ });
9022
+ return c.json({ keys });
9023
+ });
9024
+ router.post("/", async (c) => {
9025
+ const body = await readBody(c);
9026
+ if (body.roles !== void 0) throw ApiError.badRequest("A personal key runs as your account's roles and takes none of its own.", "INVALID_INPUT");
9027
+ if (body.rate_limit !== void 0) throw ApiError.badRequest("A personal key uses the server's API-key rate limit and cannot set its own.", "INVALID_INPUT");
9028
+ const name = readName(body.name);
9029
+ const scopes = readScopesField(body.scopes);
9030
+ const expires_at = readExpiresAt(body.expires_at, true) ?? null;
9031
+ const me = caller(c);
9032
+ assertScopesGrantable(scopes, callerScopes(c), model(), targets);
9033
+ const key = await store.createApiKey({
9034
+ name,
9035
+ kind: "personal",
9036
+ scopes,
9037
+ roles: [],
9038
+ owner_uid: me.uid,
9039
+ rate_limit: null,
9040
+ expires_at
9041
+ }, me.uid);
9042
+ return c.json({ key }, 201);
9043
+ });
9044
+ router.delete("/:id", async (c) => {
9045
+ if (!await store.revokeApiKey(c.req.param("id"), caller(c).uid)) throw ApiError.notFound("API key not found or already revoked");
7465
9046
  return c.json({ success: true });
7466
9047
  });
7467
9048
  return router;
@@ -7512,12 +9093,16 @@ function createCustomAuthAdapter(options) {
7512
9093
  //#endregion
7513
9094
  //#region src/auth/index.ts
7514
9095
  var auth_exports = /* @__PURE__ */ __exportAll({
9096
+ AccessModelError: () => AccessModelError,
7515
9097
  DEFAULT_CAPTCHA_ROUTES: () => DEFAULT_CAPTCHA_ROUTES,
7516
9098
  MemoryRateLimitStore: () => MemoryRateLimitStore,
7517
9099
  RateLimitStoreConfigurationError: () => RateLimitStoreConfigurationError,
9100
+ accessModelFromCollections: () => accessModelFromCollections,
7518
9101
  apiKeyKeyGenerator: () => apiKeyKeyGenerator,
7519
9102
  buildBuiltinAuthCapabilities: () => buildBuiltinAuthCapabilities,
7520
9103
  buildCaptchaMiddlewares: () => buildCaptchaMiddlewares,
9104
+ callerScopes: () => callerScopes,
9105
+ configureAccess: () => configureAccess,
7521
9106
  configureJwt: () => configureJwt,
7522
9107
  createAdapterAuthMiddleware: () => createAdapterAuthMiddleware,
7523
9108
  createApiKeyRoutes: () => createApiKeyRoutes,
@@ -7539,6 +9124,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
7539
9124
  createJwksRoutes: () => createJwksRoutes,
7540
9125
  createLinkedinProvider: () => createLinkedinProvider,
7541
9126
  createMicrosoftProvider: () => createMicrosoftProvider,
9127
+ createPersonalKeyRoutes: () => createPersonalKeyRoutes,
7542
9128
  createRateLimiter: () => createRateLimiter,
7543
9129
  createResetPasswordRoute: () => createResetPasswordRoute,
7544
9130
  createSlackProvider: () => createSlackProvider,
@@ -7555,17 +9141,18 @@ var auth_exports = /* @__PURE__ */ __exportAll({
7555
9141
  generateRefreshToken: () => generateRefreshToken,
7556
9142
  generateSecurePassword: () => generateSecurePassword,
7557
9143
  generateSecureToken: () => generateSecureToken,
9144
+ getAccessModel: () => getAccessModel,
7558
9145
  getAccessTokenExpiry: () => getAccessTokenExpiry,
7559
9146
  getJwks: () => getJwks,
7560
9147
  getRefreshTokenExpiry: () => getRefreshTokenExpiry,
7561
9148
  hasAsymmetricSigningKey: () => hasAsymmetricSigningKey,
9149
+ hasScope: () => hasScope,
7562
9150
  hashPassword: () => hashPassword,
7563
9151
  hashRefreshToken: () => hashRefreshToken,
7564
- hashToken: () => hashToken$1,
9152
+ hashToken: () => hashToken,
7565
9153
  httpMethodToOperation: () => httpMethodToOperation,
7566
9154
  isApiKeyToken: () => isApiKeyToken,
7567
9155
  isJwtConfigured: () => isJwtConfigured,
7568
- isOperationAllowed: () => isOperationAllowed,
7569
9156
  isRedirectUriAllowed: () => isRedirectUriAllowed,
7570
9157
  mountMagicLinkRoutes: () => mountMagicLinkRoutes,
7571
9158
  oauthCodeFlowSchema: () => oauthCodeFlowSchema,
@@ -7576,6 +9163,9 @@ var auth_exports = /* @__PURE__ */ __exportAll({
7576
9163
  queryTokenAuth: () => queryTokenAuth,
7577
9164
  requireAdmin: () => requireAdmin,
7578
9165
  requireAuth: () => requireAuth,
9166
+ requireScope: () => requireScope,
9167
+ requireScopeByMethod: () => requireScopeByMethod,
9168
+ resolveApiKey: () => resolveApiKey,
7579
9169
  resolveAuthHooks: () => resolveAuthHooks,
7580
9170
  resolveCaptchaVerifier: () => resolveCaptchaVerifier,
7581
9171
  resolveRateLimitStoreKind: () => resolveRateLimitStoreKind,
@@ -7590,6 +9180,6 @@ var auth_exports = /* @__PURE__ */ __exportAll({
7590
9180
  verifyPassword: () => verifyPassword
7591
9181
  });
7592
9182
  //#endregion
7593
- export { requireAdmin as $, createRateLimiter as A, isPublicStoragePath as At, createEmailService as B, providerVerifiedEmail as C, RawHtml as Ct, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as D, httpMethodToOperation as Dt, isBootstrapWindowOpen as E, raw as Et, clearActiveDevEmailSink as F, createAdapterAuthMiddleware as G, hashPassword as H, createDevEmailSink as I, extractUserFromToken as J, createAuthMiddleware as K, extractLinks as L, setSharedRateLimitStore as M, MemoryRateLimitStore as N, createDataRateLimitCheck as O, isOperationAllowed as Ot, activeDevEmailSink as P, queryTokenAuth as Q, registerDevEmailSink as R, pkceTokenParams as S, resolveEmailBranding as St, createBuiltinAuthAdapter as T, html as Tt, validatePasswordStrength as U, resolveAuthHooks as V, verifyPassword as W, optionalAuth as X, fileTokenAuth as Y, publicObjectAuth as Z, verifyOidcIdToken as _, getEmailVerificationTemplate as _t, resolveRateLimitStoreKind as a, validateApiKey as at, createGoogleProvider as b, getUserInvitationTemplate as bt, createSlackProvider as c, SERVICE_IDENTITY as ct, createDiscordProvider as d, isAccessTokenRevoked as dt, requireAuth as et, createTwitterProvider as f, completeUserCreation as ft, tryVerifyOidcIdToken as g, getEmailOtpTemplate as gt, createMicrosoftProvider as h, resolveEmailLinkBase as ht, createApiKeyStore as i, isApiKeyToken as it, defaultAuthLimiter as j, createDataRateLimiter as k, PUBLIC_STORAGE_PREFIX as kt, createBitbucketProvider as l, scopeDataDriver as lt, createAppleProvider as m, assertEmailLinkBases as mt, createCustomAuthAdapter as n, createFunctionApiKeyGuard as nt, createSqlRateLimitStore as o, extractBearerToken as ot, createFacebookProvider as p, generateSecurePassword 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, getMagicLinkTemplate as vt, createJwksRoutes as w, escapeHtml as wt, oauthCodeFlowSchema as x, getWelcomeEmailTemplate as xt, createLinkedinProvider as y, getPasswordResetTemplate as yt, SMTPEmailService as z };
9183
+ export { queryTokenAuth as $, createDataRateLimiter as A, getEmailChangeNoticeTemplate as At, SMTPEmailService as B, escapeHtml as Bt, pkceTokenParams as C, scopeDataDriver as Ct, isBootstrapWindowOpen as D, generateSecurePassword as Dt, createBuiltinAuthAdapter as E, completeUserCreation as Et, activeDevEmailSink as F, getPasswordResetTemplate as Ft, verifyPassword as G, isPublicStorageKey as Gt, resolveAuthHooks as H, raw as Ht, clearActiveDevEmailSink as I, getUserInvitationTemplate as It, createRequireAuth as J, IMPERSONATE_HEADER as Jt, createAdapterAuthMiddleware as K, isPublicStoragePath as Kt, createDevEmailSink as L, getWelcomeEmailTemplate as Lt, defaultAuthLimiter as M, getEmailOtpTemplate as Mt, setSharedRateLimitStore as N, getEmailVerificationTemplate as Nt, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as O, assertEmailLinkBases as Ot, MemoryRateLimitStore as P, getMagicLinkTemplate as Pt, publicObjectAuth as Q, extractLinks as R, resolveEmailBranding as Rt, oauthCodeFlowSchema as S, SERVICE_IDENTITY as St, createJwksRoutes as T, isAccessTokenRevoked as Tt, hashPassword as U, httpMethodToOperation as Ut, createEmailService as V, html as Vt, validatePasswordStrength as W, PUBLIC_STORAGE_PREFIX as Wt, fileTokenAuth as X, extractUserFromToken as Y, optionalAuth as Z, tryVerifyOidcIdToken as _, hasScope as _t, createApiKeyStore as a, decideImpersonation as at, createLinkedinProvider as b, extractBearerToken as bt, createSpotifyProvider as c, createFunctionScopeGuard as ct, createGitLabProvider as d, resolveApiKey as dt, requireAdmin as et, createDiscordProvider as f, validateApiKey as ft, createMicrosoftProvider as g, getAccessModel as gt, createAppleProvider as h, configureAccess as ht, createPersonalKeyRoutes as i, storageRequestWildcard as it, createRateLimiter as j, getEmailChangeTemplate as jt, createDataRateLimitCheck as k, resolveEmailLinkBase as kt, createSlackProvider as l, createTusScopeGuard as lt, createFacebookProvider as m, callerScopes as mt, createCustomAuthAdapter as n, requestedStorageObject as nt, resolveRateLimitStoreKind as o, refuseUnhonouredImpersonation as ot, createTwitterProvider as p, accessModelFromCollections as pt, createAuthMiddleware as q, storageObjectPath as qt, createApiKeyRoutes as r, storageObjectOfPath as rt, createSqlRateLimitStore as s, createApiKeyPreAuth as st, auth_exports as t, requireAuth as tt, createBitbucketProvider as u, isApiKeyToken as ut, verifyOidcIdToken as v, requireScope as vt, providerVerifiedEmail as w, supportsRlsScoping as wt, createGoogleProvider as x, safeCompare as xt, createGitHubProvider as y, requireScopeByMethod as yt, registerDevEmailSink as z, RawHtml as zt };
7594
9184
 
7595
- //# sourceMappingURL=auth-B-GIMpDG.js.map
9185
+ //# sourceMappingURL=auth-DMLngxn_.js.map