mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
fix: full-stack E2E hardening — client blobs, modal errors, audit coverage, MCP context
- api-client: getResponseBody returns Blob for non-JSON (fixes storage preview/download TypeError); postgen.mjs reapplies the patch on regen - Modal: error prop renders inside the dialog so request failures are visible instead of hidden behind the overlay (6 pages wired) - lib/api: extract server-provided error message from error.body - Modal: unique id per instance (duplicate modal-title broke aria-label) - nginx: index.html no-cache + immutable /assets (stale shell pointed at deleted hashed assets after redeploy → white screen) - vite: drop dead Google Fonts runtime caching (fonts are vendored) - functions: audit function.created/updated/deleted/invoked - AuditPage: expand action filter to all recorded actions - MCP: whoami uses /context for API keys; projectId/orgId resolve via /context with auth-aware precedence (stale `primora use` selection can never authorize a differently-scoped key) 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
182b0c49c2
commit
89f3d84234
17 files changed
+251
-88
No files matched your search
@@ -27,13 +27,20 @@ RUN npm run build
|
||||
# Stage 2: Serve with Nginx
|
||||
FROM nginx:1.27-alpine
|
||||
COPY --from=builder /app/apps/frontend/dist /usr/share/nginx/html
|
||||
# Add simple nginx config for SPA routing
|
||||
# Add simple nginx config for SPA routing. index.html must always revalidate —
|
||||
# it references hashed assets that change every deploy. Hashed /assets files are
|
||||
# immutable and safe to cache long-term.
|
||||
RUN echo 'server { \
|
||||
listen 80; \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
location /assets/ { \
|
||||
try_files $uri =404; \
|
||||
add_header Cache-Control "public, max-age=31536000, immutable"; \
|
||||
} \
|
||||
location / { \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
add_header Cache-Control "no-cache, must-revalidate"; \
|
||||
} \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js";
|
||||
|
||||
import { authClient, fetchApiToken } from "./lib/auth-client";
|
||||
import { configureApiToken } from "./lib/api";
|
||||
import { configureApiToken, errorMessage } from "./lib/api";
|
||||
import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode";
|
||||
import {
|
||||
AppShell,
|
||||
@@ -84,11 +84,7 @@ type ObjectPreview =
|
||||
| { kind: "unsupported"; message: string };
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
const message = (error as { message?: unknown }).message;
|
||||
if (typeof message === "string" && message.length > 0) return message;
|
||||
}
|
||||
return fallback;
|
||||
return errorMessage(error, fallback);
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type JSX, Show, createEffect, onCleanup, splitProps } from "solid-js";
|
||||
import { type JSX, Show, createEffect, createUniqueId, onCleanup, splitProps } from "solid-js";
|
||||
import { Portal } from "solid-js/web";
|
||||
|
||||
interface ModalProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
@@ -6,6 +6,7 @@ interface ModalProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
error?: JSX.Element;
|
||||
size?: "sm" | "md" | "lg" | "xl" | "full";
|
||||
closeOnEscape?: boolean;
|
||||
closeOnBackdrop?: boolean;
|
||||
@@ -26,6 +27,7 @@ export function Modal(props: ModalProps) {
|
||||
"onClose",
|
||||
"title",
|
||||
"description",
|
||||
"error",
|
||||
"size",
|
||||
"closeOnEscape",
|
||||
"closeOnBackdrop",
|
||||
@@ -38,6 +40,8 @@ export function Modal(props: ModalProps) {
|
||||
const closeOnEscape = () => local.closeOnEscape ?? true;
|
||||
const closeOnBackdrop = () => local.closeOnBackdrop ?? true;
|
||||
const showClose = () => local.showClose ?? true;
|
||||
const titleId = createUniqueId();
|
||||
const descriptionId = createUniqueId();
|
||||
|
||||
createEffect(() => {
|
||||
if (local.open) {
|
||||
@@ -71,8 +75,8 @@ export function Modal(props: ModalProps) {
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={local.title ? "modal-title" : undefined}
|
||||
aria-describedby={local.description ? "modal-description" : undefined}
|
||||
aria-labelledby={local.title ? titleId : undefined}
|
||||
aria-describedby={local.description ? descriptionId : undefined}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
@@ -91,12 +95,12 @@ export function Modal(props: ModalProps) {
|
||||
<div class="flex items-start justify-between p-6 border-b border-border">
|
||||
<div class="flex-1">
|
||||
<Show when={local.title}>
|
||||
<h2 id="modal-title" class="text-xl font-semibold text-text-1">
|
||||
<h2 id={titleId} class="text-xl font-semibold text-text-1">
|
||||
{local.title}
|
||||
</h2>
|
||||
</Show>
|
||||
<Show when={local.description}>
|
||||
<p id="modal-description" class="mt-1.5 text-sm text-text-2">
|
||||
<p id={descriptionId} class="mt-1.5 text-sm text-text-2">
|
||||
{local.description}
|
||||
</p>
|
||||
</Show>
|
||||
@@ -116,7 +120,14 @@ export function Modal(props: ModalProps) {
|
||||
</Show>
|
||||
|
||||
{/* Body */}
|
||||
<div class="p-6">{local.children}</div>
|
||||
<div class="p-6">
|
||||
<Show when={local.error}>
|
||||
<div class="message message-error mb-4" role="alert">
|
||||
{local.error}
|
||||
</div>
|
||||
</Show>
|
||||
{local.children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
|
||||
@@ -12,3 +12,20 @@ OpenAPI.TOKEN = async () => (await fetchApiToken()) ?? "";
|
||||
export function configureApiToken(tokenResolver?: () => Promise<string | undefined>) {
|
||||
OpenAPI.TOKEN = tokenResolver ? async () => (await tokenResolver()) ?? "" : async () => (await fetchApiToken()) ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a human-readable message from an API failure.
|
||||
* ApiError.message is only the HTTP status text ("Bad Request") — the backend
|
||||
* sends the actionable reason in body.error.message. Prefer it.
|
||||
*/
|
||||
export function errorMessage(error: unknown, fallback = "Request failed"): string {
|
||||
if (error && typeof error === "object") {
|
||||
const body = (error as { body?: { error?: { message?: unknown } } }).body;
|
||||
if (typeof body?.error?.message === "string" && body.error.message.length > 0) {
|
||||
return body.error.message;
|
||||
}
|
||||
const message = (error as { message?: unknown }).message;
|
||||
if (typeof message === "string" && message.length > 0) return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -21,25 +21,63 @@ const PAGE_SIZE = 25;
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
"",
|
||||
"platform.bootstrap",
|
||||
"organization.created",
|
||||
"organization.updated",
|
||||
"organization.deleted",
|
||||
"organization.member.role_updated",
|
||||
"organization.member.removed",
|
||||
"project.created",
|
||||
"project.updated",
|
||||
"project.deleted",
|
||||
"project.member.role_updated",
|
||||
"project.member.removed",
|
||||
"invitation.created",
|
||||
"invitation.accepted",
|
||||
"invitation.revoked",
|
||||
"bucket.created",
|
||||
"bucket.updated",
|
||||
"bucket.deleted",
|
||||
"object.uploaded",
|
||||
"object.updated",
|
||||
"object.deleted",
|
||||
"object.copied",
|
||||
"object.moved",
|
||||
"api_key.created",
|
||||
"api_key.revoked",
|
||||
"member.invited",
|
||||
"member.role_updated",
|
||||
"member.removed",
|
||||
"collection.created",
|
||||
"collection.updated",
|
||||
"collection.deleted",
|
||||
"document.created",
|
||||
"document.updated",
|
||||
"document.deleted",
|
||||
"db_connection.created",
|
||||
"db_connection.deleted",
|
||||
"db_connection.transferred",
|
||||
"secret.set",
|
||||
"secret.revealed",
|
||||
"secret.deleted",
|
||||
"function.created",
|
||||
"function.updated",
|
||||
"function.deleted",
|
||||
"function.invoked",
|
||||
"job.created",
|
||||
"job.updated",
|
||||
"job.deleted",
|
||||
"job.run",
|
||||
"webhook.created",
|
||||
"webhook.updated",
|
||||
"webhook.deleted",
|
||||
"webhook.tested",
|
||||
"inbound_hook.created",
|
||||
"inbound_hook.deleted",
|
||||
"integration.created",
|
||||
"integration.deleted",
|
||||
"integration.tested",
|
||||
"alert.created",
|
||||
"alert.updated",
|
||||
"alert.deleted",
|
||||
"deploy.marker",
|
||||
];
|
||||
|
||||
function actionVariant(action: string): "success" | "error" | "primary" | "warning" | "neutral" {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { demoService } from "../lib/demo-mode";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { Input, Textarea } from "../components/Input";
|
||||
import { errorMessage } from "../lib/api";
|
||||
import {
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
@@ -37,7 +38,7 @@ interface RealtimeEvent {
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const err = (e: unknown) => errorMessage(e, String(e));
|
||||
const svc = (demo: boolean) =>
|
||||
(demo ? (demoService as unknown as typeof AutomationService) : AutomationService);
|
||||
|
||||
@@ -532,7 +533,7 @@ export function AutomationPage(props: AutomationPageProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Modal open={showCreate()} onClose={() => setShowCreate(false)} title="New schedule" size="md">
|
||||
<Modal error={error()} open={showCreate()} onClose={() => setShowCreate(false)} title="New schedule" size="md">
|
||||
<form onSubmit={createJob} class="space-y-4">
|
||||
<Input
|
||||
label="Name"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { demoService } from "../lib/demo-mode";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal, ModalFooter } from "../components/Modal";
|
||||
import { Input, Select, Textarea } from "../components/Input";
|
||||
import { errorMessage } from "../lib/api";
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconDatabases,
|
||||
@@ -57,7 +58,7 @@ const emptyForm: ConnForm = {
|
||||
const dbTypes = Object.values(CreateDBConnectionRequest.db_type);
|
||||
const sqlish = (t?: string) => t !== "redis";
|
||||
|
||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const err = (e: unknown) => errorMessage(e, String(e));
|
||||
|
||||
/* the demo service mirrors the generated DatabasesService call shapes */
|
||||
const svc = (demo: boolean) =>
|
||||
@@ -933,6 +934,7 @@ export function DatabasesPage(props: DatabasesPageProps) {
|
||||
<Modal
|
||||
open={addOpen()}
|
||||
onClose={() => setAddOpen(false)}
|
||||
error={error()}
|
||||
title="Add connection"
|
||||
description="Credentials are stored server-side and never sent back to the browser."
|
||||
>
|
||||
@@ -1024,6 +1026,7 @@ export function DatabasesPage(props: DatabasesPageProps) {
|
||||
<Modal
|
||||
open={!!confirmDelete()}
|
||||
onClose={() => setConfirmDelete(undefined)}
|
||||
error={error()}
|
||||
title="Delete connection"
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Modal, ModalFooter } from "../components/Modal";
|
||||
import { Input, Select, Textarea } from "../components/Input";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { IconPlus, IconTrash, IconPlay, IconTerminal } from "../components/Icons";
|
||||
import { errorMessage } from "../lib/api";
|
||||
|
||||
interface FunctionsPageProps {
|
||||
projectID?: string;
|
||||
@@ -17,7 +18,7 @@ interface FunctionsPageProps {
|
||||
demoMode: boolean;
|
||||
}
|
||||
|
||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const err = (e: unknown) => errorMessage(e, String(e));
|
||||
const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—");
|
||||
|
||||
const STARTER_CODE = `// The payload arrives on stdin and as PRIMORA_PAYLOAD.
|
||||
@@ -409,7 +410,7 @@ export function FunctionsPage(props: FunctionsPageProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Modal open={showCreate()} onClose={() => setShowCreate(false)} title="New function">
|
||||
<Modal error={error()} open={showCreate()} onClose={() => setShowCreate(false)} title="New function">
|
||||
<form onSubmit={create}>
|
||||
<div class="modal-body">
|
||||
<Input
|
||||
|
||||
@@ -22,6 +22,7 @@ import { demoService } from "../lib/demo-mode";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { Input, Select } from "../components/Input";
|
||||
import { errorMessage } from "../lib/api";
|
||||
import {
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
@@ -40,7 +41,7 @@ interface IntegrationsPageProps {
|
||||
|
||||
type Tab = "integrations" | "webhooks";
|
||||
|
||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const err = (e: unknown) => errorMessage(e, String(e));
|
||||
const intSvc = (demo: boolean) =>
|
||||
(demo ? (demoService as unknown as typeof IntegrationsService) : IntegrationsService);
|
||||
const hookSvc = (demo: boolean) =>
|
||||
@@ -798,7 +799,7 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Modal open={showCreateIntegration()} onClose={() => setShowCreateIntegration(false)} title="Add connector" size="md">
|
||||
<Modal error={error()} open={showCreateIntegration()} onClose={() => setShowCreateIntegration(false)} title="Add connector" size="md">
|
||||
<form onSubmit={createIntegration} class="space-y-4">
|
||||
<Select label="Type" value="rybbit" disabled options={[{ value: "rybbit", label: "Rybbit — web analytics" }]} />
|
||||
<Input
|
||||
@@ -839,7 +840,7 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={showCreateWebhook()} onClose={() => setShowCreateWebhook(false)} title="Add webhook" size="md">
|
||||
<Modal error={error()} open={showCreateWebhook()} onClose={() => setShowCreateWebhook(false)} title="Add webhook" size="md">
|
||||
<form onSubmit={createWebhook} class="space-y-4">
|
||||
<Input
|
||||
label="Endpoint URL"
|
||||
@@ -883,7 +884,7 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={showCreateAlert()} onClose={() => setShowCreateAlert(false)} title="Add alert rule" size="md">
|
||||
<Modal error={error()} open={showCreateAlert()} onClose={() => setShowCreateAlert(false)} title="Add alert rule" size="md">
|
||||
<form onSubmit={createAlertRule} class="space-y-4">
|
||||
<Input
|
||||
label="Name"
|
||||
@@ -935,7 +936,7 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={showCreateInbound()} onClose={() => setShowCreateInbound(false)} title="Add inbound hook" size="md">
|
||||
<Modal error={error()} open={showCreateInbound()} onClose={() => setShowCreateInbound(false)} title="Add inbound hook" size="md">
|
||||
<form onSubmit={createInboundHook} class="space-y-4">
|
||||
<Input
|
||||
label="Name"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { demoService } from "../lib/demo-mode";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { Select } from "../components/Input";
|
||||
import { errorMessage } from "../lib/api";
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconRefresh,
|
||||
@@ -25,7 +26,7 @@ interface TelemetryPageProps {
|
||||
|
||||
type Tab = "health" | "issues" | "events" | "metrics" | "integrate";
|
||||
|
||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const err = (e: unknown) => errorMessage(e, String(e));
|
||||
const svc = (demo: boolean) =>
|
||||
(demo ? (demoService as unknown as typeof TelemetryService) : TelemetryService);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { Modal } from "../components/Modal";
|
||||
import { Input, Select, Textarea } from "../components/Input";
|
||||
import { IconPlus, IconTrash, IconCopy, IconRefresh, IconKey } from "../components/Icons";
|
||||
import { errorMessage } from "../lib/api";
|
||||
|
||||
interface VaultPageProps {
|
||||
projectID?: string;
|
||||
@@ -13,7 +14,7 @@ interface VaultPageProps {
|
||||
demoMode: boolean;
|
||||
}
|
||||
|
||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const err = (e: unknown) => errorMessage(e, String(e));
|
||||
const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—");
|
||||
|
||||
const DEMO_SECRETS: ProjectSecret[] = [
|
||||
@@ -376,7 +377,7 @@ export function VaultPage(props: VaultPageProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Modal open={showCreate()} onClose={() => setShowCreate(false)} title="New secret" size="md">
|
||||
<Modal error={error()} open={showCreate()} onClose={() => setShowCreate(false)} title="New secret" size="md">
|
||||
<form onSubmit={createSecret} class="space-y-4">
|
||||
<Input label="Name" placeholder="STRIPE_SECRET" value={form().name} onInput={(e) => setForm({ ...form(), name: e.currentTarget.value })} required />
|
||||
<Input label="Value" type="password" placeholder="sk_live_…" value={form().value} onInput={(e) => setForm({ ...form(), value: e.currentTarget.value })} required />
|
||||
@@ -389,7 +390,7 @@ export function VaultPage(props: VaultPageProps) {
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={showImport()} onClose={() => setShowImport(false)} title="Import .env" size="md">
|
||||
<Modal error={error()} open={showImport()} onClose={() => setShowImport(false)} title="Import .env" size="md">
|
||||
<form onSubmit={importEnv} class="space-y-4">
|
||||
<Textarea label="Paste .env contents" rows={8} placeholder={"KEY=value\nOTHER=…"} value={importText()} onInput={(e) => setImportText(e.currentTarget.value)} class="font-mono text-sm" />
|
||||
<p class="text-xs text-text-2">Existing names are overwritten. Keys that aren't env-var-safe are skipped.</p>
|
||||
|
||||
@@ -38,15 +38,6 @@ export default defineConfig({
|
||||
urlPattern: /\/(api|auth|mailpit)(\/|$)/,
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
{
|
||||
// Geist font files + CSS from Google Fonts — long-lived static assets.
|
||||
urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com\//,
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "primora-fonts",
|
||||
expiration: { maxEntries: 20, maxAgeSeconds: 60 * 60 * 24 * 365 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user