diff --git a/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx b/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx
index 184d2423fa7..26824a3f330 100644
--- a/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx
+++ b/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx
@@ -33,6 +33,23 @@ interface IntegrationDetailProps {
children?: ReactNode
}
+type IntegrationAccount = MemberIntegration['accounts'][number]
+
+/** Sites recorded for an account whose one grant reaches several, such as Confluence or Jira. */
+function sitesOf(account: IntegrationAccount) {
+ return 'sites' in account ? account.sites : []
+}
+
+function siteHost(url: string) {
+ return url.replace(/^https:\/\//, '')
+}
+
+function accountSummary(account: IntegrationAccount) {
+ const status = account.status === 'needs_reauth' ? 'Reconnect needed' : 'Connected'
+ const count = sitesOf(account).length
+ return count > 0 ? `${status} ยท ${count} ${count === 1 ? 'site' : 'sites'}` : status
+}
+
function savedProviderIdFor(provider: string) {
const providerId = liveSearchSavedMemberProvider(provider)
return providerId === 'jama-service-account' || providerId === 'flow-service-account'
@@ -81,7 +98,11 @@ export function IntegrationDetail({
? undefined
: 'savedProviderId' in target
? () => savedProvider && setSavedConnection({ providerId: savedProvider })
- : () => connect.mutate({ organizationId, ...target }, { onError })
+ : () =>
+ connect.mutate(
+ { organizationId, ...target },
+ { onError, onSuccess: () => toast.success(`${integration.name} connected`) }
+ )
}
onReconnect={() => {
if (!selected) return
@@ -91,7 +112,11 @@ export function IntegrationDetail({
credentialId: selected.credentialId,
displayName: selected.displayName,
})
- else reconnect.mutate(selected.credentialId, { onError })
+ else
+ reconnect.mutate(selected.credentialId, {
+ onError,
+ onSuccess: () => toast.success(`${integration.name} reconnected`),
+ })
}}
connectionPending={connect.isPending || reconnect.isPending}
canConnect={integration.ready}
@@ -117,19 +142,33 @@ export function IntegrationDetail({
This account is no longer connected.
) : selected ? (
-
- {selected.displayName}
-
+
+
+ {selected.displayName}
+
+ {sitesOf(selected).length > 0 && (
+
+
+ {sitesOf(selected).map((site) => (
+ -
+ {site.name}{' '}
+ {siteHost(site.url)}
+
+ ))}
+
+
+ )}
+
) : integration.accounts.length ? (
{integration.accounts.map((account) => (
{
it('uses the existing connector callback contract and verifies the current account', async () => {
- const fetchMock = vi.fn().mockResolvedValue(
- new Response(
- JSON.stringify({
- account_type: 'atlassian',
- account_id: 'account-1',
- email: 'person@example.com',
- name: 'Person',
- picture: 'https://example.com/avatar.png',
- account_status: 'active',
- }),
- { status: 200, headers: { 'Content-Type': 'application/json' } }
- )
+ const fetchMock = vi.fn().mockImplementation(
+ async () =>
+ new Response(
+ JSON.stringify({
+ account_type: 'atlassian',
+ account_id: 'account-1',
+ email: 'person@example.com',
+ name: 'Person',
+ picture: 'https://example.com/avatar.png',
+ account_status: 'active',
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ )
)
vi.stubGlobal('fetch', fetchMock)
const connector = createAtlassianManagedOAuthConnector('jira')
@@ -60,18 +61,76 @@ describe('Atlassian managed OAuth connector', () => {
})
})
+ const ATLASSIAN_PROFILE = {
+ account_type: 'atlassian',
+ account_id: 'account-1',
+ email: 'person@example.com',
+ account_status: 'active',
+ }
+ const json = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ function stubAtlassian(sites: () => Response) {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (url: string) =>
+ url === 'https://api.atlassian.com/me' ? json(ATLASSIAN_PROFILE) : sites()
+ )
+ )
+ }
+ const verify = (provider: 'jira' | 'confluence' = 'confluence') =>
+ createAtlassianManagedOAuthConnector(provider).verifyIdentity({
+ tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: ATLASSIAN_SCOPES },
+ clientId: 'client-1',
+ })
+
+ it('records the sites the grant reaches, so an added site is visible', async () => {
+ stubAtlassian(() =>
+ json([
+ { id: 'cloud-1', name: 'Acme', url: 'https://acme.example.net', scopes: [] },
+ { id: 'cloud-2', name: 'Beta', url: 'https://beta.example.net/', scopes: [] },
+ { id: 'cloud-3', name: 'Insecure', url: 'http://insecure.example.net' },
+ { id: 'cloud-4', url: 'https://unnamed.example.net' },
+ ])
+ )
+
+ expect((await verify()).sites).toEqual([
+ { name: 'Acme', url: 'https://acme.example.net' },
+ { name: 'Beta', url: 'https://beta.example.net' },
+ ])
+ })
+
+ it.each([
+ ['an error status', () => json({ message: 'unavailable' }, 503)],
+ [
+ 'a network failure',
+ () => {
+ throw new TypeError('fetch failed')
+ },
+ ],
+ ['a malformed body', () => json({ sites: 'nope' })],
+ ])('still connects without a site list after %s', async (_name, sites) => {
+ stubAtlassian(sites)
+ const identity = await verify('jira')
+ expect(identity).toMatchObject({ providerSubjectId: 'account-1' })
+ expect(identity.sites).toBeUndefined()
+ })
+
it('fails closed for an inactive or incomplete Atlassian identity', async () => {
vi.stubGlobal(
'fetch',
- vi.fn().mockResolvedValue(
- new Response(
- JSON.stringify({
- account_type: 'atlassian',
- account_id: 'account-1',
- account_status: 'inactive',
- }),
- { status: 200, headers: { 'Content-Type': 'application/json' } }
- )
+ vi.fn().mockImplementation(
+ async () =>
+ new Response(
+ JSON.stringify({
+ account_type: 'atlassian',
+ account_id: 'account-1',
+ account_status: 'inactive',
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ )
)
)
const connector = createAtlassianManagedOAuthConnector('confluence')
diff --git a/apps/sim/lib/auth/connectors/managed-oauth.ts b/apps/sim/lib/auth/connectors/managed-oauth.ts
index b10118070bd..aad3acd77b4 100644
--- a/apps/sim/lib/auth/connectors/managed-oauth.ts
+++ b/apps/sim/lib/auth/connectors/managed-oauth.ts
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto'
+import type { ManagedOAuthSite } from '@sim/db/schema'
import { isRecordLike, toRecord } from '@sim/utils/object'
import type { OAuth2Tokens } from 'better-auth/oauth2'
import type { GenericOAuthConfig } from 'better-auth/plugins'
@@ -6,6 +7,7 @@ import { OAuth2Client, type TokenPayload } from 'google-auth-library'
import { createRemoteJWKSet, type JWTPayload, jwtVerify } from 'jose'
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
+import { boundedAccountSites } from '@/lib/credential-groups/account-sites'
import { getDocusignOAuthUrl } from '@/lib/oauth/docusign'
import { verifyGitHubRepositoriesIdentity } from '@/lib/oauth/github-repositories'
import { deriveMicrosoftEmailVerified, mapMicrosoftProfileToUser } from '@/lib/oauth/microsoft'
@@ -24,6 +26,7 @@ const GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels'
const ATLASSIAN_USER_INFO_URL = 'https://api.atlassian.com/me'
const ATLASSIAN_USER_INFO_MAX_BYTES = 256 * 1024
const ATLASSIAN_USER_INFO_TIMEOUT_MS = 10_000
+const ATLASSIAN_SITES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources'
const MICROSOFT_JWKS_URL = 'https://login.microsoftonline.com/common/discovery/v2.0/keys'
const MICROSOFT_OIDC_USER_INFO_URL = 'https://graph.microsoft.com/oidc/userinfo'
const MICROSOFT_OIDC_USER_INFO_MAX_BYTES = 256 * 1024
@@ -49,6 +52,7 @@ export interface ManagedOAuthConnectorIdentity {
avatarUrl?: string
nonce?: string
grantedScopes: string[]
+ sites?: ManagedOAuthSite[]
}
export interface ManagedOAuthConnectorConfig {
@@ -187,6 +191,42 @@ function requireAtlassianUserProfile(value: AtlassianUserProfile): {
}
}
+function httpsOrigin(value: string): string | null {
+ try {
+ const url = new URL(value)
+ return url.protocol === 'https:' ? url.origin : null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * The sites one Atlassian grant reaches, shown so a person can see a site they added. Display only:
+ * Search lists sites again on every request, so a missing or stale list never changes access, and
+ * a failed listing never fails the connection.
+ */
+async function listAtlassianSites(accessToken: string): Promise {
+ try {
+ const response = await fetch(ATLASSIAN_SITES_URL, {
+ headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
+ signal: AbortSignal.timeout(ATLASSIAN_USER_INFO_TIMEOUT_MS),
+ })
+ const body = await readResponseJsonWithLimit(response, {
+ maxBytes: ATLASSIAN_USER_INFO_MAX_BYTES,
+ label: 'Atlassian accessible resources response',
+ })
+ if (!response.ok || !Array.isArray(body)) return undefined
+ return boundedAccountSites(
+ body.map((entry) => {
+ const site = toRecord(entry)
+ return { name: site.name, url: typeof site.url === 'string' ? httpsOrigin(site.url) : null }
+ })
+ )
+ } catch {
+ return undefined
+ }
+}
+
/** Managed enrollment policy for Atlassian's Jira and Confluence 3LO clients. */
export function createAtlassianManagedOAuthConnector(
providerId: AtlassianManagedOAuthProviderId
@@ -205,6 +245,7 @@ export function createAtlassianManagedOAuthConnector(
if (!tokens.accessToken || !tokens.scopes?.length) {
throw new Error('Atlassian returned an incomplete authorization')
}
+ const sites = listAtlassianSites(tokens.accessToken)
const response = await fetch(ATLASSIAN_USER_INFO_URL, {
headers: {
Accept: 'application/json',
@@ -228,6 +269,7 @@ export function createAtlassianManagedOAuthConnector(
...(identity.name ? { displayName: identity.name } : {}),
...(identity.picture ? { avatarUrl: identity.picture } : {}),
grantedScopes: [...new Set(tokens.scopes)],
+ ...(await sites.then((listed) => (listed ? { sites: listed } : {}))),
}
},
hasRequiredScopes(grantedScopes, requiredScopes) {
diff --git a/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts b/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts
index 4cd6b31a801..430e5aef103 100644
--- a/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts
+++ b/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts
@@ -15,7 +15,7 @@ import {
} from '@sim/db/schema'
import { sha256Hex } from '@sim/security/hash'
import { generateId } from '@sim/utils/id'
-import { eq } from 'drizzle-orm'
+import { eq, sql } from 'drizzle-orm'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments'
import { listCredentialGroupEnrollments } from '@/lib/credential-groups/enrollments'
@@ -24,6 +24,7 @@ import { completeCredentialGroupOAuth } from '@/lib/credential-groups/oauth'
import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state'
import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter'
import type { CredentialGroupProvider } from '@/lib/credential-groups/providers'
+import { listViewerOrganizationAccounts } from '@/lib/credential-groups/viewer-accounts'
import { persistManagedMcpCredential } from '@/lib/credentials/managed-mcp'
import { decryptManagedOAuthTokenSet } from '@/lib/credentials/managed-oauth'
import { resolveSlackSearchMember } from '@/lib/knowledge/application/slack-search/identity'
@@ -41,6 +42,8 @@ vi.mock('@/lib/credential-groups/scoped-availability', () => ({
/** Each grant carries its own token, as a real exchange does, so a saved grant is identifiable. */
let grants = 0
+/** Sites the next grant reports, or `undefined` when its site listing failed. */
+let grantSites: Array<{ name: string; url: string }> | undefined
vi.mock('@/lib/credential-groups/provider-registry', async (importOriginal) => {
const { getCredentialGroupProviderId } = await import('@/lib/credential-groups/providers')
@@ -63,7 +66,7 @@ vi.mock('@/lib/credential-groups/provider-registry', async (importOriginal) => {
providerSubjectId: subject,
providerTenantId: tenant || null,
displayName: code,
- metadata: { email: code },
+ metadata: { email: code, ...(grantSites ? { sites: grantSites } : {}) },
accessToken: `token-for-${code}-${(grants += 1)}`,
grantedScopes: POLICY.requiredScopes,
accessTokenExpiresAt: null,
@@ -229,6 +232,7 @@ beforeAll(async () => {
})
beforeEach(async () => {
+ grantSites = undefined
await db.delete(credential).where(eq(credential.organizationId, organizationId))
})
@@ -357,6 +361,77 @@ describe('adding another site to an Atlassian login', () => {
expect(await saved()).not.toBe(firstToken)
})
+ it('shows the sites recorded for the account, ignoring anything unusable', async () => {
+ const account = await connect('person@atlassian', { kind: 'create' }, CONFLUENCE)
+ const viewer = async () =>
+ (await listViewerOrganizationAccounts({ organizationId, userId })).find(
+ (row) => row.credentialId === account.credentialId
+ )
+ expect((await viewer())?.sites).toEqual([])
+
+ await db
+ .update(credential)
+ .set({
+ providerMetadata: sql`${JSON.stringify({
+ email: 'person@atlassian',
+ sites: [
+ { name: 'Acme', url: 'https://acme.example.net' },
+ { name: 42, url: 'https://broken.example.net' },
+ 'not a site',
+ ],
+ })}::jsonb`,
+ })
+ .where(eq(credential.id, account.credentialId))
+
+ expect((await viewer())?.sites).toEqual([{ name: 'Acme', url: 'https://acme.example.net' }])
+ })
+
+ it('never lets oversized stored sites break the account list', async () => {
+ const account = await connect('person@atlassian', { kind: 'create' }, CONFLUENCE)
+ const site = (index: number) => ({
+ name: `Site ${index}`,
+ url: `https://s${index}.example.net`,
+ })
+ await db
+ .update(credential)
+ .set({
+ providerMetadata: sql`${JSON.stringify({
+ email: 'person@atlassian',
+ sites: [
+ { name: 'x'.repeat(600), url: 'https://long-name.example.net' },
+ { name: 'Long URL', url: `https://example.net/${'x'.repeat(2100)}` },
+ ...Array.from({ length: 60 }, (_, index) => site(index)),
+ ],
+ })}::jsonb`,
+ })
+ .where(eq(credential.id, account.credentialId))
+
+ const [viewer] = await listViewerOrganizationAccounts({ organizationId, userId })
+ expect(viewer?.sites).toHaveLength(50)
+ expect(viewer?.sites[0]).toEqual(site(0))
+ })
+
+ it('keeps the recorded sites when a reconnect cannot list them', async () => {
+ grantSites = [{ name: 'Acme', url: 'https://acme.example.net' }]
+ const account = await connect('person@atlassian', { kind: 'create' }, CONFLUENCE)
+ const reconnect = () =>
+ connect(
+ 'person@atlassian',
+ { kind: 'reconnect', credentialId: account.credentialId },
+ CONFLUENCE
+ )
+ const sites = async () =>
+ (await listViewerOrganizationAccounts({ organizationId, userId }))[0]?.sites
+
+ grantSites = undefined
+ await reconnect()
+ expect(await sites()).toEqual([{ name: 'Acme', url: 'https://acme.example.net' }])
+
+ grantSites = []
+ await reconnect()
+ expect(await sites()).toEqual([])
+ })
+
it('still adds a different Atlassian login as its own account', async () => {
await connect('person@atlassian', { kind: 'create' }, CONFLUENCE)
await connect('other@atlassian', { kind: 'create' }, CONFLUENCE)
diff --git a/apps/sim/lib/credential-groups/account-sites.ts b/apps/sim/lib/credential-groups/account-sites.ts
new file mode 100644
index 00000000000..a37798289c5
--- /dev/null
+++ b/apps/sim/lib/credential-groups/account-sites.ts
@@ -0,0 +1,27 @@
+import type { ManagedOAuthSite } from '@sim/db/schema'
+import { toArray, toRecord } from '@sim/utils/object'
+import {
+ ACCOUNT_SITE_LIMIT,
+ ACCOUNT_SITE_NAME_MAX_LENGTH,
+ ACCOUNT_SITE_URL_MAX_LENGTH,
+} from '@/lib/credential-groups/limits'
+
+/**
+ * Keeps the well-formed sites within the recorded bounds. Site lists are display data from a
+ * provider, so a bad entry is dropped rather than allowed to fail the account it belongs to.
+ */
+export function boundedAccountSites(value: unknown): ManagedOAuthSite[] {
+ return toArray(value)
+ .flatMap((entry) => {
+ const site = toRecord(entry)
+ const name = typeof site.name === 'string' ? site.name.trim() : ''
+ const url = typeof site.url === 'string' ? site.url : ''
+ return name &&
+ url &&
+ name.length <= ACCOUNT_SITE_NAME_MAX_LENGTH &&
+ url.length <= ACCOUNT_SITE_URL_MAX_LENGTH
+ ? [{ name, url }]
+ : []
+ })
+ .slice(0, ACCOUNT_SITE_LIMIT)
+}
diff --git a/apps/sim/lib/credential-groups/application/organization-access.test.ts b/apps/sim/lib/credential-groups/application/organization-access.test.ts
index 9f0ead2c1c5..c3f72b4821f 100644
--- a/apps/sim/lib/credential-groups/application/organization-access.test.ts
+++ b/apps/sim/lib/credential-groups/application/organization-access.test.ts
@@ -194,7 +194,7 @@ describe('organization workspace sharing administration', () => {
principal,
input: { organizationId: 'org-1' },
})
- expect(result.viewerAccounts).toEqual([account])
+ expect(result.viewerAccounts).toEqual([{ ...account, sites: [] }])
expect(result.canManage).toBe(false)
expect(eq).toHaveBeenCalledWith(schemaMock.credentialGroup.id, 'group-1')
expect(eq).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.userId, 'admin-user')
diff --git a/apps/sim/lib/credential-groups/limits.ts b/apps/sim/lib/credential-groups/limits.ts
index 9d2e1c019ec..29f917d17aa 100644
--- a/apps/sim/lib/credential-groups/limits.ts
+++ b/apps/sim/lib/credential-groups/limits.ts
@@ -9,3 +9,7 @@ export const CREDENTIAL_GROUP_KNOWLEDGE_CONNECTOR_ACCESS_LIMIT = 32
export const ORGANIZATION_ACCOUNT_INDEXING_SOURCE_LIMIT = 100
/** Live accounts one member may connect per provider; bounds a search's per-account fan-out. */
export const CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT = 5
+/** Bounds on the sites recorded for one account, enforced wherever they are written or read. */
+export const ACCOUNT_SITE_LIMIT = 50
+export const ACCOUNT_SITE_NAME_MAX_LENGTH = 512
+export const ACCOUNT_SITE_URL_MAX_LENGTH = 2048
diff --git a/apps/sim/lib/credential-groups/oauth.ts b/apps/sim/lib/credential-groups/oauth.ts
index 7853425c85e..f8773adffd7 100644
--- a/apps/sim/lib/credential-groups/oauth.ts
+++ b/apps/sim/lib/credential-groups/oauth.ts
@@ -231,6 +231,7 @@ async function persistGrant(
id: credential.id,
revokedAt: credential.revokedAt,
managedOauthStatus: credential.managedOauthStatus,
+ providerMetadata: credential.providerMetadata,
providerSubjectId: credential.providerSubjectId,
providerTenantId: credential.providerTenantId,
encryptedOauthTokenSet: credential.encryptedOauthTokenSet,
@@ -287,7 +288,11 @@ async function persistGrant(
providerTenantId: grant.providerTenantId,
managedOauthStatus: 'active' as const,
grantedScopes: grant.grantedScopes,
- providerMetadata: grant.metadata,
+ // A site listing that failed reports no sites; keep the account's last known list.
+ providerMetadata:
+ grant.metadata.sites === undefined && existing?.providerMetadata?.sites
+ ? { ...grant.metadata, sites: existing.providerMetadata.sites }
+ : grant.metadata,
encryptedOauthTokenSet,
grantedAt: now,
revokedAt: null,
diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.ts
index 5c4c343277d..575aef31263 100644
--- a/apps/sim/lib/credential-groups/standard-oauth-provider.ts
+++ b/apps/sim/lib/credential-groups/standard-oauth-provider.ts
@@ -363,6 +363,7 @@ export function createStandardOAuthCredentialGroupProviderAdapter(
email,
...(identity.displayName ? { displayName: identity.displayName } : {}),
...(identity.avatarUrl ? { avatarUrl: identity.avatarUrl } : {}),
+ ...(identity.sites ? { sites: identity.sites } : {}),
},
accessToken: tokens.accessToken,
...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
diff --git a/apps/sim/lib/credential-groups/viewer-accounts.ts b/apps/sim/lib/credential-groups/viewer-accounts.ts
index 3208145acde..c838dfaba3a 100644
--- a/apps/sim/lib/credential-groups/viewer-accounts.ts
+++ b/apps/sim/lib/credential-groups/viewer-accounts.ts
@@ -1,7 +1,9 @@
import { db } from '@sim/db'
import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema'
+import { toRecord } from '@sim/utils/object'
import { and, eq, inArray, isNull, type SQL } from 'drizzle-orm'
import { resourceScopeCondition } from '@/lib/core/resource-scope.server'
+import { boundedAccountSites } from '@/lib/credential-groups/account-sites'
import { ORGANIZATION_VIEWER_ACCOUNT_LIMIT } from '@/lib/credential-groups/limits'
/** Own grant metadata remains manageable even while provider setup or enrollment is disabled. */
@@ -19,6 +21,7 @@ export async function listViewerOrganizationAccounts(input: {
groupId: credentialGroup.id,
optionId: credential.credentialGroupOptionId,
providerId: credential.providerId,
+ metadata: credential.providerMetadata,
})
.from(credential)
.innerJoin(
@@ -47,11 +50,13 @@ export async function listViewerOrganizationAccounts(input: {
(account.status !== 'active' && account.status !== 'needs_reauth')
)
throw new Error('Invalid personal account metadata')
+ const { metadata, ...rest } = account
return {
- ...account,
+ ...rest,
optionId: account.optionId,
providerId: account.providerId,
status: account.status,
+ sites: boundedAccountSites(toRecord(metadata).sites),
}
})
}
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 979adaf251e..f820b423751 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -6408,12 +6408,19 @@ export const managedOauthCredentialStatusEnum = pgEnum('managed_oauth_credential
'revoked',
])
+/** A site one grant reaches, recorded for display only; access is always rechecked live. */
+export interface ManagedOAuthSite {
+ name: string
+ url: string
+}
+
export interface ManagedOAuthProviderMetadata {
email: string
displayName?: string
avatarUrl?: string
username?: string
tenantDisplayName?: string
+ sites?: ManagedOAuthSite[]
}
export interface ManagedMcpToolSnapshot {