diff --git a/.env.production.example b/.env.production.example index 09001a4..4903399 100644 --- a/.env.production.example +++ b/.env.production.example @@ -46,9 +46,9 @@ OAUTH_ISSUER=https://api.buildmymcpserver.com SECRETS_ENCRYPTION_KEY=CHANGE-ME-run-openssl-rand-hex-32 # ---- Admin bootstrap (upserted idempotently on API boot) ---- -ADMIN_EMAIL=marco.frangiskatos@gmail.com +ADMIN_EMAIL=CHANGE-ME-admin@example.com ADMIN_PASSWORD=CHANGE-ME-strong-admin-password -ADMIN_NAME=Marco Frangiskatos +ADMIN_NAME=CHANGE-ME-Admin # ---- Anthropic (empty = mock generation; set for real Claude generation) ---- ANTHROPIC_API_KEY= @@ -75,6 +75,23 @@ RUNNER_HOST=buildmymcpserver.com RUNNER_PORT_RANGE_START=4400 RUNNER_PORT_RANGE_END=4900 +# ---- Stripe (billing) ---- +# Secret key (server-side only — NEVER expose). From Stripe Dashboard → Developers → API keys. +STRIPE_SECRET_KEY=CHANGE-ME-sk_live_... +# Publishable key (safe to expose). Used by the embedded in-app checkout. +STRIPE_PUBLISHABLE_KEY=CHANGE-ME-pk_live_... +# Same publishable key, exposed to the web client bundle at BUILD time (the web +# image is rebuilt by the deploy, so this must be set before deploying or the +# in-app checkout shows "not configured"). Keep it identical to STRIPE_PUBLISHABLE_KEY. +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=CHANGE-ME-pk_live_... +# Webhook signing secret — from the endpoint you create at /v1/billing/webhook. +STRIPE_WEBHOOK_SECRET=CHANGE-ME-whsec_... +# Price IDs (price_… not prod_…) from each product's pricing in the Dashboard. +STRIPE_PRICE_PRO_MONTHLY=CHANGE-ME-price_... +STRIPE_PRICE_PRO_YEARLY=CHANGE-ME-price_... +STRIPE_PRICE_TEAM_MONTHLY=CHANGE-ME-price_... +STRIPE_PRICE_TEAM_YEARLY=CHANGE-ME-price_... + # ---- Observability (optional) ---- SENTRY_DSN= OTEL_EXPORTER_OTLP_ENDPOINT= diff --git a/apps/api/src/lib/stripe.ts b/apps/api/src/lib/stripe.ts index 71933e0..b9d5e80 100644 --- a/apps/api/src/lib/stripe.ts +++ b/apps/api/src/lib/stripe.ts @@ -63,6 +63,17 @@ export async function isDuplicateEvent(eventId: string): Promise { return set === null; } +/** + * Roll back the idempotency marker for an event whose handler FAILED, so + * Stripe's retry re-processes it. Without this, the marker set by the failed + * first attempt makes every retry look like a duplicate and the event is lost + * forever (e.g. a paid org that never gets upgraded). (BILL-003) + */ +export async function clearProcessedEvent(eventId: string): Promise { + const redis = getRedis(); + await redis.del(`stripe:event:${eventId}`); +} + /** * Sanity-check that price-id env vars actually contain price ids — a common * setup mistake is to paste the product id (prod_…) instead. Logs loudly on diff --git a/apps/api/src/routes/billing.ts b/apps/api/src/routes/billing.ts index d7af1a4..749cd23 100644 --- a/apps/api/src/routes/billing.ts +++ b/apps/api/src/routes/billing.ts @@ -6,6 +6,7 @@ import { config } from '../config.js'; import { audit } from '../lib/audit.js'; import { type PriceTier, + clearProcessedEvent, isDuplicateEvent, planFromPriceId, priceIdForTier, @@ -41,6 +42,14 @@ export async function billingRoutes(app: FastifyInstance): Promise { try { const session = await stripe.checkout.sessions.create({ + // Embedded UI: the payment form mounts INSIDE our dashboard via Stripe.js + // instead of redirecting to checkout.stripe.com. Keeps the flow in-app + // (critical for the installed PWA, which otherwise pops out to the + // system browser). Embedded mode uses return_url, not success/cancel_url. + // NOTE: stripe-node v22 / API 2025-10 renamed this enum 'embedded' → + // 'embedded_page'; it returns a client_secret for @stripe/react-stripe-js + // EmbeddedCheckout. ('hosted' is now 'hosted_page'.) + ui_mode: 'embedded_page', mode: 'subscription', payment_method_types: ['card', 'sepa_debit'], line_items: [{ price: priceId, quantity: 1 }], @@ -54,8 +63,7 @@ export async function billingRoutes(app: FastifyInstance): Promise { subscription_data: { metadata: { orgId: user.orgId, userId: user.userId }, }, - success_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true`, - cancel_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?cancelled=true`, + return_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`, automatic_tax: { enabled: true }, tax_id_collection: { enabled: true }, billing_address_collection: 'required', @@ -71,7 +79,8 @@ export async function billingRoutes(app: FastifyInstance): Promise { ipAddress: req.ip, }); - return reply.send({ url: session.url, sessionId: session.id }); + // client_secret drives the embedded form; sessionId for optional verification. + return reply.send({ clientSecret: session.client_secret, sessionId: session.id }); } catch (err) { app.log.error({ err }, 'checkout session create failed'); const msg = err instanceof Error ? err.message : 'unknown_error'; @@ -272,6 +281,14 @@ export async function billingRoutes(app: FastifyInstance): Promise { items: [{ id: itemId, price: newPriceId }], proration_behavior: 'create_prorations', }); + // Reconcile the local plan immediately instead of waiting for the + // customer.subscription.updated webhook — otherwise quota enforcement + // reads a stale tier in the gap between this call and webhook delivery. + // Idempotent: the webhook will set the same value. (BILL-001) + await db + .update(organizations) + .set({ plan: planFromPriceId(newPriceId) }) + .where(eq(organizations.id, user.orgId)); await audit({ orgId: user.orgId, userId: user.userId, @@ -328,6 +345,11 @@ export async function billingRoutes(app: FastifyInstance): Promise { await handleStripeEvent(app, event); return reply.send({ ok: true }); } catch (err) { + // Roll back the idempotency marker so the retry actually re-runs the + // handler instead of being skipped as a duplicate. Handlers are + // idempotent (they SET state, not increment), so a rare double-process + // on concurrent retries is safe. (BILL-003) + await clearProcessedEvent(event.id); // Return 5xx so Stripe retries with exponential backoff. app.log.error( { err, eventId: event.id, type: event.type }, @@ -364,11 +386,26 @@ async function handleStripeEvent(app: FastifyInstance, event: Stripe.Event): Pro } async function findOrgIdForSubscription(sub: Stripe.Subscription): Promise { - // Prefer the metadata we set at checkout — it's the most reliable mapping. - // Fallback: look the org up by stored customer id. - const metaOrgId = sub.metadata?.orgId; - if (typeof metaOrgId === 'string' && metaOrgId.length > 0) return metaOrgId; + // Prefer the metadata we set at checkout — but DON'T blindly trust it. A + // webhook signature proves the event came from Stripe, not that + // sub.metadata.orgId is honest (metadata is editable in the dashboard/portal). + // Only honour the metadata orgId if the subscription's customer actually + // matches that org's stored stripeCustomerId; otherwise fall back to the + // customer lookup. This prevents a sub with a forged metadata.orgId from + // re-planning a victim org. (BILL-004) const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id; + const metaOrgId = sub.metadata?.orgId; + if (typeof metaOrgId === 'string' && metaOrgId.length > 0) { + const [byMeta] = await db + .select({ id: organizations.id, customer: organizations.stripeCustomerId }) + .from(organizations) + .where(eq(organizations.id, metaOrgId)) + .limit(1); + if (byMeta && (byMeta.customer === null || byMeta.customer === customerId)) { + return byMeta.id; + } + // metadata orgId does not own this customer — ignore it and fall through. + } const [row] = await db .select({ id: organizations.id }) .from(organizations) @@ -474,15 +511,18 @@ async function handleSubscriptionDeleted( async function handleInvoicePaid(_app: FastifyInstance, invoice: Stripe.Invoice): Promise { const orgId = await findOrgIdForInvoice(invoice); if (!orgId) return; - // Successful renewal — clear any past-due suspension and reset the usage - // period (so the new month's call quota starts fresh). + // Only the actual monthly renewal (`subscription_cycle`) resets the usage + // counter. Stripe also sends `invoice.paid` for proration/manual/one-off + // invoices (e.g. every plan up/downgrade); resetting on those would let a + // user zero their call quota on demand by churning plan changes. For + // non-cycle invoices we only clear a past-due suspension. (BILL-002) + const isRenewal = invoice.billing_reason === 'subscription_cycle'; await db .update(organizations) .set({ suspended: false, suspendedReason: null, - callsThisPeriod: 0, - periodStartsAt: new Date(), + ...(isRenewal ? { callsThisPeriod: 0, periodStartsAt: new Date() } : {}), }) .where(eq(organizations.id, orgId)); await audit({ diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 8545106..0d71b2d 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -24,6 +24,11 @@ RUN pnpm install --frozen-lockfile FROM deps AS build ARG NEXT_PUBLIC_API_URL=http://localhost:4000 ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +# Stripe publishable key — inlined into the client bundle so the embedded +# checkout can initialise. Safe to expose (publishable, not secret). Empty +# build = embedded checkout shows a "not configured" message until set. +ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= +ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ENV NEXT_TELEMETRY_DISABLED=1 COPY . . RUN pnpm --filter @bmm/web build @@ -34,4 +39,9 @@ ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 WORKDIR /app/apps/web EXPOSE 3001 +# NOTE (INF-003): non-root `USER node` was reverted — `pnpm start` via corepack +# can't reach its root-owned cache as the node user and the deploy health-check +# doesn't cover web, so a broken web would deploy "green" but take the site down. +# Re-enable only after switching the runtime CMD to invoke next directly +# (node_modules/.bin/next) and smoke-testing the image locally. CMD ["pnpm", "start"] diff --git a/apps/web/app/(dashboard)/settings/billing/page.tsx b/apps/web/app/(dashboard)/settings/billing/page.tsx index a388a64..6258142 100644 --- a/apps/web/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/app/(dashboard)/settings/billing/page.tsx @@ -2,11 +2,19 @@ import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/api'; -import { Loader2 } from 'lucide-react'; +import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js'; +import { loadStripe } from '@stripe/stripe-js'; +import { Loader2, X } from 'lucide-react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useCallback, useEffect, useState } from 'react'; +// Load Stripe.js once at module scope (Stripe's recommendation). Null when the +// publishable key isn't baked into the build — the modal then shows a clear +// "not configured" message instead of throwing. +const STRIPE_PK = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; +const stripePromise = STRIPE_PK ? loadStripe(STRIPE_PK) : null; + type Plan = 'hobby' | 'pro' | 'team' | 'enterprise'; type Tier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly'; @@ -68,6 +76,8 @@ function BillingInner() { const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(null); + // When set, the in-app embedded Stripe checkout modal is open. + const [clientSecret, setClientSecret] = useState(null); const loadStatus = useCallback(() => { apiFetch('/v1/billing/status') @@ -98,15 +108,17 @@ function BillingInner() { setBusy(tier); setError(null); try { - const res = await apiFetch<{ url: string }>('/v1/billing/checkout-session', { + const res = await apiFetch<{ clientSecret: string }>('/v1/billing/checkout-session', { method: 'POST', body: JSON.stringify({ tier }), }); - window.location.href = res.url; + // Open the embedded checkout in-app instead of redirecting to Stripe. + setClientSecret(res.clientSecret); } catch (e) { - setBusy(null); const detail = (e as { detail?: { detail?: string; error?: string } }).detail; setError(detail?.detail ?? detail?.error ?? (e as Error).message); + } finally { + setBusy(null); } }, []); @@ -183,6 +195,15 @@ function BillingInner() { return (
+ {clientSecret && ( + { + setClientSecret(null); + setBusy(null); + }} + /> + )}

Billing

@@ -421,6 +442,44 @@ function BillingInner() { ); } +function CheckoutModal({ + clientSecret, + onClose, +}: { + clientSecret: string; + onClose: () => void; +}) { + return ( +

+
+ + {stripePromise ? ( + + + + ) : ( +
+ + Payments aren’t configured (missing Stripe publishable key). Please contact support. + +
+ )} +
+
+ ); +} + function Alert({ tone, children, @@ -485,7 +544,7 @@ function TierCard({ onClick={() => onSubscribe(monthlyTier)} disabled={Boolean(busy)} > - {busy === monthlyTier ? 'Redirecting…' : `Subscribe — €${monthly}/mo`} + {busy === monthlyTier ? 'Loading…' : `Subscribe — €${monthly}/mo`}
diff --git a/apps/web/package.json b/apps/web/package.json index bd9c4c4..30b12d5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "@bmm/types": "workspace:*", + "@stripe/react-stripe-js": "^6.4.0", + "@stripe/stripe-js": "^9.7.0", "clsx": "2.1.1", "framer-motion": "11.18.2", "geist": "1.3.1", diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7861dce..805999c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -54,6 +54,36 @@ services: timeout: 5s retries: 20 + # Restricted Docker API gateway for the control plane. The API only needs to + # stop/remove generated containers (`docker rm -f`), so it talks to this proxy + # — which exposes ONLY the containers endpoints + write methods — instead of + # mounting the raw root-equivalent /var/run/docker.sock. A compromised API can + # no longer build images, create privileged containers, exec, or mount host + # paths. (INF-003) NOTE: the generator still mounts the raw socket because it + # legitimately builds+runs containers (an inherently privileged operation); + # that residual is tracked in the audit backlog (rootless buildkit / build VM). + docker-socket-proxy: + image: tecnativa/docker-socket-proxy:0.2.0 + container_name: bmm-docker-proxy + restart: unless-stopped + environment: + CONTAINERS: 1 + POST: 1 + # everything else stays at the image default (0 = blocked) + IMAGES: 0 + BUILD: 0 + NETWORKS: 0 + VOLUMES: 0 + EXEC: 0 + INFO: 0 + AUTH: 0 + SECRETS: 0 + SWARM: 0 + SYSTEM: 0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: [bmm-network] + api: build: context: . @@ -61,10 +91,13 @@ services: container_name: bmm-api restart: unless-stopped env_file: .env.production + environment: + # Route docker CLI calls through the restricted proxy instead of a raw + # socket mount. (INF-003) + DOCKER_HOST: tcp://docker-socket-proxy:2375 ports: - "127.0.0.1:${API_PORT:-4000}:4000" volumes: - - /var/run/docker.sock:/var/run/docker.sock - bmm_keys:/app/apps/api/keys # Per-runner nginx snippets — written by the generator, deleted by the # api when a server is removed. The host-side systemd watcher combines @@ -79,6 +112,8 @@ services: condition: service_healthy redis: condition: service_healthy + docker-socket-proxy: + condition: service_started web: build: @@ -86,6 +121,9 @@ services: dockerfile: apps/web/Dockerfile args: NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?set NEXT_PUBLIC_API_URL in .env.production} + # Publishable (not secret) — baked into the client bundle for embedded + # checkout. Default empty so the build never fails when it's unset. + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-} container_name: bmm-web restart: unless-stopped env_file: .env.production @@ -109,6 +147,12 @@ services: DATABASE_URL: postgresql://${POSTGRES_USER:-bmm}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT:-5440}/${POSTGRES_DB:-bmm} REDIS_URL: redis://127.0.0.1:${REDIS_PORT:-6390} volumes: + # SECURITY (INF-003): the generator mounts the RAW docker socket because it + # builds images and runs containers — inherently root-equivalent on this + # host, and a socket-proxy can't filter that (container-create with host + # binds is the dangerous primitive it legitimately needs). It is NOT + # internet-facing (driven only by the Redis build queue). Real remediation + # = rootless buildkit or a dedicated build VM; tracked in the audit backlog. - /var/run/docker.sock:/var/run/docker.sock - bmm_build_context:/app/build-context # Same runner-map mount as the api — generator drops the snippet on diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5520d49..207a37f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,12 @@ importers: '@bmm/types': specifier: workspace:* version: link:../../packages/types + '@stripe/react-stripe-js': + specifier: ^6.4.0 + version: 6.4.0(@stripe/stripe-js@9.7.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@stripe/stripe-js': + specifier: ^9.7.0 + version: 9.7.0 clsx: specifier: 2.1.1 version: 2.1.1 @@ -1244,6 +1250,17 @@ packages: '@remotion/studio@4.0.220': resolution: {integrity: sha512-97sb8ta+4cRmsntAWPgyNkAGo3i9/q/j+jLBvhbUF0njC+rFs3QVb/qx27mEI8/Avw3har7Nu7BR62eH8TNfcg==} + '@stripe/react-stripe-js@6.4.0': + resolution: {integrity: sha512-5cAf7GAqf8VHPoPLVnJQ2MHtDWLdiEPs5GW/loU7iDEue3xf620v11skrk5HICUraDOhODPYpttgFC5N579NxA==} + peerDependencies: + '@stripe/stripe-js': '>=9.5.0 <10.0.0' + react: '>=16.8.0 <20.0.0' + react-dom: '>=16.8.0 <20.0.0' + + '@stripe/stripe-js@9.7.0': + resolution: {integrity: sha512-r1ElolvWXM4aYnZZVHvKW3EDL8JcwEuIgTuWxlB5lvC+YsvjkQ0gX35x9d8dTDubX395fViLVqkaolVs1PmIQQ==} + engines: {node: '>=12.16'} + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -2103,6 +2120,9 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -2217,6 +2237,10 @@ packages: lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -2471,6 +2495,9 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2502,6 +2529,9 @@ packages: peerDependencies: react: ^19.0.0 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-refresh@0.9.0: resolution: {integrity: sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==} engines: {node: '>=0.10.0'} @@ -3703,6 +3733,15 @@ snapshots: - supports-color - utf-8-validate + '@stripe/react-stripe-js@6.4.0(@stripe/stripe-js@9.7.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@stripe/stripe-js': 9.7.0 + prop-types: 15.8.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + + '@stripe/stripe-js@9.7.0': {} + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.15': @@ -4607,6 +4646,8 @@ snapshots: jose@6.2.3: {} + js-tokens@4.0.0: {} + json-parse-even-better-errors@2.3.1: {} json-schema-ref-resolver@3.0.0: @@ -4692,6 +4733,10 @@ snapshots: lodash.sortby@4.7.0: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@6.0.0: dependencies: yallist: 4.0.0 @@ -4919,6 +4964,12 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -4951,6 +5002,8 @@ snapshots: react: 19.0.0 scheduler: 0.25.0 + react-is@16.13.1: {} + react-refresh@0.9.0: {} react@19.0.0: {}