feat: Swiss-compliant launch — Impressum/AGB/Contact, support panel, DSG exports, cookie banner
All checks were successful
Deploy to Production / deploy (push) Successful in 57s

Legal (Swiss minimum, no individual named):
- Impressum page (UWG Art. 3 lit. s) — provider, contact via support panel,
  no email required, jurisdiction = Switzerland
- AGB page — subscription terms, payment, cancellation, suspension on payment
  fail, 14-day money-back, AI-processing-per-tier disclosure, Swiss law +
  Swiss venue, modeled after typical Schweizer SaaS terms
- Privacy: Stripe added as subprocessor with full data-flow disclosure

Support panel replaces email contact entirely:
- @bmm/db: support_status enum + support_tickets + support_messages tables,
  migration applied to prod DB
- @bmm/api: support routes (user create/list/view/reply, admin list/view/reply
  /set-status), public /v1/contact for logged-out visitors with per-IP rate
  limit of 3 submissions/day to prevent spam-flood
- Web: /settings/support (list + new), /settings/support/[id] (conversation),
  /admin/support, /admin/support/[id]
- Public /contact form with email collection for guest tickets

Data rights (DSG Art. 25 / GDPR Art. 15+20):
- /v1/account/export returns user-scoped JSON of profile, org, servers,
  builds, audit, support tickets and messages — excludes hashes, encrypted
  secrets, other-user data
- /settings/account: download button + deletion-via-ticket workflow

Production-readiness gaps closed:
- org.suspended now blocks /v1/servers POST and /v1/servers/preview (402);
  webhook flagged this state but enforcement was missing
- Cookie banner: minimal, essential-cookies-only disclosure (Swiss DSG +
  GDPR compliant without dark-pattern consent UI), mounts on both layouts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi
2026-05-25 17:12:06 +02:00
parent c2a21fc3cd
commit ef30baf52a
18 changed files with 1692 additions and 6 deletions

View File

@@ -6,12 +6,14 @@ import Fastify from 'fastify';
import { config } from './config.js';
import { ensureActiveKey } from './lib/crypto.js';
import { validateStripePriceConfig } from './lib/stripe.js';
import { accountRoutes } from './routes/account.js';
import { adminRoutes } from './routes/admin.js';
import { authRoutes } from './routes/auth.js';
import { billingRoutes } from './routes/billing.js';
import { oauthRoutes } from './routes/oauth.js';
import { serverRoutes } from './routes/servers.js';
import { settingsRoutes } from './routes/settings.js';
import { supportRoutes } from './routes/support.js';
import { templateRoutes } from './routes/templates.js';
// Stripe webhook signature verification requires the raw request body, so we
@@ -72,6 +74,8 @@ await app.register(settingsRoutes);
await app.register(adminRoutes);
await app.register(templateRoutes);
await app.register(billingRoutes);
await app.register(supportRoutes);
await app.register(accountRoutes);
// Loud warning if STRIPE_PRICE_* env vars are set to product ids (prod_…)
// instead of price ids (price_…). Stripe Checkout would silently 400 — easier

View File

@@ -14,6 +14,31 @@ export async function getOrgPlan(orgId: string): Promise<Plan> {
return (row?.plan ?? 'hobby') as Plan;
}
export interface OrgBilling {
plan: Plan;
suspended: boolean;
suspendedReason: string | null;
}
/** Like getOrgPlan but also reports suspension state. Use in routes that
* should refuse new work when a subscription is past-due / unpaid. */
export async function getOrgBilling(orgId: string): Promise<OrgBilling> {
const [row] = await db
.select({
plan: organizations.plan,
suspended: organizations.suspended,
suspendedReason: organizations.suspendedReason,
})
.from(organizations)
.where(eq(organizations.id, orgId))
.limit(1);
return {
plan: (row?.plan ?? 'hobby') as Plan,
suspended: row?.suspended ?? false,
suspendedReason: row?.suspendedReason ?? null,
};
}
/** Max MCP servers per org by plan. Enforced at POST /v1/servers. */
export const SERVER_LIMITS: Record<Plan, number> = {
hobby: 1,

View File

@@ -0,0 +1,137 @@
import {
auditLog,
builds,
createDb,
desc,
eq,
inArray,
mcpServers,
organizations,
supportMessages,
supportTickets,
users,
} from '@bmm/db';
import type { FastifyInstance } from 'fastify';
import { audit } from '../lib/audit.js';
import { requireAuth } from '../plugins/session.js';
const db = createDb();
export async function accountRoutes(app: FastifyInstance): Promise<void> {
/**
* GDPR Art. 15 / Swiss DSG Art. 25 — right of access. Returns every record
* we hold that belongs to the calling user. Excludes hashed passwords,
* encrypted secret payloads, and any other user's data. Streamed as JSON
* attachment so the browser downloads it directly.
*/
app.get('/v1/account/export', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const [userRow] = await db.select().from(users).where(eq(users.id, user.userId)).limit(1);
const [org] = await db
.select()
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
const orgServers = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.orgId, user.orgId));
const serverIds = orgServers.map((s) => s.id);
const orgBuilds =
serverIds.length > 0
? await db.select().from(builds).where(inArray(builds.serverId, serverIds))
: [];
const userAudit = await db
.select()
.from(auditLog)
.where(eq(auditLog.userId, user.userId))
.orderBy(desc(auditLog.createdAt))
.limit(1000);
const userTickets = await db
.select()
.from(supportTickets)
.where(eq(supportTickets.userId, user.userId));
const ticketIds = userTickets.map((t) => t.id);
const userTicketMessages =
ticketIds.length > 0
? await db
.select()
.from(supportMessages)
.where(inArray(supportMessages.ticketId, ticketIds))
: [];
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'account.export',
resourceType: 'account',
ipAddress: req.ip,
});
reply
.header('Content-Type', 'application/json; charset=utf-8')
.header(
'Content-Disposition',
`attachment; filename="buildmymcpserver-export-${Date.now()}.json"`,
);
return reply.send({
exportedAt: new Date().toISOString(),
_format: 'BuildMyMCPServer Account Export v1',
_excluded: [
'password hashes',
'encrypted secret payloads',
'session tokens',
'other users in the same organization',
],
user: userRow
? {
id: userRow.id,
email: userRow.email,
name: userRow.name,
phone: userRow.phone,
isAdmin: userRow.isAdmin,
createdAt: userRow.createdAt,
}
: null,
organization: org
? {
id: org.id,
slug: org.slug,
name: org.name,
plan: org.plan,
createdAt: org.createdAt,
}
: null,
servers: orgServers.map((s) => ({
id: s.id,
slug: s.slug,
name: s.name,
status: s.status,
publicUrl: s.publicUrl,
toolsSchema: s.toolsSchema,
createdAt: s.createdAt,
})),
builds: orgBuilds.map((b) => ({
id: b.id,
serverId: b.serverId,
version: b.version,
prompt: b.prompt,
status: b.status,
createdAt: b.createdAt,
})),
audit: userAudit.map((a) => ({
id: a.id,
action: a.action,
resourceType: a.resourceType,
resourceId: a.resourceId,
metadata: a.metadata,
ipAddress: a.ipAddress,
createdAt: a.createdAt,
})),
supportTickets: userTickets,
supportMessages: userTicketMessages,
});
});
}

View File

@@ -32,7 +32,7 @@ import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import { encryptSecret } from '../lib/crypto.js';
import { stopContainer } from '../lib/docker.js';
import { SERVER_LIMITS, getOrgPlan } from '../lib/plan.js';
import { SERVER_LIMITS, getOrgBilling } from '../lib/plan.js';
import { cacheSpec, loadSpec, overwriteSpec } from '../lib/preview-cache.js';
import { getBuildQueue } from '../lib/queue.js';
import { BUILD_DAILY_LIMIT, PREVIEW_DAILY_LIMIT, checkDailyLimit } from '../lib/rate-limit.js';
@@ -60,7 +60,18 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
}
const plan = await getOrgPlan(user.orgId);
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 plan = billing.plan;
// Daily preview rate-limit per user. Free is tight (5/day) because every
// preview is a paid LLM call; paid tiers have headroom for real iteration.
@@ -142,7 +153,18 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
} = parsed.data;
// ---- Plan enforcement (must happen before any DB write) ----
const plan = await getOrgPlan(user.orgId);
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 plan = billing.plan;
// Daily build rate-limit.
const rl = await checkDailyLimit('build', user.userId, BUILD_DAILY_LIMIT[plan]);

View File

@@ -0,0 +1,290 @@
import {
and,
createDb,
desc,
eq,
supportMessages,
supportTickets,
users,
} from '@bmm/db';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { audit } from '../lib/audit.js';
import { checkDailyLimit } from '../lib/rate-limit.js';
import { requireAdmin, requireAuth } from '../plugins/session.js';
const db = createDb();
const NewTicketBody = z.object({
subject: z.string().min(3).max(200),
body: z.string().min(10).max(10_000),
});
const GuestTicketBody = z.object({
email: z.string().email(),
subject: z.string().min(3).max(200),
body: z.string().min(10).max(10_000),
});
const NewMessageBody = z.object({
body: z.string().min(1).max(10_000),
});
const StatusBody = z.object({
status: z.enum(['awaiting_admin', 'awaiting_user', 'closed']),
});
export async function supportRoutes(app: FastifyInstance): Promise<void> {
// ─── User-side ──────────────────────────────────────────────────────────
app.post('/v1/support/tickets', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const parsed = NewTicketBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const [ticket] = await db
.insert(supportTickets)
.values({
userId: user.userId,
orgId: user.orgId,
subject: parsed.data.subject,
status: 'awaiting_admin',
})
.returning();
if (!ticket) return reply.code(500).send({ error: 'ticket_create_failed' });
await db.insert(supportMessages).values({
ticketId: ticket.id,
authorUserId: user.userId,
authorIsAdmin: false,
body: parsed.data.body,
});
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'support.ticket_created',
resourceType: 'support_ticket',
resourceId: ticket.id,
metadata: { subject: parsed.data.subject },
ipAddress: req.ip,
});
return reply.send({ ticket });
});
app.get('/v1/support/tickets', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const rows = await db
.select()
.from(supportTickets)
.where(eq(supportTickets.userId, user.userId))
.orderBy(desc(supportTickets.lastMessageAt));
return reply.send({ tickets: rows });
});
app.get('/v1/support/tickets/:id', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const [ticket] = await db
.select()
.from(supportTickets)
.where(
and(eq(supportTickets.id, parsed.data.id), eq(supportTickets.userId, user.userId)),
)
.limit(1);
if (!ticket) return reply.code(404).send({ error: 'not_found' });
const messages = await db
.select()
.from(supportMessages)
.where(eq(supportMessages.ticketId, ticket.id))
.orderBy(supportMessages.createdAt);
return reply.send({ ticket, messages });
});
app.post(
'/v1/support/tickets/:id/messages',
{ preHandler: requireAuth },
async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const body = NewMessageBody.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
const [ticket] = await db
.select()
.from(supportTickets)
.where(
and(eq(supportTickets.id, parsed.data.id), eq(supportTickets.userId, user.userId)),
)
.limit(1);
if (!ticket) return reply.code(404).send({ error: 'not_found' });
await db.insert(supportMessages).values({
ticketId: ticket.id,
authorUserId: user.userId,
authorIsAdmin: false,
body: body.data.body,
});
await db
.update(supportTickets)
.set({
status: 'awaiting_admin',
lastMessageAt: new Date(),
updatedAt: new Date(),
})
.where(eq(supportTickets.id, ticket.id));
return reply.send({ ok: true });
},
);
// ─── Public contact form (no auth) ─────────────────────────────────────
// Satisfies UWG Art. 3 lit. s ("easy electronic contact") for non-logged-in
// visitors. Rate-limited per IP to prevent spam-flood of admin queue.
app.post('/v1/contact', async (req, reply) => {
const parsed = GuestTicketBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const rl = await checkDailyLimit('contact', req.ip, 3);
if (!rl.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: 'Too many contact submissions from this IP. Try again tomorrow.',
});
}
const [ticket] = await db
.insert(supportTickets)
.values({
guestEmail: parsed.data.email,
subject: parsed.data.subject,
status: 'awaiting_admin',
})
.returning();
if (!ticket) return reply.code(500).send({ error: 'ticket_create_failed' });
await db.insert(supportMessages).values({
ticketId: ticket.id,
authorUserId: null,
authorIsAdmin: false,
body: parsed.data.body,
});
return reply.send({ ok: true });
});
// ─── Admin-side ────────────────────────────────────────────────────────
app.get(
'/v1/admin/support/tickets',
{ preHandler: requireAdmin },
async (_req, reply) => {
const rows = await db
.select({
ticket: supportTickets,
userEmail: users.email,
userName: users.name,
})
.from(supportTickets)
.leftJoin(users, eq(users.id, supportTickets.userId))
.orderBy(desc(supportTickets.lastMessageAt))
.limit(200);
return reply.send({ tickets: rows });
},
);
app.get(
'/v1/admin/support/tickets/:id',
{ preHandler: requireAdmin },
async (req, reply) => {
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const [row] = await db
.select({ ticket: supportTickets, userEmail: users.email, userName: users.name })
.from(supportTickets)
.leftJoin(users, eq(users.id, supportTickets.userId))
.where(eq(supportTickets.id, parsed.data.id))
.limit(1);
if (!row) return reply.code(404).send({ error: 'not_found' });
const messages = await db
.select()
.from(supportMessages)
.where(eq(supportMessages.ticketId, parsed.data.id))
.orderBy(supportMessages.createdAt);
return reply.send({ ticket: row.ticket, userEmail: row.userEmail, userName: row.userName, messages });
},
);
app.post(
'/v1/admin/support/tickets/:id/messages',
{ preHandler: requireAdmin },
async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const body = NewMessageBody.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
await db.insert(supportMessages).values({
ticketId: parsed.data.id,
authorUserId: user.userId,
authorIsAdmin: true,
body: body.data.body,
});
await db
.update(supportTickets)
.set({
status: 'awaiting_user',
lastMessageAt: new Date(),
updatedAt: new Date(),
})
.where(eq(supportTickets.id, parsed.data.id));
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'support.admin_reply',
resourceType: 'support_ticket',
resourceId: parsed.data.id,
});
return reply.send({ ok: true });
},
);
app.post(
'/v1/admin/support/tickets/:id/status',
{ preHandler: requireAdmin },
async (req, reply) => {
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const body = StatusBody.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
await db
.update(supportTickets)
.set({
status: body.data.status,
closedAt: body.data.status === 'closed' ? new Date() : null,
updatedAt: new Date(),
})
.where(eq(supportTickets.id, parsed.data.id));
return reply.send({ ok: true });
},
);
}