apiblaze 0.21.43 → 0.21.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +210 -129
- package/package.json +1 -1
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.45";
|
|
38
38
|
}
|
|
39
39
|
});
|
|
40
40
|
|
|
@@ -2139,6 +2139,17 @@ function fail(message) {
|
|
|
2139
2139
|
console.error(import_chalk11.default.red(`Error: ${message}`));
|
|
2140
2140
|
process.exit(1);
|
|
2141
2141
|
}
|
|
2142
|
+
function claimRefusalMessage(code, status2, team, claimCode) {
|
|
2143
|
+
if (code === "merge_not_supported_with_iam") {
|
|
2144
|
+
return `This workspace has people, groups or admins, so it can't be merged into ${team ? `"${team}"` : "an existing team"} yet.
|
|
2145
|
+
Claim it as a new team instead: ${B.cli} claim ${claimCode}`;
|
|
2146
|
+
}
|
|
2147
|
+
if (status2 === 403 && code === "forbidden" && team) {
|
|
2148
|
+
return `Merging into "${team}" needs the admin role on that team.
|
|
2149
|
+
Claim it as a new team instead: ${B.cli} claim ${claimCode}`;
|
|
2150
|
+
}
|
|
2151
|
+
return null;
|
|
2152
|
+
}
|
|
2142
2153
|
async function runClaim(claimCodeArg, opts) {
|
|
2143
2154
|
const creds = loadCredentials();
|
|
2144
2155
|
if (!creds) fail("Not logged in. Run `apiblaze login` first, then claim.");
|
|
@@ -2170,6 +2181,8 @@ async function runClaim(claimCodeArg, opts) {
|
|
|
2170
2181
|
} catch (err) {
|
|
2171
2182
|
if (err instanceof ApiError) {
|
|
2172
2183
|
const c = typeof err.body === "object" && err.body ? err.body.error : void 0;
|
|
2184
|
+
const refusal = claimRefusalMessage(c, err.status, opts.team, claimCode);
|
|
2185
|
+
if (refusal) fail(refusal);
|
|
2173
2186
|
if (c === "already_claimed" || c === "not_claimable") {
|
|
2174
2187
|
if (fromLocal) clearAnonCred();
|
|
2175
2188
|
console.log(import_chalk11.default.yellow("\nThis workspace was already claimed (e.g. on dashboard.apiblaze.com)."));
|
|
@@ -2212,6 +2225,7 @@ var init_claim = __esm({
|
|
|
2212
2225
|
init_anon_cred();
|
|
2213
2226
|
init_api();
|
|
2214
2227
|
init_types();
|
|
2228
|
+
init_brand();
|
|
2215
2229
|
}
|
|
2216
2230
|
});
|
|
2217
2231
|
|
|
@@ -6476,6 +6490,71 @@ var init_create = __esm({
|
|
|
6476
6490
|
}
|
|
6477
6491
|
});
|
|
6478
6492
|
|
|
6493
|
+
// src/server/core.ts
|
|
6494
|
+
async function safeErr(res) {
|
|
6495
|
+
try {
|
|
6496
|
+
const b = await res.json();
|
|
6497
|
+
if (typeof b.error === "string") return b.error;
|
|
6498
|
+
if (b.error?.message) return b.error.message;
|
|
6499
|
+
if (b.detail || b.title) return [b.title, b.detail].filter(Boolean).join(". ").replace(/\.\. /, ". ");
|
|
6500
|
+
return `apiblaze ${res.status}`;
|
|
6501
|
+
} catch {
|
|
6502
|
+
return `apiblaze ${res.status}`;
|
|
6503
|
+
}
|
|
6504
|
+
}
|
|
6505
|
+
function createPlaneCore(cfg) {
|
|
6506
|
+
if (!cfg.cpKey) throw new Error("apiblaze: cpKey is required (set APIBLAZE_CP_KEY). It is server-only \u2014 never expose it to the browser.");
|
|
6507
|
+
const apikeysBase = (cfg.apikeysBase ?? "https://apikeys.apiblaze.com").replace(/\/$/, "");
|
|
6508
|
+
const iamBase = (cfg.iamBase ?? "https://iam.apiblaze.com").replace(/\/$/, "");
|
|
6509
|
+
const tenantCache = /* @__PURE__ */ new Map();
|
|
6510
|
+
const endUserCache = /* @__PURE__ */ new Map();
|
|
6511
|
+
function planeFetch(base2, method, path16, user, opts = {}) {
|
|
6512
|
+
const params = new URLSearchParams({ product: "apiblaze", ...opts.query ?? {} });
|
|
6513
|
+
const headers = { "X-API-Key": cfg.cpKey, "X-End-User-Id": user.userId };
|
|
6514
|
+
let body;
|
|
6515
|
+
if (opts.body !== void 0) {
|
|
6516
|
+
headers["Content-Type"] = "application/json";
|
|
6517
|
+
body = JSON.stringify(opts.body);
|
|
6518
|
+
}
|
|
6519
|
+
return fetch(`${base2}${path16}?${params.toString()}`, { method, headers, body, cache: "no-store" });
|
|
6520
|
+
}
|
|
6521
|
+
async function resolveTenant2(user) {
|
|
6522
|
+
const cached = tenantCache.get(user.tenant);
|
|
6523
|
+
if (cached) return cached;
|
|
6524
|
+
const res = await planeFetch(apikeysBase, "POST", "/tenants", user, { body: { ref: user.tenant } });
|
|
6525
|
+
if (!res.ok) throw new PlaneError(res.status, await safeErr(res));
|
|
6526
|
+
const { tenant: tenant2 } = await res.json();
|
|
6527
|
+
tenantCache.set(user.tenant, tenant2);
|
|
6528
|
+
return tenant2;
|
|
6529
|
+
}
|
|
6530
|
+
async function ensureEndUser(user, tenant2) {
|
|
6531
|
+
const cacheKey = `${tenant2}:${user.userId}`;
|
|
6532
|
+
const cached = endUserCache.get(cacheKey);
|
|
6533
|
+
if (cached) return cached;
|
|
6534
|
+
const res = await planeFetch(apikeysBase, "POST", "/users", user, {
|
|
6535
|
+
body: { tenant: tenant2, ref: user.userId, email: user.email ?? null, display_name: user.label ?? null }
|
|
6536
|
+
});
|
|
6537
|
+
if (!res.ok) throw new PlaneError(res.status, await safeErr(res));
|
|
6538
|
+
const { abz_sub } = await res.json();
|
|
6539
|
+
endUserCache.set(cacheKey, abz_sub);
|
|
6540
|
+
return abz_sub;
|
|
6541
|
+
}
|
|
6542
|
+
return { planeFetch, resolveTenant: resolveTenant2, ensureEndUser, apikeysBase, iamBase };
|
|
6543
|
+
}
|
|
6544
|
+
var PlaneError;
|
|
6545
|
+
var init_core = __esm({
|
|
6546
|
+
"src/server/core.ts"() {
|
|
6547
|
+
"use strict";
|
|
6548
|
+
PlaneError = class extends Error {
|
|
6549
|
+
constructor(status2, detail) {
|
|
6550
|
+
super(detail);
|
|
6551
|
+
this.status = status2;
|
|
6552
|
+
this.detail = detail;
|
|
6553
|
+
}
|
|
6554
|
+
};
|
|
6555
|
+
}
|
|
6556
|
+
});
|
|
6557
|
+
|
|
6479
6558
|
// src/demo-app/files.ts
|
|
6480
6559
|
var DEMO_FILES;
|
|
6481
6560
|
var init_files = __esm({
|
|
@@ -6577,7 +6656,7 @@ components:
|
|
|
6577
6656
|
properties:
|
|
6578
6657
|
id: { type: string, example: r1 }
|
|
6579
6658
|
`,
|
|
6580
|
-
"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// 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 who = req.headers['x-abz-user-id'] || '';\n const
|
|
6659
|
+
"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",
|
|
6581
6660
|
"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",
|
|
6582
6661
|
"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",
|
|
6583
6662
|
"public/index.html": `<!doctype html>
|
|
@@ -7699,6 +7778,7 @@ __export(demo_exports, {
|
|
|
7699
7778
|
DEMO_RPS: () => DEMO_RPS,
|
|
7700
7779
|
demoName: () => demoName,
|
|
7701
7780
|
isDemoProcess: () => isDemoProcess,
|
|
7781
|
+
personaPlan: () => personaPlan,
|
|
7702
7782
|
portFree: () => portFree,
|
|
7703
7783
|
routesOf: () => routesOf,
|
|
7704
7784
|
runDemo: () => runDemo,
|
|
@@ -7708,6 +7788,19 @@ __export(demo_exports, {
|
|
|
7708
7788
|
stopDemos: () => stopDemos,
|
|
7709
7789
|
stopTunnels: () => stopTunnels
|
|
7710
7790
|
});
|
|
7791
|
+
function personaPlan(ninoTenant, ginoTenant) {
|
|
7792
|
+
const nino = [
|
|
7793
|
+
{ tenant: ninoTenant, userId: "ana", label: "Ana", email: DEMO_ADMIN_EMAIL },
|
|
7794
|
+
{ tenant: ninoTenant, userId: "ben", label: "Ben" }
|
|
7795
|
+
];
|
|
7796
|
+
if (!ginoTenant) return nino;
|
|
7797
|
+
return [
|
|
7798
|
+
...nino,
|
|
7799
|
+
{ tenant: ginoTenant, userId: "gina", label: "Gina", email: DEMO_GINO_ADMIN_EMAIL },
|
|
7800
|
+
{ tenant: ginoTenant, userId: "ana", label: "Ana" },
|
|
7801
|
+
{ tenant: ginoTenant, userId: "ben", label: "Ben" }
|
|
7802
|
+
];
|
|
7803
|
+
}
|
|
7711
7804
|
function demoName() {
|
|
7712
7805
|
return `demo${pick2(ADJ)}${pick2(NOUN)}${import_crypto.default.randomBytes(2).toString("hex")}`;
|
|
7713
7806
|
}
|
|
@@ -8169,9 +8262,19 @@ paths:`));
|
|
|
8169
8262
|
})()
|
|
8170
8263
|
]);
|
|
8171
8264
|
if (!secretOk) throw new DemoError(`Could not give ${B.product} the backend secret.`, `Run again: ${LATEST} demo`, "secret-failed");
|
|
8172
|
-
|
|
8265
|
+
const userIds2 = {};
|
|
8266
|
+
const plane = createPlaneCore({ cpKey: cpKey2 });
|
|
8267
|
+
await Promise.all(personaPlan(tenant3, gino2?.tenant).map(async (p) => {
|
|
8268
|
+
try {
|
|
8269
|
+
const id = await plane.ensureEndUser({ tenant: p.tenant, userId: p.userId, label: p.label, email: p.email }, p.tenant);
|
|
8270
|
+
if (id) (userIds2[p.tenant] ??= {})[p.userId] = id;
|
|
8271
|
+
} catch {
|
|
8272
|
+
}
|
|
8273
|
+
}));
|
|
8274
|
+
return { cpKey: cpKey2, teamId: teamId2, serverKey: serverKey2, project: project2, version: version3, tenant: tenant3, gino: gino2, createSecs: createSecs2, adminOk: adminOk2, ginaOk: ginaOk2, limited: limited2, userIds: userIds2, claimCode: rr.claim_code, applied: rr.secure?.applied ?? [] };
|
|
8173
8275
|
});
|
|
8174
|
-
const { cpKey, teamId, serverKey, project, version: version2, tenant: tenant2, gino, createSecs, adminOk, ginaOk, limited } = setup;
|
|
8276
|
+
const { cpKey, teamId, serverKey, project, version: version2, tenant: tenant2, gino, createSecs, adminOk, ginaOk, limited, userIds } = setup;
|
|
8277
|
+
const peopleOk = Object.values(userIds).reduce((n, m) => n + Object.keys(m).length, 0);
|
|
8175
8278
|
const apiUrl = restUrl(project, version2, "dev", { anon: true });
|
|
8176
8279
|
const agents = mcpUrl(project, version2, "dev", { tenant: tenant2, anon: true });
|
|
8177
8280
|
const applied = setup.applied.map((k) => k.replace(/^\//, "").split("/")[0]);
|
|
@@ -8191,7 +8294,9 @@ paths:`));
|
|
|
8191
8294
|
DEMO_NINO_TENANT: ninoTenant,
|
|
8192
8295
|
APP_PORT: appPort,
|
|
8193
8296
|
BACKEND_PORT: backendPort2,
|
|
8194
|
-
DIRECT_PORT: directPort
|
|
8297
|
+
DIRECT_PORT: directPort,
|
|
8298
|
+
// { tenant: { handle: stable id } }: the backend maps the handles of calls that skip the gateway.
|
|
8299
|
+
DEMO_USER_IDS: JSON.stringify(userIds)
|
|
8195
8300
|
});
|
|
8196
8301
|
const state = { name: project, version: version2, tenant: tenant2, teamId, cpKey, appPort, backendPort: backendPort2, apiUrl, claimCode: setup.claimCode };
|
|
8197
8302
|
saveState(dir, state);
|
|
@@ -8209,6 +8314,7 @@ paths:`));
|
|
|
8209
8314
|
what: ginaOk ? "Ana runs Nino Pizza, Gina runs Gino Pizza" : "Ana runs Nino Pizza",
|
|
8210
8315
|
cmds: [L(`admins add ${DEMO_ADMIN_EMAIL} --tenant ${tenant2}`), ...ginaOk && gino?.tenant ? [L(`admins add ${DEMO_GINO_ADMIN_EMAIL} --tenant ${gino.tenant}`)] : []]
|
|
8211
8316
|
}] : [],
|
|
8317
|
+
...peopleOk ? [{ label: "People", what: `${peopleOk} people provisioned up front (Ana, Ben, Gina), so their ids never change`, cmds: ["(the keys & users widgets' own call: npm i apiblaze \u2192 ensureEndUser)"] }] : [],
|
|
8212
8318
|
...applied.length ? [{ label: "Rules", what: "only who made a reservation can change it", cmds: [L(`rule ${project} "${RULE_SENTENCE}"`)] }] : [],
|
|
8213
8319
|
{ label: "AI (MCP Tools)", what: `${tools.length} tools, one per route`, cmds: ["(part of create, above)"] },
|
|
8214
8320
|
...limited ? [{ label: "Limits", what: `${DEMO_RPS} calls a second per customer`, cmds: [L(`limits set ${project} --rate ${DEMO_RPS} --end-user-rate ${DEMO_RPS}`)] }] : [],
|
|
@@ -8385,6 +8491,7 @@ var init_demo = __esm({
|
|
|
8385
8491
|
init_auth();
|
|
8386
8492
|
init_anon_cred();
|
|
8387
8493
|
init_brand();
|
|
8494
|
+
init_core();
|
|
8388
8495
|
init_create();
|
|
8389
8496
|
init_files();
|
|
8390
8497
|
DIR_NAME = `${B.cli}-demo`;
|
|
@@ -8765,6 +8872,72 @@ async function journey(label3, work, home, checks) {
|
|
|
8765
8872
|
}
|
|
8766
8873
|
return env;
|
|
8767
8874
|
}
|
|
8875
|
+
async function claimJourney(work, checks, env) {
|
|
8876
|
+
const add = (id, ok, detail2) => checks.push({ id: `claim:${id}`, ok, detail: detail2 });
|
|
8877
|
+
let code = "";
|
|
8878
|
+
let teamId = "";
|
|
8879
|
+
try {
|
|
8880
|
+
const st = JSON.parse(import_fs10.default.readFileSync(import_path7.default.join(work, `${B.cli}-demo`, ".demo", "state.json"), "utf8"));
|
|
8881
|
+
code = st.claimCode ?? "";
|
|
8882
|
+
teamId = st.teamId ?? "";
|
|
8883
|
+
} catch {
|
|
8884
|
+
}
|
|
8885
|
+
if (!code || !teamId || !env) {
|
|
8886
|
+
add("code", false, "no claim code in the demo state");
|
|
8887
|
+
return;
|
|
8888
|
+
}
|
|
8889
|
+
const fail7 = (e) => ({ status: e instanceof ApiError ? e.status : 0, error: e instanceof ApiError ? String(e.body?.error ?? "") : String(e.message) });
|
|
8890
|
+
const api = env.APIBLAZE_URL;
|
|
8891
|
+
const key = env.APIBLAZE_SERVER_KEY;
|
|
8892
|
+
const ana = { "x-api-key": key, "x-end-user-id": "ana", "content-type": "application/json" };
|
|
8893
|
+
const before = await http(`${api}/reservations`, { method: "POST", headers: ana, body: JSON.stringify({ time: "21:00" }) });
|
|
8894
|
+
const bookedId = before.status === 201 && before.body?.id ? String(before.body.id) : null;
|
|
8895
|
+
const seenBefore = await http(`${api}/reservations`, { headers: ana });
|
|
8896
|
+
const ownersBefore = new Map((Array.isArray(seenBefore.body) ? seenBefore.body : []).map((r) => [String(r.id), String(r.owner)]));
|
|
8897
|
+
let refused = { status: 200, error: "MERGED" };
|
|
8898
|
+
try {
|
|
8899
|
+
await claimTeam(code, SANDBOX_TEAM);
|
|
8900
|
+
} catch (e) {
|
|
8901
|
+
refused = fail7(e);
|
|
8902
|
+
}
|
|
8903
|
+
add("merge-refused", refused.status === 409 && refused.error === "merge_not_supported_with_iam", `HTTP ${refused.status} ${refused.error}`);
|
|
8904
|
+
let res = {};
|
|
8905
|
+
let err = { status: 0, error: "" };
|
|
8906
|
+
try {
|
|
8907
|
+
res = await claimTeam(code);
|
|
8908
|
+
} catch (e) {
|
|
8909
|
+
err = fail7(e);
|
|
8910
|
+
}
|
|
8911
|
+
add("promote", res.claimed === true && !res.merged && res.team_id === teamId, res.claimed ? `team ${res.team_id}` : `HTTP ${err.status} ${err.error}`);
|
|
8912
|
+
const list = await http(`${api}/reservations`, { headers: ana });
|
|
8913
|
+
const after = new Map((Array.isArray(list.body) ? list.body : []).map((r) => [String(r.id), String(r.owner)]));
|
|
8914
|
+
const missing = [...ownersBefore.keys()].filter((id) => !after.has(id));
|
|
8915
|
+
const changed = [...ownersBefore].filter(([id, owner]) => after.has(id) && after.get(id) !== owner).map(([id]) => id);
|
|
8916
|
+
add(
|
|
8917
|
+
"identity-continuity",
|
|
8918
|
+
list.status === 200 && !!bookedId && ownersBefore.has(bookedId) && missing.length === 0 && changed.length === 0,
|
|
8919
|
+
`${ownersBefore.size} booking(s) before (incl. ${bookedId ?? "-"}) \xB7 list ${list.status} after \xB7 ${missing.length ? `MISSING ${missing.join(",")}` : "all still hers"}${changed.length ? ` \xB7 OWNER ID CHANGED ${changed.join(",")}` : ""}`
|
|
8920
|
+
);
|
|
8921
|
+
let gone = false;
|
|
8922
|
+
let detail = "";
|
|
8923
|
+
if (res.claimed) {
|
|
8924
|
+
try {
|
|
8925
|
+
await admin({ method: "DELETE", path: `/teams/${encodeURIComponent(teamId)}`, summary: "delete the promoted demo team" });
|
|
8926
|
+
for (let i = 0; i < 180 && !gone; i++) {
|
|
8927
|
+
try {
|
|
8928
|
+
await admin({ method: "GET", path: `/teams/${encodeURIComponent(teamId)}` });
|
|
8929
|
+
} catch (e) {
|
|
8930
|
+
if (e instanceof ApiError && (e.status === 404 || e.status === 403)) gone = true;
|
|
8931
|
+
}
|
|
8932
|
+
if (!gone) await new Promise((r) => setTimeout(r, 1e3));
|
|
8933
|
+
}
|
|
8934
|
+
detail = gone ? "cascade done" : "still present after 180 s";
|
|
8935
|
+
} catch (e) {
|
|
8936
|
+
detail = `delete \u2192 ${fail7(e).status} ${fail7(e).error}`;
|
|
8937
|
+
}
|
|
8938
|
+
} else detail = "nothing to delete (claim failed)";
|
|
8939
|
+
add("cleanup", gone, detail);
|
|
8940
|
+
}
|
|
8768
8941
|
async function runOpAnon(o = {}) {
|
|
8769
8942
|
const t0 = Date.now();
|
|
8770
8943
|
const root = import_fs10.default.mkdtempSync(import_path7.default.join(import_os3.default.tmpdir(), `${B.cli}-op-anon-`));
|
|
@@ -8779,7 +8952,8 @@ async function runOpAnon(o = {}) {
|
|
|
8779
8952
|
log4(import_chalk70.default.bold(`${B.cli} op anon \u2014 a first-time visitor's demo, against production`));
|
|
8780
8953
|
try {
|
|
8781
8954
|
await journey("first", work, home, checks);
|
|
8782
|
-
await journey("rerun", work, home, checks);
|
|
8955
|
+
const env = await journey("rerun", work, home, checks);
|
|
8956
|
+
await claimJourney(work, checks, env);
|
|
8783
8957
|
} finally {
|
|
8784
8958
|
const stop = await runCli(["demo", "stop"], work, home, 3e4);
|
|
8785
8959
|
const demoFolder = import_path7.default.join(work, `${B.cli}-demo`);
|
|
@@ -8809,7 +8983,7 @@ async function runOpAnon(o = {}) {
|
|
|
8809
8983
|
if (failed) process.exitCode = 1;
|
|
8810
8984
|
return { ok: !failed, checks };
|
|
8811
8985
|
}
|
|
8812
|
-
var import_fs10, import_os3, import_path7, import_child_process6, import_chalk70, SECRET;
|
|
8986
|
+
var import_fs10, import_os3, import_path7, import_child_process6, import_chalk70, SECRET, SANDBOX_TEAM;
|
|
8813
8987
|
var init_op_anon = __esm({
|
|
8814
8988
|
"src/commands/op-anon.ts"() {
|
|
8815
8989
|
"use strict";
|
|
@@ -8819,7 +8993,11 @@ var init_op_anon = __esm({
|
|
|
8819
8993
|
import_child_process6 = require("child_process");
|
|
8820
8994
|
import_chalk70 = __toESM(require("chalk"));
|
|
8821
8995
|
init_brand();
|
|
8996
|
+
init_api();
|
|
8997
|
+
init_admin();
|
|
8998
|
+
init_types();
|
|
8822
8999
|
SECRET = /\bsk_(?:dev|test|prod)_[A-Za-z0-9]{6,}/;
|
|
9000
|
+
SANDBOX_TEAM = "team_1782844865835_zujrf";
|
|
8823
9001
|
}
|
|
8824
9002
|
});
|
|
8825
9003
|
|
|
@@ -8930,13 +9108,26 @@ async function defaultName(openapi, teamId) {
|
|
|
8930
9108
|
}
|
|
8931
9109
|
return randomProxyName();
|
|
8932
9110
|
}
|
|
9111
|
+
function saltedName(name) {
|
|
9112
|
+
return `${name}${Math.random().toString(36).slice(2, 6)}`;
|
|
9113
|
+
}
|
|
8933
9114
|
async function chooseName(opts, openapi, teamId, interactive2) {
|
|
8934
9115
|
if (opts.name !== void 0) {
|
|
8935
9116
|
const name = normalizeName2(opts.name);
|
|
8936
9117
|
if (name.length < 3) throw new Error(`${cap(B.thingShort)} name must be at least 3 characters (letters and digits only).`);
|
|
8937
9118
|
const check = await checkProxyName(name, teamId).catch(() => null);
|
|
8938
9119
|
if (check && (!check.canUseProjectName || !check.canUseApiVersion)) {
|
|
8939
|
-
|
|
9120
|
+
if (opts.auto || !interactive2) {
|
|
9121
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
9122
|
+
const candidate = saltedName(name);
|
|
9123
|
+
const again = await checkProxyName(candidate, teamId).catch(() => null);
|
|
9124
|
+
if (!again || again.canUseProjectName && again.canUseApiVersion) {
|
|
9125
|
+
console.log(import_chalk20.default.dim(` "${name}" is taken (names are global) \u2014 using "${candidate}" instead.`));
|
|
9126
|
+
return candidate;
|
|
9127
|
+
}
|
|
9128
|
+
}
|
|
9129
|
+
}
|
|
9130
|
+
throw new Error(`${cap(B.thingShort)} name "${name}" is not available${check.message ? ` \u2014 ${check.message}` : ""}. Omit --name and a free one is chosen for you.`);
|
|
8940
9131
|
}
|
|
8941
9132
|
return name;
|
|
8942
9133
|
}
|
|
@@ -9794,10 +9985,12 @@ their own app).
|
|
|
9794
9985
|
}
|
|
9795
9986
|
function shipMarkdown() {
|
|
9796
9987
|
const legacy = legacySkillMarkdown();
|
|
9797
|
-
const at = legacy.indexOf("## Adopt");
|
|
9798
9988
|
const backendRules = `## What their backend does \u2014 and nothing else
|
|
9799
9989
|
|
|
9800
9990
|
The gateway does sign-in, keys, tenancy and single-record ownership. The backend does only:
|
|
9991
|
+
- provision each person before their first call (what the Users widget's \`ensureEndUser\` does): an
|
|
9992
|
+
unprovisioned caller arrives as the raw id the app asserted, a provisioned one as their stable id, and
|
|
9993
|
+
a row stamped with the former is not found by the latter;
|
|
9801
9994
|
- stamp each new row's \`tenant\` with the \`x-abz-tenant-id\` header and its \`owner\` with the
|
|
9802
9995
|
\`x-abz-user-id\` header;
|
|
9803
9996
|
- filter every collection GET to rows whose \`tenant\` AND \`owner\` match those headers. Both,
|
|
@@ -9812,7 +10005,7 @@ No code yet? Write the smallest plain backend that does exactly that, plus its \
|
|
|
9812
10005
|
return withLatest(`# Put ${B.product} in front of their app
|
|
9813
10006
|
|
|
9814
10007
|
${backendRules}${blockedMarkdown()}
|
|
9815
|
-
${
|
|
10008
|
+
${legacy}`);
|
|
9816
10009
|
}
|
|
9817
10010
|
function skillMarkdown() {
|
|
9818
10011
|
return `${heroMarkdown()}
|
|
@@ -9822,121 +10015,7 @@ function legacySkillMarkdown() {
|
|
|
9822
10015
|
const P = B.product;
|
|
9823
10016
|
const cli = B.cli;
|
|
9824
10017
|
const LIB = "apiblaze";
|
|
9825
|
-
|
|
9826
|
-
const doors = BRAND === "mcpblaze" ? `${P} ships your code to agents (an MCP server); ${other} ships the same code to users (API keys + sign-in).` : `${P} ships your code to users (API keys + sign-in); ${other} ships the same code to agents (an MCP server).`;
|
|
9827
|
-
return `---
|
|
9828
|
-
name: ${SKILL_NAME}
|
|
9829
|
-
description: Use when the user asks what ${P} (or ${cli}) does or can do for them; says yes to a demo or asks to see / try / demo it; wants to ship, share, expose or make an API public; says "serve my backend behind a proxy and MCP"; wants auth, API keys, sign-in, multi-tenant registration, users and groups, rate limits (throttling), quotas or spend caps; wants agents to use the API through an MCP; or says users can reach each other's records or admin functions. Puts the ${P} gateway in front of the API instead of hand-rolling auth and tenancy in the backend.
|
|
9830
|
-
---
|
|
9831
|
-
|
|
9832
|
-
# Ship safely with ${P}
|
|
9833
|
-
|
|
9834
|
-
One gateway, two doors. ${doors}
|
|
9835
|
-
Same rules, one command. Nothing about auth, tenancy or ownership goes into the backend's
|
|
9836
|
-
business logic: the gateway sits in front of the code and enforces it.
|
|
9837
|
-
|
|
9838
|
-
## Which mode \u2014 decide this first
|
|
9839
|
-
|
|
9840
|
-
- They ask what ${P} does or can do \u2192 **Explain**. Glance at the workspace first (see
|
|
9841
|
-
Explain), answer, then offer either the demo or their own app. Run no \`npx ${cli}\`
|
|
9842
|
-
command \u2014 the whole answer is in this file.
|
|
9843
|
-
- They say yes to the demo, or ask to see / try / demo it \u2192 **Demo app**. It lives in its own
|
|
9844
|
-
folder and never touches their code.
|
|
9845
|
-
- They say yes to "can I read the code" \u2192 **Adopt**: scan their app, ask only what the scan
|
|
9846
|
-
could not find, then ship it through the Ship steps.
|
|
9847
|
-
- They have a backend, just wrote one, or ask you to BUILD one and then ship it \u2192 **Ship**.
|
|
9848
|
-
No code yet? First write the smallest plain backend that serves the resources they named,
|
|
9849
|
-
plus its \`openapi.yaml\`, start it on their port, then continue at Ship \xA71. Do NOT build
|
|
9850
|
-
the Demo app for this. The gateway does auth, tenancy and item ownership; the backend does
|
|
9851
|
-
only these three things:
|
|
9852
|
-
- stamp each new row's \`tenant\` with the \`x-abz-tenant-id\` header and its \`owner\` with
|
|
9853
|
-
the \`x-abz-user-id\` header;
|
|
9854
|
-
- filter every collection GET to rows whose \`tenant\` AND \`owner\` match those headers (the
|
|
9855
|
-
gateway's rules lock single records, never lists \u2014 a list route has no one record to
|
|
9856
|
-
check). Both, always: a user id is not unique across tenants ("ana" at one customer and
|
|
9857
|
-
"ana" at another are two people);
|
|
9858
|
-
- answer 401 to any call whose \`x-target-api-key\` is not \`process.env.TARGET_SERVER_SECRET\`
|
|
9859
|
-
(generate it with \`openssl rand -hex 24\`, put it in \`.env.local\`, start the backend with
|
|
9860
|
-
it; Ship \xA73b makes the proxy send it). If they say "use
|
|
9861
|
-
${cli} on this" or "ship it with ${cli}" and point at a backend, skip any pitch and run it.
|
|
9862
|
-
|
|
9863
|
-
Tie-breaks: a question ("what can it do", "what is it") is always **Explain**, whatever else the
|
|
9864
|
-
message names. Mentioning \`npx ${cli} skills\` is NOT a request to run anything \u2014 it is the
|
|
9865
|
-
command that installed this file. If a message fits two modes, the earliest bullet wins.
|
|
9866
|
-
|
|
9867
|
-
## Explain
|
|
9868
|
-
|
|
9869
|
-
Say this, in these words or close to them, then stop:
|
|
9870
|
-
|
|
9871
|
-
"${P} puts a gateway in front of your backend in one command: a proxy (every request passes
|
|
9872
|
-
through it first) and an MCP server (the same API, callable by AI agents like Claude). No auth
|
|
9873
|
-
or ownership logic goes into your code \u2014 your backend only has to trust one header. Each of
|
|
9874
|
-
your customers \u2014 a tenant \u2014 gets their own API key, created from a widget you drop into your
|
|
9875
|
-
site. People and agents sign in with hosted GitHub, or your own OAuth app. Each user can only
|
|
9876
|
-
touch the records they created; those rules come from your OpenAPI spec (the file listing your
|
|
9877
|
-
endpoints), and if you have none I draft one from your routes and show it to you first. One
|
|
9878
|
-
more command sets a rate limit, a daily quota and a spend cap. In dev it tunnels to localhost;
|
|
9879
|
-
in prod it points at your deploy."
|
|
9880
|
-
|
|
9881
|
-
Before answering, look at the workspace \u2014 file names and \`package.json\` only, no reading of
|
|
9882
|
-
code yet. It "has an app" if ANY of these is present: an \`openapi.*\` / \`swagger.*\` file; a
|
|
9883
|
-
server framework (express, fastify, hono, koa, nest, fastapi, flask, django, rails, gin, chi,
|
|
9884
|
-
net/http); or a frontend (next, react, vue, svelte, remix, nuxt) whose \`.env*\` file NAMES an
|
|
9885
|
-
API base-URL variable (\`API_URL\`, \`NEXT_PUBLIC_API_URL\`, \`VITE_API_URL\`, or similar) \u2014
|
|
9886
|
-
\`grep -l\` the env files, open nothing else. Then end with exactly one of these two lines:
|
|
9887
|
-
|
|
9888
|
-
- No app \u2192 **"Would you like me to spin up a demo app?"**
|
|
9889
|
-
- An app \u2192 **"I see you have an app already coded. Can I read the code to see how to ready it
|
|
9890
|
-
for human and agent traffic with ${P}?"**
|
|
9891
|
-
|
|
9892
|
-
## Demo app \u2014 the same reservation system, before and after
|
|
9893
|
-
|
|
9894
|
-
One Next.js app on port 3000. Two routes, same UI, same data: \`/before\` is how most apps
|
|
9895
|
-
ship; \`/\` is the same thing behind ${P}. Narrate each step in one line and show every
|
|
9896
|
-
\`npx ${cli}\` command \u2014 the commands are the product. Everything goes in \`${cli}-demo/\`.
|
|
9897
|
-
|
|
9898
|
-
1. **Scaffold.** \`npx create-next-app@latest ${cli}-demo\` (TypeScript, App Router). Then
|
|
9899
|
-
\`npm i hono ${LIB}\`.
|
|
9900
|
-
2. **The backend.** Hono mounted at \`app/api/[[...route]]/route.ts\` (\`import { handle } from
|
|
9901
|
-
'hono/vercel'\`). Three resources \u2014 restaurants, tables, reservations \u2014 in an in-memory Map,
|
|
9902
|
-
every row stamped with \`owner\` = the \`x-abz-user-id\` header (${P} sets it on every call it
|
|
9903
|
-
forwards). Every route requires \`X-Api-Secret: demo-secret\` OR an \`x-abz-user-id\` header \u2014
|
|
9904
|
-
the first is how /before gets in, the second is how the gateway gets in. Write
|
|
9905
|
-
\`openapi.yaml\` at the repo root: every path and method, and the \`id\` each POST returns \u2014
|
|
9906
|
-
the ownership rules are read from it.
|
|
9907
|
-
3. **/before.** \`app/before/page.tsx\`: list and create reservations through one server route,
|
|
9908
|
-
\`app/before/api/route.ts\`, which holds \`demo-secret\` and calls \`/api/...\` directly. Put the
|
|
9909
|
-
caption on the page: "Before \u2014 one shared secret, anyone holding it can do anything."
|
|
9910
|
-
4. **Put ${P} in front.** In the BACKGROUND, and leave it running \u2014 it is the tunnel:
|
|
9911
|
-
\`npx ${cli} dev --port 3000 --openapi ./openapi.yaml --name demo --auto\`
|
|
9912
|
-
It prints the API URL, the MCP URL, the tenant, and the bootstrap key ONCE. Write them to
|
|
9913
|
-
\`.env.local\` as \`APIBLAZE_URL\`, \`APIBLAZE_SERVER_KEY\`, \`APIBLAZE_TENANT\`. \`--auto\` locks
|
|
9914
|
-
all three resources to their creator \u2014 say so.
|
|
9915
|
-
5. **Users and groups.** Turn them on and make the group the demo is about:
|
|
9916
|
-
\`npx ${cli} iam demo on\`
|
|
9917
|
-
\`npx ${cli} group create reservationists --tenant <tenant>\`
|
|
9918
|
-
\`npx ${cli} group add-user <the demo user> reservationists --tenant <tenant>\`
|
|
9919
|
-
\`npx ${cli} admins add <the user's email> --tenant <tenant>\` (so the widget has an admin).
|
|
9920
|
-
Optional, billed per turn: \`npx ${cli} rule demo "only members of reservationists can create
|
|
9921
|
-
or change reservations"\`. If the user is logged out or their wallet has nothing available
|
|
9922
|
-
(\`npx ${cli} billing balance\`), keep the free creator-lock rules and say so \u2014 do not pretend
|
|
9923
|
-
the group rule applied.
|
|
9924
|
-
6. **/.** \`app/page.tsx\`: the same UI, plus a **user picker** (ana \xB7 ben \xB7 admin \u2014 a select, no
|
|
9925
|
-
real login; the demo is about authorization, not building sign-in). Its server route calls
|
|
9926
|
-
\`process.env.APIBLAZE_URL\` with \`X-API-Key: APIBLAZE_SERVER_KEY\` and \`X-End-User-Id: <the
|
|
9927
|
-
picked user>\`. Add the three widgets from \`${LIB}/react\`, each with its server route:
|
|
9928
|
-
- \`<ApiKeyWidget />\` + \`app/api/apiblaze/keys/route.ts\` (\`createApiblazeKeys\` from \`${LIB}/server\`)
|
|
9929
|
-
- \`<UsersGroupsWidget />\` + \`app/api/apiblaze/groups/route.ts\` (\`createApiblazeGroups\` \u2014
|
|
9930
|
-
the keys route with one word changed)
|
|
9931
|
-
- \`<ChatWidget />\` + \`app/api/apiblaze/chat/route.ts\` (\`createApiblazeChat\`)
|
|
9932
|
-
Exact snippets for the keys and chat pair: \`npx ${cli} integration demo --stack nextjs\`.
|
|
9933
|
-
Caption: "After \u2014 a key per tenant, every call names its user, and ${P} decides."
|
|
9934
|
-
7. **Prove it, in the browser.** \`npm run dev\`, open \`http://localhost:3000/before\` then
|
|
9935
|
-
\`http://localhost:3000/\`. On \`/\`: create a reservation as ana, switch to ben, try to cancel
|
|
9936
|
-
it \u2014 show the 403 in the UI. Open the groups widget: reservationists is there. Ask the chat
|
|
9937
|
-
widget to list reservations. Then say what each \`npx ${cli}\` command did, in order.
|
|
9938
|
-
|
|
9939
|
-
## Adopt \u2014 reading their code first
|
|
10018
|
+
return `## Adopt \u2014 reading their code first
|
|
9940
10019
|
|
|
9941
10020
|
They said yes. Scan properly now (routes, env files, auth, network calls), classify, then ask
|
|
9942
10021
|
ONLY for what the scan could not find \u2014 one short list of questions, not an interview.
|
|
@@ -9997,12 +10076,14 @@ ONLY for what the scan could not find \u2014 one short list of questions, not an
|
|
|
9997
10076
|
the report. Only ask "where do your users sign in today?" when they mention an existing
|
|
9998
10077
|
login. Their own OAuth app \u2192 \`--apikey --oauth '{"provider":"auth0","clientId":"\u2026","clientSecret":"\u2026"}'\`
|
|
9999
10078
|
(both doors: keys for tenants, their login for people and agents). Shapes below.
|
|
10000
|
-
3. **Local code** \u2192 \`npx ${cli} dev --port <port> --openapi ./openapi.yaml --
|
|
10079
|
+
3. **Local code** \u2192 \`npx ${cli} dev --port <port> --openapi ./openapi.yaml --auto <door flags>\`,
|
|
10001
10080
|
left running \u2014 it is the tunnel. Start it as its own detached process, never \`cmd &\` inside
|
|
10002
10081
|
a shell call that ends: \`nohup npx ${cli} dev \u2026 > ${cli}-dev.log 2>&1 < /dev/null &\`, then
|
|
10003
|
-
read the log.
|
|
10004
|
-
|
|
10005
|
-
|
|
10082
|
+
read the log. Leave \`--name\` out: names are global, so a free one is chosen and printed
|
|
10083
|
+
(\`--name\` only for a name the user asked for; a taken one is salted). If the tunnel stops,
|
|
10084
|
+
run the \`--project <name>\` line it printed: that reattaches and creates nothing. Never start
|
|
10085
|
+
a new name to get it back \u2014 that is a second proxy. **Deployed code** \u2192
|
|
10086
|
+
\`npx ${cli} create --target <url-or-spec> --auto <door flags>\`. Both print the
|
|
10006
10087
|
URL, the MCP URL (the "agents:" line) and what is locked. Run by you (no terminal), \`dev\`
|
|
10007
10088
|
saves the bootstrap key to \`.env.local\` as \`APIBLAZE_SERVER_KEY\` and never prints it;
|
|
10008
10089
|
\`create\` prints it ONCE \u2014 save it there yourself. Either way say only "saved your key to
|