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.
- package/LICENSE +201 -0
- package/NOTICE +4 -0
- package/README.md +51 -0
- package/dist/api-handler.d.ts +4 -0
- package/dist/api-handler.d.ts.map +1 -0
- package/dist/api-handler.js +110 -0
- package/dist/api-handler.js.map +1 -0
- package/dist/audit.d.ts +18 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +42 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth/db-auth-provider.d.ts +10 -0
- package/dist/auth/db-auth-provider.d.ts.map +1 -0
- package/dist/auth/db-auth-provider.js +130 -0
- package/dist/auth/db-auth-provider.js.map +1 -0
- package/dist/auth/jwt-strategy.d.ts +3 -0
- package/dist/auth/jwt-strategy.d.ts.map +1 -0
- package/dist/auth/jwt-strategy.js +39 -0
- package/dist/auth/jwt-strategy.js.map +1 -0
- package/dist/auth/middleware.d.ts +4 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +40 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/password-verifier.d.ts +3 -0
- package/dist/auth/password-verifier.d.ts.map +1 -0
- package/dist/auth/password-verifier.js +18 -0
- package/dist/auth/password-verifier.js.map +1 -0
- package/dist/auth/policy-evaluator.d.ts +3 -0
- package/dist/auth/policy-evaluator.d.ts.map +1 -0
- package/dist/auth/policy-evaluator.js +11 -0
- package/dist/auth/policy-evaluator.js.map +1 -0
- package/dist/auth/refresh-store.d.ts +8 -0
- package/dist/auth/refresh-store.d.ts.map +1 -0
- package/dist/auth/refresh-store.js +34 -0
- package/dist/auth/refresh-store.js.map +1 -0
- package/dist/auth/scope-filter.d.ts +10 -0
- package/dist/auth/scope-filter.d.ts.map +1 -0
- package/dist/auth/scope-filter.js +51 -0
- package/dist/auth/scope-filter.js.map +1 -0
- package/dist/auth/types.d.ts +75 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +3 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/auth/user-context.d.ts +4 -0
- package/dist/auth/user-context.d.ts.map +1 -0
- package/dist/auth/user-context.js +18 -0
- package/dist/auth/user-context.js.map +1 -0
- package/dist/catalog-builder.d.ts +3 -0
- package/dist/catalog-builder.d.ts.map +1 -0
- package/dist/catalog-builder.js +35 -0
- package/dist/catalog-builder.js.map +1 -0
- package/dist/connection.d.ts +4 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +18 -0
- package/dist/connection.js.map +1 -0
- package/dist/crud-builder.d.ts +14 -0
- package/dist/crud-builder.d.ts.map +1 -0
- package/dist/crud-builder.js +43 -0
- package/dist/crud-builder.js.map +1 -0
- package/dist/handler-loader.d.ts +9 -0
- package/dist/handler-loader.d.ts.map +1 -0
- package/dist/handler-loader.js +42 -0
- package/dist/handler-loader.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/cors.d.ts +3 -0
- package/dist/middleware/cors.d.ts.map +1 -0
- package/dist/middleware/cors.js +8 -0
- package/dist/middleware/cors.js.map +1 -0
- package/dist/middleware/error-handler.d.ts +9 -0
- package/dist/middleware/error-handler.d.ts.map +1 -0
- package/dist/middleware/error-handler.js +56 -0
- package/dist/middleware/error-handler.js.map +1 -0
- package/dist/query-engine.d.ts +8 -0
- package/dist/query-engine.d.ts.map +1 -0
- package/dist/query-engine.js +66 -0
- package/dist/query-engine.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +717 -0
- package/dist/server.js.map +1 -0
- package/dist/spec-loader.d.ts +6 -0
- package/dist/spec-loader.d.ts.map +1 -0
- package/dist/spec-loader.js +21 -0
- package/dist/spec-loader.js.map +1 -0
- package/dist/spec-serving.d.ts +9 -0
- package/dist/spec-serving.d.ts.map +1 -0
- package/dist/spec-serving.js +53 -0
- package/dist/spec-serving.js.map +1 -0
- package/dist/types.d.ts +131 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validation/identifier-guard.d.ts +2 -0
- package/dist/validation/identifier-guard.d.ts.map +1 -0
- package/dist/validation/identifier-guard.js +3 -0
- package/dist/validation/identifier-guard.js.map +1 -0
- package/dist/validation/spec-validator.d.ts +3 -0
- package/dist/validation/spec-validator.d.ts.map +1 -0
- package/dist/validation/spec-validator.js +3 -0
- package/dist/validation/spec-validator.js.map +1 -0
- 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
|