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

+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 },
});