@rebasepro/server 0.25.0 → 0.26.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 (174) hide show
  1. package/dist/{GCSStorageController-C7ARrDAp.js → GCSStorageController-40FyFeSx.js} +8 -4
  2. package/dist/GCSStorageController-40FyFeSx.js.map +1 -0
  3. package/dist/{S3StorageController-qevJ0pVE.js → S3StorageController-Bl5shp94.js} +8 -4
  4. package/dist/S3StorageController-Bl5shp94.js.map +1 -0
  5. package/dist/access-BFAUvsSs.js +1897 -0
  6. package/dist/access-BFAUvsSs.js.map +1 -0
  7. package/dist/api/branch-routes.d.ts +25 -0
  8. package/dist/api/closed-database.d.ts +25 -0
  9. package/dist/api/live-schema-routes.d.ts +10 -0
  10. package/dist/api/rest/api-generator.d.ts +9 -1
  11. package/dist/api/rest/idempotency.d.ts +15 -1
  12. package/dist/api/root-error-handler.d.ts +6 -5
  13. package/dist/api/types.d.ts +12 -0
  14. package/dist/{ast-schema-editor-mCeSC03x.js → ast-schema-editor-madTa9Hh.js} +2 -2
  15. package/dist/{ast-schema-editor-mCeSC03x.js.map → ast-schema-editor-madTa9Hh.js.map} +1 -1
  16. package/dist/auth/access.d.ts +10 -0
  17. package/dist/auth/adapter-middleware.d.ts +2 -1
  18. package/dist/auth/adapter-service-key.d.ts +24 -0
  19. package/dist/auth/api-keys/api-key-middleware.d.ts +21 -1
  20. package/dist/auth/api-keys/api-key-store.d.ts +8 -1
  21. package/dist/auth/api-keys/key-grant.d.ts +18 -1
  22. package/dist/auth/expired-token-sweep.d.ts +5 -0
  23. package/dist/auth/impersonation.d.ts +3 -1
  24. package/dist/auth/jwt.d.ts +8 -2
  25. package/dist/auth/oauth-signin-policy.d.ts +21 -35
  26. package/dist/auth/rls-scope.d.ts +17 -7
  27. package/dist/auth/rls-simulator-routes.d.ts +51 -0
  28. package/dist/auth/simulation-read-only.d.ts +28 -0
  29. package/dist/auth/simulation.d.ts +141 -0
  30. package/dist/auth/sql-rate-limit-store.d.ts +15 -1
  31. package/dist/{auth-CteGWxsL.js → auth-izI7AEzC.js} +391 -1893
  32. package/dist/auth-izI7AEzC.js.map +1 -0
  33. package/dist/auth_adapter-B7RUMk6e.js +89 -0
  34. package/dist/auth_adapter-B7RUMk6e.js.map +1 -0
  35. package/dist/backend/boot-announcements.d.ts +13 -0
  36. package/dist/backend/create-backend.d.ts +10 -0
  37. package/dist/backend/options.d.ts +9 -4
  38. package/dist/{backend-DTAOsLQc.js → backend-CaG_Jyso.js} +9 -2
  39. package/dist/backend-CaG_Jyso.js.map +1 -0
  40. package/dist/{backup-Dq0lyJ_D.js → backup-BVbHoKo8.js} +8 -8
  41. package/dist/{backup-Dq0lyJ_D.js.map → backup-BVbHoKo8.js.map} +1 -1
  42. package/dist/boot/boot.d.ts +2 -15
  43. package/dist/boot/ddl-bootstrap.d.ts +34 -0
  44. package/dist/boot/env.d.ts +6 -0
  45. package/dist/boot/fetch-bundle.d.ts +5 -1
  46. package/dist/boot/provision-only.d.ts +23 -0
  47. package/dist/boot/provision.d.ts +53 -2
  48. package/dist/boot/role.d.ts +2 -0
  49. package/dist/boot/schema-change-recorder.d.ts +45 -0
  50. package/dist/boot/security-headers.d.ts +22 -0
  51. package/dist/branch-routes-B2vvEx53.js +84 -0
  52. package/dist/branch-routes-B2vvEx53.js.map +1 -0
  53. package/dist/{contract-routes-DiFwiLg2.js → contract-routes-CU2A3JnF.js} +3 -3
  54. package/dist/{contract-routes-DiFwiLg2.js.map → contract-routes-CU2A3JnF.js.map} +1 -1
  55. package/dist/cron/cron-store.d.ts +8 -1
  56. package/dist/{cron-loader-NHqRfKF0.js → cron-loader-mMhqbVsl.js} +3 -3
  57. package/dist/{cron-loader-NHqRfKF0.js.map → cron-loader-mMhqbVsl.js.map} +1 -1
  58. package/dist/{cron-routes-Dhf82HzT.js → cron-routes-Cs33j_Kr.js} +5 -5
  59. package/dist/{cron-routes-Dhf82HzT.js.map → cron-routes-Cs33j_Kr.js.map} +1 -1
  60. package/dist/{cron-scheduler-D1DT0nw5.js → cron-scheduler-Be7CNghL.js} +3 -3
  61. package/dist/{cron-scheduler-D1DT0nw5.js.map → cron-scheduler-Be7CNghL.js.map} +1 -1
  62. package/dist/{cron-store-DEpVstUU.js → cron-store-DLn11M8H.js} +11 -10
  63. package/dist/cron-store-DLn11M8H.js.map +1 -0
  64. package/dist/{strings.es-BeZWAodn.js → data_driver-D0mg1Y_x.js} +2 -90
  65. package/dist/data_driver-D0mg1Y_x.js.map +1 -0
  66. package/dist/{ddl-bootstrap-BNqi2NZe.js → ddl-bootstrap-CjRrn12l.js} +92 -3
  67. package/dist/ddl-bootstrap-CjRrn12l.js.map +1 -0
  68. package/dist/deploy/pod-contract.d.ts +1 -1
  69. package/dist/email/dev-sink.d.ts +6 -0
  70. package/dist/email/index.d.ts +1 -1
  71. package/dist/{errors-_ytoGi8A.js → errors-OMWkKSH-.js} +66 -5
  72. package/dist/errors-OMWkKSH-.js.map +1 -0
  73. package/dist/{soft-delete-params-BAgpmBvp.js → field-access-DYsvjI_P.js} +27 -57
  74. package/dist/field-access-DYsvjI_P.js.map +1 -0
  75. package/dist/{filter-dialect-O1HK6Nvr.js → filter-dialect-TrKFWLqT.js} +3 -3
  76. package/dist/{filter-dialect-O1HK6Nvr.js.map → filter-dialect-TrKFWLqT.js.map} +1 -1
  77. package/dist/{function-loader-DbCSoNJj.js → function-loader-B8YzXEsQ.js} +22 -15
  78. package/dist/function-loader-B8YzXEsQ.js.map +1 -0
  79. package/dist/{function-routes-DNV4RUF0.js → function-routes-RxrCHNuM.js} +2 -2
  80. package/dist/{function-routes-DNV4RUF0.js.map → function-routes-RxrCHNuM.js.map} +1 -1
  81. package/dist/functions/hono-like.d.ts +12 -0
  82. package/dist/functions/index.js +37 -2
  83. package/dist/functions/index.js.map +1 -1
  84. package/dist/functions/request-timeout.d.ts +3 -3
  85. package/dist/history/history-routes.d.ts +6 -0
  86. package/dist/history/retention-sweep.d.ts +5 -0
  87. package/dist/{history-recorder-RZ1Ow4YY.js → history-recorder-Bsau7h4X.js} +7 -7
  88. package/dist/history-recorder-Bsau7h4X.js.map +1 -0
  89. package/dist/{history-store-DPofGVDp.js → history-store-BQz5YCkW.js} +26 -2
  90. package/dist/history-store-BQz5YCkW.js.map +1 -0
  91. package/dist/index.d.ts +3 -1
  92. package/dist/index.es.js +3003 -681
  93. package/dist/index.es.js.map +1 -1
  94. package/dist/init/process-safety.d.ts +4 -3
  95. package/dist/init/shutdown.d.ts +8 -0
  96. package/dist/init.d.ts +6 -1
  97. package/dist/jobs/job-store.d.ts +8 -1
  98. package/dist/{jobs-8G-_agld.js → jobs-Co6Ni5HP.js} +7 -7
  99. package/dist/jobs-Co6Ni5HP.js.map +1 -0
  100. package/dist/{jwt-YhciR93H.js → jwt-rDS0884_.js} +5 -4
  101. package/dist/{jwt-YhciR93H.js.map → jwt-rDS0884_.js.map} +1 -1
  102. package/dist/{keys-COzQ2aE8.js → keys-BXcdRNhJ.js} +2 -2
  103. package/dist/{keys-COzQ2aE8.js.map → keys-BXcdRNhJ.js.map} +1 -1
  104. package/dist/{logger-Bt32sHFw.js → logger-CW8pmTS6.js} +35 -3
  105. package/dist/logger-CW8pmTS6.js.map +1 -0
  106. package/dist/{logs-routes-CelTiS7C.js → logs-routes-CBt7mMIL.js} +3 -3
  107. package/dist/{logs-routes-CelTiS7C.js.map → logs-routes-CBt7mMIL.js.map} +1 -1
  108. package/dist/mcp/consent-page.d.ts +38 -1
  109. package/dist/mcp/mcp-routes.d.ts +9 -0
  110. package/dist/mcp/mcp-tools.d.ts +7 -1
  111. package/dist/mcp/oauth-store.d.ts +12 -5
  112. package/dist/metrics/history-recorder.d.ts +4 -1
  113. package/dist/metrics/history-store.d.ts +8 -0
  114. package/dist/{offline-BrqCRgih-BtDB1ecy.js → offline-AvuEcg7u-DFHL98OT.js} +127 -40
  115. package/dist/offline-AvuEcg7u-DFHL98OT.js.map +1 -0
  116. package/dist/{offline-store-BhYXViha-BW5j1We_.js → offline-store-BhYXViha-YTmjBpo2.js} +5 -5
  117. package/dist/{offline-store-BhYXViha-BW5j1We_.js.map → offline-store-BhYXViha-YTmjBpo2.js.map} +1 -1
  118. package/dist/{openapi-generator-BamY6-ga.js → openapi-generator-CyxEtYEX.js} +61 -22
  119. package/dist/openapi-generator-CyxEtYEX.js.map +1 -0
  120. package/dist/{proxy-BEKbsil6.js → proxy-BYir8tWK.js} +5 -7
  121. package/dist/proxy-BYir8tWK.js.map +1 -0
  122. package/dist/{query-parser-CGohjyWI.js → query-parser-CDXWf2hH.js} +7 -6
  123. package/dist/{query-parser-CGohjyWI.js.map → query-parser-CDXWf2hH.js.map} +1 -1
  124. package/dist/{request-timeout-CRny5KD0.js → request-timeout-DVjo-c_8.js} +6 -6
  125. package/dist/request-timeout-DVjo-c_8.js.map +1 -0
  126. package/dist/rls-simulator-routes-bEY7-5-u.js +190 -0
  127. package/dist/rls-simulator-routes-bEY7-5-u.js.map +1 -0
  128. package/dist/schema-edit/deployed-schema-guard.d.ts +47 -0
  129. package/dist/{schema-editor-routes-CnNGCayU.js → schema-editor-routes-Dc3DU_jm.js} +4 -4
  130. package/dist/{schema-editor-routes-CnNGCayU.js.map → schema-editor-routes-Dc3DU_jm.js.map} +1 -1
  131. package/dist/soft-delete-params-BRONCOA2.js +59 -0
  132. package/dist/soft-delete-params-BRONCOA2.js.map +1 -0
  133. package/dist/{sql-export-routes-BgTJ7K3S.js → sql-export-routes-QycX8W8S.js} +40 -6
  134. package/dist/sql-export-routes-QycX8W8S.js.map +1 -0
  135. package/dist/{src-D5Y3N-S4.js → src-Bh9LRPb9.js} +21 -2
  136. package/dist/{src-D5Y3N-S4.js.map → src-Bh9LRPb9.js.map} +1 -1
  137. package/dist/storage/GCSStorageController.d.ts +2 -0
  138. package/dist/storage/LocalStorageController.d.ts +8 -3
  139. package/dist/storage/S3StorageController.d.ts +2 -0
  140. package/dist/storage/maintenance-keys.d.ts +17 -0
  141. package/dist/storage/maintenance-routes.d.ts +28 -5
  142. package/dist/storage/maintenance.d.ts +136 -4
  143. package/dist/storage/orphan-scans.d.ts +120 -0
  144. package/dist/storage/types.d.ts +11 -0
  145. package/dist/strings.es-B7QLnHh2.js +96 -0
  146. package/dist/strings.es-B7QLnHh2.js.map +1 -0
  147. package/dist/{upload-validation-I9cCUBIW.js → upload-validation-Bgo626uY.js} +2 -2
  148. package/dist/upload-validation-Bgo626uY.js.map +1 -0
  149. package/dist/utils/dev-dependency-command.d.ts +15 -0
  150. package/dist/utils/dev-port.d.ts +8 -0
  151. package/dist/utils/logger.d.ts +24 -0
  152. package/package.json +5 -5
  153. package/dist/GCSStorageController-C7ARrDAp.js.map +0 -1
  154. package/dist/S3StorageController-qevJ0pVE.js.map +0 -1
  155. package/dist/auth-CteGWxsL.js.map +0 -1
  156. package/dist/auth_adapter-CIItCTc8.js +0 -21
  157. package/dist/auth_adapter-CIItCTc8.js.map +0 -1
  158. package/dist/backend-DTAOsLQc.js.map +0 -1
  159. package/dist/cron-store-DEpVstUU.js.map +0 -1
  160. package/dist/ddl-bootstrap-BNqi2NZe.js.map +0 -1
  161. package/dist/errors-_ytoGi8A.js.map +0 -1
  162. package/dist/function-loader-DbCSoNJj.js.map +0 -1
  163. package/dist/history-recorder-RZ1Ow4YY.js.map +0 -1
  164. package/dist/history-store-DPofGVDp.js.map +0 -1
  165. package/dist/jobs-8G-_agld.js.map +0 -1
  166. package/dist/logger-Bt32sHFw.js.map +0 -1
  167. package/dist/offline-BrqCRgih-BtDB1ecy.js.map +0 -1
  168. package/dist/openapi-generator-BamY6-ga.js.map +0 -1
  169. package/dist/proxy-BEKbsil6.js.map +0 -1
  170. package/dist/request-timeout-CRny5KD0.js.map +0 -1
  171. package/dist/soft-delete-params-BAgpmBvp.js.map +0 -1
  172. package/dist/sql-export-routes-BgTJ7K3S.js.map +0 -1
  173. package/dist/strings.es-BeZWAodn.js.map +0 -1
  174. package/dist/upload-validation-I9cCUBIW.js.map +0 -1
@@ -3,20 +3,18 @@ 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 { r as toSnakeCase } from "./strings.es-BeZWAodn.js";
7
- import { B as rewriteLegacyRlsFunctions, _ 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-D5Y3N-S4.js";
6
+ import { V as rewriteLegacyRlsFunctions, _ as scopesBeyond, a as KEY_MANAGEMENT_SCOPES, f as knownScopes, l as isAdminScope, m as roleNameProblem, o as SCOPE_TARGETS, p as parseScope, s as hasAdminRole, u as isBuiltInScope, x as validateScopes, y as summarizeRoles } from "./src-Bh9LRPb9.js";
8
7
  import { n as isAnonymousUid, r as policy, t as ANONYMOUS_USER_ID } from "./policy-PdzX-Opd.js";
9
- import { t as isSQLAdmin } from "./backend-DTAOsLQc.js";
10
- import { t as IMPERSONATE_HEADER } from "./auth_adapter-CIItCTc8.js";
11
- import { r as logger } from "./logger-Bt32sHFw.js";
8
+ import { n as isSQLAdmin } from "./backend-CaG_Jyso.js";
9
+ import { $ as accessTokenRefusalError, C as queryTokenAuth, E as requireAuth, G as isApiKeyToken, J as extractBearerToken, K as resolveApiKey, L as applySimulation, P as applyImpersonation, T as requireAdmin, X as SERVICE_IDENTITY, Y as safeCompare, Z as scopeDataDriver, _ as createAuthMiddleware, a as assertMayManageAccount, at as httpMethodToOperation, b as fileTokenAuth, c as configureAccess, f as processAccessControl, h as requireScopeByMethod, i as assertMayGrantRoles, l as getAccessModel, m as requireScope, n as accessModelFromCollections, nt as replaceUserPassword, o as callerIsAdmin, p as readRoleNames, q as validateApiKey, r as accessOf, rt as revokeAllSessions, s as callerScopes, t as AccessModelError, tt as judgeAccessToken, u as hasScope, v as createRequireAuth, w as refuse, x as optionalAuth, y as extractUserFromToken } from "./access-BFAUvsSs.js";
10
+ import { a as logger } from "./logger-CW8pmTS6.js";
11
+ import { r as toSnakeCase } from "./strings.es-B7QLnHh2.js";
12
12
  import { n as revokeInternalTableSql, t as REBASE_USER_ROLE } from "./internal-tables-L2t39RdT.js";
13
13
  import { t as firstSqlRow } from "./sql-rows-C6GEc2oE.js";
14
- import { a as resolveListLimitParam } from "./query-parser-CGohjyWI.js";
15
- import { a as isDataException, i as errorHandler, o as isDatabaseUnreachable, t as ApiError } from "./errors-_ytoGi8A.js";
16
- import { E as sha256Hex, S as constantTimeEqual, _ as verifyAccessToken, a as generateAccessToken, c as getAccessTokenExpiry, d as hasAsymmetricSigningKey, f as hashRefreshToken, h as processJwtService, i as customClaimsOf, l as getJwks, m as jwtOf, n as configureJwt, o as generateDownloadToken, p as isJwtConfigured, s as generateRefreshToken, t as MAX_COOKIE_AGE_MS, u as getRefreshTokenExpiry, v as verifyDownloadToken, w as randomInt$1, x as require_jsonwebtoken } from "./jwt-YhciR93H.js";
17
- import { a as canonicalStorageId, o as canonicalStorageKey, r as InvalidStorageKeyError } from "./keys-COzQ2aE8.js";
18
- import { n as scopeGuardProblem } from "./admin-roles-DI9RsgN3.js";
19
- import { n as createDdlBootstrapper } from "./ddl-bootstrap-BNqi2NZe.js";
14
+ import { a as resolveListLimitParam } from "./query-parser-CDXWf2hH.js";
15
+ import { _ as verifyAccessToken, a as generateAccessToken, c as getAccessTokenExpiry, d as hasAsymmetricSigningKey, f as hashRefreshToken, h as processJwtService, i as customClaimsOf, l as getJwks, m as jwtOf, n as configureJwt, o as generateDownloadToken, p as isJwtConfigured, s as generateRefreshToken, t as MAX_COOKIE_AGE_MS, u as getRefreshTokenExpiry, v as verifyDownloadToken, w as randomInt$1, x as require_jsonwebtoken } from "./jwt-rDS0884_.js";
16
+ import { c as provisioningExec, n as SchemaChangeRefusedError, r as createDdlBootstrapper } from "./ddl-bootstrap-CjRrn12l.js";
17
+ import { a as isDataException, c as warnIfDatabaseClosed, i as errorHandler, o as isDatabaseUnreachable, t as ApiError } from "./errors-OMWkKSH-.js";
20
18
  import { createHash, randomBytes, randomInt } from "node:crypto";
21
19
  import { Hono } from "hono";
22
20
  import { promisify } from "util";
@@ -24,108 +22,6 @@ import { createCipheriv, createDecipheriv, createHash as createHash$1, createHma
24
22
  import { getConnInfo } from "@hono/node-server/conninfo";
25
23
  import { createMiddleware } from "hono/factory";
26
24
  import { z } from "zod";
27
- //#region ../types/src/controllers/storage.ts
28
- /**
29
- * Path prefix that marks an object as **public**. Files stored under this
30
- * prefix are served without any auth token via a stable, permanent,
31
- * CDN-cacheable URL (see {@link StorageSource.getSignedUrl}). Shared by the
32
- * client SDK and the backend so both agree on which objects are public.
33
- *
34
- * @group Models
35
- */
36
- var PUBLIC_STORAGE_PREFIX = "public/";
37
- /**
38
- * True when a storage **key** is public: it lies under
39
- * {@link PUBLIC_STORAGE_PREFIX}.
40
- *
41
- * The key is read exactly as it is — the canonical key within its bucket, the
42
- * one the server stores, serves, and hands a `storageAuthorize` hook as
43
- * `key` — and nothing is parsed out of it. That is the difference from
44
- * {@link isPublicStoragePath}, which reads a client-side *path* and so strips
45
- * a `scheme://` and a leading `default/` bucket segment first. On a key both are
46
- * wrong: `default/public/x` is a key in a folder called `default`, and a
47
- * request path `notes://public/x` names the key `notes:/public/x`. Neither is
48
- * public, and the server decides on this function so that it never says they
49
- * are.
50
- *
51
- * @group Models
52
- */
53
- function isPublicStorageKey(key) {
54
- if (!key || !key.startsWith("public/")) return false;
55
- return !key.split(/[\\/]/).includes("..");
56
- }
57
- /**
58
- * True when a storage key or client-side path points at a public object
59
- * (lives under {@link PUBLIC_STORAGE_PREFIX}).
60
- *
61
- * For a path as a client holds it: a `scheme://` prefix and a single leading
62
- * `default/` bucket segment are stripped first. The server and a
63
- * `storageAuthorize` hook, which hold the canonical key, use
64
- * {@link isPublicStorageKey}.
65
- *
66
- * @group Models
67
- */
68
- function isPublicStoragePath(path) {
69
- if (!path) return false;
70
- let p = path;
71
- const scheme = p.indexOf("://");
72
- if (scheme !== -1) p = p.substring(scheme + 3);
73
- p = p.replace(/^\/+/, "");
74
- if (p.split("/").some((seg) => seg === "..")) return false;
75
- return p.startsWith("public/") || p.startsWith(`default/public/`);
76
- }
77
- /**
78
- * The path the storage routes address an object by, from a key or from the
79
- * `storageUrl` an upload returned (`<scheme>://<bucket>/<key>`, which is what a
80
- * property with `includeBucketUrl: true` keeps in its column).
81
- *
82
- * The routes address a source's objects by key. On S3 and GCS the bucket in a
83
- * `storageUrl` is always the source's own — the server refuses any other — so
84
- * it is dropped: sent along, `s3://acme-media/products/a.png` reached the
85
- * server as the key `acme-media/products/a.png`, the preview found nothing and
86
- * a delete reported success while the object stayed. It worked on local disk
87
- * only, because `local://default/<key>` happens to carry the one bucket
88
- * segment the routes recognise — which is kept, as is any other local bucket.
89
- *
90
- * A bare key whose first folder is `default` is sent behind that bucket
91
- * segment. The routes read one leading `default/` (in any case) as the bucket,
92
- * so the key `default/photo.png` sent as it is names the root `photo.png`:
93
- * {@link defaultBucketPath} is the spelling that names the key itself.
94
- *
95
- * Shared by every storage source that talks to those routes, so they address
96
- * an object alike.
97
- *
98
- * @group Models
99
- */
100
- function storageObjectPath(keyOrUrl, bucket) {
101
- let filePath = keyOrUrl;
102
- let bucketQualified = false;
103
- const scheme = /^(local|s3|gs):\/\//.exec(filePath);
104
- if (scheme) {
105
- filePath = filePath.substring(scheme[0].length);
106
- if (scheme[1] !== "local") {
107
- const slash = filePath.indexOf("/");
108
- if (slash > 0) filePath = filePath.substring(slash + 1);
109
- } else bucketQualified = true;
110
- }
111
- if (bucket && filePath) {
112
- if (!filePath.startsWith(`${bucket}/`)) filePath = `${bucket}/${filePath}`;
113
- bucketQualified = true;
114
- }
115
- return bucketQualified ? filePath : defaultBucketPath(filePath);
116
- }
117
- /**
118
- * The request path of a key in the default bucket: the key itself, unless the
119
- * routes would read its first folder as the bucket — a first folder named
120
- * `default`, matched as the routes match it (case-insensitively, and only when
121
- * something follows it). Then the bucket segment goes in front, so exactly the
122
- * one segment the routes strip is the one added.
123
- */
124
- function defaultBucketPath(key) {
125
- const segments = key.split("/");
126
- return segments.length > 1 && segments[0].toLowerCase() === "default" ? `default/${key}` : key;
127
- }
128
- //#endregion
129
25
  //#region ../common/src/util/email.ts
130
26
  /**
131
27
  * Email normalization — one implementation, because the database enforces it.
@@ -283,6 +179,21 @@ function scalarSubqueryBody(s) {
283
179
  const inner = s.slice(1, -1).trim();
284
180
  return STARTS_WITH_SELECT.test(inner) ? inner.slice(6).trim() : null;
285
181
  }
182
+ /**
183
+ * The names in a role array's body — `'admin', 'editor'`, each with or without
184
+ * Postgres's `::text` — or `null` when an element is anything else. A quote
185
+ * or a comma inside a name is not split into roles that were never there: the
186
+ * clause stays raw and round-trips verbatim.
187
+ */
188
+ function roleArrayElements(list) {
189
+ const roles = [];
190
+ for (const element of list.split(",")) {
191
+ const match = /^'([^',]*)'(?:::text)?$/.exec(element.trim());
192
+ if (!match) return null;
193
+ roles.push(match[1]);
194
+ }
195
+ return roles;
196
+ }
286
197
  /** Whether any part of `expr` fell through to `raw`. */
287
198
  function containsRaw(expr) {
288
199
  switch (expr.kind) {
@@ -303,16 +214,9 @@ function sqlToPolicy(sql) {
303
214
  }
304
215
  if (trimmed.toLowerCase() === "true") return policy.true();
305
216
  if (trimmed.toLowerCase() === "false") return policy.false();
306
- const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
307
- if (overlapMatch) {
308
- const roles = overlapMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
309
- return policy.rolesOverlap(roles);
310
- }
311
- const containMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
312
- if (containMatch) {
313
- const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
314
- return policy.rolesContain(roles);
315
- }
217
+ const rolesMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','(?:::text)?\s*\)\s*(&&|@>)\s*ARRAY\s*\[(.+)\]$/i);
218
+ const roles = rolesMatch ? roleArrayElements(rolesMatch[2]) : null;
219
+ if (rolesMatch && roles) return rolesMatch[1] === "&&" ? policy.rolesOverlap(roles) : policy.rolesContain(roles);
316
220
  const orParts = splitTopLevel(trimmed, "OR");
317
221
  if (orParts) return policy.or(...orParts.map(sqlToPolicy));
318
222
  const andParts = splitTopLevel(trimmed, "AND");
@@ -629,26 +533,188 @@ function checkOperation(collection, authContext, entity, targetOperation, option
629
533
  return hasPermissive ? grantedByPermissive : false;
630
534
  }
631
535
  //#endregion
632
- //#region src/auth/api-keys/http-operation.ts
536
+ //#region src/auth/resolve-rate-limit-store.ts
537
+ /**
538
+ * A refusal to boot, phrased as the variable to change.
539
+ *
540
+ * Its own class so `bootFromBundle` can present it as a configuration problem
541
+ * rather than a crash, matching `RoleConfigurationError`.
542
+ */
543
+ var RateLimitStoreConfigurationError = class extends Error {
544
+ hint;
545
+ constructor(message, hint) {
546
+ super(message);
547
+ this.hint = hint;
548
+ this.name = "RateLimitStoreConfigurationError";
549
+ }
550
+ };
633
551
  /**
634
- * Map an HTTP method to the operation it performs.
552
+ * Which store this process should build.
635
553
  *
636
- * - `GET`, `HEAD`, `OPTIONS` → `"read"`
637
- * - `POST`, `PUT`, `PATCH` → `"write"`
638
- * - `DELETE` → `"delete"`
554
+ * A misspelled value **refuses to boot** rather than falling back to memory.
555
+ * Every other outcome here is silent — the whole failure mode of this setting is
556
+ * that nothing looks wrong — so the one moment it can be caught is the moment it
557
+ * is read. `REBASE_RATE_LIMIT_STORE=postgres` is the obvious thing to type and
558
+ * would otherwise be a per-replica limit wearing the shape of a shared one.
639
559
  *
640
- * Any other method is a `"write"`: a verb this does not know is not assumed
641
- * to be harmless.
560
+ * A blank or whitespace-only value is *unset*, not invalid:
561
+ * `REBASE_RATE_LIMIT_STORE=${SOMETHING}` with `SOMETHING` undefined is the
562
+ * ordinary way to write a compose file, and it is how the platform neutralises a
563
+ * tenant's own variable.
642
564
  */
643
- function httpMethodToOperation(method) {
644
- switch (method.toUpperCase()) {
645
- case "GET":
646
- case "HEAD":
647
- case "OPTIONS": return "read";
648
- case "DELETE": return "delete";
649
- default: return "write";
565
+ function resolveRateLimitStoreKind(env) {
566
+ const raw = (env.REBASE_RATE_LIMIT_STORE ?? "").trim().toLowerCase();
567
+ if (raw === "") return "memory";
568
+ if (raw === "memory" || raw === "sql") return raw;
569
+ 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.");
570
+ }
571
+ //#endregion
572
+ //#region src/auth/sql-rate-limit-store.ts
573
+ var SCHEMA = "rebase";
574
+ var TABLE_NAME = "rate_limit_hits";
575
+ var TABLE$1 = `${SCHEMA}.${TABLE_NAME}`;
576
+ /** How often expired buckets are deleted. */
577
+ var DEFAULT_SWEEP_MS = 300 * 1e3;
578
+ function createSqlRateLimitStore(driver, options = {}) {
579
+ const admin = driver.admin;
580
+ if (!isSQLAdmin(admin)) {
581
+ logger.warn("⚠️ [rate-limit] The configured driver has no SQL admin, so rate-limit counts cannot be shared between processes. Falling back to this process's memory — each replica will enforce the limit independently.");
582
+ return;
583
+ }
584
+ const exec = provisioningExec((sqlText, execOptions) => admin.executeSql(sqlText, execOptions?.params ? { params: execOptions.params } : void 0), options.provision);
585
+ const ddl = createDdlBootstrapper(exec, "rate-limit-store");
586
+ const now = options.now ?? Date.now;
587
+ const sweepMs = options.sweepMs ?? DEFAULT_SWEEP_MS;
588
+ let sweepTimer;
589
+ let ready;
590
+ /**
591
+ * Create the table once, and remember whether it worked.
592
+ *
593
+ * Lazily rather than at boot because the store is constructed while routes
594
+ * are being mounted, and an `await` there would put a database round trip on
595
+ * the boot path of every deployment — including the ones that never take a
596
+ * request. The first request pays for it.
597
+ *
598
+ * The result is cached even on failure: a store that cannot create its table
599
+ * must not retry the DDL on every request, which would turn one broken
600
+ * permission into a per-request query storm.
601
+ *
602
+ * Readiness is decided by reading the table, never by the DDL not throwing —
603
+ * `ensureObject` routes through `step`, which logs a failure and returns
604
+ * normally. Keying on the absence of an exception would report a table that
605
+ * was never created as ready, and every later request would fall into the
606
+ * per-request error path instead of the one-time fallback.
607
+ */
608
+ async function ensureReady() {
609
+ if (!ready) ready = (async () => {
610
+ try {
611
+ await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
612
+ await ddl.ensureObject(`Creating ${TABLE$1}`, `
613
+ CREATE TABLE IF NOT EXISTS ${TABLE$1} (
614
+ key TEXT NOT NULL,
615
+ bucket BIGINT NOT NULL,
616
+ hits INTEGER NOT NULL DEFAULT 0,
617
+ expires_at TIMESTAMPTZ NOT NULL,
618
+ PRIMARY KEY (key, bucket)
619
+ )
620
+ `);
621
+ await ddl.ensureObject("Creating idx_rate_limit_hits_expiry", `
622
+ CREATE INDEX IF NOT EXISTS idx_rate_limit_hits_expiry
623
+ ON ${TABLE$1}(expires_at)
624
+ `);
625
+ const readable = await ddl.isReadable(TABLE$1);
626
+ if (readable) await ddl.step("Revoking end-user access to rate_limit_hits", () => exec(revokeInternalTableSql(SCHEMA, TABLE_NAME)));
627
+ else if (options.provision === false) logger.error(`[rate-limit] ${TABLE$1} does not exist, and this backend changes nothing in its database, so counts stay per-process. Every replica will enforce the limit independently until a provisioning run creates the table.`);
628
+ else logger.error("[rate-limit] The shared rate-limit table could not be created, so counts stay per-process. Every replica will enforce the limit independently.");
629
+ return readable;
630
+ } catch (err) {
631
+ logger.error("[rate-limit] Could not prepare the shared rate-limit table. Falling back to per-process counts for the life of this process.", { error: err instanceof Error ? err.message : String(err) });
632
+ return false;
633
+ }
634
+ })();
635
+ return ready;
636
+ }
637
+ function startSweeping() {
638
+ if (sweepTimer) return;
639
+ sweepTimer = setInterval(() => {
640
+ exec(`DELETE FROM ${TABLE$1} WHERE expires_at < now()`).catch((err) => {
641
+ logger.debug("[rate-limit] Bucket sweep failed", { error: err instanceof Error ? err.message : String(err) });
642
+ });
643
+ }, sweepMs);
644
+ sweepTimer.unref?.();
650
645
  }
646
+ return {
647
+ ensureTable: ensureReady,
648
+ async hit(key, windowMs, limit) {
649
+ if (!await ensureReady()) return {
650
+ allowed: true,
651
+ remaining: limit,
652
+ retryAfterMs: 0
653
+ };
654
+ startSweeping();
655
+ const at = now();
656
+ const bucket = Math.floor(at / windowMs);
657
+ const carry = 1 - (at - bucket * windowMs) / windowMs;
658
+ const expiresAt = new Date((bucket + 2) * windowMs).toISOString();
659
+ let current;
660
+ let previous;
661
+ try {
662
+ const row = firstRow(await exec(`WITH bumped AS (
663
+ INSERT INTO ${TABLE$1} AS r (key, bucket, hits, expires_at)
664
+ VALUES ($1, $2, 1, $3)
665
+ ON CONFLICT (key, bucket)
666
+ DO UPDATE SET hits = r.hits + 1, expires_at = EXCLUDED.expires_at
667
+ RETURNING hits
668
+ )
669
+ SELECT
670
+ (SELECT hits FROM bumped) AS current,
671
+ COALESCE((SELECT hits FROM ${TABLE$1} WHERE key = $1 AND bucket = $4), 0) AS previous`, { params: [
672
+ key,
673
+ bucket,
674
+ expiresAt,
675
+ bucket - 1
676
+ ] }));
677
+ current = Number(row?.current ?? 1);
678
+ previous = Number(row?.previous ?? 0);
679
+ } catch (err) {
680
+ logger.debug("[rate-limit] Shared count unavailable for this request", { error: err instanceof Error ? err.message : String(err) });
681
+ return {
682
+ allowed: true,
683
+ remaining: limit,
684
+ retryAfterMs: 0
685
+ };
686
+ }
687
+ const estimate = current + previous * carry;
688
+ if (estimate > limit) {
689
+ exec(`UPDATE ${TABLE$1} SET hits = GREATEST(hits - 1, 0) WHERE key = $1 AND bucket = $2`, { params: [key, bucket] }).catch(() => {});
690
+ return {
691
+ allowed: false,
692
+ remaining: 0,
693
+ retryAfterMs: Math.max(0, (bucket + 1) * windowMs - at)
694
+ };
695
+ }
696
+ return {
697
+ allowed: true,
698
+ remaining: Math.max(0, Math.floor(limit - estimate)),
699
+ retryAfterMs: 0
700
+ };
701
+ },
702
+ dispose() {
703
+ if (sweepTimer) clearInterval(sweepTimer);
704
+ sweepTimer = void 0;
705
+ }
706
+ };
651
707
  }
708
+ /**
709
+ * The first row of whatever shape this driver returns.
710
+ *
711
+ * `SqlExec` declares an array of rows, but drivers in this repository have also
712
+ * returned a `{ rows }` envelope through the same escape hatch. Reading one
713
+ * shape only is how a store silently counts nothing and reports every caller as
714
+ * being on their first request — a limiter that never limits, with no error
715
+ * anywhere. Tolerating both costs one line.
716
+ */
717
+ var firstRow = firstSqlRow;
652
718
  //#endregion
653
719
  //#region src/email/html.ts
654
720
  /**
@@ -1660,6 +1726,7 @@ function enforceCreationPasswordPolicy(body, resolvedHooks, options) {
1660
1726
  */
1661
1727
  async function prepareAdminUserValues(rawBody, ctx, options) {
1662
1728
  const { resolvedHooks, collectionAuthConfig } = ctx;
1729
+ if (rawBody.roles !== void 0 && rawBody.roles !== null) readRoleNames(rawBody.roles);
1663
1730
  const body = enforceCreationPasswordPolicy(rawBody, resolvedHooks, options);
1664
1731
  if (collectionAuthConfig?.onCreateUser) {
1665
1732
  const collectionCtx = buildCollectionContext(ctx);
@@ -1822,1509 +1889,46 @@ async function normalizeUserEmailChange(authRepo, uid, email) {
1822
1889
  */
1823
1890
  function runAfterUserDelete(resolvedHooks, uid) {
1824
1891
  if (!resolvedHooks.afterUserDelete) return;
1825
- resolvedHooks.afterUserDelete(uid).catch((err) => {
1826
- logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
1827
- });
1828
- }
1829
- /**
1830
- * The auth adapter's side of user administration through the auth collection
1831
- * (`AuthAdapter.prepareUserUpdates` and the deletion pair): the admin user
1832
- * routes' rules and hooks, for the data API's writes to the same rows.
1833
- */
1834
- function authCollectionUserAdmin(ctx) {
1835
- const { authRepo, resolvedHooks } = ctx;
1836
- return {
1837
- async prepareUserUpdates(updates) {
1838
- await assertRoleChangesAllowed(authRepo, updates.flatMap(({ uid, values }) => Array.isArray(values.roles) ? [{
1839
- uid,
1840
- roles: values.roles
1841
- }] : []));
1842
- const prepared = [];
1843
- for (const { uid, values } of updates) prepared.push(typeof values.email === "string" ? {
1844
- ...values,
1845
- email: await normalizeUserEmailChange(authRepo, uid, values.email)
1846
- } : values);
1847
- return prepared;
1848
- },
1849
- async prepareUserDeletions(uids) {
1850
- await assertUserDeletionsAllowed(authRepo, uids);
1851
- if (resolvedHooks.beforeUserDelete) for (const uid of uids) await resolvedHooks.beforeUserDelete(uid);
1852
- },
1853
- async finalizeUserDeletions(uids) {
1854
- for (const uid of uids) {
1855
- await authRepo.deleteAllRefreshTokensForUser(uid);
1856
- runAfterUserDelete(resolvedHooks, uid);
1857
- }
1858
- }
1859
- };
1860
- }
1861
- //#endregion
1862
- //#region src/auth/token-revocation.ts
1863
- /**
1864
- * Has this access token been revoked?
1865
- *
1866
- * Everything that ends every session a user holds — a password change or reset
1867
- * of any kind, and `DELETE /auth/sessions` — goes through
1868
- * {@link revokeAllSessions}, which stamps a `tokensValidAfter` watermark on the
1869
- * user. It also deletes refresh-token rows, which is what made the gap easy to
1870
- * miss: the session really is gone, and the *refresh* path really does check
1871
- * the watermark — so signing out looked like it worked.
1872
- *
1873
- * The access token was untouched. It is a bearer credential that nothing
1874
- * consulted a database about, so it stayed valid for its full lifetime after
1875
- * every one of those actions. "Sign out everywhere" left the stolen token
1876
- * working; so did changing your password because you thought it had leaked.
1877
- *
1878
- * The watermark had exactly one read in the repository, on refresh. This adds
1879
- * the other one.
1880
- *
1881
- * ## Cost
1882
- *
1883
- * One indexed lookup per request, on the paths that already make one. The
1884
- * adapter re-reads roles for every authenticated request; the admin routes now
1885
- * do too. Both call this with the row they were already fetching in mind — a
1886
- * repository that wants to serve both from one query is free to cache
1887
- * internally, but nothing here assumes it.
1888
- *
1889
- * ## Failure
1890
- *
1891
- * Fails **open**, deliberately, and this is the one place in the auth stack
1892
- * where that is right. The watermark is an extra revocation signal layered over
1893
- * a token that has already been verified — signature, expiry and purpose all
1894
- * checked. Refusing every request when the database is unreachable would turn a
1895
- * transient outage into a total sign-out of an entire deployment, and the
1896
- * attacker this protects against needs to have already stolen a live token. The
1897
- * failure is logged at warn so it is visible rather than silent.
1898
- */
1899
- async function isAccessTokenRevoked(authRepo, payload) {
1900
- if (typeof authRepo.getTokensValidAfter !== "function") return false;
1901
- if (typeof payload.iat !== "number") return false;
1902
- let validAfter;
1903
- try {
1904
- validAfter = await authRepo.getTokensValidAfter(payload.uid);
1905
- } catch (error) {
1906
- logger.warn("[Auth] Could not read the token revocation watermark; allowing the request", {
1907
- uid: payload.uid,
1908
- error
1909
- });
1910
- return false;
1911
- }
1912
- return issuedBefore(payload, validAfter);
1913
- }
1914
- /**
1915
- * Is the account behind this verified access token still the one that may use
1916
- * it — and with which roles?
1917
- *
1918
- * A verified signature says who the token was minted for, an hour ago at
1919
- * most. This asks the database what has happened to that account since. Every
1920
- * door that honours an access token — the data plane, the admin gates, the
1921
- * realtime socket — asks it here, so they cannot disagree about it.
1922
- *
1923
- * They did. A deleted account read as "not revoked": the watermark lives on
1924
- * the user row, so once the row was gone there was no watermark, and the
1925
- * roles lookup answered `[]` rather than "nobody". A token its owner had
1926
- * revoked came back to life when an administrator deleted the account, as an
1927
- * authenticated principal with that uid, for the rest of its lifetime. MCP
1928
- * refresh and personal API keys already treated a missing account as revoked;
1929
- * the main door did not.
1930
- *
1931
- * Throws when the repository does. Unlike the watermark read on its own, this
1932
- * one decides the roles a request runs with, so a failure is a refusal (the
1933
- * callers answer 503) rather than a guess.
1934
- *
1935
- * Except one failure that is an answer: the store refusing the uid as a value
1936
- * its id column cannot hold — `u1` against a UUID key. That names no account,
1937
- * exactly as a well-formed id nobody has does, so it is `account-deleted` and
1938
- * the caller answers 401. Answered as a failure it was a 503 asking the
1939
- * client to try again, which it would do forever.
1940
- */
1941
- async function judgeAccessToken(authRepo, payload) {
1942
- try {
1943
- return await readAccessVerdict(authRepo, payload);
1944
- } catch (error) {
1945
- if (isDataException(error)) return {
1946
- live: false,
1947
- refusal: "account-deleted"
1948
- };
1949
- throw error;
1950
- }
1951
- }
1952
- async function readAccessVerdict(authRepo, payload) {
1953
- let roles;
1954
- let validAfter = null;
1955
- if (typeof authRepo.getAccountAccessState === "function") {
1956
- const state = await authRepo.getAccountAccessState(payload.uid, payload.sid);
1957
- if (!state) return {
1958
- live: false,
1959
- refusal: "account-deleted"
1960
- };
1961
- if (state.disabled) return {
1962
- live: false,
1963
- refusal: "account-disabled"
1964
- };
1965
- roles = state.roles;
1966
- validAfter = state.tokensValidAfter;
1967
- if (payload.sid && state.sessionActive === false) return {
1968
- live: false,
1969
- refusal: "session-revoked"
1970
- };
1971
- } else {
1972
- if (typeof authRepo.getUserWithRoles === "function") {
1973
- const account = await authRepo.getUserWithRoles(payload.uid);
1974
- if (!account) return {
1975
- live: false,
1976
- refusal: "account-deleted"
1977
- };
1978
- if (account.user.disabled) return {
1979
- live: false,
1980
- refusal: "account-disabled"
1981
- };
1982
- roles = account.roles;
1983
- }
1984
- if (typeof authRepo.getTokensValidAfter === "function") validAfter = await authRepo.getTokensValidAfter(payload.uid);
1985
- }
1986
- if (issuedBefore(payload, validAfter)) return {
1987
- live: false,
1988
- refusal: "revoked"
1989
- };
1990
- return {
1991
- live: true,
1992
- roles
1993
- };
1994
- }
1995
- /**
1996
- * The 401 a door answers an access token with when {@link judgeAccessToken}
1997
- * refuses it — one wording and one code for each reason, wherever the token
1998
- * was presented.
1999
- *
2000
- * `SESSION_REVOKED` for a token whose session or account is gone: the client
2001
- * SDK answers it with a refresh, which the refresh route refuses for the same
2002
- * reason, and the client signs out. `ACCOUNT_DISABLED` for an account an
2003
- * administrator switched off, which no refresh can mend.
2004
- */
2005
- function accessTokenRefusalError(refusal) {
2006
- switch (refusal) {
2007
- case "account-disabled": return ApiError.unauthorized("This account has been disabled", "ACCOUNT_DISABLED");
2008
- case "account-deleted": return ApiError.unauthorized("This account no longer exists", "SESSION_REVOKED");
2009
- case "revoked":
2010
- case "session-revoked": return ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
2011
- }
2012
- }
2013
- /** The watermark comparison {@link isAccessTokenRevoked} and the judge share. */
2014
- function issuedBefore(payload, validAfter) {
2015
- if (!validAfter || typeof payload.iat !== "number") return false;
2016
- return payload.iat < Math.floor(validAfter.getTime() / 1e3);
2017
- }
2018
- /**
2019
- * End every session this user holds, on every device.
2020
- *
2021
- * Two writes, because each covers what the other cannot. Deleting the refresh
2022
- * rows ends the sessions that exist at this instant, and on a repository
2023
- * without the watermark it is the only revocation there is. The watermark
2024
- * voids what the delete cannot see: a refresh already in flight that inserts
2025
- * its rotated token a moment after the delete ran, and every access token
2026
- * already handed out, which {@link isAccessTokenRevoked} refuses from here on.
2027
- *
2028
- * The watermark write does not fail the caller. By the time it runs the
2029
- * credential has usually already changed and the rows are gone, so a 500 would
2030
- * tell someone their password did not change when it did. It is logged, because
2031
- * a failure here leaves access tokens working until they expire.
2032
- */
2033
- async function revokeAllSessions(authRepo, uid) {
2034
- await authRepo.deleteAllRefreshTokensForUser(uid);
2035
- try {
2036
- await authRepo.setTokensValidAfter?.(uid, /* @__PURE__ */ new Date());
2037
- } catch (error) {
2038
- logger.warn("[Auth] Could not write the token revocation watermark; access tokens issued before now stay valid until they expire", {
2039
- uid,
2040
- error
2041
- });
2042
- }
2043
- }
2044
- /**
2045
- * Replace a user's password and end every session they hold.
2046
- *
2047
- * The one way a password hash is written over an existing account. A new
2048
- * password is what someone sets when they believe the old one — or a session
2049
- * signed in with it — is in someone else's hands, so a password change that
2050
- * leaves those sessions alive does not do the thing it was done for.
2051
- *
2052
- * It used to be a pair of lines repeated after each `updatePassword`, and it
2053
- * reached the two self-service routes and none of the admin ones: an
2054
- * administrator resetting a phished account left the attacker's refresh token
2055
- * minting access tokens for the rest of its lifetime.
2056
- * `test/password-change-revokes-sessions.test.ts` holds every route that sets a
2057
- * password to this, and fails if anything but this function calls
2058
- * `updatePassword`.
2059
- *
2060
- * `null` removes the password instead, for the same reason and with the same
2061
- * revocation: see `confirmAddressOwnership`.
2062
- */
2063
- async function replaceUserPassword(authRepo, uid, passwordHash) {
2064
- await authRepo.updatePassword(uid, passwordHash);
2065
- await revokeAllSessions(authRepo, uid);
2066
- await authRepo.deleteAllPasswordResetTokensForUser?.(uid);
2067
- }
2068
- //#endregion
2069
- //#region src/auth/rls-scope.ts
2070
- var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
2071
- /**
2072
- * The identity the trusted server plane runs as: `rebase.dataAsAdmin`, and any
2073
- * caller presenting the service key.
2074
- *
2075
- * Naming it makes the privilege legible, because it is narrower than it reads.
2076
- * This is **not** an RLS bypass — a driver scoped with it is an
2077
- * `AuthenticatedPostgresBackendDriver`, so every statement runs in a
2078
- * transaction that has done `SET LOCAL ROLE rebase_user` with
2079
- * `app.uid = 'service'`, and policies are evaluated. Two consequences worth
2080
- * knowing before you write a policy:
2081
- *
2082
- * - It passes the default policies through their `rolesOverlap(['admin'])`
2083
- * arm — the same arm an application user holding the `admin` role passes.
2084
- * - `policy.serverContext()` compiles to `rebase.uid() IS NULL` and is therefore
2085
- * **false** for it. A collection with `disableDefaultPolicies: true` whose
2086
- * only rule is `serverContext()` denies these writes (42501) and returns
2087
- * zero rows for these reads.
2088
- *
2089
- * The true bypass is `rebase.sql()`, which runs on the owner connection and
2090
- * never goes near `withAuth`.
2091
- */
2092
- var SERVICE_IDENTITY = {
2093
- uid: "service",
2094
- roles: ["admin"]
2095
- };
2096
- /**
2097
- * Returns true if the driver supports RLS scoping via `withAuth()`.
2098
- */
2099
- function isRLSScopedDriver(driver) {
2100
- return "withAuth" in driver && typeof driver.withAuth === "function";
2101
- }
2102
- /**
2103
- * Can this driver run a request as a specific user?
2104
- *
2105
- * The same question `scopeDataDriver` asks internally, exported because one
2106
- * caller must act on the answer BEFORE it has a request to scope.
2107
- *
2108
- * `scopeDataDriver` returns the driver unscoped when the answer is no, which is
2109
- * the right behaviour for `/api/data` — Mongo has no row-level security and
2110
- * never claimed to, and the route's own checks still apply. It is the wrong
2111
- * behaviour for a surface whose entire promise is "this runs as you": there,
2112
- * an unscoped driver silently means "runs as the database owner". So the MCP
2113
- * surface asks this at boot and declines to mount rather than degrade.
2114
- */
2115
- function supportsRlsScoping(driver) {
2116
- return isRLSScopedDriver(driver);
2117
- }
2118
- /**
2119
- * Scope a DataDriver via `withAuth()` for RLS.
2120
- *
2121
- * SECURITY: If `withAuth()` is available but fails, the error is re-thrown
2122
- * so the request is **denied** rather than proceeding with unscoped access
2123
- * (fail-closed behavior).
2124
- *
2125
- * If the driver does not support RLS, the original driver is returned.
2126
- *
2127
- * @param driver - The DataDriver to scope.
2128
- * @param user - The authenticated user identity for RLS.
2129
- * @returns The RLS-scoped DataDriver (or the original if RLS is unsupported).
2130
- */
2131
- async function scopeDataDriver(driver, user) {
2132
- if (isRLSScopedDriver(driver)) return await driver.withAuth(user);
2133
- return driver;
2134
- }
2135
- //#endregion
2136
- //#region src/auth/crypto-utils.ts
2137
- /**
2138
- * Cryptographic utility functions for auth.
2139
- *
2140
- * @module
2141
- */
2142
- /**
2143
- * Constant-time string comparison to prevent timing attacks.
2144
- *
2145
- * Used for comparing service keys, tokens, and other secrets where
2146
- * timing side-channels could leak information about the expected value.
2147
- *
2148
- * The implementation lives in `utils/portable-crypto.ts`, which does this over
2149
- * UTF-8 bytes without `node:crypto` — this function is on the request path, and
2150
- * the byte-level reasoning it depends on is documented there.
2151
- *
2152
- * @param a - First string to compare.
2153
- * @param b - Second string to compare.
2154
- * @returns `true` if the strings are identical, `false` otherwise.
2155
- */
2156
- function safeCompare(a, b) {
2157
- return constantTimeEqual(a, b);
2158
- }
2159
- //#endregion
2160
- //#region src/auth/bearer-token.ts
2161
- /**
2162
- * Parsing of the `Authorization: Bearer <token>` header.
2163
- *
2164
- * Its own module rather than a helper inside `middleware.ts`, because the
2165
- * API-key middleware needs it too and `middleware.ts` already imports from
2166
- * there — putting it in either one would close an import cycle.
2167
- *
2168
- * @module
2169
- */
2170
- /**
2171
- * Pull the token out of an `Authorization: Bearer <token>` header.
2172
- *
2173
- * RFC 7235 §2.1 makes the auth-scheme name case-insensitive, and plenty of
2174
- * clients send `bearer` — HTTP libraries that lower-case what they emit,
2175
- * hand-written curl, proxies that normalise headers. Matching `"Bearer "`
2176
- * exactly turned those into a 401 that, from the caller's side, is
2177
- * indistinguishable from a rejected token; people spend an afternoon
2178
- * regenerating credentials that were fine. Only the scheme is folded — the
2179
- * token itself is returned byte-for-byte.
2180
- *
2181
- * Returns `undefined` when there is no header or it names another scheme.
2182
- * That is deliberately distinct from an empty token (`Bearer ` with nothing
2183
- * after it), which callers still route into verification so it fails as a bad
2184
- * token rather than as a missing header.
2185
- */
2186
- function extractBearerToken(authHeader) {
2187
- if (!authHeader) return void 0;
2188
- const separator = authHeader.indexOf(" ");
2189
- if (separator < 0) return void 0;
2190
- if (authHeader.slice(0, separator).toLowerCase() !== "bearer") return void 0;
2191
- return authHeader.slice(separator + 1);
2192
- }
2193
- //#endregion
2194
- //#region src/auth/access.ts
2195
- /**
2196
- * How a personal key finds its owner in an account store: the account as it
2197
- * is now — a missing or disabled one is no owner — and its roles.
2198
- *
2199
- * One read where the store answers both at once: `getAccountAccessState`, the
2200
- * statement every access token is already judged against, which reads roles
2201
- * and `disabled_at` together. Otherwise the account, then its roles — the two
2202
- * reads this always made, one round trip more per request.
2203
- */
2204
- function keyOwnerResolverFor(repo) {
2205
- if (typeof repo.getAccountAccessState === "function") {
2206
- const readAccount = repo.getAccountAccessState.bind(repo);
2207
- return async (uid) => {
2208
- const account = await readAccount(uid);
2209
- if (!account || account.disabled) return null;
2210
- return { roles: account.roles };
2211
- };
2212
- }
2213
- return async (uid) => {
2214
- const user = await repo.getUserById(uid);
2215
- if (!user || user.disabled) return null;
2216
- return { roles: await repo.getUserRoleIds(uid) };
2217
- };
2218
- }
2219
- /**
2220
- * What the module-level API answers for: {@link getAccessModel},
2221
- * {@link getKeyOwnerResolver}, and every scope check on a route mounted
2222
- * outside any backend. Built-ins only until {@link configureAccess} or a
2223
- * backend installed as the process default sets it.
2224
- */
2225
- var processAccess = { model: EMPTY_ACCESS_MODEL };
2226
- /** The process default's access model. Built-ins only until one is configured. */
2227
- function getAccessModel() {
2228
- return processAccess.model;
2229
- }
2230
- /**
2231
- * Set the process default's access model, and — when the app enables
2232
- * `auth.personalKeys` and has an account store to read owners from — how
2233
- * personal keys find their owner. For a test, or a server wired by hand that
2234
- * uses the module-level API; a backend built by `createRebaseBackend` carries
2235
- * its own.
2236
- */
2237
- function configureAccess(options) {
2238
- processAccess = {
2239
- model: options.model,
2240
- resolveKeyOwner: options.resolveKeyOwner
2241
- };
2242
- }
2243
- /** @internal See {@link defaultAccessControl}. */
2244
- function setDefaultAccessControl(access) {
2245
- processAccess = access;
2246
- }
2247
- /**
2248
- * The process default as an {@link AccessControl} that follows it — what a
2249
- * route or a key check built outside any backend falls back to.
2250
- */
2251
- var processAccessControl = {
2252
- get model() {
2253
- return processAccess.model;
2254
- },
2255
- get resolveKeyOwner() {
2256
- return processAccess.resolveKeyOwner;
2257
- }
2258
- };
2259
- /**
2260
- * The access control serving this request: the backend's whose route group is
2261
- * answering it, or the process default for a route mounted outside any.
2262
- */
2263
- function accessOf(c) {
2264
- return c.get("services")?.access ?? processAccessControl;
2265
- }
2266
- /** The resource names Rebase's own scopes use; an app scope may not reuse one. */
2267
- var BUILT_IN_RESOURCES = new Set(BUILT_IN_SCOPES.map((scope) => scope.slice(0, scope.indexOf(":"))));
2268
- /** A declaration the access model cannot be built from. Fails the boot. */
2269
- var AccessModelError = class extends Error {
2270
- constructor(message) {
2271
- super(message);
2272
- this.name = "AccessModelError";
2273
- }
2274
- };
2275
- /**
2276
- * Build the access model from the users collection's `auth` block, refusing
2277
- * anything that would read as a grant it is not.
2278
- *
2279
- * - `admin` may not be declared: it is built in and holds everything, and a
2280
- * declaration would either repeat that or quietly narrow it.
2281
- * - An app scope may not reuse a built-in resource name (`logs:export`): the
2282
- * built-in resources are Rebase's, and a reader of `logs:*` should not have
2283
- * to know which half is whose.
2284
- * - A role may not list a data-plane scope: a person's data access is the
2285
- * collection's `securityRules`, so `data:write` on a role would grant nothing
2286
- * and look like it granted something.
2287
- */
2288
- function accessModelFromCollections(collections) {
2289
- const authCollection = collections.find((collection) => {
2290
- const auth = collection.auth;
2291
- return auth === true || !!auth && typeof auth === "object" && auth.enabled === true;
2292
- });
2293
- const auth = authCollection && typeof authCollection.auth === "object" ? authCollection.auth : void 0;
2294
- const declaredScopes = { ...auth?.scopes ?? {} };
2295
- const declaredRoles = { ...auth?.roles ?? {} };
2296
- const where = `the "${authCollection?.slug ?? "users"}" collection's auth block`;
2297
- for (const [name, definition] of Object.entries(declaredScopes)) {
2298
- const parsed = parseScope(name);
2299
- 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".`);
2300
- const resource = name.slice(0, name.indexOf(":"));
2301
- 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(", ")}.`);
2302
- 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.`);
2303
- }
2304
- const model = {
2305
- roles: declaredRoles,
2306
- scopes: declaredScopes
2307
- };
2308
- for (const [role, definition] of Object.entries(declaredRoles)) {
2309
- 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.`);
2310
- if (!Array.isArray(definition?.scopes)) throw new AccessModelError(`auth.roles: "${role}" in ${where} needs a scopes list.`);
2311
- const dataPlane = definition.scopes.filter((scope) => isDataPlaneScope(parseScope(scope)?.scope ?? ""));
2312
- 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.`);
2313
- const problems = validateScopes(definition.scopes, model);
2314
- 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.`);
2315
- }
2316
- return model;
2317
- }
2318
- /** Narrow a context's user to the roles it carries. */
2319
- function rolesOf(user) {
2320
- if (typeof user !== "object" || user === null || !("roles" in user)) return [];
2321
- const roles = user.roles;
2322
- return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
2323
- }
2324
- /**
2325
- * Everything the caller of this request may do. Empty for a request with no
2326
- * caller.
2327
- */
2328
- function callerScopes(c) {
2329
- const narrowed = c.get("scopes");
2330
- if (narrowed) return narrowed;
2331
- const user = c.get("user");
2332
- if (!user) return [];
2333
- return scopesForRoles(rolesOf(user), accessOf(c).model);
2334
- }
2335
- /**
2336
- * Whether this request's caller holds the `admin` role. The service key signs
2337
- * in as one.
2338
- */
2339
- function callerIsAdmin(c) {
2340
- return hasAdminRole(rolesOf(c.get("user")));
2341
- }
2342
- /** Does this request's caller hold `scope`, on `target` when one is given? */
2343
- function hasScope(c, scope, target) {
2344
- return scopeGrants(callerScopes(c), scope, target);
2345
- }
2346
- /**
2347
- * The refusal for a missing scope, worded for whoever is holding the
2348
- * credential. `details.requiredScope` names it for clients that branch.
2349
- */
2350
- function scopeMissing(c, scope, target) {
2351
- const wanted = target !== void 0 ? `${scope}:${target}` : scope;
2352
- 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 });
2353
- }
2354
- /**
2355
- * Refuse a request whose caller does not hold `scope`.
2356
- *
2357
- * `scope` is a built-in scope or one this backend declares under
2358
- * `auth.scopes`. A name no caller could ever hold — not a scope name, a typo
2359
- * of a built-in one (`data:raed`) — throws as the route is registered; an app
2360
- * scope the backend answering does not declare is answered
2361
- * `500 SCOPE_NOT_DECLARED`, naming the declaration to add.
2362
- *
2363
- * `target` narrows the check to one collection, bucket, function or app
2364
- * resource, and may be read from the request. A guard, not an authenticator:
2365
- * it judges the caller an auth middleware identified, so mount it after one —
2366
- * `requireAuth`, or a route group's own. A request with no caller is refused
2367
- * as unauthenticated, not as unscoped.
2368
- *
2369
- * @example
2370
- * ```ts
2371
- * app.post("/deploy/:project", requireAuth, requireScope("project:deploy", c => c.req.param("project")), handler);
2372
- * ```
2373
- */
2374
- function requireScope(scope, target) {
2375
- const problem = scopeGuardProblem(scope, typeof target === "string" ? target : void 0);
2376
- if (problem) throw new Error(problem);
2377
- return async (c, next) => {
2378
- if (!c.get("user")) return refuse(c, noCallerRefusal(c));
2379
- const undeclared = scopeGuardProblem(scope, void 0, Object.keys(accessOf(c).model.scopes));
2380
- if (undeclared) return refuse(c, new ApiError(500, "SCOPE_NOT_DECLARED", undeclared));
2381
- const resolvedTarget = typeof target === "function" ? target(c) : target;
2382
- if (!hasScope(c, scope, resolvedTarget)) return refuse(c, scopeMissing(c, scope, resolvedTarget));
2383
- return next();
2384
- };
2385
- }
2386
- /**
2387
- * The 401 for a guard that found no caller.
2388
- *
2389
- * A request that sent a credential, on a route where no auth middleware ran —
2390
- * it sets `driver` for every caller, anonymous included — never had that
2391
- * credential read. "Authentication required" sends whoever holds a valid
2392
- * session to check it, so this names the wiring instead.
2393
- */
2394
- function noCallerRefusal(c) {
2395
- if (c.req.header("authorization") !== void 0 && c.get("driver") === void 0) return ApiError.unauthenticated("Authentication required: this request carries a credential, but nothing on this route verified it. requireScope judges the caller an auth middleware identified; put requireAuth in front of it — app.post(path, requireAuth, requireScope(scope), handler).");
2396
- return ApiError.unauthenticated("Authentication required");
2397
- }
2398
- /**
2399
- * Refuse a request unless its caller holds the scope for its HTTP method:
2400
- * `read` for GET and HEAD, `write` for everything else. For an admin surface
2401
- * whose reads and writes split exactly along the method.
2402
- */
2403
- function requireScopeByMethod(scopes) {
2404
- const read = requireScope(scopes.read);
2405
- const write = requireScope(scopes.write);
2406
- return async (c, next) => {
2407
- const method = c.req.method.toUpperCase();
2408
- return (method === "GET" || method === "HEAD" ? read : write)(c, next);
2409
- };
2410
- }
2411
- /**
2412
- * What a set of roles holds beyond this request's caller — the scopes, plus
2413
- * `admin` itself when the roles include it and the caller does not hold it.
2414
- * Empty when the caller covers every one.
2415
- *
2416
- * `admin` is named separately because it is more than its scopes: it is the
2417
- * RLS role that reads every row, so a role listing every admin-plane scope
2418
- * still cannot hand it out.
2419
- */
2420
- function beyondCaller(c, roles) {
2421
- const beyond = scopesBeyond(scopesForRoles(roles, accessOf(c).model), callerScopes(c));
2422
- if (hasAdminRole(roles) && !hasAdminRole(rolesOf(c.get("user")))) beyond.unshift(ADMIN_ROLE);
2423
- return beyond;
2424
- }
2425
- /**
2426
- * Refuse to let the caller act on an account — edit it, reset its password,
2427
- * delete it — when that account holds anything the caller does not.
2428
- *
2429
- * `users:write` is the scope that lets a support role manage accounts, and
2430
- * without this it would also let that role reset an administrator's password
2431
- * and sign in as them.
2432
- */
2433
- function assertMayManageAccount(c, targetRoles) {
2434
- const beyond = beyondCaller(c, targetRoles);
2435
- 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 });
2436
- }
2437
- /** Refuse to let the caller grant roles that hold anything the caller does not. */
2438
- function assertMayGrantRoles(c, roles) {
2439
- const beyond = beyondCaller(c, roles);
2440
- 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 });
2441
- }
2442
- //#endregion
2443
- //#region src/auth/api-keys/api-key-middleware.ts
2444
- /**
2445
- * Check whether a token looks like a Rebase API key.
2446
- */
2447
- function isApiKeyToken(token) {
2448
- return token.startsWith("rk_");
2449
- }
2450
- /**
2451
- * Verify a presented key: it exists, is live, and — for a personal key —
2452
- * personal keys are on and its owner still exists. Records the use.
2453
- *
2454
- * `access` is the backend the key belongs to: whether it takes personal keys,
2455
- * how it finds their owners, and what their roles hold. The process default
2456
- * when omitted.
2457
- */
2458
- async function resolveApiKey(store, token, access = processAccessControl) {
2459
- const apiKey = await store.findByKeyHash(await sha256Hex(token));
2460
- if (!apiKey) return { message: "Invalid API key" };
2461
- if (apiKey.revoked_at) return { message: "API key has been revoked" };
2462
- if (apiKey.expires_at && new Date(apiKey.expires_at) < /* @__PURE__ */ new Date()) return { message: "API key has expired" };
2463
- let identity;
2464
- if (apiKey.kind === "personal") {
2465
- const resolveOwner = access.resolveKeyOwner;
2466
- if (!resolveOwner || !apiKey.owner_uid) return { message: "Personal API keys are switched off on this backend" };
2467
- const owner = await resolveOwner(apiKey.owner_uid);
2468
- if (!owner) return { message: "The account this API key acts as no longer exists" };
2469
- identity = {
2470
- uid: apiKey.owner_uid,
2471
- roles: owner.roles,
2472
- scopes: intersectScopes(apiKey.scopes, scopesForRoles(owner.roles, access.model))
2473
- };
2474
- } else identity = {
2475
- uid: `api-key:${apiKey.id}`,
2476
- roles: ["service", ...apiKey.roles.filter((role) => role !== "service")],
2477
- scopes: apiKey.scopes
2478
- };
2479
- const lastTouch = lastUsedTouchedAt.get(apiKey.id);
2480
- const now = Date.now();
2481
- if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
2482
- lastUsedTouchedAt.set(apiKey.id, now);
2483
- store.updateLastUsed(apiKey.id).catch(() => {});
2484
- }
2485
- const masked = {
2486
- id: apiKey.id,
2487
- name: apiKey.name,
2488
- kind: apiKey.kind,
2489
- key_prefix: apiKey.key_prefix,
2490
- scopes: apiKey.scopes,
2491
- roles: apiKey.roles,
2492
- owner_uid: apiKey.owner_uid,
2493
- rate_limit: apiKey.rate_limit,
2494
- created_by: apiKey.created_by,
2495
- created_at: apiKey.created_at,
2496
- updated_at: apiKey.updated_at,
2497
- last_used_at: apiKey.last_used_at,
2498
- expires_at: apiKey.expires_at,
2499
- revoked_at: apiKey.revoked_at
2500
- };
2501
- return {
2502
- ...identity,
2503
- apiKey: masked
2504
- };
2505
- }
2506
- /** Per-process debounce state for last_used_at touches. */
2507
- var lastUsedTouchedAt = /* @__PURE__ */ new Map();
2508
- var LAST_USED_DEBOUNCE_MS = 6e4;
2509
- /**
2510
- * Validate an API key token and populate the Hono context: `user`, `apiKey`,
2511
- * `scopes` and the RLS-scoped `driver`.
2512
- *
2513
- * Returns `true` when the context is populated, or the error Response.
2514
- */
2515
- async function validateApiKey(c, token, options) {
2516
- const resolved = await resolveApiKey(options.store, token, accessOf(c));
2517
- if (!("uid" in resolved)) return errorHandler(ApiError.unauthenticated(resolved.message), c);
2518
- c.set("user", {
2519
- uid: resolved.uid,
2520
- roles: resolved.roles
2521
- });
2522
- c.set("apiKey", resolved.apiKey);
2523
- c.set("scopes", resolved.scopes);
2524
- try {
2525
- c.set("driver", await scopeDataDriver(options.driver, {
2526
- uid: resolved.uid,
2527
- roles: resolved.roles
2528
- }));
2529
- } catch (error) {
2530
- logger.error("[AUTH] RLS scoping failed for API key", { error });
2531
- return errorHandler(ApiError.internal("Internal authentication error"), c);
2532
- }
2533
- return true;
2534
- }
2535
- /**
2536
- * The 403 for a credential that lacks a data-plane scope outside the REST
2537
- * generator (storage and functions). Names the scope that would grant it.
2538
- */
2539
- function forbidScope(c, scope, target) {
2540
- const wanted = target !== void 0 && target !== "" ? `${scope}:${target}` : scope;
2541
- return errorHandler(new ApiError(403, "SCOPE_MISSING", `This credential does not hold the "${wanted}" scope.`, { requiredScope: wanted }), c);
2542
- }
2543
- /** Whether this request's caller holds `scope` — on `target` when given. */
2544
- function callerHolds(c, scope, target) {
2545
- const narrowed = c.get("scopes");
2546
- if (!narrowed) return true;
2547
- return target === void 0 ? scopeGrantsAny(narrowed, scope) : scopeGrants(narrowed, scope, target);
2548
- }
2549
- /**
2550
- * Scope guard for the resumable-upload routes (`/tus/:id`).
2551
- *
2552
- * Every step of an upload — the offset check (HEAD), the chunks (PATCH), the
2553
- * cancel (DELETE) — is part of writing it, so all of them need
2554
- * `storage:write`. Which source the upload writes to was checked when it was
2555
- * created; these steps only reach an upload the same caller owns.
2556
- */
2557
- function createTusScopeGuard() {
2558
- return async (c, next) => {
2559
- if (/\/tus\/[^/]+$/.test(c.req.path) && c.get("user") && !callerHolds(c, "storage:write")) return forbidScope(c, "storage:write");
2560
- return next();
2561
- };
2562
- }
2563
- /**
2564
- * Scope guard for the custom-functions router: a narrowed credential needs
2565
- * `functions:invoke` on the function it calls. The functions index — the
2566
- * listing at the mount point itself — needs the unqualified scope.
2567
- *
2568
- * People pass: what a person may do inside a function is the function's own
2569
- * business, as it always was.
2570
- *
2571
- * @param mountPrefix - The path the functions router is mounted at
2572
- * (e.g. `/api/functions`), used to extract the function
2573
- * name from the request path. Omitted, it is the
2574
- * `mountPath` the functions group recorded for this
2575
- * request — wherever the application mounted it.
2576
- */
2577
- function createFunctionScopeGuard(mountPrefix) {
2578
- return async (c, next) => {
2579
- const narrowed = c.get("scopes");
2580
- if (!narrowed) return next();
2581
- const path = c.req.path;
2582
- const prefix = mountPrefix ?? c.get("mountPath") ?? "";
2583
- const idx = path.indexOf(prefix);
2584
- const rawName = (idx < 0 ? "" : path.slice(idx + prefix.length)).split("/").filter(Boolean)[0] ?? "";
2585
- let functionName;
2586
- try {
2587
- functionName = decodeURIComponent(rawName);
2588
- } catch {
2589
- functionName = rawName;
2590
- }
2591
- if (!(functionName === "" ? scopeGrants(narrowed, "functions:invoke") : scopeGrants(narrowed, "functions:invoke", functionName))) return forbidScope(c, "functions:invoke", functionName);
2592
- return next();
2593
- };
2594
- }
2595
- /**
2596
- * Pre-auth middleware for `rk_` bearer tokens.
2597
- *
2598
- * Routers whose auth gate is JWT-based (`createRequireAuth` — the admin
2599
- * surfaces, the key routes) don't know about API keys. Mounted in front of
2600
- * them, this authenticates `rk_` tokens and populates the request context; the
2601
- * downstream gates then see the already-resolved caller and check its scopes.
2602
- *
2603
- * Requests without an `rk_` bearer token pass through untouched. An invalid,
2604
- * revoked, or expired `rk_` token is rejected here (401) rather than falling
2605
- * through to be misparsed as a JWT.
2606
- */
2607
- function createApiKeyPreAuth(options) {
2608
- return async (c, next) => {
2609
- if (c.get("apiKey")) return next();
2610
- const token = extractBearerToken(c.req.header("authorization")) ?? "";
2611
- if (!token || !isApiKeyToken(token)) return next();
2612
- const result = await validateApiKey(c, token, options);
2613
- if (result !== true) return result;
2614
- return next();
2615
- };
2616
- }
2617
- //#endregion
2618
- //#region src/auth/impersonation.ts
2619
- var ADMIN_ONLY = "Only a signed-in administrator can run a request as another user.";
2620
- var NOT_A_PERSON = {
2621
- "api-key": {
2622
- reason: "api-key",
2623
- message: "An API key cannot run a request as another user. Send it with an administrator's session instead."
2624
- },
2625
- "service-key": {
2626
- reason: "service-key",
2627
- message: "The service key cannot run a request as another user. Send it with an administrator's session instead."
2628
- },
2629
- none: {
2630
- reason: "anonymous",
2631
- message: ADMIN_ONLY
2632
- }
2633
- };
2634
- /**
2635
- * Decide whether this caller may act as the user they named, and who that
2636
- * user is now.
2637
- *
2638
- * The one decision both doors take — the HTTP data plane through
2639
- * {@link applyImpersonation}, the realtime socket at `AUTHENTICATE` and before
2640
- * every frame after it. The caller is judged before the named user is looked
2641
- * up, so the answer to a caller who may not impersonate says nothing about
2642
- * whether the uid exists.
2643
- *
2644
- * Throws when a lookup fails: that is not an answer about anyone, and each
2645
- * door refuses it its own way.
2646
- */
2647
- async function decideImpersonation(request) {
2648
- const { credential, caller, resolveUser, audit } = request;
2649
- const targetUid = request.requestedUid.trim();
2650
- const refuse = (reason, error) => {
2651
- if (audit) logger.warn("[Security Audit] Refused a request to act as another user", {
2652
- eventType: "auth.impersonation.refused",
2653
- reason,
2654
- callerUid: caller?.uid,
2655
- requestedUid: targetUid,
2656
- ...audit
2657
- });
2658
- return { refused: error };
2659
- };
2660
- 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"));
2661
- if (credential !== "session") {
2662
- const { reason, message } = NOT_A_PERSON[credential];
2663
- return refuse(reason, ApiError.forbidden(message, "IMPERSONATION_FORBIDDEN"));
2664
- }
2665
- if (!caller || !hasAdminRole(caller.roles)) return refuse("not-admin", ApiError.forbidden(ADMIN_ONLY, "IMPERSONATION_FORBIDDEN"));
2666
- 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."));
2667
- const self = await resolveUser(caller.uid);
2668
- if (!self || !hasAdminRole(self.roles)) return refuse("not-admin", ApiError.forbidden(ADMIN_ONLY, "IMPERSONATION_FORBIDDEN"));
2669
- const target = await resolveUser(targetUid);
2670
- 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"));
2671
- if (audit) logger.info("[Security Audit] Ran a request as another user", {
2672
- eventType: "auth.impersonation",
2673
- impersonatorUid: caller.uid,
2674
- targetUid: target.uid,
2675
- targetRoles: target.roles,
2676
- ...audit
2677
- });
2678
- return {
2679
- granted: target,
2680
- impersonator: caller.uid
2681
- };
2682
- }
2683
- /** The caller the auth middleware put on the context, narrowed. */
2684
- function callerOf(c) {
2685
- const user = c.get("user");
2686
- if (typeof user !== "object" || user === null) return void 0;
2687
- const uid = "uid" in user && typeof user.uid === "string" && user.uid !== "" ? user.uid : void 0;
2688
- if (uid === void 0) return void 0;
2689
- return {
2690
- uid,
2691
- roles: "roles" in user && Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : []
2692
- };
2693
- }
2694
- /**
2695
- * Honour or refuse this request's {@link IMPERSONATE_HEADER}.
2696
- *
2697
- * Called by the data-plane auth middlewares once the caller is on the context
2698
- * and its driver is scoped. When the header names a user the caller may act
2699
- * as, the context's `user` and `driver` are replaced with that user's and
2700
- * `impersonator` names the caller.
2701
- *
2702
- * @returns The refusal to send, or `undefined` to carry on — as the caller
2703
- * when the header is absent, as the named user when it was granted.
2704
- */
2705
- async function applyImpersonation(c, { credential, driver, resolveUser }) {
2706
- const requested = c.req.header(IMPERSONATE_HEADER);
2707
- if (requested === void 0) return void 0;
2708
- const caller = callerOf(c);
2709
- let decision;
2710
- try {
2711
- decision = await decideImpersonation({
2712
- requestedUid: requested,
2713
- credential,
2714
- caller,
2715
- resolveUser,
2716
- audit: {
2717
- door: "http",
2718
- method: c.req.method,
2719
- path: c.req.path,
2720
- requestId: c.get("requestId")
2721
- }
2722
- });
2723
- } catch (error) {
2724
- return errorHandler(error instanceof Error ? error : new Error(String(error)), c);
2725
- }
2726
- if ("refused" in decision) return errorHandler(decision.refused, c);
2727
- const target = decision.granted;
2728
- const isAnonymous = target.isAnonymous === true;
2729
- c.set("user", {
2730
- uid: target.uid,
2731
- email: target.email,
2732
- roles: target.roles,
2733
- isAnonymous
2734
- });
2735
- c.set("impersonator", { uid: decision.impersonator });
2736
- try {
2737
- c.set("driver", await scopeDataDriver(driver, {
2738
- uid: target.uid,
2739
- roles: target.roles,
2740
- isAnonymous,
2741
- ...target.claims ? { claims: target.claims } : {}
2742
- }));
2743
- } catch (error) {
2744
- logger.error("[AUTH] RLS scoping failed for an impersonated request", { error });
2745
- return errorHandler(ApiError.internal("Internal authentication error"), c);
2746
- }
2747
- }
2748
- /**
2749
- * Refuse {@link IMPERSONATE_HEADER} on every route that does not honour it.
2750
- *
2751
- * Only the data API and custom functions run a request as another user; the
2752
- * rest — storage, auth, the admin surfaces — authenticate the caller and
2753
- * ignore the header. Ignored, a request asking to act as B would be answered
2754
- * as the administrator who sent it, which is the one outcome impersonation
2755
- * must never have. Mounted ahead of every route, so a surface added later is
2756
- * refused until it is listed here as honouring the header.
2757
- *
2758
- * @param honouredMounts - The mount points whose auth middleware applies the
2759
- * header (`/api/data`, `/api/functions`). A path is under one when it
2760
- * is the mount itself or continues it with a `/`.
2761
- */
2762
- function refuseUnhonouredImpersonation(honouredMounts) {
2763
- return async (c, next) => {
2764
- if (c.req.header("x-rebase-impersonate") === void 0) return next();
2765
- const path = c.req.path;
2766
- if (honouredMounts.some((mount) => path === mount || path.startsWith(`${mount}/`))) return next();
2767
- logger.warn("[Security Audit] Refused a request to act as another user", {
2768
- eventType: "auth.impersonation.refused",
2769
- reason: "unsupported-route",
2770
- method: c.req.method,
2771
- path,
2772
- requestId: c.get("requestId")
2773
- });
2774
- 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);
2775
- };
2776
- }
2777
- //#endregion
2778
- //#region src/storage/requested-object.ts
2779
- /**
2780
- * Which object a storage request's URL path names.
2781
- *
2782
- * Three parties read the path of a `GET /file/*`, `GET /metadata/*` or
2783
- * `DELETE /file/*`: the route, which serves or deletes an object;
2784
- * `publicObjectAuth`, which lets an anonymous caller through when that object
2785
- * is public; and `fileTokenAuth`, which compares it with the path a download
2786
- * token grants. A decision is only about the object acted on if it is made on
2787
- * the same key, so all three derive it here — the wildcard, the decoding, the
2788
- * bucket segment and the canonical key — and none of them spells a step of its
2789
- * own.
2790
- *
2791
- * They used to. `publicObjectAuth` judged the raw path with a check that strips
2792
- * everything up to a `://`, while the route canonicalized it, folding `//` to
2793
- * `/`: `GET /file/notes://public/secret.txt` was public to the one and served
2794
- * the private key `notes:/public/secret.txt` from the other, with the authorize
2795
- * hook never asked.
2796
- *
2797
- * Nothing here parses a scheme. A request path is a key, optionally behind the
2798
- * one bucket segment the routes recognise; a `://` in it is a `:` followed by
2799
- * an empty segment, and canonicalization folds the empty segment away.
2800
- */
2801
- /**
2802
- * The wildcard part of a storage route's path, still percent-encoded.
2803
- *
2804
- * Hono's `c.req.param('*')` does not work reliably in sub-routers mounted
2805
- * via `app.route(prefix, subRouter)`. Instead it is derived from the
2806
- * fully-resolved `c.req.path` and `c.req.routePath`.
2807
- *
2808
- * For a route `/metadata/*` mounted at `/api/storage`, a request to
2809
- * `/api/storage/metadata/default/file.jpg` yields routePath
2810
- * `/api/storage/metadata/*`. The prefix (everything before `/*`) is stripped,
2811
- * plus one character for the trailing `/`, to obtain `default/file.jpg`.
2812
- */
2813
- function storageRequestWildcard(c) {
2814
- const prefix = c.req.routePath.replace("/*", "");
2815
- const fullPath = c.req.path;
2816
- const idx = fullPath.indexOf(prefix);
2817
- if (idx < 0) return "";
2818
- return fullPath.substring(idx + prefix.length + 1);
2819
- }
2820
- /**
2821
- * The object a decoded path names: a key, behind an optional `default/` bucket
2822
- * segment (matched case-insensitively). Throws {@link InvalidStorageKeyError}
2823
- * when the key cannot be canonicalized.
2824
- *
2825
- * For a path that is already text — the folder route's JSON body. A URL path
2826
- * goes through {@link requestedStorageObject}, which decodes it first.
2827
- */
2828
- function storageObjectOfPath(decodedPath) {
2829
- const parts = decodedPath.split("/");
2830
- return {
2831
- bucket: "default",
2832
- key: canonicalStorageKey(parts.length > 1 && parts[0].toLowerCase() === "default" ? parts.slice(1).join("/") : decodedPath)
2833
- };
2834
- }
2835
- /**
2836
- * The object a request's wildcard path names, or {@link InvalidStorageKeyError}.
2837
- *
2838
- * The wildcard arrives as Hono leaves it — `decodeURI`d, with the reserved
2839
- * characters (`/`, `:`, `%25` among them) still escaped — so one
2840
- * `decodeURIComponent` yields the path the client encoded. A `%` that does not
2841
- * begin an escape names no key, and is refused like any other key that cannot be
2842
- * canonicalized rather than thrown as a `URIError`.
2843
- */
2844
- function requestedStorageObject(wildcard) {
2845
- let decoded;
2846
- try {
2847
- decoded = decodeURIComponent(wildcard);
2848
- } catch {
2849
- throw new InvalidStorageKeyError("Storage path is not valid percent-encoding: a '%' must begin an escape such as '%25' (a literal '%').");
2850
- }
2851
- return storageObjectOfPath(decoded);
2852
- }
2853
- /**
2854
- * {@link requestedStorageObject}, or `null` when the path names no object.
2855
- *
2856
- * For the middleware, which must fail closed without an exception: a path that
2857
- * names no object is neither public nor covered by any download token, and the
2858
- * route answers it with a 400 of its own.
2859
- */
2860
- function tryRequestedStorageObject(wildcard) {
2861
- try {
2862
- return requestedStorageObject(wildcard);
2863
- } catch (err) {
2864
- if (err instanceof InvalidStorageKeyError) return null;
2865
- throw err;
2866
- }
2867
- }
2868
- //#endregion
2869
- //#region src/storage/request-principals.ts
2870
- /**
2871
- * The two principals a storage read can be granted without a user: the reader
2872
- * of a declared-public object, and the bearer of a download token.
2873
- *
2874
- * `checkAuthorized` does not ask the `storageAuthorize` hook about either — the
2875
- * public prefix and the token are the authorization. That skip used to be
2876
- * decided on the principal's `uid` string (`"public"`, `"download-token"`), so
2877
- * any identity carrying that uid skipped the hook: a custom `AuthAdapter` whose
2878
- * `verifyRequest` returns `{ uid: "public" }` for a guest could then upload,
2879
- * delete and list anywhere, with the hook never asked.
2880
- *
2881
- * Each principal is now one object, set only by the middleware that granted
2882
- * it (`publicObjectAuth`, `fileTokenAuth`) and recognised by identity. An
2883
- * adapter builds a new object per request, so it can name the uid but never be
2884
- * the grant.
2885
- */
2886
- /** The principal `publicObjectAuth` grants an anonymous read of a public object. */
2887
- var PUBLIC_OBJECT_PRINCIPAL = Object.freeze({
2888
- uid: "public",
2889
- roles: ["public"]
2890
- });
2891
- /** The principal `fileTokenAuth` grants a read covered by a valid download token. */
2892
- var DOWNLOAD_TOKEN_PRINCIPAL = Object.freeze({
2893
- uid: "download-token",
2894
- roles: ["reader"]
2895
- });
2896
- /** Whether `user` is the grant `publicObjectAuth` made — the object, not the uid. */
2897
- function isPublicObjectGrant(user) {
2898
- return user === PUBLIC_OBJECT_PRINCIPAL;
2899
- }
2900
- /** Whether `user` is the grant `fileTokenAuth` made — the object, not the uid. */
2901
- function isDownloadTokenGrant(user) {
2902
- return user === DOWNLOAD_TOKEN_PRINCIPAL;
2903
- }
2904
- //#endregion
2905
- //#region src/auth/middleware.ts
2906
- /**
2907
- * Answer a refusal with the canonical envelope, through the same formatter
2908
- * every route uses.
2909
- *
2910
- * These middlewares used to hand-build `c.json({ error: { message, code } },
2911
- * 401)` — seven near-copies of it, which is how they came to omit `requestId`
2912
- * while every other error on the server carried it: the one string a bug report
2913
- * quotes was missing from precisely the errors an app hits most. It also meant
2914
- * a routine 401 (a client refreshing before it knows whether a session exists)
2915
- * logged like an incident, because `ApiError.expected` never reached the
2916
- * logger.
2917
- *
2918
- * `errorHandler` is *called* rather than thrown to. A throw only becomes an
2919
- * envelope where an `onError` is registered, and these middlewares are mounted
2920
- * by callers this package does not control — `defineFunction` hands users a
2921
- * Hono app and invites them to put `requireAuth` on a route of it. Making the
2922
- * status depend on the mount point would turn a 401 into a 500 for exactly
2923
- * those callers. Calling the formatter directly gets the envelope with none of
2924
- * that.
2925
- */
2926
- function refuse(c, error) {
2927
- return errorHandler(error, c);
2928
- }
2929
- /**
2930
- * Hono middleware that requires a valid JWT token via Authorization header.
2931
- * Returns 401 if token is missing or invalid.
2932
- *
2933
- * **Security:** Tokens are only accepted via the `Authorization: Bearer`
2934
- * header. Query-string tokens (`?token=`) are intentionally NOT accepted
2935
- * here because URLs leak into access logs, proxies, Referer headers, and
2936
- * browser history. Use {@link queryTokenAuth} on routes that legitimately
2937
- * need query-string tokens (e.g. storage file serving for `<img src>`).
2938
- */
2939
- var requireAuth = async (c, next) => {
2940
- if (c.get("user")) return next();
2941
- const token = extractBearerToken(c.req.header("authorization"));
2942
- if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
2943
- const payload = await jwtOf(c).verifyAccessToken(token);
2944
- if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
2945
- c.set("user", payload);
2946
- return next();
2947
- };
2948
- /**
2949
- * Factory that creates a requireAuth middleware with optional service key support.
2950
- *
2951
- * When `serviceKey` is provided, the middleware will check if the Bearer token
2952
- * matches the service key using constant-time comparison. If it matches, the
2953
- * request is authenticated as a service user with admin privileges.
2954
- *
2955
- * This allows admin routes (which use standalone requireAuth + requireAdmin)
2956
- * to be accessed via service keys for scripts and server-to-server calls.
2957
- */
2958
- function createRequireAuth(options) {
2959
- if (!options?.serviceKey && !options?.resolveRoles && !options?.revocationRepo) return requireAuth;
2960
- const key = options.serviceKey;
2961
- const resolveRoles = options.resolveRoles;
2962
- const revocationRepo = options.revocationRepo;
2963
- return async (c, next) => {
2964
- if (c.get("user")) return next();
2965
- const token = extractBearerToken(c.req.header("authorization"));
2966
- if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
2967
- if (key !== void 0 && safeCompare(token, key)) {
2968
- c.set("user", {
2969
- uid: "service",
2970
- roles: ["admin"]
2971
- });
2972
- return next();
2973
- }
2974
- const payload = await jwtOf(c).verifyAccessToken(token);
2975
- if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
2976
- if (resolveRoles || revocationRepo) try {
2977
- let judgedRoles;
2978
- if (revocationRepo) {
2979
- const verdict = await judgeAccessToken(revocationRepo, payload);
2980
- if (!verdict.live) return refuse(c, accessTokenRefusalError(verdict.refusal));
2981
- judgedRoles = verdict.roles;
2982
- }
2983
- c.set("user", resolveRoles ? {
2984
- ...payload,
2985
- roles: judgedRoles ?? await resolveRoles(payload.uid)
2986
- } : payload);
2987
- return next();
2988
- } catch (error) {
2989
- if (isDatabaseUnreachable(error)) {
2990
- logger.warn("[Auth] Could not resolve roles for an admin-gated request: the database is unreachable", { uid: payload.uid });
2991
- return refuse(c, ApiError.databaseUnavailable());
2992
- }
2993
- logger.warn("[Auth] Could not resolve roles for an admin-gated request", {
2994
- uid: payload.uid,
2995
- error
2996
- });
2997
- return refuse(c, ApiError.serviceUnavailable("Could not verify your permissions. Please try again.", "ROLE_LOOKUP_FAILED"));
2998
- }
2999
- c.set("user", payload);
3000
- return next();
3001
- };
3002
- }
3003
- /**
3004
- * Middleware that requires the user to hold the `admin` role, which holds
3005
- * every scope. Must be used AFTER requireAuth or on a route where user is
3006
- * guaranteed.
3007
- *
3008
- * Prefer `requireScope` from `./access` for anything a scope names: a scope
3009
- * can be granted to a narrower role and to a key, and `admin` cannot.
3010
- */
3011
- var requireAdmin = async (c, next) => {
3012
- const user = c.get("user");
3013
- if (!user) return refuse(c, ApiError.unauthorized("User not authenticated. requireAuth middleware is missing?"));
3014
- 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"));
3015
- return next();
3016
- };
3017
- /**
3018
- * Middleware that optionally extracts user from JWT via Authorization header.
3019
- * Does not return 401 if token is missing — allows anonymous access.
3020
- *
3021
- * Query-string tokens are NOT accepted here. Use {@link queryTokenAuth}
3022
- * on routes that need them.
3023
- */
3024
- var optionalAuth = async (c, next) => {
3025
- if (c.get("user")) return next();
3026
- const token = extractBearerToken(c.req.header("authorization"));
3027
- const jwt = jwtOf(c);
3028
- if (token !== void 0 && jwt.isConfigured()) {
3029
- const payload = await jwt.verifyAccessToken(token);
3030
- if (payload) c.set("user", payload);
3031
- }
3032
- return next();
3033
- };
3034
- /**
3035
- * Who a session token names, as `jwt` verifies it — or "nobody" rather than a
3036
- * throw on a backend with no JWT of its own, so a socket closes as
3037
- * unauthenticated, which is the outcome this describes.
3038
- */
3039
- async function userFromToken(jwt, token) {
3040
- if (!jwt.isConfigured()) return null;
3041
- return jwt.verifyAccessToken(token);
3042
- }
3043
- /**
3044
- * Extract user from token - for WebSocket authentication. Answers for the
3045
- * process default; a backend's own socket verifies with its own keys.
3046
- */
3047
- async function extractUserFromToken(token) {
3048
- return userFromToken(processJwtService, token);
3049
- }
3050
- /**
3051
- * Create a configurable auth middleware that handles:
3052
- * 1. Token extraction (via custom validator or JWT Bearer token)
3053
- * 2. RLS-scoped DataDriver via withAuth()
3054
- * 3. Enforcement (401 when requireAuth is true and no user)
3055
- *
3056
- * **Secure by default:** `requireAuth` defaults to `true`. Anonymous
3057
- * access is only allowed when the developer explicitly opts out by
3058
- * setting `requireAuth: false`, indicating that Postgres RLS policies
3059
- * fully control access.
3060
- *
3061
- * **Fail-closed:** The raw unscoped driver is never placed in the
3062
- * request context. Every code path either scopes via `withAuth()` or
3063
- * rejects the request. This prevents silent RLS bypass.
3064
- *
3065
- * This is the single source of truth for HTTP auth in Rebase.
3066
- * Use this instead of manually parsing tokens in route handlers.
3067
- */
3068
- function createAuthMiddleware(options) {
3069
- const { driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
3070
- return async (c, next) => {
3071
- const driver = resolveDriver ? resolveDriver(c) : baseDriver;
3072
- let credential = "none";
3073
- if (validator) try {
3074
- const authResult = await validator(c);
3075
- if (authResult && typeof authResult === "object") {
3076
- const id = "uid" in authResult ? authResult.uid : void 0;
3077
- if (id) {
3078
- const roles = authResult.roles || [];
3079
- credential = "session";
3080
- c.set("user", {
3081
- uid: id,
3082
- roles
3083
- });
3084
- const user = {
3085
- uid: id,
3086
- roles,
3087
- ...authResult
3088
- };
3089
- c.set("driver", await scopeDataDriver(driver, user));
3090
- } else c.set("driver", await scopeDataDriver(driver, {
3091
- uid: ANONYMOUS_USER_ID,
3092
- roles: ["anon"]
3093
- }));
3094
- } else if (authResult === true) {
3095
- credential = "session";
3096
- c.set("user", {
3097
- uid: "default",
3098
- roles: []
3099
- });
3100
- c.set("driver", await scopeDataDriver(driver, {
3101
- uid: "default",
3102
- roles: []
3103
- }));
3104
- } else c.set("driver", await scopeDataDriver(driver, {
3105
- uid: ANONYMOUS_USER_ID,
3106
- roles: ["anon"]
3107
- }));
3108
- } catch (error) {
3109
- return refuse(c, ApiError.unauthenticated("Unauthorized"));
3110
- }
3111
- else {
3112
- const token = extractBearerToken(c.req.header("authorization"));
3113
- if (token !== void 0) if (serviceKey && safeCompare(token, serviceKey)) {
3114
- const serviceUser = {
3115
- uid: "service",
3116
- roles: ["admin"]
3117
- };
3118
- credential = "service-key";
3119
- c.set("user", serviceUser);
3120
- try {
3121
- c.set("driver", await scopeDataDriver(driver, {
3122
- uid: "service",
3123
- roles: ["admin"]
3124
- }));
3125
- } catch (error) {
3126
- logger.error("[AUTH] RLS scoping failed for service key", { error });
3127
- return c.json({ error: {
3128
- message: "Internal authentication error",
3129
- code: "INTERNAL_ERROR"
3130
- } }, 500);
3131
- }
3132
- } else if (apiKeyStore && isApiKeyToken(token)) {
3133
- const result = await validateApiKey(c, token, {
3134
- store: apiKeyStore,
3135
- driver
3136
- });
3137
- if (result !== true) return result;
3138
- credential = "api-key";
3139
- } else {
3140
- const payload = await userFromToken(jwtOf(c), token);
3141
- if (payload) {
3142
- credential = "session";
3143
- c.set("user", payload);
3144
- try {
3145
- const user = {
3146
- uid: payload.uid,
3147
- roles: payload.roles,
3148
- isAnonymous: payload.isAnonymous === true,
3149
- claims: payload.claims
3150
- };
3151
- c.set("driver", await scopeDataDriver(driver, user));
3152
- } catch (error) {
3153
- logger.error("[AUTH] RLS scoping failed for authenticated user", { error });
3154
- return c.json({ error: {
3155
- message: "Internal authentication error",
3156
- code: "INTERNAL_ERROR"
3157
- } }, 500);
3158
- }
3159
- } else return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
3160
- }
3161
- else try {
3162
- c.set("driver", await scopeDataDriver(driver, {
3163
- uid: ANONYMOUS_USER_ID,
3164
- roles: ["anon"]
3165
- }));
3166
- } catch (error) {
3167
- logger.error("[AUTH] Failed to create anon-scoped driver", { error });
3168
- return c.json({ error: {
3169
- message: "Server configuration error",
3170
- code: "INTERNAL_ERROR"
3171
- } }, 500);
3172
- }
3173
- }
3174
- const refused = await applyImpersonation(c, {
3175
- credential,
3176
- driver
3177
- });
3178
- if (refused) return refused;
3179
- 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."));
3180
- return next();
3181
- };
3182
- }
3183
- /**
3184
- * Middleware that authenticates via a `?token=` query parameter.
3185
- *
3186
- * **Use sparingly.** Tokens in URLs leak into access logs, proxy logs,
3187
- * Referer headers, and browser history. This middleware exists solely for
3188
- * routes where the consumer cannot set HTTP headers — e.g. `<img src>`,
3189
- * `<a href>` for file downloads, or similar browser-native requests.
3190
- *
3191
- * Apply it **before** `requireAuth` or `optionalAuth` on the specific
3192
- * route that needs it. Those middlewares will see the user context this
3193
- * middleware sets and skip their own 401 check.
3194
- *
3195
- * @example
3196
- * ```ts
3197
- * router.get("/file/*", queryTokenAuth, readAuthMiddleware, handler);
3198
- * ```
3199
- */
3200
- var queryTokenAuth = async (c, next) => {
3201
- if (extractBearerToken(c.req.header("authorization")) !== void 0) return next();
3202
- const queryToken = c.req.query("token");
3203
- const jwt = jwtOf(c);
3204
- if (queryToken && jwt.isConfigured()) {
3205
- const payload = await jwt.verifyAccessToken(queryToken);
3206
- if (payload) c.set("user", payload);
3207
- }
3208
- return next();
3209
- };
3210
- /**
3211
- * Authorizes anonymous access to **public** storage objects (those under the
3212
- * public prefix), which are served token-less via stable, permanent URLs.
3213
- *
3214
- * Runs on the storage `/file/*` and `/metadata/*` routes, *after* the token
3215
- * middleware and *before* the read-auth gate. If the request is already
3216
- * authenticated (Bearer or scoped token), it does nothing. Otherwise, when the
3217
- * requested object path is public, it sets a minimal "public" principal so the
3218
- * downstream `requireAuth` gate lets the read through. Private paths are left
3219
- * untouched, so they still require a valid token.
3220
- *
3221
- * Public means the object the route will serve is public, so the decision is
3222
- * made on that object's canonical key, derived by the route's own function
3223
- * (`requestedStorageObject`). It used to be made on the raw path, through a
3224
- * check that strips a `scheme://`: `notes://public/secret.txt` read as
3225
- * `public/secret.txt` here while the route served the private
3226
- * `notes:/public/secret.txt`, and the authorize hook — not asked about this
3227
- * principal — never saw it.
3228
- */
3229
- var publicObjectAuth = async (c, next) => {
3230
- if (c.get("user")) return next();
3231
- const requested = tryRequestedStorageObject(storageRequestWildcard(c));
3232
- if (requested && isPublicStorageKey(requested.key)) c.set("user", PUBLIC_OBJECT_PRINCIPAL);
3233
- return next();
3234
- };
3235
- /**
3236
- * Helper to match paths for scoped file tokens.
3237
- * Matches exact paths or prefixes (exact folder prefixes or ending with /).
3238
- *
3239
- * The traversal check is defence in depth, not the load-bearing defence.
3240
- *
3241
- * A prefix comparison is a lie for any path containing `..`: `user1/../user2/x`
3242
- * starts with `user1/`, so a token scoped to `user1/` would authorize reading
3243
- * user2's file — and the storage controller would not object, because its own
3244
- * guard stops a path escaping the *bucket*, and that one resolves to `user2/x`,
3245
- * comfortably inside it. The grant is what would be escaped, and this is the
3246
- * only layer that knows what was granted.
3247
- *
3248
- * Nothing arrives here in that shape today: the URL parser resolves `..` (and
3249
- * `%2e%2e`, which the WHATWG spec treats as a dot for normalization) before
3250
- * Hono routes the request, so this comparison already runs on a resolved path.
3251
- * But that is a guarantee of the runtime rather than of this code, and it is
3252
- * one line to not depend on it. The same rule already guards the public-object
3253
- * path — see `isPublicStorageKey`.
3254
- */
3255
- function isPathMatch(requested, allowed) {
3256
- if (requested.split("/").some((seg) => seg === "..")) return false;
3257
- if (requested === allowed) return true;
3258
- if (allowed.endsWith("/")) return requested.startsWith(allowed);
3259
- return requested.startsWith(allowed + "/");
1892
+ resolvedHooks.afterUserDelete(uid).catch((err) => {
1893
+ logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
1894
+ });
3260
1895
  }
3261
1896
  /**
3262
- * Middleware that authenticates file-serving routes using scoped download tokens.
3263
- * It enforces that only scoped "file-read" tokens can access "/file/*" and "?token=" query params.
3264
- * Full access JWTs are explicitly rejected on "/file/*" routes, and in the "?token=" query param.
1897
+ * The auth adapter's side of user administration through the auth collection
1898
+ * (`AuthAdapter.prepareUserUpdates` and the deletion pair): the admin user
1899
+ * routes' rules and hooks, for the data API's writes to the same rows.
3265
1900
  */
3266
- var fileTokenAuth = async (c, next) => {
3267
- const isFileRoute = c.req.path.includes("/storage/file/");
3268
- const authHeader = c.req.header("authorization");
3269
- const queryToken = c.req.query("token");
3270
- /**
3271
- * Decide what a valid download token entitles this request to.
3272
- *
3273
- * One function rather than a copy per token location: a Bearer token and a
3274
- * `?token=` are the same grant presented two ways, and the whole failure
3275
- * this guards against is two places disagreeing about what was granted.
3276
- *
3277
- * `"no-path"` is distinct from the two denials because the caller treats it
3278
- * differently — a request with no object path at all is not a denied read,
3279
- * it is a request this middleware has nothing to say about, and on
3280
- * `/metadata/*` it must still reach the downstream auth gate.
3281
- */
3282
- const evaluateGrant = (payload) => {
3283
- if (canonicalStorageId(c.req.query("storageId")) !== payload.storageId) return "deny-storage";
3284
- const rawPath = storageRequestWildcard(c);
3285
- if (!rawPath) return "no-path";
3286
- const requested = tryRequestedStorageObject(rawPath);
3287
- if (requested === null) return "deny-path";
3288
- return isPathMatch(`${requested.bucket}/${requested.key}`, payload.path) ? "grant" : "deny-path";
3289
- };
3290
- /**
3291
- * Which half of the scope failed is named in the message. The holder of the
3292
- * token already knows both values, so this reveals nothing to an attacker —
3293
- * and without it, a client that forgets to forward `?storageId=` on the file
3294
- * URL reports a "path mismatch" for a path that matches perfectly.
3295
- */
3296
- const denyMismatch = (outcome) => refuse(c, ApiError.forbidden(outcome === "deny-storage" ? "Forbidden: Scoped token storage mismatch" : "Forbidden: Scoped token path mismatch"));
3297
- const bearerToken = extractBearerToken(authHeader);
3298
- if (bearerToken !== void 0) {
3299
- const token = bearerToken;
3300
- const payload = await jwtOf(c).verifyDownloadToken(token);
3301
- if (payload) {
3302
- const outcome = evaluateGrant(payload);
3303
- if (outcome === "grant") {
3304
- c.set("user", DOWNLOAD_TOKEN_PRINCIPAL);
3305
- return next();
3306
- }
3307
- if (outcome !== "no-path") return denyMismatch(outcome);
3308
- }
3309
- if (isFileRoute) return refuse(c, ApiError.unauthenticated("Unauthorized: Access JWT not allowed on file routes"));
3310
- return next();
3311
- }
3312
- if (queryToken) {
3313
- const payload = await jwtOf(c).verifyDownloadToken(queryToken);
3314
- if (payload) {
3315
- const outcome = evaluateGrant(payload);
3316
- if (outcome === "grant") {
3317
- c.set("user", DOWNLOAD_TOKEN_PRINCIPAL);
3318
- return next();
1901
+ function authCollectionUserAdmin(ctx) {
1902
+ const { authRepo, resolvedHooks } = ctx;
1903
+ return {
1904
+ async prepareUserUpdates(updates) {
1905
+ for (const { values } of updates) if (values.roles !== void 0 && values.roles !== null) readRoleNames(values.roles);
1906
+ await assertRoleChangesAllowed(authRepo, updates.flatMap(({ uid, values }) => Array.isArray(values.roles) ? [{
1907
+ uid,
1908
+ roles: values.roles
1909
+ }] : []));
1910
+ const prepared = [];
1911
+ for (const { uid, values } of updates) prepared.push(typeof values.email === "string" ? {
1912
+ ...values,
1913
+ email: await normalizeUserEmailChange(authRepo, uid, values.email)
1914
+ } : values);
1915
+ return prepared;
1916
+ },
1917
+ async prepareUserDeletions(uids) {
1918
+ await assertUserDeletionsAllowed(authRepo, uids);
1919
+ if (resolvedHooks.beforeUserDelete) for (const uid of uids) await resolvedHooks.beforeUserDelete(uid);
1920
+ },
1921
+ async finalizeUserDeletions(uids) {
1922
+ for (const uid of uids) {
1923
+ await authRepo.deleteAllRefreshTokensForUser(uid);
1924
+ runAfterUserDelete(resolvedHooks, uid);
3319
1925
  }
3320
- if (outcome !== "no-path") return denyMismatch(outcome);
3321
1926
  }
3322
- return refuse(c, ApiError.unauthenticated("Unauthorized: Invalid or unauthorized token"));
3323
- }
3324
- return next();
3325
- };
1927
+ };
1928
+ }
3326
1929
  //#endregion
3327
1930
  //#region src/auth/adapter-middleware.ts
1931
+ var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
3328
1932
  /**
3329
1933
  * What a request is refused with when `adapter.verifyRequest` throws: the
3330
1934
  * adapter's own answer when it gave one, otherwise a 401 — fail closed either
@@ -3353,6 +1957,11 @@ function createAdapterAuthMiddleware(options) {
3353
1957
  driver
3354
1958
  });
3355
1959
  if (result !== true) return result;
1960
+ const refusedSimulation = await applySimulation(c, {
1961
+ credential: "api-key",
1962
+ driver
1963
+ });
1964
+ if (refusedSimulation) return refusedSimulation;
3356
1965
  const refused = await applyImpersonation(c, {
3357
1966
  credential: "api-key",
3358
1967
  driver
@@ -3397,13 +2006,22 @@ function createAdapterAuthMiddleware(options) {
3397
2006
  return refuse(c, ApiError.internal("Server configuration error"));
3398
2007
  }
3399
2008
  }
2009
+ const credential = !authenticatedUser ? "none" : authenticatedUser.uid === SERVICE_IDENTITY.uid ? "service-key" : "session";
2010
+ const resolveUser = adapter.resolveUser ? adapter.resolveUser.bind(adapter) : void 0;
2011
+ const refusedSimulation = await applySimulation(c, {
2012
+ credential,
2013
+ driver,
2014
+ resolveUser,
2015
+ apiKeyStore
2016
+ });
2017
+ if (refusedSimulation) return refusedSimulation;
3400
2018
  const refused = await applyImpersonation(c, {
3401
- credential: !authenticatedUser ? "none" : authenticatedUser.uid === SERVICE_IDENTITY.uid ? "service-key" : "session",
2019
+ credential,
3402
2020
  driver,
3403
- resolveUser: adapter.resolveUser ? adapter.resolveUser.bind(adapter) : void 0
2021
+ resolveUser
3404
2022
  });
3405
2023
  if (refused) return refused;
3406
- 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."));
2024
+ if (enforceAuth && !c.get("user") && !c.get("simulation")) 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."));
3407
2025
  return next();
3408
2026
  };
3409
2027
  }
@@ -3847,6 +2465,13 @@ function createDevEmailSink(options = {}) {
3847
2465
  }
3848
2466
  var activeSink;
3849
2467
  /**
2468
+ * Every sink registered, by the `sendEmail` it hands out: what lets a backend
2469
+ * serve the mailbox it was given rather than the process's last one. With two
2470
+ * backends in one development process, the slot above made B's admins read
2471
+ * A's users' reset and magic links.
2472
+ */
2473
+ var sinksBySender = /* @__PURE__ */ new WeakMap();
2474
+ /**
3850
2475
  * Make `sink` the process's development mailbox.
3851
2476
  *
3852
2477
  * Returns the sink, so this can wrap the construction it replaces. In
@@ -3859,8 +2484,18 @@ function registerDevEmailSink(sink) {
3859
2484
  return sink;
3860
2485
  }
3861
2486
  activeSink = sink;
2487
+ sinksBySender.set(sink.sendEmail, sink);
3862
2488
  return sink;
3863
2489
  }
2490
+ /**
2491
+ * The registered mailbox whose `sendEmail` this is — the sink a backend's
2492
+ * `auth.email.sendEmail` came from — or `undefined` when it is some other
2493
+ * sender, or the process is production.
2494
+ */
2495
+ function devEmailSinkFor(sendEmail) {
2496
+ if (process.env.NODE_ENV === "production" || !sendEmail) return void 0;
2497
+ return sinksBySender.get(sendEmail);
2498
+ }
3864
2499
  /** The registered mailbox, if there is one. */
3865
2500
  function activeDevEmailSink() {
3866
2501
  return process.env.NODE_ENV === "production" ? void 0 : activeSink;
@@ -4340,6 +2975,11 @@ function createDataRateLimiter(config = {}, options = {}) {
4340
2975
  const resolveBucket = async (c) => {
4341
2976
  if (c.get("user")?.uid === SERVICE_IDENTITY.uid) return null;
4342
2977
  if (isServiceKey(c)) return null;
2978
+ const impersonator = c.get("impersonator");
2979
+ if (impersonator?.uid) return bucketFor({
2980
+ uid: impersonator.uid,
2981
+ address: () => defaultKeyGenerator(c, trustedProxyHops)
2982
+ });
4343
2983
  const apiKey = c.get("apiKey");
4344
2984
  return bucketFor({
4345
2985
  apiKey,
@@ -6258,36 +4898,29 @@ function mountOtpRoutes(deps) {
6258
4898
  /**
6259
4899
  * May an OAuth identity be attached to a pre-existing account found *by email*?
6260
4900
  *
6261
- * Both sides have to be trustworthy:
6262
- *
6263
- * - the **provider** must have verified the address, or the caller has not
6264
- * shown they control it;
6265
- * - the **local account**'s address must have been verified too. Whoever
6266
- * made an unverified account never proved the address, and every way in
6267
- * they left on it — a password, an identity from a provider that did not
6268
- * vouch for the address — would go on opening the account the owner is
6269
- * about to be signed into.
4901
+ * Only when the **provider** verified the address. Otherwise the caller has
4902
+ * not shown they control it, and linking would hand them the account.
6270
4903
  *
6271
- * The second rule used to exempt an account with no password, on the theory
6272
- * that one made by an OAuth sign-in holds no credential an attacker could have
6273
- * planted. It holds exactly one: the identity it was made with, which is an
6274
- * attacker's whenever the provider did not vouch for the address.
6275
- *
6276
- * A refusal is not a dead end. `POST /auth/link/<provider>` attaches the
6277
- * identity once the caller proves ownership by holding a session, and proving
6278
- * the address (a magic link, an email code, a password reset) verifies the
6279
- * account — removing what nobody proved — after which this answers yes.
4904
+ * When it did, the caller has proven the address exactly as reading a code out
4905
+ * of the inbox does: only the inbox's owner can hold a provider account that
4906
+ * vouches for it. If the **local account**'s address was never verified,
4907
+ * whoever made it never proved the address, and every way in they left on it —
4908
+ * a password, an identity from a provider that did not vouch for the address,
4909
+ * a live session — would go on opening the account the owner is about to be
4910
+ * signed into. So that case answers `provesAddress: true`, and the route
4911
+ * removes those ways in before it links (the pre-hijack defence). It used to
4912
+ * refuse instead, which left the owner of the address locked out of their own
4913
+ * address until they found a magic link or a reset.
6280
4914
  */
6281
4915
  function decideOAuthAutoLink(args) {
6282
4916
  if (args.providerEmailVerified !== true) return {
6283
4917
  allowed: false,
6284
4918
  reason: "provider-email-unverified"
6285
4919
  };
6286
- if (!args.existingUser.emailVerified) return {
6287
- allowed: false,
6288
- reason: args.existingUser.passwordHash ? "local-account-unverified" : "local-account-unverified-passwordless"
4920
+ return {
4921
+ allowed: true,
4922
+ provesAddress: !args.existingUser.emailVerified
6289
4923
  };
6290
- return { allowed: true };
6291
4924
  }
6292
4925
  /**
6293
4926
  * Is `redirectUri` one the operator authorised?
@@ -6743,10 +5376,18 @@ function createAuthRoutes(config) {
6743
5376
  providerEmailVerified: externalUser.emailVerified,
6744
5377
  existingUser: user
6745
5378
  });
6746
- if (!decision.allowed) throw new ApiError(403, "EMAIL_NOT_VERIFIED", `An account with this email already exists with a different sign-in method. ${decision.reason === "provider-email-unverified" ? `${provider.id} has not verified this email address, so it cannot be linked automatically.` : "That account's email address was never verified, so it cannot be linked automatically."} Sign in with your existing method, then POST to /auth/link/${provider.id} to link ${provider.id} to your account.`, {
5379
+ if (!decision.allowed) throw new ApiError(403, "EMAIL_NOT_VERIFIED", `An account with this email already exists with a different sign-in method. ${provider.id} has not verified this email address, so it cannot be linked automatically. Sign in with your existing method, then POST to /auth/link/${provider.id} to link ${provider.id} to your account.`, {
6747
5380
  reason: decision.reason,
6748
5381
  provider: provider.id
6749
5382
  });
5383
+ if (decision.provesAddress) {
5384
+ await confirmAddressOwnership(authRepo, user, null);
5385
+ user = {
5386
+ ...user,
5387
+ emailVerified: true,
5388
+ passwordHash: null
5389
+ };
5390
+ }
6750
5391
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, identityProfileData(externalUser));
6751
5392
  await authRepo.updateUser(user.id, {
6752
5393
  displayName: user.displayName || externalUser.displayName || void 0,
@@ -7710,9 +6351,10 @@ function createAdminUsersRoute(config) {
7710
6351
  });
7711
6352
  router.post("/users", requireScope("users:write"), async (c) => {
7712
6353
  const body = await c.req.json();
7713
- const { email, roles } = body;
6354
+ const { email } = body;
7714
6355
  if (!email) throw ApiError.badRequest("Email is required");
7715
- if (roles !== void 0 && Array.isArray(roles)) assertMayGrantRoles(c, roles.filter((role) => typeof role === "string"));
6356
+ const roles = body.roles === void 0 || body.roles === null ? void 0 : readRoleNames(body.roles);
6357
+ if (roles !== void 0) assertMayGrantRoles(c, roles);
7716
6358
  if (await authRepo.getUserByEmail(normalizeEmail(email))) throw ApiError.conflict("A user with this email already exists");
7717
6359
  const prepResult = await prepareAdminUserValues(body, {
7718
6360
  authRepo,
@@ -7731,7 +6373,7 @@ function createAdminUsersRoute(config) {
7731
6373
  };
7732
6374
  if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
7733
6375
  const user = await authRepo.createUser(createData);
7734
- if (roles && Array.isArray(roles)) await authRepo.setUserRoles(user.id, roles);
6376
+ if (roles !== void 0) await authRepo.setUserRoles(user.id, roles);
7735
6377
  const delivery = await completeUserCreation(prepResult, (clearPassword) => finalizeAdminUserCreation({
7736
6378
  id: user.id,
7737
6379
  values: prepResult.values
@@ -7750,7 +6392,9 @@ function createAdminUsersRoute(config) {
7750
6392
  });
7751
6393
  router.put("/users/:uid", requireScope("users:write"), async (c) => {
7752
6394
  const uid = c.req.param("uid");
7753
- const { password, email, displayName, roles, disabled } = await c.req.json();
6395
+ const body = await c.req.json();
6396
+ const { password, email, displayName, disabled } = body;
6397
+ const roles = body.roles === void 0 || body.roles === null ? void 0 : readRoleNames(body.roles);
7754
6398
  if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
7755
6399
  if (disabled !== void 0) {
7756
6400
  if (typeof disabled !== "boolean") throw ApiError.badRequest("`disabled` must be true or false", "INVALID_INPUT");
@@ -7759,8 +6403,8 @@ function createAdminUsersRoute(config) {
7759
6403
  if (typeof authRepo.setUserDisabled !== "function") throw new ApiError(501, "NOT_SUPPORTED", "This backend's auth repository cannot disable accounts.");
7760
6404
  }
7761
6405
  assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
7762
- if (roles !== void 0 && Array.isArray(roles)) {
7763
- assertMayGrantRoles(c, roles.filter((role) => typeof role === "string"));
6406
+ if (roles !== void 0) {
6407
+ assertMayGrantRoles(c, roles);
7764
6408
  await assertRoleChangesAllowed(authRepo, [{
7765
6409
  uid,
7766
6410
  roles
@@ -7777,7 +6421,7 @@ function createAdminUsersRoute(config) {
7777
6421
  }
7778
6422
  if (Object.keys(updates).length > 0) await authRepo.updateUser(uid, updates);
7779
6423
  if (passwordHash) await replaceUserPassword(authRepo, uid, passwordHash);
7780
- if (roles !== void 0 && Array.isArray(roles)) await authRepo.setUserRoles(uid, roles);
6424
+ if (roles !== void 0) await authRepo.setUserRoles(uid, roles);
7781
6425
  if (typeof disabled === "boolean" && authRepo.setUserDisabled) {
7782
6426
  await authRepo.setUserDisabled(uid, disabled);
7783
6427
  if (disabled) await revokeAllSessions(authRepo, uid);
@@ -7877,7 +6521,7 @@ function createBuiltinAuthAdapter(config) {
7877
6521
  */
7878
6522
  function lookupRefusal(line, uid, error) {
7879
6523
  if (isDatabaseUnreachable(error)) {
7880
- logger.warn(`${line}: the database is unreachable`, { uid });
6524
+ if (!warnIfDatabaseClosed(error)) logger.warn(`${line}: the database is unreachable`, { uid });
7881
6525
  return ApiError.databaseUnavailable();
7882
6526
  }
7883
6527
  logger.warn(line, {
@@ -8277,7 +6921,7 @@ function scopesFromStoredPermissions(permissions, admin) {
8277
6921
  *
8278
6922
  * @module
8279
6923
  */
8280
- var TABLE$1 = "rebase.api_keys";
6924
+ var TABLE = "rebase.api_keys";
8281
6925
  /**
8282
6926
  * Drops `rebase.idx_api_keys_hash` — a plain btree on `key_hash`, next to the
8283
6927
  * UNIQUE (key_hash) constraint's own index — but only while a valid, unique,
@@ -8332,7 +6976,7 @@ var BOOT_COLUMNS = [
8332
6976
  var BOOT_STATE_SQL = (() => {
8333
6977
  const list = (names) => names.map((name) => `'${name}'`).join(", ");
8334
6978
  const target = `(SELECT oid FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')`;
8335
- const columnCount = (names) => `(SELECT count(*) FROM pg_attribute a WHERE a.attrelid = to_regclass('${TABLE$1}') AND a.attnum > 0 AND NOT a.attisdropped AND a.attname IN (${list(names)}))::int`;
6979
+ const columnCount = (names) => `(SELECT count(*) FROM pg_attribute a WHERE a.attrelid = to_regclass('${TABLE}') AND a.attnum > 0 AND NOT a.attisdropped AND a.attname IN (${list(names)}))::int`;
8336
6980
  return `
8337
6981
  SELECT
8338
6982
  (SELECT count(*) FROM pg_class c WHERE c.relnamespace = to_regnamespace('rebase')
@@ -8342,12 +6986,12 @@ SELECT
8342
6986
  ${columnCount(BOOT_COLUMNS)} AS columns,
8343
6987
  ${columnCount(["permissions"])} AS legacy,
8344
6988
  (SELECT count(*) FROM pg_class c
8345
- WHERE c.oid = to_regclass('${TABLE$1}') AND NOT c.relrowsecurity AND ${target} IS NOT NULL
6989
+ WHERE c.oid = to_regclass('${TABLE}') AND NOT c.relrowsecurity AND ${target} IS NOT NULL
8346
6990
  AND (c.relacl IS NULL
8347
6991
  OR EXISTS (SELECT 1 FROM aclexplode(c.relacl) a WHERE a.grantee = ${target})
8348
6992
  OR EXISTS (SELECT 1 FROM pg_attribute att, aclexplode(att.attacl) a
8349
6993
  WHERE att.attrelid = c.oid AND att.attacl IS NOT NULL AND a.grantee = ${target})))::int AS granted,
8350
- (has_schema_privilege('rebase', 'USAGE') AND has_table_privilege(to_regclass('${TABLE$1}'), 'SELECT')) AS readable
6994
+ (has_schema_privilege('rebase', 'USAGE') AND has_table_privilege(to_regclass('${TABLE}'), 'SELECT')) AS readable
8351
6995
  WHERE to_regnamespace('rebase') IS NOT NULL`.trim();
8352
6996
  })();
8353
6997
  /**
@@ -8432,13 +7076,13 @@ function rowToApiKey(row) {
8432
7076
  *
8433
7077
  * Returns `undefined` if the driver does not support `executeSql`.
8434
7078
  */
8435
- function createApiKeyStore(driver) {
7079
+ function createApiKeyStore(driver, options = {}) {
8436
7080
  const admin = driver.admin;
8437
7081
  if (!isSQLAdmin(admin)) {
8438
7082
  logger.warn("⚠️ [api-key-store] DataDriver does not support SQL admin — API keys will not be available.");
8439
7083
  return;
8440
7084
  }
8441
- const exec = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
7085
+ const exec = provisioningExec((sqlText, execOptions) => admin.executeSql(sqlText, execOptions?.params ? { params: execOptions.params } : void 0), options.provision);
8442
7086
  const ddl = createDdlBootstrapper(exec, "api-key-store");
8443
7087
  /**
8444
7088
  * Give every row stored before scopes existed the scopes it now holds.
@@ -8449,10 +7093,10 @@ function createApiKeyStore(driver) {
8449
7093
  * runtime that predates scopes still reads its own keys after a rollback.
8450
7094
  */
8451
7095
  async function backfillScopes() {
8452
- const rows = await exec(`SELECT id, permissions, admin FROM ${TABLE$1} WHERE scopes IS NULL`);
7096
+ const rows = await exec(`SELECT id, permissions, admin FROM ${TABLE} WHERE scopes IS NULL`);
8453
7097
  for (const row of rows) {
8454
7098
  const { scopes, roles } = scopesFromStoredPermissions(parseStoredPermissions(row.permissions), row.admin === true);
8455
- await exec(`UPDATE ${TABLE$1} SET scopes = $1::jsonb, roles = $2::jsonb WHERE id = $3 AND scopes IS NULL`, { params: [
7099
+ await exec(`UPDATE ${TABLE} SET scopes = $1::jsonb, roles = $2::jsonb WHERE id = $3 AND scopes IS NULL`, { params: [
8456
7100
  JSON.stringify(scopes),
8457
7101
  JSON.stringify(roles),
8458
7102
  row.id
@@ -8484,8 +7128,8 @@ function createApiKeyStore(driver) {
8484
7128
  return;
8485
7129
  }
8486
7130
  await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
8487
- await ddl.ensureObject(`Creating ${TABLE$1}`, `
8488
- CREATE TABLE IF NOT EXISTS ${TABLE$1} (
7131
+ await ddl.ensureObject(`Creating ${TABLE}`, `
7132
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
8489
7133
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
8490
7134
  name TEXT NOT NULL,
8491
7135
  kind TEXT NOT NULL DEFAULT 'service',
@@ -8506,35 +7150,42 @@ function createApiKeyStore(driver) {
8506
7150
  try {
8507
7151
  await exec(DROP_DUPLICATE_KEY_HASH_INDEX_SQL);
8508
7152
  } catch (err) {
8509
- logger.warn(`[api-key-store] Left the duplicate idx_api_keys_hash in place for now: ${err instanceof Error ? err.message : String(err)}`);
7153
+ if (!(err instanceof SchemaChangeRefusedError)) logger.warn(`[api-key-store] Left the duplicate idx_api_keys_hash in place for now: ${err instanceof Error ? err.message : String(err)}`);
8510
7154
  }
8511
7155
  await ddl.ensureObject("Creating idx_api_keys_prefix", `
8512
7156
  CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
8513
- ON ${TABLE$1}(key_prefix)
7157
+ ON ${TABLE}(key_prefix)
8514
7158
  `);
8515
- await ddl.ensureObject(`Adding ${TABLE$1}.kind`, `
8516
- ALTER TABLE ${TABLE$1} ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'service'
7159
+ await ddl.ensureObject(`Adding ${TABLE}.kind`, `
7160
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'service'
8517
7161
  `);
8518
- await ddl.ensureObject(`Adding ${TABLE$1}.scopes`, `
8519
- ALTER TABLE ${TABLE$1} ADD COLUMN IF NOT EXISTS scopes JSONB
7162
+ await ddl.ensureObject(`Adding ${TABLE}.scopes`, `
7163
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS scopes JSONB
8520
7164
  `);
8521
- await ddl.ensureObject(`Adding ${TABLE$1}.roles`, `
8522
- ALTER TABLE ${TABLE$1} ADD COLUMN IF NOT EXISTS roles JSONB NOT NULL DEFAULT '[]'::jsonb
7165
+ await ddl.ensureObject(`Adding ${TABLE}.roles`, `
7166
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS roles JSONB NOT NULL DEFAULT '[]'::jsonb
8523
7167
  `);
8524
- await ddl.ensureObject(`Adding ${TABLE$1}.owner_uid`, `
8525
- ALTER TABLE ${TABLE$1} ADD COLUMN IF NOT EXISTS owner_uid TEXT
7168
+ await ddl.ensureObject(`Adding ${TABLE}.owner_uid`, `
7169
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS owner_uid TEXT
8526
7170
  `);
8527
7171
  await ddl.ensureObject("Creating idx_api_keys_owner", `
8528
7172
  CREATE INDEX IF NOT EXISTS idx_api_keys_owner
8529
- ON ${TABLE$1}(owner_uid) WHERE owner_uid IS NOT NULL
7173
+ ON ${TABLE}(owner_uid) WHERE owner_uid IS NOT NULL
8530
7174
  `);
8531
- if (!await ddl.isReadable(TABLE$1)) {
8532
- logger.error(`❌ [api-key-store] ${TABLE$1} 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.`);
7175
+ if (!await ddl.isReadable(TABLE)) {
7176
+ logger.error(options.provision === false ? `❌ [api-key-store] ${TABLE} does not exist, and this backend changes nothing in its database — every API-key authenticated request will be rejected on this instance until a provisioning run creates it.` : `❌ [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.`);
8533
7177
  return;
8534
7178
  }
8535
7179
  await ddl.step("Revoking end-user access to api_keys", () => exec(revokeInternalTableSql("rebase", "api_keys")));
8536
- if ((await exec(`SELECT 1 FROM information_schema.columns
8537
- WHERE table_schema = 'rebase' AND table_name = 'api_keys' AND column_name = 'permissions'`)).length > 0) await ddl.step("Backfilling API key scopes", backfillScopes);
7180
+ const legacyColumns = await exec(`SELECT 1 FROM information_schema.columns
7181
+ WHERE table_schema = 'rebase' AND table_name = 'api_keys' AND column_name = 'permissions'`);
7182
+ if (options.provision === false) try {
7183
+ await exec(`SELECT ${BOOT_COLUMNS.join(", ")} FROM ${TABLE} WHERE false`);
7184
+ } catch {
7185
+ logger.error(`❌ [api-key-store] ${TABLE} is out of date — it lacks one of ${BOOT_COLUMNS.join(", ")}, which this release reads. This backend changes nothing in its database; a provisioning run adds them.`);
7186
+ }
7187
+ if (legacyColumns.length > 0 && options.provision === false) logger.warn(`⚠️ [api-key-store] ${TABLE} still has the pre-scopes \`permissions\` column. Keys stored before scopes are given theirs by a provisioning run; this backend changes nothing in its database.`);
7188
+ else if (legacyColumns.length > 0) await ddl.step("Backfilling API key scopes", backfillScopes);
8538
7189
  logger.debug("✅ API keys table ready");
8539
7190
  },
8540
7191
  async createApiKey(key, createdBy) {
@@ -8542,7 +7193,7 @@ function createApiKeyStore(driver) {
8542
7193
  const hash = hashKey(plaintext);
8543
7194
  const prefix = keyPrefix(plaintext);
8544
7195
  return {
8545
- ...toMasked(rowToApiKey((await exec(`INSERT INTO ${TABLE$1} (name, kind, key_prefix, key_hash, scopes, roles, owner_uid, rate_limit, created_by, expires_at)
7196
+ ...toMasked(rowToApiKey((await exec(`INSERT INTO ${TABLE} (name, kind, key_prefix, key_hash, scopes, roles, owner_uid, rate_limit, created_by, expires_at)
8546
7197
  VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10)
8547
7198
  RETURNING *`, { params: [
8548
7199
  key.name,
@@ -8560,23 +7211,23 @@ function createApiKeyStore(driver) {
8560
7211
  };
8561
7212
  },
8562
7213
  async findByKeyHash(hash) {
8563
- const rows = await exec(`SELECT * FROM ${TABLE$1}
7214
+ const rows = await exec(`SELECT * FROM ${TABLE}
8564
7215
  WHERE key_hash = $1
8565
7216
  LIMIT 1`, { params: [hash] });
8566
7217
  if (rows.length === 0) return null;
8567
7218
  return rowToApiKey(rows[0]);
8568
7219
  },
8569
7220
  async listApiKeys(filter) {
8570
- return (filter.kind === "personal" ? await exec(`SELECT * FROM ${TABLE$1}
7221
+ return (filter.kind === "personal" ? await exec(`SELECT * FROM ${TABLE}
8571
7222
  WHERE kind = 'personal' AND owner_uid = $1
8572
7223
  ORDER BY created_at DESC`, { params: [filter.owner_uid] }) : await exec(`
8573
- SELECT * FROM ${TABLE$1}
7224
+ SELECT * FROM ${TABLE}
8574
7225
  WHERE kind = 'service'
8575
7226
  ORDER BY created_at DESC
8576
7227
  `)).map((r) => toMasked(rowToApiKey(r)));
8577
7228
  },
8578
7229
  async getApiKeyById(id) {
8579
- const rows = await exec(`SELECT * FROM ${TABLE$1}
7230
+ const rows = await exec(`SELECT * FROM ${TABLE}
8580
7231
  WHERE id = $1
8581
7232
  LIMIT 1`, { params: [id] });
8582
7233
  if (rows.length === 0) return null;
@@ -8609,7 +7260,7 @@ function createApiKeyStore(driver) {
8609
7260
  if (setClauses.length === 0) return this.getApiKeyById(id);
8610
7261
  setClauses.push("updated_at = NOW()");
8611
7262
  params.push(id);
8612
- const rows = await exec(`UPDATE ${TABLE$1}
7263
+ const rows = await exec(`UPDATE ${TABLE}
8613
7264
  SET ${setClauses.join(", ")}
8614
7265
  WHERE id = $${paramIdx} AND kind = 'service'
8615
7266
  RETURNING *`, { params });
@@ -8617,17 +7268,17 @@ function createApiKeyStore(driver) {
8617
7268
  return toMasked(rowToApiKey(rows[0]));
8618
7269
  },
8619
7270
  async revokeApiKey(id, owner_uid) {
8620
- return (owner_uid === void 0 ? await exec(`UPDATE ${TABLE$1}
7271
+ return (owner_uid === void 0 ? await exec(`UPDATE ${TABLE}
8621
7272
  SET revoked_at = NOW(), updated_at = NOW()
8622
7273
  WHERE id = $1 AND kind = 'service' AND revoked_at IS NULL
8623
- RETURNING id`, { params: [id] }) : await exec(`UPDATE ${TABLE$1}
7274
+ RETURNING id`, { params: [id] }) : await exec(`UPDATE ${TABLE}
8624
7275
  SET revoked_at = NOW(), updated_at = NOW()
8625
7276
  WHERE id = $1 AND kind = 'personal' AND owner_uid = $2 AND revoked_at IS NULL
8626
7277
  RETURNING id`, { params: [id, owner_uid] })).length > 0;
8627
7278
  },
8628
7279
  async updateLastUsed(id) {
8629
7280
  try {
8630
- await exec(`UPDATE ${TABLE$1}
7281
+ await exec(`UPDATE ${TABLE}
8631
7282
  SET last_used_at = NOW()
8632
7283
  WHERE id = $1`, { params: [id] });
8633
7284
  } catch (err) {
@@ -8658,10 +7309,20 @@ function readScopesField(value) {
8658
7309
  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");
8659
7310
  return [...new Set(value.map((entry) => entry.trim()))];
8660
7311
  }
8661
- /** Read a `roles` body field: an array of non-empty strings, deduplicated, without `service`. */
7312
+ /**
7313
+ * Read a `roles` body field: an array of role names, trimmed, deduplicated,
7314
+ * without `service`. A key's roles reach RLS joined by `,` in `app.user_roles`,
7315
+ * so a name `roleNameProblem` refuses — `"editor,admin"` — is refused here too.
7316
+ */
8662
7317
  function readRolesField(value) {
8663
7318
  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");
8664
- return [...new Set(value.map((entry) => entry.trim()))].filter((role) => role !== "service");
7319
+ const trimmed = value.map((entry) => String(entry).trim());
7320
+ const problems = trimmed.flatMap((name) => {
7321
+ const problem = roleNameProblem(name);
7322
+ return problem ? [`${JSON.stringify(name)} ${problem}`] : [];
7323
+ });
7324
+ if (problems.length > 0) throw new ApiError(400, "INVALID_ROLE", `Not valid role names: ${problems.join("; ")}. A role name is a non-empty string without , { } " \\ or control characters.`, { problems });
7325
+ return [...new Set(trimmed)].filter((role) => role !== "service");
8665
7326
  }
8666
7327
  /**
8667
7328
  * Refuse scopes that are malformed, unknown, aimed at a target that does not
@@ -8707,6 +7368,24 @@ function assertRolesGrantable(requested, minterRoles) {
8707
7368
  const beyond = requested.filter((role) => !minterRoles.includes(role));
8708
7369
  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 });
8709
7370
  }
7371
+ /**
7372
+ * Refuse changing or revoking a key that holds more than the caller — a role
7373
+ * they do not hold, or a scope beyond theirs. An admin manages any key.
7374
+ *
7375
+ * The same ceiling as minting, applied to the key as it already is: a
7376
+ * `keys:write` role could otherwise widen an admin-role key it could never
7377
+ * have minted to every data scope, or revoke the key an admin's integration
7378
+ * runs on. `assertMayManageAccount` is the same rule for accounts.
7379
+ */
7380
+ function assertMayManageKey(existing, managerRoles, managerScopes) {
7381
+ if (hasAdminRole(managerRoles)) return;
7382
+ const roles = existing.roles.filter((role) => !managerRoles.includes(role));
7383
+ const scopes = scopesBeyond(existing.scopes, managerScopes);
7384
+ if (roles.length > 0 || scopes.length > 0) throw new ApiError(403, "KEY_OUTRANKS_CALLER", `This key holds ${[...roles.map((role) => `the role ${role}`), ...scopes].join(", ")}, which you do not, so you cannot change or revoke it.`, {
7385
+ roles,
7386
+ scopes
7387
+ });
7388
+ }
8710
7389
  /** Read an optional `expires_at`: absent, null, or a future ISO-8601 instant. */
8711
7390
  function readExpiresAt(value, requireFuture) {
8712
7391
  if (value === void 0) return void 0;
@@ -8803,6 +7482,11 @@ function createApiKeyRoutes(options) {
8803
7482
  read: "keys:read",
8804
7483
  write: "keys:write"
8805
7484
  }));
7485
+ const existingServiceKey = async (id) => {
7486
+ const key = await store.getApiKeyById(id);
7487
+ if (!key || key.kind !== "service") throw ApiError.notFound("API key not found");
7488
+ return key;
7489
+ };
8806
7490
  router.get("/", async (c) => {
8807
7491
  const keys = await store.listApiKeys({ kind: "service" });
8808
7492
  return c.json({ keys });
@@ -8851,11 +7535,13 @@ function createApiKeyRoutes(options) {
8851
7535
  if (rateLimit !== void 0) updates.rate_limit = rateLimit;
8852
7536
  const expiresAt = readExpiresAt(body.expires_at, false);
8853
7537
  if (expiresAt !== void 0) updates.expires_at = expiresAt;
7538
+ assertMayManageKey(await existingServiceKey(id), minter.roles, callerScopes(c));
8854
7539
  const key = await store.updateApiKey(id, updates);
8855
7540
  if (!key) throw ApiError.notFound("API key not found");
8856
7541
  return c.json({ key });
8857
7542
  });
8858
7543
  router.delete("/:id", async (c) => {
7544
+ assertMayManageKey(await existingServiceKey(c.req.param("id")), caller(c).roles, callerScopes(c));
8859
7545
  if (!await store.revokeApiKey(c.req.param("id"))) throw ApiError.notFound("API key not found or already revoked");
8860
7546
  return c.json({ success: true });
8861
7547
  });
@@ -8925,194 +7611,6 @@ function createPersonalKeyRoutes(options) {
8925
7611
  return router;
8926
7612
  }
8927
7613
  //#endregion
8928
- //#region src/auth/sql-rate-limit-store.ts
8929
- var SCHEMA = "rebase";
8930
- var TABLE_NAME = "rate_limit_hits";
8931
- var TABLE = `${SCHEMA}.${TABLE_NAME}`;
8932
- /** How often expired buckets are deleted. */
8933
- var DEFAULT_SWEEP_MS = 300 * 1e3;
8934
- /**
8935
- * Build the store, or `undefined` if this driver cannot back it.
8936
- *
8937
- * Returning `undefined` rather than throwing matches `createCronStore`: a
8938
- * deployment on a driver with no SQL admin should degrade to per-process counts
8939
- * with a warning, not refuse to serve.
8940
- */
8941
- function createSqlRateLimitStore(driver, options = {}) {
8942
- const admin = driver.admin;
8943
- if (!isSQLAdmin(admin)) {
8944
- logger.warn("⚠️ [rate-limit] The configured driver has no SQL admin, so rate-limit counts cannot be shared between processes. Falling back to this process's memory — each replica will enforce the limit independently.");
8945
- return;
8946
- }
8947
- const exec = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
8948
- const ddl = createDdlBootstrapper(exec, "rate-limit-store");
8949
- const now = options.now ?? Date.now;
8950
- const sweepMs = options.sweepMs ?? DEFAULT_SWEEP_MS;
8951
- let sweepTimer;
8952
- let ready;
8953
- /**
8954
- * Create the table once, and remember whether it worked.
8955
- *
8956
- * Lazily rather than at boot because the store is constructed while routes
8957
- * are being mounted, and an `await` there would put a database round trip on
8958
- * the boot path of every deployment — including the ones that never take a
8959
- * request. The first request pays for it.
8960
- *
8961
- * The result is cached even on failure: a store that cannot create its table
8962
- * must not retry the DDL on every request, which would turn one broken
8963
- * permission into a per-request query storm.
8964
- *
8965
- * Readiness is decided by reading the table, never by the DDL not throwing —
8966
- * `ensureObject` routes through `step`, which logs a failure and returns
8967
- * normally. Keying on the absence of an exception would report a table that
8968
- * was never created as ready, and every later request would fall into the
8969
- * per-request error path instead of the one-time fallback.
8970
- */
8971
- async function ensureReady() {
8972
- if (!ready) ready = (async () => {
8973
- try {
8974
- await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
8975
- await ddl.ensureObject(`Creating ${TABLE}`, `
8976
- CREATE TABLE IF NOT EXISTS ${TABLE} (
8977
- key TEXT NOT NULL,
8978
- bucket BIGINT NOT NULL,
8979
- hits INTEGER NOT NULL DEFAULT 0,
8980
- expires_at TIMESTAMPTZ NOT NULL,
8981
- PRIMARY KEY (key, bucket)
8982
- )
8983
- `);
8984
- await ddl.ensureObject("Creating idx_rate_limit_hits_expiry", `
8985
- CREATE INDEX IF NOT EXISTS idx_rate_limit_hits_expiry
8986
- ON ${TABLE}(expires_at)
8987
- `);
8988
- const readable = await ddl.isReadable(TABLE);
8989
- if (readable) await ddl.step("Revoking end-user access to rate_limit_hits", () => exec(revokeInternalTableSql(SCHEMA, TABLE_NAME)));
8990
- else logger.error("[rate-limit] The shared rate-limit table could not be created, so counts stay per-process. Every replica will enforce the limit independently.");
8991
- return readable;
8992
- } catch (err) {
8993
- logger.error("[rate-limit] Could not prepare the shared rate-limit table. Falling back to per-process counts for the life of this process.", { error: err instanceof Error ? err.message : String(err) });
8994
- return false;
8995
- }
8996
- })();
8997
- return ready;
8998
- }
8999
- function startSweeping() {
9000
- if (sweepTimer) return;
9001
- sweepTimer = setInterval(() => {
9002
- exec(`DELETE FROM ${TABLE} WHERE expires_at < now()`).catch((err) => {
9003
- logger.debug("[rate-limit] Bucket sweep failed", { error: err instanceof Error ? err.message : String(err) });
9004
- });
9005
- }, sweepMs);
9006
- sweepTimer.unref?.();
9007
- }
9008
- return {
9009
- async hit(key, windowMs, limit) {
9010
- if (!await ensureReady()) return {
9011
- allowed: true,
9012
- remaining: limit,
9013
- retryAfterMs: 0
9014
- };
9015
- startSweeping();
9016
- const at = now();
9017
- const bucket = Math.floor(at / windowMs);
9018
- const carry = 1 - (at - bucket * windowMs) / windowMs;
9019
- const expiresAt = new Date((bucket + 2) * windowMs).toISOString();
9020
- let current;
9021
- let previous;
9022
- try {
9023
- const row = firstRow(await exec(`WITH bumped AS (
9024
- INSERT INTO ${TABLE} AS r (key, bucket, hits, expires_at)
9025
- VALUES ($1, $2, 1, $3)
9026
- ON CONFLICT (key, bucket)
9027
- DO UPDATE SET hits = r.hits + 1, expires_at = EXCLUDED.expires_at
9028
- RETURNING hits
9029
- )
9030
- SELECT
9031
- (SELECT hits FROM bumped) AS current,
9032
- COALESCE((SELECT hits FROM ${TABLE} WHERE key = $1 AND bucket = $4), 0) AS previous`, { params: [
9033
- key,
9034
- bucket,
9035
- expiresAt,
9036
- bucket - 1
9037
- ] }));
9038
- current = Number(row?.current ?? 1);
9039
- previous = Number(row?.previous ?? 0);
9040
- } catch (err) {
9041
- logger.debug("[rate-limit] Shared count unavailable for this request", { error: err instanceof Error ? err.message : String(err) });
9042
- return {
9043
- allowed: true,
9044
- remaining: limit,
9045
- retryAfterMs: 0
9046
- };
9047
- }
9048
- const estimate = current + previous * carry;
9049
- if (estimate > limit) {
9050
- exec(`UPDATE ${TABLE} SET hits = GREATEST(hits - 1, 0) WHERE key = $1 AND bucket = $2`, { params: [key, bucket] }).catch(() => {});
9051
- return {
9052
- allowed: false,
9053
- remaining: 0,
9054
- retryAfterMs: Math.max(0, (bucket + 1) * windowMs - at)
9055
- };
9056
- }
9057
- return {
9058
- allowed: true,
9059
- remaining: Math.max(0, Math.floor(limit - estimate)),
9060
- retryAfterMs: 0
9061
- };
9062
- },
9063
- dispose() {
9064
- if (sweepTimer) clearInterval(sweepTimer);
9065
- sweepTimer = void 0;
9066
- }
9067
- };
9068
- }
9069
- /**
9070
- * The first row of whatever shape this driver returns.
9071
- *
9072
- * `SqlExec` declares an array of rows, but drivers in this repository have also
9073
- * returned a `{ rows }` envelope through the same escape hatch. Reading one
9074
- * shape only is how a store silently counts nothing and reports every caller as
9075
- * being on their first request — a limiter that never limits, with no error
9076
- * anywhere. Tolerating both costs one line.
9077
- */
9078
- var firstRow = firstSqlRow;
9079
- //#endregion
9080
- //#region src/auth/resolve-rate-limit-store.ts
9081
- /**
9082
- * A refusal to boot, phrased as the variable to change.
9083
- *
9084
- * Its own class so `bootFromBundle` can present it as a configuration problem
9085
- * rather than a crash, matching `RoleConfigurationError`.
9086
- */
9087
- var RateLimitStoreConfigurationError = class extends Error {
9088
- hint;
9089
- constructor(message, hint) {
9090
- super(message);
9091
- this.hint = hint;
9092
- this.name = "RateLimitStoreConfigurationError";
9093
- }
9094
- };
9095
- /**
9096
- * Which store this process should build.
9097
- *
9098
- * A misspelled value **refuses to boot** rather than falling back to memory.
9099
- * Every other outcome here is silent — the whole failure mode of this setting is
9100
- * that nothing looks wrong — so the one moment it can be caught is the moment it
9101
- * is read. `REBASE_RATE_LIMIT_STORE=postgres` is the obvious thing to type and
9102
- * would otherwise be a per-replica limit wearing the shape of a shared one.
9103
- *
9104
- * A blank or whitespace-only value is *unset*, not invalid:
9105
- * `REBASE_RATE_LIMIT_STORE=${SOMETHING}` with `SOMETHING` undefined is the
9106
- * ordinary way to write a compose file, and it is how the platform neutralises a
9107
- * tenant's own variable.
9108
- */
9109
- function resolveRateLimitStoreKind(env) {
9110
- const raw = (env.REBASE_RATE_LIMIT_STORE ?? "").trim().toLowerCase();
9111
- if (raw === "") return "memory";
9112
- if (raw === "memory" || raw === "sql") return raw;
9113
- 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.");
9114
- }
9115
- //#endregion
9116
7614
  //#region src/auth/oauth-code-flow.ts
9117
7615
  /**
9118
7616
  * Build the request schema for an authorization-code provider.
@@ -10260,6 +8758,6 @@ var auth_exports = /* @__PURE__ */ __exportAll({
10260
8758
  verifyPassword: () => verifyPassword
10261
8759
  });
10262
8760
  //#endregion
10263
- export { optionalAuth as $, raw as $t, createAuthLimiters as A, setDefaultAccessControl as At, registerDevEmailSink as B, resolveEmailLinkBase as Bt, createPersonalKeyRoutes as C, configureAccess as Ct, configureUserPicking as D, processAccessControl as Dt, createBuiltinAuthAdapter as E, keyOwnerResolverFor as Et, MemoryRateLimitStore as F, supportsRlsScoping as Ft, validatePasswordStrength as G, getMagicLinkTemplate as Gt, createEmailService as H, getEmailChangeTemplate as Ht, activeDevEmailSink as I, isAccessTokenRevoked as It, createAdapterAuthMiddleware as J, getWelcomeEmailTemplate as Jt, verifyPassword as K, getPasswordResetTemplate as Kt, clearActiveDevEmailSink as L, completeUserCreation as Lt, createDataRateLimiter as M, safeCompare as Mt, createRateLimiter as N, SERVICE_IDENTITY as Nt, isBootstrapWindowOpen as O, requireScope as Ot, setSharedRateLimitStore as P, scopeDataDriver as Pt, fileTokenAuth as Q, html as Qt, createDevEmailSink as R, generateSecurePassword as Rt, createApiKeyRoutes as S, callerScopes as St, createJwksRoutes as T, hasScope as Tt, resolveAuthHooks as U, getEmailOtpTemplate as Ut, SMTPEmailService as V, getEmailChangeNoticeTemplate as Vt, hashPassword as W, getEmailVerificationTemplate as Wt, createRequireAuth as X, RawHtml as Xt, createAuthMiddleware as Y, resolveEmailBranding as Yt, extractUserFromToken as Z, escapeHtml as Zt, oauthCodeFlowSchema as _, isApiKeyToken as _t, createBitbucketProvider as a, storageObjectPath as an, userFromToken as at, resolveRateLimitStoreKind as b, accessModelFromCollections as bt, createTwitterProvider as c, isPublicObjectGrant as ct, createMicrosoftProvider as d, storageRequestWildcard as dt, httpMethodToOperation as en, publicObjectAuth as et, tryVerifyOidcIdToken as f, decideImpersonation as ft, createGoogleProvider as g, createTusScopeGuard as gt, createLinkedinProvider as h, createFunctionScopeGuard as ht, createSlackProvider as i, isPublicStoragePath as in, requireAuth as it, createDataRateLimitCheck as j, extractBearerToken as jt, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as k, requireScopeByMethod as kt, createFacebookProvider as l, requestedStorageObject as lt, createGitHubProvider as m, createApiKeyPreAuth as mt, createCustomAuthAdapter as n, PUBLIC_STORAGE_PREFIX as nn, refuse as nt, createGitLabProvider as o, PUBLIC_OBJECT_PRINCIPAL as ot, verifyOidcIdToken as p, refuseUnhonouredImpersonation as pt, adapterRefusal as q, getUserInvitationTemplate as qt, createSpotifyProvider as r, isPublicStorageKey as rn, requireAdmin as rt, createDiscordProvider as s, isDownloadTokenGrant as st, auth_exports as t, securityRuleToConditions as tn, queryTokenAuth as tt, createAppleProvider as u, storageObjectOfPath as ut, pkceTokenParams as v, resolveApiKey as vt, createApiKeyStore as w, getAccessModel as wt, createSqlRateLimitStore as x, accessOf as xt, providerVerifiedEmail as y, validateApiKey as yt, extractLinks as z, assertEmailLinkBases as zt };
8761
+ export { getEmailChangeTemplate as $, createDataRateLimiter as A, SMTPEmailService as B, createJwksRoutes as C, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as D, isBootstrapWindowOpen as E, clearActiveDevEmailSink as F, verifyPassword as G, resolveAuthHooks as H, createDevEmailSink as I, completeUserCreation as J, adapterRefusal as K, devEmailSinkFor as L, setSharedRateLimitStore as M, MemoryRateLimitStore as N, createAuthLimiters as O, activeDevEmailSink as P, getEmailChangeNoticeTemplate as Q, extractLinks as R, createApiKeyStore as S, configureUserPicking as T, hashPassword as U, createEmailService as V, validatePasswordStrength as W, assertEmailLinkBases as X, generateSecurePassword as Y, resolveEmailLinkBase as Z, oauthCodeFlowSchema as _, createBitbucketProvider as a, getWelcomeEmailTemplate as at, createApiKeyRoutes as b, createTwitterProvider as c, escapeHtml as ct, createMicrosoftProvider as d, createSqlRateLimitStore as dt, getEmailOtpTemplate as et, tryVerifyOidcIdToken as f, resolveRateLimitStoreKind as ft, createGoogleProvider as g, createLinkedinProvider as h, createSlackProvider as i, getUserInvitationTemplate as it, createRateLimiter as j, createDataRateLimitCheck as k, createFacebookProvider as l, html as lt, createGitHubProvider as m, createCustomAuthAdapter as n, getMagicLinkTemplate as nt, createGitLabProvider as o, resolveEmailBranding as ot, verifyOidcIdToken as p, securityRuleToConditions as pt, createAdapterAuthMiddleware as q, createSpotifyProvider as r, getPasswordResetTemplate as rt, createDiscordProvider as s, RawHtml as st, auth_exports as t, getEmailVerificationTemplate as tt, createAppleProvider as u, raw as ut, pkceTokenParams as v, createBuiltinAuthAdapter as w, createPersonalKeyRoutes as x, providerVerifiedEmail as y, registerDevEmailSink as z };
10264
8762
 
10265
- //# sourceMappingURL=auth-CteGWxsL.js.map
8763
+ //# sourceMappingURL=auth-izI7AEzC.js.map