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.
- package/README.md +7 -0
- package/dist/index.js +189 -37
- 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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 \`
|
|
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 --
|
|
12011
|
-
in one command, to the letter of the Claude directory's policies (\`--
|
|
12012
|
-
\`npx ${cli} connector <name> --
|
|
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(
|
|
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
|
|
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\`.)
|
|
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
|
-
|
|
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(`--
|
|
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("--
|
|
20116
|
-
var connector = program.command("connector").description(`
|
|
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.
|
|
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",
|