feat: phase 6 production hardening + DB-to-DB transfer

Security:
- Graceful shutdown: http.Server + signal.NotifyContext, 10s drain,
  retention sweeper cancelled before pool close
- Scoped API keys (migration 00006): ingest/read/write/admin enforced in
  auth middleware; scope picker + badges in Settings UI; scopeless legacy
  keys act as admin. Read-only keys now get 403 on ingest (verified live)
- nginx: baseline security headers on all responses, CSP scoped to SPA;
  fixed add_header inheritance shadowing
- better-auth rate limit enabled unconditionally (was gated on NODE_ENV,
  which the shipped .env sets to development)
- Mailpit moved to docker-compose.dev.yml; prod compose cannot expose it
- setup.sh generates PRIMORA_ENCRYPTION_KEY

Reliability/ops:
- Retention: per-project retention_{events,audit,webhook}_days
  (migration 00007, 0=off) + hourly RetentionSweeper; edit UI in Projects
- GET /api/v1/metrics Prometheus exposition
- compose: healthchecks + restart policies everywhere, dragonfly pinned
  to v2.0.0, depends_on service_healthy
- backup.sh/restore.sh (pg_dump -Fc + storage volume); restore verified
  against scratch postgres+volume
- .dockerignore: exclude target dirs (build context was 4.7GB)

Correctness/testing:
- DBX transfer endpoint: POST /db-connections/:id/transfer — SQL or Redis
  target, 1000-row bound, server-side creds (verified live pg→dragonfly)
- Pagination audit: ListComponents capped at 500 (auto-registers)
- CI: goose up/reset/up job + compose e2e-smoke job (smoke-e2e.sh
  verified locally: signup, bootstrap, scoped keys, ingest, metrics)
- k6 load sanity script: 6650 reqs, ~217/s, 0 real failures, p95 221ms
  (429s = configured rate limiter working)

Docs: DEPLOYMENT_GUIDE.md, PRODUCTION_READINESS.md; verify script
repaired (stale refs + set -e arithmetic abort). ROADMAP: phase 2 +
phase 6 marked complete.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 09:15:42 +02:00
1 parent 1b34dd43bb
commit 55edbf8b56
64 files changed
+2058 -165

No files matched your search

+51 -6
View File
@@ -14,6 +14,7 @@ import {
UpdateOrganizationRequest,
UpdateProjectMemberRoleRequest,
UpdateProjectRequest,
CreateApiKeyRequest,
CopyBucketObjectRequest,
type Bucket,
type BucketObject,
@@ -180,7 +181,14 @@ export default function App() {
});
const [projectInput, setProjectInput] = createSignal({ name: "", slug: "", description: "" });
const [projectEditInput, setProjectEditInput] = createSignal({ name: "", slug: "", description: "" });
const [projectEditInput, setProjectEditInput] = createSignal({
name: "",
slug: "",
description: "",
retentionEventsDays: "",
retentionAuditDays: "",
retentionWebhookDays: "",
});
const [organizationInput, setOrganizationInput] = createSignal({ name: "", slug: "" });
const [organizationEditInput, setOrganizationEditInput] = createSignal({ name: "", slug: "" });
const [invitationInput, setInvitationInput] = createSignal({
@@ -190,6 +198,7 @@ export default function App() {
projectRole: CreateInvitationRequest.projectRole.DEVELOPER as string,
});
const [apiKeyName, setApiKeyName] = createSignal("Frontend key");
const [apiKeyScopes, setApiKeyScopes] = createSignal<string[]>(["admin"]);
const [bucketInput, setBucketInput] = createSignal({
name: "",
slug: "",
@@ -258,7 +267,13 @@ export default function App() {
},
);
const availableProjects = createMemo<ProjectSummary[]>(() => {
type ProjectListItem = ProjectSummary & {
retentionEventsDays?: number;
retentionAuditDays?: number;
retentionWebhookDays?: number;
};
const availableProjects = createMemo<ProjectListItem[]>(() => {
const items = organizationProjects();
if (!items) return activeOrganization()?.projects ?? [];
return items.map((project) => ({
@@ -267,10 +282,13 @@ export default function App() {
slug: project.slug,
description: project.description ?? undefined,
membershipRole: project.membership_role ?? undefined,
retentionEventsDays: project.retention_events_days,
retentionAuditDays: project.retention_audit_days,
retentionWebhookDays: project.retention_webhook_days,
}));
});
const activeProject = createMemo<ProjectSummary | undefined>(() =>
const activeProject = createMemo<ProjectListItem | undefined>(() =>
availableProjects().find((item) => item.id === selectedProjectID()),
);
const canUpdateOrganization = createMemo(() => {
@@ -547,8 +565,15 @@ export default function App() {
const project = activeProject();
setProjectEditInput(
project
? { name: project.name, slug: project.slug, description: project.description ?? "" }
: { name: "", slug: "", description: "" },
? {
name: project.name,
slug: project.slug,
description: project.description ?? "",
retentionEventsDays: project.retentionEventsDays?.toString() ?? "",
retentionAuditDays: project.retentionAuditDays?.toString() ?? "",
retentionWebhookDays: project.retentionWebhookDays?.toString() ?? "",
}
: { name: "", slug: "", description: "", retentionEventsDays: "", retentionAuditDays: "", retentionWebhookDays: "" },
);
});
@@ -713,6 +738,12 @@ export default function App() {
setProjectMessage("");
try {
const trimmed = projectEditInput().description.trim();
const parseRetention = (value: string): number | undefined => {
const raw = value.trim();
if (raw === "") return undefined;
const n = Number(raw);
return Number.isFinite(n) ? Math.min(3650, Math.max(0, Math.trunc(n))) : undefined;
};
if (isDemo) {
await demoService.updateProject({ requestBody: { ...projectEditInput(), description: trimmed || null } });
} else {
@@ -722,6 +753,9 @@ export default function App() {
name: projectEditInput().name,
slug: projectEditInput().slug,
description: trimmed.length > 0 ? trimmed : null,
retention_events_days: parseRetention(projectEditInput().retentionEventsDays),
retention_audit_days: parseRetention(projectEditInput().retentionAuditDays),
retention_webhook_days: parseRetention(projectEditInput().retentionWebhookDays),
} as UpdateProjectRequest,
});
}
@@ -1001,7 +1035,14 @@ export default function App() {
try {
const result = isDemo
? await demoService.createApiKey({ requestBody: { name: apiKeyName() } })
: await ProjectsService.createApiKey({ projectId: projectID, requestBody: { name: apiKeyName() } });
: await ProjectsService.createApiKey({
projectId: projectID,
requestBody: {
name: apiKeyName(),
scopes: apiKeyScopes() as CreateApiKeyRequest["scopes"],
},
});
setApiKeyScopes(["admin"]);
await refetchAPIKeys();
await refetchAuditLogs();
await refreshProjectOverviewSnapshot();
@@ -1766,6 +1807,7 @@ export default function App() {
<SettingsPage
apiKeys={apiKeys()}
apiKeyName={apiKeyName()}
apiKeyScopes={apiKeyScopes()}
apiKeySecret={apiKeySecret()}
apiKeyMessage={apiKeyMessage()}
apiKeyPending={apiKeyPending()}
@@ -1776,6 +1818,9 @@ export default function App() {
workspacePending={workspacePending()}
hasActiveOrganization={!!activeOrganization()}
onApiKeyNameChange={setApiKeyName}
onApiKeyScopeToggle={(scope) =>
setApiKeyScopes((c) => (c.includes(scope) ? c.filter((s) => s !== scope) : [...c, scope]))
}
onCreateApiKey={createApiKey}
onDeleteApiKey={revokeApiKey}
onOrganizationInputChange={(field, value) => setOrganizationInput((c) => ({ ...c, [field]: value }))}
+3
View File
@@ -531,6 +531,9 @@ class DemoService {
name: p.name,
description: p.description ?? null,
membership_role: p.membershipRole ?? null,
retention_events_days: 90,
retention_audit_days: 90,
retention_webhook_days: 90,
}));
}
async listAuthUsers() { await this.delay(); return authUsers; }
+40 -2
View File
@@ -11,16 +11,22 @@ interface ProjectInput {
description: string;
}
interface ProjectEditInput extends ProjectInput {
retentionEventsDays: string;
retentionAuditDays: string;
retentionWebhookDays: string;
}
interface ProjectsPageProps {
projects: ProjectSummary[];
selectedProjectID?: string;
projectInput: ProjectInput;
projectEditInput: ProjectInput;
projectEditInput: ProjectEditInput;
projectMessage: string;
projectPending: boolean;
canUpdateProject: boolean;
onProjectInputChange: (field: keyof ProjectInput, value: string) => void;
onProjectEditInputChange: (field: keyof ProjectInput, value: string) => void;
onProjectEditInputChange: (field: keyof ProjectEditInput, value: string) => void;
onCreateProject: (event: SubmitEvent) => void;
onUpdateProject: (event: SubmitEvent) => void;
onDeleteProject: () => void;
@@ -252,6 +258,38 @@ export function ProjectsPage(props: ProjectsPageProps) {
value={props.projectEditInput.description}
onInput={(e) => props.onProjectEditInputChange("description", e.currentTarget.value)}
/>
<div>
<span class="label">Data retention (days)</span>
<p class="label-hint mb-2">
Rows older than this are swept hourly. 0 disables a category. Max 3650.
</p>
<div class="grid gap-3 sm:grid-cols-3">
<Input
label="Telemetry events"
type="number"
min={0}
max={3650}
value={props.projectEditInput.retentionEventsDays}
onInput={(e) => props.onProjectEditInputChange("retentionEventsDays", e.currentTarget.value)}
/>
<Input
label="Audit log"
type="number"
min={0}
max={3650}
value={props.projectEditInput.retentionAuditDays}
onInput={(e) => props.onProjectEditInputChange("retentionAuditDays", e.currentTarget.value)}
/>
<Input
label="Webhook deliveries"
type="number"
min={0}
max={3650}
value={props.projectEditInput.retentionWebhookDays}
onInput={(e) => props.onProjectEditInputChange("retentionWebhookDays", e.currentTarget.value)}
/>
</div>
</div>
<ModalFooter>
<button type="button" class="btn btn-ghost" onClick={() => setSettingsOpen(false)}>
Cancel
+40 -1
View File
@@ -17,9 +17,17 @@ interface OrgInput {
slug: string;
}
const API_KEY_SCOPES: Array<{ value: string; label: string; hint: string }> = [
{ value: "ingest", label: "ingest", hint: "telemetry ingest only" },
{ value: "read", label: "read", hint: "read endpoints" },
{ value: "write", label: "write", hint: "read + ingest + mutations" },
{ value: "admin", label: "admin", hint: "full access" },
];
interface SettingsPageProps {
apiKeys?: ApiKey[];
apiKeyName: string;
apiKeyScopes: string[];
apiKeySecret: string;
apiKeyMessage: string;
apiKeyPending: boolean;
@@ -30,6 +38,7 @@ interface SettingsPageProps {
workspacePending: boolean;
hasActiveOrganization: boolean;
onApiKeyNameChange: (name: string) => void;
onApiKeyScopeToggle: (scope: string) => void;
onCreateApiKey: () => void;
onDeleteApiKey: (id: string) => void;
onOrganizationInputChange: (field: keyof OrgInput, value: string) => void;
@@ -112,12 +121,34 @@ export function SettingsPage(props: SettingsPageProps) {
<button
class="btn btn-primary"
onClick={props.onCreateApiKey}
disabled={props.apiKeyPending || !props.apiKeyName.trim()}
disabled={props.apiKeyPending || !props.apiKeyName.trim() || props.apiKeyScopes.length === 0}
>
<IconPlus class="w-4 h-4" />
Create key
</button>
</div>
<div class="mt-4">
<span class="label">Scopes</span>
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
<For each={API_KEY_SCOPES}>
{(scope) => (
<label class="flex items-center gap-2 text-sm text-text-2">
<input
type="checkbox"
class="accent-[var(--accent)]"
checked={props.apiKeyScopes.includes(scope.value)}
disabled={props.apiKeyScopes.length === 1 && props.apiKeyScopes.includes(scope.value)}
onChange={() => props.onApiKeyScopeToggle(scope.value)}
/>
<span>
<code>{scope.label}</code>
<span class="text-text-3"> — {scope.hint}</span>
</span>
</label>
)}
</For>
</div>
</div>
</div>
<div class="card card-flush">
@@ -141,6 +172,7 @@ export function SettingsPage(props: SettingsPageProps) {
<tr>
<th>Name</th>
<th>Prefix</th>
<th>Scopes</th>
<th>Last used</th>
<th>Status</th>
<th style="width:1%" />
@@ -154,6 +186,13 @@ export function SettingsPage(props: SettingsPageProps) {
<td>
<code>{key.prefix}…</code>
</td>
<td>
<div class="flex flex-wrap gap-1">
<For each={key.scopes && key.scopes.length > 0 ? key.scopes : ["admin"]}>
{(scope) => <Badge variant="neutral">{scope}</Badge>}
</For>
</div>
</td>
<td class="text-text-3 text-xs">
{key.last_used_at ? props.formatDate(key.last_used_at) : "Never"}
</td>