feat: complete outbound event delivery, billing webhooks, and Stripe setup#1
Conversation
…setup - Implement outbound event subscription delivery engine with HMAC-SHA256 signing, channel/mentions filtering, and retry with exponential backoff - Wire publishEvent() and deliverEvent() into all mutation route handlers: message.created, thread.reply, reaction.added/removed, channel.created/ archived, dm.received, group_dm.received, file.uploaded, message.read - Complete billing webhook stubs: handleInvoicePaid resets usage counters, handlePaymentFailed downgrades workspace to free plan - Add Stripe product/price setup script (scripts/stripe-setup.ts) for creating Free/Pro/Enterprise pricing tiers idempotently - Add 29 new tests across 3 test files (eventDelivery, eventPublishing, webhooks) bringing total from 378 to 407 passing tests https://claude.ai/code/session_01NoF17Dac9CpDzg9qScB39o
Adjust pricing based on competitive analysis (Pusher, Ably, PubNub): - Pro: $49/mo → $99/mo (1M messages, 100 agents, 50GB files, 1200 rpm) - Enterprise: $249/mo → $799/mo (unlimited msgs/agents, 500GB, 6000 rpm) - Free tier unchanged ($0, 10K messages, 5 agents) Update all locations where plan limits are defined: - packages/server/src/engine/billing.ts - packages/server/src/middleware/planLimits.ts - scripts/stripe-setup.ts - middleware plan limits test assertions Add pricing section to landing page (site/index.html + styles.css) with three-column card layout, featured Pro tier, and nav link. https://claude.ai/code/session_01NoF17Dac9CpDzg9qScB39o
- Fix matchesFilter to reject events with no channel info when a channel
filter is set (e.g. dm.received should not match filter: {channel: 'alerts'})
- Remove redundant response.ok || status check (response.ok is already 200-299)
- Fix misleading comment about returning promise
- Add test for channel filter rejecting events without channel info (408 tests)
https://claude.ai/code/session_01NoF17Dac9CpDzg9qScB39o
…tency - Add Stripe webhook signature verification (HMAC-SHA256) when STRIPE_WEBHOOK_SECRET is configured - Wire command.invoked and webhook.received event publishing to their route handlers (were defined in types but never published) - Add channel_id to reaction events so they broadcast to the channel instead of the entire workspace - Add deliverEvent to presenceRefresh so agent.online events are also sent to outbound webhook subscriptions - Add channel.updated, member.joined, member.left event types and publish from channel update/join/leave routes - Add 3 new webhook signature verification tests https://claude.ai/code/session_01NoF17Dac9CpDzg9qScB39o
- Add 7 missing event types to SubscribableEventType (channel.updated, member.joined, member.left, group_dm.received, message.read, webhook.received, command.invoked) - Update MCP WsBridge to map new event types to resource URIs - Add member.joined event to channel invite route (was missing) - Add pubsub.js and eventDelivery.js mocks to all 17 route test files (previously only webhooks.test.ts had them) - Add 5 new ws-bridge mapping tests for new event types https://claude.ai/code/session_01NoF17Dac9CpDzg9qScB39o
…rds, Node 22 - Use raw body buffer (via express.json verify callback) for Stripe webhook signature verification instead of re-serialized JSON.stringify(req.body) - Add res.headersSent guards to all 17 catch blocks where event publishing follows res.send(), preventing potential double-response on sync throw - Upgrade CI, deploy, and Dockerfile from Node 20 to Node 22 (current LTS) to fix MCP integration test failure caused by global.fetch mock differences https://claude.ai/code/session_01NoF17Dac9CpDzg9qScB39o
…ebhooks, reactions - Strip channel_id from reaction API response before sending to client (#2) - Add 5-minute timestamp tolerance to webhook sig verification to prevent replay attacks (#3) - Return 500 on webhook processing failure so Stripe retries instead of swallowing errors (#4) - Use word boundary in mentions filter to prevent @bob matching @bobby (#5) - Fix backoff comment (1s, 2s — not 1s, 2s, 4s) (#6) - Replace redundant void Promise.allSettled with .catch() (#7) - Clear stripeSubscriptionId on payment failure downgrade (#14) All 692 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| app.use(express.json({ | ||
| verify: (req, _res, buf) => { | ||
| // Preserve raw body for Stripe webhook signature verification | ||
| (req as Request & { rawBody?: Buffer }).rawBody = buf; | ||
| }, | ||
| })); |
There was a problem hiding this comment.
🟡 Raw body buffer stored on every request, not just webhook requests
The verify callback in express.json() stores the raw body buffer on every incoming JSON request, not just the Stripe webhook endpoint that needs it. This doubles memory usage for every API call across the entire application.
Performance Impact Details
At packages/server/src/app.ts:29-34, the verify callback unconditionally stores rawBody on every request:
app.use(express.json({
verify: (req, _res, buf) => {
(req as Request & { rawBody?: Buffer }).rawBody = buf;
},
}));This means every single API call (messages, channels, agents, DMs, etc.) will have its raw body buffered in memory alongside the parsed JSON body. For a messaging platform handling high throughput, this effectively doubles the memory cost of every request body.
The rawBody is only needed by the Stripe webhook endpoint at packages/server/src/routes/webhooks.ts:41 for signature verification. A better approach would be to use a separate express.raw() middleware only on the webhook route, or conditionally store the raw body based on the request path.
Impact: Increased memory usage and GC pressure proportional to total API traffic, not just webhook traffic.
Prompt for agents
Instead of storing rawBody on every request, only store it for the webhook route. One approach is to use a path check inside the verify callback:
app.use(express.json({
verify: (req, _res, buf) => {
// Only preserve raw body for Stripe webhook signature verification
if ((req as any).url === '/billing/webhooks' || (req as any).originalUrl?.endsWith('/billing/webhooks')) {
(req as Request & { rawBody?: Buffer }).rawBody = buf;
}
},
}));
Alternatively, you could use a separate express.raw() middleware mounted only on the webhook route before the JSON parser, or use a dedicated body parser for that specific route.
Was this helpful? React with 👍 or 👎 to provide feedback.
| } catch (err) { | ||
| // Return 500 so Stripe retries on processing failures | ||
| if (!res.headersSent) { | ||
| res.status(500).json({ ok: false, error: { code: 'webhook_processing_error', message: (err as Error).message } }); |
There was a problem hiding this comment.
🟡 Webhook processing errors expose internal error messages to external callers
When processWebhook throws an error, the error message is directly included in the 500 response body, potentially leaking internal implementation details (database errors, stack traces, etc.) to external Stripe webhook callers.
Security Concern Details
At packages/server/src/routes/webhooks.ts:58:
res.status(500).json({ ok: false, error: { code: 'webhook_processing_error', message: (err as Error).message } });The processWebhook function at packages/server/src/engine/webhooks.ts calls database operations (getDb(), db.update(), db.select(), resetUsageCounters()). If any of these fail, the raw error message (which could contain database connection strings, SQL errors, or internal state) is sent directly to the external caller.
Since the /v1/billing/webhooks endpoint is unauthenticated (it doesn't require auth — it's called by Stripe), this could leak internal information to anyone who sends a POST to this endpoint.
Impact: Information disclosure of internal error details to unauthenticated external callers.
| res.status(500).json({ ok: false, error: { code: 'webhook_processing_error', message: (err as Error).message } }); | |
| res.status(500).json({ ok: false, error: { code: 'webhook_processing_error', message: 'Internal webhook processing error' } }); |
Was this helpful? React with 👍 or 👎 to provide feedback.
signing, channel/mentions filtering, and retry with exponential backoff
message.created, thread.reply, reaction.added/removed, channel.created/
archived, dm.received, group_dm.received, file.uploaded, message.read
handlePaymentFailed downgrades workspace to free plan
creating Free/Pro/Enterprise pricing tiers idempotently
webhooks) bringing total from 378 to 407 passing tests
https://claude.ai/code/session_01NoF17Dac9CpDzg9qScB39o