@intentius/behold 0.20.0 → 0.21.0

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/cli.js CHANGED
@@ -118,17 +118,17 @@ var init_read_scheduler = __esm({
118
118
  recordRefused();
119
119
  return Promise.reject(new Error("Chant read queue is full; retry after current reads finish"));
120
120
  }
121
- let resolve18;
121
+ let resolve19;
122
122
  let reject;
123
123
  const promise = new Promise((yes, no) => {
124
- resolve18 = yes;
124
+ resolve19 = yes;
125
125
  reject = no;
126
126
  });
127
127
  job = {
128
128
  key,
129
129
  run: run3,
130
130
  promise,
131
- resolve: resolve18,
131
+ resolve: resolve19,
132
132
  reject,
133
133
  controller: new AbortController(),
134
134
  users: 0,
@@ -142,7 +142,7 @@ var init_read_scheduler = __esm({
142
142
  }
143
143
  const shared2 = job;
144
144
  shared2.users++;
145
- const result = new Promise((resolve18, reject) => {
145
+ const result = new Promise((resolve19, reject) => {
146
146
  let done = false;
147
147
  const finish = (error, value, failed = false) => {
148
148
  if (done) return;
@@ -150,7 +150,7 @@ var init_read_scheduler = __esm({
150
150
  signal?.removeEventListener("abort", abort);
151
151
  shared2.users--;
152
152
  if (failed) reject(error);
153
- else resolve18(value);
153
+ else resolve19(value);
154
154
  };
155
155
  const abort = () => {
156
156
  finish(signal?.reason, void 0, true);
@@ -1038,11 +1038,11 @@ async function createK8sClient(options = {}) {
1038
1038
  }
1039
1039
  return void 0;
1040
1040
  }
1041
- async function resolve18(selector, signal) {
1041
+ async function resolve19(selector, signal) {
1042
1042
  return "apiVersion" in selector ? resolveByGvk(selector.apiVersion, selector.kind, signal) : resolveByResourceString(selector.resource, selector.group, signal);
1043
1043
  }
1044
1044
  async function resolveOrThrow(selector, signal) {
1045
- const info = await resolve18(selector, signal);
1045
+ const info = await resolve19(selector, signal);
1046
1046
  if (!info) throw new UnknownResourceError(selectorText(selector));
1047
1047
  return info;
1048
1048
  }
@@ -1054,7 +1054,7 @@ async function createK8sClient(options = {}) {
1054
1054
  return parts.join("/");
1055
1055
  }
1056
1056
  async function pathFor(ref, signal) {
1057
- const info = await resolve18({ apiVersion: ref.apiVersion, kind: ref.kind }, signal);
1057
+ const info = await resolve19({ apiVersion: ref.apiVersion, kind: ref.kind }, signal);
1058
1058
  if (!info) return void 0;
1059
1059
  return objectPath(info, ref.name, ref.namespace);
1060
1060
  }
@@ -1168,8 +1168,8 @@ async function createK8sClient(options = {}) {
1168
1168
  const { lines } = await openStream(listPath, query, controller.signal, target);
1169
1169
  let expired = false;
1170
1170
  const iterator = lines[Symbol.asyncIterator]();
1171
- const stopped = new Promise((resolve19) => {
1172
- const finish = () => resolve19({ done: true, value: void 0 });
1171
+ const stopped = new Promise((resolve20) => {
1172
+ const finish = () => resolve20({ done: true, value: void 0 });
1173
1173
  if (controller.signal.aborted) finish();
1174
1174
  else controller.signal.addEventListener("abort", finish, { once: true });
1175
1175
  });
@@ -1196,11 +1196,11 @@ async function createK8sClient(options = {}) {
1196
1196
  resourceVersion = await listResourceVersion();
1197
1197
  continue;
1198
1198
  }
1199
- await new Promise((resolve19) => {
1200
- const timer = setTimeout(resolve19, reopenDelayMs);
1199
+ await new Promise((resolve20) => {
1200
+ const timer = setTimeout(resolve20, reopenDelayMs);
1201
1201
  controller.signal.addEventListener("abort", () => {
1202
1202
  clearTimeout(timer);
1203
- resolve19(void 0);
1203
+ resolve20(void 0);
1204
1204
  }, { once: true });
1205
1205
  });
1206
1206
  }
@@ -1318,7 +1318,7 @@ async function createK8sClient(options = {}) {
1318
1318
  return {
1319
1319
  provenance,
1320
1320
  defaultNamespace,
1321
- resolve: resolve18,
1321
+ resolve: resolve19,
1322
1322
  read,
1323
1323
  pathFor,
1324
1324
  readIfPresent,
@@ -4384,7 +4384,7 @@ var init_chant = __esm({
4384
4384
  });
4385
4385
 
4386
4386
  // src/cli.ts
4387
- import { resolve as resolve17, dirname as dirname10, join as join29 } from "node:path";
4387
+ import { resolve as resolve18, dirname as dirname10, join as join29 } from "node:path";
4388
4388
  import { realpathSync as realpathSync2, existsSync as existsSync21, readFileSync as readFileSync22, rmSync as rmSync4 } from "node:fs";
4389
4389
  import { fileURLToPath as fileURLToPath6 } from "node:url";
4390
4390
  import { spawn as spawn7, spawnSync as spawnSync3 } from "node:child_process";
@@ -4444,7 +4444,7 @@ import { fileURLToPath as fileURLToPath4 } from "node:url";
4444
4444
  import { execFile as execFile3 } from "node:child_process";
4445
4445
  import { promisify as promisify3 } from "node:util";
4446
4446
  import { existsSync as existsSync18, readFileSync as readFileSync19 } from "node:fs";
4447
- import { basename as basename3, dirname as dirname7, join as join26, relative as relative8, resolve as resolve15, sep as sep7 } from "node:path";
4447
+ import { basename as basename3, dirname as dirname7, join as join26, relative as relative8, resolve as resolve16, sep as sep7 } from "node:path";
4448
4448
 
4449
4449
  // src/recents.ts
4450
4450
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
@@ -4686,19 +4686,19 @@ function finishPipelineProgress(state, exitCode) {
4686
4686
  }
4687
4687
 
4688
4688
  // src/gh-run.ts
4689
- var defaultGhExec = (args) => new Promise((resolve18) => {
4689
+ var defaultGhExec = (args) => new Promise((resolve19) => {
4690
4690
  let out = "";
4691
4691
  let proc;
4692
4692
  try {
4693
4693
  proc = spawn3("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
4694
4694
  } catch {
4695
- resolve18({ code: 127, out: "" });
4695
+ resolve19({ code: 127, out: "" });
4696
4696
  return;
4697
4697
  }
4698
4698
  proc.stdout.on("data", (d) => out += d);
4699
4699
  proc.stderr.on("data", (d) => out += d);
4700
- proc.on("error", () => resolve18({ code: 127, out }));
4701
- proc.on("close", (code) => resolve18({ code: code ?? 1, out }));
4700
+ proc.on("error", () => resolve19({ code: 127, out }));
4701
+ proc.on("close", (code) => resolve19({ code: code ?? 1, out }));
4702
4702
  });
4703
4703
  async function ghReady(exec = defaultGhExec) {
4704
4704
  const { code, out } = await exec(["auth", "status"]);
@@ -11502,19 +11502,19 @@ import { join as join20 } from "node:path";
11502
11502
  import { platform } from "node:os";
11503
11503
  var DOCKER_DOWN_DETAIL = "docker is down";
11504
11504
  function probe(cmd, args) {
11505
- return new Promise((resolve18) => {
11505
+ return new Promise((resolve19) => {
11506
11506
  let out = "";
11507
11507
  let proc;
11508
11508
  try {
11509
11509
  proc = spawn5(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
11510
11510
  } catch {
11511
- resolve18({ code: 127, out: "" });
11511
+ resolve19({ code: 127, out: "" });
11512
11512
  return;
11513
11513
  }
11514
11514
  proc.stdout.on("data", (d) => out += d);
11515
11515
  proc.stderr.on("data", (d) => out += d);
11516
- proc.on("error", () => resolve18({ code: 127, out }));
11517
- proc.on("close", (code) => resolve18({ code: code ?? 1, out }));
11516
+ proc.on("error", () => resolve19({ code: 127, out }));
11517
+ proc.on("close", (code) => resolve19({ code: code ?? 1, out }));
11518
11518
  });
11519
11519
  }
11520
11520
  async function dockerAvailable(run3) {
@@ -12611,6 +12611,142 @@ async function moveReceipt(plan, members, run3 = captureChoudoufu) {
12611
12611
  };
12612
12612
  }
12613
12613
 
12614
+ // src/workspace-gates.ts
12615
+ init_chant();
12616
+ import { resolve as resolve13 } from "node:path";
12617
+ var isRecord11 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
12618
+ var str7 = (v) => typeof v === "string" && v ? v : null;
12619
+ function gateKey(member, op, gate) {
12620
+ return `${member}/${op}/${gate}`;
12621
+ }
12622
+ function approveArgs(g) {
12623
+ return ["approve", g.op, g.gate, ...g.env ? ["--env", g.env] : [], ...g.planDigest ? ["--plan", g.planDigest] : []];
12624
+ }
12625
+ function parseWorkspaceStatus(text, root) {
12626
+ let doc;
12627
+ try {
12628
+ doc = JSON.parse(text);
12629
+ } catch {
12630
+ return { ok: false, refusal: { error: "chant workspace status printed something that is not JSON.", code: "workspace-status", remedy: "Run `chant workspace status <env> --json` in the workspace root to see what it says." } };
12631
+ }
12632
+ if (!isRecord11(doc)) return { ok: false, refusal: { error: "chant workspace status printed JSON that is not an object.", code: "workspace-status", remedy: "Run `chant workspace status <env> --json` in the workspace root." } };
12633
+ const contract = contractRefusal(doc, "status");
12634
+ if (contract) return { ok: false, refusal: contract };
12635
+ if (isRecord11(doc.error)) {
12636
+ const code = String(doc.error.code ?? "unknown");
12637
+ return { ok: false, refusal: { error: `chant could not read the workspace's status: ${code}: ${String(doc.error.message ?? "")}`, code, remedy: "`chant workspace check` names the problem and where it is." } };
12638
+ }
12639
+ const gates = [];
12640
+ for (const m of Array.isArray(doc.members) ? doc.members : []) {
12641
+ if (!isRecord11(m) || typeof m.name !== "string") continue;
12642
+ const dir = typeof m.dir === "string" ? m.dir === "." ? resolve13(root) : resolve13(root, m.dir) : resolve13(root);
12643
+ for (const g of Array.isArray(m.gates) ? m.gates : []) {
12644
+ if (!isRecord11(g)) continue;
12645
+ if (g.state !== void 0 && g.state !== "pending") continue;
12646
+ const op = str7(g.component);
12647
+ const gate = str7(g.name);
12648
+ if (!op || !gate) continue;
12649
+ const env = str7(g.env);
12650
+ const planDigest = str7(g.planDigest);
12651
+ const approvals = (Array.isArray(g.approvals) ? g.approvals : []).filter(isRecord11).map((a) => ({
12652
+ principal: String(a.principal ?? "unknown"),
12653
+ at: str7(a.at),
12654
+ channel: str7(a.channel)
12655
+ }));
12656
+ gates.push({
12657
+ key: gateKey(m.name, op, gate),
12658
+ member: m.name,
12659
+ dir,
12660
+ op,
12661
+ gate,
12662
+ env,
12663
+ planDigest,
12664
+ approvals,
12665
+ needed: typeof g.needed === "number" ? g.needed : 1,
12666
+ recordedAt: str7(g.recordedAt),
12667
+ expiresAt: str7(g.expiresAt),
12668
+ signed: isRecord11(g.signed),
12669
+ // status's line already carries --sign for a signed gate; only the
12670
+ // line built here needs it added.
12671
+ approve: str7(g.approve) ?? ["chant", ...approveArgs({ op, gate, env, planDigest }), ...isRecord11(g.signed) ? ["--sign"] : []].join(" ")
12672
+ });
12673
+ }
12674
+ }
12675
+ return { ok: true, env: String(doc.env ?? ""), gates };
12676
+ }
12677
+ async function readWorkspaceGates(ws, env) {
12678
+ const run3 = await runChantRaw(["workspace", "status", env, "--json"], ws.root);
12679
+ if (!run3.stdout.trim()) {
12680
+ return {
12681
+ ok: false,
12682
+ refusal: {
12683
+ error: `chant workspace status ${env} failed in ${ws.root} (exit ${run3.code}): ${run3.stderr.trim().split("\n").slice(-3).join(" ")}`,
12684
+ code: "workspace-status",
12685
+ remedy: "Run `chant workspace status <env>` in the workspace root."
12686
+ }
12687
+ };
12688
+ }
12689
+ return parseWorkspaceStatus(run3.stdout, ws.root);
12690
+ }
12691
+ function isEnvName(env) {
12692
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(env);
12693
+ }
12694
+ function localApprover(env = process.env) {
12695
+ return env.GITHUB_ACTOR ?? env.GITLAB_USER_LOGIN ?? env.USER ?? "unknown";
12696
+ }
12697
+
12698
+ // src/request-guard.ts
12699
+ var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
12700
+ var SAFE = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
12701
+ function hostName(hostport) {
12702
+ const h = hostport.trim().toLowerCase();
12703
+ if (h.startsWith("[")) {
12704
+ const end = h.indexOf("]");
12705
+ return end >= 0 ? h.slice(0, end + 1) : h;
12706
+ }
12707
+ const colon = h.lastIndexOf(":");
12708
+ return colon >= 0 && h.indexOf(":") === colon ? h.slice(0, colon) : h;
12709
+ }
12710
+ function isLoopbackName(name) {
12711
+ return LOOPBACK.has(name) || name.endsWith(".localhost");
12712
+ }
12713
+ function allowedName(name, cfg) {
12714
+ if (isLoopbackName(name)) return true;
12715
+ if (cfg.boundHost && !isWildcard(cfg.boundHost) && hostName(cfg.boundHost) === name) return true;
12716
+ return cfg.allowedHosts.some((h) => hostName(h) === name);
12717
+ }
12718
+ function isWildcard(h) {
12719
+ return h === "0.0.0.0" || h === "::" || h === "[::]";
12720
+ }
12721
+ function guardRequest(req, cfg) {
12722
+ const name = hostName(req.host);
12723
+ if (!allowedName(name, cfg)) {
12724
+ return { ok: false, status: 421, error: `behold doesn't answer to host ${name}. Start it with --allow-host ${name} if a proxy of yours sends that name.` };
12725
+ }
12726
+ if (SAFE.has(req.method.toUpperCase())) return { ok: true };
12727
+ if ((req.secFetchSite || "").toLowerCase() === "cross-site") {
12728
+ return { ok: false, status: 403, error: "a cross-site page can't write to behold" };
12729
+ }
12730
+ if (req.origin && req.origin !== "null") {
12731
+ let originHost;
12732
+ try {
12733
+ originHost = new URL(req.origin).host.toLowerCase();
12734
+ } catch {
12735
+ return { ok: false, status: 403, error: "a write with an unreadable Origin is refused" };
12736
+ }
12737
+ const same2 = originHost === req.host.trim().toLowerCase();
12738
+ if (!same2 && !cfg.allowedHosts.some((h) => hostName(h) === hostName(originHost))) {
12739
+ return { ok: false, status: 403, error: `a page from ${originHost} can't write to behold` };
12740
+ }
12741
+ } else if (req.origin === "null") {
12742
+ return { ok: false, status: 403, error: "a write from an opaque origin is refused" };
12743
+ }
12744
+ return { ok: true };
12745
+ }
12746
+ function allowedHostsFrom(flag = [], env = process.env.BEHOLD_ALLOWED_HOSTS) {
12747
+ return [...flag, ...(env || "").split(",")].map((s) => s.trim()).filter(Boolean);
12748
+ }
12749
+
12614
12750
  // src/server.ts
12615
12751
  init_member_kind();
12616
12752
  init_terraform_member();
@@ -12620,7 +12756,7 @@ init_chant();
12620
12756
  init_member_kind();
12621
12757
  init_choudoufu_member();
12622
12758
  init_project();
12623
- import { relative as relative6, resolve as resolve13 } from "node:path";
12759
+ import { relative as relative6, resolve as resolve14 } from "node:path";
12624
12760
  init_terraform_member();
12625
12761
  init_targets();
12626
12762
  var list2 = (xs) => xs.join(", ");
@@ -12870,7 +13006,7 @@ function workspaceCheck(ws, beholdMembers) {
12870
13006
  return { name: "project", status: warn ? "warn" : "pass", detail: parts.join("; "), ...warn ? { fix } : {} };
12871
13007
  }
12872
13008
  async function diagnose(dir, probes = {}) {
12873
- const root = resolve13(dir);
13009
+ const root = resolve14(dir);
12874
13010
  const behold = beholdVersion();
12875
13011
  const listing = hasDeclaration(root) ? await (probes.readWorkspace ?? readWorkspace)(root) : void 0;
12876
13012
  if (listing && !listing.ok) {
@@ -12909,7 +13045,7 @@ async function diagnose(dir, probes = {}) {
12909
13045
  };
12910
13046
  }
12911
13047
  const estate = shape.kind === "estate";
12912
- const members = (shape.members ?? []).map((m) => ({ ...m, abs: resolve13(root, m.dir) }));
13048
+ const members = (shape.members ?? []).map((m) => ({ ...m, abs: resolve14(root, m.dir) }));
12913
13049
  const targets = estate ? members.map((m) => m.abs) : [root];
12914
13050
  const primary = targets[0];
12915
13051
  const chantTargets = estate ? members.filter((m) => m.kind === "chant").map((m) => m.abs) : [root];
@@ -13294,7 +13430,7 @@ async function emulatorDown(projectDir) {
13294
13430
 
13295
13431
  // src/demos.ts
13296
13432
  import { readFileSync as readFileSync18, existsSync as existsSync17, cpSync, mkdirSync as mkdirSync6 } from "node:fs";
13297
- import { dirname as dirname6, isAbsolute as isAbsolute4, join as join25, relative as relative7, resolve as resolve14, sep as sep6 } from "node:path";
13433
+ import { dirname as dirname6, isAbsolute as isAbsolute4, join as join25, relative as relative7, resolve as resolve15, sep as sep6 } from "node:path";
13298
13434
  import { spawn as spawn6, spawnSync as spawnSync2 } from "node:child_process";
13299
13435
  function readCatalog(file, catalog) {
13300
13436
  let raw;
@@ -13305,7 +13441,7 @@ function readCatalog(file, catalog) {
13305
13441
  }
13306
13442
  const list3 = raw?.demos;
13307
13443
  if (!Array.isArray(list3)) return [];
13308
- const catalogDir = dirname6(resolve14(file));
13444
+ const catalogDir = dirname6(resolve15(file));
13309
13445
  return list3.filter((e) => validEntry(e)).map((e) => ({ ...e, catalog, catalogDir }));
13310
13446
  }
13311
13447
  function validEntry(e) {
@@ -13342,7 +13478,7 @@ function loadDemoRegistry(pkgRoot3) {
13342
13478
  const merged = readCatalog(join25(pkgRoot3, "demos.json"), "demos");
13343
13479
  const seen = new Set(merged.map((e) => e.name));
13344
13480
  const named = process.env.BEHOLD_WORKBENCH;
13345
- for (const e of [...readCatalog(join25(pkgRoot3, "workbench.json"), "workbench"), ...named ? readCatalog(resolve14(named), "workbench") : []]) {
13481
+ for (const e of [...readCatalog(join25(pkgRoot3, "workbench.json"), "workbench"), ...named ? readCatalog(resolve15(named), "workbench") : []]) {
13346
13482
  if (seen.has(e.name)) {
13347
13483
  process.stderr.write(`behold: workbench entry "${e.name}" collides with a demo already in the catalog \u2014 dropped
13348
13484
  `);
@@ -13355,7 +13491,7 @@ function loadDemoRegistry(pkgRoot3) {
13355
13491
  }
13356
13492
  function demoLocalPath(entry) {
13357
13493
  if (entry.source !== "local" || !entry.path) return void 0;
13358
- return isAbsolute4(entry.path) ? entry.path : resolve14(entry.catalogDir ?? process.cwd(), entry.path);
13494
+ return isAbsolute4(entry.path) ? entry.path : resolve15(entry.catalogDir ?? process.cwd(), entry.path);
13359
13495
  }
13360
13496
  function missingRequirements(entry) {
13361
13497
  const bins = entry.source === "git" && !entry.requires.includes("git") ? [...entry.requires, "git"] : entry.requires;
@@ -13374,9 +13510,9 @@ function fetchesFromNetwork(entry) {
13374
13510
  function demoTargetDir(entry, cwd = process.cwd()) {
13375
13511
  const inPlace = entry.inPlace ? demoLocalPath(entry) : void 0;
13376
13512
  if (inPlace) return inPlace;
13377
- const legacy = resolve14(cwd, "behold-demo");
13513
+ const legacy = resolve15(cwd, "behold-demo");
13378
13514
  if (entry.name === "writes" && existsSync17(legacy)) return legacy;
13379
- return resolve14(cwd, "behold-demos", entry.name);
13515
+ return resolve15(cwd, "behold-demos", entry.name);
13380
13516
  }
13381
13517
  async function loadDemo(entry, opts) {
13382
13518
  const { pkgRoot: pkgRoot3, target } = opts;
@@ -13785,6 +13921,20 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13785
13921
  onDone: (opEnv) => captureFrame(cfg.projectDir, opEnv ?? cfg.env, frames, broadcaster)
13786
13922
  })) {
13787
13923
  const app = new Hono();
13924
+ const guard = { allowedHosts: allowedHostsFrom(cfg.allowedHosts ?? []), ...cfg.host ? { boundHost: cfg.host } : {} };
13925
+ app.use("*", async (c, next) => {
13926
+ const verdict = guardRequest(
13927
+ {
13928
+ method: c.req.method,
13929
+ host: c.req.header("host") ?? new URL(c.req.url).host,
13930
+ origin: c.req.header("origin") ?? null,
13931
+ secFetchSite: c.req.header("sec-fetch-site") ?? null
13932
+ },
13933
+ guard
13934
+ );
13935
+ if (!verdict.ok) return c.json({ error: verdict.error, code: "request-refused" }, verdict.status);
13936
+ await next();
13937
+ });
13788
13938
  setChoudoufuRunner(cfg.choudoufu?.run);
13789
13939
  setEstateLexiconRead(cfg.choudoufu ? cfg.choudoufu.lexicon ?? null : void 0);
13790
13940
  setServedWorkspace(cfg.workspace);
@@ -14006,6 +14156,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14006
14156
  return c.json({ history });
14007
14157
  });
14008
14158
  app.post("/api/operator/approve/:op/:gate", async (c) => {
14159
+ if (cfg.previewMode) return c.json({ error: "approving is disabled in preview mode", code: "preview" }, 403);
14009
14160
  const { op, gate } = c.req.param();
14010
14161
  const info = estateOps().find((o) => o.name === op);
14011
14162
  broadcaster.emit("op", `\u270E approve ${op} ${gate} \u2014 records a fact; the next tick acts on it`);
@@ -14016,6 +14167,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14016
14167
  return c.json({ recorded: true, op, gate, semantics: APPROVE_SEMANTICS });
14017
14168
  });
14018
14169
  app.post("/api/ops/:name/signal/:gate", async (c) => {
14170
+ if (cfg.previewMode) return c.json({ error: "approving is disabled in preview mode", code: "preview" }, 403);
14019
14171
  const { name, gate } = c.req.param();
14020
14172
  const info = estateOps().find((o) => o.name === name);
14021
14173
  broadcaster.emit("op", `\u270E signal ${name} ${gate}`);
@@ -14023,6 +14175,38 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14023
14175
  if (code !== 0) return c.json({ error: stderr.trim() || `signal exited ${code}` }, 500);
14024
14176
  return c.json({ signalled: true });
14025
14177
  });
14178
+ app.get("/api/workspace/gates", async (c) => {
14179
+ const ws = servedWorkspace();
14180
+ const env = c.req.query("env") || cfg.env || "local";
14181
+ if (!isEnvName(env)) return c.json({ error: `${JSON.stringify(env)} isn't an env name`, code: "env" }, 400);
14182
+ if (!ws) return c.json({ workspace: false, env, gates: [], approver: localApprover() });
14183
+ const read = await readWorkspaceGates(ws, env);
14184
+ if (!read.ok) return c.json({ workspace: true, env, gates: [], refusal: read.refusal, approver: localApprover() });
14185
+ return c.json({ workspace: true, env, gates: read.gates.map(({ dir: _dir, ...g }) => g), approver: localApprover() });
14186
+ });
14187
+ app.post("/api/workspace/gates/approve", async (c) => {
14188
+ if (cfg.previewMode) return c.json({ error: "approving is disabled in preview mode", code: "preview" }, 403);
14189
+ if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) {
14190
+ return c.json({ error: "send the body as application/json", code: "content-type" }, 415);
14191
+ }
14192
+ const ws = servedWorkspace();
14193
+ if (!ws) return c.json({ error: "behold isn't serving a declared workspace", code: "no-workspace" }, 404);
14194
+ const body = await c.req.json().catch(() => ({}));
14195
+ const key = typeof body?.key === "string" ? body.key : "";
14196
+ const env = typeof body?.env === "string" && body.env ? body.env : cfg.env || "local";
14197
+ if (!isEnvName(env)) return c.json({ error: `${JSON.stringify(env)} isn't an env name`, code: "env" }, 400);
14198
+ const read = await readWorkspaceGates(ws, env);
14199
+ if (!read.ok) return c.json({ error: read.refusal.error, code: read.refusal.code }, 422);
14200
+ const g = read.gates.find((x) => x.key === key);
14201
+ if (!g) return c.json({ error: `no gate ${key} is waiting in ${env}`, code: "no-gate" }, 404);
14202
+ if (g.signed) return c.json({ error: `${key} wants a signed approval, which behold can't make. Run: ${g.approve}`, code: "signed" }, 422);
14203
+ const approver = localApprover();
14204
+ broadcaster.emit("op", `\u270E approve ${g.key} as ${approver}`);
14205
+ const { code, stderr, stdout } = await runChantRaw(approveArgs(g), g.dir);
14206
+ if (code !== 0) return c.json({ error: stderr.trim() || stdout.trim() || `approve exited ${code}`, code: "approve" }, 500);
14207
+ broadcaster.emit("changed", "");
14208
+ return c.json({ recorded: true, key: g.key, approver });
14209
+ });
14026
14210
  app.get(
14027
14211
  "/api/events",
14028
14212
  (c) => streamSSE(c, async (stream) => {
@@ -14089,6 +14273,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14089
14273
  currentEnv: cfg.env ?? null,
14090
14274
  // v0.1.0 preview: the SPA hides git/PR ops + arbitrary-project affordances.
14091
14275
  ...cfg.previewMode ? { previewMode: true } : {},
14276
+ // #477: who `chant approve` records when behold runs it. The approve
14277
+ // buttons say it before the click.
14278
+ approver: localApprover(),
14092
14279
  // The tier picker's options (M2 #54, sourced #70): gated on the served
14093
14280
  // project's `.behold.json` declaring a `tiers` block at all — NOT on
14094
14281
  // whether its env var happens to be set in behold's own launch env
@@ -14139,7 +14326,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14139
14326
  app.post("/api/project/open", async (c) => {
14140
14327
  if (cfg.previewMode) return c.json({ error: "switching projects is locked in preview mode" }, 403);
14141
14328
  const body = await c.req.json().catch(() => ({}));
14142
- const dir = typeof body.dir === "string" && body.dir.trim() ? resolve15(body.dir.trim()) : "";
14329
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve16(body.dir.trim()) : "";
14143
14330
  if (!dir || !existsSync18(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
14144
14331
  if (!existsSync18(join26(dir, "chant.config.ts"))) {
14145
14332
  return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
@@ -14164,7 +14351,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14164
14351
  };
14165
14352
  app.post("/api/project/reveal", async (c) => {
14166
14353
  const body = await c.req.json().catch(() => ({}));
14167
- const dir = typeof body.dir === "string" && body.dir.trim() ? resolve15(body.dir.trim()) : cfg.projectDir;
14354
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve16(body.dir.trim()) : cfg.projectDir;
14168
14355
  const known = /* @__PURE__ */ new Set([cfg.projectDir, ...cfg.projectDirs ?? [], ...listRecents().map((r) => r.dir)]);
14169
14356
  if (!known.has(dir)) return c.json({ error: "not a served or recent project directory" }, 400);
14170
14357
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
@@ -14375,10 +14562,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14375
14562
  { method: "GET", path: "/api/history", desc: "recent source commits (rollback targets)" },
14376
14563
  { method: "GET", path: "/api/frames", desc: "captured lanes frames" },
14377
14564
  { method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / run / pr" },
14378
- { method: "POST", path: "/api/refresh", desc: "re-observe live now (?env=) \u2014 returns the fresh graph" },
14565
+ { method: "POST", path: "/api/refresh", desc: "re-observe live now (?env=) \u2014 returns the fresh graph. With ?notify=1 it is a host's notice that the workspace changed: cached reads are dropped and every open page re-pulls (#476)" },
14379
14566
  { method: "POST", path: "/api/apply", desc: "delegated apply: ?env=&component=<name|all> (guarded, preview-locked)" },
14380
14567
  { method: "POST", path: "/api/ops/:name/run", desc: "run a committed Op (delegated write)" },
14381
14568
  { method: "POST", path: "/api/ops/:name/signal/:gate", desc: "approve an Op's gate (releases the waiting run)" },
14569
+ { method: "GET", path: "/api/workspace/gates", desc: "a declared workspace's waiting gates from chant workspace status <env> --json (?env=), keyed member/op/gate, with the approver behold would record (#477)" },
14570
+ { method: "POST", path: "/api/workspace/gates/approve", desc: "approve one workspace gate: JSON body {key, env}; env and plan come from a fresh status read; recorded as the user running behold; preview-locked (#477)" },
14382
14571
  { method: "POST", path: "/api/operator/approve/:op/:gate", desc: "record a converge gate's resolution (chant approve \u2014 a fact for the next tick, not an unblock)" },
14383
14572
  { method: "POST", path: "/api/rollback", desc: "open a rollback PR: ?to=<sha>" },
14384
14573
  { method: "POST", path: "/api/substrates/:name/up", desc: "bring a substrate up" },
@@ -14984,7 +15173,14 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14984
15173
  }
14985
15174
  });
14986
15175
  app.post("/api/refresh", async (c) => {
14987
- const env = optsFromQuery(new URL(c.req.url)).env ?? cfg.env;
15176
+ const url = new URL(c.req.url);
15177
+ if (url.searchParams.get("notify") === "1") {
15178
+ invalidateMember();
15179
+ invalidateOverlay();
15180
+ broadcaster.emit("changed", "");
15181
+ return c.json({ notified: true });
15182
+ }
15183
+ const env = optsFromQuery(url).env ?? cfg.env;
14988
15184
  const result = await captureFrame(cfg.projectDir, env, frames, broadcaster);
14989
15185
  if (!result) return c.json({ error: "refresh failed \u2014 see server log" }, 500);
14990
15186
  if (env) reclassifyOverlay(result.ir);
@@ -15064,9 +15260,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
15064
15260
  });
15065
15261
  const choudoufuPlanPath = (plan) => {
15066
15262
  if (!plan) return { error: "no plan named", code: "choudoufu_no_plan", remedy: "pass ?plan=<path to a carve.json inside a served choudoufu member> \u2014 GET /api/project lists the ones found" };
15067
- const abs = resolve15(cfg.projectDir, plan);
15263
+ const abs = resolve16(cfg.projectDir, plan);
15068
15264
  const members = (cfg.projectDirs ?? [cfg.projectDir]).filter((d) => memberKindOf(d) === "choudoufu");
15069
- const inside = members.some((d) => abs.startsWith(resolve15(d) + sep7));
15265
+ const inside = members.some((d) => abs.startsWith(resolve16(d) + sep7));
15070
15266
  if (!inside) return { error: `${plan} is not inside a served choudoufu member`, code: "choudoufu_plan_outside", remedy: "a plan lives in the estate it moves; point ?plan= at a carve.json under one of the served members" };
15071
15267
  return { path: abs };
15072
15268
  };
@@ -15197,6 +15393,10 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
15197
15393
  return c.json({ started: true, component, env });
15198
15394
  });
15199
15395
  const rel = relative8(process.cwd(), webRoot) || ".";
15396
+ app.use("/*", async (c, next) => {
15397
+ await next();
15398
+ if (!c.req.path.startsWith("/api/") && c.res.ok && !c.res.headers.has("cache-control")) c.res.headers.set("cache-control", "no-cache");
15399
+ });
15200
15400
  app.use("/*", serveStatic({ root: rel }));
15201
15401
  app.get("/", serveStatic({ path: join26(rel, "index.html") }));
15202
15402
  return app;
@@ -15236,6 +15436,7 @@ async function startServer(cfg) {
15236
15436
  broadcaster,
15237
15437
  onDone: (opEnv) => captureFrame(cfg.projectDir, opEnv ?? cfg.env, frames, broadcaster)
15238
15438
  });
15439
+ if (!cfg.host && process.env.BEHOLD_HOST) cfg.host = process.env.BEHOLD_HOST;
15239
15440
  const app = createApp(cfg, broadcaster, frames, runner);
15240
15441
  const autoSync = cfg.autoSync ?? "off";
15241
15442
  if (!cfg.carveReport) {
@@ -15343,7 +15544,8 @@ async function startServer(cfg) {
15343
15544
  };
15344
15545
  process.on("SIGINT", shutdown);
15345
15546
  process.on("SIGTERM", shutdown);
15346
- const server = serve({ fetch: app.fetch, port: cfg.port }, (info) => {
15547
+ const hostname = cfg.host ?? process.env.BEHOLD_HOST ?? "127.0.0.1";
15548
+ const server = serve({ fetch: app.fetch, port: cfg.port, hostname }, (info) => {
15347
15549
  if (carve) {
15348
15550
  process.stdout.write(
15349
15551
  `behold \u2192 http://localhost:${info.port}
@@ -15394,7 +15596,7 @@ init_chant();
15394
15596
  // src/workspace-convert.ts
15395
15597
  init_chant();
15396
15598
  import { existsSync as existsSync19, mkdirSync as mkdirSync7, readFileSync as readFileSync20, renameSync as renameSync2, writeFileSync as writeFileSync6 } from "node:fs";
15397
- import { basename as basename4, dirname as dirname8, join as join27, relative as relative9, resolve as resolve16, sep as sep8 } from "node:path";
15599
+ import { basename as basename4, dirname as dirname8, join as join27, relative as relative9, resolve as resolve17, sep as sep8 } from "node:path";
15398
15600
  import { shortStackNames as shortStackNames2 } from "@intentius/pinhole";
15399
15601
  init_project();
15400
15602
  init_terraform_member();
@@ -15411,7 +15613,7 @@ function unique(name, taken) {
15411
15613
  }
15412
15614
  var posix = (p) => p.split(sep8).join("/");
15413
15615
  function installedVersion(pkg, dir) {
15414
- for (let at = resolve16(dir); ; at = dirname8(at)) {
15616
+ for (let at = resolve17(dir); ; at = dirname8(at)) {
15415
15617
  const manifest = join27(at, "node_modules", ...pkg.split("/"), "package.json");
15416
15618
  if (existsSync19(manifest)) {
15417
15619
  try {
@@ -15425,14 +15627,14 @@ function installedVersion(pkg, dir) {
15425
15627
  }
15426
15628
  }
15427
15629
  function planConversion(rootArg) {
15428
- const root = resolve16(rootArg);
15630
+ const root = resolve17(rootArg);
15429
15631
  const existing = DECLARATION_FILES.find((f) => existsSync19(join27(root, f)));
15430
15632
  if (existing) return { ok: false, error: `${root} already has ${existing}; there is nothing to convert.` };
15431
15633
  const shape = detectProjectShape(root);
15432
15634
  if (shape.kind !== "estate" || !shape.members?.length || shape.membersFrom === "itself") {
15433
15635
  return { ok: false, error: `${root} is not an estate root with a member list, so there is no member list to convert.` };
15434
15636
  }
15435
- const abs = shape.members.map((m) => resolve16(root, m.dir));
15637
+ const abs = shape.members.map((m) => resolve17(root, m.dir));
15436
15638
  const shorts = shortStackNames2(abs);
15437
15639
  const taken = /* @__PURE__ */ new Set();
15438
15640
  const members = [];
@@ -15500,7 +15702,7 @@ function applyConversion(plan) {
15500
15702
  let layout;
15501
15703
  const target = layoutPath(plan.root);
15502
15704
  if (!plan.layoutFrom) layout = { skipped: "no saved layout to move" };
15503
- else if (resolve16(plan.layoutFrom) === resolve16(target)) layout = { skipped: "the saved layout is already in the root" };
15705
+ else if (resolve17(plan.layoutFrom) === resolve17(target)) layout = { skipped: "the saved layout is already in the root" };
15504
15706
  else if (existsSync19(target)) layout = { skipped: `${target} already exists; the layout in ${plan.layoutFrom} was left alone` };
15505
15707
  else {
15506
15708
  const moved = moveLayoutIds(readLayoutFile(dirname8(dirname8(plan.layoutFrom))), plan.moves);
@@ -15694,7 +15896,7 @@ Usage:
15694
15896
  behold doctor [project-dir] [--json] [--fix]
15695
15897
  behold preview [project-dir] [--port <n>] [--emulator]
15696
15898
  behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
15697
- behold serve <project-dir\u2026> [--port <n>] [--env <name>] [--poll <secs>] [--local]
15899
+ behold serve <project-dir\u2026> [--port <n>] [--host <addr>] [--allow-host <name,\u2026>] [--env <name>] [--poll <secs>] [--local]
15698
15900
  behold carve <report.json> [--port <n>]
15699
15901
 
15700
15902
  carve Render a chant Terraform peelability report \u2014 the JSON from
@@ -15753,6 +15955,12 @@ Usage:
15753
15955
 
15754
15956
  Options:
15755
15957
  --port <n> Port (default 4600). preview/serve/carve.
15958
+ --host <addr> serve only: the address to bind (default 127.0.0.1, or
15959
+ BEHOLD_HOST). behold runs writes with your credentials,
15960
+ so it answers this machine unless you say otherwise.
15961
+ --allow-host <n,\u2026> serve only: names besides loopback behold answers to, and
15962
+ whose pages may write (BEHOLD_ALLOWED_HOSTS too). For a
15963
+ proxy that frames behold, or --host 0.0.0.0.
15756
15964
  --env <name> Environment name \u2014 turns on the live drift overlay.
15757
15965
  export/serve.
15758
15966
  --poll <secs> Re-query live drift every <secs> and push updates (needs --env).
@@ -15837,9 +16045,13 @@ ${USAGE}`);
15837
16045
  let pollSecs;
15838
16046
  let autoSync = "off";
15839
16047
  let local = false;
16048
+ let host;
16049
+ const allowHosts = [];
15840
16050
  for (let i = 0; i < rest.length; i++) {
15841
16051
  const a = rest[i];
15842
16052
  if (a === "--port") port = Number(rest[++i]);
16053
+ else if (a === "--host") host = rest[++i];
16054
+ else if (a === "--allow-host") allowHosts.push(...(rest[++i] ?? "").split(","));
15843
16055
  else if (a === "--env") env = rest[++i];
15844
16056
  else if (a === "--poll") pollSecs = Number(rest[++i]);
15845
16057
  else if (a === "--local") local = true;
@@ -15880,7 +16092,7 @@ ${USAGE}`);
15880
16092
  process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
15881
16093
  process.exit(2);
15882
16094
  }
15883
- const dirs = projectDirs.map((d) => resolve17(d));
16095
+ const dirs = projectDirs.map((d) => resolve18(d));
15884
16096
  const workspace = dirs.length === 1 && hasDeclaration(dirs[0]) ? await declaredWorkspace(dirs[0]) : void 0;
15885
16097
  if (workspace) {
15886
16098
  const drawn = drawnMembers(workspace).map((m) => m.abs);
@@ -15892,7 +16104,9 @@ ${USAGE}`);
15892
16104
  ...env ? { env } : {},
15893
16105
  ...pollSecs !== void 0 ? { pollSecs } : {},
15894
16106
  ...autoSync !== "off" ? { autoSync } : {},
15895
- ...local ? { local: true } : {}
16107
+ ...local ? { local: true } : {},
16108
+ ...host ? { host } : {},
16109
+ ...allowHosts.length ? { allowedHosts: allowHosts } : {}
15896
16110
  });
15897
16111
  return;
15898
16112
  }
@@ -15909,7 +16123,9 @@ ${USAGE}`);
15909
16123
  ...env ? { env } : {},
15910
16124
  ...pollSecs !== void 0 ? { pollSecs } : {},
15911
16125
  ...autoSync !== "off" ? { autoSync } : {},
15912
- ...local ? { local: true } : {}
16126
+ ...local ? { local: true } : {},
16127
+ ...host ? { host } : {},
16128
+ ...allowHosts.length ? { allowedHosts: allowHosts } : {}
15913
16129
  });
15914
16130
  }
15915
16131
  async function declaredWorkspace(root) {
@@ -15976,7 +16192,7 @@ async function runCarve(rest) {
15976
16192
  );
15977
16193
  process.exit(2);
15978
16194
  }
15979
- const reportPath = resolve17(fileArg);
16195
+ const reportPath = resolve18(fileArg);
15980
16196
  const parsed = readCarveReport(reportPath, (p) => readFileSync22(p, "utf8"));
15981
16197
  if (!parsed.ok) {
15982
16198
  process.stderr.write(`behold carve: ${parsed.refusal.error}
@@ -16006,8 +16222,8 @@ async function runDoctor(rest) {
16006
16222
  }
16007
16223
  }
16008
16224
  const dir = dirArg ?? ".";
16009
- if (!existsSync21(resolve17(dir))) {
16010
- process.stderr.write(`behold doctor: no such directory: ${resolve17(dir)}
16225
+ if (!existsSync21(resolve18(dir))) {
16226
+ process.stderr.write(`behold doctor: no such directory: ${resolve18(dir)}
16011
16227
  `);
16012
16228
  process.exit(2);
16013
16229
  }
@@ -16106,7 +16322,7 @@ async function runDemo(rest) {
16106
16322
  `);
16107
16323
  process.exit(2);
16108
16324
  }
16109
- const target = dirArg ? resolve17(dirArg) : demoTargetDir(entry);
16325
+ const target = dirArg ? resolve18(dirArg) : demoTargetDir(entry);
16110
16326
  const loaded = await loadDemo(entry, { pkgRoot: pkgRoot3, target, log: (line) => process.stdout.write(line + "\n") });
16111
16327
  if (!loaded.ok) {
16112
16328
  process.stderr.write(`behold demo ${entry.name}: ${loaded.error}
@@ -16138,7 +16354,7 @@ function spawnStep(cmd, args, cwd) {
16138
16354
  });
16139
16355
  }
16140
16356
  async function serveCarveDemo(target, carve, port, live2 = false) {
16141
- const at = (rel) => resolve17(target, rel);
16357
+ const at = (rel) => resolve18(target, rel);
16142
16358
  const project = at(carve.project);
16143
16359
  const from = at(carve.from);
16144
16360
  const state = carve.state ? at(carve.state) : void 0;
@@ -16259,7 +16475,7 @@ async function runPreview(rest) {
16259
16475
  process.stderr.write("behold preview: --port must be a number\n");
16260
16476
  process.exit(2);
16261
16477
  }
16262
- const projectDir = resolve17(dirArg ?? process.cwd());
16478
+ const projectDir = resolve18(dirArg ?? process.cwd());
16263
16479
  if (!existsSync21(projectDir)) {
16264
16480
  process.stderr.write(`behold preview: project not found at ${projectDir}
16265
16481
  `);
@@ -16280,21 +16496,21 @@ async function runPreview(rest) {
16280
16496
  await startServer({ projectDir, port, env: "local", previewMode: true });
16281
16497
  }
16282
16498
  async function runExportCmd(rest) {
16283
- let outDir = resolve17("behold-export");
16499
+ let outDir = resolve18("behold-export");
16284
16500
  let env;
16285
16501
  let name;
16286
16502
  let dirArg;
16287
16503
  let emulator = false;
16288
16504
  for (let i = 0; i < rest.length; i++) {
16289
16505
  const a = rest[i];
16290
- if (a === "--out") outDir = resolve17(rest[++i]);
16506
+ if (a === "--out") outDir = resolve18(rest[++i]);
16291
16507
  else if (a === "--env") env = rest[++i];
16292
16508
  else if (a === "--name") name = rest[++i];
16293
16509
  else if (a === "--emulator") emulator = true;
16294
16510
  else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
16295
16511
  else if (!a.startsWith("-")) dirArg = a;
16296
16512
  }
16297
- const projectDir = resolve17(dirArg ?? process.cwd());
16513
+ const projectDir = resolve18(dirArg ?? process.cwd());
16298
16514
  if (emulator) {
16299
16515
  injectEmulatorEnv(env);
16300
16516
  env ??= "local";