@rebasepro/server 0.21.2-canary.g1ea48be → 0.23.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 (115) hide show
  1. package/dist/{GCSStorageController-CLIJXwGS.js → GCSStorageController-CjrA4PMo.js} +37 -23
  2. package/dist/GCSStorageController-CjrA4PMo.js.map +1 -0
  3. package/dist/{S3StorageController-Dcuf8lMA.js → S3StorageController-B6pKDNVj.js} +2 -2
  4. package/dist/{S3StorageController-Dcuf8lMA.js.map → S3StorageController-B6pKDNVj.js.map} +1 -1
  5. package/dist/api/ast-schema-editor.d.ts +35 -0
  6. package/dist/api/live-schema-routes.d.ts +14 -0
  7. package/dist/api/rest/api-generator.d.ts +117 -35
  8. package/dist/api/rest/auth-collection-writes.d.ts +85 -0
  9. package/dist/api/rest/field-access-query.d.ts +6 -2
  10. package/dist/api/rest/idempotency.d.ts +7 -1
  11. package/dist/api/rest/nested-write-access.d.ts +46 -0
  12. package/dist/api/rest/write-validation.d.ts +32 -0
  13. package/dist/{ast-schema-editor-CslO8Oje.js → ast-schema-editor-Mvr50v_S.js} +104 -4
  14. package/dist/ast-schema-editor-Mvr50v_S.js.map +1 -0
  15. package/dist/auth/address-ownership.d.ts +53 -0
  16. package/dist/auth/admin-user-ops.d.ts +35 -2
  17. package/dist/auth/auth-hooks.d.ts +4 -0
  18. package/dist/auth/captcha.d.ts +5 -0
  19. package/dist/auth/interfaces.d.ts +36 -6
  20. package/dist/auth/jwt.d.ts +17 -0
  21. package/dist/auth/oauth-signin-policy.d.ts +25 -8
  22. package/dist/auth/rate-limiter.d.ts +31 -1
  23. package/dist/auth/session-routes.d.ts +7 -0
  24. package/dist/auth/token-revocation.d.ts +4 -1
  25. package/dist/{auth-DZXQRmYD.js → auth-B-GIMpDG.js} +643 -230
  26. package/dist/auth-B-GIMpDG.js.map +1 -0
  27. package/dist/backend-DTAOsLQc.js +30 -0
  28. package/dist/backend-DTAOsLQc.js.map +1 -0
  29. package/dist/backup/backup-common.d.ts +19 -0
  30. package/dist/{backup-DzI9jLwc.js → backup-D7YR94N3.js} +69 -8
  31. package/dist/backup-D7YR94N3.js.map +1 -0
  32. package/dist/boot/driver.d.ts +10 -0
  33. package/dist/boot/env.d.ts +2 -2
  34. package/dist/boot/fetch-bundle.d.ts +18 -1
  35. package/dist/boot/rls-audit-option.d.ts +26 -0
  36. package/dist/boot/sources.d.ts +1 -0
  37. package/dist/{contract-routes-eLxV0le1.js → contract-routes-CbFjuBwa.js} +2 -2
  38. package/dist/{contract-routes-eLxV0le1.js.map → contract-routes-CbFjuBwa.js.map} +1 -1
  39. package/dist/cron/cron-routes.d.ts +7 -2
  40. package/dist/cron/cron-scheduler.d.ts +128 -8
  41. package/dist/cron/cron-store.d.ts +72 -8
  42. package/dist/cron/index.d.ts +1 -1
  43. package/dist/{cron-loader-CQjvjpEw.js → cron-loader-DfTj2Hbi.js} +2 -2
  44. package/dist/{cron-loader-CQjvjpEw.js.map → cron-loader-DfTj2Hbi.js.map} +1 -1
  45. package/dist/{cron-routes-cv0hSd-O.js → cron-routes-eE8nif_b.js} +33 -12
  46. package/dist/cron-routes-eE8nif_b.js.map +1 -0
  47. package/dist/{cron-scheduler-COPQxlEq.js → cron-scheduler-B0pLfAix.js} +394 -68
  48. package/dist/cron-scheduler-B0pLfAix.js.map +1 -0
  49. package/dist/{cron-store-Bdm7JqFB.js → cron-store-TcoGz-xS.js} +133 -12
  50. package/dist/cron-store-TcoGz-xS.js.map +1 -0
  51. package/dist/{ddl-bootstrap-CfNvxMuK.js → ddl-bootstrap-C6mo0Kmz.js} +2 -25
  52. package/dist/ddl-bootstrap-C6mo0Kmz.js.map +1 -0
  53. package/dist/email/link-base.d.ts +5 -4
  54. package/dist/email/smtp-email-service.d.ts +13 -1
  55. package/dist/email/templates.d.ts +9 -0
  56. package/dist/email/types.d.ts +3 -2
  57. package/dist/env.d.ts +24 -5
  58. package/dist/{history-recorder-CYfkqP2X.js → history-recorder-B4MpJfJK.js} +8 -6
  59. package/dist/history-recorder-B4MpJfJK.js.map +1 -0
  60. package/dist/{history-store-B1CBwLfi.js → history-store-BhxWOuz9.js} +2 -2
  61. package/dist/{history-store-B1CBwLfi.js.map → history-store-BhxWOuz9.js.map} +1 -1
  62. package/dist/index.d.ts +6 -2
  63. package/dist/index.es.js +2769 -878
  64. package/dist/index.es.js.map +1 -1
  65. package/dist/init/docs.d.ts +5 -2
  66. package/dist/init/global-callbacks.d.ts +21 -0
  67. package/dist/init/shutdown.d.ts +8 -3
  68. package/dist/jobs/index.d.ts +2 -2
  69. package/dist/jobs/job-queue.d.ts +23 -2
  70. package/dist/jobs/job-store.d.ts +37 -5
  71. package/dist/jobs/types.d.ts +8 -6
  72. package/dist/{jobs-2fO2BI8P.js → jobs-CazMYhyy.js} +305 -166
  73. package/dist/jobs-CazMYhyy.js.map +1 -0
  74. package/dist/{jwt-C4OW-DNq.js → jwt-DnQHNFCl.js} +77 -25
  75. package/dist/{jwt-C4OW-DNq.js.map → jwt-DnQHNFCl.js.map} +1 -1
  76. package/dist/{keys-Qfc4XieN.js → keys-CogCQpxG.js} +18 -3
  77. package/dist/{keys-Qfc4XieN.js.map → keys-CogCQpxG.js.map} +1 -1
  78. package/dist/{logs-routes-3EEzPjhl.js → logs-routes-Bj4TYYUl.js} +7 -4
  79. package/dist/{logs-routes-3EEzPjhl.js.map → logs-routes-Bj4TYYUl.js.map} +1 -1
  80. package/dist/mcp/mcp-routes.d.ts +38 -2
  81. package/dist/mcp/mcp-tools.d.ts +7 -1
  82. package/dist/mcp/oauth-routes.d.ts +27 -0
  83. package/dist/mcp/oauth-store.d.ts +29 -13
  84. package/dist/metrics/history-recorder.d.ts +1 -1
  85. package/dist/{openapi-generator-D8ZIN_ss.js → openapi-generator-O_O24MAT.js} +34 -12
  86. package/dist/openapi-generator-O_O24MAT.js.map +1 -0
  87. package/dist/{query-parser-CoIOmflr.js → query-parser-DGRVFNM3.js} +39 -30
  88. package/dist/query-parser-DGRVFNM3.js.map +1 -0
  89. package/dist/rls-audit/index.d.ts +4 -0
  90. package/dist/{schema-editor-routes-DdLihzp0.js → schema-editor-routes-C5-lh_jO.js} +2 -2
  91. package/dist/{schema-editor-routes-DdLihzp0.js.map → schema-editor-routes-C5-lh_jO.js.map} +1 -1
  92. package/dist/{src-BYax9_rm.js → src-pmvW7BFx.js} +189 -14
  93. package/dist/src-pmvW7BFx.js.map +1 -0
  94. package/dist/{src-Br6ARbs6.js → src-vkcwKXbT.js} +2 -27
  95. package/dist/{src-Br6ARbs6.js.map → src-vkcwKXbT.js.map} +1 -1
  96. package/dist/storage/GCSStorageController.d.ts +11 -1
  97. package/dist/storage/keys.d.ts +12 -0
  98. package/dist/storage/rendition-cache.d.ts +11 -1
  99. package/dist/storage/request-keys.d.ts +67 -0
  100. package/dist/storage/types.d.ts +17 -1
  101. package/dist/types-BfKcm9do.js.map +1 -1
  102. package/package.json +9 -9
  103. package/dist/GCSStorageController-CLIJXwGS.js.map +0 -1
  104. package/dist/ast-schema-editor-CslO8Oje.js.map +0 -1
  105. package/dist/auth-DZXQRmYD.js.map +0 -1
  106. package/dist/backup-DzI9jLwc.js.map +0 -1
  107. package/dist/cron-routes-cv0hSd-O.js.map +0 -1
  108. package/dist/cron-scheduler-COPQxlEq.js.map +0 -1
  109. package/dist/cron-store-Bdm7JqFB.js.map +0 -1
  110. package/dist/ddl-bootstrap-CfNvxMuK.js.map +0 -1
  111. package/dist/history-recorder-CYfkqP2X.js.map +0 -1
  112. package/dist/jobs-2fO2BI8P.js.map +0 -1
  113. package/dist/openapi-generator-D8ZIN_ss.js.map +0 -1
  114. package/dist/query-parser-CoIOmflr.js.map +0 -1
  115. package/dist/src-BYax9_rm.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 { c as DEFAULT_STORAGE_SOURCE_KEY } from "./src-vkcwKXbT.js";
6
6
  //#region src/storage/keys.ts
7
7
  /**
8
8
  * Canonical storage keys and bucket names.
@@ -224,7 +224,22 @@ function listingPrefix(rawPrefix) {
224
224
  function folderKey(rawPrefix) {
225
225
  return rawPrefix.replace(/^\/+/, "").replace(/\/+$/, "");
226
226
  }
227
+ /**
228
+ * A listing asked for paging a controller cannot honour: a page size below one,
229
+ * or a page token it never issued.
230
+ *
231
+ * Refused rather than coerced. A page size of 0 answered an empty page whose
232
+ * next token was the one it had been handed, so `while (pageToken)` never
233
+ * ended; a token of `-1` read before the first entry and crashed. The route
234
+ * turns this into a 400.
235
+ */
236
+ var InvalidListOptionsError = class extends Error {
237
+ constructor(message) {
238
+ super(message);
239
+ this.name = "InvalidListOptionsError";
240
+ }
241
+ };
227
242
  //#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 };
243
+ export { canonicalStorageId as a, listingPrefix as c, canonicalStorageBucket as i, tryCanonicalStorageKey as l, InvalidStorageBucketError as n, canonicalStorageKey as o, InvalidStorageKeyError as r, folderKey as s, InvalidListOptionsError as t };
229
244
 
230
- //# sourceMappingURL=keys-Qfc4XieN.js.map
245
+ //# sourceMappingURL=keys-CogCQpxG.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-CogCQpxG.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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"}
@@ -240,11 +240,14 @@ function createLogsRoutes(timing = {}) {
240
240
  * `?count=abc` made `slice(-NaN)` return the *entire* buffer. Three ways to
241
241
  * be wrong, none of them visible to the caller. The data plane refuses the
242
242
  * same input with a 400 — see `resolveListLimitParam`.
243
+ *
244
+ * `min` is 1 for a size and 0 for an offset: `?offset=0` is the first page,
245
+ * which is where every pager starts, and it was a 400.
243
246
  */
244
- const window = (raw, what, max) => {
247
+ const window = (raw, what, max, min = 1) => {
245
248
  if (raw === void 0 || raw.trim() === "") return void 0;
246
249
  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);
250
+ 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
251
  return parsed;
249
252
  };
250
253
  app.get("/", (c) => {
@@ -254,7 +257,7 @@ function createLogsRoutes(timing = {}) {
254
257
  source: query.source,
255
258
  search: query.search,
256
259
  limit: window(query.limit, "limit", LOG_WINDOW_MAX),
257
- offset: window(query.offset, "offset", Number.MAX_SAFE_INTEGER),
260
+ offset: window(query.offset, "offset", Number.MAX_SAFE_INTEGER, 0),
258
261
  since: query.since
259
262
  });
260
263
  return c.json(result);
@@ -337,4 +340,4 @@ var logs_routes_default = createLogsRoutes();
337
340
  //#endregion
338
341
  export { logs_routes_exports as n, logMiddleware as t };
339
342
 
340
- //# sourceMappingURL=logs-routes-3EEzPjhl.js.map
343
+ //# sourceMappingURL=logs-routes-Bj4TYYUl.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"logs-routes-3EEzPjhl.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/** 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\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 \"api\",\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\nexport function createLogsRoutes(timing: LogStreamTiming = {}): 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 const window = (raw: string | undefined, what: string, max: number): number | undefined => {\n if (raw === undefined || raw.trim() === \"\") return undefined;\n const parsed = Number(raw.trim());\n if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) {\n throw new ApiError(\n 400,\n \"INVALID_PARAM\",\n `Invalid \\`${what}\\`: ${raw}. Expected a whole number between 1 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),\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} the filtered window, oldest-first, at open\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\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 })\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 }\n });\n });\n\n return app;\n}\n\nexport default createLogsRoutes();\n"],"mappings":"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"}
1
+ {"version":3,"file":"logs-routes-Bj4TYYUl.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/** 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\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 \"api\",\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\nexport function createLogsRoutes(timing: LogStreamTiming = {}): 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} the filtered window, oldest-first, at open\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\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 })\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 }\n });\n });\n\n return app;\n}\n\nexport default createLogsRoutes();\n"],"mappings":"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"}
@@ -21,8 +21,20 @@
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
+ /**
28
+ * The most messages one JSON-RPC batch may carry.
29
+ *
30
+ * A batch is many tool calls behind one request: one body, one tick of the
31
+ * rate limiter, run one after another. Uncapped, a single POST was a thousand
32
+ * queries for the price of one. Twenty is far above what a client batches —
33
+ * the 2025-06-18 revision dropped batching altogether — and a batch over it is
34
+ * refused whole rather than truncated, so nothing runs that the client will
35
+ * not hear about.
36
+ */
37
+ export declare const MAX_BATCH_MESSAGES = 20;
26
38
  export interface McpRoutesConfig {
27
39
  /** The externally reachable origin. */
28
40
  publicUrl: string;
@@ -33,11 +45,27 @@ export interface McpRoutesConfig {
33
45
  /** Resolved per request, because a driver may be swapped at runtime. */
34
46
  getDriver(): DataDriver | undefined;
35
47
  getCollections(): CollectionConfig[];
48
+ /**
49
+ * The deployment's auth adapter. A write to the auth collection is user
50
+ * administration, and the adapter holds it to the rules `/admin/users`
51
+ * does. Without one, those rows are written as any table's.
52
+ */
53
+ getAuthAdapter?(): AuthAdapter | undefined;
36
54
  /** The server's own name and version, for `initialize`. */
37
55
  serverInfo: {
38
56
  name: string;
39
57
  version: string;
40
58
  };
59
+ /**
60
+ * The largest request body accepted, in bytes; `0` or less for none.
61
+ * Defaults to the server-wide limit. See {@link createMcpRoutes}.
62
+ */
63
+ maxBodySize?: number;
64
+ /**
65
+ * The data API's per-caller limits, or undefined when the deployment has
66
+ * rate limiting off. See {@link createMcpRoutes}.
67
+ */
68
+ rateLimit?: DataRateLimitConfig;
41
69
  }
42
70
  /**
43
71
  * The `.well-known` documents.
@@ -48,5 +76,13 @@ export interface McpRoutesConfig {
48
76
  * uncredentialed by design.
49
77
  */
50
78
  export declare function createMcpWellKnownRoutes(config: McpRoutesConfig): Hono<HonoEnv>;
51
- /** The MCP endpoint itself. */
79
+ /**
80
+ * The MCP endpoint itself.
81
+ *
82
+ * It carries its own body limit and rate limit because it is mounted at the
83
+ * origin, outside `basePath`, and the server-wide ones are registered on
84
+ * `${basePath}/*` — so neither ever saw it. The limit is the data API's, bucketed
85
+ * by the person the token acts for and shared with their other requests: one
86
+ * caller's budget, spent wherever they like.
87
+ */
52
88
  export declare function createMcpRoutes(config: McpRoutesConfig): Hono<HonoEnv>;
@@ -20,7 +20,7 @@
20
20
  * second lock, not the main one — a `mcp:write` token still cannot write a row
21
21
  * the user could not write themselves.
22
22
  */
23
- import type { CollectionConfig, DataDriver } from "@rebasepro/types";
23
+ import type { AuthAdapter, CollectionConfig, DataDriver } from "@rebasepro/types";
24
24
  /** The identity a tool call runs as. Comes from the verified access token. */
25
25
  export interface McpCaller {
26
26
  uid: string;
@@ -32,6 +32,12 @@ export interface McpToolContext {
32
32
  driver: DataDriver;
33
33
  collections: CollectionConfig[];
34
34
  caller: McpCaller;
35
+ /**
36
+ * The deployment's auth adapter. The auth collection's rows are the users,
37
+ * so the write tools hand a write to one to it, as REST and `/admin/users`
38
+ * do. See `api/rest/auth-collection-writes.ts`.
39
+ */
40
+ authAdapter?: AuthAdapter;
35
41
  }
36
42
  export interface McpToolDefinition {
37
43
  name: string;
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { Hono } from "hono";
27
27
  import type { HonoEnv } from "../api/types.js";
28
+ import type { AuthRepository } from "../auth/interfaces.js";
28
29
  import type { OAuthStore } from "./oauth-store.js";
29
30
  export interface OAuthRoutesConfig {
30
31
  store: OAuthStore;
@@ -36,7 +37,33 @@ export interface OAuthRoutesConfig {
36
37
  authBasePath: string;
37
38
  /** Whether open registration is permitted. */
38
39
  allowDynamicRegistration: boolean;
40
+ /**
41
+ * Who a grant's user is now. Absent when the auth adapter exposes no user
42
+ * repository, and then a grant carries the roles it was consented with.
43
+ */
44
+ identity?: McpGrantIdentity;
39
45
  }
46
+ /**
47
+ * The account a grant acts for, read where a grant is issued or renewed.
48
+ *
49
+ * A refresh has no session to re-read anything from, so without this a grant
50
+ * is a snapshot of the moment of consent: the roles it was given, for an
51
+ * account that may since have been demoted, signed out everywhere, or deleted.
52
+ */
53
+ export interface McpGrantIdentity {
54
+ /** The account's roles as they are now, or null when it no longer exists. */
55
+ currentRoles(uid: string): Promise<string[] | null>;
56
+ /** Where "sign out everywhere" and every password change leave their mark. */
57
+ revocation?: Pick<AuthRepository, "getTokensValidAfter">;
58
+ }
59
+ /**
60
+ * The grant identity the auth repository can answer, or undefined when the
61
+ * adapter exposes no repository that can say whether an account exists.
62
+ *
63
+ * The same repository the admin gate re-reads roles and the revocation
64
+ * watermark from, so a grant is held to what a session is held to.
65
+ */
66
+ export declare function grantIdentityFromRepository(repo: Partial<Pick<AuthRepository, "getUserById" | "getUserRoleIds" | "getTokensValidAfter">> | undefined): McpGrantIdentity | undefined;
40
67
  export declare function createOAuthRoutes(config: OAuthRoutesConfig): Hono<HonoEnv>;
41
68
  /**
42
69
  * Why a redirect URI cannot be registered, or null if it can.
@@ -53,26 +53,29 @@ export interface RefreshTokenRecord {
53
53
  clientId: string;
54
54
  uid: string;
55
55
  /**
56
- * The roles the grant was made with.
56
+ * The roles the token was minted with.
57
57
  *
58
- * Carried here because a refresh has no session to re-read them from, and
59
- * an access token minted with an empty `roles` is not a smaller grant — it
60
- * is a DIFFERENT identity to the database. Any policy written as "a row this
61
- * user's role may see" evaluates against an empty list and returns nothing,
62
- * so dropping them turns the first token refresh into an integration that
63
- * silently stops seeing data.
64
- *
65
- * The consequence of storing them is that a role change does not reach an
66
- * existing grant until the refresh token expires or the user revokes the
67
- * client. That is stated in the docs, and it is the trade this design makes
68
- * knowingly: the alternative is a user lookup on every refresh, which puts
69
- * the auth adapter on a path that currently has no dependency on it.
58
+ * A refresh re-reads the account's roles when the routes are given an
59
+ * identity to ask (`McpGrantIdentity`), so these are what a grant falls
60
+ * back on when there is none — an auth adapter that exposes no user
61
+ * repository. The fallback is these rather than an empty list because an
62
+ * access token minted with no `roles` is not a smaller grant — it is a
63
+ * DIFFERENT identity to the database: any policy written as "a row this
64
+ * user's role may see" evaluates against an empty list and returns nothing.
70
65
  */
71
66
  roles: string[];
72
67
  scope: string;
73
68
  resource: string;
74
69
  family: string;
75
70
  }
71
+ /** A refresh token that could be spent right now, and when it was minted. */
72
+ export interface LiveRefreshToken extends RefreshTokenRecord {
73
+ /**
74
+ * When this token — not its family — was issued. Compared with the user's
75
+ * revocation watermark: a token minted before "sign out everywhere" is void.
76
+ */
77
+ issuedAt: Date;
78
+ }
76
79
  export interface OAuthStore {
77
80
  ensureTables(): Promise<void>;
78
81
  registerClient(client: OAuthClient): Promise<void>;
@@ -91,6 +94,19 @@ export interface OAuthStore {
91
94
  * the same family before returning null.
92
95
  */
93
96
  consumeRefreshToken(token: string): Promise<RefreshTokenRecord | null>;
97
+ /**
98
+ * A live refresh token's grant, WITHOUT spending it.
99
+ *
100
+ * What the token endpoint checks before it rotates: whose client it is,
101
+ * the scope asked for, and whether the account behind it still stands.
102
+ * Spending first and checking after means a refusal — or a database blip
103
+ * in the account lookup — leaves the holder with a spent token, and their
104
+ * retry reads as a replay that kills the family.
105
+ *
106
+ * Returns null for anything {@link consumeRefreshToken} would refuse, and
107
+ * writes nothing: a replayed token is detected when it is spent.
108
+ */
109
+ peekRefreshToken(token: string): Promise<LiveRefreshToken | null>;
94
110
  revokeFamily(family: string): Promise<void>;
95
111
  /**
96
112
  * Revoke the family a token belongs to, for RFC 7009 — without spending it.
@@ -1,4 +1,4 @@
1
- import type { DataDriver } from "@rebasepro/types";
1
+ import { type DataDriver } from "@rebasepro/types";
2
2
  import { type MetricSeries, type SeriesPoint } from "./history-store.js";
3
3
  export interface MetricsHistory {
4
4
  /** Create the table and sweep what has aged out. */
@@ -2,8 +2,8 @@ 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 { B as findRelation, E as effectiveAccess, H as isRelationRequired, L as getTenantConfig, U as resolveCollectionRelations, ct as isToMany, z as fieldKeyForColumn } from "./src-BYax9_rm.js";
6
- import "./src-Br6ARbs6.js";
5
+ import { B as fieldKeyForColumn, E as effectiveAccess, G as resolveCollectionRelations, R as getTenantConfig, U as isRelationRequired, V as findRelation, pt as isToMany } from "./src-pmvW7BFx.js";
6
+ import "./src-vkcwKXbT.js";
7
7
  //#region src/api/openapi-generator.ts
8
8
  function generateOpenApiSpec(collections, options = {}) {
9
9
  const basePath = options.basePath ?? "/api";
@@ -108,7 +108,7 @@ function generateOpenApiSpec(collections, options = {}) {
108
108
  name: "distinct",
109
109
  in: "query",
110
110
  schema: { type: "boolean" },
111
- description: "`SELECT DISTINCT` over the returned columns. Only meaningful alongside `fields`: the primary key is always in the projection, so without narrowing it every row is already distinct. `meta.total` counts distinct rows too. Refused (400) alongside `searchString` or a vector search, which attach a per-row score that makes every row distinct by construction, and (400 DISTINCT_ORDER_BY_NOT_SELECTED) when `orderBy` names a column `fields` does not return.",
111
+ description: "`SELECT DISTINCT` over the returned columns. Only meaningful alongside `fields`: the primary key is always in the projection, so without narrowing it every row is already distinct. The response has no `meta.total` — the rows before deduplication are a different set, so their count would not describe the page — and `meta.hasMore` is true when the page came back full. Refused (400) alongside `searchString` or a vector search, which attach a per-row score that makes every row distinct by construction, and (400 DISTINCT_ORDER_BY_NOT_SELECTED) when `orderBy` names a column `fields` does not return.",
112
112
  example: "true"
113
113
  },
114
114
  {
@@ -183,7 +183,7 @@ function generateOpenApiSpec(collections, options = {}) {
183
183
  properties: {
184
184
  total: {
185
185
  type: "integer",
186
- description: "Total number of matching records"
186
+ description: "Total number of matching records. Absent on a `distinct` read, which has no count of the rows it returns."
187
187
  },
188
188
  limit: {
189
189
  type: "integer",
@@ -430,7 +430,15 @@ function generateOpenApiSpec(collections, options = {}) {
430
430
  schema: { type: "string" },
431
431
  example: "status"
432
432
  },
433
- ...listQueryParameters().filter((p) => p.name === "searchString" || p.name === "limit"),
433
+ ...listQueryParameters().filter((p) => p.name === "searchString" || p.name === "limit" || p.name === "offset" || p.name === "page"),
434
+ {
435
+ name: "orderBy",
436
+ in: "query",
437
+ required: false,
438
+ description: "Sort for the groups, by a `groupBy` field or an aggregate's result key (`count`, `sum_total`): `key:asc`, `key:desc` or `key:desc:last`. Groups it leaves tied are ordered by the `groupBy` fields, so every page boundary falls in the same place. `offset`, `page` and `orderBy` need `groupBy` — without it the result is one row — and are otherwise a 400 INVALID_AGGREGATE_WINDOW.",
439
+ schema: { type: "string" },
440
+ example: "count:desc"
441
+ },
434
442
  ...buildFilterParameters(collection, reservedParameterNames)
435
443
  ],
436
444
  responses: {
@@ -439,13 +447,27 @@ function generateOpenApiSpec(collections, options = {}) {
439
447
  content: { "application/json": { schema: {
440
448
  type: "object",
441
449
  required: ["data"],
442
- properties: { data: {
443
- type: "array",
444
- items: {
450
+ properties: {
451
+ data: {
452
+ type: "array",
453
+ items: {
454
+ type: "object",
455
+ additionalProperties: true
456
+ }
457
+ },
458
+ meta: {
445
459
  type: "object",
446
- additionalProperties: true
460
+ description: "The page of groups. Present when `groupBy` is.",
461
+ properties: {
462
+ limit: { type: "integer" },
463
+ offset: { type: "integer" },
464
+ hasMore: {
465
+ type: "boolean",
466
+ description: "Whether a group follows this page."
467
+ }
468
+ }
447
469
  }
448
- } }
470
+ }
449
471
  } } }
450
472
  },
451
473
  501: { description: "This backend's data driver does not implement aggregates" },
@@ -1055,7 +1077,7 @@ function updateOperation(collection, schemaName, requireAuth, shared) {
1055
1077
  return {
1056
1078
  tags: [collection.name],
1057
1079
  summary: `Update ${collection.singularName || collection.name}`,
1058
- description: "Partial update: only the properties present in the body are written; the rest are left unchanged.\n\nA property's value may instead be a field operation — `{ \"views\": { \"$inc\": 1 } }`, `{ \"tags\": { \"$push\": \"new\" } }`, `{ \"tags\": { \"$pull\": \"old\" } }`, `{ \"meta\": { \"$merge\": { \"seen\": true } } }` — which is applied inside the statement holding the row lock. That is the difference between a counter that is correct under concurrency and one that silently loses increments, because expressing the same change as a value means reading it first. `$inc` needs a `number` property, `$push`/`$pull` an `array`, `$merge` a `map`; anything else is a 400. See the `FieldOperation` schema.",
1080
+ description: "Partial update: only the properties present in the body are written; the rest are left unchanged.\n\nA property's value may instead be a field operation — `{ \"views\": { \"$inc\": 1 } }`, `{ \"tags\": { \"$push\": \"new\" } }`, `{ \"tags\": { \"$pull\": \"old\" } }`, `{ \"meta\": { \"$merge\": { \"seen\": true } } }` — which is applied inside the statement holding the row lock. That is the difference between a counter that is correct under concurrency and one that silently loses increments, because expressing the same change as a value means reading it first. `$inc` needs a `number` property, `$push`/`$pull` an `array`, `$merge` a `map`; anything else is a 400. Pushed elements and merged keys answer to the property's own rules, as a value would. See the `FieldOperation` schema.",
1059
1081
  operationId: `update${schemaName}`,
1060
1082
  parameters: [
1061
1083
  {
@@ -1426,4 +1448,4 @@ function toPascalCase(str) {
1426
1448
  //#endregion
1427
1449
  export { generateOpenApiSpec };
1428
1450
 
1429
- //# sourceMappingURL=openapi-generator-D8ZIN_ss.js.map
1451
+ //# sourceMappingURL=openapi-generator-O_O24MAT.js.map