max / makenotwork
6 files changed,
+52 insertions,
-33 deletions
| @@ -3551,7 +3551,7 @@ dependencies = [ | |||
| 3551 | 3551 | ||
| 3552 | 3552 | [[package]] | |
| 3553 | 3553 | name = "makenotwork" | |
| 3554 | - | version = "0.6.0" | |
| 3554 | + | version = "0.6.1" | |
| 3555 | 3555 | dependencies = [ | |
| 3556 | 3556 | "anyhow", | |
| 3557 | 3557 | "argon2", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.6.0" | |
| 3 | + | version = "0.6.1" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -50,10 +50,15 @@ is missing on our side. After this migration deploys, resend that event. | |||
| 50 | 50 | ||
| 51 | 51 | 5. **Currency moved to `stripe_types::Currency`.** | |
| 52 | 52 | ||
| 53 | - | 6. **Connect events** may need a separate Stripe dashboard subscription. The | |
| 54 | - | testaccount123 stuck event is a Connect event (`evt_1TXpkh0...0AcRNJbwd4...`). | |
| 55 | - | The May 12 events that did process were platform-level. Confirm Connect | |
| 56 | - | event subscription on the `mnw-alpha` endpoint as part of A2.7. | |
| 53 | + | 6. **Connect events live on a separate endpoint** from platform events. | |
| 54 | + | Stripe forces one endpoint per scope: "Your account" vs "Connected accounts". | |
| 55 | + | We now run **two destinations** both pointing at `https://makenot.work/stripe/webhook`: | |
| 56 | + | - `mnw-connect` → Events from connected accounts (fan→creator flow, | |
| 57 | + | creator onboarding, refunds). testaccount123's stuck event lives here. | |
| 58 | + | - `mnw-you` → Events from MNW's own account (Fan+ subs, creator tier subs, | |
| 59 | + | app sync subs, MNW's own subscription). | |
| 60 | + | Each endpoint has its own signing secret. `STRIPE_WEBHOOK_SECRET` accepts | |
| 61 | + | a comma-separated list; `verify_signature` tries each in turn. | |
| 57 | 62 | ||
| 58 | 63 | 7. **Smoke test proved rc.5 parses our fixtures.** See `/tmp/stripe-rc5-parse/` | |
| 59 | 64 | (scratch crate, can be regenerated; main.rs in that crate is the working | |
| @@ -161,23 +166,17 @@ PWYW checkout in browser at localhost using test card `4242…`. | |||
| 161 | 166 | every outgoing API call (create session × 3 modes, create subscription, | |
| 162 | 167 | fetch balance, fetch account, fetch subscription, refund). Run in CI. | |
| 163 | 168 | Catches future schema breaks before prod sees them. | |
| 164 | - | - **A2.7**: In Stripe Dashboard, confirm `mnw-alpha` (or the new endpoint we | |
| 165 | - | ended up with) is subscribed to **Connect events on connected accounts**, | |
| 166 | - | not just platform events. Connect events drive every creator transaction; | |
| 167 | - | if this checkbox is off, deployment success doesn't translate to working | |
| 168 | - | purchases. | |
| 169 | - | ||
| 170 | - | ## State of related infrastructure | |
| 171 | - | ||
| 172 | - | - Webhook signing secret was rotated to `whsec_pNXOKYm9P5bFuVwsbJPxZYw1OOy1cGsu` | |
| 173 | - | on prod at `/opt/makenotwork/.env` (backup at | |
| 174 | - | `/opt/makenotwork/.env.bak-<timestamp>`). Service was restarted. Signature | |
| 175 | - | verification now succeeds; parsing fails, which is what the rest of this | |
| 176 | - | migration fixes. | |
| 177 | - | - Old endpoint `mnw-alpha` is still active in Stripe Dashboard. Decision | |
| 178 | - | pending: keep it on the rotated secret or create a fresh endpoint pinned to | |
| 179 | - | the rc.5-supported API version. Right now we plan to keep the existing | |
| 180 | - | endpoint since rc.5 handles the current API version natively. | |
| 169 | + | - **A2.7** (resolved 2026-05-17): Two endpoints configured — `mnw-connect` | |
| 170 | + | (Events from Connected accounts, 7 events) and `mnw-you` (Events from Your | |
| 171 | + | account, same 7 events). Both deliver to `https://makenot.work/stripe/webhook`. | |
| 172 | + | ||
| 173 | + | ## State of related infrastructure (2026-05-17) | |
| 174 | + | ||
| 175 | + | - Old `mnw-alpha` endpoint was deleted during the back-pin attempt. Replaced | |
| 176 | + | with two new endpoints (see A2.7 above), each with its own signing secret. | |
| 177 | + | - `STRIPE_WEBHOOK_SECRET` is now a comma-separated list of the two secrets; | |
| 178 | + | `verify_signature` iterates and accepts a match against any of them. | |
| 179 | + | - v0.6.0 deployed to prod 2026-05-17. Service active, health 200. | |
| 181 | 180 | ||
| 182 | 181 | ## Other bugs filed during this session (for reference, not migration scope) | |
| 183 | 182 |
| @@ -313,8 +313,14 @@ impl std::fmt::Debug for ScanConfig { | |||
| 313 | 313 | pub struct StripeConfig { | |
| 314 | 314 | /// Stripe secret API key (sk_test_... or sk_live_...) | |
| 315 | 315 | pub secret_key: String, | |
| 316 | - | /// Webhook signing secret for v1 snapshot events (whsec_...) | |
| 317 | - | pub webhook_secret: String, | |
| 316 | + | /// Webhook signing secrets for v1 snapshot events (whsec_...). | |
| 317 | + | /// | |
| 318 | + | /// A list to accommodate multiple Stripe endpoints (e.g. `mnw-connect` | |
| 319 | + | /// for Connected-account events + `mnw-you` for platform events — Stripe | |
| 320 | + | /// requires one endpoint per scope, and each endpoint has its own secret). | |
| 321 | + | /// `verify_signature` accepts a match against any secret in the list. | |
| 322 | + | /// Configured via `STRIPE_WEBHOOK_SECRET` as a comma-separated list. | |
| 323 | + | pub webhook_secret: Vec<String>, | |
| 318 | 324 | /// Webhook signing secret for v2 thin events (whsec_...) | |
| 319 | 325 | /// Optional — v2 endpoint returns 503 if not set. | |
| 320 | 326 | pub webhook_secret_v2: Option<String>, | |
| @@ -325,7 +331,12 @@ impl StripeConfig { | |||
| 325 | 331 | /// Returns None if any required variable is missing (graceful degradation) | |
| 326 | 332 | pub fn from_env() -> Option<Self> { | |
| 327 | 333 | let secret_key = std::env::var("STRIPE_SECRET_KEY").ok()?; | |
| 328 | - | let webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").ok()?; | |
| 334 | + | let webhook_secret: Vec<String> = std::env::var("STRIPE_WEBHOOK_SECRET").ok()? | |
| 335 | + | .split(',') | |
| 336 | + | .map(|s| s.trim().to_string()) | |
| 337 | + | .filter(|s| !s.is_empty()) | |
| 338 | + | .collect(); | |
| 339 | + | if webhook_secret.is_empty() { return None; } | |
| 329 | 340 | let webhook_secret_v2 = std::env::var("STRIPE_WEBHOOK_SECRET_V2").ok(); | |
| 330 | 341 | ||
| 331 | 342 | Some(StripeConfig { | |
| @@ -712,7 +723,7 @@ mod tests { | |||
| 712 | 723 | let config = Config::from_env().expect("should succeed"); | |
| 713 | 724 | let stripe = config.stripe.expect("stripe should be Some when fully configured"); | |
| 714 | 725 | assert_eq!(stripe.secret_key, "sk_test_abc"); | |
| 715 | - | assert_eq!(stripe.webhook_secret, "whsec_test"); | |
| 726 | + | assert_eq!(stripe.webhook_secret, vec!["whsec_test".to_string()]); | |
| 716 | 727 | assert!(stripe.webhook_secret_v2.is_none()); | |
| 717 | 728 | drop(guard); | |
| 718 | 729 | } |
| @@ -54,13 +54,22 @@ fn take_string(v: &mut serde_json::Value, key: &str) -> Option<String> { | |||
| 54 | 54 | ||
| 55 | 55 | impl StripeClient { | |
| 56 | 56 | /// Verify the webhook signature and return the parsed envelope. | |
| 57 | + | /// | |
| 58 | + | /// Tries each configured signing secret in turn and accepts on the first | |
| 59 | + | /// match. We run multiple endpoints (`mnw-connect`, `mnw-you`), each with | |
| 60 | + | /// its own secret; signatures don't carry an endpoint id, so checking | |
| 61 | + | /// every secret is the only option. | |
| 57 | 62 | #[tracing::instrument(skip_all, name = "payments::verify_webhook")] | |
| 58 | 63 | pub fn verify_webhook(&self, payload: &str, signature: &str) -> Result<UntypedEvent> { | |
| 59 | - | verify_signature(payload, signature, &self.config.webhook_secret).map_err(|e| { | |
| 60 | - | tracing::warn!(error = %e, "webhook signature verification failed"); | |
| 61 | - | AppError::BadRequest("Invalid webhook signature".to_string()) | |
| 62 | - | })?; | |
| 63 | - | UntypedEvent::from_payload(payload) | |
| 64 | + | let mut last_err: Option<String> = None; | |
| 65 | + | for secret in &self.config.webhook_secret { | |
| 66 | + | match verify_signature(payload, signature, secret) { | |
| 67 | + | Ok(()) => return UntypedEvent::from_payload(payload), | |
| 68 | + | Err(e) => last_err = Some(e), | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | tracing::warn!(error = ?last_err, "webhook signature verification failed against all configured secrets"); | |
| 72 | + | Err(AppError::BadRequest("Invalid webhook signature".to_string())) | |
| 64 | 73 | } | |
| 65 | 74 | ||
| 66 | 75 | /// Verify a v2 thin event webhook and return the parsed JSON body. |
| @@ -147,7 +147,7 @@ impl TestHarness { | |||
| 147 | 147 | pub async fn with_stripe() -> Self { | |
| 148 | 148 | let stripe_config = StripeConfig { | |
| 149 | 149 | secret_key: "sk_test_fake_key_for_testing".to_string(), | |
| 150 | - | webhook_secret: stripe::TEST_WEBHOOK_SECRET.to_string(), | |
| 150 | + | webhook_secret: vec![stripe::TEST_WEBHOOK_SECRET.to_string()], | |
| 151 | 151 | webhook_secret_v2: Some(stripe::TEST_WEBHOOK_SECRET_V2.to_string()), | |
| 152 | 152 | }; | |
| 153 | 153 | let stripe_client: Arc<dyn PaymentProvider> = Arc::new(StripeClient::new(&stripe_config)); |