diff --git a/doc/connections/FIRST-30-MATRIX.md b/doc/connections/FIRST-30-MATRIX.md index dd5ffbff707..1374e4be0ed 100644 --- a/doc/connections/FIRST-30-MATRIX.md +++ b/doc/connections/FIRST-30-MATRIX.md @@ -89,7 +89,7 @@ PagerDuty. | 12 | PagerDuty | E | Direct MCP with REST wrapper for events | OAuth | Account, service, escalation policy, incident urgency filters | Read incidents/on-call, ack/resolve with approval | Incident webhooks | S3 | | 13 | Cloudflare | E | Direct MCP with vendor-deep wrapper for Workers/DNS | OAuth or scoped API token | Account, zone, Worker/project filters | Read zones/deployments/logs, draft DNS/Worker change | Audit/deployment sync optional | S4 | | 14 | Vercel | A | Direct MCP or thin REST wrapper | Vercel OAuth | Team, project, environment, deployment filters | Read projects/deployments/log metadata, redeploy/cancel gated | Deployment webhooks | S3 | -| 15 | PostHog | C | Direct MCP/API-key provider | Project/personal API key secret ref | Project, environment, dashboard/feature flag filters | Query events/insights/flags, create annotation | Optional insight/flag sync | S2 | +| 15 | PostHog | C | Hosted MCP OAuth or personal API key | OAuth token or personal API key secret ref | Project, read-only, feature-group, and tool filters | Query insights/errors/flags/experiments; governed writes | Optional insight/flag sync | S3 | | 16 | Datadog | C | OpenAPI-to-MCP shim first, deep wrapper later | API key + app key secret refs | Site, org, service, monitor, dashboard filters | Read metrics/logs/monitors, mute/unmute gated | Monitor/webhook events | S3 | | 17 | Figma | C | Vendor-deep wrapper; MCP for Dev Mode reads | Figma OAuth | Team, project, file, branch filters | Read files/comments/dev data, create comment | File/comment webhooks optional | S3 | | 18 | Canva | C | Direct app/MCP where available; Connect API wrapper | Canva OAuth | Team, folder, brand/template filters | Search/read designs, create design from template | Asset sync optional | S2 | diff --git a/doc/connections/POSTHOG.md b/doc/connections/POSTHOG.md new file mode 100644 index 00000000000..247950c42ae --- /dev/null +++ b/doc/connections/POSTHOG.md @@ -0,0 +1,81 @@ +# PostHog connection + +Paperclip connects to PostHog's hosted MCP service at +`https://mcp.posthog.com/mcp`. The connection supports two explicit methods: + +- browser OAuth, which is recommended for hosted PostHog accounts; or +- a PostHog personal API key stored as a Paperclip secret and sent as an + `Authorization: Bearer ...` header. + +Paperclip does not silently fall back from OAuth to an API key. The selected +method is saved on the connection and reused for reconnects. + +## Service involvement + +PostHog hosts both the MCP resource and OAuth authorization service. Paperclip +discovers the OAuth endpoints, dynamically registers the client when needed, +stores returned credentials as secret references, and handles the callback at +`/api/tools/oauth/callback`. No Paperclip-operated vendor relay is involved. + +```mermaid +sequenceDiagram + actor A as Administrator + participant P as Paperclip + participant M as mcp.posthog.com + participant O as oauth.posthog.com + + A->>P: Choose PostHog sign-in and project filters + P->>M: Discover protected-resource metadata + M-->>P: Authorization server metadata URL + P->>O: Discover endpoints and register OAuth client + O-->>P: Client registration + P-->>A: Open browser authorization + A->>O: Approve access + O-->>P: Redirect to /api/tools/oauth/callback + P->>O: Exchange authorization code + O-->>P: Access and refresh tokens + P->>M: tools/list with project and safety filters + M-->>P: Filtered PostHog tool catalog +``` + +The current hosted endpoints are: + +| Purpose | Endpoint | +| --- | --- | +| MCP resource | `https://mcp.posthog.com/mcp` | +| Protected-resource metadata | `https://mcp.posthog.com/.well-known/oauth-protected-resource/mcp` | +| Authorization-server metadata | `https://oauth.posthog.com/.well-known/oauth-authorization-server` | +| Authorize | `https://oauth.posthog.com/oauth/authorize/` | +| Token | `https://oauth.posthog.com/oauth/token/` | +| Dynamic client registration | `https://oauth.posthog.com/oauth/register/` | +| Revoke | `https://oauth.posthog.com/oauth/revoke/` | +| Paperclip callback | `/api/tools/oauth/callback` | + +Redirect-URI constraints and token lifetimes remain provider-controlled and +must be rechecked during credentialed QA; Paperclip does not encode guessed +values for either. + +## Administrator setup + +1. In **Apps → Browse**, choose **PostHog**. +2. Explicitly choose **Sign in with PostHog** or **Use a personal API key**. +3. Enter the numeric PostHog project ID. +4. Keep **Read-only mode** enabled unless agents have an approved need to make + changes. +5. Limit the catalog with one or both of **Feature groups** and **Individual + tools**. Paperclip currently fixes response mode to individual tools; CLI + mode is unavailable until nested execution is governed. +6. For OAuth, continue through browser consent. For API-key setup, create a + personal API key using PostHog's **MCP Server** preset and paste it into + Paperclip. Never put the key in connection configuration or a URL. +7. Review discovered actions. Known writes ask first, destructive or nested + execution tools remain quarantined, and unknown PostHog tools default to + write risk until reviewed. + +The safe configuration is projected to PostHog as `project_id`, `readonly`, +`features`, `tools`, and `mode` query parameters. PostHog documents these +options in its [MCP overview](https://posthog.com/docs/model-context-protocol) +and [MCP FAQ](https://posthog.com/docs/model-context-protocol/faq). + +PostHog does not charge for MCP requests themselves, but the actions they +perform can consume normal PostHog usage or AI credits. diff --git a/doc/connections/README.md b/doc/connections/README.md index ae1c76f0513..17559bbfda6 100644 --- a/doc/connections/README.md +++ b/doc/connections/README.md @@ -2,6 +2,8 @@ Audience: internal engineers and product contributors working on integrations. +Provider notes: [PostHog](./POSTHOG.md). + Post-read action: classify a new integration request, pick the right Paperclip layer to change, and avoid creating a parallel connection framework. diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index a306bef695c..dc59cd149b6 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -2,13 +2,14 @@ import a0 from "./app-definitions/zapier.json" with { type: "json" }; import a1 from "./app-definitions/github.json" with { type: "json" }; import a2 from "./app-definitions/slack.json" with { type: "json" }; import a3 from "./app-definitions/notion.json" with { type: "json" }; -import a4 from "./app-definitions/linear.json" with { type: "json" }; -import a5 from "./app-definitions/google-sheets.json" with { type: "json" }; -import a6 from "./app-definitions/context7.json" with { type: "json" }; -import a7 from "./app-definitions/oauth-generic.json" with { type: "json" }; -import a8 from "./app-definitions/api-key-generic.json" with { type: "json" }; -import a9 from "./app-definitions/sentry.json" with { type: "json" }; -import a10 from "./app-definitions/vercel.json" with { type: "json" }; -import a11 from "./app-definitions/anthropic.json" with { type: "json" }; +import a4 from "./app-definitions/posthog.json" with { type: "json" }; +import a5 from "./app-definitions/linear.json" with { type: "json" }; +import a6 from "./app-definitions/google-sheets.json" with { type: "json" }; +import a7 from "./app-definitions/context7.json" with { type: "json" }; +import a8 from "./app-definitions/oauth-generic.json" with { type: "json" }; +import a9 from "./app-definitions/api-key-generic.json" with { type: "json" }; +import a10 from "./app-definitions/sentry.json" with { type: "json" }; +import a11 from "./app-definitions/vercel.json" with { type: "json" }; +import a12 from "./app-definitions/anthropic.json" with { type: "json" }; import type { AppDefinition } from "./types/app-definition.js"; -export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11] as AppDefinition[]; +export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 489f5e0595c..253a420c312 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -3,12 +3,13 @@ import { APP_DEFINITIONS } from "./app-definitions.generated.js"; import { appDefinitionsSchema } from "./validators/app-definition.js"; describe("AppDefinition catalog",()=>{ it("validates all Wave 1 definitions",()=>expect(()=>appDefinitionsSchema.parse(APP_DEFINITIONS)).not.toThrow()); - it("contains twelve reviewed providers",()=>expect(APP_DEFINITIONS.map((app)=>app.slug)).toEqual(["zapier","github","slack","notion","linear","google-sheets","context7","oauth-generic","api-key-generic","sentry","vercel","anthropic"])); + it("contains thirteen reviewed providers",()=>expect(APP_DEFINITIONS.map((app)=>app.slug)).toEqual(["zapier","github","slack","notion","posthog","linear","google-sheets","context7","oauth-generic","api-key-generic","sentry","vercel","anthropic"])); it("uses discovery-first Notion MCP OAuth metadata",()=>{ const notion=APP_DEFINITIONS.find((app)=>app.slug==="notion"); expect(notion?.redirectConstraints).toBe("https-or-loopback-http"); expect(notion?.methods[0]?.defaults).toEqual({serverUrl:"https://mcp.notion.com/mcp"}); }); it("preserves required Linear OAuth scopes",()=>expect(APP_DEFINITIONS.find((app)=>app.slug==="linear")?.methods[0]?.defaults?.scopesHint).toEqual(["read","write"])); + it("offers PostHog OAuth and API-key methods with pinned safe defaults",()=>{const posthog=APP_DEFINITIONS.find((app)=>app.slug==="posthog");expect(posthog?.methods.map((method)=>method.key)).toEqual(["mcp-oauth","mcp-api-key"]);for(const method of posthog?.methods??[]){expect(method.riskTier).toBe("S3");expect(method.tenantFields?.find((field)=>field.key==="readOnly")?.defaultValue).toBe(true);expect(method.tenantFields?.find((field)=>field.key==="projectId")?.transport?.name).toBe("project_id");expect(method.configRequirements?.atLeastOneOf).toEqual(["features","tools"])}}); it("enforces method and field invariants",()=>{for(const app of APP_DEFINITIONS)for(const method of app.methods){if(method.auth==="api_key")expect(method.keyPlacement).toBeTruthy();if(method.auth==="oauth")expect(method.ownershipModes.length).toBeGreaterThan(0);for(const field of method.credentialFields??[])if(field.required&&field.type!=="checkbox")expect(field.placeholder).toBeTruthy()}}); }); diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index ba38d87e7f7..7ac34d87dc8 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -7,6 +7,7 @@ const CONNECTABLE_APP_SLUGS = new Set([ "github", "slack", "notion", + "posthog", "linear", "google-sheets", "context7", @@ -47,19 +48,27 @@ export function getAppDefinitionForUrl( ) ?? null; } -export function getAvailableConnectionMethod(app: AppDefinition): ConnectionMethodDef | null { +export function getAvailableConnectionMethods(app: AppDefinition): ConnectionMethodDef[] { const availability = app.ownershipAvailability ?? DEFAULT_OWNERSHIP_AVAILABILITY; - return app.methods.find((method) => + return app.methods.filter((method) => method.ownershipModes.some((ownership) => availability[ownership] !== false) - ) ?? null; + ); +} + +export function getAvailableConnectionMethod( + app: AppDefinition, + methodKey?: string | null, +): ConnectionMethodDef | null { + const methods = getAvailableConnectionMethods(app); + return methodKey ? methods.find((method) => method.key === methodKey) ?? null : methods[0] ?? null; } export function credentialConfigPath(field: FieldDef): string { return `credentials.${field.key}`; } -export function recommendedDefaultsForApp(app: AppDefinition): Record { - const method = getAvailableConnectionMethod(app); +export function recommendedDefaultsForApp(app: AppDefinition, methodKey?: string | null): Record { + const method = getAvailableConnectionMethod(app, methodKey); return { access: "all_agents", askFirstRiskLevels: method?.riskTier === "S1" ? [] : ["write", "destructive"], diff --git a/packages/shared/src/app-definitions/posthog.json b/packages/shared/src/app-definitions/posthog.json new file mode 100644 index 00000000000..fab7206edf0 --- /dev/null +++ b/packages/shared/src/app-definitions/posthog.json @@ -0,0 +1,259 @@ +{ + "schemaVersion": 1, + "slug": "posthog", + "name": "PostHog", + "description": "Analyze product usage, errors, feature flags, and experiments in a pinned PostHog project.", + "categories": [ + "analytics" + ], + "featured": true, + "branding": { + "logoUrl": "https://www.google.com/s2/favicons?domain=posthog.com&sz=128" + }, + "urlPatterns": [ + "https://mcp.posthog.com/*" + ], + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer", + "dcr" + ], + "whenToUse": "Sign in with PostHog in the browser. Recommended for hosted PostHog accounts.", + "defaults": { + "serverUrl": "https://mcp.posthog.com/mcp" + }, + "guidanceMd": "Pin the connection to one PostHog project, keep read-only mode enabled by default, and expose only the feature groups or tools agents need.", + "riskTier": "S3", + "tenantFields": [ + { + "key": "projectId", + "label": "Project ID", + "type": "text", + "required": true, + "placeholder": "12345", + "helperMd": "Find the numeric project ID in PostHog project settings.", + "validation": { + "pattern": "^[0-9]+$", + "maxLength": 32 + }, + "transport": { + "location": "query", + "name": "project_id" + } + }, + { + "key": "readOnly", + "label": "Read-only mode", + "type": "checkbox", + "defaultValue": true, + "helperMd": "Keep enabled unless agents explicitly need to change PostHog data.", + "transport": { + "location": "query", + "name": "readonly", + "format": "boolean", + "omitFalse": true + } + }, + { + "key": "features", + "label": "Feature groups", + "type": "textarea", + "placeholder": "insights, error_tracking, flags, experiments", + "defaultValue": "insights,error_tracking,flags,experiments", + "helperMd": "Comma-separated PostHog feature groups to expose.", + "validation": { + "maxLength": 500 + }, + "transport": { + "location": "query", + "name": "features", + "format": "csv" + } + }, + { + "key": "tools", + "label": "Individual tools", + "type": "textarea", + "placeholder": "Optional comma-separated tool names", + "helperMd": "Optional exact tool names. These are combined with feature groups.", + "validation": { + "maxLength": 2000 + }, + "transport": { + "location": "query", + "name": "tools", + "format": "csv" + } + }, + { + "key": "mode", + "label": "Tool response mode", + "type": "select", + "required": true, + "placeholder": "Individual tools", + "defaultValue": "tools", + "options": [ + { + "value": "tools", + "label": "Individual tools" + } + ], + "helperMd": "CLI mode remains unavailable until nested execution is governed.", + "transport": { + "location": "query", + "name": "mode" + } + } + ], + "configRequirements": { + "atLeastOneOf": [ + "features", + "tools" + ] + }, + "requiredResourceFilters": [ + "project", + "read_only", + "features", + "tools", + "mode" + ], + "label": "Sign in with PostHog", + "consoleLinks": { + "docs": "https://posthog.com/docs/model-context-protocol" + } + }, + { + "key": "mcp-api-key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a PostHog personal API key when browser sign-in is not suitable.", + "defaults": { + "serverUrl": "https://mcp.posthog.com/mcp" + }, + "guidanceMd": "Pin the connection to one PostHog project, keep read-only mode enabled by default, and expose only the feature groups or tools agents need.", + "riskTier": "S3", + "tenantFields": [ + { + "key": "projectId", + "label": "Project ID", + "type": "text", + "required": true, + "placeholder": "12345", + "helperMd": "Find the numeric project ID in PostHog project settings.", + "validation": { + "pattern": "^[0-9]+$", + "maxLength": 32 + }, + "transport": { + "location": "query", + "name": "project_id" + } + }, + { + "key": "readOnly", + "label": "Read-only mode", + "type": "checkbox", + "defaultValue": true, + "helperMd": "Keep enabled unless agents explicitly need to change PostHog data.", + "transport": { + "location": "query", + "name": "readonly", + "format": "boolean", + "omitFalse": true + } + }, + { + "key": "features", + "label": "Feature groups", + "type": "textarea", + "placeholder": "insights, error_tracking, flags, experiments", + "defaultValue": "insights,error_tracking,flags,experiments", + "helperMd": "Comma-separated PostHog feature groups to expose.", + "validation": { + "maxLength": 500 + }, + "transport": { + "location": "query", + "name": "features", + "format": "csv" + } + }, + { + "key": "tools", + "label": "Individual tools", + "type": "textarea", + "placeholder": "Optional comma-separated tool names", + "helperMd": "Optional exact tool names. These are combined with feature groups.", + "validation": { + "maxLength": 2000 + }, + "transport": { + "location": "query", + "name": "tools", + "format": "csv" + } + }, + { + "key": "mode", + "label": "Tool response mode", + "type": "select", + "required": true, + "placeholder": "Individual tools", + "defaultValue": "tools", + "options": [ + { + "value": "tools", + "label": "Individual tools" + } + ], + "helperMd": "CLI mode remains unavailable until nested execution is governed.", + "transport": { + "location": "query", + "name": "mode" + } + } + ], + "configRequirements": { + "atLeastOneOf": [ + "features", + "tools" + ] + }, + "requiredResourceFilters": [ + "project", + "read_only", + "features", + "tools", + "mode" + ], + "label": "Use a personal API key", + "credentialFields": [ + { + "key": "authorization", + "label": "PostHog personal API key", + "type": "password", + "required": true, + "placeholder": "phx_...", + "secret": true + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "keys": "https://posthog.com/docs/model-context-protocol/faq", + "docs": "https://posthog.com/docs/model-context-protocol/faq" + } + } + ] +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 03c413d7f50..26d9231bc6c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -239,6 +239,7 @@ export { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod, + getAvailableConnectionMethods, getConnectableAppDefinition, recommendedDefaultsForApp, } from "./app-definitions.js"; diff --git a/packages/shared/src/types/app-definition.ts b/packages/shared/src/types/app-definition.ts index 69a7c8606b7..134458cb49c 100644 --- a/packages/shared/src/types/app-definition.ts +++ b/packages/shared/src/types/app-definition.ts @@ -1,6 +1,6 @@ import type { ToolConnectionOwnership, ToolConnectionTransport } from "./tool-access.js"; export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|"data"|"developer"|"productivity"|"other"; export type OAuthRedirectConstraints = "https-or-loopback-http"; -export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}> } -export interface ConnectionMethodDef { key:string; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[]}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] } +export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} } +export interface ConnectionMethodDef { key:string; label?:string; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[]}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] } export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial> } diff --git a/packages/shared/src/validators/app-definition.ts b/packages/shared/src/validators/app-definition.ts index 11ca80f0f0e..79b2ee2f59d 100644 --- a/packages/shared/src/validators/app-definition.ts +++ b/packages/shared/src/validators/app-definition.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { toolConnectionOwnershipSchema, toolConnectionTransportSchema } from "./tool-access.js"; -const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]})}); -export const connectionMethodDefSchema=z.object({key:z.string().min(1),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if(v.auth==="api_key"&&!v.keyPlacement)c.addIssue({code:"custom",message:"API-key methods require keyPlacement",path:["keyPlacement"]})}); +const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]})}); +export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if(v.auth==="api_key"&&!v.keyPlacement)c.addIssue({code:"custom",message:"API-key methods require keyPlacement",path:["keyPlacement"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]})}); export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:z.string().url(),darkLogoUrl:z.string().url().optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}); export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})}); diff --git a/packages/shared/src/validators/tool-access.test.ts b/packages/shared/src/validators/tool-access.test.ts index 3ed4ed968aa..d2d05b0332a 100644 --- a/packages/shared/src/validators/tool-access.test.ts +++ b/packages/shared/src/validators/tool-access.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { connectionTokenRequestSchema, + connectToolAppSchema, createToolConnectionSchema, startConnectionAuthorizationSchema, toolCredentialSecretRefSchema, @@ -48,6 +49,18 @@ describe("tool access validators", () => { } }); + it("keeps app method configuration separate from secrets", () => { + expect(connectToolAppSchema.safeParse({ + galleryKey: "posthog", + connectionMethodKey: "mcp-api-key", + configValues: { projectId: "12345", readOnly: true, features: "insights" }, + }).success).toBe(true); + expect(connectToolAppSchema.safeParse({ + galleryKey: "posthog", + configValues: { projectId: "12345", apiKey: "phx_raw" }, + }).success).toBe(false); + }); + it("accepts secret references for connection credentials", () => { const parsed = createToolConnectionSchema.safeParse({ applicationId: "11111111-1111-4111-8111-111111111111", diff --git a/packages/shared/src/validators/tool-access.ts b/packages/shared/src/validators/tool-access.ts index 01071fff7c9..bc2f945af52 100644 --- a/packages/shared/src/validators/tool-access.ts +++ b/packages/shared/src/validators/tool-access.ts @@ -260,11 +260,14 @@ export type DisableToolStdioCommandTemplate = z.infer { + if (value.configValues) rejectSensitiveConfigKeys(value.configValues, ctx, ["configValues"]); }).refine( (value) => Boolean(value.galleryKey) !== Boolean(value.link), { message: "Provide exactly one of galleryKey or link" }, diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 05fc773ff06..4f8b91d478b 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -3,11 +3,20 @@ const root=process.cwd(); const corpus=process.env.PAPERCLIP_CONTENT_TEMPLATES?? const out=path.join(root,"packages/shared/src/app-definitions"); const favicon=d=>`https://www.google.com/s2/favicons?domain=${d}&sz=128`; const field=(key,label,placeholder)=>({key,label,type:"password",required:true,placeholder,secret:true}); const method=(key,transport,auth,defaults,riskTier,guidanceMd,extra={})=>({key,transport,auth,ownershipModes:auth==="oauth"?["customer","dcr"]:["customer"],whenToUse:transport==="mcp_remote"?"Use the provider-hosted connection for the quickest setup.":"Use credentials from your provider account.",defaults,guidanceMd,riskTier,...extra}); +const posthogConfigFields=()=>[ + {key:"projectId",label:"Project ID",type:"text",required:true,placeholder:"12345",helperMd:"Find the numeric project ID in PostHog project settings.",validation:{pattern:"^[0-9]+$",maxLength:32},transport:{location:"query",name:"project_id"}}, + {key:"readOnly",label:"Read-only mode",type:"checkbox",defaultValue:true,helperMd:"Keep enabled unless agents explicitly need to change PostHog data.",transport:{location:"query",name:"readonly",format:"boolean",omitFalse:true}}, + {key:"features",label:"Feature groups",type:"textarea",placeholder:"insights, error_tracking, flags, experiments",defaultValue:"insights,error_tracking,flags,experiments",helperMd:"Comma-separated PostHog feature groups to expose.",validation:{maxLength:500},transport:{location:"query",name:"features",format:"csv"}}, + {key:"tools",label:"Individual tools",type:"textarea",placeholder:"Optional comma-separated tool names",helperMd:"Optional exact tool names. These are combined with feature groups.",validation:{maxLength:2000},transport:{location:"query",name:"tools",format:"csv"}}, + {key:"mode",label:"Tool response mode",type:"select",required:true,placeholder:"Individual tools",defaultValue:"tools",options:[{value:"tools",label:"Individual tools"}],helperMd:"CLI mode remains unavailable until nested execution is governed.",transport:{location:"query",name:"mode"}}, +]; +const posthogMethod=(key,auth,extra={})=>method(key,"mcp_remote",auth,{serverUrl:"https://mcp.posthog.com/mcp"},"S3","Pin the connection to one PostHog project, keep read-only mode enabled by default, and expose only the feature groups or tools agents need.",{tenantFields:posthogConfigFields(),configRequirements:{atLeastOneOf:["features","tools"]},requiredResourceFilters:["project","read_only","features","tools","mode"],...extra}); const apps=[ ["zapier","Zapier","Reach thousands of apps through your Zapier account.","productivity","zapier.com",["https://mcp.zapier.com/*"],method("mcp-key","mcp_remote","api_key",{serverUrl:"https://mcp.zapier.com/api/mcp"},"S3","Create a Zapier MCP connection, then paste its token here.",{credentialFields:[field("authorization","Zapier MCP token","Paste your Zapier token")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "}})], ["github","GitHub","Read code and pull requests, and coordinate repository work.","developer","github.com",["https://api.githubcopilot.com/mcp/*"],method("mcp-key","mcp_remote","api_key",{serverUrl:"https://api.githubcopilot.com/mcp/"},"S3","Create a fine-grained token limited to the repositories agents should use.",{credentialFields:[field("authorization","GitHub token","github_pat_...")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "},requiredResourceFilters:["organization","repository"]})], ["slack","Slack","Search channels and coordinate team communication.","communication","slack.com",["https://mcp.slack.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.slack.com/mcp",authorizationEndpoint:"https://slack.com/oauth/v2/authorize",tokenEndpoint:"https://slack.com/api/oauth.v2.access",scopesHint:["channels:read","chat:write","search:read"]},"S3","Connect a Slack workspace and limit access to the channels agents need.",{requiredResourceFilters:["workspace","channel"]})], ["notion","Notion","Read and update pages in your Notion workspace.","content","notion.so",["https://mcp.notion.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.notion.com/mcp"},"S3","Connect Notion for workspace content. Share only the pages and databases agents should use.",{requiredResourceFilters:["workspace","page","database"]}),{redirectConstraints:"https-or-loopback-http"}], +["posthog","PostHog","Analyze product usage, errors, feature flags, and experiments in a pinned PostHog project.","analytics","posthog.com",["https://mcp.posthog.com/*"],[posthogMethod("mcp-oauth","oauth",{label:"Sign in with PostHog",ownershipModes:["customer","dcr"],whenToUse:"Sign in with PostHog in the browser. Recommended for hosted PostHog accounts.",consoleLinks:{docs:"https://posthog.com/docs/model-context-protocol"}}),posthogMethod("mcp-api-key","api_key",{label:"Use a personal API key",whenToUse:"Use a PostHog personal API key when browser sign-in is not suitable.",credentialFields:[field("authorization","PostHog personal API key","phx_...")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "},consoleLinks:{keys:"https://posthog.com/docs/model-context-protocol/faq",docs:"https://posthog.com/docs/model-context-protocol/faq"}})],{featured:true}], ["linear","Linear","Create, update, and read Linear issues.","productivity","linear.app",["https://mcp.linear.app/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.linear.app/mcp",authorizationEndpoint:"https://linear.app/oauth/authorize",tokenEndpoint:"https://api.linear.app/oauth/token",scopesHint:["read","write"]},"S2","Register a Linear OAuth app and add Paperclip's redirect URI before connecting.",{requiredResourceFilters:["workspace","team","project"]})], ["google-sheets","Google Sheets","Read and update selected spreadsheets.","data","sheets.google.com",["https://docs.google.com/spreadsheets/*","https://sheets.google.com/*"],method("local","local_stdio","none",{templateKey:"paperclip.google-sheets"},"S3","Share each spreadsheet with the Paperclip robot email, then paste the sheet links.",{requiredResourceFilters:["spreadsheet"]})], ["context7","Context7","Look up current documentation for software libraries.","developer","context7.com",["https://mcp.context7.com/*"],method("mcp","mcp_remote","none",{serverUrl:"https://mcp.context7.com/mcp"},"S1","Connect Context7 to give agents current library documentation.")], @@ -16,7 +25,7 @@ const apps=[ ["sentry","Sentry","Investigate errors, releases, and production issues.","developer","sentry.io",["https://mcp.sentry.dev/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.sentry.dev/mcp",discoveryUrl:"https://sentry.io/.well-known/oauth-authorization-server"},"S2","Connect the Sentry organization and projects agents need for incident work.",{requiredResourceFilters:["organization","project","environment"]})], ["vercel","Vercel","Inspect projects, deployments, and runtime logs.","developer","vercel.com",["https://mcp.vercel.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.vercel.com/mcp"},"S3","Connect the Vercel team and projects agents should operate.",{requiredResourceFilters:["team","project","environment"]})], ["anthropic","Anthropic","Use Anthropic APIs with a restricted key.","ai","anthropic.com",["https://api.anthropic.com/*"],method("api-key","rest_api","api_key",{serviceHost:"api.anthropic.com"},"S3","Create a key in the Anthropic Console and rotate it if it has been exposed.",{credentialFields:[field("apiKey","API key","sk-ant-api03-...")],keyPlacement:{location:"header",name:"x-api-key"}})], -].map(([slug,name,description,category,domain,urlPatterns,m,extra={}])=>({schemaVersion:1,slug,name,description,categories:[category],featured:["zapier","github","slack","notion","linear"].includes(slug),branding:{logoUrl:favicon(domain)},urlPatterns,methods:[m],...extra})); +].map(([slug,name,description,category,domain,urlPatterns,m,extra={}])=>({schemaVersion:1,slug,name,description,categories:[category],featured:["zapier","github","slack","notion","posthog","linear"].includes(slug),branding:{logoUrl:favicon(domain)},urlPatterns,methods:Array.isArray(m)?m:[m],...extra})); const parseTableRow=(line)=>line.slice(1,-1).split("|").map((cell)=>cell.trim()); const parseCapture=(fileName)=>{ const markdown=fs.readFileSync(path.join(corpus,fileName),"utf8"); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 291cec311a7..3f82e5f863e 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -37,11 +37,12 @@ import { toolStdioCommandTemplates, } from "@paperclipai/db"; import { and, eq } from "drizzle-orm"; +import { getConnectableAppDefinition } from "@paperclipai/shared"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { classifyRisk, toolAccessService } from "../services/tool-access.js"; +import { classifyRisk, normalizeConnectionMethodConfig, toolAccessService } from "../services/tool-access.js"; import { toolAccessPolicyService } from "../services/tool-access-policy.js"; import { secretService } from "../services/secrets.js"; import { canonicalToolArguments, signToolArguments } from "../services/tool-content-guards.js"; @@ -2661,6 +2662,62 @@ describeEmbeddedPostgres("tool access service", () => { ]); }); + it("requires an explicit PostHog method and projects validated project filters", async () => { + const company = await createCompany(db); + const service = toolAccessService(db); + + await expect(service.connectGalleryApp(company.id, { + galleryKey: "posthog", + configValues: { projectId: "12345", features: "insights" }, + }, { actorType: "user", actorId: "board" })).rejects.toMatchObject({ status: 400 }); + + const fetchMock = mockToolsList([ + { name: "query_insight", annotations: { readOnlyHint: true } }, + { name: "delete_feature_flag" }, + { name: "brand_new_tool" }, + ]); + const result = await service.connectGalleryApp(company.id, { + galleryKey: "posthog", + connectionMethodKey: "mcp-api-key", + credentialValues: { "credentials.authorization": "phx_test-secret" }, + configValues: { + projectId: "12345", + readOnly: true, + features: "insights, error_tracking\ninsights", + tools: "query_insight", + mode: "tools", + }, + }, { actorType: "user", actorId: "board" }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://mcp.posthog.com/mcp?project_id=12345&readonly=true&features=insights%2Cerror_tracking&tools=query_insight&mode=tools", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer phx_test-secret" }), + }), + ); + expect(result.connection).toMatchObject({ + authKind: "api_key", + config: { + sourceTemplateKey: "posthog", + connectionMethodKey: "mcp-api-key", + methodConfig: { + projectId: "12345", + readOnly: true, + features: "insights,error_tracking", + tools: "query_insight", + mode: "tools", + }, + safeDefault: true, + }, + }); + expect(JSON.stringify(result.connection.config)).not.toContain("phx_test-secret"); + expect(result.catalog).toEqual(expect.arrayContaining([ + expect.objectContaining({ toolName: "query_insight", riskLevel: "read", status: "active" }), + expect.objectContaining({ toolName: "delete_feature_flag", riskLevel: "destructive", status: "quarantined" }), + expect.objectContaining({ toolName: "brand_new_tool", riskLevel: "write", status: "quarantined" }), + ])); + }); + it("stores approved class-3 credential refs on thin tool connections", async () => { const company = await createCompany(db); const service = toolAccessService(db); @@ -7092,4 +7149,43 @@ describe("classifyRisk", () => { expect(notionRisk("notion-delete-page")).toBe("destructive"); expect(classifyRisk({ name: "move_pages" })).toBe("read"); }); + + it("uses conservative PostHog defaults for unknown and nested-execution tools", () => { + expect(classifyRisk({ name: "query_insight", annotations: { readOnlyHint: true } }, "posthog")).toBe("read"); + expect(classifyRisk({ name: "brand_new_tool" }, "posthog")).toBe("write"); + expect(classifyRisk({ name: "exec" }, "posthog")).toBe("destructive"); + }); +}); + +describe("normalizeConnectionMethodConfig", () => { + const posthog = getConnectableAppDefinition("posthog")!; + const apiKeyMethod = posthog.methods.find((method) => method.key === "mcp-api-key")!; + + it("normalizes and projects PostHog scope without accepting arbitrary config", () => { + expect(normalizeConnectionMethodConfig(apiKeyMethod, { + projectId: "12345", + readOnly: true, + features: "insights, error_tracking\ninsights", + tools: "query_insight", + mode: "tools", + })).toEqual({ + values: { + projectId: "12345", + readOnly: true, + features: "insights,error_tracking", + tools: "query_insight", + mode: "tools", + }, + url: "https://mcp.posthog.com/mcp?project_id=12345&readonly=true&features=insights%2Cerror_tracking&tools=query_insight&mode=tools", + }); + expect(() => normalizeConnectionMethodConfig(apiKeyMethod, { + projectId: "not-a-project", + features: "insights", + })).toThrow("Project ID has an invalid value"); + expect(() => normalizeConnectionMethodConfig(apiKeyMethod, { + projectId: "12345", + features: "insights", + apiKey: "must-not-be-config", + })).toThrow("Unknown connection setting: apiKey"); + }); }); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 90e0bab1449..d121ca7d619 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -36,6 +36,7 @@ import { } from "@paperclipai/db"; import type { AppDefinition, + ConnectionMethodDef, ConnectionTokenIssuanceOutcome, ConnectionTokenIssuancePath, ConnectionTokenRequest, @@ -107,7 +108,7 @@ import type { UpdateToolProfileWithEntries, UnbindToolProfileBinding, } from "@paperclipai/shared"; -import { CLASS3_STATIC_LEASE_ALLOWLIST, credentialConfigPath, getAvailableConnectionMethod, getConnectableAppDefinition, isToolConnectionAttentionHealth, recommendedDefaultsForApp } from "@paperclipai/shared"; +import { CLASS3_STATIC_LEASE_ALLOWLIST, credentialConfigPath, getAvailableConnectionMethod, getAvailableConnectionMethods, getConnectableAppDefinition, isToolConnectionAttentionHealth, recommendedDefaultsForApp } from "@paperclipai/shared"; import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; import { logActivity } from "./activity-log.js"; import { mcpHttpRequestHeaders, parseMcpHttpResponseBody } from "./mcp-http.js"; @@ -452,14 +453,24 @@ export function googleSheetsRobotEmailFromEnv( return { available: false, reason: "Google Sheets is not available on this instance yet." }; } -function connectionMethodFor(app: AppDefinition) { - const method = getAvailableConnectionMethod(app); +function connectionMethodFor(app: AppDefinition, methodKey?: string | null) { + const method = getAvailableConnectionMethod(app, methodKey); if (!method) throw unprocessable("This app does not have an available connection method"); return method; } -function credentialFieldsFor(app: AppDefinition) { - const method = connectionMethodFor(app); +function connectionMethodForConnection( + app: AppDefinition, + connection: typeof toolConnections.$inferSelect, +) { + const methodKey = typeof connection.config.connectionMethodKey === "string" + ? connection.config.connectionMethodKey + : null; + return connectionMethodFor(app, methodKey); +} + +function credentialFieldsFor(app: AppDefinition, methodKey?: string | null) { + const method = connectionMethodFor(app, methodKey); return (method.credentialFields ?? []).map((field) => ({ label: field.label, configPath: credentialConfigPath(field), @@ -471,6 +482,75 @@ function credentialFieldsFor(app: AppDefinition) { })); } +export function normalizeConnectionMethodConfig( + method: ConnectionMethodDef, + configValues: Record | undefined, +): { values: Record; url?: string; headers?: Record } { + const fields = [...(method.tenantFields ?? []), ...(method.extensionFields ?? [])]; + const allowedKeys = new Set(fields.map((field) => field.key)); + for (const key of Object.keys(configValues ?? {})) { + if (!allowedKeys.has(key)) throw badRequest(`Unknown connection setting: ${key}`); + } + + const values: Record = {}; + for (const field of fields) { + const raw = configValues?.[field.key] ?? field.defaultValue; + if (field.type === "checkbox") { + if (raw !== undefined && typeof raw !== "boolean") throw badRequest(`${field.label} must be true or false`); + if (raw !== undefined) values[field.key] = raw; + continue; + } + if (raw !== undefined && typeof raw !== "string") throw badRequest(`${field.label} must be text`); + let value = raw?.trim() ?? ""; + if (field.transport?.format === "csv") { + value = Array.from(new Set(value.split(/[\n,]/g).map((entry) => entry.trim()).filter(Boolean))).join(","); + } + if (field.required && !value) throw badRequest(`Missing connection setting: ${field.label}`); + if (!value) continue; + if (field.validation?.maxLength && value.length > field.validation.maxLength) { + throw badRequest(`${field.label} must be at most ${field.validation.maxLength} characters`); + } + if (field.validation?.pattern && !new RegExp(field.validation.pattern).test(value)) { + throw badRequest(`${field.label} has an invalid value`); + } + if (field.type === "select" && !field.options?.some((option) => option.value === value)) { + throw badRequest(`${field.label} has an invalid option`); + } + values[field.key] = value; + } + for (const keys of method.configRequirements?.atLeastOneOf ? [method.configRequirements.atLeastOneOf] : []) { + if (!keys.some((key) => typeof values[key] === "string" && values[key].length > 0)) { + throw badRequest(`Provide at least one of: ${keys.join(", ")}`); + } + } + + const endpoint = method.defaults?.serverUrl ? new URL(method.defaults.serverUrl) : null; + const headers: Record = {}; + for (const field of fields) { + const transport = field.transport; + const value = values[field.key]; + if (!transport || value === undefined || (value === false && transport.omitFalse)) continue; + const serialized = typeof value === "boolean" ? String(value) : value; + if (transport.location === "query") endpoint?.searchParams.set(transport.name, serialized); + else headers[transport.name] = serialized; + } + return { + values, + ...(endpoint ? { url: endpoint.toString() } : {}), + ...(Object.keys(headers).length > 0 ? { headers } : {}), + }; +} + +function projectedConnectionHeaders(connection: typeof toolConnections.$inferSelect): Record { + const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" + ? connection.config.sourceTemplateKey + : null; + const app = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null; + if (!app) return {}; + const method = connectionMethodForConnection(app, connection); + return normalizeConnectionMethodConfig(method, asRecord(connection.config.methodConfig)).headers ?? {}; +} + function googleSheetsAllowedSpreadsheetIds(configValues: Record | undefined): string[] { const raw = configValues?.allowedSpreadsheetIds; const values = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\n,]/g) : []; @@ -1273,6 +1353,7 @@ export function classifyRisk(tool: McpToolDescriptor, sourceTemplateKey?: string const annotations = tool.annotations ?? {}; if (annotations.destructiveHint === true || annotations.destructive === true) return "destructive"; const normalizedToolName = normalizedProviderToolName(tool.name); + if (sourceTemplateKey === "posthog" && normalizedToolName === "exec") return "destructive"; // Notion's hosted MCP catalog contains mutations whose names do not use one // of the generic create/update/delete verbs (move, duplicate, and convert). // Keep all reviewed tools explicit so provider changes are visible in code, @@ -1282,6 +1363,9 @@ export function classifyRisk(tool: McpToolDescriptor, sourceTemplateKey?: string if (sourceTemplateKey === "notion" && NOTION_READ_TOOLS.has(normalizedToolName)) return "read"; if (verbMatches(tool.name, "delete|remove|destroy|unpublish")) return "destructive"; if (verbMatches(tool.name, "create|update|write|set|send|publish|post|mutate|mark|archive")) return "write"; + // PostHog exposes a broad and evolving catalog. Unknown tools must never be + // silently treated as reads; provider annotations can opt known reads in. + if (sourceTemplateKey === "posthog") return annotations.readOnlyHint === true ? "read" : "write"; return "read"; } @@ -2863,7 +2947,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } async function remoteTools(connection: typeof toolConnections.$inferSelect): Promise { - const headers = await resolveCredentialHeaders(connection); + const headers = { ...projectedConnectionHeaders(connection), ...await resolveCredentialHeaders(connection) }; const endpoint = await assertRemoteEndpointAllowed(connection.config); const response = await fetch(endpoint, { method: "POST", @@ -3038,11 +3122,12 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} const existingByName = new Map(existingRows.map((entry) => [entry.toolName, entry])); const updatedEntries: ToolCatalogEntry[] = []; let quarantinedCount = 0; - const quarantineOnRefresh = shouldQuarantineNewEntries(connection) && connection.status === "active"; - const safeDefault = asRecord(connection.config).safeDefault === true; const sourceTemplateKey = typeof asRecord(connection.config).sourceTemplateKey === "string" ? String(asRecord(connection.config).sourceTemplateKey) : null; + const quarantineOnRefresh = shouldQuarantineNewEntries(connection) + && (connection.status === "active" || sourceTemplateKey === "posthog"); + const safeDefault = asRecord(connection.config).safeDefault === true; for (const descriptor of descriptors) { const riskLevel = classifyRisk(descriptor, sourceTemplateKey); const hash = descriptorHash(descriptor, riskLevel); @@ -4043,8 +4128,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return null; } - async function oauthProviderEndpoints(app: AppDefinition): Promise { - const method = connectionMethodFor(app); + async function oauthProviderEndpoints(app: AppDefinition, methodKey?: string | null): Promise { + const method = connectionMethodFor(app, methodKey); if (method.auth !== "oauth") throw unprocessable("This app does not support sign in"); let authorizationUrl = method.defaults?.authorizationEndpoint ?? null; let tokenUrl = method.defaults?.tokenEndpoint ?? null; @@ -4070,7 +4155,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} const smokeLabEndpoints = smokeLabOAuthEndpoints(connection, redirectUri); const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null; const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null; - const galleryMethod = galleryEntry ? connectionMethodFor(galleryEntry) : null; + const galleryMethod = galleryEntry ? connectionMethodForConnection(galleryEntry, connection) : null; const hasCompleteGalleryEndpointHints = Boolean( galleryMethod?.defaults?.authorizationEndpoint && galleryMethod.defaults.tokenEndpoint, ); @@ -4079,8 +4164,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} : null; const endpoints = smokeLabEndpoints ?? discovered - ?? (galleryEntry && connectionMethodFor(galleryEntry).auth === "oauth" - ? await oauthProviderEndpoints(galleryEntry) + ?? (galleryEntry && galleryMethod?.auth === "oauth" + ? await oauthProviderEndpoints(galleryEntry, galleryMethod.key) : await discoverOAuthEndpoints(connection, challenge)); if (!endpoints) throw unprocessable("This app connection does not advertise OAuth sign in"); assertNotSmokeLabOAuthEndpoints(connection, endpoints); @@ -4091,7 +4176,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null; if (!sourceTemplateKey) throw unprocessable("This app connection was not created from the app gallery"); const galleryEntry = getConnectableAppDefinition(sourceTemplateKey); - if (!galleryEntry || connectionMethodFor(galleryEntry).auth !== "oauth") { + if (!galleryEntry || connectionMethodForConnection(galleryEntry, connection).auth !== "oauth") { throw unprocessable("This app connection does not use sign in"); } return galleryEntry; @@ -4362,7 +4447,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} client: await oauthClientForConnection(input.connection, input.endpoints.provider, input.actor), }; } - const method = input.galleryEntry ? connectionMethodFor(input.galleryEntry) : null; + const method = input.galleryEntry ? connectionMethodForConnection(input.galleryEntry, input.connection) : null; if (!method?.ownershipModes.includes("dcr")) { throw unprocessable(`OAuth client id is not configured for ${input.endpoints.provider}`); } @@ -4839,13 +4924,27 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } const name = input.name ?? existingApplication?.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? ""); - const method = galleryEntry ? connectionMethodFor(galleryEntry) : null; + if (!galleryEntry && input.connectionMethodKey) throw badRequest("Connection method selection requires a gallery app"); + if (galleryEntry && getAvailableConnectionMethods(galleryEntry).length > 1 && !input.connectionMethodKey) { + throw badRequest("Choose a connection method for this app"); + } + const method = galleryEntry ? connectionMethodFor(galleryEntry, input.connectionMethodKey) : null; const transport = method?.transport ?? "mcp_remote"; + const normalizedMethodConfig = galleryEntry?.slug === GOOGLE_SHEETS_GALLERY_KEY || !method + ? null + : normalizeConnectionMethodConfig(method, input.configValues); const baseConfig = transport === "mcp_remote" - ? { url: method?.defaults?.serverUrl ?? input.link ?? "" } + ? { url: normalizedMethodConfig?.url ?? method?.defaults?.serverUrl ?? input.link ?? "" } : { templateId: method?.defaults?.templateKey }; let config: Record = galleryEntry - ? { ...baseConfig, sourceTemplateKey: galleryEntry.slug, quarantineNewEntries: true } + ? { + ...baseConfig, + sourceTemplateKey: galleryEntry.slug, + connectionMethodKey: method?.key, + methodConfig: normalizedMethodConfig?.values ?? {}, + quarantineNewEntries: true, + ...(galleryEntry.slug === "posthog" ? { safeDefault: true } : {}), + } : { ...baseConfig, quarantineNewEntries: true }; if (galleryEntry?.slug === GOOGLE_SHEETS_GALLERY_KEY) { const availability = googleSheetsRobotEmailFromEnv(); @@ -4877,7 +4976,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} let revivedConnectionPrevious: typeof toolConnections.$inferSelect | null = null; try { - const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry) : linkCredentialFields(credentialValues); + const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry, method?.key) : linkCredentialFields(credentialValues); for (const field of credentialFields) { const value = credentialValues[field.configPath]; if (!value && field.required !== false) { @@ -4952,6 +5051,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} if (revivedConnectionPrevious) { [connectionRow] = await db.update(toolConnections).set({ name, + authKind: method?.auth ?? "none", transport, status: "draft", enabled: false, @@ -4970,7 +5070,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} name, uid: connectionUid(applicationRow.applicationKey ?? applicationRow.name, name, connectionId), connectionKind: "managed", - authKind: galleryEntry ? connectionMethodFor(galleryEntry).auth : "none", + authKind: method?.auth ?? "none", transport, status: "draft", enabled: false, @@ -4985,14 +5085,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} await syncCredentialBindings(connectionRow); await ensureRuntimeSlot(connectionRow); - if (galleryEntry && connectionMethodFor(galleryEntry).auth === "oauth") { + if (galleryEntry && method?.auth === "oauth") { return { connectionId: connectionRow.id, application: toApplication(applicationRow), connection: toConnection(connectionRow), catalog: [], actions: { readOnly: [], canMakeChanges: [] }, - suggestedDefaults: recommendedDefaultsForApp(galleryEntry), + suggestedDefaults: recommendedDefaultsForApp(galleryEntry, method.key), auth: { kind: "oauth", startUrl: null }, }; } @@ -5027,7 +5127,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} connection: refresh.connection, catalog: refresh.catalog, actions: groupedActions(refresh.catalog), - suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : { + suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry, method?.key) : { access: "all_agents", askFirstRiskLevels: ["write", "destructive"], }, @@ -5401,7 +5501,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null; const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null; - const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry) : [ + const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry, connectionMethodForConnection(galleryEntry, connection).key) : [ { label: "App key", configPath: "credentials.authorization", @@ -5736,7 +5836,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} connection: toConnection(connection), catalog, actions: groupedActions(catalog), - suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : { access: "all_agents", askFirstRiskLevels: ["write", "destructive"] }, + suggestedDefaults: galleryEntry + ? recommendedDefaultsForApp(galleryEntry, connectionMethodForConnection(galleryEntry, connection).key) + : { access: "all_agents", askFirstRiskLevels: ["write", "destructive"] }, auth: null, }; } @@ -5801,7 +5903,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} connection: refresh.connection, catalog: refresh.catalog, actions: groupedActions(refresh.catalog), - suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : { + suggestedDefaults: galleryEntry ? recommendedDefaultsForApp( + galleryEntry, + connectionMethodForConnection(galleryEntry, connection).key, + ) : { access: "all_agents", askFirstRiskLevels: ["write", "destructive"], }, diff --git a/ui/src/lib/app-gallery-copy.ts b/ui/src/lib/app-gallery-copy.ts index 8807445ef25..37a5f7e7571 100644 --- a/ui/src/lib/app-gallery-copy.ts +++ b/ui/src/lib/app-gallery-copy.ts @@ -66,6 +66,10 @@ const APP_COPY: Record = { tagline: "Read and update pages in your workspace.", short: "Read and update pages in your workspace.", }, + posthog: { + tagline: "Explore product usage, errors, flags, and experiments.", + short: "Choose one project and the analytics tools your agents can use.", + }, linear: { tagline: "Create, update and read tickets.", short: "Create, update and read tickets.", diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index b05e2c25bf6..e8781750b59 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -23,6 +23,7 @@ const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined } const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier")!; const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion")!; +const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog")!; const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!; vi.mock("@/api/tools", () => ({ @@ -211,6 +212,50 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(container.textContent).not.toContain("Pick the app you want your agents to use."); }); + it("requires a PostHog method and submits the selected project scope", async () => { + mockParams.appKey = "posthog"; + listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG] }); + await render(); + + expect(container.textContent).toContain("How do you want to connect?"); + expect(buttonByText("Sign in with PostHog")?.getAttribute("aria-pressed")).toBe("false"); + expect(buttonByText("Use a personal API key")?.getAttribute("aria-pressed")).toBe("false"); + expect(buttonByText("Connect")?.disabled).toBe(true); + + await act(async () => { + buttonByText("Use a personal API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const projectInput = container.querySelector('input[placeholder="12345"]'); + const keyInput = container.querySelector('input[type="password"]'); + expect(projectInput).toBeTruthy(); + expect(keyInput).toBeTruthy(); + await act(async () => { + setInputValue(projectInput!, "12345"); + setInputValue(keyInput!, "phx_test-key"); + }); + await flushReact(); + await act(async () => { + buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(connectAppMock).toHaveBeenCalledWith("company-1", { + galleryKey: "posthog", + connectionMethodKey: "mcp-api-key", + name: "PostHog", + credentialValues: { "credentials.authorization": "phx_test-key" }, + configValues: { + projectId: "12345", + readOnly: true, + features: "insights,error_tracking,flags,experiments", + mode: "tools", + }, + applicationId: undefined, + }); + }); + it("auto-starts the allowlisted Notion source deep link and opens provider sign-in", async () => { mockSearch.value = "source=notion"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); diff --git a/ui/src/pages/apps/AppsConnect.tsx b/ui/src/pages/apps/AppsConnect.tsx index 0f2791cd53d..7a116d64f3c 100644 --- a/ui/src/pages/apps/AppsConnect.tsx +++ b/ui/src/pages/apps/AppsConnect.tsx @@ -16,12 +16,14 @@ import type { LucideIcon } from "lucide-react"; import type { Agent, AppDefinition, + ConnectionMethodDef, ConnectToolAppResult, + FieldDef, ToolApplication, ToolConnection, ToolAppConnectionActionSummary, } from "@paperclipai/shared"; -import { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod } from "@paperclipai/shared"; +import { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod, getAvailableConnectionMethods } from "@paperclipai/shared"; import { useNavigate, useParams, useSearchParams } from "@/lib/router"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; @@ -93,6 +95,15 @@ function isGoogleSheetsEntry(entry: AppDefinition | null): boolean { return entry?.slug === "google-sheets"; } +function defaultMethodConfig(method: ConnectionMethodDef | null): Record { + if (!method) return {}; + return Object.fromEntries( + [...(method.tenantFields ?? []), ...(method.extensionFields ?? [])] + .filter((field) => field.defaultValue !== undefined) + .map((field) => [field.key, field.defaultValue!]), + ); +} + function appSourceSlug(application: ToolApplication): string | null { const metadata = application.metadata; if (!metadata) return null; @@ -165,6 +176,8 @@ export function AppsConnect() { const [linkNeedsKey, setLinkNeedsKey] = useState(false); const [linkKey, setLinkKey] = useState(""); const [credentials, setCredentials] = useState>({}); + const [connectionMethodKey, setConnectionMethodKey] = useState(""); + const [configValues, setConfigValues] = useState>({}); const [googleSheetsLinks, setGoogleSheetsLinks] = useState(""); const [googleSheetsError, setGoogleSheetsError] = useState(null); const [connectResult, setConnectResult] = useState(null); @@ -186,6 +199,8 @@ export function AppsConnect() { setLinkNeedsKey(false); setLinkKey(""); setCredentials({}); + setConnectionMethodKey(""); + setConfigValues({}); setGoogleSheetsLinks(""); setGoogleSheetsError(null); setConnectResult(null); @@ -274,9 +289,14 @@ export function AppsConnect() { const trimmedGalleryName = galleryName.trim(); return toolsApi.connectApp(selectedCompanyId!, { galleryKey: connectEntry.slug, + ...(connectionMethodKey ? { connectionMethodKey } : {}), name: trimmedGalleryName || connectEntry.name, credentialValues: credentials, - configValues: isGoogleSheetsEntry(connectEntry) ? { allowedSpreadsheetIds: sheetIds } : undefined, + configValues: isGoogleSheetsEntry(connectEntry) + ? { allowedSpreadsheetIds: sheetIds } + : Object.keys(configValues).length > 0 + ? configValues + : undefined, applicationId: prefill.applicationId, }); } @@ -345,8 +365,9 @@ export function AppsConnect() { const requestedEntry = galleryQuery.data.apps.find((candidate) => candidate.slug === requestedAppKey); const method = requestedEntry ? getAvailableConnectionMethod(requestedEntry) : null; + const methods = requestedEntry ? getAvailableConnectionMethods(requestedEntry) : []; const directOAuth = method?.auth === "oauth" && isMcpDirectOAuthConnectSlug(requestedEntry?.slug); - const unsupportedOAuth = method?.auth === "oauth" && !directOAuth; + const unsupportedOAuth = methods.length === 1 && method?.auth === "oauth" && !directOAuth; if (!requestedEntry || unsupportedOAuth || requestedEntry.availability?.available === false) { setEntry(null); setStep("gallery"); @@ -362,6 +383,10 @@ export function AppsConnect() { setLinkNeedsKey(false); setLinkKey(""); setCredentials({}); + const methods = getAvailableConnectionMethods(requestedEntry); + const initialMethod = methods.length === 1 ? methods[0]! : null; + setConnectionMethodKey(initialMethod?.key ?? ""); + setConfigValues(defaultMethodConfig(initialMethod)); setGoogleSheetsLinks(""); setGoogleSheetsError(null); setConnectResult(null); @@ -507,6 +532,8 @@ export function AppsConnect() { : null; const stepLabels = zapierSource ? ZAPIER_STEP_LABELS + : entry && getAvailableConnectionMethods(entry).length > 1 + ? ["Pick app", "Choose connection", "Choose actions", "Choose access", "Install tools"] : isGoogleSheetsEntry(entry) ? ["Pick app", "Share sheet", "Choose actions", "Choose access", "Install tools"] : STEP_LABELS; @@ -558,6 +585,10 @@ export function AppsConnect() { setLinkNeedsKey(false); setLinkKey(""); setCredentials({}); + const methods = getAvailableConnectionMethods(picked); + const initialMethod = methods.length === 1 ? methods[0]! : null; + setConnectionMethodKey(initialMethod?.key ?? ""); + setConfigValues(defaultMethodConfig(initialMethod)); setGoogleSheetsLinks(""); setGoogleSheetsError(null); setConnectResult(null); @@ -593,6 +624,14 @@ export function AppsConnect() { onNameChange={setGalleryName} values={credentials} onChange={setCredentials} + methodKey={connectionMethodKey} + onMethodChange={(nextMethod) => { + setConnectionMethodKey(nextMethod.key); + setCredentials({}); + setConfigValues(defaultMethodConfig(nextMethod)); + }} + configValues={configValues} + onConfigChange={setConfigValues} googleSheetsLinks={googleSheetsLinks} googleSheetsError={googleSheetsError} onGoogleSheetsLinksChange={(next) => { @@ -978,8 +1017,9 @@ function GalleryStep({
{filtered.map((app) => { const copy = appCopyFor(app.slug, app.description); - const oauth = getAvailableConnectionMethod(app)?.auth === "oauth"; - const oauthBlocked = oauth && !isMcpDirectOAuthConnectSlug(app.slug); + const methods = getAvailableConnectionMethods(app); + const oauth = methods[0]?.auth === "oauth"; + const oauthBlocked = methods.length === 1 && oauth && !isMcpDirectOAuthConnectSlug(app.slug); const unavailable = app.availability?.available === false; return (
+ {methods.length > 1 && ( +
+ +
+ {methods.map((candidate) => ( + onMethodChange(candidate)} + /> + ))} +
+ {!method &&

Choose a method to continue.

} +
+ )} + - {fields.length === 0 ? ( + {configFields.map((field) => ( + onConfigChange({ ...configValues, [field.key]: value })} + /> + ))} + + {method && fields.length === 0 ? (

- This app doesn’t need a key. Just connect to continue. + {method.auth === "oauth" + ? `You’ll continue to ${entry.name} to sign in securely.` + : "This app doesn’t need a key. Just connect to continue."}

) : ( fields.map((field) => ( @@ -1490,15 +1581,17 @@ function KeyStep({ )) )} -
- -
-
Your key is stored securely.
-
- You can replace it anytime from this app’s page. + {method?.auth === "api_key" && ( +
+ +
+
Your key is stored securely.
+
+ You can replace it anytime from this app’s page. +
-
+ )}
@@ -1507,11 +1600,13 @@ function KeyStep({
- We’ll check the key before turning anything on. + {method?.auth === "oauth" + ? "You’ll sign in before anything turns on." + : "We’ll check the key before turning anything on."} -
@@ -1519,6 +1614,58 @@ function KeyStep({ ); } +function MethodConfigField({ + field, + value, + onChange, +}: { + field: FieldDef; + value: string | boolean | undefined; + onChange: (value: string | boolean) => void; +}) { + if (field.type === "checkbox") { + return ( +
+
+
{field.label}
+ {field.helperMd &&
{field.helperMd}
} +
+ +
+ ); + } + return ( +
+ + {field.type === "textarea" ? ( +