Skip to content

feat(webhooks): Wave native webhook receiver + per-business secret storage#113

Merged
chitcommit merged 3 commits into
mainfrom
feat/wave-webhook-per-tenant
May 11, 2026
Merged

feat(webhooks): Wave native webhook receiver + per-business secret storage#113
chitcommit merged 3 commits into
mainfrom
feat/wave-webhook-per-tenant

Conversation

@chitcommit
Copy link
Copy Markdown
Contributor

@chitcommit chitcommit commented May 1, 2026

Summary

Adds Wave's native webhook flow to chittyfinance, scoped per (tenant, business) pair. Implements the receiver, full HMAC-SHA256 signature verification per Wave's Webhooks Setup Guide, and admin endpoints for per-business secret storage.

Wave's webhook subscriptions are configured only via the Wave dashboard (no programmatic API exists in Wave's GraphQL schema), so operators paste the per-business URL into the Wave Webhooks page and seed the corresponding secret via the admin endpoint added here.

New endpoints (under /api/webhooks/wave/:tenantId/:businessId)

Method Path Purpose
POST / Wave native receiver (HMAC verified, dedup'd, audit-logged)
PUT /secret Admin secret storage (service-token auth)
GET /secret/exists Admin existence check (never returns the secret)
DELETE /secret Admin secret removal

Signature verification (per Wave's docs)

  • Header: x-wave-signature: t=<unix_ts>,v1=<hex_hmac_sha256>
  • Signed payload: <timestamp>.<raw_body> (raw body as-is — not re-serialized)
  • 5-minute replay window enforced
  • Constant-time hex compare

Receiver behaviour

  • Skips signature verification when no secret stored → allows initial Wave dashboard configuration ping during setup
  • Validates payload business_id matches URL parameter (defense in depth)
  • KV idempotency keyed on event_id, 7-day TTL
  • Audit-logs each recognized event via ledgerLog (ChittyLedger)
  • Treats empty body or unrecognized JSON shape as a setup ping (200 ack)

Currently supported events (per Wave Webhooks Setup Guide)

  • invoice.overdue
  • invoice.viewed
  • invoice.approved

"More supported events will be available soon"

The receiver is generic and audit-logs all schema-conformant events; specific business logic for future events is added as Wave expands the surface.

KV layout

webhook:wave:secret:<tenantId>:<businessId>  → HMAC secret
webhook:wave:dedup:<eventId>                 → 7d TTL idempotency marker

Operator setup runbook (post-merge)

For each tenant + Wave business:

  1. In Wave dashboard → Webhooks: paste https://finance.chitty.cc/api/webhooks/wave/<tenantId>/<businessId> and select trigger events
  2. Copy the secret Wave reveals
  3. curl -X PUT https://finance.chitty.cc/api/webhooks/wave/<tenantId>/<businessId>/secret \
      -H "Authorization: Bearer $CHITTY_AUTH_SERVICE_TOKEN" \
      -H "Content-Type: application/json" \
      -d "{\"secret\":\"<wave-hash-secret>\"}"
    
  4. Trigger a test event from Wave dashboard → should land in ledger as webhook.wave.<event_type>

Why no wave-api.ts changes / no auto-register / no registration script

Wave's GraphQL schema has no webhook-related mutations (verified end-to-end against the public Wave schema). All webhook subscription management is dashboard-only. Adding fake registration code would be dead/never-callable.

Test plan

  • Type check: npx tsc --noEmit clean
  • New tests: 28 passing (16 secret-storage + 12 receiver covering signature/replay/dedup/business-id-mismatch/setup-ping)
  • Full suite: 281/281 passing (was 253, +28)
  • Post-deploy: configure one Wave business webhook end-to-end against live finance.chitty.cc
  • Post-deploy: verify ledger entry webhook.wave.invoice_* lands when Wave fires a real event

Out of scope (future PRs)

  • Map invoice events to chittyfinance domain entities (e.g., create AR record on invoice.approved) — invoice events don't currently map cleanly to the transactions table, so for now we audit-log only
  • Surface Wave webhook config status in the integrations UI
  • Receiver for transaction:* / account:* events once Wave releases them

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Wave native webhook receiver with HMAC-SHA256 signature verification, replay protection, and setup-ping handling
    • Per-tenant/business secret management endpoints (store, check-exists without revealing, delete) protected by a service token
    • KV-backed idempotency/deduplication (7-day TTL) scoped per tenant/business and audit logging for accepted events
  • Tests

    • Comprehensive test suites covering signature handling, payload validation, secret admin endpoints, and deduplication/isolation behaviors

…orage

Adds Wave's native webhook flow to chittyfinance, scoped per
(tenant, business) pair to support tenants with multiple connected Wave
businesses. Wave webhook subscriptions are configured in the Wave
dashboard (no programmatic API exists per Wave's GraphQL schema), so
operators paste the per-business URL into the Wave Webhooks page and
seed the corresponding secret via the admin endpoint.

New endpoints (all under /api/webhooks/wave/:tenantId/:businessId):
- POST                       — receiver (HMAC verified, dedup'd, audit-logged)
- PUT  /secret               — admin secret-storage (service token auth)
- GET  /secret/exists        — admin existence check (never returns secret)
- DELETE /secret             — admin secret removal

Verification matches Wave's documented signature scheme:
  - Header: x-wave-signature: t=<unix_ts>,v1=<hex_hmac_sha256>
  - Signed payload: <timestamp>.<raw_body> (raw bytes, not re-serialized)
  - 5-minute replay window enforced
  - Constant-time hex compare

Receiver behaviour:
  - Skips signature verification when no secret stored (allows initial
    Wave dashboard ping during setup)
  - Validates payload business_id matches URL parameter (defense in depth)
  - KV idempotency keyed on event_id, 7-day TTL
  - Audit-logs each recognized event via ledgerLog (ChittyLedger)
  - Treats empty body or unrecognized JSON shape as a setup ping (200 ack)

Currently supported event types per Wave's Webhooks Setup Guide:
  invoice.overdue, invoice.viewed, invoice.approved
("More supported events will be available soon" — receiver is generic
and audit-logs all conformant events; specific business logic for
future events is added as Wave expands the surface.)

KV layout:
  webhook:wave:secret:<tenantId>:<businessId>  → HMAC secret
  webhook:wave:dedup:<eventId>                 → 7d TTL idempotency marker

Tests: +28 (16 secret-storage + 12 receiver) — full suite 281 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 1, 2026 13:17
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
chittyfinance dd153c0 May 01 2026, 01:17 PM

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 1, 2026

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 1, 2026

Warning

Rate limit exceeded

@coderabbitai[bot] has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 54 minutes and 2 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7ecda648-3452-45de-b35f-07a73dca80b4

📥 Commits

Reviewing files that changed from the base of the PR and between 0162d53 and ca9fbe2.

📒 Files selected for processing (1)
  • server/routes/webhooks.ts
📝 Walkthrough

Walkthrough

Adds a Wave native webhook receiver and secret-management endpoints with per-(tenant,business) KV secrets, HMAC-SHA256 signature verification with replay protection, KV-backed event deduplication (7-day TTL), Zod envelope validation, audit logging, and two new comprehensive Vitest suites covering receiver behavior and secret CRUD/auth flows.

Changes

Wave Webhook Integration

Layer / File(s) Summary
Data Shape / Schemas
server/routes/webhooks.ts
Adds a Zod schema for Wave native event envelope (event_id, event_type, business_id, data).
Core Verification & Dedup
server/routes/.../webhooks.ts
Implements verifyWaveSignature (HMAC-SHA256 over <ts>.<rawBody>, replay-window checks, constant-time compare) and KV-scoped deduplication keyed by (tenantId,businessId,event_id) with 7-day TTL.
Receiver Endpoint
server/routes/webhooks.ts
Adds POST /api/webhooks/wave/:tenantId/:businessId: reads raw body once, enforces signature when secret exists, treats empty/non-JSON as setup pings, validates business_id matches URL, deduplicates, logs accepted events, and returns structured 200/202/400/401 responses.
Secret Management Endpoints & Auth
server/routes/webhooks.ts
Adds PUT, GET /secret/exists, and DELETE at /api/webhooks/wave/:tenantId/:businessId/... storing secrets in KV under tenant/business keys, protected by CHITTY_AUTH_SERVICE_TOKEN (Bearer).
Tests
server/__tests__/webhooks-wave-receiver.test.ts, server/__tests__/webhooks-wave-secret.test.ts
New Vitest suites: receiver tests for signature formats, malformed/missing signatures, replay/skew enforcement, setup ping handling, payload validation, business_id mismatch, accepted event paths, and KV-backed idempotency; secret tests cover PUT/GET/DELETE behaviors, auth enforcement, validation, and KV isolation.
Test Wiring / Mocks
server/__tests__/*
Adds Map-backed KVNamespace stubs, env factories, fetch stubbing, helpers to build signed Requests and compute HMACs, and fixtures for event payloads.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant Server as Server (Webhook Handler)
    participant KV as KV Storage
    participant Audit as Audit Log

    Client->>Server: POST /api/webhooks/wave/:tenantId/:businessId<br/>with x-wave-signature and raw body
    Server->>Server: Read raw body once
    Server->>KV: Get secret for (tenantId,businessId)
    alt Secret exists
        Server->>Server: Parse x-wave-signature (t=...,v1=...)
        Server->>Server: Validate timestamp (replay window)
        Server->>Server: Compute HMAC-SHA256(t + "." + rawBody)
        Server->>Server: Constant-time compare signature
        alt Signature invalid or outside window
            Server-->>Client: 401 Unauthorized
        else Signature valid
            Server->>Server: Parse JSON (or treat as ping if empty/non-JSON)
            alt Empty / setup ping
                Server-->>Client: 200 OK { received: true }
            else Valid envelope
                Server->>Server: Validate business_id vs URL
                alt business_id mismatch
                    Server-->>Client: 400 Bad Request
                else business_id match
                    Server->>KV: Check dedup key (event_id)
                    alt Duplicate
                        Server-->>Client: 202 Accepted { duplicate: true }
                    else New
                        Server->>KV: Store dedup key (7-day TTL)
                        Server->>Audit: Log accepted event
                        Server-->>Client: 202 Accepted { received:true, eventId, eventType }
                    end
                end
            end
        end
    else No secret configured
        Server-->>Client: 401 { error: "webhook_not_configured" } or treat unsigned pings per logic
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • chittyapps/chittyfinance#99: Implements similar per-tenant KV-backed HMAC webhook handling, signature verification, and deduplication patterns for another provider (parallel webhook integration work).

Poem

🐰 Hop, hop—signature checks in the glen,

t and v1 dance, raw body penned,
KV keeps duplicates snug and tight,
secrets guarded through day and night,
A rabbit cheers: webhooks all set—what a delight! 🥕🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: adding a Wave webhook receiver and per-business secret storage, which aligns with the core objectives of implementing a complete Wave-native webhook integration.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/wave-webhook-per-tenant

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link
Copy Markdown

claude Bot commented May 1, 2026

Code Review — PR #113: Wave Native Webhook Receiver

Overall this is a well-structured implementation. The signature verification, KV dedup pattern, and test coverage are all solid. A few items worth addressing before merge, with one security concern that I'd flag as blocking.


🔴 Security — Unauthenticated event injection during setup window

server/routes/webhooks.ts, receiver POST handler:

When no secret is stored, signature verification is skipped entirely and the event is processed through the full handler (business_id check, dedup write, ledgerLog). This means during the window between configuring the Wave webhook URL in the Wave dashboard and calling PUT /secret, any caller who knows the endpoint URL can inject arbitrary recognized events into the audit ledger.

The intent — allowing Wave's initial test ping — is reasonable, but the current approach is too permissive. A targeted fix:

// Option A: always ack setup-ping 200 when no secret; never reach ledgerLog
if (!secret) {
  console.warn('[webhook:wave] No secret stored for', { tenantId, businessId }, '— treating as setup ping');
  return c.json({ received: true }, 200);
}
// signature verification from here on is unconditional

This still ACKs Wave's test ping (200 OK) but prevents audit-log pollution from unauthenticated events. The tradeoff is that the initial test webhook from Wave won't produce a ledger entry, which is fine since it's a synthetic ping rather than a real business event.


🟡 Stale comment in secret-storage block

Near the top of the new secret-storage section (around line 572):

// Secrets are issued by Wave when a webhook subscription is created and must be
// supplied to the verification step of the receiver (added in a follow-up PR once
// Wave's signature schema is verified).

The receiver is implemented in this PR, so this comment is outdated. Delete or update it before merge — it will mislead future readers looking for where the receiver lives.


🟡 Dedup key not scoped to tenant/business

const dedupKey = `webhook:wave:dedup:${event.event_id}`;

The dedup key is global across all tenants and businesses. If Wave ever reuses event_id values across business accounts (unlikely but undocumented), an event for tenant A could silently suppress the same event ID arriving for tenant B. Compare with the Mercury pattern to see if per-tenant scoping was intentional there. Suggested alternative:

const dedupKey = `webhook:wave:dedup:${tenantId}:${businessId}:${event.event_id}`;

The TTL storage cost is minimal and this is unambiguously correct.


🟡 isAuthorizedWaveSecretCaller uses non-constant-time token comparison

return token.length > 0 && token === expected;

Timing attacks over HTTP are difficult in practice, but the pattern elsewhere in this codebase (e.g. Mercury webhook HMAC) uses constant-time comparison. Given that service tokens are long random strings this is low risk, but worth aligning with project conventions:

// constant-time compare using the same XOR loop already used in verifyWaveSignature

Or, if you want to keep it simple, at minimum document why string equality is acceptable here.


🟡 No tenantId format validation on admin endpoints

The PUT/GET/DELETE secret endpoints extract tenantId from the URL with no format validation. A service-token caller who supplies a tenantId containing colons (e.g. "a:b") would write to a key like webhook:wave:secret:a:b:businessId, which has a different interpretation from webhook:wave:secret:a:b:businessId intended for tenant a:b. Since tenant IDs are UUIDs in the system schema, consider a quick UUID guard:

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_RE.test(tenantId)) return c.json({ error: 'invalid_tenant_id' }, 400);

✅ What's well done

  • verifyWaveSignature — correct constant-time hex comparison, proper replay-window enforcement, clean separation from Mercury's verification logic.
  • Raw body handling — reading rawBody once via c.req.text() before any parsing is exactly right; re-serializing JSON would break the HMAC.
  • event_type sanitization for the ledger action string (replace(/[^a-z0-9_]/gi, '_')) — good defensive practice.
  • Test coverage — 28 tests across signature verification, replay, business-ID mismatch, setup ping, dedup isolation, and tenant/business key isolation. The Map-backed KV stub pattern is clean and consistent with the Mercury tests.
  • GET /secret/exists confirming the response body never contains the secret value is a nice explicit test.
  • PR description is thorough and the KV layout diagram and operator runbook are genuinely useful for future operators.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Wave’s native webhook receiver and per-(tenant, business) KV secret management to the existing server/routes/webhooks.ts webhook surface, with Vitest coverage for admin secret endpoints and receiver behavior.

Changes:

  • Added Wave webhook secret CRUD endpoints (PUT/GET exists/DELETE) guarded by service-token auth.
  • Added Wave native receiver endpoint with HMAC-SHA256 signature verification, replay-window enforcement, payload validation, KV dedup, and ledger audit logging.
  • Added new Vitest suites covering secret storage and receiver behaviors (signature/replay/dedup/business binding/setup ping).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
server/routes/webhooks.ts Adds Wave per-business secret storage + native webhook receiver with signature verification and KV dedup/audit logging.
server/tests/webhooks-wave-secret.test.ts Tests Wave secret storage endpoints (auth, validation, KV isolation, idempotency).
server/tests/webhooks-wave-receiver.test.ts Tests Wave receiver signature verification, replay window, payload handling, and dedup behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/routes/webhooks.ts
Comment on lines +569 to +571
// subscription is created and must be supplied to the verification step
// of the receiver (added in a follow-up PR once Wave's signature schema
// is verified).
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The block comment says the Wave receiver is “added in a follow-up PR”, but the receiver is implemented later in this same file. Please update/remove this note so the documentation matches the current implementation (and doesn’t mislead operators about where signature verification lives).

Suggested change
// subscription is created and must be supplied to the verification step
// of the receiver (added in a follow-up PR once Wave's signature schema
// is verified).
// subscription is created and are supplied to the receiver's signature
// verification step implemented later in this file.

Copilot uses AI. Check for mistakes.
Comment thread server/routes/webhooks.ts
Comment on lines +581 to +586
function isAuthorizedWaveSecretCaller(env: { CHITTY_AUTH_SERVICE_TOKEN?: string }, authHeader: string): boolean {
const expected = env.CHITTY_AUTH_SERVICE_TOKEN;
if (!expected) return false;
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
return token.length > 0 && token === expected;
}
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isAuthorizedWaveSecretCaller does a direct string equality check against CHITTY_AUTH_SERVICE_TOKEN. Elsewhere in the codebase, service-token auth uses constant-time comparison via tokenEqual (see server/middleware/auth.ts). Consider reusing serviceAuth middleware for these endpoints or using tokenEqual here to avoid timing leaks and keep auth behavior consistent.

Copilot uses AI. Check for mistakes.
Comment thread server/routes/webhooks.ts
Comment on lines +610 to +616
const [key, ...rest] = part.split('=');
const value = rest.join('=');
if (key === 't') timestamp = value;
if (key === 'v1') signature = value;
}

if (!timestamp || !signature) return false;
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signature header parsing here doesn’t trim whitespace. If Wave (or an intermediary) formats the header like t=..., v1=... (note the space after the comma), key becomes ' v1' and verification will always fail. Trim each part/key/value before comparison (and consider normalizing hex casing) to make verification robust.

Suggested change
const [key, ...rest] = part.split('=');
const value = rest.join('=');
if (key === 't') timestamp = value;
if (key === 'v1') signature = value;
}
if (!timestamp || !signature) return false;
const trimmedPart = part.trim();
const [rawKey, ...rest] = trimmedPart.split('=');
const key = rawKey.trim();
const value = rest.join('=').trim();
if (key === 't') timestamp = value;
if (key === 'v1') signature = value;
}
if (!timestamp || !signature) return false;
signature = signature.toLowerCase();

Copilot uses AI. Check for mistakes.
Comment thread server/routes/webhooks.ts Outdated
Comment on lines +739 to +746
if (secret) {
if (!signatureHeader || !(await verifyWaveSignature(rawBody, signatureHeader, secret))) {
return c.json({ error: 'invalid_signature' }, 401);
}
} else {
console.warn('[webhook:wave] No secret stored for', { tenantId, businessId }, '— signature verification skipped');
}

Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When no secret is stored, the handler skips signature verification but continues on to parse schema-conformant JSON, write a 7-day dedup marker, and emit an audit ledgerLog. This makes the unauthenticated public endpoint writable during setup (anyone can spam audit logs / burn KV keys) until the secret is configured. Consider short-circuiting to a “setup ping” ack (no side effects) unless a secret exists, or only allowing side effects when a valid signature has been verified.

Suggested change
if (secret) {
if (!signatureHeader || !(await verifyWaveSignature(rawBody, signatureHeader, secret))) {
return c.json({ error: 'invalid_signature' }, 401);
}
} else {
console.warn('[webhook:wave] No secret stored for', { tenantId, businessId }, '— signature verification skipped');
}
if (!secret) {
console.warn('[webhook:wave] No secret stored for', { tenantId, businessId }, '— acking setup ping without side effects');
return c.json({ received: true, setup: true }, 200);
}
if (!signatureHeader || !(await verifyWaveSignature(rawBody, signatureHeader, secret))) {
return c.json({ error: 'invalid_signature' }, 401);
}

Copilot uses AI. Check for mistakes.
@chitcommit
Copy link
Copy Markdown
Contributor Author

Empirical verification of Wave's webhook surface

Queried the live Wave GraphQL endpoint (https://gql.waveapps.com/graphql/public) anonymously via introspection:

curl -sS -X POST "https://gql.waveapps.com/graphql/public" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ __schema { mutationType { fields { name } } } }"}'

Result confirmed:

  • 0 webhook-related mutations (full mutation set is invoice/customer/account/estimate/product/salesTax/moneyTransaction CRUD)
  • 0 webhook-related queries
  • 0 webhook-related types (no WebhookSubscription, no Subscription, no Event)

So the dashboard-only design in this PR is the only viable approach — there's no GraphQL surface to add wave-api.ts methods or auto-register-on-callback against. Confirmed both via the published Webhooks Setup Guide and the live introspection check.

@chitcommit chitcommit enabled auto-merge (squash) May 1, 2026 13:22
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/routes/webhooks.ts`:
- Around line 779-790: The audit entry currently passes raw event.data to
ledgerLog which may leak sensitive information; instead implement and call a
sanitizer (e.g., sanitizeWebhookData) and/or extract a whitelist of safe
identifiers (invoiceId, customerId, amount, status) and a short summary, mask
any account numbers/API keys, then pass the sanitized object to ledgerLog
(replace event.data with the return of sanitizeWebhookData(event.data) and
include event.event_type, event.event_id, tenantId, businessId as before).
- Around line 739-745: The code currently skips signature verification when the
stored Wave secret is missing, allowing any schema-valid payload to proceed to
deduplication and ledgerLog; change this so that when secret is falsy you only
accept explicit setup/health-check payloads (e.g., an empty body or a known safe
"ping"/"setup" marker) and otherwise return 401. Concretely: in the webhook
handler around verifyWaveSignature/signatureHeader/secret, replace the
unconditional else that logs and proceeds with a guard that inspects
rawBody/parsed body and only allows the specific safe setup payload shape
(explicitly check for empty body or a recognized ping/setup event) to bypass
verification; for all other payloads return c.json({ error: 'invalid_signature'
}, 401) and log the rejection. Apply the same change to the other webhook
handling block that spans the later section (the 755-804 area) so dedup +
ledgerLog are never reached without a secret unless the payload is the allowed
setup ping.
🪄 Autofix (Beta)

✅ Autofix completed


ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f721c93b-f404-4335-9313-f2d648220157

📥 Commits

Reviewing files that changed from the base of the PR and between a7d2271 and dd153c0.

📒 Files selected for processing (3)
  • server/__tests__/webhooks-wave-receiver.test.ts
  • server/__tests__/webhooks-wave-secret.test.ts
  • server/routes/webhooks.ts

Comment thread server/routes/webhooks.ts Outdated
Comment thread server/routes/webhooks.ts
@chitcommit chitcommit disabled auto-merge May 7, 2026 19:52
…usiness

Addresses review feedback on PR #113:

- **Forgery guard**: when no per-(tenant,business) secret is stored in KV,
  schema-valid Wave events are now rejected with 401 instead of being
  ack'd and ledger-logged. The setup-ping bypass is preserved only for
  non-event probe shapes (empty body, non-JSON, unrecognized JSON).
- **Dedup key scoping**: `webhook:wave:dedup:<event_id>` ->
  `webhook:wave:dedup:<tenantId>:<businessId>:<event_id>`. Wave's event_id
  is unique per business, not globally; without scoping, the same id
  across two businesses would silently drop the second event.
- **Replay window asymmetry**: tightened to -300s/+60s (was symmetric
  +-300s). Future-dated timestamps don't occur from Wave's signers and
  indicate clock drift or tampering.
- **Audit-log schema drift**: signed-but-unparseable bodies and
  signed-but-unrecognized envelopes now ledger-log
  `webhook.wave.parse_error` / `webhook.wave.unrecognized` so drift
  surfaces in the audit trail instead of stderr.
- **Tests**: drop `vi.mock('../lib/ledger-client', ...)` per project
  rule (no service-module mocks). Stub global fetch instead so the real
  ledger-client code path is exercised without outbound HTTP. Added
  cases for same-length wrong sig (constant-time path), future
  timestamp, non-numeric timestamp, non-JSON probe body, cross-business
  dedup isolation, and updated existing tests to use signed requests
  now that unsigned events are rejected.

Tests: 286/286 passing (+5 new); typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@chitcommit
Copy link
Copy Markdown
Contributor Author

Pushed 0162d53 addressing review findings:

Critical (fixed):

  • Forgery vector: schema-valid events now rejected 401 when no secret stored; setup-ping bypass reserved for non-event probe shapes only
  • Dedup key now scoped by (tenantId, businessId, event_id)
  • Dropped vi.mock on ledger-client (per CLAUDE.md "no service-module mocks"); stubbed global fetch instead so real ledger-client code path runs without outbound HTTP

Important (fixed):

  • Asymmetric replay window: -300s/+60s (was symmetric ±300s)
  • Signed-but-unparseable / signed-but-unrecognized bodies now ledger-log webhook.wave.parse_error / webhook.wave.unrecognized so schema drift surfaces in the audit trail

Test coverage added (+5): same-length wrong sig (constant-time path), future timestamp, non-numeric timestamp, non-JSON probe body, cross-business dedup isolation. Updated viewed/dedup tests to use signed requests since unsigned events are now rejected.

286/286 passing; typecheck clean. Auto-merge intentionally left disabled until CI greens.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the main webhook routes file
fd "webhooks.ts" --type f | head -5
cat server/routes/webhooks.ts 2>/dev/null || fd -e ts -p "webhooks" | xargs cat 2>/dev/null | head -400

Length of output: 29364


🌐 Web query:

Wave webhooks HMAC SHA256 signature verification security best practices 2024

💡 Result:

Wave webhook HMAC-SHA256 verification (and security best practices)

1) Verify the signature exactly as Wave specifies

  • Read the raw request body bytes (don’t parse/re-serialize JSON). Even whitespace/key-order changes will break verification. [1]
  • Parse x-wave-signature (format t=timestamp,v1=signature) and use x-wave-timestamp if provided. [1]
  • Recreate the signed payload as:
    "{timestamp}.{raw_request_body}"
    then compute HMAC-SHA256(secret, signed_payload) and compare to the v1 signature. [1]

2) Add replay-attack protection (required)

  • Reject old requests: ensure the timestamp is within ~5 minutes (Wave explicitly recommends 5 minutes). [1]
  • Make sure your server clock is accurate (NTP), because timestamp windows depend on clock sync. [3]

3) Compare signatures safely

  • Use a constant-time comparison (timing-safe equality) instead of normal string == / === to reduce timing-leak risk. [4]

4) Make processing idempotent (prevents duplicates from retries/replays)

  • Expect duplicate deliveries; store an event/webhook ID (if provided by the API) and ignore already-processed IDs, or enforce idempotency at the business-action level. [2], [3]

5) Operational hardening

  • HTTPS-only endpoint (never accept HTTP). [4]
  • Respond fast (do heavy work async/queue) to avoid retries/timeouts; Wave’s webhook checklist calls out performance and idempotency as production-readiness items. [2]
  • Rotate secrets periodically and immediately on suspected leak (and support overlap during rotation if possible). [4]

Sources: Wave Webhooks Setup Guide [1], Wave webhook docs/checklist [2], Webhooks.fyi replay prevention guidance [3], provider-agnostic HMAC/timing-safe/HTTPS guidance [4].


@github-actions[bot] Here is the requested security evaluation of PR #113.


Security Review — Wave Native Webhook Receiver

✅ Signature Verification (Well-implemented)

The verifyWaveSignature implementation is sound:

  • Uses Web Crypto HMAC-SHA256 (Workers-native, no third-party crypto dep)
  • Implements an asymmetric replay window (-300s past / +60s future), correctly tighter on the future side
  • Performs a constant-time hex comparison to prevent timing oracles on the HMAC itself
  • Reads the raw body exactly once and uses it unchanged for signing, consistent with Wave's documented requirement

⚠️ Issue 1 — Service-token comparison is NOT constant-time (Medium)

isAuthorizedWaveSecretCaller uses plain ===:

return token.length > 0 && token === expected;

The HMAC body verification is timing-safe, but the Bearer token gate on the admin secret endpoints is not. A network-level timing oracle exists here. This should mirror the HMAC approach:

function isAuthorizedWaveSecretCaller(env: { CHITTY_AUTH_SERVICE_TOKEN?: string }, authHeader: string): boolean {
  const expected = env.CHITTY_AUTH_SERVICE_TOKEN;
  if (!expected) return false;
  const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
  if (token.length === 0 || token.length !== expected.length) return false;
  let result = 0;
  for (let i = 0; i < expected.length; i++) {
    result |= token.charCodeAt(i) ^ expected.charCodeAt(i);
  }
  return result === 0;
}

⚠️ Issue 2 — Raw event.data logged wholesale to the audit ledger (Medium)

ledgerLog(c, {
  entityType: 'audit',
  action: `webhook.wave.${...}`,
  metadata: {
    ...
    data: event.data,   // ← z.record(z.unknown()) — arbitrary Wave payload
  },
}, c.env);

Wave invoice events carry customer names, email addresses, amounts, and due dates. Logging event.data verbatim means PII flows into the audit trail without redaction. Consider either (a) logging only a safe subset (eventId, eventType, businessId), or (b) explicitly allowlisting safe fields per event type.

⚠️ Issue 3 — KV secret storage access controls (Medium, architectural)

Workers KV is an eventually consistent read-heavy store, not a secrets manager. It lacks access-control granularity: any Worker bound to a KV namespace can read all values in that namespace, with no per-key access policies, and values are stored in plaintext on Cloudflare's infrastructure. KV also does not provide audit logging of individual key reads — you cannot reconstruct which Workers accessed which secrets and when.

Cloudflare recommends using Workers Secrets rather than KV for sensitive credentials; secrets are encrypted at rest and not visible in the dashboard after creation. However, per-business dynamic secrets (one per (tenantId, businessId) pair) cannot be stored as static Workers Secrets — KV is the only viable runtime-writable option. This is a legitimate architectural constraint.

Mitigation: consider envelope encryption — when using Workers KV, you are responsible for implementing application-level encryption, maintaining an audit trail, and defining rotation policies. Store a master wrapping key as a Workers Secret and AES-GCM-wrap each HMAC secret before writing to KV. This ensures that raw KV access (e.g. a compromised namespace binding) does not directly expose usable secrets.

Also document a rotation runbook — various compliance standards require periodic rotation; PCI-DSS requires secrets be rotated every 90 days, and relying on long-lived secrets increases exposure time and the blast radius of a compromise.

ℹ️ Issue 4 — No body size guard on the receiver (Low)

await c.req.text() reads an unbounded body before any authentication. Wave's actual payloads are small, but the endpoint is publicly reachable. A modest Content-Length check before reading (e.g. reject > 64 KB) prevents memory pressure from trivially crafted large requests.


✅ Dependency Supply Chain — No new risk

No new external npm dependencies were introduced. The implementation relies entirely on Hono and zod (already present) and the built-in Web Crypto API. Supply chain risk is unchanged from the base branch.


✅ Breaking API Changes — None

All Wave endpoints (PUT /secret, GET /secret/exists, DELETE /secret, POST /:tenantId/:businessId) are additive. No existing route signatures, response shapes, or status codes were modified. The legacy POST /api/webhooks/wave (service-token normalized path) is untouched.


🗒️ Pre-existing issues (out of scope for this PR, worth tracking)

Issue Location
Mercury dedup key not tenant-scoped (webhook:mercury:${event.id}) — cross-tenant collision possible webhooks.ts Mercury native handler
Stripe dedup stores full raw body in KV — may include sensitive payment data webhooks.ts Stripe handler
verifyMercurySignature has no replay-window check verifyMercurySignature function

Would you like me to open separate follow-up issues for any of these?

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@claude
Copy link
Copy Markdown

claude Bot commented May 7, 2026

Code Review — PR #113: Wave native webhook receiver + per-business secret storage

Overall this is well-designed and well-tested. The security model is sound, the dedup isolation is correct, and the 28-test suite covers the important edge cases. A few things to fix before merge:


Bugs / Correctness

Stale comment in the KV layout block (server/routes/webhooks.ts, new secret-section header ~line 634):

// (added in a follow-up PR once Wave's signature schema is verified)

The receiver with full signature verification is added in this very PR — the "follow-up PR" clause is dead text. Remove it or it will mislead future readers.

Inaccurate KV layout comment (same block):

//   webhook:wave:dedup:<eventId>   → 7d TTL idempotency marker

The actual implementation scopes the key to webhook:wave:dedup:${tenantId}:${businessId}:${event.event_id} (correct, matches the isolation test). The comment should reflect this.

Inconsistent error code casing (~line 731):

return c.json({ error: 'secret required' }, 400);

Every other error code in this file uses underscores (invalid_json, invalid_signature, webhook_not_configured). This one uses a space. Should be 'secret_required'.


Security

Non-constant-time service token comparison (isAuthorizedWaveSecretCaller):

return token.length > 0 && token === expected;

=== on strings is not guaranteed constant-time. This is a lower-severity issue (it's an internal admin endpoint, not public auth), but it's inconsistent with the care taken in verifyWaveSignature. Consider using a timing-safe byte-compare (same pattern as the HMAC comparison below the fold):

if (token.length !== expected.length) return false;
let result = 0;
for (let i = 0; i < token.length; i++) {
  result |= token.charCodeAt(i) ^ expected.charCodeAt(i);
}
return result === 0;

No URL parameter validation on tenantId/businessId:
These come from the URL path and go directly into KV keys. KV is string-keyed (not a filesystem), so there's no path-traversal risk, but an attacker can write entries at arbitrary keys like webhook:wave:secret:../../../some:key by crafting the URL. Validating tenantId as a UUID regex and businessId as alphanumeric-plus-hyphens would tighten the surface:

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const BIZ_RE = /^[a-z0-9-]{1,64}$/i;
if (!UUID_RE.test(tenantId) || !BIZ_RE.test(businessId)) {
  return c.json({ error: 'invalid_params' }, 400);
}

No minimum secret length on PUT:
A 1-character secret passes validation and would be trivially brutable. Wave-issued secrets are long, but a guard (e.g., secret.length < 16) adds defense in depth and catches accidental one-char pastes.


Logic / Edge Cases

parseFailed + sigVerified path returns 200 silently when !secret:
The current flow for a signed-but-non-JSON body when no secret is stored: sigVerified = falseparseFailed = true → the if (sigVerified) audit-log branch is skipped → returns { received: true } (200). This is intentional (setup ping), but there's no test for the case parseFailed && !secret. Worth adding to document the intent.

PR description KV layout differs from implementation:
The PR description shows webhook:wave:dedup:<eventId> but the code (correctly) uses webhook:wave:dedup:<tenantId>:<businessId>:<eventId>. Minor doc inconsistency but worth aligning.


Positive observations

  • Constant-time HMAC compare in verifyWaveSignature — done correctly, byte-by-byte XOR with early-exit guard on length mismatch.
  • Asymmetric replay window (300s past, 60s future) is exactly right — future-dated timestamps from Wave shouldn't happen and are likely tampering.
  • Dedup key scoped to (tenantId, businessId, eventId) — isolation test at the bottom of webhooks-wave-receiver.test.ts proves this. Good catch.
  • Forgery guard (!secret + valid event shape → 401) closes the injection vector before any ledger write.
  • event_type sanitization in the ledger action (replace(/[^a-z0-9_]/gi, '_')) prevents log injection. Nice.
  • GET /secret/exists never returns the secret value — tested explicitly. Good.
  • 28 tests covering signature rejection, replay, business_id mismatch, dedup isolation, constant-time path, and setup-ping variants — solid coverage.

Deployment failure

CI shows a Cloudflare Workers build failure (dd153c04, May 01). Is this related to this PR or a pre-existing issue? Worth resolving before merge so the deploy path is clean.


Summary: Fix the three bugs (stale comment, KV layout comment, error code casing), add UUID/businessId param validation, and address the token comparison timing issue. Everything else is solid.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 9, 2026

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 1 file(s) based on 1 unresolved review comment.

Files modified:

  • server/routes/webhooks.ts

Commit: ca9fbe29600efe27c4d02efd4ab0fc2c0c13eea0

The changes have been pushed to the feat/wave-webhook-per-tenant branch.

Time taken: 4m 23s

Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Copilot AI review requested due to automatic review settings May 9, 2026 02:44
@chitcommit chitcommit review requested due to automatic review settings May 9, 2026 02:44
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 9, 2026

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 9, 2026

Tip

For best results, initiate chat on the files or code changes.

@github-actions Here is the requested security evaluation of PR #113.


Security Review — Wave Native Webhook Receiver

✅ Signature Verification (Well-implemented)

The verifyWaveSignature implementation is sound:

  • Uses Web Crypto HMAC-SHA256 (Workers-native, no third-party crypto dep)
  • Implements an asymmetric replay window (-300s past / +60s future), correctly tighter on the future side
  • Performs a constant-time hex comparison to prevent timing oracles on the HMAC itself
  • Reads the raw body exactly once and uses it unchanged for signing, consistent with Wave's documented requirement

⚠️ Issue 1 — Service-token comparison is NOT constant-time (Medium)

isAuthorizedWaveSecretCaller uses plain ===:

return token.length > 0 && token === expected;

The HMAC body verification is timing-safe, but the Bearer token gate on the admin secret endpoints is not. A network-level timing oracle exists here. This should mirror the HMAC approach:

function isAuthorizedWaveSecretCaller(
  env: { CHITTY_AUTH_SERVICE_TOKEN?: string },
  authHeader: string
): boolean {
  const expected = env.CHITTY_AUTH_SERVICE_TOKEN;
  if (!expected) return false;
  const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
  if (token.length === 0 || token.length !== expected.length) return false;
  let result = 0;
  for (let i = 0; i < expected.length; i++) {
    result |= token.charCodeAt(i) ^ expected.charCodeAt(i);
  }
  return result === 0;
}

⚠️ Issue 2 — Raw event.data logged wholesale to the audit ledger (Medium)

ledgerLog(c, {
  entityType: 'audit',
  action: `webhook.wave.${...}`,
  metadata: {
    ...
    data: event.data,   // ← z.record(z.unknown()) — arbitrary Wave payload
  },
}, c.env);

Wave invoice events carry customer names, email addresses, amounts, and due dates. Logging event.data verbatim means PII flows into the audit trail without redaction. Consider either (a) logging only a safe subset (eventId, eventType, businessId), or (b) explicitly allowlisting safe fields per event type.


⚠️ Issue 3 — KV secret storage access controls (Medium, architectural)

Workers KV is an eventually consistent read-heavy store, not a secrets manager. It lacks access-control granularity: any Worker bound to a KV namespace can read all values in that namespace, with no per-key access policies, and values are stored in plaintext on Cloudflare's infrastructure. KV also does not provide audit logging of individual key reads — you cannot reconstruct which Workers accessed which secrets and when.

Cloudflare recommends using Workers Secrets rather than KV for sensitive credentials; secrets are encrypted at rest and not visible in the dashboard after creation. However, per-business dynamic secrets (one per (tenantId, businessId) pair) cannot be stored as static Workers Secrets — KV is the only viable runtime-writable option. This is a legitimate architectural constraint.

Mitigation: consider envelope encryption — store a master wrapping key as a Workers Secret and AES-GCM-wrap each HMAC secret before writing to KV. This ensures that raw KV namespace access (e.g. a compromised namespace binding) does not directly expose usable secrets. Also document a rotation runbook — PCI-DSS requires rotation every 90 days, and long-lived secrets increase blast radius on compromise.


ℹ️ Issue 4 — No body size guard on the receiver (Low)

await c.req.text() reads an unbounded body before any authentication. Wave's actual payloads are small, but the endpoint is publicly reachable. A modest Content-Length check before reading (e.g. reject > 64 KB) prevents memory pressure from trivially crafted large requests.


✅ Dependency Supply Chain — No new risk

No new external npm dependencies were introduced. The implementation relies entirely on Hono and zod (already present) and the built-in Web Crypto API. Supply chain risk is unchanged from the base branch.


✅ Breaking API Changes — None

All Wave endpoints (PUT /secret, GET /secret/exists, DELETE /secret, POST /:tenantId/:businessId) are additive. No existing route signatures, response shapes, or status codes were modified. The legacy POST /api/webhooks/wave path is untouched.


🗒️ Pre-existing issues (out of scope for this PR, worth tracking)

Issue Location
Mercury dedup key not tenant-scoped (webhook:mercury:${event.id}) — cross-tenant collision possible webhooks.ts Mercury native handler
Stripe dedup stores full raw body in KV — may include sensitive payment data webhooks.ts Stripe handler
verifyMercurySignature has no replay-window check verifyMercurySignature function

Would you like me to open separate follow-up issues for any of these?

@chitcommit chitcommit merged commit 7768977 into main May 11, 2026
9 of 12 checks passed
@chitcommit chitcommit deleted the feat/wave-webhook-per-tenant branch May 11, 2026 04:28
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.

2 participants