@rebasepro/server 0.13.1-canary.gef9608c → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/dist/{admin_block-EmIHae6X.js → admin_block-H86dCPsj.js} +61 -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 -0
  5. package/dist/api/rest/api-generator.d.ts +26 -1
  6. package/dist/api/rest/idempotency.d.ts +25 -4
  7. package/dist/api/rest/query-parser.d.ts +15 -1
  8. package/dist/api/rest/write-validation.d.ts +40 -4
  9. package/dist/auth/adapter-middleware.d.ts +3 -1
  10. package/dist/auth/admin-roles.d.ts +24 -0
  11. package/dist/auth/apple-oauth.d.ts +16 -11
  12. package/dist/auth/bitbucket-oauth.d.ts +2 -4
  13. package/dist/auth/builtin-auth-adapter.d.ts +11 -0
  14. package/dist/auth/discord-oauth.d.ts +5 -6
  15. package/dist/auth/facebook-oauth.d.ts +2 -4
  16. package/dist/auth/github-oauth.d.ts +2 -4
  17. package/dist/auth/gitlab-oauth.d.ts +6 -4
  18. package/dist/auth/google-oauth.d.ts +1 -0
  19. package/dist/auth/index.d.ts +7 -0
  20. package/dist/auth/interfaces.d.ts +81 -1
  21. package/dist/auth/jwt.d.ts +40 -0
  22. package/dist/auth/linkedin-oauth.d.ts +2 -4
  23. package/dist/auth/mfa-crypto.d.ts +54 -3
  24. package/dist/auth/mfa-gate.d.ts +42 -0
  25. package/dist/auth/mfa-routes.d.ts +26 -1
  26. package/dist/auth/mfa.d.ts +16 -0
  27. package/dist/auth/microsoft-oauth.d.ts +19 -4
  28. package/dist/auth/middleware.d.ts +26 -0
  29. package/dist/auth/oauth-code-flow.d.ts +66 -0
  30. package/dist/auth/oauth-signin-policy.d.ts +61 -0
  31. package/dist/auth/oidc-id-token.d.ts +61 -0
  32. package/dist/auth/rate-limiter.d.ts +62 -5
  33. package/dist/auth/registration-policy.d.ts +31 -0
  34. package/dist/auth/rls-scope.d.ts +25 -0
  35. package/dist/auth/routes.d.ts +15 -0
  36. package/dist/auth/slack-oauth.d.ts +2 -4
  37. package/dist/auth/spotify-oauth.d.ts +2 -4
  38. package/dist/auth/token-revocation.d.ts +38 -0
  39. package/dist/auth/twitter-oauth.d.ts +2 -5
  40. package/dist/{auth-DU4ShERo.js → auth-CYoPVf-E.js} +1985 -395
  41. package/dist/auth-CYoPVf-E.js.map +1 -0
  42. package/dist/{backup-BJiY_mM-.js → backup-C6ljYVTp.js} +2 -2
  43. package/dist/{backup-BJiY_mM-.js.map → backup-C6ljYVTp.js.map} +1 -1
  44. package/dist/boot/ddl-bootstrap.d.ts +70 -0
  45. package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-Bet-eCNJ.js} +3 -3
  46. package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-Bet-eCNJ.js.map} +1 -1
  47. package/dist/cron/cron-loader.d.ts +26 -1
  48. package/dist/cron/cron-routes.d.ts +1 -1
  49. package/dist/cron/cron-scheduler.d.ts +13 -3
  50. package/dist/cron/define-cron.d.ts +17 -3
  51. package/dist/cron/index.d.ts +2 -2
  52. package/dist/{cron-loader-B1S2MCSl.js → cron-loader-YhhQeVBM.js} +43 -8
  53. package/dist/cron-loader-YhhQeVBM.js.map +1 -0
  54. package/dist/{cron-routes-D5a9v1HY.js → cron-routes-maM_RlUu.js} +10 -4
  55. package/dist/cron-routes-maM_RlUu.js.map +1 -0
  56. package/dist/{cron-scheduler-BhFZWR0T.js → cron-scheduler-DIpYBmZP.js} +20 -8
  57. package/dist/cron-scheduler-DIpYBmZP.js.map +1 -0
  58. package/dist/{cron-store-Cu4sOseb.js → cron-store-Dvr4Y1sZ.js} +60 -51
  59. package/dist/cron-store-Dvr4Y1sZ.js.map +1 -0
  60. package/dist/ddl-bootstrap-BhXbTnBl.js +183 -0
  61. package/dist/ddl-bootstrap-BhXbTnBl.js.map +1 -0
  62. package/dist/email/html.d.ts +54 -0
  63. package/dist/email/index.d.ts +3 -0
  64. package/dist/email/link-base.d.ts +39 -0
  65. package/dist/email/smtp-email-service.d.ts +7 -1
  66. package/dist/email/templates.d.ts +9 -1
  67. package/dist/email/types.d.ts +11 -1
  68. package/dist/env.d.ts +5 -0
  69. package/dist/{errors-BpudWAVU.js → errors-EBYiaJ2E.js} +14 -2
  70. package/dist/errors-EBYiaJ2E.js.map +1 -0
  71. package/dist/function-loader-DDS1v7YX.js +139 -0
  72. package/dist/function-loader-DDS1v7YX.js.map +1 -0
  73. package/dist/{function-routes-C0cLIy3N.js → function-routes-Btcez1T-.js} +18 -6
  74. package/dist/function-routes-Btcez1T-.js.map +1 -0
  75. package/dist/functions/define-function.d.ts +25 -6
  76. package/dist/functions/function-loader.d.ts +23 -0
  77. package/dist/functions/function-routes.d.ts +7 -1
  78. package/dist/functions/request-timeout.d.ts +34 -0
  79. package/dist/history/history-routes.d.ts +6 -0
  80. package/dist/index.d.ts +3 -2
  81. package/dist/index.es.js +1893 -714
  82. package/dist/index.es.js.map +1 -1
  83. package/dist/init/docs.d.ts +10 -1
  84. package/dist/init/process-safety.d.ts +25 -0
  85. package/dist/init.d.ts +38 -0
  86. package/dist/{jwt-CzeviDcB.js → jwt-_IFqfTOg.js} +57 -9
  87. package/dist/jwt-_IFqfTOg.js.map +1 -0
  88. package/dist/logger-DfvF_8r-.js +190 -0
  89. package/dist/logger-DfvF_8r-.js.map +1 -0
  90. package/dist/{openapi-generator-D5xsfVJY.js → openapi-generator-DPKtUC9X.js} +326 -55
  91. package/dist/openapi-generator-DPKtUC9X.js.map +1 -0
  92. package/dist/request-timeout-RivJsME0.js +65 -0
  93. package/dist/request-timeout-RivJsME0.js.map +1 -0
  94. package/dist/schema-editor-routes-CRcS3ArS.js +437 -0
  95. package/dist/schema-editor-routes-CRcS3ArS.js.map +1 -0
  96. package/dist/serve-spa.d.ts +5 -4
  97. package/dist/services/outbound-url-guard.d.ts +53 -0
  98. package/dist/services/routed-realtime-service.d.ts +10 -2
  99. package/dist/services/webhook-service.d.ts +76 -1
  100. package/dist/singleton.d.ts +25 -8
  101. package/dist/{src-CKOZBpeJ.js → src-C7rkDGxA.js} +619 -70
  102. package/dist/src-C7rkDGxA.js.map +1 -0
  103. package/dist/{src-_qQ3RNCK.js → src-Cz9nMgUR.js} +93 -3
  104. package/dist/src-Cz9nMgUR.js.map +1 -0
  105. package/dist/storage/LocalStorageController.d.ts +12 -1
  106. package/dist/storage/image-transform.d.ts +57 -0
  107. package/dist/storage/keys.d.ts +98 -0
  108. package/dist/storage/routes.d.ts +11 -0
  109. package/dist/storage/tus-handler.d.ts +22 -2
  110. package/dist/utils/logger.d.ts +18 -0
  111. package/dist/utils/sql.d.ts +11 -6
  112. package/package.json +6 -5
  113. package/dist/admin_block-EmIHae6X.js.map +0 -1
  114. package/dist/auth-DU4ShERo.js.map +0 -1
  115. package/dist/backend-CIxN4FVm.js +0 -15
  116. package/dist/backend-CIxN4FVm.js.map +0 -1
  117. package/dist/cron-loader-B1S2MCSl.js.map +0 -1
  118. package/dist/cron-routes-D5a9v1HY.js.map +0 -1
  119. package/dist/cron-scheduler-BhFZWR0T.js.map +0 -1
  120. package/dist/cron-store-Cu4sOseb.js.map +0 -1
  121. package/dist/errors-BpudWAVU.js.map +0 -1
  122. package/dist/function-loader-B_1fYfUY.js +0 -86
  123. package/dist/function-loader-B_1fYfUY.js.map +0 -1
  124. package/dist/function-routes-C0cLIy3N.js.map +0 -1
  125. package/dist/jwt-CzeviDcB.js.map +0 -1
  126. package/dist/logger-BYU66ENZ.js +0 -94
  127. package/dist/logger-BYU66ENZ.js.map +0 -1
  128. package/dist/openapi-generator-D5xsfVJY.js.map +0 -1
  129. package/dist/schema-editor-routes-C1DxDnDR.js +0 -248
  130. package/dist/schema-editor-routes-C1DxDnDR.js.map +0 -1
  131. package/dist/src-CKOZBpeJ.js.map +0 -1
  132. 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 { C as EntityReference, E as Vector, T as GeoPoint, _ as resolvePrimaryKeys, a as serializeFilter, b as resolveClientListLimit, c as paginateFind, d as createDataSourceRegistry, f as resolveDataSource, g as buildCompositeId, h as resolveCollectionRelations, i as deserializeLogicalCondition, l as resolveFindWindow, m as findRelation, n as buildSdkData, o as serializeLogicalCondition, p as getEffectiveSecurityRules, r as deserializeFilter, s as collectAllPages, t as buildRoutedRebaseData, u as CollectionRegistry, v as toSnakeCase, w as EntityRelation } from "./src-CKOZBpeJ.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-EmIHae6X.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, 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-DU4ShERo.js";
10
- import { t as logger } from "./logger-BYU66ENZ.js";
11
- import { n as errorHandler, t as ApiError } from "./errors-BpudWAVU.js";
12
- import { i as generateDownloadToken, n as configureJwt } from "./jwt-CzeviDcB.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-BhFZWR0T.js";
21
- import { t as createCronRoutes } from "./cron-routes-D5a9v1HY.js";
22
- import { t as createCronStore } from "./cron-store-Cu4sOseb.js";
23
- import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-BJiY_mM-.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,178 +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
- let parsed;
873
- try {
874
- parsed = deserializeLogicalCondition(`${type}(${inner})`);
875
- } catch (e) {
876
- throw ApiError.badRequest(`Invalid \`${type}\` parameter: ${e instanceof Error ? e.message : String(e)}`, "INVALID_LOGICAL_GROUP");
877
- }
878
- return "type" in parsed ? parsed : void 0;
879
- }
880
- /**
881
- * Parse the `?where=` JSON filter object.
882
- *
883
- * This is the dialect the OpenAPI document publishes on every
884
- * `GET /api/data/{slug}` — `{"status":["==","active"]}`: field → canonical
885
- * `[WhereFilterOp, value]` tuple. It is normalized through the same
886
- * `deserializeFilter` as the `?field=op.value` params below, so a value that
887
- * arrives as a PostgREST dot-string (`{"status":"eq.active"}`) or as a bare
888
- * scalar (`{"status":"active"}`) compiles to the same condition. Unlike the
889
- * querystring dialect, JSON carries types — a number stays a number.
890
- *
891
- * A malformed value is a 400 rather than a silent drop: dropping the filter
892
- * would run the read unfiltered and return everything RLS happens to allow.
893
- */
894
- function parseWhereParam(raw) {
895
- const str = String(raw).trim();
896
- if (!str) return void 0;
897
- let parsed;
898
- try {
899
- parsed = JSON.parse(str);
900
- } catch {
901
- throw ApiError.badRequest("Invalid `where` parameter: expected a JSON object, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
902
- }
903
- 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");
904
- const filter = deserializeFilter(parsed);
905
- return Object.keys(filter).length > 0 ? filter : void 0;
906
- }
907
- /**
908
- * Parse query parameters into QueryOptions
909
- */
910
- function parseQueryOptions(query, limits = {}) {
911
- const options = {};
912
- const rawLimit = getLastValue(query.limit);
913
- const offsetVal = getLastValue(query.offset);
914
- if (offsetVal) options.offset = parseInt(String(offsetVal));
915
- const pageVal = getLastValue(query.page);
916
- if (pageVal) {
917
- const page = parseInt(String(pageVal));
918
- const limit = resolveClientListLimit(rawLimit, {
919
- defaultLimit: limits.defaultLimit,
920
- maxLimit: limits.maxLimit
921
- });
922
- options.offset = (page - 1) * limit;
923
- }
924
- const orVal = getLastValue(query.or);
925
- const andVal = getLastValue(query.and);
926
- if (orVal) {
927
- const logical = parseLogicalGroup("or", orVal);
928
- if (logical) options.logical = logical;
929
- } else if (andVal) {
930
- const logical = parseLogicalGroup("and", andVal);
931
- if (logical) options.logical = logical;
932
- }
933
- const reservedQueryKeys = [
934
- "limit",
935
- "offset",
936
- "page",
937
- "orderBy",
938
- "include",
939
- "fields",
940
- "searchString",
941
- "vector_search",
942
- "vector",
943
- "vector_distance",
944
- "vector_threshold",
945
- "or",
946
- "and",
947
- "where"
948
- ];
949
- const filterDict = {};
950
- for (const [key, rawValue] of Object.entries(query)) {
951
- if (reservedQueryKeys.includes(key)) continue;
952
- filterDict[key] = rawValue;
953
- }
954
- const whereVal = getLastValue(query.where);
955
- const where = {
956
- ...whereVal !== void 0 && whereVal !== null ? parseWhereParam(whereVal) : void 0,
957
- ...deserializeFilter(filterDict)
958
- };
959
- if (Object.keys(where).length > 0) options.where = where;
960
- const orderByVal = getLastValue(query.orderBy);
961
- if (orderByVal) try {
962
- options.orderBy = typeof orderByVal === "string" ? JSON.parse(orderByVal) : orderByVal;
963
- } catch {
964
- if (typeof orderByVal === "string") {
965
- const parsed = deserializeOrderBy(orderByVal);
966
- if (parsed) options.orderBy = [{
967
- field: parsed[0],
968
- direction: parsed[1]
969
- }];
970
- }
971
- }
972
- const includeVal = getLastValue(query.include);
973
- if (includeVal) {
974
- const includeStr = String(includeVal).trim();
975
- if (includeStr === "*") options.include = ["*"];
976
- else options.include = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
977
- }
978
- const fieldsVal = getLastValue(query.fields);
979
- if (fieldsVal) options.fields = String(fieldsVal).trim().split(",").map((s) => s.trim()).filter(Boolean);
980
- const vectorSearchVal = getLastValue(query.vector_search);
981
- const vectorVal = getLastValue(query.vector);
982
- if (vectorSearchVal && vectorVal) {
983
- const vectorStr = String(vectorVal);
984
- let decoded;
985
- try {
986
- decoded = JSON.parse(vectorStr);
987
- } catch {
988
- decoded = void 0;
989
- }
990
- 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");
991
- const queryVector = decoded;
992
- const distanceParamVal = getLastValue(query.vector_distance);
993
- const distanceParam = distanceParamVal ? String(distanceParamVal) : "cosine";
994
- if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") throw ApiError.badRequest(`Invalid \`vector_distance\`: ${distanceParam}. Expected: cosine, l2, or inner_product`, "INVALID_VECTOR_DISTANCE");
995
- const vectorSearch = {
996
- property: String(vectorSearchVal),
997
- vector: queryVector,
998
- distance: distanceParam
999
- };
1000
- const thresholdVal = getLastValue(query.vector_threshold);
1001
- if (thresholdVal) {
1002
- const threshold = parseFloat(String(thresholdVal));
1003
- if (isNaN(threshold)) throw ApiError.badRequest("Invalid `vector_threshold`. Expected a number.", "INVALID_VECTOR_THRESHOLD");
1004
- vectorSearch.threshold = threshold;
1005
- }
1006
- options.vectorSearch = vectorSearch;
1007
- }
1008
- options.limit = resolveClientListLimit(rawLimit, {
1009
- vectorSearch: !!options.vectorSearch,
1010
- defaultLimit: limits.defaultLimit,
1011
- maxLimit: limits.maxLimit
1012
- });
1013
- return options;
1014
- }
1015
- //#endregion
1016
855
  //#region src/api/rest/write-validation.ts
1017
856
  /**
1018
857
  * Reject a write naming a field the collection does not have.
1019
858
  *
1020
- * Unknown keys used to travel all the way into the INSERT, where Postgres
1021
- * rejected them — so a typo came back as `column "titel" does not exist`,
1022
- * phrased by the database, from a stack the caller cannot see, and only if the
1023
- * 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.
1024
871
  *
1025
872
  * What counts as known:
1026
873
  * - a declared property (for an introspected BaaS collection these *are* the
@@ -1035,7 +882,7 @@ function assertKnownWriteFields(values, collection, options) {
1035
882
  if (collection.strictWrites === false) return;
1036
883
  if (!collection.properties || Object.keys(collection.properties).length === 0) return;
1037
884
  const known = new Set(Object.keys(collection.properties));
1038
- 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));
1039
886
  for (const field of options?.extraKnownFields ?? []) known.add(field);
1040
887
  const unknown = Object.keys(values).filter((key) => !known.has(key));
1041
888
  if (unknown.length === 0) return;
@@ -1048,6 +895,145 @@ function assertKnownWriteFields(values, collection, options) {
1048
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");
1049
896
  }
1050
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
+ /**
1051
1037
  * Narrow response rows to the fields the caller asked for.
1052
1038
  *
1053
1039
  * `?fields=id,title` is documented in the generated OpenAPI — "Comma-separated
@@ -1078,7 +1064,7 @@ function projectResponseFields(rows, fields, collection, options) {
1078
1064
  const declared = new Set(Object.keys(collection.properties ?? {}));
1079
1065
  for (const [key, relation] of Object.entries(resolveCollectionRelations(collection))) {
1080
1066
  declared.add(key);
1081
- if (relation.kind === "belongsTo") declared.add(relation.localKey);
1067
+ if (relation.kind === "belongsTo") declared.add(fieldKeyForColumn(collection, relation.localKey));
1082
1068
  }
1083
1069
  for (const included of options?.include ?? []) declared.add(included);
1084
1070
  declared.add("id");
@@ -1115,6 +1101,9 @@ function projectResponseFields(rows, fields, collection, options) {
1115
1101
  * generated on the client, so keying on the id alone would let anyone who
1116
1102
  * learned (or guessed) another user's id replay their key and be handed that
1117
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.
1118
1107
  */
1119
1108
  var TABLE = "\"rebase\".\"idempotency_keys\"";
1120
1109
  /**
@@ -1125,15 +1114,75 @@ var TABLE = "\"rebase\".\"idempotency_keys\"";
1125
1114
  */
1126
1115
  var TTL_HOURS = 24;
1127
1116
  /**
1128
- * 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.
1126
+ *
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.
1129
1136
  *
1130
- * The NUL is written as an escape, not as a raw byte in the source. The
1131
- * sentinel itself is deliberate — a uid can never contain one — but written
1132
- * literally it makes this file test as binary, and every repo-wide grep then
1133
- * skips all 124 lines of it silently. Identical at runtime.
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.
1134
1155
  */
1135
1156
  function principal(uid) {
1136
- 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");
1137
1186
  }
1138
1187
  /**
1139
1188
  * Returns `undefined` when the driver cannot run SQL, which disables the whole
@@ -1150,9 +1199,19 @@ function createIdempotencyStore(driver) {
1150
1199
  if (!isSQLAdmin(admin)) return void 0;
1151
1200
  const exec = (sql, params) => admin.executeSql(sql, params ? { params } : void 0);
1152
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
+ };
1153
1210
  /** Created on first use: most deployments never send a key at all. */
1154
1211
  const ensure = () => {
1155
- ready ??= (async () => {
1212
+ if (ready) return ready;
1213
+ if (Date.now() < retryEnsureAt) return Promise.resolve(false);
1214
+ ready = (async () => {
1156
1215
  try {
1157
1216
  await exec("CREATE SCHEMA IF NOT EXISTS rebase");
1158
1217
  await exec(`
@@ -1164,28 +1223,44 @@ function createIdempotencyStore(driver) {
1164
1223
  PRIMARY KEY (uid, key)
1165
1224
  )
1166
1225
  `);
1226
+ await exec(`ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS fingerprint TEXT`);
1167
1227
  await exec(`CREATE INDEX IF NOT EXISTS idx_idempotency_created ON ${TABLE}(created_at)`);
1228
+ await exec(revokeInternalTableSql("rebase", "idempotency_keys"));
1168
1229
  return true;
1169
1230
  } catch (error) {
1231
+ retryEnsureAt = Date.now() + ENSURE_RETRY_COOLDOWN_MS;
1170
1232
  logger.warn("Idempotency keys unavailable — a replayed offline write may insert a duplicate row.", { detail: error instanceof Error ? error.message : String(error) });
1171
1233
  return false;
1172
1234
  }
1173
- })();
1235
+ })().then((ok) => {
1236
+ if (!ok) ready = void 0;
1237
+ return ok;
1238
+ });
1174
1239
  return ready;
1175
1240
  };
1176
1241
  return {
1177
- async claim(key, uid) {
1178
- if (!key || !await ensure()) return { status: "claimed" };
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" };
1179
1246
  try {
1180
- if ((await exec(`INSERT INTO ${TABLE} (key, uid, response, created_at)
1181
- VALUES ($1, $2, NULL, NOW())
1247
+ if ((await exec(`INSERT INTO ${TABLE} (key, uid, fingerprint, response, created_at)
1248
+ VALUES ($1, $2, $3, NULL, NOW())
1182
1249
  ON CONFLICT (uid, key) DO UPDATE
1183
- SET response = NULL, created_at = NOW()
1184
- WHERE ${TABLE}.created_at < NOW() - INTERVAL '${TTL_HOURS} hours'
1185
- RETURNING 1 AS claimed`, [key, principal(uid)])).length > 0) return { status: "claimed" };
1186
- const row = (await exec(`SELECT response, response IS NULL AS pending FROM ${TABLE}
1187
- WHERE uid = $1 AND key = $2`, [principal(uid), key]))[0];
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];
1188
1262
  if (!row) return { status: "claimed" };
1263
+ if (row.fingerprint != null && row.fingerprint !== fingerprint) return { status: "mismatch" };
1189
1264
  if (row.pending) return { status: "in-flight" };
1190
1265
  return {
1191
1266
  status: "replay",
@@ -1196,10 +1271,11 @@ function createIdempotencyStore(driver) {
1196
1271
  }
1197
1272
  },
1198
1273
  async complete(key, uid, response) {
1199
- if (!key || !await ensure()) return;
1274
+ const owner = principal(uid);
1275
+ if (!key || !owner || !await ensure()) return;
1200
1276
  try {
1201
1277
  await exec(`UPDATE ${TABLE} SET response = $3::jsonb WHERE uid = $1 AND key = $2`, [
1202
- principal(uid),
1278
+ owner,
1203
1279
  key,
1204
1280
  JSON.stringify(response ?? null)
1205
1281
  ]);
@@ -1207,9 +1283,10 @@ function createIdempotencyStore(driver) {
1207
1283
  } catch {}
1208
1284
  },
1209
1285
  async release(key, uid) {
1210
- if (!key || !await ensure()) return;
1286
+ const owner = principal(uid);
1287
+ if (!key || !owner || !await ensure()) return;
1211
1288
  try {
1212
- await exec(`DELETE FROM ${TABLE} WHERE uid = $1 AND key = $2 AND response IS NULL`, [principal(uid), key]);
1289
+ await exec(`DELETE FROM ${TABLE} WHERE uid = $1 AND key = $2 AND response IS NULL`, [owner, key]);
1213
1290
  } catch {}
1214
1291
  }
1215
1292
  };
@@ -1234,6 +1311,18 @@ async function parseJsonBody(c) {
1234
1311
  }
1235
1312
  }
1236
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
+ /**
1237
1326
  * Lightweight REST API generator that leverages existing Rebase DataDriver.
1238
1327
  * Supports `include` query parameter for eager-loading relations via Drizzle.
1239
1328
  */
@@ -1282,17 +1371,52 @@ var RestApiGenerator = class {
1282
1371
  this.createCollectionRoutes(collection);
1283
1372
  });
1284
1373
  this.createSubcollectionRoutes();
1374
+ this.createUnmatchedRoute();
1285
1375
  return this.router;
1286
1376
  }
1287
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
+ /**
1288
1412
  * Check API key permissions for a collection operation.
1289
1413
  * Throws 403 if the key doesn't have the required permission.
1290
1414
  * No-ops if the request is not authenticated via an API key.
1291
1415
  */
1292
- enforceApiKeyPermission(c, collectionSlug) {
1416
+ enforceApiKeyPermission(c, collectionSlug, operationOverride) {
1293
1417
  const apiKey = c.get("apiKey");
1294
1418
  if (!apiKey) return;
1295
- const operation = httpMethodToOperation(c.req.method);
1419
+ const operation = operationOverride ?? httpMethodToOperation(c.req.method);
1296
1420
  if (!isOperationAllowed(apiKey.permissions, collectionSlug, operation)) throw ApiError.forbidden(`API key does not have "${operation}" permission for collection "${collectionSlug}"`, "API_KEY_FORBIDDEN");
1297
1421
  }
1298
1422
  /**
@@ -1313,7 +1437,7 @@ var RestApiGenerator = class {
1313
1437
  * Needed so a nested write can be checked against a schema at all. Without
1314
1438
  * it these routes skipped `assertKnownWriteFields` entirely, which is why a
1315
1439
  * typo `POST /posts` rejected with a 400 while the same typo on
1316
- * `POST /authors/1/posts` reached the database.
1440
+ * `POST /authors/1/posts` was dropped from the statement and answered 201.
1317
1441
  *
1318
1442
  * Returns `undefined` rather than throwing when the path cannot be walked:
1319
1443
  * the driver raises the authoritative error a moment later, and duplicating
@@ -1363,6 +1487,7 @@ var RestApiGenerator = class {
1363
1487
  const queryDict = c.req.queries();
1364
1488
  const queryOptions = this.parseQuery(queryDict);
1365
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";
1366
1491
  const driver = this.getScopedDriver(c);
1367
1492
  const fetchService = driver.restFetchService;
1368
1493
  const entities = fetchService ? await fetchService.fetchCollectionForRest(collection.slug, {
@@ -1373,6 +1498,7 @@ var RestApiGenerator = class {
1373
1498
  orderBy: queryOptions.orderBy?.[0]?.field,
1374
1499
  order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
1375
1500
  searchString,
1501
+ searchExplain,
1376
1502
  vectorSearch: queryOptions.vectorSearch
1377
1503
  }, queryOptions.include) : await this.fetchRawCollection(driver, resolvedCollection, queryOptions, searchString);
1378
1504
  const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
@@ -1397,32 +1523,132 @@ var RestApiGenerator = class {
1397
1523
  if (!entity) throw ApiError.notFound("Entity not found");
1398
1524
  return c.json(projectResponseFields([entity], queryOptions.fields, resolvedCollection, { include: queryOptions.include })[0]);
1399
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
+ };
1400
1571
  this.router.post(`${basePath}/bulk`, async (c) => {
1401
1572
  this.enforceApiKeyPermission(c, collection.slug);
1402
1573
  const driver = this.getScopedDriver(c);
1403
1574
  const path = collection.slug;
1404
1575
  const body = await parseJsonBody(c);
1405
- if (!Array.isArray(body?.rows)) throw ApiError.badRequest("Expected a JSON body of { rows: [...] }.", "INVALID_BULK_BODY");
1406
- 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({
1407
1579
  data: [],
1408
1580
  meta: { written: 0 }
1409
1581
  });
1410
- 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");
1411
1582
  if (body.upsert !== void 0 && typeof body.upsert !== "boolean") throw ApiError.badRequest("`upsert` must be a boolean.", "INVALID_BULK_BODY");
1412
- const maxRows = this.maxBulkRows;
1413
- 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");
1414
1583
  if (!driver.saveMany) throw ApiError.badRequest("This collection's data source does not support bulk writes.", "BULK_UNSUPPORTED");
1415
- body.rows.forEach((row, rowIndex) => assertKnownWriteFields(row, resolvedCollection, { rowIndex }));
1416
- const rows = await driver.saveMany({
1417
- path,
1418
- rows: body.rows,
1419
- collection: resolvedCollection,
1420
- upsert: body.upsert === true
1584
+ rows.forEach((row, rowIndex) => {
1585
+ assertKnownWriteFields(row, resolvedCollection, { rowIndex });
1586
+ assertWriteValuesValid(row, resolvedCollection, { rowIndex });
1421
1587
  });
1422
- return c.json({
1423
- data: rows.map((row) => this.formatResponse(row)),
1424
- 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 });
1425
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));
1426
1652
  });
1427
1653
  this.router.post(basePath, async (c) => {
1428
1654
  this.enforceApiKeyPermission(c, collection.slug);
@@ -1432,10 +1658,15 @@ var RestApiGenerator = class {
1432
1658
  const isAuth = collection.auth;
1433
1659
  const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
1434
1660
  const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
1435
- if (!isAuthCollection) assertKnownWriteFields(body, resolvedCollection);
1436
- else {
1661
+ if (!isAuthCollection) {
1662
+ assertKnownWriteFields(body, resolvedCollection);
1663
+ assertWriteValuesValid(body, resolvedCollection);
1664
+ } else {
1437
1665
  const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
1438
- if (contract?.validate) assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
1666
+ if (contract?.validate) {
1667
+ assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
1668
+ assertWriteValuesValid(body, resolvedCollection);
1669
+ }
1439
1670
  }
1440
1671
  if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
1441
1672
  const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
@@ -1463,8 +1694,9 @@ var RestApiGenerator = class {
1463
1694
  const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1464
1695
  const uid = c.get("user")?.uid;
1465
1696
  const store = this.idempotency();
1466
- const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid) : void 0;
1697
+ const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid, requestFingerprint(c.req.method, c.req.path, body)) : void 0;
1467
1698
  if (claimed?.status === "replay") return c.json(claimed.response, 201);
1699
+ if (claimed?.status === "mismatch") throw idempotencyKeyReused(idempotencyKey);
1468
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");
1469
1701
  let response;
1470
1702
  try {
@@ -1482,7 +1714,7 @@ var RestApiGenerator = class {
1482
1714
  if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
1483
1715
  return c.json(response, 201);
1484
1716
  });
1485
- this.router.put(`${basePath}/:id`, async (c) => {
1717
+ const updateEntity = async (c) => {
1486
1718
  this.enforceApiKeyPermission(c, collection.slug);
1487
1719
  const id = c.req.param("id");
1488
1720
  const driver = this.getScopedDriver(c);
@@ -1493,6 +1725,7 @@ var RestApiGenerator = class {
1493
1725
  })) throw ApiError.notFound("Entity not found");
1494
1726
  const body = await parseJsonBody(c);
1495
1727
  assertKnownWriteFields(body, resolvedCollection);
1728
+ assertWriteValuesValid(body, resolvedCollection);
1496
1729
  const entity = await driver.save({
1497
1730
  path: getCollectionDataPath(collection),
1498
1731
  id: String(id),
@@ -1502,7 +1735,29 @@ var RestApiGenerator = class {
1502
1735
  });
1503
1736
  const response = this.formatResponse(entity);
1504
1737
  return c.json(response);
1505
- });
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);
1506
1761
  this.router.delete(`${basePath}/:id`, async (c) => {
1507
1762
  this.enforceApiKeyPermission(c, collection.slug);
1508
1763
  const id = c.req.param("id");
@@ -1634,7 +1889,10 @@ var RestApiGenerator = class {
1634
1889
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
1635
1890
  const body = await parseJsonBody(c);
1636
1891
  const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1637
- if (targetCollection) assertKnownWriteFields(body, targetCollection);
1892
+ if (targetCollection) {
1893
+ assertKnownWriteFields(body, targetCollection);
1894
+ assertWriteValuesValid(body, targetCollection);
1895
+ }
1638
1896
  const entity = await driver.save({
1639
1897
  path: parsed.collectionPath,
1640
1898
  values: body,
@@ -1643,7 +1901,7 @@ var RestApiGenerator = class {
1643
1901
  const response = this.formatResponse(entity);
1644
1902
  return c.json(response, 201);
1645
1903
  });
1646
- this.router.put("/:parent/:parentId/:rest{.+}", async (c, next) => {
1904
+ const updateNested = async (c, next) => {
1647
1905
  const rest = c.req.param("rest");
1648
1906
  if (!rest || rest === "undefined") return next();
1649
1907
  const rawPath = `${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`;
@@ -1653,7 +1911,10 @@ var RestApiGenerator = class {
1653
1911
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
1654
1912
  const body = await parseJsonBody(c);
1655
1913
  const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1656
- if (targetCollection) assertKnownWriteFields(body, targetCollection);
1914
+ if (targetCollection) {
1915
+ assertKnownWriteFields(body, targetCollection);
1916
+ assertWriteValuesValid(body, targetCollection);
1917
+ }
1657
1918
  const entity = await driver.save({
1658
1919
  path: parsed.collectionPath,
1659
1920
  id: parsed.id,
@@ -1662,7 +1923,9 @@ var RestApiGenerator = class {
1662
1923
  });
1663
1924
  const response = this.formatResponse(entity);
1664
1925
  return c.json(response);
1665
- });
1926
+ };
1927
+ this.router.patch("/:parent/:parentId/:rest{.+}", updateNested);
1928
+ this.router.put("/:parent/:parentId/:rest{.+}", updateNested);
1666
1929
  this.router.delete("/:parent/:parentId/:rest{.+}", async (c, next) => {
1667
1930
  const rest = c.req.param("rest");
1668
1931
  if (!rest || rest === "undefined") return next();
@@ -1871,7 +2134,7 @@ function configureMiddlewares(app, basePath, isProduction, config) {
1871
2134
  app.use(`${basePath}/*`, requestId());
1872
2135
  if (config.compression !== false) {
1873
2136
  app.use(`${basePath}/*`, responseCompression());
1874
- logger.info("Response compression enabled");
2137
+ logger.debug("Response compression enabled");
1875
2138
  }
1876
2139
  const maxBodySize = config.maxBodySize ?? 10 * 1024 * 1024;
1877
2140
  if (maxBodySize > 0) {
@@ -1884,11 +2147,11 @@ function configureMiddlewares(app, basePath, isProduction, config) {
1884
2147
  } }, 413);
1885
2148
  }
1886
2149
  }));
1887
- 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) });
1888
2151
  }
1889
2152
  if (config.csrf?.origin) {
1890
2153
  app.use(`${basePath}/*`, csrf({ origin: config.csrf.origin }));
1891
- logger.info("CSRF protection enabled");
2154
+ logger.debug("CSRF protection enabled");
1892
2155
  }
1893
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.");
1894
2157
  app.use(`${basePath}/*`, requestLogger());
@@ -1942,7 +2205,7 @@ var LocalStorageController = class {
1942
2205
  }
1943
2206
  /**
1944
2207
  * Get the full filesystem path for a storage path, with a traversal guard
1945
- * that keeps the result inside the bucket directory.
2208
+ * that keeps the result inside the storage root.
1946
2209
  *
1947
2210
  * Defaults the bucket the way `putObject` does.
1948
2211
  *
@@ -1953,10 +2216,22 @@ var LocalStorageController = class {
1953
2216
  * design), and `listObjects` returned an empty page. So the obvious
1954
2217
  * `putObject({ key })` → `getObject(key)` did not round-trip and nothing
1955
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.
1956
2230
  */
1957
2231
  getFullPath(storagePath, bucket) {
1958
- const bucketPath = path$2.join(this.basePath, bucket ?? "default");
1959
- 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);
1960
2235
  if (!resolved.startsWith(bucketPath + path$2.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
1961
2236
  return resolved;
1962
2237
  }
@@ -1972,7 +2247,7 @@ var LocalStorageController = class {
1972
2247
  }
1973
2248
  async putObject({ file, key, metadata, bucket }) {
1974
2249
  this.validateFile(file);
1975
- const usedBucket = bucket ?? "default";
2250
+ const usedBucket = bucket || "default";
1976
2251
  const fullStoragePath = key;
1977
2252
  const fullPath = this.getFullPath(fullStoragePath, usedBucket);
1978
2253
  await this.ensureDir(path$2.dirname(fullPath));
@@ -2202,38 +2477,63 @@ var VALID_FITS = /* @__PURE__ */ new Set([
2202
2477
  "outside"
2203
2478
  ]);
2204
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
+ /**
2205
2507
  * Parse transform options from URL query parameters.
2206
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".
2207
2514
  */
2208
2515
  function parseTransformOptions(query) {
2209
2516
  const opts = {};
2210
2517
  let hasTransform = false;
2211
2518
  if (query.width) {
2212
- const w = parseInt(query.width, 10);
2213
- if (!Number.isNaN(w) && w > 0) {
2214
- opts.width = Math.min(w, MAX_DIMENSION);
2215
- hasTransform = true;
2216
- }
2519
+ opts.width = boundedInt("width", query.width, 1, MAX_DIMENSION);
2520
+ hasTransform = true;
2217
2521
  }
2218
2522
  if (query.height) {
2219
- const h = parseInt(query.height, 10);
2220
- if (!Number.isNaN(h) && h > 0) {
2221
- opts.height = Math.min(h, MAX_DIMENSION);
2222
- hasTransform = true;
2223
- }
2523
+ opts.height = boundedInt("height", query.height, 1, MAX_DIMENSION);
2524
+ hasTransform = true;
2224
2525
  }
2225
2526
  if (query.quality) {
2226
- const q = parseInt(query.quality, 10);
2227
- if (!Number.isNaN(q)) {
2228
- opts.quality = Math.min(Math.max(q, MIN_QUALITY), MAX_QUALITY);
2229
- hasTransform = true;
2230
- }
2527
+ opts.quality = boundedInt("quality", query.quality, MIN_QUALITY, MAX_QUALITY);
2528
+ hasTransform = true;
2231
2529
  }
2232
- 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(", ")}.`);
2233
2532
  opts.format = query.format;
2234
2533
  hasTransform = true;
2235
2534
  }
2236
- 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(", ")}.`);
2237
2537
  opts.fit = query.fit;
2238
2538
  hasTransform = true;
2239
2539
  }
@@ -2251,9 +2551,72 @@ function isTransformableImage(contentType) {
2251
2551
  return contentType.startsWith("image/") && !contentType.includes("svg") && !contentType.includes("gif");
2252
2552
  }
2253
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
+ /**
2254
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.
2255
2615
  */
2256
2616
  async function transformImage(buffer, options) {
2617
+ return transformQueue.run(() => runTransform(buffer, options));
2618
+ }
2619
+ async function runTransform(buffer, options) {
2257
2620
  let pipeline = (await getSharp())(buffer);
2258
2621
  if (options.width || options.height) pipeline = pipeline.resize({
2259
2622
  width: options.width,
@@ -2380,6 +2743,7 @@ var TusHandler = class {
2380
2743
  this.cleanupTimer = setInterval(() => {
2381
2744
  this.cleanupStale();
2382
2745
  }, 6e4);
2746
+ this.cleanupTimer.unref?.();
2383
2747
  }
2384
2748
  /** Remove uploads that have been idle for longer than UPLOAD_EXPIRY_MS. */
2385
2749
  async cleanupStale() {
@@ -2431,11 +2795,22 @@ var TusHandler = class {
2431
2795
  if (Number.isNaN(uploadLength) || uploadLength <= 0) throw ApiError.badRequest("Invalid Upload-Length");
2432
2796
  if (uploadLength > MAX_UPLOAD_SIZE) throw new ApiError(413, "PAYLOAD_TOO_LARGE", `Upload-Length exceeds maximum of ${MAX_UPLOAD_SIZE} bytes`);
2433
2797
  const metadata = this.parseMetadata(c.req.header("Upload-Metadata") || "");
2434
- if (this.authorizeUpload) {
2435
- const key = metadata.key || metadata.filename || "";
2436
- await this.authorizeUpload(c, key, metadata.bucket || "default");
2437
- }
2438
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);
2439
2814
  const filePath = join(this.tusDir, id);
2440
2815
  await writeFile(filePath, Buffer.alloc(0));
2441
2816
  const upload = {
@@ -2445,8 +2820,9 @@ var TusHandler = class {
2445
2820
  metadata,
2446
2821
  createdAt: Date.now(),
2447
2822
  filePath,
2448
- bucket: metadata.bucket || void 0,
2449
- key: metadata.key || metadata.filename || void 0,
2823
+ bucket,
2824
+ storageId,
2825
+ key,
2450
2826
  completed: false
2451
2827
  };
2452
2828
  this.uploads.set(id, upload);
@@ -2517,21 +2893,37 @@ var TusHandler = class {
2517
2893
  });
2518
2894
  }
2519
2895
  /**
2520
- * 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.
2521
2911
  */
2522
2912
  async finalize(upload) {
2523
- upload.completed = true;
2524
- const storageId = upload.metadata.storageId;
2913
+ const storageId = upload.storageId;
2525
2914
  let targetController = this.storageController;
2526
2915
  if (this.storageRegistry) targetController = storageId ? this.storageRegistry.getOrDefault(storageId) : this.storageRegistry.getDefault();
2527
2916
  if (!targetController) {
2528
- logger.warn("[TUS] Upload completed but no StorageController configured. Temp file remains:", { filePath: upload.filePath });
2529
- 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.");
2530
2922
  }
2531
2923
  try {
2532
2924
  const { readFile } = await import("fs/promises");
2533
2925
  const data = await readFile(upload.filePath);
2534
- const fileName = upload.key || upload.metadata.filename || upload.id;
2926
+ const fileName = upload.key;
2535
2927
  const mimeType = upload.metadata.contentType || upload.metadata.filetype || "application/octet-stream";
2536
2928
  const file = new File([new Uint8Array(data)], fileName, { type: mimeType });
2537
2929
  await targetController.putObject({
@@ -2539,6 +2931,7 @@ var TusHandler = class {
2539
2931
  key: fileName,
2540
2932
  bucket: upload.bucket
2541
2933
  });
2934
+ upload.completed = true;
2542
2935
  try {
2543
2936
  await unlink(upload.filePath);
2544
2937
  } catch {}
@@ -2546,6 +2939,8 @@ var TusHandler = class {
2546
2939
  logger.info(`[TUS] Upload ${upload.id} finalized → ${fileName}`, storageId ? { storageId } : {});
2547
2940
  } catch (err) {
2548
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)));
2549
2944
  }
2550
2945
  }
2551
2946
  };
@@ -2562,6 +2957,82 @@ var TusHandler = class {
2562
2957
  /** Shared image transform cache (LRU, 500 entries, 1 hour TTL). */
2563
2958
  var transformCache = new TransformCache();
2564
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
+ /**
2565
3036
  * Extract the wildcard portion of a route path from the full request path.
2566
3037
  *
2567
3038
  * Hono's `c.req.param('*')` does not work reliably in sub-routers mounted
@@ -2581,16 +3052,48 @@ function extractWildcardPath(c) {
2581
3052
  return fullPath.substring(idx + prefix.length + 1);
2582
3053
  }
2583
3054
  /**
2584
- * Sanitize a user-supplied storage key to prevent path traversal and other attacks.
2585
- * 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.
2586
3063
  */
2587
- function sanitizeStorageKey(key) {
2588
- let sanitized = key;
2589
- sanitized = sanitized.replace(/\0/g, "");
2590
- sanitized = sanitized.replace(/\.\.\/|\.\.\\/g, "");
2591
- sanitized = sanitized.replace(/^\/+/, "");
2592
- sanitized = sanitized.slice(0, 1024);
2593
- 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
+ }
2594
3097
  }
2595
3098
  /**
2596
3099
  * Build adapter-aware auth middleware for storage routes.
@@ -2694,25 +3197,26 @@ function createStorageRoutes(config) {
2694
3197
  /**
2695
3198
  * Parse bucket and path from a combined file path.
2696
3199
  *
2697
- * The resolved path is run through `sanitizeStorageKey` here — the same
2698
- * function the upload route applies to incoming keys — so that read,
2699
- * delete, metadata and folder routes strip `../`, null bytes and leading
2700
- * slashes before the path reaches the controller, the authorize hook, or
2701
- * the download-token it mints. The `LocalStorageController` traversal guard
2702
- * (`getFullPath`) remains the load-bearing defence; this is the same
2703
- * normalization on the write and read sides so a `..%2f` read/delete cannot
2704
- * even reach it as a raw traversal string (it 404s as a normal miss instead
2705
- * 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.
2706
3210
  */
2707
3211
  const parseBucketAndPath = (filePath) => {
2708
3212
  const parts = filePath.split("/");
2709
3213
  if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
2710
3214
  bucket: "default",
2711
- resolvedPath: sanitizeStorageKey(parts.slice(1).join("/"))
3215
+ resolvedPath: canonicalKeyOrBadRequest(parts.slice(1).join("/"))
2712
3216
  };
2713
3217
  return {
2714
3218
  bucket: "default",
2715
- resolvedPath: sanitizeStorageKey(filePath)
3219
+ resolvedPath: canonicalKeyOrBadRequest(filePath)
2716
3220
  };
2717
3221
  };
2718
3222
  /**
@@ -2725,9 +3229,9 @@ function createStorageRoutes(config) {
2725
3229
  const uploadedFile = body["file"];
2726
3230
  if (!uploadedFile || typeof uploadedFile === "string") throw ApiError.badRequest("No file provided");
2727
3231
  const key = typeof body["key"] === "string" ? body["key"] : "";
2728
- const bucket = typeof body["bucket"] === "string" ? body["bucket"] : void 0;
3232
+ const bucket = canonicalBucketOrBadRequest(typeof body["bucket"] === "string" ? body["bucket"] : void 0);
2729
3233
  const storageId = typeof body["storageId"] === "string" ? body["storageId"] : c.req.query("storageId");
2730
- const finalKey = sanitizeStorageKey(key || uploadedFile.name || "unnamed");
3234
+ const finalKey = canonicalKeyOrBadRequest(key || uploadedFile.name || "unnamed");
2731
3235
  const metadata = {};
2732
3236
  for (const [k, value] of Object.entries(body)) if (k.startsWith("metadata_")) metadata[k.replace("metadata_", "")] = value;
2733
3237
  await checkAuthorized(c, "write", finalKey, bucket ?? "default", storageId);
@@ -2753,15 +3257,13 @@ function createStorageRoutes(config) {
2753
3257
  const filePath = decodeURIComponent(rawPath);
2754
3258
  const storageId = c.req.query("storageId");
2755
3259
  const resolved = resolveController(storageId);
2756
- {
2757
- const { bucket, resolvedPath } = parseBucketAndPath(filePath);
2758
- await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
2759
- }
2760
- 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());
2761
3265
  if (resolved.getType() === "local") {
2762
- const localController = resolved;
2763
- const { bucket, resolvedPath } = parseBucketAndPath(filePath);
2764
- const absolutePath = localController.getAbsolutePath(resolvedPath, bucket);
3266
+ const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
2765
3267
  try {
2766
3268
  await fsp.access(absolutePath);
2767
3269
  } catch {
@@ -2773,37 +3275,30 @@ function createStorageRoutes(config) {
2773
3275
  const metadataRaw = await fsp.readFile(metadataPath, "utf-8");
2774
3276
  contentType = JSON.parse(metadataRaw).contentType || contentType;
2775
3277
  } catch {}
2776
- const fileContent = await fsp.readFile(absolutePath);
2777
3278
  if (transformOpts && isTransformableImage(contentType)) {
2778
- const cacheKey = transformCache.buildKey(filePath, transformOpts);
2779
- let cached = transformCache.get(cacheKey);
2780
- if (!cached) {
2781
- cached = await transformImage(Buffer.from(fileContent), transformOpts);
2782
- transformCache.set(cacheKey, cached.data, cached.contentType);
2783
- }
2784
- 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);
2785
3281
  c.header("Cache-Control", "public, max-age=31536000, immutable");
2786
- return c.body(new Uint8Array(cached.data));
3282
+ return c.body(new Uint8Array(transformed.data));
2787
3283
  }
2788
- 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");
2789
3288
  return c.body(new Uint8Array(fileContent));
2790
3289
  }
2791
- const { bucket: parsedBucket, resolvedPath: parsedPath } = parseBucketAndPath(filePath);
2792
- const fileObject = await resolved.getObject(parsedPath, parsedBucket);
3290
+ const fileObject = await resolved.getObject(resolvedPath, bucket);
2793
3291
  if (!fileObject) throw ApiError.notFound("File not found");
2794
3292
  const remoteContentType = fileObject.type || "application/octet-stream";
2795
3293
  if (transformOpts && isTransformableImage(remoteContentType)) {
2796
- const cacheKey = transformCache.buildKey(filePath, transformOpts);
2797
- let cached = transformCache.get(cacheKey);
2798
- if (!cached) {
2799
- cached = await transformImage(Buffer.from(await fileObject.arrayBuffer()), transformOpts);
2800
- transformCache.set(cacheKey, cached.data, cached.contentType);
2801
- }
2802
- 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);
2803
3296
  c.header("Cache-Control", "public, max-age=31536000, immutable");
2804
- return c.body(new Uint8Array(cached.data));
3297
+ return c.body(new Uint8Array(transformed.data));
2805
3298
  }
2806
- 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");
2807
3302
  c.header("Cache-Control", "public, max-age=3600, immutable");
2808
3303
  const buf = await fileObject.arrayBuffer();
2809
3304
  return c.body(new Uint8Array(buf));
@@ -2862,8 +3357,8 @@ function createStorageRoutes(config) {
2862
3357
  * GET /list - List files in a path
2863
3358
  */
2864
3359
  router.get("/list", writeAuthMiddleware, async (c) => {
2865
- const storagePrefix = sanitizeStorageKey(c.req.query("prefix") || c.req.query("path") || "");
2866
- 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"));
2867
3362
  const maxResults = c.req.query("maxResults");
2868
3363
  const pageToken = c.req.query("pageToken");
2869
3364
  const storageId = c.req.query("storageId");
@@ -2909,8 +3404,8 @@ function createStorageRoutes(config) {
2909
3404
  }, 201);
2910
3405
  });
2911
3406
  const defaultCtrl = getDefaultController();
2912
- const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket) => {
2913
- 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);
2914
3409
  } : void 0);
2915
3410
  tusHandler.startCleanup();
2916
3411
  router.options("/tus", (_c) => tusHandler.options());
@@ -3047,7 +3542,7 @@ async function createStorageController(config) {
3047
3542
  //#region src/init/storage.ts
3048
3543
  async function initializeStorage(storageConfig, isProduction) {
3049
3544
  if (!storageConfig) return {};
3050
- logger.info("Configuring storage");
3545
+ logger.debug("Configuring storage");
3051
3546
  const controllers = {};
3052
3547
  const toController = async (entry, label) => {
3053
3548
  if (typeof entry.putObject === "function") return entry;
@@ -3104,11 +3599,20 @@ function assertStorageAccessControlConfigured(state, isProduction) {
3104
3599
  }
3105
3600
  //#endregion
3106
3601
  //#region src/init/docs.ts
3107
- async function mountOpenApiDocs(app, basePath, enableSwagger, activeCollections, requireAuth) {
3108
- if (enableSwagger === false || activeCollections.length === 0) return;
3109
- const { generateOpenApiSpec } = await import("./openapi-generator-D5xsfVJY.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");
3110
3614
  app.get(`${basePath}/docs`, (c) => {
3111
- const spec = generateOpenApiSpec(activeCollections, {
3615
+ const spec = generateOpenApiSpec(serverCollections, {
3112
3616
  basePath,
3113
3617
  requireAuth
3114
3618
  });
@@ -3283,6 +3787,54 @@ function createShutdown(config) {
3283
3787
  };
3284
3788
  }
3285
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
3286
3838
  //#region src/auth/collection-callback-warning.ts
3287
3839
  /** The data callbacks the auth write path does not run. */
3288
3840
  var DATA_CALLBACKS = [
@@ -3403,7 +3955,17 @@ function buildQueryString(params) {
3403
3955
  const wire = serializeOrderBy(params.orderBy);
3404
3956
  if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
3405
3957
  }
3406
- 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
+ }
3407
3969
  if (params.include && params.include.length > 0) parts.push(`include=${encodeURIComponent(params.include.join(","))}`);
3408
3970
  if (params.logical) {
3409
3971
  const root = params.logical;
@@ -3479,6 +4041,23 @@ function createTransport(config, environment) {
3479
4041
  ...init?.headers || {}
3480
4042
  };
3481
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
+ }
3482
4061
  async function request(path, init) {
3483
4062
  const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
3484
4063
  let activeToken = token;
@@ -3496,9 +4075,27 @@ function createTransport(config, environment) {
3496
4075
  if (res.status === 204) return void 0;
3497
4076
  const text = await res.text().catch(() => "");
3498
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;
3499
4094
  if (text) try {
3500
4095
  body = JSON.parse(text, rebaseReviver);
3501
- } catch (e) {}
4096
+ } catch (e) {
4097
+ unreadableBody = true;
4098
+ }
3502
4099
  const getErrorField = (obj, field) => {
3503
4100
  const err = obj?.error;
3504
4101
  if (err && typeof err === "object" && err !== null) return err[field];
@@ -3518,9 +4115,12 @@ function createTransport(config, environment) {
3518
4115
  if (retryRes.status === 204) return void 0;
3519
4116
  const retryText = await retryRes.text().catch(() => "");
3520
4117
  let retryBody = {};
4118
+ let retryUnreadable = false;
3521
4119
  if (retryText) try {
3522
4120
  retryBody = JSON.parse(retryText, rebaseReviver);
3523
- } catch (e) {}
4121
+ } catch (e) {
4122
+ retryUnreadable = true;
4123
+ }
3524
4124
  if (!retryRes.ok) {
3525
4125
  let fallbackMessage = retryRes.statusText;
3526
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.`;
@@ -3530,6 +4130,7 @@ function createTransport(config, environment) {
3530
4130
  details: getErrorField(retryBody, "details")
3531
4131
  });
3532
4132
  }
4133
+ if (retryUnreadable) throw unreadableResponse(retryRes.status, retryText);
3533
4134
  return retryBody;
3534
4135
  }
3535
4136
  }
@@ -3542,6 +4143,7 @@ function createTransport(config, environment) {
3542
4143
  details: getErrorField(body, "details")
3543
4144
  });
3544
4145
  }
4146
+ if (unreadableBody) throw unreadableResponse(res.status, text);
3545
4147
  return body;
3546
4148
  }
3547
4149
  return {
@@ -4505,15 +5107,61 @@ var SDKQueryBuilder = class {
4505
5107
  /**
4506
5108
  * Skip the first N results.
4507
5109
  */
4508
- offset(count) {
4509
- this.params.offset = count;
5110
+ offset(count) {
5111
+ this.params.offset = count;
5112
+ return this;
5113
+ }
5114
+ /**
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
+ * ```
5134
+ */
5135
+ search(searchString, options) {
5136
+ this.params.searchString = searchString;
5137
+ if (options?.explain !== void 0) this.params.searchExplain = options.explain;
4510
5138
  return this;
4511
5139
  }
4512
5140
  /**
4513
- * Set a free-text search string if supported by the backend.
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()
4514
5157
  */
4515
- search(searchString) {
4516
- this.params.searchString = searchString;
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
+ };
4517
5165
  return this;
4518
5166
  }
4519
5167
  /**
@@ -4549,6 +5197,11 @@ var SDKQueryBuilder = class {
4549
5197
  return this.collection.listen(this.params, onUpdate, onError);
4550
5198
  }
4551
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();
4552
5205
  function createCollectionClient(transport, slug, ws) {
4553
5206
  const basePath = `/data/${slug}`;
4554
5207
  const client = {
@@ -4593,18 +5246,63 @@ function createCollectionClient(transport, slug, ws) {
4593
5246
  body: JSON.stringify({
4594
5247
  rows: data,
4595
5248
  ...options?.upsert ? { upsert: true } : {}
4596
- })
5249
+ }),
5250
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
4597
5251
  })).data || [];
4598
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
+ */
4599
5268
  async update(id, data) {
4600
5269
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
4601
5270
  method: "PUT",
4602
5271
  body: JSON.stringify(data)
4603
5272
  });
4604
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
+ },
4605
5283
  async delete(id) {
4606
5284
  await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
4607
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
+ },
4608
5306
  async count(params) {
4609
5307
  const qs = buildQueryString({
4610
5308
  ...params,
@@ -4612,7 +5310,16 @@ function createCollectionClient(transport, slug, ws) {
4612
5310
  offset: void 0,
4613
5311
  include: void 0
4614
5312
  });
4615
- 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
+ }
4616
5323
  },
4617
5324
  observe(params, onResult, onError, options) {
4618
5325
  let closed = false;
@@ -4680,8 +5387,11 @@ function createCollectionClient(transport, slug, ws) {
4680
5387
  offset(count) {
4681
5388
  return new SDKQueryBuilder(client).offset(count);
4682
5389
  },
4683
- search(searchString) {
4684
- 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);
4685
5395
  },
4686
5396
  include(...relations) {
4687
5397
  return new SDKQueryBuilder(client).include(...relations);
@@ -4701,7 +5411,8 @@ function createCollectionClient(transport, slug, ws) {
4701
5411
  offset: window.driverOffset,
4702
5412
  orderBy: params?.orderBy?.[0],
4703
5413
  order: params?.orderBy?.[1],
4704
- searchString: params?.searchString
5414
+ searchString: params?.searchString,
5415
+ searchExplain: params?.searchExplain
4705
5416
  }, (incomingRows) => {
4706
5417
  const currentUpdateId = ++lastUpdateId;
4707
5418
  const requestedLimit = window.limit;
@@ -4983,9 +5694,11 @@ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
4983
5694
  *
4984
5695
  * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
4985
5696
  * manages this internally (exposed as `client.ws`, typed by the minimal
4986
- * `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
4987
- * package root only because the `@rebasepro/client-postgres` driver
4988
- * 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.
4989
5702
  */
4990
5703
  var RebaseWebSocketClient = class {
4991
5704
  websocketUrl;
@@ -5495,6 +6208,11 @@ var RebaseWebSocketClient = class {
5495
6208
  callback.onError(new RebaseApiError(errorMessage, { code: errorCode }));
5496
6209
  }
5497
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}`);
5498
6216
  }
5499
6217
  }
5500
6218
  async ensureAuthenticated(retryCount = 3) {
@@ -6601,11 +7319,29 @@ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
6601
7319
  503,
6602
7320
  504
6603
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
+ }
6604
7339
  /** Is this failure worth another attempt later? */
6605
7340
  function isRetryableError(error) {
6606
7341
  if (isNetworkError(error)) return true;
6607
- if (error instanceof RebaseApiError) return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
6608
- 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);
6609
7345
  }
6610
7346
  /**
6611
7347
  * Did this write fail because the row is already there?
@@ -6617,10 +7353,16 @@ function isRetryableError(error) {
6617
7353
  * The queue uses this to recognise its own earlier attempt. A create whose
6618
7354
  * response was lost is replayed, and for a row carrying an id the SDK generated
6619
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.
6620
7361
  */
6621
7362
  function isDuplicateKeyError(error) {
6622
7363
  if (!(error instanceof RebaseApiError)) return false;
6623
- return error.code === "23505" || error.status === 409;
7364
+ if (error.code === "23505") return true;
7365
+ return error.status === 409 && !isIdempotencyInProgressError(error);
6624
7366
  }
6625
7367
  var ConnectivityMonitor = class {
6626
7368
  state = "online";
@@ -7190,6 +7932,25 @@ function resolvePagination(params) {
7190
7932
  offset
7191
7933
  };
7192
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
+ }
7193
7954
  /**
7194
7955
  * Can a locally evaluated answer to `params` be trusted to match the server's,
7195
7956
  * assuming the cache holds every row of the collection?
@@ -7197,11 +7958,67 @@ function resolvePagination(params) {
7197
7958
  * `include` pulls in rows from other collections that this evaluator never
7198
7959
  * sees, and `searchString` is only approximated — both make the local answer a
7199
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}.
7200
7983
  */
7201
7984
  function isExactlyEvaluable(params) {
7202
7985
  if (!params) return true;
7203
7986
  if (params.include && params.include.length > 0) return false;
7204
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
+ }
7205
8022
  return true;
7206
8023
  }
7207
8024
  /** Run a full query — filter, sort, paginate — over a set of rows. */
@@ -7231,6 +8048,17 @@ function generateOfflineId() {
7231
8048
  return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
7232
8049
  }
7233
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;
7234
8062
  var OfflineManager = class {
7235
8063
  store;
7236
8064
  maxCachedQueries;
@@ -7511,6 +8339,72 @@ var OfflineManager = class {
7511
8339
  this.notifyCollection(slug);
7512
8340
  return rows;
7513
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
+ },
7514
8408
  update: async (id, data) => {
7515
8409
  await this.ensureCollection(slug);
7516
8410
  if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
@@ -7589,7 +8483,8 @@ var OfflineManager = class {
7589
8483
  orderBy: (column, direction) => new SDKQueryBuilder(wrapped).orderBy(column, direction),
7590
8484
  limit: (count) => new SDKQueryBuilder(wrapped).limit(count),
7591
8485
  offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
7592
- 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),
7593
8488
  include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations)
7594
8489
  };
7595
8490
  if (inner.listen) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
@@ -7846,16 +8741,15 @@ var OfflineManager = class {
7846
8741
  }
7847
8742
  let added = 0;
7848
8743
  const offset = snapshot.offset ?? 0;
7849
- if (exact && offset === 0) {
7850
- for (const [key, entry] of state.rows) {
7851
- if (seen.has(key) || !this.hasPending(slug, key)) continue;
7852
- if (!this.isLocallyCreated(slug, key)) continue;
7853
- if (!matchesParams(entry.row, params)) continue;
7854
- rows.push(entry.row);
7855
- added++;
7856
- }
7857
- 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++;
7858
8750
  }
8751
+ const orderIsLocal = isLocallySortable(rows, params?.orderBy);
8752
+ if (params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
7859
8753
  return {
7860
8754
  data: rows,
7861
8755
  meta: {
@@ -7866,7 +8760,7 @@ var OfflineManager = class {
7866
8760
  },
7867
8761
  fromCache,
7868
8762
  hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id)),
7869
- partial: !exact
8763
+ partial: !exact || !orderIsLocal
7870
8764
  };
7871
8765
  }
7872
8766
  localFind(slug, params) {
@@ -8181,7 +9075,8 @@ var OfflineManager = class {
8181
9075
  }
8182
9076
  op.attempts = (op.attempts ?? 0) + 1;
8183
9077
  op.lastError = error?.message ?? String(error);
8184
- 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) {
8185
9080
  await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
8186
9081
  this.connectivity.deferRetry();
8187
9082
  this.patchStatus({ lastError: op.lastError });
@@ -8227,11 +9122,25 @@ var OfflineManager = class {
8227
9122
  await this.adoptServerRow(op, op.id, row);
8228
9123
  } else if (op.type === "createMany") {
8229
9124
  const queued = op.data ?? [];
8230
- 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
+ });
8231
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]);
8232
9137
  } else if (op.type === "update") {
8233
9138
  const row = await inner.update(op.id, op.data);
8234
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);
8235
9144
  } else if (op.type === "delete") {
8236
9145
  await inner.delete(op.id);
8237
9146
  this.removeLocalRow(op.collection, op.id, true);
@@ -8699,39 +9608,88 @@ channel: (name, options) => {
8699
9608
  }
8700
9609
  //#endregion
8701
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;
8702
9615
  function createHistoryRoutes(params) {
8703
- const { historyService, registry, driver } = params;
9616
+ const { historyService, registry } = params;
8704
9617
  const router = new Hono();
8705
9618
  router.onError(errorHandler);
8706
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
+ /**
8707
9660
  * GET /:slug/:id/history - List history entries for a entity
8708
9661
  *
8709
9662
  * Query params:
8710
- * limit (default 20)
9663
+ * limit (default 20, maximum 100 — above that the request is refused)
8711
9664
  * offset (default 0)
8712
9665
  */
8713
9666
  router.get("/:slug/:id/history", async (c) => {
8714
9667
  const slug = c.req.param("slug");
8715
9668
  const id = c.req.param("id");
8716
- 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
+ });
8717
9673
  const parsedOffset = parseInt(c.req.query("offset") ?? "0", 10);
8718
- const limit = Number.isNaN(parsedLimit) ? 20 : parsedLimit;
8719
9674
  const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;
8720
9675
  const collection = registry.getCollections().find((col) => col.slug === slug || false);
8721
9676
  if (!collection) throw ApiError.notFound(`Collection '${slug}' not found`);
8722
9677
  if (!collection.history) throw ApiError.badRequest(`History is not enabled for collection '${slug}'`);
9678
+ await authorizeEntityRead(c, collection, id);
8723
9679
  const tableName = collection.slug;
9680
+ const appliedLimit = limit;
9681
+ const appliedOffset = Math.max(offset, 0);
8724
9682
  const result = await historyService.fetchHistory(tableName, id, {
8725
- limit: Math.min(limit, 100),
8726
- offset: Math.max(offset, 0)
9683
+ limit: appliedLimit,
9684
+ offset: appliedOffset
8727
9685
  });
8728
9686
  return c.json({
8729
9687
  data: result.data,
8730
9688
  meta: {
8731
9689
  total: result.total,
8732
- limit,
8733
- offset,
8734
- hasMore: offset + result.data.length < result.total
9690
+ limit: appliedLimit,
9691
+ offset: appliedOffset,
9692
+ hasMore: appliedOffset + result.data.length < result.total
8735
9693
  }
8736
9694
  });
8737
9695
  });
@@ -8747,12 +9705,13 @@ function createHistoryRoutes(params) {
8747
9705
  const collection = registry.getCollections().find((col) => col.slug === slug || false);
8748
9706
  if (!collection) throw ApiError.notFound(`Collection '${slug}' not found`);
8749
9707
  if (!collection.history) throw ApiError.badRequest(`History is not enabled for collection '${slug}'`);
9708
+ await authorizeEntityRead(c, collection, id);
8750
9709
  const historyEntry = await historyService.fetchHistoryEntry(historyId);
8751
9710
  if (!historyEntry) throw ApiError.notFound(`History entry '${historyId}' not found`);
8752
9711
  const tableName = collection.slug;
8753
9712
  if (historyEntry.entity_id !== String(id) || historyEntry.table_name !== tableName) throw ApiError.badRequest("History entry does not belong to this entity");
8754
9713
  if (!historyEntry.values) throw ApiError.badRequest("Cannot revert: history entry has no stored values");
8755
- const authDriver = c.get("driver") || driver;
9714
+ const authDriver = c.get("driver");
8756
9715
  const path = collection.slug;
8757
9716
  const savedEntity = await authDriver.save({
8758
9717
  path,
@@ -8769,126 +9728,6 @@ function createHistoryRoutes(params) {
8769
9728
  return router;
8770
9729
  }
8771
9730
  //#endregion
8772
- //#region src/email/smtp-email-service.ts
8773
- var _nodemailer;
8774
- async function loadNodemailer() {
8775
- if (!_nodemailer) try {
8776
- _nodemailer = await import("nodemailer");
8777
- } catch {
8778
- throw new Error("nodemailer is required for SMTP email. Install it: pnpm add nodemailer");
8779
- }
8780
- return _nodemailer;
8781
- }
8782
- /**
8783
- * Safely parse a hostname from a URL string
8784
- */
8785
- function getHostname(urlStr) {
8786
- try {
8787
- return new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`).hostname;
8788
- } catch {
8789
- return;
8790
- }
8791
- }
8792
- /**
8793
- * SMTP Email Service implementation using Nodemailer
8794
- */
8795
- var SMTPEmailService = class {
8796
- transporter = null;
8797
- config;
8798
- _initialized = false;
8799
- constructor(config) {
8800
- this.config = config;
8801
- }
8802
- /**
8803
- * Lazily initialize the SMTP transporter on first use
8804
- */
8805
- async ensureTransporter() {
8806
- if (this._initialized) return;
8807
- this._initialized = true;
8808
- if (this.config.smtp) {
8809
- const nodemailer = await loadNodemailer();
8810
- let smtpName = this.config.smtp.name;
8811
- if (!smtpName) {
8812
- const urlsToTry = [
8813
- process.env.FRONTEND_URL,
8814
- this.config.resetPasswordUrl,
8815
- this.config.verifyEmailUrl
8816
- ];
8817
- for (const urlStr of urlsToTry) if (urlStr) {
8818
- const hostname = getHostname(urlStr);
8819
- if (hostname) {
8820
- smtpName = hostname;
8821
- break;
8822
- }
8823
- }
8824
- }
8825
- this.transporter = nodemailer.createTransport({
8826
- name: smtpName,
8827
- host: this.config.smtp.host,
8828
- port: this.config.smtp.port,
8829
- secure: this.config.smtp.secure ?? this.config.smtp.port === 465,
8830
- auth: this.config.smtp.auth ? {
8831
- user: this.config.smtp.auth.user,
8832
- pass: this.config.smtp.auth.pass
8833
- } : void 0
8834
- });
8835
- }
8836
- }
8837
- /**
8838
- * Check if the email service is properly configured
8839
- */
8840
- isConfigured() {
8841
- return !!(this.config.smtp || this.config.sendEmail);
8842
- }
8843
- /**
8844
- * Send an email using SMTP or custom send function
8845
- */
8846
- async send(options) {
8847
- if (this.config.sendEmail) {
8848
- await this.config.sendEmail(options);
8849
- return;
8850
- }
8851
- await this.ensureTransporter();
8852
- if (!this.transporter) throw new Error("Email service not configured. Provide SMTP config or sendEmail function.");
8853
- const to = Array.isArray(options.to) ? options.to.join(", ") : options.to;
8854
- try {
8855
- await this.transporter.sendMail({
8856
- from: this.config.from,
8857
- to,
8858
- subject: options.subject,
8859
- html: options.html,
8860
- text: options.text,
8861
- replyTo: options.replyTo
8862
- });
8863
- } catch (error) {
8864
- const message = error instanceof Error ? error.message : String(error);
8865
- logger.error("Failed to send email", { detail: message });
8866
- throw new Error(`Failed to send email: ${message}`);
8867
- }
8868
- }
8869
- /**
8870
- * Verify SMTP connection (useful for startup checks)
8871
- */
8872
- async verifyConnection() {
8873
- await this.ensureTransporter();
8874
- if (!this.transporter) return !!this.config.sendEmail;
8875
- try {
8876
- await this.transporter.verify();
8877
- return true;
8878
- } catch (error) {
8879
- const message = error instanceof Error ? error.message : String(error);
8880
- logger.error("SMTP connection verification failed", { detail: message });
8881
- return false;
8882
- }
8883
- }
8884
- };
8885
- /**
8886
- * Create an email service from configuration
8887
- */
8888
- function createEmailService(config) {
8889
- return new SMTPEmailService(config);
8890
- }
8891
- //#endregion
8892
9731
  //#region src/singleton.ts
8893
9732
  /**
8894
9733
  * The backing instance lives on a process-global slot, NOT in a module-local
@@ -8949,14 +9788,30 @@ function _resetRebaseMock() {
8949
9788
  * **Admin data plane** (`rebase.dataAsAdmin`):
8950
9789
  * Backed by the native DataDriver — calls go directly to the database without
8951
9790
  * JSON serialization, HTTP dispatch, or middleware overhead. The driver is
8952
- * scoped as `{ uid: "service", roles: ["admin"] }`, so **every read and write
8953
- * bypasses row-level-security policies**. No `REBASE_SERVICE_KEY` is required.
8954
- *
8955
- * ⚠️ Because it bypasses RLS, `rebase.dataAsAdmin` is for trusted background
8956
- * work (cron jobs, migrations, service tasks) — **not** for serving user-facing
8957
- * data. Inside a request handler, run user-scoped queries through the
8958
- * request-scoped driver (`c.var.driver`), which carries the caller's identity
8959
- * 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.
8960
9815
  *
8961
9816
  * `rebase.data` is **gone from the type**: `RebaseServerClient` omits it, so the
8962
9817
  * admin-scoped accessor has exactly one name and the privilege is visible at the
@@ -8972,7 +9827,8 @@ function _resetRebaseMock() {
8972
9827
  * ```typescript
8973
9828
  * import { rebase } from "@rebasepro/server";
8974
9829
  *
8975
- * // 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"] }`):
8976
9832
  * await rebase.email.send({ to: "admin@co.com", subject: "Alert", html: "<p>Hi</p>" });
8977
9833
  * const jobs = await rebase.dataAsAdmin.jobs.find({ limit: 10 });
8978
9834
  * ```
@@ -9086,7 +9942,8 @@ async function initializeRebaseBackend(config) {
9086
9942
  async function _initializeRebaseBackend(config) {
9087
9943
  if (config.logging?.level) configureLogLevel(config.logging.level);
9088
9944
  else configureLogLevel();
9089
- logger.info("Initializing Rebase Backend");
9945
+ logger.debug("Initializing Rebase Backend");
9946
+ installUnhandledRejectionHandler();
9090
9947
  const basePath = config.basePath || "/api";
9091
9948
  const isProduction = process.env.NODE_ENV === "production";
9092
9949
  configureMiddlewares(config.app, basePath, isProduction, config);
@@ -9104,13 +9961,14 @@ async function _initializeRebaseBackend(config) {
9104
9961
  });
9105
9962
  }
9106
9963
  const introspectCollections = activeCollections.length === 0;
9107
- 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");
9108
9966
  const realtimeServices = {};
9109
9967
  const delegates = {};
9110
9968
  let bootstrappers = config.bootstrappers || [];
9111
9969
  if (config.database) {
9112
9970
  const dbAdapter = config.database;
9113
- logger.info("Using DatabaseAdapter", { type: dbAdapter.type });
9971
+ logger.debug("Using DatabaseAdapter", { type: dbAdapter.type });
9114
9972
  bootstrappers = [wrapDatabaseAdapter(dbAdapter)];
9115
9973
  }
9116
9974
  if (bootstrappers.length === 0) throw new Error("No bootstrappers or database adapter provided. Cannot initialize database drivers.");
@@ -9118,7 +9976,7 @@ async function _initializeRebaseBackend(config) {
9118
9976
  let defaultDriverResult = void 0;
9119
9977
  for (const bootstrapper of bootstrappers) {
9120
9978
  const b = bootstrapper;
9121
- logger.info("Running bootstrapper for driver", { driverId: b.id || bootstrapper.type });
9979
+ logger.debug("Running bootstrapper for driver", { driverId: b.id || bootstrapper.type });
9122
9980
  if (b.isDefault) defaultDriverId = b.id || bootstrapper.type;
9123
9981
  const driverResult = await bootstrapper.initializeDriver({
9124
9982
  collections: activeCollections,
@@ -9176,7 +10034,7 @@ async function _initializeRebaseBackend(config) {
9176
10034
  authAdapter = config.auth;
9177
10035
  serviceKey = authAdapter.serviceKey;
9178
10036
  if (authAdapter.initialize) await authAdapter.initialize();
9179
- logger.info("Using AuthAdapter", { id: authAdapter.id });
10037
+ logger.debug("Using AuthAdapter", { id: authAdapter.id });
9180
10038
  authConfigResult = { userService: authAdapter.userManagement ?? {} };
9181
10039
  } else {
9182
10040
  const safeAuthConfig = config.auth;
@@ -9187,7 +10045,7 @@ async function _initializeRebaseBackend(config) {
9187
10045
  });
9188
10046
  if (foundAuthCollection) {
9189
10047
  safeAuthConfig.collection = foundAuthCollection;
9190
- 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 });
9191
10049
  }
9192
10050
  }
9193
10051
  if (safeAuthConfig.collection) {
@@ -9204,17 +10062,17 @@ async function _initializeRebaseBackend(config) {
9204
10062
  if (safeAuthConfig.serviceKey) {
9205
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'))\"");
9206
10064
  serviceKey = safeAuthConfig.serviceKey;
9207
- logger.info("Service key configured for script/server-to-server authentication");
10065
+ logger.debug("Service key configured for script/server-to-server authentication");
9208
10066
  }
9209
10067
  if (defaultBootstrapper.initializeAuth) {
9210
- logger.info("Bootstrapping authentication via driver protocol");
10068
+ logger.debug("Bootstrapping authentication via driver protocol");
9211
10069
  authConfigResult = await defaultBootstrapper.initializeAuth(config.auth, defaultDriverResult);
9212
10070
  logger.info("Authentication initialized");
9213
10071
  } else logger.warn("Auth requested but default bootstrapper does not support initializeAuth");
9214
10072
  }
9215
10073
  let historyConfigResult = void 0;
9216
10074
  if (config.history) if (defaultBootstrapper.initializeHistory) {
9217
- logger.info("Bootstrapping entity history via driver protocol");
10075
+ logger.debug("Bootstrapping entity history via driver protocol");
9218
10076
  historyConfigResult = await defaultBootstrapper.initializeHistory(config.history, defaultDriverResult);
9219
10077
  if (historyConfigResult?.historyService && defaultDriverResult.internals) {
9220
10078
  const driver = defaultDriverResult.internals.driver;
@@ -9230,7 +10088,7 @@ async function _initializeRebaseBackend(config) {
9230
10088
  if (apiKeyStoreResult) {
9231
10089
  apiKeyStore = apiKeyStoreResult;
9232
10090
  await apiKeyStore.ensureTable();
9233
- logger.info("Service API Keys initialized");
10091
+ logger.debug("Service API Keys initialized");
9234
10092
  }
9235
10093
  const apiKeyPreAuth = apiKeyStore ? createApiKeyPreAuth({
9236
10094
  store: apiKeyStore,
@@ -9243,7 +10101,7 @@ async function _initializeRebaseBackend(config) {
9243
10101
  serviceKey: internalServiceKey
9244
10102
  });
9245
10103
  config.app.route(`${basePath}/admin/api-keys`, apiKeyRoutes);
9246
- 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` });
9247
10105
  }
9248
10106
  const rateLimitConfig = config.rateLimit?.enabled !== false ? {
9249
10107
  ...config.rateLimit,
@@ -9327,7 +10185,7 @@ async function _initializeRebaseBackend(config) {
9327
10185
  ]) {
9328
10186
  const providerConfig = safeAuthConfig[key];
9329
10187
  if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
9330
- const createFn = (await import("./auth-DU4ShERo.js").then((n) => n.t))[factory];
10188
+ const createFn = (await import("./auth-CYoPVf-E.js").then((n) => n.t))[factory];
9331
10189
  oauthProviders.push(createFn(providerConfig));
9332
10190
  }
9333
10191
  }
@@ -9339,9 +10197,11 @@ async function _initializeRebaseBackend(config) {
9339
10197
  emailConfig: safeAuthConfig.email,
9340
10198
  allowRegistration: safeAuthConfig.allowRegistration ?? false,
9341
10199
  disableSelfRegistration: safeAuthConfig.disableSelfRegistration ?? false,
10200
+ allowAnonymous: safeAuthConfig.allowAnonymous ?? false,
9342
10201
  allowUserLookup: safeAuthConfig.allowUserLookup ?? false,
9343
10202
  defaultRole: safeAuthConfig.defaultRole,
9344
10203
  oauthProviders,
10204
+ allowedRedirectUris: safeAuthConfig.allowedRedirectUris,
9345
10205
  serviceKey: serviceKey || internalServiceKey,
9346
10206
  authHooks: safeAuthConfig.hooks,
9347
10207
  collectionAuthConfig,
@@ -9356,14 +10216,14 @@ async function _initializeRebaseBackend(config) {
9356
10216
  const authRoutes = authAdapter.createAuthRoutes();
9357
10217
  if (authRoutes) {
9358
10218
  config.app.route(`${basePath}/auth`, authRoutes);
9359
- logger.info("Auth routes mounted via adapter", { adapter: authAdapter.id });
10219
+ logger.debug("Auth routes mounted via adapter", { adapter: authAdapter.id });
9360
10220
  }
9361
10221
  }
9362
10222
  if (authAdapter && authAdapter.createAdminRoutes) {
9363
10223
  const adminRoutes = authAdapter.createAdminRoutes();
9364
10224
  if (adminRoutes) {
9365
10225
  config.app.route(`${basePath}/admin`, adminRoutes);
9366
- logger.info("Admin routes mounted via adapter", { adapter: authAdapter.id });
10226
+ logger.debug("Admin routes mounted via adapter", { adapter: authAdapter.id });
9367
10227
  }
9368
10228
  }
9369
10229
  }
@@ -9421,7 +10281,7 @@ async function _initializeRebaseBackend(config) {
9421
10281
  let schemaEditorOff = schemaEditorUnavailable();
9422
10282
  let schemaEditorRoutes;
9423
10283
  if (!schemaEditorOff && config.collectionsDir) try {
9424
- schemaEditorRoutes = (await import("./schema-editor-routes-C1DxDnDR.js")).createSchemaEditorRoutes(config.collectionsDir);
10284
+ schemaEditorRoutes = (await import("./schema-editor-routes-CRcS3ArS.js")).createSchemaEditorRoutes(config.collectionsDir);
9425
10285
  } catch (err) {
9426
10286
  if (err?.code === "ERR_MODULE_NOT_FOUND") {
9427
10287
  schemaEditorOff = {
@@ -9445,7 +10305,7 @@ async function _initializeRebaseBackend(config) {
9445
10305
  message: schemaEditorOff.message
9446
10306
  } }, 501));
9447
10307
  config.app.route(`${basePath}/schema-editor`, schemaEditorRouter);
9448
- if (schemaEditorRoutes) logger.info("Schema Editor mounted", { path: `${basePath}/schema-editor` });
10308
+ if (schemaEditorRoutes) logger.debug("Schema Editor mounted", { path: `${basePath}/schema-editor` });
9449
10309
  else logger.debug("Schema Editor unavailable", {
9450
10310
  path: `${basePath}/schema-editor`,
9451
10311
  code: schemaEditorOff.code
@@ -9471,6 +10331,7 @@ async function _initializeRebaseBackend(config) {
9471
10331
  });
9472
10332
  const storageRouter = new Hono();
9473
10333
  if (apiKeyPreAuth) storageRouter.use("/*", apiKeyPreAuth, createStorageApiKeyGuard());
10334
+ if (rateLimitConfig) storageRouter.use("/*", createDataRateLimiter(rateLimitConfig));
9474
10335
  storageRouter.use("/upload", bodyLimit({
9475
10336
  maxSize: storageMaxSize,
9476
10337
  onError: (c) => {
@@ -9491,6 +10352,7 @@ async function _initializeRebaseBackend(config) {
9491
10352
  config.app.route(`${basePath}/storage`, storageStub);
9492
10353
  logger.info("Storage not configured — /storage returns 501 STORAGE_NOT_CONFIGURED");
9493
10354
  }
10355
+ const serverCollections = activeCollections.filter((collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server");
9494
10356
  if (activeCollections.length > 0) {
9495
10357
  const dataRouter = new Hono();
9496
10358
  dataRouter.onError(errorHandler);
@@ -9498,7 +10360,10 @@ async function _initializeRebaseBackend(config) {
9498
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.");
9499
10361
  else {
9500
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);
9501
- 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
+ }
9502
10367
  }
9503
10368
  const dataPathMarker = `${basePath}/data/`;
9504
10369
  const resolveRequestDriver = (reqPath) => {
@@ -9532,24 +10397,21 @@ async function _initializeRebaseBackend(config) {
9532
10397
  });
9533
10398
  dataRouter.route("/", historyRoutes);
9534
10399
  }
9535
- const restGenerator = new RestApiGenerator(activeCollections.filter((collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server"), defaultDriver, authAdapter);
10400
+ const restGenerator = new RestApiGenerator(serverCollections, defaultDriver, authAdapter);
9536
10401
  dataRouter.route("/", restGenerator.generateRoutes());
9537
10402
  config.app.route(`${basePath}/data`, dataRouter);
9538
10403
  }
9539
- await mountOpenApiDocs(config.app, basePath, config.enableSwagger, activeCollections, resolveRequireAuth(config.auth));
10404
+ await mountOpenApiDocs(config.app, basePath, config.enableSwagger, serverCollections, resolveRequireAuth(config.auth));
9540
10405
  const serverClient = createRebaseClient({
9541
10406
  baseUrl: "http://localhost",
9542
10407
  apiPath: basePath,
9543
- websocketUrl: "",
10408
+ realtime: false,
9544
10409
  token: internalServiceKey,
9545
10410
  fetch: async (input, init) => {
9546
10411
  return await config.app.request(input, init);
9547
10412
  }
9548
10413
  });
9549
- const serviceIdentity = {
9550
- uid: "service",
9551
- roles: ["admin"]
9552
- };
10414
+ const serviceIdentity = SERVICE_IDENTITY;
9553
10415
  const defaultData = buildSdkData(await scopeDataDriver(defaultDriver, serviceIdentity));
9554
10416
  storageAuthorizeData.current = defaultData;
9555
10417
  const dataSourcesByKey = {};
@@ -9568,20 +10430,20 @@ async function _initializeRebaseBackend(config) {
9568
10430
  data: serverData,
9569
10431
  dataAsAdmin: serverData
9570
10432
  });
9571
- logger.info("Native data plane attached to singleton (bypasses HTTP loop)");
10433
+ logger.debug("Native data plane attached to singleton (bypasses HTTP loop)");
9572
10434
  if (storageController) {
9573
10435
  Object.assign(serverClient, { storage: storageController });
9574
- logger.info("Native storage attached to singleton (bypasses HTTP loop)");
10436
+ logger.debug("Native storage attached to singleton (bypasses HTTP loop)");
9575
10437
  }
9576
10438
  let emailService;
9577
10439
  if (authConfigResult?.emailService) emailService = authConfigResult.emailService;
9578
10440
  else if (config.auth && !isAuthAdapter(config.auth) && config.auth.email) emailService = createEmailService(config.auth.email);
9579
10441
  if (emailService) {
9580
10442
  Object.assign(serverClient, { email: emailService });
9581
- logger.info("Email service attached to singleton", { configured: emailService.isConfigured() });
10443
+ logger.debug("Email service attached to singleton", { configured: emailService.isConfigured() });
9582
10444
  if (emailService.isConfigured() && typeof emailService.verifyConnection === "function") emailService.verifyConnection().then((success) => {
9583
10445
  if (!success) logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.");
9584
- else logger.info("SMTP connection verified successfully.");
10446
+ else logger.debug("SMTP connection verified successfully.");
9585
10447
  }).catch((err) => {
9586
10448
  logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.", { error: err });
9587
10449
  });
@@ -9589,57 +10451,61 @@ async function _initializeRebaseBackend(config) {
9589
10451
  const driverAdmin = defaultBootstrapper.getAdmin?.(defaultDriverResult);
9590
10452
  if (isSQLAdmin(driverAdmin)) {
9591
10453
  Object.assign(serverClient, { sql: (query, options) => driverAdmin.executeSql(query, options) });
9592
- logger.info("SQL capability attached to singleton");
10454
+ logger.debug("SQL capability attached to singleton");
9593
10455
  }
9594
- _initRebase(serverClient);
9595
- logger.info("Rebase singleton initialized");
10456
+ const serverSingleton = serverClient;
10457
+ _initRebase(serverSingleton);
10458
+ logger.debug("Rebase singleton initialized");
9596
10459
  if (defaultDriverResult.internals) {
9597
10460
  const driver = defaultDriverResult.internals.driver;
9598
10461
  if (driver && "client" in driver) driver.client = serverClient;
9599
10462
  }
9600
10463
  if (config.functionsDir) {
9601
- const { loadFunctionsFromDirectory } = await import("./function-loader-B_1fYfUY.js").then((n) => n.t);
9602
- const { createFunctionRoutes } = await import("./function-routes-C0cLIy3N.js").then((n) => n.n);
9603
- const loadedFunctions = await loadFunctionsFromDirectory(config.functionsDir);
9604
- if (loadedFunctions.length > 0) {
9605
- const functionsRouter = new Hono();
9606
- functionsRouter.onError(errorHandler);
9607
- const functionsRequireAuth = false;
9608
- if (authAdapter) functionsRouter.use("/*", createAdapterAuthMiddleware({
9609
- adapter: authAdapter,
9610
- driver: defaultDriver,
9611
- requireAuth: functionsRequireAuth,
9612
- apiKeyStore
9613
- }));
9614
- else functionsRouter.use("/*", createAuthMiddleware({
9615
- driver: defaultDriver,
9616
- requireAuth: functionsRequireAuth,
9617
- serviceKey: internalServiceKey,
9618
- apiKeyStore
9619
- }));
9620
- functionsRouter.use("/*", createFunctionApiKeyGuard(`${basePath}/functions`));
9621
- if (rateLimitConfig) functionsRouter.use("/*", createDataRateLimiter({
9622
- ...rateLimitConfig,
9623
- anonymous: null
9624
- }));
9625
- const fnRoutes = createFunctionRoutes(loadedFunctions);
9626
- functionsRouter.route("/", fnRoutes);
9627
- config.app.route(`${basePath}/functions`, functionsRouter);
9628
- logger.info("Mounted custom functions", {
9629
- count: loadedFunctions.length,
9630
- path: `${basePath}/functions`
9631
- });
9632
- }
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."));
9633
10499
  }
9634
10500
  let cronScheduler;
9635
10501
  if (config.cronsDir) {
9636
- const { loadCronJobsFromDirectory } = await import("./cron-loader-B1S2MCSl.js").then((n) => n.t);
9637
- const { CronScheduler } = await import("./cron-scheduler-BhFZWR0T.js").then((n) => n.n);
9638
- const { createCronRoutes } = await import("./cron-routes-D5a9v1HY.js").then((n) => n.n);
9639
- const { createCronStore } = await import("./cron-store-Cu4sOseb.js").then((n) => n.n);
9640
- 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);
9641
10507
  cronScheduler = new CronScheduler();
9642
- cronScheduler.setClient(serverClient);
10508
+ cronScheduler.setClient(serverSingleton);
9643
10509
  if (loadedCronJobs.length > 0) {
9644
10510
  cronScheduler.registerJobs(loadedCronJobs);
9645
10511
  const store = defaultBootstrapper.getAdmin?.(defaultDriverResult) && config.cronPersistence !== false ? createCronStore(defaultDriver) : void 0;
@@ -9650,7 +10516,7 @@ async function _initializeRebaseBackend(config) {
9650
10516
  }
9651
10517
  const cronRouter = new Hono();
9652
10518
  applyAdminGate(cronRouter, "Cron");
9653
- cronRouter.route("/", createCronRoutes(cronScheduler));
10519
+ cronRouter.route("/", createCronRoutes(cronScheduler, cronProblems.length));
9654
10520
  config.app.route(`${basePath}/cron`, cronRouter);
9655
10521
  if (loadedCronJobs.length > 0) {
9656
10522
  cronScheduler.start();
@@ -9658,10 +10524,10 @@ async function _initializeRebaseBackend(config) {
9658
10524
  count: loadedCronJobs.length,
9659
10525
  path: `${basePath}/cron`
9660
10526
  });
9661
- } 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."));
9662
10528
  }
9663
10529
  {
9664
- const { createBackupRoutes, parseBackupDestination } = await import("./backup-BJiY_mM-.js").then((n) => n.t);
10530
+ const { createBackupRoutes, parseBackupDestination } = await import("./backup-C6ljYVTp.js").then((n) => n.t);
9665
10531
  const backupRouter = new Hono();
9666
10532
  applyAdminGate(backupRouter, "Backup");
9667
10533
  backupRouter.route("/", createBackupRoutes({
@@ -9672,7 +10538,7 @@ async function _initializeRebaseBackend(config) {
9672
10538
  storage: storageController
9673
10539
  }));
9674
10540
  config.app.route(`${basePath}/admin/backups`, backupRouter);
9675
- logger.info("Backup admin routes mounted", { path: `${basePath}/admin/backups` });
10541
+ logger.debug("Backup admin routes mounted", { path: `${basePath}/admin/backups` });
9676
10542
  }
9677
10543
  {
9678
10544
  const { default: logsRoutes } = await import("./logs-routes-BYA72C_C.js").then((n) => n.n);
@@ -9680,10 +10546,10 @@ async function _initializeRebaseBackend(config) {
9680
10546
  applyAdminGate(logsRouter, "Logs");
9681
10547
  logsRouter.route("/", logsRoutes);
9682
10548
  config.app.route(`${basePath}/logs`, logsRouter);
9683
- logger.info("Logs routes mounted", { path: `${basePath}/logs` });
10549
+ logger.debug("Logs routes mounted", { path: `${basePath}/logs` });
9684
10550
  }
9685
10551
  {
9686
- 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);
9687
10553
  const contractRouter = new Hono();
9688
10554
  if (adminSurfacesGated) {
9689
10555
  if (apiKeyPreAuth) contractRouter.use("/contract", apiKeyPreAuth);
@@ -9701,7 +10567,7 @@ async function _initializeRebaseBackend(config) {
9701
10567
  runtimeVersion: config.runtimeVersion
9702
10568
  }));
9703
10569
  config.app.route(`${basePath}/meta`, contractRouter);
9704
- logger.info("Contract routes mounted", { path: `${basePath}/meta` });
10570
+ logger.debug("Contract routes mounted", { path: `${basePath}/meta` });
9705
10571
  }
9706
10572
  const effectiveRealtimeService = Object.keys(realtimeServices).length > 1 ? createRoutedRealtimeService({
9707
10573
  providers: realtimeServices,
@@ -9709,7 +10575,7 @@ async function _initializeRebaseBackend(config) {
9709
10575
  resolveKey: keyForCollectionPath
9710
10576
  }) : defaultRealtimeService;
9711
10577
  if (defaultBootstrapper.initializeWebsockets && effectiveRealtimeService) await defaultBootstrapper.initializeWebsockets(config.server, effectiveRealtimeService, defaultDriver, config.auth, authAdapter);
9712
- logger.info("Rebase Backend Initialized");
10578
+ logger.debug("Rebase Backend Initialized");
9713
10579
  const authSchemaCheck = authConfigResult?.schemaHealthCheck;
9714
10580
  const healthCheck = createHealthCheck(defaultDriver, authSchemaCheck ? () => authSchemaCheck.call(authConfigResult) : void 0);
9715
10581
  const shutdown = createShutdown({
@@ -9777,7 +10643,10 @@ async function _initializeRebaseBackend(config) {
9777
10643
  * export default defineFunction((app, { rebase }) => {
9778
10644
  * app.use("/*", requireAuth);
9779
10645
  * app.get("/home", async (c) => {
9780
- * 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`);
9781
10650
  * return c.json({ orders: Number(stats.n) });
9782
10651
  * });
9783
10652
  * });
@@ -9802,6 +10671,17 @@ function defineFunction(definition) {
9802
10671
  *
9803
10672
  * @see {@link defineFunction} for the equivalent custom-functions helper.
9804
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
+ *
9805
10685
  * @example
9806
10686
  * ```ts
9807
10687
  * import { defineCron } from "@rebasepro/server";
@@ -9809,12 +10689,15 @@ function defineFunction(definition) {
9809
10689
  * export default defineCron({
9810
10690
  * name: "Nightly cleanup",
9811
10691
  * schedule: "0 3 * * *",
9812
- * async handler({ client, log }) {
9813
- * 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({
9814
10697
  * where: { expired: ["==", true] },
9815
10698
  * });
9816
10699
  * for (const session of expired) {
9817
- * await client.data.sessions.delete(session.id);
10700
+ * await rebase.dataAsAdmin.sessions.delete(session.id as string);
9818
10701
  * }
9819
10702
  * log(`Deleted ${expired.length} expired sessions`);
9820
10703
  * },
@@ -9827,36 +10710,41 @@ function defineCron(definition) {
9827
10710
  //#endregion
9828
10711
  //#region src/utils/sql.ts
9829
10712
  /**
9830
- * Returns a SQL chunk calling `auth.uid()` — the current user's ID.
9831
- * 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
9832
10715
  * that reads `app.uid` set per-transaction by `withAuth()`.
9833
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
+ *
9834
10722
  * @example
9835
10723
  * sql`${table.uid} = ${authUid()}`
9836
10724
  */
9837
10725
  var authUid = () => {
9838
- return sql`auth.uid()`;
10726
+ return sql.raw(RLS_UID_SQL);
9839
10727
  };
9840
10728
  /**
9841
- * 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
9842
10730
  * as a comma-separated string.
9843
10731
  * Reads `app.user_roles` set per-transaction by `withAuth()`.
9844
10732
  *
9845
10733
  * @example
9846
- * sql`auth.roles() ~ 'admin'`
10734
+ * sql`${authRoles()} ~ 'admin'`
9847
10735
  */
9848
10736
  var authRoles = () => {
9849
- return sql`auth.roles()`;
10737
+ return sql.raw(RLS_ROLES_SQL);
9850
10738
  };
9851
10739
  /**
9852
- * 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.
9853
10741
  * Reads `app.jwt` set per-transaction by `withAuth()`.
9854
10742
  *
9855
10743
  * @example
9856
- * sql`auth.jwt()->>'sub'`
10744
+ * sql`${authJwt()}->>'sub'`
9857
10745
  */
9858
10746
  var authJwt = () => {
9859
- return sql`auth.jwt()`;
10747
+ return sql.raw(RLS_JWT_SQL);
9860
10748
  };
9861
10749
  //#endregion
9862
10750
  //#region src/env.ts
@@ -9930,6 +10818,7 @@ var rebaseEnvSchema = object({
9930
10818
  REBASE_SERVICE_KEY: string().optional(),
9931
10819
  ALLOW_REGISTRATION: boolString,
9932
10820
  DISABLE_SELF_REGISTRATION: optionalBoolString,
10821
+ ALLOW_ANONYMOUS: optionalBoolString,
9933
10822
  ALLOW_LOCALHOST_IN_PRODUCTION: optionalBoolString,
9934
10823
  CORS_ORIGINS: string().optional(),
9935
10824
  FRONTEND_URL: string().optional(),
@@ -9993,7 +10882,158 @@ function loadEnv(options) {
9993
10882
  return env;
9994
10883
  }
9995
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
9996
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;
9997
11037
  var WebhookDispatcher = class {
9998
11038
  webhooks = [];
9999
11039
  maxRetries = 3;
@@ -10002,33 +11042,103 @@ var WebhookDispatcher = class {
10002
11042
  5e3,
10003
11043
  15e3
10004
11044
  ];
11045
+ options;
11046
+ timeoutMs;
11047
+ queue = [];
11048
+ draining = null;
11049
+ constructor(options = {}) {
11050
+ this.options = options;
11051
+ this.timeoutMs = options.timeoutMs ?? 1e4;
11052
+ }
10005
11053
  /** Register webhooks to watch */
10006
11054
  setWebhooks(webhooks) {
10007
11055
  this.webhooks = webhooks.filter((w) => w.enabled);
10008
11056
  }
10009
- /** 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
+ */
10010
11068
  async onEntityChange(table, event, id, entity, previousEntity) {
10011
- const matchingWebhooks = this.webhooks.filter((w) => w.table === table && w.events.includes(event));
10012
- if (matchingWebhooks.length === 0) return [];
11069
+ const jobs = this.buildDeliveries(table, event, entity, previousEntity);
10013
11070
  const results = [];
10014
- for (const webhook of matchingWebhooks) {
10015
- 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: {
10016
11129
  type: event,
10017
11130
  table,
10018
11131
  record: entity,
10019
11132
  old_record: event === "UPDATE" ? previousEntity : void 0,
10020
11133
  schema: "public",
10021
11134
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
10022
- };
10023
- const result = await this.deliverWithRetry(webhook, event, payload);
10024
- results.push(result);
10025
- }
10026
- return results;
11135
+ }
11136
+ }));
10027
11137
  }
10028
11138
  async deliverWithRetry(webhook, event, payload) {
10029
11139
  for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
10030
- const result = await this.deliver(webhook, event, payload, attempt);
10031
- if (result.success) return result;
11140
+ const { result, terminal } = await this.deliver(webhook, event, payload, attempt);
11141
+ if (result.success || terminal) return result;
10032
11142
  if (attempt < this.maxRetries) {
10033
11143
  const backoff = this.retryDelays[attempt - 1] ?? this.retryDelays[this.retryDelays.length - 1];
10034
11144
  await new Promise((r) => setTimeout(r, backoff));
@@ -10055,41 +11165,106 @@ var WebhookDispatcher = class {
10055
11165
  ...webhook.headers || {}
10056
11166
  };
10057
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);
10058
11170
  try {
10059
- const controller = new AbortController();
10060
- const timeout = setTimeout(() => controller.abort(), 1e4);
10061
- 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, {
10062
11176
  method: "POST",
10063
11177
  headers,
10064
11178
  body,
10065
- signal: controller.signal
11179
+ signal: controller.signal,
11180
+ redirect: "manual"
10066
11181
  });
10067
- clearTimeout(timeout);
10068
- 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);
10069
11198
  const success = response.status >= 200 && response.status < 300;
10070
11199
  return {
10071
- webhookId: webhook.id,
10072
- event,
10073
- payload,
10074
- statusCode: response.status,
10075
- responseBody: responseBody.slice(0, 1e3),
10076
- success,
10077
- 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
+ }
10078
11210
  };
10079
11211
  } catch (error) {
10080
11212
  const message = error instanceof Error ? error.message : String(error);
10081
11213
  return {
10082
- webhookId: webhook.id,
10083
- event,
10084
- payload,
10085
- statusCode: 0,
10086
- responseBody: message.slice(0, 1e3),
10087
- success: false,
10088
- 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
+ }
10089
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);
10090
11253
  }
11254
+ return new TextDecoder().decode(concat(chunks, total));
10091
11255
  }
10092
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
+ }
10093
11268
  //#endregion
10094
11269
  //#region src/utils/dev-port.ts
10095
11270
  var MAX_PORT_ATTEMPTS = 20;
@@ -10250,10 +11425,11 @@ function isUnderPath(requestPath, prefix) {
10250
11425
  * Serve a Single Page Application from an Hono app.
10251
11426
  *
10252
11427
  * @internal Not part of the stable public API. Exported only because the
10253
- * official app template (`packages/cli/templates/template/backend/src/index.ts`
10254
- * and `app/backend/src/index.ts`) calls it to serve the built frontend in
10255
- * production. Its request-handling behavior is an implementation detail and
10256
- * 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.
10257
11433
  */
10258
11434
  function serveSPA(app, config) {
10259
11435
  const { frontendPath, apiBasePath = "/api", excludePaths = [], indexFile = "index.html", spa = true } = config;
@@ -11397,6 +12573,7 @@ function resolveAuthOptions(env, usersCollection) {
11397
12573
  requireAuth: env.AUTH_REQUIRE,
11398
12574
  allowRegistration: env.ALLOW_REGISTRATION,
11399
12575
  disableSelfRegistration: env.DISABLE_SELF_REGISTRATION,
12576
+ allowAnonymous: env.ALLOW_ANONYMOUS,
11400
12577
  allowUserLookup: env.AUTH_ALLOW_USER_LOOKUP,
11401
12578
  email: resolveEmailOptions(env),
11402
12579
  cookieAuth: { sameSite: env.AUTH_COOKIE_SAME_SITE || "Lax" }
@@ -11804,7 +12981,7 @@ async function bootFromBundle(options = {}) {
11804
12981
  const bundleDir = options.bundleDir || fetchedDir || process.env.REBASE_BUNDLE || path.resolve(process.cwd(), "dist-bundle");
11805
12982
  const bundle = options.bundle ?? loadBundle(bundleDir);
11806
12983
  const devRoot = process.env.REBASE_DEV_PROJECT_ROOT || process.cwd();
11807
- logger.info("Loaded bundle", {
12984
+ logger.debug("Loaded bundle", {
11808
12985
  app: bundle.manifest.app,
11809
12986
  kind: bundle.manifest.kind,
11810
12987
  schemaVersion: bundle.manifest.schemaVersion,
@@ -12175,8 +13352,9 @@ async function ensureCollectionSchema(bundle, dataSources, env) {
12175
13352
  skip(`none of the ${loaded.length} declared collection(s) are stored by the "${primary.engine}" data source.`);
12176
13353
  return;
12177
13354
  }
12178
- const { applied } = await primary.bootstrapper.ensureCollectionSchema(collections, preInitDriverResult(primary), (message) => logger.info(`schema: ${message}`));
12179
- logger.info(applied > 0 ? `Applied ${applied} additive schema change(s) before boot.` : "Collection schema is up to date.");
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.");
12180
13358
  }
12181
13359
  /**
12182
13360
  * The `InitializedDriver` to hand a bootstrapper before any driver exists.
@@ -12221,17 +13399,18 @@ async function ensureCollectionPolicies(bundle, dataSources, env) {
12221
13399
  if (!primary) return;
12222
13400
  if (!primary.bootstrapper.ensureCollectionPolicies) {
12223
13401
  const skew = describeDriverSkew(primary.driverVersion, readRuntimeVersion(bundleResolutionRoots(bundle.dir)));
12224
- 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 }));
12225
13403
  return;
12226
13404
  }
12227
13405
  const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
12228
13406
  if (loaded.length === 0) return;
12229
13407
  const collections = collectionsStoredBy(loaded, primary, dataSources);
12230
13408
  if (collections.length === 0) return;
12231
- const { applied } = await primary.bootstrapper.ensureCollectionPolicies(collections, preInitDriverResult(primary), (message) => logger.info(`policies: ${message}`));
12232
- 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.");
12233
13412
  }
12234
13413
  //#endregion
12235
- 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, 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 };
12236
13415
 
12237
13416
  //# sourceMappingURL=index.es.js.map