Skip to content
Merged
415 changes: 415 additions & 0 deletions docs/plans/2026-05-29-001-feat-gateway-announce-webhook-plan.md

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions packages/gateway/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ Effect.tryPromise(() => runtimeFn(args)) // catches promise rejections

All gateway code outside `runtime-effect.ts` works exclusively in Effect — `Effect.Effect<A, E, R>` everywhere. Subagents asked to add a new runtime call should add the wrapper to `runtime-effect.ts` first, never import directly from `@fro-bot/runtime` outside that adapter.

### Effect surface used (Unit 4)
### Effect surface used

- **Core** (`Effect.Effect`, `pipe`, `Effect.tryPromise`, `Effect.flatMap`, `Effect.gen`, `Effect.runPromise`, `Effect.try`, `Effect.succeed`, `Effect.fail`, `Effect.either`, `Effect.void`, `Effect.catchAll`) — composing async error paths
- **Schema** (`Schema.Struct`, `Schema.Union`, `Schema.Literal`, `Schema.NullOr`, `Schema.decodeUnknownEither`, `ParseResult.ArrayFormatter`) — announce webhook payload validation in `src/http/announce-schema.ts`. Decode errors are mapped to content-free reason strings via the typed formatter (no internal-shape casts).

Not used in Unit 4 (planned for Unit 6+):
- **Schedule** (`Schedule.exponential`, `Schedule.recurs`) — retry policies; not yet wired
- **Schema** (`Schema.Struct`, `Schema.decodeUnknown`) — payload validation; not yet wired
Not yet wired:
- **Schedule** (`Schedule.exponential`, `Schedule.recurs`) — retry policies; not yet used

Not used at this scope:
- Effect runtime / Layer / Context (overkill for v1; revisit when DI complexity warrants)
Expand All @@ -52,8 +52,10 @@ Not used at this scope:
- `src/discord/` — Discord.js integration. Client construction with safe `allowedMentions` defaults, command registry, mention handler.
- `src/discord/channels.ts` — channel creation helper used by the add-project flow. `createChannelWithCollisionSuffix` always creates a fresh channel; it never returns an existing one. Tries the exact name first, then `name-2` through `name-10`, skipping any candidate whose name is already taken.
- `src/discord/commands/add-project.ts` — `/fro-bot add-project` slash command. Orchestrates the 5-phase flow (PRE_FLIGHT → CLONING → CREATING_CHANNEL → WRITING_BINDING → READY). Depends on `channels.ts` for channel creation, `workspace-api/client.ts` for repo cloning, `bindings/store.ts` for durable binding persistence, and `github/app-client.ts` for GitHub App token acquisition.
- `src/discord/presence.ts` — resolves a channel by ID via `client.channels.fetch` and posts an embed with `allowedMentions: {parse: []}`. Used by the announce webhook to post control-plane presence messages as the Fro Bot user.
- `src/workspace-api/` — HTTP client for the workspace-agent sidecar service. `WorkspaceClient` wraps the `/clone` endpoint and maps HTTP error shapes to typed `Result<CloneSuccess, CloneError>` values. The client is injected into `add-project.ts` via `AddProjectDeps`.
- `src/shutdown.ts` — SIGTERM handler with 25s drain.
- `src/http/` — the inbound announce webhook (`POST /v1/announce`), the gateway's only HTTP ingress. Hono server (`server.ts`) reads the raw body and maps the framework-agnostic handler (`announce-handler.ts`) result to a response. The handler runs an ordered fail-closed pipeline: 8 KB size cap → rate limit → required headers → HMAC verify → timestamp window → replay reserve → JSON parse → exact-string `fired_at` cross-check → schema decode → embed render → Discord post. Auth failures (`hmac_invalid` / `timestamp_expired` / `replayed`) return an identical generic 401 so the caller cannot tell which check failed. Supporting modules: `hmac.ts` (HMAC-SHA256 over `timestamp + "." + rawBody`, `timingSafeEqual`), `announce-schema.ts` (Effect Schema), `templates.ts` (event_type → embed), `replay-cache.ts` (atomic reserve/commit/release seen-signature cache), `rate-limit.ts` (socket-keyed token bucket, bounded key count). Config: `GATEWAY_WEBHOOK_SECRET`, `GATEWAY_PRESENCE_CHANNEL_ID`, `GATEWAY_HTTP_PORT`.
- `src/shutdown.ts` — SIGTERM/SIGINT handler with a 25s drain. Races `client.destroy()` and the announce server's `close()` against the drain timer; a server-close failure is logged without masking client teardown. New announce requests are refused with 503 while draining.

## Configuration knobs

Expand Down
4 changes: 3 additions & 1 deletion packages/gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
},
"dependencies": {
"@fro-bot/runtime": "workspace:*",
"@hono/node-server": "1.19.14",
"discord.js": "14.26.4",
"effect": "3.21.2"
"effect": "3.21.2",
"hono": "4.12.23"
}
}
124 changes: 124 additions & 0 deletions packages/gateway/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ beforeEach(() => {
'GITHUB_APP_PRIVATE_KEY',
'GITHUB_APP_PRIVATE_KEY_FILE',
'GATEWAY_GITHUB_APP_INSTALL_URL',
'GATEWAY_WEBHOOK_SECRET',
'GATEWAY_WEBHOOK_SECRET_FILE',
'GATEWAY_PRESENCE_CHANNEL_ID',
'GATEWAY_PRESENCE_CHANNEL_ID_FILE',
'GATEWAY_HTTP_PORT',
'WORKSPACE_AGENT_URL',
]) {
delete process.env[key]
}
Expand Down Expand Up @@ -397,6 +403,8 @@ function setRequiredEnv(): void {
const keyFile = join(tmpDir, 'github-app-private-key-default')
writeFileSync(keyFile, '-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----', {mode: 0o600})
process.env.GITHUB_APP_PRIVATE_KEY_FILE = keyFile
process.env.GATEWAY_WEBHOOK_SECRET = 'test-webhook-secret'
process.env.GATEWAY_PRESENCE_CHANNEL_ID = 'test-presence-channel-id'
}

describe('loadGatewayConfig', () => {
Expand Down Expand Up @@ -935,3 +943,119 @@ describe('loadGatewayConfig — GitHub App credentials', () => {
expect(config.gatewayGitHubAppInstallUrl).toBe('https://github.com/apps/fro-bot/installations/new')
})
})

// ---------------------------------------------------------------------------
// GATEWAY_WEBHOOK_SECRET, GATEWAY_PRESENCE_CHANNEL_ID, GATEWAY_HTTP_PORT
// ---------------------------------------------------------------------------

describe('loadGatewayConfig — webhook secret, presence channel, http port', () => {
it('happy path: all three vars set → config reflects their values', () => {
// #given
setRequiredEnv()
process.env.GATEWAY_HTTP_PORT = '8080'

// #when
const config = loadGatewayConfig()

// #then
expect(config.webhookSecret).toBe('test-webhook-secret')
expect(config.presenceChannelId).toBe('test-presence-channel-id')
expect(config.httpPort).toBe(8080)
})

it('happy path: GATEWAY_HTTP_PORT unset → httpPort defaults to 3000', () => {
// #given
setRequiredEnv()
// GATEWAY_HTTP_PORT not set

// #when
const config = loadGatewayConfig()

// #then
expect(config.httpPort).toBe(3000)
})

it('error: GATEWAY_WEBHOOK_SECRET missing → throws "Missing required secret"', () => {
// #given
setRequiredEnv()
delete process.env.GATEWAY_WEBHOOK_SECRET

// #when / #then
expect(() => loadGatewayConfig()).toThrow('Missing required secret: GATEWAY_WEBHOOK_SECRET')
})

it('error: GATEWAY_PRESENCE_CHANNEL_ID missing → throws "Missing required secret"', () => {
// #given
setRequiredEnv()
delete process.env.GATEWAY_PRESENCE_CHANNEL_ID

// #when / #then
expect(() => loadGatewayConfig()).toThrow('Missing required secret: GATEWAY_PRESENCE_CHANNEL_ID')
})

it('edge: GATEWAY_HTTP_PORT = "0" → throws invalid port error', () => {
// #given
setRequiredEnv()
process.env.GATEWAY_HTTP_PORT = '0'

// #when / #then
expect(() => loadGatewayConfig()).toThrow('Invalid GATEWAY_HTTP_PORT value: "0"')
})

it('edge: GATEWAY_HTTP_PORT = "70000" → throws invalid port error', () => {
// #given
setRequiredEnv()
process.env.GATEWAY_HTTP_PORT = '70000'

// #when / #then
expect(() => loadGatewayConfig()).toThrow('Invalid GATEWAY_HTTP_PORT value: "70000"')
})

it('edge: GATEWAY_HTTP_PORT = "abc" → throws invalid port error', () => {
// #given
setRequiredEnv()
process.env.GATEWAY_HTTP_PORT = 'abc'

// #when / #then
expect(() => loadGatewayConfig()).toThrow('Invalid GATEWAY_HTTP_PORT value: "abc"')
})

it('edge: GATEWAY_HTTP_PORT = "1" → accepted (boundary, minimum port)', () => {
// #given
setRequiredEnv()
process.env.GATEWAY_HTTP_PORT = '1'

// #when
const config = loadGatewayConfig()

// #then
expect(config.httpPort).toBe(1)
})

it('edge: GATEWAY_HTTP_PORT = "65535" → accepted (boundary, maximum port)', () => {
// #given
setRequiredEnv()
process.env.GATEWAY_HTTP_PORT = '65535'

// #when
const config = loadGatewayConfig()

// #then
expect(config.httpPort).toBe(65535)
})

it('edge: GATEWAY_WEBHOOK_SECRET_FILE with trailing newline → trimmed and accepted', () => {
// #given
setRequiredEnv()
delete process.env.GATEWAY_WEBHOOK_SECRET
const secretFile = join(tmpDir, 'webhook-secret.txt')
writeFileSync(secretFile, 'file-webhook-secret\n', {mode: 0o600})
process.env.GATEWAY_WEBHOOK_SECRET_FILE = secretFile

// #when
const config = loadGatewayConfig()

// #then trailing newline trimmed
expect(config.webhookSecret).toBe('file-webhook-secret')
})
})
15 changes: 15 additions & 0 deletions packages/gateway/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export interface GatewayConfig {
readonly githubAppPrivateKey: string
readonly gatewayGitHubAppInstallUrl: string
readonly workspaceAgentUrl: string
readonly webhookSecret: string
readonly presenceChannelId: string
readonly httpPort: number
}

const MAX_SECRET_BYTES = 4096
Expand Down Expand Up @@ -316,6 +319,15 @@ export function loadGatewayConfig(): GatewayConfig {

const workspaceAgentUrl = readOptionalSecret('WORKSPACE_AGENT_URL') ?? 'http://workspace:9100'

const webhookSecret = readSecret('GATEWAY_WEBHOOK_SECRET')
const presenceChannelId = readSecret('GATEWAY_PRESENCE_CHANNEL_ID')

const rawHttpPort = readOptionalSecret('GATEWAY_HTTP_PORT') ?? '3000'
const httpPort = Number.parseInt(rawHttpPort, 10)
if (Number.isFinite(httpPort) === false || Number.isInteger(httpPort) === false || httpPort < 1 || httpPort > 65535) {
throw new Error(`Invalid GATEWAY_HTTP_PORT value: "${rawHttpPort}" (must be an integer in the range 1–65535)`)
}

return {
discordToken,
discordApplicationId,
Expand All @@ -328,5 +340,8 @@ export function loadGatewayConfig(): GatewayConfig {
githubAppPrivateKey,
gatewayGitHubAppInstallUrl,
workspaceAgentUrl,
webhookSecret,
presenceChannelId,
httpPort,
}
}
Loading
Loading