mythik-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.
Files changed (104) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +4 -0
  3. package/README.md +51 -0
  4. package/dist/api-handler.d.ts +4 -0
  5. package/dist/api-handler.d.ts.map +1 -0
  6. package/dist/api-handler.js +110 -0
  7. package/dist/api-handler.js.map +1 -0
  8. package/dist/audit.d.ts +18 -0
  9. package/dist/audit.d.ts.map +1 -0
  10. package/dist/audit.js +42 -0
  11. package/dist/audit.js.map +1 -0
  12. package/dist/auth/db-auth-provider.d.ts +10 -0
  13. package/dist/auth/db-auth-provider.d.ts.map +1 -0
  14. package/dist/auth/db-auth-provider.js +130 -0
  15. package/dist/auth/db-auth-provider.js.map +1 -0
  16. package/dist/auth/jwt-strategy.d.ts +3 -0
  17. package/dist/auth/jwt-strategy.d.ts.map +1 -0
  18. package/dist/auth/jwt-strategy.js +39 -0
  19. package/dist/auth/jwt-strategy.js.map +1 -0
  20. package/dist/auth/middleware.d.ts +4 -0
  21. package/dist/auth/middleware.d.ts.map +1 -0
  22. package/dist/auth/middleware.js +40 -0
  23. package/dist/auth/middleware.js.map +1 -0
  24. package/dist/auth/password-verifier.d.ts +3 -0
  25. package/dist/auth/password-verifier.d.ts.map +1 -0
  26. package/dist/auth/password-verifier.js +18 -0
  27. package/dist/auth/password-verifier.js.map +1 -0
  28. package/dist/auth/policy-evaluator.d.ts +3 -0
  29. package/dist/auth/policy-evaluator.d.ts.map +1 -0
  30. package/dist/auth/policy-evaluator.js +11 -0
  31. package/dist/auth/policy-evaluator.js.map +1 -0
  32. package/dist/auth/refresh-store.d.ts +8 -0
  33. package/dist/auth/refresh-store.d.ts.map +1 -0
  34. package/dist/auth/refresh-store.js +34 -0
  35. package/dist/auth/refresh-store.js.map +1 -0
  36. package/dist/auth/scope-filter.d.ts +10 -0
  37. package/dist/auth/scope-filter.d.ts.map +1 -0
  38. package/dist/auth/scope-filter.js +51 -0
  39. package/dist/auth/scope-filter.js.map +1 -0
  40. package/dist/auth/types.d.ts +75 -0
  41. package/dist/auth/types.d.ts.map +1 -0
  42. package/dist/auth/types.js +3 -0
  43. package/dist/auth/types.js.map +1 -0
  44. package/dist/auth/user-context.d.ts +4 -0
  45. package/dist/auth/user-context.d.ts.map +1 -0
  46. package/dist/auth/user-context.js +18 -0
  47. package/dist/auth/user-context.js.map +1 -0
  48. package/dist/catalog-builder.d.ts +3 -0
  49. package/dist/catalog-builder.d.ts.map +1 -0
  50. package/dist/catalog-builder.js +35 -0
  51. package/dist/catalog-builder.js.map +1 -0
  52. package/dist/connection.d.ts +4 -0
  53. package/dist/connection.d.ts.map +1 -0
  54. package/dist/connection.js +18 -0
  55. package/dist/connection.js.map +1 -0
  56. package/dist/crud-builder.d.ts +14 -0
  57. package/dist/crud-builder.d.ts.map +1 -0
  58. package/dist/crud-builder.js +43 -0
  59. package/dist/crud-builder.js.map +1 -0
  60. package/dist/handler-loader.d.ts +9 -0
  61. package/dist/handler-loader.d.ts.map +1 -0
  62. package/dist/handler-loader.js +42 -0
  63. package/dist/handler-loader.js.map +1 -0
  64. package/dist/index.d.ts +15 -0
  65. package/dist/index.d.ts.map +1 -0
  66. package/dist/index.js +14 -0
  67. package/dist/index.js.map +1 -0
  68. package/dist/middleware/cors.d.ts +3 -0
  69. package/dist/middleware/cors.d.ts.map +1 -0
  70. package/dist/middleware/cors.js +8 -0
  71. package/dist/middleware/cors.js.map +1 -0
  72. package/dist/middleware/error-handler.d.ts +9 -0
  73. package/dist/middleware/error-handler.d.ts.map +1 -0
  74. package/dist/middleware/error-handler.js +56 -0
  75. package/dist/middleware/error-handler.js.map +1 -0
  76. package/dist/query-engine.d.ts +8 -0
  77. package/dist/query-engine.d.ts.map +1 -0
  78. package/dist/query-engine.js +66 -0
  79. package/dist/query-engine.js.map +1 -0
  80. package/dist/server.d.ts +3 -0
  81. package/dist/server.d.ts.map +1 -0
  82. package/dist/server.js +717 -0
  83. package/dist/server.js.map +1 -0
  84. package/dist/spec-loader.d.ts +6 -0
  85. package/dist/spec-loader.d.ts.map +1 -0
  86. package/dist/spec-loader.js +21 -0
  87. package/dist/spec-loader.js.map +1 -0
  88. package/dist/spec-serving.d.ts +9 -0
  89. package/dist/spec-serving.d.ts.map +1 -0
  90. package/dist/spec-serving.js +53 -0
  91. package/dist/spec-serving.js.map +1 -0
  92. package/dist/types.d.ts +131 -0
  93. package/dist/types.d.ts.map +1 -0
  94. package/dist/types.js +2 -0
  95. package/dist/types.js.map +1 -0
  96. package/dist/validation/identifier-guard.d.ts +2 -0
  97. package/dist/validation/identifier-guard.d.ts.map +1 -0
  98. package/dist/validation/identifier-guard.js +3 -0
  99. package/dist/validation/identifier-guard.js.map +1 -0
  100. package/dist/validation/spec-validator.d.ts +3 -0
  101. package/dist/validation/spec-validator.d.ts.map +1 -0
  102. package/dist/validation/spec-validator.js +3 -0
  103. package/dist/validation/spec-validator.js.map +1 -0
  104. package/package.json +63 -0
package/dist/server.js ADDED
@@ -0,0 +1,717 @@
1
+ import express from 'express';
2
+ import mssql from 'mssql';
3
+ import { validateApiSpec } from './validation/spec-validator.js';
4
+ import { createConnectionPool } from './connection.js';
5
+ import { buildCatalogQuery } from './catalog-builder.js';
6
+ import { parseParamValue, getSqlType, buildPaginatedQuery, buildCountQuery, buildTotalsQuery } from './query-engine.js';
7
+ import { filterFields, buildInsertQuery, buildUpdateQuery, buildDeleteQuery } from './crud-builder.js';
8
+ import { injectAuditFields } from './audit.js';
9
+ import { checkScreensTable, buildSpecServingRoutes, stripSensitiveFields } from './spec-serving.js';
10
+ import { createJwtStrategy } from './auth/jwt-strategy.js';
11
+ import { discoverHandlers, getHandlerRefs, validateHandlerRefs } from './handler-loader.js';
12
+ import { createErrorHandler } from './middleware/error-handler.js';
13
+ import { createCors } from './middleware/cors.js';
14
+ import { createAuthMiddleware } from './auth/middleware.js';
15
+ import { createDbAuthProvider } from './auth/db-auth-provider.js';
16
+ import { buildScopeWhereClause, wrapQueryWithScopeFilter, resolveActiveScope, validateScopeForInsert } from './auth/scope-filter.js';
17
+ import { resolveEnvVars } from './spec-loader.js';
18
+ import path from 'path';
19
+ import fs from 'fs';
20
+ async function resolveSpec(config) {
21
+ const { spec } = config;
22
+ // Mode 1: ApiSpec object
23
+ if (typeof spec === 'object' && 'type' in spec && spec.type === 'api') {
24
+ return spec;
25
+ }
26
+ // Mode 2: File path string
27
+ if (typeof spec === 'string') {
28
+ const raw = fs.readFileSync(path.resolve(spec), 'utf-8');
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(raw);
32
+ }
33
+ catch {
34
+ throw new Error(`Failed to parse spec file "${spec}" — invalid JSON`);
35
+ }
36
+ return parsed;
37
+ }
38
+ // Mode 3: Store + ID
39
+ if (typeof spec === 'object' && 'store' in spec && 'id' in spec) {
40
+ const loaded = await spec.store.load(spec.id);
41
+ return loaded;
42
+ }
43
+ throw new Error('Invalid spec config: must be a file path (string), ApiSpec object, or { store, id }');
44
+ }
45
+ export function createServer(config) {
46
+ const app = express();
47
+ let pool = null;
48
+ let httpServer = null;
49
+ async function start(port) {
50
+ // 1. Load and validate spec
51
+ const spec = await resolveSpec(config);
52
+ const validation = validateApiSpec(spec);
53
+ if (!validation.valid) {
54
+ throw new Error(`Invalid API spec:\n${validation.errors.map(e => ` - ${e}`).join('\n')}`);
55
+ }
56
+ // 2. Resolve env vars and connect to DB
57
+ pool = await createConnectionPool(resolveEnvVars(config.database));
58
+ const specDir = typeof config.spec === 'string' ? path.dirname(path.resolve(config.spec)) : process.cwd();
59
+ // 3. Discover handlers
60
+ const handlersDir = path.resolve(specDir, config.handlersDir ?? './handlers');
61
+ const handlers = await discoverHandlers(handlersDir);
62
+ // Validate handler references
63
+ const handlerRefs = getHandlerRefs(spec);
64
+ const handlerErrors = validateHandlerRefs(handlerRefs, handlers);
65
+ if (handlerErrors.length > 0) {
66
+ throw new Error(`Handler errors:\n${handlerErrors.map(e => ` - ${e}`).join('\n')}`);
67
+ }
68
+ // 4. Setup middleware
69
+ app.use(createCors(config.cors !== false));
70
+ app.use(express.json());
71
+ const serverPort = port ?? config.port ?? 3010;
72
+ const devMode = process.env.NODE_ENV !== 'production';
73
+ // 4b. Auth setup — merge spec auth (declarative) with config auth (secrets)
74
+ const jwtConfig = config.auth?.jwt
75
+ ? resolveEnvVars(config.auth.jwt)
76
+ : null;
77
+ const fullAuthConfig = spec.auth && jwtConfig
78
+ ? { ...spec.auth, strategy: 'jwt', jwt: { ...jwtConfig, claims: spec.auth.claims } }
79
+ : null;
80
+ const authMiddleware = fullAuthConfig ? createAuthMiddleware(fullAuthConfig) : null;
81
+ const defaultPolicy = fullAuthConfig ? undefined : 'public'; // no auth config = all public
82
+ // 4c. Built-in login provider (when auth.provider is configured)
83
+ if (spec.auth?.provider && jwtConfig && pool) {
84
+ const jwtWithClaims = { ...jwtConfig, claims: spec.auth.claims };
85
+ const dbProvider = createDbAuthProvider(spec.auth.provider, jwtWithClaims, pool);
86
+ app.post('/api/auth/login', async (req, res, next) => {
87
+ try {
88
+ const { username, password } = req.body;
89
+ if (!username || !password) {
90
+ res.status(400).json({ error: { code: 'VALIDATION_FAILED', message: 'username and password are required' } });
91
+ return;
92
+ }
93
+ const result = await dbProvider.login(username, password);
94
+ res.json(result);
95
+ }
96
+ catch (err) {
97
+ next(err);
98
+ }
99
+ });
100
+ app.post('/api/auth/refresh', async (req, res, next) => {
101
+ try {
102
+ const { refreshToken } = req.body;
103
+ if (!refreshToken) {
104
+ res.status(400).json({ error: { code: 'VALIDATION_FAILED', message: 'refreshToken is required' } });
105
+ return;
106
+ }
107
+ const result = await dbProvider.refresh(refreshToken);
108
+ res.json(result);
109
+ }
110
+ catch (err) {
111
+ next(err);
112
+ }
113
+ });
114
+ }
115
+ // 5. Catalog routes
116
+ if (spec.catalogs) {
117
+ const catalogPolicy = fullAuthConfig?.catalogsPolicy === 'public' ? 'public' : defaultPolicy;
118
+ for (const [name, catalogConfig] of Object.entries(spec.catalogs)) {
119
+ const routeHandlers = [];
120
+ if (authMiddleware && catalogPolicy !== 'public') {
121
+ routeHandlers.push(authMiddleware(catalogPolicy));
122
+ }
123
+ routeHandlers.push(async (_req, res, next) => {
124
+ try {
125
+ if (catalogConfig.static) {
126
+ res.json(catalogConfig.static);
127
+ return;
128
+ }
129
+ const catalogSql = buildCatalogQuery(catalogConfig);
130
+ if (!catalogSql) {
131
+ res.json([]);
132
+ return;
133
+ }
134
+ const result = await pool.request().query(catalogSql);
135
+ res.json(result.recordset);
136
+ }
137
+ catch (err) {
138
+ next(err);
139
+ }
140
+ });
141
+ app.get(`/api/catalogs/${name}`, ...routeHandlers);
142
+ }
143
+ }
144
+ // 6. Spec serving
145
+ const specServingConfig = config.specServing;
146
+ if (specServingConfig !== false) {
147
+ const tableName = typeof specServingConfig === 'object' ? specServingConfig.table : 'screens';
148
+ const tableExists = await checkScreensTable(pool, tableName);
149
+ if (tableExists) {
150
+ const serving = buildSpecServingRoutes(pool, tableName);
151
+ // Auto-detect public screens: AppSpecs are always public (needed for bootstrap),
152
+ // and each AppSpec's loginScreen is also public (must load before auth).
153
+ // All other screens require authentication.
154
+ const publicScreenIds = new Set();
155
+ if (fullAuthConfig) {
156
+ const appSpecs = await discoverAppSpecs(pool, tableName);
157
+ for (const { id, loginScreen } of appSpecs) {
158
+ publicScreenIds.add(id);
159
+ if (loginScreen)
160
+ publicScreenIds.add(loginScreen);
161
+ }
162
+ }
163
+ // JWT strategy for optional token validation on AppSpec requests
164
+ const appSpecJwt = fullAuthConfig ? createJwtStrategy(fullAuthConfig.jwt) : null;
165
+ // /api/app/:id — returns full AppSpec with valid Bearer, filtered without
166
+ app.get('/api/app/:id', async (req, res, next) => {
167
+ try {
168
+ const appSpecData = await serving.loadApp(req.params.id);
169
+ if (!appSpecData) {
170
+ res.status(404).json({ error: { code: 'NOT_FOUND', message: `App "${req.params.id}" not found` } });
171
+ return;
172
+ }
173
+ // If auth is configured, check for valid Bearer token
174
+ if (appSpecJwt) {
175
+ const token = appSpecJwt.extractToken(req);
176
+ if (token) {
177
+ try {
178
+ await appSpecJwt.validateToken(token);
179
+ // Valid token — return full AppSpec
180
+ res.json(appSpecData);
181
+ return;
182
+ }
183
+ catch {
184
+ // Invalid token — fall through to filtered response
185
+ }
186
+ }
187
+ // No token or invalid token — return filtered AppSpec
188
+ res.json(stripSensitiveFields(appSpecData));
189
+ return;
190
+ }
191
+ // No auth configured — return full AppSpec
192
+ res.json(appSpecData);
193
+ }
194
+ catch (err) {
195
+ next(err);
196
+ }
197
+ });
198
+ // /api/screens/:id — public for loginScreens, authenticated for everything else
199
+ if (authMiddleware && fullAuthConfig) {
200
+ app.get('/api/screens/:id', async (req, res, next) => {
201
+ if (publicScreenIds.has(req.params.id)) {
202
+ return next(); // public — skip auth
203
+ }
204
+ // Apply auth middleware
205
+ authMiddleware(defaultPolicy)(req, res, next);
206
+ }, async (req, res, next) => {
207
+ try {
208
+ const screenSpec = await serving.loadScreen(req.params.id);
209
+ if (!screenSpec) {
210
+ res.status(404).json({ error: { code: 'NOT_FOUND', message: `Screen "${req.params.id}" not found` } });
211
+ return;
212
+ }
213
+ res.json(screenSpec);
214
+ }
215
+ catch (err) {
216
+ next(err);
217
+ }
218
+ });
219
+ }
220
+ else {
221
+ app.get('/api/screens/:id', async (req, res, next) => {
222
+ try {
223
+ const screenSpec = await serving.loadScreen(req.params.id);
224
+ if (!screenSpec) {
225
+ res.status(404).json({ error: { code: 'NOT_FOUND', message: `Screen "${req.params.id}" not found` } });
226
+ return;
227
+ }
228
+ res.json(screenSpec);
229
+ }
230
+ catch (err) {
231
+ next(err);
232
+ }
233
+ });
234
+ }
235
+ }
236
+ }
237
+ // 7. Endpoint routes
238
+ if (spec.endpoints) {
239
+ for (const [, endpointConfig] of Object.entries(spec.endpoints)) {
240
+ registerEndpoint(app, pool, endpointConfig, handlers, authMiddleware, defaultPolicy, fullAuthConfig ?? undefined);
241
+ }
242
+ }
243
+ // 8. Error handler (must be last)
244
+ app.use(createErrorHandler(devMode));
245
+ // 9. Start listening
246
+ await new Promise((resolve) => {
247
+ httpServer = app.listen(serverPort, () => {
248
+ printStartupInfo(spec, config, serverPort, handlers, specServingConfig !== false);
249
+ resolve();
250
+ });
251
+ });
252
+ }
253
+ async function stop() {
254
+ if (httpServer) {
255
+ await new Promise((resolve) => httpServer.close(() => resolve()));
256
+ httpServer = null;
257
+ }
258
+ if (pool) {
259
+ await pool.close();
260
+ pool = null;
261
+ }
262
+ }
263
+ function getApp() {
264
+ return app;
265
+ }
266
+ return { start, stop, getApp };
267
+ }
268
+ // --- App spec discovery (for public screen detection) ---
269
+ async function discoverAppSpecs(pool, tableName) {
270
+ try {
271
+ const result = await pool.request()
272
+ .query(`SELECT id, spec FROM [${tableName}]`);
273
+ const appSpecs = [];
274
+ for (const row of result.recordset) {
275
+ const spec = typeof row.spec === 'string' ? JSON.parse(row.spec) : row.spec;
276
+ if (spec?.type === 'app') {
277
+ appSpecs.push({
278
+ id: row.id,
279
+ loginScreen: spec.navigation?.auth?.loginScreen ?? null,
280
+ });
281
+ }
282
+ }
283
+ return appSpecs;
284
+ }
285
+ catch {
286
+ return [];
287
+ }
288
+ }
289
+ // --- Scope filter helpers ---
290
+ function resolveScopeConfig(endpoint, authConfig) {
291
+ if (!authConfig?.scopeFilter)
292
+ return null;
293
+ const sf = endpoint.scopeFilter;
294
+ if (sf === false || sf === undefined)
295
+ return null;
296
+ if (sf === true)
297
+ return authConfig.scopeFilter;
298
+ if (typeof sf === 'object' && 'column' in sf) {
299
+ return { ...authConfig.scopeFilter, column: sf.column };
300
+ }
301
+ return null;
302
+ }
303
+ function getUserFromReq(req) {
304
+ return req.user;
305
+ }
306
+ function applyScopeToQuery(req, res, scopeFilterConfig, dataQuery, dataRequest) {
307
+ const user = getUserFromReq(req);
308
+ if (!user)
309
+ return dataQuery;
310
+ // Handle "select" mode — validate active scope
311
+ let activeScope = undefined;
312
+ if (scopeFilterConfig.mode === 'select') {
313
+ const headerVal = req.headers[scopeFilterConfig.header.toLowerCase()];
314
+ activeScope = resolveActiveScope(headerVal, scopeFilterConfig.type);
315
+ if (activeScope === null) {
316
+ res.status(400).json({ error: { code: 'SCOPE_REQUIRED', message: 'Active scope header is required' } });
317
+ return null; // signal to caller to stop
318
+ }
319
+ const bypassRoles = scopeFilterConfig.bypassRoles ?? [];
320
+ if (!user.scope.includes(activeScope) && !bypassRoles.some(r => user.roles.includes(r))) {
321
+ res.status(403).json({ error: { code: 'SCOPE_VIOLATION', message: 'Active scope not in allowed values' } });
322
+ return null;
323
+ }
324
+ }
325
+ const scopeClause = buildScopeWhereClause(scopeFilterConfig, user.scope, activeScope, user.roles);
326
+ if (!scopeClause)
327
+ return dataQuery; // bypass role
328
+ for (const [key, value] of Object.entries(scopeClause.params)) {
329
+ dataRequest.input(key, value);
330
+ }
331
+ return wrapQueryWithScopeFilter(dataQuery, scopeClause);
332
+ }
333
+ // --- Endpoint registration ---
334
+ function registerEndpoint(app, pool, endpoint, handlers, authMiddleware, defaultPolicy, authConfig) {
335
+ const method = (endpoint.method ?? 'GET').toLowerCase();
336
+ const params = endpoint.params ?? {};
337
+ const policy = endpoint.policy ?? defaultPolicy;
338
+ const scopeFilterConfig = resolveScopeConfig(endpoint, authConfig);
339
+ // Auth middleware for this endpoint
340
+ const authHandler = authMiddleware && policy !== 'public' ? authMiddleware(policy) : null;
341
+ // Query-based endpoint
342
+ if (endpoint.query) {
343
+ const routeHandlers = [];
344
+ if (authHandler)
345
+ routeHandlers.push(authHandler);
346
+ routeHandlers.push(async (req, res, next) => {
347
+ try {
348
+ const paramValues = extractParamValues(req, params);
349
+ validateRequiredParams(params, paramValues);
350
+ const hasPagination = endpoint.pagination === 'offset';
351
+ const page = hasPagination ? (parseParamValue(req.query.page, 'int') ?? 0) : 0;
352
+ const pageSize = hasPagination
353
+ ? (parseParamValue(req.query.pageSize, 'int', params.pageSize?.max) ?? params.pageSize?.default ?? 20)
354
+ : 0;
355
+ // Build parallel queries
356
+ const queries = [];
357
+ // Data query
358
+ const dataRequest = pool.request();
359
+ bindAllParams(dataRequest, params, paramValues);
360
+ let dataQuery;
361
+ if (hasPagination) {
362
+ dataQuery = buildPaginatedQuery(endpoint.query);
363
+ dataRequest.input('_offset', mssql.Int, page * pageSize);
364
+ dataRequest.input('_pageSize', mssql.Int, pageSize);
365
+ }
366
+ else {
367
+ dataQuery = endpoint.query;
368
+ }
369
+ // Apply scope filter to data query
370
+ if (scopeFilterConfig) {
371
+ const filtered = applyScopeToQuery(req, res, scopeFilterConfig, dataQuery, dataRequest);
372
+ if (filtered === null)
373
+ return; // response already sent (error)
374
+ dataQuery = filtered;
375
+ }
376
+ queries.push(dataRequest.query(dataQuery));
377
+ // Count query (if pagination)
378
+ if (hasPagination) {
379
+ const countRequest = pool.request();
380
+ bindAllParams(countRequest, params, paramValues);
381
+ let countSql = endpoint.count ?? buildCountQuery(endpoint.query);
382
+ // Apply scope filter to count query too
383
+ if (scopeFilterConfig) {
384
+ const user = getUserFromReq(req);
385
+ if (user) {
386
+ let activeScope = undefined;
387
+ if (scopeFilterConfig.mode === 'select') {
388
+ activeScope = resolveActiveScope(req.headers[scopeFilterConfig.header.toLowerCase()], scopeFilterConfig.type);
389
+ }
390
+ const countClause = buildScopeWhereClause(scopeFilterConfig, user.scope, activeScope, user.roles);
391
+ if (countClause) {
392
+ countSql = wrapQueryWithScopeFilter(countSql, countClause);
393
+ for (const [key, value] of Object.entries(countClause.params)) {
394
+ countRequest.input(key, value);
395
+ }
396
+ }
397
+ }
398
+ }
399
+ queries.push(countRequest.query(countSql));
400
+ }
401
+ // Totals query
402
+ if (endpoint.totals) {
403
+ const totalsRequest = pool.request();
404
+ bindAllParams(totalsRequest, params, paramValues);
405
+ let totalsSql = typeof endpoint.totals === 'string'
406
+ ? endpoint.totals
407
+ : buildTotalsQuery(endpoint.query, endpoint.totals);
408
+ // Apply scope filter to totals query too
409
+ if (totalsSql && scopeFilterConfig) {
410
+ const user = getUserFromReq(req);
411
+ if (user) {
412
+ let activeScope = undefined;
413
+ if (scopeFilterConfig.mode === 'select') {
414
+ activeScope = resolveActiveScope(req.headers[scopeFilterConfig.header.toLowerCase()], scopeFilterConfig.type);
415
+ }
416
+ const totalsClause = buildScopeWhereClause(scopeFilterConfig, user.scope, activeScope, user.roles);
417
+ if (totalsClause) {
418
+ totalsSql = wrapQueryWithScopeFilter(totalsSql, totalsClause);
419
+ for (const [key, value] of Object.entries(totalsClause.params)) {
420
+ totalsRequest.input(key, value);
421
+ }
422
+ }
423
+ }
424
+ }
425
+ if (totalsSql) {
426
+ queries.push(totalsRequest.query(totalsSql));
427
+ }
428
+ }
429
+ const results = await Promise.all(queries);
430
+ const dataResult = results[0];
431
+ const response = { data: dataResult.recordset };
432
+ if (hasPagination && results[1]) {
433
+ const countResult = results[1];
434
+ response.total = countResult.recordset[0]?._total ?? 0;
435
+ response.page = page;
436
+ response.pageSize = pageSize;
437
+ }
438
+ if (endpoint.totals && results[hasPagination ? 2 : 1]) {
439
+ const totalsResult = results[hasPagination ? 2 : 1];
440
+ response.totals = totalsResult.recordset[0] ?? {};
441
+ }
442
+ res.json(response);
443
+ }
444
+ catch (err) {
445
+ next(err);
446
+ }
447
+ });
448
+ app[method](endpoint.path, ...routeHandlers);
449
+ }
450
+ // Handler-based endpoint
451
+ if (endpoint.handler) {
452
+ const handler = handlers.get(endpoint.handler);
453
+ const routeHandlers = [];
454
+ if (authHandler)
455
+ routeHandlers.push(authHandler);
456
+ routeHandlers.push(async (req, res, next) => {
457
+ try {
458
+ const paramValues = extractParamValues(req, params);
459
+ validateRequiredParams(params, paramValues);
460
+ const result = await handler({
461
+ params: paramValues,
462
+ db: pool,
463
+ sql: { Int: mssql.Int, Float: mssql.Float, Bit: mssql.Bit, NVarChar: mssql.NVarChar, DateTime: mssql.DateTime },
464
+ user: getUserFromReq(req) ?? null,
465
+ query: req.query,
466
+ body: req.body,
467
+ });
468
+ // Check if result is an explicit error
469
+ if (result && typeof result === 'object' && 'status' in result && 'error' in result) {
470
+ const errResult = result;
471
+ res.status(errResult.status).json({ error: errResult.error });
472
+ return;
473
+ }
474
+ res.json(result);
475
+ }
476
+ catch (err) {
477
+ next(err);
478
+ }
479
+ });
480
+ app[method](endpoint.path, ...routeHandlers);
481
+ }
482
+ // CRUD endpoints
483
+ if (endpoint.crud) {
484
+ const crud = endpoint.crud;
485
+ // POST — create
486
+ const postHandlers = [];
487
+ if (authHandler)
488
+ postHandlers.push(authHandler);
489
+ postHandlers.push(async (req, res, next) => {
490
+ try {
491
+ // Scope filter: validate insert
492
+ if (scopeFilterConfig) {
493
+ const user = getUserFromReq(req);
494
+ if (user && !validateScopeForInsert(scopeFilterConfig, req.body, user.scope, user.roles)) {
495
+ res.status(403).json({ error: { code: 'SCOPE_INSERT_VIOLATION', message: 'Cannot create record outside your scope' } });
496
+ return;
497
+ }
498
+ }
499
+ const fields = filterFields(req.body, crud.insertable);
500
+ if (endpoint.audit) {
501
+ const user = getUserFromReq(req);
502
+ injectAuditFields(fields, endpoint.audit, user?.username ?? null, 'insert');
503
+ }
504
+ if (Object.keys(fields).length === 0) {
505
+ res.status(400).json({ error: { code: 'VALIDATION_FAILED', message: 'No valid fields in request body' } });
506
+ return;
507
+ }
508
+ const { sql: insertSql, params: insertParams } = buildInsertQuery(crud.table, fields, crud.primaryKey);
509
+ const request = pool.request();
510
+ for (const [key, value] of Object.entries(insertParams)) {
511
+ request.input(key, value);
512
+ }
513
+ const result = await request.query(insertSql);
514
+ res.status(201).json(result.recordset[0] ?? fields);
515
+ }
516
+ catch (err) {
517
+ next(err);
518
+ }
519
+ });
520
+ app.post(endpoint.path, ...postHandlers);
521
+ // PUT — update
522
+ const putHandlers = [];
523
+ if (authHandler)
524
+ putHandlers.push(authHandler);
525
+ putHandlers.push(async (req, res, next) => {
526
+ try {
527
+ const fields = filterFields(req.body, crud.updatable);
528
+ if (endpoint.audit) {
529
+ const user = getUserFromReq(req);
530
+ injectAuditFields(fields, endpoint.audit, user?.username ?? null, 'update');
531
+ }
532
+ if (Object.keys(fields).length === 0) {
533
+ res.status(400).json({ error: { code: 'VALIDATION_FAILED', message: 'No valid fields in request body' } });
534
+ return;
535
+ }
536
+ let { sql: updateSql, params: updateParams } = buildUpdateQuery(crud.table, crud.primaryKey, req.params.id, fields);
537
+ const request = pool.request();
538
+ for (const [key, value] of Object.entries(updateParams)) {
539
+ request.input(key, value);
540
+ }
541
+ // Scope filter: restrict update to user's scope
542
+ if (scopeFilterConfig) {
543
+ const user = getUserFromReq(req);
544
+ if (user) {
545
+ let activeScope = undefined;
546
+ if (scopeFilterConfig.mode === 'select') {
547
+ activeScope = resolveActiveScope(req.headers[scopeFilterConfig.header.toLowerCase()], scopeFilterConfig.type);
548
+ }
549
+ const scopeClause = buildScopeWhereClause(scopeFilterConfig, user.scope, activeScope, user.roles);
550
+ if (scopeClause) {
551
+ // Append scope condition to WHERE clause
552
+ updateSql = updateSql.replace(/WHERE\s+/i, `WHERE ${scopeClause.sql} AND `);
553
+ for (const [key, value] of Object.entries(scopeClause.params)) {
554
+ request.input(key, value);
555
+ }
556
+ }
557
+ }
558
+ }
559
+ const result = await request.query(updateSql);
560
+ if (!result.recordset[0]) {
561
+ res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Record not found' } });
562
+ return;
563
+ }
564
+ res.json(result.recordset[0]);
565
+ }
566
+ catch (err) {
567
+ next(err);
568
+ }
569
+ });
570
+ app.put(`${endpoint.path}/:id`, ...putHandlers);
571
+ // DELETE
572
+ const deleteHandlers = [];
573
+ if (authHandler)
574
+ deleteHandlers.push(authHandler);
575
+ deleteHandlers.push(async (req, res, next) => {
576
+ try {
577
+ let { sql: deleteSql, params: deleteParams } = buildDeleteQuery(crud.table, crud.primaryKey, req.params.id);
578
+ const request = pool.request();
579
+ for (const [key, value] of Object.entries(deleteParams)) {
580
+ request.input(key, value);
581
+ }
582
+ // Scope filter: restrict delete to user's scope
583
+ if (scopeFilterConfig) {
584
+ const user = getUserFromReq(req);
585
+ if (user) {
586
+ let activeScope = undefined;
587
+ if (scopeFilterConfig.mode === 'select') {
588
+ activeScope = resolveActiveScope(req.headers[scopeFilterConfig.header.toLowerCase()], scopeFilterConfig.type);
589
+ }
590
+ const scopeClause = buildScopeWhereClause(scopeFilterConfig, user.scope, activeScope, user.roles);
591
+ if (scopeClause) {
592
+ deleteSql = deleteSql.replace(/WHERE\s+/i, `WHERE ${scopeClause.sql} AND `);
593
+ for (const [key, value] of Object.entries(scopeClause.params)) {
594
+ request.input(key, value);
595
+ }
596
+ }
597
+ }
598
+ }
599
+ const result = await request.query(deleteSql);
600
+ if (result.rowsAffected[0] === 0) {
601
+ res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Record not found' } });
602
+ return;
603
+ }
604
+ res.status(204).send();
605
+ }
606
+ catch (err) {
607
+ next(err);
608
+ }
609
+ });
610
+ app.delete(`${endpoint.path}/:id`, ...deleteHandlers);
611
+ }
612
+ }
613
+ function extractParamValues(req, params) {
614
+ const values = {};
615
+ for (const [name, config] of Object.entries(params)) {
616
+ const source = config.source ?? autoDetectSource(name, req);
617
+ let raw;
618
+ if (source === 'path') {
619
+ raw = req.params[name];
620
+ }
621
+ else if (source === 'body') {
622
+ raw = req.body?.[name] !== undefined ? String(req.body[name]) : undefined;
623
+ }
624
+ else {
625
+ raw = req.query[name];
626
+ }
627
+ const parsed = parseParamValue(raw, config.type, config.max);
628
+ values[name] = parsed ?? config.default ?? null;
629
+ }
630
+ return values;
631
+ }
632
+ function autoDetectSource(name, req) {
633
+ if (req.params[name] !== undefined)
634
+ return 'path';
635
+ if (req.method !== 'GET' && req.body?.[name] !== undefined)
636
+ return 'body';
637
+ return 'query';
638
+ }
639
+ function validateRequiredParams(params, values) {
640
+ for (const [name, config] of Object.entries(params)) {
641
+ if (config.required && (values[name] === null || values[name] === undefined)) {
642
+ const err = new Error(`Required parameter "${name}" is missing`);
643
+ err.type = 'VALIDATION';
644
+ err.status = 400;
645
+ throw err;
646
+ }
647
+ }
648
+ }
649
+ function bindAllParams(request, params, values) {
650
+ for (const [name, config] of Object.entries(params)) {
651
+ const value = values[name];
652
+ request.input(name, getSqlType(config.type), value ?? null);
653
+ }
654
+ }
655
+ function printStartupInfo(spec, config, port, handlers, specServingEnabled) {
656
+ console.log('\nMythik Server v0.2.0');
657
+ console.log(`Connected to SQL Server — ${config.database.database}`);
658
+ if (spec.auth) {
659
+ console.log(`\nAuth: JWT${spec.auth.provider ? ' + built-in login' : ' (external)'}`);
660
+ if (spec.auth.provider) {
661
+ console.log(' POST /api/auth/login');
662
+ console.log(' POST /api/auth/refresh');
663
+ }
664
+ if (spec.auth.policies) {
665
+ console.log(` Policies (${Object.keys(spec.auth.policies).length}): ${Object.keys(spec.auth.policies).join(', ')}`);
666
+ }
667
+ if (spec.auth.scopeFilter) {
668
+ console.log(` Scope filter: ${spec.auth.scopeFilter.mode ?? 'all'} on ${spec.auth.scopeFilter.column}`);
669
+ }
670
+ }
671
+ if (spec.catalogs) {
672
+ const catalogNames = Object.keys(spec.catalogs);
673
+ console.log(`\nCatalogs (${catalogNames.length}):`);
674
+ for (const name of catalogNames) {
675
+ const config = spec.catalogs[name];
676
+ const detail = config.static
677
+ ? '(static)'
678
+ : config.distinct
679
+ ? `(distinct: ${config.distinct})`
680
+ : `(${config.value} → ${config.label})`;
681
+ console.log(` GET /api/catalogs/${name} ${detail}`);
682
+ }
683
+ }
684
+ if (spec.endpoints) {
685
+ const endpointEntries = Object.entries(spec.endpoints);
686
+ console.log(`\nEndpoints (${endpointEntries.length}):`);
687
+ for (const [, ep] of endpointEntries) {
688
+ const m = (ep.method ?? 'GET').toUpperCase();
689
+ const details = [];
690
+ if (ep.query)
691
+ details.push('query');
692
+ if (ep.pagination)
693
+ details.push(`pagination: ${ep.pagination}`);
694
+ if (ep.totals)
695
+ details.push(`totals: ${Array.isArray(ep.totals) ? ep.totals.length : 'manual'}`);
696
+ if (ep.handler)
697
+ details.push(`handler: ${ep.handler}`);
698
+ if (ep.crud)
699
+ details.push('crud');
700
+ if (ep.policy)
701
+ details.push(`policy: ${ep.policy}`);
702
+ if (ep.scopeFilter)
703
+ details.push('scopeFilter');
704
+ console.log(` ${m.padEnd(6)} ${ep.path} (${details.join(', ')})`);
705
+ }
706
+ }
707
+ if (specServingEnabled) {
708
+ console.log('\nSpec serving:');
709
+ console.log(' GET /api/screens/:id');
710
+ console.log(' GET /api/app/:id');
711
+ }
712
+ if (handlers.size > 0) {
713
+ console.log(`\nHandlers (${handlers.size}): ${Array.from(handlers.keys()).join(', ')}`);
714
+ }
715
+ console.log(`\nListening on http://localhost:${port}\n`);
716
+ }
717
+ //# sourceMappingURL=server.js.map