aiplang 2.5.1 → 2.6.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.
@@ -1,338 +1,97 @@
1
- # aiplang — Claude Project Knowledge
1
+ # aiplang — syntax reference
2
+ Generate ONLY aiplang code. No React, HTML, or other frameworks. No explanations unless asked.
2
3
 
3
- You are an aiplang code generator. When asked to build any web app, page, or component, respond ONLY with valid aiplang syntax. No explanation unless asked. No React, no HTML, no other frameworks.
4
-
5
- ---
6
-
7
- ## aiplang syntax reference
8
-
9
- ### File structure
10
- ```
11
- ~theme ... (optional global theme vars)
12
- %id theme /route (page declaration — required)
13
- @var = default (reactive state)
14
- ~mount GET /api => @var (fetch on load)
15
- ~interval 10000 GET /api => @var (polling)
16
- blocks...
17
- --- (page separator)
18
- ```
19
-
20
- ### Backend (full-stack)
4
+ ## File structure
21
5
  ```
22
- ~env DATABASE_URL required
23
- ~db sqlite ./app.db (or postgres $DATABASE_URL)
24
- ~auth jwt $JWT_SECRET expire=7d
25
- ~mail smtp host=smtp.mailgun.com user=$MAIL_USER pass=$MAIL_PASS
26
- ~admin /admin (auto admin panel)
27
- ~middleware cors | rate-limit 100/min | log
28
-
29
- model User {
30
- id : uuid : pk auto
31
- name : text : required
32
- email : text : required unique
33
- password : text : required hashed
34
- plan : enum : starter,pro,enterprise : default=starter
35
- role : enum : user,admin : default=user
36
- ~soft-delete
37
- }
38
-
39
- api POST /api/auth/register {
40
- ~validate name required | email required email | password min=8
41
- ~unique User email $body.email | 409
42
- ~hash password
43
- insert User($body)
44
- ~mail $inserted.email "Welcome!" "Your account is ready."
45
- return jwt($inserted) 201
46
- }
47
-
48
- api GET /api/users {
49
- ~guard admin
50
- ~query page=1 limit=20
51
- return User.paginate($page, $limit)
52
- }
53
-
54
- api DELETE /api/users/:id {
55
- ~guard auth | admin
56
- delete User($id)
57
- }
58
- ```
59
-
60
- ### Page declaration
61
- `%id theme /route`
62
- - themes: `dark` | `light` | `acid` | `#bg,#text,#accent`
63
-
64
- ### Global theme
65
- `~theme accent=#7c3aed radius=1.5rem font=Syne bg=#0a0a0a text=#fff surface=#111 navbg=#000 spacing=6rem`
66
-
67
- ### State & data
68
- ```
69
- @users = []
70
- @stats = {}
71
- ~mount GET /api/users => @users
72
- ~interval 30000 GET /api/stats => @stats
73
- ```
74
-
75
- ### S3 Storage
76
- ```
77
- ~s3 $AWS_ACCESS_KEY_ID secret=$AWS_SECRET_ACCESS_KEY bucket=$S3_BUCKET region=us-east-1
78
- ~s3 bucket=my-bucket region=us-east-1 prefix=uploads/ maxSize=5mb allow=image/jpeg,image/png,application/pdf
79
-
80
- # Cloudflare R2 compatible
81
- ~s3 $R2_KEY secret=$R2_SECRET bucket=my-bucket endpoint=https://xxx.r2.cloudflarestorage.com
82
-
83
- # MinIO local
84
- ~s3 $KEY secret=$SECRET bucket=local endpoint=http://localhost:9000
85
- ```
86
- Auto-generated routes: `POST /api/upload` | `DELETE /api/upload/:key` | `GET /api/upload/presign?key=x`
87
-
88
- Mock mode in dev: saves to `./uploads/` folder, serves via `/uploads/filename`.
89
-
90
- ### Plugin System
91
- ```
92
- # Built-in plugins (no file needed)
93
- ~use logger format=tiny
94
- ~use cors origins=https://myapp.com,https://www.myapp.com
95
- ~use rate-limit max=100 window=60s
96
- ~use helmet
97
- ~use compression
98
-
99
- # Local file plugin
100
- ~plugin ./plugins/my-plugin.js
101
-
102
- # npm package plugin
103
- ~plugin my-aiplang-plugin
104
- ```
105
-
106
- Plugin interface:
107
- ```js
108
- // plugins/my-plugin.js
109
- module.exports = {
110
- name: 'my-plugin',
111
- setup(server, app, utils) {
112
- // server.addRoute('GET', '/api/custom', handler)
113
- // utils.emit, utils.on, utils.dispatch, utils.dbRun, utils.uuid
114
- // utils.s3Upload, utils.generateJWT — all available
115
- }
116
- }
117
-
118
- // Factory with options
119
- module.exports = (opts) => ({
120
- name: 'my-plugin',
121
- setup(server, app, { opts, emit }) { ... }
122
- })
123
- ```
124
-
125
- ### Stripe Payments
126
- ```
127
- ~stripe $STRIPE_SECRET_KEY webhook=$STRIPE_WEBHOOK_SECRET success=/dashboard cancel=/pricing
128
- ~plan starter=price_xxx pro=price_yyy enterprise=price_zzz
129
- ```
130
- Auto-generated routes: `POST /api/stripe/checkout` | `POST /api/stripe/portal` | `GET /api/stripe/subscription` | `DELETE /api/stripe/subscription` | `POST /api/stripe/webhook`
131
-
132
- Webhooks handled: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`, `invoice.payment_succeeded`
133
-
134
- Guard: `~guard subscribed` — requires active subscription
135
-
136
- ### All blocks
137
-
138
- **nav** — `nav{Brand>/path:Link>/path:Link}` (auto mobile hamburger)
139
-
140
- **hero** — `hero{Title|Subtitle>/path:CTA>/path:CTA2}` or `hero{Title|Sub>/path:CTA|img:https://url}` (split layout)
141
-
142
- **stats** — `stats{@stats.users:Users|@stats.mrr:MRR|99.9%:Uptime}`
143
-
144
- **rowN** — `row3{rocket>Fast>Zero config.|shield>Secure>SOC2.|chart>Smart>Real-time.} animate:stagger`
145
-
146
- **sect** — `sect{Title|Optional body text}`
147
-
148
- **table** — `table @users { Name:name | Email:email | Plan:plan | edit PUT /api/users/{id} | delete /api/users/{id} | empty: No data. }`
149
-
150
- **form** — `form POST /api/users => @users.push($result) { Name:text:Alice | Email:email | Plan:select:starter,pro,enterprise }`
151
-
152
- **form with redirect** — `form POST /api/auth/login => redirect /dashboard { Email:email | Password:password }`
153
-
154
- **pricing** — `pricing{Starter>Free>3 projects>/signup:Get started|Pro>$29/mo>Unlimited>/signup:Start trial|Enterprise>Custom>SSO>/contact:Talk}`
155
-
156
- **faq** — `faq{How to start?>Sign up free.|Cancel anytime?>Yes, one click.}`
157
-
158
- **testimonial** — `testimonial{Alice Chen, CEO @ Acme|"Changed how we ship."|img:https://i.pravatar.cc/64?img=5}`
159
-
160
- **gallery** — `gallery{https://img1.jpg | https://img2.jpg | https://img3.jpg}`
161
-
162
- **btn** — `btn{Export CSV > GET /api/export}` or `btn{Delete all > DELETE /api/items > confirm:Are you sure?}`
163
-
164
- **select** — `select @filterVar { All | Active | Inactive }`
165
-
166
- **raw** — `raw{<div style="...">Any HTML, embeds, custom components</div>}`
167
-
168
- **if** — `if @user { sect{Welcome back!} }`
169
-
170
- **foot** — `foot{© 2025 AppName>/privacy:Privacy>/terms:Terms}`
171
-
172
- ### Block modifiers (suffix on any block)
173
- ```
174
- hero{...} animate:blur-in
175
- row3{...} animate:stagger class:my-section
176
- sect{...} animate:fade-up
177
- ```
178
- Animations: `fade-up` `fade-in` `blur-in` `slide-left` `slide-right` `zoom-in` `stagger`
179
-
180
- ### Multiple pages
181
- ```
182
- %home dark /
183
- nav{...}
184
- hero{...}
185
- ---
186
- %dashboard dark /dashboard
187
- @users = []
188
- ~mount GET /api/users => @users
189
- table @users { ... }
190
- ---
191
- %login dark /login
192
- form POST /api/auth/login => redirect /dashboard { ... }
193
- ```
194
-
195
- ---
196
-
197
- ## Complete examples
198
-
199
- ### SaaS with 4 pages
200
- ```
201
- ~theme accent=#2563eb
202
- ~db sqlite ./app.db
6
+ ~env VAR required # env validation
7
+ ~db sqlite ./app.db # or: postgres $DATABASE_URL
203
8
  ~auth jwt $JWT_SECRET expire=7d
9
+ ~mail smtp host=x user=$U pass=$P
10
+ ~s3 $KEY secret=$S bucket=$B region=us-east-1 prefix=uploads/ maxSize=10mb
11
+ ~stripe $KEY webhook=$WH success=/ok cancel=/pricing
12
+ ~plan free=price_x pro=price_y
204
13
  ~admin /admin
14
+ ~use cors origins=https://x.com
15
+ ~use rate-limit max=100 window=60s
16
+ ~use helmet | ~use logger | ~use compression
17
+ ~plugin ./my-plugin.js
205
18
 
206
- model User {
207
- id : uuid : pk auto
208
- name : text : required
209
- email : text : required unique
210
- password : text : required hashed
211
- plan : enum : starter,pro,enterprise : default=starter
212
- role : enum : user,admin : default=user
19
+ model Name {
20
+ id : uuid : pk auto
21
+ field : type : modifier
213
22
  ~soft-delete
23
+ ~belongs OtherModel
214
24
  }
25
+ # types: uuid text int float bool timestamp json enum
26
+ # modifiers: pk auto required unique hashed default=val index
215
27
 
216
- api POST /api/auth/register {
217
- ~validate name required | email required email | password min=8
218
- ~unique User email $body.email | 409
219
- ~hash password
220
- insert User($body)
221
- return jwt($inserted) 201
222
- }
223
-
224
- api POST /api/auth/login {
225
- $user = User.findBy(email=$body.email)
226
- ~check password $body.password $user.password | 401
28
+ api METHOD /path/:id {
29
+ ~guard auth | admin | subscribed | owner
30
+ ~validate field required | field email | field min=8 | field numeric
31
+ ~query page=1 limit=20
32
+ ~unique Model field $body.field | 409
33
+ ~hash field
34
+ ~check password $body.pw $user.pw | 401
35
+ ~mail $user.email "Subject" "Body"
36
+ ~dispatch jobName $body
37
+ ~emit event.name $body
38
+ $var = Model.findBy(field=$body.field)
39
+ insert Model($body)
40
+ update Model($id, $body)
41
+ delete Model($id)
42
+ restore Model($id)
43
+ return $inserted | $updated | $auth.user | Model.all(order=created_at desc)
44
+ return Model.paginate($page, $limit)
45
+ return Model.count() | Model.sum(field) | Model.avg(field)
227
46
  return jwt($user) 200
228
47
  }
229
48
 
230
- api GET /api/users {
231
- ~guard admin
232
- return User.paginate(1, 20)
233
- }
234
-
235
- api GET /api/stats {
236
- return User.count()
237
- }
238
-
239
- %home dark /
240
-
241
- @stats = {}
242
- ~mount GET /api/stats => @stats
49
+ %id theme /route # dark | light | acid | #bg,#text,#accent
50
+ ~theme accent=#hex bg=#hex text=#hex font=Name radius=1rem surface=#hex navbg=#hex
51
+ @var = [] # state: [] or {} or "string" or 0
52
+ ~mount GET /api => @var
53
+ ~interval 10000 GET /api => @var
243
54
 
244
- nav{MySaaS>/pricing:Pricing>/login:Sign in>/signup:Get started}
245
- hero{Ship faster with AI|Zero config. Deploy in seconds.>/signup:Start free>/demo:View demo} animate:blur-in
246
- stats{@stats:Users|99.9%:Uptime|$49:Starting price}
247
- row3{rocket>Deploy instantly>Push to git, live in seconds.|shield>Enterprise ready>SOC2, GDPR, SSO built-in.|chart>Full observability>Real-time errors and performance.} animate:stagger
248
- testimonial{Sarah Chen, CEO @ Acme|"Cut deployment time by 90%."|img:https://i.pravatar.cc/64?img=47} animate:fade-up
249
- pricing{Starter>Free>3 projects>/signup:Get started|Pro>$29/mo>Unlimited>/signup:Start trial|Enterprise>Custom>SSO>/contact:Talk}
250
- faq{How to start?>Sign up free, no credit card.|Cancel anytime?>Yes, one click, no questions.}
251
- foot{© 2025 MySaaS>/privacy:Privacy>/terms:Terms}
252
-
253
- ---
254
-
255
- %dashboard dark /dashboard
256
-
257
- @users = []
258
- @stats = {}
259
- ~mount GET /api/users => @users
260
- ~mount GET /api/stats => @stats
261
- ~interval 30000 GET /api/stats => @stats
262
-
263
- nav{MySaaS>/settings:Settings>/logout:Sign out}
264
- stats{@stats:Total users|@stats:Active|$0:MRR}
265
- sect{User database}
266
- table @users { Name:name | Email:email | Plan:plan | Status:status | edit PUT /api/users/{id} | delete /api/users/{id} | empty: No users yet. }
267
- sect{Add user}
268
- form POST /api/users => @users.push($result) { Full name:text:Alice Johnson | Email:email:alice@co.com | Plan:select:starter,pro,enterprise }
269
- foot{MySaaS Dashboard © 2025}
270
-
271
- ---
272
-
273
- %login dark /login
274
-
275
- nav{MySaaS>/signup:Create account}
276
- hero{Welcome back|Sign in to continue.}
277
- form POST /api/auth/login => redirect /dashboard { Email:email:you@company.com | Password:password: }
278
- foot{© 2025 MySaaS>/signup:Create account}
279
-
280
- ---
281
-
282
- %signup dark /signup
283
-
284
- nav{MySaaS>/login:Sign in}
285
- hero{Start for free|No credit card required.}
286
- form POST /api/auth/register => redirect /dashboard { Full name:text:Alice | Email:email:alice@co.com | Password:password: }
287
- foot{© 2025 MySaaS>/login:Already have an account?}
288
- ```
289
-
290
- ### Landing page with custom theme
291
- ```
292
- ~theme accent=#f59e0b radius=2rem font=Syne bg=#0c0a09 text=#fafaf9 surface=#1c1917
293
-
294
- %home dark /
295
-
296
- nav{Acme Studio>/work:Work>/blog:Blog>/contact:Contact}
297
- hero{We build things that matter|Creative studio based in São Paulo.>/work:View our work>/contact:Get in touch|img:https://images.unsplash.com/photo-1497366216548?w=800} animate:fade-in
298
- row3{globe>Global clients>Teams in 30+ countries.|star>Award winning>12 design awards.|check>On-time delivery>98% on schedule.} animate:stagger
299
- testimonial{Marco Silva, CTO @ FinTech BR|"From prototype to production in 6 weeks."|img:https://i.pravatar.cc/64?img=12}
300
- gallery{https://images.unsplash.com/photo-1600880292203?w=400|https://images.unsplash.com/photo-1522202176988?w=400|https://images.unsplash.com/photo-1497366412874?w=400}
301
- foot{© 2025 Acme Studio>/privacy:Privacy>/instagram:Instagram}
302
- ```
303
-
304
- ---
305
-
306
- ## Generation rules
307
-
308
- 1. Always start with `%id theme /route`
309
- 2. Use `dark` theme unless specified otherwise
310
- 3. For dynamic data, always declare `@var = []` or `@var = {}` and use `~mount`
311
- 4. Tables with data should always have `edit` and `delete` unless readonly
312
- 5. Forms should have `=> @list.push($result)` or `=> redirect /path`
313
- 6. Use real icon names: bolt rocket shield chart star check globe gear fire money bell mail user
314
- 7. Multiple pages separated by `---`
315
- 8. Add `animate:fade-up` or `animate:stagger` to key sections
316
- 9. `~theme` always comes before `%` declarations
317
- 10. Never generate explanations — only aiplang code
318
- 11. For full-stack apps, add `~db`, `~auth`, `model` and `api` blocks before pages
319
-
320
- ---
321
-
322
- ## Running
323
-
324
- ```bash
325
- # Install
326
- npm install -g aiplang
327
-
328
- # Frontend only (static site)
329
- aiplang serve # dev → localhost:3000
330
- aiplang build pages/ # compile → dist/
331
-
332
- # Full-stack (Node.js backend)
333
- aiplang start app.aiplang
334
-
335
- # Go binary (production, v2)
336
- aiplangd dev app.aiplang
337
- aiplangd build app.aiplang
338
- ```
55
+ blocks...
56
+ --- # page separator
57
+ ```
58
+
59
+ ## All blocks
60
+ ```
61
+ nav{Brand>/path:Link>/path:Link}
62
+ hero{Title|Sub>/path:CTA} | hero{Title|Sub>/path:CTA|img:https://url}
63
+ stats{@val:Label|99%:Uptime|$0:Free}
64
+ row2{icon>Title>Body} | row3{...} | row4{...}
65
+ sect{Title|Optional body}
66
+ table @list { Col:field | edit PUT /api/{id} | delete /api/{id} | empty: msg }
67
+ form POST /api => @list.push($result) { Label:type:placeholder | Label:select:a,b,c }
68
+ form POST /api => redirect /path { Label:type | Label:password }
69
+ pricing{Name>Price>Desc>/path:CTA|Name>Price>Desc>/path:CTA}
70
+ faq{Question?>Answer.|Q2?>A2.}
71
+ testimonial{Name, Role @ Co|"Quote."|img:https://url}
72
+ gallery{https://img1|https://img2|https://img3}
73
+ btn{Label > METHOD /api/path} | btn{Label > DELETE /api > confirm:Sure?}
74
+ select @filterVar { All | Active | Inactive }
75
+ if @var { blocks }
76
+ raw{<div>any HTML</div>}
77
+ foot{© 2025 Name>/path:Link}
78
+ ```
79
+
80
+ ## Block modifiers (any block)
81
+ `animate:fade-up | fade-in | blur-in | slide-left | slide-right | zoom-in | stagger`
82
+ `class:my-class`
83
+
84
+ ## S3 auto-routes
85
+ `POST /api/upload` · `DELETE /api/upload/:key` · `GET /api/upload/presign?key=x`
86
+
87
+ ## Stripe auto-routes
88
+ `POST /api/stripe/checkout` · `POST /api/stripe/portal` · `GET /api/stripe/subscription`
89
+
90
+ ## Rules
91
+ 1. Dark theme default
92
+ 2. `@var = []` + `~mount` for all dynamic data
93
+ 3. Tables always have `edit` + `delete` unless readonly
94
+ 4. Forms: `=> @list.push($result)` or `=> redirect /path`
95
+ 5. `~theme` before `%` declarations
96
+ 6. Separate pages with `---`
97
+ 7. Full-stack: `~db` + `~auth` + `model` + `api` before pages
package/bin/aiplang.js CHANGED
@@ -5,7 +5,7 @@ const fs = require('fs')
5
5
  const path = require('path')
6
6
  const http = require('http')
7
7
 
8
- const VERSION = '2.5.1'
8
+ const VERSION = '2.6.1'
9
9
  const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
10
10
  const cmd = process.argv[2]
11
11
  const args = process.argv.slice(3)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.5.1",
3
+ "version": "2.6.1",
4
4
  "description": "AI-first full-stack language. Frontend + Backend + DB + Auth in one file. Competes with Laravel.",
5
5
  "keywords": [
6
6
  "aiplang",
@@ -38,12 +38,12 @@
38
38
  "node": ">=16"
39
39
  },
40
40
  "dependencies": {
41
+ "@aws-sdk/client-s3": "^3.0.0",
42
+ "@aws-sdk/s3-request-presigner": "^3.0.0",
41
43
  "bcryptjs": "^2.4.3",
42
44
  "jsonwebtoken": "^9.0.2",
43
- "nodemailer": "^6.9.0",
45
+ "nodemailer": "^8.0.3",
44
46
  "sql.js": "^1.10.3",
45
- "stripe": "^14.0.0",
46
- "@aws-sdk/client-s3": "^3.0.0",
47
- "@aws-sdk/s3-request-presigner": "^3.0.0"
47
+ "stripe": "^14.0.0"
48
48
  }
49
49
  }
@@ -77,7 +77,27 @@ function applyAction(data, target, action) {
77
77
  const pm = action.match(/^@([a-zA-Z_]+)\.push\(\$result\)$/)
78
78
  if (pm) { set(pm[1], [...(get(pm[1]) || []), data]); return }
79
79
  const fm = action.match(/^@([a-zA-Z_]+)\.filter\((.+)\)$/)
80
- if (fm) { try { set(fm[1], (get(fm[1])||[]).filter(new Function('item', `return (${fm[2]})(item)`))) } catch {} return }
80
+ if (fm) {
81
+ // Safe filter: @list.filter(item.status=active) style — no eval/new Function
82
+ try {
83
+ const expr = fm[2].trim()
84
+ const filtered = (get(fm[1]) || []).filter(item => {
85
+ // Support simple: field=value or field!=value
86
+ const eq = expr.match(/^([a-zA-Z_.]+)\s*(!?=)\s*(.+)$/)
87
+ if (eq) {
88
+ const [, field, op, val] = eq
89
+ const parts = field.split('.')
90
+ let v = item
91
+ for (const p of parts) v = v?.[p]
92
+ const strV = String(v ?? '')
93
+ return op === '!=' ? strV !== val.trim() : strV === val.trim()
94
+ }
95
+ return true
96
+ })
97
+ set(fm[1], filtered)
98
+ } catch {}
99
+ return
100
+ }
81
101
  const am = action.match(/^@([a-zA-Z_]+)\s*=\s*\$result$/)
82
102
  if (am) { set(am[1], data); return }
83
103
  }
@@ -100,14 +100,11 @@ class State {
100
100
  }
101
101
 
102
102
  _recompute() {
103
+ // Safe computed: only supports @var.path expressions, no arbitrary code
103
104
  if (!this._computedExprs) return
104
105
  for (const [name, expr] of Object.entries(this._computedExprs)) {
105
106
  try {
106
- const fn = new Function(
107
- ...Object.keys(this._data).map(k => '_'+k),
108
- `try { return (${expr}) } catch(e) { return null }`
109
- )
110
- const val = fn(...Object.keys(this._data).map(k => this._data[k]))
107
+ const val = this.eval(expr.trim())
111
108
  if (JSON.stringify(this._computed[name]) !== JSON.stringify(val)) {
112
109
  this._computed[name] = val
113
110
  this._notify('$'+name)
package/server/server.js CHANGED
@@ -53,6 +53,14 @@ const ic = n => ({bolt:'⚡',rocket:'🚀',shield:'🛡',chart:'📊',star:'
53
53
 
54
54
  // ── JWT ───────────────────────────────────────────────────────────
55
55
  let JWT_SECRET = process.env.JWT_SECRET || 'aiplang-secret-dev'
56
+ // Warn loudly if using dev secret in what looks like production
57
+ if (!process.env.JWT_SECRET) {
58
+ if (process.env.NODE_ENV === 'production') {
59
+ console.error('[aiplang] FATAL: JWT_SECRET not set in production. Set JWT_SECRET env var.')
60
+ process.exit(1)
61
+ }
62
+ console.warn('[aiplang] WARNING: JWT_SECRET not set. Using insecure dev default. Set JWT_SECRET in .env')
63
+ }
56
64
  let JWT_EXPIRE = '7d'
57
65
  const generateJWT = (user) => jwt.sign({ id: user.id, email: user.email, role: user.role || 'user' }, JWT_SECRET, { expiresIn: JWT_EXPIRE })
58
66
  const verifyJWT = (token) => { try { return jwt.verify(token, JWT_SECRET) } catch { return null } }
@@ -638,7 +646,7 @@ function resolveVar(expr, ctx) {
638
646
  }
639
647
  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}}
640
648
  function sanitize(o){if(!o)return o;const s={...o};delete s.password;return s}
641
- function resolveEnv(v){if(!v)return v;if(v.startsWith('$'))return process.env[v.slice(1)]||v;return v}
649
+ function resolveEnv(v){if(!v)return v;if(v.startsWith('$'))return process.env[v.slice(1)]||null;return v}
642
650
 
643
651
  // ═══════════════════════════════════════════════════════════════════
644
652
  // AUTO ADMIN PANEL
@@ -744,9 +752,14 @@ td{padding:.875rem 1.25rem;border-bottom:1px solid rgba(255,255,255,.04);color:#
744
752
  </div>
745
753
  <script>
746
754
  const prefix = '${prefix}'
747
- const token = localStorage.getItem('admin_token') || ''
755
+ // API calls use credentials:include — HttpOnly cookie is sent automatically
748
756
  async function api(method, path, body) {
749
- const r = await fetch(prefix + '/api' + path, {method, headers:{'Content-Type':'application/json','Authorization':'Bearer '+token},body:body?JSON.stringify(body):undefined})
757
+ const r = await fetch(prefix + '/api' + path, {
758
+ method,
759
+ headers: {'Content-Type':'application/json'},
760
+ credentials: 'same-origin',
761
+ body: body ? JSON.stringify(body) : undefined
762
+ })
750
763
  return r.json()
751
764
  }
752
765
  async function loadModel(name, page=1) {
@@ -782,9 +795,9 @@ function renderAdminLogin(prefix) {
782
795
  <button onclick="login()">Sign in</button></div>
783
796
  <script>
784
797
  async function login(){
785
- 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})})
798
+ const r=await fetch('${prefix}/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:document.getElementById('email').value,password:document.getElementById('pass').value}),credentials:'same-origin'})
786
799
  const d=await r.json()
787
- if(d.token){localStorage.setItem('admin_token',d.token);location.href='${prefix}'}
800
+ if(d.ok){location.href='${prefix}'}
788
801
  else document.getElementById('err').textContent=d.error||'Invalid credentials'
789
802
  }
790
803
  document.addEventListener('keydown',e=>{if(e.key==='Enter')login()})
@@ -804,8 +817,14 @@ class AiplangServer {
804
817
 
805
818
  // Multipart — don't pre-parse, let S3 upload handler do it
806
819
  const isMultipart = (req.headers['content-type'] || '').includes('multipart/form-data')
807
- if (req.method !== 'GET' && req.method !== 'DELETE' && !isMultipart) req.body = await parseBody(req)
808
- else if (!isMultipart) req.body = {}
820
+ const hasJsonCT = (req.headers['content-type'] || '').includes('application/json')
821
+ if (req.method !== 'GET' && req.method !== 'DELETE' && !isMultipart) {
822
+ req.body = await parseBody(req)
823
+ if (req.body.__tooBig) {
824
+ res.writeHead(413, { 'Content-Type': 'application/json' })
825
+ res.end(JSON.stringify({ error: 'Request body too large' })); return
826
+ }
827
+ } else if (!isMultipart) req.body = {}
809
828
 
810
829
  const parsed = url.parse(req.url, true)
811
830
  req.query = parsed.query; req.path = parsed.pathname
@@ -825,6 +844,11 @@ class AiplangServer {
825
844
  // CORS — use plugin config if set, otherwise allow all
826
845
  const origins = this._corsOrigins || ['*']
827
846
  const origin = req.headers['origin'] || ''
847
+ // Warn once if using wildcard CORS in production
848
+ if (origins.includes('*') && process.env.NODE_ENV === 'production' && !this._corsWarned) {
849
+ this._corsWarned = true
850
+ console.warn('[aiplang] WARNING: CORS is set to * (allow all origins). Use ~use cors origins=https://yourdomain.com in production')
851
+ }
828
852
  const allowOrigin = origins.includes('*') ? '*' : (origins.includes(origin) ? origin : origins[0])
829
853
  res.setHeader('Access-Control-Allow-Origin', allowOrigin)
830
854
  res.setHeader('Access-Control-Allow-Methods','GET,POST,PUT,PATCH,DELETE,OPTIONS')
@@ -864,9 +888,35 @@ function matchRoute(pattern, reqPath) {
864
888
  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}
865
889
  return params
866
890
  }
867
- function extractToken(req) { const a=req.headers.authorization; return a?.startsWith('Bearer ')?a.slice(7):null }
891
+ function extractToken(req) {
892
+ // 1. Bearer token from Authorization header
893
+ const a = req.headers.authorization
894
+ if (a?.startsWith('Bearer ')) return a.slice(7)
895
+ // 2. HttpOnly cookie (admin panel)
896
+ const cookies = req.headers['cookie'] || ''
897
+ const adminCookie = cookies.split(';').map(s=>s.trim()).find(s=>s.startsWith('aiplang_admin='))
898
+ if (adminCookie) return adminCookie.slice('aiplang_admin='.length)
899
+ return null
900
+ }
901
+ const MAX_BODY_BYTES = parseInt(process.env.MAX_BODY_BYTES || '1048576') // 1MB default
868
902
  async function parseBody(req) {
869
- 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({}))})
903
+ return new Promise((resolve) => {
904
+ const chunks = []
905
+ let size = 0, done = false
906
+ req.on('data', chunk => {
907
+ if (done) return
908
+ size += chunk.length
909
+ if (size > MAX_BODY_BYTES) { done = true; resolve({ __tooBig: true }); return }
910
+ chunks.push(chunk)
911
+ })
912
+ req.on('end', () => {
913
+ if (done) return
914
+ done = true
915
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString())) }
916
+ catch { resolve({}) }
917
+ })
918
+ req.on('error', () => { if (!done) { done = true; resolve({}) } })
919
+ })
870
920
  }
871
921
 
872
922
  // ═══════════════════════════════════════════════════════════════════
@@ -954,7 +1004,7 @@ function setupS3(config) {
954
1004
  endpoint: config.endpoint ? resolveEnv(config.endpoint) : null,
955
1005
  }
956
1006
 
957
- const isMock = !S3_CONFIG.key || S3_CONFIG.key.startsWith('$') || S3_CONFIG.key.includes('mock')
1007
+ const isMock = !S3_CONFIG.key || S3_CONFIG.key === null || S3_CONFIG.key.startsWith('$') || S3_CONFIG.key.includes('mock')
958
1008
  if (isMock) {
959
1009
  console.log('[aiplang] S3: mock mode (set AWS_ACCESS_KEY_ID for real storage)')
960
1010
  S3_CLIENT = null
@@ -1289,6 +1339,19 @@ async function startServer(aipFile, port = 3000) {
1289
1339
  const app = parseApp(src)
1290
1340
  const srv = new AiplangServer()
1291
1341
 
1342
+ // Validate required env vars up front
1343
+ const missingEnvs = []
1344
+ for (const envDef of app.env) {
1345
+ if (envDef.required && !process.env[envDef.name]) {
1346
+ missingEnvs.push(envDef.name)
1347
+ }
1348
+ }
1349
+ if (missingEnvs.length) {
1350
+ console.error(`[aiplang] FATAL: Missing required env vars: ${missingEnvs.join(', ')}`)
1351
+ console.error('[aiplang] Set them in .env or export them before starting')
1352
+ process.exit(1)
1353
+ }
1354
+
1292
1355
  // Auth setup
1293
1356
  if (app.auth) {
1294
1357
  JWT_SECRET = resolveEnv(app.auth.secret) || JWT_SECRET
@@ -1372,7 +1435,7 @@ async function startServer(aipFile, port = 3000) {
1372
1435
 
1373
1436
  // Health
1374
1437
  srv.addRoute('GET', '/health', (req, res) => res.json(200, {
1375
- status:'ok', version:'2.5.0',
1438
+ status:'ok', version:'2.6.1',
1376
1439
  models: app.models.map(m=>m.name),
1377
1440
  routes: app.apis.length, pages: app.pages.length,
1378
1441
  admin: app.admin?.prefix || null,
@@ -1415,7 +1478,7 @@ function setupStripe(config) {
1415
1478
  STRIPE_CONFIG = config
1416
1479
  const key = resolveEnv(config.key) || ''
1417
1480
  // Use mock if key is placeholder, test/mock value, or SDK unavailable
1418
- const isMock = !key || key.startsWith('$') || key === 'sk_test_mock' || key.includes('mock')
1481
+ const isMock = !key || key === null || key.startsWith('$') || key === 'sk_test_mock' || key.includes('mock')
1419
1482
  if (isMock) {
1420
1483
  console.log('[aiplang] Stripe: mock mode (set STRIPE_SECRET_KEY for real payments)')
1421
1484
  STRIPE = null // will use mockStripe()