diff --git a/.agent-marketplace.json b/.agent-marketplace.json index da55de5e134..5cce9fec98d 100644 --- a/.agent-marketplace.json +++ b/.agent-marketplace.json @@ -15,7 +15,7 @@ }, "category": "development", "source": "./plugins/github", - "version": "1.0.0" + "version": "1.0.1" }, { "name": "linear", @@ -25,7 +25,37 @@ }, "category": "productivity", "source": "./plugins/linear", - "version": "1.4.0" + "version": "1.5.2" + }, + { + "name": "notion", + "description": "Search, read, and write your Notion workspace: pages, databases, and comments.", + "author": { + "name": "Superset" + }, + "category": "productivity", + "source": "./plugins/notion", + "version": "1.0.2" + }, + { + "name": "slack", + "description": "Search, read, and post across your Slack workspace: channels, DMs, and threads.", + "author": { + "name": "Superset" + }, + "category": "communication", + "source": "./plugins/slack", + "version": "1.2.3" + }, + { + "name": "gmail", + "description": "Read, search, send, and organize mail in your Gmail account.", + "author": { + "name": "Superset" + }, + "category": "communication", + "source": "./plugins/gmail", + "version": "1.1.5" } ], "featured": ["github", "linear"], diff --git a/.env.example b/.env.example index f77be313fe4..8314d7aa97d 100644 --- a/.env.example +++ b/.env.example @@ -148,6 +148,27 @@ POSTHOG_PROJECT_ID= GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT= GOOGLE_SEARCH_CONSOLE_SITE_URL=sc-domain:superset.sh +# ----------------------------------------------------------------------------- +# Plugin OAuth (optional — falls back to NEXT_PUBLIC_API_URL) +# Origin an authorization server fetches a plugin's client id metadata document +# from. Setting this alone does not make local dev work: redirect_uris in that +# document come from NEXT_PUBLIC_API_URL, and the document declares +# application_type "web", so a loopback redirect is rejected. To connect a +# dynamic-client plugin locally, point one public https tunnel at the API and +# set NEXT_PUBLIC_API_URL to it; this variable is for the case where the two +# origins genuinely differ. +# ----------------------------------------------------------------------------- +PLUGIN_CLIENT_METADATA_BASE_URL= + +# ----------------------------------------------------------------------------- +# Connector OAuth clients +# Only for connectors whose requires_env names a pair that no other feature +# already sets — the rest reuse the integration clients above. Without them the +# connector still lists, but Connect answers "not configured". +# ----------------------------------------------------------------------------- +PLUGIN_GITHUB_CLIENT_ID= +PLUGIN_GITHUB_CLIENT_SECRET= + # ----------------------------------------------------------------------------- # Sentry Error Tracking # ----------------------------------------------------------------------------- diff --git a/.env.local.example b/.env.local.example index 2dabaa6e9e1..ddc46c7679b 100644 --- a/.env.local.example +++ b/.env.local.example @@ -135,6 +135,27 @@ POSTHOG_PROJECT_ID=00000 GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT= GOOGLE_SEARCH_CONSOLE_SITE_URL=sc-domain:superset.sh +# ----------------------------------------------------------------------------- +# Plugin OAuth (optional — falls back to NEXT_PUBLIC_API_URL) +# Origin an authorization server fetches a plugin's client id metadata document +# from. Setting this alone does not make local dev work: redirect_uris in that +# document come from NEXT_PUBLIC_API_URL, and the document declares +# application_type "web", so a loopback redirect is rejected. To connect a +# dynamic-client plugin locally, point one public https tunnel at the API and +# set NEXT_PUBLIC_API_URL to it; this variable is for the case where the two +# origins genuinely differ. +# ----------------------------------------------------------------------------- +PLUGIN_CLIENT_METADATA_BASE_URL= + +# ----------------------------------------------------------------------------- +# Connector OAuth clients +# Only for connectors whose requires_env names a pair that no other feature +# already sets — the rest reuse the integration clients above. Without them the +# connector still lists, but Connect answers "not configured". +# ----------------------------------------------------------------------------- +PLUGIN_GITHUB_CLIENT_ID= +PLUGIN_GITHUB_CLIENT_SECRET= + # ----------------------------------------------------------------------------- # Sentry Error Tracking (optional — leave blank to disable) # ----------------------------------------------------------------------------- diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 605c56fe983..0fc50677015 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -151,6 +151,9 @@ jobs: NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT }} + PLUGIN_CLIENT_METADATA_BASE_URL: ${{ secrets.PLUGIN_CLIENT_METADATA_BASE_URL }} + PLUGIN_GITHUB_CLIENT_ID: ${{ secrets.PLUGIN_GITHUB_CLIENT_ID }} + PLUGIN_GITHUB_CLIENT_SECRET: ${{ secrets.PLUGIN_GITHUB_CLIENT_SECRET }} POSTHOG_PROJECT_ID: ${{ secrets.POSTHOG_PROJECT_ID }} KV_REST_API_URL: ${{ secrets.KV_REST_API_URL }} KV_REST_API_TOKEN: ${{ secrets.KV_REST_API_TOKEN }} @@ -225,6 +228,9 @@ jobs: --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ --env POSTHOG_API_KEY=$POSTHOG_API_KEY \ --env GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT="$GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT" \ + --env PLUGIN_CLIENT_METADATA_BASE_URL="$PLUGIN_CLIENT_METADATA_BASE_URL" \ + --env PLUGIN_GITHUB_CLIENT_ID="$PLUGIN_GITHUB_CLIENT_ID" \ + --env PLUGIN_GITHUB_CLIENT_SECRET="$PLUGIN_GITHUB_CLIENT_SECRET" \ --env POSTHOG_PROJECT_ID=$POSTHOG_PROJECT_ID \ --env KV_REST_API_URL=$KV_REST_API_URL \ --env KV_REST_API_TOKEN=$KV_REST_API_TOKEN \ diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 976dbdd198c..789a3f72d19 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -138,6 +138,9 @@ jobs: STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} MERCURY_API_TOKEN: ${{ secrets.MERCURY_API_TOKEN }} GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT }} + PLUGIN_CLIENT_METADATA_BASE_URL: ${{ secrets.PLUGIN_CLIENT_METADATA_BASE_URL }} + PLUGIN_GITHUB_CLIENT_ID: ${{ secrets.PLUGIN_GITHUB_CLIENT_ID }} + PLUGIN_GITHUB_CLIENT_SECRET: ${{ secrets.PLUGIN_GITHUB_CLIENT_SECRET }} STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} STRIPE_PRO_MONTHLY_PRICE_ID: ${{ secrets.STRIPE_PRO_MONTHLY_PRICE_ID }} STRIPE_PRO_YEARLY_PRICE_ID: ${{ secrets.STRIPE_PRO_YEARLY_PRICE_ID }} @@ -224,6 +227,9 @@ jobs: --env STRIPE_SECRET_KEY=$STRIPE_SECRET_KEY \ --env MERCURY_API_TOKEN=$MERCURY_API_TOKEN \ --env GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT="$GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT" \ + --env PLUGIN_CLIENT_METADATA_BASE_URL="$PLUGIN_CLIENT_METADATA_BASE_URL" \ + --env PLUGIN_GITHUB_CLIENT_ID="$PLUGIN_GITHUB_CLIENT_ID" \ + --env PLUGIN_GITHUB_CLIENT_SECRET="$PLUGIN_GITHUB_CLIENT_SECRET" \ --env STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET \ --env STRIPE_PRO_MONTHLY_PRICE_ID=$STRIPE_PRO_MONTHLY_PRICE_ID \ --env STRIPE_PRO_YEARLY_PRICE_ID=$STRIPE_PRO_YEARLY_PRICE_ID \ diff --git a/AGENTS.md b/AGENTS.md index ff847646234..cbb9bf74255 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,8 +94,11 @@ Desktop, host-service, and cli share one version; cut releases on a dedicated br ## Plugins -First-party plugins live in `plugins//`: a `plugin.json` manifest, `skills/`, and optionally -an MCP server. A release is the git tag `@` on this repo — that tree is what a host +First-party plugins live in `plugins//`: a `plugin.json` manifest and `skills/`. A plugin +ships no code — agents reach its tools through `/mcp/plugins//` on the API, +which proxies to the vendor's MCP server or, for the plugins named in the `SUPERSET_HOSTED_PLUGINS` constant, +serves them from `packages/trpc/src/router/plugins/servers/`. A release is the git tag +`@` on this repo — that tree is what a host downloads — and `packages/shared/src/plugins/manifests.generated.ts` is the bundle the API resolves against, which is generated and must never be hand-edited. Change the source, then `superset plugins publish --bump patch`, which rewrites the marketplace entry in diff --git a/apps/api/src/app/api/connectors/[connector]/client-metadata/route.ts b/apps/api/src/app/api/connectors/[connector]/client-metadata/route.ts new file mode 100644 index 00000000000..1e569f17d94 --- /dev/null +++ b/apps/api/src/app/api/connectors/[connector]/client-metadata/route.ts @@ -0,0 +1,31 @@ +import { getConnector } from "@superset/shared/connectors"; +import { clientMetadataUrl, redirectUriFor } from "@superset/trpc/connectors"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ connector: string }> }, +) { + const { connector: slug } = await params; + if (!getConnector(slug)) { + return Response.json({ error: "Unknown connector" }, { status: 404 }); + } + + return Response.json( + { + client_id: clientMetadataUrl(slug), + client_name: "Superset", + client_uri: "https://superset.sh", + redirect_uris: [redirectUriFor(slug)], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + application_type: "web", + }, + { + headers: { + "cache-control": "public, max-age=3600", + "content-type": "application/json", + }, + }, + ); +} diff --git a/apps/api/src/app/api/connectors/[connector]/connect/route.ts b/apps/api/src/app/api/connectors/[connector]/connect/route.ts new file mode 100644 index 00000000000..b6d0de200d7 --- /dev/null +++ b/apps/api/src/app/api/connectors/[connector]/connect/route.ts @@ -0,0 +1,91 @@ +import type { ConnectorMethod } from "@superset/shared/connectors"; +import { + authorizeUrl, + connectorMethod, + createCodeVerifier, + MissingConnectorEnvError, + redirectUriFor, + requireConnector, + resolveEndpoints, + UnknownConnectorError, +} from "@superset/trpc/connectors"; +import { encryptSecret } from "@superset/trpc/integrations/plugins"; + +import { env } from "@/env"; +import { requireOrgMember } from "@/lib/integrations/requireOrgMember"; +import { createSignedState } from "@/lib/oauth-state"; + +export const STATE_COOKIE = "connector_oauth_state"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ connector: string }> }, +) { + const { connector: slug } = await params; + const member = await requireOrgMember(request); + if (member instanceof Response) return member; + + // Same reason as the callback: the generic connect page covers every + // connector, `/integrations/` only the seven older ones. + const connectUrl = `${env.NEXT_PUBLIC_WEB_URL}/connect/${slug}`; + const requested = new URL(request.url).searchParams.get("method"); + + let method: ConnectorMethod; + try { + const connector = requireConnector(slug); + method = connectorMethod( + connector, + (requested as ConnectorMethod["type"] | null) ?? undefined, + ); + } catch (error) { + if (error instanceof UnknownConnectorError) + return Response.json({ error: "Unknown connector" }, { status: 404 }); + throw error; + } + + if (method.type === "api_key") + return Response.json( + { error: "This method takes inputs, not a redirect" }, + { status: 400 }, + ); + + let target: string; + let state: string; + try { + const redirect = redirectUriFor(slug); + const wantsPkce = + method.type === "oauth2" && + (await resolveEndpoints(slug, method, redirect)).pkce; + const codeVerifier = wantsPkce ? createCodeVerifier() : null; + state = codeVerifier + ? createSignedState({ + organizationId: member.organizationId, + userId: member.userId, + codeVerifier: await encryptSecret(codeVerifier), + }) + : member.state; + + target = ( + await authorizeUrl(slug, method, { + redirectUri: redirect, + state, + codeVerifier, + }) + ).url; + } catch (error) { + if (error instanceof MissingConnectorEnvError) { + console.error(`[connectors/${slug}] ${error.message}`); + return Response.redirect(`${connectUrl}?error=not_configured`); + } + throw error; + } + + const secure = env.NEXT_PUBLIC_API_URL.startsWith("https") ? " Secure;" : ""; + return new Response(null, { + status: 302, + headers: { + Location: target, + "Set-Cookie": `${STATE_COOKIE}=${state}; HttpOnly;${secure} SameSite=Lax; Path=/api/connectors; Max-Age=600`, + }, + }); +} diff --git a/apps/api/src/app/api/connectors/callback/[connector]/route.ts b/apps/api/src/app/api/connectors/callback/[connector]/route.ts new file mode 100644 index 00000000000..a1be6110ab7 --- /dev/null +++ b/apps/api/src/app/api/connectors/callback/[connector]/route.ts @@ -0,0 +1,117 @@ +import type { ConnectorMethod } from "@superset/shared/connectors"; +import { + connectorMethod, + exchangeCode, + probeIdentity, + redirectUriFor, + requireConnector, + UnknownConnectorError, + upsertConnection, +} from "@superset/trpc/connectors"; +import { decryptSecret } from "@superset/trpc/integrations/plugins"; +import { STATE_COOKIE } from "@/app/api/connectors/[connector]/connect/route"; +import { env } from "@/env"; +import { resolveCallback } from "@/lib/integrations/resolveCallback"; +import { connectorStateSchema, verifySignedState } from "@/lib/oauth-state"; + +function callbackParams(method: ConnectorMethod): string[] { + if (method.type === "oauth2") return ["code"]; + if (method.type === "app_install" || method.type === "admin_consent") + return [...method.callback_params]; + return []; +} + +function stateCookie(request: Request): string | null { + const cookie = request.headers.get("cookie") ?? ""; + const match = cookie.match(new RegExp(`(?:^|;\\s*)${STATE_COOKIE}=([^;]+)`)); + return match?.[1] ?? null; +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ connector: string }> }, +) { + const { connector: slug } = await params; + // The generic connect page exists for every connector; `/integrations/` + // only exists for the seven that predate the registry, so a new connector + // would 404 the moment someone finished authorizing it. + const connectUrl = `${env.NEXT_PUBLIC_WEB_URL}/connect/${slug}`; + const web = (query: string) => Response.redirect(`${connectUrl}${query}`); + + let connector: ReturnType; + let method: ConnectorMethod; + try { + connector = requireConnector(slug); + method = connectorMethod(connector); + } catch (error) { + if (error instanceof UnknownConnectorError) + return Response.json({ error: "Unknown connector" }, { status: 404 }); + throw error; + } + + const callback = await resolveCallback(request, { + params: callbackParams(method), + redirect: (error) => web(`?error=${error}`), + stateFrom: method.type === "app_install" ? stateCookie : undefined, + }); + if (callback instanceof Response) return callback; + + // The signed state proves who asked, not who is answering. Without this the + // authorization code a victim approves can be paired with a state minted by + // an attacker, binding the victim's account to the attacker's connection. + if (method.type !== "app_install") { + const query = new URL(request.url).searchParams.get("state"); + if (!query || query !== stateCookie(request)) + return web("?error=invalid_state"); + } + + try { + const rawState = + method.type === "app_install" + ? stateCookie(request) + : new URL(request.url).searchParams.get("state"); + const carried = rawState + ? verifySignedState(rawState, connectorStateSchema) + : null; + const codeVerifier = carried?.codeVerifier + ? await decryptSecret(carried.codeVerifier) + : null; + + const tokens = await exchangeCode(slug, method, { + code: callback.params.code ?? "", + redirectUri: redirectUriFor(slug), + codeVerifier, + params: callback.params, + issuer: new URL(request.url).searchParams.get("iss"), + }); + + const identity = await probeIdentity( + slug, + method, + tokens.accessToken, + callback.params, + ); + + const result = await upsertConnection({ + connector, + slug, + authMethod: method.type, + organizationId: callback.organizationId, + userId: callback.userId, + tokens, + identity, + }); + + if (result.conflict) { + const owner = result.conflict.ownerEmail + ? `&owner=${encodeURIComponent(result.conflict.ownerEmail)}` + : ""; + return web(`?error=account_already_linked${owner}`); + } + } catch (error) { + console.error(`[connectors/${slug}] callback failed:`, error); + return web("?error=token_exchange_failed"); + } + + return web("?connected=1"); +} diff --git a/apps/api/src/app/api/integrations/google/calendar/push/route.ts b/apps/api/src/app/api/integrations/google/calendar/push/route.ts index d410e120c03..9fe6791d14e 100644 --- a/apps/api/src/app/api/integrations/google/calendar/push/route.ts +++ b/apps/api/src/app/api/integrations/google/calendar/push/route.ts @@ -1,6 +1,6 @@ import { createHash, timingSafeEqual } from "node:crypto"; import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; +import { connections } from "@superset/db/schema"; import { googleConfigOf } from "@superset/trpc/integrations/google"; import { and, eq, isNull, sql } from "drizzle-orm"; import { syncCalendar } from "../../lib/syncCalendar"; @@ -28,13 +28,13 @@ export async function POST(request: Request) { // calendar state holds this channel id. const [connection] = await db .select() - .from(integrationConnections) + .from(connections) .where( and( - eq(integrationConnections.provider, "google"), - isNull(integrationConnections.disconnectedAt), + eq(connections.connector, "google"), + isNull(connections.disconnectedAt), sql`EXISTS ( - SELECT 1 FROM jsonb_each(coalesce(${integrationConnections.config} -> 'calendars', '{}'::jsonb)) AS c + SELECT 1 FROM jsonb_each(coalesce(${connections.state} -> 'calendars', '{}'::jsonb)) AS c WHERE c.value ->> 'channelId' = ${channelId} )`, ), @@ -46,11 +46,11 @@ export async function POST(request: Request) { return Response.json({ error: "Unknown channel" }, { status: 404 }); } - const calendars = googleConfigOf(connection.config).calendars ?? {}; + const calendars = googleConfigOf(connection.state).calendars ?? {}; const entry = Object.entries(calendars).find( ([, s]) => s.channelId === channelId, ); - // Only the hash is stored (the config column reaches every member's + // Only the hash is stored (the state column reaches every member's // client); the token Google echoes is hashed the same way and compared. const expected = entry?.[1].channelTokenHash; const presented = createHash("sha256").update(token).digest("hex"); diff --git a/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts b/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts index 23c31a1e0d2..f6cc7b2c642 100644 --- a/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts +++ b/apps/api/src/app/api/integrations/google/calendar/scheduled/route.ts @@ -1,5 +1,5 @@ import { db } from "@superset/db/client"; -import type { SelectIntegrationConnection } from "@superset/db/schema"; +import type { SelectConnection } from "@superset/db/schema"; import { eventEnd, eventStart, @@ -82,7 +82,7 @@ export async function POST(request: Request) { } function normalizeFire( - connection: SelectIntegrationConnection, + connection: SelectConnection, fire: z.infer, event: GoogleCalendarEvent, ): NormalizedDelivery { diff --git a/apps/api/src/app/api/integrations/google/callback/route.ts b/apps/api/src/app/api/integrations/google/callback/route.ts index adb648eac69..32fce956250 100644 --- a/apps/api/src/app/api/integrations/google/callback/route.ts +++ b/apps/api/src/app/api/integrations/google/callback/route.ts @@ -1,12 +1,17 @@ -import { integrationConnections } from "@superset/db/schema"; +import { db } from "@superset/db/client"; +import { connections } from "@superset/db/schema"; +import { + connectorMethod, + requireConnector, + upsertConnection, +} from "@superset/trpc/connectors"; import { googleTokenResponseSchema } from "@superset/trpc/integrations/google"; import { Client } from "@upstash/qstash"; -import { sql } from "drizzle-orm"; +import { and, eq, ne, sql } from "drizzle-orm"; import { z } from "zod"; import { env } from "@/env"; import { resolveCallback } from "@/lib/integrations/resolveCallback"; -import { upsertConnection } from "@/lib/integrations/upsertConnection"; import { upsertIdentity } from "@/lib/integrations/upsertIdentity"; const qstash = new Client({ token: env.QSTASH_TOKEN, baseUrl: env.QSTASH_URL }); @@ -88,25 +93,47 @@ export async function GET(request: Request) { const info = parsedInfo.data; const email = info.email.toLowerCase(); + const connector = requireConnector("google"); const result = await upsertConnection({ + connector, + slug: "google", + authMethod: connectorMethod(connector, "oauth2").type, organizationId, userId, - provider: "google", - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000), - // The account's address, not an organization: Calendar and Gmail are - // one person's, and everything downstream treats them as theirs. - externalOrgId: email, - externalOrgName: email, - config: { provider: "google" }, - // Reconnecting the same account keeps its sync tokens and channels; - // a different account starts over. The old account's channels are - // then unknown to the push route and expire within a week. - configOnUpdate: sql`CASE WHEN ${integrationConnections.externalOrgId} = ${email} THEN ${integrationConnections.config} ELSE '{"provider":"google"}'::jsonb END`, + tokens: { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: new Date(Date.now() + tokens.expires_in * 1000), + scopes: tokens.scope ? tokens.scope.split(" ") : null, + stored: {}, + raw: tokens as unknown as Record, + }, + identity: { + // The account's address, not an organization: Calendar and Gmail are + // one person's, and everything downstream treats them as theirs. + account: { id: email, label: email }, + user: { id: info.sub, label: email }, + }, + state: { provider: "google" }, + // Reconnecting the same account keeps its sync tokens and channels. + stateOnUpdate: sql`${connections.state}`, }); if (result.conflict) return fail("account_already_linked"); + // A different Google account is a different row under the connector + // uniqueness, and a member holds one. The old row's channels are then + // unknown to the push route and expire within a week. + await db + .delete(connections) + .where( + and( + eq(connections.organizationId, organizationId), + eq(connections.connector, "google"), + eq(connections.connectedByUserId, userId), + ne(connections.id, result.connectionId), + ), + ); + // The identity's external id is the address rather than Google's subject // id, because calendar events and mail headers name people by address. await upsertIdentity({ diff --git a/apps/api/src/app/api/integrations/google/gmail/push/route.ts b/apps/api/src/app/api/integrations/google/gmail/push/route.ts index 0550572d2fe..4e3b8765335 100644 --- a/apps/api/src/app/api/integrations/google/gmail/push/route.ts +++ b/apps/api/src/app/api/integrations/google/gmail/push/route.ts @@ -1,7 +1,5 @@ import { timingSafeEqual } from "node:crypto"; -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; +import { accountConnections } from "@superset/trpc/connectors"; import { z } from "zod"; import { env } from "@/env"; import { syncMailbox } from "../../lib/syncMailbox"; @@ -59,20 +57,11 @@ export async function POST(request: Request) { return Response.json({ ok: true, skipped: "malformed" }); } - const connections = await db - .select() - .from(integrationConnections) - .where( - and( - eq(integrationConnections.provider, "google"), - eq( - integrationConnections.externalOrgId, - notification.emailAddress.toLowerCase(), - ), - isNull(integrationConnections.disconnectedAt), - ), - ); - if (connections.length === 0) { + const mailboxes = await accountConnections( + "google", + notification.emailAddress.toLowerCase(), + ); + if (mailboxes.length === 0) { return Response.json({ ok: true, skipped: "no connection" }); } @@ -81,7 +70,7 @@ export async function POST(request: Request) { // non-2xx, which is what asks for the redelivery. const results = []; let failed = 0; - for (const connection of connections) { + for (const connection of mailboxes) { try { const result = await syncMailbox(connection); if (result.recorded > 0) { diff --git a/apps/api/src/app/api/integrations/google/jobs/renew-watches/route.ts b/apps/api/src/app/api/integrations/google/jobs/renew-watches/route.ts index 6e313f938a8..539f5fa925a 100644 --- a/apps/api/src/app/api/integrations/google/jobs/renew-watches/route.ts +++ b/apps/api/src/app/api/integrations/google/jobs/renew-watches/route.ts @@ -1,5 +1,5 @@ import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; +import { connections } from "@superset/db/schema"; import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; import { verifyQstashRequest } from "@/lib/verifyQstash"; @@ -36,21 +36,21 @@ export async function POST(request: Request) { return Response.json({ error: "Invalid payload" }, { status: 400 }); } - const connections = await db - .select({ id: integrationConnections.id }) - .from(integrationConnections) + const rows = await db + .select({ id: connections.id }) + .from(connections) .where( and( - eq(integrationConnections.provider, "google"), - isNull(integrationConnections.disconnectedAt), + eq(connections.connector, "google"), + isNull(connections.disconnectedAt), ...(parsed.data.connectionId - ? [eq(integrationConnections.id, parsed.data.connectionId)] + ? [eq(connections.id, parsed.data.connectionId)] : []), ), ); const results = []; - for (const connection of connections) { + for (const connection of rows) { try { const result = await reconcileWatches(connection.id); if (result.errors.length > 0) { @@ -68,5 +68,5 @@ export async function POST(request: Request) { }); } } - return Response.json({ connections: connections.length, results }); + return Response.json({ connections: rows.length, results }); } diff --git a/apps/api/src/app/api/integrations/google/jobs/sweep-schedules/route.ts b/apps/api/src/app/api/integrations/google/jobs/sweep-schedules/route.ts index 3b2ca2ae94e..b430da217b0 100644 --- a/apps/api/src/app/api/integrations/google/jobs/sweep-schedules/route.ts +++ b/apps/api/src/app/api/integrations/google/jobs/sweep-schedules/route.ts @@ -63,7 +63,7 @@ export async function POST(request: Request) { const window = sweepWindow(plan, now); let scheduled = 0; for (const calendarId of Object.keys( - googleConfigOf(connection.config).calendars ?? {}, + googleConfigOf(connection.state).calendars ?? {}, )) { if (!plan.allows(calendarId)) continue; const instances = await listUpcomingInstances( diff --git a/apps/api/src/app/api/integrations/google/lib/calendarEvents.ts b/apps/api/src/app/api/integrations/google/lib/calendarEvents.ts index d36f9f1c441..3a7661c33a2 100644 --- a/apps/api/src/app/api/integrations/google/lib/calendarEvents.ts +++ b/apps/api/src/app/api/integrations/google/lib/calendarEvents.ts @@ -1,4 +1,4 @@ -import type { SelectIntegrationConnection } from "@superset/db/schema"; +import type { SelectConnection } from "@superset/db/schema"; import type { GoogleCalendarMatchableEvent } from "@superset/shared/automation-matching"; import type { GoogleCalendarTriggerEvent } from "@superset/shared/automation-triggers"; import { @@ -8,11 +8,11 @@ import { /** The domain of the connected account: what "external" is measured against. */ export function accountDomain( - connection: Pick, + connection: Pick, ): string | null { - const at = connection.externalOrgId?.lastIndexOf("@") ?? -1; + const at = connection.externalAccountId?.lastIndexOf("@") ?? -1; if (at < 0) return null; - return connection.externalOrgId?.slice(at + 1).toLowerCase() ?? null; + return connection.externalAccountId?.slice(at + 1).toLowerCase() ?? null; } export function resourceKeyFor( diff --git a/apps/api/src/app/api/integrations/google/lib/reconcileWatches.ts b/apps/api/src/app/api/integrations/google/lib/reconcileWatches.ts index 4fca5b25569..fd6b32c4d16 100644 --- a/apps/api/src/app/api/integrations/google/lib/reconcileWatches.ts +++ b/apps/api/src/app/api/integrations/google/lib/reconcileWatches.ts @@ -1,5 +1,5 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; -import type { SelectIntegrationConnection } from "@superset/db/schema"; +import type { SelectConnection } from "@superset/db/schema"; import { findGoogleConnectionById, googleConfigOf, @@ -55,7 +55,7 @@ export async function reconcileWatches( ); const calendars = listed.slice(0, MAX_WATCHED_CALENDARS); result.calendars = calendars.length; - const known = googleConfigOf(connection.config).calendars ?? {}; + const known = googleConfigOf(connection.state).calendars ?? {}; const now = Date.now(); const address = `${env.NEXT_PUBLIC_API_URL}/api/integrations/google/calendar/push`; @@ -154,11 +154,11 @@ export async function reconcileWatches( } async function reconcileGmailWatch( - connection: SelectIntegrationConnection, + connection: SelectConnection, topicName: string, now: number, ): Promise { - const state = googleConfigOf(connection.config).gmail; + const state = googleConfigOf(connection.state).gmail; const expiresSoon = (state?.watchExpiresAt ?? 0) - now < WATCH_RENEW_WINDOW_MS; if (!expiresSoon) return; diff --git a/apps/api/src/app/api/integrations/google/lib/syncCalendar.ts b/apps/api/src/app/api/integrations/google/lib/syncCalendar.ts index e67c1c76270..a5ce7b9fba6 100644 --- a/apps/api/src/app/api/integrations/google/lib/syncCalendar.ts +++ b/apps/api/src/app/api/integrations/google/lib/syncCalendar.ts @@ -1,8 +1,5 @@ import { db } from "@superset/db/client"; -import { - automationEvents, - type SelectIntegrationConnection, -} from "@superset/db/schema"; +import { automationEvents, type SelectConnection } from "@superset/db/schema"; import type { GoogleCalendarTriggerEvent } from "@superset/shared/automation-triggers"; import { eventStart, @@ -51,10 +48,10 @@ export type CalendarSyncResult = { * on it at once. */ export async function syncCalendar( - connection: SelectIntegrationConnection, + connection: SelectConnection, calendarId: string, ): Promise { - const state = googleConfigOf(connection.config).calendars?.[calendarId]; + const state = googleConfigOf(connection.state).calendars?.[calendarId]; const result = await listEventChanges( connection.id, calendarId, @@ -116,7 +113,7 @@ export async function syncCalendar( * them. Separate from the listing so the same path runs over a captured page. */ export async function applyCalendarChanges( - connection: SelectIntegrationConnection, + connection: SelectConnection, calendarId: string, items: GoogleCalendarEvent[], options: { watchedSince: Date; now?: Date }, @@ -184,7 +181,7 @@ export async function applyCalendarChanges( * applied in order: a later change's kind depends on this one being recorded. */ async function recordChange(params: { - connection: SelectIntegrationConnection; + connection: SelectConnection; calendarId: string; item: GoogleCalendarEvent; watchedSince: Date; @@ -225,7 +222,7 @@ async function recordChange(params: { * creation; without the first, every later edit of a new event would. */ function normalizeChange(params: { - connection: SelectIntegrationConnection; + connection: SelectConnection; calendarId: string; item: GoogleCalendarEvent; watchedSince: Date; diff --git a/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts b/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts index d36d7c3676f..c2b1f6f6c54 100644 --- a/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts +++ b/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts @@ -1,8 +1,5 @@ import { db } from "@superset/db/client"; -import { - automationEvents, - type SelectIntegrationConnection, -} from "@superset/db/schema"; +import { automationEvents, type SelectConnection } from "@superset/db/schema"; import type { GmailMatchableEvent } from "@superset/shared/automation-matching"; import { type GmailMessage, @@ -41,9 +38,9 @@ export type MailboxSyncResult = { * the profile; whatever arrived in the gap is not replayed. */ export async function syncMailbox( - connection: SelectIntegrationConnection, + connection: SelectConnection, ): Promise { - const state = googleConfigOf(connection.config).gmail; + const state = googleConfigOf(connection.state).gmail; if (!state?.historyId) { const profile = await getProfile(connection.id); await patchGmailState(connection.id, { historyId: profile.historyId }); @@ -113,7 +110,7 @@ export async function syncMailbox( * the body stays in the mailbox. */ export function normalizeMessage( - connection: SelectIntegrationConnection, + connection: SelectConnection, message: GmailMessage, ): NormalizedDelivery { const from = headerValue(message, "From"); diff --git a/apps/api/src/app/api/integrations/linear/callback/route.ts b/apps/api/src/app/api/integrations/linear/callback/route.ts index f5727812676..6cb52f4db4e 100644 --- a/apps/api/src/app/api/integrations/linear/callback/route.ts +++ b/apps/api/src/app/api/integrations/linear/callback/route.ts @@ -1,10 +1,14 @@ import { LinearClient } from "@linear/sdk"; +import { + connectorMethod, + requireConnector, + upsertConnection, +} from "@superset/trpc/connectors"; import { linearTokenResponseSchema } from "@superset/trpc/integrations/linear"; import { Client } from "@upstash/qstash"; import { env } from "@/env"; import { resolveCallback } from "@/lib/integrations/resolveCallback"; -import { upsertConnection } from "@/lib/integrations/upsertConnection"; import { upsertIdentity } from "@/lib/integrations/upsertIdentity"; const qstash = new Client({ token: env.QSTASH_TOKEN }); @@ -43,15 +47,25 @@ export async function GET(request: Request) { const viewer = await linearClient.viewer; const linearOrg = await viewer.organization; + const connector = requireConnector("linear"); const result = await upsertConnection({ + connector, + slug: "linear", + authMethod: connectorMethod(connector, "oauth2").type, organizationId, userId, - provider: "linear", - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - tokenExpiresAt: new Date(Date.now() + tokenData.expires_in * 1000), - externalOrgId: linearOrg.id, - externalOrgName: linearOrg.name, + tokens: { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresAt: new Date(Date.now() + tokenData.expires_in * 1000), + scopes: tokenData.scope ? tokenData.scope.split(",") : null, + stored: {}, + raw: tokenData as unknown as Record, + }, + identity: { + account: { id: linearOrg.id, label: linearOrg.name }, + user: { id: viewer.id, label: viewer.displayName }, + }, }); if (result.conflict) { return Response.redirect(`${settingsUrl}?error=workspace_already_linked`); diff --git a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts index 87bdf10cd7e..b0286e70ed0 100644 --- a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts +++ b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts @@ -32,7 +32,7 @@ export async function POST(request: Request) { const { organizationId, creatorUserId } = parsed.data; - const client = await getLinearClient(organizationId); + const client = await getLinearClient(organizationId, creatorUserId); if (!client) { return Response.json({ error: "No Linear connection or connection disconnected", diff --git a/apps/api/src/app/api/integrations/linear/jobs/refresh-tokens/route.ts b/apps/api/src/app/api/integrations/linear/jobs/refresh-tokens/route.ts index d6af6f4ad58..2ee5d8531bf 100644 --- a/apps/api/src/app/api/integrations/linear/jobs/refresh-tokens/route.ts +++ b/apps/api/src/app/api/integrations/linear/jobs/refresh-tokens/route.ts @@ -1,5 +1,5 @@ import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; +import { connections } from "@superset/db/schema"; import { refreshLinearToken } from "@superset/trpc/integrations/linear"; import { and, eq, isNotNull, isNull, lt, sql } from "drizzle-orm"; import { verifyQstashRequest } from "@/lib/verifyQstash"; @@ -13,18 +13,17 @@ export async function POST(request: Request) { ); if (rejected) return rejected; - const stale = await db.query.integrationConnections.findMany({ - where: and( - eq(integrationConnections.provider, "linear"), - isNull(integrationConnections.disconnectedAt), - isNotNull(integrationConnections.refreshToken), - lt( - integrationConnections.tokenExpiresAt, - sql`now() + interval '90 minutes'`, + const stale = await db + .select({ id: connections.id }) + .from(connections) + .where( + and( + eq(connections.connector, "linear"), + isNull(connections.disconnectedAt), + isNotNull(connections.refreshToken), + lt(connections.tokenExpiresAt, sql`now() + interval '90 minutes'`), ), - ), - columns: { id: true }, - }); + ); const results = await Promise.allSettled( stale.map(async (connection) => { diff --git a/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts b/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts index c9de617de25..317cf752c72 100644 --- a/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts +++ b/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts @@ -1,13 +1,8 @@ import type { LinearClient, WorkflowState } from "@linear/sdk"; import { db } from "@superset/db/client"; -import type { LinearConfig, SelectTask } from "@superset/db/schema"; -import { - integrationConnections, - members, - taskStatuses, - tasks, - users, -} from "@superset/db/schema"; +import type { SelectTask } from "@superset/db/schema"; +import { members, taskStatuses, tasks, users } from "@superset/db/schema"; +import { userConnection } from "@superset/trpc/connectors"; import { getLinearClient, mapPriorityToLinear, @@ -23,20 +18,17 @@ const payloadSchema = z.object({ async function getNewTasksTeamId( organizationId: string, + userId: string, ): Promise { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, organizationId), - eq(integrationConnections.provider, "linear"), - ), + const connection = await userConnection(organizationId, "linear", userId, { + includeDisconnected: true, }); - if (!connection?.config) { + if (connection?.state?.provider !== "linear") { return null; } - const config = connection.config as LinearConfig; - return config.newTasksTeamId ?? null; + return connection.state.newTasksTeamId ?? null; } async function findLinearState( @@ -88,7 +80,7 @@ async function syncTaskToLinear( externalUrl?: string; error?: string; }> { - const client = await getLinearClient(task.organizationId); + const client = await getLinearClient(task.organizationId, task.creatorId); if (!client) { return { success: false, error: "No Linear connection found" }; @@ -285,7 +277,7 @@ export async function POST(request: Request) { } const resolvedTeamId = - teamId ?? (await getNewTasksTeamId(task.organizationId)); + teamId ?? (await getNewTasksTeamId(task.organizationId, task.creatorId)); const result = await syncTaskToLinear(task, resolvedTeamId); diff --git a/apps/api/src/app/api/integrations/linear/webhook/normalizeLinearDelivery.ts b/apps/api/src/app/api/integrations/linear/webhook/normalizeLinearDelivery.ts index b445e28bbd7..8595980566e 100644 --- a/apps/api/src/app/api/integrations/linear/webhook/normalizeLinearDelivery.ts +++ b/apps/api/src/app/api/integrations/linear/webhook/normalizeLinearDelivery.ts @@ -88,7 +88,11 @@ export function normalizeLinearDelivery(params: { event: LinearMatchableEvent; /** Linear's delivery id when the header carried one; a redelivery reuses it. */ deliveryId: string; - connection: { id: string; organizationId: string }; + connection: { + id: string; + organizationId: string; + connectedByUserId: string; + }; webhookEventId: string; }): NormalizedDelivery { const { delivery, event, connection } = params; @@ -106,6 +110,6 @@ export function normalizeLinearDelivery(params: { payload: delivery, webhookEventId: params.webhookEventId, }, - dispatch: { event }, + dispatch: { event, ownerUserId: connection.connectedByUserId }, }; } diff --git a/apps/api/src/app/api/integrations/linear/webhook/processDelivery.test.ts b/apps/api/src/app/api/integrations/linear/webhook/processDelivery.test.ts new file mode 100644 index 00000000000..1185625c1e3 --- /dev/null +++ b/apps/api/src/app/api/integrations/linear/webhook/processDelivery.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const ORG = "org-1"; +const WORKSPACE = "linear-workspace-1"; + +function connection(id: string, userId: string) { + return { + id, + organizationId: ORG, + connectedByUserId: userId, + connector: "linear", + ownerKind: "user", + externalAccountId: WORKSPACE, + disconnectedAt: null, + }; +} + +let subscribers: ReturnType[] = []; +// `mock.module` is process-wide, so every export the real module has must be +// here: another file's import of `connectionBotToken` resolves against this +// stub too. +mock.module("@superset/trpc/connectors", () => ({ + accountConnection: mock(async () => subscribers[0] ?? null), + accountConnections: mock(async () => subscribers), + connectionBotToken: mock(async () => "bot-token"), +})); + +mock.module("@superset/trpc/integrations/linear", () => ({ + getLinearClient: mock(async () => null), + linearClientFor: mock(async () => null), + isLinearAuthError: () => false, + mapPriorityFromLinear: () => null, +})); + +let eventSeq = 0; +mock.module("@/lib/ingest/recordWebhookDelivery", () => ({ + recordWebhookDelivery: mock(async () => ({ + id: `webhook-event-${++eventSeq}`, + status: "pending", + retryCount: 0, + receivedAt: new Date(), + })), +})); + +const ingestCalls: Array<{ + connectionId: string | null; + externalEventId: string; + ownerUserId: string | null; +}> = []; +mock.module("@/lib/automations/ingestAutomationEvent", () => ({ + ingestAutomationEvent: mock(async (_db: unknown, delivery: never) => { + const d = delivery as { + skip?: string; + event?: { + integrationConnectionId: string | null; + externalEventId: string; + }; + dispatch?: { ownerUserId?: string } | null; + }; + if (d.skip) return { status: "skipped", reason: d.skip }; + ingestCalls.push({ + connectionId: d.event?.integrationConnectionId ?? null, + externalEventId: d.event?.externalEventId ?? "", + ownerUserId: d.dispatch?.ownerUserId ?? null, + }); + return { status: "dispatched", eventId: "e" }; + }), +})); + +mock.module("@superset/db/client", () => ({ + db: { update: () => ({ set: () => ({ where: async () => undefined }) }) }, +})); + +const { processDelivery } = await import("./processDelivery"); + +const DELIVERY = { + organizationId: WORKSPACE, + type: "Issue", + action: "create", + webhookTimestamp: 1_700_000_000, + data: { id: "issue-1", title: "Repro", teamId: "team-1", assigneeId: null }, + updatedFrom: null, +} as never; + +describe("one Linear delivery, two connections in the same organization", () => { + beforeEach(() => { + ingestCalls.length = 0; + eventSeq = 0; + subscribers = [ + connection("conn-a", "user-a"), + connection("conn-b", "user-b"), + ]; + }); + + test("records one automation event per connection", async () => { + await processDelivery({ payload: DELIVERY, deliveryId: "delivery-1" }); + expect(ingestCalls.map((c) => c.connectionId)).toEqual([ + "conn-a", + "conn-b", + ]); + }); + + test("both events name the same delivery", async () => { + await processDelivery({ payload: DELIVERY, deliveryId: "delivery-1" }); + const ids = new Set(ingestCalls.map((c) => c.externalEventId)); + expect(ids.size).toBe(1); + }); + + test("narrows each dispatch to the member who owns that connection", async () => { + await processDelivery({ payload: DELIVERY, deliveryId: "delivery-1" }); + expect(ingestCalls.map((c) => c.ownerUserId)).toEqual(["user-a", "user-b"]); + }); +}); diff --git a/apps/api/src/app/api/integrations/linear/webhook/processDelivery.ts b/apps/api/src/app/api/integrations/linear/webhook/processDelivery.ts index ef2ba28bdb6..335c57864a8 100644 --- a/apps/api/src/app/api/integrations/linear/webhook/processDelivery.ts +++ b/apps/api/src/app/api/integrations/linear/webhook/processDelivery.ts @@ -3,9 +3,8 @@ import type { LinearWebhookPayload, } from "@linear/sdk/webhooks"; import { db } from "@superset/db/client"; -import type { SelectIntegrationConnection } from "@superset/db/schema"; +import type { SelectConnection } from "@superset/db/schema"; import { - integrationConnections, members, taskStatuses, tasks, @@ -13,11 +12,15 @@ import { webhookEvents, } from "@superset/db/schema"; import { - getLinearClient, + accountConnection, + accountConnections, +} from "@superset/trpc/connectors"; +import { isLinearAuthError, + linearClientFor, mapPriorityFromLinear, } from "@superset/trpc/integrations/linear"; -import { and, asc, eq, isNull, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { ingestAutomationEvent } from "@/lib/automations/ingestAutomationEvent"; import { recordWebhookDelivery } from "@/lib/ingest/recordWebhookDelivery"; @@ -53,19 +56,8 @@ export interface DeliveryResult { export async function hasActiveSubscriber( externalOrgId: string, ): Promise { - const [subscriber] = await db - .select({ id: integrationConnections.id }) - .from(integrationConnections) - .where( - and( - eq(integrationConnections.externalOrgId, externalOrgId), - eq(integrationConnections.provider, "linear"), - isNull(integrationConnections.disconnectedAt), - ), - ) - .limit(1); - - return subscriber !== undefined; + const subscriber = await accountConnection("linear", externalOrgId); + return subscriber !== null; } /** @@ -88,16 +80,12 @@ export async function processDelivery({ payload: LinearWebhookPayload; deliveryId: string | null; }): Promise { - const connections = await db.query.integrationConnections.findMany({ - where: and( - eq(integrationConnections.externalOrgId, payload.organizationId), - eq(integrationConnections.provider, "linear"), - isNull(integrationConnections.disconnectedAt), - ), - orderBy: [asc(integrationConnections.id)], - }); + const subscribers = await accountConnections( + "linear", + payload.organizationId, + ); - if (connections.length === 0) { + if (subscribers.length === 0) { console.log( "[linear/process-delivery] No active connections for Linear org:", payload.organizationId, @@ -105,11 +93,15 @@ export async function processDelivery({ return { status: "no_subscribers", results: [] }; } + console.log( + `[linear/process-delivery] ${payload.type}.${payload.action} fans out to ${subscribers.length} connection(s) across ${new Set(subscribers.map((c) => c.organizationId)).size} organization(s)`, + ); + // Caught per connection, and the loop runs to the end regardless: one // organization's broken token or missing workflow state must not cost the // other sixteen their delivery. const results: ConnectionResult[] = []; - for (const connection of connections) { + for (const connection of subscribers) { results.push( await processForConnection(payload, deliveryId, connection).catch( (error) => ({ @@ -132,7 +124,7 @@ export async function processDelivery({ async function processForConnection( payload: LinearWebhookPayload, deliveryId: string | null, - connection: SelectIntegrationConnection, + connection: SelectConnection, ): Promise { // One webhookEvents row per (Linear event × Superset connection) so each // tenant's processing status is independently retryable, and so a retried @@ -229,7 +221,7 @@ function isEntityDelivery(payload: unknown): payload is LinearDelivery { async function ingest( delivery: LinearDelivery, deliveryHeader: string | null, - connection: SelectIntegrationConnection, + connection: SelectConnection, webhookEventId: string, ): Promise { const event = matchableFrom(delivery); @@ -261,10 +253,10 @@ async function ingest( // rethrown so the webhook-event retry path re-runs the sync instead of // recording a processed event with a stale branch. async function fetchIssueBranchName( - organizationId: string, + connection: SelectConnection, issueId: string, ): Promise { - const client = await getLinearClient(organizationId); + const client = await linearClientFor(connection); if (!client) return null; try { const response = await client.client.request< @@ -290,7 +282,7 @@ async function fetchIssueBranchName( async function processIssueEvent( payload: EntityWebhookPayloadWithIssueData, - connection: SelectIntegrationConnection, + connection: SelectConnection, ): Promise<"processed" | "skipped"> { const issue = payload.data; @@ -364,10 +356,7 @@ async function processIssueEvent( assigneeAvatarUrl = issue.assignee.avatarUrl ?? null; } - const branchName = await fetchIssueBranchName( - connection.organizationId, - issue.id, - ); + const branchName = await fetchIssueBranchName(connection, issue.id); const taskData = { slug: issue.identifier, diff --git a/apps/api/src/app/api/integrations/microsoft-teams/callback/route.ts b/apps/api/src/app/api/integrations/microsoft-teams/callback/route.ts index 8a3f7dece52..950fbdab1de 100644 --- a/apps/api/src/app/api/integrations/microsoft-teams/callback/route.ts +++ b/apps/api/src/app/api/integrations/microsoft-teams/callback/route.ts @@ -1,7 +1,12 @@ import { randomBytes } from "node:crypto"; -import { db } from "@superset/db/client"; import type { MicrosoftTeamsConfig } from "@superset/db/schema"; -import { integrationConnections } from "@superset/db/schema"; +import { + connectionConflict, + connectorMethod, + orgConnection, + requireConnector, + upsertConnection, +} from "@superset/trpc/connectors"; import { acquireAppToken, deleteTeamsSubscriptions, @@ -9,15 +14,10 @@ import { graphRequest, microsoftCredentials, } from "@superset/trpc/integrations/microsoft-teams"; -import { and, eq } from "drizzle-orm"; import { env } from "@/env"; import { posthog } from "@/lib/analytics"; import { resolveCallback } from "@/lib/integrations/resolveCallback"; -import { - connectionConflict, - upsertConnection, -} from "@/lib/integrations/upsertConnection"; import { createSignedState } from "@/lib/oauth-state"; import { IDENTITY_REDIRECT_URI, @@ -105,12 +105,8 @@ export async function GET(request: Request) { // A reconnect replaces the clientState below, so whatever subscriptions the // previous connection held would only ever be refused. Remove them from // Graph while their ids are still on the row. - const previous = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, organizationId), - eq(integrationConnections.provider, "microsoft_teams"), - ), - columns: { id: true }, + const previous = await orgConnection(organizationId, "microsoft_teams", { + includeDisconnected: true, }); if (previous) await deleteTeamsSubscriptions(previous.id); @@ -124,15 +120,28 @@ export async function GET(request: Request) { }; const externalOrgName = await tenantDisplayName(token.accessToken, tenantId); + const connector = requireConnector("microsoft_teams"); const result = await upsertConnection({ + connector, + slug: "microsoft_teams", + authMethod: connectorMethod(connector, "admin_consent").type, organizationId, userId, - provider: "microsoft_teams", - accessToken: token.accessToken, - tokenExpiresAt: token.expiresAt, - externalOrgId: tenantId, - externalOrgName, - config, + tokens: { + accessToken: token.accessToken, + refreshToken: null, + expiresAt: token.expiresAt, + scopes: null, + stored: {}, + raw: {}, + }, + identity: { + // The tenant id, not Graph's directory object id: it is the path + // segment every client-credentials token acquisition needs. + account: { id: tenantId, label: externalOrgName }, + user: null, + }, + state: config, }); if (result.conflict) { return fail( diff --git a/apps/api/src/app/api/integrations/microsoft-teams/jobs/renew-subscriptions/route.ts b/apps/api/src/app/api/integrations/microsoft-teams/jobs/renew-subscriptions/route.ts index dcfd21c791b..4547c6c0850 100644 --- a/apps/api/src/app/api/integrations/microsoft-teams/jobs/renew-subscriptions/route.ts +++ b/apps/api/src/app/api/integrations/microsoft-teams/jobs/renew-subscriptions/route.ts @@ -1,7 +1,5 @@ -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; +import { connectorConnections } from "@superset/trpc/connectors"; import { ensureTeamsSubscriptions } from "@superset/trpc/integrations/microsoft-teams"; -import { and, eq, isNull } from "drizzle-orm"; import { verifyQstashRequest } from "@/lib/verifyQstash"; @@ -26,22 +24,14 @@ export async function POST(request: Request): Promise { ); if (rejected) return rejected; - const connections = await db - .select({ id: integrationConnections.id }) - .from(integrationConnections) - .where( - and( - eq(integrationConnections.provider, "microsoft_teams"), - isNull(integrationConnections.disconnectedAt), - ), - ); + const rows = await connectorConnections("microsoft_teams"); const results = await Promise.allSettled( - connections.map((connection) => ensureTeamsSubscriptions(connection.id)), + rows.map((connection) => ensureTeamsSubscriptions(connection.id)), ); const failures = results.flatMap((result, index) => { - const connectionId = connections[index]?.id; + const connectionId = rows[index]?.id; if (result.status === "rejected") { return [{ connectionId, reason: String(result.reason) }]; } @@ -58,7 +48,7 @@ export async function POST(request: Request): Promise { } return Response.json({ - connections: connections.length, + connections: rows.length, failed: failures.length, }); } diff --git a/apps/api/src/app/api/integrations/microsoft-teams/notify/notifications.ts b/apps/api/src/app/api/integrations/microsoft-teams/notify/notifications.ts index c2999f6e629..bb190c0a110 100644 --- a/apps/api/src/app/api/integrations/microsoft-teams/notify/notifications.ts +++ b/apps/api/src/app/api/integrations/microsoft-teams/notify/notifications.ts @@ -1,7 +1,5 @@ import { timingSafeEqual } from "node:crypto"; -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; +import { accountConnection, connectionById } from "@superset/trpc/connectors"; import { z } from "zod"; /** @@ -78,32 +76,22 @@ export type AuthenticatedConnection = { export async function loadConnection( connectionId: string, ): Promise { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.id, connectionId), - eq(integrationConnections.provider, "microsoft_teams"), - isNull(integrationConnections.disconnectedAt), - ), - columns: { - id: true, - organizationId: true, - externalOrgId: true, - config: true, - }, + const connection = await connectionById(connectionId, { + connector: "microsoft_teams", }); if ( !connection || - !connection.externalOrgId || - connection.config?.provider !== "microsoft_teams" + !connection.externalAccountId || + connection.state?.provider !== "microsoft_teams" ) { return null; } return { id: connection.id, organizationId: connection.organizationId, - tenantId: connection.externalOrgId, - clientState: connection.config.clientState, - subscriptions: connection.config.subscriptions, + tenantId: connection.externalAccountId, + clientState: connection.state.clientState, + subscriptions: connection.state.subscriptions, }; } @@ -119,24 +107,15 @@ export async function authenticateNotification(notification: { | { ok: true; connection: AuthenticatedConnection } | { ok: false; reason: string } > { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.provider, "microsoft_teams"), - eq(integrationConnections.externalOrgId, notification.tenantId), - isNull(integrationConnections.disconnectedAt), - ), - columns: { - id: true, - organizationId: true, - externalOrgId: true, - config: true, - }, - }); - if (!connection || connection.config?.provider !== "microsoft_teams") { + const connection = await accountConnection( + "microsoft_teams", + notification.tenantId, + ); + if (!connection || connection.state?.provider !== "microsoft_teams") { return { ok: false, reason: "unknown tenant" }; } if ( - !equalClientState(connection.config.clientState, notification.clientState) + !equalClientState(connection.state.clientState, notification.clientState) ) { return { ok: false, reason: "clientState mismatch" }; } @@ -146,8 +125,8 @@ export async function authenticateNotification(notification: { id: connection.id, organizationId: connection.organizationId, tenantId: notification.tenantId, - clientState: connection.config.clientState, - subscriptions: connection.config.subscriptions, + clientState: connection.state.clientState, + subscriptions: connection.state.subscriptions, }, }; } diff --git a/apps/api/src/app/api/integrations/notion/callback/route.ts b/apps/api/src/app/api/integrations/notion/callback/route.ts index d0dbdd14ded..75dcf437885 100644 --- a/apps/api/src/app/api/integrations/notion/callback/route.ts +++ b/apps/api/src/app/api/integrations/notion/callback/route.ts @@ -1,9 +1,13 @@ +import { + connectorMethod, + requireConnector, + upsertConnection, +} from "@superset/trpc/connectors"; import { NOTION_VERSION } from "@superset/trpc/integrations/notion"; import { z } from "zod"; import { env } from "@/env"; import { resolveCallback } from "@/lib/integrations/resolveCallback"; -import { upsertConnection } from "@/lib/integrations/upsertConnection"; import { upsertIdentity } from "@/lib/integrations/upsertIdentity"; /** @@ -79,14 +83,30 @@ export async function GET(request: Request) { } const token = parsed.data; + const connector = requireConnector("notion"); const result = await upsertConnection({ + connector, + slug: "notion", + authMethod: connectorMethod(connector, "oauth2").type, organizationId, userId, - provider: "notion", - accessToken: token.access_token, - refreshToken: token.refresh_token ?? null, - externalOrgId: token.workspace_id, - externalOrgName: token.workspace_name ?? null, + tokens: { + accessToken: token.access_token, + refreshToken: token.refresh_token ?? null, + expiresAt: null, + scopes: null, + stored: {}, + raw: token as unknown as Record, + }, + identity: { + account: { id: token.workspace_id, label: token.workspace_name ?? null }, + // The bot id stands in when the grant names no Notion person: a + // user-scoped connection has to carry an external user either way. + user: { + id: token.owner.user?.id ?? token.bot_id, + label: token.owner.user?.name ?? null, + }, + }, }); if (result.conflict) { return Response.redirect(`${settingsUrl}?error=workspace_already_linked`); diff --git a/apps/api/src/app/api/integrations/notion/webhook/route.ts b/apps/api/src/app/api/integrations/notion/webhook/route.ts index 662d93e7337..f6d4b066dac 100644 --- a/apps/api/src/app/api/integrations/notion/webhook/route.ts +++ b/apps/api/src/app/api/integrations/notion/webhook/route.ts @@ -1,8 +1,12 @@ import { createHash } from "node:crypto"; import { db } from "@superset/db/client"; -import type { SelectIntegrationConnection } from "@superset/db/schema"; -import { integrationConnections, webhookEvents } from "@superset/db/schema"; -import { and, asc, eq, isNull } from "drizzle-orm"; +import type { SelectConnection } from "@superset/db/schema"; +import { webhookEvents } from "@superset/db/schema"; +import { + accountConnections, + connectionAccessToken, +} from "@superset/trpc/connectors"; +import { eq } from "drizzle-orm"; import { env } from "@/env"; import { ingestAutomationEvent } from "@/lib/automations/ingestAutomationEvent"; @@ -95,16 +99,9 @@ export async function POST(request: Request) { return Response.json({ success: true, status: "ignored" }); } - const connections = await db.query.integrationConnections.findMany({ - where: and( - eq(integrationConnections.provider, "notion"), - eq(integrationConnections.externalOrgId, event.workspace_id), - isNull(integrationConnections.disconnectedAt), - ), - orderBy: [asc(integrationConnections.id)], - }); + const subscribers = await accountConnections("notion", event.workspace_id); - if (connections.length === 0) { + if (subscribers.length === 0) { console.log( "[notion/webhook] No active connections for workspace:", event.workspace_id, @@ -113,7 +110,7 @@ export async function POST(request: Request) { } const results = await Promise.all( - connections.map((connection) => + subscribers.map((connection) => processForConnection(event, connection).catch((error) => ({ connectionId: connection.id, outcome: "failed" as const, @@ -142,7 +139,7 @@ export async function POST(request: Request) { async function processForConnection( event: NotionWebhookEvent, - connection: SelectIntegrationConnection, + connection: SelectConnection, ): Promise<{ connectionId: string; outcome: "processed" | "skipped" | "failed"; @@ -179,7 +176,7 @@ async function processForConnection( await normalizeNotionDelivery({ organizationId: connection.organizationId, connectionId: connection.id, - accessToken: connection.accessToken, + accessToken: await connectionAccessToken(connection), event, webhookEventId: webhookEvent.id, }), diff --git a/apps/api/src/app/api/integrations/sentry/callback/route.ts b/apps/api/src/app/api/integrations/sentry/callback/route.ts index e94b2e1137a..bbf0cf1653b 100644 --- a/apps/api/src/app/api/integrations/sentry/callback/route.ts +++ b/apps/api/src/app/api/integrations/sentry/callback/route.ts @@ -1,4 +1,9 @@ import type { SentryConfig } from "@superset/db/schema"; +import { + connectorMethod, + requireConnector, + upsertConnection, +} from "@superset/trpc/connectors"; import { exchangeSentryCode, fetchSentryOrganization, @@ -7,7 +12,6 @@ import { import { env } from "@/env"; import { resolveCallback } from "@/lib/integrations/resolveCallback"; -import { upsertConnection } from "@/lib/integrations/upsertConnection"; import { SENTRY_STATE_COOKIE } from "../connect/route"; /** @@ -66,16 +70,26 @@ export async function GET(request: Request) { regionUrl: organization.regionUrl, }; + const connector = requireConnector("sentry"); const result = await upsertConnection({ + connector, + slug: "sentry", + authMethod: connectorMethod(connector, "app_install").type, organizationId, userId, - provider: "sentry", - accessToken: token.token, - refreshToken: token.refreshToken, - tokenExpiresAt: new Date(token.expiresAt), - externalOrgId: organization.slug, - externalOrgName: organization.name, - config, + tokens: { + accessToken: token.token, + refreshToken: token.refreshToken, + expiresAt: new Date(token.expiresAt), + scopes: null, + stored: {}, + raw: token as unknown as Record, + }, + identity: { + account: { id: organization.slug, label: organization.name }, + user: null, + }, + state: config, }); if (result.conflict) { // Who holds it, so the message can name someone to ask — the blocked org diff --git a/apps/api/src/app/api/integrations/sentry/webhook/route.ts b/apps/api/src/app/api/integrations/sentry/webhook/route.ts index b1c60416da5..150ab7fbdd4 100644 --- a/apps/api/src/app/api/integrations/sentry/webhook/route.ts +++ b/apps/api/src/app/api/integrations/sentry/webhook/route.ts @@ -1,5 +1,5 @@ import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; +import { connections } from "@superset/db/schema"; import { disconnectSentry } from "@superset/trpc/integrations/sentry"; import { and, eq, sql } from "drizzle-orm"; @@ -33,13 +33,21 @@ const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; /** The connection an installation uuid belongs to, active or not. */ async function connectionByInstallation(installationUuid: string) { - return db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.provider, "sentry"), - sql`${integrationConnections.config}->>'installationUuid' = ${installationUuid}`, - ), - columns: { id: true, organizationId: true, disconnectedAt: true }, - }); + const [row] = await db + .select({ + id: connections.id, + organizationId: connections.organizationId, + disconnectedAt: connections.disconnectedAt, + }) + .from(connections) + .where( + and( + eq(connections.connector, "sentry"), + sql`${connections.state}->>'installationUuid' = ${installationUuid}`, + ), + ) + .limit(1); + return row; } export async function POST(request: Request) { diff --git a/apps/api/src/app/api/integrations/slack/callback/route.ts b/apps/api/src/app/api/integrations/slack/callback/route.ts index 9d52b580520..67aa62ccd47 100644 --- a/apps/api/src/app/api/integrations/slack/callback/route.ts +++ b/apps/api/src/app/api/integrations/slack/callback/route.ts @@ -1,10 +1,13 @@ import { WebClient } from "@slack/web-api"; -import type { SlackConfig } from "@superset/db/schema"; +import { + connectorMethod, + requireConnector, + upsertConnection, +} from "@superset/trpc/connectors"; import { env } from "@/env"; import { posthog } from "@/lib/analytics"; import { resolveCallback } from "@/lib/integrations/resolveCallback"; -import { upsertConnection } from "@/lib/integrations/upsertConnection"; const settingsUrl = `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack`; @@ -32,18 +35,38 @@ export async function GET(request: Request) { return Response.redirect(`${settingsUrl}?error=slack_api_error`); } - const config: SlackConfig = { - provider: "slack", - }; - + // This flow asks for bot scopes only, so `access_token` is the workspace + // bot token. It is stored in both places: `config.bot_token` is where + // everything that posts as the app reads it, and the access token is + // what a connector-shaped caller binds. + const botToken = tokenData.access_token; + const connector = requireConnector("slack"); const result = await upsertConnection({ + connector, + slug: "slack", + authMethod: connectorMethod(connector, "oauth2").type, organizationId, userId, - provider: "slack", - accessToken: tokenData.access_token, - externalOrgId: tokenData.team.id, - externalOrgName: tokenData.team.name, - config, + tokens: { + accessToken: botToken, + refreshToken: null, + expiresAt: null, + scopes: tokenData.scope ? tokenData.scope.split(",") : null, + stored: { + bot_token: botToken, + bot_user_id: tokenData.bot_user_id ?? null, + slack_user_id: tokenData.authed_user?.id ?? null, + }, + raw: tokenData as unknown as Record, + }, + identity: { + account: { id: tokenData.team.id, label: tokenData.team.name ?? null }, + user: { + id: tokenData.authed_user?.id ?? tokenData.bot_user_id ?? userId, + label: null, + }, + }, + state: { provider: "slack" }, }); if (result.conflict) { const owner = result.conflict.ownerEmail diff --git a/apps/api/src/app/api/integrations/slack/events/process-agent-message/process-agent-message.test.ts b/apps/api/src/app/api/integrations/slack/events/process-agent-message/process-agent-message.test.ts index 7c96d4b5ed4..9abf0769060 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-agent-message/process-agent-message.test.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-agent-message/process-agent-message.test.ts @@ -41,16 +41,17 @@ const findLink = mock( mock.module("@superset/db/client", () => ({ db: { query: { - integrationConnections: { - findFirst: async () => ({ - organizationId: "org", - accessToken: "token", - }), - }, subscriptions: { findFirst: async () => ({ id: "subscription" }) }, }, }, })); +// `mock.module` is process-wide, so every export the real module has must be +// here: another file's import of one of these resolves against this stub too. +mock.module("@superset/trpc/connectors", () => ({ + accountConnection: async () => ({ organizationId: "org" }), + accountConnections: async () => [{ organizationId: "org" }], + connectionBotToken: async () => "token", +})); mock.module("@/env", () => ({ env: { NEXT_PUBLIC_WEB_URL: "https://app.superset.sh", diff --git a/apps/api/src/app/api/integrations/slack/events/process-agent-message/process-agent-message.ts b/apps/api/src/app/api/integrations/slack/events/process-agent-message/process-agent-message.ts index 56ef88a2316..eaa4e3d0e90 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-agent-message/process-agent-message.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-agent-message/process-agent-message.ts @@ -1,7 +1,11 @@ import { db } from "@superset/db/client"; -import { integrationConnections, subscriptions } from "@superset/db/schema"; +import { subscriptions } from "@superset/db/schema"; +import { + accountConnection, + connectionBotToken, +} from "@superset/trpc/connectors"; import { Client as QStash } from "@upstash/qstash"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { env } from "@/env"; import { posthog } from "@/lib/analytics"; import { findSlackUserLink } from "../../lib/find-slack-user-link"; @@ -109,17 +113,7 @@ export async function processAgentMessage({ user: event.user, }); - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.provider, "slack"), - eq(integrationConnections.externalOrgId, teamId), - isNull(integrationConnections.disconnectedAt), - ), - orderBy: [ - desc(integrationConnections.updatedAt), - desc(integrationConnections.id), - ], - }); + const connection = await accountConnection("slack", teamId); if (!connection) { console.error( @@ -129,7 +123,8 @@ export async function processAgentMessage({ return; } - const slack = createSlackClient(connection.accessToken); + const botToken = await connectionBotToken(connection); + const slack = createSlackClient(botToken); const [slackUserLink, activeSubscription] = await Promise.all([ event.user @@ -266,9 +261,9 @@ export async function processAgentMessage({ // a placeholder message that carries progress and is removed once the final // reply exists, so the thread ends with one notifying message. const deadline = Date.now() + RUN_BUDGET_MS; - const run = createSlackClient(connection.accessToken, { deadline }); + const run = createSlackClient(botToken, { deadline }); const replyDeadline = deadline + REPLY_BUDGET_MS; - const reply = createSlackClient(connection.accessToken, { + const reply = createSlackClient(botToken, { deadline: replyDeadline, }); let placeholderTs: string | undefined; @@ -394,7 +389,7 @@ export async function processAgentMessage({ const imageAssets = await extractSlackImageAssets({ eventFiles: event.files, slack: run, - slackToken: connection.accessToken, + slackToken: botToken, deadline, }); @@ -437,7 +432,7 @@ export async function processAgentMessage({ messageTs: event.ts, organizationId: connection.organizationId, userId: slackUserLink.userId, - slackToken: connection.accessToken, + slackToken: botToken, model: slackUserLink.modelPreference ?? undefined, images: imageAssets, deadline, diff --git a/apps/api/src/app/api/integrations/slack/events/process-app-home-opened/process-app-home-opened.ts b/apps/api/src/app/api/integrations/slack/events/process-app-home-opened/process-app-home-opened.ts index 4b6463f424e..a8b991c1308 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-app-home-opened/process-app-home-opened.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-app-home-opened/process-app-home-opened.ts @@ -1,6 +1,7 @@ -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { + accountConnection, + connectionBotToken, +} from "@superset/trpc/connectors"; import { findSlackUserLink } from "../../lib/find-slack-user-link"; import { generateConnectUrl } from "../utils/generate-connect-url"; import { createSlackClient } from "../utils/slack-client"; @@ -16,17 +17,7 @@ export async function processAppHomeOpened({ event, teamId, }: ProcessAppHomeOpenedParams): Promise { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.provider, "slack"), - eq(integrationConnections.externalOrgId, teamId), - isNull(integrationConnections.disconnectedAt), - ), - orderBy: [ - desc(integrationConnections.updatedAt), - desc(integrationConnections.id), - ], - }); + const connection = await accountConnection("slack", teamId); if (!connection) { console.error( @@ -49,13 +40,13 @@ export async function processAppHomeOpened({ ? undefined : generateConnectUrl({ slackUserId: event.user, teamId }); - const slack = createSlackClient(connection.accessToken); + const slack = createSlackClient(await connectionBotToken(connection)); await slack.views.publish({ user_id: event.user, view: buildHomeView({ modelPreference: slackUserLink?.modelPreference ?? undefined, - externalOrgName: connection.externalOrgName ?? undefined, + externalOrgName: connection.externalAccountLabel ?? undefined, isUserLinked, userName: userName ?? undefined, connectUrl, diff --git a/apps/api/src/app/api/integrations/slack/events/process-automation-event/process-automation-event.ts b/apps/api/src/app/api/integrations/slack/events/process-automation-event/process-automation-event.ts index 6fa13e2d0bd..279fbccdc13 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-automation-event/process-automation-event.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-automation-event/process-automation-event.ts @@ -1,7 +1,6 @@ import type { SlackEvent } from "@slack/types"; import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; +import { accountConnection } from "@superset/trpc/connectors"; import { type IngestOutcome, @@ -50,14 +49,7 @@ export function isAutomationEvent(envelope: { export async function processAutomationEvent( envelope: SlackAutomationEnvelope, ): Promise { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.provider, "slack"), - eq(integrationConnections.externalOrgId, envelope.team_id), - isNull(integrationConnections.disconnectedAt), - ), - columns: { id: true, organizationId: true }, - }); + const connection = await accountConnection("slack", envelope.team_id); if (!connection) return { status: "skipped", reason: "unknown workspace" }; return ingestAutomationEvent( diff --git a/apps/api/src/app/api/integrations/slack/events/process-entity-details/process-entity-details.ts b/apps/api/src/app/api/integrations/slack/events/process-entity-details/process-entity-details.ts index f2959f5b155..31ae22bc273 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-entity-details/process-entity-details.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-entity-details/process-entity-details.ts @@ -1,7 +1,11 @@ import type { SlackEvent } from "@slack/types"; import { db } from "@superset/db/client"; -import { integrationConnections, tasks } from "@superset/db/schema"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { tasks } from "@superset/db/schema"; +import { + accountConnection, + connectionBotToken, +} from "@superset/trpc/connectors"; +import { and, eq } from "drizzle-orm"; import { createSlackClient } from "../utils/slack-client"; import { createTaskFlexpaneObject, @@ -32,17 +36,7 @@ export async function processEntityDetails({ externalRef: event.external_ref, }); - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.provider, "slack"), - eq(integrationConnections.externalOrgId, teamId), - isNull(integrationConnections.disconnectedAt), - ), - orderBy: [ - desc(integrationConnections.updatedAt), - desc(integrationConnections.id), - ], - }); + const connection = await accountConnection("slack", teamId); if (!connection) { console.error( @@ -52,7 +46,7 @@ export async function processEntityDetails({ return; } - const slack = createSlackClient(connection.accessToken); + const slack = createSlackClient(await connectionBotToken(connection)); const taskSlug = parseTaskSlugFromUrl(event.entity_url); diff --git a/apps/api/src/app/api/integrations/slack/events/process-link-shared/process-link-shared.ts b/apps/api/src/app/api/integrations/slack/events/process-link-shared/process-link-shared.ts index 589778702cb..d529c6c428e 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-link-shared/process-link-shared.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-link-shared/process-link-shared.ts @@ -1,7 +1,11 @@ import type { EntityMetadata, LinkSharedEvent } from "@slack/types"; import { db } from "@superset/db/client"; -import { integrationConnections, tasks } from "@superset/db/schema"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { tasks } from "@superset/db/schema"; +import { + accountConnection, + connectionBotToken, +} from "@superset/trpc/connectors"; +import { and, eq } from "drizzle-orm"; import { createSlackClient } from "../utils/slack-client"; import { createTaskWorkObject, @@ -25,17 +29,7 @@ export async function processLinkShared({ linkCount: event.links.length, }); - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.provider, "slack"), - eq(integrationConnections.externalOrgId, teamId), - isNull(integrationConnections.disconnectedAt), - ), - orderBy: [ - desc(integrationConnections.updatedAt), - desc(integrationConnections.id), - ], - }); + const connection = await accountConnection("slack", teamId); if (!connection) { console.error( @@ -45,7 +39,7 @@ export async function processLinkShared({ return; } - const slack = createSlackClient(connection.accessToken); + const slack = createSlackClient(await connectionBotToken(connection)); const entities: EntityMetadata[] = []; diff --git a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/plugin-tools.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/plugin-tools.ts index 0c69caa94d3..a3769f9a904 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/plugin-tools.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/plugin-tools.ts @@ -1,14 +1,25 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { - type ConnectionContext, - callTool, - listTools, - type ToolDefinition, - templateScope, - toolConnections, -} from "@superset/trpc/integrations/plugins"; + AmbiguousPluginError, + buildPluginServer, + PluginTargetError, + resolveTarget, +} from "@superset/trpc/plugins-proxy"; + +export type ToolDefinition = Tool; + +/** One connected client per plugin, over the same server the MCP endpoint serves. */ +export interface PluginSession { + client: Client; + /** Per installation: the same plugin from two marketplaces lists different tools. */ + cacheKey: string; + close: () => Promise; +} export interface PluginToolSet { - context: ConnectionContext; + context: PluginSession; tools: ToolDefinition[]; } @@ -16,6 +27,7 @@ export interface PluginTools { sets: Map; /** False when connections could not be listed: absence then means unknown, not unconnected. */ resolved: boolean; + close: () => Promise; } export interface ToolCallResult { @@ -36,70 +48,101 @@ export function invalidatePluginToolCache(): void { toolListCache.clear(); } -/** Per installation: the same plugin from two marketplaces can list different tools. */ -function cacheKey({ connection, install }: ConnectionContext): string { - return `${install.id}:${install.marketplace}/${connection.pluginName}@${install.manifest.version}:${connection.authMethod}`; +async function openSession( + userId: string, + organizationId: string | null, + plugin: string, +): Promise { + const target = await resolveTarget({ userId, organizationId, plugin }); + if (target.kind === "needs-auth") return null; + + const server = await buildPluginServer(target); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "slack-agent", version: "1.0.0" }); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + return { + client, + cacheKey: `${target.connectionId}:${target.plugin}@${target.version}`, + close: async () => { + await client.close(); + await server.close(); + }, + }; } async function cachedListTools( - context: ConnectionContext, + session: PluginSession, signal: AbortSignal, ): Promise { - const key = cacheKey(context); - const cached = toolListCache.get(key); + const cached = toolListCache.get(session.cacheKey); if (cached && cached.expiresAt > Date.now()) return cached.tools; - const tools = await listTools( - context.install.manifest, - await templateScope(context.connection), - context.connection.authMethod, - context.source, - { signal }, - ); - toolListCache.set(key, { tools, expiresAt: Date.now() + TOOL_LIST_TTL_MS }); + const { tools } = await session.client.listTools(undefined, { signal }); + toolListCache.set(session.cacheKey, { + tools, + expiresAt: Date.now() + TOOL_LIST_TTL_MS, + }); return tools; } -/** The newest connection wins when a user holds several for one plugin. */ export async function loadPluginTools({ userId, + organizationId, pluginNames, signal, }: { userId: string; + organizationId: string | null; pluginNames: Iterable; signal: AbortSignal; }): Promise { - const wanted = new Set(pluginNames); - const byPlugin = new Map(); - let contexts: ConnectionContext[]; - try { - contexts = await toolConnections(userId); - } catch (error) { - console.warn("[slack-agent] Skipping plugin tools this run:", error); - return { sets: new Map(), resolved: false }; - } - for (const context of contexts) { - const name = context.connection.pluginName; - if (wanted.has(name) && !byPlugin.has(name)) byPlugin.set(name, context); - } - const sets = new Map(); + let resolved = true; + await Promise.all( - [...byPlugin.values()].map(async (context) => { - const name = context.connection.pluginName; + [...new Set(pluginNames)].map(async (name) => { + let session: PluginSession | null = null; + try { + session = await openSession(userId, organizationId, name); + } catch (error) { + // Not installed is an answer; anything else leaves this plugin's + // connection state unknown, so the brief must not claim either. + const absent = + (error instanceof PluginTargetError && error.status === 404) || + error instanceof AmbiguousPluginError; + if (!absent) resolved = false; + console.warn(`[slack-agent] Skipping ${name} tools:`, error); + return; + } + // Not connected: absent from the sets means "Connect" in the brief. + if (!session) return; try { sets.set(name, { - context, - tools: await cachedListTools(context, signal), + context: session, + tools: await cachedListTools(session, signal), }); } catch (error) { + // The connection is real, so it stays in the sets with no tools + // rather than being reported as unconnected. console.warn(`[slack-agent] Skipping ${name} tools:`, error); - sets.set(name, { context, tools: [] }); + sets.set(name, { context: session, tools: [] }); } }), ); - return { sets, resolved: true }; + + return { + sets, + resolved, + close: async () => { + await Promise.all( + [...sets.values()].map((set) => set.context.close().catch(() => {})), + ); + }, + }; } export async function callPluginTool({ @@ -108,18 +151,14 @@ export async function callPluginTool({ args, signal, }: { - context: ConnectionContext; + context: PluginSession; tool: string; args: Record; signal: AbortSignal; }): Promise { - const result = await callTool( - context.install.manifest, - await templateScope(context.connection), - tool, - args, - context.connection.authMethod, - context.source, + const result = await context.client.callTool( + { name: tool, arguments: args }, + undefined, { signal }, ); return (result ?? {}) as ToolCallResult; diff --git a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.test.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.test.ts index 03d81cea04e..fd457be35f2 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.test.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.test.ts @@ -28,7 +28,13 @@ const listTools = mock(async () => ({ }), ), })); -const toolConnections = mock(async (_userId: string): Promise => []); +const resolveTarget = mock( + async (_request: { + plugin: string; + userId: string; + organizationId: string | null; + }): Promise | null> => null, +); const pluginListTools = mock( async ( ..._args: unknown[] @@ -44,13 +50,47 @@ class FakeAPIError extends Error { } class FakeConnectionError extends FakeAPIError {} class FakeTimeoutError extends FakeConnectionError {} -mock.module("@superset/trpc/integrations/plugins", () => ({ - toolConnections, - listTools: pluginListTools, - callTool: pluginCallTool, - templateScope: async (connection: { pluginName: string }) => ({ - config: { access_token: `${connection.pluginName}-token` }, - }), +class FakePluginTargetError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} +class FakeAmbiguousPluginError extends Error {} +// The real in-memory client/server pair runs; only target resolution and the +// two request handlers behind it are faked. +mock.module("@superset/trpc/plugins-proxy", () => ({ + AmbiguousPluginError: FakeAmbiguousPluginError, + PluginTargetError: FakePluginTargetError, + resolveTarget, + buildPluginServer: async (target: { plugin: string }) => { + const { Server } = await import( + "@modelcontextprotocol/sdk/server/index.js" + ); + const { CallToolRequestSchema, ListToolsRequestSchema } = await import( + "@modelcontextprotocol/sdk/types.js" + ); + const server = new Server( + { name: target.plugin, version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + server.setRequestHandler( + ListToolsRequestSchema, + async (_request, extra) => ({ + tools: await pluginListTools(target.plugin, extra?.signal), + }), + ); + server.setRequestHandler(CallToolRequestSchema, async (request) => ({ + ...(await pluginCallTool( + target.plugin, + request.params.name, + request.params.arguments ?? {}, + )), + })); + return server; + }, })); mock.module("@anthropic-ai/sdk", () => ({ default: class { @@ -108,19 +148,25 @@ const toolResponse = ( stop_reason: "tool_use", content: [{ type: "tool_use", id: "tool-1", name, input }], }); -const pluginContext = (pluginName: string, version = "1.0.0") => ({ - connection: { - id: `${pluginName}-connection`, - pluginName, - authMethod: "oauth2", - }, - install: { - id: `${pluginName}-install`, - manifest: { name: pluginName, version }, - marketplace: "superset", - }, - source: null, +const pluginContext = ( + pluginName: string, + version = "1.0.0", + connectionId = `${pluginName}-install`, +) => ({ + kind: "first-party" as const, + plugin: pluginName, + version, + connectionId, }); +/** Resolve only the named plugins; everything else reads as not connected. */ +const connectPlugins = ( + ...targets: Array> +) => { + resolveTarget.mockImplementation(async ({ plugin }) => { + const match = targets.find((target) => target.plugin === plugin); + return match ?? { kind: "needs-auth", plugin, version: "0.0.0" }; + }); +}; const textResult = (value: unknown) => ({ content: [{ type: "text", text: JSON.stringify(value) }], }); @@ -146,8 +192,8 @@ beforeEach(() => { callTool.mockReset(); callTool.mockImplementation(async () => ({})); cleanup.mockClear(); - toolConnections.mockReset(); - toolConnections.mockImplementation(async () => []); + resolveTarget.mockReset(); + connectPlugins(); pluginListTools.mockReset(); pluginListTools.mockImplementation(async () => []); pluginCallTool.mockReset(); @@ -595,15 +641,13 @@ describe("plugin tools", () => { ]; const githubListing = [ { name: "create_pull_request", inputSchema: { type: "object" } }, - { name: "merge_pull_request" }, + { name: "merge_pull_request", inputSchema: { type: "object" } }, ]; - const listingFor = async (manifest: unknown) => - (manifest as { name: string }).name === "linear" - ? linearListing - : githubListing; + const listingFor = async (plugin: unknown) => + plugin === "linear" ? linearListing : githubListing; test("registers curated tools for connected plugins only and briefs the model", async () => { - toolConnections.mockImplementation(async () => [pluginContext("linear")]); + connectPlugins(pluginContext("linear")); pluginListTools.mockImplementation(listingFor); const result = await runSlackAgent({ ...params, @@ -618,10 +662,9 @@ describe("plugin tools", () => { expect(contextualSystemText()).toContain("Linear is connected"); expect(contextualSystemText()).toContain("GitHub is not connected"); expect(result.unconnectedPlugins.map((p) => p.name)).toEqual(["github"]); - expect(toolConnections).toHaveBeenCalledWith("user"); - expect(pluginListTools.mock.calls[0]?.[4]).toMatchObject({ - signal: expect.any(AbortSignal), - }); + expect(resolveTarget).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user", organizationId: "org" }), + ); }); test("says nothing about a plugin that is neither connected nor asked for", async () => { @@ -635,7 +678,7 @@ describe("plugin tools", () => { }); test("refuses a plugin tool outside the curated subset at execution", async () => { - toolConnections.mockImplementation(async () => [pluginContext("linear")]); + connectPlugins(pluginContext("linear")); pluginListTools.mockImplementation(listingFor); create.mockImplementationOnce(async () => toolResponse("linear_delete_comment", { id: "c1" }), @@ -649,7 +692,7 @@ describe("plugin tools", () => { }); test("refuses a curated tool for a plugin the user has not connected", async () => { - toolConnections.mockImplementation(async () => [pluginContext("linear")]); + connectPlugins(pluginContext("linear")); pluginListTools.mockImplementation(listingFor); create.mockImplementationOnce(async () => toolResponse("github_create_pull_request", { title: "Fix" }), @@ -659,10 +702,7 @@ describe("plugin tools", () => { }); test("routes linear_* calls to that connection with the remaining budget and records the issue", async () => { - toolConnections.mockImplementation(async () => [ - pluginContext("linear"), - pluginContext("github"), - ]); + connectPlugins(pluginContext("linear"), pluginContext("github")); pluginListTools.mockImplementation(listingFor); create.mockImplementationOnce(async () => toolResponse("linear_save_issue", { team: "SUP", title: "Bug" }), @@ -680,15 +720,10 @@ describe("plugin tools", () => { deadline: Date.now() + 30_000, }); expect(pluginCallTool).toHaveBeenCalledTimes(1); - const [manifest, scope, tool, args, method, source, options] = - pluginCallTool.mock.calls[0] ?? []; - expect(manifest).toMatchObject({ name: "linear" }); - expect(scope).toEqual({ config: { access_token: "linear-token" } }); + const [plugin, tool, args] = pluginCallTool.mock.calls[0] ?? []; + expect(plugin).toBe("linear"); expect(tool).toBe("save_issue"); expect(args).toEqual({ team: "SUP", title: "Bug" }); - expect(method).toBe("oauth2"); - expect(source).toBeNull(); - expect(options).toMatchObject({ signal: expect.any(AbortSignal) }); expect(result.actions).toEqual([ { type: "issue_created", @@ -704,7 +739,7 @@ describe("plugin tools", () => { }); test("an issue update through save_issue is not reported as a creation", async () => { - toolConnections.mockImplementation(async () => [pluginContext("linear")]); + connectPlugins(pluginContext("linear")); pluginListTools.mockImplementation(listingFor); create.mockImplementationOnce(async () => toolResponse("linear_save_issue", { id: "SUP-12", title: "Renamed" }), @@ -718,7 +753,7 @@ describe("plugin tools", () => { }); test("records an opened pull request from GitHub's minimal response", async () => { - toolConnections.mockImplementation(async () => [pluginContext("github")]); + connectPlugins(pluginContext("github")); pluginListTools.mockImplementation(listingFor); create.mockImplementationOnce(async () => toolResponse("github_create_pull_request", { @@ -749,8 +784,9 @@ describe("plugin tools", () => { }); test("a failure resolving plugin connections leaves the run with Superset tools only", async () => { - toolConnections.mockImplementationOnce(async () => { - throw new Error("ambiguous install"); + resolveTarget.mockImplementation(async ({ plugin }) => { + if (plugin === "linear") throw new Error("ambiguous install"); + return { kind: "needs-auth", plugin, version: "0.0.0" }; }); const result = await runSlackAgent({ ...params, @@ -776,12 +812,9 @@ describe("plugin tools", () => { expect(mentionsPlugin("linearize the model", linear)).toBe(false); }); test("a failing plugin listing skips that plugin without failing the run or calling it unconnected", async () => { - toolConnections.mockImplementation(async () => [ - pluginContext("linear"), - pluginContext("github"), - ]); - pluginListTools.mockImplementation(async (manifest) => { - if ((manifest as { name: string }).name === "github") { + connectPlugins(pluginContext("linear"), pluginContext("github")); + pluginListTools.mockImplementation(async (plugin) => { + if (plugin === "github") { throw new Error("Upstream returned 502 Bad Gateway"); } return linearListing; @@ -798,17 +831,11 @@ describe("plugin tools", () => { }); test("a plugin whose listing hangs costs that plugin, not the turn", async () => { - toolConnections.mockImplementation(async () => [pluginContext("linear")]); + connectPlugins(pluginContext("linear")); pluginListTools.mockImplementation( - ( - _m: unknown, - _s: unknown, - _a: unknown, - _src: unknown, - opts?: { signal?: AbortSignal }, - ) => + (_plugin: unknown, signal?: unknown) => new Promise((_resolve, reject) => { - opts?.signal?.addEventListener("abort", () => + (signal as AbortSignal | undefined)?.addEventListener("abort", () => reject(new Error("aborted")), ); }), @@ -825,30 +852,23 @@ describe("plugin tools", () => { }); test("the tool cache is per installation, not per plugin name", async () => { - const first = pluginContext("linear"); - const other = { - ...first, - install: { ...first.install, id: "install-2", marketplace: "acme" }, - }; pluginListTools.mockImplementation(listingFor); - toolConnections.mockImplementation(async () => [first]); + connectPlugins(pluginContext("linear")); await runSlackAgent(params); - toolConnections.mockImplementation(async () => [other]); + connectPlugins(pluginContext("linear", "1.0.0", "install-2")); await runSlackAgent(params); expect(pluginListTools).toHaveBeenCalledTimes(2); }); test("caches a plugin's tool listing across runs for the same plugin version and auth method", async () => { - toolConnections.mockImplementation(async () => [pluginContext("linear")]); + connectPlugins(pluginContext("linear")); pluginListTools.mockImplementation(listingFor); await runSlackAgent(params); await runSlackAgent(params); expect(pluginListTools).toHaveBeenCalledTimes(1); expect(requestToolNames(1)).toContain("linear_list_issues"); - toolConnections.mockImplementation(async () => [ - pluginContext("linear", "1.1.0"), - ]); + connectPlugins(pluginContext("linear", "1.1.0")); await runSlackAgent(params); expect(pluginListTools).toHaveBeenCalledTimes(2); }); diff --git a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts index 47b0d9fd522..301b929c6ab 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts @@ -760,6 +760,7 @@ export async function runSlackAgent( let supersetMcp: Client | null = null; let cleanupSuperset: (() => Promise) | null = null; + let closePlugins: (() => Promise) | null = null; try { const [threadContext, supersetMcpResult] = await Promise.all([ @@ -789,10 +790,12 @@ export async function runSlackAgent( }), loadPluginTools({ userId: params.userId, + organizationId: params.organizationId, pluginNames: Object.keys(PLUGIN_SLACK_TOOLS), signal: discoverySignal(), }), ]); + closePlugins = pluginLoad.close; const pluginToolSets = pluginLoad.sets; // Unresolved connections are unknown, not absent: no Connect prompt and // no "not connected" line on a transient failure. @@ -1152,5 +1155,10 @@ ${agentContext}`; await cleanupSuperset(); } catch {} } + if (closePlugins) { + try { + await closePlugins(); + } catch {} + } } } diff --git a/apps/api/src/app/api/integrations/slack/interactions/route.test.ts b/apps/api/src/app/api/integrations/slack/interactions/route.test.ts index 5b26809e985..b7fbd8bce1d 100644 --- a/apps/api/src/app/api/integrations/slack/interactions/route.test.ts +++ b/apps/api/src/app/api/integrations/slack/interactions/route.test.ts @@ -16,18 +16,24 @@ mock.module("@superset/db/client", () => ({ db: { query: { userIdentities: { findFirst: findSlackUser }, - // The route resolves the Slack workspace to an organization before it - // looks at any action. Returning a connection is what lets the tests - // below reach the action handling at all. - integrationConnections: { - findFirst: async () => ({ organizationId: "org-1" }), - }, }, update: () => ({ set: () => ({ where: async () => undefined }) }), delete: () => ({ where: async () => undefined }), }, })); +// The route resolves the Slack workspace to an organization before it looks at +// any action. Returning a connection is what lets the tests below reach the +// action handling at all. +// `mock.module` is process-wide, so every export the real module has must be +// here: another file's import of `connectionBotToken` resolves against this +// stub too. +mock.module("@superset/trpc/connectors", () => ({ + accountConnection: async () => ({ organizationId: "org-1" }), + accountConnections: async () => [{ organizationId: "org-1" }], + connectionBotToken: async () => "bot-token", +})); + mock.module("../verify-signature", () => ({ verifySlackSignature: () => true, })); diff --git a/apps/api/src/app/api/integrations/slack/interactions/route.ts b/apps/api/src/app/api/integrations/slack/interactions/route.ts index 299cdd29c0b..0c556d99346 100644 --- a/apps/api/src/app/api/integrations/slack/interactions/route.ts +++ b/apps/api/src/app/api/integrations/slack/interactions/route.ts @@ -1,6 +1,7 @@ import { db } from "@superset/db/client"; -import { integrationConnections, userIdentities } from "@superset/db/schema"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { userIdentities } from "@superset/db/schema"; +import { accountConnection } from "@superset/trpc/connectors"; +import { and, eq } from "drizzle-orm"; import { posthog } from "@/lib/analytics"; import { DEFAULT_SLACK_MODEL } from "../constants"; import { processAppHomeOpened } from "../events/process-app-home-opened"; @@ -70,18 +71,7 @@ export async function POST(request: Request) { // The identity is scoped by organization, and an interaction payload only // names the Slack workspace — so resolve the connection to get one. - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.provider, "slack"), - eq(integrationConnections.externalOrgId, teamId), - isNull(integrationConnections.disconnectedAt), - ), - columns: { organizationId: true }, - orderBy: [ - desc(integrationConnections.updatedAt), - desc(integrationConnections.id), - ], - }); + const connection = await accountConnection("slack", teamId); if (!connection) { console.warn("[slack/interactions] No active connection for team:", { teamId, diff --git a/apps/api/src/app/api/integrations/slack/link/route.ts b/apps/api/src/app/api/integrations/slack/link/route.ts index bcdfab2ccd6..2a22afa3904 100644 --- a/apps/api/src/app/api/integrations/slack/link/route.ts +++ b/apps/api/src/app/api/integrations/slack/link/route.ts @@ -1,9 +1,7 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { auth } from "@superset/auth/server"; -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; import { findOrgMembership } from "@superset/db/utils"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { accountConnection } from "@superset/trpc/connectors"; import { headers } from "next/headers"; import { env } from "@/env"; import { upsertIdentity } from "@/lib/integrations/upsertIdentity"; @@ -54,17 +52,7 @@ export async function GET(request: Request) { ); } - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.provider, "slack"), - eq(integrationConnections.externalOrgId, payload.teamId), - isNull(integrationConnections.disconnectedAt), - ), - orderBy: [ - desc(integrationConnections.updatedAt), - desc(integrationConnections.id), - ], - }); + const connection = await accountConnection("slack", payload.teamId); if (!connection) { return new Response( diff --git a/apps/api/src/app/api/plugins/[plugin]/connect/route.ts b/apps/api/src/app/api/plugins/[plugin]/connect/route.ts deleted file mode 100644 index 5381ae6bdb4..00000000000 --- a/apps/api/src/app/api/plugins/[plugin]/connect/route.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { auth } from "@superset/auth/server"; -import { - AmbiguousPluginError, - authMethod, - buildAuthorizationUrl, - installedManifest, - manifestAuth, -} from "@superset/trpc/integrations/plugins"; -import { createSignedState } from "@/lib/oauth-state"; - -/** - * Starts an OAuth2 connection by redirecting to the provider. - * - * Stays a raw route because it answers with a redirect the desktop opens in - * the system browser. Inputs travel through the signed state so the callback - * can re-resolve `${inputs.site}` when exchanging the code against a - * per-tenant token_url; api_key connections go through plugins.connectApiKey, - * so a credential never enters a URL. - */ -export async function GET( - request: Request, - { params }: { params: Promise<{ plugin: string }> }, -) { - const session = await auth.api.getSession({ headers: request.headers }); - if (!session?.user) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { plugin } = await params; - const url = new URL(request.url); - - let manifest: Awaited>; - try { - manifest = await installedManifest(session.user.id, plugin); - } catch (error) { - if (error instanceof AmbiguousPluginError) { - return Response.json({ error: error.message }, { status: 409 }); - } - throw error; - } - if (!manifest) { - return Response.json( - { error: `Plugin "${plugin}" is not installed` }, - { status: 404 }, - ); - } - - const requested = url.searchParams.get("method") ?? undefined; - const authSpec = authMethod(manifestAuth(manifest), requested); - if (!authSpec) { - return Response.json( - { - error: requested - ? `Plugin "${plugin}" declares no "${requested}" auth method` - : `Plugin "${plugin}" needs a method: it offers more than one`, - }, - { status: 400 }, - ); - } - - if (authSpec.type === "api_key") { - return Response.json( - { - error: - "api_key plugins connect through the plugins.connectApiKey procedure, so the credential never enters a URL.", - }, - { status: 405 }, - ); - } - - const secrets = (authSpec.inputs ?? []).filter((input) => input.secret); - if (secrets.length) { - return Response.json( - { - error: `Plugin "${plugin}" declares ${secrets - .map((input) => `"${input.name}"`) - .join( - ", ", - )} as secret on its ${authSpec.type} method. A browser authorization URL cannot carry a secret.`, - }, - { status: 400 }, - ); - } - - const inputs: Record = {}; - for (const input of authSpec.inputs ?? []) { - const value = url.searchParams.get(input.name); - if (value) inputs[input.name] = value; - else if (input.required) { - return Response.json( - { error: `Missing required input "${input.name}"` }, - { status: 400 }, - ); - } - } - - const state = createSignedState({ - userId: session.user.id, - pluginName: plugin, - authMethod: authSpec.type, - inputs, - }); - - try { - return Response.redirect( - buildAuthorizationUrl(plugin, authSpec, { inputs }, state), - ); - } catch (error) { - return Response.json( - { error: error instanceof Error ? error.message : String(error) }, - { status: 500 }, - ); - } -} diff --git a/apps/api/src/app/api/plugins/callback/[plugin]/route.ts b/apps/api/src/app/api/plugins/callback/[plugin]/route.ts deleted file mode 100644 index a16a9e51a09..00000000000 --- a/apps/api/src/app/api/plugins/callback/[plugin]/route.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { auth } from "@superset/auth/server"; -import { - authMethod, - exchangeCode, - installedPlugin, - manifestAuth, - resolveIdentity, - trustedManifest, - upsertConnection, -} from "@superset/trpc/integrations/plugins"; -import { env } from "@/env"; -import { pluginStateSchema, verifySignedState } from "@/lib/oauth-state"; - -function settingsRedirect(plugin: string, params: Record) { - const url = new URL(`${env.NEXT_PUBLIC_WEB_URL}/plugins/${plugin}`); - for (const [key, value] of Object.entries(params)) { - url.searchParams.set(key, value); - } - return Response.redirect(url.toString()); -} - -export async function GET( - request: Request, - { params }: { params: Promise<{ plugin: string }> }, -) { - const { plugin } = await params; - const url = new URL(request.url); - - if (url.searchParams.get("error")) { - return settingsRedirect(plugin, { error: "oauth_denied" }); - } - - const code = url.searchParams.get("code"); - const stateToken = url.searchParams.get("state"); - if (!code || !stateToken) { - return settingsRedirect(plugin, { error: "missing_params" }); - } - - const state = verifySignedState(stateToken, pluginStateSchema); - if (!state || state.pluginName !== plugin) { - return settingsRedirect(plugin, { error: "invalid_state" }); - } - - const session = await auth.api.getSession({ headers: request.headers }); - if (!session?.user || session.user.id !== state.userId) { - return settingsRedirect(plugin, { error: "unauthorized" }); - } - - let install: Awaited>; - try { - install = await installedPlugin(state.userId, plugin); - } catch { - return settingsRedirect(plugin, { error: "ambiguous_plugin" }); - } - const authSpec = install - ? authMethod(manifestAuth(install.manifest), state.authMethod) - : null; - if (!install || !authSpec) { - return settingsRedirect(plugin, { error: "not_installed" }); - } - - try { - const token = await exchangeCode( - plugin, - authSpec, - { inputs: state.inputs }, - code, - ); - - const identity = await resolveIdentity( - trustedManifest(install.marketplace) ? authSpec.identity : undefined, - { config: { access_token: token.accessToken }, inputs: state.inputs }, - authSpec.type, - authSpec, - ); - - await upsertConnection({ - installId: install.id, - userId: state.userId, - organizationId: null, - pluginName: plugin, - authMethod: authSpec.type, - accessToken: token.accessToken, - refreshToken: token.refreshToken, - tokenExpiresAt: token.expiresAt, - scopes: token.scopes, - inputs: state.inputs, - secretInputs: (authSpec.inputs ?? []) - .filter((input) => input.secret) - .map((input) => input.name), - externalAccountId: identity.id, - externalAccountLabel: identity.label, - }); - - return settingsRedirect(plugin, { connected: identity.label ?? "1" }); - } catch (error) { - console.error(`Plugin ${plugin} callback failed:`, error); - return settingsRedirect(plugin, { error: "connection_failed" }); - } -} diff --git a/apps/api/src/app/api/proxy/linear-image/route.ts b/apps/api/src/app/api/proxy/linear-image/route.ts index 34876d37986..d9de79df445 100644 --- a/apps/api/src/app/api/proxy/linear-image/route.ts +++ b/apps/api/src/app/api/proxy/linear-image/route.ts @@ -1,7 +1,8 @@ import { auth } from "@superset/auth/server"; -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; -import { and, eq } from "drizzle-orm"; +import { + connectionAccessToken, + userConnection, +} from "@superset/trpc/connectors"; const LINEAR_IMAGE_HOST = "uploads.linear.app"; const CACHE_MAX_AGE = 31536000; // 1 year (Linear URLs are content-addressed) @@ -42,22 +43,27 @@ export async function GET(request: Request): Promise { } // Get the org's Linear access token - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, organizationId), - eq(integrationConnections.provider, "linear"), - ), - }); + const connection = await userConnection( + organizationId, + "linear", + sessionData.user.id, + ); if (!connection) { return new Response("Linear integration not connected", { status: 400 }); } + let token: string; + try { + token = await connectionAccessToken(connection); + } catch (error) { + console.error("[proxy/linear-image] could not load a token:", error); + return new Response("Linear integration not connected", { status: 400 }); + } + // Fetch the image from Linear with auth const linearResponse = await fetch(linearUrl, { - headers: { - Authorization: `Bearer ${connection.accessToken}`, - }, + headers: { Authorization: `Bearer ${token}` }, }); if (!linearResponse.ok) { diff --git a/apps/api/src/app/mcp/plugins/[marketplace]/[plugin]/route.ts b/apps/api/src/app/mcp/plugins/[marketplace]/[plugin]/route.ts new file mode 100644 index 00000000000..59f4001451f --- /dev/null +++ b/apps/api/src/app/mcp/plugins/[marketplace]/[plugin]/route.ts @@ -0,0 +1,144 @@ +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { + isMcpUnauthorized, + type McpContext, + resolveMcpContext, +} from "@superset/mcp"; +import { + AmbiguousConnectionError, + AmbiguousPluginError, + buildPluginServer, + PluginTargetError, + resolveTarget, +} from "@superset/trpc/plugins-proxy"; +import { env } from "@/env"; +import { + checkRateLimit, + rateLimitedResponse, + withRateLimitHeaders, +} from "@/lib/mcp-rate-limit"; +import { + getRequestOrigin, + mcpProtectedResourceMetadataUrl, + mcpUnauthorizedResponse, +} from "@/lib/oauth-metadata"; + +interface RouteParams { + params: Promise<{ marketplace: string; plugin: string }>; +} + +function errorResponse( + code: string, + message: string, + status: number, +): Response { + return Response.json({ error: { code, message } }, { status }); +} + +function describeServer( + req: Request, + marketplace: string, + plugin: string, +): Response { + const origin = getRequestOrigin(req); + return Response.json( + { + name: plugin, + marketplace, + transport: "streamable-http", + url: `${origin}/mcp/plugins/${marketplace}/${plugin}`, + description: `Superset MCP endpoint for the "${plugin}" plugin. Tools run against the calling user's connected ${plugin} account.`, + usage: + "POST JSON-RPC 2.0 messages to this URL with `Accept: application/json, text/event-stream` and a Bearer token (OAuth 2.1 access token or Superset API key). Add ?connection= to pin a specific connected account.", + authentication: { + type: "oauth2", + resourceMetadataUrl: mcpProtectedResourceMetadataUrl(req), + }, + }, + { headers: { "Access-Control-Allow-Origin": "*" } }, + ); +} + +async function handle( + req: Request, + { params }: RouteParams, +): Promise { + const { marketplace, plugin } = await params; + + if ( + req.method === "GET" && + !(req.headers.get("accept") ?? "").includes("text/event-stream") + ) { + return describeServer(req, marketplace, plugin); + } + + const rateLimitState = await checkRateLimit(req, "ratelimit:mcp-plugins"); + if (rateLimitState && !rateLimitState.success) { + return withRateLimitHeaders(rateLimitedResponse(), rateLimitState); + } + + let ctx: McpContext; + try { + ctx = await resolveMcpContext(req, { + apiUrl: env.NEXT_PUBLIC_API_URL, + relayUrl: env.RELAY_URL, + }); + } catch (error) { + if (isMcpUnauthorized(error)) { + return withRateLimitHeaders( + mcpUnauthorizedResponse(req, error.message), + rateLimitState, + ); + } + throw error; + } + + let server: Awaited>; + try { + const target = await resolveTarget({ + userId: ctx.userId, + organizationId: ctx.organizationId, + marketplace, + plugin, + connectionId: new URL(req.url).searchParams.get("connection"), + }); + server = await buildPluginServer(target); + } catch (error) { + if (error instanceof PluginTargetError) { + return withRateLimitHeaders( + errorResponse("PLUGIN_UNAVAILABLE", error.message, error.status), + rateLimitState, + ); + } + if (error instanceof AmbiguousConnectionError) { + return withRateLimitHeaders( + errorResponse("AMBIGUOUS_CONNECTION", error.message, 409), + rateLimitState, + ); + } + if (error instanceof AmbiguousPluginError) { + return withRateLimitHeaders( + errorResponse("AMBIGUOUS_PLUGIN", error.message, 409), + rateLimitState, + ); + } + throw error; + } + + const transport = new WebStandardStreamableHTTPServerTransport(); + await server.connect(transport); + + const response = await transport.handleRequest(req, { + authInfo: { + token: ctx.bearerToken, + clientId: ctx.source === "api-key" ? "api-key" : "oauth", + scopes: ["mcp:full"], + extra: { mcpContext: ctx }, + }, + }); + return withRateLimitHeaders(response, rateLimitState); +} + +export const maxDuration = 800; + +export { handle as GET, handle as POST, handle as DELETE }; diff --git a/apps/api/src/app/mcp/route.ts b/apps/api/src/app/mcp/route.ts index 3103c8a563f..83d640e9fa1 100644 --- a/apps/api/src/app/mcp/route.ts +++ b/apps/api/src/app/mcp/route.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { createMcpServer, @@ -7,131 +6,19 @@ import { type McpContext, resolveMcpContext, } from "@superset/mcp"; -import { Ratelimit } from "@upstash/ratelimit"; -import { Redis } from "@upstash/redis"; import { env } from "@/env"; import { posthog } from "@/lib/analytics"; import { - getOAuthProtectedResourceMetadataUrl, + checkRateLimit, + rateLimitedResponse, + withRateLimitHeaders, +} from "@/lib/mcp-rate-limit"; +import { getRequestOrigin, + mcpProtectedResourceMetadataUrl, + mcpUnauthorizedResponse, } from "@/lib/oauth-metadata"; -// Per-credential (or per-IP before auth) ceiling on MCP requests. Generous: -// a busy orchestrator polling terminals stays well under it; a runaway loop -// gets a 429 with Retry-After instead of degrading everyone else. -const RATE_LIMIT_REQUESTS = 600; -const RATE_LIMIT_WINDOW_SECONDS = 60; - -const rateLimit = new Ratelimit({ - redis: new Redis({ - url: env.KV_REST_API_URL, - token: env.KV_REST_API_TOKEN, - }), - limiter: Ratelimit.slidingWindow( - RATE_LIMIT_REQUESTS, - `${RATE_LIMIT_WINDOW_SECONDS} s`, - ), - prefix: "ratelimit:mcp", -}); - -interface RateLimitState { - success: boolean; - limit: number; - remaining: number; - /** Epoch milliseconds when the window resets. */ - reset: number; -} - -function rateLimitKey(req: Request): string { - const authorization = req.headers.get("authorization") ?? ""; - const token = authorization.replace(/^Bearer\s+/i, "").trim(); - if (token) { - const digest = createHash("sha256").update(token).digest("hex"); - return `token:${digest.slice(0, 32)}`; - } - const ip = - req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - req.headers.get("x-real-ip") || - "unknown"; - return `ip:${ip}`; -} - -async function checkRateLimit( - req: Request, -): Promise { - try { - const result = await rateLimit.limit(rateLimitKey(req)); - return { - success: result.success, - limit: result.limit, - remaining: result.remaining, - reset: result.reset, - }; - } catch { - // Redis unavailable: fail open rather than take the MCP server down. - return undefined; - } -} - -// IETF RateLimit header fields (draft-ietf-httpapi-ratelimit-headers) plus the -// X-RateLimit-* names most SDKs already parse. -function withRateLimitHeaders( - response: Response, - state: RateLimitState | undefined, -): Response { - if (!state) return response; - const resetInSeconds = Math.max( - 0, - Math.ceil((state.reset - Date.now()) / 1000), - ); - const remaining = Math.max(0, state.remaining); - const headers = new Headers(response.headers); - headers.set("RateLimit-Limit", String(state.limit)); - headers.set("RateLimit-Remaining", String(remaining)); - headers.set("RateLimit-Reset", String(resetInSeconds)); - headers.set( - "RateLimit-Policy", - `${state.limit};w=${RATE_LIMIT_WINDOW_SECONDS}`, - ); - headers.set("X-RateLimit-Limit", String(state.limit)); - headers.set("X-RateLimit-Remaining", String(remaining)); - headers.set("X-RateLimit-Reset", String(Math.ceil(state.reset / 1000))); - if (response.status === 429) { - headers.set("Retry-After", String(Math.max(1, resetInSeconds))); - } - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} - -function rateLimitedResponse(): Response { - return Response.json( - { - error: { - code: "RATE_LIMITED", - message: `Too many MCP requests: the limit is ${RATE_LIMIT_REQUESTS} per ${RATE_LIMIT_WINDOW_SECONDS} seconds per credential.`, - hint: "Wait for the number of seconds in the Retry-After header, then retry. Batch reads where you can (for example terminals_read with a larger line count) instead of polling in a tight loop.", - }, - }, - { status: 429, headers: { "Content-Type": "application/json" } }, - ); -} - -function unauthorizedResponse(req: Request, message: string): Response { - return new Response( - JSON.stringify({ error: { code: "UNAUTHORIZED", message } }), - { - status: 401, - headers: { - "WWW-Authenticate": `Bearer realm="superset", resource_metadata="${getOAuthProtectedResourceMetadataUrl(req)}"`, - "Content-Type": "application/json", - }, - }, - ); -} - // A plain GET (no `Accept: text/event-stream`) is not an MCP stream request; // it is a human or an agent following a link. Describe the server instead of // answering 401, so the endpoint URL itself resolves to something useful. @@ -153,7 +40,7 @@ function describeServer(req: Request): Response { documentation: "https://docs.superset.sh/mcp-server", authentication: { type: "oauth2", - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(req), + resourceMetadataUrl: mcpProtectedResourceMetadataUrl(req), walkthrough: "https://superset.sh/auth.md", }, install: "https://superset.sh/mcp-install", @@ -189,7 +76,7 @@ async function handle(req: Request): Promise { } catch (error) { if (isMcpUnauthorized(error)) { return withRateLimitHeaders( - unauthorizedResponse(req, error.message), + mcpUnauthorizedResponse(req, error.message), rateLimitState, ); } diff --git a/apps/api/src/lib/ingest/recordWebhookDelivery.ts b/apps/api/src/lib/ingest/recordWebhookDelivery.ts index 1ed3b6f8af9..759e57fa800 100644 --- a/apps/api/src/lib/ingest/recordWebhookDelivery.ts +++ b/apps/api/src/lib/ingest/recordWebhookDelivery.ts @@ -1,9 +1,9 @@ import { db } from "@superset/db/client"; -import type { integrationProvider } from "@superset/db/schema"; import { DrizzleQueryError, sql } from "drizzle-orm"; import { databaseErrorMessage } from "@/lib/databaseErrorMessage"; -type Provider = (typeof integrationProvider.enumValues)[number]; +/** The connector slug a delivery arrived for; free text since 0114. */ +type Provider = string; export interface RecordedDelivery { id: string; diff --git a/apps/api/src/lib/integrations/upsertConnection.ts b/apps/api/src/lib/integrations/upsertConnection.ts deleted file mode 100644 index d079b7ff32f..00000000000 --- a/apps/api/src/lib/integrations/upsertConnection.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { db } from "@superset/db/client"; -import { - type IntegrationConfig, - type IntegrationProvider, - integrationConnections, - users, -} from "@superset/db/schema"; -import { and, eq, isNull, ne, type SQL, sql } from "drizzle-orm"; - -const UNIQUE_VIOLATION = "23505"; -const ACTIVE_LINKAGE_INDEXES = new Set([ - "integration_connections_provider_external_org_active_unique", - "integration_connections_slack_external_org_active_unique", -]); - -export type ConnectionConflict = { ownerEmail: string | null }; - -/** - * The active connection in a DIFFERENT Superset organization already holding - * this external workspace/tenant/account, if any. One external org, one - * Superset organization: a second organization claiming the same one would - * receive its events too. - */ -export async function connectionConflict( - provider: IntegrationProvider, - externalOrgId: string, - organizationId: string, -): Promise { - const [conflict] = await db - .select({ email: users.email }) - .from(integrationConnections) - .innerJoin(users, eq(users.id, integrationConnections.connectedByUserId)) - .where( - and( - eq(integrationConnections.provider, provider), - eq(integrationConnections.externalOrgId, externalOrgId), - isNull(integrationConnections.disconnectedAt), - ne(integrationConnections.organizationId, organizationId), - ), - ) - .limit(1); - return conflict ? { ownerEmail: conflict.email } : null; -} - -type UpsertConnectionInput = { - organizationId: string; - userId: string; - provider: IntegrationProvider; - accessToken: string; - /** Omit to leave the stored value alone on reconnect. */ - refreshToken?: string | null; - tokenExpiresAt?: Date | null; - externalOrgId: string; - externalOrgName?: string | null; - /** Omit to leave the stored config alone on reconnect. */ - config?: IntegrationConfig; - /** Overrides `config` on the update path (Google merges sync state in SQL). */ - configOnUpdate?: SQL; -}; - -export type UpsertConnectionResult = - | { conflict: ConnectionConflict } - | { conflict?: undefined; connectionId: string }; - -/** - * Writes (or revives) the organization's connection for a provider. Refuses - * with `conflict` when the external org is already actively linked to a - * different Superset organization. - */ -export async function upsertConnection( - input: UpsertConnectionInput, -): Promise { - const conflict = await connectionConflict( - input.provider, - input.externalOrgId, - input.organizationId, - ); - if (conflict) return { conflict }; - - // One connection per organization for org-scoped providers; Google is per - // member. Both uniquenesses are partial indexes, and Postgres only infers a - // partial index when the predicate is named in the conflict target. - const conflictTarget = - input.provider === "google" - ? { - target: [ - integrationConnections.organizationId, - integrationConnections.provider, - integrationConnections.connectedByUserId, - ], - targetWhere: sql`${integrationConnections.provider} = 'google'`, - } - : { - target: [ - integrationConnections.organizationId, - integrationConnections.provider, - ], - targetWhere: sql`${integrationConnections.provider} <> 'google'`, - }; - - try { - const [connection] = await db - .insert(integrationConnections) - .values({ - organizationId: input.organizationId, - connectedByUserId: input.userId, - provider: input.provider, - accessToken: input.accessToken, - refreshToken: input.refreshToken, - tokenExpiresAt: input.tokenExpiresAt, - externalOrgId: input.externalOrgId, - externalOrgName: input.externalOrgName, - config: input.config, - }) - .onConflictDoUpdate({ - ...conflictTarget, - set: { - accessToken: input.accessToken, - refreshToken: input.refreshToken, - tokenExpiresAt: input.tokenExpiresAt, - externalOrgId: input.externalOrgId, - externalOrgName: input.externalOrgName, - connectedByUserId: input.userId, - config: input.configOnUpdate ?? input.config, - disconnectedAt: null, - disconnectReason: null, - updatedAt: new Date(), - }, - }) - .returning({ id: integrationConnections.id }); - if (!connection) throw new Error("Connection upsert returned no row"); - return { connectionId: connection.id }; - } catch (error) { - // Racing connects: the partial unique index on active external orgs wins - // where the pre-check above cannot. - const raced = error as { code?: string; constraint?: string }; - if ( - raced.code === UNIQUE_VIOLATION && - raced.constraint && - ACTIVE_LINKAGE_INDEXES.has(raced.constraint) - ) { - return { conflict: { ownerEmail: null } }; - } - throw error; - } -} diff --git a/apps/api/src/lib/mcp-rate-limit.ts b/apps/api/src/lib/mcp-rate-limit.ts new file mode 100644 index 00000000000..f431cf6301b --- /dev/null +++ b/apps/api/src/lib/mcp-rate-limit.ts @@ -0,0 +1,119 @@ +import { createHash } from "node:crypto"; +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import { env } from "@/env"; + +// Per-credential (or per-IP before auth) ceiling on MCP requests. Generous: +// a busy orchestrator polling terminals stays well under it; a runaway loop +// gets a 429 with Retry-After instead of degrading everyone else. +const RATE_LIMIT_REQUESTS = 600; +const RATE_LIMIT_WINDOW_SECONDS = 60; + +export interface RateLimitState { + success: boolean; + limit: number; + remaining: number; + /** Epoch milliseconds when the window resets. */ + reset: number; +} + +const redis = new Redis({ + url: env.KV_REST_API_URL, + token: env.KV_REST_API_TOKEN, +}); + +const limiters = new Map(); + +function limiterFor(prefix: string): Ratelimit { + let limiter = limiters.get(prefix); + if (!limiter) { + limiter = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow( + RATE_LIMIT_REQUESTS, + `${RATE_LIMIT_WINDOW_SECONDS} s`, + ), + prefix, + }); + limiters.set(prefix, limiter); + } + return limiter; +} + +function rateLimitKey(req: Request): string { + const authorization = req.headers.get("authorization") ?? ""; + const token = authorization.replace(/^Bearer\s+/i, "").trim(); + if (token) { + const digest = createHash("sha256").update(token).digest("hex"); + return `token:${digest.slice(0, 32)}`; + } + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + req.headers.get("x-real-ip") || + "unknown"; + return `ip:${ip}`; +} + +export async function checkRateLimit( + req: Request, + prefix = "ratelimit:mcp", +): Promise { + try { + const result = await limiterFor(prefix).limit(rateLimitKey(req)); + return { + success: result.success, + limit: result.limit, + remaining: result.remaining, + reset: result.reset, + }; + } catch { + // Redis unavailable: fail open rather than take the MCP server down. + return undefined; + } +} + +// IETF RateLimit header fields (draft-ietf-httpapi-ratelimit-headers) plus the +// X-RateLimit-* names most SDKs already parse. +export function withRateLimitHeaders( + response: Response, + state: RateLimitState | undefined, +): Response { + if (!state) return response; + const resetInSeconds = Math.max( + 0, + Math.ceil((state.reset - Date.now()) / 1000), + ); + const remaining = Math.max(0, state.remaining); + const headers = new Headers(response.headers); + headers.set("RateLimit-Limit", String(state.limit)); + headers.set("RateLimit-Remaining", String(remaining)); + headers.set("RateLimit-Reset", String(resetInSeconds)); + headers.set( + "RateLimit-Policy", + `${state.limit};w=${RATE_LIMIT_WINDOW_SECONDS}`, + ); + headers.set("X-RateLimit-Limit", String(state.limit)); + headers.set("X-RateLimit-Remaining", String(remaining)); + headers.set("X-RateLimit-Reset", String(Math.ceil(state.reset / 1000))); + if (response.status === 429) { + headers.set("Retry-After", String(Math.max(1, resetInSeconds))); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +export function rateLimitedResponse(): Response { + return Response.json( + { + error: { + code: "RATE_LIMITED", + message: `Too many MCP requests: the limit is ${RATE_LIMIT_REQUESTS} per ${RATE_LIMIT_WINDOW_SECONDS} seconds per credential.`, + hint: "Wait for the number of seconds in the Retry-After header, then retry. Batch reads where you can (for example terminals_read with a larger line count) instead of polling in a tight loop.", + }, + }, + { status: 429, headers: { "Content-Type": "application/json" } }, + ); +} diff --git a/apps/api/src/lib/oauth-metadata.ts b/apps/api/src/lib/oauth-metadata.ts index c8f838a4691..197e37bbd75 100644 --- a/apps/api/src/lib/oauth-metadata.ts +++ b/apps/api/src/lib/oauth-metadata.ts @@ -39,6 +39,35 @@ export function getOAuthProtectedResourceMetadataUrl(req: Request): string { )}`; } +/** + * The resource every Superset MCP endpoint authorizes against. Plugin + * endpoints live under /mcp/plugins//, an unbounded path + * the authorization server's audience allowlist cannot enumerate, so they + * present the one audience it already issues. Nothing scopes a token to a + * single endpoint anyway: the audience check is on the token, not the path. + */ +export const MCP_RESOURCE_PATH = "/mcp"; + +export function mcpProtectedResourceMetadataUrl(req: Request): string { + return `${getRequestOrigin(req)}/.well-known/oauth-protected-resource${MCP_RESOURCE_PATH}`; +} + +export function mcpUnauthorizedResponse( + req: Request, + message: string, +): Response { + return new Response( + JSON.stringify({ error: { code: "UNAUTHORIZED", message } }), + { + status: 401, + headers: { + "WWW-Authenticate": `Bearer realm="superset", resource_metadata="${mcpProtectedResourceMetadataUrl(req)}"`, + "Content-Type": "application/json", + }, + }, + ); +} + export function buildProtectedResourceMetadata( req: Request, resourcePath: string, diff --git a/apps/api/src/lib/oauth-state.ts b/apps/api/src/lib/oauth-state.ts index 65192eec947..74944990cd8 100644 --- a/apps/api/src/lib/oauth-state.ts +++ b/apps/api/src/lib/oauth-state.ts @@ -22,11 +22,18 @@ export const pluginStateSchema = z.object({ pluginName: z.string().min(1), authMethod: z.string().min(1).default("oauth2"), inputs: z.record(z.string(), z.string()).default({}), + codeVerifier: z.string().optional(), timestamp: z.number(), }); export type PluginState = z.infer; +export const connectorStateSchema = basePayloadSchema.extend({ + codeVerifier: z.string().optional(), +}); + +export type ConnectorState = z.infer; + function sign(body: string): string { return createHmac("sha256", env.BETTER_AUTH_SECRET) .update(body) diff --git a/apps/desktop/docs/OPTIMISTIC_ELECTRIC_UPDATES.md b/apps/desktop/docs/OPTIMISTIC_ELECTRIC_UPDATES.md index bf40a83d511..11f14bbcdad 100644 --- a/apps/desktop/docs/OPTIMISTIC_ELECTRIC_UPDATES.md +++ b/apps/desktop/docs/OPTIMISTIC_ELECTRIC_UPDATES.md @@ -56,7 +56,6 @@ These are Electric-backed in the renderer but have no collection mutation handle - `members` - `users` - `invitations` -- `integrationConnections` - `subscriptions` - `apiKeys` - `sessionHosts` diff --git a/apps/desktop/src/renderer/components/ConnectorSection/ConnectorSection.tsx b/apps/desktop/src/renderer/components/ConnectorSection/ConnectorSection.tsx new file mode 100644 index 00000000000..7d793c4e7b9 --- /dev/null +++ b/apps/desktop/src/renderer/components/ConnectorSection/ConnectorSection.tsx @@ -0,0 +1,157 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { errorMessage } from "@superset/i18n/errors"; +import { Badge } from "@superset/ui/badge"; +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { useState } from "react"; +import { LuCheck, LuExternalLink, LuKeyRound, LuUnplug } from "react-icons/lu"; +import { useConnector } from "./hooks/useConnector"; + +interface ConnectorSectionProps { + slug: string; + organizationId?: string | null; + onConnected?: () => void; +} + +export function ConnectorSection({ + slug, + organizationId, + onConnected, +}: ConnectorSectionProps) { + const { t } = useLingui(); + const { + connector, + connection, + isPending, + connectApiKey, + disconnect, + openOAuth, + organizationId: resolvedOrganizationId, + } = useConnector(slug, organizationId, onConnected); + + const [selected, setSelected] = useState(null); + const [values, setValues] = useState>({}); + const [error, setError] = useState(null); + + if (isPending || !connector) { + return ( +

+ Loading… +

+ ); + } + + if (connection) { + const who = connection.externalUserLabel; + const where = connection.externalAccountLabel; + return ( +
+ + + Connected + + + {who && where ? `${who} · ${where}` : (who ?? where ?? "")} + + +
+ ); + } + + const method = + connector.methods.find((entry) => entry.type === selected) ?? + connector.methods[0]; + if (!method) return null; + + return ( +
+ {connector.methods.length > 1 && ( + + )} + + {method.type === "api_key" ? ( +
{ + event.preventDefault(); + setError(null); + connectApiKey.mutate( + { organizationId: resolvedOrganizationId, slug, inputs: values }, + { onError: (e) => setError(errorMessage(e)) }, + ); + }} + > + {method.inputs.map((field) => ( +
+ + setValues({ ...values, [field.name]: event.target.value }) + } + /> + {field.description && ( +

+ {field.description} +

+ )} +
+ ))} + +
+ ) : ( + + )} + + {error &&

{error}

} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/ConnectorSection/components/ConnectConnectorDialog/ConnectConnectorDialog.tsx b/apps/desktop/src/renderer/components/ConnectorSection/components/ConnectConnectorDialog/ConnectConnectorDialog.tsx new file mode 100644 index 00000000000..3dcbb19d8bd --- /dev/null +++ b/apps/desktop/src/renderer/components/ConnectorSection/components/ConnectConnectorDialog/ConnectConnectorDialog.tsx @@ -0,0 +1,140 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import type { ReactNode } from "react"; +import { usePresetIcon } from "renderer/assets/app-icons/preset-icons"; +import { ConnectorSection } from "../../ConnectorSection"; +import { useConnector } from "../../hooks/useConnector"; + +interface ConnectConnectorDialogProps { + slug: string | null; + icon?: ReactNode; + author?: string | null; + organizationId?: string | null; + onOpenChange: (open: boolean) => void; +} + +export function ConnectConnectorDialog({ + slug, + icon, + author, + organizationId, + onOpenChange, +}: ConnectConnectorDialogProps) { + return ( + + + {slug && ( + onOpenChange(false)} + /> + )} + + + ); +} + +function DialogBody({ + slug, + icon, + author, + organizationId, + onConnected, +}: { + slug: string; + icon?: ReactNode; + author?: string | null; + organizationId?: string | null; + onConnected: () => void; +}) { + const { t } = useLingui(); + const supersetIcon = usePresetIcon("superset"); + const { connector } = useConnector(slug, organizationId); + const name = connector?.displayName ?? slug; + + return ( + <> + +
+ {supersetIcon && ( + + )} + + {icon} +
+ + {t({ message: `Connect ${name}` })} + + + {author ? ( + t({ message: `Developed by ${author}` }) + ) : ( + Authorize Superset to act on your behalf. + )} + +
+ +
+ You control the access} + body={ + + Superset only receives the permissions this connector asks for. + Disconnect at any time to revoke them. + + } + /> + Credentials stay on the server} + body={ + + Tokens are held by Superset and attached to requests there. They + are never written into your local agent config. + + } + /> + Connectors carry risk} + body={t({ + message: `Connecting lets your agents read and act in ${name} on your behalf. Review what you are granting before you continue.`, + })} + /> +
+ + + + ); +} + +function TrustItem({ title, body }: { title: ReactNode; body: ReactNode }) { + return ( +
+
{title}
+

+ {body} +

+
+ ); +} diff --git a/apps/desktop/src/renderer/components/ConnectorSection/components/ConnectConnectorDialog/index.ts b/apps/desktop/src/renderer/components/ConnectorSection/components/ConnectConnectorDialog/index.ts new file mode 100644 index 00000000000..4c90d81ddfa --- /dev/null +++ b/apps/desktop/src/renderer/components/ConnectorSection/components/ConnectConnectorDialog/index.ts @@ -0,0 +1 @@ +export { ConnectConnectorDialog } from "./ConnectConnectorDialog"; diff --git a/apps/desktop/src/renderer/components/ConnectorSection/components/ConnectorRow/ConnectorRow.tsx b/apps/desktop/src/renderer/components/ConnectorSection/components/ConnectorRow/ConnectorRow.tsx new file mode 100644 index 00000000000..11b78e1beeb --- /dev/null +++ b/apps/desktop/src/renderer/components/ConnectorSection/components/ConnectorRow/ConnectorRow.tsx @@ -0,0 +1,85 @@ +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@superset/ui/badge"; +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import type { ReactNode } from "react"; +import { LuCheck, LuLock, LuUnplug } from "react-icons/lu"; +import { useConnector } from "../../hooks/useConnector"; + +interface ConnectorRowProps { + slug: string; + description?: string | null; + icon?: ReactNode; + organizationId?: string | null; + canConnect?: boolean; + onConnect: () => void; +} + +export function ConnectorRow({ + slug, + description, + icon, + organizationId, + canConnect = true, + onConnect, +}: ConnectorRowProps) { + const { connector, connection, isPending, disconnect } = useConnector( + slug, + organizationId, + ); + + if (isPending || !connector) return null; + + const who = connection?.externalUserLabel; + const where = connection?.externalAccountLabel; + const account = who && where ? `${who} · ${where}` : (who ?? where ?? ""); + + return ( +
+ {icon} +
+
+ {connector.displayName} +
+

+ {connection ? account : description} +

+
+ {connection ? ( +
+ + + Connected + + +
+ ) : canConnect ? ( + + ) : ( + + + + + + Install the plugin to connect + + + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/ConnectorSection/components/ConnectorRow/index.ts b/apps/desktop/src/renderer/components/ConnectorSection/components/ConnectorRow/index.ts new file mode 100644 index 00000000000..123b1a2b30d --- /dev/null +++ b/apps/desktop/src/renderer/components/ConnectorSection/components/ConnectorRow/index.ts @@ -0,0 +1 @@ +export { ConnectorRow } from "./ConnectorRow"; diff --git a/apps/desktop/src/renderer/components/ConnectorSection/hooks/useConnector/index.ts b/apps/desktop/src/renderer/components/ConnectorSection/hooks/useConnector/index.ts new file mode 100644 index 00000000000..81d15597226 --- /dev/null +++ b/apps/desktop/src/renderer/components/ConnectorSection/hooks/useConnector/index.ts @@ -0,0 +1 @@ +export { useConnector } from "./useConnector"; diff --git a/apps/desktop/src/renderer/components/ConnectorSection/hooks/useConnector/useConnector.ts b/apps/desktop/src/renderer/components/ConnectorSection/hooks/useConnector/useConnector.ts new file mode 100644 index 00000000000..5d425a92615 --- /dev/null +++ b/apps/desktop/src/renderer/components/ConnectorSection/hooks/useConnector/useConnector.ts @@ -0,0 +1,93 @@ +import { useEffect, useRef } from "react"; +import { env } from "renderer/env.renderer"; +import { cloudTrpc } from "renderer/lib/cloud-trpc"; + +const POLL_MS = 5_000; + +export function useConnector( + slug: string, + explicitOrganizationId?: string | null, + onConnected?: () => void, +) { + const utils = cloudTrpc.useUtils(); + + const myOrganization = cloudTrpc.user.myOrganization.useQuery(undefined, { + enabled: !explicitOrganizationId, + staleTime: Number.POSITIVE_INFINITY, + }); + const organizationId = + explicitOrganizationId ?? myOrganization.data?.id ?? ""; + + const connector = cloudTrpc.connectors.get.useQuery( + { slug }, + { staleTime: Number.POSITIVE_INFINITY }, + ); + + const status = cloudTrpc.connectors.status.useQuery( + { organizationId }, + { + enabled: Boolean(organizationId), + refetchInterval: POLL_MS, + refetchIntervalInBackground: false, + refetchOnWindowFocus: true, + staleTime: 0, + }, + ); + + const connection = status.data?.find((row) => row.connector === slug) ?? null; + + const wasConnected = useRef(Boolean(connection)); + useEffect(() => { + if (connection && !wasConnected.current) onConnected?.(); + wasConnected.current = Boolean(connection); + }, [connection, onConnected]); + + const invalidate = () => + utils.connectors.status.invalidate({ organizationId }); + + const connectApiKey = cloudTrpc.connectors.connectApiKey.useMutation({ + onSuccess: invalidate, + }); + + const disconnect = cloudTrpc.connectors.disconnect.useMutation({ + onMutate: async ({ connectionId }) => { + await utils.connectors.status.cancel({ organizationId }); + const previous = utils.connectors.status.getData({ organizationId }); + utils.connectors.status.setData({ organizationId }, (rows) => + (rows ?? []).filter((row) => row.id !== connectionId), + ); + return { previous }; + }, + onError: (_error, _input, context) => { + if (context?.previous) + utils.connectors.status.setData({ organizationId }, context.previous); + }, + onSettled: invalidate, + }); + + const openOAuth = (method: string) => { + const url = new URL( + `${env.NEXT_PUBLIC_API_URL}/api/connectors/${slug}/connect`, + ); + url.searchParams.set("organizationId", organizationId); + url.searchParams.set("method", method); + window.open(url.toString(), "_blank", "noopener,noreferrer"); + }; + + return { + connector: connector.data ?? null, + connection, + organizationId, + isPending: + connector.isPending || + (Boolean(organizationId) && status.isPending) || + (!explicitOrganizationId && myOrganization.isPending), + connectApiKey, + disconnect: { + ...disconnect, + mutate: (input: { connectionId: string }) => + disconnect.mutate({ organizationId, ...input }), + }, + openOAuth, + }; +} diff --git a/apps/desktop/src/renderer/components/ConnectorSection/index.ts b/apps/desktop/src/renderer/components/ConnectorSection/index.ts new file mode 100644 index 00000000000..9b32e520774 --- /dev/null +++ b/apps/desktop/src/renderer/components/ConnectorSection/index.ts @@ -0,0 +1,3 @@ +export { ConnectorSection } from "./ConnectorSection"; +export { ConnectConnectorDialog } from "./components/ConnectConnectorDialog"; +export { ConnectorRow } from "./components/ConnectorRow"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/TriggerSentence.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/TriggerSentence.tsx index 58ef9c695ee..f90005cd645 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/TriggerSentence.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/TriggerSentence.tsx @@ -4,12 +4,10 @@ import type { DraftTrigger, TriggerProblem, } from "@superset/shared/automation-triggers"; -import { INTEGRATIONS } from "@superset/shared/integrations"; import { Button } from "@superset/ui/button"; import type { ReactNode } from "react"; -import { LuArrowUpRight, LuTrash2 } from "react-icons/lu"; -import { env } from "renderer/env.renderer"; -import { type ProviderOptions, providerFor } from "../providers"; +import { LuTrash2 } from "react-icons/lu"; +import { connectorFor, type ProviderOptions, providerFor } from "../providers"; import { triggerEventLabel } from "../providers/eventLabel"; import type { OptionGroupState } from "../providers/types"; import { CHIP_INVALID } from "./chipStyles"; @@ -24,6 +22,8 @@ interface TriggerSentenceProps { problems?: TriggerProblem[]; /** Trailing "Next run ..." text for a schedule row. */ nextRun?: ReactNode; + /** Opens the connect dialog for this row's connector. */ + onConnect?: (connector: string) => void; /** * True when this provider needs an integration nobody has connected yet. * The row collapses to the trigger's name and the way to fix it: with no @@ -31,6 +31,8 @@ interface TriggerSentenceProps { * of empty ones would only ask for choices that cannot be made. */ requiresConnection?: boolean; + /** The connector was connected and its refresh failed; offer Reconnect. */ + needsReauth?: boolean; disabled?: boolean; } @@ -51,7 +53,9 @@ export function TriggerSentence({ problems, nextRun, requiresConnection, + needsReauth, disabled, + onConnect, }: TriggerSentenceProps) { const { _: translate } = useTranslation(); @@ -63,11 +67,7 @@ export function TriggerSentence({ // could each be the empty one. const invalid = new Set((problems ?? []).map((p) => p.field)); - // The web app owns every connect flow, because that is where the browser - // session lives; this only has to point at the right page. - const webPath = INTEGRATIONS.find( - (integration) => integration.provider === provider.connectionProvider, - )?.webPath; + const connector = connectorFor(provider); // Always the first element of the right-hand cluster, so whatever follows // it — nothing, or a Connect button — is what sits against the row's right @@ -101,21 +101,18 @@ export function TriggerSentence({ {triggerEventLabel(provider, config)} - Requires connection + {needsReauth ? "Connection expired" : "Requires connection"} {removeButton} - {webPath && ( + {connector && onConnect && ( )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx index 18a7fb241ca..f96f9c512a7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx @@ -15,11 +15,14 @@ import { } from "@superset/ui/dropdown-menu"; import { Input } from "@superset/ui/input"; import { Separator } from "@superset/ui/separator"; +import { useQueryClient } from "@tanstack/react-query"; import { useFeatureFlagPayload } from "posthog-js/react"; import { type ReactNode, useMemo, useState } from "react"; import { LuPlus } from "react-icons/lu"; +import { ConnectConnectorDialog } from "renderer/components/ConnectorSection"; import { useCurrentPlan } from "renderer/hooks/useCurrentPlan"; -import { providerFor, TRIGGER_PROVIDERS } from "../providers"; +import { cloudTrpc } from "renderer/lib/cloud-trpc"; +import { connectorFor, providerFor, TRIGGER_PROVIDERS } from "../providers"; import type { OptionGroupState, ProviderOptions } from "../providers/types"; import { useProviderConnections } from "../providers/useProviderConnections"; import { TriggerSentence } from "../TriggerSentence"; @@ -62,13 +65,24 @@ export function TriggersEditor({ onEdit([...drafts, { config }]); const { plan } = useCurrentPlan(); - const { connected, isPending: connectionsPending } = - useProviderConnections(organizationId); + const { + connected, + needsReauth, + isPending: connectionsPending, + } = useProviderConnections(organizationId); + const [connecting, setConnecting] = useState(null); + const utils = cloudTrpc.useUtils(); + const queryClient = useQueryClient(); const missingConnection = (config: DraftTrigger["config"]) => { if (connectionsPending) return false; - const required = providerFor(config).connectionProvider; - return required !== undefined && !connected[required]; + const required = connectorFor(providerFor(config)); + return required !== null && !connected[required]; + }; + + const expiredConnection = (config: DraftTrigger["config"]) => { + const required = connectorFor(providerFor(config)); + return required !== null && Boolean(needsReauth[required]); }; const runtimeWarnings = useMemo( @@ -108,6 +122,7 @@ export function TriggersEditor({
{drafts.map((trigger, index) => ( @@ -123,6 +138,7 @@ export function TriggersEditor({ : undefined } requiresConnection={missingConnection(trigger.config)} + needsReauth={expiredConnection(trigger.config)} disabled={readOnly} /> ))} @@ -206,6 +222,19 @@ export function TriggersEditor({ them: these outlive any save, so they cannot live in the submit-gated banner. */} + + { + if (open) return; + setConnecting(null); + void utils.integration.connectionStatus.invalidate(); + void queryClient.invalidateQueries({ + queryKey: ["integration.triggerOptions"], + }); + }} + />
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.test.tsx index 1161290656d..ffe6527c66b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.test.tsx @@ -197,7 +197,10 @@ describe("a GitHub row filtering by person", () => { */ describe("a GitHub row whose integration is not connected", () => { const disconnected = () => - row(config("pull_request.opened"), { requiresConnection: true }); + row(config("pull_request.opened"), { + requiresConnection: true, + onConnect: () => {}, + }); // With no connection there is nothing to populate the pickers, so a // sentence full of empty ones would only ask for choices nobody can make. diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx index a785dc8a482..1f42c46227b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx @@ -139,6 +139,7 @@ function renderSlot( export const gmailProvider: TriggerProvider = { kind: "gmail", optionGroup: "google", + connectionProvider: "google", label: "Gmail", icon: SiGmail, menu: GMAIL_MENU, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx index 502a3735a1a..9e0db2c67f8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx @@ -127,6 +127,7 @@ function renderSlot( export const googleCalendarProvider: TriggerProvider = { kind: "google_calendar", optionGroup: "google", + connectionProvider: "google", label: "Google Calendar", icon: SiGooglecalendar, menu: CALENDAR_MENU, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/index.ts index 2667a4da94e..ffb0bd05b17 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/index.ts @@ -53,3 +53,9 @@ export function providerFor(config: TriggerConfigInput): TriggerProvider { } return provider; } + +export function connectorFor(provider: TriggerProvider): string | null { + return provider.connectionProvider === undefined + ? provider.kind + : provider.connectionProvider; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.tsx index 0b122994fa3..ce56ebe57f5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.tsx @@ -21,6 +21,7 @@ function createScheduleConfig(rrule: string): ScheduleConfig { export const scheduleProvider: TriggerProvider = { kind: "schedule", + connectionProvider: null, label: "Scheduled", icon: LuClock, menu: [ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.test.tsx index 5a1fdef9e5e..364a9c94063 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.test.tsx @@ -330,7 +330,8 @@ describe("a Slack row watching a channel the bot is not in", () => { * ⌗ Message in channel Requires connection [🗑] [Connect ↗] */ describe("a Slack row whose integration is not connected", () => { - const disconnected = () => row(message(), { requiresConnection: true }); + const disconnected = () => + row(message(), { requiresConnection: true, onConnect: () => {} }); test("collapses to the name of the trigger", async () => { const { ui } = await disconnected(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/types.ts index eaf8e5cb394..32cb652d3cd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/types.ts @@ -26,16 +26,12 @@ export type TriggerProvider< label: string | MessageDescriptor; icon: IconType; /** - * The `integration_provider` this trigger needs connected before it can - * fire, if any. Absent for providers that carry their own credentials — a - * schedule needs nothing, and a webhook trigger carries its own URL. - * - * Deliberately not derived from `optionGroup`: the two disagree (Teams is - * `microsoftTeams` there and `microsoft_teams` here) and they answer - * different questions — one names a list to fetch, this names a connection - * to check. + * Overrides the connector slug this trigger needs connected, which defaults + * to `kind`. A string where they diverge (Calendar and Gmail share one + * `google` connection), `null` where no connection is needed at all. Read + * through `connectorFor`, never directly. */ - connectionProvider?: string; + connectionProvider?: string | null; /** * The Add Trigger subtree. A single leaf for providers with one trigger * (Scheduled, Webhook); nested for those with many (GitHub). diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderConnections.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderConnections.ts index 9f065647050..3b14fcdc076 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderConnections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderConnections.ts @@ -19,6 +19,8 @@ const POLL_MS = 10_000; */ export function useProviderConnections(organizationId: string): { connected: Record; + /** Connected once, but the refresh failed: the fix is Reconnect, not Connect. */ + needsReauth: Record; isPending: boolean; } { const query = cloudTrpc.integration.connectionStatus.useQuery( @@ -34,8 +36,13 @@ export function useProviderConnections(organizationId: string): { }, ); - return useMemo( - () => ({ connected: query.data ?? {}, isPending: query.isPending }), - [query.data, query.isPending], - ); + return useMemo(() => { + const connected: Record = {}; + const needsReauth: Record = {}; + for (const [provider, state] of Object.entries(query.data ?? {})) { + connected[provider] = state.connected; + needsReauth[provider] = state.needsReauth; + } + return { connected, needsReauth, isPending: query.isPending }; + }, [query.data, query.isPending]); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/webhook/webhook.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/webhook/webhook.tsx index 7bc374225e3..2dca358d7c1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/webhook/webhook.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/webhook/webhook.tsx @@ -8,6 +8,7 @@ type WebhookConfig = Extract; export const webhookProvider: TriggerProvider = { kind: "webhook", + connectionProvider: null, label: msg({ message: "Webhook triggered", }), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/$pluginName/components/PluginDetail/PluginDetail.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/$pluginName/components/PluginDetail/PluginDetail.tsx index 4839a0f845e..6e05ed9170d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/$pluginName/components/PluginDetail/PluginDetail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/$pluginName/components/PluginDetail/PluginDetail.tsx @@ -2,21 +2,42 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { Button } from "@superset/ui/button"; import { Switch } from "@superset/ui/switch"; import { useNavigate } from "@tanstack/react-router"; -import { LuArrowLeft, LuArrowUp, LuExternalLink } from "react-icons/lu"; +import { useEffect, useRef, useState } from "react"; +import { + LuArrowLeft, + LuArrowUp, + LuExternalLink, + LuPlus, + LuTrash2, +} from "react-icons/lu"; +import { + ConnectConnectorDialog, + ConnectorRow, +} from "renderer/components/ConnectorSection"; import { PluginIcon } from "renderer/routes/_authenticated/_dashboard/plugins/components/PluginIcon"; import { SkillIcon } from "renderer/routes/_authenticated/_dashboard/plugins/components/SkillIcon"; import type { CatalogPlugin } from "renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginCatalog"; import { usePluginMutations } from "renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginMutations"; import { InfoRow } from "./components/InfoRow"; -import { PluginConnections } from "./components/PluginConnections"; import { SectionHeader } from "./components/SectionHeader"; -import { useAuthMethodLabel } from "./hooks/useAuthMethodLabel"; export function PluginDetail({ plugin }: { plugin: CatalogPlugin }) { const { t } = useLingui(); - const authMethodLabel = useAuthMethodLabel(); const navigate = useNavigate(); - const { add, uninstall, setEnabled, update, isBusy } = usePluginMutations(); + const { install, uninstall, setEnabled, update, isBusy } = + usePluginMutations(); + + const [isConnectOpen, setIsConnectOpen] = useState(false); + const wasInstalled = useRef(plugin.installed); + const needsConnection = Boolean( + plugin.connector && plugin.connections.length === 0, + ); + + useEffect(() => { + if (plugin.installed && !wasInstalled.current && needsConnection) + setIsConnectOpen(true); + wasInstalled.current = plugin.installed; + }, [plugin.installed, needsConnection]); const skills = plugin.pluginSkills ?? []; @@ -48,6 +69,16 @@ export function PluginDetail({ plugin }: { plugin: CatalogPlugin }) {
+ {!plugin.installed && ( + + )} {plugin.updateAvailable && ( )} + {plugin.installed && ( + + )} {plugin.installed && (
- add(plugin.name)} - onRemove={() => uninstall(plugin.name)} - isBusy={isBusy} - /> + {plugin.connector && ( +
+ Connector} /> +
+ } + canConnect={plugin.installed} + onConnect={() => setIsConnectOpen(true)} + /> +
+
+ )} {skills.length > 0 && (
@@ -113,11 +161,9 @@ export function PluginDetail({ plugin }: { plugin: CatalogPlugin }) { Category}> {plugin.interface.category} - {plugin.auth?.length ? ( - Authentication}> - {plugin.auth - .map((method) => authMethodLabel(method.type)) - .join(", ")} + {plugin.connector ? ( + Connector}> + {plugin.connector} ) : null} Version}> @@ -149,6 +195,13 @@ export function PluginDetail({ plugin }: { plugin: CatalogPlugin }) { )}
+ + } + author={plugin.author} + onOpenChange={(open) => !open && setIsConnectOpen(false)} + /> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/$pluginName/components/PluginDetail/components/PluginConnections/PluginConnections.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/$pluginName/components/PluginDetail/components/PluginConnections/PluginConnections.tsx deleted file mode 100644 index 92ff678b7dc..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/$pluginName/components/PluginDetail/components/PluginConnections/PluginConnections.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { Trans, useLingui } from "@lingui/react/macro"; -import { errorMessage } from "@superset/i18n/errors"; -import { useFormat } from "@superset/i18n/react"; -import { Badge } from "@superset/ui/badge"; -import { Button } from "@superset/ui/button"; -import { Input } from "@superset/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@superset/ui/select"; -import { useState } from "react"; -import { LuGlobe, LuKeyRound, LuPlus, LuTrash2 } from "react-icons/lu"; -import { PluginIcon } from "renderer/routes/_authenticated/_dashboard/plugins/components/PluginIcon"; -import type { AuthMethod } from "renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginCatalog"; -import { usePluginConnections } from "renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginConnections"; -import { useAuthMethodLabel } from "../../hooks/useAuthMethodLabel"; -import { SectionHeader } from "../SectionHeader"; - -export function PluginConnections({ - pluginName, - displayName, - auth, - installed, - onAdd, - onRemove, - isBusy, -}: { - pluginName: string; - displayName: string; - auth?: readonly AuthMethod[]; - installed: boolean; - onAdd: () => Promise; - onRemove: () => void; - isBusy: boolean; -}) { - const { formatDate } = useFormat(); - - const { t } = useLingui(); - const methodLabel = useAuthMethodLabel(); - const { - connections, - isLoading, - connectOAuth, - connectApiKey, - isConnecting, - connectError, - disconnect, - isDisconnecting, - } = usePluginConnections(pluginName); - const [values, setValues] = useState>({}); - - const methods = auth ?? []; - const [method, setMethod] = useState( - methods[0]?.type ?? "oauth2", - ); - - const selected = methods.find((entry) => entry.type === method); - const inputs = selected?.inputs ?? []; - const missingRequired = inputs.some( - (input) => input.required && !values[input.name]?.trim(), - ); - const connected = connections.length > 0; - - const authenticate = async () => { - try { - if (!(await onAdd())) return; - } catch { - return; - } - if (method === "api_key") { - connectApiKey(values); - return; - } - - connectOAuth( - Object.fromEntries( - inputs - .filter((input) => !input.secret && values[input.name]) - .map((input) => [input.name, values[input.name] as string]), - ), - method, - ); - }; - - return ( -
- Connections} - count={connections.length} - /> - - {connections.map((connection) => ( -
- -
-
- {connection.account ?? displayName} -
-

- - connected {formatDate(new Date(connection.createdAt))} - -

-
- - - Connected - - -
- ))} - - {methods.length === 0 && ( -
-
-

- - This plugin needs no account — its skills are ready to use. - -

- {installed ? ( - - ) : ( - - )} -
-
- )} - - {methods.length > 0 && installed && ( -
- -
- )} - - {methods.length > 0 && !connected && !isLoading && ( -
- {/* A select with one option is a control that cannot be used — - it only appears once a plugin actually offers a choice. The - single-method case states the fact under Information instead. */} - {methods.length > 1 && ( -
- - -
- )} - - {inputs.length > 0 && ( -
- {inputs.map((input) => { - const fieldId = `${pluginName}-${input.name}`; - return ( -
- - - setValues((current) => ({ - ...current, - [input.name]: event.target.value, - })) - } - /> -
- ); - })} -
- )} - -
- -
- - {connectError && ( -

- {errorMessage(connectError)} -

- )} -
- )} -
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/$pluginName/components/PluginDetail/components/PluginConnections/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/$pluginName/components/PluginDetail/components/PluginConnections/index.ts deleted file mode 100644 index 8da9fee3e51..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/$pluginName/components/PluginDetail/components/PluginConnections/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PluginConnections } from "./PluginConnections"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/$pluginName/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/$pluginName/page.tsx index 638bd1fcc12..2a6335c1a2a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/$pluginName/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/$pluginName/page.tsx @@ -1,23 +1,75 @@ +import { useLingui } from "@lingui/react/macro"; import { FEATURE_FLAGS } from "@superset/shared/constants"; +import { toast } from "@superset/ui/sonner"; import { createFileRoute } from "@tanstack/react-router"; import { useFeatureFlagEnabled } from "posthog-js/react"; +import { useEffect } from "react"; import { Redirect } from "renderer/components/Redirect"; import { env } from "renderer/env.renderer"; import { usePluginCatalog } from "renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginCatalog"; import { PluginDetail } from "./components/PluginDetail"; +export type PluginDetailSearch = { + connected?: string; + error?: string; +}; + export const Route = createFileRoute( "/_authenticated/_dashboard/plugins/$pluginName/", )({ component: PluginDetailPage, + validateSearch: (search: Record): PluginDetailSearch => ({ + connected: + typeof search.connected === "string" ? search.connected : undefined, + error: typeof search.error === "string" ? search.error : undefined, + }), }); +function useConnectOutcome(plugin: string) { + const { t } = useLingui(); + const navigate = Route.useNavigate(); + const { connected, error } = Route.useSearch(); + + useEffect(() => { + if (connected === undefined && !error) return; + + if (connected !== undefined) { + const label = connected || plugin; + toast.success(t({ message: `Connected ${label}` })); + } else if (error === "oauth_denied") { + toast.error( + t({ message: `You declined the ${plugin} authorization request.` }), + ); + } else if (error === "not_installed") { + toast.error(t({ message: `${plugin} is no longer installed.` })); + } else if (error === "invalid_state") { + toast.error( + t({ + message: `That ${plugin} sign-in link expired. Try connecting again.`, + }), + ); + } else if (error === "client_unconfigured") { + toast.error( + t({ + message: `${plugin} has no OAuth client configured on this deployment yet.`, + }), + ); + } else { + toast.error(t({ message: `Could not connect ${plugin}. Try again.` })); + } + + void navigate({ search: {}, replace: true }); + }, [connected, error, navigate, plugin, t]); +} + function PluginDetailPage() { const { pluginName } = Route.useParams(); const isEnabled = useFeatureFlagEnabled(FEATURE_FLAGS.PLUGINS); const { plugins, isLoading } = usePluginCatalog(); const plugin = plugins.find((entry) => entry.name === pluginName); + useConnectOutcome(pluginName); + if (env.NODE_ENV !== "development") { if (isEnabled === undefined) return null; if (!isEnabled) return ; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/components/PluginIcon/PluginIcon.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/components/PluginIcon/PluginIcon.tsx index b6144c6da49..922b92d24db 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/components/PluginIcon/PluginIcon.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/components/PluginIcon/PluginIcon.tsx @@ -2,7 +2,15 @@ import { cn } from "@superset/ui/utils"; import type { IconType } from "react-icons"; import { FaGithub } from "react-icons/fa"; import { LuBookOpen, LuDrama, LuPuzzle } from "react-icons/lu"; -import { SiGooglechrome, SiSentry, SiStripe, SiVercel } from "react-icons/si"; +import { + SiGmail, + SiGooglechrome, + SiGoogledocs, + SiGooglesheets, + SiSentry, + SiStripe, + SiVercel, +} from "react-icons/si"; import { usePresetIcon } from "renderer/assets/app-icons/preset-icons"; import circlebackIconUrl from "renderer/assets/icons/circleback-icon.png"; import figmaIconUrl from "renderer/assets/icons/figma-icon.svg"; @@ -54,6 +62,9 @@ const PLUGIN_ICONS: Record< context7: { icon: LuBookOpen }, playwright: { icon: LuDrama, color: "#2EAD33" }, "chrome-devtools": { icon: SiGooglechrome, color: "#4285F4" }, + gmail: { icon: SiGmail, color: "#EA4335" }, + "google-docs": { icon: SiGoogledocs, color: "#4285F4" }, + "google-sheets": { icon: SiGooglesheets, color: "#0F9D58" }, vercel: { icon: SiVercel, scale: "size-1/2" }, }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/components/PluginsView/PluginsView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/components/PluginsView/PluginsView.tsx index a705bd02a60..35685783fc2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/components/PluginsView/PluginsView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/components/PluginsView/PluginsView.tsx @@ -32,9 +32,9 @@ export function PluginsView() { } = usePluginCatalog(); const isConnected = (plugin: CatalogPlugin) => - plugin.installed && (!plugin.auth || plugin.connections.length > 0); + plugin.installed && (!plugin.connector || plugin.connections.length > 0); - const { uninstall, setEnabled, update, isBusy } = usePluginMutations(); + const { add, uninstall, setEnabled, update, isBusy } = usePluginMutations(); const handleOpen = (plugin: CatalogPlugin) => { navigate({ @@ -102,6 +102,7 @@ export function PluginsView() { isDisabled={plugin.installed && !plugin.enabled} isBusy={isBusy} onOpen={handleOpen} + onInstall={(target) => void add(target.name)} onUninstall={(target) => uninstall(target.name)} onSetEnabled={setEnabled} onUpdate={(name) => void update(name)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/components/PluginsView/components/PluginCard/PluginCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/components/PluginsView/components/PluginCard/PluginCard.tsx index 715468879c7..0726076239f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/components/PluginsView/components/PluginCard/PluginCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/components/PluginsView/components/PluginCard/PluginCard.tsx @@ -26,6 +26,7 @@ interface PluginCardProps { isDisabled: boolean; isBusy: boolean; onOpen: (plugin: CatalogPlugin) => void; + onInstall: (plugin: CatalogPlugin) => void; onUninstall: (plugin: CatalogPlugin) => void; onSetEnabled: (name: string, enabled: boolean) => void; onUpdate: (name: string) => void; @@ -38,6 +39,7 @@ export function PluginCard({ isDisabled, isBusy, onOpen, + onInstall, onUninstall, onSetEnabled, onUpdate, @@ -149,7 +151,20 @@ export function PluginCard({ - ) : null} + ) : ( + + )} ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginCatalog/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginCatalog/index.ts index f1ca4ae0e5f..5a7e6207b9c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginCatalog/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginCatalog/index.ts @@ -1,6 +1,4 @@ export type { - AuthInput, - AuthMethod, CatalogPlugin, PluginSkill, } from "./usePluginCatalog"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginCatalog/usePluginCatalog.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginCatalog/usePluginCatalog.ts index f4c3749022e..fdcff4010fb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginCatalog/usePluginCatalog.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginCatalog/usePluginCatalog.ts @@ -8,12 +8,10 @@ import { cloudTrpc } from "renderer/lib/cloud-trpc"; type CatalogRow = RouterOutputs["plugins"]["list"][number]; -export type AuthMethod = CatalogRow["authMethods"][number]; -export type AuthInput = AuthMethod["inputs"][number]; export type PluginSkill = CatalogRow["skills"][number]; export interface CatalogPlugin extends Omit { - auth?: readonly AuthMethod[]; + connector: string | null; marketplace: string; installed: boolean; latestVersion: string | null; @@ -39,7 +37,7 @@ function toCatalogPlugin(plugin: CatalogRow): CatalogPlugin { mcpServers: plugin.mcpUrl ? { [plugin.name]: { type: "http" as const, url: plugin.mcpUrl } } : {}, - auth: plugin.authMethods.length ? plugin.authMethods : undefined, + connector: plugin.connector, skills: plugin.skills.map((skill) => skill.name), marketplace: plugin.marketplace, installed: plugin.installed, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginConnections/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginConnections/index.ts deleted file mode 100644 index 6f8206f66f3..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginConnections/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { PluginConnection } from "./usePluginConnections"; -export { openPluginOAuth, usePluginConnections } from "./usePluginConnections"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginConnections/usePluginConnections.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginConnections/usePluginConnections.ts deleted file mode 100644 index 8c20fb66150..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/plugins/hooks/usePluginConnections/usePluginConnections.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { RouterOutputs } from "@superset/trpc"; -import { env } from "renderer/env.renderer"; -import { authClient } from "renderer/lib/auth-client"; -import { cloudTrpc } from "renderer/lib/cloud-trpc"; - -export type PluginConnection = - RouterOutputs["plugins"]["connections"]["list"][number]; - -/** - * Sends the browser to the provider. Stays a raw URL because the flow ends in - * a redirect the system browser has to follow, which no procedure can express. - */ -export function openPluginOAuth( - apiUrl: string, - pluginName: string, - inputs: Record, - method: string, -): void { - const url = new URL(`${apiUrl}/api/plugins/${pluginName}/connect`); - for (const [key, value] of Object.entries(inputs)) { - url.searchParams.set(key, value); - } - url.searchParams.set("method", method); - window.open(url.toString(), "_blank", "noopener,noreferrer"); -} - -export function usePluginConnections(pluginName: string) { - const utils = cloudTrpc.useUtils(); - const { data: session } = authClient.useSession(); - const userId = session?.user?.id ?? null; - - const refresh = async () => { - await Promise.all([ - utils.plugins.connections.list.invalidate({ plugin: pluginName }), - utils.plugins.list.invalidate(), - ]); - }; - - const connections = cloudTrpc.plugins.connections.list.useQuery( - { plugin: pluginName }, - { enabled: Boolean(userId) }, - ); - - const disconnect = cloudTrpc.plugins.connections.disconnect.useMutation({ - onMutate: async ({ connectionId }) => { - await utils.plugins.connections.list.cancel({ plugin: pluginName }); - const previous = utils.plugins.connections.list.getData({ - plugin: pluginName, - }); - utils.plugins.connections.list.setData({ plugin: pluginName }, (rows) => - (rows ?? []).filter((row) => row.id !== connectionId), - ); - return { previous }; - }, - onError: (_error, _input, context) => { - if (context?.previous) { - utils.plugins.connections.list.setData( - { plugin: pluginName }, - context.previous, - ); - } - }, - onSettled: refresh, - }); - - const connectApiKey = cloudTrpc.plugins.connectApiKey.useMutation({ - onSuccess: refresh, - }); - - return { - connections: connections.data ?? [], - isLoading: connections.isLoading, - error: connections.error, - refetch: connections.refetch, - connectOAuth: (inputs: Record = {}, method = "oauth2") => - openPluginOAuth(env.NEXT_PUBLIC_API_URL, pluginName, inputs, method), - connectApiKey: (inputs: Record) => - connectApiKey.mutate({ name: pluginName, inputs }), - isConnecting: connectApiKey.isPending, - connectError: connectApiKey.error, - disconnect: (connectionId: string) => disconnect.mutate({ connectionId }), - isDisconnecting: disconnect.isPending, - }; -} diff --git a/apps/marketing/src/app/schemas/plugin/1.0.0.json/route.ts b/apps/marketing/src/app/schemas/plugin/1.0.0.json/route.ts index 576ed30f32a..88c1f4d2c32 100644 --- a/apps/marketing/src/app/schemas/plugin/1.0.0.json/route.ts +++ b/apps/marketing/src/app/schemas/plugin/1.0.0.json/route.ts @@ -1,239 +1,12 @@ -// biome-ignore-all lint/suspicious/noTemplateCurlyInString: ${inputs.*} and ${config.*} are the manifest placeholder syntax this schema documents import { COMPANY } from "@superset/shared/constants"; +import { pluginManifestSchema } from "@superset/shared/plugins/manifest-schema"; +import { z } from "zod"; export function GET() { - const base = COMPANY.MARKETING_URL; - - const authInput = { - type: "object", - required: ["name"], - additionalProperties: false, - properties: { - name: { - type: "string", - pattern: "^[a-z][a-z0-9_]*$", - description: "Referenced elsewhere as ${inputs.}.", - }, - label: { type: "string" }, - placeholder: { type: "string" }, - description: { type: "string" }, - required: { type: "boolean" }, - secret: { - type: "boolean", - description: "Encrypted at rest and never returned to a client.", - }, - }, - }; - - const identity = { - type: "object", - required: ["url", "id"], - additionalProperties: false, - description: - "How to learn which external account a connection belongs to, run once after auth completes. Omit and the connection gets a generated id and no label. Honoured only for first-party plugins: the probe sends the credential to the URL named here, so a manifest from another marketplace is connected without it.", - properties: { - url: { type: "string" }, - method: { enum: ["GET", "POST"], default: "GET" }, - headers: { type: "object", additionalProperties: { type: "string" } }, - body: { - description: "Sent when method is POST. Present for GraphQL providers.", - }, - id: { - type: "string", - pattern: "^\\$\\.", - description: - "JSONPath-lite ($.a.b[0].c) to the stable account id in the response.", - }, - label: { - type: "string", - pattern: "^\\$\\.", - description: "JSONPath-lite to a human-readable account name.", - }, - }, - }; - - const authMethod = { - type: "object", - required: ["type"], - additionalProperties: false, - properties: { - type: { enum: ["oauth2", "api_key"] }, - label: { - type: "string", - description: - "Shown in the picker when a plugin offers several methods.", - }, - provider: { - type: "string", - description: - "Slug of the upstream product, when Superset already models it as an integration.", - }, - inputs: { type: "array", items: authInput }, - credential_input: { - type: "string", - description: - "api_key only: which input holds the secret. It becomes ${config.access_token}, so `bind` is identical for both auth types.", - }, - authorization_url: { type: "string" }, - token_url: { type: "string" }, - scopes: { type: "array", items: { type: "string" } }, - scope_separator: { - type: "string", - default: " ", - description: - "Linear separates with a comma; most providers use a space.", - }, - token_request_auth_method: { - enum: ["client_secret_post", "client_secret_basic"], - default: "client_secret_post", - }, - token_expiration_buffer: { - type: "integer", - minimum: 0, - description: "Seconds subtracted from expires_in when storing.", - }, - requires_env: { - type: "array", - items: { - type: "string", - pattern: "^PLUGIN_[A-Z0-9_]+_CLIENT_(ID|SECRET)$", - }, - description: - "The deployment variables holding this method's OAuth client. Named here rather than derived from the plugin name, so two plugins for one service can share one registered client. Confined to PLUGIN__CLIENT_ID and _CLIENT_SECRET: the token exchange sends what it reads to a manifest-supplied token_url, so an unbounded name would leak unrelated secrets.", - }, - identity, - bind: { - type: "object", - additionalProperties: false, - description: - "How this method's credential reaches the server. Per method, not per plugin: two methods on one plugin can need different headers — Linear sends OAuth tokens as `Bearer ` and personal API keys raw.", - properties: { - headers: { type: "object", additionalProperties: { type: "string" } }, - env: { type: "object", additionalProperties: { type: "string" } }, - }, - }, - }, - allOf: [ - { - if: { properties: { type: { const: "oauth2" } } }, - // biome-ignore lint/suspicious/noThenProperty: JSON Schema's conditional keyword, not a thenable - then: { required: ["authorization_url", "token_url"] }, - }, - { - if: { properties: { type: { const: "api_key" } } }, - // biome-ignore lint/suspicious/noThenProperty: JSON Schema's conditional keyword, not a thenable - then: { required: ["inputs"] }, - }, - ], - }; - const schema = { $schema: "https://json-schema.org/draft/2020-12/schema", - $id: `${base}/schemas/plugin/1.0.0.json`, - title: "Superset plugin manifest", - type: "object", - required: ["name", "version"], - properties: { - $schema: { type: "string" }, - name: { - type: "string", - minLength: 1, - maxLength: 64, - pattern: "^(?!.*(?:--|\\.\\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$", - }, - version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+" }, - description: { type: "string" }, - author: { - type: "object", - properties: { - name: { type: "string" }, - email: { type: "string" }, - url: { type: "string" }, - }, - }, - homepage: { type: "string" }, - repository: { type: "string" }, - license: { type: "string" }, - keywords: { type: "array", items: { type: "string" } }, - extensions: { - type: "object", - properties: { - superset: { - type: "object", - additionalProperties: false, - not: { required: ["mcp", "server"] }, - properties: { - interface: { - type: "object", - required: ["displayName"], - properties: { - displayName: { type: "string" }, - category: { type: "string" }, - icon: { type: "string" }, - }, - }, - auth: { - type: "array", - minItems: 1, - items: authMethod, - description: - "Ways to authenticate this plugin. A list because a plugin may offer more than one and they are not interchangeable.", - }, - bind: { - type: "object", - additionalProperties: false, - description: - "How the credential reaches the server. No server key: a plugin has exactly one. Resolved inside the proxy, never written to a config file on disk.", - properties: { - headers: { - type: "object", - additionalProperties: { type: "string" }, - }, - env: { - type: "object", - additionalProperties: { type: "string" }, - }, - }, - }, - server: { - type: "object", - required: ["path", "integrity"], - additionalProperties: false, - description: - "Written by `superset plugins publish` into the published snapshot, never hand-authored. Addresses the bundled server relative to the marketplace repo root and pins its bytes, so the host can verify what it downloads before importing it.", - properties: { - path: { type: "string" }, - integrity: { - type: "string", - pattern: "^sha256-[A-Za-z0-9+/]+=*$", - }, - ref: { - type: "string", - description: - "The release tag the bytes were published at, so the download is pinned to the version rather than the marketplace's branch.", - }, - }, - }, - mcp: { - type: "object", - required: ["type", "url"], - additionalProperties: false, - description: - "A single remote server, not a map: a plugin serves tools from exactly one place. Omit it when the plugin ships a bundled server instead. Streamable HTTP only \u2014 the legacy HTTP+SSE transport is not supported.", - properties: { - type: { const: "streamable-http" }, - url: { type: "string", format: "uri" }, - headers: { - type: "object", - additionalProperties: { type: "string" }, - }, - }, - }, - }, - }, - }, - }, - }, + $id: `${COMPANY.MARKETING_URL}/schemas/plugin/1.0.0.json`, + ...z.toJSONSchema(pluginManifestSchema, { io: "input" }), }; return Response.json(schema, { diff --git a/apps/web/src/app/(app)/(page)/connect/[connector]/components/ConnectorCallbackToast/ConnectorCallbackToast.tsx b/apps/web/src/app/(app)/(page)/connect/[connector]/components/ConnectorCallbackToast/ConnectorCallbackToast.tsx new file mode 100644 index 00000000000..22357bab100 --- /dev/null +++ b/apps/web/src/app/(app)/(page)/connect/[connector]/components/ConnectorCallbackToast/ConnectorCallbackToast.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useLingui } from "@lingui/react/macro"; +import { toast } from "@superset/ui/sonner"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +interface ConnectorCallbackToastProps { + slug: string; + plugin: string; +} + +/** + * Reports what the connector callback redirected back with. + * + * The API sends every connector here, so this replaces the seven per-provider + * handlers for anything in the registry. Messages are the ones the plugin + * detail page already uses, keyed the same way, so the two surfaces say the + * same thing about the same failure. + */ +export function ConnectorCallbackToast({ + slug, + plugin, +}: ConnectorCallbackToastProps) { + const { t } = useLingui(); + const router = useRouter(); + const params = useSearchParams(); + const connected = params.get("connected"); + const error = params.get("error"); + const owner = params.get("owner"); + + useEffect(() => { + if (!connected && !error) return; + + if (connected) { + // `label` keeps this the same message the plugin detail page shows. + const label = plugin; + toast.success(t({ message: `Connected ${label}` })); + } else if (error === "oauth_denied") { + toast.error( + t({ message: `You declined the ${plugin} authorization request.` }), + ); + } else if (error === "invalid_state") { + toast.error( + t({ + message: `That ${plugin} sign-in link expired. Try connecting again.`, + }), + ); + } else if (error === "not_configured") { + toast.error( + t({ + message: `${plugin} has no OAuth client configured on this deployment yet.`, + }), + ); + } else if (error === "account_already_linked") { + toast.error( + owner + ? t({ + message: `This ${plugin} account is already connected by ${owner}. Ask them to disconnect first.`, + }) + : t({ + message: `This ${plugin} account is already connected by another Superset organization.`, + }), + ); + } else { + toast.error(t({ message: `Could not connect ${plugin}. Try again.` })); + } + + // The toast has fired; a refresh or a back-and-forward must not repeat it. + router.replace(`/connect/${slug}`); + }, [connected, error, owner, slug, plugin, router, t]); + + return null; +} diff --git a/apps/web/src/app/(app)/(page)/connect/[connector]/components/ConnectorCallbackToast/index.ts b/apps/web/src/app/(app)/(page)/connect/[connector]/components/ConnectorCallbackToast/index.ts new file mode 100644 index 00000000000..7391807e99a --- /dev/null +++ b/apps/web/src/app/(app)/(page)/connect/[connector]/components/ConnectorCallbackToast/index.ts @@ -0,0 +1 @@ +export { ConnectorCallbackToast } from "./ConnectorCallbackToast"; diff --git a/apps/web/src/app/(app)/(page)/connect/[connector]/components/ConnectorConnect/ConnectorConnect.tsx b/apps/web/src/app/(app)/(page)/connect/[connector]/components/ConnectorConnect/ConnectorConnect.tsx new file mode 100644 index 00000000000..55fdcc6e311 --- /dev/null +++ b/apps/web/src/app/(app)/(page)/connect/[connector]/components/ConnectorConnect/ConnectorConnect.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { Trans, useLingui } from "@lingui/react/macro"; +import { Badge } from "@superset/ui/badge"; +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2, Unplug } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { env } from "@/env"; +import { useTRPC } from "@/trpc/react"; + +interface MethodSummary { + type: "oauth2" | "api_key" | "app_install" | "admin_consent"; + label: string; + inputs: readonly { + name: string; + label?: string; + placeholder?: string; + description?: string; + required: boolean; + secret: boolean; + }[]; +} + +interface ConnectorConnectProps { + slug: string; + displayName: string; + organizationId: string; + methods: readonly MethodSummary[]; + connection: { + id: string; + externalAccountLabel: string | null; + externalUserLabel: string | null; + } | null; +} + +export function ConnectorConnect({ + slug, + displayName, + organizationId, + methods, + connection, +}: ConnectorConnectProps) { + const trpc = useTRPC(); + const router = useRouter(); + const queryClient = useQueryClient(); + const { t } = useLingui(); + + const [selected, setSelected] = useState(methods[0]?.type ?? "oauth2"); + const [values, setValues] = useState>({}); + const [error, setError] = useState(null); + + const invalidate = () => { + queryClient.invalidateQueries({ + queryKey: trpc.connectors.status.queryKey({ organizationId }), + }); + router.refresh(); + }; + + const connectApiKey = useMutation( + trpc.connectors.connectApiKey.mutationOptions({ + onSuccess: invalidate, + onError: (e) => setError(e.message), + }), + ); + + const disconnect = useMutation( + trpc.connectors.disconnect.mutationOptions({ onSuccess: invalidate }), + ); + + if (connection) { + const who = connection.externalUserLabel; + const where = connection.externalAccountLabel; + return ( +
+
+ + + Connected + + + {who && where ? `${who} · ${where}` : (who ?? where ?? "")} + +
+ +
+ ); + } + + const method = methods.find((m) => m.type === selected) ?? methods[0]; + if (!method) return null; + + const redirect = () => { + window.location.href = `${env.NEXT_PUBLIC_API_URL}/api/connectors/${slug}/connect?organizationId=${organizationId}&method=${method.type}`; + }; + + return ( +
+ {methods.length > 1 && ( +
+ {methods.map((m) => ( + + ))} +
+ )} + + {method.type === "api_key" ? ( +
{ + e.preventDefault(); + setError(null); + connectApiKey.mutate({ organizationId, slug, inputs: values }); + }} + > + {method.inputs.map((field) => ( +
+ + + setValues({ ...values, [field.name]: e.target.value }) + } + /> + {field.description && ( +

+ {field.description} +

+ )} +
+ ))} + +
+ ) : ( + + )} + + {error &&

{error}

} +
+ ); +} diff --git a/apps/web/src/app/(app)/(page)/connect/[connector]/components/ConnectorConnect/index.ts b/apps/web/src/app/(app)/(page)/connect/[connector]/components/ConnectorConnect/index.ts new file mode 100644 index 00000000000..bc97e04274e --- /dev/null +++ b/apps/web/src/app/(app)/(page)/connect/[connector]/components/ConnectorConnect/index.ts @@ -0,0 +1 @@ +export { ConnectorConnect } from "./ConnectorConnect"; diff --git a/apps/web/src/app/(app)/(page)/connect/[connector]/page.tsx b/apps/web/src/app/(app)/(page)/connect/[connector]/page.tsx new file mode 100644 index 00000000000..8a21c1d2f28 --- /dev/null +++ b/apps/web/src/app/(app)/(page)/connect/[connector]/page.tsx @@ -0,0 +1,107 @@ +import { msg } from "@lingui/core/macro"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@superset/ui/card"; +import { notFound } from "next/navigation"; +import { initServerI18n } from "@/lib/i18n-server"; +import { api } from "@/trpc/server"; +import { ConnectorCallbackToast } from "./components/ConnectorCallbackToast"; +import { ConnectorConnect } from "./components/ConnectorConnect"; + +export default async function ConnectorPage({ + params, +}: { + params: Promise<{ connector: string }>; +}) { + const { connector: slug } = await params; + const i18n = await initServerI18n(); + const trpc = await api(); + const organization = await trpc.user.myOrganization.query(); + + if (!organization) { + return ( +
+

+ {i18n._( + msg({ + message: + "You need to be part of an organization to connect accounts.", + }), + )} +

+
+ ); + } + + const connector = await trpc.connectors.get.query({ slug }).catch(() => null); + if (!connector) notFound(); + + const live = await trpc.connectors.status.query({ + organizationId: organization.id, + }); + const connection = live.find((row) => row.connector === slug) ?? null; + + return ( +
+ +
+

{connector.displayName}

+

+ {connector.scope === "org" + ? i18n._( + msg({ + message: "Connects for your whole team.", + }), + ) + : i18n._( + msg({ + message: + "Connects your account. Teammates connect their own.", + }), + )} +

+
+ + + + + {i18n._( + msg({ + message: "Connection", + }), + )} + + + {i18n._( + msg({ + message: + "Authorize Superset so your plugins, automations and triggers can act on your behalf.", + }), + )} + + + + + + +
+ ); +} diff --git a/apps/web/src/app/plugins/[plugin]/page.tsx b/apps/web/src/app/plugins/[plugin]/page.tsx new file mode 100644 index 00000000000..c6edad3382c --- /dev/null +++ b/apps/web/src/app/plugins/[plugin]/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Trans } from "@lingui/react/macro"; +import Image from "next/image"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +export default function PluginDeepLinkPage() { + const params = useParams<{ plugin: string }>(); + const plugin = encodeURIComponent(params.plugin); + const [deepLink, setDeepLink] = useState(`superset://plugins/${plugin}`); + + useEffect(() => { + const target = `superset://plugins/${plugin}${window.location.search}`; + setDeepLink(target); + window.location.href = target; + }, [plugin]); + + return ( +
+
+ Superset +

+ Redirecting to desktop app... +

+ + Click here if not redirected + +
+
+ ); +} diff --git a/bun.lock b/bun.lock index 3fef0dc9cce..d0911617424 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@types/bun": "1.3.14", "@types/semver": "7.7.1", "@vercel/sandbox": "3.2.2", + "babel-plugin-macros": "3.1.0", "dotenv-cli": "11.0.0", "semver": "7.7.4", "sherif": "1.11.0", @@ -886,6 +887,7 @@ "version": "1.29.0", "dependencies": { "@clack/prompts": "0.10.1", + "@modelcontextprotocol/sdk": "1.30.0", "@superset/agent-setup": "workspace:*", "@superset/cli-framework": "workspace:*", "@superset/host-service": "workspace:*", @@ -900,6 +902,7 @@ "mime-types": "3.0.2", "react": "19.2.3", "superjson": "2.2.6", + "zod": "4.4.3", }, "devDependencies": { "@superset/typescript": "workspace:*", @@ -1237,6 +1240,7 @@ "@googleapis/gmail": "18.0.0", "@linear/sdk": "68.1.1", "@microsoft/microsoft-graph-client": "3.0.7", + "@modelcontextprotocol/sdk": "1.30.0", "@notionhq/client": "5.25.2", "@octokit/app": "16.1.2", "@sentry/core": "10.68.0", @@ -3599,6 +3603,8 @@ "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], + "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], + "@types/pg": ["@types/pg@8.21.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q=="], "@types/pidusage": ["@types/pidusage@2.0.5", "", {}, "sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA=="], @@ -3905,6 +3911,8 @@ "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], + "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], + "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.13.0", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A=="], @@ -4165,7 +4173,7 @@ "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], - "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], + "cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], @@ -4955,7 +4963,7 @@ "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], - "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], @@ -7295,7 +7303,7 @@ "connect/finalhandler": ["finalhandler@1.1.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "~2.3.0", "parseurl": "~1.3.3", "statuses": "~1.5.0", "unpipe": "~1.0.0" } }, "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA=="], - "cosmiconfig/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "cosmiconfig/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], "crc/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], @@ -7335,8 +7343,6 @@ "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], - "esquery/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], @@ -7647,6 +7653,8 @@ "shadcn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "shadcn/cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], + "shadcn/fs-extra": ["fs-extra@11.4.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA=="], "shadcn/open": ["open@11.0.1", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.0", "wsl-utils": "^1.0.0" } }, "sha512-NzwMUB6C1D0+Kd+9iMS/H4k+Ck3cTX6Ckyfr/gAGlmvSE1LUQZnEZvWBi4PYmMwH/S5SMeTXnE+9uAz8uF+pWw=="], @@ -7669,6 +7677,8 @@ "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], + "simple-swizzle/is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + "socket.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -8781,6 +8791,8 @@ "react-native/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "shadcn/cosmiconfig/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "shadcn/open/wsl-utils": ["wsl-utils@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="], "shell-env/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], diff --git a/docs/plugins.md b/docs/plugins.md index 4907ddbf861..c477a1d7a66 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -6,26 +6,28 @@ and are listed in `.agent-marketplace.json` at the repo root. ## What is source and what is generated -Only three things in a plugin directory are hand-written: +Only two things in a plugin directory are hand-written: | Path | Owner | | --- | --- | | `plugins//plugin.json` | you | | `plugins//skills/*/SKILL.md` | you | -| `plugins//src/index.ts` | you (optional MCP server) | -| `plugins//server/` | `superset plugins build` (committed: it is what a release ships) | | `.agent-marketplace.json` | `superset plugins create` / `publish` | | `packages/shared/src/plugins/manifests.generated.ts` | `superset plugins publish` | +A plugin ships no code. Tools come either from the vendor's own MCP server, which Superset proxies +to, or from a server Superset hosts itself for the handful of plugins whose vendor publishes none +(`SUPERSET_HOSTED_PLUGINS` in `packages/shared/src/plugins/index.ts`, implemented under +`packages/trpc/src/router/plugins/servers/`). Hosted tools ship on an API deploy, not a plugin tag. + `manifests.generated.ts` is generated; editing it by hand is the one way to get a marketplace that disagrees with itself. It exists because the API must resolve `token_url` and the proxy target *without* trusting anything client-supplied; a manifest posted in a request would be an exfiltration path. **A release is a git tag, not a folder.** `@` on the marketplace repo is the version: -`plugins install` fetches that tag and takes the plugin's tree at it, and the generated manifest -pins a bundled server by `path` + `ref` + `integrity`, so the bytes a host downloads belong to the -version rather than to whatever the branch holds now. A `path:` marketplace has no releases in it +`plugins install` fetches that tag and takes the plugin's skills at it, so what a host installs +belongs to the version rather than to whatever the branch holds now. A `path:` marketplace has no releases in it and installs the working tree, which is what makes local authoring work. ## Changing a plugin @@ -52,19 +54,46 @@ that must not change under them. - `interface` — `displayName`, `category` (one of `PLUGIN_CATEGORIES` in `packages/shared/src/plugins/index.ts`), and `icon`. -- `auth` — an array of methods, each `oauth2` or `api_key`. OAuth entries carry - `authorization_url`, `token_url`, `scopes`, `requires_env` (the client id/secret env names the API - reads — name the service's pair, not the plugin's, so two plugins for one service share one - registered OAuth app; only `PLUGIN__CLIENT_ID`/`_SECRET` may be named), an `identity` - probe that names the connected account, and `bind`, which says how the - credential is attached to outbound calls. `${config.access_token}` and `${inputs.}` - placeholders are resolved server-side by `apps/api/src/lib/plugins/manifest.ts`. -- `mcpServers` — server name → config, the same shape as an `.mcp.json` value. The name lands - verbatim as a config key in agent CLIs. - -Credentials never reach the manifest, the renderer, or the agent's machine. They are encrypted at -rest with `BETTER_AUTH_SECRET` (`apps/api/src/lib/plugins/crypto.ts`) and attached by the proxy in -`apps/api/src/lib/plugins/dispatch.ts`, so a tool call goes out from the API, not from the agent. +- `connector` — the one connection this plugin needs, as `{ "slug" }`. A slug names a connector in + `packages/shared/src/connectors/connectors.json`; the manifest carries no OAuth configuration of + its own — no scopes, no client mode, no `requires_env`. A connection is account state, and the + connections system already owns obtaining, refreshing and disconnecting it, so naming one is all + a plugin does. Omit it for a plugin whose tools need no connection. +- `bind` — how the connection's credential is attached to outbound calls. + `${config.access_token}` and `${inputs.}` placeholders are resolved server-side by + `packages/trpc/src/router/plugins/manifest.ts`. +- `mcp` — the vendor's own MCP server (`type: "streamable-http"`, `url`, optional `headers`), not a + map: a plugin serves tools from exactly one place. Omit it for a Superset-hosted plugin. + +The published JSON Schema at `https://superset.sh/schemas/plugin/1.0.0.json` is generated from +`pluginManifestSchema` in `packages/shared/src/plugins/manifest-schema.ts`, and +`superset plugins publish` validates against that same definition — so the schema we serve and the +schema we enforce cannot drift. + +### Where a connection comes from + +How a connector obtains its connection is the connections system's decision, not a manifest field. +A connector either names a pre-registered client through `requires_env` in `connectors.json`, or +declares `"client": "dynamic"` and takes both endpoints and client identity from the MCP server at +connect time: the API reads `/.well-known/oauth-protected-resource`, follows it to the +authorization server's metadata, and then gets a client identity one of two ways: + +- the server advertises `client_id_metadata_document_supported`, so the client id is the URL of a + document we host at `/api/connectors//client-metadata` — nothing is registered or + stored; or +- the server offers a `registration_endpoint`, so we register once per authorization server + (RFC 7591) and keep the result in `plugin_oauth_clients`, keyed by issuer and redirect URI so + every user shares one registration. + +Either way the flow is PKCE with a `resource` indicator, the authorization response's `iss` is +checked against the discovered issuer before the code is redeemed (RFC 9207), and refresh happens +before a tool call. This is what lets a hosted MCP server be installable without anyone registering an +OAuth app first. + +Credentials never reach the manifest, the renderer, or the agent's machine. They are sealed at rest +by `packages/trpc/src/lib/secret-box.ts` under `SECRETS_ENCRYPTION_KEY` and attached by the proxy in +`packages/trpc/src/router/plugins/proxy/`, so a tool call goes out from the API, not from the +agent. ## Install state on a machine diff --git a/package.json b/package.json index 8fbc35ccc08..c3193d4c6cf 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@types/bun": "1.3.14", "@types/semver": "7.7.1", "@vercel/sandbox": "3.2.2", + "babel-plugin-macros": "3.1.0", "dotenv-cli": "11.0.0", "semver": "7.7.4", "sherif": "1.11.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 9a779286b18..762a7d4157a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@clack/prompts": "0.10.1", + "@modelcontextprotocol/sdk": "1.30.0", "@superset/agent-setup": "workspace:*", "@superset/cli-framework": "workspace:*", "@superset/host-service": "workspace:*", @@ -29,7 +30,8 @@ "ink": "7.0.1", "mime-types": "3.0.2", "react": "19.2.3", - "superjson": "2.2.6" + "superjson": "2.2.6", + "zod": "4.4.3" }, "devDependencies": { "@superset/typescript": "workspace:*", diff --git a/packages/cli/src/commands/mcp/call-tool/command.ts b/packages/cli/src/commands/mcp/call-tool/command.ts index d6455dbcf14..3842a524a35 100644 --- a/packages/cli/src/commands/mcp/call-tool/command.ts +++ b/packages/cli/src/commands/mcp/call-tool/command.ts @@ -1,28 +1,28 @@ import { CLIError, positional, string } from "@superset/cli-framework"; import { command } from "../../../lib/command"; -import { resolveConnectionId } from "../../../lib/plugins/connection-ref"; +import { resolvePluginName } from "../../../lib/plugins/connection-ref"; import { readStdin } from "../../../lib/plugins/inputs"; +import { connectPluginMcp } from "../../../lib/plugins/mcp-client"; export default command({ description: "Call a tool on a connected plugin", args: [ - positional("plugin").desc("Plugin name, when it has one connection"), positional("tool").required().desc("Tool name"), positional("arguments").desc( 'Tool arguments as JSON (default: {}; "-" reads them from stdin)', ), ], options: { - connection: string().desc("Connection id from `superset plugins list`"), - pluginId: string().desc("Deprecated alias for --connection"), + plugin: string().desc("Plugin name from `superset plugins list`"), + connection: string().desc("Deprecated alias for --plugin"), + pluginId: string().desc("Deprecated alias for --plugin"), }, run: async ({ ctx, args, options }) => { const tool = args.tool as string; - const connectionId = await resolveConnectionId(ctx.api, { - connection: (options.connection ?? options.pluginId) as - | string - | undefined, - plugin: args.plugin as string | undefined, + const pluginName = resolvePluginName({ + plugin: options.plugin as string | undefined, + connection: options.connection as string | undefined, + pluginId: options.pluginId as string | undefined, }); const raw = args.arguments as string | undefined; @@ -36,15 +36,18 @@ export default command({ ); } - const { result } = await ctx.api.plugins.tools.call.mutate({ - connectionId, - tool, - arguments: parsed, - }); - - return { - data: result, - message: JSON.stringify(result, null, 2), - }; + const { client } = await connectPluginMcp(pluginName, ctx.bearer); + try { + const result = await client.callTool({ + name: tool, + arguments: parsed, + }); + return { + data: result, + message: JSON.stringify(result, null, 2), + }; + } finally { + await client.close(); + } }, }); diff --git a/packages/cli/src/commands/mcp/tools/command.ts b/packages/cli/src/commands/mcp/tools/command.ts index e760d8b7313..124b4521282 100644 --- a/packages/cli/src/commands/mcp/tools/command.ts +++ b/packages/cli/src/commands/mcp/tools/command.ts @@ -1,13 +1,15 @@ -import { positional, string, table } from "@superset/cli-framework"; +import { string, table } from "@superset/cli-framework"; import { command } from "../../../lib/command"; -import { resolveConnectionId } from "../../../lib/plugins/connection-ref"; +import { resolvePluginName } from "../../../lib/plugins/connection-ref"; +import { connectPluginMcp } from "../../../lib/plugins/mcp-client"; export default command({ description: "List the tools a connected plugin exposes", - args: [positional("plugin").desc("Plugin name, when it has one connection")], + args: [], options: { - connection: string().desc("Connection id from `superset plugins list`"), - pluginId: string().desc("Deprecated alias for --connection"), + plugin: string().desc("Plugin name from `superset plugins list`"), + connection: string().desc("Deprecated alias for --plugin"), + pluginId: string().desc("Deprecated alias for --plugin"), }, display: (data) => table( @@ -16,25 +18,26 @@ export default command({ ["PLUGIN", "TOOL", "DESCRIPTION"], [16, 30, 70], ), - run: async ({ ctx, args, options }) => { - const connectionId = await resolveConnectionId(ctx.api, { - connection: (options.connection ?? options.pluginId) as - | string - | undefined, - plugin: args.plugin as string | undefined, + run: async ({ ctx, options }) => { + const pluginName = resolvePluginName({ + plugin: options.plugin as string | undefined, + connection: options.connection as string | undefined, + pluginId: options.pluginId as string | undefined, }); - const { plugin, tools } = await ctx.api.plugins.tools.list.query({ - connectionId, - }); - - return { - data: tools.map((tool) => ({ - plugin, - tool: tool.name, - description: tool.description ?? "", - })), - message: `${tools.length} tool${tools.length === 1 ? "" : "s"} on ${plugin}.`, - }; + const { client, ref } = await connectPluginMcp(pluginName, ctx.bearer); + try { + const { tools } = await client.listTools(); + return { + data: tools.map((tool) => ({ + plugin: ref.plugin, + tool: tool.name, + description: tool.description ?? "", + })), + message: `${tools.length} tool${tools.length === 1 ? "" : "s"} on ${ref.plugin}.`, + }; + } finally { + await client.close(); + } }, }); diff --git a/packages/cli/src/commands/plugins/build/command.ts b/packages/cli/src/commands/plugins/build/command.ts deleted file mode 100644 index 7af5b30b22f..00000000000 --- a/packages/cli/src/commands/plugins/build/command.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { boolean, positional, table } from "@superset/cli-framework"; -import { command } from "../../../lib/command"; -import { buildPlugin } from "../../../lib/plugins/build"; -import { - findMarketplace, - resolvePlugins, -} from "../../../lib/plugins/marketplace"; - -export default command({ - description: "Build plugin servers from TypeScript source", - args: [ - positional("names") - .variadic() - .desc("Plugin names to build (default: every plugin in the marketplace)"), - ], - options: { - force: boolean().desc("Rebuild even when the existing build is current"), - }, - skipMiddleware: true, - display: (data) => - table( - (data ?? []) as Record[], - ["name", "status", "size"], - ["PLUGIN", "STATUS", "SIZE"], - [24, 16, 12], - ), - run: async ({ args, options }) => { - const ctx = findMarketplace(); - const plugins = resolvePlugins(ctx, args.names as string[] | undefined); - const results = []; - for (const plugin of plugins) { - results.push( - await buildPlugin(plugin, { force: options.force as boolean }), - ); - } - - const data = results.map((result) => ({ - name: result.name, - status: result.built ? "built" : (result.reason ?? "skipped"), - size: result.bytes ? `${(result.bytes / 1024).toFixed(0)} KB` : "-", - })); - const built = results.filter((r) => r.built).length; - - return { - data, - message: built - ? `Built ${built} of ${results.length} plugin${results.length === 1 ? "" : "s"}.` - : "Nothing to build; every server is up to date.", - }; - }, -}); diff --git a/packages/cli/src/commands/plugins/connect/command.ts b/packages/cli/src/commands/plugins/connect/command.ts index 4f275b35077..35af73d65b4 100644 --- a/packages/cli/src/commands/plugins/connect/command.ts +++ b/packages/cli/src/commands/plugins/connect/command.ts @@ -1,5 +1,3 @@ -import fs from "node:fs"; -import path from "node:path"; import { CLIError, positional, string, table } from "@superset/cli-framework"; import { command } from "../../../lib/command"; import { getApiUrl } from "../../../lib/config"; @@ -13,7 +11,6 @@ import { missingInputsError, parseInputs, } from "../../../lib/plugins/inputs"; -import { supersetExtension } from "../../../lib/plugins/marketplace"; export default command({ description: "Connect an account to an installed plugin", @@ -54,15 +51,23 @@ export default command({ ); } - const methods = - supersetExtension( - JSON.parse( - fs.readFileSync( - path.join(installed.installPath, "plugin.json"), - "utf8", - ), - ), - )?.auth ?? []; + const catalog = await ctx.api.plugins.list.query(); + const slug = + catalog.find( + (entry) => + entry.name === name && + (!marketplace || entry.marketplace === marketplace), + )?.connector ?? undefined; + + if (!slug) { + return { + data: [{ plugin: name, status: "not required", detail: "" }], + message: `"${name}" needs no connection.`, + }; + } + + const connector = await ctx.api.connectors.get.query({ slug }); + const methods = connector.methods; const requested = options.method as string | undefined; const auth = requested @@ -85,10 +90,7 @@ export default command({ } if (!auth) { - return { - data: [{ plugin: name, status: "not required", detail: "" }], - message: `"${name}" needs no connection.`, - }; + throw new CLIError(`"${slug}" has no ${requested ?? "default"} method.`); } const declared = (auth.inputs ?? []) as AuthInputSpec[]; @@ -100,19 +102,23 @@ export default command({ ); if (missing.length) throw missingInputsError(name, missing, declared); - const created = await ctx.api.plugins.connectApiKey.mutate({ - name, + const organization = await ctx.api.user.myOrganization.query(); + if (!organization) { + throw new CLIError( + "You need to be part of an organization to connect accounts.", + ); + } + + const created = await ctx.api.connectors.connectApiKey.mutate({ + organizationId: organization.id, + slug, inputs: provided, }); return { data: [ - { - plugin: name, - status: "connected", - detail: created.account ?? created.connectionId, - }, + { plugin: name, status: "connected", detail: created.connectionId }, ], - message: `Connected ${name}${created.account ? ` as ${created.account}` : ""}.`, + message: `Connected ${slug} for ${name}.`, }; } @@ -140,7 +146,14 @@ export default command({ ), method: auth.type, }); - const url = `${getApiUrl()}/api/plugins/${name}/connect?${params}`; + const organization = await ctx.api.user.myOrganization.query(); + if (!organization) { + throw new CLIError( + "You need to be part of an organization to connect accounts.", + ); + } + params.set("organizationId", organization.id); + const url = `${getApiUrl()}/api/connectors/${slug}/connect?${params}`; return { data: [{ plugin: name, status: "authorize", detail: url }], @@ -148,7 +161,7 @@ export default command({ `Open this URL to authorize ${name}:`, ` ${url}`, "", - `Then confirm with: superset plugins connections --plugin ${name}`, + `Then confirm with: superset plugins list`, ].join("\n"), }; }, diff --git a/packages/cli/src/commands/plugins/connections/command.ts b/packages/cli/src/commands/plugins/connections/command.ts deleted file mode 100644 index 23fa1687749..00000000000 --- a/packages/cli/src/commands/plugins/connections/command.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { string, table } from "@superset/cli-framework"; -import { command } from "../../../lib/command"; - -export default command({ - description: "List the plugin accounts connected to your Superset account", - options: { - plugin: string().desc("Only this plugin"), - }, - display: (data) => - table( - (data ?? []) as Record[], - ["plugin", "account", "id"], - ["PLUGIN", "ACCOUNT", "CONNECTION"], - [18, 36, 38], - ), - run: async ({ ctx, options }) => { - const plugin = options.plugin as string | undefined; - const connections = await ctx.api.plugins.connections.list.query({ - plugin, - }); - - return { - data: connections.map((connection) => ({ - plugin: connection.plugin, - account: connection.account ?? connection.accountId, - id: connection.id, - createdAt: connection.createdAt, - })), - message: connections.length - ? `${connections.length} connection${connections.length === 1 ? "" : "s"}.` - : "No connections. Connect one with: superset plugins connect ", - }; - }, -}); diff --git a/packages/cli/src/commands/plugins/create/command.ts b/packages/cli/src/commands/plugins/create/command.ts index e3a01bc7236..c391e6838c6 100644 --- a/packages/cli/src/commands/plugins/create/command.ts +++ b/packages/cli/src/commands/plugins/create/command.ts @@ -13,13 +13,15 @@ export default command({ options: { kind: string() .required() - .enum("url", "server", "none") + .enum("url", "none") .desc( - "Where tools come from: url (remote MCP server), server (custom MCP you write), none (skills only)", + "Where tools come from: url (remote MCP server), none (skills only)", ), url: string().desc("MCP server URL, required when --kind url"), skills: boolean().desc("Scaffold a skills/ folder with a starter skill"), - auth: boolean().desc("Include an OAuth2 block to fill in"), + connector: string().desc( + "Slug of the connector this plugin needs a connection to", + ), "display-name": string().desc( "Name shown in the UI (default: derived from name)", ), @@ -46,17 +48,12 @@ export default command({ description: options.description as string | undefined, category: options.category as string | undefined, skills: Boolean(options.skills), - auth: Boolean(options.auth), + connector: options.connector as string | undefined, }); - const next = - options.kind === "server" - ? `Edit ${result.dir}/src/tools.ts, then run: superset plugins publish ${name}` - : `Edit ${result.dir}/plugin.json, then run: superset plugins publish ${name}`; - return { data: result.files.map((file) => ({ file: `${result.dir}/${file}` })), - message: `Created ${name} (${result.files.length} files) and added it to ${ctx.marketplace.name}. ${next}`, + message: `Created ${name} (${result.files.length} files) and added it to ${ctx.marketplace.name}. Edit ${result.dir}/plugin.json, then run: superset plugins publish ${name}`, }; }, }); diff --git a/packages/cli/src/commands/plugins/install/command.ts b/packages/cli/src/commands/plugins/install/command.ts index c874f71300e..fa6cfc1110c 100644 --- a/packages/cli/src/commands/plugins/install/command.ts +++ b/packages/cli/src/commands/plugins/install/command.ts @@ -1,6 +1,12 @@ import fs from "node:fs"; import path from "node:path"; -import { boolean, positional, string, table } from "@superset/cli-framework"; +import { + boolean, + CLIError, + positional, + string, + table, +} from "@superset/cli-framework"; import { command } from "../../../lib/command"; import { resolvePluginRef } from "../../../lib/plugins/host"; import { @@ -12,7 +18,7 @@ import { ensureDefaultMarketplace, installPlugin, } from "../../../lib/plugins/install"; -import { supersetExtension } from "../../../lib/plugins/marketplace"; +import { pluginConnector } from "../../../lib/plugins/marketplace"; export default command({ description: @@ -64,19 +70,23 @@ export default command({ accountError = error instanceof Error ? error.message : String(error); } - const methods = - supersetExtension( - JSON.parse( - fs.readFileSync(path.join(local.installPath, "plugin.json"), "utf8"), - ), - )?.auth ?? []; + const slug = pluginConnector( + JSON.parse( + fs.readFileSync(path.join(local.installPath, "plugin.json"), "utf8"), + ), + ); + + const connector = slug + ? await ctx.api.connectors.get.query({ slug }) + : null; + const methods = connector?.methods ?? []; const auth = methods.length === 1 ? methods[0] : undefined; let connection = accountError ? "account sync failed" : "not required"; if (account?.needsConnection && methods.length > 1) { connection = `needs connection: superset plugins connect ${name} --method <${methods.map((m) => m.type).join("|")}>`; - } else if (account?.needsConnection && auth) { + } else if (account?.needsConnection && auth && slug) { const connections = await ctx.api.plugins.connections.list.query({ plugin: name, }); @@ -94,11 +104,18 @@ export default command({ if (missing.length) { throw missingInputsError(name, missing, declared); } - const created = await ctx.api.plugins.connectApiKey.mutate({ - name, + const organization = await ctx.api.user.myOrganization.query(); + if (!organization) { + throw new CLIError( + "You need to be part of an organization to connect accounts.", + ); + } + await ctx.api.connectors.connectApiKey.mutate({ + organizationId: organization.id, + slug, inputs: provided, }); - connection = `connected as ${created.account ?? "unknown"}`; + connection = `connected ${slug}`; } else { connection = "authorize in a browser"; } diff --git a/packages/cli/src/commands/plugins/list/command.ts b/packages/cli/src/commands/plugins/list/command.ts index bf7283ae2f8..aadc10ed3c8 100644 --- a/packages/cli/src/commands/plugins/list/command.ts +++ b/packages/cli/src/commands/plugins/list/command.ts @@ -13,8 +13,8 @@ export default command({ display: (data) => table( (data ?? []) as Record[], - ["name", "version", "status", "account", "pluginId"], - ["PLUGIN", "VERSION", "STATUS", "ACCOUNT", "PLUGIN ID"], + ["name", "version", "status", "account", "connectionId"], + ["PLUGIN", "VERSION", "STATUS", "ACCOUNT", "CONNECTION"], [16, 9, 24, 24, 38], ), run: async ({ ctx, options }) => { @@ -22,12 +22,16 @@ export default command({ const visible = options.available ? plugins - : plugins.filter((plugin) => plugin.installed); + : plugins.filter( + (plugin) => plugin.installed || plugin.connections.length > 0, + ); const status = (plugin: CatalogPlugin) => { - if (!plugin.installed) return "available"; + if (!plugin.installed) { + return plugin.connections.length ? "not installed" : "available"; + } if (!plugin.enabled) return "disabled"; - if (plugin.authMethods.length > 0 && plugin.connections.length === 0) { + if (plugin.connector && plugin.connections.length === 0) { return "needs connection"; } return plugin.accounts.length @@ -46,11 +50,11 @@ export default command({ description: plugin.description, }; if (plugin.connections.length === 0) { - return [{ ...row, pluginId: "", account: null }]; + return [{ ...row, connectionId: "", account: null }]; } return plugin.connections.map((connection) => ({ ...row, - pluginId: connection.id, + connectionId: connection.id, account: connection.account, })); }), diff --git a/packages/cli/src/commands/plugins/uninstall/command.ts b/packages/cli/src/commands/plugins/uninstall/command.ts index afd1c66939b..47ba0518f96 100644 --- a/packages/cli/src/commands/plugins/uninstall/command.ts +++ b/packages/cli/src/commands/plugins/uninstall/command.ts @@ -23,8 +23,13 @@ export default command({ ); let accountError: string | null = null; + let disconnected = 0; try { - await ctx.api.plugins.uninstall.mutate({ name, marketplace }); + const result = await ctx.api.plugins.uninstall.mutate({ + name, + marketplace, + }); + disconnected = result.disconnected; } catch (error) { const code = error && typeof error === "object" && "data" in error @@ -37,11 +42,19 @@ export default command({ const removed = await removePlugin(name, marketplace); + const alsoDisconnected = disconnected + ? ` Disconnected ${disconnected} account${disconnected === 1 ? "" : "s"}.` + : ""; + return { - data: { name: removed.name, marketplace: removed.marketplace }, + data: { + name: removed.name, + marketplace: removed.marketplace, + disconnected, + }, message: accountError ? `Removed ${removed.name}@${removed.version} (${removed.marketplace}) from this machine, but the account removal could not be confirmed: ${accountError}` - : `Removed ${removed.name}@${removed.version} (${removed.marketplace}).`, + : `Removed ${removed.name}@${removed.version} (${removed.marketplace}).${alsoDisconnected}`, }; }, }); diff --git a/packages/cli/src/lib/plugins/build.ts b/packages/cli/src/lib/plugins/build.ts deleted file mode 100644 index 7797a75c04a..00000000000 --- a/packages/cli/src/lib/plugins/build.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { execFile } from "node:child_process"; -import crypto from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import { promisify } from "node:util"; -import { CLIError } from "@superset/cli-framework"; -import type { ResolvedPlugin } from "./marketplace"; - -const run = promisify(execFile); - -export const SERVER_ENTRY = path.join("server", "index.mjs"); -export const SERVER_STAMP = path.join("server", "build-stamp.json"); - -interface BuildStamp { - sourceHash: string; -} - -export function hashSourceTree(dir: string): string { - const src = path.join(dir, "src"); - if (!fs.existsSync(src)) return ""; - - const files: string[] = []; - const walk = (current: string) => { - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const full = path.join(current, entry.name); - if (entry.isDirectory()) walk(full); - else files.push(full); - } - }; - walk(src); - - const hash = crypto.createHash("sha256"); - for (const file of files.sort()) { - hash.update(path.relative(src, file)); - hash.update(fs.readFileSync(file)); - } - return hash.digest("hex"); -} - -export function readStamp(plugin: ResolvedPlugin): BuildStamp | null { - const file = path.join(plugin.dir, SERVER_STAMP); - if (!fs.existsSync(file)) return null; - try { - return JSON.parse(fs.readFileSync(file, "utf8")) as BuildStamp; - } catch { - return null; - } -} - -export function isBuildCurrent(plugin: ResolvedPlugin): boolean { - if (!plugin.hasServerSource) return true; - if (!fs.existsSync(path.join(plugin.dir, SERVER_ENTRY))) return false; - return readStamp(plugin)?.sourceHash === hashSourceTree(plugin.dir); -} - -export function isPublishedBuildCurrent( - plugin: ResolvedPlugin, - versionPath: string, -): boolean | null { - if (!plugin.hasServerSource) return true; - if (!fs.existsSync(path.join(versionPath, SERVER_ENTRY))) return false; - const stamp = path.join(versionPath, SERVER_STAMP); - if (!fs.existsSync(stamp)) return null; - try { - const parsed = JSON.parse(fs.readFileSync(stamp, "utf8")) as BuildStamp; - return parsed.sourceHash === hashSourceTree(plugin.dir); - } catch { - return null; - } -} - -export interface BuildResult { - name: string; - built: boolean; - bytes: number; - reason?: string; -} - -export async function buildPlugin( - plugin: ResolvedPlugin, - options: { force?: boolean } = {}, -): Promise { - if (!plugin.hasServerSource) { - return { - name: plugin.manifest.name, - built: false, - bytes: 0, - reason: "no src/index.ts", - }; - } - - const outfile = path.join(plugin.dir, SERVER_ENTRY); - if (!options.force && isBuildCurrent(plugin)) { - return { - name: plugin.manifest.name, - built: false, - bytes: fs.statSync(outfile).size, - reason: "up to date", - }; - } - - fs.mkdirSync(path.dirname(outfile), { recursive: true }); - - try { - await run( - "bun", - [ - "build", - path.join("src", "index.ts"), - "--outfile", - SERVER_ENTRY, - "--target", - "node", - "--format", - "esm", - ], - { cwd: plugin.dir }, - ); - } catch (error) { - const detail = - error && typeof error === "object" && "stderr" in error - ? String((error as { stderr: unknown }).stderr) - : error instanceof Error - ? error.message - : String(error); - throw new CLIError( - `Building ${plugin.manifest.name} failed:\n${detail}\nIs bun on PATH?`, - ); - } - - if (!fs.existsSync(outfile)) { - throw new CLIError( - `Building ${plugin.manifest.name} produced no ${SERVER_ENTRY}.`, - ); - } - - const stamp: BuildStamp = { sourceHash: hashSourceTree(plugin.dir) }; - fs.writeFileSync( - path.join(plugin.dir, SERVER_STAMP), - `${JSON.stringify(stamp, null, "\t")}\n`, - ); - - return { - name: plugin.manifest.name, - built: true, - bytes: fs.statSync(outfile).size, - }; -} diff --git a/packages/cli/src/lib/plugins/connection-ref.ts b/packages/cli/src/lib/plugins/connection-ref.ts index 834b94b54a0..c43a1cadcbb 100644 --- a/packages/cli/src/lib/plugins/connection-ref.ts +++ b/packages/cli/src/lib/plugins/connection-ref.ts @@ -1,41 +1,32 @@ import { CLIError } from "@superset/cli-framework"; -import type { ApiClient } from "../api-client"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** - * The connection to act on: an explicit id, or the single connection a plugin - * name resolves to. A name with several connected accounts is a question for - * the caller, not a guess. + * The plugin to act on. Credentials now live on the connector a plugin names, + * and a person holds one connection per connector — so the plugin name is the + * handle again, and a connection id no longer identifies which plugin was + * meant (one connector can back several). */ -export async function resolveConnectionId( - api: ApiClient, - opts: { connection?: string; plugin?: string }, -): Promise { - if (opts.connection) return opts.connection; - if (!opts.plugin) { +export function resolvePluginName(opts: { + plugin?: string; + connection?: string; + pluginId?: string; +}): string { + const legacy = opts.connection ?? opts.pluginId; + if (!opts.plugin && legacy && UUID.test(legacy)) { throw new CLIError( - "Name a plugin, or pass --connection .", - "Run: superset plugins list (the PLUGIN ID column holds the id)", + "--connection takes a plugin name now, not a connection id.", + "Run: superset mcp tools --plugin ", ); } - const connections = await api.plugins.connections.list.query({ - plugin: opts.plugin, - }); - if (connections.length === 0) { - throw new CLIError( - `"${opts.plugin}" is not connected. Connect an account first.`, - `Run: superset plugins connect ${opts.plugin}`, - ); - } - if (connections.length > 1) { - const accounts = connections - .map( - (row) => ` --connection ${row.id} (${row.account ?? row.accountId})`, - ) - .join("\n"); + const name = opts.plugin ?? legacy; + if (!name) { throw new CLIError( - `"${opts.plugin}" has ${connections.length} connected accounts; choose one:\n${accounts}`, + "Pass --plugin .", + "Run: superset plugins list (the PLUGIN column holds the name)", ); } - return connections[0]?.id as string; + return name; } diff --git a/packages/cli/src/lib/plugins/inputs.ts b/packages/cli/src/lib/plugins/inputs.ts index e6000e6fd59..235733f9686 100644 --- a/packages/cli/src/lib/plugins/inputs.ts +++ b/packages/cli/src/lib/plugins/inputs.ts @@ -2,7 +2,7 @@ import { CLIError } from "@superset/cli-framework"; import type { RouterOutputs } from "@superset/trpc"; export type AuthInputSpec = - RouterOutputs["plugins"]["list"][number]["authMethods"][number]["inputs"][number]; + RouterOutputs["connectors"]["get"]["methods"][number]["inputs"][number]; export function missingInputsError( pluginName: string, diff --git a/packages/cli/src/lib/plugins/install.ts b/packages/cli/src/lib/plugins/install.ts index 96c1173e009..3058cca23ec 100644 --- a/packages/cli/src/lib/plugins/install.ts +++ b/packages/cli/src/lib/plugins/install.ts @@ -491,7 +491,7 @@ export async function installPlugin( if (fs.existsSync(manifestSrc)) { fs.copyFileSync(manifestSrc, path.join(target, "plugin.json")); } - for (const item of ["skills", "server"]) { + for (const item of ["skills"]) { const src = path.join(dir, item); if (fs.existsSync(src)) copyTree(src, path.join(target, item)); } diff --git a/packages/cli/src/lib/plugins/marketplace.ts b/packages/cli/src/lib/plugins/marketplace.ts index 75197f1460c..57ec668046f 100644 --- a/packages/cli/src/lib/plugins/marketplace.ts +++ b/packages/cli/src/lib/plugins/marketplace.ts @@ -38,50 +38,6 @@ export interface Marketplace { renames?: Record; } -export interface AuthInput { - name: string; - label?: string; - placeholder?: string; - description?: string; - required?: boolean; - secret?: boolean; -} - -export interface AuthIdentity { - url: string; - method?: "GET" | "POST"; - headers?: Record; - body?: unknown; - id: string; - label?: string; -} - -export interface PluginAuthMethod { - type: "oauth2" | "api_key"; - label?: string; - provider?: string; - inputs?: AuthInput[]; - credential_input?: string; - authorization_url?: string; - token_url?: string; - scopes?: string[]; - scope_separator?: string; - token_request_auth_method?: string; - token_expiration_buffer?: number; - requires_env?: string[]; - identity?: AuthIdentity; - bind?: PluginBind; -} - -export type PluginAuth = PluginAuthMethod[]; - -export function suggestedEnvFor(name: string): string[] { - const slug = name.toUpperCase().replace(/[.-]/g, "_"); - return [`PLUGIN_${slug}_CLIENT_ID`, `PLUGIN_${slug}_CLIENT_SECRET`]; -} - -export const CLIENT_ENV_PATTERN = /^PLUGIN_[A-Z0-9_]+_CLIENT_(ID|SECRET)$/; - export interface PluginMcp { type: "streamable-http"; url: string; @@ -93,9 +49,13 @@ export interface PluginBind { env?: Record; } +export interface PluginConnectorRef { + slug: string; +} + export interface SupersetExtension { interface?: { displayName: string; category?: string; icon?: string }; - auth?: PluginAuth; + connector?: PluginConnectorRef; bind?: PluginBind; mcp?: PluginMcp; } @@ -113,11 +73,14 @@ export function supersetExtension( return manifest.extensions?.[SUPERSET_EXTENSION]; } +export function pluginConnector(manifest: PluginManifest): string | undefined { + return supersetExtension(manifest)?.connector?.slug; +} + export interface ResolvedPlugin { entry: MarketplaceEntry; dir: string; manifest: PluginManifest; - hasServerSource: boolean; hasSkills: boolean; hasRemoteServer: boolean; } @@ -203,7 +166,6 @@ export function resolvePlugin( entry, dir, manifest, - hasServerSource: fs.existsSync(path.join(dir, "src", "index.ts")), hasSkills: fs.existsSync(path.join(dir, "skills")), hasRemoteServer: Boolean(supersetExtension(manifest)?.mcp), }; diff --git a/packages/cli/src/lib/plugins/mcp-client.ts b/packages/cli/src/lib/plugins/mcp-client.ts new file mode 100644 index 00000000000..6a5d2335ce0 --- /dev/null +++ b/packages/cli/src/lib/plugins/mcp-client.ts @@ -0,0 +1,49 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { CLIError } from "@superset/cli-framework"; +import { DEFAULT_MARKETPLACE } from "@superset/shared/plugins"; +import { getApiUrl } from "../config"; + +export interface PluginRef { + plugin: string; + marketplace: string; +} + +export function parsePluginRef(name: string): PluginRef { + const at = name.lastIndexOf("@"); + if (at <= 0) return { plugin: name, marketplace: DEFAULT_MARKETPLACE }; + return { + plugin: name.slice(0, at), + marketplace: name.slice(at + 1), + }; +} + +export function pluginMcpUrl(ref: PluginRef, connection?: string): URL { + const url = new URL( + `${getApiUrl()}/mcp/plugins/${encodeURIComponent(ref.marketplace)}/${encodeURIComponent(ref.plugin)}`, + ); + if (connection) url.searchParams.set("connection", connection); + return url; +} + +export async function connectPluginMcp( + name: string, + bearer: string, + connection?: string, +): Promise<{ client: Client; ref: PluginRef }> { + const ref = parsePluginRef(name); + const client = new Client({ name: "superset-cli", version: "1.0.0" }); + try { + await client.connect( + new StreamableHTTPClientTransport(pluginMcpUrl(ref, connection), { + requestInit: { headers: { Authorization: `Bearer ${bearer}` } }, + }), + ); + } catch (error) { + throw new CLIError( + `Could not reach the "${ref.plugin}" plugin: ${error instanceof Error ? error.message : String(error)}`, + "Run: superset plugins list (check the plugin is installed and connected)", + ); + } + return { client, ref }; +} diff --git a/packages/cli/src/lib/plugins/publish.test.ts b/packages/cli/src/lib/plugins/publish.test.ts index 1d05fda06fa..8fe4cb4029c 100644 --- a/packages/cli/src/lib/plugins/publish.test.ts +++ b/packages/cli/src/lib/plugins/publish.test.ts @@ -67,7 +67,6 @@ function plugin( entry: entry as never, dir, manifest: manifest as never, - hasServerSource: false, hasSkills: true, hasRemoteServer: true, }, @@ -143,17 +142,9 @@ describe("checkPlugin", () => { p.hasRemoteServer = false; expect((await checkPlugin(ctx, p))[0]?.problem).toContain( - "no skills, mcp server, or server source", + "no skills and no mcp server", ); }); - - test("catches a plugin declaring two servers", async () => { - const { ctx, plugin: p } = plugin("linear", "1.3.0"); - p.hasServerSource = true; - - const problems = (await checkPlugin(ctx, p)).map((i) => i.problem); - expect(problems.some((p) => p.includes("exactly one server"))).toBe(true); - }); }); describe("generatedManifestDrift", () => { diff --git a/packages/cli/src/lib/plugins/publish.ts b/packages/cli/src/lib/plugins/publish.ts index e643165db0b..0c4af6b229b 100644 --- a/packages/cli/src/lib/plugins/publish.ts +++ b/packages/cli/src/lib/plugins/publish.ts @@ -1,12 +1,12 @@ import { execFile } from "node:child_process"; -import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; import { CLIError } from "@superset/cli-framework"; -import { buildPlugin, isBuildCurrent, SERVER_ENTRY } from "./build"; +import { CONNECTOR_SLUGS } from "@superset/shared/connectors"; +import { isSupersetHosted } from "@superset/shared/plugins"; +import { pluginManifestSchema } from "@superset/shared/plugins/manifest-schema"; import { - CLIENT_ENV_PATTERN, type MarketplaceContext, type ResolvedPlugin, releaseTag, @@ -82,7 +82,6 @@ export interface PublishResult { version: string; tag: string; files: number; - rebuilt: boolean; } export async function publishPlugin( @@ -107,19 +106,6 @@ export async function publishPlugin( ); } - const build = await buildPlugin(plugin, { force: options.force }); - if (plugin.hasServerSource && !isBuildCurrent(plugin)) { - throw new CLIError( - `Build for "${name}" is stale after rebuilding; refusing to publish.`, - ); - } - if ( - plugin.hasServerSource && - !fs.existsSync(path.join(plugin.dir, SERVER_ENTRY)) - ) { - throw new CLIError(`${name}@${version} is missing ${SERVER_ENTRY}.`); - } - entry.version = version; writeJson(ctx.file, ctx.marketplace); writeGeneratedManifests(ctx); @@ -129,110 +115,30 @@ export async function publishPlugin( version, tag, files: treeFiles(path.join(plugin.dir, "skills")).length + 1, - rebuilt: build.built, }; } -function checkAuth(plugin: ResolvedPlugin): CheckIssue[] { +function checkManifest(plugin: ResolvedPlugin): CheckIssue[] { const name = plugin.manifest.name; - const methods = supersetExtension(plugin.manifest)?.auth; - if (!methods) return []; - - const issues: CheckIssue[] = []; - if (!Array.isArray(methods)) { - issues.push({ name, problem: "auth must be a list of methods" }); - return issues; - } - - const seen = new Set(); - for (const auth of methods) { - if (seen.has(auth.type)) { - issues.push({ name, problem: `auth declares ${auth.type} twice` }); - } - seen.add(auth.type); - - const inputs = new Set((auth.inputs ?? []).map((input) => input.name)); - - if (auth.type === "oauth2") { - if (!auth.authorization_url || !auth.token_url) { - issues.push({ - name, - problem: "oauth2 auth needs both authorization_url and token_url", - }); - } - - const secrets = (auth.inputs ?? []).filter((input) => input.secret); - if (secrets.length) { - issues.push({ - name, - problem: `oauth2 inputs cannot be secret (${secrets.map((i) => i.name).join(", ")}); they travel in the authorize URL`, - }); - } - - const declared = auth.requires_env ?? []; - const foreign = declared.filter( - (variable) => !CLIENT_ENV_PATTERN.test(variable), - ); - if (foreign.length) { - issues.push({ - name, - problem: `requires_env may only name PLUGIN__CLIENT_ID and PLUGIN__CLIENT_SECRET; found ${foreign.join(", ")}`, - }); - } - if (!declared.some((variable) => variable.endsWith("_CLIENT_ID"))) { - issues.push({ - name, - problem: - "oauth2 auth must name its client id in requires_env; it is what the host reads to start the flow", - }); - } - if (!declared.some((variable) => variable.endsWith("_CLIENT_SECRET"))) { - issues.push({ - name, - problem: - "oauth2 auth must name its client secret in requires_env; it is what the host reads to exchange the code", - }); - } - } else if (auth.type === "api_key") { - const credential = auth.credential_input ?? "api_key"; - if (!inputs.has(credential)) { - issues.push({ - name, - problem: `api_key auth names credential_input "${credential}" but no input declares it`, - }); - } - } else { - issues.push({ name, problem: `unknown auth type "${auth.type}"` }); - } - - for (const template of templatedStrings(auth)) { - for (const reference of template.matchAll(/\$\{inputs\.([\w.-]+)\}/g)) { - const key = reference[1]; - if (key && !inputs.has(key)) { - issues.push({ - name, - problem: `references \${inputs.${key}} but no input declares it`, - }); - } - } - } - - if (auth.identity && !auth.identity.url) { - issues.push({ name, problem: "identity needs a url" }); - } - if (auth.identity && !auth.identity.id) { - issues.push({ name, problem: "identity needs an id path" }); - } + const parsed = pluginManifestSchema.safeParse(plugin.manifest); + if (!parsed.success) { + return parsed.error.issues.map((issue) => ({ + name, + problem: `${issue.path.join(".") || "manifest"}: ${issue.message}`, + })); } - return issues; -} - -function templatedStrings(value: unknown): string[] { - if (typeof value === "string") return [value]; - if (Array.isArray(value)) return value.flatMap(templatedStrings); - if (value && typeof value === "object") { - return Object.values(value).flatMap(templatedStrings); + const slug = supersetExtension(plugin.manifest)?.connector?.slug; + if ( + slug && + !CONNECTOR_SLUGS.includes(slug as (typeof CONNECTOR_SLUGS)[number]) + ) { + return [ + { + name, + problem: `names connector "${slug}", which is not in the registry (${CONNECTOR_SLUGS.join(", ")})`, + }, + ]; } return []; } @@ -277,13 +183,6 @@ export async function checkPlugin( }); } - if (plugin.hasServerSource && !isBuildCurrent(plugin)) { - issues.push({ - name, - problem: `src/ has changed since ${SERVER_ENTRY} was built; run \`superset plugins build ${name}\``, - }); - } - // A tag is immutable, so a release that exists and no longer matches the // tree means the change was never published, not that the tag went stale. const tag = releaseTag(name, version); @@ -305,22 +204,11 @@ export async function checkPlugin( } } - if (!plugin.hasServerSource && !plugin.hasRemoteServer && !plugin.hasSkills) { - issues.push({ - name, - problem: "plugin has no skills, mcp server, or server source", - }); - } - - if (plugin.hasServerSource && plugin.hasRemoteServer) { - issues.push({ - name, - problem: - "plugin declares both an mcp url and src/index.ts; tools come from exactly one server", - }); + if (!plugin.hasRemoteServer && !plugin.hasSkills && !isSupersetHosted(name)) { + issues.push({ name, problem: "plugin has no skills and no mcp server" }); } - issues.push(...checkAuth(plugin)); + issues.push(...checkManifest(plugin)); return issues; } @@ -350,33 +238,6 @@ function publishedSkills( return skills; } -/** - * Where the host downloads a plugin's bundled server from, and what it must - * hash to. `ref` is the release tag rather than the marketplace's branch, so - * the bytes are pinned to the version that was published even after the branch - * moves on. Stamped into the generated bundle, never into the hand-authored - * plugin.json. - */ -function serverStamp( - pluginDir: string, - source: string, - name: string, - version: string, -): { path: string; integrity: string; ref: string } | null { - const bundle = path.join(pluginDir, SERVER_ENTRY); - if (!fs.existsSync(bundle)) return null; - - const digest = createHash("sha256") - .update(fs.readFileSync(bundle)) - .digest("base64"); - const dir = source.replace(/^\.\//, "").replace(/\/$/, ""); - return { - path: `${dir}/${SERVER_ENTRY}`, - integrity: `sha256-${digest}`, - ref: releaseTag(name, version), - }; -} - const GENERATED_HEADER = `// Generated by \`superset plugins publish\`. Do not edit. // // The first-party marketplace's published manifests, bundled so the API can @@ -407,12 +268,6 @@ export function renderGeneratedManifests( if (!fs.existsSync(manifestPath)) continue; const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); manifest.skills = publishedSkills(pluginDir); - const stamp = serverStamp(pluginDir, entry.source, entry.name, version); - if (stamp) { - manifest.extensions ??= {}; - manifest.extensions.superset ??= {}; - manifest.extensions.superset.server = stamp; - } entries.push( `\t${JSON.stringify(entry.name)}: ${JSON.stringify(manifest, null, "\t") .split("\n") diff --git a/packages/cli/src/lib/plugins/scaffold.ts b/packages/cli/src/lib/plugins/scaffold.ts index 8ecaf6678f8..116cf7c59eb 100644 --- a/packages/cli/src/lib/plugins/scaffold.ts +++ b/packages/cli/src/lib/plugins/scaffold.ts @@ -4,11 +4,10 @@ import { CLIError } from "@superset/cli-framework"; import { type MarketplaceContext, SUPERSET_EXTENSION, - suggestedEnvFor, writeJson, } from "./marketplace"; -export type PluginKind = "url" | "server" | "none"; +export type PluginKind = "url" | "none"; export interface ScaffoldOptions { name: string; @@ -18,7 +17,7 @@ export interface ScaffoldOptions { description?: string; category?: string; skills: boolean; - auth: boolean; + connector?: string; } const NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; @@ -59,152 +58,6 @@ acting, and what to do when the obvious answer is wrong. `; } -function serverIndex(): string { - return `import { callTool, getTools } from "./tools"; -import { type PluginEvent, PluginEventType, type PluginResult } from "./types"; - -export async function run(event: PluginEvent): Promise { - switch (event.event) { - case PluginEventType.GET_TOOLS: - return getTools(); - - case PluginEventType.CALL_TOOL: - return await callTool( - event.eventBody.name, - event.eventBody.arguments ?? {}, - event.config?.access_token, - ); - - default: - return { message: \`Unhandled event: \${(event as PluginEvent).event}\` }; - } -} -`; -} - -function serverTools(name: string, auth: boolean): string { - const guard = auth - ? ` if (!accessToken) { - return { - content: [ - { type: "text", text: "Not connected; connect the plugin first." }, - ], - isError: true, - }; - } - -` - : ""; - return `${serverToolsHeader(name)}export async function callTool( - name: string, - args: Record, - accessToken${auth ? "" : "?"}: string, -): Promise { -${guard} switch (name) { - case "${name.replace(/[.-]/g, "_")}_example": - return { - content: [{ type: "text", text: JSON.stringify(args, null, 2) }], - }; - - default: - return { - content: [{ type: "text", text: \`Unknown tool: \${name}\` }], - isError: true, - }; - } -} -`; -} - -function serverToolsHeader(name: string): string { - return `import type { ToolDefinition, ToolResult } from "./types"; - -export function getTools(): ToolDefinition[] { - return [ - { - name: "${name.replace(/[.-]/g, "_")}_example", - description: "Replace this with a real tool.", - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "What to look up" }, - }, - required: ["query"], - }, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - }, - }, - ]; -} - -`; -} - -const SERVER_TYPES = `export const PluginEventType = { - SYNC: "sync", - GET_TOOLS: "get-tools", - CALL_TOOL: "call-tool", -} as const; - -export interface PluginConfig { - access_token: string; - [key: string]: unknown; -} - -export interface ToolDefinition { - name: string; - description: string; - inputSchema: Record; - annotations?: { - readOnlyHint?: boolean; - destructiveHint?: boolean; - idempotentHint?: boolean; - }; -} - -export interface ToolResult { - content: Array<{ type: "text"; text: string }>; - isError?: boolean; -} - -export type PluginEvent = - | { event: "sync"; eventBody: Record; config: PluginConfig } - | { - event: "get-tools"; - eventBody: Record; - config: PluginConfig; - } - | { - event: "call-tool"; - eventBody: { name: string; arguments: Record }; - config: PluginConfig; - }; - -export type PluginResult = - | ToolDefinition[] - | ToolResult - | { message: string } - | Record; -`; - -const TSCONFIG = `{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM"], - "strict": true, - "noEmit": true, - "skipLibCheck": true, - "verbatimModuleSyntax": true - }, - "include": ["src"] -} -`; - export interface ScaffoldResult { dir: string; files: string[]; @@ -256,18 +109,8 @@ export function scaffoldPlugin( }, }; - if (options.auth) { - const method: Record = { - type: "oauth2", - provider: name, - authorization_url: `https://example.com/oauth/authorize`, - token_url: `https://example.com/oauth/token`, - scopes: ["read"], - scope_separator: " ", - token_request_auth_method: "client_secret_post", - requires_env: suggestedEnvFor(name), - }; - extension.auth = [method]; + if (options.connector) { + extension.connector = { slug: options.connector }; if (kind === "url") { extension.bind = { // biome-ignore lint/suspicious/noTemplateCurlyInString: the proxy interpolates this at call time; it must stay a literal @@ -297,28 +140,14 @@ export function scaffoldPlugin( version: "0.1.0", private: true, type: "module", - scripts: - kind === "server" - ? { - build: `superset plugins build ${name}`, - publish: `superset plugins publish ${name}`, - check: `superset plugins validate plugins/${name}`, - } - : { - publish: `superset plugins publish ${name}`, - check: `superset plugins validate plugins/${name}`, - }, + scripts: { + publish: `superset plugins publish ${name}`, + check: `superset plugins validate plugins/${name}`, + }, }; writeJson(path.join(dir, "package.json"), pkg); files.push("package.json"); - if (kind === "server") { - add(path.join("src", "index.ts"), serverIndex()); - add(path.join("src", "tools.ts"), serverTools(name, options.auth)); - add(path.join("src", "types.ts"), SERVER_TYPES); - add("tsconfig.json", TSCONFIG); - } - if (options.skills) { add( path.join("skills", `${name}-workflow`, "SKILL.md"), diff --git a/packages/db/drizzle/0119_plugin_oauth_clients_and_connectors.sql b/packages/db/drizzle/0119_plugin_oauth_clients_and_connectors.sql new file mode 100644 index 00000000000..483721748c5 --- /dev/null +++ b/packages/db/drizzle/0119_plugin_oauth_clients_and_connectors.sql @@ -0,0 +1,53 @@ +CREATE TABLE "plugin_oauth_clients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "issuer" text NOT NULL, + "redirect_uri" text NOT NULL, + "client_id" text NOT NULL, + "client_secret" text, + "client_secret_expires_at" timestamp, + "registration_access_token" text, + "registration_client_uri" text, + "token_endpoint_auth_method" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "connections" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "connected_by_user_id" uuid NOT NULL, + "connector" text NOT NULL, + "owner_kind" text NOT NULL, + "auth_method" text NOT NULL, + "access_token" text NOT NULL, + "refresh_token" text, + "token_expires_at" timestamp, + "scopes" text[], + "issuer" text, + "resource" text, + "external_account_id" text NOT NULL, + "external_account_label" text, + "external_user_id" text, + "external_user_label" text, + "config" jsonb, + "state" jsonb, + "disconnected_at" timestamp, + "disconnect_reason" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "connections_user_identity_present" CHECK (owner_kind <> 'user' OR external_user_id IS NOT NULL) +); +--> statement-breakpoint +ALTER TABLE "plugin_connections" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +DROP TABLE "plugin_connections" CASCADE;--> statement-breakpoint +ALTER TABLE "automation_events" DROP CONSTRAINT "automation_events_integration_connection_id_integration_connections_id_fk"; +--> statement-breakpoint +ALTER TABLE "ingest"."webhook_events" ALTER COLUMN "provider" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "connections" ADD CONSTRAINT "connections_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "connections" ADD CONSTRAINT "connections_connected_by_user_id_users_id_fk" FOREIGN KEY ("connected_by_user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "plugin_oauth_clients_issuer_redirect_unique" ON "plugin_oauth_clients" USING btree ("issuer","redirect_uri");--> statement-breakpoint +CREATE UNIQUE INDEX "connections_org_connector_unique" ON "connections" USING btree ("organization_id","connector") WHERE "connections"."owner_kind" = 'org';--> statement-breakpoint +CREATE UNIQUE INDEX "connections_user_connector_unique" ON "connections" USING btree ("organization_id","connector","connected_by_user_id","external_account_id") WHERE "connections"."owner_kind" = 'user';--> statement-breakpoint +CREATE INDEX "connections_org_idx" ON "connections" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "connections_user_connector_idx" ON "connections" USING btree ("connected_by_user_id","connector") WHERE "connections"."disconnected_at" IS NULL;--> statement-breakpoint +CREATE INDEX "connections_external_account_idx" ON "connections" USING btree ("connector","external_account_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0119_snapshot.json b/packages/db/drizzle/meta/0119_snapshot.json new file mode 100644 index 00000000000..98e3fced17e --- /dev/null +++ b/packages/db/drizzle/meta/0119_snapshot.json @@ -0,0 +1,11046 @@ +{ + "id": "aedc55ed-1344-43c3-a89d-d002ea861018", + "prevId": "52dcd5f1-52d3-461e-8609-5e6452d3ecd0", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.accounts": { + "name": "accounts", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.apikeys": { + "name": "apikeys", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n\t\t\t\tWHEN metadata IS NULL OR metadata = '' THEN NULL\n\t\t\t\tWHEN NOT (metadata IS JSON OBJECT) THEN NULL\n\t\t\t\tWHEN (metadata::jsonb->>'organizationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'\n\t\t\t\t\tTHEN (metadata::jsonb->>'organizationId')::uuid\n\t\t\t\tELSE NULL\n\t\t\tEND", + "type": "stored" + } + } + }, + "indexes": { + "apikeys_configId_idx": { + "name": "apikeys_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_referenceId_idx": { + "name": "apikeys_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_key_idx": { + "name": "apikeys_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_organization_id_idx": { + "name": "apikeys_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_metadata_trgm_idx": { + "name": "apikeys_metadata_trgm_idx", + "columns": [ + { + "expression": "\"metadata\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.device_codes": { + "name": "device_codes", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitations": { + "name": "invitations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_team_id_teams_id_fk": { + "name": "invitations_team_id_teams_id_fk", + "tableFrom": "invitations", + "tableTo": "teams", + "schemaTo": "auth", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.jwkss": { + "name": "jwkss", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.members": { + "name": "members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_session_id_sessions_id_fk": { + "name": "oauth_access_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk": { + "name": "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_refresh_tokens", + "schemaTo": "auth", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_clients": { + "name": "oauth_clients", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_clients_user_id_users_id_fk": { + "name": "oauth_clients_user_id_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_consents": { + "name": "oauth_consents", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consents_client_id_oauth_clients_client_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_session_id_sessions_id_fk": { + "name": "oauth_refresh_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organizations": { + "name": "organizations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_domains": { + "name": "allowed_domains", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_allowed_domains_idx": { + "name": "organizations_allowed_domains_idx", + "columns": [ + { + "expression": "allowed_domains", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.team_members": { + "name": "team_members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_user_id_idx": { + "name": "team_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_organization_id_idx": { + "name": "team_members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_team_user_unique": { + "name": "team_members_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "auth", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_organization_id_organizations_id_fk": { + "name": "team_members_organization_id_organizations_id_fk", + "tableFrom": "team_members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.teams": { + "name": "teams", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_idx": { + "name": "teams_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_org_slug_unique": { + "name": "teams_org_slug_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_ids": { + "name": "organization_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "last_active_organization_id": { + "name": "last_active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deletion_requested_at": { + "name": "deletion_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_organization_ids_idx": { + "name": "users_organization_ids_idx", + "columns": [ + { + "expression": "organization_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verifications": { + "name": "verifications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_organization_id_organizations_id_fk": { + "name": "github_installations_organization_id_organizations_id_fk", + "tableFrom": "github_installations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_connected_by_user_id_users_id_fk": { + "name": "github_installations_connected_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + }, + "github_installations_org_unique": { + "name": "github_installations_org_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pull_requests": { + "name": "github_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pull_requests_repository_id_idx": { + "name": "github_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_state_idx": { + "name": "github_pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_head_branch_idx": { + "name": "github_pull_requests_head_branch_idx", + "columns": [ + { + "expression": "head_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_org_id_idx": { + "name": "github_pull_requests_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pull_requests_repository_id_github_repositories_id_fk": { + "name": "github_pull_requests_repository_id_github_repositories_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_pull_requests_organization_id_organizations_id_fk": { + "name": "github_pull_requests_organization_id_organizations_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_pull_requests_repo_pr_unique": { + "name": "github_pull_requests_repo_pr_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repositories_installation_id_idx": { + "name": "github_repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_full_name_idx": { + "name": "github_repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_org_id_idx": { + "name": "github_repositories_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_installations_id_fk": { + "name": "github_repositories_installation_id_github_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_repositories_organization_id_organizations_id_fk": { + "name": "github_repositories_organization_id_organizations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repositories_repo_id_unique": { + "name": "github_repositories_repo_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repo_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "ingest.webhook_events": { + "name": "webhook_events", + "schema": "ingest", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_provider_status_idx": { + "name": "webhook_events_provider_status_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_provider_event_id_idx": { + "name": "webhook_events_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_received_at_idx": { + "name": "webhook_events_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leaderboard_daily": { + "name": "leaderboard_daily", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uncached_input": { + "name": "uncached_input", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_input": { + "name": "cached_input", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_5m": { + "name": "cache_write_5m", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_1h": { + "name": "cache_write_1h", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output": { + "name": "output", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_output": { + "name": "reasoning_output", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens": { + "name": "tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usd_estimate": { + "name": "usd_estimate", + "type": "numeric(14, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "approximate": { + "name": "approximate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sessions": { + "name": "sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leaderboard_daily_day_idx": { + "name": "leaderboard_daily_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leaderboard_daily_user_day_idx": { + "name": "leaderboard_daily_user_day_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leaderboard_daily_model_day_idx": { + "name": "leaderboard_daily_model_day_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leaderboard_daily_user_id_public_profiles_user_id_fk": { + "name": "leaderboard_daily_user_id_public_profiles_user_id_fk", + "tableFrom": "leaderboard_daily", + "tableTo": "public_profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "leaderboard_daily_identity_key": { + "name": "leaderboard_daily_identity_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "day", + "provider", + "model", + "host_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leaderboard_daily_factory": { + "name": "leaderboard_daily_factory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sessions": { + "name": "sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parallel_sessions": { + "name": "parallel_sessions", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "agent_prs_merged": { + "name": "agent_prs_merged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leaderboard_daily_factory_day_idx": { + "name": "leaderboard_daily_factory_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leaderboard_daily_factory_user_day_idx": { + "name": "leaderboard_daily_factory_user_day_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leaderboard_daily_factory_user_id_public_profiles_user_id_fk": { + "name": "leaderboard_daily_factory_user_id_public_profiles_user_id_fk", + "tableFrom": "leaderboard_daily_factory", + "tableTo": "public_profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "leaderboard_daily_factory_identity_key": { + "name": "leaderboard_daily_factory_identity_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "day", + "host_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leaderboard_participants": { + "name": "leaderboard_participants", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "leaderboard_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "opted_in_at": { + "name": "opted_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "flagged_at": { + "name": "flagged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_published_at": { + "name": "last_published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload_version": { + "name": "payload_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "tokens": { + "name": "tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usd": { + "name": "usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sessions": { + "name": "sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "uncached_input": { + "name": "uncached_input", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_input": { + "name": "cached_input", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_5m": { + "name": "cache_write_5m", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_1h": { + "name": "cache_write_1h", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output": { + "name": "output", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_output": { + "name": "reasoning_output", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "approximate": { + "name": "approximate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "day_range_start": { + "name": "day_range_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "day_range_end": { + "name": "day_range_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tier_computed_at": { + "name": "tier_computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active_days": { + "name": "active_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "axis_width": { + "name": "axis_width", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "axis_depth": { + "name": "axis_depth", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "axis_output": { + "name": "axis_output", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "axis_cost": { + "name": "axis_cost", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leaderboard_participants_tokens_idx": { + "name": "leaderboard_participants_tokens_idx", + "columns": [ + { + "expression": "tokens", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leaderboard_participants_org_idx": { + "name": "leaderboard_participants_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leaderboard_participants_usd_idx": { + "name": "leaderboard_participants_usd_idx", + "columns": [ + { + "expression": "usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leaderboard_participants_tier_idx": { + "name": "leaderboard_participants_tier_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leaderboard_participants_user_id_users_id_fk": { + "name": "leaderboard_participants_user_id_users_id_fk", + "tableFrom": "leaderboard_participants", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leaderboard_participants_organization_id_organizations_id_fk": { + "name": "leaderboard_participants_organization_id_organizations_id_fk", + "tableFrom": "leaderboard_participants", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "leaderboard_participants_handle_unique": { + "name": "leaderboard_participants_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_installs": { + "name": "plugin_installs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "marketplace": { + "name": "marketplace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_name": { + "name": "plugin_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_installs_user_plugin_unique": { + "name": "plugin_installs_user_plugin_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "marketplace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_installs_user_idx": { + "name": "plugin_installs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_installs_organization_id_organizations_id_fk": { + "name": "plugin_installs_organization_id_organizations_id_fk", + "tableFrom": "plugin_installs", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_installs_user_id_users_id_fk": { + "name": "plugin_installs_user_id_users_id_fk", + "tableFrom": "plugin_installs", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_marketplaces": { + "name": "plugin_marketplaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_marketplaces_user_name_unique": { + "name": "plugin_marketplaces_user_name_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_marketplaces_user_idx": { + "name": "plugin_marketplaces_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_marketplaces_organization_id_organizations_id_fk": { + "name": "plugin_marketplaces_organization_id_organizations_id_fk", + "tableFrom": "plugin_marketplaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_marketplaces_user_id_users_id_fk": { + "name": "plugin_marketplaces_user_id_users_id_fk", + "tableFrom": "plugin_marketplaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_oauth_clients": { + "name": "plugin_oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_secret_expires_at": { + "name": "client_secret_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "registration_access_token": { + "name": "registration_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_client_uri": { + "name": "registration_client_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_oauth_clients_issuer_redirect_unique": { + "name": "plugin_oauth_clients_issuer_redirect_unique", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "redirect_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.handles": { + "name": "handles", + "schema": "", + "columns": { + "handle": { + "name": "handle", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_type": { + "name": "owner_type", + "type": "handle_owner_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "handles_owner_type_idx": { + "name": "handles_owner_type_idx", + "columns": [ + { + "expression": "owner_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "handles_user_id_users_id_fk": { + "name": "handles_user_id_users_id_fk", + "tableFrom": "handles", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "handles_organization_id_organizations_id_fk": { + "name": "handles_organization_id_organizations_id_fk", + "tableFrom": "handles", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "handles_user_key": { + "name": "handles_user_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "handles_organization_key": { + "name": "handles_organization_key", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + }, + "handles_user_owner_key": { + "name": "handles_user_owner_key", + "nullsNotDistinct": false, + "columns": [ + "handle", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "handles_owner_matches_type": { + "name": "handles_owner_matches_type", + "value": "(\n\t\t\t\t(\"handles\".\"owner_type\" = 'user' and \"handles\".\"user_id\" is not null and \"handles\".\"organization_id\" is null)\n\t\t\t\tor (\"handles\".\"owner_type\" = 'organization' and \"handles\".\"organization_id\" is not null and \"handles\".\"user_id\" is null)\n\t\t\t\tor (\"handles\".\"owner_type\" = 'reserved' and \"handles\".\"user_id\" is null and \"handles\".\"organization_id\" is null)\n\t\t\t)" + }, + "handles_shape": { + "name": "handles_shape", + "value": "length(\"handles\".\"handle\") between 2 and 39 and \"handles\".\"handle\" ~ '^[a-z0-9]+(-[a-z0-9]+)*$'" + } + }, + "isRLSEnabled": false + }, + "public.profile_awards": { + "name": "profile_awards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "value": { + "name": "value", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "awarded_on": { + "name": "awarded_on", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "awarded_at": { + "name": "awarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "profile_awards_user_idx": { + "name": "profile_awards_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_awards_slug_idx": { + "name": "profile_awards_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "awarded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profile_awards_user_id_public_profiles_user_id_fk": { + "name": "profile_awards_user_id_public_profiles_user_id_fk", + "tableFrom": "profile_awards", + "tableTo": "public_profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_awards_identity_key": { + "name": "profile_awards_identity_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "slug", + "tier" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_profiles": { + "name": "public_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "leaderboard_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_handle": { + "name": "github_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "x_handle": { + "name": "x_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "opted_in_at": { + "name": "opted_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "flagged_at": { + "name": "flagged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_published_at": { + "name": "last_published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload_version": { + "name": "payload_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "awards_catalog_version": { + "name": "awards_catalog_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens": { + "name": "tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usd": { + "name": "usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sessions": { + "name": "sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "uncached_input": { + "name": "uncached_input", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_input": { + "name": "cached_input", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_5m": { + "name": "cache_write_5m", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_1h": { + "name": "cache_write_1h", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output": { + "name": "output", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_output": { + "name": "reasoning_output", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "approximate": { + "name": "approximate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "day_range_start": { + "name": "day_range_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "day_range_end": { + "name": "day_range_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tier_computed_at": { + "name": "tier_computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active_days": { + "name": "active_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "axis_width": { + "name": "axis_width", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "axis_depth": { + "name": "axis_depth", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "axis_output": { + "name": "axis_output", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "axis_cost": { + "name": "axis_cost", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_profiles_tokens_idx": { + "name": "public_profiles_tokens_idx", + "columns": [ + { + "expression": "tokens", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_profiles_org_idx": { + "name": "public_profiles_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_profiles_usd_idx": { + "name": "public_profiles_usd_idx", + "columns": [ + { + "expression": "usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_profiles_tier_idx": { + "name": "public_profiles_tier_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_profiles_user_id_users_id_fk": { + "name": "public_profiles_user_id_users_id_fk", + "tableFrom": "public_profiles", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_profiles_organization_id_organizations_id_fk": { + "name": "public_profiles_organization_id_organizations_id_fk", + "tableFrom": "public_profiles", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "public_profiles_handle_owner_fk": { + "name": "public_profiles_handle_owner_fk", + "tableFrom": "public_profiles", + "tableTo": "handles", + "columnsFrom": [ + "handle", + "user_id" + ], + "columnsTo": [ + "handle", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "public_profiles_handle_unique": { + "name": "public_profiles_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": { + "public_profiles_bio_length": { + "name": "public_profiles_bio_length", + "value": "length(\"public_profiles\".\"bio\") <= 160" + }, + "public_profiles_website_scheme": { + "name": "public_profiles_website_scheme", + "value": "\"public_profiles\".\"website_url\" is null or \"public_profiles\".\"website_url\" ~ '^https://'" + } + }, + "isRLSEnabled": false + }, + "public.agent_commands": { + "name": "agent_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_device_id": { + "name": "target_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_device_type": { + "name": "target_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_command_id": { + "name": "parent_command_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "command_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_commands_user_status_idx": { + "name": "agent_commands_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_target_device_status_idx": { + "name": "agent_commands_target_device_status_idx", + "columns": [ + { + "expression": "target_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_org_created_idx": { + "name": "agent_commands_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_commands_user_id_users_id_fk": { + "name": "agent_commands_user_id_users_id_fk", + "tableFrom": "agent_commands", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_commands_organization_id_organizations_id_fk": { + "name": "agent_commands_organization_id_organizations_id_fk", + "tableFrom": "agent_commands", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_credentials": { + "name": "agent_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "agent_credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_label": { + "name": "account_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_validated_at": { + "name": "last_validated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_credentials_user_id_idx": { + "name": "agent_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_credentials_user_id_users_id_fk": { + "name": "agent_credentials_user_id_users_id_fk", + "tableFrom": "agent_credentials", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agent_credentials_user_id_agent_unique": { + "name": "agent_credentials_user_id_agent_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "agent" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_kind": { + "name": "parent_kind", + "type": "attachment_parent_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_file_id_idx": { + "name": "attachments_file_id_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_parent_kind_parent_id_idx": { + "name": "attachments_parent_kind_parent_id_idx", + "columns": [ + { + "expression": "parent_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_parent_path_unique": { + "name": "attachments_parent_path_unique", + "columns": [ + { + "expression": "parent_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"attachments\".\"path\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_file_id_files_id_fk": { + "name": "attachments_file_id_files_id_fk", + "tableFrom": "attachments", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_events": { + "name": "automation_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "integration_connection_id": { + "name": "integration_connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_event_id": { + "name": "external_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_login": { + "name": "actor_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_is_external": { + "name": "actor_is_external", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "webhook_event_id": { + "name": "webhook_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dispatch_input": { + "name": "dispatch_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "automation_events_undispatched_idx": { + "name": "automation_events_undispatched_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"automation_events\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_events_org_received_idx": { + "name": "automation_events_org_received_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_events_resource_idx": { + "name": "automation_events_resource_idx", + "columns": [ + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_events_prunable_idx": { + "name": "automation_events_prunable_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"automation_events\".\"payload\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_events_organization_id_organizations_id_fk": { + "name": "automation_events_organization_id_organizations_id_fk", + "tableFrom": "automation_events", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "automation_events_dedup_unique": { + "name": "automation_events_dedup_unique", + "nullsNotDistinct": true, + "columns": [ + "integration_connection_id", + "provider", + "external_event_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_prompt_versions": { + "name": "automation_prompt_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_bucket": { + "name": "window_bucket", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "automation_prompt_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "restored_from_version_id": { + "name": "restored_from_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_prompt_versions_bucket_uniq": { + "name": "automation_prompt_versions_bucket_uniq", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"automation_prompt_versions\".\"source\" <> 'restore'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_prompt_versions_automation_idx": { + "name": "automation_prompt_versions_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_prompt_versions_automation_id_automations_id_fk": { + "name": "automation_prompt_versions_automation_id_automations_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_prompt_versions_author_user_id_users_id_fk": { + "name": "automation_prompt_versions_author_user_id_users_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_prompt_versions_restored_from_version_id_fk": { + "name": "automation_prompt_versions_restored_from_version_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "automation_prompt_versions", + "columnsFrom": [ + "restored_from_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_runs": { + "name": "automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_id": { + "name": "trigger_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_kind": { + "name": "session_kind", + "type": "automation_session_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "terminal_session_id": { + "name": "terminal_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "automation_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "automation_run_error_code", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_runs_schedule_dedup_idx": { + "name": "automation_runs_schedule_dedup_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "scheduled_for IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_event_dedup_idx": { + "name": "automation_runs_event_dedup_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "event_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_inflight_resource_idx": { + "name": "automation_runs_inflight_resource_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status IN ('dispatching', 'dispatched')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_history_idx": { + "name": "automation_runs_history_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_status_idx": { + "name": "automation_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_workspace_idx": { + "name": "automation_runs_workspace_idx", + "columns": [ + { + "expression": "v2_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_runs_organization_id_organizations_id_fk": { + "name": "automation_runs_organization_id_organizations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_trigger_id_automation_triggers_id_fk": { + "name": "automation_runs_trigger_id_automation_triggers_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automation_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "automation_runs_event_id_automation_events_id_fk": { + "name": "automation_runs_event_id_automation_events_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automation_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "automation_runs_chat_session_id_chat_sessions_id_fk": { + "name": "automation_runs_chat_session_id_chat_sessions_id_fk", + "tableFrom": "automation_runs", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "automation_runs_automation_org_fk": { + "name": "automation_runs_automation_org_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_triggers": { + "name": "automation_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "automation_trigger_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_prefix": { + "name": "secret_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_rotated_at": { + "name": "secret_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_triggers_dispatcher_idx": { + "name": "automation_triggers_dispatcher_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "kind = 'schedule'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_triggers_matcher_idx": { + "name": "automation_triggers_matcher_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_triggers_automation_idx": { + "name": "automation_triggers_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_triggers_schedule_idx": { + "name": "automation_triggers_schedule_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "kind = 'schedule'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_triggers_organization_id_organizations_id_fk": { + "name": "automation_triggers_organization_id_organizations_id_fk", + "tableFrom": "automation_triggers", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_triggers_automation_org_fk": { + "name": "automation_triggers_automation_org_fk", + "tableFrom": "automation_triggers", + "tableTo": "automations", + "columnsFrom": [ + "automation_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "automation_triggers_kind_matches_config": { + "name": "automation_triggers_kind_matches_config", + "value": "config->>'kind' = kind::text" + } + }, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_host_id": { + "name": "target_host_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_project_id": { + "name": "v2_project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"automation\"]'::jsonb" + }, + "continue_agent_session": { + "name": "continue_agent_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automations_owner_idx": { + "name": "automations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automations_organization_idx": { + "name": "automations_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automations_organization_id_organizations_id_fk": { + "name": "automations_organization_id_organizations_id_fk", + "tableFrom": "automations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automations_owner_user_id_users_id_fk": { + "name": "automations_owner_user_id_users_id_fk", + "tableFrom": "automations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "automations_id_org_unique": { + "name": "automations_id_org_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_sessions_org_idx": { + "name": "chat_sessions_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_created_by_idx": { + "name": "chat_sessions_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_last_active_idx": { + "name": "chat_sessions_last_active_idx", + "columns": [ + { + "expression": "last_active_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_sessions_organization_id_organizations_id_fk": { + "name": "chat_sessions_organization_id_organizations_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_created_by_users_id_fk": { + "name": "chat_sessions_created_by_users_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_workspace_id_workspaces_id_fk": { + "name": "chat_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_workspace_repositories": { + "name": "cloud_workspace_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cloud_workspace_id": { + "name": "cloud_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cloud_workspace_repositories_cloud_workspace_id_idx": { + "name": "cloud_workspace_repositories_cloud_workspace_id_idx", + "columns": [ + { + "expression": "cloud_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_workspace_repositories_cloud_workspace_id_cloud_workspaces_id_fk": { + "name": "cloud_workspace_repositories_cloud_workspace_id_cloud_workspaces_id_fk", + "tableFrom": "cloud_workspace_repositories", + "tableTo": "cloud_workspaces", + "columnsFrom": [ + "cloud_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_workspace_repositories_repository_id_github_repositories_id_fk": { + "name": "cloud_workspace_repositories_repository_id_github_repositories_id_fk", + "tableFrom": "cloud_workspace_repositories", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cloud_workspace_repositories_workspace_repository_unique": { + "name": "cloud_workspace_repositories_workspace_repository_unique", + "nullsNotDistinct": false, + "columns": [ + "cloud_workspace_id", + "repository_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_workspaces": { + "name": "cloud_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'vercel'" + }, + "provider_sandbox_id": { + "name": "provider_sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_url": { + "name": "sandbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "cloud_workspace_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioning'" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_version": { + "name": "host_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cloud_workspaces_organization_id_idx": { + "name": "cloud_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_workspaces_organization_id_organizations_id_fk": { + "name": "cloud_workspaces_organization_id_organizations_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_workspaces_environment_id_environments_id_fk": { + "name": "cloud_workspaces_environment_id_environments_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cloud_workspaces_created_by_user_id_users_id_fk": { + "name": "cloud_workspaces_created_by_user_id_users_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cloud_workspaces_provider_sandbox_id_unique": { + "name": "cloud_workspaces_provider_sandbox_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_sandbox_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connections": { + "name": "connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connector": { + "name": "connector", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_kind": { + "name": "owner_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_method": { + "name": "auth_method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_account_id": { + "name": "external_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_account_label": { + "name": "external_account_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_user_label": { + "name": "external_user_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnect_reason": { + "name": "disconnect_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connections_org_connector_unique": { + "name": "connections_org_connector_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"connections\".\"owner_kind\" = 'org'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_user_connector_unique": { + "name": "connections_user_connector_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connected_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"connections\".\"owner_kind\" = 'user'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_org_idx": { + "name": "connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_user_connector_idx": { + "name": "connections_user_connector_idx", + "columns": [ + { + "expression": "connected_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"connections\".\"disconnected_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_external_account_idx": { + "name": "connections_external_account_idx", + "columns": [ + { + "expression": "connector", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connections_organization_id_organizations_id_fk": { + "name": "connections_organization_id_organizations_id_fk", + "tableFrom": "connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connections_connected_by_user_id_users_id_fk": { + "name": "connections_connected_by_user_id_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "connections_user_identity_present": { + "name": "connections_user_identity_present", + "value": "owner_kind <> 'user' OR external_user_id IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.deal_redemptions": { + "name": "deal_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_redemption_id": { + "name": "external_redemption_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_id": { + "name": "deal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_batch": { + "name": "company_batch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promotion_code": { + "name": "promotion_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deal_redemptions_source_external_id_unique": { + "name": "deal_redemptions_source_external_id_unique", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_redemption_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deal_redemptions_email_idx": { + "name": "deal_redemptions_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deal_redemptions_organization_id_organizations_id_fk": { + "name": "deal_redemptions_organization_id_organizations_id_fk", + "tableFrom": "deal_redemptions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.desktop_notices": { + "name": "desktop_notices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "severity": { + "name": "severity", + "type": "desktop_notice_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "desktop_notice_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'immediate'" + }, + "min_version": { + "name": "min_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_version": { + "name": "max_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platforms": { + "name": "platforms", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "channels": { + "name": "channels", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta_label": { + "name": "cta_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cta_action": { + "name": "cta_action", + "type": "desktop_notice_cta_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "cta_url": { + "name": "cta_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissible": { + "name": "dismissible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "desktop_notices_active_idx": { + "name": "desktop_notices_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_repositories": { + "name": "environment_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "environment_repositories_environment_id_idx": { + "name": "environment_repositories_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_repositories_environment_id_environments_id_fk": { + "name": "environment_repositories_environment_id_environments_id_fk", + "tableFrom": "environment_repositories", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_repositories_repository_id_github_repositories_id_fk": { + "name": "environment_repositories_repository_id_github_repositories_id_fk", + "tableFrom": "environment_repositories", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_repositories_environment_id_repository_id_unique": { + "name": "environment_repositories_environment_id_repository_id_unique", + "nullsNotDistinct": false, + "columns": [ + "environment_id", + "repository_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_secrets": { + "name": "environment_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_secrets_environment_id_idx": { + "name": "environment_secrets_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_secrets_organization_id_idx": { + "name": "environment_secrets_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_secrets_organization_id_organizations_id_fk": { + "name": "environment_secrets_organization_id_organizations_id_fk", + "tableFrom": "environment_secrets", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_secrets_environment_id_environments_id_fk": { + "name": "environment_secrets_environment_id_environments_id_fk", + "tableFrom": "environment_secrets", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_secrets_created_by_user_id_users_id_fk": { + "name": "environment_secrets_created_by_user_id_users_id_fk", + "tableFrom": "environment_secrets", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_secrets_environment_id_organization_id_key_unique": { + "name": "environment_secrets_environment_id_organization_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "environment_id", + "organization_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environments": { + "name": "environments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'vercel'" + }, + "source_kind": { + "name": "source_kind", + "type": "environment_source_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bundle_sha": { + "name": "bundle_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hooks_repository_id": { + "name": "hooks_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "environment_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environments_organization_id_idx": { + "name": "environments_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environments_organization_id_organizations_id_fk": { + "name": "environments_organization_id_organizations_id_fk", + "tableFrom": "environments", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_hooks_repository_id_github_repositories_id_fk": { + "name": "environments_hooks_repository_id_github_repositories_id_fk", + "tableFrom": "environments", + "tableTo": "github_repositories", + "columnsFrom": [ + "hooks_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "environments_created_by_user_id_users_id_fk": { + "name": "environments_created_by_user_id_users_id_fk", + "tableFrom": "environments", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environments_organization_id_name_unique": { + "name": "environments_organization_id_name_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "file_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "files_organization_id_created_at_idx": { + "name": "files_organization_id_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_status_created_at_idx": { + "name": "files_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "files_organization_id_organizations_id_fk": { + "name": "files_organization_id_organizations_id_fk", + "tableFrom": "files", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_created_by_user_id_users_id_fk": { + "name": "files_created_by_user_id_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_user_connections": { + "name": "github_user_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "github_user_id": { + "name": "github_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_access_token": { + "name": "encrypted_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "encrypted_refresh_token": { + "name": "encrypted_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "github_user_connections_user_id_users_id_fk": { + "name": "github_user_connections_user_id_users_id_fk", + "tableFrom": "github_user_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_user_connections_user_id_unique": { + "name": "github_user_connections_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnect_reason": { + "name": "disconnect_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_id": { + "name": "external_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_name": { + "name": "external_org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_org_provider_unique": { + "name": "integration_connections_org_provider_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"integration_connections\".\"provider\" <> 'google'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_google_user_unique": { + "name": "integration_connections_google_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connected_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"integration_connections\".\"provider\" = 'google'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_slack_external_org_active_unique": { + "name": "integration_connections_slack_external_org_active_unique", + "columns": [ + { + "expression": "external_org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"integration_connections\".\"provider\" = 'slack' AND \"integration_connections\".\"disconnected_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_org_idx": { + "name": "integration_connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_organization_id_organizations_id_fk": { + "name": "integration_connections_organization_id_organizations_id_fk", + "tableFrom": "integration_connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_user_id_users_id_fk": { + "name": "integration_connections_connected_by_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.page_comment_threads": { + "name": "page_comment_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "page_id": { + "name": "page_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_version_id": { + "name": "page_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "anchor_kind": { + "name": "anchor_kind", + "type": "page_comment_anchor_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "page_comment_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "anchor_text": { + "name": "anchor_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_activated_at": { + "name": "agent_activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "agent_activated_by_user_id": { + "name": "agent_activated_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by_user_id": { + "name": "resolved_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "page_comment_threads_page_id_idx": { + "name": "page_comment_threads_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_comment_threads_page_version_id_idx": { + "name": "page_comment_threads_page_version_id_idx", + "columns": [ + { + "expression": "page_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_comment_threads_open_idx": { + "name": "page_comment_threads_open_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "resolved_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_comment_threads_page_id_pages_id_fk": { + "name": "page_comment_threads_page_id_pages_id_fk", + "tableFrom": "page_comment_threads", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_comment_threads_page_version_id_page_versions_id_fk": { + "name": "page_comment_threads_page_version_id_page_versions_id_fk", + "tableFrom": "page_comment_threads", + "tableTo": "page_versions", + "columnsFrom": [ + "page_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_comment_threads_created_by_user_id_users_id_fk": { + "name": "page_comment_threads_created_by_user_id_users_id_fk", + "tableFrom": "page_comment_threads", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "page_comment_threads_agent_activated_by_user_id_users_id_fk": { + "name": "page_comment_threads_agent_activated_by_user_id_users_id_fk", + "tableFrom": "page_comment_threads", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "agent_activated_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "page_comment_threads_resolved_by_user_id_users_id_fk": { + "name": "page_comment_threads_resolved_by_user_id_users_id_fk", + "tableFrom": "page_comment_threads", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "resolved_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "page_comment_threads_anchor_matches_kind": { + "name": "page_comment_threads_anchor_matches_kind", + "value": "(anchor_kind = 'page') = (anchor IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.page_comments": { + "name": "page_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_kind": { + "name": "author_kind", + "type": "page_comment_author_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "author_user_id": { + "name": "author_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "page_comments_thread_id_created_at_idx": { + "name": "page_comments_thread_id_created_at_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_comments_thread_id_page_comment_threads_id_fk": { + "name": "page_comments_thread_id_page_comment_threads_id_fk", + "tableFrom": "page_comments", + "tableTo": "page_comment_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_comments_author_user_id_users_id_fk": { + "name": "page_comments_author_user_id_users_id_fk", + "tableFrom": "page_comments", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "page_comments_agent_has_session": { + "name": "page_comments_agent_has_session", + "value": "author_kind <> 'agent' OR agent_session_id IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.page_versions": { + "name": "page_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "page_id": { + "name": "page_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "page_versions_page_id_idx": { + "name": "page_versions_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_versions_page_id_pages_id_fk": { + "name": "page_versions_page_id_pages_id_fk", + "tableFrom": "page_versions", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_versions_created_by_user_id_users_id_fk": { + "name": "page_versions_created_by_user_id_users_id_fk", + "tableFrom": "page_versions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "page_versions_page_id_version_unique": { + "name": "page_versions_page_id_version_unique", + "nullsNotDistinct": false, + "columns": [ + "page_id", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "page_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'just_me'" + }, + "shared_version": { + "name": "shared_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "watched_by_agent": { + "name": "watched_by_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "watch_heartbeat_at": { + "name": "watch_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pages_slug_unique": { + "name": "pages_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_organization_id_updated_at_idx": { + "name": "pages_organization_id_updated_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_created_by_user_id_idx": { + "name": "pages_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pages_organization_id_organizations_id_fk": { + "name": "pages_organization_id_organizations_id_fk", + "tableFrom": "pages", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pages_created_by_user_id_users_id_fk": { + "name": "pages_created_by_user_id_users_id_fk", + "tableFrom": "pages", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_organization_id_idx": { + "name": "projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_github_repository_id_github_repositories_id_fk": { + "name": "projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_org_slug_unique": { + "name": "projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submitted_prompts": { + "name": "submitted_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "prompt_text": { + "name": "prompt_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submitter_name": { + "name": "submitter_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "submitted_prompts_user_id_idx": { + "name": "submitted_prompts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submitted_prompts_organization_id_idx": { + "name": "submitted_prompts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submitted_prompts_created_at_idx": { + "name": "submitted_prompts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submitted_prompts_user_id_users_id_fk": { + "name": "submitted_prompts_user_id_users_id_fk", + "tableFrom": "submitted_prompts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submitted_prompts_organization_id_organizations_id_fk": { + "name": "submitted_prompts_organization_id_organizations_id_fk", + "tableFrom": "submitted_prompts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_reference_id_idx": { + "name": "subscriptions_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_customer_id_idx": { + "name": "subscriptions_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_status_idx": { + "name": "subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_reference_id_organizations_id_fk": { + "name": "subscriptions_reference_id_organizations_id_fk", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_statuses": { + "name": "task_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_statuses_organization_id_idx": { + "name": "task_statuses_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_statuses_type_idx": { + "name": "task_statuses_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_statuses_organization_id_organizations_id_fk": { + "name": "task_statuses_organization_id_organizations_id_fk", + "tableFrom": "task_statuses", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_statuses_org_external_unique": { + "name": "task_statuses_org_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_updated_at": { + "name": "external_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "external_project_id": { + "name": "external_project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_project_name": { + "name": "external_project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_cycle_id": { + "name": "external_cycle_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_cycle_name": { + "name": "external_cycle_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_external_id": { + "name": "assignee_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_display_name": { + "name": "assignee_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_avatar_url": { + "name": "assignee_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_creator_id_idx": { + "name": "tasks_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_id_idx": { + "name": "tasks_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_provider_idx": { + "name": "tasks_external_provider_idx", + "columns": [ + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_project_id_idx": { + "name": "tasks_external_project_id_idx", + "columns": [ + { + "expression": "external_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_project_name_idx": { + "name": "tasks_external_project_name_idx", + "columns": [ + { + "expression": "external_project_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_cycle_id_idx": { + "name": "tasks_external_cycle_id_idx", + "columns": [ + { + "expression": "external_cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_external_id_idx": { + "name": "tasks_assignee_external_id_idx", + "columns": [ + { + "expression": "assignee_external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_status_id_task_statuses_id_fk": { + "name": "tasks_status_id_task_statuses_id_fk", + "tableFrom": "tasks", + "tableTo": "task_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tasks_external_unique": { + "name": "tasks_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + }, + "tasks_org_slug_unique": { + "name": "tasks_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_identities": { + "name": "user_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_scope_id": { + "name": "external_scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_identities_user_idx": { + "name": "user_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_identities_org_provider_idx": { + "name": "user_identities_org_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_identities_organization_id_organizations_id_fk": { + "name": "user_identities_organization_id_organizations_id_fk", + "tableFrom": "user_identities", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_identities_account_unique": { + "name": "user_identities_account_unique", + "nullsNotDistinct": true, + "columns": [ + "organization_id", + "provider", + "external_scope_id", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_clients": { + "name": "v2_clients", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_client_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_clients_organization_id_idx": { + "name": "v2_clients_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_clients_user_id_idx": { + "name": "v2_clients_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_clients_organization_id_organizations_id_fk": { + "name": "v2_clients_organization_id_organizations_id_fk", + "tableFrom": "v2_clients", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_clients_user_id_users_id_fk": { + "name": "v2_clients_user_id_users_id_fk", + "tableFrom": "v2_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_clients_organization_id_user_id_machine_id_pk": { + "name": "v2_clients_organization_id_user_id_machine_id_pk", + "columns": [ + "organization_id", + "user_id", + "machine_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_hosts": { + "name": "v2_hosts", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wake_command": { + "name": "wake_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "install_source": { + "name": "install_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_hosts_organization_id_idx": { + "name": "v2_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_hosts_organization_id_organizations_id_fk": { + "name": "v2_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_hosts_created_by_user_id_users_id_fk": { + "name": "v2_hosts_created_by_user_id_users_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_hosts_organization_id_machine_id_pk": { + "name": "v2_hosts_organization_id_machine_id_pk", + "columns": [ + "organization_id", + "machine_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_projects": { + "name": "v2_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_clone_url": { + "name": "repo_clone_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_projects_organization_id_idx": { + "name": "v2_projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_projects_organization_id_organizations_id_fk": { + "name": "v2_projects_organization_id_organizations_id_fk", + "tableFrom": "v2_projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_projects_github_repository_id_github_repositories_id_fk": { + "name": "v2_projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "v2_projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_projects_org_slug_unique": { + "name": "v2_projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_users_hosts": { + "name": "v2_users_hosts", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "v2_users_host_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_users_hosts_organization_id_idx": { + "name": "v2_users_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_user_id_idx": { + "name": "v2_users_hosts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_host_id_idx": { + "name": "v2_users_hosts_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_users_hosts_organization_id_organizations_id_fk": { + "name": "v2_users_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_user_id_users_id_fk": { + "name": "v2_users_hosts_user_id_users_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_host_fk": { + "name": "v2_users_hosts_host_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "v2_hosts", + "columnsFrom": [ + "organization_id", + "host_id" + ], + "columnsTo": [ + "organization_id", + "machine_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_users_hosts_organization_id_user_id_host_id_pk": { + "name": "v2_users_hosts_organization_id_user_id_host_id_pk", + "columns": [ + "organization_id", + "user_id", + "host_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_workspaces": { + "name": "v2_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'worktree'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_workspaces_project_id_idx": { + "name": "v2_workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_organization_id_idx": { + "name": "v2_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_host_id_idx": { + "name": "v2_workspaces_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_task_id_idx": { + "name": "v2_workspaces_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_one_main_per_host": { + "name": "v2_workspaces_one_main_per_host", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"v2_workspaces\".\"type\" = 'main'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_workspaces_organization_id_organizations_id_fk": { + "name": "v2_workspaces_organization_id_organizations_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_workspaces_created_by_user_id_users_id_fk": { + "name": "v2_workspaces_created_by_user_id_users_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "v2_workspaces_task_id_tasks_id_fk": { + "name": "v2_workspaces_task_id_tasks_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "v2_workspaces_host_fk": { + "name": "v2_workspaces_host_fk", + "tableFrom": "v2_workspaces", + "tableTo": "v2_hosts", + "columnsFrom": [ + "organization_id", + "host_id" + ], + "columnsTo": [ + "organization_id", + "machine_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_pages": { + "name": "workspace_pages", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entry_path": { + "name": "entry_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_pages_workspace_id_entry_path_unique": { + "name": "workspace_pages_workspace_id_entry_path_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entry_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_pages_page_id_idx": { + "name": "workspace_pages_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_pages_page_id_pages_id_fk": { + "name": "workspace_pages_page_id_pages_id_fk", + "tableFrom": "workspace_pages", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_pages_workspace_id_page_id_pk": { + "name": "workspace_pages_workspace_id_page_id_pk", + "columns": [ + "workspace_id", + "page_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_organization_id_idx": { + "name": "workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_type_idx": { + "name": "workspaces_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_created_by_user_id_users_id_fk": { + "name": "workspaces_created_by_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_thread_sessions": { + "name": "slack_thread_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "quiet": { + "name": "quiet", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_context_ts": { + "name": "last_context_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_log": { + "name": "entity_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "queued_events": { + "name": "queued_events", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "slack_thread_sessions_organization_id_organizations_id_fk": { + "name": "slack_thread_sessions_organization_id_organizations_id_fk", + "tableFrom": "slack_thread_sessions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_thread_sessions_started_by_user_id_users_id_fk": { + "name": "slack_thread_sessions_started_by_user_id_users_id_fk", + "tableFrom": "slack_thread_sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "started_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_thread_sessions_thread_unique": { + "name": "slack_thread_sessions_thread_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "team_id", + "channel_id", + "thread_ts" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.handle_owner_type": { + "name": "handle_owner_type", + "schema": "public", + "values": [ + "user", + "organization", + "reserved" + ] + }, + "public.leaderboard_visibility": { + "name": "leaderboard_visibility", + "schema": "public", + "values": [ + "public", + "hidden" + ] + }, + "public.agent_credential_kind": { + "name": "agent_credential_kind", + "schema": "public", + "values": [ + "subscription", + "api_key" + ] + }, + "public.attachment_parent_kind": { + "name": "attachment_parent_kind", + "schema": "public", + "values": [ + "page_version", + "issue", + "doc", + "chat_session", + "comment", + "page" + ] + }, + "public.automation_prompt_source": { + "name": "automation_prompt_source", + "schema": "public", + "values": [ + "human", + "agent", + "restore" + ] + }, + "public.automation_run_error_code": { + "name": "automation_run_error_code", + "schema": "public", + "values": [ + "no_instructions", + "host_offline", + "agent_not_found", + "workspace_not_found" + ] + }, + "public.automation_run_status": { + "name": "automation_run_status", + "schema": "public", + "values": [ + "dispatching", + "dispatched", + "skipped_offline", + "dispatch_failed", + "debounced", + "rejected" + ] + }, + "public.automation_session_kind": { + "name": "automation_session_kind", + "schema": "public", + "values": [ + "chat", + "terminal" + ] + }, + "public.automation_trigger_kind": { + "name": "automation_trigger_kind", + "schema": "public", + "values": [ + "schedule", + "webhook", + "github", + "slack", + "linear", + "sentry", + "microsoft_teams", + "google_calendar", + "gmail", + "notion" + ] + }, + "public.cloud_workspace_status": { + "name": "cloud_workspace_status", + "schema": "public", + "values": [ + "provisioning", + "ready", + "failed", + "deleted" + ] + }, + "public.command_status": { + "name": "command_status", + "schema": "public", + "values": [ + "pending", + "completed", + "failed", + "timeout" + ] + }, + "public.desktop_notice_cta_action": { + "name": "desktop_notice_cta_action", + "schema": "public", + "values": [ + "install-update", + "open-url" + ] + }, + "public.desktop_notice_severity": { + "name": "desktop_notice_severity", + "schema": "public", + "values": [ + "info", + "warning", + "blocking" + ] + }, + "public.desktop_notice_trigger": { + "name": "desktop_notice_trigger", + "schema": "public", + "values": [ + "immediate", + "pre-update", + "post-update" + ] + }, + "public.environment_scope": { + "name": "environment_scope", + "schema": "public", + "values": [ + "organization", + "personal" + ] + }, + "public.environment_source_kind": { + "name": "environment_source_kind", + "schema": "public", + "values": [ + "image", + "fork" + ] + }, + "public.file_status": { + "name": "file_status", + "schema": "public", + "values": [ + "pending", + "ready" + ] + }, + "public.integration_provider": { + "name": "integration_provider", + "schema": "public", + "values": [ + "linear", + "github", + "slack", + "sentry", + "microsoft_teams", + "google", + "notion" + ] + }, + "public.page_comment_anchor_kind": { + "name": "page_comment_anchor_kind", + "schema": "public", + "values": [ + "element", + "text", + "page" + ] + }, + "public.page_comment_author_kind": { + "name": "page_comment_author_kind", + "schema": "public", + "values": [ + "human", + "agent" + ] + }, + "public.page_comment_intent": { + "name": "page_comment_intent", + "schema": "public", + "values": [ + "delete", + "approve" + ] + }, + "public.page_visibility": { + "name": "page_visibility", + "schema": "public", + "values": [ + "just_me", + "org", + "everyone" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low", + "none" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "backlog", + "todo", + "planning", + "working", + "needs-feedback", + "ready-to-merge", + "completed", + "canceled" + ] + }, + "public.v2_client_type": { + "name": "v2_client_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.v2_users_host_role": { + "name": "v2_users_host_role", + "schema": "public", + "values": [ + "owner", + "member" + ] + }, + "public.v2_workspace_type": { + "name": "v2_workspace_type", + "schema": "public", + "values": [ + "main", + "worktree" + ] + }, + "public.workspace_type": { + "name": "workspace_type", + "schema": "public", + "values": [ + "local", + "cloud" + ] + } + }, + "schemas": { + "auth": "auth", + "ingest": "ingest" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 5247ec588cc..b1dc74be227 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -834,6 +834,13 @@ "when": 1789546826052, "tag": "0118_slack_thread_stop_requested", "breakpoints": true + }, + { + "idx": 119, + "version": "7", + "when": 1789625882184, + "tag": "0119_plugin_oauth_clients_and_connectors", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/ingest.ts b/packages/db/src/schema/ingest.ts index 3fb7505f6db..529c779ed69 100644 --- a/packages/db/src/schema/ingest.ts +++ b/packages/db/src/schema/ingest.ts @@ -7,8 +7,6 @@ import { uniqueIndex, uuid, } from "drizzle-orm/pg-core"; -import { integrationProvider } from "./schema"; - export const ingestSchema = pgSchema("ingest"); /** @@ -30,7 +28,7 @@ export const webhookEvents = ingestSchema.table( id: uuid().primaryKey().defaultRandom(), // Source - provider: integrationProvider().notNull(), + provider: text().notNull(), eventId: text("event_id").notNull(), eventType: text("event_type"), diff --git a/packages/db/src/schema/plugins.ts b/packages/db/src/schema/plugins.ts index 59a0ffa5676..def0af129f0 100644 --- a/packages/db/src/schema/plugins.ts +++ b/packages/db/src/schema/plugins.ts @@ -1,4 +1,3 @@ -import { sql } from "drizzle-orm"; import { boolean, index, @@ -48,8 +47,8 @@ export const pluginInstalls = pgTable( export type InsertPluginInstall = typeof pluginInstalls.$inferInsert; export type SelectPluginInstall = typeof pluginInstalls.$inferSelect; -export const pluginConnections = pgTable( - "plugin_connections", +export const pluginMarketplaces = pgTable( + "plugin_marketplaces", { id: uuid().primaryKey().defaultRandom(), organizationId: uuid("organization_id").references(() => organizations.id, { @@ -59,77 +58,60 @@ export const pluginConnections = pgTable( .notNull() .references(() => users.id, { onDelete: "cascade" }), - pluginName: text("plugin_name").notNull(), - installId: uuid("install_id").references(() => pluginInstalls.id, { - onDelete: "set null", - }), - authMethod: text("auth_method").notNull().default("oauth2"), - - accessToken: text("access_token").notNull(), - refreshToken: text("refresh_token"), - tokenExpiresAt: timestamp("token_expires_at"), - scopes: text().array(), - - config: jsonb(), - - externalAccountId: text("external_account_id").notNull(), - externalAccountLabel: text("external_account_label"), - - disconnectedAt: timestamp("disconnected_at"), - disconnectReason: text("disconnect_reason"), + name: text().notNull(), + sourceKind: text("source_kind").notNull(), + repo: text(), + ref: text(), + path: text(), - createdAt: timestamp("created_at").notNull().defaultNow(), + addedAt: timestamp("added_at").notNull().defaultNow(), updatedAt: timestamp("updated_at") .notNull() .defaultNow() .$onUpdate(() => new Date()), }, (table) => [ - uniqueIndex("plugin_connections_account_active_unique") - .on(table.userId, table.installId, table.externalAccountId) - .where(sql`${table.disconnectedAt} IS NULL`), - index("plugin_connections_user_plugin_idx").on( + uniqueIndex("plugin_marketplaces_user_name_unique").on( table.userId, - table.pluginName, + table.name, ), - index("plugin_connections_install_idx").on(table.installId), + index("plugin_marketplaces_user_idx").on(table.userId), ], ); -export type InsertPluginConnection = typeof pluginConnections.$inferInsert; -export type SelectPluginConnection = typeof pluginConnections.$inferSelect; +export type InsertPluginMarketplace = typeof pluginMarketplaces.$inferInsert; +export type SelectPluginMarketplace = typeof pluginMarketplaces.$inferSelect; -export const pluginMarketplaces = pgTable( - "plugin_marketplaces", +export const pluginOauthClients = pgTable( + "plugin_oauth_clients", { id: uuid().primaryKey().defaultRandom(), - organizationId: uuid("organization_id").references(() => organizations.id, { - onDelete: "cascade", - }), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - name: text().notNull(), - sourceKind: text("source_kind").notNull(), - repo: text(), - ref: text(), - path: text(), + issuer: text().notNull(), + redirectUri: text("redirect_uri").notNull(), - addedAt: timestamp("added_at").notNull().defaultNow(), + clientId: text("client_id").notNull(), + clientSecret: text("client_secret"), + clientSecretExpiresAt: timestamp("client_secret_expires_at"), + + registrationAccessToken: text("registration_access_token"), + registrationClientUri: text("registration_client_uri"), + + tokenEndpointAuthMethod: text("token_endpoint_auth_method"), + + createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at") .notNull() .defaultNow() .$onUpdate(() => new Date()), }, (table) => [ - uniqueIndex("plugin_marketplaces_user_name_unique").on( - table.userId, - table.name, + uniqueIndex("plugin_oauth_clients_issuer_redirect_unique").on( + table.issuer, + table.redirectUri, ), - index("plugin_marketplaces_user_idx").on(table.userId), ], ); -export type InsertPluginMarketplace = typeof pluginMarketplaces.$inferInsert; -export type SelectPluginMarketplace = typeof pluginMarketplaces.$inferSelect; +export type InsertPluginOauthClient = typeof pluginOauthClients.$inferInsert; +export type SelectPluginOauthClient = typeof pluginOauthClients.$inferSelect; diff --git a/packages/db/src/schema/relations.ts b/packages/db/src/schema/relations.ts index 7ba56423598..e11fdafedfe 100644 --- a/packages/db/src/schema/relations.ts +++ b/packages/db/src/schema/relations.ts @@ -16,6 +16,7 @@ import { import { agentCommands, chatSessions, + connections, integrationConnections, pageComments, pageCommentThreads, @@ -41,7 +42,7 @@ export const usersRelations = relations(users, ({ many }) => ({ invitations: many(invitations), createdTasks: many(tasks, { relationName: "creator" }), assignedTasks: many(tasks, { relationName: "assignee" }), - connectedIntegrations: many(integrationConnections), + connectedIntegrations: many(connections), githubInstallations: many(githubInstallations), v2Hosts: many(v2Hosts), v2Clients: many(v2Clients), @@ -76,7 +77,7 @@ export const organizationsRelations = relations(organizations, ({ many }) => ({ workspaces: many(workspaces), tasks: many(tasks), taskStatuses: many(taskStatuses), - integrations: many(integrationConnections), + integrations: many(connections), githubInstallations: many(githubInstallations), githubRepositories: many(githubRepositories), githubPullRequests: many(githubPullRequests), @@ -145,6 +146,17 @@ export const taskStatusesRelations = relations( }), ); +export const connectionsRelations = relations(connections, ({ one }) => ({ + organization: one(organizations, { + fields: [connections.organizationId], + references: [organizations.id], + }), + connectedBy: one(users, { + fields: [connections.connectedByUserId], + references: [users.id], + }), +})); + export const integrationConnectionsRelations = relations( integrationConnections, ({ one }) => ({ diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index 5310845d80b..a595483be72 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -297,6 +297,80 @@ export type InsertIntegrationConnection = export type SelectIntegrationConnection = typeof integrationConnections.$inferSelect; +export const connections = pgTable( + "connections", + { + id: uuid().primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + connectedByUserId: uuid("connected_by_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + connector: text().notNull(), + ownerKind: text("owner_kind").notNull(), + authMethod: text("auth_method").notNull(), + + accessToken: text("access_token").notNull(), + refreshToken: text("refresh_token"), + tokenExpiresAt: timestamp("token_expires_at"), + scopes: text().array(), + + issuer: text(), + resource: text(), + + externalAccountId: text("external_account_id").notNull(), + externalAccountLabel: text("external_account_label"), + externalUserId: text("external_user_id"), + externalUserLabel: text("external_user_label"), + + config: jsonb().$type>(), + state: jsonb().$type(), + + disconnectedAt: timestamp("disconnected_at"), + disconnectReason: text("disconnect_reason"), + + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at") + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + uniqueIndex("connections_org_connector_unique") + .on(table.organizationId, table.connector) + .where(sql`${table.ownerKind} = 'org'`), + uniqueIndex("connections_user_connector_unique") + .on( + table.organizationId, + table.connector, + table.connectedByUserId, + table.externalAccountId, + ) + .where(sql`${table.ownerKind} = 'user'`), + index("connections_org_idx").on(table.organizationId), + // Every plugin surface asks "what has this person connected" — the + // plugins list, the connections list, and the uninstall sweep — and the + // two unique indexes above lead with organization_id, so none of them + // can serve it. The table this replaced had plugin_connections_user_plugin_idx. + index("connections_user_connector_idx") + .on(table.connectedByUserId, table.connector) + .where(sql`${table.disconnectedAt} IS NULL`), + index("connections_external_account_idx").on( + table.connector, + table.externalAccountId, + ), + check( + "connections_user_identity_present", + sql`owner_kind <> 'user' OR external_user_id IS NOT NULL`, + ), + ], +); + +export type InsertConnection = typeof connections.$inferInsert; +export type SelectConnection = typeof connections.$inferSelect; + // Stripe subscriptions (org-based billing) export const subscriptions = pgTable( "subscriptions", @@ -1113,12 +1187,7 @@ export const automationEvents = pgTable( // Text, not integration_provider: this must hold "webhook" and // "superset", which have no connection behind them. - // Which connection produced this. Null for webhook and superset events. - // Not backfillable later: provider payloads do not always name it. - integrationConnectionId: uuid("integration_connection_id").references( - () => integrationConnections.id, - { onDelete: "set null" }, - ), + integrationConnectionId: uuid("integration_connection_id"), provider: text().notNull(), eventType: text("event_type").notNull(), diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index 5400eb63234..940f691cf08 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · Připojeno {joined}" msgid "{planLabel} plan" msgstr "Tarif {planLabel}" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "{plugin} zatím nemá v tomto nasazení nakonfigurovaného klienta OAuth." + +msgid "{plugin} is no longer installed." +msgstr "{plugin} už není nainstalován." + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel} obnoveno na výchozí" @@ -1477,9 +1483,6 @@ msgstr "Začněte tím, že jednoho přidáte z nabídky v postranním panelu." msgid "Add photos or files" msgstr "Přidat fotky nebo soubory" -msgid "Add plugin" -msgstr "Přidat plugin" - msgid "Add project" msgstr "Přidat projekt" @@ -2249,9 +2252,6 @@ msgstr "Auditní logy" msgid "Aug 2026" msgstr "Srp 2026" -msgid "Authenticate your {displayName} account" -msgstr "Ověřte svůj účet {displayName}" - msgid "Authentication" msgstr "Ověřování" @@ -2279,6 +2279,12 @@ msgstr "Autorizovat" msgid "Authorize {displayName}" msgstr "Autorizovat {displayName}" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "Autorizujte Superset, aby vaše pluginy, automatizace a spouštěče mohly jednat vaším jménem." + +msgid "Authorize Superset to act on your behalf." +msgstr "Autorizujte Superset, aby jednal vaším jménem." + msgid "Authorizing..." msgstr "Autorizuji..." @@ -3710,6 +3716,12 @@ msgstr "Potvrdit před ukončením" msgid "Connect" msgstr "Připojit" +msgid "Connect {displayName}" +msgstr "Připojit {displayName}" + +msgid "Connect {name}" +msgstr "Připojit {name}" + msgid "Connect a device" msgstr "Připojit zařízení" @@ -3782,9 +3794,8 @@ msgstr "Připojte svůj Slack workspace a spravujte úkoly přímo z konverzací msgid "Connected" msgstr "Připojeno" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "připojeno {0}" +msgid "Connected {label}" +msgstr "Připojeno {label}" msgid "Connected as" msgstr "Připojeno jako" @@ -3816,6 +3827,9 @@ msgstr "Připojuji" msgid "Connecting GitHub is not configured on this server" msgstr "Připojení ke GitHubu není na tomto serveru nastaveno" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "Připojením umožníte svým agentům číst a jednat v {name} vaším jménem. Než budete pokračovat, zkontrolujte, co udělujete." + msgid "Connecting to the desktop…" msgstr "Připojování k ploše…" @@ -3847,6 +3861,18 @@ msgstr "Vypršel časový limit spojení" msgid "Connections" msgstr "Připojení" +msgid "Connector" +msgstr "Konektor" + +msgid "Connectors carry risk" +msgstr "Konektory přinášejí riziko" + +msgid "Connects for your whole team." +msgstr "Připojuje se pro celý váš tým." + +msgid "Connects your account. Teammates connect their own." +msgstr "Připojí váš účet. Kolegové si připojí vlastní." + msgid "consecutive active days" msgstr "po sobě jdoucích aktivních dní" @@ -3904,6 +3930,10 @@ msgstr "Pokračovat nebo forkovat relaci" msgid "Continue the session the last run left" msgstr "Pokračovat v relaci, kterou zanechalo poslední spuštění" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "Pokračovat na {0}" + msgid "Continue update" msgstr "Pokračovat v aktualizaci" @@ -4153,6 +4183,9 @@ msgstr "Relaci se nepodařilo zavřít" msgid "Could not close the session" msgstr "Relaci se nepodařilo zavřít" +msgid "Could not connect {plugin}. Try again." +msgstr "Nepodařilo se připojit {plugin}. Zkuste to znovu." + msgid "Could not copy the link" msgstr "Odkaz se nepodařilo zkopírovat" @@ -4678,6 +4711,9 @@ msgstr "Vytvářím worktree" msgid "Creating..." msgstr "Vytvářím..." +msgid "Credentials stay on the server" +msgstr "Přihlašovací údaje zůstávají na serveru" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "Komponenty napříč funkcemi mimo packages/ui — podívejte se sem, než budete stavět novou" @@ -5135,6 +5171,9 @@ msgstr "Vývojářské přihlášení" msgid "Dev sign-in failed" msgstr "Dev přihlášení selhalo" +msgid "Developed by {author}" +msgstr "Vyvinul {author}" + msgid "Developer" msgstr "Vývojář" @@ -5253,10 +5292,6 @@ msgstr "Zahozeno — příště se nezeptáme" msgid "Disconnect" msgstr "Odpojit" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "Odpojit {0}" - msgid "Disconnect GitHub?" msgstr "Odpojit GitHub?" @@ -7596,6 +7631,9 @@ msgstr "Postřehy týmu Superset o paralelních kódovacích agentech a produkti msgid "Inspecting {0}" msgstr "Zkoumám {0}" +msgid "Install" +msgstr "Nainstalovat" + msgid "Install & restart" msgstr "Nainstalovat a restartovat" @@ -7608,12 +7646,18 @@ msgstr "Instalace selhala" msgid "Install GitHub App" msgstr "Nainstalovat GitHub App" +msgid "Install plugin" +msgstr "Nainstalovat plugin" + msgid "Install Superset MCP in your client" msgstr "Nainstalujte Superset MCP do svého klienta" msgid "Install the desktop app" msgstr "Nainstalujte desktopovou aplikaci" +msgid "Install the plugin to connect" +msgstr "Pro připojení nainstalujte plugin" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "Nainstalujte Superset GitHub App a připojte své repozitáře." @@ -8885,6 +8929,10 @@ msgstr "Další stránky" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "Víc než polovinu sloučených změn napíší agenti bez jediné lidské úpravy." +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "Odpovídá více připojení {connector}; odpojte to, které nechcete." + msgid "most developers" msgstr "většina vývojářů" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile, cloudové workspaces, automatizace a prioritní podpora msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "Superset potřebuje Plný přístup k disku, aby mohl číst data jiného prohlížeče. Povolte ho v Nastavení systému a zkontrolujte to znovu." +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset získá pouze oprávnění, o která tento konektor žádá. Kdykoli je můžete odvolat odpojením." + msgid "Superset originals" msgstr "Originály Superset" @@ -14688,6 +14739,9 @@ msgstr "Textové pole" msgid "Thanks for reaching out" msgstr "Díky, že jste se ozvali" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "Odkaz pro přihlášení k {plugin} vypršel. Zkuste se připojit znovu." + msgid "That endpoint could not be resolved." msgstr "Tento koncový bod se nepodařilo přeložit." @@ -15048,6 +15102,12 @@ msgstr "Přemýšlím..." msgid "Thirty" msgstr "Třicet" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "Účet {plugin} už připojil(a) {owner}. Požádejte ho/ji, aby ho nejprve odpojil(a)." + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "Tento účet {plugin} už je připojený k jiné organizaci Supersetu." + msgid "This action is irreversible." msgstr "Tuto akci nelze vrátit zpět." @@ -15212,9 +15272,6 @@ msgstr "Tato cesta existuje, ale není to adresář." msgid "This path is a directory" msgstr "Tato cesta je adresář" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "Tento plugin nevyžaduje účet — jeho Skills jsou připraveny k použití." - msgid "This port is still in use" msgstr "Tento port se stále používá" @@ -15453,6 +15510,9 @@ msgstr "Využití tokenů" msgid "Tokens" msgstr "Tokeny" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "Tokeny uchovává Superset a připojuje je k požadavkům na své straně. Nikdy se nezapisují do vaší místní konfigurace agenta." + msgid "Tokens per session" msgstr "Tokenů na relaci" @@ -16816,6 +16876,12 @@ msgstr "Toto předplatné nelze změnit v aplikaci. Tarif vaší organizace spra msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "Tuto akci nelze vrátit zpět. Panely terminálu zobrazí „Proces skončil“ a půjde je spustit znovu." +msgid "You control the access" +msgstr "Přístup máte pod kontrolou" + +msgid "You declined the {plugin} authorization request." +msgstr "Žádost o autorizaci pro {plugin} jste odmítli." + msgid "You don't have access to this host" msgstr "K tomuto hostu nemáte přístup" @@ -16825,6 +16891,9 @@ msgstr "K tomuto terminálu nemáte přístup." msgid "You don't have permission to remove this member" msgstr "Nemáte oprávnění tohoto člena odebrat" +msgid "You need to be part of an organization to connect accounts." +msgstr "Pro připojení účtů musíte být součástí organizace." + msgid "You need to be part of an organization to use integrations." msgstr "Pro používání integrací musíte být součástí organizace." diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index b11bea4d2bd..5f3e3856676 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · Beigetreten {joined}" msgid "{planLabel} plan" msgstr "Tarif {planLabel}" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "Für {plugin} ist in dieser Bereitstellung noch kein OAuth-Client konfiguriert." + +msgid "{plugin} is no longer installed." +msgstr "{plugin} ist nicht mehr installiert." + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel} auf Standardwerte zurückgesetzt" @@ -1477,9 +1483,6 @@ msgstr "Füge über das Menü in der Seitenleiste einen hinzu, um loszulegen." msgid "Add photos or files" msgstr "Fotos oder Dateien hinzufügen" -msgid "Add plugin" -msgstr "Plugin hinzufügen" - msgid "Add project" msgstr "Projekt hinzufügen" @@ -2249,9 +2252,6 @@ msgstr "Audit-Logs" msgid "Aug 2026" msgstr "Aug. 2026" -msgid "Authenticate your {displayName} account" -msgstr "Authentifiziere dein {displayName}-Konto" - msgid "Authentication" msgstr "Authentifizierung" @@ -2279,6 +2279,12 @@ msgstr "Autorisieren" msgid "Authorize {displayName}" msgstr "{displayName} autorisieren" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "Autorisieren Sie Superset, damit Ihre Plugins, Automatisierungen und Trigger in Ihrem Namen handeln können." + +msgid "Authorize Superset to act on your behalf." +msgstr "Autorisieren Sie Superset, in Ihrem Namen zu handeln." + msgid "Authorizing..." msgstr "Wird autorisiert..." @@ -3710,6 +3716,12 @@ msgstr "Vor dem Beenden bestätigen" msgid "Connect" msgstr "Verbinden" +msgid "Connect {displayName}" +msgstr "{displayName} verbinden" + +msgid "Connect {name}" +msgstr "{name} verbinden" + msgid "Connect a device" msgstr "Gerät verbinden" @@ -3782,9 +3794,8 @@ msgstr "Verbinde deinen Slack-Workspace, um Aufgaben aus Unterhaltungen zu verwa msgid "Connected" msgstr "Verbunden" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "verbunden {0}" +msgid "Connected {label}" +msgstr "{label} verbunden" msgid "Connected as" msgstr "Verbunden als" @@ -3816,6 +3827,9 @@ msgstr "Verbindet" msgid "Connecting GitHub is not configured on this server" msgstr "Die GitHub-Verbindung ist auf diesem Server nicht eingerichtet" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "Durch das Verbinden können Ihre Agenten in {name} in Ihrem Namen lesen und handeln. Prüfen Sie vor dem Fortfahren, was Sie gewähren." + msgid "Connecting to the desktop…" msgstr "Verbindung zum Desktop wird hergestellt …" @@ -3847,6 +3861,18 @@ msgstr "Zeitüberschreitung der Verbindung" msgid "Connections" msgstr "Verbindungen" +msgid "Connector" +msgstr "Konnektor" + +msgid "Connectors carry risk" +msgstr "Connectors bergen Risiken" + +msgid "Connects for your whole team." +msgstr "Wird für Ihr gesamtes Team verbunden." + +msgid "Connects your account. Teammates connect their own." +msgstr "Verbindet Ihr Konto. Teammitglieder verbinden ihr eigenes." + msgid "consecutive active days" msgstr "aufeinanderfolgende aktive Tage" @@ -3904,6 +3930,10 @@ msgstr "Sitzung fortsetzen oder forken" msgid "Continue the session the last run left" msgstr "Die Sitzung des letzten Laufs fortsetzen" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "Weiter zu {0}" + msgid "Continue update" msgstr "Update fortsetzen" @@ -4153,6 +4183,9 @@ msgstr "Sitzung konnte nicht geschlossen werden" msgid "Could not close the session" msgstr "Sitzung konnte nicht geschlossen werden" +msgid "Could not connect {plugin}. Try again." +msgstr "{plugin} konnte nicht verbunden werden. Versuchen Sie es erneut." + msgid "Could not copy the link" msgstr "Link konnte nicht kopiert werden" @@ -4678,6 +4711,9 @@ msgstr "Worktree wird erstellt" msgid "Creating..." msgstr "Wird erstellt..." +msgid "Credentials stay on the server" +msgstr "Zugangsdaten bleiben auf dem Server" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "Feature-übergreifende Komponenten außerhalb von packages/ui — schau hier nach, bevor du eine neue baust" @@ -5135,6 +5171,9 @@ msgstr "Dev-Anmeldung" msgid "Dev sign-in failed" msgstr "Dev-Anmeldung fehlgeschlagen" +msgid "Developed by {author}" +msgstr "Entwickelt von {author}" + msgid "Developer" msgstr "Entwickler" @@ -5253,10 +5292,6 @@ msgstr "Verworfen — wird nicht mehr gefragt" msgid "Disconnect" msgstr "Trennen" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "{0} trennen" - msgid "Disconnect GitHub?" msgstr "GitHub trennen?" @@ -7596,6 +7631,9 @@ msgstr "Einblicke vom Superset-Team zu parallelen Coding-Agenten und Entwicklerp msgid "Inspecting {0}" msgstr "{0} wird untersucht" +msgid "Install" +msgstr "Installieren" + msgid "Install & restart" msgstr "Installieren & neu starten" @@ -7608,12 +7646,18 @@ msgstr "Installation fehlgeschlagen" msgid "Install GitHub App" msgstr "GitHub-App installieren" +msgid "Install plugin" +msgstr "Plugin installieren" + msgid "Install Superset MCP in your client" msgstr "Superset MCP in deinem Client installieren" msgid "Install the desktop app" msgstr "Desktop-App installieren" +msgid "Install the plugin to connect" +msgstr "Installieren Sie das Plugin, um zu verbinden" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "Installiere die Superset-GitHub-App, um deine Repositories zu verbinden." @@ -8885,6 +8929,10 @@ msgstr "Weitere Seiten" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "Mehr als die Hälfte der gemergten Änderungen stammt von Agenten, ohne menschliche Bearbeitung." +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "Mehrere {connector}-Verbindungen passen; trenne die, die du nicht willst." + msgid "most developers" msgstr "die meisten Entwickler" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile, Cloud-Workspaces, Automatisierungen und priorisierter S msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "Superset braucht Festplattenvollzugriff, um die Daten eines anderen Browsers zu lesen. Erteile ihn in den Systemeinstellungen und prüfe dann erneut." +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset erhält nur die Berechtigungen, die dieser Connector anfordert. Trennen Sie die Verbindung jederzeit, um sie zu widerrufen." + msgid "Superset originals" msgstr "Superset-Originale" @@ -14688,6 +14739,9 @@ msgstr "Textarea" msgid "Thanks for reaching out" msgstr "Danke für deine Nachricht" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "Dieser Anmeldelink für {plugin} ist abgelaufen. Versuchen Sie es erneut." + msgid "That endpoint could not be resolved." msgstr "Dieser Endpunkt konnte nicht aufgelöst werden." @@ -15048,6 +15102,12 @@ msgstr "Denkt nach..." msgid "Thirty" msgstr "Dreißig" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "Das {plugin}-Konto ist bereits von {owner} verbunden. Bitte sie oder ihn, es zuerst zu trennen." + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "Dieses {plugin}-Konto ist bereits mit einer anderen Superset-Organisation verbunden." + msgid "This action is irreversible." msgstr "Diese Aktion ist nicht umkehrbar." @@ -15212,9 +15272,6 @@ msgstr "Dieser Pfad existiert, ist aber kein Verzeichnis." msgid "This path is a directory" msgstr "Dieser Pfad ist ein Verzeichnis" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "Dieses Plugin braucht kein Konto — seine Skills sind sofort einsatzbereit." - msgid "This port is still in use" msgstr "Dieser Port ist noch in Benutzung" @@ -15453,6 +15510,9 @@ msgstr "Token-Nutzung" msgid "Tokens" msgstr "Tokens" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "Tokens werden von Superset gehalten und dort an Anfragen angehängt. Sie werden nie in Ihre lokale Agent-Konfiguration geschrieben." + msgid "Tokens per session" msgstr "Tokens pro Sitzung" @@ -16816,6 +16876,12 @@ msgstr "Dieses Abo lässt sich nicht in der App ändern. Der Tarif deiner Organi msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "Das lässt sich nicht rückgängig machen. Terminal-Bereiche zeigen dann \"Process exited\" und können neu gestartet werden." +msgid "You control the access" +msgstr "Sie behalten die Kontrolle" + +msgid "You declined the {plugin} authorization request." +msgstr "Sie haben die Autorisierungsanfrage für {plugin} abgelehnt." + msgid "You don't have access to this host" msgstr "Du hast keinen Zugriff auf diesen Host" @@ -16825,6 +16891,9 @@ msgstr "Du hast keinen Zugriff auf dieses Terminal." msgid "You don't have permission to remove this member" msgstr "Du hast keine Berechtigung, dieses Mitglied zu entfernen" +msgid "You need to be part of an organization to connect accounts." +msgstr "Sie müssen Teil einer Organisation sein, um Konten zu verbinden." + msgid "You need to be part of an organization to use integrations." msgstr "Du musst Teil einer Organisation sein, um Integrationen zu nutzen." diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index 0f1e62ce0c3..d0017f30eed 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · Joined {joined}" msgid "{planLabel} plan" msgstr "{planLabel} plan" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "{plugin} has no OAuth client configured on this deployment yet." + +msgid "{plugin} is no longer installed." +msgstr "{plugin} is no longer installed." + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel} reset to defaults" @@ -1477,9 +1483,6 @@ msgstr "Add one from the menu in the sidebar to get started." msgid "Add photos or files" msgstr "Add photos or files" -msgid "Add plugin" -msgstr "Add plugin" - msgid "Add project" msgstr "Add project" @@ -2249,9 +2252,6 @@ msgstr "Audit logs" msgid "Aug 2026" msgstr "Aug 2026" -msgid "Authenticate your {displayName} account" -msgstr "Authenticate your {displayName} account" - msgid "Authentication" msgstr "Authentication" @@ -2279,6 +2279,12 @@ msgstr "Authorize" msgid "Authorize {displayName}" msgstr "Authorize {displayName}" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "Authorize Superset so your plugins, automations and triggers can act on your behalf." + +msgid "Authorize Superset to act on your behalf." +msgstr "Authorize Superset to act on your behalf." + msgid "Authorizing..." msgstr "Authorizing..." @@ -3710,6 +3716,12 @@ msgstr "Confirm before quitting" msgid "Connect" msgstr "Connect" +msgid "Connect {displayName}" +msgstr "Connect {displayName}" + +msgid "Connect {name}" +msgstr "Connect {name}" + msgid "Connect a device" msgstr "Connect a device" @@ -3782,9 +3794,8 @@ msgstr "Connect your Slack workspace to manage tasks from conversations." msgid "Connected" msgstr "Connected" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "connected {0}" +msgid "Connected {label}" +msgstr "Connected {label}" msgid "Connected as" msgstr "Connected as" @@ -3816,6 +3827,9 @@ msgstr "Connecting" msgid "Connecting GitHub is not configured on this server" msgstr "Connecting GitHub is not configured on this server" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." + msgid "Connecting to the desktop…" msgstr "Connecting to the desktop…" @@ -3847,6 +3861,18 @@ msgstr "Connection Timed Out" msgid "Connections" msgstr "Connections" +msgid "Connector" +msgstr "Connector" + +msgid "Connectors carry risk" +msgstr "Connectors carry risk" + +msgid "Connects for your whole team." +msgstr "Connects for your whole team." + +msgid "Connects your account. Teammates connect their own." +msgstr "Connects your account. Teammates connect their own." + msgid "consecutive active days" msgstr "consecutive active days" @@ -3904,6 +3930,10 @@ msgstr "Continue or fork session" msgid "Continue the session the last run left" msgstr "Continue the session the last run left" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "Continue to {0}" + msgid "Continue update" msgstr "Continue update" @@ -4153,6 +4183,9 @@ msgstr "Could not close session" msgid "Could not close the session" msgstr "Could not close the session" +msgid "Could not connect {plugin}. Try again." +msgstr "Could not connect {plugin}. Try again." + msgid "Could not copy the link" msgstr "Could not copy the link" @@ -4678,6 +4711,9 @@ msgstr "Creating worktree" msgid "Creating..." msgstr "Creating..." +msgid "Credentials stay on the server" +msgstr "Credentials stay on the server" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "Cross-feature components living outside packages/ui — check here before building a new one" @@ -5135,6 +5171,9 @@ msgstr "Dev sign in" msgid "Dev sign-in failed" msgstr "Dev sign-in failed" +msgid "Developed by {author}" +msgstr "Developed by {author}" + msgid "Developer" msgstr "Developer" @@ -5253,10 +5292,6 @@ msgstr "Discarded — won't ask again" msgid "Disconnect" msgstr "Disconnect" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "Disconnect {0}" - msgid "Disconnect GitHub?" msgstr "Disconnect GitHub?" @@ -7596,6 +7631,9 @@ msgstr "Insights from the Superset team about parallel coding agents and develop msgid "Inspecting {0}" msgstr "Inspecting {0}" +msgid "Install" +msgstr "Install" + msgid "Install & restart" msgstr "Install & restart" @@ -7608,12 +7646,18 @@ msgstr "Install failed" msgid "Install GitHub App" msgstr "Install GitHub App" +msgid "Install plugin" +msgstr "Install plugin" + msgid "Install Superset MCP in your client" msgstr "Install Superset MCP in your client" msgid "Install the desktop app" msgstr "Install the desktop app" +msgid "Install the plugin to connect" +msgstr "Install the plugin to connect" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "Install the Superset GitHub App to connect your repositories." @@ -8885,6 +8929,10 @@ msgstr "More pages" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "More than half of merged changes are written by agents with zero human edits." +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "More than one {connector} connection matches; disconnect the one you do not want." + msgid "most developers" msgstr "most developers" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile, cloud workspaces, automations, and priority support." msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." + msgid "Superset originals" msgstr "Superset originals" @@ -14688,6 +14739,9 @@ msgstr "Textarea" msgid "Thanks for reaching out" msgstr "Thanks for reaching out" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "That {plugin} sign-in link expired. Try connecting again." + msgid "That endpoint could not be resolved." msgstr "That endpoint could not be resolved." @@ -15048,6 +15102,12 @@ msgstr "Thinking..." msgid "Thirty" msgstr "Thirty" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "This {plugin} account is already connected by {owner}. Ask them to disconnect first." + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "This {plugin} account is already connected by another Superset organization." + msgid "This action is irreversible." msgstr "This action is irreversible." @@ -15212,9 +15272,6 @@ msgstr "This path exists, but it is not a directory." msgid "This path is a directory" msgstr "This path is a directory" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "This plugin needs no account — its skills are ready to use." - msgid "This port is still in use" msgstr "This port is still in use" @@ -15453,6 +15510,9 @@ msgstr "Token usage" msgid "Tokens" msgstr "Tokens" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." + msgid "Tokens per session" msgstr "Tokens per session" @@ -16816,6 +16876,12 @@ msgstr "You can't change this subscription in the app. Your organization's plan msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." +msgid "You control the access" +msgstr "You control the access" + +msgid "You declined the {plugin} authorization request." +msgstr "You declined the {plugin} authorization request." + msgid "You don't have access to this host" msgstr "You don't have access to this host" @@ -16825,6 +16891,9 @@ msgstr "You don't have access to this terminal." msgid "You don't have permission to remove this member" msgstr "You don't have permission to remove this member" +msgid "You need to be part of an organization to connect accounts." +msgstr "You need to be part of an organization to connect accounts." + msgid "You need to be part of an organization to use integrations." msgstr "You need to be part of an organization to use integrations." diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index 52f7f913baf..4dc8e3cbf6b 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · Se unió {joined}" msgid "{planLabel} plan" msgstr "Plan {planLabel}" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "{plugin} aún no tiene un cliente OAuth configurado en este despliegue." + +msgid "{plugin} is no longer installed." +msgstr "{plugin} ya no está instalado." + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel} restablecido a los valores predeterminados" @@ -1477,9 +1483,6 @@ msgstr "Añade uno desde el menú de la barra lateral para empezar." msgid "Add photos or files" msgstr "Añadir fotos o archivos" -msgid "Add plugin" -msgstr "Añadir plugin" - msgid "Add project" msgstr "Añadir proyecto" @@ -2249,9 +2252,6 @@ msgstr "Registros de auditoría" msgid "Aug 2026" msgstr "Ago 2026" -msgid "Authenticate your {displayName} account" -msgstr "Autentica tu cuenta de {displayName}" - msgid "Authentication" msgstr "Autenticación" @@ -2279,6 +2279,12 @@ msgstr "Autorizar" msgid "Authorize {displayName}" msgstr "Autorizar {displayName}" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "Autoriza Superset para que tus plugins, automatizaciones y disparadores puedan actuar en tu nombre." + +msgid "Authorize Superset to act on your behalf." +msgstr "Autoriza a Superset a actuar en tu nombre." + msgid "Authorizing..." msgstr "Autorizando..." @@ -3710,6 +3716,12 @@ msgstr "Confirmar antes de salir" msgid "Connect" msgstr "Conectar" +msgid "Connect {displayName}" +msgstr "Conectar {displayName}" + +msgid "Connect {name}" +msgstr "Conectar {name}" + msgid "Connect a device" msgstr "Conectar un dispositivo" @@ -3782,9 +3794,8 @@ msgstr "Conecta tu espacio de trabajo de Slack para gestionar tareas desde las c msgid "Connected" msgstr "Conectado" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "conectado {0}" +msgid "Connected {label}" +msgstr "{label} conectado" msgid "Connected as" msgstr "Conectado como" @@ -3816,6 +3827,9 @@ msgstr "Conectando" msgid "Connecting GitHub is not configured on this server" msgstr "La conexión con GitHub no está configurada en este servidor" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "Conectar permite que tus agentes lean y actúen en {name} en tu nombre. Revisa lo que estás concediendo antes de continuar." + msgid "Connecting to the desktop…" msgstr "Conectando con el escritorio…" @@ -3847,6 +3861,18 @@ msgstr "Se agotó el tiempo de conexión" msgid "Connections" msgstr "Conexiones" +msgid "Connector" +msgstr "Conector" + +msgid "Connectors carry risk" +msgstr "Los conectores implican riesgos" + +msgid "Connects for your whole team." +msgstr "Se conecta para todo tu equipo." + +msgid "Connects your account. Teammates connect their own." +msgstr "Conecta tu cuenta. Tus compañeros conectan la suya." + msgid "consecutive active days" msgstr "días activos consecutivos" @@ -3904,6 +3930,10 @@ msgstr "Continuar o bifurcar la sesión" msgid "Continue the session the last run left" msgstr "Continuar la sesión que dejó la última ejecución" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "Continuar a {0}" + msgid "Continue update" msgstr "Continuar con la actualización" @@ -4153,6 +4183,9 @@ msgstr "No se pudo cerrar la sesión" msgid "Could not close the session" msgstr "No se pudo cerrar la sesión" +msgid "Could not connect {plugin}. Try again." +msgstr "No se pudo conectar {plugin}. Inténtalo de nuevo." + msgid "Could not copy the link" msgstr "No se pudo copiar el enlace" @@ -4678,6 +4711,9 @@ msgstr "Creando el worktree" msgid "Creating..." msgstr "Creando..." +msgid "Credentials stay on the server" +msgstr "Las credenciales permanecen en el servidor" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "Componentes transversales que viven fuera de packages/ui: míralos aquí antes de crear uno nuevo" @@ -5135,6 +5171,9 @@ msgstr "Inicio de sesión de desarrollo" msgid "Dev sign-in failed" msgstr "Error en el inicio de sesión de desarrollo" +msgid "Developed by {author}" +msgstr "Desarrollado por {author}" + msgid "Developer" msgstr "Desarrollador" @@ -5253,10 +5292,6 @@ msgstr "Descartado: no se volverá a preguntar" msgid "Disconnect" msgstr "Desconectar" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "Desconectar {0}" - msgid "Disconnect GitHub?" msgstr "¿Desconectar GitHub?" @@ -7596,6 +7631,9 @@ msgstr "Análisis del equipo de Superset sobre agentes de código en paralelo y msgid "Inspecting {0}" msgstr "Inspeccionando {0}" +msgid "Install" +msgstr "Instalar" + msgid "Install & restart" msgstr "Instalar y reiniciar" @@ -7608,12 +7646,18 @@ msgstr "Error al instalar" msgid "Install GitHub App" msgstr "Instalar la GitHub App" +msgid "Install plugin" +msgstr "Instalar plugin" + msgid "Install Superset MCP in your client" msgstr "Instala Superset MCP en tu cliente" msgid "Install the desktop app" msgstr "Instala la app de escritorio" +msgid "Install the plugin to connect" +msgstr "Instala el plugin para conectar" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "Instala la GitHub App de Superset para conectar tus repositorios." @@ -8885,6 +8929,10 @@ msgstr "Más páginas" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "Más de la mitad de los cambios fusionados los escriben agentes sin ninguna edición humana." +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "Hay más de una conexión de {connector}; desconecta la que no quieras." + msgid "most developers" msgstr "la mayoría de los desarrolladores" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile, workspaces en la nube, automatizaciones y soporte prior msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "Superset necesita Acceso a disco completo para leer los datos de otro navegador. Concederlo en Ajustes del Sistema y volver a comprobar." +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset solo recibe los permisos que pide este conector. Desconéctalo en cualquier momento para revocarlos." + msgid "Superset originals" msgstr "Originales de Superset" @@ -14688,6 +14739,9 @@ msgstr "Área de texto" msgid "Thanks for reaching out" msgstr "Gracias por escribirnos" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "Ese enlace de inicio de sesión de {plugin} ha caducado. Vuelve a conectarte." + msgid "That endpoint could not be resolved." msgstr "No se pudo resolver ese endpoint." @@ -15048,6 +15102,12 @@ msgstr "Pensando..." msgid "Thirty" msgstr "Treinta" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "{owner} ya conectó esta cuenta de {plugin}. Pídele que la desconecte primero." + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "Esta cuenta de {plugin} ya está conectada por otra organización de Superset." + msgid "This action is irreversible." msgstr "Esta acción es irreversible." @@ -15212,9 +15272,6 @@ msgstr "Esta ruta existe, pero no es un directorio." msgid "This path is a directory" msgstr "Esta ruta es un directorio" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "Este plugin no necesita cuenta — sus Skills están listos para usar." - msgid "This port is still in use" msgstr "Este puerto sigue en uso" @@ -15453,6 +15510,9 @@ msgstr "Uso de tokens" msgid "Tokens" msgstr "Tokens" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "Superset guarda los tokens y los adjunta a las solicitudes en el servidor. Nunca se escriben en tu configuración local del agente." + msgid "Tokens per session" msgstr "Tokens por sesión" @@ -16816,6 +16876,12 @@ msgstr "No puedes cambiar esta suscripción en la app. El plan de tu organizaci msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "Esta acción no se puede deshacer. Los paneles de terminal mostrarán \"Process exited\" y se pueden reiniciar." +msgid "You control the access" +msgstr "Tú controlas el acceso" + +msgid "You declined the {plugin} authorization request." +msgstr "Has rechazado la solicitud de autorización de {plugin}." + msgid "You don't have access to this host" msgstr "No tienes acceso a este host" @@ -16825,6 +16891,9 @@ msgstr "No tienes acceso a este terminal." msgid "You don't have permission to remove this member" msgstr "No tienes permiso para quitar a este miembro" +msgid "You need to be part of an organization to connect accounts." +msgstr "Necesitas formar parte de una organización para conectar cuentas." + msgid "You need to be part of an organization to use integrations." msgstr "Tienes que formar parte de una organización para usar integraciones." diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index 76826f42f89..33ac76da7ce 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · Membre depuis {joined}" msgid "{planLabel} plan" msgstr "Forfait {planLabel}" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "Aucun client OAuth n'est encore configuré pour {plugin} sur ce déploiement." + +msgid "{plugin} is no longer installed." +msgstr "{plugin} n'est plus installé." + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel} réinitialisé aux valeurs par défaut" @@ -1477,9 +1483,6 @@ msgstr "Ajoutez-en un depuis le menu de la barre latérale pour commencer." msgid "Add photos or files" msgstr "Ajouter des photos ou des fichiers" -msgid "Add plugin" -msgstr "Ajouter un plugin" - msgid "Add project" msgstr "Ajouter un projet" @@ -2249,9 +2252,6 @@ msgstr "Journaux d'audit" msgid "Aug 2026" msgstr "Août 2026" -msgid "Authenticate your {displayName} account" -msgstr "Authentifiez votre compte {displayName}" - msgid "Authentication" msgstr "Authentification" @@ -2279,6 +2279,12 @@ msgstr "Autoriser" msgid "Authorize {displayName}" msgstr "Autoriser {displayName}" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "Autorisez Superset afin que vos plugins, automatisations et déclencheurs puissent agir en votre nom." + +msgid "Authorize Superset to act on your behalf." +msgstr "Autorisez Superset à agir en votre nom." + msgid "Authorizing..." msgstr "Autorisation..." @@ -3710,6 +3716,12 @@ msgstr "Confirmer avant de quitter" msgid "Connect" msgstr "Connecter" +msgid "Connect {displayName}" +msgstr "Connecter {displayName}" + +msgid "Connect {name}" +msgstr "Connecter {name}" + msgid "Connect a device" msgstr "Connecter un appareil" @@ -3782,9 +3794,8 @@ msgstr "Connectez votre espace de travail Slack pour gérer les tâches depuis v msgid "Connected" msgstr "Connecté" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "connecté {0}" +msgid "Connected {label}" +msgstr "{label} connecté" msgid "Connected as" msgstr "Connecté en tant que" @@ -3816,6 +3827,9 @@ msgstr "Connexion" msgid "Connecting GitHub is not configured on this server" msgstr "La connexion GitHub n'est pas configurée sur ce serveur" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "La connexion permet à vos agents de lire et d'agir dans {name} en votre nom. Vérifiez ce que vous accordez avant de continuer." + msgid "Connecting to the desktop…" msgstr "Connexion au bureau…" @@ -3847,6 +3861,18 @@ msgstr "Délai de connexion dépassé" msgid "Connections" msgstr "Connexions" +msgid "Connector" +msgstr "Connecteur" + +msgid "Connectors carry risk" +msgstr "Les connecteurs comportent des risques" + +msgid "Connects for your whole team." +msgstr "Se connecte pour toute votre équipe." + +msgid "Connects your account. Teammates connect their own." +msgstr "Connecte votre compte. Vos collègues connectent le leur." + msgid "consecutive active days" msgstr "jours actifs consécutifs" @@ -3904,6 +3930,10 @@ msgstr "Continuer ou forker la session" msgid "Continue the session the last run left" msgstr "Continuer la session laissée par la dernière exécution" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "Continuer vers {0}" + msgid "Continue update" msgstr "Poursuivre la mise à jour" @@ -4153,6 +4183,9 @@ msgstr "Impossible de fermer la session" msgid "Could not close the session" msgstr "Impossible de fermer la session" +msgid "Could not connect {plugin}. Try again." +msgstr "Impossible de connecter {plugin}. Réessayez." + msgid "Could not copy the link" msgstr "Impossible de copier le lien" @@ -4678,6 +4711,9 @@ msgstr "Création du worktree" msgid "Creating..." msgstr "Création..." +msgid "Credentials stay on the server" +msgstr "Les identifiants restent sur le serveur" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "Composants transverses situés hors de packages/ui — regardez ici avant d'en créer un nouveau" @@ -5135,6 +5171,9 @@ msgstr "Connexion dev" msgid "Dev sign-in failed" msgstr "Échec de la connexion dev" +msgid "Developed by {author}" +msgstr "Développé par {author}" + msgid "Developer" msgstr "Développeur" @@ -5253,10 +5292,6 @@ msgstr "Abandonnées — ne sera plus demandé" msgid "Disconnect" msgstr "Déconnecter" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "Déconnecter {0}" - msgid "Disconnect GitHub?" msgstr "Déconnecter GitHub ?" @@ -7596,6 +7631,9 @@ msgstr "Le point de vue de l'équipe Superset sur les agents de code parallèles msgid "Inspecting {0}" msgstr "Inspection de {0}" +msgid "Install" +msgstr "Installer" + msgid "Install & restart" msgstr "Installer et redémarrer" @@ -7608,12 +7646,18 @@ msgstr "Échec de l'installation" msgid "Install GitHub App" msgstr "Installer la GitHub App" +msgid "Install plugin" +msgstr "Installer le plugin" + msgid "Install Superset MCP in your client" msgstr "Installer Superset MCP dans votre client" msgid "Install the desktop app" msgstr "Installer l'application de bureau" +msgid "Install the plugin to connect" +msgstr "Installez le plugin pour vous connecter" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "Installez la GitHub App Superset pour connecter vos dépôts." @@ -8885,6 +8929,10 @@ msgstr "Plus de pages" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "Plus de la moitié des changements fusionnés sont écrits par des agents, sans aucune retouche humaine." +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "Plusieurs connexions {connector} correspondent ; déconnectez celle dont vous ne voulez pas." + msgid "most developers" msgstr "la plupart des développeurs" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile, workspaces cloud, automatisations et support prioritair msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "Superset a besoin d'un accès complet au disque pour lire les données d'un autre navigateur. Accordez-le dans les Réglages Système, puis vérifiez à nouveau." +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset ne reçoit que les autorisations demandées par ce connecteur. Déconnectez-le à tout moment pour les révoquer." + msgid "Superset originals" msgstr "Originaux Superset" @@ -14688,6 +14739,9 @@ msgstr "Zone de texte" msgid "Thanks for reaching out" msgstr "Merci de nous avoir écrit" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "Ce lien de connexion {plugin} a expiré. Réessayez de vous connecter." + msgid "That endpoint could not be resolved." msgstr "Ce point de terminaison n'a pas pu être résolu." @@ -15048,6 +15102,12 @@ msgstr "Réflexion..." msgid "Thirty" msgstr "Trente" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "Ce compte {plugin} est déjà connecté par {owner}. Demandez-lui de le déconnecter d'abord." + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "Ce compte {plugin} est déjà connecté par une autre organisation Superset." + msgid "This action is irreversible." msgstr "Cette action est irréversible." @@ -15212,9 +15272,6 @@ msgstr "Ce chemin existe, mais ce n'est pas un répertoire." msgid "This path is a directory" msgstr "Ce chemin est un répertoire" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "Ce plugin ne nécessite aucun compte — ses compétences sont prêtes à l'emploi." - msgid "This port is still in use" msgstr "Ce port est toujours utilisé" @@ -15453,6 +15510,9 @@ msgstr "Utilisation de tokens" msgid "Tokens" msgstr "Tokens" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "Les jetons sont conservés par Superset et ajoutés aux requêtes côté serveur. Ils ne sont jamais écrits dans votre configuration d'agent locale." + msgid "Tokens per session" msgstr "Tokens par session" @@ -16816,6 +16876,12 @@ msgstr "Vous ne pouvez pas modifier cet abonnement dans l'application. Le forfai msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "Cette action est irréversible. Les volets de terminal afficheront « Processus terminé » et pourront être relancés." +msgid "You control the access" +msgstr "Vous contrôlez l'accès" + +msgid "You declined the {plugin} authorization request." +msgstr "Vous avez refusé la demande d'autorisation de {plugin}." + msgid "You don't have access to this host" msgstr "Vous n'avez pas accès à cet hôte" @@ -16825,6 +16891,9 @@ msgstr "Vous n'avez pas accès à ce terminal." msgid "You don't have permission to remove this member" msgstr "Vous n'avez pas la permission de retirer ce membre" +msgid "You need to be part of an organization to connect accounts." +msgstr "Vous devez faire partie d'une organisation pour connecter des comptes." + msgid "You need to be part of an organization to use integrations." msgstr "Vous devez faire partie d'une organisation pour utiliser les intégrations." diff --git a/packages/i18n/locales/id/messages.po b/packages/i18n/locales/id/messages.po index ec1b6e68519..9813510737c 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · Bergabung {joined}" msgid "{planLabel} plan" msgstr "Paket {planLabel}" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "{plugin} belum memiliki klien OAuth yang dikonfigurasi di deployment ini." + +msgid "{plugin} is no longer installed." +msgstr "{plugin} sudah tidak terpasang." + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel} direset ke setelan bawaan" @@ -1477,9 +1483,6 @@ msgstr "Tambahkan satu dari menu di sidebar untuk memulai." msgid "Add photos or files" msgstr "Tambah foto atau file" -msgid "Add plugin" -msgstr "Tambah plugin" - msgid "Add project" msgstr "Tambah proyek" @@ -2249,9 +2252,6 @@ msgstr "Log audit" msgid "Aug 2026" msgstr "Agu 2026" -msgid "Authenticate your {displayName} account" -msgstr "Autentikasi akun {displayName} Anda" - msgid "Authentication" msgstr "Autentikasi" @@ -2279,6 +2279,12 @@ msgstr "Otorisasi" msgid "Authorize {displayName}" msgstr "Otorisasi {displayName}" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "Izinkan Superset agar plugin, otomatisasi, dan pemicu Anda dapat bertindak atas nama Anda." + +msgid "Authorize Superset to act on your behalf." +msgstr "Izinkan Superset bertindak atas nama Anda." + msgid "Authorizing..." msgstr "Mengotorisasi..." @@ -3710,6 +3716,12 @@ msgstr "Konfirmasi sebelum keluar" msgid "Connect" msgstr "Hubungkan" +msgid "Connect {displayName}" +msgstr "Hubungkan {displayName}" + +msgid "Connect {name}" +msgstr "Hubungkan {name}" + msgid "Connect a device" msgstr "Hubungkan perangkat" @@ -3782,9 +3794,8 @@ msgstr "Hubungkan workspace Slack Anda untuk mengelola tugas dari percakapan." msgid "Connected" msgstr "Terhubung" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "terhubung {0}" +msgid "Connected {label}" +msgstr "{label} terhubung" msgid "Connected as" msgstr "Terhubung sebagai" @@ -3816,6 +3827,9 @@ msgstr "Menghubungkan" msgid "Connecting GitHub is not configured on this server" msgstr "Koneksi GitHub belum dikonfigurasi di server ini" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "Menghubungkan memungkinkan agen Anda membaca dan bertindak di {name} atas nama Anda. Tinjau apa yang Anda berikan sebelum melanjutkan." + msgid "Connecting to the desktop…" msgstr "Menghubungkan ke desktop…" @@ -3847,6 +3861,18 @@ msgstr "Koneksi Kehabisan Waktu" msgid "Connections" msgstr "Koneksi" +msgid "Connector" +msgstr "Konektor" + +msgid "Connectors carry risk" +msgstr "Konektor membawa risiko" + +msgid "Connects for your whole team." +msgstr "Terhubung untuk seluruh tim Anda." + +msgid "Connects your account. Teammates connect their own." +msgstr "Menghubungkan akun Anda. Rekan tim menghubungkan akun mereka sendiri." + msgid "consecutive active days" msgstr "hari aktif berturut-turut" @@ -3904,6 +3930,10 @@ msgstr "Lanjutkan atau fork sesi" msgid "Continue the session the last run left" msgstr "Lanjutkan sesi yang ditinggalkan proses terakhir" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "Lanjutkan ke {0}" + msgid "Continue update" msgstr "Lanjutkan pembaruan" @@ -4153,6 +4183,9 @@ msgstr "Tidak bisa menutup sesi" msgid "Could not close the session" msgstr "Tidak dapat menutup sesi" +msgid "Could not connect {plugin}. Try again." +msgstr "Tidak dapat menghubungkan {plugin}. Coba lagi." + msgid "Could not copy the link" msgstr "Tidak bisa menyalin tautan" @@ -4678,6 +4711,9 @@ msgstr "Membuat worktree" msgid "Creating..." msgstr "Membuat..." +msgid "Credentials stay on the server" +msgstr "Kredensial tetap di server" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "Komponen lintas fitur yang berada di luar packages/ui — cek di sini sebelum membuat yang baru" @@ -5135,6 +5171,9 @@ msgstr "Masuk dev" msgid "Dev sign-in failed" msgstr "Masuk dev gagal" +msgid "Developed by {author}" +msgstr "Dikembangkan oleh {author}" + msgid "Developer" msgstr "Developer" @@ -5253,10 +5292,6 @@ msgstr "Dibuang — tidak akan ditanya lagi" msgid "Disconnect" msgstr "Putuskan" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "Putuskan {0}" - msgid "Disconnect GitHub?" msgstr "Putuskan GitHub?" @@ -7596,6 +7631,9 @@ msgstr "Wawasan dari tim Superset tentang agen coding paralel dan produktivitas msgid "Inspecting {0}" msgstr "Memeriksa {0}" +msgid "Install" +msgstr "Instal" + msgid "Install & restart" msgstr "Instal & mulai ulang" @@ -7608,12 +7646,18 @@ msgstr "Pemasangan gagal" msgid "Install GitHub App" msgstr "Pasang GitHub App" +msgid "Install plugin" +msgstr "Instal plugin" + msgid "Install Superset MCP in your client" msgstr "Pasang Superset MCP di klien Anda" msgid "Install the desktop app" msgstr "Pasang aplikasi desktop" +msgid "Install the plugin to connect" +msgstr "Instal plugin untuk menghubungkan" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "Pasang GitHub App Superset untuk menghubungkan repositori Anda." @@ -8885,6 +8929,10 @@ msgstr "Halaman lainnya" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "Lebih dari separuh perubahan yang di-merge ditulis agen tanpa suntingan manusia." +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "Lebih dari satu koneksi {connector} cocok; putuskan yang tidak Anda inginkan." + msgid "most developers" msgstr "sebagian besar developer" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile, workspace cloud, otomatisasi, dan dukungan prioritas." msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "Superset butuh Full Disk Access untuk membaca data browser lain. Berikan izinnya di System Settings, lalu periksa lagi." +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset hanya menerima izin yang diminta konektor ini. Putuskan koneksi kapan saja untuk mencabutnya." + msgid "Superset originals" msgstr "Orisinal Superset" @@ -14688,6 +14739,9 @@ msgstr "Textarea" msgid "Thanks for reaching out" msgstr "Terima kasih sudah menghubungi" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "Tautan masuk {plugin} itu telah kedaluwarsa. Coba hubungkan lagi." + msgid "That endpoint could not be resolved." msgstr "Endpoint itu tidak dapat ditemukan." @@ -15048,6 +15102,12 @@ msgstr "Berpikir..." msgid "Thirty" msgstr "Tiga puluh" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "Akun {plugin} ini sudah dihubungkan oleh {owner}. Minta dia memutuskannya terlebih dahulu." + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "Akun {plugin} ini sudah dihubungkan oleh organisasi Superset lain." + msgid "This action is irreversible." msgstr "Tindakan ini tidak bisa dibatalkan." @@ -15212,9 +15272,6 @@ msgstr "Path ini ada, tapi bukan direktori." msgid "This path is a directory" msgstr "Path ini adalah direktori" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "Plugin ini tidak memerlukan akun — Skill-nya siap dipakai." - msgid "This port is still in use" msgstr "Port ini masih dipakai" @@ -15453,6 +15510,9 @@ msgstr "Penggunaan token" msgid "Tokens" msgstr "Token" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "Token disimpan oleh Superset dan dilampirkan ke permintaan di sana. Token tidak pernah ditulis ke konfigurasi agen lokal Anda." + msgid "Tokens per session" msgstr "Token per sesi" @@ -16816,6 +16876,12 @@ msgstr "Anda tidak bisa mengubah langganan ini di aplikasi. Paket organisasi And msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "Tindakan ini tidak bisa dibatalkan. Panel terminal akan menampilkan \"Process exited\" dan bisa dijalankan ulang." +msgid "You control the access" +msgstr "Anda mengendalikan akses" + +msgid "You declined the {plugin} authorization request." +msgstr "Anda menolak permintaan otorisasi {plugin}." + msgid "You don't have access to this host" msgstr "Anda tidak punya akses ke host ini" @@ -16825,6 +16891,9 @@ msgstr "Anda tidak punya akses ke terminal ini." msgid "You don't have permission to remove this member" msgstr "Anda tidak punya izin untuk menghapus anggota ini" +msgid "You need to be part of an organization to connect accounts." +msgstr "Anda harus menjadi bagian dari organisasi untuk menghubungkan akun." + msgid "You need to be part of an organization to use integrations." msgstr "Anda harus menjadi bagian dari sebuah organisasi untuk memakai integrasi." diff --git a/packages/i18n/locales/it/messages.po b/packages/i18n/locales/it/messages.po index 6470677baec..0d1b9e046de 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · Iscritto il {joined}" msgid "{planLabel} plan" msgstr "Piano {planLabel}" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "{plugin} non ha ancora un client OAuth configurato su questo deployment." + +msgid "{plugin} is no longer installed." +msgstr "{plugin} non è più installato." + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel} reimpostato ai valori predefiniti" @@ -1477,9 +1483,6 @@ msgstr "Aggiungine uno dal menu nella barra laterale per iniziare." msgid "Add photos or files" msgstr "Aggiungi foto o file" -msgid "Add plugin" -msgstr "Aggiungi plugin" - msgid "Add project" msgstr "Aggiungi progetto" @@ -2249,9 +2252,6 @@ msgstr "Log di audit" msgid "Aug 2026" msgstr "Ago 2026" -msgid "Authenticate your {displayName} account" -msgstr "Autentica il tuo account {displayName}" - msgid "Authentication" msgstr "Autenticazione" @@ -2279,6 +2279,12 @@ msgstr "Autorizza" msgid "Authorize {displayName}" msgstr "Autorizza {displayName}" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "Autorizza Superset affinché i tuoi plugin, automazioni e trigger possano agire per tuo conto." + +msgid "Authorize Superset to act on your behalf." +msgstr "Autorizza Superset ad agire per tuo conto." + msgid "Authorizing..." msgstr "Autorizzazione..." @@ -3710,6 +3716,12 @@ msgstr "Chiedi conferma prima di uscire" msgid "Connect" msgstr "Collega" +msgid "Connect {displayName}" +msgstr "Connetti {displayName}" + +msgid "Connect {name}" +msgstr "Collega {name}" + msgid "Connect a device" msgstr "Collega un dispositivo" @@ -3782,9 +3794,8 @@ msgstr "Collega il tuo workspace Slack per gestire i task dalle conversazioni." msgid "Connected" msgstr "Collegato" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "collegato {0}" +msgid "Connected {label}" +msgstr "{label} connesso" msgid "Connected as" msgstr "Collegato come" @@ -3816,6 +3827,9 @@ msgstr "Connessione" msgid "Connecting GitHub is not configured on this server" msgstr "La connessione a GitHub non è configurata su questo server" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "Collegandolo, i tuoi agenti potranno leggere e agire in {name} per tuo conto. Controlla cosa stai concedendo prima di continuare." + msgid "Connecting to the desktop…" msgstr "Connessione al desktop…" @@ -3847,6 +3861,18 @@ msgstr "Connessione scaduta" msgid "Connections" msgstr "Connessioni" +msgid "Connector" +msgstr "Connettore" + +msgid "Connectors carry risk" +msgstr "I connettori comportano rischi" + +msgid "Connects for your whole team." +msgstr "Si connette per tutto il tuo team." + +msgid "Connects your account. Teammates connect their own." +msgstr "Connette il tuo account. I colleghi connettono il proprio." + msgid "consecutive active days" msgstr "giorni attivi consecutivi" @@ -3904,6 +3930,10 @@ msgstr "Continua o biforca la sessione" msgid "Continue the session the last run left" msgstr "Continua la sessione lasciata dall'ultima esecuzione" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "Continua su {0}" + msgid "Continue update" msgstr "Continua l'aggiornamento" @@ -4153,6 +4183,9 @@ msgstr "Impossibile chiudere la sessione" msgid "Could not close the session" msgstr "Impossibile chiudere la sessione" +msgid "Could not connect {plugin}. Try again." +msgstr "Impossibile connettere {plugin}. Riprova." + msgid "Could not copy the link" msgstr "Impossibile copiare il link" @@ -4678,6 +4711,9 @@ msgstr "Creazione del worktree" msgid "Creating..." msgstr "Creazione..." +msgid "Credentials stay on the server" +msgstr "Le credenziali restano sul server" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "Componenti trasversali che vivono fuori da packages/ui — controlla qui prima di crearne uno nuovo" @@ -5135,6 +5171,9 @@ msgstr "Accesso di sviluppo" msgid "Dev sign-in failed" msgstr "Accesso di sviluppo non riuscito" +msgid "Developed by {author}" +msgstr "Sviluppato da {author}" + msgid "Developer" msgstr "Sviluppatore" @@ -5253,10 +5292,6 @@ msgstr "Scartate — non te lo chiederò più" msgid "Disconnect" msgstr "Disconnetti" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "Disconnetti {0}" - msgid "Disconnect GitHub?" msgstr "Scollegare GitHub?" @@ -7596,6 +7631,9 @@ msgstr "Approfondimenti del team Superset su agenti di programmazione in paralle msgid "Inspecting {0}" msgstr "Ispezione di {0}" +msgid "Install" +msgstr "Installa" + msgid "Install & restart" msgstr "Installa e riavvia" @@ -7608,12 +7646,18 @@ msgstr "Installazione non riuscita" msgid "Install GitHub App" msgstr "Installa la GitHub App" +msgid "Install plugin" +msgstr "Installa plugin" + msgid "Install Superset MCP in your client" msgstr "Installa Superset MCP nel tuo client" msgid "Install the desktop app" msgstr "Installa l'app desktop" +msgid "Install the plugin to connect" +msgstr "Installa il plugin per collegarti" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "Installa la GitHub App di Superset per collegare i tuoi repository." @@ -8885,6 +8929,10 @@ msgstr "Altre pagine" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "Più della metà delle modifiche unite è scritta dagli agenti senza modifiche umane." +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "Più di una connessione {connector} corrisponde; scollega quella che non vuoi." + msgid "most developers" msgstr "la maggior parte degli sviluppatori" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile, workspace cloud, automazioni e supporto prioritario." msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "Superset ha bisogno dell'Accesso completo al disco per leggere i dati di un altro browser. Concedilo in Impostazioni di Sistema, poi controlla di nuovo." +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset riceve solo i permessi richiesti da questo connettore. Scollegalo in qualsiasi momento per revocarli." + msgid "Superset originals" msgstr "Originali Superset" @@ -14688,6 +14739,9 @@ msgstr "Textarea" msgid "Thanks for reaching out" msgstr "Grazie per averci scritto" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "Quel link di accesso a {plugin} è scaduto. Prova a connetterti di nuovo." + msgid "That endpoint could not be resolved." msgstr "Impossibile risolvere questo endpoint." @@ -15048,6 +15102,12 @@ msgstr "Sto pensando..." msgid "Thirty" msgstr "Trenta" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "Questo account {plugin} è già collegato da {owner}. Chiedi di scollegarlo prima." + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "Questo account {plugin} è già collegato da un'altra organizzazione Superset." + msgid "This action is irreversible." msgstr "L'operazione è irreversibile." @@ -15212,9 +15272,6 @@ msgstr "Questo percorso esiste, ma non è una directory." msgid "This path is a directory" msgstr "Questo percorso è una directory" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "Questo plugin non richiede un account — le sue Skill sono pronte all'uso." - msgid "This port is still in use" msgstr "Questa porta è ancora in uso" @@ -15453,6 +15510,9 @@ msgstr "Utilizzo dei token" msgid "Tokens" msgstr "Token" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "I token sono conservati da Superset e allegati alle richieste lì. Non vengono mai scritti nella configurazione locale del tuo agente." + msgid "Tokens per session" msgstr "Token per sessione" @@ -16816,6 +16876,12 @@ msgstr "Non puoi modificare questo abbonamento nell'app. Il piano della tua orga msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "Non puoi annullare questa azione. I pannelli del terminale mostreranno \"Processo terminato\" e potranno essere riavviati." +msgid "You control the access" +msgstr "Controlli tu l'accesso" + +msgid "You declined the {plugin} authorization request." +msgstr "Hai rifiutato la richiesta di autorizzazione di {plugin}." + msgid "You don't have access to this host" msgstr "Non hai accesso a questo host" @@ -16825,6 +16891,9 @@ msgstr "Non hai accesso a questo terminale." msgid "You don't have permission to remove this member" msgstr "Non hai il permesso di rimuovere questo membro" +msgid "You need to be part of an organization to connect accounts." +msgstr "Devi far parte di un'organizzazione per connettere account." + msgid "You need to be part of an organization to use integrations." msgstr "Devi far parte di un'organizzazione per usare le integrazioni." diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index 1332445180a..131f005cd5e 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · {joined}に参加" msgid "{planLabel} plan" msgstr "{planLabel}プラン" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "{plugin} の OAuth クライアントはこのデプロイにまだ設定されていません。" + +msgid "{plugin} is no longer installed." +msgstr "{plugin} はインストールされていません。" + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel}をデフォルトに戻しました" @@ -1477,9 +1483,6 @@ msgstr "サイドバーのメニューから追加して始めましょう。" msgid "Add photos or files" msgstr "写真やファイルを追加" -msgid "Add plugin" -msgstr "プラグインを追加" - msgid "Add project" msgstr "プロジェクトを追加" @@ -2249,9 +2252,6 @@ msgstr "監査ログ" msgid "Aug 2026" msgstr "2026年8月" -msgid "Authenticate your {displayName} account" -msgstr "{displayName} アカウントを認証" - msgid "Authentication" msgstr "認証" @@ -2279,6 +2279,12 @@ msgstr "許可" msgid "Authorize {displayName}" msgstr "{displayName}を許可" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "プラグイン、オートメーション、トリガーがあなたに代わって操作できるよう、Superset を承認します。" + +msgid "Authorize Superset to act on your behalf." +msgstr "Superset があなたに代わって操作することを承認します。" + msgid "Authorizing..." msgstr "許可中..." @@ -3710,6 +3716,12 @@ msgstr "終了前に確認" msgid "Connect" msgstr "接続" +msgid "Connect {displayName}" +msgstr "{displayName} を接続" + +msgid "Connect {name}" +msgstr "{name} を接続" + msgid "Connect a device" msgstr "デバイスを接続" @@ -3782,9 +3794,8 @@ msgstr "Slackのワークスペースを接続して、会話からタスクを msgid "Connected" msgstr "接続済み" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "{0} に接続" +msgid "Connected {label}" +msgstr "{label} に接続しました" msgid "Connected as" msgstr "接続アカウント" @@ -3816,6 +3827,9 @@ msgstr "接続中" msgid "Connecting GitHub is not configured on this server" msgstr "このサーバーでは GitHub の接続が設定されていません" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "接続すると、エージェントがあなたに代わって {name} で読み取りと操作を行えるようになります。続行する前に許可する内容を確認してください。" + msgid "Connecting to the desktop…" msgstr "デスクトップに接続しています…" @@ -3847,6 +3861,18 @@ msgstr "接続がタイムアウトしました" msgid "Connections" msgstr "接続" +msgid "Connector" +msgstr "コネクタ" + +msgid "Connectors carry risk" +msgstr "コネクタにはリスクがあります" + +msgid "Connects for your whole team." +msgstr "チーム全体で共有されます。" + +msgid "Connects your account. Teammates connect their own." +msgstr "あなたのアカウントを接続します。チームメイトは各自で接続します。" + msgid "consecutive active days" msgstr "連続アクティブ日数" @@ -3904,6 +3930,10 @@ msgstr "セッションを続行またはフォーク" msgid "Continue the session the last run left" msgstr "前回の実行が残したセッションを継続" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "{0} に進む" + msgid "Continue update" msgstr "更新を続行" @@ -4153,6 +4183,9 @@ msgstr "セッションを終了できませんでした" msgid "Could not close the session" msgstr "セッションを閉じられませんでした" +msgid "Could not connect {plugin}. Try again." +msgstr "{plugin} に接続できませんでした。もう一度お試しください。" + msgid "Could not copy the link" msgstr "リンクをコピーできませんでした" @@ -4678,6 +4711,9 @@ msgstr "ワークツリーを作成中" msgid "Creating..." msgstr "作成中..." +msgid "Credentials stay on the server" +msgstr "認証情報はサーバー上に保持されます" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "packages/uiの外にある機能横断のコンポーネント — 新しく作る前にここを確認してください" @@ -5135,6 +5171,9 @@ msgstr "開発用サインイン" msgid "Dev sign-in failed" msgstr "開発用サインインに失敗しました" +msgid "Developed by {author}" +msgstr "開発元: {author}" + msgid "Developer" msgstr "開発者" @@ -5253,10 +5292,6 @@ msgstr "破棄しました — 今後は確認しません" msgid "Disconnect" msgstr "切断" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "{0} を切断" - msgid "Disconnect GitHub?" msgstr "GitHubの接続を解除しますか?" @@ -7596,6 +7631,9 @@ msgstr "並列コーディングエージェントと開発者の生産性につ msgid "Inspecting {0}" msgstr "{0}を検査中" +msgid "Install" +msgstr "インストール" + msgid "Install & restart" msgstr "インストールして再起動" @@ -7608,12 +7646,18 @@ msgstr "インストールに失敗しました" msgid "Install GitHub App" msgstr "GitHub Appをインストール" +msgid "Install plugin" +msgstr "プラグインをインストール" + msgid "Install Superset MCP in your client" msgstr "クライアントにSuperset MCPをインストール" msgid "Install the desktop app" msgstr "デスクトップアプリをインストール" +msgid "Install the plugin to connect" +msgstr "接続するにはプラグインをインストールしてください" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "Superset GitHub Appをインストールしてリポジトリを接続します。" @@ -8885,6 +8929,10 @@ msgstr "その他のページ" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "マージされた変更の半数超が、人間の編集なしにエージェントによって書かれる。" +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "{connector} の接続が複数一致します。不要なほうの接続を解除してください。" + msgid "most developers" msgstr "大半の開発者" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile、クラウドワークスペース、オートメーシ msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "他のブラウザのデータを読み取るには、Supersetにフルディスクアクセスが必要です。システム設定で許可してから再確認してください。" +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset はこのコネクタが要求する権限のみを受け取ります。接続を解除すればいつでも取り消せます。" + msgid "Superset originals" msgstr "Superset独自コンポーネント" @@ -14688,6 +14739,9 @@ msgstr "Textarea" msgid "Thanks for reaching out" msgstr "お問い合わせありがとうございます" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "{plugin} のサインインリンクの有効期限が切れました。もう一度接続してください。" + msgid "That endpoint could not be resolved." msgstr "そのエンドポイントを解決できませんでした。" @@ -15048,6 +15102,12 @@ msgstr "思考中..." msgid "Thirty" msgstr "30日" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "この {plugin} アカウントはすでに {owner} が接続しています。先に接続を解除してもらってください。" + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "この {plugin} アカウントはすでに別の Superset 組織が接続しています。" + msgid "This action is irreversible." msgstr "この操作は元に戻せません。" @@ -15212,9 +15272,6 @@ msgstr "このパスは存在しますが、ディレクトリではありませ msgid "This path is a directory" msgstr "このパスはディレクトリです" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "このプラグインにアカウントは不要です — スキルはすぐに使えます。" - msgid "This port is still in use" msgstr "このポートはまだ使用中です" @@ -15453,6 +15510,9 @@ msgstr "トークン使用量" msgid "Tokens" msgstr "トークン" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "トークンは Superset が保持し、サーバー側でリクエストに付与されます。ローカルのエージェント設定には書き込まれません。" + msgid "Tokens per session" msgstr "セッションあたりのトークン" @@ -16816,6 +16876,12 @@ msgstr "このサブスクリプションはアプリでは変更できません msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "この操作は元に戻せません。ターミナルペインに「Process exited」と表示され、再起動できます。" +msgid "You control the access" +msgstr "アクセス権はあなたが管理します" + +msgid "You declined the {plugin} authorization request." +msgstr "{plugin} の認可リクエストを拒否しました。" + msgid "You don't have access to this host" msgstr "このホストへのアクセス権がありません" @@ -16825,6 +16891,9 @@ msgstr "このターミナルへのアクセス権がありません。" msgid "You don't have permission to remove this member" msgstr "このメンバーを削除する権限がありません" +msgid "You need to be part of an organization to connect accounts." +msgstr "アカウントを接続するには、組織に所属している必要があります。" + msgid "You need to be part of an organization to use integrations." msgstr "連携を使うには組織に所属している必要があります。" diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index 43285c6056a..c6c7f361505 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · {joined} 가입" msgid "{planLabel} plan" msgstr "{planLabel} 플랜" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "이 배포에는 {plugin}의 OAuth 클라이언트가 아직 구성되지 않았습니다." + +msgid "{plugin} is no longer installed." +msgstr "{plugin}이(가) 더 이상 설치되어 있지 않습니다." + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel}이(가) 기본값으로 초기화되었습니다" @@ -1477,9 +1483,6 @@ msgstr "사이드바 메뉴에서 하나 추가해 시작하세요." msgid "Add photos or files" msgstr "사진 또는 파일 추가" -msgid "Add plugin" -msgstr "플러그인 추가" - msgid "Add project" msgstr "프로젝트 추가" @@ -2249,9 +2252,6 @@ msgstr "감사 로그" msgid "Aug 2026" msgstr "2026년 8월" -msgid "Authenticate your {displayName} account" -msgstr "{displayName} 계정 인증" - msgid "Authentication" msgstr "인증" @@ -2279,6 +2279,12 @@ msgstr "승인" msgid "Authorize {displayName}" msgstr "{displayName} 승인" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "플러그인, 자동화, 트리거가 사용자를 대신해 작동할 수 있도록 Superset을 승인합니다." + +msgid "Authorize Superset to act on your behalf." +msgstr "Superset가 사용자를 대신해 작업하도록 승인하세요." + msgid "Authorizing..." msgstr "승인하는 중..." @@ -3710,6 +3716,12 @@ msgstr "종료 전 확인" msgid "Connect" msgstr "연결" +msgid "Connect {displayName}" +msgstr "{displayName} 연결" + +msgid "Connect {name}" +msgstr "{name} 연결" + msgid "Connect a device" msgstr "기기 연결" @@ -3782,9 +3794,8 @@ msgstr "Slack 워크스페이스를 연결해 대화에서 바로 작업을 관 msgid "Connected" msgstr "연결됨" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "{0}에 연결됨" +msgid "Connected {label}" +msgstr "{label} 연결됨" msgid "Connected as" msgstr "연결 계정" @@ -3816,6 +3827,9 @@ msgstr "연결 중" msgid "Connecting GitHub is not configured on this server" msgstr "이 서버에는 GitHub 연결이 구성되어 있지 않습니다" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "연결하면 에이전트가 사용자를 대신해 {name}에서 읽고 작업할 수 있습니다. 계속하기 전에 부여하는 권한을 확인하세요." + msgid "Connecting to the desktop…" msgstr "데스크톱에 연결하는 중…" @@ -3847,6 +3861,18 @@ msgstr "연결 시간 초과" msgid "Connections" msgstr "연결" +msgid "Connector" +msgstr "커넥터" + +msgid "Connectors carry risk" +msgstr "커넥터에는 위험이 따릅니다" + +msgid "Connects for your whole team." +msgstr "팀 전체에 연결됩니다." + +msgid "Connects your account. Teammates connect their own." +msgstr "내 계정을 연결합니다. 팀원은 각자 연결합니다." + msgid "consecutive active days" msgstr "연속 활동 일수" @@ -3904,6 +3930,10 @@ msgstr "세션 계속하기 또는 포크" msgid "Continue the session the last run left" msgstr "마지막 실행이 남긴 세션을 계속" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "{0}(으)로 계속" + msgid "Continue update" msgstr "업데이트 계속" @@ -4153,6 +4183,9 @@ msgstr "세션을 닫을 수 없습니다" msgid "Could not close the session" msgstr "세션을 닫지 못했습니다" +msgid "Could not connect {plugin}. Try again." +msgstr "{plugin}을(를) 연결하지 못했습니다. 다시 시도하세요." + msgid "Could not copy the link" msgstr "링크를 복사할 수 없습니다" @@ -4678,6 +4711,9 @@ msgstr "worktree 만드는 중" msgid "Creating..." msgstr "만드는 중..." +msgid "Credentials stay on the server" +msgstr "자격 증명은 서버에 보관됩니다" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "packages/ui 밖에 있는 기능 공통 컴포넌트 — 새로 만들기 전에 여기를 확인하세요" @@ -5135,6 +5171,9 @@ msgstr "개발용 로그인" msgid "Dev sign-in failed" msgstr "dev 로그인 실패" +msgid "Developed by {author}" +msgstr "개발: {author}" + msgid "Developer" msgstr "개발자" @@ -5253,10 +5292,6 @@ msgstr "버렸습니다 — 다시 묻지 않습니다" msgid "Disconnect" msgstr "연결 해제" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "{0} 연결 해제" - msgid "Disconnect GitHub?" msgstr "GitHub 연결을 해제할까요?" @@ -7596,6 +7631,9 @@ msgstr "병렬 코딩 에이전트와 개발자 생산성에 대한 Superset 팀 msgid "Inspecting {0}" msgstr "{0} 검사 중" +msgid "Install" +msgstr "설치" + msgid "Install & restart" msgstr "설치 후 재시작" @@ -7608,12 +7646,18 @@ msgstr "설치 실패" msgid "Install GitHub App" msgstr "GitHub App 설치" +msgid "Install plugin" +msgstr "플러그인 설치" + msgid "Install Superset MCP in your client" msgstr "클라이언트에 Superset MCP 설치하기" msgid "Install the desktop app" msgstr "데스크톱 앱 설치" +msgid "Install the plugin to connect" +msgstr "연결하려면 플러그인을 설치하세요" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "리포지토리를 연결하려면 Superset GitHub App을 설치하세요." @@ -8885,6 +8929,10 @@ msgstr "더 많은 페이지" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "머지된 변경의 절반 이상을 사람의 수정 없이 에이전트가 작성합니다." +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "{connector} 연결이 두 개 이상 일치합니다. 원하지 않는 연결을 해제하세요." + msgid "most developers" msgstr "대부분의 개발자" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile, 클라우드 워크스페이스, 자동화, 우선 지 msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "다른 브라우저의 데이터를 읽으려면 Superset에 전체 디스크 접근 권한이 필요합니다. 시스템 설정에서 권한을 허용한 다음 다시 확인하세요." +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset는 이 커넥터가 요청한 권한만 받습니다. 언제든지 연결을 해제해 취소할 수 있습니다." + msgid "Superset originals" msgstr "Superset 오리지널" @@ -14688,6 +14739,9 @@ msgstr "텍스트 영역" msgid "Thanks for reaching out" msgstr "연락해 주셔서 감사합니다" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "해당 {plugin} 로그인 링크가 만료되었습니다. 다시 연결해 보세요." + msgid "That endpoint could not be resolved." msgstr "해당 엔드포인트를 확인할 수 없습니다." @@ -15048,6 +15102,12 @@ msgstr "생각하는 중..." msgid "Thirty" msgstr "서른 날" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "이 {plugin} 계정은 이미 {owner}님이 연결했습니다. 먼저 연결을 해제해 달라고 요청하세요." + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "이 {plugin} 계정은 이미 다른 Superset 조직에 연결되어 있습니다." + msgid "This action is irreversible." msgstr "이 동작은 되돌릴 수 없습니다." @@ -15212,9 +15272,6 @@ msgstr "이 경로는 있지만 디렉터리가 아닙니다." msgid "This path is a directory" msgstr "이 경로는 디렉터리입니다" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "이 플러그인은 계정이 필요 없습니다 — 스킬을 바로 사용할 수 있습니다." - msgid "This port is still in use" msgstr "이 포트는 아직 사용 중입니다" @@ -15453,6 +15510,9 @@ msgstr "토큰 사용량" msgid "Tokens" msgstr "토큰" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "토큰은 Superset가 보관하며 서버에서 요청에 첨부됩니다. 로컬 에이전트 설정에는 기록되지 않습니다." + msgid "Tokens per session" msgstr "세션당 토큰" @@ -16816,6 +16876,12 @@ msgstr "이 구독은 앱에서 변경할 수 없습니다. 조직의 플랜은 msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "이 작업은 되돌릴 수 없습니다. 터미널 패널에 \"Process exited\"가 표시되며 다시 시작할 수 있습니다." +msgid "You control the access" +msgstr "접근 권한은 사용자가 관리합니다" + +msgid "You declined the {plugin} authorization request." +msgstr "{plugin} 인증 요청을 거부했습니다." + msgid "You don't have access to this host" msgstr "이 호스트에 접근할 권한이 없습니다" @@ -16825,6 +16891,9 @@ msgstr "이 터미널에 접근할 권한이 없습니다." msgid "You don't have permission to remove this member" msgstr "이 멤버를 제거할 권한이 없습니다" +msgid "You need to be part of an organization to connect accounts." +msgstr "계정을 연결하려면 조직에 속해 있어야 합니다." + msgid "You need to be part of an organization to use integrations." msgstr "연동을 사용하려면 조직에 속해 있어야 합니다." diff --git a/packages/i18n/locales/nl/messages.po b/packages/i18n/locales/nl/messages.po index b8af305081a..b21a12afb8d 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · Lid sinds {joined}" msgid "{planLabel} plan" msgstr "{planLabel}-plan" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "Voor {plugin} is op deze deployment nog geen OAuth-client geconfigureerd." + +msgid "{plugin} is no longer installed." +msgstr "{plugin} is niet meer geïnstalleerd." + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel} teruggezet naar standaardwaarden" @@ -1477,9 +1483,6 @@ msgstr "Voeg er een toe via het menu in de zijbalk om te beginnen." msgid "Add photos or files" msgstr "Foto's of bestanden toevoegen" -msgid "Add plugin" -msgstr "Plugin toevoegen" - msgid "Add project" msgstr "Project toevoegen" @@ -2249,9 +2252,6 @@ msgstr "Auditlogs" msgid "Aug 2026" msgstr "Aug 2026" -msgid "Authenticate your {displayName} account" -msgstr "Verifieer je {displayName}-account" - msgid "Authentication" msgstr "Authenticatie" @@ -2279,6 +2279,12 @@ msgstr "Autoriseren" msgid "Authorize {displayName}" msgstr "{displayName} autoriseren" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "Autoriseer Superset zodat je plug-ins, automatiseringen en triggers namens jou kunnen handelen." + +msgid "Authorize Superset to act on your behalf." +msgstr "Geef Superset toestemming om namens jou te handelen." + msgid "Authorizing..." msgstr "Autoriseren..." @@ -3710,6 +3716,12 @@ msgstr "Bevestigen voor afsluiten" msgid "Connect" msgstr "Verbinden" +msgid "Connect {displayName}" +msgstr "{displayName} verbinden" + +msgid "Connect {name}" +msgstr "{name} verbinden" + msgid "Connect a device" msgstr "Apparaat koppelen" @@ -3782,9 +3794,8 @@ msgstr "Verbind je Slack-werkruimte om taken vanuit gesprekken te beheren." msgid "Connected" msgstr "Verbonden" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "verbonden {0}" +msgid "Connected {label}" +msgstr "{label} verbonden" msgid "Connected as" msgstr "Verbonden als" @@ -3816,6 +3827,9 @@ msgstr "Verbinden" msgid "Connecting GitHub is not configured on this server" msgstr "GitHub koppelen is niet ingesteld op deze server" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "Verbinden laat je agents in {name} lezen en handelen namens jou. Controleer wat je verleent voordat je doorgaat." + msgid "Connecting to the desktop…" msgstr "Verbinden met het bureaublad…" @@ -3847,6 +3861,18 @@ msgstr "Verbinding verlopen" msgid "Connections" msgstr "Verbindingen" +msgid "Connector" +msgstr "Connector" + +msgid "Connectors carry risk" +msgstr "Connectors brengen risico met zich mee" + +msgid "Connects for your whole team." +msgstr "Wordt voor je hele team verbonden." + +msgid "Connects your account. Teammates connect their own." +msgstr "Verbindt je eigen account. Teamgenoten verbinden dat van henzelf." + msgid "consecutive active days" msgstr "opeenvolgende actieve dagen" @@ -3904,6 +3930,10 @@ msgstr "Sessie voortzetten of forken" msgid "Continue the session the last run left" msgstr "Ga verder met de sessie van de vorige run" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "Doorgaan naar {0}" + msgid "Continue update" msgstr "Doorgaan met update" @@ -4153,6 +4183,9 @@ msgstr "Kan sessie niet sluiten" msgid "Could not close the session" msgstr "Kan de sessie niet sluiten" +msgid "Could not connect {plugin}. Try again." +msgstr "Kan {plugin} niet verbinden. Probeer het opnieuw." + msgid "Could not copy the link" msgstr "Kan de link niet kopiëren" @@ -4678,6 +4711,9 @@ msgstr "Worktree aanmaken" msgid "Creating..." msgstr "Aanmaken..." +msgid "Credentials stay on the server" +msgstr "Inloggegevens blijven op de server" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "Componenten die over features heen worden gebruikt en buiten packages/ui staan — kijk hier voordat je een nieuwe bouwt" @@ -5135,6 +5171,9 @@ msgstr "Dev-login" msgid "Dev sign-in failed" msgstr "Dev-login mislukt" +msgid "Developed by {author}" +msgstr "Ontwikkeld door {author}" + msgid "Developer" msgstr "Ontwikkelaar" @@ -5253,10 +5292,6 @@ msgstr "Verworpen — we vragen het niet meer" msgid "Disconnect" msgstr "Verbinding verbreken" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "{0} loskoppelen" - msgid "Disconnect GitHub?" msgstr "GitHub loskoppelen?" @@ -7596,6 +7631,9 @@ msgstr "Inzichten van het Superset-team over parallelle coding agents en product msgid "Inspecting {0}" msgstr "{0} inspecteren" +msgid "Install" +msgstr "Installeren" + msgid "Install & restart" msgstr "Installeren en herstarten" @@ -7608,12 +7646,18 @@ msgstr "Installeren mislukt" msgid "Install GitHub App" msgstr "GitHub App installeren" +msgid "Install plugin" +msgstr "Plugin installeren" + msgid "Install Superset MCP in your client" msgstr "Superset MCP installeren in je client" msgid "Install the desktop app" msgstr "Installeer de desktop-app" +msgid "Install the plugin to connect" +msgstr "Installeer de plugin om te verbinden" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "Installeer de Superset GitHub App om je repository's te koppelen." @@ -8885,6 +8929,10 @@ msgstr "Meer pagina's" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "Meer dan de helft van de gemergede wijzigingen is door agents geschreven zonder menselijke bewerkingen." +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "Meerdere {connector}-verbindingen komen overeen; koppel de verbinding los die je niet wilt." + msgid "most developers" msgstr "de meeste ontwikkelaars" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile, cloud-workspaces, automatiseringen en prioritaire suppo msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "Superset heeft Volledige Schijftoegang nodig om de gegevens van een andere browser te lezen. Geef die in Systeeminstellingen en controleer opnieuw." +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset ontvangt alleen de rechten die deze connector vraagt. Verbreek de verbinding wanneer je wilt om ze in te trekken." + msgid "Superset originals" msgstr "Superset-originelen" @@ -14688,6 +14739,9 @@ msgstr "Textarea" msgid "Thanks for reaching out" msgstr "Bedankt voor je bericht" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "Die aanmeldlink voor {plugin} is verlopen. Probeer opnieuw verbinding te maken." + msgid "That endpoint could not be resolved." msgstr "Dat endpoint kon niet worden omgezet." @@ -15048,6 +15102,12 @@ msgstr "Denken..." msgid "Thirty" msgstr "Dertig" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "Dit {plugin}-account is al verbonden door {owner}. Vraag om het eerst los te koppelen." + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "Dit {plugin}-account is al verbonden door een andere Superset-organisatie." + msgid "This action is irreversible." msgstr "Deze actie kan niet worden teruggedraaid." @@ -15212,9 +15272,6 @@ msgstr "Dit pad bestaat, maar is geen map." msgid "This path is a directory" msgstr "Dit pad is een map" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "Deze plugin heeft geen account nodig — de Skills zijn meteen klaar voor gebruik." - msgid "This port is still in use" msgstr "Deze poort is nog in gebruik" @@ -15453,6 +15510,9 @@ msgstr "Tokengebruik" msgid "Tokens" msgstr "Tokens" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "Tokens worden door Superset bewaard en daar aan verzoeken toegevoegd. Ze worden nooit naar je lokale agentconfiguratie geschreven." + msgid "Tokens per session" msgstr "Tokens per sessie" @@ -16816,6 +16876,12 @@ msgstr "Je kunt dit abonnement niet in de app wijzigen. Het plan van je organisa msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "Dit kun je niet ongedaan maken. Terminalpanelen tonen \"Process exited\" en zijn opnieuw te starten." +msgid "You control the access" +msgstr "Jij bepaalt de toegang" + +msgid "You declined the {plugin} authorization request." +msgstr "Je hebt het autorisatieverzoek van {plugin} geweigerd." + msgid "You don't have access to this host" msgstr "Je hebt geen toegang tot deze host" @@ -16825,6 +16891,9 @@ msgstr "Je hebt geen toegang tot deze terminal." msgid "You don't have permission to remove this member" msgstr "Je hebt geen rechten om dit lid te verwijderen" +msgid "You need to be part of an organization to connect accounts." +msgstr "Je moet deel uitmaken van een organisatie om accounts te verbinden." + msgid "You need to be part of an organization to use integrations." msgstr "Je moet deel uitmaken van een organisatie om integraties te gebruiken." diff --git a/packages/i18n/locales/pl/messages.po b/packages/i18n/locales/pl/messages.po index 052655197e7..a1538e15ee3 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · dołączono {joined}" msgid "{planLabel} plan" msgstr "Plan {planLabel}" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "{plugin} nie ma jeszcze skonfigurowanego klienta OAuth w tym wdrożeniu." + +msgid "{plugin} is no longer installed." +msgstr "{plugin} nie jest już zainstalowany." + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel} przywrócono do ustawień domyślnych" @@ -1477,9 +1483,6 @@ msgstr "Dodaj go z menu na pasku bocznym, aby zacząć." msgid "Add photos or files" msgstr "Dodaj zdjęcia lub pliki" -msgid "Add plugin" -msgstr "Dodaj wtyczkę" - msgid "Add project" msgstr "Dodaj projekt" @@ -2249,9 +2252,6 @@ msgstr "Dzienniki audytu" msgid "Aug 2026" msgstr "Sie 2026" -msgid "Authenticate your {displayName} account" -msgstr "Uwierzytelnij swoje konto {displayName}" - msgid "Authentication" msgstr "Uwierzytelnianie" @@ -2279,6 +2279,12 @@ msgstr "Autoryzuj" msgid "Authorize {displayName}" msgstr "Autoryzuj {displayName}" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "Autoryzuj Superset, aby Twoje wtyczki, automatyzacje i wyzwalacze mogły działać w Twoim imieniu." + +msgid "Authorize Superset to act on your behalf." +msgstr "Autoryzuj Superset do działania w Twoim imieniu." + msgid "Authorizing..." msgstr "Autoryzowanie..." @@ -3710,6 +3716,12 @@ msgstr "Potwierdzaj przed zamknięciem" msgid "Connect" msgstr "Połącz" +msgid "Connect {displayName}" +msgstr "Połącz {displayName}" + +msgid "Connect {name}" +msgstr "Połącz {name}" + msgid "Connect a device" msgstr "Podłącz urządzenie" @@ -3782,9 +3794,8 @@ msgstr "Podłącz swój obszar roboczy Slacka, aby zarządzać zadaniami z rozm msgid "Connected" msgstr "Połączono" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "połączono {0}" +msgid "Connected {label}" +msgstr "Połączono {label}" msgid "Connected as" msgstr "Połączono jako" @@ -3816,6 +3827,9 @@ msgstr "Łączenie" msgid "Connecting GitHub is not configured on this server" msgstr "Połączenie z GitHubem nie jest skonfigurowane na tym serwerze" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "Połączenie pozwoli Twoim agentom czytać i działać w {name} w Twoim imieniu. Sprawdź, co przyznajesz, zanim przejdziesz dalej." + msgid "Connecting to the desktop…" msgstr "Łączenie z pulpitem…" @@ -3847,6 +3861,18 @@ msgstr "Przekroczono czas połączenia" msgid "Connections" msgstr "Połączenia" +msgid "Connector" +msgstr "Konektor" + +msgid "Connectors carry risk" +msgstr "Konektory wiążą się z ryzykiem" + +msgid "Connects for your whole team." +msgstr "Łączy dla całego zespołu." + +msgid "Connects your account. Teammates connect their own." +msgstr "Łączy Twoje konto. Członkowie zespołu łączą własne." + msgid "consecutive active days" msgstr "kolejnych aktywnych dni" @@ -3904,6 +3930,10 @@ msgstr "Kontynuuj lub forkuj sesję" msgid "Continue the session the last run left" msgstr "Kontynuuj sesję pozostawioną przez ostatnie uruchomienie" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "Przejdź do {0}" + msgid "Continue update" msgstr "Kontynuuj aktualizację" @@ -4153,6 +4183,9 @@ msgstr "Nie udało się zamknąć sesji" msgid "Could not close the session" msgstr "Nie udało się zamknąć sesji" +msgid "Could not connect {plugin}. Try again." +msgstr "Nie udało się połączyć {plugin}. Spróbuj ponownie." + msgid "Could not copy the link" msgstr "Nie udało się skopiować linku" @@ -4678,6 +4711,9 @@ msgstr "Tworzenie worktree" msgid "Creating..." msgstr "Tworzenie..." +msgid "Credentials stay on the server" +msgstr "Poświadczenia pozostają na serwerze" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "Komponenty międzyfunkcyjne spoza packages/ui — sprawdź tutaj, zanim zbudujesz nowy" @@ -5135,6 +5171,9 @@ msgstr "Logowanie deweloperskie" msgid "Dev sign-in failed" msgstr "Logowanie deweloperskie nie powiodło się" +msgid "Developed by {author}" +msgstr "Autor: {author}" + msgid "Developer" msgstr "Programista" @@ -5253,10 +5292,6 @@ msgstr "Odrzucono — nie zapytamy ponownie" msgid "Disconnect" msgstr "Rozłącz" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "Rozłącz {0}" - msgid "Disconnect GitHub?" msgstr "Rozłączyć GitHub?" @@ -7596,6 +7631,9 @@ msgstr "Spostrzeżenia zespołu Superset o równoległych agentach kodujących i msgid "Inspecting {0}" msgstr "Inspekcja {0}" +msgid "Install" +msgstr "Zainstaluj" + msgid "Install & restart" msgstr "Zainstaluj i uruchom ponownie" @@ -7608,12 +7646,18 @@ msgstr "Instalacja nie powiodła się" msgid "Install GitHub App" msgstr "Zainstaluj aplikację GitHub" +msgid "Install plugin" +msgstr "Zainstaluj wtyczkę" + msgid "Install Superset MCP in your client" msgstr "Zainstaluj Superset MCP w swoim kliencie" msgid "Install the desktop app" msgstr "Zainstaluj aplikację desktopową" +msgid "Install the plugin to connect" +msgstr "Zainstaluj wtyczkę, aby połączyć" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "Zainstaluj aplikację GitHub Superset, aby podłączyć swoje repozytoria." @@ -8885,6 +8929,10 @@ msgstr "Więcej stron" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "Ponad połowa scalonych zmian jest napisana przez agentów bez żadnych poprawek człowieka." +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "Pasuje więcej niż jedno połączenie {connector}; rozłącz to, którego nie chcesz." + msgid "most developers" msgstr "większość programistów" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile, workspace'y w chmurze, automatyzacje i wsparcie prioryt msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "Superset potrzebuje pełnego dostępu do dysku, aby odczytać dane innej przeglądarki. Przyznaj go w Ustawieniach systemowych, a potem sprawdź ponownie." +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset otrzymuje tylko uprawnienia, o które prosi ten konektor. Odłącz go w dowolnej chwili, aby je cofnąć." + msgid "Superset originals" msgstr "Oryginały Superset" @@ -14688,6 +14739,9 @@ msgstr "Textarea" msgid "Thanks for reaching out" msgstr "Dziękujemy za kontakt" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "Ten link logowania do {plugin} wygasł. Spróbuj połączyć się ponownie." + msgid "That endpoint could not be resolved." msgstr "Nie udało się rozpoznać tego punktu końcowego." @@ -15048,6 +15102,12 @@ msgstr "Myślenie..." msgid "Thirty" msgstr "Trzydzieści" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "To konto {plugin} zostało już połączone przez {owner}. Poproś o wcześniejsze rozłączenie." + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "To konto {plugin} jest już połączone z inną organizacją Superset." + msgid "This action is irreversible." msgstr "Tej operacji nie można cofnąć." @@ -15212,9 +15272,6 @@ msgstr "Ta ścieżka istnieje, ale nie jest katalogiem." msgid "This path is a directory" msgstr "Ta ścieżka jest katalogiem" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "Ta wtyczka nie wymaga konta — jej Umiejętności są gotowe do użycia." - msgid "This port is still in use" msgstr "Ten port jest nadal używany" @@ -15453,6 +15510,9 @@ msgstr "Zużycie tokenów" msgid "Tokens" msgstr "Tokeny" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "Tokeny przechowuje Superset i dołącza je do żądań po swojej stronie. Nigdy nie trafiają do lokalnej konfiguracji agenta." + msgid "Tokens per session" msgstr "Tokenów na sesję" @@ -16816,6 +16876,12 @@ msgstr "Tej subskrypcji nie zmienisz w aplikacji. Planem Twojej organizacji zarz msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "Tej operacji nie można cofnąć. Panele terminala pokażą „Proces zakończony” i będzie można je uruchomić ponownie." +msgid "You control the access" +msgstr "Ty kontrolujesz dostęp" + +msgid "You declined the {plugin} authorization request." +msgstr "Odrzucono żądanie autoryzacji {plugin}." + msgid "You don't have access to this host" msgstr "Nie masz dostępu do tego hosta" @@ -16825,6 +16891,9 @@ msgstr "Nie masz dostępu do tego terminala." msgid "You don't have permission to remove this member" msgstr "Nie masz uprawnień, aby usunąć tego członka" +msgid "You need to be part of an organization to connect accounts." +msgstr "Aby łączyć konta, musisz należeć do organizacji." + msgid "You need to be part of an organization to use integrations." msgstr "Aby korzystać z integracji, trzeba należeć do organizacji." diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index d7145766a67..5f949bc5790 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · Entrou em {joined}" msgid "{planLabel} plan" msgstr "Plano {planLabel}" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "O {plugin} ainda não tem um cliente OAuth configurado nesta implantação." + +msgid "{plugin} is no longer installed." +msgstr "{plugin} não está mais instalado." + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel} redefinido para os padrões" @@ -1477,9 +1483,6 @@ msgstr "Adicione um pelo menu da barra lateral para começar." msgid "Add photos or files" msgstr "Adicionar fotos ou arquivos" -msgid "Add plugin" -msgstr "Adicionar plugin" - msgid "Add project" msgstr "Adicionar projeto" @@ -2249,9 +2252,6 @@ msgstr "Logs de auditoria" msgid "Aug 2026" msgstr "Ago 2026" -msgid "Authenticate your {displayName} account" -msgstr "Autentique sua conta {displayName}" - msgid "Authentication" msgstr "Autenticação" @@ -2279,6 +2279,12 @@ msgstr "Autorizar" msgid "Authorize {displayName}" msgstr "Autorizar {displayName}" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "Autorize o Superset para que seus plugins, automações e gatilhos possam agir em seu nome." + +msgid "Authorize Superset to act on your behalf." +msgstr "Autorize o Superset a agir em seu nome." + msgid "Authorizing..." msgstr "Autorizando..." @@ -3710,6 +3716,12 @@ msgstr "Confirmar antes de encerrar" msgid "Connect" msgstr "Conectar" +msgid "Connect {displayName}" +msgstr "Conectar {displayName}" + +msgid "Connect {name}" +msgstr "Conectar {name}" + msgid "Connect a device" msgstr "Conectar um dispositivo" @@ -3782,9 +3794,8 @@ msgstr "Conecte seu workspace do Slack para gerenciar tarefas a partir das conve msgid "Connected" msgstr "Conectado" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "conectado {0}" +msgid "Connected {label}" +msgstr "{label} conectado" msgid "Connected as" msgstr "Conectado como" @@ -3816,6 +3827,9 @@ msgstr "Conectando" msgid "Connecting GitHub is not configured on this server" msgstr "A conexão com o GitHub não está configurada neste servidor" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "Conectar permite que seus agentes leiam e ajam no {name} em seu nome. Revise o que você está concedendo antes de continuar." + msgid "Connecting to the desktop…" msgstr "Conectando à área de trabalho…" @@ -3847,6 +3861,18 @@ msgstr "Tempo de conexão esgotado" msgid "Connections" msgstr "Conexões" +msgid "Connector" +msgstr "Conector" + +msgid "Connectors carry risk" +msgstr "Conectores envolvem riscos" + +msgid "Connects for your whole team." +msgstr "Conecta para toda a sua equipe." + +msgid "Connects your account. Teammates connect their own." +msgstr "Conecta sua conta. Seus colegas conectam a deles." + msgid "consecutive active days" msgstr "dias ativos consecutivos" @@ -3904,6 +3930,10 @@ msgstr "Continuar ou bifurcar a sessão" msgid "Continue the session the last run left" msgstr "Retomar a sessão da última execução" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "Continuar para {0}" + msgid "Continue update" msgstr "Continuar a atualização" @@ -4153,6 +4183,9 @@ msgstr "Não foi possível fechar a sessão" msgid "Could not close the session" msgstr "Não foi possível fechar a sessão" +msgid "Could not connect {plugin}. Try again." +msgstr "Não foi possível conectar o {plugin}. Tente novamente." + msgid "Could not copy the link" msgstr "Não foi possível copiar o link" @@ -4678,6 +4711,9 @@ msgstr "Criando a worktree" msgid "Creating..." msgstr "Criando..." +msgid "Credentials stay on the server" +msgstr "As credenciais ficam no servidor" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "Componentes usados por várias funcionalidades que vivem fora de packages/ui — confira aqui antes de criar um novo" @@ -5135,6 +5171,9 @@ msgstr "Login de dev" msgid "Dev sign-in failed" msgstr "Falha no login de dev" +msgid "Developed by {author}" +msgstr "Desenvolvido por {author}" + msgid "Developer" msgstr "Pessoa desenvolvedora" @@ -5253,10 +5292,6 @@ msgstr "Descartado — não vamos perguntar de novo" msgid "Disconnect" msgstr "Desconectar" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "Desconectar {0}" - msgid "Disconnect GitHub?" msgstr "Desconectar o GitHub?" @@ -7596,6 +7631,9 @@ msgstr "O que o time do Superset tem a dizer sobre agentes de código em paralel msgid "Inspecting {0}" msgstr "Inspecionando {0}" +msgid "Install" +msgstr "Instalar" + msgid "Install & restart" msgstr "Instalar e reiniciar" @@ -7608,12 +7646,18 @@ msgstr "Falha na instalação" msgid "Install GitHub App" msgstr "Instalar o GitHub App" +msgid "Install plugin" +msgstr "Instalar plugin" + msgid "Install Superset MCP in your client" msgstr "Instale o MCP do Superset no seu cliente" msgid "Install the desktop app" msgstr "Instale o app de desktop" +msgid "Install the plugin to connect" +msgstr "Instale o plugin para conectar" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "Instale o GitHub App do Superset para conectar seus repositórios." @@ -8885,6 +8929,10 @@ msgstr "Mais páginas" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "Mais da metade das mudanças mescladas é escrita por agentes, sem nenhuma edição humana." +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "Mais de uma conexão do {connector} corresponde; desconecte a que você não quer." + msgid "most developers" msgstr "a maioria das pessoas desenvolvedoras" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile, workspaces na nuvem, automações e suporte prioritári msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "O Superset precisa de Acesso Total ao Disco para ler os dados de outro navegador. Conceda em Ajustes do Sistema e verifique novamente." +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "O Superset recebe apenas as permissões que este conector solicita. Desconecte a qualquer momento para revogá-las." + msgid "Superset originals" msgstr "Originais do Superset" @@ -14688,6 +14739,9 @@ msgstr "Textarea" msgid "Thanks for reaching out" msgstr "Obrigado pelo contato" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "Esse link de login do {plugin} expirou. Tente conectar novamente." + msgid "That endpoint could not be resolved." msgstr "Não foi possível resolver esse endpoint." @@ -15048,6 +15102,12 @@ msgstr "Pensando..." msgid "Thirty" msgstr "Trinta" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "Esta conta do {plugin} já foi conectada por {owner}. Peça para desconectar primeiro." + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "Esta conta do {plugin} já está conectada por outra organização do Superset." + msgid "This action is irreversible." msgstr "Não dá para desfazer." @@ -15212,9 +15272,6 @@ msgstr "Este caminho existe, mas não é um diretório." msgid "This path is a directory" msgstr "Este caminho é um diretório" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "Este plugin não precisa de conta — suas Skills já estão prontas para usar." - msgid "This port is still in use" msgstr "Esta porta ainda está em uso" @@ -15453,6 +15510,9 @@ msgstr "Uso de tokens" msgid "Tokens" msgstr "Tokens" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "Os tokens ficam com o Superset e são anexados às requisições lá. Eles nunca são gravados na configuração local do seu agente." + msgid "Tokens per session" msgstr "Tokens por sessão" @@ -16816,6 +16876,12 @@ msgstr "Você não pode alterar esta assinatura no app. O plano da sua organiza msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "Não dá para desfazer. Os painéis de terminal vão mostrar \"Processo encerrado\" e podem ser reiniciados." +msgid "You control the access" +msgstr "Você controla o acesso" + +msgid "You declined the {plugin} authorization request." +msgstr "Você recusou a solicitação de autorização do {plugin}." + msgid "You don't have access to this host" msgstr "Você não tem acesso a este host" @@ -16825,6 +16891,9 @@ msgstr "Você não tem acesso a este terminal." msgid "You don't have permission to remove this member" msgstr "Você não tem permissão para remover este membro" +msgid "You need to be part of an organization to connect accounts." +msgstr "Você precisa fazer parte de uma organização para conectar contas." + msgid "You need to be part of an organization to use integrations." msgstr "Você precisa fazer parte de uma organização para usar integrações." diff --git a/packages/i18n/locales/ru/messages.po b/packages/i18n/locales/ru/messages.po index 46e5677adc6..7bd22b8742d 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · присоединился {joined}" msgid "{planLabel} plan" msgstr "Тариф {planLabel}" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "Для {plugin} в этом развёртывании ещё не настроен клиент OAuth." + +msgid "{plugin} is no longer installed." +msgstr "{plugin} больше не установлен." + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel} сброшен к значениям по умолчанию" @@ -1477,9 +1483,6 @@ msgstr "Добавьте агента из меню в боковой панел msgid "Add photos or files" msgstr "Добавить фото или файлы" -msgid "Add plugin" -msgstr "Добавить плагин" - msgid "Add project" msgstr "Добавить проект" @@ -2249,9 +2252,6 @@ msgstr "Журналы аудита" msgid "Aug 2026" msgstr "Авг 2026" -msgid "Authenticate your {displayName} account" -msgstr "Авторизуйте свой аккаунт {displayName}" - msgid "Authentication" msgstr "Аутентификация" @@ -2279,6 +2279,12 @@ msgstr "Авторизовать" msgid "Authorize {displayName}" msgstr "Авторизовать {displayName}" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "Разрешите Superset действовать от вашего имени через плагины, автоматизации и триггеры." + +msgid "Authorize Superset to act on your behalf." +msgstr "Разрешите Superset действовать от вашего имени." + msgid "Authorizing..." msgstr "Авторизация..." @@ -3710,6 +3716,12 @@ msgstr "Подтверждать выход" msgid "Connect" msgstr "Подключить" +msgid "Connect {displayName}" +msgstr "Подключить {displayName}" + +msgid "Connect {name}" +msgstr "Подключить {name}" + msgid "Connect a device" msgstr "Подключите устройство" @@ -3782,9 +3794,8 @@ msgstr "Подключите рабочее пространство Slack, чт msgid "Connected" msgstr "Подключено" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "подключено {0}" +msgid "Connected {label}" +msgstr "{label} подключено" msgid "Connected as" msgstr "Подключено как" @@ -3816,6 +3827,9 @@ msgstr "Подключение" msgid "Connecting GitHub is not configured on this server" msgstr "Подключение GitHub не настроено на этом сервере" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "Подключение позволит вашим агентам читать и действовать в {name} от вашего имени. Проверьте, что вы предоставляете, прежде чем продолжить." + msgid "Connecting to the desktop…" msgstr "Подключение к рабочему столу…" @@ -3847,6 +3861,18 @@ msgstr "Истекло время ожидания соединения" msgid "Connections" msgstr "Подключения" +msgid "Connector" +msgstr "Коннектор" + +msgid "Connectors carry risk" +msgstr "Коннекторы несут риск" + +msgid "Connects for your whole team." +msgstr "Подключается для всей команды." + +msgid "Connects your account. Teammates connect their own." +msgstr "Подключает вашу учётную запись. Коллеги подключают свои." + msgid "consecutive active days" msgstr "активных дней подряд" @@ -3904,6 +3930,10 @@ msgstr "Продолжить или форкнуть сессию" msgid "Continue the session the last run left" msgstr "Продолжить сессию, оставшуюся от прошлого запуска" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "Перейти к {0}" + msgid "Continue update" msgstr "Продолжить обновление" @@ -4153,6 +4183,9 @@ msgstr "Не удалось закрыть сессию" msgid "Could not close the session" msgstr "Не удалось закрыть сессию" +msgid "Could not connect {plugin}. Try again." +msgstr "Не удалось подключить {plugin}. Попробуйте ещё раз." + msgid "Could not copy the link" msgstr "Не удалось скопировать ссылку" @@ -4678,6 +4711,9 @@ msgstr "Создание worktree" msgid "Creating..." msgstr "Создание..." +msgid "Credentials stay on the server" +msgstr "Учётные данные остаются на сервере" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "Кросс-функциональные компоненты вне packages/ui — загляните сюда, прежде чем делать новый" @@ -5135,6 +5171,9 @@ msgstr "Dev-вход" msgid "Dev sign-in failed" msgstr "Не удалось выполнить dev-вход" +msgid "Developed by {author}" +msgstr "Разработчик: {author}" + msgid "Developer" msgstr "Разработчик" @@ -5253,10 +5292,6 @@ msgstr "Изменения отменены — больше не спросим msgid "Disconnect" msgstr "Отключить" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "Отключить {0}" - msgid "Disconnect GitHub?" msgstr "Отключить GitHub?" @@ -7596,6 +7631,9 @@ msgstr "Мысли команды Superset о параллельных кодо msgid "Inspecting {0}" msgstr "Инспектируем {0}" +msgid "Install" +msgstr "Установить" + msgid "Install & restart" msgstr "Установить и перезапустить" @@ -7608,12 +7646,18 @@ msgstr "Не удалось установить" msgid "Install GitHub App" msgstr "Установить GitHub App" +msgid "Install plugin" +msgstr "Установить плагин" + msgid "Install Superset MCP in your client" msgstr "Установите Superset MCP в своём клиенте" msgid "Install the desktop app" msgstr "Установите десктопное приложение" +msgid "Install the plugin to connect" +msgstr "Установите плагин, чтобы подключиться" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "Установите GitHub App Superset, чтобы подключить свои репозитории." @@ -8885,6 +8929,10 @@ msgstr "Ещё страницы" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "Больше половины объединённых изменений написаны агентами без правок человека." +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "Подходит несколько подключений {connector}; отключите ненужное." + msgid "most developers" msgstr "большинство разработчиков" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile, облачные рабочие пространств msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "Superset нужен полный доступ к диску, чтобы читать данные другого браузера. Выдайте его в Системных настройках и проверьте снова." +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset получает только те разрешения, которые запрашивает этот коннектор. Отключите его в любой момент, чтобы отозвать их." + msgid "Superset originals" msgstr "Оригиналы Superset" @@ -14688,6 +14739,9 @@ msgstr "Текстовое поле" msgid "Thanks for reaching out" msgstr "Спасибо, что написали" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "Срок действия ссылки для входа в {plugin} истёк. Попробуйте подключиться снова." + msgid "That endpoint could not be resolved." msgstr "Не удалось разрешить эту конечную точку." @@ -15048,6 +15102,12 @@ msgstr "Думает..." msgid "Thirty" msgstr "Тридцать" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "Аккаунт {plugin} уже подключил(а) {owner}. Попросите сначала отключить его." + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "Этот аккаунт {plugin} уже подключён другой организацией Superset." + msgid "This action is irreversible." msgstr "Это действие необратимо." @@ -15212,9 +15272,6 @@ msgstr "Этот путь существует, но это не папка." msgid "This path is a directory" msgstr "Этот путь — папка" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "Этому плагину не нужен аккаунт — его Навыки готовы к использованию." - msgid "This port is still in use" msgstr "Этот порт ещё используется" @@ -15453,6 +15510,9 @@ msgstr "Использование токенов" msgid "Tokens" msgstr "Токены" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "Токены хранятся в Superset и добавляются к запросам на его стороне. Они никогда не записываются в локальную конфигурацию агента." + msgid "Tokens per session" msgstr "Токенов за сессию" @@ -16816,6 +16876,12 @@ msgstr "Изменить эту подписку в приложении нел msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "Это действие нельзя отменить. В панелях терминала появится «Process exited», их можно перезапустить." +msgid "You control the access" +msgstr "Вы контролируете доступ" + +msgid "You declined the {plugin} authorization request." +msgstr "Вы отклонили запрос на авторизацию {plugin}." + msgid "You don't have access to this host" msgstr "У вас нет доступа к этому хосту" @@ -16825,6 +16891,9 @@ msgstr "У вас нет доступа к этому терминалу." msgid "You don't have permission to remove this member" msgstr "У вас нет прав удалить этого участника" +msgid "You need to be part of an organization to connect accounts." +msgstr "Чтобы подключать учётные записи, нужно состоять в организации." + msgid "You need to be part of an organization to use integrations." msgstr "Чтобы пользоваться интеграциями, нужно состоять в организации." diff --git a/packages/i18n/locales/tr/messages.po b/packages/i18n/locales/tr/messages.po index 62062443f27..0e4b2d34d1e 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · Katılım {joined}" msgid "{planLabel} plan" msgstr "{planLabel} planı" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "{plugin} için bu dağıtımda henüz bir OAuth istemcisi yapılandırılmadı." + +msgid "{plugin} is no longer installed." +msgstr "{plugin} artık yüklü değil." + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel} varsayılanlara sıfırlandı" @@ -1477,9 +1483,6 @@ msgstr "Başlamak için kenar çubuğundaki menüden bir tane ekleyin." msgid "Add photos or files" msgstr "Fotoğraf veya dosya ekle" -msgid "Add plugin" -msgstr "Eklenti ekle" - msgid "Add project" msgstr "Proje ekle" @@ -2249,9 +2252,6 @@ msgstr "Denetim günlükleri" msgid "Aug 2026" msgstr "Ağu 2026" -msgid "Authenticate your {displayName} account" -msgstr "{displayName} hesabınızın kimliğini doğrulayın" - msgid "Authentication" msgstr "Kimlik doğrulama" @@ -2279,6 +2279,12 @@ msgstr "Yetkilendir" msgid "Authorize {displayName}" msgstr "{displayName} uygulamasını yetkilendir" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "Eklentilerinizin, otomasyonlarınızın ve tetikleyicilerinizin sizin adınıza işlem yapabilmesi için Superset'e yetki verin." + +msgid "Authorize Superset to act on your behalf." +msgstr "Superset'in sizin adınıza işlem yapmasına izin verin." + msgid "Authorizing..." msgstr "Yetkilendiriliyor..." @@ -3710,6 +3716,12 @@ msgstr "Çıkmadan önce onay iste" msgid "Connect" msgstr "Bağla" +msgid "Connect {displayName}" +msgstr "{displayName} bağla" + +msgid "Connect {name}" +msgstr "{name} bağla" + msgid "Connect a device" msgstr "Cihaz bağla" @@ -3782,9 +3794,8 @@ msgstr "Görevleri konuşmalardan yönetmek için Slack çalışma alanınızı msgid "Connected" msgstr "Bağlı" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "bağlandı {0}" +msgid "Connected {label}" +msgstr "{label} bağlandı" msgid "Connected as" msgstr "Şu hesapla bağlı" @@ -3816,6 +3827,9 @@ msgstr "Bağlanılıyor" msgid "Connecting GitHub is not configured on this server" msgstr "Bu sunucuda GitHub bağlantısı yapılandırılmamış" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "Bağlanmak, aracılarınızın {name} içinde sizin adınıza okuma ve işlem yapmasına izin verir. Devam etmeden önce neyi verdiğinizi gözden geçirin." + msgid "Connecting to the desktop…" msgstr "Masaüstüne bağlanılıyor…" @@ -3847,6 +3861,18 @@ msgstr "Bağlantı zaman aşımına uğradı" msgid "Connections" msgstr "Bağlantılar" +msgid "Connector" +msgstr "Bağlayıcı" + +msgid "Connectors carry risk" +msgstr "Bağlayıcılar risk taşır" + +msgid "Connects for your whole team." +msgstr "Tüm ekibiniz için bağlanır." + +msgid "Connects your account. Teammates connect their own." +msgstr "Hesabınızı bağlar. Takım arkadaşlarınız kendi hesaplarını bağlar." + msgid "consecutive active days" msgstr "art arda aktif gün" @@ -3904,6 +3930,10 @@ msgstr "Oturumu sürdür veya forkla" msgid "Continue the session the last run left" msgstr "Son çalıştırmanın bıraktığı oturumu sürdür" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "{0} adresine devam et" + msgid "Continue update" msgstr "Güncellemeye devam et" @@ -4153,6 +4183,9 @@ msgstr "Oturum kapatılamadı" msgid "Could not close the session" msgstr "Oturum kapatılamadı" +msgid "Could not connect {plugin}. Try again." +msgstr "{plugin} bağlanamadı. Yeniden deneyin." + msgid "Could not copy the link" msgstr "Bağlantı kopyalanamadı" @@ -4678,6 +4711,9 @@ msgstr "Worktree oluşturuluyor" msgid "Creating..." msgstr "Oluşturuluyor..." +msgid "Credentials stay on the server" +msgstr "Kimlik bilgileri sunucuda kalır" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "packages/ui dışında yaşayan, özellikler arası bileşenler — yenisini geliştirmeden önce buraya bakın" @@ -5135,6 +5171,9 @@ msgstr "Dev oturum açma" msgid "Dev sign-in failed" msgstr "Dev oturum açma başarısız" +msgid "Developed by {author}" +msgstr "Geliştiren: {author}" + msgid "Developer" msgstr "Geliştirici" @@ -5253,10 +5292,6 @@ msgstr "Atıldı — bir daha sorulmayacak" msgid "Disconnect" msgstr "Bağlantıyı kes" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "{0} bağlantısını kes" - msgid "Disconnect GitHub?" msgstr "GitHub bağlantısı kesilsin mi?" @@ -7596,6 +7631,9 @@ msgstr "Superset ekibinden paralel kodlama ajanları ve geliştirici verimliliğ msgid "Inspecting {0}" msgstr "{0} inceleniyor" +msgid "Install" +msgstr "Kur" + msgid "Install & restart" msgstr "Kur ve yeniden başlat" @@ -7608,12 +7646,18 @@ msgstr "Kurulum başarısız" msgid "Install GitHub App" msgstr "GitHub App'i kur" +msgid "Install plugin" +msgstr "Eklenti kur" + msgid "Install Superset MCP in your client" msgstr "Superset MCP'yi istemcinize kurun" msgid "Install the desktop app" msgstr "Masaüstü uygulamasını kurun" +msgid "Install the plugin to connect" +msgstr "Bağlanmak için eklentiyi kurun" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "Depolarınızı bağlamak için Superset GitHub App'i kurun." @@ -8885,6 +8929,10 @@ msgstr "Daha fazla sayfa" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "Birleştirilen değişikliklerin yarısından fazlası, hiç insan düzenlemesi olmadan ajanlar tarafından yazılır." +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "Birden fazla {connector} bağlantısı eşleşiyor; istemediğinizi kaldırın." + msgid "most developers" msgstr "çoğu geliştirici" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile, bulut çalışma alanları, otomasyonlar ve öncelikli msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "Superset'in başka bir tarayıcının verilerini okuyabilmesi için Tam Disk Erişimi gerekir. Sistem Ayarları'ndan verin, sonra yeniden denetleyin." +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset yalnızca bu bağlayıcının istediği izinleri alır. İzinleri geri almak için istediğiniz zaman bağlantıyı kesin." + msgid "Superset originals" msgstr "Superset özgünleri" @@ -14688,6 +14739,9 @@ msgstr "Metin alanı" msgid "Thanks for reaching out" msgstr "Bize ulaştığınız için teşekkürler" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "Bu {plugin} oturum açma bağlantısının süresi doldu. Yeniden bağlanmayı deneyin." + msgid "That endpoint could not be resolved." msgstr "Bu uç nokta çözümlenemedi." @@ -15048,6 +15102,12 @@ msgstr "Düşünülüyor..." msgid "Thirty" msgstr "Otuz" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "Bu {plugin} hesabını zaten {owner} bağlamış. Önce bağlantıyı kesmesini isteyin." + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "Bu {plugin} hesabı zaten başka bir Superset kuruluşuna bağlı." + msgid "This action is irreversible." msgstr "Bu işlem geri alınamaz." @@ -15212,9 +15272,6 @@ msgstr "Bu yol mevcut, ancak bir dizin değil." msgid "This path is a directory" msgstr "Bu yol bir dizin" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "Bu eklenti hesap gerektirmez — Becerileri kullanıma hazır." - msgid "This port is still in use" msgstr "Bu port hâlâ kullanımda" @@ -15453,6 +15510,9 @@ msgstr "Token kullanımı" msgid "Tokens" msgstr "Token" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "Anahtarlar Superset tarafından tutulur ve isteklere orada eklenir. Yerel aracı yapılandırmanıza asla yazılmaz." + msgid "Tokens per session" msgstr "Oturum başına token" @@ -16816,6 +16876,12 @@ msgstr "Bu aboneliği uygulamada değiştiremezsiniz. Kuruluşunuzun planı, {0} msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "Bu işlemi geri alamazsınız. Terminal bölmelerinde \"Process exited\" görünür ve yeniden başlatılabilirler." +msgid "You control the access" +msgstr "Erişimi siz kontrol edersiniz" + +msgid "You declined the {plugin} authorization request." +msgstr "{plugin} yetkilendirme isteğini reddettiniz." + msgid "You don't have access to this host" msgstr "Bu host'a erişiminiz yok" @@ -16825,6 +16891,9 @@ msgstr "Bu terminale erişiminiz yok." msgid "You don't have permission to remove this member" msgstr "Bu üyeyi kaldırma izniniz yok" +msgid "You need to be part of an organization to connect accounts." +msgstr "Hesap bağlamak için bir organizasyona üye olmanız gerekir." + msgid "You need to be part of an organization to use integrations." msgstr "Entegrasyonları kullanmak için bir kuruluşun parçası olmanız gerekir." diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index 7d68b4acdf2..b04a4ed4462 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · Tham gia {joined}" msgid "{planLabel} plan" msgstr "Gói {planLabel}" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "{plugin} chưa được cấu hình ứng dụng khách OAuth trên bản triển khai này." + +msgid "{plugin} is no longer installed." +msgstr "{plugin} không còn được cài đặt." + msgid "{presetLabel} reset to defaults" msgstr "Đã đặt lại {presetLabel} về mặc định" @@ -1477,9 +1483,6 @@ msgstr "Thêm một agent từ menu ở thanh bên để bắt đầu." msgid "Add photos or files" msgstr "Thêm ảnh hoặc tệp" -msgid "Add plugin" -msgstr "Thêm plugin" - msgid "Add project" msgstr "Thêm dự án" @@ -2249,9 +2252,6 @@ msgstr "Nhật ký kiểm toán" msgid "Aug 2026" msgstr "Th8 2026" -msgid "Authenticate your {displayName} account" -msgstr "Xác thực tài khoản {displayName} của bạn" - msgid "Authentication" msgstr "Xác thực" @@ -2279,6 +2279,12 @@ msgstr "Cấp quyền" msgid "Authorize {displayName}" msgstr "Cấp quyền cho {displayName}" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "Ủy quyền cho Superset để các plugin, tự động hóa và trình kích hoạt có thể hành động thay bạn." + +msgid "Authorize Superset to act on your behalf." +msgstr "Cho phép Superset thay mặt bạn thực hiện thao tác." + msgid "Authorizing..." msgstr "Đang cấp quyền..." @@ -3710,6 +3716,12 @@ msgstr "Xác nhận trước khi thoát" msgid "Connect" msgstr "Kết nối" +msgid "Connect {displayName}" +msgstr "Kết nối {displayName}" + +msgid "Connect {name}" +msgstr "Kết nối {name}" + msgid "Connect a device" msgstr "Kết nối một thiết bị" @@ -3782,9 +3794,8 @@ msgstr "Kết nối workspace Slack của bạn để quản lý tác vụ ngay msgid "Connected" msgstr "Đã kết nối" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "đã kết nối {0}" +msgid "Connected {label}" +msgstr "Đã kết nối {label}" msgid "Connected as" msgstr "Đã kết nối với tư cách" @@ -3816,6 +3827,9 @@ msgstr "Đang kết nối" msgid "Connecting GitHub is not configured on this server" msgstr "Máy chủ này chưa cấu hình kết nối GitHub" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "Kết nối cho phép các tác nhân của bạn đọc và thao tác trong {name} thay mặt bạn. Hãy xem lại những gì bạn đang cấp trước khi tiếp tục." + msgid "Connecting to the desktop…" msgstr "Đang kết nối tới màn hình nền…" @@ -3847,6 +3861,18 @@ msgstr "Kết nối quá thời gian" msgid "Connections" msgstr "Kết nối" +msgid "Connector" +msgstr "Trình kết nối" + +msgid "Connectors carry risk" +msgstr "Trình kết nối có rủi ro" + +msgid "Connects for your whole team." +msgstr "Kết nối cho toàn bộ nhóm của bạn." + +msgid "Connects your account. Teammates connect their own." +msgstr "Kết nối tài khoản của bạn. Đồng nghiệp kết nối tài khoản riêng của họ." + msgid "consecutive active days" msgstr "ngày hoạt động liên tiếp" @@ -3904,6 +3930,10 @@ msgstr "Tiếp tục hoặc fork phiên" msgid "Continue the session the last run left" msgstr "Tiếp tục phiên mà lần chạy trước để lại" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "Tiếp tục đến {0}" + msgid "Continue update" msgstr "Tiếp tục cập nhật" @@ -4153,6 +4183,9 @@ msgstr "Không thể đóng phiên" msgid "Could not close the session" msgstr "Không thể đóng phiên" +msgid "Could not connect {plugin}. Try again." +msgstr "Không thể kết nối {plugin}. Vui lòng thử lại." + msgid "Could not copy the link" msgstr "Không thể sao chép liên kết" @@ -4678,6 +4711,9 @@ msgstr "Đang tạo worktree" msgid "Creating..." msgstr "Đang tạo..." +msgid "Credentials stay on the server" +msgstr "Thông tin xác thực được giữ trên máy chủ" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "Các component dùng chung nhiều tính năng, nằm ngoài packages/ui — hãy xem ở đây trước khi dựng cái mới" @@ -5135,6 +5171,9 @@ msgstr "Đăng nhập dev" msgid "Dev sign-in failed" msgstr "Đăng nhập dev thất bại" +msgid "Developed by {author}" +msgstr "Phát triển bởi {author}" + msgid "Developer" msgstr "Lập trình viên" @@ -5253,10 +5292,6 @@ msgstr "Đã bỏ — sẽ không hỏi lại" msgid "Disconnect" msgstr "Ngắt kết nối" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "Ngắt kết nối {0}" - msgid "Disconnect GitHub?" msgstr "Ngắt kết nối GitHub?" @@ -7596,6 +7631,9 @@ msgstr "Góc nhìn từ đội ngũ Superset về coding agent chạy song song msgid "Inspecting {0}" msgstr "Đang kiểm tra {0}" +msgid "Install" +msgstr "Cài đặt" + msgid "Install & restart" msgstr "Cài đặt & khởi động lại" @@ -7608,12 +7646,18 @@ msgstr "Cài đặt thất bại" msgid "Install GitHub App" msgstr "Cài GitHub App" +msgid "Install plugin" +msgstr "Cài đặt plugin" + msgid "Install Superset MCP in your client" msgstr "Cài Superset MCP vào client của bạn" msgid "Install the desktop app" msgstr "Cài ứng dụng máy tính" +msgid "Install the plugin to connect" +msgstr "Cài đặt plugin để kết nối" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "Hãy cài Superset GitHub App để kết nối các repository của bạn." @@ -8885,6 +8929,10 @@ msgstr "Thêm trang" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "Hơn một nửa thay đổi được merge do agent viết mà không có chỉnh sửa của con người." +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "Có nhiều hơn một kết nối {connector} khớp; hãy ngắt kết nối cái bạn không muốn." + msgid "most developers" msgstr "phần lớn lập trình viên" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile, workspace đám mây, tự động hóa và hỗ trợ msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "Superset cần quyền Full Disk Access để đọc dữ liệu của trình duyệt khác. Cấp quyền trong System Settings rồi kiểm tra lại." +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset chỉ nhận các quyền mà trình kết nối này yêu cầu. Ngắt kết nối bất cứ lúc nào để thu hồi." + msgid "Superset originals" msgstr "Thành phần riêng của Superset" @@ -14688,6 +14739,9 @@ msgstr "Textarea" msgid "Thanks for reaching out" msgstr "Cảm ơn bạn đã liên hệ" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "Liên kết đăng nhập {plugin} đó đã hết hạn. Hãy thử kết nối lại." + msgid "That endpoint could not be resolved." msgstr "Không thể phân giải endpoint đó." @@ -15048,6 +15102,12 @@ msgstr "Đang suy nghĩ..." msgid "Thirty" msgstr "Ba mươi" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "Tài khoản {plugin} này đã được {owner} kết nối. Hãy đề nghị họ ngắt kết nối trước." + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "Tài khoản {plugin} này đã được một tổ chức Superset khác kết nối." + msgid "This action is irreversible." msgstr "Không thể hoàn tác thao tác này." @@ -15212,9 +15272,6 @@ msgstr "Đường dẫn này tồn tại nhưng không phải thư mục." msgid "This path is a directory" msgstr "Đường dẫn này là một thư mục" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "Plugin này không cần tài khoản — các Skill của nó đã sẵn sàng dùng." - msgid "This port is still in use" msgstr "Cổng này vẫn đang được dùng" @@ -15453,6 +15510,9 @@ msgstr "Mức dùng token" msgid "Tokens" msgstr "Token" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "Superset giữ các token và đính kèm chúng vào yêu cầu tại máy chủ. Chúng không bao giờ được ghi vào cấu hình tác nhân cục bộ của bạn." + msgid "Tokens per session" msgstr "Token mỗi phiên" @@ -16816,6 +16876,12 @@ msgstr "Bạn không thể thay đổi gói đăng ký này trong ứng dụng. msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "Không thể hoàn tác thao tác này. Các khung terminal sẽ hiện \"Process exited\" và có thể khởi động lại." +msgid "You control the access" +msgstr "Bạn kiểm soát quyền truy cập" + +msgid "You declined the {plugin} authorization request." +msgstr "Bạn đã từ chối yêu cầu ủy quyền của {plugin}." + msgid "You don't have access to this host" msgstr "Bạn không có quyền truy cập host này" @@ -16825,6 +16891,9 @@ msgstr "Bạn không có quyền truy cập terminal này." msgid "You don't have permission to remove this member" msgstr "Bạn không có quyền gỡ thành viên này" +msgid "You need to be part of an organization to connect accounts." +msgstr "Bạn cần thuộc về một tổ chức để kết nối tài khoản." + msgid "You need to be part of an organization to use integrations." msgstr "Bạn cần thuộc một tổ chức để dùng tích hợp." diff --git a/packages/i18n/locales/zh-CN/messages.po b/packages/i18n/locales/zh-CN/messages.po index ba0a8366e42..65f62881b40 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · 加入于{joined}" msgid "{planLabel} plan" msgstr "{planLabel}方案" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "此部署尚未为 {plugin} 配置 OAuth 客户端。" + +msgid "{plugin} is no longer installed." +msgstr "{plugin} 已不再安装。" + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel}已重置为默认值" @@ -1477,9 +1483,6 @@ msgstr "从侧边栏菜单中添加一个即可开始。" msgid "Add photos or files" msgstr "添加照片或文件" -msgid "Add plugin" -msgstr "添加插件" - msgid "Add project" msgstr "添加项目" @@ -2249,9 +2252,6 @@ msgstr "审计日志" msgid "Aug 2026" msgstr "2026年8月" -msgid "Authenticate your {displayName} account" -msgstr "验证你的 {displayName} 账户" - msgid "Authentication" msgstr "认证" @@ -2279,6 +2279,12 @@ msgstr "授权" msgid "Authorize {displayName}" msgstr "授权{displayName}" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "授权 Superset,使您的插件、自动化和触发器可以代表您执行操作。" + +msgid "Authorize Superset to act on your behalf." +msgstr "授权 Superset 代表你执行操作。" + msgid "Authorizing..." msgstr "正在授权..." @@ -3710,6 +3716,12 @@ msgstr "退出前确认" msgid "Connect" msgstr "连接" +msgid "Connect {displayName}" +msgstr "连接 {displayName}" + +msgid "Connect {name}" +msgstr "连接 {name}" + msgid "Connect a device" msgstr "连接设备" @@ -3782,9 +3794,8 @@ msgstr "连接你的Slack工作区,直接在对话中管理任务。" msgid "Connected" msgstr "已连接" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "连接于 {0}" +msgid "Connected {label}" +msgstr "已连接 {label}" msgid "Connected as" msgstr "已连接为" @@ -3816,6 +3827,9 @@ msgstr "连接中" msgid "Connecting GitHub is not configured on this server" msgstr "此服务器未配置 GitHub 连接" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "连接后,你的智能体可代表你在 {name} 中读取和操作。继续前请检查你授予的权限。" + msgid "Connecting to the desktop…" msgstr "正在连接桌面…" @@ -3847,6 +3861,18 @@ msgstr "连接超时" msgid "Connections" msgstr "连接" +msgid "Connector" +msgstr "连接器" + +msgid "Connectors carry risk" +msgstr "连接器存在风险" + +msgid "Connects for your whole team." +msgstr "为整个团队连接。" + +msgid "Connects your account. Teammates connect their own." +msgstr "连接您的账户。团队成员各自连接。" + msgid "consecutive active days" msgstr "连续活跃天数" @@ -3904,6 +3930,10 @@ msgstr "继续或分叉会话" msgid "Continue the session the last run left" msgstr "继续上次运行留下的会话" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "继续前往 {0}" + msgid "Continue update" msgstr "继续更新" @@ -4153,6 +4183,9 @@ msgstr "无法关闭会话" msgid "Could not close the session" msgstr "无法关闭会话" +msgid "Could not connect {plugin}. Try again." +msgstr "无法连接 {plugin}。请重试。" + msgid "Could not copy the link" msgstr "无法复制链接" @@ -4678,6 +4711,9 @@ msgstr "正在创建worktree" msgid "Creating..." msgstr "正在创建..." +msgid "Credentials stay on the server" +msgstr "凭据保留在服务器上" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "位于packages/ui之外的跨功能组件——新建组件之前先来这里看看" @@ -5135,6 +5171,9 @@ msgstr "开发登录" msgid "Dev sign-in failed" msgstr "开发登录失败" +msgid "Developed by {author}" +msgstr "开发者:{author}" + msgid "Developer" msgstr "开发者" @@ -5253,10 +5292,6 @@ msgstr "已放弃——不再询问" msgid "Disconnect" msgstr "断开连接" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "断开 {0}" - msgid "Disconnect GitHub?" msgstr "断开GitHub连接?" @@ -7596,6 +7631,9 @@ msgstr "来自Superset团队的洞见,关于并行编码智能体与开发者 msgid "Inspecting {0}" msgstr "正在检查{0}" +msgid "Install" +msgstr "安装" + msgid "Install & restart" msgstr "安装并重启" @@ -7608,12 +7646,18 @@ msgstr "安装失败" msgid "Install GitHub App" msgstr "安装GitHub App" +msgid "Install plugin" +msgstr "安装插件" + msgid "Install Superset MCP in your client" msgstr "在你的客户端里安装Superset MCP" msgid "Install the desktop app" msgstr "安装桌面应用" +msgid "Install the plugin to connect" +msgstr "安装插件后即可连接" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "安装Superset GitHub App以连接你的仓库。" @@ -8885,6 +8929,10 @@ msgstr "更多页面" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "超过一半的合并变更由智能体撰写,且无任何人工修改。" +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "匹配到多个 {connector} 连接;请断开你不需要的那个。" + msgid "most developers" msgstr "多数开发者" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile、云工作区、自动化和优先支持。" msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "Superset需要“完全磁盘访问权限”才能读取其他浏览器的数据。请在系统设置中授予权限,然后重新检查。" +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset 只获得此连接器请求的权限。你可以随时断开连接以撤销。" + msgid "Superset originals" msgstr "Superset原创组件" @@ -14688,6 +14739,9 @@ msgstr "多行文本框" msgid "Thanks for reaching out" msgstr "感谢联系" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "该 {plugin} 登录链接已过期。请重新连接。" + msgid "That endpoint could not be resolved." msgstr "无法解析该端点。" @@ -15048,6 +15102,12 @@ msgstr "正在思考..." msgid "Thirty" msgstr "三十天" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "该 {plugin} 账户已由 {owner} 连接。请先请其断开连接。" + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "该 {plugin} 账户已被另一个 Superset 组织连接。" + msgid "This action is irreversible." msgstr "此操作不可逆。" @@ -15212,9 +15272,6 @@ msgstr "此路径存在,但不是目录。" msgid "This path is a directory" msgstr "该路径是一个目录" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "此插件无需账户 — 它的技能可直接使用。" - msgid "This port is still in use" msgstr "此端口仍在使用中" @@ -15453,6 +15510,9 @@ msgstr "Token用量" msgid "Tokens" msgstr "Token" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "令牌由 Superset 保管,并在服务端附加到请求中,绝不会写入你本地的智能体配置。" + msgid "Tokens per session" msgstr "每次会话的 Token" @@ -16816,6 +16876,12 @@ msgstr "无法在应用内更改此订阅。你所在组织的方案由所有者 msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "此操作无法撤销。终端窗格会显示“进程已退出”,之后可以重新启动。" +msgid "You control the access" +msgstr "访问权限由你掌控" + +msgid "You declined the {plugin} authorization request." +msgstr "你拒绝了 {plugin} 的授权请求。" + msgid "You don't have access to this host" msgstr "你没有此主机的访问权限" @@ -16825,6 +16891,9 @@ msgstr "你没有此终端的访问权限。" msgid "You don't have permission to remove this member" msgstr "你没有移除该成员的权限" +msgid "You need to be part of an organization to connect accounts." +msgstr "您需要加入一个组织才能连接账户。" + msgid "You need to be part of an organization to use integrations." msgstr "你需要加入一个组织才能使用集成。" diff --git a/packages/i18n/locales/zh-TW/messages.po b/packages/i18n/locales/zh-TW/messages.po index 09daf32d1b3..4155da3a8b2 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -830,6 +830,12 @@ msgstr "{planLabel} · 加入於{joined}" msgid "{planLabel} plan" msgstr "{planLabel}方案" +msgid "{plugin} has no OAuth client configured on this deployment yet." +msgstr "此部署尚未為 {plugin} 設定 OAuth 用戶端。" + +msgid "{plugin} is no longer installed." +msgstr "{plugin} 已不再安裝。" + msgid "{presetLabel} reset to defaults" msgstr "{presetLabel}已重置為預設值" @@ -1477,9 +1483,6 @@ msgstr "從側邊欄選單中新增一個即可開始。" msgid "Add photos or files" msgstr "新增照片或檔案" -msgid "Add plugin" -msgstr "新增外掛" - msgid "Add project" msgstr "新增專案" @@ -2249,9 +2252,6 @@ msgstr "審計日誌" msgid "Aug 2026" msgstr "2026年8月" -msgid "Authenticate your {displayName} account" -msgstr "驗證你的 {displayName} 帳戶" - msgid "Authentication" msgstr "認證" @@ -2279,6 +2279,12 @@ msgstr "授權" msgid "Authorize {displayName}" msgstr "授權{displayName}" +msgid "Authorize Superset so your plugins, automations and triggers can act on your behalf." +msgstr "授權 Superset,讓您的外掛、自動化與觸發器能代表您執行操作。" + +msgid "Authorize Superset to act on your behalf." +msgstr "授權 Superset 代表你執行操作。" + msgid "Authorizing..." msgstr "正在授權..." @@ -3710,6 +3716,12 @@ msgstr "退出前確認" msgid "Connect" msgstr "連線" +msgid "Connect {displayName}" +msgstr "連接 {displayName}" + +msgid "Connect {name}" +msgstr "連線 {name}" + msgid "Connect a device" msgstr "連線裝置" @@ -3782,9 +3794,8 @@ msgstr "連線你的Slack工作區,直接在對話中管理任務。" msgid "Connected" msgstr "已連線" -#. placeholder {0}: formatDate(new Date(connection.createdAt)) -msgid "connected {0}" -msgstr "連線於 {0}" +msgid "Connected {label}" +msgstr "已連線 {label}" msgid "Connected as" msgstr "已連線為" @@ -3816,6 +3827,9 @@ msgstr "連線中" msgid "Connecting GitHub is not configured on this server" msgstr "此伺服器未設定 GitHub 連結" +msgid "Connecting lets your agents read and act in {name} on your behalf. Review what you are granting before you continue." +msgstr "連線後,你的代理可代表你在 {name} 中讀取和操作。繼續前請檢查你授予的權限。" + msgid "Connecting to the desktop…" msgstr "正在連線至桌面…" @@ -3847,6 +3861,18 @@ msgstr "連線逾時" msgid "Connections" msgstr "連線" +msgid "Connector" +msgstr "連接器" + +msgid "Connectors carry risk" +msgstr "連接器存在風險" + +msgid "Connects for your whole team." +msgstr "為整個團隊連接。" + +msgid "Connects your account. Teammates connect their own." +msgstr "連接您的帳戶。團隊成員各自連接。" + msgid "consecutive active days" msgstr "連續活躍天數" @@ -3904,6 +3930,10 @@ msgstr "繼續或分支會話" msgid "Continue the session the last run left" msgstr "繼續上次執行留下的工作階段" +#. placeholder {0}: connector.displayName +msgid "Continue to {0}" +msgstr "繼續前往 {0}" + msgid "Continue update" msgstr "繼續更新" @@ -4153,6 +4183,9 @@ msgstr "無法關閉會話" msgid "Could not close the session" msgstr "無法關閉會話" +msgid "Could not connect {plugin}. Try again." +msgstr "無法連線 {plugin}。請重試。" + msgid "Could not copy the link" msgstr "無法複製連結" @@ -4678,6 +4711,9 @@ msgstr "正在建立worktree" msgid "Creating..." msgstr "正在建立..." +msgid "Credentials stay on the server" +msgstr "憑證保留在伺服器上" + msgid "Cross-feature components living outside packages/ui — check here before building a new one" msgstr "位於packages/ui之外的跨功能元件——新建元件之前先來這裡看看" @@ -5135,6 +5171,9 @@ msgstr "開發登入" msgid "Dev sign-in failed" msgstr "開發登入失敗" +msgid "Developed by {author}" +msgstr "開發者:{author}" + msgid "Developer" msgstr "開發者" @@ -5253,10 +5292,6 @@ msgstr "已放棄——不再詢問" msgid "Disconnect" msgstr "斷開連線" -#. placeholder {0}: connection.account ?? displayName -msgid "Disconnect {0}" -msgstr "斷開 {0}" - msgid "Disconnect GitHub?" msgstr "斷開GitHub連線?" @@ -7596,6 +7631,9 @@ msgstr "來自Superset團隊的洞見,關於並行編碼代理程式與開發 msgid "Inspecting {0}" msgstr "正在檢查{0}" +msgid "Install" +msgstr "安裝" + msgid "Install & restart" msgstr "安裝並重啟" @@ -7608,12 +7646,18 @@ msgstr "安裝失敗" msgid "Install GitHub App" msgstr "安裝GitHub App" +msgid "Install plugin" +msgstr "安裝外掛" + msgid "Install Superset MCP in your client" msgstr "在你的客戶端裡安裝Superset MCP" msgid "Install the desktop app" msgstr "安裝桌面應用程式" +msgid "Install the plugin to connect" +msgstr "安裝外掛後即可連線" + msgid "Install the Superset GitHub App to connect your repositories." msgstr "安裝Superset GitHub App以連線你的儲存庫。" @@ -8885,6 +8929,10 @@ msgstr "更多頁面" msgid "More than half of merged changes are written by agents with zero human edits." msgstr "超過一半的合併變更由代理程式撰寫,且無任何人工修改。" +#. js-lingui-explicit-id +msgid "serverError.plugins.ambiguousConnection" +msgstr "符合多個 {connector} 連接;請中斷你不需要的那個。" + msgid "most developers" msgstr "多數開發者" @@ -14282,6 +14330,9 @@ msgstr "Superset Mobile、雲端工作區、自動化與優先支援。" msgid "Superset needs Full Disk Access to read another browser's data. Grant it in System Settings, then check again." msgstr "Superset需要「完全磁碟存取權限」才能讀取其他瀏覽器的資料。請在系統設定中授予權限,然後重新檢查。" +msgid "Superset only receives the permissions this connector asks for. Disconnect at any time to revoke them." +msgstr "Superset 只取得此連接器請求的權限。你可以隨時中斷連線以撤銷。" + msgid "Superset originals" msgstr "Superset原創元件" @@ -14688,6 +14739,9 @@ msgstr "多行文字框" msgid "Thanks for reaching out" msgstr "感謝聯絡" +msgid "That {plugin} sign-in link expired. Try connecting again." +msgstr "該 {plugin} 登入連結已過期。請重新連線。" + msgid "That endpoint could not be resolved." msgstr "無法解析該端點。" @@ -15048,6 +15102,12 @@ msgstr "正在思考..." msgid "Thirty" msgstr "三十天" +msgid "This {plugin} account is already connected by {owner}. Ask them to disconnect first." +msgstr "這個 {plugin} 帳戶已由 {owner} 連接。請先請對方中斷連接。" + +msgid "This {plugin} account is already connected by another Superset organization." +msgstr "這個 {plugin} 帳戶已由另一個 Superset 組織連接。" + msgid "This action is irreversible." msgstr "此操作不可逆。" @@ -15212,9 +15272,6 @@ msgstr "此路徑存在,但不是目錄。" msgid "This path is a directory" msgstr "該路徑是一個目錄" -msgid "This plugin needs no account — its skills are ready to use." -msgstr "此外掛不需要帳戶 — 它的技能可直接使用。" - msgid "This port is still in use" msgstr "此埠仍在使用中" @@ -15453,6 +15510,9 @@ msgstr "Token用量" msgid "Tokens" msgstr "Token" +msgid "Tokens are held by Superset and attached to requests there. They are never written into your local agent config." +msgstr "權杖由 Superset 保管,並在伺服器端附加到請求中,絕不會寫入你本機的代理設定。" + msgid "Tokens per session" msgstr "每次會話的 Token" @@ -16816,6 +16876,12 @@ msgstr "無法在應用程式內變更此訂閱。你所在組織的方案由擁 msgid "You can't undo this action. Terminal panes will show \"Process exited\" and can be restarted." msgstr "此操作無法復原。終端窗格會顯示「處理程序已退出」,之後可以重新啟動。" +msgid "You control the access" +msgstr "存取權限由你掌控" + +msgid "You declined the {plugin} authorization request." +msgstr "你拒絕了 {plugin} 的授權請求。" + msgid "You don't have access to this host" msgstr "你沒有此主機的存取權限" @@ -16825,6 +16891,9 @@ msgstr "你沒有此終端的存取權限。" msgid "You don't have permission to remove this member" msgstr "你沒有移除該成員的權限" +msgid "You need to be part of an organization to connect accounts." +msgstr "您需要加入組織才能連接帳戶。" + msgid "You need to be part of an organization to use integrations." msgstr "你需要加入一個組織才能使用整合。" diff --git a/packages/i18n/src/server-errors.ts b/packages/i18n/src/server-errors.ts index 13f5dc80b66..0fd05a79ec4 100644 --- a/packages/i18n/src/server-errors.ts +++ b/packages/i18n/src/server-errors.ts @@ -637,6 +637,13 @@ export const serverErrorMessages: Record< message: "Thread not found", }), ), + "serverError.plugins.ambiguousConnection": (params) => + i18n._({ + id: "serverError.plugins.ambiguousConnection", + message: + "More than one {connector} connection matches; disconnect the one you do not want.", + values: params, + }), "serverError.plugins.ambiguousPlugin": (params) => i18n._({ id: "serverError.plugins.ambiguousPlugin", diff --git a/packages/shared/package.json b/packages/shared/package.json index 604dfe0e284..a9ebddb80fa 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -4,6 +4,10 @@ "private": true, "type": "module", "exports": { + "./connectors": { + "types": "./src/connectors/index.ts", + "default": "./src/connectors/index.ts" + }, "./page-zoom": { "types": "./src/page-zoom.ts", "default": "./src/page-zoom.ts" @@ -311,6 +315,10 @@ "./theme-colors": { "types": "./src/themes/types.ts", "default": "./src/themes/types.ts" + }, + "./plugins/manifest-schema": { + "types": "./src/plugins/manifest-schema.ts", + "default": "./src/plugins/manifest-schema.ts" } }, "scripts": { diff --git a/packages/shared/src/connectors/connectors.json b/packages/shared/src/connectors/connectors.json new file mode 100644 index 00000000000..d91f01d4716 --- /dev/null +++ b/packages/shared/src/connectors/connectors.json @@ -0,0 +1,369 @@ +{ + "version": "1.0.0", + "connectors": { + "google": { + "displayName": "Google", + "icon": "google", + "category": "Communication", + "scope": "user", + "methods": [ + { + "type": "oauth2", + "label": "Sign in with Google", + "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scopes": [ + "openid", + "email", + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.labels", + "https://www.googleapis.com/auth/gmail.settings.basic", + "https://mail.google.com/" + ], + "scope_separator": " ", + "token_request_auth_method": "client_secret_post", + "token_expiration_buffer": 300, + "requires_env": ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"], + "authorization_params": { + "access_type": "offline", + "prompt": "consent", + "include_granted_scopes": "true" + }, + "identity": { + "url": "https://openidconnect.googleapis.com/v1/userinfo", + "method": "GET", + "headers": { + "Authorization": "Bearer ${config.access_token}" + }, + "account": { + "id": "$.email", + "label": "$.email" + }, + "user": { + "id": "$.sub", + "label": "$.email" + } + }, + "bind": { + "headers": { + "Authorization": "Bearer ${config.access_token}" + } + } + } + ] + }, + "linear": { + "displayName": "Linear", + "icon": "linear", + "category": "Productivity", + "scope": "user", + "methods": [ + { + "type": "oauth2", + "label": "OAuth 2.0", + "authorization_url": "https://linear.app/oauth/authorize", + "token_url": "https://api.linear.app/oauth/token", + "scopes": ["read", "write", "issues:create"], + "scope_separator": ",", + "token_request_auth_method": "client_secret_post", + "requires_env": ["LINEAR_CLIENT_ID", "LINEAR_CLIENT_SECRET"], + "identity": { + "url": "https://api.linear.app/graphql", + "method": "POST", + "headers": { + "Authorization": "Bearer ${config.access_token}", + "Content-Type": "application/json" + }, + "body": { + "query": "query { viewer { id name email organization { id name } } }" + }, + "account": { + "id": "$.data.viewer.organization.id", + "label": "$.data.viewer.organization.name" + }, + "user": { + "id": "$.data.viewer.id", + "label": "$.data.viewer.email" + } + }, + "bind": { + "headers": { + "Authorization": "Bearer ${config.access_token}" + } + } + }, + { + "type": "api_key", + "label": "Personal API key", + "credential_input": "api_key", + "inputs": [ + { + "name": "api_key", + "label": "Linear API key", + "placeholder": "lin_api_\u2026", + "description": "Linear \u2192 Settings \u2192 Security & access \u2192 Personal API keys", + "required": true, + "secret": true + } + ], + "identity": { + "url": "https://api.linear.app/graphql", + "method": "POST", + "headers": { + "Authorization": "${config.access_token}", + "Content-Type": "application/json" + }, + "body": { + "query": "query { viewer { id name email organization { id name } } }" + }, + "account": { + "id": "$.data.viewer.organization.id", + "label": "$.data.viewer.organization.name" + }, + "user": { + "id": "$.data.viewer.id", + "label": "$.data.viewer.email" + } + }, + "bind": { + "headers": { + "Authorization": "Bearer ${config.access_token}" + } + } + } + ] + }, + "notion": { + "displayName": "Notion", + "icon": "notion", + "category": "Productivity", + "scope": "user", + "methods": [ + { + "type": "oauth2", + "label": "OAuth 2.0", + "client": "dynamic", + "authorization_url": "https://mcp.notion.com/mcp", + "scopes": [], + "token_request_auth_method": "client_secret_basic", + "authorization_params": { + "owner": "user" + }, + "identity": { + "url": "https://api.notion.com/v1/users/me", + "method": "GET", + "headers": { + "Authorization": "Bearer ${config.access_token}", + "Notion-Version": "2022-06-28" + }, + "account": { + "id": "$.bot.workspace_id", + "label": "$.bot.workspace_name" + }, + "user": { + "id": "$.bot.owner.user.id", + "label": "$.bot.owner.user.name" + } + }, + "bind": { + "headers": { + "Authorization": "Bearer ${config.access_token}", + "Notion-Version": "2022-06-28" + } + } + } + ] + }, + "slack": { + "displayName": "Slack", + "icon": "slack", + "category": "Communication", + "scope": "user", + "methods": [ + { + "type": "oauth2", + "label": "Add to Slack", + "authorization_url": "https://slack.com/oauth/v2/authorize", + "token_url": "https://slack.com/api/oauth.v2.access", + "scope_separator": ",", + "scope_identifier": "user_scope", + "token_request_auth_method": "client_secret_post", + "requires_env": ["SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET"], + "scopes": [ + "channels:history", + "channels:read", + "channels:write", + "groups:history", + "groups:read", + "groups:write", + "im:history", + "im:read", + "im:write", + "mpim:history", + "mpim:read", + "mpim:write", + "chat:write", + "reactions:read", + "reactions:write", + "users:read", + "users:read.email", + "users:write", + "search:read", + "team:read" + ], + "authorization_params": { + "scope": "app_mentions:read,chat:write,reactions:read,reactions:write,channels:history,channels:read,groups:history,groups:read,im:history,im:read,im:write,mpim:history,users:read,files:read,assistant:write,links:read,links:write" + }, + "token": "$.authed_user.access_token", + "store": { + "bot_token": "$.access_token", + "bot_user_id": "$.bot_user_id", + "slack_user_id": "$.authed_user.id" + }, + "identity": { + "url": "https://slack.com/api/auth.test", + "method": "GET", + "headers": { + "Authorization": "Bearer ${config.access_token}" + }, + "account": { + "id": "$.team_id", + "label": "$.team" + }, + "user": { + "id": "$.user_id", + "label": "$.user" + } + }, + "bind": { + "headers": { + "Authorization": "Bearer ${config.access_token}" + } + } + } + ] + }, + "sentry": { + "displayName": "Sentry", + "icon": "sentry", + "category": "Developer tools", + "scope": "org", + "methods": [ + { + "type": "app_install", + "label": "Install the Sentry app", + "install_url": "https://sentry.io/sentry-apps/${env.SENTRY_APP_SLUG}/external-install/", + "token_url": "https://sentry.io/api/0/sentry-app-installations/${params.installationId}/authorizations/", + "token_request_auth_method": "client_secret_post", + "requires_env": [ + "SENTRY_CLIENT_ID", + "SENTRY_CLIENT_SECRET", + "SENTRY_APP_SLUG" + ], + "callback_params": ["code", "installationId", "orgSlug"], + "identity": { + "url": "https://sentry.io/api/0/sentry-app-installations/${params.installationId}/", + "method": "GET", + "headers": { + "Authorization": "Bearer ${config.access_token}" + }, + "account": { + "id": "$.organization.slug", + "label": "$.organization.name" + } + }, + "bind": { + "headers": { + "Authorization": "Bearer ${config.access_token}" + } + } + } + ] + }, + "microsoft_teams": { + "displayName": "Microsoft Teams", + "icon": "microsoft-teams", + "category": "Communication", + "scope": "org", + "methods": [ + { + "type": "admin_consent", + "label": "Grant admin consent", + "consent_url": "https://login.microsoftonline.com/organizations/v2.0/adminconsent", + "token_url": "https://login.microsoftonline.com/${config.tenant_id}/oauth2/v2.0/token", + "grant_type": "client_credentials", + "scopes": ["https://graph.microsoft.com/.default"], + "scope_separator": " ", + "requires_env": ["MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET"], + "callback_params": ["tenant", "admin_consent"], + "identity_signin": { + "authorization_url": "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize", + "scopes": ["openid", "profile", "email"], + "scope_separator": " " + }, + "identity": { + "url": "https://graph.microsoft.com/v1.0/organization", + "method": "GET", + "headers": { + "Authorization": "Bearer ${config.access_token}" + }, + "account": { + "id": "$.value[0].id", + "label": "$.value[0].displayName" + } + }, + "bind": { + "headers": { + "Authorization": "Bearer ${config.access_token}" + } + } + } + ] + }, + "github": { + "displayName": "GitHub", + "icon": "github", + "category": "Developer tools", + "scope": "user", + "methods": [ + { + "type": "oauth2", + "label": "OAuth 2.0", + "authorization_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "scopes": ["repo", "read:org", "workflow"], + "scope_separator": " ", + "token_request_auth_method": "client_secret_post", + "requires_env": [ + "PLUGIN_GITHUB_CLIENT_ID", + "PLUGIN_GITHUB_CLIENT_SECRET" + ], + "identity": { + "url": "https://api.github.com/user", + "method": "GET", + "headers": { + "Authorization": "Bearer ${config.access_token}", + "Accept": "application/vnd.github+json" + }, + "account": { + "id": "$.id", + "label": "$.login" + }, + "user": { + "id": "$.id", + "label": "$.login" + } + }, + "bind": { + "headers": { + "Authorization": "Bearer ${config.access_token}" + } + } + } + ] + } + } +} diff --git a/packages/shared/src/connectors/index.ts b/packages/shared/src/connectors/index.ts new file mode 100644 index 00000000000..018f09da19c --- /dev/null +++ b/packages/shared/src/connectors/index.ts @@ -0,0 +1,140 @@ +import { z } from "zod"; +import raw from "./connectors.json"; + +const identityRef = z.object({ + id: z.string(), + label: z.string().optional(), +}); + +const identityProbe = z.object({ + url: z.string(), + method: z.enum(["GET", "POST"]).default("GET"), + headers: z.record(z.string(), z.string()).optional(), + body: z.record(z.string(), z.unknown()).optional(), + account: identityRef, + user: identityRef.optional(), +}); + +const bindSpec = z.object({ + headers: z.record(z.string(), z.string()).optional(), + query: z.record(z.string(), z.string()).optional(), +}); + +const methodInput = z.object({ + name: z.string(), + label: z.string().optional(), + placeholder: z.string().optional(), + description: z.string().optional(), + required: z.boolean().default(false), + secret: z.boolean().default(false), +}); + +const shared = { + label: z.string().optional(), + requires_env: z.array(z.string()).default([]), + scopes: z.array(z.string()).default([]), + scope_separator: z.string().default(" "), + token_request_auth_method: z + .enum(["client_secret_post", "client_secret_basic"]) + .optional(), + token_expiration_buffer: z.number().optional(), + identity: identityProbe, + bind: bindSpec.optional(), +}; + +const oauth2Method = z.object({ + type: z.literal("oauth2"), + authorization_url: z.string().optional(), + token_url: z.string().optional(), + authorization_params: z.record(z.string(), z.string()).optional(), + token_params: z.record(z.string(), z.string()).optional(), + pkce: z.boolean().default(false), + scope_identifier: z.string().default("scope"), + client: z.enum(["static", "dynamic"]).default("static"), + token: z.string().default("$.access_token"), + store: z.record(z.string(), z.string()).default({}), + ...shared, +}); + +const apiKeyMethod = z.object({ + type: z.literal("api_key"), + credential_input: z.string(), + inputs: z.array(methodInput), + ...shared, +}); + +const appInstallMethod = z.object({ + type: z.literal("app_install"), + install_url: z.string(), + token_url: z.string(), + callback_params: z.array(z.string()).default([]), + ...shared, +}); + +const adminConsentMethod = z.object({ + type: z.literal("admin_consent"), + consent_url: z.string(), + token_url: z.string(), + grant_type: z.literal("client_credentials"), + callback_params: z.array(z.string()).default([]), + identity_signin: z + .object({ + authorization_url: z.string(), + scopes: z.array(z.string()), + scope_separator: z.string().default(" "), + }) + .optional(), + ...shared, +}); + +const connectorMethod = z.discriminatedUnion("type", [ + oauth2Method, + apiKeyMethod, + appInstallMethod, + adminConsentMethod, +]); + +const connector = z.object({ + displayName: z.string(), + icon: z.string(), + category: z.string(), + scope: z.enum(["user", "org"]), + methods: z.array(connectorMethod).min(1), +}); + +const registry = z.object({ + version: z.string(), + connectors: z.record(z.string(), connector), +}); + +export type Connector = z.infer; +export type ConnectorMethod = z.infer; +export type ConnectorMethodType = ConnectorMethod["type"]; +export type ConnectorOwnerKind = Connector["scope"]; +export type ConnectorIdentityProbe = z.infer; +export type ConnectorBind = z.infer; +export type ConnectorSlug = keyof typeof raw.connectors; + +const parsed = registry.parse(raw); + +export const CONNECTORS: Record = parsed.connectors; +export const CONNECTOR_SLUGS = Object.keys(CONNECTORS) as ConnectorSlug[]; + +export const getConnector = (slug: string): Connector | undefined => + CONNECTORS[slug]; + +export const connectorRequiredEnv = (slug: string): string[] => [ + ...new Set(getConnector(slug)?.methods.flatMap((m) => m.requires_env) ?? []), +]; + +export function secretInputNames(method: ConnectorMethod): string[] { + if (method.type !== "api_key") return []; + return [ + ...new Set([ + ...method.inputs + .filter((input) => input.secret) + .map((input) => input.name), + method.credential_input, + ]), + ]; +} diff --git a/packages/shared/src/plugins/index.ts b/packages/shared/src/plugins/index.ts index 2aef0d3c731..ca5122c075c 100644 --- a/packages/shared/src/plugins/index.ts +++ b/packages/shared/src/plugins/index.ts @@ -1,8 +1,17 @@ export * from "./manifests.generated"; +import { firstPartyManifest } from "./manifests.generated"; + export const DEFAULT_MARKETPLACE = "superset"; export const DEFAULT_MARKETPLACE_REPO = "superset-sh/superset"; export const DEFAULT_MARKETPLACE_REF = "main"; + +export const SUPERSET_HOSTED_PLUGINS = ["gmail", "slack"] as const; +export type SupersetHostedPlugin = (typeof SUPERSET_HOSTED_PLUGINS)[number]; + +export function isSupersetHosted(name: string): boolean { + return (SUPERSET_HOSTED_PLUGINS as readonly string[]).includes(name); +} /** * The curated plugin catalog the desktop Plugins page renders and installs * from. Static for the MVP — each entry is shaped as a pre-resolved plugin @@ -258,7 +267,7 @@ export const PLUGIN_CATALOG: readonly PluginCatalogEntry[] = [ }, { name: "linear", - version: "1.0.0", + version: "1.5.2", description: "Plan and build products", interface: { displayName: "Linear", category: "Productivity" }, auth: [{ type: "oauth2" }], @@ -269,7 +278,7 @@ export const PLUGIN_CATALOG: readonly PluginCatalogEntry[] = [ }, { name: "github", - version: "1.0.0", + version: "1.0.1", description: "Work with issues, pull requests, and repos", interface: { displayName: "GitHub", category: "Developer tools" }, auth: [{ type: "oauth2" }], @@ -280,7 +289,7 @@ export const PLUGIN_CATALOG: readonly PluginCatalogEntry[] = [ }, { name: "notion", - version: "1.0.0", + version: "1.0.2", description: "Notion workflows for specs, research, and docs", interface: { displayName: "Notion", category: "Productivity" }, mcpServers: { @@ -346,12 +355,19 @@ export const PLUGIN_CATALOG: readonly PluginCatalogEntry[] = [ }, { name: "slack", - version: "1.0.0", + version: "1.2.3", description: "Read and send messages in your workspace", interface: { displayName: "Slack", category: "Communication" }, - mcpServers: { - slack: { type: "http", url: "https://mcp.slack.com/mcp" }, - }, + auth: [{ type: "oauth2" }], + mcpServers: {}, + }, + { + name: "gmail", + version: "1.1.5", + description: "Read, search, send, and organize mail in your Gmail account", + interface: { displayName: "Gmail", category: "Communication" }, + auth: [{ type: "oauth2" }], + mcpServers: {}, }, { name: "vercel", @@ -436,8 +452,36 @@ export const PLUGIN_CATALOG: readonly PluginCatalogEntry[] = [ }, ]; +export const SUPERSET_API_URL = "https://api.superset.sh"; + +/** + * Where an agent reaches a plugin whose tools run on someone's credential. + * Superset fronts every one of them, so the agent holds a Superset token and + * never the vendor's — and the endpoint is `/mcp/plugins//`, + * which the manifest already determines. Writing it out per plugin would only + * add somewhere for it to be wrong. + */ +export function pluginProxyMcpServers( + name: string, + marketplace: string = DEFAULT_MARKETPLACE, +): Record | undefined { + const extension = firstPartyManifest(name)?.extensions?.superset as + | { connector?: { slug: string } } + | undefined; + if (!extension?.connector) return undefined; + return { + [name]: { + type: "http", + url: `${SUPERSET_API_URL}/mcp/plugins/${marketplace}/${name}`, + }, + }; +} + export function getPluginByName(name: string): PluginCatalogEntry | undefined { - return PLUGIN_CATALOG.find((plugin) => plugin.name === name); + const entry = PLUGIN_CATALOG.find((plugin) => plugin.name === name); + if (!entry) return undefined; + const proxied = pluginProxyMcpServers(name); + return proxied ? { ...entry, mcpServers: proxied } : entry; } /** diff --git a/packages/shared/src/plugins/manifest-schema.ts b/packages/shared/src/plugins/manifest-schema.ts new file mode 100644 index 00000000000..ceb8b33d269 --- /dev/null +++ b/packages/shared/src/plugins/manifest-schema.ts @@ -0,0 +1,82 @@ +// biome-ignore-all lint/suspicious/noTemplateCurlyInString: ${inputs.*} and ${config.*} are the manifest placeholder syntax this schema documents +import { z } from "zod"; +import { PLUGIN_CATEGORIES } from "./index"; + +const NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; + +export const pluginConnectorRefSchema = z + .object({ + slug: z + .string() + .describe( + "A connector in the Superset registry. The connections system owns how the connection is obtained; a manifest only names which one it needs.", + ), + }) + .meta({ id: "PluginConnectorRef" }); + +const pluginBindSchema = z + .object({ + headers: z.record(z.string(), z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + }) + .describe( + "How the connection's credential reaches the server. ${config.access_token} is substituted by the credential proxy at call time.", + ); + +const pluginMcpSchema = z + .object({ + type: z.literal("streamable-http"), + url: z.url(), + headers: z.record(z.string(), z.string()).optional(), + }) + .describe( + "The vendor's own MCP server, which Superset dials with the connection's credential attached. Omit it when Superset hosts the plugin's tools itself — absence is the declaration, not a gap. Agents never receive this URL; they are pointed at the Superset endpoint that fronts it. Streamable HTTP only — the legacy HTTP+SSE transport is not supported.", + ); + +export const supersetExtensionSchema = z + .object({ + interface: z + .object({ + displayName: z.string(), + category: z.enum(PLUGIN_CATEGORIES).optional(), + icon: z.string().optional(), + }) + .optional(), + connector: pluginConnectorRefSchema + .optional() + .describe( + "The one connection this plugin needs, by connector slug. Its credential is what a tool call runs under. A manifest carries no OAuth configuration of its own — no scopes, no client mode, no requires_env.", + ), + bind: pluginBindSchema.optional(), + mcp: pluginMcpSchema.optional(), + }) + .meta({ id: "SupersetExtension" }); + +export const pluginManifestSchema = z + .object({ + $schema: z.string().optional(), + name: z + .string() + .regex( + NAME_PATTERN, + "lowercase letters, digits, dots and dashes; no leading, trailing or doubled separators", + ), + version: z.string(), + description: z.string().optional(), + author: z + .object({ name: z.string().optional(), url: z.url().optional() }) + .optional(), + homepage: z.url().optional(), + repository: z.string().optional(), + license: z.string().optional(), + keywords: z.array(z.string()).optional(), + extensions: z.object({ superset: supersetExtensionSchema }).optional(), + }) + .meta({ + id: "PluginManifest", + title: "Superset plugin manifest", + description: + "plugin.json for a Superset marketplace plugin. Authoring guide: https://docs.superset.sh", + }); + +export type PluginManifestInput = z.input; diff --git a/packages/shared/src/plugins/manifests.generated.ts b/packages/shared/src/plugins/manifests.generated.ts index 1f9423007ff..86e21e281c0 100644 --- a/packages/shared/src/plugins/manifests.generated.ts +++ b/packages/shared/src/plugins/manifests.generated.ts @@ -9,7 +9,7 @@ export const FIRST_PARTY_MANIFESTS = { "github": { "$schema": "https://superset.sh/schemas/plugin/1.0.0.json", "name": "github", - "version": "1.0.0", + "version": "1.0.1", "description": "Work with issues, pull requests, and CI across your repositories.", "author": { "name": "Superset", @@ -32,41 +32,9 @@ export const FIRST_PARTY_MANIFESTS = { "category": "Developer tools", "icon": "github" }, - "auth": [ - { - "type": "oauth2", - "provider": "github", - "authorization_url": "https://github.com/login/oauth/authorize", - "token_url": "https://github.com/login/oauth/access_token", - "scopes": [ - "repo", - "read:org", - "workflow" - ], - "scope_separator": " ", - "token_request_auth_method": "client_secret_post", - "requires_env": [ - "PLUGIN_GITHUB_CLIENT_ID", - "PLUGIN_GITHUB_CLIENT_SECRET" - ], - "identity": { - "url": "https://api.github.com/user", - "method": "GET", - "headers": { - "Authorization": "Bearer ${config.access_token}", - "Accept": "application/vnd.github+json" - }, - "id": "$.id", - "label": "$.login" - }, - "label": "OAuth 2.0", - "bind": { - "headers": { - "Authorization": "Bearer ${config.access_token}" - } - } - } - ], + "connector": { + "slug": "github" + }, "mcp": { "type": "streamable-http", "url": "https://api.githubcopilot.com/mcp/" @@ -87,7 +55,7 @@ export const FIRST_PARTY_MANIFESTS = { "linear": { "$schema": "https://superset.sh/schemas/plugin/1.0.0.json", "name": "linear", - "version": "1.4.0", + "version": "1.5.2", "description": "Plan and build products: create, search, and update Linear issues.", "author": { "name": "Superset", @@ -109,78 +77,9 @@ export const FIRST_PARTY_MANIFESTS = { "category": "Productivity", "icon": "linear" }, - "auth": [ - { - "type": "oauth2", - "label": "OAuth 2.0", - "provider": "linear", - "authorization_url": "https://linear.app/oauth/authorize", - "token_url": "https://api.linear.app/oauth/token", - "scopes": [ - "read", - "write", - "issues:create" - ], - "scope_separator": ",", - "token_request_auth_method": "client_secret_post", - "token_expiration_buffer": 300, - "requires_env": [ - "PLUGIN_LINEAR_CLIENT_ID", - "PLUGIN_LINEAR_CLIENT_SECRET" - ], - "identity": { - "url": "https://api.linear.app/graphql", - "method": "POST", - "headers": { - "Authorization": "Bearer ${config.access_token}", - "Content-Type": "application/json" - }, - "body": { - "query": "query { viewer { id name organization { id name } } }" - }, - "id": "$.data.viewer.organization.id", - "label": "$.data.viewer.organization.name" - }, - "bind": { - "headers": { - "Authorization": "Bearer ${config.access_token}" - } - } - }, - { - "type": "api_key", - "label": "Personal API key", - "credential_input": "api_key", - "inputs": [ - { - "name": "api_key", - "label": "Linear API key", - "placeholder": "lin_api_…", - "description": "Linear → Settings → Security & access → Personal API keys", - "required": true, - "secret": true - } - ], - "identity": { - "url": "https://api.linear.app/graphql", - "method": "POST", - "headers": { - "Authorization": "${config.access_token}", - "Content-Type": "application/json" - }, - "body": { - "query": "query { viewer { id name organization { id name } } }" - }, - "id": "$.data.viewer.organization.id", - "label": "$.data.viewer.organization.name" - }, - "bind": { - "headers": { - "Authorization": "Bearer ${config.access_token}" - } - } - } - ], + "connector": { + "slug": "linear" + }, "mcp": { "type": "streamable-http", "url": "https://mcp.linear.app/mcp" @@ -202,6 +101,118 @@ export const FIRST_PARTY_MANIFESTS = { } ] } as const, + "notion": { + "$schema": "https://superset.sh/schemas/plugin/1.0.0.json", + "name": "notion", + "version": "1.0.2", + "description": "Search, read, and write your Notion workspace: pages, databases, and comments.", + "author": { + "name": "Superset", + "url": "https://superset.sh" + }, + "homepage": "https://docs.superset.sh", + "repository": "https://github.com/superset-sh/superset", + "license": "MIT", + "keywords": [ + "notion", + "docs", + "notes", + "knowledge-base", + "wiki" + ], + "extensions": { + "superset": { + "interface": { + "displayName": "Notion", + "category": "Productivity", + "icon": "notion" + }, + "connector": { + "slug": "notion" + }, + "mcp": { + "type": "streamable-http", + "url": "https://mcp.notion.com/mcp" + } + } + }, + "skills": [ + { + "name": "find-in-notion", + "description": "Find what the workspace already says before you answer or write — search Notion, tell the canonical page from the stale copy, and cite what you used. Use when the user asks what's in Notion, refers to a doc, spec, or meeting note, or asks a question the workspace probably already answers." + }, + { + "name": "write-to-notion", + "description": "Write a page into Notion that someone else can find and trust — pick the right parent, check for the page that already exists, and structure it for a reader who was not in this conversation. Use when the user says to write up, document, save, or capture something in Notion, or to update an existing page." + } + ] + } as const, + "slack": { + "$schema": "https://superset.sh/schemas/plugin/1.0.0.json", + "name": "slack", + "version": "1.2.3", + "description": "Search, read, and post across your Slack workspace: channels, DMs, and threads.", + "author": { + "name": "Superset", + "url": "https://superset.sh" + }, + "homepage": "https://docs.superset.sh", + "repository": "https://github.com/superset-sh/superset", + "license": "MIT", + "keywords": [ + "slack", + "chat", + "messages", + "communication", + "search" + ], + "extensions": { + "superset": { + "interface": { + "displayName": "Slack", + "category": "Communication", + "icon": "slack" + }, + "connector": { + "slug": "slack" + } + } + }, + "skills": [] + } as const, + "gmail": { + "$schema": "https://superset.sh/schemas/plugin/1.0.0.json", + "name": "gmail", + "version": "1.1.5", + "description": "Read, search, send, and organize mail in your Gmail account.", + "author": { + "name": "Superset", + "url": "https://superset.sh" + }, + "homepage": "https://docs.superset.sh", + "repository": "https://github.com/superset-sh/superset", + "license": "MIT", + "keywords": [ + "gmail", + "email", + "mail", + "google", + "inbox" + ], + "extensions": { + "superset": { + "interface": { + "displayName": "Gmail", + "category": "Communication", + "icon": "gmail" + }, + "connector": { + "slug": "google" + } + } + }, + "skills": [] + } as const, } as const; export type FirstPartyPluginName = keyof typeof FIRST_PARTY_MANIFESTS; diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 75009819c67..087932b12ce 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -8,6 +8,10 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, + "./connectors": { + "types": "./src/lib/connectors/index.ts", + "default": "./src/lib/connectors/index.ts" + }, "./lib/sandbox": { "types": "./src/lib/sandbox/index.ts", "default": "./src/lib/sandbox/index.ts" @@ -92,6 +96,10 @@ "types": "./src/router/analytics/business.ts", "default": "./src/router/analytics/business.ts" }, + "./plugins-proxy": { + "types": "./src/router/plugins/proxy/index.ts", + "default": "./src/router/plugins/proxy/index.ts" + }, "./router/cloud-workspace/provision": { "types": "./src/router/cloud-workspace/provision.ts", "default": "./src/router/cloud-workspace/provision.ts" @@ -111,6 +119,7 @@ "@googleapis/gmail": "18.0.0", "@linear/sdk": "68.1.1", "@microsoft/microsoft-graph-client": "3.0.7", + "@modelcontextprotocol/sdk": "1.30.0", "@notionhq/client": "5.25.2", "@octokit/app": "16.1.2", "@sentry/core": "10.68.0", diff --git a/packages/trpc/src/env.ts b/packages/trpc/src/env.ts index e5bb3bd02f1..31359a7f9d9 100644 --- a/packages/trpc/src/env.ts +++ b/packages/trpc/src/env.ts @@ -109,6 +109,11 @@ export const env = createEnv({ .string() .min(1) .default("sc-domain:superset.sh"), + // Optional, falls back to NEXT_PUBLIC_API_URL: the origin an + // authorization server fetches a plugin's client id metadata document + // from. Only needs setting where NEXT_PUBLIC_API_URL is unreachable from + // the public internet, which in practice means local dev behind a tunnel. + PLUGIN_CLIENT_METADATA_BASE_URL: z.string().url().optional(), }, clientPrefix: "PUBLIC_", client: {}, diff --git a/packages/trpc/src/lib/cloud-guards/cloud-guards.test.ts b/packages/trpc/src/lib/cloud-guards/cloud-guards.test.ts index 2b3cdc61264..992d24ade23 100644 --- a/packages/trpc/src/lib/cloud-guards/cloud-guards.test.ts +++ b/packages/trpc/src/lib/cloud-guards/cloud-guards.test.ts @@ -11,10 +11,9 @@ let calls: Array<{ // Both stubbed so the real clients — and the validated env and database // connection they open at import — stay out of this test's module graph. -// Bun keeps the export names of the first stub of a module for the whole -// run, so this lists `dbWs` for test files that load after it and import it. +// `mock.module` is process-wide, so every export the real module has must be +// here: another file's import of `dbWs` resolves against this stub too. mock.module("@superset/db/client", () => ({ - dbWs: {}, db: { query: { users: { @@ -27,6 +26,9 @@ mock.module("@superset/db/client", () => ({ }, }, }, + dbWs: { + transaction: () => Promise.reject(new Error("dbWs is stubbed in tests")), + }, })); mock.module("../analytics", () => ({ posthog: { diff --git a/packages/trpc/src/lib/connectors/client-identity.ts b/packages/trpc/src/lib/connectors/client-identity.ts new file mode 100644 index 00000000000..6bebb50a1a3 --- /dev/null +++ b/packages/trpc/src/lib/connectors/client-identity.ts @@ -0,0 +1,230 @@ +import { db } from "@superset/db/client"; +import { pluginOauthClients } from "@superset/db/schema"; +import type { ConnectorMethod } from "@superset/shared/connectors"; +import { and, eq, isNotNull, lte } from "drizzle-orm"; +import { env } from "../../env"; +import { decryptOptional, encryptOptional } from "../../router/plugins/crypto"; +import { credentialFetch } from "../../router/plugins/manifest"; +import type { DiscoveredServer } from "./discovery"; + +export interface ClientIdentity { + clientId: string; + clientSecret?: string; + authentication?: "basic" | "post"; +} + +export function clientMetadataUrl(connectorSlug: string): string { + const base = env.PLUGIN_CLIENT_METADATA_BASE_URL ?? env.NEXT_PUBLIC_API_URL; + return `${base}/api/connectors/${connectorSlug}/client-metadata`; +} + +export function redirectUriFor(connectorSlug: string): string { + return `${env.NEXT_PUBLIC_API_URL}/api/connectors/callback/${connectorSlug}`; +} + +function pickAuthMethod( + supported: string[] | undefined, + issuer: string, +): "client_secret_post" | "client_secret_basic" | "none" { + const methods = supported ?? ["client_secret_basic"]; + if (methods.includes("client_secret_post")) return "client_secret_post"; + if (methods.includes("client_secret_basic")) return "client_secret_basic"; + // Registering as `none` against a server that never offered it yields a + // public client whose token requests it will reject, far from here. + if (!methods.includes("none")) { + throw new Error( + `${issuer} supports none of the client authentication methods Superset can use (it advertises ${methods.join(", ")}).`, + ); + } + return "none"; +} + +/** Only ask for what the server grants; requesting refresh_token blindly can fail registration. */ +function pickGrantTypes(supported: string[] | undefined): string[] { + if (!supported) return ["authorization_code", "refresh_token"]; + return ["authorization_code", "refresh_token"].filter((grant) => + supported.includes(grant), + ); +} + +function authenticationFor( + method: string | null | undefined, +): "basic" | "post" | undefined { + if (method === "client_secret_post") return "post"; + if (method === "client_secret_basic") return "basic"; + return undefined; +} + +async function storedClient( + issuer: string, + redirectUri: string, +): Promise { + const [row] = await db + .select() + .from(pluginOauthClients) + .where( + and( + eq(pluginOauthClients.issuer, issuer), + eq(pluginOauthClients.redirectUri, redirectUri), + ), + ) + .limit(1); + if (!row) return null; + if (row.clientSecretExpiresAt && row.clientSecretExpiresAt <= new Date()) { + return null; + } + + const secret = await decryptOptional(row.clientSecret); + return { + clientId: row.clientId, + ...(secret ? { clientSecret: secret } : {}), + ...(authenticationFor(row.tokenEndpointAuthMethod) + ? { authentication: authenticationFor(row.tokenEndpointAuthMethod) } + : {}), + }; +} + +async function register( + connectorSlug: string, + server: DiscoveredServer, + method: ConnectorMethod, + redirectUri: string, +): Promise { + const endpoint = server.metadata.registration_endpoint; + if (!endpoint) { + throw new Error( + `${server.issuer} supports neither a client id metadata document nor dynamic client registration, so the "${connectorSlug}" connector cannot obtain a client. Give it a static authorization_url, token_url, and requires_env instead.`, + ); + } + + const tokenEndpointAuthMethod = pickAuthMethod( + server.metadata.token_endpoint_auth_methods_supported, + server.issuer, + ); + const response = await credentialFetch( + endpoint, + { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + }, + body: JSON.stringify({ + client_name: "Superset", + client_uri: "https://superset.sh", + redirect_uris: [redirectUri], + grant_types: pickGrantTypes(server.metadata.grant_types_supported), + response_types: ["code"], + application_type: "web", + token_endpoint_auth_method: tokenEndpointAuthMethod, + ...(method.scopes?.length + ? { scope: method.scopes.join(method.scope_separator ?? " ") } + : {}), + }), + }, + "client registration", + ); + + if (!response.ok) { + throw new Error( + `Dynamic client registration with ${server.issuer} failed: ${response.status} ${await response.text()}`, + ); + } + + const payload = (await response.json()) as { + client_id?: string; + client_secret?: string; + client_secret_expires_at?: number; + registration_access_token?: string; + registration_client_uri?: string; + token_endpoint_auth_method?: string; + }; + if (!payload.client_id) { + throw new Error( + `${server.issuer} returned no client_id from dynamic client registration.`, + ); + } + + const resolvedAuthMethod = payload.client_secret + ? (payload.token_endpoint_auth_method ?? tokenEndpointAuthMethod) + : "none"; + const expiresAt = + payload.client_secret_expires_at && payload.client_secret_expires_at > 0 + ? new Date(payload.client_secret_expires_at * 1000) + : null; + + const row = { + clientId: payload.client_id, + clientSecret: await encryptOptional(payload.client_secret), + clientSecretExpiresAt: expiresAt, + registrationAccessToken: await encryptOptional( + payload.registration_access_token, + ), + registrationClientUri: payload.registration_client_uri ?? null, + tokenEndpointAuthMethod: resolvedAuthMethod, + }; + + await db + .insert(pluginOauthClients) + .values({ issuer: server.issuer, redirectUri, ...row }) + .onConflictDoUpdate({ + target: [pluginOauthClients.issuer, pluginOauthClients.redirectUri], + set: row, + setWhere: and( + isNotNull(pluginOauthClients.clientSecretExpiresAt), + lte(pluginOauthClients.clientSecretExpiresAt, new Date()), + ), + }); + + return ( + (await storedClient(server.issuer, redirectUri)) ?? { + clientId: payload.client_id, + ...(payload.client_secret ? { clientSecret: payload.client_secret } : {}), + ...(authenticationFor(resolvedAuthMethod) + ? { authentication: authenticationFor(resolvedAuthMethod) } + : {}), + } + ); +} + +const registering = new Map>(); + +export async function resolveClientIdentity( + connectorSlug: string, + server: DiscoveredServer, + method: ConnectorMethod, + redirectUri: string, +): Promise { + if (server.metadata.client_id_metadata_document_supported) { + return { clientId: clientMetadataUrl(connectorSlug) }; + } + + const existing = await storedClient(server.issuer, redirectUri); + if (existing) return existing; + + const key = `${server.issuer}\n${redirectUri}`; + const pending = registering.get(key); + if (pending) return await pending; + + const attempt = register(connectorSlug, server, method, redirectUri).finally( + () => registering.delete(key), + ); + registering.set(key, attempt); + return await attempt; +} + +export async function forgetClient( + issuer: string, + redirectUri: string, + clientId: string, +): Promise { + await db + .delete(pluginOauthClients) + .where( + and( + eq(pluginOauthClients.issuer, issuer), + eq(pluginOauthClients.redirectUri, redirectUri), + eq(pluginOauthClients.clientId, clientId), + ), + ); +} diff --git a/packages/trpc/src/lib/connectors/connectors.test.ts b/packages/trpc/src/lib/connectors/connectors.test.ts new file mode 100644 index 00000000000..f7ea7defdda --- /dev/null +++ b/packages/trpc/src/lib/connectors/connectors.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { CONNECTOR_SLUGS, getConnector } from "@superset/shared/connectors"; +import { generateCodeChallenge } from "better-auth/oauth2"; +import { + assertRespondingIssuer, + authorizeUrl, + connectorMethod, + IssuerMismatchError, + MissingConnectorEnvError, + probeIdentity, + type ResolvedEndpoints, + requireConnector, + resolveConnectorTemplate, + UnknownConnectorError, +} from "./index"; + +const ENV = { + SLACK_CLIENT_ID: "sc", + SLACK_CLIENT_SECRET: "ss", + GOOGLE_CLIENT_ID: "gc", + GOOGLE_CLIENT_SECRET: "gs", + SENTRY_CLIENT_ID: "xc", + SENTRY_CLIENT_SECRET: "xs", + SENTRY_APP_SLUG: "superset-app", +}; + +const original: Record = {}; + +beforeEach(() => { + for (const [key, value] of Object.entries(ENV)) { + original[key] = process.env[key]; + process.env[key] = value; + } +}); + +afterEach(() => { + for (const key of Object.keys(ENV)) { + if (original[key] === undefined) delete process.env[key]; + else process.env[key] = original[key]; + } +}); + +const authorize = async (slug: string) => + new URL( + ( + await authorizeUrl(slug, connectorMethod(requireConnector(slug)), { + redirectUri: "https://api.test/cb", + state: "st", + }) + ).url, + ); + +describe("authorizeUrl", () => { + test("slack asks for bot and user scopes in one authorize", async () => { + const params = (await authorize("slack")).searchParams; + expect(params.get("user_scope")).toContain("search:read"); + expect(params.get("scope")).toContain("app_mentions:read"); + expect(params.get("scope")).not.toContain("search:read"); + }); + + test("google joins scopes with a space and keeps authorization_params", async () => { + const params = (await authorize("google")).searchParams; + expect(params.get("scope")).toContain("openid email"); + expect(params.get("access_type")).toBe("offline"); + expect(params.get("include_granted_scopes")).toBe("true"); + }); + + test("app_install resolves the env template and sends no client_id", async () => { + const url = await authorize("sentry"); + expect(url.pathname).toBe("/sentry-apps/superset-app/external-install/"); + expect(url.searchParams.get("client_id")).toBeNull(); + }); + + test("a missing client secret is named, not swallowed", async () => { + delete process.env.SLACK_CLIENT_SECRET; + expect(authorize("slack")).rejects.toThrow(MissingConnectorEnvError); + }); + + test("a static client sends no PKCE challenge", async () => { + const params = (await authorize("google")).searchParams; + expect(params.get("code_challenge")).toBeNull(); + }); + + test("pkce puts an S256 challenge on the URL and returns the verifier", async () => { + const method = { + ...connectorMethod(requireConnector("google")), + pkce: true, + } as never; + const target = await authorizeUrl("google", method, { + redirectUri: "https://api.test/cb", + state: "st", + }); + const params = new URL(target.url).searchParams; + expect(target.codeVerifier).toBeTruthy(); + expect(params.get("code_challenge_method")).toBe("S256"); + expect(params.get("code_challenge")).toBe( + await generateCodeChallenge(target.codeVerifier as string), + ); + }); +}); + +describe("requireConnector", () => { + test("an unknown slug names itself", () => { + expect(() => requireConnector("dropbox")).toThrow(UnknownConnectorError); + }); +}); + +describe("resolveConnectorTemplate", () => { + test("leaves an unresolvable placeholder alone", () => { + expect(resolveConnectorTemplate(`a/$\{params.missing}/b`, {})).toBe( + `a/$\{params.missing}/b`, + ); + }); +}); + +describe("probeIdentity", () => { + const realFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = realFetch; + }); + + const respond = (payload: unknown) => { + globalThis.fetch = (async () => + new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + })) as typeof fetch; + }; + + test("splits slack into workspace and person", async () => { + respond({ + team_id: "T123", + team: "Tegon", + user_id: "U9", + user: "harshith", + }); + const identity = await probeIdentity( + "slack", + connectorMethod(requireConnector("slack")), + "xoxp-test", + ); + expect(identity.account).toEqual({ id: "T123", label: "Tegon" }); + expect(identity.user).toEqual({ id: "U9", label: "harshith" }); + }); + + test("an org-scoped connector yields no user", async () => { + respond({ organization: { slug: "tegon", name: "Tegon" } }); + const identity = await probeIdentity( + "sentry", + connectorMethod(requireConnector("sentry")), + "sen-test", + ); + expect(identity.account.id).toBe("tegon"); + expect(identity.user).toBeNull(); + }); + + test("a probe that returns no account id fails loudly", async () => { + respond({ team: "Tegon" }); + await expect( + probeIdentity( + "slack", + connectorMethod(requireConnector("slack")), + "xoxp-test", + ), + ).rejects.toThrow(/returned nothing/); + }); +}); + +describe("registry invariants", () => { + test("a user-scoped connector declares where its person id lives", () => { + for (const slug of CONNECTOR_SLUGS) { + const connector = getConnector(slug); + if (!connector) throw new Error(`missing ${slug}`); + for (const method of connector.methods) + expect([slug, Boolean(method.identity.user)]).toEqual([ + slug, + connector.scope === "user", + ]); + } + }); +}); + +describe("authorization response issuer (RFC 9207)", () => { + const discovered = ( + extra: Partial = {}, + ): ResolvedEndpoints => ({ + authorizationEndpoint: "https://as.example.com/authorize", + tokenEndpoint: "https://as.example.com/token", + clientId: "cid", + pkce: true, + issuer: "https://as.example.com", + ...extra, + }); + + test("accepts the issuer that was discovered, ignoring a trailing slash", () => { + expect(() => + assertRespondingIssuer("linear", discovered(), "https://as.example.com/"), + ).not.toThrow(); + }); + + test("refuses a code minted by a different authorization server", () => { + expect(() => + assertRespondingIssuer( + "linear", + discovered(), + "https://evil.example.com", + ), + ).toThrow(IssuerMismatchError); + }); + + test("refuses a missing iss when the server said it sends one", () => { + expect(() => + assertRespondingIssuer( + "linear", + discovered({ issuerParameterSupported: true }), + null, + ), + ).toThrow(IssuerMismatchError); + }); + + test("allows a missing iss when the server never advertised it", () => { + expect(() => + assertRespondingIssuer("linear", discovered(), null), + ).not.toThrow(); + }); + + test("has nothing to check for a static client with no discovered issuer", () => { + expect(() => + assertRespondingIssuer( + "slack", + discovered({ issuer: undefined }), + "https://evil.example.com", + ), + ).not.toThrow(); + }); +}); diff --git a/packages/trpc/src/lib/connectors/discovery.test.ts b/packages/trpc/src/lib/connectors/discovery.test.ts new file mode 100644 index 00000000000..3e89aefca85 --- /dev/null +++ b/packages/trpc/src/lib/connectors/discovery.test.ts @@ -0,0 +1,100 @@ +import { afterAll, beforeEach, describe, expect, test } from "bun:test"; +import { discoverServer } from "./discovery"; + +const realFetch = global.fetch; + +let requests: string[]; + +function serve(routes: Record) { + requests = []; + global.fetch = (async (input: string | URL) => { + const url = String(input); + requests.push(url); + if (!(url in routes)) return new Response("no route", { status: 404 }); + return new Response(JSON.stringify(routes[url]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +} + +describe("dynamic client discovery", () => { + beforeEach(() => { + serve({}); + }); + + afterAll(() => { + global.fetch = realFetch; + }); + + test("refuses an authorization server the resource does not name over https", async () => { + serve({ + "https://plain.test/.well-known/oauth-protected-resource/mcp": { + resource: "https://plain.test/mcp", + authorization_servers: ["http://plain.test"], + }, + }); + + expect(discoverServer("https://plain.test/mcp")).rejects.toThrow( + /names no https authorization server/, + ); + }); + + test("refuses an authorization_endpoint the server advertises over http", async () => { + serve({ + "https://cleartext.test/.well-known/oauth-protected-resource/mcp": { + resource: "https://cleartext.test/mcp", + authorization_servers: ["https://cleartext.test"], + }, + "https://cleartext.test/.well-known/oauth-authorization-server": { + issuer: "https://cleartext.test", + authorization_endpoint: "http://cleartext.test/authorize", + token_endpoint: "https://cleartext.test/token", + }, + }); + + expect(discoverServer("https://cleartext.test/mcp")).rejects.toThrow( + /authorization_endpoint of http:/, + ); + }); + + test("refuses metadata that describes a different issuer", async () => { + serve({ + "https://swap.test/.well-known/oauth-protected-resource/mcp": { + resource: "https://swap.test/mcp", + authorization_servers: ["https://swap.test"], + }, + "https://swap.test/.well-known/oauth-authorization-server": { + issuer: "https://elsewhere.test", + authorization_endpoint: "https://swap.test/authorize", + token_endpoint: "https://swap.test/token", + }, + }); + + expect(discoverServer("https://swap.test/mcp")).rejects.toThrow( + /may only describe the issuer/, + ); + }); + + test("resolves a well-formed server", async () => { + serve({ + "https://ok.test/.well-known/oauth-protected-resource/mcp": { + resource: "https://ok.test/mcp", + authorization_servers: ["https://ok.test"], + }, + "https://ok.test/.well-known/oauth-authorization-server": { + issuer: "https://ok.test", + authorization_endpoint: "https://ok.test/authorize", + token_endpoint: "https://ok.test/token", + registration_endpoint: "https://ok.test/register", + }, + }); + + const server = await discoverServer("https://ok.test/mcp"); + expect(server.issuer).toBe("https://ok.test"); + expect(server.resource).toBe("https://ok.test/mcp"); + expect(server.metadata.registration_endpoint).toBe( + "https://ok.test/register", + ); + }); +}); diff --git a/packages/trpc/src/lib/connectors/discovery.ts b/packages/trpc/src/lib/connectors/discovery.ts new file mode 100644 index 00000000000..c8ff2906298 --- /dev/null +++ b/packages/trpc/src/lib/connectors/discovery.ts @@ -0,0 +1,175 @@ +import { credentialFetch } from "../../router/plugins/manifest"; + +export interface AuthorizationServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint?: string; + scopes_supported?: string[]; + grant_types_supported?: string[]; + code_challenge_methods_supported?: string[]; + token_endpoint_auth_methods_supported?: string[]; + client_id_metadata_document_supported?: boolean; + authorization_response_iss_parameter_supported?: boolean; +} + +export interface DiscoveredServer { + resource: string; + issuer: string; + metadata: AuthorizationServerMetadata; +} + +const CACHE_TTL_MS = 10 * 60 * 1000; +const cache = new Map(); + +function wellKnown(base: URL, name: string): string[] { + const path = base.pathname.replace(/\/$/, ""); + if (!path) return [`${base.origin}/.well-known/${name}`]; + return [ + `${base.origin}/.well-known/${name}${path}`, + `${base.origin}${path}/.well-known/${name}`, + `${base.origin}/.well-known/${name}`, + ]; +} + +function evictExpired(): void { + const now = Date.now(); + for (const [key, entry] of cache) { + if (now - entry.at >= CACHE_TTL_MS) cache.delete(key); + } +} + +function requireHttps(value: string, field: string, issuer: string): void { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error(`${issuer} advertises a ${field} that is not a URL.`); + } + if (parsed.protocol !== "https:") { + throw new Error( + `${issuer} advertises a ${field} of ${parsed.protocol}//${parsed.host}; it must be https.`, + ); + } +} + +export function sameIssuer(a: string, b: string): boolean { + return a.replace(/\/$/, "") === b.replace(/\/$/, ""); +} + +async function fetchJson(urls: string[], what: string): Promise { + const failures: string[] = []; + for (const url of urls) { + let response: Response; + try { + response = await credentialFetch(url, { method: "GET" }, what); + } catch (error) { + failures.push( + `${url}: ${error instanceof Error ? error.message : String(error)}`, + ); + continue; + } + if (!response.ok) { + failures.push(`${url}: ${response.status} ${response.statusText}`); + continue; + } + try { + return await response.json(); + } catch { + failures.push(`${url}: response was not JSON`); + } + } + throw new Error(`Could not read ${what} (${failures.join("; ")})`); +} + +async function protectedResource( + mcpUrl: string, +): Promise<{ resource: string; issuers: string[] }> { + const base = new URL(mcpUrl); + const payload = (await fetchJson( + wellKnown(base, "oauth-protected-resource"), + "protected resource metadata", + )) as { resource?: string; authorization_servers?: string[] }; + + const issuers = (payload.authorization_servers ?? []).filter( + (entry) => typeof entry === "string" && entry.startsWith("https://"), + ); + if (issuers.length === 0) { + throw new Error( + `${mcpUrl} names no https authorization server in its protected resource metadata.`, + ); + } + return { resource: payload.resource ?? mcpUrl, issuers }; +} + +async function authorizationServer( + issuer: string, +): Promise { + const base = new URL(issuer); + const payload = (await fetchJson( + [ + ...wellKnown(base, "oauth-authorization-server"), + ...wellKnown(base, "openid-configuration"), + ], + "authorization server metadata", + )) as Partial; + + if (!payload.authorization_endpoint || !payload.token_endpoint) { + throw new Error( + `${issuer} advertises no authorization_endpoint or token_endpoint.`, + ); + } + requireHttps( + payload.authorization_endpoint, + "authorization_endpoint", + issuer, + ); + requireHttps(payload.token_endpoint, "token_endpoint", issuer); + if (payload.registration_endpoint) { + requireHttps( + payload.registration_endpoint, + "registration_endpoint", + issuer, + ); + } + if (payload.issuer && !sameIssuer(payload.issuer, issuer)) { + throw new Error( + `${issuer} returned metadata for issuer "${payload.issuer}"; an authorization server may only describe the issuer it was requested from.`, + ); + } + return { + ...payload, + issuer, + authorization_endpoint: payload.authorization_endpoint, + token_endpoint: payload.token_endpoint, + }; +} + +export async function discoverServer( + mcpUrl: string, +): Promise { + const cached = cache.get(mcpUrl); + if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.value; + evictExpired(); + + const { resource, issuers } = await protectedResource(mcpUrl); + const failures: string[] = []; + for (const issuer of issuers) { + try { + const value = { + resource, + issuer, + metadata: await authorizationServer(issuer), + }; + cache.set(mcpUrl, { at: Date.now(), value }); + return value; + } catch (error) { + failures.push( + `${issuer}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + throw new Error( + `No authorization server named by ${mcpUrl} could be read (${failures.join("; ")})`, + ); +} diff --git a/packages/trpc/src/lib/connectors/index.ts b/packages/trpc/src/lib/connectors/index.ts new file mode 100644 index 00000000000..79944ab765c --- /dev/null +++ b/packages/trpc/src/lib/connectors/index.ts @@ -0,0 +1,488 @@ +import { randomBytes } from "node:crypto"; +import { + type Connector, + type ConnectorMethod, + getConnector, +} from "@superset/shared/connectors"; +import { generateCodeChallenge } from "better-auth/oauth2"; +import { credentialFetch, readPath } from "../../router/plugins/manifest"; +import { forgetClient, resolveClientIdentity } from "./client-identity"; +import { discoverServer, sameIssuer } from "./discovery"; + +const TEMPLATE = /\$\{(env|params|config)\.([\w.-]+)\}/g; + +export interface ConnectorTemplateScope { + env?: Record; + params?: Record; + config?: Record; +} + +export function resolveConnectorTemplate( + value: string, + scope: ConnectorTemplateScope, +): string { + return value.replace(TEMPLATE, (whole, root: string, key: string) => { + const source = (scope as Record>)[root]; + const resolved = source?.[key]; + return resolved === undefined || resolved === null + ? whole + : String(resolved); + }); +} + +export class UnknownConnectorError extends Error { + constructor(slug: string) { + super(`No connector named "${slug}" in connectors.json.`); + this.name = "UnknownConnectorError"; + } +} + +export class MissingConnectorEnvError extends Error { + constructor(slug: string, missing: string[]) { + super(`Connector "${slug}" needs ${missing.join(", ")}, which are unset.`); + this.name = "MissingConnectorEnvError"; + } +} + +export function requireConnector(slug: string): Connector { + const connector = getConnector(slug); + if (!connector) throw new UnknownConnectorError(slug); + return connector; +} + +export function connectorMethod( + connector: Connector, + type?: ConnectorMethod["type"], +): ConnectorMethod { + const method = type + ? connector.methods.find((m) => m.type === type) + : connector.methods[0]; + if (!method) + throw new Error(`Connector has no ${type ?? "default"} auth method.`); + return method; +} + +export function connectorEnv( + slug: string, + method: ConnectorMethod, +): Record { + const resolved: Record = {}; + const missing: string[] = []; + for (const name of method.requires_env) { + const value = process.env[name]; + if (value) resolved[name] = value; + else missing.push(name); + } + if (missing.length) throw new MissingConnectorEnvError(slug, missing); + return resolved; +} + +export function clientCredentials( + slug: string, + method: ConnectorMethod, +): { clientId: string; clientSecret: string } { + const env = connectorEnv(slug, method); + const idName = method.requires_env.find((n) => n.endsWith("_CLIENT_ID")); + if (!idName) + throw new MissingConnectorEnvError(slug, ["_CLIENT_ID"]); + const secretName = idName.replace(/_CLIENT_ID$/, "_CLIENT_SECRET"); + const clientId = env[idName]; + const clientSecret = env[secretName]; + if (!clientId || !clientSecret) + throw new MissingConnectorEnvError(slug, [idName, secretName]); + return { clientId, clientSecret }; +} + +export interface ResolvedEndpoints { + authorizationEndpoint: string; + tokenEndpoint: string; + clientId: string; + clientSecret?: string; + authentication?: "basic" | "post"; + resource?: string; + pkce: boolean; + issuer?: string; + issuerParameterSupported?: boolean; +} + +export async function resolveEndpoints( + slug: string, + method: ConnectorMethod, + redirectUri: string, +): Promise { + if (method.type !== "oauth2") + throw new Error(`Connector "${slug}" is not an oauth2 method.`); + + if (method.client === "dynamic") { + if (!method.authorization_url) + throw new Error( + `Connector "${slug}" uses a dynamic client but names no authorization_url to discover from.`, + ); + const server = await discoverServer(method.authorization_url); + const identity = await resolveClientIdentity( + slug, + server, + method, + redirectUri, + ); + return { + authorizationEndpoint: server.metadata.authorization_endpoint, + tokenEndpoint: server.metadata.token_endpoint, + clientId: identity.clientId, + ...(identity.clientSecret ? { clientSecret: identity.clientSecret } : {}), + ...(identity.authentication + ? { authentication: identity.authentication } + : {}), + resource: server.resource, + pkce: true, + issuer: server.issuer, + issuerParameterSupported: + server.metadata.authorization_response_iss_parameter_supported === true, + }; + } + + const env = connectorEnv(slug, method); + if (!method.authorization_url || !method.token_url) + throw new Error( + `Connector "${slug}" declares a static client but no authorization_url/token_url.`, + ); + const { clientId, clientSecret } = clientCredentials(slug, method); + return { + authorizationEndpoint: resolveConnectorTemplate(method.authorization_url, { + env, + }), + tokenEndpoint: resolveConnectorTemplate(method.token_url, { env }), + clientId, + clientSecret, + authentication: + method.token_request_auth_method === "client_secret_basic" + ? "basic" + : "post", + pkce: method.pkce, + }; +} + +export function createCodeVerifier(): string { + return randomBytes(32).toString("base64url"); +} + +export interface AuthorizeTarget { + url: string; + codeVerifier: string | null; +} + +export async function authorizeUrl( + slug: string, + method: ConnectorMethod, + options: { redirectUri: string; state: string; codeVerifier?: string | null }, +): Promise { + const env = connectorEnv(slug, method); + + if (method.type === "app_install") + return { + url: resolveConnectorTemplate(method.install_url, { env }), + codeVerifier: null, + }; + + if (method.type === "api_key") + throw new Error(`Connector "${slug}" has no authorize URL.`); + + const endpoints = + method.type === "oauth2" + ? await resolveEndpoints(slug, method, options.redirectUri) + : null; + + const base = + endpoints?.authorizationEndpoint ?? + (method.type === "admin_consent" ? method.consent_url : null); + if (!base) throw new Error(`Connector "${slug}" has no authorize URL.`); + const clientId = + endpoints?.clientId ?? clientCredentials(slug, method).clientId; + + const url = new URL(resolveConnectorTemplate(base, { env })); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", options.redirectUri); + url.searchParams.set("state", options.state); + + let codeVerifier: string | null = null; + if (method.type === "oauth2") { + url.searchParams.set("response_type", "code"); + if (endpoints?.resource) + url.searchParams.set("resource", endpoints.resource); + if (endpoints?.pkce) { + codeVerifier = options.codeVerifier ?? createCodeVerifier(); + url.searchParams.set( + "code_challenge", + await generateCodeChallenge(codeVerifier), + ); + url.searchParams.set("code_challenge_method", "S256"); + } + if (method.scopes.length) + url.searchParams.set( + method.scope_identifier, + method.scopes.join(method.scope_separator), + ); + for (const [key, value] of Object.entries( + method.authorization_params ?? {}, + )) + url.searchParams.set(key, resolveConnectorTemplate(value, { env })); + } else { + url.searchParams.set( + "scope", + method.scopes.join(method.scope_separator ?? " "), + ); + } + + return { url: url.toString(), codeVerifier }; +} + +export interface ConnectorTokens { + accessToken: string; + refreshToken: string | null; + expiresAt: Date | null; + scopes: string[] | null; + stored: Record; + raw: Record; +} + +export class IssuerMismatchError extends Error {} + +/** + * RFC 9207. `state` proves the response is ours; only `iss` proves which + * authorization server answered. A connector whose issuer comes from per-connect + * discovery can otherwise be induced to redeem server A's code at server B. + */ +export function assertRespondingIssuer( + slug: string, + endpoints: ResolvedEndpoints, + iss: string | null, +): void { + if (!endpoints.issuer) return; + if (iss === null) { + if (!endpoints.issuerParameterSupported) return; + throw new IssuerMismatchError( + `Connector "${slug}": ${endpoints.issuer} advertises iss on the authorization response but returned none.`, + ); + } + if (!sameIssuer(iss, endpoints.issuer)) { + throw new IssuerMismatchError( + `Connector "${slug}": authorization response came from "${iss}", not the expected ${endpoints.issuer}.`, + ); + } +} + +export async function exchangeCode( + slug: string, + method: ConnectorMethod, + options: { + code: string; + redirectUri: string; + codeVerifier?: string | null; + params?: Record; + issuer?: string | null; + }, +): Promise { + if (method.type === "api_key") + throw new Error(`Connector "${slug}" uses an api_key, not a code grant.`); + + const endpoints = + method.type === "oauth2" + ? await resolveEndpoints(slug, method, options.redirectUri) + : null; + if (endpoints) + assertRespondingIssuer(slug, endpoints, options.issuer ?? null); + const env = connectorEnv(slug, method); + const { clientId, clientSecret } = endpoints + ? { + clientId: endpoints.clientId, + clientSecret: endpoints.clientSecret ?? "", + } + : clientCredentials(slug, method); + const tokenUrl = + endpoints?.tokenEndpoint ?? + resolveConnectorTemplate(method.token_url ?? "", { + env, + params: options.params, + }); + + const body = new URLSearchParams({ + grant_type: + method.type === "admin_consent" + ? method.grant_type + : "authorization_code", + redirect_uri: options.redirectUri, + }); + if (method.type !== "admin_consent") body.set("code", options.code); + if (options.codeVerifier) body.set("code_verifier", options.codeVerifier); + if (method.type === "oauth2") + for (const [key, value] of Object.entries(method.token_params ?? {})) + body.set(key, value); + if (method.type === "admin_consent") + body.set("scope", method.scopes.join(method.scope_separator ?? " ")); + + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }; + if (endpoints?.resource) body.set("resource", endpoints.resource); + const basic = + endpoints?.authentication === "basic" || + (!endpoints && method.token_request_auth_method === "client_secret_basic"); + if (basic) + headers.Authorization = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`; + else { + body.set("client_id", clientId); + if (clientSecret) body.set("client_secret", clientSecret); + } + + const response = await credentialFetch( + tokenUrl, + { method: "POST", headers, body }, + `Connector "${slug}" token`, + ); + const payload = (await response.json()) as Record; + if (!response.ok) { + if (endpoints?.issuer && payload.error === "invalid_client") + await forgetClient(endpoints.issuer, options.redirectUri, clientId); + throw new Error( + `Connector "${slug}" token exchange failed: ${response.status} ${JSON.stringify(payload).slice(0, 200)}`, + ); + } + + const tokenPath = method.type === "oauth2" ? method.token : "$.access_token"; + const accessToken = readPath(payload, tokenPath); + if (typeof accessToken !== "string" || !accessToken) + throw new Error(`Connector "${slug}" returned no token at ${tokenPath}.`); + + const stored: Record = {}; + if (method.type === "oauth2") + for (const [key, path] of Object.entries(method.store)) + stored[key] = readPath(payload, path); + + const expiresIn = payload.expires_in; + const rawScope = payload.scope; + + return { + accessToken, + refreshToken: + typeof payload.refresh_token === "string" ? payload.refresh_token : null, + expiresAt: + typeof expiresIn === "number" + ? new Date(Date.now() + expiresIn * 1000) + : null, + scopes: + typeof rawScope === "string" + ? rawScope.split( + method.type === "oauth2" ? method.scope_separator : " ", + ) + : null, + stored, + raw: payload, + }; +} + +export interface ConnectorIdentity { + account: { id: string; label: string | null }; + user: { id: string; label: string | null } | null; +} + +export async function probeIdentity( + slug: string, + method: ConnectorMethod, + accessToken: string, + params?: Record, +): Promise { + const env = connectorEnv(slug, method); + const scope = { env, params, config: { access_token: accessToken } }; + const probe = method.identity; + + const url = resolveConnectorTemplate(probe.url, scope); + const headers = Object.fromEntries( + Object.entries(probe.headers ?? {}).map(([key, value]) => [ + key, + resolveConnectorTemplate(value, scope), + ]), + ); + + const response = await credentialFetch( + url, + { + method: probe.method, + headers, + body: probe.body ? JSON.stringify(probe.body) : undefined, + }, + `Connector "${slug}" identity`, + ); + const payload = (await response.json()) as Record; + if (!response.ok) + throw new Error( + `Connector "${slug}" identity probe failed: ${response.status}`, + ); + + const read = (path: string): string | null => { + const value = readPath(payload, path); + return value === undefined || value === null ? null : String(value); + }; + + const accountId = read(probe.account.id); + if (!accountId) + throw new Error( + `Connector "${slug}" identity probe returned nothing at ${probe.account.id}.`, + ); + + const userId = probe.user ? read(probe.user.id) : null; + + return { + account: { + id: accountId, + label: probe.account.label ? read(probe.account.label) : null, + }, + user: + probe.user && userId + ? { + id: userId, + label: probe.user.label ? read(probe.user.label) : null, + } + : null, + }; +} + +export { + decryptOptional, + decryptSecret, + encryptOptional, + encryptSecret, +} from "../../router/plugins/crypto"; +export { + clientMetadataUrl, + forgetClient, + redirectUriFor, +} from "./client-identity"; +export { type DiscoveredServer, discoverServer } from "./discovery"; +export { + AmbiguousConnectionError, + accountConnection, + accountConnections, + type ConnectionLookupOptions, + connectionBotToken, + connectionById, + connectorConnections, + orgConnection, + userConnection, +} from "./lookup"; +export { + ConnectorUnavailableError, + connectionAccessToken, + ensureFreshConnection, + NEEDS_REAUTH, + UnrefreshableConnectionError, +} from "./refresh"; +export { + activeConnection, + type ConnectionConflict, + type ConnectionSecrets, + connectionConflict, + connectionSecrets, + type UpsertConnectionResult, + upsertConnection, +} from "./upsert"; diff --git a/packages/trpc/src/lib/connectors/lookup.ts b/packages/trpc/src/lib/connectors/lookup.ts new file mode 100644 index 00000000000..1c3640e0320 --- /dev/null +++ b/packages/trpc/src/lib/connectors/lookup.ts @@ -0,0 +1,166 @@ +import { db } from "@superset/db/client"; +import { connections, type SelectConnection } from "@superset/db/schema"; +import { and, asc, desc, eq, isNull, type SQL } from "drizzle-orm"; +import { decryptSecret } from "../../router/plugins/crypto"; +import { ensureFreshConnection } from "./refresh"; + +export type ConnectionLookupOptions = { includeDisconnected?: boolean }; + +const NEWEST_FIRST = [desc(connections.updatedAt), desc(connections.id)]; + +/** + * Two live connections match one lookup, so there is no single account to run + * under. Picking the newest would silently bind tool calls to whichever was + * touched last — behaviour people would come to rely on before anyone noticed + * it was arbitrary. Callers surface this as a conflict the user resolves by + * disconnecting one. + */ +export class AmbiguousConnectionError extends Error { + constructor( + readonly connector: string, + readonly connectionIds: string[], + ) { + super( + `More than one ${connector} connection matches; disconnect the one you do not want.`, + ); + this.name = "AmbiguousConnectionError"; + } +} + +/** Two rows are read so a second one can be detected, never to choose between them. */ +function single( + rows: SelectConnection[], + connector: string, +): SelectConnection | null { + if (rows.length > 1) + throw new AmbiguousConnectionError( + connector, + rows.map((row) => row.id), + ); + return rows[0] ?? null; +} + +function live( + options: ConnectionLookupOptions, + clauses: (SQL | undefined)[], +): SQL | undefined { + return and( + ...clauses, + ...(options.includeDisconnected + ? [] + : [isNull(connections.disconnectedAt)]), + ); +} + +export async function orgConnection( + organizationId: string, + connector: string, + options: ConnectionLookupOptions = {}, +): Promise { + const rows = await db + .select() + .from(connections) + .where( + live(options, [ + eq(connections.organizationId, organizationId), + eq(connections.connector, connector), + ]), + ) + .orderBy(...NEWEST_FIRST) + .limit(2); + return single(rows, connector); +} + +export async function userConnection( + organizationId: string, + connector: string, + userId: string, + options: ConnectionLookupOptions = {}, +): Promise { + const rows = await db + .select() + .from(connections) + .where( + live(options, [ + eq(connections.organizationId, organizationId), + eq(connections.connector, connector), + eq(connections.connectedByUserId, userId), + ]), + ) + .orderBy(...NEWEST_FIRST) + .limit(2); + return single(rows, connector); +} + +export async function accountConnection( + connector: string, + externalAccountId: string, + options: ConnectionLookupOptions = {}, +): Promise { + const [row] = await db + .select() + .from(connections) + .where( + live(options, [ + eq(connections.connector, connector), + eq(connections.externalAccountId, externalAccountId), + ]), + ) + .orderBy(...NEWEST_FIRST) + .limit(1); + return row ?? null; +} + +export async function accountConnections( + connector: string, + externalAccountId: string, + options: ConnectionLookupOptions = {}, +): Promise { + return db + .select() + .from(connections) + .where( + live(options, [ + eq(connections.connector, connector), + eq(connections.externalAccountId, externalAccountId), + ]), + ) + .orderBy(asc(connections.id)); +} + +export async function connectorConnections( + connector: string, + options: ConnectionLookupOptions = {}, +): Promise { + return db + .select() + .from(connections) + .where(live(options, [eq(connections.connector, connector)])) + .orderBy(asc(connections.id)); +} + +export async function connectionById( + connectionId: string, + options: ConnectionLookupOptions & { connector?: string } = {}, +): Promise { + const [row] = await db + .select() + .from(connections) + .where( + live(options, [ + eq(connections.id, connectionId), + ...(options.connector + ? [eq(connections.connector, options.connector)] + : []), + ]), + ) + .limit(1); + return row ?? null; +} + +export async function connectionBotToken( + row: SelectConnection, +): Promise { + const fresh = await ensureFreshConnection(row); + return decryptSecret(fresh.config?.bot_token ?? fresh.accessToken); +} diff --git a/packages/trpc/src/lib/connectors/refresh.test.ts b/packages/trpc/src/lib/connectors/refresh.test.ts new file mode 100644 index 00000000000..a77645c5451 --- /dev/null +++ b/packages/trpc/src/lib/connectors/refresh.test.ts @@ -0,0 +1,215 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; + +// A refresh is a token-endpoint call plus what gets written back, so the +// endpoint and the update are the two things stubbed here. `mock.module` is +// process-wide, so each stub lists every export the real module has. +// +// The lookup rule lives here too rather than in its own file: these stubs are +// stateful, and a second file stubbing the same modules would both lose the +// race for whichever registration bun pins and leave its own `rows` binding +// unread by the pinned closure. +// +// `./index`, `./client-identity` and the manifest module are deliberately NOT +// stubbed: a partial stub of any of them pins process-wide and breaks every +// other file that imports the rest. A real registry connector with a static +// client is used instead, so `resolveEndpoints` runs for real and reaches no +// network, and the token endpoint is intercepted at `globalThis.fetch` — the +// same seam `connectors.test.ts` uses. + +let rows: Array> = []; +let stored: Record | null = null; +let updates: Array> = []; +let response: { status: number; ok: boolean; body: unknown } | null = null; +let fetchError: Error | null = null; + +let limits: number[] = []; + +// `refresh` re-reads one row by id; the lookups read two to detect a second. +const selectChain = { + from: () => selectChain, + where: () => selectChain, + orderBy: () => selectChain, + limit: (n: number) => { + limits.push(n); + if (rows.length) return Promise.resolve(rows.slice(0, n)); + return Promise.resolve(stored ? [stored] : []); + }, +}; + +// `where()` is both awaited directly and chained into `returning()`, so it +// answers a real promise carrying that method rather than a hand-rolled +// thenable. +const updateResult = Object.assign(Promise.resolve(undefined), { + returning: () => Promise.resolve([stored]), +}); + +const updateChain = { + set: (values: Record) => { + updates.push(values); + return updateChain; + }, + where: () => updateResult, +}; + +mock.module("@superset/db/client", () => ({ + db: { + select: () => selectChain, + update: () => updateChain, + query: {}, + }, + dbWs: { + transaction: () => Promise.reject(new Error("dbWs is stubbed in tests")), + }, +})); + +mock.module("../../router/plugins/crypto", () => ({ + decryptSecret: (value: string) => Promise.resolve(value), + decryptOptional: (value: string | null) => Promise.resolve(value), + encryptSecret: (value: string) => Promise.resolve(value), + encryptOptional: (value: string | null) => Promise.resolve(value), +})); + +const { + ConnectorUnavailableError, + ensureFreshConnection, + NEEDS_REAUTH, + UnrefreshableConnectionError, +} = await import("./refresh"); +const { AmbiguousConnectionError, orgConnection, userConnection } = + await import("./lookup"); + +const CONNECTOR = "linear"; +const ENV = { LINEAR_CLIENT_ID: "id", LINEAR_CLIENT_SECRET: "secret" }; +const original: Record = {}; +for (const [key, value] of Object.entries(ENV)) { + original[key] = process.env[key]; + process.env[key] = value; +} +afterAll(() => { + for (const key of Object.keys(ENV)) { + if (original[key] === undefined) delete process.env[key]; + else process.env[key] = original[key]; + } +}); + +const EXPIRED = new Date(Date.now() - 60_000); + +function connection(overrides: Record = {}) { + return { + id: "conn-1", + connector: CONNECTOR, + authMethod: "oauth2", + accessToken: "old-access", + refreshToken: "refresh", + tokenExpiresAt: EXPIRED, + config: null, + ...overrides, + } as never; +} + +const realFetch = globalThis.fetch; +afterAll(() => { + globalThis.fetch = realFetch; +}); + +beforeEach(() => { + updates = []; + limits = []; + rows = []; + response = null; + fetchError = null; + stored = connection(); + globalThis.fetch = (async () => { + if (fetchError) throw fetchError; + const current = response; + if (!current) throw new Error("no token-endpoint response configured"); + return new Response(JSON.stringify(current.body), { + status: current.status, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +}); + +describe("a connection that cannot be refreshed", () => { + test("is recorded as needing reauthorization, not only thrown", async () => { + stored = connection({ refreshToken: null }); + + await expect( + ensureFreshConnection(connection({ refreshToken: null })), + ).rejects.toBeInstanceOf(UnrefreshableConnectionError); + + // Without this write the row still reads as connected everywhere. + expect(updates).toHaveLength(1); + expect(updates[0]).toMatchObject({ disconnectReason: NEEDS_REAUTH }); + expect(updates[0]?.disconnectedAt).toBeInstanceOf(Date); + }); + + test("a rejected refresh token is recorded the same way", async () => { + response = { ok: false, status: 400, body: { error: "invalid_grant" } }; + + await expect(ensureFreshConnection(connection())).rejects.toBeInstanceOf( + UnrefreshableConnectionError, + ); + expect(updates[0]).toMatchObject({ disconnectReason: NEEDS_REAUTH }); + }); +}); + +describe("a token endpoint that is merely down", () => { + test("does not disconnect the connection", async () => { + response = { ok: false, status: 503, body: {} }; + + await expect(ensureFreshConnection(connection())).rejects.toBeInstanceOf( + ConnectorUnavailableError, + ); + // Someone else's outage must not cost the user their connection. + expect(updates).toHaveLength(0); + }); + + test("an unreachable endpoint is unavailable, not unrefreshable", async () => { + fetchError = new Error("ECONNREFUSED"); + + const error = await ensureFreshConnection(connection()).catch((e) => e); + expect(error).toBeInstanceOf(ConnectorUnavailableError); + expect(updates).toHaveLength(0); + }); +}); + +describe("a connection that is still valid", () => { + test("is returned without calling the token endpoint", async () => { + const fresh = connection({ + tokenExpiresAt: new Date(Date.now() + 3600_000), + }); + await expect(ensureFreshConnection(fresh)).resolves.toBe(fresh); + expect(updates).toHaveLength(0); + }); +}); + +describe("one account, or an error", () => { + test("a single match resolves", async () => { + rows = [{ id: "conn-1" }]; + await expect( + userConnection("org", "linear", "user"), + ).resolves.toMatchObject({ id: "conn-1" }); + }); + + test("two live rows throw rather than resolving to the newest", async () => { + rows = [{ id: "conn-newer" }, { id: "conn-older" }]; + const error = await userConnection("org", "linear", "user").catch((e) => e); + expect(error).toBeInstanceOf(AmbiguousConnectionError); + expect(error.connector).toBe("linear"); + expect(error.connectionIds).toEqual(["conn-newer", "conn-older"]); + }); + + test("the org lookup is ambiguous on the same terms", async () => { + rows = [{ id: "a" }, { id: "b" }]; + await expect(orgConnection("org", "slack")).rejects.toBeInstanceOf( + AmbiguousConnectionError, + ); + }); + + test("a second row is read, so a second row can be seen at all", async () => { + rows = [{ id: "conn-1" }]; + await userConnection("org", "linear", "user"); + expect(limits).toEqual([2]); + }); +}); diff --git a/packages/trpc/src/lib/connectors/refresh.ts b/packages/trpc/src/lib/connectors/refresh.ts new file mode 100644 index 00000000000..5e10d8c1495 --- /dev/null +++ b/packages/trpc/src/lib/connectors/refresh.ts @@ -0,0 +1,214 @@ +import { db } from "@superset/db/client"; +import { connections, type SelectConnection } from "@superset/db/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { + decryptOptional, + decryptSecret, + encryptOptional, + encryptSecret, +} from "../../router/plugins/crypto"; +import { credentialFetch } from "../../router/plugins/manifest"; +import { forgetClient, redirectUriFor } from "./client-identity"; +import { connectorMethod, requireConnector, resolveEndpoints } from "./index"; + +const DEFAULT_EXPIRY_BUFFER_SECONDS = 60; +export const NEEDS_REAUTH = "needs_reauth"; + +export class UnrefreshableConnectionError extends Error { + constructor(connector: string) { + super( + `The ${connector} connection expired and carries no refresh token; reconnect it.`, + ); + this.name = "UnrefreshableConnectionError"; + } +} + +/** + * The token endpoint was unreachable or answered 5xx. The connection is not + * known to be bad, so it must not be disconnected or reported as needing + * reauthorization — the caller is expected to surface this as a bad gateway. + */ +export class ConnectorUnavailableError extends Error { + constructor( + readonly connector: string, + detail: string, + ) { + super(`The ${connector} token endpoint is unavailable: ${detail}`); + this.name = "ConnectorUnavailableError"; + } +} + +function expiringSoon(expiresAt: Date | null, bufferSeconds: number): boolean { + if (!expiresAt) return false; + return expiresAt.getTime() - Date.now() <= bufferSeconds * 1000; +} + +function live(id: string) { + return and(eq(connections.id, id), isNull(connections.disconnectedAt)); +} + +async function readConnection(id: string): Promise { + const [row] = await db.select().from(connections).where(live(id)).limit(1); + return row ?? null; +} + +/** + * A connection that cannot be refreshed stays a row: marking it here is what + * lets `connectors.status` say "Reconnect" rather than "Connect", instead of + * every caller rediscovering the failure. `upsertConnection` clears both + * fields when the user reconnects. + */ +async function markNeedsReauth(id: string): Promise { + await db + .update(connections) + .set({ disconnectedAt: new Date(), disconnectReason: NEEDS_REAUTH }) + .where(live(id)); +} + +const inFlight = new Map>(); + +export async function ensureFreshConnection( + row: SelectConnection, +): Promise { + const connector = requireConnector(row.connector); + const method = connectorMethod( + connector, + row.authMethod as never as undefined, + ); + if (method.type !== "oauth2") return row; + + const buffer = + method.token_expiration_buffer ?? DEFAULT_EXPIRY_BUFFER_SECONDS; + if (!expiringSoon(row.tokenExpiresAt, buffer)) return row; + + // Concurrent requests for one connection nearly always land in the same + // process, so collapsing them here is what keeps the token endpoint from + // being asked the same question N times. + const pending = inFlight.get(row.id); + if (pending) return await pending; + + const load = refresh(row, buffer).finally(() => { + inFlight.delete(row.id); + }); + inFlight.set(row.id, load); + return await load; +} + +/** + * Deliberately not wrapped in a transaction. The token endpoint is a third + * party on the other side of the internet, and holding a pooled connection + * across that call means one slow identity provider drains the pool for every + * unrelated query in the process. Mutual exclusion comes from the in-flight map + * above within a process, and from the compare-and-swap below across them: a + * refresh that loses the race writes nothing and adopts the winner's row. + */ +async function refresh( + row: SelectConnection, + buffer: number, +): Promise { + const current = await readConnection(row.id); + if (!current) return row; + if (!expiringSoon(current.tokenExpiresAt, buffer)) return current; + + const refreshToken = await decryptOptional(current.refreshToken); + if (!refreshToken) { + await markNeedsReauth(current.id); + throw new UnrefreshableConnectionError(current.connector); + } + + const connector = requireConnector(current.connector); + const method = connectorMethod( + connector, + current.authMethod as never as undefined, + ); + const endpoints = await resolveEndpoints( + current.connector, + method, + redirectUriFor(current.connector), + ); + const { clientId, clientSecret } = endpoints; + + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + }); + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }; + if (endpoints.authentication === "basic") + headers.Authorization = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`; + else { + body.set("client_id", clientId); + if (clientSecret) body.set("client_secret", clientSecret); + } + + let response: Response; + try { + response = await credentialFetch( + endpoints.tokenEndpoint, + { method: "POST", headers, body }, + `Connector "${current.connector}" refresh`, + ); + } catch (error) { + throw new ConnectorUnavailableError( + current.connector, + error instanceof Error ? error.message : String(error), + ); + } + // A server that is failing tells us nothing about the token; treating it as + // expired would disconnect a good connection over someone else's outage. + if (response.status >= 500) { + throw new ConnectorUnavailableError( + current.connector, + `${response.status} ${response.statusText}`, + ); + } + const payload = (await response.json()) as Record; + if (!response.ok || typeof payload.access_token !== "string") { + if (endpoints.issuer && payload.error === "invalid_client") + await forgetClient( + endpoints.issuer, + redirectUriFor(current.connector), + clientId, + ); + await markNeedsReauth(current.id); + throw new UnrefreshableConnectionError(current.connector); + } + + const expiresIn = payload.expires_in; + const [updated] = await db + .update(connections) + .set({ + accessToken: await encryptSecret(payload.access_token), + refreshToken: + typeof payload.refresh_token === "string" + ? await encryptSecret(payload.refresh_token) + : await encryptOptional(refreshToken), + tokenExpiresAt: + typeof expiresIn === "number" + ? new Date(Date.now() + expiresIn * 1000) + : null, + }) + .where( + and( + live(current.id), + current.tokenExpiresAt + ? eq(connections.tokenExpiresAt, current.tokenExpiresAt) + : isNull(connections.tokenExpiresAt), + ), + ) + .returning(); + if (updated) return updated; + + // Zero rows means another process refreshed between our read and our write. + // Its tokens are the live ones; ours are already superseded. + return (await readConnection(current.id)) ?? current; +} + +export async function connectionAccessToken( + row: SelectConnection, +): Promise { + const fresh = await ensureFreshConnection(row); + return await decryptSecret(fresh.accessToken); +} diff --git a/packages/trpc/src/lib/connectors/upsert.ts b/packages/trpc/src/lib/connectors/upsert.ts new file mode 100644 index 00000000000..2b3509d26a5 --- /dev/null +++ b/packages/trpc/src/lib/connectors/upsert.ts @@ -0,0 +1,188 @@ +import { db } from "@superset/db/client"; +import { + connections, + type IntegrationConfig, + type SelectConnection, + users, +} from "@superset/db/schema"; +import type { Connector } from "@superset/shared/connectors"; +import { and, eq, isNull, ne, type SQL, sql } from "drizzle-orm"; +import { + decryptOptional, + decryptSecret, + encryptOptional, + encryptSecret, +} from "../../router/plugins/crypto"; +import type { ConnectorIdentity, ConnectorTokens } from "./index"; +import { userConnection } from "./lookup"; + +export type ConnectionConflict = { ownerEmail: string | null }; + +export async function connectionConflict( + connector: string, + externalAccountId: string, + organizationId: string, +): Promise { + const [conflict] = await db + .select({ email: users.email }) + .from(connections) + .innerJoin(users, eq(users.id, connections.connectedByUserId)) + .where( + and( + eq(connections.connector, connector), + eq(connections.externalAccountId, externalAccountId), + isNull(connections.disconnectedAt), + ne(connections.organizationId, organizationId), + ), + ) + .limit(1); + return conflict ? { ownerEmail: conflict.email } : null; +} + +export type UpsertConnectionResult = + | { conflict: ConnectionConflict } + | { conflict?: undefined; connectionId: string }; + +export async function upsertConnection(input: { + connector: Connector; + slug: string; + authMethod: string; + organizationId: string; + userId: string; + tokens: ConnectorTokens; + identity: ConnectorIdentity; + /** Plaintext per-connection state, written on insert and on reconnect. */ + state?: IntegrationConfig; + /** Overrides `state` on the update path (Google merges sync state in SQL). */ + stateOnUpdate?: SQL; +}): Promise { + const { connector, slug, organizationId, userId, tokens, identity } = input; + + const conflict = await connectionConflict( + slug, + identity.account.id, + organizationId, + ); + if (conflict) return { conflict }; + + const ownerKind = connector.scope; + const target = + ownerKind === "org" + ? { + target: [connections.organizationId, connections.connector], + targetWhere: sql`${connections.ownerKind} = 'org'`, + } + : { + target: [ + connections.organizationId, + connections.connector, + connections.connectedByUserId, + connections.externalAccountId, + ], + targetWhere: sql`${connections.ownerKind} = 'user'`, + }; + + const accessToken = await encryptSecret(tokens.accessToken); + const refreshToken = await encryptOptional(tokens.refreshToken); + const config = await encryptStored(tokens.stored); + + const [row] = await db + .insert(connections) + .values({ + organizationId, + connectedByUserId: userId, + connector: slug, + ownerKind, + authMethod: input.authMethod, + accessToken, + refreshToken, + tokenExpiresAt: tokens.expiresAt, + scopes: tokens.scopes, + externalAccountId: identity.account.id, + externalAccountLabel: identity.account.label, + externalUserId: identity.user?.id ?? null, + externalUserLabel: identity.user?.label ?? null, + config, + state: input.state ?? null, + disconnectedAt: null, + disconnectReason: null, + }) + .onConflictDoUpdate({ + ...target, + set: { + accessToken, + refreshToken, + tokenExpiresAt: tokens.expiresAt, + scopes: tokens.scopes, + externalAccountLabel: identity.account.label, + externalUserId: identity.user?.id ?? null, + externalUserLabel: identity.user?.label ?? null, + config, + ...(input.stateOnUpdate + ? { state: input.stateOnUpdate } + : input.state !== undefined + ? { state: input.state } + : {}), + disconnectedAt: null, + disconnectReason: null, + }, + }) + .returning({ id: connections.id }); + + if (!row) return { conflict: { ownerEmail: null } }; + return { connectionId: row.id }; +} + +async function encryptStored( + stored: Record, +): Promise | null> { + const keys = Object.keys(stored); + if (!keys.length) return null; + const entries = await Promise.all( + keys.map(async (key) => { + const value = stored[key]; + return [ + key, + value === undefined || value === null + ? null + : await encryptSecret(String(value)), + ] as const; + }), + ); + return Object.fromEntries(entries); +} + +export interface ConnectionSecrets { + accessToken: string; + refreshToken: string | null; + config: Record; +} + +export async function connectionSecrets(row: { + accessToken: string; + refreshToken: string | null; + config: unknown; +}): Promise { + const stored = (row.config ?? {}) as Record; + const entries = await Promise.all( + Object.entries(stored).map( + async ([key, value]) => [key, await decryptOptional(value)] as const, + ), + ); + return { + accessToken: await decryptSecret(row.accessToken), + refreshToken: await decryptOptional(row.refreshToken), + config: Object.fromEntries(entries), + }; +} + +export async function activeConnection( + userId: string, + connector: string, + organizationId: string | null, +): Promise { + if (!organizationId) return null; + // Same lookup as `userConnection`, and it must stay the same: two live rows + // for one account is a conflict wherever it is found, not a pick. + return userConnection(organizationId, connector, userId); +} diff --git a/packages/trpc/src/lib/integrations/linear/index.ts b/packages/trpc/src/lib/integrations/linear/index.ts index d6265ea9997..1667287e4e4 100644 --- a/packages/trpc/src/lib/integrations/linear/index.ts +++ b/packages/trpc/src/lib/integrations/linear/index.ts @@ -1,5 +1,6 @@ export { callLinear, + callLinearForConnection, isLinearAuthError, type LinearTokenResponse, linearTokenResponseSchema, @@ -7,6 +8,7 @@ export { } from "../../../router/integration/linear/refresh"; export { getLinearClient, + linearClientFor, mapPriorityFromLinear, mapPriorityToLinear, } from "../../../router/integration/linear/utils"; diff --git a/packages/trpc/src/lib/integrations/plugins/index.ts b/packages/trpc/src/lib/integrations/plugins/index.ts index a50d734b718..33d5dfb66cf 100644 --- a/packages/trpc/src/lib/integrations/plugins/index.ts +++ b/packages/trpc/src/lib/integrations/plugins/index.ts @@ -1,27 +1,14 @@ export { AmbiguousPluginError, - type ConnectionContext, installedManifest, installedPlugin, - manifestAuth, - templateScope, - toolConnections, - upsertConnection, } from "../../../router/plugins/connections"; export { - callTool, - type DispatchOptions, - listTools, - PluginDispatchError, - type ToolDefinition, -} from "../../../router/plugins/dispatch"; + decryptSecret, + encryptSecret, +} from "../../../router/plugins/crypto"; export { - authMethod, DEFAULT_CREDENTIAL_INPUT, + pluginConnector, trustedManifest, } from "../../../router/plugins/manifest"; -export { - buildAuthorizationUrl, - exchangeCode, - resolveIdentity, -} from "../../../router/plugins/oauth"; diff --git a/packages/trpc/src/lib/integrations/sync/tasks.ts b/packages/trpc/src/lib/integrations/sync/tasks.ts index d749dbfb6e3..83ae3e7fde6 100644 --- a/packages/trpc/src/lib/integrations/sync/tasks.ts +++ b/packages/trpc/src/lib/integrations/sync/tasks.ts @@ -1,5 +1,5 @@ import { db } from "@superset/db/client"; -import { integrationConnections, tasks } from "@superset/db/schema"; +import { connections, tasks } from "@superset/db/schema"; import { Client } from "@upstash/qstash"; import { eq } from "drizzle-orm"; import { env } from "../../../env"; @@ -20,18 +20,18 @@ export async function syncTask(taskId: string) { throw new Error("Task not found"); } - const connections = await db.query.integrationConnections.findMany({ - where: eq(integrationConnections.organizationId, task.organizationId), - columns: { provider: true }, - }); + const connected = await db + .selectDistinct({ connector: connections.connector }) + .from(connections) + .where(eq(connections.organizationId, task.organizationId)); const qstashBaseUrl = env.NEXT_PUBLIC_API_URL; const results = await Promise.allSettled( - connections.map(async (conn) => { - const endpoint = PROVIDER_ENDPOINTS[conn.provider]; + connected.map(async (conn) => { + const endpoint = PROVIDER_ENDPOINTS[conn.connector]; if (!endpoint) { - return { provider: conn.provider, skipped: true }; + return { provider: conn.connector, skipped: true }; } const syncUrl = `${qstashBaseUrl}${endpoint}`; @@ -42,7 +42,7 @@ export async function syncTask(taskId: string) { retries: 3, }); - return { provider: conn.provider, queued: true }; + return { provider: conn.connector, queued: true }; }), ); diff --git a/packages/trpc/src/lib/secret-box.ts b/packages/trpc/src/lib/secret-box.ts index 3013943f876..54745e1e18a 100644 --- a/packages/trpc/src/lib/secret-box.ts +++ b/packages/trpc/src/lib/secret-box.ts @@ -1,13 +1,16 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; -import { env } from "../env"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; const AUTH_TAG_LENGTH = 16; const VERSION = 1; +export function secretsKeyConfigured(): boolean { + return Boolean(process.env.SECRETS_ENCRYPTION_KEY); +} + function getKey(): Buffer { - const raw = env.SECRETS_ENCRYPTION_KEY; + const raw = process.env.SECRETS_ENCRYPTION_KEY; if (!raw) throw new Error("SECRETS_ENCRYPTION_KEY not set"); const key = Buffer.from(raw, "base64"); if (key.length !== 32) diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index e9110be535b..9ccd4cd7397 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -11,6 +11,7 @@ import { automationRouter } from "./router/automation"; import { billingRouter } from "./router/billing"; import { chatRouter } from "./router/chat"; import { cloudWorkspaceRouter } from "./router/cloud-workspace"; +import { connectorsRouter } from "./router/connectors"; import { environmentRouter } from "./router/environment"; import { githubUserRouter } from "./router/github-user"; import { hostManagementRouter, hostRouter } from "./router/host"; @@ -41,6 +42,7 @@ export const appRouter = createTRPCRouter({ environment: environmentRouter, growth: growthRouter, host: { ...hostRouter, ...hostManagementRouter }, + connectors: connectorsRouter, integration: integrationRouter, leaderboard: leaderboardRouter, organization: organizationRouter, diff --git a/packages/trpc/src/router/automation/automation.ts b/packages/trpc/src/router/automation/automation.ts index 42cfa08825f..da60f91f2b0 100644 --- a/packages/trpc/src/router/automation/automation.ts +++ b/packages/trpc/src/router/automation/automation.ts @@ -457,7 +457,11 @@ export const automationRouter = { // trigger set may describe a different schedule, or none at all. // After the commit: joining can only make a saved trigger start working. if (input.triggers) { - await joinSlackTriggerChannels(organizationId, input.triggers); + await joinSlackTriggerChannels( + organizationId, + ctx.session.user.id, + input.triggers, + ); } return withSchedule(created, input.triggers ?? null, legacySchedule); @@ -652,7 +656,11 @@ export const automationRouter = { }); if (input.triggers) { - await joinSlackTriggerChannels(organizationId, input.triggers); + await joinSlackTriggerChannels( + organizationId, + ctx.session.user.id, + input.triggers, + ); } // Same as create: a trigger set may have replaced or removed the diff --git a/packages/trpc/src/router/connectors/index.ts b/packages/trpc/src/router/connectors/index.ts new file mode 100644 index 00000000000..10c146de3c8 --- /dev/null +++ b/packages/trpc/src/router/connectors/index.ts @@ -0,0 +1,222 @@ +import { db } from "@superset/db/client"; +import { connections } from "@superset/db/schema"; +import { + CONNECTOR_SLUGS, + type ConnectorMethod, + getConnector, +} from "@superset/shared/connectors"; +import type { TRPCRouterRecord } from "@trpc/server"; +import { TRPCError } from "@trpc/server"; +import { and, eq, isNull, or } from "drizzle-orm"; +import { z } from "zod"; +import { + connectorMethod, + NEEDS_REAUTH, + probeIdentity, + requireConnector, + upsertConnection, +} from "../../lib/connectors"; +import { protectedProcedure } from "../../trpc"; +import { verifyOrgAdmin, verifyOrgMembership } from "../integration/utils"; +import { forgetUpstreamTools } from "../plugins/proxy"; + +function methodSummary(method: ConnectorMethod) { + return { + type: method.type, + label: method.label ?? method.type, + inputs: method.type === "api_key" ? method.inputs : [], + }; +} + +export const connectorsRouter = { + catalog: protectedProcedure.query(() => + CONNECTOR_SLUGS.map((slug) => { + const connector = requireConnector(slug); + return { + slug, + displayName: connector.displayName, + icon: connector.icon, + category: connector.category, + scope: connector.scope, + methods: connector.methods.map(methodSummary), + }; + }), + ), + + get: protectedProcedure + .input(z.object({ slug: z.string() })) + .query(({ input }) => { + const connector = getConnector(input.slug); + if (!connector) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Unknown connector", + }); + return { + slug: input.slug, + displayName: connector.displayName, + icon: connector.icon, + category: connector.category, + scope: connector.scope, + methods: connector.methods.map(methodSummary), + }; + }), + + status: protectedProcedure + .input(z.object({ organizationId: z.uuid() })) + .query(async ({ ctx, input }) => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); + + // A connection whose refresh failed is still the user's connection: + // dropping it here would offer "Connect" for an account they already + // linked, losing the distinction between never-connected and expired. + const rows = await db.query.connections.findMany({ + where: and( + eq(connections.organizationId, input.organizationId), + or( + isNull(connections.disconnectedAt), + eq(connections.disconnectReason, NEEDS_REAUTH), + ), + ), + columns: { + id: true, + connector: true, + ownerKind: true, + connectedByUserId: true, + externalAccountId: true, + externalAccountLabel: true, + externalUserId: true, + externalUserLabel: true, + disconnectedAt: true, + disconnectReason: true, + }, + }); + + return rows + .filter( + (row) => + row.ownerKind === "org" || + row.connectedByUserId === ctx.session.user.id, + ) + .map(({ disconnectedAt, disconnectReason, ...row }) => ({ + ...row, + needsReauth: disconnectedAt !== null, + })); + }), + + connectApiKey: protectedProcedure + .input( + z.object({ + organizationId: z.uuid(), + slug: z.string(), + inputs: z.record(z.string(), z.string()), + }), + ) + .mutation(async ({ ctx, input }) => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); + + const connector = requireConnector(input.slug); + const method = connectorMethod(connector, "api_key"); + if (method.type !== "api_key") + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This connector has no API key method", + }); + + for (const field of method.inputs) + if (field.required && !input.inputs[field.name]) + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${field.label ?? field.name} is required`, + }); + + const credential = input.inputs[method.credential_input]; + if (!credential) + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Missing ${method.credential_input}`, + }); + + const identity = await probeIdentity(input.slug, method, credential); + + const result = await upsertConnection({ + connector, + slug: input.slug, + authMethod: "api_key", + organizationId: input.organizationId, + userId: ctx.session.user.id, + tokens: { + accessToken: credential, + refreshToken: null, + expiresAt: null, + scopes: null, + stored: {}, + raw: {}, + }, + identity, + }); + + if (result.conflict) + throw new TRPCError({ + code: "CONFLICT", + message: result.conflict.ownerEmail + ? `Already connected by ${result.conflict.ownerEmail}` + : "Already connected in another organization", + }); + + return { connectionId: result.connectionId }; + }), + + disconnect: protectedProcedure + .input(z.object({ organizationId: z.uuid(), connectionId: z.uuid() })) + .mutation(async ({ ctx, input }) => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); + + // `status` still lists a needs-reauth row, so discarding one has to be + // reachable from the same screen that offers to reconnect it. + const reachable = or( + isNull(connections.disconnectedAt), + eq(connections.disconnectReason, NEEDS_REAUTH), + ); + + const [existing] = await db + .select({ + ownerKind: connections.ownerKind, + connectedByUserId: connections.connectedByUserId, + }) + .from(connections) + .where( + and( + eq(connections.id, input.connectionId), + eq(connections.organizationId, input.organizationId), + reachable, + ), + ) + .limit(1); + + if (!existing) + throw new TRPCError({ code: "NOT_FOUND", message: "No connection" }); + + if (existing.ownerKind === "org") + await verifyOrgAdmin(ctx.session.user.id, input.organizationId); + else if (existing.connectedByUserId !== ctx.session.user.id) + throw new TRPCError({ code: "NOT_FOUND", message: "No connection" }); + + const [row] = await db + .update(connections) + .set({ disconnectedAt: new Date(), disconnectReason: "user" }) + .where( + and( + eq(connections.id, input.connectionId), + eq(connections.organizationId, input.organizationId), + reachable, + ), + ) + .returning({ id: connections.id }); + + if (!row) + throw new TRPCError({ code: "NOT_FOUND", message: "No connection" }); + forgetUpstreamTools(row.id); + return { disconnected: row.id }; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/connection-status.ts b/packages/trpc/src/router/integration/connection-status.ts index 7be8d017d9a..f6c5f592bb1 100644 --- a/packages/trpc/src/router/integration/connection-status.ts +++ b/packages/trpc/src/router/integration/connection-status.ts @@ -1,10 +1,9 @@ import { db } from "@superset/db/client"; -import { - githubInstallations, - integrationConnections, -} from "@superset/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; +import { connections, githubInstallations } from "@superset/db/schema"; +import { getConnector } from "@superset/shared/connectors"; +import { and, eq, isNull, or } from "drizzle-orm"; import { z } from "zod"; +import { NEEDS_REAUTH } from "../../lib/connectors"; import { protectedProcedure } from "../../trpc"; import { verifyOrgMembership } from "./utils"; @@ -13,52 +12,67 @@ import { verifyOrgMembership } from "./utils"; * * One procedure rather than the seven per-provider queries the settings pane * makes, because the trigger editor asks on every render of every row and - * polls while the page is open. Three queries answer all of them: the - * organization's live connections, the caller's own Google connection, and the - * GitHub installation, which lives in its own table. + * polls while the page is open. Two queries answer all of them: the + * organization's live connections, and the GitHub installation, which lives in + * its own table. * * "Connected" means the same thing here as everywhere else — a row marked - * disconnected is not connected — so this stays in step with - * `activeConnection` and the per-provider `getConnection` procedures. + * disconnected is not connected — so this stays in step with the per-provider + * `getConnection` procedures. A connection whose refresh failed is reported + * with `needsReauth`, because a trigger built on one will not fire until the + * user reconnects, and "not connected" would send them to the wrong button. */ +export interface ProviderConnection { + connected: boolean; + needsReauth: boolean; +} + export const connectionStatusProcedure = protectedProcedure .input(z.object({ organizationId: z.uuid() })) - .query(async ({ ctx, input }): Promise> => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); + .query( + async ({ ctx, input }): Promise> => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); - const [orgConnections, googleConnection, installation] = await Promise.all([ - db.query.integrationConnections.findMany({ - where: and( - eq(integrationConnections.organizationId, input.organizationId), - isNull(integrationConnections.disconnectedAt), - ), - columns: { provider: true }, - }), - // Google is per member: another member's mailbox is not this caller's - // to trigger on, so their connection must not read as connected here. - db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "google"), - eq(integrationConnections.connectedByUserId, ctx.session.user.id), - isNull(integrationConnections.disconnectedAt), - ), - columns: { id: true }, - }), - db.query.githubInstallations.findFirst({ - where: eq(githubInstallations.organizationId, input.organizationId), - columns: { suspended: true }, - }), - ]); + const [connectorRows, installation] = await Promise.all([ + db.query.connections.findMany({ + where: and( + eq(connections.organizationId, input.organizationId), + or( + isNull(connections.disconnectedAt), + eq(connections.disconnectReason, NEEDS_REAUTH), + ), + ), + columns: { + connector: true, + connectedByUserId: true, + disconnectedAt: true, + }, + }), + db.query.githubInstallations.findFirst({ + where: eq(githubInstallations.organizationId, input.organizationId), + columns: { suspended: true }, + }), + ]); - const connected: Record = {}; - for (const row of orgConnections) connected[row.provider] = true; + const connected: Record = {}; + for (const row of connectorRows) { + if ( + getConnector(row.connector)?.scope === "user" && + row.connectedByUserId !== ctx.session.user.id + ) + continue; + const needsReauth = row.disconnectedAt !== null; + // A live row wins over an expired one for the same connector. + if (connected[row.connector]?.connected && needsReauth) continue; + connected[row.connector] = { connected: !needsReauth, needsReauth }; + } - // Both overrides narrow rather than widen: the org-wide scan above would - // otherwise report someone else's Google account as this caller's. - connected.google = googleConnection !== undefined; - // A suspended installation still has a row, and delivers nothing. - connected.github = installation !== undefined && !installation.suspended; + // A suspended installation still has a row, and delivers nothing. + connected.github = { + connected: installation !== undefined && !installation.suspended, + needsReauth: false, + }; - return connected; - }); + return connected; + }, + ); diff --git a/packages/trpc/src/router/integration/connections.ts b/packages/trpc/src/router/integration/connections.ts index 0d01743288c..7a73279dc38 100644 --- a/packages/trpc/src/router/integration/connections.ts +++ b/packages/trpc/src/router/integration/connections.ts @@ -1,86 +1,70 @@ import { db } from "@superset/db/client"; -import { - type IntegrationProvider, - integrationConnections, - type SelectIntegrationConnection, -} from "@superset/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; +import { connections, type SelectConnection } from "@superset/db/schema"; +import { getConnector } from "@superset/shared/connectors"; +import { and, eq } from "drizzle-orm"; import { z } from "zod"; +import { orgConnection, userConnection } from "../../lib/connectors"; import { protectedProcedure } from "../../trpc"; import { verifyOrgAdmin, verifyOrgMembership } from "./utils"; -type ConnectionColumns = Partial< - Record ->; - -type Selected = Pick< - SelectIntegrationConnection, - Extract ->; - -/** - * The organization's live connection for a provider. "Is it connected" has one - * answer everywhere: a row marked disconnected is not. - */ -export async function activeConnection( - organizationId: string, - provider: IntegrationProvider, - columns: C, -): Promise | undefined> { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, organizationId), - eq(integrationConnections.provider, provider), - isNull(integrationConnections.disconnectedAt), - ), - columns, - }); - return connection as Selected | undefined; -} - -export function getConnectionProcedure( - provider: IntegrationProvider, - columns: C, - present: (connection: Selected) => R, +export function getConnectionProcedure( + connector: string, + present: (connection: SelectConnection) => R, ) { return protectedProcedure .input(z.object({ organizationId: z.uuid() })) .query(async ({ ctx, input }): Promise => { await verifyOrgMembership(ctx.session.user.id, input.organizationId); - const connection = await activeConnection( - input.organizationId, - provider, - columns, - ); + const connection = + getConnector(connector)?.scope === "user" + ? await userConnection( + input.organizationId, + connector, + ctx.session.user.id, + ) + : await orgConnection(input.organizationId, connector); return connection ? present(connection) : null; }); } export function disconnectProcedure( - provider: IntegrationProvider, + connector: string, before?: (connectionId: string, organizationId: string) => Promise, + beforeAll?: (organizationId: string) => Promise, ) { return protectedProcedure .input(z.object({ organizationId: z.uuid() })) .mutation(async ({ ctx, input }) => { await verifyOrgAdmin(ctx.session.user.id, input.organizationId); - // Not activeConnection: a needs-reconnect row must still be removable. - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, provider), - ), - columns: { id: true }, - }); - if (!connection) { + // Every row, disconnected ones included: a needs-reconnect row must + // still be removable, and a user-scoped connector can hold one per + // member. + const rows = await db + .select({ id: connections.id }) + .from(connections) + .where( + and( + eq(connections.organizationId, input.organizationId), + eq(connections.connector, connector), + ), + ); + if (rows.length === 0) { return { success: false, error: "No connection found" }; } - if (before) await before(connection.id, input.organizationId); + if (before) { + for (const row of rows) await before(row.id, input.organizationId); + } + if (beforeAll) await beforeAll(input.organizationId); await db - .delete(integrationConnections) - .where(eq(integrationConnections.id, connection.id)); + .delete(connections) + .where( + and( + eq(connections.organizationId, input.organizationId), + eq(connections.connector, connector), + ), + ); return { success: true }; }); diff --git a/packages/trpc/src/router/integration/google/auth.ts b/packages/trpc/src/router/integration/google/auth.ts index ebced2b37d6..f0224d10379 100644 --- a/packages/trpc/src/router/integration/google/auth.ts +++ b/packages/trpc/src/router/integration/google/auth.ts @@ -1,11 +1,17 @@ import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; +import { connections } from "@superset/db/schema"; import { withConnectionLock } from "@superset/db/utils"; import { eq } from "drizzle-orm"; import { GaxiosError, type GaxiosResponse } from "gaxios"; import { type Credentials, OAuth2Client } from "google-auth-library"; import { z } from "zod"; import { env } from "../../../env"; +import { + decryptOptional, + decryptSecret, + encryptOptional, + encryptSecret, +} from "../../../lib/connectors"; import { REFRESH_BUFFER_MS, REFRESH_TOKEN_TIMEOUT_MS } from "./constants"; export const googleTokenResponseSchema = z.object({ @@ -48,32 +54,34 @@ class ConnectionOAuth2Client extends OAuth2Client { protected override async refreshTokenNoCache(): Promise { return withConnectionLock(this.connectionId, async (tx) => { - const [connection] = await tx + const [row] = await tx .select({ - accessToken: integrationConnections.accessToken, - refreshToken: integrationConnections.refreshToken, - tokenExpiresAt: integrationConnections.tokenExpiresAt, - disconnectedAt: integrationConnections.disconnectedAt, + accessToken: connections.accessToken, + refreshToken: connections.refreshToken, + tokenExpiresAt: connections.tokenExpiresAt, + disconnectedAt: connections.disconnectedAt, }) - .from(integrationConnections) - .where(eq(integrationConnections.id, this.connectionId)) + .from(connections) + .where(eq(connections.id, this.connectionId)) .limit(1); - if (!connection?.refreshToken || connection.disconnectedAt) { + if (!row?.refreshToken || row.disconnectedAt) { throw new Error(`Google connection ${this.connectionId} disconnected`); } + const accessToken = await decryptSecret(row.accessToken); + const refreshToken = await decryptSecret(row.refreshToken); // Another caller refreshed while this one waited on the lock: its // token is stored and fresh, so use it instead of refreshing again. if ( - connection.accessToken !== this.credentials.access_token && - connection.tokenExpiresAt && - connection.tokenExpiresAt.getTime() > Date.now() + REFRESH_BUFFER_MS + accessToken !== this.credentials.access_token && + row.tokenExpiresAt && + row.tokenExpiresAt.getTime() > Date.now() + REFRESH_BUFFER_MS ) { return { tokens: { - access_token: connection.accessToken, - refresh_token: connection.refreshToken, - expiry_date: connection.tokenExpiresAt.getTime(), + access_token: accessToken, + refresh_token: refreshToken, + expiry_date: row.tokenExpiresAt.getTime(), }, res: null, }; @@ -81,18 +89,18 @@ class ConnectionOAuth2Client extends OAuth2Client { let response: GetTokenResponse; try { - response = await super.refreshTokenNoCache(connection.refreshToken); + response = await super.refreshTokenNoCache(refreshToken); } catch (error) { // The grant was revoked (or the app's access removed in the account // settings). Nothing here can recover it; the person has to reconnect. if (isInvalidGrant(error)) { await tx - .update(integrationConnections) + .update(connections) .set({ disconnectedAt: new Date(), disconnectReason: "invalid_grant", }) - .where(eq(integrationConnections.id, this.connectionId)); + .where(eq(connections.id, this.connectionId)); } throw error; } @@ -100,17 +108,19 @@ class ConnectionOAuth2Client extends OAuth2Client { const { tokens } = response; if (tokens.access_token) { await tx - .update(integrationConnections) + .update(connections) .set({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token ?? connection.refreshToken, + accessToken: await encryptSecret(tokens.access_token), + refreshToken: await encryptOptional( + tokens.refresh_token ?? refreshToken, + ), tokenExpiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : null, disconnectedAt: null, disconnectReason: null, }) - .where(eq(integrationConnections.id, this.connectionId)); + .where(eq(connections.id, this.connectionId)); } return response; }); @@ -125,43 +135,44 @@ function isInvalidGrant(error: unknown): boolean { /** * An authenticated client for one connection, its tokens loaded from - * `integration_connections`. Throws when the connection is disconnected or, + * `connections` and decrypted. Throws when the connection is disconnected or, * with no refresh token, its access token is about to expire. */ export async function googleAuthFor( connectionId: string, ): Promise { - const connection = await db.query.integrationConnections.findFirst({ - where: eq(integrationConnections.id, connectionId), - columns: { - accessToken: true, - refreshToken: true, - tokenExpiresAt: true, - disconnectedAt: true, - }, - }); - if (!connection || connection.disconnectedAt) { + const [row] = await db + .select({ + accessToken: connections.accessToken, + refreshToken: connections.refreshToken, + tokenExpiresAt: connections.tokenExpiresAt, + disconnectedAt: connections.disconnectedAt, + }) + .from(connections) + .where(eq(connections.id, connectionId)) + .limit(1); + if (!row || row.disconnectedAt) { throw new Error(`Google connection ${connectionId} disconnected`); } const expiresSoon = - !connection.tokenExpiresAt || - connection.tokenExpiresAt.getTime() - Date.now() < REFRESH_BUFFER_MS; - if (!connection.refreshToken && expiresSoon) { + !row.tokenExpiresAt || + row.tokenExpiresAt.getTime() - Date.now() < REFRESH_BUFFER_MS; + if (!row.refreshToken && expiresSoon) { await db - .update(integrationConnections) + .update(connections) .set({ disconnectedAt: new Date(), disconnectReason: "no_refresh_token" }) - .where(eq(integrationConnections.id, connectionId)); + .where(eq(connections.id, connectionId)); throw new Error(`Google connection ${connectionId} disconnected`); } const client = new ConnectionOAuth2Client(connectionId); client.setCredentials({ - access_token: connection.accessToken, - refresh_token: connection.refreshToken, + access_token: await decryptSecret(row.accessToken), + refresh_token: await decryptOptional(row.refreshToken), // A row without an expiry cannot be trusted; 1 rather than 0, which the // SDK reads as "never expires", forces a refresh before first use. - expiry_date: connection.tokenExpiresAt?.getTime() ?? 1, + expiry_date: row.tokenExpiresAt?.getTime() ?? 1, }); return client; } diff --git a/packages/trpc/src/router/integration/google/google.ts b/packages/trpc/src/router/integration/google/google.ts index e3f43b857db..6a7f6d07930 100644 --- a/packages/trpc/src/router/integration/google/google.ts +++ b/packages/trpc/src/router/integration/google/google.ts @@ -1,8 +1,13 @@ import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; +import { connections } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; +import { + decryptOptional, + decryptSecret, + userConnection, +} from "../../../lib/connectors"; import { protectedProcedure } from "../../../trpc"; import { verifyOrgMembership } from "../utils"; import { stopChannel } from "./calendar"; @@ -17,28 +22,19 @@ export const googleRouter = { // The caller's own connection: Google is per member, so another // member's account is not this person's to see or manage. - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "google"), - eq(integrationConnections.connectedByUserId, ctx.session.user.id), - ), - columns: { - id: true, - externalOrgId: true, - connectedByUserId: true, - disconnectedAt: true, - disconnectReason: true, - createdAt: true, - }, - }); + const connection = await userConnection( + input.organizationId, + "google", + ctx.session.user.id, + { includeDisconnected: true }, + ); if (!connection) return null; return { id: connection.id, // The Google account's address; the connection is that person's // calendars and mailbox, not the organization's. - email: connection.externalOrgId, + email: connection.externalAccountId, connectedByUserId: connection.connectedByUserId, connectedAt: connection.createdAt, needsReconnect: connection.disconnectedAt !== null, @@ -58,12 +54,15 @@ export const googleRouter = { // Best effort: Google keeps pushing to a channel until it is stopped // or expires, and the push route would only reject those with a // missing connection. A failure here must not block the disconnect. - const config = googleConfigOf(connection.config); + const config = googleConfigOf(connection.state); await Promise.allSettled([ // The grant outlives the row unless it is revoked: without this // the refresh token would be gone from our side and the app would // still hold read access on Google's. - revokeGrant(connection.refreshToken ?? connection.accessToken), + revokeGrant( + (await decryptOptional(connection.refreshToken)) ?? + (await decryptSecret(connection.accessToken)), + ), ...Object.values(config.calendars ?? {}).flatMap((state) => state.channelId && state.resourceId ? [ @@ -81,15 +80,15 @@ export const googleRouter = { } const result = await db - .delete(integrationConnections) + .delete(connections) .where( and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "google"), - eq(integrationConnections.connectedByUserId, ctx.session.user.id), + eq(connections.organizationId, input.organizationId), + eq(connections.connector, "google"), + eq(connections.connectedByUserId, ctx.session.user.id), ), ) - .returning({ id: integrationConnections.id }); + .returning({ id: connections.id }); if (result.length === 0) { return { success: false, error: "No connection found" }; diff --git a/packages/trpc/src/router/integration/google/state.ts b/packages/trpc/src/router/integration/google/state.ts index ed599b0ff23..af75cd1fb79 100644 --- a/packages/trpc/src/router/integration/google/state.ts +++ b/packages/trpc/src/router/integration/google/state.ts @@ -1,22 +1,24 @@ import { db } from "@superset/db/client"; import { + connections, type GoogleCalendarWatchState, type GoogleConfig, - integrationConnections, + type SelectConnection, } from "@superset/db/schema"; -import { and, eq, isNull, sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; +import { connectionById, userConnection } from "../../../lib/connectors"; /** - * The per-connection sync state lives in `integration_connections.config`. - * Every write here is a jsonb merge in SQL rather than a read-modify-write in - * JavaScript, because two calendars on one connection can be syncing at once - * and the second write must not undo the first. + * The per-connection sync state lives in `connections.state`, plaintext beside + * the encrypted `config`. Every write here is a jsonb merge in SQL rather than + * a read-modify-write in JavaScript, because two calendars on one connection + * can be syncing at once and the second write must not undo the first. */ -export function googleConfigOf(config: unknown): GoogleConfig { - if (config && typeof config === "object" && "provider" in config) { - const candidate = config as { provider?: string }; - if (candidate.provider === "google") return config as GoogleConfig; +export function googleConfigOf(state: unknown): GoogleConfig { + if (state && typeof state === "object" && "provider" in state) { + const candidate = state as { provider?: string }; + if (candidate.provider === "google") return state as GoogleConfig; } return { provider: "google" }; } @@ -28,29 +30,20 @@ export function googleConfigOf(config: unknown): GoogleConfig { export async function findGoogleConnection( organizationId: string, userId: string, -) { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, organizationId), - eq(integrationConnections.provider, "google"), - eq(integrationConnections.connectedByUserId, userId), - isNull(integrationConnections.disconnectedAt), - ), - }); - return connection ?? null; +): Promise { + return userConnection(organizationId, "google", userId); } -export async function findGoogleConnectionById(connectionId: string) { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.id, connectionId), - eq(integrationConnections.provider, "google"), - ), +export async function findGoogleConnectionById( + connectionId: string, +): Promise { + return connectionById(connectionId, { + connector: "google", + includeDisconnected: true, }); - return connection ?? null; } -/** Merges `patch` into `config.calendars[calendarId]`, creating as needed. */ +/** Merges `patch` into `state.calendars[calendarId]`, creating as needed. */ export async function patchCalendarState( connectionId: string, calendarId: string, @@ -58,19 +51,19 @@ export async function patchCalendarState( ): Promise { const json = JSON.stringify(patch); await db - .update(integrationConnections) + .update(connections) .set({ - config: sql`jsonb_set( + state: sql`jsonb_set( jsonb_set( - coalesce(${integrationConnections.config}, '{}'::jsonb) || '{"provider":"google"}'::jsonb, + coalesce(${connections.state}, '{}'::jsonb) || '{"provider":"google"}'::jsonb, '{calendars}', - coalesce(${integrationConnections.config} -> 'calendars', '{}'::jsonb) + coalesce(${connections.state} -> 'calendars', '{}'::jsonb) ), ARRAY['calendars', ${calendarId}]::text[], - coalesce(${integrationConnections.config} #> ARRAY['calendars', ${calendarId}]::text[], '{}'::jsonb) || ${json}::jsonb + coalesce(${connections.state} #> ARRAY['calendars', ${calendarId}]::text[], '{}'::jsonb) || ${json}::jsonb )`, }) - .where(eq(integrationConnections.id, connectionId)); + .where(eq(connections.id, connectionId)); } export async function removeCalendarState( @@ -78,11 +71,11 @@ export async function removeCalendarState( calendarId: string, ): Promise { await db - .update(integrationConnections) + .update(connections) .set({ - config: sql`${integrationConnections.config} #- ARRAY['calendars', ${calendarId}]::text[]`, + state: sql`${connections.state} #- ARRAY['calendars', ${calendarId}]::text[]`, }) - .where(eq(integrationConnections.id, connectionId)); + .where(eq(connections.id, connectionId)); } export async function patchGmailState( @@ -91,13 +84,13 @@ export async function patchGmailState( ): Promise { const json = JSON.stringify(patch); await db - .update(integrationConnections) + .update(connections) .set({ - config: sql`jsonb_set( - coalesce(${integrationConnections.config}, '{}'::jsonb) || '{"provider":"google"}'::jsonb, + state: sql`jsonb_set( + coalesce(${connections.state}, '{}'::jsonb) || '{"provider":"google"}'::jsonb, '{gmail}', - coalesce(${integrationConnections.config} -> 'gmail', '{}'::jsonb) || ${json}::jsonb + coalesce(${connections.state} -> 'gmail', '{}'::jsonb) || ${json}::jsonb )`, }) - .where(eq(integrationConnections.id, connectionId)); + .where(eq(connections.id, connectionId)); } diff --git a/packages/trpc/src/router/integration/integration.ts b/packages/trpc/src/router/integration/integration.ts index 49ac4f9ac76..f8ca674af67 100644 --- a/packages/trpc/src/router/integration/integration.ts +++ b/packages/trpc/src/router/integration/integration.ts @@ -1,5 +1,5 @@ import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; +import { connections } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; import { eq } from "drizzle-orm"; import { z } from "zod"; @@ -33,16 +33,25 @@ export const integrationRouter = { .query(async ({ ctx, input }) => { await verifyOrgMembership(ctx.session.user.id, input.organizationId); - return db.query.integrationConnections.findMany({ - where: eq(integrationConnections.organizationId, input.organizationId), - columns: { - id: true, - provider: true, - externalOrgId: true, - externalOrgName: true, - createdAt: true, - updatedAt: true, - }, - }); + const rows = await db + .select({ + id: connections.id, + connector: connections.connector, + externalAccountId: connections.externalAccountId, + externalAccountLabel: connections.externalAccountLabel, + createdAt: connections.createdAt, + updatedAt: connections.updatedAt, + }) + .from(connections) + .where(eq(connections.organizationId, input.organizationId)); + + return rows.map((row) => ({ + id: row.id, + provider: row.connector, + externalOrgId: row.externalAccountId, + externalOrgName: row.externalAccountLabel, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); }), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/linear/linear.ts b/packages/trpc/src/router/integration/linear/linear.ts index 7e5ce6e836b..5ba4ca739e6 100644 --- a/packages/trpc/src/router/integration/linear/linear.ts +++ b/packages/trpc/src/router/integration/linear/linear.ts @@ -1,6 +1,6 @@ import { db, dbWs } from "@superset/db/client"; import { - integrationConnections, + connections, type LinearConfig, taskStatuses, tasks, @@ -9,104 +9,108 @@ import { seedDefaultStatuses } from "@superset/db/seed-default-statuses"; import type { TRPCRouterRecord } from "@trpc/server"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; +import { userConnection } from "../../../lib/connectors"; import { protectedProcedure } from "../../../trpc"; import { disconnectProcedure } from "../connections"; import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; -import { callLinear } from "./refresh"; +import { callLinear, callLinearForConnection } from "./refresh"; export const linearRouter = { getConnection: protectedProcedure .input(z.object({ organizationId: z.uuid() })) .query(async ({ ctx, input }) => { await verifyOrgMembership(ctx.session.user.id, input.organizationId); - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "linear"), - ), - columns: { - id: true, - config: true, - disconnectedAt: true, - disconnectReason: true, - }, - }); + const connection = await userConnection( + input.organizationId, + "linear", + ctx.session.user.id, + { includeDisconnected: true }, + ); if (!connection) return null; return { - config: connection.config as LinearConfig | null, + config: + connection.state?.provider === "linear" ? connection.state : null, needsReconnect: !!connection.disconnectedAt, disconnectReason: connection.disconnectReason, }; }), - disconnect: disconnectProcedure("linear", async (_, organizationId) => { - try { - await callLinear(organizationId, (client) => client.logout()); - } catch {} - - await dbWs.transaction(async (tx) => { - // 1. Delete Linear-synced tasks - await tx - .delete(tasks) - .where( - and( - eq(tasks.organizationId, organizationId), - eq(tasks.externalProvider, "linear"), - ), + disconnect: disconnectProcedure( + "linear", + async (connectionId) => { + try { + await callLinearForConnection(connectionId, (client) => + client.logout(), ); + } catch {} + }, + async (organizationId) => { + await dbWs.transaction(async (tx) => { + // 1. Delete Linear-synced tasks + await tx + .delete(tasks) + .where( + and( + eq(tasks.organizationId, organizationId), + eq(tasks.externalProvider, "linear"), + ), + ); - // 2. Seed default statuses inside the transaction - const backlogStatusId = await seedDefaultStatuses(organizationId, tx); + // 2. Seed default statuses inside the transaction + const backlogStatusId = await seedDefaultStatuses(organizationId, tx); - // 3. Remap remaining local tasks from Linear statuses to default statuses - const allStatuses = await tx.query.taskStatuses.findMany({ - where: eq(taskStatuses.organizationId, organizationId), - }); + // 3. Remap remaining local tasks from Linear statuses to default statuses + const allStatuses = await tx.query.taskStatuses.findMany({ + where: eq(taskStatuses.organizationId, organizationId), + }); - const defaultStatusByType = new Map(); - for (const status of allStatuses) { - if (!status.externalProvider && status.type) { - if (!defaultStatusByType.has(status.type)) { - defaultStatusByType.set(status.type, status.id); + const defaultStatusByType = new Map(); + for (const status of allStatuses) { + if (!status.externalProvider && status.type) { + if (!defaultStatusByType.has(status.type)) { + defaultStatusByType.set(status.type, status.id); + } } } - } - for (const status of allStatuses) { - if (status.externalProvider === "linear") { - const defaultStatusId = - (status.type && defaultStatusByType.get(status.type)) || - backlogStatusId; - await tx - .update(tasks) - .set({ statusId: defaultStatusId }) - .where( - and( - eq(tasks.organizationId, organizationId), - eq(tasks.statusId, status.id), - ), - ); + for (const status of allStatuses) { + if (status.externalProvider === "linear") { + const defaultStatusId = + (status.type && defaultStatusByType.get(status.type)) || + backlogStatusId; + await tx + .update(tasks) + .set({ statusId: defaultStatusId }) + .where( + and( + eq(tasks.organizationId, organizationId), + eq(tasks.statusId, status.id), + ), + ); + } } - } - // 4. Delete Linear task statuses - await tx - .delete(taskStatuses) - .where( - and( - eq(taskStatuses.organizationId, organizationId), - eq(taskStatuses.externalProvider, "linear"), - ), - ); - }); - }), + // 4. Delete Linear task statuses + await tx + .delete(taskStatuses) + .where( + and( + eq(taskStatuses.organizationId, organizationId), + eq(taskStatuses.externalProvider, "linear"), + ), + ); + }); + }, + ), getTeams: protectedProcedure .input(z.object({ organizationId: z.uuid() })) .query(async ({ ctx, input }) => { await verifyOrgMembership(ctx.session.user.id, input.organizationId); - const teams = await callLinear(input.organizationId, (client) => - client.teams(), + const teams = await callLinear( + input.organizationId, + ctx.session.user.id, + (client) => client.teams(), ); if (!teams) return []; return teams.nodes.map((t) => ({ id: t.id, name: t.name, key: t.key })); @@ -128,12 +132,12 @@ export const linearRouter = { }; await db - .update(integrationConnections) - .set({ config }) + .update(connections) + .set({ state: config }) .where( and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "linear"), + eq(connections.organizationId, input.organizationId), + eq(connections.connector, "linear"), ), ); diff --git a/packages/trpc/src/router/integration/linear/refresh.ts b/packages/trpc/src/router/integration/linear/refresh.ts index 863dfc7a29b..2385d2be77a 100644 --- a/packages/trpc/src/router/integration/linear/refresh.ts +++ b/packages/trpc/src/router/integration/linear/refresh.ts @@ -1,9 +1,7 @@ import { LinearClient } from "@linear/sdk"; -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; -import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { env } from "../../../env"; +import { connectionById, userConnection } from "../../../lib/connectors"; import { markDisconnected, type RefreshedToken, @@ -11,7 +9,7 @@ import { withRefreshedToken, } from "../token-refresh"; import { REFRESH_TOKEN_TIMEOUT_MS } from "./constants"; -import { getLinearClient } from "./utils"; +import { getLinearClient, linearClientFor } from "./utils"; export const linearTokenResponseSchema = z.object({ access_token: z.string(), @@ -75,11 +73,30 @@ export async function refreshLinearToken( }); } +export async function callLinearForConnection( + connectionId: string, + fn: (client: LinearClient) => Promise, +): Promise { + const connection = await connectionById(connectionId, { + includeDisconnected: true, + }); + if (!connection) return null; + const client = await linearClientFor(connection); + if (!client) return null; + try { + return await fn(client); + } catch (error) { + if (isLinearAuthError(error)) return null; + throw error; + } +} + export async function callLinear( organizationId: string, + userId: string, fn: (client: LinearClient) => Promise, ): Promise { - const client = await getLinearClient(organizationId); + const client = await getLinearClient(organizationId, userId); if (!client) return null; try { @@ -87,11 +104,8 @@ export async function callLinear( } catch (error) { if (!isLinearAuthError(error)) throw error; - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, organizationId), - eq(integrationConnections.provider, "linear"), - ), + const connection = await userConnection(organizationId, "linear", userId, { + includeDisconnected: true, }); if (!connection) return null; if (!connection.refreshToken) { diff --git a/packages/trpc/src/router/integration/linear/trigger-options.ts b/packages/trpc/src/router/integration/linear/trigger-options.ts index 7b86e043771..7ffb9fccc81 100644 --- a/packages/trpc/src/router/integration/linear/trigger-options.ts +++ b/packages/trpc/src/router/integration/linear/trigger-options.ts @@ -69,13 +69,16 @@ async function fetchAllTriggerOptions( */ const inFlight = new Map>(); -function triggerOptionsFor(organizationId: string) { - let pending = inFlight.get(organizationId); +function triggerOptionsFor(organizationId: string, userId: string) { + const key = `${organizationId}:${userId}`; + let pending = inFlight.get(key); if (!pending) { - pending = callLinear(organizationId, fetchAllTriggerOptions).finally(() => - inFlight.delete(organizationId), - ); - inFlight.set(organizationId, pending); + pending = callLinear( + organizationId, + userId, + fetchAllTriggerOptions, + ).finally(() => inFlight.delete(key)); + inFlight.set(key, pending); } return pending; } @@ -95,8 +98,8 @@ const byLabel = (options: TriggerOption[]) => function source( pick: (result: TriggerOptionsQuery) => TriggerOption[], ): TriggerOptionSource { - return async ({ organizationId }) => { - const result = await triggerOptionsFor(organizationId); + return async ({ organizationId, userId }) => { + const result = await triggerOptionsFor(organizationId, userId); return result ? byLabel(pick(result)) : []; }; } diff --git a/packages/trpc/src/router/integration/linear/utils.ts b/packages/trpc/src/router/integration/linear/utils.ts index 89988f065b2..bb1c0063db9 100644 --- a/packages/trpc/src/router/integration/linear/utils.ts +++ b/packages/trpc/src/router/integration/linear/utils.ts @@ -1,7 +1,6 @@ import { LinearClient } from "@linear/sdk"; -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; -import { and, eq } from "drizzle-orm"; +import type { SelectConnection } from "@superset/db/schema"; +import { decryptSecret, userConnection } from "../../../lib/connectors"; import { markDisconnected, REFRESH_BUFFER_MS } from "../token-refresh"; import { isLinearAuthError, refreshLinearToken } from "./refresh"; @@ -39,15 +38,18 @@ export function mapPriorityFromLinear(linearPriority: number): Priority { export async function getLinearClient( organizationId: string, + userId: string, ): Promise { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, organizationId), - eq(integrationConnections.provider, "linear"), - ), + const connection = await userConnection(organizationId, "linear", userId, { + includeDisconnected: true, }); + return connection ? linearClientFor(connection) : null; +} - if (!connection || connection.disconnectedAt) { +export async function linearClientFor( + connection: SelectConnection, +): Promise { + if (connection.disconnectedAt) { return null; } @@ -69,11 +71,15 @@ export async function getLinearClient( connection.tokenExpiresAt && connection.tokenExpiresAt.getTime() > Date.now(); if (tokenStillValid && !isLinearAuthError(error)) { - return new LinearClient({ accessToken: connection.accessToken }); + return new LinearClient({ + accessToken: await decryptSecret(connection.accessToken), + }); } throw error; } } - return new LinearClient({ accessToken: connection.accessToken }); + return new LinearClient({ + accessToken: await decryptSecret(connection.accessToken), + }); } diff --git a/packages/trpc/src/router/integration/microsoft-teams/graph.ts b/packages/trpc/src/router/integration/microsoft-teams/graph.ts index 04736d12f20..1680ab2f11c 100644 --- a/packages/trpc/src/router/integration/microsoft-teams/graph.ts +++ b/packages/trpc/src/router/integration/microsoft-teams/graph.ts @@ -1,10 +1,14 @@ import { AuthError, ConfidentialClientApplication } from "@azure/msal-node"; import { Client, GraphError } from "@microsoft/microsoft-graph-client"; -import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; +import { connections, type SelectConnection } from "@superset/db/schema"; import { withConnectionLock } from "@superset/db/utils"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { env } from "../../../env"; +import { + decryptSecret, + encryptSecret, + orgConnection, +} from "../../../lib/connectors"; /** * Microsoft Graph, app-only. @@ -91,47 +95,47 @@ export async function getGraphAccessToken( connectionId: string, ): Promise { return withConnectionLock(connectionId, async (tx) => { - const [connection] = await tx + const [row] = await tx .select({ - accessToken: integrationConnections.accessToken, - tokenExpiresAt: integrationConnections.tokenExpiresAt, - externalOrgId: integrationConnections.externalOrgId, - disconnectedAt: integrationConnections.disconnectedAt, + accessToken: connections.accessToken, + tokenExpiresAt: connections.tokenExpiresAt, + externalAccountId: connections.externalAccountId, + disconnectedAt: connections.disconnectedAt, }) - .from(integrationConnections) - .where(eq(integrationConnections.id, connectionId)) + .from(connections) + .where(eq(connections.id, connectionId)) .limit(1); - if (!connection || connection.disconnectedAt || !connection.externalOrgId) { + if (!row || row.disconnectedAt || !row.externalAccountId) { return null; } if ( - connection.tokenExpiresAt && - connection.tokenExpiresAt.getTime() > Date.now() + TOKEN_REFRESH_BUFFER_MS + row.tokenExpiresAt && + row.tokenExpiresAt.getTime() > Date.now() + TOKEN_REFRESH_BUFFER_MS ) { - return connection.accessToken; + return decryptSecret(row.accessToken); } try { - const token = await acquireAppToken(connection.externalOrgId); + const token = await acquireAppToken(row.externalAccountId); await tx - .update(integrationConnections) + .update(connections) .set({ - accessToken: token.accessToken, + accessToken: await encryptSecret(token.accessToken), tokenExpiresAt: token.expiresAt, updatedAt: new Date(), }) - .where(eq(integrationConnections.id, connectionId)); + .where(eq(connections.id, connectionId)); return token.accessToken; } catch (error) { if (isTenantRevokedError(error)) { await tx - .update(integrationConnections) + .update(connections) .set({ disconnectedAt: new Date(), disconnectReason: error.errorCode, updatedAt: new Date(), }) - .where(eq(integrationConnections.id, connectionId)); + .where(eq(connections.id, connectionId)); return null; } throw error; @@ -140,15 +144,10 @@ export async function getGraphAccessToken( } /** The active Teams connection for an organization, or null. */ -export async function findTeamsConnection(organizationId: string) { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, organizationId), - eq(integrationConnections.provider, "microsoft_teams"), - ), - }); - if (!connection || connection.disconnectedAt) return null; - return connection; +export async function findTeamsConnection( + organizationId: string, +): Promise { + return orgConnection(organizationId, "microsoft_teams"); } /** diff --git a/packages/trpc/src/router/integration/microsoft-teams/microsoft-teams.ts b/packages/trpc/src/router/integration/microsoft-teams/microsoft-teams.ts index 8bbcff2f25a..2b850096f90 100644 --- a/packages/trpc/src/router/integration/microsoft-teams/microsoft-teams.ts +++ b/packages/trpc/src/router/integration/microsoft-teams/microsoft-teams.ts @@ -3,34 +3,24 @@ import { disconnectProcedure, getConnectionProcedure } from "../connections"; import { deleteTeamsSubscriptions } from "./subscriptions"; export const microsoftTeamsRouter = { - getConnection: getConnectionProcedure( - "microsoft_teams", - { - id: true, - externalOrgId: true, - externalOrgName: true, - config: true, - createdAt: true, - }, - (connection) => { - const config = - connection.config?.provider === "microsoft_teams" - ? connection.config - : null; - return { - id: connection.id, - tenantId: connection.externalOrgId, - externalOrgName: connection.externalOrgName, - connectedAt: connection.createdAt, - // Whether Graph is actually delivering: a connection whose - // subscriptions never got created is consented but deaf. - subscriptions: { - channelMessages: config?.subscriptions.channelMessages ?? null, - channels: config?.subscriptions.channels ?? null, - }, - }; - }, - ), + getConnection: getConnectionProcedure("microsoft_teams", (connection) => { + const state = + connection.state?.provider === "microsoft_teams" + ? connection.state + : null; + return { + id: connection.id, + tenantId: connection.externalAccountId, + externalOrgName: connection.externalAccountLabel, + connectedAt: connection.createdAt, + // Whether Graph is actually delivering: a connection whose + // subscriptions never got created is consented but deaf. + subscriptions: { + channelMessages: state?.subscriptions.channelMessages ?? null, + channels: state?.subscriptions.channels ?? null, + }, + }; + }), // Before the row goes: the subscription ids live on it, and Graph would // otherwise keep posting to the notify route for two more days. diff --git a/packages/trpc/src/router/integration/microsoft-teams/subscriptions.ts b/packages/trpc/src/router/integration/microsoft-teams/subscriptions.ts index da790a589a1..d1eee01c435 100644 --- a/packages/trpc/src/router/integration/microsoft-teams/subscriptions.ts +++ b/packages/trpc/src/router/integration/microsoft-teams/subscriptions.ts @@ -2,7 +2,7 @@ import type { Client } from "@microsoft/microsoft-graph-client"; import type { Subscription } from "@microsoft/microsoft-graph-types"; import { db } from "@superset/db/client"; import { - integrationConnections, + connections, type MicrosoftTeamsConfig, type MicrosoftTeamsSubscription, } from "@superset/db/schema"; @@ -119,12 +119,12 @@ export async function ensureTeamsSubscriptions( const graph = graphClient(accessToken); return withConnectionLock(connectionId, async (tx) => { - const [connection] = await tx - .select({ config: integrationConnections.config }) - .from(integrationConnections) - .where(eq(integrationConnections.id, connectionId)) + const [row] = await tx + .select({ state: connections.state }) + .from(connections) + .where(eq(connections.id, connectionId)) .limit(1); - const config = connection?.config; + const config = row?.state; if (!config || config.provider !== "microsoft_teams") return null; const subscriptions = { ...config.subscriptions }; @@ -166,9 +166,9 @@ export async function ensureTeamsSubscriptions( const next: MicrosoftTeamsConfig = { ...config, subscriptions }; await tx - .update(integrationConnections) - .set({ config: next, updatedAt: new Date() }) - .where(eq(integrationConnections.id, connectionId)); + .update(connections) + .set({ state: next, updatedAt: new Date() }) + .where(eq(connections.id, connectionId)); return { connectionId, subscriptions, failures }; }); @@ -180,12 +180,12 @@ export async function ensureTeamsSubscriptions( export async function deleteTeamsSubscriptions( connectionId: string, ): Promise { - const [connection] = await db - .select({ config: integrationConnections.config }) - .from(integrationConnections) - .where(eq(integrationConnections.id, connectionId)) + const [row] = await db + .select({ state: connections.state }) + .from(connections) + .where(eq(connections.id, connectionId)) .limit(1); - const config = connection?.config; + const config = row?.state; if (!config || config.provider !== "microsoft_teams") return; let accessToken: string | null = null; diff --git a/packages/trpc/src/router/integration/notion/notion.ts b/packages/trpc/src/router/integration/notion/notion.ts index 37aa8e031dd..e46b8191281 100644 --- a/packages/trpc/src/router/integration/notion/notion.ts +++ b/packages/trpc/src/router/integration/notion/notion.ts @@ -2,15 +2,11 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { disconnectProcedure, getConnectionProcedure } from "../connections"; export const notionRouter = { - getConnection: getConnectionProcedure( - "notion", - { id: true, externalOrgName: true, createdAt: true }, - (connection) => ({ - id: connection.id, - externalOrgName: connection.externalOrgName, - connectedAt: connection.createdAt, - }), - ), + getConnection: getConnectionProcedure("notion", (connection) => ({ + id: connection.id, + externalOrgName: connection.externalAccountLabel, + connectedAt: connection.createdAt, + })), disconnect: disconnectProcedure("notion"), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/notion/trigger-options.ts b/packages/trpc/src/router/integration/notion/trigger-options.ts index d6d1bc3e452..1a4357c4237 100644 --- a/packages/trpc/src/router/integration/notion/trigger-options.ts +++ b/packages/trpc/src/router/integration/notion/trigger-options.ts @@ -4,7 +4,7 @@ import { isFullUser, iteratePaginatedAPI, } from "@notionhq/client"; -import { activeConnection } from "../connections"; +import { connectionAccessToken, userConnection } from "../../../lib/connectors"; import type { TriggerOption, TriggerOptionSource } from "../trigger-options"; import { notionClient, plainText } from "./client"; @@ -17,19 +17,21 @@ const MAX_OPTIONS = 500; * list users) throws to the shared procedure, which shows an empty list — * the chip keeps its "anyone / me" entries rather than the editor going red. */ -async function connectedClient(organizationId: string): Promise { - const connection = await activeConnection(organizationId, "notion", { - accessToken: true, - }); - return connection ? notionClient(connection.accessToken) : null; +async function connectedClient( + organizationId: string, + userId: string, +): Promise { + const connection = await userConnection(organizationId, "notion", userId); + if (!connection) return null; + return notionClient(await connectionAccessToken(connection)); } /** * The data sources the workspace has shared with the integration. Ids, not * titles: a title can be renamed. */ -const dataSources: TriggerOptionSource = async ({ organizationId }) => { - const client = await connectedClient(organizationId); +const dataSources: TriggerOptionSource = async ({ organizationId, userId }) => { + const client = await connectedClient(organizationId, userId); if (!client) return []; const options: TriggerOption[] = []; @@ -51,8 +53,8 @@ const dataSources: TriggerOptionSource = async ({ organizationId }) => { * The people in the workspace, by Notion user id — what a comment's author * and mentions carry. Bots are left out: nobody filters on them. */ -const people: TriggerOptionSource = async ({ organizationId }) => { - const client = await connectedClient(organizationId); +const people: TriggerOptionSource = async ({ organizationId, userId }) => { + const client = await connectedClient(organizationId, userId); if (!client) return []; const options: TriggerOption[] = []; diff --git a/packages/trpc/src/router/integration/sentry/sentry.ts b/packages/trpc/src/router/integration/sentry/sentry.ts index 657caaddd66..fbcf6be15fb 100644 --- a/packages/trpc/src/router/integration/sentry/sentry.ts +++ b/packages/trpc/src/router/integration/sentry/sentry.ts @@ -8,16 +8,12 @@ import { disconnectProcedure, getConnectionProcedure } from "../connections"; * settings page use. */ export const sentryRouter = { - getConnection: getConnectionProcedure( - "sentry", - { id: true, externalOrgId: true, externalOrgName: true, createdAt: true }, - (connection) => ({ - id: connection.id, - organizationSlug: connection.externalOrgId, - organizationName: connection.externalOrgName, - connectedAt: connection.createdAt, - }), - ), + getConnection: getConnectionProcedure("sentry", (connection) => ({ + id: connection.id, + organizationSlug: connection.externalAccountId, + organizationName: connection.externalAccountLabel, + connectedAt: connection.createdAt, + })), disconnect: disconnectProcedure("sentry"), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/sentry/trigger-options.ts b/packages/trpc/src/router/integration/sentry/trigger-options.ts index a7ea53b3169..5dd79956d4c 100644 --- a/packages/trpc/src/router/integration/sentry/trigger-options.ts +++ b/packages/trpc/src/router/integration/sentry/trigger-options.ts @@ -1,5 +1,4 @@ -import type { SentryConfig } from "@superset/db/schema"; -import { activeConnection } from "../connections"; +import { orgConnection } from "../../../lib/connectors"; import type { TriggerOptionSource } from "../trigger-options"; import { fetchSentryProjects, getSentryAccessToken, SENTRY_URL } from "./utils"; @@ -8,20 +7,17 @@ import { fetchSentryProjects, getSentryAccessToken, SENTRY_URL } from "./utils"; * stop matching the moment someone renames the project. The slug is the label. */ const projects: TriggerOptionSource = async ({ organizationId }) => { - const connection = await activeConnection(organizationId, "sentry", { - id: true, - externalOrgId: true, - config: true, - }); - if (!connection?.externalOrgId) return []; + const connection = await orgConnection(organizationId, "sentry"); + if (!connection) return []; const token = await getSentryAccessToken(connection.id); if (token.disconnected) return []; - const config = connection.config as SentryConfig | null; + const state = + connection.state?.provider === "sentry" ? connection.state : null; const list = await fetchSentryProjects( - config?.regionUrl ?? SENTRY_URL, - connection.externalOrgId, + state?.regionUrl ?? SENTRY_URL, + connection.externalAccountId, token.accessToken, ); return list.map((project) => ({ id: project.id, label: project.slug })); diff --git a/packages/trpc/src/router/integration/sentry/utils.ts b/packages/trpc/src/router/integration/sentry/utils.ts index 62acc9b81f8..c53b7f8f81d 100644 --- a/packages/trpc/src/router/integration/sentry/utils.ts +++ b/packages/trpc/src/router/integration/sentry/utils.ts @@ -1,5 +1,4 @@ import { createHmac, randomUUID } from "node:crypto"; -import type { SentryConfig } from "@superset/db/schema"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { env } from "../../../env"; @@ -182,8 +181,10 @@ export async function getSentryAccessToken( // The install uuid is the token endpoint's path segment; without it // there is nothing to refresh against, so the current token is all // there is. - const installationUuid = (connection.config as SentryConfig | null) - ?.installationUuid; + const installationUuid = + connection.state?.provider === "sentry" + ? connection.state.installationUuid + : undefined; if (!installationUuid) return { keep: true }; const response = await fetch(authorizationsUrl(installationUuid), { diff --git a/packages/trpc/src/router/integration/slack/joinChannels.ts b/packages/trpc/src/router/integration/slack/joinChannels.ts index 7fd10b3f6e0..044196e92a8 100644 --- a/packages/trpc/src/router/integration/slack/joinChannels.ts +++ b/packages/trpc/src/router/integration/slack/joinChannels.ts @@ -1,6 +1,6 @@ import { WebClient } from "@slack/web-api"; import type { DraftTrigger } from "@superset/shared/automation-triggers"; -import { activeConnection } from "../connections"; +import { connectionBotToken, userConnection } from "../../../lib/connectors"; /** * Joins the bot to every channel a Slack trigger watches, so saving a trigger @@ -13,6 +13,7 @@ import { activeConnection } from "../connections"; */ export async function joinSlackTriggerChannels( organizationId: string, + userId: string, triggers: DraftTrigger[], ): Promise { const channelIds = new Set(); @@ -28,11 +29,9 @@ export async function joinSlackTriggerChannels( // throw here would reject a mutation whose write already landed. let client: WebClient; try { - const connection = await activeConnection(organizationId, "slack", { - accessToken: true, - }); + const connection = await userConnection(organizationId, "slack", userId); if (!connection) return; - client = new WebClient(connection.accessToken, { + client = new WebClient(await connectionBotToken(connection), { timeout: 5_000, retryConfig: { retries: 0 }, }); diff --git a/packages/trpc/src/router/integration/slack/slack.ts b/packages/trpc/src/router/integration/slack/slack.ts index 8ddb122bba0..7e596c8517b 100644 --- a/packages/trpc/src/router/integration/slack/slack.ts +++ b/packages/trpc/src/router/integration/slack/slack.ts @@ -2,15 +2,11 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { disconnectProcedure, getConnectionProcedure } from "../connections"; export const slackRouter = { - getConnection: getConnectionProcedure( - "slack", - { id: true, externalOrgName: true, createdAt: true }, - (connection) => ({ - id: connection.id, - externalOrgName: connection.externalOrgName, - connectedAt: connection.createdAt, - }), - ), + getConnection: getConnectionProcedure("slack", (connection) => ({ + id: connection.id, + externalOrgName: connection.externalAccountLabel, + connectedAt: connection.createdAt, + })), disconnect: disconnectProcedure("slack"), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/slack/trigger-options.ts b/packages/trpc/src/router/integration/slack/trigger-options.ts index 5a29ea44d25..77046c95526 100644 --- a/packages/trpc/src/router/integration/slack/trigger-options.ts +++ b/packages/trpc/src/router/integration/slack/trigger-options.ts @@ -3,7 +3,7 @@ import { type UsersListResponse, WebClient, } from "@slack/web-api"; -import { activeConnection } from "../connections"; +import { connectionBotToken, userConnection } from "../../../lib/connectors"; import type { TriggerOption, TriggerOptionSource } from "../trigger-options"; /** A hard stop, not a page size: enough for any workspace this is pointed at. */ @@ -16,12 +16,13 @@ const MAX_PEOPLE = 1000; * (until the app is reinstalled with the scope) throws to the shared * procedure, which shows an empty list. */ -async function slackClient(organizationId: string): Promise { - const connection = await activeConnection(organizationId, "slack", { - accessToken: true, - }); +async function slackClient( + organizationId: string, + userId: string, +): Promise { + const connection = await userConnection(organizationId, "slack", userId); if (!connection) return null; - return new WebClient(connection.accessToken, { + return new WebClient(await connectionBotToken(connection), { timeout: 5_000, retryConfig: { retries: 0 }, }); @@ -35,8 +36,8 @@ function byLabel(options: TriggerOption[]): TriggerOption[] { * Public and private channels both — a private channel the bot has been * invited to is where "a message in #incidents" most often means something. */ -const channels: TriggerOptionSource = async ({ organizationId }) => { - const client = await slackClient(organizationId); +const channels: TriggerOptionSource = async ({ organizationId, userId }) => { + const client = await slackClient(organizationId, userId); if (!client) return []; const options: TriggerOption[] = []; @@ -67,8 +68,8 @@ const channels: TriggerOptionSource = async ({ organizationId }) => { * The workspace's human members, keyed by Slack user id — what an event's * `user` carries and the matcher compares against. */ -const people: TriggerOptionSource = async ({ organizationId }) => { - const client = await slackClient(organizationId); +const people: TriggerOptionSource = async ({ organizationId, userId }) => { + const client = await slackClient(organizationId, userId); if (!client) return []; const options: TriggerOption[] = []; diff --git a/packages/trpc/src/router/integration/token-refresh.ts b/packages/trpc/src/router/integration/token-refresh.ts index 807b8bf5e82..4b1aa48294d 100644 --- a/packages/trpc/src/router/integration/token-refresh.ts +++ b/packages/trpc/src/router/integration/token-refresh.ts @@ -1,7 +1,13 @@ import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; +import { connections, type IntegrationConfig } from "@superset/db/schema"; import { withConnectionLock } from "@superset/db/utils"; import { eq } from "drizzle-orm"; +import { + decryptOptional, + decryptSecret, + encryptOptional, + encryptSecret, +} from "../../lib/connectors"; /** Refresh a token this many ms before it actually expires. */ export const REFRESH_BUFFER_MS = 5 * 60 * 1000; @@ -14,7 +20,7 @@ export type RefreshableConnection = { accessToken: string; refreshToken: string | null; tokenExpiresAt: Date | null; - config: unknown; + state: IntegrationConfig | null; }; /** A token endpoint's non-ok answer, kept for `revokedWhen` to classify. */ @@ -47,6 +53,8 @@ type TokenExchange = ( * `{revoked: reason}` when the grant is known gone. A thrown error goes * through `revokedWhen`: a reason marks the connection disconnected, null * rethrows (transient). Success clears any disconnected marker. + * + * Tokens are stored encrypted; `exchange` is handed and returns plaintext. */ export async function withRefreshedToken( connectionId: string, @@ -56,31 +64,40 @@ export async function withRefreshedToken( }, ): Promise { return withConnectionLock(connectionId, async (tx) => { - const [connection] = await tx + const [row] = await tx .select({ - accessToken: integrationConnections.accessToken, - refreshToken: integrationConnections.refreshToken, - tokenExpiresAt: integrationConnections.tokenExpiresAt, - disconnectedAt: integrationConnections.disconnectedAt, - config: integrationConnections.config, + accessToken: connections.accessToken, + refreshToken: connections.refreshToken, + tokenExpiresAt: connections.tokenExpiresAt, + disconnectedAt: connections.disconnectedAt, + state: connections.state, }) - .from(integrationConnections) - .where(eq(integrationConnections.id, connectionId)) + .from(connections) + .where(eq(connections.id, connectionId)) .limit(1); - if (!connection || connection.disconnectedAt) return { disconnected: true }; + if (!row || row.disconnectedAt) return { disconnected: true }; + + const accessToken = await decryptSecret(row.accessToken); if ( - connection.tokenExpiresAt && - connection.tokenExpiresAt.getTime() > Date.now() + REFRESH_BUFFER_MS + row.tokenExpiresAt && + row.tokenExpiresAt.getTime() > Date.now() + REFRESH_BUFFER_MS ) { - return { disconnected: false, accessToken: connection.accessToken }; + return { disconnected: false, accessToken }; } + const connection: RefreshableConnection = { + accessToken, + refreshToken: await decryptOptional(row.refreshToken), + tokenExpiresAt: row.tokenExpiresAt, + state: row.state ?? null, + }; + const disconnect = async (reason: string): Promise => { await tx - .update(integrationConnections) + .update(connections) .set({ disconnectedAt: new Date(), disconnectReason: reason }) - .where(eq(integrationConnections.id, connectionId)); + .where(eq(connections.id, connectionId)); return { disconnected: true }; }; @@ -92,21 +109,19 @@ export async function withRefreshedToken( if (reason) return disconnect(reason); throw error; } - if ("keep" in result) { - return { disconnected: false, accessToken: connection.accessToken }; - } + if ("keep" in result) return { disconnected: false, accessToken }; if ("revoked" in result) return disconnect(result.revoked); await tx - .update(integrationConnections) + .update(connections) .set({ - accessToken: result.accessToken, - refreshToken: result.refreshToken, + accessToken: await encryptSecret(result.accessToken), + refreshToken: await encryptOptional(result.refreshToken), tokenExpiresAt: result.tokenExpiresAt, disconnectedAt: null, disconnectReason: null, }) - .where(eq(integrationConnections.id, connectionId)); + .where(eq(connections.id, connectionId)); return { disconnected: false, accessToken: result.accessToken }; }); } @@ -118,11 +133,13 @@ export async function markDisconnected( opts: { clearTokens?: boolean } = {}, ): Promise { await db - .update(integrationConnections) + .update(connections) .set({ disconnectedAt: new Date(), disconnectReason: reason, - ...(opts.clearTokens ? { accessToken: "", refreshToken: null } : {}), + ...(opts.clearTokens + ? { accessToken: await encryptSecret(""), refreshToken: null } + : {}), }) - .where(eq(integrationConnections.id, connectionId)); + .where(eq(connections.id, connectionId)); } diff --git a/packages/trpc/src/router/plugins/connections.ts b/packages/trpc/src/router/plugins/connections.ts index 5aae46aac67..a54d00db743 100644 --- a/packages/trpc/src/router/plugins/connections.ts +++ b/packages/trpc/src/router/plugins/connections.ts @@ -1,222 +1,7 @@ import { db } from "@superset/db/client"; -import { - pluginConnections, - pluginInstalls, - pluginMarketplaces, - type SelectPluginConnection, -} from "@superset/db/schema"; -import { - DEFAULT_MARKETPLACE, - DEFAULT_MARKETPLACE_REF, - DEFAULT_MARKETPLACE_REPO, -} from "@superset/shared/plugins"; -import { and, asc, countDistinct, desc, eq, isNull } from "drizzle-orm"; -import { - decryptOptional, - decryptSecret, - encryptOptional, - encryptSecret, -} from "./crypto"; -import { - type PluginManifest, - supersetExtension, - type TemplateScope, -} from "./manifest"; - -export interface ConnectionSecrets { - accessToken: string; - refreshToken: string | null; - inputs: Record; -} - -export interface UpsertConnectionInput { - userId: string; - organizationId: string | null; - pluginName: string; - installId?: string | null; - authMethod: string; - accessToken: string; - refreshToken?: string | null; - tokenExpiresAt?: Date | null; - scopes?: string[] | null; - inputs?: Record; - secretInputs?: string[]; - externalAccountId: string; - externalAccountLabel?: string | null; -} - -async function encryptInputs( - inputs: Record, - secretNames: string[], -): Promise> { - const secrets = new Set(secretNames); - const out: Record = {}; - for (const [name, value] of Object.entries(inputs)) { - out[name] = - secrets.has(name) && typeof value === "string" - ? { __encrypted: await encryptSecret(value) } - : value; - } - return out; -} - -async function decryptInputs( - config: unknown, -): Promise> { - if (!config || typeof config !== "object") return {}; - const out: Record = {}; - for (const [name, value] of Object.entries( - config as Record, - )) { - if (value && typeof value === "object" && "__encrypted" in value) { - out[name] = await decryptSecret( - (value as { __encrypted: string }).__encrypted, - ); - } else { - out[name] = value; - } - } - return out; -} - -export async function upsertConnection( - input: UpsertConnectionInput, -): Promise { - const values = { - userId: input.userId, - organizationId: input.organizationId, - pluginName: input.pluginName, - installId: input.installId ?? null, - authMethod: input.authMethod, - accessToken: await encryptSecret(input.accessToken), - refreshToken: await encryptOptional(input.refreshToken), - tokenExpiresAt: input.tokenExpiresAt ?? null, - scopes: input.scopes ?? null, - config: await encryptInputs(input.inputs ?? {}, input.secretInputs ?? []), - externalAccountId: input.externalAccountId, - externalAccountLabel: input.externalAccountLabel ?? null, - }; - - await db - .update(pluginConnections) - .set({ disconnectedAt: new Date(), disconnectReason: "install_removed" }) - .where( - and( - eq(pluginConnections.userId, values.userId), - eq(pluginConnections.pluginName, values.pluginName), - eq(pluginConnections.externalAccountId, values.externalAccountId), - isNull(pluginConnections.installId), - isNull(pluginConnections.disconnectedAt), - ), - ); - - const [row] = await db - .insert(pluginConnections) - .values(values) - .onConflictDoUpdate({ - target: [ - pluginConnections.userId, - pluginConnections.installId, - pluginConnections.externalAccountId, - ], - targetWhere: isNull(pluginConnections.disconnectedAt), - set: { - installId: values.installId, - authMethod: values.authMethod, - accessToken: values.accessToken, - ...(values.refreshToken ? { refreshToken: values.refreshToken } : {}), - tokenExpiresAt: values.tokenExpiresAt, - scopes: values.scopes, - config: values.config, - externalAccountLabel: values.externalAccountLabel, - organizationId: values.organizationId, - }, - }) - .returning(); - - if (!row) throw new Error("Failed to persist connection"); - return row; -} - -export async function listConnections( - userId: string, - pluginName?: string, -): Promise { - return await db - .select() - .from(pluginConnections) - .where( - and( - eq(pluginConnections.userId, userId), - isNull(pluginConnections.disconnectedAt), - pluginName ? eq(pluginConnections.pluginName, pluginName) : undefined, - ), - ) - .orderBy(desc(pluginConnections.createdAt)); -} - -export async function getConnection( - userId: string, - connectionId: string, -): Promise { - const [row] = await db - .select() - .from(pluginConnections) - .where( - and( - eq(pluginConnections.id, connectionId), - eq(pluginConnections.userId, userId), - isNull(pluginConnections.disconnectedAt), - ), - ) - .limit(1); - return row ?? null; -} - -export async function disconnect( - userId: string, - connectionId: string, - reason = "user_disconnected", -): Promise { - const result = await db - .update(pluginConnections) - .set({ - disconnectedAt: new Date(), - disconnectReason: reason, - accessToken: "", - refreshToken: null, - config: null, - }) - .where( - and( - eq(pluginConnections.id, connectionId), - eq(pluginConnections.userId, userId), - isNull(pluginConnections.disconnectedAt), - ), - ) - .returning({ id: pluginConnections.id }); - return result.length > 0; -} - -export async function connectionSecrets( - connection: SelectPluginConnection, -): Promise { - return { - accessToken: await decryptSecret(connection.accessToken), - refreshToken: await decryptOptional(connection.refreshToken), - inputs: await decryptInputs(connection.config), - }; -} - -export async function templateScope( - connection: SelectPluginConnection, -): Promise { - const secrets = await connectionSecrets(connection); - return { - config: { access_token: secrets.accessToken }, - inputs: secrets.inputs, - }; -} +import { pluginInstalls } from "@superset/db/schema"; +import { and, asc, countDistinct, eq } from "drizzle-orm"; +import type { PluginManifest } from "./manifest"; export interface InstalledPlugin { id: string; @@ -345,46 +130,6 @@ export async function installById( }; } -export async function installForConnection( - userId: string, - connection: Pick, -): Promise { - return connection.installId - ? await installById(userId, connection.installId) - : null; -} - -export interface ConnectionContext { - connection: SelectPluginConnection; - install: InstalledPlugin; - source: BundledSource | null; -} - -function exposesTools(manifest: PluginManifest): boolean { - const extension = supersetExtension(manifest); - return Boolean(extension?.mcp?.url || extension?.server?.path); -} - -export async function toolConnections( - userId: string, -): Promise { - const connections = await listConnections(userId); - const contexts = await Promise.all( - connections.map(async (connection) => { - const install = await installForConnection(userId, connection); - if (!install || !exposesTools(install.manifest)) return null; - return { - connection, - install, - source: await bundledSource(userId, install.marketplace), - }; - }), - ); - return contexts.filter( - (context): context is ConnectionContext => context !== null, - ); -} - export async function installedManifest( userId: string, pluginName: string, @@ -394,36 +139,3 @@ export async function installedManifest( (await installedPlugin(userId, pluginName, marketplace))?.manifest ?? null ); } - -export interface BundledSource { - repo: string; - ref: string; -} - -export async function bundledSource( - userId: string, - marketplace: string, -): Promise { - const [row] = await db - .select() - .from(pluginMarketplaces) - .where( - and( - eq(pluginMarketplaces.userId, userId), - eq(pluginMarketplaces.name, marketplace), - ), - ) - .limit(1); - - if (!row) { - return marketplace === DEFAULT_MARKETPLACE - ? { repo: DEFAULT_MARKETPLACE_REPO, ref: DEFAULT_MARKETPLACE_REF } - : null; - } - if (row.sourceKind !== "github" || !row.repo) return null; - return { repo: row.repo, ref: row.ref ?? "HEAD" }; -} - -export function manifestAuth(manifest: PluginManifest) { - return supersetExtension(manifest)?.auth; -} diff --git a/packages/trpc/src/router/plugins/crypto.ts b/packages/trpc/src/router/plugins/crypto.ts index 453cb5ae7a9..63213859d35 100644 --- a/packages/trpc/src/router/plugins/crypto.ts +++ b/packages/trpc/src/router/plugins/crypto.ts @@ -1,16 +1,32 @@ -import { env } from "@superset/auth/env"; +import { env as authEnv } from "@superset/auth/env"; import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto"; +import { + open, + SECRET_BOX_VERSION, + seal, + secretsKeyConfigured, +} from "../../lib/secret-box"; -function key(): string { - return env.BETTER_AUTH_SECRET; +const SEALED_PREFIX = `v${SECRET_BOX_VERSION}:`; +const AAD = `v${SECRET_BOX_VERSION}:connector-secret`; + +function encryptWithAuthSecret(value: string): Promise { + return symmetricEncrypt({ key: authEnv.BETTER_AUTH_SECRET, data: value }); +} + +function decryptWithAuthSecret(value: string): Promise { + return symmetricDecrypt({ key: authEnv.BETTER_AUTH_SECRET, data: value }); } export async function encryptSecret(value: string): Promise { - return await symmetricEncrypt({ key: key(), data: value }); + if (!secretsKeyConfigured()) return await encryptWithAuthSecret(value); + return SEALED_PREFIX + seal(value, AAD); } export async function decryptSecret(value: string): Promise { - return await symmetricDecrypt({ key: key(), data: value }); + if (!value.startsWith(SEALED_PREFIX)) + return await decryptWithAuthSecret(value); + return open(value.slice(SEALED_PREFIX.length), AAD); } export async function encryptOptional( diff --git a/packages/trpc/src/router/plugins/dispatch.ts b/packages/trpc/src/router/plugins/dispatch.ts deleted file mode 100644 index 5901735097b..00000000000 --- a/packages/trpc/src/router/plugins/dispatch.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { createHash } from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import type { BundledSource } from "./connections"; -import { - credentialFetch, - type PluginManifest, - resolveTemplateDeep, - resolveUrlTemplate, - supersetExtension, - type TemplateScope, -} from "./manifest"; - -export interface ToolDefinition { - name: string; - description?: string; - inputSchema?: unknown; - annotations?: Record; -} - -export interface DispatchOptions { - signal?: AbortSignal; -} - -const REQUEST_ID = 1; - -export class PluginDispatchError extends Error { - constructor( - message: string, - readonly status: number, - ) { - super(message); - } -} - -async function post( - url: string, - headers: Record, - body: unknown, - signal?: AbortSignal, -): Promise { - return await credentialFetch( - url, - { - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - ...headers, - }, - body: JSON.stringify(body), - signal, - }, - "mcp", - ); -} - -async function rpc( - url: string, - headers: Record, - method: string, - params: unknown, - signal?: AbortSignal, -): Promise<{ result: unknown; response: Response }> { - const response = await post( - url, - headers, - { jsonrpc: "2.0", id: REQUEST_ID, method, params }, - signal, - ); - - if (response.status === 401 || response.status === 403) { - throw new PluginDispatchError( - `Upstream rejected the credential (${response.status}); reconnect the plugin.`, - 401, - ); - } - if (response.status === 404 || response.status === 400) { - throw new PluginDispatchError( - `Upstream returned ${response.status} ${response.statusText}`, - response.status, - ); - } - if (!response.ok) { - throw new PluginDispatchError( - `Upstream returned ${response.status} ${response.statusText}`, - 502, - ); - } - - const text = await response.text(); - const frames = text - .split("\n") - .map((line) => - line.startsWith("data:") ? line.slice(5).trim() : line.trim(), - ) - .filter(Boolean); - - for (const frame of frames.reverse()) { - let payload: { - id?: unknown; - result?: unknown; - error?: { message: string }; - }; - try { - payload = JSON.parse(frame); - } catch { - continue; - } - if (payload.id !== REQUEST_ID) continue; - if (payload.error) { - throw new PluginDispatchError(payload.error.message, 502); - } - return { result: payload.result, response }; - } - - throw new PluginDispatchError( - "Upstream returned no JSON-RPC response for this request", - 502, - ); -} - -const PROTOCOL_VERSION = "2025-06-18"; - -async function initialize( - url: string, - headers: Record, - signal?: AbortSignal, -): Promise> { - const { result, response } = await rpc( - url, - headers, - "initialize", - { - protocolVersion: PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: "superset", version: "1.0.0" }, - }, - signal, - ); - - const session: Record = { - "mcp-protocol-version": - (result as { protocolVersion?: string } | undefined)?.protocolVersion ?? - PROTOCOL_VERSION, - }; - const id = response.headers.get("mcp-session-id"); - if (id) session["mcp-session-id"] = id; - - await post( - url, - { ...headers, ...session }, - { jsonrpc: "2.0", method: "notifications/initialized" }, - signal, - ); - return session; -} - -async function mcpCall( - target: { url: string; headers: Record }, - method: string, - params: unknown, - signal?: AbortSignal, -): Promise { - try { - return (await rpc(target.url, target.headers, method, params, signal)) - .result; - } catch (error) { - const wantsSession = - error instanceof PluginDispatchError && - (error.status === 400 || error.status === 404); - if (!wantsSession) throw error; - - const session = await initialize(target.url, target.headers, signal); - return ( - await rpc( - target.url, - { ...target.headers, ...session }, - method, - params, - signal, - ) - ).result; - } -} - -function remoteTarget( - manifest: PluginManifest, - scope: TemplateScope, - method?: string | null, -) { - const extension = supersetExtension(manifest); - const mcp = extension?.mcp; - if (!mcp?.url) return null; - - const authMethod = method - ? extension?.auth?.find((entry) => entry.type === method) - : undefined; - const methodBind = authMethod?.bind; - - const headers: Record = { - ...resolveTemplateDeep(mcp.headers ?? {}, scope), - ...resolveTemplateDeep(methodBind ?? extension?.bind ?? {}, scope).headers, - }; - return { - url: resolveUrlTemplate(mcp.url, scope, authMethod, "mcp.url"), - headers, - }; -} - -type BundledRun = (event: { - event: "get-tools" | "call-tool"; - eventBody: Record; - config: Record; -}) => Promise | unknown; - -interface ServerRef { - path: string; - integrity: string; - /** The release tag the bytes were published at, when the manifest pins one. */ - ref?: string; -} - -const loaded = new Map>(); - -const importAtRuntime = new Function( - "specifier", - "return import(specifier)", -) as (specifier: string) => Promise<{ run?: BundledRun }>; - -const MAX_SERVER_BYTES = 8 * 1024 * 1024; - -function serverRef(manifest: PluginManifest): ServerRef | null { - const server = supersetExtension(manifest)?.server; - if (!server?.path || !server.integrity) return null; - return { - path: server.path, - integrity: server.integrity, - ...(server.ref ? { ref: server.ref } : {}), - }; -} - -function digestOf(source: Buffer): string { - return `sha256-${createHash("sha256").update(source).digest("base64")}`; -} - -async function* streamOf(response: Response): AsyncGenerator { - const reader = response.body?.getReader(); - if (!reader) return; - while (true) { - const { done, value } = await reader.read(); - if (done) return; - if (value) yield Buffer.from(value); - } -} - -let serverCacheDir: string | undefined; -function cacheDir(): string { - if (!serverCacheDir) { - serverCacheDir = fs.mkdtempSync( - path.join(os.tmpdir(), "superset-plugin-servers-"), - ); - fs.chmodSync(serverCacheDir, 0o700); - } - return serverCacheDir; -} - -async function fetchServer( - pluginName: string, - source: BundledSource, - ref: ServerRef, -): Promise { - // The manifest's own ref pins the bytes to the release they were published - // at; the marketplace's ref is a branch that has moved on since. - const at = ref.ref ?? source.ref; - const url = `https://raw.githubusercontent.com/${source.repo}/${at}/${ref.path}`; - const response = await fetch(url); - if (!response.ok) { - throw new PluginDispatchError( - `Could not download the server for "${pluginName}" (${response.status} from ${url}).`, - 502, - ); - } - - const chunks: Buffer[] = []; - let total = 0; - const tooLarge = () => - new PluginDispatchError( - `Server for "${pluginName}" is over the ${MAX_SERVER_BYTES} byte limit.`, - 502, - ); - - const declared = Number(response.headers.get("content-length")); - if (Number.isFinite(declared) && declared > MAX_SERVER_BYTES) { - await response.body?.cancel(); - throw tooLarge(); - } - - for await (const chunk of streamOf(response)) { - total += chunk.byteLength; - if (total > MAX_SERVER_BYTES) { - await response.body?.cancel(); - throw tooLarge(); - } - chunks.push(chunk); - } - const body = Buffer.concat(chunks); - - const actual = digestOf(body); - if (actual !== ref.integrity) { - throw new PluginDispatchError( - `Server for "${pluginName}" does not match the published digest; refusing to run it.`, - 502, - ); - } - return body; -} - -async function bundledRun( - pluginName: string, - manifest: PluginManifest, - source: BundledSource | null, -): Promise { - const ref = serverRef(manifest); - if (!ref) { - throw new PluginDispatchError( - `Plugin "${pluginName}" declares no mcp url and no published server.`, - 501, - ); - } - if (!source) { - throw new PluginDispatchError( - `Plugin "${pluginName}" came from a marketplace this server cannot reach, so its bundled server cannot be downloaded.`, - 501, - ); - } - - const cached = loaded.get(ref.integrity); - if (cached) return await cached; - - const load = (async () => { - const key = Buffer.from(ref.integrity).toString("base64url"); - const dir = cacheDir(); - const file = path.join(dir, `${key}.mjs`); - - let usable = false; - try { - usable = digestOf(await fs.promises.readFile(file)) === ref.integrity; - } catch { - usable = false; - } - - if (!usable) { - const body = await fetchServer(pluginName, source, ref); - const staging = `${file}.${process.pid}.partial`; - await fs.promises.writeFile(staging, body); - await fs.promises.rename(staging, file); - } - - const module = await importAtRuntime(pathToFileURL(file).href); - if (typeof module.run !== "function") { - throw new PluginDispatchError( - `Plugin "${pluginName}" server exports no run().`, - 500, - ); - } - return module.run; - })(); - - loaded.set(ref.integrity, load); - try { - return await load; - } catch (error) { - loaded.delete(ref.integrity); - throw error; - } -} - -function bundledConfig(scope: TemplateScope): Record { - return { ...(scope.inputs ?? {}), ...(scope.config ?? {}) }; -} - -function abortable(promise: Promise, signal?: AbortSignal): Promise { - if (!signal) return promise; - return new Promise((resolve, reject) => { - const onAbort = () => - reject( - signal.reason instanceof Error - ? signal.reason - : new Error("Plugin call aborted"), - ); - if (signal.aborted) return onAbort(); - signal.addEventListener("abort", onAbort, { once: true }); - promise.then(resolve, reject).finally(() => { - signal.removeEventListener("abort", onAbort); - }); - }); -} - -async function bundledDispatch( - manifest: PluginManifest, - scope: TemplateScope, - source: BundledSource | null, - event: "get-tools" | "call-tool", - eventBody: Record, - signal?: AbortSignal, -): Promise { - const pluginName = manifest.name; - signal?.throwIfAborted(); - const run = await bundledRun(pluginName, manifest, source); - try { - // A bundled server runs in-process and cannot be cancelled; stop - // waiting on it when the caller's deadline passes instead. - return await abortable( - Promise.resolve(run({ event, eventBody, config: bundledConfig(scope) })), - signal, - ); - } catch (error) { - throw new PluginDispatchError( - `Bundled server for "${pluginName}" failed: ${error instanceof Error ? error.message : String(error)}`, - 502, - ); - } -} - -export async function listTools( - manifest: PluginManifest, - scope: TemplateScope, - method?: string | null, - source?: BundledSource | null, - options?: DispatchOptions, -): Promise { - const target = remoteTarget(manifest, scope, method); - if (!target) { - const tools = await bundledDispatch( - manifest, - scope, - source ?? null, - "get-tools", - {}, - options?.signal, - ); - return Array.isArray(tools) ? (tools as ToolDefinition[]) : []; - } - - const result = (await mcpCall(target, "tools/list", {}, options?.signal)) as { - tools?: ToolDefinition[]; - }; - return result.tools ?? []; -} - -export async function callTool( - manifest: PluginManifest, - scope: TemplateScope, - tool: string, - args: Record, - method?: string | null, - source?: BundledSource | null, - options?: DispatchOptions, -): Promise { - const target = remoteTarget(manifest, scope, method); - if (!target) { - return await bundledDispatch( - manifest, - scope, - source ?? null, - "call-tool", - { name: tool, arguments: args }, - options?.signal, - ); - } - - return await mcpCall( - target, - "tools/call", - { name: tool, arguments: args }, - options?.signal, - ); -} diff --git a/packages/trpc/src/router/plugins/manifest.test.ts b/packages/trpc/src/router/plugins/manifest.test.ts index f2f20ecfa37..9e7ef241bca 100644 --- a/packages/trpc/src/router/plugins/manifest.test.ts +++ b/packages/trpc/src/router/plugins/manifest.test.ts @@ -1,8 +1,6 @@ // biome-ignore-all lint/suspicious/noTemplateCurlyInString: ${config.*} and ${inputs.*} are the manifest placeholder syntax, not template literals import { describe, expect, test } from "bun:test"; import { - credentialFetch, - readPath, resolveTemplate, resolveTemplateDeep, resolveUrlTemplate, @@ -55,10 +53,7 @@ describe("resolveTemplate", () => { }); describe("resolveUrlTemplate", () => { - const auth = { - type: "api_key" as const, - inputs: [{ name: "site" }, { name: "api_key", secret: true }], - }; + const secrets = ["api_key"]; const withSecret = { config: { access_token: "tok_123" }, inputs: { site: "acme.atlassian.net", api_key: "lin_api_secret" }, @@ -66,23 +61,23 @@ describe("resolveUrlTemplate", () => { test("expands a non-secret input, which is what per-tenant hosts need", () => { expect( - resolveUrlTemplate("https://${inputs.site}/mcp", withSecret, auth), + resolveUrlTemplate("https://${inputs.site}/mcp", withSecret, secrets), ).toBe("https://acme.atlassian.net/mcp"); }); test("refuses to put the access token in a URL", () => { expect(() => - resolveUrlTemplate("https://x/?t=${config.access_token}", scope, auth), + resolveUrlTemplate("https://x/?t=${config.access_token}", scope, secrets), ).toThrow(/credential/); }); test("refuses to put a secret input in a URL", () => { expect(() => - resolveUrlTemplate("https://x/?k=${inputs.api_key}", withSecret, auth), + resolveUrlTemplate("https://x/?k=${inputs.api_key}", withSecret, secrets), ).toThrow(/secret/); }); - test("a secret input is still expandable where no manifest declares it", () => { + test("a secret input is still expandable where no connector declares it", () => { expect( resolveUrlTemplate( "https://x/?k=${inputs.api_key}", @@ -92,28 +87,12 @@ describe("resolveUrlTemplate", () => { ).toBe("https://x/?k=lin_api_secret"); }); - test("refuses an api_key's credential input even when it is not marked secret", () => { + test("refuses a connector's credential input even when it is not marked secret", () => { expect(() => resolveUrlTemplate( "https://x/?k=${inputs.token}", - { - inputs: { token: "raw" }, - }, - { - type: "api_key", - credential_input: "token", - inputs: [{ name: "token" }], - }, - ), - ).toThrow(/secret/); - }); - - test("refuses the default api_key input when the method names none", () => { - expect(() => - resolveUrlTemplate( - "https://x/?k=${inputs.api_key}", - { inputs: { api_key: "raw" } }, - { type: "api_key", inputs: [{ name: "api_key" }] }, + { inputs: { token: "raw" } }, + ["token"], ), ).toThrow(/secret/); }); @@ -122,98 +101,9 @@ describe("resolveUrlTemplate", () => { expect(() => resolveUrlTemplate( "https://${inputs.site}/mcp", - { - inputs: { site: "evil.example@real.example" }, - }, - auth, + { inputs: { site: "evil.example@real.example" } }, + secrets, ), ).toThrow(/host or path/); }); }); - -describe("readPath", () => { - test("reads a GitHub identity response", () => { - const payload = { id: 17528887, login: "harshithmullapudi" }; - expect(readPath(payload, "$.id")).toBe(17528887); - expect(readPath(payload, "$.login")).toBe("harshithmullapudi"); - }); - - test("reads a nested GraphQL response", () => { - const payload = { - data: { viewer: { organization: { id: "org-1", name: "Tegon" } } }, - }; - expect(readPath(payload, "$.data.viewer.organization.id")).toBe("org-1"); - }); - - test("indexes into arrays", () => { - expect( - readPath({ items: [{ id: "a" }, { id: "b" }] }, "$.items[1].id"), - ).toBe("b"); - }); - - test.each([ - "$.a.b.c", - "$.missing", - "$.items[9].id", - ])("returns undefined for %s rather than throwing", (path) => { - expect(readPath({ items: [] }, path)).toBeUndefined(); - }); - - test("tolerates a path without the $ prefix", () => { - expect(readPath({ a: 1 }, "a")).toBe(1); - }); -}); - -describe("credentialFetch", () => { - test.each([ - "http://linear.app/oauth/token", - "ftp://linear.app/token", - "file:///etc/passwd", - ])("refuses %j", async (url) => { - await expect(credentialFetch(url, {}, "token_url")).rejects.toThrow( - /must be https/, - ); - }); - - test("refuses a value that is not a URL", async () => { - await expect(credentialFetch("not a url", {}, "identity")).rejects.toThrow( - /is not a URL/, - ); - }); - - test("refuses to follow a redirect rather than resend the credential", async () => { - const original = globalThis.fetch; - globalThis.fetch = (async () => - new Response(null, { - status: 302, - headers: { location: "https://attacker.test/collect" }, - })) as typeof fetch; - - try { - await expect( - credentialFetch("https://linear.app/oauth/token", {}, "token_url"), - ).rejects.toThrow(/attacker\.test/); - } finally { - globalThis.fetch = original; - } - }); - - test("passes a plain https response through", async () => { - const original = globalThis.fetch; - globalThis.fetch = (async () => - new Response(JSON.stringify({ ok: true }), { - status: 200, - })) as typeof fetch; - - try { - const response = await credentialFetch( - "https://linear.app/oauth/token", - {}, - "token_url", - ); - expect(response.status).toBe(200); - } finally { - globalThis.fetch = original; - } - }); -}); diff --git a/packages/trpc/src/router/plugins/manifest.ts b/packages/trpc/src/router/plugins/manifest.ts index aab41e3edfb..df6fdf257b2 100644 --- a/packages/trpc/src/router/plugins/manifest.ts +++ b/packages/trpc/src/router/plugins/manifest.ts @@ -3,52 +3,6 @@ export const SUPERSET_EXTENSION = "superset"; /** The marketplace whose manifests we ship and review. */ export const FIRST_PARTY_MARKETPLACE = "superset"; -export interface AuthInput { - name: string; - label?: string; - placeholder?: string; - description?: string; - required?: boolean; - secret?: boolean; -} - -export interface AuthIdentity { - url: string; - method?: "GET" | "POST"; - headers?: Record; - body?: unknown; - id: string; - label?: string; -} - -export interface PluginAuthMethod { - type: "oauth2" | "api_key"; - label?: string; - provider?: string; - inputs?: AuthInput[]; - credential_input?: string; - authorization_url?: string; - token_url?: string; - scopes?: string[]; - scope_separator?: string; - token_request_auth_method?: string; - token_expiration_buffer?: number; - requires_env?: string[]; - identity?: AuthIdentity; - bind?: PluginBind; -} - -export type PluginAuth = PluginAuthMethod[]; - -export function authMethod( - auth: PluginAuth | undefined, - type?: string, -): PluginAuthMethod | undefined { - if (!auth?.length) return undefined; - if (!type) return auth.length === 1 ? auth[0] : undefined; - return auth.find((method) => method.type === type); -} - export interface PluginMcp { type: "streamable-http"; url: string; @@ -60,18 +14,23 @@ export interface PluginBind { env?: Record; } -export interface PluginServer { - path?: string; - integrity?: string; - ref?: string; +export interface PluginConnectorRef { + readonly slug: string; } export interface SupersetExtension { interface?: { displayName: string; category?: string; icon?: string }; - auth?: PluginAuth; + connector?: PluginConnectorRef; bind?: PluginBind; mcp?: PluginMcp; - server?: PluginServer; +} + +export function pluginConnector(manifest: PluginManifest): string | undefined { + return supersetExtension(manifest)?.connector?.slug; +} + +export function pluginNeedsConnection(manifest: PluginManifest): boolean { + return pluginConnector(manifest) !== undefined; } export interface PluginManifest { @@ -114,17 +73,10 @@ export function resolveTemplate(value: string, scope: TemplateScope): string { export function resolveUrlTemplate( value: string, scope: TemplateScope, - auth?: PluginAuthMethod, + secretInputs?: readonly string[], what = "URL", ): string { - const secrets = new Set( - (auth?.inputs ?? []) - .filter((input) => input.secret) - .map((input) => input.name), - ); - if (auth?.type === "api_key") { - secrets.add(auth.credential_input ?? DEFAULT_CREDENTIAL_INPUT); - } + const secrets = new Set(secretInputs ?? []); return value.replace(TEMPLATE, (whole, root: string, key: string) => { if (root === "config") { throw new Error( @@ -190,10 +142,13 @@ export function readPath(source: unknown, path: string): unknown { return current; } +const CREDENTIAL_FETCH_TIMEOUT_MS = 10_000; + export async function credentialFetch( url: string, init: RequestInit, what: string, + timeoutMs: number = CREDENTIAL_FETCH_TIMEOUT_MS, ): Promise { let parsed: URL; try { @@ -207,7 +162,12 @@ export async function credentialFetch( ); } - const response = await fetch(url, { ...init, redirect: "manual" }); + const deadline = AbortSignal.timeout(timeoutMs); + const response = await fetch(url, { + ...init, + redirect: "manual", + signal: init.signal ? AbortSignal.any([init.signal, deadline]) : deadline, + }); if (response.status >= 300 && response.status < 400) { throw new Error( `${what} URL redirected to ${response.headers.get("location") ?? "an unnamed location"}; refusing to resend the credential.`, diff --git a/packages/trpc/src/router/plugins/oauth.test.ts b/packages/trpc/src/router/plugins/oauth.test.ts deleted file mode 100644 index ae334398f13..00000000000 --- a/packages/trpc/src/router/plugins/oauth.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; - -mock.module("../../env", () => ({ - env: { - BETTER_AUTH_SECRET: "test-secret", - NEXT_PUBLIC_API_URL: "https://api.test", - }, -})); - -const { buildAuthorizationUrl, clientCredentials, redirectUri } = await import( - "./oauth" -); - -describe("clientCredentials", () => { - const vars = [ - "PLUGIN_LINEAR_CLIENT_ID", - "PLUGIN_LINEAR_CLIENT_SECRET", - "PLUGIN_GITHUB_CLIENT_SECRET", - "ADMIN_CLIENT_ID", - "ADMIN_CLIENT_SECRET", - "BETTER_AUTH_SECRET", - ]; - const saved = new Map(vars.map((name) => [name, process.env[name]])); - - afterEach(() => { - for (const name of vars) { - const value = saved.get(name); - if (value === undefined) delete process.env[name]; - else process.env[name] = value; - } - }); - - const method = (requires_env: string[]) => ({ - type: "oauth2" as const, - requires_env, - }); - - test("reads the pair the manifest names", () => { - process.env.PLUGIN_LINEAR_CLIENT_ID = "id"; - process.env.PLUGIN_LINEAR_CLIENT_SECRET = "secret"; - - expect( - clientCredentials( - method(["PLUGIN_LINEAR_CLIENT_ID", "PLUGIN_LINEAR_CLIENT_SECRET"]), - ), - ).toEqual({ clientId: "id", clientSecret: "secret" }); - }); - - // The point of naming them: an OAuth client belongs to the service, so a - // second plugin for it shares the pair rather than needing its own app. - test("lets a differently named plugin share one service's client", () => { - process.env.PLUGIN_LINEAR_CLIENT_ID = "id"; - process.env.PLUGIN_LINEAR_CLIENT_SECRET = "secret"; - - expect( - clientCredentials( - method(["PLUGIN_LINEAR_CLIENT_ID", "PLUGIN_LINEAR_CLIENT_SECRET"]), - ), - ).not.toBeNull(); - }); - - test("returns null when only one half is set", () => { - process.env.PLUGIN_LINEAR_CLIENT_ID = "id"; - delete process.env.PLUGIN_LINEAR_CLIENT_SECRET; - - expect( - clientCredentials( - method(["PLUGIN_LINEAR_CLIENT_ID", "PLUGIN_LINEAR_CLIENT_SECRET"]), - ), - ).toBeNull(); - }); - - test("returns null when the manifest names nothing", () => { - expect(clientCredentials(method([]))).toBeNull(); - }); - - // The exchange POSTs whatever this reads to a manifest-supplied token_url, - // so a name outside the client namespace would leak an unrelated secret. - // Each case names a pair that is shaped like a client but sits outside - // PLUGIN_*, so only the namespace guard can reject it. - test.each([ - ["ADMIN_CLIENT_ID", "ADMIN_CLIENT_SECRET"], - ["plugin_linear_client_id", "plugin_linear_client_secret"], - ])("refuses to read %s", (id, secret) => { - process.env[id] = "id"; - process.env[secret] = "server-secret"; - - expect(clientCredentials(method([id, secret]))).toBeNull(); - }); - - // Both halves are real client variables and both are set, so only the - // same-service pairing keeps GitHub's secret out of Linear's exchange. - test("refuses a pair split across two services", () => { - process.env.PLUGIN_LINEAR_CLIENT_ID = "linear-id"; - process.env.PLUGIN_GITHUB_CLIENT_SECRET = "github-secret"; - delete process.env.PLUGIN_LINEAR_CLIENT_SECRET; - - expect( - clientCredentials( - method(["PLUGIN_LINEAR_CLIENT_ID", "PLUGIN_GITHUB_CLIENT_SECRET"]), - ), - ).toBeNull(); - }); -}); - -describe("buildAuthorizationUrl", () => { - beforeEach(() => { - process.env.PLUGIN_LINEAR_CLIENT_ID = "id"; - process.env.PLUGIN_LINEAR_CLIENT_SECRET = "secret"; - }); - - afterEach(() => { - delete process.env.PLUGIN_LINEAR_CLIENT_ID; - delete process.env.PLUGIN_LINEAR_CLIENT_SECRET; - }); - - const auth = { - type: "oauth2" as const, - authorization_url: "https://linear.app/oauth/authorize", - scopes: ["read", "write"], - scope_separator: ",", - requires_env: ["PLUGIN_LINEAR_CLIENT_ID", "PLUGIN_LINEAR_CLIENT_SECRET"], - }; - - test("carries the client id, redirect, and state", () => { - const url = new URL( - buildAuthorizationUrl("linear", auth, { inputs: {} }, "state-token"), - ); - - expect(url.searchParams.get("client_id")).toBe("id"); - expect(url.searchParams.get("state")).toBe("state-token"); - expect(url.searchParams.get("response_type")).toBe("code"); - expect(url.searchParams.get("redirect_uri")).toBe(redirectUri("linear")); - }); - - test("joins scopes with the manifest's separator", () => { - const url = new URL( - buildAuthorizationUrl("linear", auth, { inputs: {} }, "s"), - ); - expect(url.searchParams.get("scope")).toBe("read,write"); - }); - - test("defaults the separator to a space", () => { - const url = new URL( - buildAuthorizationUrl( - "linear", - { ...auth, scope_separator: undefined }, - { inputs: {} }, - "s", - ), - ); - expect(url.searchParams.get("scope")).toBe("read write"); - }); - - test("refuses to build a URL for an unconfigured plugin", () => { - delete process.env.PLUGIN_LINEAR_CLIENT_SECRET; - expect(() => - buildAuthorizationUrl("linear", auth, { inputs: {} }, "s"), - ).toThrow(/No OAuth client configured/); - }); - - test("every plugin's callback sits under one registerable prefix", () => { - expect(redirectUri("linear")).toBe( - "https://api.test/api/plugins/callback/linear", - ); - }); -}); diff --git a/packages/trpc/src/router/plugins/oauth.ts b/packages/trpc/src/router/plugins/oauth.ts deleted file mode 100644 index d0dccafa169..00000000000 --- a/packages/trpc/src/router/plugins/oauth.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { env } from "../../env"; -import { - type AuthIdentity, - credentialFetch, - type PluginAuthMethod, - readPath, - resolveTemplateDeep, - resolveUrlTemplate, - type TemplateScope, -} from "./manifest"; - -/** - * The variables a manifest may name. An OAuth client is a property of the - * upstream product, not of the plugin, so a manifest has to be able to point - * at a pair a sibling plugin already uses. It must not be able to point at an - * unrelated secret: the token exchange POSTs whatever it reads to a - * manifest-supplied token_url, so an unbounded name here would send our own - * server secrets to a host the manifest chose. - */ -const CLIENT_ENV = /^PLUGIN_[A-Z0-9_]+_CLIENT_(ID|SECRET)$/; - -export function clientCredentials(auth: PluginAuthMethod): { - clientId: string; - clientSecret: string; -} | null { - const declared = (auth.requires_env ?? []).filter((name) => - CLIENT_ENV.test(name), - ); - // The pair has to name one service. Taking the first id and the first - // secret independently would post one product's client secret to another - // product's token_url the moment a manifest named two. - const clientId = declared.find((name) => name.endsWith("_CLIENT_ID")); - if (!clientId) return null; - const clientSecret = clientId.replace(/_CLIENT_ID$/, "_CLIENT_SECRET"); - if (!declared.includes(clientSecret)) return null; - - const id = process.env[clientId]; - const secret = process.env[clientSecret]; - return id && secret ? { clientId: id, clientSecret: secret } : null; -} - -export function redirectUri(pluginName: string): string { - return `${env.NEXT_PUBLIC_API_URL}/api/plugins/callback/${pluginName}`; -} - -export function buildAuthorizationUrl( - pluginName: string, - auth: PluginAuthMethod, - scope: TemplateScope, - state: string, -): string { - const credentials = clientCredentials(auth); - if (!credentials) { - throw new Error(`No OAuth client configured for plugin "${pluginName}".`); - } - if (!auth.authorization_url) { - throw new Error(`Plugin "${pluginName}" declares no authorization_url.`); - } - - const url = new URL( - resolveUrlTemplate( - auth.authorization_url, - scope, - auth, - "authorization_url", - ), - ); - url.searchParams.set("client_id", credentials.clientId); - url.searchParams.set("redirect_uri", redirectUri(pluginName)); - url.searchParams.set("response_type", "code"); - url.searchParams.set("state", state); - if (auth.scopes?.length) { - url.searchParams.set( - "scope", - auth.scopes.join(auth.scope_separator ?? " "), - ); - } - return url.toString(); -} - -export interface ExchangedToken { - accessToken: string; - refreshToken: string | null; - expiresAt: Date | null; - scopes: string[] | null; -} - -export async function exchangeCode( - pluginName: string, - auth: PluginAuthMethod, - scope: TemplateScope, - code: string, -): Promise { - const credentials = clientCredentials(auth); - if (!credentials) { - throw new Error(`No OAuth client configured for plugin "${pluginName}".`); - } - if (!auth.token_url) { - throw new Error(`Plugin "${pluginName}" declares no token_url.`); - } - - const body = new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: redirectUri(pluginName), - }); - - const headers: Record = { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }; - - if (auth.token_request_auth_method === "client_secret_basic") { - const basic = Buffer.from( - `${credentials.clientId}:${credentials.clientSecret}`, - ).toString("base64"); - headers.Authorization = `Basic ${basic}`; - } else { - body.set("client_id", credentials.clientId); - body.set("client_secret", credentials.clientSecret); - } - - const response = await credentialFetch( - resolveUrlTemplate(auth.token_url, scope, auth, "token_url"), - { method: "POST", headers, body }, - "token_url", - ); - - if (!response.ok) { - throw new Error( - `Token exchange failed: ${response.status} ${await response.text()}`, - ); - } - - const payload = (await response.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - scope?: string; - error?: string; - error_description?: string; - }; - - if (payload.error || !payload.access_token) { - throw new Error( - payload.error_description ?? payload.error ?? "No access_token returned", - ); - } - - const bufferSeconds = auth.token_expiration_buffer ?? 0; - return { - accessToken: payload.access_token, - refreshToken: payload.refresh_token ?? null, - expiresAt: payload.expires_in - ? new Date(Date.now() + (payload.expires_in - bufferSeconds) * 1000) - : null, - scopes: payload.scope - ? payload.scope.split(auth.scope_separator ?? " ").filter(Boolean) - : (auth.scopes ?? null), - }; -} - -export interface ResolvedIdentity { - id: string; - label: string | null; -} - -export async function resolveIdentity( - identity: AuthIdentity | undefined, - scope: TemplateScope, - fallbackId: string, - auth?: PluginAuthMethod, -): Promise { - if (!identity) return { id: fallbackId, label: null }; - - const method = identity.method ?? "GET"; - const headers = resolveTemplateDeep(identity.headers ?? {}, scope); - const response = await credentialFetch( - resolveUrlTemplate(identity.url, scope, auth, "identity.url"), - { - method, - headers, - body: - method === "POST" && identity.body !== undefined - ? JSON.stringify(resolveTemplateDeep(identity.body, scope)) - : undefined, - }, - "identity", - ); - - if (!response.ok) { - throw new Error( - `Identity request failed: ${response.status} ${response.statusText}`, - ); - } - - const payload = await response.json(); - const id = readPath(payload, identity.id); - if (id === undefined || id === null || id === "") { - throw new Error(`Identity response has nothing at ${identity.id}`); - } - - const label = identity.label ? readPath(payload, identity.label) : null; - return { - id: String(id), - label: label === undefined || label === null ? null : String(label), - }; -} diff --git a/packages/trpc/src/router/plugins/plugins.ts b/packages/trpc/src/router/plugins/plugins.ts index 7bea79ba0c1..fc751a8af31 100644 --- a/packages/trpc/src/router/plugins/plugins.ts +++ b/packages/trpc/src/router/plugins/plugins.ts @@ -1,6 +1,6 @@ import { db } from "@superset/db/client"; import { - pluginConnections, + connections, pluginInstalls, pluginMarketplaces, } from "@superset/db/schema"; @@ -9,64 +9,41 @@ import { firstPartyManifest, } from "@superset/shared/plugins"; import type { TRPCError, TRPCRouterRecord } from "@trpc/server"; -import { and, asc, eq, isNull, or } from "drizzle-orm"; +import { and, asc, eq, isNull } from "drizzle-orm"; import { z } from "zod"; import { userError } from "../../i18n-error"; +import { AmbiguousConnectionError } from "../../lib/connectors/lookup"; import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { AmbiguousPluginError, - bundledSource, - disconnect, - getConnection, installedPlugin, - installForConnection, installRecord, - listConnections, - manifestAuth, - templateScope, - upsertConnection, } from "./connections"; -import { callTool, listTools, PluginDispatchError } from "./dispatch"; import { - authMethod, - DEFAULT_CREDENTIAL_INPUT, type PluginManifest, + pluginConnector, + pluginNeedsConnection, supersetExtension, - trustedManifest, } from "./manifest"; -import { resolveIdentity } from "./oauth"; +import { forgetUpstreamTools } from "./proxy"; const FIRST_PARTY = "superset"; -function dispatchError(error: unknown): never { - if (error instanceof PluginDispatchError) { - const code = - error.status === 401 - ? "UNAUTHORIZED" - : error.status === 404 - ? "NOT_FOUND" - : error.status === 501 - ? "NOT_IMPLEMENTED" - : error.status === 400 - ? "BAD_REQUEST" - : "BAD_GATEWAY"; +function ambiguous(error: unknown): never { + if (error instanceof AmbiguousPluginError) { throw userError({ - code, + code: "CONFLICT", message: error.message, - i18nKey: "serverError.plugins.dispatchFailed", + i18nKey: "serverError.plugins.ambiguousPlugin", params: { reason: error.message }, }); } - throw error; -} - -function ambiguous(error: unknown): never { - if (error instanceof AmbiguousPluginError) { + if (error instanceof AmbiguousConnectionError) { throw userError({ code: "CONFLICT", message: error.message, - i18nKey: "serverError.plugins.ambiguousPlugin", - params: { reason: error.message }, + i18nKey: "serverError.plugins.ambiguousConnection", + params: { connector: error.connector }, }); } throw error; @@ -81,28 +58,6 @@ function notInstalled(name: string): TRPCError { }); } -async function connectionContext(userId: string, connectionId: string) { - const connection = await getConnection(userId, connectionId); - if (!connection) { - throw userError({ - code: "NOT_FOUND", - message: "Connection not found", - i18nKey: "serverError.plugins.connectionNotFound", - }); - } - - const install = await installForConnection(userId, connection).catch( - ambiguous, - ); - if (!install) throw notInstalled(connection.pluginName); - - return { - connection, - install, - source: await bundledSource(userId, install.marketplace), - }; -} - function describe( manifest: PluginManifest & { skills?: { name: string; description: string }[]; @@ -118,11 +73,7 @@ function describe( displayName: extension?.interface?.displayName ?? manifest.name, category: extension?.interface?.category ?? "Developer tools", icon: extension?.interface?.icon, - authMethods: (extension?.auth ?? []).map((method) => ({ - type: method.type, - label: method.label ?? null, - inputs: method.inputs ?? [], - })), + connector: pluginConnector(manifest) ?? null, mcpUrl: extension?.mcp?.url ?? null, skills: manifest.skills ?? [], homepage: (manifest as { homepage?: string }).homepage ?? null, @@ -276,85 +227,72 @@ const connectionsRouter = { list: protectedProcedure .input(z.object({ plugin: z.string().min(1).optional() }).optional()) .query(async ({ ctx, input }) => { - const rows = await listConnections(ctx.session.user.id, input?.plugin); - return rows.map((connection) => ({ - id: connection.id, - plugin: connection.pluginName, - account: connection.externalAccountLabel, - accountId: connection.externalAccountId, - scopes: connection.scopes, - createdAt: connection.createdAt, + const install = input?.plugin + ? await installedPlugin(ctx.session.user.id, input.plugin).catch( + () => null, + ) + : null; + const wanted = install ? pluginConnector(install.manifest) : undefined; + + const rows = await db + .select({ + id: connections.id, + connector: connections.connector, + account: connections.externalAccountLabel, + accountId: connections.externalAccountId, + user: connections.externalUserLabel, + scopes: connections.scopes, + createdAt: connections.createdAt, + }) + .from(connections) + .where( + and( + eq(connections.connectedByUserId, ctx.session.user.id), + isNull(connections.disconnectedAt), + wanted ? eq(connections.connector, wanted) : undefined, + ), + ); + + return rows.map((row) => ({ + id: row.id, + plugin: row.connector, + account: row.user ?? row.account, + accountId: row.accountId, + scopes: row.scopes, + createdAt: row.createdAt, })); }), disconnect: protectedProcedure .input(z.object({ connectionId: z.uuid() })) .mutation(async ({ ctx, input }) => { - const removed = await disconnect(ctx.session.user.id, input.connectionId); - if (!removed) { + const [row] = await db + .update(connections) + .set({ disconnectedAt: new Date(), disconnectReason: "user" }) + .where( + and( + eq(connections.id, input.connectionId), + eq(connections.connectedByUserId, ctx.session.user.id), + isNull(connections.disconnectedAt), + ), + ) + .returning({ id: connections.id }); + + if (!row) { throw userError({ code: "NOT_FOUND", message: "Connection not found", i18nKey: "serverError.plugins.connectionNotFound", }); } + forgetUpstreamTools(row.id); return { disconnected: input.connectionId }; }), } satisfies TRPCRouterRecord; -const toolsRouter = { - list: protectedProcedure - .input(z.object({ connectionId: z.uuid() })) - .query(async ({ ctx, input }) => { - const { connection, install, source } = await connectionContext( - ctx.session.user.id, - input.connectionId, - ); - try { - const tools = await listTools( - install.manifest, - await templateScope(connection), - connection.authMethod, - source, - ); - return { plugin: connection.pluginName, tools }; - } catch (error) { - dispatchError(error); - } - }), - - call: protectedProcedure - .input( - z.object({ - connectionId: z.uuid(), - tool: z.string().min(1), - arguments: z.record(z.string(), z.unknown()).default({}), - }), - ) - .mutation(async ({ ctx, input }) => { - const { connection, install, source } = await connectionContext( - ctx.session.user.id, - input.connectionId, - ); - try { - const result = await callTool( - install.manifest, - await templateScope(connection), - input.tool, - input.arguments, - connection.authMethod, - source, - ); - return { result }; - } catch (error) { - dispatchError(error); - } - }), -} satisfies TRPCRouterRecord; - export const pluginsRouter = createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) => { - const [installs, connections] = await Promise.all([ + const [installs, live] = await Promise.all([ db .select() .from(pluginInstalls) @@ -362,28 +300,33 @@ export const pluginsRouter = createTRPCRouter({ .orderBy(asc(pluginInstalls.pluginName)), db .select({ - id: pluginConnections.id, - pluginName: pluginConnections.pluginName, - account: pluginConnections.externalAccountLabel, + id: connections.id, + connector: connections.connector, + account: connections.externalAccountLabel, + user: connections.externalUserLabel, }) - .from(pluginConnections) + .from(connections) .where( and( - eq(pluginConnections.userId, ctx.session.user.id), - isNull(pluginConnections.disconnectedAt), + eq(connections.connectedByUserId, ctx.session.user.id), + isNull(connections.disconnectedAt), ), ), ]); - const held = new Map(); - for (const row of connections) { - const list = held.get(row.pluginName) ?? []; - list.push({ id: row.id, account: row.account }); - held.set(row.pluginName, list); + const held = new Map< + string, + { id: string; account: string | null; user: string | null }[] + >(); + for (const row of live) { + const list = held.get(row.connector) ?? []; + list.push({ id: row.id, account: row.account, user: row.user }); + held.set(row.connector, list); } const installed = installs.map((row) => { - const held_ = held.get(row.pluginName) ?? []; + const slug = pluginConnector(row.manifest as PluginManifest); + const held_ = slug ? (held.get(slug) ?? []) : []; const published = row.marketplace === FIRST_PARTY ? firstPartyManifest(row.pluginName)?.version @@ -396,7 +339,7 @@ export const pluginsRouter = createTRPCRouter({ latestVersion: published ?? null, connections: held_, accounts: held_ - .map((connection) => connection.account) + .map((connection) => connection.user ?? connection.account) .filter((account): account is string => account !== null), }; }); @@ -405,6 +348,38 @@ export const pluginsRouter = createTRPCRouter({ installs.map((row) => `${row.marketplace}/${row.pluginName}`), ); + const claimed = new Set( + installs + .map((row) => pluginConnector(row.manifest as PluginManifest)) + .filter((slug): slug is string => slug !== undefined), + ); + + const orphaned = [...held.entries()] + .filter(([slug]) => !claimed.has(slug)) + .map(([slug, rows]) => ({ + name: slug, + version: "", + description: "", + marketplace: FIRST_PARTY, + displayName: slug, + category: "Developer tools", + icon: undefined, + connector: slug, + mcpUrl: null, + skills: [] as { name: string; description: string }[], + homepage: null, + author: null, + license: null, + installed: false, + enabled: false, + installedAt: null as Date | null, + latestVersion: null, + connections: rows, + accounts: rows + .map((connection) => connection.user ?? connection.account) + .filter((account): account is string => account !== null), + })); + const available = Object.values(FIRST_PARTY_MANIFESTS) .filter( (manifest) => !installedKeys.has(`${FIRST_PARTY}/${manifest.name}`), @@ -419,7 +394,7 @@ export const pluginsRouter = createTRPCRouter({ accounts: [] as string[], })); - return [...installed, ...available]; + return [...installed, ...orphaned, ...available]; }), install: protectedProcedure @@ -478,7 +453,8 @@ export const pluginsRouter = createTRPCRouter({ plugin: input.name, version: manifest.version, marketplace: FIRST_PARTY, - needsConnection: Boolean(manifest.extensions?.superset?.auth), + connector: pluginConnector(manifest) ?? null, + needsConnection: pluginNeedsConnection(manifest), }; }), @@ -527,132 +503,60 @@ export const pluginsRouter = createTRPCRouter({ ).catch(ambiguous); if (!install) throw notInstalled(input.name); - const { id, marketplace, siblings } = install; + const { id, marketplace } = install; - await db - .update(pluginConnections) - .set({ - disconnectedAt: new Date(), - disconnectReason: "plugin_uninstalled", - accessToken: "", - refreshToken: null, - config: null, - }) - .where( - and( - eq(pluginConnections.userId, ctx.session.user.id), - isNull(pluginConnections.disconnectedAt), - siblings === 1 - ? or( - eq(pluginConnections.installId, id), - and( - isNull(pluginConnections.installId), - eq(pluginConnections.pluginName, input.name), - ), - ) - : eq(pluginConnections.installId, id), - ), - ); + const [uninstalled] = await db + .select({ manifest: pluginInstalls.manifest }) + .from(pluginInstalls) + .where(eq(pluginInstalls.id, id)) + .limit(1); await db.delete(pluginInstalls).where(eq(pluginInstalls.id, id)); - return { uninstalled: input.name, marketplace }; - }), - - connectApiKey: protectedProcedure - .input( - z.object({ - name: z.string().min(1), - inputs: z.record(z.string(), z.string()), - }), - ) - .mutation(async ({ ctx, input }) => { - const install = await installedPlugin( - ctx.session.user.id, - input.name, - ).catch(ambiguous); - if (!install) throw notInstalled(input.name); - - const authSpec = authMethod(manifestAuth(install.manifest), "api_key"); - if (!authSpec) { - throw userError({ - code: "BAD_REQUEST", - message: `Plugin "${input.name}" does not use api_key auth`, - i18nKey: "serverError.plugins.noApiKeyAuth", - params: { plugin: input.name }, - }); - } - - const inputs: Record = {}; - for (const spec of authSpec.inputs ?? []) { - const value = input.inputs[spec.name]; - if (value) inputs[spec.name] = value; - else if (spec.required) { - throw userError({ - code: "BAD_REQUEST", - message: `Missing required input "${spec.name}"`, - i18nKey: "serverError.plugins.missingInput", - params: { input: spec.name }, - }); - } - } - - const credentialName = - authSpec.credential_input ?? DEFAULT_CREDENTIAL_INPUT; - const credential = inputs[credentialName]; - if (!credential) { - throw userError({ - code: "BAD_REQUEST", - message: `Missing "${credentialName}"`, - i18nKey: "serverError.plugins.missingInput", - params: { input: credentialName }, - }); - } - - let identity: { id: string; label: string | null }; - try { - identity = await resolveIdentity( - trustedManifest(install.marketplace) ? authSpec.identity : undefined, - { config: { access_token: credential }, inputs }, - authSpec.type, - authSpec, + const connector = uninstalled + ? pluginConnector(uninstalled.manifest as PluginManifest) + : null; + + const stillShared = + connector && + ( + await db + .select({ manifest: pluginInstalls.manifest }) + .from(pluginInstalls) + .where(eq(pluginInstalls.userId, ctx.session.user.id)) + ).some( + (entry) => + pluginConnector(entry.manifest as PluginManifest) === connector, ); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - throw userError({ - code: "BAD_REQUEST", - message: `Could not verify the credential: ${reason}`, - i18nKey: "serverError.plugins.credentialUnverified", - params: { reason }, - }); - } - const connection = await upsertConnection({ - userId: ctx.session.user.id, - organizationId: null, - pluginName: input.name, - installId: install.id, - authMethod: authSpec.type, - accessToken: credential, - inputs, - secretInputs: [ - credentialName, - ...(authSpec.inputs ?? []) - .filter((spec) => spec.secret && spec.name !== credentialName) - .map((spec) => spec.name), - ], - externalAccountId: identity.id, - externalAccountLabel: identity.label, - }); + const disconnected = + connector && !stillShared + ? await db + .update(connections) + .set({ + disconnectedAt: new Date(), + disconnectReason: "plugin_uninstalled", + }) + .where( + and( + eq(connections.connector, connector), + eq(connections.ownerKind, "user"), + eq(connections.connectedByUserId, ctx.session.user.id), + isNull(connections.disconnectedAt), + ), + ) + .returning({ id: connections.id }) + : []; + + for (const row of disconnected) forgetUpstreamTools(row.id); return { - connectionId: connection.id, - plugin: input.name, - account: connection.externalAccountLabel, + uninstalled: input.name, + marketplace, + disconnected: disconnected.length, }; }), marketplaces: marketplacesRouter, connections: connectionsRouter, - tools: toolsRouter, }); diff --git a/packages/trpc/src/router/plugins/proxy/index.ts b/packages/trpc/src/router/plugins/proxy/index.ts new file mode 100644 index 00000000000..eb4471fae84 --- /dev/null +++ b/packages/trpc/src/router/plugins/proxy/index.ts @@ -0,0 +1,9 @@ +export { AmbiguousConnectionError } from "../../../lib/connectors/lookup"; +export { AmbiguousPluginError } from "../connections"; +export { buildPluginServer } from "./plugin-server"; +export { + type PluginTarget, + PluginTargetError, + resolveTarget, +} from "./resolve-target"; +export { forgetUpstreamTools } from "./upstream-catalog"; diff --git a/packages/trpc/src/router/plugins/proxy/plugin-server.ts b/packages/trpc/src/router/plugins/proxy/plugin-server.ts new file mode 100644 index 00000000000..c72f03e77b3 --- /dev/null +++ b/packages/trpc/src/router/plugins/proxy/plugin-server.ts @@ -0,0 +1,94 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import type { PluginTarget } from "./resolve-target"; +import { upstreamTools } from "./upstream-catalog"; +import { upstreamClient } from "./upstream-client"; + +function bare(name: string, version: string): Server { + return new Server({ name, version }, { capabilities: { tools: {} } }); +} + +function needsAuthServer( + target: Extract, +): Server { + const server = bare(target.plugin, "0.0.0"); + const detail = target.reason ? ` (${target.reason})` : ""; + const tool = { + name: "authenticate", + description: `${target.connector} is not connected${detail}. Call this to get a link for the user; the plugin's real tools appear once they finish.`, + inputSchema: { type: "object" as const, properties: {} }, + annotations: { readOnlyHint: true }, + }; + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [tool], + })); + server.setRequestHandler(CallToolRequestSchema, async () => ({ + isError: true, + content: [ + { + type: "text" as const, + text: `Ask the user to open ${target.connectUrl} and authorize ${target.connector}, then retry.`, + }, + ], + })); + return server; +} + +function firstPartyServer( + target: Extract, +): Server { + const server = bare(target.plugin, target.version); + const credential = target.build.credential(target.secrets); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: target.build.getTools(), + })); + server.setRequestHandler(CallToolRequestSchema, async (request) => + target.build.callTool( + request.params.name, + request.params.arguments ?? {}, + credential, + ), + ); + return server; +} + +function remoteServer( + target: Extract, +): Server { + const server = bare(target.plugin, target.version); + + // Resolved inside the handler, not while building the server: the route + // rebuilds this per request, so fetching eagerly made a tools/call open one + // upstream session for a tool list nothing would read, then a second to + // make the call. + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: await upstreamTools(target.connectionId, target.plugin, target), + })); + server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + const session = await upstreamClient(target); + try { + return await session.client.callTool(request.params, undefined, { + signal: extra.signal, + }); + } finally { + await session.close(); + } + }); + return server; +} + +export async function buildPluginServer(target: PluginTarget): Promise { + switch (target.kind) { + case "needs-auth": + return needsAuthServer(target); + case "first-party": + return firstPartyServer(target); + case "remote": + return remoteServer(target); + } +} diff --git a/packages/trpc/src/router/plugins/proxy/resolve-target.test.ts b/packages/trpc/src/router/plugins/proxy/resolve-target.test.ts new file mode 100644 index 00000000000..9f1acea2599 --- /dev/null +++ b/packages/trpc/src/router/plugins/proxy/resolve-target.test.ts @@ -0,0 +1,343 @@ +// biome-ignore-all lint/suspicious/noTemplateCurlyInString: ${config.*} is the manifest placeholder syntax, not a template literal +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import type { InstalledPlugin } from "../connections"; + +// Every dependency that reaches the database is stubbed so the validated env +// and the connection it opens at import stay out of this test's module graph. +// `mock.module` is process-wide, so each stub lists every export the real +// module has: another file's import resolves against the stub too. + +let install: InstalledPlugin | null = null; +let installedCalls: Array<[string, string, string | undefined]> = []; +let active: Record | null = null; +let pinned: Record | null = null; +let pinnedCalls: Array<[string, unknown]> = []; +let refreshError: Error | null = null; + +class StubUnrefreshable extends Error { + constructor(connector: string) { + super(`The ${connector} connection expired and carries no refresh token.`); + this.name = "UnrefreshableConnectionError"; + } +} + +class StubUnavailable extends Error { + constructor( + readonly connector: string, + detail: string, + ) { + super(`The ${connector} token endpoint is unavailable: ${detail}`); + this.name = "ConnectorUnavailableError"; + } +} + +mock.module("../../../env", () => ({ + env: { NEXT_PUBLIC_API_URL: "https://api.superset.test" }, +})); + +mock.module("../connections", () => ({ + installedPlugin: (userId: string, plugin: string, marketplace?: string) => { + installedCalls.push([userId, plugin, marketplace]); + return Promise.resolve(install); + }, + installRecord: () => Promise.resolve(null), + installById: () => Promise.resolve(null), + installedManifest: () => Promise.resolve(null), + AmbiguousPluginError: class extends Error {}, +})); + +mock.module("../../../lib/connectors/lookup", () => ({ + connectionById: (id: string, options: unknown) => { + pinnedCalls.push([id, options]); + return Promise.resolve(pinned); + }, + orgConnection: () => Promise.resolve(null), + userConnection: () => Promise.resolve(null), + accountConnection: () => Promise.resolve(null), + accountConnections: () => Promise.resolve([]), + connectorConnections: () => Promise.resolve([]), + connectionBotToken: () => Promise.resolve(null), + AmbiguousConnectionError: class extends Error {}, +})); + +mock.module("../../../lib/connectors/upsert", () => ({ + activeConnection: () => Promise.resolve(active), + connectionSecrets: (row: { id: string }) => + Promise.resolve({ + accessToken: `token-for-${row.id}`, + refreshToken: null, + config: { bot_token: null }, + }), + connectionConflict: () => Promise.resolve(null), + upsertConnection: () => Promise.resolve(null), +})); + +mock.module("../../../lib/connectors/refresh", () => ({ + ensureFreshConnection: (row: Record) => + refreshError ? Promise.reject(refreshError) : Promise.resolve(row), + connectionAccessToken: () => Promise.resolve("token"), + NEEDS_REAUTH: "needs_reauth", + ConnectorUnavailableError: StubUnavailable, + UnrefreshableConnectionError: StubUnrefreshable, +})); + +const { PluginTargetError, resolveTarget } = await import("./resolve-target"); + +interface ManifestOptions { + name?: string; + connector?: string; + mcpUrl?: string; + bindHeaders?: Record; +} + +function manifest({ + name = "acme", + connector, + mcpUrl, + bindHeaders, +}: ManifestOptions) { + const extension: Record = { + interface: { displayName: name }, + }; + if (connector) extension.connector = { slug: connector }; + if (mcpUrl) extension.mcp = { type: "streamable-http", url: mcpUrl }; + if (bindHeaders) extension.bind = { headers: bindHeaders }; + return { + name, + version: "1.2.3", + description: `${name} plugin`, + extensions: { superset: extension }, + }; +} + +function installed( + marketplace: string, + options: ManifestOptions, +): InstalledPlugin { + return { + id: "install-1", + marketplace, + manifest: manifest(options) as InstalledPlugin["manifest"], + }; +} + +const request = { + userId: "user-1", + organizationId: "org-1", + marketplace: "superset", + plugin: "acme", +}; + +beforeEach(() => { + install = null; + active = null; + pinned = null; + refreshError = null; + installedCalls = []; + pinnedCalls = []; +}); + +describe("resolveTarget", () => { + test("404s when the plugin is not installed", async () => { + await expect(resolveTarget(request)).rejects.toThrow(PluginTargetError); + await resolveTarget(request).then( + () => expect.unreachable("should have thrown"), + (error: PluginTargetError) => expect(error.status).toBe(404), + ); + expect(installedCalls[0]).toEqual(["user-1", "acme", "superset"]); + }); + + test("404s when a connector-free plugin declares no mcp url", async () => { + install = installed("superset", {}); + await resolveTarget(request).then( + () => expect.unreachable("should have thrown"), + (error: PluginTargetError) => { + expect(error).toBeInstanceOf(PluginTargetError); + expect(error.status).toBe(404); + expect(error.message).toContain("exposes no tools"); + }, + ); + }); + + test("serves a connector-free plugin from its mcp url, keyed by the install", async () => { + install = installed("superset", { mcpUrl: "https://mcp.acme.test/mcp" }); + + const target = await resolveTarget(request); + + expect(target).toMatchObject({ + kind: "remote", + plugin: "acme", + version: "1.2.3", + url: "https://mcp.acme.test/mcp", + connectionId: "install-1", + }); + }); + + test("asks for auth when the connector has no active connection", async () => { + install = installed("superset", { connector: "acme-crm" }); + + const target = await resolveTarget(request); + + expect(target).toMatchObject({ + kind: "needs-auth", + connector: "acme-crm", + }); + expect(target).toHaveProperty( + "connectUrl", + "https://api.superset.test/api/connectors/acme-crm/connect?method=oauth2&organizationId=org-1", + ); + }); + + test("asks for auth, with the reason, when the token cannot be refreshed", async () => { + install = installed("superset", { connector: "acme-crm" }); + active = { id: "conn-1", authMethod: "oauth2" }; + refreshError = new StubUnrefreshable("acme-crm"); + + const target = await resolveTarget(request); + + expect(target.kind).toBe("needs-auth"); + expect(target).toHaveProperty( + "reason", + expect.stringContaining("carries no refresh token"), + ); + }); + + test("rethrows a refresh failure that is not an expired credential", async () => { + install = installed("superset", { connector: "acme-crm" }); + active = { id: "conn-1", authMethod: "oauth2" }; + refreshError = new Error("token endpoint is down"); + + await expect(resolveTarget(request)).rejects.toThrow( + "token endpoint is down", + ); + }); + + test("an unreachable token endpoint is a bad gateway, not a reconnect prompt", async () => { + install = installed("superset", { connector: "acme-crm" }); + active = { id: "conn-1", authMethod: "oauth2" }; + refreshError = new StubUnavailable("acme-crm", "503 Service Unavailable"); + + // needs-auth would tell the user to reconnect a connection that is fine. + const error = await resolveTarget(request).catch((e) => e); + expect(error).toBeInstanceOf(PluginTargetError); + expect(error.status).toBe(502); + }); + + test("binds the connection's credential into the remote server's headers", async () => { + install = installed("superset", { + connector: "acme-crm", + mcpUrl: "https://mcp.acme.test/mcp", + bindHeaders: { Authorization: "Bearer ${config.access_token}" }, + }); + active = { id: "conn-1", authMethod: "oauth2" }; + + const target = await resolveTarget(request); + + expect(target).toMatchObject({ + kind: "remote", + url: "https://mcp.acme.test/mcp", + headers: { Authorization: "Bearer token-for-conn-1" }, + connectionId: "conn-1", + }); + }); + + test("serves a first-party hosted plugin from its in-tree server", async () => { + install = { + id: "install-1", + marketplace: "superset", + manifest: manifest({ + name: "gmail", + connector: "google", + }) as InstalledPlugin["manifest"], + }; + active = { id: "conn-1", authMethod: "oauth2" }; + + const target = await resolveTarget({ ...request, plugin: "gmail" }); + + expect(target).toMatchObject({ kind: "first-party", plugin: "gmail" }); + expect(target).toHaveProperty("secrets.accessToken", "token-for-conn-1"); + }); + + test("refuses to hand a hosted server to a plugin from another marketplace", async () => { + install = { + id: "install-1", + marketplace: "community", + manifest: manifest({ + name: "gmail", + connector: "google", + }) as InstalledPlugin["manifest"], + }; + active = { id: "conn-1", authMethod: "oauth2" }; + + await resolveTarget({ + ...request, + marketplace: "community", + plugin: "gmail", + }).then( + () => expect.unreachable("a foreign gmail must not get the real server"), + (error: PluginTargetError) => { + expect(error).toBeInstanceOf(PluginTargetError); + expect(error.status).toBe(501); + }, + ); + }); + + test("ignores a pinned connection that belongs to another user", async () => { + install = installed("superset", { connector: "acme-crm" }); + pinned = { + id: "conn-2", + connectedByUserId: "user-2", + organizationId: "org-1", + }; + + const target = await resolveTarget({ + ...request, + connectionId: "conn-2", + }); + + expect(target.kind).toBe("needs-auth"); + expect(pinnedCalls[0]).toEqual(["conn-2", { connector: "acme-crm" }]); + }); + + test("ignores a pinned connection from another organization", async () => { + install = installed("superset", { connector: "acme-crm" }); + pinned = { + id: "conn-3", + connectedByUserId: "user-1", + organizationId: "org-2", + }; + + const target = await resolveTarget({ ...request, connectionId: "conn-3" }); + + expect(target.kind).toBe("needs-auth"); + }); + + test("uses a pinned connection the caller owns", async () => { + install = installed("superset", { + connector: "acme-crm", + mcpUrl: "https://mcp.acme.test/mcp", + }); + pinned = { + id: "conn-4", + connectedByUserId: "user-1", + organizationId: "org-1", + authMethod: "oauth2", + }; + + const target = await resolveTarget({ ...request, connectionId: "conn-4" }); + + expect(target).toMatchObject({ kind: "remote", connectionId: "conn-4" }); + }); + + test("501s when a connected plugin has neither an mcp url nor a hosted server", async () => { + install = installed("superset", { connector: "acme-crm" }); + active = { id: "conn-1", authMethod: "oauth2" }; + + await resolveTarget(request).then( + () => expect.unreachable("should have thrown"), + (error: PluginTargetError) => { + expect(error.status).toBe(501); + }, + ); + }); +}); diff --git a/packages/trpc/src/router/plugins/proxy/resolve-target.ts b/packages/trpc/src/router/plugins/proxy/resolve-target.ts new file mode 100644 index 00000000000..321078e832b --- /dev/null +++ b/packages/trpc/src/router/plugins/proxy/resolve-target.ts @@ -0,0 +1,221 @@ +import type { SelectConnection } from "@superset/db/schema"; +import { getConnector, secretInputNames } from "@superset/shared/connectors"; +import { env } from "../../../env"; +import { connectionById } from "../../../lib/connectors/lookup"; +import { + ConnectorUnavailableError, + ensureFreshConnection, + UnrefreshableConnectionError, +} from "../../../lib/connectors/refresh"; +import { + activeConnection, + type ConnectionSecrets, + connectionSecrets, +} from "../../../lib/connectors/upsert"; +import { installedPlugin } from "../connections"; +import { + type PluginManifest, + pluginConnector, + resolveTemplateDeep, + resolveUrlTemplate, + supersetExtension, + type TemplateScope, + trustedManifest, +} from "../manifest"; +import { type FirstPartyServer, firstPartyServer } from "../servers"; + +export class PluginTargetError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + +export interface TargetIdentity { + plugin: string; + version: string; +} + +export type PluginTarget = TargetIdentity & + ( + | { + kind: "first-party"; + build: FirstPartyServer; + secrets: ConnectionSecrets; + connectionId: string; + } + | { + kind: "remote"; + url: string; + headers: Record; + connectionId: string; + } + | { + kind: "needs-auth"; + connector: string; + connectUrl: string; + reason?: string; + } + ); + +export interface TargetRequest { + userId: string; + organizationId: string | null; + /** Omitted by in-process callers with no URL to read it from; the install + * is then resolved by name alone and ambiguity across marketplaces throws. */ + marketplace?: string; + plugin: string; + connectionId?: string | null; +} + +function connectUrl(slug: string, organizationId: string | null): string { + const params = new URLSearchParams({ method: "oauth2" }); + if (organizationId) params.set("organizationId", organizationId); + return `${env.NEXT_PUBLIC_API_URL}/api/connectors/${slug}/connect?${params}`; +} + +async function pinnedConnection( + connectionId: string, + slug: string, + { userId, organizationId }: TargetRequest, +): Promise { + const row = await connectionById(connectionId, { connector: slug }); + if (!row) return null; + if (row.connectedByUserId !== userId) return null; + if (organizationId && row.organizationId !== organizationId) return null; + return row; +} + +function remoteBinding( + manifest: PluginManifest, + scope: TemplateScope, + authMethod: string | null, +): { url: string; headers: Record } | null { + const extension = supersetExtension(manifest); + const mcp = extension?.mcp; + if (!mcp?.url) return null; + + const slug = pluginConnector(manifest); + const connector = slug ? getConnector(slug) : undefined; + const method = authMethod + ? connector?.methods.find((entry) => entry.type === authMethod) + : connector?.methods[0]; + + return { + url: resolveUrlTemplate( + mcp.url, + scope, + method ? secretInputNames(method) : undefined, + "mcp.url", + ), + headers: { + ...resolveTemplateDeep(mcp.headers ?? {}, scope), + ...resolveTemplateDeep(method?.bind ?? extension?.bind ?? {}, scope) + .headers, + }, + }; +} + +export async function resolveTarget( + request: TargetRequest, +): Promise { + const install = await installedPlugin( + request.userId, + request.plugin, + request.marketplace, + ); + if (!install) { + throw new PluginTargetError( + request.marketplace + ? `"${request.plugin}" is not installed from ${request.marketplace}.` + : `"${request.plugin}" is not installed.`, + 404, + ); + } + + const identity: TargetIdentity = { + plugin: install.manifest.name, + version: install.manifest.version, + }; + const slug = pluginConnector(install.manifest); + // A hosted server runs against a first-party connection, so only a + // first-party manifest may claim one: otherwise any marketplace could + // publish a plugin named "gmail" and be handed the real Gmail tools. + const local = trustedManifest(install.marketplace) + ? firstPartyServer(install.manifest.name) + : undefined; + + if (!slug) { + const binding = remoteBinding(install.manifest, {}, null); + if (!binding) { + throw new PluginTargetError(`"${request.plugin}" exposes no tools.`, 404); + } + return { + ...identity, + kind: "remote", + ...binding, + connectionId: install.id, + }; + } + + const row = request.connectionId + ? await pinnedConnection(request.connectionId, slug, request) + : await activeConnection(request.userId, slug, request.organizationId); + if (!row) { + return { + ...identity, + kind: "needs-auth", + connector: slug, + connectUrl: connectUrl(slug, request.organizationId), + }; + } + + let secrets: ConnectionSecrets; + let authMethod: string | null; + try { + const fresh = await ensureFreshConnection(row); + authMethod = fresh.authMethod; + secrets = await connectionSecrets(fresh); + } catch (error) { + if (error instanceof UnrefreshableConnectionError) { + return { + ...identity, + kind: "needs-auth", + connector: slug, + connectUrl: connectUrl(slug, request.organizationId), + reason: error.message, + }; + } + // The provider is down, not the connection: sending the user to + // reconnect would be a lie they cannot act on. + if (error instanceof ConnectorUnavailableError) { + throw new PluginTargetError(error.message, 502); + } + throw error; + } + + if (local) { + return { + ...identity, + kind: "first-party", + build: local, + secrets, + connectionId: row.id, + }; + } + + const scope: TemplateScope = { + config: { access_token: secrets.accessToken, ...secrets.config }, + }; + const binding = remoteBinding(install.manifest, scope, authMethod); + if (!binding) { + throw new PluginTargetError( + `"${request.plugin}" declares no mcp url and has no first-party server.`, + 501, + ); + } + + return { ...identity, kind: "remote", ...binding, connectionId: row.id }; +} diff --git a/packages/trpc/src/router/plugins/proxy/upstream-catalog.ts b/packages/trpc/src/router/plugins/proxy/upstream-catalog.ts new file mode 100644 index 00000000000..bb6a936aced --- /dev/null +++ b/packages/trpc/src/router/plugins/proxy/upstream-catalog.ts @@ -0,0 +1,75 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { type UpstreamTarget, upstreamClient } from "./upstream-client"; + +const CATALOG_TTL_MS = 5 * 60_000; + +interface CatalogEntry { + connectionId: string; + tools: Tool[]; + at: number; +} + +const catalog = new Map(); +const inFlight = new Map>(); + +// One connection can back several plugins — every Google plugin resolves to the +// same "google" row — so the connection alone would serve one plugin's tools to +// another. +function catalogKey(connectionId: string, plugin: string): string { + return `${connectionId}:${plugin}`; +} + +function prune(): void { + const now = Date.now(); + for (const [key, entry] of catalog) { + if (now - entry.at >= CATALOG_TTL_MS) catalog.delete(key); + } +} + +async function fetchTools( + key: string, + connectionId: string, + target: UpstreamTarget, +): Promise { + const session = await upstreamClient(target); + try { + const { tools } = await session.client.listTools(); + prune(); + catalog.set(key, { connectionId, tools, at: Date.now() }); + return tools; + } finally { + await session.close(); + } +} + +export async function upstreamTools( + connectionId: string, + plugin: string, + target: UpstreamTarget, +): Promise { + const key = catalogKey(connectionId, plugin); + const cached = catalog.get(key); + if (cached && Date.now() - cached.at < CATALOG_TTL_MS) return cached.tools; + + // Without this a burst on a cold key opens one upstream session per request, + // each of which also has to be torn down again. + const pending = inFlight.get(key); + if (pending) return await pending; + + const load = fetchTools(key, connectionId, target).finally(() => { + inFlight.delete(key); + }); + inFlight.set(key, load); + return await load; +} + +/** + * Drop every plugin's catalog for a connection, so a reconnect with different + * scopes does not serve the old tool list. Best-effort: the cache is per + * process, and only the process handling the disconnect is cleared. + */ +export function forgetUpstreamTools(connectionId: string): void { + for (const [key, entry] of catalog) { + if (entry.connectionId === connectionId) catalog.delete(key); + } +} diff --git a/packages/trpc/src/router/plugins/proxy/upstream-client.ts b/packages/trpc/src/router/plugins/proxy/upstream-client.ts new file mode 100644 index 00000000000..ec72eb1c5a0 --- /dev/null +++ b/packages/trpc/src/router/plugins/proxy/upstream-client.ts @@ -0,0 +1,41 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { credentialFetch } from "../manifest"; + +const UPSTREAM_TIMEOUT_MS = 120_000; + +export interface UpstreamTarget { + url: string; + headers: Record; +} + +export interface UpstreamSession { + client: Client; + close(): Promise; +} + +export async function upstreamClient( + target: UpstreamTarget, +): Promise { + const client = new Client({ name: "superset", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(target.url), { + requestInit: { headers: target.headers }, + fetch: (url, init) => + credentialFetch(String(url), init ?? {}, "mcp", UPSTREAM_TIMEOUT_MS), + }); + await client.connect(transport); + + return { + client, + async close() { + // close() only aborts our end, so the vendor holds the session open + // until it expires on its own. The DELETE is what releases it, and a + // server that refuses one is not a reason to fail the call that just + // succeeded. + try { + await transport.terminateSession(); + } catch {} + await client.close(); + }, + }; +} diff --git a/packages/trpc/src/router/plugins/servers/gmail/api.ts b/packages/trpc/src/router/plugins/servers/gmail/api.ts new file mode 100644 index 00000000000..dec732df7be --- /dev/null +++ b/packages/trpc/src/router/plugins/servers/gmail/api.ts @@ -0,0 +1,125 @@ +import type { ToolResult } from "./types"; + +const BASE = "https://gmail.googleapis.com/gmail/v1/users/me"; + +export interface GmailRequest { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + query?: Record; + body?: unknown; +} + +function safePath(path: string): string { + return path + .split("/") + .map((segment) => { + if (segment === "." || segment === "..") { + throw new Error(`invalid Gmail path segment "${segment}"`); + } + return encodeURIComponent(segment); + }) + .join("/"); +} + +export async function gmail>( + accessToken: string, + path: string, + request: GmailRequest = {}, +): Promise { + const url = new URL(`${BASE}${safePath(path)}`); + for (const [key, value] of Object.entries(request.query ?? {})) { + if (value === undefined || value === null || value === "") continue; + if (Array.isArray(value)) { + for (const item of value) url.searchParams.append(key, String(item)); + } else { + url.searchParams.set(key, String(value)); + } + } + + const response = await fetch(url, { + method: request.method ?? "GET", + headers: { + authorization: `Bearer ${accessToken}`, + ...(request.body === undefined + ? {} + : { "content-type": "application/json" }), + }, + ...(request.body === undefined + ? {} + : { body: JSON.stringify(request.body) }), + }); + + if (response.status === 204) return {} as T; + + const payload = (await response.json().catch(() => null)) as { + error?: { message?: string; status?: string }; + } | null; + + if (!response.ok) { + const detail = + payload?.error?.message ?? `${response.status} ${response.statusText}`; + throw new Error(`Gmail API error: ${detail}`); + } + return (payload ?? {}) as T; +} + +export function text(value: string): ToolResult { + return { content: [{ type: "text", text: value }] }; +} + +export function failure(value: string): ToolResult { + return { ...text(value), isError: true }; +} + +export function stringList(value: unknown, field: string): string[] { + if (value === undefined || value === null) return []; + if (typeof value === "string") return value.trim() ? [value.trim()] : []; + if (!Array.isArray(value)) { + throw new Error(`${field} must be a string or an array of strings`); + } + return value.map((entry) => String(entry).trim()).filter(Boolean); +} + +export function requireString( + args: Record, + field: string, +): string { + const value = args[field]; + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${field} is required`); + } + return value.trim(); +} + +export function optionalNumber( + args: Record, + field: string, +): number | undefined { + const value = args[field]; + if (value === undefined || value === null) return undefined; + const parsed = Number(value); + if (!Number.isFinite(parsed)) throw new Error(`${field} must be a number`); + return parsed; +} + +const MAX_CONCURRENT_REQUESTS = 5; + +export async function mapLimited( + items: T[], + fn: (item: T, index: number) => Promise, + limit = MAX_CONCURRENT_REQUESTS, +): Promise { + const results = new Array(items.length); + let next = 0; + + const worker = async () => { + while (next < items.length) { + const index = next++; + results[index] = await fn(items[index] as T, index); + } + }; + + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, worker), + ); + return results; +} diff --git a/packages/trpc/src/router/plugins/servers/gmail/handlers/drafts.ts b/packages/trpc/src/router/plugins/servers/gmail/handlers/drafts.ts new file mode 100644 index 00000000000..07a9f7d5091 --- /dev/null +++ b/packages/trpc/src/router/plugins/servers/gmail/handlers/drafts.ts @@ -0,0 +1,113 @@ +import { gmail, mapLimited, optionalNumber, requireString, text } from "../api"; +import { + encodeRaw, + type GmailMessage, + parseMessage, + readEmailFields, + renderMessage, +} from "../mime"; +import type { Handler } from "../types"; + +interface Draft { + id?: string; + message?: GmailMessage; +} + +function draftBody(args: Record) { + const fields = readEmailFields(args); + return { + message: { + raw: encodeRaw(fields), + ...(fields.threadId ? { threadId: fields.threadId } : {}), + }, + }; +} + +export const draftHandlers: Record = { + draft_email: async (args, accessToken) => { + const draft = await gmail(accessToken, "/drafts", { + method: "POST", + body: draftBody(args), + }); + return text( + `✓ Created draft ${draft.id}\nMessage ID: ${draft.message?.id}\nThread ID: ${draft.message?.threadId}`, + ); + }, + + list_drafts: async (args, accessToken) => { + const data = await gmail<{ + drafts?: Draft[]; + nextPageToken?: string; + resultSizeEstimate?: number; + }>(accessToken, "/drafts", { + query: { + maxResults: Math.min(optionalNumber(args, "maxResults") ?? 20, 100), + q: args.query, + }, + }); + + const drafts = data.drafts ?? []; + if (!drafts.length) return text("No drafts found"); + + const details = await mapLimited(drafts, (draft) => + gmail(accessToken, `/drafts/${draft.id}`, { + query: { format: "metadata" }, + }).catch(() => null), + ); + + const lines = [`Found ${drafts.length} draft(s):`, ""]; + details.forEach((draft, index) => { + const id = drafts[index]?.id ?? ""; + if (!draft?.message) { + lines.push(`[${id}] (could not be read)`); + return; + } + const parsed = parseMessage(draft.message); + lines.push( + `[${id}] To: ${parsed.to || "(no recipient)"}\n ${parsed.subject || "(no subject)"}`, + ); + }); + if (data.nextPageToken) { + lines.push("", `📄 More available. pageToken: "${data.nextPageToken}"`); + } + return text(lines.join("\n")); + }, + + get_draft: async (args, accessToken) => { + const draftId = requireString(args, "draftId"); + const draft = await gmail(accessToken, `/drafts/${draftId}`, { + query: { format: "full" }, + }); + if (!draft.message) throw new Error(`draft ${draftId} has no message`); + return text( + `Draft ID: ${draftId}\n${renderMessage(parseMessage(draft.message))}`, + ); + }, + + update_draft: async (args, accessToken) => { + const draftId = requireString(args, "draftId"); + const draft = await gmail(accessToken, `/drafts/${draftId}`, { + method: "PUT", + body: draftBody(args), + }); + return text(`✓ Updated draft ${draft.id ?? draftId}`); + }, + + delete_draft: async (args, accessToken) => { + const draftId = requireString(args, "draftId"); + await gmail(accessToken, `/drafts/${draftId}`, { method: "DELETE" }); + return text(`✓ Deleted draft ${draftId}`); + }, + + send_draft: async (args, accessToken) => { + const draftId = requireString(args, "draftId"); + const sent = await gmail<{ id?: string; threadId?: string }>( + accessToken, + "/drafts/send", + { method: "POST", body: { id: draftId } }, + ); + return text( + `✓ Sent draft ${draftId}\nMessage ID: ${sent.id}\nThread ID: ${sent.threadId}`, + ); + }, +}; diff --git a/packages/trpc/src/router/plugins/servers/gmail/handlers/filters.ts b/packages/trpc/src/router/plugins/servers/gmail/handlers/filters.ts new file mode 100644 index 00000000000..56b384c3499 --- /dev/null +++ b/packages/trpc/src/router/plugins/servers/gmail/handlers/filters.ts @@ -0,0 +1,170 @@ +import { gmail, requireString, stringList, text } from "../api"; +import type { Handler } from "../types"; + +interface FilterCriteria { + from?: string; + to?: string; + subject?: string; + query?: string; + negatedQuery?: string; + hasAttachment?: boolean; + excludeChats?: boolean; + size?: number; + sizeComparison?: string; +} + +interface FilterAction { + addLabelIds?: string[]; + removeLabelIds?: string[]; + forward?: string; +} + +interface Filter { + id?: string; + criteria?: FilterCriteria; + action?: FilterAction; +} + +function describe(filter: Filter): string { + const criteria = Object.entries(filter.criteria ?? {}) + .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .join(", "); + const action = Object.entries(filter.action ?? {}) + .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .join(", "); + return `[${filter.id}]\n when: ${criteria || "(none)"}\n then: ${action || "(none)"}`; +} + +type Template = (parameters: Record) => { + criteria: FilterCriteria; + action: FilterAction; +}; + +function labels(parameters: Record): string[] { + return stringList(parameters.labelIds, "parameters.labelIds"); +} + +function required(parameters: Record, field: string): string { + const value = parameters[field]; + if (typeof value !== "string" || !value.trim()) { + throw new Error(`parameters.${field} is required for this template`); + } + return value.trim(); +} + +const TEMPLATES: Record = { + fromSender: (parameters) => ({ + criteria: { from: required(parameters, "senderEmail") }, + action: { + addLabelIds: labels(parameters), + ...(parameters.archive ? { removeLabelIds: ["INBOX"] } : {}), + }, + }), + withSubject: (parameters) => ({ + criteria: { subject: required(parameters, "subjectText") }, + action: { + addLabelIds: labels(parameters), + ...(parameters.markAsRead ? { removeLabelIds: ["UNREAD"] } : {}), + }, + }), + withAttachments: (parameters) => ({ + criteria: { hasAttachment: true }, + action: { addLabelIds: labels(parameters) }, + }), + largeEmails: (parameters) => { + const size = Number(parameters.sizeInBytes); + if (!Number.isFinite(size)) { + throw new Error("parameters.sizeInBytes is required for this template"); + } + return { + criteria: { size, sizeComparison: "larger" }, + action: { addLabelIds: labels(parameters) }, + }; + }, + containingText: (parameters) => ({ + criteria: { query: `"${required(parameters, "searchText")}"` }, + action: { + addLabelIds: parameters.markImportant + ? [...labels(parameters), "IMPORTANT"] + : labels(parameters), + }, + }), + mailingList: (parameters) => { + const list = required(parameters, "listIdentifier"); + return { + criteria: { query: `list:${list} OR subject:[${list}]` }, + action: { + addLabelIds: labels(parameters), + ...(parameters.archive === false ? {} : { removeLabelIds: ["INBOX"] }), + }, + }; + }, +}; + +async function create( + accessToken: string, + body: { criteria: FilterCriteria; action: FilterAction }, +) { + const filter = await gmail(accessToken, "/settings/filters", { + method: "POST", + body, + }); + return text(`✓ Created filter\n${describe(filter)}`); +} + +export const filterHandlers: Record = { + list_filters: async (_args, accessToken) => { + const data = await gmail<{ filter?: Filter[] }>( + accessToken, + "/settings/filters", + ); + const filters = data.filter ?? []; + if (!filters.length) return text("No filters configured"); + return text( + `${filters.length} filter(s):\n\n${filters.map(describe).join("\n\n")}`, + ); + }, + + get_filter: async (args, accessToken) => { + const filterId = requireString(args, "filterId"); + const filter = await gmail( + accessToken, + `/settings/filters/${filterId}`, + ); + return text(describe(filter)); + }, + + create_filter: async (args, accessToken) => { + const criteria = args.criteria as FilterCriteria | undefined; + const action = args.action as FilterAction | undefined; + if (!criteria || !Object.keys(criteria).length) { + throw new Error("criteria must name at least one condition"); + } + if (!action || !Object.keys(action).length) { + throw new Error("action must name at least one effect"); + } + return await create(accessToken, { criteria, action }); + }, + + create_filter_from_template: async (args, accessToken) => { + const name = requireString(args, "template"); + const template = TEMPLATES[name]; + if (!template) { + throw new Error( + `Unknown template "${name}". Valid: ${Object.keys(TEMPLATES).join(", ")}`, + ); + } + return await create( + accessToken, + template((args.parameters as Record) ?? {}), + ); + }, + + delete_filter: async (args, accessToken) => { + const filterId = requireString(args, "filterId"); + await gmail(accessToken, `/settings/filters/${filterId}`, { + method: "DELETE", + }); + return text(`✓ Deleted filter ${filterId}`); + }, +}; diff --git a/packages/trpc/src/router/plugins/servers/gmail/handlers/index.ts b/packages/trpc/src/router/plugins/servers/gmail/handlers/index.ts new file mode 100644 index 00000000000..73e9da2aa95 --- /dev/null +++ b/packages/trpc/src/router/plugins/servers/gmail/handlers/index.ts @@ -0,0 +1,34 @@ +import { failure } from "../api"; +import type { Handler, ToolResult } from "../types"; +import { draftHandlers } from "./drafts"; +import { filterHandlers } from "./filters"; +import { labelHandlers } from "./labels"; +import { messageHandlers } from "./messages"; +import { threadHandlers } from "./threads"; + +const HANDLERS: Record = { + ...messageHandlers, + ...draftHandlers, + ...threadHandlers, + ...labelHandlers, + ...filterHandlers, +}; + +export async function callTool( + name: string, + args: Record, + accessToken: string | undefined, +): Promise { + if (!accessToken) return failure("Not connected; connect the plugin first."); + + const handler = Object.hasOwn(HANDLERS, name) ? HANDLERS[name] : undefined; + if (!handler) return failure(`Unknown tool: ${name}`); + + try { + return await handler(args, accessToken); + } catch (error) { + return failure( + `Error: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/packages/trpc/src/router/plugins/servers/gmail/handlers/labels.ts b/packages/trpc/src/router/plugins/servers/gmail/handlers/labels.ts new file mode 100644 index 00000000000..5cf4b4218a6 --- /dev/null +++ b/packages/trpc/src/router/plugins/servers/gmail/handlers/labels.ts @@ -0,0 +1,101 @@ +import { gmail, requireString, text } from "../api"; +import { resolveColor } from "../palette"; +import type { Handler } from "../types"; + +interface Label { + id?: string; + name?: string; + type?: string; + messagesTotal?: number; + messagesUnread?: number; + color?: { textColor?: string; backgroundColor?: string }; +} + +function visibility(args: Record) { + return { + ...(args.messageListVisibility + ? { messageListVisibility: args.messageListVisibility } + : {}), + ...(args.labelListVisibility + ? { labelListVisibility: args.labelListVisibility } + : {}), + }; +} + +function describe(label: Label): string { + const counts = + label.messagesTotal === undefined + ? "" + : ` — ${label.messagesTotal} message(s), ${label.messagesUnread ?? 0} unread`; + const color = label.color?.backgroundColor + ? ` [${label.color.backgroundColor} on ${label.color.textColor}]` + : ""; + return `[${label.id}] ${label.name}${color}${counts}`; +} + +async function allLabels(accessToken: string): Promise { + const data = await gmail<{ labels?: Label[] }>(accessToken, "/labels"); + return data.labels ?? []; +} + +export const labelHandlers: Record = { + list_email_labels: async (_args, accessToken) => { + const labels = await allLabels(accessToken); + if (!labels.length) return text("No labels found"); + const system = labels.filter((label) => label.type === "system"); + const user = labels.filter((label) => label.type !== "system"); + const lines = [`${labels.length} label(s)`, "", "System:"]; + for (const label of system) lines.push(` ${describe(label)}`); + lines.push("", "User:"); + for (const label of user) lines.push(` ${describe(label)}`); + return text(lines.join("\n")); + }, + + create_label: async (args, accessToken) => { + const color = resolveColor(args.color); + const label = await gmail