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.
- package/README.md +86 -151
- package/aiplang-knowledge.md +172 -129
- package/bin/aiplang.js +4 -3
- package/package.json +3 -2
- package/runtime/aiplang-hydrate.js +1 -1
- package/server/node_modules/.package-lock.json +9 -0
- package/server/node_modules/nodemailer/.gitattributes +6 -0
- package/server/node_modules/nodemailer/.ncurc.js +9 -0
- package/server/node_modules/nodemailer/.prettierignore +8 -0
- package/server/node_modules/nodemailer/.prettierrc +12 -0
- package/server/node_modules/nodemailer/.prettierrc.js +10 -0
- package/server/node_modules/nodemailer/.release-please-config.json +9 -0
- package/server/node_modules/nodemailer/CHANGELOG.md +976 -0
- package/server/node_modules/nodemailer/CODE_OF_CONDUCT.md +76 -0
- package/server/node_modules/nodemailer/LICENSE +16 -0
- package/server/node_modules/nodemailer/README.md +86 -0
- package/server/node_modules/nodemailer/SECURITY.txt +22 -0
- package/server/node_modules/nodemailer/eslint.config.js +88 -0
- package/server/node_modules/nodemailer/lib/addressparser/index.js +382 -0
- package/server/node_modules/nodemailer/lib/base64/index.js +140 -0
- package/server/node_modules/nodemailer/lib/dkim/index.js +245 -0
- package/server/node_modules/nodemailer/lib/dkim/message-parser.js +154 -0
- package/server/node_modules/nodemailer/lib/dkim/relaxed-body.js +154 -0
- package/server/node_modules/nodemailer/lib/dkim/sign.js +116 -0
- package/server/node_modules/nodemailer/lib/errors.js +58 -0
- package/server/node_modules/nodemailer/lib/fetch/cookies.js +276 -0
- package/server/node_modules/nodemailer/lib/fetch/index.js +278 -0
- package/server/node_modules/nodemailer/lib/json-transport/index.js +82 -0
- package/server/node_modules/nodemailer/lib/mail-composer/index.js +599 -0
- package/server/node_modules/nodemailer/lib/mailer/index.js +446 -0
- package/server/node_modules/nodemailer/lib/mailer/mail-message.js +312 -0
- package/server/node_modules/nodemailer/lib/mime-funcs/index.js +610 -0
- package/server/node_modules/nodemailer/lib/mime-funcs/mime-types.js +2109 -0
- package/server/node_modules/nodemailer/lib/mime-node/index.js +1334 -0
- package/server/node_modules/nodemailer/lib/mime-node/last-newline.js +33 -0
- package/server/node_modules/nodemailer/lib/mime-node/le-unix.js +40 -0
- package/server/node_modules/nodemailer/lib/mime-node/le-windows.js +49 -0
- package/server/node_modules/nodemailer/lib/nodemailer.js +151 -0
- package/server/node_modules/nodemailer/lib/punycode/index.js +460 -0
- package/server/node_modules/nodemailer/lib/qp/index.js +230 -0
- package/server/node_modules/nodemailer/lib/sendmail-transport/index.js +205 -0
- package/server/node_modules/nodemailer/lib/ses-transport/index.js +223 -0
- package/server/node_modules/nodemailer/lib/shared/index.js +698 -0
- package/server/node_modules/nodemailer/lib/smtp-connection/data-stream.js +105 -0
- package/server/node_modules/nodemailer/lib/smtp-connection/http-proxy-client.js +144 -0
- package/server/node_modules/nodemailer/lib/smtp-connection/index.js +1903 -0
- package/server/node_modules/nodemailer/lib/smtp-pool/index.js +641 -0
- package/server/node_modules/nodemailer/lib/smtp-pool/pool-resource.js +256 -0
- package/server/node_modules/nodemailer/lib/smtp-transport/index.js +402 -0
- package/server/node_modules/nodemailer/lib/stream-transport/index.js +135 -0
- package/server/node_modules/nodemailer/lib/well-known/index.js +47 -0
- package/server/node_modules/nodemailer/lib/well-known/services.json +619 -0
- package/server/node_modules/nodemailer/lib/xoauth2/index.js +436 -0
- package/server/node_modules/nodemailer/package.json +48 -0
- package/server/server.js +1008 -856
- package/bin/flux.js +0 -572
- package/runtime/flux-hydrate.js +0 -473
- 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
|
-
//
|
|
4
|
-
|
|
5
|
-
const http
|
|
6
|
-
const fs
|
|
7
|
-
const path
|
|
8
|
-
const url
|
|
9
|
-
const crypto
|
|
10
|
-
const bcrypt
|
|
11
|
-
const jwt
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
97
|
+
return MAIL_TRANSPORTER.sendMail({
|
|
98
|
+
from: MAIL_CONFIG?.from || 'noreply@aiplang.app',
|
|
99
|
+
...opts
|
|
100
|
+
})
|
|
237
101
|
}
|
|
238
102
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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.
|
|
314
|
-
this.
|
|
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 (
|
|
321
|
-
if (opts.
|
|
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) {
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const vals = Object.values(row)
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
360
|
-
delete
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
this.
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
if (rule
|
|
517
|
-
|
|
518
|
-
}
|
|
519
|
-
if (rule.startsWith('
|
|
520
|
-
|
|
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
|
|
436
|
+
// Execute ops
|
|
548
437
|
for (const op of route.body) {
|
|
549
438
|
const result = await execOp(op, ctx, server)
|
|
550
|
-
if (result === '
|
|
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
|
-
|
|
557
|
-
|
|
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 |
|
|
453
|
+
// ~check password plain hashed | status
|
|
575
454
|
if (line.startsWith('~check ')) {
|
|
576
|
-
const
|
|
577
|
-
const plain
|
|
578
|
-
const
|
|
579
|
-
|
|
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
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
|
601
|
-
const varName
|
|
602
|
-
|
|
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
|
|
610
|
-
|
|
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
|
|
622
|
-
|
|
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
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
|
527
|
+
// return expr status
|
|
644
528
|
if (line.startsWith('return ')) {
|
|
645
|
-
const
|
|
646
|
-
const
|
|
647
|
-
const
|
|
648
|
-
let result
|
|
649
|
-
if
|
|
650
|
-
ctx.res.json(status,
|
|
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
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
if (expr.
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
if (expr.includes('.
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
734
|
-
|
|
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
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
//
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
//
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
816
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
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
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
const
|
|
861
|
-
return`<div class="
|
|
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
|
-
|
|
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
|
-
|
|
867
|
-
const
|
|
868
|
-
const
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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,'"')+'">'+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
|
-
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
const
|
|
912
|
-
|
|
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
|
-
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
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
|
-
|
|
953
|
-
|
|
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
|
-
|
|
957
|
-
|
|
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
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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((
|
|
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
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
|
1005
|
-
|
|
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
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
864
|
+
JWT_SECRET = resolveEnv(app.auth.secret) || JWT_SECRET
|
|
1026
865
|
JWT_EXPIRE = app.auth.expire || '7d'
|
|
1027
866
|
}
|
|
1028
867
|
|
|
1029
|
-
//
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
//
|
|
1035
|
-
console.log(`[aiplang]
|
|
876
|
+
// Migrations
|
|
877
|
+
console.log(`[aiplang] Tables:`)
|
|
1036
878
|
migrateModels(app.models)
|
|
1037
879
|
|
|
1038
|
-
// Register models
|
|
1039
|
-
for (const
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
-
//
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
const
|
|
1080
|
-
|
|
1081
|
-
|
|
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
|