apiblaze 0.21.21 → 0.21.22

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.22";
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,211 @@ 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}\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');\nconst { recent } = require('./backend');\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// The person using the page. A real app takes this from its own login.\nconst ME = { userId: 'ana', email: 'ana@ninopizza.example', label: 'Ana' };\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: 'Ana', 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 project = env('APIBLAZE_NAME');\n const tenant = env('APIBLAZE_TENANT');\n const out = {};\n out.chat = lib.createApiblazeChat({\n project: `${project}-${tenant}`, apiKey: env('APIBLAZE_SERVER_KEY'),\n host: 'tryabz.run', environment: 'dev', getUser: () => ({ userId: ME.userId }),\n });\n if (env('APIBLAZE_CP_KEY') && env('APIBLAZE_WIDGETS') === '1') {\n const user = () => ({ tenant, userId: ME.userId, email: ME.email, label: ME.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'), key: mask(env('APIBLAZE_SERVER_KEY')), ginoKey: mask(env('APIBLAZE_GINO_KEY')),\n backendPort: env('BACKEND_PORT', '3001'), rps: Number(env('APIBLAZE_DEMO_RPS')) || null, rateCard: env('APIBLAZE_RATE_CARD') === '1', widgets: { chat: !!w.chat, keys: !!w.keys, groups: !!w.groups },\n });\n }\n if (p === '/api/ticker') return json(res, 200, recent.slice(0, 12));\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 if (req.method === 'POST' && p === '/api/hammer') {\n // 30 calls at once, as one customer: the demo's rate limit lets only some through.\n const codes = await Promise.all(Array.from({ length: 30 }, () => call('list').then((r) => r.status).catch(() => 0)));\n const served = codes.filter((c) => c === 200).length;\n const limited = codes.filter((c) => c === 429).length;\n return json(res, 200, { sent: codes.length, served, limited, other: codes.length - served - limited });\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
+ --live: #16a34a; --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; } }
6391
+ main { max-width: 1080px; margin: 0 auto; padding: 28px 16px 64px; }
6392
+ h1 { font-size: 30px; margin: 0; letter-spacing: -0.02em; }
6393
+ .lede { font-size: 18px; color: var(--muted); margin: 6px 0 20px; }
6490
6394
  .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; }
6395
+ .hero { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 16px; }
6396
+ @media (max-width: 820px) { .hero, .grid { grid-template-columns: 1fr !important; } }
6397
+ .addr { display: grid; gap: 14px; }
6398
+ .addr .label { font-size: 13px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
6399
+ .addr .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
6400
+ code, .mono { font-family: var(--mono); font-size: 13px; }
6401
+ .url { background: var(--soft); padding: 8px 10px; border-radius: 10px; word-break: break-all; flex: 1; min-width: 0; }
6402
+ button { background: var(--soft); color: var(--ink); border: 1px solid var(--line); border-radius: 10px; padding: 9px 13px; font-size: 14px; cursor: pointer; }
6403
+ button:hover { border-color: var(--muted); }
6404
+ button.primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; font-weight: 600; }
6405
+ .hint { color: var(--muted); font-size: 14px; margin: 2px 0 0; }
6406
+ .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--live); margin-right: 6px; box-shadow: 0 0 0 0 rgba(22,163,74,.6); animation: pulse 1.6s infinite; }
6407
+ @keyframes pulse { 70% { box-shadow: 0 0 0 8px rgba(22,163,74,0); } 100% { box-shadow: 0 0 0 0 rgba(22,163,74,0); } }
6408
+ .ticker { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 12px; }
6409
+ .ticker ol { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; max-height: 230px; overflow: auto; }
6410
+ .ticker li { font-family: var(--mono); font-size: 12.5px; background: var(--soft); border-radius: 8px; padding: 6px 9px; display: flex; gap: 8px; }
6411
+ .ticker li.new { animation: flash 1.2s ease-out; }
6412
+ @keyframes flash { from { background: var(--good-bg); } }
6413
+ .ticker .st { margin-left: auto; color: var(--muted); }
6414
+ .empty { color: var(--muted); font-size: 14px; }
6415
+ h2 { font-size: 20px; margin: 0 0 4px; }
6416
+ h3 { font-size: 17px; margin: 0 0 4px; }
6417
+ .section { margin: 28px 0 10px; font-size: 13px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
6418
+ .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
6419
+ .grid .card { padding: 16px; }
6420
+ .sub { color: var(--muted); font-size: 14px; margin: 0 0 12px; }
6421
+ .out { border-radius: 10px; padding: 8px 10px; font-size: 14px; margin-top: 10px; background: var(--soft); }
6500
6422
  .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; }
6423
+ .out.bad { background: var(--bad-bg); color: var(--bad); font-weight: 600; }
6424
+ .out small { display: block; font-weight: 400; color: var(--muted); font-family: var(--mono); font-size: 12px; }
6425
+ .wide { grid-column: span 3; }
6426
+ @media (max-width: 820px) { .wide { grid-column: auto; } }
6427
+ .footer { margin-top: 26px; font-size: 17px; }
6428
+ #chat { min-height: 420px; }
6515
6429
  </style>
6516
6430
  </head>
6517
6431
  <body>
6518
6432
  <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>
6433
+ <h1>\u{1F355} Nino's Pizza</h1>
6434
+ <p class="lede">This booking app is running on your laptop. One command put it on the internet, with a door for AI assistants. You wrote no code for it.</p>
6525
6435
 
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>
6436
+ <section class="hero">
6437
+ <div class="card">
6438
+ <div class="addr">
6439
+ <div>
6440
+ <div class="label"><span class="dot"></span>Your API, live on the internet</div>
6441
+ <div class="row"><span class="url mono" id="api">\u2026</span><button data-copy="api">Copy</button></div>
6442
+ <p class="hint">Anyone you give a key to can call it from anywhere. It's answered by <span class="mono" id="local">localhost</span> on this machine.</p>
6443
+ </div>
6444
+ <div>
6445
+ <div class="label">\u{1F916} Its MCP address, for AI assistants</div>
6446
+ <div class="row"><span class="url mono" id="mcp">\u2026</span><button data-copy="mcp">Copy</button></div>
6447
+ <p class="hint">Paste it into Claude, ChatGPT or Cursor and they can use your app, under the same rules.</p>
6448
+ </div>
6534
6449
  </div>
6535
- <div class="log" id="log-before"></div>
6536
- </section>
6537
-
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>
6450
+ <div class="ticker">
6451
+ <div class="label" style="font-size:13px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)">Requests reaching your laptop, live</div>
6452
+ <ol id="ticker"><li class="empty" id="ticker-empty">Nothing yet. Ask the assistant to book a table \u2192</li></ol>
6545
6453
  </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>
6556
6454
  </div>
6557
- <div class="log" id="log-gino"></div>
6455
+ <div class="card" style="padding:0;overflow:hidden"><div id="chat"></div></div>
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
+ <div class="section">Included with every APIblaze API, still no code</div>
6459
+ <section class="grid">
6460
+ <div class="card">
6461
+ <h3>\u{1F6E1} Only the owner can change it</h3>
6462
+ <p class="sub">Ana books a table. Ben tries to cancel it.</p>
6463
+ <button id="rule">Ben cancels Ana's booking</button>
6464
+ <div id="rule-out"></div>
6465
+ </div>
6466
+ <div class="card" id="tenants" hidden>
6467
+ <h3>\u{1F3E2} Each customer is separate</h3>
6468
+ <p class="sub">Nino Pizza and Gino Pizza use the same app, each with its own key.</p>
6469
+ <button id="gino">Gino's key asks for Nino's booking</button>
6470
+ <div id="gino-out"></div>
6471
+ </div>
6472
+ <div class="card" id="rate-card" hidden>
6473
+ <h3>\u23F1 Rate limits</h3>
6474
+ <p class="sub">Each customer gets <span id="rps">a few</span> calls a second. Send 30 at once and see what happens.</p>
6475
+ <button id="hammer">Send 30 calls at once</button>
6476
+ <div id="hammer-out"></div>
6477
+ </div>
6478
+ <div class="card">
6479
+ <h3>\u{1F510} Sign-in</h3>
6480
+ <p class="sub">People and AI agents can sign in with GitHub instead of a key. Your API has a developer portal for that.</p>
6481
+ <a id="portal" href="#" target="_blank" rel="noopener"><button>Open the developer portal</button></a>
6482
+ </div>
6483
+ <div class="card wide" id="keys-card" hidden>
6484
+ <h3>\u{1F511} API keys, self-serve</h3>
6485
+ <p class="sub">Drop this widget into your site and each customer makes their own keys.</p>
6486
+ <div id="keys"></div>
6487
+ </div>
6488
+ <div class="card wide" id="groups-card" hidden>
6489
+ <h3>\u{1F465} Users and roles</h3>
6490
+ <p class="sub">Put people in groups. Rules can say "only staff may cancel".</p>
6491
+ <div id="groups"></div>
6567
6492
  </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
6493
  </section>
6574
6494
 
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>
6583
- </section>
6495
+ <p class="footer">This is exactly what <code>npx apiblaze@latest dev</code> does for your own app. Ask Claude: <b>"Put my app on APIblaze."</b></p>
6584
6496
  </main>
6497
+ <script src="/apiblaze-widgets.js"></script>
6585
6498
  <script>
6586
6499
  const $ = (s) => document.querySelector(s);
6587
- const ids = { before: null, after: null };
6588
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6589
-
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);
6500
+ const post = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) }).then((r) => r.json()).catch((e) => ({ status: 0, error: String(e) }));
6501
+ function out(el, cls, text, detail) {
6502
+ const d = document.createElement('div'); d.className = 'out ' + (cls || ''); d.textContent = text;
6503
+ if (detail) { const s = document.createElement('small'); s.textContent = detail; d.appendChild(s); }
6504
+ $(el).replaceChildren(d);
6596
6505
  }
6506
+ document.querySelectorAll('[data-copy]').forEach((b) => b.onclick = () => { navigator.clipboard?.writeText($('#' + b.dataset.copy).textContent); b.textContent = 'Copied'; setTimeout(() => b.textContent = 'Copy', 1200); });
6597
6507
 
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
- }
6508
+ let bookingId = null;
6509
+ async function book() { const r = await post('/api/act', { op: 'book', as: 'ana' }); if (r.status === 201) bookingId = r.body.id; return r; }
6615
6510
 
6511
+ $('#rule').onclick = async () => {
6512
+ if (!bookingId) await book();
6513
+ const r = await post('/api/act', { op: 'cancel', as: 'ben', id: bookingId });
6514
+ if (r.status === 403) out('#rule-out', 'good', "\u{1F6E1} Blocked. Only Ana can cancel Ana's booking.", 'HTTP 403 \xB7 decided by APIblaze, not your code');
6515
+ else if (r.status === 204) { out('#rule-out', 'bad', 'Cancelled. That should not happen.', 'HTTP 204'); bookingId = null; }
6516
+ else out('#rule-out', 'bad', explain(r), 'HTTP ' + r.status);
6517
+ };
6518
+ $('#gino').onclick = async () => {
6519
+ if (!bookingId) await book();
6520
+ const r = await post('/api/act', { op: 'open', as: 'ana', id: bookingId, key: 'gino' });
6521
+ if (r.status === 403 || r.status === 404) out('#gino-out', 'good', "\u{1F6E1} Blocked. Gino Pizza only sees Gino Pizza's bookings.", 'HTTP ' + r.status);
6522
+ else out('#gino-out', 'bad', r.status === 200 ? "Gino Pizza can read Nino's booking." : explain(r), 'HTTP ' + r.status);
6523
+ };
6524
+ $('#hammer').onclick = async () => {
6525
+ out('#hammer-out', '', 'Sending\u2026');
6526
+ const r = await post('/api/hammer');
6527
+ out('#hammer-out', 'good', \`\${r.served} served, \${r.limited} told to slow down.\`, \`\${r.sent} calls \xB7 HTTP 200 \xD7 \${r.served}, 429 \xD7 \${r.limited}\`);
6528
+ };
6616
6529
  function explain(r) {
6617
- const b = r.body || {};
6618
6530
  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') + '';
6531
+ if (r.status === 429) return 'Too many calls at once. Wait a second.';
6532
+ if (!r.status || r.status >= 500) return 'The connection to APIblaze dropped. Run again: npx apiblaze@latest demo';
6533
+ return (r.body && (r.body.detail || r.body.error)) || 'Something went wrong';
6622
6534
  }
6623
6535
 
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
- });
6628
-
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 = '';
6536
+ // Live ticker: what the backend on this laptop just answered.
6537
+ const seen = new Set();
6538
+ async function tick() {
6637
6539
  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.');
6540
+ const rows = await fetch('/api/ticker').then((r) => r.json());
6541
+ if (rows.length) $('#ticker-empty')?.remove();
6542
+ for (const r of rows.slice().reverse()) {
6543
+ const k = r.at + r.method + r.path + r.who;
6544
+ if (seen.has(k)) continue; seen.add(k);
6545
+ const li = document.createElement('li'); li.className = 'new';
6546
+ const t = new Date(r.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
6547
+ const who = r.who ? \`\${r.who}\${r.tenant ? ' @ ' + r.tenant.replace(/[0-9a-f]{4}$/, '') : ''}\` : '';
6548
+ // Text only: these values come from requests made on the internet.
6549
+ for (const [tag, text, cls] of [['span', t], ['b', r.method], ['span', r.path], ['span', who], ['span', String(r.status), 'st']]) {
6550
+ const el = document.createElement(tag); el.textContent = text; if (cls) el.className = cls; li.appendChild(el);
6660
6551
  }
6552
+ $('#ticker').prepend(li);
6661
6553
  }
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.'); }
6554
+ } catch { /* the page keeps working */ }
6664
6555
  }
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.\`);
6668
- };
6669
- $('#ask-ana').onclick = () => ask('ana', 'What are my bookings?');
6670
-
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
- };
6556
+ setInterval(tick, 1000);
6696
6557
 
6697
6558
  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
- }
6559
+ $('#api').textContent = c.apiUrl || 'not connected';
6560
+ $('#mcp').textContent = c.mcpUrl || 'not connected';
6561
+ $('#local').textContent = 'localhost:' + c.backendPort;
6562
+ if (c.portalUrl) $('#portal').href = c.portalUrl; else $('#portal').hidden = true;
6563
+ if (c.ginoKey) $('#tenants').hidden = false;
6564
+ if (c.rps) $('#rps').textContent = c.rps;
6565
+ if (c.rateCard) $('#rate-card').hidden = false;
6566
+ const W = window.ApiblazeWidgets;
6567
+ const theme = { accent: '#e4572e' };
6568
+ if (W && c.widgets.chat) W.chat('#chat', { endpoint: '/api/apiblaze/chat', mode: 'inline', title: "Nino's Pizza assistant", avatar: '\u{1F355}', height: 520, theme,
6569
+ welcome: "Hi Ana! I can book, list and cancel tables at Nino's Pizza. I'm using the app on your laptop, through APIblaze.",
6570
+ suggestions: ['Book table 4 for 2 people at 7:30pm', 'What are my bookings?', "Cancel booking r1"] });
6571
+ if (W && c.widgets.keys) { $('#keys-card').hidden = false; W.keys('#keys', { endpoint: '/api/apiblaze/keys', theme }); }
6572
+ if (W && c.widgets.groups) { $('#groups-card').hidden = false; W.groups('#groups', { endpoint: '/api/apiblaze/groups', theme }); }
6708
6573
  });
6709
6574
  </script>
6710
6575
  </body>
@@ -6717,6 +6582,8 @@ fetch('/api/config').then((r) => r.json()).then((c) => {
6717
6582
  // src/commands/demo.ts
6718
6583
  var demo_exports = {};
6719
6584
  __export(demo_exports, {
6585
+ DEMO_ADMIN_EMAIL: () => DEMO_ADMIN_EMAIL,
6586
+ DEMO_RPS: () => DEMO_RPS,
6720
6587
  demoName: () => demoName,
6721
6588
  runDemo: () => runDemo,
6722
6589
  runDemoStop: () => runDemoStop,
@@ -6782,6 +6649,11 @@ function writeApp(dir) {
6782
6649
  }
6783
6650
  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
6651
  import_fs5.default.writeFileSync(import_path4.default.join(dir, ".gitignore"), ".env.local\n.demo/\n");
6652
+ 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")]]) {
6653
+ if (!import_fs5.default.existsSync(from)) continue;
6654
+ import_fs5.default.mkdirSync(import_path4.default.dirname(to), { recursive: true });
6655
+ import_fs5.default.copyFileSync(from, to);
6656
+ }
6785
6657
  }
6786
6658
  function saveState(dir, s) {
6787
6659
  const d = import_path4.default.join(dir, STATE_DIR);
@@ -6892,14 +6764,30 @@ async function startDemo(opts) {
6892
6764
  const missing = [!cpKey && "workspace key", !teamId && "team", !serverKey && "API key", !tenant2 && "tenant"].filter(Boolean);
6893
6765
  if (missing.length) throw new DemoError(`${B.product} did not return everything the demo needs (${missing.join(", ")}).`, `Run again: ${LATEST} demo`);
6894
6766
  const apiUrl = restUrl(project, version2, "dev", { anon: true });
6895
- const chatBase = `https://${project}-${tenant2}.mcp.tryabz.run/${version2}/dev`;
6767
+ await cpFetch(cpKey, `/projects/${encodeURIComponent(project)}/${encodeURIComponent(version2)}/limits`, {
6768
+ method: "PUT",
6769
+ body: JSON.stringify({ consumerRps: DEMO_RPS, endUserRps: DEMO_RPS })
6770
+ }).catch(() => {
6771
+ });
6772
+ await cpFetch(cpKey, `/teams/${encodeURIComponent(teamId)}/tenants/${encodeURIComponent(tenant2)}/admin-emails`, {
6773
+ method: "POST",
6774
+ body: JSON.stringify({ email: DEMO_ADMIN_EMAIL })
6775
+ }).catch(() => {
6776
+ });
6896
6777
  const gino = (r.tenants ?? []).find((t) => t.api_key);
6778
+ const agents = mcpUrl(project, version2, "dev", { tenant: tenant2, anon: true });
6897
6779
  writeEnv(dir, {
6898
6780
  APIBLAZE_NAME: project,
6781
+ APIBLAZE_TENANT: tenant2,
6899
6782
  APIBLAZE_URL: apiUrl,
6900
6783
  APIBLAZE_SERVER_KEY: serverKey,
6901
6784
  ...gino?.api_key ? { APIBLAZE_GINO_KEY: gino.api_key } : {},
6902
- APIBLAZE_MCP_URL: chatBase,
6785
+ APIBLAZE_MCP_URL: agents,
6786
+ APIBLAZE_PORTAL_URL: `https://${tenant2}.portal.apiblaze.com/${version2}`,
6787
+ // The keys + users widgets mint through the workspace's control key (server-side only).
6788
+ APIBLAZE_CP_KEY: cpKey,
6789
+ APIBLAZE_WIDGETS: process.env.APIBLAZE_DEMO_WIDGETS === "0" ? 0 : 1,
6790
+ APIBLAZE_DEMO_RPS: DEMO_RPS,
6903
6791
  APP_PORT: appPort,
6904
6792
  BACKEND_PORT: backendPort
6905
6793
  });
@@ -6914,23 +6802,20 @@ async function startDemo(opts) {
6914
6802
  const url = `http://localhost:${appPort}`;
6915
6803
  if (opts.open !== false) openBrowser3(url);
6916
6804
  const secs = Math.round((Date.now() - t0) / 1e3);
6917
- const agents = mcpUrl(project, version2, "dev", { tenant: tenant2, anon: true });
6918
6805
  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)`)}`);
6806
+ console.log(`${import_chalk25.default.green("\u2713")} Nino's Pizza, running on this laptop, is live on the internet: ${import_chalk25.default.bold(url)} ${import_chalk25.default.dim(`(${secs} s)`)}`);
6920
6807
  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.');
6808
+ console.log(" Tell the user this, in plain words:");
6809
+ console.log(` 1. "This booking app runs on your laptop. ${B.product} just gave it a public address, plus an MCP address so AI assistants can use it."`);
6810
+ console.log(' 2. "On the page, ask the assistant to book a table, and watch the request arrive on your laptop."');
6811
+ console.log(' 3. "API keys, sign-in and who-can-change-what are already on, with no code. Try the cards below."');
6927
6812
  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`));
6813
+ console.log(` Public API ${apiUrl}`);
6814
+ console.log(` MCP ${agents}`);
6815
+ console.log(` Same as ${LATEST} dev --port ${backendPort} --openapi ./openapi.yaml ${import_chalk25.default.dim("(the command for your own app)")}`);
6816
+ console.log(import_chalk25.default.dim(` Code in ./${DIR_NAME} \xB7 key saved in .env.local, never shown \xB7 stop: ${LATEST} demo stop`));
6932
6817
  console.log();
6933
- console.log(` Ask the user one question: "Want to put this in front of your own app?"`);
6818
+ console.log(` Ask the user one question: "Want to put your own app on the internet like this?"`);
6934
6819
  }
6935
6820
  async function runDemoStop() {
6936
6821
  const n = await stopDemos();
@@ -6999,7 +6884,7 @@ async function serveDemo(dir) {
6999
6884
  await new Promise(() => {
7000
6885
  });
7001
6886
  }
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;
6887
+ 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, DemoError, ADJ, NOUN, pick2, registryFile, alive;
7003
6888
  var init_demo = __esm({
7004
6889
  "src/commands/demo.ts"() {
7005
6890
  "use strict";
@@ -7013,12 +6898,15 @@ var init_demo = __esm({
7013
6898
  init_api();
7014
6899
  init_tunnel_client();
7015
6900
  init_auth();
6901
+ init_anon_cred();
7016
6902
  init_brand();
7017
6903
  init_create();
7018
6904
  init_files();
7019
6905
  DIR_NAME = `${B.cli}-demo`;
7020
6906
  STATE_DIR = ".demo";
7021
6907
  LATEST = `npx ${B.cli}@latest`;
6908
+ DEMO_RPS = 2;
6909
+ DEMO_ADMIN_EMAIL = "ana@ninopizza.example";
7022
6910
  DemoError = class extends Error {
7023
6911
  constructor(message, fix) {
7024
6912
  super(message);
@@ -7354,6 +7242,13 @@ async function journey(label3, work, home, checks) {
7354
7242
  } else {
7355
7243
  add("tenants-record", false, "no Gino Pizza key: the server did not return extra_tenants");
7356
7244
  }
7245
+ const page = `http://127.0.0.1:${env.APP_PORT}`;
7246
+ const made = await http(`${page}/api/apiblaze/keys`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "create" }) });
7247
+ const widgetKey = made.body?.key;
7248
+ const used = widgetKey ? await http(`${api}/reservations`, { headers: { "x-api-key": widgetKey } }) : { status: 0 };
7249
+ add("keys-widget", made.status === 201 && used.status === 200, `create ${made.status} \xB7 the new key calls the API ${used.status}`);
7250
+ const snap = await http(`${page}/api/apiblaze/groups`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "snapshot" }) });
7251
+ add("users-widget", snap.status === 200 && snap.body?.access === "ok", `snapshot ${snap.status} \xB7 access ${snap.body?.access ?? "-"}`);
7357
7252
  const chat = await http(`${env.APIBLAZE_MCP_URL}/runtime-chat`, {
7358
7253
  method: "POST",
7359
7254
  headers: as("ana"),
@@ -7368,11 +7263,16 @@ async function journey(label3, work, home, checks) {
7368
7263
  }
7369
7264
  }).filter(Boolean).filter((e) => e.type === "text-delta").map((e) => e.delta ?? "").join("");
7370
7265
  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
- );
7266
+ const ipCapped = /data-apiblaze-upsell/.test(chat.text) && /Free chats used up/.test(chat.text);
7267
+ if (ipCapped) {
7268
+ 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" });
7269
+ } else {
7270
+ add(
7271
+ "ai-turn",
7272
+ chat.status === 200 && chat.type.includes("event-stream") && said.length > 0 && !streamErr,
7273
+ `HTTP ${chat.status}${streamErr ? " \xB7 stream error" : ""} \xB7 ${said.length} chars${chat.status !== 200 ? ` \xB7 ${chat.text.slice(0, 120)}` : ""}`
7274
+ );
7275
+ }
7376
7276
  return env;
7377
7277
  }
7378
7278
  async function runOpAnon(o = {}) {
@@ -7401,9 +7301,13 @@ async function runOpAnon(o = {}) {
7401
7301
  checks.push({ id: "stop", ok: stop.code === 0 && portsFree, detail: `exit ${stop.code}${portsFree ? "" : " \xB7 ports still answering"}` });
7402
7302
  import_fs8.default.rmSync(root, { recursive: true, force: true });
7403
7303
  }
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)}`);
7304
+ 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
7305
  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)`));
7306
+ const skipped = checks.filter((c) => c.skipped).length;
7307
+ const passed = checks.length - failed - skipped;
7308
+ const secs = Math.round((Date.now() - t0) / 1e3);
7309
+ const tail = `${skipped ? `, ${skipped} skipped` : ""} (${secs} s)`;
7310
+ 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
7311
  if (o.json) process.stdout.write(JSON.stringify({ ok: !failed, checks }, null, 2) + "\n");
7408
7312
  if (failed) process.exitCode = 1;
7409
7313
  return { ok: !failed, checks };
@@ -7975,10 +7879,9 @@ description: Use when the user asks what ${P} (or ${B.cli}) does or can do for t
7975
7879
 
7976
7880
  Run nothing. Say this, in these words or very close to them, then stop:
7977
7881
 
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?"
7882
+ "${P} puts the app on your laptop on the internet in one command. It gets a public address,
7883
+ plus an address AI assistants like me can use to work with it. API keys, sign-in and
7884
+ who-can-change-what come built in, with no code. Want me to show you in about 30 seconds?"
7982
7885
 
7983
7886
  Do not use the words proxy, MCP, tenant, OpenAPI or header unless the user does first.
7984
7887
 
@@ -7988,13 +7891,12 @@ Do not use the words proxy, MCP, tenant, OpenAPI or header unless the user does
7988
7891
  2. Run exactly this, in the foreground. It needs no login and asks nothing. It sets
7989
7892
  everything up, opens the browser and exits by itself once the page is live:
7990
7893
  \`${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?"**
7894
+ 3. When it prints \u2713, tell the story it printed, in plain words, with the link:
7895
+ - "This booking app runs on your laptop. ${P} just gave it a public address, and one for AI assistants."
7896
+ - "On the page, ask the assistant to book a table and watch the request arrive on your laptop."
7897
+ - "Keys, sign-in and who-can-change-what are already on, with no code. Try the cards."
7898
+ Then ask one question: **"Want to put your own app on the internet like this?"**
7899
+ If they say yes: their app runs locally \u2192 \`${run2} dev\`; it is deployed \u2192 \`${run2} create\`.
7998
7900
  4. If it prints \u2717, say its one sentence and run the one command it gives, once.
7999
7901
 
8000
7902
  Rules for the demo: keep each update to one line. Do not narrate steps, read the demo's
@@ -17377,7 +17279,7 @@ program.command("login").description(`Authenticate with ${B.product}`).option("-
17377
17279
  process.exit(1);
17378
17280
  }
17379
17281
  });
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) => {
17282
+ 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
17283
  try {
17382
17284
  await runCreate({ ...opts, openapi: opts.openapi ?? opts.openapispec });
17383
17285
  } catch (err) {
@@ -17429,7 +17331,7 @@ withSetupOptions(sidecar.command("setup").description(`Wire a Next.js app to rou
17429
17331
  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
17332
  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
17333
  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) => {
17334
+ 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
17335
  try {
17434
17336
  const resolved = parseInt(port ?? opts.port, 10);
17435
17337
  if (Number.isNaN(resolved)) {