aiplang 2.9.2 → 2.9.4
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/README.md +16 -3
- package/bin/aiplang.js +1 -1
- package/package.json +1 -1
- package/server/server.js +194 -15
package/README.md
CHANGED
|
@@ -6,9 +6,16 @@
|
|
|
6
6
|
[](https://npmjs.com/package/aiplang)
|
|
7
7
|
|
|
8
8
|
```bash
|
|
9
|
+
# npm
|
|
9
10
|
npx aiplang init my-app
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
|
|
12
|
+
# yarn
|
|
13
|
+
yarn dlx aiplang init my-app
|
|
14
|
+
|
|
15
|
+
# pnpm
|
|
16
|
+
pnpm dlx aiplang init my-app
|
|
17
|
+
|
|
18
|
+
cd my-app && npx aiplang serve
|
|
12
19
|
```
|
|
13
20
|
|
|
14
21
|
Ask Claude to generate a page → paste into `pages/home.aip` → see it live.
|
|
@@ -56,11 +63,17 @@ foot{© 2025}
|
|
|
56
63
|
## Commands
|
|
57
64
|
|
|
58
65
|
```bash
|
|
66
|
+
# Install globally (pick one)
|
|
67
|
+
npm install -g aiplang
|
|
68
|
+
yarn global add aiplang
|
|
69
|
+
pnpm add -g aiplang
|
|
70
|
+
|
|
71
|
+
# Or run directly without installing
|
|
59
72
|
npx aiplang serve # dev server + hot reload
|
|
60
73
|
npx aiplang build pages/ # compile → static HTML
|
|
61
74
|
npx aiplang start app.aip # full-stack Node.js server
|
|
62
75
|
npx aiplang init my-app # create project
|
|
63
|
-
npx aiplang init my-app --template saas|landing|crud|dashboard|blog|ecommerce|todo|analytics|chat
|
|
76
|
+
npx aiplang init my-app --template saas|landing|crud|dashboard|blog|ecommerce|todo|analytics|chat|hello
|
|
64
77
|
npx aiplang template list # list saved templates
|
|
65
78
|
npx aiplang template save my-tpl # save current project as template
|
|
66
79
|
```
|
package/bin/aiplang.js
CHANGED
|
@@ -5,7 +5,7 @@ const fs = require('fs')
|
|
|
5
5
|
const path = require('path')
|
|
6
6
|
const http = require('http')
|
|
7
7
|
|
|
8
|
-
const VERSION = '2.9.
|
|
8
|
+
const VERSION = '2.9.4'
|
|
9
9
|
const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
|
|
10
10
|
const cmd = process.argv[2]
|
|
11
11
|
const args = process.argv.slice(3)
|
package/package.json
CHANGED
package/server/server.js
CHANGED
|
@@ -139,9 +139,41 @@ if (!process.env.JWT_SECRET) {
|
|
|
139
139
|
}
|
|
140
140
|
console.warn('[aiplang] WARNING: JWT_SECRET not set. Using insecure dev default. Set JWT_SECRET in .env')
|
|
141
141
|
}
|
|
142
|
-
let JWT_EXPIRE
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
let JWT_EXPIRE = '7d'
|
|
143
|
+
let JWT_REFRESH_EXPIRE = '30d'
|
|
144
|
+
let JWT_REFRESH_SECRET = null
|
|
145
|
+
|
|
146
|
+
function getRefreshSecret() {
|
|
147
|
+
return JWT_REFRESH_SECRET || (JWT_SECRET + ':refresh')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const generateJWT = (user) => jwt.sign(
|
|
151
|
+
{ id: user.id, email: user.email, role: user.role || 'user', type: 'access' },
|
|
152
|
+
JWT_SECRET,
|
|
153
|
+
{ expiresIn: JWT_EXPIRE }
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const generateRefreshToken = (user) => jwt.sign(
|
|
157
|
+
{ id: user.id, type: 'refresh' },
|
|
158
|
+
getRefreshSecret(),
|
|
159
|
+
{ expiresIn: JWT_REFRESH_EXPIRE }
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const verifyJWT = (token) => {
|
|
163
|
+
try {
|
|
164
|
+
const p = jwt.verify(token, JWT_SECRET)
|
|
165
|
+
if (p.type === 'refresh') return null
|
|
166
|
+
return p
|
|
167
|
+
} catch { return null }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const verifyRefreshToken = (token) => {
|
|
171
|
+
try {
|
|
172
|
+
const p = jwt.verify(token, getRefreshSecret())
|
|
173
|
+
if (p.type !== 'refresh') return null
|
|
174
|
+
return p
|
|
175
|
+
} catch { return null }
|
|
176
|
+
}
|
|
145
177
|
|
|
146
178
|
// ── WebSocket Realtime Server ─────────────────────────────────────
|
|
147
179
|
let _wsServer = null
|
|
@@ -279,6 +311,62 @@ const MODEL_DEFS = {}
|
|
|
279
311
|
function toTable(name) { return name.toLowerCase().replace(/([A-Z])/g,'_$1').replace(/^_/,'') + 's' }
|
|
280
312
|
function toCol(field) { return field.replace(/([A-Z])/g,'_$1').toLowerCase() }
|
|
281
313
|
|
|
314
|
+
// ── Runtime schema validation + coercion ─────────────────────────
|
|
315
|
+
function validateAndCoerce(data, schema) {
|
|
316
|
+
if (!schema) return { ok: true, data }
|
|
317
|
+
const out = { ...data }
|
|
318
|
+
const errors = []
|
|
319
|
+
|
|
320
|
+
for (const [col, def] of Object.entries(schema)) {
|
|
321
|
+
const val = out[col]
|
|
322
|
+
|
|
323
|
+
// Skip pk/auto/hashed fields
|
|
324
|
+
if (col === 'id' || def.hashed) continue
|
|
325
|
+
|
|
326
|
+
// Coerce type
|
|
327
|
+
if (val !== undefined && val !== null) {
|
|
328
|
+
if (Array.isArray(val) || (typeof val === 'object' && val !== null && def.type !== 'json')) {
|
|
329
|
+
errors.push(`${col}: expected ${def.type}, got ${Array.isArray(val)?'array':'object'}`)
|
|
330
|
+
} else if (def.type === 'int') {
|
|
331
|
+
const n = parseInt(val)
|
|
332
|
+
if (isNaN(n)) errors.push(`${col}: expected integer, got "${val}"`)
|
|
333
|
+
else out[col] = n
|
|
334
|
+
} else if (def.type === 'float') {
|
|
335
|
+
const n = parseFloat(val)
|
|
336
|
+
if (isNaN(n)) errors.push(`${col}: expected number, got "${val}"`)
|
|
337
|
+
else out[col] = n
|
|
338
|
+
} else if (def.type === 'bool') {
|
|
339
|
+
if (typeof val === 'string') out[col] = val === 'true' || val === '1'
|
|
340
|
+
else out[col] = Boolean(val)
|
|
341
|
+
} else if (def.type === 'enum' && def.enumVals.length > 0) {
|
|
342
|
+
if (!def.enumVals.includes(String(val))) {
|
|
343
|
+
errors.push(`${col}: "${val}" is not valid. Must be one of: ${def.enumVals.join(', ')}`)
|
|
344
|
+
}
|
|
345
|
+
} else if (def.type === 'json' && typeof val === 'string') {
|
|
346
|
+
try { out[col] = JSON.parse(val) } catch { /* keep as string */ }
|
|
347
|
+
} else if (Array.isArray(val)) {
|
|
348
|
+
// Arrays are not allowed for non-json fields
|
|
349
|
+
if (def.type !== 'json') {
|
|
350
|
+
errors.push(`${col}: expected ${def.type}, got array`)
|
|
351
|
+
}
|
|
352
|
+
} else if (typeof val === 'object' && val !== null && def.type !== 'json') {
|
|
353
|
+
errors.push(`${col}: expected ${def.type}, got object`)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Apply default
|
|
358
|
+
if ((val === undefined || val === null || val === '') && def.default !== null && def.default !== undefined) {
|
|
359
|
+
if (def.type === 'bool') out[col] = def.default === 'true' || def.default === true
|
|
360
|
+
else if (def.type === 'int') out[col] = parseInt(def.default) || 0
|
|
361
|
+
else if (def.type === 'float') out[col] = parseFloat(def.default) || 0
|
|
362
|
+
else out[col] = def.default
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
367
|
+
return { ok: true, data: out }
|
|
368
|
+
}
|
|
369
|
+
|
|
282
370
|
class Model {
|
|
283
371
|
constructor(name, def = null) {
|
|
284
372
|
this.modelName = name
|
|
@@ -286,6 +374,7 @@ class Model {
|
|
|
286
374
|
this.def = def || MODEL_DEFS[name] || {}
|
|
287
375
|
this.softDelete = this.def.softDelete || false
|
|
288
376
|
this.timestamps = this.def.timestamps !== false
|
|
377
|
+
this.schema = this.def.schema || null
|
|
289
378
|
}
|
|
290
379
|
|
|
291
380
|
// ── Core queries ────────────────────────────────────────────────
|
|
@@ -338,7 +427,15 @@ class Model {
|
|
|
338
427
|
}
|
|
339
428
|
|
|
340
429
|
create(data) {
|
|
341
|
-
|
|
430
|
+
// Runtime schema validation + coercion
|
|
431
|
+
const validated = validateAndCoerce(data, this.schema)
|
|
432
|
+
if (!validated.ok) {
|
|
433
|
+
const err = new Error(`Schema validation failed: ${validated.errors.join('; ')}`)
|
|
434
|
+
err.statusCode = 422
|
|
435
|
+
err.errors = validated.errors
|
|
436
|
+
throw err
|
|
437
|
+
}
|
|
438
|
+
const row = { ...validated.data }
|
|
342
439
|
if (!row.id) row.id = uuid()
|
|
343
440
|
if (this.timestamps) {
|
|
344
441
|
if (!row.created_at) row.created_at = now()
|
|
@@ -351,7 +448,15 @@ class Model {
|
|
|
351
448
|
}
|
|
352
449
|
|
|
353
450
|
update(id, data) {
|
|
354
|
-
|
|
451
|
+
// Validate + coerce update payload
|
|
452
|
+
const validated = validateAndCoerce(data, this.schema)
|
|
453
|
+
if (!validated.ok) {
|
|
454
|
+
const err = new Error(`Schema validation failed: ${validated.errors.join('; ')}`)
|
|
455
|
+
err.statusCode = 422
|
|
456
|
+
err.errors = validated.errors
|
|
457
|
+
throw err
|
|
458
|
+
}
|
|
459
|
+
const row = { ...validated.data }
|
|
355
460
|
delete row.id; delete row.created_at; delete row.password
|
|
356
461
|
if (this.timestamps) row.updated_at = now()
|
|
357
462
|
const sets = Object.keys(row).map(k => `${k} = ?`).join(', ')
|
|
@@ -449,7 +554,19 @@ function migrateModels(models) {
|
|
|
449
554
|
// Always index created_at for pagination performance
|
|
450
555
|
try { dbRun(`CREATE INDEX IF NOT EXISTS idx_${table}_created_at ON ${table}(created_at)`) } catch {}
|
|
451
556
|
console.log(`[aiplang] ✓ ${table} (${cols.length} cols${model.softDelete ? ', soft-delete' : ''})`)
|
|
452
|
-
|
|
557
|
+
// Store full schema for runtime validation
|
|
558
|
+
const schema = {}
|
|
559
|
+
for (const f of model.fields) {
|
|
560
|
+
schema[toCol(f.name)] = {
|
|
561
|
+
type: f.type,
|
|
562
|
+
required: f.modifiers.includes('required'),
|
|
563
|
+
unique: f.modifiers.includes('unique'),
|
|
564
|
+
hashed: f.modifiers.includes('hashed'),
|
|
565
|
+
enumVals: f.enumVals || [],
|
|
566
|
+
default: f.default,
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
MODEL_DEFS[model.name] = { softDelete: model.softDelete, timestamps: true, schema }
|
|
453
570
|
}
|
|
454
571
|
}
|
|
455
572
|
|
|
@@ -502,11 +619,25 @@ function parseApp(src) {
|
|
|
502
619
|
|
|
503
620
|
if (line.startsWith('api ')) {
|
|
504
621
|
if (inAPI && curAPI) app.apis.push(curAPI)
|
|
505
|
-
const
|
|
622
|
+
const braceIdx = line.indexOf('{')
|
|
623
|
+
const closeBraceIdx = line.lastIndexOf('}')
|
|
624
|
+
const pts = line.slice(4, braceIdx).trim().split(/\s+/)
|
|
506
625
|
curAPI = { method:pts[0], path:pts[1], guards:[], validate:[], query:[], body:[], return:null }
|
|
626
|
+
// Inline api: "api GET /path { ops }" — entire api on one line
|
|
627
|
+
if (braceIdx !== -1 && closeBraceIdx > braceIdx) {
|
|
628
|
+
const inlineBody = line.slice(braceIdx+1, closeBraceIdx).trim()
|
|
629
|
+
if (inlineBody) {
|
|
630
|
+
inlineBody.split('\n').forEach(op => { op = op.trim(); if (op) parseAPILine(op, curAPI) })
|
|
631
|
+
}
|
|
632
|
+
app.apis.push(curAPI); curAPI=null; inAPI=false; i++; continue
|
|
633
|
+
}
|
|
507
634
|
inAPI=true; i++; continue
|
|
508
635
|
}
|
|
509
|
-
if (inAPI && line === '}'
|
|
636
|
+
if (inAPI && (line === '}' || (line.endsWith('}') && !line.includes('{')))) {
|
|
637
|
+
const opLine = line !== '}' ? line.slice(0, line.lastIndexOf('}')).trim() : null
|
|
638
|
+
if (opLine && curAPI) parseAPILine(opLine, curAPI)
|
|
639
|
+
if (curAPI) app.apis.push(curAPI); curAPI=null; inAPI=false; i++; continue
|
|
640
|
+
}
|
|
510
641
|
if (inAPI && curAPI) { parseAPILine(line, curAPI); i++; continue }
|
|
511
642
|
i++
|
|
512
643
|
}
|
|
@@ -518,7 +649,7 @@ function parseApp(src) {
|
|
|
518
649
|
|
|
519
650
|
function parseEnvLine(s) { const p=s.split(/\s+/); const ev={name:'',required:false,default:null}; for(const x of p){if(x==='required')ev.required=true;else if(x.includes('=')){const[k,v]=x.split('=');ev.name=k;ev.default=v}else ev.name=x}; return ev }
|
|
520
651
|
function parseDBLine(s) { const p=s.split(/\s+/); return{driver:p[0]||'sqlite',dsn:p[1]||'./app.db'} }
|
|
521
|
-
function parseAuthLine(s) { const p=s.split(/\s+/); const a={provider:'jwt',secret:p[1]||'$JWT_SECRET',expire:'7d'}; for(const x of p){if(x.startsWith('expire='))a.expire=x.slice(7);if(x==='google')a.oauth=['google'];if(x==='github')a.oauth=[...(a.oauth||[]),'google']}; return a }
|
|
652
|
+
function parseAuthLine(s) { const p=s.split(/\s+/); const a={provider:'jwt',secret:p[1]||'$JWT_SECRET',expire:'7d',refresh:'30d'}; for(const x of p){if(x.startsWith('expire='))a.expire=x.slice(7);if(x.startsWith('refresh='))a.refresh=x.slice(8);if(x==='google')a.oauth=['google'];if(x==='github')a.oauth=[...(a.oauth||[]),'google']}; return a }
|
|
522
653
|
function parseMailLine(s) { const parts=s.split(/\s+/); const m={driver:parts[0]||'smtp'}; for(const x of parts.slice(1)){const[k,v]=x.split('='); m[k]=v}; return m }
|
|
523
654
|
function parseStripeLine(s) {
|
|
524
655
|
const parts = s.split(/\s+/)
|
|
@@ -577,7 +708,13 @@ function parseEventLine(s) { const m=s.match(/^(\S+)\s*=>\s*(.+)$/); return{even
|
|
|
577
708
|
function parseField(line) {
|
|
578
709
|
const p=line.split(':').map(s=>s.trim())
|
|
579
710
|
const f={name:p[0],type:p[1]||'text',modifiers:[],enumVals:[],default:null}
|
|
580
|
-
|
|
711
|
+
// If type is enum, p[2] contains comma-separated values directly
|
|
712
|
+
if (f.type === 'enum' && p[2] && !p[2].startsWith('default=') && !['required','unique','hashed','pk','auto','index'].includes(p[2])) {
|
|
713
|
+
f.enumVals = p[2].split(',').map(v=>v.trim()).filter(Boolean)
|
|
714
|
+
for(let j=3;j<p.length;j++){const x=p[j];if(x.startsWith('default='))f.default=x.slice(8);else if(x)f.modifiers.push(x)}
|
|
715
|
+
} else {
|
|
716
|
+
for(let j=2;j<p.length;j++){const x=p[j];if(x.startsWith('default='))f.default=x.slice(8);else if(x.startsWith('enum:'))f.enumVals=x.slice(5).split(',').map(v=>v.trim());else if(x)f.modifiers.push(x)}
|
|
717
|
+
}
|
|
581
718
|
return f
|
|
582
719
|
}
|
|
583
720
|
function parseAPILine(line, route) {
|
|
@@ -745,14 +882,33 @@ async function execOp(line, ctx, server) {
|
|
|
745
882
|
// insert Model($body)
|
|
746
883
|
if (line.startsWith('insert ')) {
|
|
747
884
|
const modelName=line.match(/insert\s+(\w+)/)?.[1]; const m=server.models[modelName]
|
|
748
|
-
if (m) {
|
|
885
|
+
if (m) {
|
|
886
|
+
try {
|
|
887
|
+
ctx.vars['inserted'] = m.create({...ctx.body})
|
|
888
|
+
broadcast(modelName.toLowerCase(), {action:'created',data:ctx.vars['inserted']})
|
|
889
|
+
return ctx.vars['inserted']
|
|
890
|
+
} catch(e) {
|
|
891
|
+
if (e.statusCode) { ctx.res.error(e.statusCode, e.message); return '__DONE__' }
|
|
892
|
+
throw e
|
|
893
|
+
}
|
|
894
|
+
}
|
|
749
895
|
return null
|
|
750
896
|
}
|
|
751
897
|
|
|
752
898
|
// update Model($id, $body)
|
|
753
899
|
if (line.startsWith('update ')) {
|
|
754
900
|
const modelName=line.match(/update\s+(\w+)/)?.[1]; const m=server.models[modelName]
|
|
755
|
-
if (m) {
|
|
901
|
+
if (m) {
|
|
902
|
+
try {
|
|
903
|
+
const id=ctx.params.id||ctx.vars['id']
|
|
904
|
+
ctx.vars['updated'] = m.update(id,{...ctx.body})
|
|
905
|
+
broadcast(modelName.toLowerCase(), {action:'updated',data:ctx.vars['updated']})
|
|
906
|
+
return ctx.vars['updated']
|
|
907
|
+
} catch(e) {
|
|
908
|
+
if (e.statusCode) { ctx.res.error(e.statusCode, e.message); return '__DONE__' }
|
|
909
|
+
throw e
|
|
910
|
+
}
|
|
911
|
+
}
|
|
756
912
|
return null
|
|
757
913
|
}
|
|
758
914
|
|
|
@@ -785,7 +941,7 @@ async function execOp(line, ctx, server) {
|
|
|
785
941
|
|
|
786
942
|
function evalExpr(expr, ctx, server) {
|
|
787
943
|
expr=expr.trim()
|
|
788
|
-
if (expr.startsWith('jwt(')) { const vn=expr.match(/jwt\(\$([^)]+)\)/)?.[1]; const u=vn?ctx.vars[vn]:ctx.body; return{token:generateJWT(u),user:sanitize(u)} }
|
|
944
|
+
if (expr.startsWith('jwt(')) { const vn=expr.match(/jwt\(\$([^)]+)\)/)?.[1]; const u=vn?ctx.vars[vn]:ctx.body; return{token:generateJWT(u),refresh_token:generateRefreshToken(u),expires_in:JWT_EXPIRE,user:sanitize(u)} }
|
|
789
945
|
if (expr==='$auth.user'||expr==='$auth') return ctx.user
|
|
790
946
|
if (expr.includes('.all(')) { return evalModelOp('all', expr, ctx, server) }
|
|
791
947
|
if (expr.includes('.find(')) { return evalModelOp('find', expr, ctx, server) }
|
|
@@ -1537,6 +1693,8 @@ async function startServer(aipFile, port = 3000) {
|
|
|
1537
1693
|
// Auth setup
|
|
1538
1694
|
if (app.auth) {
|
|
1539
1695
|
JWT_SECRET = resolveEnv(app.auth.secret) || JWT_SECRET
|
|
1696
|
+
if (app.auth.expire) JWT_EXPIRE = app.auth.expire
|
|
1697
|
+
if (app.auth.refresh) JWT_REFRESH_EXPIRE = app.auth.refresh
|
|
1540
1698
|
JWT_EXPIRE = app.auth.expire || '7d'
|
|
1541
1699
|
}
|
|
1542
1700
|
|
|
@@ -1560,11 +1718,32 @@ async function startServer(aipFile, port = 3000) {
|
|
|
1560
1718
|
migrateModels(app.models)
|
|
1561
1719
|
|
|
1562
1720
|
// Register models
|
|
1563
|
-
for (const m of app.models) srv.registerModel(m.name, { softDelete: m.softDelete, timestamps: true })
|
|
1721
|
+
for (const m of app.models) srv.registerModel(m.name, MODEL_DEFS[m.name] || { softDelete: m.softDelete, timestamps: true })
|
|
1564
1722
|
|
|
1565
1723
|
// Events
|
|
1566
1724
|
for (const ev of app.events) on(ev.event, (data) => console.log(`[aiplang:event] ${ev.event}:`, ev.action))
|
|
1567
1725
|
|
|
1726
|
+
// Auto refresh token endpoint — POST /api/auth/refresh
|
|
1727
|
+
if (app.auth) {
|
|
1728
|
+
srv.addRoute('POST', '/api/auth/refresh', async (req, res) => {
|
|
1729
|
+
const refreshToken = req.body?.refresh_token ||
|
|
1730
|
+
(req.headers['authorization']?.startsWith('Bearer ') ? req.headers['authorization'].slice(7) : null)
|
|
1731
|
+
if (!refreshToken) { res.error(401, 'refresh_token required'); return }
|
|
1732
|
+
const payload = verifyRefreshToken(refreshToken)
|
|
1733
|
+
if (!payload) { res.error(401, 'Invalid or expired refresh token'); return }
|
|
1734
|
+
// Find the user
|
|
1735
|
+
const userModel = srv.models['User'] || Object.values(srv.models)[0]
|
|
1736
|
+
if (!userModel) { res.error(500, 'No user model found'); return }
|
|
1737
|
+
const user = userModel.find(payload.id)
|
|
1738
|
+
if (!user) { res.error(401, 'User not found'); return }
|
|
1739
|
+
// Issue new tokens
|
|
1740
|
+
const newToken = generateJWT(user)
|
|
1741
|
+
const newRefresh = generateRefreshToken(user)
|
|
1742
|
+
res.json(200, { token: newToken, refresh_token: newRefresh, expires_in: JWT_EXPIRE })
|
|
1743
|
+
})
|
|
1744
|
+
console.log('[aiplang] Route: POST /api/auth/refresh (auto)')
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1568
1747
|
// Auth rate limiting (automatic — 20 req/min per IP on /api/auth/*)
|
|
1569
1748
|
const _authAttempts = {}
|
|
1570
1749
|
srv._authRateLimit = (req) => {
|
|
@@ -1618,7 +1797,7 @@ async function startServer(aipFile, port = 3000) {
|
|
|
1618
1797
|
|
|
1619
1798
|
// Health
|
|
1620
1799
|
srv.addRoute('GET', '/health', (req, res) => res.json(200, {
|
|
1621
|
-
status:'ok', version:'2.9.
|
|
1800
|
+
status:'ok', version:'2.9.4',
|
|
1622
1801
|
models: app.models.map(m=>m.name),
|
|
1623
1802
|
routes: app.apis.length, pages: app.pages.length,
|
|
1624
1803
|
admin: app.admin?.prefix || null,
|