@rebasepro/server 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +1 -1
  2. package/bin/rebase-server.js +4 -2
  3. package/dist/{GCSStorageController-CLIJXwGS.js → GCSStorageController-BSiP1c-f.js} +57 -29
  4. package/dist/GCSStorageController-BSiP1c-f.js.map +1 -0
  5. package/dist/{S3StorageController-Dcuf8lMA.js → S3StorageController-CAwFRgjV.js} +19 -7
  6. package/dist/S3StorageController-CAwFRgjV.js.map +1 -0
  7. package/dist/api/ast-schema-editor.d.ts +127 -1
  8. package/dist/api/errors.d.ts +9 -0
  9. package/dist/api/live-schema-routes.d.ts +52 -8
  10. package/dist/api/logs-routes.d.ts +39 -1
  11. package/dist/api/openapi-generator.d.ts +17 -0
  12. package/dist/api/rest/api-generator.d.ts +158 -42
  13. package/dist/api/rest/auth-collection-writes.d.ts +85 -0
  14. package/dist/api/rest/field-access-query.d.ts +6 -2
  15. package/dist/api/rest/idempotency.d.ts +7 -1
  16. package/dist/api/rest/nested-write-access.d.ts +46 -0
  17. package/dist/api/rest/write-validation.d.ts +34 -2
  18. package/dist/api/types.d.ts +17 -1
  19. package/dist/{ast-schema-editor-CslO8Oje.js → ast-schema-editor-CWqS_sLJ.js} +411 -13
  20. package/dist/ast-schema-editor-CWqS_sLJ.js.map +1 -0
  21. package/dist/auth/access.d.ts +105 -0
  22. package/dist/auth/adapter-middleware.d.ts +2 -1
  23. package/dist/auth/address-ownership.d.ts +68 -0
  24. package/dist/auth/admin-roles-route.d.ts +4 -2
  25. package/dist/auth/admin-roles.d.ts +17 -20
  26. package/dist/auth/admin-user-ops.d.ts +35 -2
  27. package/dist/auth/admin-users-route.d.ts +1 -0
  28. package/dist/auth/api-keys/api-key-middleware.d.ts +56 -55
  29. package/dist/auth/api-keys/api-key-routes.d.ts +41 -11
  30. package/dist/auth/api-keys/api-key-store.d.ts +31 -8
  31. package/dist/auth/api-keys/api-key-types.d.ts +14 -16
  32. package/dist/auth/api-keys/http-operation.d.ts +19 -0
  33. package/dist/auth/api-keys/index.d.ts +11 -11
  34. package/dist/auth/api-keys/key-grant.d.ts +41 -0
  35. package/dist/auth/api-keys/legacy-permissions.d.ts +33 -0
  36. package/dist/auth/auth-hooks.d.ts +50 -7
  37. package/dist/auth/builtin-auth-adapter.d.ts +8 -0
  38. package/dist/auth/captcha.d.ts +5 -0
  39. package/dist/auth/cookie-utils.d.ts +7 -0
  40. package/dist/auth/deliverable-address.d.ts +6 -0
  41. package/dist/auth/email-change-routes.d.ts +41 -0
  42. package/dist/auth/expired-token-sweep.d.ts +67 -0
  43. package/dist/auth/impersonation.d.ts +110 -0
  44. package/dist/auth/index.d.ts +4 -2
  45. package/dist/auth/interfaces.d.ts +146 -65
  46. package/dist/auth/jwt.d.ts +66 -3
  47. package/dist/auth/magic-link-routes.d.ts +2 -6
  48. package/dist/auth/mfa-routes.d.ts +2 -9
  49. package/dist/auth/middleware.d.ts +17 -5
  50. package/dist/auth/oauth-signin-policy.d.ts +25 -8
  51. package/dist/auth/otp-routes.d.ts +2 -6
  52. package/dist/auth/passwordless-signup.d.ts +27 -0
  53. package/dist/auth/platform-token.d.ts +122 -0
  54. package/dist/auth/rate-limiter.d.ts +72 -1
  55. package/dist/auth/routes.d.ts +45 -0
  56. package/dist/auth/scope-routes.d.ts +22 -0
  57. package/dist/auth/session-routes.d.ts +18 -6
  58. package/dist/auth/token-revocation.d.ts +53 -1
  59. package/dist/auth/verify-credential.d.ts +28 -0
  60. package/dist/{auth-CCDpk2rn.js → auth-DMLngxn_.js} +2712 -711
  61. package/dist/auth-DMLngxn_.js.map +1 -0
  62. package/dist/backend-DTAOsLQc.js +30 -0
  63. package/dist/backend-DTAOsLQc.js.map +1 -0
  64. package/dist/backup/backup-common.d.ts +29 -0
  65. package/dist/backup/backup-routes.d.ts +24 -4
  66. package/dist/backup/backup-schedule.d.ts +33 -0
  67. package/dist/backup/backup-storage.d.ts +14 -0
  68. package/dist/backup/index.d.ts +2 -0
  69. package/dist/backup-CN0s50D2.js +444 -0
  70. package/dist/backup-CN0s50D2.js.map +1 -0
  71. package/dist/boot/bundle.d.ts +19 -0
  72. package/dist/boot/driver.d.ts +10 -0
  73. package/dist/boot/env.d.ts +51 -6
  74. package/dist/boot/fetch-bundle.d.ts +18 -1
  75. package/dist/boot/rls-audit-option.d.ts +26 -0
  76. package/dist/boot/security-headers.d.ts +26 -0
  77. package/dist/boot/sources.d.ts +1 -0
  78. package/dist/boot/static-routing.d.ts +56 -0
  79. package/dist/collection_patch-BRu-BvDv.js +472 -0
  80. package/dist/collection_patch-BRu-BvDv.js.map +1 -0
  81. package/dist/{contract-routes-eLxV0le1.js → contract-routes-fz8i4pxs.js} +17 -4
  82. package/dist/contract-routes-fz8i4pxs.js.map +1 -0
  83. package/dist/cron/cron-routes.d.ts +7 -2
  84. package/dist/cron/cron-scheduler.d.ts +146 -21
  85. package/dist/cron/cron-store.d.ts +76 -8
  86. package/dist/cron/index.d.ts +1 -1
  87. package/dist/{cron-loader-CQjvjpEw.js → cron-loader-CwaANlOG.js} +4 -4
  88. package/dist/cron-loader-CwaANlOG.js.map +1 -0
  89. package/dist/cron-routes-Bc-SB0Se.js +96 -0
  90. package/dist/cron-routes-Bc-SB0Se.js.map +1 -0
  91. package/dist/{cron-scheduler-COPQxlEq.js → cron-scheduler-CYQgco86.js} +427 -83
  92. package/dist/cron-scheduler-CYQgco86.js.map +1 -0
  93. package/dist/{cron-store-BYGZFNWk.js → cron-store-D2Q9-Aco.js} +139 -23
  94. package/dist/cron-store-D2Q9-Aco.js.map +1 -0
  95. package/dist/{ddl-bootstrap-CfNvxMuK.js → ddl-bootstrap-BaqMSa4Y.js} +3 -26
  96. package/dist/ddl-bootstrap-BaqMSa4Y.js.map +1 -0
  97. package/dist/email/index.d.ts +2 -2
  98. package/dist/email/link-base.d.ts +5 -4
  99. package/dist/email/smtp-email-service.d.ts +13 -1
  100. package/dist/email/templates.d.ts +31 -0
  101. package/dist/email/types.d.ts +29 -2
  102. package/dist/env.d.ts +25 -7
  103. package/dist/{errors-DWsX4yTd.js → errors-D6_y86c5.js} +102 -8
  104. package/dist/errors-D6_y86c5.js.map +1 -0
  105. package/dist/{function-loader-xnbDAPfa.js → function-loader-D7o5Epjj.js} +2 -2
  106. package/dist/{function-loader-xnbDAPfa.js.map → function-loader-D7o5Epjj.js.map} +1 -1
  107. package/dist/{function-routes-Chet4-lB.js → function-routes-CaNG4waN.js} +24 -12
  108. package/dist/function-routes-CaNG4waN.js.map +1 -0
  109. package/dist/functions/context.d.ts +17 -6
  110. package/dist/functions/guards.d.ts +22 -5
  111. package/dist/functions/index.d.ts +2 -2
  112. package/dist/functions/index.js +90 -36
  113. package/dist/functions/index.js.map +1 -1
  114. package/dist/{history-recorder-BQmB0P_j.js → history-recorder-Nr8zLvoU.js} +9 -7
  115. package/dist/history-recorder-Nr8zLvoU.js.map +1 -0
  116. package/dist/{history-store-CetkrBBD.js → history-store-rcAm_xFR.js} +2 -2
  117. package/dist/{history-store-CetkrBBD.js.map → history-store-rcAm_xFR.js.map} +1 -1
  118. package/dist/index.d.ts +14 -4
  119. package/dist/index.es.js +5733 -1551
  120. package/dist/index.es.js.map +1 -1
  121. package/dist/init/docs.d.ts +5 -2
  122. package/dist/init/health.d.ts +17 -2
  123. package/dist/init/shutdown.d.ts +18 -3
  124. package/dist/init.d.ts +54 -0
  125. package/dist/jobs/index.d.ts +2 -2
  126. package/dist/jobs/job-queue.d.ts +23 -2
  127. package/dist/jobs/job-store.d.ts +37 -5
  128. package/dist/jobs/types.d.ts +8 -6
  129. package/dist/{jobs-Bjr8DZAi.js → jobs-DqYNfquG.js} +306 -167
  130. package/dist/jobs-DqYNfquG.js.map +1 -0
  131. package/dist/{jwt-C4OW-DNq.js → jwt-R6bSPMjk.js} +114 -38
  132. package/dist/{jwt-C4OW-DNq.js.map → jwt-R6bSPMjk.js.map} +1 -1
  133. package/dist/{keys-Qfc4XieN.js → keys-GAVZqbqx.js} +18 -17
  134. package/dist/{keys-Qfc4XieN.js.map → keys-GAVZqbqx.js.map} +1 -1
  135. package/dist/{logger-DO2PZc4i.js → logger-D-S-hO5e.js} +26 -3
  136. package/dist/logger-D-S-hO5e.js.map +1 -0
  137. package/dist/{logs-routes-3EEzPjhl.js → logs-routes-DAdv37GI.js} +54 -11
  138. package/dist/logs-routes-DAdv37GI.js.map +1 -0
  139. package/dist/mcp/consent-page.d.ts +1 -1
  140. package/dist/mcp/mcp-routes.d.ts +45 -2
  141. package/dist/mcp/mcp-tools.d.ts +22 -10
  142. package/dist/mcp/oauth-metadata.d.ts +21 -16
  143. package/dist/mcp/oauth-routes.d.ts +34 -1
  144. package/dist/mcp/oauth-store.d.ts +29 -13
  145. package/dist/metrics/history-recorder.d.ts +1 -1
  146. package/dist/{openapi-generator-D8uFz-LW.js → openapi-generator-DAq_XVDu.js} +135 -22
  147. package/dist/openapi-generator-DAq_XVDu.js.map +1 -0
  148. package/dist/{proxy-Czngl3p9.js → proxy-qRlqeUmO.js} +2 -2
  149. package/dist/{proxy-Czngl3p9.js.map → proxy-qRlqeUmO.js.map} +1 -1
  150. package/dist/{query-parser-BleZmY18.js → query-parser-BgiKJKvc.js} +41 -82
  151. package/dist/query-parser-BgiKJKvc.js.map +1 -0
  152. package/dist/{request-timeout-C_4C2BeR.js → request-timeout-DgH7j8qO.js} +3 -3
  153. package/dist/{request-timeout-C_4C2BeR.js.map → request-timeout-DgH7j8qO.js.map} +1 -1
  154. package/dist/rls-audit/index.d.ts +4 -0
  155. package/dist/schema-edit/apply-schema-change.d.ts +63 -3
  156. package/dist/schema-edit/project-root.d.ts +3 -2
  157. package/dist/schema-edit/remote-source.d.ts +9 -4
  158. package/dist/{schema-editor-routes-DdLihzp0.js → schema-editor-routes-oIyuWl3L.js} +12 -7
  159. package/dist/schema-editor-routes-oIyuWl3L.js.map +1 -0
  160. package/dist/serve-spa.d.ts +58 -0
  161. package/dist/services/routed-realtime-service.d.ts +11 -0
  162. package/dist/soft-delete-params-BWPilMPF.js +59 -0
  163. package/dist/soft-delete-params-BWPilMPF.js.map +1 -0
  164. package/dist/{src-Br6ARbs6.js → src-CatHFUym.js} +439 -45
  165. package/dist/src-CatHFUym.js.map +1 -0
  166. package/dist/{src-1vL-I1Po.js → src-I3aG1PcY.js} +371 -81
  167. package/dist/src-I3aG1PcY.js.map +1 -0
  168. package/dist/storage/GCSStorageController.d.ts +13 -1
  169. package/dist/storage/LocalStorageController.d.ts +2 -0
  170. package/dist/storage/S3StorageController.d.ts +2 -0
  171. package/dist/storage/index.d.ts +2 -2
  172. package/dist/storage/keys.d.ts +12 -0
  173. package/dist/storage/property-limits.d.ts +41 -6
  174. package/dist/storage/rendition-cache.d.ts +11 -1
  175. package/dist/storage/request-keys.d.ts +82 -0
  176. package/dist/storage/requested-object.d.ts +74 -0
  177. package/dist/storage/routes.d.ts +36 -18
  178. package/dist/storage/tus-handler.d.ts +30 -5
  179. package/dist/storage/types.d.ts +36 -1
  180. package/dist/types-BfKcm9do.js.map +1 -1
  181. package/dist/utils/logger.d.ts +12 -0
  182. package/package.json +9 -9
  183. package/dist/GCSStorageController-CLIJXwGS.js.map +0 -1
  184. package/dist/S3StorageController-Dcuf8lMA.js.map +0 -1
  185. package/dist/admin-roles-vYdp_Pil.js +0 -36
  186. package/dist/admin-roles-vYdp_Pil.js.map +0 -1
  187. package/dist/admin_block-DxKLmdiv.js +0 -206
  188. package/dist/admin_block-DxKLmdiv.js.map +0 -1
  189. package/dist/ast-schema-editor-CslO8Oje.js.map +0 -1
  190. package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -65
  191. package/dist/auth-CCDpk2rn.js.map +0 -1
  192. package/dist/backup-DzI9jLwc.js +0 -192
  193. package/dist/backup-DzI9jLwc.js.map +0 -1
  194. package/dist/contract-routes-eLxV0le1.js.map +0 -1
  195. package/dist/cron-loader-CQjvjpEw.js.map +0 -1
  196. package/dist/cron-routes-B7CRGfiq.js +0 -72
  197. package/dist/cron-routes-B7CRGfiq.js.map +0 -1
  198. package/dist/cron-scheduler-COPQxlEq.js.map +0 -1
  199. package/dist/cron-store-BYGZFNWk.js.map +0 -1
  200. package/dist/ddl-bootstrap-CfNvxMuK.js.map +0 -1
  201. package/dist/errors-DWsX4yTd.js.map +0 -1
  202. package/dist/function-routes-Chet4-lB.js.map +0 -1
  203. package/dist/history-recorder-BQmB0P_j.js.map +0 -1
  204. package/dist/jobs-Bjr8DZAi.js.map +0 -1
  205. package/dist/logger-DO2PZc4i.js.map +0 -1
  206. package/dist/logs-routes-3EEzPjhl.js.map +0 -1
  207. package/dist/openapi-generator-D8uFz-LW.js.map +0 -1
  208. package/dist/query-parser-BleZmY18.js.map +0 -1
  209. package/dist/schema-editor-routes-DdLihzp0.js.map +0 -1
  210. package/dist/src-1vL-I1Po.js.map +0 -1
  211. package/dist/src-Br6ARbs6.js.map +0 -1
@@ -2,7 +2,7 @@ import { createRequire as __rebaseCreateRequire } from "module";
2
2
  import __rebaseProcess from "process";
3
3
  globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
- import { c as DEFAULT_STORAGE_SOURCE_KEY } from "./src-Br6ARbs6.js";
5
+ import { O as DEFAULT_STORAGE_SOURCE_KEY } from "./src-CatHFUym.js";
6
6
  //#region src/storage/keys.ts
7
7
  /**
8
8
  * Canonical storage keys and bucket names.
@@ -119,20 +119,6 @@ function canonicalStorageKey(rawKey) {
119
119
  return denotesDirectory && !key.endsWith("/") ? `${key}/` : key;
120
120
  }
121
121
  /**
122
- * Canonicalize, or return `null` when the key is not canonicalizable.
123
- *
124
- * For callers that must fail closed without an exception — the download-token
125
- * middleware compares a request path against a granted path, and a key it
126
- * cannot canonicalize simply matches nothing.
127
- */
128
- function tryCanonicalStorageKey(rawKey) {
129
- try {
130
- return canonicalStorageKey(rawKey);
131
- } catch {
132
- return null;
133
- }
134
- }
135
- /**
136
122
  * Canonicalize the storage *source* a request names (`?storageId=`), so that
137
123
  * "the default source" has exactly one spelling.
138
124
  *
@@ -224,7 +210,22 @@ function listingPrefix(rawPrefix) {
224
210
  function folderKey(rawPrefix) {
225
211
  return rawPrefix.replace(/^\/+/, "").replace(/\/+$/, "");
226
212
  }
213
+ /**
214
+ * A listing asked for paging a controller cannot honour: a page size below one,
215
+ * or a page token it never issued.
216
+ *
217
+ * Refused rather than coerced. A page size of 0 answered an empty page whose
218
+ * next token was the one it had been handed, so `while (pageToken)` never
219
+ * ended; a token of `-1` read before the first entry and crashed. The route
220
+ * turns this into a 400.
221
+ */
222
+ var InvalidListOptionsError = class extends Error {
223
+ constructor(message) {
224
+ super(message);
225
+ this.name = "InvalidListOptionsError";
226
+ }
227
+ };
227
228
  //#endregion
228
- export { canonicalStorageKey as a, tryCanonicalStorageKey as c, canonicalStorageId as i, InvalidStorageKeyError as n, folderKey as o, canonicalStorageBucket as r, listingPrefix as s, InvalidStorageBucketError as t };
229
+ export { canonicalStorageId as a, listingPrefix as c, canonicalStorageBucket as i, InvalidStorageBucketError as n, canonicalStorageKey as o, InvalidStorageKeyError as r, folderKey as s, InvalidListOptionsError as t };
229
230
 
230
- //# sourceMappingURL=keys-Qfc4XieN.js.map
231
+ //# sourceMappingURL=keys-GAVZqbqx.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"keys-Qfc4XieN.js","names":[],"sources":["../src/storage/keys.ts"],"sourcesContent":["/**\n * Canonical storage keys and bucket names.\n *\n * Storage is not under RLS, so a `storageAuthorize` hook is the whole access\n * control model — and a hook can only be correct if the key it is shown is the\n * key that is written. That is the invariant this module exists to hold: one\n * canonical string, computed once per request, handed to the hook, to the\n * controller, and to the download token alike.\n *\n * ## Why rejecting beats stripping\n *\n * The previous `sanitizeStorageKey` *stripped* `../` in a single pass. Two\n * things were wrong with that, and only one of them was the obvious one.\n *\n * The obvious one: a single pass is not a fixed point. `....//` contains `../`\n * at offset 2, so removing it leaves `../` behind — the sanitizer manufactured\n * the traversal it was there to remove. `users/alice/....//bob/x` came out as\n * `users/alice/../bob/x`, which a prefix hook reads as alice's (it starts with\n * `users/alice/`) and the filesystem reads as bob's. The hook approved one\n * object and the controller wrote another.\n *\n * The subtler one, and the reason this is a rewrite rather than a loop: even a\n * correct strip is a silent rewrite. A caller who asks to store at `a/../b` and\n * gets an object at `a/b` was not protected, they were misled — and every later\n * read, ownership row and audit line now refers to a path nobody chose. So a key\n * that means something other than what it says is refused (400), not repaired.\n *\n * Note what is NOT traversal under this rule: `....` is an ordinary directory\n * name, and `users/alice/....//bob/x` canonicalizes to\n * `users/alice/..../bob/x` — still comfortably inside alice's prefix, which is\n * exactly right. Only a real `..` segment is refused.\n */\n\nimport { DEFAULT_STORAGE_SOURCE_KEY } from \"@rebasepro/types\";\n\n/**\n * `path.posix.normalize`, without `node:path`.\n *\n * A storage key is a POSIX-shaped string that never touches a filesystem, so\n * reaching for `node:path` to fold `.` and `//` out of it was always a little\n * wrong on its own terms — on Windows the platform `path` would have applied\n * different rules to the same key. It also put the module that every storage\n * read, ownership row and audit line goes through on the list of things that\n * only run in a Node process.\n *\n * This is the algorithm Node implements, transcribed: segments are folded left\n * to right, `..` pops unless it would climb past the root of a relative path,\n * and both the leading and the trailing separator survive the round trip. The\n * `..` branch is unreachable from {@link canonicalStorageKey}, which refuses\n * those keys outright before it gets here — it exists so that this function is\n * *the* normalizer rather than a subset of one, and\n * `storage-keys.property.test.ts` holds it to that with a property test against\n * `path.posix.normalize` itself. Exported for exactly that — nothing outside\n * this module should be normalizing a key.\n */\nexport function normalizePosix(input: string): string {\n if (input.length === 0) return \".\";\n const isAbsolute = input.startsWith(\"/\");\n const trailingSeparator = input.endsWith(\"/\");\n\n const segments: string[] = [];\n for (const segment of input.split(\"/\")) {\n if (segment === \"\" || segment === \".\") continue;\n if (segment === \"..\") {\n if (segments.length > 0 && segments[segments.length - 1] !== \"..\") segments.pop();\n else if (!isAbsolute) segments.push(\"..\");\n continue;\n }\n segments.push(segment);\n }\n\n const joined = segments.join(\"/\");\n if (joined === \"\") {\n if (isAbsolute) return \"/\";\n return trailingSeparator ? \"./\" : \".\";\n }\n const withTrailing = trailingSeparator ? `${joined}/` : joined;\n return isAbsolute ? `/${withTrailing}` : withTrailing;\n}\n\n/** Longest key accepted, in UTF-16 code units. Matches the previous cap. */\nexport const MAX_STORAGE_KEY_LENGTH = 1024;\n\n/**\n * A key that cannot be canonicalized. Carries no path back to the caller\n * beyond what they sent, so it is safe to surface as a 400 message.\n */\nexport class InvalidStorageKeyError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"InvalidStorageKeyError\";\n }\n}\n\n/**\n * Canonicalize a caller-supplied storage key, or throw\n * {@link InvalidStorageKeyError}.\n *\n * Normalizations applied (safe, idempotent, and meaning-preserving):\n * - leading slashes removed — `/a/b` and `a/b` name the same object\n * - `.` segments and repeated slashes collapsed\n *\n * Refusals (the key means something other than what it says):\n * - any `..` segment, on either separator, at any depth\n * - null bytes\n * - keys longer than {@link MAX_STORAGE_KEY_LENGTH}\n *\n * A trailing slash is preserved: it is how the folder route marks a prefix.\n */\nexport function canonicalStorageKey(rawKey: string): string {\n if (rawKey.includes(\"\\0\")) {\n throw new InvalidStorageKeyError(\"Storage key contains a null byte.\");\n }\n if (rawKey.length > MAX_STORAGE_KEY_LENGTH) {\n throw new InvalidStorageKeyError(\n `Storage key exceeds the maximum length of ${MAX_STORAGE_KEY_LENGTH} characters.`\n );\n }\n\n // Split on both separators: `path.join` treats `\\` as a separator on\n // Windows, so `..\\` is traversal there even though POSIX reads it as a\n // filename. Checked against the RAW key, before any normalization — the\n // whole point is that `a/../b` is refused rather than quietly turned into\n // `b`.\n if (rawKey.split(/[\\\\/]/).some((segment) => segment === \"..\")) {\n throw new InvalidStorageKeyError(\n \"Storage key contains a '..' path segment. Keys must name an object directly.\"\n );\n }\n\n const withoutLeadingSlashes = rawKey.replace(/^\\/+/, \"\");\n if (withoutLeadingSlashes === \"\") return \"\";\n\n // Whether the key names a *directory* rather than an object: it ends with a\n // separator, or its last segment is the `.` that means \"this directory\".\n //\n // Recorded before normalizing, because normalization drops the\n // distinction in one of those two spellings and not the other: `public/.`\n // becomes `public` while `public/./` stays `public/`. That asymmetry breaks\n // the rule this module states — a trailing slash is preserved, because it is\n // how the folder route marks a prefix — and it is not cosmetic on `list`,\n // where a prefix of `public` also matches `publicity/` and hands back keys\n // the caller never asked for.\n const denotesDirectory = /(?:^|\\/)\\.?$/.test(withoutLeadingSlashes);\n\n // Safe now: with no `..` segment in the input, `normalize` can only collapse\n // `.` and duplicate slashes — it cannot climb.\n const normalized = normalizePosix(withoutLeadingSlashes);\n\n // `normalize(\".\")`, and `normalize(\"./\")` → `./`; neither names an object.\n if (normalized === \".\" || normalized === \"./\") return \"\";\n\n // Defensive: nothing reaching here still carries a leading `./`, since the\n // leading slashes are already gone and `normalize` only emits `./` for a\n // key that normalizes to nothing — which returned above.\n const key = normalized.replace(/^\\.\\//, \"\").replace(/^\\/+/, \"\");\n if (key === \"\") return \"\";\n return denotesDirectory && !key.endsWith(\"/\") ? `${key}/` : key;\n}\n\n/**\n * Canonicalize, or return `null` when the key is not canonicalizable.\n *\n * For callers that must fail closed without an exception — the download-token\n * middleware compares a request path against a granted path, and a key it\n * cannot canonicalize simply matches nothing.\n */\nexport function tryCanonicalStorageKey(rawKey: string): string | null {\n try {\n return canonicalStorageKey(rawKey);\n } catch {\n return null;\n }\n}\n\n/**\n * Canonicalize the storage *source* a request names (`?storageId=`), so that\n * \"the default source\" has exactly one spelling.\n *\n * The default source can be asked for three ways — the parameter omitted, sent\n * empty, or sent as the literal `(default)` — and all three resolve to the same\n * controller. Without a single spelling, the value derived when a download\n * token is minted and the value derived when it is presented can differ for the\n * same object, which is either a spurious 403 or, if the comparison is dropped\n * to stop those, no scoping at all.\n *\n * Deliberately *not* validated against the registry: this is a naming rule, not\n * an existence check, and it must give the same answer in `auth/` (which has no\n * registry) as in the storage routes. An id that names no source still\n * canonicalizes to itself and simply matches only itself.\n */\nexport function canonicalStorageId(rawStorageId: string | undefined | null): string {\n if (rawStorageId === undefined || rawStorageId === null) return DEFAULT_STORAGE_SOURCE_KEY;\n const trimmed = rawStorageId.trim();\n return trimmed === \"\" ? DEFAULT_STORAGE_SOURCE_KEY : trimmed;\n}\n\n/**\n * Longest bucket name accepted. 63 is the S3/GCS limit, and a local bucket is\n * one directory name, so the tighter of the two bounds everything.\n */\nexport const MAX_STORAGE_BUCKET_LENGTH = 63;\n\n/** A bucket name that does not name a bucket. See {@link canonicalStorageBucket}. */\nexport class InvalidStorageBucketError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"InvalidStorageBucketError\";\n }\n}\n\n/**\n * One path segment: letters, digits, `.`, `_`, `-`, first character\n * alphanumeric. Deliberately narrow — it is the intersection of what S3, GCS\n * and a filesystem directory all accept, and it makes `..`, `.tus-uploads`,\n * absolute paths and anything containing a separator unrepresentable.\n */\nconst STORAGE_BUCKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;\n\n/**\n * Canonicalize a caller-supplied bucket name, or throw\n * {@link InvalidStorageBucketError}.\n *\n * The bucket is the *other* caller-controlled routing value in an upload\n * request, and it was the one nobody validated. `LocalStorageController`\n * builds `join(basePath, bucket)` and then checks containment against that\n * result, so a bucket of `../../etc` moved the boundary rather than crossing\n * it — the guard passed because the guard's reference point was the attacker's.\n * The containment check now resolves against the storage root, and this is the\n * check at the route boundary that stops the value before it gets there.\n *\n * A bucket is configuration, not user data: there is no legitimate caller that\n * needs a separator, a leading dot, or a `..` in one. So this refuses rather\n * than repairs, exactly as {@link canonicalStorageKey} does — a rewritten\n * bucket would silently store the object somewhere the caller did not ask for.\n *\n * Returns `undefined` when the caller named no bucket (absent, or an empty\n * form field), which is how every controller spells \"use my default\". An empty\n * string used to reach `getFullPath` and resolve to the storage *root* rather\n * than the `default` bucket, which is a third place a bare key did not\n * round-trip.\n */\nexport function canonicalStorageBucket(rawBucket: string | undefined | null): string | undefined {\n if (rawBucket === undefined || rawBucket === null || rawBucket === \"\") return undefined;\n if (rawBucket.length > MAX_STORAGE_BUCKET_LENGTH) {\n throw new InvalidStorageBucketError(\n `Storage bucket exceeds the maximum length of ${MAX_STORAGE_BUCKET_LENGTH} characters.`\n );\n }\n if (!STORAGE_BUCKET_PATTERN.test(rawBucket)) {\n throw new InvalidStorageBucketError(\n \"Storage bucket must be a single name of letters, digits, '.', '_' or '-', \" +\n \"starting with a letter or digit.\"\n );\n }\n return rawBucket;\n}\n\n/**\n * The prefix an object store's listing should be given for a caller's key, and\n * the key a listing should hand back for a folder.\n *\n * The three controllers answer the same question in two different ways, and\n * they used to disagree about the answer. `LocalStorageController` resolves a\n * *directory* on disk, so `listObjects(\"products/images\")` and\n * `listObjects(\"products/images/\")` both list what is inside it. An object\n * store under `Delimiter: \"/\"` does not: `Prefix: \"products/images\"` matches\n * `products/images.txt` and `productsomething/`, and returns none of the files\n * inside `products/images/`. So the same call — the SDK's own documented one —\n * answered one thing against local dev and another against the S3 bucket in\n * production, which is the worst place for a divergence to live.\n *\n * `listingPrefix` is what to *send*: a trailing slash, always, because that is\n * the only spelling that means \"inside this folder\" to a delimiter listing.\n * `folderKey` is what to *return* for a common prefix: no trailing slash, the\n * form the local controller has always used, so a `fullPath` from a listing is\n * a key you can pass straight back to any of them.\n */\nexport function listingPrefix(rawPrefix: string): string | undefined {\n const key = rawPrefix.replace(/^\\/+/, \"\").replace(/\\/+$/, \"\");\n return key === \"\" ? undefined : `${key}/`;\n}\n\n/** @see listingPrefix */\nexport function folderKey(rawPrefix: string): string {\n return rawPrefix.replace(/^\\/+/, \"\").replace(/\\/+$/, \"\");\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"keys-GAVZqbqx.js","names":[],"sources":["../src/storage/keys.ts"],"sourcesContent":["/**\n * Canonical storage keys and bucket names.\n *\n * Storage is not under RLS, so a `storageAuthorize` hook is the whole access\n * control model — and a hook can only be correct if the key it is shown is the\n * key that is written. That is the invariant this module exists to hold: one\n * canonical string, computed once per request, handed to the hook, to the\n * controller, and to the download token alike.\n *\n * ## Why rejecting beats stripping\n *\n * The previous `sanitizeStorageKey` *stripped* `../` in a single pass. Two\n * things were wrong with that, and only one of them was the obvious one.\n *\n * The obvious one: a single pass is not a fixed point. `....//` contains `../`\n * at offset 2, so removing it leaves `../` behind — the sanitizer manufactured\n * the traversal it was there to remove. `users/alice/....//bob/x` came out as\n * `users/alice/../bob/x`, which a prefix hook reads as alice's (it starts with\n * `users/alice/`) and the filesystem reads as bob's. The hook approved one\n * object and the controller wrote another.\n *\n * The subtler one, and the reason this is a rewrite rather than a loop: even a\n * correct strip is a silent rewrite. A caller who asks to store at `a/../b` and\n * gets an object at `a/b` was not protected, they were misled — and every later\n * read, ownership row and audit line now refers to a path nobody chose. So a key\n * that means something other than what it says is refused (400), not repaired.\n *\n * Note what is NOT traversal under this rule: `....` is an ordinary directory\n * name, and `users/alice/....//bob/x` canonicalizes to\n * `users/alice/..../bob/x` — still comfortably inside alice's prefix, which is\n * exactly right. Only a real `..` segment is refused.\n */\n\nimport { DEFAULT_STORAGE_SOURCE_KEY } from \"@rebasepro/types\";\n\n/**\n * `path.posix.normalize`, without `node:path`.\n *\n * A storage key is a POSIX-shaped string that never touches a filesystem, so\n * reaching for `node:path` to fold `.` and `//` out of it was always a little\n * wrong on its own terms — on Windows the platform `path` would have applied\n * different rules to the same key. It also put the module that every storage\n * read, ownership row and audit line goes through on the list of things that\n * only run in a Node process.\n *\n * This is the algorithm Node implements, transcribed: segments are folded left\n * to right, `..` pops unless it would climb past the root of a relative path,\n * and both the leading and the trailing separator survive the round trip. The\n * `..` branch is unreachable from {@link canonicalStorageKey}, which refuses\n * those keys outright before it gets here — it exists so that this function is\n * *the* normalizer rather than a subset of one, and\n * `storage-keys.property.test.ts` holds it to that with a property test against\n * `path.posix.normalize` itself. Exported for exactly that — nothing outside\n * this module should be normalizing a key.\n */\nexport function normalizePosix(input: string): string {\n if (input.length === 0) return \".\";\n const isAbsolute = input.startsWith(\"/\");\n const trailingSeparator = input.endsWith(\"/\");\n\n const segments: string[] = [];\n for (const segment of input.split(\"/\")) {\n if (segment === \"\" || segment === \".\") continue;\n if (segment === \"..\") {\n if (segments.length > 0 && segments[segments.length - 1] !== \"..\") segments.pop();\n else if (!isAbsolute) segments.push(\"..\");\n continue;\n }\n segments.push(segment);\n }\n\n const joined = segments.join(\"/\");\n if (joined === \"\") {\n if (isAbsolute) return \"/\";\n return trailingSeparator ? \"./\" : \".\";\n }\n const withTrailing = trailingSeparator ? `${joined}/` : joined;\n return isAbsolute ? `/${withTrailing}` : withTrailing;\n}\n\n/** Longest key accepted, in UTF-16 code units. Matches the previous cap. */\nexport const MAX_STORAGE_KEY_LENGTH = 1024;\n\n/**\n * A key that cannot be canonicalized. Carries no path back to the caller\n * beyond what they sent, so it is safe to surface as a 400 message.\n */\nexport class InvalidStorageKeyError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"InvalidStorageKeyError\";\n }\n}\n\n/**\n * Canonicalize a caller-supplied storage key, or throw\n * {@link InvalidStorageKeyError}.\n *\n * Normalizations applied (safe, idempotent, and meaning-preserving):\n * - leading slashes removed — `/a/b` and `a/b` name the same object\n * - `.` segments and repeated slashes collapsed\n *\n * Refusals (the key means something other than what it says):\n * - any `..` segment, on either separator, at any depth\n * - null bytes\n * - keys longer than {@link MAX_STORAGE_KEY_LENGTH}\n *\n * A trailing slash is preserved: it is how the folder route marks a prefix.\n */\nexport function canonicalStorageKey(rawKey: string): string {\n if (rawKey.includes(\"\\0\")) {\n throw new InvalidStorageKeyError(\"Storage key contains a null byte.\");\n }\n if (rawKey.length > MAX_STORAGE_KEY_LENGTH) {\n throw new InvalidStorageKeyError(\n `Storage key exceeds the maximum length of ${MAX_STORAGE_KEY_LENGTH} characters.`\n );\n }\n\n // Split on both separators: `path.join` treats `\\` as a separator on\n // Windows, so `..\\` is traversal there even though POSIX reads it as a\n // filename. Checked against the RAW key, before any normalization — the\n // whole point is that `a/../b` is refused rather than quietly turned into\n // `b`.\n if (rawKey.split(/[\\\\/]/).some((segment) => segment === \"..\")) {\n throw new InvalidStorageKeyError(\n \"Storage key contains a '..' path segment. Keys must name an object directly.\"\n );\n }\n\n const withoutLeadingSlashes = rawKey.replace(/^\\/+/, \"\");\n if (withoutLeadingSlashes === \"\") return \"\";\n\n // Whether the key names a *directory* rather than an object: it ends with a\n // separator, or its last segment is the `.` that means \"this directory\".\n //\n // Recorded before normalizing, because normalization drops the\n // distinction in one of those two spellings and not the other: `public/.`\n // becomes `public` while `public/./` stays `public/`. That asymmetry breaks\n // the rule this module states — a trailing slash is preserved, because it is\n // how the folder route marks a prefix — and it is not cosmetic on `list`,\n // where a prefix of `public` also matches `publicity/` and hands back keys\n // the caller never asked for.\n const denotesDirectory = /(?:^|\\/)\\.?$/.test(withoutLeadingSlashes);\n\n // Safe now: with no `..` segment in the input, `normalize` can only collapse\n // `.` and duplicate slashes — it cannot climb.\n const normalized = normalizePosix(withoutLeadingSlashes);\n\n // `normalize(\".\")`, and `normalize(\"./\")` → `./`; neither names an object.\n if (normalized === \".\" || normalized === \"./\") return \"\";\n\n // Defensive: nothing reaching here still carries a leading `./`, since the\n // leading slashes are already gone and `normalize` only emits `./` for a\n // key that normalizes to nothing — which returned above.\n const key = normalized.replace(/^\\.\\//, \"\").replace(/^\\/+/, \"\");\n if (key === \"\") return \"\";\n return denotesDirectory && !key.endsWith(\"/\") ? `${key}/` : key;\n}\n\n/**\n * Canonicalize, or return `null` when the key is not canonicalizable.\n *\n * For callers that must fail closed without an exception — the download-token\n * middleware compares a request path against a granted path, and a key it\n * cannot canonicalize simply matches nothing.\n */\nexport function tryCanonicalStorageKey(rawKey: string): string | null {\n try {\n return canonicalStorageKey(rawKey);\n } catch {\n return null;\n }\n}\n\n/**\n * Canonicalize the storage *source* a request names (`?storageId=`), so that\n * \"the default source\" has exactly one spelling.\n *\n * The default source can be asked for three ways — the parameter omitted, sent\n * empty, or sent as the literal `(default)` — and all three resolve to the same\n * controller. Without a single spelling, the value derived when a download\n * token is minted and the value derived when it is presented can differ for the\n * same object, which is either a spurious 403 or, if the comparison is dropped\n * to stop those, no scoping at all.\n *\n * Deliberately *not* validated against the registry: this is a naming rule, not\n * an existence check, and it must give the same answer in `auth/` (which has no\n * registry) as in the storage routes. An id that names no source still\n * canonicalizes to itself and simply matches only itself.\n */\nexport function canonicalStorageId(rawStorageId: string | undefined | null): string {\n if (rawStorageId === undefined || rawStorageId === null) return DEFAULT_STORAGE_SOURCE_KEY;\n const trimmed = rawStorageId.trim();\n return trimmed === \"\" ? DEFAULT_STORAGE_SOURCE_KEY : trimmed;\n}\n\n/**\n * Longest bucket name accepted. 63 is the S3/GCS limit, and a local bucket is\n * one directory name, so the tighter of the two bounds everything.\n */\nexport const MAX_STORAGE_BUCKET_LENGTH = 63;\n\n/** A bucket name that does not name a bucket. See {@link canonicalStorageBucket}. */\nexport class InvalidStorageBucketError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"InvalidStorageBucketError\";\n }\n}\n\n/**\n * One path segment: letters, digits, `.`, `_`, `-`, first character\n * alphanumeric. Deliberately narrow — it is the intersection of what S3, GCS\n * and a filesystem directory all accept, and it makes `..`, `.tus-uploads`,\n * absolute paths and anything containing a separator unrepresentable.\n */\nconst STORAGE_BUCKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;\n\n/**\n * Canonicalize a caller-supplied bucket name, or throw\n * {@link InvalidStorageBucketError}.\n *\n * The bucket is the *other* caller-controlled routing value in an upload\n * request, and it was the one nobody validated. `LocalStorageController`\n * builds `join(basePath, bucket)` and then checks containment against that\n * result, so a bucket of `../../etc` moved the boundary rather than crossing\n * it — the guard passed because the guard's reference point was the attacker's.\n * The containment check now resolves against the storage root, and this is the\n * check at the route boundary that stops the value before it gets there.\n *\n * A bucket is configuration, not user data: there is no legitimate caller that\n * needs a separator, a leading dot, or a `..` in one. So this refuses rather\n * than repairs, exactly as {@link canonicalStorageKey} does — a rewritten\n * bucket would silently store the object somewhere the caller did not ask for.\n *\n * Returns `undefined` when the caller named no bucket (absent, or an empty\n * form field), which is how every controller spells \"use my default\". An empty\n * string used to reach `getFullPath` and resolve to the storage *root* rather\n * than the `default` bucket, which is a third place a bare key did not\n * round-trip.\n */\nexport function canonicalStorageBucket(rawBucket: string | undefined | null): string | undefined {\n if (rawBucket === undefined || rawBucket === null || rawBucket === \"\") return undefined;\n if (rawBucket.length > MAX_STORAGE_BUCKET_LENGTH) {\n throw new InvalidStorageBucketError(\n `Storage bucket exceeds the maximum length of ${MAX_STORAGE_BUCKET_LENGTH} characters.`\n );\n }\n if (!STORAGE_BUCKET_PATTERN.test(rawBucket)) {\n throw new InvalidStorageBucketError(\n \"Storage bucket must be a single name of letters, digits, '.', '_' or '-', \" +\n \"starting with a letter or digit.\"\n );\n }\n return rawBucket;\n}\n\n/**\n * The prefix an object store's listing should be given for a caller's key, and\n * the key a listing should hand back for a folder.\n *\n * The three controllers answer the same question in two different ways, and\n * they used to disagree about the answer. `LocalStorageController` resolves a\n * *directory* on disk, so `listObjects(\"products/images\")` and\n * `listObjects(\"products/images/\")` both list what is inside it. An object\n * store under `Delimiter: \"/\"` does not: `Prefix: \"products/images\"` matches\n * `products/images.txt` and `productsomething/`, and returns none of the files\n * inside `products/images/`. So the same call — the SDK's own documented one —\n * answered one thing against local dev and another against the S3 bucket in\n * production, which is the worst place for a divergence to live.\n *\n * `listingPrefix` is what to *send*: a trailing slash, always, because that is\n * the only spelling that means \"inside this folder\" to a delimiter listing.\n * `folderKey` is what to *return* for a common prefix: no trailing slash, the\n * form the local controller has always used, so a `fullPath` from a listing is\n * a key you can pass straight back to any of them.\n */\nexport function listingPrefix(rawPrefix: string): string | undefined {\n const key = rawPrefix.replace(/^\\/+/, \"\").replace(/\\/+$/, \"\");\n return key === \"\" ? undefined : `${key}/`;\n}\n\n/** @see listingPrefix */\nexport function folderKey(rawPrefix: string): string {\n return rawPrefix.replace(/^\\/+/, \"\").replace(/\\/+$/, \"\");\n}\n\n/**\n * A listing asked for paging a controller cannot honour: a page size below one,\n * or a page token it never issued.\n *\n * Refused rather than coerced. A page size of 0 answered an empty page whose\n * next token was the one it had been handed, so `while (pageToken)` never\n * ended; a token of `-1` read before the first entry and crashed. The route\n * turns this into a 400.\n */\nexport class InvalidListOptionsError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"InvalidListOptionsError\";\n }\n}\n"],"mappings":"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"}
@@ -139,7 +139,7 @@ function setLogLevel(level) {
139
139
  }
140
140
  function getMinLevel() {
141
141
  if (configuredLevel) return configuredLevel;
142
- const env = (hostEnv().LOG_LEVEL || "info").toLowerCase();
142
+ const env = (hostEnv().LOG_LEVEL || "info").trim().toLowerCase();
143
143
  if (env in LOG_PRIORITY) return env;
144
144
  return "info";
145
145
  }
@@ -326,6 +326,29 @@ function formatData(data) {
326
326
  for (const [key, val] of Object.entries(data)) out[key] = isSensitiveKey(key) ? REDACTED_VALUE : redactValue(val, 0, seen);
327
327
  return out;
328
328
  }
329
+ /**
330
+ * `JSON.stringify`, with the redaction a structured field gets.
331
+ *
332
+ * For a value that has to go *into the message*. Once an object is a string,
333
+ * `emit` can only strip `Failed query:` spans out of it: a sensitive key would
334
+ * pass, and so would the `query` and `params` own-properties `DrizzleQueryError`
335
+ * carries beside its message, which `JSON.stringify` copies and the message
336
+ * redaction never sees. Unlike the structured walk this honours `toJSON`, since
337
+ * the result is read as text. Never throws: a value that cannot be stringified
338
+ * is a marker, not a failed log line.
339
+ */
340
+ function stringifyForLog(value) {
341
+ try {
342
+ return JSON.stringify(value, (key, val) => {
343
+ if (isSensitiveKey(key)) return REDACTED_VALUE;
344
+ if (val instanceof Error) return serialiseError(val);
345
+ if (typeof val === "string") return redactSensitiveText(val);
346
+ return val;
347
+ });
348
+ } catch {
349
+ return "[unserialisable]";
350
+ }
351
+ }
329
352
  var sinks = /* @__PURE__ */ new Set();
330
353
  /**
331
354
  * Tee this logger somewhere else. Returns the unsubscribe.
@@ -458,6 +481,6 @@ function describeOneCause(value) {
458
481
  return parts.length > 0 ? parts.join(" ") : void 0;
459
482
  }
460
483
  //#endregion
461
- export { redactSensitiveText as a, rawQueryLoggingEnabled as i, describeCauseChain as n, setLogLevel as o, logger as r, hostEnv as s, addLogSink as t };
484
+ export { redactSensitiveText as a, hostEnv as c, rawQueryLoggingEnabled as i, describeCauseChain as n, setLogLevel as o, logger as r, stringifyForLog as s, addLogSink as t };
462
485
 
463
- //# sourceMappingURL=logger-DO2PZc4i.js.map
486
+ //# sourceMappingURL=logger-D-S-hO5e.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger-D-S-hO5e.js","names":[],"sources":["../src/utils/host.ts","../src/utils/logger.ts"],"sourcesContent":["/**\n * The host globals this package reads, behind functions that do not assume Node.\n *\n * `process` is not defined on workerd, on Deno Deploy without the compat flag,\n * or in a browser. That matters here for one specific reason: the portable\n * authoring surface (`@rebasepro/server/functions`) reaches the logger and the\n * error handler, and a bare `process.env.NODE_ENV` inside either of them turns\n * the first log line of a request into a `ReferenceError` on a runtime that has\n * no `process` — a failure that reads as \"the framework crashed\" rather than\n * \"this runtime has no process object\".\n *\n * Nothing here throws and nothing here is async. A runtime that cannot answer\n * gets the empty answer, because every caller in this file's blast radius is\n * choosing a log level or a format, and the safe default for both is the\n * development one.\n *\n * @module\n */\n\n/**\n * Where an adapter with no `process` can publish the environment.\n *\n * Cloudflare Workers hand the environment to the *request*, not to the module,\n * so there is no global to read at import time. An edge adapter that has\n * already seen a request can stash the bag here and every contextless reader in\n * the framework — the logger, chiefly — starts answering correctly.\n *\n * `Symbol.for` rather than a module-local for the same reason the singleton\n * uses it: more than one copy of this module can be loaded into one process,\n * and a module-local would leave every copy but the writer's blind. See\n * `../singleton.ts`.\n *\n * Request-scoped code should NOT read this. Use `getEnv(c)` from\n * `@rebasepro/server/functions`, which reads the binding attached to the\n * request it is serving — the only correct source on a runtime where two\n * concurrent requests can carry different bindings.\n */\nconst ENV_SLOT = Symbol.for(\"@rebasepro/server:host-env\");\n\ntype GlobalWithEnv = typeof globalThis & {\n [ENV_SLOT]?: Record<string, string | undefined>;\n process?: { env?: Record<string, string | undefined> };\n};\n\n/**\n * The process environment, or the closest thing this runtime has to one.\n *\n * Order: a bag published by {@link setHostEnv} first, because an adapter that\n * set one knows more than the ambient globals do; then `process.env`; then\n * nothing.\n */\nexport function hostEnv(): Record<string, string | undefined> {\n const global = globalThis as GlobalWithEnv;\n return global[ENV_SLOT] ?? global.process?.env ?? {};\n}\n\n/**\n * Read one environment variable without touching `process` directly.\n *\n * Trimmed, and blank is treated as absent — a variable declared with no value\n * is the ordinary way to write a compose file or a `.env` line, and every\n * caller in this package means \"unset\" by it. See `resolveFunctionsTimeoutMs`,\n * which learned that the hard way.\n */\nexport function hostEnvVar(name: string): string | undefined {\n const raw = hostEnv()[name];\n if (typeof raw !== \"string\") return undefined;\n const trimmed = raw.trim();\n return trimmed === \"\" ? undefined : trimmed;\n}\n\n/**\n * Publish an environment bag for contextless readers.\n *\n * Called by an adapter for a runtime whose environment is not ambient. Merges\n * rather than replaces, so two adapters (or an adapter plus a test) do not\n * silently erase each other's variables.\n */\nexport function setHostEnv(env: Record<string, string | undefined>): void {\n const global = globalThis as GlobalWithEnv;\n global[ENV_SLOT] = {\n ...(global[ENV_SLOT] ?? {}),\n ...env\n };\n}\n\n/** @internal Test seam — drops anything {@link setHostEnv} published. */\nexport function _clearHostEnv(): void {\n delete (globalThis as GlobalWithEnv)[ENV_SLOT];\n}\n\ntype GlobalWithStdio = typeof globalThis & {\n process?: {\n stdout?: { write?: (chunk: string) => unknown };\n stderr?: { write?: (chunk: string) => unknown };\n };\n};\n\n/**\n * Write one already-formatted line to the process's output.\n *\n * `process.stdout.write` is preferred where it exists because it is the only\n * one of the two that does not append its own formatting to a line that is\n * already a complete JSON document — `console.log` on Node is\n * `process.stdout.write` plus `util.format`, and `util.format` will happily\n * reinterpret a `%s` that appeared inside a user's log message.\n *\n * Where it does not exist, `console` is the runtime's log sink and is what its\n * platform collects.\n */\nexport function writeLine(stream: \"out\" | \"err\", line: string): void {\n const proc = (globalThis as GlobalWithStdio).process;\n const sink = stream === \"err\" ? proc?.stderr : proc?.stdout;\n if (typeof sink?.write === \"function\") {\n sink.write(line + \"\\n\");\n return;\n }\n if (stream === \"err\") console.error(line);\n else console.log(line);\n}\n","/**\n * Structured Logger for Rebase Backend\n *\n * Outputs JSON lines when `NODE_ENV=production`, human-readable prefixed\n * lines otherwise. Designed to work with Google Cloud Logging severity levels.\n *\n * Every line — message and data, at any depth — passes through the redaction\n * below, which strips Drizzle's `Failed query: … / params: …` wrapper and the\n * values of secret-looking keys. See the block above `serialiseError`.\n *\n * Usage:\n * import { logger } from \"./utils/logger\";\n * logger.info(\"Server started\", { port: 3001 });\n * logger.error(\"Request failed\", { path: \"/api/test\", error: err });\n *\n * Every host global goes through `./host`, and that is load-bearing rather than\n * tidy: this module is reachable from `@rebasepro/server/functions`, the\n * authoring surface that has to import cleanly on a runtime with no `process`.\n * A bare `process.env.NODE_ENV` here would make the first log line of the first\n * request on workerd a `ReferenceError`.\n */\nimport { hostEnv, writeLine } from \"./host\";\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\n/** Google Cloud Logging severity strings. */\nconst GCP_SEVERITY: Record<LogLevel, string> = {\n debug: \"DEBUG\",\n info: \"INFO\",\n warn: \"WARNING\",\n error: \"ERROR\"\n};\n\nconst LOG_PRIORITY: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3\n};\n\nexport interface LogEntry {\n severity: string;\n message: string;\n timestamp: string;\n [key: string]: unknown;\n}\n\nexport interface Logger {\n debug(message: string, data?: Record<string, unknown>): void;\n info(message: string, data?: Record<string, unknown>): void;\n warn(message: string, data?: Record<string, unknown>): void;\n error(message: string, data?: Record<string, unknown>): void;\n child(defaultFields: Record<string, unknown>): Logger;\n}\n\nfunction isProduction(): boolean {\n return hostEnv().NODE_ENV === \"production\";\n}\n\n/**\n * An explicit level from `config.logging.level`, when a project set one.\n *\n * Outranks `LOG_LEVEL` because it is the more specific statement: an\n * environment variable is the deployment's default, and this is the\n * application saying what it wants regardless of where it runs.\n *\n * There used to be a second, separate mechanism for this — `utils/logging.ts`\n * reassigned `console.debug`/`console.log`/`console.warn` to no-ops — and the\n * two disagreed in a way nobody could have guessed from either: `LOG_LEVEL=warn`\n * silenced this logger's info lines *and* every `console.log` in the process,\n * including a dependency's, including a project's own debugging. It also could\n * not be undone, because the originals were gone.\n */\nlet configuredLevel: LogLevel | undefined;\n\n/**\n * Set the level from configuration. `undefined` returns to `LOG_LEVEL`.\n *\n * Read per line rather than captured at construction, so a logger created\n * before configuration is read still honours it — which the singleton below\n * always is.\n */\nexport function setLogLevel(level?: LogLevel): void {\n configuredLevel = level;\n}\n\nfunction getMinLevel(): LogLevel {\n if (configuredLevel) return configuredLevel;\n const env = (hostEnv().LOG_LEVEL || \"info\").trim().toLowerCase();\n if (env in LOG_PRIORITY) return env as LogLevel;\n return \"info\";\n}\n\n// ── Redaction ───────────────────────────────────────────────────────\n//\n// Drizzle builds every query failure as\n// `Failed query: ${query}\\nparams: ${params}` (drizzle-orm/errors.js), so the\n// statement *and* every bound value ride along in `.message` and `.stack` of\n// whatever a driver rethrows — an email and a bcrypt hash reach stdout the\n// moment a registration hits a unique violation. The redaction lives here, in\n// the one function every log line passes through, rather than at the ~124\n// `{ error: … }` call sites: a per-site rule is what produced the leak (one\n// file suppressed the stack, four others did not), and the next caller would\n// reintroduce it. Nothing above this line needs to know about it.\n\nconst FAILED_QUERY_MARKER = \"Failed query:\";\n/**\n * The marker says how to lift it.\n *\n * Every DDL, RLS and CDC failure ends at this string, and the statement is the\n * whole diagnosis — three of them landed in one boot of a two-database project,\n * each a dead end. The switch existed; nothing named it, in the log or in the\n * docs, so `grep -rn REBASE_LOG_RAW_QUERIES` over the documentation, the\n * templates and the agent skills came back empty.\n */\nconst REDACTED_QUERY =\n \"Failed query: [redacted — set REBASE_LOG_RAW_QUERIES=true in development to see it]\";\nconst REDACTED_VALUE = \"[redacted]\";\n\n/**\n * Key fragments whose values are never safe to publish. Compared against the\n * key with separators and case removed, so `api_key`, `apiKey` and `API-KEY`\n * all match `apikey`.\n */\nconst SENSITIVE_KEY_FRAGMENTS = [\n \"password\",\n \"passwd\",\n \"passphrase\",\n \"secret\",\n \"token\",\n \"apikey\",\n \"authorization\",\n \"credential\",\n \"cookie\",\n \"privatekey\",\n \"sessionid\"\n];\n\n/** Longest structure the redactor will walk before giving up. */\nconst MAX_REDACT_DEPTH = 8;\n\nfunction isSensitiveKey(key: string): boolean {\n const normalised = key.toLowerCase().replace(/[^a-z0-9]/g, \"\");\n return SENSITIVE_KEY_FRAGMENTS.some(fragment => normalised.includes(fragment));\n}\n\n/**\n * Whether a SQL statement may be written out at all.\n *\n * The escape hatch for the `Failed query:` strip — the statement is the fastest\n * way to diagnose a failing query on a developer machine. Ignored in\n * production, so a runtime that inherits the variable cannot leak because of\n * it, and it never re-enables the key deny-list.\n *\n * Exported because it is the *only* answer to \"may this process print SQL\", and\n * a driver that wants to trace what it executes has to ask the same question.\n * The Postgres driver used to decide for itself, with a `console.debug` gated\n * on `NODE_ENV` alone: every statement went to stdout whatever `LOG_LEVEL`\n * said, and it went there without passing through the redaction that lives in\n * this file.\n */\nexport function rawQueryLoggingEnabled(): boolean {\n return hostEnv().NODE_ENV !== \"production\"\n && hostEnv().REBASE_LOG_RAW_QUERIES === \"true\";\n}\n\n/**\n * Strip every `Failed query: … / params: …` span out of a message or stack,\n * keeping the surrounding text (including stack frames, which carry no user\n * data). When no `params:` line follows the marker the rest of the string is\n * dropped: a statement of unknown extent is treated as sensitive rather than\n * guessed at.\n *\n * Idempotent, and it has to be: an already-redacted span still starts with the\n * marker but has no `params:` line, so a second pass over it would fall into\n * the drop-the-rest branch and eat the stack frames behind it. Redaction runs\n * more than once on the same string in practice — the cron scheduler redacts\n * before persisting and then logs the result.\n */\nexport function redactSensitiveText(text: string): string {\n if (!text.includes(FAILED_QUERY_MARKER) || rawQueryLoggingEnabled()) return text;\n\n let out = text;\n let idx = out.indexOf(FAILED_QUERY_MARKER);\n while (idx !== -1) {\n if (out.startsWith(REDACTED_QUERY, idx)) {\n idx = out.indexOf(FAILED_QUERY_MARKER, idx + REDACTED_QUERY.length);\n continue;\n }\n const paramsIdx = out.indexOf(\"\\nparams:\", idx);\n let end: number;\n if (paramsIdx === -1) {\n end = out.length;\n } else {\n const eol = out.indexOf(\"\\n\", paramsIdx + 1);\n end = eol === -1 ? out.length : eol;\n }\n out = out.slice(0, idx) + REDACTED_QUERY + out.slice(end);\n idx = out.indexOf(FAILED_QUERY_MARKER, idx + REDACTED_QUERY.length);\n }\n return out;\n}\n\n/**\n * Diagnostic own-properties worth carrying up out of an error.\n *\n * These are what a socket failure actually says: `ECONNREFUSED` with the\n * `address` and `port` it was refused on, `ENOTFOUND` with the hostname that\n * did not resolve. They live as own-properties on the Node error rather than in\n * its message, so a serialiser that copies only `message` and `stack` prints a\n * boot failure that names no host, no port and no reason.\n *\n * Deliberately a fixed list rather than \"every own-property\": `DrizzleQueryError`\n * carries `query` and `params` beside its message, and copying those would put\n * the statement and its bound values — an email, a bcrypt hash — straight back\n * on stdout, which is what the redaction above exists to prevent. Postgres's own\n * `detail` and `hint` are left out for the same reason: `23505` reports\n * `Key (email)=(a@b.c) already exists.`, which is a row's contents.\n */\nconst ERROR_DETAIL_KEYS = [\"code\", \"errno\", \"syscall\", \"address\", \"port\", \"hostname\"] as const;\n\n/** How far the cause chain is followed before the serialiser gives up. */\nconst MAX_CAUSE_DEPTH = 4;\n\n/** How many of an `AggregateError`'s children are serialised. */\nconst MAX_AGGREGATE_ERRORS = 4;\n\n/**\n * Serialise an Error into a plain object, with the query text redacted out of\n * its message and stack. `query`/`params` own-properties — which\n * `DrizzleQueryError` carries beside the message — are deliberately not copied.\n *\n * The chain matters more than the top. Everything a driver rethrows is a\n * wrapper: Drizzle's is `Failed query: SELECT 1` with a stack through drizzle\n * internals, and the sentence that says what is wrong — `connect ECONNREFUSED\n * 127.0.0.1:5432`, `password authentication failed for user \"app\"` — sits in\n * `.cause`, or inside the `AggregateError.errors` that `net` raises when every\n * resolved address is refused. Serialising only the wrapper is why a boot\n * against a stopped database used to log a redacted query and nothing else.\n *\n * Handles non-Error values gracefully.\n */\nfunction serialiseError(value: unknown, depth = 0): Record<string, unknown> {\n const isError = value instanceof Error;\n // A cause is not always an Error: drivers throw plain `{ code, address,\n // port }` bags, and stringifying one yields `[object Object]`, which is\n // worse than nothing. Only the named fields are copied out of it — the same\n // fixed list, for the same reason.\n const isDetailBag = !isError && depth > 0 && Boolean(value) && typeof value === \"object\" && !Array.isArray(value);\n if (!isError && !isDetailBag) {\n return { value: redactSensitiveText(String(value)) };\n }\n\n const own = value as Record<string, unknown>;\n const out: Record<string, unknown> = isError\n ? {\n name: (value as Error).name,\n message: redactSensitiveText((value as Error).message),\n stack: (value as Error).stack ? redactSensitiveText((value as Error).stack as string) : undefined\n }\n : {\n ...(typeof own.name === \"string\" ? { name: own.name } : {}),\n ...(typeof own.message === \"string\" ? { message: redactSensitiveText(own.message) } : {})\n };\n\n for (const key of ERROR_DETAIL_KEYS) {\n const detail = own[key];\n if (detail === undefined || detail === null) continue;\n if (typeof detail === \"object\") continue;\n out[key] = typeof detail === \"string\" ? redactSensitiveText(detail) : detail;\n }\n\n if (depth >= MAX_CAUSE_DEPTH) return out;\n\n if (own.cause !== undefined && own.cause !== null) {\n out.cause = serialiseError(own.cause, depth + 1);\n }\n const aggregated = own.errors;\n if (Array.isArray(aggregated) && aggregated.length > 0) {\n out.errors = aggregated\n .slice(0, MAX_AGGREGATE_ERRORS)\n .map(item => serialiseError(item, depth + 1));\n }\n return out;\n}\n\n/**\n * Redact one logged value: errors are serialised, strings are stripped of\n * query text, objects and arrays are walked. Cycles and over-deep structures\n * collapse to a marker rather than throwing — a logger that can fail is worse\n * than one that logs less. (An object referenced twice in one payload is\n * reported as `[circular]` the second time; bounding the walk matters more\n * than rendering a shared reference twice.)\n */\nfunction redactValue(value: unknown, depth: number, seen: WeakSet<object>): unknown {\n // `serialiseError` returns only already-redacted strings, so it is the\n // terminal step — walking its output again would redact twice.\n if (value instanceof Error) return serialiseError(value);\n if (typeof value === \"string\") return redactSensitiveText(value);\n if (value === null || typeof value !== \"object\") return value;\n if (depth >= MAX_REDACT_DEPTH) return \"[truncated]\";\n if (seen.has(value)) return \"[circular]\";\n seen.add(value);\n\n if (Array.isArray(value)) {\n return value.map(item => redactValue(item, depth + 1, seen));\n }\n if (value instanceof Date) return value;\n\n const out: Record<string, unknown> = {};\n for (const [key, val] of Object.entries(value as Record<string, unknown>)) {\n out[key] = isSensitiveKey(key) ? REDACTED_VALUE : redactValue(val, depth + 1, seen);\n }\n return out;\n}\n\nfunction formatData(data?: Record<string, unknown>): Record<string, unknown> | undefined {\n if (!data) return undefined;\n const seen = new WeakSet<object>();\n const out: Record<string, unknown> = {};\n for (const [key, val] of Object.entries(data)) {\n out[key] = isSensitiveKey(key) ? REDACTED_VALUE : redactValue(val, 0, seen);\n }\n return out;\n}\n\n/**\n * `JSON.stringify`, with the redaction a structured field gets.\n *\n * For a value that has to go *into the message*. Once an object is a string,\n * `emit` can only strip `Failed query:` spans out of it: a sensitive key would\n * pass, and so would the `query` and `params` own-properties `DrizzleQueryError`\n * carries beside its message, which `JSON.stringify` copies and the message\n * redaction never sees. Unlike the structured walk this honours `toJSON`, since\n * the result is read as text. Never throws: a value that cannot be stringified\n * is a marker, not a failed log line.\n */\nexport function stringifyForLog(value: unknown): string | undefined {\n try {\n return JSON.stringify(value, (key, val: unknown) => {\n if (isSensitiveKey(key)) return REDACTED_VALUE;\n if (val instanceof Error) return serialiseError(val);\n if (typeof val === \"string\") return redactSensitiveText(val);\n return val;\n });\n } catch {\n return \"[unserialisable]\";\n }\n}\n\n/**\n * Something that wants a copy of every line this logger writes.\n *\n * Receives the message and fields *after* redaction, never before: a sink is\n * another destination for the same line, and the one thing that must not vary\n * by destination is whether the query and its bound values are in it.\n */\nexport type LogSink = (\n level: LogLevel,\n message: string,\n data: Record<string, unknown>\n) => void;\n\nconst sinks = new Set<LogSink>();\n\n/**\n * Tee this logger somewhere else. Returns the unsubscribe.\n *\n * The Studio's Logs Explorer is the caller: its ring buffer used to be fed only\n * by a request middleware, so the panel showed a wall of `GET … 200` and not one\n * of the errors, warnings or diagnoses the server was writing to stdout at the\n * same moment. A log viewer that cannot show you an error is a log viewer\n * nobody opens twice.\n *\n * A sink MUST NOT log. It is called from inside `emit`, so anything that comes\n * back through `logger` recurses; the guard below stops the stack blowing, but\n * the line is dropped rather than delivered, which is its own bug.\n */\nexport function addLogSink(sink: LogSink): () => void {\n sinks.add(sink);\n return () => { sinks.delete(sink); };\n}\n\n/** Re-entrancy guard: see `addLogSink`. */\nlet inSink = false;\n\nfunction fanOut(level: LogLevel, message: string, data: Record<string, unknown>): void {\n if (sinks.size === 0 || inSink) return;\n inSink = true;\n try {\n for (const sink of sinks) {\n // One broken sink must not take down the line, nor the request that\n // was writing it.\n try { sink(level, message, data); } catch { /* a broken tee is not the caller's problem */ }\n }\n } finally {\n inSink = false;\n }\n}\n\nfunction createLogger(rawDefaultFields: Record<string, unknown> = {}): Logger {\n // Child fields go through the same pass as per-call data — they are merged\n // into every line this logger emits, so leaving them raw would be a hole\n // the moment `child()` gets its first caller.\n const defaultFields = formatData(rawDefaultFields) ?? {};\n\n function emit(level: LogLevel, message: string, data?: Record<string, unknown>): void {\n // Per line, not captured at construction: the singleton is created when\n // this module is first imported, which is long before a project's\n // `config.logging.level` has been read.\n if (LOG_PRIORITY[level] < LOG_PRIORITY[getMinLevel()]) return;\n\n // The message is redacted too, not just the data: several call sites\n // interpolate `error.message` straight into the line they log.\n const safeMessage = redactSensitiveText(message);\n const merged = { ...defaultFields,\n...formatData(data) };\n\n // Before the write, so a sink still sees the line if stdout is the\n // thing that is broken.\n fanOut(level, safeMessage, merged);\n\n if (isProduction()) {\n // Structured JSON for Cloud Logging\n const entry: LogEntry = {\n severity: GCP_SEVERITY[level],\n message: safeMessage,\n timestamp: new Date().toISOString(),\n ...merged\n };\n const line = JSON.stringify(entry);\n\n if (level === \"error\") {\n writeLine(\"err\", line);\n } else {\n writeLine(\"out\", line);\n }\n } else {\n // Human-readable for development\n const prefix = level === \"error\" ? \"❌\"\n : level === \"warn\" ? \"⚠️\"\n : level === \"info\" ? \"ℹ️\"\n : \"🐛\";\n const extra = Object.keys(merged).length > 0 ? ` ${JSON.stringify(merged)}` : \"\";\n const out = `${prefix} [${level.toUpperCase()}] ${safeMessage}${extra}`;\n\n if (level === \"error\") {\n console.error(out);\n } else if (level === \"warn\") {\n console.warn(out);\n } else {\n console.log(out);\n }\n }\n }\n\n return {\n debug: (msg, data) => emit(\"debug\", msg, data),\n info: (msg, data) => emit(\"info\", msg, data),\n warn: (msg, data) => emit(\"warn\", msg, data),\n error: (msg, data) => emit(\"error\", msg, data),\n child(fields: Record<string, unknown>): Logger {\n return createLogger({ ...defaultFields,\n...fields });\n }\n };\n}\n\n/**\n * Singleton logger instance.\n * In production: emits JSON lines with `severity`, `message`, `timestamp`.\n * In development: emits human-readable prefixed lines.\n */\nexport const logger: Logger = createLogger();\n\n/**\n * The cause chain, one readable line per link.\n *\n * `serialiseError` puts the chain in the structured payload, which is the right\n * place for a log aggregator and the wrong place for a person staring at a\n * container that will not start: the sentence they need is inside a JSON blob\n * behind an escaped stack trace. This renders the same chain as lines to print\n * beside the headline, so the first thing on screen after \"Failed to start\" is\n * `caused by: connect ECONNREFUSED 127.0.0.1:5432 (ECONNREFUSED)`.\n *\n * Redacted like everything else, and bounded by the same depth: a chain is\n * usually two links and never usefully more than four.\n */\nexport function describeCauseChain(error: unknown): string[] {\n const lines: string[] = [];\n const seen = new Set<unknown>();\n\n const walk = (value: unknown, depth: number): void => {\n if (depth > MAX_CAUSE_DEPTH || value === undefined || value === null) return;\n if (typeof value === \"object\") {\n if (seen.has(value)) return;\n seen.add(value);\n }\n if (depth > 0) {\n const described = describeOneCause(value);\n if (described) lines.push(`caused by: ${described}`);\n }\n if (typeof value !== \"object\") return;\n const own = value as Record<string, unknown>;\n walk(own.cause, depth + 1);\n const aggregated = own.errors;\n if (Array.isArray(aggregated)) {\n for (const item of aggregated.slice(0, MAX_AGGREGATE_ERRORS)) walk(item, depth + 1);\n }\n };\n\n walk(error, 0);\n return lines;\n}\n\n/** One cause rendered as `message (CODE) address:port`, or nothing to say. */\nfunction describeOneCause(value: unknown): string | undefined {\n if (value === null || typeof value !== \"object\") {\n const text = redactSensitiveText(String(value));\n return text || undefined;\n }\n const own = value as Record<string, unknown>;\n const message = typeof own.message === \"string\" && own.message\n ? redactSensitiveText(own.message)\n : undefined;\n const code = typeof own.code === \"string\" ? own.code : undefined;\n // Only when the message does not already carry it. Node writes\n // `connect ECONNREFUSED 127.0.0.1:5432` and also sets `address`/`port`, and\n // repeating the endpoint reads like two different facts.\n const endpoint = own.address !== undefined && own.port !== undefined\n ? `${String(own.address)}:${String(own.port)}`\n : undefined;\n const where = endpoint && !(message ?? \"\").includes(endpoint) ? endpoint : undefined;\n const parts = [message ?? code, code && message ? `(${code})` : undefined, where]\n .filter(Boolean);\n return parts.length > 0 ? parts.join(\" \") : undefined;\n}\n"],"mappings":"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"}
@@ -3,18 +3,19 @@ import __rebaseProcess from "process";
3
3
  globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
5
  import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
6
- import { t as addLogSink } from "./logger-DO2PZc4i.js";
7
- import { r as errorHandler, t as ApiError } from "./errors-DWsX4yTd.js";
6
+ import { t as addLogSink } from "./logger-D-S-hO5e.js";
7
+ import { r as errorHandler, t as ApiError } from "./errors-D6_y86c5.js";
8
8
  import { Hono } from "hono";
9
9
  import { streamSSE } from "hono/streaming";
10
10
  //#region src/api/logs-routes.ts
11
11
  var logs_routes_exports = /* @__PURE__ */ __exportAll({
12
12
  addLog: () => addLog,
13
13
  createLogsRoutes: () => createLogsRoutes,
14
- default: () => logs_routes_default,
15
14
  logBuffer: () => logBuffer,
15
+ logInstanceName: () => logInstanceName,
16
16
  logMiddleware: () => logMiddleware,
17
17
  sourceForMessage: () => sourceForMessage,
18
+ sourceForRequestPath: () => sourceForRequestPath,
18
19
  teeLoggerIntoLogBuffer: () => teeLoggerIntoLogBuffer
19
20
  });
20
21
  function normalizeFilter(options) {
@@ -87,6 +88,17 @@ var LogRingBuffer = class {
87
88
  }
88
89
  };
89
90
  var logBuffer = new LogRingBuffer();
91
+ /**
92
+ * Which process this log belongs to.
93
+ *
94
+ * The ring is this process's memory, and nothing gathers the rings of a
95
+ * deployment's other replicas or of its split runtime roles. So a reader is
96
+ * told whose log it is: `HOSTNAME` is the pod name on Kubernetes and the
97
+ * container id under Docker; the pid tells two local processes apart.
98
+ */
99
+ function logInstanceName() {
100
+ return process.env.HOSTNAME?.trim() || `pid-${process.pid}`;
101
+ }
90
102
  /** Add a log entry */
91
103
  function addLog(level, source, message, metadata) {
92
104
  logBuffer.push({
@@ -97,6 +109,29 @@ function addLog(level, source, message, metadata) {
97
109
  metadata
98
110
  });
99
111
  }
112
+ /** The first path segment under the base path that files a request under a source other than `api`. */
113
+ var SOURCE_BY_SEGMENT = {
114
+ auth: "auth",
115
+ oauth: "auth",
116
+ storage: "storage"
117
+ };
118
+ /**
119
+ * The `source` of a request entry: the subsystem its path addresses.
120
+ *
121
+ * `/api/auth/*` and the MCP authorization server under `/api/oauth/*` are
122
+ * `auth`, `/api/storage/*` is `storage`, and everything else is `api`. The
123
+ * Logs Explorer's Source filter matches this field, so a sign-in filed as
124
+ * `api` was invisible under Auth. The realtime socket is not an HTTP request
125
+ * and is not recorded here.
126
+ *
127
+ * Exported for its test.
128
+ */
129
+ function sourceForRequestPath(path, basePath = "") {
130
+ const base = basePath.replace(/\/+$/, "");
131
+ if (base && path !== base && !path.startsWith(`${base}/`)) return "api";
132
+ const segment = path.slice(base.length).split("/").find((part) => part !== "")?.toLowerCase();
133
+ return segment !== void 0 && Object.prototype.hasOwnProperty.call(SOURCE_BY_SEGMENT, segment) ? SOURCE_BY_SEGMENT[segment] : "api";
134
+ }
100
135
  /** Hono middleware to log API requests */
101
136
  function logMiddleware(options = {}) {
102
137
  const ignored = new Set(options.ignorePaths ?? []);
@@ -109,7 +144,7 @@ function logMiddleware(options = {}) {
109
144
  const status = c.res.status;
110
145
  const level = status >= 500 ? "error" : status >= 400 ? "warn" : "info";
111
146
  const failure = c.get("errorSummary");
112
- addLog(level, "api", `${c.req.method} ${c.req.path} ${status} ${duration}ms` + (failure ? ` — ${failure.code}: ${failure.message}` : ""), {
147
+ addLog(level, sourceForRequestPath(c.req.path, options.basePath), `${c.req.method} ${c.req.path} ${status} ${duration}ms` + (failure ? ` — ${failure.code}: ${failure.message}` : ""), {
113
148
  method: c.req.method,
114
149
  path: c.req.path,
115
150
  status,
@@ -224,7 +259,7 @@ var STREAM_MAX_PENDING = 2e3;
224
259
  * indistinguishable from "that is all there is".
225
260
  */
226
261
  var LOG_WINDOW_MAX = 1e4;
227
- function createLogsRoutes(timing = {}) {
262
+ function createLogsRoutes(timing = {}, options = {}) {
228
263
  teeLoggerIntoLogBuffer();
229
264
  const flushMs = timing.flushMs ?? STREAM_FLUSH_MS;
230
265
  const heartbeatMs = timing.heartbeatMs ?? STREAM_HEARTBEAT_MS;
@@ -240,11 +275,14 @@ function createLogsRoutes(timing = {}) {
240
275
  * `?count=abc` made `slice(-NaN)` return the *entire* buffer. Three ways to
241
276
  * be wrong, none of them visible to the caller. The data plane refuses the
242
277
  * same input with a 400 — see `resolveListLimitParam`.
278
+ *
279
+ * `min` is 1 for a size and 0 for an offset: `?offset=0` is the first page,
280
+ * which is where every pager starts, and it was a 400.
243
281
  */
244
- const window = (raw, what, max) => {
282
+ const window = (raw, what, max, min = 1) => {
245
283
  if (raw === void 0 || raw.trim() === "") return void 0;
246
284
  const parsed = Number(raw.trim());
247
- if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) throw new ApiError(400, "INVALID_PARAM", `Invalid \`${what}\`: ${raw}. Expected a whole number between 1 and ${max}.`, void 0, true);
285
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) throw new ApiError(400, "INVALID_PARAM", `Invalid \`${what}\`: ${raw}. Expected a whole number between ${min} and ${max}.`, void 0, true);
248
286
  return parsed;
249
287
  };
250
288
  app.get("/", (c) => {
@@ -254,7 +292,7 @@ function createLogsRoutes(timing = {}) {
254
292
  source: query.source,
255
293
  search: query.search,
256
294
  limit: window(query.limit, "limit", LOG_WINDOW_MAX),
257
- offset: window(query.offset, "offset", Number.MAX_SAFE_INTEGER),
295
+ offset: window(query.offset, "offset", Number.MAX_SAFE_INTEGER, 0),
258
296
  since: query.since
259
297
  });
260
298
  return c.json(result);
@@ -292,12 +330,16 @@ function createLogsRoutes(timing = {}) {
292
330
  };
293
331
  c.req.raw.signal.addEventListener("abort", abortOnDisconnect, { once: true });
294
332
  if (c.req.raw.signal.aborted) abortOnDisconnect();
333
+ const closeSignal = options.closeSignal;
334
+ closeSignal?.addEventListener("abort", abortOnDisconnect, { once: true });
335
+ if (closeSignal?.aborted) abortOnDisconnect();
295
336
  try {
296
337
  await stream.writeSSE({
297
338
  event: "snapshot",
298
339
  data: JSON.stringify({
299
340
  entries: snapshot.entries.slice().reverse(),
300
- total: snapshot.total
341
+ total: snapshot.total,
342
+ instance: logInstanceName()
301
343
  })
302
344
  });
303
345
  let idleMs = 0;
@@ -328,13 +370,14 @@ function createLogsRoutes(timing = {}) {
328
370
  } finally {
329
371
  unsubscribe();
330
372
  c.req.raw.signal.removeEventListener("abort", abortOnDisconnect);
373
+ closeSignal?.removeEventListener("abort", abortOnDisconnect);
331
374
  }
332
375
  });
333
376
  });
334
377
  return app;
335
378
  }
336
- var logs_routes_default = createLogsRoutes();
379
+ createLogsRoutes();
337
380
  //#endregion
338
381
  export { logs_routes_exports as n, logMiddleware as t };
339
382
 
340
- //# sourceMappingURL=logs-routes-3EEzPjhl.js.map
383
+ //# sourceMappingURL=logs-routes-DAdv37GI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logs-routes-DAdv37GI.js","names":[],"sources":["../src/api/logs-routes.ts"],"sourcesContent":["import { Hono } from \"hono\";\nimport type { MiddlewareHandler } from \"hono\";\nimport { streamSSE } from \"hono/streaming\";\nimport type { HonoEnv } from \"./types\";\nimport { ApiError, errorHandler } from \"./errors\";\nimport { addLogSink } from \"../utils/logger\";\n\nexport interface LogEntry {\n id: string;\n timestamp: string;\n level: \"debug\" | \"info\" | \"warn\" | \"error\";\n source: \"api\" | \"auth\" | \"storage\" | \"realtime\" | \"system\";\n message: string;\n metadata?: Record<string, unknown>;\n}\n\n/** What a caller can narrow the log by, in either direction (query or stream). */\nexport interface LogFilterOptions {\n level?: string;\n source?: string;\n search?: string;\n since?: string;\n}\n\n/**\n * A filter with the search term already lowercased.\n *\n * The distinction matters on the stream path: `query()` lowercases once and then\n * scans, but a subscriber tests one entry at a time and would otherwise redo the\n * same `toLowerCase()` on every request the server handles.\n */\ntype NormalizedFilter = LogFilterOptions;\n\nfunction normalizeFilter(options: LogFilterOptions): NormalizedFilter {\n return { ...options,\n search: options.search?.toLowerCase() };\n}\n\n/**\n * Whether one entry belongs in a filtered view.\n *\n * Shared by the query and the stream on purpose: two copies of this would drift,\n * and the failure that produces is invisible — a tail that quietly shows a\n * different set of lines than the snapshot it started from.\n */\nfunction matchesFilter(entry: LogEntry, filter: NormalizedFilter): boolean {\n if (filter.level && entry.level !== filter.level) return false;\n if (filter.source && entry.source !== filter.source) return false;\n if (filter.search && !entry.message.toLowerCase().includes(filter.search)) return false;\n if (filter.since && entry.timestamp < filter.since) return false;\n return true;\n}\n\n/** Notified for every entry pushed, in push order. */\nexport type LogListener = (entry: LogEntry) => void;\n\nclass LogRingBuffer {\n private buffer: LogEntry[] = [];\n private maxSize: number;\n private idCounter = 0;\n private listeners = new Set<LogListener>();\n\n constructor(maxSize = 10000) {\n this.maxSize = maxSize;\n }\n\n push(entry: Omit<LogEntry, \"id\">): void {\n const id = `log_${++this.idCounter}`;\n const stored: LogEntry = { ...entry,\n id };\n this.buffer.push(stored);\n if (this.buffer.length > this.maxSize) {\n this.buffer.shift();\n }\n // This runs on the request hot path, so a listener must never be able to\n // take the request down with it: a tail that throws loses its own tail,\n // not the response the log line was describing.\n for (const listener of this.listeners) {\n try {\n listener(stored);\n } catch {\n /* a broken tail is not the request's problem */\n }\n }\n }\n\n /**\n * Follow the buffer. Returns the unsubscribe — call it, always: a listener\n * left behind holds its whole closure, and on this class that closure is a\n * pending-entry array.\n *\n * A listener must not log. It is called from inside `push`, so anything that\n * reaches `addLog` from here recurses until the stack gives out.\n */\n subscribe(listener: LogListener): () => void {\n this.listeners.add(listener);\n return () => {\n this.listeners.delete(listener);\n };\n }\n\n query(options: LogFilterOptions & {\n limit?: number;\n offset?: number;\n }): { entries: LogEntry[]; total: number } {\n const filter = normalizeFilter(options);\n const filtered = this.buffer.filter(e => matchesFilter(e, filter));\n\n // Newest first\n const sorted = [...filtered].reverse();\n const total = sorted.length;\n const limit = options.limit || 100;\n const offset = options.offset || 0;\n\n return {\n entries: sorted.slice(offset, offset + limit),\n total\n };\n }\n\n getLatest(count = 50): LogEntry[] {\n return this.buffer.slice(-count).reverse();\n }\n}\n\n// Global singleton\nexport const logBuffer = new LogRingBuffer();\n\n/**\n * Which process this log belongs to.\n *\n * The ring is this process's memory, and nothing gathers the rings of a\n * deployment's other replicas or of its split runtime roles. So a reader is\n * told whose log it is: `HOSTNAME` is the pod name on Kubernetes and the\n * container id under Docker; the pid tells two local processes apart.\n */\nexport function logInstanceName(): string {\n return process.env.HOSTNAME?.trim() || `pid-${process.pid}`;\n}\n\n/** Add a log entry */\nexport function addLog(\n level: LogEntry[\"level\"],\n source: LogEntry[\"source\"],\n message: string,\n metadata?: Record<string, unknown>\n): void {\n logBuffer.push({\n timestamp: new Date().toISOString(),\n level,\n source,\n message,\n metadata\n });\n}\n\nexport interface LogMiddlewareOptions {\n /**\n * Paths this sink ignores, matched exactly against `c.req.path`.\n *\n * For requests whose only reason to exist is to read the log. Recording\n * those makes the reader the loudest thing in its own output, and on a quiet\n * server it is also the thing evicting real entries out of the ring.\n */\n ignorePaths?: string[];\n /**\n * Where the API is mounted (`/api`). The segment after it names the\n * subsystem a request is filed under — see {@link sourceForRequestPath}.\n * Unset, the first segment of the path is read.\n */\n basePath?: string;\n}\n\n/** The first path segment under the base path that files a request under a source other than `api`. */\nconst SOURCE_BY_SEGMENT: Readonly<Record<string, LogEntry[\"source\"]>> = {\n auth: \"auth\",\n oauth: \"auth\",\n storage: \"storage\"\n};\n\n/**\n * The `source` of a request entry: the subsystem its path addresses.\n *\n * `/api/auth/*` and the MCP authorization server under `/api/oauth/*` are\n * `auth`, `/api/storage/*` is `storage`, and everything else is `api`. The\n * Logs Explorer's Source filter matches this field, so a sign-in filed as\n * `api` was invisible under Auth. The realtime socket is not an HTTP request\n * and is not recorded here.\n *\n * Exported for its test.\n */\nexport function sourceForRequestPath(path: string, basePath = \"\"): LogEntry[\"source\"] {\n const base = basePath.replace(/\\/+$/, \"\");\n if (base && path !== base && !path.startsWith(`${base}/`)) return \"api\";\n const segment = path.slice(base.length).split(\"/\").find(part => part !== \"\")?.toLowerCase();\n return (segment !== undefined && Object.prototype.hasOwnProperty.call(SOURCE_BY_SEGMENT, segment))\n ? SOURCE_BY_SEGMENT[segment]\n : \"api\";\n}\n\n/** Hono middleware to log API requests */\nexport function logMiddleware(options: LogMiddlewareOptions = {}): MiddlewareHandler<HonoEnv> {\n const ignored = new Set(options.ignorePaths ?? []);\n return async (c, next) => {\n const start = Date.now();\n await next();\n if (ignored.has(c.req.path)) return;\n const duration = Date.now() - start;\n const reqId = c.get(\"requestId\");\n // Every request used to be recorded at `info`, whatever it answered, so\n // the Logs Explorer's level filter could not find a single failure: a\n // 500 sat at the same level as the 200 above it, in a wall of them.\n const status = c.res.status;\n const level: LogEntry[\"level\"] = status >= 500 ? \"error\" : status >= 400 ? \"warn\" : \"info\";\n // What the error handler answered, so the failure is on the entry\n // rather than in a stdout line the panel cannot see. See\n // `HonoEnv.Variables.errorSummary`.\n const failure = c.get(\"errorSummary\");\n addLog(\n level,\n sourceForRequestPath(c.req.path, options.basePath),\n `${c.req.method} ${c.req.path} ${status} ${duration}ms`\n + (failure ? ` — ${failure.code}: ${failure.message}` : \"\"),\n {\n method: c.req.method,\n path: c.req.path,\n status,\n duration,\n ...(reqId && { requestId: reqId }),\n ...(c.get(\"collection\") && { collection: c.get(\"collection\") }),\n ...(failure && { errorCode: failure.code, errorMessage: failure.message })\n }\n );\n };\n}\n\n/**\n * Prefixes the server writes at the head of a log message, and the `source`\n * each one belongs to.\n *\n * The ring's `source` is a closed set the Studio filters on, and the messages\n * carry their origin as a bracketed prefix — `[API]`, `[Auth]`, `[functions]`,\n * `[schema]`. Matching them is what makes a teed line filterable beside the\n * request entries rather than a heap under \"system\".\n */\nconst SOURCE_BY_PREFIX: Array<[RegExp, LogEntry[\"source\"]]> = [\n [/^\\[?(api|rest)\\b/i, \"api\"],\n [/^\\[?auth\\b/i, \"auth\"],\n [/^\\[?(storage|s3|gcs)\\b/i, \"storage\"],\n [/^\\[?(realtime|ws|websocket|cdc)\\b/i, \"realtime\"]\n];\n\n/**\n * The `source` for a teed line, from whatever prefix it carries.\n *\n * Exported for its test: the mapping is a string match on wording somebody else\n * writes, which is the shape of check that stops working without failing.\n */\nexport function sourceForMessage(message: string): LogEntry[\"source\"] {\n // The level emoji comes first on several call sites (`⚠️ [API] …`), so the\n // prefix is whatever is inside the first bracket, wherever that is.\n const bracketed = message.match(/\\[([^\\]]{1,32})\\]/);\n const candidate = bracketed?.[1] ?? message;\n for (const [pattern, source] of SOURCE_BY_PREFIX) {\n if (pattern.test(candidate)) return source;\n }\n return \"system\";\n}\n\n/**\n * Feed the Logs Explorer everything the server says at warn and above.\n *\n * The ring used to be filled by `logMiddleware` alone, so the panel showed a\n * wall of `GET /api/data/posts 200 4ms` and not one of the errors, warnings or\n * boot diagnoses being written to stdout at the same moment. A function that\n * threw was the sharpest case: the request entry said `500` and the reason\n * existed only in a terminal the person looking at the panel does not have.\n *\n * Warn and above, deliberately. `info` is where the steady-state chatter lives,\n * and a 10,000-entry ring filled with it evicts the lines somebody opened the\n * panel to find.\n *\n * Idempotent: called from `createLogsRoutes`, which a split deployment may\n * reach more than once.\n */\nlet detachLoggerTee: (() => void) | undefined;\nexport function teeLoggerIntoLogBuffer(): () => void {\n if (detachLoggerTee) return detachLoggerTee;\n const detach = addLogSink((level, message, data) => {\n if (level !== \"warn\" && level !== \"error\") return;\n // `requestLogger` writes this one to stdout for every request, and\n // `logMiddleware` has already recorded the same request here with the\n // fields this panel renders. Teeing it too would double every failure.\n if (message === \"request\") return;\n addLog(level, sourceForMessage(message), message, Object.keys(data).length > 0 ? data : undefined);\n });\n detachLoggerTee = () => {\n detach();\n detachLoggerTee = undefined;\n };\n return detachLoggerTee;\n}\n\n/**\n * How long entries accumulate before a batch goes out.\n *\n * Not zero, and that is the point. A busy server logs faster than a browser can\n * render, and one SSE frame per line would hand the client a re-render per\n * request served — worse than the 3s poll this replaces, precisely when the logs\n * are worth watching. Coalescing keeps the frame rate bounded by the window\n * rather than by traffic, and 250ms still reads as \"live\" to a person.\n */\nconst STREAM_FLUSH_MS = 250;\n\n/**\n * Idle gap after which the stream sends a comment line.\n *\n * A silent SSE connection is indistinguishable from a dead one to everything in\n * between — proxies, load balancers and laptop NICs all reap idle sockets, and a\n * server with nothing to say is the normal state here.\n */\nconst STREAM_HEARTBEAT_MS = 25_000;\n\n/**\n * Entries a single connection will hold between flushes.\n *\n * This is a *rate* ceiling, not just a memory bound, and that is easy to get\n * wrong: nothing drains `pending` between flushes, so the most a connection can\n * carry losslessly is `maxPending` per `flushMs` — here 2000 per 250ms, or 8000\n * entries a second. Above that the oldest pending entries go and the client is\n * told how many, whatever speed it is reading at.\n *\n * It was 500, which put that ceiling at 2000/s. A healthy reader on a loopback\n * socket lost 85% of a 20k burst to it — the cap fired on the server's own\n * coalescing window rather than on any slowness at the client, which is a drop\n * notice that says nothing true about why. 8000/s is past what one Node process\n * serves, so reaching it now means genuinely more log than a person can be shown.\n *\n * The memory this bounds is the copy a *stalled* reader causes: roughly 2000\n * entries, a few MB, per stuck connection.\n */\nconst STREAM_MAX_PENDING = 2000;\n\n/**\n * The largest window any of these routes will hand back.\n *\n * The ring buffer holds 10,000 entries, so asking for more than all of it is a\n * mistake worth naming rather than silently clamping — and a clamped answer is\n * indistinguishable from \"that is all there is\".\n */\nconst LOG_WINDOW_MAX = 10_000;\n\n/**\n * The stream's timings, injectable only so they can be tested.\n *\n * The defaults above are the contract and nothing in production passes this. A\n * heartbeat is a 25-second wait to observe, and a suite that cannot observe it is\n * a suite where the keepalive can rot — which surfaces as \"the tail dies after a\n * few minutes behind the load balancer\", months later, on someone else's cluster.\n */\nexport interface LogStreamTiming {\n flushMs?: number;\n heartbeatMs?: number;\n maxPending?: number;\n}\n\n/** What ends the stream from the server's side. */\nexport interface LogsRoutesOptions {\n /**\n * Aborted when the backend shuts down. Every open stream ends then, rather\n * than when its client happens to leave: `server.close()` waits on every\n * open connection, so one Studio → Logs tab held a SIGTERM for the whole\n * force timeout — longer than Cloud Run's or `docker stop`'s grace period,\n * which killed the process before the pool was closed.\n */\n closeSignal?: AbortSignal;\n}\n\nexport function createLogsRoutes(timing: LogStreamTiming = {}, options: LogsRoutesOptions = {}): Hono<HonoEnv> {\n // Here rather than at module load: the ring exists whether or not anything\n // reads it, but there is no reason to fill it on a process that serves no\n // logs surface. Idempotent, so the repeated calls a split deployment makes\n // do not stack up sinks.\n teeLoggerIntoLogBuffer();\n\n const flushMs = timing.flushMs ?? STREAM_FLUSH_MS;\n const heartbeatMs = timing.heartbeatMs ?? STREAM_HEARTBEAT_MS;\n const maxPending = timing.maxPending ?? STREAM_MAX_PENDING;\n\n const app = new Hono<HonoEnv>();\n // Its own, like every other router here: nothing registers one on the host\n // app — not `boot.ts`, not the scaffolded backend, not the eject template —\n // so a router that throws without this answers Hono's default 500 in plain\n // text, outside the `{ error: { code, message } }` envelope the rest of the\n // API keeps to.\n app.onError(errorHandler);\n\n /**\n * A window into the ring buffer, or a 400 saying why not.\n *\n * `parseInt` was the whole of it before, and every malformed value failed\n * differently and silently: `?limit=abc` fell through to the default,\n * `?limit=-5` sliced an empty window and answered 200 with no entries, and\n * `?count=abc` made `slice(-NaN)` return the *entire* buffer. Three ways to\n * be wrong, none of them visible to the caller. The data plane refuses the\n * same input with a 400 — see `resolveListLimitParam`.\n *\n * `min` is 1 for a size and 0 for an offset: `?offset=0` is the first page,\n * which is where every pager starts, and it was a 400.\n */\n const window = (raw: string | undefined, what: string, max: number, min = 1): number | undefined => {\n if (raw === undefined || raw.trim() === \"\") return undefined;\n const parsed = Number(raw.trim());\n if (!Number.isInteger(parsed) || parsed < min || parsed > max) {\n throw new ApiError(\n 400,\n \"INVALID_PARAM\",\n `Invalid \\`${what}\\`: ${raw}. Expected a whole number between ${min} and ${max}.`,\n undefined,\n true\n );\n }\n return parsed;\n };\n\n // GET /api/logs — Query logs\n app.get(\"/\", (c) => {\n const query = c.req.query();\n const result = logBuffer.query({\n level: query.level,\n source: query.source,\n search: query.search,\n limit: window(query.limit, \"limit\", LOG_WINDOW_MAX),\n offset: window(query.offset, \"offset\", Number.MAX_SAFE_INTEGER, 0),\n since: query.since\n });\n return c.json(result);\n });\n\n // GET /api/logs/latest — Get latest logs (for real-time)\n app.get(\"/latest\", (c) => {\n const count = window(c.req.query(\"count\"), \"count\", LOG_WINDOW_MAX) ?? 50;\n return c.json({ entries: logBuffer.getLatest(count) });\n });\n\n // GET /api/logs/stream — tail the buffer over SSE.\n //\n // The Logs Explorer used to poll this router every 3 seconds, which cost a\n // request per client per 3s to say \"nothing happened\" and still showed each\n // line up to 3s late. Here the buffer pushes instead, so an idle server is an\n // idle socket.\n //\n // Events:\n // snapshot {entries, total, instance}\n // the filtered window, oldest-first, at open,\n // and the process whose ring it is\n // append {entries, dropped} entries since the last frame, oldest-first\n // `: ping` comment, keepalive only\n //\n // Snapshot and appends come down the same connection deliberately. A client\n // that fetched its backlog separately would race the subscription — entries\n // logged between the two calls belong to neither — and closing that race from\n // the outside needs an id cursor and dedupe on every frame.\n app.get(\"/stream\", (c) => {\n const query = c.req.query();\n const filter = normalizeFilter({\n level: query.level,\n source: query.source,\n search: query.search\n });\n const limit = window(query.limit, \"limit\", LOG_WINDOW_MAX) ?? 200;\n\n // Reverse proxies buffer text responses by default, which turns a live\n // tail into nothing at all until the buffer fills. nginx (and the ingress\n // in front of the managed runtime) reads this header; everything else\n // ignores it. The rest of the SSE headers are set by `streamSSE`.\n c.header(\"X-Accel-Buffering\", \"no\");\n\n return streamSSE(c, async (stream) => {\n let pending: LogEntry[] = [];\n let dropped = 0;\n\n // Subscribe *before* reading the backlog, with nothing awaited\n // between the two. Both are synchronous, so the two halves meet\n // exactly: an entry logged after the query but before the\n // subscription would otherwise be in neither, and that gap is the one\n // thing this route exists to close.\n const unsubscribe = logBuffer.subscribe(entry => {\n if (!matchesFilter(entry, filter)) return;\n if (pending.length >= maxPending) {\n pending.shift();\n dropped++;\n }\n pending.push(entry);\n });\n const snapshot = logBuffer.query({ ...filter,\n limit });\n\n // A client that goes away has to end this handler, or the\n // subscription outlives the socket. `streamSSE` only wires the\n // request signal through on old Bun, so do it here and let\n // `stream.aborted` be the one condition the loop tests.\n //\n // The `aborted` check is not belt-and-braces. A listener added to an\n // already-aborted signal is never called, so a client that leaves\n // during the snapshot write — a fast navigation, or a reconnect storm\n // against a restarting server — would leave this handler with no way\n // to learn it had gone: a subscriber and a flush loop, per attempt,\n // for the life of the process.\n const abortOnDisconnect = () => {\n if (!stream.closed) stream.abort();\n };\n c.req.raw.signal.addEventListener(\"abort\", abortOnDisconnect, { once: true });\n if (c.req.raw.signal.aborted) abortOnDisconnect();\n // The server going away ends it the same way, for the same reason\n // the client going away does — and a stream opened after shutdown\n // began still sends its snapshot, then ends.\n const closeSignal = options.closeSignal;\n closeSignal?.addEventListener(\"abort\", abortOnDisconnect, { once: true });\n if (closeSignal?.aborted) abortOnDisconnect();\n\n try {\n await stream.writeSSE({\n event: \"snapshot\",\n // The view tails like a terminal; both frames are oldest-first\n // so the client only ever appends.\n data: JSON.stringify({\n entries: snapshot.entries.slice().reverse(),\n total: snapshot.total,\n instance: logInstanceName()\n })\n });\n\n let idleMs = 0;\n while (!stream.aborted && !stream.closed) {\n await stream.sleep(flushMs);\n if (stream.aborted || stream.closed) break;\n\n if (pending.length === 0) {\n idleMs += flushMs;\n if (idleMs >= heartbeatMs) {\n await stream.write(\": ping\\n\\n\");\n idleMs = 0;\n }\n continue;\n }\n\n const entries = pending;\n const lost = dropped;\n pending = [];\n dropped = 0;\n idleMs = 0;\n await stream.writeSSE({\n event: \"append\",\n data: JSON.stringify(lost > 0 ? { entries,\n dropped: lost } : { entries })\n });\n }\n } finally {\n unsubscribe();\n c.req.raw.signal.removeEventListener(\"abort\", abortOnDisconnect);\n closeSignal?.removeEventListener(\"abort\", abortOnDisconnect);\n }\n });\n });\n\n return app;\n}\n\nexport default createLogsRoutes();\n"],"mappings":"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"}
@@ -14,7 +14,7 @@
14
14
  * own login and mentions the third party in small print. The client's name
15
15
  * is attacker-controlled — anyone may register — so it is escaped, length-
16
16
  * capped, and always rendered as a quoted, untrusted string.
17
- * 2. **Say what is being granted in words**, not scope identifiers. `mcp:read`
17
+ * 2. **Say what is being granted in words**, not scope identifiers. `data:read`
18
18
  * means nothing to the person deciding.
19
19
  * 3. **Say what is NOT being granted.** The interesting property of this
20
20
  * integration is that the grant cannot exceed the user's own access, and
@@ -21,9 +21,28 @@
21
21
  * 401, and a token too narrow for the tool it names is 403 `insufficient_scope`.
22
22
  */
23
23
  import { Hono } from "hono";
24
- import type { CollectionConfig, DataDriver } from "@rebasepro/types";
24
+ import type { AuthAdapter, CollectionConfig, DataDriver } from "@rebasepro/types";
25
25
  import type { HonoEnv } from "../api/types.js";
26
+ import { type DataRateLimitConfig } from "../auth/rate-limiter.js";
27
+ import type { ApiKeyIdentity, ApiKeyRefusal } from "../auth/api-keys/api-key-middleware.js";
28
+ /**
29
+ * The most messages one JSON-RPC batch may carry.
30
+ *
31
+ * A batch is many tool calls behind one request: one body, one tick of the
32
+ * rate limiter, run one after another. Uncapped, a single POST was a thousand
33
+ * queries for the price of one. Twenty is far above what a client batches —
34
+ * the 2025-06-18 revision dropped batching altogether — and a batch over it is
35
+ * refused whole rather than truncated, so nothing runs that the client will
36
+ * not hear about.
37
+ */
38
+ export declare const MAX_BATCH_MESSAGES = 20;
26
39
  export interface McpRoutesConfig {
40
+ /**
41
+ * Verify an `rk_` API key, for clients that are configured with a header
42
+ * rather than an OAuth flow. A key reaches the tools its `data:*` scopes
43
+ * cover, as whoever it acts as. Absent, only OAuth tokens are accepted.
44
+ */
45
+ resolveApiKey?: (token: string) => Promise<ApiKeyIdentity | ApiKeyRefusal>;
27
46
  /** The externally reachable origin. */
28
47
  publicUrl: string;
29
48
  /** Where this router is mounted. */
@@ -33,11 +52,27 @@ export interface McpRoutesConfig {
33
52
  /** Resolved per request, because a driver may be swapped at runtime. */
34
53
  getDriver(): DataDriver | undefined;
35
54
  getCollections(): CollectionConfig[];
55
+ /**
56
+ * The deployment's auth adapter. A write to the auth collection is user
57
+ * administration, and the adapter holds it to the rules `/admin/users`
58
+ * does. Without one, those rows are written as any table's.
59
+ */
60
+ getAuthAdapter?(): AuthAdapter | undefined;
36
61
  /** The server's own name and version, for `initialize`. */
37
62
  serverInfo: {
38
63
  name: string;
39
64
  version: string;
40
65
  };
66
+ /**
67
+ * The largest request body accepted, in bytes; `0` or less for none.
68
+ * Defaults to the server-wide limit. See {@link createMcpRoutes}.
69
+ */
70
+ maxBodySize?: number;
71
+ /**
72
+ * The data API's per-caller limits, or undefined when the deployment has
73
+ * rate limiting off. See {@link createMcpRoutes}.
74
+ */
75
+ rateLimit?: DataRateLimitConfig;
41
76
  }
42
77
  /**
43
78
  * The `.well-known` documents.
@@ -48,5 +83,13 @@ export interface McpRoutesConfig {
48
83
  * uncredentialed by design.
49
84
  */
50
85
  export declare function createMcpWellKnownRoutes(config: McpRoutesConfig): Hono<HonoEnv>;
51
- /** The MCP endpoint itself. */
86
+ /**
87
+ * The MCP endpoint itself.
88
+ *
89
+ * It carries its own body limit and rate limit because it is mounted at the
90
+ * origin, outside `basePath`, and the server-wide ones are registered on
91
+ * `${basePath}/*` — so neither ever saw it. The limit is the data API's, bucketed
92
+ * by the person the token acts for and shared with their other requests: one
93
+ * caller's budget, spent wherever they like.
94
+ */
52
95
  export declare function createMcpRoutes(config: McpRoutesConfig): Hono<HonoEnv>;