@airoom/nextmin-node 2.0.2 → 2.0.3

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 (37) hide show
  1. package/README.md +151 -0
  2. package/dist/api/apiRouter.d.ts +20 -0
  3. package/dist/api/apiRouter.js +122 -7
  4. package/dist/api/router/mountCrudRoutes.js +69 -18
  5. package/dist/api/router/setupAuthRoutes.js +475 -38
  6. package/dist/api/router/setupChatWidgetRoutes.d.ts +3 -0
  7. package/dist/api/router/setupChatWidgetRoutes.js +207 -0
  8. package/dist/api/router/setupFileRoutes.js +221 -16
  9. package/dist/api/router/utils.d.ts +2 -1
  10. package/dist/api/router/utils.js +8 -6
  11. package/dist/cli.d.ts +1 -0
  12. package/dist/cli.js +145 -64
  13. package/dist/database/DatabaseAdapter.d.ts +1 -0
  14. package/dist/database/NMAdapter.d.ts +6 -0
  15. package/dist/database/NMAdapter.js +437 -86
  16. package/dist/database/QueryEngine.js +7 -4
  17. package/dist/files/FileStorageAdapter.d.ts +1 -0
  18. package/dist/files/LocalFileStorageAdapter.d.ts +1 -0
  19. package/dist/files/LocalFileStorageAdapter.js +25 -3
  20. package/dist/files/S3FileStorageAdapter.d.ts +3 -1
  21. package/dist/files/S3FileStorageAdapter.js +72 -11
  22. package/dist/files/filename.js +6 -4
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.js +3 -1
  25. package/dist/models/BaseModel.d.ts +4 -0
  26. package/dist/policy/authorize.d.ts +1 -1
  27. package/dist/policy/authorize.js +64 -13
  28. package/dist/schemas/Users.json +20 -10
  29. package/dist/services/IndexingService.d.ts +24 -0
  30. package/dist/services/IndexingService.js +555 -0
  31. package/dist/services/LocalAIService.d.ts +34 -0
  32. package/dist/services/LocalAIService.js +471 -0
  33. package/dist/services/OpenAIService.d.ts +16 -0
  34. package/dist/services/OpenAIService.js +586 -0
  35. package/dist/utils/SchemaLoader.d.ts +1 -1
  36. package/dist/utils/SchemaLoader.js +40 -4
  37. package/package.json +12 -4
package/README.md CHANGED
@@ -160,6 +160,150 @@ events.on(getModelEvent('Post', 'delete', 'before'), ({ id }) => {
160
160
 
161
161
  See full examples in documentation and the `examples/node` app inside this monorepo.
162
162
 
163
+ ## Cloudflare AI Search Integration (RAG Chatbot)
164
+
165
+ NextMin includes a pluggable, cloud-native AI chatbot assistant widget powered entirely by **Cloudflare AI Search** (a fully managed cloud-based Retrieval-Augmented Generation platform). It supports crawling/scraping website pages, indexing database collections, and generating context-aware chat completions natively in the cloud.
166
+
167
+ ### 1. Environment Configuration
168
+
169
+ Add the following environment variables to your `.env` file to enable Cloudflare AI Search:
170
+
171
+ ```env
172
+ CLOUDFLARE_ACCOUNT_ID=your_cloudflare_account_id
173
+ CLOUDFLARE_API_TOKEN=your_cloudflare_api_token
174
+ CLOUDFLARE_AI_SEARCH_INDEX=your_ai_search_instance_name
175
+ ```
176
+
177
+ ### 2. Chat Widget Backend Setup
178
+
179
+ Pass the `chatWidget` option when creating the NextMin router:
180
+
181
+ ```ts
182
+ import { createNextMinRouter } from '@airoom/nextmin-node';
183
+
184
+ const router = createNextMinRouter({
185
+ dbAdapter,
186
+ server, // Required for socket.io realtime events
187
+ chatWidget: {
188
+ enabled: true,
189
+ siteUrl: 'http://localhost:3000', // Root URL to crawl & scrape
190
+ scrapePaths: ['/'], // Entry points for crawler
191
+ frontendBuildPath: './.next', // Local build directory (optional)
192
+ autoCrawlIntervalMs: 3600000, // Auto-crawl interval (1 hour)
193
+ concurrency: 5 // Concurrent crawler thread count (default: 5)
194
+ }
195
+ });
196
+ ```
197
+
198
+ * **Zero Local server CPU/GPU footprint**: No GGUF or local model files are loaded in memory.
199
+ * **Pluggable Database Modularity**: No database tables or vector schemas (like `nextminembedding`) are registered when using Cloudflare AI Search, keeping your database 100% clean.
200
+ * **Scraped Staged Pages**: Scraped files are saved as `.txt` files in `.nextmin/scraped-pages/` on the local disk.
201
+ * **Cost-Efficient Incremental Crawls**: Hashes are tracked in `.nextmin/indexing-manifest.json` to only upload modified pages and delete removed ones.
202
+
203
+ ---
204
+
205
+ ### 3. Reindexing
206
+
207
+ #### Programmatically in Code
208
+
209
+ ```ts
210
+ import { reindex } from '@airoom/nextmin-node';
211
+
212
+ await reindex({
213
+ chatWidget: {
214
+ enabled: true,
215
+ siteUrl: 'http://localhost:3000',
216
+ scrapePaths: ['/'],
217
+ concurrency: 5 // concurrent scraping threads (default: 5)
218
+ }
219
+ });
220
+ ```
221
+
222
+ #### Manual Reindexing via CLI
223
+
224
+ To manually trigger a crawl and sync with Cloudflare AI Search, invoke the CLI directly:
225
+
226
+ ```bash
227
+ # Using npx (globally available)
228
+ npx nextmin reindex
229
+
230
+ or
231
+
232
+ npx nextmin reindex --force
233
+
234
+ # Set custom concurrency threads
235
+ npx nextmin reindex --threads 10
236
+
237
+ # Or invoking the compiled JS file directly
238
+ node ./node_modules/@airoom/nextmin-node/dist/cli.js reindex
239
+ node ./node_modules/@airoom/nextmin-node/dist/cli.js reindex --force --threads 10
240
+ ```
241
+
242
+ > **Note:** The CLI automatically loads your server's configuration from `.nextmin/config.json` (saved transparently on your server boot) so you do not need to duplicate setup params or provide database credentials. It also traverses directories upwards to load `.env.local` or `.env` files automatically.
243
+
244
+ #### Environment Variables for Reindexing
245
+
246
+ The `reindex` command reads the following environment variables:
247
+
248
+ | Variable | Description | Default / Fallback |
249
+ |---|---|---|
250
+ | `NEXTMIN_CHAT_ENABLED` | Set to `false` to disable the indexer process completely. | `true` |
251
+ | `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare Account ID (Required). | - |
252
+ | `CLOUDFLARE_API_TOKEN` | Cloudflare API Token with vector/AI permissions (Required). | - |
253
+ | `CLOUDFLARE_AI_SEARCH_INDEX` | Cloudflare AI Search index name (Required, falls back to `CLOUDFLARE_VECTORIZE_INDEX`). | - |
254
+ | `NEXTMIN_CHAT_SITE_URL` | The full base website URL to crawl. | `process.env.FRONTEND_URL` \|\| `http://localhost:${PORT \|\| 3000}` |
255
+ | `NEXTMIN_CHAT_SCRAPE_PATHS` | Comma-separated paths to scrape from the site URL. | `/` |
256
+ | `NEXTMIN_CHAT_CONCURRENCY` | Number of concurrent scraping threads to run. | `5` |
257
+ | `NEXTMIN_CHAT_FRONTEND_PATH` | Path to your frontend Next.js build directory. | `./.next` |
258
+ | `NEXTMIN_CHAT_MODEL_PATH` | Path to a local GGUF model (if using local chat). | - |
259
+
260
+ Alternatively, you can pass these directly as CLI flags (e.g. `--cf-account-id <id>`, `--cf-token <token>`, `--cf-index <index>`, `--site-url <url>`, `--threads <count>`).
261
+
262
+
263
+
264
+ ## Registration Whitelisting and Role Onboarding
265
+
266
+ You can secure the user registration flow by configuring `publicRegisterFields` and `roleDefaultStatuses` in your `Users.json` schema:
267
+
268
+ - **`publicRegisterFields`**: Array of fields allowed during public signup (e.g. `["username", "email", "firstName", "lastName", "password", "role"]`). If a field outside this list (like `status`) is submitted, a `400 Bad Request` is returned. If omitted, all fields are permitted for backward compatibility.
269
+ - **`roleDefaultStatuses`**: Map of initial user statuses based on their selected role (e.g., `"buyer": "active"` to allow buyers immediately, and `"developer": "pending"` or `"agent": "pending"` to require administrator verification).
270
+ - **Admin role protection**: Public endpoints block attempts to register under the `admin` or `superadmin` role (returns `403 Forbidden` unless requested by an authenticated administrator).
271
+
272
+ ## Schema Features: Dynamic `refPath` & `readOnly` Attributes
273
+
274
+ ### Dynamic References (`refPath`)
275
+ To dynamically populate a reference based on another field's value in the same document (e.g. `subjectId` pointing to `Doctors` or `Hospitals` based on `subjectType`), specify `refPath` on the attribute definition:
276
+
277
+ ```json
278
+ "subjectType": {
279
+ "type": "string",
280
+ "required": true,
281
+ "enum": ["Doctors", "Hospitals"],
282
+ "readOnly": true
283
+ },
284
+ "subjectId": {
285
+ "type": "ObjectId",
286
+ "refPath": "subjectType",
287
+ "show": "fullName",
288
+ "required": true,
289
+ "readOnly": true
290
+ }
291
+ ```
292
+
293
+ - **Mutually Exclusive with `ref`**: If `refPath` is defined, `refPath` takes precedence to resolve the reference target model dynamically per document. If `refPath` is absent, standard static `ref` is used as before.
294
+ - **Strict Enum Constraint**: The attribute referenced by `refPath` (e.g. `subjectType`) MUST exist in the same schema and define an `enum` array of valid model names (e.g. `enum: ["Doctors", "Hospitals"]`).
295
+ - **Developer Warnings**: During schema initialization, `nextmin-node` automatically validates `refPath` configurations and logs warnings in the terminal if:
296
+ 1. The target field specified in `refPath` does not exist in the same schema.
297
+ 2. The target field is not a string attribute with an `enum` array of target model names.
298
+ 3. An `enum` string value does not correspond to any registered schema model.
299
+ 4. Both `ref` and `refPath` are declared on the same attribute.
300
+ - **Runtime Safety**: If a document has an invalid or missing `refPath` value or if the model is unpopulated, `nextmin-node` keeps the raw ID gracefully without throwing uncaught runtime exceptions.
301
+
302
+ ### Attribute Immutability (`readOnly`)
303
+ Set `"readOnly": true` or `"editable": false` on an attribute definition. When present:
304
+ - **API Level**: Update endpoints (`PUT /:model/:id`) strip read-only attributes from the payload automatically.
305
+ - **Admin UI**: `@airoom/nextmin-react` renders disabled inputs in form views.
306
+
163
307
  ## Headers and auth
164
308
 
165
309
  - All requests must include the API key header: `x-api-key: <YOUR_API_KEY>`
@@ -182,6 +326,13 @@ Database
182
326
  - `MONGO_URL`: e.g. mongodb://localhost:27017
183
327
  - `MONGO_DB`: database name
184
328
 
329
+ Cloudflare Vector Search (optional)
330
+
331
+ - `CLOUDFLARE_ACCOUNT_ID`: Your Cloudflare Account ID
332
+ - `CLOUDFLARE_API_TOKEN`: Cloudflare API Token with Vectorize & Workers AI permissions
333
+ - `CLOUDFLARE_VECTORIZE_INDEX`: Cloudflare Vectorize index name
334
+ - `CLOUDFLARE_EMBEDDING_MODEL`: Cloudflare Workers AI model name (e.g. `@cf/baai/bge-small-en-v1.5`)
335
+
185
336
  File storage (optional)
186
337
 
187
338
  - `S3_BUCKET`, `S3_REGION`
@@ -2,6 +2,21 @@ import express from 'express';
2
2
  import type { Server as HttpServer } from 'http';
3
3
  import { DatabaseAdapter } from '../database/DatabaseAdapter';
4
4
  import type { FileStorageAdapter } from '../files/FileStorageAdapter';
5
+ export interface ChatWidgetConfig {
6
+ enabled: boolean;
7
+ whitelabelSchemas?: string[];
8
+ sourceType?: 'file' | 'db' | 'both';
9
+ siteUrl?: string;
10
+ scrapePaths?: string[];
11
+ autoCrawlIntervalMs?: number;
12
+ concurrency?: number;
13
+ sitemapUrl?: string;
14
+ systemPrompt?: string;
15
+ localModelPath?: string;
16
+ threads?: number;
17
+ enableOpenai?: boolean;
18
+ maxMessagesPerThread?: number;
19
+ }
5
20
  export interface APIRouterOptions {
6
21
  dbAdapter: DatabaseAdapter;
7
22
  server?: HttpServer;
@@ -10,6 +25,7 @@ export interface APIRouterOptions {
10
25
  originalBase?: string;
11
26
  proxyBase?: string;
12
27
  };
28
+ chatWidget?: ChatWidgetConfig;
13
29
  }
14
30
  export declare class APIRouter {
15
31
  private router;
@@ -31,6 +47,10 @@ export declare class APIRouter {
31
47
  private realtimeService?;
32
48
  private fileRoutesMounted;
33
49
  private aggregateService?;
50
+ private chatWidgetConfig?;
51
+ private autoCrawlTimer?;
52
+ private isAutoCrawling;
53
+ private startAutoCrawlScheduler;
34
54
  constructor(options: APIRouterOptions);
35
55
  getRouter(): express.Router;
36
56
  private wireSchemaHotReload;
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
@@ -7,6 +40,8 @@ exports.APIRouter = void 0;
7
40
  const express_1 = __importDefault(require("express"));
8
41
  const BaseModel_1 = require("../models/BaseModel");
9
42
  const Logger_1 = __importDefault(require("../utils/Logger"));
43
+ const fs_1 = __importDefault(require("fs"));
44
+ const path_1 = __importDefault(require("path"));
10
45
  const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
11
46
  const SchemaLoader_1 = require("../utils/SchemaLoader");
12
47
  const DefaultDataInitializer_1 = require("../utils/DefaultDataInitializer");
@@ -19,11 +54,48 @@ const mountCrudRoutes_1 = require("./router/mountCrudRoutes");
19
54
  const mountFindRoutes_1 = require("./router/mountFindRoutes");
20
55
  const mountSearchRoutes_1 = require("./router/mountSearchRoutes");
21
56
  const mountBatchRoutes_1 = require("./router/mountBatchRoutes");
57
+ const setupChatWidgetRoutes_1 = require("./router/setupChatWidgetRoutes");
22
58
  class APIRouter {
23
59
  get isDevelopment() {
24
60
  return (process.env.APP_MODE?.toLowerCase() !== 'production' &&
25
61
  process.env.NODE_ENV?.toLowerCase() !== 'production');
26
62
  }
63
+ startAutoCrawlScheduler() {
64
+ const config = this.chatWidgetConfig;
65
+ if (!config ||
66
+ !config.enabled ||
67
+ process.env.DISABLE_AUTO_CRAWL === 'true' ||
68
+ process.env.NEXTMIN_DISABLE_AUTO_CRAWL === 'true' ||
69
+ !config.autoCrawlIntervalMs ||
70
+ config.autoCrawlIntervalMs <= 0) {
71
+ Logger_1.default.info('APIRouter', 'Auto-crawl scheduler is disabled (stop switch active or interval <= 0).');
72
+ return;
73
+ }
74
+ Logger_1.default.info('APIRouter', `Auto-crawl scheduler initialized. Interval: ${config.autoCrawlIntervalMs}ms (${(config.autoCrawlIntervalMs / 1000 / 60).toFixed(2)} minutes)`);
75
+ const runCrawl = async () => {
76
+ if (this.isAutoCrawling) {
77
+ Logger_1.default.warn('APIRouter', 'Previous auto-crawl run is still in progress. Skipping this interval.');
78
+ return;
79
+ }
80
+ this.isAutoCrawling = true;
81
+ Logger_1.default.info('APIRouter', 'Starting auto-crawl and reindexing...');
82
+ try {
83
+ const { reindex } = await Promise.resolve().then(() => __importStar(require('../services/IndexingService')));
84
+ await reindex({ chatWidget: config });
85
+ Logger_1.default.info('APIRouter', 'Auto-crawl and reindexing completed successfully.');
86
+ }
87
+ catch (err) {
88
+ Logger_1.default.error('APIRouter', `Auto-crawl and reindexing failed: ${err?.message || err}`);
89
+ }
90
+ finally {
91
+ this.isAutoCrawling = false;
92
+ }
93
+ };
94
+ // Run immediately on startup (2s delay to allow server to finish booting) - Disabled as requested to avoid startup overhead
95
+ // setTimeout(() => { runCrawl(); }, 2000);
96
+ // Then run on the recurring interval
97
+ this.autoCrawlTimer = setInterval(runCrawl, config.autoCrawlIntervalMs);
98
+ }
27
99
  constructor(options) {
28
100
  this.models = {};
29
101
  this.findRoutesMounted = false;
@@ -34,6 +106,7 @@ class APIRouter {
34
106
  this.registeredModels = new Set();
35
107
  this.liveSchemas = {};
36
108
  this.fileRoutesMounted = false;
109
+ this.isAutoCrawling = false;
37
110
  // ---------- Live lookups ----------
38
111
  this.getSchema = (name) => {
39
112
  const s = this.liveSchemas[name];
@@ -78,10 +151,16 @@ class APIRouter {
78
151
  const fromString = async (s) => {
79
152
  if (!s)
80
153
  return null;
81
- if (isHex24(s) && rolesModel) {
82
- const docs = await rolesModel.read({ id: s }, 1, 0, true);
83
- const n = docs?.[0]?.name;
84
- return typeof n === 'string' ? n : null;
154
+ if (rolesModel) {
155
+ const hexMatch = isHex24(s);
156
+ const numericMatch = !isNaN(Number(s)) && s.trim() !== '';
157
+ if (hexMatch || numericMatch) {
158
+ const queryId = numericMatch ? Number(s) : s;
159
+ const docs = await rolesModel.read({ id: queryId }, 1, 0, true);
160
+ const n = docs?.[0]?.name;
161
+ if (typeof n === 'string')
162
+ return n;
163
+ }
85
164
  }
86
165
  return s;
87
166
  };
@@ -191,6 +270,36 @@ class APIRouter {
191
270
  };
192
271
  this.router = express_1.default.Router();
193
272
  this.fileStorage = options.fileStorageAdapter;
273
+ this.chatWidgetConfig = options.chatWidget;
274
+ if (options.chatWidget) {
275
+ try {
276
+ const dir = path_1.default.join(process.cwd(), '.nextmin');
277
+ if (!fs_1.default.existsSync(dir)) {
278
+ fs_1.default.mkdirSync(dir, { recursive: true });
279
+ }
280
+ const configPath = path_1.default.join(dir, 'config.json');
281
+ let existingConfig = {};
282
+ if (fs_1.default.existsSync(configPath)) {
283
+ try {
284
+ existingConfig = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
285
+ }
286
+ catch { }
287
+ }
288
+ const mergedConfig = {
289
+ ...options.chatWidget,
290
+ enableOpenai: options.chatWidget.enableOpenai !== false,
291
+ };
292
+ if (existingConfig.openai) {
293
+ mergedConfig.openai = existingConfig.openai;
294
+ }
295
+ fs_1.default.writeFileSync(configPath, JSON.stringify(mergedConfig, null, 2), 'utf-8');
296
+ Logger_1.default.info('APIRouter', `Saved merged chat widget config to .nextmin/config.json`);
297
+ this.chatWidgetConfig = mergedConfig;
298
+ }
299
+ catch (err) {
300
+ Logger_1.default.error('APIRouter', 'Failed to save chat widget config to disk', err);
301
+ }
302
+ }
194
303
  this.dbAdapter = options.dbAdapter;
195
304
  this.jwtSecret = process.env.JWT_SECRET || 'default_jwt_secret';
196
305
  if (options.server) {
@@ -205,7 +314,7 @@ class APIRouter {
205
314
  }
206
315
  this.schemaLoader =
207
316
  SchemaLoader_1.SchemaLoader.getInstance?.() ?? new SchemaLoader_1.SchemaLoader();
208
- const initialSchemas = this.schemaLoader.getSchemas();
317
+ const initialSchemas = { ...this.schemaLoader.getSchemas() };
209
318
  this.setLiveSchemas(initialSchemas);
210
319
  const finishBoot = async () => {
211
320
  if (typeof this.dbAdapter.registerSchemas === 'function') {
@@ -233,6 +342,7 @@ class APIRouter {
233
342
  this.setupNotFoundMiddleware();
234
343
  this.aggregateService = new AggregateService_1.AggregateService(this.createCtx());
235
344
  this.wireSchemaHotReload();
345
+ this.startAutoCrawlScheduler();
236
346
  };
237
347
  if (typeof this.dbAdapter.registerSchemas === 'function') {
238
348
  const res = this.dbAdapter.registerSchemas(initialSchemas);
@@ -257,8 +367,9 @@ class APIRouter {
257
367
  const anyLoader = this.schemaLoader;
258
368
  if (typeof anyLoader.on !== 'function')
259
369
  return;
260
- anyLoader.on('schemasChanged', async (newSchemas) => {
370
+ anyLoader.on('schemasChanged', async (newSchemasMap) => {
261
371
  try {
372
+ const newSchemas = { ...newSchemasMap };
262
373
  const removed = this.diffRemovedModels(this.liveSchemas, newSchemas);
263
374
  if (removed.length &&
264
375
  typeof this.dbAdapter.unregisterSchemas === 'function') {
@@ -369,6 +480,8 @@ class APIRouter {
369
480
  (0, setupAuthRoutes_1.setupAuthRoutes)(this.createCtx());
370
481
  this.authRoutesInitialized = true;
371
482
  }
483
+ // Mount Chat Widget Routes
484
+ (0, setupChatWidgetRoutes_1.setupChatWidgetRoutes)(this.createCtx(), this.chatWidgetConfig);
372
485
  for (const s of Object.values(schemas)) {
373
486
  const name = s.modelName.toLowerCase();
374
487
  if (this.registeredModels.has(name))
@@ -503,10 +616,12 @@ class APIRouter {
503
616
  const conflictingFields = [];
504
617
  for (const field of uniqueFields) {
505
618
  const val = data[field];
619
+ if (val === undefined)
620
+ continue;
506
621
  const attr = schema.attributes?.[field];
507
622
  const isRequired = Array.isArray(attr) ? attr[0]?.required : attr?.required;
508
623
  // Sparse uniqueness: skip checking if value is empty/null AND the field is not required
509
- if (val === undefined || val === null || (typeof val === 'string' && val.trim() === '')) {
624
+ if (val === null || (typeof val === 'string' && val.trim() === '')) {
510
625
  if (!isRequired)
511
626
  continue;
512
627
  }
@@ -36,6 +36,35 @@ async function injectActionRelatedData(ctx, schema, rows) {
36
36
  }
37
37
  }
38
38
  const utils_1 = require("./utils");
39
+ /**
40
+ * For User-extending schemas: strip inherited fields from a response doc
41
+ * when no base user is linked (baseId is null/empty).
42
+ * This keeps API responses clean — no null Username, First Name, etc.
43
+ */
44
+ function stripInheritedIfNoBase(doc, schema) {
45
+ if (!doc || !schema?.extends)
46
+ return doc;
47
+ const isUserExt = String(schema.extends).toLowerCase() === 'users';
48
+ if (!isUserExt)
49
+ return doc;
50
+ const hasBase = !!(doc.baseId || doc.exId);
51
+ if (hasBase)
52
+ return doc;
53
+ const attrs = schema.attributes || {};
54
+ const result = { ...doc };
55
+ for (const [key, attr] of Object.entries(attrs)) {
56
+ const head = Array.isArray(attr) ? attr[0] : attr;
57
+ if (head?.inherited) {
58
+ delete result[key];
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+ function stripInheritedIfNoBaseMany(docs, schema) {
64
+ if (!schema?.extends)
65
+ return docs;
66
+ return docs.map(d => stripInheritedIfNoBase(d, schema));
67
+ }
39
68
  function mountCrudRoutes(ctx, modelNameLC) {
40
69
  const { router } = ctx;
41
70
  const basePath = `/${modelNameLC}`;
@@ -93,7 +122,14 @@ function mountCrudRoutes(ctx, modelNameLC) {
93
122
  return res.status(403).json({ error: true, message: 'forbidden' });
94
123
  }
95
124
  let payload = (0, authorize_1.mergeCreateDefaults)(req.body, cdec.createDefaults);
96
- (0, authorize_1.enforceRestrictions)(payload, cdec.restrictions, pctx);
125
+ // Apply schema attribute defaults if missing in the request body
126
+ // For extended schemas, skip defaults for inherited attributes (they belong to the base record)
127
+ for (const [key, attr] of Object.entries(schema.attributes || {})) {
128
+ if (!Array.isArray(attr) && attr?.default !== undefined && payload[key] === undefined && !attr?.inherited) {
129
+ payload[key] = attr.default;
130
+ }
131
+ }
132
+ await (0, authorize_1.enforceRestrictions)(payload, cdec.restrictions, pctx, ctx);
97
133
  if (!cdec.exposePrivate)
98
134
  payload = (0, authorize_1.stripWriteDeny)(payload, cdec.writeDeny);
99
135
  payload = (0, fieldCodecs_1.coerceForStorage)(schema, payload);
@@ -126,6 +162,9 @@ function mountCrudRoutes(ctx, modelNameLC) {
126
162
  const basePayload = {};
127
163
  const childPayload = {};
128
164
  for (const [k, v] of Object.entries(payload)) {
165
+ if (k === 'id' || k === '_id' || k === 'exId' || k === 'createdAt' || k === 'updatedAt') {
166
+ continue;
167
+ }
129
168
  if (k === 'baseId') {
130
169
  childPayload[k] = v;
131
170
  continue;
@@ -164,7 +203,13 @@ function mountCrudRoutes(ctx, modelNameLC) {
164
203
  // Only attempt creation if at least one base field is provided or if it's not a UserExt?
165
204
  // Actually, for UserExt, if baseId is missing and they didn't fill user fields (which are hidden),
166
205
  // we should skip base creation and just create the child.
167
- if (baseMissing.length === 0 && Object.keys(basePayload).length > 0) {
206
+ // For User extensions: only create a base user if meaningful user fields
207
+ // are explicitly provided (not just auto-injected defaults like status, type, role)
208
+ const AUTO_DEFAULT_KEYS = new Set(['status', 'type', 'role', 'createdBy']);
209
+ const hasMeaningfulBaseData = isUserExt
210
+ ? Object.keys(basePayload).some(k => !AUTO_DEFAULT_KEYS.has(k))
211
+ : Object.keys(basePayload).length > 0;
212
+ if (baseMissing.length === 0 && hasMeaningfulBaseData) {
168
213
  if (baseLC === 'users' && basePayload.password && ctx.jwtSecret) {
169
214
  const salt = await bcrypt_1.default.genSalt(10);
170
215
  basePayload.password = await bcrypt_1.default.hash(String(basePayload.password) + ctx.jwtSecret, salt);
@@ -231,7 +276,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
231
276
  return res.status(201).json({
232
277
  success: true,
233
278
  message: `${schema.modelName} has been created successfully.`,
234
- data: masked,
279
+ data: stripInheritedIfNoBase(masked, schema),
235
280
  });
236
281
  }
237
282
  // non-extended
@@ -275,7 +320,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
275
320
  }
276
321
  const model = ctx.getModel(modelNameLC);
277
322
  const baseKeys = new Set(Object.keys(schema.attributes || {}));
278
- const { limit, page, skip, sort, projection } = (0, utils_1.parseQuery)(req, baseKeys);
323
+ const { limit, page, skip, sort, projection, _populate } = (0, utils_1.parseQuery)(req, baseKeys);
279
324
  const q = String(req.query.q ?? '').trim();
280
325
  const searchKey = String(req.query.searchKey ?? '').trim();
281
326
  const searchKeysCSV = String(req.query.searchKeys ?? '').trim();
@@ -330,13 +375,14 @@ function mountCrudRoutes(ctx, modelNameLC) {
330
375
  let dataRows = [];
331
376
  if (requiresBaseProcessing) {
332
377
  // If we need to filter/sort by base fields, read ALL child rows (already hydrated by adapter)
333
- dataRows = await model.read(childFilter, 0, 0, needPrivateForHydrate, { projection });
378
+ dataRows = await model.read(childFilter, 0, 0, needPrivateForHydrate, { projection, _populate });
334
379
  }
335
380
  else {
336
381
  // Normal paginated read (already hydrated by adapter)
337
382
  dataRows = await model.read(childFilter, limit, skip, needPrivateForHydrate, {
338
383
  sort: Object.keys(childSort).length ? childSort : undefined,
339
384
  projection,
385
+ _populate,
340
386
  });
341
387
  }
342
388
  let merged = dataRows;
@@ -377,9 +423,6 @@ function mountCrudRoutes(ctx, modelNameLC) {
377
423
  }
378
424
  // **dedupe**: one row per base entity
379
425
  merged = (0, utils_1.dedupeBy)(merged, (r) => String(r.exId || r.id || r._id || ''));
380
- if (currentUserId && isUsersModel) {
381
- merged = merged.filter((r) => String(r?.id) !== String(currentUserId));
382
- }
383
426
  // pagination + totals
384
427
  let totalRows;
385
428
  let paged;
@@ -400,7 +443,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
400
443
  return res.status(200).json({
401
444
  success: true,
402
445
  message: `Data fetched for ${schema.modelName}`,
403
- data,
446
+ data: stripInheritedIfNoBaseMany(data, schema),
404
447
  pagination: { totalRows, page, limit },
405
448
  sort: combinedSort,
406
449
  });
@@ -410,7 +453,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
410
453
  let rawRows = [];
411
454
  const exposePrivateForRead = !!rdec.exposePrivate;
412
455
  try {
413
- rawRows = await model.read(finalFilter, limit + 1, skip, exposePrivateForRead, { sort, projection });
456
+ rawRows = await model.read(finalFilter, limit + 1, skip, exposePrivateForRead, { sort, projection, _populate });
414
457
  }
415
458
  catch {
416
459
  rawRows = await model.read(finalFilter, limit + 1, skip, exposePrivateForRead);
@@ -438,11 +481,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
438
481
  });
439
482
  }
440
483
  }
441
- let rows = currentUserId && isUsersModel
442
- ? rawRows
443
- .filter((r) => String(r?.id) !== String(currentUserId))
444
- .slice(0, limit)
445
- : rawRows.slice(0, limit);
484
+ let rows = rawRows.slice(0, limit);
446
485
  const data = rdec.exposePrivate
447
486
  ? (0, authorize_1.applyReadMaskMany)(rows, rdec.sensitiveMask)
448
487
  : (0, authorize_1.applyReadMaskMany)(rows, rdec.readMask);
@@ -524,7 +563,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
524
563
  return res.status(200).json({
525
564
  success: true,
526
565
  message: `${schema.modelName} found`,
527
- data,
566
+ data: stripInheritedIfNoBase(data, schema),
528
567
  });
529
568
  }
530
569
  catch (error) {
@@ -556,9 +595,18 @@ function mountCrudRoutes(ctx, modelNameLC) {
556
595
  return res.status(403).json({ error: true, message: 'forbidden' });
557
596
  }
558
597
  let upd = { ...req.body };
559
- (0, authorize_1.enforceRestrictions)(upd, udec.restrictions, pctx);
598
+ await (0, authorize_1.enforceRestrictions)(upd, udec.restrictions, pctx, ctx);
560
599
  if (!udec.exposePrivate)
561
600
  upd = (0, authorize_1.stripWriteDeny)(upd, udec.writeDeny);
601
+ // Enforce schema readOnly / non-editable fields on update
602
+ if (schema?.attributes) {
603
+ for (const [attrName, attrDef] of Object.entries(schema.attributes)) {
604
+ const a = Array.isArray(attrDef) ? attrDef[0] : attrDef;
605
+ if (a?.readOnly === true || a?.editable === false) {
606
+ delete upd[attrName];
607
+ }
608
+ }
609
+ }
562
610
  upd = (0, fieldCodecs_1.coerceForStorage)(schema, upd);
563
611
  if (modelNameLC === 'users' &&
564
612
  Object.prototype.hasOwnProperty.call(upd, 'password')) {
@@ -589,6 +637,9 @@ function mountCrudRoutes(ctx, modelNameLC) {
589
637
  const baseUpd = {};
590
638
  const childUpd = {};
591
639
  for (const [k, v] of Object.entries(upd)) {
640
+ if (k === 'id' || k === '_id' || k === 'exId' || k === 'createdAt' || k === 'updatedAt') {
641
+ continue;
642
+ }
592
643
  if (k === 'baseId') {
593
644
  childUpd[k] = v;
594
645
  continue;
@@ -680,7 +731,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
680
731
  const masked = udec.exposePrivate
681
732
  ? (0, authorize_1.applyReadMaskOne)(responseDoc, udec.sensitiveMask)
682
733
  : (0, authorize_1.applyReadMaskOne)(responseDoc, udec.readMask);
683
- return res.json(masked);
734
+ return res.json(stripInheritedIfNoBase(masked, schema));
684
735
  }
685
736
  // non-extended
686
737
  const conflicts = await ctx.checkUniqueFields(schema, upd, req.params.id);