apiblaze 0.21.56 → 0.21.59

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 +200 -26
  3. package/package.json +1 -1
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.56";
37
+ version = "0.21.59";
38
38
  }
39
39
  });
40
40
 
@@ -5302,6 +5302,7 @@ var init_directory_probes = __esm({
5302
5302
  var directory_exports = {};
5303
5303
  __export(directory_exports, {
5304
5304
  ATTESTATIONS: () => ATTESTATIONS,
5305
+ AUTH_MODES: () => AUTH_MODES,
5305
5306
  PLATFORMS: () => PLATFORMS,
5306
5307
  attestationId: () => attestationId,
5307
5308
  blockerLines: () => blockerLines,
@@ -5309,6 +5310,7 @@ __export(directory_exports, {
5309
5310
  fetchDirectoryReport: () => fetchDirectoryReport,
5310
5311
  isApiError: () => isApiError,
5311
5312
  isReady: () => isReady,
5313
+ parseAuthMode: () => parseAuthMode,
5312
5314
  parseCompliant: () => parseCompliant,
5313
5315
  parsePlatforms: () => parsePlatforms,
5314
5316
  printReport: () => printReport
@@ -5317,6 +5319,12 @@ function attestationId(flag) {
5317
5319
  const f = flag.trim().toLowerCase();
5318
5320
  return ATTESTATIONS.find((a) => a.id === f || a.flags.includes(f))?.id ?? null;
5319
5321
  }
5322
+ function parseAuthMode(raw) {
5323
+ if (raw === void 0) return void 0;
5324
+ const v = String(raw).trim().toLowerCase();
5325
+ if (AUTH_MODES.includes(v)) return v;
5326
+ throw new Error(`Unknown --auth-mode "${raw}". Use signin (OAuth sign-in, the default) or open (no sign-in: public, read-only data).`);
5327
+ }
5320
5328
  function directoryBodyFromFlags(f) {
5321
5329
  const m = {};
5322
5330
  if (f.privacyUrl) m.privacy_url = f.privacyUrl;
@@ -5329,6 +5337,8 @@ function directoryBodyFromFlags(f) {
5329
5337
  if (f.iconUrl) m.icon_url = f.iconUrl;
5330
5338
  if (f.vulnContact) m.vuln_contact = f.vulnContact;
5331
5339
  if (f.allowedOrigin?.length) m.allowed_origins = f.allowedOrigin.map((o) => o.trim()).filter(Boolean);
5340
+ const mode = parseAuthMode(f.authMode);
5341
+ if (mode) m.auth_mode = mode;
5332
5342
  const out = {};
5333
5343
  if (Object.keys(m).length) out.directory_metadata = m;
5334
5344
  if (f.openaiChallenge?.trim()) out.openai_challenge_token = f.openaiChallenge.trim();
@@ -5409,7 +5419,7 @@ function blockerLines(r, platforms) {
5409
5419
  function isApiError(e, status2) {
5410
5420
  return e instanceof ApiError && (status2 === void 0 || e.status === status2);
5411
5421
  }
5412
- var import_chalk17, PLATFORMS, ATTESTATIONS, reportPath;
5422
+ var import_chalk17, PLATFORMS, ATTESTATIONS, AUTH_MODES, reportPath;
5413
5423
  var init_directory = __esm({
5414
5424
  "src/lib/directory.ts"() {
5415
5425
  "use strict";
@@ -5432,6 +5442,7 @@ var init_directory = __esm({
5432
5442
  { id: "content_13_no_ads", flags: ["content-13-no-ads", "content-13", "content_13_no_ads"], label: "Content suitable for 13+ and no advertising (ChatGPT)", platforms: ["chatgpt"] },
5433
5443
  { id: "no_restricted_data", flags: ["no-restricted-data", "no_restricted_data"], label: "No card data, health records, government ids or credentials are collected (ChatGPT)", platforms: ["chatgpt"] }
5434
5444
  ];
5445
+ AUTH_MODES = ["signin", "open"];
5435
5446
  reportPath = (proj2) => `/projects/${encodeURIComponent(proj2.projectId)}/${encodeURIComponent(proj2.apiVersion)}/directory-report`;
5436
5447
  }
5437
5448
  });
@@ -7621,7 +7632,7 @@ var init_files = __esm({
7621
7632
  "src/demo-app/files.ts"() {
7622
7633
  "use strict";
7623
7634
  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",
7635
+ "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
7636
  "openapi.yaml": `openapi: 3.0.3
7626
7637
  info:
7627
7638
  title: ResiResi API
@@ -7717,7 +7728,7 @@ components:
7717
7728
  id: { type: string, example: r1 }
7718
7729
  `,
7719
7730
  "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",
7731
+ "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
7732
  "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
7733
  "public/index.html": `<!doctype html>
7723
7734
  <html lang="en">
@@ -7794,7 +7805,38 @@ components:
7794
7805
  .stack { display: grid; gap: 16px; }
7795
7806
  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
7807
  button.copy:hover { border-color: var(--muted); }
7808
+
7809
+ /* The measurement choice. One line, at the bottom, asked once. */
7810
+ .consent { position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; display: flex; flex-wrap: wrap; align-items: center;
7811
+ gap: 10px 16px; padding: 12px 16px; background: var(--card); border-top: 1px solid var(--line); font-size: 13.5px; color: var(--muted); }
7812
+ .consent b { color: var(--ink); }
7813
+ .consent .sp { flex: 1 1 auto; }
7797
7814
  </style>
7815
+
7816
+ <!--
7817
+ Google Analytics, opt-in.
7818
+
7819
+ This page runs on the visitor's OWN laptop (\`npx apiblaze@latest demo\`), which
7820
+ makes measurement a different question than it is on apiblaze.com. So:
7821
+ \xB7 every consent signal starts denied, right here, before anything else runs;
7822
+ \xB7 nothing is fetched from Google until the visitor presses Allow \u2014 no tag,
7823
+ no cookie, and no cookieless ping either;
7824
+ \xB7 Do Not Track (or Global Privacy Control) is read as "no" without asking;
7825
+ \xB7 \`APIBLAZE_DEMO_ANALYTICS=0\` when starting the demo removes the question
7826
+ entirely (app.js stops advertising measurement in /api/config).
7827
+
7828
+ Only \`demo_*\` EVENTS are sent, never a page view: a page view from here would
7829
+ land in the same property as apiblaze.com under the hostname "localhost" and
7830
+ quietly ruin every page report there.
7831
+ -->
7832
+ <script>
7833
+ window.dataLayer = window.dataLayer || [];
7834
+ function gtag() { dataLayer.push(arguments); }
7835
+ gtag('consent', 'default', {
7836
+ analytics_storage: 'denied', ad_storage: 'denied',
7837
+ ad_user_data: 'denied', ad_personalization: 'denied',
7838
+ });
7839
+ </script>
7798
7840
  </head>
7799
7841
  <body>
7800
7842
  <main>
@@ -7861,6 +7903,16 @@ components:
7861
7903
  <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
7904
  </section>
7863
7905
  </main>
7906
+
7907
+ <div class="consent" id="consent" hidden>
7908
+ <span>May this demo send <b>anonymous usage events</b> \u2014 which buttons you press on this page \u2014 to
7909
+ Google Analytics? It helps us see what people actually try. Your bookings, your messages to the
7910
+ assistant and anything you type are never included, and nothing is sent unless you say yes.</span>
7911
+ <span class="sp"></span>
7912
+ <button class="act" id="consent-no">No thanks</button>
7913
+ <button class="act primary" id="consent-yes">Allow</button>
7914
+ </div>
7915
+
7864
7916
  <script src="/apiblaze-widgets.js"></script>
7865
7917
  <script>
7866
7918
  const $ = (s) => document.querySelector(s);
@@ -7884,10 +7936,49 @@ function reloadWith(next) {
7884
7936
  const p = new URLSearchParams({ pizzeria: next.pizzeria ?? state.pizzeria, as: next.as ?? $('#as').value, tab: next.tab ?? state.tab });
7885
7937
  location.assign(\`\${location.pathname}?\${p}\`);
7886
7938
  }
7939
+ // \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
7940
+ // \`track()\` is a no-op until the visitor allows it, so every call site below can
7941
+ // be written as if measurement were always on.
7942
+ const GA_ID = 'G-M0KZHLJ0ZD';
7943
+ const CONSENT_KEY = 'abz_demo_consent';
7944
+ const doNotTrack = navigator.doNotTrack === '1' || window.doNotTrack === '1' || navigator.globalPrivacyControl === true;
7945
+ const consentRead = () => { try { return localStorage.getItem(CONSENT_KEY); } catch { return null; } };
7946
+ const consentWrite = (v) => { try { localStorage.setItem(CONSENT_KEY, v); } catch { /* private mode: the choice holds for this page */ } };
7947
+ let gaOn = false;
7948
+ function startGa() {
7949
+ if (gaOn) return;
7950
+ gaOn = true;
7951
+ gtag('js', new Date());
7952
+ gtag('config', GA_ID, { send_page_view: false });
7953
+ gtag('consent', 'update', { analytics_storage: 'granted' });
7954
+ const s = document.createElement('script');
7955
+ s.async = true;
7956
+ s.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_ID;
7957
+ document.head.append(s);
7958
+ }
7959
+ /**
7960
+ * One demo event. \`beacon\` transport because half of these fire on a button that
7961
+ * immediately navigates (the mode switch, the tabs) \u2014 a normal request would be
7962
+ * cancelled by the page unload and the event lost.
7963
+ */
7964
+ function track(name, params) {
7965
+ if (!gaOn) return;
7966
+ gtag('event', name, Object.assign(
7967
+ { app: 'resiresi-demo', mode: state.direct ? 'direct' : 'protected', pizzeria: state.pizzeria, transport_type: 'beacon' },
7968
+ params || {},
7969
+ ));
7970
+ }
7971
+
7887
7972
  let cfg = null;
7888
7973
  const ready = fetch('/api/config').then((r) => r.json()).then((c) => { cfg = c; return c; });
7889
7974
  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 });
7975
+ // When the PAGE last changed a booking. The poller below uses it to tell its own
7976
+ // work apart from the assistant's (see demo_assistant_booking).
7977
+ let lastSelfAct = 0;
7978
+ const act = (op, extra) => {
7979
+ if (op !== 'list') lastSelfAct = Date.now();
7980
+ return post('/api/act', { op, pizzeria: state.pizzeria, as: me(), ...extra });
7981
+ };
7891
7982
  const me = () => $('#as').value;
7892
7983
  const pz = () => (cfg?.pizzerias || []).find((p) => p.id === state.pizzeria) || { label: 'Nino Pizza', admin: 'Ana' };
7893
7984
  const anaBooking = {};
@@ -7899,6 +7990,7 @@ document.querySelectorAll('button.copy').forEach((b) => b.onclick = async () =>
7899
7990
  try { await navigator.clipboard.writeText(text); }
7900
7991
  catch { const r = document.createRange(); r.selectNodeContents(el); const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); document.execCommand('copy'); sel.removeAllRanges(); }
7901
7992
  b.textContent = 'Copied'; setTimeout(() => { b.textContent = 'Copy'; }, 1200);
7993
+ track('demo_copy', { what: (b.dataset.copy || '').replace('#', '') });
7902
7994
  });
7903
7995
 
7904
7996
  // \u2500\u2500 mode (which site this is), pizzeria, tab \u2500\u2500
@@ -7911,17 +8003,17 @@ function setMode(direct) {
7911
8003
  $('#foot-protected').hidden = direct; $('#foot-direct').hidden = !direct;
7912
8004
  }
7913
8005
  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); };
8006
+ $('#mode-protected').onclick = () => { if (state.direct) { track('demo_mode', { to: 'protected' }); goTo(cfg.protectedUrl); } };
8007
+ $('#mode-direct').onclick = () => { if (!state.direct) { track('demo_mode', { to: 'direct' }); goTo(cfg.directUrl); } };
7916
8008
  function setTab(tab) {
7917
8009
  state.tab = tab;
7918
8010
  $('#app').hidden = tab !== 'app'; $('#admin').hidden = tab !== 'admin';
7919
8011
  $('#tab-app').setAttribute('aria-pressed', tab === 'app'); $('#tab-admin').setAttribute('aria-pressed', tab === 'admin');
7920
8012
  }
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 }); }
8013
+ $('#tab-app').onclick = () => { if (state.tab !== 'app') { track('demo_tab', { to: 'app' }); reloadWith({ tab: 'app' }); } };
8014
+ $('#tab-admin').onclick = () => { if (state.tab !== 'admin') { track('demo_tab', { to: 'admin' }); reloadWith({ tab: 'admin' }); } };
8015
+ $('#go-admin').onclick = () => { track('demo_tab', { to: 'admin', from: 'footer-link' }); reloadWith({ tab: 'admin' }); };
8016
+ function setPizzeria(id) { if (id !== state.pizzeria) { track('demo_pizzeria', { to: id }); reloadWith({ pizzeria: id }); } }
7925
8017
 
7926
8018
  async function render() {
7927
8019
  await ready;
@@ -7956,7 +8048,7 @@ async function refresh() {
7956
8048
  const t = document.createElement('span');
7957
8049
  t.textContent = \`\${b.guests || 2} people \xB7 \${dayLabel(b.date)} \${b.time} \xB7 table \${b.table} \xB7 #\${b.id}\`;
7958
8050
  const x = document.createElement('button'); x.className = 'act'; x.textContent = 'Cancel';
7959
- x.onclick = async () => { await act('cancel', { id: b.id }); refresh(); };
8051
+ x.onclick = async () => { track('demo_cancel'); await act('cancel', { id: b.id }); refresh(); };
7960
8052
  li.append(t, x); ul.append(li);
7961
8053
  }
7962
8054
  $('#try-ben').hidden = !(me() === 'ben' && anaBooking[state.pizzeria]);
@@ -7965,11 +8057,15 @@ $('#book').onclick = async () => {
7965
8057
  const guests = Math.max(1, Number($('#guests').value) || 2);
7966
8058
  const date = $('#date').value || localDay();
7967
8059
  const time = $('#time').value || '19:30';
7968
- await act('book', { body: { guests, date, time } }); // the restaurant picks the table
8060
+ const r = await act('book', { body: { guests, date, time } }); // the restaurant picks the table
8061
+ track('demo_book', { guests, status: r.status || 0 });
7969
8062
  refresh();
7970
8063
  };
7971
8064
  $('#ben-cancel').onclick = async () => {
8065
+ lastSelfAct = Date.now();
7972
8066
  const r = await post('/api/act', { op: 'cancel', pizzeria: state.pizzeria, as: 'ben', id: anaBooking[state.pizzeria] });
8067
+ // The point of the whole demo: protected refuses Ben, direct lets him through.
8068
+ track('demo_ownership_check', { blocked: r.status === 403, status: r.status || 0 });
7973
8069
  const d = document.createElement('div');
7974
8070
  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
8071
  else if (r.status === 204) { d.className = 'out bad'; d.textContent = "Cancelled. Straight to localhost, nothing stopped Ben."; anaBooking[state.pizzeria] = null; }
@@ -7985,7 +8081,13 @@ setInterval(async () => {
7985
8081
  if (state.tab !== 'app' || document.hidden) return;
7986
8082
  const c = await fetch('/api/changes').then((x) => x.json()).catch(() => null);
7987
8083
  if (!c) return;
7988
- if (lastChange !== null && c.n !== lastChange) refresh();
8084
+ if (lastChange !== null && c.n !== lastChange) {
8085
+ // A booking moved that this page did not move. On this demo the only other
8086
+ // actor is the AI assistant, so that is what we count \u2014 with a 5s window so a
8087
+ // click of our own that is still settling is never miscounted as the assistant.
8088
+ if (Date.now() - lastSelfAct > 5000) track('demo_assistant_booking');
8089
+ refresh();
8090
+ }
7989
8091
  lastChange = c.n;
7990
8092
  }, 2000);
7991
8093
 
@@ -8018,7 +8120,25 @@ function mountAdmin() {
8018
8120
  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
8121
  }
8020
8122
 
8123
+ for (const id of ['#portal-top', '#portal']) {
8124
+ $(id).addEventListener('click', () => track('demo_portal_open', { from: id.replace('#', '') }));
8125
+ }
8126
+
8021
8127
  ready.then((c) => {
8128
+ // Ask once, and only when there is something to ask about. A visitor who has
8129
+ // already answered is never asked again; one who sends Do Not Track, or who
8130
+ // started the demo with APIBLAZE_DEMO_ANALYTICS=0, is not asked at all.
8131
+ if (c.analytics !== false && !doNotTrack) {
8132
+ const choice = consentRead();
8133
+ const begin = () => { startGa(); track('demo_open', { tab: state.tab }); };
8134
+ if (choice === 'granted') begin();
8135
+ else if (choice !== 'denied') {
8136
+ $('#consent').hidden = false;
8137
+ $('#consent-yes').onclick = () => { consentWrite('granted'); $('#consent').hidden = true; begin(); };
8138
+ $('#consent-no').onclick = () => { consentWrite('denied'); $('#consent').hidden = true; };
8139
+ }
8140
+ }
8141
+
8022
8142
  $('#api').textContent = c.apiUrl || 'not connected';
8023
8143
  setMode(!!c.direct);
8024
8144
  if (c.direct && !c.protectedUrl) { $('#mode-protected').disabled = true; $('#mode-protected').querySelector('small').textContent = 'starts once APIblaze is set up'; }
@@ -10930,6 +11050,27 @@ async function promptForMissing(r, interactive2, defaults = { prompts: [] }, pla
10930
11050
  }
10931
11051
  return out;
10932
11052
  }
11053
+ function isOpenMode(r) {
11054
+ return r.target?.project?.authMode === "open";
11055
+ }
11056
+ function toolReadsOnly(t) {
11057
+ if (t.annotations && typeof t.annotations.readOnlyHint === "boolean") return t.annotations.readOnlyHint && t.annotations.destructiveHint !== true;
11058
+ return /^(get|head)$/i.test((t.method ?? "").trim());
11059
+ }
11060
+ var OPEN_MODE_QUESTION = "Every tool only reads. Is this public data that needs no account? Then the connector can skip sign-in (Anthropic allows it for public data; your API host keeps its own keys).";
11061
+ async function askOpenMode(caller, proj2, r, opts, interactive2, say) {
11062
+ if (opts.authMode !== void 0) return false;
11063
+ if (isOpenMode(r)) return false;
11064
+ const tools = r.target?.tools ?? [];
11065
+ if (!tools.length || !tools.every(toolReadsOnly)) return false;
11066
+ if (!interactive2) return false;
11067
+ const { default: inquirer3 } = await import("inquirer");
11068
+ const { open } = await inquirer3.prompt([{ type: "confirm", name: "open", message: OPEN_MODE_QUESTION, default: false }]);
11069
+ if (!open) return false;
11070
+ await patchProject(caller, proj2, { directory_metadata: { auth_mode: "open" } }, "Open mode: no sign-in on the connector");
11071
+ say(import_chalk28.default.green(" \u2713 open mode: the connector takes no sign-in (public data); no reviewer account is needed."));
11072
+ return true;
11073
+ }
10933
11074
  async function promptListingBasics(r, interactive2, defaults, given, platforms = PLATFORMS) {
10934
11075
  const meta = r.target?.metadata ?? {};
10935
11076
  const gpt = platforms.includes("chatgpt");
@@ -11184,6 +11325,7 @@ async function runConnector(target2, opts) {
11184
11325
  const changed = await fixToolDescriptions(proj2, r.report.project.environment, interactive2, say);
11185
11326
  if (changed) r = await fetchDirectoryReport(caller, proj2, { platforms });
11186
11327
  }
11328
+ if (await askOpenMode(caller, proj2, r, opts, interactive2, say)) r = await fetchDirectoryReport(caller, proj2, { platforms });
11187
11329
  const basics = await promptListingBasics(r, interactive2, defaults, opts, platforms);
11188
11330
  if (!isReady(r, platforms) || Object.keys(basics).length) {
11189
11331
  const asked = { ...basics, ...isReady(r, platforms) ? {} : await promptForMissing(r, interactive2, defaults, platforms) };
@@ -11230,7 +11372,7 @@ async function runConnector(target2, opts) {
11230
11372
  console.log();
11231
11373
  console.log(import_chalk28.default.green.bold(` ${r.rendered.headline}`));
11232
11374
  console.log(` Submission packet: ${import_chalk28.default.cyan(mdPath)} and ${import_chalk28.default.cyan(jsonPath)}`);
11233
- console.log(import_chalk28.default.dim(" (the packet carries the reviewer credentials \u2014 keep it out of git)"));
11375
+ console.log(import_chalk28.default.dim(isOpenMode(r) ? " (open mode: no reviewer credentials in the packet; the connector takes no sign-in)" : " (the packet carries the reviewer credentials \u2014 keep it out of git)"));
11234
11376
  printSubmitSteps(r, mdPath);
11235
11377
  console.log(import_chalk28.default.dim(` ${r.report.disclaimer}`));
11236
11378
  }
@@ -11337,10 +11479,11 @@ async function headStatus(u) {
11337
11479
  }
11338
11480
  function printSubmitSteps(r, mdPath) {
11339
11481
  const url = r.report.project.publishedUrl ?? "(the MCP URL in the packet)";
11482
+ const open = isOpenMode(r);
11340
11483
  console.log();
11341
11484
  console.log(import_chalk28.default.bold(" Submit (you do this part; the packet has every value to paste):"));
11342
- console.log(` 1. Test it yourself first: add ${import_chalk28.default.cyan(url)} as a custom connector in Claude (Settings \u2192 Connectors) and in ChatGPT (Developer mode), sign in with the reviewer account, run a tool.`);
11343
- console.log(` 2. Claude: ${import_chalk28.default.underline(PORTALS.claude)} \u2192 Submit new \u2192 MCP connector. Paste the listing, the reviewer credentials and the test steps from ${import_chalk28.default.cyan(import_path4.default.basename(mdPath))}.`);
11485
+ console.log(` 1. Test it yourself first: add ${import_chalk28.default.cyan(url)} as a custom connector in Claude (Settings \u2192 Connectors) and in ChatGPT (Developer mode), ${open ? "no sign-in needed (open mode)" : "sign in with the reviewer account"}, run a tool.`);
11486
+ console.log(` 2. Claude: ${import_chalk28.default.underline(PORTALS.claude)} \u2192 Submit new \u2192 MCP connector. Paste the listing, ${open ? "Authentication: None with the access instructions" : "the reviewer credentials"} and the test steps from ${import_chalk28.default.cyan(import_path4.default.basename(mdPath))}.`);
11344
11487
  console.log(` 3. ChatGPT: ${import_chalk28.default.underline(PORTALS.chatgpt)} \u2192 the same packet, plus the 5 + 3 test cases it lists.`);
11345
11488
  console.log(` 4. Keep the reviewer account alive; a nightly re-check emails you if the listing drifts.`);
11346
11489
  console.log();
@@ -11735,7 +11878,7 @@ ${steps.map((t, i) => `${i + 1}. ${t}`).join("\n")}
11735
11878
  })() : "";
11736
11879
  const frontMatter = found ? "" : `---
11737
11880
  name: ${SKILL_NAME}
11738
- 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.
11881
+ 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.
11739
11882
  ---
11740
11883
 
11741
11884
  `;
@@ -11788,12 +11931,19 @@ ${blockedMarkdown()}
11788
11931
  On this machine \u2192 \`${run2} dev\`; deployed \u2192 \`${run2} create\`. \`${run2} skills ship\` has the
11789
11932
  whole procedure.
11790
11933
 
11934
+ ## A Claude connector or a ChatGPT plugin
11935
+
11936
+ \`${run2} create --compliant --target https://yourdomain.com/openapi.yaml --domain mcp.yourdomain.com\`
11937
+ makes the ${B.thingShort} a Claude connector that meets the Claude directory's policies; \`--compliant chatgpt\`
11938
+ makes a ChatGPT plugin to OpenAI's policies, \`--compliant all\` does both. It ends with a table of
11939
+ every requirement and why it is met, and the submission packet. \`${run2} skills connector\` has the page.
11940
+
11791
11941
  ## One thing at a time
11792
11942
 
11793
11943
  Each topic has its own page: \`${run2} skills keys\` (API keys) \xB7 \`signin\` (OAuth sign-in and the
11794
11944
  developer portal) \xB7 \`rules\` (who can change what) \xB7 \`limits\` (rate limits and quotas) \xB7 \`mcp\`
11795
- (AI agents) \xB7 \`widgets\` (keys, users and chat on their site) \xB7 \`ship\` (the whole procedure for
11796
- their own app).
11945
+ (AI agents) \xB7 \`widgets\` (keys, users and chat on their site) \xB7 \`connector\` (a compliant Claude
11946
+ connector or ChatGPT plugin) \xB7 \`ship\` (the whole procedure for their own app).
11797
11947
  `;
11798
11948
  }
11799
11949
  function shipMarkdown() {
@@ -12036,7 +12186,7 @@ ${heading}`);
12036
12186
  const next = md.indexOf("\n## ", at + heading.length + 1);
12037
12187
  return md.slice(at + 1, next < 0 ? void 0 : next).trim();
12038
12188
  }
12039
- var TOPICS = ["ship", "keys", "signin", "rules", "limits", "mcp", "widgets"];
12189
+ var TOPICS = ["ship", "keys", "signin", "rules", "limits", "mcp", "widgets", "connector"];
12040
12190
  function topicMarkdown(topic) {
12041
12191
  const P = B.product;
12042
12192
  const cli = B.cli;
@@ -12106,6 +12256,30 @@ of 3,000 a day. Say so in those words (rate limit, quota).
12106
12256
  ${sectionOf(legacy, "## No shell? The control-plane MCP")}`);
12107
12257
  case "widgets":
12108
12258
  return page(`Keys, users and chat on their site`, sectionOf(legacy, "## Wiring a real site").replace(/^## Wiring a real site\n+/, ""));
12259
+ case "connector":
12260
+ return page(`A compliant Claude connector or ChatGPT plugin with ${P}`, `
12261
+ A connector (Claude) or a plugin (ChatGPT) is the ${B.thingShort}'s MCP server, listed in that
12262
+ directory. Each directory has its own policies: a custom MCP hostname, OAuth sign-in with dynamic client
12263
+ registration, tool descriptions and listing texts within its limits, a privacy policy, a reviewer account,
12264
+ and attestations. \`--compliant\` makes the ${B.thingShort} meet them to the letter.
12265
+
12266
+ - Which directory: \`--compliant\` alone is Claude. \`--compliant chatgpt\` is OpenAI's, \`--compliant all\` is both.
12267
+ - From an OpenAPI URL: \`npx ${cli} create --compliant --target https://yourdomain.com/openapi.yaml --domain mcp.yourdomain.com\`.
12268
+ On an existing ${B.thingShort}: \`npx ${cli} connector <name> --compliant --domain mcp.yourdomain.com\`.
12269
+ - It asks for the listing answers with every field pre-filled from the spec and cut to the portal's limits
12270
+ (Enter accepts), and waits for ONE CNAME (mcp.yourdomain.com \u2192 proxy.abz.run) \u2014 the user adds it at their DNS
12271
+ provider; relay the exact record it prints. For ChatGPT it also asks for the challenge token the OpenAI portal
12272
+ issues for the domain (\`--openai-challenge <token>\`).
12273
+ - Sign-in: by default the connector takes OAuth sign-in (API keys keep working alongside) and a reviewer
12274
+ account is created for the packet. Public, read-only data can skip sign-in \u2014 Anthropic allows it: when every
12275
+ tool is read-only the flow asks once (y/N), or pass \`--auth-mode open\`. The API host keeps its own doors.
12276
+ - When every check passes it prints the compliance table (one row per requirement: met or not, why, and the
12277
+ policy page it comes from) and writes the submission packet. The user submits the packet to Anthropic or
12278
+ OpenAI themselves; listing decisions are theirs.
12279
+ - \`npx ${cli} check --directory-ready <name> --compliant\` is the report alone, with every blocker and its fix.
12280
+ Exit 1 while a blocker remains. Say "meets the requirements ${P} can verify", never "certified".
12281
+
12282
+ ${sectionOf(legacy, "## Listing it in the Claude or ChatGPT directory")}`);
12109
12283
  default:
12110
12284
  return null;
12111
12285
  }
@@ -20078,7 +20252,7 @@ init_auth();
20078
20252
  init_brand();
20079
20253
  installBrandFilter();
20080
20254
  var program = new import_commander.Command();
20081
- 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)");
20255
+ 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)");
20082
20256
  program.hook("preAction", (_thisCommand, actionCommand) => {
20083
20257
  if (program.opts().verbose) setVerbose(true);
20084
20258
  const chain = [];
@@ -20108,7 +20282,7 @@ program.command("login").description(`Authenticate with ${B.product}`).option("-
20108
20282
  process.exit(1);
20109
20283
  }
20110
20284
  });
20111
- 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("--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) => {
20285
+ 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("--auth-mode <mode>", "With --compliant: how directory clients get in. signin (default) = OAuth sign-in on the connector, API keys alongside; open = no sign-in at all, for public read-only data (Anthropic allows it) \u2014 the API host keeps its own doors. Asked once when every tool is read-only.").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) => {
20112
20286
  try {
20113
20287
  const compliant = opts.compliant ?? opts.policy;
20114
20288
  if (compliant !== void 0 && compliant !== false) {
@@ -20127,8 +20301,8 @@ program.command("create").description(`Put an app that is already DEPLOYED behin
20127
20301
  function collect(value, previous) {
20128
20302
  return [...previous ?? [], value];
20129
20303
  }
20130
- 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: claude \xB7 chatgpt \xB7 all (default claude; 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 })));
20131
- 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("--compliant <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("--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) => {
20304
+ 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 })));
20305
+ 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("--auth-mode <mode>", "How directory clients get in: signin (default) = OAuth sign-in on the connector, API keys alongside; open = no sign-in at all, for public read-only data (Anthropic allows it) \u2014 the API host keeps its own doors. Asked once when every tool is read-only.").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) => {
20132
20306
  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>`);
20133
20307
  await runConnector(project, opts);
20134
20308
  }));
@@ -20144,7 +20318,7 @@ program.command("demo").description(`See what ${B.product} does in a minute: a s
20144
20318
  }
20145
20319
  await runDemo(opts);
20146
20320
  });
20147
- 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 })));
20321
+ 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 })));
20148
20322
  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)));
20149
20323
  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)));
20150
20324
  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.56",
3
+ "version": "0.21.59",
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",