mindoodb 0.0.47 → 0.0.49

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 (79) hide show
  1. package/README-server.md +39 -3
  2. package/dist/appendonlystores/network/HttpTransport.d.ts +14 -0
  3. package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
  4. package/dist/appendonlystores/network/HttpTransport.js +58 -39
  5. package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
  6. package/dist/appendonlystores/network/RequestScheduler.d.ts +65 -0
  7. package/dist/appendonlystores/network/RequestScheduler.d.ts.map +1 -0
  8. package/dist/appendonlystores/network/RequestScheduler.js +147 -0
  9. package/dist/appendonlystores/network/RequestScheduler.js.map +1 -0
  10. package/dist/appendonlystores/network/index.d.ts +1 -0
  11. package/dist/appendonlystores/network/index.d.ts.map +1 -1
  12. package/dist/appendonlystores/network/index.js +6 -1
  13. package/dist/appendonlystores/network/index.js.map +1 -1
  14. package/dist/browser/cache/IndexedDBLocalCacheStore.d.ts +15 -6
  15. package/dist/browser/cache/IndexedDBLocalCacheStore.d.ts.map +1 -1
  16. package/dist/browser/cache/IndexedDBLocalCacheStore.js +85 -26
  17. package/dist/browser/cache/IndexedDBLocalCacheStore.js.map +1 -1
  18. package/dist/browser/index.d.ts +1 -1
  19. package/dist/browser/index.d.ts.map +1 -1
  20. package/dist/browser/index.js +3 -1
  21. package/dist/browser/index.js.map +1 -1
  22. package/dist/browser/index.mjs +2 -0
  23. package/dist/core/BaseMindooDB.d.ts +127 -0
  24. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  25. package/dist/core/BaseMindooDB.js +514 -51
  26. package/dist/core/BaseMindooDB.js.map +1 -1
  27. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  28. package/dist/core/BaseMindooTenant.js +8 -1
  29. package/dist/core/BaseMindooTenant.js.map +1 -1
  30. package/dist/core/appendonlystores/network/NetworkTransport.d.ts +10 -0
  31. package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
  32. package/dist/core/expressions/builder.d.ts +1 -0
  33. package/dist/core/expressions/builder.d.ts.map +1 -1
  34. package/dist/core/expressions/builder.js +3 -0
  35. package/dist/core/expressions/builder.js.map +1 -1
  36. package/dist/core/expressions/evaluateExpression.d.ts +2 -0
  37. package/dist/core/expressions/evaluateExpression.d.ts.map +1 -1
  38. package/dist/core/expressions/evaluateExpression.js +2 -0
  39. package/dist/core/expressions/evaluateExpression.js.map +1 -1
  40. package/dist/core/expressions/formulaSource.d.ts.map +1 -1
  41. package/dist/core/expressions/formulaSource.js +1 -0
  42. package/dist/core/expressions/formulaSource.js.map +1 -1
  43. package/dist/core/expressions/metadata.d.ts +1 -1
  44. package/dist/core/expressions/metadata.d.ts.map +1 -1
  45. package/dist/core/expressions/metadata.js +1 -0
  46. package/dist/core/expressions/metadata.js.map +1 -1
  47. package/dist/core/expressions/types.d.ts +1 -1
  48. package/dist/core/expressions/types.d.ts.map +1 -1
  49. package/dist/core/indexing/createViewDataProvider.d.ts.map +1 -1
  50. package/dist/core/indexing/createViewDataProvider.js +1 -0
  51. package/dist/core/indexing/createViewDataProvider.js.map +1 -1
  52. package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.d.ts.map +1 -1
  53. package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.js +1 -0
  54. package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.js.map +1 -1
  55. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts.map +1 -1
  56. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js +1 -0
  57. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js.map +1 -1
  58. package/dist/core/query/executeQuery.d.ts.map +1 -1
  59. package/dist/core/query/executeQuery.js +2 -0
  60. package/dist/core/query/executeQuery.js.map +1 -1
  61. package/dist/core/types.d.ts +19 -1
  62. package/dist/core/types.d.ts.map +1 -1
  63. package/dist/core/types.js.map +1 -1
  64. package/dist/node/server/MindooDBServer.d.ts +51 -5
  65. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  66. package/dist/node/server/MindooDBServer.js +164 -40
  67. package/dist/node/server/MindooDBServer.js.map +1 -1
  68. package/dist/node/server/config.d.ts.map +1 -1
  69. package/dist/node/server/config.js +4 -0
  70. package/dist/node/server/config.js.map +1 -1
  71. package/dist/node/server/rateLimits.d.ts +46 -0
  72. package/dist/node/server/rateLimits.d.ts.map +1 -0
  73. package/dist/node/server/rateLimits.js +50 -0
  74. package/dist/node/server/rateLimits.js.map +1 -0
  75. package/dist/node/server/types.d.ts +15 -0
  76. package/dist/node/server/types.d.ts.map +1 -1
  77. package/dist/node/server/types.js +9 -0
  78. package/dist/node/server/types.js.map +1 -1
  79. package/package.json +2 -2
@@ -56,7 +56,7 @@ const path_1 = __importDefault(require("path"));
56
56
  const fs_1 = require("fs");
57
57
  const helmet_1 = __importDefault(require("helmet"));
58
58
  const cors_1 = __importDefault(require("cors"));
59
- const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
59
+ const express_rate_limit_1 = __importStar(require("express-rate-limit"));
60
60
  const compressionMiddleware_1 = require("./compressionMiddleware");
61
61
  const binaryEntryFraming_1 = require("../../core/appendonlystores/network/binaryEntryFraming");
62
62
  const SyncEventBus_1 = require("./SyncEventBus");
@@ -71,6 +71,7 @@ const validation_1 = require("./validation");
71
71
  const urlSafety_1 = require("../../core/utils/urlSafety");
72
72
  const types_3 = require("./types");
73
73
  const timestampProxy_1 = require("./timestampProxy");
74
+ const rateLimits_1 = require("./rateLimits");
74
75
  /**
75
76
  * Rate-limit defaults. Auth and sync have separate tiers because a single
76
77
  * challenge/authenticate handshake is two requests; with multiple tenants,
@@ -81,7 +82,23 @@ const timestampProxy_1 = require("./timestampProxy");
81
82
  const DEFAULT_AUTH_RATE_LIMIT_WINDOW_MS = 60000;
82
83
  const DEFAULT_AUTH_RATE_LIMIT_MAX = 120;
83
84
  const DEFAULT_SYNC_RATE_LIMIT_WINDOW_MS = 60000;
84
- const DEFAULT_SYNC_RATE_LIMIT_MAX = 1000;
85
+ /**
86
+ * Sync is keyed per authenticated principal, and a client legitimately opens
87
+ * several databases at once: a host like Teacher's Desk reads six, and a cold
88
+ * pass over one of them costs a few hundred batched requests. A budget sized
89
+ * for a single database turns that normal open into a throttled one.
90
+ */
91
+ const DEFAULT_SYNC_RATE_LIMIT_MAX = 3000;
92
+ /**
93
+ * The global limiter is a coarse net for unauthenticated floods and unknown
94
+ * routes; the per-tier limiters are the real budgets. It runs first, so a
95
+ * global max below a tier max silently replaces that tier — the sync budget
96
+ * was unreachable while this was a hardcoded 500. The default is therefore
97
+ * derived from the tiers, and an override that would shadow one is honoured
98
+ * but flagged at startup rather than quietly applied.
99
+ */
100
+ const DEFAULT_GLOBAL_RATE_LIMIT_WINDOW_MS = 60000;
101
+ const GLOBAL_RATE_LIMIT_TIER_HEADROOM = 500;
85
102
  /**
86
103
  * Timestamping gets its own budget rather than sharing the sync one. Sync is
87
104
  * chatty and high-volume; sealing a document is a deliberate, rare user action.
@@ -242,6 +259,7 @@ class MindooDBServer {
242
259
  * 5. request logger
243
260
  */
244
261
  setupMiddleware() {
262
+ this.applyTrustProxySetting();
245
263
  this.app.use((0, helmet_1.default)());
246
264
  const corsOrigin = this.readCorsOriginsFromEnv();
247
265
  this.app.use((0, cors_1.default)({
@@ -249,9 +267,11 @@ class MindooDBServer {
249
267
  methods: ["GET", "POST", "PUT", "DELETE"],
250
268
  allowedHeaders: ["Content-Type", "Authorization"],
251
269
  }));
270
+ const globalRateLimitConfig = this.getGlobalRateLimitConfig();
271
+ console.log(`[MindooDBServer] Global rate limit configured windowMs=${globalRateLimitConfig.windowMs} max=${globalRateLimitConfig.max}`);
252
272
  this.app.use((0, express_rate_limit_1.default)({
253
- windowMs: 60000,
254
- max: 500,
273
+ windowMs: globalRateLimitConfig.windowMs,
274
+ max: globalRateLimitConfig.max,
255
275
  standardHeaders: true,
256
276
  legacyHeaders: false,
257
277
  message: { error: "Too many requests, please try again later" },
@@ -273,6 +293,40 @@ class MindooDBServer {
273
293
  });
274
294
  }
275
295
  /** Parse MINDOODB_CORS_ORIGIN env var; supports comma-separated multiple origins. */
296
+ /**
297
+ * Teach Express which upstream hops to trust for `X-Forwarded-For`, so
298
+ * `req.ip` is the client rather than the proxy. Every rate limiter keys on
299
+ * `req.ip`; without this, a deployment behind Cloudflare or nginx puts all
300
+ * of its users in one bucket and the first busy client locks out the rest.
301
+ *
302
+ * `MINDOODB_TRUST_PROXY` takes the Express `trust proxy` values: a hop count
303
+ * (`1` for Cloudflare only, `2` when nginx also fronts the origin), a
304
+ * comma-separated list of trusted addresses/CIDRs, or `loopback`. Off by
305
+ * default, because trusting a header nobody strips lets a client spoof its
306
+ * own address and mint unlimited budgets. Prefer a hop count or an address
307
+ * list over `true`, which trusts the whole chain.
308
+ */
309
+ applyTrustProxySetting() {
310
+ const raw = process.env[types_3.ENV_VARS.TRUST_PROXY]?.trim();
311
+ if (!raw || /^(0|false)$/i.test(raw)) {
312
+ return;
313
+ }
314
+ const hops = Number(raw);
315
+ const value = Number.isInteger(hops) && hops >= 0
316
+ ? hops
317
+ : /^true$/i.test(raw)
318
+ ? true
319
+ : raw.split(",").map((entry) => entry.trim()).filter(Boolean);
320
+ this.app.set("trust proxy", value);
321
+ if (value === true) {
322
+ console.warn("[MindooDBServer] MINDOODB_TRUST_PROXY=true trusts the entire forwarding chain; " +
323
+ "a client can then spoof X-Forwarded-For and bypass per-IP rate limits. " +
324
+ "Set a hop count (e.g. 1 behind Cloudflare) or a trusted address list instead.");
325
+ }
326
+ else {
327
+ console.log(`[MindooDBServer] trust proxy set to ${JSON.stringify(value)}`);
328
+ }
329
+ }
276
330
  readCorsOriginsFromEnv() {
277
331
  const raw = process.env.MINDOODB_CORS_ORIGIN?.trim();
278
332
  if (!raw) {
@@ -906,9 +960,9 @@ class MindooDBServer {
906
960
  }
907
961
  // ==================== Tenant Routes ====================
908
962
  /**
909
- * Build the per-tenant router. Rate limiters are scoped: auth endpoints
910
- * use a per-user composite key (see createAuthRateLimit) while sync
911
- * endpoints use a plain IP-based bucket with a higher ceiling.
963
+ * Build the per-tenant router. Rate limiters are scoped: auth and sync
964
+ * endpoints both use a per-user composite key (see createAuthRateLimit and
965
+ * createSyncRateLimit), sync with a much higher ceiling.
912
966
  *
913
967
  * Both "docs" and "attachments" store kinds expose the same set of sync
914
968
  * operations, mounted at /sync/docs/* and /sync/attachments/*.
@@ -916,16 +970,13 @@ class MindooDBServer {
916
970
  createTenantRouter() {
917
971
  const router = (0, express_1.Router)({ mergeParams: true });
918
972
  const authRateLimitConfig = this.getAuthRateLimitConfig();
919
- const syncRateLimitConfig = this.getSyncRateLimitConfig();
920
973
  const challengeRateLimit = this.createAuthRateLimit("tenant", "challenge");
921
974
  const authenticateRateLimit = this.createAuthRateLimit("tenant", "authenticate");
922
- const syncRateLimit = (0, express_rate_limit_1.default)({
923
- windowMs: syncRateLimitConfig.windowMs,
924
- max: syncRateLimitConfig.max,
925
- standardHeaders: true,
926
- legacyHeaders: false,
927
- message: { error: "Too many sync requests, please try again later" },
928
- });
975
+ // Identity resolution precedes the limiter so it can key on the caller.
976
+ const syncGuards = [
977
+ this.syncPrincipalMiddleware.bind(this),
978
+ this.createSyncRateLimit(),
979
+ ];
929
980
  console.log(`[MindooDBServer] Tenant auth rate limit configured windowMs=${authRateLimitConfig.windowMs} max=${authRateLimitConfig.max}`);
930
981
  router.post("/auth/challenge", challengeRateLimit, this.handleChallenge.bind(this));
931
982
  router.post("/auth/authenticate", authenticateRateLimit, this.handleAuthenticate.bind(this));
@@ -935,34 +986,53 @@ class MindooDBServer {
935
986
  router.post("/timestamps/rfc3161", this.timestampAuthMiddleware.bind(this), this.createTimestampRateLimit("burst"), this.createTimestampRateLimit("daily"), express_1.default.raw({ type: timestampProxy_1.TIMESTAMP_QUERY_CONTENT_TYPE, limit: timestampProxy_1.MAX_TIMESTAMP_REQUEST_BYTES }), this.handleTimestampRequest.bind(this));
936
987
  for (const storeKind of ["docs", "attachments"]) {
937
988
  const syncBase = `/sync/${storeKind}`;
938
- router.post(`${syncBase}/findNewEntries`, syncRateLimit, this.handleFindNewEntries.bind(this));
939
- router.post(`${syncBase}/findNewEntriesForDoc`, syncRateLimit, this.handleFindNewEntriesForDoc.bind(this));
940
- router.post(`${syncBase}/findEntries`, syncRateLimit, this.handleFindEntries.bind(this));
941
- router.post(`${syncBase}/scanEntriesSince`, syncRateLimit, this.handleScanEntriesSince.bind(this));
942
- router.post(`${syncBase}/getStoreHead`, syncRateLimit, this.handleGetStoreHead.bind(this));
943
- router.post(`${syncBase}/getIdBloomSummary`, syncRateLimit, this.handleGetIdBloomSummary.bind(this));
944
- router.post(`${syncBase}/getCompactionStatus`, syncRateLimit, this.handleGetCompactionStatus.bind(this));
945
- router.get(`${syncBase}/capabilities`, syncRateLimit, this.handleGetCapabilities.bind(this));
989
+ router.post(`${syncBase}/findNewEntries`, syncGuards, this.handleFindNewEntries.bind(this));
990
+ router.post(`${syncBase}/findNewEntriesForDoc`, syncGuards, this.handleFindNewEntriesForDoc.bind(this));
991
+ router.post(`${syncBase}/findEntries`, syncGuards, this.handleFindEntries.bind(this));
992
+ router.post(`${syncBase}/scanEntriesSince`, syncGuards, this.handleScanEntriesSince.bind(this));
993
+ router.post(`${syncBase}/getStoreHead`, syncGuards, this.handleGetStoreHead.bind(this));
994
+ router.post(`${syncBase}/getIdBloomSummary`, syncGuards, this.handleGetIdBloomSummary.bind(this));
995
+ router.post(`${syncBase}/getCompactionStatus`, syncGuards, this.handleGetCompactionStatus.bind(this));
996
+ router.get(`${syncBase}/capabilities`, syncGuards, this.handleGetCapabilities.bind(this));
946
997
  // SSE live change feed (sync-v5, phase 5): one long-lived GET per
947
998
  // subscriber; putEntries publishes into the in-process event bus.
948
- router.get(`${syncBase}/events`, syncRateLimit, this.handleSyncEvents.bind(this));
949
- router.post(`${syncBase}/getEntries`, syncRateLimit, this.handleGetEntries.bind(this));
999
+ router.get(`${syncBase}/events`, syncGuards, this.handleSyncEvents.bind(this));
1000
+ router.post(`${syncBase}/getEntries`, syncGuards, this.handleGetEntries.bind(this));
950
1001
  // Binary wire format v2 (sync-v5, phase 3): octet-stream framing for the
951
1002
  // two payload-heavy endpoints. putEntriesBinary needs its own raw body
952
1003
  // parser (the global JSON parser only handles application/json).
953
- router.post(`${syncBase}/getEntriesBinary`, syncRateLimit, this.handleGetEntriesBinary.bind(this));
954
- router.post(`${syncBase}/putEntriesBinary`, syncRateLimit, express_1.default.raw({ type: binaryEntryFraming_1.BINARY_ENTRIES_CONTENT_TYPE, limit: this.jsonBodyLimit }), this.handlePutEntriesBinary.bind(this));
955
- router.post(`${syncBase}/getEntryMetadata`, syncRateLimit, this.handleGetEntryMetadata.bind(this));
956
- router.post(`${syncBase}/putEntries`, syncRateLimit, this.handlePutEntries.bind(this));
957
- router.post(`${syncBase}/hasEntries`, syncRateLimit, this.handleHasEntries.bind(this));
958
- router.get(`${syncBase}/getAllIds`, syncRateLimit, this.handleGetAllIds.bind(this));
959
- router.post(`${syncBase}/resolveDependencies`, syncRateLimit, this.handleResolveDependencies.bind(this));
960
- router.post(`${syncBase}/planDocumentMaterialization`, syncRateLimit, this.handlePlanDocumentMaterialization.bind(this));
961
- router.post(`${syncBase}/planDocumentMaterializationBatch`, syncRateLimit, this.handlePlanDocumentMaterializationBatch.bind(this));
962
- router.post(`${syncBase}/planAttachmentReadByWalkingMetadata`, syncRateLimit, this.handlePlanAttachmentReadByWalkingMetadata.bind(this));
1004
+ router.post(`${syncBase}/getEntriesBinary`, syncGuards, this.handleGetEntriesBinary.bind(this));
1005
+ router.post(`${syncBase}/putEntriesBinary`, syncGuards, express_1.default.raw({ type: binaryEntryFraming_1.BINARY_ENTRIES_CONTENT_TYPE, limit: this.jsonBodyLimit }), this.handlePutEntriesBinary.bind(this));
1006
+ router.post(`${syncBase}/getEntryMetadata`, syncGuards, this.handleGetEntryMetadata.bind(this));
1007
+ router.post(`${syncBase}/putEntries`, syncGuards, this.handlePutEntries.bind(this));
1008
+ router.post(`${syncBase}/hasEntries`, syncGuards, this.handleHasEntries.bind(this));
1009
+ router.get(`${syncBase}/getAllIds`, syncGuards, this.handleGetAllIds.bind(this));
1010
+ router.post(`${syncBase}/resolveDependencies`, syncGuards, this.handleResolveDependencies.bind(this));
1011
+ router.post(`${syncBase}/planDocumentMaterialization`, syncGuards, this.handlePlanDocumentMaterialization.bind(this));
1012
+ router.post(`${syncBase}/planDocumentMaterializationBatch`, syncGuards, this.handlePlanDocumentMaterializationBatch.bind(this));
1013
+ router.post(`${syncBase}/planAttachmentReadByWalkingMetadata`, syncGuards, this.handlePlanAttachmentReadByWalkingMetadata.bind(this));
963
1014
  }
964
1015
  return router;
965
1016
  }
1017
+ /**
1018
+ * Resolved global rate-limit config. The default leaves room for the sync
1019
+ * and auth tiers to spend their full budgets inside the global window, so
1020
+ * the net catches only traffic no tier claims.
1021
+ */
1022
+ getGlobalRateLimitConfig() {
1023
+ const resolved = (0, rateLimits_1.resolveGlobalRateLimit)(this.serverConfig.rateLimits?.global, {
1024
+ sync: this.getSyncRateLimitConfig(),
1025
+ auth: this.getAuthRateLimitConfig(),
1026
+ defaultWindowMs: DEFAULT_GLOBAL_RATE_LIMIT_WINDOW_MS,
1027
+ headroom: GLOBAL_RATE_LIMIT_TIER_HEADROOM,
1028
+ });
1029
+ if (resolved.shadowsTiers) {
1030
+ console.warn(`[MindooDBServer] rateLimits.global.max=${resolved.max} is below the ${resolved.tierFloor} ` +
1031
+ "requests the sync and auth tiers may spend in the same window; the global limiter " +
1032
+ "will reject requests those tiers still allow.");
1033
+ }
1034
+ return { windowMs: resolved.windowMs, max: resolved.max };
1035
+ }
966
1036
  /** Resolved auth rate-limit config; falls back to defaults when not set in config.json. */
967
1037
  getAuthRateLimitConfig() {
968
1038
  return {
@@ -1006,7 +1076,7 @@ class MindooDBServer {
1006
1076
  keyGenerator: (req) => {
1007
1077
  const tenantId = req.tenantId ?? "unknown";
1008
1078
  const principal = req.timestampPrincipal ?? "anonymous";
1009
- return `timestamp:${tier}:${tenantId}:${principal}:${req.ip}`;
1079
+ return `timestamp:${tier}:${tenantId}:${principal}:${this.getClientIpForRateLimit(req)}`;
1010
1080
  },
1011
1081
  handler: (req, res, _next, options) => {
1012
1082
  console.warn(`[MindooDBServer] Timestamp rate limit hit (${tier}) tenant=${req.tenantId} ip=${req.ip}`);
@@ -1019,6 +1089,53 @@ class MindooDBServer {
1019
1089
  },
1020
1090
  });
1021
1091
  }
1092
+ /**
1093
+ * Resolve the caller of a sync request so {@link createSyncRateLimit} can
1094
+ * bucket per identity instead of per address.
1095
+ *
1096
+ * Unlike {@link timestampAuthMiddleware} this never rejects: the route
1097
+ * handlers do their own token validation and own the 401, and this pass only
1098
+ * needs to know whose budget to spend. An unverifiable token simply falls
1099
+ * back to address-only bucketing — a forged one must not be able to conjure
1100
+ * a fresh budget, which is why the token is verified rather than decoded.
1101
+ */
1102
+ async syncPrincipalMiddleware(req, _res, next) {
1103
+ try {
1104
+ const token = this.extractToken(req);
1105
+ const authService = await this.tenantManager.getAuthService(req.tenantId);
1106
+ const payload = await authService.validateToken(token);
1107
+ if (payload && payload.tenantId === req.tenantId) {
1108
+ req.syncPrincipal = payload.deviceSigningKey ?? payload.sub;
1109
+ }
1110
+ }
1111
+ catch {
1112
+ // No usable token; fall through to address-only bucketing.
1113
+ }
1114
+ next();
1115
+ }
1116
+ /**
1117
+ * Sync budget, keyed on tenant + principal + IP like the auth and timestamp
1118
+ * tiers. Address-only keying would put every user behind one NAT or reverse
1119
+ * proxy in a single bucket, so one client opening several databases would
1120
+ * throttle everyone else on that address.
1121
+ */
1122
+ createSyncRateLimit() {
1123
+ const config = this.getSyncRateLimitConfig();
1124
+ console.log(`[MindooDBServer] Tenant sync rate limit configured windowMs=${config.windowMs} max=${config.max}`);
1125
+ return (0, express_rate_limit_1.default)({
1126
+ windowMs: config.windowMs,
1127
+ max: config.max,
1128
+ standardHeaders: true,
1129
+ legacyHeaders: false,
1130
+ keyGenerator: (req) => (0, rateLimits_1.buildSyncRateLimitKey)(req.tenantId, req.syncPrincipal, this.getClientIpForRateLimit(req)),
1131
+ handler: (req, res, _next, options) => {
1132
+ console.warn(`[MindooDBServer] Sync rate limit hit tenant=${req.tenantId} ` +
1133
+ `principal=${req.syncPrincipal ?? "anonymous"} ip=${req.ip} path=${req.path}`);
1134
+ res.status(options.statusCode).json(options.message);
1135
+ },
1136
+ message: { error: "Too many sync requests, please try again later" },
1137
+ });
1138
+ }
1022
1139
  /**
1023
1140
  * Authenticate a timestamp request with the tenant's existing bearer token and
1024
1141
  * record the principal for rate-limit bucketing.
@@ -1158,13 +1275,20 @@ class MindooDBServer {
1158
1275
  console.warn(`[MindooDBServer] Auth rate limit exceeded scope=${scope} phase=${phase} tenant=${req.tenantId ?? req.params.tenantId?.toLowerCase() ?? "-"} ip=${this.getClientIpForRateLimit(req)} forwarded-for=${forwardedFor ?? "-"} username=${username} path=${req.path}`);
1159
1276
  }
1160
1277
  /**
1161
- * Best-effort client IP. Express populates `req.ip` from the trust-proxy
1162
- * setting; fall back to the raw socket address when it's unavailable.
1278
+ * Best-effort client IP for bucketing. Express populates `req.ip` from the
1279
+ * trust-proxy setting; fall back to the raw socket address when it's
1280
+ * unavailable.
1281
+ *
1282
+ * IPv6 addresses collapse to their /56 prefix. A single IPv6 client is
1283
+ * routinely handed far more than one address, so keying on the full address
1284
+ * would let it mint a fresh budget per request. Non-IPv6 values, including
1285
+ * the "unknown" fallback, pass through unchanged.
1163
1286
  */
1164
1287
  getClientIpForRateLimit(req) {
1165
- return ((typeof req.ip === "string" && req.ip.length > 0
1288
+ const raw = (typeof req.ip === "string" && req.ip.length > 0
1166
1289
  ? req.ip
1167
- : req.socket?.remoteAddress) ?? "unknown");
1290
+ : req.socket?.remoteAddress) ?? "unknown";
1291
+ return (0, express_rate_limit_1.ipKeyGenerator)(raw);
1168
1292
  }
1169
1293
  // ==================== Validation Helpers ====================
1170
1294
  validateDbId(dbId) {