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/browser/widgets.js +59 -0
- package/dist/index.js +246 -344
- package/dist/server/index.d.mts +3 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.js +1 -1
- package/dist/server/index.mjs +1 -1
- package/package.json +5 -2
package/dist/index.js
CHANGED
|
@@ -34,7 +34,7 @@ var __toESM = (mod, isNodeMode, target2) => (target2 = mod != null ? __create(__
|
|
|
34
34
|
var version;
|
|
35
35
|
var init_package = __esm({
|
|
36
36
|
"package.json"() {
|
|
37
|
-
version = "0.21.
|
|
37
|
+
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(
|
|
5758
|
-
|
|
5759
|
-
|
|
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": "#
|
|
6375
|
-
"openapi.yaml": "openapi: 3.0.3\ninfo:\n title:
|
|
6376
|
-
"backend.js":
|
|
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>
|
|
6378
|
+
<title>Nino's Pizza demo</title>
|
|
6467
6379
|
<style>
|
|
6468
6380
|
:root {
|
|
6469
|
-
--bg: #faf7f2; --card: #
|
|
6470
|
-
--
|
|
6471
|
-
--
|
|
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
|
-
--
|
|
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:
|
|
6482
|
-
|
|
6483
|
-
|
|
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
|
-
.
|
|
6492
|
-
|
|
6493
|
-
.
|
|
6494
|
-
.
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
.
|
|
6498
|
-
|
|
6499
|
-
|
|
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
|
|
6502
|
-
.
|
|
6503
|
-
.
|
|
6504
|
-
|
|
6505
|
-
.
|
|
6506
|
-
|
|
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
|
-
<
|
|
6520
|
-
|
|
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
|
-
<
|
|
6527
|
-
<
|
|
6528
|
-
<div class="
|
|
6529
|
-
|
|
6530
|
-
|
|
6531
|
-
|
|
6532
|
-
|
|
6533
|
-
|
|
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="
|
|
6536
|
-
|
|
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="
|
|
6455
|
+
<div class="card" style="padding:0;overflow:hidden"><div id="chat"></div></div>
|
|
6558
6456
|
</section>
|
|
6559
6457
|
|
|
6560
|
-
<
|
|
6561
|
-
|
|
6562
|
-
<
|
|
6563
|
-
|
|
6564
|
-
|
|
6565
|
-
<button
|
|
6566
|
-
<
|
|
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
|
-
<
|
|
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
|
|
6588
|
-
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
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
|
-
|
|
6599
|
-
|
|
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
|
|
6620
|
-
if (r.status
|
|
6621
|
-
return (
|
|
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
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
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
|
|
6639
|
-
|
|
6640
|
-
const
|
|
6641
|
-
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
|
|
6647
|
-
|
|
6648
|
-
|
|
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
|
-
|
|
6663
|
-
} catch (e) { bubble('bot', 'The assistant is not reachable right now.'); }
|
|
6554
|
+
} catch { /* the page keeps working */ }
|
|
6664
6555
|
}
|
|
6665
|
-
|
|
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
|
-
$('#
|
|
6699
|
-
$('#
|
|
6700
|
-
$('#
|
|
6701
|
-
if (c.
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
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
|
-
|
|
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:
|
|
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")}
|
|
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("
|
|
6922
|
-
console.log(
|
|
6923
|
-
console.log(
|
|
6924
|
-
|
|
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(`
|
|
6929
|
-
console.log(`
|
|
6930
|
-
console.log(import_chalk25.default.dim(
|
|
6931
|
-
console.log(import_chalk25.default.dim(`
|
|
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
|
|
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
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
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
|
-
|
|
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
|
|
7979
|
-
|
|
7980
|
-
|
|
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
|
|
7992
|
-
- "
|
|
7993
|
-
- "
|
|
7994
|
-
-
|
|
7995
|
-
|
|
7996
|
-
|
|
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(`
|
|
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
|
|
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)) {
|