Skip to content

feat(webhook): add local HTTP transport to account-webhook#6

Merged
I-am-nothing merged 1 commit into
mainfrom
feat/v1-webhook-local-http
May 18, 2026
Merged

feat(webhook): add local HTTP transport to account-webhook#6
I-am-nothing merged 1 commit into
mainfrom
feat/v1-webhook-local-http

Conversation

@I-am-nothing

Copy link
Copy Markdown
Contributor

Why

cmd/account-webhook is currently Lambda-only and refuses to start outside the Lambda runtime. That blocks the obvious local dev loop for verifying signature verification + idempotency + handler dispatch against real test-mode events:

stripe listen --forward-to localhost:8092/webhook

The sister cmd/account-api binary already runs in dual mode, and so does every comparable Lambda in api-platform (cmd/account, cmd/applications, cmd/dispatch). This PR brings account-webhook in line with that convention.

What

One body of logic, two thin transports — main() detects the Lambda runtime and dispatches:

if isLambda() {
    lambda.Start(proxyHandler(router))   // production
} else {
    http.ListenAndServe(":"+port, mux)   // local HTTP
}

Both transports decode the request body + + "Stripe-Signature" + header and feed them to the same webhook.Router.Process — the verifier and per-event handlers don't change.

Code changes

File Change
cmd/account-webhook/main.go Refactored: env-reading moved out of init() into buildRouter(); proxyHandler and new httpHandler are closures taking *webhook.Router (testable); main() dispatches via isLambda(); body wrapped in http.MaxBytesReader (512 KB) for local-path defense.
cmd/account-webhook/main_test.go New — 5 transport-layer tests using local stubVerifier + stubStore. Covers bad signature, unhandled event, duplicate (idempotency short-circuit), and the Lambda transport's lowercase-header fallback.
Makefile New make dev-webhook target.
CLAUDE.md Quickstart updated.

Behavior matrix

Trigger Mode Port Path
AWS_LAMBDA_FUNCTION_NAME set Lambda n/a API-Gateway-routed
Otherwise Local HTTP ACCOUNT_WEBHOOK_PORT (default 8092) /webhook

Why AWS_LAMBDA_FUNCTION_NAME?

Single env var, matching cmd/account-api/main.go:251 and api-platform/internal/shared/config/config.go. The first draft of this PR also probed AWS_EXECUTION_ENV; the /simplify reuse review flagged the divergence — fixed in this PR.

How to use locally

# 1. Boot Postgres
make db && make db-init

# 2. Source the dev env (contains STRIPE_WEBHOOK_SECRET, DATABASE_URL)
set -a; source .env.local; set +a

# 3. Start the webhook receiver on :8092
make dev-webhook

# 4. In another terminal — forward real Stripe test events
stripe listen --forward-to localhost:8092/webhook
# (copy the printed whsec_ into .env.local if not already there)

# 5. Trigger a test event from a third terminal
stripe trigger customer.created

The handler verifies signatures, marks the event processed (idempotent), and writes the side effects to ms_billing.*.

/simplify pass

Ran the 3-agent review before push. Applied fixes:

  • A1#1 (HIGH) — Aligned isLambda() to use AWS_LAMBDA_FUNCTION_NAME only (was probing two env vars; divergent from sister binary).
  • A2#1 / A3#3 (HIGH) — Moved defer r.Body.Close() to handler entry so the error path still releases the body.
  • A3#4 (MEDIUM) — Wrapped body read with http.MaxBytesReader (512 KB) to defend the local HTTP path against pathological dev requests; Stripe caps webhook payloads at ~256 KB.
  • A2#6 (LOW) — Renamed localWebhookPathwebhookPath (the path applies in both transports, not just local).
  • A2#8 (LOW) — Used http.StatusBadRequest constant instead of magic 400.
  • A2#3, A2#4, A2#5, A2#9 (LOW) — Trimmed unnecessary comments + inlined a one-line silentLogger() test helper.

What's NOT in this PR (deferred to a dedicated refactor)

The /simplify Code Reuse review flagged that this PR introduces the second cmd binary in the repo, which is the natural inflection point to:

  1. Graduate cmd/account-api's open-coded helpers (mustEnv, mustPgxPool, port resolution, IsLambda, JSON response writer) into internal/shared/config/ and internal/shared/httputil/ so both binaries call into one place.
  2. Extract fakeVerifier + fakeStore from internal/account/webhook/router_test.go into a internal/account/webhook/webhooktest/ subpackage shared between that test and this new cmd/account-webhook/main_test.go.

That's a cross-binary cleanup with its own scope and review surface — opening as a separate PR per the project's "separate PRs per component" convention. This PR's value (unblocking local webhook testing) doesn't depend on it.

Verification

go vet ./...                  → clean
go build ./...                → clean
go test ./...                 → all packages pass
go test ./cmd/account-webhook → 5 new tests pass

🤖 Generated with Claude Code

Currently account-webhook is Lambda-only and refuses to start outside
the Lambda runtime. That blocks the obvious local dev loop:

    stripe listen --forward-to localhost:8092/webhook

Mirror the api-platform pattern (cmd/account/main.go:251-260,
cmd/applications/main.go, cmd/dispatch/main.go) where the same binary
auto-detects Lambda via AWS_LAMBDA_FUNCTION_NAME and falls back to
http.ListenAndServe otherwise. One body of logic, two thin transports.

What changed:

- cmd/account-webhook/main.go
  - main() dispatches on isLambda():
      lambda.Start(proxyHandler(router))      // Lambda
      http.ListenAndServe(":"+port, mux)      // local HTTP
  - Env-reading moved out of init() into buildRouter() so tests don't
    trip env validation when the test binary starts.
  - proxyHandler refactored to closure constructor taking *webhook.Router
    (testability — closures over the shared dep).
  - New httpHandler(*webhook.Router) http.HandlerFunc mirrors
    proxyHandler for the HTTP transport.
  - New writeJSONResponse helper for HTTP; existing jsonResponse renamed
    proxyResponse for accuracy (both transports emit JSON; the
    distinction is the wire envelope).
  - Body read wrapped in http.MaxBytesReader (512 KB ceiling) to defend
    the local HTTP path against pathological dev requests; Stripe caps
    webhook payloads at ~256 KB.
  - isLambda() now checks AWS_LAMBDA_FUNCTION_NAME only (matches
    cmd/account-api/main.go:251). The earlier AWS_EXECUTION_ENV fallback
    was unnecessary and divergent.
  - defer r.Body.Close() registered at handler entry so the error path
    still releases the body.

- cmd/account-webhook/main_test.go (new)
  - 5 tests against the new closure-form handlers using local
    stubVerifier + stubStore implementations:
      TestHTTPHandler_BadSignature   (400 + bad signature)
      TestHTTPHandler_UnhandledEvent (200 + unhandled)
      TestHTTPHandler_Duplicate      (200 + duplicate; short-circuits dispatch)
      TestProxyHandler_BadSignature  (Lambda transport, same router contract)
      TestProxyHandler_LowercaseHeaderFallback
                                     (API Gateway lowercase header probe)

- Makefile: new `make dev-webhook` target (`cd cmd/account-webhook && go run .`)
- CLAUDE.md: Quickstart updated to document both local dev paths.

What's NOT in this PR (deferred to a dedicated refactor):

The /simplify Code Reuse review flagged that this PR introduces the
second cmd binary in the repo, which is the natural inflection point to
graduate cmd/account-api's open-coded helpers (mustEnv, mustPgxPool,
Port, IsLambda, the JSON-response writer) into internal/shared/config/
and internal/shared/httputil/, and to extract the fakeVerifier +
fakeStore from internal/account/webhook/router_test.go into a
webhooktest subpackage shared between this test and the existing
router_test.go.

That's a cross-binary cleanup with its own scope and review surface —
opening as a separate PR per the project's "separate PRs per
component" convention. This PR's value (unblocking local webhook
testing) doesn't depend on it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@I-am-nothing I-am-nothing merged commit c6aa595 into main May 18, 2026
1 check passed
I-am-nothing added a commit that referenced this pull request Jun 16, 2026
Milestone D PR #3 — the ms_billing usage-metering storage + ingest spine.
Metering is declaration-first: a module declares each metric once via
ms.Meter(name, kind, ms.Unit, ms.Price); the declaration flows into the
manifest and api-platform syncs it into metric_definitions via
SetMetricDefinitions. kind is never on the wire (the catalog is
authoritative) and an undeclared metric is rejected at ingest.

Migrations 006-010 (born-clean, all ms_billing.*):
- 006 metric_definitions + metric_kind enum (manifest-fed catalog;
  unit_price_micros = final per-unit customer price, NULL = unpriced)
- 007 usage_events (raw, immutable, idempotent on event_id; lazy NULL
  account; declared kind snapshotted)
- 008 billing_periods (table only; its writer ships with the cycle binary)
- 009 usage_aggregates (snapshot billable record; 10/10 = NO markup for
  custom metrics; margin_share_class is developer-settlement only)
- 010 module_visibility (developer margin-share mirror; default private)

internal/account/usage (new pkg) — Store + pgxStore + in-memory fake +
Service:
- RecordUsage: idempotent ON CONFLICT(event_id) DO NOTHING; resolves the
  declared kind from the catalog and REJECTS an undeclared or retired
  metric (INVALID_INPUT); rejects reserved platform.* / infra.*; lazy
  owner-account resolution.
- GetUsageSummary: live current-period charged per metric = quantity ×
  declared unit_price (NO blanket 1.2×; custom metric = declared price).
- SetMetricDefinitions: validates the full set, then upserts it in ONE
  transaction (all-or-nothing) so the catalog is never partially synced.
- SetModuleVisibility: upserts the developer margin-share mirror.

cmd/account-api — dispatcher cases + chi routes; RecordUsage gated by a
dedicated meter secret/header (METER_SECRET / X-MS-Meter-Secret), never
overloading X-MS-Internal-Secret.

Deferred to PR #5/#6: period rollup + per-kind aggregation, the
flat-1.2× platform-infra markup, OpenPeriodForAccount, Stripe charge +
cycle binary + migrations 011-013.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
I-am-nothing added a commit that referenced this pull request Jun 18, 2026
feat: Stripe charge + billing-cycle (usage/arrears leg, Milestone D PR #6)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant