@rebasepro/server 0.25.1 → 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 (157) hide show
  1. package/dist/{GCSStorageController-CRd9bFrc.js → GCSStorageController-40FyFeSx.js} +6 -2
  2. package/dist/GCSStorageController-40FyFeSx.js.map +1 -0
  3. package/dist/{S3StorageController-D00EFa5P.js → S3StorageController-Bl5shp94.js} +6 -2
  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/live-schema-routes.d.ts +10 -0
  9. package/dist/api/rest/api-generator.d.ts +9 -1
  10. package/dist/api/rest/idempotency.d.ts +15 -1
  11. package/dist/api/root-error-handler.d.ts +6 -5
  12. package/dist/api/types.d.ts +12 -0
  13. package/dist/{ast-schema-editor-mCeSC03x.js → ast-schema-editor-madTa9Hh.js} +2 -2
  14. package/dist/{ast-schema-editor-mCeSC03x.js.map → ast-schema-editor-madTa9Hh.js.map} +1 -1
  15. package/dist/auth/access.d.ts +10 -0
  16. package/dist/auth/adapter-middleware.d.ts +2 -1
  17. package/dist/auth/adapter-service-key.d.ts +24 -0
  18. package/dist/auth/api-keys/api-key-middleware.d.ts +21 -1
  19. package/dist/auth/api-keys/api-key-store.d.ts +8 -1
  20. package/dist/auth/api-keys/key-grant.d.ts +18 -1
  21. package/dist/auth/expired-token-sweep.d.ts +5 -0
  22. package/dist/auth/impersonation.d.ts +3 -1
  23. package/dist/auth/jwt.d.ts +8 -2
  24. package/dist/auth/oauth-signin-policy.d.ts +21 -35
  25. package/dist/auth/rls-scope.d.ts +17 -7
  26. package/dist/auth/rls-simulator-routes.d.ts +51 -0
  27. package/dist/auth/simulation-read-only.d.ts +28 -0
  28. package/dist/auth/simulation.d.ts +141 -0
  29. package/dist/auth/sql-rate-limit-store.d.ts +15 -1
  30. package/dist/{auth-Dkvo2tTJ.js → auth-izI7AEzC.js} +370 -1880
  31. package/dist/auth-izI7AEzC.js.map +1 -0
  32. package/dist/auth_adapter-B7RUMk6e.js +89 -0
  33. package/dist/auth_adapter-B7RUMk6e.js.map +1 -0
  34. package/dist/backend/create-backend.d.ts +6 -0
  35. package/dist/backend/options.d.ts +9 -4
  36. package/dist/{backend-DTAOsLQc.js → backend-CaG_Jyso.js} +9 -2
  37. package/dist/backend-CaG_Jyso.js.map +1 -0
  38. package/dist/{backup-B9m3cb3X.js → backup-BVbHoKo8.js} +6 -6
  39. package/dist/{backup-B9m3cb3X.js.map → backup-BVbHoKo8.js.map} +1 -1
  40. package/dist/boot/boot.d.ts +2 -15
  41. package/dist/boot/ddl-bootstrap.d.ts +34 -0
  42. package/dist/boot/env.d.ts +6 -0
  43. package/dist/boot/fetch-bundle.d.ts +5 -1
  44. package/dist/boot/provision-only.d.ts +23 -0
  45. package/dist/boot/provision.d.ts +53 -2
  46. package/dist/boot/role.d.ts +2 -0
  47. package/dist/boot/schema-change-recorder.d.ts +45 -0
  48. package/dist/boot/security-headers.d.ts +22 -0
  49. package/dist/branch-routes-B2vvEx53.js +84 -0
  50. package/dist/branch-routes-B2vvEx53.js.map +1 -0
  51. package/dist/{contract-routes-CsFXW7j4.js → contract-routes-CU2A3JnF.js} +2 -2
  52. package/dist/{contract-routes-CsFXW7j4.js.map → contract-routes-CU2A3JnF.js.map} +1 -1
  53. package/dist/cron/cron-store.d.ts +8 -1
  54. package/dist/{cron-loader-Dj6lA4HQ.js → cron-loader-mMhqbVsl.js} +2 -2
  55. package/dist/{cron-loader-Dj6lA4HQ.js.map → cron-loader-mMhqbVsl.js.map} +1 -1
  56. package/dist/{cron-routes-CwbLZguO.js → cron-routes-Cs33j_Kr.js} +3 -3
  57. package/dist/{cron-routes-CwbLZguO.js.map → cron-routes-Cs33j_Kr.js.map} +1 -1
  58. package/dist/{cron-scheduler-B04ACoUE.js → cron-scheduler-Be7CNghL.js} +2 -2
  59. package/dist/{cron-scheduler-B04ACoUE.js.map → cron-scheduler-Be7CNghL.js.map} +1 -1
  60. package/dist/{cron-store-DkyfuLOH.js → cron-store-DLn11M8H.js} +10 -9
  61. package/dist/cron-store-DLn11M8H.js.map +1 -0
  62. package/dist/{strings.es-BeZWAodn.js → data_driver-D0mg1Y_x.js} +2 -90
  63. package/dist/data_driver-D0mg1Y_x.js.map +1 -0
  64. package/dist/{ddl-bootstrap-Ck20GSIX.js → ddl-bootstrap-CjRrn12l.js} +91 -2
  65. package/dist/ddl-bootstrap-CjRrn12l.js.map +1 -0
  66. package/dist/deploy/pod-contract.d.ts +1 -1
  67. package/dist/email/dev-sink.d.ts +6 -0
  68. package/dist/email/index.d.ts +1 -1
  69. package/dist/{soft-delete-params-BmlVwecC.js → field-access-DYsvjI_P.js} +27 -57
  70. package/dist/field-access-DYsvjI_P.js.map +1 -0
  71. package/dist/{filter-dialect-O1HK6Nvr.js → filter-dialect-TrKFWLqT.js} +3 -3
  72. package/dist/{filter-dialect-O1HK6Nvr.js.map → filter-dialect-TrKFWLqT.js.map} +1 -1
  73. package/dist/{function-loader-KKDqTcIr.js → function-loader-B8YzXEsQ.js} +21 -14
  74. package/dist/function-loader-B8YzXEsQ.js.map +1 -0
  75. package/dist/functions/hono-like.d.ts +12 -0
  76. package/dist/functions/index.js +19 -1
  77. package/dist/functions/index.js.map +1 -1
  78. package/dist/functions/request-timeout.d.ts +3 -3
  79. package/dist/history/history-routes.d.ts +6 -0
  80. package/dist/history/retention-sweep.d.ts +5 -0
  81. package/dist/{history-recorder-B2rkuhuy.js → history-recorder-Bsau7h4X.js} +6 -6
  82. package/dist/history-recorder-Bsau7h4X.js.map +1 -0
  83. package/dist/{history-store-DPofGVDp.js → history-store-BQz5YCkW.js} +26 -2
  84. package/dist/history-store-BQz5YCkW.js.map +1 -0
  85. package/dist/index.d.ts +3 -1
  86. package/dist/index.es.js +2449 -543
  87. package/dist/index.es.js.map +1 -1
  88. package/dist/init/process-safety.d.ts +4 -3
  89. package/dist/init/shutdown.d.ts +8 -0
  90. package/dist/init.d.ts +6 -1
  91. package/dist/jobs/job-store.d.ts +8 -1
  92. package/dist/{jobs-h1WRq3cp.js → jobs-Co6Ni5HP.js} +6 -6
  93. package/dist/jobs-Co6Ni5HP.js.map +1 -0
  94. package/dist/{jwt-D3oFauZI.js → jwt-rDS0884_.js} +4 -3
  95. package/dist/{jwt-D3oFauZI.js.map → jwt-rDS0884_.js.map} +1 -1
  96. package/dist/{keys-COzQ2aE8.js → keys-BXcdRNhJ.js} +2 -2
  97. package/dist/{keys-COzQ2aE8.js.map → keys-BXcdRNhJ.js.map} +1 -1
  98. package/dist/mcp/consent-page.d.ts +38 -1
  99. package/dist/mcp/mcp-routes.d.ts +9 -0
  100. package/dist/mcp/mcp-tools.d.ts +7 -1
  101. package/dist/mcp/oauth-store.d.ts +12 -5
  102. package/dist/metrics/history-recorder.d.ts +4 -1
  103. package/dist/metrics/history-store.d.ts +8 -0
  104. package/dist/{offline-BrqCRgih-BtDB1ecy.js → offline-AvuEcg7u-DFHL98OT.js} +127 -40
  105. package/dist/offline-AvuEcg7u-DFHL98OT.js.map +1 -0
  106. package/dist/{offline-store-BhYXViha-BW5j1We_.js → offline-store-BhYXViha-YTmjBpo2.js} +5 -5
  107. package/dist/{offline-store-BhYXViha-BW5j1We_.js.map → offline-store-BhYXViha-YTmjBpo2.js.map} +1 -1
  108. package/dist/{openapi-generator-BjHr6upl.js → openapi-generator-CyxEtYEX.js} +61 -22
  109. package/dist/openapi-generator-CyxEtYEX.js.map +1 -0
  110. package/dist/{proxy-CH8Jsovg.js → proxy-BYir8tWK.js} +4 -6
  111. package/dist/proxy-BYir8tWK.js.map +1 -0
  112. package/dist/{query-parser-otFZqJsC.js → query-parser-CDXWf2hH.js} +6 -5
  113. package/dist/{query-parser-otFZqJsC.js.map → query-parser-CDXWf2hH.js.map} +1 -1
  114. package/dist/{request-timeout-59crS-9u.js → request-timeout-DVjo-c_8.js} +4 -4
  115. package/dist/request-timeout-DVjo-c_8.js.map +1 -0
  116. package/dist/rls-simulator-routes-bEY7-5-u.js +190 -0
  117. package/dist/rls-simulator-routes-bEY7-5-u.js.map +1 -0
  118. package/dist/schema-edit/deployed-schema-guard.d.ts +47 -0
  119. package/dist/{schema-editor-routes-Bsut0bCC.js → schema-editor-routes-Dc3DU_jm.js} +3 -3
  120. package/dist/{schema-editor-routes-Bsut0bCC.js.map → schema-editor-routes-Dc3DU_jm.js.map} +1 -1
  121. package/dist/soft-delete-params-BRONCOA2.js +59 -0
  122. package/dist/soft-delete-params-BRONCOA2.js.map +1 -0
  123. package/dist/{sql-export-routes-DmB_uZ4l.js → sql-export-routes-QycX8W8S.js} +38 -4
  124. package/dist/sql-export-routes-QycX8W8S.js.map +1 -0
  125. package/dist/{src-D5Y3N-S4.js → src-Bh9LRPb9.js} +21 -2
  126. package/dist/{src-D5Y3N-S4.js.map → src-Bh9LRPb9.js.map} +1 -1
  127. package/dist/storage/GCSStorageController.d.ts +2 -0
  128. package/dist/storage/LocalStorageController.d.ts +8 -3
  129. package/dist/storage/S3StorageController.d.ts +2 -0
  130. package/dist/storage/maintenance-keys.d.ts +17 -0
  131. package/dist/storage/maintenance-routes.d.ts +28 -5
  132. package/dist/storage/maintenance.d.ts +136 -4
  133. package/dist/storage/orphan-scans.d.ts +120 -0
  134. package/dist/storage/types.d.ts +11 -0
  135. package/dist/strings.es-B7QLnHh2.js +96 -0
  136. package/dist/strings.es-B7QLnHh2.js.map +1 -0
  137. package/dist/upload-validation-Bgo626uY.js.map +1 -1
  138. package/package.json +5 -5
  139. package/dist/GCSStorageController-CRd9bFrc.js.map +0 -1
  140. package/dist/S3StorageController-D00EFa5P.js.map +0 -1
  141. package/dist/auth-Dkvo2tTJ.js.map +0 -1
  142. package/dist/auth_adapter-CIItCTc8.js +0 -21
  143. package/dist/auth_adapter-CIItCTc8.js.map +0 -1
  144. package/dist/backend-DTAOsLQc.js.map +0 -1
  145. package/dist/cron-store-DkyfuLOH.js.map +0 -1
  146. package/dist/ddl-bootstrap-Ck20GSIX.js.map +0 -1
  147. package/dist/function-loader-KKDqTcIr.js.map +0 -1
  148. package/dist/history-recorder-B2rkuhuy.js.map +0 -1
  149. package/dist/history-store-DPofGVDp.js.map +0 -1
  150. package/dist/jobs-h1WRq3cp.js.map +0 -1
  151. package/dist/offline-BrqCRgih-BtDB1ecy.js.map +0 -1
  152. package/dist/openapi-generator-BjHr6upl.js.map +0 -1
  153. package/dist/proxy-CH8Jsovg.js.map +0 -1
  154. package/dist/request-timeout-59crS-9u.js.map +0 -1
  155. package/dist/soft-delete-params-BmlVwecC.js.map +0 -1
  156. package/dist/sql-export-routes-DmB_uZ4l.js.map +0 -1
  157. package/dist/strings.es-BeZWAodn.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";
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";
11
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-otFZqJsC.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";
15
17
  import { a as isDataException, c as warnIfDatabaseClosed, i as errorHandler, o as isDatabaseUnreachable, t as ApiError } from "./errors-OMWkKSH-.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-D3oFauZI.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-Ck20GSIX.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.
@@ -637,26 +533,188 @@ function checkOperation(collection, authContext, entity, targetOperation, option
637
533
  return hasPermissive ? grantedByPermissive : false;
638
534
  }
639
535
  //#endregion
640
- //#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
+ };
641
551
  /**
642
- * Map an HTTP method to the operation it performs.
552
+ * Which store this process should build.
643
553
  *
644
- * - `GET`, `HEAD`, `OPTIONS` → `"read"`
645
- * - `POST`, `PUT`, `PATCH` → `"write"`
646
- * - `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.
647
559
  *
648
- * Any other method is a `"write"`: a verb this does not know is not assumed
649
- * 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.
650
564
  */
651
- function httpMethodToOperation(method) {
652
- switch (method.toUpperCase()) {
653
- case "GET":
654
- case "HEAD":
655
- case "OPTIONS": return "read";
656
- case "DELETE": return "delete";
657
- 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;
658
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?.();
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
+ };
659
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;
660
718
  //#endregion
661
719
  //#region src/email/html.ts
662
720
  /**
@@ -1668,6 +1726,7 @@ function enforceCreationPasswordPolicy(body, resolvedHooks, options) {
1668
1726
  */
1669
1727
  async function prepareAdminUserValues(rawBody, ctx, options) {
1670
1728
  const { resolvedHooks, collectionAuthConfig } = ctx;
1729
+ if (rawBody.roles !== void 0 && rawBody.roles !== null) readRoleNames(rawBody.roles);
1671
1730
  const body = enforceCreationPasswordPolicy(rawBody, resolvedHooks, options);
1672
1731
  if (collectionAuthConfig?.onCreateUser) {
1673
1732
  const collectionCtx = buildCollectionContext(ctx);
@@ -1830,1509 +1889,46 @@ async function normalizeUserEmailChange(authRepo, uid, email) {
1830
1889
  */
1831
1890
  function runAfterUserDelete(resolvedHooks, uid) {
1832
1891
  if (!resolvedHooks.afterUserDelete) return;
1833
- resolvedHooks.afterUserDelete(uid).catch((err) => {
1834
- logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
1835
- });
1836
- }
1837
- /**
1838
- * The auth adapter's side of user administration through the auth collection
1839
- * (`AuthAdapter.prepareUserUpdates` and the deletion pair): the admin user
1840
- * routes' rules and hooks, for the data API's writes to the same rows.
1841
- */
1842
- function authCollectionUserAdmin(ctx) {
1843
- const { authRepo, resolvedHooks } = ctx;
1844
- return {
1845
- async prepareUserUpdates(updates) {
1846
- await assertRoleChangesAllowed(authRepo, updates.flatMap(({ uid, values }) => Array.isArray(values.roles) ? [{
1847
- uid,
1848
- roles: values.roles
1849
- }] : []));
1850
- const prepared = [];
1851
- for (const { uid, values } of updates) prepared.push(typeof values.email === "string" ? {
1852
- ...values,
1853
- email: await normalizeUserEmailChange(authRepo, uid, values.email)
1854
- } : values);
1855
- return prepared;
1856
- },
1857
- async prepareUserDeletions(uids) {
1858
- await assertUserDeletionsAllowed(authRepo, uids);
1859
- if (resolvedHooks.beforeUserDelete) for (const uid of uids) await resolvedHooks.beforeUserDelete(uid);
1860
- },
1861
- async finalizeUserDeletions(uids) {
1862
- for (const uid of uids) {
1863
- await authRepo.deleteAllRefreshTokensForUser(uid);
1864
- runAfterUserDelete(resolvedHooks, uid);
1865
- }
1866
- }
1867
- };
1868
- }
1869
- //#endregion
1870
- //#region src/auth/token-revocation.ts
1871
- /**
1872
- * Has this access token been revoked?
1873
- *
1874
- * Everything that ends every session a user holds — a password change or reset
1875
- * of any kind, and `DELETE /auth/sessions` — goes through
1876
- * {@link revokeAllSessions}, which stamps a `tokensValidAfter` watermark on the
1877
- * user. It also deletes refresh-token rows, which is what made the gap easy to
1878
- * miss: the session really is gone, and the *refresh* path really does check
1879
- * the watermark — so signing out looked like it worked.
1880
- *
1881
- * The access token was untouched. It is a bearer credential that nothing
1882
- * consulted a database about, so it stayed valid for its full lifetime after
1883
- * every one of those actions. "Sign out everywhere" left the stolen token
1884
- * working; so did changing your password because you thought it had leaked.
1885
- *
1886
- * The watermark had exactly one read in the repository, on refresh. This adds
1887
- * the other one.
1888
- *
1889
- * ## Cost
1890
- *
1891
- * One indexed lookup per request, on the paths that already make one. The
1892
- * adapter re-reads roles for every authenticated request; the admin routes now
1893
- * do too. Both call this with the row they were already fetching in mind — a
1894
- * repository that wants to serve both from one query is free to cache
1895
- * internally, but nothing here assumes it.
1896
- *
1897
- * ## Failure
1898
- *
1899
- * Fails **open**, deliberately, and this is the one place in the auth stack
1900
- * where that is right. The watermark is an extra revocation signal layered over
1901
- * a token that has already been verified — signature, expiry and purpose all
1902
- * checked. Refusing every request when the database is unreachable would turn a
1903
- * transient outage into a total sign-out of an entire deployment, and the
1904
- * attacker this protects against needs to have already stolen a live token. The
1905
- * failure is logged at warn so it is visible rather than silent.
1906
- */
1907
- async function isAccessTokenRevoked(authRepo, payload) {
1908
- if (typeof authRepo.getTokensValidAfter !== "function") return false;
1909
- if (typeof payload.iat !== "number") return false;
1910
- let validAfter;
1911
- try {
1912
- validAfter = await authRepo.getTokensValidAfter(payload.uid);
1913
- } catch (error) {
1914
- logger.warn("[Auth] Could not read the token revocation watermark; allowing the request", {
1915
- uid: payload.uid,
1916
- error
1917
- });
1918
- return false;
1919
- }
1920
- return issuedBefore(payload, validAfter);
1921
- }
1922
- /**
1923
- * Is the account behind this verified access token still the one that may use
1924
- * it — and with which roles?
1925
- *
1926
- * A verified signature says who the token was minted for, an hour ago at
1927
- * most. This asks the database what has happened to that account since. Every
1928
- * door that honours an access token — the data plane, the admin gates, the
1929
- * realtime socket — asks it here, so they cannot disagree about it.
1930
- *
1931
- * They did. A deleted account read as "not revoked": the watermark lives on
1932
- * the user row, so once the row was gone there was no watermark, and the
1933
- * roles lookup answered `[]` rather than "nobody". A token its owner had
1934
- * revoked came back to life when an administrator deleted the account, as an
1935
- * authenticated principal with that uid, for the rest of its lifetime. MCP
1936
- * refresh and personal API keys already treated a missing account as revoked;
1937
- * the main door did not.
1938
- *
1939
- * Throws when the repository does. Unlike the watermark read on its own, this
1940
- * one decides the roles a request runs with, so a failure is a refusal (the
1941
- * callers answer 503) rather than a guess.
1942
- *
1943
- * Except one failure that is an answer: the store refusing the uid as a value
1944
- * its id column cannot hold — `u1` against a UUID key. That names no account,
1945
- * exactly as a well-formed id nobody has does, so it is `account-deleted` and
1946
- * the caller answers 401. Answered as a failure it was a 503 asking the
1947
- * client to try again, which it would do forever.
1948
- */
1949
- async function judgeAccessToken(authRepo, payload) {
1950
- try {
1951
- return await readAccessVerdict(authRepo, payload);
1952
- } catch (error) {
1953
- if (isDataException(error)) return {
1954
- live: false,
1955
- refusal: "account-deleted"
1956
- };
1957
- throw error;
1958
- }
1959
- }
1960
- async function readAccessVerdict(authRepo, payload) {
1961
- let roles;
1962
- let validAfter = null;
1963
- if (typeof authRepo.getAccountAccessState === "function") {
1964
- const state = await authRepo.getAccountAccessState(payload.uid, payload.sid);
1965
- if (!state) return {
1966
- live: false,
1967
- refusal: "account-deleted"
1968
- };
1969
- if (state.disabled) return {
1970
- live: false,
1971
- refusal: "account-disabled"
1972
- };
1973
- roles = state.roles;
1974
- validAfter = state.tokensValidAfter;
1975
- if (payload.sid && state.sessionActive === false) return {
1976
- live: false,
1977
- refusal: "session-revoked"
1978
- };
1979
- } else {
1980
- if (typeof authRepo.getUserWithRoles === "function") {
1981
- const account = await authRepo.getUserWithRoles(payload.uid);
1982
- if (!account) return {
1983
- live: false,
1984
- refusal: "account-deleted"
1985
- };
1986
- if (account.user.disabled) return {
1987
- live: false,
1988
- refusal: "account-disabled"
1989
- };
1990
- roles = account.roles;
1991
- }
1992
- if (typeof authRepo.getTokensValidAfter === "function") validAfter = await authRepo.getTokensValidAfter(payload.uid);
1993
- }
1994
- if (issuedBefore(payload, validAfter)) return {
1995
- live: false,
1996
- refusal: "revoked"
1997
- };
1998
- return {
1999
- live: true,
2000
- roles
2001
- };
2002
- }
2003
- /**
2004
- * The 401 a door answers an access token with when {@link judgeAccessToken}
2005
- * refuses it — one wording and one code for each reason, wherever the token
2006
- * was presented.
2007
- *
2008
- * `SESSION_REVOKED` for a token whose session or account is gone: the client
2009
- * SDK answers it with a refresh, which the refresh route refuses for the same
2010
- * reason, and the client signs out. `ACCOUNT_DISABLED` for an account an
2011
- * administrator switched off, which no refresh can mend.
2012
- */
2013
- function accessTokenRefusalError(refusal) {
2014
- switch (refusal) {
2015
- case "account-disabled": return ApiError.unauthorized("This account has been disabled", "ACCOUNT_DISABLED");
2016
- case "account-deleted": return ApiError.unauthorized("This account no longer exists", "SESSION_REVOKED");
2017
- case "revoked":
2018
- case "session-revoked": return ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
2019
- }
2020
- }
2021
- /** The watermark comparison {@link isAccessTokenRevoked} and the judge share. */
2022
- function issuedBefore(payload, validAfter) {
2023
- if (!validAfter || typeof payload.iat !== "number") return false;
2024
- return payload.iat < Math.floor(validAfter.getTime() / 1e3);
2025
- }
2026
- /**
2027
- * End every session this user holds, on every device.
2028
- *
2029
- * Two writes, because each covers what the other cannot. Deleting the refresh
2030
- * rows ends the sessions that exist at this instant, and on a repository
2031
- * without the watermark it is the only revocation there is. The watermark
2032
- * voids what the delete cannot see: a refresh already in flight that inserts
2033
- * its rotated token a moment after the delete ran, and every access token
2034
- * already handed out, which {@link isAccessTokenRevoked} refuses from here on.
2035
- *
2036
- * The watermark write does not fail the caller. By the time it runs the
2037
- * credential has usually already changed and the rows are gone, so a 500 would
2038
- * tell someone their password did not change when it did. It is logged, because
2039
- * a failure here leaves access tokens working until they expire.
2040
- */
2041
- async function revokeAllSessions(authRepo, uid) {
2042
- await authRepo.deleteAllRefreshTokensForUser(uid);
2043
- try {
2044
- await authRepo.setTokensValidAfter?.(uid, /* @__PURE__ */ new Date());
2045
- } catch (error) {
2046
- logger.warn("[Auth] Could not write the token revocation watermark; access tokens issued before now stay valid until they expire", {
2047
- uid,
2048
- error
2049
- });
2050
- }
2051
- }
2052
- /**
2053
- * Replace a user's password and end every session they hold.
2054
- *
2055
- * The one way a password hash is written over an existing account. A new
2056
- * password is what someone sets when they believe the old one — or a session
2057
- * signed in with it — is in someone else's hands, so a password change that
2058
- * leaves those sessions alive does not do the thing it was done for.
2059
- *
2060
- * It used to be a pair of lines repeated after each `updatePassword`, and it
2061
- * reached the two self-service routes and none of the admin ones: an
2062
- * administrator resetting a phished account left the attacker's refresh token
2063
- * minting access tokens for the rest of its lifetime.
2064
- * `test/password-change-revokes-sessions.test.ts` holds every route that sets a
2065
- * password to this, and fails if anything but this function calls
2066
- * `updatePassword`.
2067
- *
2068
- * `null` removes the password instead, for the same reason and with the same
2069
- * revocation: see `confirmAddressOwnership`.
2070
- */
2071
- async function replaceUserPassword(authRepo, uid, passwordHash) {
2072
- await authRepo.updatePassword(uid, passwordHash);
2073
- await revokeAllSessions(authRepo, uid);
2074
- await authRepo.deleteAllPasswordResetTokensForUser?.(uid);
2075
- }
2076
- //#endregion
2077
- //#region src/auth/rls-scope.ts
2078
- var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
2079
- /**
2080
- * The identity the trusted server plane runs as: `rebase.dataAsAdmin`, and any
2081
- * caller presenting the service key.
2082
- *
2083
- * Naming it makes the privilege legible, because it is narrower than it reads.
2084
- * This is **not** an RLS bypass — a driver scoped with it is an
2085
- * `AuthenticatedPostgresBackendDriver`, so every statement runs in a
2086
- * transaction that has done `SET LOCAL ROLE rebase_user` with
2087
- * `app.uid = 'service'`, and policies are evaluated. Two consequences worth
2088
- * knowing before you write a policy:
2089
- *
2090
- * - It passes the default policies through their `rolesOverlap(['admin'])`
2091
- * arm — the same arm an application user holding the `admin` role passes.
2092
- * - `policy.serverContext()` compiles to `rebase.uid() IS NULL` and is therefore
2093
- * **false** for it. A collection with `disableDefaultPolicies: true` whose
2094
- * only rule is `serverContext()` denies these writes (42501) and returns
2095
- * zero rows for these reads.
2096
- *
2097
- * The true bypass is `rebase.sql()`, which runs on the owner connection and
2098
- * never goes near `withAuth`.
2099
- */
2100
- var SERVICE_IDENTITY = {
2101
- uid: "service",
2102
- roles: ["admin"]
2103
- };
2104
- /**
2105
- * Returns true if the driver supports RLS scoping via `withAuth()`.
2106
- */
2107
- function isRLSScopedDriver(driver) {
2108
- return "withAuth" in driver && typeof driver.withAuth === "function";
2109
- }
2110
- /**
2111
- * Can this driver run a request as a specific user?
2112
- *
2113
- * The same question `scopeDataDriver` asks internally, exported because one
2114
- * caller must act on the answer BEFORE it has a request to scope.
2115
- *
2116
- * `scopeDataDriver` returns the driver unscoped when the answer is no, which is
2117
- * the right behaviour for `/api/data` — Mongo has no row-level security and
2118
- * never claimed to, and the route's own checks still apply. It is the wrong
2119
- * behaviour for a surface whose entire promise is "this runs as you": there,
2120
- * an unscoped driver silently means "runs as the database owner". So the MCP
2121
- * surface asks this at boot and declines to mount rather than degrade.
2122
- */
2123
- function supportsRlsScoping(driver) {
2124
- return isRLSScopedDriver(driver);
2125
- }
2126
- /**
2127
- * Scope a DataDriver via `withAuth()` for RLS.
2128
- *
2129
- * SECURITY: If `withAuth()` is available but fails, the error is re-thrown
2130
- * so the request is **denied** rather than proceeding with unscoped access
2131
- * (fail-closed behavior).
2132
- *
2133
- * If the driver does not support RLS, the original driver is returned.
2134
- *
2135
- * @param driver - The DataDriver to scope.
2136
- * @param user - The authenticated user identity for RLS.
2137
- * @returns The RLS-scoped DataDriver (or the original if RLS is unsupported).
2138
- */
2139
- async function scopeDataDriver(driver, user) {
2140
- if (isRLSScopedDriver(driver)) return await driver.withAuth(user);
2141
- return driver;
2142
- }
2143
- //#endregion
2144
- //#region src/auth/crypto-utils.ts
2145
- /**
2146
- * Cryptographic utility functions for auth.
2147
- *
2148
- * @module
2149
- */
2150
- /**
2151
- * Constant-time string comparison to prevent timing attacks.
2152
- *
2153
- * Used for comparing service keys, tokens, and other secrets where
2154
- * timing side-channels could leak information about the expected value.
2155
- *
2156
- * The implementation lives in `utils/portable-crypto.ts`, which does this over
2157
- * UTF-8 bytes without `node:crypto` — this function is on the request path, and
2158
- * the byte-level reasoning it depends on is documented there.
2159
- *
2160
- * @param a - First string to compare.
2161
- * @param b - Second string to compare.
2162
- * @returns `true` if the strings are identical, `false` otherwise.
2163
- */
2164
- function safeCompare(a, b) {
2165
- return constantTimeEqual(a, b);
2166
- }
2167
- //#endregion
2168
- //#region src/auth/bearer-token.ts
2169
- /**
2170
- * Parsing of the `Authorization: Bearer <token>` header.
2171
- *
2172
- * Its own module rather than a helper inside `middleware.ts`, because the
2173
- * API-key middleware needs it too and `middleware.ts` already imports from
2174
- * there — putting it in either one would close an import cycle.
2175
- *
2176
- * @module
2177
- */
2178
- /**
2179
- * Pull the token out of an `Authorization: Bearer <token>` header.
2180
- *
2181
- * RFC 7235 §2.1 makes the auth-scheme name case-insensitive, and plenty of
2182
- * clients send `bearer` — HTTP libraries that lower-case what they emit,
2183
- * hand-written curl, proxies that normalise headers. Matching `"Bearer "`
2184
- * exactly turned those into a 401 that, from the caller's side, is
2185
- * indistinguishable from a rejected token; people spend an afternoon
2186
- * regenerating credentials that were fine. Only the scheme is folded — the
2187
- * token itself is returned byte-for-byte.
2188
- *
2189
- * Returns `undefined` when there is no header or it names another scheme.
2190
- * That is deliberately distinct from an empty token (`Bearer ` with nothing
2191
- * after it), which callers still route into verification so it fails as a bad
2192
- * token rather than as a missing header.
2193
- */
2194
- function extractBearerToken(authHeader) {
2195
- if (!authHeader) return void 0;
2196
- const separator = authHeader.indexOf(" ");
2197
- if (separator < 0) return void 0;
2198
- if (authHeader.slice(0, separator).toLowerCase() !== "bearer") return void 0;
2199
- return authHeader.slice(separator + 1);
2200
- }
2201
- //#endregion
2202
- //#region src/auth/access.ts
2203
- /**
2204
- * How a personal key finds its owner in an account store: the account as it
2205
- * is now — a missing or disabled one is no owner — and its roles.
2206
- *
2207
- * One read where the store answers both at once: `getAccountAccessState`, the
2208
- * statement every access token is already judged against, which reads roles
2209
- * and `disabled_at` together. Otherwise the account, then its roles — the two
2210
- * reads this always made, one round trip more per request.
2211
- */
2212
- function keyOwnerResolverFor(repo) {
2213
- if (typeof repo.getAccountAccessState === "function") {
2214
- const readAccount = repo.getAccountAccessState.bind(repo);
2215
- return async (uid) => {
2216
- const account = await readAccount(uid);
2217
- if (!account || account.disabled) return null;
2218
- return { roles: account.roles };
2219
- };
2220
- }
2221
- return async (uid) => {
2222
- const user = await repo.getUserById(uid);
2223
- if (!user || user.disabled) return null;
2224
- return { roles: await repo.getUserRoleIds(uid) };
2225
- };
2226
- }
2227
- /**
2228
- * What the module-level API answers for: {@link getAccessModel},
2229
- * {@link getKeyOwnerResolver}, and every scope check on a route mounted
2230
- * outside any backend. Built-ins only until {@link configureAccess} or a
2231
- * backend installed as the process default sets it.
2232
- */
2233
- var processAccess = { model: EMPTY_ACCESS_MODEL };
2234
- /** The process default's access model. Built-ins only until one is configured. */
2235
- function getAccessModel() {
2236
- return processAccess.model;
2237
- }
2238
- /**
2239
- * Set the process default's access model, and — when the app enables
2240
- * `auth.personalKeys` and has an account store to read owners from — how
2241
- * personal keys find their owner. For a test, or a server wired by hand that
2242
- * uses the module-level API; a backend built by `createRebaseBackend` carries
2243
- * its own.
2244
- */
2245
- function configureAccess(options) {
2246
- processAccess = {
2247
- model: options.model,
2248
- resolveKeyOwner: options.resolveKeyOwner
2249
- };
2250
- }
2251
- /** @internal See {@link defaultAccessControl}. */
2252
- function setDefaultAccessControl(access) {
2253
- processAccess = access;
2254
- }
2255
- /**
2256
- * The process default as an {@link AccessControl} that follows it — what a
2257
- * route or a key check built outside any backend falls back to.
2258
- */
2259
- var processAccessControl = {
2260
- get model() {
2261
- return processAccess.model;
2262
- },
2263
- get resolveKeyOwner() {
2264
- return processAccess.resolveKeyOwner;
2265
- }
2266
- };
2267
- /**
2268
- * The access control serving this request: the backend's whose route group is
2269
- * answering it, or the process default for a route mounted outside any.
2270
- */
2271
- function accessOf(c) {
2272
- return c.get("services")?.access ?? processAccessControl;
2273
- }
2274
- /** The resource names Rebase's own scopes use; an app scope may not reuse one. */
2275
- var BUILT_IN_RESOURCES = new Set(BUILT_IN_SCOPES.map((scope) => scope.slice(0, scope.indexOf(":"))));
2276
- /** A declaration the access model cannot be built from. Fails the boot. */
2277
- var AccessModelError = class extends Error {
2278
- constructor(message) {
2279
- super(message);
2280
- this.name = "AccessModelError";
2281
- }
2282
- };
2283
- /**
2284
- * Build the access model from the users collection's `auth` block, refusing
2285
- * anything that would read as a grant it is not.
2286
- *
2287
- * - `admin` may not be declared: it is built in and holds everything, and a
2288
- * declaration would either repeat that or quietly narrow it.
2289
- * - An app scope may not reuse a built-in resource name (`logs:export`): the
2290
- * built-in resources are Rebase's, and a reader of `logs:*` should not have
2291
- * to know which half is whose.
2292
- * - A role may not list a data-plane scope: a person's data access is the
2293
- * collection's `securityRules`, so `data:write` on a role would grant nothing
2294
- * and look like it granted something.
2295
- */
2296
- function accessModelFromCollections(collections) {
2297
- const authCollection = collections.find((collection) => {
2298
- const auth = collection.auth;
2299
- return auth === true || !!auth && typeof auth === "object" && auth.enabled === true;
2300
- });
2301
- const auth = authCollection && typeof authCollection.auth === "object" ? authCollection.auth : void 0;
2302
- const declaredScopes = { ...auth?.scopes ?? {} };
2303
- const declaredRoles = { ...auth?.roles ?? {} };
2304
- const where = `the "${authCollection?.slug ?? "users"}" collection's auth block`;
2305
- for (const [name, definition] of Object.entries(declaredScopes)) {
2306
- const parsed = parseScope(name);
2307
- 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".`);
2308
- const resource = name.slice(0, name.indexOf(":"));
2309
- 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(", ")}.`);
2310
- 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.`);
2311
- }
2312
- const model = {
2313
- roles: declaredRoles,
2314
- scopes: declaredScopes
2315
- };
2316
- for (const [role, definition] of Object.entries(declaredRoles)) {
2317
- 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.`);
2318
- if (!Array.isArray(definition?.scopes)) throw new AccessModelError(`auth.roles: "${role}" in ${where} needs a scopes list.`);
2319
- const dataPlane = definition.scopes.filter((scope) => isDataPlaneScope(parseScope(scope)?.scope ?? ""));
2320
- 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.`);
2321
- const problems = validateScopes(definition.scopes, model);
2322
- 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.`);
2323
- }
2324
- return model;
2325
- }
2326
- /** Narrow a context's user to the roles it carries. */
2327
- function rolesOf(user) {
2328
- if (typeof user !== "object" || user === null || !("roles" in user)) return [];
2329
- const roles = user.roles;
2330
- return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
2331
- }
2332
- /**
2333
- * Everything the caller of this request may do. Empty for a request with no
2334
- * caller.
2335
- */
2336
- function callerScopes(c) {
2337
- const narrowed = c.get("scopes");
2338
- if (narrowed) return narrowed;
2339
- const user = c.get("user");
2340
- if (!user) return [];
2341
- return scopesForRoles(rolesOf(user), accessOf(c).model);
2342
- }
2343
- /**
2344
- * Whether this request's caller holds the `admin` role. The service key signs
2345
- * in as one.
2346
- */
2347
- function callerIsAdmin(c) {
2348
- return hasAdminRole(rolesOf(c.get("user")));
2349
- }
2350
- /** Does this request's caller hold `scope`, on `target` when one is given? */
2351
- function hasScope(c, scope, target) {
2352
- return scopeGrants(callerScopes(c), scope, target);
2353
- }
2354
- /**
2355
- * The refusal for a missing scope, worded for whoever is holding the
2356
- * credential. `details.requiredScope` names it for clients that branch.
2357
- */
2358
- function scopeMissing(c, scope, target) {
2359
- const wanted = target !== void 0 ? `${scope}:${target}` : scope;
2360
- 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 });
2361
- }
2362
- /**
2363
- * Refuse a request whose caller does not hold `scope`.
2364
- *
2365
- * `scope` is a built-in scope or one this backend declares under
2366
- * `auth.scopes`. A name no caller could ever hold — not a scope name, a typo
2367
- * of a built-in one (`data:raed`) — throws as the route is registered; an app
2368
- * scope the backend answering does not declare is answered
2369
- * `500 SCOPE_NOT_DECLARED`, naming the declaration to add.
2370
- *
2371
- * `target` narrows the check to one collection, bucket, function or app
2372
- * resource, and may be read from the request. A guard, not an authenticator:
2373
- * it judges the caller an auth middleware identified, so mount it after one —
2374
- * `requireAuth`, or a route group's own. A request with no caller is refused
2375
- * as unauthenticated, not as unscoped.
2376
- *
2377
- * @example
2378
- * ```ts
2379
- * app.post("/deploy/:project", requireAuth, requireScope("project:deploy", c => c.req.param("project")), handler);
2380
- * ```
2381
- */
2382
- function requireScope(scope, target) {
2383
- const problem = scopeGuardProblem(scope, typeof target === "string" ? target : void 0);
2384
- if (problem) throw new Error(problem);
2385
- return async (c, next) => {
2386
- if (!c.get("user")) return refuse(c, noCallerRefusal(c));
2387
- const undeclared = scopeGuardProblem(scope, void 0, Object.keys(accessOf(c).model.scopes));
2388
- if (undeclared) return refuse(c, new ApiError(500, "SCOPE_NOT_DECLARED", undeclared));
2389
- const resolvedTarget = typeof target === "function" ? target(c) : target;
2390
- if (!hasScope(c, scope, resolvedTarget)) return refuse(c, scopeMissing(c, scope, resolvedTarget));
2391
- return next();
2392
- };
2393
- }
2394
- /**
2395
- * The 401 for a guard that found no caller.
2396
- *
2397
- * A request that sent a credential, on a route where no auth middleware ran —
2398
- * it sets `driver` for every caller, anonymous included — never had that
2399
- * credential read. "Authentication required" sends whoever holds a valid
2400
- * session to check it, so this names the wiring instead.
2401
- */
2402
- function noCallerRefusal(c) {
2403
- 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).");
2404
- return ApiError.unauthenticated("Authentication required");
2405
- }
2406
- /**
2407
- * Refuse a request unless its caller holds the scope for its HTTP method:
2408
- * `read` for GET and HEAD, `write` for everything else. For an admin surface
2409
- * whose reads and writes split exactly along the method.
2410
- */
2411
- function requireScopeByMethod(scopes) {
2412
- const read = requireScope(scopes.read);
2413
- const write = requireScope(scopes.write);
2414
- return async (c, next) => {
2415
- const method = c.req.method.toUpperCase();
2416
- return (method === "GET" || method === "HEAD" ? read : write)(c, next);
2417
- };
2418
- }
2419
- /**
2420
- * What a set of roles holds beyond this request's caller — the scopes, plus
2421
- * `admin` itself when the roles include it and the caller does not hold it.
2422
- * Empty when the caller covers every one.
2423
- *
2424
- * `admin` is named separately because it is more than its scopes: it is the
2425
- * RLS role that reads every row, so a role listing every admin-plane scope
2426
- * still cannot hand it out.
2427
- */
2428
- function beyondCaller(c, roles) {
2429
- const beyond = scopesBeyond(scopesForRoles(roles, accessOf(c).model), callerScopes(c));
2430
- if (hasAdminRole(roles) && !hasAdminRole(rolesOf(c.get("user")))) beyond.unshift(ADMIN_ROLE);
2431
- return beyond;
2432
- }
2433
- /**
2434
- * Refuse to let the caller act on an account — edit it, reset its password,
2435
- * delete it — when that account holds anything the caller does not.
2436
- *
2437
- * `users:write` is the scope that lets a support role manage accounts, and
2438
- * without this it would also let that role reset an administrator's password
2439
- * and sign in as them.
2440
- */
2441
- function assertMayManageAccount(c, targetRoles) {
2442
- const beyond = beyondCaller(c, targetRoles);
2443
- 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 });
2444
- }
2445
- /** Refuse to let the caller grant roles that hold anything the caller does not. */
2446
- function assertMayGrantRoles(c, roles) {
2447
- const beyond = beyondCaller(c, roles);
2448
- 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 });
2449
- }
2450
- //#endregion
2451
- //#region src/auth/api-keys/api-key-middleware.ts
2452
- /**
2453
- * Check whether a token looks like a Rebase API key.
2454
- */
2455
- function isApiKeyToken(token) {
2456
- return token.startsWith("rk_");
2457
- }
2458
- /**
2459
- * Verify a presented key: it exists, is live, and — for a personal key —
2460
- * personal keys are on and its owner still exists. Records the use.
2461
- *
2462
- * `access` is the backend the key belongs to: whether it takes personal keys,
2463
- * how it finds their owners, and what their roles hold. The process default
2464
- * when omitted.
2465
- */
2466
- async function resolveApiKey(store, token, access = processAccessControl) {
2467
- const apiKey = await store.findByKeyHash(await sha256Hex(token));
2468
- if (!apiKey) return { message: "Invalid API key" };
2469
- if (apiKey.revoked_at) return { message: "API key has been revoked" };
2470
- if (apiKey.expires_at && new Date(apiKey.expires_at) < /* @__PURE__ */ new Date()) return { message: "API key has expired" };
2471
- let identity;
2472
- if (apiKey.kind === "personal") {
2473
- const resolveOwner = access.resolveKeyOwner;
2474
- if (!resolveOwner || !apiKey.owner_uid) return { message: "Personal API keys are switched off on this backend" };
2475
- const owner = await resolveOwner(apiKey.owner_uid);
2476
- if (!owner) return { message: "The account this API key acts as no longer exists" };
2477
- identity = {
2478
- uid: apiKey.owner_uid,
2479
- roles: owner.roles,
2480
- scopes: intersectScopes(apiKey.scopes, scopesForRoles(owner.roles, access.model))
2481
- };
2482
- } else identity = {
2483
- uid: `api-key:${apiKey.id}`,
2484
- roles: ["service", ...apiKey.roles.filter((role) => role !== "service")],
2485
- scopes: apiKey.scopes
2486
- };
2487
- const lastTouch = lastUsedTouchedAt.get(apiKey.id);
2488
- const now = Date.now();
2489
- if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
2490
- lastUsedTouchedAt.set(apiKey.id, now);
2491
- store.updateLastUsed(apiKey.id).catch(() => {});
2492
- }
2493
- const masked = {
2494
- id: apiKey.id,
2495
- name: apiKey.name,
2496
- kind: apiKey.kind,
2497
- key_prefix: apiKey.key_prefix,
2498
- scopes: apiKey.scopes,
2499
- roles: apiKey.roles,
2500
- owner_uid: apiKey.owner_uid,
2501
- rate_limit: apiKey.rate_limit,
2502
- created_by: apiKey.created_by,
2503
- created_at: apiKey.created_at,
2504
- updated_at: apiKey.updated_at,
2505
- last_used_at: apiKey.last_used_at,
2506
- expires_at: apiKey.expires_at,
2507
- revoked_at: apiKey.revoked_at
2508
- };
2509
- return {
2510
- ...identity,
2511
- apiKey: masked
2512
- };
2513
- }
2514
- /** Per-process debounce state for last_used_at touches. */
2515
- var lastUsedTouchedAt = /* @__PURE__ */ new Map();
2516
- var LAST_USED_DEBOUNCE_MS = 6e4;
2517
- /**
2518
- * Validate an API key token and populate the Hono context: `user`, `apiKey`,
2519
- * `scopes` and the RLS-scoped `driver`.
2520
- *
2521
- * Returns `true` when the context is populated, or the error Response.
2522
- */
2523
- async function validateApiKey(c, token, options) {
2524
- const resolved = await resolveApiKey(options.store, token, accessOf(c));
2525
- if (!("uid" in resolved)) return errorHandler(ApiError.unauthenticated(resolved.message), c);
2526
- c.set("user", {
2527
- uid: resolved.uid,
2528
- roles: resolved.roles
2529
- });
2530
- c.set("apiKey", resolved.apiKey);
2531
- c.set("scopes", resolved.scopes);
2532
- try {
2533
- c.set("driver", await scopeDataDriver(options.driver, {
2534
- uid: resolved.uid,
2535
- roles: resolved.roles
2536
- }));
2537
- } catch (error) {
2538
- logger.error("[AUTH] RLS scoping failed for API key", { error });
2539
- return errorHandler(ApiError.internal("Internal authentication error"), c);
2540
- }
2541
- return true;
2542
- }
2543
- /**
2544
- * The 403 for a credential that lacks a data-plane scope outside the REST
2545
- * generator (storage and functions). Names the scope that would grant it.
2546
- */
2547
- function forbidScope(c, scope, target) {
2548
- const wanted = target !== void 0 && target !== "" ? `${scope}:${target}` : scope;
2549
- return errorHandler(new ApiError(403, "SCOPE_MISSING", `This credential does not hold the "${wanted}" scope.`, { requiredScope: wanted }), c);
2550
- }
2551
- /** Whether this request's caller holds `scope` — on `target` when given. */
2552
- function callerHolds(c, scope, target) {
2553
- const narrowed = c.get("scopes");
2554
- if (!narrowed) return true;
2555
- return target === void 0 ? scopeGrantsAny(narrowed, scope) : scopeGrants(narrowed, scope, target);
2556
- }
2557
- /**
2558
- * Scope guard for the resumable-upload routes (`/tus/:id`).
2559
- *
2560
- * Every step of an upload — the offset check (HEAD), the chunks (PATCH), the
2561
- * cancel (DELETE) — is part of writing it, so all of them need
2562
- * `storage:write`. Which source the upload writes to was checked when it was
2563
- * created; these steps only reach an upload the same caller owns.
2564
- */
2565
- function createTusScopeGuard() {
2566
- return async (c, next) => {
2567
- if (/\/tus\/[^/]+$/.test(c.req.path) && c.get("user") && !callerHolds(c, "storage:write")) return forbidScope(c, "storage:write");
2568
- return next();
2569
- };
2570
- }
2571
- /**
2572
- * Scope guard for the custom-functions router: a narrowed credential needs
2573
- * `functions:invoke` on the function it calls. The functions index — the
2574
- * listing at the mount point itself — needs the unqualified scope.
2575
- *
2576
- * People pass: what a person may do inside a function is the function's own
2577
- * business, as it always was.
2578
- *
2579
- * @param mountPrefix - The path the functions router is mounted at
2580
- * (e.g. `/api/functions`), used to extract the function
2581
- * name from the request path. Omitted, it is the
2582
- * `mountPath` the functions group recorded for this
2583
- * request — wherever the application mounted it.
2584
- */
2585
- function createFunctionScopeGuard(mountPrefix) {
2586
- return async (c, next) => {
2587
- const narrowed = c.get("scopes");
2588
- if (!narrowed) return next();
2589
- const path = c.req.path;
2590
- const prefix = mountPrefix ?? c.get("mountPath") ?? "";
2591
- const idx = path.indexOf(prefix);
2592
- const rawName = (idx < 0 ? "" : path.slice(idx + prefix.length)).split("/").filter(Boolean)[0] ?? "";
2593
- let functionName;
2594
- try {
2595
- functionName = decodeURIComponent(rawName);
2596
- } catch {
2597
- functionName = rawName;
2598
- }
2599
- if (!(functionName === "" ? scopeGrants(narrowed, "functions:invoke") : scopeGrants(narrowed, "functions:invoke", functionName))) return forbidScope(c, "functions:invoke", functionName);
2600
- return next();
2601
- };
2602
- }
2603
- /**
2604
- * Pre-auth middleware for `rk_` bearer tokens.
2605
- *
2606
- * Routers whose auth gate is JWT-based (`createRequireAuth` — the admin
2607
- * surfaces, the key routes) don't know about API keys. Mounted in front of
2608
- * them, this authenticates `rk_` tokens and populates the request context; the
2609
- * downstream gates then see the already-resolved caller and check its scopes.
2610
- *
2611
- * Requests without an `rk_` bearer token pass through untouched. An invalid,
2612
- * revoked, or expired `rk_` token is rejected here (401) rather than falling
2613
- * through to be misparsed as a JWT.
2614
- */
2615
- function createApiKeyPreAuth(options) {
2616
- return async (c, next) => {
2617
- if (c.get("apiKey")) return next();
2618
- const token = extractBearerToken(c.req.header("authorization")) ?? "";
2619
- if (!token || !isApiKeyToken(token)) return next();
2620
- const result = await validateApiKey(c, token, options);
2621
- if (result !== true) return result;
2622
- return next();
2623
- };
2624
- }
2625
- //#endregion
2626
- //#region src/auth/impersonation.ts
2627
- var ADMIN_ONLY = "Only a signed-in administrator can run a request as another user.";
2628
- var NOT_A_PERSON = {
2629
- "api-key": {
2630
- reason: "api-key",
2631
- message: "An API key cannot run a request as another user. Send it with an administrator's session instead."
2632
- },
2633
- "service-key": {
2634
- reason: "service-key",
2635
- message: "The service key cannot run a request as another user. Send it with an administrator's session instead."
2636
- },
2637
- none: {
2638
- reason: "anonymous",
2639
- message: ADMIN_ONLY
2640
- }
2641
- };
2642
- /**
2643
- * Decide whether this caller may act as the user they named, and who that
2644
- * user is now.
2645
- *
2646
- * The one decision both doors take — the HTTP data plane through
2647
- * {@link applyImpersonation}, the realtime socket at `AUTHENTICATE` and before
2648
- * every frame after it. The caller is judged before the named user is looked
2649
- * up, so the answer to a caller who may not impersonate says nothing about
2650
- * whether the uid exists.
2651
- *
2652
- * Throws when a lookup fails: that is not an answer about anyone, and each
2653
- * door refuses it its own way.
2654
- */
2655
- async function decideImpersonation(request) {
2656
- const { credential, caller, resolveUser, audit } = request;
2657
- const targetUid = request.requestedUid.trim();
2658
- const refuse = (reason, error) => {
2659
- if (audit) logger.warn("[Security Audit] Refused a request to act as another user", {
2660
- eventType: "auth.impersonation.refused",
2661
- reason,
2662
- callerUid: caller?.uid,
2663
- requestedUid: targetUid,
2664
- ...audit
2665
- });
2666
- return { refused: error };
2667
- };
2668
- 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"));
2669
- if (credential !== "session") {
2670
- const { reason, message } = NOT_A_PERSON[credential];
2671
- return refuse(reason, ApiError.forbidden(message, "IMPERSONATION_FORBIDDEN"));
2672
- }
2673
- if (!caller || !hasAdminRole(caller.roles)) return refuse("not-admin", ApiError.forbidden(ADMIN_ONLY, "IMPERSONATION_FORBIDDEN"));
2674
- 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."));
2675
- const self = await resolveUser(caller.uid);
2676
- if (!self || !hasAdminRole(self.roles)) return refuse("not-admin", ApiError.forbidden(ADMIN_ONLY, "IMPERSONATION_FORBIDDEN"));
2677
- const target = await resolveUser(targetUid);
2678
- 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"));
2679
- if (audit) logger.info("[Security Audit] Ran a request as another user", {
2680
- eventType: "auth.impersonation",
2681
- impersonatorUid: caller.uid,
2682
- targetUid: target.uid,
2683
- targetRoles: target.roles,
2684
- ...audit
2685
- });
2686
- return {
2687
- granted: target,
2688
- impersonator: caller.uid
2689
- };
2690
- }
2691
- /** The caller the auth middleware put on the context, narrowed. */
2692
- function callerOf(c) {
2693
- const user = c.get("user");
2694
- if (typeof user !== "object" || user === null) return void 0;
2695
- const uid = "uid" in user && typeof user.uid === "string" && user.uid !== "" ? user.uid : void 0;
2696
- if (uid === void 0) return void 0;
2697
- return {
2698
- uid,
2699
- roles: "roles" in user && Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : []
2700
- };
2701
- }
2702
- /**
2703
- * Honour or refuse this request's {@link IMPERSONATE_HEADER}.
2704
- *
2705
- * Called by the data-plane auth middlewares once the caller is on the context
2706
- * and its driver is scoped. When the header names a user the caller may act
2707
- * as, the context's `user` and `driver` are replaced with that user's and
2708
- * `impersonator` names the caller.
2709
- *
2710
- * @returns The refusal to send, or `undefined` to carry on — as the caller
2711
- * when the header is absent, as the named user when it was granted.
2712
- */
2713
- async function applyImpersonation(c, { credential, driver, resolveUser }) {
2714
- const requested = c.req.header(IMPERSONATE_HEADER);
2715
- if (requested === void 0) return void 0;
2716
- const caller = callerOf(c);
2717
- let decision;
2718
- try {
2719
- decision = await decideImpersonation({
2720
- requestedUid: requested,
2721
- credential,
2722
- caller,
2723
- resolveUser,
2724
- audit: {
2725
- door: "http",
2726
- method: c.req.method,
2727
- path: c.req.path,
2728
- requestId: c.get("requestId")
2729
- }
2730
- });
2731
- } catch (error) {
2732
- return errorHandler(error instanceof Error ? error : new Error(String(error)), c);
2733
- }
2734
- if ("refused" in decision) return errorHandler(decision.refused, c);
2735
- const target = decision.granted;
2736
- const isAnonymous = target.isAnonymous === true;
2737
- c.set("user", {
2738
- uid: target.uid,
2739
- email: target.email,
2740
- roles: target.roles,
2741
- isAnonymous
2742
- });
2743
- c.set("impersonator", { uid: decision.impersonator });
2744
- try {
2745
- c.set("driver", await scopeDataDriver(driver, {
2746
- uid: target.uid,
2747
- roles: target.roles,
2748
- isAnonymous,
2749
- ...target.claims ? { claims: target.claims } : {}
2750
- }));
2751
- } catch (error) {
2752
- logger.error("[AUTH] RLS scoping failed for an impersonated request", { error });
2753
- return errorHandler(ApiError.internal("Internal authentication error"), c);
2754
- }
2755
- }
2756
- /**
2757
- * Refuse {@link IMPERSONATE_HEADER} on every route that does not honour it.
2758
- *
2759
- * Only the data API and custom functions run a request as another user; the
2760
- * rest — storage, auth, the admin surfaces — authenticate the caller and
2761
- * ignore the header. Ignored, a request asking to act as B would be answered
2762
- * as the administrator who sent it, which is the one outcome impersonation
2763
- * must never have. Mounted ahead of every route, so a surface added later is
2764
- * refused until it is listed here as honouring the header.
2765
- *
2766
- * @param honouredMounts - The mount points whose auth middleware applies the
2767
- * header (`/api/data`, `/api/functions`). A path is under one when it
2768
- * is the mount itself or continues it with a `/`.
2769
- */
2770
- function refuseUnhonouredImpersonation(honouredMounts) {
2771
- return async (c, next) => {
2772
- if (c.req.header("x-rebase-impersonate") === void 0) return next();
2773
- const path = c.req.path;
2774
- if (honouredMounts.some((mount) => path === mount || path.startsWith(`${mount}/`))) return next();
2775
- logger.warn("[Security Audit] Refused a request to act as another user", {
2776
- eventType: "auth.impersonation.refused",
2777
- reason: "unsupported-route",
2778
- method: c.req.method,
2779
- path,
2780
- requestId: c.get("requestId")
2781
- });
2782
- 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);
2783
- };
2784
- }
2785
- //#endregion
2786
- //#region src/storage/requested-object.ts
2787
- /**
2788
- * Which object a storage request's URL path names.
2789
- *
2790
- * Three parties read the path of a `GET /file/*`, `GET /metadata/*` or
2791
- * `DELETE /file/*`: the route, which serves or deletes an object;
2792
- * `publicObjectAuth`, which lets an anonymous caller through when that object
2793
- * is public; and `fileTokenAuth`, which compares it with the path a download
2794
- * token grants. A decision is only about the object acted on if it is made on
2795
- * the same key, so all three derive it here — the wildcard, the decoding, the
2796
- * bucket segment and the canonical key — and none of them spells a step of its
2797
- * own.
2798
- *
2799
- * They used to. `publicObjectAuth` judged the raw path with a check that strips
2800
- * everything up to a `://`, while the route canonicalized it, folding `//` to
2801
- * `/`: `GET /file/notes://public/secret.txt` was public to the one and served
2802
- * the private key `notes:/public/secret.txt` from the other, with the authorize
2803
- * hook never asked.
2804
- *
2805
- * Nothing here parses a scheme. A request path is a key, optionally behind the
2806
- * one bucket segment the routes recognise; a `://` in it is a `:` followed by
2807
- * an empty segment, and canonicalization folds the empty segment away.
2808
- */
2809
- /**
2810
- * The wildcard part of a storage route's path, still percent-encoded.
2811
- *
2812
- * Hono's `c.req.param('*')` does not work reliably in sub-routers mounted
2813
- * via `app.route(prefix, subRouter)`. Instead it is derived from the
2814
- * fully-resolved `c.req.path` and `c.req.routePath`.
2815
- *
2816
- * For a route `/metadata/*` mounted at `/api/storage`, a request to
2817
- * `/api/storage/metadata/default/file.jpg` yields routePath
2818
- * `/api/storage/metadata/*`. The prefix (everything before `/*`) is stripped,
2819
- * plus one character for the trailing `/`, to obtain `default/file.jpg`.
2820
- */
2821
- function storageRequestWildcard(c) {
2822
- const prefix = c.req.routePath.replace("/*", "");
2823
- const fullPath = c.req.path;
2824
- const idx = fullPath.indexOf(prefix);
2825
- if (idx < 0) return "";
2826
- return fullPath.substring(idx + prefix.length + 1);
2827
- }
2828
- /**
2829
- * The object a decoded path names: a key, behind an optional `default/` bucket
2830
- * segment (matched case-insensitively). Throws {@link InvalidStorageKeyError}
2831
- * when the key cannot be canonicalized.
2832
- *
2833
- * For a path that is already text — the folder route's JSON body. A URL path
2834
- * goes through {@link requestedStorageObject}, which decodes it first.
2835
- */
2836
- function storageObjectOfPath(decodedPath) {
2837
- const parts = decodedPath.split("/");
2838
- return {
2839
- bucket: "default",
2840
- key: canonicalStorageKey(parts.length > 1 && parts[0].toLowerCase() === "default" ? parts.slice(1).join("/") : decodedPath)
2841
- };
2842
- }
2843
- /**
2844
- * The object a request's wildcard path names, or {@link InvalidStorageKeyError}.
2845
- *
2846
- * The wildcard arrives as Hono leaves it — `decodeURI`d, with the reserved
2847
- * characters (`/`, `:`, `%25` among them) still escaped — so one
2848
- * `decodeURIComponent` yields the path the client encoded. A `%` that does not
2849
- * begin an escape names no key, and is refused like any other key that cannot be
2850
- * canonicalized rather than thrown as a `URIError`.
2851
- */
2852
- function requestedStorageObject(wildcard) {
2853
- let decoded;
2854
- try {
2855
- decoded = decodeURIComponent(wildcard);
2856
- } catch {
2857
- throw new InvalidStorageKeyError("Storage path is not valid percent-encoding: a '%' must begin an escape such as '%25' (a literal '%').");
2858
- }
2859
- return storageObjectOfPath(decoded);
2860
- }
2861
- /**
2862
- * {@link requestedStorageObject}, or `null` when the path names no object.
2863
- *
2864
- * For the middleware, which must fail closed without an exception: a path that
2865
- * names no object is neither public nor covered by any download token, and the
2866
- * route answers it with a 400 of its own.
2867
- */
2868
- function tryRequestedStorageObject(wildcard) {
2869
- try {
2870
- return requestedStorageObject(wildcard);
2871
- } catch (err) {
2872
- if (err instanceof InvalidStorageKeyError) return null;
2873
- throw err;
2874
- }
2875
- }
2876
- //#endregion
2877
- //#region src/storage/request-principals.ts
2878
- /**
2879
- * The two principals a storage read can be granted without a user: the reader
2880
- * of a declared-public object, and the bearer of a download token.
2881
- *
2882
- * `checkAuthorized` does not ask the `storageAuthorize` hook about either — the
2883
- * public prefix and the token are the authorization. That skip used to be
2884
- * decided on the principal's `uid` string (`"public"`, `"download-token"`), so
2885
- * any identity carrying that uid skipped the hook: a custom `AuthAdapter` whose
2886
- * `verifyRequest` returns `{ uid: "public" }` for a guest could then upload,
2887
- * delete and list anywhere, with the hook never asked.
2888
- *
2889
- * Each principal is now one object, set only by the middleware that granted
2890
- * it (`publicObjectAuth`, `fileTokenAuth`) and recognised by identity. An
2891
- * adapter builds a new object per request, so it can name the uid but never be
2892
- * the grant.
2893
- */
2894
- /** The principal `publicObjectAuth` grants an anonymous read of a public object. */
2895
- var PUBLIC_OBJECT_PRINCIPAL = Object.freeze({
2896
- uid: "public",
2897
- roles: ["public"]
2898
- });
2899
- /** The principal `fileTokenAuth` grants a read covered by a valid download token. */
2900
- var DOWNLOAD_TOKEN_PRINCIPAL = Object.freeze({
2901
- uid: "download-token",
2902
- roles: ["reader"]
2903
- });
2904
- /** Whether `user` is the grant `publicObjectAuth` made — the object, not the uid. */
2905
- function isPublicObjectGrant(user) {
2906
- return user === PUBLIC_OBJECT_PRINCIPAL;
2907
- }
2908
- /** Whether `user` is the grant `fileTokenAuth` made — the object, not the uid. */
2909
- function isDownloadTokenGrant(user) {
2910
- return user === DOWNLOAD_TOKEN_PRINCIPAL;
2911
- }
2912
- //#endregion
2913
- //#region src/auth/middleware.ts
2914
- /**
2915
- * Answer a refusal with the canonical envelope, through the same formatter
2916
- * every route uses.
2917
- *
2918
- * These middlewares used to hand-build `c.json({ error: { message, code } },
2919
- * 401)` — seven near-copies of it, which is how they came to omit `requestId`
2920
- * while every other error on the server carried it: the one string a bug report
2921
- * quotes was missing from precisely the errors an app hits most. It also meant
2922
- * a routine 401 (a client refreshing before it knows whether a session exists)
2923
- * logged like an incident, because `ApiError.expected` never reached the
2924
- * logger.
2925
- *
2926
- * `errorHandler` is *called* rather than thrown to. A throw only becomes an
2927
- * envelope where an `onError` is registered, and these middlewares are mounted
2928
- * by callers this package does not control — `defineFunction` hands users a
2929
- * Hono app and invites them to put `requireAuth` on a route of it. Making the
2930
- * status depend on the mount point would turn a 401 into a 500 for exactly
2931
- * those callers. Calling the formatter directly gets the envelope with none of
2932
- * that.
2933
- */
2934
- function refuse(c, error) {
2935
- return errorHandler(error, c);
2936
- }
2937
- /**
2938
- * Hono middleware that requires a valid JWT token via Authorization header.
2939
- * Returns 401 if token is missing or invalid.
2940
- *
2941
- * **Security:** Tokens are only accepted via the `Authorization: Bearer`
2942
- * header. Query-string tokens (`?token=`) are intentionally NOT accepted
2943
- * here because URLs leak into access logs, proxies, Referer headers, and
2944
- * browser history. Use {@link queryTokenAuth} on routes that legitimately
2945
- * need query-string tokens (e.g. storage file serving for `<img src>`).
2946
- */
2947
- var requireAuth = async (c, next) => {
2948
- if (c.get("user")) return next();
2949
- const token = extractBearerToken(c.req.header("authorization"));
2950
- if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
2951
- const payload = await jwtOf(c).verifyAccessToken(token);
2952
- if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
2953
- c.set("user", payload);
2954
- return next();
2955
- };
2956
- /**
2957
- * Factory that creates a requireAuth middleware with optional service key support.
2958
- *
2959
- * When `serviceKey` is provided, the middleware will check if the Bearer token
2960
- * matches the service key using constant-time comparison. If it matches, the
2961
- * request is authenticated as a service user with admin privileges.
2962
- *
2963
- * This allows admin routes (which use standalone requireAuth + requireAdmin)
2964
- * to be accessed via service keys for scripts and server-to-server calls.
2965
- */
2966
- function createRequireAuth(options) {
2967
- if (!options?.serviceKey && !options?.resolveRoles && !options?.revocationRepo) return requireAuth;
2968
- const key = options.serviceKey;
2969
- const resolveRoles = options.resolveRoles;
2970
- const revocationRepo = options.revocationRepo;
2971
- return async (c, next) => {
2972
- if (c.get("user")) return next();
2973
- const token = extractBearerToken(c.req.header("authorization"));
2974
- if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
2975
- if (key !== void 0 && safeCompare(token, key)) {
2976
- c.set("user", {
2977
- uid: "service",
2978
- roles: ["admin"]
2979
- });
2980
- return next();
2981
- }
2982
- const payload = await jwtOf(c).verifyAccessToken(token);
2983
- if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
2984
- if (resolveRoles || revocationRepo) try {
2985
- let judgedRoles;
2986
- if (revocationRepo) {
2987
- const verdict = await judgeAccessToken(revocationRepo, payload);
2988
- if (!verdict.live) return refuse(c, accessTokenRefusalError(verdict.refusal));
2989
- judgedRoles = verdict.roles;
2990
- }
2991
- c.set("user", resolveRoles ? {
2992
- ...payload,
2993
- roles: judgedRoles ?? await resolveRoles(payload.uid)
2994
- } : payload);
2995
- return next();
2996
- } catch (error) {
2997
- if (isDatabaseUnreachable(error)) {
2998
- if (!warnIfDatabaseClosed(error)) logger.warn("[Auth] Could not resolve roles for an admin-gated request: the database is unreachable", { uid: payload.uid });
2999
- return refuse(c, ApiError.databaseUnavailable());
3000
- }
3001
- logger.warn("[Auth] Could not resolve roles for an admin-gated request", {
3002
- uid: payload.uid,
3003
- error
3004
- });
3005
- return refuse(c, ApiError.serviceUnavailable("Could not verify your permissions. Please try again.", "ROLE_LOOKUP_FAILED"));
3006
- }
3007
- c.set("user", payload);
3008
- return next();
3009
- };
3010
- }
3011
- /**
3012
- * Middleware that requires the user to hold the `admin` role, which holds
3013
- * every scope. Must be used AFTER requireAuth or on a route where user is
3014
- * guaranteed.
3015
- *
3016
- * Prefer `requireScope` from `./access` for anything a scope names: a scope
3017
- * can be granted to a narrower role and to a key, and `admin` cannot.
3018
- */
3019
- var requireAdmin = async (c, next) => {
3020
- const user = c.get("user");
3021
- if (!user) return refuse(c, ApiError.unauthorized("User not authenticated. requireAuth middleware is missing?"));
3022
- 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"));
3023
- return next();
3024
- };
3025
- /**
3026
- * Middleware that optionally extracts user from JWT via Authorization header.
3027
- * Does not return 401 if token is missing — allows anonymous access.
3028
- *
3029
- * Query-string tokens are NOT accepted here. Use {@link queryTokenAuth}
3030
- * on routes that need them.
3031
- */
3032
- var optionalAuth = async (c, next) => {
3033
- if (c.get("user")) return next();
3034
- const token = extractBearerToken(c.req.header("authorization"));
3035
- const jwt = jwtOf(c);
3036
- if (token !== void 0 && jwt.isConfigured()) {
3037
- const payload = await jwt.verifyAccessToken(token);
3038
- if (payload) c.set("user", payload);
3039
- }
3040
- return next();
3041
- };
3042
- /**
3043
- * Who a session token names, as `jwt` verifies it — or "nobody" rather than a
3044
- * throw on a backend with no JWT of its own, so a socket closes as
3045
- * unauthenticated, which is the outcome this describes.
3046
- */
3047
- async function userFromToken(jwt, token) {
3048
- if (!jwt.isConfigured()) return null;
3049
- return jwt.verifyAccessToken(token);
3050
- }
3051
- /**
3052
- * Extract user from token - for WebSocket authentication. Answers for the
3053
- * process default; a backend's own socket verifies with its own keys.
3054
- */
3055
- async function extractUserFromToken(token) {
3056
- return userFromToken(processJwtService, token);
3057
- }
3058
- /**
3059
- * Create a configurable auth middleware that handles:
3060
- * 1. Token extraction (via custom validator or JWT Bearer token)
3061
- * 2. RLS-scoped DataDriver via withAuth()
3062
- * 3. Enforcement (401 when requireAuth is true and no user)
3063
- *
3064
- * **Secure by default:** `requireAuth` defaults to `true`. Anonymous
3065
- * access is only allowed when the developer explicitly opts out by
3066
- * setting `requireAuth: false`, indicating that Postgres RLS policies
3067
- * fully control access.
3068
- *
3069
- * **Fail-closed:** The raw unscoped driver is never placed in the
3070
- * request context. Every code path either scopes via `withAuth()` or
3071
- * rejects the request. This prevents silent RLS bypass.
3072
- *
3073
- * This is the single source of truth for HTTP auth in Rebase.
3074
- * Use this instead of manually parsing tokens in route handlers.
3075
- */
3076
- function createAuthMiddleware(options) {
3077
- const { driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
3078
- return async (c, next) => {
3079
- const driver = resolveDriver ? resolveDriver(c) : baseDriver;
3080
- let credential = "none";
3081
- if (validator) try {
3082
- const authResult = await validator(c);
3083
- if (authResult && typeof authResult === "object") {
3084
- const id = "uid" in authResult ? authResult.uid : void 0;
3085
- if (id) {
3086
- const roles = authResult.roles || [];
3087
- credential = "session";
3088
- c.set("user", {
3089
- uid: id,
3090
- roles
3091
- });
3092
- const user = {
3093
- uid: id,
3094
- roles,
3095
- ...authResult
3096
- };
3097
- c.set("driver", await scopeDataDriver(driver, user));
3098
- } else c.set("driver", await scopeDataDriver(driver, {
3099
- uid: ANONYMOUS_USER_ID,
3100
- roles: ["anon"]
3101
- }));
3102
- } else if (authResult === true) {
3103
- credential = "session";
3104
- c.set("user", {
3105
- uid: "default",
3106
- roles: []
3107
- });
3108
- c.set("driver", await scopeDataDriver(driver, {
3109
- uid: "default",
3110
- roles: []
3111
- }));
3112
- } else c.set("driver", await scopeDataDriver(driver, {
3113
- uid: ANONYMOUS_USER_ID,
3114
- roles: ["anon"]
3115
- }));
3116
- } catch (error) {
3117
- return refuse(c, ApiError.unauthenticated("Unauthorized"));
3118
- }
3119
- else {
3120
- const token = extractBearerToken(c.req.header("authorization"));
3121
- if (token !== void 0) if (serviceKey && safeCompare(token, serviceKey)) {
3122
- const serviceUser = {
3123
- uid: "service",
3124
- roles: ["admin"]
3125
- };
3126
- credential = "service-key";
3127
- c.set("user", serviceUser);
3128
- try {
3129
- c.set("driver", await scopeDataDriver(driver, {
3130
- uid: "service",
3131
- roles: ["admin"]
3132
- }));
3133
- } catch (error) {
3134
- logger.error("[AUTH] RLS scoping failed for service key", { error });
3135
- return c.json({ error: {
3136
- message: "Internal authentication error",
3137
- code: "INTERNAL_ERROR"
3138
- } }, 500);
3139
- }
3140
- } else if (apiKeyStore && isApiKeyToken(token)) {
3141
- const result = await validateApiKey(c, token, {
3142
- store: apiKeyStore,
3143
- driver
3144
- });
3145
- if (result !== true) return result;
3146
- credential = "api-key";
3147
- } else {
3148
- const payload = await userFromToken(jwtOf(c), token);
3149
- if (payload) {
3150
- credential = "session";
3151
- c.set("user", payload);
3152
- try {
3153
- const user = {
3154
- uid: payload.uid,
3155
- roles: payload.roles,
3156
- isAnonymous: payload.isAnonymous === true,
3157
- claims: payload.claims
3158
- };
3159
- c.set("driver", await scopeDataDriver(driver, user));
3160
- } catch (error) {
3161
- logger.error("[AUTH] RLS scoping failed for authenticated user", { error });
3162
- return c.json({ error: {
3163
- message: "Internal authentication error",
3164
- code: "INTERNAL_ERROR"
3165
- } }, 500);
3166
- }
3167
- } else return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
3168
- }
3169
- else try {
3170
- c.set("driver", await scopeDataDriver(driver, {
3171
- uid: ANONYMOUS_USER_ID,
3172
- roles: ["anon"]
3173
- }));
3174
- } catch (error) {
3175
- logger.error("[AUTH] Failed to create anon-scoped driver", { error });
3176
- return c.json({ error: {
3177
- message: "Server configuration error",
3178
- code: "INTERNAL_ERROR"
3179
- } }, 500);
3180
- }
3181
- }
3182
- const refused = await applyImpersonation(c, {
3183
- credential,
3184
- driver
3185
- });
3186
- if (refused) return refused;
3187
- 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."));
3188
- return next();
3189
- };
3190
- }
3191
- /**
3192
- * Middleware that authenticates via a `?token=` query parameter.
3193
- *
3194
- * **Use sparingly.** Tokens in URLs leak into access logs, proxy logs,
3195
- * Referer headers, and browser history. This middleware exists solely for
3196
- * routes where the consumer cannot set HTTP headers — e.g. `<img src>`,
3197
- * `<a href>` for file downloads, or similar browser-native requests.
3198
- *
3199
- * Apply it **before** `requireAuth` or `optionalAuth` on the specific
3200
- * route that needs it. Those middlewares will see the user context this
3201
- * middleware sets and skip their own 401 check.
3202
- *
3203
- * @example
3204
- * ```ts
3205
- * router.get("/file/*", queryTokenAuth, readAuthMiddleware, handler);
3206
- * ```
3207
- */
3208
- var queryTokenAuth = async (c, next) => {
3209
- if (extractBearerToken(c.req.header("authorization")) !== void 0) return next();
3210
- const queryToken = c.req.query("token");
3211
- const jwt = jwtOf(c);
3212
- if (queryToken && jwt.isConfigured()) {
3213
- const payload = await jwt.verifyAccessToken(queryToken);
3214
- if (payload) c.set("user", payload);
3215
- }
3216
- return next();
3217
- };
3218
- /**
3219
- * Authorizes anonymous access to **public** storage objects (those under the
3220
- * public prefix), which are served token-less via stable, permanent URLs.
3221
- *
3222
- * Runs on the storage `/file/*` and `/metadata/*` routes, *after* the token
3223
- * middleware and *before* the read-auth gate. If the request is already
3224
- * authenticated (Bearer or scoped token), it does nothing. Otherwise, when the
3225
- * requested object path is public, it sets a minimal "public" principal so the
3226
- * downstream `requireAuth` gate lets the read through. Private paths are left
3227
- * untouched, so they still require a valid token.
3228
- *
3229
- * Public means the object the route will serve is public, so the decision is
3230
- * made on that object's canonical key, derived by the route's own function
3231
- * (`requestedStorageObject`). It used to be made on the raw path, through a
3232
- * check that strips a `scheme://`: `notes://public/secret.txt` read as
3233
- * `public/secret.txt` here while the route served the private
3234
- * `notes:/public/secret.txt`, and the authorize hook — not asked about this
3235
- * principal — never saw it.
3236
- */
3237
- var publicObjectAuth = async (c, next) => {
3238
- if (c.get("user")) return next();
3239
- const requested = tryRequestedStorageObject(storageRequestWildcard(c));
3240
- if (requested && isPublicStorageKey(requested.key)) c.set("user", PUBLIC_OBJECT_PRINCIPAL);
3241
- return next();
3242
- };
3243
- /**
3244
- * Helper to match paths for scoped file tokens.
3245
- * Matches exact paths or prefixes (exact folder prefixes or ending with /).
3246
- *
3247
- * The traversal check is defence in depth, not the load-bearing defence.
3248
- *
3249
- * A prefix comparison is a lie for any path containing `..`: `user1/../user2/x`
3250
- * starts with `user1/`, so a token scoped to `user1/` would authorize reading
3251
- * user2's file — and the storage controller would not object, because its own
3252
- * guard stops a path escaping the *bucket*, and that one resolves to `user2/x`,
3253
- * comfortably inside it. The grant is what would be escaped, and this is the
3254
- * only layer that knows what was granted.
3255
- *
3256
- * Nothing arrives here in that shape today: the URL parser resolves `..` (and
3257
- * `%2e%2e`, which the WHATWG spec treats as a dot for normalization) before
3258
- * Hono routes the request, so this comparison already runs on a resolved path.
3259
- * But that is a guarantee of the runtime rather than of this code, and it is
3260
- * one line to not depend on it. The same rule already guards the public-object
3261
- * path — see `isPublicStorageKey`.
3262
- */
3263
- function isPathMatch(requested, allowed) {
3264
- if (requested.split("/").some((seg) => seg === "..")) return false;
3265
- if (requested === allowed) return true;
3266
- if (allowed.endsWith("/")) return requested.startsWith(allowed);
3267
- return requested.startsWith(allowed + "/");
1892
+ resolvedHooks.afterUserDelete(uid).catch((err) => {
1893
+ logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
1894
+ });
3268
1895
  }
3269
1896
  /**
3270
- * Middleware that authenticates file-serving routes using scoped download tokens.
3271
- * It enforces that only scoped "file-read" tokens can access "/file/*" and "?token=" query params.
3272
- * 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.
3273
1900
  */
3274
- var fileTokenAuth = async (c, next) => {
3275
- const isFileRoute = c.req.path.includes("/storage/file/");
3276
- const authHeader = c.req.header("authorization");
3277
- const queryToken = c.req.query("token");
3278
- /**
3279
- * Decide what a valid download token entitles this request to.
3280
- *
3281
- * One function rather than a copy per token location: a Bearer token and a
3282
- * `?token=` are the same grant presented two ways, and the whole failure
3283
- * this guards against is two places disagreeing about what was granted.
3284
- *
3285
- * `"no-path"` is distinct from the two denials because the caller treats it
3286
- * differently — a request with no object path at all is not a denied read,
3287
- * it is a request this middleware has nothing to say about, and on
3288
- * `/metadata/*` it must still reach the downstream auth gate.
3289
- */
3290
- const evaluateGrant = (payload) => {
3291
- if (canonicalStorageId(c.req.query("storageId")) !== payload.storageId) return "deny-storage";
3292
- const rawPath = storageRequestWildcard(c);
3293
- if (!rawPath) return "no-path";
3294
- const requested = tryRequestedStorageObject(rawPath);
3295
- if (requested === null) return "deny-path";
3296
- return isPathMatch(`${requested.bucket}/${requested.key}`, payload.path) ? "grant" : "deny-path";
3297
- };
3298
- /**
3299
- * Which half of the scope failed is named in the message. The holder of the
3300
- * token already knows both values, so this reveals nothing to an attacker —
3301
- * and without it, a client that forgets to forward `?storageId=` on the file
3302
- * URL reports a "path mismatch" for a path that matches perfectly.
3303
- */
3304
- const denyMismatch = (outcome) => refuse(c, ApiError.forbidden(outcome === "deny-storage" ? "Forbidden: Scoped token storage mismatch" : "Forbidden: Scoped token path mismatch"));
3305
- const bearerToken = extractBearerToken(authHeader);
3306
- if (bearerToken !== void 0) {
3307
- const token = bearerToken;
3308
- const payload = await jwtOf(c).verifyDownloadToken(token);
3309
- if (payload) {
3310
- const outcome = evaluateGrant(payload);
3311
- if (outcome === "grant") {
3312
- c.set("user", DOWNLOAD_TOKEN_PRINCIPAL);
3313
- return next();
3314
- }
3315
- if (outcome !== "no-path") return denyMismatch(outcome);
3316
- }
3317
- if (isFileRoute) return refuse(c, ApiError.unauthenticated("Unauthorized: Access JWT not allowed on file routes"));
3318
- return next();
3319
- }
3320
- if (queryToken) {
3321
- const payload = await jwtOf(c).verifyDownloadToken(queryToken);
3322
- if (payload) {
3323
- const outcome = evaluateGrant(payload);
3324
- if (outcome === "grant") {
3325
- c.set("user", DOWNLOAD_TOKEN_PRINCIPAL);
3326
- 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);
3327
1925
  }
3328
- if (outcome !== "no-path") return denyMismatch(outcome);
3329
1926
  }
3330
- return refuse(c, ApiError.unauthenticated("Unauthorized: Invalid or unauthorized token"));
3331
- }
3332
- return next();
3333
- };
1927
+ };
1928
+ }
3334
1929
  //#endregion
3335
1930
  //#region src/auth/adapter-middleware.ts
1931
+ var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
3336
1932
  /**
3337
1933
  * What a request is refused with when `adapter.verifyRequest` throws: the
3338
1934
  * adapter's own answer when it gave one, otherwise a 401 — fail closed either
@@ -3361,6 +1957,11 @@ function createAdapterAuthMiddleware(options) {
3361
1957
  driver
3362
1958
  });
3363
1959
  if (result !== true) return result;
1960
+ const refusedSimulation = await applySimulation(c, {
1961
+ credential: "api-key",
1962
+ driver
1963
+ });
1964
+ if (refusedSimulation) return refusedSimulation;
3364
1965
  const refused = await applyImpersonation(c, {
3365
1966
  credential: "api-key",
3366
1967
  driver
@@ -3405,13 +2006,22 @@ function createAdapterAuthMiddleware(options) {
3405
2006
  return refuse(c, ApiError.internal("Server configuration error"));
3406
2007
  }
3407
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;
3408
2018
  const refused = await applyImpersonation(c, {
3409
- credential: !authenticatedUser ? "none" : authenticatedUser.uid === SERVICE_IDENTITY.uid ? "service-key" : "session",
2019
+ credential,
3410
2020
  driver,
3411
- resolveUser: adapter.resolveUser ? adapter.resolveUser.bind(adapter) : void 0
2021
+ resolveUser
3412
2022
  });
3413
2023
  if (refused) return refused;
3414
- 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."));
3415
2025
  return next();
3416
2026
  };
3417
2027
  }
@@ -3855,6 +2465,13 @@ function createDevEmailSink(options = {}) {
3855
2465
  }
3856
2466
  var activeSink;
3857
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
+ /**
3858
2475
  * Make `sink` the process's development mailbox.
3859
2476
  *
3860
2477
  * Returns the sink, so this can wrap the construction it replaces. In
@@ -3867,8 +2484,18 @@ function registerDevEmailSink(sink) {
3867
2484
  return sink;
3868
2485
  }
3869
2486
  activeSink = sink;
2487
+ sinksBySender.set(sink.sendEmail, sink);
3870
2488
  return sink;
3871
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
+ }
3872
2499
  /** The registered mailbox, if there is one. */
3873
2500
  function activeDevEmailSink() {
3874
2501
  return process.env.NODE_ENV === "production" ? void 0 : activeSink;
@@ -4348,6 +2975,11 @@ function createDataRateLimiter(config = {}, options = {}) {
4348
2975
  const resolveBucket = async (c) => {
4349
2976
  if (c.get("user")?.uid === SERVICE_IDENTITY.uid) return null;
4350
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
+ });
4351
2983
  const apiKey = c.get("apiKey");
4352
2984
  return bucketFor({
4353
2985
  apiKey,
@@ -6266,36 +4898,29 @@ function mountOtpRoutes(deps) {
6266
4898
  /**
6267
4899
  * May an OAuth identity be attached to a pre-existing account found *by email*?
6268
4900
  *
6269
- * Both sides have to be trustworthy:
6270
- *
6271
- * - the **provider** must have verified the address, or the caller has not
6272
- * shown they control it;
6273
- * - the **local account**'s address must have been verified too. Whoever
6274
- * made an unverified account never proved the address, and every way in
6275
- * they left on it — a password, an identity from a provider that did not
6276
- * vouch for the address — would go on opening the account the owner is
6277
- * 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.
6278
4903
  *
6279
- * The second rule used to exempt an account with no password, on the theory
6280
- * that one made by an OAuth sign-in holds no credential an attacker could have
6281
- * planted. It holds exactly one: the identity it was made with, which is an
6282
- * attacker's whenever the provider did not vouch for the address.
6283
- *
6284
- * A refusal is not a dead end. `POST /auth/link/<provider>` attaches the
6285
- * identity once the caller proves ownership by holding a session, and proving
6286
- * the address (a magic link, an email code, a password reset) verifies the
6287
- * 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.
6288
4914
  */
6289
4915
  function decideOAuthAutoLink(args) {
6290
4916
  if (args.providerEmailVerified !== true) return {
6291
4917
  allowed: false,
6292
4918
  reason: "provider-email-unverified"
6293
4919
  };
6294
- if (!args.existingUser.emailVerified) return {
6295
- allowed: false,
6296
- reason: args.existingUser.passwordHash ? "local-account-unverified" : "local-account-unverified-passwordless"
4920
+ return {
4921
+ allowed: true,
4922
+ provesAddress: !args.existingUser.emailVerified
6297
4923
  };
6298
- return { allowed: true };
6299
4924
  }
6300
4925
  /**
6301
4926
  * Is `redirectUri` one the operator authorised?
@@ -6751,10 +5376,18 @@ function createAuthRoutes(config) {
6751
5376
  providerEmailVerified: externalUser.emailVerified,
6752
5377
  existingUser: user
6753
5378
  });
6754
- 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.`, {
6755
5380
  reason: decision.reason,
6756
5381
  provider: provider.id
6757
5382
  });
5383
+ if (decision.provesAddress) {
5384
+ await confirmAddressOwnership(authRepo, user, null);
5385
+ user = {
5386
+ ...user,
5387
+ emailVerified: true,
5388
+ passwordHash: null
5389
+ };
5390
+ }
6758
5391
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, identityProfileData(externalUser));
6759
5392
  await authRepo.updateUser(user.id, {
6760
5393
  displayName: user.displayName || externalUser.displayName || void 0,
@@ -7718,9 +6351,10 @@ function createAdminUsersRoute(config) {
7718
6351
  });
7719
6352
  router.post("/users", requireScope("users:write"), async (c) => {
7720
6353
  const body = await c.req.json();
7721
- const { email, roles } = body;
6354
+ const { email } = body;
7722
6355
  if (!email) throw ApiError.badRequest("Email is required");
7723
- 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);
7724
6358
  if (await authRepo.getUserByEmail(normalizeEmail(email))) throw ApiError.conflict("A user with this email already exists");
7725
6359
  const prepResult = await prepareAdminUserValues(body, {
7726
6360
  authRepo,
@@ -7739,7 +6373,7 @@ function createAdminUsersRoute(config) {
7739
6373
  };
7740
6374
  if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
7741
6375
  const user = await authRepo.createUser(createData);
7742
- if (roles && Array.isArray(roles)) await authRepo.setUserRoles(user.id, roles);
6376
+ if (roles !== void 0) await authRepo.setUserRoles(user.id, roles);
7743
6377
  const delivery = await completeUserCreation(prepResult, (clearPassword) => finalizeAdminUserCreation({
7744
6378
  id: user.id,
7745
6379
  values: prepResult.values
@@ -7758,7 +6392,9 @@ function createAdminUsersRoute(config) {
7758
6392
  });
7759
6393
  router.put("/users/:uid", requireScope("users:write"), async (c) => {
7760
6394
  const uid = c.req.param("uid");
7761
- 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);
7762
6398
  if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
7763
6399
  if (disabled !== void 0) {
7764
6400
  if (typeof disabled !== "boolean") throw ApiError.badRequest("`disabled` must be true or false", "INVALID_INPUT");
@@ -7767,8 +6403,8 @@ function createAdminUsersRoute(config) {
7767
6403
  if (typeof authRepo.setUserDisabled !== "function") throw new ApiError(501, "NOT_SUPPORTED", "This backend's auth repository cannot disable accounts.");
7768
6404
  }
7769
6405
  assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
7770
- if (roles !== void 0 && Array.isArray(roles)) {
7771
- assertMayGrantRoles(c, roles.filter((role) => typeof role === "string"));
6406
+ if (roles !== void 0) {
6407
+ assertMayGrantRoles(c, roles);
7772
6408
  await assertRoleChangesAllowed(authRepo, [{
7773
6409
  uid,
7774
6410
  roles
@@ -7785,7 +6421,7 @@ function createAdminUsersRoute(config) {
7785
6421
  }
7786
6422
  if (Object.keys(updates).length > 0) await authRepo.updateUser(uid, updates);
7787
6423
  if (passwordHash) await replaceUserPassword(authRepo, uid, passwordHash);
7788
- if (roles !== void 0 && Array.isArray(roles)) await authRepo.setUserRoles(uid, roles);
6424
+ if (roles !== void 0) await authRepo.setUserRoles(uid, roles);
7789
6425
  if (typeof disabled === "boolean" && authRepo.setUserDisabled) {
7790
6426
  await authRepo.setUserDisabled(uid, disabled);
7791
6427
  if (disabled) await revokeAllSessions(authRepo, uid);
@@ -8285,7 +6921,7 @@ function scopesFromStoredPermissions(permissions, admin) {
8285
6921
  *
8286
6922
  * @module
8287
6923
  */
8288
- var TABLE$1 = "rebase.api_keys";
6924
+ var TABLE = "rebase.api_keys";
8289
6925
  /**
8290
6926
  * Drops `rebase.idx_api_keys_hash` — a plain btree on `key_hash`, next to the
8291
6927
  * UNIQUE (key_hash) constraint's own index — but only while a valid, unique,
@@ -8340,7 +6976,7 @@ var BOOT_COLUMNS = [
8340
6976
  var BOOT_STATE_SQL = (() => {
8341
6977
  const list = (names) => names.map((name) => `'${name}'`).join(", ");
8342
6978
  const target = `(SELECT oid FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')`;
8343
- 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`;
8344
6980
  return `
8345
6981
  SELECT
8346
6982
  (SELECT count(*) FROM pg_class c WHERE c.relnamespace = to_regnamespace('rebase')
@@ -8350,12 +6986,12 @@ SELECT
8350
6986
  ${columnCount(BOOT_COLUMNS)} AS columns,
8351
6987
  ${columnCount(["permissions"])} AS legacy,
8352
6988
  (SELECT count(*) FROM pg_class c
8353
- 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
8354
6990
  AND (c.relacl IS NULL
8355
6991
  OR EXISTS (SELECT 1 FROM aclexplode(c.relacl) a WHERE a.grantee = ${target})
8356
6992
  OR EXISTS (SELECT 1 FROM pg_attribute att, aclexplode(att.attacl) a
8357
6993
  WHERE att.attrelid = c.oid AND att.attacl IS NOT NULL AND a.grantee = ${target})))::int AS granted,
8358
- (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
8359
6995
  WHERE to_regnamespace('rebase') IS NOT NULL`.trim();
8360
6996
  })();
8361
6997
  /**
@@ -8440,13 +7076,13 @@ function rowToApiKey(row) {
8440
7076
  *
8441
7077
  * Returns `undefined` if the driver does not support `executeSql`.
8442
7078
  */
8443
- function createApiKeyStore(driver) {
7079
+ function createApiKeyStore(driver, options = {}) {
8444
7080
  const admin = driver.admin;
8445
7081
  if (!isSQLAdmin(admin)) {
8446
7082
  logger.warn("⚠️ [api-key-store] DataDriver does not support SQL admin — API keys will not be available.");
8447
7083
  return;
8448
7084
  }
8449
- 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);
8450
7086
  const ddl = createDdlBootstrapper(exec, "api-key-store");
8451
7087
  /**
8452
7088
  * Give every row stored before scopes existed the scopes it now holds.
@@ -8457,10 +7093,10 @@ function createApiKeyStore(driver) {
8457
7093
  * runtime that predates scopes still reads its own keys after a rollback.
8458
7094
  */
8459
7095
  async function backfillScopes() {
8460
- 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`);
8461
7097
  for (const row of rows) {
8462
7098
  const { scopes, roles } = scopesFromStoredPermissions(parseStoredPermissions(row.permissions), row.admin === true);
8463
- 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: [
8464
7100
  JSON.stringify(scopes),
8465
7101
  JSON.stringify(roles),
8466
7102
  row.id
@@ -8492,8 +7128,8 @@ function createApiKeyStore(driver) {
8492
7128
  return;
8493
7129
  }
8494
7130
  await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
8495
- await ddl.ensureObject(`Creating ${TABLE$1}`, `
8496
- CREATE TABLE IF NOT EXISTS ${TABLE$1} (
7131
+ await ddl.ensureObject(`Creating ${TABLE}`, `
7132
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
8497
7133
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
8498
7134
  name TEXT NOT NULL,
8499
7135
  kind TEXT NOT NULL DEFAULT 'service',
@@ -8514,35 +7150,42 @@ function createApiKeyStore(driver) {
8514
7150
  try {
8515
7151
  await exec(DROP_DUPLICATE_KEY_HASH_INDEX_SQL);
8516
7152
  } catch (err) {
8517
- 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)}`);
8518
7154
  }
8519
7155
  await ddl.ensureObject("Creating idx_api_keys_prefix", `
8520
7156
  CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
8521
- ON ${TABLE$1}(key_prefix)
7157
+ ON ${TABLE}(key_prefix)
8522
7158
  `);
8523
- await ddl.ensureObject(`Adding ${TABLE$1}.kind`, `
8524
- 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'
8525
7161
  `);
8526
- await ddl.ensureObject(`Adding ${TABLE$1}.scopes`, `
8527
- 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
8528
7164
  `);
8529
- await ddl.ensureObject(`Adding ${TABLE$1}.roles`, `
8530
- 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
8531
7167
  `);
8532
- await ddl.ensureObject(`Adding ${TABLE$1}.owner_uid`, `
8533
- 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
8534
7170
  `);
8535
7171
  await ddl.ensureObject("Creating idx_api_keys_owner", `
8536
7172
  CREATE INDEX IF NOT EXISTS idx_api_keys_owner
8537
- ON ${TABLE$1}(owner_uid) WHERE owner_uid IS NOT NULL
7173
+ ON ${TABLE}(owner_uid) WHERE owner_uid IS NOT NULL
8538
7174
  `);
8539
- if (!await ddl.isReadable(TABLE$1)) {
8540
- 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.`);
8541
7177
  return;
8542
7178
  }
8543
7179
  await ddl.step("Revoking end-user access to api_keys", () => exec(revokeInternalTableSql("rebase", "api_keys")));
8544
- if ((await exec(`SELECT 1 FROM information_schema.columns
8545
- 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);
8546
7189
  logger.debug("✅ API keys table ready");
8547
7190
  },
8548
7191
  async createApiKey(key, createdBy) {
@@ -8550,7 +7193,7 @@ function createApiKeyStore(driver) {
8550
7193
  const hash = hashKey(plaintext);
8551
7194
  const prefix = keyPrefix(plaintext);
8552
7195
  return {
8553
- ...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)
8554
7197
  VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10)
8555
7198
  RETURNING *`, { params: [
8556
7199
  key.name,
@@ -8568,23 +7211,23 @@ function createApiKeyStore(driver) {
8568
7211
  };
8569
7212
  },
8570
7213
  async findByKeyHash(hash) {
8571
- const rows = await exec(`SELECT * FROM ${TABLE$1}
7214
+ const rows = await exec(`SELECT * FROM ${TABLE}
8572
7215
  WHERE key_hash = $1
8573
7216
  LIMIT 1`, { params: [hash] });
8574
7217
  if (rows.length === 0) return null;
8575
7218
  return rowToApiKey(rows[0]);
8576
7219
  },
8577
7220
  async listApiKeys(filter) {
8578
- return (filter.kind === "personal" ? await exec(`SELECT * FROM ${TABLE$1}
7221
+ return (filter.kind === "personal" ? await exec(`SELECT * FROM ${TABLE}
8579
7222
  WHERE kind = 'personal' AND owner_uid = $1
8580
7223
  ORDER BY created_at DESC`, { params: [filter.owner_uid] }) : await exec(`
8581
- SELECT * FROM ${TABLE$1}
7224
+ SELECT * FROM ${TABLE}
8582
7225
  WHERE kind = 'service'
8583
7226
  ORDER BY created_at DESC
8584
7227
  `)).map((r) => toMasked(rowToApiKey(r)));
8585
7228
  },
8586
7229
  async getApiKeyById(id) {
8587
- const rows = await exec(`SELECT * FROM ${TABLE$1}
7230
+ const rows = await exec(`SELECT * FROM ${TABLE}
8588
7231
  WHERE id = $1
8589
7232
  LIMIT 1`, { params: [id] });
8590
7233
  if (rows.length === 0) return null;
@@ -8617,7 +7260,7 @@ function createApiKeyStore(driver) {
8617
7260
  if (setClauses.length === 0) return this.getApiKeyById(id);
8618
7261
  setClauses.push("updated_at = NOW()");
8619
7262
  params.push(id);
8620
- const rows = await exec(`UPDATE ${TABLE$1}
7263
+ const rows = await exec(`UPDATE ${TABLE}
8621
7264
  SET ${setClauses.join(", ")}
8622
7265
  WHERE id = $${paramIdx} AND kind = 'service'
8623
7266
  RETURNING *`, { params });
@@ -8625,17 +7268,17 @@ function createApiKeyStore(driver) {
8625
7268
  return toMasked(rowToApiKey(rows[0]));
8626
7269
  },
8627
7270
  async revokeApiKey(id, owner_uid) {
8628
- return (owner_uid === void 0 ? await exec(`UPDATE ${TABLE$1}
7271
+ return (owner_uid === void 0 ? await exec(`UPDATE ${TABLE}
8629
7272
  SET revoked_at = NOW(), updated_at = NOW()
8630
7273
  WHERE id = $1 AND kind = 'service' AND revoked_at IS NULL
8631
- RETURNING id`, { params: [id] }) : await exec(`UPDATE ${TABLE$1}
7274
+ RETURNING id`, { params: [id] }) : await exec(`UPDATE ${TABLE}
8632
7275
  SET revoked_at = NOW(), updated_at = NOW()
8633
7276
  WHERE id = $1 AND kind = 'personal' AND owner_uid = $2 AND revoked_at IS NULL
8634
7277
  RETURNING id`, { params: [id, owner_uid] })).length > 0;
8635
7278
  },
8636
7279
  async updateLastUsed(id) {
8637
7280
  try {
8638
- await exec(`UPDATE ${TABLE$1}
7281
+ await exec(`UPDATE ${TABLE}
8639
7282
  SET last_used_at = NOW()
8640
7283
  WHERE id = $1`, { params: [id] });
8641
7284
  } catch (err) {
@@ -8666,10 +7309,20 @@ function readScopesField(value) {
8666
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");
8667
7310
  return [...new Set(value.map((entry) => entry.trim()))];
8668
7311
  }
8669
- /** 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
+ */
8670
7317
  function readRolesField(value) {
8671
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");
8672
- 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");
8673
7326
  }
8674
7327
  /**
8675
7328
  * Refuse scopes that are malformed, unknown, aimed at a target that does not
@@ -8715,6 +7368,24 @@ function assertRolesGrantable(requested, minterRoles) {
8715
7368
  const beyond = requested.filter((role) => !minterRoles.includes(role));
8716
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 });
8717
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
+ }
8718
7389
  /** Read an optional `expires_at`: absent, null, or a future ISO-8601 instant. */
8719
7390
  function readExpiresAt(value, requireFuture) {
8720
7391
  if (value === void 0) return void 0;
@@ -8811,6 +7482,11 @@ function createApiKeyRoutes(options) {
8811
7482
  read: "keys:read",
8812
7483
  write: "keys:write"
8813
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
+ };
8814
7490
  router.get("/", async (c) => {
8815
7491
  const keys = await store.listApiKeys({ kind: "service" });
8816
7492
  return c.json({ keys });
@@ -8859,11 +7535,13 @@ function createApiKeyRoutes(options) {
8859
7535
  if (rateLimit !== void 0) updates.rate_limit = rateLimit;
8860
7536
  const expiresAt = readExpiresAt(body.expires_at, false);
8861
7537
  if (expiresAt !== void 0) updates.expires_at = expiresAt;
7538
+ assertMayManageKey(await existingServiceKey(id), minter.roles, callerScopes(c));
8862
7539
  const key = await store.updateApiKey(id, updates);
8863
7540
  if (!key) throw ApiError.notFound("API key not found");
8864
7541
  return c.json({ key });
8865
7542
  });
8866
7543
  router.delete("/:id", async (c) => {
7544
+ assertMayManageKey(await existingServiceKey(c.req.param("id")), caller(c).roles, callerScopes(c));
8867
7545
  if (!await store.revokeApiKey(c.req.param("id"))) throw ApiError.notFound("API key not found or already revoked");
8868
7546
  return c.json({ success: true });
8869
7547
  });
@@ -8933,194 +7611,6 @@ function createPersonalKeyRoutes(options) {
8933
7611
  return router;
8934
7612
  }
8935
7613
  //#endregion
8936
- //#region src/auth/sql-rate-limit-store.ts
8937
- var SCHEMA = "rebase";
8938
- var TABLE_NAME = "rate_limit_hits";
8939
- var TABLE = `${SCHEMA}.${TABLE_NAME}`;
8940
- /** How often expired buckets are deleted. */
8941
- var DEFAULT_SWEEP_MS = 300 * 1e3;
8942
- /**
8943
- * Build the store, or `undefined` if this driver cannot back it.
8944
- *
8945
- * Returning `undefined` rather than throwing matches `createCronStore`: a
8946
- * deployment on a driver with no SQL admin should degrade to per-process counts
8947
- * with a warning, not refuse to serve.
8948
- */
8949
- function createSqlRateLimitStore(driver, options = {}) {
8950
- const admin = driver.admin;
8951
- if (!isSQLAdmin(admin)) {
8952
- 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.");
8953
- return;
8954
- }
8955
- const exec = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
8956
- const ddl = createDdlBootstrapper(exec, "rate-limit-store");
8957
- const now = options.now ?? Date.now;
8958
- const sweepMs = options.sweepMs ?? DEFAULT_SWEEP_MS;
8959
- let sweepTimer;
8960
- let ready;
8961
- /**
8962
- * Create the table once, and remember whether it worked.
8963
- *
8964
- * Lazily rather than at boot because the store is constructed while routes
8965
- * are being mounted, and an `await` there would put a database round trip on
8966
- * the boot path of every deployment — including the ones that never take a
8967
- * request. The first request pays for it.
8968
- *
8969
- * The result is cached even on failure: a store that cannot create its table
8970
- * must not retry the DDL on every request, which would turn one broken
8971
- * permission into a per-request query storm.
8972
- *
8973
- * Readiness is decided by reading the table, never by the DDL not throwing —
8974
- * `ensureObject` routes through `step`, which logs a failure and returns
8975
- * normally. Keying on the absence of an exception would report a table that
8976
- * was never created as ready, and every later request would fall into the
8977
- * per-request error path instead of the one-time fallback.
8978
- */
8979
- async function ensureReady() {
8980
- if (!ready) ready = (async () => {
8981
- try {
8982
- await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
8983
- await ddl.ensureObject(`Creating ${TABLE}`, `
8984
- CREATE TABLE IF NOT EXISTS ${TABLE} (
8985
- key TEXT NOT NULL,
8986
- bucket BIGINT NOT NULL,
8987
- hits INTEGER NOT NULL DEFAULT 0,
8988
- expires_at TIMESTAMPTZ NOT NULL,
8989
- PRIMARY KEY (key, bucket)
8990
- )
8991
- `);
8992
- await ddl.ensureObject("Creating idx_rate_limit_hits_expiry", `
8993
- CREATE INDEX IF NOT EXISTS idx_rate_limit_hits_expiry
8994
- ON ${TABLE}(expires_at)
8995
- `);
8996
- const readable = await ddl.isReadable(TABLE);
8997
- if (readable) await ddl.step("Revoking end-user access to rate_limit_hits", () => exec(revokeInternalTableSql(SCHEMA, TABLE_NAME)));
8998
- 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.");
8999
- return readable;
9000
- } catch (err) {
9001
- 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) });
9002
- return false;
9003
- }
9004
- })();
9005
- return ready;
9006
- }
9007
- function startSweeping() {
9008
- if (sweepTimer) return;
9009
- sweepTimer = setInterval(() => {
9010
- exec(`DELETE FROM ${TABLE} WHERE expires_at < now()`).catch((err) => {
9011
- logger.debug("[rate-limit] Bucket sweep failed", { error: err instanceof Error ? err.message : String(err) });
9012
- });
9013
- }, sweepMs);
9014
- sweepTimer.unref?.();
9015
- }
9016
- return {
9017
- async hit(key, windowMs, limit) {
9018
- if (!await ensureReady()) return {
9019
- allowed: true,
9020
- remaining: limit,
9021
- retryAfterMs: 0
9022
- };
9023
- startSweeping();
9024
- const at = now();
9025
- const bucket = Math.floor(at / windowMs);
9026
- const carry = 1 - (at - bucket * windowMs) / windowMs;
9027
- const expiresAt = new Date((bucket + 2) * windowMs).toISOString();
9028
- let current;
9029
- let previous;
9030
- try {
9031
- const row = firstRow(await exec(`WITH bumped AS (
9032
- INSERT INTO ${TABLE} AS r (key, bucket, hits, expires_at)
9033
- VALUES ($1, $2, 1, $3)
9034
- ON CONFLICT (key, bucket)
9035
- DO UPDATE SET hits = r.hits + 1, expires_at = EXCLUDED.expires_at
9036
- RETURNING hits
9037
- )
9038
- SELECT
9039
- (SELECT hits FROM bumped) AS current,
9040
- COALESCE((SELECT hits FROM ${TABLE} WHERE key = $1 AND bucket = $4), 0) AS previous`, { params: [
9041
- key,
9042
- bucket,
9043
- expiresAt,
9044
- bucket - 1
9045
- ] }));
9046
- current = Number(row?.current ?? 1);
9047
- previous = Number(row?.previous ?? 0);
9048
- } catch (err) {
9049
- logger.debug("[rate-limit] Shared count unavailable for this request", { error: err instanceof Error ? err.message : String(err) });
9050
- return {
9051
- allowed: true,
9052
- remaining: limit,
9053
- retryAfterMs: 0
9054
- };
9055
- }
9056
- const estimate = current + previous * carry;
9057
- if (estimate > limit) {
9058
- exec(`UPDATE ${TABLE} SET hits = GREATEST(hits - 1, 0) WHERE key = $1 AND bucket = $2`, { params: [key, bucket] }).catch(() => {});
9059
- return {
9060
- allowed: false,
9061
- remaining: 0,
9062
- retryAfterMs: Math.max(0, (bucket + 1) * windowMs - at)
9063
- };
9064
- }
9065
- return {
9066
- allowed: true,
9067
- remaining: Math.max(0, Math.floor(limit - estimate)),
9068
- retryAfterMs: 0
9069
- };
9070
- },
9071
- dispose() {
9072
- if (sweepTimer) clearInterval(sweepTimer);
9073
- sweepTimer = void 0;
9074
- }
9075
- };
9076
- }
9077
- /**
9078
- * The first row of whatever shape this driver returns.
9079
- *
9080
- * `SqlExec` declares an array of rows, but drivers in this repository have also
9081
- * returned a `{ rows }` envelope through the same escape hatch. Reading one
9082
- * shape only is how a store silently counts nothing and reports every caller as
9083
- * being on their first request — a limiter that never limits, with no error
9084
- * anywhere. Tolerating both costs one line.
9085
- */
9086
- var firstRow = firstSqlRow;
9087
- //#endregion
9088
- //#region src/auth/resolve-rate-limit-store.ts
9089
- /**
9090
- * A refusal to boot, phrased as the variable to change.
9091
- *
9092
- * Its own class so `bootFromBundle` can present it as a configuration problem
9093
- * rather than a crash, matching `RoleConfigurationError`.
9094
- */
9095
- var RateLimitStoreConfigurationError = class extends Error {
9096
- hint;
9097
- constructor(message, hint) {
9098
- super(message);
9099
- this.hint = hint;
9100
- this.name = "RateLimitStoreConfigurationError";
9101
- }
9102
- };
9103
- /**
9104
- * Which store this process should build.
9105
- *
9106
- * A misspelled value **refuses to boot** rather than falling back to memory.
9107
- * Every other outcome here is silent — the whole failure mode of this setting is
9108
- * that nothing looks wrong — so the one moment it can be caught is the moment it
9109
- * is read. `REBASE_RATE_LIMIT_STORE=postgres` is the obvious thing to type and
9110
- * would otherwise be a per-replica limit wearing the shape of a shared one.
9111
- *
9112
- * A blank or whitespace-only value is *unset*, not invalid:
9113
- * `REBASE_RATE_LIMIT_STORE=${SOMETHING}` with `SOMETHING` undefined is the
9114
- * ordinary way to write a compose file, and it is how the platform neutralises a
9115
- * tenant's own variable.
9116
- */
9117
- function resolveRateLimitStoreKind(env) {
9118
- const raw = (env.REBASE_RATE_LIMIT_STORE ?? "").trim().toLowerCase();
9119
- if (raw === "") return "memory";
9120
- if (raw === "memory" || raw === "sql") return raw;
9121
- 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.");
9122
- }
9123
- //#endregion
9124
7614
  //#region src/auth/oauth-code-flow.ts
9125
7615
  /**
9126
7616
  * Build the request schema for an authorization-code provider.
@@ -10268,6 +8758,6 @@ var auth_exports = /* @__PURE__ */ __exportAll({
10268
8758
  verifyPassword: () => verifyPassword
10269
8759
  });
10270
8760
  //#endregion
10271
- 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 };
10272
8762
 
10273
- //# sourceMappingURL=auth-Dkvo2tTJ.js.map
8763
+ //# sourceMappingURL=auth-izI7AEzC.js.map