feat(billing): in-app embedded Stripe checkout + webhook hardening Checkout previously used hosted ui_mode → window.location to checkout.stripe.com, which pops out of the installed PWA into the system browser. Switch to embedded: - API: ui_mode embedded_page (stripe-node v22 / API 2025-10 renamed the enum), return_url instead of success/cancel_url, returns client_secret. - web: @stripe/react-stripe-js EmbeddedCheckout mounted in an in-app modal; NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY baked at build (Dockerfile arg + compose arg). - .env.production.example: full Stripe section (was missing) + admin-email placeholder (INF-001). Also bundled (same files): BILL-002 invoice.paid resets quota only on subscription_cycle; BILL-003 webhook dedup rolled back on handler failure; BILL-001 change-plan writes plan locally; BILL-004 webhook cross-checks sub.customer before trusting metadata.orgId; INF-003 API routed off the raw docker.sock through a locked-down tecnativa/docker-socket-proxy (CONTAINERS+POST). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
This commit is contained in:
parent
9d5386ccba
commit
cf423de3d5
@ -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=
|
||||
|
||||
@ -63,6 +63,17 @@ export async function isDuplicateEvent(eventId: string): Promise<boolean> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@ -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<void> {
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
// 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<void> {
|
||||
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({
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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<BillingStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
// When set, the in-app embedded Stripe checkout modal is open.
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
|
||||
const loadStatus = useCallback(() => {
|
||||
apiFetch<BillingStatus>('/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 (
|
||||
<div className="mx-auto max-w-3xl px-6 py-10">
|
||||
{clientSecret && (
|
||||
<CheckoutModal
|
||||
clientSecret={clientSecret}
|
||||
onClose={() => {
|
||||
setClientSecret(null);
|
||||
setBusy(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">Billing</h1>
|
||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||
@ -421,6 +442,44 @@ function BillingInner() {
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutModal({
|
||||
clientSecret,
|
||||
onClose,
|
||||
}: {
|
||||
clientSecret: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 backdrop-blur-sm sm:p-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="relative my-auto w-full max-w-xl rounded-lg border border-[--color-border] bg-[--color-bg] p-1 shadow-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close checkout"
|
||||
className="absolute right-2 top-2 z-10 rounded-md p-1.5 text-[--color-fg-muted] hover:bg-[--color-bg-subtle] hover:text-[--color-fg]"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
{stripePromise ? (
|
||||
<EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
<Alert tone="error">
|
||||
Payments aren’t configured (missing Stripe publishable key). Please contact support.
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -493,7 +552,7 @@ function TierCard({
|
||||
onClick={() => onSubscribe(yearlyTier)}
|
||||
disabled={Boolean(busy)}
|
||||
>
|
||||
{busy === yearlyTier ? 'Redirecting…' : `Or €${yearly}/year — 2 months free`}
|
||||
{busy === yearlyTier ? 'Loading…' : `Or €${yearly}/year — 2 months free`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
53
pnpm-lock.yaml
generated
53
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user