@rebasepro/server 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/dist/{GCSStorageController-CRd9bFrc.js → GCSStorageController-40FyFeSx.js} +6 -2
  2. package/dist/GCSStorageController-40FyFeSx.js.map +1 -0
  3. package/dist/{S3StorageController-D00EFa5P.js → S3StorageController-Bl5shp94.js} +6 -2
  4. package/dist/S3StorageController-Bl5shp94.js.map +1 -0
  5. package/dist/access-BFAUvsSs.js +1897 -0
  6. package/dist/access-BFAUvsSs.js.map +1 -0
  7. package/dist/api/branch-routes.d.ts +25 -0
  8. package/dist/api/live-schema-routes.d.ts +10 -0
  9. package/dist/api/rest/api-generator.d.ts +9 -1
  10. package/dist/api/rest/idempotency.d.ts +15 -1
  11. package/dist/api/root-error-handler.d.ts +6 -5
  12. package/dist/api/types.d.ts +12 -0
  13. package/dist/{ast-schema-editor-mCeSC03x.js → ast-schema-editor-madTa9Hh.js} +2 -2
  14. package/dist/{ast-schema-editor-mCeSC03x.js.map → ast-schema-editor-madTa9Hh.js.map} +1 -1
  15. package/dist/auth/access.d.ts +10 -0
  16. package/dist/auth/adapter-middleware.d.ts +2 -1
  17. package/dist/auth/adapter-service-key.d.ts +24 -0
  18. package/dist/auth/api-keys/api-key-middleware.d.ts +21 -1
  19. package/dist/auth/api-keys/api-key-store.d.ts +8 -1
  20. package/dist/auth/api-keys/key-grant.d.ts +18 -1
  21. package/dist/auth/expired-token-sweep.d.ts +5 -0
  22. package/dist/auth/impersonation.d.ts +3 -1
  23. package/dist/auth/jwt.d.ts +8 -2
  24. package/dist/auth/oauth-signin-policy.d.ts +21 -35
  25. package/dist/auth/rls-scope.d.ts +17 -7
  26. package/dist/auth/rls-simulator-routes.d.ts +51 -0
  27. package/dist/auth/simulation-read-only.d.ts +28 -0
  28. package/dist/auth/simulation.d.ts +141 -0
  29. package/dist/auth/sql-rate-limit-store.d.ts +15 -1
  30. package/dist/{auth-Dkvo2tTJ.js → auth-izI7AEzC.js} +370 -1880
  31. package/dist/auth-izI7AEzC.js.map +1 -0
  32. package/dist/auth_adapter-B7RUMk6e.js +89 -0
  33. package/dist/auth_adapter-B7RUMk6e.js.map +1 -0
  34. package/dist/backend/create-backend.d.ts +6 -0
  35. package/dist/backend/options.d.ts +9 -4
  36. package/dist/{backend-DTAOsLQc.js → backend-CaG_Jyso.js} +9 -2
  37. package/dist/backend-CaG_Jyso.js.map +1 -0
  38. package/dist/{backup-B9m3cb3X.js → backup-BVbHoKo8.js} +6 -6
  39. package/dist/{backup-B9m3cb3X.js.map → backup-BVbHoKo8.js.map} +1 -1
  40. package/dist/boot/boot.d.ts +2 -15
  41. package/dist/boot/ddl-bootstrap.d.ts +34 -0
  42. package/dist/boot/env.d.ts +6 -0
  43. package/dist/boot/fetch-bundle.d.ts +5 -1
  44. package/dist/boot/provision-only.d.ts +23 -0
  45. package/dist/boot/provision.d.ts +53 -2
  46. package/dist/boot/role.d.ts +2 -0
  47. package/dist/boot/schema-change-recorder.d.ts +45 -0
  48. package/dist/boot/security-headers.d.ts +22 -0
  49. package/dist/branch-routes-B2vvEx53.js +84 -0
  50. package/dist/branch-routes-B2vvEx53.js.map +1 -0
  51. package/dist/{contract-routes-CsFXW7j4.js → contract-routes-CU2A3JnF.js} +2 -2
  52. package/dist/{contract-routes-CsFXW7j4.js.map → contract-routes-CU2A3JnF.js.map} +1 -1
  53. package/dist/cron/cron-store.d.ts +8 -1
  54. package/dist/{cron-loader-Dj6lA4HQ.js → cron-loader-mMhqbVsl.js} +2 -2
  55. package/dist/{cron-loader-Dj6lA4HQ.js.map → cron-loader-mMhqbVsl.js.map} +1 -1
  56. package/dist/{cron-routes-CwbLZguO.js → cron-routes-Cs33j_Kr.js} +3 -3
  57. package/dist/{cron-routes-CwbLZguO.js.map → cron-routes-Cs33j_Kr.js.map} +1 -1
  58. package/dist/{cron-scheduler-B04ACoUE.js → cron-scheduler-Be7CNghL.js} +2 -2
  59. package/dist/{cron-scheduler-B04ACoUE.js.map → cron-scheduler-Be7CNghL.js.map} +1 -1
  60. package/dist/{cron-store-DkyfuLOH.js → cron-store-DLn11M8H.js} +10 -9
  61. package/dist/cron-store-DLn11M8H.js.map +1 -0
  62. package/dist/{strings.es-BeZWAodn.js → data_driver-D0mg1Y_x.js} +2 -90
  63. package/dist/data_driver-D0mg1Y_x.js.map +1 -0
  64. package/dist/{ddl-bootstrap-Ck20GSIX.js → ddl-bootstrap-CjRrn12l.js} +91 -2
  65. package/dist/ddl-bootstrap-CjRrn12l.js.map +1 -0
  66. package/dist/deploy/pod-contract.d.ts +1 -1
  67. package/dist/email/dev-sink.d.ts +6 -0
  68. package/dist/email/index.d.ts +1 -1
  69. package/dist/{soft-delete-params-BmlVwecC.js → field-access-DYsvjI_P.js} +27 -57
  70. package/dist/field-access-DYsvjI_P.js.map +1 -0
  71. package/dist/{filter-dialect-O1HK6Nvr.js → filter-dialect-TrKFWLqT.js} +3 -3
  72. package/dist/{filter-dialect-O1HK6Nvr.js.map → filter-dialect-TrKFWLqT.js.map} +1 -1
  73. package/dist/{function-loader-KKDqTcIr.js → function-loader-B8YzXEsQ.js} +21 -14
  74. package/dist/function-loader-B8YzXEsQ.js.map +1 -0
  75. package/dist/functions/hono-like.d.ts +12 -0
  76. package/dist/functions/index.js +19 -1
  77. package/dist/functions/index.js.map +1 -1
  78. package/dist/functions/request-timeout.d.ts +3 -3
  79. package/dist/history/history-routes.d.ts +6 -0
  80. package/dist/history/retention-sweep.d.ts +5 -0
  81. package/dist/{history-recorder-B2rkuhuy.js → history-recorder-Bsau7h4X.js} +6 -6
  82. package/dist/history-recorder-Bsau7h4X.js.map +1 -0
  83. package/dist/{history-store-DPofGVDp.js → history-store-BQz5YCkW.js} +26 -2
  84. package/dist/history-store-BQz5YCkW.js.map +1 -0
  85. package/dist/index.d.ts +3 -1
  86. package/dist/index.es.js +2449 -543
  87. package/dist/index.es.js.map +1 -1
  88. package/dist/init/process-safety.d.ts +4 -3
  89. package/dist/init/shutdown.d.ts +8 -0
  90. package/dist/init.d.ts +6 -1
  91. package/dist/jobs/job-store.d.ts +8 -1
  92. package/dist/{jobs-h1WRq3cp.js → jobs-Co6Ni5HP.js} +6 -6
  93. package/dist/jobs-Co6Ni5HP.js.map +1 -0
  94. package/dist/{jwt-D3oFauZI.js → jwt-rDS0884_.js} +4 -3
  95. package/dist/{jwt-D3oFauZI.js.map → jwt-rDS0884_.js.map} +1 -1
  96. package/dist/{keys-COzQ2aE8.js → keys-BXcdRNhJ.js} +2 -2
  97. package/dist/{keys-COzQ2aE8.js.map → keys-BXcdRNhJ.js.map} +1 -1
  98. package/dist/mcp/consent-page.d.ts +38 -1
  99. package/dist/mcp/mcp-routes.d.ts +9 -0
  100. package/dist/mcp/mcp-tools.d.ts +7 -1
  101. package/dist/mcp/oauth-store.d.ts +12 -5
  102. package/dist/metrics/history-recorder.d.ts +4 -1
  103. package/dist/metrics/history-store.d.ts +8 -0
  104. package/dist/{offline-BrqCRgih-BtDB1ecy.js → offline-AvuEcg7u-DFHL98OT.js} +127 -40
  105. package/dist/offline-AvuEcg7u-DFHL98OT.js.map +1 -0
  106. package/dist/{offline-store-BhYXViha-BW5j1We_.js → offline-store-BhYXViha-YTmjBpo2.js} +5 -5
  107. package/dist/{offline-store-BhYXViha-BW5j1We_.js.map → offline-store-BhYXViha-YTmjBpo2.js.map} +1 -1
  108. package/dist/{openapi-generator-BjHr6upl.js → openapi-generator-CyxEtYEX.js} +61 -22
  109. package/dist/openapi-generator-CyxEtYEX.js.map +1 -0
  110. package/dist/{proxy-CH8Jsovg.js → proxy-BYir8tWK.js} +4 -6
  111. package/dist/proxy-BYir8tWK.js.map +1 -0
  112. package/dist/{query-parser-otFZqJsC.js → query-parser-CDXWf2hH.js} +6 -5
  113. package/dist/{query-parser-otFZqJsC.js.map → query-parser-CDXWf2hH.js.map} +1 -1
  114. package/dist/{request-timeout-59crS-9u.js → request-timeout-DVjo-c_8.js} +4 -4
  115. package/dist/request-timeout-DVjo-c_8.js.map +1 -0
  116. package/dist/rls-simulator-routes-bEY7-5-u.js +190 -0
  117. package/dist/rls-simulator-routes-bEY7-5-u.js.map +1 -0
  118. package/dist/schema-edit/deployed-schema-guard.d.ts +47 -0
  119. package/dist/{schema-editor-routes-Bsut0bCC.js → schema-editor-routes-Dc3DU_jm.js} +3 -3
  120. package/dist/{schema-editor-routes-Bsut0bCC.js.map → schema-editor-routes-Dc3DU_jm.js.map} +1 -1
  121. package/dist/soft-delete-params-BRONCOA2.js +59 -0
  122. package/dist/soft-delete-params-BRONCOA2.js.map +1 -0
  123. package/dist/{sql-export-routes-DmB_uZ4l.js → sql-export-routes-QycX8W8S.js} +38 -4
  124. package/dist/sql-export-routes-QycX8W8S.js.map +1 -0
  125. package/dist/{src-D5Y3N-S4.js → src-Bh9LRPb9.js} +21 -2
  126. package/dist/{src-D5Y3N-S4.js.map → src-Bh9LRPb9.js.map} +1 -1
  127. package/dist/storage/GCSStorageController.d.ts +2 -0
  128. package/dist/storage/LocalStorageController.d.ts +8 -3
  129. package/dist/storage/S3StorageController.d.ts +2 -0
  130. package/dist/storage/maintenance-keys.d.ts +17 -0
  131. package/dist/storage/maintenance-routes.d.ts +28 -5
  132. package/dist/storage/maintenance.d.ts +136 -4
  133. package/dist/storage/orphan-scans.d.ts +120 -0
  134. package/dist/storage/types.d.ts +11 -0
  135. package/dist/strings.es-B7QLnHh2.js +96 -0
  136. package/dist/strings.es-B7QLnHh2.js.map +1 -0
  137. package/dist/upload-validation-Bgo626uY.js.map +1 -1
  138. package/package.json +5 -5
  139. package/dist/GCSStorageController-CRd9bFrc.js.map +0 -1
  140. package/dist/S3StorageController-D00EFa5P.js.map +0 -1
  141. package/dist/auth-Dkvo2tTJ.js.map +0 -1
  142. package/dist/auth_adapter-CIItCTc8.js +0 -21
  143. package/dist/auth_adapter-CIItCTc8.js.map +0 -1
  144. package/dist/backend-DTAOsLQc.js.map +0 -1
  145. package/dist/cron-store-DkyfuLOH.js.map +0 -1
  146. package/dist/ddl-bootstrap-Ck20GSIX.js.map +0 -1
  147. package/dist/function-loader-KKDqTcIr.js.map +0 -1
  148. package/dist/history-recorder-B2rkuhuy.js.map +0 -1
  149. package/dist/history-store-DPofGVDp.js.map +0 -1
  150. package/dist/jobs-h1WRq3cp.js.map +0 -1
  151. package/dist/offline-BrqCRgih-BtDB1ecy.js.map +0 -1
  152. package/dist/openapi-generator-BjHr6upl.js.map +0 -1
  153. package/dist/proxy-CH8Jsovg.js.map +0 -1
  154. package/dist/request-timeout-59crS-9u.js.map +0 -1
  155. package/dist/soft-delete-params-BmlVwecC.js.map +0 -1
  156. package/dist/sql-export-routes-DmB_uZ4l.js.map +0 -1
  157. package/dist/strings.es-BeZWAodn.js.map +0 -1
@@ -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 { O as DEFAULT_STORAGE_SOURCE_KEY } from "./src-D5Y3N-S4.js";
5
+ import { k as DEFAULT_STORAGE_SOURCE_KEY } from "./src-Bh9LRPb9.js";
6
6
  //#region src/storage/keys.ts
7
7
  /**
8
8
  * Canonical storage keys and bucket names.
@@ -228,4 +228,4 @@ var InvalidListOptionsError = class extends Error {
228
228
  //#endregion
229
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 };
230
230
 
231
- //# sourceMappingURL=keys-COzQ2aE8.js.map
231
+ //# sourceMappingURL=keys-BXcdRNhJ.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"keys-COzQ2aE8.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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuDA,SAAgB,eAAe,OAAuB;CAClD,IAAI,MAAM,WAAW,GAAG,OAAO;CAC/B,MAAM,aAAa,MAAM,WAAW,GAAG;CACvC,MAAM,oBAAoB,MAAM,SAAS,GAAG;CAE5C,MAAM,WAAqB,CAAC;CAC5B,KAAK,MAAM,WAAW,MAAM,MAAM,GAAG,GAAG;EACpC,IAAI,YAAY,MAAM,YAAY,KAAK;EACvC,IAAI,YAAY,MAAM;GAClB,IAAI,SAAS,SAAS,KAAK,SAAS,SAAS,SAAS,OAAO,MAAM,SAAS,IAAI;QAC3E,IAAI,CAAC,YAAY,SAAS,KAAK,IAAI;GACxC;EACJ;EACA,SAAS,KAAK,OAAO;CACzB;CAEA,MAAM,SAAS,SAAS,KAAK,GAAG;CAChC,IAAI,WAAW,IAAI;EACf,IAAI,YAAY,OAAO;EACvB,OAAO,oBAAoB,OAAO;CACtC;CACA,MAAM,eAAe,oBAAoB,GAAG,OAAO,KAAK;CACxD,OAAO,aAAa,IAAI,iBAAiB;AAC7C;;AAGA,IAAa,yBAAyB;;;;;AAMtC,IAAa,yBAAb,cAA4C,MAAM;CAC9C,YAAY,SAAiB;EACzB,MAAM,OAAO;EACb,KAAK,OAAO;CAChB;AACJ;;;;;;;;;;;;;;;;AAiBA,SAAgB,oBAAoB,QAAwB;CACxD,IAAI,OAAO,SAAS,IAAI,GACpB,MAAM,IAAI,uBAAuB,mCAAmC;CAExE,IAAI,OAAO,SAAA,MACP,MAAM,IAAI,uBACN,6CAA6C,uBAAuB,aACxE;CAQJ,IAAI,OAAO,MAAM,OAAO,CAAC,CAAC,MAAM,YAAY,YAAY,IAAI,GACxD,MAAM,IAAI,uBACN,8EACJ;CAGJ,MAAM,wBAAwB,OAAO,QAAQ,QAAQ,EAAE;CACvD,IAAI,0BAA0B,IAAI,OAAO;CAYzC,MAAM,mBAAmB,eAAe,KAAK,qBAAqB;CAIlE,MAAM,aAAa,eAAe,qBAAqB;CAGvD,IAAI,eAAe,OAAO,eAAe,MAAM,OAAO;CAKtD,MAAM,MAAM,WAAW,QAAQ,SAAS,EAAE,CAAC,CAAC,QAAQ,QAAQ,EAAE;CAC9D,IAAI,QAAQ,IAAI,OAAO;CACvB,OAAO,oBAAoB,CAAC,IAAI,SAAS,GAAG,IAAI,GAAG,IAAI,KAAK;AAChE;;;;;;;;;;;;;;;;;AAiCA,SAAgB,mBAAmB,cAAiD;CAChF,IAAI,iBAAiB,KAAA,KAAa,iBAAiB,MAAM,OAAO;CAChE,MAAM,UAAU,aAAa,KAAK;CAClC,OAAO,YAAY,KAAK,6BAA6B;AACzD;;AASA,IAAa,4BAAb,cAA+C,MAAM;CACjD,YAAY,SAAiB;EACzB,MAAM,OAAO;EACb,KAAK,OAAO;CAChB;AACJ;;;;;;;AAQA,IAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;AAyB/B,SAAgB,uBAAuB,WAA0D;CAC7F,IAAI,cAAc,KAAA,KAAa,cAAc,QAAQ,cAAc,IAAI,OAAO,KAAA;CAC9E,IAAI,UAAU,SAAA,IACV,MAAM,IAAI,0BACN,6DACJ;CAEJ,IAAI,CAAC,uBAAuB,KAAK,SAAS,GACtC,MAAM,IAAI,0BACN,4GAEJ;CAEJ,OAAO;AACX;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,cAAc,WAAuC;CACjE,MAAM,MAAM,UAAU,QAAQ,QAAQ,EAAE,CAAC,CAAC,QAAQ,QAAQ,EAAE;CAC5D,OAAO,QAAQ,KAAK,KAAA,IAAY,GAAG,IAAI;AAC3C;;AAGA,SAAgB,UAAU,WAA2B;CACjD,OAAO,UAAU,QAAQ,QAAQ,EAAE,CAAC,CAAC,QAAQ,QAAQ,EAAE;AAC3D;;;;;;;;;;AAWA,IAAa,0BAAb,cAA6C,MAAM;CAC/C,YAAY,SAAiB;EACzB,MAAM,OAAO;EACb,KAAK,OAAO;CAChB;AACJ"}
1
+ {"version":3,"file":"keys-BXcdRNhJ.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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"}
@@ -28,7 +28,14 @@
28
28
  * 5. **Never post the password anywhere but the existing login endpoint.**
29
29
  * The form below sends credentials to `${basePath}/auth/login` and nowhere
30
30
  * else; this file's own endpoint receives only the resulting session token.
31
- * 6. **Refuse to be framed.** See {@link consentPageHeaders} — a consent screen
31
+ * 6. **Say where the code goes.** Registration is open by default and the
32
+ * client name is the registrant's to choose, so "“Claude” wants access"
33
+ * can be anyone. The redirect host cannot, and the page names it.
34
+ * 7. **Let every account that can sign in consent.** An account with a second
35
+ * factor finishes here through the existing MFA routes; an account that
36
+ * has only ever used Google, GitHub or an email link is told how to get a
37
+ * password, rather than shown an error it cannot act on.
38
+ * 8. **Refuse to be framed.** See {@link consentPageHeaders} — a consent screen
32
39
  * that can be iframed is a consent screen an attacker can have clicked.
33
40
  */
34
41
  export interface ConsentPageParams {
@@ -42,6 +49,24 @@ export interface ConsentPageParams {
42
49
  requestToken: string;
43
50
  /** The existing auth endpoint the sign-in form posts to. */
44
51
  loginUrl: string;
52
+ /**
53
+ * The existing MFA routes (`POST /auth/mfa/challenge` and
54
+ * `/auth/mfa/challenge/verify`). An account with a verified factor answers
55
+ * the login with `MFA_REQUIRED` and a pending token; the page opens a
56
+ * challenge with it and posts the code, which is where that account's
57
+ * session is minted. Without this step such an account could never consent.
58
+ */
59
+ mfaChallengeUrl: string;
60
+ mfaVerifyUrl: string;
61
+ /**
62
+ * Where the authorization code goes once the person allows.
63
+ *
64
+ * The client's name is free text anyone may register, so it proves nothing;
65
+ * the redirect URI is the one fact on this screen an attacker cannot make
66
+ * look like someone else's without owning that host. It is shown, as a
67
+ * host, with a plain statement that this server has not verified it.
68
+ */
69
+ redirectUri: string;
45
70
  /** Where the decision is posted. */
46
71
  decisionUrl: string;
47
72
  /** The canonical resource, shown so the user can see which person this is. */
@@ -73,3 +98,15 @@ export interface ConsentPageParams {
73
98
  */
74
99
  export declare function consentPageHeaders(nonce: string): Record<string, string>;
75
100
  export declare function renderConsentPage(params: ConsentPageParams): string;
101
+ /**
102
+ * What to call the redirect target on the consent screen.
103
+ *
104
+ * The host, not the whole URI: a path is the registrant's to dress up
105
+ * ("/claude/official/callback"), the host is what they must own. A loopback
106
+ * redirect is a native client on this person's own machine; a private-use
107
+ * scheme (`com.example.app:/cb`) has no host, so it is shown whole.
108
+ */
109
+ export declare function describeRedirect(redirectUri: string): {
110
+ label: string;
111
+ note: string;
112
+ };
@@ -26,6 +26,7 @@ import type { HonoEnv } from "../api/types.js";
26
26
  import { type JwtService } from "../auth/jwt.js";
27
27
  import { type DataRateLimitConfig } from "../auth/rate-limiter.js";
28
28
  import type { ApiKeyIdentity, ApiKeyRefusal } from "../auth/api-keys/api-key-middleware.js";
29
+ import type { McpGrantIdentity } from "./oauth-routes.js";
29
30
  /**
30
31
  * The most messages one JSON-RPC batch may carry.
31
32
  *
@@ -79,6 +80,14 @@ export interface McpRoutesConfig {
79
80
  * rate limiting off. See {@link createMcpRoutes}.
80
81
  */
81
82
  rateLimit?: DataRateLimitConfig;
83
+ /**
84
+ * Who an access token's account is now — the identity the OAuth routes
85
+ * re-read at consent and refresh. Asked on every request, so a deleted,
86
+ * disabled or signed-out-everywhere account stops at once and a demotion
87
+ * takes effect on the next call. Absent when the auth adapter exposes no
88
+ * user repository, and then a token carries the roles it was minted with.
89
+ */
90
+ identity?: McpGrantIdentity;
82
91
  }
83
92
  /**
84
93
  * The `.well-known` documents.
@@ -21,7 +21,7 @@
21
21
  * is a second lock, not the main one: a `data:write` token still cannot write
22
22
  * a row the user could not write themselves.
23
23
  */
24
- import type { AuthAdapter, CollectionConfig, DataDriver } from "@rebasepro/types";
24
+ import type { AuthAdapter, CallerCredential, CollectionConfig, DataDriver } from "@rebasepro/types";
25
25
  import type { McpScope } from "./oauth-metadata.js";
26
26
  /** The identity a tool call runs as. Comes from the verified access token. */
27
27
  export interface McpCaller {
@@ -30,6 +30,12 @@ export interface McpCaller {
30
30
  /** The scopes granted to this connection — `data:read`, `data:write:posts`, … */
31
31
  scopes: string[];
32
32
  clientId: string;
33
+ /**
34
+ * The API key the connection authenticated with, when it was one rather
35
+ * than an OAuth grant. Handed to collection callbacks as
36
+ * `context.credential`.
37
+ */
38
+ credential?: CallerCredential;
33
39
  }
34
40
  export interface McpToolContext {
35
41
  driver: DataDriver;
@@ -146,10 +146,10 @@ export interface OAuthStore {
146
146
  * there was nothing to revoke, which lets the route answer 404 rather than
147
147
  * reporting success for a client the user never connected.
148
148
  *
149
- * Access tokens already issued are NOT invalidated — they are self-contained
150
- * JWTs with no per-request database lookup, which is what makes `/mcp` cheap.
151
- * The window is therefore one access-token lifetime (an hour), and the
152
- * route's response says so rather than implying an instant cut-off.
149
+ * Access tokens already issued are NOT invalidated — `/mcp` re-checks the
150
+ * account on each request, not the grant. The window is therefore one
151
+ * access-token lifetime (an hour), and the route's response says so rather
152
+ * than implying an instant cut-off.
153
153
  */
154
154
  revokeGrant(uid: string, clientId: string): Promise<boolean>;
155
155
  }
@@ -170,4 +170,11 @@ export interface GrantSummary {
170
170
  * server that cannot persist a code is not a degraded authorization server, it
171
171
  * is one that hands out credentials it cannot check.
172
172
  */
173
- export declare function createOAuthStore(driver: DataDriver): OAuthStore | null;
173
+ export declare function createOAuthStore(driver: DataDriver, options?: {
174
+ /**
175
+ * Default `true`. `false` is a backend that changes nothing in its
176
+ * database: `ensureTables` creates and revokes nothing, and throws
177
+ * when the tables are not already there.
178
+ */
179
+ provision?: boolean;
180
+ }): OAuthStore | null;
@@ -15,4 +15,7 @@ export interface MetricsHistory {
15
15
  * chart that renders empty is indistinguishable from a quiet period and this is
16
16
  * exactly the class of silence the panel it feeds exists to remove.
17
17
  */
18
- export declare function createMetricsHistory(driver: DataDriver): MetricsHistory | undefined;
18
+ export declare function createMetricsHistory(driver: DataDriver, options?: {
19
+ /** Default `true`. `false` uses the table if it is there and never creates it. */
20
+ provision?: boolean;
21
+ }): MetricsHistory | undefined;
@@ -59,6 +59,14 @@ export interface MetricSample {
59
59
  * nobody is mid-request.
60
60
  */
61
61
  export declare function ensureMetricsHistory(exec: Exec): Promise<void>;
62
+ /**
63
+ * {@link ensureMetricsHistory} for a backend that changes nothing in its
64
+ * database: the table is read, never made, and its absence is an error — which
65
+ * the caller turns into "no history", the same outcome as a create that failed.
66
+ * The sweep still runs: it deletes rows, and keeping the table bounded is not a
67
+ * schema change.
68
+ */
69
+ export declare function adoptExistingMetricsHistory(exec: Exec): Promise<void>;
62
70
  /**
63
71
  * What this process is using right now.
64
72
  *
@@ -2,11 +2,12 @@ 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 RebaseClientError, l as isUnsupported, s as RebaseApiError, t as splitSearchTerms } from "./strings.es-BeZWAodn.js";
6
- import { D as Vector, E as GeoPoint, S as hasFieldOperation, T as EntityRelation, _ as normalizeOrderBy, c as collectAllPages, l as paginateFind, u as resolveFindWindow, w as EntityReference } from "./filter-dialect-O1HK6Nvr.js";
7
- import { $ as parseRelationAggregateSort, tt as toCanonicalOp } from "./src-D5Y3N-S4.js";
8
- import { a as byIdRead, f as rebaseReviver, h as unref, i as buildQueryString, n as MemoryOfflineStore, p as etagOf, r as SDKQueryBuilder, s as createMutationId, t as IndexedDBOfflineStore, u as invalidBulkBody } from "./offline-store-BhYXViha-BW5j1We_.js";
9
- //#region ../client/dist/offline-BrqCRgih.js
5
+ import { a as RebaseClientError, i as RebaseApiError, o as isUnsupported } from "./data_driver-D0mg1Y_x.js";
6
+ import { D as Vector, E as GeoPoint, S as hasFieldOperation, T as EntityRelation, _ as normalizeOrderBy, c as collectAllPages, l as paginateFind, u as resolveFindWindow, w as EntityReference } from "./filter-dialect-TrKFWLqT.js";
7
+ import { et as parseRelationAggregateSort, nt as toCanonicalOp } from "./src-Bh9LRPb9.js";
8
+ import { t as splitSearchTerms } from "./strings.es-B7QLnHh2.js";
9
+ import { a as byIdRead, f as rebaseReviver, h as unref, i as buildQueryString, m as withETag, n as MemoryOfflineStore, p as etagOf, r as SDKQueryBuilder, s as createMutationId, t as IndexedDBOfflineStore, u as invalidBulkBody } from "./offline-store-BhYXViha-YTmjBpo2.js";
10
+ //#region ../client/dist/offline-AvuEcg7u.js
10
11
  /**
11
12
  * Lossless round-tripping of rows through the offline store.
12
13
  *
@@ -901,6 +902,13 @@ function isPlainObject(value) {
901
902
  const proto = Object.getPrototypeOf(value);
902
903
  return proto === null || Object.getPrototypeOf(proto) === null;
903
904
  }
905
+ /**
906
+ * The key a folded run is sent under: its first and last member and its
907
+ * length, which fixes its composition.
908
+ */
909
+ function runKey(run) {
910
+ return `${run[0].mutationId}..${run[run.length - 1].mutationId}:${run.length}`;
911
+ }
904
912
  /** Whether a queued write's payload mentions `id` anywhere — see {@link replaceMintedId}. */
905
913
  function mentionsMintedId(op, id) {
906
914
  return [op.data, ...(op.updates ?? []).map((u) => u.data)].some((payload) => replaceMintedId(payload, id, "").changed);
@@ -1324,19 +1332,45 @@ var OfflineManager = class {
1324
1332
  */
1325
1333
  ticket() {
1326
1334
  if (this.scopeHold) return void 0;
1327
- return {
1328
- scope: this.scope,
1329
- epoch: this.scopeEpoch
1330
- };
1335
+ return this.currentTicket();
1331
1336
  }
1332
1337
  /** The ticket of whoever the held scope turns out to be. */
1333
1338
  async nextTicket() {
1334
1339
  await this.scopeKnown();
1335
- return {
1340
+ return this.currentTicket();
1341
+ }
1342
+ currentTicket() {
1343
+ return this.unverified ? {
1344
+ scope: this.scope,
1345
+ epoch: this.scopeEpoch,
1346
+ unverified: true
1347
+ } : {
1336
1348
  scope: this.scope,
1337
1349
  epoch: this.scopeEpoch
1338
1350
  };
1339
1351
  }
1352
+ /**
1353
+ * Whether a write may go to the server now. Not while the scope is the last
1354
+ * user's, unconfirmed: it would go out with no credentials, as nobody. It
1355
+ * queues for its author instead, as it does offline.
1356
+ */
1357
+ sendsWrites() {
1358
+ return this.connectivity.shouldAttempt() && !this.unverified;
1359
+ }
1360
+ reachedUnconfirmedListeners = /* @__PURE__ */ new Set();
1361
+ /**
1362
+ * `listener` runs when a request made while the scope is unconfirmed
1363
+ * reached the server: the connection is back, so the session can be asked
1364
+ * about. The client wires it to the same confirmation the status listener
1365
+ * runs — the status may never have said the connection was gone.
1366
+ */
1367
+ onReachedUnconfirmed(listener) {
1368
+ this.reachedUnconfirmedListeners.add(listener);
1369
+ return () => this.reachedUnconfirmedListeners.delete(listener);
1370
+ }
1371
+ reachedUnconfirmed() {
1372
+ for (const listener of this.reachedUnconfirmedListeners) callSafely(listener);
1373
+ }
1340
1374
  /** Whether an operation's user is still the signed-in one. */
1341
1375
  isCurrent(ticket) {
1342
1376
  return ticket.epoch === this.scopeEpoch;
@@ -1393,15 +1427,18 @@ var OfflineManager = class {
1393
1427
  const res = await inner.find(params);
1394
1428
  this.connectivity.markSuccess();
1395
1429
  if (!this.isCurrent(ticket)) return res;
1396
- await this.ingest(slug, res.data ?? [], ticket, isProjection(params));
1397
- if (!this.isCurrent(ticket)) return res;
1398
- const snapshot = this.recordSnapshot(slug, params, res, ticket);
1399
- const answer = this.answer(slug, params, snapshot);
1400
- this.notifyCollection(slug, false);
1401
- return {
1402
- data: answer.data,
1403
- meta: answer.meta
1404
- };
1430
+ if (ticket.unverified) this.reachedUnconfirmed();
1431
+ else {
1432
+ await this.ingest(slug, res.data ?? [], ticket, isProjection(params));
1433
+ if (!this.isCurrent(ticket)) return res;
1434
+ const snapshot = this.recordSnapshot(slug, params, res, ticket);
1435
+ const answer = this.answer(slug, params, snapshot);
1436
+ this.notifyCollection(slug, false);
1437
+ return {
1438
+ data: answer.data,
1439
+ meta: answer.meta
1440
+ };
1441
+ }
1405
1442
  } catch (error) {
1406
1443
  if (!isNetworkError(error)) {
1407
1444
  if (isRetryableError(error) && this.hasLocalAnswer(state, slug, params) && !(walk && !this.snapshotFor(slug, params))) {
@@ -1435,10 +1472,16 @@ var OfflineManager = class {
1435
1472
  const row = await inner.findById(id, options);
1436
1473
  this.connectivity.markSuccess();
1437
1474
  if (!this.isCurrent(ticket)) return row;
1475
+ if (ticket.unverified) {
1476
+ this.reachedUnconfirmed();
1477
+ return this.localRow(slug, id);
1478
+ }
1438
1479
  if (row !== void 0) await this.ingest(slug, [row], ticket);
1439
1480
  else if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
1440
1481
  this.notifyCollection(slug, false);
1441
- return this.localRow(slug, id);
1482
+ const local = this.localRow(slug, id);
1483
+ const etag = etagOf(row);
1484
+ return etag && local !== void 0 && !this.hasPending(slug, id) ? withETag({ ...local }, etag) : local;
1442
1485
  } catch (error) {
1443
1486
  if (!isNetworkError(error)) throw error;
1444
1487
  this.connectivity.markFailure();
@@ -1461,7 +1504,7 @@ var OfflineManager = class {
1461
1504
  await this.ensureCollection(slug);
1462
1505
  const idempotencyKey = options?.idempotencyKey ?? createMutationId();
1463
1506
  let sent = false;
1464
- if (this.connectivity.shouldAttempt()) try {
1507
+ if (this.sendsWrites()) try {
1465
1508
  const row = await inner.create(data, id, {
1466
1509
  ...options,
1467
1510
  idempotencyKey
@@ -1503,7 +1546,7 @@ var OfflineManager = class {
1503
1546
  if (data.length === 0) return [];
1504
1547
  const idempotencyKey = options?.idempotencyKey ?? createMutationId();
1505
1548
  let sent = false;
1506
- if (this.connectivity.shouldAttempt()) try {
1549
+ if (this.sendsWrites()) try {
1507
1550
  const rows = await inner.createMany(data, {
1508
1551
  ...options,
1509
1552
  idempotencyKey
@@ -1555,7 +1598,7 @@ var OfflineManager = class {
1555
1598
  upsert: async (data, options) => {
1556
1599
  const ticket = this.ticket() ?? await this.nextTicket();
1557
1600
  await this.ensureCollection(slug);
1558
- if (this.connectivity.shouldAttempt()) try {
1601
+ if (this.sendsWrites()) try {
1559
1602
  const row = await inner.upsert(data, options);
1560
1603
  this.connectivity.markSuccess();
1561
1604
  await this.ingest(slug, [row], ticket);
@@ -1586,7 +1629,7 @@ var OfflineManager = class {
1586
1629
  if (!Array.isArray(updates)) throw invalidBulkBody("updateMany expects an array of { id, data } entries.");
1587
1630
  if (updates.length === 0) return [];
1588
1631
  const anyPending = updates.some((u) => this.hasPending(slug, u.id));
1589
- if (this.connectivity.shouldAttempt() && !anyPending) try {
1632
+ if (this.sendsWrites() && !anyPending) try {
1590
1633
  const rows = await inner.updateMany(updates, options);
1591
1634
  this.connectivity.markSuccess();
1592
1635
  await this.ingest(slug, rows, ticket);
@@ -1625,7 +1668,7 @@ var OfflineManager = class {
1625
1668
  if (!Array.isArray(ids)) throw invalidBulkBody("deleteMany expects an array of ids.");
1626
1669
  if (ids.length === 0) return;
1627
1670
  const anyPending = ids.some((id) => this.hasPending(slug, id));
1628
- if (this.connectivity.shouldAttempt() && !anyPending) try {
1671
+ if (this.sendsWrites() && !anyPending) try {
1629
1672
  await inner.deleteMany(ids, options);
1630
1673
  this.connectivity.markSuccess();
1631
1674
  for (const id of ids) this.removeLocalRow(slug, id, ticket, true);
@@ -1653,7 +1696,7 @@ var OfflineManager = class {
1653
1696
  const idempotencyKey = options?.idempotencyKey ?? createMutationId();
1654
1697
  let sent = false;
1655
1698
  let sendFailure;
1656
- if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
1699
+ if (this.sendsWrites() && !this.hasPending(slug, id)) try {
1657
1700
  const row = await inner.update(id, data, {
1658
1701
  ...options,
1659
1702
  idempotencyKey
@@ -1678,7 +1721,7 @@ var OfflineManager = class {
1678
1721
  throw new RebaseClientError(`Cannot apply a field operation to "${slug}" while offline: it is evaluated against the stored value, which this device does not have a current copy of. Send the value itself, or retry when the connection is back.`, { code: "OFFLINE_FIELD_OP_UNSUPPORTED" });
1679
1722
  }
1680
1723
  const base = this.rawLocalRow(slug, id);
1681
- const ifMatch = this.onConflict ? options?.ifMatch ?? (this.hasPending(slug, id) ? void 0 : this.etagFor(slug, id)) : void 0;
1724
+ const ifMatch = options?.ifMatch ?? (this.onConflict && !this.hasPending(slug, id) ? this.etagFor(slug, id) : void 0);
1682
1725
  await this.enqueue({
1683
1726
  collection: slug,
1684
1727
  type: "update",
@@ -1701,7 +1744,7 @@ var OfflineManager = class {
1701
1744
  await this.ensureCollection(slug);
1702
1745
  const idempotencyKey = options?.idempotencyKey ?? createMutationId();
1703
1746
  let sent = false;
1704
- if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
1747
+ if (this.sendsWrites() && !this.hasPending(slug, id)) try {
1705
1748
  await inner.delete(id, {
1706
1749
  ...options,
1707
1750
  idempotencyKey
@@ -1721,6 +1764,7 @@ var OfflineManager = class {
1721
1764
  type: "delete",
1722
1765
  id,
1723
1766
  ...sent ? { sent: { idempotencyKey } } : {},
1767
+ ...options?.ifMatch ? { ifMatch: options.ifMatch } : {},
1724
1768
  rollback: { rows: { [String(id)]: this.rawLocalRow(slug, id) ?? null } }
1725
1769
  }, ticket);
1726
1770
  this.removeLocalRow(slug, id, ticket);
@@ -1733,6 +1777,12 @@ var OfflineManager = class {
1733
1777
  const n = await inner.count(params);
1734
1778
  this.connectivity.markSuccess();
1735
1779
  if (!this.isCurrent(ticket)) return n;
1780
+ if (ticket.unverified) {
1781
+ this.reachedUnconfirmed();
1782
+ const cached = await this.readCache(this.countKey(slug, params));
1783
+ if (cached !== void 0) return Math.max(0, cached + this.pendingDelta(slug, params));
1784
+ throw offlineError(`No count of "${slug}" for the last user is cached, and their session is not confirmed yet.`);
1785
+ }
1736
1786
  this.writeCache(this.countKey(slug, params), n);
1737
1787
  return Math.max(0, n + this.pendingDelta(slug, params));
1738
1788
  } catch (error) {
@@ -1771,6 +1821,15 @@ var OfflineManager = class {
1771
1821
  onUpdate(response);
1772
1822
  return;
1773
1823
  }
1824
+ if (ticket.unverified) {
1825
+ const answer = this.localFind(slug, params);
1826
+ onUpdate({
1827
+ ...response,
1828
+ data: answer.data,
1829
+ meta: answer.meta
1830
+ });
1831
+ return;
1832
+ }
1774
1833
  this.ingest(slug, response.data ?? [], ticket, isProjection(params)).then(() => {
1775
1834
  if (!this.isCurrent(ticket)) return;
1776
1835
  const snapshot = this.recordSnapshot(slug, params, response, ticket);
@@ -1789,6 +1848,10 @@ var OfflineManager = class {
1789
1848
  onUpdate(row);
1790
1849
  return;
1791
1850
  }
1851
+ if (ticket.unverified) {
1852
+ onUpdate(this.localRow(slug, id));
1853
+ return;
1854
+ }
1792
1855
  if (!row) {
1793
1856
  if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
1794
1857
  this.notifyCollection(slug, false);
@@ -1820,7 +1883,7 @@ var OfflineManager = class {
1820
1883
  wrapTrash(slug, inner) {
1821
1884
  const online = async (what, call) => {
1822
1885
  const message = `Offline: ${what} on the trash of "${slug}" needs the server; the trash is never cached on this device.`;
1823
- if (!this.connectivity.shouldAttempt()) throw offlineError(message);
1886
+ if (!this.sendsWrites()) throw offlineError(message);
1824
1887
  try {
1825
1888
  const result = await call();
1826
1889
  this.connectivity.markSuccess();
@@ -1934,7 +1997,7 @@ var OfflineManager = class {
1934
1997
  })();
1935
1998
  if (options?.realtime !== false && !isUnsupported(inner.listen)) unlisten = inner.listen(params, (response) => {
1936
1999
  const ticket = this.ticket();
1937
- if (!ticket) return;
2000
+ if (!ticket || ticket.unverified) return;
1938
2001
  this.ingest(slug, response.data ?? [], ticket, isProjection(params)).then(() => {
1939
2002
  this.recordSnapshot(slug, params, response, ticket);
1940
2003
  this.notifyCollection(slug, false);
@@ -1989,7 +2052,7 @@ var OfflineManager = class {
1989
2052
  })();
1990
2053
  if (options?.realtime !== false && !isUnsupported(inner.listenById)) unlisten = inner.listenById(id, (row) => {
1991
2054
  const ticket = this.ticket();
1992
- if (!ticket) return;
2055
+ if (!ticket || ticket.unverified) return;
1993
2056
  if (!row) {
1994
2057
  if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
1995
2058
  this.notifyCollection(slug, false);
@@ -2314,6 +2377,7 @@ var OfflineManager = class {
2314
2377
  */
2315
2378
  removeLocalRow(slug, id, ticket, known = false) {
2316
2379
  if (!this.isCurrent(ticket)) return;
2380
+ if (known && ticket.unverified) return;
2317
2381
  const state = this.collectionState(slug);
2318
2382
  const key = String(id);
2319
2383
  const existed = state.rows.delete(key);
@@ -2346,7 +2410,7 @@ var OfflineManager = class {
2346
2410
  * dropdown elsewhere asked for titles.
2347
2411
  */
2348
2412
  async ingest(slug, rows, ticket, projection = false) {
2349
- if (rows.length === 0 || !this.isCurrent(ticket)) return;
2413
+ if (rows.length === 0 || !this.isCurrent(ticket) || ticket.unverified) return;
2350
2414
  const state = await this.ensureCollection(slug);
2351
2415
  if (!this.isCurrent(ticket)) return;
2352
2416
  const cachedAt = Date.now();
@@ -2450,7 +2514,7 @@ var OfflineManager = class {
2450
2514
  hasMore: meta.hasMore ?? false,
2451
2515
  ...isProjection(params) ? { rows: (result.data ?? []).map((row) => ({ ...row })) } : {}
2452
2516
  };
2453
- if (!this.isCurrent(ticket)) return snapshot;
2517
+ if (!this.isCurrent(ticket) || ticket.unverified) return snapshot;
2454
2518
  const state = this.collectionState(slug);
2455
2519
  const key = buildQueryString(params);
2456
2520
  state.snapshots.set(key, snapshot);
@@ -2548,7 +2612,7 @@ var OfflineManager = class {
2548
2612
  }
2549
2613
  if (mutation.type === "update") {
2550
2614
  const tail = this.queue[this.queue.length - 1];
2551
- if (tail && !this.inFlight.has(tail.mutationId) && tail.sent === void 0 && (tail.schemaVersion ?? 1) <= 1 && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id) {
2615
+ if (tail && !this.inFlight.has(tail.mutationId) && tail.sent === void 0 && (tail.schemaVersion ?? 1) <= 1 && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id && (mutation.ifMatch === void 0 || mutation.ifMatch === tail.ifMatch)) {
2552
2616
  if (tail.type === "update") tail.parts = [...tail.parts ?? [{ ...tail.data }], { ...mutation.data }];
2553
2617
  tail.data = {
2554
2618
  ...tail.data,
@@ -2729,7 +2793,7 @@ var OfflineManager = class {
2729
2793
  this.connectivity.markFailure();
2730
2794
  break;
2731
2795
  }
2732
- if (op.type === "update" && op.ifMatch && isPreconditionFailed(error)) {
2796
+ if ((op.type === "update" || op.type === "delete") && op.ifMatch && this.onConflict && isPreconditionFailed(error)) {
2733
2797
  this.connectivity.markSuccess();
2734
2798
  await this.resolveConflict(op, error, ticket);
2735
2799
  continue;
@@ -2800,16 +2864,18 @@ var OfflineManager = class {
2800
2864
  */
2801
2865
  foldableRun(unbatched) {
2802
2866
  if (this.replayBatchSize < 2) return [];
2803
- const foldable = (op, collection) => op !== void 0 && op.type === "create" && op.generatedId === true && typeof op.id === "string" && op.sent === void 0 && !op.upsert && (op.schemaVersion ?? 1) <= 1 && !unbatched.has(op.mutationId) && (collection === void 0 || op.collection === collection);
2804
2867
  const head = this.queue[0];
2868
+ const resent = head?.sent?.run;
2869
+ const foldable = (op, collection) => op !== void 0 && op.type === "create" && op.generatedId === true && typeof op.id === "string" && (resent === void 0 ? op.sent === void 0 : op.sent?.run === resent) && !op.upsert && (op.schemaVersion ?? 1) <= 1 && !unbatched.has(op.mutationId) && (collection === void 0 || op.collection === collection);
2805
2870
  if (!foldable(head)) return [];
2806
2871
  const run = [head];
2807
- for (let i = 1; i < this.queue.length && run.length < this.replayBatchSize; i++) {
2872
+ for (let i = 1; i < this.queue.length && (resent !== void 0 || run.length < this.replayBatchSize); i++) {
2808
2873
  const next = this.queue[i];
2809
2874
  if (!foldable(next, head.collection)) break;
2810
2875
  if (run.some((member) => mentionsMintedId(next, member.id))) break;
2811
2876
  run.push(next);
2812
2877
  }
2878
+ if (resent !== void 0 && runKey(run) !== resent) return [];
2813
2879
  return run;
2814
2880
  }
2815
2881
  /**
@@ -2830,9 +2896,10 @@ var OfflineManager = class {
2830
2896
  */
2831
2897
  async replayRun(run, ticket) {
2832
2898
  const inner = this.innerFor(run[0].collection);
2833
- const idempotencyKey = `${run[0].mutationId}..${run[run.length - 1].mutationId}:${run.length}`;
2899
+ const idempotencyKey = runKey(run);
2834
2900
  for (const member of run) this.inFlight.add(member.mutationId);
2835
2901
  try {
2902
+ for (const member of run) await this.markCreateSent(member, ticket, idempotencyKey);
2836
2903
  let rows;
2837
2904
  try {
2838
2905
  rows = await inner.createMany(run.map((member) => member.data), { idempotencyKey });
@@ -2854,6 +2921,7 @@ var OfflineManager = class {
2854
2921
  async replay(op, ticket) {
2855
2922
  const inner = this.innerFor(op.collection);
2856
2923
  if (op.type === "create") {
2924
+ await this.markCreateSent(op, ticket);
2857
2925
  let row;
2858
2926
  try {
2859
2927
  row = await this.sendQueuedWrite(op, (idempotencyKey) => inner.create(op.data, void 0, { idempotencyKey }), (sent) => inner.create(sent.data, sent.id, { idempotencyKey: sent.idempotencyKey }));
@@ -2906,11 +2974,30 @@ var OfflineManager = class {
2906
2974
  }
2907
2975
  for (const id of ids) this.removeLocalRow(op.collection, id, ticket, true);
2908
2976
  } else if (op.type === "delete") {
2909
- await inner.delete(op.id, { idempotencyKey: op.sent?.idempotencyKey ?? op.mutationId });
2977
+ await inner.delete(op.id, {
2978
+ idempotencyKey: op.sent?.idempotencyKey ?? op.mutationId,
2979
+ ...op.ifMatch ? { ifMatch: op.ifMatch } : {}
2980
+ });
2910
2981
  this.removeLocalRow(op.collection, op.id, ticket, true);
2911
2982
  }
2912
2983
  }
2913
2984
  /**
2985
+ * Record a queued create as sent, with the request about to go out, and
2986
+ * persist it — before the request, so a lost answer cannot leave it
2987
+ * unmarked. One already marked (sent online before it was queued, or by
2988
+ * an earlier replay) keeps what it holds: that is the request the server
2989
+ * may have stored.
2990
+ */
2991
+ async markCreateSent(op, ticket, run) {
2992
+ if (op.sent) return;
2993
+ op.sent = {
2994
+ idempotencyKey: op.mutationId,
2995
+ data: { ...op.data },
2996
+ ...run ? { run } : {}
2997
+ };
2998
+ await this.persistMutation(`${ticket.scope}|${op.mutationId}`, op).catch(() => void 0);
2999
+ }
3000
+ /**
2914
3001
  * Send a queued write under its key.
2915
3002
  *
2916
3003
  * A write the queue sent once already, before it was queued, carries that
@@ -3302,4 +3389,4 @@ var OfflineManager = class {
3302
3389
  //#endregion
3303
3390
  export { OfflineManager };
3304
3391
 
3305
- //# sourceMappingURL=offline-BrqCRgih-BtDB1ecy.js.map
3392
+ //# sourceMappingURL=offline-AvuEcg7u-DFHL98OT.js.map