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 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.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.9.2",
3
+ "version": "2.9.3",
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,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
- const row = { ...data }
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
- const row = { ...data }
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
- MODEL_DEFS[model.name] = { softDelete: model.softDelete, timestamps: true }
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 pts = line.slice(4).replace('{','').trim().split(/\s+/)
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 === '}') { if (curAPI) app.apis.push(curAPI); curAPI=null; inAPI=false; i++; continue }
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) { ctx.vars['inserted']=m.create({...ctx.body}); broadcast(modelName.toLowerCase(), {action:'created',data:ctx.vars['inserted']}); return ctx.vars['inserted'] }
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) { 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'] }
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.2',
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,