mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
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:
1 parent
1b34dd43bb
commit
55edbf8b56
64 files changed
+2058
-165
No files matched your search
@@ -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 }))}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user