apiblaze 0.21.54 → 0.21.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +7 -0
  2. package/dist/index.js +189 -37
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -52,6 +52,13 @@ npx apiblaze create --target https://petstore3.swagger.io/api/v3/openapi.json
52
52
  # Same thing with zero prompts: both doors, every lockable resource locked.
53
53
  npx apiblaze create --target ./openapi.yaml --auto
54
54
 
55
+ # A compliant Claude connector, directory-ready in one command: the MCP server on your own
56
+ # hostname (one CNAME), OAuth sign-in, listing answers pre-filled from the spec and cut to the
57
+ # portal's limits, the reviewer account, and the submission packet. It ends with a table of every
58
+ # requirement and why it is met. `--compliant chatgpt` makes a ChatGPT plugin to OpenAI's
59
+ # policies; `--compliant all` does both. You submit the packet to Anthropic or OpenAI yourself.
60
+ npx apiblaze create --compliant --target https://yourdomain.com/openapi.yaml --domain mcp.yourdomain.com
61
+
55
62
  # Teach Claude Code to do all of the above for you when you say "ship this API"
56
63
  npx apiblaze skill --install
57
64
 
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.54";
37
+ version = "0.21.58";
38
38
  }
39
39
  });
40
40
 
@@ -1124,7 +1124,7 @@ async function resolveProject(teamId, nameOrId, version2) {
1124
1124
  projectName: chosen.projectName,
1125
1125
  apiVersion: chosen.apiVersion,
1126
1126
  teamId,
1127
- tenant: chosen.tenant
1127
+ tenant: chosen.tenant ?? void 0
1128
1128
  };
1129
1129
  }
1130
1130
  var import_chalk6;
@@ -5309,8 +5309,8 @@ __export(directory_exports, {
5309
5309
  fetchDirectoryReport: () => fetchDirectoryReport,
5310
5310
  isApiError: () => isApiError,
5311
5311
  isReady: () => isReady,
5312
+ parseCompliant: () => parseCompliant,
5312
5313
  parsePlatforms: () => parsePlatforms,
5313
- parsePolicy: () => parsePolicy,
5314
5314
  printReport: () => printReport
5315
5315
  });
5316
5316
  function attestationId(flag) {
@@ -5343,7 +5343,7 @@ function directoryBodyFromFlags(f) {
5343
5343
  }
5344
5344
  return out;
5345
5345
  }
5346
- function parsePolicy(policy, platform) {
5346
+ function parseCompliant(policy, platform) {
5347
5347
  if (platform && (policy === void 0 || policy === true)) return parsePlatforms(platform) ?? ["claude"];
5348
5348
  if (policy === void 0 || policy === false || policy === true) return ["claude"];
5349
5349
  const raw = (Array.isArray(policy) ? policy : [policy]).flatMap((s) => String(s).split(",")).map((s) => s.trim().toLowerCase()).filter(Boolean);
@@ -5354,7 +5354,7 @@ function parsePolicy(policy, platform) {
5354
5354
  PLATFORMS.forEach((x) => out.add(x));
5355
5355
  continue;
5356
5356
  }
5357
- if (!PLATFORMS.includes(p)) throw new Error(`Unknown policy "${p}". Use claude, chatgpt or all.`);
5357
+ if (!PLATFORMS.includes(p)) throw new Error(`Unknown directory "${p}" for --compliant. Use claude, chatgpt or all.`);
5358
5358
  out.add(p);
5359
5359
  }
5360
5360
  return PLATFORMS.filter((p) => out.has(p));
@@ -7621,7 +7621,7 @@ var init_files = __esm({
7621
7621
  "src/demo-app/files.ts"() {
7622
7622
  "use strict";
7623
7623
  DEMO_FILES = {
7624
- "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",
7624
+ "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\n## Measurement\n\nThe page asks, once, whether it may send anonymous usage events to Google Analytics \u2014 which\nbuttons you press here, so we can see what people actually try. It never includes your bookings,\nyour messages to the assistant, or anything you type.\n\nNothing is loaded from Google and nothing is sent until you press **Allow**. Your answer is kept\nin this browser. If your browser sends Do Not Track (or Global Privacy Control), the question is\nnot asked and nothing is sent. To remove it entirely:\n\n```\nAPIBLAZE_DEMO_ANALYTICS=0 npx apiblaze@latest demo\n```\n",
7625
7625
  "openapi.yaml": `openapi: 3.0.3
7626
7626
  info:
7627
7627
  title: ResiResi API
@@ -7717,7 +7717,7 @@ components:
7717
7717
  id: { type: string, example: r1 }
7718
7718
  `,
7719
7719
  "backend.js": "// ResiResi backend: restaurants (tenants, like Nino Pizza and Gino Pizza) let their customers\n// book, list and cancel tables. It runs on YOUR laptop.\n//\n// `npx apiblaze@latest dev` (which the demo runs for you) puts it on the internet\n// at a public URL and gives AI assistants an MCP address for it. Everything else\n// (API keys, sign-in, who may change what, rate limits) happens in APIblaze, in\n// front of this file. Look at what is NOT here: no login, no keys, no permission\n// checks, no AI code.\n//\n// What APIblaze tells this backend about each call, in two headers:\n// x-abz-tenant-id which customer (pizzeria) is calling\n// x-abz-user-id which person\n// and one shared secret it adds to every call it forwards (x-target-api-key), so a\n// caller who skips APIblaze and hits localhost:3001 directly is refused.\n//\n// Zero dependencies. Run it alone with `node backend.js` (port 3001).\n\nconst http = require('http');\nconst fs = require('fs');\nconst path = require('path');\n\nconst bookings = new Map();\nlet next = 1;\n// The people the demo provisioned up front, per pizzeria: { tenant: { handle: stable id } }.\n// Through APIblaze every call already names the stable id (x-abz-user-id); a call straight\n// to this port still says \"ana\", and must mean the same person.\nconst USER_IDS = (() => { try { return JSON.parse(process.env.DEMO_USER_IDS || '{}'); } catch { return {}; } })();\nconst idOf = (tenant, handle) => (USER_IDS[tenant] || {})[handle] || handle;\n// Bumps on every booking made or cancelled. The page asks the app (on this laptop, free)\n// whether it moved before re-listing through APIblaze, so an open, idle page spends nothing.\nlet changes = 0;\n// The last requests this laptop answered \u2014 the demo page shows them live.\nconst recent = [];\nfunction remember(entry) {\n recent.unshift({ at: Date.now(), ...entry });\n recent.length = Math.min(recent.length, 50);\n // One line per request in the terminal (`npx apiblaze@latest demo logs` follows it).\n const who = entry.who ? `${entry.who}${entry.tenant ? ` @ ${entry.tenant}` : ''}` : '-';\n console.log(`${new Date().toISOString().slice(11, 19)} ${entry.status} ${entry.method.padEnd(6)} ${entry.path.padEnd(22)} ${who}`);\n}\n\n// The restaurant's day, YYYY-MM-DD, on this laptop's clock: a booking without a date is for today.\nfunction today() {\n const d = new Date();\n return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;\n}\n// A real calendar day written YYYY-MM-DD (2026-02-30 is not one).\nfunction isDay(s) {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return false;\n const [y, m, d] = s.split('-').map(Number);\n const t = new Date(Date.UTC(y, m - 1, d));\n return t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d;\n}\n\n// The restaurant picks the table: the lowest one still free that day at that time, at that restaurant.\nfunction freeTable(tenant, date, time) {\n const taken = new Set([...bookings.values()].filter((r) => r.tenant === tenant && r.date === date && r.time === time).map((r) => r.table));\n let t = 1;\n while (taken.has(t)) t++;\n return t;\n}\n\nfunction send(res, status, body) {\n res.writeHead(status, { 'content-type': 'application/json' });\n res.end(body === undefined ? '' : JSON.stringify(body));\n}\n\nfunction handle(req, res, body) {\n const url = new URL(req.url, 'http://x');\n if (url.pathname === '/openapi.yaml') {\n res.writeHead(200, { 'content-type': 'text/yaml' });\n return res.end(fs.readFileSync(path.join(__dirname, 'openapi.yaml')));\n }\n const tenant = req.headers['x-abz-tenant-id'] || '';\n const who = idOf(tenant, req.headers['x-abz-user-id'] || '');\n // Shown in the ticker and the terminal: the name the caller gave (APIblaze forwards it as\n // x-end-user-id next to the stable id), never the opaque id.\n const shown = req.headers['x-end-user-id'] || req.headers['x-abz-user-id'] || '';\n const reply = (status, data) => { remember({ method: req.method, path: url.pathname, who: shown, tenant, status }); send(res, status, data); };\n const secret = process.env.BACKEND_SECRET || '';\n if (secret && req.headers['x-target-api-key'] !== secret) return reply(401, { error: 'missing or wrong backend secret (APIblaze adds it to every call it forwards)' });\n if (!who) return reply(401, { error: 'say who is calling (x-abz-user-id)' });\n\n const m = /^\\/reservations(?:\\/([^/]+))?$/.exec(url.pathname);\n if (!m) return reply(404, { error: 'not found' });\n const id = m[1];\n\n if (!id && req.method === 'POST') {\n let input = {};\n try { input = JSON.parse(body || '{}'); } catch { return reply(400, { error: 'body must be JSON' }); }\n const date = input.date ? String(input.date) : today();\n if (!isDay(date)) return reply(400, { error: 'date must be a day, YYYY-MM-DD' });\n const time = String(input.time || '19:30');\n const row = {\n id: `r${next++}`, name: String(input.name || who), table: Number(input.table) || freeTable(tenant, date, time),\n date, time, guests: Number(input.guests) || 2, owner: who, tenant,\n };\n bookings.set(row.id, row); changes++;\n return reply(201, row);\n }\n if (!id && req.method === 'GET') {\n // Lists are the backend's job: each person sees their own bookings, at their own pizzeria.\n return reply(200, [...bookings.values()].filter((r) => r.owner === who && r.tenant === tenant)\n .sort((a, b) => `${a.date} ${a.time}`.localeCompare(`${b.date} ${b.time}`)));\n }\n const row = bookings.get(id);\n if (!row) return reply(404, { error: 'no such booking' });\n if (req.method === 'GET') return reply(200, row);\n if (req.method === 'PATCH') {\n let input = {};\n try { input = JSON.parse(body || '{}'); } catch { return reply(400, { error: 'body must be JSON' }); }\n const date = input.date ? String(input.date) : row.date;\n if (!isDay(date)) return reply(400, { error: 'date must be a day, YYYY-MM-DD' });\n const time = input.time ? String(input.time) : row.time;\n const moved = date !== row.date || time !== row.time;\n const table = input.table ? Number(input.table) : (moved ? freeTable(row.tenant, date, time) : row.table);\n const clash = [...bookings.values()].find((r) => r.id !== row.id && r.tenant === row.tenant && r.date === date && r.time === time && r.table === table);\n if (clash) return reply(409, { error: `table ${table} is already booked on ${date} at ${time}` });\n Object.assign(row, { date, time, table, ...(input.guests ? { guests: Number(input.guests) } : {}) }); changes++;\n return reply(200, row);\n }\n if (req.method === 'DELETE') { bookings.delete(id); changes++; return reply(204); }\n return reply(405, { error: 'method not allowed' });\n}\n\nfunction startBackend(port = Number(process.env.BACKEND_PORT) || 3001) {\n const server = http.createServer((req, res) => {\n let body = '';\n req.on('data', (c) => { body += c; });\n req.on('end', () => {\n try { handle(req, res, body); } catch (e) { send(res, 500, { error: String((e && e.message) || e) }); }\n });\n });\n return new Promise((resolve, reject) => {\n server.once('error', reject);\n server.listen(port, '127.0.0.1', () => resolve(server));\n });\n}\n\nmodule.exports = { startBackend, recent, changeCount: () => changes };\nif (require.main === module) startBackend().then(() => console.log('backend on http://localhost:3001'));\n",
7720
- "app.js": "// ResiResi website server: serves the page and makes its calls.\n//\n// PROTECTED (the default): every call goes to the PUBLIC API \u2014 through APIblaze \u2014 with the\n// pizzeria's server key (kept here, never in the browser) and X-End-User-Id naming the\n// person. APIblaze checks the key, applies the rules and forwards to the backend.\n//\n// DIRECT (the page's \"unprotected\" switch): the same calls go straight to the backend on\n// localhost, with the shared backend secret and whatever user the page claims. No APIblaze:\n// nobody checks who may do what. That is how most apps ship.\n//\n// The chat and the two admin widgets use apiblaze's own server helpers\n// (lib/apiblaze-server.js, copied in by `npx apiblaze@latest demo` \u2014 the same code as\n// `import \u2026 from 'apiblaze/server'`).\n\nconst http = require('http');\nconst fs = require('fs');\nconst path = require('path');\n\nconst env = (name, fallback = '') => process.env[name] || fallback;\nconst json = (res, status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };\n\nlet lib = null;\ntry { lib = require('./lib/apiblaze-server.js'); } catch { /* run without the widgets */ }\n\n// The two pizzerias (tenants) and the people the demo lets you be. A real app takes\n// \"who is this\" from its own login.\nconst PIZZERIAS = {\n nino: { label: 'Nino Pizza', tenant: () => env('APIBLAZE_TENANT', env('DEMO_NINO_TENANT')), key: () => env('APIBLAZE_SERVER_KEY'), admin: { userId: 'ana', label: 'Ana', email: 'ana@ninopizza.example' } },\n gino: { label: 'Gino Pizza', tenant: () => env('APIBLAZE_GINO_TENANT', env('DEMO_GINO_TENANT')), key: () => env('APIBLAZE_GINO_KEY'), admin: { userId: 'gina', label: 'Gina', email: 'gina@ginopizza.example' } },\n};\n// Each pizzeria (tenant) has its own MCP address: https://{proxy}-{tenant}.mcp.\u2026\nconst portalFor = (tenant) => (env('APIBLAZE_PORTAL_URL') && env('APIBLAZE_TENANT') && tenant ? env('APIBLAZE_PORTAL_URL').replace(`${env('APIBLAZE_TENANT')}.portal`, `${tenant}.portal`) : '');\nconst mcpFor = (tenant) => (env('APIBLAZE_MCP_URL') && env('APIBLAZE_TENANT') && tenant ? env('APIBLAZE_MCP_URL').replace(`-${env('APIBLAZE_TENANT')}.`, `-${tenant}.`) : '');\nconst CUSTOMERS = { ana: 'Ana', ben: 'Ben' };\nconst pizzeriaOf = (id) => PIZZERIAS[id] || PIZZERIAS.nino;\nfunction queryOf(req) { return new URL(req.url, 'http://x').searchParams; }\n\nasync function call(op, { pizzeria = 'nino', as = 'ana', id, body, direct = false } = {}) {\n const pz = pizzeriaOf(pizzeria);\n const base = direct ? `http://127.0.0.1:${env('BACKEND_PORT', '3001')}` : env('APIBLAZE_URL').replace(/\\/$/, '');\n const headers = { 'content-type': 'application/json' };\n if (direct) {\n // Straight to localhost: the backend trusts whatever these headers say.\n headers['x-target-api-key'] = env('BACKEND_SECRET');\n headers['x-abz-user-id'] = as;\n headers['x-abz-tenant-id'] = pz.tenant();\n } else {\n headers['x-api-key'] = pz.key();\n headers['x-end-user-id'] = as;\n }\n const req = op === 'book' ? { method: 'POST', url: `${base}/reservations`, body: JSON.stringify({ name: CUSTOMERS[as] || as, ...(body || {}) }) }\n : op === 'cancel' ? { method: 'DELETE', url: `${base}/reservations/${encodeURIComponent(id || '')}` }\n : { method: 'GET', url: `${base}/reservations` };\n const r = await fetch(req.url, { method: req.method, headers, body: req.body });\n const text = await r.text();\n let data = null;\n try { data = text ? JSON.parse(text) : null; } catch { data = text; }\n return { status: r.status, body: data };\n}\n\n// Node http <-> the web-standard handlers apiblaze/server exports.\nasync function viaHandler(handler, req, res, raw) {\n const request = new Request(`http://localhost${req.url}`, {\n method: req.method,\n headers: { 'content-type': req.headers['content-type'] || 'application/json' },\n body: ['GET', 'HEAD'].includes(req.method) ? undefined : raw,\n });\n const out = await handler(request);\n const headers = {};\n out.headers.forEach((v, k) => { headers[k] = v; });\n res.writeHead(out.status, headers);\n if (!out.body) return res.end();\n const reader = out.body.getReader();\n for (;;) { const { done, value } = await reader.read(); if (done) break; res.write(Buffer.from(value)); }\n res.end();\n}\n\nfunction widgets() {\n if (!lib) return {};\n const out = { chat: {} };\n for (const [id, pz] of Object.entries(PIZZERIAS)) {\n if (!pz.tenant() || !pz.key()) continue;\n // The assistant acts as the signed-in customer, at that customer's pizzeria.\n out.chat[id] = lib.createApiblazeChat({\n project: `${env('APIBLAZE_NAME')}-${pz.tenant()}`, apiKey: pz.key(),\n host: 'tryabz.run', environment: 'dev', getUser: (req) => ({ userId: queryOf(req).get('as') || 'ana' }),\n });\n }\n if (env('APIBLAZE_CP_KEY') && env('APIBLAZE_WIDGETS') === '1') {\n // Admin widgets: the admin of the chosen pizzeria.\n const admin = (req) => { const pz = pizzeriaOf(queryOf(req).get('pizzeria')); return { tenant: pz.tenant(), ...pz.admin }; };\n out.keys = lib.createApiblazeKeys({ cpKey: env('APIBLAZE_CP_KEY'), getUser: admin, environment: 'dev' });\n out.groups = lib.createApiblazeGroups({ cpKey: env('APIBLAZE_CP_KEY'), getUser: admin });\n }\n return out;\n}\n\n/** One of the two websites: `direct` = straight to the backend (unprotected), else through APIblaze. */\nfunction startApp(port = Number(env('APP_PORT', '3000')), { direct = false } = {}) {\n const w = direct ? {} : widgets();\n const pub = path.join(__dirname, 'public');\n const server = http.createServer((req, res) => {\n let raw = '';\n req.on('data', (c) => { raw += c; });\n req.on('end', async () => {\n try {\n const url = new URL(req.url, 'http://x');\n const p = url.pathname;\n if (req.method === 'GET' && (p === '/' || p === '/index.html')) {\n res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });\n return res.end(fs.readFileSync(path.join(pub, 'index.html')));\n }\n for (const asset of ['apiblaze-widgets.js', 'apiblaze-logo.svg']) {\n if (req.method === 'GET' && p === `/${asset}` && fs.existsSync(path.join(pub, asset))) {\n res.writeHead(200, { 'content-type': asset.endsWith('.svg') ? 'image/svg+xml' : 'text/javascript; charset=utf-8' });\n return res.end(fs.readFileSync(path.join(pub, asset)));\n }\n }\n if (p === '/api/config') {\n return json(res, 200, {\n direct, apiUrl: env('APIBLAZE_URL'), portalUrl: env('APIBLAZE_PORTAL_URL'),\n backendPort: env('BACKEND_PORT', '3001'),\n protectedUrl: env('APIBLAZE_URL') ? `http://localhost:${env('APP_PORT', '3000')}` : '',\n directUrl: `http://localhost:${env('DIRECT_PORT', '3002')}`,\n logsCommand: `npx apiblaze@latest logs ${env('APIBLAZE_NAME')} --tenant ${env('APIBLAZE_TENANT')}`,\n pizzerias: Object.entries(PIZZERIAS).filter(([, pz]) => pz.tenant()).map(([id, pz]) => ({ id, label: pz.label, admin: pz.admin.label, mcpUrl: mcpFor(pz.tenant()), portalUrl: portalFor(pz.tenant()) })),\n widgets: { chat: Object.keys(w.chat || {}), keys: !!w.keys, groups: !!w.groups },\n });\n }\n if (p === '/api/changes') {\n // Local and free: has any booking changed since the page last listed? The page\n // re-lists through APIblaze only when it has (the backend runs in this process).\n let n = 0;\n try { n = require('./backend').changeCount(); } catch { /* backend runs elsewhere */ }\n return json(res, 200, { n });\n }\n if (p === '/api/apiblaze/chat') {\n const h = (w.chat || {})[url.searchParams.get('pizzeria') || 'nino'];\n if (h) return viaHandler(h.handler, req, res, raw);\n }\n if (p === '/api/apiblaze/keys' && w.keys) return viaHandler(w.keys.handler, req, res, raw);\n if (p === '/api/apiblaze/groups' && w.groups) return viaHandler(w.groups.handler, req, res, raw);\n if (req.method === 'POST' && p === '/api/act') {\n const b = JSON.parse(raw || '{}');\n return json(res, 200, await call(String(b.op || 'list'), { pizzeria: b.pizzeria, as: String(b.as || 'ana'), id: b.id, body: b.body, direct }));\n }\n json(res, 404, { error: 'not found' });\n } catch (e) {\n json(res, 502, { error: `Could not reach the API: ${String((e && e.message) || e)}` });\n }\n });\n });\n return new Promise((resolve, reject) => {\n server.once('error', reject);\n server.listen(port, '127.0.0.1', () => resolve(server));\n });\n}\n\nmodule.exports = { startApp };\n",
7720
+ "app.js": "// ResiResi website server: serves the page and makes its calls.\n//\n// PROTECTED (the default): every call goes to the PUBLIC API \u2014 through APIblaze \u2014 with the\n// pizzeria's server key (kept here, never in the browser) and X-End-User-Id naming the\n// person. APIblaze checks the key, applies the rules and forwards to the backend.\n//\n// DIRECT (the page's \"unprotected\" switch): the same calls go straight to the backend on\n// localhost, with the shared backend secret and whatever user the page claims. No APIblaze:\n// nobody checks who may do what. That is how most apps ship.\n//\n// The chat and the two admin widgets use apiblaze's own server helpers\n// (lib/apiblaze-server.js, copied in by `npx apiblaze@latest demo` \u2014 the same code as\n// `import \u2026 from 'apiblaze/server'`).\n\nconst http = require('http');\nconst fs = require('fs');\nconst path = require('path');\n\nconst env = (name, fallback = '') => process.env[name] || fallback;\nconst json = (res, status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };\n\nlet lib = null;\ntry { lib = require('./lib/apiblaze-server.js'); } catch { /* run without the widgets */ }\n\n// The two pizzerias (tenants) and the people the demo lets you be. A real app takes\n// \"who is this\" from its own login.\nconst PIZZERIAS = {\n nino: { label: 'Nino Pizza', tenant: () => env('APIBLAZE_TENANT', env('DEMO_NINO_TENANT')), key: () => env('APIBLAZE_SERVER_KEY'), admin: { userId: 'ana', label: 'Ana', email: 'ana@ninopizza.example' } },\n gino: { label: 'Gino Pizza', tenant: () => env('APIBLAZE_GINO_TENANT', env('DEMO_GINO_TENANT')), key: () => env('APIBLAZE_GINO_KEY'), admin: { userId: 'gina', label: 'Gina', email: 'gina@ginopizza.example' } },\n};\n// Each pizzeria (tenant) has its own MCP address: https://{proxy}-{tenant}.mcp.\u2026\nconst portalFor = (tenant) => (env('APIBLAZE_PORTAL_URL') && env('APIBLAZE_TENANT') && tenant ? env('APIBLAZE_PORTAL_URL').replace(`${env('APIBLAZE_TENANT')}.portal`, `${tenant}.portal`) : '');\nconst mcpFor = (tenant) => (env('APIBLAZE_MCP_URL') && env('APIBLAZE_TENANT') && tenant ? env('APIBLAZE_MCP_URL').replace(`-${env('APIBLAZE_TENANT')}.`, `-${tenant}.`) : '');\nconst CUSTOMERS = { ana: 'Ana', ben: 'Ben' };\nconst pizzeriaOf = (id) => PIZZERIAS[id] || PIZZERIAS.nino;\nfunction queryOf(req) { return new URL(req.url, 'http://x').searchParams; }\n\nasync function call(op, { pizzeria = 'nino', as = 'ana', id, body, direct = false } = {}) {\n const pz = pizzeriaOf(pizzeria);\n const base = direct ? `http://127.0.0.1:${env('BACKEND_PORT', '3001')}` : env('APIBLAZE_URL').replace(/\\/$/, '');\n const headers = { 'content-type': 'application/json' };\n if (direct) {\n // Straight to localhost: the backend trusts whatever these headers say.\n headers['x-target-api-key'] = env('BACKEND_SECRET');\n headers['x-abz-user-id'] = as;\n headers['x-abz-tenant-id'] = pz.tenant();\n } else {\n headers['x-api-key'] = pz.key();\n headers['x-end-user-id'] = as;\n }\n const req = op === 'book' ? { method: 'POST', url: `${base}/reservations`, body: JSON.stringify({ name: CUSTOMERS[as] || as, ...(body || {}) }) }\n : op === 'cancel' ? { method: 'DELETE', url: `${base}/reservations/${encodeURIComponent(id || '')}` }\n : { method: 'GET', url: `${base}/reservations` };\n const r = await fetch(req.url, { method: req.method, headers, body: req.body });\n const text = await r.text();\n let data = null;\n try { data = text ? JSON.parse(text) : null; } catch { data = text; }\n return { status: r.status, body: data };\n}\n\n// Node http <-> the web-standard handlers apiblaze/server exports.\nasync function viaHandler(handler, req, res, raw) {\n const request = new Request(`http://localhost${req.url}`, {\n method: req.method,\n headers: { 'content-type': req.headers['content-type'] || 'application/json' },\n body: ['GET', 'HEAD'].includes(req.method) ? undefined : raw,\n });\n const out = await handler(request);\n const headers = {};\n out.headers.forEach((v, k) => { headers[k] = v; });\n res.writeHead(out.status, headers);\n if (!out.body) return res.end();\n const reader = out.body.getReader();\n for (;;) { const { done, value } = await reader.read(); if (done) break; res.write(Buffer.from(value)); }\n res.end();\n}\n\nfunction widgets() {\n if (!lib) return {};\n const out = { chat: {} };\n for (const [id, pz] of Object.entries(PIZZERIAS)) {\n if (!pz.tenant() || !pz.key()) continue;\n // The assistant acts as the signed-in customer, at that customer's pizzeria.\n out.chat[id] = lib.createApiblazeChat({\n project: `${env('APIBLAZE_NAME')}-${pz.tenant()}`, apiKey: pz.key(),\n host: 'tryabz.run', environment: 'dev', getUser: (req) => ({ userId: queryOf(req).get('as') || 'ana' }),\n });\n }\n if (env('APIBLAZE_CP_KEY') && env('APIBLAZE_WIDGETS') === '1') {\n // Admin widgets: the admin of the chosen pizzeria.\n const admin = (req) => { const pz = pizzeriaOf(queryOf(req).get('pizzeria')); return { tenant: pz.tenant(), ...pz.admin }; };\n out.keys = lib.createApiblazeKeys({ cpKey: env('APIBLAZE_CP_KEY'), getUser: admin, environment: 'dev' });\n out.groups = lib.createApiblazeGroups({ cpKey: env('APIBLAZE_CP_KEY'), getUser: admin });\n }\n return out;\n}\n\n/** One of the two websites: `direct` = straight to the backend (unprotected), else through APIblaze. */\nfunction startApp(port = Number(env('APP_PORT', '3000')), { direct = false } = {}) {\n const w = direct ? {} : widgets();\n const pub = path.join(__dirname, 'public');\n const server = http.createServer((req, res) => {\n let raw = '';\n req.on('data', (c) => { raw += c; });\n req.on('end', async () => {\n try {\n const url = new URL(req.url, 'http://x');\n const p = url.pathname;\n if (req.method === 'GET' && (p === '/' || p === '/index.html')) {\n res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });\n return res.end(fs.readFileSync(path.join(pub, 'index.html')));\n }\n for (const asset of ['apiblaze-widgets.js', 'apiblaze-logo.svg']) {\n if (req.method === 'GET' && p === `/${asset}` && fs.existsSync(path.join(pub, asset))) {\n res.writeHead(200, { 'content-type': asset.endsWith('.svg') ? 'image/svg+xml' : 'text/javascript; charset=utf-8' });\n return res.end(fs.readFileSync(path.join(pub, asset)));\n }\n }\n if (p === '/api/config') {\n return json(res, 200, {\n direct, apiUrl: env('APIBLAZE_URL'), portalUrl: env('APIBLAZE_PORTAL_URL'),\n backendPort: env('BACKEND_PORT', '3001'),\n protectedUrl: env('APIBLAZE_URL') ? `http://localhost:${env('APP_PORT', '3000')}` : '',\n directUrl: `http://localhost:${env('DIRECT_PORT', '3002')}`,\n logsCommand: `npx apiblaze@latest logs ${env('APIBLAZE_NAME')} --tenant ${env('APIBLAZE_TENANT')}`,\n pizzerias: Object.entries(PIZZERIAS).filter(([, pz]) => pz.tenant()).map(([id, pz]) => ({ id, label: pz.label, admin: pz.admin.label, mcpUrl: mcpFor(pz.tenant()), portalUrl: portalFor(pz.tenant()) })),\n widgets: { chat: Object.keys(w.chat || {}), keys: !!w.keys, groups: !!w.groups },\n // Measurement off-switch for whoever runs the demo: start it with\n // APIBLAZE_DEMO_ANALYTICS=0 and the page never asks and never loads\n // anything from Google. Anything else leaves the opt-in question to\n // the visitor (public/index.html).\n analytics: env('APIBLAZE_DEMO_ANALYTICS', '1') !== '0',\n });\n }\n if (p === '/api/changes') {\n // Local and free: has any booking changed since the page last listed? The page\n // re-lists through APIblaze only when it has (the backend runs in this process).\n let n = 0;\n try { n = require('./backend').changeCount(); } catch { /* backend runs elsewhere */ }\n return json(res, 200, { n });\n }\n if (p === '/api/apiblaze/chat') {\n const h = (w.chat || {})[url.searchParams.get('pizzeria') || 'nino'];\n if (h) return viaHandler(h.handler, req, res, raw);\n }\n if (p === '/api/apiblaze/keys' && w.keys) return viaHandler(w.keys.handler, req, res, raw);\n if (p === '/api/apiblaze/groups' && w.groups) return viaHandler(w.groups.handler, req, res, raw);\n if (req.method === 'POST' && p === '/api/act') {\n const b = JSON.parse(raw || '{}');\n return json(res, 200, await call(String(b.op || 'list'), { pizzeria: b.pizzeria, as: String(b.as || 'ana'), id: b.id, body: b.body, direct }));\n }\n json(res, 404, { error: 'not found' });\n } catch (e) {\n json(res, 502, { error: `Could not reach the API: ${String((e && e.message) || e)}` });\n }\n });\n });\n return new Promise((resolve, reject) => {\n server.once('error', reject);\n server.listen(port, '127.0.0.1', () => resolve(server));\n });\n}\n\nmodule.exports = { startApp };\n",
7721
7721
  "start.js": "// Starts ResiResi on this laptop: the backend (3001), the unprotected front-end straight to it\n// (3002) and, once APIblaze is set up, the protected website through APIblaze (3000).\n// No APIblaze involved here \u2014 the tunnel is `npx apiblaze@latest demo start|stop|restart`.\n//\n// node start.js run in the foreground (Ctrl-C stops)\n// npm run start run in the background\n// npm run stop stop the background one\n// npm run restart stop, then start again\n\nconst fs = require('fs');\nconst path = require('path');\nconst { spawn } = require('child_process');\n\nconst PID = path.join(__dirname, '.demo', 'app.pid');\n\nfunction loadEnv(file = path.join(__dirname, '.env.local')) {\n if (!fs.existsSync(file)) return;\n for (const line of fs.readFileSync(file, 'utf8').split('\\n')) {\n const m = /^\\s*([A-Z0-9_]+)\\s*=\\s*(.*)\\s*$/.exec(line);\n if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^\"(.*)\"$/, '$1');\n }\n}\n\nasync function start() {\n loadEnv();\n const { startBackend } = require('./backend');\n const { startApp } = require('./app');\n const backendPort = Number(process.env.BACKEND_PORT) || 3001;\n const appPort = Number(process.env.APP_PORT) || 3000;\n const directPort = Number(process.env.DIRECT_PORT) || 3002;\n await startBackend(backendPort);\n // The unprotected front-end: straight to the backend, no APIblaze.\n await startApp(directPort, { direct: true });\n // The protected website: through APIblaze (once the demo has written its keys).\n if (process.env.APIBLAZE_URL) await startApp(appPort);\n return { backendPort, appPort, directPort };\n}\n\nconst alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };\nfunction runningPid() {\n try { const pid = Number(fs.readFileSync(PID, 'utf8')); return pid && alive(pid) ? pid : null; } catch { return null; }\n}\nasync function stop() {\n const pid = runningPid();\n if (!pid) { console.log('ResiResi is not running.'); return; }\n process.kill(pid, 'SIGTERM');\n for (let i = 0; i < 30 && alive(pid); i++) await new Promise((r) => setTimeout(r, 100));\n try { fs.unlinkSync(PID); } catch { /* gone */ }\n console.log('Stopped ResiResi (backend and websites).');\n}\nfunction background() {\n if (runningPid()) { console.log('ResiResi is already running.'); return; }\n fs.mkdirSync(path.dirname(PID), { recursive: true });\n const log = fs.openSync(path.join(__dirname, '.demo', 'app.log'), 'a');\n const child = spawn(process.execPath, [__filename], { cwd: __dirname, detached: true, stdio: ['ignore', log, log] });\n child.unref();\n fs.writeFileSync(PID, String(child.pid));\n loadEnv();\n console.log(`Started ResiResi: backend localhost:${process.env.BACKEND_PORT || 3001}, unprotected front-end http://localhost:${process.env.DIRECT_PORT || 3002}${process.env.APIBLAZE_URL ? `, protected website http://localhost:${process.env.APP_PORT || 3000}` : ''}.`);\n}\n\nmodule.exports = { start, loadEnv };\nif (require.main === module) {\n const arg = process.argv[2];\n if (arg === '--stop') stop();\n else if (arg === '--background') background();\n else if (arg === '--restart') stop().then(() => setTimeout(background, 300));\n else {\n start().then(({ appPort, backendPort, directPort }) => console.log(`ResiResi running: backend localhost:${backendPort}, unprotected front-end http://localhost:${directPort}${process.env.APIBLAZE_URL ? `, protected website http://localhost:${appPort}` : ''}. Ctrl-C stops.`))\n .catch((e) => { console.error(`Could not start: ${e.message}`); process.exit(1); });\n }\n}\n",
7722
7722
  "public/index.html": `<!doctype html>
7723
7723
  <html lang="en">
@@ -7794,7 +7794,38 @@ components:
7794
7794
  .stack { display: grid; gap: 16px; }
7795
7795
  button.copy { font-size: 11.5px; padding: 1px 8px; border-radius: 8px; border: 1px solid var(--line); background: var(--card); color: var(--ink); cursor: pointer; vertical-align: 1px; }
7796
7796
  button.copy:hover { border-color: var(--muted); }
7797
+
7798
+ /* The measurement choice. One line, at the bottom, asked once. */
7799
+ .consent { position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; display: flex; flex-wrap: wrap; align-items: center;
7800
+ gap: 10px 16px; padding: 12px 16px; background: var(--card); border-top: 1px solid var(--line); font-size: 13.5px; color: var(--muted); }
7801
+ .consent b { color: var(--ink); }
7802
+ .consent .sp { flex: 1 1 auto; }
7797
7803
  </style>
7804
+
7805
+ <!--
7806
+ Google Analytics, opt-in.
7807
+
7808
+ This page runs on the visitor's OWN laptop (\`npx apiblaze@latest demo\`), which
7809
+ makes measurement a different question than it is on apiblaze.com. So:
7810
+ \xB7 every consent signal starts denied, right here, before anything else runs;
7811
+ \xB7 nothing is fetched from Google until the visitor presses Allow \u2014 no tag,
7812
+ no cookie, and no cookieless ping either;
7813
+ \xB7 Do Not Track (or Global Privacy Control) is read as "no" without asking;
7814
+ \xB7 \`APIBLAZE_DEMO_ANALYTICS=0\` when starting the demo removes the question
7815
+ entirely (app.js stops advertising measurement in /api/config).
7816
+
7817
+ Only \`demo_*\` EVENTS are sent, never a page view: a page view from here would
7818
+ land in the same property as apiblaze.com under the hostname "localhost" and
7819
+ quietly ruin every page report there.
7820
+ -->
7821
+ <script>
7822
+ window.dataLayer = window.dataLayer || [];
7823
+ function gtag() { dataLayer.push(arguments); }
7824
+ gtag('consent', 'default', {
7825
+ analytics_storage: 'denied', ad_storage: 'denied',
7826
+ ad_user_data: 'denied', ad_personalization: 'denied',
7827
+ });
7828
+ </script>
7798
7829
  </head>
7799
7830
  <body>
7800
7831
  <main>
@@ -7861,6 +7892,16 @@ components:
7861
7892
  <p class="next">Each pizzeria only sees its own keys and people: switch pizzeria above. Developers can also sign in and get a key at the <a id="portal" target="_blank" rel="noopener">developer portal</a>.</p>
7862
7893
  </section>
7863
7894
  </main>
7895
+
7896
+ <div class="consent" id="consent" hidden>
7897
+ <span>May this demo send <b>anonymous usage events</b> \u2014 which buttons you press on this page \u2014 to
7898
+ Google Analytics? It helps us see what people actually try. Your bookings, your messages to the
7899
+ assistant and anything you type are never included, and nothing is sent unless you say yes.</span>
7900
+ <span class="sp"></span>
7901
+ <button class="act" id="consent-no">No thanks</button>
7902
+ <button class="act primary" id="consent-yes">Allow</button>
7903
+ </div>
7904
+
7864
7905
  <script src="/apiblaze-widgets.js"></script>
7865
7906
  <script>
7866
7907
  const $ = (s) => document.querySelector(s);
@@ -7884,10 +7925,49 @@ function reloadWith(next) {
7884
7925
  const p = new URLSearchParams({ pizzeria: next.pizzeria ?? state.pizzeria, as: next.as ?? $('#as').value, tab: next.tab ?? state.tab });
7885
7926
  location.assign(\`\${location.pathname}?\${p}\`);
7886
7927
  }
7928
+ // \u2500\u2500 measurement (opt-in; see the comment in <head>) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7929
+ // \`track()\` is a no-op until the visitor allows it, so every call site below can
7930
+ // be written as if measurement were always on.
7931
+ const GA_ID = 'G-M0KZHLJ0ZD';
7932
+ const CONSENT_KEY = 'abz_demo_consent';
7933
+ const doNotTrack = navigator.doNotTrack === '1' || window.doNotTrack === '1' || navigator.globalPrivacyControl === true;
7934
+ const consentRead = () => { try { return localStorage.getItem(CONSENT_KEY); } catch { return null; } };
7935
+ const consentWrite = (v) => { try { localStorage.setItem(CONSENT_KEY, v); } catch { /* private mode: the choice holds for this page */ } };
7936
+ let gaOn = false;
7937
+ function startGa() {
7938
+ if (gaOn) return;
7939
+ gaOn = true;
7940
+ gtag('js', new Date());
7941
+ gtag('config', GA_ID, { send_page_view: false });
7942
+ gtag('consent', 'update', { analytics_storage: 'granted' });
7943
+ const s = document.createElement('script');
7944
+ s.async = true;
7945
+ s.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_ID;
7946
+ document.head.append(s);
7947
+ }
7948
+ /**
7949
+ * One demo event. \`beacon\` transport because half of these fire on a button that
7950
+ * immediately navigates (the mode switch, the tabs) \u2014 a normal request would be
7951
+ * cancelled by the page unload and the event lost.
7952
+ */
7953
+ function track(name, params) {
7954
+ if (!gaOn) return;
7955
+ gtag('event', name, Object.assign(
7956
+ { app: 'resiresi-demo', mode: state.direct ? 'direct' : 'protected', pizzeria: state.pizzeria, transport_type: 'beacon' },
7957
+ params || {},
7958
+ ));
7959
+ }
7960
+
7887
7961
  let cfg = null;
7888
7962
  const ready = fetch('/api/config').then((r) => r.json()).then((c) => { cfg = c; return c; });
7889
7963
  const post = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) }).then((r) => r.json()).catch(() => ({ status: 0 }));
7890
- const act = (op, extra) => post('/api/act', { op, pizzeria: state.pizzeria, as: me(), ...extra });
7964
+ // When the PAGE last changed a booking. The poller below uses it to tell its own
7965
+ // work apart from the assistant's (see demo_assistant_booking).
7966
+ let lastSelfAct = 0;
7967
+ const act = (op, extra) => {
7968
+ if (op !== 'list') lastSelfAct = Date.now();
7969
+ return post('/api/act', { op, pizzeria: state.pizzeria, as: me(), ...extra });
7970
+ };
7891
7971
  const me = () => $('#as').value;
7892
7972
  const pz = () => (cfg?.pizzerias || []).find((p) => p.id === state.pizzeria) || { label: 'Nino Pizza', admin: 'Ana' };
7893
7973
  const anaBooking = {};
@@ -7899,6 +7979,7 @@ document.querySelectorAll('button.copy').forEach((b) => b.onclick = async () =>
7899
7979
  try { await navigator.clipboard.writeText(text); }
7900
7980
  catch { const r = document.createRange(); r.selectNodeContents(el); const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); document.execCommand('copy'); sel.removeAllRanges(); }
7901
7981
  b.textContent = 'Copied'; setTimeout(() => { b.textContent = 'Copy'; }, 1200);
7982
+ track('demo_copy', { what: (b.dataset.copy || '').replace('#', '') });
7902
7983
  });
7903
7984
 
7904
7985
  // \u2500\u2500 mode (which site this is), pizzeria, tab \u2500\u2500
@@ -7911,17 +7992,17 @@ function setMode(direct) {
7911
7992
  $('#foot-protected').hidden = direct; $('#foot-direct').hidden = !direct;
7912
7993
  }
7913
7994
  function goTo(url) { if (url) location.assign(url + location.search); }
7914
- $('#mode-protected').onclick = () => { if (state.direct) goTo(cfg.protectedUrl); };
7915
- $('#mode-direct').onclick = () => { if (!state.direct) goTo(cfg.directUrl); };
7995
+ $('#mode-protected').onclick = () => { if (state.direct) { track('demo_mode', { to: 'protected' }); goTo(cfg.protectedUrl); } };
7996
+ $('#mode-direct').onclick = () => { if (!state.direct) { track('demo_mode', { to: 'direct' }); goTo(cfg.directUrl); } };
7916
7997
  function setTab(tab) {
7917
7998
  state.tab = tab;
7918
7999
  $('#app').hidden = tab !== 'app'; $('#admin').hidden = tab !== 'admin';
7919
8000
  $('#tab-app').setAttribute('aria-pressed', tab === 'app'); $('#tab-admin').setAttribute('aria-pressed', tab === 'admin');
7920
8001
  }
7921
- $('#tab-app').onclick = () => { if (state.tab !== 'app') reloadWith({ tab: 'app' }); };
7922
- $('#tab-admin').onclick = () => { if (state.tab !== 'admin') reloadWith({ tab: 'admin' }); };
7923
- $('#go-admin').onclick = () => reloadWith({ tab: 'admin' });
7924
- function setPizzeria(id) { if (id !== state.pizzeria) reloadWith({ pizzeria: id }); }
8002
+ $('#tab-app').onclick = () => { if (state.tab !== 'app') { track('demo_tab', { to: 'app' }); reloadWith({ tab: 'app' }); } };
8003
+ $('#tab-admin').onclick = () => { if (state.tab !== 'admin') { track('demo_tab', { to: 'admin' }); reloadWith({ tab: 'admin' }); } };
8004
+ $('#go-admin').onclick = () => { track('demo_tab', { to: 'admin', from: 'footer-link' }); reloadWith({ tab: 'admin' }); };
8005
+ function setPizzeria(id) { if (id !== state.pizzeria) { track('demo_pizzeria', { to: id }); reloadWith({ pizzeria: id }); } }
7925
8006
 
7926
8007
  async function render() {
7927
8008
  await ready;
@@ -7956,7 +8037,7 @@ async function refresh() {
7956
8037
  const t = document.createElement('span');
7957
8038
  t.textContent = \`\${b.guests || 2} people \xB7 \${dayLabel(b.date)} \${b.time} \xB7 table \${b.table} \xB7 #\${b.id}\`;
7958
8039
  const x = document.createElement('button'); x.className = 'act'; x.textContent = 'Cancel';
7959
- x.onclick = async () => { await act('cancel', { id: b.id }); refresh(); };
8040
+ x.onclick = async () => { track('demo_cancel'); await act('cancel', { id: b.id }); refresh(); };
7960
8041
  li.append(t, x); ul.append(li);
7961
8042
  }
7962
8043
  $('#try-ben').hidden = !(me() === 'ben' && anaBooking[state.pizzeria]);
@@ -7965,11 +8046,15 @@ $('#book').onclick = async () => {
7965
8046
  const guests = Math.max(1, Number($('#guests').value) || 2);
7966
8047
  const date = $('#date').value || localDay();
7967
8048
  const time = $('#time').value || '19:30';
7968
- await act('book', { body: { guests, date, time } }); // the restaurant picks the table
8049
+ const r = await act('book', { body: { guests, date, time } }); // the restaurant picks the table
8050
+ track('demo_book', { guests, status: r.status || 0 });
7969
8051
  refresh();
7970
8052
  };
7971
8053
  $('#ben-cancel').onclick = async () => {
8054
+ lastSelfAct = Date.now();
7972
8055
  const r = await post('/api/act', { op: 'cancel', pizzeria: state.pizzeria, as: 'ben', id: anaBooking[state.pizzeria] });
8056
+ // The point of the whole demo: protected refuses Ben, direct lets him through.
8057
+ track('demo_ownership_check', { blocked: r.status === 403, status: r.status || 0 });
7973
8058
  const d = document.createElement('div');
7974
8059
  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."; }
7975
8060
  else if (r.status === 204) { d.className = 'out bad'; d.textContent = "Cancelled. Straight to localhost, nothing stopped Ben."; anaBooking[state.pizzeria] = null; }
@@ -7985,7 +8070,13 @@ setInterval(async () => {
7985
8070
  if (state.tab !== 'app' || document.hidden) return;
7986
8071
  const c = await fetch('/api/changes').then((x) => x.json()).catch(() => null);
7987
8072
  if (!c) return;
7988
- if (lastChange !== null && c.n !== lastChange) refresh();
8073
+ if (lastChange !== null && c.n !== lastChange) {
8074
+ // A booking moved that this page did not move. On this demo the only other
8075
+ // actor is the AI assistant, so that is what we count \u2014 with a 5s window so a
8076
+ // click of our own that is still settling is never miscounted as the assistant.
8077
+ if (Date.now() - lastSelfAct > 5000) track('demo_assistant_booking');
8078
+ refresh();
8079
+ }
7989
8080
  lastChange = c.n;
7990
8081
  }, 2000);
7991
8082
 
@@ -8018,7 +8109,25 @@ function mountAdmin() {
8018
8109
  if (cfg.widgets.groups) W.groups('#groups', { endpoint: '/api/apiblaze/groups?pizzeria=' + state.pizzeria, theme, title: \`People and groups \xB7 \${pz().label}\`, defaultOpenAdmins: true });
8019
8110
  }
8020
8111
 
8112
+ for (const id of ['#portal-top', '#portal']) {
8113
+ $(id).addEventListener('click', () => track('demo_portal_open', { from: id.replace('#', '') }));
8114
+ }
8115
+
8021
8116
  ready.then((c) => {
8117
+ // Ask once, and only when there is something to ask about. A visitor who has
8118
+ // already answered is never asked again; one who sends Do Not Track, or who
8119
+ // started the demo with APIBLAZE_DEMO_ANALYTICS=0, is not asked at all.
8120
+ if (c.analytics !== false && !doNotTrack) {
8121
+ const choice = consentRead();
8122
+ const begin = () => { startGa(); track('demo_open', { tab: state.tab }); };
8123
+ if (choice === 'granted') begin();
8124
+ else if (choice !== 'denied') {
8125
+ $('#consent').hidden = false;
8126
+ $('#consent-yes').onclick = () => { consentWrite('granted'); $('#consent').hidden = true; begin(); };
8127
+ $('#consent-no').onclick = () => { consentWrite('denied'); $('#consent').hidden = true; };
8128
+ }
8129
+ }
8130
+
8022
8131
  $('#api').textContent = c.apiUrl || 'not connected';
8023
8132
  setMode(!!c.direct);
8024
8133
  if (c.direct && !c.protectedUrl) { $('#mode-protected').disabled = true; $('#mode-protected').querySelector('small').textContent = 'starts once APIblaze is set up'; }
@@ -10604,7 +10713,7 @@ async function runCheck(project, opts) {
10604
10713
  }
10605
10714
  const name = project;
10606
10715
  if (!name) throw new Error(`Which ${B.thingShort}? ${B.cli} check --directory-ready <project>`);
10607
- const platforms = parsePolicy(opts.policy, opts.platform);
10716
+ const platforms = parseCompliant(opts.compliant ?? opts.policy, opts.platform);
10608
10717
  const stage = opts.stage === "create" ? "create" : opts.stage === "ready" || !opts.stage ? "ready" : void 0;
10609
10718
  if (!stage) throw new Error("--stage must be create or ready");
10610
10719
  const caller = requireCaller();
@@ -11123,7 +11232,7 @@ function seedMessage(proj2) {
11123
11232
  async function runConnector(target2, opts) {
11124
11233
  const caller = requireCaller();
11125
11234
  const { teamId } = await resolveActingTeam(caller, opts.team);
11126
- const platforms = parsePolicy(opts.policy, opts.platform);
11235
+ const platforms = parseCompliant(opts.compliant ?? opts.policy, opts.platform);
11127
11236
  const json = !!opts.json;
11128
11237
  const interactive2 = !!process.stdin.isTTY && !json && !opts.yes;
11129
11238
  const say = (s) => {
@@ -11221,7 +11330,12 @@ async function runConnector(target2, opts) {
11221
11330
  process.stdout.write(JSON.stringify({ ready: true, project: proj2.projectId, headline: r.rendered.headline, report: r.report, packet: { markdown: mdPath, json: jsonPath }, portals: PORTALS, ...reviewer ? { reviewer } : {} }) + "\n");
11222
11331
  return;
11223
11332
  }
11224
- printReport(r, false);
11333
+ if (r.rendered.compliance) {
11334
+ console.log();
11335
+ console.log(r.rendered.compliance.split("\n").map((l, i) => i === 0 ? import_chalk28.default.bold(l) : l.replace(/^(\s+)✓ /, (_m, sp2) => `${sp2}${import_chalk28.default.green("\u2713")} `).replace(/^(\s+)! /, (_m, sp2) => `${sp2}${import_chalk28.default.yellow("!")} `)).join("\n"));
11336
+ } else {
11337
+ printReport(r, false);
11338
+ }
11225
11339
  console.log();
11226
11340
  console.log(import_chalk28.default.green.bold(` ${r.rendered.headline}`));
11227
11341
  console.log(` Submission packet: ${import_chalk28.default.cyan(mdPath)} and ${import_chalk28.default.cyan(jsonPath)}`);
@@ -11730,7 +11844,7 @@ ${steps.map((t, i) => `${i + 1}. ${t}`).join("\n")}
11730
11844
  })() : "";
11731
11845
  const frontMatter = found ? "" : `---
11732
11846
  name: ${SKILL_NAME}
11733
- description: Use when the user asks what ${P} (or ${B.cli}) does or can do for them; says yes to a demo or asks to see / try / demo it; wants to ship, share or expose an API; wants API keys, sign-in, per-user data, rate limits or quotas; wants AI agents to use their app safely; or says users can reach each other's records.
11847
+ description: Use when the user asks what ${P} (or ${B.cli}) does or can do for them; says yes to a demo or asks to see / try / demo it; wants to ship, share or expose an API; wants API keys, sign-in, per-user data, rate limits or quotas; wants AI agents to use their app safely; wants a Claude connector or a ChatGPT plugin, or their API listed in the Claude or ChatGPT directory; or says users can reach each other's records.
11734
11848
  ---
11735
11849
 
11736
11850
  `;
@@ -11783,12 +11897,19 @@ ${blockedMarkdown()}
11783
11897
  On this machine \u2192 \`${run2} dev\`; deployed \u2192 \`${run2} create\`. \`${run2} skills ship\` has the
11784
11898
  whole procedure.
11785
11899
 
11900
+ ## A Claude connector or a ChatGPT plugin
11901
+
11902
+ \`${run2} create --compliant --target https://yourdomain.com/openapi.yaml --domain mcp.yourdomain.com\`
11903
+ makes the ${B.thingShort} a Claude connector that meets the Claude directory's policies; \`--compliant chatgpt\`
11904
+ makes a ChatGPT plugin to OpenAI's policies, \`--compliant all\` does both. It ends with a table of
11905
+ every requirement and why it is met, and the submission packet. \`${run2} skills connector\` has the page.
11906
+
11786
11907
  ## One thing at a time
11787
11908
 
11788
11909
  Each topic has its own page: \`${run2} skills keys\` (API keys) \xB7 \`signin\` (OAuth sign-in and the
11789
11910
  developer portal) \xB7 \`rules\` (who can change what) \xB7 \`limits\` (rate limits and quotas) \xB7 \`mcp\`
11790
- (AI agents) \xB7 \`widgets\` (keys, users and chat on their site) \xB7 \`ship\` (the whole procedure for
11791
- their own app).
11911
+ (AI agents) \xB7 \`widgets\` (keys, users and chat on their site) \xB7 \`connector\` (a compliant Claude
11912
+ connector or ChatGPT plugin) \xB7 \`ship\` (the whole procedure for their own app).
11792
11913
  `;
11793
11914
  }
11794
11915
  function shipMarkdown() {
@@ -12007,9 +12128,9 @@ the header are not owned by ana@example.com's key.
12007
12128
 
12008
12129
  ## Listing it in the Claude or ChatGPT directory
12009
12130
 
12010
- \`npx ${cli} create --policy --target https://yourdomain.com/openapi.yaml --domain mcp.yourdomain.com\` \u2014 directory-ready
12011
- in one command, to the letter of the Claude directory's policies (\`--policy chatgpt\` or \`--policy all\` for OpenAI's too;
12012
- \`npx ${cli} connector <name> --policy \u2026\` runs the same flow on an existing ${B.thingShort}): it creates the ${B.thingShort} from the spec, checks what the
12131
+ \`npx ${cli} create --compliant --target https://yourdomain.com/openapi.yaml --domain mcp.yourdomain.com\` \u2014 directory-ready
12132
+ in one command, to the letter of the Claude directory's policies (\`--compliant chatgpt\` or \`--compliant all\` for OpenAI's too;
12133
+ \`npx ${cli} connector <name> --compliant \u2026\` runs the same flow on an existing ${B.thingShort}): it creates the ${B.thingShort} from the spec, checks what the
12013
12134
  directories require, applies the safe fixes, registers the MCP hostname and waits for its ONE CNAME
12014
12135
  (\u2192 proxy.abz.run), hides utility endpoints and fills thin tool descriptions, asks for the rest with
12015
12136
  every answer pre-filled from the spec and cut to the portal's limits (Enter accepts), creates the reviewer account, and writes the
@@ -12031,7 +12152,7 @@ ${heading}`);
12031
12152
  const next = md.indexOf("\n## ", at + heading.length + 1);
12032
12153
  return md.slice(at + 1, next < 0 ? void 0 : next).trim();
12033
12154
  }
12034
- var TOPICS = ["ship", "keys", "signin", "rules", "limits", "mcp", "widgets"];
12155
+ var TOPICS = ["ship", "keys", "signin", "rules", "limits", "mcp", "widgets", "connector"];
12035
12156
  function topicMarkdown(topic) {
12036
12157
  const P = B.product;
12037
12158
  const cli = B.cli;
@@ -12101,6 +12222,27 @@ of 3,000 a day. Say so in those words (rate limit, quota).
12101
12222
  ${sectionOf(legacy, "## No shell? The control-plane MCP")}`);
12102
12223
  case "widgets":
12103
12224
  return page(`Keys, users and chat on their site`, sectionOf(legacy, "## Wiring a real site").replace(/^## Wiring a real site\n+/, ""));
12225
+ case "connector":
12226
+ return page(`A compliant Claude connector or ChatGPT plugin with ${P}`, `
12227
+ A connector (Claude) or a plugin (ChatGPT) is the ${B.thingShort}'s MCP server, listed in that
12228
+ directory. Each directory has its own policies: a custom MCP hostname, OAuth sign-in with dynamic client
12229
+ registration, tool descriptions and listing texts within its limits, a privacy policy, a reviewer account,
12230
+ and attestations. \`--compliant\` makes the ${B.thingShort} meet them to the letter.
12231
+
12232
+ - Which directory: \`--compliant\` alone is Claude. \`--compliant chatgpt\` is OpenAI's, \`--compliant all\` is both.
12233
+ - From an OpenAPI URL: \`npx ${cli} create --compliant --target https://yourdomain.com/openapi.yaml --domain mcp.yourdomain.com\`.
12234
+ On an existing ${B.thingShort}: \`npx ${cli} connector <name> --compliant --domain mcp.yourdomain.com\`.
12235
+ - It asks for the listing answers with every field pre-filled from the spec and cut to the portal's limits
12236
+ (Enter accepts), and waits for ONE CNAME (mcp.yourdomain.com \u2192 proxy.abz.run) \u2014 the user adds it at their DNS
12237
+ provider; relay the exact record it prints. For ChatGPT it also asks for the challenge token the OpenAI portal
12238
+ issues for the domain (\`--openai-challenge <token>\`).
12239
+ - When every check passes it prints the compliance table (one row per requirement: met or not, why, and the
12240
+ policy page it comes from) and writes the submission packet. The user submits the packet to Anthropic or
12241
+ OpenAI themselves; listing decisions are theirs.
12242
+ - \`npx ${cli} check --directory-ready <name> --compliant\` is the report alone, with every blocker and its fix.
12243
+ Exit 1 while a blocker remains. Say "meets the requirements ${P} can verify", never "certified".
12244
+
12245
+ ${sectionOf(legacy, "## Listing it in the Claude or ChatGPT directory")}`);
12104
12246
  default:
12105
12247
  return null;
12106
12248
  }
@@ -12752,6 +12894,9 @@ async function runAuthz(projectArg, apiVersionArg) {
12752
12894
  if (!match) {
12753
12895
  throw new Error(`Project "${projectArg}" not found in your active team (${creds.teamName ?? teamId}). Run \`apiblaze projects\`.`);
12754
12896
  }
12897
+ if (match.configMissing) {
12898
+ throw new Error(`Project "${projectArg}": its settings could not be loaded, so its tenant is unknown and no rule can be published to it. Open it in the dashboard (Refresh, or delete and re-create it) first.`);
12899
+ }
12755
12900
  const projectId = match.projectId;
12756
12901
  const apiVersion = apiVersionArg || match.apiVersion;
12757
12902
  const tenant2 = match.tenant || projectId;
@@ -12813,6 +12958,9 @@ async function runRule(projectArg, ruleText, opts) {
12813
12958
  if (!match) {
12814
12959
  throw new Error(`Project "${projectArg}" not found in your active team (${creds.teamName ?? teamId}). Run \`${B.cli} projects\`.`);
12815
12960
  }
12961
+ if (match.configMissing) {
12962
+ throw new Error(`Project "${projectArg}": its settings could not be loaded, so its tenant is unknown and no rule can be published to it. Open it in the dashboard (Refresh, or delete and re-create it) first.`);
12963
+ }
12816
12964
  const projectId = match.projectId;
12817
12965
  const apiVersion = opts.apiversion || match.apiVersion;
12818
12966
  const tenant2 = match.tenant || projectId;
@@ -14068,7 +14216,10 @@ function declineAll(items) {
14068
14216
  console.log(import_chalk48.default.yellow(` --yes: installing without ${skippable.length} setting${skippable.length === 1 ? "" : "s"} that need consent.`));
14069
14217
  for (const i of skippable) console.log(import_chalk48.default.dim(` skipped ${i.kind} ${i.value}`));
14070
14218
  }
14071
- return new Set(skippable.map((i) => i.kind));
14219
+ return new Set(skippable.map(declineKey));
14220
+ }
14221
+ function declineKey(item) {
14222
+ return item.kind === "transform" ? `transform:${item.value}` : `kind:${item.kind}`;
14072
14223
  }
14073
14224
  async function confirmYesNo(message, interactive2) {
14074
14225
  if (!interactive2) {
@@ -14101,7 +14252,7 @@ async function askConsent(items, interactive2, reinstalling) {
14101
14252
  }
14102
14253
  if (typed.toLowerCase() === "yes") return declined;
14103
14254
  for (const item of items) {
14104
- declined.add(item.kind === "transform" ? `transform:${item.value}` : `kind:${item.kind}`);
14255
+ declined.add(declineKey(item));
14105
14256
  }
14106
14257
  console.log(import_chalk48.default.dim(` Installing without ${count} setting${count === 1 ? "" : "s"} \u2014 everything else is applied.`));
14107
14258
  return declined;
@@ -20064,7 +20215,7 @@ init_auth();
20064
20215
  init_brand();
20065
20216
  installBrandFilter();
20066
20217
  var program = new import_commander.Command();
20067
- program.name(B.cli).description(B.cli === "mcpblaze" ? "MCPblaze CLI \u2014 turn any API into an MCP server your agents can use, with sign-in and per-user rules" : "APIblaze CLI \u2014 create & manage API proxies and run dev tunnels").version(version).option("-v, --verbose", "Print the exact series of API calls each command makes (curl-equivalent you could run yourself)");
20218
+ program.name(B.cli).description(B.cli === "mcpblaze" ? "MCPblaze CLI \u2014 turn any API into an MCP server your agents can use, with sign-in and per-user rules. New: `create --compliant` builds a Claude connector or a ChatGPT plugin that meets the directory policies, and prints the table of why." : "APIblaze CLI \u2014 create & manage API proxies and run dev tunnels. New: `create --compliant` builds a Claude connector or a ChatGPT plugin that meets the directory policies, and prints the table of why.").version(version).option("-v, --verbose", "Print the exact series of API calls each command makes (curl-equivalent you could run yourself)");
20068
20219
  program.hook("preAction", (_thisCommand, actionCommand) => {
20069
20220
  if (program.opts().verbose) setVerbose(true);
20070
20221
  const chain = [];
@@ -20094,13 +20245,14 @@ program.command("login").description(`Authenticate with ${B.product}`).option("-
20094
20245
  process.exit(1);
20095
20246
  }
20096
20247
  });
20097
- 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)").option("--policy [directories...]", `Directory-ready in one command, to the letter of that directory's policies: create the ${B.thingShort} from --target (an OpenAPI URL/file), apply the safe fixes, set up the custom MCP domain (--domain, one CNAME), ask for every listing answer with the spec's proposal pre-filled (cut to the portal's limits), create the reviewer account, write the submission packet. Bare = claude; or claude \xB7 chatgpt \xB7 all. Same as \`${B.cli} connector <spec> --policy \u2026\`.`).option("--domain <host>", "With --policy: the MCP server's custom hostname (mcp.<your domain>); asked for when missing, suggested from the spec").option("--wait <minutes>", "With --policy: how long to wait for the custom domain to activate once its CNAME is in place (default 15; 0 = do not wait)").option("--directory-ready", "Make it directory-ready for the Claude and ChatGPT directories: OAuth door, custom-domain checks, listing fields; the readiness report runs after the create and a blocker exits 1").option("--privacy-url <url>", "Directory listing: public https privacy policy").option("--support <email|url>", "Directory listing: support contact").option("--docs-url <url>", "Directory listing: public documentation URL (the developer portal by default)").option("--prompt <text>", "Directory listing: an example prompt (repeat the flag; three are needed)", collect, []).option("--server-name <name>", 'Directory listing: the name (2\u201340 chars, no "MCP"/"Plugin" suffix)').option("--tagline <text>", "Directory listing: one-line tagline").option("--description <text>", "Directory listing: description").option("--icon-url <url>", "Directory listing: square icon (https)").option("--vuln-contact <email|url>", "Directory listing: vulnerability-disclosure contact").option("--attest <id>", "Attest (repeatable): guidelines \xB7 own-backend \xB7 no-financial \xB7 no-genai-media \xB7 no-prompt-injection \xB7 no-conversation-data \xB7 public-docs (Claude's seven) \xB7 no-digital-goods \xB7 content-13-no-ads \xB7 no-restricted-data (ChatGPT)", collect, []).option("--allowed-origin <origin>", "Directory mode: an extra browser origin allowed on the MCP host (repeatable)", collect, []).option("--openai-challenge <token>", "ChatGPT: the domain-verification token the OpenAI portal shows under Plugins \u2192 Connect MCP server").action(async (opts) => {
20248
+ 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\`.) Add --compliant to make it a Claude connector, or a ChatGPT plugin with --compliant chatgpt, that meets the directory's policies.`).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)").option("--compliant [directories...]", `Directory-ready in one command, to the letter of that directory's policies: create the ${B.thingShort} from --target (an OpenAPI URL/file), apply the safe fixes, set up the custom MCP domain (--domain, one CNAME), ask for every listing answer with the spec's proposal pre-filled (cut to the portal's limits), create the reviewer account, write the submission packet. Bare = claude; or claude \xB7 chatgpt \xB7 all. Ends with the compliance table: every rule the listing satisfies and the policy page it comes from. Same as \`${B.cli} connector <spec> --compliant \u2026\`.`).addOption(new import_commander.Option("--policy [directories...]", "Older spelling of --compliant").hideHelp()).option("--domain <host>", "With --compliant: the MCP server's custom hostname (mcp.<your domain>); asked for when missing, suggested from the spec").option("--wait <minutes>", "With --compliant: how long to wait for the custom domain to activate once its CNAME is in place (default 15; 0 = do not wait)").option("--directory-ready", "Make it directory-ready for the Claude and ChatGPT directories: OAuth door, custom-domain checks, listing fields; the readiness report runs after the create and a blocker exits 1").option("--privacy-url <url>", "Directory listing: public https privacy policy").option("--support <email|url>", "Directory listing: support contact").option("--docs-url <url>", "Directory listing: public documentation URL (the developer portal by default)").option("--prompt <text>", "Directory listing: an example prompt (repeat the flag; three are needed)", collect, []).option("--server-name <name>", 'Directory listing: the name (2\u201340 chars, no "MCP"/"Plugin" suffix)').option("--tagline <text>", "Directory listing: one-line tagline").option("--description <text>", "Directory listing: description").option("--icon-url <url>", "Directory listing: square icon (https)").option("--vuln-contact <email|url>", "Directory listing: vulnerability-disclosure contact").option("--attest <id>", "Attest (repeatable): guidelines \xB7 own-backend \xB7 no-financial \xB7 no-genai-media \xB7 no-prompt-injection \xB7 no-conversation-data \xB7 public-docs (Claude's seven) \xB7 no-digital-goods \xB7 content-13-no-ads \xB7 no-restricted-data (ChatGPT)", collect, []).option("--allowed-origin <origin>", "Directory mode: an extra browser origin allowed on the MCP host (repeatable)", collect, []).option("--openai-challenge <token>", "ChatGPT: the domain-verification token the OpenAI portal shows under Plugins \u2192 Connect MCP server").action(async (opts) => {
20098
20249
  try {
20099
- if (opts.policy !== void 0 && opts.policy !== false) {
20250
+ const compliant = opts.compliant ?? opts.policy;
20251
+ if (compliant !== void 0 && compliant !== false) {
20100
20252
  const spec2 = opts.target ?? opts.openapi ?? opts.openapispec;
20101
- if (!spec2) throw new Error(`--policy needs the OpenAPI document: ${B.cli} create --policy --target https://yourdomain.com/openapi.yaml --domain mcp.yourdomain.com`);
20253
+ if (!spec2) throw new Error(`--compliant needs the OpenAPI document: ${B.cli} create --compliant --target https://yourdomain.com/openapi.yaml --domain mcp.yourdomain.com`);
20102
20254
  const { auth: _auth, target: _target, openapi: _openapi, openapispec: _spec, directoryReady: _dr, ...rest } = opts;
20103
- await runConnector(spec2, { ...rest, full: true });
20255
+ await runConnector(spec2, { ...rest, compliant, full: true });
20104
20256
  return;
20105
20257
  }
20106
20258
  await runCreate({ ...opts, openapi: opts.openapi ?? opts.openapispec });
@@ -20112,8 +20264,8 @@ program.command("create").description(`Put an app that is already DEPLOYED behin
20112
20264
  function collect(value, previous) {
20113
20265
  return [...previous ?? [], value];
20114
20266
  }
20115
- program.command("check").description(`Readiness report for the Claude and ChatGPT directories (--directory-ready): what passes, every blocker with its fix, and the ${B.thingShort}'s published URL. Exit 1 while a blocker remains.`).argument("[project]", `The ${B.thingShort} (project name)`).option("--directory-ready", "The directory readiness checks (the only mode today)").option("--policy <directory>", "Which directory's policies to check against: claude \xB7 chatgpt \xB7 all (default claude; repeat or comma-separate)", collect, []).addOption(new import_commander.Option("--platform <list>", "Older spelling of --policy").hideHelp()).option("--stage <stage>", "create (relaxed: docs URL is a warning) \xB7 ready (the submission gate, default)").option("--refresh-snapshot", "Re-take the listing snapshot (after you edited the listing in the portals)").option("--no-probe", "Skip the live probes from this machine (report what the server can see)").option("--team <id|name>", "Team the project is in (defaults to your active team)").option("--apiversion <version>", "API version (defaults to the project's)").option("--json", "Print the raw report JSON (report, rendered text, target)").action(action((project, opts) => runCheck(project, { ...opts, noProbe: opts.probe === false })));
20116
- var connector = program.command("connector").description(`Directory-ready in one command: from a project or straight from an OpenAPI URL \u2014 creates the ${B.thingShort} if needed, applies the safe fixes, sets up the custom MCP domain (one CNAME), pre-fills every listing answer from the spec, creates the reviewer account, and writes the submission packet when every check passes (you submit it to Anthropic and OpenAI yourself)`).argument("[project-or-spec]", `The ${B.thingShort} (project name), or an OpenAPI URL/file to create a directory-ready ${B.thingShort} from first`).option("--domain <host>", "Custom hostname for the MCP server (mcp.<your domain>); asked for when missing, suggested from the spec").option("--wait <minutes>", "How long to wait for the custom domain to activate once its CNAME is in place (default 15; 0 = do not wait)").option("--name <name>", `When creating from a spec: the ${B.thingShort} name (default: derived from the spec host)`).option("--privacy-url <url>", "Public https privacy policy").option("--support <email|url>", "Support contact").option("--docs-url <url>", "Public documentation URL").option("--prompt <text>", "An example prompt (repeatable; three are needed)", collect, []).option("--server-name <name>", 'Listing name (2\u201340 chars, no "MCP"/"Plugin" suffix)').option("--tagline <text>", "One-line tagline").option("--description <text>", "Description").option("--icon-url <url>", "Square icon (https)").option("--vuln-contact <email|url>", "Vulnerability-disclosure contact").option("--attest <id>", "Attest (repeatable): guidelines \xB7 own-backend \xB7 no-financial \xB7 no-genai-media \xB7 no-prompt-injection \xB7 no-conversation-data \xB7 public-docs (Claude's seven) \xB7 no-digital-goods \xB7 content-13-no-ads \xB7 no-restricted-data (ChatGPT). Alone, this only records the attestation.", collect, []).option("--allowed-origin <origin>", "An extra browser origin allowed on the MCP host (repeatable)", collect, []).option("--openai-challenge <token>", "ChatGPT: the domain-verification token the OpenAI portal shows under Plugins \u2192 Connect MCP server; served verbatim at /.well-known/openai-apps-challenge on your MCP domain").option("--full", "Run the full flow even when only --attest was given").option("--policy <directory>", "Which directory's policies this run must satisfy: claude \xB7 chatgpt \xB7 all (default claude; repeat or comma-separate)", collect, []).addOption(new import_commander.Option("--platform <list>", "Older spelling of --policy").hideHelp()).option("--out <dir>", "Where to write connector-packet-<project>.md/.json (default: current directory)").option("-y, --yes", "No questions: apply the flags, report, exit 1 on a blocker").option("--team <id|name>", "Team the project is in (defaults to your active team)").option("--apiversion <version>", "API version (defaults to the project's)").option("--json", "Output machine-readable JSON").action(action(async (project, opts) => {
20267
+ program.command("check").description(`Readiness report for the Claude and ChatGPT directories (--directory-ready): what passes, every blocker with its fix, and the ${B.thingShort}'s published URL. Exit 1 while a blocker remains.`).argument("[project]", `The ${B.thingShort} (project name)`).option("--directory-ready", "The directory readiness checks (the only mode today)").option("--compliant [directory]", "Which directory's policies to check against: bare = claude; or claude \xB7 chatgpt \xB7 all (repeat or comma-separate)", collect, []).addOption(new import_commander.Option("--policy <directory>", "Older spelling of --compliant").hideHelp()).addOption(new import_commander.Option("--platform <list>", "Older spelling of --compliant").hideHelp()).option("--stage <stage>", "create (relaxed: docs URL is a warning) \xB7 ready (the submission gate, default)").option("--refresh-snapshot", "Re-take the listing snapshot (after you edited the listing in the portals)").option("--no-probe", "Skip the live probes from this machine (report what the server can see)").option("--team <id|name>", "Team the project is in (defaults to your active team)").option("--apiversion <version>", "API version (defaults to the project's)").option("--json", "Print the raw report JSON (report, rendered text, target)").action(action((project, opts) => runCheck(project, { ...opts, noProbe: opts.probe === false })));
20268
+ var connector = program.command("connector").description(`A compliant Claude connector (--compliant chatgpt: a ChatGPT plugin), directory-ready in one command: from a project or straight from an OpenAPI URL \u2014 creates the ${B.thingShort} if needed, applies the safe fixes, sets up the custom MCP domain (one CNAME), pre-fills every listing answer from the spec, creates the reviewer account, and writes the submission packet when every check passes (you submit it to Anthropic and OpenAI yourself)`).argument("[project-or-spec]", `The ${B.thingShort} (project name), or an OpenAPI URL/file to create a directory-ready ${B.thingShort} from first`).option("--domain <host>", "Custom hostname for the MCP server (mcp.<your domain>); asked for when missing, suggested from the spec").option("--wait <minutes>", "How long to wait for the custom domain to activate once its CNAME is in place (default 15; 0 = do not wait)").option("--name <name>", `When creating from a spec: the ${B.thingShort} name (default: derived from the spec host)`).option("--privacy-url <url>", "Public https privacy policy").option("--support <email|url>", "Support contact").option("--docs-url <url>", "Public documentation URL").option("--prompt <text>", "An example prompt (repeatable; three are needed)", collect, []).option("--server-name <name>", 'Listing name (2\u201340 chars, no "MCP"/"Plugin" suffix)').option("--tagline <text>", "One-line tagline").option("--description <text>", "Description").option("--icon-url <url>", "Square icon (https)").option("--vuln-contact <email|url>", "Vulnerability-disclosure contact").option("--attest <id>", "Attest (repeatable): guidelines \xB7 own-backend \xB7 no-financial \xB7 no-genai-media \xB7 no-prompt-injection \xB7 no-conversation-data \xB7 public-docs (Claude's seven) \xB7 no-digital-goods \xB7 content-13-no-ads \xB7 no-restricted-data (ChatGPT). Alone, this only records the attestation.", collect, []).option("--allowed-origin <origin>", "An extra browser origin allowed on the MCP host (repeatable)", collect, []).option("--openai-challenge <token>", "ChatGPT: the domain-verification token the OpenAI portal shows under Plugins \u2192 Connect MCP server; served verbatim at /.well-known/openai-apps-challenge on your MCP domain").option("--full", "Run the full flow even when only --attest was given").option("--compliant [directory]", "Which directory's policies this run must satisfy: bare = claude; or claude \xB7 chatgpt \xB7 all (repeat or comma-separate)", collect, []).addOption(new import_commander.Option("--policy <directory>", "Older spelling of --compliant").hideHelp()).addOption(new import_commander.Option("--platform <list>", "Older spelling of --compliant").hideHelp()).option("--out <dir>", "Where to write connector-packet-<project>.md/.json (default: current directory)").option("-y, --yes", "No questions: apply the flags, report, exit 1 on a blocker").option("--team <id|name>", "Team the project is in (defaults to your active team)").option("--apiversion <version>", "API version (defaults to the project's)").option("--json", "Output machine-readable JSON").action(action(async (project, opts) => {
20117
20269
  if (!project) throw new Error(`Which ${B.thingShort}? ${B.cli} connector <project> \xB7 ${B.cli} connector https://\u2026/openapi.yaml \xB7 ${B.cli} connector reviewer <project>`);
20118
20270
  await runConnector(project, opts);
20119
20271
  }));
@@ -20129,7 +20281,7 @@ program.command("demo").description(`See what ${B.product} does in a minute: a s
20129
20281
  }
20130
20282
  await runDemo(opts);
20131
20283
  });
20132
- program.command("skill").alias("skills").description(`What ${B.product} does, for you or your AI assistant: checks this folder for a backend and offers the next step (put it on the internet, or a sample app)`).argument("[page]", "One page instead: ship \xB7 keys \xB7 signin \xB7 rules \xB7 limits \xB7 mcp \xB7 widgets").option("--agent", "Print what an AI assistant reads, even at a terminal").option("--static", "Print the installed text, with no folder check (for generated docs)").option("--install", "Write it to .claude/skills/ship-safely/SKILL.md in the current project (instead of printing it)").option("--force", "Overwrite an existing SKILL.md").option("--ship", "Same as the ship page: the steps for putting it in front of your own app").action(action((page, opts) => runSkill({ ...opts, topic: page })));
20284
+ program.command("skill").alias("skills").description(`What ${B.product} does, for you or your AI assistant: checks this folder for a backend and offers the next step (put it on the internet, or a sample app)`).argument("[page]", "One page instead: ship \xB7 keys \xB7 signin \xB7 rules \xB7 limits \xB7 mcp \xB7 widgets \xB7 connector (a compliant Claude connector or ChatGPT plugin)").option("--agent", "Print what an AI assistant reads, even at a terminal").option("--static", "Print the installed text, with no folder check (for generated docs)").option("--install", "Write it to .claude/skills/ship-safely/SKILL.md in the current project (instead of printing it)").option("--force", "Overwrite an existing SKILL.md").option("--ship", "Same as the ship page: the steps for putting it in front of your own app").action(action((page, opts) => runSkill({ ...opts, topic: page })));
20133
20285
  var agent = program.command("agent").description("Chat with an assistant that builds and runs your APIs (billed per turn)").option("--team <id|name>", "Team to work in (defaults to your active team)").option("--seed-for <project>", "Open with a first message that seeds realistic data through that proxy (for a directory reviewer account)").action(action((opts) => runAgent(opts)));
20134
20286
  agent.command("authz").description("Chat to design and turn on access rules for an API").argument("<project>", "Project name or id").argument("[apiVersion]", "API version (defaults to the project's)").action(action((project, apiVersion) => runAuthz(project, apiVersion)));
20135
20287
  program.command("rule").description("Lock resources to the person who created them \u2014 or describe any access rule in plain English (that one is billed per turn)").argument("<project>", "Project name or id").argument("[sentence]", 'Optional rule in plain English, e.g. "users see only their own rows". Omit to pick from what the spec can protect.').option("--enforce", "Turn enforcement on right after saving (default: save in shadow mode, then ask)").option("--apiversion <version>", "API version (defaults to the project's)").action(action((project, sentence, opts) => runRule(project, sentence, opts)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apiblaze",
3
- "version": "0.21.54",
3
+ "version": "0.21.58",
4
4
  "description": "APIblaze CLI — Chat with your APIs, Manage your API keys, users and groups with the APIblaze serverless proxy",
5
5
  "keywords": [
6
6
  "apiblaze",
@@ -65,7 +65,7 @@
65
65
  "typecheck": "tsc --noEmit",
66
66
  "prepublishOnly": "npm run build && npm run typecheck",
67
67
  "release": "bash release.sh",
68
- "test": "node test/tail-inspector.test.js"
68
+ "test": "node test/tail-inspector.test.js && node test/config-missing.test.js"
69
69
  },
70
70
  "dependencies": {
71
71
  "chalk": "^4.1.2",