mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41: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
@@ -228,6 +228,11 @@ func (s *PlatformService) CreateFunction(ctx context.Context, actor *models.Acto
|
||||
if err != nil {
|
||||
return FunctionSummary{}, err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "function.created", "function", row.ID.String(), map[string]any{
|
||||
"name": row.Name, "runtime": row.Runtime,
|
||||
}))
|
||||
}
|
||||
return functionSummary(row), nil
|
||||
}
|
||||
|
||||
@@ -282,6 +287,11 @@ func (s *PlatformService) UpdateFunction(ctx context.Context, actor *models.Acto
|
||||
if err != nil {
|
||||
return FunctionSummary{}, err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.updated", "function", functionID.String(), map[string]any{
|
||||
"name": updated.Name,
|
||||
}))
|
||||
}
|
||||
return functionSummary(updated), nil
|
||||
}
|
||||
|
||||
@@ -293,10 +303,18 @@ func (s *PlatformService) DeleteFunction(ctx context.Context, actor *models.Acto
|
||||
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Queries().DeleteFunction(ctx, db.DeleteFunctionParams{
|
||||
if err := s.repo.Queries().DeleteFunction(ctx, db.DeleteFunctionParams{
|
||||
ID: functionID,
|
||||
ProjectID: row.ProjectID,
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.deleted", "function", functionID.String(), map[string]any{
|
||||
"name": row.Name,
|
||||
}))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvokeFunction executes a function with a JSON payload, records the run,
|
||||
@@ -309,7 +327,16 @@ func (s *PlatformService) InvokeFunction(ctx context.Context, actor *models.Acto
|
||||
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
|
||||
return FunctionRunSummary{}, err
|
||||
}
|
||||
return s.executeFunction(ctx, row, payload, "manual")
|
||||
summary, err := s.executeFunction(ctx, row, payload, "manual")
|
||||
if err != nil {
|
||||
return FunctionRunSummary{}, err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.invoked", "function", functionID.String(), map[string]any{
|
||||
"name": row.Name, "status": summary.Status,
|
||||
}))
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// executeFunction runs a function row and records the outcome — shared by
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
+91
-42
@@ -50,14 +50,62 @@ function fail(error: unknown) {
|
||||
return { content: [{ type: "text" as const, text: `error: ${message}` }], isError: true };
|
||||
}
|
||||
|
||||
function projectId(override?: string): string {
|
||||
const id = override ?? cfg.projectId;
|
||||
async function projectId(override?: string): Promise<string> {
|
||||
// API keys are hard-scoped to one project — a stored `primora use`
|
||||
// selection for a different project can never authorize, so /context
|
||||
// wins. Session actors keep the stored choice (users switch projects).
|
||||
const id =
|
||||
override ??
|
||||
(cfg.auth?.type === "apiKey"
|
||||
? ((await contextProjectId()) ?? cfg.projectId)
|
||||
: (cfg.projectId ?? (await contextProjectId())));
|
||||
if (!id) {
|
||||
throw new Error("no project selected — pass projectId, run `primora use`, or set PRIMORA_PROJECT");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async function orgId(override?: string): Promise<string> {
|
||||
const id =
|
||||
override ??
|
||||
(cfg.auth?.type === "apiKey"
|
||||
? ((await contextOrgId()) ?? cfg.organizationId)
|
||||
: (cfg.organizationId ?? (await contextOrgId())));
|
||||
if (!id) {
|
||||
throw new Error("no organization selected — pass organizationId or run `primora use`");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// API keys are project-scoped — /context resolves the project without /me.
|
||||
async function actorContext(): Promise<ActorContext | undefined> {
|
||||
if (ctxCache) return ctxCache;
|
||||
try {
|
||||
const res = await apiFetch("/context");
|
||||
ctxCache = (await res.json()) as ActorContext;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return ctxCache;
|
||||
}
|
||||
|
||||
async function contextProjectId(): Promise<string | undefined> {
|
||||
return (await actorContext())?.project?.id;
|
||||
}
|
||||
|
||||
async function contextOrgId(): Promise<string | undefined> {
|
||||
return (await actorContext())?.organization?.id;
|
||||
}
|
||||
|
||||
interface ActorContext {
|
||||
actor: string;
|
||||
scopes?: string[];
|
||||
key_prefix?: string;
|
||||
organization?: { id: string; slug: string; name: string; role?: string };
|
||||
project?: { id: string; slug: string; name: string };
|
||||
}
|
||||
let ctxCache: ActorContext | undefined;
|
||||
|
||||
async function bucketIdFor(project: string, ref: string): Promise<string> {
|
||||
const { items } = await StorageService.listBuckets({ projectId: project });
|
||||
const match = items.find((b) => b.id === ref || b.slug === ref || b.name === ref);
|
||||
@@ -92,6 +140,11 @@ server.registerTool(
|
||||
{ description: "Current identity, organizations and projects visible to the credentials" },
|
||||
async () => {
|
||||
try {
|
||||
// API keys can't call /me — /context is the actor-aware self-discovery.
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
const res = await apiFetch("/context");
|
||||
return ok(await res.json());
|
||||
}
|
||||
return ok(await PlatformService.getMe());
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -119,9 +172,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ organizationId }) => {
|
||||
try {
|
||||
const org = organizationId ?? cfg.organizationId;
|
||||
if (!org) throw new Error("no organization selected — pass organizationId or run `primora use`");
|
||||
return ok(await ProjectsService.listProjects({ organizationId: org }));
|
||||
return ok(await ProjectsService.listProjects({ organizationId: await orgId(organizationId) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -141,11 +192,9 @@ server.registerTool(
|
||||
},
|
||||
async ({ name, slug, description, organizationId }) => {
|
||||
try {
|
||||
const org = organizationId ?? cfg.organizationId;
|
||||
if (!org) throw new Error("no organization selected — pass organizationId or run `primora use`");
|
||||
return ok(
|
||||
await ProjectsService.createProject({
|
||||
organizationId: org,
|
||||
organizationId: await orgId(organizationId),
|
||||
requestBody: { name, slug: slug ?? slugify(name), description },
|
||||
}),
|
||||
);
|
||||
@@ -163,7 +212,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await StorageService.listBuckets({ projectId: projectId(p) }));
|
||||
return ok(await StorageService.listBuckets({ projectId: await projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -185,7 +234,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await StorageService.createBucket({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
requestBody: {
|
||||
name,
|
||||
slug: slug ?? slugify(name),
|
||||
@@ -217,7 +266,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ bucket, projectId: p, q, limit, offset }) => {
|
||||
try {
|
||||
const bucketId = await bucketIdFor(projectId(p), bucket);
|
||||
const bucketId = await bucketIdFor(await projectId(p), bucket);
|
||||
return ok(await StorageService.listBucketObjects({ bucketId, q, limit, offset }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -240,7 +289,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ bucket, key, content, contentBase64, contentType, projectId: p }) => {
|
||||
try {
|
||||
const bucketId = await bucketIdFor(projectId(p), bucket);
|
||||
const bucketId = await bucketIdFor(await projectId(p), bucket);
|
||||
let blob: Blob;
|
||||
if (contentBase64 !== undefined) {
|
||||
// Buffer.from(..., "base64") silently drops invalid chars — strict-
|
||||
@@ -281,7 +330,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ bucket, key, projectId: p }) => {
|
||||
try {
|
||||
const bucketId = await bucketIdFor(projectId(p), bucket);
|
||||
const bucketId = await bucketIdFor(await projectId(p), bucket);
|
||||
const res = await apiFetch(`/buckets/${bucketId}/objects/${encodeURIComponent(key)}`);
|
||||
const type = res.headers.get("content-type") ?? "application/octet-stream";
|
||||
if (TEXT_TYPES.test(type)) {
|
||||
@@ -307,7 +356,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ bucket, key, projectId: p }) => {
|
||||
try {
|
||||
const bucketId = await bucketIdFor(projectId(p), bucket);
|
||||
const bucketId = await bucketIdFor(await projectId(p), bucket);
|
||||
await StorageService.deleteBucketObject({ bucketId, objectKey: key });
|
||||
return ok({ deleted: key });
|
||||
} catch (e) {
|
||||
@@ -324,7 +373,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await ProjectsService.listApiKeys({ projectId: projectId(p) }));
|
||||
return ok(await ProjectsService.listApiKeys({ projectId: await projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -339,7 +388,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ name, projectId: p }) => {
|
||||
try {
|
||||
return ok(await ProjectsService.createApiKey({ projectId: projectId(p), requestBody: { name } }));
|
||||
return ok(await ProjectsService.createApiKey({ projectId: await projectId(p), requestBody: { name } }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -354,7 +403,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ apiKeyId, projectId: p }) => {
|
||||
try {
|
||||
await ProjectsService.revokeApiKey({ projectId: projectId(p), apiKeyId });
|
||||
await ProjectsService.revokeApiKey({ projectId: await projectId(p), apiKeyId });
|
||||
return ok({ revoked: apiKeyId });
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -377,7 +426,7 @@ server.registerTool(
|
||||
async ({ projectId: p, q, action, limit, offset }) => {
|
||||
try {
|
||||
return ok(
|
||||
await ProjectsService.listAuditLogs({ projectId: projectId(p), q, action, limit, offset }),
|
||||
await ProjectsService.listAuditLogs({ projectId: await projectId(p), q, action, limit, offset }),
|
||||
);
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -398,7 +447,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p, days }) => {
|
||||
try {
|
||||
return ok(await TelemetryService.listTelemetryIssues({ projectId: projectId(p), days }));
|
||||
return ok(await TelemetryService.listTelemetryIssues({ projectId: await projectId(p), days }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -422,7 +471,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await TelemetryService.listTelemetryEvents({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
type,
|
||||
component,
|
||||
fingerprint,
|
||||
@@ -447,7 +496,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p, window }) => {
|
||||
try {
|
||||
return ok(await TelemetryService.getTelemetryStats({ projectId: projectId(p), window }));
|
||||
return ok(await TelemetryService.getTelemetryStats({ projectId: await projectId(p), window }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -467,7 +516,7 @@ server.registerTool(
|
||||
async ({ projectId: p, name, window }) => {
|
||||
try {
|
||||
return ok(
|
||||
await TelemetryService.getTelemetryMetricSeries({ projectId: projectId(p), name, window }),
|
||||
await TelemetryService.getTelemetryMetricSeries({ projectId: await projectId(p), name, window }),
|
||||
);
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -489,7 +538,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await IntegrationsService.listIntegrations({ projectId: projectId(p) }));
|
||||
return ok(await IntegrationsService.listIntegrations({ projectId: await projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -513,7 +562,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await IntegrationsService.createIntegration({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
requestBody: {
|
||||
name,
|
||||
type: CreateIntegrationRequest.type.RYBBIT,
|
||||
@@ -537,7 +586,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ integrationId, projectId: p }) => {
|
||||
try {
|
||||
await IntegrationsService.deleteIntegration({ projectId: projectId(p), integrationId });
|
||||
await IntegrationsService.deleteIntegration({ projectId: await projectId(p), integrationId });
|
||||
return ok({ deleted: integrationId });
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -553,7 +602,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ integrationId, projectId: p }) => {
|
||||
try {
|
||||
return ok(await IntegrationsService.testIntegration({ projectId: projectId(p), integrationId }));
|
||||
return ok(await IntegrationsService.testIntegration({ projectId: await projectId(p), integrationId }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -576,7 +625,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await IntegrationsService.getIntegrationAnalytics({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
integrationId,
|
||||
site,
|
||||
days,
|
||||
@@ -618,7 +667,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await WebhooksService.listWebhooks({ projectId: projectId(p) }));
|
||||
return ok(await WebhooksService.listWebhooks({ projectId: await projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -642,7 +691,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await WebhooksService.createWebhook({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
requestBody: { url, secret, events, enabled },
|
||||
}),
|
||||
);
|
||||
@@ -670,7 +719,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await WebhooksService.updateWebhook({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
webhookId,
|
||||
requestBody: { url, secret, events, enabled },
|
||||
}),
|
||||
@@ -689,7 +738,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ webhookId, projectId: p }) => {
|
||||
try {
|
||||
await WebhooksService.deleteWebhook({ projectId: projectId(p), webhookId });
|
||||
await WebhooksService.deleteWebhook({ projectId: await projectId(p), webhookId });
|
||||
return ok({ deleted: webhookId });
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -711,7 +760,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await WebhooksService.listWebhookDeliveries({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
webhookId,
|
||||
limit,
|
||||
}),
|
||||
@@ -730,7 +779,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ webhookId, projectId: p }) => {
|
||||
try {
|
||||
return ok(await WebhooksService.testWebhook({ projectId: projectId(p), webhookId }));
|
||||
return ok(await WebhooksService.testWebhook({ projectId: await projectId(p), webhookId }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -755,7 +804,7 @@ server.registerTool(
|
||||
if (!version && !ref) throw new Error("pass version or ref");
|
||||
return ok(
|
||||
await WebhooksService.createDeployMarker({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
requestBody: { version, ref, environment, note },
|
||||
}),
|
||||
);
|
||||
@@ -779,7 +828,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await AutomationService.listScheduledJobs({ projectId: projectId(p) }));
|
||||
return ok(await AutomationService.listScheduledJobs({ projectId: await projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -807,7 +856,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await AutomationService.createScheduledJob({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
requestBody: { name, schedule, url, secret, payload, enabled },
|
||||
}),
|
||||
);
|
||||
@@ -836,7 +885,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await AutomationService.updateScheduledJob({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
jobId,
|
||||
requestBody: { name, schedule, url, secret, payload, enabled },
|
||||
}),
|
||||
@@ -855,7 +904,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ jobId, projectId: p }) => {
|
||||
try {
|
||||
await AutomationService.deleteScheduledJob({ projectId: projectId(p), jobId });
|
||||
await AutomationService.deleteScheduledJob({ projectId: await projectId(p), jobId });
|
||||
return ok({ deleted: jobId });
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -876,7 +925,7 @@ server.registerTool(
|
||||
async ({ jobId, limit, projectId: p }) => {
|
||||
try {
|
||||
return ok(
|
||||
await AutomationService.listScheduledJobRuns({ projectId: projectId(p), jobId, limit }),
|
||||
await AutomationService.listScheduledJobRuns({ projectId: await projectId(p), jobId, limit }),
|
||||
);
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -892,7 +941,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ jobId, projectId: p }) => {
|
||||
try {
|
||||
return ok(await AutomationService.runScheduledJob({ projectId: projectId(p), jobId }));
|
||||
return ok(await AutomationService.runScheduledJob({ projectId: await projectId(p), jobId }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -1051,7 +1100,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await SecretsService.listProjectSecrets({ projectId: projectId(p) }));
|
||||
return ok(await SecretsService.listProjectSecrets({ projectId: await projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -1074,7 +1123,7 @@ server.registerTool(
|
||||
async ({ name, value, url, notes, projectId: p }) => {
|
||||
try {
|
||||
const res = await SecretsService.setProjectSecret({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
name,
|
||||
requestBody: { value, url, notes },
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user