apiblaze 0.21.6 → 0.21.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +6 -2
  2. package/dist/index.js +147 -41
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -96,9 +96,13 @@ The skill teaches Claude to ask "where do your users sign in today?" before crea
96
96
  npx apiblaze create --target https://api.example.com
97
97
 
98
98
  # 2. YOUR OWN OAuth app on a hosted login page — the agent's users sign in exactly where
99
- # your users already do. provider is one of: auth0 · google · github · microsoft · facebook.
99
+ # your users already do. provider is one of: oidc · auth0 · google · github · microsoft · facebook.
100
+ # clientSecret is optional for every provider except github and facebook (public client, PKCE is always on).
100
101
  npx apiblaze create --target https://api.example.com \
101
- --oauth «'{"provider":"auth0","clientId":"AbC123…","clientSecret":"s3cr3t…"}'»
102
+ --oauth «'{"provider":"auth0","issuer":"https://acme.auth0.com/","clientId":"AbC123…","clientSecret":"s3cr3t…"}'»
103
+ # Any OpenID Connect IdP (Okta, Entra, Keycloak, Cognito, …) is `oidc` + its issuer URL:
104
+ npx apiblaze create --target https://api.example.com \
105
+ --oauth «'{"provider":"oidc","issuer":"https://acme.okta.com/oauth2/default","clientId":"0oa…"}'»
102
106
 
103
107
  # 3. YOUR OWN JWT issuer — no login page at all; the proxy verifies the tokens you already
104
108
  # issue. All three fields are required; jwks is that issuer's JWKS URL.
package/dist/index.js CHANGED
@@ -1149,7 +1149,7 @@ async function addApiblazeHostedLogin(teamId, tenant2, opts = {}) {
1149
1149
  message: "Which login provider?",
1150
1150
  pageSize: 10,
1151
1151
  choices: [
1152
- ...PROVIDERS.map((p) => ({ name: p.label, value: p.id })),
1152
+ ...PROVIDERS2.map((p) => ({ name: p.label, value: p.id })),
1153
1153
  ...opts.allowSkip ? [new inquirer3.Separator(), { name: import_chalk44.default.dim("Skip for now \u2014 set up a login method later"), value: null }] : []
1154
1154
  ]
1155
1155
  }]);
@@ -1157,7 +1157,7 @@ async function addApiblazeHostedLogin(teamId, tenant2, opts = {}) {
1157
1157
  console.log(import_chalk44.default.dim(" No login method yet \u2014 add one later under Login methods."));
1158
1158
  return false;
1159
1159
  }
1160
- const opt = PROVIDERS.find((p) => p.id === provider);
1160
+ const opt = PROVIDERS2.find((p) => p.id === provider);
1161
1161
  let providerBody;
1162
1162
  if (!opt.own) {
1163
1163
  providerBody = { type: "github", managed: true };
@@ -1215,14 +1215,14 @@ async function createTenantInteractive(teamId) {
1215
1215
  await addApiblazeHostedLogin(teamId, tenant2, { allowSkip: true });
1216
1216
  return tenant2;
1217
1217
  }
1218
- var import_chalk44, import_ora17, PROVIDERS, DEFAULT_SCOPES;
1218
+ var import_chalk44, import_ora17, PROVIDERS2, DEFAULT_SCOPES;
1219
1219
  var init_tenant_create = __esm({
1220
1220
  "src/lib/tenant-create.ts"() {
1221
1221
  "use strict";
1222
1222
  import_chalk44 = __toESM(require("chalk"));
1223
1223
  import_ora17 = __toESM(require("ora"));
1224
1224
  init_admin();
1225
- PROVIDERS = [
1225
+ PROVIDERS2 = [
1226
1226
  { id: "apiblaze", label: "APIblaze (via GitHub) \u2014 zero setup, recommended", own: false },
1227
1227
  { id: "google", label: "Google", own: true },
1228
1228
  { id: "github", label: "GitHub", own: true },
@@ -1337,7 +1337,7 @@ var import_commander = require("commander");
1337
1337
  var import_chalk62 = __toESM(require("chalk"));
1338
1338
 
1339
1339
  // package.json
1340
- var version = "0.21.6";
1340
+ var version = "0.21.8";
1341
1341
 
1342
1342
  // src/index.ts
1343
1343
  init_types();
@@ -4675,6 +4675,44 @@ init_api();
4675
4675
  var import_chalk13 = __toESM(require("chalk"));
4676
4676
  var import_ora3 = __toESM(require("ora"));
4677
4677
  init_caller();
4678
+ var HOLDER_HELP = {
4679
+ backend: "Lives on your server; says who is calling with X-End-User-Id.",
4680
+ client: "Safe to ship in a browser, app or widget; cannot name a user; calls are anonymous unless the user signs in."
4681
+ };
4682
+ function holderPermissions(kind) {
4683
+ return kind === "backend" ? ["call", "assert-end-user"] : ["call"];
4684
+ }
4685
+ function resolveHolder(opts) {
4686
+ const who = (opts.for ?? "").trim();
4687
+ if (opts.backend && opts.client) throw new Error("--backend and --client are mutually exclusive: a key is either kept on your server or shipped to users.");
4688
+ if (who && (opts.backend || opts.client)) throw new Error(`--for issues the key to ${who} (a person-bound key): drop --backend / --client.`);
4689
+ if (who) return "person";
4690
+ return opts.client ? "client" : "backend";
4691
+ }
4692
+ function holderKindOf(record) {
4693
+ const cm = record.creation_method;
4694
+ if (cm === "self" || cm === "on_behalf") return "person";
4695
+ let perms = record.permissions ?? record.permissions_json;
4696
+ if (typeof perms === "string") {
4697
+ try {
4698
+ perms = JSON.parse(perms);
4699
+ } catch {
4700
+ perms = void 0;
4701
+ }
4702
+ }
4703
+ if (!Array.isArray(perms)) return cm == null ? void 0 : "client";
4704
+ return perms.includes("assert-end-user") ? "backend" : "client";
4705
+ }
4706
+ function holderLine(kind, person) {
4707
+ switch (kind) {
4708
+ case "backend":
4709
+ return "backend key \u2014 send X-API-Key and say who is calling with X-End-User-Id";
4710
+ case "client":
4711
+ return "client key \u2014 safe to ship to users; it cannot name a user (calls are anonymous unless they sign in)";
4712
+ case "person":
4713
+ return `issued to: ${person ?? "?"} \u2014 calls made with this key count as this person; an X-End-User-Id naming anyone else is refused`;
4714
+ }
4715
+ }
4678
4716
  async function runApikeysMenu(opts) {
4679
4717
  await runKeyList(opts);
4680
4718
  if (opts.json) return;
@@ -4706,11 +4744,14 @@ async function runKeyList(opts) {
4706
4744
  return;
4707
4745
  }
4708
4746
  for (const k of keys) {
4709
- console.log(` ${import_chalk13.default.bold(k.key_id ?? k.id)} ${import_chalk13.default.dim(k.description ?? "")} ${import_chalk13.default.dim(k.expires_at ?? "no expiry")}`);
4747
+ const kind = holderKindOf(k);
4748
+ const kindCol = kind ? `${import_chalk13.default.cyan(kind.padEnd(7))} ` : "";
4749
+ console.log(` ${import_chalk13.default.bold(k.key_id ?? k.id)} ${kindCol}${import_chalk13.default.dim(k.description ?? "")} ${import_chalk13.default.dim(k.expires_at ?? "no expiry")}`);
4710
4750
  }
4711
4751
  }
4712
4752
  async function runKeyMint(opts) {
4713
- if (opts.tenant || opts.for) return runConsumerKeyMint(opts);
4753
+ resolveHolder(opts);
4754
+ if (opts.tenant || opts.for || opts.client) return runConsumerKeyMint(opts);
4714
4755
  const caller = requireCaller();
4715
4756
  const { teamId } = await resolveActingTeam(caller, opts.team);
4716
4757
  const body = { role: "consumer-admin" };
@@ -4739,14 +4780,22 @@ async function runKeyMint(opts) {
4739
4780
  }
4740
4781
  async function mintConsumerKey(o) {
4741
4782
  const tenant2 = (o.tenant ?? "").trim();
4742
- if (!tenant2) throw new Error("--for needs a tenant: add --tenant <slug> (the tenant whose person this key is for).");
4743
4783
  const who = (o.for ?? "").trim();
4784
+ if (!tenant2) {
4785
+ throw new Error(who ? "--for needs a tenant: add --tenant <slug> (the tenant whose person this key is for)." : `a ${o.holder ?? "backend"} key is a consumer key: add --tenant <slug> (the tenant it calls).`);
4786
+ }
4744
4787
  const environment = (o.env ?? "").trim() || "dev";
4788
+ const holder = who ? "person" : o.holder ?? "backend";
4789
+ const permissions = holder === "person" ? void 0 : holderPermissions(holder);
4745
4790
  const caller = requireCaller();
4746
4791
  const { teamId } = await resolveActingTeam(caller, o.team);
4747
4792
  const body = { tenant: tenant2, environment };
4748
4793
  if (o.desc) body.description = o.desc;
4749
4794
  if (o.expiresDays) body.expires_in_seconds = Number(o.expiresDays) * 24 * 60 * 60;
4795
+ if (permissions) {
4796
+ body.holder = holder;
4797
+ body.permissions = permissions;
4798
+ }
4750
4799
  const headers = { "Content-Type": "application/json" };
4751
4800
  if (who) {
4752
4801
  headers["X-End-User-Id"] = who;
@@ -4767,16 +4816,35 @@ async function mintConsumerKey(o) {
4767
4816
  });
4768
4817
  const key = out?.key ?? out?.fullKey;
4769
4818
  if (typeof key !== "string" || !key) throw new Error("Mint failed: the apikeys plane returned no key.");
4770
- return { key, keyId: out?.metadata?.key_id, expiresAt: out?.metadata?.expires_at, tenant: tenant2, environment, ...who ? { person: who } : {}, raw: out };
4819
+ return {
4820
+ key,
4821
+ keyId: out?.metadata?.key_id,
4822
+ expiresAt: out?.metadata?.expires_at,
4823
+ tenant: tenant2,
4824
+ environment,
4825
+ holder,
4826
+ ...permissions ? { permissions } : {},
4827
+ ...who ? { person: who } : {},
4828
+ raw: out
4829
+ };
4771
4830
  }
4772
4831
  async function runConsumerKeyMint(opts) {
4832
+ const kind = resolveHolder(opts);
4773
4833
  const who = (opts.for ?? "").trim();
4774
4834
  const tenant2 = (opts.tenant ?? "").trim();
4775
- const spinner = (0, import_ora3.default)(who ? `Minting a key for ${who} in ${tenant2}...` : `Minting a key in ${tenant2}...`).start();
4835
+ const spinner = (0, import_ora3.default)(who ? `Minting a key for ${who} in ${tenant2}...` : `Minting a ${kind} key in ${tenant2}...`).start();
4776
4836
  let out;
4777
4837
  try {
4778
- out = await mintConsumerKey({ tenant: tenant2, for: who || void 0, env: opts.env, team: opts.team, desc: opts.desc, expiresDays: opts.expiresDays });
4779
- spinner.succeed(who ? `Key minted for ${who}.` : "Key minted.");
4838
+ out = await mintConsumerKey({
4839
+ tenant: tenant2,
4840
+ for: who || void 0,
4841
+ env: opts.env,
4842
+ team: opts.team,
4843
+ desc: opts.desc,
4844
+ expiresDays: opts.expiresDays,
4845
+ ...kind === "person" ? {} : { holder: kind }
4846
+ });
4847
+ spinner.succeed(who ? `Key minted for ${who}.` : `${kind === "client" ? "Client" : "Backend"} key minted.`);
4780
4848
  } catch (err) {
4781
4849
  spinner.fail("Mint failed.");
4782
4850
  throw err;
@@ -4789,10 +4857,11 @@ async function runConsumerKeyMint(opts) {
4789
4857
  console.log(` ${import_chalk13.default.bold("key")}: ${import_chalk13.default.green(out.key)} ${import_chalk13.default.dim("(shown once \u2014 store it now)")}`);
4790
4858
  console.log(` ${import_chalk13.default.dim("tenant:")} ${out.tenant} ${import_chalk13.default.dim("env:")} ${out.environment}`);
4791
4859
  if (out.expiresAt) console.log(` ${import_chalk13.default.dim("expires:")} ${out.expiresAt}`);
4792
- if (out.person) {
4860
+ if (out.holder === "person") {
4793
4861
  console.log(` ${import_chalk13.default.dim("issued to:")} ${out.person} ${import_chalk13.default.dim("\u2014 calls made with this key count as this person; an X-End-User-Id naming anyone else is refused")}`);
4794
4862
  } else {
4795
- console.log(import_chalk13.default.dim(" Not issued to anyone: send X-End-User-Id on each call to say who is calling (or re-mint with --for <email>)."));
4863
+ console.log(` ${import_chalk13.default.cyan(holderLine(out.holder))}`);
4864
+ console.log(import_chalk13.default.dim(out.holder === "backend" ? " (For a key you ship to users, re-mint with --client; to bind one to a person, --for <email>.)" : " (For your server, re-mint with --backend; to bind one to a person, --for <email>.)"));
4796
4865
  }
4797
4866
  }
4798
4867
  async function runKeyRevoke(keyId, opts) {
@@ -4961,12 +5030,18 @@ var MANAGED_GITHUB = {
4961
5030
  };
4962
5031
  var API_KEY = { auth: "api_key", doors: ["api_key"], bodyPatch: {}, summary: "callers send the API key (X-API-Key) \u2014 no sign-in" };
4963
5032
  var OPEN = { auth: "none", doors: [], bodyPatch: {}, summary: "open \u2014 no credential required" };
5033
+ var PROVIDERS = ["github", "google", "microsoft", "facebook", "auth0", "oidc"];
5034
+ var ISSUER_PROVIDERS = /* @__PURE__ */ new Set(["oidc", "auth0"]);
5035
+ var SECRET_REQUIRED_TYPES = /* @__PURE__ */ new Set(["github", "facebook"]);
4964
5036
  var OAUTH_SHAPES = `Shapes:
4965
5037
  --oauth ${B.product}-hosted GitHub sign-in
4966
5038
  --oauth '{"iss":"https://login.acme.com/","aud":"acme-api","jwks":"https://login.acme.com/.well-known/jwks.json"}'
4967
5039
  your own hosted login: the proxy trusts JWTs from that issuer
4968
5040
  --oauth '{"provider":"google","clientId":"\u2026","clientSecret":"\u2026"}'
4969
- ${B.product}-hosted login page with YOUR OAuth app (github \xB7 google \xB7 microsoft \xB7 facebook \xB7 auth0)`;
5041
+ ${B.product}-hosted login page with YOUR OAuth app (github \xB7 google \xB7 microsoft \xB7 facebook \xB7 auth0 \xB7 oidc)
5042
+ --oauth '{"provider":"oidc","issuer":"https://acme.okta.com/oauth2/default","clientId":"\u2026"}'
5043
+ any OpenID Connect IdP (Okta \xB7 Entra \xB7 Keycloak \xB7 Cognito \xB7 \u2026) by issuer; clientSecret is optional
5044
+ for every provider except github and facebook (public client, PKCE); "scopes":[\u2026] overrides the default`;
4970
5045
  function parseOauthFlag(raw) {
4971
5046
  if (raw === void 0 || raw === false) return null;
4972
5047
  if (raw === true || typeof raw === "string" && raw.trim() === "") return MANAGED_GITHUB;
@@ -4999,21 +5074,34 @@ function parseOauthFlag(raw) {
4999
5074
  }
5000
5075
  if (typeof parsed.provider === "string") {
5001
5076
  const provider = parsed.provider.trim().toLowerCase();
5002
- const PROVIDERS2 = ["github", "google", "microsoft", "facebook", "auth0"];
5003
- if (!PROVIDERS2.includes(provider)) fail4(`--oauth: unknown provider "${provider}". One of: ${PROVIDERS2.join(" \xB7 ")}.`);
5077
+ if (!PROVIDERS.includes(provider)) fail4(`--oauth: unknown provider "${provider}". One of: ${PROVIDERS.join(" \xB7 ")}.`, OAUTH_SHAPES);
5004
5078
  const clientId = typeof parsed.clientId === "string" ? parsed.clientId.trim() : typeof parsed.client_id === "string" ? parsed.client_id.trim() : "";
5005
5079
  const clientSecret = typeof parsed.clientSecret === "string" ? parsed.clientSecret.trim() : typeof parsed.client_secret === "string" ? parsed.client_secret.trim() : "";
5006
- if (!clientId || !clientSecret) fail4("--oauth provider shape needs clientId and clientSecret (from your OAuth app).");
5080
+ if (!clientId) fail4("--oauth provider shape needs clientId (from your OAuth app).", OAUTH_SHAPES);
5081
+ if (SECRET_REQUIRED_TYPES.has(provider) && !clientSecret) fail4(`--oauth provider "${provider}" needs clientSecret too (its OAuth apps cannot be public clients).`, OAUTH_SHAPES);
5082
+ const issuer = typeof parsed.issuer === "string" ? parsed.issuer.trim() : "";
5083
+ if (ISSUER_PROVIDERS.has(provider) && !issuer) fail4(`--oauth provider "${provider}" needs "issuer" (your IdP's issuer URL, e.g. https://acme.okta.com/oauth2/default).`, OAUTH_SHAPES);
5084
+ if (issuer && !isHttpUrl3(issuer)) fail4("--oauth: issuer must be an http(s) URL.", OAUTH_SHAPES);
5085
+ if (parsed.scopes !== void 0 && (!Array.isArray(parsed.scopes) || !parsed.scopes.every((s) => typeof s === "string" && s.trim()))) {
5086
+ fail4('--oauth: scopes must be an array of non-empty strings, e.g. ["openid","email","profile"].', OAUTH_SHAPES);
5087
+ }
5088
+ const scopes = Array.isArray(parsed.scopes) ? parsed.scopes.map((s) => s.trim()) : void 0;
5007
5089
  return {
5008
5090
  auth: "oauth",
5009
5091
  doors: ["oauth"],
5010
5092
  bodyPatch: {
5011
5093
  login: {
5012
- providers: [{ type: provider, client_id: clientId, client_secret: clientSecret }]
5094
+ providers: [{
5095
+ type: provider,
5096
+ client_id: clientId,
5097
+ ...clientSecret ? { client_secret: clientSecret } : {},
5098
+ ...issuer ? { issuer } : {},
5099
+ ...scopes && scopes.length ? { scopes } : {}
5100
+ }]
5013
5101
  }
5014
5102
  },
5015
- summary: `callers sign in with your own ${provider} app (${B.product}-hosted page)`,
5016
- provider: cap(provider),
5103
+ summary: provider === "oidc" ? `callers sign in through your OpenID Connect provider (${issuer}, ${B.product}-hosted page)` : `callers sign in with your own ${provider} app (${B.product}-hosted page)`,
5104
+ provider: provider === "oidc" ? "OpenID Connect" : cap(provider),
5017
5105
  ownApp: true
5018
5106
  };
5019
5107
  }
@@ -5372,7 +5460,7 @@ async function printDevOutcome(o, port) {
5372
5460
  if (!o.adminEmail) console.log(` ${import_chalk16.default.dim("\u2013")} ${import_chalk16.default.dim(`no admin yet \u2014 npx ${B.cli} admins add you@example.com --tenant ${o.tenant ?? "<tenant>"}`)}`);
5373
5461
  if (o.auth.doors.includes("api_key") && o.apiKey) {
5374
5462
  console.log();
5375
- console.log(` ${import_chalk16.default.dim("API key (for your frontend-server, shown once):")} ${import_chalk16.default.bold.green(o.apiKey)} ${import_chalk16.default.dim("\u2192 send it as X-API-Key and say who is calling with X-End-User-Id")}`);
5463
+ console.log(` ${import_chalk16.default.dim("API key (backend key, for your frontend-server, shown once):")} ${import_chalk16.default.bold.green(o.apiKey)} ${import_chalk16.default.dim("\u2192 send it as X-API-Key and say who is calling with X-End-User-Id")}`);
5376
5464
  }
5377
5465
  const okLines = agentPrompts(locked, o.openapi, null).filter((l) => l.ok);
5378
5466
  const tryHint = okLines[1]?.say ?? okLines[0]?.say;
@@ -5415,7 +5503,7 @@ async function printOutcome(o) {
5415
5503
  }
5416
5504
  if (o.auth.doors.includes("api_key") && o.apiKey) {
5417
5505
  console.log();
5418
- console.log(` ${import_chalk16.default.dim("API key (for your backend and widget \u2014 shown once, save it now):")}`);
5506
+ console.log(` ${import_chalk16.default.dim("API key (backend key \u2014 for your backend and widget; shown once, save it now):")}`);
5419
5507
  console.log(` ${import_chalk16.default.bold.green(o.apiKey)}`);
5420
5508
  console.log(import_chalk16.default.dim(" Send it as X-API-Key and say who is calling with X-End-User-Id."));
5421
5509
  const otherEnvs = Object.keys(o.keys).filter((e) => o.keys[e] !== o.apiKey);
@@ -5425,6 +5513,7 @@ async function printOutcome(o) {
5425
5513
  printTryIt(buildTryItCurl(url, o.auth.auth, o.apiKey, o.tenant, o.auth.ownIssuer), [o.tenant, "$TOKEN", o.apiKey, "X-End-User-Id: you"]);
5426
5514
  if (o.auth.auth === "api_key" && o.apiKey) {
5427
5515
  console.log(import_chalk16.default.dim(" X-End-User-Id = who your server is acting for; the rules check that person (any handle works for a test)."));
5516
+ console.log(import_chalk16.default.dim(` Keys you ship to users (browser, app, widget) must not name anyone: npx ${B.cli} apikeys mint --client --tenant ${o.tenant ?? "<tenant>"}`));
5428
5517
  }
5429
5518
  if (mcp !== shown) {
5430
5519
  console.log();
@@ -13307,6 +13396,18 @@ async function runnerCall(body, timeoutS) {
13307
13396
  return data;
13308
13397
  }
13309
13398
  var call = runnerCall;
13399
+ var TEARDOWN_GUT_ID = "sandbox-key-scrub";
13400
+ async function scrubSandboxKeys(timeoutS = 150) {
13401
+ try {
13402
+ const r = await runnerCall({ action: "gut", id: TEARDOWN_GUT_ID }, timeoutS);
13403
+ const first = r.results?.[0]?.detail ?? r.error ?? "";
13404
+ log(import_chalk58.default.dim(`teardown: ${first || (r.ok ? "sandbox fixture keys scrubbed" : "scrub reported a problem")}`));
13405
+ return r.ok;
13406
+ } catch (e) {
13407
+ log(import_chalk58.default.dim(`teardown: sandbox key scrub skipped (${e instanceof Error ? e.message : String(e)}) \u2014 leftovers expire within 14 days; scripts/purge-test-keys.mjs reaps them`));
13408
+ return false;
13409
+ }
13410
+ }
13310
13411
  var matches = (id, group2, o) => {
13311
13412
  if (o.only) {
13312
13413
  const wanted = o.only.split(",").map((s) => s.trim()).filter(Boolean);
@@ -13322,7 +13423,7 @@ async function runOpTest(o = {}) {
13322
13423
  const timeoutS = o.timeout && o.timeout > 0 ? o.timeout : 150;
13323
13424
  const listing = await call({ action: "list" }, 60);
13324
13425
  const specs = o.gutsOnly ? [] : listing.specs.filter((s) => matches(s.id, s.group, o));
13325
- const guts = o.skipGuts ? [] : listing.guts.filter((g) => matches(g.id, void 0, { ...o, group: void 0 }));
13426
+ const guts = o.skipGuts ? [] : listing.guts.filter((g) => g.id !== TEARDOWN_GUT_ID && matches(g.id, void 0, { ...o, group: void 0 }));
13326
13427
  const total = specs.length + guts.length;
13327
13428
  if (total === 0) {
13328
13429
  log(import_chalk58.default.yellow("nothing matched"));
@@ -13344,27 +13445,31 @@ async function runOpTest(o = {}) {
13344
13445
  else for (const x of r.results.filter((x2) => x2 && x2.ok === false)) log(import_chalk58.default.red(` \u2717 ${x.name ?? ""}: ${x.detail ?? JSON.stringify(x)}`));
13345
13446
  }
13346
13447
  };
13347
- for (const s of specs) {
13348
- let r;
13349
- try {
13350
- r = await call({ action: "spec", id: s.id }, timeoutS);
13351
- } catch (e) {
13352
- r = { kind: "spec", id: s.id, label: s.label, group: s.group, ok: false, cases: [{ name: "runner", ok: false, detail: e instanceof Error ? e.message : String(e) }], duration_ms: 0 };
13353
- }
13354
- record(r);
13355
- if (!r.ok && o.failFast) break;
13356
- }
13357
- if (!(o.failFast && failed)) {
13358
- for (const g of guts) {
13448
+ try {
13449
+ for (const s of specs) {
13359
13450
  let r;
13360
13451
  try {
13361
- r = await call({ action: "gut", id: g.id }, timeoutS);
13452
+ r = await call({ action: "spec", id: s.id }, timeoutS);
13362
13453
  } catch (e) {
13363
- r = { kind: "gut", id: g.id, label: g.label, ok: false, results: [], error: e instanceof Error ? e.message : String(e), duration_ms: 0 };
13454
+ r = { kind: "spec", id: s.id, label: s.label, group: s.group, ok: false, cases: [{ name: "runner", ok: false, detail: e instanceof Error ? e.message : String(e) }], duration_ms: 0 };
13364
13455
  }
13365
13456
  record(r);
13366
13457
  if (!r.ok && o.failFast) break;
13367
13458
  }
13459
+ if (!(o.failFast && failed)) {
13460
+ for (const g of guts) {
13461
+ let r;
13462
+ try {
13463
+ r = await call({ action: "gut", id: g.id }, timeoutS);
13464
+ } catch (e) {
13465
+ r = { kind: "gut", id: g.id, label: g.label, ok: false, results: [], error: e instanceof Error ? e.message : String(e), duration_ms: 0 };
13466
+ }
13467
+ record(r);
13468
+ if (!r.ok && o.failFast) break;
13469
+ }
13470
+ }
13471
+ } finally {
13472
+ await scrubSandboxKeys(timeoutS);
13368
13473
  }
13369
13474
  const summary = { ran: results.length, passed: results.length - failed, failed, duration_ms: Date.now() - started, sandbox_team: listing.sandbox_team };
13370
13475
  log("");
@@ -13772,6 +13877,7 @@ async function runOpPerf(o = {}) {
13772
13877
  log3(import_chalk60.default.red(`teardown of ${fixture.proxy} failed: ${e instanceof Error ? e.message : String(e)} \u2014 run \`apiblaze op residue\` / clean the sandbox`));
13773
13878
  process.exitCode = 1;
13774
13879
  }
13880
+ await scrubSandboxKeys(120);
13775
13881
  }
13776
13882
  }
13777
13883
  }
@@ -14214,7 +14320,7 @@ program.command("login").description(`Authenticate with ${B.product}`).option("-
14214
14320
  process.exit(1);
14215
14321
  }
14216
14322
  });
14217
- program.command("create").description(`Create a new ${B.thing} \u2014 both doors open by default (an API key for your backend and widget, GitHub sign-in for people and agents) and resources locked to their creator (no login needed: logged out it creates an anonymous ${B.thingShort} you can claim)`).option("--name <name>", B.cli === "mcpblaze" ? "MCP server name (becomes <name>-<tenant>.mcpblaze.com)" : "Proxy name (becomes <name>.abz.run)").option("--target <url|file>", "What to proxy \u2014 pass ANY of: a target server base URL (https://httpbin.org), a local OpenAPI file (./openapi.yaml), or a remote OpenAPI URL (https://acme.com/openapi.yaml). Spec inputs are detected automatically; routes, API version and environments then come from the spec").addOption(new import_commander.Option("--openapi <file|url>", "Deprecated alias \u2014 --target now detects spec files/URLs itself").hideHelp()).addOption(new import_commander.Option("--openapispec <file|url>", "Deprecated alias for --openapi").hideHelp()).option("--team <id|name>", "Team to create under (defaults to your active team)").option("--auth <type>", "Open ONE door only: api_key | oauth (sign in with GitHub, APIblaze-hosted) | none. Omitted = both doors.").option("--apikey", "API-key door only (no sign-in): the bootstrap keys are minted and printed; callers send X-API-Key, agents connect with a key.").option("--oauth [config]", `Sign-in door only (no API key). Bare = APIblaze-hosted GitHub sign-in. '{"iss","aud","jwks"}' = trust YOUR hosted login's JWTs. '{"provider","clientId","clientSecret"}' = APIblaze-hosted login page with YOUR OAuth app (github \xB7 google \xB7 microsoft \xB7 facebook \xB7 auth0).`).option("--identified", "Require every call to identify its end user (X-End-User-Id or a login token); unattributed calls are rejected").option("--iam", `Turn IAM on for the ${B.thingShort}'s tenant so users & groups apply to identified calls`).option("--apiversion <version>", `API version to create (e.g. 2.0.0). Creating a new version of a ${B.thingShort} you own adds a version to the existing project.`).option("--tenant <slug>", `Tenant to attach the ${B.thingShort} to (created if new). Omitted \u2192 your team's most-recently-used tenant.`).option("--product <slug>", "Product tag to group this project under in the portal (team-scoped; anonymous create). Defaults to your team's existing/placeholder tag.").option("--display-name <name>", "Human-friendly display name").option("--subdomain <slug>", "Explicit subdomain (defaults to --name)").option("--config <file>", "JSON file with the full request body (anonymous create): requests_auth, login providers + client/server token types, scopes, callback URLs, etc. See apiblaze_anonymous.yaml. Flags override its fields.").option("-y, --yes", "Skip the confirmation prompt").option("--auto", `Zero prompts, start to finish: generates the ${B.thingShort} name when --name is omitted (from the target host, salted), takes every confirmation as yes, auto-picks the tenant. Implies --yes.`).option("--new-session", "Start a fresh anonymous session (do not group with prior anonymous creates)").option("--json", "Output machine-readable JSON (non-interactive)").action(async (opts) => {
14323
+ program.command("create").description(`Create a new ${B.thing} \u2014 both doors open by default (an API key for your backend and widget, GitHub sign-in for people and agents) and resources locked to their creator (no login needed: logged out it creates an anonymous ${B.thingShort} you can claim)`).option("--name <name>", B.cli === "mcpblaze" ? "MCP server name (becomes <name>-<tenant>.mcpblaze.com)" : "Proxy name (becomes <name>.abz.run)").option("--target <url|file>", "What to proxy \u2014 pass ANY of: a target server base URL (https://httpbin.org), a local OpenAPI file (./openapi.yaml), or a remote OpenAPI URL (https://acme.com/openapi.yaml). Spec inputs are detected automatically; routes, API version and environments then come from the spec").addOption(new import_commander.Option("--openapi <file|url>", "Deprecated alias \u2014 --target now detects spec files/URLs itself").hideHelp()).addOption(new import_commander.Option("--openapispec <file|url>", "Deprecated alias for --openapi").hideHelp()).option("--team <id|name>", "Team to create under (defaults to your active team)").option("--auth <type>", "Open ONE door only: api_key | oauth (sign in with GitHub, APIblaze-hosted) | none. Omitted = both doors.").option("--apikey", "API-key door only (no sign-in): the bootstrap keys are minted and printed; callers send X-API-Key, agents connect with a key.").option("--oauth [config]", `Sign-in door only (no API key). Bare = APIblaze-hosted GitHub sign-in. '{"iss","aud","jwks"}' = trust YOUR hosted login's JWTs. '{"provider","clientId","clientSecret"?}' = APIblaze-hosted login page with YOUR OAuth app (github \xB7 google \xB7 microsoft \xB7 facebook \xB7 auth0 \xB7 oidc); the secret is optional except for github and facebook. '{"provider":"oidc","issuer":"https://acme.okta.com/oauth2/default","clientId":"\u2026"}' = any OpenID Connect IdP by issuer (Okta \xB7 Entra \xB7 Keycloak \xB7 Cognito \xB7 \u2026).`).option("--identified", "Require every call to identify its end user (X-End-User-Id from a backend key, or a login token \u2014 a client key alone cannot); unattributed calls are rejected").option("--iam", `Turn IAM on for the ${B.thingShort}'s tenant so users & groups apply to identified calls`).option("--apiversion <version>", `API version to create (e.g. 2.0.0). Creating a new version of a ${B.thingShort} you own adds a version to the existing project.`).option("--tenant <slug>", `Tenant to attach the ${B.thingShort} to (created if new). Omitted \u2192 your team's most-recently-used tenant.`).option("--product <slug>", "Product tag to group this project under in the portal (team-scoped; anonymous create). Defaults to your team's existing/placeholder tag.").option("--display-name <name>", "Human-friendly display name").option("--subdomain <slug>", "Explicit subdomain (defaults to --name)").option("--config <file>", "JSON file with the full request body (anonymous create): requests_auth, login providers + client/server token types, scopes, callback URLs, etc. See apiblaze_anonymous.yaml. Flags override its fields.").option("-y, --yes", "Skip the confirmation prompt").option("--auto", `Zero prompts, start to finish: generates the ${B.thingShort} name when --name is omitted (from the target host, salted), takes every confirmation as yes, auto-picks the tenant. Implies --yes.`).option("--new-session", "Start a fresh anonymous session (do not group with prior anonymous creates)").option("--json", "Output machine-readable JSON (non-interactive)").action(async (opts) => {
14218
14324
  try {
14219
14325
  await runCreate({ ...opts, openapi: opts.openapi ?? opts.openapispec });
14220
14326
  } catch (err) {
@@ -14348,10 +14454,10 @@ admins.command("add").description("Add an email as a tenant admin (e.g. yourself
14348
14454
  admins.command("remove").description("Remove a tenant admin").argument("<email>", "Email to remove").option("--tenant <slug>", "Tenant slug").option("--team <id|name>", "Team").option("--json", "JSON").action(action((email, opts, cmd) => runAdminsRemove(email, { ...cmd.parent?.opts(), ...opts })));
14349
14455
  program.addCommand(admins);
14350
14456
  program.command("iam").description("Turn users & groups on/off for a proxy's tenant (identified calls get their user's groups applied)").argument("<project>", "Project name or id").argument("<state>", "on | off").option("--tenant <name>", "Tenant to toggle (defaults to the project's tenant)").option("--team <id|name>", "Team (defaults to active team, or your anonymous workspace)").option("--apiversion <version>", "API version (defaults to the first)").option("--json", "Output machine-readable JSON").action(action((project, state, opts) => runIamToggle(project, state, opts)));
14351
- program.command("identified").description("Require calls to identify their end user (X-End-User-Id or a login token) \u2014 or allow unattributed calls again").argument("<project>", "Project name or id").argument("<mode>", "require | allow-anon").option("--team <id|name>", "Team (defaults to active team, or your anonymous workspace)").option("--apiversion <version>", "API version (defaults to the first)").option("--json", "Output machine-readable JSON").action(action((project, mode, opts) => runIdentifiedToggle(project, mode, opts)));
14457
+ program.command("identified").description("Require calls to identify their end user (X-End-User-Id from a backend key, or a login token \u2014 a client key alone cannot) \u2014 or allow unattributed calls again").argument("<project>", "Project name or id").argument("<mode>", "require | allow-anon").option("--team <id|name>", "Team (defaults to active team, or your anonymous workspace)").option("--apiversion <version>", "API version (defaults to the first)").option("--json", "Output machine-readable JSON").action(action((project, mode, opts) => runIdentifiedToggle(project, mode, opts)));
14352
14458
  var apikeys = new import_commander.Command("apikeys").description("Producer control-plane API keys (list, then offer to create one)").option("--team <id|name>", "Team (defaults to active team)").option("--json", "Output machine-readable JSON").action(action((opts) => runApikeysMenu(opts)));
14353
14459
  apikeys.command("list").description("List control-plane API keys in your team").option("--team <id|name>", "Team (defaults to active team)").option("--json", "Output machine-readable JSON").action(action((opts, cmd) => runKeyList({ ...cmd.parent?.opts(), ...opts })));
14354
- apikeys.command("mint").description("Create a control-plane API key (secret shown once) \u2014 or, with --tenant, a consumer key in that tenant").option("--desc <text>", "Description").option("--expires-days <n>", "Expiry in days (default 90 server-side)").option("--tenant <slug>", "Mint a CONSUMER key in this tenant instead of a control-plane key").option("--for <email|id>", "Issue this key to a named person \u2014 calls made with it count as that person, and ownership rules trust it. Needs --tenant.").option("--env <name>", "Environment the consumer key is valid for (with --tenant; default dev)").option("--team <id|name>", "Team (defaults to active team)").option("--json", "Output machine-readable JSON").action(action((opts, cmd) => runKeyMint({ ...cmd.parent?.opts(), ...opts })));
14460
+ apikeys.command("mint").description("Create a control-plane API key (secret shown once) \u2014 or, with --tenant, a consumer key in that tenant: a backend key (default), a client key (--client) or a person-bound key (--for)").option("--desc <text>", "Description").option("--expires-days <n>", "Expiry in days (default 90 server-side)").option("--tenant <slug>", "Mint a CONSUMER key in this tenant instead of a control-plane key").option("--backend", `Backend key (default). ${HOLDER_HELP.backend}`).option("--client", `Client key. ${HOLDER_HELP.client} Needs --tenant.`).option("--for <email|id>", "Issue this key to a named person \u2014 calls made with it count as that person, and ownership rules trust it. Needs --tenant.").option("--env <name>", "Environment the consumer key is valid for (with --tenant; default dev)").option("--team <id|name>", "Team (defaults to active team)").option("--json", "Output machine-readable JSON").action(action((opts, cmd) => runKeyMint({ ...cmd.parent?.opts(), ...opts })));
14355
14461
  apikeys.command("revoke").description("Revoke a control-plane API key").argument("<keyId>", "Key id (see `apikeys list`)").option("--team <id|name>", "Team (defaults to active team)").action(action((keyId, opts, cmd) => runKeyRevoke(keyId, { ...cmd.parent?.opts(), ...opts })));
14356
14462
  program.addCommand(apikeys, { hidden: true });
14357
14463
  var op = new import_commander.Command("op").description("Operator menu (platform operators only)").argument("[action]", "latency | billing | mark | credits | residue | sweep | test | perf (omit for the menu)").argument("[view]", "for `latency`: slow | llm | compare | grades \xB7 for `billing`: doors | meter \xB7 for `mark`: the change name, quoted").option("-y, --yes", "Skip the sweep confirmation prompt").option("--json", "Output machine-readable JSON").option("--hours <n>", "Look-back window in hours (default 1)").option("--team <id>", "Filter to one team").option("--only <ids>", "for `test`: comma-separated spec/gut ids or prefixes").option("--group <name>", "for `test`: only specs whose group matches").option("--skip-guts", "for `test`: contract specs only").option("--guts-only", "for `test`: server-side gut checks only").option("--fail-fast", "for `test`: stop at the first failure").option("--timeout <s>", "for `test`: seconds to wait per check (default 150)").option("--perf", "for `test`: run the performance gate instead of the suite (fixed-rate load on a sandbox proxy)").option("--consumer", "for `test`: probe the data plane with the stored `consumer login` token instead of the suite").option("--path <p>", "for `test --consumer`: request path on the proxy (default /get)").option("--proxy <name@version>", "for `test --consumer`: the OAuth-door proxy to knock on (default: the sandbox proxy for a sandbox-tenant session)").option("--rps <n>", "for `test --perf`: requests per second (default 100, max 300)").option("--seconds <n>", "for `test --perf`: how long to send for (default 60, max 300)").option("--url <url>", "for `test --perf`: load this fixture URL instead of provisioning one (with --key; nothing is torn down)").option("--max-p50 <ms>", "for `test --perf`: client p50 threshold (default 250)").option("--max-p95 <ms>", "for `test --perf`: client p95 threshold (default 800)").option("--max-error-rate <pct>", "for `test --perf`: error-rate threshold in percent (default 0.5)").option("--max-gateway-p50 <ms>", "for `test --perf`: apiblaze-overhead p50 threshold (default 60)").option("--max-gateway-p95 <ms>", "for `test --perf`: apiblaze-overhead p95 threshold (default 250)").option("--project <name>", "Filter to one proxy").option("--api-version <v>", "Filter to one API version").option("--tenant <name>", "Filter to one tenant").option("--route <path>", "Filter to one route").option("--environment <env>", "Filter to one environment").option("--status-class <c>", "Filter to a status class (2xx/4xx/5xx)").option("--min-ms <n>", "For `latency slow`: minimum duration (default 1000)").option("--days <n>", "For `latency grades`: how many days back (default 7)").option("--excellent <ms>", "Grade threshold: under this is excellent (default 250)").option("--okay <ms>", "Grade threshold: under this is okay (default 500)").option("--bad <ms>", "Grade threshold: under this is bad, at/over is terrible (default 1000)").option("--key <key>", "For `billing meter`: a data-plane API key for the sandbox proxy (without it the served-request checks are SKIPPED) \xB7 for `test --perf --url`: the fixture's API key").option("--count <n>", "For `billing meter`: requests per check (default 3, max 10)").option("--mcp-host <host>", "For `billing meter`: an MCP host to check tools/call is charged once").action(action((sub, view, opts) => runOp(sub, opts, view))).addHelpText("after", () => `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apiblaze",
3
- "version": "0.21.6",
3
+ "version": "0.21.8",
4
4
  "description": "APIblaze CLI — Chat with your APIs, Manage your API keys, users and groups with the APIblaze serverless proxy",
5
5
  "keywords": [
6
6
  "apiblaze",