apiblaze 0.21.21 → 0.21.23

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/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.21";
37
+ version = "0.21.23";
38
38
  }
39
39
  });
40
40
 
@@ -5734,8 +5734,6 @@ async function printDevOutcome(o, port) {
5734
5734
  const where = { tenant: o.tenant, anon: o.anon };
5735
5735
  const rest = restUrl(o.name, o.version, o.env, where);
5736
5736
  const agents = o.anon ? mcpUrl(o.name, o.version, o.env, where) : `https://${mcpHost(o.name, o.tenant)}/${o.version}/${o.env}`;
5737
- const first = BRAND === "mcpblaze" ? agents : rest;
5738
- const third = BRAND === "mcpblaze" ? `REST API: ${import_chalk19.default.bold(rest)}` : `agents: ${import_chalk19.default.bold(agents)}`;
5739
5737
  const s = o.secure;
5740
5738
  const applied = s?.applied ?? [];
5741
5739
  const locked = (o.families ?? []).filter((f) => applied.includes(f.key));
@@ -5753,24 +5751,19 @@ async function printDevOutcome(o, port) {
5753
5751
  spinner.stop();
5754
5752
  admin2 = await addAdmin(o.adminEmail, o.tenant, o.teamId);
5755
5753
  }
5754
+ const lockedPhrase = applied.length && s?.enforced ? `${describeFamilies(applied)} locked to their creator` : applied.length && s?.pending ? `${describeFamilies(applied)} rules being applied` : s && !s.enforced && s.errors?.length ? import_chalk19.default.yellow(`rules not applied (${s.errors.join("; ")})`) : "no rules yet (needs a spec)";
5755
+ const adminBit = o.adminEmail ? admin2 ? `${o.adminEmail} is admin${admin2.active ? "" : " (on first sign-in)"}` : import_chalk19.default.yellow(`could not add ${o.adminEmail} as admin`) : "";
5756
+ const restLine = ` ${import_chalk19.default.dim("API ")} ${import_chalk19.default.bold(rest)} ${import_chalk19.default.dim(doorsPhrase(o.auth))}`;
5757
+ const mcpLine = ` ${import_chalk19.default.dim("MCP ")} ${import_chalk19.default.bold(agents)} ${import_chalk19.default.dim("paste into Claude, ChatGPT or Cursor")}`;
5756
5758
  console.log();
5757
- console.log(` ${import_chalk19.default.green("\u2713")} ${import_chalk19.default.bold(first)} \u2192 localhost:${port} ${import_chalk19.default.dim(`(${doorsPhrase(o.auth)})`)}`);
5758
- const adminBit = o.adminEmail ? admin2 ? ` \xB7 ${import_chalk19.default.bold(o.adminEmail)} is admin${admin2.active ? "" : " (active on first sign-in)"}` : ` \xB7 ${import_chalk19.default.yellow(`could not add ${o.adminEmail} as admin`)}` : "";
5759
- if (applied.length && s?.enforced) {
5760
- console.log(` ${import_chalk19.default.green("\u2713")} ${describeFamilies(applied)} locked to their creator${s.skipped?.length ? ` (${s.skipped.length} skipped)` : ""}${adminBit}`);
5761
- } else if (applied.length && s?.pending) {
5762
- console.log(` ${import_chalk19.default.green("\u2713")} ${describeFamilies(applied)}: rules are being applied \u2014 check with ${import_chalk19.default.cyan(`${B.cli} rule ${o.name}`)} in a minute${adminBit}`);
5763
- } else if (s && !s.enforced && s.errors?.length) {
5764
- console.log(` ${import_chalk19.default.yellow("\u26A0")} ${import_chalk19.default.yellow(`not locked \u2014 ${s.errors.join("; ")}`)}${adminBit}`);
5765
- } else {
5766
- console.log(` ${import_chalk19.default.dim("\u2013")} ${import_chalk19.default.dim(`nothing locked \u2014 every caller who gets in can reach every route (npx ${B.cli} rule ${o.name} once you have a spec)`)}${adminBit}`);
5767
- }
5768
- console.log(` ${import_chalk19.default.green("\u2713")} ${third}`);
5769
- if (!o.adminEmail) console.log(` ${import_chalk19.default.dim("\u2013")} ${import_chalk19.default.dim(`no admin yet \u2014 npx ${B.cli} admins add you@example.com --tenant ${o.tenant ?? "<tenant>"}`)}`);
5759
+ console.log(` ${import_chalk19.default.green("\u2713")} ${import_chalk19.default.bold(`localhost:${port} is live on the internet`)}`);
5760
+ console.log(BRAND === "mcpblaze" ? mcpLine : restLine);
5761
+ console.log(BRAND === "mcpblaze" ? restLine : mcpLine);
5770
5762
  if (o.auth.doors.includes("api_key") && o.apiKey) {
5771
- console.log();
5772
- console.log(` ${import_chalk19.default.dim("API key (backend key, for your frontend-server, shown once):")} ${import_chalk19.default.bold.green(o.apiKey)} ${import_chalk19.default.dim("\u2192 send it as X-API-Key and say who is calling with X-End-User-Id")}`);
5763
+ console.log(` ${import_chalk19.default.dim("Key ")} ${import_chalk19.default.bold.green(o.apiKey)} ${import_chalk19.default.dim("shown once \xB7 your server sends it as X-API-Key, naming the user in X-End-User-Id")}`);
5773
5764
  }
5765
+ console.log(` ${import_chalk19.default.dim("Included")} ${["API keys", "sign-in", lockedPhrase, "rate limits", adminBit].filter(Boolean).join(import_chalk19.default.dim(" \xB7 "))}`);
5766
+ if (isVerbose() && !o.adminEmail) console.log(import_chalk19.default.dim(` No admin yet: npx ${B.cli}@latest admins add you@example.com --tenant ${o.tenant ?? "<tenant>"}`));
5774
5767
  const okLines = agentPrompts(locked, o.openapi, null).filter((l) => l.ok);
5775
5768
  const tryHint = okLines[1]?.say ?? okLines[0]?.say;
5776
5769
  await connectAgentStep({
@@ -6320,6 +6313,7 @@ var init_create = __esm({
6320
6313
  init_apichat();
6321
6314
  import_yaml4 = require("yaml");
6322
6315
  import_chalk19 = __toESM(require("chalk"));
6316
+ init_trace();
6323
6317
  import_ora6 = __toESM(require("ora"));
6324
6318
  init_auth();
6325
6319
  init_api();
@@ -6371,340 +6365,186 @@ var init_files = __esm({
6371
6365
  "src/demo-app/files.ts"() {
6372
6366
  "use strict";
6373
6367
  DEMO_FILES = {
6374
- "README.md": "# ResiResi: 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. **Without APIblaze.** The app uses one shared password, so Ben can cancel Ana's booking.\n2. **With APIblaze.** Same app, unchanged. Ben tries again and is blocked: only the person who made\n a booking can change it.\n3. **An AI assistant.** It uses the same app, under the same rules, and cannot cancel Ana's booking either.\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` | The list of endpoints. APIblaze reads it to work out who may change what. |\n| `app.js` | The website's server. It 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\nStop the demo with `npx apiblaze@latest demo stop`.\n",
6375
- "openapi.yaml": "openapi: 3.0.3\ninfo:\n title: ResiResi API\n version: 1.0.0\n description: Table bookings for a pizzeria. This file is all APIblaze reads to decide who may do 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 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: [name, table, time]\n properties:\n name: { type: string, example: Ana }\n table: { type: integer, example: 4 }\n time: { type: string, example: '19:30' }\n Reservation:\n allOf:\n - $ref: '#/components/schemas/NewReservation'\n - type: object\n properties:\n id: { type: string, example: r1 }\n",
6376
- "backend.js": `// The pizzeria's backend: book, list and cancel tables.
6377
- //
6378
- // Look at what is NOT here: no login, no API keys, no "is this your booking?"
6379
- // check, no AI code. APIblaze sits in front of this file and does all of that.
6380
- // The only things this backend knows about people are two headers APIblaze adds
6381
- // to every call it lets through: x-abz-tenant-id, "which pizzeria", and
6382
- // x-abz-user-id, "who is asking".
6383
- //
6384
- // Two doors, so the demo can show the difference:
6385
- // /before/... the way most apps ship: one shared password, anyone holding it can do anything.
6386
- // /... behind APIblaze: every call names its person, and APIblaze decides.
6387
- //
6388
- // Zero dependencies. Run it alone with \`node backend.js\` (port 3001).
6389
-
6390
- const http = require('http');
6391
-
6392
- const PASSWORD = 'pizza123'; // the "before" app's shared secret
6393
- const stores = { before: new Map(), after: new Map() };
6394
- let next = 1;
6395
-
6396
- function send(res, status, body) {
6397
- res.writeHead(status, { 'content-type': 'application/json' });
6398
- res.end(body === undefined ? '' : JSON.stringify(body));
6399
- }
6400
-
6401
- function handle(req, res, body) {
6402
- const url = new URL(req.url, 'http://x');
6403
- let path = url.pathname;
6404
- let door = 'after';
6405
- if (path.startsWith('/before/')) { door = 'before'; path = path.slice('/before'.length); }
6406
-
6407
- if (path === '/openapi.yaml') {
6408
- res.writeHead(200, { 'content-type': 'text/yaml' });
6409
- return res.end(require('fs').readFileSync(require('path').join(__dirname, 'openapi.yaml')));
6410
- }
6411
-
6412
- const who = req.headers['x-abz-user-id'] || '';
6413
- const tenant = req.headers['x-abz-tenant-id'] || '';
6414
- if (door === 'before' && req.headers['x-demo-password'] !== PASSWORD) return send(res, 401, { error: 'wrong password' });
6415
- if (door === 'after' && !who) return send(res, 401, { error: 'only APIblaze may call this door' });
6416
-
6417
- const db = stores[door];
6418
- const m = /^\\/reservations(?:\\/([^/]+))?$/.exec(path);
6419
- if (!m) return send(res, 404, { error: 'not found' });
6420
- const id = m[1];
6421
-
6422
- if (!id && req.method === 'POST') {
6423
- let input = {};
6424
- try { input = JSON.parse(body || '{}'); } catch { return send(res, 400, { error: 'body must be JSON' }); }
6425
- const row = { id: \`r\${next++}\`, name: String(input.name || 'Guest'), table: Number(input.table) || 1, time: String(input.time || '19:30'), owner: who || null, tenant: tenant || null };
6426
- db.set(row.id, row);
6427
- return send(res, 201, row);
6428
- }
6429
- if (!id && req.method === 'GET') {
6430
- // Lists are the backend's job: show each person their own bookings, in their
6431
- // own pizzeria. ("ana" at Nino Pizza and "ana" at Gino Pizza are two people.)
6432
- const rows = [...db.values()].filter((r) => door === 'before' || (r.owner === who && r.tenant === tenant));
6433
- return send(res, 200, rows);
6434
- }
6435
- const row = db.get(id);
6436
- if (!row) return send(res, 404, { error: 'no such booking' });
6437
- if (req.method === 'GET') return send(res, 200, row);
6438
- if (req.method === 'DELETE') { db.delete(id); return send(res, 204); }
6439
- return send(res, 405, { error: 'method not allowed' });
6440
- }
6441
-
6442
- function startBackend(port = Number(process.env.BACKEND_PORT) || 3001) {
6443
- const server = http.createServer((req, res) => {
6444
- let body = '';
6445
- req.on('data', (c) => { body += c; });
6446
- req.on('end', () => {
6447
- try { handle(req, res, body); } catch (e) { send(res, 500, { error: String(e && e.message || e) }); }
6448
- });
6449
- });
6450
- return new Promise((resolve, reject) => {
6451
- server.once('error', reject);
6452
- server.listen(port, '127.0.0.1', () => resolve(server));
6453
- });
6454
- }
6455
-
6456
- module.exports = { startBackend, PASSWORD };
6457
- if (require.main === module) startBackend().then(() => console.log('backend on http://localhost:3001'));
6458
- `,
6459
- "app.js": "// The pizzeria's website server. It serves the page and makes the calls for it.\n//\n// \"Before\" calls go straight to the backend with the shared password.\n// \"After\" calls go through APIblaze with ONE server key, and say who is\n// acting with X-End-User-Id. The browser never sees the key.\n//\n// That is the whole integration: a URL, a key, and a header naming the person.\n\nconst http = require('http');\nconst fs = require('fs');\nconst path = require('path');\nconst { PASSWORD } = require('./backend');\n\nfunction env(name, fallback = '') { return process.env[name] || fallback; }\n\nfunction json(res, status, body) {\n res.writeHead(status, { 'content-type': 'application/json' });\n res.end(JSON.stringify(body));\n}\n\nconst mask = (k) => (k ? `${k.slice(0, 7)}\u2022\u2022\u2022\u2022${k.slice(-4)}` : '');\n\nasync function call(door, as, op, id, extra) {\n const backend = `http://127.0.0.1:${env('BACKEND_PORT', '3001')}`;\n const base = door === 'before' ? `${backend}/before` : env('APIBLAZE_URL').replace(/\\/$/, '');\n const headers = { 'content-type': 'application/json' };\n if (door === 'before') headers['x-demo-password'] = PASSWORD;\n else {\n // Each pizzeria has its own key. \"after\" = Nino Pizza's key, \"gino\" = Gino Pizza's key.\n headers['x-api-key'] = door === 'gino' ? env('APIBLAZE_GINO_KEY') : env('APIBLAZE_SERVER_KEY');\n headers['x-end-user-id'] = as;\n }\n const who = { ana: 'Ana', ben: 'Ben' }[as] || as;\n const req = op === 'book'\n ? { method: 'POST', url: `${base}/reservations`, body: JSON.stringify({ name: who, table: (extra && extra.table) || 4, time: (extra && extra.time) || '19:30' }) }\n : op === 'cancel'\n ? { method: 'DELETE', url: `${base}/reservations/${encodeURIComponent(id || '')}` }\n : op === 'open'\n ? { method: 'GET', 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 body = null;\n try { body = text ? JSON.parse(text) : null; } catch { body = text; }\n return { status: r.status, body };\n}\n\nasync function relayChat(req, res, raw) {\n let input;\n try { input = JSON.parse(raw || '{}'); } catch { return json(res, 400, { error: 'bad json' }); }\n const mcp = env('APIBLAZE_MCP_URL').replace(/\\/$/, '');\n if (!mcp) return json(res, 503, { error: 'The assistant is not set up. Run: npx apiblaze@latest demo' });\n const upstream = await fetch(`${mcp}/runtime-chat`, {\n method: 'POST',\n headers: { 'content-type': 'application/json', 'x-api-key': env('APIBLAZE_SERVER_KEY'), 'x-end-user-id': String(input.as || 'ben') },\n body: JSON.stringify({ id: String(input.id || Date.now()), trigger: 'submit-message', messages: input.messages || [] }),\n });\n res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') || 'application/json', 'cache-control': 'no-cache' });\n if (!upstream.body) return res.end();\n const reader = upstream.body.getReader();\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n res.write(Buffer.from(value));\n }\n res.end();\n}\n\nfunction startApp(port = Number(env('APP_PORT', '3000'))) {\n const page = path.join(__dirname, 'public', 'index.html');\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 if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {\n res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });\n return res.end(fs.readFileSync(page));\n }\n if (url.pathname === '/api/config') {\n return json(res, 200, {\n name: env('APIBLAZE_NAME'), apiUrl: env('APIBLAZE_URL'), mcpUrl: env('APIBLAZE_MCP_URL'),\n key: mask(env('APIBLAZE_SERVER_KEY')),\n ginoKey: mask(env('APIBLAZE_GINO_KEY')),\n });\n }\n if (req.method === 'POST' && url.pathname === '/api/act') {\n const b = JSON.parse(raw || '{}');\n const door = ['before', 'gino'].includes(b.door) ? b.door : 'after';\n return json(res, 200, await call(door, String(b.as || 'ana'), String(b.op || 'list'), b.id, b));\n }\n if (req.method === 'POST' && url.pathname === '/api/chat') return relayChat(req, res, raw);\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",
6368
+ "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",
6369
+ "openapi.yaml": "openapi: 3.0.3\ninfo:\n title: Nino's Pizza API\n version: 1.0.0\n description: Table bookings at Nino's 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 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: [name, table, time]\n properties:\n name: { type: string, example: Ana }\n table: { type: integer, example: 4 }\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' }\n Reservation:\n allOf:\n - $ref: '#/components/schemas/NewReservation'\n - type: object\n properties:\n id: { type: string, example: r1 }\n",
6370
+ "backend.js": "// Nino's Pizza backend: 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//\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// 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\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 if (!who) return reply(401, { error: 'calls come through APIblaze, which says who is calling' });\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 row = {\n id: `r${next++}`, name: String(input.name || who), table: Number(input.table) || 1,\n time: String(input.time || '19:30'), guests: Number(input.guests) || 2, owner: who, tenant,\n };\n bookings.set(row.id, row);\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); 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 };\nif (require.main === module) startBackend().then(() => console.log('backend on http://localhost:3001'));\n",
6371
+ "app.js": "// Nino's Pizza website server: serves the page and makes its calls.\n//\n// Every call goes through APIblaze with ONE server key (kept here, never in the\n// browser) and says who is acting with X-End-User-Id. The chat and the two\n// widgets use apiblaze's own server helpers (lib/apiblaze-server.js, copied in by\n// `npx apiblaze@latest demo` \u2014 the same code as `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)); };\nconst mask = (k) => (k ? `${k.slice(0, 7)}\u2022\u2022\u2022\u2022${k.slice(-4)}` : '');\n\nlet lib = null;\ntry { lib = require('./lib/apiblaze-server.js'); } catch { /* run without the widgets */ }\n\n// Who is using the page. A real app takes this from its own login; the demo lets you switch.\n// App tab: ana, ben \u2014 customers of Nino Pizza\n// Admin tab: ana \u2014 admin of Nino Pizza; gina \u2014 admin of Gino Pizza\nconst PEOPLE = {\n ana: { userId: 'ana', label: 'Ana', email: 'ana@ninopizza.example', tenant: () => env('APIBLAZE_TENANT') },\n ben: { userId: 'ben', label: 'Ben', email: 'ben@ninopizza.example', tenant: () => env('APIBLAZE_TENANT') },\n gina: { userId: 'gina', label: 'Gina', email: 'gina@ginopizza.example', tenant: () => env('APIBLAZE_GINO_TENANT') },\n};\nfunction personOf(req) {\n const as = new URL(req.url, 'http://x').searchParams.get('as') || 'ana';\n return PEOPLE[as] || PEOPLE.ana;\n}\n\nasync function call(op, { as = 'ana', id, key = 'nino', body } = {}) {\n const base = env('APIBLAZE_URL').replace(/\\/$/, '');\n const headers = {\n 'content-type': 'application/json',\n 'x-api-key': key === 'gino' ? env('APIBLAZE_GINO_KEY') : env('APIBLAZE_SERVER_KEY'),\n 'x-end-user-id': as,\n };\n const req = op === 'book' ? { method: 'POST', url: `${base}/reservations`, body: JSON.stringify(body || { name: (PEOPLE[as] || PEOPLE.ana).label, table: 4, time: '19:30', guests: 2 }) }\n : op === 'cancel' ? { method: 'DELETE', url: `${base}/reservations/${encodeURIComponent(id || '')}` }\n : op === 'open' ? { method: 'GET', 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 = {};\n out.chat = lib.createApiblazeChat({\n project: `${env('APIBLAZE_NAME')}-${env('APIBLAZE_TENANT')}`, apiKey: env('APIBLAZE_SERVER_KEY'),\n host: 'tryabz.run', environment: 'dev', getUser: (req) => ({ userId: personOf(req).userId }),\n });\n if (env('APIBLAZE_CP_KEY') && env('APIBLAZE_WIDGETS') === '1') {\n const user = (req) => { const p = personOf(req); return { tenant: p.tenant(), userId: p.userId, email: p.email, label: p.label }; };\n out.keys = lib.createApiblazeKeys({ cpKey: env('APIBLAZE_CP_KEY'), getUser: user, environment: 'dev' });\n out.groups = lib.createApiblazeGroups({ cpKey: env('APIBLAZE_CP_KEY'), getUser: user });\n }\n return out;\n}\n\nfunction startApp(port = Number(env('APP_PORT', '3000'))) {\n const w = 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 if (req.method === 'GET' && p === '/apiblaze-widgets.js' && fs.existsSync(path.join(pub, 'apiblaze-widgets.js'))) {\n res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });\n return res.end(fs.readFileSync(path.join(pub, 'apiblaze-widgets.js')));\n }\n if (p === '/api/config') {\n return json(res, 200, {\n name: env('APIBLAZE_NAME'), apiUrl: env('APIBLAZE_URL'), mcpUrl: env('APIBLAZE_MCP_URL'),\n portalUrl: env('APIBLAZE_PORTAL_URL'), gino: !!env('APIBLAZE_GINO_TENANT'),\n backendPort: env('BACKEND_PORT', '3001'), widgets: { chat: !!w.chat, keys: !!w.keys, groups: !!w.groups },\n });\n }\n if (p === '/api/apiblaze/chat' && w.chat) return viaHandler(w.chat.handler, req, res, raw);\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'), { as: String(b.as || 'ana'), id: b.id, key: b.key, body: b.body }));\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",
6460
6372
  "start.js": "// Starts the pizzeria: the backend (port 3001) and the website (port 3000).\n// `npx apiblaze@latest demo` runs this for you and connects APIblaze to the backend.\n// To run it yourself: `node start.js`, then in another terminal\n// npx apiblaze@latest dev --port 3001 --project <the name in .env.local> --auto\n\nconst fs = require('fs');\nconst path = require('path');\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 await startBackend(backendPort);\n await startApp(appPort);\n return { backendPort, appPort };\n}\n\nmodule.exports = { start, loadEnv };\nif (require.main === module) {\n start().then(({ appPort }) => console.log(`Pizzeria running: http://localhost:${appPort}`))\n .catch((e) => { console.error(`Could not start: ${e.message}`); process.exit(1); });\n}\n",
6461
6373
  "public/index.html": `<!doctype html>
6462
6374
  <html lang="en">
6463
6375
  <head>
6464
6376
  <meta charset="utf-8">
6465
6377
  <meta name="viewport" content="width=device-width, initial-scale=1">
6466
- <title>ResiResi demo</title>
6378
+ <title>Nino's Pizza demo</title>
6467
6379
  <style>
6468
6380
  :root {
6469
- --bg: #faf7f2; --card: #ffffff; --ink: #1d1b19; --muted: #6b6560; --line: #e8e2d9;
6470
- --bad: #c2410c; --bad-bg: #fff1e8; --good: #15803d; --good-bg: #ecfdf3;
6471
- --accent: #e4572e; --accent-ink: #fff; --soft: #f3eee6; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
6381
+ --bg: #faf7f2; --card: #fff; --ink: #1d1b19; --muted: #6b6560; --line: #e8e2d9; --soft: #f3eee6;
6382
+ --accent: #e4572e; --accent-ink: #fff; --good: #15803d; --good-bg: #ecfdf3; --bad: #c2410c; --bad-bg: #fff1e8;
6383
+ --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
6472
6384
  }
6473
6385
  @media (prefers-color-scheme: dark) {
6474
- :root {
6475
- --bg: #161412; --card: #1f1c1a; --ink: #f3efe9; --muted: #a39b92; --line: #34302c;
6476
- --bad: #fb923c; --bad-bg: #2b1a10; --good: #4ade80; --good-bg: #10241a; --soft: #26221f;
6477
- }
6386
+ :root { --bg: #161412; --card: #1f1c1a; --ink: #f3efe9; --muted: #a39b92; --line: #34302c; --soft: #26221f;
6387
+ --good: #4ade80; --good-bg: #10241a; --bad: #fb923c; --bad-bg: #2b1a10; }
6478
6388
  }
6479
6389
  * { box-sizing: border-box; }
6480
6390
  body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
6481
- main { max-width: 1040px; margin: 0 auto; padding: 32px 16px 64px; }
6482
- header { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
6483
- h1 { font-size: 28px; margin: 0; letter-spacing: -0.02em; }
6484
- .badge { font-size: 13px; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px; background: var(--card); }
6485
- .lede { font-size: 18px; color: var(--muted); margin: 8px 0 24px; max-width: 46em; }
6486
- .play { background: var(--accent); color: var(--accent-ink); border: 0; border-radius: 12px; padding: 14px 22px; font-size: 17px; font-weight: 600; cursor: pointer; }
6487
- .play:disabled { opacity: .6; cursor: default; }
6488
- .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px; }
6489
- @media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
6490
- .card { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 20px; }
6491
- .step { font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
6492
- h2 { font-size: 20px; margin: 4px 0 4px; }
6493
- .sub { color: var(--muted); margin: 0 0 16px; font-size: 15px; }
6494
- .row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
6495
- button.act { background: var(--soft); color: var(--ink); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; font-size: 15px; cursor: pointer; }
6496
- button.act:hover { border-color: var(--muted); }
6497
- .log { display: grid; gap: 8px; min-height: 96px; }
6498
- .out { border-radius: 10px; padding: 10px 12px; font-size: 15px; background: var(--soft); }
6499
- .out.bad { background: var(--bad-bg); color: var(--bad); font-weight: 600; }
6391
+ main { max-width: 1000px; margin: 0 auto; padding: 20px 16px 56px; }
6392
+ .live { display: flex; flex-wrap: wrap; gap: 6px 18px; align-items: center; font-size: 13px; color: var(--muted);
6393
+ background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 8px 12px; }
6394
+ .live b { color: var(--ink); font-weight: 600; }
6395
+ .live code { font-family: var(--mono); font-size: 12px; color: var(--ink); word-break: break-all; }
6396
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #16a34a; margin-right: 6px; }
6397
+ nav { display: flex; gap: 6px; margin: 18px 0 14px; }
6398
+ nav button { border: 0; background: transparent; font-size: 17px; padding: 8px 14px; border-radius: 10px; cursor: pointer; color: var(--muted); }
6399
+ nav button[aria-selected="true"] { background: var(--card); color: var(--ink); font-weight: 600; box-shadow: 0 0 0 1px var(--line); }
6400
+ .who { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; font-size: 15px; color: var(--muted); }
6401
+ select { font-size: 15px; padding: 6px 10px; border-radius: 10px; border: 1px solid var(--line); background: var(--card); color: var(--ink); }
6402
+ .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
6403
+ @media (max-width: 820px) { .cols { grid-template-columns: 1fr; } }
6404
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 18px; }
6405
+ h2 { font-size: 19px; margin: 0 0 4px; }
6406
+ .sub { color: var(--muted); font-size: 14px; margin: 0 0 12px; }
6407
+ button.act { background: var(--soft); color: var(--ink); border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; font-size: 14px; cursor: pointer; }
6408
+ button.primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; font-weight: 600; }
6409
+ ul.res { list-style: none; padding: 0; margin: 12px 0 0; display: grid; gap: 8px; }
6410
+ ul.res li { display: flex; align-items: center; gap: 10px; background: var(--soft); border-radius: 10px; padding: 10px 12px; }
6411
+ ul.res li.new { animation: flash 1.5s ease-out; }
6412
+ @keyframes flash { from { background: var(--good-bg); } }
6413
+ ul.res li span { flex: 1; }
6414
+ .empty { color: var(--muted); font-size: 14px; }
6415
+ .out { border-radius: 10px; padding: 8px 10px; font-size: 14px; margin-top: 10px; }
6500
6416
  .out.good { background: var(--good-bg); color: var(--good); font-weight: 600; }
6501
- .out small { display: block; font-weight: 400; color: var(--muted); font-family: var(--mono); font-size: 12px; margin-top: 2px; }
6502
- .chat { margin-top: 16px; }
6503
- .msgs { display: grid; gap: 8px; margin: 12px 0; min-height: 60px; }
6504
- .msg { border-radius: 12px; padding: 10px 12px; max-width: 85%; white-space: pre-wrap; }
6505
- .msg.user { background: var(--accent); color: var(--accent-ink); justify-self: end; }
6506
- .msg.bot { background: var(--soft); }
6507
- .msg.tool { font-family: var(--mono); font-size: 12px; color: var(--muted); background: transparent; padding: 0 12px; }
6508
- form.ask { display: flex; gap: 8px; }
6509
- form.ask input { flex: 1; min-width: 0; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; font-size: 15px; background: var(--card); color: var(--ink); }
6510
- .added { margin-top: 16px; }
6511
- .added ul { list-style: none; padding: 0; margin: 8px 0 0; display: grid; gap: 8px; }
6512
- .added li::before { content: "\u2713 "; color: var(--good); font-weight: 700; }
6513
- code { font-family: var(--mono); font-size: 13px; background: var(--soft); padding: 1px 6px; border-radius: 6px; word-break: break-all; }
6514
- .next { margin-top: 16px; font-size: 17px; }
6417
+ .out.bad { background: var(--bad-bg); color: var(--bad); font-weight: 600; }
6418
+ #chat { min-height: 460px; }
6419
+ .chatcard { padding: 0; overflow: hidden; }
6420
+ .next { margin-top: 16px; color: var(--muted); font-size: 15px; }
6421
+ .next a { color: var(--accent); cursor: pointer; text-decoration: none; font-weight: 600; }
6422
+ .next code { font-family: var(--mono); font-size: 13px; }
6423
+ .stack { display: grid; gap: 16px; }
6515
6424
  </style>
6516
6425
  </head>
6517
6426
  <body>
6518
6427
  <main>
6519
- <header>
6520
- <h1>\u{1F355} ResiResi \xB7 table bookings for pizzerias</h1>
6521
- <span class="badge" id="badge">connecting\u2026</span>
6522
- </header>
6523
- <p class="lede">Ana books a table at Nino Pizza. The same little booking app, twice. On the left, it ships the way most apps do. On the right, APIblaze stands in front of it. Nobody changed the app's code.</p>
6524
- <button class="play" id="play">\u25B6 Play the story</button>
6428
+ <div class="live"><span><span class="dot"></span><b>Running on your laptop, live on the internet</b></span>
6429
+ <span>API <code id="api">\u2026</code></span><span>MCP <code id="mcp">\u2026</code></span></div>
6525
6430
 
6526
- <div class="grid">
6527
- <section class="card">
6528
- <div class="step">1 \xB7 Without APIblaze</div>
6529
- <h2>One shared password</h2>
6530
- <p class="sub">The app holds a password. Anyone who gets it can do anything.</p>
6531
- <div class="row">
6532
- <button class="act" data-door="before" data-as="ana" data-op="book">Ana books table 4</button>
6533
- <button class="act" data-door="before" data-as="ben" data-op="cancel">Ben cancels Ana's booking</button>
6534
- </div>
6535
- <div class="log" id="log-before"></div>
6536
- </section>
6431
+ <nav role="tablist">
6432
+ <button role="tab" id="tab-app" aria-selected="true">\u{1F355} Nino's Pizza</button>
6433
+ <button role="tab" id="tab-admin" aria-selected="false">\u2699 Admin</button>
6434
+ </nav>
6537
6435
 
6538
- <section class="card">
6539
- <div class="step">2 \xB7 With APIblaze</div>
6540
- <h2>Every call says who is asking</h2>
6541
- <p class="sub">APIblaze checks each request. Only the person who made a booking can change it.</p>
6542
- <div class="row">
6543
- <button class="act" data-door="after" data-as="ana" data-op="book">Ana books table 4</button>
6544
- <button class="act" data-door="after" data-as="ben" data-op="cancel">Ben cancels Ana's booking</button>
6436
+ <!-- THE APP: what a customer of the pizzeria sees -->
6437
+ <section id="app">
6438
+ <div class="who">Signed in as
6439
+ <select id="as"><option value="ana">Ana</option><option value="ben">Ben</option></select>
6440
+ </div>
6441
+ <div class="cols">
6442
+ <div class="card">
6443
+ <h2>Your reservations</h2>
6444
+ <p class="sub">Book here, or ask the assistant. Each person only sees their own.</p>
6445
+ <button class="act primary" id="book">Book table 4 at 7:30pm</button>
6446
+ <ul class="res" id="list"><li class="empty">No reservations yet.</li></ul>
6447
+ <div id="try-ben" hidden>
6448
+ <p class="sub" style="margin:14px 0 6px">Ana booked a table. Can Ben cancel it?</p>
6449
+ <button class="act" id="ben-cancel">Cancel Ana's booking</button>
6450
+ <div id="ben-out"></div>
6451
+ </div>
6545
6452
  </div>
6546
- <div class="log" id="log-after"></div>
6547
- </section>
6548
- </div>
6549
-
6550
- <section class="card chat" id="tenants" hidden>
6551
- <div class="step">3 \xB7 Two pizzerias, one app</div>
6552
- <h2>Each pizzeria gets its own key</h2>
6553
- <p class="sub">Nino Pizza and Gino Pizza both use this app. Nino's key <code id="nino-key">\u2026</code> \xB7 Gino's key <code id="gino-key">\u2026</code></p>
6554
- <div class="row">
6555
- <button class="act" id="gino-peek">Gino Pizza's key asks for Nino's booking</button>
6453
+ <div class="card chatcard"><div id="chat"></div></div>
6556
6454
  </div>
6557
- <div class="log" id="log-gino"></div>
6455
+ <p class="next">Every request here went from the internet, through APIblaze, to your laptop: <code>npx apiblaze@latest demo logs</code> shows them. Next: <a id="go-admin">the admin side \u2192</a></p>
6558
6456
  </section>
6559
6457
 
6560
- <section class="card chat">
6561
- <div class="step" id="ai-step">3 \xB7 An AI assistant, same rules</div>
6562
- <h2>Ben asks an AI to do it instead</h2>
6563
- <p class="sub">AI assistants can use this app too, and they get exactly the same answer as Ben.</p>
6564
- <div class="row">
6565
- <button class="act" id="ask-ben">Ben asks: "Cancel Ana's booking"</button>
6566
- <button class="act" id="ask-ana">Ana asks: "What are my bookings?"</button>
6458
+ <!-- ADMIN: what each pizzeria's admin sees -->
6459
+ <section id="admin" hidden>
6460
+ <div class="who">Signed in as
6461
+ <select id="admin-as"><option value="ana">Ana, admin of Nino Pizza</option><option value="gina">Gina, admin of Gino Pizza</option></select>
6567
6462
  </div>
6568
- <div class="msgs" id="msgs"></div>
6569
- <form class="ask" id="ask">
6570
- <input id="q" placeholder="Ask the assistant something, as Ben" autocomplete="off">
6571
- <button class="act" type="submit">Send</button>
6572
- </form>
6573
- </section>
6574
-
6575
- <section class="card added">
6576
- <div class="step">What APIblaze added, with zero code in this app</div>
6577
- <ul>
6578
- <li>A key for this app: <code id="key">\u2026</code> (kept on the server, never in the browser)</li>
6579
- <li>"Only the person who made a booking can change it", worked out from <code>openapi.yaml</code></li>
6580
- <li>A door for AI assistants: <code id="mcp">\u2026</code></li>
6581
- </ul>
6582
- <p class="next">Want this in front of your own app? Ask Claude: <b>"Put APIblaze in front of my app."</b></p>
6463
+ <div class="stack">
6464
+ <div class="card"><div id="keys"></div></div>
6465
+ <div class="card"><div id="groups"></div></div>
6466
+ </div>
6467
+ <p class="next">Each pizzeria only sees its own keys and people. Developers can also sign in and get a key at the <a id="portal" target="_blank" rel="noopener">developer portal</a>.</p>
6583
6468
  </section>
6584
6469
  </main>
6470
+ <script src="/apiblaze-widgets.js"></script>
6585
6471
  <script>
6586
6472
  const $ = (s) => document.querySelector(s);
6587
- const ids = { before: null, after: null };
6588
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6473
+ const post = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) }).then((r) => r.json()).catch(() => ({ status: 0 }));
6474
+ const W = window.ApiblazeWidgets;
6475
+ const theme = { accent: '#e4572e' };
6476
+ let cfg = {};
6477
+ let anaBooking = null;
6589
6478
 
6590
- function show(door, cls, text, detail) {
6591
- const el = document.createElement('div');
6592
- el.className = 'out ' + (cls || '');
6593
- el.textContent = text;
6594
- if (detail) { const s = document.createElement('small'); s.textContent = detail; el.appendChild(s); }
6595
- $('#log-' + door).appendChild(el);
6596
- }
6597
-
6598
- async function act(door, as, op) {
6599
- if (op === 'cancel' && !ids[door]) await act(door, 'ana', 'book');
6600
- const r = await fetch('/api/act', { method: 'POST', headers: { 'content-type': 'application/json' },
6601
- body: JSON.stringify({ door, as, op, id: ids[door] }) }).then((x) => x.json()).catch((e) => ({ status: 0, body: { error: String(e) } }));
6602
- const code = 'HTTP ' + r.status;
6603
- if (op === 'book') {
6604
- if (r.status === 201) { ids[door] = r.body.id; show(door, '', \`Ana booked table \${r.body.table} at \${r.body.time}.\`, \`\${code} \xB7 booking \${r.body.id}\`); }
6605
- else show(door, 'bad', explain(r), code);
6606
- return;
6607
- }
6608
- if (r.status === 204) {
6609
- show(door, door === 'before' ? 'bad' : '', door === 'before' ? "Cancelled. Ben just deleted Ana's table." : 'Cancelled.', code);
6610
- ids[door] = null;
6611
- } else if (r.status === 403 || r.status === 404) {
6612
- show(door, 'good', "\u{1F6E1} Blocked. Only Ana can cancel Ana's booking.", code + ' \xB7 decided by APIblaze, not the app');
6613
- } else show(door, 'bad', explain(r), code);
6614
- }
6615
-
6616
- function explain(r) {
6617
- const b = r.body || {};
6618
- if (r.status === 402) return 'The free demo calls ran out. Run again: npx apiblaze@latest demo';
6619
- if (r.status === 429) return 'Too many clicks at once. Wait a second and try again.';
6620
- if (r.status >= 500 || r.status === 0) return 'The connection to APIblaze dropped. Run again: npx apiblaze@latest demo';
6621
- return (b.detail || b.title || b.error || 'Something went wrong') + '';
6622
- }
6623
-
6624
- $('.grid').addEventListener('click', (e) => {
6625
- const b = e.target.closest('button.act'); if (!b) return;
6626
- act(b.dataset.door, b.dataset.as, b.dataset.op);
6627
- });
6479
+ // \u2500\u2500 tabs \u2500\u2500
6480
+ function show(tab) {
6481
+ $('#app').hidden = tab !== 'app'; $('#admin').hidden = tab !== 'admin';
6482
+ $('#tab-app').setAttribute('aria-selected', tab === 'app'); $('#tab-admin').setAttribute('aria-selected', tab === 'admin');
6483
+ if (tab === 'admin') mountAdmin();
6484
+ }
6485
+ $('#tab-app').onclick = () => show('app');
6486
+ $('#tab-admin').onclick = () => show('admin');
6487
+ $('#go-admin').onclick = () => show('admin');
6628
6488
 
6629
- // \u2500\u2500 the assistant \u2500\u2500
6630
- const thread = { ana: [], ben: [] };
6631
- function bubble(cls, text) { const d = document.createElement('div'); d.className = 'msg ' + cls; d.textContent = text; $('#msgs').appendChild(d); return d; }
6632
- async function ask(as, text) {
6633
- bubble('user', (as === 'ana' ? 'Ana: ' : 'Ben: ') + text);
6634
- const msg = { id: String(Math.random()), role: 'user', parts: [{ type: 'text', text }] };
6635
- thread[as].push(msg);
6636
- let bot = null, said = '';
6637
- try {
6638
- const res = await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' },
6639
- body: JSON.stringify({ as, id: 'demo-' + as, messages: thread[as] }) });
6640
- const ct = res.headers.get('content-type') || '';
6641
- if (!res.ok || !ct.includes('event-stream')) {
6642
- const d = await res.json().catch(() => ({}));
6643
- bubble('bot', res.status === 402 ? 'The free assistant turns ran out for this demo.' : (d.error || d.detail || 'The assistant is not available right now.'));
6644
- return;
6645
- }
6646
- const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = '';
6647
- for (;;) {
6648
- const { done, value } = await reader.read(); if (done) break;
6649
- buf += dec.decode(value, { stream: true });
6650
- let at;
6651
- while ((at = buf.indexOf('\\n\\n')) !== -1) {
6652
- const raw = buf.slice(0, at); buf = buf.slice(at + 2);
6653
- const data = raw.split('\\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\\n');
6654
- if (!data || data === '[DONE]') continue;
6655
- let ev; try { ev = JSON.parse(data); } catch { continue; }
6656
- if (ev.type === 'tool-input-start') bubble('tool', '\u2192 using the app: ' + (ev.toolName || 'tool'));
6657
- if (ev.type === 'tool-output-error') bubble('tool', '\u2190 refused: ' + String(ev.errorText || '').slice(0, 120));
6658
- if (ev.type === 'text-delta') { if (!bot) bot = bubble('bot', ''); said += ev.delta || ''; bot.textContent = said; }
6659
- if (ev.type === 'error') bubble('bot', ev.errorText || 'Something went wrong.');
6660
- }
6661
- }
6662
- if (said) thread[as].push({ id: String(Math.random()), role: 'assistant', parts: [{ type: 'text', text: said }] });
6663
- } catch (e) { bubble('bot', 'The assistant is not reachable right now.'); }
6664
- }
6665
- $('#ask-ben').onclick = async () => {
6666
- if (!ids.after) await act('after', 'ana', 'book');
6667
- ask('ben', \`Cancel booking \${ids.after}. It's Ana's table, I want it.\`);
6489
+ // \u2500\u2500 the app \u2500\u2500
6490
+ const me = () => $('#as').value;
6491
+ const seen = new Set();
6492
+ async function refresh() {
6493
+ const r = await post('/api/act', { op: 'list', as: me() });
6494
+ const rows = Array.isArray(r.body) ? r.body : [];
6495
+ if (me() === 'ana') anaBooking = rows.length ? rows[0].id : null;
6496
+ const ul = $('#list'); ul.replaceChildren();
6497
+ if (!rows.length) { const li = document.createElement('li'); li.className = 'empty'; li.textContent = 'No reservations yet.'; ul.append(li); }
6498
+ for (const b of rows) {
6499
+ const li = document.createElement('li');
6500
+ if (!seen.has(b.id)) { li.className = 'new'; seen.add(b.id); }
6501
+ const t = document.createElement('span');
6502
+ t.textContent = \`Table \${b.table} \xB7 \${b.time} \xB7 \${b.guests || 2} people \xB7 #\${b.id}\`;
6503
+ const x = document.createElement('button'); x.className = 'act'; x.textContent = 'Cancel';
6504
+ x.onclick = async () => { await post('/api/act', { op: 'cancel', as: me(), id: b.id }); refresh(); };
6505
+ li.append(t, x); ul.append(li);
6506
+ }
6507
+ $('#try-ben').hidden = !(me() === 'ben' && anaBooking);
6508
+ }
6509
+ $('#book').onclick = async () => { await post('/api/act', { op: 'book', as: me() }); refresh(); };
6510
+ $('#ben-cancel').onclick = async () => {
6511
+ const r = await post('/api/act', { op: 'cancel', as: 'ben', id: anaBooking });
6512
+ const d = document.createElement('div');
6513
+ if (r.status === 403) { d.className = 'out good'; d.textContent = "\u{1F6E1} Blocked. Only Ana can cancel Ana's booking. APIblaze decided, not the app's code."; }
6514
+ else { d.className = 'out bad'; d.textContent = r.status === 204 ? 'Cancelled. That should not happen.' : 'Something went wrong (HTTP ' + r.status + ').'; }
6515
+ $('#ben-out').replaceChildren(d);
6668
6516
  };
6669
- $('#ask-ana').onclick = () => ask('ana', 'What are my bookings?');
6517
+ function mountChat() {
6518
+ if (!W || !cfg.widgets || !cfg.widgets.chat) return;
6519
+ const who = me() === 'ben' ? 'Ben' : 'Ana';
6520
+ $('#chat').replaceChildren();
6521
+ W.chat('#chat', { endpoint: '/api/apiblaze/chat?as=' + me(), mode: 'inline', title: "Nino's Pizza assistant", avatar: '\u{1F355}', height: 520, theme,
6522
+ storageKey: 'nino-chat-' + me(),
6523
+ welcome: \`Hi \${who}! I can book, list and cancel your tables. I'm using the app on your laptop, through APIblaze.\`,
6524
+ suggestions: ['Book table 2 for 4 people at 8pm', 'What are my reservations?'] });
6525
+ }
6526
+ $('#as').onchange = () => { $('#ben-out').replaceChildren(); refresh(); mountChat(); };
6527
+ setInterval(() => { if (!$('#app').hidden) refresh(); }, 2000); // the assistant's bookings appear here
6670
6528
 
6671
- // \u2500\u2500 two pizzerias \u2500\u2500
6672
- let hasTenants = false;
6673
- async function ginoPeek() {
6674
- if (!ids.after) await act('after', 'ana', 'book');
6675
- // Same person name, different pizzeria: only the key differs.
6676
- const r = await fetch('/api/act', { method: 'POST', headers: { 'content-type': 'application/json' },
6677
- body: JSON.stringify({ door: 'gino', as: 'ana', op: 'open', id: ids.after }) }).then((x) => x.json()).catch(() => ({ status: 0 }));
6678
- const code = 'HTTP ' + r.status;
6679
- if (r.status === 200) show('gino', 'bad', "Gino Pizza can read Nino's booking.", code);
6680
- else if (r.status === 403 || r.status === 404) show('gino', 'good', "\u{1F6E1} Blocked. Gino Pizza's key only sees Gino Pizza's bookings.", code + ' \xB7 decided by APIblaze, not the app');
6681
- else show('gino', 'bad', explain(r), code);
6682
- }
6683
- $('#gino-peek').onclick = ginoPeek;
6684
- $('#ask').onsubmit = (e) => { e.preventDefault(); const q = $('#q').value.trim(); if (q) { $('#q').value = ''; ask('ben', q); } };
6685
-
6686
- $('#play').onclick = async () => {
6687
- $('#play').disabled = true;
6688
- await act('before', 'ana', 'book'); await sleep(700);
6689
- await act('before', 'ben', 'cancel'); await sleep(1200);
6690
- await act('after', 'ana', 'book'); await sleep(700);
6691
- await act('after', 'ben', 'cancel'); await sleep(900);
6692
- if (hasTenants) { await ginoPeek(); await sleep(900); }
6693
- $('#ask-ben').click();
6694
- $('#play').disabled = false;
6695
- };
6529
+ // \u2500\u2500 admin \u2500\u2500
6530
+ function mountAdmin() {
6531
+ if (!W) return;
6532
+ const as = $('#admin-as').value;
6533
+ for (const id of ['#keys', '#groups']) $(id).replaceChildren();
6534
+ if (cfg.widgets && cfg.widgets.keys) W.keys('#keys', { endpoint: '/api/apiblaze/keys?as=' + as, theme, title: 'API keys' });
6535
+ if (cfg.widgets && cfg.widgets.groups) W.groups('#groups', { endpoint: '/api/apiblaze/groups?as=' + as, theme, title: 'People and groups' });
6536
+ }
6537
+ $('#admin-as').onchange = mountAdmin;
6696
6538
 
6697
6539
  fetch('/api/config').then((r) => r.json()).then((c) => {
6698
- $('#badge').textContent = c.name ? 'Protected by APIblaze \xB7 ' + c.name : 'APIblaze not connected';
6699
- $('#key').textContent = c.key || 'not set';
6700
- $('#mcp').textContent = c.mcpUrl || 'not set';
6701
- if (c.ginoKey) {
6702
- hasTenants = true;
6703
- $('#tenants').hidden = false;
6704
- $('#nino-key').textContent = c.key;
6705
- $('#gino-key').textContent = c.ginoKey;
6706
- $('#ai-step').textContent = '4 \xB7 An AI assistant, same rules';
6707
- }
6540
+ cfg = c;
6541
+ $('#api').textContent = c.apiUrl || 'not connected';
6542
+ $('#mcp').textContent = c.mcpUrl || 'not connected';
6543
+ if (c.portalUrl) $('#portal').href = c.portalUrl;
6544
+ if (!c.gino) { const o = $('#admin-as').querySelector('[value="gina"]'); if (o) o.remove(); }
6545
+ if (!c.widgets || (!c.widgets.keys && !c.widgets.groups)) $('#tab-admin').hidden = true;
6546
+ refresh(); mountChat();
6547
+ if (location.hash === '#admin') show('admin');
6708
6548
  });
6709
6549
  </script>
6710
6550
  </body>
@@ -6717,8 +6557,14 @@ fetch('/api/config').then((r) => r.json()).then((c) => {
6717
6557
  // src/commands/demo.ts
6718
6558
  var demo_exports = {};
6719
6559
  __export(demo_exports, {
6560
+ DEMO_ADMIN_EMAIL: () => DEMO_ADMIN_EMAIL,
6561
+ DEMO_GINO_ADMIN_EMAIL: () => DEMO_GINO_ADMIN_EMAIL,
6562
+ DEMO_RPS: () => DEMO_RPS,
6720
6563
  demoName: () => demoName,
6564
+ routesOf: () => routesOf,
6721
6565
  runDemo: () => runDemo,
6566
+ runDemoLogs: () => runDemoLogs,
6567
+ runDemoStart: () => runDemoStart,
6722
6568
  runDemoStop: () => runDemoStop,
6723
6569
  stopDemos: () => stopDemos
6724
6570
  });
@@ -6774,7 +6620,8 @@ async function stopDemos() {
6774
6620
  return stopped;
6775
6621
  }
6776
6622
  function writeApp(dir) {
6777
- import_fs5.default.rmSync(dir, { recursive: true, force: true });
6623
+ import_fs5.default.mkdirSync(dir, { recursive: true });
6624
+ for (const entry of import_fs5.default.readdirSync(dir)) import_fs5.default.rmSync(import_path4.default.join(dir, entry), { recursive: true, force: true });
6778
6625
  for (const [rel, text] of Object.entries(DEMO_FILES)) {
6779
6626
  const f = import_path4.default.join(dir, rel);
6780
6627
  import_fs5.default.mkdirSync(import_path4.default.dirname(f), { recursive: true });
@@ -6782,6 +6629,11 @@ function writeApp(dir) {
6782
6629
  }
6783
6630
  import_fs5.default.writeFileSync(import_path4.default.join(dir, "package.json"), JSON.stringify({ name: "nino-pizza-demo", private: true, scripts: { start: "node start.js" } }, null, 2) + "\n");
6784
6631
  import_fs5.default.writeFileSync(import_path4.default.join(dir, ".gitignore"), ".env.local\n.demo/\n");
6632
+ for (const [from, to] of [[import_path4.default.join(__dirname, "server", "index.js"), import_path4.default.join(dir, "lib", "apiblaze-server.js")], [import_path4.default.join(__dirname, "browser", "widgets.js"), import_path4.default.join(dir, "public", "apiblaze-widgets.js")]]) {
6633
+ if (!import_fs5.default.existsSync(from)) continue;
6634
+ import_fs5.default.mkdirSync(import_path4.default.dirname(to), { recursive: true });
6635
+ import_fs5.default.copyFileSync(from, to);
6636
+ }
6785
6637
  }
6786
6638
  function saveState(dir, s) {
6787
6639
  const d = import_path4.default.join(dir, STATE_DIR);
@@ -6795,7 +6647,7 @@ function loadState(dir) {
6795
6647
  return null;
6796
6648
  }
6797
6649
  }
6798
- async function createProxy2(name, backendPort, openapi) {
6650
+ async function createProxy2(name, backendPort, openapi, tenantName) {
6799
6651
  const auth = resolveAuth({});
6800
6652
  const proposed = await proposeFromSpec(openapi).catch(() => null);
6801
6653
  const families = proposed?.status === 200 && Array.isArray(proposed.data?.families) ? proposed.data.families.filter((f) => !f.skipped_reason).map((f) => f.key) : [];
@@ -6808,7 +6660,7 @@ async function createProxy2(name, backendPort, openapi) {
6808
6660
  environments: { dev: { target: `http://localhost:${backendPort}`, description: `the ${B.cli} demo on this machine` } },
6809
6661
  // Two customers of the same app, each with its own key: the proxy's own tenant
6810
6662
  // is Nino Pizza, and the server adds Gino Pizza and returns its key.
6811
- tenant: `ninopizza${import_crypto.default.randomBytes(2).toString("hex")}`,
6663
+ tenant: tenantName,
6812
6664
  extra_tenants: ["Gino Pizza"],
6813
6665
  ...families.length ? { secure: { families } } : {}
6814
6666
  };
@@ -6849,6 +6701,14 @@ async function waitReady(dir, s, serverKey) {
6849
6701
  }
6850
6702
  throw new DemoError(`The demo is running but ${B.product} cannot reach it yet (last answer ${last}).`, `Run again: ${LATEST} demo`);
6851
6703
  }
6704
+ async function offerBrowser(url, opts) {
6705
+ if (opts.open === false || !process.stdin.isTTY || !process.stdout.isTTY) return;
6706
+ const readline3 = await import("readline");
6707
+ const rl = readline3.createInterface({ input: process.stdin, output: process.stdout });
6708
+ const answer = await new Promise((res) => rl.question(` Open ${url} in your browser? (Y/n) `, res));
6709
+ rl.close();
6710
+ if (!/^n/i.test(answer.trim())) openBrowser3(url);
6711
+ }
6852
6712
  function openBrowser3(url) {
6853
6713
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
6854
6714
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
@@ -6870,17 +6730,56 @@ async function runDemo(opts = {}) {
6870
6730
  process.exit(1);
6871
6731
  }
6872
6732
  }
6733
+ function routesOf(openapi) {
6734
+ const out = [];
6735
+ let curPath = "";
6736
+ let cur = null;
6737
+ for (const line of openapi.split("\n")) {
6738
+ const p = /^ {2}(\/[^:]*):\s*$/.exec(line);
6739
+ if (p) {
6740
+ curPath = p[1];
6741
+ cur = null;
6742
+ continue;
6743
+ }
6744
+ const mth = /^ {4}(get|post|put|patch|delete):\s*$/.exec(line);
6745
+ if (mth && curPath) {
6746
+ cur = { method: mth[1].toUpperCase(), path: curPath, summary: "", op: "" };
6747
+ out.push(cur);
6748
+ continue;
6749
+ }
6750
+ const sum = /^ {6}summary:\s*(.+)$/.exec(line);
6751
+ if (sum && cur) cur.summary = sum[1].trim();
6752
+ const op2 = /^ {6}operationId:\s*(.+)$/.exec(line);
6753
+ if (op2 && cur) cur.op = op2[1].trim();
6754
+ }
6755
+ return out;
6756
+ }
6873
6757
  async function startDemo(opts) {
6874
6758
  const t0 = Date.now();
6875
- const dir = import_path4.default.resolve(process.cwd(), DIR_NAME);
6876
- console.log(import_chalk25.default.dim(`Setting up the demo in ./${DIR_NAME} (about 30 seconds)\u2026`));
6759
+ const here = process.cwd();
6760
+ const dir = import_fs5.default.existsSync(import_path4.default.join(here, STATE_DIR, "state.json")) ? here : import_path4.default.resolve(here, DIR_NAME);
6761
+ const rel = import_path4.default.relative(here, dir) || ".";
6762
+ const run2 = (...cmds) => {
6763
+ for (const c of cmds) console.log(import_chalk25.default.dim(` $ ${LATEST} ${c}`));
6764
+ };
6765
+ const step = (label3, text, note = "") => console.log(` ${import_chalk25.default.green("\u2713")} ${import_chalk25.default.bold(label3.padEnd(17))} ${text}${note ? ` ${import_chalk25.default.dim(note)}` : ""}`);
6877
6766
  await stopDemos();
6878
6767
  writeApp(dir);
6879
6768
  const appPort = await freePort(3e3);
6880
6769
  const backendPort = await freePort(3001, [appPort]);
6881
6770
  const name = demoName();
6882
6771
  const openapi = DEMO_FILES["openapi.yaml"];
6883
- const r = await createProxy2(name, backendPort, openapi);
6772
+ console.log();
6773
+ console.log(import_chalk25.default.bold(`1. The app: Nino's Pizza, a table-booking backend, now in ${rel}`));
6774
+ console.log(import_chalk25.default.dim(` backend.js runs on this laptop (localhost:${backendPort}). Its routes, from openapi.yaml:`));
6775
+ for (const r2 of routesOf(openapi)) console.log(` ${r2.method.padEnd(7)}${r2.path.padEnd(21)}${import_chalk25.default.dim(r2.summary)}`);
6776
+ console.log(import_chalk25.default.dim(" No login, no API keys, no permission checks in its code. That is APIblaze's job."));
6777
+ console.log();
6778
+ console.log(import_chalk25.default.bold(`2. ${B.product}, in front of it`) + import_chalk25.default.dim(" (each step does what the command above it does)"));
6779
+ const ninoTenant = `ninopizza${import_crypto.default.randomBytes(2).toString("hex")}`;
6780
+ run2(`dev --port ${backendPort} --openapi ./openapi.yaml --name ${name} --tenant ${ninoTenant} --auto`);
6781
+ const tCreate = Date.now();
6782
+ const r = await createProxy2(name, backendPort, openapi, ninoTenant);
6884
6783
  const cpKey = r.cp_key;
6885
6784
  const teamId = r.team_id;
6886
6785
  const keys = r.api_keys ?? {};
@@ -6892,45 +6791,152 @@ async function startDemo(opts) {
6892
6791
  const missing = [!cpKey && "workspace key", !teamId && "team", !serverKey && "API key", !tenant2 && "tenant"].filter(Boolean);
6893
6792
  if (missing.length) throw new DemoError(`${B.product} did not return everything the demo needs (${missing.join(", ")}).`, `Run again: ${LATEST} demo`);
6894
6793
  const apiUrl = restUrl(project, version2, "dev", { anon: true });
6895
- const chatBase = `https://${project}-${tenant2}.mcp.tryabz.run/${version2}/dev`;
6896
- const gino = (r.tenants ?? []).find((t) => t.api_key);
6794
+ const gino = (rr.tenants ?? []).find((t) => t.api_key);
6795
+ const agents = mcpUrl(project, version2, "dev", { tenant: tenant2, anon: true });
6796
+ step("Proxy", `${project}, created from openapi.yaml`, `${((Date.now() - tCreate) / 1e3).toFixed(1)} s \xB7 no account needed`);
6797
+ const applied = (rr.secure?.applied ?? []).map((k) => k.replace(/^\//, "").split("/")[0]);
6798
+ if (applied.length) step("Rules", `${applied.join(", ")}: only the person who made one can change or cancel it`);
6799
+ const tools0 = routesOf(openapi).map((x) => x.op).filter(Boolean);
6800
+ step("AI", `${tools0.length} tools for AI assistants, one per route (${tools0.join(", ")})`, "the MCP address");
6801
+ if (gino?.tenant) run2(`tenant create --name "Gino Pizza" --slug ${gino.tenant}`, `tenant attach ${project} --tenant ${gino.tenant}`, `apikeys mint --tenant ${gino.tenant} --backend --env dev`);
6802
+ step("Customers/Tenants", gino ? `Nino Pizza and Gino Pizza, each with its own API key` : `Nino Pizza, with its own API key`, "one API, kept apart");
6803
+ run2(`admins add ${DEMO_ADMIN_EMAIL} --tenant ${tenant2}`, ...gino?.tenant ? [`admins add ${DEMO_GINO_ADMIN_EMAIL} --tenant ${gino.tenant}`] : []);
6804
+ const limited = await cpFetch(cpKey, `/projects/${encodeURIComponent(project)}/${encodeURIComponent(version2)}/limits`, {
6805
+ method: "PUT",
6806
+ body: JSON.stringify({ consumerRps: DEMO_RPS, endUserRps: DEMO_RPS })
6807
+ }).then(() => true).catch(() => false);
6808
+ const adminOk = await cpFetch(cpKey, `/teams/${encodeURIComponent(teamId)}/tenants/${encodeURIComponent(tenant2)}/admin-emails`, {
6809
+ method: "POST",
6810
+ body: JSON.stringify({ email: DEMO_ADMIN_EMAIL })
6811
+ }).then(() => true).catch(() => false);
6812
+ let ginaOk = false;
6813
+ if (gino?.tenant) {
6814
+ ginaOk = await cpFetch(cpKey, `/teams/${encodeURIComponent(teamId)}/tenants/${encodeURIComponent(gino.tenant)}/admin-emails`, {
6815
+ method: "POST",
6816
+ body: JSON.stringify({ email: DEMO_GINO_ADMIN_EMAIL })
6817
+ }).then(() => true).catch(() => false);
6818
+ }
6819
+ if (adminOk) step("Admins", ginaOk ? "Ana runs Nino Pizza, Gina runs Gino Pizza" : "Ana runs Nino Pizza", "they manage keys and people on the Admin tab");
6820
+ run2(`limits set ${project} --rate ${DEMO_RPS} --end-user-rate ${DEMO_RPS}`);
6821
+ if (limited) step("Limits", `${DEMO_RPS} calls a second per customer`);
6897
6822
  writeEnv(dir, {
6898
6823
  APIBLAZE_NAME: project,
6824
+ APIBLAZE_TENANT: tenant2,
6899
6825
  APIBLAZE_URL: apiUrl,
6900
6826
  APIBLAZE_SERVER_KEY: serverKey,
6901
- ...gino?.api_key ? { APIBLAZE_GINO_KEY: gino.api_key } : {},
6902
- APIBLAZE_MCP_URL: chatBase,
6827
+ ...gino?.api_key ? { APIBLAZE_GINO_KEY: gino.api_key, APIBLAZE_GINO_TENANT: gino.tenant ?? "" } : {},
6828
+ APIBLAZE_MCP_URL: agents,
6829
+ APIBLAZE_PORTAL_URL: `https://${tenant2}.portal.apiblaze.com/${version2}`,
6830
+ // The keys + users widgets mint through the workspace's control key (server-side only).
6831
+ APIBLAZE_CP_KEY: cpKey,
6832
+ APIBLAZE_WIDGETS: process.env.APIBLAZE_DEMO_WIDGETS === "0" ? 0 : 1,
6833
+ APIBLAZE_DEMO_RPS: DEMO_RPS,
6903
6834
  APP_PORT: appPort,
6904
6835
  BACKEND_PORT: backendPort
6905
6836
  });
6906
6837
  const state = { name: project, version: version2, tenant: tenant2, teamId, cpKey, appPort, backendPort, apiUrl, claimCode: r.claim_code };
6907
6838
  saveState(dir, state);
6908
- const log4 = import_fs5.default.openSync(import_path4.default.join(dir, STATE_DIR, "demo.log"), "a");
6909
- const child = (0, import_child_process4.spawn)(process.execPath, [process.argv[1], "demo", "--serve", dir], { cwd: dir, detached: true, stdio: ["ignore", log4, log4] });
6910
- child.unref();
6911
- saveState(dir, { ...state, pid: child.pid });
6912
- writeRegistry([...readRegistry(), { dir, pid: child.pid ?? 0 }]);
6913
- await waitReady(dir, state, serverKey);
6839
+ step("Keys", "saved to .env.local for the website's server, never printed");
6840
+ await spawnServe(dir, state, serverKey);
6841
+ step("Tunnel", `the public address now reaches localhost:${backendPort} on this laptop`, "the dev command above keeps it open \xB7 checked with a real call");
6842
+ console.log(import_chalk25.default.dim(` $ node start.js`) + import_chalk25.default.dim(" (in the demo folder: the backend and the website)"));
6843
+ step("Website", `http://localhost:${appPort}, calling ${B.product} with one server key`);
6914
6844
  const url = `http://localhost:${appPort}`;
6915
- if (opts.open !== false) openBrowser3(url);
6916
6845
  const secs = Math.round((Date.now() - t0) / 1e3);
6917
- const agents = mcpUrl(project, version2, "dev", { tenant: tenant2, anon: true });
6846
+ printReady(dir, secs);
6847
+ await offerBrowser(url, opts);
6848
+ }
6849
+ function printReady(dir, secs) {
6850
+ const env = readEnvFile(import_path4.default.join(dir, ".env.local"));
6851
+ const row = (label3, value, note = "") => console.log(` ${label3.padEnd(12)} ${value}${note ? ` ${import_chalk25.default.dim(note)}` : ""}`);
6918
6852
  console.log();
6919
- console.log(`${import_chalk25.default.green("\u2713")} The demo is live: ${import_chalk25.default.bold(url)} ${import_chalk25.default.dim(`(${secs} s)`)}`);
6853
+ console.log(import_chalk25.default.bold(`3. Live: Nino's Pizza runs on this laptop and is on the internet`) + ` ${import_chalk25.default.dim(`(${secs} s)`)}`);
6920
6854
  console.log();
6921
- console.log(" What it shows. Tell the user this, in plain words:");
6922
- console.log(" 1. Without APIblaze, a booking app uses one shared password, so Ben can cancel Ana's table.");
6923
- console.log(` 2. With ${B.product} in front of the same app, unchanged, Ben tries again. Blocked.`);
6924
- if (gino) console.log(" 3. Two pizzerias share the app, each with its own key. Gino Pizza's key cannot see Nino Pizza's bookings.");
6925
- console.log(` ${gino ? 4 : 3}. An AI assistant uses the same app under the same rules. It cannot cancel Ana's table either.`);
6926
- console.log(' The page has a "Play the story" button that runs it all.');
6855
+ row("App", import_chalk25.default.bold(`http://localhost:${env.APP_PORT}`));
6856
+ row("Public API", env.APIBLAZE_URL ?? "", `answered by localhost:${env.BACKEND_PORT}`);
6857
+ row("MCP", env.APIBLAZE_MCP_URL ?? "", "for Claude, ChatGPT, Cursor");
6858
+ if (env.APIBLAZE_PORTAL_URL) row("Dev portal", env.APIBLAZE_PORTAL_URL, "sign in, get a key, try the API");
6927
6859
  console.log();
6928
- console.log(` The app's code is in ./${DIR_NAME}. backend.js has no login or permissions code.`);
6929
- console.log(` Its key is saved in ./${DIR_NAME}/.env.local, never shown here.`);
6930
- console.log(import_chalk25.default.dim(` For AI assistants: ${agents}`));
6931
- console.log(import_chalk25.default.dim(` Stop it: ${LATEST} demo stop \xB7 Run it again any time: ${LATEST} demo`));
6860
+ row("Logs", `${LATEST} demo logs`, "every request reaching your laptop");
6861
+ row("Stop", `${LATEST} demo stop`);
6862
+ row("Restart", `${LATEST} demo restart`, "same addresses and keys");
6863
+ row("Start over", `${LATEST} demo`, "a brand-new demo");
6932
6864
  console.log();
6933
- console.log(` Ask the user one question: "Want to put this in front of your own app?"`);
6865
+ console.log(import_chalk25.default.dim(` Code in ${import_path4.default.relative(process.cwd(), dir) || "."} \xB7 keys in .env.local, never shown here`));
6866
+ console.log(import_chalk25.default.dim(` Your own app: ${LATEST} dev --port <port> \xB7 deployed already: ${LATEST} create --target <url>`));
6867
+ }
6868
+ function readEnvFile(file2) {
6869
+ const out = {};
6870
+ try {
6871
+ for (const line of import_fs5.default.readFileSync(file2, "utf8").split("\n")) {
6872
+ const m = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
6873
+ if (m) out[m[1]] = m[2];
6874
+ }
6875
+ } catch {
6876
+ }
6877
+ return out;
6878
+ }
6879
+ function demoDir() {
6880
+ const here = process.cwd();
6881
+ if (import_fs5.default.existsSync(import_path4.default.join(here, STATE_DIR, "state.json"))) return here;
6882
+ return import_path4.default.resolve(here, DIR_NAME);
6883
+ }
6884
+ async function spawnServe(dir, state, serverKey) {
6885
+ const log4 = import_fs5.default.openSync(import_path4.default.join(dir, STATE_DIR, "demo.log"), "a");
6886
+ const child = (0, import_child_process4.spawn)(process.execPath, [process.argv[1], "demo", "--serve", dir], { cwd: dir, detached: true, stdio: ["ignore", log4, log4] });
6887
+ child.unref();
6888
+ saveState(dir, { ...state, pid: child.pid, ready: false, error: void 0 });
6889
+ writeRegistry([...readRegistry(), { dir, pid: child.pid ?? 0 }]);
6890
+ await waitReady(dir, state, serverKey);
6891
+ }
6892
+ async function runDemoStart(opts = {}) {
6893
+ const t0 = Date.now();
6894
+ const dir = demoDir();
6895
+ const state = loadState(dir);
6896
+ const env = readEnvFile(import_path4.default.join(dir, ".env.local"));
6897
+ if (!state || !env.APIBLAZE_SERVER_KEY) {
6898
+ console.log(`${import_chalk25.default.red("\u2717")} There is no demo here to start.`);
6899
+ console.log(` Make one: ${LATEST} demo`);
6900
+ process.exit(1);
6901
+ }
6902
+ try {
6903
+ await stopDemos();
6904
+ await spawnServe(dir, state, env.APIBLAZE_SERVER_KEY);
6905
+ } catch (err) {
6906
+ const e = err instanceof DemoError ? err : new DemoError(`The demo could not start (${err.message}).`, `Start over: ${LATEST} demo`);
6907
+ await stopDemos().catch(() => 0);
6908
+ console.log(`${import_chalk25.default.red("\u2717")} ${e.message}`);
6909
+ console.log(` ${e.fix}`);
6910
+ process.exit(1);
6911
+ }
6912
+ printReady(dir, Math.round((Date.now() - t0) / 1e3));
6913
+ await offerBrowser(`http://localhost:${env.APP_PORT}`, opts);
6914
+ }
6915
+ async function runDemoLogs() {
6916
+ const file2 = import_path4.default.join(demoDir(), STATE_DIR, "demo.log");
6917
+ if (!import_fs5.default.existsSync(file2)) {
6918
+ console.log(`${import_chalk25.default.red("\u2717")} There is no demo log here.`);
6919
+ console.log(` Make a demo: ${LATEST} demo`);
6920
+ process.exit(1);
6921
+ }
6922
+ console.log(import_chalk25.default.dim(`Following ${import_path4.default.relative(process.cwd(), file2)} \u2014 Ctrl-C stops following (the demo keeps running).`));
6923
+ let pos = Math.max(0, import_fs5.default.statSync(file2).size - 4e3);
6924
+ const pump = () => {
6925
+ const size = import_fs5.default.statSync(file2).size;
6926
+ if (size < pos) pos = 0;
6927
+ if (size > pos) {
6928
+ const fd = import_fs5.default.openSync(file2, "r");
6929
+ const buf = Buffer.alloc(size - pos);
6930
+ import_fs5.default.readSync(fd, buf, 0, buf.length, pos);
6931
+ import_fs5.default.closeSync(fd);
6932
+ pos = size;
6933
+ process.stdout.write(buf.toString("utf8"));
6934
+ }
6935
+ };
6936
+ pump();
6937
+ setInterval(pump, 500);
6938
+ await new Promise(() => {
6939
+ });
6934
6940
  }
6935
6941
  async function runDemoStop() {
6936
6942
  const n = await stopDemos();
@@ -6999,7 +7005,7 @@ async function serveDemo(dir) {
6999
7005
  await new Promise(() => {
7000
7006
  });
7001
7007
  }
7002
- var import_fs5, import_path4, import_net, import_child_process4, import_crypto, import_module, import_chalk25, DIR_NAME, STATE_DIR, LATEST, DemoError, ADJ, NOUN, pick2, registryFile, alive;
7008
+ var import_fs5, import_path4, import_net, import_child_process4, import_crypto, import_module, import_chalk25, DIR_NAME, STATE_DIR, LATEST, DEMO_RPS, DEMO_ADMIN_EMAIL, DEMO_GINO_ADMIN_EMAIL, DemoError, ADJ, NOUN, pick2, registryFile, alive;
7003
7009
  var init_demo = __esm({
7004
7010
  "src/commands/demo.ts"() {
7005
7011
  "use strict";
@@ -7013,12 +7019,16 @@ var init_demo = __esm({
7013
7019
  init_api();
7014
7020
  init_tunnel_client();
7015
7021
  init_auth();
7022
+ init_anon_cred();
7016
7023
  init_brand();
7017
7024
  init_create();
7018
7025
  init_files();
7019
7026
  DIR_NAME = `${B.cli}-demo`;
7020
7027
  STATE_DIR = ".demo";
7021
7028
  LATEST = `npx ${B.cli}@latest`;
7029
+ DEMO_RPS = 2;
7030
+ DEMO_ADMIN_EMAIL = "ana@ninopizza.example";
7031
+ DEMO_GINO_ADMIN_EMAIL = "gina@ginopizza.example";
7022
7032
  DemoError = class extends Error {
7023
7033
  constructor(message, fix) {
7024
7034
  super(message);
@@ -7354,6 +7364,13 @@ async function journey(label3, work, home, checks) {
7354
7364
  } else {
7355
7365
  add("tenants-record", false, "no Gino Pizza key: the server did not return extra_tenants");
7356
7366
  }
7367
+ const page = `http://127.0.0.1:${env.APP_PORT}`;
7368
+ const made = await http(`${page}/api/apiblaze/keys`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "create" }) });
7369
+ const widgetKey = made.body?.key;
7370
+ const used = widgetKey ? await http(`${api}/reservations`, { headers: { "x-api-key": widgetKey } }) : { status: 0 };
7371
+ add("keys-widget", made.status === 201 && used.status === 200, `create ${made.status} \xB7 the new key calls the API ${used.status}`);
7372
+ const snap = await http(`${page}/api/apiblaze/groups`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "snapshot" }) });
7373
+ add("users-widget", snap.status === 200 && snap.body?.access === "ok", `snapshot ${snap.status} \xB7 access ${snap.body?.access ?? "-"}`);
7357
7374
  const chat = await http(`${env.APIBLAZE_MCP_URL}/runtime-chat`, {
7358
7375
  method: "POST",
7359
7376
  headers: as("ana"),
@@ -7368,11 +7385,16 @@ async function journey(label3, work, home, checks) {
7368
7385
  }
7369
7386
  }).filter(Boolean).filter((e) => e.type === "text-delta").map((e) => e.delta ?? "").join("");
7370
7387
  const streamErr = /"type":"error"/.test(chat.text);
7371
- add(
7372
- "ai-turn",
7373
- chat.status === 200 && chat.type.includes("event-stream") && said.length > 0 && !streamErr,
7374
- `HTTP ${chat.status}${streamErr ? " \xB7 stream error" : ""} \xB7 ${said.length} chars${chat.status !== 200 ? ` \xB7 ${chat.text.slice(0, 120)}` : ""}`
7375
- );
7388
+ const ipCapped = /data-apiblaze-upsell/.test(chat.text) && /Free chats used up/.test(chat.text);
7389
+ if (ipCapped) {
7390
+ checks.push({ id: `${label3}:ai-turn`, ok: true, skipped: true, detail: "SKIPPED \u2014 this machine's IP is at today's free-chat cap (resets daily); a newcomer's first chat is unaffected" });
7391
+ } else {
7392
+ add(
7393
+ "ai-turn",
7394
+ chat.status === 200 && chat.type.includes("event-stream") && said.length > 0 && !streamErr,
7395
+ `HTTP ${chat.status}${streamErr ? " \xB7 stream error" : ""} \xB7 ${said.length} chars${chat.status !== 200 ? ` \xB7 ${chat.text.slice(0, 120)}` : ""}`
7396
+ );
7397
+ }
7376
7398
  return env;
7377
7399
  }
7378
7400
  async function runOpAnon(o = {}) {
@@ -7401,9 +7423,13 @@ async function runOpAnon(o = {}) {
7401
7423
  checks.push({ id: "stop", ok: stop.code === 0 && portsFree, detail: `exit ${stop.code}${portsFree ? "" : " \xB7 ports still answering"}` });
7402
7424
  import_fs8.default.rmSync(root, { recursive: true, force: true });
7403
7425
  }
7404
- for (const c of checks) log4(` ${c.ok ? import_chalk70.default.green("\u2713") : import_chalk70.default.red("\u2717")} ${c.id.padEnd(24)} ${import_chalk70.default.dim(c.detail)}`);
7426
+ for (const c of checks) log4(` ${c.skipped ? import_chalk70.default.yellow("\u2013") : c.ok ? import_chalk70.default.green("\u2713") : import_chalk70.default.red("\u2717")} ${c.id.padEnd(24)} ${import_chalk70.default.dim(c.detail)}`);
7405
7427
  const failed = checks.filter((c) => !c.ok).length;
7406
- log4(failed ? import_chalk70.default.red(`${checks.length - failed}/${checks.length} passed (${Math.round((Date.now() - t0) / 1e3)} s)`) : import_chalk70.default.green(`${checks.length}/${checks.length} passed (${Math.round((Date.now() - t0) / 1e3)} s)`));
7428
+ const skipped = checks.filter((c) => c.skipped).length;
7429
+ const passed = checks.length - failed - skipped;
7430
+ const secs = Math.round((Date.now() - t0) / 1e3);
7431
+ const tail = `${skipped ? `, ${skipped} skipped` : ""} (${secs} s)`;
7432
+ log4(failed ? import_chalk70.default.red(`${passed}/${checks.length - skipped} passed, ${failed} failed${tail}`) : import_chalk70.default.green(`${passed}/${checks.length - skipped} passed${tail}`));
7407
7433
  if (o.json) process.stdout.write(JSON.stringify({ ok: !failed, checks }, null, 2) + "\n");
7408
7434
  if (failed) process.exitCode = 1;
7409
7435
  return { ok: !failed, checks };
@@ -7973,12 +7999,11 @@ description: Use when the user asks what ${P} (or ${B.cli}) does or can do for t
7973
7999
 
7974
8000
  ## Explain: they ask what ${P} does
7975
8001
 
7976
- Run nothing. Say this, in these words or very close to them, then stop:
8002
+ Run nothing. Say exactly this and nothing else: no list of use cases, no commands, no options. Then stop:
7977
8003
 
7978
- "${P} puts a smart front door on your app's backend. In one command, every customer gets
7979
- their own key, people can only touch their own data, and AI assistants like me can use your
7980
- app safely, under the same rules. You don't write any login or permissions code. Want me to
7981
- show you in about 30 seconds?"
8004
+ "${P} puts the app on your laptop on the internet in one command. It gets a public address,
8005
+ plus an address AI assistants like me can use to work with it. API keys, sign-in and
8006
+ who-can-change-what come built in, with no code. Want me to show you in about 30 seconds?"
7982
8007
 
7983
8008
  Do not use the words proxy, MCP, tenant, OpenAPI or header unless the user does first.
7984
8009
 
@@ -7986,15 +8011,13 @@ Do not use the words proxy, MCP, tenant, OpenAPI or header unless the user does
7986
8011
 
7987
8012
  1. Say one line: "Starting the demo, about 30 seconds."
7988
8013
  2. Run exactly this, in the foreground. It needs no login and asks nothing. It sets
7989
- everything up, opens the browser and exits by itself once the page is live:
8014
+ everything up and exits by itself once the page is live. It opens nothing: give the user the App link it prints:
7990
8015
  \`${run2} demo\`
7991
- 3. When it prints \u2713, tell the three-beat story it printed, in plain words, with the link:
7992
- - "Here's a booking app where anyone with the password can do anything: Ben cancels Ana's table."
7993
- - "Same app with ${P} in front, no code changed: Ben tries again. Blocked."
7994
- - If it printed the pizzerias line: "Two pizzerias share the app, each with its own key. Gino's can't see Nino's bookings."
7995
- - "And here's an AI assistant using the same app, with the same rules."
7996
- Point them at the "Play the story" button. Then ask one question:
7997
- **"Want to put this in front of your own app?"**
8016
+ 3. When it prints \u2713, tell this story in plain words, with the App link it printed:
8017
+ - "This booking app runs on your laptop. ${P} just gave it a public address, and one for AI assistants."
8018
+ - "On the Nino's Pizza tab, ask the assistant to book a table, and it appears in your list."
8019
+ - "The Admin tab is what each pizzeria's admin gets: their own API keys and people, kept apart."
8020
+ Then ask one question: **"Want to put your own app on the internet like this?"**
7998
8021
  4. If it prints \u2717, say its one sentence and run the one command it gives, once.
7999
8022
 
8000
8023
  Rules for the demo: keep each update to one line. Do not narrate steps, read the demo's
@@ -8003,6 +8026,7 @@ in \`.env.local\` and never prints it.
8003
8026
 
8004
8027
  ## Their own app: they say yes to that
8005
8028
 
8029
+ Their app runs on this machine \u2192 it will use \`${run2} dev\`; it is deployed \u2192 \`${run2} create\`.
8006
8030
  Run \`${run2} skills --ship\` and follow what it prints.
8007
8031
  `;
8008
8032
  }
@@ -12867,6 +12891,7 @@ async function runDomainSetBase(project, opts) {
12867
12891
  var import_chalk53 = __toESM(require("chalk"));
12868
12892
  var import_ora22 = __toESM(require("ora"));
12869
12893
  init_admin();
12894
+ init_caller();
12870
12895
  init_resolve();
12871
12896
  init_auth();
12872
12897
  init_tenant_pick();
@@ -12929,10 +12954,11 @@ async function runTenantCreate(opts) {
12929
12954
  console.error(import_chalk53.default.red("--name (display name) is required."));
12930
12955
  process.exit(1);
12931
12956
  }
12932
- const { teamId } = await resolveTeam(opts.team);
12957
+ const caller = requireCaller();
12958
+ const { teamId } = await resolveActingTeam(caller, opts.team);
12933
12959
  const spinner = (0, import_ora22.default)("Creating tenant...").start();
12934
12960
  try {
12935
- const out = await admin({
12961
+ const out = await producer(caller, {
12936
12962
  method: "POST",
12937
12963
  path: `/teams/${encodeURIComponent(teamId)}/tenants`,
12938
12964
  body: { display_name: opts.name, ...opts.slug ? { tenant_name: opts.slug } : {} },
@@ -12950,11 +12976,12 @@ async function runTenantAttach(project, opts) {
12950
12976
  console.error(import_chalk53.default.red("--tenant <slug> is required."));
12951
12977
  process.exit(1);
12952
12978
  }
12953
- const { teamId } = await resolveTeam(opts.team);
12954
- const proj2 = await resolveProject(teamId, project, opts.apiversion);
12979
+ const caller = requireCaller();
12980
+ const { teamId } = await resolveActingTeam(caller, opts.team);
12981
+ const proj2 = await resolveProjectVia(caller, teamId, project, opts.apiversion);
12955
12982
  const spinner = (0, import_ora22.default)("Attaching tenant...").start();
12956
12983
  try {
12957
- const out = await admin({
12984
+ const out = await producer(caller, {
12958
12985
  method: "POST",
12959
12986
  path: `/projects/${proj2.projectId}/${proj2.apiVersion}/tenants`,
12960
12987
  body: { tenant_name: opts.tenant, ...opts.authConfig ? { auth_config_id: opts.authConfig } : {} },
@@ -17377,7 +17404,7 @@ program.command("login").description(`Authenticate with ${B.product}`).option("-
17377
17404
  process.exit(1);
17378
17405
  }
17379
17406
  });
17380
- program.command("create").description(`Create a new ${B.thing} \u2014 both doors open by default (an API key for your backend and widget, GitHub sign-in for people and agents) and resources locked to their creator (no login needed: logged out it creates an anonymous ${B.thingShort} you can claim)`).option("--name <name>", B.cli === "mcpblaze" ? "MCP server name (becomes <name>-<tenant>.mcpblaze.com)" : "Proxy name (becomes <name>.abz.run)").option("--target <url|file>", "What to proxy \u2014 pass ANY of: a target server base URL (https://httpbin.org), a local OpenAPI file (./openapi.yaml), or a remote OpenAPI URL (https://acme.com/openapi.yaml). Spec inputs are detected automatically; routes, API version and environments then come from the spec").addOption(new import_commander.Option("--openapi <file|url>", "Deprecated alias \u2014 --target now detects spec files/URLs itself").hideHelp()).addOption(new import_commander.Option("--openapispec <file|url>", "Deprecated alias for --openapi").hideHelp()).option("--team <id|name>", "Team to create under (defaults to your active team)").option("--auth <type>", "Open ONE door only: api_key | oauth (sign in with GitHub, APIblaze-hosted) | none. Omitted = both doors.").option("--apikey", "API-key door only (no sign-in): the bootstrap keys are minted and printed; callers send X-API-Key, agents connect with a key.").option("--oauth [config]", `Sign-in door only (no API key). Bare = APIblaze-hosted GitHub sign-in. '{"iss","aud","jwks"}' = trust YOUR hosted login's JWTs. '{"provider","clientId","clientSecret"?}' = APIblaze-hosted login page with YOUR OAuth app (github \xB7 google \xB7 microsoft \xB7 facebook \xB7 auth0 \xB7 oidc); the secret is optional except for github and facebook. '{"provider":"oidc","issuer":"https://acme.okta.com/oauth2/default","clientId":"\u2026"}' = any OpenID Connect IdP by issuer (Okta \xB7 Entra \xB7 Keycloak \xB7 Cognito \xB7 \u2026).`).option("--identified", "Require every call to identify its end user (X-End-User-Id from a backend key, or a login token \u2014 a client key alone cannot); unattributed calls are rejected").option("--iam", `Turn IAM on for the ${B.thingShort}'s tenant so users & groups apply to identified calls`).option("--apiversion <version>", `API version to create (e.g. 2.0.0). Creating a new version of a ${B.thingShort} you own adds a version to the existing project.`).option("--tenant <slug>", `Tenant to attach the ${B.thingShort} to (created if new). Omitted \u2192 your team's most-recently-used tenant.`).option("--product <slug>", "Product tag to group this project under in the portal (team-scoped; anonymous create). Defaults to your team's existing/placeholder tag.").option("--display-name <name>", "Human-friendly display name").option("--subdomain <slug>", "Explicit subdomain (defaults to --name)").option("--config <file>", "JSON file with the full request body (anonymous create): requests_auth, login providers + client/server token types, scopes, callback URLs, etc. See apiblaze_anonymous.yaml. Flags override its fields.").option("-y, --yes", "Skip the confirmation prompt").option("--auto", `Zero prompts, start to finish: generates the ${B.thingShort} name when --name is omitted (from the target host, salted), takes every confirmation as yes, auto-picks the tenant. Implies --yes.`).option("--new-session", "Start a fresh anonymous session (do not group with prior anonymous creates)").option("--json", "Output machine-readable JSON (non-interactive)").action(async (opts) => {
17407
+ program.command("create").description(`Put an app that is already DEPLOYED behind ${B.product}: a public URL and an MCP address for AI agents in front of your URL or OpenAPI spec. Included: API keys, sign-in, ownership rules, rate limits. No login needed. (App on this machine? Use \`dev\`.)`).option("--name <name>", B.cli === "mcpblaze" ? "MCP server name (becomes <name>-<tenant>.mcpblaze.com)" : "Proxy name (becomes <name>.abz.run)").option("--target <url|file>", "What to proxy \u2014 pass ANY of: a target server base URL (https://httpbin.org), a local OpenAPI file (./openapi.yaml), or a remote OpenAPI URL (https://acme.com/openapi.yaml). Spec inputs are detected automatically; routes, API version and environments then come from the spec").addOption(new import_commander.Option("--openapi <file|url>", "Deprecated alias \u2014 --target now detects spec files/URLs itself").hideHelp()).addOption(new import_commander.Option("--openapispec <file|url>", "Deprecated alias for --openapi").hideHelp()).option("--team <id|name>", "Team to create under (defaults to your active team)").option("--auth <type>", "Open ONE door only: api_key | oauth (sign in with GitHub, APIblaze-hosted) | none. Omitted = both doors.").option("--apikey", "API-key door only (no sign-in): the bootstrap keys are minted and printed; callers send X-API-Key, agents connect with a key.").option("--oauth [config]", `Sign-in door only (no API key). Bare = APIblaze-hosted GitHub sign-in. '{"iss","aud","jwks"}' = trust YOUR hosted login's JWTs. '{"provider","clientId","clientSecret"?}' = APIblaze-hosted login page with YOUR OAuth app (github \xB7 google \xB7 microsoft \xB7 facebook \xB7 auth0 \xB7 oidc); the secret is optional except for github and facebook. '{"provider":"oidc","issuer":"https://acme.okta.com/oauth2/default","clientId":"\u2026"}' = any OpenID Connect IdP by issuer (Okta \xB7 Entra \xB7 Keycloak \xB7 Cognito \xB7 \u2026).`).option("--identified", "Require every call to identify its end user (X-End-User-Id from a backend key, or a login token \u2014 a client key alone cannot); unattributed calls are rejected").option("--iam", `Turn IAM on for the ${B.thingShort}'s tenant so users & groups apply to identified calls`).option("--apiversion <version>", `API version to create (e.g. 2.0.0). Creating a new version of a ${B.thingShort} you own adds a version to the existing project.`).option("--tenant <slug>", `Tenant to attach the ${B.thingShort} to (created if new). Omitted \u2192 your team's most-recently-used tenant.`).option("--product <slug>", "Product tag to group this project under in the portal (team-scoped; anonymous create). Defaults to your team's existing/placeholder tag.").option("--display-name <name>", "Human-friendly display name").option("--subdomain <slug>", "Explicit subdomain (defaults to --name)").option("--config <file>", "JSON file with the full request body (anonymous create): requests_auth, login providers + client/server token types, scopes, callback URLs, etc. See apiblaze_anonymous.yaml. Flags override its fields.").option("-y, --yes", "Skip the confirmation prompt").option("--auto", `Zero prompts, start to finish: generates the ${B.thingShort} name when --name is omitted (from the target host, salted), takes every confirmation as yes, auto-picks the tenant. Implies --yes.`).option("--new-session", "Start a fresh anonymous session (do not group with prior anonymous creates)").option("--json", "Output machine-readable JSON (non-interactive)").action(async (opts) => {
17381
17408
  try {
17382
17409
  await runCreate({ ...opts, openapi: opts.openapi ?? opts.openapispec });
17383
17410
  } catch (err) {
@@ -17386,8 +17413,10 @@ program.command("create").description(`Create a new ${B.thing} \u2014 both doors
17386
17413
  }
17387
17414
  });
17388
17415
  program.command("integration").description(`What to add to your frontend and frontend-server for ${B.cli === "mcpblaze" ? "an MCP server" : "a proxy"} \u2014 the same kit the ${B.product} MCP hands an agent: widgets, the call-through with the key + X-End-User-Id, what your backend must trust, env vars, what stays open`).argument("<name>", `The ${B.thingShort} (project name)`).option("--stack <stack>", "nextjs | express | fastapi | other", "nextjs").option("--team <id|name>", "Team the project is in (defaults to your active team)").option("--json", "Print the raw kit as JSON").action(action((name, opts) => runIntegration(name, opts)));
17389
- program.command("demo").description(`See what ${B.product} does in 30 seconds: a small booking app on this machine, with ${B.product} in front of it (no login, no prompts)`).argument("[action]", '"stop" stops a running demo').option("--no-open", "Do not open the browser").addOption(new import_commander.Option("--serve <dir>", "internal: run the demo servers and tunnel").hideHelp()).action(async (actionArg, opts) => {
17416
+ program.command("demo").description(`See what ${B.product} does in 30 seconds: a small booking app on this machine, with ${B.product} in front of it (no login, no prompts)`).argument("[action]", "stop \xB7 start / restart (the same demo again: same addresses and keys) \xB7 logs (follow the requests reaching your laptop)").option("--no-open", "Do not open the browser").addOption(new import_commander.Option("--serve <dir>", "internal: run the demo servers and tunnel").hideHelp()).action(async (actionArg, opts) => {
17390
17417
  if (actionArg === "stop") return runDemoStop();
17418
+ if (actionArg === "start" || actionArg === "restart") return runDemoStart(opts);
17419
+ if (actionArg === "logs") return runDemoLogs();
17391
17420
  if (actionArg) {
17392
17421
  console.log(`Unknown demo action "${actionArg}". Run: npx ${B.cli}@latest demo`);
17393
17422
  process.exit(1);
@@ -17429,7 +17458,7 @@ withSetupOptions(sidecar.command("setup").description(`Wire a Next.js app to rou
17429
17458
  sidecar.command("approve").description(`Route an origin through ${B.product} (creates its proxy)`).argument("<origin>", "Origin, e.g. api.stripe.com").option("--team <id|name>", "Team (defaults to active team)").option("--json", "Machine-readable output").action(action((origin, opts, cmd) => runOriginsApprove(origin, { ...cmd.parent?.opts(), ...opts })));
17430
17459
  sidecar.command("deny").description("Dismiss a candidate origin so it stops being suggested").argument("<origin>", "Origin, e.g. sentry.io").option("--team <id|name>", "Team (defaults to active team)").action(action((origin, opts, cmd) => runOriginsDeny(origin, { ...cmd.parent?.opts(), ...opts })));
17431
17460
  sidecar.command("remove").description("Un-route an approved origin (deletes its proxy; the app goes direct again)").argument("<origin>", "Origin, e.g. api.stripe.com").option("--team <id|name>", "Team (defaults to active team)").action(action((origin, opts, cmd) => runOriginsRemove(origin, { ...cmd.parent?.opts(), ...opts })));
17432
- program.command("dev").description(`Put the code running on this machine behind ${B.product}: everything \`create\` does \u2014 both doors, resources locked to their creator, an admin, an agent connected \u2014 with localhost as the target, through a tunnel (no login needed)`).argument("[port]", "Local port to tunnel (positional; overrides --port)").option("-p, --port <number>", "Local port your server listens on", "3000").option("--openapi <file|url>", "Your OpenAPI spec (a local file or a URL). Omitted \u2192 looks for /openapi.json, /openapi.yaml, /docs/openapi.json on that port. Rules need one.").option("--name <name>", B.cli === "mcpblaze" ? "MCP server name (becomes <name>-<tenant>.mcpblaze.com); omitted \u2192 one short question with a generated default" : "Proxy name (becomes <name>.abz.run); omitted \u2192 one short question with a generated default").option("--team <id|name>", "Team to create under (defaults to your active team)").option("--tenant <slug>", `Tenant to attach the ${B.thingShort} to (created if new)`).option("--auth <type>", "Open ONE door only: api_key | oauth (sign in with GitHub) | none. Omitted = both doors.").option("--apikey", "API-key door only (no sign-in). With --oauth <config>: BOTH doors, sign-in through your own app or issuer").option("--oauth [config]", "Sign-in door only (no API key). Same shapes as `create --oauth`.").option("--auto", "Every answer = the default: generated name, both doors, every resource locked, no admin, no agent. No prompts, no TTY needed.").option("--project <nameOrId>", "Re-attach this existing localhost project (skips the picker \u2014 for scripts)").option("-y, --yes", "Skip the confirmation when one project already points at this machine").option("-o, --capture-file <path>", "Stream full request/response traffic to a file (JSON lines)").option("--new-session", "Logged-out only: start a fresh anonymous workspace instead of reusing this machine's (each run = a throwaway proxy)").action(async (port, opts) => {
17461
+ program.command("dev").description(`Put the app running on THIS machine on the internet: a public URL and an MCP address for AI agents, tunnelled to localhost. Included: API keys, sign-in, ownership rules, rate limits. No login needed. (Deployed app? Use \`create\`.)`).argument("[port]", "Local port to tunnel (positional; overrides --port)").option("-p, --port <number>", "Local port your server listens on", "3000").option("--openapi <file|url>", "Your OpenAPI spec (a local file or a URL). Omitted \u2192 looks for /openapi.json, /openapi.yaml, /docs/openapi.json on that port. Rules need one.").option("--name <name>", B.cli === "mcpblaze" ? "MCP server name (becomes <name>-<tenant>.mcpblaze.com); omitted \u2192 one short question with a generated default" : "Proxy name (becomes <name>.abz.run); omitted \u2192 one short question with a generated default").option("--team <id|name>", "Team to create under (defaults to your active team)").option("--tenant <slug>", `Tenant to attach the ${B.thingShort} to (created if new)`).option("--auth <type>", "Open ONE door only: api_key | oauth (sign in with GitHub) | none. Omitted = both doors.").option("--apikey", "API-key door only (no sign-in). With --oauth <config>: BOTH doors, sign-in through your own app or issuer").option("--oauth [config]", "Sign-in door only (no API key). Same shapes as `create --oauth`.").option("--auto", "Every answer = the default: generated name, both doors, every resource locked, no admin, no agent. No prompts, no TTY needed.").option("--project <nameOrId>", "Re-attach this existing localhost project (skips the picker \u2014 for scripts)").option("-y, --yes", "Skip the confirmation when one project already points at this machine").option("-o, --capture-file <path>", "Stream full request/response traffic to a file (JSON lines)").option("--new-session", "Logged-out only: start a fresh anonymous workspace instead of reusing this machine's (each run = a throwaway proxy)").action(async (port, opts) => {
17433
17462
  try {
17434
17463
  const resolved = parseInt(port ?? opts.port, 10);
17435
17464
  if (Number.isNaN(resolved)) {