mindoodb 0.0.9 → 0.0.11

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 (77) hide show
  1. package/dist/appendonlystores/InMemoryContentAddressedStoreFactory.d.ts.map +1 -1
  2. package/dist/appendonlystores/InMemoryContentAddressedStoreFactory.js +3 -5
  3. package/dist/appendonlystores/InMemoryContentAddressedStoreFactory.js.map +1 -1
  4. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +7 -2
  5. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
  6. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +23 -3
  7. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
  8. package/dist/appendonlystores/network/HttpTransport.d.ts +5 -0
  9. package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
  10. package/dist/appendonlystores/network/HttpTransport.js +66 -36
  11. package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
  12. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts +4 -1
  13. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts.map +1 -1
  14. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js +15 -3
  15. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js.map +1 -1
  16. package/dist/browser/appendonlystores/IndexedDBContentAddressedStoreFactory.d.ts.map +1 -1
  17. package/dist/browser/appendonlystores/IndexedDBContentAddressedStoreFactory.js +7 -3
  18. package/dist/browser/appendonlystores/IndexedDBContentAddressedStoreFactory.js.map +1 -1
  19. package/dist/browser/index.mjs +1 -0
  20. package/dist/core/BaseMindooDB.d.ts +44 -5
  21. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  22. package/dist/core/BaseMindooDB.js +244 -53
  23. package/dist/core/BaseMindooDB.js.map +1 -1
  24. package/dist/core/BaseMindooTenant.d.ts +2 -3
  25. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  26. package/dist/core/BaseMindooTenant.js +9 -18
  27. package/dist/core/BaseMindooTenant.js.map +1 -1
  28. package/dist/core/BaseMindooTenantDirectory.d.ts +1 -0
  29. package/dist/core/BaseMindooTenantDirectory.d.ts.map +1 -1
  30. package/dist/core/BaseMindooTenantDirectory.js +24 -8
  31. package/dist/core/BaseMindooTenantDirectory.js.map +1 -1
  32. package/dist/core/appendonlystores/AttachmentReadPlanner.d.ts.map +1 -1
  33. package/dist/core/appendonlystores/AttachmentReadPlanner.js +1 -1
  34. package/dist/core/appendonlystores/AttachmentReadPlanner.js.map +1 -1
  35. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts +4 -2
  36. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts.map +1 -1
  37. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js +13 -8
  38. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js.map +1 -1
  39. package/dist/core/appendonlystores/network/NetworkTransport.d.ts +5 -1
  40. package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
  41. package/dist/core/appendonlystores/types.d.ts +14 -10
  42. package/dist/core/appendonlystores/types.d.ts.map +1 -1
  43. package/dist/core/appendonlystores/types.js +6 -0
  44. package/dist/core/appendonlystores/types.js.map +1 -1
  45. package/dist/core/types.d.ts +24 -12
  46. package/dist/core/types.d.ts.map +1 -1
  47. package/dist/core/types.js +3 -1
  48. package/dist/core/types.js.map +1 -1
  49. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts +5 -2
  50. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +1 -1
  51. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js +10 -4
  52. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js.map +1 -1
  53. package/dist/node/server/MindooDBServer.d.ts +105 -0
  54. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  55. package/dist/node/server/MindooDBServer.js +240 -66
  56. package/dist/node/server/MindooDBServer.js.map +1 -1
  57. package/dist/node/server/ServerSync.d.ts +1 -1
  58. package/dist/node/server/ServerSync.d.ts.map +1 -1
  59. package/dist/node/server/ServerSync.js +2 -1
  60. package/dist/node/server/ServerSync.js.map +1 -1
  61. package/dist/node/server/StoreFactory.d.ts +3 -3
  62. package/dist/node/server/StoreFactory.d.ts.map +1 -1
  63. package/dist/node/server/StoreFactory.js +8 -8
  64. package/dist/node/server/StoreFactory.js.map +1 -1
  65. package/dist/node/server/TenantManager.d.ts +5 -3
  66. package/dist/node/server/TenantManager.d.ts.map +1 -1
  67. package/dist/node/server/TenantManager.js +33 -20
  68. package/dist/node/server/TenantManager.js.map +1 -1
  69. package/dist/node/server/config.d.ts.map +1 -1
  70. package/dist/node/server/config.js +4 -0
  71. package/dist/node/server/config.js.map +1 -1
  72. package/dist/node/server/server.js +6 -5
  73. package/dist/node/server/server.js.map +1 -1
  74. package/dist/node/server/types.d.ts +1 -0
  75. package/dist/node/server/types.d.ts.map +1 -1
  76. package/dist/node/server/types.js.map +1 -1
  77. package/package.json +1 -1
@@ -51,21 +51,33 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
51
51
  Object.defineProperty(exports, "__esModule", { value: true });
52
52
  exports.MindooDBServer = void 0;
53
53
  const express_1 = __importStar(require("express"));
54
- const https_1 = __importDefault(require("https"));
54
+ const http2_1 = __importDefault(require("http2"));
55
55
  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
59
  const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
60
- const types_1 = require("../../core/appendonlystores/network/types");
60
+ const types_1 = require("../../core/appendonlystores/types");
61
+ const types_2 = require("../../core/appendonlystores/network/types");
61
62
  const TenantManager_1 = require("./TenantManager");
62
63
  const CapabilityMatcher_1 = require("./CapabilityMatcher");
63
64
  const SystemAdminAuth_1 = require("./SystemAdminAuth");
64
65
  const config_1 = require("./config");
65
66
  const SystemIpAllowlist_1 = require("./SystemIpAllowlist");
66
67
  const validation_1 = require("./validation");
68
+ /**
69
+ * Rate-limit defaults. Auth and sync have separate tiers because a single
70
+ * challenge/authenticate handshake is two requests; with multiple tenants,
71
+ * store kinds, and token renewals the auth budget exhausts quickly if set
72
+ * too low. These can be overridden in config.json under `rateLimits.auth`
73
+ * and `rateLimits.sync`.
74
+ */
75
+ const DEFAULT_AUTH_RATE_LIMIT_WINDOW_MS = 60000;
76
+ const DEFAULT_AUTH_RATE_LIMIT_MAX = 120;
67
77
  const DEFAULT_SYNC_RATE_LIMIT_WINDOW_MS = 60000;
68
78
  const DEFAULT_SYNC_RATE_LIMIT_MAX = 1000;
79
+ const DEFAULT_SERVER_SOCKET_TIMEOUT_MS = 120000;
80
+ /** Parse a human-readable size string ("5mb", "1024", "100kb") into bytes. */
69
81
  function parseBodySizeLimitToBytes(limit) {
70
82
  const normalized = limit.trim().toLowerCase();
71
83
  if (!normalized) {
@@ -90,6 +102,15 @@ function parseBodySizeLimitToBytes(limit) {
90
102
  return Math.round(value * multiplier);
91
103
  }
92
104
  class MindooDBServer {
105
+ /**
106
+ * @param dataDir Root directory for on-disk tenant data and server identity
107
+ * @param serverPassword Password used to decrypt the server identity private keys
108
+ * and per-tenant key bags at startup
109
+ * @param staticDir Optional directory served at /statics/ (e.g. the Haven web UI)
110
+ * @param config Pre-loaded ServerConfig; when omitted, an empty config
111
+ * is used (all /system/* endpoints locked down)
112
+ * @param configPath Explicit path to config.json; defaults to `<dataDir>/config.json`
113
+ */
93
114
  constructor(dataDir, serverPassword, staticDir, config, configPath) {
94
115
  this.app = (0, express_1.default)();
95
116
  this.tenantManager = new TenantManager_1.TenantManager(dataDir, serverPassword);
@@ -99,6 +120,8 @@ class MindooDBServer {
99
120
  this.jsonBodyLimitBytes = parseBodySizeLimitToBytes(this.jsonBodyLimit);
100
121
  this.serverConfig = config ?? { capabilities: {} };
101
122
  this.capabilityMatcher = new CapabilityMatcher_1.CapabilityMatcher(this.serverConfig);
123
+ // SystemAdminAuthService generates a random HMAC-SHA256 secret for JWTs
124
+ // on each server start — existing tokens become invalid after restart.
102
125
  const cryptoAdapter = this.tenantManager.getCryptoAdapter();
103
126
  this.systemAdminAuth = new SystemAdminAuth_1.SystemAdminAuthService(cryptoAdapter, this.serverConfig);
104
127
  const principalCount = Object.values(this.serverConfig.capabilities).reduce((sum, arr) => sum + arr.length, 0);
@@ -126,6 +149,11 @@ class MindooDBServer {
126
149
  getConfigPath() {
127
150
  return this.configPath;
128
151
  }
152
+ /**
153
+ * Hot-swap the server config without restarting. In-flight JWTs remain
154
+ * valid (same HMAC secret), but removed principals will fail the
155
+ * CapabilityMatcher check on their next request.
156
+ */
129
157
  reloadConfig(newConfig) {
130
158
  this.serverConfig = newConfig;
131
159
  this.capabilityMatcher.reload(newConfig);
@@ -135,19 +163,37 @@ class MindooDBServer {
135
163
  const server = this.app.listen(port, () => {
136
164
  console.log(`[MindooDBServer] Listening on port ${port}`);
137
165
  });
138
- server.setTimeout(30000);
166
+ server.setTimeout(DEFAULT_SERVER_SOCKET_TIMEOUT_MS);
139
167
  }
168
+ /**
169
+ * Start the server with TLS using HTTP/2 (with automatic HTTP/1.1 ALPN
170
+ * fallback for clients that don't support h2). Express is mounted as the
171
+ * raw handler because http2.createSecureServer doesn't natively integrate
172
+ * with Express; the `as unknown` casts bridge the type gap.
173
+ */
140
174
  listenTls(port, certPath, keyPath) {
141
175
  const tlsOptions = {
176
+ allowHTTP1: true,
142
177
  cert: (0, fs_1.readFileSync)(certPath),
143
178
  key: (0, fs_1.readFileSync)(keyPath),
144
179
  };
145
- const server = https_1.default.createServer(tlsOptions, this.app);
180
+ const server = http2_1.default.createSecureServer(tlsOptions, (req, res) => {
181
+ this.app(req, res);
182
+ });
146
183
  server.listen(port, () => {
147
- console.log(`[MindooDBServer] Listening on HTTPS port ${port}`);
184
+ console.log(`[MindooDBServer] Listening on HTTPS port ${port} with HTTP/2 enabled (HTTP/1 fallback active)`);
148
185
  });
149
- server.setTimeout(30000);
186
+ server.setTimeout(DEFAULT_SERVER_SOCKET_TIMEOUT_MS);
150
187
  }
188
+ /**
189
+ * Global middleware stack applied to every request, in order:
190
+ * 1. helmet — security headers (CSP, X-Frame-Options, etc.)
191
+ * 2. cors — controlled cross-origin access for web clients (e.g. Haven)
192
+ * 3. global rate limit — coarse IP-based throttle as a safety net;
193
+ * individual route tiers (auth, sync, system) provide finer control
194
+ * 4. JSON body parser — captures raw byte size for diagnostics
195
+ * 5. request logger
196
+ */
151
197
  setupMiddleware() {
152
198
  this.app.use((0, helmet_1.default)());
153
199
  const corsOrigin = this.readCorsOriginsFromEnv();
@@ -172,10 +218,11 @@ class MindooDBServer {
172
218
  this.app.use((req, res, next) => {
173
219
  const contentType = req.headers["content-type"] ?? "-";
174
220
  const contentLength = req.headers["content-length"] ?? "-";
175
- console.log(`[${new Date().toISOString()}] ${req.method} ${req.path} content-type=${contentType} content-length=${contentLength}`);
221
+ console.log(`[${new Date().toISOString()}] HTTP/${req.httpVersion} ${req.method} ${req.path} content-type=${contentType} content-length=${contentLength}`);
176
222
  next();
177
223
  });
178
224
  }
225
+ /** Parse MINDOODB_CORS_ORIGIN env var; supports comma-separated multiple origins. */
179
226
  readCorsOriginsFromEnv() {
180
227
  const raw = process.env.MINDOODB_CORS_ORIGIN?.trim();
181
228
  if (!raw) {
@@ -190,8 +237,16 @@ class MindooDBServer {
190
237
  }
191
238
  return origins;
192
239
  }
240
+ /**
241
+ * Route hierarchy:
242
+ * / — redirect to static UI (if present)
243
+ * /health — unauthenticated health probe
244
+ * /.well-known/* — public discovery endpoints (server info, tenant fingerprints)
245
+ * /statics/* — optional static file serving (Haven web UI)
246
+ * /system/* — admin API: IP-allowlisted, JWT-authenticated, capability-checked
247
+ * /:tenantId/* — per-tenant sync & auth API
248
+ */
193
249
  setupRoutes() {
194
- // Root redirect
195
250
  this.app.get("/", (req, res) => {
196
251
  if (this.staticDir && (0, fs_1.existsSync)(path_1.default.join(this.staticDir, "index.html"))) {
197
252
  res.redirect(302, "/statics/index.html");
@@ -203,6 +258,8 @@ class MindooDBServer {
203
258
  this.app.get("/health", (req, res) => {
204
259
  res.json({ status: "ok", timestamp: Date.now() });
205
260
  });
261
+ // .well-known endpoints are unauthenticated so clients can discover
262
+ // server identity and tenant availability before attempting to authenticate.
206
263
  this.app.get("/.well-known/mindoodb-server-info", (req, res) => {
207
264
  const info = this.tenantManager.getServerPublicInfo();
208
265
  if (!info) {
@@ -255,7 +312,10 @@ class MindooDBServer {
255
312
  }, express_1.default.static(resolvedStaticDir, { dotfiles: "deny", etag: true }));
256
313
  console.log(`[MindooDBServer] Serving static files from ${resolvedStaticDir} at /statics/`);
257
314
  }
258
- // System admin routes (replaces former /admin/*)
315
+ // /system/* admin routes are protected by three layers (applied in order):
316
+ // 1. IP allowlist — optional whitelist via MINDOODB_ADMIN_ALLOWED_IPS env var
317
+ // 2. Rate limit — aggressive per-IP throttle (30 req/min)
318
+ // 3. JWT auth — applied per-route (auth endpoints exempt; they *produce* JWTs)
259
319
  const systemIpRaw = (0, SystemIpAllowlist_1.readSystemIpAllowListFromEnv)();
260
320
  if (!(0, SystemIpAllowlist_1.isSystemIpAllowListDisabled)(systemIpRaw)) {
261
321
  console.log(`[MindooDBServer] MINDOODB_ADMIN_ALLOWED_IPS is set — /system/* limited to: ${systemIpRaw}`);
@@ -270,8 +330,10 @@ class MindooDBServer {
270
330
  const systemRouter = (0, express_1.Router)();
271
331
  this.setupSystemRoutes(systemRouter);
272
332
  this.app.use("/system", SystemIpAllowlist_1.systemIpAllowlistMiddleware, systemRateLimit, systemRouter);
273
- // Tenant-scoped routes
333
+ // Tenant-scoped routes: tenantMiddleware validates the :tenantId param
334
+ // and rejects unknown tenants before any handler runs.
274
335
  this.app.use("/:tenantId", this.tenantMiddleware.bind(this), this.createTenantRouter());
336
+ // Express error handler — must be registered last and have the 4-arg signature
275
337
  this.app.use(this.errorHandler.bind(this));
276
338
  }
277
339
  // ==================== System Admin Auth Middleware ====================
@@ -328,18 +390,17 @@ class MindooDBServer {
328
390
  next();
329
391
  }
330
392
  // ==================== System Routes ====================
393
+ /**
394
+ * Wire up all /system/* routes. Auth endpoints sit outside the JWT
395
+ * middleware because they implement the challenge-response handshake
396
+ * that *produces* the JWT. Every other route requires a valid JWT and
397
+ * passes through the CapabilityMatcher for fine-grained authorization.
398
+ */
331
399
  setupSystemRoutes(router) {
332
- // Auth endpoints — no JWT required (these *produce* JWTs)
333
- const authRateLimit = (0, express_rate_limit_1.default)({
334
- windowMs: 60000,
335
- max: 20,
336
- standardHeaders: true,
337
- legacyHeaders: false,
338
- message: { error: "Too many authentication attempts, please try again later" },
339
- });
340
- router.post("/auth/challenge", authRateLimit, this.handleSystemChallenge.bind(this));
341
- router.post("/auth/authenticate", authRateLimit, this.handleSystemAuthenticate.bind(this));
342
- // All other /system/* routes require JWT + capability check
400
+ const challengeRateLimit = this.createAuthRateLimit("system", "challenge");
401
+ const authenticateRateLimit = this.createAuthRateLimit("system", "authenticate");
402
+ router.post("/auth/challenge", challengeRateLimit, this.handleSystemChallenge.bind(this));
403
+ router.post("/auth/authenticate", authenticateRateLimit, this.handleSystemAuthenticate.bind(this));
343
404
  const authMiddleware = this.systemAdminMiddleware.bind(this);
344
405
  // Tenant CRUD
345
406
  router.get("/tenants", authMiddleware, (req, res) => {
@@ -353,6 +414,8 @@ class MindooDBServer {
353
414
  res.status(500).json({ error: "Failed to list tenants" });
354
415
  }
355
416
  });
417
+ // Tenant registration is idempotent: re-POSTing with the same tenantId
418
+ // and matching $publicinfos key returns 200 instead of 409.
356
419
  router.post("/tenants/:tenantId", authMiddleware, async (req, res) => {
357
420
  try {
358
421
  const tenantId = req.params.tenantId.toLowerCase();
@@ -724,7 +787,8 @@ class MindooDBServer {
724
787
  });
725
788
  return;
726
789
  }
727
- // Self-lockout protection: verify the calling admin retains PUT /system/config access
790
+ // Self-lockout protection: simulate the proposed config against the calling
791
+ // admin's identity — reject if they would lose their own config access.
728
792
  const callingAdmin = req.systemAdmin;
729
793
  const proposedMatcher = new CapabilityMatcher_1.CapabilityMatcher(newConfig);
730
794
  if (!proposedMatcher.isAuthorized("PUT", "/system/config", callingAdmin.username, callingAdmin.publicsignkey)) {
@@ -756,18 +820,20 @@ class MindooDBServer {
756
820
  }
757
821
  }
758
822
  // ==================== Tenant Routes ====================
823
+ /**
824
+ * Build the per-tenant router. Rate limiters are scoped: auth endpoints
825
+ * use a per-user composite key (see createAuthRateLimit) while sync
826
+ * endpoints use a plain IP-based bucket with a higher ceiling.
827
+ *
828
+ * Both "docs" and "attachments" store kinds expose the same set of sync
829
+ * operations, mounted at /sync/docs/* and /sync/attachments/*.
830
+ */
759
831
  createTenantRouter() {
760
832
  const router = (0, express_1.Router)({ mergeParams: true });
833
+ const authRateLimitConfig = this.getAuthRateLimitConfig();
761
834
  const syncRateLimitConfig = this.getSyncRateLimitConfig();
762
- // Auth endpoints get stricter rate limiting
763
- const authRateLimit = (0, express_rate_limit_1.default)({
764
- windowMs: 60000,
765
- max: 20,
766
- standardHeaders: true,
767
- legacyHeaders: false,
768
- message: { error: "Too many authentication attempts, please try again later" },
769
- });
770
- // Sync endpoints get a higher limit
835
+ const challengeRateLimit = this.createAuthRateLimit("tenant", "challenge");
836
+ const authenticateRateLimit = this.createAuthRateLimit("tenant", "authenticate");
771
837
  const syncRateLimit = (0, express_rate_limit_1.default)({
772
838
  windowMs: syncRateLimitConfig.windowMs,
773
839
  max: syncRateLimitConfig.max,
@@ -775,32 +841,107 @@ class MindooDBServer {
775
841
  legacyHeaders: false,
776
842
  message: { error: "Too many sync requests, please try again later" },
777
843
  });
778
- router.post("/auth/challenge", authRateLimit, this.handleChallenge.bind(this));
779
- router.post("/auth/authenticate", authRateLimit, this.handleAuthenticate.bind(this));
780
- router.post("/sync/findNewEntries", syncRateLimit, this.handleFindNewEntries.bind(this));
781
- router.post("/sync/findNewEntriesForDoc", syncRateLimit, this.handleFindNewEntriesForDoc.bind(this));
782
- router.post("/sync/findEntries", syncRateLimit, this.handleFindEntries.bind(this));
783
- router.post("/sync/scanEntriesSince", syncRateLimit, this.handleScanEntriesSince.bind(this));
784
- router.post("/sync/getIdBloomSummary", syncRateLimit, this.handleGetIdBloomSummary.bind(this));
785
- router.post("/sync/getCompactionStatus", syncRateLimit, this.handleGetCompactionStatus.bind(this));
786
- router.get("/sync/capabilities", syncRateLimit, this.handleGetCapabilities.bind(this));
787
- router.post("/sync/getEntries", syncRateLimit, this.handleGetEntries.bind(this));
788
- router.post("/sync/getEntryMetadata", syncRateLimit, this.handleGetEntryMetadata.bind(this));
789
- router.post("/sync/putEntries", syncRateLimit, this.handlePutEntries.bind(this));
790
- router.post("/sync/hasEntries", syncRateLimit, this.handleHasEntries.bind(this));
791
- router.get("/sync/getAllIds", syncRateLimit, this.handleGetAllIds.bind(this));
792
- router.post("/sync/resolveDependencies", syncRateLimit, this.handleResolveDependencies.bind(this));
793
- router.post("/sync/planDocumentMaterialization", syncRateLimit, this.handlePlanDocumentMaterialization.bind(this));
794
- router.post("/sync/planDocumentMaterializationBatch", syncRateLimit, this.handlePlanDocumentMaterializationBatch.bind(this));
795
- router.post("/sync/planAttachmentReadByWalkingMetadata", syncRateLimit, this.handlePlanAttachmentReadByWalkingMetadata.bind(this));
844
+ console.log(`[MindooDBServer] Tenant auth rate limit configured windowMs=${authRateLimitConfig.windowMs} max=${authRateLimitConfig.max}`);
845
+ router.post("/auth/challenge", challengeRateLimit, this.handleChallenge.bind(this));
846
+ router.post("/auth/authenticate", authenticateRateLimit, this.handleAuthenticate.bind(this));
847
+ for (const storeKind of ["docs", "attachments"]) {
848
+ const syncBase = `/sync/${storeKind}`;
849
+ router.post(`${syncBase}/findNewEntries`, syncRateLimit, this.handleFindNewEntries.bind(this));
850
+ router.post(`${syncBase}/findNewEntriesForDoc`, syncRateLimit, this.handleFindNewEntriesForDoc.bind(this));
851
+ router.post(`${syncBase}/findEntries`, syncRateLimit, this.handleFindEntries.bind(this));
852
+ router.post(`${syncBase}/scanEntriesSince`, syncRateLimit, this.handleScanEntriesSince.bind(this));
853
+ router.post(`${syncBase}/getIdBloomSummary`, syncRateLimit, this.handleGetIdBloomSummary.bind(this));
854
+ router.post(`${syncBase}/getCompactionStatus`, syncRateLimit, this.handleGetCompactionStatus.bind(this));
855
+ router.get(`${syncBase}/capabilities`, syncRateLimit, this.handleGetCapabilities.bind(this));
856
+ router.post(`${syncBase}/getEntries`, syncRateLimit, this.handleGetEntries.bind(this));
857
+ router.post(`${syncBase}/getEntryMetadata`, syncRateLimit, this.handleGetEntryMetadata.bind(this));
858
+ router.post(`${syncBase}/putEntries`, syncRateLimit, this.handlePutEntries.bind(this));
859
+ router.post(`${syncBase}/hasEntries`, syncRateLimit, this.handleHasEntries.bind(this));
860
+ router.get(`${syncBase}/getAllIds`, syncRateLimit, this.handleGetAllIds.bind(this));
861
+ router.post(`${syncBase}/resolveDependencies`, syncRateLimit, this.handleResolveDependencies.bind(this));
862
+ router.post(`${syncBase}/planDocumentMaterialization`, syncRateLimit, this.handlePlanDocumentMaterialization.bind(this));
863
+ router.post(`${syncBase}/planDocumentMaterializationBatch`, syncRateLimit, this.handlePlanDocumentMaterializationBatch.bind(this));
864
+ router.post(`${syncBase}/planAttachmentReadByWalkingMetadata`, syncRateLimit, this.handlePlanAttachmentReadByWalkingMetadata.bind(this));
865
+ }
796
866
  return router;
797
867
  }
868
+ /** Resolved auth rate-limit config; falls back to defaults when not set in config.json. */
869
+ getAuthRateLimitConfig() {
870
+ return {
871
+ windowMs: this.serverConfig.rateLimits?.auth?.windowMs ?? DEFAULT_AUTH_RATE_LIMIT_WINDOW_MS,
872
+ max: this.serverConfig.rateLimits?.auth?.max ?? DEFAULT_AUTH_RATE_LIMIT_MAX,
873
+ };
874
+ }
875
+ /** Resolved sync rate-limit config; falls back to defaults when not set in config.json. */
798
876
  getSyncRateLimitConfig() {
799
877
  return {
800
878
  windowMs: this.serverConfig.rateLimits?.sync?.windowMs ?? DEFAULT_SYNC_RATE_LIMIT_WINDOW_MS,
801
879
  max: this.serverConfig.rateLimits?.sync?.max ?? DEFAULT_SYNC_RATE_LIMIT_MAX,
802
880
  };
803
881
  }
882
+ /**
883
+ * Create a rate-limiter for authentication endpoints. Challenge and
884
+ * authenticate get separate limiters so that one phase can't starve the
885
+ * other. The custom keyGenerator buckets by (scope, tenant, phase, IP,
886
+ * username) — this prevents users behind a shared IP (NAT / reverse
887
+ * proxy) from exhausting each other's budgets.
888
+ */
889
+ createAuthRateLimit(scope, phase) {
890
+ const authRateLimitConfig = this.getAuthRateLimitConfig();
891
+ return (0, express_rate_limit_1.default)({
892
+ windowMs: authRateLimitConfig.windowMs,
893
+ max: authRateLimitConfig.max,
894
+ standardHeaders: true,
895
+ legacyHeaders: false,
896
+ keyGenerator: (req) => this.getAuthRateLimitKey(req, scope, phase),
897
+ handler: (req, res, _next, options) => {
898
+ this.logAuthRateLimitHit(req, scope, phase);
899
+ res.status(options.statusCode).json(options.message);
900
+ },
901
+ message: { error: "Too many authentication attempts, please try again later" },
902
+ });
903
+ }
904
+ /**
905
+ * Composite rate-limit key. Challenge requests include the username so
906
+ * that brute-forcing one account doesn't block legitimate users on the
907
+ * same IP. Authenticate requests omit username because the challenge
908
+ * token is already scoped to a single principal.
909
+ */
910
+ getAuthRateLimitKey(req, scope, phase) {
911
+ const tenantId = scope === "tenant"
912
+ ? req.tenantId ?? req.params.tenantId?.toLowerCase() ?? "-"
913
+ : "system";
914
+ const clientIp = this.getClientIpForRateLimit(req);
915
+ if (phase === "challenge") {
916
+ const username = typeof req.body?.username === "string"
917
+ ? req.body.username.trim().toLowerCase()
918
+ : "-";
919
+ return `${scope}:${tenantId}:${phase}:${clientIp}:${username}`;
920
+ }
921
+ return `${scope}:${tenantId}:${phase}:${clientIp}`;
922
+ }
923
+ /**
924
+ * Emit a structured warning when an auth rate limit is triggered.
925
+ * The forwarded-for header is logged to help diagnose situations where
926
+ * a reverse proxy collapses many clients onto a single visible IP.
927
+ */
928
+ logAuthRateLimitHit(req, scope, phase) {
929
+ const forwardedForHeader = req.headers["x-forwarded-for"];
930
+ const forwardedFor = Array.isArray(forwardedForHeader)
931
+ ? forwardedForHeader.join(", ")
932
+ : forwardedForHeader;
933
+ const username = typeof req.body?.username === "string" ? req.body.username : "-";
934
+ 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}`);
935
+ }
936
+ /**
937
+ * Best-effort client IP. Express populates `req.ip` from the trust-proxy
938
+ * setting; fall back to the raw socket address when it's unavailable.
939
+ */
940
+ getClientIpForRateLimit(req) {
941
+ return ((typeof req.ip === "string" && req.ip.length > 0
942
+ ? req.ip
943
+ : req.socket?.remoteAddress) ?? "unknown");
944
+ }
804
945
  // ==================== Validation Helpers ====================
805
946
  validateDbId(dbId) {
806
947
  return (0, validation_1.validateIdentifier)(dbId, "dbId");
@@ -810,6 +951,10 @@ class MindooDBServer {
810
951
  return `Tenant ${tenantId} not found on server ${serverName}`;
811
952
  }
812
953
  // ==================== Tenant Auth Handlers ====================
954
+ // Two-step challenge-response flow:
955
+ // 1. POST /auth/challenge → client sends username, server returns a random nonce
956
+ // 2. POST /auth/authenticate → client signs the nonce with its Ed25519 private key,
957
+ // server verifies the signature and returns a JWT for subsequent sync calls
813
958
  async handleChallenge(req, res) {
814
959
  try {
815
960
  const { username } = req.body;
@@ -847,7 +992,7 @@ class MindooDBServer {
847
992
  const { dbId, haveIds } = req.body;
848
993
  const validDbId = this.validateDbId(dbId);
849
994
  (0, validation_1.validateArraySize)(haveIds, validation_1.MAX_HAVE_IDS, "haveIds");
850
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
995
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
851
996
  const entries = await serverStore.handleFindNewEntries(token, haveIds || []);
852
997
  res.json({
853
998
  entries: entries.map((e) => this.serializeEntryMetadata(e)),
@@ -867,7 +1012,7 @@ class MindooDBServer {
867
1012
  return;
868
1013
  }
869
1014
  (0, validation_1.validateArraySize)(haveIds, validation_1.MAX_HAVE_IDS, "haveIds");
870
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1015
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
871
1016
  const entries = await serverStore.handleFindNewEntriesForDoc(token, haveIds || [], docId);
872
1017
  res.json({
873
1018
  entries: entries.map((e) => this.serializeEntryMetadata(e)),
@@ -882,7 +1027,7 @@ class MindooDBServer {
882
1027
  const token = this.extractToken(req);
883
1028
  const { dbId, type, creationDateFrom, creationDateUntil } = req.body;
884
1029
  const validDbId = this.validateDbId(dbId);
885
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1030
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
886
1031
  const entries = await serverStore.handleFindEntries(token, type, creationDateFrom ?? null, creationDateUntil ?? null);
887
1032
  res.json({
888
1033
  entries: entries.map((e) => this.serializeEntryMetadata(e)),
@@ -897,7 +1042,7 @@ class MindooDBServer {
897
1042
  const token = this.extractToken(req);
898
1043
  const { dbId, cursor, limit, filters } = req.body;
899
1044
  const validDbId = this.validateDbId(dbId);
900
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1045
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
901
1046
  const result = await serverStore.handleScanEntriesSince(token, cursor ?? null, limit, filters);
902
1047
  res.json({
903
1048
  entries: result.entries.map((e) => this.serializeEntryMetadata(e)),
@@ -914,7 +1059,7 @@ class MindooDBServer {
914
1059
  const token = this.extractToken(req);
915
1060
  const { dbId } = req.body;
916
1061
  const validDbId = this.validateDbId(dbId);
917
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1062
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
918
1063
  const summary = await serverStore.handleGetIdBloomSummary(token);
919
1064
  res.json({ summary: summary });
920
1065
  }
@@ -927,7 +1072,7 @@ class MindooDBServer {
927
1072
  const token = this.extractToken(req);
928
1073
  const rawDbId = req.query.dbId || "directory";
929
1074
  const validDbId = this.validateDbId(rawDbId);
930
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1075
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
931
1076
  const capabilities = await serverStore.handleGetCapabilities(token);
932
1077
  res.json({ capabilities: capabilities });
933
1078
  }
@@ -940,7 +1085,7 @@ class MindooDBServer {
940
1085
  const token = this.extractToken(req);
941
1086
  const { dbId } = req.body;
942
1087
  const validDbId = this.validateDbId(dbId);
943
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1088
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
944
1089
  const status = await serverStore.handleGetCompactionStatus(token);
945
1090
  res.json({ status: status });
946
1091
  }
@@ -954,7 +1099,7 @@ class MindooDBServer {
954
1099
  const { dbId, ids } = req.body;
955
1100
  const validDbId = this.validateDbId(dbId);
956
1101
  (0, validation_1.validateArraySize)(ids, validation_1.MAX_ENTRY_IDS, "ids");
957
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1102
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
958
1103
  const entries = await serverStore.handleGetEntries(token, ids || []);
959
1104
  res.json({
960
1105
  entries: entries.map((e) => ({
@@ -976,7 +1121,7 @@ class MindooDBServer {
976
1121
  res.status(400).json({ error: "id is required" });
977
1122
  return;
978
1123
  }
979
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1124
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
980
1125
  const entry = await serverStore.handleGetEntryMetadata(token, id);
981
1126
  res.json({
982
1127
  entry: entry ? this.serializeEntryMetadata(entry) : null,
@@ -986,6 +1131,11 @@ class MindooDBServer {
986
1131
  this.handleRequestError(error, res);
987
1132
  }
988
1133
  }
1134
+ /**
1135
+ * Ingest encrypted entries from a client. The `stage` variable tracks
1136
+ * progress through the pipeline so that error logs pinpoint the failing
1137
+ * step (token extraction → body parsing → deserialization → storage).
1138
+ */
989
1139
  async handlePutEntries(req, res) {
990
1140
  let stage = "extract-token";
991
1141
  try {
@@ -1002,7 +1152,7 @@ class MindooDBServer {
1002
1152
  }, {});
1003
1153
  console.log(`[MindooDBServer] putEntries request tenant=${req.tenantId ?? "-"} db=${validDbId} entries=${serializedEntries.length} parsed-bytes=${req.jsonBodyBytes ?? "-"} content-length=${req.headers["content-length"] ?? "-"} types=${JSON.stringify(serializedEntryTypeCounts)}`);
1004
1154
  stage = "load-server-store";
1005
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1155
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
1006
1156
  stage = "deserialize-entries";
1007
1157
  const deserializedEntries = serializedEntries.map((entry, index) => {
1008
1158
  try {
@@ -1050,7 +1200,7 @@ class MindooDBServer {
1050
1200
  const { dbId, ids } = req.body;
1051
1201
  const validDbId = this.validateDbId(dbId);
1052
1202
  (0, validation_1.validateArraySize)(ids, validation_1.MAX_ENTRY_IDS, "ids");
1053
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1203
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
1054
1204
  const existingIds = await serverStore.handleHasEntries(token, ids || []);
1055
1205
  res.json({ ids: existingIds });
1056
1206
  }
@@ -1063,7 +1213,7 @@ class MindooDBServer {
1063
1213
  const token = this.extractToken(req);
1064
1214
  const rawDbId = req.query.dbId || "directory";
1065
1215
  const validDbId = this.validateDbId(rawDbId);
1066
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1216
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
1067
1217
  const ids = await serverStore.handleGetAllIds(token);
1068
1218
  res.json({ ids });
1069
1219
  }
@@ -1080,7 +1230,7 @@ class MindooDBServer {
1080
1230
  res.status(400).json({ error: "startId is required" });
1081
1231
  return;
1082
1232
  }
1083
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1233
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
1084
1234
  const ids = await serverStore.handleResolveDependencies(token, startId, options);
1085
1235
  res.json({ ids });
1086
1236
  }
@@ -1097,7 +1247,7 @@ class MindooDBServer {
1097
1247
  res.status(400).json({ error: "docId is required" });
1098
1248
  return;
1099
1249
  }
1100
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1250
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
1101
1251
  const plan = await serverStore.handlePlanDocumentMaterialization(token, docId, options);
1102
1252
  res.json({ plan: plan });
1103
1253
  }
@@ -1111,7 +1261,7 @@ class MindooDBServer {
1111
1261
  const { dbId, docIds, options } = req.body;
1112
1262
  const validDbId = this.validateDbId(dbId);
1113
1263
  (0, validation_1.validateArraySize)(docIds, validation_1.MAX_HAVE_IDS, "docIds");
1114
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1264
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
1115
1265
  const batchPlan = await serverStore.handlePlanDocumentMaterializationBatch(token, docIds || [], options);
1116
1266
  res.json({ batchPlan: batchPlan });
1117
1267
  }
@@ -1136,7 +1286,7 @@ class MindooDBServer {
1136
1286
  res.status(400).json({ error: "options.startByte is required" });
1137
1287
  return;
1138
1288
  }
1139
- const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId);
1289
+ const serverStore = await this.tenantManager.getServerStore(req.tenantId, validDbId, this.getStoreKindFromRequest(req));
1140
1290
  const plan = await serverStore.handlePlanAttachmentReadByWalkingMetadata(token, lastChunkId, attachmentSize, options);
1141
1291
  res.json({ plan: plan });
1142
1292
  }
@@ -1149,13 +1299,29 @@ class MindooDBServer {
1149
1299
  res.json({ success: true, message: "Sync triggered (not yet implemented)" });
1150
1300
  }
1151
1301
  // ==================== Helper Methods ====================
1302
+ /**
1303
+ * Derive the StoreKind from the request path. Both "docs" and
1304
+ * "attachments" share the same handler implementations; the store kind
1305
+ * selects which on-disk append-only store to operate against.
1306
+ */
1307
+ getStoreKindFromRequest(req) {
1308
+ const trimmedPath = req.path.replace(/\/$/, "");
1309
+ if (trimmedPath.startsWith("/sync/docs/") || trimmedPath === "/sync/docs/capabilities" || trimmedPath === "/sync/docs/getAllIds") {
1310
+ return types_1.StoreKind.docs;
1311
+ }
1312
+ if (trimmedPath.startsWith("/sync/attachments/") || trimmedPath === "/sync/attachments/capabilities" || trimmedPath === "/sync/attachments/getAllIds") {
1313
+ return types_1.StoreKind.attachments;
1314
+ }
1315
+ throw new Error(`Unsupported sync store kind for path: ${req.path}`);
1316
+ }
1152
1317
  extractToken(req) {
1153
1318
  const auth = req.headers.authorization;
1154
1319
  if (!auth || !auth.startsWith("Bearer ")) {
1155
- throw new types_1.NetworkError("INVALID_TOKEN", "Missing or invalid Authorization header");
1320
+ throw new types_2.NetworkError("INVALID_TOKEN", "Missing or invalid Authorization header");
1156
1321
  }
1157
1322
  return auth.substring(7);
1158
1323
  }
1324
+ /** Map domain errors to HTTP status codes; unknown errors become 500. */
1159
1325
  handleRequestError(error, res) {
1160
1326
  if (error instanceof validation_1.ValidationError) {
1161
1327
  res.status(400).json({ error: error.message });
@@ -1186,6 +1352,11 @@ class MindooDBServer {
1186
1352
  return 500;
1187
1353
  }
1188
1354
  }
1355
+ /**
1356
+ * Express error handler (4-arg signature). Handles body-parser errors
1357
+ * (413 entity too large, 400 parse failed) with structured JSON responses;
1358
+ * everything else is logged and returned as a generic 500.
1359
+ */
1189
1360
  errorHandler(err, req, res, next) {
1190
1361
  const parserError = err;
1191
1362
  const status = parserError.statusCode ?? parserError.status;
@@ -1221,6 +1392,9 @@ class MindooDBServer {
1221
1392
  res.status(500).json({ error: "Internal server error" });
1222
1393
  }
1223
1394
  // ==================== Serialization ====================
1395
+ // Entries are stored with binary fields (Uint8Array) internally but
1396
+ // transmitted as base64 strings over JSON. These methods handle the
1397
+ // conversion in both directions.
1224
1398
  serializeEntryMetadata(metadata) {
1225
1399
  return {
1226
1400
  entryType: metadata.entryType,