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
10 files changed,
+222
−17
CLAUDE.md+1 −0bootstrap.go+8 −0docs/webhooks.md+15 −4hooks.go+8 −1hooks_test.go+30 −0main.go+12 −5tools/dev+3 −1tools/dev-config.mjs+10 −0tools/lchat-dev-up.mjs+122 −4web/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(); |