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.
- package/README.md +7 -0
- package/dist/index.js +200 -26
- 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.
|
|
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
|
-
|
|
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)
|
|
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 \`
|
|
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\`.)
|
|
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
|
|
20131
|
-
var connector = program.command("connector").description(`
|
|
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)));
|