aiplang 2.0.1 → 2.1.1

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.
Files changed (58) hide show
  1. package/README.md +86 -151
  2. package/aiplang-knowledge.md +172 -129
  3. package/bin/aiplang.js +4 -3
  4. package/package.json +3 -2
  5. package/runtime/aiplang-hydrate.js +1 -1
  6. package/server/node_modules/.package-lock.json +9 -0
  7. package/server/node_modules/nodemailer/.gitattributes +6 -0
  8. package/server/node_modules/nodemailer/.ncurc.js +9 -0
  9. package/server/node_modules/nodemailer/.prettierignore +8 -0
  10. package/server/node_modules/nodemailer/.prettierrc +12 -0
  11. package/server/node_modules/nodemailer/.prettierrc.js +10 -0
  12. package/server/node_modules/nodemailer/.release-please-config.json +9 -0
  13. package/server/node_modules/nodemailer/CHANGELOG.md +976 -0
  14. package/server/node_modules/nodemailer/CODE_OF_CONDUCT.md +76 -0
  15. package/server/node_modules/nodemailer/LICENSE +16 -0
  16. package/server/node_modules/nodemailer/README.md +86 -0
  17. package/server/node_modules/nodemailer/SECURITY.txt +22 -0
  18. package/server/node_modules/nodemailer/eslint.config.js +88 -0
  19. package/server/node_modules/nodemailer/lib/addressparser/index.js +382 -0
  20. package/server/node_modules/nodemailer/lib/base64/index.js +140 -0
  21. package/server/node_modules/nodemailer/lib/dkim/index.js +245 -0
  22. package/server/node_modules/nodemailer/lib/dkim/message-parser.js +154 -0
  23. package/server/node_modules/nodemailer/lib/dkim/relaxed-body.js +154 -0
  24. package/server/node_modules/nodemailer/lib/dkim/sign.js +116 -0
  25. package/server/node_modules/nodemailer/lib/errors.js +58 -0
  26. package/server/node_modules/nodemailer/lib/fetch/cookies.js +276 -0
  27. package/server/node_modules/nodemailer/lib/fetch/index.js +278 -0
  28. package/server/node_modules/nodemailer/lib/json-transport/index.js +82 -0
  29. package/server/node_modules/nodemailer/lib/mail-composer/index.js +599 -0
  30. package/server/node_modules/nodemailer/lib/mailer/index.js +446 -0
  31. package/server/node_modules/nodemailer/lib/mailer/mail-message.js +312 -0
  32. package/server/node_modules/nodemailer/lib/mime-funcs/index.js +610 -0
  33. package/server/node_modules/nodemailer/lib/mime-funcs/mime-types.js +2109 -0
  34. package/server/node_modules/nodemailer/lib/mime-node/index.js +1334 -0
  35. package/server/node_modules/nodemailer/lib/mime-node/last-newline.js +33 -0
  36. package/server/node_modules/nodemailer/lib/mime-node/le-unix.js +40 -0
  37. package/server/node_modules/nodemailer/lib/mime-node/le-windows.js +49 -0
  38. package/server/node_modules/nodemailer/lib/nodemailer.js +151 -0
  39. package/server/node_modules/nodemailer/lib/punycode/index.js +460 -0
  40. package/server/node_modules/nodemailer/lib/qp/index.js +230 -0
  41. package/server/node_modules/nodemailer/lib/sendmail-transport/index.js +205 -0
  42. package/server/node_modules/nodemailer/lib/ses-transport/index.js +223 -0
  43. package/server/node_modules/nodemailer/lib/shared/index.js +698 -0
  44. package/server/node_modules/nodemailer/lib/smtp-connection/data-stream.js +105 -0
  45. package/server/node_modules/nodemailer/lib/smtp-connection/http-proxy-client.js +144 -0
  46. package/server/node_modules/nodemailer/lib/smtp-connection/index.js +1903 -0
  47. package/server/node_modules/nodemailer/lib/smtp-pool/index.js +641 -0
  48. package/server/node_modules/nodemailer/lib/smtp-pool/pool-resource.js +256 -0
  49. package/server/node_modules/nodemailer/lib/smtp-transport/index.js +402 -0
  50. package/server/node_modules/nodemailer/lib/stream-transport/index.js +135 -0
  51. package/server/node_modules/nodemailer/lib/well-known/index.js +47 -0
  52. package/server/node_modules/nodemailer/lib/well-known/services.json +619 -0
  53. package/server/node_modules/nodemailer/lib/xoauth2/index.js +436 -0
  54. package/server/node_modules/nodemailer/package.json +48 -0
  55. package/server/server.js +1008 -856
  56. package/bin/flux.js +0 -572
  57. package/runtime/flux-hydrate.js +0 -473
  58. package/runtime/flux-runtime.js +0 -1100
package/server/server.js CHANGED
@@ -1,1082 +1,1234 @@
1
1
  'use strict'
2
- // aiplang Full-Stack Server
3
- // Competes with Laravel: ORM, relationships, migrations, auth, middleware, validation, queues, email
4
-
5
- const http = require('http')
6
- const fs = require('fs')
7
- const path = require('path')
8
- const url = require('url')
9
- const crypto = require('crypto')
10
- const bcrypt = require('bcryptjs')
11
- const jwt = require('jsonwebtoken')
12
-
13
- // ── SQL.js DB (pure JS SQLite, no native deps) ─────────────────
14
- let SQL, DB_FILE
15
- let _db = null
16
-
2
+ // aiplang Full-Stack Server v2 — Laravel-competitive
3
+ // Features: ORM+relations, email, jobs/queues, admin panel, OAuth, soft deletes, events
4
+
5
+ const http = require('http')
6
+ const fs = require('fs')
7
+ const path = require('path')
8
+ const url = require('url')
9
+ const crypto = require('crypto')
10
+ const bcrypt = require('bcryptjs')
11
+ const jwt = require('jsonwebtoken')
12
+ const nodemailer = require('nodemailer').createTransport ? require('nodemailer') : null
13
+
14
+ // ── SQL.js (pure JS SQLite) ───────────────────────────────────────
15
+ let SQL, DB_FILE, _db = null
17
16
  async function getDB(dbFile = ':memory:') {
18
17
  if (_db) return _db
19
18
  const initSqlJs = require('sql.js')
20
19
  SQL = await initSqlJs()
21
20
  if (dbFile !== ':memory:' && fs.existsSync(dbFile)) {
22
- const fileBuffer = fs.readFileSync(dbFile)
23
- _db = new SQL.Database(fileBuffer)
21
+ _db = new SQL.Database(fs.readFileSync(dbFile))
24
22
  } else {
25
23
  _db = new SQL.Database()
26
24
  }
27
25
  DB_FILE = dbFile
28
26
  return _db
29
27
  }
30
-
31
28
  function persistDB() {
32
29
  if (!_db || !DB_FILE || DB_FILE === ':memory:') return
33
- const data = _db.export()
34
- fs.writeFileSync(DB_FILE, Buffer.from(data))
35
- }
36
-
37
- function dbRun(sql, params = []) {
38
- _db.run(sql, params)
39
- persistDB()
30
+ try { fs.writeFileSync(DB_FILE, Buffer.from(_db.export())) } catch {}
40
31
  }
41
-
32
+ function dbRun(sql, params = []) { _db.run(sql, params); persistDB() }
42
33
  function dbAll(sql, params = []) {
43
- const stmt = _db.prepare(sql)
44
- stmt.bind(params)
45
- const rows = []
46
- while (stmt.step()) {
47
- rows.push(stmt.getAsObject())
48
- }
49
- stmt.free()
34
+ const stmt = _db.prepare(sql); stmt.bind(params)
35
+ const rows = []; while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free()
50
36
  return rows
51
37
  }
52
-
53
- function dbGet(sql, params = []) {
54
- const rows = dbAll(sql, params)
55
- return rows[0] || null
38
+ function dbGet(sql, params = []) { return dbAll(sql, params)[0] || null }
39
+
40
+ // ── Helpers ───────────────────────────────────────────────────────
41
+ const uuid = () => crypto.randomUUID()
42
+ const now = () => new Date().toISOString()
43
+ const esc = s => s == null ? '' : String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
44
+ const ic = n => ({bolt:'⚡',rocket:'🚀',shield:'🛡',chart:'📊',star:'⭐',check:'✓',globe:'🌐',lock:'🔒',user:'👤',gear:'⚙',fire:'🔥',money:'💰',bell:'🔔',mail:'✉',heart:'❤',eye:'👁',tag:'🏷',search:'🔍',home:'🏠',plus:'+',edit:'✏',trash:'🗑',info:'ℹ'}[n] || n)
45
+
46
+ // ── JWT ───────────────────────────────────────────────────────────
47
+ let JWT_SECRET = process.env.JWT_SECRET || 'aiplang-secret-dev'
48
+ let JWT_EXPIRE = '7d'
49
+ const generateJWT = (user) => jwt.sign({ id: user.id, email: user.email, role: user.role || 'user' }, JWT_SECRET, { expiresIn: JWT_EXPIRE })
50
+ const verifyJWT = (token) => { try { return jwt.verify(token, JWT_SECRET) } catch { return null } }
51
+
52
+ // ── Queue system ──────────────────────────────────────────────────
53
+ const QUEUE = []
54
+ const WORKERS = {}
55
+ let QUEUE_RUNNING = false
56
+ function dispatch(jobName, payload) {
57
+ QUEUE.push({ job: jobName, payload, id: uuid(), created: now(), attempts: 0 })
58
+ if (!QUEUE_RUNNING) processQueue()
56
59
  }
57
-
58
- // ── UUID ────────────────────────────────────────────────────────
59
- const uuid = () => crypto.randomUUID()
60
-
61
- // ── JWT ─────────────────────────────────────────────────────────
62
- let JWT_SECRET = process.env.JWT_SECRET || 'aiplang-secret-change-in-production'
63
- let JWT_EXPIRE = '7d'
64
-
65
- function generateJWT(user) {
66
- const payload = { id: user.id, email: user.email, role: user.role || 'user' }
67
- return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRE })
68
- }
69
-
70
- function verifyJWT(token) {
71
- try { return jwt.verify(token, JWT_SECRET) }
72
- catch { return null }
73
- }
74
-
75
- // ═══════════════════════════════════════════════════════════════
76
- // PARSER — extends aiplang v2 syntax
77
- // ═══════════════════════════════════════════════════════════════
78
-
79
- function parseApp(src) {
80
- const app = {
81
- env: [], db: null, auth: null, cache: null,
82
- middleware: [], models: [], apis: [], pages: [],
83
- seeds: [], jobs: [], events: []
84
- }
85
-
86
- const lines = src.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))
87
- let i = 0
88
- let inModel = false, inAPI = false, currentModel = null, currentAPI = null
89
- let pageLines = [], inPage = false
90
-
91
- while (i < lines.length) {
92
- const line = lines[i]
93
-
94
- // Page separator
95
- if (line === '---') {
96
- if (inPage && pageLines.length) app.pages.push(parsePage(pageLines.join('\n')))
97
- pageLines = []; inPage = false; inModel = false; inAPI = false
98
- currentModel = null; currentAPI = null
99
- i++; continue
100
- }
101
-
102
- // Page start
103
- if (line.startsWith('%')) {
104
- inPage = true; inModel = false; inAPI = false
105
- currentModel = null; currentAPI = null
106
- pageLines.push(line); i++; continue
107
- }
108
- if (inPage) { pageLines.push(line); i++; continue }
109
-
110
- // Config directives
111
- if (line.startsWith('~env ')) { app.env.push(parseEnvLine(line.slice(5))); i++; continue }
112
- if (line.startsWith('~db ')) { app.db = parseDBLine(line.slice(4)); i++; continue }
113
- if (line.startsWith('~auth ')) { app.auth = parseAuthLine(line.slice(6)); i++; continue }
114
- if (line.startsWith('~middleware ')) { app.middleware = line.slice(12).split('|').map(s=>s.trim()); i++; continue }
115
- if (line.startsWith('~cache ')) { app.cache = parseCacheLine(line.slice(7)); i++; continue }
116
-
117
- // Model block
118
- if (line.startsWith('model ')) {
119
- if (inModel && currentModel) app.models.push(currentModel)
120
- const mName = line.slice(6).replace('{','').trim()
121
- currentModel = { name: mName, fields: [], relationships: [], hooks: [] }
122
- inModel = true; inAPI = false; i++; continue
123
- }
124
- if (inModel && line === '}') { if (currentModel) app.models.push(currentModel); currentModel = null; inModel = false; i++; continue }
125
- if (inModel && currentModel) {
126
- if (line.startsWith('~has-many ')) currentModel.relationships.push({ type: 'hasMany', model: line.slice(10).trim() })
127
- else if (line.startsWith('~belongs ')) currentModel.relationships.push({ type: 'belongsTo', model: line.slice(9).trim() })
128
- else if (line.startsWith('~hook ')) currentModel.hooks.push(line.slice(6).trim())
129
- else if (line && line !== '{') currentModel.fields.push(parseModelField(line))
130
- i++; continue
131
- }
132
-
133
- // API block
134
- if (line.startsWith('api ')) {
135
- if (inAPI && currentAPI) app.apis.push(currentAPI)
136
- const parts = line.slice(4).replace('{','').trim().split(/\s+/)
137
- currentAPI = { method: parts[0], path: parts[1], guards: [], validate: [], query: [], body: [], return: null }
138
- inAPI = true; i++; continue
60
+ async function processQueue() {
61
+ QUEUE_RUNNING = true
62
+ while (QUEUE.length > 0) {
63
+ const item = QUEUE.shift()
64
+ const worker = WORKERS[item.job]
65
+ if (worker) {
66
+ try { await worker(item.payload) }
67
+ catch (e) {
68
+ item.attempts++
69
+ if (item.attempts < 3) QUEUE.push(item)
70
+ else console.error(`[aiplang:queue] Job ${item.job} failed after 3 attempts:`, e.message)
71
+ }
139
72
  }
140
- if (inAPI && line === '}') { if (currentAPI) app.apis.push(currentAPI); currentAPI = null; inAPI = false; i++; continue }
141
- if (inAPI && currentAPI) { parseAPILine(line, currentAPI); i++; continue }
142
-
143
- i++
144
73
  }
145
-
146
- if (inPage && pageLines.length) app.pages.push(parsePage(pageLines.join('\n')))
147
- if (inModel && currentModel) app.models.push(currentModel)
148
- if (inAPI && currentAPI) app.apis.push(currentAPI)
149
-
150
- return app
74
+ QUEUE_RUNNING = false
151
75
  }
152
76
 
153
- function parseEnvLine(s) {
154
- const parts = s.split(/\s+/)
155
- const ev = { name: '', required: false, default: null }
156
- for (const p of parts) {
157
- if (p === 'required') ev.required = true
158
- else if (p.includes('=')) { const [k,v] = p.split('='); ev.name = k; ev.default = v }
159
- else ev.name = p
160
- }
161
- return ev
162
- }
163
-
164
- function parseDBLine(s) {
165
- const parts = s.split(/\s+/)
166
- return { driver: parts[0] || 'sqlite', dsn: parts[1] || './app.db' }
167
- }
168
-
169
- function parseAuthLine(s) {
170
- const parts = s.split(/\s+/)
171
- const auth = { provider: parts[0] || 'jwt', secret: parts[1] || '$JWT_SECRET', expire: '7d' }
172
- for (const p of parts) { if (p.startsWith('expire=')) auth.expire = p.slice(7) }
173
- return auth
174
- }
175
-
176
- function parseCacheLine(s) {
177
- const parts = s.split(/\s+/)
178
- return { driver: parts[0] || 'memory', url: parts[1] || '', ttl: 300 }
179
- }
180
-
181
- function parseModelField(line) {
182
- const parts = line.split(':').map(s => s.trim())
183
- const f = { name: parts[0], type: parts[1] || 'text', modifiers: [], enumVals: [], default: null, ref: null }
184
- for (let j = 2; j < parts.length; j++) {
185
- const p = parts[j]
186
- if (p.startsWith('default=')) f.default = p.slice(8)
187
- else if (p.startsWith('ref ')) f.ref = p.slice(4)
188
- else if (p.startsWith('enum:')) f.enumVals = p.slice(5).split(',')
189
- else if (p !== '') f.modifiers.push(p)
190
- }
191
- return f
192
- }
193
-
194
- function parseAPILine(line, route) {
195
- if (line.startsWith('~guard ')) route.guards = line.slice(7).split('|').map(s=>s.trim())
196
- else if (line.startsWith('~validate ')) {
197
- line.slice(10).split('|').forEach(v => {
198
- const parts = v.trim().split(/\s+/)
199
- if (parts[0]) route.validate.push({ field: parts[0], rules: parts.slice(1) })
200
- })
201
- }
202
- else if (line.startsWith('~query ')) {
203
- line.slice(7).split('|').forEach(q => {
204
- q = q.trim(); const eq = q.indexOf('=')
205
- route.query.push(eq !== -1 ? { name: q.slice(0,eq), default: q.slice(eq+1) } : { name: q, default: null })
77
+ // ── Email ─────────────────────────────────────────────────────────
78
+ let MAIL_CONFIG = null
79
+ let MAIL_TRANSPORTER = null
80
+ function setupMail(config) {
81
+ MAIL_CONFIG = config
82
+ if (!nodemailer) return
83
+ try {
84
+ MAIL_TRANSPORTER = nodemailer.createTransport({
85
+ host: config.host || 'smtp.gmail.com',
86
+ port: parseInt(config.port || '587'),
87
+ secure: config.port === '465',
88
+ auth: { user: resolveEnv(config.user), pass: resolveEnv(config.pass) }
206
89
  })
207
- }
208
- else route.body.push(line)
90
+ } catch {}
209
91
  }
210
-
211
- function parsePage(src) {
212
- // Reuse existing frontend parser logic
213
- const lines = src.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))
214
- const p = { id: 'page', theme: 'dark', route: '/', themeVars: null, state: {}, queries: [], blocks: [] }
215
- for (const line of lines) {
216
- if (line.startsWith('%')) {
217
- const pts = line.slice(1).trim().split(/\s+/)
218
- p.id = pts[0]||'page'; p.route = pts[2]||'/'
219
- const rt = pts[1]||'dark'
220
- if (rt.includes('#')) { const c=rt.split(','); p.theme='custom'; p.customTheme={bg:c[0],text:c[1]||'#f1f5f9',accent:c[2]||'#2563eb'} }
221
- else p.theme = rt
222
- } else if (line.startsWith('~theme ')) {
223
- p.themeVars = p.themeVars || {}
224
- line.slice(7).trim().split(/\s+/).forEach(pair => { const eq=pair.indexOf('='); if(eq!==-1) p.themeVars[pair.slice(0,eq)]=pair.slice(eq+1) })
225
- } else if (line.startsWith('@') && line.includes('=')) {
226
- const eq = line.indexOf('='); p.state[line.slice(1,eq).trim()] = line.slice(eq+1).trim()
227
- } else if (line.startsWith('~')) {
228
- const pts = line.slice(1).trim().split(/\s+/)
229
- const ai = pts.indexOf('=>')
230
- if (pts[0]==='mount') p.queries.push({ trigger:'mount', method:pts[1], path:pts[2], target:ai===-1?pts[3]:null, action:ai!==-1?pts.slice(ai+1).join(' '):null })
231
- else if (pts[0]==='interval') p.queries.push({ trigger:'interval', interval:parseInt(pts[1]), method:pts[2], path:pts[3], target:ai===-1?pts[4]:null, action:ai!==-1?pts.slice(ai+1).join(' '):null })
232
- } else {
233
- p.blocks.push({ kind: blockKind(line), rawLine: line })
234
- }
92
+ async function sendMail(opts) {
93
+ if (!MAIL_TRANSPORTER) {
94
+ console.log(`[aiplang:mail] MOCK To: ${opts.to} | Subject: ${opts.subject}`)
95
+ return { messageId: 'mock-' + uuid() }
235
96
  }
236
- return p
97
+ return MAIL_TRANSPORTER.sendMail({
98
+ from: MAIL_CONFIG?.from || 'noreply@aiplang.app',
99
+ ...opts
100
+ })
237
101
  }
238
102
 
239
- function blockKind(line) {
240
- const bi = line.indexOf('{'); if (bi === -1) return 'unknown'
241
- const head = line.slice(0, bi).trim()
242
- const m = head.match(/^([a-z]+)\d+$/)
243
- return m ? m[1] : head
103
+ // ── Events system (simple pub/sub) ───────────────────────────────
104
+ const EVENT_LISTENERS = {}
105
+ function emit(event, data) {
106
+ const listeners = EVENT_LISTENERS[event] || []
107
+ listeners.forEach(fn => { try { fn(data) } catch {} })
244
108
  }
245
-
246
- // ═══════════════════════════════════════════════════════════════
247
- // AUTO MIGRATION
248
- // ═══════════════════════════════════════════════════════════════
249
-
250
- function toTableName(model) {
251
- return model.toLowerCase().replace(/([A-Z])/g, '_$1').replace(/^_/, '') + 's'
109
+ function on(event, fn) {
110
+ EVENT_LISTENERS[event] = EVENT_LISTENERS[event] || []
111
+ EVENT_LISTENERS[event].push(fn)
252
112
  }
253
113
 
254
- function toColumnName(field) {
255
- return field.replace(/([A-Z])/g, '_$1').toLowerCase()
256
- }
257
-
258
- function migrateModels(models) {
259
- for (const model of models) {
260
- const table = toTableName(model.name)
261
- let cols = []
262
-
263
- for (const f of model.fields) {
264
- const col = toColumnName(f.name)
265
- let sqlType = 'TEXT'
266
- switch (f.type) {
267
- case 'uuid': sqlType = 'TEXT'; break
268
- case 'int': sqlType = 'INTEGER'; break
269
- case 'float': sqlType = 'REAL'; break
270
- case 'bool': sqlType = 'INTEGER'; break
271
- case 'timestamp': sqlType = 'TEXT'; break
272
- case 'json': sqlType = 'TEXT'; break
273
- case 'enum': sqlType = 'TEXT'; break
274
- default: sqlType = 'TEXT'
275
- }
276
-
277
- let def = `${col} ${sqlType}`
278
- if (f.modifiers.includes('pk')) def += ' PRIMARY KEY'
279
- if (f.modifiers.includes('required')) def += ' NOT NULL'
280
- if (f.modifiers.includes('unique')) def += ' UNIQUE'
281
- if (f.default !== null) def += ` DEFAULT '${f.default}'`
282
-
283
- cols.push(def)
284
- }
285
-
286
- // Add relationship foreign keys
287
- for (const rel of model.relationships || []) {
288
- if (rel.type === 'belongsTo') {
289
- const fkCol = rel.model.toLowerCase() + '_id'
290
- cols.push(`${fkCol} TEXT`)
291
- }
292
- }
293
-
294
- // Always include timestamp columns
295
- const hasCreatedAt = cols.some(c => c.startsWith('created_at'))
296
- const hasUpdatedAt = cols.some(c => c.startsWith('updated_at'))
297
- if (!hasCreatedAt) cols.push('created_at TEXT')
298
- if (!hasUpdatedAt) cols.push('updated_at TEXT')
299
-
300
- const sql = `CREATE TABLE IF NOT EXISTS ${table} (${cols.join(', ')})`
301
- try { dbRun(sql) } catch (e) { /* table might already exist - try ALTER */ }
302
-
303
- console.log(`[aiplang] ✓ ${table} (${cols.length} columns)`)
304
- }
305
- }
114
+ // ═══════════════════════════════════════════════════════════════════
115
+ // ORM — enhanced Model
116
+ // ═══════════════════════════════════════════════════════════════════
117
+ const MODEL_DEFS = {}
306
118
 
307
- // ═══════════════════════════════════════════════════════════════
308
- // ORM Model operations
309
- // ═══════════════════════════════════════════════════════════════
119
+ function toTable(name) { return name.toLowerCase().replace(/([A-Z])/g,'_$1').replace(/^_/,'') + 's' }
120
+ function toCol(field) { return field.replace(/([A-Z])/g,'_$1').toLowerCase() }
310
121
 
311
122
  class Model {
312
- constructor(name) {
313
- this.tableName = toTableName(name)
314
- this.modelName = name
123
+ constructor(name, def = null) {
124
+ this.modelName = name
125
+ this.tableName = toTable(name)
126
+ this.def = def || MODEL_DEFS[name] || {}
127
+ this.softDelete = this.def.softDelete || false
128
+ this.timestamps = this.def.timestamps !== false
315
129
  }
316
130
 
131
+ // ── Core queries ────────────────────────────────────────────────
317
132
  all(opts = {}) {
318
133
  let sql = `SELECT * FROM ${this.tableName}`
319
- const params = []
320
- if (opts.where) { sql += ` WHERE ${opts.where}`; if (opts.whereParams) params.push(...opts.whereParams) }
321
- if (opts.order) sql += ` ORDER BY ${opts.order}`
134
+ const params = [], conditions = []
135
+ if (this.softDelete) conditions.push('deleted_at IS NULL')
136
+ if (opts.where) { conditions.push(opts.where); if (opts.whereParams) params.push(...opts.whereParams) }
137
+ if (conditions.length) sql += ` WHERE ${conditions.join(' AND ')}`
138
+ if (opts.order) sql += ` ORDER BY ${opts.order}`
322
139
  if (opts.limit) sql += ` LIMIT ${opts.limit}`
323
140
  if (opts.offset) sql += ` OFFSET ${opts.offset}`
324
141
  return dbAll(sql, params)
325
142
  }
326
143
 
327
- find(id) { return dbGet(`SELECT * FROM ${this.tableName} WHERE id = ?`, [id]) }
144
+ find(id) {
145
+ let sql = `SELECT * FROM ${this.tableName} WHERE id = ?`
146
+ if (this.softDelete) sql += ' AND deleted_at IS NULL'
147
+ return dbGet(sql, [id])
148
+ }
328
149
 
329
- findBy(field, value) { return dbGet(`SELECT * FROM ${this.tableName} WHERE ${field} = ? LIMIT 1`, [value]) }
150
+ findBy(field, value) {
151
+ let sql = `SELECT * FROM ${this.tableName} WHERE ${field} = ? LIMIT 1`
152
+ if (this.softDelete) sql = `SELECT * FROM ${this.tableName} WHERE ${field} = ? AND deleted_at IS NULL LIMIT 1`
153
+ return dbGet(sql, [value])
154
+ }
330
155
 
331
156
  where(field, op, value) {
332
- return dbAll(`SELECT * FROM ${this.tableName} WHERE ${field} ${op} ?`, [value])
157
+ let sql = `SELECT * FROM ${this.tableName} WHERE ${field} ${op} ?`
158
+ if (this.softDelete) sql += ' AND deleted_at IS NULL'
159
+ return dbAll(sql, [value])
160
+ }
161
+
162
+ scope(name) {
163
+ const scopeDef = this.def.scopes?.[name]
164
+ if (!scopeDef) return this.all()
165
+ return this.all({ where: scopeDef.where, order: scopeDef.order })
333
166
  }
334
167
 
335
168
  paginate(page = 1, perPage = 15, opts = {}) {
336
169
  const offset = (page - 1) * perPage
337
- const total = dbGet(`SELECT COUNT(*) as count FROM ${this.tableName}`)?.count || 0
170
+ let countSql = `SELECT COUNT(*) as count FROM ${this.tableName}`
171
+ if (this.softDelete) countSql += ' WHERE deleted_at IS NULL'
172
+ const total = dbGet(countSql)?.count || 0
338
173
  const data = this.all({ ...opts, limit: perPage, offset })
339
- return {
340
- data,
341
- meta: { total, page, per_page: perPage, last_page: Math.ceil(total / perPage) }
342
- }
174
+ return { data, meta: { total, page, per_page: perPage, last_page: Math.ceil(total / perPage), from: offset + 1, to: Math.min(offset + perPage, total) } }
343
175
  }
344
176
 
345
177
  create(data) {
346
178
  const row = { ...data }
347
179
  if (!row.id) row.id = uuid()
348
- if (!row.created_at) row.created_at = new Date().toISOString()
349
- if (!row.updated_at) row.updated_at = new Date().toISOString()
350
-
351
- const keys = Object.keys(row)
352
- const vals = Object.values(row)
353
- const sql = `INSERT INTO ${this.tableName} (${keys.join(',')}) VALUES (${keys.map(()=>'?').join(',')})`
354
- dbRun(sql, vals)
180
+ if (this.timestamps) {
181
+ if (!row.created_at) row.created_at = now()
182
+ if (!row.updated_at) row.updated_at = now()
183
+ }
184
+ const keys = Object.keys(row), vals = Object.values(row)
185
+ dbRun(`INSERT INTO ${this.tableName} (${keys.join(',')}) VALUES (${keys.map(()=>'?').join(',')})`, vals)
186
+ emit(`${this.modelName}.created`, row)
355
187
  return row
356
188
  }
357
189
 
358
190
  update(id, data) {
359
- data.updated_at = new Date().toISOString()
360
- delete data.id; delete data.created_at; delete data.password
361
- const sets = Object.keys(data).map(k => `${k} = ?`).join(', ')
362
- dbRun(`UPDATE ${this.tableName} SET ${sets} WHERE id = ?`, [...Object.values(data), id])
363
- return this.find(id)
191
+ const row = { ...data }
192
+ delete row.id; delete row.created_at; delete row.password
193
+ if (this.timestamps) row.updated_at = now()
194
+ const sets = Object.keys(row).map(k => `${k} = ?`).join(', ')
195
+ dbRun(`UPDATE ${this.tableName} SET ${sets} WHERE id = ?`, [...Object.values(row), id])
196
+ const updated = this.find(id)
197
+ emit(`${this.modelName}.updated`, updated)
198
+ return updated
364
199
  }
365
200
 
366
201
  delete(id) {
367
- dbRun(`DELETE FROM ${this.tableName} WHERE id = ?`, [id])
202
+ if (this.softDelete) {
203
+ dbRun(`UPDATE ${this.tableName} SET deleted_at = ?, updated_at = ? WHERE id = ?`, [now(), now(), id])
204
+ emit(`${this.modelName}.softDeleted`, { id })
205
+ } else {
206
+ dbRun(`DELETE FROM ${this.tableName} WHERE id = ?`, [id])
207
+ emit(`${this.modelName}.deleted`, { id })
208
+ }
209
+ }
210
+
211
+ restore(id) {
212
+ if (this.softDelete) {
213
+ dbRun(`UPDATE ${this.tableName} SET deleted_at = NULL WHERE id = ?`, [id])
214
+ }
368
215
  }
369
216
 
370
217
  count(opts = {}) {
371
218
  let sql = `SELECT COUNT(*) as count FROM ${this.tableName}`
372
- if (opts.where) sql += ` WHERE ${opts.where}`
219
+ const conditions = []
220
+ if (this.softDelete) conditions.push('deleted_at IS NULL')
221
+ if (opts.where) conditions.push(opts.where)
222
+ if (conditions.length) sql += ` WHERE ${conditions.join(' AND ')}`
373
223
  return dbGet(sql)?.count || 0
374
224
  }
375
225
 
376
- // Relationships
377
- hasMany(relModel, foreignKey) {
378
- return (parentId) => {
379
- const m = new Model(relModel)
380
- const fk = foreignKey || this.modelName.toLowerCase() + '_id'
381
- return dbAll(`SELECT * FROM ${m.tableName} WHERE ${fk} = ?`, [parentId])
382
- }
226
+ sum(field, opts = {}) {
227
+ let sql = `SELECT SUM(${field}) as total FROM ${this.tableName}`
228
+ if (this.softDelete) sql += ' WHERE deleted_at IS NULL'
229
+ return dbGet(sql)?.total || 0
383
230
  }
384
231
 
385
- belongsTo(relModel, foreignKey) {
386
- return (childRow) => {
387
- const m = new Model(relModel)
388
- const fk = foreignKey || relModel.toLowerCase() + '_id'
389
- return m.find(childRow[fk])
390
- }
232
+ avg(field) {
233
+ let sql = `SELECT AVG(${field}) as avg FROM ${this.tableName}`
234
+ if (this.softDelete) sql += ' WHERE deleted_at IS NULL'
235
+ return parseFloat(dbGet(sql)?.avg || 0).toFixed(2)
391
236
  }
392
- }
393
237
 
394
- // ═══════════════════════════════════════════════════════════════
395
- // HTTP SERVER — Express-like but stdlib
396
- // ═══════════════════════════════════════════════════════════════
397
-
398
- class AiplangServer {
399
- constructor() {
400
- this.routes = []
401
- this.globalMiddleware = []
402
- this.models = {}
403
- this.app = null
238
+ // ── Relationships ───────────────────────────────────────────────
239
+ hasMany(relModel, fk) {
240
+ const m = new Model(relModel)
241
+ return (parentId) => dbAll(`SELECT * FROM ${m.tableName} WHERE ${fk || this.modelName.toLowerCase() + '_id'} = ?`, [parentId])
404
242
  }
405
-
406
- use(fn) { this.globalMiddleware.push(fn) }
407
-
408
- addRoute(method, routePath, handler) {
409
- this.routes.push({ method: method.toUpperCase(), path: routePath, handler, params: parseRouteParams(routePath) })
243
+ belongsTo(relModel, fk) {
244
+ const m = new Model(relModel)
245
+ return (row) => m.find(row[fk || relModel.toLowerCase() + '_id'])
410
246
  }
411
-
412
- registerModel(name) {
413
- this.models[name] = new Model(name)
414
- return this.models[name]
247
+ hasOne(relModel, fk) {
248
+ const m = new Model(relModel)
249
+ return (parentId) => dbGet(`SELECT * FROM ${m.tableName} WHERE ${fk || this.modelName.toLowerCase() + '_id'} = ? LIMIT 1`, [parentId])
415
250
  }
416
251
 
417
- async handleRequest(req, res) {
418
- // Parse body
419
- if (req.method !== 'GET' && req.method !== 'DELETE') {
420
- req.body = await parseBody(req)
421
- } else {
422
- req.body = {}
423
- }
424
-
425
- // Parse query string
426
- const parsed = url.parse(req.url, true)
427
- req.query = parsed.query
428
- req.path = parsed.pathname
252
+ // ── Observers / hooks ───────────────────────────────────────────
253
+ observe(event, fn) { on(`${this.modelName}.${event}`, fn) }
254
+ }
429
255
 
430
- // Parse auth token
431
- const token = extractToken(req)
432
- req.user = token ? verifyJWT(token) : null
256
+ // ═══════════════════════════════════════════════════════════════════
257
+ // MIGRATION
258
+ // ═══════════════════════════════════════════════════════════════════
259
+ function migrateModels(models) {
260
+ for (const model of models) {
261
+ const table = toTable(model.name)
262
+ const cols = []
263
+ for (const f of model.fields) {
264
+ let sqlType = { uuid:'TEXT',int:'INTEGER',float:'REAL',bool:'INTEGER',timestamp:'TEXT',json:'TEXT',enum:'TEXT',text:'TEXT' }[f.type] || 'TEXT'
265
+ let def = `${toCol(f.name)} ${sqlType}`
266
+ if (f.modifiers.includes('pk')) def += ' PRIMARY KEY'
267
+ if (f.modifiers.includes('required')) def += ' NOT NULL'
268
+ if (f.modifiers.includes('unique')) def += ' UNIQUE'
269
+ if (f.default !== null) def += ` DEFAULT '${f.default}'`
270
+ cols.push(def)
271
+ }
272
+ for (const rel of model.relationships || []) {
273
+ if (rel.type === 'belongsTo') cols.push(`${rel.model.toLowerCase()}_id TEXT`)
274
+ }
275
+ if (!cols.some(c=>c.startsWith('created_at'))) cols.push('created_at TEXT')
276
+ if (!cols.some(c=>c.startsWith('updated_at'))) cols.push('updated_at TEXT')
277
+ if (model.softDelete) { if (!cols.some(c=>c.startsWith('deleted_at'))) cols.push('deleted_at TEXT') }
278
+ try { dbRun(`CREATE TABLE IF NOT EXISTS ${table} (${cols.join(', ')})`) } catch {}
279
+ console.log(`[aiplang] ✓ ${table} (${cols.length} cols${model.softDelete ? ', soft-delete' : ''})`)
280
+ MODEL_DEFS[model.name] = { softDelete: model.softDelete, timestamps: true }
281
+ }
282
+ }
433
283
 
434
- // CORS
435
- res.setHeader('Access-Control-Allow-Origin', '*')
436
- res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS')
437
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
438
- if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return }
284
+ // ═══════════════════════════════════════════════════════════════════
285
+ // PARSER
286
+ // ═══════════════════════════════════════════════════════════════════
287
+ function parseApp(src) {
288
+ const app = { env:[], db:null, auth:null, mail:null, stripe:null, middleware:[], models:[], apis:[], pages:[], jobs:[], events:[], admin:null }
289
+ const lines = src.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('#'))
290
+ let i=0, inModel=false, inAPI=false, curModel=null, curAPI=null, pageLines=[], inPage=false
439
291
 
440
- // Match route
441
- for (const route of this.routes) {
442
- if (route.method !== req.method) continue
443
- const match = matchRoute(route.path, req.path)
444
- if (!match) continue
445
- req.params = match
292
+ while (i < lines.length) {
293
+ const line = lines[i]
294
+ if (line === '---') {
295
+ if (inPage && pageLines.length) app.pages.push(parseFrontPage(pageLines.join('\n')))
296
+ pageLines=[]; inPage=false; inModel=false; inAPI=false; curModel=null; curAPI=null; i++; continue
297
+ }
298
+ if (line.startsWith('%')) { inPage=true; inModel=false; inAPI=false; curModel=null; curAPI=null; pageLines.push(line); i++; continue }
299
+ if (inPage) { pageLines.push(line); i++; continue }
446
300
 
447
- // Helper methods
448
- res.json = (status, data) => {
449
- if (typeof status === 'object') { data = status; status = 200 }
450
- res.writeHead(status, { 'Content-Type': 'application/json' })
451
- res.end(JSON.stringify(data))
452
- }
453
- res.error = (status, msg) => res.json(status, { error: msg })
454
- res.noContent = () => { res.writeHead(204); res.end() }
301
+ if (line.startsWith('~env ')) { app.env.push(parseEnvLine(line.slice(5))); i++; continue }
302
+ if (line.startsWith('~db ')) { app.db = parseDBLine(line.slice(4)); i++; continue }
303
+ if (line.startsWith('~auth ')) { app.auth = parseAuthLine(line.slice(6)); i++; continue }
304
+ if (line.startsWith('~mail ')) { app.mail = parseMailLine(line.slice(6)); i++; continue }
305
+ if (line.startsWith('~middleware ')) { app.middleware = line.slice(12).split('|').map(s=>s.trim()); i++; continue }
306
+ if (line.startsWith('~admin')) { app.admin = parseAdminLine(line); i++; continue }
307
+ if (line.startsWith('~stripe ')) { app.stripe = parseStripeLine(line.slice(8)); i++; continue }
308
+ if (line.startsWith('~plan ')) { app.stripe = app.stripe || {}; app.stripe.plans = app.stripe.plans || {}; parsePlanLine(line.slice(6), app.stripe.plans); i++; continue }
309
+ if (line.startsWith('~job ')) { app.jobs.push(parseJobLine(line.slice(5))); i++; continue }
310
+ if (line.startsWith('~on ')) { app.events.push(parseEventLine(line.slice(4))); i++; continue }
455
311
 
456
- try { await route.handler(req, res) }
457
- catch (e) {
458
- console.error('[aiplang] Route error:', e.message)
459
- res.json(500, { error: 'Internal server error' })
460
- }
461
- return
312
+ if (line.startsWith('model ')) {
313
+ if (inModel && curModel) app.models.push(curModel)
314
+ curModel = { name: line.slice(6).replace('{','').trim(), fields:[], relationships:[], hooks:[], softDelete:false }
315
+ inModel=true; inAPI=false; i++; continue
316
+ }
317
+ if (inModel && line === '}') { if (curModel) app.models.push(curModel); curModel=null; inModel=false; i++; continue }
318
+ if (inModel && curModel) {
319
+ if (line.startsWith('~has-many ')) curModel.relationships.push({ type:'hasMany', model:line.slice(10).trim() })
320
+ else if (line.startsWith('~has-one '))curModel.relationships.push({ type:'hasOne', model:line.slice(9).trim() })
321
+ else if (line.startsWith('~belongs '))curModel.relationships.push({ type:'belongsTo', model:line.slice(9).trim() })
322
+ else if (line.startsWith('~hook ')) curModel.hooks.push(line.slice(6).trim())
323
+ else if (line === '~soft-delete') curModel.softDelete = true
324
+ else if (line && line !== '{') curModel.fields.push(parseField(line))
325
+ i++; continue
462
326
  }
463
327
 
464
- res.writeHead(404, { 'Content-Type': 'application/json' })
465
- res.end(JSON.stringify({ error: 'Not found' }))
328
+ if (line.startsWith('api ')) {
329
+ if (inAPI && curAPI) app.apis.push(curAPI)
330
+ const pts = line.slice(4).replace('{','').trim().split(/\s+/)
331
+ curAPI = { method:pts[0], path:pts[1], guards:[], validate:[], query:[], body:[], return:null }
332
+ inAPI=true; i++; continue
333
+ }
334
+ if (inAPI && line === '}') { if (curAPI) app.apis.push(curAPI); curAPI=null; inAPI=false; i++; continue }
335
+ if (inAPI && curAPI) { parseAPILine(line, curAPI); i++; continue }
336
+ i++
466
337
  }
338
+ if (inPage && pageLines.length) app.pages.push(parseFrontPage(pageLines.join('\n')))
339
+ if (inModel && curModel) app.models.push(curModel)
340
+ if (inAPI && curAPI) app.apis.push(curAPI)
341
+ return app
342
+ }
467
343
 
468
- listen(port) {
469
- const server = http.createServer((req, res) => this.handleRequest(req, res))
470
- server.listen(port, () => console.log(`[aiplang] Server running http://localhost:${port}`))
471
- return server
344
+ 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 }
345
+ function parseDBLine(s) { const p=s.split(/\s+/); return{driver:p[0]||'sqlite',dsn:p[1]||'./app.db'} }
346
+ 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 }
347
+ 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 }
348
+ function parseStripeLine(s) {
349
+ const parts = s.split(/\s+/)
350
+ const cfg = { key: parts[0] || '$STRIPE_SECRET_KEY', plans: {}, mode: 'subscription' }
351
+ for (const p of parts.slice(1)) {
352
+ if (p.startsWith('webhook=')) cfg.webhookSecret = p.slice(8)
353
+ if (p.startsWith('success=')) cfg.successUrl = p.slice(8)
354
+ if (p.startsWith('cancel=')) cfg.cancelUrl = p.slice(7)
355
+ if (p === 'payment') cfg.mode = 'payment'
472
356
  }
357
+ return cfg
358
+ }
359
+ function parsePlanLine(s, plans) {
360
+ // ~plan starter=price_xxx pro=price_yyy enterprise=price_zzz
361
+ s.split(/\s+/).forEach(pair => {
362
+ const eq = pair.indexOf('='); if (eq !== -1) plans[pair.slice(0,eq)] = pair.slice(eq+1)
363
+ })
364
+ }
365
+ function parseAdminLine(s) { const m=s.match(/~admin\s+(\S+)/); return{prefix:m?.[1]||'/admin',guard:'admin'} }
366
+ function parseJobLine(s) { const[name,...rest]=s.split(/\s+/); return{name,action:rest.join(' ')} }
367
+ function parseEventLine(s) { const m=s.match(/^(\S+)\s*=>\s*(.+)$/); return{event:m?.[1],action:m?.[2]} }
368
+ function parseField(line) {
369
+ const p=line.split(':').map(s=>s.trim())
370
+ const f={name:p[0],type:p[1]||'text',modifiers:[],enumVals:[],default:null}
371
+ 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)}
372
+ return f
473
373
  }
374
+ function parseAPILine(line, route) {
375
+ if(line.startsWith('~guard ')) route.guards=line.slice(7).split('|').map(s=>s.trim())
376
+ else if(line.startsWith('~validate ')) line.slice(10).split('|').forEach(v=>{const p=v.trim().split(/\s+/);if(p[0])route.validate.push({field:p[0],rules:p.slice(1)})})
377
+ else if(line.startsWith('~query ')) line.slice(7).split('|').forEach(q=>{q=q.trim();const eq=q.indexOf('=');route.query.push(eq!==-1?{name:q.slice(0,eq),default:q.slice(eq+1)}:{name:q,default:null})})
378
+ else route.body.push(line)
379
+ }
380
+ function parseFrontPage(src) {
381
+ const lines=src.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('#'))
382
+ const p={id:'page',theme:'dark',route:'/',themeVars:null,state:{},queries:[],blocks:[]}
383
+ for(const line of lines){
384
+ if(line.startsWith('%')){const pts=line.slice(1).trim().split(/\s+/);p.id=pts[0]||'page';p.route=pts[2]||'/';const rt=pts[1]||'dark';if(rt.includes('#')){const c=rt.split(',');p.theme='custom';p.customTheme={bg:c[0],text:c[1]||'#f1f5f9',accent:c[2]||'#2563eb'}}else p.theme=rt}
385
+ else if(line.startsWith('~theme ')){p.themeVars=p.themeVars||{};line.slice(7).trim().split(/\s+/).forEach(pair=>{const eq=pair.indexOf('=');if(eq!==-1)p.themeVars[pair.slice(0,eq)]=pair.slice(eq+1)})}
386
+ else if(line.startsWith('@')&&line.includes('=')){const eq=line.indexOf('=');p.state[line.slice(1,eq).trim()]=line.slice(eq+1).trim()}
387
+ else if(line.startsWith('~')){const pts=line.slice(1).trim().split(/\s+/);const ai=pts.indexOf('=>');if(pts[0]==='mount')p.queries.push({trigger:'mount',method:pts[1],path:pts[2],target:ai===-1?pts[3]:null,action:ai!==-1?pts.slice(ai+1).join(' '):null});else if(pts[0]==='interval')p.queries.push({trigger:'interval',interval:parseInt(pts[1]),method:pts[2],path:pts[3],target:ai===-1?pts[4]:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})}
388
+ else p.blocks.push({kind:blockKind(line),rawLine:line})
389
+ }
390
+ return p
391
+ }
392
+ function blockKind(line){const bi=line.indexOf('{');if(bi===-1)return'unknown';const h=line.slice(0,bi).trim();const m=h.match(/^([a-z]+)\d+$/);return m?m[1]:h}
474
393
 
475
- // ═══════════════════════════════════════════════════════════════
476
- // API ROUTE COMPILER
477
- // ═══════════════════════════════════════════════════════════════
478
-
479
- function compileAPIRoute(route, app, server) {
480
- const handler = async (req, res) => {
481
- const ctx = {
482
- req, res, params: req.params,
483
- body: req.body, query: req.query,
484
- user: req.user,
485
- vars: {},
486
- models: server.models,
487
- }
394
+ // ═══════════════════════════════════════════════════════════════════
395
+ // ROUTE COMPILER
396
+ // ═══════════════════════════════════════════════════════════════════
397
+ function compileRoute(route, server) {
398
+ server.addRoute(route.method, route.path, async (req, res) => {
399
+ const ctx = { req, res, params:req.params, body:req.body, query:req.query, user:req.user, vars:{}, models:server.models }
488
400
 
489
401
  // Guards
490
402
  for (const guard of route.guards) {
491
- if (guard === 'auth') {
492
- if (!req.user) { res.error(401, 'Unauthorized'); return }
493
- ctx.authUser = req.user
494
- }
495
- if (guard === 'admin') {
496
- if (!req.user || req.user.role !== 'admin') { res.error(403, 'Forbidden'); return }
403
+ if (guard === 'auth' && !req.user) { res.error(401, 'Unauthorized'); return }
404
+ if (guard === 'admin' && req.user?.role !== 'admin') { res.error(403, 'Forbidden'); return }
405
+ if (guard === 'subscribed') {
406
+ const activeStatuses = ['active', 'trialing']
407
+ if (!req.user || (!activeStatuses.includes(req.user.subscription_status) && req.user.plan === 'free')) {
408
+ res.error(402, 'Active subscription required')
409
+ return
410
+ }
497
411
  }
498
412
  if (guard === 'owner') {
499
- // Check if record belongs to user — simple implementation
500
413
  if (!req.user) { res.error(401, 'Unauthorized'); return }
414
+ // owner check happens in ops
501
415
  }
502
416
  }
503
417
 
504
418
  // Query params
505
- for (const qp of route.query) {
506
- ctx.vars[qp.name] = req.query[qp.name] || qp.default
507
- }
419
+ for (const qp of route.query) ctx.vars[qp.name] = req.query[qp.name] ?? qp.default
508
420
 
509
421
  // Validation
510
422
  for (const v of route.validate) {
511
423
  const val = ctx.body[v.field]
512
424
  for (const rule of v.rules) {
513
- if (rule === 'required' && (!val || val === '')) {
514
- res.error(422, `${v.field} is required`); return
515
- }
516
- if (rule === 'email' && val && !val.includes('@')) {
517
- res.error(422, `${v.field} must be a valid email`); return
518
- }
519
- if (rule.startsWith('min=')) {
520
- const min = parseInt(rule.slice(4))
521
- if (!val || String(val).length < min) { res.error(422, `${v.field} must be at least ${min} characters`); return }
522
- }
523
- if (rule.startsWith('max=')) {
524
- const max = parseInt(rule.slice(4))
525
- if (val && String(val).length > max) { res.error(422, `${v.field} must be at most ${max} characters`); return }
526
- }
527
- if (rule.startsWith('unique:')) {
528
- const modelName = rule.slice(7)
529
- const m = server.models[modelName]
530
- if (m) {
531
- const existing = m.findBy(v.field, val)
532
- if (existing) { res.error(409, `${v.field} already exists`); return }
533
- }
534
- }
535
- if (rule.startsWith('exists:')) {
536
- const modelName = rule.slice(7)
537
- const m = server.models[modelName]
538
- if (m && !m.find(val)) { res.error(422, `${v.field} does not exist`); return }
539
- }
540
- if (rule.startsWith('in:')) {
541
- const allowed = rule.slice(3).split(',')
542
- if (val && !allowed.includes(val)) { res.error(422, `${v.field} must be one of: ${allowed.join(', ')}`); return }
543
- }
425
+ if (rule === 'required' && (!val && val !== 0)) { res.error(422, `${v.field} is required`); return }
426
+ if (rule === 'email' && val && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) { res.error(422, `${v.field} must be a valid email`); return }
427
+ if (rule.startsWith('min=') && (!val || String(val).length < parseInt(rule.slice(4)))) { res.error(422, `${v.field} min length is ${rule.slice(4)}`); return }
428
+ if (rule.startsWith('max=') && val && String(val).length > parseInt(rule.slice(4))) { res.error(422, `${v.field} max length is ${rule.slice(4)}`); return }
429
+ if (rule === 'numeric' && val && isNaN(Number(val))) { res.error(422, `${v.field} must be numeric`); return }
430
+ if (rule.startsWith('in:') && val && !rule.slice(3).split(',').includes(val)) { res.error(422, `${v.field} must be one of: ${rule.slice(3)}`); return }
431
+ if (rule.startsWith('unique:')) { const m=server.models[rule.slice(7)]; if(m&&m.findBy(v.field,val)){ res.error(409,`${v.field} already exists`); return } }
432
+ if (rule.startsWith('exists:')) { const m=server.models[rule.slice(7)]; if(m&&!m.find(val)){ res.error(422,`${v.field} not found`); return } }
544
433
  }
545
434
  }
546
435
 
547
- // Execute body operations
436
+ // Execute ops
548
437
  for (const op of route.body) {
549
438
  const result = await execOp(op, ctx, server)
550
- if (result === '__RESPONDED__') return
551
- if (result !== null && result !== undefined) {
552
- ctx.lastResult = result
553
- }
439
+ if (result === '__DONE__') return
440
+ if (result !== null && result !== undefined) ctx.lastResult = result
554
441
  }
555
442
 
556
- // Default 200 if no explicit return
557
- if (!res.writableEnded) res.json(200, ctx.lastResult || {})
558
- }
559
-
560
- server.addRoute(route.method, route.path, handler)
443
+ if (!res.writableEnded) res.json(200, ctx.lastResult ?? {})
444
+ })
561
445
  }
562
446
 
563
447
  async function execOp(line, ctx, server) {
564
- line = line.trim()
565
- if (!line) return null
448
+ line = line.trim(); if (!line) return null
566
449
 
567
450
  // ~hash field
568
- if (line.startsWith('~hash ')) {
569
- const field = line.slice(6).trim()
570
- if (ctx.body[field]) ctx.body[field] = await bcrypt.hash(ctx.body[field], 12)
571
- return null
572
- }
451
+ if (line.startsWith('~hash ')) { const f=line.slice(6).trim(); if(ctx.body[f])ctx.body[f]=await bcrypt.hash(ctx.body[f],12); return null }
573
452
 
574
- // ~check password plain hashed | 401
453
+ // ~check password plain hashed | status
575
454
  if (line.startsWith('~check ')) {
576
- const parts = line.slice(7).trim().split(/\s+/)
577
- const plain = resolveVar(parts[1], ctx)
578
- const hashed = resolveVar(parts[2], ctx)
579
- const status = parseInt(parts[4]) || 401
580
- const ok = await bcrypt.compare(String(plain||''), String(hashed||''))
581
- if (!ok) { ctx.res.error(status, 'Invalid credentials'); return '__RESPONDED__' }
455
+ const p=line.slice(7).trim().split(/\s+/)
456
+ const plain=resolveVar(p[1],ctx), hashed=resolveVar(p[2],ctx), status=parseInt(p[4])||401
457
+ const ok=await bcrypt.compare(String(plain||''),String(hashed||''))
458
+ if (!ok) { ctx.res.error(status,'Invalid credentials'); return '__DONE__' }
582
459
  return null
583
460
  }
584
461
 
585
462
  // ~unique Model field value | status
586
463
  if (line.startsWith('~unique ')) {
587
- const parts = line.slice(8).trim().split(/\s+/)
588
- const modelName = parts[0], field = parts[1], value = resolveVar(parts[2], ctx)
589
- const status = parseInt(parts[4]) || 409
590
- const m = server.models[modelName]
591
- if (m) {
592
- const existing = m.findBy(field, value)
593
- if (existing) { ctx.res.error(status, `${field} already exists`); return '__RESPONDED__' }
594
- }
464
+ const p=line.slice(8).trim().split(/\s+/)
465
+ const m=server.models[p[0]]; if(m&&m.findBy(p[1],resolveVar(p[2],ctx))){ ctx.res.error(parseInt(p[4])||409,`${p[1]} already exists`); return '__DONE__' }
466
+ return null
467
+ }
468
+
469
+ // ~dispatch jobName payload
470
+ if (line.startsWith('~dispatch ')) {
471
+ const p=line.slice(10).trim().split(/\s+/)
472
+ dispatch(p[0], resolveVar(p.slice(1).join(' '), ctx))
473
+ return null
474
+ }
475
+
476
+ // ~mail to subject body
477
+ if (line.startsWith('~mail ')) {
478
+ const expr=line.slice(6).trim()
479
+ const m=expr.match(/^(\S+)\s+"([^"]+)"\s+"([^"]+)"/)
480
+ if (m) await sendMail({ to:resolveVar(m[1],ctx), subject:m[2], text:m[3] })
481
+ return null
482
+ }
483
+
484
+ // ~emit event data
485
+ if (line.startsWith('~emit ')) {
486
+ const p=line.slice(6).trim().split(/\s+/)
487
+ emit(p[0], resolveVar(p.slice(1).join(' '),ctx))
595
488
  return null
596
489
  }
597
490
 
598
491
  // $var = expr
599
492
  if (line.startsWith('$') && line.includes('=')) {
600
- const eq = line.indexOf('=')
601
- const varName = line.slice(1, eq).trim()
602
- const expr = line.slice(eq+1).trim()
603
- ctx.vars[varName] = evalExpr(expr, ctx, server)
493
+ const eq=line.indexOf('=')
494
+ const varName=line.slice(1,eq).trim()
495
+ ctx.vars[varName] = evalExpr(line.slice(eq+1).trim(), ctx, server)
604
496
  return null
605
497
  }
606
498
 
607
499
  // insert Model($body)
608
500
  if (line.startsWith('insert ')) {
609
- const modelName = line.match(/insert\s+(\w+)/)?.[1]
610
- const m = server.models[modelName]
611
- if (m) {
612
- const data = { ...ctx.body }
613
- ctx.vars['inserted'] = m.create(data)
614
- return ctx.vars['inserted']
615
- }
501
+ const modelName=line.match(/insert\s+(\w+)/)?.[1]; const m=server.models[modelName]
502
+ if (m) { ctx.vars['inserted']=m.create({...ctx.body}); return ctx.vars['inserted'] }
616
503
  return null
617
504
  }
618
505
 
619
506
  // update Model($id, $body)
620
507
  if (line.startsWith('update ')) {
621
- const modelName = line.match(/update\s+(\w+)/)?.[1]
622
- const m = server.models[modelName]
623
- if (m) {
624
- const id = ctx.params.id || ctx.vars['id']
625
- ctx.vars['updated'] = m.update(id, { ...ctx.body })
626
- return ctx.vars['updated']
627
- }
508
+ const modelName=line.match(/update\s+(\w+)/)?.[1]; const m=server.models[modelName]
509
+ if (m) { const id=ctx.params.id||ctx.vars['id']; ctx.vars['updated']=m.update(id,{...ctx.body}); return ctx.vars['updated'] }
628
510
  return null
629
511
  }
630
512
 
631
513
  // delete Model($id)
632
514
  if (line.startsWith('delete ')) {
633
- const modelName = line.match(/delete\s+(\w+)/)?.[1]
634
- const m = server.models[modelName]
635
- if (m) {
636
- const id = ctx.params.id || ctx.vars['id']
637
- m.delete(id)
638
- ctx.res.noContent(); return '__RESPONDED__'
639
- }
515
+ const modelName=line.match(/delete\s+(\w+)/)?.[1]; const m=server.models[modelName]
516
+ if (m) { m.delete(ctx.params.id||ctx.vars['id']); ctx.res.noContent(); return '__DONE__' }
517
+ return null
518
+ }
519
+
520
+ // restore Model($id) - soft delete restore
521
+ if (line.startsWith('restore ')) {
522
+ const modelName=line.match(/restore\s+(\w+)/)?.[1]; const m=server.models[modelName]
523
+ if (m) { m.restore(ctx.params.id); return m.find(ctx.params.id) }
640
524
  return null
641
525
  }
642
526
 
643
- // return expr statusCode
527
+ // return expr status
644
528
  if (line.startsWith('return ')) {
645
- const parts = line.slice(7).trim().split(/\s+/)
646
- const expr = parts[0]
647
- const status = parseInt(parts[1]) || 200
648
- let result = evalExpr(expr, ctx, server)
649
- if (result === null || result === undefined) result = ctx.vars['inserted'] || ctx.vars['updated'] || {}
650
- ctx.res.json(status, result)
651
- return '__RESPONDED__'
529
+ const p=line.slice(7).trim().split(/\s+/)
530
+ const status=parseInt(p[p.length-1])||200
531
+ const exprParts=isNaN(parseInt(p[p.length-1]))?p:p.slice(0,-1)
532
+ let result=evalExpr(exprParts.join(' '),ctx,server)
533
+ if(result===null||result===undefined)result=ctx.vars['inserted']||ctx.vars['updated']||{}
534
+ ctx.res.json(status,result); return '__DONE__'
652
535
  }
653
536
 
654
537
  return null
655
538
  }
656
539
 
657
540
  function evalExpr(expr, ctx, server) {
658
- expr = expr.trim()
659
-
660
- // jwt($var)
661
- if (expr.startsWith('jwt(')) {
662
- const varName = expr.match(/jwt\(\$([^)]+)\)/)?.[1]
663
- const user = varName ? ctx.vars[varName] : ctx.body
664
- return { token: generateJWT(user), user: sanitize(user) }
665
- }
666
-
667
- // Model.all(...)
668
- if (expr.includes('.all(')) {
669
- const modelName = expr.match(/^(\w+)\.all/)?.[1]
670
- const m = server.models[modelName]
671
- if (!m) return []
672
- const opts = {}
673
- const limitM = expr.match(/limit=(\$?[\w.]+)/)
674
- const offsetM = expr.match(/offset=([^,)]+)/)
675
- const orderM = expr.match(/order=([^,)]+)/)
676
- const whereM = expr.match(/where=([^,)]+)/)
677
- if (limitM) opts.limit = resolveVar(limitM[1], ctx)
678
- if (offsetM) opts.offset = evalMath(offsetM[1], ctx)
679
- if (orderM) opts.order = orderM[1]
680
- if (whereM) opts.where = whereM[1]
681
- return m.all(opts)
682
- }
683
-
684
- // Model.find($id)
685
- if (expr.includes('.find(')) {
686
- const modelName = expr.match(/^(\w+)\.find/)?.[1]
687
- const idExpr = expr.match(/\.find\(([^)]+)\)/)?.[1]
688
- const m = server.models[modelName]
689
- return m ? m.find(resolveVar(idExpr, ctx)) : null
690
- }
691
-
692
- // Model.findBy(field=value)
693
- if (expr.includes('.findBy(')) {
694
- const modelName = expr.match(/^(\w+)\.findBy/)?.[1]
695
- const args = expr.match(/\.findBy\(([^)]+)\)/)?.[1]
696
- const [field, valExpr] = (args || '').split('=')
697
- const m = server.models[modelName]
698
- return m ? m.findBy(field.trim(), resolveVar(valExpr?.trim(), ctx)) : null
699
- }
700
-
701
- // Model.paginate(page, perPage)
702
- if (expr.includes('.paginate(')) {
703
- const modelName = expr.match(/^(\w+)\.paginate/)?.[1]
704
- const args = expr.match(/\.paginate\(([^)]+)\)/)?.[1]?.split(',')
705
- const m = server.models[modelName]
706
- if (!m) return { data: [], meta: {} }
707
- const page = parseInt(resolveVar(args?.[0]?.trim(), ctx)) || 1
708
- const perPage = parseInt(resolveVar(args?.[1]?.trim(), ctx)) || 15
709
- return m.paginate(page, perPage)
710
- }
711
-
712
- // Model.count()
713
- if (expr.includes('.count(')) {
714
- const modelName = expr.match(/^(\w+)\.count/)?.[1]
715
- const m = server.models[modelName]
716
- return m ? m.count() : 0
717
- }
718
-
719
- // $var references
720
- if (expr === '$auth.user') return ctx.user
721
- if (expr.startsWith('$')) return resolveVar(expr, ctx)
722
-
723
- // @auth.user
724
- if (expr === '$auth.user' || expr === '$auth') return ctx.user
725
-
541
+ expr=expr.trim()
542
+ 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)} }
543
+ if (expr==='$auth.user'||expr==='$auth') return ctx.user
544
+ if (expr.includes('.all(')) { return evalModelOp('all', expr, ctx, server) }
545
+ if (expr.includes('.find(')) { return evalModelOp('find', expr, ctx, server) }
546
+ if (expr.includes('.findBy(')) { return evalModelOp('findBy', expr, ctx, server) }
547
+ if (expr.includes('.paginate(')) { return evalModelOp('paginate', expr, ctx, server) }
548
+ if (expr.includes('.count(')) { return evalModelOp('count', expr, ctx, server) }
549
+ if (expr.includes('.sum(')) { return evalModelOp('sum', expr, ctx, server) }
550
+ if (expr.includes('.avg(')) { return evalModelOp('avg', expr, ctx, server) }
551
+ if (expr.includes('.where(')) { return evalModelOp('where', expr, ctx, server) }
552
+ if (expr.includes('.scope(')) { return evalModelOp('scope', expr, ctx, server) }
553
+ if (expr.startsWith('$')) { return resolveVar(expr, ctx) }
726
554
  return expr
727
555
  }
728
556
 
557
+ function evalModelOp(op, expr, ctx, server) {
558
+ const modelName=expr.match(/^(\w+)\./)?.[1]; const m=server.models[modelName]; if(!m)return op==='all'?[]:null
559
+ const inner=expr.match(/\.\w+\(([^)]*)\)/)?.[1]||''
560
+ const getArg=(key)=>{ const r=inner.match(new RegExp(key+'=([^,)]+)')); return r?resolveVar(r[1],ctx):null }
561
+ if(op==='all') return m.all({limit:getArg('limit'),offset:getArg('offset')||evalMath(getArg('_offset')||'0',ctx),order:getArg('order'),where:getArg('where')})
562
+ if(op==='find') { const id=inner.trim(); return m.find(resolveVar(id,ctx)||ctx.params.id) }
563
+ if(op==='findBy') { const[f,v]=inner.split('='); return m.findBy(f.trim(),resolveVar(v?.trim(),ctx)) }
564
+ if(op==='paginate') { const[pg,pp]=inner.split(','); return m.paginate(parseInt(resolveVar(pg?.trim(),ctx))||1,parseInt(resolveVar(pp?.trim(),ctx))||15) }
565
+ if(op==='count') return m.count()
566
+ if(op==='sum') return m.sum(inner.trim())
567
+ if(op==='avg') return m.avg(inner.trim())
568
+ if(op==='where') { const p=inner.split(','); return m.where(p[0]?.trim(),p[1]?.trim()||'=',resolveVar(p[2]?.trim(),ctx)) }
569
+ if(op==='scope') return m.scope(inner.trim())
570
+ return null
571
+ }
572
+
729
573
  function resolveVar(expr, ctx) {
730
- if (!expr) return undefined
731
- expr = expr.trim()
574
+ if (!expr) return undefined; expr=expr.trim()
732
575
  if (expr.startsWith('$body.')) return ctx.body[expr.slice(6)]
733
- if (expr.startsWith('$params.') || expr === '$id') {
734
- const key = expr.startsWith('$params.') ? expr.slice(8) : 'id'
735
- return ctx.params[key] || ctx.params['id']
736
- }
576
+ if (expr==='$id'||expr==='$params.id') return ctx.params.id
577
+ if (expr.startsWith('$params.'))return ctx.params[expr.slice(8)]
737
578
  if (expr.startsWith('$query.')) return ctx.query[expr.slice(7)]
738
579
  if (expr.startsWith('$auth.')) return ctx.user?.[expr.slice(6)]
739
- if (expr.startsWith('$')) {
740
- const path = expr.slice(1).split('.')
741
- let val = ctx.vars[path[0]]
742
- for (let i = 1; i < path.length; i++) val = val?.[path[i]]
743
- return val
744
- }
580
+ if (expr.startsWith('$')) { const path=expr.slice(1).split('.'); let v=ctx.vars[path[0]]; for(let i=1;i<path.length;i++)v=v?.[path[i]]; return v }
745
581
  return expr
746
582
  }
747
-
748
- function evalMath(expr, ctx) {
749
- // Handles simple expressions like ($page-1)*$limit
750
- try {
751
- const resolved = expr.replace(/\$[\w.]+/g, m => resolveVar(m, ctx) || 0)
752
- return Function(`"use strict"; return (${resolved})`)()
753
- } catch { return 0 }
754
- }
755
-
756
- // ═══════════════════════════════════════════════════════════════
757
- // FRONTEND RENDERER — same as v1 but served dynamically
758
- // ═══════════════════════════════════════════════════════════════
759
-
760
- function serveStaticFrontend(server, pages) {
761
- // Serve CSS + hydration runtime
762
- server.addRoute('GET', '/aiplang-hydrate.js', (req, res) => {
763
- const hydratePath = path.join(__dirname, 'node_modules', '..', 'flux-lang', 'runtime', 'aiplang-hydrate.js')
764
- if (fs.existsSync(hydratePath)) {
765
- res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'public, max-age=3600' })
766
- res.end(fs.readFileSync(hydratePath))
767
- } else {
768
- res.writeHead(404); res.end('// hydrate runtime not found')
583
+ function evalMath(expr,ctx){try{const r=expr.replace(/\$[\w.]+/g,m=>resolveVar(m,ctx)||0);return Function('"use strict";return('+r+')')()}catch{return 0}}
584
+ function sanitize(o){if(!o)return o;const s={...o};delete s.password;return s}
585
+ function resolveEnv(v){if(!v)return v;if(v.startsWith('$'))return process.env[v.slice(1)]||v;return v}
586
+
587
+ // ═══════════════════════════════════════════════════════════════════
588
+ // AUTO ADMIN PANEL
589
+ // ═══════════════════════════════════════════════════════════════════
590
+ function registerAdminPanel(server, adminConfig, models) {
591
+ const prefix = adminConfig.prefix || '/admin'
592
+ const guard = adminConfig.guard || 'admin'
593
+
594
+ // Admin dashboard
595
+ server.addRoute('GET', prefix, (req, res) => {
596
+ if (guard === 'admin' && req.user?.role !== 'admin') {
597
+ res.writeHead(302, { Location: prefix + '/login' }); res.end(); return
769
598
  }
599
+ const html = renderAdminDashboard(prefix, models, server.models)
600
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html)
770
601
  })
771
602
 
772
- // Serve each page
773
- for (const page of pages) {
774
- const route = page.route || '/'
775
- server.addRoute('GET', route, (req, res) => {
776
- const html = renderPageHTML(page, pages)
777
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
778
- res.end(html)
779
- })
780
- }
781
- }
782
-
783
- function renderPageHTML(page, allPages) {
784
- const needsJS = page.queries.length > 0 || page.blocks.some(b => ['table','form','if','btn','select','faq'].includes(b.kind))
785
- const body = page.blocks.map(b => renderBlock(b)).join('')
786
- const config = needsJS ? JSON.stringify({
787
- id: page.id, theme: page.theme, state: page.state,
788
- routes: allPages.map(p=>p.route), queries: page.queries
789
- }) : ''
790
- const hydrate = needsJS ? `\n<script>window.__FLUX_PAGE__=${config};</script>\n<script src="/aiplang-hydrate.js" defer></script>` : ''
791
- const themeCSS = page.themeVars ? genThemeVarCSS(page.themeVars) : ''
792
- const customCSS = page.customTheme ? genCustomCSS(page.customTheme) : ''
793
- return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${page.id}</title><style>${getBaseCSS(page.theme)}${customCSS}${themeCSS}</style></head><body>${body}${hydrate}</body></html>`
794
- }
795
-
796
- function renderBlock(b) {
797
- const line = b.rawLine
798
- switch (b.kind) {
799
- case 'nav': return renderNav(line)
800
- case 'hero': return renderHero(line)
801
- case 'stats': return renderStats(line)
802
- case 'row': return renderRow(line)
803
- case 'sect': return renderSect(line)
804
- case 'foot': return renderFoot(line)
805
- case 'table': return renderTable(line)
806
- case 'form': return renderForm(line)
807
- case 'pricing': return renderPricing(line)
808
- case 'faq': return renderFaq(line)
809
- case 'raw': return extractBody(line) + '\n'
810
- case 'if': return `<div class="fx-if-wrap" data-fx-if="${extractCond(line)}" style="display:none"></div>\n`
811
- default: return ''
812
- }
813
- }
603
+ // Admin login page
604
+ server.addRoute('GET', prefix + '/login', (req, res) => {
605
+ const html = renderAdminLogin(prefix)
606
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html)
607
+ })
814
608
 
815
- const esc = s => s==null?'':String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
816
- const ic = n => ({bolt:'',rocket:'🚀',shield:'🛡',chart:'📊',star:'⭐',check:'✓',globe:'🌐',lock:'🔒',user:'👤',gear:'⚙',fire:'🔥',money:'💰',bell:'🔔',mail:'✉'}[n] || n)
609
+ // Admin API: list model records
610
+ server.addRoute('GET', prefix + '/api/:model', (req, res) => {
611
+ if (guard === 'admin' && req.user?.role !== 'admin') { res.error(403, 'Forbidden'); return }
612
+ const modelName = req.params.model.charAt(0).toUpperCase() + req.params.model.slice(1).replace(/s$/, '')
613
+ const m = server.models[modelName]
614
+ if (!m) { res.error(404, 'Model not found'); return }
615
+ const page = parseInt(req.query.page) || 1
616
+ res.json(200, m.paginate(page, 20))
617
+ })
817
618
 
818
- function extractBody(line) {
819
- const bi=line.indexOf('{'),li=line.lastIndexOf('}')
820
- return bi!==-1&&li!==-1?line.slice(bi+1,li).trim():''
821
- }
822
- function extractCond(line) { return line.slice(3,line.indexOf('{')).trim() }
823
-
824
- function parseItems(body) {
825
- return body.split('|').map(raw=>{
826
- raw=raw.trim();if(!raw)return null
827
- return raw.split('>').map(f=>{
828
- f=f.trim()
829
- if(f.startsWith('img:')) return{isImg:true,src:f.slice(4)}
830
- if(f.startsWith('/')) {const[p,l]=f.split(':');return{isLink:true,path:p.trim(),label:(l||'').trim()}}
831
- return{isLink:false,text:f}
832
- })
833
- }).filter(Boolean)
834
- }
619
+ // Admin API: delete record
620
+ server.addRoute('DELETE', prefix + '/api/:model/:id', (req, res) => {
621
+ if (guard === 'admin' && req.user?.role !== 'admin') { res.error(403, 'Forbidden'); return }
622
+ const modelName = req.params.model.charAt(0).toUpperCase() + req.params.model.slice(1).replace(/s$/, '')
623
+ const m = server.models[modelName]
624
+ if (!m) { res.error(404, 'Model not found'); return }
625
+ m.delete(req.params.id)
626
+ res.noContent()
627
+ })
835
628
 
836
- function renderNav(line) {
837
- const items=parseItems(extractBody(line))
838
- if(!items[0]) return ''
839
- const it=items[0],brand=!it[0]?.isLink?`<span class="fx-brand">${esc(it[0].text)}</span>`:''
840
- const start=!it[0]?.isLink?1:0
841
- const links=it.slice(start).filter(f=>f.isLink).map(f=>`<a href="${esc(f.path)}" class="fx-nav-link">${esc(f.label)}</a>`).join('')
842
- return `<nav class="fx-nav">${brand}<button class="fx-hamburger" onclick="this.classList.toggle('open');document.querySelector('.fx-nav-links').classList.toggle('open')"><span></span><span></span><span></span></button><div class="fx-nav-links">${links}</div></nav>\n`
843
- }
629
+ // Admin API: update record
630
+ server.addRoute('PUT', prefix + '/api/:model/:id', (req, res) => {
631
+ if (guard === 'admin' && req.user?.role !== 'admin') { res.error(403, 'Forbidden'); return }
632
+ const modelName = req.params.model.charAt(0).toUpperCase() + req.params.model.slice(1).replace(/s$/, '')
633
+ const m = server.models[modelName]
634
+ if (!m) { res.error(404, 'Model not found'); return }
635
+ const updated = m.update(req.params.id, req.body)
636
+ res.json(200, updated)
637
+ })
844
638
 
845
- function renderHero(line) {
846
- const items=parseItems(extractBody(line))
847
- let h1='',sub='',img='',ctas=''
848
- for(const item of items) for(const f of item){
849
- if(f.isImg) img=`<img src="${esc(f.src)}" class="fx-hero-img" alt="hero" loading="eager">`
850
- else if(f.isLink) ctas+=`<a href="${esc(f.path)}" class="fx-cta">${esc(f.label)}</a>`
851
- else if(!h1) h1=`<h1 class="fx-title">${esc(f.text)}</h1>`
852
- else sub+=`<p class="fx-sub">${esc(f.text)}</p>`
853
- }
854
- return `<section class="fx-hero${img?' fx-hero-split':''}"><div class="fx-hero-inner">${h1}${sub}${ctas}</div>${img}</section>\n`
639
+ console.log(`[aiplang] Admin: ${prefix} (guard: ${guard})`)
855
640
  }
856
641
 
857
- function renderStats(line) {
858
- const cells=parseItems(extractBody(line)).map(item=>{
859
- const[val,lbl]=(item[0]?.text||'').split(':')
860
- const bind=(val?.includes('@')||val?.includes('$'))?` data-fx-bind="${esc(val?.trim())}"` :''
861
- return`<div class="fx-stat"><div class="fx-stat-val"${bind}>${esc(val?.trim())}</div><div class="fx-stat-lbl">${esc(lbl?.trim())}</div></div>`
642
+ function renderAdminDashboard(prefix, modelDefs, models) {
643
+ const modelNames = modelDefs.map(m => m.name)
644
+ const stats = modelNames.map(name => {
645
+ const m = models[name]; const count = m?.count() || 0
646
+ return `<div class="stat-card"><div class="stat-num">${count}</div><div class="stat-label">${name}s</div></div>`
862
647
  }).join('')
863
- return `<div class="fx-stats">${cells}</div>\n`
648
+ const nav = modelNames.map(name =>
649
+ `<a href="#" onclick="loadModel('${name}')" class="nav-link">${name}s</a>`
650
+ ).join('')
651
+
652
+ return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>aiplang Admin</title>
653
+ <style>
654
+ *{box-sizing:border-box;margin:0;padding:0}body{font-family:-apple-system,sans-serif;background:#030712;color:#f1f5f9;min-height:100vh}
655
+ .sidebar{position:fixed;top:0;left:0;width:240px;height:100vh;background:#0f172a;border-right:1px solid #1e293b;padding:1.5rem}
656
+ .sidebar .brand{font-size:1.25rem;font-weight:800;color:#2563eb;margin-bottom:2rem}
657
+ .sidebar .brand span{color:#64748b;font-weight:400;font-size:.875rem;display:block;margin-top:.25rem}
658
+ .nav-link{display:block;padding:.625rem 1rem;border-radius:.5rem;color:#94a3b8;font-size:.875rem;font-weight:500;cursor:pointer;text-decoration:none;margin-bottom:.25rem}
659
+ .nav-link:hover{background:#1e293b;color:#f1f5f9}.main{margin-left:240px;padding:2rem}
660
+ .header{margin-bottom:2rem}.header h1{font-size:1.75rem;font-weight:800;letter-spacing:-.03em}
661
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1rem;margin-bottom:2rem}
662
+ .stat-card{background:#0f172a;border:1px solid #1e293b;border-radius:1rem;padding:1.5rem;text-align:center}
663
+ .stat-num{font-size:2.5rem;font-weight:900;color:#2563eb;letter-spacing:-.05em;line-height:1}
664
+ .stat-label{font-size:.75rem;color:#64748b;text-transform:uppercase;letter-spacing:.08em;margin-top:.5rem;font-weight:600}
665
+ .table-wrap{background:#0f172a;border:1px solid #1e293b;border-radius:1rem;overflow:hidden}
666
+ .table-header{padding:1.25rem 1.5rem;border-bottom:1px solid #1e293b;display:flex;align-items:center;justify-content:space-between}
667
+ .table-title{font-weight:700;font-size:1rem}
668
+ table{width:100%;border-collapse:collapse;font-size:.875rem}
669
+ th{padding:.875rem 1.25rem;text-align:left;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#475569;border-bottom:1px solid #1e293b}
670
+ td{padding:.875rem 1.25rem;border-bottom:1px solid rgba(255,255,255,.04);color:#94a3b8;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
671
+ .btn-sm{border:none;cursor:pointer;font-size:.75rem;font-weight:600;padding:.3rem .75rem;border-radius:.375rem;font-family:inherit}
672
+ .btn-delete{background:#7f1d1d;color:#fca5a5}.btn-delete:hover{background:#991b1b}
673
+ .pagination{padding:1rem 1.5rem;display:flex;gap:.5rem;justify-content:flex-end}
674
+ .page-btn{padding:.375rem .75rem;border-radius:.375rem;border:1px solid #1e293b;background:transparent;color:#64748b;cursor:pointer;font-size:.8125rem}
675
+ .page-btn.active{background:#2563eb;color:#fff;border-color:#2563eb}
676
+ .empty{text-align:center;padding:3rem;color:#334155}
677
+ #content{min-height:200px}
678
+ </style></head><body>
679
+ <div class="sidebar">
680
+ <div class="brand">aiplang Admin<span>v2.0.1</span></div>
681
+ <a href="${prefix}" class="nav-link" style="color:#f1f5f9;background:#1e293b">📊 Dashboard</a>
682
+ ${nav}
683
+ </div>
684
+ <div class="main">
685
+ <div class="header"><h1>Dashboard</h1></div>
686
+ <div class="stats">${stats}</div>
687
+ <div id="content"><div class="table-wrap"><div class="empty">← Selecione um modelo na sidebar</div></div></div>
688
+ </div>
689
+ <script>
690
+ const prefix = '${prefix}'
691
+ const token = localStorage.getItem('admin_token') || ''
692
+ async function api(method, path, body) {
693
+ const r = await fetch(prefix + '/api' + path, {method, headers:{'Content-Type':'application/json','Authorization':'Bearer '+token},body:body?JSON.stringify(body):undefined})
694
+ return r.json()
864
695
  }
865
-
866
- function renderRow(line) {
867
- const bi=line.indexOf('{'),head=line.slice(0,bi).trim()
868
- const m=head.match(/row(\d+)/),cols=m?parseInt(m[1]):3
869
- const cards=parseItems(extractBody(line)).map(item=>{
870
- const inner=item.map((f,fi)=>{
871
- if(f.isImg) return`<img src="${esc(f.src)}" class="fx-card-img" alt="" loading="lazy">`
872
- if(f.isLink) return`<a href="${esc(f.path)}" class="fx-card-link">${esc(f.label)} →</a>`
873
- if(fi===0) return`<div class="fx-icon">${ic(f.text)}</div>`
874
- if(fi===1) return`<h3 class="fx-card-title">${esc(f.text)}</h3>`
875
- return`<p class="fx-card-body">${esc(f.text)}</p>`
876
- }).join('')
877
- return`<div class="fx-card">${inner}</div>`
696
+ async function loadModel(name, page=1) {
697
+ const table = name.toLowerCase() + 's'
698
+ const data = await api('GET', '/' + table + '?page=' + page)
699
+ const rows = data.data || []
700
+ const meta = data.meta || {}
701
+ const cols = rows.length ? Object.keys(rows[0]).filter(k => !['password','deleted_at'].includes(k)) : []
702
+ const ths = cols.map(c=>'<th>'+c+'</th>').join('') + '<th>Actions</th>'
703
+ const trs = rows.map(r=>{
704
+ const tds = cols.map(c=>'<td title="'+String(r[c]||'').replace(/"/g,'&quot;')+'">'+String(r[c]||'-').slice(0,40)+'</td>').join('')
705
+ return '<tr>'+tds+'<td><button class="btn-sm btn-delete" onclick="del(\\'' + table + '\\',\\''+r.id+'\\')">Delete</button></td></tr>'
878
706
  }).join('')
879
- return `<div class="fx-grid fx-grid-${cols}">${cards}</div>\n`
707
+ const pages = Array.from({length:meta.last_page||1},(_,i)=>'<button class="page-btn'+(i+1===page?' active':'')+'" onclick="loadModel(\\'' + name + '\\',' + (i+1) + ')">'+(i+1)+'</button>').join('')
708
+ document.getElementById('content').innerHTML = '<div class="table-wrap"><div class="table-header"><span class="table-title">'+name+'s</span><span style="color:#64748b;font-size:.8125rem">'+meta.total+' records</span></div>' + (rows.length ? '<table><thead><tr>'+ths+'</tr></thead><tbody>'+trs+'</tbody></table>' : '<div class="empty">No records</div>') + '<div class="pagination">'+pages+'</div></div>'
880
709
  }
881
-
882
- function renderSect(line) {
883
- let inner=''
884
- parseItems(extractBody(line)).forEach((item,ii)=>item.forEach(f=>{
885
- if(f.isLink) inner+=`<a href="${esc(f.path)}" class="fx-sect-link">${esc(f.label)}</a>`
886
- else if(ii===0) inner+=`<h2 class="fx-sect-title">${esc(f.text)}</h2>`
887
- else inner+=`<p class="fx-sect-body">${esc(f.text)}</p>`
888
- }))
889
- return `<section class="fx-sect">${inner}</section>\n`
710
+ async function del(table, id) {
711
+ if (!confirm('Delete this record?')) return
712
+ await api('DELETE', '/' + table + '/' + id)
713
+ const name = table.charAt(0).toUpperCase() + table.slice(1).replace(/s$/, '')
714
+ loadModel(name)
890
715
  }
891
-
892
- function renderFoot(line) {
893
- let inner=''
894
- for(const item of parseItems(extractBody(line))) for(const f of item){
895
- if(f.isLink) inner+=`<a href="${esc(f.path)}" class="fx-footer-link">${esc(f.label)}</a>`
896
- else inner+=`<p class="fx-footer-text">${esc(f.text)}</p>`
897
- }
898
- return `<footer class="fx-footer">${inner}</footer>\n`
716
+ </script></body></html>`
899
717
  }
900
718
 
901
- function renderTable(line) {
902
- const bi=line.indexOf('{'),binding=line.slice(6,bi).trim()
903
- const content=extractBody(line)
904
- const em=content.match(/edit\s+(PUT|PATCH)\s+(\S+)/),dm=content.match(/delete\s+(?:DELETE\s+)?(\S+)/)
905
- const clean=content.replace(/edit\s+(PUT|PATCH)\s+\S+/g,'').replace(/delete\s+(?:DELETE\s+)?\S+/g,'')
906
- const cols=clean.split('|').map(c=>{c=c.trim();if(c.startsWith('empty:')||!c)return null;const[l,k]=c.split(':').map(x=>x.trim());return k?{label:l,key:k}:null}).filter(Boolean)
907
- const emptyMsg=clean.match(/empty:\s*([^|]+)/)?.[1]||'No data.'
908
- const ths=cols.map(c=>`<th class="fx-th">${esc(c.label)}</th>`).join('')
909
- const ea=em?` data-fx-edit="${esc(em[2])}" data-fx-edit-method="${esc(em[1])}"` :''
910
- const da=dm?` data-fx-delete="${esc(dm[1])}"` :''
911
- const at=(em||dm)?'<th class="fx-th fx-th-actions">Actions</th>':''
912
- return `<div class="fx-table-wrap"><table class="fx-table" data-fx-table="${esc(binding)}" data-fx-cols='${JSON.stringify(cols.map(c=>c.key))}'${ea}${da}><thead><tr>${ths}${at}</tr></thead><tbody class="fx-tbody"><tr><td colspan="${cols.length+(em||dm?1:0)}" class="fx-td-empty">${esc(emptyMsg)}</td></tr></tbody></table></div>\n`
719
+ function renderAdminLogin(prefix) {
720
+ return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Admin Login</title>
721
+ <style>*{box-sizing:border-box;margin:0;padding:0}body{background:#030712;color:#f1f5f9;font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh}.card{background:#0f172a;border:1px solid #1e293b;border-radius:1.25rem;padding:2.5rem;width:100%;max-width:360px}.h1{font-size:1.5rem;font-weight:800;margin-bottom:1.75rem;letter-spacing:-.03em}.field{margin-bottom:1.25rem}label{display:block;font-size:.8125rem;font-weight:600;margin-bottom:.5rem;color:#94a3b8}input{width:100%;padding:.75rem 1rem;background:#020617;border:1px solid #1e293b;border-radius:.625rem;color:#f1f5f9;font-size:.9375rem;outline:none}input:focus{border-color:#2563eb}button{width:100%;padding:.875rem;background:#2563eb;color:#fff;border:none;border-radius:.625rem;font-size:.9375rem;font-weight:700;cursor:pointer;margin-top:.5rem}.err{color:#f87171;font-size:.8125rem;margin-top:.5rem;min-height:1.25rem}</style></head>
722
+ <body><div class="card"><div class="h1">aiplang Admin</div>
723
+ <div class="field"><label>Email</label><input id="email" type="email" placeholder="admin@app.com"></div>
724
+ <div class="field"><label>Password</label><input id="pass" type="password" placeholder="••••••••"></div>
725
+ <div class="err" id="err"></div>
726
+ <button onclick="login()">Sign in</button></div>
727
+ <script>
728
+ async function login(){
729
+ const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:document.getElementById('email').value,password:document.getElementById('pass').value})})
730
+ const d=await r.json()
731
+ if(d.token){localStorage.setItem('admin_token',d.token);location.href='${prefix}'}
732
+ else document.getElementById('err').textContent=d.error||'Invalid credentials'
913
733
  }
914
-
915
- function renderForm(line) {
916
- const bi=line.indexOf('{')
917
- let head=line.slice(5,bi).trim(),action='',method='POST',bpath='#'
918
- const ai=head.indexOf('=>');if(ai!==-1){action=head.slice(ai+2).trim();head=head.slice(0,ai).trim()}
919
- const pts=head.split(/\s+/);method=pts[0]||'POST';bpath=pts[1]||'#'
920
- const fields=extractBody(line).split('|').map(f=>{
921
- const[label,type,ph]=f.split(':').map(x=>x.trim())
922
- if(!label) return''
923
- const name=label.toLowerCase().replace(/\s+/g,'_')
924
- const inp=type==='select'?`<select class="fx-input" name="${esc(name)}"><option value="">Select...</option></select>`:`<input class="fx-input" type="${esc(type||'text')}" name="${esc(name)}" placeholder="${esc(ph||'')}">`
925
- return`<div class="fx-field"><label class="fx-label">${esc(label)}</label>${inp}</div>`
926
- }).join('')
927
- return `<div class="fx-form-wrap"><form class="fx-form" data-fx-form="${esc(bpath)}" data-fx-method="${esc(method)}" data-fx-action="${esc(action)}">${fields}<div class="fx-form-msg"></div><button type="submit" class="fx-btn">Submit</button></form></div>\n`
734
+ document.addEventListener('keydown',e=>{if(e.key==='Enter')login()})
735
+ </script></body></html>`
928
736
  }
929
737
 
930
- function renderPricing(line) {
931
- const plans=extractBody(line).split('|').map(p=>{
932
- const pts=p.trim().split('>').map(x=>x.trim())
933
- return{name:pts[0],price:pts[1],desc:pts[2],linkRaw:pts[3]}
934
- }).filter(p=>p.name)
935
- const cards=plans.map((p,i)=>{
936
- let lh='#',ll='Get started'
937
- if(p.linkRaw){const m=p.linkRaw.match(/\/([^:]+):(.+)/);if(m){lh='/'+m[1];ll=m[2]}}
938
- const f=i===1?' fx-pricing-featured':''
939
- return`<div class="fx-pricing-card${f}">${i===1?'<div class="fx-pricing-badge">Most popular</div>':''}<div class="fx-pricing-name">${esc(p.name)}</div><div class="fx-pricing-price">${esc(p.price)}</div><p class="fx-pricing-desc">${esc(p.desc)}</p><a href="${esc(lh)}" class="fx-cta fx-pricing-cta">${esc(ll)}</a></div>`
940
- }).join('')
941
- return `<div class="fx-pricing">${cards}</div>\n`
942
- }
738
+ // ═══════════════════════════════════════════════════════════════════
739
+ // HTTP SERVER
740
+ // ═══════════════════════════════════════════════════════════════════
741
+ class AiplangServer {
742
+ constructor() { this.routes=[]; this.models={} }
743
+ addRoute(method, p, handler) { this.routes.push({method:method.toUpperCase(),path:p,handler,params:p.split('/').filter(s=>s.startsWith(':')).map(s=>s.slice(1))}) }
744
+ registerModel(name, def) { this.models[name]=new Model(name, def); return this.models[name] }
943
745
 
944
- function renderFaq(line) {
945
- const items=extractBody(line).split('|').map(i=>{const idx=i.indexOf('>');return{q:i.slice(0,idx).trim(),a:i.slice(idx+1).trim()}}).filter(i=>i.q)
946
- const html=items.map(i=>`<div class="fx-faq-item" onclick="this.classList.toggle('open')"><div class="fx-faq-q">${esc(i.q)}<span class="fx-faq-arrow">▸</span></div><div class="fx-faq-a">${esc(i.a)}</div></div>`).join('')
947
- return `<section class="fx-sect"><div class="fx-faq">${html}</div></section>\n`
948
- }
746
+ async handle(req, res) {
747
+ if (req.method !== 'GET' && req.method !== 'DELETE') req.body = await parseBody(req)
748
+ else req.body = {}
749
+ const parsed = url.parse(req.url, true)
750
+ req.query = parsed.query; req.path = parsed.pathname
751
+ req.user = extractToken(req) ? verifyJWT(extractToken(req)) : null
949
752
 
950
- // ─── Helpers ──────────────────────────────────────────────────
753
+ res.setHeader('Access-Control-Allow-Origin','*')
754
+ res.setHeader('Access-Control-Allow-Methods','GET,POST,PUT,PATCH,DELETE,OPTIONS')
755
+ res.setHeader('Access-Control-Allow-Headers','Content-Type,Authorization')
756
+ if (req.method==='OPTIONS') { res.writeHead(204); res.end(); return }
951
757
 
952
- function parseRouteParams(routePath) {
953
- return routePath.split('/').filter(s => s.startsWith(':')).map(s => s.slice(1))
954
- }
758
+ for (const route of this.routes) {
759
+ if (route.method !== req.method) continue
760
+ const match = matchRoute(route.path, req.path); if (!match) continue
761
+ req.params = match
762
+ res.json = (s, d) => { if(typeof s==='object'){d=s;s=200}; res.writeHead(s,{'Content-Type':'application/json'}); res.end(JSON.stringify(d)) }
763
+ res.error = (s, m) => res.json(s, {error:m})
764
+ res.noContent = () => { res.writeHead(204); res.end() }
765
+ res.redirect = (u) => { res.writeHead(302,{Location:u}); res.end() }
766
+ try { await route.handler(req, res) } catch(e) { console.error('[aiplang] Error:', e.message); if(!res.writableEnded) res.json(500,{error:'Internal server error'}) }
767
+ return
768
+ }
769
+ res.writeHead(404,{'Content-Type':'application/json'}); res.end(JSON.stringify({error:'Not found'}))
770
+ }
955
771
 
956
- function matchRoute(routePattern, reqPath) {
957
- const rParts = routePattern.split('/')
958
- const uParts = reqPath.split('/')
959
- if (rParts.length !== uParts.length) return null
960
- const params = {}
961
- for (let i = 0; i < rParts.length; i++) {
962
- if (rParts[i].startsWith(':')) params[rParts[i].slice(1)] = uParts[i]
963
- else if (rParts[i] !== uParts[i]) return null
772
+ listen(port) {
773
+ http.createServer((req,res)=>this.handle(req,res)).listen(port,()=>console.log(`[aiplang] Server http://localhost:${port}`))
964
774
  }
965
- return params
966
775
  }
967
776
 
968
- function extractToken(req) {
969
- const auth = req.headers.authorization
970
- if (auth?.startsWith('Bearer ')) return auth.slice(7)
971
- return null
777
+ // ── Utils ─────────────────────────────────────────────────────────
778
+ function matchRoute(pattern, reqPath) {
779
+ const pp=pattern.split('/'), rp=reqPath.split('/')
780
+ if(pp.length!==rp.length)return null
781
+ const params={}
782
+ for(let i=0;i<pp.length;i++){if(pp[i].startsWith(':'))params[pp[i].slice(1)]=rp[i];else if(pp[i]!==rp[i])return null}
783
+ return params
972
784
  }
973
-
785
+ function extractToken(req) { const a=req.headers.authorization; return a?.startsWith('Bearer ')?a.slice(7):null }
974
786
  async function parseBody(req) {
975
- return new Promise((resolve) => {
976
- let data = ''
977
- req.on('data', chunk => data += chunk)
978
- req.on('end', () => {
979
- try { resolve(JSON.parse(data)) }
980
- catch { resolve({}) }
981
- })
982
- req.on('error', () => resolve({}))
983
- })
984
- }
985
-
986
- function sanitize(obj) {
987
- if (!obj) return obj
988
- const s = { ...obj }
989
- delete s.password
990
- return s
787
+ return new Promise(r=>{let d='';req.on('data',c=>d+=c);req.on('end',()=>{try{r(JSON.parse(d))}catch{r({})}});req.on('error',()=>r({}))})
991
788
  }
992
789
 
993
- function genThemeVarCSS(t) {
994
- const r=[]
995
- if(t.accent) r.push(`.fx-cta,.fx-btn{background:${t.accent}!important;color:#fff!important}`)
996
- if(t.bg) r.push(`body{background:${t.bg}!important}`)
997
- if(t.text) r.push(`body{color:${t.text}!important}`)
998
- if(t.font) r.push(`@import url('https://fonts.googleapis.com/css2?family=${t.font.replace(/ /g,'+')}:wght@400;700;900&display=swap');body{font-family:'${t.font}',system-ui,sans-serif!important}`)
999
- if(t.radius) r.push(`.fx-card,.fx-form,.fx-btn,.fx-input,.fx-cta{border-radius:${t.radius}!important}`)
1000
- if(t.surface)r.push(`.fx-card,.fx-form{background:${t.surface}!important}`)
1001
- return r.join('')
790
+ // ═══════════════════════════════════════════════════════════════════
791
+ // FRONTEND RENDERER (same as v1)
792
+ // ═══════════════════════════════════════════════════════════════════
793
+ function renderHTML(page, allPages) {
794
+ const needsJS=page.queries.length>0||page.blocks.some(b=>['table','form','if','btn','select','faq'].includes(b.kind))
795
+ const body=page.blocks.map(b=>renderBlock(b)).join('')
796
+ const config=needsJS?JSON.stringify({id:page.id,theme:page.theme,state:page.state,routes:allPages.map(p=>p.route),queries:page.queries}):''
797
+ const hydrate=needsJS?`<script>window.__AIPLANG_PAGE__=${config};</script><script src="/aiplang-hydrate.js" defer></script>`:''
798
+ const themeCSS=page.themeVars?genThemeCSS(page.themeVars):''
799
+ const customCSS=page.customTheme?`body{background:${page.customTheme.bg};color:${page.customTheme.text}}.fx-cta,.fx-btn{background:${page.customTheme.accent};color:#fff}` :''
800
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${page.id}</title><style>${baseCSS(page.theme)}${customCSS}${themeCSS}</style></head><body>${body}${hydrate}</body></html>`
1002
801
  }
1003
802
 
1004
- function genCustomCSS(ct) {
1005
- return `body{background:${ct.bg};color:${ct.text}}.fx-cta,.fx-btn{background:${ct.accent};color:#fff}`
803
+ function renderBlock(b) {
804
+ const line=b.rawLine
805
+ let animate='',extraClass=''
806
+ const am=line.match(/\banimate:(\S+)/); if(am)animate='fx-anim-'+am[1]
807
+ const cm=line.match(/\bclass:(\S+)/); if(cm)extraClass=cm[1]
808
+ const addCls=(html)=>animate||extraClass?html.replace(/class="([^"]*)"/, (_,c)=>`class="${c} ${animate} ${extraClass}".trim().replace(/ +/g,' ')`):html
809
+
810
+ switch(b.kind){
811
+ case 'nav': return addCls(rNav(line))
812
+ case 'hero': return addCls(rHero(line))
813
+ case 'stats':return addCls(rStats(line))
814
+ case 'row': return addCls(rRow(line))
815
+ case 'sect': return addCls(rSect(line))
816
+ case 'foot': return addCls(rFoot(line))
817
+ case 'table':return rTable(line)
818
+ case 'form': return rForm(line)
819
+ case 'pricing':return rPricing(line)
820
+ case 'faq': return rFaq(line)
821
+ case 'testimonial':return rTestimonial(line)
822
+ case 'gallery':return rGallery(line)
823
+ case 'raw': return extractBody(line)+'\n'
824
+ case 'if': return `<div class="fx-if-wrap" data-fx-if="${esc(extractCond(line))}" style="display:none"></div>\n`
825
+ default: return ''
826
+ }
1006
827
  }
1007
828
 
1008
- function getBaseCSS(theme) {
1009
- const base=`*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html{scroll-behavior:smooth}body{font-family:-apple-system,'Segoe UI',system-ui,sans-serif;-webkit-font-smoothing:antialiased;min-height:100vh}a{text-decoration:none;color:inherit}input,button,select{font-family:inherit}img{max-width:100%;height:auto}.fx-nav{display:flex;align-items:center;justify-content:space-between;padding:1rem 2.5rem;position:sticky;top:0;z-index:50;backdrop-filter:blur(12px);flex-wrap:wrap;gap:.5rem}.fx-brand{font-size:1.25rem;font-weight:800;letter-spacing:-.03em}.fx-nav-links{display:flex;align-items:center;gap:1.75rem}.fx-nav-link{font-size:.875rem;font-weight:500;opacity:.65;transition:opacity .15s}.fx-nav-link:hover{opacity:1}.fx-hamburger{display:none;flex-direction:column;gap:5px;background:none;border:none;cursor:pointer;padding:.25rem}.fx-hamburger span{display:block;width:22px;height:2px;background:currentColor;transition:all .2s;border-radius:1px}.fx-hamburger.open span:nth-child(1){transform:rotate(45deg) translate(5px,5px)}.fx-hamburger.open span:nth-child(2){opacity:0}.fx-hamburger.open span:nth-child(3){transform:rotate(-45deg) translate(5px,-5px)}@media(max-width:640px){.fx-hamburger{display:flex}.fx-nav-links{display:none;width:100%;flex-direction:column;align-items:flex-start;gap:.75rem;padding:.75rem 0}.fx-nav-links.open{display:flex}}.fx-hero{display:flex;align-items:center;justify-content:center;min-height:92vh;padding:4rem 1.5rem}.fx-hero-split{display:grid;grid-template-columns:1fr 1fr;gap:3rem;align-items:center;padding:4rem 2.5rem;min-height:70vh}.fx-hero-img{width:100%;border-radius:1.25rem;object-fit:cover;max-height:500px}.fx-hero-inner{max-width:56rem;text-align:center;display:flex;flex-direction:column;align-items:center;gap:1.5rem}.fx-hero-split .fx-hero-inner{text-align:left;align-items:flex-start;max-width:none}.fx-title{font-size:clamp(2.5rem,8vw,5.5rem);font-weight:900;letter-spacing:-.04em;line-height:1}.fx-sub{font-size:clamp(1rem,2vw,1.25rem);line-height:1.75;max-width:40rem}.fx-cta{display:inline-flex;align-items:center;padding:.875rem 2.5rem;border-radius:.75rem;font-weight:700;font-size:1rem;transition:transform .15s;margin:.25rem}.fx-cta:hover{transform:translateY(-1px)}.fx-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:3rem;padding:5rem 2.5rem;text-align:center}.fx-stat-val{font-size:clamp(2.5rem,5vw,4rem);font-weight:900;letter-spacing:-.04em;line-height:1}.fx-stat-lbl{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.1em;margin-top:.5rem}.fx-grid{display:grid;gap:1.25rem;padding:1rem 2.5rem 5rem}.fx-grid-2{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}.fx-grid-3{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}.fx-grid-4{grid-template-columns:repeat(auto-fit,minmax(200px,1fr))}.fx-card{border-radius:1rem;padding:1.75rem;transition:transform .2s,box-shadow .2s}.fx-card:hover{transform:translateY(-2px)}.fx-card-img{width:100%;border-radius:.75rem;object-fit:cover;height:180px;margin-bottom:1rem}.fx-icon{font-size:2rem;margin-bottom:1rem}.fx-card-title{font-size:1.0625rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.5rem}.fx-card-body{font-size:.875rem;line-height:1.65}.fx-sect{padding:5rem 2.5rem}.fx-sect-title{font-size:clamp(1.75rem,4vw,3rem);font-weight:800;letter-spacing:-.04em;margin-bottom:1.5rem;text-align:center}.fx-sect-body{font-size:1rem;line-height:1.75;text-align:center;max-width:48rem;margin:0 auto}.fx-form-wrap{padding:3rem 2.5rem;display:flex;justify-content:center}.fx-form{width:100%;max-width:28rem;border-radius:1.25rem;padding:2.5rem}.fx-field{margin-bottom:1.25rem}.fx-label{display:block;font-size:.8125rem;font-weight:600;margin-bottom:.5rem}.fx-input{width:100%;padding:.75rem 1rem;border-radius:.625rem;font-size:.9375rem;outline:none;transition:box-shadow .15s}.fx-input:focus{box-shadow:0 0 0 3px rgba(37,99,235,.35)}.fx-btn{width:100%;padding:.875rem 1.5rem;border:none;border-radius:.625rem;font-size:.9375rem;font-weight:700;cursor:pointer;margin-top:.5rem;transition:transform .15s,opacity .15s}.fx-btn:hover{transform:translateY(-1px)}.fx-btn:disabled{opacity:.5;cursor:not-allowed}.fx-form-msg{font-size:.8125rem;padding:.5rem 0;min-height:1.5rem;text-align:center}.fx-form-err{color:#f87171}.fx-form-ok{color:#4ade80}.fx-table-wrap{overflow-x:auto;padding:0 2.5rem 4rem}.fx-table{width:100%;border-collapse:collapse;font-size:.875rem}.fx-th{text-align:left;padding:.875rem 1.25rem;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.fx-th-actions{opacity:.6}.fx-tr{transition:background .1s}.fx-td{padding:.875rem 1.25rem}.fx-td-empty{padding:2rem 1.25rem;text-align:center;opacity:.4}.fx-td-actions{white-space:nowrap;padding:.5rem 1rem!important}.fx-action-btn{border:none;cursor:pointer;font-size:.75rem;font-weight:600;padding:.3rem .75rem;border-radius:.375rem;margin-right:.375rem;font-family:inherit;transition:opacity .15s}.fx-action-btn:hover{opacity:.85}.fx-edit-btn{background:#1e40af;color:#93c5fd}.fx-delete-btn{background:#7f1d1d;color:#fca5a5}.fx-pricing{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1.5rem;padding:2rem 2.5rem 5rem;align-items:start}.fx-pricing-card{border-radius:1.25rem;padding:2rem;position:relative;transition:transform .2s}.fx-pricing-featured{transform:scale(1.03)}.fx-pricing-badge{position:absolute;top:-12px;left:50%;transform:translateX(-50%);background:#2563eb;color:#fff;font-size:.7rem;font-weight:700;padding:.25rem .875rem;border-radius:999px;white-space:nowrap}.fx-pricing-name{font-size:.875rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;margin-bottom:.5rem;opacity:.7}.fx-pricing-price{font-size:3rem;font-weight:900;letter-spacing:-.05em;line-height:1;margin-bottom:.75rem}.fx-pricing-desc{font-size:.875rem;line-height:1.65;margin-bottom:1.5rem;opacity:.7}.fx-pricing-cta{display:block;text-align:center;padding:.75rem;border-radius:.625rem;font-weight:700;font-size:.9rem}.fx-faq{max-width:48rem;margin:0 auto}.fx-faq-item{border-radius:.75rem;margin-bottom:.625rem;cursor:pointer;overflow:hidden}.fx-faq-q{display:flex;justify-content:space-between;align-items:center;padding:1rem 1.25rem;font-size:.9375rem;font-weight:600}.fx-faq-arrow{transition:transform .2s;font-size:.75rem;opacity:.5}.fx-faq-item.open .fx-faq-arrow{transform:rotate(90deg)}.fx-faq-a{max-height:0;overflow:hidden;padding:0 1.25rem;font-size:.875rem;line-height:1.7;transition:max-height .3s,padding .3s}.fx-faq-item.open .fx-faq-a{max-height:300px;padding:.75rem 1.25rem 1.25rem}.fx-if-wrap{display:contents}.fx-footer{padding:3rem 2.5rem;text-align:center}.fx-footer-text{font-size:.8125rem}.fx-footer-link{font-size:.8125rem;margin:0 .75rem;opacity:.5;transition:opacity .15s}.fx-footer-link:hover{opacity:1}`
1010
- const T={dark:`body{background:#030712;color:#f1f5f9}.fx-nav{border-bottom:1px solid #1e293b;background:rgba(3,7,18,.85)}.fx-nav-link{color:#cbd5e1}.fx-sub{color:#94a3b8}.fx-cta{background:#2563eb;color:#fff;box-shadow:0 8px 24px rgba(37,99,235,.35)}.fx-stat-lbl{color:#64748b}.fx-card{background:#0f172a;border:1px solid #1e293b}.fx-card:hover{box-shadow:0 20px 40px rgba(0,0,0,.5)}.fx-card-body{color:#64748b}.fx-sect-body{color:#64748b}.fx-form{background:#0f172a;border:1px solid #1e293b}.fx-label{color:#94a3b8}.fx-input{background:#020617;border:1px solid #1e293b;color:#f1f5f9}.fx-input::placeholder{color:#334155}.fx-btn{background:#2563eb;color:#fff;box-shadow:0 4px 14px rgba(37,99,235,.4)}.fx-th{color:#475569;border-bottom:1px solid #1e293b}.fx-tr:hover{background:#0f172a}.fx-td{border-bottom:1px solid rgba(255,255,255,.03)}.fx-footer{border-top:1px solid #1e293b}.fx-footer-text{color:#334155}.fx-pricing-card{background:#0f172a;border:1px solid #1e293b}.fx-faq-item{background:#0f172a}`,light:`body{background:#fff;color:#0f172a}.fx-nav{border-bottom:1px solid #e2e8f0;background:rgba(255,255,255,.85)}.fx-cta{background:#2563eb;color:#fff}.fx-btn{background:#2563eb;color:#fff}.fx-card{background:#f8fafc;border:1px solid #e2e8f0}.fx-form{background:#f8fafc;border:1px solid #e2e8f0}.fx-input{background:#fff;border:1px solid #cbd5e1;color:#0f172a}.fx-th{color:#94a3b8;border-bottom:1px solid #e2e8f0}.fx-tr:hover{background:#f8fafc}.fx-footer{border-top:1px solid #e2e8f0}.fx-pricing-card{background:#f8fafc;border:1px solid #e2e8f0}.fx-faq-item{background:#f8fafc}`}
829
+ function extractBody(line){const bi=line.indexOf('{'),li=line.lastIndexOf('}');return bi!==-1&&li!==-1?line.slice(bi+1,li).trim():''}
830
+ function extractCond(line){return line.slice(3,line.indexOf('{')).trim()}
831
+ function parseItems(body){return body.split('|').map(raw=>{raw=raw.trim();if(!raw)return null;return raw.split('>').map(f=>{f=f.trim();if(f.startsWith('img:'))return{isImg:true,src:f.slice(4)};if(f.startsWith('/'))return{isLink:true,path:f.split(':')[0].trim(),label:(f.split(':')[1]||'').trim()};return{isLink:false,text:f}})}).filter(Boolean)}
832
+
833
+ function rNav(line){const items=parseItems(extractBody(line));if(!items[0])return '';const it=items[0],brand=!it[0]?.isLink?`<span class="fx-brand">${esc(it[0].text)}</span>`:'';const start=!it[0]?.isLink?1:0;const links=it.slice(start).filter(f=>f.isLink).map(f=>`<a href="${esc(f.path)}" class="fx-nav-link">${esc(f.label)}</a>`).join('');return`<nav class="fx-nav">${brand}<button class="fx-hamburger" onclick="this.classList.toggle('open');document.querySelector('.fx-nav-links').classList.toggle('open')"><span></span><span></span><span></span></button><div class="fx-nav-links">${links}</div></nav>\n`}
834
+ function rHero(line){const items=parseItems(extractBody(line));let h1='',sub='',img='',ctas='';for(const item of items)for(const f of item){if(f.isImg)img=`<img src="${esc(f.src)}" class="fx-hero-img" alt="hero" loading="eager">`;else if(f.isLink)ctas+=`<a href="${esc(f.path)}" class="fx-cta">${esc(f.label)}</a>`;else if(!h1)h1=`<h1 class="fx-title">${esc(f.text)}</h1>`;else sub+=`<p class="fx-sub">${esc(f.text)}</p>`};return`<section class="fx-hero${img?' fx-hero-split':''}"><div class="fx-hero-inner">${h1}${sub}${ctas}</div>${img}</section>\n`}
835
+ function rStats(line){return`<div class="fx-stats">${parseItems(extractBody(line)).map(item=>{const[val,lbl]=(item[0]?.text||'').split(':');const bind=(val?.includes('@')||val?.includes('$'))?` data-fx-bind="${esc(val?.trim())}"` :'';return`<div class="fx-stat"><div class="fx-stat-val"${bind}>${esc(val?.trim())}</div><div class="fx-stat-lbl">${esc(lbl?.trim())}</div></div>`}).join('')}</div>\n`}
836
+ function rRow(line){const bi=line.indexOf('{'),head=line.slice(0,bi).trim(),m=head.match(/row(\d+)/),cols=m?parseInt(m[1]):3;const cards=parseItems(extractBody(line)).map(item=>`<div class="fx-card">${item.map((f,fi)=>f.isImg?`<img src="${esc(f.src)}" class="fx-card-img" alt="" loading="lazy">`:f.isLink?`<a href="${esc(f.path)}" class="fx-card-link">${esc(f.label)} →</a>`:fi===0?`<div class="fx-icon">${ic(f.text)}</div>`:fi===1?`<h3 class="fx-card-title">${esc(f.text)}</h3>`:`<p class="fx-card-body">${esc(f.text)}</p>`).join('')}</div>`).join('');return`<div class="fx-grid fx-grid-${cols}">${cards}</div>\n`}
837
+ function rSect(line){let inner='';parseItems(extractBody(line)).forEach((item,ii)=>item.forEach(f=>{if(f.isLink)inner+=`<a href="${esc(f.path)}" class="fx-sect-link">${esc(f.label)}</a>`;else if(ii===0)inner+=`<h2 class="fx-sect-title">${esc(f.text)}</h2>`;else inner+=`<p class="fx-sect-body">${esc(f.text)}</p>`}));return`<section class="fx-sect">${inner}</section>\n`}
838
+ function rFoot(line){let inner='';for(const item of parseItems(extractBody(line)))for(const f of item){if(f.isLink)inner+=`<a href="${esc(f.path)}" class="fx-footer-link">${esc(f.label)}</a>`;else inner+=`<p class="fx-footer-text">${esc(f.text)}</p>`};return`<footer class="fx-footer">${inner}</footer>\n`}
839
+ function rTable(line){const bi=line.indexOf('{'),binding=line.slice(6,bi).trim(),content=extractBody(line),em=content.match(/edit\s+(PUT|PATCH)\s+(\S+)/),dm=content.match(/delete\s+(?:DELETE\s+)?(\S+)/);const clean=content.replace(/edit\s+(PUT|PATCH)\s+\S+/g,'').replace(/delete\s+(?:DELETE\s+)?\S+/g,'');const cols=clean.split('|').map(c=>{c=c.trim();if(c.startsWith('empty:')||!c)return null;const[l,k]=c.split(':').map(x=>x.trim());return k?{label:l,key:k}:null}).filter(Boolean);const emptyMsg=clean.match(/empty:\s*([^|]+)/)?.[1]||'No data.';const ths=cols.map(c=>`<th class="fx-th">${esc(c.label)}</th>`).join('');const at=(em||dm)?'<th class="fx-th fx-th-actions">Actions</th>':'';return`<div class="fx-table-wrap"><table class="fx-table" data-fx-table="${esc(binding)}" data-fx-cols='${JSON.stringify(cols.map(c=>c.key))}'${em?` data-fx-edit="${esc(em[2])}" data-fx-edit-method="${esc(em[1])}"` :'' }${dm?` data-fx-delete="${esc(dm[1])}"` :'' }><thead><tr>${ths}${at}</tr></thead><tbody class="fx-tbody"><tr><td colspan="${cols.length+(em||dm?1:0)}" class="fx-td-empty">${esc(emptyMsg)}</td></tr></tbody></table></div>\n`}
840
+ function rForm(line){const bi=line.indexOf('{');let head=line.slice(5,bi).trim(),action='',method='POST',bpath='#';const ai=head.indexOf('=>');if(ai!==-1){action=head.slice(ai+2).trim();head=head.slice(0,ai).trim()};const pts=head.split(/\s+/);method=pts[0]||'POST';bpath=pts[1]||'#';const fields=extractBody(line).split('|').map(f=>{const[label,type,ph]=f.split(':').map(x=>x.trim());if(!label)return'';const name=label.toLowerCase().replace(/\s+/g,'_');const inp=type==='select'?`<select class="fx-input" name="${esc(name)}"><option value="">Select...</option></select>`:`<input class="fx-input" type="${esc(type||'text')}" name="${esc(name)}" placeholder="${esc(ph||'')}">`;return`<div class="fx-field"><label class="fx-label">${esc(label)}</label>${inp}</div>`}).join('');return`<div class="fx-form-wrap"><form class="fx-form" data-fx-form="${esc(bpath)}" data-fx-method="${esc(method)}" data-fx-action="${esc(action)}">${fields}<div class="fx-form-msg"></div><button type="submit" class="fx-btn">Submit</button></form></div>\n`}
841
+ function rPricing(line){const plans=extractBody(line).split('|').map(p=>{const pts=p.trim().split('>').map(x=>x.trim());return{name:pts[0],price:pts[1],desc:pts[2],linkRaw:pts[3]}}).filter(p=>p.name);const cards=plans.map((p,i)=>{let lh='#',ll='Get started';if(p.linkRaw){const m=p.linkRaw.match(/\/([^:]+):(.+)/);if(m){lh='/'+m[1];ll=m[2]}};return`<div class="fx-pricing-card${i===1?' fx-pricing-featured':''}">${i===1?'<div class="fx-pricing-badge">Most popular</div>':''}<div class="fx-pricing-name">${esc(p.name)}</div><div class="fx-pricing-price">${esc(p.price)}</div><p class="fx-pricing-desc">${esc(p.desc)}</p><a href="${esc(lh)}" class="fx-cta fx-pricing-cta">${esc(ll)}</a></div>`}).join('');return`<div class="fx-pricing">${cards}</div>\n`}
842
+ function rFaq(line){const items=extractBody(line).split('|').map(i=>{const idx=i.indexOf('>');return{q:i.slice(0,idx).trim(),a:i.slice(idx+1).trim()}}).filter(i=>i.q);return`<section class="fx-sect"><div class="fx-faq">${items.map(i=>`<div class="fx-faq-item" onclick="this.classList.toggle('open')"><div class="fx-faq-q">${esc(i.q)}<span class="fx-faq-arrow">▸</span></div><div class="fx-faq-a">${esc(i.a)}</div></div>`).join('')}</div></section>\n`}
843
+ function rTestimonial(line){const parts=extractBody(line).split('|').map(x=>x.trim());const imgPart=parts.find(p=>p.startsWith('img:'));const img=imgPart?`<img src="${esc(imgPart.slice(4))}" class="fx-testi-img" alt="${esc(parts[0])}" loading="lazy">`:`<div class="fx-testi-avatar">${esc((parts[0]||'?').charAt(0))}</div>`;return`<section class="fx-testi-wrap"><div class="fx-testi">${img}<blockquote class="fx-testi-quote">"${esc(parts[1]?.replace(/^"|"$/g,''))}"</blockquote><div class="fx-testi-author">${esc(parts[0])}</div></div></section>\n`}
844
+ function rGallery(line){return`<div class="fx-gallery">${extractBody(line).split('|').map(src=>`<div class="fx-gallery-item"><img src="${esc(src.trim())}" alt="" loading="lazy"></div>`).join('')}</div>\n`}
845
+
846
+ function genThemeCSS(t){const r=[];if(t.accent)r.push(`.fx-cta,.fx-btn{background:${t.accent}!important;color:#fff!important}`);if(t.bg)r.push(`body{background:${t.bg}!important}`);if(t.text)r.push(`body{color:${t.text}!important}`);if(t.font)r.push(`@import url('https://fonts.googleapis.com/css2?family=${t.font.replace(/ /g,'+')}:wght@400;700;900&display=swap');body{font-family:'${t.font}',system-ui,sans-serif!important}`);if(t.radius)r.push(`.fx-card,.fx-form,.fx-btn,.fx-input,.fx-cta{border-radius:${t.radius}!important}`);if(t.surface)r.push(`.fx-card,.fx-form{background:${t.surface}!important}`);return r.join('')}
847
+
848
+ function baseCSS(theme) {
849
+ const base=`*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html{scroll-behavior:smooth}body{font-family:-apple-system,'Segoe UI',system-ui,sans-serif;-webkit-font-smoothing:antialiased;min-height:100vh}a{text-decoration:none;color:inherit}input,button,select{font-family:inherit}img{max-width:100%;height:auto}.fx-nav{display:flex;align-items:center;justify-content:space-between;padding:1rem 2.5rem;position:sticky;top:0;z-index:50;backdrop-filter:blur(12px);flex-wrap:wrap;gap:.5rem}.fx-brand{font-size:1.25rem;font-weight:800;letter-spacing:-.03em}.fx-nav-links{display:flex;align-items:center;gap:1.75rem}.fx-nav-link{font-size:.875rem;font-weight:500;opacity:.65;transition:opacity .15s}.fx-nav-link:hover{opacity:1}.fx-hamburger{display:none;flex-direction:column;gap:5px;background:none;border:none;cursor:pointer;padding:.25rem}.fx-hamburger span{display:block;width:22px;height:2px;background:currentColor;transition:all .2s;border-radius:1px}.fx-hamburger.open span:nth-child(1){transform:rotate(45deg) translate(5px,5px)}.fx-hamburger.open span:nth-child(2){opacity:0}.fx-hamburger.open span:nth-child(3){transform:rotate(-45deg) translate(5px,-5px)}@media(max-width:640px){.fx-hamburger{display:flex}.fx-nav-links{display:none;width:100%;flex-direction:column;align-items:flex-start;gap:.75rem;padding:.75rem 0}.fx-nav-links.open{display:flex}}.fx-hero{display:flex;align-items:center;justify-content:center;min-height:92vh;padding:4rem 1.5rem}.fx-hero-split{display:grid;grid-template-columns:1fr 1fr;gap:3rem;align-items:center;padding:4rem 2.5rem;min-height:70vh}@media(max-width:768px){.fx-hero-split{grid-template-columns:1fr}}.fx-hero-img{width:100%;border-radius:1.25rem;object-fit:cover;max-height:500px}.fx-hero-inner{max-width:56rem;text-align:center;display:flex;flex-direction:column;align-items:center;gap:1.5rem}.fx-hero-split .fx-hero-inner{text-align:left;align-items:flex-start;max-width:none}.fx-title{font-size:clamp(2.5rem,8vw,5.5rem);font-weight:900;letter-spacing:-.04em;line-height:1}.fx-sub{font-size:clamp(1rem,2vw,1.25rem);line-height:1.75;max-width:40rem}.fx-cta{display:inline-flex;align-items:center;padding:.875rem 2.5rem;border-radius:.75rem;font-weight:700;font-size:1rem;transition:transform .15s;margin:.25rem}.fx-cta:hover{transform:translateY(-1px)}.fx-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:3rem;padding:5rem 2.5rem;text-align:center}.fx-stat-val{font-size:clamp(2.5rem,5vw,4rem);font-weight:900;letter-spacing:-.04em;line-height:1}.fx-stat-lbl{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.1em;margin-top:.5rem}.fx-grid{display:grid;gap:1.25rem;padding:1rem 2.5rem 5rem}.fx-grid-2{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}.fx-grid-3{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}.fx-grid-4{grid-template-columns:repeat(auto-fit,minmax(200px,1fr))}.fx-card{border-radius:1rem;padding:1.75rem;transition:transform .2s,box-shadow .2s}.fx-card:hover{transform:translateY(-2px)}.fx-card-img{width:100%;border-radius:.75rem;object-fit:cover;height:180px;margin-bottom:1rem}.fx-icon{font-size:2rem;margin-bottom:1rem}.fx-card-title{font-size:1.0625rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.5rem}.fx-card-body{font-size:.875rem;line-height:1.65}.fx-card-link{font-size:.8125rem;font-weight:600;display:inline-block;margin-top:1rem;opacity:.6;transition:opacity .15s}.fx-card-link:hover{opacity:1}.fx-sect{padding:5rem 2.5rem}.fx-sect-title{font-size:clamp(1.75rem,4vw,3rem);font-weight:800;letter-spacing:-.04em;margin-bottom:1.5rem;text-align:center}.fx-sect-body{font-size:1rem;line-height:1.75;text-align:center;max-width:48rem;margin:0 auto}.fx-form-wrap{padding:3rem 2.5rem;display:flex;justify-content:center}.fx-form{width:100%;max-width:28rem;border-radius:1.25rem;padding:2.5rem}.fx-field{margin-bottom:1.25rem}.fx-label{display:block;font-size:.8125rem;font-weight:600;margin-bottom:.5rem}.fx-input{width:100%;padding:.75rem 1rem;border-radius:.625rem;font-size:.9375rem;outline:none;transition:box-shadow .15s}.fx-input:focus{box-shadow:0 0 0 3px rgba(37,99,235,.35)}.fx-btn{width:100%;padding:.875rem 1.5rem;border:none;border-radius:.625rem;font-size:.9375rem;font-weight:700;cursor:pointer;margin-top:.5rem;transition:transform .15s,opacity .15s}.fx-btn:hover{transform:translateY(-1px)}.fx-btn:disabled{opacity:.5;cursor:not-allowed}.fx-form-msg{font-size:.8125rem;padding:.5rem 0;min-height:1.5rem;text-align:center}.fx-form-err{color:#f87171}.fx-form-ok{color:#4ade80}.fx-table-wrap{overflow-x:auto;padding:0 2.5rem 4rem}.fx-table{width:100%;border-collapse:collapse;font-size:.875rem}.fx-th{text-align:left;padding:.875rem 1.25rem;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.fx-th-actions{opacity:.6}.fx-tr{transition:background .1s}.fx-td{padding:.875rem 1.25rem}.fx-td-empty{padding:2rem 1.25rem;text-align:center;opacity:.4}.fx-td-actions{white-space:nowrap;padding:.5rem 1rem!important}.fx-action-btn{border:none;cursor:pointer;font-size:.75rem;font-weight:600;padding:.3rem .75rem;border-radius:.375rem;margin-right:.375rem;font-family:inherit}.fx-edit-btn{background:#1e40af;color:#93c5fd}.fx-delete-btn{background:#7f1d1d;color:#fca5a5}.fx-pricing{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1.5rem;padding:2rem 2.5rem 5rem;align-items:start}.fx-pricing-card{border-radius:1.25rem;padding:2rem;position:relative;transition:transform .2s}.fx-pricing-featured{transform:scale(1.03)}.fx-pricing-badge{position:absolute;top:-12px;left:50%;transform:translateX(-50%);background:#2563eb;color:#fff;font-size:.7rem;font-weight:700;padding:.25rem .875rem;border-radius:999px;white-space:nowrap}.fx-pricing-name{font-size:.875rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;margin-bottom:.5rem;opacity:.7}.fx-pricing-price{font-size:3rem;font-weight:900;letter-spacing:-.05em;line-height:1;margin-bottom:.75rem}.fx-pricing-desc{font-size:.875rem;line-height:1.65;margin-bottom:1.5rem;opacity:.7}.fx-pricing-cta{display:block;text-align:center;padding:.75rem;border-radius:.625rem;font-weight:700;font-size:.9rem}.fx-faq{max-width:48rem;margin:0 auto}.fx-faq-item{border-radius:.75rem;margin-bottom:.625rem;cursor:pointer;overflow:hidden}.fx-faq-q{display:flex;justify-content:space-between;align-items:center;padding:1rem 1.25rem;font-size:.9375rem;font-weight:600}.fx-faq-arrow{transition:transform .2s;font-size:.75rem;opacity:.5}.fx-faq-item.open .fx-faq-arrow{transform:rotate(90deg)}.fx-faq-a{max-height:0;overflow:hidden;padding:0 1.25rem;font-size:.875rem;line-height:1.7;transition:max-height .3s,padding .3s}.fx-faq-item.open .fx-faq-a{max-height:300px;padding:.75rem 1.25rem 1.25rem}.fx-testi-wrap{padding:5rem 2.5rem;display:flex;justify-content:center}.fx-testi{max-width:42rem;text-align:center;display:flex;flex-direction:column;align-items:center;gap:1.25rem}.fx-testi-img{width:64px;height:64px;border-radius:50%;object-fit:cover}.fx-testi-avatar{width:64px;height:64px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:1.5rem;font-weight:700;background:#1e293b}.fx-testi-quote{font-size:1.25rem;line-height:1.7;font-style:italic;opacity:.9}.fx-testi-author{font-size:.875rem;font-weight:600;opacity:.5}.fx-gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.75rem;padding:1rem 2.5rem 4rem}.fx-gallery-item{border-radius:.75rem;overflow:hidden;aspect-ratio:4/3}.fx-gallery-item img{width:100%;height:100%;object-fit:cover;transition:transform .3s}.fx-gallery-item:hover img{transform:scale(1.04)}.fx-if-wrap{display:contents}.fx-footer{padding:3rem 2.5rem;text-align:center}.fx-footer-text{font-size:.8125rem}.fx-footer-link{font-size:.8125rem;margin:0 .75rem;opacity:.5;transition:opacity .15s}.fx-footer-link:hover{opacity:1}@keyframes fx-fade-up{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:none}}@keyframes fx-blur-in{from{opacity:0;filter:blur(8px)}to{opacity:1;filter:blur(0)}}@keyframes fx-fade-in{from{opacity:0}to{opacity:1}}.fx-anim-fade-up{animation:fx-fade-up .6s cubic-bezier(.4,0,.2,1) both}.fx-anim-fade-in{animation:fx-fade-in .6s ease both}.fx-anim-blur-in{animation:fx-blur-in .7s ease both}.fx-anim-stagger>.fx-card:nth-child(1){animation:fx-fade-up .5s 0s both}.fx-anim-stagger>.fx-card:nth-child(2){animation:fx-fade-up .5s .1s both}.fx-anim-stagger>.fx-card:nth-child(3){animation:fx-fade-up .5s .2s both}.fx-anim-stagger>.fx-card:nth-child(4){animation:fx-fade-up .5s .3s both}.fx-anim-stagger>.fx-card:nth-child(5){animation:fx-fade-up .5s .4s both}.fx-anim-stagger>.fx-card:nth-child(6){animation:fx-fade-up .5s .5s both}`
850
+ const T={dark:`body{background:#030712;color:#f1f5f9}.fx-nav{border-bottom:1px solid #1e293b;background:rgba(3,7,18,.85)}.fx-nav-link{color:#cbd5e1}.fx-sub{color:#94a3b8}.fx-cta{background:#2563eb;color:#fff;box-shadow:0 8px 24px rgba(37,99,235,.35)}.fx-stat-lbl{color:#64748b}.fx-card{background:#0f172a;border:1px solid #1e293b}.fx-card:hover{box-shadow:0 20px 40px rgba(0,0,0,.5)}.fx-card-body{color:#64748b}.fx-sect-body{color:#64748b}.fx-form{background:#0f172a;border:1px solid #1e293b}.fx-label{color:#94a3b8}.fx-input{background:#020617;border:1px solid #1e293b;color:#f1f5f9}.fx-input::placeholder{color:#334155}.fx-btn{background:#2563eb;color:#fff}.fx-th{color:#475569;border-bottom:1px solid #1e293b}.fx-tr:hover{background:#0f172a}.fx-td{border-bottom:1px solid rgba(255,255,255,.03)}.fx-footer{border-top:1px solid #1e293b}.fx-footer-text{color:#334155}.fx-pricing-card{background:#0f172a;border:1px solid #1e293b}.fx-faq-item{background:#0f172a}`,light:`body{background:#fff;color:#0f172a}.fx-nav{border-bottom:1px solid #e2e8f0;background:rgba(255,255,255,.85)}.fx-cta{background:#2563eb;color:#fff}.fx-btn{background:#2563eb;color:#fff}.fx-card{background:#f8fafc;border:1px solid #e2e8f0}.fx-form{background:#f8fafc;border:1px solid #e2e8f0}.fx-input{background:#fff;border:1px solid #cbd5e1;color:#0f172a}.fx-th{color:#94a3b8;border-bottom:1px solid #e2e8f0}.fx-footer{border-top:1px solid #e2e8f0}.fx-pricing-card{background:#f8fafc;border:1px solid #e2e8f0}.fx-faq-item{background:#f8fafc}`}
1011
851
  return base+(T[theme]||T.dark)
1012
852
  }
1013
853
 
1014
- // ═══════════════════════════════════════════════════════════════
1015
- // MAIN ENTRY POINT
1016
- // ═══════════════════════════════════════════════════════════════
854
+ // ═══════════════════════════════════════════════════════════════════
855
+ // MAIN
856
+ // ═══════════════════════════════════════════════════════════════════
857
+ async function startServer(aipFile, port = 3000) {
858
+ const src = fs.readFileSync(aipFile, 'utf8')
859
+ const app = parseApp(src)
860
+ const srv = new AiplangServer()
1017
861
 
1018
- async function startServer(fluxFile, port = 3000) {
1019
- const src = fs.readFileSync(fluxFile, 'utf8')
1020
- const app = parseApp(src)
1021
- const srv = new AiplangServer()
1022
-
1023
- // Setup JWT
862
+ // Auth setup
1024
863
  if (app.auth) {
1025
- JWT_SECRET = resolveEnvValue(app.auth.secret) || JWT_SECRET
864
+ JWT_SECRET = resolveEnv(app.auth.secret) || JWT_SECRET
1026
865
  JWT_EXPIRE = app.auth.expire || '7d'
1027
866
  }
1028
867
 
1029
- // Setup DB
1030
- const dbFile = app.db ? resolveEnvValue(app.db.dsn) : ':memory:'
1031
- await getDB(dbFile === ':memory:' ? ':memory:' : dbFile)
868
+ // Mail setup
869
+ if (app.mail) setupMail(app.mail)
870
+
871
+ // DB setup
872
+ const dbFile = app.db ? resolveEnv(app.db.dsn) : ':memory:'
873
+ await getDB(dbFile)
1032
874
  console.log(`[aiplang] DB: ${dbFile}`)
1033
875
 
1034
- // Migrate models
1035
- console.log(`[aiplang] Migrations:`)
876
+ // Migrations
877
+ console.log(`[aiplang] Tables:`)
1036
878
  migrateModels(app.models)
1037
879
 
1038
- // Register models in server
1039
- for (const model of app.models) {
1040
- srv.registerModel(model.name)
1041
- }
880
+ // Register models
881
+ for (const m of app.models) srv.registerModel(m.name, { softDelete: m.softDelete, timestamps: true })
1042
882
 
1043
- // Register API routes
883
+ // Events
884
+ for (const ev of app.events) on(ev.event, (data) => console.log(`[aiplang:event] ${ev.event}:`, ev.action))
885
+
886
+ // Routes
1044
887
  for (const route of app.apis) {
1045
- compileAPIRoute(route, app, srv)
1046
- console.log(`[aiplang] Route: ${route.method} ${route.path}`)
888
+ compileRoute(route, srv)
889
+ console.log(`[aiplang] Route: ${route.method} ${route.path}${route.guards.length?' ['+route.guards.join('|')+']':''}`)
1047
890
  }
1048
891
 
1049
- // Register frontend pages
1050
- serveStaticFrontend(srv, app.pages)
1051
- for (const page of app.pages) {
1052
- console.log(`[aiplang] Page: GET ${page.route}`)
892
+ // Admin panel
893
+ if (app.admin) registerAdminPanel(srv, app.admin, app.models)
894
+
895
+ // Stripe
896
+ if (app.stripe) {
897
+ setupStripe(app.stripe)
898
+ registerStripeRoutes(srv, app.stripe)
899
+ // Add subscription guard to compileRoute
900
+ STRIPE_PLANS = app.stripe.plans || {}
1053
901
  }
1054
902
 
1055
- // Health check
1056
- srv.addRoute('GET', '/health', (req, res) => {
1057
- res.json(200, {
1058
- status: 'ok', version: '1.0.0',
1059
- models: app.models.map(m => m.name),
1060
- routes: app.apis.length, pages: app.pages.length
903
+ // Frontend
904
+ for (const page of app.pages) {
905
+ srv.addRoute('GET', page.route, (req, res) => {
906
+ const html = renderHTML(page, app.pages)
907
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html)
1061
908
  })
909
+ console.log(`[aiplang] Page: ${page.route}`)
910
+ }
911
+
912
+ // Static assets
913
+ srv.addRoute('GET', '/aiplang-hydrate.js', (req, res) => {
914
+ const p = path.join(__dirname, '..', 'runtime', 'aiplang-hydrate.js')
915
+ if (fs.existsSync(p)) { res.writeHead(200,{'Content-Type':'application/javascript'}); res.end(fs.readFileSync(p)) }
916
+ else { res.writeHead(404); res.end('// not found') }
1062
917
  })
1063
918
 
919
+ // Health
920
+ srv.addRoute('GET', '/health', (req, res) => res.json(200, {
921
+ status:'ok', version:'2.0.1',
922
+ models: app.models.map(m=>m.name),
923
+ routes: app.apis.length, pages: app.pages.length,
924
+ admin: app.admin?.prefix || null,
925
+ mail: !!app.mail, jobs: QUEUE.length
926
+ }))
927
+
1064
928
  srv.listen(port)
1065
929
  return srv
1066
930
  }
1067
931
 
1068
- function resolveEnvValue(val) {
1069
- if (!val) return val
1070
- if (val.startsWith('$')) return process.env[val.slice(1)] || val
1071
- return val
932
+ module.exports = { startServer, parseApp, Model, getDB, dispatch, on, sendMail }
933
+ if (require.main === module) {
934
+ const f=process.argv[2], p=parseInt(process.argv[3]||process.env.PORT||'3000')
935
+ if (!f) { console.error('Usage: node server.js <app.flux> [port]'); process.exit(1) }
936
+ startServer(f, p).catch(e=>{console.error(e);process.exit(1)})
937
+ }
938
+
939
+ // ═══════════════════════════════════════════════════════════════════
940
+ // STRIPE — Payments, Subscriptions, Webhooks
941
+ // ═══════════════════════════════════════════════════════════════════
942
+
943
+ let STRIPE = null
944
+ let STRIPE_CONFIG = null
945
+ let STRIPE_PLANS = {}
946
+
947
+ function setupStripe(config) {
948
+ STRIPE_CONFIG = config
949
+ const key = resolveEnv(config.key) || ''
950
+ // Use mock if key is placeholder, test/mock value, or SDK unavailable
951
+ const isMock = !key || key.startsWith('$') || key === 'sk_test_mock' || key.includes('mock')
952
+ if (isMock) {
953
+ console.log('[aiplang] Stripe: mock mode (set STRIPE_SECRET_KEY for real payments)')
954
+ STRIPE = null // will use mockStripe()
955
+ return
956
+ }
957
+ try {
958
+ const Stripe = require('stripe')
959
+ STRIPE = new Stripe(key, { apiVersion: '2024-06-20' })
960
+ console.log(`[aiplang] Stripe: live mode (${key.startsWith('sk_test') ? 'test key' : 'production key'})`)
961
+ } catch (e) {
962
+ console.log('[aiplang] Stripe: SDK error, using mock')
963
+ STRIPE = null
964
+ }
1072
965
  }
1073
966
 
1074
- module.exports = { startServer, parseApp, Model, getDB }
967
+ function mockStripe() {
968
+ // Mock Stripe for dev/test without real key
969
+ return {
970
+ customers: {
971
+ create: async (opts) => ({ id: 'cus_mock_' + uuid(), email: opts.email }),
972
+ retrieve: async (id) => ({ id, email: 'mock@test.com' })
973
+ },
974
+ checkout: {
975
+ sessions: {
976
+ create: async (opts) => ({
977
+ id: 'cs_mock_' + uuid(),
978
+ url: opts.success_url + '?session_id=mock',
979
+ payment_status: 'unpaid'
980
+ })
981
+ }
982
+ },
983
+ subscriptions: {
984
+ retrieve: async (id) => ({
985
+ id, status: 'active',
986
+ current_period_end: Math.floor(Date.now()/1000) + 86400*30,
987
+ items: { data: [{ price: { id: 'price_mock', nickname: 'Pro' } }] }
988
+ }),
989
+ cancel: async (id) => ({ id, status: 'canceled' })
990
+ },
991
+ billingPortal: {
992
+ sessions: {
993
+ create: async (opts) => ({ url: opts.return_url + '?portal=mock' })
994
+ }
995
+ },
996
+ webhooks: {
997
+ constructEvent: (body, sig, secret) => {
998
+ try { return JSON.parse(body) } catch { throw new Error('Invalid payload') }
999
+ }
1000
+ },
1001
+ prices: {
1002
+ list: async () => ({ data: [] })
1003
+ }
1004
+ }
1005
+ }
1075
1006
 
1076
- // Run if called directly
1077
- if (require.main === module) {
1078
- const fluxFile = process.argv[2]
1079
- const port = parseInt(process.argv[3] || process.env.PORT || '3000')
1080
- if (!fluxFile) { console.error('Usage: node server.js <app.flux> [port]'); process.exit(1) }
1081
- startServer(fluxFile, port).catch(e => { console.error(e); process.exit(1) })
1007
+ function getStripe() { return STRIPE || mockStripe() }
1008
+
1009
+ function registerStripeRoutes(server, stripeConfig) {
1010
+ const stripe = getStripe()
1011
+ const plans = stripeConfig.plans || {}
1012
+ const webhookSecret = resolveEnv(stripeConfig.webhookSecret || '$STRIPE_WEBHOOK_SECRET')
1013
+ const successUrl = stripeConfig.successUrl || `${process.env.APP_URL || 'http://localhost:3000'}/dashboard?payment=success`
1014
+ const cancelUrl = stripeConfig.cancelUrl || `${process.env.APP_URL || 'http://localhost:3000'}/pricing?payment=cancelled`
1015
+
1016
+ // ── POST /api/stripe/checkout ──────────────────────────────────
1017
+ // Creates a Stripe Checkout session
1018
+ // Body: { plan: 'pro', email: '...' } or uses logged-in user
1019
+ server.addRoute('POST', '/api/stripe/checkout', async (req, res) => {
1020
+ try {
1021
+ const plan = req.body.plan || req.body.price_id || Object.keys(plans)[0]
1022
+ const priceId = plans[plan] || plan // allow passing price_id directly
1023
+ const email = req.body.email || req.user?.email
1024
+
1025
+ if (!priceId) { res.error(400, 'Plan not found. Available: ' + Object.keys(plans).join(', ')); return }
1026
+
1027
+ // Get or create Stripe customer
1028
+ let customerId = null
1029
+ if (req.user?.id) {
1030
+ const userModel = Object.values(server.models).find(m => m.tableName === 'users')
1031
+ if (userModel) {
1032
+ const user = userModel.find(req.user.id)
1033
+ if (user?.stripe_customer_id) {
1034
+ customerId = user.stripe_customer_id
1035
+ } else {
1036
+ const customer = await stripe.customers.create({ email, metadata: { user_id: req.user.id } })
1037
+ customerId = customer.id
1038
+ if (userModel.find(req.user.id)) userModel.update(req.user.id, { stripe_customer_id: customerId })
1039
+ }
1040
+ }
1041
+ }
1042
+
1043
+ const sessionOpts = {
1044
+ mode: stripeConfig.mode === 'payment' ? 'payment' : 'subscription',
1045
+ line_items: [{ price: priceId, quantity: 1 }],
1046
+ success_url: successUrl + (successUrl.includes('?') ? '&' : '?') + 'session_id={CHECKOUT_SESSION_ID}',
1047
+ cancel_url: cancelUrl,
1048
+ allow_promotion_codes: true,
1049
+ }
1050
+ if (customerId) sessionOpts.customer = customerId
1051
+ else if (email) sessionOpts.customer_email = email
1052
+ if (req.body.trial_days) sessionOpts.subscription_data = { trial_period_days: parseInt(req.body.trial_days) }
1053
+ if (req.user?.id) sessionOpts.metadata = { user_id: req.user.id, plan }
1054
+
1055
+ const session = await stripe.checkout.sessions.create(sessionOpts)
1056
+ res.json(200, { url: session.url, session_id: session.id })
1057
+ } catch (e) {
1058
+ console.error('[aiplang:stripe] Checkout error:', e.message)
1059
+ res.error(500, e.message)
1060
+ }
1061
+ })
1062
+
1063
+ // ── POST /api/stripe/portal ────────────────────────────────────
1064
+ // Customer billing portal (manage subscription, invoices, card)
1065
+ server.addRoute('POST', '/api/stripe/portal', async (req, res) => {
1066
+ if (!req.user) { res.error(401, 'Unauthorized'); return }
1067
+ try {
1068
+ const userModel = Object.values(server.models).find(m => m.tableName === 'users')
1069
+ const user = userModel?.find(req.user.id)
1070
+ if (!user?.stripe_customer_id) { res.error(404, 'No billing account found'); return }
1071
+
1072
+ const session = await stripe.billingPortal.sessions.create({
1073
+ customer: user.stripe_customer_id,
1074
+ return_url: cancelUrl
1075
+ })
1076
+ res.json(200, { url: session.url })
1077
+ } catch (e) {
1078
+ res.error(500, e.message)
1079
+ }
1080
+ })
1081
+
1082
+ // ── GET /api/stripe/subscription ──────────────────────────────
1083
+ // Get current user's subscription status
1084
+ server.addRoute('GET', '/api/stripe/subscription', async (req, res) => {
1085
+ if (!req.user) { res.error(401, 'Unauthorized'); return }
1086
+ try {
1087
+ const userModel = Object.values(server.models).find(m => m.tableName === 'users')
1088
+ const user = userModel?.find(req.user.id)
1089
+ res.json(200, {
1090
+ plan: user?.plan || 'free',
1091
+ status: user?.subscription_status || 'inactive',
1092
+ customer_id: user?.stripe_customer_id || null,
1093
+ period_end: user?.subscription_period_end || null
1094
+ })
1095
+ } catch (e) { res.error(500, e.message) }
1096
+ })
1097
+
1098
+ // ── DELETE /api/stripe/subscription ───────────────────────────
1099
+ // Cancel subscription
1100
+ server.addRoute('DELETE', '/api/stripe/subscription', async (req, res) => {
1101
+ if (!req.user) { res.error(401, 'Unauthorized'); return }
1102
+ try {
1103
+ const userModel = Object.values(server.models).find(m => m.tableName === 'users')
1104
+ const user = userModel?.find(req.user.id)
1105
+ if (!user?.subscription_id) { res.error(404, 'No active subscription'); return }
1106
+ await stripe.subscriptions.cancel(user.subscription_id)
1107
+ userModel?.update(req.user.id, { subscription_status: 'canceled', plan: 'free' })
1108
+ res.json(200, { status: 'canceled' })
1109
+ } catch (e) { res.error(500, e.message) }
1110
+ })
1111
+
1112
+ // ── POST /api/stripe/webhook ───────────────────────────────────
1113
+ // Stripe webhook handler — updates user subscription state
1114
+ server.addRoute('POST', '/api/stripe/webhook', async (req, res) => {
1115
+ let event
1116
+ try {
1117
+ const rawBody = await getRawBody(req)
1118
+ const sig = req.headers['stripe-signature']
1119
+ if (webhookSecret && sig) {
1120
+ event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret)
1121
+ } else {
1122
+ event = JSON.parse(rawBody)
1123
+ }
1124
+ } catch (e) {
1125
+ res.error(400, 'Webhook error: ' + e.message); return
1126
+ }
1127
+
1128
+ const userModel = Object.values(server.models).find(m => m.tableName === 'users')
1129
+ const data = event.data?.object
1130
+
1131
+ switch (event.type) {
1132
+ case 'checkout.session.completed': {
1133
+ const userId = data.metadata?.user_id
1134
+ if (userId && userModel) {
1135
+ const planName = data.metadata?.plan || 'pro'
1136
+ userModel.update(userId, {
1137
+ plan: planName,
1138
+ subscription_status: 'active',
1139
+ stripe_customer_id: data.customer || undefined,
1140
+ subscription_id: data.subscription || undefined,
1141
+ })
1142
+ emit('stripe.checkout.completed', { userId, plan: planName, session: data.id })
1143
+ }
1144
+ break
1145
+ }
1146
+ case 'customer.subscription.updated': {
1147
+ const custId = data.customer
1148
+ if (custId && userModel) {
1149
+ const user = userModel.findBy('stripe_customer_id', custId)
1150
+ if (user) {
1151
+ const planItem = data.items?.data?.[0]?.price
1152
+ const planName = resolvePlanFromPrice(planItem?.id, plans)
1153
+ userModel.update(user.id, {
1154
+ subscription_status: data.status,
1155
+ plan: planName || user.plan,
1156
+ subscription_period_end: data.current_period_end
1157
+ ? new Date(data.current_period_end * 1000).toISOString() : undefined
1158
+ })
1159
+ emit('stripe.subscription.updated', { userId: user.id, status: data.status })
1160
+ }
1161
+ }
1162
+ break
1163
+ }
1164
+ case 'customer.subscription.deleted': {
1165
+ const custId = data.customer
1166
+ if (custId && userModel) {
1167
+ const user = userModel.findBy('stripe_customer_id', custId)
1168
+ if (user) {
1169
+ userModel.update(user.id, { subscription_status: 'canceled', plan: 'free' })
1170
+ emit('stripe.subscription.canceled', { userId: user.id })
1171
+ }
1172
+ }
1173
+ break
1174
+ }
1175
+ case 'invoice.payment_failed': {
1176
+ const custId = data.customer
1177
+ if (custId && userModel) {
1178
+ const user = userModel.findBy('stripe_customer_id', custId)
1179
+ if (user) {
1180
+ userModel.update(user.id, { subscription_status: 'past_due' })
1181
+ emit('stripe.payment.failed', { userId: user.id, amount: data.amount_due })
1182
+ }
1183
+ }
1184
+ break
1185
+ }
1186
+ case 'invoice.payment_succeeded': {
1187
+ const custId = data.customer
1188
+ if (custId && userModel) {
1189
+ const user = userModel.findBy('stripe_customer_id', custId)
1190
+ if (user && user.subscription_status === 'past_due') {
1191
+ userModel.update(user.id, { subscription_status: 'active' })
1192
+ }
1193
+ emit('stripe.payment.succeeded', { userId: user?.id, amount: data.amount_paid })
1194
+ }
1195
+ break
1196
+ }
1197
+ }
1198
+
1199
+ res.json(200, { received: true, type: event.type })
1200
+ })
1201
+
1202
+ console.log(`[aiplang] Stripe: /api/stripe/checkout | /api/stripe/portal | /api/stripe/webhook`)
1203
+ console.log(`[aiplang] Stripe: Plans: ${Object.keys(plans).join(', ') || 'none defined'}`)
1204
+ }
1205
+
1206
+ // ── Guard: ~guard subscription ────────────────────────────────────
1207
+ // Check if user has active subscription
1208
+ function checkSubscription(req, plan = null) {
1209
+ if (!req.user) return false
1210
+ const userModel = Object.values({}).find(m => m.tableName === 'users')
1211
+ // Simplified: check from JWT claims if we embed subscription_status
1212
+ if (req.user.subscription_status === 'active') return true
1213
+ if (req.user.plan && req.user.plan !== 'free') return true
1214
+ return false
1215
+ }
1216
+
1217
+ function resolvePlanFromPrice(priceId, plans) {
1218
+ for (const [name, id] of Object.entries(plans)) {
1219
+ if (id === priceId) return name
1220
+ }
1221
+ return null
1082
1222
  }
1223
+
1224
+ async function getRawBody(req) {
1225
+ return new Promise((resolve, reject) => {
1226
+ let data = ''
1227
+ req.on('data', chunk => data += chunk)
1228
+ req.on('end', () => resolve(data))
1229
+ req.on('error', reject)
1230
+ })
1231
+ }
1232
+
1233
+ module.exports.setupStripe = setupStripe
1234
+ module.exports.registerStripeRoutes = registerStripeRoutes