@rebasepro/server 0.13.0 → 0.13.1-canary.g1822133

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 (136) hide show
  1. package/dist/{admin_block-CcSGdH7M.js → admin_block-H86dCPsj.js} +62 -2
  2. package/dist/admin_block-H86dCPsj.js.map +1 -0
  3. package/dist/api/ast-schema-editor.d.ts +74 -6
  4. package/dist/api/errors.d.ts +12 -6
  5. package/dist/api/openapi-generator.d.ts +14 -0
  6. package/dist/api/rest/api-generator.d.ts +26 -1
  7. package/dist/api/rest/idempotency.d.ts +62 -4
  8. package/dist/api/rest/query-parser.d.ts +15 -1
  9. package/dist/api/rest/write-validation.d.ts +52 -9
  10. package/dist/auth/adapter-middleware.d.ts +3 -1
  11. package/dist/auth/admin-roles.d.ts +24 -0
  12. package/dist/auth/apple-oauth.d.ts +16 -11
  13. package/dist/auth/bitbucket-oauth.d.ts +2 -4
  14. package/dist/auth/builtin-auth-adapter.d.ts +11 -0
  15. package/dist/auth/discord-oauth.d.ts +5 -6
  16. package/dist/auth/facebook-oauth.d.ts +2 -4
  17. package/dist/auth/github-oauth.d.ts +2 -4
  18. package/dist/auth/gitlab-oauth.d.ts +6 -4
  19. package/dist/auth/google-oauth.d.ts +1 -0
  20. package/dist/auth/index.d.ts +8 -1
  21. package/dist/auth/interfaces.d.ts +81 -1
  22. package/dist/auth/jwt.d.ts +52 -0
  23. package/dist/auth/linkedin-oauth.d.ts +2 -4
  24. package/dist/auth/mfa-crypto.d.ts +54 -3
  25. package/dist/auth/mfa-gate.d.ts +42 -0
  26. package/dist/auth/mfa-routes.d.ts +26 -1
  27. package/dist/auth/mfa.d.ts +16 -0
  28. package/dist/auth/microsoft-oauth.d.ts +19 -4
  29. package/dist/auth/middleware.d.ts +26 -0
  30. package/dist/auth/oauth-code-flow.d.ts +66 -0
  31. package/dist/auth/oauth-signin-policy.d.ts +61 -0
  32. package/dist/auth/oidc-id-token.d.ts +61 -0
  33. package/dist/auth/rate-limiter.d.ts +62 -5
  34. package/dist/auth/registration-policy.d.ts +31 -0
  35. package/dist/auth/rls-scope.d.ts +25 -0
  36. package/dist/auth/routes.d.ts +15 -0
  37. package/dist/auth/slack-oauth.d.ts +2 -4
  38. package/dist/auth/spotify-oauth.d.ts +2 -4
  39. package/dist/auth/token-revocation.d.ts +38 -0
  40. package/dist/auth/twitter-oauth.d.ts +2 -5
  41. package/dist/{auth-CuC9M2x6.js → auth-CYoPVf-E.js} +1996 -426
  42. package/dist/auth-CYoPVf-E.js.map +1 -0
  43. package/dist/{backup-CVggVhR2.js → backup-C6ljYVTp.js} +2 -2
  44. package/dist/{backup-CVggVhR2.js.map → backup-C6ljYVTp.js.map} +1 -1
  45. package/dist/boot/boot.d.ts +19 -0
  46. package/dist/boot/ddl-bootstrap.d.ts +70 -0
  47. package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-Bet-eCNJ.js} +3 -3
  48. package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-Bet-eCNJ.js.map} +1 -1
  49. package/dist/cron/cron-loader.d.ts +26 -1
  50. package/dist/cron/cron-routes.d.ts +1 -1
  51. package/dist/cron/cron-scheduler.d.ts +13 -3
  52. package/dist/cron/define-cron.d.ts +17 -3
  53. package/dist/cron/index.d.ts +2 -2
  54. package/dist/{cron-loader-B1S2MCSl.js → cron-loader-YhhQeVBM.js} +43 -8
  55. package/dist/cron-loader-YhhQeVBM.js.map +1 -0
  56. package/dist/{cron-routes-CrQ0tK-_.js → cron-routes-maM_RlUu.js} +10 -4
  57. package/dist/cron-routes-maM_RlUu.js.map +1 -0
  58. package/dist/{cron-scheduler-B3RFt0HS.js → cron-scheduler-DIpYBmZP.js} +40 -9
  59. package/dist/cron-scheduler-DIpYBmZP.js.map +1 -0
  60. package/dist/{cron-store-BywZsyfZ.js → cron-store-Dvr4Y1sZ.js} +66 -47
  61. package/dist/cron-store-Dvr4Y1sZ.js.map +1 -0
  62. package/dist/ddl-bootstrap-BhXbTnBl.js +183 -0
  63. package/dist/ddl-bootstrap-BhXbTnBl.js.map +1 -0
  64. package/dist/email/html.d.ts +54 -0
  65. package/dist/email/index.d.ts +3 -0
  66. package/dist/email/link-base.d.ts +39 -0
  67. package/dist/email/smtp-email-service.d.ts +7 -1
  68. package/dist/email/templates.d.ts +9 -1
  69. package/dist/email/types.d.ts +11 -1
  70. package/dist/env.d.ts +5 -0
  71. package/dist/{errors-CgkCzoj7.js → errors-EBYiaJ2E.js} +15 -11
  72. package/dist/errors-EBYiaJ2E.js.map +1 -0
  73. package/dist/function-loader-DDS1v7YX.js +139 -0
  74. package/dist/function-loader-DDS1v7YX.js.map +1 -0
  75. package/dist/{function-routes-C0cLIy3N.js → function-routes-Btcez1T-.js} +18 -6
  76. package/dist/function-routes-Btcez1T-.js.map +1 -0
  77. package/dist/functions/define-function.d.ts +25 -6
  78. package/dist/functions/function-loader.d.ts +23 -0
  79. package/dist/functions/function-routes.d.ts +7 -1
  80. package/dist/functions/request-timeout.d.ts +34 -0
  81. package/dist/history/history-routes.d.ts +6 -0
  82. package/dist/index.d.ts +3 -2
  83. package/dist/index.es.js +2147 -845
  84. package/dist/index.es.js.map +1 -1
  85. package/dist/init/docs.d.ts +10 -1
  86. package/dist/init/process-safety.d.ts +25 -0
  87. package/dist/init.d.ts +38 -0
  88. package/dist/{jwt-DD6EtpGj.js → jwt-_IFqfTOg.js} +72 -9
  89. package/dist/jwt-_IFqfTOg.js.map +1 -0
  90. package/dist/logger-DfvF_8r-.js +190 -0
  91. package/dist/logger-DfvF_8r-.js.map +1 -0
  92. package/dist/openapi-generator-DPKtUC9X.js +865 -0
  93. package/dist/openapi-generator-DPKtUC9X.js.map +1 -0
  94. package/dist/request-timeout-RivJsME0.js +65 -0
  95. package/dist/request-timeout-RivJsME0.js.map +1 -0
  96. package/dist/schema-editor-routes-CRcS3ArS.js +437 -0
  97. package/dist/schema-editor-routes-CRcS3ArS.js.map +1 -0
  98. package/dist/serve-spa.d.ts +5 -4
  99. package/dist/services/outbound-url-guard.d.ts +53 -0
  100. package/dist/services/routed-realtime-service.d.ts +10 -2
  101. package/dist/services/webhook-service.d.ts +76 -1
  102. package/dist/singleton.d.ts +25 -8
  103. package/dist/{src-Cum9kox5.js → src-C7rkDGxA.js} +678 -89
  104. package/dist/src-C7rkDGxA.js.map +1 -0
  105. package/dist/{src-_qQ3RNCK.js → src-Cz9nMgUR.js} +93 -3
  106. package/dist/src-Cz9nMgUR.js.map +1 -0
  107. package/dist/storage/LocalStorageController.d.ts +12 -1
  108. package/dist/storage/image-transform.d.ts +57 -0
  109. package/dist/storage/keys.d.ts +98 -0
  110. package/dist/storage/routes.d.ts +11 -0
  111. package/dist/storage/tus-handler.d.ts +22 -2
  112. package/dist/utils/logger.d.ts +18 -0
  113. package/dist/utils/logging.d.ts +0 -4
  114. package/dist/utils/sql.d.ts +11 -6
  115. package/package.json +7 -6
  116. package/dist/admin_block-CcSGdH7M.js.map +0 -1
  117. package/dist/auth-CuC9M2x6.js.map +0 -1
  118. package/dist/backend-CIxN4FVm.js +0 -15
  119. package/dist/backend-CIxN4FVm.js.map +0 -1
  120. package/dist/cron-loader-B1S2MCSl.js.map +0 -1
  121. package/dist/cron-routes-CrQ0tK-_.js.map +0 -1
  122. package/dist/cron-scheduler-B3RFt0HS.js.map +0 -1
  123. package/dist/cron-store-BywZsyfZ.js.map +0 -1
  124. package/dist/errors-CgkCzoj7.js.map +0 -1
  125. package/dist/function-loader-B_1fYfUY.js +0 -86
  126. package/dist/function-loader-B_1fYfUY.js.map +0 -1
  127. package/dist/function-routes-C0cLIy3N.js.map +0 -1
  128. package/dist/jwt-DD6EtpGj.js.map +0 -1
  129. package/dist/logger-BYU66ENZ.js +0 -94
  130. package/dist/logger-BYU66ENZ.js.map +0 -1
  131. package/dist/openapi-generator-BEwyiaAr.js +0 -597
  132. package/dist/openapi-generator-BEwyiaAr.js.map +0 -1
  133. package/dist/schema-editor-routes-CbF20Mf1.js +0 -248
  134. package/dist/schema-editor-routes-CbF20Mf1.js.map +0 -1
  135. package/dist/src-Cum9kox5.js.map +0 -1
  136. package/dist/src-_qQ3RNCK.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -1,27 +1,27 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
- import { S as Vector, a as serializeFilter, b as EntityRelation, c as paginateFind, d as resolveDataSource, f as getEffectiveSecurityRules, g as toSnakeCase, h as buildCompositeId, i as deserializeLogicalCondition, l as CollectionRegistry, m as resolveCollectionRelations, n as buildSdkData, o as serializeLogicalCondition, p as findRelation, r as deserializeFilter, s as collectAllPages, t as buildRoutedRebaseData, u as createDataSourceRegistry, x as GeoPoint, y as EntityReference } from "./src-Cum9kox5.js";
5
- import { a as DEFAULT_DATA_SOURCE_KEY, r as isPostgresCollectionConfig, t as getCollectionDataPath, u as toCanonicalOp } from "./src-_qQ3RNCK.js";
6
- import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-CcSGdH7M.js";
7
- import { t as isSQLAdmin } from "./backend-CIxN4FVm.js";
8
- import { n as createContractRoutes } from "./contract-routes-Dj8i5AiM.js";
9
- import { $ as httpMethodToOperation, A as getEmailVerificationTemplate, B as optionalAuth, C as createDataRateLimiter, D as validatePasswordStrength, E as hashPassword, F as createAdapterAuthMiddleware, G as createApiKeyPreAuth, H as queryTokenAuth, I as createAuthMiddleware, J as isApiKeyToken, K as createFunctionApiKeyGuard, L as createRequireAuth, M as getPasswordResetTemplate, N as getUserInvitationTemplate, O as verifyPassword, P as getWelcomeEmailTemplate, Q as scopeDataDriver, R as extractUserFromToken, S as _coercedNumber, T as resolveAuthHooks, U as requireAdmin, V as publicObjectAuth, W as requireAuth, X as extractBearerToken, Y as validateApiKey, Z as safeCompare, _ as createBuiltinAuthAdapter, a as createSpotifyProvider, b as object, c as createGitLabProvider, d as createFacebookProvider, et as isOperationAllowed, f as createAppleProvider, g as createGoogleProvider, h as createLinkedinProvider, i as createApiKeyStore, it as resolveClientListLimit, j as getMagicLinkTemplate, k as generateSecurePassword, l as createDiscordProvider, m as createGitHubProvider, n as createCustomAuthAdapter, nt as isPublicStoragePath, o as createSlackProvider, p as createMicrosoftProvider, q as createStorageApiKeyGuard, r as createApiKeyRoutes, s as createBitbucketProvider, tt as PUBLIC_STORAGE_PREFIX, u as createTwitterProvider, v as ZodNumber, w as MemoryRateLimitStore, x as string, y as _enum, z as fileTokenAuth } from "./auth-CuC9M2x6.js";
10
- import { t as logger } from "./logger-BYU66ENZ.js";
11
- import { n as errorHandler, r as isRebaseApiError, t as ApiError } from "./errors-CgkCzoj7.js";
12
- import { i as generateDownloadToken, n as configureJwt } from "./jwt-DD6EtpGj.js";
4
+ import { A as RebaseApiError, D as EntityRelation, E as EntityReference, O as GeoPoint, _ as resolveCollectionRelations, b as toSnakeCase, c as collectAllPages, d as CollectionRegistry, f as createDataSourceRegistry, g as findRelation, h as fieldKeyForColumn, j as RebaseClientError, k as Vector, l as paginateFind, m as getEffectiveSecurityRules, n as buildSdkData, o as serializeFilter, p as resolveDataSource, s as serializeLogicalCondition, t as buildRoutedRebaseData, u as resolveFindWindow, v as buildCompositeId, y as resolvePrimaryKeys } from "./src-C7rkDGxA.js";
5
+ import { c as DEFAULT_DATA_SOURCE_KEY, i as getCollectionDataPath, m as toCanonicalOp, n as RLS_ROLES_SQL, o as isPostgresCollectionConfig, r as RLS_UID_SQL, t as RLS_JWT_SQL } from "./src-Cz9nMgUR.js";
6
+ import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-H86dCPsj.js";
7
+ import { i as isSQLAdmin, r as revokeInternalTableSql } from "./ddl-bootstrap-BhXbTnBl.js";
8
+ import { n as createContractRoutes } from "./contract-routes-Bet-eCNJ.js";
9
+ import { $ as fileTokenAuth, A as MemoryRateLimitStore, B as getEmailVerificationTemplate, C as ZodNumber, Ct as isPublicStoragePath, D as _coercedNumber, E as string, F as resolveAuthHooks, G as RawHtml, H as getPasswordResetTemplate, I as hashPassword, J as raw, K as escapeHtml, L as validatePasswordStrength, M as createEmailService, N as assertEmailLinkBases, O as DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT, P as resolveEmailLinkBase, Q as extractUserFromToken, R as verifyPassword, S as createBuiltinAuthAdapter, St as PUBLIC_STORAGE_PREFIX, T as object, U as getUserInvitationTemplate, V as getMagicLinkTemplate, W as getWelcomeEmailTemplate, X as createAuthMiddleware, Y as createAdapterAuthMiddleware, Z as createRequireAuth, _ as createLinkedinProvider, _t as scopeDataDriver, a as createSpotifyProvider, at as InvalidStorageBucketError, b as pkceTokenParams, bt as parseQueryOptions, c as createGitLabProvider, ct as canonicalStorageKey, d as createFacebookProvider, dt as createStorageApiKeyGuard, et as optionalAuth, f as createAppleProvider, ft as isApiKeyToken, g as createGitHubProvider, gt as SERVICE_IDENTITY, h as verifyOidcIdToken, ht as safeCompare, i as createApiKeyStore, it as requireAuth, j as SMTPEmailService, k as createDataRateLimiter, l as createDiscordProvider, lt as createApiKeyPreAuth, m as tryVerifyOidcIdToken, mt as extractBearerToken, n as createCustomAuthAdapter, nt as queryTokenAuth, o as createSlackProvider, ot as InvalidStorageKeyError, p as createMicrosoftProvider, pt as validateApiKey, q as html, r as createApiKeyRoutes, rt as requireAdmin, s as createBitbucketProvider, st as canonicalStorageBucket, tt as publicObjectAuth, u as createTwitterProvider, ut as createFunctionApiKeyGuard, v as createGoogleProvider, vt as httpMethodToOperation, w as _enum, x as providerVerifiedEmail, xt as resolveListLimitParam, y as oauthCodeFlowSchema, yt as isOperationAllowed, z as generateSecurePassword } from "./auth-CYoPVf-E.js";
10
+ import { t as logger } from "./logger-DfvF_8r-.js";
11
+ import { n as errorHandler, t as ApiError } from "./errors-EBYiaJ2E.js";
12
+ import { i as generateDownloadToken, n as configureJwt } from "./jwt-_IFqfTOg.js";
13
13
  import { t as logMiddleware } from "./logs-routes-BYA72C_C.js";
14
14
  import { n as DOCUMENT_MIME_TYPES, r as IMAGE_MIME_TYPES, t as DEFAULT_MAX_FILE_SIZE } from "./types-DSnOC4mF.js";
15
15
  import { t as S3StorageController } from "./S3StorageController-B2EDXXMH.js";
16
16
  import { t as GCSStorageController } from "./GCSStorageController-BEmDYFKc.js";
17
- import { n as loadFunctionsFromDirectory } from "./function-loader-B_1fYfUY.js";
18
- import { t as createFunctionRoutes } from "./function-routes-C0cLIy3N.js";
19
- import { n as loadCronJobsFromDirectory } from "./cron-loader-B1S2MCSl.js";
20
- import { r as validateCronExpression, t as CronScheduler } from "./cron-scheduler-B3RFt0HS.js";
21
- import { t as createCronRoutes } from "./cron-routes-CrQ0tK-_.js";
22
- import { t as createCronStore } from "./cron-store-BywZsyfZ.js";
23
- import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-CVggVhR2.js";
24
- import { randomBytes, randomUUID } from "node:crypto";
17
+ import { n as loadFunctionsFromDirectory } from "./function-loader-DDS1v7YX.js";
18
+ import { t as createFunctionRoutes } from "./function-routes-Btcez1T-.js";
19
+ import { n as loadCronJobsFromDirectory, r as loadCronJobsWithDiagnostics } from "./cron-loader-YhhQeVBM.js";
20
+ import { r as validateCronExpression, t as CronScheduler } from "./cron-scheduler-DIpYBmZP.js";
21
+ import { t as createCronRoutes } from "./cron-routes-maM_RlUu.js";
22
+ import { t as createCronStore } from "./cron-store-Dvr4Y1sZ.js";
23
+ import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-C6ljYVTp.js";
24
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
25
25
  import * as fs$2 from "fs";
26
26
  import fs, { existsSync } from "fs";
27
27
  import * as path$2 from "path";
@@ -31,79 +31,24 @@ import { Hono } from "hono";
31
31
  import { promisify } from "util";
32
32
  import * as crypto$1 from "crypto";
33
33
  import { createHmac, randomUUID as randomUUID$1 } from "crypto";
34
+ import path$1 from "node:path";
34
35
  import { bodyLimit } from "hono/body-limit";
35
36
  import { csrf } from "hono/csrf";
36
37
  import { compress } from "hono/compress";
37
38
  import fs$1 from "node:fs";
38
39
  import fsp from "node:fs/promises";
40
+ import os from "node:os";
39
41
  import { mkdir, open, unlink, writeFile } from "fs/promises";
40
42
  import { sql } from "drizzle-orm";
43
+ import { lookup } from "node:dns/promises";
44
+ import { isIP } from "node:net";
41
45
  import { serveStatic } from "@hono/node-server/serve-static";
42
46
  import { createServer } from "http";
43
47
  import { cors } from "hono/cors";
44
48
  import { secureHeaders } from "hono/secure-headers";
45
49
  import { getRequestListener } from "@hono/node-server";
46
- import path$1 from "node:path";
47
- import os from "node:os";
48
50
  import { execFile } from "node:child_process";
49
51
  import { promisify as promisify$1 } from "node:util";
50
- //#region ../types/src/errors.ts
51
- /**
52
- * The single error type thrown across the entire Rebase client surface —
53
- * HTTP data/control-plane calls, realtime/WebSocket operations, and
54
- * client-side logic errors (e.g. an unknown collection accessor). A `catch`
55
- * block only ever needs to check for this one class:
56
- *
57
- * ```ts
58
- * import { RebaseApiError } from "@rebasepro/client"; // re-exported
59
- *
60
- * try {
61
- * await client.data.products.update(id, { price: 9 });
62
- * } catch (e) {
63
- * if (e instanceof RebaseApiError) {
64
- * if (e.status === 404) { ... } // HTTP failures carry a status
65
- * console.error(e.code, e.details);
66
- * }
67
- * }
68
- * ```
69
- *
70
- * `status` is present for HTTP failures and `undefined` otherwise, so its
71
- * presence distinguishes transport-level errors from realtime/logic errors.
72
- *
73
- * @group Errors
74
- */
75
- var RebaseApiError = class extends Error {
76
- /** HTTP status code, or `undefined` for non-HTTP errors. */
77
- status;
78
- /** Stable machine-readable error code, when the server supplied one. */
79
- code;
80
- /** Structured error payload from the server, when present. */
81
- details;
82
- constructor(message, init = {}) {
83
- super(message);
84
- this.name = "RebaseApiError";
85
- this.status = init.status;
86
- this.code = init.code;
87
- this.details = init.details;
88
- if (init.cause !== void 0) this.cause = init.cause;
89
- }
90
- };
91
- /**
92
- * Client-side logic error — raised before any request is made (e.g. accessing
93
- * an unknown collection accessor when a typed dictionary is configured).
94
- *
95
- * A subclass of {@link RebaseApiError} (with no `status`), so a single
96
- * `catch (e) { if (e instanceof RebaseApiError) ... }` handles it too.
97
- *
98
- * @group Errors
99
- */
100
- var RebaseClientError = class extends RebaseApiError {
101
- constructor(message) {
102
- super(message);
103
- this.name = "RebaseClientError";
104
- }
105
- };
106
- //#endregion
107
52
  //#region ../types/src/types/storage_source.ts
108
53
  /**
109
54
  * Describes a named storage backend — a place files live.
@@ -250,23 +195,6 @@ function serializeOrderBy(orderBy) {
250
195
  if (typeof orderBy === "string") return orderBy;
251
196
  return `${orderBy[0]}:${orderBy[1]}`;
252
197
  }
253
- /**
254
- * Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
255
- *
256
- * Lenient parsing (matches existing server behaviour):
257
- * - Bare field name (no colon): `"name"` → `["name", "asc"]`
258
- * - Unknown direction: `"name:foo"` → `["name", "asc"]`
259
- * - Empty / falsy input: → `undefined`
260
- *
261
- * @param raw - The wire-format string from an HTTP query parameter.
262
- * @returns The canonical tuple, or `undefined` if the input is empty/falsy.
263
- */
264
- function deserializeOrderBy(raw) {
265
- if (!raw) return void 0;
266
- const idx = raw.indexOf(":");
267
- if (idx === -1) return [raw, "asc"];
268
- return [raw.slice(0, idx), raw.slice(idx + 1) === "desc" ? "desc" : "asc"];
269
- }
270
198
  //#endregion
271
199
  //#region src/collections/BackendCollectionRegistry.ts
272
200
  /**
@@ -311,7 +239,6 @@ function unknownKeyPolicyFromEnv(env = process.env) {
311
239
  ].includes(raw)) return "off";
312
240
  return "warn";
313
241
  }
314
- /** `BaseCollectionConfig`, plus every engine-specific field, plus the `admin` block. */
315
242
  var COLLECTION_KEYS = /* @__PURE__ */ new Set([
316
243
  "slug",
317
244
  "name",
@@ -333,6 +260,7 @@ var COLLECTION_KEYS = /* @__PURE__ */ new Set([
333
260
  "relations",
334
261
  "securityRules",
335
262
  "schema",
263
+ "search",
336
264
  "path",
337
265
  "subcollections",
338
266
  "admin"
@@ -544,6 +472,35 @@ function checkRelation(relation, path, collect) {
544
472
  for (const field of RELATION_LINK_FIELDS) if (relation[field] !== void 0 && !allowed.includes(field)) collect.error(`${path}.${field}`, `\`${field}\` is not valid on a "${kind}" relation. A "${kind}" takes ${allowed.length ? allowed.map((a) => `\`${a}\``).join(" and ") : "no link field"}.`);
545
473
  }
546
474
  }
475
+ /**
476
+ * `validation.matches`, checked by compiling it.
477
+ *
478
+ * This file is about key *identity* and this is a value — but it is a value
479
+ * with the property the file exists for: no runtime signal. `toPattern` in
480
+ * `write-validation.ts` builds the `RegExp` per request and answers `undefined`
481
+ * when the pattern will not compile, and the caller reads
482
+ * `if (pattern && !pattern.test(value))` — so a pattern with an unclosed
483
+ * bracket does not reject the write, it removes the rule. Every value passes,
484
+ * for the lifetime of the deployment, and the only trace is that the author
485
+ * believes a validation is running.
486
+ *
487
+ * Fatal for the reason a renamed key is fatal: the author wrote a rule to keep
488
+ * something out of their database, and a minute of downtime beats finding out
489
+ * from the data.
490
+ *
491
+ * A `RegExp` literal cannot be wrong here — the engine already compiled it — so
492
+ * only strings are checked.
493
+ */
494
+ function checkValidationPattern(validation, path, collect) {
495
+ if (!isPlainObject$1(validation)) return;
496
+ const matches = validation.matches;
497
+ if (typeof matches !== "string") return;
498
+ try {
499
+ new RegExp(matches);
500
+ } catch (e) {
501
+ collect.error(`${path}.matches`, `\`matches: ${JSON.stringify(matches)}\` is not a valid regular expression (${e instanceof Error ? e.message : String(e)}). It would compile to nothing at write time, which does not reject values — it silently drops the rule, and every value passes.`);
502
+ }
503
+ }
547
504
  function checkProperty(property, path, collect) {
548
505
  if (typeof property === "function") return;
549
506
  if (!isPlainObject$1(property)) {
@@ -571,6 +528,7 @@ function checkProperty(property, path, collect) {
571
528
  collect.unknown(`${path}.${key}`, key, `property (\`${String(type)}\`)`);
572
529
  }
573
530
  if (type === "relation" && property.relation !== void 0) checkRelation(property.relation, `${path}.relation`, collect);
531
+ checkValidationPattern(property.validation, `${path}.validation`, collect);
574
532
  if (type === "array") {
575
533
  const of = property.of;
576
534
  if (Array.isArray(of)) of.forEach((entry, index) => checkProperty(entry, `${path}.of[${index}]`, collect));
@@ -785,6 +743,20 @@ function isDataDriverDelegate(obj) {
785
743
  //#endregion
786
744
  //#region src/services/routed-realtime-service.ts
787
745
  /**
746
+ * Channel/presence/broadcast messages are engine-agnostic pub/sub — which is
747
+ * why they go to a provider that implements them, not to whichever provider a
748
+ * bootstrapper happened to mark `isDefault`.
749
+ */
750
+ var CHANNEL_MESSAGE_TYPES$1 = /* @__PURE__ */ new Set([
751
+ "join_channel",
752
+ "leave_channel",
753
+ "broadcast",
754
+ "presence_track",
755
+ "presence_untrack",
756
+ "presence_state",
757
+ "channel_history"
758
+ ]);
759
+ /**
788
760
  * Compose multiple per-engine {@link RealtimeProvider}s into one that routes
789
761
  * each subscription to the provider owning the subscribed collection — the
790
762
  * realtime counterpart of `buildRoutedRebaseData`.
@@ -794,8 +766,9 @@ function isDataDriverDelegate(obj) {
794
766
  * - `subscribe_collection` / `subscribe_entity` → the provider for the
795
767
  * collection's data source (by `payload.path`).
796
768
  * - `unsubscribe` → forwarded to all providers (a no-op on non-owners).
797
- * - channel / presence / broadcast → the default provider (these are global
798
- * pub/sub, not bound to an engine).
769
+ * - channel / presence / broadcast → the first provider that declares
770
+ * `supportsChannels` (these are global pub/sub, not bound to an engine — but
771
+ * they are not implemented by every engine either).
799
772
  * - `addClient` and lifecycle (`onServerReady`/`destroy`/`stopListening`) →
800
773
  * all providers (each registers its own ws close handler for cleanup).
801
774
  */
@@ -809,6 +782,30 @@ function createRoutedRealtimeService(opts) {
809
782
  const key = resolveKey(path);
810
783
  return asWs(providers[key] ?? providers[defaultKey] ?? Object.values(providers)[0]);
811
784
  };
785
+ /**
786
+ * The provider channel frames go to, resolved once and reported once.
787
+ *
788
+ * By capability, not by default-ness: the default provider is whichever
789
+ * bootstrapper set `isDefault`, and routing pub/sub there sends every
790
+ * broadcast, presence update and catch-up request into an engine that may
791
+ * not implement any of them. When the default *does* support channels it is
792
+ * still preferred, so single-engine behaviour is unchanged.
793
+ */
794
+ let channelRouting;
795
+ const forChannels = () => {
796
+ if (!channelRouting) {
797
+ const entries = Object.entries(providers);
798
+ const supports = ([, p]) => asWs(p).supportsChannels === true;
799
+ const chosen = entries.find(([key]) => key === defaultKey && supports([key, providers[key]])) ?? entries.find(supports);
800
+ channelRouting = chosen ? {
801
+ provider: asWs(chosen[1]),
802
+ key: chosen[0]
803
+ } : {};
804
+ if (chosen) logger.info(`📡 [Realtime] Channels, presence and broadcast are served by the "${chosen[0]}" data source.`);
805
+ else logger.error(`❌ [Realtime] No configured realtime provider implements channels, presence or broadcast — every channel frame will be refused. Configured sources: [${entries.map(([k]) => k).join(", ")}]. Channels currently require a Postgres data source.`);
806
+ }
807
+ return channelRouting.provider;
808
+ };
812
809
  return {
813
810
  addClient(clientId, ws) {
814
811
  for (const p of all()) p.addClient?.(clientId, ws);
@@ -823,6 +820,12 @@ function createRoutedRealtimeService(opts) {
823
820
  await Promise.all(all().map((p) => p.handleClientMessage(clientId, message, authContext)));
824
821
  return;
825
822
  }
823
+ if (CHANNEL_MESSAGE_TYPES$1.has(type)) {
824
+ const provider = forChannels();
825
+ if (!provider) throw new Error(`Channels are not supported by any configured data source — "${type}" refused.`);
826
+ await provider.handleClientMessage(clientId, message, authContext);
827
+ return;
828
+ }
826
829
  await fallback().handleClientMessage(clientId, message, authContext);
827
830
  },
828
831
  subscribeToCollection(subscriptionId, config, callback) {
@@ -849,173 +852,22 @@ function createRoutedRealtimeService(opts) {
849
852
  };
850
853
  }
851
854
  //#endregion
852
- //#region src/api/rest/query-parser.ts
853
- function getLastValue(val) {
854
- if (Array.isArray(val)) return val[val.length - 1];
855
- return val;
856
- }
857
- /**
858
- * Parse an `or(...)` / `and(...)` logical group from its wire form.
859
- *
860
- * The wire carries the inner conditions wrapped in parens (e.g.
861
- * `(status.eq.active,age.gte.18)`); we re-attach the `or`/`and` prefix and
862
- * delegate to the canonical filter dialect (`@rebasepro/common`). Values are
863
- * preserved as strings — type coercion is the schema-aware driver's job, so
864
- * this path stays byte-for-byte consistent with the SDK/admin path (which
865
- * also parses via the shared dialect).
866
- */
867
- function parseLogicalGroup(type, raw) {
868
- let inner = String(raw).trim();
869
- if (inner.startsWith("(") && inner.endsWith(")")) inner = inner.slice(1, -1);
870
- inner = inner.trim();
871
- if (!inner) return void 0;
872
- const parsed = deserializeLogicalCondition(`${type}(${inner})`);
873
- return "type" in parsed ? parsed : void 0;
874
- }
875
- /**
876
- * Parse the `?where=` JSON filter object.
877
- *
878
- * This is the dialect the OpenAPI document publishes on every
879
- * `GET /api/data/{slug}` — `{"status":["==","active"]}`: field → canonical
880
- * `[WhereFilterOp, value]` tuple. It is normalized through the same
881
- * `deserializeFilter` as the `?field=op.value` params below, so a value that
882
- * arrives as a PostgREST dot-string (`{"status":"eq.active"}`) or as a bare
883
- * scalar (`{"status":"active"}`) compiles to the same condition. Unlike the
884
- * querystring dialect, JSON carries types — a number stays a number.
885
- *
886
- * A malformed value is a 400 rather than a silent drop: dropping the filter
887
- * would run the read unfiltered and return everything RLS happens to allow.
888
- */
889
- function parseWhereParam(raw) {
890
- const str = String(raw).trim();
891
- if (!str) return void 0;
892
- let parsed;
893
- try {
894
- parsed = JSON.parse(str);
895
- } catch {
896
- throw ApiError.badRequest("Invalid `where` parameter: expected a JSON object, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
897
- }
898
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw ApiError.badRequest("Invalid `where` parameter: expected a JSON object mapping fields to conditions, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
899
- const filter = deserializeFilter(parsed);
900
- return Object.keys(filter).length > 0 ? filter : void 0;
901
- }
902
- /**
903
- * Parse query parameters into QueryOptions
904
- */
905
- function parseQueryOptions(query, limits = {}) {
906
- const options = {};
907
- const rawLimit = getLastValue(query.limit);
908
- const offsetVal = getLastValue(query.offset);
909
- if (offsetVal) options.offset = parseInt(String(offsetVal));
910
- const pageVal = getLastValue(query.page);
911
- if (pageVal) {
912
- const page = parseInt(String(pageVal));
913
- const limit = resolveClientListLimit(rawLimit, {
914
- defaultLimit: limits.defaultLimit,
915
- maxLimit: limits.maxLimit
916
- });
917
- options.offset = (page - 1) * limit;
918
- }
919
- const orVal = getLastValue(query.or);
920
- const andVal = getLastValue(query.and);
921
- if (orVal) {
922
- const logical = parseLogicalGroup("or", orVal);
923
- if (logical) options.logical = logical;
924
- } else if (andVal) {
925
- const logical = parseLogicalGroup("and", andVal);
926
- if (logical) options.logical = logical;
927
- }
928
- const reservedQueryKeys = [
929
- "limit",
930
- "offset",
931
- "page",
932
- "orderBy",
933
- "include",
934
- "fields",
935
- "searchString",
936
- "vector_search",
937
- "vector",
938
- "vector_distance",
939
- "vector_threshold",
940
- "or",
941
- "and",
942
- "where"
943
- ];
944
- const filterDict = {};
945
- for (const [key, rawValue] of Object.entries(query)) {
946
- if (reservedQueryKeys.includes(key)) continue;
947
- filterDict[key] = rawValue;
948
- }
949
- const whereVal = getLastValue(query.where);
950
- const where = {
951
- ...whereVal !== void 0 && whereVal !== null ? parseWhereParam(whereVal) : void 0,
952
- ...deserializeFilter(filterDict)
953
- };
954
- if (Object.keys(where).length > 0) options.where = where;
955
- const orderByVal = getLastValue(query.orderBy);
956
- if (orderByVal) try {
957
- options.orderBy = typeof orderByVal === "string" ? JSON.parse(orderByVal) : orderByVal;
958
- } catch {
959
- if (typeof orderByVal === "string") {
960
- const parsed = deserializeOrderBy(orderByVal);
961
- if (parsed) options.orderBy = [{
962
- field: parsed[0],
963
- direction: parsed[1]
964
- }];
965
- }
966
- }
967
- const includeVal = getLastValue(query.include);
968
- if (includeVal) {
969
- const includeStr = String(includeVal).trim();
970
- if (includeStr === "*") options.include = ["*"];
971
- else options.include = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
972
- }
973
- const fieldsVal = getLastValue(query.fields);
974
- if (fieldsVal) options.fields = String(fieldsVal).trim().split(",").map((s) => s.trim()).filter(Boolean);
975
- const vectorSearchVal = getLastValue(query.vector_search);
976
- const vectorVal = getLastValue(query.vector);
977
- if (vectorSearchVal && vectorVal) {
978
- const vectorStr = String(vectorVal);
979
- let decoded;
980
- try {
981
- decoded = JSON.parse(vectorStr);
982
- } catch {
983
- decoded = void 0;
984
- }
985
- if (!Array.isArray(decoded) || !decoded.every((v) => typeof v === "number")) throw ApiError.badRequest("Invalid `vector` format. Expected a JSON array of numbers, e.g. [0.1,0.2,0.3]", "INVALID_VECTOR");
986
- const queryVector = decoded;
987
- const distanceParamVal = getLastValue(query.vector_distance);
988
- const distanceParam = distanceParamVal ? String(distanceParamVal) : "cosine";
989
- if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") throw ApiError.badRequest(`Invalid \`vector_distance\`: ${distanceParam}. Expected: cosine, l2, or inner_product`, "INVALID_VECTOR_DISTANCE");
990
- const vectorSearch = {
991
- property: String(vectorSearchVal),
992
- vector: queryVector,
993
- distance: distanceParam
994
- };
995
- const thresholdVal = getLastValue(query.vector_threshold);
996
- if (thresholdVal) {
997
- const threshold = parseFloat(String(thresholdVal));
998
- if (isNaN(threshold)) throw ApiError.badRequest("Invalid `vector_threshold`. Expected a number.", "INVALID_VECTOR_THRESHOLD");
999
- vectorSearch.threshold = threshold;
1000
- }
1001
- options.vectorSearch = vectorSearch;
1002
- }
1003
- options.limit = resolveClientListLimit(rawLimit, {
1004
- vectorSearch: !!options.vectorSearch,
1005
- defaultLimit: limits.defaultLimit,
1006
- maxLimit: limits.maxLimit
1007
- });
1008
- return options;
1009
- }
1010
- //#endregion
1011
855
  //#region src/api/rest/write-validation.ts
1012
856
  /**
1013
857
  * Reject a write naming a field the collection does not have.
1014
858
  *
1015
- * Unknown keys used to travel all the way into the INSERT, where Postgres
1016
- * rejected them — so a typo came back as `column "titel" does not exist`,
1017
- * phrased by the database, from a stack the caller cannot see, and only if the
1018
- * column really was absent. It is a request problem and belongs in a 400.
859
+ * An unknown key does *not* reach the INSERT and come back as `column "titel"
860
+ * does not exist`, which is what this comment used to claim. Drizzle builds
861
+ * INSERT and UPDATE from the table's own column list, so a key the table does
862
+ * not carry is left out of the statement: the write answers 201 having stored
863
+ * nothing under that name. A typo is a request problem, it belongs in a 400,
864
+ * and nothing below this was ever going to raise one.
865
+ *
866
+ * This is the config-level check and it is skipped on several paths (see the
867
+ * `strictWrites` bail below, and the auth-adapter contract at the call site).
868
+ * `assertWritableColumns` in the Postgres driver is the backstop every write
869
+ * passes through, including in-process `rebase.data` writes that never come
870
+ * near this layer.
1019
871
  *
1020
872
  * What counts as known:
1021
873
  * - a declared property (for an introspected BaaS collection these *are* the
@@ -1030,7 +882,7 @@ function assertKnownWriteFields(values, collection, options) {
1030
882
  if (collection.strictWrites === false) return;
1031
883
  if (!collection.properties || Object.keys(collection.properties).length === 0) return;
1032
884
  const known = new Set(Object.keys(collection.properties));
1033
- for (const relation of Object.values(resolveCollectionRelations(collection))) if (relation.kind === "belongsTo") known.add(relation.localKey);
885
+ for (const relation of Object.values(resolveCollectionRelations(collection))) if (relation.kind === "belongsTo") known.add(fieldKeyForColumn(collection, relation.localKey));
1034
886
  for (const field of options?.extraKnownFields ?? []) known.add(field);
1035
887
  const unknown = Object.keys(values).filter((key) => !known.has(key));
1036
888
  if (unknown.length === 0) return;
@@ -1043,6 +895,145 @@ function assertKnownWriteFields(values, collection, options) {
1043
895
  throw ApiError.badRequest(`${where}'${collection.slug}' has no field${unknown.length > 1 ? "s" : ""} ${unknown.map((f) => `'${f}'`).join(", ")}. Known fields: ${[...known].sort().map((f) => `'${f}'`).join(", ")}.`, "VALIDATION_UNKNOWN_FIELDS");
1044
896
  }
1045
897
  /**
898
+ * The number a constraint should judge, or `undefined` if the value is not one.
899
+ *
900
+ * A JSON body may carry `"5"` for a number column and Postgres accepts it, so
901
+ * the string form is judged too — refusing to look at it would leave the exact
902
+ * bypass this function exists to close.
903
+ */
904
+ function asNumber(value) {
905
+ if (typeof value === "number") return Number.isNaN(value) ? void 0 : value;
906
+ if (typeof value === "string" && value.trim() !== "") {
907
+ const parsed = Number(value);
908
+ return Number.isNaN(parsed) ? void 0 : parsed;
909
+ }
910
+ }
911
+ /** The instant a constraint should judge, or `undefined` if the value is not one. */
912
+ function asDate(value) {
913
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? void 0 : value;
914
+ if (typeof value === "string" || typeof value === "number") {
915
+ const parsed = new Date(value);
916
+ return Number.isNaN(parsed.getTime()) ? void 0 : parsed;
917
+ }
918
+ }
919
+ /**
920
+ * `validation.matches` as a regex that can be tested repeatedly.
921
+ *
922
+ * A `RegExp` carrying `g` or `y` keeps `lastIndex` between calls, so the same
923
+ * pattern would accept and reject the same value on alternate rows of a bulk
924
+ * write. Rebuilt without those flags.
925
+ */
926
+ function toPattern(matches) {
927
+ try {
928
+ if (typeof matches === "string") return new RegExp(matches);
929
+ return new RegExp(matches.source, matches.flags.replace(/[gy]/g, ""));
930
+ } catch {
931
+ return;
932
+ }
933
+ }
934
+ function collectViolations(key, property, value, into) {
935
+ if (value === null || value === void 0) return;
936
+ switch (property.type) {
937
+ case "string": {
938
+ const rules = property.validation;
939
+ if (!rules || typeof value !== "string") return;
940
+ if (rules.length !== void 0 && value.length !== rules.length) into.push(`'${key}' must be exactly ${rules.length} character${rules.length === 1 ? "" : "s"} long (received ${value.length}).`);
941
+ if (rules.min !== void 0 && value.length < rules.min) into.push(`'${key}' must be at least ${rules.min} character${rules.min === 1 ? "" : "s"} long (received ${value.length}).`);
942
+ if (rules.max !== void 0 && value.length > rules.max) into.push(`'${key}' must be at most ${rules.max} character${rules.max === 1 ? "" : "s"} long (received ${value.length}).`);
943
+ if (rules.matches !== void 0) {
944
+ const pattern = toPattern(rules.matches);
945
+ if (pattern && !pattern.test(value)) into.push(rules.matchesMessage ?? `'${key}' does not match the required pattern ${pattern}.`);
946
+ }
947
+ return;
948
+ }
949
+ case "number": {
950
+ const rules = property.validation;
951
+ if (!rules) return;
952
+ const num = asNumber(value);
953
+ if (num === void 0) return;
954
+ if (rules.integer && !Number.isInteger(num)) into.push(`'${key}' must be a whole number (received ${num}).`);
955
+ if (rules.positive && !(num > 0)) into.push(`'${key}' must be positive (received ${num}).`);
956
+ if (rules.negative && !(num < 0)) into.push(`'${key}' must be negative (received ${num}).`);
957
+ if (rules.min !== void 0 && num < rules.min) into.push(`'${key}' must be at least ${rules.min} (received ${num}).`);
958
+ if (rules.max !== void 0 && num > rules.max) into.push(`'${key}' must be at most ${rules.max} (received ${num}).`);
959
+ if (rules.moreThan !== void 0 && !(num > rules.moreThan)) into.push(`'${key}' must be greater than ${rules.moreThan} (received ${num}).`);
960
+ if (rules.lessThan !== void 0 && !(num < rules.lessThan)) into.push(`'${key}' must be less than ${rules.lessThan} (received ${num}).`);
961
+ return;
962
+ }
963
+ case "date": {
964
+ const rules = property.validation;
965
+ if (!rules) return;
966
+ const date = asDate(value);
967
+ if (date === void 0) return;
968
+ const min = rules.min !== void 0 ? asDate(rules.min) : void 0;
969
+ const max = rules.max !== void 0 ? asDate(rules.max) : void 0;
970
+ if (min && date.getTime() < min.getTime()) into.push(`'${key}' must not be before ${min.toISOString()}.`);
971
+ if (max && date.getTime() > max.getTime()) into.push(`'${key}' must not be after ${max.toISOString()}.`);
972
+ return;
973
+ }
974
+ case "array": {
975
+ if (!Array.isArray(value)) return;
976
+ const rules = property.validation;
977
+ if (rules?.min !== void 0 && value.length < rules.min) into.push(`'${key}' must have at least ${rules.min} item${rules.min === 1 ? "" : "s"} (received ${value.length}).`);
978
+ if (rules?.max !== void 0 && value.length > rules.max) into.push(`'${key}' must have at most ${rules.max} item${rules.max === 1 ? "" : "s"} (received ${value.length}).`);
979
+ const of = property.of;
980
+ if (of && !Array.isArray(of)) value.forEach((item, index) => collectViolations(`${key}[${index}]`, of, item, into));
981
+ return;
982
+ }
983
+ case "map": {
984
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return;
985
+ const properties = property.properties;
986
+ if (!properties) return;
987
+ for (const [subKey, subValue] of Object.entries(value)) {
988
+ const subProperty = properties[subKey];
989
+ if (subProperty) collectViolations(`${key}.${subKey}`, subProperty, subValue, into);
990
+ }
991
+ return;
992
+ }
993
+ default:
994
+ }
995
+ }
996
+ /**
997
+ * Reject a write whose values break the constraints the collection declares.
998
+ *
999
+ * `validation.min`, `max`, `matches`, `positive`, `integer` and the date and
1000
+ * array bounds were read by exactly three things: the DDL generators (for
1001
+ * `integer` and for a `varchar` width), and the admin's client-side form. No
1002
+ * layer on the write path looked at them, no `CHECK` constraint was emitted,
1003
+ * and the generated OpenAPI published every one of them as `minimum`,
1004
+ * `maximum`, `minLength`, `maxLength` and `pattern`. So the docs' own example —
1005
+ * `price: { type: "number", validation: { min: 0 } }` — accepted `-5000` with a
1006
+ * 201 while the spec said it would not, and any client or gateway generated
1007
+ * from that spec believed the server was checking.
1008
+ *
1009
+ * Only `required` is left to the database, where it is a NOT NULL: a partial
1010
+ * update legitimately omits keys, and this function sees one request's values,
1011
+ * not the row they land on.
1012
+ *
1013
+ * A value of the wrong *type* is not this function's business — that reaches
1014
+ * Postgres, which types every column and gives a 400 through the driver's
1015
+ * SQLSTATE mapping. Constraints are checked against values of the right type,
1016
+ * plus the numeric-string form a JSON body may carry for a number column.
1017
+ *
1018
+ * Every failing field is reported at once. A form that fixes one field per
1019
+ * round trip is the reason validation errors get ignored.
1020
+ */
1021
+ function assertWriteValuesValid(values, collection, options) {
1022
+ const properties = collection.properties;
1023
+ if (!properties || !values) return;
1024
+ const violations = [];
1025
+ for (const [key, value] of Object.entries(values)) {
1026
+ const property = properties[key];
1027
+ if (property) collectViolations(key, property, value, violations);
1028
+ }
1029
+ if (violations.length === 0) return;
1030
+ const where = options?.rowIndex !== void 0 ? `Row ${options.rowIndex}: ` : "";
1031
+ throw ApiError.badRequest(`${where}${violations.join(" ")}`, "VALIDATION_CONSTRAINT", {
1032
+ collection: collection.slug,
1033
+ violations
1034
+ });
1035
+ }
1036
+ /**
1046
1037
  * Narrow response rows to the fields the caller asked for.
1047
1038
  *
1048
1039
  * `?fields=id,title` is documented in the generated OpenAPI — "Comma-separated
@@ -1055,18 +1046,25 @@ function assertKnownWriteFields(values, collection, options) {
1055
1046
  * This shapes the *response*, which is what the parameter says it does; it is
1056
1047
  * not a column pushdown, so it saves bandwidth rather than database work.
1057
1048
  *
1058
- * `id` always survives. Rows are addressed by it everywhere above this layer —
1059
- * the admin table, realtime reconciliation, the offline cache — and a row that
1060
- * arrives without one is not a smaller row, it is an unusable one. Asking for
1061
- * `fields=title` and being unable to open the record you clicked is a worse
1062
- * answer than one extra key.
1049
+ * The collection's key always survives. Rows are addressed by it everywhere
1050
+ * above this layer — the admin table, realtime reconciliation, the offline
1051
+ * cache — and a row that arrives without one is not a smaller row, it is an
1052
+ * unusable one. Asking for `fields=title` and being unable to open the record
1053
+ * you clicked is a worse answer than one extra key.
1054
+ *
1055
+ * That is a statement about the key, not about the name `id`: a collection
1056
+ * keyed on `slug`, or on `user_id + role_id`, was projected down to the fields
1057
+ * asked for and nothing else, which is the very outcome the paragraph above
1058
+ * describes. `assertKnownWriteFields` one function up already has a dedicated
1059
+ * error for collections with no `id` column, so this layer had no excuse for
1060
+ * assuming one.
1063
1061
  */
1064
1062
  function projectResponseFields(rows, fields, collection, options) {
1065
1063
  if (!fields || fields.length === 0) return rows;
1066
1064
  const declared = new Set(Object.keys(collection.properties ?? {}));
1067
1065
  for (const [key, relation] of Object.entries(resolveCollectionRelations(collection))) {
1068
1066
  declared.add(key);
1069
- if (relation.kind === "belongsTo") declared.add(relation.localKey);
1067
+ if (relation.kind === "belongsTo") declared.add(fieldKeyForColumn(collection, relation.localKey));
1070
1068
  }
1071
1069
  for (const included of options?.include ?? []) declared.add(included);
1072
1070
  declared.add("id");
@@ -1077,7 +1075,8 @@ function projectResponseFields(rows, fields, collection, options) {
1077
1075
  collection: collection.slug
1078
1076
  });
1079
1077
  }
1080
- const keep = /* @__PURE__ */ new Set([...fields, "id"]);
1078
+ const primaryKeys = resolvePrimaryKeys(collection).map((key) => key.fieldName);
1079
+ const keep = /* @__PURE__ */ new Set([...fields, ...primaryKeys.length > 0 ? primaryKeys : ["id"]]);
1081
1080
  return rows.map((row) => {
1082
1081
  const projected = {};
1083
1082
  for (const key of Object.keys(row)) if (keep.has(key)) projected[key] = row[key];
@@ -1102,6 +1101,9 @@ function projectResponseFields(rows, fields, collection, options) {
1102
1101
  * generated on the client, so keying on the id alone would let anyone who
1103
1102
  * learned (or guessed) another user's id replay their key and be handed that
1104
1103
  * user's row back — a read of someone else's data through a write endpoint.
1104
+ * That is also why an unauthenticated caller gets no idempotency at all: every
1105
+ * anonymous request shares one principal, so honouring a key there would hand
1106
+ * one visitor's stored response to the next one who sent the same string.
1105
1107
  */
1106
1108
  var TABLE = "\"rebase\".\"idempotency_keys\"";
1107
1109
  /**
@@ -1112,15 +1114,75 @@ var TABLE = "\"rebase\".\"idempotency_keys\"";
1112
1114
  */
1113
1115
  var TTL_HOURS = 24;
1114
1116
  /**
1115
- * The principal a key belongs to; anonymous and service writes share a sentinel.
1117
+ * How long a claim that has not answered yet is presumed to still be running.
1118
+ *
1119
+ * A claim is taken before the write and given back after it, so every exit the
1120
+ * process does not survive — a rolled pod, an OOM kill, a node drain, a
1121
+ * `complete` that itself fails — leaves the row pending with nobody coming back
1122
+ * for it. Reusing the 24-hour replay window as the lease made that
1123
+ * indistinguishable from a request still in flight, so every retry of that key
1124
+ * was refused for a day: the mechanism turned a write that plain retrying would
1125
+ * have completed into permanent loss, which is worse than not having it at all.
1116
1126
  *
1117
- * The NUL is written as an escape, not as a raw byte in the source. The
1118
- * sentinel itself is deliberate — a uid can never contain one — but written
1119
- * literally it makes this file test as binary, and every repo-wide grep then
1120
- * skips all 124 lines of it silently. Identical at runtime.
1127
+ * The number trades the two ways it can be wrong, and both are bounded. Too
1128
+ * short and a genuinely slow write has its key taken over by its own retry —
1129
+ * the duplicate this exists to prevent. Too long and a crashed claim refuses
1130
+ * its retries for that long. A minute outlasts any write the row cap allows
1131
+ * and stays well inside the window the offline queue keeps retrying for.
1132
+ */
1133
+ var PENDING_LEASE_SECONDS = 60;
1134
+ /**
1135
+ * How long to wait before attempting the table creation again after it failed.
1136
+ *
1137
+ * `ensure`'s answer used to be memoised unconditionally, so one transient DDL
1138
+ * failure — a database still starting, a lock on the `rebase` schema, a sibling
1139
+ * pod running the same `CREATE TABLE IF NOT EXISTS` — disabled idempotency on
1140
+ * that pod for the life of the process after a single log line. A failure is
1141
+ * retried instead, and the cooldown is what stops every keyed request re-running
1142
+ * DDL against a database that is genuinely unhappy.
1143
+ */
1144
+ var ENSURE_RETRY_COOLDOWN_MS = 3e4;
1145
+ /**
1146
+ * The principal a key belongs to, or `undefined` when there is nobody to scope
1147
+ * it to and the key must therefore be ignored.
1148
+ *
1149
+ * Anonymous callers are a supported configuration — `auth.requireAuth: false`
1150
+ * delegates access control to RLS — and they used to share one sentinel
1151
+ * principal, which is exactly the cross-principal replay the note above says
1152
+ * must not happen: two visitors of a public form who send the same key string
1153
+ * are handed each other's stored response. Refusing the key degrades them to no
1154
+ * idempotency, which is what they had before this table existed.
1121
1155
  */
1122
1156
  function principal(uid) {
1123
- return uid && uid.length > 0 ? uid : "\0anon";
1157
+ return uid && uid.length > 0 ? uid : void 0;
1158
+ }
1159
+ /**
1160
+ * A stable rendering of a JSON value: one body always produces one string,
1161
+ * whatever order its keys arrived in.
1162
+ *
1163
+ * `JSON.stringify` preserves insertion order, so two encodings of the same
1164
+ * object would fingerprint differently and a legitimate retry would be refused
1165
+ * as a reused key. Only JSON types reach here — the value came from `JSON.parse`.
1166
+ */
1167
+ function stableStringify(value) {
1168
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
1169
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1170
+ return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
1171
+ }
1172
+ /**
1173
+ * What a key was claimed *for*, so presenting it on a different request is
1174
+ * caught instead of silently replaying the first one's answer.
1175
+ *
1176
+ * Nothing about the request used to enter the key, so `createMany(rows, { key })`
1177
+ * followed by `deleteMany(ids, { key })` — the reusable business id the docs
1178
+ * recommended — answered the delete with the create's `200` body and deleted
1179
+ * nothing. Method and path separate the routes; the body separates a re-run
1180
+ * carrying corrected rows from the retry of an unanswered one, which is the
1181
+ * difference between "here is your earlier answer" and silently discarding a
1182
+ * correction.
1183
+ */
1184
+ function requestFingerprint(method, path, body) {
1185
+ return createHash("sha256").update(`${method.toUpperCase()} ${path}\n${stableStringify(body)}`).digest("hex");
1124
1186
  }
1125
1187
  /**
1126
1188
  * Returns `undefined` when the driver cannot run SQL, which disables the whole
@@ -1137,9 +1199,19 @@ function createIdempotencyStore(driver) {
1137
1199
  if (!isSQLAdmin(admin)) return void 0;
1138
1200
  const exec = (sql, params) => admin.executeSql(sql, params ? { params } : void 0);
1139
1201
  let ready;
1202
+ let retryEnsureAt = 0;
1203
+ let warnedAnonymous = false;
1204
+ /** Said once per process: it is a deployment fact, not a per-request event. */
1205
+ const warnAnonymous = () => {
1206
+ if (warnedAnonymous) return;
1207
+ warnedAnonymous = true;
1208
+ logger.warn("Idempotency-Key ignored for an unauthenticated request — keys are scoped to a signed-in principal, and honouring one here would replay a stored response to whoever sends the same key next. A replayed write from an anonymous client may insert a duplicate row.");
1209
+ };
1140
1210
  /** Created on first use: most deployments never send a key at all. */
1141
1211
  const ensure = () => {
1142
- ready ??= (async () => {
1212
+ if (ready) return ready;
1213
+ if (Date.now() < retryEnsureAt) return Promise.resolve(false);
1214
+ ready = (async () => {
1143
1215
  try {
1144
1216
  await exec("CREATE SCHEMA IF NOT EXISTS rebase");
1145
1217
  await exec(`
@@ -1151,36 +1223,71 @@ function createIdempotencyStore(driver) {
1151
1223
  PRIMARY KEY (uid, key)
1152
1224
  )
1153
1225
  `);
1226
+ await exec(`ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS fingerprint TEXT`);
1154
1227
  await exec(`CREATE INDEX IF NOT EXISTS idx_idempotency_created ON ${TABLE}(created_at)`);
1228
+ await exec(revokeInternalTableSql("rebase", "idempotency_keys"));
1155
1229
  return true;
1156
1230
  } catch (error) {
1231
+ retryEnsureAt = Date.now() + ENSURE_RETRY_COOLDOWN_MS;
1157
1232
  logger.warn("Idempotency keys unavailable — a replayed offline write may insert a duplicate row.", { detail: error instanceof Error ? error.message : String(error) });
1158
1233
  return false;
1159
1234
  }
1160
- })();
1235
+ })().then((ok) => {
1236
+ if (!ok) ready = void 0;
1237
+ return ok;
1238
+ });
1161
1239
  return ready;
1162
1240
  };
1163
1241
  return {
1164
- async recall(key, uid) {
1165
- if (!key || !await ensure()) return void 0;
1242
+ async claim(key, uid, fingerprint) {
1243
+ const owner = principal(uid);
1244
+ if (key && !owner) warnAnonymous();
1245
+ if (!key || !owner || !await ensure()) return { status: "claimed" };
1166
1246
  try {
1167
- return (await exec(`SELECT response FROM ${TABLE}
1168
- WHERE uid = $1 AND key = $2 AND created_at > NOW() - INTERVAL '${TTL_HOURS} hours'`, [principal(uid), key]))[0]?.response;
1247
+ if ((await exec(`INSERT INTO ${TABLE} (key, uid, fingerprint, response, created_at)
1248
+ VALUES ($1, $2, $3, NULL, NOW())
1249
+ ON CONFLICT (uid, key) DO UPDATE
1250
+ SET response = NULL, created_at = NOW(), fingerprint = EXCLUDED.fingerprint
1251
+ WHERE (${TABLE}.response IS NULL
1252
+ AND ${TABLE}.created_at < NOW() - INTERVAL '${PENDING_LEASE_SECONDS} seconds')
1253
+ OR (${TABLE}.response IS NOT NULL
1254
+ AND ${TABLE}.created_at < NOW() - INTERVAL '${TTL_HOURS} hours')
1255
+ RETURNING 1 AS claimed`, [
1256
+ key,
1257
+ owner,
1258
+ fingerprint
1259
+ ])).length > 0) return { status: "claimed" };
1260
+ const row = (await exec(`SELECT response, response IS NULL AS pending, fingerprint FROM ${TABLE}
1261
+ WHERE uid = $1 AND key = $2`, [owner, key]))[0];
1262
+ if (!row) return { status: "claimed" };
1263
+ if (row.fingerprint != null && row.fingerprint !== fingerprint) return { status: "mismatch" };
1264
+ if (row.pending) return { status: "in-flight" };
1265
+ return {
1266
+ status: "replay",
1267
+ response: row.response
1268
+ };
1169
1269
  } catch {
1170
- return;
1270
+ return { status: "claimed" };
1171
1271
  }
1172
1272
  },
1173
- async remember(key, uid, response) {
1174
- if (!key || !await ensure()) return;
1273
+ async complete(key, uid, response) {
1274
+ const owner = principal(uid);
1275
+ if (!key || !owner || !await ensure()) return;
1175
1276
  try {
1176
- await exec(`INSERT INTO ${TABLE} (key, uid, response) VALUES ($1, $2, $3::jsonb)
1177
- ON CONFLICT (uid, key) DO NOTHING`, [
1277
+ await exec(`UPDATE ${TABLE} SET response = $3::jsonb WHERE uid = $1 AND key = $2`, [
1278
+ owner,
1178
1279
  key,
1179
- principal(uid),
1180
1280
  JSON.stringify(response ?? null)
1181
1281
  ]);
1182
1282
  if (Math.random() < .01) await exec(`DELETE FROM ${TABLE} WHERE created_at < NOW() - INTERVAL '${TTL_HOURS} hours'`);
1183
1283
  } catch {}
1284
+ },
1285
+ async release(key, uid) {
1286
+ const owner = principal(uid);
1287
+ if (!key || !owner || !await ensure()) return;
1288
+ try {
1289
+ await exec(`DELETE FROM ${TABLE} WHERE uid = $1 AND key = $2 AND response IS NULL`, [owner, key]);
1290
+ } catch {}
1184
1291
  }
1185
1292
  };
1186
1293
  }
@@ -1204,6 +1311,18 @@ async function parseJsonBody(c) {
1204
1311
  }
1205
1312
  }
1206
1313
  /**
1314
+ * A live key presented on a different request than the one it was claimed for.
1315
+ *
1316
+ * Refused rather than replayed, because the replay is the dangerous answer: a
1317
+ * delete sent under the key of an earlier create used to be handed the create's
1318
+ * `200` and its rows, so the caller saw a success for rows that are still
1319
+ * there. A `422` names the mistake instead — the key is well-formed, the
1320
+ * request is not repeatable under it.
1321
+ */
1322
+ function idempotencyKeyReused(key) {
1323
+ return new ApiError(422, "IDEMPOTENCY_KEY_REUSED", `Idempotency-Key '${key}' was already used for a different request. A key names one write: use a new key for each distinct request, and re-send the identical request to replay its answer.`);
1324
+ }
1325
+ /**
1207
1326
  * Lightweight REST API generator that leverages existing Rebase DataDriver.
1208
1327
  * Supports `include` query parameter for eager-loading relations via Drizzle.
1209
1328
  */
@@ -1252,17 +1371,52 @@ var RestApiGenerator = class {
1252
1371
  this.createCollectionRoutes(collection);
1253
1372
  });
1254
1373
  this.createSubcollectionRoutes();
1374
+ this.createUnmatchedRoute();
1255
1375
  return this.router;
1256
1376
  }
1257
1377
  /**
1378
+ * Answer anything left under `/api/data` in the canonical error envelope.
1379
+ *
1380
+ * Without this, `GET /api/data/nonexistent` fell through to Hono's default
1381
+ * handler and came back as the plain text `404 Not Found` — the one error
1382
+ * on the data API that is not `{"error":{"message","code","requestId"}}`.
1383
+ * A client that does `res.json()` on the error path got a parse failure
1384
+ * where every other 4xx hands it a code, so a mistyped collection name
1385
+ * surfaced as "invalid JSON" rather than "no such collection".
1386
+ *
1387
+ * Registered after every real route, so a request reaches it only when the
1388
+ * slug is unknown *or* the method/sub-path is: those are different
1389
+ * mistakes, and the message says which. The collection list is deliberately
1390
+ * not enumerated — an unauthenticated request to this same URL is answered
1391
+ * 401 before routing, on purpose, and echoing the full set of slugs back
1392
+ * would undo that for any signed-in caller.
1393
+ *
1394
+ * Every answer here is `expected`, so it logs at debug (see
1395
+ * {@link ApiError.expected}). This path used to log *nothing at all* —
1396
+ * Hono's default 404 never reaches `errorHandler` — so routing it through
1397
+ * the handler at warn would have traded a missing error envelope for a
1398
+ * `⚠️` line on every request from any frontend holding a stale slug. The
1399
+ * envelope is the fix; the log volume is not part of it.
1400
+ */
1401
+ createUnmatchedRoute() {
1402
+ const known = new Set(this.collections.map((collection) => collection.slug));
1403
+ const notFound = (message) => new ApiError(404, "NOT_FOUND", message, void 0, true);
1404
+ this.router.all("/:slug{.*}", (c) => {
1405
+ const slug = (c.req.param("slug") ?? "").split("/")[0];
1406
+ if (!slug) throw notFound("No collection in the request path. Expected /api/data/<collection>.");
1407
+ if (known.has(slug)) throw notFound(`No ${c.req.method} route on collection '${slug}' at this path.`);
1408
+ throw notFound(`Unknown collection '${slug}'. It is not defined in this backend, or its route is not exposed.`);
1409
+ });
1410
+ }
1411
+ /**
1258
1412
  * Check API key permissions for a collection operation.
1259
1413
  * Throws 403 if the key doesn't have the required permission.
1260
1414
  * No-ops if the request is not authenticated via an API key.
1261
1415
  */
1262
- enforceApiKeyPermission(c, collectionSlug) {
1416
+ enforceApiKeyPermission(c, collectionSlug, operationOverride) {
1263
1417
  const apiKey = c.get("apiKey");
1264
1418
  if (!apiKey) return;
1265
- const operation = httpMethodToOperation(c.req.method);
1419
+ const operation = operationOverride ?? httpMethodToOperation(c.req.method);
1266
1420
  if (!isOperationAllowed(apiKey.permissions, collectionSlug, operation)) throw ApiError.forbidden(`API key does not have "${operation}" permission for collection "${collectionSlug}"`, "API_KEY_FORBIDDEN");
1267
1421
  }
1268
1422
  /**
@@ -1283,7 +1437,7 @@ var RestApiGenerator = class {
1283
1437
  * Needed so a nested write can be checked against a schema at all. Without
1284
1438
  * it these routes skipped `assertKnownWriteFields` entirely, which is why a
1285
1439
  * typo `POST /posts` rejected with a 400 while the same typo on
1286
- * `POST /authors/1/posts` reached the database.
1440
+ * `POST /authors/1/posts` was dropped from the statement and answered 201.
1287
1441
  *
1288
1442
  * Returns `undefined` rather than throwing when the path cannot be walked:
1289
1443
  * the driver raises the authoritative error a moment later, and duplicating
@@ -1333,6 +1487,7 @@ var RestApiGenerator = class {
1333
1487
  const queryDict = c.req.queries();
1334
1488
  const queryOptions = this.parseQuery(queryDict);
1335
1489
  const searchString = Array.isArray(queryDict.searchString) ? queryDict.searchString[queryDict.searchString.length - 1] : void 0;
1490
+ const searchExplain = (Array.isArray(queryDict.searchExplain) ? queryDict.searchExplain[queryDict.searchExplain.length - 1] : void 0) === "true";
1336
1491
  const driver = this.getScopedDriver(c);
1337
1492
  const fetchService = driver.restFetchService;
1338
1493
  const entities = fetchService ? await fetchService.fetchCollectionForRest(collection.slug, {
@@ -1343,6 +1498,7 @@ var RestApiGenerator = class {
1343
1498
  orderBy: queryOptions.orderBy?.[0]?.field,
1344
1499
  order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
1345
1500
  searchString,
1501
+ searchExplain,
1346
1502
  vectorSearch: queryOptions.vectorSearch
1347
1503
  }, queryOptions.include) : await this.fetchRawCollection(driver, resolvedCollection, queryOptions, searchString);
1348
1504
  const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
@@ -1367,121 +1523,241 @@ var RestApiGenerator = class {
1367
1523
  if (!entity) throw ApiError.notFound("Entity not found");
1368
1524
  return c.json(projectResponseFields([entity], queryOptions.fields, resolvedCollection, { include: queryOptions.include })[0]);
1369
1525
  });
1526
+ /**
1527
+ * Run a bulk handler under an idempotency key.
1528
+ *
1529
+ * All three bulk routes need the identical claim-before-write dance —
1530
+ * claim, replay on a repeat, 409 while one is in flight, release on
1531
+ * failure, complete on success — and getting one of those steps wrong
1532
+ * is exactly the bug the key exists to prevent. Written once so the
1533
+ * three cannot drift into three different notions of "already done".
1534
+ *
1535
+ * `body` is what the key is claimed *for*: the same key on a different
1536
+ * request is a caller mistake, and replaying a create's answer to a
1537
+ * delete would report a deletion that never happened.
1538
+ */
1539
+ const withIdempotency = async (c, body, run, respond) => {
1540
+ const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1541
+ const uid = c.get("user")?.uid;
1542
+ const store = this.idempotency();
1543
+ const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid, requestFingerprint(c.req.method, c.req.path, body)) : void 0;
1544
+ if (claimed?.status === "replay") return respond(claimed.response);
1545
+ if (claimed?.status === "mismatch") throw idempotencyKeyReused(idempotencyKey);
1546
+ if (claimed?.status === "in-flight") throw ApiError.conflict(`A request with Idempotency-Key '${idempotencyKey}' is already in progress. Retry once it has answered; its result will be replayed.`, "IDEMPOTENCY_KEY_IN_PROGRESS");
1547
+ let response;
1548
+ try {
1549
+ response = await run();
1550
+ } catch (error) {
1551
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.release(idempotencyKey, uid);
1552
+ throw error;
1553
+ }
1554
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
1555
+ return respond(response);
1556
+ };
1557
+ /**
1558
+ * Validate the envelope every bulk route shares: an array, non-empty,
1559
+ * of objects, within the row cap.
1560
+ *
1561
+ * The cap is the load-bearing one. A batch is a single transaction that
1562
+ * holds its locks for its whole duration, so an unbounded one is a
1563
+ * self-inflicted outage — and the message names both the limit and the
1564
+ * actual count so the caller can chunk to it rather than guess.
1565
+ */
1566
+ const assertBulkShape = (items, field, requireObjects) => {
1567
+ if (!Array.isArray(items)) throw ApiError.badRequest(`Expected a JSON body of { ${field}: [...] }.`, "INVALID_BULK_BODY");
1568
+ if (requireObjects && items.some((it) => typeof it !== "object" || it === null || Array.isArray(it))) throw ApiError.badRequest(`Every entry in \`${field}\` must be an object.`, "INVALID_BULK_BODY");
1569
+ if (items.length > this.maxBulkRows) throw ApiError.badRequest(`Too many rows: ${items.length} exceeds the ${this.maxBulkRows}-row limit for a single bulk write. Send it in chunks of ${this.maxBulkRows} or fewer.`, "BULK_TOO_LARGE");
1570
+ };
1370
1571
  this.router.post(`${basePath}/bulk`, async (c) => {
1371
1572
  this.enforceApiKeyPermission(c, collection.slug);
1372
1573
  const driver = this.getScopedDriver(c);
1373
1574
  const path = collection.slug;
1374
1575
  const body = await parseJsonBody(c);
1375
- if (!Array.isArray(body?.rows)) throw ApiError.badRequest("Expected a JSON body of { rows: [...] }.", "INVALID_BULK_BODY");
1376
- if (body.rows.length === 0) return c.json({
1576
+ assertBulkShape(body?.rows, "rows", true);
1577
+ const rows = body.rows;
1578
+ if (rows.length === 0) return c.json({
1377
1579
  data: [],
1378
1580
  meta: { written: 0 }
1379
1581
  });
1380
- if (body.rows.some((row) => typeof row !== "object" || row === null || Array.isArray(row))) throw ApiError.badRequest("Every entry in `rows` must be an object.", "INVALID_BULK_BODY");
1381
1582
  if (body.upsert !== void 0 && typeof body.upsert !== "boolean") throw ApiError.badRequest("`upsert` must be a boolean.", "INVALID_BULK_BODY");
1382
- const maxRows = this.maxBulkRows;
1383
- if (body.rows.length > maxRows) throw ApiError.badRequest(`Too many rows: ${body.rows.length} exceeds the ${maxRows}-row limit for a single bulk write. Send it in chunks of ${maxRows} or fewer.`, "BULK_TOO_LARGE");
1384
1583
  if (!driver.saveMany) throw ApiError.badRequest("This collection's data source does not support bulk writes.", "BULK_UNSUPPORTED");
1385
- body.rows.forEach((row, rowIndex) => assertKnownWriteFields(row, resolvedCollection, { rowIndex }));
1386
- const rows = await driver.saveMany({
1387
- path,
1388
- rows: body.rows,
1389
- collection: resolvedCollection,
1390
- upsert: body.upsert === true
1584
+ rows.forEach((row, rowIndex) => {
1585
+ assertKnownWriteFields(row, resolvedCollection, { rowIndex });
1586
+ assertWriteValuesValid(row, resolvedCollection, { rowIndex });
1391
1587
  });
1392
- return c.json({
1393
- data: rows.map((row) => this.formatResponse(row)),
1394
- meta: { written: rows.length }
1588
+ return withIdempotency(c, body, async () => {
1589
+ const written = await driver.saveMany({
1590
+ path,
1591
+ rows,
1592
+ collection: resolvedCollection,
1593
+ upsert: body.upsert === true
1594
+ });
1595
+ return {
1596
+ data: written.map((row) => this.formatResponse(row)),
1597
+ meta: { written: written.length }
1598
+ };
1599
+ }, (result) => c.json(result));
1600
+ });
1601
+ this.router.patch(`${basePath}/bulk`, async (c) => {
1602
+ this.enforceApiKeyPermission(c, collection.slug);
1603
+ const driver = this.getScopedDriver(c);
1604
+ const path = collection.slug;
1605
+ const body = await parseJsonBody(c);
1606
+ assertBulkShape(body?.updates, "updates", true);
1607
+ const updates = body.updates;
1608
+ if (updates.length === 0) return c.json({
1609
+ data: [],
1610
+ meta: { written: 0 }
1611
+ });
1612
+ if (!driver.updateMany) throw ApiError.badRequest("This collection's data source does not support bulk updates.", "BULK_UNSUPPORTED");
1613
+ updates.forEach((entry, rowIndex) => {
1614
+ if (entry.id === void 0 || entry.id === null || entry.id === "") throw ApiError.badRequest(`Entry ${rowIndex} of \`updates\` is missing \`id\`. Each entry must be { id, data }.`, "INVALID_BULK_BODY");
1615
+ if (typeof entry.data !== "object" || entry.data === null || Array.isArray(entry.data)) throw ApiError.badRequest(`Entry ${rowIndex} of \`updates\` is missing \`data\`, or it is not an object.`, "INVALID_BULK_BODY");
1616
+ assertKnownWriteFields(entry.data, resolvedCollection, { rowIndex });
1617
+ assertWriteValuesValid(entry.data, resolvedCollection, { rowIndex });
1395
1618
  });
1619
+ return withIdempotency(c, body, async () => {
1620
+ const written = await driver.updateMany({
1621
+ path,
1622
+ updates: updates.map((entry) => ({
1623
+ id: entry.id,
1624
+ values: entry.data
1625
+ })),
1626
+ collection: resolvedCollection
1627
+ });
1628
+ return {
1629
+ data: written.map((row) => this.formatResponse(row)),
1630
+ meta: { written: written.length }
1631
+ };
1632
+ }, (result) => c.json(result));
1633
+ });
1634
+ this.router.post(`${basePath}/bulk/delete`, async (c) => {
1635
+ this.enforceApiKeyPermission(c, collection.slug, "delete");
1636
+ const driver = this.getScopedDriver(c);
1637
+ const path = collection.slug;
1638
+ const body = await parseJsonBody(c);
1639
+ assertBulkShape(body?.ids, "ids", false);
1640
+ const ids = body.ids;
1641
+ if (ids.length === 0) return c.json({ meta: { deleted: 0 } });
1642
+ if (ids.some((id) => typeof id !== "string" && typeof id !== "number")) throw ApiError.badRequest("Every entry in `ids` must be a string or a number.", "INVALID_BULK_BODY");
1643
+ if (!driver.deleteMany) throw ApiError.badRequest("This collection's data source does not support bulk deletes.", "BULK_UNSUPPORTED");
1644
+ return withIdempotency(c, body, async () => {
1645
+ await driver.deleteMany({
1646
+ path,
1647
+ ids,
1648
+ collection: resolvedCollection
1649
+ });
1650
+ return { meta: { deleted: ids.length } };
1651
+ }, (result) => c.json(result));
1396
1652
  });
1397
1653
  this.router.post(basePath, async (c) => {
1398
- try {
1399
- this.enforceApiKeyPermission(c, collection.slug);
1400
- const driver = this.getScopedDriver(c);
1401
- const path = collection.slug;
1402
- const body = await parseJsonBody(c);
1403
- const isAuth = collection.auth;
1404
- const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
1405
- const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
1406
- if (!isAuthCollection) assertKnownWriteFields(body, resolvedCollection);
1407
- else {
1408
- const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
1409
- if (contract?.validate) assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
1410
- }
1411
- if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
1412
- const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
1413
- const entity = await driver.save({
1414
- path,
1415
- values: prepared.values,
1416
- collection: resolvedCollection,
1417
- status: "new"
1418
- });
1419
- const result = prepared.hookHandledEmail ? {
1420
- temporaryPassword: prepared.clearPassword,
1421
- invitationSent: prepared.invitationSent
1422
- } : this.authAdapter.finalizeUserCreation ? await this.authAdapter.finalizeUserCreation({
1423
- id: entity.id,
1424
- values: entity
1425
- }, prepared.clearPassword) : { invitationSent: false };
1426
- const response = this.formatResponse(entity);
1427
- return c.json({
1428
- ...response,
1429
- invitationSent: result.invitationSent,
1430
- ...result.temporaryPassword ? { temporaryPassword: result.temporaryPassword } : {},
1431
- ..."emailDeliveryFailed" in result && result.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
1432
- }, 201);
1433
- }
1434
- const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1435
- const uid = c.get("user")?.uid;
1436
- const store = this.idempotency();
1437
- if (idempotencyKey && store) {
1438
- const already = await store.recall(idempotencyKey, uid);
1439
- if (already !== void 0) return c.json(already, 201);
1654
+ this.enforceApiKeyPermission(c, collection.slug);
1655
+ const driver = this.getScopedDriver(c);
1656
+ const path = collection.slug;
1657
+ const body = await parseJsonBody(c);
1658
+ const isAuth = collection.auth;
1659
+ const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
1660
+ const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
1661
+ if (!isAuthCollection) {
1662
+ assertKnownWriteFields(body, resolvedCollection);
1663
+ assertWriteValuesValid(body, resolvedCollection);
1664
+ } else {
1665
+ const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
1666
+ if (contract?.validate) {
1667
+ assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
1668
+ assertWriteValuesValid(body, resolvedCollection);
1440
1669
  }
1670
+ }
1671
+ if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
1672
+ const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
1441
1673
  const entity = await driver.save({
1442
1674
  path,
1443
- values: body,
1675
+ values: prepared.values,
1444
1676
  collection: resolvedCollection,
1445
1677
  status: "new"
1446
1678
  });
1679
+ const result = prepared.hookHandledEmail ? {
1680
+ temporaryPassword: prepared.clearPassword,
1681
+ invitationSent: prepared.invitationSent
1682
+ } : this.authAdapter.finalizeUserCreation ? await this.authAdapter.finalizeUserCreation({
1683
+ id: entity.id,
1684
+ values: entity
1685
+ }, prepared.clearPassword) : { invitationSent: false };
1447
1686
  const response = this.formatResponse(entity);
1448
- if (idempotencyKey && store) await store.remember(idempotencyKey, uid, response);
1449
- return c.json(response, 201);
1450
- } catch (error) {
1451
- if (isRebaseApiError(error) && !error.code) {
1452
- if (!(error instanceof TypeError || error instanceof RangeError || error instanceof SyntaxError || error instanceof ReferenceError)) error.code = "BAD_REQUEST";
1453
- }
1454
- throw error;
1687
+ return c.json({
1688
+ ...response,
1689
+ invitationSent: result.invitationSent,
1690
+ ...result.temporaryPassword ? { temporaryPassword: result.temporaryPassword } : {},
1691
+ ..."emailDeliveryFailed" in result && result.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
1692
+ }, 201);
1455
1693
  }
1456
- });
1457
- this.router.put(`${basePath}/:id`, async (c) => {
1694
+ const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1695
+ const uid = c.get("user")?.uid;
1696
+ const store = this.idempotency();
1697
+ const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid, requestFingerprint(c.req.method, c.req.path, body)) : void 0;
1698
+ if (claimed?.status === "replay") return c.json(claimed.response, 201);
1699
+ if (claimed?.status === "mismatch") throw idempotencyKeyReused(idempotencyKey);
1700
+ if (claimed?.status === "in-flight") throw ApiError.conflict(`A request with Idempotency-Key '${idempotencyKey}' is already in progress. Retry once it has answered; its result will be replayed.`, "IDEMPOTENCY_KEY_IN_PROGRESS");
1701
+ let response;
1458
1702
  try {
1459
- this.enforceApiKeyPermission(c, collection.slug);
1460
- const id = c.req.param("id");
1461
- const driver = this.getScopedDriver(c);
1462
- if (!await driver.fetchOne({
1463
- path: getCollectionDataPath(collection),
1464
- id: String(id),
1465
- collection: resolvedCollection
1466
- })) throw ApiError.notFound("Entity not found");
1467
- const body = await parseJsonBody(c);
1468
- assertKnownWriteFields(body, resolvedCollection);
1469
1703
  const entity = await driver.save({
1470
- path: getCollectionDataPath(collection),
1471
- id: String(id),
1704
+ path,
1472
1705
  values: body,
1473
1706
  collection: resolvedCollection,
1474
- status: "existing"
1707
+ status: "new"
1475
1708
  });
1476
- const response = this.formatResponse(entity);
1477
- return c.json(response);
1709
+ response = this.formatResponse(entity);
1478
1710
  } catch (error) {
1479
- if (isRebaseApiError(error) && !error.code) {
1480
- if (!(error instanceof TypeError || error instanceof RangeError || error instanceof SyntaxError || error instanceof ReferenceError)) error.code = "BAD_REQUEST";
1481
- }
1711
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.release(idempotencyKey, uid);
1482
1712
  throw error;
1483
1713
  }
1714
+ if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
1715
+ return c.json(response, 201);
1484
1716
  });
1717
+ const updateEntity = async (c) => {
1718
+ this.enforceApiKeyPermission(c, collection.slug);
1719
+ const id = c.req.param("id");
1720
+ const driver = this.getScopedDriver(c);
1721
+ if (!await driver.fetchOne({
1722
+ path: getCollectionDataPath(collection),
1723
+ id: String(id),
1724
+ collection: resolvedCollection
1725
+ })) throw ApiError.notFound("Entity not found");
1726
+ const body = await parseJsonBody(c);
1727
+ assertKnownWriteFields(body, resolvedCollection);
1728
+ assertWriteValuesValid(body, resolvedCollection);
1729
+ const entity = await driver.save({
1730
+ path: getCollectionDataPath(collection),
1731
+ id: String(id),
1732
+ values: body,
1733
+ collection: resolvedCollection,
1734
+ status: "existing"
1735
+ });
1736
+ const response = this.formatResponse(entity);
1737
+ return c.json(response);
1738
+ };
1739
+ /**
1740
+ * The verb that matches what this actually does.
1741
+ *
1742
+ * The handler merges: it writes the columns in the body and leaves the
1743
+ * rest alone, which is what the SDK's `update(id, data: Partial<M>)`
1744
+ * sends. PUT was the only route, and PUT means replace — so the
1745
+ * generated OpenAPI spec described a full replacement while the server
1746
+ * performed a merge, and it reused the *create* input schema for it,
1747
+ * `required` fields and all. A client generated from that spec was
1748
+ * given a contract the server does not implement, and a spec-validating
1749
+ * gateway in front of this API would reject partial updates the server
1750
+ * would have accepted.
1751
+ *
1752
+ * PUT stays mounted on the same handler rather than being changed or
1753
+ * removed: every published SDK sends it, and altering its semantics to
1754
+ * be a true replace would silently start nulling columns that callers
1755
+ * had simply omitted for years — a data-loss change disguised as a
1756
+ * standards fix. So PATCH is the honest name, PUT is the compatible one,
1757
+ * and both do the documented thing.
1758
+ */
1759
+ this.router.patch(`${basePath}/:id`, updateEntity);
1760
+ this.router.put(`${basePath}/:id`, updateEntity);
1485
1761
  this.router.delete(`${basePath}/:id`, async (c) => {
1486
1762
  this.enforceApiKeyPermission(c, collection.slug);
1487
1763
  const id = c.req.param("id");
@@ -1552,6 +1828,7 @@ var RestApiGenerator = class {
1552
1828
  const total = driver.count ? await driver.count({
1553
1829
  path: parsed.collectionPath,
1554
1830
  filter: queryOptions.where,
1831
+ logical: queryOptions.logical,
1555
1832
  searchString
1556
1833
  }) : 0;
1557
1834
  return c.json({ count: total });
@@ -1563,7 +1840,9 @@ var RestApiGenerator = class {
1563
1840
  id: parsed.id
1564
1841
  });
1565
1842
  if (!entity) throw ApiError.notFound("Entity not found");
1566
- return c.json(entity);
1843
+ const nestedCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1844
+ if (!nestedCollection) return c.json(entity);
1845
+ return c.json(projectResponseFields([entity], queryOptions.fields, nestedCollection, { include: queryOptions.include })[0]);
1567
1846
  } else {
1568
1847
  const queryDict = c.req.queries();
1569
1848
  const queryOptions = this.parseQuery(queryDict);
@@ -1588,8 +1867,9 @@ var RestApiGenerator = class {
1588
1867
  logical: queryOptions.logical,
1589
1868
  searchString
1590
1869
  }) : entities.length;
1870
+ const listCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1591
1871
  return c.json({
1592
- data: entities,
1872
+ data: listCollection ? projectResponseFields(entities, queryOptions.fields, listCollection, { include: queryOptions.include }) : entities,
1593
1873
  meta: {
1594
1874
  total,
1595
1875
  limit: queryOptions.limit,
@@ -1609,7 +1889,10 @@ var RestApiGenerator = class {
1609
1889
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
1610
1890
  const body = await parseJsonBody(c);
1611
1891
  const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1612
- if (targetCollection) assertKnownWriteFields(body, targetCollection);
1892
+ if (targetCollection) {
1893
+ assertKnownWriteFields(body, targetCollection);
1894
+ assertWriteValuesValid(body, targetCollection);
1895
+ }
1613
1896
  const entity = await driver.save({
1614
1897
  path: parsed.collectionPath,
1615
1898
  values: body,
@@ -1618,7 +1901,7 @@ var RestApiGenerator = class {
1618
1901
  const response = this.formatResponse(entity);
1619
1902
  return c.json(response, 201);
1620
1903
  });
1621
- this.router.put("/:parent/:parentId/:rest{.+}", async (c, next) => {
1904
+ const updateNested = async (c, next) => {
1622
1905
  const rest = c.req.param("rest");
1623
1906
  if (!rest || rest === "undefined") return next();
1624
1907
  const rawPath = `${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`;
@@ -1628,7 +1911,10 @@ var RestApiGenerator = class {
1628
1911
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
1629
1912
  const body = await parseJsonBody(c);
1630
1913
  const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1631
- if (targetCollection) assertKnownWriteFields(body, targetCollection);
1914
+ if (targetCollection) {
1915
+ assertKnownWriteFields(body, targetCollection);
1916
+ assertWriteValuesValid(body, targetCollection);
1917
+ }
1632
1918
  const entity = await driver.save({
1633
1919
  path: parsed.collectionPath,
1634
1920
  id: parsed.id,
@@ -1637,7 +1923,9 @@ var RestApiGenerator = class {
1637
1923
  });
1638
1924
  const response = this.formatResponse(entity);
1639
1925
  return c.json(response);
1640
- });
1926
+ };
1927
+ this.router.patch("/:parent/:parentId/:rest{.+}", updateNested);
1928
+ this.router.put("/:parent/:parentId/:rest{.+}", updateNested);
1641
1929
  this.router.delete("/:parent/:parentId/:rest{.+}", async (c, next) => {
1642
1930
  const rest = c.req.param("rest");
1643
1931
  if (!rest || rest === "undefined") return next();
@@ -1846,7 +2134,7 @@ function configureMiddlewares(app, basePath, isProduction, config) {
1846
2134
  app.use(`${basePath}/*`, requestId());
1847
2135
  if (config.compression !== false) {
1848
2136
  app.use(`${basePath}/*`, responseCompression());
1849
- logger.info("Response compression enabled");
2137
+ logger.debug("Response compression enabled");
1850
2138
  }
1851
2139
  const maxBodySize = config.maxBodySize ?? 10 * 1024 * 1024;
1852
2140
  if (maxBodySize > 0) {
@@ -1859,11 +2147,11 @@ function configureMiddlewares(app, basePath, isProduction, config) {
1859
2147
  } }, 413);
1860
2148
  }
1861
2149
  }));
1862
- logger.info("Request body limit configured", { maxSizeMB: Math.round(maxBodySize / 1024 / 1024) });
2150
+ logger.debug("Request body limit configured", { maxSizeMB: Math.round(maxBodySize / 1024 / 1024) });
1863
2151
  }
1864
2152
  if (config.csrf?.origin) {
1865
2153
  app.use(`${basePath}/*`, csrf({ origin: config.csrf.origin }));
1866
- logger.info("CSRF protection enabled");
2154
+ logger.debug("CSRF protection enabled");
1867
2155
  }
1868
2156
  if (!config.corsHandled && !process.env.CORS_ORIGINS && !process.env.FRONTEND_URL) logger.warn((isProduction ? "[PRODUCTION] " : "") + "No CORS configuration detected (CORS_ORIGINS / FRONTEND_URL not set). If your app does not install its own CORS middleware, the API may accept requests from any origin. Set CORS_ORIGINS to restrict access.");
1869
2157
  app.use(`${basePath}/*`, requestLogger());
@@ -1917,7 +2205,7 @@ var LocalStorageController = class {
1917
2205
  }
1918
2206
  /**
1919
2207
  * Get the full filesystem path for a storage path, with a traversal guard
1920
- * that keeps the result inside the bucket directory.
2208
+ * that keeps the result inside the storage root.
1921
2209
  *
1922
2210
  * Defaults the bucket the way `putObject` does.
1923
2211
  *
@@ -1928,10 +2216,22 @@ var LocalStorageController = class {
1928
2216
  * design), and `listObjects` returned an empty page. So the obvious
1929
2217
  * `putObject({ key })` → `getObject(key)` did not round-trip and nothing
1930
2218
  * said why. One default, applied everywhere, removes the whole class.
2219
+ *
2220
+ * Two boundaries, checked separately, because there are two of them. The
2221
+ * key must stay inside its bucket — that check always worked. The bucket
2222
+ * must stay inside the storage root, and that one did not exist: the guard
2223
+ * derived `bucketPath` from the caller's `bucket` and then asked whether
2224
+ * the result was inside `bucketPath`, so `bucket = "../../etc"` made the
2225
+ * reference point `/etc` and `/etc/passwd` is comfortably inside it. A
2226
+ * guard whose reference point moves with its input is not a guard. The
2227
+ * route boundary rejects such a bucket outright
2228
+ * (`canonicalStorageBucket`); this is the layer underneath that does not
2229
+ * depend on it having run.
1931
2230
  */
1932
2231
  getFullPath(storagePath, bucket) {
1933
- const bucketPath = path$2.join(this.basePath, bucket ?? "default");
1934
- const resolved = path$2.resolve(path$2.join(bucketPath, storagePath));
2232
+ const bucketPath = path$2.resolve(this.basePath, bucket || "default");
2233
+ if (!bucketPath.startsWith(this.basePath + path$2.sep)) throw new Error("Path traversal detected: bucket resolves outside the storage root.");
2234
+ const resolved = path$2.resolve(bucketPath, storagePath);
1935
2235
  if (!resolved.startsWith(bucketPath + path$2.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
1936
2236
  return resolved;
1937
2237
  }
@@ -1947,7 +2247,7 @@ var LocalStorageController = class {
1947
2247
  }
1948
2248
  async putObject({ file, key, metadata, bucket }) {
1949
2249
  this.validateFile(file);
1950
- const usedBucket = bucket ?? "default";
2250
+ const usedBucket = bucket || "default";
1951
2251
  const fullStoragePath = key;
1952
2252
  const fullPath = this.getFullPath(fullStoragePath, usedBucket);
1953
2253
  await this.ensureDir(path$2.dirname(fullPath));
@@ -2177,38 +2477,63 @@ var VALID_FITS = /* @__PURE__ */ new Set([
2177
2477
  "outside"
2178
2478
  ]);
2179
2479
  /**
2480
+ * A transform request naming parameters outside the declared bounds.
2481
+ *
2482
+ * Surfaced as a 400 by the route. Out-of-range values used to be *clamped*:
2483
+ * `width=99999` silently became 4096 and `format=tiff` silently became webp,
2484
+ * so the caller debugged a wrongly-sized `<img>` in the browser instead of
2485
+ * reading an error. A bound that rewrites its input is invisible, and this
2486
+ * endpoint's bounds are load-bearing — see {@link runTransform}.
2487
+ */
2488
+ var InvalidTransformOptionsError = class extends Error {
2489
+ constructor(message) {
2490
+ super(message);
2491
+ this.name = "InvalidTransformOptionsError";
2492
+ }
2493
+ };
2494
+ /**
2495
+ * Parse a bounded integer parameter, or throw.
2496
+ *
2497
+ * Strict digits only: `parseInt` accepted `300px` and `12abc`, which is a
2498
+ * second way for a caller to get a transform they did not describe.
2499
+ */
2500
+ function boundedInt(name, raw, min, max) {
2501
+ if (!/^\d+$/.test(raw)) throw new InvalidTransformOptionsError(`Invalid '${name}': expected an integer between ${min} and ${max}.`);
2502
+ const value = parseInt(raw, 10);
2503
+ if (value < min || value > max) throw new InvalidTransformOptionsError(`Invalid '${name}': must be between ${min} and ${max}, got ${value}.`);
2504
+ return value;
2505
+ }
2506
+ /**
2180
2507
  * Parse transform options from URL query parameters.
2181
2508
  * Returns `null` when no transformation is requested.
2509
+ *
2510
+ * Throws {@link InvalidTransformOptionsError} for anything outside the bounds
2511
+ * declared here — these are the whole parameter surface of an endpoint that is
2512
+ * reachable anonymously for public objects, so what it accepts has to be a
2513
+ * closed, stated set rather than "whatever we could coerce into range".
2182
2514
  */
2183
2515
  function parseTransformOptions(query) {
2184
2516
  const opts = {};
2185
2517
  let hasTransform = false;
2186
2518
  if (query.width) {
2187
- const w = parseInt(query.width, 10);
2188
- if (!Number.isNaN(w) && w > 0) {
2189
- opts.width = Math.min(w, MAX_DIMENSION);
2190
- hasTransform = true;
2191
- }
2519
+ opts.width = boundedInt("width", query.width, 1, MAX_DIMENSION);
2520
+ hasTransform = true;
2192
2521
  }
2193
2522
  if (query.height) {
2194
- const h = parseInt(query.height, 10);
2195
- if (!Number.isNaN(h) && h > 0) {
2196
- opts.height = Math.min(h, MAX_DIMENSION);
2197
- hasTransform = true;
2198
- }
2523
+ opts.height = boundedInt("height", query.height, 1, MAX_DIMENSION);
2524
+ hasTransform = true;
2199
2525
  }
2200
2526
  if (query.quality) {
2201
- const q = parseInt(query.quality, 10);
2202
- if (!Number.isNaN(q)) {
2203
- opts.quality = Math.min(Math.max(q, MIN_QUALITY), MAX_QUALITY);
2204
- hasTransform = true;
2205
- }
2527
+ opts.quality = boundedInt("quality", query.quality, MIN_QUALITY, MAX_QUALITY);
2528
+ hasTransform = true;
2206
2529
  }
2207
- if (query.format && VALID_FORMATS.has(query.format)) {
2530
+ if (query.format) {
2531
+ if (!VALID_FORMATS.has(query.format)) throw new InvalidTransformOptionsError(`Invalid 'format': expected one of ${[...VALID_FORMATS].join(", ")}.`);
2208
2532
  opts.format = query.format;
2209
2533
  hasTransform = true;
2210
2534
  }
2211
- if (query.fit && VALID_FITS.has(query.fit)) {
2535
+ if (query.fit) {
2536
+ if (!VALID_FITS.has(query.fit)) throw new InvalidTransformOptionsError(`Invalid 'fit': expected one of ${[...VALID_FITS].join(", ")}.`);
2212
2537
  opts.fit = query.fit;
2213
2538
  hasTransform = true;
2214
2539
  }
@@ -2226,9 +2551,72 @@ function isTransformableImage(contentType) {
2226
2551
  return contentType.startsWith("image/") && !contentType.includes("svg") && !contentType.includes("gif");
2227
2552
  }
2228
2553
  /**
2554
+ * A transform refused because the server is already doing as many as it will.
2555
+ *
2556
+ * Surfaced as a 503 by the route.
2557
+ */
2558
+ var TransformOverloadedError = class extends Error {
2559
+ constructor(message) {
2560
+ super(message);
2561
+ this.name = "TransformOverloadedError";
2562
+ }
2563
+ };
2564
+ /**
2565
+ * A bounded work queue.
2566
+ *
2567
+ * A transform is seconds of libvips CPU and a full-resolution bitmap in RSS,
2568
+ * and `GET /file/*?width=…` is reachable anonymously for public objects — so
2569
+ * without a bound, a few hundred bytes per request buys unlimited pod CPU, and
2570
+ * enough of them at once buys the heap too. `maxConcurrent` caps the CPU and
2571
+ * memory in flight; `maxQueued` is what keeps an unbounded backlog from
2572
+ * becoming the same problem one level up, refusing fast (503) instead of
2573
+ * accepting work the process will not get to.
2574
+ */
2575
+ var TransformQueue = class {
2576
+ maxConcurrent;
2577
+ maxQueued;
2578
+ active = 0;
2579
+ waiting = [];
2580
+ constructor(maxConcurrent, maxQueued) {
2581
+ this.maxConcurrent = maxConcurrent;
2582
+ this.maxQueued = maxQueued;
2583
+ }
2584
+ /** Number of tasks running plus waiting. Exposed for tests and metrics. */
2585
+ get depth() {
2586
+ return this.active + this.waiting.length;
2587
+ }
2588
+ async run(work) {
2589
+ if (this.active >= this.maxConcurrent) {
2590
+ if (this.waiting.length >= this.maxQueued) throw new TransformOverloadedError("Image transformation is at capacity. Retry shortly.");
2591
+ await new Promise((resolve) => this.waiting.push(resolve));
2592
+ }
2593
+ this.active++;
2594
+ try {
2595
+ return await work();
2596
+ } finally {
2597
+ this.active--;
2598
+ this.waiting.shift()?.();
2599
+ }
2600
+ }
2601
+ };
2602
+ /**
2603
+ * The queue every transform goes through.
2604
+ *
2605
+ * Small on purpose: transforms are CPU-bound, so more concurrency than cores
2606
+ * buys nothing but resident bitmaps.
2607
+ */
2608
+ var transformQueue = new TransformQueue(Math.max(1, Math.min(4, os.availableParallelism?.() ?? os.cpus().length)), 64);
2609
+ /**
2229
2610
  * Apply image transformations and return the result buffer + content type.
2611
+ *
2612
+ * Runs on {@link transformQueue}: the work is bounded, and callers past the
2613
+ * bound get {@link TransformOverloadedError} rather than a share of a
2614
+ * saturated CPU.
2230
2615
  */
2231
2616
  async function transformImage(buffer, options) {
2617
+ return transformQueue.run(() => runTransform(buffer, options));
2618
+ }
2619
+ async function runTransform(buffer, options) {
2232
2620
  let pipeline = (await getSharp())(buffer);
2233
2621
  if (options.width || options.height) pipeline = pipeline.resize({
2234
2622
  width: options.width,
@@ -2355,6 +2743,7 @@ var TusHandler = class {
2355
2743
  this.cleanupTimer = setInterval(() => {
2356
2744
  this.cleanupStale();
2357
2745
  }, 6e4);
2746
+ this.cleanupTimer.unref?.();
2358
2747
  }
2359
2748
  /** Remove uploads that have been idle for longer than UPLOAD_EXPIRY_MS. */
2360
2749
  async cleanupStale() {
@@ -2406,11 +2795,22 @@ var TusHandler = class {
2406
2795
  if (Number.isNaN(uploadLength) || uploadLength <= 0) throw ApiError.badRequest("Invalid Upload-Length");
2407
2796
  if (uploadLength > MAX_UPLOAD_SIZE) throw new ApiError(413, "PAYLOAD_TOO_LARGE", `Upload-Length exceeds maximum of ${MAX_UPLOAD_SIZE} bytes`);
2408
2797
  const metadata = this.parseMetadata(c.req.header("Upload-Metadata") || "");
2409
- if (this.authorizeUpload) {
2410
- const key = metadata.key || metadata.filename || "";
2411
- await this.authorizeUpload(c, key, metadata.bucket || "default");
2412
- }
2413
2798
  const id = randomUUID$1();
2799
+ const rawKey = metadata.key || metadata.filename || "";
2800
+ let key;
2801
+ try {
2802
+ key = canonicalStorageKey(rawKey) || id;
2803
+ } catch (err) {
2804
+ throw new ApiError(400, "INVALID_STORAGE_KEY", err instanceof InvalidStorageKeyError ? err.message : "Invalid storage key");
2805
+ }
2806
+ let bucket;
2807
+ try {
2808
+ bucket = canonicalStorageBucket(metadata.bucket);
2809
+ } catch (err) {
2810
+ throw new ApiError(400, "INVALID_STORAGE_BUCKET", err instanceof InvalidStorageBucketError ? err.message : "Invalid storage bucket");
2811
+ }
2812
+ const storageId = metadata.storageId || c.req.query("storageId") || void 0;
2813
+ if (this.authorizeUpload) await this.authorizeUpload(c, key, bucket || "default", storageId);
2414
2814
  const filePath = join(this.tusDir, id);
2415
2815
  await writeFile(filePath, Buffer.alloc(0));
2416
2816
  const upload = {
@@ -2420,8 +2820,9 @@ var TusHandler = class {
2420
2820
  metadata,
2421
2821
  createdAt: Date.now(),
2422
2822
  filePath,
2423
- bucket: metadata.bucket || void 0,
2424
- key: metadata.key || metadata.filename || void 0,
2823
+ bucket,
2824
+ storageId,
2825
+ key,
2425
2826
  completed: false
2426
2827
  };
2427
2828
  this.uploads.set(id, upload);
@@ -2492,21 +2893,37 @@ var TusHandler = class {
2492
2893
  });
2493
2894
  }
2494
2895
  /**
2495
- * Move a completed upload into the storage controller.
2896
+ * Move a fully-received upload into the storage controller.
2897
+ *
2898
+ * Everything here used to be best-effort: no controller warned and
2899
+ * returned, a failing `putObject` was caught and logged, and either way the
2900
+ * `PATCH` that carried the last chunk answered `204` with
2901
+ * `Upload-Offset: <size>` — which in TUS is the server saying "I have your
2902
+ * file". The object did not exist. For the one operation a user can spend
2903
+ * minutes on, the failure mode was silent data loss with a success code.
2904
+ *
2905
+ * `completed` is now set only once the bytes are stored, and that matters
2906
+ * twice over: a "completed" upload is refused a retry (`patch` answers
2907
+ * "Upload already completed") and is skipped by the stale sweeper, so
2908
+ * marking it up front both locked the client out of retrying and leaked the
2909
+ * temp file forever. Left false, a client that retries sends an empty chunk
2910
+ * at `offset === size`, which lands straight back here.
2496
2911
  */
2497
2912
  async finalize(upload) {
2498
- upload.completed = true;
2499
- const storageId = upload.metadata.storageId;
2913
+ const storageId = upload.storageId;
2500
2914
  let targetController = this.storageController;
2501
2915
  if (this.storageRegistry) targetController = storageId ? this.storageRegistry.getOrDefault(storageId) : this.storageRegistry.getDefault();
2502
2916
  if (!targetController) {
2503
- logger.warn("[TUS] Upload completed but no StorageController configured. Temp file remains:", { filePath: upload.filePath });
2504
- return;
2917
+ logger.error("[TUS] Upload received but no StorageController is configured — nothing can be written", {
2918
+ uploadId: upload.id,
2919
+ storageId
2920
+ });
2921
+ throw new ApiError(503, "STORAGE_NOT_CONFIGURED", storageId ? `Storage source "${storageId}" is not configured on this server, so the upload cannot be stored.` : "No storage is configured on this server, so the upload cannot be stored.");
2505
2922
  }
2506
2923
  try {
2507
2924
  const { readFile } = await import("fs/promises");
2508
2925
  const data = await readFile(upload.filePath);
2509
- const fileName = upload.key || upload.metadata.filename || upload.id;
2926
+ const fileName = upload.key;
2510
2927
  const mimeType = upload.metadata.contentType || upload.metadata.filetype || "application/octet-stream";
2511
2928
  const file = new File([new Uint8Array(data)], fileName, { type: mimeType });
2512
2929
  await targetController.putObject({
@@ -2514,6 +2931,7 @@ var TusHandler = class {
2514
2931
  key: fileName,
2515
2932
  bucket: upload.bucket
2516
2933
  });
2934
+ upload.completed = true;
2517
2935
  try {
2518
2936
  await unlink(upload.filePath);
2519
2937
  } catch {}
@@ -2521,6 +2939,8 @@ var TusHandler = class {
2521
2939
  logger.info(`[TUS] Upload ${upload.id} finalized → ${fileName}`, storageId ? { storageId } : {});
2522
2940
  } catch (err) {
2523
2941
  logger.error(`[TUS] Failed to finalize upload ${upload.id}`, { error: err });
2942
+ if (err instanceof ApiError) throw err;
2943
+ throw new ApiError(502, "STORAGE_WRITE_FAILED", `Upload ${upload.id} was received but could not be stored: ` + (err instanceof Error ? err.message : String(err)));
2524
2944
  }
2525
2945
  }
2526
2946
  };
@@ -2537,6 +2957,82 @@ var TusHandler = class {
2537
2957
  /** Shared image transform cache (LRU, 500 entries, 1 hour TTL). */
2538
2958
  var transformCache = new TransformCache();
2539
2959
  /**
2960
+ * Transforms currently being computed, keyed the same way the cache is.
2961
+ *
2962
+ * Without it, N concurrent requests for one uncached variant run N full
2963
+ * decode+encode pipelines — the cache only helps the requests that arrive
2964
+ * after the first one has finished. Joining the in-flight promise makes a
2965
+ * thundering herd cost one decode.
2966
+ */
2967
+ var transformsInFlight = /* @__PURE__ */ new Map();
2968
+ /**
2969
+ * Compute a transform, or join the one already running for this key.
2970
+ *
2971
+ * `loadSource` is only called on a real miss, so the concurrent requests that
2972
+ * join an in-flight transform do not each read the source object either.
2973
+ */
2974
+ async function transformOnce(cacheKey, options, loadSource) {
2975
+ const cached = transformCache.get(cacheKey);
2976
+ if (cached) return cached;
2977
+ const running = transformsInFlight.get(cacheKey);
2978
+ if (running) return running;
2979
+ const pending = (async () => {
2980
+ try {
2981
+ const result = await transformImage(await loadSource(), options);
2982
+ transformCache.set(cacheKey, result.data, result.contentType);
2983
+ return result;
2984
+ } catch (err) {
2985
+ if (err instanceof TransformOverloadedError) throw ApiError.serviceUnavailable(err.message, "TRANSFORM_OVERLOADED");
2986
+ throw err;
2987
+ }
2988
+ })();
2989
+ transformsInFlight.set(cacheKey, pending);
2990
+ try {
2991
+ return await pending;
2992
+ } finally {
2993
+ transformsInFlight.delete(cacheKey);
2994
+ }
2995
+ }
2996
+ /**
2997
+ * Content types served inline. Everything else is handed back as
2998
+ * `application/octet-stream` with `Content-Disposition: attachment`.
2999
+ *
3000
+ * The stored content type is the *uploader's claim* — `putObject` writes
3001
+ * `file.type` from the multipart part, and TUS takes it from a request header;
3002
+ * nothing sniffs the bytes. Echoing that claim back as the response type turns
3003
+ * `/api/storage/file/*` into an HTML hosting endpoint on the API origin, and
3004
+ * where `cookieAuth` is enabled the refresh cookie is `Path=/` on exactly that
3005
+ * origin — so an uploaded page can fetch `/api/auth/refresh` same-origin and
3006
+ * read out a fresh access token, `HttpOnly` notwithstanding.
3007
+ *
3008
+ * `image/svg+xml` is deliberately absent: an SVG is a document that can carry
3009
+ * script. `text/html` and `application/xhtml+xml` are absent for the same
3010
+ * reason, and are what the attack actually uses.
3011
+ */
3012
+ var INLINE_CONTENT_TYPE_PREFIXES = [
3013
+ "image/",
3014
+ "video/",
3015
+ "audio/"
3016
+ ];
3017
+ var INLINE_CONTENT_TYPES = /* @__PURE__ */ new Set(["application/pdf", "text/plain"]);
3018
+ /**
3019
+ * Decide what to actually serve for a stored content type.
3020
+ *
3021
+ * Returns the type to send and whether to force a download. The check is an
3022
+ * allowlist rather than a blocklist of dangerous types: the set of types a
3023
+ * browser will execute grows, and the set we want to render inline does not.
3024
+ */
3025
+ function resolveServedContentType(storedContentType) {
3026
+ const base = storedContentType.split(";")[0].trim().toLowerCase();
3027
+ return (base.includes("svg") ? false : INLINE_CONTENT_TYPES.has(base) || INLINE_CONTENT_TYPE_PREFIXES.some((p) => base.startsWith(p))) ? {
3028
+ contentType: storedContentType,
3029
+ attachment: false
3030
+ } : {
3031
+ contentType: "application/octet-stream",
3032
+ attachment: true
3033
+ };
3034
+ }
3035
+ /**
2540
3036
  * Extract the wildcard portion of a route path from the full request path.
2541
3037
  *
2542
3038
  * Hono's `c.req.param('*')` does not work reliably in sub-routers mounted
@@ -2556,16 +3052,48 @@ function extractWildcardPath(c) {
2556
3052
  return fullPath.substring(idx + prefix.length + 1);
2557
3053
  }
2558
3054
  /**
2559
- * Sanitize a user-supplied storage key to prevent path traversal and other attacks.
2560
- * Removes null bytes, ../ sequences, leading slashes, and limits length.
3055
+ * Canonicalize a caller-supplied storage key, answering 400 when it names
3056
+ * something other than what it says.
3057
+ *
3058
+ * The single place a request's key becomes canonical. Every route runs its key
3059
+ * through this before anything else touches it, so the authorize hook, the
3060
+ * storage controller and the minted download token are all looking at the same
3061
+ * string — which is the only thing that makes the hook's answer meaningful.
3062
+ * See `keys.ts` for why an unacceptable key is refused rather than repaired.
2561
3063
  */
2562
- function sanitizeStorageKey(key) {
2563
- let sanitized = key;
2564
- sanitized = sanitized.replace(/\0/g, "");
2565
- sanitized = sanitized.replace(/\.\.\/|\.\.\\/g, "");
2566
- sanitized = sanitized.replace(/^\/+/, "");
2567
- sanitized = sanitized.slice(0, 1024);
2568
- return sanitized;
3064
+ function canonicalKeyOrBadRequest(key) {
3065
+ try {
3066
+ return canonicalStorageKey(key);
3067
+ } catch (err) {
3068
+ throw new ApiError(400, "INVALID_STORAGE_KEY", err instanceof InvalidStorageKeyError ? err.message : "Invalid storage key");
3069
+ }
3070
+ }
3071
+ /**
3072
+ * Canonicalize a caller-supplied bucket name, answering 400 when it is not one.
3073
+ *
3074
+ * The bucket's counterpart to {@link canonicalKeyOrBadRequest}, and it exists
3075
+ * for the same reason: the value routes a write, so it has to be checked where
3076
+ * it enters rather than where it is used. Applied at every entry point a bucket
3077
+ * has — this route's multipart body, the `?bucket=` query, and the TUS
3078
+ * `Upload-Metadata` header.
3079
+ */
3080
+ function canonicalBucketOrBadRequest(bucket) {
3081
+ try {
3082
+ return canonicalStorageBucket(bucket);
3083
+ } catch (err) {
3084
+ throw new ApiError(400, "INVALID_STORAGE_BUCKET", err instanceof InvalidStorageBucketError ? err.message : "Invalid storage bucket");
3085
+ }
3086
+ }
3087
+ /**
3088
+ * Parse image transform query parameters, answering 400 when they are out of
3089
+ * bounds rather than clamping them to something the caller did not ask for.
3090
+ */
3091
+ function transformOptionsOrBadRequest(query) {
3092
+ try {
3093
+ return parseTransformOptions(query);
3094
+ } catch (err) {
3095
+ throw new ApiError(400, "INVALID_TRANSFORM_OPTIONS", err instanceof InvalidTransformOptionsError ? err.message : "Invalid image transform options");
3096
+ }
2569
3097
  }
2570
3098
  /**
2571
3099
  * Build adapter-aware auth middleware for storage routes.
@@ -2669,25 +3197,26 @@ function createStorageRoutes(config) {
2669
3197
  /**
2670
3198
  * Parse bucket and path from a combined file path.
2671
3199
  *
2672
- * The resolved path is run through `sanitizeStorageKey` here — the same
2673
- * function the upload route applies to incoming keys — so that read,
2674
- * delete, metadata and folder routes strip `../`, null bytes and leading
2675
- * slashes before the path reaches the controller, the authorize hook, or
2676
- * the download-token it mints. The `LocalStorageController` traversal guard
2677
- * (`getFullPath`) remains the load-bearing defence; this is the same
2678
- * normalization on the write and read sides so a `..%2f` read/delete cannot
2679
- * even reach it as a raw traversal string (it 404s as a normal miss instead
2680
- * of throwing, and never leaks whether an escape was attempted).
3200
+ * The resolved path is canonicalized here — by the same function the upload
3201
+ * route applies to incoming keys — so read, delete, metadata and folder
3202
+ * routes agree with the write side about what a key means before it reaches
3203
+ * the controller, the authorize hook, or the download token it mints.
3204
+ *
3205
+ * A key that cannot be canonicalized (a real `..` segment, a null byte) is
3206
+ * a 400, not a repaired key. The `LocalStorageController` traversal guard
3207
+ * (`getFullPath`) is the load-bearing defence for the *storage root*, and
3208
+ * `canonicalStorageBucket` for the bucket the caller names; this is what
3209
+ * defends the boundary the hook drew inside them.
2681
3210
  */
2682
3211
  const parseBucketAndPath = (filePath) => {
2683
3212
  const parts = filePath.split("/");
2684
3213
  if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
2685
3214
  bucket: "default",
2686
- resolvedPath: sanitizeStorageKey(parts.slice(1).join("/"))
3215
+ resolvedPath: canonicalKeyOrBadRequest(parts.slice(1).join("/"))
2687
3216
  };
2688
3217
  return {
2689
3218
  bucket: "default",
2690
- resolvedPath: sanitizeStorageKey(filePath)
3219
+ resolvedPath: canonicalKeyOrBadRequest(filePath)
2691
3220
  };
2692
3221
  };
2693
3222
  /**
@@ -2700,9 +3229,9 @@ function createStorageRoutes(config) {
2700
3229
  const uploadedFile = body["file"];
2701
3230
  if (!uploadedFile || typeof uploadedFile === "string") throw ApiError.badRequest("No file provided");
2702
3231
  const key = typeof body["key"] === "string" ? body["key"] : "";
2703
- const bucket = typeof body["bucket"] === "string" ? body["bucket"] : void 0;
3232
+ const bucket = canonicalBucketOrBadRequest(typeof body["bucket"] === "string" ? body["bucket"] : void 0);
2704
3233
  const storageId = typeof body["storageId"] === "string" ? body["storageId"] : c.req.query("storageId");
2705
- const finalKey = sanitizeStorageKey(key || uploadedFile.name || "unnamed");
3234
+ const finalKey = canonicalKeyOrBadRequest(key || uploadedFile.name || "unnamed");
2706
3235
  const metadata = {};
2707
3236
  for (const [k, value] of Object.entries(body)) if (k.startsWith("metadata_")) metadata[k.replace("metadata_", "")] = value;
2708
3237
  await checkAuthorized(c, "write", finalKey, bucket ?? "default", storageId);
@@ -2728,15 +3257,13 @@ function createStorageRoutes(config) {
2728
3257
  const filePath = decodeURIComponent(rawPath);
2729
3258
  const storageId = c.req.query("storageId");
2730
3259
  const resolved = resolveController(storageId);
2731
- {
2732
- const { bucket, resolvedPath } = parseBucketAndPath(filePath);
2733
- await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
2734
- }
2735
- const transformOpts = parseTransformOptions(c.req.query());
3260
+ const { bucket, resolvedPath } = parseBucketAndPath(filePath);
3261
+ await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
3262
+ c.header("X-Content-Type-Options", "nosniff");
3263
+ const transformKeyPrefix = `${storageId || "(default)"}/${bucket}/${resolvedPath}`;
3264
+ const transformOpts = transformOptionsOrBadRequest(c.req.query());
2736
3265
  if (resolved.getType() === "local") {
2737
- const localController = resolved;
2738
- const { bucket, resolvedPath } = parseBucketAndPath(filePath);
2739
- const absolutePath = localController.getAbsolutePath(resolvedPath, bucket);
3266
+ const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
2740
3267
  try {
2741
3268
  await fsp.access(absolutePath);
2742
3269
  } catch {
@@ -2748,37 +3275,30 @@ function createStorageRoutes(config) {
2748
3275
  const metadataRaw = await fsp.readFile(metadataPath, "utf-8");
2749
3276
  contentType = JSON.parse(metadataRaw).contentType || contentType;
2750
3277
  } catch {}
2751
- const fileContent = await fsp.readFile(absolutePath);
2752
3278
  if (transformOpts && isTransformableImage(contentType)) {
2753
- const cacheKey = transformCache.buildKey(filePath, transformOpts);
2754
- let cached = transformCache.get(cacheKey);
2755
- if (!cached) {
2756
- cached = await transformImage(Buffer.from(fileContent), transformOpts);
2757
- transformCache.set(cacheKey, cached.data, cached.contentType);
2758
- }
2759
- c.header("Content-Type", cached.contentType);
3279
+ const transformed = await transformOnce(transformCache.buildKey(transformKeyPrefix, transformOpts), transformOpts, async () => Buffer.from(await fsp.readFile(absolutePath)));
3280
+ c.header("Content-Type", transformed.contentType);
2760
3281
  c.header("Cache-Control", "public, max-age=31536000, immutable");
2761
- return c.body(new Uint8Array(cached.data));
3282
+ return c.body(new Uint8Array(transformed.data));
2762
3283
  }
2763
- c.header("Content-Type", contentType);
3284
+ const fileContent = await fsp.readFile(absolutePath);
3285
+ const served = resolveServedContentType(contentType);
3286
+ c.header("Content-Type", served.contentType);
3287
+ if (served.attachment) c.header("Content-Disposition", "attachment");
2764
3288
  return c.body(new Uint8Array(fileContent));
2765
3289
  }
2766
- const { bucket: parsedBucket, resolvedPath: parsedPath } = parseBucketAndPath(filePath);
2767
- const fileObject = await resolved.getObject(parsedPath, parsedBucket);
3290
+ const fileObject = await resolved.getObject(resolvedPath, bucket);
2768
3291
  if (!fileObject) throw ApiError.notFound("File not found");
2769
3292
  const remoteContentType = fileObject.type || "application/octet-stream";
2770
3293
  if (transformOpts && isTransformableImage(remoteContentType)) {
2771
- const cacheKey = transformCache.buildKey(filePath, transformOpts);
2772
- let cached = transformCache.get(cacheKey);
2773
- if (!cached) {
2774
- cached = await transformImage(Buffer.from(await fileObject.arrayBuffer()), transformOpts);
2775
- transformCache.set(cacheKey, cached.data, cached.contentType);
2776
- }
2777
- c.header("Content-Type", cached.contentType);
3294
+ const transformed = await transformOnce(transformCache.buildKey(transformKeyPrefix, transformOpts), transformOpts, async () => Buffer.from(await fileObject.arrayBuffer()));
3295
+ c.header("Content-Type", transformed.contentType);
2778
3296
  c.header("Cache-Control", "public, max-age=31536000, immutable");
2779
- return c.body(new Uint8Array(cached.data));
3297
+ return c.body(new Uint8Array(transformed.data));
2780
3298
  }
2781
- c.header("Content-Type", remoteContentType);
3299
+ const servedRemote = resolveServedContentType(remoteContentType);
3300
+ c.header("Content-Type", servedRemote.contentType);
3301
+ if (servedRemote.attachment) c.header("Content-Disposition", "attachment");
2782
3302
  c.header("Cache-Control", "public, max-age=3600, immutable");
2783
3303
  const buf = await fileObject.arrayBuffer();
2784
3304
  return c.body(new Uint8Array(buf));
@@ -2837,8 +3357,8 @@ function createStorageRoutes(config) {
2837
3357
  * GET /list - List files in a path
2838
3358
  */
2839
3359
  router.get("/list", writeAuthMiddleware, async (c) => {
2840
- const storagePrefix = sanitizeStorageKey(c.req.query("prefix") || c.req.query("path") || "");
2841
- const bucket = c.req.query("bucket");
3360
+ const storagePrefix = canonicalKeyOrBadRequest(c.req.query("prefix") || c.req.query("path") || "");
3361
+ const bucket = canonicalBucketOrBadRequest(c.req.query("bucket"));
2842
3362
  const maxResults = c.req.query("maxResults");
2843
3363
  const pageToken = c.req.query("pageToken");
2844
3364
  const storageId = c.req.query("storageId");
@@ -2884,8 +3404,8 @@ function createStorageRoutes(config) {
2884
3404
  }, 201);
2885
3405
  });
2886
3406
  const defaultCtrl = getDefaultController();
2887
- const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket) => {
2888
- await checkAuthorized(c, "write", sanitizeStorageKey(key), bucket, c.req.query("storageId"));
3407
+ const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket, storageId) => {
3408
+ await checkAuthorized(c, "write", key, bucket, storageId);
2889
3409
  } : void 0);
2890
3410
  tusHandler.startCleanup();
2891
3411
  router.options("/tus", (_c) => tusHandler.options());
@@ -3022,7 +3542,7 @@ async function createStorageController(config) {
3022
3542
  //#region src/init/storage.ts
3023
3543
  async function initializeStorage(storageConfig, isProduction) {
3024
3544
  if (!storageConfig) return {};
3025
- logger.info("Configuring storage");
3545
+ logger.debug("Configuring storage");
3026
3546
  const controllers = {};
3027
3547
  const toController = async (entry, label) => {
3028
3548
  if (typeof entry.putObject === "function") return entry;
@@ -3079,11 +3599,20 @@ function assertStorageAccessControlConfigured(state, isProduction) {
3079
3599
  }
3080
3600
  //#endregion
3081
3601
  //#region src/init/docs.ts
3082
- async function mountOpenApiDocs(app, basePath, enableSwagger, activeCollections, requireAuth) {
3083
- if (enableSwagger === false || activeCollections.length === 0) return;
3084
- const { generateOpenApiSpec } = await import("./openapi-generator-BEwyiaAr.js");
3602
+ /**
3603
+ * Serve the OpenAPI document, and Swagger UI outside production.
3604
+ *
3605
+ * `serverCollections` must be the collections the REST routes were generated
3606
+ * from — the ones whose data source has a `"server"` transport — not every
3607
+ * active collection. A direct-transport collection is served by the client
3608
+ * against its own backend; documenting it here publishes a full set of CRUD
3609
+ * paths that 404, with a Try-It button next to each.
3610
+ */
3611
+ async function mountOpenApiDocs(app, basePath, enableSwagger, serverCollections, requireAuth) {
3612
+ if (enableSwagger === false || serverCollections.length === 0) return;
3613
+ const { generateOpenApiSpec } = await import("./openapi-generator-DPKtUC9X.js");
3085
3614
  app.get(`${basePath}/docs`, (c) => {
3086
- const spec = generateOpenApiSpec(activeCollections, {
3615
+ const spec = generateOpenApiSpec(serverCollections, {
3087
3616
  basePath,
3088
3617
  requireAuth
3089
3618
  });
@@ -3258,6 +3787,54 @@ function createShutdown(config) {
3258
3787
  };
3259
3788
  }
3260
3789
  //#endregion
3790
+ //#region src/init/process-safety.ts
3791
+ /**
3792
+ * Installed once per process. The singleton is process-global for the same
3793
+ * reason (see `singleton.ts`): more than one copy of this module can be loaded
3794
+ * — the image ships one at `/app/node_modules` and a project's bundle installs
3795
+ * another — and each copy would otherwise add its own listener.
3796
+ */
3797
+ var HANDLER_SLOT = Symbol.for("@rebasepro/server:unhandled-rejection-handler");
3798
+ /**
3799
+ * Log unhandled promise rejections instead of letting Node terminate the
3800
+ * process.
3801
+ *
3802
+ * Node has terminated on an unhandled rejection since v15. Hono catches
3803
+ * anything thrown or rejected inside an awaited handler, so the ordinary
3804
+ * request path is covered — but a fire-and-forget `void syncToCrm(body)`, a
3805
+ * `setTimeout(async () => …)`, or an unawaited `.then()` in one custom function
3806
+ * rejects outside Hono's frame. The default outcome is that one project's
3807
+ * floating promise ends the process; on the managed runtime that process is
3808
+ * shared, so every other tenant on the pod goes with it, and the request log
3809
+ * points nowhere because the request itself succeeded.
3810
+ *
3811
+ * A logged rejection is the better trade here: the process keeps serving, and
3812
+ * the stack reaches the operator. `uncaughtException` is deliberately **not**
3813
+ * handled — a synchronous throw that escaped every frame leaves the process in
3814
+ * a state we cannot reason about, and swallowing it would be a worse bargain
3815
+ * than a restart.
3816
+ *
3817
+ * Set `REBASE_EXIT_ON_UNHANDLED_REJECTION=1` to restore Node's default.
3818
+ *
3819
+ * @returns An uninstall function (used by tests), or `undefined` when nothing
3820
+ * was installed.
3821
+ */
3822
+ function installUnhandledRejectionHandler() {
3823
+ if (process.env.REBASE_EXIT_ON_UNHANDLED_REJECTION === "1") return void 0;
3824
+ const slot = globalThis;
3825
+ if (slot[HANDLER_SLOT]) return void 0;
3826
+ const handler = (reason) => {
3827
+ const error = reason instanceof Error ? reason : new Error(String(reason));
3828
+ logger.error("Unhandled promise rejection — the process kept running. This is almost always a fire-and-forget call in application code (a custom function, a cron job, a callback). Await it, or attach a .catch().", { error });
3829
+ };
3830
+ slot[HANDLER_SLOT] = handler;
3831
+ process.on("unhandledRejection", handler);
3832
+ return () => {
3833
+ process.removeListener("unhandledRejection", handler);
3834
+ delete slot[HANDLER_SLOT];
3835
+ };
3836
+ }
3837
+ //#endregion
3261
3838
  //#region src/auth/collection-callback-warning.ts
3262
3839
  /** The data callbacks the auth write path does not run. */
3263
3840
  var DATA_CALLBACKS = [
@@ -3378,7 +3955,17 @@ function buildQueryString(params) {
3378
3955
  const wire = serializeOrderBy(params.orderBy);
3379
3956
  if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
3380
3957
  }
3381
- if (params.searchString) parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
3958
+ if (params.searchString) {
3959
+ parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
3960
+ if (params.searchExplain) parts.push("searchExplain=true");
3961
+ }
3962
+ if (params.vectorSearch) {
3963
+ const vs = params.vectorSearch;
3964
+ parts.push(`vector_search=${encodeURIComponent(vs.property)}`);
3965
+ parts.push(`vector=${encodeURIComponent(JSON.stringify(vs.vector))}`);
3966
+ if (vs.distance) parts.push(`vector_distance=${encodeURIComponent(vs.distance)}`);
3967
+ if (vs.threshold !== void 0) parts.push(`vector_threshold=${encodeURIComponent(String(vs.threshold))}`);
3968
+ }
3382
3969
  if (params.include && params.include.length > 0) parts.push(`include=${encodeURIComponent(params.include.join(","))}`);
3383
3970
  if (params.logical) {
3384
3971
  const root = params.logical;
@@ -3418,6 +4005,13 @@ function resolveBaseUrl(configured) {
3418
4005
  function createTransport(config, environment) {
3419
4006
  const fetchFn = config.fetch || globalThis.fetch;
3420
4007
  const apiPath = config.apiPath || "/api";
4008
+ for (const field of ["baseUrl", "storageUrlOrigin"]) {
4009
+ const value = config[field];
4010
+ if (!value || !apiPath) continue;
4011
+ const trimmed = value.replace(/\/+$/, "");
4012
+ if (!trimmed.endsWith(apiPath)) continue;
4013
+ console.warn(`[Rebase] ${field} ${JSON.stringify(value)} already ends with the API path ${JSON.stringify(apiPath)}, which is appended to it — requests will go to ${trimmed}${apiPath}/… and 404. Pass the origin only (${JSON.stringify(trimmed.slice(0, trimmed.length - apiPath.length) || "/")}), or set \`apiPath\` if the server really does mount the API one level deeper.`);
4014
+ }
3421
4015
  let token = config.token;
3422
4016
  let tokenGetter;
3423
4017
  let onUnauthorizedHandler = config.onUnauthorized;
@@ -3447,6 +4041,23 @@ function createTransport(config, environment) {
3447
4041
  ...init?.headers || {}
3448
4042
  };
3449
4043
  }
4044
+ /**
4045
+ * The refusal for a success status carrying a body this client cannot read.
4046
+ *
4047
+ * The first 120 characters go in the message because they identify the
4048
+ * sender at a glance: `<!doctype html>` says "you are talking to a web
4049
+ * server, not to this API" faster than any wording here could.
4050
+ *
4051
+ * One function for both the first attempt and the post-refresh retry — the
4052
+ * retry is a second copy of this whole response-reading path, and copies
4053
+ * are how one of them ends up fixed and the other not.
4054
+ */
4055
+ function unreadableResponse(status, text) {
4056
+ return new RebaseApiError(`The server answered ${status} with a body that is not JSON, so there is nothing to return. This usually means the request reached something other than the Rebase API — a single-page-app fallback serving index.html, or a proxy error page — so check the API URL configuration (e.g. VITE_API_URL). The body began: ${JSON.stringify(text.slice(0, 120))}`, {
4057
+ status,
4058
+ code: "INVALID_JSON_RESPONSE"
4059
+ });
4060
+ }
3450
4061
  async function request(path, init) {
3451
4062
  const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
3452
4063
  let activeToken = token;
@@ -3464,9 +4075,27 @@ function createTransport(config, environment) {
3464
4075
  if (res.status === 204) return void 0;
3465
4076
  const text = await res.text().catch(() => "");
3466
4077
  let body = {};
4078
+ /**
4079
+ * Whether the body was there and could not be read as JSON.
4080
+ *
4081
+ * On an error status this does not matter — the status is the answer
4082
+ * and the message falls back to `statusText`. On a *success* status it
4083
+ * is the whole answer, and `{}` was being returned as though the server
4084
+ * had sent it: `find()` answered `{}` instead of an array, `getOne()`
4085
+ * an empty object, with nothing thrown.
4086
+ *
4087
+ * The case that produces it is not exotic. Point `VITE_API_URL` at the
4088
+ * frontend's own host and `/api/data/posts` lands on the SPA fallback,
4089
+ * which answers `200` with `index.html` — so the misconfiguration the
4090
+ * 404 branch below spends four lines explaining reaches the caller, in
4091
+ * its most common form, as an empty success.
4092
+ */
4093
+ let unreadableBody = false;
3467
4094
  if (text) try {
3468
4095
  body = JSON.parse(text, rebaseReviver);
3469
- } catch (e) {}
4096
+ } catch (e) {
4097
+ unreadableBody = true;
4098
+ }
3470
4099
  const getErrorField = (obj, field) => {
3471
4100
  const err = obj?.error;
3472
4101
  if (err && typeof err === "object" && err !== null) return err[field];
@@ -3486,9 +4115,12 @@ function createTransport(config, environment) {
3486
4115
  if (retryRes.status === 204) return void 0;
3487
4116
  const retryText = await retryRes.text().catch(() => "");
3488
4117
  let retryBody = {};
4118
+ let retryUnreadable = false;
3489
4119
  if (retryText) try {
3490
4120
  retryBody = JSON.parse(retryText, rebaseReviver);
3491
- } catch (e) {}
4121
+ } catch (e) {
4122
+ retryUnreadable = true;
4123
+ }
3492
4124
  if (!retryRes.ok) {
3493
4125
  let fallbackMessage = retryRes.statusText;
3494
4126
  if (retryRes.status === 404 && !fallbackMessage) fallbackMessage = `Endpoint not found (${init?.method || "GET"} ${path}). This usually means the collection is not registered on the backend, or the frontend API URL configuration (e.g. VITE_API_URL) is missing or pointing to the wrong host.`;
@@ -3498,6 +4130,7 @@ function createTransport(config, environment) {
3498
4130
  details: getErrorField(retryBody, "details")
3499
4131
  });
3500
4132
  }
4133
+ if (retryUnreadable) throw unreadableResponse(retryRes.status, retryText);
3501
4134
  return retryBody;
3502
4135
  }
3503
4136
  }
@@ -3510,6 +4143,7 @@ function createTransport(config, environment) {
3510
4143
  details: getErrorField(body, "details")
3511
4144
  });
3512
4145
  }
4146
+ if (unreadableBody) throw unreadableResponse(res.status, text);
3513
4147
  return body;
3514
4148
  }
3515
4149
  return {
@@ -3601,6 +4235,11 @@ function createAuth(transport, options) {
3601
4235
  const authFlowMode = opts.authFlowMode || "json";
3602
4236
  const STORAGE_KEY = "rebase_auth";
3603
4237
  const REFRESH_BUFFER_MS = 12e4;
4238
+ /**
4239
+ * The largest delay `setTimeout` can hold — 2^31 - 1 ms, about 24.8 days.
4240
+ * Anything larger is silently clamped to 1ms by Node and every browser.
4241
+ */
4242
+ const MAX_TIMER_DELAY_MS = 2147483647;
3604
4243
  const MAX_REFRESH_RETRIES = 5;
3605
4244
  const REFRESH_RETRY_BASE_MS = 1e3;
3606
4245
  const REFRESH_RETRY_MAX_MS = 3e4;
@@ -3628,7 +4267,9 @@ function createAuth(transport, options) {
3628
4267
  function emit(event, session) {
3629
4268
  for (const fn of listeners) try {
3630
4269
  fn(event, session);
3631
- } catch (e) {}
4270
+ } catch (e) {
4271
+ console.error("Error in auth state change listener:", e);
4272
+ }
3632
4273
  }
3633
4274
  function saveSession(session) {
3634
4275
  if (!persistSession || authFlowMode === "cookie") return;
@@ -3732,6 +4373,10 @@ function createAuth(transport, options) {
3732
4373
  attemptScheduledRefresh(0);
3733
4374
  return;
3734
4375
  }
4376
+ if (delay > MAX_TIMER_DELAY_MS) {
4377
+ refreshTimeout = setTimeout(() => scheduleRefresh(expiresAt), MAX_TIMER_DELAY_MS);
4378
+ return;
4379
+ }
3735
4380
  refreshTimeout = setTimeout(() => {
3736
4381
  attemptScheduledRefresh(0);
3737
4382
  }, delay);
@@ -4468,9 +5113,55 @@ var SDKQueryBuilder = class {
4468
5113
  }
4469
5114
  /**
4470
5115
  * Set a free-text search string if supported by the backend.
5116
+ *
5117
+ * By default this is a substring match across the collection's top-level
5118
+ * string properties. A Postgres collection that declares a `search` block
5119
+ * gets ranked full-text matching over the fields it named instead, and each
5120
+ * row comes back with a `_score` you can sort on:
5121
+ *
5122
+ * ```ts
5123
+ * client.data.talents.search("auditor iso 14001").orderBy("_score", "desc").find()
5124
+ * ```
5125
+ *
5126
+ * Pass `{ explain: true }` to have each row report which of the declared
5127
+ * fields matched, with a highlighted snippet, on `_matches`:
5128
+ *
5129
+ * ```ts
5130
+ * const { data } = await client.data.talents.search("iso 14001", { explain: true }).find();
5131
+ * data[0]._matches
5132
+ * // [{ field: "questionnaire.certifications", snippet: "<mark>ISO</mark> <mark>14001</mark> Lead Auditor" }]
5133
+ * ```
4471
5134
  */
4472
- search(searchString) {
5135
+ search(searchString, options) {
4473
5136
  this.params.searchString = searchString;
5137
+ if (options?.explain !== void 0) this.params.searchExplain = options.explain;
5138
+ return this;
5139
+ }
5140
+ /**
5141
+ * Order rows by nearest-neighbour distance to `vector`.
5142
+ *
5143
+ * The server has supported this from the REST layer since vectors landed;
5144
+ * this is the SDK reaching it. Results come back closest-first with a
5145
+ * `_distance` on each row, and any `where` / `orderBy` on the same query is
5146
+ * a filter applied before the ordering — distance decides the order.
5147
+ *
5148
+ * You supply the query vector. Rebase stores and searches embeddings; it
5149
+ * does not produce them, so this is where whatever model you already use
5150
+ * for the stored vectors gets called.
5151
+ *
5152
+ * @param property - Name of the `vector` property to compare against.
5153
+ * @param vector - The query embedding. Its length must match the property's
5154
+ * declared `dimensions`, or the server answers 400.
5155
+ * @example
5156
+ * client.data.docs.vectorSearch("embedding", queryVector, { threshold: 0.35 }).limit(10).find()
5157
+ */
5158
+ vectorSearch(property, vector, options) {
5159
+ this.params.vectorSearch = {
5160
+ property,
5161
+ vector,
5162
+ ...options?.distance !== void 0 && { distance: options.distance },
5163
+ ...options?.threshold !== void 0 && { threshold: options.threshold }
5164
+ };
4474
5165
  return this;
4475
5166
  }
4476
5167
  /**
@@ -4506,6 +5197,11 @@ var SDKQueryBuilder = class {
4506
5197
  return this.collection.listen(this.params, onUpdate, onError);
4507
5198
  }
4508
5199
  };
5200
+ /**
5201
+ * Counts currently in flight, keyed by the exact request they issue. Entries
5202
+ * live only for the duration of the request — see `count()` for why.
5203
+ */
5204
+ var inflightCounts = /* @__PURE__ */ new Map();
4509
5205
  function createCollectionClient(transport, slug, ws) {
4510
5206
  const basePath = `/data/${slug}`;
4511
5207
  const client = {
@@ -4550,18 +5246,63 @@ function createCollectionClient(transport, slug, ws) {
4550
5246
  body: JSON.stringify({
4551
5247
  rows: data,
4552
5248
  ...options?.upsert ? { upsert: true } : {}
4553
- })
5249
+ }),
5250
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
4554
5251
  })).data || [];
4555
5252
  },
5253
+ /**
5254
+ * Still `PUT`, deliberately, even though the server now serves `PATCH`
5255
+ * on the same handler and `PATCH` is the honest verb for a merge.
5256
+ *
5257
+ * The two are interchangeable server-side, so switching buys nothing at
5258
+ * runtime — and it costs compatibility in the direction that fails
5259
+ * quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
5260
+ * a route that does not exist and get a **404**, which is
5261
+ * indistinguishable from "that row is gone". Every write would look like
5262
+ * a missing record.
5263
+ *
5264
+ * `PATCH` is what the OpenAPI spec advertises, so anyone generating a
5265
+ * client gets the correct verb; this stays on `PUT` until the oldest
5266
+ * supported server is one that serves both.
5267
+ */
4556
5268
  async update(id, data) {
4557
5269
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
4558
5270
  method: "PUT",
4559
5271
  body: JSON.stringify(data)
4560
5272
  });
4561
5273
  },
5274
+ async updateMany(updates, options) {
5275
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
5276
+ if (updates.length === 0) return [];
5277
+ return (await transport.request(`${basePath}/bulk`, {
5278
+ method: "PATCH",
5279
+ body: JSON.stringify({ updates }),
5280
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
5281
+ })).data || [];
5282
+ },
4562
5283
  async delete(id) {
4563
5284
  await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
4564
5285
  },
5286
+ /**
5287
+ * `POST .../bulk/delete`, not `DELETE .../bulk`.
5288
+ *
5289
+ * The honest verb would take the ids in a DELETE body, and that is the
5290
+ * one request shape the HTTP ecosystem handles unreliably: bodies on
5291
+ * DELETE are permitted but widely dropped by proxies and CDNs, and
5292
+ * several OpenAPI generators ignore `requestBody` on a DELETE
5293
+ * operation, so a generated client would send the request without its
5294
+ * ids. A backend deployed behind arbitrary ingress cannot take that
5295
+ * bet. Same reason `:batchDelete` exists in Google's API guidelines.
5296
+ */
5297
+ async deleteMany(ids, options) {
5298
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
5299
+ if (ids.length === 0) return;
5300
+ await transport.request(`${basePath}/bulk/delete`, {
5301
+ method: "POST",
5302
+ body: JSON.stringify({ ids }),
5303
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
5304
+ });
5305
+ },
4565
5306
  async count(params) {
4566
5307
  const qs = buildQueryString({
4567
5308
  ...params,
@@ -4569,12 +5310,28 @@ function createCollectionClient(transport, slug, ws) {
4569
5310
  offset: void 0,
4570
5311
  include: void 0
4571
5312
  });
4572
- return (await transport.request(basePath + "/count" + qs, { method: "GET" })).count ?? 0;
5313
+ const key = basePath + "/count" + qs;
5314
+ const inflight = inflightCounts.get(key);
5315
+ if (inflight) return inflight;
5316
+ const request = transport.request(key, { method: "GET" }).then((raw) => raw.count ?? 0);
5317
+ inflightCounts.set(key, request);
5318
+ try {
5319
+ return await request;
5320
+ } finally {
5321
+ inflightCounts.delete(key);
5322
+ }
4573
5323
  },
4574
5324
  observe(params, onResult, onError, options) {
4575
5325
  let closed = false;
4576
- const emit = (result) => {
5326
+ let liveDelivered = false;
5327
+ let signature;
5328
+ const deliver = (result, fromLive) => {
4577
5329
  if (closed) return;
5330
+ if (fromLive) liveDelivered = true;
5331
+ else if (liveDelivered) return;
5332
+ const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
5333
+ if (signature !== void 0 && next === signature) return;
5334
+ signature = next;
4578
5335
  onResult({
4579
5336
  ...result,
4580
5337
  fromCache: false,
@@ -4582,10 +5339,10 @@ function createCollectionClient(transport, slug, ws) {
4582
5339
  partial: false
4583
5340
  });
4584
5341
  };
4585
- client.find(params).then(emit).catch((error) => {
5342
+ client.find(params).then((result) => deliver(result, false)).catch((error) => {
4586
5343
  if (!closed) onError?.(error);
4587
5344
  });
4588
- const live = options?.realtime !== false && client.listen ? client.listen(params, emit, onError) : void 0;
5345
+ const live = options?.realtime !== false && client.listen ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
4589
5346
  return () => {
4590
5347
  closed = true;
4591
5348
  live?.();
@@ -4593,17 +5350,24 @@ function createCollectionClient(transport, slug, ws) {
4593
5350
  },
4594
5351
  observeById(id, onResult, onError, options) {
4595
5352
  let closed = false;
4596
- const emit = (row) => {
5353
+ let liveDelivered = false;
5354
+ let signature;
5355
+ const deliver = (row, fromLive) => {
4597
5356
  if (closed) return;
5357
+ if (fromLive) liveDelivered = true;
5358
+ else if (liveDelivered) return;
5359
+ const next = row === void 0 ? "\0missing" : JSON.stringify(row);
5360
+ if (signature !== void 0 && next === signature) return;
5361
+ signature = next;
4598
5362
  onResult(row, {
4599
5363
  fromCache: false,
4600
5364
  hasPendingWrites: false
4601
5365
  });
4602
5366
  };
4603
- client.findById(id).then(emit).catch((error) => {
5367
+ client.findById(id).then((row) => deliver(row, false)).catch((error) => {
4604
5368
  if (!closed) onError?.(error);
4605
5369
  });
4606
- const live = options?.realtime !== false && client.listenById ? client.listenById(id, emit, onError) : void 0;
5370
+ const live = options?.realtime !== false && client.listenById ? client.listenById(id, (row) => deliver(row, true), onError) : void 0;
4607
5371
  return () => {
4608
5372
  closed = true;
4609
5373
  live?.();
@@ -4623,8 +5387,11 @@ function createCollectionClient(transport, slug, ws) {
4623
5387
  offset(count) {
4624
5388
  return new SDKQueryBuilder(client).offset(count);
4625
5389
  },
4626
- search(searchString) {
4627
- return new SDKQueryBuilder(client).search(searchString);
5390
+ search(searchString, options) {
5391
+ return new SDKQueryBuilder(client).search(searchString, options);
5392
+ },
5393
+ vectorSearch(property, vector, options) {
5394
+ return new SDKQueryBuilder(client).vectorSearch(property, vector, options);
4628
5395
  },
4629
5396
  include(...relations) {
4630
5397
  return new SDKQueryBuilder(client).include(...relations);
@@ -4634,51 +5401,44 @@ function createCollectionClient(transport, slug, ws) {
4634
5401
  client.listen = (params, onUpdate, onError) => {
4635
5402
  let active = true;
4636
5403
  let lastUpdateId = 0;
5404
+ let lastKnownTotal;
5405
+ const window = resolveFindWindow(params);
4637
5406
  const unsub = ws.listenCollection({
4638
5407
  path: slug,
4639
5408
  filter: params?.where,
5409
+ logical: params?.logical,
4640
5410
  limit: params?.limit,
4641
- startAfter: params?.offset ? String(params.offset) : void 0,
5411
+ offset: window.driverOffset,
4642
5412
  orderBy: params?.orderBy?.[0],
4643
5413
  order: params?.orderBy?.[1],
4644
- searchString: params?.searchString
5414
+ searchString: params?.searchString,
5415
+ searchExplain: params?.searchExplain
4645
5416
  }, (incomingRows) => {
4646
- const currentUpdateId = ++lastUpdateId;
4647
- const requestedLimit = params?.limit || 20;
4648
- const offset = params?.offset || 0;
4649
- const rows = incomingRows;
4650
- const heuristicTotal = rows.length;
4651
- const heuristicHasMore = rows.length >= requestedLimit;
4652
- if (client.count) client.count(params).then((total) => {
4653
- if (active && currentUpdateId === lastUpdateId) onUpdate({
4654
- data: rows,
4655
- meta: {
4656
- total,
4657
- limit: requestedLimit,
4658
- offset,
4659
- hasMore: offset + rows.length < total
4660
- }
4661
- });
4662
- }).catch(() => {
4663
- if (active && currentUpdateId === lastUpdateId) onUpdate({
5417
+ const currentUpdateId = ++lastUpdateId;
5418
+ const requestedLimit = window.limit;
5419
+ const offset = window.offset;
5420
+ const rows = incomingRows;
5421
+ const emit = (total, hasMore) => {
5422
+ if (!active || currentUpdateId !== lastUpdateId) return;
5423
+ onUpdate({
4664
5424
  data: rows,
4665
5425
  meta: {
4666
- total: heuristicTotal,
5426
+ total,
4667
5427
  limit: requestedLimit,
4668
5428
  offset,
4669
- hasMore: heuristicHasMore
5429
+ hasMore
4670
5430
  }
4671
5431
  });
5432
+ };
5433
+ const emitWithoutCount = () => emit(offset + rows.length, rows.length >= requestedLimit);
5434
+ if (client.count) client.count(params).then((total) => {
5435
+ lastKnownTotal = total;
5436
+ emit(total, offset + rows.length < total);
5437
+ }).catch(() => {
5438
+ if (lastKnownTotal !== void 0) emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
5439
+ else emitWithoutCount();
4672
5440
  });
4673
- else onUpdate({
4674
- data: rows,
4675
- meta: {
4676
- total: heuristicTotal,
4677
- limit: requestedLimit,
4678
- offset,
4679
- hasMore: heuristicHasMore
4680
- }
4681
- });
5441
+ else emitWithoutCount();
4682
5442
  }, onError);
4683
5443
  return () => {
4684
5444
  active = false;
@@ -4934,9 +5694,11 @@ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
4934
5694
  *
4935
5695
  * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
4936
5696
  * manages this internally (exposed as `client.ws`, typed by the minimal
4937
- * `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
4938
- * package root only because the `@rebasepro/client-postgres` driver
4939
- * instantiates it directly; its surface may change without a major bump.
5697
+ * `RebaseWebSocket` contract in `@rebasepro/types`). It stays exported from the
5698
+ * package root for the same reason it always was — a data-source driver may
5699
+ * instantiate it directly — but nothing in this repo does since
5700
+ * `@rebasepro/client-postgres` was removed; its surface may change without a
5701
+ * major bump.
4940
5702
  */
4941
5703
  var RebaseWebSocketClient = class {
4942
5704
  websocketUrl;
@@ -5446,6 +6208,11 @@ var RebaseWebSocketClient = class {
5446
6208
  callback.onError(new RebaseApiError(errorMessage, { code: errorCode }));
5447
6209
  }
5448
6210
  } else callback.onUpdate(message);
6211
+ return;
6212
+ }
6213
+ if (type === "ERROR" || type === "error" || message.error) {
6214
+ const { errorMessage, errorCode } = extractMessageError(message);
6215
+ console.warn(`[Rebase] Realtime error from the server${errorCode ? ` (${errorCode})` : ""}: ${errorMessage}`);
5449
6216
  }
5450
6217
  }
5451
6218
  async ensureAuthenticated(retryCount = 3) {
@@ -6061,15 +6828,10 @@ var RebaseWebSocketClient = class {
6061
6828
  }
6062
6829
  }
6063
6830
  createCollectionSubscriptionKey(props) {
6831
+ const { collection, ...query } = props;
6064
6832
  const key = {
6065
- path: props.path,
6066
- filter: props.filter,
6067
- limit: props.limit,
6068
- startAfter: props.startAfter,
6069
- orderBy: props.orderBy,
6070
- order: props.order,
6071
- searchString: props.searchString,
6072
- collection: props.collection?.name
6833
+ ...query,
6834
+ collection: collection?.name
6073
6835
  };
6074
6836
  return JSON.stringify(key, (_, value) => {
6075
6837
  if (value && typeof value === "object" && !Array.isArray(value)) return Object.keys(value).sort().reduce((sorted, k) => {
@@ -6557,11 +7319,29 @@ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
6557
7319
  503,
6558
7320
  504
6559
7321
  ]);
7322
+ /**
7323
+ * The server holds this key for a request it has not answered yet.
7324
+ *
7325
+ * It is a 409 like a duplicate row is a 409, and nothing but the code separates
7326
+ * them — one means "your write is already there", the other means "your write
7327
+ * may not have happened at all, ask again".
7328
+ */
7329
+ var IDEMPOTENCY_IN_PROGRESS = "IDEMPOTENCY_KEY_IN_PROGRESS";
7330
+ /**
7331
+ * Is the server still answering an earlier attempt of this same write?
7332
+ *
7333
+ * The only correct response is to ask again — which is exactly what the
7334
+ * server's own message says, and exactly what this SDK used not to do.
7335
+ */
7336
+ function isIdempotencyInProgressError(error) {
7337
+ return error instanceof RebaseApiError && error.status === 409 && error.code === IDEMPOTENCY_IN_PROGRESS;
7338
+ }
6560
7339
  /** Is this failure worth another attempt later? */
6561
7340
  function isRetryableError(error) {
6562
7341
  if (isNetworkError(error)) return true;
6563
- if (error instanceof RebaseApiError) return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
6564
- return false;
7342
+ if (!(error instanceof RebaseApiError)) return false;
7343
+ if (isIdempotencyInProgressError(error)) return true;
7344
+ return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
6565
7345
  }
6566
7346
  /**
6567
7347
  * Did this write fail because the row is already there?
@@ -6573,10 +7353,16 @@ function isRetryableError(error) {
6573
7353
  * The queue uses this to recognise its own earlier attempt. A create whose
6574
7354
  * response was lost is replayed, and for a row carrying an id the SDK generated
6575
7355
  * the server can only be rejecting it because the first attempt actually landed.
7356
+ *
7357
+ * Which is why the status alone cannot decide it: `IDEMPOTENCY_KEY_IN_PROGRESS`
7358
+ * is a 409 that means the opposite — the row may not exist at all. Read as a
7359
+ * duplicate, the queue looked for a row that was never written, found nothing,
7360
+ * concluded there was nothing left to do and deleted the write from the queue.
6576
7361
  */
6577
7362
  function isDuplicateKeyError(error) {
6578
7363
  if (!(error instanceof RebaseApiError)) return false;
6579
- return error.code === "23505" || error.status === 409;
7364
+ if (error.code === "23505") return true;
7365
+ return error.status === 409 && !isIdempotencyInProgressError(error);
6580
7366
  }
6581
7367
  var ConnectivityMonitor = class {
6582
7368
  state = "online";
@@ -6982,14 +7768,23 @@ function looseEquals(a, b) {
6982
7768
  */
6983
7769
  function likeToRegExp(pattern, caseInsensitive) {
6984
7770
  let source = "^";
7771
+ let lastWasWildcard = false;
6985
7772
  for (let i = 0; i < pattern.length; i++) {
6986
7773
  const char = pattern[i];
6987
7774
  if (char === "\\" && i + 1 < pattern.length) {
6988
7775
  source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6989
7776
  i++;
6990
- } else if (char === "%") source += "[\\s\\S]*";
6991
- else if (char === "_") source += "[\\s\\S]";
6992
- else source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7777
+ lastWasWildcard = false;
7778
+ } else if (char === "%") {
7779
+ if (!lastWasWildcard) source += "[\\s\\S]*";
7780
+ lastWasWildcard = true;
7781
+ } else if (char === "_") {
7782
+ source += "[\\s\\S]";
7783
+ lastWasWildcard = false;
7784
+ } else {
7785
+ source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7786
+ lastWasWildcard = false;
7787
+ }
6993
7788
  }
6994
7789
  return new RegExp(source + "$", caseInsensitive ? "i" : "");
6995
7790
  }
@@ -7121,14 +7916,41 @@ function sortRows(rows, orderBy) {
7121
7916
  function tiebreak(a, b) {
7122
7917
  return compareValues(a.id, b.id) ?? 0;
7123
7918
  }
7124
- /** Resolve `page`/`offset`/`limit` the way the server does. */
7919
+ /**
7920
+ * Resolve `page`/`offset`/`limit` the way the server does.
7921
+ *
7922
+ * It did not: this defaulted an absent limit to 20 while `/api/data` pages by
7923
+ * 50, so the same `observe()` answered with 20 rows from the local database and
7924
+ * 50 from the network — a list that changed length depending on which side
7925
+ * answered, with `page` striding differently on each. Delegated now, so the
7926
+ * sentence above is true by construction rather than by agreement.
7927
+ */
7125
7928
  function resolvePagination(params) {
7126
- const limit = params?.limit ?? 20;
7929
+ const { limit, offset } = resolveFindWindow(params);
7127
7930
  return {
7128
7931
  limit,
7129
- offset: params?.page != null ? Math.max(0, (params.page - 1) * limit) : params?.offset ?? 0
7932
+ offset
7130
7933
  };
7131
7934
  }
7935
+ /** `<`, `<=`, `>`, `>=` — the operators whose answer depends on a collation. */
7936
+ var ORDERING_OPS = /* @__PURE__ */ new Set([
7937
+ "<",
7938
+ "<=",
7939
+ ">",
7940
+ ">="
7941
+ ]);
7942
+ /** Does any condition in this `where` clause order its operands? */
7943
+ function whereOrders(where) {
7944
+ if (!where) return false;
7945
+ for (const condition of Object.values(where)) if ((isTuple(condition) ? [condition] : condition.filter(isTuple)).some(([op]) => ORDERING_OPS.has(op))) return true;
7946
+ return false;
7947
+ }
7948
+ /** The same question, through an `and(...)`/`or(...)` tree. */
7949
+ function logicalOrders(condition, depth = 0) {
7950
+ if (!condition || depth > 32) return false;
7951
+ if ("type" in condition) return (condition.conditions ?? []).some((c) => logicalOrders(c, depth + 1));
7952
+ return ORDERING_OPS.has(condition.operator);
7953
+ }
7132
7954
  /**
7133
7955
  * Can a locally evaluated answer to `params` be trusted to match the server's,
7134
7956
  * assuming the cache holds every row of the collection?
@@ -7136,11 +7958,67 @@ function resolvePagination(params) {
7136
7958
  * `include` pulls in rows from other collections that this evaluator never
7137
7959
  * sees, and `searchString` is only approximated — both make the local answer a
7138
7960
  * best effort rather than an equivalent one.
7961
+ *
7962
+ * **Ordering comparisons are refused, and that is the interesting one.**
7963
+ * `compareValues` falls back to an `Intl.Collator` for operands it cannot read
7964
+ * as numbers or instants. PostgreSQL orders text by the *database's* collation,
7965
+ * which is a property of the server this process has never been told: under the
7966
+ * C collation `'apple' < 'Banana'` is false, under `en_US.UTF-8` it is true,
7967
+ * and the collator says true. So `["<", "Banana"]` selects a different set here
7968
+ * than it does there — silently, and in whichever direction the deployment
7969
+ * happens to have been created.
7970
+ *
7971
+ * The refusal covers *every* ordering comparison rather than only the ones with
7972
+ * a string operand, because the operand type does not settle it: a numeric
7973
+ * bound against a text column (`["<", 10]` on a `varchar`) also reaches the
7974
+ * collator, and nothing in `params` says what the column holds. Conservative on
7975
+ * purpose — the cost is that a query combining an ordering filter with
7976
+ * *unsynced local writes* stops placing those writes optimistically, which is a
7977
+ * degraded answer rather than a wrong one. Claiming exactness we do not have is
7978
+ * the other way round.
7979
+ *
7980
+ * This says nothing about ordering *results*; that is a separate claim with a
7981
+ * separate answer, because a sort changes which rows come first and not which
7982
+ * rows match. See {@link isLocallySortable}.
7139
7983
  */
7140
7984
  function isExactlyEvaluable(params) {
7141
7985
  if (!params) return true;
7142
7986
  if (params.include && params.include.length > 0) return false;
7143
7987
  if (params.searchString) return false;
7988
+ if (params.vectorSearch) return false;
7989
+ if (whereOrders(params.where)) return false;
7990
+ if (logicalOrders(params.logical)) return false;
7991
+ return true;
7992
+ }
7993
+ /**
7994
+ * Would sorting `rows` locally reproduce the order the server would have sent?
7995
+ *
7996
+ * Asked of the rows rather than of the query, because unlike a filter this one
7997
+ * *is* decidable from the data in hand: {@link compareValues} reaches the
7998
+ * collator only when it cannot read both operands as numbers, and `toComparable`
7999
+ * has already turned dates and relations into numbers and ids by then. If every
8000
+ * value on the sort column normalises to a number, the collator is unreachable
8001
+ * and the local order is the server's order.
8002
+ *
8003
+ * A text column is therefore refused — see {@link isExactlyEvaluable} for why
8004
+ * the two cannot be made to agree — and so is a column this page happens to see
8005
+ * only as strings, which is the same thing from here.
8006
+ *
8007
+ * Nulls are fine either way: they are ordered by an explicit rule (last
8008
+ * ascending, first descending) that matches Postgres and never reaches the
8009
+ * comparator.
8010
+ */
8011
+ function isLocallySortable(rows, orderBy) {
8012
+ if (!orderBy) return true;
8013
+ const [field] = orderBy;
8014
+ for (const row of rows) {
8015
+ const value = toComparable(row[field]);
8016
+ if (isNullish(value)) continue;
8017
+ if (typeof value === "number" || typeof value === "boolean") continue;
8018
+ if (typeof value === "bigint") continue;
8019
+ if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) continue;
8020
+ return false;
8021
+ }
7144
8022
  return true;
7145
8023
  }
7146
8024
  /** Run a full query — filter, sort, paginate — over a set of rows. */
@@ -7170,6 +8048,17 @@ function generateOfflineId() {
7170
8048
  return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
7171
8049
  }
7172
8050
  var MISSING = "\0missing";
8051
+ /**
8052
+ * Replays to spend on a mutation whose idempotency key the server is still
8053
+ * holding, when the app has not asked for more.
8054
+ *
8055
+ * Retries double from a second and cap at the sync interval, so the default
8056
+ * budget of five covers about half a minute — less than the lease a server
8057
+ * gives a claim nobody came back for. This many outlast it with room for a slow
8058
+ * batch, and the count is what stops a server that never releases the key from
8059
+ * blocking the queue behind it indefinitely.
8060
+ */
8061
+ var IN_PROGRESS_MIN_RETRIES = 12;
7173
8062
  var OfflineManager = class {
7174
8063
  store;
7175
8064
  maxCachedQueries;
@@ -7450,6 +8339,72 @@ var OfflineManager = class {
7450
8339
  this.notifyCollection(slug);
7451
8340
  return rows;
7452
8341
  },
8342
+ updateMany: async (updates, options) => {
8343
+ await this.ensureCollection(slug);
8344
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
8345
+ if (updates.length === 0) return [];
8346
+ const anyPending = updates.some((u) => this.hasPending(slug, u.id));
8347
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
8348
+ const rows = await inner.updateMany(updates, options);
8349
+ this.connectivity.markSuccess();
8350
+ await this.ingest(slug, rows);
8351
+ this.notifyCollection(slug);
8352
+ return rows;
8353
+ } catch (error) {
8354
+ if (!isNetworkError(error)) throw error;
8355
+ this.connectivity.markFailure();
8356
+ }
8357
+ const rollback = {};
8358
+ const optimistic = [];
8359
+ for (const { id, data } of updates) {
8360
+ const base = this.rawLocalRow(slug, id);
8361
+ rollback[String(id)] = base ?? null;
8362
+ optimistic.push({
8363
+ ...base ?? {},
8364
+ ...data,
8365
+ id
8366
+ });
8367
+ }
8368
+ await this.enqueue({
8369
+ collection: slug,
8370
+ type: "updateMany",
8371
+ updates: updates.map((u) => ({
8372
+ id: u.id,
8373
+ data: u.data
8374
+ })),
8375
+ rollback: { rows: rollback }
8376
+ });
8377
+ for (const row of optimistic) this.setLocalRow(slug, row.id, row);
8378
+ this.notifyCollection(slug);
8379
+ return optimistic;
8380
+ },
8381
+ deleteMany: async (ids, options) => {
8382
+ await this.ensureCollection(slug);
8383
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
8384
+ if (ids.length === 0) return;
8385
+ const anyPending = ids.some((id) => this.hasPending(slug, id));
8386
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
8387
+ await inner.deleteMany(ids, options);
8388
+ this.connectivity.markSuccess();
8389
+ for (const id of ids) this.removeLocalRow(slug, id, true);
8390
+ this.notifyCollection(slug);
8391
+ this.scheduleRefresh(slug);
8392
+ return;
8393
+ } catch (error) {
8394
+ if (!isNetworkError(error)) throw error;
8395
+ this.connectivity.markFailure();
8396
+ }
8397
+ const rollback = {};
8398
+ for (const id of ids) rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
8399
+ await this.enqueue({
8400
+ collection: slug,
8401
+ type: "deleteMany",
8402
+ ids,
8403
+ rollback: { rows: rollback }
8404
+ });
8405
+ for (const id of ids) this.removeLocalRow(slug, id, false);
8406
+ this.notifyCollection(slug);
8407
+ },
7453
8408
  update: async (id, data) => {
7454
8409
  await this.ensureCollection(slug);
7455
8410
  if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
@@ -7528,7 +8483,8 @@ var OfflineManager = class {
7528
8483
  orderBy: (column, direction) => new SDKQueryBuilder(wrapped).orderBy(column, direction),
7529
8484
  limit: (count) => new SDKQueryBuilder(wrapped).limit(count),
7530
8485
  offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
7531
- search: (searchString) => new SDKQueryBuilder(wrapped).search(searchString),
8486
+ search: (searchString, options) => new SDKQueryBuilder(wrapped).search(searchString, options),
8487
+ vectorSearch: (property, vector, options) => new SDKQueryBuilder(wrapped).vectorSearch(property, vector, options),
7532
8488
  include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations)
7533
8489
  };
7534
8490
  if (inner.listen) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
@@ -7748,9 +8704,8 @@ var OfflineManager = class {
7748
8704
  if (!state) return {
7749
8705
  data: [],
7750
8706
  meta: {
8707
+ ...resolvePagination(params),
7751
8708
  total: 0,
7752
- limit: params?.limit ?? 20,
7753
- offset: params?.offset ?? 0,
7754
8709
  hasMore: false
7755
8710
  },
7756
8711
  fromCache: true,
@@ -7786,16 +8741,15 @@ var OfflineManager = class {
7786
8741
  }
7787
8742
  let added = 0;
7788
8743
  const offset = snapshot.offset ?? 0;
7789
- if (exact && offset === 0) {
7790
- for (const [key, entry] of state.rows) {
7791
- if (seen.has(key) || !this.hasPending(slug, key)) continue;
7792
- if (!this.isLocallyCreated(slug, key)) continue;
7793
- if (!matchesParams(entry.row, params)) continue;
7794
- rows.push(entry.row);
7795
- added++;
7796
- }
7797
- if (added > 0 && params?.orderBy) sortRows(rows, params.orderBy);
8744
+ if (exact && offset === 0) for (const [key, entry] of state.rows) {
8745
+ if (seen.has(key) || !this.hasPending(slug, key)) continue;
8746
+ if (!this.isLocallyCreated(slug, key)) continue;
8747
+ if (!matchesParams(entry.row, params)) continue;
8748
+ rows.push(entry.row);
8749
+ added++;
7798
8750
  }
8751
+ const orderIsLocal = isLocallySortable(rows, params?.orderBy);
8752
+ if (params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
7799
8753
  return {
7800
8754
  data: rows,
7801
8755
  meta: {
@@ -7806,7 +8760,7 @@ var OfflineManager = class {
7806
8760
  },
7807
8761
  fromCache,
7808
8762
  hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id)),
7809
- partial: !exact
8763
+ partial: !exact || !orderIsLocal
7810
8764
  };
7811
8765
  }
7812
8766
  localFind(slug, params) {
@@ -7942,17 +8896,17 @@ var OfflineManager = class {
7942
8896
  return row;
7943
8897
  }
7944
8898
  recordSnapshot(slug, params, result) {
8899
+ const window = resolvePagination(params);
7945
8900
  const meta = result.meta ?? {
7946
8901
  total: result.data?.length ?? 0,
7947
- limit: 20,
7948
- offset: 0,
8902
+ ...window,
7949
8903
  hasMore: false
7950
8904
  };
7951
8905
  const snapshot = {
7952
8906
  ids: (result.data ?? []).map((row) => row.id).filter((id) => id !== void 0),
7953
8907
  total: meta.total ?? result.data?.length ?? 0,
7954
- limit: meta.limit ?? params?.limit ?? 20,
7955
- offset: meta.offset ?? params?.offset ?? 0,
8908
+ limit: meta.limit ?? window.limit,
8909
+ offset: meta.offset ?? window.offset,
7956
8910
  hasMore: meta.hasMore ?? false
7957
8911
  };
7958
8912
  const state = this.collectionState(slug);
@@ -8121,7 +9075,8 @@ var OfflineManager = class {
8121
9075
  }
8122
9076
  op.attempts = (op.attempts ?? 0) + 1;
8123
9077
  op.lastError = error?.message ?? String(error);
8124
- if (isRetryableError(error) && op.attempts < this.maxRetries) {
9078
+ const limit = isIdempotencyInProgressError(error) ? Math.max(this.maxRetries, IN_PROGRESS_MIN_RETRIES) : this.maxRetries;
9079
+ if (isRetryableError(error) && op.attempts < limit) {
8125
9080
  await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
8126
9081
  this.connectivity.deferRetry();
8127
9082
  this.patchStatus({ lastError: op.lastError });
@@ -8167,11 +9122,25 @@ var OfflineManager = class {
8167
9122
  await this.adoptServerRow(op, op.id, row);
8168
9123
  } else if (op.type === "createMany") {
8169
9124
  const queued = op.data ?? [];
8170
- const rows = await inner.createMany(queued, op.upsert ? { upsert: true } : void 0);
9125
+ const rows = await inner.createMany(queued, {
9126
+ ...op.upsert ? { upsert: true } : {},
9127
+ idempotencyKey: op.mutationId
9128
+ });
8171
9129
  for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
9130
+ } else if (op.type === "updateMany") {
9131
+ const queued = op.updates ?? [];
9132
+ const rows = await inner.updateMany(queued.map((u) => ({
9133
+ id: u.id,
9134
+ data: u.data
9135
+ })), { idempotencyKey: op.mutationId });
9136
+ for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
8172
9137
  } else if (op.type === "update") {
8173
9138
  const row = await inner.update(op.id, op.data);
8174
9139
  await this.ingestReplaced(op, op.id, row);
9140
+ } else if (op.type === "deleteMany") {
9141
+ const ids = op.ids ?? [];
9142
+ await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
9143
+ for (const id of ids) this.removeLocalRow(op.collection, id, true);
8175
9144
  } else if (op.type === "delete") {
8176
9145
  await inner.delete(op.id);
8177
9146
  this.removeLocalRow(op.collection, op.id, true);
@@ -8414,23 +9383,37 @@ var OfflineManager = class {
8414
9383
  /**
8415
9384
  * Derive a WebSocket URL from an HTTP base URL.
8416
9385
  * `http://` → `ws://`, `https://` → `wss://`.
9386
+ *
9387
+ * A backend mounted under a path is the reason `baseUrl` accepts one, so the
9388
+ * path is kept. It used to be kept for an absolute `baseUrl` and dropped for a
9389
+ * relative one — resolved through `.origin` — so one deployment dialled two
9390
+ * different sockets depending on whether its config said `"/backend"` or
9391
+ * `"https://app.example.com/backend"`.
9392
+ *
9393
+ * Returns `""` when there is nothing to resolve against: a relative `baseUrl`
9394
+ * outside a browser has no origin, and inventing one would dial somewhere
9395
+ * arbitrary. The caller warns rather than leaving that silent.
8417
9396
  */
8418
9397
  function deriveWebSocketUrl(baseUrl) {
9398
+ const toWsProtocol = (url) => {
9399
+ const secure = /^(https|wss):/i.test(url);
9400
+ return url.replace(/^https?:\/\//i, secure ? "wss://" : "ws://").replace(/^wss?:\/\//i, secure ? "wss://" : "ws://").replace(/\/$/, "");
9401
+ };
8419
9402
  if (typeof window !== "undefined") {
8420
- let absoluteUrl = "";
9403
+ let absoluteUrl;
8421
9404
  if (!baseUrl) absoluteUrl = window.location.origin;
8422
9405
  else if (/^https?:\/\//i.test(baseUrl) || /^wss?:\/\//i.test(baseUrl)) absoluteUrl = baseUrl;
8423
9406
  else try {
8424
- absoluteUrl = new URL(baseUrl, window.location.href).origin;
9407
+ const resolved = new URL(baseUrl, window.location.href);
9408
+ absoluteUrl = resolved.origin + resolved.pathname;
8425
9409
  } catch {
8426
9410
  absoluteUrl = window.location.origin;
8427
9411
  }
8428
- const protocol = absoluteUrl.startsWith("https:") || absoluteUrl.startsWith("wss:") ? "wss:" : "ws:";
8429
- return absoluteUrl.replace(/^https?:\/\//i, `${protocol}//`).replace(/^wss?:\/\//i, `${protocol}//`).replace(/\/$/, "");
9412
+ return toWsProtocol(absoluteUrl);
8430
9413
  }
8431
9414
  if (!baseUrl) return "";
8432
9415
  if (!/^https?:\/\//i.test(baseUrl) && !/^wss?:\/\//i.test(baseUrl)) return "";
8433
- return baseUrl.replace(/^https?:\/\//i, (match) => match.toLowerCase() === "https://" ? "wss://" : "ws://").replace(/\/$/, "");
9416
+ return toWsProtocol(baseUrl);
8434
9417
  }
8435
9418
  function createRebaseClient(options) {
8436
9419
  const transport = createTransport(options, { credentialOutOfBand: options.auth?.authFlowMode === "cookie" });
@@ -8458,7 +9441,11 @@ function createRebaseClient(options) {
8458
9441
  });
8459
9442
  return storageSourcesPromise;
8460
9443
  };
8461
- const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
9444
+ const realtimeEnabled = options.realtime !== false;
9445
+ const resolvedWsUrl = realtimeEnabled ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
9446
+ const realtimeUnreachable = realtimeEnabled && !resolvedWsUrl;
9447
+ const unreachableReason = `no WebSocket URL could be derived from baseUrl ${JSON.stringify(options.baseUrl ?? null)} — outside a browser there is no page origin to resolve a relative URL against. Pass an absolute \`baseUrl\`, set \`websocketUrl\` explicitly, or pass \`realtime: false\` to say this was intended.`;
9448
+ if (realtimeUnreachable) console.warn(`[Rebase] Realtime is enabled but ${unreachableReason} Live queries will fall back to a single fetch and channels will throw.`);
8462
9449
  let ws;
8463
9450
  /** One channel object per name — see `realtime.channel`. */
8464
9451
  const realtimeChannels = /* @__PURE__ */ new Map();
@@ -8578,7 +9565,7 @@ function createRebaseClient(options) {
8578
9565
  * cut off the others.
8579
9566
  */
8580
9567
  channel: (name, options) => {
8581
- if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
9568
+ if (!ws) throw new RebaseClientError(realtimeUnreachable ? `Realtime is enabled but ${unreachableReason}` : "Realtime is disabled on this client (realtime: false), so channels are unavailable.");
8582
9569
  let existing = realtimeChannels.get(name);
8583
9570
  if (!existing) {
8584
9571
  existing = new RebaseRealtimeChannel(name, ws, options);
@@ -8621,39 +9608,88 @@ channel: (name, options) => {
8621
9608
  }
8622
9609
  //#endregion
8623
9610
  //#region src/history/history-routes.ts
9611
+ /** Entries returned when the caller names no `?limit`. */
9612
+ var HISTORY_DEFAULT_LIMIT = 20;
9613
+ /** Most entries one history read will serve. Above it the request is refused. */
9614
+ var HISTORY_MAX_LIMIT = 100;
8624
9615
  function createHistoryRoutes(params) {
8625
- const { historyService, registry, driver } = params;
9616
+ const { historyService, registry } = params;
8626
9617
  const router = new Hono();
8627
9618
  router.onError(errorHandler);
8628
9619
  /**
9620
+ * Both halves of the authorization the REST generator applies to every
9621
+ * other route on this router, for the row whose history is being asked for.
9622
+ *
9623
+ * `historyService` reads `rebase.entity_history` through the *privileged*
9624
+ * handle the bootstrapper built — not `c.get("driver")` — and it has to:
9625
+ * history is one table shadowing every audited collection, so it cannot
9626
+ * carry their row policies, and `ensure-history-table` revokes it from
9627
+ * `rebase_user` outright for exactly that reason. Nothing about the read is
9628
+ * therefore scoped to the caller. That makes this function the *entire*
9629
+ * access-control model for the route, the same way `storageAuthorize` is
9630
+ * for storage.
9631
+ *
9632
+ * It asks the two questions the data API already answers, in its order:
9633
+ *
9634
+ * - the API key's permission list, via the same predicate
9635
+ * `RestApiGenerator.enforceApiKeyPermission` uses, and keyed on the
9636
+ * request's own method so that reverting needs `write` and listing needs
9637
+ * `read` — a key scoped to no collection, or to a different one, or to
9638
+ * reading only, must not get past this;
9639
+ * - RLS, by fetching the row itself through the request-scoped driver.
9640
+ * A row the caller may not read comes back undefined, and its history is
9641
+ * then a 404 rather than a 403, because a 403 confirms the row exists.
9642
+ *
9643
+ * A missing scoped driver is an internal error, never a fallback to the
9644
+ * unscoped one — the same rule, and the same reason, as
9645
+ * {@link RestApiGenerator.getScopedDriver}.
9646
+ */
9647
+ async function authorizeEntityRead(c, collection, id) {
9648
+ const apiKey = c.get("apiKey");
9649
+ const operation = httpMethodToOperation(c.req.method);
9650
+ if (apiKey && !isOperationAllowed(apiKey.permissions, collection.slug, operation)) throw ApiError.forbidden(`API key does not have "${operation}" permission for collection "${collection.slug}"`, "API_KEY_FORBIDDEN");
9651
+ const scoped = c.get("driver");
9652
+ if (!scoped) throw ApiError.internal("Scoped driver not available");
9653
+ if (!await scoped.fetchOne({
9654
+ path: collection.slug,
9655
+ id,
9656
+ collection
9657
+ })) throw ApiError.notFound(`Entity '${id}' not found in collection '${collection.slug}'`);
9658
+ }
9659
+ /**
8629
9660
  * GET /:slug/:id/history - List history entries for a entity
8630
9661
  *
8631
9662
  * Query params:
8632
- * limit (default 20)
9663
+ * limit (default 20, maximum 100 — above that the request is refused)
8633
9664
  * offset (default 0)
8634
9665
  */
8635
9666
  router.get("/:slug/:id/history", async (c) => {
8636
9667
  const slug = c.req.param("slug");
8637
9668
  const id = c.req.param("id");
8638
- const parsedLimit = parseInt(c.req.query("limit") ?? "20", 10);
9669
+ const limit = resolveListLimitParam(c.req.query("limit"), {
9670
+ defaultLimit: HISTORY_DEFAULT_LIMIT,
9671
+ maxLimit: HISTORY_MAX_LIMIT
9672
+ });
8639
9673
  const parsedOffset = parseInt(c.req.query("offset") ?? "0", 10);
8640
- const limit = Number.isNaN(parsedLimit) ? 20 : parsedLimit;
8641
9674
  const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;
8642
9675
  const collection = registry.getCollections().find((col) => col.slug === slug || false);
8643
9676
  if (!collection) throw ApiError.notFound(`Collection '${slug}' not found`);
8644
9677
  if (!collection.history) throw ApiError.badRequest(`History is not enabled for collection '${slug}'`);
9678
+ await authorizeEntityRead(c, collection, id);
8645
9679
  const tableName = collection.slug;
9680
+ const appliedLimit = limit;
9681
+ const appliedOffset = Math.max(offset, 0);
8646
9682
  const result = await historyService.fetchHistory(tableName, id, {
8647
- limit: Math.min(limit, 100),
8648
- offset: Math.max(offset, 0)
9683
+ limit: appliedLimit,
9684
+ offset: appliedOffset
8649
9685
  });
8650
9686
  return c.json({
8651
9687
  data: result.data,
8652
9688
  meta: {
8653
9689
  total: result.total,
8654
- limit,
8655
- offset,
8656
- hasMore: offset + result.data.length < result.total
9690
+ limit: appliedLimit,
9691
+ offset: appliedOffset,
9692
+ hasMore: appliedOffset + result.data.length < result.total
8657
9693
  }
8658
9694
  });
8659
9695
  });
@@ -8669,12 +9705,13 @@ function createHistoryRoutes(params) {
8669
9705
  const collection = registry.getCollections().find((col) => col.slug === slug || false);
8670
9706
  if (!collection) throw ApiError.notFound(`Collection '${slug}' not found`);
8671
9707
  if (!collection.history) throw ApiError.badRequest(`History is not enabled for collection '${slug}'`);
9708
+ await authorizeEntityRead(c, collection, id);
8672
9709
  const historyEntry = await historyService.fetchHistoryEntry(historyId);
8673
9710
  if (!historyEntry) throw ApiError.notFound(`History entry '${historyId}' not found`);
8674
9711
  const tableName = collection.slug;
8675
9712
  if (historyEntry.entity_id !== String(id) || historyEntry.table_name !== tableName) throw ApiError.badRequest("History entry does not belong to this entity");
8676
9713
  if (!historyEntry.values) throw ApiError.badRequest("Cannot revert: history entry has no stored values");
8677
- const authDriver = c.get("driver") || driver;
9714
+ const authDriver = c.get("driver");
8678
9715
  const path = collection.slug;
8679
9716
  const savedEntity = await authDriver.save({
8680
9717
  path,
@@ -8691,126 +9728,6 @@ function createHistoryRoutes(params) {
8691
9728
  return router;
8692
9729
  }
8693
9730
  //#endregion
8694
- //#region src/email/smtp-email-service.ts
8695
- var _nodemailer;
8696
- async function loadNodemailer() {
8697
- if (!_nodemailer) try {
8698
- _nodemailer = await import("nodemailer");
8699
- } catch {
8700
- throw new Error("nodemailer is required for SMTP email. Install it: pnpm add nodemailer");
8701
- }
8702
- return _nodemailer;
8703
- }
8704
- /**
8705
- * Safely parse a hostname from a URL string
8706
- */
8707
- function getHostname(urlStr) {
8708
- try {
8709
- return new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`).hostname;
8710
- } catch {
8711
- return;
8712
- }
8713
- }
8714
- /**
8715
- * SMTP Email Service implementation using Nodemailer
8716
- */
8717
- var SMTPEmailService = class {
8718
- transporter = null;
8719
- config;
8720
- _initialized = false;
8721
- constructor(config) {
8722
- this.config = config;
8723
- }
8724
- /**
8725
- * Lazily initialize the SMTP transporter on first use
8726
- */
8727
- async ensureTransporter() {
8728
- if (this._initialized) return;
8729
- this._initialized = true;
8730
- if (this.config.smtp) {
8731
- const nodemailer = await loadNodemailer();
8732
- let smtpName = this.config.smtp.name;
8733
- if (!smtpName) {
8734
- const urlsToTry = [
8735
- process.env.FRONTEND_URL,
8736
- this.config.resetPasswordUrl,
8737
- this.config.verifyEmailUrl
8738
- ];
8739
- for (const urlStr of urlsToTry) if (urlStr) {
8740
- const hostname = getHostname(urlStr);
8741
- if (hostname) {
8742
- smtpName = hostname;
8743
- break;
8744
- }
8745
- }
8746
- }
8747
- this.transporter = nodemailer.createTransport({
8748
- name: smtpName,
8749
- host: this.config.smtp.host,
8750
- port: this.config.smtp.port,
8751
- secure: this.config.smtp.secure ?? this.config.smtp.port === 465,
8752
- auth: this.config.smtp.auth ? {
8753
- user: this.config.smtp.auth.user,
8754
- pass: this.config.smtp.auth.pass
8755
- } : void 0
8756
- });
8757
- }
8758
- }
8759
- /**
8760
- * Check if the email service is properly configured
8761
- */
8762
- isConfigured() {
8763
- return !!(this.config.smtp || this.config.sendEmail);
8764
- }
8765
- /**
8766
- * Send an email using SMTP or custom send function
8767
- */
8768
- async send(options) {
8769
- if (this.config.sendEmail) {
8770
- await this.config.sendEmail(options);
8771
- return;
8772
- }
8773
- await this.ensureTransporter();
8774
- if (!this.transporter) throw new Error("Email service not configured. Provide SMTP config or sendEmail function.");
8775
- const to = Array.isArray(options.to) ? options.to.join(", ") : options.to;
8776
- try {
8777
- await this.transporter.sendMail({
8778
- from: this.config.from,
8779
- to,
8780
- subject: options.subject,
8781
- html: options.html,
8782
- text: options.text,
8783
- replyTo: options.replyTo
8784
- });
8785
- } catch (error) {
8786
- const message = error instanceof Error ? error.message : String(error);
8787
- logger.error("Failed to send email", { detail: message });
8788
- throw new Error(`Failed to send email: ${message}`);
8789
- }
8790
- }
8791
- /**
8792
- * Verify SMTP connection (useful for startup checks)
8793
- */
8794
- async verifyConnection() {
8795
- await this.ensureTransporter();
8796
- if (!this.transporter) return !!this.config.sendEmail;
8797
- try {
8798
- await this.transporter.verify();
8799
- return true;
8800
- } catch (error) {
8801
- const message = error instanceof Error ? error.message : String(error);
8802
- logger.error("SMTP connection verification failed", { detail: message });
8803
- return false;
8804
- }
8805
- }
8806
- };
8807
- /**
8808
- * Create an email service from configuration
8809
- */
8810
- function createEmailService(config) {
8811
- return new SMTPEmailService(config);
8812
- }
8813
- //#endregion
8814
9731
  //#region src/singleton.ts
8815
9732
  /**
8816
9733
  * The backing instance lives on a process-global slot, NOT in a module-local
@@ -8871,14 +9788,30 @@ function _resetRebaseMock() {
8871
9788
  * **Admin data plane** (`rebase.dataAsAdmin`):
8872
9789
  * Backed by the native DataDriver — calls go directly to the database without
8873
9790
  * JSON serialization, HTTP dispatch, or middleware overhead. The driver is
8874
- * scoped as `{ uid: "service", roles: ["admin"] }`, so **every read and write
8875
- * bypasses row-level-security policies**. No `REBASE_SERVICE_KEY` is required.
8876
- *
8877
- * ⚠️ Because it bypasses RLS, `rebase.dataAsAdmin` is for trusted background
8878
- * work (cron jobs, migrations, service tasks) — **not** for serving user-facing
8879
- * data. Inside a request handler, run user-scoped queries through the
8880
- * request-scoped driver (`c.var.driver`), which carries the caller's identity
8881
- * so RLS applies.
9791
+ * scoped once as `{ uid: "service", roles: ["admin"] }` (`SERVICE_IDENTITY`),
9792
+ * which makes it **admin-scoped, not RLS-bypassing**: every read and write runs
9793
+ * in a transaction that has done `SET LOCAL ROLE rebase_user` with
9794
+ * `app.uid = 'service'`, and policies are evaluated against that. No
9795
+ * `REBASE_SERVICE_KEY` is required.
9796
+ *
9797
+ * It clears the default policies through their `rolesOverlap(['admin'])` arm —
9798
+ * which is why the difference rarely shows. It shows when you write your own:
9799
+ *
9800
+ * - `policy.serverContext()` compiles to `auth.uid() IS NULL` and is therefore
9801
+ * **false** for this accessor. A collection with `disableDefaultPolicies:
9802
+ * true` whose rule is `serverContext()` denies these writes (`42501`) and
9803
+ * returns zero rows — HTTP 200, empty — for these reads.
9804
+ * - Its reach equals an `admin`-roled application user's reach. It is not a
9805
+ * private door.
9806
+ *
9807
+ * `rebase.sql()` *is* an unconditional bypass — it runs on the owner connection
9808
+ * and never goes through `withAuth`. Of the two accessors on this object, the
9809
+ * quieter one is the more privileged.
9810
+ *
9811
+ * ⚠️ `rebase.dataAsAdmin` is for trusted background work (cron jobs,
9812
+ * migrations, service tasks) — **not** for serving user-facing data. Inside a
9813
+ * request handler, run user-scoped queries through the request-scoped driver
9814
+ * (`c.var.driver`), which carries the caller's identity.
8882
9815
  *
8883
9816
  * `rebase.data` is **gone from the type**: `RebaseServerClient` omits it, so the
8884
9817
  * admin-scoped accessor has exactly one name and the privilege is visible at the
@@ -8894,7 +9827,8 @@ function _resetRebaseMock() {
8894
9827
  * ```typescript
8895
9828
  * import { rebase } from "@rebasepro/server";
8896
9829
  *
8897
- * // In a cron job, hook, or trusted service file (admin scope, bypasses RLS):
9830
+ * // In a cron job, hook, or trusted service file (admin scope, RLS evaluated
9831
+ * // as `{ uid: "service", roles: ["admin"] }`):
8898
9832
  * await rebase.email.send({ to: "admin@co.com", subject: "Alert", html: "<p>Hi</p>" });
8899
9833
  * const jobs = await rebase.dataAsAdmin.jobs.find({ limit: 10 });
8900
9834
  * ```
@@ -9008,7 +9942,8 @@ async function initializeRebaseBackend(config) {
9008
9942
  async function _initializeRebaseBackend(config) {
9009
9943
  if (config.logging?.level) configureLogLevel(config.logging.level);
9010
9944
  else configureLogLevel();
9011
- logger.info("Initializing Rebase Backend");
9945
+ logger.debug("Initializing Rebase Backend");
9946
+ installUnhandledRejectionHandler();
9012
9947
  const basePath = config.basePath || "/api";
9013
9948
  const isProduction = process.env.NODE_ENV === "production";
9014
9949
  configureMiddlewares(config.app, basePath, isProduction, config);
@@ -9026,13 +9961,14 @@ async function _initializeRebaseBackend(config) {
9026
9961
  });
9027
9962
  }
9028
9963
  const introspectCollections = activeCollections.length === 0;
9029
- logger.info(introspectCollections ? "No collections declared — deriving them from the database schema" : "Serving declared collections");
9964
+ if (introspectCollections) logger.info("No collections declared — deriving them from the database schema");
9965
+ else logger.debug("Serving declared collections");
9030
9966
  const realtimeServices = {};
9031
9967
  const delegates = {};
9032
9968
  let bootstrappers = config.bootstrappers || [];
9033
9969
  if (config.database) {
9034
9970
  const dbAdapter = config.database;
9035
- logger.info("Using DatabaseAdapter", { type: dbAdapter.type });
9971
+ logger.debug("Using DatabaseAdapter", { type: dbAdapter.type });
9036
9972
  bootstrappers = [wrapDatabaseAdapter(dbAdapter)];
9037
9973
  }
9038
9974
  if (bootstrappers.length === 0) throw new Error("No bootstrappers or database adapter provided. Cannot initialize database drivers.");
@@ -9040,7 +9976,7 @@ async function _initializeRebaseBackend(config) {
9040
9976
  let defaultDriverResult = void 0;
9041
9977
  for (const bootstrapper of bootstrappers) {
9042
9978
  const b = bootstrapper;
9043
- logger.info("Running bootstrapper for driver", { driverId: b.id || bootstrapper.type });
9979
+ logger.debug("Running bootstrapper for driver", { driverId: b.id || bootstrapper.type });
9044
9980
  if (b.isDefault) defaultDriverId = b.id || bootstrapper.type;
9045
9981
  const driverResult = await bootstrapper.initializeDriver({
9046
9982
  collections: activeCollections,
@@ -9098,7 +10034,7 @@ async function _initializeRebaseBackend(config) {
9098
10034
  authAdapter = config.auth;
9099
10035
  serviceKey = authAdapter.serviceKey;
9100
10036
  if (authAdapter.initialize) await authAdapter.initialize();
9101
- logger.info("Using AuthAdapter", { id: authAdapter.id });
10037
+ logger.debug("Using AuthAdapter", { id: authAdapter.id });
9102
10038
  authConfigResult = { userService: authAdapter.userManagement ?? {} };
9103
10039
  } else {
9104
10040
  const safeAuthConfig = config.auth;
@@ -9109,7 +10045,7 @@ async function _initializeRebaseBackend(config) {
9109
10045
  });
9110
10046
  if (foundAuthCollection) {
9111
10047
  safeAuthConfig.collection = foundAuthCollection;
9112
- logger.info("Auto-discovered auth collection from collection definitions", { slug: foundAuthCollection.slug });
10048
+ logger.debug("Auto-discovered auth collection from collection definitions", { slug: foundAuthCollection.slug });
9113
10049
  }
9114
10050
  }
9115
10051
  if (safeAuthConfig.collection) {
@@ -9126,17 +10062,17 @@ async function _initializeRebaseBackend(config) {
9126
10062
  if (safeAuthConfig.serviceKey) {
9127
10063
  if (safeAuthConfig.serviceKey.length < 32) throw new Error("REBASE_SERVICE_KEY is too short. Must be at least 32 characters. Generate one with: node -e \"logger.info(require('crypto').randomBytes(48).toString('base64'))\"");
9128
10064
  serviceKey = safeAuthConfig.serviceKey;
9129
- logger.info("Service key configured for script/server-to-server authentication");
10065
+ logger.debug("Service key configured for script/server-to-server authentication");
9130
10066
  }
9131
10067
  if (defaultBootstrapper.initializeAuth) {
9132
- logger.info("Bootstrapping authentication via driver protocol");
10068
+ logger.debug("Bootstrapping authentication via driver protocol");
9133
10069
  authConfigResult = await defaultBootstrapper.initializeAuth(config.auth, defaultDriverResult);
9134
10070
  logger.info("Authentication initialized");
9135
10071
  } else logger.warn("Auth requested but default bootstrapper does not support initializeAuth");
9136
10072
  }
9137
10073
  let historyConfigResult = void 0;
9138
10074
  if (config.history) if (defaultBootstrapper.initializeHistory) {
9139
- logger.info("Bootstrapping entity history via driver protocol");
10075
+ logger.debug("Bootstrapping entity history via driver protocol");
9140
10076
  historyConfigResult = await defaultBootstrapper.initializeHistory(config.history, defaultDriverResult);
9141
10077
  if (historyConfigResult?.historyService && defaultDriverResult.internals) {
9142
10078
  const driver = defaultDriverResult.internals.driver;
@@ -9152,7 +10088,7 @@ async function _initializeRebaseBackend(config) {
9152
10088
  if (apiKeyStoreResult) {
9153
10089
  apiKeyStore = apiKeyStoreResult;
9154
10090
  await apiKeyStore.ensureTable();
9155
- logger.info("Service API Keys initialized");
10091
+ logger.debug("Service API Keys initialized");
9156
10092
  }
9157
10093
  const apiKeyPreAuth = apiKeyStore ? createApiKeyPreAuth({
9158
10094
  store: apiKeyStore,
@@ -9165,7 +10101,7 @@ async function _initializeRebaseBackend(config) {
9165
10101
  serviceKey: internalServiceKey
9166
10102
  });
9167
10103
  config.app.route(`${basePath}/admin/api-keys`, apiKeyRoutes);
9168
- logger.info("API key admin routes mounted", { path: `${basePath}/admin/api-keys` });
10104
+ logger.debug("API key admin routes mounted", { path: `${basePath}/admin/api-keys` });
9169
10105
  }
9170
10106
  const rateLimitConfig = config.rateLimit?.enabled !== false ? {
9171
10107
  ...config.rateLimit,
@@ -9249,7 +10185,7 @@ async function _initializeRebaseBackend(config) {
9249
10185
  ]) {
9250
10186
  const providerConfig = safeAuthConfig[key];
9251
10187
  if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
9252
- const createFn = (await import("./auth-CuC9M2x6.js").then((n) => n.t))[factory];
10188
+ const createFn = (await import("./auth-CYoPVf-E.js").then((n) => n.t))[factory];
9253
10189
  oauthProviders.push(createFn(providerConfig));
9254
10190
  }
9255
10191
  }
@@ -9261,9 +10197,11 @@ async function _initializeRebaseBackend(config) {
9261
10197
  emailConfig: safeAuthConfig.email,
9262
10198
  allowRegistration: safeAuthConfig.allowRegistration ?? false,
9263
10199
  disableSelfRegistration: safeAuthConfig.disableSelfRegistration ?? false,
10200
+ allowAnonymous: safeAuthConfig.allowAnonymous ?? false,
9264
10201
  allowUserLookup: safeAuthConfig.allowUserLookup ?? false,
9265
10202
  defaultRole: safeAuthConfig.defaultRole,
9266
10203
  oauthProviders,
10204
+ allowedRedirectUris: safeAuthConfig.allowedRedirectUris,
9267
10205
  serviceKey: serviceKey || internalServiceKey,
9268
10206
  authHooks: safeAuthConfig.hooks,
9269
10207
  collectionAuthConfig,
@@ -9278,14 +10216,14 @@ async function _initializeRebaseBackend(config) {
9278
10216
  const authRoutes = authAdapter.createAuthRoutes();
9279
10217
  if (authRoutes) {
9280
10218
  config.app.route(`${basePath}/auth`, authRoutes);
9281
- logger.info("Auth routes mounted via adapter", { adapter: authAdapter.id });
10219
+ logger.debug("Auth routes mounted via adapter", { adapter: authAdapter.id });
9282
10220
  }
9283
10221
  }
9284
10222
  if (authAdapter && authAdapter.createAdminRoutes) {
9285
10223
  const adminRoutes = authAdapter.createAdminRoutes();
9286
10224
  if (adminRoutes) {
9287
10225
  config.app.route(`${basePath}/admin`, adminRoutes);
9288
- logger.info("Admin routes mounted via adapter", { adapter: authAdapter.id });
10226
+ logger.debug("Admin routes mounted via adapter", { adapter: authAdapter.id });
9289
10227
  }
9290
10228
  }
9291
10229
  }
@@ -9343,7 +10281,7 @@ async function _initializeRebaseBackend(config) {
9343
10281
  let schemaEditorOff = schemaEditorUnavailable();
9344
10282
  let schemaEditorRoutes;
9345
10283
  if (!schemaEditorOff && config.collectionsDir) try {
9346
- schemaEditorRoutes = (await import("./schema-editor-routes-CbF20Mf1.js")).createSchemaEditorRoutes(config.collectionsDir);
10284
+ schemaEditorRoutes = (await import("./schema-editor-routes-CRcS3ArS.js")).createSchemaEditorRoutes(config.collectionsDir);
9347
10285
  } catch (err) {
9348
10286
  if (err?.code === "ERR_MODULE_NOT_FOUND") {
9349
10287
  schemaEditorOff = {
@@ -9367,7 +10305,7 @@ async function _initializeRebaseBackend(config) {
9367
10305
  message: schemaEditorOff.message
9368
10306
  } }, 501));
9369
10307
  config.app.route(`${basePath}/schema-editor`, schemaEditorRouter);
9370
- if (schemaEditorRoutes) logger.info("Schema Editor mounted", { path: `${basePath}/schema-editor` });
10308
+ if (schemaEditorRoutes) logger.debug("Schema Editor mounted", { path: `${basePath}/schema-editor` });
9371
10309
  else logger.debug("Schema Editor unavailable", {
9372
10310
  path: `${basePath}/schema-editor`,
9373
10311
  code: schemaEditorOff.code
@@ -9393,6 +10331,7 @@ async function _initializeRebaseBackend(config) {
9393
10331
  });
9394
10332
  const storageRouter = new Hono();
9395
10333
  if (apiKeyPreAuth) storageRouter.use("/*", apiKeyPreAuth, createStorageApiKeyGuard());
10334
+ if (rateLimitConfig) storageRouter.use("/*", createDataRateLimiter(rateLimitConfig));
9396
10335
  storageRouter.use("/upload", bodyLimit({
9397
10336
  maxSize: storageMaxSize,
9398
10337
  onError: (c) => {
@@ -9413,6 +10352,7 @@ async function _initializeRebaseBackend(config) {
9413
10352
  config.app.route(`${basePath}/storage`, storageStub);
9414
10353
  logger.info("Storage not configured — /storage returns 501 STORAGE_NOT_CONFIGURED");
9415
10354
  }
10355
+ const serverCollections = activeCollections.filter((collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server");
9416
10356
  if (activeCollections.length > 0) {
9417
10357
  const dataRouter = new Hono();
9418
10358
  dataRouter.onError(errorHandler);
@@ -9420,7 +10360,10 @@ async function _initializeRebaseBackend(config) {
9420
10360
  if (!dataRequireAuth) logger.warn("Data routes running WITHOUT authentication enforcement. Access control is fully delegated to Postgres RLS policies. If no RLS policies exist, data is publicly accessible. Set auth.requireAuth to true (or remove it) to require authentication.");
9421
10361
  else {
9422
10362
  const publicSelect = activeCollections.filter((c) => getEffectiveSecurityRules(c).some((rule) => "access" in rule && rule.access === "public" && (rule.operation === "select" || rule.operation === "all" || Array.isArray(rule.operations) && rule.operations.includes("select")))).map((c) => c.slug);
9423
- if (publicSelect.length > 0) logger.info(`${publicSelect.length} collection(s) grant unfiltered reads (${publicSelect.join(", ")}), but every /api/data route still requires a token: \`access: "public"\` widens which ROWS a caller sees, not who may call. An unauthenticated read answers 401 regardless. To let RLS alone decide — the usual choice for a public website reading its own backend — set AUTH_REQUIRE=false (or \`auth.requireAuth: false\`).`);
10363
+ if (publicSelect.length > 0) {
10364
+ logger.info(`${publicSelect.length} collection(s) grant unfiltered reads (${publicSelect.join(", ")}) — reads still require a token. Set AUTH_REQUIRE=false to let RLS alone decide: https://rebase.pro/docs/collections/security-rules`);
10365
+ logger.debug("`access: \"public\"` widens which ROWS a caller sees, not who may call, so an unauthenticated read of a public collection answers 401 rather than returning rows. Setting AUTH_REQUIRE=false (or `auth.requireAuth: false`) removes the API-level gate and delegates the decision entirely to the Postgres RLS policies — the usual choice for a public website reading its own backend.");
10366
+ }
9424
10367
  }
9425
10368
  const dataPathMarker = `${basePath}/data/`;
9426
10369
  const resolveRequestDriver = (reqPath) => {
@@ -9454,24 +10397,21 @@ async function _initializeRebaseBackend(config) {
9454
10397
  });
9455
10398
  dataRouter.route("/", historyRoutes);
9456
10399
  }
9457
- const restGenerator = new RestApiGenerator(activeCollections.filter((collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server"), defaultDriver, authAdapter);
10400
+ const restGenerator = new RestApiGenerator(serverCollections, defaultDriver, authAdapter);
9458
10401
  dataRouter.route("/", restGenerator.generateRoutes());
9459
10402
  config.app.route(`${basePath}/data`, dataRouter);
9460
10403
  }
9461
- await mountOpenApiDocs(config.app, basePath, config.enableSwagger, activeCollections, resolveRequireAuth(config.auth));
10404
+ await mountOpenApiDocs(config.app, basePath, config.enableSwagger, serverCollections, resolveRequireAuth(config.auth));
9462
10405
  const serverClient = createRebaseClient({
9463
10406
  baseUrl: "http://localhost",
9464
10407
  apiPath: basePath,
9465
- websocketUrl: "",
10408
+ realtime: false,
9466
10409
  token: internalServiceKey,
9467
10410
  fetch: async (input, init) => {
9468
10411
  return await config.app.request(input, init);
9469
10412
  }
9470
10413
  });
9471
- const serviceIdentity = {
9472
- uid: "service",
9473
- roles: ["admin"]
9474
- };
10414
+ const serviceIdentity = SERVICE_IDENTITY;
9475
10415
  const defaultData = buildSdkData(await scopeDataDriver(defaultDriver, serviceIdentity));
9476
10416
  storageAuthorizeData.current = defaultData;
9477
10417
  const dataSourcesByKey = {};
@@ -9490,20 +10430,20 @@ async function _initializeRebaseBackend(config) {
9490
10430
  data: serverData,
9491
10431
  dataAsAdmin: serverData
9492
10432
  });
9493
- logger.info("Native data plane attached to singleton (bypasses HTTP loop)");
10433
+ logger.debug("Native data plane attached to singleton (bypasses HTTP loop)");
9494
10434
  if (storageController) {
9495
10435
  Object.assign(serverClient, { storage: storageController });
9496
- logger.info("Native storage attached to singleton (bypasses HTTP loop)");
10436
+ logger.debug("Native storage attached to singleton (bypasses HTTP loop)");
9497
10437
  }
9498
10438
  let emailService;
9499
10439
  if (authConfigResult?.emailService) emailService = authConfigResult.emailService;
9500
10440
  else if (config.auth && !isAuthAdapter(config.auth) && config.auth.email) emailService = createEmailService(config.auth.email);
9501
10441
  if (emailService) {
9502
10442
  Object.assign(serverClient, { email: emailService });
9503
- logger.info("Email service attached to singleton", { configured: emailService.isConfigured() });
10443
+ logger.debug("Email service attached to singleton", { configured: emailService.isConfigured() });
9504
10444
  if (emailService.isConfigured() && typeof emailService.verifyConnection === "function") emailService.verifyConnection().then((success) => {
9505
10445
  if (!success) logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.");
9506
- else logger.info("SMTP connection verified successfully.");
10446
+ else logger.debug("SMTP connection verified successfully.");
9507
10447
  }).catch((err) => {
9508
10448
  logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.", { error: err });
9509
10449
  });
@@ -9511,57 +10451,61 @@ async function _initializeRebaseBackend(config) {
9511
10451
  const driverAdmin = defaultBootstrapper.getAdmin?.(defaultDriverResult);
9512
10452
  if (isSQLAdmin(driverAdmin)) {
9513
10453
  Object.assign(serverClient, { sql: (query, options) => driverAdmin.executeSql(query, options) });
9514
- logger.info("SQL capability attached to singleton");
10454
+ logger.debug("SQL capability attached to singleton");
9515
10455
  }
9516
- _initRebase(serverClient);
9517
- logger.info("Rebase singleton initialized");
10456
+ const serverSingleton = serverClient;
10457
+ _initRebase(serverSingleton);
10458
+ logger.debug("Rebase singleton initialized");
9518
10459
  if (defaultDriverResult.internals) {
9519
10460
  const driver = defaultDriverResult.internals.driver;
9520
10461
  if (driver && "client" in driver) driver.client = serverClient;
9521
10462
  }
9522
10463
  if (config.functionsDir) {
9523
- const { loadFunctionsFromDirectory } = await import("./function-loader-B_1fYfUY.js").then((n) => n.t);
9524
- const { createFunctionRoutes } = await import("./function-routes-C0cLIy3N.js").then((n) => n.n);
9525
- const loadedFunctions = await loadFunctionsFromDirectory(config.functionsDir);
9526
- if (loadedFunctions.length > 0) {
9527
- const functionsRouter = new Hono();
9528
- functionsRouter.onError(errorHandler);
9529
- const functionsRequireAuth = false;
9530
- if (authAdapter) functionsRouter.use("/*", createAdapterAuthMiddleware({
9531
- adapter: authAdapter,
9532
- driver: defaultDriver,
9533
- requireAuth: functionsRequireAuth,
9534
- apiKeyStore
9535
- }));
9536
- else functionsRouter.use("/*", createAuthMiddleware({
9537
- driver: defaultDriver,
9538
- requireAuth: functionsRequireAuth,
9539
- serviceKey: internalServiceKey,
9540
- apiKeyStore
9541
- }));
9542
- functionsRouter.use("/*", createFunctionApiKeyGuard(`${basePath}/functions`));
9543
- if (rateLimitConfig) functionsRouter.use("/*", createDataRateLimiter({
9544
- ...rateLimitConfig,
9545
- anonymous: null
9546
- }));
9547
- const fnRoutes = createFunctionRoutes(loadedFunctions);
9548
- functionsRouter.route("/", fnRoutes);
9549
- config.app.route(`${basePath}/functions`, functionsRouter);
9550
- logger.info("Mounted custom functions", {
9551
- count: loadedFunctions.length,
9552
- path: `${basePath}/functions`
9553
- });
9554
- }
10464
+ const { loadFunctionsWithDiagnostics } = await import("./function-loader-DDS1v7YX.js").then((n) => n.t);
10465
+ const { createFunctionRoutes } = await import("./function-routes-Btcez1T-.js").then((n) => n.n);
10466
+ const { createFunctionsRequestTimeout, resolveFunctionsTimeoutMs } = await import("./request-timeout-RivJsME0.js");
10467
+ const { functions: loadedFunctions, problems } = await loadFunctionsWithDiagnostics(config.functionsDir);
10468
+ const functionsRouter = new Hono();
10469
+ functionsRouter.onError(errorHandler);
10470
+ const functionsTimeoutMs = resolveFunctionsTimeoutMs(config.functionsTimeoutMs);
10471
+ if (functionsTimeoutMs > 0) functionsRouter.use("/*", createFunctionsRequestTimeout(functionsTimeoutMs));
10472
+ const functionsRequireAuth = false;
10473
+ if (authAdapter) functionsRouter.use("/*", createAdapterAuthMiddleware({
10474
+ adapter: authAdapter,
10475
+ driver: defaultDriver,
10476
+ requireAuth: functionsRequireAuth,
10477
+ apiKeyStore
10478
+ }));
10479
+ else functionsRouter.use("/*", createAuthMiddleware({
10480
+ driver: defaultDriver,
10481
+ requireAuth: functionsRequireAuth,
10482
+ serviceKey: internalServiceKey,
10483
+ apiKeyStore
10484
+ }));
10485
+ functionsRouter.use("/*", createFunctionApiKeyGuard(`${basePath}/functions`));
10486
+ if (rateLimitConfig) functionsRouter.use("/*", createDataRateLimiter({
10487
+ ...rateLimitConfig,
10488
+ anonymous: rateLimitConfig.anonymousFunctions !== void 0 ? rateLimitConfig.anonymousFunctions : DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT
10489
+ }));
10490
+ const fnRoutes = createFunctionRoutes(loadedFunctions, problems.length);
10491
+ functionsRouter.route("/", fnRoutes);
10492
+ config.app.route(`${basePath}/functions`, functionsRouter);
10493
+ if (loadedFunctions.length > 0) logger.info("Mounted custom functions", {
10494
+ count: loadedFunctions.length,
10495
+ path: `${basePath}/functions`,
10496
+ timeoutMs: functionsTimeoutMs
10497
+ });
10498
+ else logger.warn(`Function routes mounted at ${basePath}/functions, but no functions loaded from ${config.functionsDir}. ` + (problems.length > 0 ? `${problems.length} file(s) were skipped — see the messages above.` : "The directory holds no .ts/.js function files."));
9555
10499
  }
9556
10500
  let cronScheduler;
9557
10501
  if (config.cronsDir) {
9558
- const { loadCronJobsFromDirectory } = await import("./cron-loader-B1S2MCSl.js").then((n) => n.t);
9559
- const { CronScheduler } = await import("./cron-scheduler-B3RFt0HS.js").then((n) => n.n);
9560
- const { createCronRoutes } = await import("./cron-routes-CrQ0tK-_.js").then((n) => n.n);
9561
- const { createCronStore } = await import("./cron-store-BywZsyfZ.js").then((n) => n.n);
9562
- const loadedCronJobs = await loadCronJobsFromDirectory(config.cronsDir);
10502
+ const { loadCronJobsWithDiagnostics } = await import("./cron-loader-YhhQeVBM.js").then((n) => n.t);
10503
+ const { CronScheduler } = await import("./cron-scheduler-DIpYBmZP.js").then((n) => n.n);
10504
+ const { createCronRoutes } = await import("./cron-routes-maM_RlUu.js").then((n) => n.n);
10505
+ const { createCronStore } = await import("./cron-store-Dvr4Y1sZ.js").then((n) => n.n);
10506
+ const { jobs: loadedCronJobs, problems: cronProblems } = await loadCronJobsWithDiagnostics(config.cronsDir);
9563
10507
  cronScheduler = new CronScheduler();
9564
- cronScheduler.setClient(serverClient);
10508
+ cronScheduler.setClient(serverSingleton);
9565
10509
  if (loadedCronJobs.length > 0) {
9566
10510
  cronScheduler.registerJobs(loadedCronJobs);
9567
10511
  const store = defaultBootstrapper.getAdmin?.(defaultDriverResult) && config.cronPersistence !== false ? createCronStore(defaultDriver) : void 0;
@@ -9572,7 +10516,7 @@ async function _initializeRebaseBackend(config) {
9572
10516
  }
9573
10517
  const cronRouter = new Hono();
9574
10518
  applyAdminGate(cronRouter, "Cron");
9575
- cronRouter.route("/", createCronRoutes(cronScheduler));
10519
+ cronRouter.route("/", createCronRoutes(cronScheduler, cronProblems.length));
9576
10520
  config.app.route(`${basePath}/cron`, cronRouter);
9577
10521
  if (loadedCronJobs.length > 0) {
9578
10522
  cronScheduler.start();
@@ -9580,10 +10524,10 @@ async function _initializeRebaseBackend(config) {
9580
10524
  count: loadedCronJobs.length,
9581
10525
  path: `${basePath}/cron`
9582
10526
  });
9583
- } else logger.warn(`Cron routes mounted at ${basePath}/cron, but no jobs loaded from ${config.cronsDir}. Nothing is scheduled — check the messages above for files that failed to load.`);
10527
+ } else logger.warn(`Cron routes mounted at ${basePath}/cron, but no jobs loaded from ${config.cronsDir}. ` + (cronProblems.length > 0 ? `Nothing is scheduled — ${cronProblems.length} file(s) were skipped, see the messages above.` : "The directory holds no .ts/.js cron files, so nothing is scheduled."));
9584
10528
  }
9585
10529
  {
9586
- const { createBackupRoutes, parseBackupDestination } = await import("./backup-CVggVhR2.js").then((n) => n.t);
10530
+ const { createBackupRoutes, parseBackupDestination } = await import("./backup-C6ljYVTp.js").then((n) => n.t);
9587
10531
  const backupRouter = new Hono();
9588
10532
  applyAdminGate(backupRouter, "Backup");
9589
10533
  backupRouter.route("/", createBackupRoutes({
@@ -9594,7 +10538,7 @@ async function _initializeRebaseBackend(config) {
9594
10538
  storage: storageController
9595
10539
  }));
9596
10540
  config.app.route(`${basePath}/admin/backups`, backupRouter);
9597
- logger.info("Backup admin routes mounted", { path: `${basePath}/admin/backups` });
10541
+ logger.debug("Backup admin routes mounted", { path: `${basePath}/admin/backups` });
9598
10542
  }
9599
10543
  {
9600
10544
  const { default: logsRoutes } = await import("./logs-routes-BYA72C_C.js").then((n) => n.n);
@@ -9602,10 +10546,10 @@ async function _initializeRebaseBackend(config) {
9602
10546
  applyAdminGate(logsRouter, "Logs");
9603
10547
  logsRouter.route("/", logsRoutes);
9604
10548
  config.app.route(`${basePath}/logs`, logsRouter);
9605
- logger.info("Logs routes mounted", { path: `${basePath}/logs` });
10549
+ logger.debug("Logs routes mounted", { path: `${basePath}/logs` });
9606
10550
  }
9607
10551
  {
9608
- const { createContractRoutes } = await import("./contract-routes-Dj8i5AiM.js").then((n) => n.t);
10552
+ const { createContractRoutes } = await import("./contract-routes-Bet-eCNJ.js").then((n) => n.t);
9609
10553
  const contractRouter = new Hono();
9610
10554
  if (adminSurfacesGated) {
9611
10555
  if (apiKeyPreAuth) contractRouter.use("/contract", apiKeyPreAuth);
@@ -9623,7 +10567,7 @@ async function _initializeRebaseBackend(config) {
9623
10567
  runtimeVersion: config.runtimeVersion
9624
10568
  }));
9625
10569
  config.app.route(`${basePath}/meta`, contractRouter);
9626
- logger.info("Contract routes mounted", { path: `${basePath}/meta` });
10570
+ logger.debug("Contract routes mounted", { path: `${basePath}/meta` });
9627
10571
  }
9628
10572
  const effectiveRealtimeService = Object.keys(realtimeServices).length > 1 ? createRoutedRealtimeService({
9629
10573
  providers: realtimeServices,
@@ -9631,7 +10575,7 @@ async function _initializeRebaseBackend(config) {
9631
10575
  resolveKey: keyForCollectionPath
9632
10576
  }) : defaultRealtimeService;
9633
10577
  if (defaultBootstrapper.initializeWebsockets && effectiveRealtimeService) await defaultBootstrapper.initializeWebsockets(config.server, effectiveRealtimeService, defaultDriver, config.auth, authAdapter);
9634
- logger.info("Rebase Backend Initialized");
10578
+ logger.debug("Rebase Backend Initialized");
9635
10579
  const authSchemaCheck = authConfigResult?.schemaHealthCheck;
9636
10580
  const healthCheck = createHealthCheck(defaultDriver, authSchemaCheck ? () => authSchemaCheck.call(authConfigResult) : void 0);
9637
10581
  const shutdown = createShutdown({
@@ -9699,7 +10643,10 @@ async function _initializeRebaseBackend(config) {
9699
10643
  * export default defineFunction((app, { rebase }) => {
9700
10644
  * app.use("/*", requireAuth);
9701
10645
  * app.get("/home", async (c) => {
9702
- * const [stats] = await rebase.sql!(`SELECT count(*) AS n FROM orders`);
10646
+ * // `rebase.sql` runs on the owner connection: no RLS, no policies,
10647
+ * // every row. It is the most privileged thing in this context —
10648
+ * // more so than `dataAsAdmin`, which is merely admin-scoped.
10649
+ * const [stats] = await rebase.sql(`SELECT count(*) AS n FROM orders`);
9703
10650
  * return c.json({ orders: Number(stats.n) });
9704
10651
  * });
9705
10652
  * });
@@ -9724,6 +10671,17 @@ function defineFunction(definition) {
9724
10671
  *
9725
10672
  * @see {@link defineFunction} for the equivalent custom-functions helper.
9726
10673
  *
10674
+ * `ctx.rebase` is the `rebase` singleton itself — the same object a custom
10675
+ * function is handed — so `rebase.dataAsAdmin` here is **admin-scoped, not an
10676
+ * RLS bypass**: it is scoped once at boot as `SERVICE_IDENTITY`
10677
+ * (`{ uid: "service", roles: ["admin"] }`), and your policies are evaluated
10678
+ * against that identity, not skipped. See that constant's docblock. It clears
10679
+ * the default policies through their `rolesOverlap(['admin'])` arm, which is why
10680
+ * the distinction rarely shows; it shows against hand-written rules, where
10681
+ * `policy.serverContext()` (`auth.uid() IS NULL`) is **false** for it. Use
10682
+ * `rebase.sql()` when you need the genuine bypass — owner connection, no
10683
+ * policies.
10684
+ *
9727
10685
  * @example
9728
10686
  * ```ts
9729
10687
  * import { defineCron } from "@rebasepro/server";
@@ -9731,12 +10689,15 @@ function defineFunction(definition) {
9731
10689
  * export default defineCron({
9732
10690
  * name: "Nightly cleanup",
9733
10691
  * schedule: "0 3 * * *",
9734
- * async handler({ client, log }) {
9735
- * const { data: expired } = await client.data.sessions.find({
10692
+ * async handler({ rebase, log }) {
10693
+ * // `dataAsAdmin` runs as the service identity, with the `admin` role.
10694
+ * // A cron has no per-request user to narrow that, so scope the
10695
+ * // filters yourself — RLS will not do it for you here.
10696
+ * const { data: expired } = await rebase.dataAsAdmin.sessions.find({
9736
10697
  * where: { expired: ["==", true] },
9737
10698
  * });
9738
10699
  * for (const session of expired) {
9739
- * await client.data.sessions.delete(session.id);
10700
+ * await rebase.dataAsAdmin.sessions.delete(session.id as string);
9740
10701
  * }
9741
10702
  * log(`Deleted ${expired.length} expired sessions`);
9742
10703
  * },
@@ -9749,36 +10710,41 @@ function defineCron(definition) {
9749
10710
  //#endregion
9750
10711
  //#region src/utils/sql.ts
9751
10712
  /**
9752
- * Returns a SQL chunk calling `auth.uid()` — the current user's ID.
9753
- * This is a PostgreSQL RLS helper function created in the `auth` schema
10713
+ * Returns a SQL chunk calling `rebase.uid()` — the current user's ID.
10714
+ * This is a PostgreSQL RLS helper function created in the `rebase` schema
9754
10715
  * that reads `app.uid` set per-transaction by `withAuth()`.
9755
10716
  *
10717
+ * The function moved from `auth.uid()` to `rebase.uid()` in 1.0 — `auth` is
10718
+ * Supabase's schema, and taking it meant Rebase could not be pointed at a
10719
+ * database that already had one. Use this helper rather than writing the call
10720
+ * by hand and the move costs you nothing.
10721
+ *
9756
10722
  * @example
9757
10723
  * sql`${table.uid} = ${authUid()}`
9758
10724
  */
9759
10725
  var authUid = () => {
9760
- return sql`auth.uid()`;
10726
+ return sql.raw(RLS_UID_SQL);
9761
10727
  };
9762
10728
  /**
9763
- * Returns a SQL chunk calling `auth.roles()` — the current user's roles
10729
+ * Returns a SQL chunk calling `rebase.roles()` — the current user's roles
9764
10730
  * as a comma-separated string.
9765
10731
  * Reads `app.user_roles` set per-transaction by `withAuth()`.
9766
10732
  *
9767
10733
  * @example
9768
- * sql`auth.roles() ~ 'admin'`
10734
+ * sql`${authRoles()} ~ 'admin'`
9769
10735
  */
9770
10736
  var authRoles = () => {
9771
- return sql`auth.roles()`;
10737
+ return sql.raw(RLS_ROLES_SQL);
9772
10738
  };
9773
10739
  /**
9774
- * Returns a SQL chunk calling `auth.jwt()` — the full JWT claims as JSONB.
10740
+ * Returns a SQL chunk calling `rebase.jwt()` — the full JWT claims as JSONB.
9775
10741
  * Reads `app.jwt` set per-transaction by `withAuth()`.
9776
10742
  *
9777
10743
  * @example
9778
- * sql`auth.jwt()->>'sub'`
10744
+ * sql`${authJwt()}->>'sub'`
9779
10745
  */
9780
10746
  var authJwt = () => {
9781
- return sql`auth.jwt()`;
10747
+ return sql.raw(RLS_JWT_SQL);
9782
10748
  };
9783
10749
  //#endregion
9784
10750
  //#region src/env.ts
@@ -9852,6 +10818,7 @@ var rebaseEnvSchema = object({
9852
10818
  REBASE_SERVICE_KEY: string().optional(),
9853
10819
  ALLOW_REGISTRATION: boolString,
9854
10820
  DISABLE_SELF_REGISTRATION: optionalBoolString,
10821
+ ALLOW_ANONYMOUS: optionalBoolString,
9855
10822
  ALLOW_LOCALHOST_IN_PRODUCTION: optionalBoolString,
9856
10823
  CORS_ORIGINS: string().optional(),
9857
10824
  FRONTEND_URL: string().optional(),
@@ -9915,7 +10882,158 @@ function loadEnv(options) {
9915
10882
  return env;
9916
10883
  }
9917
10884
  //#endregion
10885
+ //#region src/services/outbound-url-guard.ts
10886
+ /**
10887
+ * Refusal to send a request to a destination the guard does not allow.
10888
+ *
10889
+ * Distinct from a network error on purpose: a blocked destination is
10890
+ * *terminal* — retrying it re-runs the same refusal — while an `ENOTFOUND`
10891
+ * or a refused connection is worth another attempt.
10892
+ */
10893
+ var BlockedUrlError = class extends Error {
10894
+ constructor(message) {
10895
+ super(message);
10896
+ this.name = "BlockedUrlError";
10897
+ }
10898
+ };
10899
+ /** Names that mean "this machine" or "this network" before DNS is even asked. */
10900
+ var BLOCKED_HOST_SUFFIXES = [
10901
+ ".localhost",
10902
+ ".local",
10903
+ ".internal",
10904
+ ".home.arpa"
10905
+ ];
10906
+ async function defaultLookup(hostname) {
10907
+ return (await lookup(hostname, {
10908
+ all: true,
10909
+ verbatim: true
10910
+ })).map((e) => e.address);
10911
+ }
10912
+ /**
10913
+ * Parse an IPv6 literal into its 16 bytes. Returns `null` for anything that is
10914
+ * not one — the caller has already established the string *is* an IPv6 address
10915
+ * via `isIP`, so `null` here means "shape we do not model", which is treated as
10916
+ * blocked rather than allowed.
10917
+ */
10918
+ function parseIpv6(address) {
10919
+ let text = address;
10920
+ const zone = text.indexOf("%");
10921
+ if (zone !== -1) text = text.slice(0, zone);
10922
+ let tail = [];
10923
+ const lastColon = text.lastIndexOf(":");
10924
+ const maybeV4 = text.slice(lastColon + 1);
10925
+ if (maybeV4.includes(".")) {
10926
+ if (isIP(maybeV4) !== 4) return null;
10927
+ tail = maybeV4.split(".").map(Number);
10928
+ text = text.slice(0, lastColon);
10929
+ if (text.endsWith(":")) text += ":";
10930
+ }
10931
+ const halves = text.split("::");
10932
+ if (halves.length > 2) return null;
10933
+ const head = halves[0] ? halves[0].split(":") : [];
10934
+ const rest = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : [];
10935
+ const missing = 8 - tail.length / 2 - head.length - rest.length;
10936
+ if (halves.length === 1 ? missing !== 0 : missing < 0) return null;
10937
+ const all = [
10938
+ ...head,
10939
+ ...new Array(halves.length === 2 ? missing : 0).fill("0"),
10940
+ ...rest
10941
+ ];
10942
+ const bytes = [];
10943
+ for (const group of all) {
10944
+ const value = Number.parseInt(group, 16);
10945
+ if (!Number.isFinite(value) || value < 0 || value > 65535 || !/^[0-9a-fA-F]{1,4}$/.test(group)) return null;
10946
+ bytes.push(value >> 8 & 255, value & 255);
10947
+ }
10948
+ return [...bytes, ...tail];
10949
+ }
10950
+ /**
10951
+ * Why this IPv4 address must not be a webhook destination, or `null` if it is
10952
+ * an ordinary public address.
10953
+ */
10954
+ function blockedIpv4Reason(bytes) {
10955
+ const [a, b] = bytes;
10956
+ if (a === 0) return "\"this network\" (0.0.0.0/8)";
10957
+ if (a === 10) return "private (10.0.0.0/8)";
10958
+ if (a === 127) return "loopback (127.0.0.0/8)";
10959
+ if (a === 169 && b === 254) return "link-local (169.254.0.0/16) — the cloud metadata range";
10960
+ if (a === 172 && b >= 16 && b <= 31) return "private (172.16.0.0/12)";
10961
+ if (a === 192 && b === 168) return "private (192.168.0.0/16)";
10962
+ if (a === 100 && b >= 64 && b <= 127) return "carrier-grade NAT (100.64.0.0/10)";
10963
+ if (a === 192 && b === 0 && bytes[2] === 0) return "IETF protocol assignments (192.0.0.0/24)";
10964
+ if (a === 198 && (b === 18 || b === 19)) return "benchmarking (198.18.0.0/15)";
10965
+ if (a >= 224) return "multicast, reserved or broadcast (224.0.0.0/4 and above)";
10966
+ return null;
10967
+ }
10968
+ /**
10969
+ * Why this address must not be a webhook destination, or `null` if it is an
10970
+ * ordinary public address. Exported for tests and for anyone else adding an
10971
+ * outbound call whose destination comes from data.
10972
+ */
10973
+ function blockedAddressReason(address) {
10974
+ const version = isIP(address);
10975
+ if (version === 4) return blockedIpv4Reason(address.split(".").map(Number));
10976
+ if (version !== 6) return `not an IP address: ${address}`;
10977
+ const bytes = parseIpv6(address);
10978
+ if (!bytes || bytes.length !== 16) return `unparseable IPv6 address: ${address}`;
10979
+ const low32 = bytes.slice(12);
10980
+ const isMapped = bytes.slice(0, 10).every((x) => x === 0) && bytes[10] === 255 && bytes[11] === 255;
10981
+ const isCompat = bytes.slice(0, 12).every((x) => x === 0) && !(low32[0] === 0 && low32[1] === 0);
10982
+ const isNat64 = bytes[0] === 0 && bytes[1] === 100 && bytes[2] === 255 && bytes[3] === 155;
10983
+ if (isMapped || isCompat || isNat64) return blockedIpv4Reason(low32);
10984
+ if (bytes[0] === 32 && bytes[1] === 2) return blockedIpv4Reason(bytes.slice(2, 6));
10985
+ if (bytes.every((x) => x === 0)) return "unspecified (::)";
10986
+ if (bytes.slice(0, 15).every((x) => x === 0) && bytes[15] === 1) return "loopback (::1)";
10987
+ if (bytes[0] === 254 && (bytes[1] & 192) === 128) return "link-local (fe80::/10)";
10988
+ if ((bytes[0] & 254) === 252) return "unique local (fc00::/7)";
10989
+ if (bytes[0] === 255) return "multicast (ff00::/8)";
10990
+ return null;
10991
+ }
10992
+ /**
10993
+ * Resolve and validate a destination before anything connects to it.
10994
+ *
10995
+ * Three checks, in order, because each is cheaper than the next: the scheme,
10996
+ * the hostname, then every address the hostname resolves to. *Every* address —
10997
+ * a name that answers with one public and one private record is a rebinding
10998
+ * attempt, and taking the first record would let it through.
10999
+ *
11000
+ * Known limit: this is validate-then-fetch. Between the lookup here and the
11001
+ * connection the runtime makes there is a second resolution this code does not
11002
+ * see, so a TTL-0 name that flips between answers can still land on a blocked
11003
+ * address. Closing that needs the socket pinned to the address checked here,
11004
+ * which Node's global `fetch` does not expose without an undici dispatcher.
11005
+ * The redirect policy in {@link WebhookDispatcher} closes the much wider
11006
+ * version of the same hole — a receiver that simply *points* at the internal
11007
+ * address.
11008
+ *
11009
+ * @throws BlockedUrlError when the destination is not allowed. DNS failures
11010
+ * are rethrown as they are, so callers can tell "never send this" from
11011
+ * "could not send this right now".
11012
+ */
11013
+ async function assertAllowedOutboundUrl(rawUrl, options = {}) {
11014
+ let url;
11015
+ try {
11016
+ url = new URL(rawUrl);
11017
+ } catch {
11018
+ throw new BlockedUrlError(`Invalid webhook URL: ${JSON.stringify(rawUrl)}`);
11019
+ }
11020
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new BlockedUrlError(`Webhook URL must be http(s), got "${url.protocol}" in ${url.href}`);
11021
+ if (options.allowPrivateNetworks) return url;
11022
+ const hostname = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname;
11023
+ const lowered = hostname.toLowerCase();
11024
+ if (lowered === "localhost" || BLOCKED_HOST_SUFFIXES.some((suffix) => lowered.endsWith(suffix))) throw new BlockedUrlError(`Webhook URL host "${hostname}" is an internal name`);
11025
+ const addresses = isIP(hostname) ? [hostname] : await (options.lookup ?? defaultLookup)(hostname);
11026
+ if (addresses.length === 0) throw new BlockedUrlError(`Webhook URL host "${hostname}" resolved to no addresses`);
11027
+ for (const address of addresses) {
11028
+ const reason = blockedAddressReason(address);
11029
+ if (reason) throw new BlockedUrlError(`Webhook URL host "${hostname}" resolves to ${address}, which is ${reason}. Set allowPrivateNetworks to deliver to it anyway.`);
11030
+ }
11031
+ return url;
11032
+ }
11033
+ //#endregion
9918
11034
  //#region src/services/webhook-service.ts
11035
+ /** Read at most this much of a response before cancelling the rest. */
11036
+ var MAX_RESPONSE_BYTES = 64 * 1024;
9919
11037
  var WebhookDispatcher = class {
9920
11038
  webhooks = [];
9921
11039
  maxRetries = 3;
@@ -9924,35 +11042,107 @@ var WebhookDispatcher = class {
9924
11042
  5e3,
9925
11043
  15e3
9926
11044
  ];
11045
+ options;
11046
+ timeoutMs;
11047
+ queue = [];
11048
+ draining = null;
11049
+ constructor(options = {}) {
11050
+ this.options = options;
11051
+ this.timeoutMs = options.timeoutMs ?? 1e4;
11052
+ }
9927
11053
  /** Register webhooks to watch */
9928
11054
  setWebhooks(webhooks) {
9929
11055
  this.webhooks = webhooks.filter((w) => w.enabled);
9930
11056
  }
9931
- /** Called when a entity changes — checks if any webhook matches */
11057
+ /**
11058
+ * Called when a entity changes — checks if any webhook matches, and awaits
11059
+ * every delivery, retries included.
11060
+ *
11061
+ * **Do not await this inside a collection callback.** `afterSave` and
11062
+ * `afterDelete` run inside the write's Postgres transaction, so awaiting a
11063
+ * delivery here holds a pooled connection and the row's locks for as long
11064
+ * as the receiver takes to answer — up to ~36s across three attempts, per
11065
+ * matching webhook. Use {@link enqueueEntityChange} there; this method is
11066
+ * for a custom function or a job that wants the results.
11067
+ */
9932
11068
  async onEntityChange(table, event, id, entity, previousEntity) {
9933
- const matchingWebhooks = this.webhooks.filter((w) => w.table === table && w.events.includes(event));
9934
- if (matchingWebhooks.length === 0) return [];
11069
+ const jobs = this.buildDeliveries(table, event, entity, previousEntity);
9935
11070
  const results = [];
9936
- for (const webhook of matchingWebhooks) {
9937
- const payload = {
11071
+ for (const job of jobs) {
11072
+ const result = await this.deliverWithRetry(job.webhook, job.event, job.payload);
11073
+ this.options.onDelivery?.(result);
11074
+ results.push(result);
11075
+ }
11076
+ return results;
11077
+ }
11078
+ /**
11079
+ * Queue the same deliveries and return immediately.
11080
+ *
11081
+ * This is the form a collection callback wants. `afterSave` is awaited
11082
+ * *inside* the transaction that wrote the row, so anything it awaits is
11083
+ * transaction time; queueing hands the HTTP to a drain loop that runs
11084
+ * after the callback returns, and the transaction commits without waiting
11085
+ * for a third party. It also means a receiver that is down can no longer
11086
+ * fail the customer's write.
11087
+ *
11088
+ * The cost, stated plainly: the queue is in-process and in-memory. A crash
11089
+ * or a deploy between the enqueue and the delivery drops the event, and a
11090
+ * receiver may see the notification a few milliseconds before the row it
11091
+ * describes is committed. Use {@link flush} on shutdown, and
11092
+ * `onDelivery` to record failures.
11093
+ */
11094
+ enqueueEntityChange(table, event, id, entity, previousEntity) {
11095
+ const jobs = this.buildDeliveries(table, event, entity, previousEntity);
11096
+ if (jobs.length === 0) return;
11097
+ this.queue.push(...jobs);
11098
+ this.startDraining();
11099
+ }
11100
+ /**
11101
+ * Wait for every queued delivery to finish. For graceful shutdown, and for
11102
+ * tests that need to observe what {@link enqueueEntityChange} sent.
11103
+ */
11104
+ async flush() {
11105
+ while (this.draining) await this.draining;
11106
+ }
11107
+ startDraining() {
11108
+ if (this.draining) return;
11109
+ const run = this.drainQueue().finally(() => {
11110
+ if (this.draining === run) this.draining = null;
11111
+ });
11112
+ this.draining = run;
11113
+ }
11114
+ async drainQueue() {
11115
+ while (this.queue.length > 0) {
11116
+ const job = this.queue.shift();
11117
+ try {
11118
+ const result = await this.deliverWithRetry(job.webhook, job.event, job.payload);
11119
+ this.options.onDelivery?.(result);
11120
+ } catch {}
11121
+ }
11122
+ }
11123
+ /** The payload each matching webhook gets for this change. */
11124
+ buildDeliveries(table, event, entity, previousEntity) {
11125
+ return this.webhooks.filter((w) => w.table === table && w.events.includes(event)).map((webhook) => ({
11126
+ webhook,
11127
+ event,
11128
+ payload: {
9938
11129
  type: event,
9939
11130
  table,
9940
11131
  record: entity,
9941
11132
  old_record: event === "UPDATE" ? previousEntity : void 0,
9942
11133
  schema: "public",
9943
11134
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
9944
- };
9945
- const result = await this.deliverWithRetry(webhook, event, payload);
9946
- results.push(result);
9947
- }
9948
- return results;
11135
+ }
11136
+ }));
9949
11137
  }
9950
11138
  async deliverWithRetry(webhook, event, payload) {
9951
11139
  for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
9952
- const result = await this.deliver(webhook, event, payload, attempt);
9953
- if (result.success) return result;
9954
- if (attempt < this.maxRetries) await new Promise((r) => setTimeout(r, this.retryDelays[attempt - 1]));
9955
- else return result;
11140
+ const { result, terminal } = await this.deliver(webhook, event, payload, attempt);
11141
+ if (result.success || terminal) return result;
11142
+ if (attempt < this.maxRetries) {
11143
+ const backoff = this.retryDelays[attempt - 1] ?? this.retryDelays[this.retryDelays.length - 1];
11144
+ await new Promise((r) => setTimeout(r, backoff));
11145
+ } else return result;
9956
11146
  }
9957
11147
  return {
9958
11148
  webhookId: webhook.id,
@@ -9975,41 +11165,106 @@ var WebhookDispatcher = class {
9975
11165
  ...webhook.headers || {}
9976
11166
  };
9977
11167
  if (webhook.secret) headers["X-Webhook-Signature"] = `sha256=${createHmac("sha256", webhook.secret).update(body).digest("hex")}`;
11168
+ const controller = new AbortController();
11169
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
9978
11170
  try {
9979
- const controller = new AbortController();
9980
- const timeout = setTimeout(() => controller.abort(), 1e4);
9981
- const response = await fetch(webhook.url, {
11171
+ const url = await assertAllowedOutboundUrl(webhook.url, {
11172
+ allowPrivateNetworks: this.options.allowPrivateNetworks,
11173
+ lookup: this.options.lookup
11174
+ });
11175
+ const response = await fetch(url.href, {
9982
11176
  method: "POST",
9983
11177
  headers,
9984
11178
  body,
9985
- signal: controller.signal
11179
+ signal: controller.signal,
11180
+ redirect: "manual"
9986
11181
  });
9987
- clearTimeout(timeout);
9988
- const responseBody = await response.text().catch(() => "");
11182
+ if (response.status >= 300 && response.status < 400) {
11183
+ const location = typeof response.headers?.get === "function" ? response.headers.get("location") : null;
11184
+ return {
11185
+ terminal: true,
11186
+ result: {
11187
+ webhookId: webhook.id,
11188
+ event,
11189
+ payload,
11190
+ statusCode: response.status,
11191
+ responseBody: `Webhook receivers must not redirect: HTTP ${response.status}` + (location ? ` to ${location}` : "") + ". The redirect target is not re-validated, so it is not followed.",
11192
+ success: false,
11193
+ attemptNumber
11194
+ }
11195
+ };
11196
+ }
11197
+ const responseBody = await this.readCappedBody(response);
9989
11198
  const success = response.status >= 200 && response.status < 300;
9990
11199
  return {
9991
- webhookId: webhook.id,
9992
- event,
9993
- payload,
9994
- statusCode: response.status,
9995
- responseBody: responseBody.slice(0, 1e3),
9996
- success,
9997
- attemptNumber
11200
+ terminal: false,
11201
+ result: {
11202
+ webhookId: webhook.id,
11203
+ event,
11204
+ payload,
11205
+ statusCode: response.status,
11206
+ responseBody: responseBody.slice(0, 1e3),
11207
+ success,
11208
+ attemptNumber
11209
+ }
9998
11210
  };
9999
11211
  } catch (error) {
10000
11212
  const message = error instanceof Error ? error.message : String(error);
10001
11213
  return {
10002
- webhookId: webhook.id,
10003
- event,
10004
- payload,
10005
- statusCode: 0,
10006
- responseBody: message.slice(0, 1e3),
10007
- success: false,
10008
- attemptNumber
11214
+ terminal: error instanceof BlockedUrlError,
11215
+ result: {
11216
+ webhookId: webhook.id,
11217
+ event,
11218
+ payload,
11219
+ statusCode: 0,
11220
+ responseBody: message.slice(0, 1e3),
11221
+ success: false,
11222
+ attemptNumber
11223
+ }
10009
11224
  };
11225
+ } finally {
11226
+ clearTimeout(timeout);
11227
+ }
11228
+ }
11229
+ /**
11230
+ * Read at most {@link MAX_RESPONSE_BYTES} of the response and cancel the
11231
+ * rest, so a receiver cannot buffer a 10 GB body into the pod before the
11232
+ * caller truncates it to 1000 characters.
11233
+ */
11234
+ async readCappedBody(response) {
11235
+ const stream = response.body;
11236
+ if (!stream || typeof stream.getReader !== "function") return await response.text().catch(() => "");
11237
+ const reader = stream.getReader();
11238
+ const chunks = [];
11239
+ let total = 0;
11240
+ try {
11241
+ for (;;) {
11242
+ const { done, value } = await reader.read();
11243
+ if (done) break;
11244
+ if (!value) continue;
11245
+ chunks.push(value);
11246
+ total += value.byteLength;
11247
+ if (total >= MAX_RESPONSE_BYTES) break;
11248
+ }
11249
+ } catch {
11250
+ return new TextDecoder().decode(concat(chunks, total));
11251
+ } finally {
11252
+ await reader.cancel().catch(() => void 0);
10010
11253
  }
11254
+ return new TextDecoder().decode(concat(chunks, total));
10011
11255
  }
10012
11256
  };
11257
+ function concat(chunks, total) {
11258
+ const out = new Uint8Array(total);
11259
+ let offset = 0;
11260
+ for (const chunk of chunks) {
11261
+ const room = out.length - offset;
11262
+ if (room <= 0) break;
11263
+ out.set(chunk.length > room ? chunk.subarray(0, room) : chunk, offset);
11264
+ offset += chunk.length;
11265
+ }
11266
+ return out;
11267
+ }
10013
11268
  //#endregion
10014
11269
  //#region src/utils/dev-port.ts
10015
11270
  var MAX_PORT_ATTEMPTS = 20;
@@ -10170,10 +11425,11 @@ function isUnderPath(requestPath, prefix) {
10170
11425
  * Serve a Single Page Application from an Hono app.
10171
11426
  *
10172
11427
  * @internal Not part of the stable public API. Exported only because the
10173
- * official app template (`packages/cli/templates/template/backend/src/index.ts`
10174
- * and `app/backend/src/index.ts`) calls it to serve the built frontend in
10175
- * production. Its request-handling behavior is an implementation detail and
10176
- * may change without a major version bump.
11428
+ * entrypoint `rebase eject` writes
11429
+ * (`packages/cli/templates/eject/backend/src/index.ts`) and `app/backend/src/index.ts`
11430
+ * call it to serve the built frontend in production — the scaffolded template
11431
+ * has no entrypoint of its own. Its request-handling behavior is an
11432
+ * implementation detail and may change without a major version bump.
10177
11433
  */
10178
11434
  function serveSPA(app, config) {
10179
11435
  const { frontendPath, apiBasePath = "/api", excludePaths = [], indexFile = "index.html", spa = true } = config;
@@ -11317,6 +12573,7 @@ function resolveAuthOptions(env, usersCollection) {
11317
12573
  requireAuth: env.AUTH_REQUIRE,
11318
12574
  allowRegistration: env.ALLOW_REGISTRATION,
11319
12575
  disableSelfRegistration: env.DISABLE_SELF_REGISTRATION,
12576
+ allowAnonymous: env.ALLOW_ANONYMOUS,
11320
12577
  allowUserLookup: env.AUTH_ALLOW_USER_LOOKUP,
11321
12578
  email: resolveEmailOptions(env),
11322
12579
  cookieAuth: { sameSite: env.AUTH_COOKIE_SAME_SITE || "Lax" }
@@ -11724,7 +12981,7 @@ async function bootFromBundle(options = {}) {
11724
12981
  const bundleDir = options.bundleDir || fetchedDir || process.env.REBASE_BUNDLE || path.resolve(process.cwd(), "dist-bundle");
11725
12982
  const bundle = options.bundle ?? loadBundle(bundleDir);
11726
12983
  const devRoot = process.env.REBASE_DEV_PROJECT_ROOT || process.cwd();
11727
- logger.info("Loaded bundle", {
12984
+ logger.debug("Loaded bundle", {
11728
12985
  app: bundle.manifest.app,
11729
12986
  kind: bundle.manifest.kind,
11730
12987
  schemaVersion: bundle.manifest.schemaVersion,
@@ -11785,7 +13042,8 @@ async function bootFromBundle(options = {}) {
11785
13042
  });
11786
13043
  await ensureCollectionPolicies(bundle, dataSources, env);
11787
13044
  metrics?.setKnownCollections(backend.collectionRegistry.getCollections().map((collection) => collection.slug).filter((slug) => Boolean(slug)));
11788
- app.get("/health", async (c) => {
13045
+ const healthPaths = ["/health", `${(env.REBASE_BASE_PATH || "/api").replace(/\/$/, "")}/health`];
13046
+ for (const healthPath of [...new Set(healthPaths)]) app.get(healthPath, async (c) => {
11789
13047
  const result = await backend.healthCheck();
11790
13048
  const unhealthy = (await Promise.all(dataSources.filter((source) => source.key !== DEFAULT_DATA_SOURCE_KEY).map(async (source) => ({
11791
13049
  key: source.key,
@@ -11891,7 +13149,7 @@ async function bootStaticApp(bundle, devRoot, options) {
11891
13149
  if (metrics) app.use("/*", metrics.middleware);
11892
13150
  const server = createServer(getRequestListener(app.fetch));
11893
13151
  app.get("/livez", (c) => c.json({ status: "ok" }));
11894
- app.get("/health", (c) => c.json({
13152
+ for (const healthPath of [.../* @__PURE__ */ new Set(["/health", `${basePath.replace(/\/$/, "")}/health`])]) app.get(healthPath, (c) => c.json({
11895
13153
  status: "ok",
11896
13154
  latencyMs: 0
11897
13155
  }));
@@ -12001,6 +13259,40 @@ function warnOnDriverSkew(dataSources, runtimeVersion) {
12001
13259
  }
12002
13260
  }
12003
13261
  /**
13262
+ * The collections a data source's engine is the store for.
13263
+ *
13264
+ * A bundle's collections directory holds *every* collection the project
13265
+ * declares, whatever engine serves it — that is the point of `dataSource`
13266
+ * routing. What it is not is a list of tables to create: handing the whole
13267
+ * directory to the primary source's bootstrapper made a Firestore collection
13268
+ * declared alongside the Postgres ones arrive as an empty Postgres table, with
13269
+ * RLS policies, while the app went on reading its documents from Firestore.
13270
+ *
13271
+ * Excluding is deliberately conservative. A collection that names neither an
13272
+ * `engine` nor a `dataSource` belongs to whichever source is primary — the
13273
+ * "postgres" that `resolveDataSource` falls back to there is a default, not a
13274
+ * declaration, and must not exclude anything on its own. Only a collection that
13275
+ * explicitly routes to a *different* engine is dropped, so a project running two
13276
+ * sources on the same engine is unaffected.
13277
+ */
13278
+ function collectionsStoredBy(collections, primary, dataSources) {
13279
+ const registry = createDataSourceRegistry(dataSources.map((source) => ({
13280
+ key: source.key,
13281
+ engine: source.engine
13282
+ })));
13283
+ return collections.filter((collection) => {
13284
+ const declared = collection.engine ?? (collection.dataSource ? resolveDataSource(collection, registry).engine : void 0);
13285
+ if (!declared) return true;
13286
+ return declared === primary.engine;
13287
+ });
13288
+ }
13289
+ /** Say what was routed elsewhere, so a missing table is never a silent one. */
13290
+ function logForeignCollections(all, stored, primary) {
13291
+ if (stored.length === all.length) return;
13292
+ const foreign = all.filter((c) => !stored.includes(c)).map((c) => c.slug);
13293
+ logger.info(`Skipping ${foreign.length} collection(s) served by another engine, not "${primary.engine}": ${foreign.join(", ")}. Their storage is not managed by this data source.`);
13294
+ }
13295
+ /**
12004
13296
  * Bring the database's collection tables up to date before serving.
12005
13297
  *
12006
13298
  * Delegates to whichever driver bootstrapped the default data source; a driver
@@ -12049,13 +13341,20 @@ async function ensureCollectionSchema(bundle, dataSources, env) {
12049
13341
  skip(`the adapter from "${primary.driverPackage}" (engine "${primary.engine}") does not expose collection-table creation. ` + (skew.detail ? `${skew.detail} ` : "") + "The driver package may well implement it on a class the adapter does not forward, so check the adapter's shape before blaming its version. Collection tables will NOT be created, so every /api/data route will fail on a missing relation.\n" + schemaRecoveryGuidance({ staleDriver: skew.stale }), "warn");
12050
13342
  return;
12051
13343
  }
12052
- const collections = await loadCollectionsFromDirectory(bundle.collectionsDir);
12053
- if (collections.length === 0) {
13344
+ const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
13345
+ if (loaded.length === 0) {
12054
13346
  skip(`no collections were loaded from "${bundle.collectionsDir}".`, "warn");
12055
13347
  return;
12056
13348
  }
12057
- const { applied } = await primary.bootstrapper.ensureCollectionSchema(collections, preInitDriverResult(primary), (message) => logger.info(`schema: ${message}`));
12058
- logger.info(applied > 0 ? `Applied ${applied} additive schema change(s) before boot.` : "Collection schema is up to date.");
13349
+ const collections = collectionsStoredBy(loaded, primary, dataSources);
13350
+ logForeignCollections(loaded, collections, primary);
13351
+ if (collections.length === 0) {
13352
+ skip(`none of the ${loaded.length} declared collection(s) are stored by the "${primary.engine}" data source.`);
13353
+ return;
13354
+ }
13355
+ const { applied } = await primary.bootstrapper.ensureCollectionSchema(collections, preInitDriverResult(primary), (message) => logger.debug(`schema: ${message}`));
13356
+ if (applied > 0) logger.info(`Applied ${applied} additive schema change(s) before boot.`);
13357
+ else logger.debug("Collection schema is up to date.");
12059
13358
  }
12060
13359
  /**
12061
13360
  * The `InitializedDriver` to hand a bootstrapper before any driver exists.
@@ -12100,15 +13399,18 @@ async function ensureCollectionPolicies(bundle, dataSources, env) {
12100
13399
  if (!primary) return;
12101
13400
  if (!primary.bootstrapper.ensureCollectionPolicies) {
12102
13401
  const skew = describeDriverSkew(primary.driverVersion, readRuntimeVersion(bundleResolutionRoots(bundle.dir)));
12103
- logger.warn(`Collection policies: skipped — the "${primary.driverPackage}" driver (engine "${primary.engine}") does not apply RLS policies at boot. ` + (skew.detail ? `${skew.detail} ` : "") + "Collections will deny reads until policies are applied.\n" + schemaRecoveryGuidance({ staleDriver: skew.stale }));
13402
+ logger.warn(`Collection policies: skipped — the "${primary.driverPackage}" driver (engine "${primary.engine}") does not apply RLS policies at boot. ` + (skew.detail ? `${skew.detail} ` : "") + "Collections are not row-secured until policies are applied — depending on the driver's grants that means reads are denied or that they are unfiltered. Do not serve traffic until this is resolved.\n" + schemaRecoveryGuidance({ staleDriver: skew.stale }));
12104
13403
  return;
12105
13404
  }
12106
- const collections = await loadCollectionsFromDirectory(bundle.collectionsDir);
13405
+ const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
13406
+ if (loaded.length === 0) return;
13407
+ const collections = collectionsStoredBy(loaded, primary, dataSources);
12107
13408
  if (collections.length === 0) return;
12108
- const { applied } = await primary.bootstrapper.ensureCollectionPolicies(collections, preInitDriverResult(primary), (message) => logger.info(`policies: ${message}`));
12109
- logger.info(applied > 0 ? `Applied ${applied} RLS policy statement(s) before serving.` : "RLS policies are up to date.");
13409
+ const { applied } = await primary.bootstrapper.ensureCollectionPolicies(collections, preInitDriverResult(primary), (message) => logger.debug(`policies: ${message}`));
13410
+ if (applied > 0) logger.info(`Applied ${applied} RLS policy statement(s) before serving.`);
13411
+ else logger.debug("RLS policies are up to date.");
12110
13412
  }
12111
13413
  //#endregion
12112
- export { ApiError, BundleError, CronScheduler, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, GCSStorageController, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, S3StorageController, SMTPEmailService, TransformCache, TusHandler, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, applyCollectionDefaults, assertCollectionConfigs, assertDistinctSuffixes, authJwt, authRoles, authUid, bootFromBundle, classifySurface, cleanupDevPortFile, createAppleProvider, createBackupRoutes, createBitbucketProvider, createBuiltinAuthAdapter, createContractRoutes, createCronRoutes, createCronStore, createCustomAuthAdapter, createDiscordProvider, createEmailService, createFacebookProvider, createFunctionRoutes, createGitHubProvider, createGitLabProvider, createGoogleProvider, createHistoryRoutes, createLinkedinProvider, createMetricsMiddleware, createMetricsRoutes, createMicrosoftProvider, createSlackProvider, createSourceBundle, createSpotifyProvider, createStorageController, createStorageRoutes, createTwitterProvider, defineCron, defineFunction, envSuffixForKey, errorHandler, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getEmailVerificationTemplate, getMagicLinkTemplate, getPasswordResetTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, hashPassword, httpMethodToOperation, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, isApiKeyToken, isAuthAdapter, isDatabaseAdapter, isLocalhostOrigin, isOperationAllowed, isRebaseApiError, isTransformableImage, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadDeclaredStorageSources, loadEnv, loadFunctionsFromDirectory, loadUsersCollection, logger, optionalAuth, parseBackupDestination, parseBackupTimestamp, parseTransformOptions, queryTokenAuth, readBackupBytes, readBundleManifest, rebase, requireAdmin, requireAuth, resolveAuthHooks, resolveAuthOptions, resolveCorsOrigin, resolveDataSources, resolveEmailOptions, resolveRequireAuth, resolveStorageBackend, resolveStorageSources, runFromBundle, safeCompare, serveSPA, transformImage, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyPassword };
13414
+ export { ApiError, BlockedUrlError, BundleError, CronScheduler, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, GCSStorageController, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, RawHtml, S3StorageController, SMTPEmailService, TransformCache, TusHandler, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, applyCollectionDefaults, assertAllowedOutboundUrl, assertCollectionConfigs, assertDistinctSuffixes, assertEmailLinkBases, authJwt, authRoles, authUid, blockedAddressReason, bootFromBundle, classifySurface, cleanupDevPortFile, createAppleProvider, createBackupRoutes, createBitbucketProvider, createBuiltinAuthAdapter, createContractRoutes, createCronRoutes, createCronStore, createCustomAuthAdapter, createDiscordProvider, createEmailService, createFacebookProvider, createFunctionRoutes, createGitHubProvider, createGitLabProvider, createGoogleProvider, createHistoryRoutes, createLinkedinProvider, createMetricsMiddleware, createMetricsRoutes, createMicrosoftProvider, createSlackProvider, createSourceBundle, createSpotifyProvider, createStorageController, createStorageRoutes, createTwitterProvider, defineCron, defineFunction, envSuffixForKey, errorHandler, escapeHtml, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getEmailVerificationTemplate, getMagicLinkTemplate, getPasswordResetTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, hashPassword, html, httpMethodToOperation, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, isApiKeyToken, isAuthAdapter, isDatabaseAdapter, isLocalhostOrigin, isOperationAllowed, isTransformableImage, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadCronJobsWithDiagnostics, loadDeclaredStorageSources, loadEnv, loadFunctionsFromDirectory, loadUsersCollection, logger, oauthCodeFlowSchema, optionalAuth, parseBackupDestination, parseBackupTimestamp, parseTransformOptions, pkceTokenParams, providerVerifiedEmail, queryTokenAuth, raw, readBackupBytes, readBundleManifest, rebase, requireAdmin, requireAuth, resolveAuthHooks, resolveAuthOptions, resolveCorsOrigin, resolveDataSources, resolveEmailLinkBase, resolveEmailOptions, resolveRequireAuth, resolveStorageBackend, resolveStorageSources, runFromBundle, safeCompare, serveSPA, transformImage, tryVerifyOidcIdToken, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyOidcIdToken, verifyPassword };
12113
13415
 
12114
13416
  //# sourceMappingURL=index.es.js.map