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
+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