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 CHANGED
@@ -6,9 +6,16 @@
6
6
  [![npm](https://img.shields.io/npm/v/aiplang)](https://npmjs.com/package/aiplang)
7
7
 
8
8
  ```bash
9
+ # npm
9
10
  npx aiplang init my-app
10
- cd my-app
11
- npx aiplang serve
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.2'
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.9.2",
3
+ "version": "2.9.4",
4
4
  "description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
5
5
  "keywords": [
6
6
  "aiplang",
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 = '7d'
143
- const generateJWT = (user) => jwt.sign({ id: user.id, email: user.email, role: user.role || 'user' }, JWT_SECRET, { expiresIn: JWT_EXPIRE })
144
- const verifyJWT = (token) => { try { return jwt.verify(token, JWT_SECRET) } catch { return null } }
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
- const row = { ...data }
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
- const row = { ...data }
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
- MODEL_DEFS[model.name] = { softDelete: model.softDelete, timestamps: true }
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 pts = line.slice(4).replace('{','').trim().split(/\s+/)
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 === '}') { if (curAPI) app.apis.push(curAPI); curAPI=null; inAPI=false; i++; continue }
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
- 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(',');else if(x)f.modifiers.push(x)}
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) { ctx.vars['inserted']=m.create({...ctx.body}); broadcast(modelName.toLowerCase(), {action:'created',data:ctx.vars['inserted']}); return ctx.vars['inserted'] }
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) { const id=ctx.params.id||ctx.vars['id']; ctx.vars['updated']=m.update(id,{...ctx.body}); broadcast(modelName.toLowerCase(), {action:'updated',data:ctx.vars['updated']}); return ctx.vars['updated'] }
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.2',
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,