fix(deploy): wire Phase 3 services + GitHub git-sync webhook
CI / typecheck + build (turbo) (push) Canceled after 0s
CI / typecheck + build (turbo) (push) Canceled after 0s
- 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.
This commit is contained in:
@@ -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.
|
||||
+23
-4
@@ -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<boolean> {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user