eleven / ocho Public

tools+server: run the webhook-ingest sidecar in local dev, end to end (#610)

./tools/dev up now stands up the whole webhook loop with no router:

- lchat-dev-up mints the hooks service invite (hooks_service), spawns
  `lchat hooks run` on a sticky local TCP port (identity persisted in
  .lchat-dev-hooks.json, cleared on fresh instances like the dev bot's),
  health-checks it, and prints the ingest URL + a curl recipe. --no-hooks
  opts out; ./tools/dev up now passes flags through.
- New LCHAT_HOOKS_BASE server env, echoed as ingest_base on
  GET /api/hooks/service, overrides the client's https://in.<host>
  derivation — so the Webhooks app displays URLs that actually work
  locally. Unset in production (test covers both).
- Dev-you is seeded as an ADMIN member (grants_admin on the invite, plus a
  best-effort sqlite role patch that also promotes pre-existing dev
  instances), so the in-app admin surfaces — Admin, Webhooks — work out of
  the box.
- bootstrap now ships deploy/systemd/lchat-hooks@.service with the other
  embedded units, so add-hooks works on a freshly bootstrapped box.

Verified end to end against the real tooling: dev up → create webhook via
the exact bridge flow → sidecar deploys → external POST → encrypted
webhooks.github event decrypts in the target thread (Authorization
filtered, allowlisted headers kept); restart reuses the port, session, and
route table, and pre-restart URLs keep working.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdpyYWMyEhubwEPEWNmJZG
Claude 9b2641817e06806821bf37d6a6fafd34a732579c parent d704f2c
10 files changed, +222 −17
  • CLAUDE.md +1 −0
  • bootstrap.go +8 −0
  • docs/webhooks.md +15 −4
  • hooks.go +8 −1
  • hooks_test.go +30 −0
  • main.go +12 −5
  • tools/dev +3 −1
  • tools/dev-config.mjs +10 −0
  • tools/lchat-dev-up.mjs +122 −4
  • web/static/shell.js +13 −2
diff --git a/CLAUDE.md b/CLAUDE.md
index 4c2d0a6..04e2b10 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -647,6 +647,7 @@ Simple command-line interface for common dev tasks. Run from anywhere in the rep
./tools/dev run ios # Run iOS app on simulator
./tools/dev alt [slug] # Build + launch a side-by-side dev alt of the Mac app
./tools/dev up # Start local dev server (persistent config)
+ # + the webhook-ingest sidecar (--no-hooks skips)
./tools/dev seed-mac # Seed Mac app with dev server credentials
./tools/dev link # Print dev server sign-in link
./tools/dev test # Run full test suite (Go + Node)
diff --git a/bootstrap.go b/bootstrap.go
index 88cb757..0e1494b 100644
--- a/bootstrap.go
+++ b/bootstrap.go
@@ -23,6 +23,13 @@ var unitInstance string
//go:embed deploy/systemd/lchat-bot@.service
var unitBot string
+// The webhook-ingest sidecar (incoming webhooks, docs/webhooks.md). Installs
+// everywhere like the bot template; a host only runs one after `add-hooks`
+// stages its /data/<host>-hooks.env and enables the unit.
+//
+//go:embed deploy/systemd/lchat-hooks@.service
+var unitHooks string
+
// The push relay + home server are optional sibling services (one each per box).
// Their units install alongside the rest but stay inert until enabled with a
// /data/{push,home}.env present, so a box that runs neither is unaffected.
@@ -164,6 +171,7 @@ func runBootstrap(args []string) {
write(filepath.Join(*unitDir, "lchat-router.service"), unitRouter)
write(filepath.Join(*unitDir, "lchat@.service"), unitInstance)
write(filepath.Join(*unitDir, "lchat-bot@.service"), unitBot)
+ write(filepath.Join(*unitDir, "lchat-hooks@.service"), unitHooks)
write(filepath.Join(*unitDir, "lchat-push.service"), unitPush)
write(filepath.Join(*unitDir, "lchat-home.service"), unitHome)
write(filepath.Join(*unitDir, "lchat-links.service"), unitLinks)
diff --git a/docs/webhooks.md b/docs/webhooks.md
index 0033c5e..58b8682 100644
--- a/docs/webhooks.md
+++ b/docs/webhooks.md
@@ -157,10 +157,21 @@ re-ingesting.
## Dev story
-No router locally: `lchat hooks run --addr :8090 --base http://localhost:1747`
-listens on TCP directly (`LCHAT_INVITE` from a hand-minted agent invite). The
-webhooks app displays `in.<page-host>` URLs regardless; point the sender at
-the dev listener.
+`./tools/dev up` runs the whole loop locally, no router: it mints the service
+invite, spawns `lchat hooks run` on a sticky local TCP port (the sidecar's
+identity persists in `.lchat-dev-hooks.json`, exactly like the dev bot's), and
+passes the server `LCHAT_HOOKS_BASE=http://127.0.0.1:<port>` — which the
+service endpoint echoes as `ingest_base`, so the Webhooks app displays URLs
+that actually work on your machine. Dev-you is seeded as an admin member, so
+the app is right there under Apps → Webhooks; create one, wait for the
+"Active" chip (~10s poll), and `curl` the URL — the event lands encrypted in
+the chosen thread. `--no-hooks` opts out.
+
+Hand-run equivalent (any server): `lchat hooks run --addr :8090 --base
+http://localhost:1747` with `LCHAT_INVITE` from an agent invite minted with
+`hooks_service: true`, and `LCHAT_HOOKS_BASE=http://127.0.0.1:8090` on the
+server so displayed URLs match. In production leave `LCHAT_HOOKS_BASE` unset —
+clients derive `https://in.<host>`.
## Later (deliberately not in v1)
diff --git a/hooks.go b/hooks.go
index ffae7ce..8a61057 100644
--- a/hooks.go
+++ b/hooks.go
@@ -66,7 +66,14 @@ func (s *Server) getHooksService(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{})
return
}
- writeJSON(w, http.StatusOK, map[string]any{"user_id": uid, "public_key": s.db.userPublicKey(uid)})
+ resp := map[string]any{"user_id": uid, "public_key": s.db.userPublicKey(uid)}
+ // ingest_base overrides the client's https://in.<host> derivation — set
+ // only where that derivation is wrong (LCHAT_HOOKS_BASE; local dev runs
+ // the sidecar on a bare port with no router in front).
+ if s.hooksBase != "" {
+ resp["ingest_base"] = s.hooksBase
+ }
+ writeJSON(w, http.StatusOK, resp)
}
// createWebhookHandler mints a webhook: the agent user row (plaintext label,
diff --git a/hooks_test.go b/hooks_test.go
index eb5f201..110766d 100644
--- a/hooks_test.go
+++ b/hooks_test.go
@@ -380,6 +380,36 @@ func TestWebhookGates(t *testing.T) {
}
}
+// TestWebhookIngestBaseOverride: LCHAT_HOOKS_BASE (dev: the sidecar on a bare
+// local port) is echoed as ingest_base on the service endpoint; unset — the
+// production default — omits it so clients derive https://in.<host>.
+func TestWebhookIngestBaseOverride(t *testing.T) {
+ ts, s := newTestServer(t)
+ sess := provisionHooksService(t, ts.URL)
+
+ get := func() map[string]any {
+ t.Helper()
+ resp, err := http.Get(ts.URL + "/api/hooks/service?token=" + sess.Token)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var out map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatal(err)
+ }
+ return out
+ }
+
+ if out := get(); out["ingest_base"] != nil {
+ t.Errorf("ingest_base = %v with no override, want absent", out["ingest_base"])
+ }
+ s.hooksBase = "http://127.0.0.1:9999"
+ if out := get(); out["ingest_base"] != "http://127.0.0.1:9999" {
+ t.Errorf("ingest_base = %v, want the override", out["ingest_base"])
+ }
+}
+
// TestWebhookRateLimit drains one webhook's bucket and expects 429 — the
// sidecar-side dial; the server's message brake backstops it independently.
func TestWebhookRateLimit(t *testing.T) {
diff --git a/main.go b/main.go
index faa236c..b99c314 100644
--- a/main.go
+++ b/main.go
@@ -88,11 +88,17 @@ type Server struct {
// Upgrade entry; empty on self-hosted/operator instances, which have no
// billing surface and show nothing.
entitlement string
- vapidPub string
- vapidPriv string
- apns apnsSender // nil unless APNs (direct p8 or relay) is configured
- maxUpload int64 // per-upload attachment cap (LCHAT_MAX_UPLOAD_BYTES)
- blobQuota int64 // per-instance total blob-bytes ceiling (LCHAT_BLOB_QUOTA_BYTES)
+ // Where this instance's webhook-ingest sidecar is reachable from outside
+ // (LCHAT_HOOKS_BASE, e.g. "http://127.0.0.1:8090" in dev). Empty — the
+ // production default — lets clients derive https://in.<host>; set only
+ // where that derivation is wrong (local dev, unusual fronting). Served as
+ // ingest_base on GET /api/hooks/service; see hooks.go / docs/webhooks.md.
+ hooksBase string
+ vapidPub string
+ vapidPriv string
+ apns apnsSender // nil unless APNs (direct p8 or relay) is configured
+ maxUpload int64 // per-upload attachment cap (LCHAT_MAX_UPLOAD_BYTES)
+ blobQuota int64 // per-instance total blob-bytes ceiling (LCHAT_BLOB_QUOTA_BYTES)
// blobMirrorKick nudges the blob mirror sweep after an upload
// (blobmirror.go, #992). Nil unless the store is mirrored.
blobMirrorKick chan struct{}
@@ -307,6 +313,7 @@ func main() {
blobQuota: envBytes("LCHAT_BLOB_QUOTA_BYTES", defaultBlobQuotaBytes),
meterURL: strings.TrimSpace(os.Getenv("LCHAT_METER_URL")),
meterToken: strings.TrimSpace(os.Getenv("LCHAT_METER_TOKEN")),
+ hooksBase: strings.TrimRight(strings.TrimSpace(os.Getenv("LCHAT_HOOKS_BASE")), "/"),
appsDir: strings.TrimSpace(os.Getenv("LCHAT_APPS_DIR"))}
s.brakes = newModBrakes()
diff --git a/tools/dev b/tools/dev
index 7fa9bd0..12918c5 100755
--- a/tools/dev
+++ b/tools/dev
@@ -8,6 +8,7 @@
// ./tools/dev alt [slug] Build + launch a side-by-side dev alt of the Mac app
// ./tools/dev run ios Run the iOS app on simulator (see instructions)
// ./tools/dev up Start the local dev server (persistent, reusable config)
+// + the webhook-ingest sidecar (skip with --no-hooks)
// ./tools/dev seed-mac Seed the Mac app with dev server credentials
// ./tools/dev seed-ios Seed the iOS app with dev server credentials
// ./tools/dev link Print the sign-in link for the dev server
@@ -119,7 +120,8 @@ const commands = {
"up": async () => {
console.log("Starting dev server (lchat-dev-up)...");
- await runAsync("node", ["tools/lchat-dev-up.mjs"]);
+ // Pass flags through (--clean, --onboarding, --no-hooks, --port N, …).
+ await runAsync("node", ["tools/lchat-dev-up.mjs", ...process.argv.slice(3)]);
},
"down": () => {
diff --git a/tools/dev-config.mjs b/tools/dev-config.mjs
index b8bcc59..81b8488 100644
--- a/tools/dev-config.mjs
+++ b/tools/dev-config.mjs
@@ -23,6 +23,12 @@ export const DEFAULT_DB_PATH = path.join(REPO_ROOT, ".lchat-dev.db");
// fresh start, and lchat-dev-bot --clean drops it to re-provision.
export const BOT_SESSION_PATH = path.join(REPO_ROOT, ".lchat-dev-bot.json");
+// Where the dev hooks sidecar (lchat hooks run, spawned by lchat-dev-up)
+// persists its service-identity session. Tied to the db exactly like the bot
+// session: a fresh instance invalidates it, so lchat-dev-up clears it on any
+// fresh start and re-mints the service invite.
+export const HOOKS_SESSION_PATH = path.join(REPO_ROOT, ".lchat-dev-hooks.json");
+
// The three files SQLite writes for one WAL-mode database, so callers can clean
// them all in one sweep.
export function dbFiles(dbPath) {
@@ -49,3 +55,7 @@ export function clearConfig() {
export function clearBotSession() {
fs.rmSync(BOT_SESSION_PATH, { force: true });
}
+
+export function clearHooksSession() {
+ fs.rmSync(HOOKS_SESSION_PATH, { force: true });
+}
diff --git a/tools/lchat-dev-up.mjs b/tools/lchat-dev-up.mjs
index 64c1b1a..4d3ded3 100755
--- a/tools/lchat-dev-up.mjs
+++ b/tools/lchat-dev-up.mjs
@@ -44,6 +44,16 @@
// The binary is always rebuilt from the current checkout so code changes take
// effect on restart. Ctrl-C stops the server but KEEPS the db + config (use
// --clean to wipe them).
+//
+// It also runs the webhook-ingest sidecar (`lchat hooks run`, docs/webhooks.md)
+// on its own sticky local port — same binary, real provision/encrypt/post path,
+// no router — so the Webhooks app works end-to-end locally: the server is told
+// the port via LCHAT_HOOKS_BASE, making the app display URLs you can curl. The
+// sidecar's service identity persists in .lchat-dev-hooks.json (cleared on any
+// fresh instance, like the dev bot's). --no-hooks skips all of it. Dev-you is
+// seeded as an ADMIN member (grants_admin on the invite + a best-effort role
+// patch for pre-existing instances) so the in-app admin surfaces — Admin,
+// Webhooks — work without the console.
import { spawn, execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
@@ -65,11 +75,13 @@ import {
CONFIG_PATH,
DEFAULT_DB_PATH,
BOT_SESSION_PATH,
+ HOOKS_SESSION_PATH,
dbFiles,
readConfig,
writeConfig,
clearConfig,
clearBotSession,
+ clearHooksSession,
} from "./dev-config.mjs";
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -92,7 +104,9 @@ if (process.argv.includes("-h") || process.argv.includes("--help")) {
" --clean delete .lchat-dev.json + its db files and start fresh\n" +
" --onboarding fresh instance with no buddies or chats (default: Dev Buddy + group chat)\n" +
" --port N override the stored/auto port (changes origin — may drop your browser session)\n" +
- " --buddy-name / --group-name labels used only on a fresh seed\n",
+ " --buddy-name / --group-name labels used only on a fresh seed\n" +
+ " --no-hooks skip the webhook-ingest sidecar (default: runs it, so the\n" +
+ " Webhooks app works end-to-end — curl its printed URL locally)\n",
);
process.exit(0);
}
@@ -102,6 +116,7 @@ const GROUP_NAME = arg("group-name", "Dev chat");
const explicitPort = arg("port");
const isOnboarding = process.argv.includes("--onboarding");
const isClean = process.argv.includes("--clean");
+const wantHooks = !process.argv.includes("--no-hooks");
function log(msg) {
process.stderr.write(`lchat-dev-up: ${msg}\n`);
@@ -188,14 +203,16 @@ async function seed(base, adminToken) {
// the same shape shell.js's addDevice() builds — so opening the link signs
// the browser straight in as this already-provisioned member. We keep the
// keypair in the config so later runs can re-issue a fresh add-device link
- // for the same account without re-seeding.
+ // for the same account without re-seeding. grants_admin makes dev-you an
+ // ADMIN member (belt: patchAdminRole braces it) — the in-app admin surfaces
+ // (Admin, Webhooks) all work without the console token.
const youKp = await generateUserKeypair();
const youT = randomSecret();
const youDerived = await deriveInvite(youT);
const youWrapped = await encrypt(youDerived.wrapKey, { pub: youKp.publicKey, priv: youKp.privateKey });
const youCreate = await postJSON(
`${base}/api/admin/key-invites`,
- { id: youDerived.id, wrapped: youWrapped, claim_hash: youDerived.claimHash },
+ { id: youDerived.id, wrapped: youWrapped, claim_hash: youDerived.claimHash, grants_admin: true },
admin,
);
@@ -269,12 +286,70 @@ function patchInviteThread(dbPath, inviteId, threadId) {
}
}
+// Best-effort, idempotent: make dev-you an admin MEMBER (role, not the console
+// token) — the in-app admin surfaces (Admin, Webhooks) gate on it. Fresh seeds
+// get it via the invite's grants_admin; this patch also promotes a "you" from
+// an instance seeded before that existed. Same throwaway-dev-DB caveat as
+// patchInviteThread.
+function patchAdminRole(dbPath, userId) {
+ if (!userId || !SAFE_ID.test(userId)) return;
+ try {
+ execFileSync("sqlite3", [dbPath, `UPDATE users SET role = 'admin' WHERE id = '${userId}';`]);
+ } catch (e) {
+ log(`couldn't grant the admin role (${e.message}) — in-app admin surfaces won't show`);
+ }
+}
+
+// Mint the hooks service-identity invite (only when no session is stored — the
+// sidecar persists its identity like the dev bot does) and spawn
+// `lchat hooks run` on its own local TCP port: the same binary, the real
+// provision → poll → encrypt → post path, just with no router in front. The
+// server is told the port via LCHAT_HOOKS_BASE so the Webhooks app displays
+// URLs that actually work on this machine.
+async function startHooksSidecar(binPath, base, adminToken, hooksPort) {
+ let invite = "";
+ if (!existsSync(HOOKS_SESSION_PATH)) {
+ const T = randomSecret();
+ const derived = await deriveInvite(T);
+ await postJSON(
+ `${base}/api/admin/key-invites`,
+ { label: "Webhooks", id: derived.id, wrapped: "", claim_hash: derived.claimHash, agent: true, hooks_service: true },
+ { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` },
+ );
+ invite = T;
+ log("minted a hooks service invite (the sidecar claims it on first boot)");
+ }
+ const child = spawn(binPath, ["hooks", "run"], {
+ env: {
+ ...process.env,
+ LCHAT_BASE: base,
+ LCHAT_SESSION: HOOKS_SESSION_PATH,
+ LCHAT_INVITE: invite,
+ LCHAT_SOCKET: "", // never inherit a socket path from the ambient env
+ ADDR: `127.0.0.1:${hooksPort}`,
+ },
+ });
+ child.stdout.pipe(process.stderr);
+ child.stderr.pipe(process.stderr);
+ const deadline = Date.now() + 10_000;
+ while (Date.now() < deadline) {
+ if (child.exitCode !== null) throw new Error(`hooks sidecar exited early (code ${child.exitCode})`);
+ try {
+ if ((await fetch(`http://127.0.0.1:${hooksPort}/healthz`)).ok) return child;
+ } catch {}
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ child.kill();
+ throw new Error("hooks sidecar didn't become ready in time");
+}
+
async function main() {
if (isClean) {
const stale = readConfig();
const staleDb = stale?.dbPath || DEFAULT_DB_PATH;
for (const f of dbFiles(staleDb)) rmSync(f, { force: true });
clearConfig();
+ clearHooksSession();
log(`cleaned ${path.basename(CONFIG_PATH)} + its db — starting fresh`);
}
@@ -291,20 +366,30 @@ async function main() {
clearBotSession();
log("fresh instance — cleared the stored dev-bot session (it'll re-provision)");
}
+ // Same for the hooks sidecar's service identity.
+ if (!reusing && existsSync(HOOKS_SESSION_PATH)) {
+ clearHooksSession();
+ log("fresh instance — cleared the stored hooks-sidecar session (it'll re-provision)");
+ }
const onboarding = reusing ? existing.mode === "onboarding" : isOnboarding;
const adminToken = reusing ? existing.adminToken : randomBytes(16).toString("hex");
const port = explicitPort ? Number(explicitPort) : reusing ? existing.port : await freePort();
const base = `http://127.0.0.1:${port}`;
+ // The hooks sidecar's port is as sticky as the server's, so webhook URLs
+ // already handed to outside senders keep working across restarts.
+ const hooksPort = wantHooks ? (reusing && existing.hooksPort) || (await freePort()) : null;
const buildDir = mkdtempSync(path.join(tmpdir(), "lchat-dev-"));
const binPath = path.join(buildDir, "lchat");
let cleaned = false;
let server;
+ let hooksSidecar;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
+ hooksSidecar?.kill();
server?.kill();
// Keep the db + config on purpose — that's what makes restarts reuse them.
rmSync(buildDir, { recursive: true, force: true });
@@ -319,7 +404,16 @@ async function main() {
log(`starting server on ${base} (db ${path.basename(dbPath)})...`);
server = spawn(binPath, [], {
cwd: buildDir,
- env: { ...process.env, LCHAT_DB: dbPath, LCHAT_ADMIN_TOKEN: adminToken, ADDR: `127.0.0.1:${port}` },
+ env: {
+ ...process.env,
+ LCHAT_DB: dbPath,
+ LCHAT_ADMIN_TOKEN: adminToken,
+ ADDR: `127.0.0.1:${port}`,
+ // With the sidecar on a bare local port (no router, no in.<host> DNS),
+ // the server tells clients the real ingest base so the Webhooks app
+ // displays URLs that work on this machine.
+ ...(hooksPort ? { LCHAT_HOOKS_BASE: `http://127.0.0.1:${hooksPort}` } : {}),
+ },
});
server.stdout.pipe(process.stderr);
server.stderr.pipe(process.stderr);
@@ -369,12 +463,30 @@ async function main() {
inviteType = "Regular (user invite)";
}
+ // Dev-you is an admin member (fresh seeds via the invite's grants_admin;
+ // this idempotent patch also promotes a "you" seeded before that existed),
+ // so the in-app admin surfaces — Admin, Webhooks — work out of the box.
+ if (cfg.you?.userId) patchAdminRole(dbPath, cfg.you.userId);
+
+ if (hooksPort) cfg.hooksPort = hooksPort;
writeConfig(cfg);
+ if (hooksPort) {
+ try {
+ hooksSidecar = await startHooksSidecar(binPath, base, adminToken, hooksPort);
+ } catch (e) {
+ // The chat server is still useful without ingest — degrade, loudly.
+ log(`hooks sidecar failed to start (${e.message}) — continuing without webhook ingest`);
+ }
+ }
+
console.log("");
console.log(`Server: ${base} ${reusing ? "(reused)" : "(fresh)"}`);
console.log(`Admin: ${cfg.adminURL}`);
console.log(`Config: ${CONFIG_PATH} (lchat-dev-bot reads this)`);
+ if (hooksSidecar) {
+ console.log(`Webhooks: http://127.0.0.1:${hooksPort} (ingest — create one in the app: Apps → Webhooks)`);
+ }
console.log(`Type: ${inviteType}`);
if (cfg.inviteURL) {
console.log(`Invite ID: ${cfg.inviteId}`);
@@ -392,6 +504,12 @@ async function main() {
if (reusing) console.log(" (Already signed in from before? Just reload — the link is only if you need it.)");
}
console.log("");
+ if (hooksSidecar) {
+ console.log("🪝 Webhook round-trip: open Apps → Webhooks, create one (it deploys within ~10s),");
+ console.log(" then: curl -X POST -d '{\"hello\":\"world\"}' '<the URL the app shows>'");
+ console.log(" The event lands encrypted in the chosen thread (typed events render only once");
+ console.log(" an app claims them — check the sidecar log lines above to watch delivery).");
+ }
console.log("Ctrl-C stops the server; the db + config are kept (use --clean to wipe them).");
console.log("");
diff --git a/web/static/shell.js b/web/static/shell.js
index 2948f03..a4d965d 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -5266,13 +5266,22 @@ setElevenTour(async () => {
const adminAuth = () => ({ Authorization: "Bearer " + session.token, "Content-Type": "application/json" });
// hookIngestURL: where a webhook's requests land — the in.<host> ingest origin
// (docs/webhooks.md), derived from the page host so custom domains follow.
-const hookIngestURL = (secret) => `https://in.${location.host}/h/${secret}`;
+// The server can override the derivation via ingest_base on /api/hooks/service
+// (LCHAT_HOOKS_BASE — local dev runs the sidecar on a bare port, no router),
+// cached here by fetchHooksService, which every webhook surface calls first.
+let hooksIngestBase = "";
+let hooksServiceFetched = false;
+const hookIngestURL = (secret) => `${hooksIngestBase || `https://in.${location.host}`}/h/${secret}`;
// fetchHooksService: the hooks sidecar's service identity ({user_id, public_key},
// {} when none is provisioned) — the seal target for webhook definitions.
async function fetchHooksService() {
try {
const r = await fetch(`/api/hooks/service?token=${encodeURIComponent(session.token)}`);
- return r.ok ? await r.json() : null;
+ if (!r.ok) return null;
+ const svc = await r.json();
+ if (svc && typeof svc.ingest_base === "string") hooksIngestBase = svc.ingest_base;
+ hooksServiceFetched = true;
+ return svc;
} catch {
return null;
}
@@ -5376,6 +5385,8 @@ setSetupBridge({
},
hooksList: async () => {
try {
+ // URLs below need the (possibly overridden) ingest base — warm it once.
+ if (!hooksServiceFetched) await fetchHooksService();
const r = await fetch("/api/hooks", { headers: adminAuth() });
if (!r.ok) return null;
const { hooks } = await r.json();