aiplang 2.9.2 → 2.9.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.
- package/README.md +16 -3
- package/bin/aiplang.js +1 -1
- package/package.json +1 -1
- package/server/server.js +184 -13
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.3'
|
|
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,60 @@ 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 (def.type === 'int') {
|
|
329
|
+
const n = parseInt(val)
|
|
330
|
+
if (isNaN(n)) errors.push(`${col}: expected integer, got "${val}"`)
|
|
331
|
+
else out[col] = n
|
|
332
|
+
} else if (def.type === 'float') {
|
|
333
|
+
const n = parseFloat(val)
|
|
334
|
+
if (isNaN(n)) errors.push(`${col}: expected number, got "${val}"`)
|
|
335
|
+
else out[col] = n
|
|
336
|
+
} else if (def.type === 'bool') {
|
|
337
|
+
if (typeof val === 'string') out[col] = val === 'true' || val === '1'
|
|
338
|
+
else out[col] = Boolean(val)
|
|
339
|
+
} else if (def.type === 'enum' && def.enumVals.length > 0) {
|
|
340
|
+
if (!def.enumVals.includes(String(val))) {
|
|
341
|
+
errors.push(`${col}: "${val}" is not valid. Must be one of: ${def.enumVals.join(', ')}`)
|
|
342
|
+
}
|
|
343
|
+
} else if (def.type === 'json' && typeof val === 'string') {
|
|
344
|
+
try { out[col] = JSON.parse(val) } catch { /* keep as string */ }
|
|
345
|
+
} else if (Array.isArray(val)) {
|
|
346
|
+
// Arrays are not allowed for non-json fields
|
|
347
|
+
if (def.type !== 'json') {
|
|
348
|
+
errors.push(`${col}: expected ${def.type}, got array`)
|
|
349
|
+
}
|
|
350
|
+
} else if (typeof val === 'object' && val !== null && def.type !== 'json') {
|
|
351
|
+
errors.push(`${col}: expected ${def.type}, got object`)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Apply default
|
|
356
|
+
if ((val === undefined || val === null || val === '') && def.default !== null && def.default !== undefined) {
|
|
357
|
+
if (def.type === 'bool') out[col] = def.default === 'true' || def.default === true
|
|
358
|
+
else if (def.type === 'int') out[col] = parseInt(def.default) || 0
|
|
359
|
+
else if (def.type === 'float') out[col] = parseFloat(def.default) || 0
|
|
360
|
+
else out[col] = def.default
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
365
|
+
return { ok: true, data: out }
|
|
366
|
+
}
|
|
367
|
+
|
|
282
368
|
class Model {
|
|
283
369
|
constructor(name, def = null) {
|
|
284
370
|
this.modelName = name
|
|
@@ -286,6 +372,7 @@ class Model {
|
|
|
286
372
|
this.def = def || MODEL_DEFS[name] || {}
|
|
287
373
|
this.softDelete = this.def.softDelete || false
|
|
288
374
|
this.timestamps = this.def.timestamps !== false
|
|
375
|
+
this.schema = this.def.schema || null
|
|
289
376
|
}
|
|
290
377
|
|
|
291
378
|
// ── Core queries ────────────────────────────────────────────────
|
|
@@ -338,7 +425,15 @@ class Model {
|
|
|
338
425
|
}
|
|
339
426
|
|
|
340
427
|
create(data) {
|
|
341
|
-
|
|
428
|
+
// Runtime schema validation + coercion
|
|
429
|
+
const validated = validateAndCoerce(data, this.schema)
|
|
430
|
+
if (!validated.ok) {
|
|
431
|
+
const err = new Error(`Schema validation failed: ${validated.errors.join('; ')}`)
|
|
432
|
+
err.statusCode = 422
|
|
433
|
+
err.errors = validated.errors
|
|
434
|
+
throw err
|
|
435
|
+
}
|
|
436
|
+
const row = { ...validated.data }
|
|
342
437
|
if (!row.id) row.id = uuid()
|
|
343
438
|
if (this.timestamps) {
|
|
344
439
|
if (!row.created_at) row.created_at = now()
|
|
@@ -351,7 +446,15 @@ class Model {
|
|
|
351
446
|
}
|
|
352
447
|
|
|
353
448
|
update(id, data) {
|
|
354
|
-
|
|
449
|
+
// Validate + coerce update payload
|
|
450
|
+
const validated = validateAndCoerce(data, this.schema)
|
|
451
|
+
if (!validated.ok) {
|
|
452
|
+
const err = new Error(`Schema validation failed: ${validated.errors.join('; ')}`)
|
|
453
|
+
err.statusCode = 422
|
|
454
|
+
err.errors = validated.errors
|
|
455
|
+
throw err
|
|
456
|
+
}
|
|
457
|
+
const row = { ...validated.data }
|
|
355
458
|
delete row.id; delete row.created_at; delete row.password
|
|
356
459
|
if (this.timestamps) row.updated_at = now()
|
|
357
460
|
const sets = Object.keys(row).map(k => `${k} = ?`).join(', ')
|
|
@@ -449,7 +552,19 @@ function migrateModels(models) {
|
|
|
449
552
|
// Always index created_at for pagination performance
|
|
450
553
|
try { dbRun(`CREATE INDEX IF NOT EXISTS idx_${table}_created_at ON ${table}(created_at)`) } catch {}
|
|
451
554
|
console.log(`[aiplang] ✓ ${table} (${cols.length} cols${model.softDelete ? ', soft-delete' : ''})`)
|
|
452
|
-
|
|
555
|
+
// Store full schema for runtime validation
|
|
556
|
+
const schema = {}
|
|
557
|
+
for (const f of model.fields) {
|
|
558
|
+
schema[toCol(f.name)] = {
|
|
559
|
+
type: f.type,
|
|
560
|
+
required: f.modifiers.includes('required'),
|
|
561
|
+
unique: f.modifiers.includes('unique'),
|
|
562
|
+
hashed: f.modifiers.includes('hashed'),
|
|
563
|
+
enumVals: f.enumVals || [],
|
|
564
|
+
default: f.default,
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
MODEL_DEFS[model.name] = { softDelete: model.softDelete, timestamps: true, schema }
|
|
453
568
|
}
|
|
454
569
|
}
|
|
455
570
|
|
|
@@ -502,11 +617,25 @@ function parseApp(src) {
|
|
|
502
617
|
|
|
503
618
|
if (line.startsWith('api ')) {
|
|
504
619
|
if (inAPI && curAPI) app.apis.push(curAPI)
|
|
505
|
-
const
|
|
620
|
+
const braceIdx = line.indexOf('{')
|
|
621
|
+
const closeBraceIdx = line.lastIndexOf('}')
|
|
622
|
+
const pts = line.slice(4, braceIdx).trim().split(/\s+/)
|
|
506
623
|
curAPI = { method:pts[0], path:pts[1], guards:[], validate:[], query:[], body:[], return:null }
|
|
624
|
+
// Inline api: "api GET /path { ops }" — entire api on one line
|
|
625
|
+
if (braceIdx !== -1 && closeBraceIdx > braceIdx) {
|
|
626
|
+
const inlineBody = line.slice(braceIdx+1, closeBraceIdx).trim()
|
|
627
|
+
if (inlineBody) {
|
|
628
|
+
inlineBody.split('\n').forEach(op => { op = op.trim(); if (op) parseAPILine(op, curAPI) })
|
|
629
|
+
}
|
|
630
|
+
app.apis.push(curAPI); curAPI=null; inAPI=false; i++; continue
|
|
631
|
+
}
|
|
507
632
|
inAPI=true; i++; continue
|
|
508
633
|
}
|
|
509
|
-
if (inAPI && line === '}'
|
|
634
|
+
if (inAPI && (line === '}' || (line.endsWith('}') && !line.includes('{')))) {
|
|
635
|
+
const opLine = line !== '}' ? line.slice(0, line.lastIndexOf('}')).trim() : null
|
|
636
|
+
if (opLine && curAPI) parseAPILine(opLine, curAPI)
|
|
637
|
+
if (curAPI) app.apis.push(curAPI); curAPI=null; inAPI=false; i++; continue
|
|
638
|
+
}
|
|
510
639
|
if (inAPI && curAPI) { parseAPILine(line, curAPI); i++; continue }
|
|
511
640
|
i++
|
|
512
641
|
}
|
|
@@ -518,7 +647,7 @@ function parseApp(src) {
|
|
|
518
647
|
|
|
519
648
|
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
649
|
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 }
|
|
650
|
+
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
651
|
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
652
|
function parseStripeLine(s) {
|
|
524
653
|
const parts = s.split(/\s+/)
|
|
@@ -745,14 +874,33 @@ async function execOp(line, ctx, server) {
|
|
|
745
874
|
// insert Model($body)
|
|
746
875
|
if (line.startsWith('insert ')) {
|
|
747
876
|
const modelName=line.match(/insert\s+(\w+)/)?.[1]; const m=server.models[modelName]
|
|
748
|
-
if (m) {
|
|
877
|
+
if (m) {
|
|
878
|
+
try {
|
|
879
|
+
ctx.vars['inserted'] = m.create({...ctx.body})
|
|
880
|
+
broadcast(modelName.toLowerCase(), {action:'created',data:ctx.vars['inserted']})
|
|
881
|
+
return ctx.vars['inserted']
|
|
882
|
+
} catch(e) {
|
|
883
|
+
if (e.statusCode) { ctx.res.error(e.statusCode, e.message); return '__DONE__' }
|
|
884
|
+
throw e
|
|
885
|
+
}
|
|
886
|
+
}
|
|
749
887
|
return null
|
|
750
888
|
}
|
|
751
889
|
|
|
752
890
|
// update Model($id, $body)
|
|
753
891
|
if (line.startsWith('update ')) {
|
|
754
892
|
const modelName=line.match(/update\s+(\w+)/)?.[1]; const m=server.models[modelName]
|
|
755
|
-
if (m) {
|
|
893
|
+
if (m) {
|
|
894
|
+
try {
|
|
895
|
+
const id=ctx.params.id||ctx.vars['id']
|
|
896
|
+
ctx.vars['updated'] = m.update(id,{...ctx.body})
|
|
897
|
+
broadcast(modelName.toLowerCase(), {action:'updated',data:ctx.vars['updated']})
|
|
898
|
+
return ctx.vars['updated']
|
|
899
|
+
} catch(e) {
|
|
900
|
+
if (e.statusCode) { ctx.res.error(e.statusCode, e.message); return '__DONE__' }
|
|
901
|
+
throw e
|
|
902
|
+
}
|
|
903
|
+
}
|
|
756
904
|
return null
|
|
757
905
|
}
|
|
758
906
|
|
|
@@ -785,7 +933,7 @@ async function execOp(line, ctx, server) {
|
|
|
785
933
|
|
|
786
934
|
function evalExpr(expr, ctx, server) {
|
|
787
935
|
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)} }
|
|
936
|
+
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
937
|
if (expr==='$auth.user'||expr==='$auth') return ctx.user
|
|
790
938
|
if (expr.includes('.all(')) { return evalModelOp('all', expr, ctx, server) }
|
|
791
939
|
if (expr.includes('.find(')) { return evalModelOp('find', expr, ctx, server) }
|
|
@@ -1537,6 +1685,8 @@ async function startServer(aipFile, port = 3000) {
|
|
|
1537
1685
|
// Auth setup
|
|
1538
1686
|
if (app.auth) {
|
|
1539
1687
|
JWT_SECRET = resolveEnv(app.auth.secret) || JWT_SECRET
|
|
1688
|
+
if (app.auth.expire) JWT_EXPIRE = app.auth.expire
|
|
1689
|
+
if (app.auth.refresh) JWT_REFRESH_EXPIRE = app.auth.refresh
|
|
1540
1690
|
JWT_EXPIRE = app.auth.expire || '7d'
|
|
1541
1691
|
}
|
|
1542
1692
|
|
|
@@ -1565,6 +1715,27 @@ async function startServer(aipFile, port = 3000) {
|
|
|
1565
1715
|
// Events
|
|
1566
1716
|
for (const ev of app.events) on(ev.event, (data) => console.log(`[aiplang:event] ${ev.event}:`, ev.action))
|
|
1567
1717
|
|
|
1718
|
+
// Auto refresh token endpoint — POST /api/auth/refresh
|
|
1719
|
+
if (app.auth) {
|
|
1720
|
+
srv.addRoute('POST', '/api/auth/refresh', async (req, res) => {
|
|
1721
|
+
const refreshToken = req.body?.refresh_token ||
|
|
1722
|
+
(req.headers['authorization']?.startsWith('Bearer ') ? req.headers['authorization'].slice(7) : null)
|
|
1723
|
+
if (!refreshToken) { res.error(401, 'refresh_token required'); return }
|
|
1724
|
+
const payload = verifyRefreshToken(refreshToken)
|
|
1725
|
+
if (!payload) { res.error(401, 'Invalid or expired refresh token'); return }
|
|
1726
|
+
// Find the user
|
|
1727
|
+
const userModel = srv.models['User'] || Object.values(srv.models)[0]
|
|
1728
|
+
if (!userModel) { res.error(500, 'No user model found'); return }
|
|
1729
|
+
const user = userModel.find(payload.id)
|
|
1730
|
+
if (!user) { res.error(401, 'User not found'); return }
|
|
1731
|
+
// Issue new tokens
|
|
1732
|
+
const newToken = generateJWT(user)
|
|
1733
|
+
const newRefresh = generateRefreshToken(user)
|
|
1734
|
+
res.json(200, { token: newToken, refresh_token: newRefresh, expires_in: JWT_EXPIRE })
|
|
1735
|
+
})
|
|
1736
|
+
console.log('[aiplang] Route: POST /api/auth/refresh (auto)')
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1568
1739
|
// Auth rate limiting (automatic — 20 req/min per IP on /api/auth/*)
|
|
1569
1740
|
const _authAttempts = {}
|
|
1570
1741
|
srv._authRateLimit = (req) => {
|
|
@@ -1618,7 +1789,7 @@ async function startServer(aipFile, port = 3000) {
|
|
|
1618
1789
|
|
|
1619
1790
|
// Health
|
|
1620
1791
|
srv.addRoute('GET', '/health', (req, res) => res.json(200, {
|
|
1621
|
-
status:'ok', version:'2.9.
|
|
1792
|
+
status:'ok', version:'2.9.3',
|
|
1622
1793
|
models: app.models.map(m=>m.name),
|
|
1623
1794
|
routes: app.apis.length, pages: app.pages.length,
|
|
1624
1795
|
admin: app.admin?.prefix || null,
|