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:
Tomas DvorakandDevin committed 2026-09-24 22:51:10 +02:00
1 parent 182b0c49c2
commit 89f3d84234
17 files changed
+251 -88

No files matched your search

+30 -3
View File
@@ -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
+10 -3
View File
@@ -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
+2 -6
View File
@@ -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) {
+17 -6
View File
@@ -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>
+17
View File
@@ -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;
}
+41 -3
View File
@@ -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" {
+3 -2
View File
@@ -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"
+4 -1
View File
@@ -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"
>
+3 -2
View File
@@ -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
+6 -5
View File
@@ -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"
+2 -1
View File
@@ -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);
+4 -3
View File
@@ -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>
-9
View File
@@ -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
View File
@@ -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 },
});
+1 -1
View File
@@ -6,7 +6,7 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"generate": "openapi --input ../../apps/backend/openapi/openapi.yaml --output ./src/generated --client fetch --useOptions --exportSchemas true"
"generate": "openapi --input ../../apps/backend/openapi/openapi.yaml --output ./src/generated --client fetch --useOptions --exportSchemas true && node ./postgen.mjs"
},
"dependencies": {
"cross-fetch": "^4.1.0"
+19
View File
@@ -0,0 +1,19 @@
// Post-generation patch: openapi-typescript-codegen's request.ts returns
// response.text() for every non-JSON response, but binary endpoints (object
// download) are typed Blob. Return a real Blob so the generated contract holds.
import { readFile, writeFile } from "node:fs/promises";
const file = new URL("./src/generated/core/request.ts", import.meta.url);
const source = await readFile(file, "utf8");
const needle = "return await response.text();";
const replacement = "return await response.blob();";
if (source.includes(replacement)) {
process.exit(0);
}
if (!source.includes(needle)) {
console.error("postgen: response.text() call not found in request.ts — patch manually");
process.exit(1);
}
await writeFile(file, source.replace(needle, replacement));
console.log("postgen: patched getResponseBody to return Blob for non-JSON responses");
@@ -239,7 +239,7 @@ export const getResponseBody = async (response: Response): Promise<any> => {
if (isJSON) {
return await response.json();
} else {
return await response.text();
return await response.blob();
}
}
} catch (error) {