apiblaze 0.21.42 → 0.21.44
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 +211 -14
- 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.44";
|
|
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({
|
|
@@ -6505,7 +6584,7 @@ paths:
|
|
|
6505
6584
|
post:
|
|
6506
6585
|
operationId: createReservation
|
|
6507
6586
|
summary: Book a table
|
|
6508
|
-
description: Only the time is needed. The restaurant picks a free table and books it in the signed-in person's name, so never ask for a table number or a name.
|
|
6587
|
+
description: Only the time is needed; the day is today unless a date is sent. For tomorrow or any other day, work out the date and send it. The restaurant picks a free table and books it in the signed-in person's name, so never ask for a table number or a name.
|
|
6509
6588
|
requestBody:
|
|
6510
6589
|
required: true
|
|
6511
6590
|
content:
|
|
@@ -6533,7 +6612,7 @@ paths:
|
|
|
6533
6612
|
patch:
|
|
6534
6613
|
operationId: updateReservation
|
|
6535
6614
|
summary: Change a booking
|
|
6536
|
-
description: Move a booking to another table or time, or change the number of guests. Only the person who booked it (or the restaurant's admin) can change it. Get the id from listReservations; never ask for it.
|
|
6615
|
+
description: Move a booking to another day, table or time, or change the number of guests. Only the person who booked it (or the restaurant's admin) can change it. Get the id from listReservations; never ask for it.
|
|
6537
6616
|
requestBody:
|
|
6538
6617
|
required: true
|
|
6539
6618
|
content:
|
|
@@ -6545,7 +6624,7 @@ paths:
|
|
|
6545
6624
|
content:
|
|
6546
6625
|
application/json:
|
|
6547
6626
|
schema: { $ref: '#/components/schemas/Reservation' }
|
|
6548
|
-
'409': { description: That table is already booked at that time }
|
|
6627
|
+
'409': { description: That table is already booked that day at that time }
|
|
6549
6628
|
delete:
|
|
6550
6629
|
operationId: cancelReservation
|
|
6551
6630
|
summary: Cancel a booking
|
|
@@ -6560,12 +6639,14 @@ components:
|
|
|
6560
6639
|
properties:
|
|
6561
6640
|
name: { type: string, example: Ana, description: 'Optional: defaults to the signed-in person. Do not ask for it.' }
|
|
6562
6641
|
table: { type: integer, example: 4, description: 'Optional: the restaurant picks a free table. Do not ask for it.' }
|
|
6642
|
+
date: { type: string, format: date, example: '2026-10-09', description: 'Day of the booking, YYYY-MM-DD. Optional: today if not given.' }
|
|
6563
6643
|
time: { type: string, example: '19:30', description: 'Time of the booking, 24h HH:MM' }
|
|
6564
6644
|
guests: { type: integer, example: 2, description: 'How many people. Optional, 2 if not given.' }
|
|
6565
6645
|
ReservationChange:
|
|
6566
6646
|
type: object
|
|
6567
6647
|
properties:
|
|
6568
6648
|
table: { type: integer, example: 2, description: 'Move to this table.' }
|
|
6649
|
+
date: { type: string, format: date, example: '2026-10-10', description: 'New day, YYYY-MM-DD' }
|
|
6569
6650
|
time: { type: string, example: '20:00', description: 'New time, 24h HH:MM' }
|
|
6570
6651
|
guests: { type: integer, example: 4, description: 'New number of people' }
|
|
6571
6652
|
Reservation:
|
|
@@ -6575,7 +6656,7 @@ components:
|
|
|
6575
6656
|
properties:
|
|
6576
6657
|
id: { type: string, example: r1 }
|
|
6577
6658
|
`,
|
|
6578
|
-
"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 picks the table: the lowest one still free at that time, at that restaurant.\nfunction freeTable(tenant, time) {\n const taken = new Set([...bookings.values()].filter((r) => r.tenant === tenant && 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",
|
|
6579
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",
|
|
6580
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",
|
|
6581
6662
|
"public/index.html": `<!doctype html>
|
|
@@ -6693,6 +6774,7 @@ components:
|
|
|
6693
6774
|
<p class="sub">Book here, or ask the assistant. Each person only sees their own.</p>
|
|
6694
6775
|
<div class="form">
|
|
6695
6776
|
<label><input type="number" id="guests" min="1" max="20" value="2"> people</label>
|
|
6777
|
+
<label>on <input type="date" id="date"></label>
|
|
6696
6778
|
<label>at <input type="time" id="time" value="19:30"></label>
|
|
6697
6779
|
<button class="act primary" id="book">Book a table</button>
|
|
6698
6780
|
</div>
|
|
@@ -6722,6 +6804,16 @@ components:
|
|
|
6722
6804
|
<script src="/apiblaze-widgets.js"></script>
|
|
6723
6805
|
<script>
|
|
6724
6806
|
const $ = (s) => document.querySelector(s);
|
|
6807
|
+
// Days as the backend writes them (YYYY-MM-DD), on this laptop's clock; shown as today / tomorrow / Thu, Oct 9.
|
|
6808
|
+
const localDay = (offset = 0) => { const d = new Date(); d.setDate(d.getDate() + offset); return \`\${d.getFullYear()}-\${String(d.getMonth() + 1).padStart(2, '0')}-\${String(d.getDate()).padStart(2, '0')}\`; };
|
|
6809
|
+
const dayLabel = (day) => {
|
|
6810
|
+
if (!day || day === localDay()) return 'today';
|
|
6811
|
+
if (day === localDay(1)) return 'tomorrow';
|
|
6812
|
+
const [y, m, d] = day.split('-').map(Number);
|
|
6813
|
+
return new Date(y, m - 1, d).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
|
6814
|
+
};
|
|
6815
|
+
// The form books today unless they pick another day (and never a day already gone).
|
|
6816
|
+
$('#date').value = $('#date').min = localDay();
|
|
6725
6817
|
const W = window.ApiblazeWidgets;
|
|
6726
6818
|
const theme = { accent: '#e4572e' };
|
|
6727
6819
|
// Every switch (pizzeria, tab, person) is a fresh page load, the choice kept in the address:
|
|
@@ -6802,7 +6894,7 @@ async function refresh() {
|
|
|
6802
6894
|
const li = document.createElement('li');
|
|
6803
6895
|
if (!seen.has(b.id)) { li.className = 'new'; seen.add(b.id); }
|
|
6804
6896
|
const t = document.createElement('span');
|
|
6805
|
-
t.textContent = \`\${b.guests || 2} people \xB7 \${b.time} \xB7 table \${b.table} \xB7 #\${b.id}\`;
|
|
6897
|
+
t.textContent = \`\${b.guests || 2} people \xB7 \${dayLabel(b.date)} \${b.time} \xB7 table \${b.table} \xB7 #\${b.id}\`;
|
|
6806
6898
|
const x = document.createElement('button'); x.className = 'act'; x.textContent = 'Cancel';
|
|
6807
6899
|
x.onclick = async () => { await act('cancel', { id: b.id }); refresh(); };
|
|
6808
6900
|
li.append(t, x); ul.append(li);
|
|
@@ -6811,8 +6903,9 @@ async function refresh() {
|
|
|
6811
6903
|
}
|
|
6812
6904
|
$('#book').onclick = async () => {
|
|
6813
6905
|
const guests = Math.max(1, Number($('#guests').value) || 2);
|
|
6906
|
+
const date = $('#date').value || localDay();
|
|
6814
6907
|
const time = $('#time').value || '19:30';
|
|
6815
|
-
await act('book', { body: { guests, time } }); // the restaurant picks the table
|
|
6908
|
+
await act('book', { body: { guests, date, time } }); // the restaurant picks the table
|
|
6816
6909
|
refresh();
|
|
6817
6910
|
};
|
|
6818
6911
|
$('#ben-cancel').onclick = async () => {
|
|
@@ -6849,7 +6942,7 @@ function mountChat() {
|
|
|
6849
6942
|
W.chat('#chat', { endpoint: \`/api/apiblaze/chat?pizzeria=\${state.pizzeria}&as=\${me()}\`, mode: 'inline', title: \`\${pz().label} assistant\`, avatar: '\u{1F355}', height: 520, theme,
|
|
6850
6943
|
storageKey: 'pizza-chat-' + key,
|
|
6851
6944
|
welcome: \`Hi \${who}! I can book, list and cancel your tables at \${pz().label}. I'm using the app on your laptop, through APIblaze.\`,
|
|
6852
|
-
suggestions: ['Book a table for 4 people at 8pm', 'What are my reservations?'] });
|
|
6945
|
+
suggestions: ['Book a table for 4 people tomorrow at 8pm', 'What are my reservations?'] });
|
|
6853
6946
|
}
|
|
6854
6947
|
|
|
6855
6948
|
// \u2500\u2500 admin \u2500\u2500
|
|
@@ -7685,6 +7778,7 @@ __export(demo_exports, {
|
|
|
7685
7778
|
DEMO_RPS: () => DEMO_RPS,
|
|
7686
7779
|
demoName: () => demoName,
|
|
7687
7780
|
isDemoProcess: () => isDemoProcess,
|
|
7781
|
+
personaPlan: () => personaPlan,
|
|
7688
7782
|
portFree: () => portFree,
|
|
7689
7783
|
routesOf: () => routesOf,
|
|
7690
7784
|
runDemo: () => runDemo,
|
|
@@ -7694,6 +7788,19 @@ __export(demo_exports, {
|
|
|
7694
7788
|
stopDemos: () => stopDemos,
|
|
7695
7789
|
stopTunnels: () => stopTunnels
|
|
7696
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
|
+
}
|
|
7697
7804
|
function demoName() {
|
|
7698
7805
|
return `demo${pick2(ADJ)}${pick2(NOUN)}${import_crypto.default.randomBytes(2).toString("hex")}`;
|
|
7699
7806
|
}
|
|
@@ -8109,7 +8216,9 @@ paths:`));
|
|
|
8109
8216
|
console.log(import_chalk24.default.cyan(` curl -X POST http://localhost:${backendPort2}/reservations \\`));
|
|
8110
8217
|
console.log(import_chalk24.default.cyan(` -H "x-target-api-key: $(grep BACKEND_SECRET ${import_path5.default.join(rel, ".env.local")} | cut -d= -f2)" \\`));
|
|
8111
8218
|
console.log(import_chalk24.default.cyan(` -H "x-abz-user-id: ana" -H "x-abz-tenant-id: ${ninoTenant}" \\`));
|
|
8112
|
-
|
|
8219
|
+
const tomorrow = new Date(Date.now() + 864e5);
|
|
8220
|
+
const day = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, "0")}-${String(tomorrow.getDate()).padStart(2, "0")}`;
|
|
8221
|
+
console.log(import_chalk24.default.cyan(` -H "content-type: application/json" -d '{"date":"${day}","time":"19:30","guests":2}'`));
|
|
8113
8222
|
if (!await confirmStep(opts, `Ok to put an ${B.product} proxy and MCP in front of the reservation backend?`)) {
|
|
8114
8223
|
return stopHere("proxy", [
|
|
8115
8224
|
`Stopped before ${B.product}. ResiResi keeps running on this laptop: http://localhost:${directPort} ${import_chalk24.default.dim(`(${cdDir}npm run stop stops it)`)}`,
|
|
@@ -8153,9 +8262,19 @@ paths:`));
|
|
|
8153
8262
|
})()
|
|
8154
8263
|
]);
|
|
8155
8264
|
if (!secretOk) throw new DemoError(`Could not give ${B.product} the backend secret.`, `Run again: ${LATEST} demo`, "secret-failed");
|
|
8156
|
-
|
|
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 ?? [] };
|
|
8157
8275
|
});
|
|
8158
|
-
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);
|
|
8159
8278
|
const apiUrl = restUrl(project, version2, "dev", { anon: true });
|
|
8160
8279
|
const agents = mcpUrl(project, version2, "dev", { tenant: tenant2, anon: true });
|
|
8161
8280
|
const applied = setup.applied.map((k) => k.replace(/^\//, "").split("/")[0]);
|
|
@@ -8175,7 +8294,9 @@ paths:`));
|
|
|
8175
8294
|
DEMO_NINO_TENANT: ninoTenant,
|
|
8176
8295
|
APP_PORT: appPort,
|
|
8177
8296
|
BACKEND_PORT: backendPort2,
|
|
8178
|
-
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)
|
|
8179
8300
|
});
|
|
8180
8301
|
const state = { name: project, version: version2, tenant: tenant2, teamId, cpKey, appPort, backendPort: backendPort2, apiUrl, claimCode: setup.claimCode };
|
|
8181
8302
|
saveState(dir, state);
|
|
@@ -8193,6 +8314,7 @@ paths:`));
|
|
|
8193
8314
|
what: ginaOk ? "Ana runs Nino Pizza, Gina runs Gino Pizza" : "Ana runs Nino Pizza",
|
|
8194
8315
|
cmds: [L(`admins add ${DEMO_ADMIN_EMAIL} --tenant ${tenant2}`), ...ginaOk && gino?.tenant ? [L(`admins add ${DEMO_GINO_ADMIN_EMAIL} --tenant ${gino.tenant}`)] : []]
|
|
8195
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)"] }] : [],
|
|
8196
8318
|
...applied.length ? [{ label: "Rules", what: "only who made a reservation can change it", cmds: [L(`rule ${project} "${RULE_SENTENCE}"`)] }] : [],
|
|
8197
8319
|
{ label: "AI (MCP Tools)", what: `${tools.length} tools, one per route`, cmds: ["(part of create, above)"] },
|
|
8198
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}`)] }] : [],
|
|
@@ -8369,6 +8491,7 @@ var init_demo = __esm({
|
|
|
8369
8491
|
init_auth();
|
|
8370
8492
|
init_anon_cred();
|
|
8371
8493
|
init_brand();
|
|
8494
|
+
init_core();
|
|
8372
8495
|
init_create();
|
|
8373
8496
|
init_files();
|
|
8374
8497
|
DIR_NAME = `${B.cli}-demo`;
|
|
@@ -8749,6 +8872,72 @@ async function journey(label3, work, home, checks) {
|
|
|
8749
8872
|
}
|
|
8750
8873
|
return env;
|
|
8751
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 < 60 && !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 60 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
|
+
}
|
|
8752
8941
|
async function runOpAnon(o = {}) {
|
|
8753
8942
|
const t0 = Date.now();
|
|
8754
8943
|
const root = import_fs10.default.mkdtempSync(import_path7.default.join(import_os3.default.tmpdir(), `${B.cli}-op-anon-`));
|
|
@@ -8763,7 +8952,8 @@ async function runOpAnon(o = {}) {
|
|
|
8763
8952
|
log4(import_chalk70.default.bold(`${B.cli} op anon \u2014 a first-time visitor's demo, against production`));
|
|
8764
8953
|
try {
|
|
8765
8954
|
await journey("first", work, home, checks);
|
|
8766
|
-
await journey("rerun", work, home, checks);
|
|
8955
|
+
const env = await journey("rerun", work, home, checks);
|
|
8956
|
+
await claimJourney(work, checks, env);
|
|
8767
8957
|
} finally {
|
|
8768
8958
|
const stop = await runCli(["demo", "stop"], work, home, 3e4);
|
|
8769
8959
|
const demoFolder = import_path7.default.join(work, `${B.cli}-demo`);
|
|
@@ -8793,7 +8983,7 @@ async function runOpAnon(o = {}) {
|
|
|
8793
8983
|
if (failed) process.exitCode = 1;
|
|
8794
8984
|
return { ok: !failed, checks };
|
|
8795
8985
|
}
|
|
8796
|
-
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;
|
|
8797
8987
|
var init_op_anon = __esm({
|
|
8798
8988
|
"src/commands/op-anon.ts"() {
|
|
8799
8989
|
"use strict";
|
|
@@ -8803,7 +8993,11 @@ var init_op_anon = __esm({
|
|
|
8803
8993
|
import_child_process6 = require("child_process");
|
|
8804
8994
|
import_chalk70 = __toESM(require("chalk"));
|
|
8805
8995
|
init_brand();
|
|
8996
|
+
init_api();
|
|
8997
|
+
init_admin();
|
|
8998
|
+
init_types();
|
|
8806
8999
|
SECRET = /\bsk_(?:dev|test|prod)_[A-Za-z0-9]{6,}/;
|
|
9000
|
+
SANDBOX_TEAM = "team_1782844865835_zujrf";
|
|
8807
9001
|
}
|
|
8808
9002
|
});
|
|
8809
9003
|
|
|
@@ -9782,6 +9976,9 @@ function shipMarkdown() {
|
|
|
9782
9976
|
const backendRules = `## What their backend does \u2014 and nothing else
|
|
9783
9977
|
|
|
9784
9978
|
The gateway does sign-in, keys, tenancy and single-record ownership. The backend does only:
|
|
9979
|
+
- provision each person before their first call (what the Users widget's \`ensureEndUser\` does): an
|
|
9980
|
+
unprovisioned caller arrives as the raw id the app asserted, a provisioned one as their stable id, and
|
|
9981
|
+
a row stamped with the former is not found by the latter;
|
|
9785
9982
|
- stamp each new row's \`tenant\` with the \`x-abz-tenant-id\` header and its \`owner\` with the
|
|
9786
9983
|
\`x-abz-user-id\` header;
|
|
9787
9984
|
- filter every collection GET to rows whose \`tenant\` AND \`owner\` match those headers. Both,
|