apiblaze 0.21.37 → 0.21.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +117 -21
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ var __toESM = (mod, isNodeMode, target2) => (target2 = mod != null ? __create(__
34
34
  var version;
35
35
  var init_package = __esm({
36
36
  "package.json"() {
37
- version = "0.21.37";
37
+ version = "0.21.38";
38
38
  }
39
39
  });
40
40
 
@@ -6457,8 +6457,99 @@ var init_files = __esm({
6457
6457
  "use strict";
6458
6458
  DEMO_FILES = {
6459
6459
  "README.md": "# Nino's Pizza: the APIblaze demo\n\nA tiny table-booking app for pizzerias, used by `npx apiblaze@latest demo` to show what APIblaze does.\n\n```\nnpx apiblaze@latest demo\n```\n\nThat one command writes this folder to `./apiblaze-demo`, puts APIblaze in front of the backend,\nstarts everything and opens the page. No login, no prompts, no dependencies.\n\n## What the page shows\n\n1. **Your API is live on the internet.** The backend runs on your laptop; APIblaze gives it a public\n URL and an MCP address for AI assistants. A live ticker shows each request reaching your laptop.\n2. **An AI assistant uses it.** Ask it to book a table and watch the request arrive.\n3. **Included, no code:** only the owner can change a booking, each customer (Nino Pizza,\n Gino Pizza) is separate, rate limits, sign-in, and drop-in widgets for API keys and roles.\n\n## The files\n\n| File | What it is |\n| --- | --- |\n| `backend.js` | The pizzeria's API. No login, no keys, no permission checks. |\n| `openapi.yaml` | Its endpoints. APIblaze builds the AI tools and the ownership rules from it. |\n| `app.js` | The website's server. Calls the API with one key and says who is acting. |\n| `public/index.html` | The page. |\n| `start.js` | Starts the backend (port 3001) and the website (port 3000). |\n\n`npx apiblaze@latest demo` also adds `lib/apiblaze-server.js` and `public/apiblaze-widgets.js`, the same\nhelpers you get from `npm i apiblaze`. Stop the demo with `npx apiblaze@latest demo stop`.\n",
6460
- "openapi.yaml": "openapi: 3.0.3\ninfo:\n title: ResiResi API\n version: 1.0.0\n description: Table bookings at restaurants (each restaurant is a tenant, like Nino Pizza and Gino Pizza). APIblaze reads this file to build the MCP tools for AI assistants and to work out who may change what.\npaths:\n /reservations:\n get:\n operationId: listReservations\n summary: List my bookings\n responses:\n '200':\n description: The caller's bookings\n content:\n application/json:\n schema:\n type: array\n items: { $ref: '#/components/schemas/Reservation' }\n post:\n operationId: createReservation\n summary: Book a table\n description: Only the time is needed. The restaurant picks a free table and books it in the signed-in person's name, so never ask for a table number or a name.\n requestBody:\n required: true\n content:\n application/json:\n schema: { $ref: '#/components/schemas/NewReservation' }\n responses:\n '201':\n description: Booked\n content:\n application/json:\n schema: { $ref: '#/components/schemas/Reservation' }\n /reservations/{id}:\n parameters:\n - { name: id, in: path, required: true, schema: { type: string } }\n get:\n operationId: getReservation\n summary: Get one booking\n responses:\n '200':\n description: The booking\n content:\n application/json:\n schema: { $ref: '#/components/schemas/Reservation' }\n delete:\n operationId: cancelReservation\n summary: Cancel a booking\n responses:\n '204': { description: Cancelled }\ncomponents:\n schemas:\n NewReservation:\n type: object\n required: [time]\n properties:\n name: { type: string, example: Ana, description: 'Optional: defaults to the signed-in person. Do not ask for it.' }\n table: { type: integer, example: 4, description: 'Optional: the restaurant picks a free table. Do not ask for it.' }\n time: { type: string, example: '19:30', description: 'Time of the booking, 24h HH:MM' }\n guests: { type: integer, example: 2, description: 'How many people. Optional, 2 if not given.' }\n Reservation:\n allOf:\n - $ref: '#/components/schemas/NewReservation'\n - type: object\n properties:\n id: { type: string, example: r1 }\n",
6461
- "backend.js": "// ResiResi backend: restaurants (tenants, like Nino Pizza and Gino Pizza) let their customers\n// book, list and cancel tables. It runs on YOUR laptop.\n//\n// `npx apiblaze@latest dev` (which the demo runs for you) puts it on the internet\n// at a public URL and gives AI assistants an MCP address for it. Everything else\n// (API keys, sign-in, who may change what, rate limits) happens in APIblaze, in\n// front of this file. Look at what is NOT here: no login, no keys, no permission\n// checks, no AI code.\n//\n// What APIblaze tells this backend about each call, in two headers:\n// x-abz-tenant-id which customer (pizzeria) is calling\n// x-abz-user-id which person\n// and one shared secret it adds to every call it forwards (x-target-api-key), so a\n// caller who skips APIblaze and hits localhost:3001 directly is refused.\n//\n// Zero dependencies. Run it alone with `node backend.js` (port 3001).\n\nconst http = require('http');\nconst fs = require('fs');\nconst path = require('path');\n\nconst bookings = new Map();\nlet next = 1;\n// Bumps on every booking made or cancelled. The page asks the app (on this laptop, free)\n// whether it moved before re-listing through APIblaze, so an open, idle page spends nothing.\nlet changes = 0;\n// The last requests this laptop answered \u2014 the demo page shows them live.\nconst recent = [];\nfunction remember(entry) {\n recent.unshift({ at: Date.now(), ...entry });\n recent.length = Math.min(recent.length, 50);\n // One line per request in the terminal (`npx apiblaze@latest demo logs` follows it).\n const who = entry.who ? `${entry.who}${entry.tenant ? ` @ ${entry.tenant}` : ''}` : '-';\n console.log(`${new Date().toISOString().slice(11, 19)} ${entry.status} ${entry.method.padEnd(6)} ${entry.path.padEnd(22)} ${who}`);\n}\n\n// The restaurant picks the table: the lowest one still free at that time, at that restaurant.\nfunction freeTable(tenant, time) {\n const taken = new Set([...bookings.values()].filter((r) => r.tenant === tenant && r.time === time).map((r) => r.table));\n let t = 1;\n while (taken.has(t)) t++;\n return t;\n}\n\nfunction send(res, status, body) {\n res.writeHead(status, { 'content-type': 'application/json' });\n res.end(body === undefined ? '' : JSON.stringify(body));\n}\n\nfunction handle(req, res, body) {\n const url = new URL(req.url, 'http://x');\n if (url.pathname === '/openapi.yaml') {\n res.writeHead(200, { 'content-type': 'text/yaml' });\n return res.end(fs.readFileSync(path.join(__dirname, 'openapi.yaml')));\n }\n const who = req.headers['x-abz-user-id'] || '';\n const tenant = req.headers['x-abz-tenant-id'] || '';\n const reply = (status, data) => { remember({ method: req.method, path: url.pathname, who, tenant, status }); send(res, status, data); };\n const secret = process.env.BACKEND_SECRET || '';\n if (secret && req.headers['x-target-api-key'] !== secret) return reply(401, { error: 'missing or wrong backend secret (APIblaze adds it to every call it forwards)' });\n if (!who) return reply(401, { error: 'say who is calling (x-abz-user-id)' });\n\n const m = /^\\/reservations(?:\\/([^/]+))?$/.exec(url.pathname);\n if (!m) return reply(404, { error: 'not found' });\n const id = m[1];\n\n if (!id && req.method === 'POST') {\n let input = {};\n try { input = JSON.parse(body || '{}'); } catch { return reply(400, { error: 'body must be JSON' }); }\n const time = String(input.time || '19:30');\n const row = {\n id: `r${next++}`, name: String(input.name || who), table: Number(input.table) || freeTable(tenant, time),\n time, guests: Number(input.guests) || 2, owner: who, tenant,\n };\n bookings.set(row.id, row); changes++;\n return reply(201, row);\n }\n if (!id && req.method === 'GET') {\n // Lists are the backend's job: each person sees their own bookings, at their own pizzeria.\n return reply(200, [...bookings.values()].filter((r) => r.owner === who && r.tenant === tenant));\n }\n const row = bookings.get(id);\n if (!row) return reply(404, { error: 'no such booking' });\n if (req.method === 'GET') return reply(200, row);\n if (req.method === 'DELETE') { bookings.delete(id); changes++; return reply(204); }\n return reply(405, { error: 'method not allowed' });\n}\n\nfunction startBackend(port = Number(process.env.BACKEND_PORT) || 3001) {\n const server = http.createServer((req, res) => {\n let body = '';\n req.on('data', (c) => { body += c; });\n req.on('end', () => {\n try { handle(req, res, body); } catch (e) { send(res, 500, { error: String((e && e.message) || e) }); }\n });\n });\n return new Promise((resolve, reject) => {\n server.once('error', reject);\n server.listen(port, '127.0.0.1', () => resolve(server));\n });\n}\n\nmodule.exports = { startBackend, recent, changeCount: () => changes };\nif (require.main === module) startBackend().then(() => console.log('backend on http://localhost:3001'));\n",
6460
+ "openapi.yaml": `openapi: 3.0.3
6461
+ info:
6462
+ title: ResiResi API
6463
+ version: 1.0.0
6464
+ description: Table bookings at restaurants (each restaurant is a tenant, like Nino Pizza and Gino Pizza). APIblaze reads this file to build the MCP tools for AI assistants and to work out who may change what.
6465
+ paths:
6466
+ /reservations:
6467
+ get:
6468
+ operationId: listReservations
6469
+ summary: List my bookings
6470
+ description: The signed-in person's own bookings at this restaurant, and nobody else's. Use it for "my bookings" or "the one I just made", and call it again whenever asked, since bookings change.
6471
+ responses:
6472
+ '200':
6473
+ description: The caller's bookings
6474
+ content:
6475
+ application/json:
6476
+ schema:
6477
+ type: array
6478
+ items: { $ref: '#/components/schemas/Reservation' }
6479
+ post:
6480
+ operationId: createReservation
6481
+ summary: Book a table
6482
+ description: Only the time is needed. The restaurant picks a free table and books it in the signed-in person's name, so never ask for a table number or a name.
6483
+ requestBody:
6484
+ required: true
6485
+ content:
6486
+ application/json:
6487
+ schema: { $ref: '#/components/schemas/NewReservation' }
6488
+ responses:
6489
+ '201':
6490
+ description: Booked
6491
+ content:
6492
+ application/json:
6493
+ schema: { $ref: '#/components/schemas/Reservation' }
6494
+ /reservations/{id}:
6495
+ parameters:
6496
+ - { name: id, in: path, required: true, schema: { type: string } }
6497
+ get:
6498
+ operationId: getReservation
6499
+ summary: Get one booking
6500
+ description: Only the person who booked it (or the restaurant's admin) can see it. Anyone else is refused.
6501
+ responses:
6502
+ '200':
6503
+ description: The booking
6504
+ content:
6505
+ application/json:
6506
+ schema: { $ref: '#/components/schemas/Reservation' }
6507
+ patch:
6508
+ operationId: updateReservation
6509
+ summary: Change a booking
6510
+ description: Move a booking to another table or time, or change the number of guests. Only the person who booked it (or the restaurant's admin) can change it. Get the id from listReservations; never ask for it.
6511
+ requestBody:
6512
+ required: true
6513
+ content:
6514
+ application/json:
6515
+ schema: { $ref: '#/components/schemas/ReservationChange' }
6516
+ responses:
6517
+ '200':
6518
+ description: Changed
6519
+ content:
6520
+ application/json:
6521
+ schema: { $ref: '#/components/schemas/Reservation' }
6522
+ '409': { description: That table is already booked at that time }
6523
+ delete:
6524
+ operationId: cancelReservation
6525
+ summary: Cancel a booking
6526
+ description: Only the person who booked it (or the restaurant's admin) can cancel it.
6527
+ responses:
6528
+ '204': { description: Cancelled }
6529
+ components:
6530
+ schemas:
6531
+ NewReservation:
6532
+ type: object
6533
+ required: [time]
6534
+ properties:
6535
+ name: { type: string, example: Ana, description: 'Optional: defaults to the signed-in person. Do not ask for it.' }
6536
+ table: { type: integer, example: 4, description: 'Optional: the restaurant picks a free table. Do not ask for it.' }
6537
+ time: { type: string, example: '19:30', description: 'Time of the booking, 24h HH:MM' }
6538
+ guests: { type: integer, example: 2, description: 'How many people. Optional, 2 if not given.' }
6539
+ ReservationChange:
6540
+ type: object
6541
+ properties:
6542
+ table: { type: integer, example: 2, description: 'Move to this table.' }
6543
+ time: { type: string, example: '20:00', description: 'New time, 24h HH:MM' }
6544
+ guests: { type: integer, example: 4, description: 'New number of people' }
6545
+ Reservation:
6546
+ allOf:
6547
+ - $ref: '#/components/schemas/NewReservation'
6548
+ - type: object
6549
+ properties:
6550
+ id: { type: string, example: r1 }
6551
+ `,
6552
+ "backend.js": "// ResiResi backend: restaurants (tenants, like Nino Pizza and Gino Pizza) let their customers\n// book, list and cancel tables. It runs on YOUR laptop.\n//\n// `npx apiblaze@latest dev` (which the demo runs for you) puts it on the internet\n// at a public URL and gives AI assistants an MCP address for it. Everything else\n// (API keys, sign-in, who may change what, rate limits) happens in APIblaze, in\n// front of this file. Look at what is NOT here: no login, no keys, no permission\n// checks, no AI code.\n//\n// What APIblaze tells this backend about each call, in two headers:\n// x-abz-tenant-id which customer (pizzeria) is calling\n// x-abz-user-id which person\n// and one shared secret it adds to every call it forwards (x-target-api-key), so a\n// caller who skips APIblaze and hits localhost:3001 directly is refused.\n//\n// Zero dependencies. Run it alone with `node backend.js` (port 3001).\n\nconst http = require('http');\nconst fs = require('fs');\nconst path = require('path');\n\nconst bookings = new Map();\nlet next = 1;\n// Bumps on every booking made or cancelled. The page asks the app (on this laptop, free)\n// whether it moved before re-listing through APIblaze, so an open, idle page spends nothing.\nlet changes = 0;\n// The last requests this laptop answered \u2014 the demo page shows them live.\nconst recent = [];\nfunction remember(entry) {\n recent.unshift({ at: Date.now(), ...entry });\n recent.length = Math.min(recent.length, 50);\n // One line per request in the terminal (`npx apiblaze@latest demo logs` follows it).\n const who = entry.who ? `${entry.who}${entry.tenant ? ` @ ${entry.tenant}` : ''}` : '-';\n console.log(`${new Date().toISOString().slice(11, 19)} ${entry.status} ${entry.method.padEnd(6)} ${entry.path.padEnd(22)} ${who}`);\n}\n\n// The restaurant picks the table: the lowest one still free at that time, at that restaurant.\nfunction freeTable(tenant, time) {\n const taken = new Set([...bookings.values()].filter((r) => r.tenant === tenant && r.time === time).map((r) => r.table));\n let t = 1;\n while (taken.has(t)) t++;\n return t;\n}\n\nfunction send(res, status, body) {\n res.writeHead(status, { 'content-type': 'application/json' });\n res.end(body === undefined ? '' : JSON.stringify(body));\n}\n\nfunction handle(req, res, body) {\n const url = new URL(req.url, 'http://x');\n if (url.pathname === '/openapi.yaml') {\n res.writeHead(200, { 'content-type': 'text/yaml' });\n return res.end(fs.readFileSync(path.join(__dirname, 'openapi.yaml')));\n }\n const who = req.headers['x-abz-user-id'] || '';\n const tenant = req.headers['x-abz-tenant-id'] || '';\n const reply = (status, data) => { remember({ method: req.method, path: url.pathname, who, tenant, status }); send(res, status, data); };\n const secret = process.env.BACKEND_SECRET || '';\n if (secret && req.headers['x-target-api-key'] !== secret) return reply(401, { error: 'missing or wrong backend secret (APIblaze adds it to every call it forwards)' });\n if (!who) return reply(401, { error: 'say who is calling (x-abz-user-id)' });\n\n const m = /^\\/reservations(?:\\/([^/]+))?$/.exec(url.pathname);\n if (!m) return reply(404, { error: 'not found' });\n const id = m[1];\n\n if (!id && req.method === 'POST') {\n let input = {};\n try { input = JSON.parse(body || '{}'); } catch { return reply(400, { error: 'body must be JSON' }); }\n const time = String(input.time || '19:30');\n const row = {\n id: `r${next++}`, name: String(input.name || who), table: Number(input.table) || freeTable(tenant, time),\n time, guests: Number(input.guests) || 2, owner: who, tenant,\n };\n bookings.set(row.id, row); changes++;\n return reply(201, row);\n }\n if (!id && req.method === 'GET') {\n // Lists are the backend's job: each person sees their own bookings, at their own pizzeria.\n return reply(200, [...bookings.values()].filter((r) => r.owner === who && r.tenant === tenant));\n }\n const row = bookings.get(id);\n if (!row) return reply(404, { error: 'no such booking' });\n if (req.method === 'GET') return reply(200, row);\n if (req.method === 'PATCH') {\n let input = {};\n try { input = JSON.parse(body || '{}'); } catch { return reply(400, { error: 'body must be JSON' }); }\n const time = input.time ? String(input.time) : row.time;\n const table = input.table ? Number(input.table) : (time === row.time ? row.table : freeTable(row.tenant, time));\n const clash = [...bookings.values()].find((r) => r.id !== row.id && r.tenant === row.tenant && r.time === time && r.table === table);\n if (clash) return reply(409, { error: `table ${table} is already booked at ${time}` });\n Object.assign(row, { time, table, ...(input.guests ? { guests: Number(input.guests) } : {}) }); changes++;\n return reply(200, row);\n }\n if (req.method === 'DELETE') { bookings.delete(id); changes++; return reply(204); }\n return reply(405, { error: 'method not allowed' });\n}\n\nfunction startBackend(port = Number(process.env.BACKEND_PORT) || 3001) {\n const server = http.createServer((req, res) => {\n let body = '';\n req.on('data', (c) => { body += c; });\n req.on('end', () => {\n try { handle(req, res, body); } catch (e) { send(res, 500, { error: String((e && e.message) || e) }); }\n });\n });\n return new Promise((resolve, reject) => {\n server.once('error', reject);\n server.listen(port, '127.0.0.1', () => resolve(server));\n });\n}\n\nmodule.exports = { startBackend, recent, changeCount: () => changes };\nif (require.main === module) startBackend().then(() => console.log('backend on http://localhost:3001'));\n",
6462
6553
  "app.js": "// ResiResi website server: serves the page and makes its calls.\n//\n// PROTECTED (the default): every call goes to the PUBLIC API \u2014 through APIblaze \u2014 with the\n// pizzeria's server key (kept here, never in the browser) and X-End-User-Id naming the\n// person. APIblaze checks the key, applies the rules and forwards to the backend.\n//\n// DIRECT (the page's \"unprotected\" switch): the same calls go straight to the backend on\n// localhost, with the shared backend secret and whatever user the page claims. No APIblaze:\n// nobody checks who may do what. That is how most apps ship.\n//\n// The chat and the two admin widgets use apiblaze's own server helpers\n// (lib/apiblaze-server.js, copied in by `npx apiblaze@latest demo` \u2014 the same code as\n// `import \u2026 from 'apiblaze/server'`).\n\nconst http = require('http');\nconst fs = require('fs');\nconst path = require('path');\n\nconst env = (name, fallback = '') => process.env[name] || fallback;\nconst json = (res, status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };\n\nlet lib = null;\ntry { lib = require('./lib/apiblaze-server.js'); } catch { /* run without the widgets */ }\n\n// The two pizzerias (tenants) and the people the demo lets you be. A real app takes\n// \"who is this\" from its own login.\nconst PIZZERIAS = {\n nino: { label: 'Nino Pizza', tenant: () => env('APIBLAZE_TENANT', env('DEMO_NINO_TENANT')), key: () => env('APIBLAZE_SERVER_KEY'), admin: { userId: 'ana', label: 'Ana', email: 'ana@ninopizza.example' } },\n gino: { label: 'Gino Pizza', tenant: () => env('APIBLAZE_GINO_TENANT', env('DEMO_GINO_TENANT')), key: () => env('APIBLAZE_GINO_KEY'), admin: { userId: 'gina', label: 'Gina', email: 'gina@ginopizza.example' } },\n};\n// Each pizzeria (tenant) has its own MCP address: https://{proxy}-{tenant}.mcp.\u2026\nconst portalFor = (tenant) => (env('APIBLAZE_PORTAL_URL') && env('APIBLAZE_TENANT') && tenant ? env('APIBLAZE_PORTAL_URL').replace(`${env('APIBLAZE_TENANT')}.portal`, `${tenant}.portal`) : '');\nconst mcpFor = (tenant) => (env('APIBLAZE_MCP_URL') && env('APIBLAZE_TENANT') && tenant ? env('APIBLAZE_MCP_URL').replace(`-${env('APIBLAZE_TENANT')}.`, `-${tenant}.`) : '');\nconst CUSTOMERS = { ana: 'Ana', ben: 'Ben' };\nconst pizzeriaOf = (id) => PIZZERIAS[id] || PIZZERIAS.nino;\nfunction queryOf(req) { return new URL(req.url, 'http://x').searchParams; }\n\nasync function call(op, { pizzeria = 'nino', as = 'ana', id, body, direct = false } = {}) {\n const pz = pizzeriaOf(pizzeria);\n const base = direct ? `http://127.0.0.1:${env('BACKEND_PORT', '3001')}` : env('APIBLAZE_URL').replace(/\\/$/, '');\n const headers = { 'content-type': 'application/json' };\n if (direct) {\n // Straight to localhost: the backend trusts whatever these headers say.\n headers['x-target-api-key'] = env('BACKEND_SECRET');\n headers['x-abz-user-id'] = as;\n headers['x-abz-tenant-id'] = pz.tenant();\n } else {\n headers['x-api-key'] = pz.key();\n headers['x-end-user-id'] = as;\n }\n const req = op === 'book' ? { method: 'POST', url: `${base}/reservations`, body: JSON.stringify({ name: CUSTOMERS[as] || as, ...(body || {}) }) }\n : op === 'cancel' ? { method: 'DELETE', url: `${base}/reservations/${encodeURIComponent(id || '')}` }\n : { method: 'GET', url: `${base}/reservations` };\n const r = await fetch(req.url, { method: req.method, headers, body: req.body });\n const text = await r.text();\n let data = null;\n try { data = text ? JSON.parse(text) : null; } catch { data = text; }\n return { status: r.status, body: data };\n}\n\n// Node http <-> the web-standard handlers apiblaze/server exports.\nasync function viaHandler(handler, req, res, raw) {\n const request = new Request(`http://localhost${req.url}`, {\n method: req.method,\n headers: { 'content-type': req.headers['content-type'] || 'application/json' },\n body: ['GET', 'HEAD'].includes(req.method) ? undefined : raw,\n });\n const out = await handler(request);\n const headers = {};\n out.headers.forEach((v, k) => { headers[k] = v; });\n res.writeHead(out.status, headers);\n if (!out.body) return res.end();\n const reader = out.body.getReader();\n for (;;) { const { done, value } = await reader.read(); if (done) break; res.write(Buffer.from(value)); }\n res.end();\n}\n\nfunction widgets() {\n if (!lib) return {};\n const out = { chat: {} };\n for (const [id, pz] of Object.entries(PIZZERIAS)) {\n if (!pz.tenant() || !pz.key()) continue;\n // The assistant acts as the signed-in customer, at that customer's pizzeria.\n out.chat[id] = lib.createApiblazeChat({\n project: `${env('APIBLAZE_NAME')}-${pz.tenant()}`, apiKey: pz.key(),\n host: 'tryabz.run', environment: 'dev', getUser: (req) => ({ userId: queryOf(req).get('as') || 'ana' }),\n });\n }\n if (env('APIBLAZE_CP_KEY') && env('APIBLAZE_WIDGETS') === '1') {\n // Admin widgets: the admin of the chosen pizzeria.\n const admin = (req) => { const pz = pizzeriaOf(queryOf(req).get('pizzeria')); return { tenant: pz.tenant(), ...pz.admin }; };\n out.keys = lib.createApiblazeKeys({ cpKey: env('APIBLAZE_CP_KEY'), getUser: admin, environment: 'dev' });\n out.groups = lib.createApiblazeGroups({ cpKey: env('APIBLAZE_CP_KEY'), getUser: admin });\n }\n return out;\n}\n\n/** One of the two websites: `direct` = straight to the backend (unprotected), else through APIblaze. */\nfunction startApp(port = Number(env('APP_PORT', '3000')), { direct = false } = {}) {\n const w = direct ? {} : widgets();\n const pub = path.join(__dirname, 'public');\n const server = http.createServer((req, res) => {\n let raw = '';\n req.on('data', (c) => { raw += c; });\n req.on('end', async () => {\n try {\n const url = new URL(req.url, 'http://x');\n const p = url.pathname;\n if (req.method === 'GET' && (p === '/' || p === '/index.html')) {\n res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });\n return res.end(fs.readFileSync(path.join(pub, 'index.html')));\n }\n for (const asset of ['apiblaze-widgets.js', 'apiblaze-logo.svg']) {\n if (req.method === 'GET' && p === `/${asset}` && fs.existsSync(path.join(pub, asset))) {\n res.writeHead(200, { 'content-type': asset.endsWith('.svg') ? 'image/svg+xml' : 'text/javascript; charset=utf-8' });\n return res.end(fs.readFileSync(path.join(pub, asset)));\n }\n }\n if (p === '/api/config') {\n return json(res, 200, {\n direct, apiUrl: env('APIBLAZE_URL'), portalUrl: env('APIBLAZE_PORTAL_URL'),\n backendPort: env('BACKEND_PORT', '3001'),\n protectedUrl: env('APIBLAZE_URL') ? `http://localhost:${env('APP_PORT', '3000')}` : '',\n directUrl: `http://localhost:${env('DIRECT_PORT', '3002')}`,\n logsCommand: `npx apiblaze@latest logs ${env('APIBLAZE_NAME')} --tenant ${env('APIBLAZE_TENANT')}`,\n pizzerias: Object.entries(PIZZERIAS).filter(([, pz]) => pz.tenant()).map(([id, pz]) => ({ id, label: pz.label, admin: pz.admin.label, mcpUrl: mcpFor(pz.tenant()), portalUrl: portalFor(pz.tenant()) })),\n widgets: { chat: Object.keys(w.chat || {}), keys: !!w.keys, groups: !!w.groups },\n });\n }\n if (p === '/api/changes') {\n // Local and free: has any booking changed since the page last listed? The page\n // re-lists through APIblaze only when it has (the backend runs in this process).\n let n = 0;\n try { n = require('./backend').changeCount(); } catch { /* backend runs elsewhere */ }\n return json(res, 200, { n });\n }\n if (p === '/api/apiblaze/chat') {\n const h = (w.chat || {})[url.searchParams.get('pizzeria') || 'nino'];\n if (h) return viaHandler(h.handler, req, res, raw);\n }\n if (p === '/api/apiblaze/keys' && w.keys) return viaHandler(w.keys.handler, req, res, raw);\n if (p === '/api/apiblaze/groups' && w.groups) return viaHandler(w.groups.handler, req, res, raw);\n if (req.method === 'POST' && p === '/api/act') {\n const b = JSON.parse(raw || '{}');\n return json(res, 200, await call(String(b.op || 'list'), { pizzeria: b.pizzeria, as: String(b.as || 'ana'), id: b.id, body: b.body, direct }));\n }\n json(res, 404, { error: 'not found' });\n } catch (e) {\n json(res, 502, { error: `Could not reach the API: ${String((e && e.message) || e)}` });\n }\n });\n });\n return new Promise((resolve, reject) => {\n server.once('error', reject);\n server.listen(port, '127.0.0.1', () => resolve(server));\n });\n}\n\nmodule.exports = { startApp };\n",
6463
6554
  "start.js": "// Starts ResiResi on this laptop: the backend (3001), the unprotected front-end straight to it\n// (3002) and, once APIblaze is set up, the protected website through APIblaze (3000).\n// No APIblaze involved here \u2014 the tunnel is `npx apiblaze@latest demo start|stop|restart`.\n//\n// node start.js run in the foreground (Ctrl-C stops)\n// npm run start run in the background\n// npm run stop stop the background one\n// npm run restart stop, then start again\n\nconst fs = require('fs');\nconst path = require('path');\nconst { spawn } = require('child_process');\n\nconst PID = path.join(__dirname, '.demo', 'app.pid');\n\nfunction loadEnv(file = path.join(__dirname, '.env.local')) {\n if (!fs.existsSync(file)) return;\n for (const line of fs.readFileSync(file, 'utf8').split('\\n')) {\n const m = /^\\s*([A-Z0-9_]+)\\s*=\\s*(.*)\\s*$/.exec(line);\n if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^\"(.*)\"$/, '$1');\n }\n}\n\nasync function start() {\n loadEnv();\n const { startBackend } = require('./backend');\n const { startApp } = require('./app');\n const backendPort = Number(process.env.BACKEND_PORT) || 3001;\n const appPort = Number(process.env.APP_PORT) || 3000;\n const directPort = Number(process.env.DIRECT_PORT) || 3002;\n await startBackend(backendPort);\n // The unprotected front-end: straight to the backend, no APIblaze.\n await startApp(directPort, { direct: true });\n // The protected website: through APIblaze (once the demo has written its keys).\n if (process.env.APIBLAZE_URL) await startApp(appPort);\n return { backendPort, appPort, directPort };\n}\n\nconst alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };\nfunction runningPid() {\n try { const pid = Number(fs.readFileSync(PID, 'utf8')); return pid && alive(pid) ? pid : null; } catch { return null; }\n}\nasync function stop() {\n const pid = runningPid();\n if (!pid) { console.log('ResiResi is not running.'); return; }\n process.kill(pid, 'SIGTERM');\n for (let i = 0; i < 30 && alive(pid); i++) await new Promise((r) => setTimeout(r, 100));\n try { fs.unlinkSync(PID); } catch { /* gone */ }\n console.log('Stopped ResiResi (backend and websites).');\n}\nfunction background() {\n if (runningPid()) { console.log('ResiResi is already running.'); return; }\n fs.mkdirSync(path.dirname(PID), { recursive: true });\n const log = fs.openSync(path.join(__dirname, '.demo', 'app.log'), 'a');\n const child = spawn(process.execPath, [__filename], { cwd: __dirname, detached: true, stdio: ['ignore', log, log] });\n child.unref();\n fs.writeFileSync(PID, String(child.pid));\n loadEnv();\n console.log(`Started ResiResi: backend localhost:${process.env.BACKEND_PORT || 3001}, unprotected front-end http://localhost:${process.env.DIRECT_PORT || 3002}${process.env.APIBLAZE_URL ? `, protected website http://localhost:${process.env.APP_PORT || 3000}` : ''}.`);\n}\n\nmodule.exports = { start, loadEnv };\nif (require.main === module) {\n const arg = process.argv[2];\n if (arg === '--stop') stop();\n else if (arg === '--background') background();\n else if (arg === '--restart') stop().then(() => setTimeout(background, 300));\n else {\n start().then(({ appPort, backendPort, directPort }) => console.log(`ResiResi running: backend localhost:${backendPort}, unprotected front-end http://localhost:${directPort}${process.env.APIBLAZE_URL ? `, protected website http://localhost:${appPort}` : ''}. Ctrl-C stops.`))\n .catch((e) => { console.error(`Could not start: ${e.message}`); process.exit(1); });\n }\n}\n",
6464
6555
  "public/index.html": `<!doctype html>
@@ -11828,7 +11919,7 @@ function printAuth(recipe) {
11828
11919
  console.log(` ${import_chalk42.default.dim("provider ")} ${a.provider.type ?? "(unknown)"}`);
11829
11920
  if (a.provider.scopes) console.log(` ${import_chalk42.default.dim("scopes ")} ${a.provider.scopes}`);
11830
11921
  if (a.provider.target_server_token) console.log(` ${import_chalk42.default.dim("sends to API")} ${a.provider.target_server_token}`);
11831
- console.log(import_chalk42.default.dim(" (no credentials travel in a recipe \u2014 install asks you for your own)"));
11922
+ console.log(a.provider.managed === true ? import_chalk42.default.dim(" (APIblaze's own GitHub app \u2014 installers get the same zero-config door, nothing to create)") : import_chalk42.default.dim(" (no credentials travel in a recipe \u2014 install asks for your own OAuth app)"));
11832
11923
  } else {
11833
11924
  console.log(import_chalk42.default.dim(" no login provider \u2014 consumers call it with an API key"));
11834
11925
  }
@@ -11915,7 +12006,7 @@ async function runRecipeInstall(nameArg, opts) {
11915
12006
  if (reuseTenant) {
11916
12007
  recipe = { ...recipe, auth: { app_client: null, provider: null, key_types: [] }, questions: [] };
11917
12008
  }
11918
- const declined = assumeYes ? declineAll(recipe.requires_confirmation ?? []) : await askConsent(recipe.requires_confirmation ?? [], interactive2);
12009
+ const declined = assumeYes ? declineAll(recipe.requires_confirmation ?? []) : await askConsent(recipe.requires_confirmation ?? [], interactive2, !!opts.into);
11919
12010
  const answers = await askQuestions(recipe.questions ?? [], ctx, interactive2);
11920
12011
  const started = Date.now();
11921
12012
  const steps = buildSteps({ recipe, caller, teamId, chosenTeamId, teamName, ctx, answers, declined, apiVersion, reinstall, pinned });
@@ -12052,16 +12143,14 @@ function buildSteps(inp) {
12052
12143
  skip: !filled.auth.provider,
12053
12144
  run: async () => {
12054
12145
  const p = filled.auth.provider;
12055
- const clientId = answers.oauth_client_id;
12056
- const clientSecret = answers.oauth_client_secret;
12057
- if (!clientId || !clientSecret) {
12058
- throw new Error("the OAuth client id and secret were not supplied, so the login provider cannot be created");
12059
- }
12060
- const appClientId = await defaultAppClientId(caller, teamId, tenant2);
12061
- await producer(caller, {
12062
- method: "POST",
12063
- path: `/teams/${encodeURIComponent(teamId)}/tenants/${encodeURIComponent(tenant2)}/app-clients/${encodeURIComponent(appClientId)}/providers`,
12064
- body: {
12146
+ const managed = p.managed === true;
12147
+ const body = managed ? { type: p.type, managed: true } : (() => {
12148
+ const clientId = answers.oauth_client_id;
12149
+ const clientSecret = answers.oauth_client_secret;
12150
+ if (!clientId || !clientSecret) {
12151
+ throw new Error("the OAuth client id and secret were not supplied, so the login provider cannot be created");
12152
+ }
12153
+ return {
12065
12154
  type: p.type,
12066
12155
  clientId,
12067
12156
  clientSecret,
@@ -12070,10 +12159,16 @@ function buildSteps(inp) {
12070
12159
  targetServerToken: p.target_server_token,
12071
12160
  includeApiblazeAccessTokenHeader: p.include_apiblaze_access_token_header === true,
12072
12161
  includeApiblazeIdTokenHeader: p.include_apiblaze_id_token_header === true
12073
- },
12074
- summary: `Create the ${String(p.type)} login provider on ${tenant2}`
12162
+ };
12163
+ })();
12164
+ const appClientId = await defaultAppClientId(caller, teamId, tenant2);
12165
+ await producer(caller, {
12166
+ method: "POST",
12167
+ path: `/teams/${encodeURIComponent(teamId)}/tenants/${encodeURIComponent(tenant2)}/app-clients/${encodeURIComponent(appClientId)}/providers`,
12168
+ body,
12169
+ summary: managed ? `Set up APIblaze's managed GitHub login on ${tenant2}` : `Create the ${String(p.type)} login provider on ${tenant2}`
12075
12170
  });
12076
- return String(p.type);
12171
+ return managed ? "APIblaze-hosted GitHub (no setup needed)" : String(p.type);
12077
12172
  }
12078
12173
  },
12079
12174
  {
@@ -12167,7 +12262,8 @@ function buildSteps(inp) {
12167
12262
  function providerLabel(provider) {
12168
12263
  if (!provider) return "login provider";
12169
12264
  const t = String(provider.type ?? "oauth");
12170
- return `${t.charAt(0).toUpperCase()}${t.slice(1)} provider`;
12265
+ const pretty = `${t.charAt(0).toUpperCase()}${t.slice(1)}`;
12266
+ return provider.managed === true ? `${pretty} sign-in (APIblaze's own app \u2014 no setup)` : `${pretty} provider`;
12171
12267
  }
12172
12268
  function transformsLabel(rules, tables) {
12173
12269
  const parts = [];
@@ -12342,13 +12438,13 @@ async function confirmYesNo(message, interactive2) {
12342
12438
  const { ok } = await inquirer3.prompt([{ type: "confirm", name: "ok", message, default: false }]);
12343
12439
  return !!ok;
12344
12440
  }
12345
- async function askConsent(items, interactive2) {
12441
+ async function askConsent(items, interactive2, reinstalling) {
12346
12442
  const declined = /* @__PURE__ */ new Set();
12347
12443
  if (!items.length) return declined;
12348
12444
  const count = items.length;
12349
12445
  const word = ["", "it", "both", "all three", "all four", "all five", "all six"][count] ?? `all ${count}`;
12350
12446
  console.log();
12351
- console.log(import_chalk43.default.yellow("\u26A0 This recipe changes who can reach your proxy, what it trusts, and what it can cost."));
12447
+ console.log(import_chalk43.default.yellow(reinstalling ? "\u26A0 This recipe changes who can reach your proxy, what it trusts, and what it can cost." : "\u26A0 The proxy this creates will be reachable by others, trust an upstream host, and can cost you money:"));
12352
12448
  console.log();
12353
12449
  for (const item of items) {
12354
12450
  console.log(` ${import_chalk43.default.bold(item.kind.padEnd(16))} ${item.value}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apiblaze",
3
- "version": "0.21.37",
3
+ "version": "0.21.38",
4
4
  "description": "APIblaze CLI — Chat with your APIs, Manage your API keys, users and groups with the APIblaze serverless proxy",
5
5
  "keywords": [
6
6
  "apiblaze",