2026-05-19 00:21:18 +02:00
|
|
|
import {
|
|
|
|
|
bigint,
|
|
|
|
|
boolean,
|
|
|
|
|
index,
|
|
|
|
|
integer,
|
|
|
|
|
jsonb,
|
|
|
|
|
pgEnum,
|
|
|
|
|
pgTable,
|
|
|
|
|
text,
|
|
|
|
|
timestamp,
|
|
|
|
|
uuid,
|
|
|
|
|
varchar,
|
|
|
|
|
} from 'drizzle-orm/pg-core';
|
|
|
|
|
|
|
|
|
|
export const planEnum = pgEnum('plan', ['hobby', 'pro', 'team', 'enterprise']);
|
|
|
|
|
|
|
|
|
|
export const serverStatusEnum = pgEnum('server_status', [
|
|
|
|
|
'draft',
|
|
|
|
|
'queued',
|
|
|
|
|
'generating',
|
|
|
|
|
'building',
|
|
|
|
|
'deploying',
|
|
|
|
|
'live',
|
|
|
|
|
'failed',
|
|
|
|
|
'paused',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
export const buildStatusEnum = pgEnum('build_status', [
|
|
|
|
|
'queued',
|
|
|
|
|
'generating',
|
|
|
|
|
'building',
|
|
|
|
|
'deploying',
|
|
|
|
|
'success',
|
|
|
|
|
'failed',
|
|
|
|
|
'cancelled',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
export const organizations = pgTable('organizations', {
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
slug: varchar('slug', { length: 64 }).notNull().unique(),
|
|
|
|
|
name: varchar('name', { length: 128 }).notNull(),
|
|
|
|
|
plan: planEnum('plan').default('hobby').notNull(),
|
|
|
|
|
stripeCustomerId: varchar('stripe_customer_id', { length: 128 }),
|
|
|
|
|
stripeSubscriptionId: varchar('stripe_subscription_id', { length: 128 }),
|
|
|
|
|
monthlyCallQuota: bigint('monthly_call_quota', { mode: 'number' }).default(100_000).notNull(),
|
|
|
|
|
callsThisPeriod: bigint('calls_this_period', { mode: 'number' }).default(0).notNull(),
|
|
|
|
|
periodStartsAt: timestamp('period_starts_at').defaultNow().notNull(),
|
feat(admin): password-auth admin panel with 8 pages + 15 API endpoints
Schema migrations:
- users.is_admin boolean
- users.password_hash text (scrypt N=16384, 16-byte salt)
- users.last_login_at timestamp
- organizations.suspended + suspended_reason
- admin_settings table (DB-stored prompt override + future settings)
Auth (@bmm/auth):
- hashPassword + verifyPassword via node:crypto scrypt (no extra dep)
- loginWithPassword: scrypt-verifies, issues 30-day session, updates last_login_at
- seedAdmin: idempotent upsert keyed on email; creates org + membership on first run
- AuthedUser now carries isAdmin flag
API:
- POST /v1/auth/admin/login (email + password) — 300ms throttle on failure
- requireAdmin preHandler — 401 if no session, 403 if non-admin
- Bootstrap: api on boot calls seedAdmin(ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME)
if env present. Idempotent.
Admin API routes (all gated by requireAdmin):
- GET /v1/admin/overview (totals, trends 7d, server-status breakdown, builds 24h, recent activity)
- GET /v1/admin/users (search, per-row org + plan + serverCount)
- PATCH /v1/admin/users/:id (isAdmin, name)
- DELETE /v1/admin/users/:id (self-delete blocked)
- GET /v1/admin/orgs (member + server counts)
- PATCH /v1/admin/orgs/:id (plan, quota, suspended; cascades to mcp_servers.status=paused on suspend)
- GET /v1/admin/servers (cross-org with status filter)
- POST /v1/admin/servers/:id/rebuild (re-queues build using last prompt)
- DELETE /v1/admin/servers/:id
- GET /v1/admin/builds (status filter, error messages, prompt previews)
- GET /v1/admin/builds/:id/logs
- GET /v1/admin/audit (system-wide with user email join)
- GET /v1/admin/system (DB ping, Redis ping, BullMQ queue depth, docker ps count)
- GET /v1/admin/prompt (builtin + override + updatedAt)
- PATCH /v1/admin/prompt (value: string | null) — saves DB override or drops it
UI (apps/web/app/admin/*):
- /admin/login — password form, separate from /login magic-link
- AdminLayout — Linear-style sidebar (8 nav items), bottom panel with user email +
'user view' shortcut + logout, client-side requireAdmin guard with redirect
- /admin — overview dashboard with 4 metric cards, 2 panels (status + 24h builds),
recent activity table linking to full audit
- /admin/users — search + admin toggle + delete (self-delete blocked)
- /admin/orgs — plan/quota/suspend actions via prompts
- /admin/servers — cross-org table with rebuild + delete actions, status filter
- /admin/builds — every build cross-fleet with error vs prompt preview
- /admin/audit — system-wide log + CSV export + filter dropdowns
- /admin/system — auto-refreshing 5s health probes for Postgres, Redis, queue, Docker
- /admin/prompt — live editor for the LLM system prompt with built-in baseline,
override-state badge, drop-override action, diff preview, save-as-override
End-to-end verified: login as marco.frangiskatos@gmail.com + Melusa112233.*, every
admin page returns 200, admin login + overview tested via screenshot, docker probe
returns true count of running MCP containers.
2026-05-19 23:01:26 +02:00
|
|
|
suspended: boolean('suspended').default(false).notNull(),
|
|
|
|
|
suspendedReason: text('suspended_reason'),
|
2026-05-19 00:21:18 +02:00
|
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
feat(admin): password-auth admin panel with 8 pages + 15 API endpoints
Schema migrations:
- users.is_admin boolean
- users.password_hash text (scrypt N=16384, 16-byte salt)
- users.last_login_at timestamp
- organizations.suspended + suspended_reason
- admin_settings table (DB-stored prompt override + future settings)
Auth (@bmm/auth):
- hashPassword + verifyPassword via node:crypto scrypt (no extra dep)
- loginWithPassword: scrypt-verifies, issues 30-day session, updates last_login_at
- seedAdmin: idempotent upsert keyed on email; creates org + membership on first run
- AuthedUser now carries isAdmin flag
API:
- POST /v1/auth/admin/login (email + password) — 300ms throttle on failure
- requireAdmin preHandler — 401 if no session, 403 if non-admin
- Bootstrap: api on boot calls seedAdmin(ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME)
if env present. Idempotent.
Admin API routes (all gated by requireAdmin):
- GET /v1/admin/overview (totals, trends 7d, server-status breakdown, builds 24h, recent activity)
- GET /v1/admin/users (search, per-row org + plan + serverCount)
- PATCH /v1/admin/users/:id (isAdmin, name)
- DELETE /v1/admin/users/:id (self-delete blocked)
- GET /v1/admin/orgs (member + server counts)
- PATCH /v1/admin/orgs/:id (plan, quota, suspended; cascades to mcp_servers.status=paused on suspend)
- GET /v1/admin/servers (cross-org with status filter)
- POST /v1/admin/servers/:id/rebuild (re-queues build using last prompt)
- DELETE /v1/admin/servers/:id
- GET /v1/admin/builds (status filter, error messages, prompt previews)
- GET /v1/admin/builds/:id/logs
- GET /v1/admin/audit (system-wide with user email join)
- GET /v1/admin/system (DB ping, Redis ping, BullMQ queue depth, docker ps count)
- GET /v1/admin/prompt (builtin + override + updatedAt)
- PATCH /v1/admin/prompt (value: string | null) — saves DB override or drops it
UI (apps/web/app/admin/*):
- /admin/login — password form, separate from /login magic-link
- AdminLayout — Linear-style sidebar (8 nav items), bottom panel with user email +
'user view' shortcut + logout, client-side requireAdmin guard with redirect
- /admin — overview dashboard with 4 metric cards, 2 panels (status + 24h builds),
recent activity table linking to full audit
- /admin/users — search + admin toggle + delete (self-delete blocked)
- /admin/orgs — plan/quota/suspend actions via prompts
- /admin/servers — cross-org table with rebuild + delete actions, status filter
- /admin/builds — every build cross-fleet with error vs prompt preview
- /admin/audit — system-wide log + CSV export + filter dropdowns
- /admin/system — auto-refreshing 5s health probes for Postgres, Redis, queue, Docker
- /admin/prompt — live editor for the LLM system prompt with built-in baseline,
override-state badge, drop-override action, diff preview, save-as-override
End-to-end verified: login as marco.frangiskatos@gmail.com + Melusa112233.*, every
admin page returns 200, admin login + overview tested via screenshot, docker probe
returns true count of running MCP containers.
2026-05-19 23:01:26 +02:00
|
|
|
export const adminSettings = pgTable('admin_settings', {
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
key: varchar('key', { length: 64 }).notNull().unique(),
|
|
|
|
|
value: text('value'),
|
|
|
|
|
updatedBy: uuid('updated_by').references(() => users.id, { onDelete: 'set null' }),
|
|
|
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
feat(marketplace): template publish + fork + voting/ranking + admin moderation
What this enables:
- A user builds an MCP server. If others would benefit, they click 'Publish as
template' on their server detail page. The spec + pre-rendered TypeScript
snapshot is preserved.
- Visitors browse /templates, filter by category, sort by trending/top/newest.
Each template card shows fork count + active deployment count as natural
manipulation-resistant popularity signal.
- /templates/[slug] shows the full plan: tool list with input schemas,
required-credential explanations (with 'how to get one' deep links), and a
collapsible code preview so users can audit before forking.
- Fork is one click → /servers/new?template=slug. The wizard skips Step 1 and
pre-fills Step 2 with the template's parsed spec. Forker only fills in their
own credentials. mcp_servers.template_id is recorded; template.fork_count is
bumped atomically. Each fork gets its own isolated container with its own
port, its own AES-256 secrets — the template author has zero visibility into
the fork's traffic or data.
- Admin /admin/templates moderation: verify quality templates (shows shield
badge in marketplace), hide low-effort ones, takedown anything malicious.
Takedowns cascade-pause every fork container — owners must re-deploy.
Why template+fork instead of shared-container:
- Shared containers would mean the publisher's quota + their secrets + their
logs are exposed to forkers. Bad ergonomics, bad security, bad ownership.
- Templates/forks decouple the spec (shared, vouched-for) from the runtime
(isolated per user). Network-effect moat without the trust collapse.
Why no 5-star voting in v1:
- Manipulation-anfällig, empty lists without adoption. We use fork count +
active deploys + verified badge. Trending algorithm:
score = (activeDeploys * 3 + forks) / sqrt(ageDays + 1)
Real signal, no brigading attack surface.
Backend:
- New schema: templates table (16 cols incl. tools_schema, generated_code,
required_secrets, allowedDomains, status enum, verified, fork_count).
- mcp_servers.template_id FK + idx for fork lookup.
- @bmm/types: SpecEdit unchanged, CreateServerInput accepts optional templateId.
- preview-cache.ts: new cachePrebuiltCode/loadPrebuiltCode for storing the
template's full rendered server.ts alongside the spec. Generator worker
detects this and skips the render step — uses the audited pre-built code
verbatim. Banned-pattern re-scan at publish time.
- routes/templates.ts: 5 public/auth routes + 2 admin routes. Banned-pattern
re-scan before publish. Slug auto-uniqued. forkCount atomic-increment via
SQL.
UI:
- /templates marketplace with trending/top/newest tabs, category filter, search.
Cards show forks + live count + author + verified badge.
- /templates/[slug] full detail with tools, credentials-with-hints, expandable
code preview, fork CTA, ownership + stats sidebar, 'forking is safe' explainer.
- /servers/new?template=slug — wizard auto-jumps to Step 2 with template spec
pre-filled, fork banner at top with link back to template.
- /servers/[id] new Publish tab with title, category, descriptions, per-secret
hint fields (description + howToGetUrl per UPPER_SNAKE_CASE key).
- /admin/templates moderation with verify/hide/takedown actions.
- Marketing nav now includes /templates.
Verified end-to-end:
- Published Echo Demo Template from marco@test.local's live server
- Marketplace lists it correctly with stats
- Detail page renders with all sections
- Fork CTA navigates to wizard with ?template= param
- Wizard skips Step 1, shows fork banner, pre-fills spec
- Build succeeds in ~10s (cached spec + prebuilt code path skips Claude AND
render), container live on :4109 with proper OAuth 401 → token → 200 flow
- DB: templates.fork_count=1, activeDeployments=1, mcp_servers.template_id
populated on the fork
- /admin/templates shows the new template with verify/hide/takedown controls
2026-05-19 23:22:35 +02:00
|
|
|
export const templateStatusEnum = pgEnum('template_status', [
|
|
|
|
|
'draft',
|
|
|
|
|
'public',
|
|
|
|
|
'hidden',
|
|
|
|
|
'takedown',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
export const templates = pgTable(
|
|
|
|
|
'templates',
|
|
|
|
|
{
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'set null' }),
|
|
|
|
|
ownerOrgId: uuid('owner_org_id').references(() => organizations.id, { onDelete: 'set null' }),
|
|
|
|
|
sourceServerId: uuid('source_server_id'),
|
|
|
|
|
slug: varchar('slug', { length: 64 }).notNull().unique(),
|
|
|
|
|
title: varchar('title', { length: 128 }).notNull(),
|
|
|
|
|
shortDescription: varchar('short_description', { length: 280 }).notNull(),
|
|
|
|
|
longDescription: text('long_description'),
|
|
|
|
|
category: varchar('category', { length: 64 }).notNull(),
|
|
|
|
|
toolsSchema: jsonb('tools_schema').notNull(),
|
|
|
|
|
generatedCode: text('generated_code').notNull(),
|
|
|
|
|
requiredSecrets: jsonb('required_secrets').notNull(),
|
|
|
|
|
scopes: jsonb('scopes').notNull(),
|
|
|
|
|
allowedDomains: jsonb('allowed_domains'),
|
|
|
|
|
status: templateStatusEnum('status').default('public').notNull(),
|
|
|
|
|
verified: boolean('verified').default(false).notNull(),
|
|
|
|
|
takedownReason: text('takedown_reason'),
|
|
|
|
|
forkCount: integer('fork_count').default(0).notNull(),
|
|
|
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
|
|
|
},
|
|
|
|
|
(t) => ({
|
|
|
|
|
statusIdx: index('idx_templates_status').on(t.status, t.createdAt),
|
|
|
|
|
categoryIdx: index('idx_templates_category').on(t.category),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-19 00:21:18 +02:00
|
|
|
export const users = pgTable('users', {
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
email: varchar('email', { length: 255 }).notNull().unique(),
|
|
|
|
|
name: varchar('name', { length: 128 }),
|
|
|
|
|
avatarUrl: text('avatar_url'),
|
|
|
|
|
emailVerified: boolean('email_verified').default(false).notNull(),
|
feat(admin): password-auth admin panel with 8 pages + 15 API endpoints
Schema migrations:
- users.is_admin boolean
- users.password_hash text (scrypt N=16384, 16-byte salt)
- users.last_login_at timestamp
- organizations.suspended + suspended_reason
- admin_settings table (DB-stored prompt override + future settings)
Auth (@bmm/auth):
- hashPassword + verifyPassword via node:crypto scrypt (no extra dep)
- loginWithPassword: scrypt-verifies, issues 30-day session, updates last_login_at
- seedAdmin: idempotent upsert keyed on email; creates org + membership on first run
- AuthedUser now carries isAdmin flag
API:
- POST /v1/auth/admin/login (email + password) — 300ms throttle on failure
- requireAdmin preHandler — 401 if no session, 403 if non-admin
- Bootstrap: api on boot calls seedAdmin(ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME)
if env present. Idempotent.
Admin API routes (all gated by requireAdmin):
- GET /v1/admin/overview (totals, trends 7d, server-status breakdown, builds 24h, recent activity)
- GET /v1/admin/users (search, per-row org + plan + serverCount)
- PATCH /v1/admin/users/:id (isAdmin, name)
- DELETE /v1/admin/users/:id (self-delete blocked)
- GET /v1/admin/orgs (member + server counts)
- PATCH /v1/admin/orgs/:id (plan, quota, suspended; cascades to mcp_servers.status=paused on suspend)
- GET /v1/admin/servers (cross-org with status filter)
- POST /v1/admin/servers/:id/rebuild (re-queues build using last prompt)
- DELETE /v1/admin/servers/:id
- GET /v1/admin/builds (status filter, error messages, prompt previews)
- GET /v1/admin/builds/:id/logs
- GET /v1/admin/audit (system-wide with user email join)
- GET /v1/admin/system (DB ping, Redis ping, BullMQ queue depth, docker ps count)
- GET /v1/admin/prompt (builtin + override + updatedAt)
- PATCH /v1/admin/prompt (value: string | null) — saves DB override or drops it
UI (apps/web/app/admin/*):
- /admin/login — password form, separate from /login magic-link
- AdminLayout — Linear-style sidebar (8 nav items), bottom panel with user email +
'user view' shortcut + logout, client-side requireAdmin guard with redirect
- /admin — overview dashboard with 4 metric cards, 2 panels (status + 24h builds),
recent activity table linking to full audit
- /admin/users — search + admin toggle + delete (self-delete blocked)
- /admin/orgs — plan/quota/suspend actions via prompts
- /admin/servers — cross-org table with rebuild + delete actions, status filter
- /admin/builds — every build cross-fleet with error vs prompt preview
- /admin/audit — system-wide log + CSV export + filter dropdowns
- /admin/system — auto-refreshing 5s health probes for Postgres, Redis, queue, Docker
- /admin/prompt — live editor for the LLM system prompt with built-in baseline,
override-state badge, drop-override action, diff preview, save-as-override
End-to-end verified: login as marco.frangiskatos@gmail.com + Melusa112233.*, every
admin page returns 200, admin login + overview tested via screenshot, docker probe
returns true count of running MCP containers.
2026-05-19 23:01:26 +02:00
|
|
|
isAdmin: boolean('is_admin').default(false).notNull(),
|
|
|
|
|
passwordHash: text('password_hash'),
|
|
|
|
|
lastLoginAt: timestamp('last_login_at'),
|
2026-05-19 00:21:18 +02:00
|
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const sessions = pgTable(
|
|
|
|
|
'sessions',
|
|
|
|
|
{
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
userId: uuid('user_id')
|
|
|
|
|
.references(() => users.id, { onDelete: 'cascade' })
|
|
|
|
|
.notNull(),
|
|
|
|
|
tokenHash: text('token_hash').notNull().unique(),
|
|
|
|
|
expiresAt: timestamp('expires_at').notNull(),
|
|
|
|
|
ipAddress: varchar('ip_address', { length: 64 }),
|
|
|
|
|
userAgent: text('user_agent'),
|
|
|
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
|
|
|
},
|
|
|
|
|
(t) => ({
|
|
|
|
|
userIdx: index('idx_sessions_user').on(t.userId),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const magicLinks = pgTable('magic_links', {
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
email: varchar('email', { length: 255 }).notNull(),
|
|
|
|
|
tokenHash: text('token_hash').notNull().unique(),
|
|
|
|
|
expiresAt: timestamp('expires_at').notNull(),
|
|
|
|
|
consumedAt: timestamp('consumed_at'),
|
|
|
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const memberships = pgTable('memberships', {
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
orgId: uuid('org_id')
|
|
|
|
|
.references(() => organizations.id, { onDelete: 'cascade' })
|
|
|
|
|
.notNull(),
|
|
|
|
|
userId: uuid('user_id')
|
|
|
|
|
.references(() => users.id, { onDelete: 'cascade' })
|
|
|
|
|
.notNull(),
|
|
|
|
|
role: varchar('role', { length: 32 }).default('owner').notNull(),
|
|
|
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const mcpServers = pgTable(
|
|
|
|
|
'mcp_servers',
|
|
|
|
|
{
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
orgId: uuid('org_id')
|
|
|
|
|
.references(() => organizations.id, { onDelete: 'cascade' })
|
|
|
|
|
.notNull(),
|
|
|
|
|
slug: varchar('slug', { length: 64 }).notNull(),
|
|
|
|
|
name: varchar('name', { length: 128 }).notNull(),
|
|
|
|
|
description: text('description'),
|
|
|
|
|
status: serverStatusEnum('status').default('draft').notNull(),
|
|
|
|
|
currentVersion: integer('current_version').default(0).notNull(),
|
|
|
|
|
containerId: varchar('container_id', { length: 128 }),
|
|
|
|
|
hostPort: integer('host_port'),
|
|
|
|
|
publicUrl: text('public_url'),
|
|
|
|
|
toolsSchema: jsonb('tools_schema'),
|
|
|
|
|
oauthEnabled: boolean('oauth_enabled').default(true).notNull(),
|
feat(marketplace): template publish + fork + voting/ranking + admin moderation
What this enables:
- A user builds an MCP server. If others would benefit, they click 'Publish as
template' on their server detail page. The spec + pre-rendered TypeScript
snapshot is preserved.
- Visitors browse /templates, filter by category, sort by trending/top/newest.
Each template card shows fork count + active deployment count as natural
manipulation-resistant popularity signal.
- /templates/[slug] shows the full plan: tool list with input schemas,
required-credential explanations (with 'how to get one' deep links), and a
collapsible code preview so users can audit before forking.
- Fork is one click → /servers/new?template=slug. The wizard skips Step 1 and
pre-fills Step 2 with the template's parsed spec. Forker only fills in their
own credentials. mcp_servers.template_id is recorded; template.fork_count is
bumped atomically. Each fork gets its own isolated container with its own
port, its own AES-256 secrets — the template author has zero visibility into
the fork's traffic or data.
- Admin /admin/templates moderation: verify quality templates (shows shield
badge in marketplace), hide low-effort ones, takedown anything malicious.
Takedowns cascade-pause every fork container — owners must re-deploy.
Why template+fork instead of shared-container:
- Shared containers would mean the publisher's quota + their secrets + their
logs are exposed to forkers. Bad ergonomics, bad security, bad ownership.
- Templates/forks decouple the spec (shared, vouched-for) from the runtime
(isolated per user). Network-effect moat without the trust collapse.
Why no 5-star voting in v1:
- Manipulation-anfällig, empty lists without adoption. We use fork count +
active deploys + verified badge. Trending algorithm:
score = (activeDeploys * 3 + forks) / sqrt(ageDays + 1)
Real signal, no brigading attack surface.
Backend:
- New schema: templates table (16 cols incl. tools_schema, generated_code,
required_secrets, allowedDomains, status enum, verified, fork_count).
- mcp_servers.template_id FK + idx for fork lookup.
- @bmm/types: SpecEdit unchanged, CreateServerInput accepts optional templateId.
- preview-cache.ts: new cachePrebuiltCode/loadPrebuiltCode for storing the
template's full rendered server.ts alongside the spec. Generator worker
detects this and skips the render step — uses the audited pre-built code
verbatim. Banned-pattern re-scan at publish time.
- routes/templates.ts: 5 public/auth routes + 2 admin routes. Banned-pattern
re-scan before publish. Slug auto-uniqued. forkCount atomic-increment via
SQL.
UI:
- /templates marketplace with trending/top/newest tabs, category filter, search.
Cards show forks + live count + author + verified badge.
- /templates/[slug] full detail with tools, credentials-with-hints, expandable
code preview, fork CTA, ownership + stats sidebar, 'forking is safe' explainer.
- /servers/new?template=slug — wizard auto-jumps to Step 2 with template spec
pre-filled, fork banner at top with link back to template.
- /servers/[id] new Publish tab with title, category, descriptions, per-secret
hint fields (description + howToGetUrl per UPPER_SNAKE_CASE key).
- /admin/templates moderation with verify/hide/takedown actions.
- Marketing nav now includes /templates.
Verified end-to-end:
- Published Echo Demo Template from marco@test.local's live server
- Marketplace lists it correctly with stats
- Detail page renders with all sections
- Fork CTA navigates to wizard with ?template= param
- Wizard skips Step 1, shows fork banner, pre-fills spec
- Build succeeds in ~10s (cached spec + prebuilt code path skips Claude AND
render), container live on :4109 with proper OAuth 401 → token → 200 flow
- DB: templates.fork_count=1, activeDeployments=1, mcp_servers.template_id
populated on the fork
- /admin/templates shows the new template with verify/hide/takedown controls
2026-05-19 23:22:35 +02:00
|
|
|
templateId: uuid('template_id'),
|
2026-05-19 00:21:18 +02:00
|
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
|
|
|
},
|
|
|
|
|
(t) => ({
|
|
|
|
|
orgSlugIdx: index('idx_servers_org_slug').on(t.orgId, t.slug),
|
feat(marketplace): template publish + fork + voting/ranking + admin moderation
What this enables:
- A user builds an MCP server. If others would benefit, they click 'Publish as
template' on their server detail page. The spec + pre-rendered TypeScript
snapshot is preserved.
- Visitors browse /templates, filter by category, sort by trending/top/newest.
Each template card shows fork count + active deployment count as natural
manipulation-resistant popularity signal.
- /templates/[slug] shows the full plan: tool list with input schemas,
required-credential explanations (with 'how to get one' deep links), and a
collapsible code preview so users can audit before forking.
- Fork is one click → /servers/new?template=slug. The wizard skips Step 1 and
pre-fills Step 2 with the template's parsed spec. Forker only fills in their
own credentials. mcp_servers.template_id is recorded; template.fork_count is
bumped atomically. Each fork gets its own isolated container with its own
port, its own AES-256 secrets — the template author has zero visibility into
the fork's traffic or data.
- Admin /admin/templates moderation: verify quality templates (shows shield
badge in marketplace), hide low-effort ones, takedown anything malicious.
Takedowns cascade-pause every fork container — owners must re-deploy.
Why template+fork instead of shared-container:
- Shared containers would mean the publisher's quota + their secrets + their
logs are exposed to forkers. Bad ergonomics, bad security, bad ownership.
- Templates/forks decouple the spec (shared, vouched-for) from the runtime
(isolated per user). Network-effect moat without the trust collapse.
Why no 5-star voting in v1:
- Manipulation-anfällig, empty lists without adoption. We use fork count +
active deploys + verified badge. Trending algorithm:
score = (activeDeploys * 3 + forks) / sqrt(ageDays + 1)
Real signal, no brigading attack surface.
Backend:
- New schema: templates table (16 cols incl. tools_schema, generated_code,
required_secrets, allowedDomains, status enum, verified, fork_count).
- mcp_servers.template_id FK + idx for fork lookup.
- @bmm/types: SpecEdit unchanged, CreateServerInput accepts optional templateId.
- preview-cache.ts: new cachePrebuiltCode/loadPrebuiltCode for storing the
template's full rendered server.ts alongside the spec. Generator worker
detects this and skips the render step — uses the audited pre-built code
verbatim. Banned-pattern re-scan at publish time.
- routes/templates.ts: 5 public/auth routes + 2 admin routes. Banned-pattern
re-scan before publish. Slug auto-uniqued. forkCount atomic-increment via
SQL.
UI:
- /templates marketplace with trending/top/newest tabs, category filter, search.
Cards show forks + live count + author + verified badge.
- /templates/[slug] full detail with tools, credentials-with-hints, expandable
code preview, fork CTA, ownership + stats sidebar, 'forking is safe' explainer.
- /servers/new?template=slug — wizard auto-jumps to Step 2 with template spec
pre-filled, fork banner at top with link back to template.
- /servers/[id] new Publish tab with title, category, descriptions, per-secret
hint fields (description + howToGetUrl per UPPER_SNAKE_CASE key).
- /admin/templates moderation with verify/hide/takedown actions.
- Marketing nav now includes /templates.
Verified end-to-end:
- Published Echo Demo Template from marco@test.local's live server
- Marketplace lists it correctly with stats
- Detail page renders with all sections
- Fork CTA navigates to wizard with ?template= param
- Wizard skips Step 1, shows fork banner, pre-fills spec
- Build succeeds in ~10s (cached spec + prebuilt code path skips Claude AND
render), container live on :4109 with proper OAuth 401 → token → 200 flow
- DB: templates.fork_count=1, activeDeployments=1, mcp_servers.template_id
populated on the fork
- /admin/templates shows the new template with verify/hide/takedown controls
2026-05-19 23:22:35 +02:00
|
|
|
templateIdx: index('idx_servers_template').on(t.templateId),
|
2026-05-19 00:21:18 +02:00
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const builds = pgTable(
|
|
|
|
|
'builds',
|
|
|
|
|
{
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
serverId: uuid('server_id')
|
|
|
|
|
.references(() => mcpServers.id, { onDelete: 'cascade' })
|
|
|
|
|
.notNull(),
|
|
|
|
|
version: integer('version').notNull(),
|
|
|
|
|
prompt: text('prompt').notNull(),
|
|
|
|
|
generatedSpec: jsonb('generated_spec'),
|
|
|
|
|
generatedCode: text('generated_code'),
|
|
|
|
|
status: buildStatusEnum('status').default('queued').notNull(),
|
|
|
|
|
errorMessage: text('error_message'),
|
|
|
|
|
startedAt: timestamp('started_at'),
|
|
|
|
|
finishedAt: timestamp('finished_at'),
|
|
|
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
|
|
|
},
|
|
|
|
|
(t) => ({
|
|
|
|
|
serverIdx: index('idx_builds_server').on(t.serverId),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const buildLogs = pgTable(
|
|
|
|
|
'build_logs',
|
|
|
|
|
{
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
buildId: uuid('build_id')
|
|
|
|
|
.references(() => builds.id, { onDelete: 'cascade' })
|
|
|
|
|
.notNull(),
|
|
|
|
|
level: varchar('level', { length: 16 }).default('info').notNull(),
|
|
|
|
|
message: text('message').notNull(),
|
|
|
|
|
timestamp: timestamp('timestamp').defaultNow().notNull(),
|
|
|
|
|
},
|
|
|
|
|
(t) => ({
|
|
|
|
|
buildIdx: index('idx_logs_build').on(t.buildId, t.timestamp),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
feat(crypto): envelope encryption + key rotation via admin panel
Closes structural weakness #4 from the audit (single global key, no rotation,
no KMS path). Customer secrets now use envelope encryption with a real
rotation story.
Model:
KEK — Key Encryption Key, 32 bytes from env (SECRETS_ENCRYPTION_KEY). Never
stored in the DB. Root of trust.
DEK — Data Encryption Key, 32 random bytes we generate, stored in the new
encryption_keys table *wrapped* (AES-256-GCM encrypted) with the KEK.
Secrets are encrypted with the DEK.
Schema:
- encryption_keys (version, wrappedDek, active, rotatedBy, createdAt, retiredAt)
- secrets.keyId — which DEK encrypted this row. NULL = legacy (KEK-direct,
pre-envelope); decryptSecret handles both and the first rotation migrates
legacy rows onto a DEK.
crypto.ts (full rewrite):
- ensureActiveKey() — boot-time, loads keys + creates v1 if none. Fail-closed:
index.ts process.exit(1) if it throws — the API will not serve if encryption
can't initialize.
- encryptSecret() — encrypts with the active DEK, returns { value, keyId }.
- decryptSecret(value, keyId) — DEK path or legacy KEK-direct path.
- rotateKeys() — mints a fresh DEK, re-encrypts EVERY secret under it inside a
single transaction (decrypt-old / encrypt-new per row), retires the old key,
activates the new one. A partial failure is recoverable because every row
carries its own keyId.
- encryptionStatus() — active version, key history, secret + legacy counts.
Admin:
- GET /v1/admin/encryption — status
- POST /v1/admin/encryption/rotate — triggers rotateKeys, audit-logged as
admin.encryption.rotate with { newVersion, reEncrypted }.
- /admin/encryption page — active-key/secret/legacy cards, Rotate button with
confirm, key-history table, plain-English how-it-works. Added to admin nav.
Verified end-to-end:
- boot → encryption_keys v1 active, '[crypto] envelope encryption ready'
- created a server with secret MY_API_KEY → stored ciphertext, keyId = v1
- POST rotate → { newVersion: 2, reEncrypted: 1 }; ciphertext changed, keyId
now v2, v1 retired, v2 active. The decrypt-then-reencrypt round-trip
succeeded (rotation throws otherwise) — the secret is provably recoverable.
- admin UI renders the status + history correctly.
Deferred, named honestly (not built this iteration):
- worker reads secrets from the DB instead of the BullMQ job-data plaintext
copy — would also remove plaintext secrets from Redis. Separate change with
its own risk surface on the iterate/fork flows.
- per-server secret-value rotation UI
- audit_log hash-chaining (tamper-evidence)
- rate limiting on auth endpoints
2026-05-20 22:36:08 +02:00
|
|
|
// Envelope encryption: a Data Encryption Key (DEK) is generated, wrapped (itself
|
|
|
|
|
// AES-256-GCM encrypted) with the Key Encryption Key (KEK) from the env, and
|
|
|
|
|
// stored here. Secrets are encrypted with the DEK. Rotation mints a fresh DEK
|
|
|
|
|
// and re-encrypts every secret — the KEK never leaves the environment.
|
|
|
|
|
export const encryptionKeys = pgTable('encryption_keys', {
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
version: integer('version').notNull().unique(),
|
|
|
|
|
wrappedDek: text('wrapped_dek').notNull(),
|
|
|
|
|
active: boolean('active').default(false).notNull(),
|
|
|
|
|
rotatedBy: uuid('rotated_by').references(() => users.id, { onDelete: 'set null' }),
|
|
|
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
|
|
|
retiredAt: timestamp('retired_at'),
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 00:21:18 +02:00
|
|
|
export const secrets = pgTable('secrets', {
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
serverId: uuid('server_id')
|
|
|
|
|
.references(() => mcpServers.id, { onDelete: 'cascade' })
|
|
|
|
|
.notNull(),
|
|
|
|
|
key: varchar('key', { length: 128 }).notNull(),
|
|
|
|
|
encryptedValue: text('encrypted_value').notNull(),
|
feat(crypto): envelope encryption + key rotation via admin panel
Closes structural weakness #4 from the audit (single global key, no rotation,
no KMS path). Customer secrets now use envelope encryption with a real
rotation story.
Model:
KEK — Key Encryption Key, 32 bytes from env (SECRETS_ENCRYPTION_KEY). Never
stored in the DB. Root of trust.
DEK — Data Encryption Key, 32 random bytes we generate, stored in the new
encryption_keys table *wrapped* (AES-256-GCM encrypted) with the KEK.
Secrets are encrypted with the DEK.
Schema:
- encryption_keys (version, wrappedDek, active, rotatedBy, createdAt, retiredAt)
- secrets.keyId — which DEK encrypted this row. NULL = legacy (KEK-direct,
pre-envelope); decryptSecret handles both and the first rotation migrates
legacy rows onto a DEK.
crypto.ts (full rewrite):
- ensureActiveKey() — boot-time, loads keys + creates v1 if none. Fail-closed:
index.ts process.exit(1) if it throws — the API will not serve if encryption
can't initialize.
- encryptSecret() — encrypts with the active DEK, returns { value, keyId }.
- decryptSecret(value, keyId) — DEK path or legacy KEK-direct path.
- rotateKeys() — mints a fresh DEK, re-encrypts EVERY secret under it inside a
single transaction (decrypt-old / encrypt-new per row), retires the old key,
activates the new one. A partial failure is recoverable because every row
carries its own keyId.
- encryptionStatus() — active version, key history, secret + legacy counts.
Admin:
- GET /v1/admin/encryption — status
- POST /v1/admin/encryption/rotate — triggers rotateKeys, audit-logged as
admin.encryption.rotate with { newVersion, reEncrypted }.
- /admin/encryption page — active-key/secret/legacy cards, Rotate button with
confirm, key-history table, plain-English how-it-works. Added to admin nav.
Verified end-to-end:
- boot → encryption_keys v1 active, '[crypto] envelope encryption ready'
- created a server with secret MY_API_KEY → stored ciphertext, keyId = v1
- POST rotate → { newVersion: 2, reEncrypted: 1 }; ciphertext changed, keyId
now v2, v1 retired, v2 active. The decrypt-then-reencrypt round-trip
succeeded (rotation throws otherwise) — the secret is provably recoverable.
- admin UI renders the status + history correctly.
Deferred, named honestly (not built this iteration):
- worker reads secrets from the DB instead of the BullMQ job-data plaintext
copy — would also remove plaintext secrets from Redis. Separate change with
its own risk surface on the iterate/fork flows.
- per-server secret-value rotation UI
- audit_log hash-chaining (tamper-evidence)
- rate limiting on auth endpoints
2026-05-20 22:36:08 +02:00
|
|
|
// null = legacy (encrypted directly with the KEK, pre-envelope). Non-null
|
|
|
|
|
// rows are encrypted with the referenced key's DEK.
|
|
|
|
|
keyId: uuid('key_id').references(() => encryptionKeys.id),
|
2026-05-19 00:21:18 +02:00
|
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const oauthClients = pgTable('oauth_clients', {
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
serverId: uuid('server_id')
|
|
|
|
|
.references(() => mcpServers.id, { onDelete: 'cascade' })
|
|
|
|
|
.notNull(),
|
|
|
|
|
clientId: varchar('client_id', { length: 128 }).notNull().unique(),
|
|
|
|
|
clientSecretHash: text('client_secret_hash'),
|
|
|
|
|
redirectUris: jsonb('redirect_uris').notNull(),
|
|
|
|
|
metadata: jsonb('metadata'),
|
|
|
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const oauthCodes = pgTable('oauth_codes', {
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
clientDbId: uuid('client_db_id')
|
|
|
|
|
.references(() => oauthClients.id, { onDelete: 'cascade' })
|
|
|
|
|
.notNull(),
|
|
|
|
|
code: varchar('code', { length: 128 }).notNull().unique(),
|
|
|
|
|
codeChallenge: varchar('code_challenge', { length: 256 }).notNull(),
|
|
|
|
|
codeChallengeMethod: varchar('code_challenge_method', { length: 16 }).notNull(),
|
|
|
|
|
redirectUri: text('redirect_uri').notNull(),
|
|
|
|
|
scope: text('scope'),
|
|
|
|
|
resource: text('resource'),
|
|
|
|
|
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
|
|
|
|
|
expiresAt: timestamp('expires_at').notNull(),
|
|
|
|
|
consumedAt: timestamp('consumed_at'),
|
|
|
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const oauthTokens = pgTable('oauth_tokens', {
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
clientDbId: uuid('client_db_id')
|
|
|
|
|
.references(() => oauthClients.id, { onDelete: 'cascade' })
|
|
|
|
|
.notNull(),
|
|
|
|
|
accessTokenHash: text('access_token_hash').notNull(),
|
|
|
|
|
refreshTokenHash: text('refresh_token_hash'),
|
|
|
|
|
scope: text('scope'),
|
|
|
|
|
resource: text('resource'),
|
|
|
|
|
expiresAt: timestamp('expires_at').notNull(),
|
|
|
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const toolCallMetrics = pgTable(
|
|
|
|
|
'tool_call_metrics',
|
|
|
|
|
{
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
serverId: uuid('server_id')
|
|
|
|
|
.references(() => mcpServers.id, { onDelete: 'cascade' })
|
|
|
|
|
.notNull(),
|
|
|
|
|
toolName: varchar('tool_name', { length: 128 }).notNull(),
|
|
|
|
|
durationMs: integer('duration_ms'),
|
|
|
|
|
success: boolean('success').notNull(),
|
|
|
|
|
errorCode: varchar('error_code', { length: 64 }),
|
|
|
|
|
timestamp: timestamp('timestamp').defaultNow().notNull(),
|
|
|
|
|
},
|
|
|
|
|
(t) => ({
|
|
|
|
|
serverTimeIdx: index('idx_metrics_server_time').on(t.serverId, t.timestamp),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const auditLog = pgTable('audit_log', {
|
|
|
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
|
|
|
orgId: uuid('org_id').references(() => organizations.id, { onDelete: 'set null' }),
|
|
|
|
|
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
|
|
|
|
|
action: varchar('action', { length: 128 }).notNull(),
|
|
|
|
|
resourceType: varchar('resource_type', { length: 64 }),
|
|
|
|
|
resourceId: varchar('resource_id', { length: 128 }),
|
|
|
|
|
metadata: jsonb('metadata'),
|
|
|
|
|
ipAddress: varchar('ip_address', { length: 64 }),
|
|
|
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export type Organization = typeof organizations.$inferSelect;
|
|
|
|
|
export type User = typeof users.$inferSelect;
|
|
|
|
|
export type Session = typeof sessions.$inferSelect;
|
|
|
|
|
export type McpServer = typeof mcpServers.$inferSelect;
|
|
|
|
|
export type Build = typeof builds.$inferSelect;
|
|
|
|
|
export type BuildLog = typeof buildLogs.$inferSelect;
|
|
|
|
|
export type Secret = typeof secrets.$inferSelect;
|
|
|
|
|
export type OAuthClient = typeof oauthClients.$inferSelect;
|
feat(marketplace): template publish + fork + voting/ranking + admin moderation
What this enables:
- A user builds an MCP server. If others would benefit, they click 'Publish as
template' on their server detail page. The spec + pre-rendered TypeScript
snapshot is preserved.
- Visitors browse /templates, filter by category, sort by trending/top/newest.
Each template card shows fork count + active deployment count as natural
manipulation-resistant popularity signal.
- /templates/[slug] shows the full plan: tool list with input schemas,
required-credential explanations (with 'how to get one' deep links), and a
collapsible code preview so users can audit before forking.
- Fork is one click → /servers/new?template=slug. The wizard skips Step 1 and
pre-fills Step 2 with the template's parsed spec. Forker only fills in their
own credentials. mcp_servers.template_id is recorded; template.fork_count is
bumped atomically. Each fork gets its own isolated container with its own
port, its own AES-256 secrets — the template author has zero visibility into
the fork's traffic or data.
- Admin /admin/templates moderation: verify quality templates (shows shield
badge in marketplace), hide low-effort ones, takedown anything malicious.
Takedowns cascade-pause every fork container — owners must re-deploy.
Why template+fork instead of shared-container:
- Shared containers would mean the publisher's quota + their secrets + their
logs are exposed to forkers. Bad ergonomics, bad security, bad ownership.
- Templates/forks decouple the spec (shared, vouched-for) from the runtime
(isolated per user). Network-effect moat without the trust collapse.
Why no 5-star voting in v1:
- Manipulation-anfällig, empty lists without adoption. We use fork count +
active deploys + verified badge. Trending algorithm:
score = (activeDeploys * 3 + forks) / sqrt(ageDays + 1)
Real signal, no brigading attack surface.
Backend:
- New schema: templates table (16 cols incl. tools_schema, generated_code,
required_secrets, allowedDomains, status enum, verified, fork_count).
- mcp_servers.template_id FK + idx for fork lookup.
- @bmm/types: SpecEdit unchanged, CreateServerInput accepts optional templateId.
- preview-cache.ts: new cachePrebuiltCode/loadPrebuiltCode for storing the
template's full rendered server.ts alongside the spec. Generator worker
detects this and skips the render step — uses the audited pre-built code
verbatim. Banned-pattern re-scan at publish time.
- routes/templates.ts: 5 public/auth routes + 2 admin routes. Banned-pattern
re-scan before publish. Slug auto-uniqued. forkCount atomic-increment via
SQL.
UI:
- /templates marketplace with trending/top/newest tabs, category filter, search.
Cards show forks + live count + author + verified badge.
- /templates/[slug] full detail with tools, credentials-with-hints, expandable
code preview, fork CTA, ownership + stats sidebar, 'forking is safe' explainer.
- /servers/new?template=slug — wizard auto-jumps to Step 2 with template spec
pre-filled, fork banner at top with link back to template.
- /servers/[id] new Publish tab with title, category, descriptions, per-secret
hint fields (description + howToGetUrl per UPPER_SNAKE_CASE key).
- /admin/templates moderation with verify/hide/takedown actions.
- Marketing nav now includes /templates.
Verified end-to-end:
- Published Echo Demo Template from marco@test.local's live server
- Marketplace lists it correctly with stats
- Detail page renders with all sections
- Fork CTA navigates to wizard with ?template= param
- Wizard skips Step 1, shows fork banner, pre-fills spec
- Build succeeds in ~10s (cached spec + prebuilt code path skips Claude AND
render), container live on :4109 with proper OAuth 401 → token → 200 flow
- DB: templates.fork_count=1, activeDeployments=1, mcp_servers.template_id
populated on the fork
- /admin/templates shows the new template with verify/hide/takedown controls
2026-05-19 23:22:35 +02:00
|
|
|
export type Template = typeof templates.$inferSelect;
|
feat(crypto): envelope encryption + key rotation via admin panel
Closes structural weakness #4 from the audit (single global key, no rotation,
no KMS path). Customer secrets now use envelope encryption with a real
rotation story.
Model:
KEK — Key Encryption Key, 32 bytes from env (SECRETS_ENCRYPTION_KEY). Never
stored in the DB. Root of trust.
DEK — Data Encryption Key, 32 random bytes we generate, stored in the new
encryption_keys table *wrapped* (AES-256-GCM encrypted) with the KEK.
Secrets are encrypted with the DEK.
Schema:
- encryption_keys (version, wrappedDek, active, rotatedBy, createdAt, retiredAt)
- secrets.keyId — which DEK encrypted this row. NULL = legacy (KEK-direct,
pre-envelope); decryptSecret handles both and the first rotation migrates
legacy rows onto a DEK.
crypto.ts (full rewrite):
- ensureActiveKey() — boot-time, loads keys + creates v1 if none. Fail-closed:
index.ts process.exit(1) if it throws — the API will not serve if encryption
can't initialize.
- encryptSecret() — encrypts with the active DEK, returns { value, keyId }.
- decryptSecret(value, keyId) — DEK path or legacy KEK-direct path.
- rotateKeys() — mints a fresh DEK, re-encrypts EVERY secret under it inside a
single transaction (decrypt-old / encrypt-new per row), retires the old key,
activates the new one. A partial failure is recoverable because every row
carries its own keyId.
- encryptionStatus() — active version, key history, secret + legacy counts.
Admin:
- GET /v1/admin/encryption — status
- POST /v1/admin/encryption/rotate — triggers rotateKeys, audit-logged as
admin.encryption.rotate with { newVersion, reEncrypted }.
- /admin/encryption page — active-key/secret/legacy cards, Rotate button with
confirm, key-history table, plain-English how-it-works. Added to admin nav.
Verified end-to-end:
- boot → encryption_keys v1 active, '[crypto] envelope encryption ready'
- created a server with secret MY_API_KEY → stored ciphertext, keyId = v1
- POST rotate → { newVersion: 2, reEncrypted: 1 }; ciphertext changed, keyId
now v2, v1 retired, v2 active. The decrypt-then-reencrypt round-trip
succeeded (rotation throws otherwise) — the secret is provably recoverable.
- admin UI renders the status + history correctly.
Deferred, named honestly (not built this iteration):
- worker reads secrets from the DB instead of the BullMQ job-data plaintext
copy — would also remove plaintext secrets from Redis. Separate change with
its own risk surface on the iterate/fork flows.
- per-server secret-value rotation UI
- audit_log hash-chaining (tamper-evidence)
- rate limiting on auth endpoints
2026-05-20 22:36:08 +02:00
|
|
|
export type EncryptionKey = typeof encryptionKeys.$inferSelect;
|