Skip to content

feat(#16): Stripe webhook handler + wired "Book a consult" CTA#121

Merged
4444J99 merged 1 commit into
mainfrom
feat/stripe-webhook-cta
May 26, 2026
Merged

feat(#16): Stripe webhook handler + wired "Book a consult" CTA#121
4444J99 merged 1 commit into
mainfrom
feat/stripe-webhook-cta

Conversation

@4444J99
Copy link
Copy Markdown
Owner

@4444J99 4444J99 commented May 26, 2026

More #16 code, per your call.

  • Worker POST /api/stripe-webhook — verifies the Stripe-Signature via Web Crypto HMAC-SHA256 (constant-time compare) and acknowledges checkout.session.completed. 503 NOT_CONFIGURED until STRIPE_WEBHOOK_SECRET is set; 400 on bad/missing signature. Added the env field.
  • consult.astro "Book a consult" CTA — POSTs to /api/checkout, redirects to the returned Stripe URL. Degrades gracefully: shows a status message on 503 (checkout unconfigured) and hides itself if no consult API base is set.

This connects the Organ III product to the gateway (a #16 acceptance item). What still requires you: provision Stripe (secret + price + webhook endpoint), then verify a live transaction and update omega.json #11.

Test plan

  • npm run lint / typecheck:strict — clean / 0 hints
  • npm run build — CTA renders on /consult
  • worker esbuild --bundle + contract tests — clean / 6/6
  • Stripe paths not runtime-verified (no Cloudflare runtime; worker deploy-on-demand) — validate with wrangler dev + test keys.

https://claude.ai/code/session_01PW9DnyVijUNmUJ4qMfgpHn


Generated by Claude Code

Summary by Sourcery

Add a Stripe webhook endpoint and wire the consult booking CTA to Stripe checkout.

New Features:

  • Introduce a client-side "Book a consult" call-to-action on the consult page that initiates a Stripe checkout session and redirects to the hosted checkout URL.
  • Add a Stripe webhook handler endpoint that validates signatures and acknowledges checkout.session.completed events.

Enhancements:

  • Improve consult page UX with inline status messaging and styling for the booking CTA, including graceful degradation when checkout is not configured.

Extends the Stripe commerce scaffolding (still inert until secrets set):

- Worker: POST /api/stripe-webhook verifies the Stripe-Signature via
  Web Crypto HMAC-SHA256 (constant-time compare) and acknowledges
  checkout.session.completed. Returns 503 NOT_CONFIGURED until
  STRIPE_WEBHOOK_SECRET is set; 400 on bad/missing signature. Added the
  env field.
- consult.astro: a "Book a consult" CTA that POSTs to /api/checkout and
  redirects to the returned Stripe URL. Degrades gracefully — shows a
  status message on 503 (checkout not configured) and hides itself if no
  consult API base is configured.

Remaining for #16 (yours): provision Stripe (secret + price + webhook
endpoint), then verify a live transaction and update omega.json #11.

Verified: lint, typecheck:strict (0 hints), build (CTA renders), worker
esbuild + contract tests 6/6. Stripe paths are not runtime-verified (no
Cloudflare runtime here; worker is deploy-on-demand).

https://claude.ai/code/session_01PW9DnyVijUNmUJ4qMfgpHn
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 26, 2026

Reviewer's Guide

Implements a Stripe checkout integration by adding a consult page CTA that posts to a new checkout API, and a worker-side Stripe webhook endpoint that verifies webhook signatures via Web Crypto and acknowledges checkout completion events, gated by new Stripe webhook configuration.

Sequence diagram for Book a consult CTA checkout flow

sequenceDiagram
    actor User
    participant Browser as consult_astro
    participant CheckoutAPI as handleCheckout
    participant Stripe

    User->>Browser: click book-consult-btn
    Browser->>Browser: initBookCta (astro:page-load)
    Browser->>CheckoutAPI: fetch(CHECKOUT_API_URL, POST)
    CheckoutAPI-->>Browser: 200 { url }
    Browser->>Browser: window.location.href = url
    Browser->>Stripe: HTTPS redirect to checkout

    alt checkout not configured (503)
        CheckoutAPI-->>Browser: 503
        Browser->>Browser: status.textContent = "Online booking isn't set up yet..."
    end

    alt network or other error
        Browser->>Browser: status.textContent = "Could not reach checkout..."
    end
Loading

Sequence diagram for Stripe webhook verification handler

sequenceDiagram
    participant Stripe
    participant Worker as fetch
    participant WebhookHandler as handleStripeWebhook
    participant Verifier as verifyStripeSignature

    Stripe->>Worker: POST /api/stripe-webhook
    Worker->>WebhookHandler: handleStripeWebhook(request, env, corsHeaders)

    alt STRIPE_WEBHOOK_SECRET missing
        WebhookHandler-->>Stripe: 503 NOT_CONFIGURED (jsonResponse)
    else secret configured
        WebhookHandler->>Verifier: verifyStripeSignature(payload, sigHeader, secret)
        Verifier-->>WebhookHandler: boolean
        alt signature invalid
            WebhookHandler-->>Stripe: 400 BAD_SIGNATURE (jsonResponse)
        else signature valid
            WebhookHandler->>WebhookHandler: JSON.parse(payload)
            alt event.type === checkout.session.completed
                WebhookHandler->>WebhookHandler: console.log(session id)
            end
            WebhookHandler-->>Stripe: 200 { ok: true, received: true }
        end
    end
Loading

File-Level Changes

Change Details Files
Add client-side "Book a consult" CTA wired to checkout API with graceful degradation.
  • Render a new CTA section with button and status message on the consult page.
  • Introduce CHECKOUT_API_URL derived from CONSULT_API_BASE for the checkout endpoint.
  • Add initBookCta initializer that hides the CTA if checkout base is unset, posts to /api/checkout on click, handles success by redirecting to Stripe URL, and surfaces user-friendly error messages for 503 and network errors.
  • Register initBookCta on astro:page-load alongside the existing consult form init.
  • Add styling for the CTA container, button, and status text consistent with existing design tokens.
src/pages/consult.astro
Add Stripe webhook handling with HMAC-based signature verification and configuration gating in the worker.
  • Extend Env interface to include STRIPE_WEBHOOK_SECRET for webhook verification configuration.
  • Implement verifyStripeSignature using Web Crypto HMAC-SHA256 and a constant-time comparison of the v1 signature.
  • Add handleStripeWebhook to validate configuration, verify the Stripe-Signature header, parse the event payload, log checkout.session.completed events, and return structured JSON responses with appropriate HTTP status codes.
  • Wire POST /api/stripe-webhook route into the worker fetch function to dispatch to handleStripeWebhook.
workers/consult-api/src/index.ts

Possibly linked issues

  • #Omega Horizon 3: PR adds Stripe checkout and webhook integration, directly implementing core Omega Horizon 3 Stripe orchestration requirements.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a "Book a consult" call-to-action button on the consult page and implements a Stripe webhook endpoint (/api/stripe-webhook) with signature verification in the backend worker. The review feedback identifies critical security enhancements for the webhook signature verification—specifically addressing whitespace handling, replay attack prevention, and key rotation support—as well as a layout adjustment to hide the entire CTA container rather than just the button when the checkout API is unconfigured.

Comment on lines +326 to +354
async function verifyStripeSignature(
payload: string,
sigHeader: string,
secret: string,
): Promise<boolean> {
// Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]"
const parts: Record<string, string> = {};
for (const seg of sigHeader.split(',')) {
const idx = seg.indexOf('=');
if (idx > 0) parts[seg.slice(0, idx)] = seg.slice(idx + 1);
}
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${payload}`));
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
// Constant-time comparison.
if (expected.length !== v1.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ v1.charCodeAt(i);
return diff === 0;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-high high

The current signature verification implementation has three main issues:

  1. Whitespace Vulnerability: Splitting the Stripe-Signature header by , without trimming whitespace can cause verification to fail if leading/trailing spaces are present (e.g., t=..., v1=...).
  2. Replay Attack Vulnerability: The timestamp t is parsed but never validated against the current time. Attackers could intercept and replay valid webhook payloads indefinitely. Stripe recommends verifying that the timestamp is within a 5-minute (300 seconds) tolerance.
  3. Multiple Signatures / Key Rotation: If multiple v1 signatures are present (e.g., during webhook secret rotation), the current implementation only checks the last one because it overwrites the v1 key in the parts object. It should collect all v1 signatures and verify if any of them match.

Here is a secure and robust implementation that addresses all three issues.

async function verifyStripeSignature(
	payload: string,
	sigHeader: string,
	secret: string,
): Promise<boolean> {
	// Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]"
	const v1Signatures: string[] = [];
	let t = "";
	for (const seg of sigHeader.split(",")) {
		const trimmed = seg.trim();
		const idx = trimmed.indexOf("=");
		if (idx > 0) {
			const key = trimmed.slice(0, idx);
			const val = trimmed.slice(idx + 1);
			if (key === "t") {
				t = val;
			} else if (key === "v1") {
				v1Signatures.push(val);
			}
		}
	}
	if (!t || v1Signatures.length === 0) return false;

	// Prevent replay attacks by verifying the timestamp is within tolerance (e.g., 5 minutes)
	const timestamp = parseInt(t, 10);
	if (isNaN(timestamp)) return false;
	const now = Math.floor(Date.now() / 1000);
	if (Math.abs(now - timestamp) > 300) return false;

	const key = await crypto.subtle.importKey(
		"raw",
		new TextEncoder().encode(secret),
		{ name: "HMAC", hash: "SHA-256" },
		false,
		["sign"],
	);
	const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(t + "." + payload));
	const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");

	// Constant-time comparison against all v1 signatures to support key rotation
	let matched = false;
	for (const sig of v1Signatures) {
		if (expected.length === sig.length) {
			let diff = 0;
			for (let i = 0; i < expected.length; i++) {
				diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
			}
			if (diff === 0) {
				matched = true;
			}
		}
	}
	return matched;
}

Comment thread src/pages/consult.astro
Comment on lines +303 to +311
function initBookCta() {
const btn = document.getElementById('book-consult-btn') as HTMLButtonElement | null;
if (!btn || btn.dataset.initialized === 'true') return;
btn.dataset.initialized = 'true';
const status = document.getElementById('book-consult-status');
if (!CHECKOUT_API_URL) {
btn.hidden = true;
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

When CHECKOUT_API_URL is not configured, hiding only the button (btn.hidden = true) leaves the parent .book-cta container visible. Since .book-cta has a large vertical margin (margin: var(--space-2xl) 0;), this results in an awkward empty space in the layout. Hiding the entire container instead resolves this layout issue.

  function initBookCta() {
    const btn = document.getElementById('book-consult-btn') as HTMLButtonElement | null;
    if (!btn || btn.dataset.initialized === 'true') return;
    btn.dataset.initialized = 'true';
    const status = document.getElementById('book-consult-status');
    if (!CHECKOUT_API_URL) {
      const container = btn.closest('.book-cta') as HTMLElement | null;
      if (container) container.hidden = true;
      return;
    }

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • In verifyStripeSignature/handleStripeWebhook, consider enforcing a reasonable t timestamp window from the Stripe-Signature header to mitigate replay attacks rather than only checking the HMAC.
  • The CTA button is hidden only client-side when CHECKOUT_API_URL is unset; if you want to avoid briefly showing a non-functional control on slow JS, you could gate the markup on the same env condition server-side or render it progressively enhanced (e.g., as a link that upgrades to a button).
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `verifyStripeSignature`/`handleStripeWebhook`, consider enforcing a reasonable `t` timestamp window from the `Stripe-Signature` header to mitigate replay attacks rather than only checking the HMAC.
- The CTA button is hidden only client-side when `CHECKOUT_API_URL` is unset; if you want to avoid briefly showing a non-functional control on slow JS, you could gate the markup on the same env condition server-side or render it progressively enhanced (e.g., as a link that upgrades to a button).

## Individual Comments

### Comment 1
<location path="workers/consult-api/src/index.ts" line_range="326-335" />
<code_context>
 	}
 }

+async function verifyStripeSignature(
+	payload: string,
+	sigHeader: string,
+	secret: string,
+): Promise<boolean> {
+	// Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]"
+	const parts: Record<string, string> = {};
+	for (const seg of sigHeader.split(',')) {
+		const idx = seg.indexOf('=');
+		if (idx > 0) parts[seg.slice(0, idx)] = seg.slice(idx + 1);
+	}
+	const t = parts.t;
+	const v1 = parts.v1;
+	if (!t || !v1) return false;
</code_context>
<issue_to_address>
**🚨 issue (security):** The Stripe signature verification should enforce a timestamp tolerance to mitigate replay attacks.

The HMAC check is correct, but `t` isn’t validated against a time window as Stripe recommends. This allows a valid payload/signature pair to be replayed indefinitely. Please parse `t` as an integer, compare it to `Date.now() / 1000`, and reject requests where the difference exceeds a configured threshold (e.g., 300 seconds).
</issue_to_address>

### Comment 2
<location path="workers/consult-api/src/index.ts" line_range="331-338" />
<code_context>
+	sigHeader: string,
+	secret: string,
+): Promise<boolean> {
+	// Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]"
+	const parts: Record<string, string> = {};
+	for (const seg of sigHeader.split(',')) {
+		const idx = seg.indexOf('=');
+		if (idx > 0) parts[seg.slice(0, idx)] = seg.slice(idx + 1);
+	}
+	const t = parts.t;
+	const v1 = parts.v1;
+	if (!t || !v1) return false;
+	const key = await crypto.subtle.importKey(
</code_context>
<issue_to_address>
**suggestion:** Signature parsing assumes a single v1 value and doesn’t robustly handle the full Stripe-Signature header format.

Stripe may include multiple `v1` signatures and whitespace around segments. This implementation overwrites earlier `v1` values and preserves any whitespace, so a valid signature might be ignored if it isn’t last or if formatting varies. Consider trimming each segment/key/value, storing all `v1` values, and verifying the computed HMAC against each until one matches, mirroring Stripe’s verification behavior.

Suggested implementation:

```typescript
	// Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]"
	let timestamp: string | null = null;
	const v1Signatures: string[] = [];

	for (const rawSeg of sigHeader.split(',')) {
		const seg = rawSeg.trim();
		if (!seg) continue;

		const idx = seg.indexOf('=');
		if (idx <= 0) continue;

		const key = seg.slice(0, idx).trim();
		const value = seg.slice(idx + 1).trim();

		if (key === 't' && !timestamp) {
			timestamp = value;
		} else if (key === 'v1') {
			v1Signatures.push(value);
		}
	}

	if (!timestamp || v1Signatures.length === 0) return false;

```

```typescript
	const mac = await crypto.subtle.sign(
		'HMAC',
		key,
		new TextEncoder().encode(`${timestamp}.${payload}`),
	);
	const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');

	// Constant-time comparison against all provided v1 signatures.
	for (const sig of v1Signatures) {
		if (expected.length !== sig.length) continue;

		let result = 0;
		for (let i = 0; i < expected.length; i++) {
			result |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
		}

		if (result === 0) return true;
	}

	return false;

```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +326 to +335
async function verifyStripeSignature(
payload: string,
sigHeader: string,
secret: string,
): Promise<boolean> {
// Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]"
const parts: Record<string, string> = {};
for (const seg of sigHeader.split(',')) {
const idx = seg.indexOf('=');
if (idx > 0) parts[seg.slice(0, idx)] = seg.slice(idx + 1);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 issue (security): The Stripe signature verification should enforce a timestamp tolerance to mitigate replay attacks.

The HMAC check is correct, but t isn’t validated against a time window as Stripe recommends. This allows a valid payload/signature pair to be replayed indefinitely. Please parse t as an integer, compare it to Date.now() / 1000, and reject requests where the difference exceeds a configured threshold (e.g., 300 seconds).

Comment on lines +331 to +338
// Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]"
const parts: Record<string, string> = {};
for (const seg of sigHeader.split(',')) {
const idx = seg.indexOf('=');
if (idx > 0) parts[seg.slice(0, idx)] = seg.slice(idx + 1);
}
const t = parts.t;
const v1 = parts.v1;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Signature parsing assumes a single v1 value and doesn’t robustly handle the full Stripe-Signature header format.

Stripe may include multiple v1 signatures and whitespace around segments. This implementation overwrites earlier v1 values and preserves any whitespace, so a valid signature might be ignored if it isn’t last or if formatting varies. Consider trimming each segment/key/value, storing all v1 values, and verifying the computed HMAC against each until one matches, mirroring Stripe’s verification behavior.

Suggested implementation:

	// Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]"
	let timestamp: string | null = null;
	const v1Signatures: string[] = [];

	for (const rawSeg of sigHeader.split(',')) {
		const seg = rawSeg.trim();
		if (!seg) continue;

		const idx = seg.indexOf('=');
		if (idx <= 0) continue;

		const key = seg.slice(0, idx).trim();
		const value = seg.slice(idx + 1).trim();

		if (key === 't' && !timestamp) {
			timestamp = value;
		} else if (key === 'v1') {
			v1Signatures.push(value);
		}
	}

	if (!timestamp || v1Signatures.length === 0) return false;
	const mac = await crypto.subtle.sign(
		'HMAC',
		key,
		new TextEncoder().encode(`${timestamp}.${payload}`),
	);
	const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');

	// Constant-time comparison against all provided v1 signatures.
	for (const sig of v1Signatures) {
		if (expected.length !== sig.length) continue;

		let result = 0;
		for (let i = 0; i < expected.length; i++) {
			result |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
		}

		if (result === 0) return true;
	}

	return false;

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e1a0f58ba8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +332 to +338
const parts: Record<string, string> = {};
for (const seg of sigHeader.split(',')) {
const idx = seg.indexOf('=');
if (idx > 0) parts[seg.slice(0, idx)] = seg.slice(idx + 1);
}
const t = parts.t;
const v1 = parts.v1;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Validate against all Stripe v1 signatures

Stripe can send multiple v1= entries in Stripe-Signature (for example during webhook secret rotation), but this parser stores header parts in a map and keeps only one v1 value. If Stripe’s matching signature is not the retained one, valid webhook deliveries will be rejected with BAD_SIGNATURE (400), which can break checkout-completion processing until retries exhaust. Collect all v1 signatures and accept the event when any one matches the computed HMAC.

Useful? React with 👍 / 👎.

Comment on lines +337 to +339
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject stale Stripe signatures

The webhook verifier checks HMAC correctness but never enforces timestamp recency from t, so any previously captured valid payload+signature can be replayed indefinitely and still return 200. Stripe’s own guidance includes a short tolerance window specifically to mitigate replay attacks; without that check, this endpoint cannot distinguish fresh deliveries from replays.

Useful? React with 👍 / 👎.

);
}
const sig = request.headers.get('Stripe-Signature');
const payload = await request.text();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cap webhook body size before buffering payload

handleStripeWebhook reads the entire request body with await request.text() before any size validation, then computes HMAC over it. Because this endpoint is publicly reachable, oversized POSTs can force high memory/CPU use and trigger Worker resource-limit failures, which can block legitimate webhook deliveries under load. Add a strict content-length/body-size guard (as done for /api/consult) before buffering and verifying the payload.

Useful? React with 👍 / 👎.

@4444J99 4444J99 merged commit f5634c1 into main May 26, 2026
3 checks passed
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