@rebasepro/server 0.13.1-canary.gf57a27e → 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 (126) hide show
  1. package/dist/{admin_block-CLeUMG8G.js → admin_block-H86dCPsj.js} +60 -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-BANhRhN9.js → auth-CYoPVf-E.js} +1814 -354
  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/{contract-routes-FSt0Nyfk.js → contract-routes-Bet-eCNJ.js} +3 -3
  45. package/dist/{contract-routes-FSt0Nyfk.js.map → contract-routes-Bet-eCNJ.js.map} +1 -1
  46. package/dist/cron/cron-loader.d.ts +26 -1
  47. package/dist/cron/cron-routes.d.ts +1 -1
  48. package/dist/cron/cron-scheduler.d.ts +5 -0
  49. package/dist/cron/define-cron.d.ts +14 -2
  50. package/dist/cron/index.d.ts +2 -2
  51. package/dist/{cron-loader-B1S2MCSl.js → cron-loader-YhhQeVBM.js} +43 -8
  52. package/dist/cron-loader-YhhQeVBM.js.map +1 -0
  53. package/dist/{cron-routes-D5a9v1HY.js → cron-routes-maM_RlUu.js} +10 -4
  54. package/dist/cron-routes-maM_RlUu.js.map +1 -0
  55. package/dist/{cron-scheduler-CsQtYLMq.js → cron-scheduler-DIpYBmZP.js} +13 -7
  56. package/dist/cron-scheduler-DIpYBmZP.js.map +1 -0
  57. package/dist/{cron-store-ZbjfuvMb.js → cron-store-Dvr4Y1sZ.js} +5 -5
  58. package/dist/{cron-store-ZbjfuvMb.js.map → cron-store-Dvr4Y1sZ.js.map} +1 -1
  59. package/dist/{ddl-bootstrap-Bp3mr6Td.js → ddl-bootstrap-BhXbTnBl.js} +2 -2
  60. package/dist/{ddl-bootstrap-Bp3mr6Td.js.map → ddl-bootstrap-BhXbTnBl.js.map} +1 -1
  61. package/dist/email/html.d.ts +54 -0
  62. package/dist/email/index.d.ts +3 -0
  63. package/dist/email/link-base.d.ts +39 -0
  64. package/dist/email/smtp-email-service.d.ts +7 -1
  65. package/dist/email/templates.d.ts +9 -1
  66. package/dist/email/types.d.ts +11 -1
  67. package/dist/env.d.ts +5 -0
  68. package/dist/{errors-BpudWAVU.js → errors-EBYiaJ2E.js} +14 -2
  69. package/dist/errors-EBYiaJ2E.js.map +1 -0
  70. package/dist/function-loader-DDS1v7YX.js +139 -0
  71. package/dist/function-loader-DDS1v7YX.js.map +1 -0
  72. package/dist/{function-routes-C0cLIy3N.js → function-routes-Btcez1T-.js} +18 -6
  73. package/dist/function-routes-Btcez1T-.js.map +1 -0
  74. package/dist/functions/define-function.d.ts +24 -5
  75. package/dist/functions/function-loader.d.ts +23 -0
  76. package/dist/functions/function-routes.d.ts +7 -1
  77. package/dist/functions/request-timeout.d.ts +34 -0
  78. package/dist/index.d.ts +3 -2
  79. package/dist/index.es.js +1437 -694
  80. package/dist/index.es.js.map +1 -1
  81. package/dist/init/docs.d.ts +10 -1
  82. package/dist/init/process-safety.d.ts +25 -0
  83. package/dist/init.d.ts +38 -0
  84. package/dist/{jwt-CzeviDcB.js → jwt-_IFqfTOg.js} +57 -9
  85. package/dist/jwt-_IFqfTOg.js.map +1 -0
  86. package/dist/logger-DfvF_8r-.js +190 -0
  87. package/dist/logger-DfvF_8r-.js.map +1 -0
  88. package/dist/{openapi-generator-VB0MUH8h.js → openapi-generator-DPKtUC9X.js} +87 -31
  89. package/dist/openapi-generator-DPKtUC9X.js.map +1 -0
  90. package/dist/request-timeout-RivJsME0.js +65 -0
  91. package/dist/request-timeout-RivJsME0.js.map +1 -0
  92. package/dist/schema-editor-routes-CRcS3ArS.js +437 -0
  93. package/dist/schema-editor-routes-CRcS3ArS.js.map +1 -0
  94. package/dist/serve-spa.d.ts +5 -4
  95. package/dist/services/outbound-url-guard.d.ts +53 -0
  96. package/dist/services/routed-realtime-service.d.ts +10 -2
  97. package/dist/services/webhook-service.d.ts +76 -1
  98. package/dist/singleton.d.ts +25 -8
  99. package/dist/{src-DVb5a0bp.js → src-C7rkDGxA.js} +536 -61
  100. package/dist/src-C7rkDGxA.js.map +1 -0
  101. package/dist/{src-Ca6NhxKs.js → src-Cz9nMgUR.js} +16 -3
  102. package/dist/src-Cz9nMgUR.js.map +1 -0
  103. package/dist/storage/LocalStorageController.d.ts +12 -1
  104. package/dist/storage/image-transform.d.ts +57 -0
  105. package/dist/storage/keys.d.ts +34 -1
  106. package/dist/storage/routes.d.ts +11 -0
  107. package/dist/storage/tus-handler.d.ts +22 -2
  108. package/dist/utils/logger.d.ts +18 -0
  109. package/package.json +6 -5
  110. package/dist/admin_block-CLeUMG8G.js.map +0 -1
  111. package/dist/auth-BANhRhN9.js.map +0 -1
  112. package/dist/cron-loader-B1S2MCSl.js.map +0 -1
  113. package/dist/cron-routes-D5a9v1HY.js.map +0 -1
  114. package/dist/cron-scheduler-CsQtYLMq.js.map +0 -1
  115. package/dist/errors-BpudWAVU.js.map +0 -1
  116. package/dist/function-loader-B_1fYfUY.js +0 -86
  117. package/dist/function-loader-B_1fYfUY.js.map +0 -1
  118. package/dist/function-routes-C0cLIy3N.js.map +0 -1
  119. package/dist/jwt-CzeviDcB.js.map +0 -1
  120. package/dist/logger-BYU66ENZ.js +0 -94
  121. package/dist/logger-BYU66ENZ.js.map +0 -1
  122. package/dist/openapi-generator-VB0MUH8h.js.map +0 -1
  123. package/dist/schema-editor-routes-BqjzvvWU.js +0 -248
  124. package/dist/schema-editor-routes-BqjzvvWU.js.map +0 -1
  125. package/dist/src-Ca6NhxKs.js.map +0 -1
  126. package/dist/src-DVb5a0bp.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-DVb5a0bp.js";
5
- import { c as DEFAULT_DATA_SOURCE_KEY, i as getCollectionDataPath, n as RLS_ROLES_SQL, o as isPostgresCollectionConfig, p as toCanonicalOp, r as RLS_UID_SQL, t as RLS_JWT_SQL } from "./src-Ca6NhxKs.js";
6
- import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-CLeUMG8G.js";
7
- import { i as isSQLAdmin, r as revokeInternalTableSql } from "./ddl-bootstrap-Bp3mr6Td.js";
8
- import { n as createContractRoutes } from "./contract-routes-FSt0Nyfk.js";
9
- import { $ as safeCompare, A as getEmailVerificationTemplate, B as optionalAuth, C as createDataRateLimiter, D as validatePasswordStrength, E as hashPassword, F as createAdapterAuthMiddleware, G as InvalidStorageKeyError, H as queryTokenAuth, I as createAuthMiddleware, J as createFunctionApiKeyGuard, K as canonicalStorageKey, L as createRequireAuth, M as getPasswordResetTemplate, N as getUserInvitationTemplate, O as verifyPassword, P as getWelcomeEmailTemplate, Q as extractBearerToken, R as extractUserFromToken, S as _coercedNumber, T as resolveAuthHooks, U as requireAdmin, V as publicObjectAuth, W as requireAuth, X as isApiKeyToken, Y as createStorageApiKeyGuard, Z as validateApiKey, _ as createBuiltinAuthAdapter, a as createSpotifyProvider, b as object, c as createGitLabProvider, d as createFacebookProvider, et as scopeDataDriver, f as createAppleProvider, g as createGoogleProvider, h as createLinkedinProvider, i as createApiKeyStore, it as isPublicStoragePath, j as getMagicLinkTemplate, k as generateSecurePassword, l as createDiscordProvider, m as createGitHubProvider, n as createCustomAuthAdapter, nt as isOperationAllowed, o as createSlackProvider, p as createMicrosoftProvider, q as createApiKeyPreAuth, r as createApiKeyRoutes, rt as PUBLIC_STORAGE_PREFIX, s as createBitbucketProvider, tt as httpMethodToOperation, u as createTwitterProvider, v as ZodNumber, w as MemoryRateLimitStore, x as string, y as _enum, z as fileTokenAuth } from "./auth-BANhRhN9.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-CsQtYLMq.js";
21
- import { t as createCronRoutes } from "./cron-routes-D5a9v1HY.js";
22
- import { t as createCronStore } from "./cron-store-ZbjfuvMb.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";
@@ -37,73 +37,18 @@ import { csrf } from "hono/csrf";
37
37
  import { compress } from "hono/compress";
38
38
  import fs$1 from "node:fs";
39
39
  import fsp from "node:fs/promises";
40
+ import os from "node:os";
40
41
  import { mkdir, open, unlink, writeFile } from "fs/promises";
41
42
  import { sql } from "drizzle-orm";
43
+ import { lookup } from "node:dns/promises";
44
+ import { isIP } from "node:net";
42
45
  import { serveStatic } from "@hono/node-server/serve-static";
43
46
  import { createServer } from "http";
44
47
  import { cors } from "hono/cors";
45
48
  import { secureHeaders } from "hono/secure-headers";
46
49
  import { getRequestListener } from "@hono/node-server";
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. See {@link RebaseErrorCode}. */
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
  /**
@@ -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,236 +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
- function invalidOrderBy(detail) {
908
- throw ApiError.badRequest(`Invalid \`orderBy\` parameter: ${detail}. Expected \`field\`, \`field:desc\`, or a JSON array like [{"field":"created_at","direction":"desc"}]`, "INVALID_ORDER_BY");
909
- }
910
- /** `asc`/`desc`, in any case. Anything else is a request to sort in a way that does not exist. */
911
- function toDirection(raw, context) {
912
- if (raw === void 0 || raw === null) return "asc";
913
- if (typeof raw !== "string") invalidOrderBy(`${context} has a non-string \`direction\``);
914
- const lowered = raw.toLowerCase();
915
- if (lowered !== "asc" && lowered !== "desc") invalidOrderBy(`${context} has direction '${raw}'`);
916
- return lowered;
917
- }
918
- /** One entry: the canonical `{field, direction}`, or the `field:direction` shorthand as a string. */
919
- function toOrderByEntry(raw, index) {
920
- const context = `entry ${index}`;
921
- if (typeof raw === "string") {
922
- const tuple = deserializeOrderBy(raw);
923
- if (!tuple || !tuple[0]) invalidOrderBy(`${context} is an empty field name`);
924
- return {
925
- field: tuple[0],
926
- direction: toDirection(tuple[1], context)
927
- };
928
- }
929
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) invalidOrderBy(`${context} is not a field name or a {field, direction} object`);
930
- const entry = raw;
931
- if (typeof entry.field !== "string" || entry.field.trim() === "") invalidOrderBy(`${context} has no \`field\``);
932
- return {
933
- field: entry.field,
934
- direction: toDirection(entry.direction, context)
935
- };
936
- }
937
- /**
938
- * Parse the `orderBy` query parameter.
939
- *
940
- * The field *name* has been validated against the schema for a while — an
941
- * `?orderBy=titel` is a 400 rather than 200 with unsorted rows, on the grounds
942
- * that silently dropping the sort leaves the caller believing in an order that
943
- * is not there. The parameter's *shape* was never checked the same way, and it
944
- * failed in exactly the same silent manner one layer earlier: whatever
945
- * `JSON.parse` returned was assigned to an option declared as an array of
946
- * `{field, direction}`, and the REST layer reads only `orderBy[0].field`. So
947
- * `?orderBy={"field":"name"}` — an object rather than an array, and the most
948
- * natural thing for a client to try — read `undefined`, dropped the ORDER BY,
949
- * and answered 200. So did a number, a boolean, `null`, and `["name"]`.
950
- *
951
- * This refuses those, the way `parseWhereParam` above already refuses a
952
- * malformed filter and for the same reason. What it keeps working is every
953
- * shape that worked before: the `field` and `field:desc` shorthands, and the
954
- * canonical JSON array.
955
- */
956
- function parseOrderByParam(raw) {
957
- if (Array.isArray(raw)) return raw.length === 0 ? void 0 : raw.map(toOrderByEntry);
958
- const str = String(raw).trim();
959
- if (!str) return void 0;
960
- let parsed;
961
- try {
962
- parsed = JSON.parse(str);
963
- } catch {
964
- return [toOrderByEntry(str, 0)];
965
- }
966
- if (Array.isArray(parsed)) {
967
- if (parsed.length === 0) return void 0;
968
- return parsed.map(toOrderByEntry);
969
- }
970
- if (typeof parsed === "string") return [toOrderByEntry(parsed, 0)];
971
- if (typeof parsed === "object" && parsed !== null) invalidOrderBy("a single object was given where a JSON array was expected");
972
- invalidOrderBy(`${typeof parsed} is not a field name or a list of them`);
973
- }
974
- /**
975
- * Parse query parameters into QueryOptions
976
- */
977
- function parseQueryOptions(query, limits = {}) {
978
- const options = {};
979
- const rawLimit = getLastValue(query.limit);
980
- const offsetVal = getLastValue(query.offset);
981
- if (offsetVal) options.offset = parseInt(String(offsetVal));
982
- const pageVal = getLastValue(query.page);
983
- if (pageVal) {
984
- const page = parseInt(String(pageVal));
985
- const limit = resolveClientListLimit(rawLimit, {
986
- defaultLimit: limits.defaultLimit,
987
- maxLimit: limits.maxLimit
988
- });
989
- options.offset = (page - 1) * limit;
990
- }
991
- const orVal = getLastValue(query.or);
992
- const andVal = getLastValue(query.and);
993
- if (orVal) {
994
- const logical = parseLogicalGroup("or", orVal);
995
- if (logical) options.logical = logical;
996
- } else if (andVal) {
997
- const logical = parseLogicalGroup("and", andVal);
998
- if (logical) options.logical = logical;
999
- }
1000
- const reservedQueryKeys = [
1001
- "limit",
1002
- "offset",
1003
- "page",
1004
- "orderBy",
1005
- "include",
1006
- "fields",
1007
- "searchString",
1008
- "searchExplain",
1009
- "vector_search",
1010
- "vector",
1011
- "vector_distance",
1012
- "vector_threshold",
1013
- "or",
1014
- "and",
1015
- "where"
1016
- ];
1017
- const filterDict = {};
1018
- for (const [key, rawValue] of Object.entries(query)) {
1019
- if (reservedQueryKeys.includes(key)) continue;
1020
- filterDict[key] = rawValue;
1021
- }
1022
- const whereVal = getLastValue(query.where);
1023
- const where = {
1024
- ...whereVal !== void 0 && whereVal !== null ? parseWhereParam(whereVal) : void 0,
1025
- ...deserializeFilter(filterDict)
1026
- };
1027
- if (Object.keys(where).length > 0) options.where = where;
1028
- const orderByVal = getLastValue(query.orderBy);
1029
- if (orderByVal) options.orderBy = parseOrderByParam(orderByVal);
1030
- const includeVal = getLastValue(query.include);
1031
- if (includeVal) {
1032
- const includeStr = String(includeVal).trim();
1033
- if (includeStr === "*") options.include = ["*"];
1034
- else options.include = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
1035
- }
1036
- const fieldsVal = getLastValue(query.fields);
1037
- if (fieldsVal) options.fields = String(fieldsVal).trim().split(",").map((s) => s.trim()).filter(Boolean);
1038
- const vectorSearchVal = getLastValue(query.vector_search);
1039
- const vectorVal = getLastValue(query.vector);
1040
- if (vectorSearchVal && vectorVal) {
1041
- const vectorStr = String(vectorVal);
1042
- let decoded;
1043
- try {
1044
- decoded = JSON.parse(vectorStr);
1045
- } catch {
1046
- decoded = void 0;
1047
- }
1048
- 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");
1049
- const queryVector = decoded;
1050
- const distanceParamVal = getLastValue(query.vector_distance);
1051
- const distanceParam = distanceParamVal ? String(distanceParamVal) : "cosine";
1052
- if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") throw ApiError.badRequest(`Invalid \`vector_distance\`: ${distanceParam}. Expected: cosine, l2, or inner_product`, "INVALID_VECTOR_DISTANCE");
1053
- const vectorSearch = {
1054
- property: String(vectorSearchVal),
1055
- vector: queryVector,
1056
- distance: distanceParam
1057
- };
1058
- const thresholdVal = getLastValue(query.vector_threshold);
1059
- if (thresholdVal) {
1060
- const threshold = parseFloat(String(thresholdVal));
1061
- if (isNaN(threshold)) throw ApiError.badRequest("Invalid `vector_threshold`. Expected a number.", "INVALID_VECTOR_THRESHOLD");
1062
- vectorSearch.threshold = threshold;
1063
- }
1064
- options.vectorSearch = vectorSearch;
1065
- }
1066
- options.limit = resolveClientListLimit(rawLimit, {
1067
- vectorSearch: !!options.vectorSearch,
1068
- defaultLimit: limits.defaultLimit,
1069
- maxLimit: limits.maxLimit
1070
- });
1071
- return options;
1072
- }
1073
- //#endregion
1074
855
  //#region src/api/rest/write-validation.ts
1075
856
  /**
1076
857
  * Reject a write naming a field the collection does not have.
1077
858
  *
1078
- * Unknown keys used to travel all the way into the INSERT, where Postgres
1079
- * rejected them — so a typo came back as `column "titel" does not exist`,
1080
- * phrased by the database, from a stack the caller cannot see, and only if the
1081
- * 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.
1082
871
  *
1083
872
  * What counts as known:
1084
873
  * - a declared property (for an introspected BaaS collection these *are* the
@@ -1093,7 +882,7 @@ function assertKnownWriteFields(values, collection, options) {
1093
882
  if (collection.strictWrites === false) return;
1094
883
  if (!collection.properties || Object.keys(collection.properties).length === 0) return;
1095
884
  const known = new Set(Object.keys(collection.properties));
1096
- 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));
1097
886
  for (const field of options?.extraKnownFields ?? []) known.add(field);
1098
887
  const unknown = Object.keys(values).filter((key) => !known.has(key));
1099
888
  if (unknown.length === 0) return;
@@ -1106,6 +895,145 @@ function assertKnownWriteFields(values, collection, options) {
1106
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");
1107
896
  }
1108
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
+ /**
1109
1037
  * Narrow response rows to the fields the caller asked for.
1110
1038
  *
1111
1039
  * `?fields=id,title` is documented in the generated OpenAPI — "Comma-separated
@@ -1136,7 +1064,7 @@ function projectResponseFields(rows, fields, collection, options) {
1136
1064
  const declared = new Set(Object.keys(collection.properties ?? {}));
1137
1065
  for (const [key, relation] of Object.entries(resolveCollectionRelations(collection))) {
1138
1066
  declared.add(key);
1139
- if (relation.kind === "belongsTo") declared.add(relation.localKey);
1067
+ if (relation.kind === "belongsTo") declared.add(fieldKeyForColumn(collection, relation.localKey));
1140
1068
  }
1141
1069
  for (const included of options?.include ?? []) declared.add(included);
1142
1070
  declared.add("id");
@@ -1173,6 +1101,9 @@ function projectResponseFields(rows, fields, collection, options) {
1173
1101
  * generated on the client, so keying on the id alone would let anyone who
1174
1102
  * learned (or guessed) another user's id replay their key and be handed that
1175
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.
1176
1107
  */
1177
1108
  var TABLE = "\"rebase\".\"idempotency_keys\"";
1178
1109
  /**
@@ -1183,15 +1114,75 @@ var TABLE = "\"rebase\".\"idempotency_keys\"";
1183
1114
  */
1184
1115
  var TTL_HOURS = 24;
1185
1116
  /**
1186
- * 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.
1136
+ *
1137
+ * `ensure`'s answer used to be memoised unconditionally, so one transient DDL
1138
+ * failure — a database still starting, a lock on the `rebase` schema, a sibling
1139
+ * pod running the same `CREATE TABLE IF NOT EXISTS` — disabled idempotency on
1140
+ * that pod for the life of the process after a single log line. A failure is
1141
+ * retried instead, and the cooldown is what stops every keyed request re-running
1142
+ * DDL against a database that is genuinely unhappy.
1143
+ */
1144
+ var ENSURE_RETRY_COOLDOWN_MS = 3e4;
1145
+ /**
1146
+ * The principal a key belongs to, or `undefined` when there is nobody to scope
1147
+ * it to and the key must therefore be ignored.
1187
1148
  *
1188
- * The NUL is written as an escape, not as a raw byte in the source. The
1189
- * sentinel itself is deliberate — a uid can never contain one — but written
1190
- * literally it makes this file test as binary, and every repo-wide grep then
1191
- * skips all 124 lines of it silently. Identical at runtime.
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.
1192
1155
  */
1193
1156
  function principal(uid) {
1194
- 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");
1195
1186
  }
1196
1187
  /**
1197
1188
  * Returns `undefined` when the driver cannot run SQL, which disables the whole
@@ -1208,9 +1199,19 @@ function createIdempotencyStore(driver) {
1208
1199
  if (!isSQLAdmin(admin)) return void 0;
1209
1200
  const exec = (sql, params) => admin.executeSql(sql, params ? { params } : void 0);
1210
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
+ };
1211
1210
  /** Created on first use: most deployments never send a key at all. */
1212
1211
  const ensure = () => {
1213
- ready ??= (async () => {
1212
+ if (ready) return ready;
1213
+ if (Date.now() < retryEnsureAt) return Promise.resolve(false);
1214
+ ready = (async () => {
1214
1215
  try {
1215
1216
  await exec("CREATE SCHEMA IF NOT EXISTS rebase");
1216
1217
  await exec(`
@@ -1222,29 +1223,44 @@ function createIdempotencyStore(driver) {
1222
1223
  PRIMARY KEY (uid, key)
1223
1224
  )
1224
1225
  `);
1226
+ await exec(`ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS fingerprint TEXT`);
1225
1227
  await exec(`CREATE INDEX IF NOT EXISTS idx_idempotency_created ON ${TABLE}(created_at)`);
1226
1228
  await exec(revokeInternalTableSql("rebase", "idempotency_keys"));
1227
1229
  return true;
1228
1230
  } catch (error) {
1231
+ retryEnsureAt = Date.now() + ENSURE_RETRY_COOLDOWN_MS;
1229
1232
  logger.warn("Idempotency keys unavailable — a replayed offline write may insert a duplicate row.", { detail: error instanceof Error ? error.message : String(error) });
1230
1233
  return false;
1231
1234
  }
1232
- })();
1235
+ })().then((ok) => {
1236
+ if (!ok) ready = void 0;
1237
+ return ok;
1238
+ });
1233
1239
  return ready;
1234
1240
  };
1235
1241
  return {
1236
- async claim(key, uid) {
1237
- 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" };
1238
1246
  try {
1239
- if ((await exec(`INSERT INTO ${TABLE} (key, uid, response, created_at)
1240
- 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())
1241
1249
  ON CONFLICT (uid, key) DO UPDATE
1242
- SET response = NULL, created_at = NOW()
1243
- WHERE ${TABLE}.created_at < NOW() - INTERVAL '${TTL_HOURS} hours'
1244
- RETURNING 1 AS claimed`, [key, principal(uid)])).length > 0) return { status: "claimed" };
1245
- const row = (await exec(`SELECT response, response IS NULL AS pending FROM ${TABLE}
1246
- 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];
1247
1262
  if (!row) return { status: "claimed" };
1263
+ if (row.fingerprint != null && row.fingerprint !== fingerprint) return { status: "mismatch" };
1248
1264
  if (row.pending) return { status: "in-flight" };
1249
1265
  return {
1250
1266
  status: "replay",
@@ -1255,10 +1271,11 @@ function createIdempotencyStore(driver) {
1255
1271
  }
1256
1272
  },
1257
1273
  async complete(key, uid, response) {
1258
- if (!key || !await ensure()) return;
1274
+ const owner = principal(uid);
1275
+ if (!key || !owner || !await ensure()) return;
1259
1276
  try {
1260
1277
  await exec(`UPDATE ${TABLE} SET response = $3::jsonb WHERE uid = $1 AND key = $2`, [
1261
- principal(uid),
1278
+ owner,
1262
1279
  key,
1263
1280
  JSON.stringify(response ?? null)
1264
1281
  ]);
@@ -1266,9 +1283,10 @@ function createIdempotencyStore(driver) {
1266
1283
  } catch {}
1267
1284
  },
1268
1285
  async release(key, uid) {
1269
- if (!key || !await ensure()) return;
1286
+ const owner = principal(uid);
1287
+ if (!key || !owner || !await ensure()) return;
1270
1288
  try {
1271
- 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]);
1272
1290
  } catch {}
1273
1291
  }
1274
1292
  };
@@ -1293,6 +1311,18 @@ async function parseJsonBody(c) {
1293
1311
  }
1294
1312
  }
1295
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
+ /**
1296
1326
  * Lightweight REST API generator that leverages existing Rebase DataDriver.
1297
1327
  * Supports `include` query parameter for eager-loading relations via Drizzle.
1298
1328
  */
@@ -1341,17 +1371,52 @@ var RestApiGenerator = class {
1341
1371
  this.createCollectionRoutes(collection);
1342
1372
  });
1343
1373
  this.createSubcollectionRoutes();
1374
+ this.createUnmatchedRoute();
1344
1375
  return this.router;
1345
1376
  }
1346
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
+ /**
1347
1412
  * Check API key permissions for a collection operation.
1348
1413
  * Throws 403 if the key doesn't have the required permission.
1349
1414
  * No-ops if the request is not authenticated via an API key.
1350
1415
  */
1351
- enforceApiKeyPermission(c, collectionSlug) {
1416
+ enforceApiKeyPermission(c, collectionSlug, operationOverride) {
1352
1417
  const apiKey = c.get("apiKey");
1353
1418
  if (!apiKey) return;
1354
- const operation = httpMethodToOperation(c.req.method);
1419
+ const operation = operationOverride ?? httpMethodToOperation(c.req.method);
1355
1420
  if (!isOperationAllowed(apiKey.permissions, collectionSlug, operation)) throw ApiError.forbidden(`API key does not have "${operation}" permission for collection "${collectionSlug}"`, "API_KEY_FORBIDDEN");
1356
1421
  }
1357
1422
  /**
@@ -1372,7 +1437,7 @@ var RestApiGenerator = class {
1372
1437
  * Needed so a nested write can be checked against a schema at all. Without
1373
1438
  * it these routes skipped `assertKnownWriteFields` entirely, which is why a
1374
1439
  * typo `POST /posts` rejected with a 400 while the same typo on
1375
- * `POST /authors/1/posts` reached the database.
1440
+ * `POST /authors/1/posts` was dropped from the statement and answered 201.
1376
1441
  *
1377
1442
  * Returns `undefined` rather than throwing when the path cannot be walked:
1378
1443
  * the driver raises the authoritative error a moment later, and duplicating
@@ -1466,13 +1531,18 @@ var RestApiGenerator = class {
1466
1531
  * failure, complete on success — and getting one of those steps wrong
1467
1532
  * is exactly the bug the key exists to prevent. Written once so the
1468
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.
1469
1538
  */
1470
- const withIdempotency = async (c, run, respond) => {
1539
+ const withIdempotency = async (c, body, run, respond) => {
1471
1540
  const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1472
1541
  const uid = c.get("user")?.uid;
1473
1542
  const store = this.idempotency();
1474
- const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid) : void 0;
1543
+ const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid, requestFingerprint(c.req.method, c.req.path, body)) : void 0;
1475
1544
  if (claimed?.status === "replay") return respond(claimed.response);
1545
+ if (claimed?.status === "mismatch") throw idempotencyKeyReused(idempotencyKey);
1476
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");
1477
1547
  let response;
1478
1548
  try {
@@ -1511,8 +1581,11 @@ var RestApiGenerator = class {
1511
1581
  });
1512
1582
  if (body.upsert !== void 0 && typeof body.upsert !== "boolean") throw ApiError.badRequest("`upsert` must be a boolean.", "INVALID_BULK_BODY");
1513
1583
  if (!driver.saveMany) throw ApiError.badRequest("This collection's data source does not support bulk writes.", "BULK_UNSUPPORTED");
1514
- rows.forEach((row, rowIndex) => assertKnownWriteFields(row, resolvedCollection, { rowIndex }));
1515
- return withIdempotency(c, async () => {
1584
+ rows.forEach((row, rowIndex) => {
1585
+ assertKnownWriteFields(row, resolvedCollection, { rowIndex });
1586
+ assertWriteValuesValid(row, resolvedCollection, { rowIndex });
1587
+ });
1588
+ return withIdempotency(c, body, async () => {
1516
1589
  const written = await driver.saveMany({
1517
1590
  path,
1518
1591
  rows,
@@ -1541,8 +1614,9 @@ var RestApiGenerator = class {
1541
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");
1542
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");
1543
1616
  assertKnownWriteFields(entry.data, resolvedCollection, { rowIndex });
1617
+ assertWriteValuesValid(entry.data, resolvedCollection, { rowIndex });
1544
1618
  });
1545
- return withIdempotency(c, async () => {
1619
+ return withIdempotency(c, body, async () => {
1546
1620
  const written = await driver.updateMany({
1547
1621
  path,
1548
1622
  updates: updates.map((entry) => ({
@@ -1558,7 +1632,7 @@ var RestApiGenerator = class {
1558
1632
  }, (result) => c.json(result));
1559
1633
  });
1560
1634
  this.router.post(`${basePath}/bulk/delete`, async (c) => {
1561
- this.enforceApiKeyPermission(c, collection.slug);
1635
+ this.enforceApiKeyPermission(c, collection.slug, "delete");
1562
1636
  const driver = this.getScopedDriver(c);
1563
1637
  const path = collection.slug;
1564
1638
  const body = await parseJsonBody(c);
@@ -1567,7 +1641,7 @@ var RestApiGenerator = class {
1567
1641
  if (ids.length === 0) return c.json({ meta: { deleted: 0 } });
1568
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");
1569
1643
  if (!driver.deleteMany) throw ApiError.badRequest("This collection's data source does not support bulk deletes.", "BULK_UNSUPPORTED");
1570
- return withIdempotency(c, async () => {
1644
+ return withIdempotency(c, body, async () => {
1571
1645
  await driver.deleteMany({
1572
1646
  path,
1573
1647
  ids,
@@ -1584,10 +1658,15 @@ var RestApiGenerator = class {
1584
1658
  const isAuth = collection.auth;
1585
1659
  const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
1586
1660
  const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
1587
- if (!isAuthCollection) assertKnownWriteFields(body, resolvedCollection);
1588
- else {
1661
+ if (!isAuthCollection) {
1662
+ assertKnownWriteFields(body, resolvedCollection);
1663
+ assertWriteValuesValid(body, resolvedCollection);
1664
+ } else {
1589
1665
  const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
1590
- 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
+ }
1591
1670
  }
1592
1671
  if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
1593
1672
  const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
@@ -1615,8 +1694,9 @@ var RestApiGenerator = class {
1615
1694
  const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
1616
1695
  const uid = c.get("user")?.uid;
1617
1696
  const store = this.idempotency();
1618
- 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;
1619
1698
  if (claimed?.status === "replay") return c.json(claimed.response, 201);
1699
+ if (claimed?.status === "mismatch") throw idempotencyKeyReused(idempotencyKey);
1620
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");
1621
1701
  let response;
1622
1702
  try {
@@ -1645,6 +1725,7 @@ var RestApiGenerator = class {
1645
1725
  })) throw ApiError.notFound("Entity not found");
1646
1726
  const body = await parseJsonBody(c);
1647
1727
  assertKnownWriteFields(body, resolvedCollection);
1728
+ assertWriteValuesValid(body, resolvedCollection);
1648
1729
  const entity = await driver.save({
1649
1730
  path: getCollectionDataPath(collection),
1650
1731
  id: String(id),
@@ -1808,7 +1889,10 @@ var RestApiGenerator = class {
1808
1889
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
1809
1890
  const body = await parseJsonBody(c);
1810
1891
  const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1811
- if (targetCollection) assertKnownWriteFields(body, targetCollection);
1892
+ if (targetCollection) {
1893
+ assertKnownWriteFields(body, targetCollection);
1894
+ assertWriteValuesValid(body, targetCollection);
1895
+ }
1812
1896
  const entity = await driver.save({
1813
1897
  path: parsed.collectionPath,
1814
1898
  values: body,
@@ -1827,7 +1911,10 @@ var RestApiGenerator = class {
1827
1911
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
1828
1912
  const body = await parseJsonBody(c);
1829
1913
  const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1830
- if (targetCollection) assertKnownWriteFields(body, targetCollection);
1914
+ if (targetCollection) {
1915
+ assertKnownWriteFields(body, targetCollection);
1916
+ assertWriteValuesValid(body, targetCollection);
1917
+ }
1831
1918
  const entity = await driver.save({
1832
1919
  path: parsed.collectionPath,
1833
1920
  id: parsed.id,
@@ -2047,7 +2134,7 @@ function configureMiddlewares(app, basePath, isProduction, config) {
2047
2134
  app.use(`${basePath}/*`, requestId());
2048
2135
  if (config.compression !== false) {
2049
2136
  app.use(`${basePath}/*`, responseCompression());
2050
- logger.info("Response compression enabled");
2137
+ logger.debug("Response compression enabled");
2051
2138
  }
2052
2139
  const maxBodySize = config.maxBodySize ?? 10 * 1024 * 1024;
2053
2140
  if (maxBodySize > 0) {
@@ -2060,11 +2147,11 @@ function configureMiddlewares(app, basePath, isProduction, config) {
2060
2147
  } }, 413);
2061
2148
  }
2062
2149
  }));
2063
- 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) });
2064
2151
  }
2065
2152
  if (config.csrf?.origin) {
2066
2153
  app.use(`${basePath}/*`, csrf({ origin: config.csrf.origin }));
2067
- logger.info("CSRF protection enabled");
2154
+ logger.debug("CSRF protection enabled");
2068
2155
  }
2069
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.");
2070
2157
  app.use(`${basePath}/*`, requestLogger());
@@ -2118,7 +2205,7 @@ var LocalStorageController = class {
2118
2205
  }
2119
2206
  /**
2120
2207
  * Get the full filesystem path for a storage path, with a traversal guard
2121
- * that keeps the result inside the bucket directory.
2208
+ * that keeps the result inside the storage root.
2122
2209
  *
2123
2210
  * Defaults the bucket the way `putObject` does.
2124
2211
  *
@@ -2129,10 +2216,22 @@ var LocalStorageController = class {
2129
2216
  * design), and `listObjects` returned an empty page. So the obvious
2130
2217
  * `putObject({ key })` → `getObject(key)` did not round-trip and nothing
2131
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.
2132
2230
  */
2133
2231
  getFullPath(storagePath, bucket) {
2134
- const bucketPath = path$2.join(this.basePath, bucket ?? "default");
2135
- 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);
2136
2235
  if (!resolved.startsWith(bucketPath + path$2.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
2137
2236
  return resolved;
2138
2237
  }
@@ -2148,7 +2247,7 @@ var LocalStorageController = class {
2148
2247
  }
2149
2248
  async putObject({ file, key, metadata, bucket }) {
2150
2249
  this.validateFile(file);
2151
- const usedBucket = bucket ?? "default";
2250
+ const usedBucket = bucket || "default";
2152
2251
  const fullStoragePath = key;
2153
2252
  const fullPath = this.getFullPath(fullStoragePath, usedBucket);
2154
2253
  await this.ensureDir(path$2.dirname(fullPath));
@@ -2378,38 +2477,63 @@ var VALID_FITS = /* @__PURE__ */ new Set([
2378
2477
  "outside"
2379
2478
  ]);
2380
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
+ /**
2381
2507
  * Parse transform options from URL query parameters.
2382
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".
2383
2514
  */
2384
2515
  function parseTransformOptions(query) {
2385
2516
  const opts = {};
2386
2517
  let hasTransform = false;
2387
2518
  if (query.width) {
2388
- const w = parseInt(query.width, 10);
2389
- if (!Number.isNaN(w) && w > 0) {
2390
- opts.width = Math.min(w, MAX_DIMENSION);
2391
- hasTransform = true;
2392
- }
2519
+ opts.width = boundedInt("width", query.width, 1, MAX_DIMENSION);
2520
+ hasTransform = true;
2393
2521
  }
2394
2522
  if (query.height) {
2395
- const h = parseInt(query.height, 10);
2396
- if (!Number.isNaN(h) && h > 0) {
2397
- opts.height = Math.min(h, MAX_DIMENSION);
2398
- hasTransform = true;
2399
- }
2523
+ opts.height = boundedInt("height", query.height, 1, MAX_DIMENSION);
2524
+ hasTransform = true;
2400
2525
  }
2401
2526
  if (query.quality) {
2402
- const q = parseInt(query.quality, 10);
2403
- if (!Number.isNaN(q)) {
2404
- opts.quality = Math.min(Math.max(q, MIN_QUALITY), MAX_QUALITY);
2405
- hasTransform = true;
2406
- }
2527
+ opts.quality = boundedInt("quality", query.quality, MIN_QUALITY, MAX_QUALITY);
2528
+ hasTransform = true;
2407
2529
  }
2408
- 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(", ")}.`);
2409
2532
  opts.format = query.format;
2410
2533
  hasTransform = true;
2411
2534
  }
2412
- 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(", ")}.`);
2413
2537
  opts.fit = query.fit;
2414
2538
  hasTransform = true;
2415
2539
  }
@@ -2427,9 +2551,72 @@ function isTransformableImage(contentType) {
2427
2551
  return contentType.startsWith("image/") && !contentType.includes("svg") && !contentType.includes("gif");
2428
2552
  }
2429
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
+ /**
2430
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.
2431
2615
  */
2432
2616
  async function transformImage(buffer, options) {
2617
+ return transformQueue.run(() => runTransform(buffer, options));
2618
+ }
2619
+ async function runTransform(buffer, options) {
2433
2620
  let pipeline = (await getSharp())(buffer);
2434
2621
  if (options.width || options.height) pipeline = pipeline.resize({
2435
2622
  width: options.width,
@@ -2556,6 +2743,7 @@ var TusHandler = class {
2556
2743
  this.cleanupTimer = setInterval(() => {
2557
2744
  this.cleanupStale();
2558
2745
  }, 6e4);
2746
+ this.cleanupTimer.unref?.();
2559
2747
  }
2560
2748
  /** Remove uploads that have been idle for longer than UPLOAD_EXPIRY_MS. */
2561
2749
  async cleanupStale() {
@@ -2615,7 +2803,14 @@ var TusHandler = class {
2615
2803
  } catch (err) {
2616
2804
  throw new ApiError(400, "INVALID_STORAGE_KEY", err instanceof InvalidStorageKeyError ? err.message : "Invalid storage key");
2617
2805
  }
2618
- if (this.authorizeUpload) await this.authorizeUpload(c, key, metadata.bucket || "default");
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);
2619
2814
  const filePath = join(this.tusDir, id);
2620
2815
  await writeFile(filePath, Buffer.alloc(0));
2621
2816
  const upload = {
@@ -2625,7 +2820,8 @@ var TusHandler = class {
2625
2820
  metadata,
2626
2821
  createdAt: Date.now(),
2627
2822
  filePath,
2628
- bucket: metadata.bucket || void 0,
2823
+ bucket,
2824
+ storageId,
2629
2825
  key,
2630
2826
  completed: false
2631
2827
  };
@@ -2697,16 +2893,32 @@ var TusHandler = class {
2697
2893
  });
2698
2894
  }
2699
2895
  /**
2700
- * 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.
2701
2911
  */
2702
2912
  async finalize(upload) {
2703
- upload.completed = true;
2704
- const storageId = upload.metadata.storageId;
2913
+ const storageId = upload.storageId;
2705
2914
  let targetController = this.storageController;
2706
2915
  if (this.storageRegistry) targetController = storageId ? this.storageRegistry.getOrDefault(storageId) : this.storageRegistry.getDefault();
2707
2916
  if (!targetController) {
2708
- logger.warn("[TUS] Upload completed but no StorageController configured. Temp file remains:", { filePath: upload.filePath });
2709
- 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.");
2710
2922
  }
2711
2923
  try {
2712
2924
  const { readFile } = await import("fs/promises");
@@ -2719,6 +2931,7 @@ var TusHandler = class {
2719
2931
  key: fileName,
2720
2932
  bucket: upload.bucket
2721
2933
  });
2934
+ upload.completed = true;
2722
2935
  try {
2723
2936
  await unlink(upload.filePath);
2724
2937
  } catch {}
@@ -2726,6 +2939,8 @@ var TusHandler = class {
2726
2939
  logger.info(`[TUS] Upload ${upload.id} finalized → ${fileName}`, storageId ? { storageId } : {});
2727
2940
  } catch (err) {
2728
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)));
2729
2944
  }
2730
2945
  }
2731
2946
  };
@@ -2742,6 +2957,82 @@ var TusHandler = class {
2742
2957
  /** Shared image transform cache (LRU, 500 entries, 1 hour TTL). */
2743
2958
  var transformCache = new TransformCache();
2744
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
+ /**
2745
3036
  * Extract the wildcard portion of a route path from the full request path.
2746
3037
  *
2747
3038
  * Hono's `c.req.param('*')` does not work reliably in sub-routers mounted
@@ -2778,6 +3069,33 @@ function canonicalKeyOrBadRequest(key) {
2778
3069
  }
2779
3070
  }
2780
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
+ }
3097
+ }
3098
+ /**
2781
3099
  * Build adapter-aware auth middleware for storage routes.
2782
3100
  *
2783
3101
  * When an `AuthAdapter` is provided, token verification is delegated to the
@@ -2886,8 +3204,9 @@ function createStorageRoutes(config) {
2886
3204
  *
2887
3205
  * A key that cannot be canonicalized (a real `..` segment, a null byte) is
2888
3206
  * a 400, not a repaired key. The `LocalStorageController` traversal guard
2889
- * (`getFullPath`) is still the load-bearing defence for the *bucket*
2890
- * boundary; this is what defends the boundary the hook drew inside it.
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.
2891
3210
  */
2892
3211
  const parseBucketAndPath = (filePath) => {
2893
3212
  const parts = filePath.split("/");
@@ -2910,7 +3229,7 @@ function createStorageRoutes(config) {
2910
3229
  const uploadedFile = body["file"];
2911
3230
  if (!uploadedFile || typeof uploadedFile === "string") throw ApiError.badRequest("No file provided");
2912
3231
  const key = typeof body["key"] === "string" ? body["key"] : "";
2913
- const bucket = typeof body["bucket"] === "string" ? body["bucket"] : void 0;
3232
+ const bucket = canonicalBucketOrBadRequest(typeof body["bucket"] === "string" ? body["bucket"] : void 0);
2914
3233
  const storageId = typeof body["storageId"] === "string" ? body["storageId"] : c.req.query("storageId");
2915
3234
  const finalKey = canonicalKeyOrBadRequest(key || uploadedFile.name || "unnamed");
2916
3235
  const metadata = {};
@@ -2938,15 +3257,13 @@ function createStorageRoutes(config) {
2938
3257
  const filePath = decodeURIComponent(rawPath);
2939
3258
  const storageId = c.req.query("storageId");
2940
3259
  const resolved = resolveController(storageId);
2941
- {
2942
- const { bucket, resolvedPath } = parseBucketAndPath(filePath);
2943
- await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
2944
- }
2945
- 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());
2946
3265
  if (resolved.getType() === "local") {
2947
- const localController = resolved;
2948
- const { bucket, resolvedPath } = parseBucketAndPath(filePath);
2949
- const absolutePath = localController.getAbsolutePath(resolvedPath, bucket);
3266
+ const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
2950
3267
  try {
2951
3268
  await fsp.access(absolutePath);
2952
3269
  } catch {
@@ -2958,37 +3275,30 @@ function createStorageRoutes(config) {
2958
3275
  const metadataRaw = await fsp.readFile(metadataPath, "utf-8");
2959
3276
  contentType = JSON.parse(metadataRaw).contentType || contentType;
2960
3277
  } catch {}
2961
- const fileContent = await fsp.readFile(absolutePath);
2962
3278
  if (transformOpts && isTransformableImage(contentType)) {
2963
- const cacheKey = transformCache.buildKey(filePath, transformOpts);
2964
- let cached = transformCache.get(cacheKey);
2965
- if (!cached) {
2966
- cached = await transformImage(Buffer.from(fileContent), transformOpts);
2967
- transformCache.set(cacheKey, cached.data, cached.contentType);
2968
- }
2969
- 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);
2970
3281
  c.header("Cache-Control", "public, max-age=31536000, immutable");
2971
- return c.body(new Uint8Array(cached.data));
3282
+ return c.body(new Uint8Array(transformed.data));
2972
3283
  }
2973
- 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");
2974
3288
  return c.body(new Uint8Array(fileContent));
2975
3289
  }
2976
- const { bucket: parsedBucket, resolvedPath: parsedPath } = parseBucketAndPath(filePath);
2977
- const fileObject = await resolved.getObject(parsedPath, parsedBucket);
3290
+ const fileObject = await resolved.getObject(resolvedPath, bucket);
2978
3291
  if (!fileObject) throw ApiError.notFound("File not found");
2979
3292
  const remoteContentType = fileObject.type || "application/octet-stream";
2980
3293
  if (transformOpts && isTransformableImage(remoteContentType)) {
2981
- const cacheKey = transformCache.buildKey(filePath, transformOpts);
2982
- let cached = transformCache.get(cacheKey);
2983
- if (!cached) {
2984
- cached = await transformImage(Buffer.from(await fileObject.arrayBuffer()), transformOpts);
2985
- transformCache.set(cacheKey, cached.data, cached.contentType);
2986
- }
2987
- 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);
2988
3296
  c.header("Cache-Control", "public, max-age=31536000, immutable");
2989
- return c.body(new Uint8Array(cached.data));
3297
+ return c.body(new Uint8Array(transformed.data));
2990
3298
  }
2991
- 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");
2992
3302
  c.header("Cache-Control", "public, max-age=3600, immutable");
2993
3303
  const buf = await fileObject.arrayBuffer();
2994
3304
  return c.body(new Uint8Array(buf));
@@ -3048,7 +3358,7 @@ function createStorageRoutes(config) {
3048
3358
  */
3049
3359
  router.get("/list", writeAuthMiddleware, async (c) => {
3050
3360
  const storagePrefix = canonicalKeyOrBadRequest(c.req.query("prefix") || c.req.query("path") || "");
3051
- const bucket = c.req.query("bucket");
3361
+ const bucket = canonicalBucketOrBadRequest(c.req.query("bucket"));
3052
3362
  const maxResults = c.req.query("maxResults");
3053
3363
  const pageToken = c.req.query("pageToken");
3054
3364
  const storageId = c.req.query("storageId");
@@ -3094,8 +3404,8 @@ function createStorageRoutes(config) {
3094
3404
  }, 201);
3095
3405
  });
3096
3406
  const defaultCtrl = getDefaultController();
3097
- const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket) => {
3098
- await checkAuthorized(c, "write", 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);
3099
3409
  } : void 0);
3100
3410
  tusHandler.startCleanup();
3101
3411
  router.options("/tus", (_c) => tusHandler.options());
@@ -3232,7 +3542,7 @@ async function createStorageController(config) {
3232
3542
  //#region src/init/storage.ts
3233
3543
  async function initializeStorage(storageConfig, isProduction) {
3234
3544
  if (!storageConfig) return {};
3235
- logger.info("Configuring storage");
3545
+ logger.debug("Configuring storage");
3236
3546
  const controllers = {};
3237
3547
  const toController = async (entry, label) => {
3238
3548
  if (typeof entry.putObject === "function") return entry;
@@ -3289,11 +3599,20 @@ function assertStorageAccessControlConfigured(state, isProduction) {
3289
3599
  }
3290
3600
  //#endregion
3291
3601
  //#region src/init/docs.ts
3292
- async function mountOpenApiDocs(app, basePath, enableSwagger, activeCollections, requireAuth) {
3293
- if (enableSwagger === false || activeCollections.length === 0) return;
3294
- const { generateOpenApiSpec } = await import("./openapi-generator-VB0MUH8h.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");
3295
3614
  app.get(`${basePath}/docs`, (c) => {
3296
- const spec = generateOpenApiSpec(activeCollections, {
3615
+ const spec = generateOpenApiSpec(serverCollections, {
3297
3616
  basePath,
3298
3617
  requireAuth
3299
3618
  });
@@ -3468,6 +3787,54 @@ function createShutdown(config) {
3468
3787
  };
3469
3788
  }
3470
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
3471
3838
  //#region src/auth/collection-callback-warning.ts
3472
3839
  /** The data callbacks the auth write path does not run. */
3473
3840
  var DATA_CALLBACKS = [
@@ -3674,6 +4041,23 @@ function createTransport(config, environment) {
3674
4041
  ...init?.headers || {}
3675
4042
  };
3676
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
+ }
3677
4061
  async function request(path, init) {
3678
4062
  const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
3679
4063
  let activeToken = token;
@@ -3691,9 +4075,27 @@ function createTransport(config, environment) {
3691
4075
  if (res.status === 204) return void 0;
3692
4076
  const text = await res.text().catch(() => "");
3693
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;
3694
4094
  if (text) try {
3695
4095
  body = JSON.parse(text, rebaseReviver);
3696
- } catch (e) {}
4096
+ } catch (e) {
4097
+ unreadableBody = true;
4098
+ }
3697
4099
  const getErrorField = (obj, field) => {
3698
4100
  const err = obj?.error;
3699
4101
  if (err && typeof err === "object" && err !== null) return err[field];
@@ -3713,9 +4115,12 @@ function createTransport(config, environment) {
3713
4115
  if (retryRes.status === 204) return void 0;
3714
4116
  const retryText = await retryRes.text().catch(() => "");
3715
4117
  let retryBody = {};
4118
+ let retryUnreadable = false;
3716
4119
  if (retryText) try {
3717
4120
  retryBody = JSON.parse(retryText, rebaseReviver);
3718
- } catch (e) {}
4121
+ } catch (e) {
4122
+ retryUnreadable = true;
4123
+ }
3719
4124
  if (!retryRes.ok) {
3720
4125
  let fallbackMessage = retryRes.statusText;
3721
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.`;
@@ -3725,6 +4130,7 @@ function createTransport(config, environment) {
3725
4130
  details: getErrorField(retryBody, "details")
3726
4131
  });
3727
4132
  }
4133
+ if (retryUnreadable) throw unreadableResponse(retryRes.status, retryText);
3728
4134
  return retryBody;
3729
4135
  }
3730
4136
  }
@@ -3737,6 +4143,7 @@ function createTransport(config, environment) {
3737
4143
  details: getErrorField(body, "details")
3738
4144
  });
3739
4145
  }
4146
+ if (unreadableBody) throw unreadableResponse(res.status, text);
3740
4147
  return body;
3741
4148
  }
3742
4149
  return {
@@ -5287,9 +5694,11 @@ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
5287
5694
  *
5288
5695
  * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
5289
5696
  * manages this internally (exposed as `client.ws`, typed by the minimal
5290
- * `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
5291
- * package root only because the `@rebasepro/client-postgres` driver
5292
- * 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.
5293
5702
  */
5294
5703
  var RebaseWebSocketClient = class {
5295
5704
  websocketUrl;
@@ -5799,6 +6208,11 @@ var RebaseWebSocketClient = class {
5799
6208
  callback.onError(new RebaseApiError(errorMessage, { code: errorCode }));
5800
6209
  }
5801
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}`);
5802
6216
  }
5803
6217
  }
5804
6218
  async ensureAuthenticated(retryCount = 3) {
@@ -6905,11 +7319,29 @@ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
6905
7319
  503,
6906
7320
  504
6907
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
+ }
6908
7339
  /** Is this failure worth another attempt later? */
6909
7340
  function isRetryableError(error) {
6910
7341
  if (isNetworkError(error)) return true;
6911
- if (error instanceof RebaseApiError) return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
6912
- 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);
6913
7345
  }
6914
7346
  /**
6915
7347
  * Did this write fail because the row is already there?
@@ -6921,10 +7353,16 @@ function isRetryableError(error) {
6921
7353
  * The queue uses this to recognise its own earlier attempt. A create whose
6922
7354
  * response was lost is replayed, and for a row carrying an id the SDK generated
6923
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.
6924
7361
  */
6925
7362
  function isDuplicateKeyError(error) {
6926
7363
  if (!(error instanceof RebaseApiError)) return false;
6927
- return error.code === "23505" || error.status === 409;
7364
+ if (error.code === "23505") return true;
7365
+ return error.status === 409 && !isIdempotencyInProgressError(error);
6928
7366
  }
6929
7367
  var ConnectivityMonitor = class {
6930
7368
  state = "online";
@@ -7494,6 +7932,25 @@ function resolvePagination(params) {
7494
7932
  offset
7495
7933
  };
7496
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
+ }
7497
7954
  /**
7498
7955
  * Can a locally evaluated answer to `params` be trusted to match the server's,
7499
7956
  * assuming the cache holds every row of the collection?
@@ -7501,12 +7958,67 @@ function resolvePagination(params) {
7501
7958
  * `include` pulls in rows from other collections that this evaluator never
7502
7959
  * sees, and `searchString` is only approximated — both make the local answer a
7503
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}.
7504
7983
  */
7505
7984
  function isExactlyEvaluable(params) {
7506
7985
  if (!params) return true;
7507
7986
  if (params.include && params.include.length > 0) return false;
7508
7987
  if (params.searchString) return false;
7509
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
+ }
7510
8022
  return true;
7511
8023
  }
7512
8024
  /** Run a full query — filter, sort, paginate — over a set of rows. */
@@ -7536,6 +8048,17 @@ function generateOfflineId() {
7536
8048
  return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
7537
8049
  }
7538
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;
7539
8062
  var OfflineManager = class {
7540
8063
  store;
7541
8064
  maxCachedQueries;
@@ -8225,7 +8748,8 @@ var OfflineManager = class {
8225
8748
  rows.push(entry.row);
8226
8749
  added++;
8227
8750
  }
8228
- if (params?.orderBy) sortRows(rows, params.orderBy);
8751
+ const orderIsLocal = isLocallySortable(rows, params?.orderBy);
8752
+ if (params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
8229
8753
  return {
8230
8754
  data: rows,
8231
8755
  meta: {
@@ -8236,7 +8760,7 @@ var OfflineManager = class {
8236
8760
  },
8237
8761
  fromCache,
8238
8762
  hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id)),
8239
- partial: !exact
8763
+ partial: !exact || !orderIsLocal
8240
8764
  };
8241
8765
  }
8242
8766
  localFind(slug, params) {
@@ -8551,7 +9075,8 @@ var OfflineManager = class {
8551
9075
  }
8552
9076
  op.attempts = (op.attempts ?? 0) + 1;
8553
9077
  op.lastError = error?.message ?? String(error);
8554
- 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) {
8555
9080
  await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
8556
9081
  this.connectivity.deferRetry();
8557
9082
  this.patchStatus({ lastError: op.lastError });
@@ -9083,6 +9608,10 @@ channel: (name, options) => {
9083
9608
  }
9084
9609
  //#endregion
9085
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;
9086
9615
  function createHistoryRoutes(params) {
9087
9616
  const { historyService, registry } = params;
9088
9617
  const router = new Hono();
@@ -9131,22 +9660,24 @@ function createHistoryRoutes(params) {
9131
9660
  * GET /:slug/:id/history - List history entries for a entity
9132
9661
  *
9133
9662
  * Query params:
9134
- * limit (default 20)
9663
+ * limit (default 20, maximum 100 — above that the request is refused)
9135
9664
  * offset (default 0)
9136
9665
  */
9137
9666
  router.get("/:slug/:id/history", async (c) => {
9138
9667
  const slug = c.req.param("slug");
9139
9668
  const id = c.req.param("id");
9140
- 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
+ });
9141
9673
  const parsedOffset = parseInt(c.req.query("offset") ?? "0", 10);
9142
- const limit = Number.isNaN(parsedLimit) ? 20 : parsedLimit;
9143
9674
  const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;
9144
9675
  const collection = registry.getCollections().find((col) => col.slug === slug || false);
9145
9676
  if (!collection) throw ApiError.notFound(`Collection '${slug}' not found`);
9146
9677
  if (!collection.history) throw ApiError.badRequest(`History is not enabled for collection '${slug}'`);
9147
9678
  await authorizeEntityRead(c, collection, id);
9148
9679
  const tableName = collection.slug;
9149
- const appliedLimit = Math.min(limit, 100);
9680
+ const appliedLimit = limit;
9150
9681
  const appliedOffset = Math.max(offset, 0);
9151
9682
  const result = await historyService.fetchHistory(tableName, id, {
9152
9683
  limit: appliedLimit,
@@ -9197,126 +9728,6 @@ function createHistoryRoutes(params) {
9197
9728
  return router;
9198
9729
  }
9199
9730
  //#endregion
9200
- //#region src/email/smtp-email-service.ts
9201
- var _nodemailer;
9202
- async function loadNodemailer() {
9203
- if (!_nodemailer) try {
9204
- _nodemailer = await import("nodemailer");
9205
- } catch {
9206
- throw new Error("nodemailer is required for SMTP email. Install it: pnpm add nodemailer");
9207
- }
9208
- return _nodemailer;
9209
- }
9210
- /**
9211
- * Safely parse a hostname from a URL string
9212
- */
9213
- function getHostname(urlStr) {
9214
- try {
9215
- return new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`).hostname;
9216
- } catch {
9217
- return;
9218
- }
9219
- }
9220
- /**
9221
- * SMTP Email Service implementation using Nodemailer
9222
- */
9223
- var SMTPEmailService = class {
9224
- transporter = null;
9225
- config;
9226
- _initialized = false;
9227
- constructor(config) {
9228
- this.config = config;
9229
- }
9230
- /**
9231
- * Lazily initialize the SMTP transporter on first use
9232
- */
9233
- async ensureTransporter() {
9234
- if (this._initialized) return;
9235
- this._initialized = true;
9236
- if (this.config.smtp) {
9237
- const nodemailer = await loadNodemailer();
9238
- let smtpName = this.config.smtp.name;
9239
- if (!smtpName) {
9240
- const urlsToTry = [
9241
- process.env.FRONTEND_URL,
9242
- this.config.resetPasswordUrl,
9243
- this.config.verifyEmailUrl
9244
- ];
9245
- for (const urlStr of urlsToTry) if (urlStr) {
9246
- const hostname = getHostname(urlStr);
9247
- if (hostname) {
9248
- smtpName = hostname;
9249
- break;
9250
- }
9251
- }
9252
- }
9253
- this.transporter = nodemailer.createTransport({
9254
- name: smtpName,
9255
- host: this.config.smtp.host,
9256
- port: this.config.smtp.port,
9257
- secure: this.config.smtp.secure ?? this.config.smtp.port === 465,
9258
- auth: this.config.smtp.auth ? {
9259
- user: this.config.smtp.auth.user,
9260
- pass: this.config.smtp.auth.pass
9261
- } : void 0
9262
- });
9263
- }
9264
- }
9265
- /**
9266
- * Check if the email service is properly configured
9267
- */
9268
- isConfigured() {
9269
- return !!(this.config.smtp || this.config.sendEmail);
9270
- }
9271
- /**
9272
- * Send an email using SMTP or custom send function
9273
- */
9274
- async send(options) {
9275
- if (this.config.sendEmail) {
9276
- await this.config.sendEmail(options);
9277
- return;
9278
- }
9279
- await this.ensureTransporter();
9280
- if (!this.transporter) throw new Error("Email service not configured. Provide SMTP config or sendEmail function.");
9281
- const to = Array.isArray(options.to) ? options.to.join(", ") : options.to;
9282
- try {
9283
- await this.transporter.sendMail({
9284
- from: this.config.from,
9285
- to,
9286
- subject: options.subject,
9287
- html: options.html,
9288
- text: options.text,
9289
- replyTo: options.replyTo
9290
- });
9291
- } catch (error) {
9292
- const message = error instanceof Error ? error.message : String(error);
9293
- logger.error("Failed to send email", { detail: message });
9294
- throw new Error(`Failed to send email: ${message}`);
9295
- }
9296
- }
9297
- /**
9298
- * Verify SMTP connection (useful for startup checks)
9299
- */
9300
- async verifyConnection() {
9301
- await this.ensureTransporter();
9302
- if (!this.transporter) return !!this.config.sendEmail;
9303
- try {
9304
- await this.transporter.verify();
9305
- return true;
9306
- } catch (error) {
9307
- const message = error instanceof Error ? error.message : String(error);
9308
- logger.error("SMTP connection verification failed", { detail: message });
9309
- return false;
9310
- }
9311
- }
9312
- };
9313
- /**
9314
- * Create an email service from configuration
9315
- */
9316
- function createEmailService(config) {
9317
- return new SMTPEmailService(config);
9318
- }
9319
- //#endregion
9320
9731
  //#region src/singleton.ts
9321
9732
  /**
9322
9733
  * The backing instance lives on a process-global slot, NOT in a module-local
@@ -9377,14 +9788,30 @@ function _resetRebaseMock() {
9377
9788
  * **Admin data plane** (`rebase.dataAsAdmin`):
9378
9789
  * Backed by the native DataDriver — calls go directly to the database without
9379
9790
  * JSON serialization, HTTP dispatch, or middleware overhead. The driver is
9380
- * scoped as `{ uid: "service", roles: ["admin"] }`, so **every read and write
9381
- * bypasses row-level-security policies**. No `REBASE_SERVICE_KEY` is required.
9382
- *
9383
- * ⚠️ Because it bypasses RLS, `rebase.dataAsAdmin` is for trusted background
9384
- * work (cron jobs, migrations, service tasks) — **not** for serving user-facing
9385
- * data. Inside a request handler, run user-scoped queries through the
9386
- * request-scoped driver (`c.var.driver`), which carries the caller's identity
9387
- * 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.
9388
9815
  *
9389
9816
  * `rebase.data` is **gone from the type**: `RebaseServerClient` omits it, so the
9390
9817
  * admin-scoped accessor has exactly one name and the privilege is visible at the
@@ -9400,7 +9827,8 @@ function _resetRebaseMock() {
9400
9827
  * ```typescript
9401
9828
  * import { rebase } from "@rebasepro/server";
9402
9829
  *
9403
- * // 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"] }`):
9404
9832
  * await rebase.email.send({ to: "admin@co.com", subject: "Alert", html: "<p>Hi</p>" });
9405
9833
  * const jobs = await rebase.dataAsAdmin.jobs.find({ limit: 10 });
9406
9834
  * ```
@@ -9514,7 +9942,8 @@ async function initializeRebaseBackend(config) {
9514
9942
  async function _initializeRebaseBackend(config) {
9515
9943
  if (config.logging?.level) configureLogLevel(config.logging.level);
9516
9944
  else configureLogLevel();
9517
- logger.info("Initializing Rebase Backend");
9945
+ logger.debug("Initializing Rebase Backend");
9946
+ installUnhandledRejectionHandler();
9518
9947
  const basePath = config.basePath || "/api";
9519
9948
  const isProduction = process.env.NODE_ENV === "production";
9520
9949
  configureMiddlewares(config.app, basePath, isProduction, config);
@@ -9532,13 +9961,14 @@ async function _initializeRebaseBackend(config) {
9532
9961
  });
9533
9962
  }
9534
9963
  const introspectCollections = activeCollections.length === 0;
9535
- 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");
9536
9966
  const realtimeServices = {};
9537
9967
  const delegates = {};
9538
9968
  let bootstrappers = config.bootstrappers || [];
9539
9969
  if (config.database) {
9540
9970
  const dbAdapter = config.database;
9541
- logger.info("Using DatabaseAdapter", { type: dbAdapter.type });
9971
+ logger.debug("Using DatabaseAdapter", { type: dbAdapter.type });
9542
9972
  bootstrappers = [wrapDatabaseAdapter(dbAdapter)];
9543
9973
  }
9544
9974
  if (bootstrappers.length === 0) throw new Error("No bootstrappers or database adapter provided. Cannot initialize database drivers.");
@@ -9546,7 +9976,7 @@ async function _initializeRebaseBackend(config) {
9546
9976
  let defaultDriverResult = void 0;
9547
9977
  for (const bootstrapper of bootstrappers) {
9548
9978
  const b = bootstrapper;
9549
- logger.info("Running bootstrapper for driver", { driverId: b.id || bootstrapper.type });
9979
+ logger.debug("Running bootstrapper for driver", { driverId: b.id || bootstrapper.type });
9550
9980
  if (b.isDefault) defaultDriverId = b.id || bootstrapper.type;
9551
9981
  const driverResult = await bootstrapper.initializeDriver({
9552
9982
  collections: activeCollections,
@@ -9604,7 +10034,7 @@ async function _initializeRebaseBackend(config) {
9604
10034
  authAdapter = config.auth;
9605
10035
  serviceKey = authAdapter.serviceKey;
9606
10036
  if (authAdapter.initialize) await authAdapter.initialize();
9607
- logger.info("Using AuthAdapter", { id: authAdapter.id });
10037
+ logger.debug("Using AuthAdapter", { id: authAdapter.id });
9608
10038
  authConfigResult = { userService: authAdapter.userManagement ?? {} };
9609
10039
  } else {
9610
10040
  const safeAuthConfig = config.auth;
@@ -9615,7 +10045,7 @@ async function _initializeRebaseBackend(config) {
9615
10045
  });
9616
10046
  if (foundAuthCollection) {
9617
10047
  safeAuthConfig.collection = foundAuthCollection;
9618
- 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 });
9619
10049
  }
9620
10050
  }
9621
10051
  if (safeAuthConfig.collection) {
@@ -9632,17 +10062,17 @@ async function _initializeRebaseBackend(config) {
9632
10062
  if (safeAuthConfig.serviceKey) {
9633
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'))\"");
9634
10064
  serviceKey = safeAuthConfig.serviceKey;
9635
- logger.info("Service key configured for script/server-to-server authentication");
10065
+ logger.debug("Service key configured for script/server-to-server authentication");
9636
10066
  }
9637
10067
  if (defaultBootstrapper.initializeAuth) {
9638
- logger.info("Bootstrapping authentication via driver protocol");
10068
+ logger.debug("Bootstrapping authentication via driver protocol");
9639
10069
  authConfigResult = await defaultBootstrapper.initializeAuth(config.auth, defaultDriverResult);
9640
10070
  logger.info("Authentication initialized");
9641
10071
  } else logger.warn("Auth requested but default bootstrapper does not support initializeAuth");
9642
10072
  }
9643
10073
  let historyConfigResult = void 0;
9644
10074
  if (config.history) if (defaultBootstrapper.initializeHistory) {
9645
- logger.info("Bootstrapping entity history via driver protocol");
10075
+ logger.debug("Bootstrapping entity history via driver protocol");
9646
10076
  historyConfigResult = await defaultBootstrapper.initializeHistory(config.history, defaultDriverResult);
9647
10077
  if (historyConfigResult?.historyService && defaultDriverResult.internals) {
9648
10078
  const driver = defaultDriverResult.internals.driver;
@@ -9658,7 +10088,7 @@ async function _initializeRebaseBackend(config) {
9658
10088
  if (apiKeyStoreResult) {
9659
10089
  apiKeyStore = apiKeyStoreResult;
9660
10090
  await apiKeyStore.ensureTable();
9661
- logger.info("Service API Keys initialized");
10091
+ logger.debug("Service API Keys initialized");
9662
10092
  }
9663
10093
  const apiKeyPreAuth = apiKeyStore ? createApiKeyPreAuth({
9664
10094
  store: apiKeyStore,
@@ -9671,7 +10101,7 @@ async function _initializeRebaseBackend(config) {
9671
10101
  serviceKey: internalServiceKey
9672
10102
  });
9673
10103
  config.app.route(`${basePath}/admin/api-keys`, apiKeyRoutes);
9674
- 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` });
9675
10105
  }
9676
10106
  const rateLimitConfig = config.rateLimit?.enabled !== false ? {
9677
10107
  ...config.rateLimit,
@@ -9755,7 +10185,7 @@ async function _initializeRebaseBackend(config) {
9755
10185
  ]) {
9756
10186
  const providerConfig = safeAuthConfig[key];
9757
10187
  if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
9758
- const createFn = (await import("./auth-BANhRhN9.js").then((n) => n.t))[factory];
10188
+ const createFn = (await import("./auth-CYoPVf-E.js").then((n) => n.t))[factory];
9759
10189
  oauthProviders.push(createFn(providerConfig));
9760
10190
  }
9761
10191
  }
@@ -9767,9 +10197,11 @@ async function _initializeRebaseBackend(config) {
9767
10197
  emailConfig: safeAuthConfig.email,
9768
10198
  allowRegistration: safeAuthConfig.allowRegistration ?? false,
9769
10199
  disableSelfRegistration: safeAuthConfig.disableSelfRegistration ?? false,
10200
+ allowAnonymous: safeAuthConfig.allowAnonymous ?? false,
9770
10201
  allowUserLookup: safeAuthConfig.allowUserLookup ?? false,
9771
10202
  defaultRole: safeAuthConfig.defaultRole,
9772
10203
  oauthProviders,
10204
+ allowedRedirectUris: safeAuthConfig.allowedRedirectUris,
9773
10205
  serviceKey: serviceKey || internalServiceKey,
9774
10206
  authHooks: safeAuthConfig.hooks,
9775
10207
  collectionAuthConfig,
@@ -9784,14 +10216,14 @@ async function _initializeRebaseBackend(config) {
9784
10216
  const authRoutes = authAdapter.createAuthRoutes();
9785
10217
  if (authRoutes) {
9786
10218
  config.app.route(`${basePath}/auth`, authRoutes);
9787
- logger.info("Auth routes mounted via adapter", { adapter: authAdapter.id });
10219
+ logger.debug("Auth routes mounted via adapter", { adapter: authAdapter.id });
9788
10220
  }
9789
10221
  }
9790
10222
  if (authAdapter && authAdapter.createAdminRoutes) {
9791
10223
  const adminRoutes = authAdapter.createAdminRoutes();
9792
10224
  if (adminRoutes) {
9793
10225
  config.app.route(`${basePath}/admin`, adminRoutes);
9794
- logger.info("Admin routes mounted via adapter", { adapter: authAdapter.id });
10226
+ logger.debug("Admin routes mounted via adapter", { adapter: authAdapter.id });
9795
10227
  }
9796
10228
  }
9797
10229
  }
@@ -9849,7 +10281,7 @@ async function _initializeRebaseBackend(config) {
9849
10281
  let schemaEditorOff = schemaEditorUnavailable();
9850
10282
  let schemaEditorRoutes;
9851
10283
  if (!schemaEditorOff && config.collectionsDir) try {
9852
- schemaEditorRoutes = (await import("./schema-editor-routes-BqjzvvWU.js")).createSchemaEditorRoutes(config.collectionsDir);
10284
+ schemaEditorRoutes = (await import("./schema-editor-routes-CRcS3ArS.js")).createSchemaEditorRoutes(config.collectionsDir);
9853
10285
  } catch (err) {
9854
10286
  if (err?.code === "ERR_MODULE_NOT_FOUND") {
9855
10287
  schemaEditorOff = {
@@ -9873,7 +10305,7 @@ async function _initializeRebaseBackend(config) {
9873
10305
  message: schemaEditorOff.message
9874
10306
  } }, 501));
9875
10307
  config.app.route(`${basePath}/schema-editor`, schemaEditorRouter);
9876
- if (schemaEditorRoutes) logger.info("Schema Editor mounted", { path: `${basePath}/schema-editor` });
10308
+ if (schemaEditorRoutes) logger.debug("Schema Editor mounted", { path: `${basePath}/schema-editor` });
9877
10309
  else logger.debug("Schema Editor unavailable", {
9878
10310
  path: `${basePath}/schema-editor`,
9879
10311
  code: schemaEditorOff.code
@@ -9899,6 +10331,7 @@ async function _initializeRebaseBackend(config) {
9899
10331
  });
9900
10332
  const storageRouter = new Hono();
9901
10333
  if (apiKeyPreAuth) storageRouter.use("/*", apiKeyPreAuth, createStorageApiKeyGuard());
10334
+ if (rateLimitConfig) storageRouter.use("/*", createDataRateLimiter(rateLimitConfig));
9902
10335
  storageRouter.use("/upload", bodyLimit({
9903
10336
  maxSize: storageMaxSize,
9904
10337
  onError: (c) => {
@@ -9919,6 +10352,7 @@ async function _initializeRebaseBackend(config) {
9919
10352
  config.app.route(`${basePath}/storage`, storageStub);
9920
10353
  logger.info("Storage not configured — /storage returns 501 STORAGE_NOT_CONFIGURED");
9921
10354
  }
10355
+ const serverCollections = activeCollections.filter((collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server");
9922
10356
  if (activeCollections.length > 0) {
9923
10357
  const dataRouter = new Hono();
9924
10358
  dataRouter.onError(errorHandler);
@@ -9926,7 +10360,10 @@ async function _initializeRebaseBackend(config) {
9926
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.");
9927
10361
  else {
9928
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);
9929
- 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
+ }
9930
10367
  }
9931
10368
  const dataPathMarker = `${basePath}/data/`;
9932
10369
  const resolveRequestDriver = (reqPath) => {
@@ -9960,24 +10397,21 @@ async function _initializeRebaseBackend(config) {
9960
10397
  });
9961
10398
  dataRouter.route("/", historyRoutes);
9962
10399
  }
9963
- const restGenerator = new RestApiGenerator(activeCollections.filter((collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server"), defaultDriver, authAdapter);
10400
+ const restGenerator = new RestApiGenerator(serverCollections, defaultDriver, authAdapter);
9964
10401
  dataRouter.route("/", restGenerator.generateRoutes());
9965
10402
  config.app.route(`${basePath}/data`, dataRouter);
9966
10403
  }
9967
- await mountOpenApiDocs(config.app, basePath, config.enableSwagger, activeCollections, resolveRequireAuth(config.auth));
10404
+ await mountOpenApiDocs(config.app, basePath, config.enableSwagger, serverCollections, resolveRequireAuth(config.auth));
9968
10405
  const serverClient = createRebaseClient({
9969
10406
  baseUrl: "http://localhost",
9970
10407
  apiPath: basePath,
9971
- websocketUrl: "",
10408
+ realtime: false,
9972
10409
  token: internalServiceKey,
9973
10410
  fetch: async (input, init) => {
9974
10411
  return await config.app.request(input, init);
9975
10412
  }
9976
10413
  });
9977
- const serviceIdentity = {
9978
- uid: "service",
9979
- roles: ["admin"]
9980
- };
10414
+ const serviceIdentity = SERVICE_IDENTITY;
9981
10415
  const defaultData = buildSdkData(await scopeDataDriver(defaultDriver, serviceIdentity));
9982
10416
  storageAuthorizeData.current = defaultData;
9983
10417
  const dataSourcesByKey = {};
@@ -9996,20 +10430,20 @@ async function _initializeRebaseBackend(config) {
9996
10430
  data: serverData,
9997
10431
  dataAsAdmin: serverData
9998
10432
  });
9999
- logger.info("Native data plane attached to singleton (bypasses HTTP loop)");
10433
+ logger.debug("Native data plane attached to singleton (bypasses HTTP loop)");
10000
10434
  if (storageController) {
10001
10435
  Object.assign(serverClient, { storage: storageController });
10002
- logger.info("Native storage attached to singleton (bypasses HTTP loop)");
10436
+ logger.debug("Native storage attached to singleton (bypasses HTTP loop)");
10003
10437
  }
10004
10438
  let emailService;
10005
10439
  if (authConfigResult?.emailService) emailService = authConfigResult.emailService;
10006
10440
  else if (config.auth && !isAuthAdapter(config.auth) && config.auth.email) emailService = createEmailService(config.auth.email);
10007
10441
  if (emailService) {
10008
10442
  Object.assign(serverClient, { email: emailService });
10009
- logger.info("Email service attached to singleton", { configured: emailService.isConfigured() });
10443
+ logger.debug("Email service attached to singleton", { configured: emailService.isConfigured() });
10010
10444
  if (emailService.isConfigured() && typeof emailService.verifyConnection === "function") emailService.verifyConnection().then((success) => {
10011
10445
  if (!success) logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.");
10012
- else logger.info("SMTP connection verified successfully.");
10446
+ else logger.debug("SMTP connection verified successfully.");
10013
10447
  }).catch((err) => {
10014
10448
  logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.", { error: err });
10015
10449
  });
@@ -10017,56 +10451,59 @@ async function _initializeRebaseBackend(config) {
10017
10451
  const driverAdmin = defaultBootstrapper.getAdmin?.(defaultDriverResult);
10018
10452
  if (isSQLAdmin(driverAdmin)) {
10019
10453
  Object.assign(serverClient, { sql: (query, options) => driverAdmin.executeSql(query, options) });
10020
- logger.info("SQL capability attached to singleton");
10454
+ logger.debug("SQL capability attached to singleton");
10021
10455
  }
10022
10456
  const serverSingleton = serverClient;
10023
10457
  _initRebase(serverSingleton);
10024
- logger.info("Rebase singleton initialized");
10458
+ logger.debug("Rebase singleton initialized");
10025
10459
  if (defaultDriverResult.internals) {
10026
10460
  const driver = defaultDriverResult.internals.driver;
10027
10461
  if (driver && "client" in driver) driver.client = serverClient;
10028
10462
  }
10029
10463
  if (config.functionsDir) {
10030
- const { loadFunctionsFromDirectory } = await import("./function-loader-B_1fYfUY.js").then((n) => n.t);
10031
- const { createFunctionRoutes } = await import("./function-routes-C0cLIy3N.js").then((n) => n.n);
10032
- const loadedFunctions = await loadFunctionsFromDirectory(config.functionsDir);
10033
- if (loadedFunctions.length > 0) {
10034
- const functionsRouter = new Hono();
10035
- functionsRouter.onError(errorHandler);
10036
- const functionsRequireAuth = false;
10037
- if (authAdapter) functionsRouter.use("/*", createAdapterAuthMiddleware({
10038
- adapter: authAdapter,
10039
- driver: defaultDriver,
10040
- requireAuth: functionsRequireAuth,
10041
- apiKeyStore
10042
- }));
10043
- else functionsRouter.use("/*", createAuthMiddleware({
10044
- driver: defaultDriver,
10045
- requireAuth: functionsRequireAuth,
10046
- serviceKey: internalServiceKey,
10047
- apiKeyStore
10048
- }));
10049
- functionsRouter.use("/*", createFunctionApiKeyGuard(`${basePath}/functions`));
10050
- if (rateLimitConfig) functionsRouter.use("/*", createDataRateLimiter({
10051
- ...rateLimitConfig,
10052
- anonymous: null
10053
- }));
10054
- const fnRoutes = createFunctionRoutes(loadedFunctions);
10055
- functionsRouter.route("/", fnRoutes);
10056
- config.app.route(`${basePath}/functions`, functionsRouter);
10057
- logger.info("Mounted custom functions", {
10058
- count: loadedFunctions.length,
10059
- path: `${basePath}/functions`
10060
- });
10061
- }
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."));
10062
10499
  }
10063
10500
  let cronScheduler;
10064
10501
  if (config.cronsDir) {
10065
- const { loadCronJobsFromDirectory } = await import("./cron-loader-B1S2MCSl.js").then((n) => n.t);
10066
- const { CronScheduler } = await import("./cron-scheduler-CsQtYLMq.js").then((n) => n.n);
10067
- const { createCronRoutes } = await import("./cron-routes-D5a9v1HY.js").then((n) => n.n);
10068
- const { createCronStore } = await import("./cron-store-ZbjfuvMb.js").then((n) => n.n);
10069
- 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);
10070
10507
  cronScheduler = new CronScheduler();
10071
10508
  cronScheduler.setClient(serverSingleton);
10072
10509
  if (loadedCronJobs.length > 0) {
@@ -10079,7 +10516,7 @@ async function _initializeRebaseBackend(config) {
10079
10516
  }
10080
10517
  const cronRouter = new Hono();
10081
10518
  applyAdminGate(cronRouter, "Cron");
10082
- cronRouter.route("/", createCronRoutes(cronScheduler));
10519
+ cronRouter.route("/", createCronRoutes(cronScheduler, cronProblems.length));
10083
10520
  config.app.route(`${basePath}/cron`, cronRouter);
10084
10521
  if (loadedCronJobs.length > 0) {
10085
10522
  cronScheduler.start();
@@ -10087,10 +10524,10 @@ async function _initializeRebaseBackend(config) {
10087
10524
  count: loadedCronJobs.length,
10088
10525
  path: `${basePath}/cron`
10089
10526
  });
10090
- } 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."));
10091
10528
  }
10092
10529
  {
10093
- 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);
10094
10531
  const backupRouter = new Hono();
10095
10532
  applyAdminGate(backupRouter, "Backup");
10096
10533
  backupRouter.route("/", createBackupRoutes({
@@ -10101,7 +10538,7 @@ async function _initializeRebaseBackend(config) {
10101
10538
  storage: storageController
10102
10539
  }));
10103
10540
  config.app.route(`${basePath}/admin/backups`, backupRouter);
10104
- logger.info("Backup admin routes mounted", { path: `${basePath}/admin/backups` });
10541
+ logger.debug("Backup admin routes mounted", { path: `${basePath}/admin/backups` });
10105
10542
  }
10106
10543
  {
10107
10544
  const { default: logsRoutes } = await import("./logs-routes-BYA72C_C.js").then((n) => n.n);
@@ -10109,10 +10546,10 @@ async function _initializeRebaseBackend(config) {
10109
10546
  applyAdminGate(logsRouter, "Logs");
10110
10547
  logsRouter.route("/", logsRoutes);
10111
10548
  config.app.route(`${basePath}/logs`, logsRouter);
10112
- logger.info("Logs routes mounted", { path: `${basePath}/logs` });
10549
+ logger.debug("Logs routes mounted", { path: `${basePath}/logs` });
10113
10550
  }
10114
10551
  {
10115
- const { createContractRoutes } = await import("./contract-routes-FSt0Nyfk.js").then((n) => n.t);
10552
+ const { createContractRoutes } = await import("./contract-routes-Bet-eCNJ.js").then((n) => n.t);
10116
10553
  const contractRouter = new Hono();
10117
10554
  if (adminSurfacesGated) {
10118
10555
  if (apiKeyPreAuth) contractRouter.use("/contract", apiKeyPreAuth);
@@ -10130,7 +10567,7 @@ async function _initializeRebaseBackend(config) {
10130
10567
  runtimeVersion: config.runtimeVersion
10131
10568
  }));
10132
10569
  config.app.route(`${basePath}/meta`, contractRouter);
10133
- logger.info("Contract routes mounted", { path: `${basePath}/meta` });
10570
+ logger.debug("Contract routes mounted", { path: `${basePath}/meta` });
10134
10571
  }
10135
10572
  const effectiveRealtimeService = Object.keys(realtimeServices).length > 1 ? createRoutedRealtimeService({
10136
10573
  providers: realtimeServices,
@@ -10138,7 +10575,7 @@ async function _initializeRebaseBackend(config) {
10138
10575
  resolveKey: keyForCollectionPath
10139
10576
  }) : defaultRealtimeService;
10140
10577
  if (defaultBootstrapper.initializeWebsockets && effectiveRealtimeService) await defaultBootstrapper.initializeWebsockets(config.server, effectiveRealtimeService, defaultDriver, config.auth, authAdapter);
10141
- logger.info("Rebase Backend Initialized");
10578
+ logger.debug("Rebase Backend Initialized");
10142
10579
  const authSchemaCheck = authConfigResult?.schemaHealthCheck;
10143
10580
  const healthCheck = createHealthCheck(defaultDriver, authSchemaCheck ? () => authSchemaCheck.call(authConfigResult) : void 0);
10144
10581
  const shutdown = createShutdown({
@@ -10206,6 +10643,9 @@ async function _initializeRebaseBackend(config) {
10206
10643
  * export default defineFunction((app, { rebase }) => {
10207
10644
  * app.use("/*", requireAuth);
10208
10645
  * app.get("/home", async (c) => {
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.
10209
10649
  * const [stats] = await rebase.sql(`SELECT count(*) AS n FROM orders`);
10210
10650
  * return c.json({ orders: Number(stats.n) });
10211
10651
  * });
@@ -10231,6 +10671,17 @@ function defineFunction(definition) {
10231
10671
  *
10232
10672
  * @see {@link defineFunction} for the equivalent custom-functions helper.
10233
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
+ *
10234
10685
  * @example
10235
10686
  * ```ts
10236
10687
  * import { defineCron } from "@rebasepro/server";
@@ -10239,8 +10690,9 @@ function defineFunction(definition) {
10239
10690
  * name: "Nightly cleanup",
10240
10691
  * schedule: "0 3 * * *",
10241
10692
  * async handler({ rebase, log }) {
10242
- * // `dataAsAdmin` bypasses RLS. A cron has no per-request user, so
10243
- * // there is no policy to fall back on — scope the filters yourself.
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.
10244
10696
  * const { data: expired } = await rebase.dataAsAdmin.sessions.find({
10245
10697
  * where: { expired: ["==", true] },
10246
10698
  * });
@@ -10366,6 +10818,7 @@ var rebaseEnvSchema = object({
10366
10818
  REBASE_SERVICE_KEY: string().optional(),
10367
10819
  ALLOW_REGISTRATION: boolString,
10368
10820
  DISABLE_SELF_REGISTRATION: optionalBoolString,
10821
+ ALLOW_ANONYMOUS: optionalBoolString,
10369
10822
  ALLOW_LOCALHOST_IN_PRODUCTION: optionalBoolString,
10370
10823
  CORS_ORIGINS: string().optional(),
10371
10824
  FRONTEND_URL: string().optional(),
@@ -10429,7 +10882,158 @@ function loadEnv(options) {
10429
10882
  return env;
10430
10883
  }
10431
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
10432
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;
10433
11037
  var WebhookDispatcher = class {
10434
11038
  webhooks = [];
10435
11039
  maxRetries = 3;
@@ -10438,33 +11042,103 @@ var WebhookDispatcher = class {
10438
11042
  5e3,
10439
11043
  15e3
10440
11044
  ];
11045
+ options;
11046
+ timeoutMs;
11047
+ queue = [];
11048
+ draining = null;
11049
+ constructor(options = {}) {
11050
+ this.options = options;
11051
+ this.timeoutMs = options.timeoutMs ?? 1e4;
11052
+ }
10441
11053
  /** Register webhooks to watch */
10442
11054
  setWebhooks(webhooks) {
10443
11055
  this.webhooks = webhooks.filter((w) => w.enabled);
10444
11056
  }
10445
- /** 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
+ */
10446
11068
  async onEntityChange(table, event, id, entity, previousEntity) {
10447
- const matchingWebhooks = this.webhooks.filter((w) => w.table === table && w.events.includes(event));
10448
- if (matchingWebhooks.length === 0) return [];
11069
+ const jobs = this.buildDeliveries(table, event, entity, previousEntity);
10449
11070
  const results = [];
10450
- for (const webhook of matchingWebhooks) {
10451
- 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: {
10452
11129
  type: event,
10453
11130
  table,
10454
11131
  record: entity,
10455
11132
  old_record: event === "UPDATE" ? previousEntity : void 0,
10456
11133
  schema: "public",
10457
11134
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
10458
- };
10459
- const result = await this.deliverWithRetry(webhook, event, payload);
10460
- results.push(result);
10461
- }
10462
- return results;
11135
+ }
11136
+ }));
10463
11137
  }
10464
11138
  async deliverWithRetry(webhook, event, payload) {
10465
11139
  for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
10466
- const result = await this.deliver(webhook, event, payload, attempt);
10467
- if (result.success) return result;
11140
+ const { result, terminal } = await this.deliver(webhook, event, payload, attempt);
11141
+ if (result.success || terminal) return result;
10468
11142
  if (attempt < this.maxRetries) {
10469
11143
  const backoff = this.retryDelays[attempt - 1] ?? this.retryDelays[this.retryDelays.length - 1];
10470
11144
  await new Promise((r) => setTimeout(r, backoff));
@@ -10491,41 +11165,106 @@ var WebhookDispatcher = class {
10491
11165
  ...webhook.headers || {}
10492
11166
  };
10493
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);
10494
11170
  try {
10495
- const controller = new AbortController();
10496
- const timeout = setTimeout(() => controller.abort(), 1e4);
10497
- 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, {
10498
11176
  method: "POST",
10499
11177
  headers,
10500
11178
  body,
10501
- signal: controller.signal
11179
+ signal: controller.signal,
11180
+ redirect: "manual"
10502
11181
  });
10503
- clearTimeout(timeout);
10504
- 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);
10505
11198
  const success = response.status >= 200 && response.status < 300;
10506
11199
  return {
10507
- webhookId: webhook.id,
10508
- event,
10509
- payload,
10510
- statusCode: response.status,
10511
- responseBody: responseBody.slice(0, 1e3),
10512
- success,
10513
- 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
+ }
10514
11210
  };
10515
11211
  } catch (error) {
10516
11212
  const message = error instanceof Error ? error.message : String(error);
10517
11213
  return {
10518
- webhookId: webhook.id,
10519
- event,
10520
- payload,
10521
- statusCode: 0,
10522
- responseBody: message.slice(0, 1e3),
10523
- success: false,
10524
- 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
+ }
10525
11224
  };
11225
+ } finally {
11226
+ clearTimeout(timeout);
10526
11227
  }
10527
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);
11253
+ }
11254
+ return new TextDecoder().decode(concat(chunks, total));
11255
+ }
10528
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
+ }
10529
11268
  //#endregion
10530
11269
  //#region src/utils/dev-port.ts
10531
11270
  var MAX_PORT_ATTEMPTS = 20;
@@ -10686,10 +11425,11 @@ function isUnderPath(requestPath, prefix) {
10686
11425
  * Serve a Single Page Application from an Hono app.
10687
11426
  *
10688
11427
  * @internal Not part of the stable public API. Exported only because the
10689
- * official app template (`packages/cli/templates/template/backend/src/index.ts`
10690
- * and `app/backend/src/index.ts`) calls it to serve the built frontend in
10691
- * production. Its request-handling behavior is an implementation detail and
10692
- * 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.
10693
11433
  */
10694
11434
  function serveSPA(app, config) {
10695
11435
  const { frontendPath, apiBasePath = "/api", excludePaths = [], indexFile = "index.html", spa = true } = config;
@@ -11833,6 +12573,7 @@ function resolveAuthOptions(env, usersCollection) {
11833
12573
  requireAuth: env.AUTH_REQUIRE,
11834
12574
  allowRegistration: env.ALLOW_REGISTRATION,
11835
12575
  disableSelfRegistration: env.DISABLE_SELF_REGISTRATION,
12576
+ allowAnonymous: env.ALLOW_ANONYMOUS,
11836
12577
  allowUserLookup: env.AUTH_ALLOW_USER_LOOKUP,
11837
12578
  email: resolveEmailOptions(env),
11838
12579
  cookieAuth: { sameSite: env.AUTH_COOKIE_SAME_SITE || "Lax" }
@@ -12240,7 +12981,7 @@ async function bootFromBundle(options = {}) {
12240
12981
  const bundleDir = options.bundleDir || fetchedDir || process.env.REBASE_BUNDLE || path.resolve(process.cwd(), "dist-bundle");
12241
12982
  const bundle = options.bundle ?? loadBundle(bundleDir);
12242
12983
  const devRoot = process.env.REBASE_DEV_PROJECT_ROOT || process.cwd();
12243
- logger.info("Loaded bundle", {
12984
+ logger.debug("Loaded bundle", {
12244
12985
  app: bundle.manifest.app,
12245
12986
  kind: bundle.manifest.kind,
12246
12987
  schemaVersion: bundle.manifest.schemaVersion,
@@ -12611,8 +13352,9 @@ async function ensureCollectionSchema(bundle, dataSources, env) {
12611
13352
  skip(`none of the ${loaded.length} declared collection(s) are stored by the "${primary.engine}" data source.`);
12612
13353
  return;
12613
13354
  }
12614
- const { applied } = await primary.bootstrapper.ensureCollectionSchema(collections, preInitDriverResult(primary), (message) => logger.info(`schema: ${message}`));
12615
- 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.");
12616
13358
  }
12617
13359
  /**
12618
13360
  * The `InitializedDriver` to hand a bootstrapper before any driver exists.
@@ -12657,17 +13399,18 @@ async function ensureCollectionPolicies(bundle, dataSources, env) {
12657
13399
  if (!primary) return;
12658
13400
  if (!primary.bootstrapper.ensureCollectionPolicies) {
12659
13401
  const skew = describeDriverSkew(primary.driverVersion, readRuntimeVersion(bundleResolutionRoots(bundle.dir)));
12660
- 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 }));
12661
13403
  return;
12662
13404
  }
12663
13405
  const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
12664
13406
  if (loaded.length === 0) return;
12665
13407
  const collections = collectionsStoredBy(loaded, primary, dataSources);
12666
13408
  if (collections.length === 0) return;
12667
- const { applied } = await primary.bootstrapper.ensureCollectionPolicies(collections, preInitDriverResult(primary), (message) => logger.info(`policies: ${message}`));
12668
- 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.");
12669
13412
  }
12670
13413
  //#endregion
12671
- 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 };
12672
13415
 
12673
13416
  //# sourceMappingURL=index.es.js.map