From ec0ab4dbb31904ff43e13fd7835282e07408160f Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 20 Aug 2026 09:38:32 +0700 Subject: [PATCH] fix(deploy): wire Phase 3 services + GitHub git-sync webhook - apps/api: assertWebhookAuth now verifies GitHub X-Hub-Signature-256 HMAC (raw-body HMAC-SHA256) AND the manual x-webhook-secret header. GitHub does not send a custom header, so only the HMAC path made the push webhook work. - root package.json: api/worker scripts use absolute bun path + direct-file form (bun --cwd apps/api run dev errored in bun 1.3.14). - deploy/*.service: ExecStart uses /home/code/.bun/bin/bun (systemd PATH lacks bun). - GitHub push webhook created -> https://wiki.asepharyana.my.id/hooks/reindex (verified: ping + push deliveries return 200, worker drains, 0 failed). - Caddy: expose /hooks/* + /health on wiki.asepharyana.my.id -> :4020. Services mcpedia-api + mcpedia-worker now enabled + active on host. --- .hermes/plans/phase3-deploy.md | 24 ++++++++++++++++++++++++ apps/api/src/index.ts | 27 +++++++++++++++++++++++---- deploy/mcpedia-api.service | 3 ++- deploy/mcpedia-worker.service | 3 ++- package.json | 4 ++-- 5 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 .hermes/plans/phase3-deploy.md diff --git a/.hermes/plans/phase3-deploy.md b/.hermes/plans/phase3-deploy.md new file mode 100644 index 0000000..aa03593 --- /dev/null +++ b/.hermes/plans/phase3-deploy.md @@ -0,0 +1,24 @@ +# Phase 3 — Deploy + git-sync wiring (remaining work) + +Status: Phase 3/4 code is DONE and e2e-verified (webhook enqueue -> worker drain, 0 failed). +What was missing on the host: API + worker never ran as systemd services, and the GitHub +push webhook was never created. Also a real integration bug: `assertWebhookAuth` only +accepts a plain `x-webhook-secret` header, which GitHub does NOT send (GitHub delivers +`X-Hub-Signature-256` = HMAC-SHA256 of raw body). So a real GitHub webhook would 401. + +## Changes +1. `apps/api/src/index.ts` — `assertWebhookAuth` now verifies GitHub `X-Hub-Signature-256` + (HMAC-SHA256 of raw body w/ WEBHOOK_SECRET) and still accepts `x-webhook-secret` for manual tests. +2. root `package.json` scripts — `api`: `bun --cwd apps/api run dev` -> `bun --cwd apps/api src/index.ts` + (the `run dev` form errors in bun 1.3.14; direct-file form verified booting + health). `worker` -> same form for consistency. +3. systemd — `cp deploy/*.service /etc/systemd/system`, `daemon-reload`, `enable --now mcpedia-api mcpedia-worker`. +4. Caddy — expose `/hooks/*` on `wiki.asepharyana.my.id` -> :4020 (no new DNS). Keep web on :4016. +5. GitHub webhook — `gh api repos/asepharyana/mcpedia/hooks` POST: + `https://wiki.asepharyana.my.id/hooks/reindex`, content_type json, secret=WEBHOOK_SECRET, events=push. + +## Verification +- `systemctl is-active mcpedia-api mcpedia-worker` == active. +- `curl /health` on :4020 -> ok. +- `curl -X POST https://wiki.asepharyana.my.id/hooks/reindex -H "X-Hub-Signature-256: ..."` (or x-webhook-secret) -> 200 + jobId; worker drains. +- `gh api .../hooks` lists the webhook. +- `turbo run typecheck` green; commit + push. diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d59001a..acd68fc 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,5 +1,7 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; +import type { Context as HonoContext } from "hono"; +import { createHmac, timingSafeEqual } from "node:crypto"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { db } from "@mcpedia/db"; import { appRouter } from "./router"; @@ -22,8 +24,25 @@ app.get("/health", (c) => c.json({ ok: true })); // Shared guard for the git-sync webhooks: require `x-webhook-secret` header to // match the configured secret. Reject anything else with 401. -function assertWebhookAuth(c: { req: { header: (k: string) => string | undefined } }): boolean { - const provided = c.req.header("x-webhook-secret"); +// Verify a git-provider webhook. Supports GitHub's native HMAC signature +// (X-Hub-Signature-256 = HMAC-SHA256 of the raw body with the webhook secret) and a +// plain `x-webhook-secret` header for manual/local triggers. GitHub does NOT send a +// custom header, so the HMAC path is what a real GitHub delivery will hit. +async function assertWebhookAuth(c: HonoContext): Promise { + if (!WEBHOOK_SECRET) return false; + const raw = c.req.raw; + const ghSig = raw.headers.get("x-hub-signature-256"); + if (ghSig && ghSig.startsWith("sha256=")) { + try { + const body = await raw.text(); + const mac = createHmac("sha256", WEBHOOK_SECRET).update(body).digest("hex"); + const expected = `sha256=${mac}`; + return timingSafeEqual(Buffer.from(ghSig), Buffer.from(expected)); + } catch { + return false; + } + } + const provided = raw.headers.get("x-webhook-secret"); return provided != null && provided === WEBHOOK_SECRET; } @@ -32,13 +51,13 @@ function assertWebhookAuth(c: { req: { header: (k: string) => string | undefined // POST /hooks/index?slug=... -> enqueue a single document reindex // Returns the created job id(s). The worker processes them asynchronously. app.post("/hooks/reindex", async (c) => { - if (!assertWebhookAuth(c)) return c.json({ ok: false, error: "unauthorized" }, 401); + if (!(await assertWebhookAuth(c))) return c.json({ ok: false, error: "unauthorized" }, 401); const job = await enqueueFullIndex("git-push"); return c.json({ ok: true, jobId: job.id, kind: "full" }); }); app.post("/hooks/index", async (c) => { - if (!assertWebhookAuth(c)) return c.json({ ok: false, error: "unauthorized" }, 401); + if (!(await assertWebhookAuth(c))) return c.json({ ok: false, error: "unauthorized" }, 401); const slug = c.req.query("slug"); if (!slug) return c.json({ ok: false, error: "slug query param required" }, 400); // slug is the relative path without extension, e.g. docs/websocket/contract diff --git a/deploy/mcpedia-api.service b/deploy/mcpedia-api.service index 17fc02f..7b1d49b 100644 --- a/deploy/mcpedia-api.service +++ b/deploy/mcpedia-api.service @@ -9,7 +9,8 @@ WorkingDirectory=/home/code/mcpedia # Loads DATABASE_URL, REDIS_*, EMBED_*, WEBHOOK_SECRET from the repo .env # (.env is gitignored; for prod, point this at a deployed secret file). EnvironmentFile=/home/code/mcpedia/.env -ExecStart=/usr/bin/env bun run api +# Absolute bun path (systemd has a minimal PATH; /usr/bin/env bun fails). +ExecStart=/home/code/.bun/bin/bun --cwd apps/api src/index.ts Restart=on-failure RestartSec=5 User=code diff --git a/deploy/mcpedia-worker.service b/deploy/mcpedia-worker.service index ed08f8d..d31d39e 100644 --- a/deploy/mcpedia-worker.service +++ b/deploy/mcpedia-worker.service @@ -9,7 +9,8 @@ WorkingDirectory=/home/code/mcpedia # Loads DATABASE_URL, REDIS_*, EMBED_* from the repo .env # (.env is gitignored; for prod, point this at a deployed secret file). EnvironmentFile=/home/code/mcpedia/.env -ExecStart=/usr/bin/env bun run worker +# Absolute bun path (systemd has a minimal PATH; /usr/bin/env bun fails). +ExecStart=/home/code/.bun/bin/bun --cwd apps/worker src/index.ts Restart=on-failure RestartSec=5 User=code diff --git a/package.json b/package.json index 8483c55..69c6c52 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,9 @@ "typecheck": "turbo run typecheck", "index": "bun run scripts/indexer.ts", "enqueue": "bun run scripts/enqueue.ts", - "worker": "bun --cwd apps/worker run start", + "worker": "/home/code/.bun/bin/bun --cwd apps/worker src/index.ts", "mcp": "bun --cwd apps/mcp run start", - "api": "bun --cwd apps/api run dev" + "api": "/home/code/.bun/bin/bun --cwd apps/api src/index.ts" }, "devDependencies": { "@trpc/client": "^11.18.0",