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.
- package/aiplang-knowledge.md +85 -326
- package/bin/aiplang.js +1 -1
- package/package.json +5 -5
- package/runtime/aiplang-hydrate.js +21 -1
- package/runtime/aiplang-runtime.js +2 -5
- package/server/server.js +75 -12
package/aiplang-knowledge.md
CHANGED
|
@@ -1,338 +1,97 @@
|
|
|
1
|
-
# aiplang —
|
|
1
|
+
# aiplang — syntax reference
|
|
2
|
+
Generate ONLY aiplang code. No React, HTML, or other frameworks. No explanations unless asked.
|
|
2
3
|
|
|
3
|
-
|
|
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
|
|
23
|
-
~db sqlite ./app.db
|
|
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
|
|
207
|
-
id
|
|
208
|
-
|
|
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
|
|
217
|
-
~
|
|
218
|
-
~
|
|
219
|
-
~
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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.
|
|
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.
|
|
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": "^
|
|
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) {
|
|
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
|
|
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)]||
|
|
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
|
-
|
|
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, {
|
|
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('/
|
|
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.
|
|
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
|
-
|
|
808
|
-
|
|
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) {
|
|
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(
|
|
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.
|
|
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()
|