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
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
137
apps/api/src/routes/account.ts
Normal file
137
apps/api/src/routes/account.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
290
apps/api/src/routes/support.ts
Normal file
290
apps/api/src/routes/support.ts
Normal 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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user