@codixus/server 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.
package/dist/index.js ADDED
@@ -0,0 +1,1068 @@
1
+ // src/db/connection.ts
2
+ import { MongoClient } from "mongodb";
3
+ var ConnectionManager = class {
4
+ constructor(uri, dbName) {
5
+ this.uri = uri;
6
+ this.dbName = dbName;
7
+ }
8
+ client = null;
9
+ db = null;
10
+ async connect() {
11
+ if (this.db) return this.db;
12
+ this.client = new MongoClient(this.uri, {
13
+ serverSelectionTimeoutMS: 5e3,
14
+ maxPoolSize: 10,
15
+ retryWrites: true,
16
+ retryReads: true
17
+ });
18
+ await this.client.connect();
19
+ this.db = this.client.db(this.dbName);
20
+ return this.db;
21
+ }
22
+ getDb() {
23
+ if (!this.db) {
24
+ throw new Error(
25
+ "Database not connected. Call codixus.connect() first."
26
+ );
27
+ }
28
+ return this.db;
29
+ }
30
+ getClient() {
31
+ if (!this.client) {
32
+ throw new Error(
33
+ "Database not connected. Call codixus.connect() first."
34
+ );
35
+ }
36
+ return this.client;
37
+ }
38
+ async disconnect() {
39
+ if (this.client) {
40
+ await this.client.close();
41
+ this.client = null;
42
+ this.db = null;
43
+ }
44
+ }
45
+ };
46
+
47
+ // src/auth/jwt.ts
48
+ import { SignJWT, jwtVerify } from "jose";
49
+ import { CodixusError } from "@codixus/shared";
50
+ import crypto from "crypto";
51
+ var JwtService = class {
52
+ secretKey;
53
+ refreshSecretKey;
54
+ accessTtl;
55
+ refreshTtl;
56
+ constructor(config) {
57
+ this.secretKey = new TextEncoder().encode(config.secret);
58
+ const refreshSecret = config.refreshSecret ?? crypto.createHmac("sha256", config.secret).update("codixus:refresh").digest("hex");
59
+ this.refreshSecretKey = new TextEncoder().encode(refreshSecret);
60
+ this.accessTtl = config.accessTokenTtl;
61
+ this.refreshTtl = config.refreshTokenTtl;
62
+ }
63
+ async sign(subject, claims = {}) {
64
+ const jti = crypto.randomUUID();
65
+ const accessToken = await new SignJWT({
66
+ ...claims,
67
+ type: "access",
68
+ deviceId: subject
69
+ }).setProtectedHeader({ alg: "HS256" }).setSubject(subject).setIssuedAt().setExpirationTime(this.accessTtl).sign(this.secretKey);
70
+ const refreshToken = await new SignJWT({
71
+ type: "refresh",
72
+ deviceId: subject
73
+ }).setProtectedHeader({ alg: "HS256" }).setSubject(subject).setJti(jti).setIssuedAt().setExpirationTime(this.refreshTtl).sign(this.refreshSecretKey);
74
+ return { accessToken, refreshToken };
75
+ }
76
+ async verify(token) {
77
+ try {
78
+ const { payload } = await jwtVerify(token, this.secretKey);
79
+ return payload;
80
+ } catch (error) {
81
+ const message = error instanceof Error ? error.message : "Token verification failed";
82
+ if (message.includes("exp")) {
83
+ throw new CodixusError("TOKEN_EXPIRED", "Access token expired", 401);
84
+ }
85
+ throw new CodixusError("TOKEN_INVALID", "Invalid access token", 401);
86
+ }
87
+ }
88
+ async verifyRefresh(token) {
89
+ try {
90
+ const { payload } = await jwtVerify(token, this.refreshSecretKey);
91
+ return payload;
92
+ } catch {
93
+ throw new CodixusError(
94
+ "REFRESH_TOKEN_INVALID",
95
+ "Invalid refresh token",
96
+ 401
97
+ );
98
+ }
99
+ }
100
+ static hashToken(token) {
101
+ return crypto.createHash("sha256").update(token).digest("hex");
102
+ }
103
+ };
104
+
105
+ // src/auth/ban.ts
106
+ var BanService = class {
107
+ collection;
108
+ refreshTokensCol;
109
+ constructor(db) {
110
+ this.collection = db.collection("banned_devices");
111
+ this.refreshTokensCol = db.collection("refresh_tokens");
112
+ }
113
+ async ban(deviceId, reason) {
114
+ await this.refreshTokensCol.updateMany(
115
+ { deviceId, revokedAt: { $exists: false } },
116
+ { $set: { revokedAt: /* @__PURE__ */ new Date() } }
117
+ );
118
+ await this.collection.updateOne(
119
+ { _id: deviceId },
120
+ {
121
+ $set: { reason, bannedAt: /* @__PURE__ */ new Date() },
122
+ $setOnInsert: { _id: deviceId }
123
+ },
124
+ { upsert: true }
125
+ );
126
+ }
127
+ async unban(deviceId) {
128
+ await this.collection.deleteOne({ _id: deviceId });
129
+ }
130
+ async isBanned(deviceId) {
131
+ const doc = await this.collection.findOne({ _id: deviceId });
132
+ return doc !== null;
133
+ }
134
+ async list(options = {}) {
135
+ return this.collection.find().sort({ bannedAt: -1 }).skip(options.offset ?? 0).limit(options.limit ?? 50).toArray();
136
+ }
137
+ };
138
+
139
+ // src/auth/guard.ts
140
+ function createGuard(jwt) {
141
+ return function guard(options = {}) {
142
+ return async (req, res, next) => {
143
+ const authHeader = req.headers.authorization;
144
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
145
+ if (options.optional) {
146
+ next();
147
+ return;
148
+ }
149
+ res.status(401).json({ success: false, error: "Missing authorization header" });
150
+ return;
151
+ }
152
+ const token = authHeader.substring(7);
153
+ try {
154
+ const payload = await jwt.verify(token);
155
+ req.user = {
156
+ uid: payload.sub,
157
+ deviceId: payload.deviceId
158
+ };
159
+ next();
160
+ } catch (error) {
161
+ if (options.optional) {
162
+ next();
163
+ return;
164
+ }
165
+ res.status(401).json({ success: false, error: "Invalid or expired token" });
166
+ }
167
+ };
168
+ };
169
+ }
170
+
171
+ // src/auth/router.ts
172
+ import { Router } from "express";
173
+ import { authTokenRequestSchema, refreshTokenRequestSchema } from "@codixus/shared";
174
+ import { CodixusError as CodixusError2 } from "@codixus/shared";
175
+ function createAuthRouter(jwt, banService, db, config) {
176
+ const router = Router();
177
+ const usersCol = db.collection(config.usersCollection);
178
+ const refreshTokensCol = db.collection("refresh_tokens");
179
+ router.post("/token", async (req, res) => {
180
+ try {
181
+ const parsed = authTokenRequestSchema.safeParse(req.body);
182
+ if (!parsed.success) {
183
+ res.status(400).json({ success: false, error: "Invalid request body" });
184
+ return;
185
+ }
186
+ const { deviceId, deviceInfo, metadata } = parsed.data;
187
+ const banned = await banService.isBanned(deviceId);
188
+ if (banned) {
189
+ res.status(403).json({
190
+ success: false,
191
+ error: "DEVICE_BANNED",
192
+ message: "Your device has been banned from using this application"
193
+ });
194
+ return;
195
+ }
196
+ const existingUser = await usersCol.findOne({ _id: deviceId });
197
+ let isNewUser = false;
198
+ let userData;
199
+ if (!existingUser) {
200
+ userData = {
201
+ _id: deviceId,
202
+ ...config.onRegister(deviceId, { deviceInfo, metadata })
203
+ };
204
+ await usersCol.insertOne(userData);
205
+ isNewUser = true;
206
+ } else {
207
+ userData = existingUser;
208
+ if (config.deviceBinding !== false) {
209
+ const binding = existingUser.deviceBinding;
210
+ if (binding) {
211
+ if (binding.brand !== deviceInfo.brand || binding.model !== deviceInfo.model) {
212
+ res.status(403).json({
213
+ success: false,
214
+ error: "DEVICE_BINDING_MISMATCH",
215
+ message: "This account is bound to a different device"
216
+ });
217
+ return;
218
+ }
219
+ } else {
220
+ await usersCol.updateOne(
221
+ { _id: deviceId },
222
+ { $set: { deviceBinding: deviceInfo } }
223
+ );
224
+ }
225
+ }
226
+ if (metadata) {
227
+ await usersCol.updateOne(
228
+ { _id: deviceId },
229
+ {
230
+ $set: {
231
+ metadata: { ...metadata, lastUpdated: Date.now() }
232
+ }
233
+ }
234
+ );
235
+ }
236
+ }
237
+ const tokens = await jwt.sign(deviceId);
238
+ const tokenHash = JwtService.hashToken(tokens.refreshToken);
239
+ await refreshTokensCol.insertOne({
240
+ deviceId,
241
+ tokenHash,
242
+ expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3),
243
+ // 30 days
244
+ createdAt: /* @__PURE__ */ new Date()
245
+ });
246
+ const { _id, ...userResponse } = userData;
247
+ res.json({
248
+ success: true,
249
+ accessToken: tokens.accessToken,
250
+ refreshToken: tokens.refreshToken,
251
+ user: { ...userResponse, deviceId },
252
+ isNewUser
253
+ });
254
+ } catch (error) {
255
+ console.error("Auth token error:", error);
256
+ res.status(500).json({
257
+ success: false,
258
+ error: "Failed to create authentication token"
259
+ });
260
+ }
261
+ });
262
+ router.post(
263
+ "/refresh",
264
+ async (req, res) => {
265
+ try {
266
+ const parsed = refreshTokenRequestSchema.safeParse(req.body);
267
+ if (!parsed.success) {
268
+ res.status(400).json({ success: false, error: "refreshToken is required" });
269
+ return;
270
+ }
271
+ const { refreshToken } = parsed.data;
272
+ const payload = await jwt.verifyRefresh(refreshToken);
273
+ const deviceId = payload.sub;
274
+ const banned = await banService.isBanned(deviceId);
275
+ if (banned) {
276
+ res.status(403).json({
277
+ success: false,
278
+ error: "DEVICE_BANNED"
279
+ });
280
+ return;
281
+ }
282
+ const tokenHash = JwtService.hashToken(refreshToken);
283
+ const storedToken = await refreshTokensCol.findOneAndUpdate(
284
+ { tokenHash, revokedAt: { $exists: false } },
285
+ { $set: { revokedAt: /* @__PURE__ */ new Date() } }
286
+ );
287
+ if (!storedToken) {
288
+ res.status(401).json({
289
+ success: false,
290
+ error: "REFRESH_TOKEN_REVOKED"
291
+ });
292
+ return;
293
+ }
294
+ const tokens = await jwt.sign(deviceId);
295
+ const newTokenHash = JwtService.hashToken(tokens.refreshToken);
296
+ await refreshTokensCol.insertOne({
297
+ deviceId,
298
+ tokenHash: newTokenHash,
299
+ expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3),
300
+ createdAt: /* @__PURE__ */ new Date()
301
+ });
302
+ res.json({
303
+ success: true,
304
+ accessToken: tokens.accessToken,
305
+ refreshToken: tokens.refreshToken
306
+ });
307
+ } catch (error) {
308
+ if (error instanceof CodixusError2) {
309
+ res.status(error.statusCode).json({ success: false, error: error.code });
310
+ return;
311
+ }
312
+ console.error("Auth refresh error:", error);
313
+ res.status(500).json({ success: false, error: "Failed to refresh token" });
314
+ }
315
+ }
316
+ );
317
+ router.post(
318
+ "/logout",
319
+ async (req, res) => {
320
+ try {
321
+ const { refreshToken } = req.body ?? {};
322
+ if (refreshToken) {
323
+ const tokenHash = JwtService.hashToken(refreshToken);
324
+ await refreshTokensCol.updateOne(
325
+ { tokenHash },
326
+ { $set: { revokedAt: /* @__PURE__ */ new Date() } }
327
+ );
328
+ }
329
+ res.json({ success: true });
330
+ } catch {
331
+ res.json({ success: true });
332
+ }
333
+ }
334
+ );
335
+ return router;
336
+ }
337
+
338
+ // src/db/query.ts
339
+ var Query = class {
340
+ _filter;
341
+ _sort;
342
+ _limit;
343
+ _skip;
344
+ _projection;
345
+ _startAfterFilter;
346
+ collection;
347
+ constructor(collection, filter = {}) {
348
+ this.collection = collection;
349
+ this._filter = filter;
350
+ }
351
+ sort(sort) {
352
+ this._sort = sort;
353
+ return this;
354
+ }
355
+ limit(n) {
356
+ this._limit = n;
357
+ return this;
358
+ }
359
+ skip(n) {
360
+ this._skip = n;
361
+ return this;
362
+ }
363
+ select(projection) {
364
+ this._projection = projection;
365
+ return this;
366
+ }
367
+ /**
368
+ * Cursor-based pagination.
369
+ * Pass the last document from the previous page.
370
+ * Requires sort() to be called first.
371
+ *
372
+ * Example:
373
+ * const page1 = await Post.find({}).sort({ createdAt: -1 }).limit(20);
374
+ * const page2 = await Post.find({}).sort({ createdAt: -1 }).startAfter(page1.at(-1)!).limit(20);
375
+ */
376
+ startAfter(doc) {
377
+ if (!this._sort) {
378
+ throw new Error("startAfter() requires sort() to be called first");
379
+ }
380
+ const sortEntries = Object.entries(this._sort);
381
+ if (sortEntries.length === 0) {
382
+ throw new Error("startAfter() requires at least one sort field");
383
+ }
384
+ const conditions = [];
385
+ for (const [field, direction] of sortEntries) {
386
+ const value = doc[field];
387
+ if (value === void 0) continue;
388
+ const op = direction === -1 ? "$lt" : "$gt";
389
+ conditions.push({ [field]: { [op]: value } });
390
+ }
391
+ if (conditions.length === 1) {
392
+ this._startAfterFilter = conditions[0];
393
+ } else if (conditions.length > 1) {
394
+ this._startAfterFilter = { $or: conditions };
395
+ }
396
+ return this;
397
+ }
398
+ async exec() {
399
+ let filter = this._filter;
400
+ if (this._startAfterFilter) {
401
+ filter = { $and: [filter, this._startAfterFilter] };
402
+ }
403
+ const options = {};
404
+ if (this._sort) options.sort = this._sort;
405
+ if (this._limit) options.limit = this._limit;
406
+ if (this._skip) options.skip = this._skip;
407
+ if (this._projection) options.projection = this._projection;
408
+ return this.collection.find(filter, options).toArray();
409
+ }
410
+ // Allow awaiting the query directly
411
+ then(onfulfilled, onrejected) {
412
+ return this.exec().then(onfulfilled, onrejected);
413
+ }
414
+ };
415
+
416
+ // src/db/model.ts
417
+ import crypto2 from "crypto";
418
+ var Model = class {
419
+ collection;
420
+ definition;
421
+ _db = null;
422
+ _boundTo = null;
423
+ collectionName;
424
+ constructor(collectionName, definition) {
425
+ this.collectionName = collectionName;
426
+ this.definition = definition;
427
+ }
428
+ /** @internal - bind to a specific server instance */
429
+ _bind(db, instanceId) {
430
+ if (this._boundTo === instanceId) return;
431
+ this._db = db;
432
+ this.collection = db.collection(this.collectionName);
433
+ this._boundTo = instanceId;
434
+ }
435
+ /** @internal */
436
+ async _ensureIndexes() {
437
+ if (!this.definition.indexes?.length) return;
438
+ const indexSpecs = this.definition.indexes.map(
439
+ (fields) => ({ key: fields })
440
+ );
441
+ await this.collection.createIndexes(indexSpecs);
442
+ }
443
+ getCol() {
444
+ if (!this.collection) {
445
+ throw new Error(
446
+ `Model "${this.collectionName}" not bound to a database. Call codixus.connect() first.`
447
+ );
448
+ }
449
+ return this.collection;
450
+ }
451
+ validate(data) {
452
+ return this.definition.schema.parse(data);
453
+ }
454
+ async create(data, options) {
455
+ const validated = this.validate(data);
456
+ const id = data._id ?? crypto2.randomUUID();
457
+ const doc = { ...validated, _id: id };
458
+ if (this.definition.timestamps) {
459
+ doc.createdAt = /* @__PURE__ */ new Date();
460
+ doc.updatedAt = /* @__PURE__ */ new Date();
461
+ }
462
+ await this.getCol().insertOne(doc, { session: options?.session });
463
+ return doc;
464
+ }
465
+ async findById(id, options) {
466
+ const doc = await this.getCol().findOne(
467
+ { _id: id },
468
+ { session: options?.session }
469
+ );
470
+ return doc ?? null;
471
+ }
472
+ async findOne(filter, options) {
473
+ const doc = await this.getCol().findOne(filter, {
474
+ session: options?.session
475
+ });
476
+ return doc ?? null;
477
+ }
478
+ find(filter = {}) {
479
+ return new Query(this.getCol(), filter);
480
+ }
481
+ async updateById(id, update, options) {
482
+ const timestampUpdate = this.definition.timestamps ? { $set: { updatedAt: /* @__PURE__ */ new Date() }, ...update } : update;
483
+ const result = await this.getCol().updateOne(
484
+ { _id: id },
485
+ timestampUpdate,
486
+ { session: options?.session }
487
+ );
488
+ return result.matchedCount > 0;
489
+ }
490
+ async findByIdAndUpdate(id, update, options) {
491
+ const timestampUpdate = this.definition.timestamps ? { $set: { updatedAt: /* @__PURE__ */ new Date() }, ...update } : update;
492
+ const result = await this.getCol().findOneAndUpdate(
493
+ { _id: id },
494
+ timestampUpdate,
495
+ {
496
+ returnDocument: options?.returnNew ? "after" : "before",
497
+ session: options?.session
498
+ }
499
+ );
500
+ return result ?? null;
501
+ }
502
+ async updateMany(filter, update, options) {
503
+ const result = await this.getCol().updateMany(filter, update, {
504
+ session: options?.session
505
+ });
506
+ return result.modifiedCount;
507
+ }
508
+ async deleteById(id, options) {
509
+ const result = await this.getCol().deleteOne(
510
+ { _id: id },
511
+ { session: options?.session }
512
+ );
513
+ return result.deletedCount > 0;
514
+ }
515
+ async deleteMany(filter, options) {
516
+ const result = await this.getCol().deleteMany(filter, {
517
+ session: options?.session
518
+ });
519
+ return result.deletedCount;
520
+ }
521
+ async count(filter = {}, options) {
522
+ return this.getCol().countDocuments(filter, {
523
+ session: options?.session
524
+ });
525
+ }
526
+ async exists(filter, options) {
527
+ const doc = await this.getCol().findOne(filter, {
528
+ projection: { _id: 1 },
529
+ session: options?.session
530
+ });
531
+ return doc !== null;
532
+ }
533
+ async bulk(operations, options) {
534
+ return this.getCol().bulkWrite(operations, options);
535
+ }
536
+ async aggregate(pipeline) {
537
+ return this.getCol().aggregate(pipeline).toArray();
538
+ }
539
+ /**
540
+ * Create a subcollection accessor.
541
+ * Maps Firestore subcollections to separate MongoDB collections.
542
+ * e.g. User.sub("user-123", "decisions") → "users__decisions" collection
543
+ * with { _parentId: "user-123", ... } on each document
544
+ */
545
+ sub(parentId, subName, definition) {
546
+ const subColName = `${this.collectionName}__${subName}`;
547
+ return new SubCollection(
548
+ this.getCol().dbName ? this._db : this._db,
549
+ subColName,
550
+ parentId,
551
+ definition
552
+ );
553
+ }
554
+ };
555
+ var SubCollection = class {
556
+ collection;
557
+ parentId;
558
+ definition;
559
+ constructor(db, collectionName, parentId, definition) {
560
+ this.collection = db.collection(collectionName);
561
+ this.parentId = parentId;
562
+ this.definition = definition;
563
+ }
564
+ async create(data, options) {
565
+ let validated;
566
+ if (this.definition) {
567
+ validated = this.definition.schema.parse(data);
568
+ } else {
569
+ validated = data;
570
+ }
571
+ const id = data._id ?? crypto2.randomUUID();
572
+ const doc = { ...validated, _id: id, _parentId: this.parentId };
573
+ await this.collection.insertOne(doc, { session: options?.session });
574
+ return doc;
575
+ }
576
+ async findById(id, options) {
577
+ return this.collection.findOne(
578
+ { _id: id, _parentId: this.parentId },
579
+ { session: options?.session }
580
+ );
581
+ }
582
+ async findOne(filter = {}, options) {
583
+ return this.collection.findOne(
584
+ { ...filter, _parentId: this.parentId },
585
+ { session: options?.session }
586
+ );
587
+ }
588
+ find(filter = {}) {
589
+ return new Query(this.collection, {
590
+ ...filter,
591
+ _parentId: this.parentId
592
+ });
593
+ }
594
+ async updateById(id, update, options) {
595
+ const result = await this.collection.updateOne(
596
+ { _id: id, _parentId: this.parentId },
597
+ update,
598
+ { session: options?.session }
599
+ );
600
+ return result.matchedCount > 0;
601
+ }
602
+ async deleteById(id, options) {
603
+ const result = await this.collection.deleteOne(
604
+ { _id: id, _parentId: this.parentId },
605
+ { session: options?.session }
606
+ );
607
+ return result.deletedCount > 0;
608
+ }
609
+ async count(filter = {}, options) {
610
+ return this.collection.countDocuments(
611
+ { ...filter, _parentId: this.parentId },
612
+ { session: options?.session }
613
+ );
614
+ }
615
+ };
616
+ var modelRegistry = [];
617
+ function model(collectionName, definition) {
618
+ const m = new Model(collectionName, definition);
619
+ modelRegistry.push(m);
620
+ return m;
621
+ }
622
+ function getModelRegistry() {
623
+ return modelRegistry;
624
+ }
625
+
626
+ // src/db/transaction.ts
627
+ async function runTransaction(client, fn) {
628
+ const session = client.startSession();
629
+ try {
630
+ let result;
631
+ await session.withTransaction(async () => {
632
+ result = await fn(session);
633
+ });
634
+ return result;
635
+ } finally {
636
+ await session.endSession();
637
+ }
638
+ }
639
+
640
+ // src/middleware/signing.ts
641
+ import crypto3 from "crypto";
642
+ var DEFAULT_WINDOW_MS = 5 * 60 * 1e3;
643
+ var NONCE_CLEANUP_INTERVAL = 60 * 1e3;
644
+ var usedNonces = /* @__PURE__ */ new Map();
645
+ setInterval(() => {
646
+ const cutoff = Date.now() - DEFAULT_WINDOW_MS * 2;
647
+ for (const [nonce, timestamp] of usedNonces) {
648
+ if (timestamp < cutoff) {
649
+ usedNonces.delete(nonce);
650
+ }
651
+ }
652
+ }, NONCE_CLEANUP_INTERVAL).unref();
653
+ function computeSignature(secret, method, path, timestamp, nonce, body) {
654
+ const bodyHash = body ? crypto3.createHash("sha256").update(body).digest("hex") : "";
655
+ const payload = [method.toUpperCase(), path, timestamp, nonce, bodyHash].join(
656
+ "\n"
657
+ );
658
+ return crypto3.createHmac("sha256", secret).update(payload).digest("hex");
659
+ }
660
+ function createSigningMiddleware(config) {
661
+ const { appSecret, timestampWindowMs = DEFAULT_WINDOW_MS } = config;
662
+ return (req, res, next) => {
663
+ const timestamp = req.headers["x-codixus-timestamp"];
664
+ const nonce = req.headers["x-codixus-nonce"];
665
+ const signature = req.headers["x-codixus-signature"];
666
+ if (!timestamp || !nonce || !signature) {
667
+ res.status(401).json({
668
+ success: false,
669
+ error: "Missing signing headers"
670
+ });
671
+ return;
672
+ }
673
+ const ts = Number(timestamp);
674
+ if (Number.isNaN(ts)) {
675
+ res.status(401).json({
676
+ success: false,
677
+ error: "Invalid timestamp"
678
+ });
679
+ return;
680
+ }
681
+ const now = Date.now();
682
+ if (Math.abs(now - ts) > timestampWindowMs) {
683
+ res.status(401).json({
684
+ success: false,
685
+ error: "Request timestamp expired"
686
+ });
687
+ return;
688
+ }
689
+ if (usedNonces.has(nonce)) {
690
+ res.status(401).json({
691
+ success: false,
692
+ error: "Nonce already used"
693
+ });
694
+ return;
695
+ }
696
+ const rawBody = req.body && Object.keys(req.body).length > 0 ? JSON.stringify(req.body) : "";
697
+ const expectedSignature = computeSignature(
698
+ appSecret,
699
+ req.method,
700
+ req.originalUrl || req.url,
701
+ timestamp,
702
+ nonce,
703
+ rawBody
704
+ );
705
+ if (!crypto3.timingSafeEqual(
706
+ Buffer.from(signature, "hex"),
707
+ Buffer.from(expectedSignature, "hex")
708
+ )) {
709
+ res.status(401).json({
710
+ success: false,
711
+ error: "Invalid signature"
712
+ });
713
+ return;
714
+ }
715
+ usedNonces.set(nonce, ts);
716
+ next();
717
+ };
718
+ }
719
+
720
+ // src/middleware/rate-limit.ts
721
+ var CLEANUP_INTERVAL = 60 * 1e3;
722
+ function createRateLimit(config = {}) {
723
+ const {
724
+ windowMs = 6e4,
725
+ maxRequests = 60,
726
+ keyGenerator = defaultKeyGenerator,
727
+ message = "Too many requests, please try again later"
728
+ } = config;
729
+ const store = /* @__PURE__ */ new Map();
730
+ const cleanup = setInterval(() => {
731
+ const now = Date.now();
732
+ for (const [key, entry] of store) {
733
+ if (entry.resetAt <= now) {
734
+ store.delete(key);
735
+ }
736
+ }
737
+ }, CLEANUP_INTERVAL);
738
+ cleanup.unref();
739
+ return (req, res, next) => {
740
+ const key = keyGenerator(req);
741
+ const now = Date.now();
742
+ let entry = store.get(key);
743
+ if (!entry || entry.resetAt <= now) {
744
+ entry = { count: 0, resetAt: now + windowMs };
745
+ store.set(key, entry);
746
+ }
747
+ entry.count++;
748
+ const remaining = Math.max(0, maxRequests - entry.count);
749
+ res.setHeader("X-RateLimit-Limit", maxRequests);
750
+ res.setHeader("X-RateLimit-Remaining", remaining);
751
+ res.setHeader("X-RateLimit-Reset", Math.ceil(entry.resetAt / 1e3));
752
+ if (entry.count > maxRequests) {
753
+ const retryAfter = Math.ceil((entry.resetAt - now) / 1e3);
754
+ res.setHeader("Retry-After", retryAfter);
755
+ res.status(429).json({
756
+ success: false,
757
+ error: message,
758
+ retryAfter
759
+ });
760
+ return;
761
+ }
762
+ next();
763
+ };
764
+ }
765
+ function defaultKeyGenerator(req) {
766
+ const ip = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket?.remoteAddress || "unknown";
767
+ const deviceId = req.user?.deviceId || "anonymous";
768
+ return `${ip}:${deviceId}`;
769
+ }
770
+
771
+ // src/db/rest.ts
772
+ import { Router as Router2 } from "express";
773
+ import crypto4 from "crypto";
774
+ var SAFE_UPDATE_OPS = /* @__PURE__ */ new Set([
775
+ "$set",
776
+ "$unset",
777
+ "$inc",
778
+ "$push",
779
+ "$pull",
780
+ "$addToSet",
781
+ "$pop",
782
+ "$min",
783
+ "$max",
784
+ "$mul",
785
+ "$currentDate",
786
+ "$setOnInsert"
787
+ ]);
788
+ var BLOCKED_OPS = /* @__PURE__ */ new Set([
789
+ "$where",
790
+ "$function",
791
+ "$accumulator",
792
+ "$expr",
793
+ "$jsonSchema",
794
+ "$comment"
795
+ ]);
796
+ function sanitizeObject(obj, depth = 0) {
797
+ if (depth > 10) throw new Error("Object too deeply nested");
798
+ if (obj === null || obj === void 0) return {};
799
+ if (typeof obj !== "object" || Array.isArray(obj)) throw new Error("Expected object");
800
+ const result = {};
801
+ for (const [key, value] of Object.entries(obj)) {
802
+ if (BLOCKED_OPS.has(key)) throw new Error(`Operator "${key}" is not allowed`);
803
+ if (value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date)) {
804
+ result[key] = sanitizeObject(value, depth + 1);
805
+ } else {
806
+ result[key] = value;
807
+ }
808
+ }
809
+ return result;
810
+ }
811
+ function sanitizeUpdate(obj) {
812
+ if (!obj || typeof obj !== "object") throw new Error("Update must be an object");
813
+ for (const key of Object.keys(obj)) {
814
+ if (key.startsWith("$") && !SAFE_UPDATE_OPS.has(key)) {
815
+ throw new Error(`Update operator "${key}" is not allowed`);
816
+ }
817
+ }
818
+ return sanitizeObject(obj);
819
+ }
820
+ function param(req, name) {
821
+ const val = req.params[name];
822
+ return Array.isArray(val) ? val[0] : val ?? "";
823
+ }
824
+ function createRestRouter(model2, options = {}) {
825
+ const {
826
+ allowCreate = true,
827
+ allowUpdate = true,
828
+ allowDelete = true,
829
+ maxLimit = 100,
830
+ defaultLimit = 20,
831
+ defaultSort
832
+ } = options;
833
+ const router = Router2();
834
+ router.get("/", async (req, res) => {
835
+ try {
836
+ const filterStr = req.query.filter;
837
+ const sortStr = req.query.sort;
838
+ const limitStr = req.query.limit;
839
+ const skipStr = req.query.skip;
840
+ const cursor = req.query.cursor;
841
+ let filter = {};
842
+ if (filterStr) {
843
+ filter = sanitizeObject(JSON.parse(filterStr));
844
+ }
845
+ const sort = sortStr ? JSON.parse(sortStr) : defaultSort;
846
+ const limit = Math.min(Number(limitStr) || defaultLimit, maxLimit);
847
+ const skip = Number(skipStr) || 0;
848
+ let query = model2.find(filter);
849
+ if (sort) query = query.sort(sort);
850
+ if (cursor && sort) {
851
+ const sortField = Object.keys(sort)[0];
852
+ const cursorValue = Number.isNaN(Number(cursor)) ? cursor : Number(cursor);
853
+ query = query.startAfter({ [sortField]: cursorValue });
854
+ }
855
+ if (skip) query = query.skip(skip);
856
+ query = query.limit(limit);
857
+ const docs = await query.exec();
858
+ let nextCursor = null;
859
+ if (docs.length === limit && sort) {
860
+ const sortField = Object.keys(sort)[0];
861
+ nextCursor = docs[docs.length - 1]?.[sortField] ?? null;
862
+ }
863
+ res.json({ success: true, data: docs, nextCursor });
864
+ } catch (error) {
865
+ res.status(400).json({ success: false, error: error.message });
866
+ }
867
+ });
868
+ router.get("/:id", async (req, res) => {
869
+ const id = param(req, "id");
870
+ const doc = await model2.findById(id);
871
+ if (!doc) {
872
+ res.status(404).json({ success: false, error: "Not found" });
873
+ return;
874
+ }
875
+ res.json({ success: true, data: doc });
876
+ });
877
+ router.post("/", async (req, res) => {
878
+ if (!allowCreate) {
879
+ res.status(403).json({ success: false, error: "Create not allowed" });
880
+ return;
881
+ }
882
+ try {
883
+ const data = req.body;
884
+ if (!data._id) data._id = crypto4.randomUUID();
885
+ const doc = await model2.create(data);
886
+ res.status(201).json({ success: true, data: doc });
887
+ } catch (error) {
888
+ res.status(400).json({ success: false, error: error.message });
889
+ }
890
+ });
891
+ router.post("/count", async (req, res) => {
892
+ try {
893
+ const filter = req.body.filter ? sanitizeObject(req.body.filter) : {};
894
+ const count = await model2.count(filter);
895
+ res.json({ success: true, count });
896
+ } catch (error) {
897
+ res.status(400).json({ success: false, error: error.message });
898
+ }
899
+ });
900
+ router.patch("/:id", async (req, res) => {
901
+ if (!allowUpdate) {
902
+ res.status(403).json({ success: false, error: "Update not allowed" });
903
+ return;
904
+ }
905
+ const id = param(req, "id");
906
+ try {
907
+ const update = sanitizeUpdate(req.body.update ?? req.body);
908
+ const success = await model2.updateById(id, update);
909
+ if (!success) {
910
+ res.status(404).json({ success: false, error: "Not found" });
911
+ return;
912
+ }
913
+ const doc = await model2.findById(id);
914
+ res.json({ success: true, data: doc });
915
+ } catch (error) {
916
+ res.status(400).json({ success: false, error: error.message });
917
+ }
918
+ });
919
+ router.delete("/:id", async (req, res) => {
920
+ const id = param(req, "id");
921
+ if (typeof allowDelete === "function") {
922
+ const doc = await model2.findById(id);
923
+ if (!doc) {
924
+ res.status(404).json({ success: false, error: "Not found" });
925
+ return;
926
+ }
927
+ if (!allowDelete(req, doc)) {
928
+ res.status(403).json({ success: false, error: "Delete not allowed" });
929
+ return;
930
+ }
931
+ } else if (!allowDelete) {
932
+ res.status(403).json({ success: false, error: "Delete not allowed" });
933
+ return;
934
+ }
935
+ const success = await model2.deleteById(id);
936
+ if (!success) {
937
+ res.status(404).json({ success: false, error: "Not found" });
938
+ return;
939
+ }
940
+ res.json({ success: true });
941
+ });
942
+ return router;
943
+ }
944
+
945
+ // src/codixus-server.ts
946
+ var CodixusServer = class {
947
+ constructor(config) {
948
+ this.config = config;
949
+ this.connection = new ConnectionManager(config.mongoUri, config.dbName);
950
+ this._appSecret = config.appSecret;
951
+ this.jwt = new JwtService({
952
+ secret: config.jwtSecret,
953
+ refreshSecret: config.jwtRefreshSecret,
954
+ accessTokenTtl: config.accessTokenTtl ?? "15m",
955
+ refreshTokenTtl: config.refreshTokenTtl ?? "30d"
956
+ });
957
+ }
958
+ connection;
959
+ jwt;
960
+ banService;
961
+ guardFn;
962
+ _db;
963
+ instanceId = /* @__PURE__ */ Symbol("codixus-server");
964
+ _appSecret;
965
+ auth;
966
+ db;
967
+ async connect() {
968
+ this._db = await this.connection.connect();
969
+ this.banService = new BanService(this._db);
970
+ this.guardFn = createGuard(this.jwt);
971
+ for (const m of getModelRegistry()) {
972
+ m._bind(this._db, this.instanceId);
973
+ await m._ensureIndexes();
974
+ }
975
+ const refreshTokensCol = this._db.collection("refresh_tokens");
976
+ await refreshTokensCol.createIndex({ tokenHash: 1 }, { unique: true });
977
+ await refreshTokensCol.createIndex({ deviceId: 1 });
978
+ await refreshTokensCol.createIndex(
979
+ { expiresAt: 1 },
980
+ { expireAfterSeconds: 0 }
981
+ );
982
+ this.auth = {
983
+ sign: (subject, claims) => this.jwt.sign(subject, claims),
984
+ verify: (token) => this.jwt.verify(token),
985
+ refresh: async (refreshToken) => {
986
+ const payload = await this.jwt.verifyRefresh(refreshToken);
987
+ const deviceId = payload.sub;
988
+ const tokens = await this.jwt.sign(deviceId);
989
+ return tokens;
990
+ },
991
+ guard: (options) => this.guardFn(options),
992
+ router: (config) => createAuthRouter(this.jwt, this.banService, this._db, config),
993
+ ban: (deviceId, reason) => this.banService.ban(deviceId, reason),
994
+ unban: (deviceId) => this.banService.unban(deviceId),
995
+ isBanned: (deviceId) => this.banService.isBanned(deviceId)
996
+ };
997
+ this.db = {
998
+ transaction: (fn) => runTransaction(this.connection.getClient(), fn),
999
+ getDb: () => this._db
1000
+ };
1001
+ }
1002
+ /**
1003
+ * HMAC request signing middleware.
1004
+ * Verifies that requests come from a client with the correct appSecret.
1005
+ */
1006
+ signing(overrides) {
1007
+ const secret = overrides?.appSecret ?? this._appSecret;
1008
+ if (!secret) {
1009
+ throw new Error(
1010
+ "appSecret is required for signing. Set it in CodixusServer config or pass it to signing()."
1011
+ );
1012
+ }
1013
+ return createSigningMiddleware({
1014
+ appSecret: secret,
1015
+ timestampWindowMs: overrides?.timestampWindowMs
1016
+ });
1017
+ }
1018
+ /**
1019
+ * Rate limiting middleware.
1020
+ */
1021
+ rateLimit(config) {
1022
+ return createRateLimit(config);
1023
+ }
1024
+ /**
1025
+ * Auto REST router for a model.
1026
+ * Generates CRUD endpoints: GET /, GET /:id, POST /, PATCH /:id, DELETE /:id, POST /count
1027
+ */
1028
+ rest(m, options) {
1029
+ return createRestRouter(m, options);
1030
+ }
1031
+ async disconnect() {
1032
+ await this.connection.disconnect();
1033
+ }
1034
+ };
1035
+
1036
+ // src/middleware/validate.ts
1037
+ function validate(schema) {
1038
+ return (req, res, next) => {
1039
+ const result = schema.safeParse(req.body);
1040
+ if (!result.success) {
1041
+ res.status(400).json({
1042
+ success: false,
1043
+ error: "VALIDATION_ERROR",
1044
+ details: result.error.issues
1045
+ });
1046
+ return;
1047
+ }
1048
+ req.body = result.data;
1049
+ next();
1050
+ };
1051
+ }
1052
+
1053
+ // src/index.ts
1054
+ import {
1055
+ CodixusError as CodixusError3,
1056
+ ErrorCodes
1057
+ } from "@codixus/shared";
1058
+ export {
1059
+ CodixusError3 as CodixusError,
1060
+ CodixusServer,
1061
+ ErrorCodes,
1062
+ Model,
1063
+ Query,
1064
+ SubCollection,
1065
+ model,
1066
+ validate
1067
+ };
1068
+ //# sourceMappingURL=index.js.map