aiplang 2.1.0 → 2.1.2

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/server/server.js CHANGED
@@ -285,7 +285,7 @@ function migrateModels(models) {
285
285
  // PARSER
286
286
  // ═══════════════════════════════════════════════════════════════════
287
287
  function parseApp(src) {
288
- const app = { env:[], db:null, auth:null, mail:null, middleware:[], models:[], apis:[], pages:[], jobs:[], events:[], admin:null }
288
+ const app = { env:[], db:null, auth:null, mail:null, stripe:null, middleware:[], models:[], apis:[], pages:[], jobs:[], events:[], admin:null }
289
289
  const lines = src.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('#'))
290
290
  let i=0, inModel=false, inAPI=false, curModel=null, curAPI=null, pageLines=[], inPage=false
291
291
 
@@ -304,6 +304,8 @@ function parseApp(src) {
304
304
  if (line.startsWith('~mail ')) { app.mail = parseMailLine(line.slice(6)); i++; continue }
305
305
  if (line.startsWith('~middleware ')) { app.middleware = line.slice(12).split('|').map(s=>s.trim()); i++; continue }
306
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 }
307
309
  if (line.startsWith('~job ')) { app.jobs.push(parseJobLine(line.slice(5))); i++; continue }
308
310
  if (line.startsWith('~on ')) { app.events.push(parseEventLine(line.slice(4))); i++; continue }
309
311
 
@@ -343,6 +345,23 @@ function parseEnvLine(s) { const p=s.split(/\s+/); const ev={name:'',required:fa
343
345
  function parseDBLine(s) { const p=s.split(/\s+/); return{driver:p[0]||'sqlite',dsn:p[1]||'./app.db'} }
344
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 }
345
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'
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
+ }
346
365
  function parseAdminLine(s) { const m=s.match(/~admin\s+(\S+)/); return{prefix:m?.[1]||'/admin',guard:'admin'} }
347
366
  function parseJobLine(s) { const[name,...rest]=s.split(/\s+/); return{name,action:rest.join(' ')} }
348
367
  function parseEventLine(s) { const m=s.match(/^(\S+)\s*=>\s*(.+)$/); return{event:m?.[1],action:m?.[2]} }
@@ -383,6 +402,13 @@ function compileRoute(route, server) {
383
402
  for (const guard of route.guards) {
384
403
  if (guard === 'auth' && !req.user) { res.error(401, 'Unauthorized'); return }
385
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
+ }
411
+ }
386
412
  if (guard === 'owner') {
387
413
  if (!req.user) { res.error(401, 'Unauthorized'); return }
388
414
  // owner check happens in ops
@@ -768,7 +794,7 @@ function renderHTML(page, allPages) {
768
794
  const needsJS=page.queries.length>0||page.blocks.some(b=>['table','form','if','btn','select','faq'].includes(b.kind))
769
795
  const body=page.blocks.map(b=>renderBlock(b)).join('')
770
796
  const config=needsJS?JSON.stringify({id:page.id,theme:page.theme,state:page.state,routes:allPages.map(p=>p.route),queries:page.queries}):''
771
- const hydrate=needsJS?`<script>window.__FLUX_PAGE__=${config};</script><script src="/aiplang-hydrate.js" defer></script>`:''
797
+ const hydrate=needsJS?`<script>window.__AIPLANG_PAGE__=${config};</script><script src="/aiplang-hydrate.js" defer></script>`:''
772
798
  const themeCSS=page.themeVars?genThemeCSS(page.themeVars):''
773
799
  const customCSS=page.customTheme?`body{background:${page.customTheme.bg};color:${page.customTheme.text}}.fx-cta,.fx-btn{background:${page.customTheme.accent};color:#fff}` :''
774
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>`
@@ -828,8 +854,8 @@ function baseCSS(theme) {
828
854
  // ═══════════════════════════════════════════════════════════════════
829
855
  // MAIN
830
856
  // ═══════════════════════════════════════════════════════════════════
831
- async function startServer(fluxFile, port = 3000) {
832
- const src = fs.readFileSync(fluxFile, 'utf8')
857
+ async function startServer(aipFile, port = 3000) {
858
+ const src = fs.readFileSync(aipFile, 'utf8')
833
859
  const app = parseApp(src)
834
860
  const srv = new AiplangServer()
835
861
 
@@ -866,6 +892,14 @@ async function startServer(fluxFile, port = 3000) {
866
892
  // Admin panel
867
893
  if (app.admin) registerAdminPanel(srv, app.admin, app.models)
868
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 || {}
901
+ }
902
+
869
903
  // Frontend
870
904
  for (const page of app.pages) {
871
905
  srv.addRoute('GET', page.route, (req, res) => {
@@ -877,7 +911,7 @@ async function startServer(fluxFile, port = 3000) {
877
911
 
878
912
  // Static assets
879
913
  srv.addRoute('GET', '/aiplang-hydrate.js', (req, res) => {
880
- const p = path.join(__dirname, '..', 'flux-lang', 'runtime', 'aiplang-hydrate.js')
914
+ const p = path.join(__dirname, '..', 'runtime', 'aiplang-hydrate.js')
881
915
  if (fs.existsSync(p)) { res.writeHead(200,{'Content-Type':'application/javascript'}); res.end(fs.readFileSync(p)) }
882
916
  else { res.writeHead(404); res.end('// not found') }
883
917
  })
@@ -901,3 +935,300 @@ if (require.main === module) {
901
935
  if (!f) { console.error('Usage: node server.js <app.flux> [port]'); process.exit(1) }
902
936
  startServer(f, p).catch(e=>{console.error(e);process.exit(1)})
903
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
+ }
965
+ }
966
+
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
+ }
1006
+
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
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