Compare commits
2 Commits
092290bb38
...
cf423de3d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf423de3d5 | ||
|
|
9d5386ccba |
@ -1,5 +1,8 @@
|
|||||||
# ---- Core ----
|
# ---- Core ----
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
# Local dev only: skip runner container hardening (--read-only etc. break on
|
||||||
|
# Windows Docker Desktop). NEVER set this in .env.production. (GEN-002)
|
||||||
|
RUNNER_DISABLE_HARDENING=1
|
||||||
|
|
||||||
# ---- Database ----
|
# ---- Database ----
|
||||||
DATABASE_URL=postgresql://bmm:bmm@localhost:5440/bmm
|
DATABASE_URL=postgresql://bmm:bmm@localhost:5440/bmm
|
||||||
|
|||||||
@ -46,9 +46,9 @@ OAUTH_ISSUER=https://api.buildmymcpserver.com
|
|||||||
SECRETS_ENCRYPTION_KEY=CHANGE-ME-run-openssl-rand-hex-32
|
SECRETS_ENCRYPTION_KEY=CHANGE-ME-run-openssl-rand-hex-32
|
||||||
|
|
||||||
# ---- Admin bootstrap (upserted idempotently on API boot) ----
|
# ---- 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_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 (empty = mock generation; set for real Claude generation) ----
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
@ -75,6 +75,23 @@ RUNNER_HOST=buildmymcpserver.com
|
|||||||
RUNNER_PORT_RANGE_START=4400
|
RUNNER_PORT_RANGE_START=4400
|
||||||
RUNNER_PORT_RANGE_END=4900
|
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) ----
|
# ---- Observability (optional) ----
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
|
|||||||
@ -17,7 +17,14 @@ let queue: Queue<BuildJobData> | null = null;
|
|||||||
|
|
||||||
export function getBuildQueue(): Queue<BuildJobData> {
|
export function getBuildQueue(): Queue<BuildJobData> {
|
||||||
if (!queue) {
|
if (!queue) {
|
||||||
queue = new Queue<BuildJobData>('build', { connection: getRedis() });
|
queue = new Queue<BuildJobData>('build', {
|
||||||
|
connection: getRedis(),
|
||||||
|
// Explicit job lifecycle. attempts:1 because a build is non-idempotent
|
||||||
|
// (allocates a host port, runs a container, spends an LLM call) — a blind
|
||||||
|
// BullMQ retry would double-spend; users re-run via /iterate instead.
|
||||||
|
// removeOnComplete/Fail caps Redis growth. (GEN-007)
|
||||||
|
defaultJobOptions: { attempts: 1, removeOnComplete: 100, removeOnFail: 500 },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return queue;
|
return queue;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,6 +63,17 @@ export async function isDuplicateEvent(eventId: string): Promise<boolean> {
|
|||||||
return set === null;
|
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
|
* 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
|
* 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 { audit } from '../lib/audit.js';
|
||||||
import {
|
import {
|
||||||
type PriceTier,
|
type PriceTier,
|
||||||
|
clearProcessedEvent,
|
||||||
isDuplicateEvent,
|
isDuplicateEvent,
|
||||||
planFromPriceId,
|
planFromPriceId,
|
||||||
priceIdForTier,
|
priceIdForTier,
|
||||||
@ -41,6 +42,14 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await stripe.checkout.sessions.create({
|
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',
|
mode: 'subscription',
|
||||||
payment_method_types: ['card', 'sepa_debit'],
|
payment_method_types: ['card', 'sepa_debit'],
|
||||||
line_items: [{ price: priceId, quantity: 1 }],
|
line_items: [{ price: priceId, quantity: 1 }],
|
||||||
@ -54,8 +63,7 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
subscription_data: {
|
subscription_data: {
|
||||||
metadata: { orgId: user.orgId, userId: user.userId },
|
metadata: { orgId: user.orgId, userId: user.userId },
|
||||||
},
|
},
|
||||||
success_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true`,
|
return_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?cancelled=true`,
|
|
||||||
automatic_tax: { enabled: true },
|
automatic_tax: { enabled: true },
|
||||||
tax_id_collection: { enabled: true },
|
tax_id_collection: { enabled: true },
|
||||||
billing_address_collection: 'required',
|
billing_address_collection: 'required',
|
||||||
@ -71,7 +79,8 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
ipAddress: req.ip,
|
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) {
|
} catch (err) {
|
||||||
app.log.error({ err }, 'checkout session create failed');
|
app.log.error({ err }, 'checkout session create failed');
|
||||||
const msg = err instanceof Error ? err.message : 'unknown_error';
|
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 }],
|
items: [{ id: itemId, price: newPriceId }],
|
||||||
proration_behavior: 'create_prorations',
|
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({
|
await audit({
|
||||||
orgId: user.orgId,
|
orgId: user.orgId,
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
@ -328,6 +345,11 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
await handleStripeEvent(app, event);
|
await handleStripeEvent(app, event);
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
} catch (err) {
|
} 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.
|
// Return 5xx so Stripe retries with exponential backoff.
|
||||||
app.log.error(
|
app.log.error(
|
||||||
{ err, eventId: event.id, type: event.type },
|
{ 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> {
|
async function findOrgIdForSubscription(sub: Stripe.Subscription): Promise<string | null> {
|
||||||
// Prefer the metadata we set at checkout — it's the most reliable mapping.
|
// Prefer the metadata we set at checkout — but DON'T blindly trust it. A
|
||||||
// Fallback: look the org up by stored customer id.
|
// webhook signature proves the event came from Stripe, not that
|
||||||
const metaOrgId = sub.metadata?.orgId;
|
// sub.metadata.orgId is honest (metadata is editable in the dashboard/portal).
|
||||||
if (typeof metaOrgId === 'string' && metaOrgId.length > 0) return metaOrgId;
|
// 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 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
|
const [row] = await db
|
||||||
.select({ id: organizations.id })
|
.select({ id: organizations.id })
|
||||||
.from(organizations)
|
.from(organizations)
|
||||||
@ -474,15 +511,18 @@ async function handleSubscriptionDeleted(
|
|||||||
async function handleInvoicePaid(_app: FastifyInstance, invoice: Stripe.Invoice): Promise<void> {
|
async function handleInvoicePaid(_app: FastifyInstance, invoice: Stripe.Invoice): Promise<void> {
|
||||||
const orgId = await findOrgIdForInvoice(invoice);
|
const orgId = await findOrgIdForInvoice(invoice);
|
||||||
if (!orgId) return;
|
if (!orgId) return;
|
||||||
// Successful renewal — clear any past-due suspension and reset the usage
|
// Only the actual monthly renewal (`subscription_cycle`) resets the usage
|
||||||
// period (so the new month's call quota starts fresh).
|
// 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
|
await db
|
||||||
.update(organizations)
|
.update(organizations)
|
||||||
.set({
|
.set({
|
||||||
suspended: false,
|
suspended: false,
|
||||||
suspendedReason: null,
|
suspendedReason: null,
|
||||||
callsThisPeriod: 0,
|
...(isRenewal ? { callsThisPeriod: 0, periodStartsAt: new Date() } : {}),
|
||||||
periodStartsAt: new Date(),
|
|
||||||
})
|
})
|
||||||
.where(eq(organizations.id, orgId));
|
.where(eq(organizations.id, orgId));
|
||||||
await audit({
|
await audit({
|
||||||
|
|||||||
@ -219,7 +219,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
if (choice.provider !== 'anthropic' || !config.ANTHROPIC_API_KEY) {
|
if (choice.provider !== 'anthropic' || !config.ANTHROPIC_API_KEY) {
|
||||||
return reply.code(409).send({
|
return reply.code(409).send({
|
||||||
error: 'streaming_unavailable',
|
error: 'streaming_unavailable',
|
||||||
detail: 'Streaming preview is only available for Anthropic-backed tiers. Use POST /v1/servers/preview instead.',
|
detail:
|
||||||
|
'Streaming preview is only available for Anthropic-backed tiers. Use POST /v1/servers/preview instead.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -254,7 +255,10 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
// open as long as bytes flow; comments are SSE-noop but count as bytes.
|
// open as long as bytes flow; comments are SSE-noop but count as bytes.
|
||||||
const keepalive = setInterval(() => reply.raw.write(`: ping\n\n`), 15_000);
|
const keepalive = setInterval(() => reply.raw.write(`: ping\n\n`), 15_000);
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
req.raw.on('close', () => abort.abort());
|
req.raw.on('close', () => {
|
||||||
|
abort.abort();
|
||||||
|
clearInterval(keepalive);
|
||||||
|
});
|
||||||
|
|
||||||
// `resolved` is set inside the awaited handlers below — by the time
|
// `resolved` is set inside the awaited handlers below — by the time
|
||||||
// streamSpecFromAnthropic returns, exactly one of onSpec/onError will
|
// streamSpecFromAnthropic returns, exactly one of onSpec/onError will
|
||||||
@ -263,6 +267,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
// ended without either handler running (which would be a programming
|
// ended without either handler running (which would be a programming
|
||||||
// bug, not a runtime path).
|
// bug, not a runtime path).
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
|
try {
|
||||||
await streamSpecFromAnthropic(
|
await streamSpecFromAnthropic(
|
||||||
parsed.data.prompt,
|
parsed.data.prompt,
|
||||||
{
|
{
|
||||||
@ -350,8 +355,17 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
app.log.error({ prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_unresolved');
|
app.log.error({ prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_unresolved');
|
||||||
send('error', { error: 'preview_failed', detail: 'stream ended without a final event' });
|
send('error', { error: 'preview_failed', detail: 'stream ended without a final event' });
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If the stream itself rejects (e.g. cacheSpec/Redis throws inside onSpec,
|
||||||
|
// or a network error before either handler runs) we must still tear down
|
||||||
|
// the keepalive timer and close the socket — otherwise the interval keeps
|
||||||
|
// writing to a dead connection forever, leaking a timer + FD per failure. (SRV-004)
|
||||||
|
app.log.error({ err, prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_threw');
|
||||||
|
if (!resolved) send('error', { error: 'preview_failed', detail: 'spec generation failed' });
|
||||||
|
} finally {
|
||||||
clearInterval(keepalive);
|
clearInterval(keepalive);
|
||||||
reply.raw.end();
|
reply.raw.end();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/v1/servers', { preHandler: requireAuth }, async (req, reply) => {
|
app.post('/v1/servers', { preHandler: requireAuth }, async (req, reply) => {
|
||||||
@ -574,6 +588,32 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (!server) return reply.code(404).send({ error: 'not_found' });
|
if (!server) return reply.code(404).send({ error: 'not_found' });
|
||||||
|
|
||||||
|
// iterate queues a full paid LLM build exactly like POST /v1/servers, so it
|
||||||
|
// must enforce the same suspension + daily-build gates. Without these a
|
||||||
|
// suspended (non-paying) or rate-capped org could generate unlimited builds
|
||||||
|
// by hitting iterate instead of create. (SRV-003)
|
||||||
|
const billing = await getOrgBilling(user.orgId);
|
||||||
|
if (billing.suspended) {
|
||||||
|
return reply.code(402).send({
|
||||||
|
error: 'subscription_suspended',
|
||||||
|
detail:
|
||||||
|
billing.suspendedReason === 'payment_failed'
|
||||||
|
? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.'
|
||||||
|
: 'Your subscription is paused. Visit /settings/billing for details.',
|
||||||
|
suspendedReason: billing.suspendedReason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const iterateRl = await checkDailyLimit('build', user.userId, BUILD_DAILY_LIMIT[billing.plan]);
|
||||||
|
if (!iterateRl.ok) {
|
||||||
|
return reply.code(429).send({
|
||||||
|
error: 'rate_limited',
|
||||||
|
detail: `Daily build limit reached for plan "${billing.plan}" (${BUILD_DAILY_LIMIT[billing.plan]}/day). Resets in ${Math.ceil(iterateRl.resetIn / 3600)}h.`,
|
||||||
|
plan: billing.plan,
|
||||||
|
limit: BUILD_DAILY_LIMIT[billing.plan],
|
||||||
|
resetIn: iterateRl.resetIn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const nextVersion = server.currentVersion + 1;
|
const nextVersion = server.currentVersion + 1;
|
||||||
const [build] = await db
|
const [build] = await db
|
||||||
.insert(builds)
|
.insert(builds)
|
||||||
|
|||||||
@ -250,6 +250,15 @@ export async function supportRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const body = NewMessageBody.safeParse(req.body);
|
const body = NewMessageBody.safeParse(req.body);
|
||||||
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
|
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
|
||||||
|
|
||||||
|
// Confirm the ticket exists first — otherwise the insert below hits a raw
|
||||||
|
// FK violation (500) instead of a clean 404. (SUP-002)
|
||||||
|
const [ticket] = await db
|
||||||
|
.select({ id: supportTickets.id })
|
||||||
|
.from(supportTickets)
|
||||||
|
.where(eq(supportTickets.id, parsed.data.id))
|
||||||
|
.limit(1);
|
||||||
|
if (!ticket) return reply.code(404).send({ error: 'not_found' });
|
||||||
|
|
||||||
await db.insert(supportMessages).values({
|
await db.insert(supportMessages).values({
|
||||||
ticketId: parsed.data.id,
|
ticketId: parsed.data.id,
|
||||||
authorUserId: user.userId,
|
authorUserId: user.userId,
|
||||||
@ -282,12 +291,22 @@ export async function supportRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
'/v1/admin/support/tickets/:id/status',
|
'/v1/admin/support/tickets/:id/status',
|
||||||
{ preHandler: requireAdmin },
|
{ preHandler: requireAdmin },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
|
const user = req.user!;
|
||||||
const Params = z.object({ id: z.string().uuid() });
|
const Params = z.object({ id: z.string().uuid() });
|
||||||
const parsed = Params.safeParse(req.params);
|
const parsed = Params.safeParse(req.params);
|
||||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
|
||||||
const body = StatusBody.safeParse(req.body);
|
const body = StatusBody.safeParse(req.body);
|
||||||
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
|
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
|
||||||
|
|
||||||
|
// 404 on unknown ticket instead of a silent no-op `UPDATE ... WHERE id=?`
|
||||||
|
// that returns ok:true and masks the bad id. (SUP-002)
|
||||||
|
const [ticket] = await db
|
||||||
|
.select({ id: supportTickets.id })
|
||||||
|
.from(supportTickets)
|
||||||
|
.where(eq(supportTickets.id, parsed.data.id))
|
||||||
|
.limit(1);
|
||||||
|
if (!ticket) return reply.code(404).send({ error: 'not_found' });
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(supportTickets)
|
.update(supportTickets)
|
||||||
.set({
|
.set({
|
||||||
@ -297,6 +316,17 @@ export async function supportRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
})
|
})
|
||||||
.where(eq(supportTickets.id, parsed.data.id));
|
.where(eq(supportTickets.id, parsed.data.id));
|
||||||
|
|
||||||
|
// Status changes were previously unaudited, unlike admin replies — close
|
||||||
|
// the compliance-trail gap. (SUP-002)
|
||||||
|
await audit({
|
||||||
|
orgId: user.orgId,
|
||||||
|
userId: user.userId,
|
||||||
|
action: 'support.status_changed',
|
||||||
|
resourceType: 'support_ticket',
|
||||||
|
resourceId: parsed.data.id,
|
||||||
|
metadata: { status: body.data.status },
|
||||||
|
});
|
||||||
|
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -194,10 +194,15 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
toolsSchema: server.toolsSchema,
|
toolsSchema: server.toolsSchema,
|
||||||
generatedCode: build.generatedCode,
|
generatedCode: build.generatedCode,
|
||||||
requiredSecrets: parsed.data.secretHints,
|
requiredSecrets: parsed.data.secretHints,
|
||||||
scopes: (server.toolsSchema as Array<{ scopes?: string[] }>).reduce<string[]>(
|
// Aggregate the distinct scopes actually declared by the server's tools
|
||||||
() => ['mcp:read'],
|
// (deduped), falling back to read-only. The previous reduce ignored its
|
||||||
[],
|
// input and hardcoded ['mcp:read'] for every template regardless of what
|
||||||
),
|
// its tools did. (TPL-003)
|
||||||
|
scopes: (() => {
|
||||||
|
const tools = (server.toolsSchema as Array<{ scopes?: string[] }> | null) ?? [];
|
||||||
|
const all = [...new Set(tools.flatMap((t) => t.scopes ?? []))];
|
||||||
|
return all.length > 0 ? all : ['mcp:read'];
|
||||||
|
})(),
|
||||||
allowedDomains: parsed.data.allowedDomains ?? null,
|
allowedDomains: parsed.data.allowedDomains ?? null,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@ -310,13 +315,19 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.from(templates)
|
.from(templates)
|
||||||
.leftJoin(users, eq(users.id, templates.ownerUserId))
|
.leftJoin(users, eq(users.id, templates.ownerUserId))
|
||||||
.leftJoin(organizations, eq(organizations.id, templates.ownerOrgId))
|
.leftJoin(organizations, eq(organizations.id, templates.ownerOrgId))
|
||||||
.where(eq(templates.status, 'public'))
|
// Category filter belongs in the WHERE, BEFORE limit — filtering in JS
|
||||||
|
// after `.limit(50)` meant `?category=x` searched only the 50 newest
|
||||||
|
// public templates (any category), returning far fewer than `limit`. (TPL-008)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(templates.status, 'public'),
|
||||||
|
parsed.data.category ? eq(templates.category, parsed.data.category) : undefined,
|
||||||
|
),
|
||||||
|
)
|
||||||
.orderBy(desc(templates.createdAt))
|
.orderBy(desc(templates.createdAt))
|
||||||
.limit(parsed.data.limit);
|
.limit(parsed.data.limit);
|
||||||
|
|
||||||
const filtered = parsed.data.category
|
const filtered = rows;
|
||||||
? rows.filter((r) => r.template.category === parsed.data.category)
|
|
||||||
: rows;
|
|
||||||
|
|
||||||
// Single grouped query — was N+1 (one COUNT per template). On a 100-row
|
// Single grouped query — was N+1 (one COUNT per template). On a 100-row
|
||||||
// listing that's 101 round-trips → p95 latency cliff once the marketplace
|
// listing that's 101 round-trips → p95 latency cliff once the marketplace
|
||||||
|
|||||||
@ -39,7 +39,10 @@ export async function prepareBuildContext(
|
|||||||
pkg.dependencies = { ...pkg.dependencies, ...spec.dependencies };
|
pkg.dependencies = { ...pkg.dependencies, ...spec.dependencies };
|
||||||
await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
|
await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
const imageTag = `bmm-mcp-${slug}:v${version}`;
|
// Include serverId in the tag: `slug` is unique only per-org, so two orgs
|
||||||
|
// sharing a slug at the same version would otherwise collide on one global
|
||||||
|
// image tag and run each other's code. Matches the contextDir scheme. (GEN-009)
|
||||||
|
const imageTag = `bmm-mcp-${serverId.slice(0, 8)}-${slug}:v${version}`;
|
||||||
return { contextDir, imageTag };
|
return { contextDir, imageTag };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,12 +73,21 @@ export async function staticCheck(contextDir: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A hung `docker build` (stalled npm install, wedged daemon) must not pin a
|
||||||
|
// worker slot forever — concurrency is 2, so two stuck builds = zero throughput
|
||||||
|
// with no alarm. Kill and fail the build past this ceiling. (GEN-008)
|
||||||
|
const BUILD_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
export async function dockerBuild(contextDir: string, imageTag: string, onLog: (msg: string) => void): Promise<void> {
|
export async function dockerBuild(contextDir: string, imageTag: string, onLog: (msg: string) => void): Promise<void> {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const child = spawn('docker', ['build', '-t', imageTag, '.'], {
|
const child = spawn('docker', ['build', '-t', imageTag, '.'], {
|
||||||
cwd: contextDir,
|
cwd: contextDir,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
reject(new Error(`docker_build_timeout (exceeded ${BUILD_TIMEOUT_MS / 1000}s)`));
|
||||||
|
}, BUILD_TIMEOUT_MS);
|
||||||
child.stdout.on('data', (d) => {
|
child.stdout.on('data', (d) => {
|
||||||
for (const line of d.toString().split(/\r?\n/)) {
|
for (const line of d.toString().split(/\r?\n/)) {
|
||||||
if (line.trim()) onLog(line.trim());
|
if (line.trim()) onLog(line.trim());
|
||||||
@ -86,8 +98,12 @@ export async function dockerBuild(contextDir: string, imageTag: string, onLog: (
|
|||||||
if (line.trim()) onLog(line.trim());
|
if (line.trim()) onLog(line.trim());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
child.on('error', (e) => reject(e));
|
child.on('error', (e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
if (code === 0) resolve();
|
if (code === 0) resolve();
|
||||||
else reject(new Error(`docker_build_failed (exit ${code})`));
|
else reject(new Error(`docker_build_failed (exit ${code})`));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -81,12 +81,25 @@ const HARDENING_FLAGS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function shouldHarden(): boolean {
|
function shouldHarden(): boolean {
|
||||||
// Explicit opt-out for local dev on Windows where --read-only conflicts
|
// Fail-CLOSED: harden by default everywhere. The only opt-out is the explicit
|
||||||
// with how Docker Desktop binds volumes. Production must always harden.
|
// RUNNER_DISABLE_HARDENING=1 flag (local Windows Docker Desktop, where
|
||||||
if (process.env.RUNNER_DISABLE_HARDENING === '1') return false;
|
// --read-only conflicts with how volumes bind). The previous NODE_ENV gate was
|
||||||
const env = process.env.NODE_ENV;
|
// fail-OPEN — a missing/typo'd NODE_ENV silently ran tenant containers as root
|
||||||
return env === 'production' || env === 'staging';
|
// with full caps on the shared host, which is the one defense the LLM
|
||||||
|
// static-check explicitly is NOT. (GEN-002)
|
||||||
|
if (process.env.RUNNER_DISABLE_HARDENING === '1') {
|
||||||
|
console.warn(
|
||||||
|
'[deploy] container hardening DISABLED via RUNNER_DISABLE_HARDENING=1 — never set this in production',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// docker run / rm should return in seconds; cap them so a wedged daemon can't
|
||||||
|
// hang a worker slot indefinitely. (GEN-008)
|
||||||
|
const DOCKER_RUN_TIMEOUT_MS = 60 * 1000;
|
||||||
|
const DOCKER_STOP_TIMEOUT_MS = 60 * 1000;
|
||||||
|
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
|
|
||||||
@ -157,14 +170,24 @@ export async function deployContainer(input: DeployInput): Promise<DeployHandle>
|
|||||||
const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
let out = '';
|
let out = '';
|
||||||
let err = '';
|
let err = '';
|
||||||
|
// `docker run -d` returns promptly; if it hangs (wedged daemon) don't pin a
|
||||||
|
// worker slot forever. (GEN-008)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
reject(new Error('docker_run_timeout'));
|
||||||
|
}, DOCKER_RUN_TIMEOUT_MS);
|
||||||
child.stdout.on('data', (d) => {
|
child.stdout.on('data', (d) => {
|
||||||
out += d.toString();
|
out += d.toString();
|
||||||
});
|
});
|
||||||
child.stderr.on('data', (d) => {
|
child.stderr.on('data', (d) => {
|
||||||
err += d.toString();
|
err += d.toString();
|
||||||
});
|
});
|
||||||
child.on('error', (e) => reject(e));
|
child.on('error', (e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
child.on('close', async (code) => {
|
child.on('close', async (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
reject(new Error(`docker_run_failed (exit ${code}): ${err.trim() || out.trim()}`));
|
reject(new Error(`docker_run_failed (exit ${code}): ${err.trim() || out.trim()}`));
|
||||||
return;
|
return;
|
||||||
@ -207,13 +230,21 @@ export async function stopContainer(
|
|||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
let err = '';
|
let err = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
resolve({ ok: false, detail: 'stop_timeout' });
|
||||||
|
}, DOCKER_STOP_TIMEOUT_MS);
|
||||||
child.stderr?.on('data', (d: Buffer) => {
|
child.stderr?.on('data', (d: Buffer) => {
|
||||||
err += d.toString();
|
err += d.toString();
|
||||||
});
|
});
|
||||||
child.on('error', () => resolve({ ok: false, detail: 'spawn_failed' }));
|
child.on('error', () => {
|
||||||
child.on('close', (code) =>
|
clearTimeout(timer);
|
||||||
resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: err.trim() || `exit ${code}` }),
|
resolve({ ok: false, detail: 'spawn_failed' });
|
||||||
);
|
});
|
||||||
|
child.on('close', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: err.trim() || `exit ${code}` });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -184,6 +184,7 @@ export const worker = new Worker<JobData>(
|
|||||||
`Container ${handle.containerId.slice(0, 12)} running at ${handle.publicUrl}`,
|
`Container ${handle.containerId.slice(0, 12)} running at ${handle.publicUrl}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
await db
|
await db
|
||||||
.update(builds)
|
.update(builds)
|
||||||
.set({ status: 'success', finishedAt: new Date() })
|
.set({ status: 'success', finishedAt: new Date() })
|
||||||
@ -197,9 +198,11 @@ export const worker = new Worker<JobData>(
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(mcpServers.id, serverId));
|
.where(eq(mcpServers.id, serverId));
|
||||||
|
} finally {
|
||||||
// Rolling deploy: the new container is live — now retire the previous one.
|
// Rolling deploy: retire the previous container even if the success DB
|
||||||
// Without this every iterate would leave an orphan holding a host port.
|
// writes above threw — otherwise a DB hiccup after a healthy deploy
|
||||||
|
// leaves the old container orphaned, holding its host port. The new
|
||||||
|
// container is already live and its id is persisted in deployContainer. (GEN-007)
|
||||||
if (oldContainerId && oldContainerId !== handle.containerId) {
|
if (oldContainerId && oldContainerId !== handle.containerId) {
|
||||||
const stopped = await stopContainer(oldContainerId);
|
const stopped = await stopContainer(oldContainerId);
|
||||||
await log(
|
await log(
|
||||||
@ -209,6 +212,7 @@ export const worker = new Worker<JobData>(
|
|||||||
: `Could not stop previous container ${oldContainerId.slice(0, 12)}: ${stopped.detail}`,
|
: `Could not stop previous container ${oldContainerId.slice(0, 12)}: ${stopped.detail}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await emitStatus(buildId, 'success');
|
await emitStatus(buildId, 'success');
|
||||||
await emitDone(buildId, 'success', serverId, handle.publicUrl);
|
await emitDone(buildId, 'success', serverId, handle.publicUrl);
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
FROM node:20-alpine AS deps
|
FROM node:20-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN npm install --omit=dev --no-audit --no-fund && npm install --no-save tsx@4.19.2 typescript@5.7.2
|
# --ignore-scripts: generated package.json carries LLM/user-chosen dependencies.
|
||||||
|
# Without this, a malicious dependency's postinstall lifecycle script would run
|
||||||
|
# at `docker build` time on the shared host. Specifiers are also validated to
|
||||||
|
# registry semver ranges at the API boundary (DependencyMap). (GEN-001)
|
||||||
|
RUN npm install --omit=dev --ignore-scripts --no-audit --no-fund && npm install --no-save --ignore-scripts tsx@4.19.2 typescript@5.7.2
|
||||||
|
|
||||||
FROM node:20-alpine AS runtime
|
FROM node:20-alpine AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@ -24,6 +24,11 @@ RUN pnpm install --frozen-lockfile
|
|||||||
FROM deps AS build
|
FROM deps AS build
|
||||||
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
|
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
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
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm --filter @bmm/web build
|
RUN pnpm --filter @bmm/web build
|
||||||
@ -34,4 +39,9 @@ ENV NODE_ENV=production
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
WORKDIR /app/apps/web
|
WORKDIR /app/apps/web
|
||||||
EXPOSE 3001
|
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"]
|
CMD ["pnpm", "start"]
|
||||||
|
|||||||
@ -2,11 +2,19 @@
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { apiFetch } from '@/lib/api';
|
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 Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
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 Plan = 'hobby' | 'pro' | 'team' | 'enterprise';
|
||||||
type Tier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly';
|
type Tier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly';
|
||||||
|
|
||||||
@ -68,6 +76,8 @@ function BillingInner() {
|
|||||||
const [status, setStatus] = useState<BillingStatus | null>(null);
|
const [status, setStatus] = useState<BillingStatus | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = 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(() => {
|
const loadStatus = useCallback(() => {
|
||||||
apiFetch<BillingStatus>('/v1/billing/status')
|
apiFetch<BillingStatus>('/v1/billing/status')
|
||||||
@ -98,15 +108,17 @@ function BillingInner() {
|
|||||||
setBusy(tier);
|
setBusy(tier);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ url: string }>('/v1/billing/checkout-session', {
|
const res = await apiFetch<{ clientSecret: string }>('/v1/billing/checkout-session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ tier }),
|
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) {
|
} catch (e) {
|
||||||
setBusy(null);
|
|
||||||
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
|
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
|
||||||
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
|
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -183,6 +195,15 @@ function BillingInner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl px-6 py-10">
|
<div className="mx-auto max-w-3xl px-6 py-10">
|
||||||
|
{clientSecret && (
|
||||||
|
<CheckoutModal
|
||||||
|
clientSecret={clientSecret}
|
||||||
|
onClose={() => {
|
||||||
|
setClientSecret(null);
|
||||||
|
setBusy(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-[22px] font-semibold tracking-tight">Billing</h1>
|
<h1 className="text-[22px] font-semibold tracking-tight">Billing</h1>
|
||||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
<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({
|
function Alert({
|
||||||
tone,
|
tone,
|
||||||
children,
|
children,
|
||||||
@ -485,7 +544,7 @@ function TierCard({
|
|||||||
onClick={() => onSubscribe(monthlyTier)}
|
onClick={() => onSubscribe(monthlyTier)}
|
||||||
disabled={Boolean(busy)}
|
disabled={Boolean(busy)}
|
||||||
>
|
>
|
||||||
{busy === monthlyTier ? 'Redirecting…' : `Subscribe — €${monthly}/mo`}
|
{busy === monthlyTier ? 'Loading…' : `Subscribe — €${monthly}/mo`}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -493,7 +552,7 @@ function TierCard({
|
|||||||
onClick={() => onSubscribe(yearlyTier)}
|
onClick={() => onSubscribe(yearlyTier)}
|
||||||
disabled={Boolean(busy)}
|
disabled={Boolean(busy)}
|
||||||
>
|
>
|
||||||
{busy === yearlyTier ? 'Redirecting…' : `Or €${yearly}/year — 2 months free`}
|
{busy === yearlyTier ? 'Loading…' : `Or €${yearly}/year — 2 months free`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -11,6 +11,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bmm/types": "workspace:*",
|
"@bmm/types": "workspace:*",
|
||||||
|
"@stripe/react-stripe-js": "^6.4.0",
|
||||||
|
"@stripe/stripe-js": "^9.7.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"framer-motion": "11.18.2",
|
"framer-motion": "11.18.2",
|
||||||
"geist": "1.3.1",
|
"geist": "1.3.1",
|
||||||
|
|||||||
@ -54,6 +54,36 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 20
|
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:
|
api:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@ -61,10 +91,13 @@ services:
|
|||||||
container_name: bmm-api
|
container_name: bmm-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env.production
|
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:
|
ports:
|
||||||
- "127.0.0.1:${API_PORT:-4000}:4000"
|
- "127.0.0.1:${API_PORT:-4000}:4000"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- bmm_keys:/app/apps/api/keys
|
- bmm_keys:/app/apps/api/keys
|
||||||
# Per-runner nginx snippets — written by the generator, deleted by the
|
# Per-runner nginx snippets — written by the generator, deleted by the
|
||||||
# api when a server is removed. The host-side systemd watcher combines
|
# api when a server is removed. The host-side systemd watcher combines
|
||||||
@ -79,6 +112,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
docker-socket-proxy:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
@ -86,6 +121,9 @@ services:
|
|||||||
dockerfile: apps/web/Dockerfile
|
dockerfile: apps/web/Dockerfile
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?set NEXT_PUBLIC_API_URL in .env.production}
|
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
|
container_name: bmm-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env.production
|
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}
|
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}
|
REDIS_URL: redis://127.0.0.1:${REDIS_PORT:-6390}
|
||||||
volumes:
|
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
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- bmm_build_context:/app/build-context
|
- bmm_build_context:/app/build-context
|
||||||
# Same runner-map mount as the api — generator drops the snippet on
|
# Same runner-map mount as the api — generator drops the snippet on
|
||||||
|
|||||||
@ -21,6 +21,14 @@ server {
|
|||||||
|
|
||||||
client_max_body_size 12M;
|
client_max_body_size 12M;
|
||||||
|
|
||||||
|
# Security headers (INF-002). Cloudflare sits in front — if it also injects
|
||||||
|
# HSTS, drop that line here to avoid duplication. CSP intentionally omitted
|
||||||
|
# for now (a wrong policy breaks Next/Tailwind inline) — track separately.
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:4001;
|
proxy_pass http://127.0.0.1:4001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@ -48,6 +56,13 @@ server {
|
|||||||
|
|
||||||
client_max_body_size 12M;
|
client_max_body_size 12M;
|
||||||
|
|
||||||
|
# Security headers (INF-002). nosniff matters for the JSON API; XFO/HSTS are
|
||||||
|
# belt-and-suspenders for any HTML the API might ever serve.
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
# Build-log WebSocket stream (/v1/builds/:id/stream) — needs the upgrade
|
# Build-log WebSocket stream (/v1/builds/:id/stream) — needs the upgrade
|
||||||
# headers and a long read timeout; buffering off so frames are not held.
|
# headers and a long read timeout; buffering off so frames are not held.
|
||||||
location /v1/builds/ {
|
location /v1/builds/ {
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import {
|
|||||||
eq,
|
eq,
|
||||||
gt,
|
gt,
|
||||||
isNull,
|
isNull,
|
||||||
|
lt,
|
||||||
|
sql,
|
||||||
magicLinks,
|
magicLinks,
|
||||||
memberships,
|
memberships,
|
||||||
organizations,
|
organizations,
|
||||||
@ -272,12 +274,18 @@ export async function consumeSmsCode(
|
|||||||
.orderBy(desc(smsCodes.createdAt))
|
.orderBy(desc(smsCodes.createdAt))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!row || row.consumedAt) throw new Error('invalid_or_expired_code');
|
if (!row || row.consumedAt) throw new Error('invalid_or_expired_code');
|
||||||
if (row.attempts >= SMS_MAX_ATTEMPTS) throw new Error('too_many_attempts');
|
// Atomically claim one guess attempt. The increment is gated on
|
||||||
if (sha256(`${phone}:${code}`) !== row.codeHash) {
|
// `attempts < MAX` inside the same UPDATE, so the DB row-lock serialises
|
||||||
await db
|
// concurrent verifies and at most MAX increments ever succeed for a code.
|
||||||
|
// The previous read-then-write (`row.attempts + 1`) let N parallel requests
|
||||||
|
// all pass a stale `attempts` read and brute-force the 6-digit code. (AUTH-001)
|
||||||
|
const slot = await db
|
||||||
.update(smsCodes)
|
.update(smsCodes)
|
||||||
.set({ attempts: row.attempts + 1 })
|
.set({ attempts: sql`${smsCodes.attempts} + 1` })
|
||||||
.where(eq(smsCodes.id, row.id));
|
.where(and(eq(smsCodes.id, row.id), lt(smsCodes.attempts, SMS_MAX_ATTEMPTS)))
|
||||||
|
.returning({ attempts: smsCodes.attempts });
|
||||||
|
if (slot.length === 0) throw new Error('too_many_attempts');
|
||||||
|
if (sha256(`${phone}:${code}`) !== row.codeHash) {
|
||||||
throw new Error('invalid_code');
|
throw new Error('invalid_code');
|
||||||
}
|
}
|
||||||
await db.update(smsCodes).set({ consumedAt: new Date() }).where(eq(smsCodes.id, row.id));
|
await db.update(smsCodes).set({ consumedAt: new Date() }).where(eq(smsCodes.id, row.id));
|
||||||
@ -344,10 +352,16 @@ export async function getSession(
|
|||||||
.where(eq(sessions.tokenHash, hash))
|
.where(eq(sessions.tokenHash, hash))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!row || row.expiresAt < new Date()) return null;
|
if (!row || row.expiresAt < new Date()) return null;
|
||||||
|
// Deterministic primary-org selection — must match the login flows
|
||||||
|
// (consumeMagicLink / loginWithPassword order by oldest membership). Without
|
||||||
|
// this orderBy, a user with >1 membership (once org-invites land) would get a
|
||||||
|
// nondeterministic org per request, silently scoping their reads/writes to a
|
||||||
|
// different tenant than they logged in as. (SRV-001)
|
||||||
const [membership] = await db
|
const [membership] = await db
|
||||||
.select({ orgId: memberships.orgId, role: memberships.role })
|
.select({ orgId: memberships.orgId, role: memberships.role })
|
||||||
.from(memberships)
|
.from(memberships)
|
||||||
.where(eq(memberships.userId, row.userId))
|
.where(eq(memberships.userId, row.userId))
|
||||||
|
.orderBy(memberships.createdAt)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!membership) return null;
|
if (!membership) return null;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -68,6 +68,33 @@ export const PromptSpec = z.object({
|
|||||||
});
|
});
|
||||||
export type PromptSpec = z.infer<typeof PromptSpec>;
|
export type PromptSpec = z.infer<typeof PromptSpec>;
|
||||||
|
|
||||||
|
// Dependency specifiers come from untrusted LLM output and are merged into the
|
||||||
|
// build's package.json, then `npm install`-ed inside `docker build` on the
|
||||||
|
// SHARED host. Restrict to npm-registry semver ranges only: no `git+`, `http(s):`,
|
||||||
|
// `file:` or tarball-URL specifiers (which fetch/checkout+run arbitrary code at
|
||||||
|
// install time), and valid (optionally-scoped) npm package names. Combined with
|
||||||
|
// `--ignore-scripts` in the runner Dockerfile this closes the build-time RCE. (GEN-001)
|
||||||
|
const DepName = z
|
||||||
|
.string()
|
||||||
|
.max(214)
|
||||||
|
.regex(/^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i, 'invalid npm package name');
|
||||||
|
const DepRange = z
|
||||||
|
.string()
|
||||||
|
.max(64)
|
||||||
|
.regex(/^([\^~]?\d+(\.\d+){0,2}(-[\w.]+)?|\*|latest)$/, 'must be a plain semver range');
|
||||||
|
export const DependencyMap = z.record(DepName, DepRange);
|
||||||
|
|
||||||
|
// Secret KEYS become `-e KEY=VALUE` docker run args and the runtime env of the
|
||||||
|
// tenant container. Constrain to UPPER_SNAKE_CASE (matches requiredSecrets) and
|
||||||
|
// reject names that could hijack the Node runtime/loader if the container
|
||||||
|
// hardening ever regressed. Values stay free-form. (GEN-003)
|
||||||
|
const RESERVED_ENV = new Set(['PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_ENV']);
|
||||||
|
const SecretKey = z
|
||||||
|
.string()
|
||||||
|
.regex(/^[A-Z][A-Z0-9_]*$/, 'UPPER_SNAKE_CASE env var name required')
|
||||||
|
.refine((k) => !RESERVED_ENV.has(k) && !k.startsWith('NODE_'), 'reserved env var name');
|
||||||
|
export const SecretMap = z.record(SecretKey, z.string());
|
||||||
|
|
||||||
export const GeneratorSpec = z.object({
|
export const GeneratorSpec = z.object({
|
||||||
name: z.string().min(1).max(128),
|
name: z.string().min(1).max(128),
|
||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
@ -76,7 +103,7 @@ export const GeneratorSpec = z.object({
|
|||||||
prompts: z.array(PromptSpec).max(50).default([]),
|
prompts: z.array(PromptSpec).max(50).default([]),
|
||||||
requiredSecrets: z.array(z.string().regex(/^[A-Z][A-Z0-9_]*$/)).max(30).default([]),
|
requiredSecrets: z.array(z.string().regex(/^[A-Z][A-Z0-9_]*$/)).max(30).default([]),
|
||||||
scopes: z.array(z.string()).max(50).default([]),
|
scopes: z.array(z.string()).max(50).default([]),
|
||||||
dependencies: z.record(z.string(), z.string()).default({}),
|
dependencies: DependencyMap.default({}),
|
||||||
});
|
});
|
||||||
export type GeneratorSpec = z.infer<typeof GeneratorSpec>;
|
export type GeneratorSpec = z.infer<typeof GeneratorSpec>;
|
||||||
|
|
||||||
@ -136,7 +163,7 @@ export const CreateServerInput = z.object({
|
|||||||
.max(64)
|
.max(64)
|
||||||
.regex(/^[a-z][a-z0-9-]*$/, 'lowercase, hyphenated'),
|
.regex(/^[a-z][a-z0-9-]*$/, 'lowercase, hyphenated'),
|
||||||
prompt: z.string().min(10).max(8000),
|
prompt: z.string().min(10).max(8000),
|
||||||
secrets: z.record(z.string(), z.string()).default({}),
|
secrets: SecretMap.default({}),
|
||||||
previewId: z.string().min(1).max(64).optional(),
|
previewId: z.string().min(1).max(64).optional(),
|
||||||
specEdit: SpecEdit.optional(),
|
specEdit: SpecEdit.optional(),
|
||||||
templateId: z.string().uuid().optional(),
|
templateId: z.string().uuid().optional(),
|
||||||
@ -169,7 +196,7 @@ export type PreviewResult = z.infer<typeof PreviewResult>;
|
|||||||
|
|
||||||
export const IterateServerInput = z.object({
|
export const IterateServerInput = z.object({
|
||||||
prompt: z.string().min(10).max(8000),
|
prompt: z.string().min(10).max(8000),
|
||||||
secrets: z.record(z.string(), z.string()).default({}),
|
secrets: SecretMap.default({}),
|
||||||
});
|
});
|
||||||
export type IterateServerInput = z.infer<typeof IterateServerInput>;
|
export type IterateServerInput = z.infer<typeof IterateServerInput>;
|
||||||
|
|
||||||
|
|||||||
53
pnpm-lock.yaml
generated
53
pnpm-lock.yaml
generated
@ -137,6 +137,12 @@ importers:
|
|||||||
'@bmm/types':
|
'@bmm/types':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/types
|
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:
|
clsx:
|
||||||
specifier: 2.1.1
|
specifier: 2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
@ -1244,6 +1250,17 @@ packages:
|
|||||||
'@remotion/studio@4.0.220':
|
'@remotion/studio@4.0.220':
|
||||||
resolution: {integrity: sha512-97sb8ta+4cRmsntAWPgyNkAGo3i9/q/j+jLBvhbUF0njC+rFs3QVb/qx27mEI8/Avw3har7Nu7BR62eH8TNfcg==}
|
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':
|
'@swc/counter@0.1.3':
|
||||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||||
|
|
||||||
@ -2103,6 +2120,9 @@ packages:
|
|||||||
jose@6.2.3:
|
jose@6.2.3:
|
||||||
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||||
|
|
||||||
|
js-tokens@4.0.0:
|
||||||
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
json-parse-even-better-errors@2.3.1:
|
json-parse-even-better-errors@2.3.1:
|
||||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||||
|
|
||||||
@ -2217,6 +2237,10 @@ packages:
|
|||||||
lodash.sortby@4.7.0:
|
lodash.sortby@4.7.0:
|
||||||
resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==}
|
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:
|
lru-cache@6.0.0:
|
||||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -2471,6 +2495,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
prop-types@15.8.1:
|
||||||
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
proxy-addr@2.0.7:
|
proxy-addr@2.0.7:
|
||||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@ -2502,6 +2529,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.0.0
|
react: ^19.0.0
|
||||||
|
|
||||||
|
react-is@16.13.1:
|
||||||
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
react-refresh@0.9.0:
|
react-refresh@0.9.0:
|
||||||
resolution: {integrity: sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==}
|
resolution: {integrity: sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -3703,6 +3733,15 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- utf-8-validate
|
- 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/counter@0.1.3': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
@ -4607,6 +4646,8 @@ snapshots:
|
|||||||
|
|
||||||
jose@6.2.3: {}
|
jose@6.2.3: {}
|
||||||
|
|
||||||
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
json-parse-even-better-errors@2.3.1: {}
|
json-parse-even-better-errors@2.3.1: {}
|
||||||
|
|
||||||
json-schema-ref-resolver@3.0.0:
|
json-schema-ref-resolver@3.0.0:
|
||||||
@ -4692,6 +4733,10 @@ snapshots:
|
|||||||
|
|
||||||
lodash.sortby@4.7.0: {}
|
lodash.sortby@4.7.0: {}
|
||||||
|
|
||||||
|
loose-envify@1.4.0:
|
||||||
|
dependencies:
|
||||||
|
js-tokens: 4.0.0
|
||||||
|
|
||||||
lru-cache@6.0.0:
|
lru-cache@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
yallist: 4.0.0
|
yallist: 4.0.0
|
||||||
@ -4919,6 +4964,12 @@ snapshots:
|
|||||||
kleur: 3.0.3
|
kleur: 3.0.3
|
||||||
sisteransi: 1.0.5
|
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:
|
proxy-addr@2.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
@ -4951,6 +5002,8 @@ snapshots:
|
|||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
scheduler: 0.25.0
|
scheduler: 0.25.0
|
||||||
|
|
||||||
|
react-is@16.13.1: {}
|
||||||
|
|
||||||
react-refresh@0.9.0: {}
|
react-refresh@0.9.0: {}
|
||||||
|
|
||||||
react@19.0.0: {}
|
react@19.0.0: {}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user