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