@objy/server 0.0.0-stage → 0.1.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 (55) hide show
  1. package/API.md +1469 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +9 -0
  4. package/README.md +21 -2
  5. package/objydb.config.json +48 -0
  6. package/package.json +42 -4
  7. package/public/index.html +4134 -0
  8. package/public/objy-db-logo.png +0 -0
  9. package/src/aggregate.js +438 -0
  10. package/src/api.js +15 -0
  11. package/src/auth.js +472 -0
  12. package/src/clock.js +18 -0
  13. package/src/cluster-secret.js +25 -0
  14. package/src/cluster-status.js +562 -0
  15. package/src/compression-stats.js +32 -0
  16. package/src/config.js +360 -0
  17. package/src/crdt.js +1076 -0
  18. package/src/derived-state-scheduler.js +128 -0
  19. package/src/files.js +324 -0
  20. package/src/hnsw.js +269 -0
  21. package/src/http-cors.js +31 -0
  22. package/src/http-router.js +162 -0
  23. package/src/index.js +137 -0
  24. package/src/interfaces/auth.js +123 -0
  25. package/src/interfaces/cluster.js +296 -0
  26. package/src/interfaces/public-object.js +2452 -0
  27. package/src/interfaces/ui.js +38 -0
  28. package/src/lib/crypto.cjs +54 -0
  29. package/src/lib/jwt.cjs +231 -0
  30. package/src/local-wal.js +122 -0
  31. package/src/memory-guard.js +152 -0
  32. package/src/ndjson.js +117 -0
  33. package/src/node-runtime.js +29 -0
  34. package/src/object-hooks.js +282 -0
  35. package/src/object-id.js +7 -0
  36. package/src/object-storage.js +263 -0
  37. package/src/object-store.js +288 -0
  38. package/src/patch.js +87 -0
  39. package/src/permissions.js +178 -0
  40. package/src/persistence.js +1918 -0
  41. package/src/plugin-loader.js +21 -0
  42. package/src/query-planner.js +266 -0
  43. package/src/query.js +416 -0
  44. package/src/references.js +349 -0
  45. package/src/replication-scheduler.js +303 -0
  46. package/src/restore-status.js +77 -0
  47. package/src/runtime.js +47 -0
  48. package/src/server.js +242 -0
  49. package/src/shape.js +286 -0
  50. package/src/shard-map-store.js +85 -0
  51. package/src/shard-map.js +333 -0
  52. package/src/shard-router.js +737 -0
  53. package/src/store.js +3410 -0
  54. package/src/system-secrets.js +107 -0
  55. package/src/vector.js +83 -0
package/src/auth.js ADDED
@@ -0,0 +1,472 @@
1
+ import { createRequire } from "node:module";
2
+ import { normalizeCollectionPermissions, normalizePermissions } from "./permissions.js";
3
+ import { randomSecret } from "./system-secrets.js";
4
+
5
+ const require = createRequire(import.meta.url);
6
+ const JWT = require("./lib/jwt.cjs");
7
+ const { hashPassword, randomUUID, verifyPassword } = require("./lib/crypto.cjs");
8
+
9
+ export const GLOBAL_DATABASE = "_global";
10
+ export const USERS_COLLECTION = "users";
11
+
12
+ const DEFAULT_ACCESS_TTL_SECONDS = 60 * 60;
13
+ const DEFAULT_REFRESH_TTL_SECONDS = 14 * 24 * 60 * 60;
14
+
15
+ export function normalizeAuthConfig(config = {}) {
16
+ if (config === false || config.enabled === false) return { enabled: false };
17
+
18
+ return {
19
+ enabled: true,
20
+ jwtSecret: optionalString(config.jwtSecret),
21
+ accessTokenTtlSeconds: positiveInt(config.accessTokenTtlSeconds, DEFAULT_ACCESS_TTL_SECONDS),
22
+ refreshTokenTtlSeconds: positiveInt(config.refreshTokenTtlSeconds, DEFAULT_REFRESH_TTL_SECONDS),
23
+ clockToleranceSeconds: positiveInt(config.clockToleranceSeconds, 0),
24
+ users: Array.isArray(config.users) ? config.users : [],
25
+ };
26
+ }
27
+
28
+ export function createAuthService(node, config = {}, { environment = process.env } = {}) {
29
+ const normalized = normalizeAuthConfig(config);
30
+ if (normalized.enabled === false) return new DisabledAuthService();
31
+ normalized.jwtSecret ||= randomSecret();
32
+ const auth = new AuthService(node, normalized);
33
+ auth.ensureGlobalUsers({ adminPassword: environment?.OBJYDB_ADMIN_PASSWORD });
34
+ return auth;
35
+ }
36
+
37
+ export class AuthService {
38
+ constructor(node, config) {
39
+ if (!node) throw new Error("node is required");
40
+ this.node = node;
41
+ this.config = config;
42
+ this.enabled = true;
43
+ this.bootstrapNotice = null;
44
+ }
45
+
46
+ ensureGlobalUsers({ adminPassword } = {}) {
47
+ if (this.node.getDatabaseMeta(GLOBAL_DATABASE) === null) {
48
+ this.node.createDatabase(GLOBAL_DATABASE, { system: true });
49
+ }
50
+
51
+ const existingMeta = this.node.getCollectionMeta(GLOBAL_DATABASE, USERS_COLLECTION);
52
+ if (existingMeta === null) {
53
+ this.node.createCollection(GLOBAL_DATABASE, USERS_COLLECTION, {
54
+ system: true,
55
+ indexes: ["username"],
56
+ });
57
+ } else if (!Array.isArray(existingMeta.indexes) || !existingMeta.indexes.includes("username")) {
58
+ this.node.setCollectionMeta(GLOBAL_DATABASE, USERS_COLLECTION, {
59
+ ...existingMeta,
60
+ indexes: unique([...(existingMeta.indexes || []), "username"]),
61
+ });
62
+ }
63
+
64
+ for (const user of this.config.users) this.ensureBootstrapUser(user);
65
+ this.ensureDefaultAdmin(adminPassword);
66
+ }
67
+
68
+ ensureDefaultAdmin(adminPassword) {
69
+ const id = "admin";
70
+ const shard = this.node.shardForObject(GLOBAL_DATABASE, USERS_COLLECTION, id);
71
+ if (!this.node.isPrimaryForShard(shard)) return null;
72
+ if (this.node.get(GLOBAL_DATABASE, USERS_COLLECTION, id)) return null;
73
+ if (this.findUserByUsername(GLOBAL_DATABASE, USERS_COLLECTION, "admin")) return null;
74
+
75
+ const supplied = typeof adminPassword === "string" && adminPassword.length > 0;
76
+ const password = supplied ? adminPassword : randomSecret(18);
77
+ const user = this.ensureBootstrapUser({
78
+ _id: id,
79
+ username: "admin",
80
+ password,
81
+ role: "admin",
82
+ admin: true,
83
+ });
84
+ if (user) this.bootstrapNotice = { username: "admin", password: supplied ? null : password, supplied };
85
+ return user;
86
+ }
87
+
88
+ consumeBootstrapNotice() {
89
+ const notice = this.bootstrapNotice;
90
+ this.bootstrapNotice = null;
91
+ return notice;
92
+ }
93
+
94
+ ensureBootstrapUser(user) {
95
+ const { id: _ignoredId, _id, ...rest } = user;
96
+ const prepared = this.prepareUserObject({
97
+ role: "user",
98
+ ...rest,
99
+ _id: _id || user.username,
100
+ }, { partial: false });
101
+ const existingById = this.node.get(GLOBAL_DATABASE, USERS_COLLECTION, prepared._id);
102
+ const existingByUsername = this.findUserByUsername(GLOBAL_DATABASE, USERS_COLLECTION, prepared.username);
103
+ if (existingById || existingByUsername) return null;
104
+ return this.node.insertMany(GLOBAL_DATABASE, USERS_COLLECTION, [prepared])[0];
105
+ }
106
+
107
+ login(database, collection, username, password) {
108
+ this.assertLoginInput(database, collection, username, password);
109
+ if (this.node.getCollectionMeta(database, collection) === null) return null;
110
+ return this.loginFromUsers(database, collection, username, password, this.node.find(database, collection, { username }));
111
+ }
112
+
113
+ loginFromUsers(database, collection, username, password, users = []) {
114
+ this.assertLoginInput(database, collection, username, password);
115
+ const user = (users || []).find((candidate) => verifyPassword(password, candidate.password));
116
+ if (!user) return null;
117
+ return this.authResponse(database, collection, user);
118
+ }
119
+
120
+ assertLoginInput(database, collection, username, password) {
121
+ assertName(database, "database");
122
+ assertName(collection, "collection");
123
+ if (typeof username !== "string" || username.length === 0) {
124
+ throw httpError(400, "body.username must be a non-empty string");
125
+ }
126
+ if (typeof password !== "string") {
127
+ throw httpError(400, "body.password must be a string");
128
+ }
129
+ }
130
+
131
+ renew(database, collection, refreshToken) {
132
+ const claims = this.verifyRefreshClaims(database, collection, refreshToken);
133
+ const user = this.node.get(database, collection, claims.sub);
134
+ return this.renewForUser(database, collection, refreshToken, user, claims);
135
+ }
136
+
137
+ renewForUser(database, collection, refreshToken, user, claims = null) {
138
+ const verified = claims || this.verifyRefreshClaims(database, collection, refreshToken);
139
+ if (!user) throw httpError(401, "invalid refresh token: user not found");
140
+ if ((user.tokenVersion || 0) !== (verified.tokenVersion || 0)) {
141
+ throw httpError(401, "invalid refresh token: token version changed");
142
+ }
143
+ return this.authResponse(database, collection, user, { sessionId: verified.sessionId });
144
+ }
145
+
146
+ verifyRefreshClaims(database, collection, refreshToken) {
147
+ assertName(database, "database");
148
+ assertName(collection, "collection");
149
+ if (typeof refreshToken !== "string" || refreshToken.length === 0) {
150
+ throw httpError(400, "body.refreshToken must be a non-empty string");
151
+ }
152
+
153
+ let claims;
154
+ try {
155
+ claims = JWT.verifyRefreshToken(refreshToken, this.config.jwtSecret, {
156
+ clockToleranceSeconds: this.config.clockToleranceSeconds,
157
+ });
158
+ } catch (err) {
159
+ throw httpError(401, `invalid refresh token: ${err.message}`);
160
+ }
161
+
162
+ if (claims.database !== database) {
163
+ throw httpError(403, "token database does not match URL database");
164
+ }
165
+ if (claims.collection !== collection) {
166
+ throw httpError(403, "token collection does not match URL collection");
167
+ }
168
+
169
+ return claims;
170
+ }
171
+
172
+ authResponse(database, collection, user, { sessionId = null } = {}) {
173
+ const pair = this.issueTokenPair(database, collection, user, { sessionId });
174
+ return {
175
+ user: this.redactUser(user),
176
+ accessToken: pair.accessToken,
177
+ refreshToken: pair.refreshToken,
178
+ access_token: pair.accessToken,
179
+ refresh_token: pair.refreshToken,
180
+ token_type: "Bearer",
181
+ token: pair,
182
+ expiresIn: this.config.accessTokenTtlSeconds,
183
+ expires_in: this.config.accessTokenTtlSeconds,
184
+ };
185
+ }
186
+
187
+ issueTokenPair(database, collection, user, { sessionId = null } = {}) {
188
+ const id = sessionId || randomUUID();
189
+ return {
190
+ accessToken: this.issueToken(database, collection, user, "access", this.config.accessTokenTtlSeconds, id),
191
+ refreshToken: this.issueToken(database, collection, user, "refresh", this.config.refreshTokenTtlSeconds, id),
192
+ };
193
+ }
194
+
195
+ issueToken(database, collection, user, type, ttlSeconds, sessionId) {
196
+ const now = Math.floor(Date.now() / 1000);
197
+ const permissions = normalizePermissions(user.permissions);
198
+ const collectionPermissions = normalizeCollectionPermissions(user.collectionPermissions);
199
+ const role = user.role || "user";
200
+ const admin = user.admin === true || role === "admin";
201
+ return JWT.sign({
202
+ typ: type,
203
+ sub: user._id,
204
+ id: user._id,
205
+ username: user.username,
206
+ role,
207
+ userFamily: role,
208
+ admin,
209
+ permissions,
210
+ collectionPermissions,
211
+ authorisations: user.authorisations || {},
212
+ applications: Array.isArray(user.applications) ? user.applications : [],
213
+ database,
214
+ tenant: database,
215
+ client: database,
216
+ collection,
217
+ family: collection,
218
+ globalUser: this.isGlobalUsersCollection(database, collection),
219
+ sessionId,
220
+ tokenId: sessionId,
221
+ tokenVersion: user.tokenVersion || 0,
222
+ iat: now,
223
+ exp: now + ttlSeconds,
224
+ }, this.config.jwtSecret);
225
+ }
226
+
227
+ authenticateRequest(req) {
228
+ const token = accessTokenFromRequest(req);
229
+ if (!token) {
230
+ return { ok: false, status: 401, code: "ACCESS_TOKEN_REQUIRED", error: "missing or malformed Authorization header" };
231
+ }
232
+
233
+ try {
234
+ const claims = JWT.verifyAccessToken(token, this.config.jwtSecret, {
235
+ clockToleranceSeconds: this.config.clockToleranceSeconds,
236
+ });
237
+ if (typeof claims.sub !== "string" || typeof claims.database !== "string" || typeof claims.collection !== "string") {
238
+ return { ok: false, status: 401, code: "ACCESS_TOKEN_INVALID", error: "invalid token: user identity claims required" };
239
+ }
240
+ if (req.params?.database && !this.canAccessDatabase(claims, req.params.database)) {
241
+ return { ok: false, status: 403, code: "DATABASE_ACCESS_DENIED", error: "token database does not match URL database" };
242
+ }
243
+ req.jwt = claims;
244
+ req.auth = {
245
+ mode: claims.globalUser === true ? "global-user" : "database-user",
246
+ claims,
247
+ user: {
248
+ id: claims.sub,
249
+ username: claims.username,
250
+ role: claims.role || "user",
251
+ admin: claims.admin === true,
252
+ permissions: normalizePermissions(claims.permissions),
253
+ collectionPermissions: normalizeCollectionPermissions(claims.collectionPermissions),
254
+ database: claims.database,
255
+ collection: claims.collection,
256
+ },
257
+ };
258
+ return { ok: true, claims };
259
+ } catch (err) {
260
+ return { ok: false, status: 401, code: err.code === 'E_TOKEN_EXPIRED' ? 'ACCESS_TOKEN_EXPIRED' : 'ACCESS_TOKEN_INVALID', error: `invalid token: ${err.message}` };
261
+ }
262
+ }
263
+
264
+ prepareWriteObjects(database, collection, objects, { partial = false } = {}) {
265
+ const prepared = objects.map((object) => {
266
+ const existing = !partial &&
267
+ object?.password === undefined &&
268
+ object?.username !== undefined &&
269
+ object?._id !== undefined &&
270
+ object?._id !== null
271
+ ? this.node.get(database, collection, String(object._id))
272
+ : null;
273
+ return this.prepareUserObject(object, { partial, existing });
274
+ });
275
+ this.assertUniqueUsernames(database, collection, prepared);
276
+ return prepared;
277
+ }
278
+
279
+ prepareUserObject(object, { partial = false, existing = null } = {}) {
280
+ if (!isPlainObject(object)) throw httpError(400, "user object must be a plain object");
281
+
282
+ const next = { ...object };
283
+ if (!partial && next.password === undefined && existing?.password !== undefined) {
284
+ next.password = existing.password;
285
+ }
286
+
287
+ if (!partial && next.password !== undefined) {
288
+ if (typeof next.username !== "string" || next.username.length === 0) {
289
+ throw httpError(400, "user.username must be a non-empty string");
290
+ }
291
+ if (typeof next.password !== "string" || next.password.length === 0) {
292
+ throw httpError(400, "user.password must be a non-empty string");
293
+ }
294
+ }
295
+
296
+ if (next.username !== undefined && (typeof next.username !== "string" || next.username.length === 0)) {
297
+ throw httpError(400, "user.username must be a non-empty string");
298
+ }
299
+ if (next.password !== undefined) {
300
+ if (typeof next.password !== "string" || next.password.length === 0) {
301
+ throw httpError(400, "user.password must be a non-empty string");
302
+ }
303
+ if (!isPasswordHash(next.password)) next.password = hashPassword(next.password);
304
+ }
305
+ if (next.tokenVersion !== undefined) next.tokenVersion = positiveInt(next.tokenVersion, 0);
306
+ if (next.permissions !== undefined) next.permissions = normalizePermissions(next.permissions);
307
+ if (next.collectionPermissions !== undefined) {
308
+ next.collectionPermissions = normalizeCollectionPermissions(next.collectionPermissions);
309
+ }
310
+ return next;
311
+ }
312
+
313
+ assertUniqueUsernames(database, collection, objects) {
314
+ const seen = new Map();
315
+ for (const object of objects) {
316
+ if (object.password === undefined || !object.username) continue;
317
+ const id = object._id === undefined || object._id === null ? null : String(object._id);
318
+ if (seen.has(object.username)) throw httpError(409, `username already exists: ${object.username}`);
319
+ seen.set(object.username, id);
320
+
321
+ const matches = this.node.find(database, collection, { username: object.username });
322
+ for (const existing of matches) {
323
+ if (!id || existing._id !== id) throw httpError(409, `username already exists: ${object.username}`);
324
+ }
325
+ }
326
+ }
327
+
328
+ findUserByUsername(database, collection, username) {
329
+ return this.node.find(database, collection, { username }, { limit: 1 })[0] || null;
330
+ }
331
+
332
+ redactObject(database, collection, object) {
333
+ if (!object) return object;
334
+ return redactSensitiveValue(object);
335
+ }
336
+
337
+ redactObjects(database, collection, objects) {
338
+ return (objects || []).map((object) => this.redactObject(database, collection, object));
339
+ }
340
+
341
+ redactResult(database, collection, result) {
342
+ if (!result) return result;
343
+ if (Array.isArray(result)) return this.redactObjects(database, collection, result);
344
+ if (Array.isArray(result.items)) return { ...result, items: this.redactObjects(database, collection, result.items) };
345
+ return this.redactObject(database, collection, result);
346
+ }
347
+
348
+ redactUser(user) {
349
+ if (!user) return user;
350
+ const { password, ...safe } = user;
351
+ if (safe.permissions !== undefined) safe.permissions = normalizePermissions(safe.permissions);
352
+ if (safe.collectionPermissions !== undefined) {
353
+ safe.collectionPermissions = normalizeCollectionPermissions(safe.collectionPermissions);
354
+ }
355
+ return safe;
356
+ }
357
+
358
+ isGlobalUsersCollection(database, collection) {
359
+ return database === GLOBAL_DATABASE && collection === USERS_COLLECTION;
360
+ }
361
+
362
+ isAuthObject(object) {
363
+ return isAuthObject(object);
364
+ }
365
+
366
+ canAccessDatabase(claims, database) {
367
+ return claims.globalUser === true || claims.database === database;
368
+ }
369
+ }
370
+
371
+ class DisabledAuthService {
372
+ constructor() {
373
+ this.enabled = false;
374
+ }
375
+
376
+ authenticateRequest() {
377
+ return { ok: true, claims: null };
378
+ }
379
+
380
+ login() {
381
+ throw httpError(404, "auth is disabled");
382
+ }
383
+
384
+ loginFromUsers() {
385
+ throw httpError(404, "auth is disabled");
386
+ }
387
+
388
+ renew() {
389
+ throw httpError(404, "auth is disabled");
390
+ }
391
+
392
+ renewForUser() {
393
+ throw httpError(404, "auth is disabled");
394
+ }
395
+
396
+ verifyRefreshClaims() {
397
+ throw httpError(404, "auth is disabled");
398
+ }
399
+
400
+ prepareWriteObjects(_database, _collection, objects) {
401
+ return objects;
402
+ }
403
+
404
+ redactObject(_database, _collection, object) {
405
+ return object;
406
+ }
407
+
408
+ redactObjects(_database, _collection, objects) {
409
+ return objects;
410
+ }
411
+
412
+ redactResult(_database, _collection, result) {
413
+ return result;
414
+ }
415
+ }
416
+
417
+ export function isPasswordHash(value) {
418
+ return typeof value === "string" && value.startsWith("pbkdf2$sha256$");
419
+ }
420
+
421
+ export function accessTokenFromRequest(req) {
422
+ const header = req?.headers?.authorization;
423
+ if (typeof header === "string") {
424
+ const match = header.match(/^Bearer\s+(.+)$/i);
425
+ if (match) return match[1].trim() || null;
426
+ }
427
+ return null;
428
+ }
429
+
430
+ function isPlainObject(value) {
431
+ return value !== null && typeof value === "object" && !Array.isArray(value);
432
+ }
433
+
434
+ function redactSensitiveValue(value) {
435
+ if (Array.isArray(value)) return value.map(redactSensitiveValue);
436
+ if (!isPlainObject(value)) return value;
437
+ const out = {};
438
+ for (const [key, item] of Object.entries(value)) {
439
+ if (key === "password" || key === "_vec") continue;
440
+ out[key] = redactSensitiveValue(item);
441
+ }
442
+ return out;
443
+ }
444
+
445
+ function isAuthObject(object) {
446
+ return object && object.password !== undefined;
447
+ }
448
+
449
+ function optionalString(value) {
450
+ return typeof value === "string" && value.length > 0 ? value : null;
451
+ }
452
+
453
+ function unique(values) {
454
+ return [...new Set(values)];
455
+ }
456
+
457
+ function assertName(value, field) {
458
+ if (typeof value !== "string" || value.length === 0) {
459
+ throw httpError(400, `${field} must be a non-empty string`);
460
+ }
461
+ }
462
+
463
+ function positiveInt(value, fallback) {
464
+ const n = Number(value);
465
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
466
+ }
467
+
468
+ function httpError(status, message) {
469
+ const err = new Error(message);
470
+ err.status = status;
471
+ return err;
472
+ }
package/src/clock.js ADDED
@@ -0,0 +1,18 @@
1
+ export function makeClock(id) {
2
+ return { c: 0, n: id };
3
+ }
4
+
5
+ export function nextTs(clock) {
6
+ return { c: ++clock.c, n: clock.n };
7
+ }
8
+
9
+ export function touch(clock, ts) {
10
+ if (ts && ts.c > clock.c) clock.c = ts.c;
11
+ }
12
+
13
+ export function gt(a, b) {
14
+ if (!a) return true;
15
+ if (!b) return a != null;
16
+ if (a.c !== b.c) return a.c > b.c;
17
+ return a.n > b.n;
18
+ }
@@ -0,0 +1,25 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+
3
+ export const CLUSTER_SECRET_HEADER = "x-objydb-cluster-secret";
4
+
5
+ export function normalizeClusterSecret(secret) {
6
+ return typeof secret === "string" && secret.length > 0 ? secret : null;
7
+ }
8
+
9
+ export function clusterSecretFromRequest(req, { allowBearer = false } = {}) {
10
+ const header = req?.headers?.[CLUSTER_SECRET_HEADER];
11
+ if (typeof header === "string" && header.length > 0) return header;
12
+ const authorization = req?.headers?.authorization;
13
+ if (allowBearer && typeof authorization === "string" && authorization.startsWith("Bearer ")) {
14
+ return authorization.slice("Bearer ".length).trim();
15
+ }
16
+ return null;
17
+ }
18
+
19
+ export function clusterSecretMatches(actual, expected) {
20
+ const normalized = normalizeClusterSecret(expected);
21
+ if (!actual || !normalized) return false;
22
+ const actualBytes = Buffer.from(actual);
23
+ const expectedBytes = Buffer.from(normalized);
24
+ return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
25
+ }