diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 323cefcc7..811ff0e21 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -45,7 +45,9 @@ "Date": "Date", "Delete": "Delete", "Initiative": "Initiative", + "Last modified by {{name}}": "Last modified by {{name}}", "Open in Slack": "Open in Slack", + "Paid": "Paid", "Paste as": "Paste as", "Project": "Project", "Remove from page": "Remove from page", @@ -217,6 +219,7 @@ "Theme": "Theme", "To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.", "Toggle full page width": "Toggle full page width", + "Toggle {{name}} integration": "Toggle {{name}} integration", "Unable to import pages. Please try again.": "Unable to import pages. Please try again.", "Unassigned": "Unassigned", "untitled": "untitled", diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts index e50e8d70b..77bd1db73 100644 --- a/apps/client/src/ee/features.ts +++ b/apps/client/src/ee/features.ts @@ -22,4 +22,5 @@ export const Feature = { PERSONAL_SPACES: 'spaces:personal', DOCX_EXPORT: 'export:docx', BASES: 'bases', + INTEGRATIONS: 'integrations', } as const; diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index f9b360df6..67ff5767e 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -26,15 +26,14 @@ const ATTACHMENT_NODE_TYPES = [ const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//; -// Only installed + enabled providers get card treatment; anything else pastes -// as an ordinary link. The cache is prefetched when the page editor mounts; +// Only installed providers get card treatment; anything else pastes as an +// ordinary link. The cache is prefetched when the page editor mounts; // a cold cache also means ordinary link. function isIntegrationInstalled(provider: string): boolean { const installed = queryClient.getQueryData([ "installed-integrations", ]); - const integration = installed?.find((i) => i.type === provider); - return Boolean(integration?.isEnabled); + return Boolean(installed?.some((i) => i.type === provider)); } export const handlePaste = ( diff --git a/apps/client/src/features/editor/components/integration-link/integration-link-view.module.css b/apps/client/src/features/editor/components/integration-link/integration-link-view.module.css index ef269f139..abb2e880b 100644 --- a/apps/client/src/features/editor/components/integration-link/integration-link-view.module.css +++ b/apps/client/src/features/editor/components/integration-link/integration-link-view.module.css @@ -13,6 +13,18 @@ background-color: var(--mantine-color-dark-5); } +.thumbnail { + display: block; + width: 100%; + max-height: 320px; + object-fit: cover; + background-color: var(--mantine-color-gray-0); +} + +:global([data-mantine-color-scheme="dark"]) .thumbnail { + background-color: var(--mantine-color-dark-6); +} + .mention { display: inline-flex; align-items: center; diff --git a/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx b/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx index 170d3d9b6..c94aeeca1 100644 --- a/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx +++ b/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx @@ -215,6 +215,85 @@ function JiraIssueCard({ ); } +function FigmaFileCard({ + url, + unfurlData, +}: { + url: string; + unfurlData: Record; +}) { + const { t } = useTranslation(); + // Figma thumbnail links are pre-signed and expire; drop the preview rather + // than render a broken image. + const [thumbnailFailed, setThumbnailFailed] = useState(false); + const meta = unfurlData.metadata ?? {}; + const thumbnailUrl: string | undefined = meta.thumbnailUrl; + const showThumbnail = Boolean(thumbnailUrl) && !thumbnailFailed; + + const subtitle = [ + unfurlData.author + ? t("Last modified by {{name}}", { name: unfurlData.author }) + : unfurlData.description, + meta.lastModified ? timeAgo(new Date(meta.lastModified)) : null, + ] + .filter(Boolean) + .join(" • "); + + return ( + + + {showThumbnail && ( + + setThumbnailFailed(true)} + /> + + )} + + + + {(unfurlData.author ?? unfurlData.title ?? "F").charAt(0)} + + + + + {unfurlData.title} + + {subtitle && ( + + {subtitle} + + )} + + +
+ {getIntegrationIcon("figma", 18)} +
+
+
+
+ ); +} + function IntegrationLinkView(props: any) { const { node } = props; const { url, provider } = node.attrs; @@ -331,6 +410,10 @@ function IntegrationLinkView(props: any) { return ; } + if (provider === "figma") { + return ; + } + return ( {connection ? ( <> - - {t("Connected")} - {connection.providerUserId && ` (${connection.providerUserId})`} - + {connection.invalidatedAt ? ( + <> + + {t("Connection expired")} + + + + ) : ( + + {t("Connected")} + {connection.providerUserId && ` (${connection.providerUserId})`} + + )} - - ) : ( + ) : ( + + + )} diff --git a/apps/client/src/features/integration/pages/connections.tsx b/apps/client/src/features/integration/pages/connections.tsx index cd3e686fa..42beb8ff4 100644 --- a/apps/client/src/features/integration/pages/connections.tsx +++ b/apps/client/src/features/integration/pages/connections.tsx @@ -81,8 +81,7 @@ export default function Connections() { {available .filter((def) => { if (!def.capabilities.includes("oauth")) return false; - const installation = installed?.find((i) => i.type === def.type); - return installation?.isEnabled; + return installed?.some((i) => i.type === def.type); }) .map((def) => { const connection = myConnections?.find( diff --git a/apps/client/src/features/integration/pages/integrations.tsx b/apps/client/src/features/integration/pages/integrations.tsx index 3cb23e9b6..244848829 100644 --- a/apps/client/src/features/integration/pages/integrations.tsx +++ b/apps/client/src/features/integration/pages/integrations.tsx @@ -11,13 +11,12 @@ import { useInstalledIntegrations, useInstallIntegration, useUninstallIntegration, - useUpdateIntegrationSettings, } from "../queries/integration-query"; -import { Integration } from "../types/integration.types"; import { getOAuthAuthorizeUrl, getOAuthInstallUrl, } from "../services/integration-service"; +import { Integration } from "../types/integration.types"; import { notifications } from "@mantine/notifications"; export default function Integrations() { @@ -28,7 +27,6 @@ export default function Integrations() { useInstalledIntegrations(); const installMutation = useInstallIntegration(); const uninstallMutation = useUninstallIntegration(); - const updateMutation = useUpdateIntegrationSettings(); const handleInstall = useCallback( async (type: string) => { @@ -50,10 +48,34 @@ export default function Integrations() { return; } - // Per-user OAuth providers (Linear, Jira, GitHub, ...): keep existing - // two-step flow — create the integration row, then individual users - // OAuth-connect from /settings/account/connections. - installMutation.mutate({ type }); + // Per-user OAuth providers (GitLab, Jira, GitHub, ...): create the + // integration row, then send the installing admin straight into their + // own OAuth so they leave with a working connection. Other members + // connect for themselves from /settings/account/connections. + let integration: Integration; + try { + integration = await installMutation.mutateAsync({ type }); + } catch { + return; // the mutation reports its own failure + } + + if (!definition?.capabilities?.includes("oauth")) return; + + try { + const { authorizationUrl } = await getOAuthAuthorizeUrl({ + integrationId: integration.id, + returnPath: "/settings/integrations", + }); + window.location.href = authorizationUrl; + } catch (err: any) { + // The integration stays installed; the admin can connect later. + notifications.show({ + message: + err?.response?.data?.message ?? + t("Failed to start OAuth connection"), + color: "red", + }); + } }, [installMutation, available, t], ); @@ -65,16 +87,6 @@ export default function Integrations() { [uninstallMutation], ); - const handleToggle = useCallback( - (integration: Integration, enabled: boolean) => { - updateMutation.mutate({ - integrationId: integration.id, - isEnabled: enabled, - }); - }, - [updateMutation], - ); - const isLoading = loadingAvailable || loadingInstalled; const error = new URLSearchParams(window.location.search).get("error"); @@ -115,7 +127,6 @@ export default function Integrations() { installation={installation} onInstall={handleInstall} onUninstall={handleUninstall} - onToggle={handleToggle} /> ); })} diff --git a/apps/client/src/features/integration/queries/integration-query.ts b/apps/client/src/features/integration/queries/integration-query.ts index 154d5938c..1c177181c 100644 --- a/apps/client/src/features/integration/queries/integration-query.ts +++ b/apps/client/src/features/integration/queries/integration-query.ts @@ -57,25 +57,6 @@ export function useUninstallIntegration() { }); } -export function useUpdateIntegrationSettings() { - const qc = useQueryClient(); - const { t } = useTranslation(); - return useMutation({ - mutationFn: integrationService.updateIntegrationSettings, - onSuccess: () => { - notifications.show({ message: t("Integration updated successfully") }); - qc.invalidateQueries({ queryKey: ["installed-integrations"] }); - }, - onError: (error) => { - const errorMessage = error["response"]?.data?.message; - notifications.show({ - message: errorMessage || t("Failed to update integration"), - color: "red", - }); - }, - }); -} - export function useMyConnections() { return useQuery({ queryKey: ["my-connections"], diff --git a/apps/client/src/features/integration/services/integration-service.ts b/apps/client/src/features/integration/services/integration-service.ts index 541ed9c26..3073b7ad4 100644 --- a/apps/client/src/features/integration/services/integration-service.ts +++ b/apps/client/src/features/integration/services/integration-service.ts @@ -35,15 +35,6 @@ export async function uninstallIntegration(data: { await api.post("/integrations/uninstall", data); } -export async function updateIntegrationSettings(data: { - integrationId: string; - settings?: Record; - isEnabled?: boolean; -}): Promise { - const req = await api.post("/integrations/update", data); - return req.data; -} - export async function getMyConnections(): Promise { const req = await api.post("/integrations/connections/mine"); return req.data; diff --git a/apps/client/src/features/integration/types/integration.types.ts b/apps/client/src/features/integration/types/integration.types.ts index 1efb36f96..22dd309aa 100644 --- a/apps/client/src/features/integration/types/integration.types.ts +++ b/apps/client/src/features/integration/types/integration.types.ts @@ -14,13 +14,13 @@ export type IntegrationDefinition = { icon: string; capabilities: IntegrationCapability[]; oauth?: OAuthConfig; + requiresLicense?: boolean; }; export type Integration = { id: string; workspaceId: string; type: string; - isEnabled: boolean; settings: Record | null; installedById: string | null; createdAt: string; @@ -35,9 +35,9 @@ export type ConnectionStatus = { export type UserConnection = { integrationId: string; type: string; - isEnabled: boolean; providerUserId: string | null; connectedAt: string; + invalidatedAt: string | null; }; export type UnfurlResult = { diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts index 2a889fd08..a3194f0d3 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -23,6 +23,7 @@ export const Feature = { PERSONAL_SPACES: 'spaces:personal', DOCX_EXPORT: 'export:docx', BASES: 'bases', + INTEGRATIONS: 'integrations', } as const; export type FeatureKey = (typeof Feature)[keyof typeof Feature]; diff --git a/apps/server/src/core/integration/dto/integration.dto.ts b/apps/server/src/core/integration/dto/integration.dto.ts index 726a5fbd9..0a5a0b423 100644 --- a/apps/server/src/core/integration/dto/integration.dto.ts +++ b/apps/server/src/core/integration/dto/integration.dto.ts @@ -1,5 +1,4 @@ import { - IsBoolean, IsNotEmpty, IsObject, IsOptional, @@ -28,10 +27,6 @@ export class UpdateIntegrationDto { @IsOptional() @IsObject() settings?: Record; - - @IsOptional() - @IsBoolean() - isEnabled?: boolean; } export class IntegrationIdDto { diff --git a/apps/server/src/core/integration/integration-connection.service.ts b/apps/server/src/core/integration/integration-connection.service.ts index ca21c4d64..3e7e1f035 100644 --- a/apps/server/src/core/integration/integration-connection.service.ts +++ b/apps/server/src/core/integration/integration-connection.service.ts @@ -28,7 +28,7 @@ export class IntegrationConnectionService { ); return { - connected: !!connection, + connected: !!connection && !connection.invalidatedAt, providerUserId: connection?.providerUserId ?? undefined, }; } @@ -61,9 +61,9 @@ export class IntegrationConnectionService { return rows.map((row) => ({ integrationId: row.integrationId, type: row.type, - isEnabled: row.isEnabled, providerUserId: row.providerUserId ?? null, connectedAt: row.createdAt, + invalidatedAt: row.invalidatedAt ?? null, })); } diff --git a/apps/server/src/core/integration/integration.controller.ts b/apps/server/src/core/integration/integration.controller.ts index 30c295c41..f9bd0dcd1 100644 --- a/apps/server/src/core/integration/integration.controller.ts +++ b/apps/server/src/core/integration/integration.controller.ts @@ -19,11 +19,14 @@ import { UpdateIntegrationDto, IntegrationIdDto, } from './dto/integration.dto'; +import { IntegrationRegistry } from './registry/integration-registry'; import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory'; import { WorkspaceCaslAction, WorkspaceCaslSubject, } from '../casl/interfaces/workspace-ability.type'; +import { LicenseCheckService } from '../../integrations/environment/license-check.service'; +import { Feature } from '../../common/features'; @Controller('integrations') export class IntegrationController { @@ -31,8 +34,22 @@ export class IntegrationController { private readonly integrationService: IntegrationService, private readonly connectionService: IntegrationConnectionService, private readonly workspaceAbility: WorkspaceAbilityFactory, + private readonly licenseCheckService: LicenseCheckService, + private readonly registry: IntegrationRegistry, ) {} + private assertIntegrationsLicensed(workspace: Workspace) { + if ( + !this.licenseCheckService.hasFeature( + workspace.licenseKey, + Feature.INTEGRATIONS, + workspace.plan, + ) + ) { + throw new ForbiddenException('This feature requires a valid license'); + } + } + @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post('available') @@ -67,6 +84,9 @@ export class IntegrationController { throw new ForbiddenException(); } + if (this.registry.getProvider(dto.type)?.definition.requiresLicense) { + this.assertIntegrationsLicensed(workspace); + } return this.integrationService.install(dto.type, workspace.id, user.id); } @@ -112,7 +132,6 @@ export class IntegrationController { return this.integrationService.update(dto.integrationId, workspace.id, { settings: dto.settings, - isEnabled: dto.isEnabled, }); } diff --git a/apps/server/src/core/integration/integration.processor.ts b/apps/server/src/core/integration/integration.processor.ts index a00e9616c..d8de890f9 100644 --- a/apps/server/src/core/integration/integration.processor.ts +++ b/apps/server/src/core/integration/integration.processor.ts @@ -1,5 +1,7 @@ -import { Processor, WorkerHost } from '@nestjs/bullmq'; -import { Logger } from '@nestjs/common'; +import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger, NotFoundException } from '@nestjs/common'; +import { IntegrationConnection } from '@docmost/db/types/entity.types'; +import { TokenInvalidError } from './registry/integration-provider.interface'; import { Job } from 'bullmq'; import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants'; import { IntegrationRegistry } from './registry/integration-registry'; @@ -35,6 +37,13 @@ export class IntegrationProcessor extends WorkerHost { } } + // Route worker-level errors (e.g. lock renewal after laptop sleep) through + // the logger instead of bullmq's raw console.error fallback. + @OnWorkerEvent('error') + onError(err: Error): void { + this.logger.error(`Worker error: ${err.message}`); + } + private async handleTokenRefresh(): Promise { const connections = await this.connectionRepo.findExpiringTokens( TOKEN_REFRESH_WINDOW_MS, @@ -55,6 +64,15 @@ export class IntegrationProcessor extends WorkerHost { this.logger.error( `Token refresh failed for connection ${connection.id}: ${(err as Error).message}`, ); + // Dead credential or orphaned row: retire it so findExpiringTokens stops selecting it. + if ( + err instanceof NotFoundException || + err instanceof TokenInvalidError + ) { + await this.connectionRepo + .invalidate(connection.id) + .catch(() => undefined); + } } } } @@ -67,7 +85,7 @@ export class IntegrationProcessor extends WorkerHost { } const integrations = - await this.integrationRepo.findEnabledByWorkspace(workspaceId); + await this.integrationRepo.findAllByWorkspace(workspaceId); for (const integration of integrations) { const provider = this.registry.getProvider(integration.type); @@ -75,12 +93,13 @@ export class IntegrationProcessor extends WorkerHost { continue; } + let connection: IntegrationConnection | undefined; try { const connections = await this.connectionRepo.findByIntegration( integration.id, ); - const connection = connections[0]; + connection = connections[0]; let accessToken: string | undefined; if (connection) { @@ -103,6 +122,11 @@ export class IntegrationProcessor extends WorkerHost { this.logger.error( `Integration event handler failed for ${integration.type}: ${(err as Error).message}`, ); + if (err instanceof TokenInvalidError && connection) { + await this.connectionRepo + .invalidate(connection.id) + .catch(() => undefined); + } } } } diff --git a/apps/server/src/core/integration/integration.service.ts b/apps/server/src/core/integration/integration.service.ts index 8ba80a8c8..594248336 100644 --- a/apps/server/src/core/integration/integration.service.ts +++ b/apps/server/src/core/integration/integration.service.ts @@ -3,7 +3,12 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; import { IntegrationRepo } from './repos/integration.repo'; +import { IntegrationConnectionRepo } from './repos/integration-connection.repo'; +import { IntegrationWebhookRepo } from './repos/integration-webhook.repo'; import { IntegrationRegistry } from './registry/integration-registry'; import { Integration } from '@docmost/db/types/entity.types'; import { validateIntegrationSettings } from './dto/integration-settings.schema'; @@ -11,7 +16,10 @@ import { validateIntegrationSettings } from './dto/integration-settings.schema'; @Injectable() export class IntegrationService { constructor( + @InjectKysely() private readonly db: KyselyDB, private readonly integrationRepo: IntegrationRepo, + private readonly connectionRepo: IntegrationConnectionRepo, + private readonly webhookRepo: IntegrationWebhookRepo, private readonly registry: IntegrationRegistry, ) {} @@ -33,7 +41,7 @@ export class IntegrationService { userId: string, ): Promise { const provider = this.registry.getProvider(type); - if (!provider) { + if (!provider || provider.definition.hidden) { throw new BadRequestException(`Unknown integration type: ${type}`); } @@ -59,13 +67,18 @@ export class IntegrationService { if (!integration || integration.workspaceId !== workspaceId) { throw new NotFoundException('Integration not found'); } - await this.integrationRepo.softDelete(integrationId); + // Delete child rows first so no orphan connections keep feeding the token refresh scheduler. + await executeTx(this.db, async (trx) => { + await this.connectionRepo.deleteByIntegration(integrationId, trx); + await this.webhookRepo.deleteByIntegration(integrationId, trx); + await this.integrationRepo.softDelete(integrationId, trx); + }); } async update( integrationId: string, workspaceId: string, - data: { settings?: Record; isEnabled?: boolean }, + data: { settings?: Record }, ): Promise { const integration = await this.integrationRepo.findById(integrationId); if (!integration || integration.workspaceId !== workspaceId) { @@ -85,7 +98,6 @@ export class IntegrationService { return this.integrationRepo.update(integrationId, { ...(data.settings !== undefined && { settings: data.settings }), - ...(data.isEnabled !== undefined && { isEnabled: data.isEnabled }), }); } } diff --git a/apps/server/src/core/integration/oauth/oauth.controller.ts b/apps/server/src/core/integration/oauth/oauth.controller.ts index f40d8f9dc..08273446c 100644 --- a/apps/server/src/core/integration/oauth/oauth.controller.ts +++ b/apps/server/src/core/integration/oauth/oauth.controller.ts @@ -24,6 +24,10 @@ import { OAuthInstallDto, } from '../dto/integration.dto'; import { IntegrationConnectionService } from '../integration-connection.service'; +import { IntegrationRegistry } from '../registry/integration-registry'; +import { LicenseCheckService } from '../../../integrations/environment/license-check.service'; +import { Feature } from '../../../common/features'; +import { ForbiddenException } from '@nestjs/common'; @Controller('integrations/oauth') export class OAuthController { @@ -32,6 +36,8 @@ export class OAuthController { constructor( private readonly oauthService: OAuthService, private readonly connectionService: IntegrationConnectionService, + private readonly licenseCheckService: LicenseCheckService, + private readonly registry: IntegrationRegistry, ) {} @UseGuards(JwtAuthGuard) @@ -66,6 +72,19 @@ export class OAuthController { @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { + // This flow creates the integration row on callback success; gate it + // like a plain install. + if ( + this.registry.getProvider(dto.type)?.definition.requiresLicense && + !this.licenseCheckService.hasFeature( + workspace.licenseKey, + Feature.INTEGRATIONS, + workspace.plan, + ) + ) { + throw new ForbiddenException('This feature requires a valid license'); + } + const { authorizationUrl } = await this.oauthService.getInstallAuthorizationUrl( dto.type, workspace.id, diff --git a/apps/server/src/core/integration/providers/github/github.service.ts b/apps/server/src/core/integration/providers/github/github.service.ts index 7f57e59c8..028db23e9 100644 --- a/apps/server/src/core/integration/providers/github/github.service.ts +++ b/apps/server/src/core/integration/providers/github/github.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { UnfurlResult } from '../../registry/integration-provider.interface'; import { relativeTime } from '../../utils/relative-time'; +import { providerApiFetch } from '../../utils/provider-fetch'; @Injectable() export class GitHubService { @@ -253,7 +254,7 @@ export class GitHubService { apiBaseUrl: string, path: string, ): Promise { - const response = await fetch(`${apiBaseUrl}${path}`, { + const response = await providerApiFetch('GitHub', `${apiBaseUrl}${path}`, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github.v3+json', @@ -261,12 +262,6 @@ export class GitHubService { }, }); - if (!response.ok) { - throw new Error( - `GitHub API error: ${response.status} ${response.statusText}`, - ); - } - return response.json(); } } diff --git a/apps/server/src/core/integration/providers/gitlab/gitlab.service.ts b/apps/server/src/core/integration/providers/gitlab/gitlab.service.ts index e38ef88ab..9a279409c 100644 --- a/apps/server/src/core/integration/providers/gitlab/gitlab.service.ts +++ b/apps/server/src/core/integration/providers/gitlab/gitlab.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { UnfurlResult } from '../../registry/integration-provider.interface'; import { relativeTime } from '../../utils/relative-time'; +import { providerApiFetch } from '../../utils/provider-fetch'; @Injectable() export class GitLabService { @@ -240,19 +241,13 @@ export class GitLabService { apiBaseUrl: string, path: string, ): Promise { - const response = await fetch(`${apiBaseUrl}${path}`, { + const response = await providerApiFetch('GitLab', `${apiBaseUrl}${path}`, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, }); - if (!response.ok) { - throw new Error( - `GitLab API error: ${response.status} ${response.statusText}`, - ); - } - return response.json(); } } diff --git a/apps/server/src/core/integration/registry/integration-provider.interface.ts b/apps/server/src/core/integration/registry/integration-provider.interface.ts index 9d78d4a3d..ce12abb1d 100644 --- a/apps/server/src/core/integration/registry/integration-provider.interface.ts +++ b/apps/server/src/core/integration/registry/integration-provider.interface.ts @@ -35,6 +35,11 @@ export type IntegrationDefinition = { capabilities: IntegrationCapability[]; oauth?: OAuthConfig; unfurlPatterns?: UnfurlPattern[]; + // Kept out of the available list and refused for install; existing + // installations keep unfurling. + hidden?: boolean; + // Install requires the INTEGRATIONS license feature; unset = free. + requiresLicense?: boolean; }; export type ConnectedEvent = { @@ -84,6 +89,26 @@ export class UnfurlForbiddenError extends Error { } } +// Thrown when the provider definitively rejects the stored credential (API 401, +// or invalid_grant at the token endpoint). Callers retire the connection. +export class TokenInvalidError extends Error { + constructor(message = 'Integration credential is no longer valid') { + super(message); + this.name = 'TokenInvalidError'; + } +} + +export class ProviderApiError extends Error { + constructor( + readonly provider: string, + readonly status: number, + statusText = '', + ) { + super(`${provider} API error: ${status} ${statusText}`.trimEnd()); + this.name = 'ProviderApiError'; + } +} + export type LinkDescription = { title: string; description?: string; diff --git a/apps/server/src/core/integration/registry/integration-registry.ts b/apps/server/src/core/integration/registry/integration-registry.ts index 0c2f98285..f7d2eb345 100644 --- a/apps/server/src/core/integration/registry/integration-registry.ts +++ b/apps/server/src/core/integration/registry/integration-registry.ts @@ -21,7 +21,9 @@ export class IntegrationRegistry { } getAvailableIntegrations(): IntegrationDefinition[] { - return this.getAllProviders().map((p) => p.definition); + return this.getAllProviders() + .map((p) => p.definition) + .filter((definition) => !definition.hidden); } findUnfurlProvider( diff --git a/apps/server/src/core/integration/repos/integration-connection.repo.ts b/apps/server/src/core/integration/repos/integration-connection.repo.ts index a3a47de59..9e21eae6e 100644 --- a/apps/server/src/core/integration/repos/integration-connection.repo.ts +++ b/apps/server/src/core/integration/repos/integration-connection.repo.ts @@ -91,6 +91,7 @@ export class IntegrationConnectionRepo { accessToken: connection.accessToken, refreshToken: connection.refreshToken, tokenExpiresAt: connection.tokenExpiresAt, + invalidatedAt: null, scopes: connection.scopes, providerUserId: connection.providerUserId, metadata: connection.metadata, @@ -123,6 +124,7 @@ export class IntegrationConnectionRepo { accessToken: input.accessToken, refreshToken: input.refreshToken ?? null, tokenExpiresAt: input.tokenExpiresAt ?? null, + invalidatedAt: null, scopes: input.scopes ?? null, userId: input.userId, }, @@ -200,9 +202,9 @@ export class IntegrationConnectionRepo { .select([ 'integrationConnections.integrationId', 'integrations.type', - 'integrations.isEnabled', 'integrationConnections.providerUserId', 'integrationConnections.createdAt', + 'integrationConnections.invalidatedAt', ]) .where('integrationConnections.userId', '=', userId) .where('integrations.workspaceId', '=', workspaceId) @@ -216,10 +218,31 @@ export class IntegrationConnectionRepo { const threshold = new Date(Date.now() + expiresBeforeMs); return this.db .selectFrom('integrationConnections') - .selectAll() - .where('refreshToken', 'is not', null) - .where('tokenExpiresAt', 'is not', null) - .where('tokenExpiresAt', '<', threshold) + .innerJoin( + 'integrations', + 'integrations.id', + 'integrationConnections.integrationId', + ) + .selectAll('integrationConnections') + .where('integrations.deletedAt', 'is', null) + .where('integrationConnections.invalidatedAt', 'is', null) + .where('integrationConnections.refreshToken', 'is not', null) + .where('integrationConnections.tokenExpiresAt', 'is not', null) + .where('integrationConnections.tokenExpiresAt', '<', threshold) + .execute(); + } + + // Retire a rejected credential: flag for reconnect UX, drop the dead refresh token; no-op if the row is gone. + async invalidate(connectionId: string): Promise { + await this.db + .updateTable('integrationConnections') + .set({ + invalidatedAt: new Date(), + refreshToken: null, + tokenExpiresAt: null, + updatedAt: new Date(), + }) + .where('id', '=', connectionId) .execute(); } diff --git a/apps/server/src/core/integration/repos/integration.repo.ts b/apps/server/src/core/integration/repos/integration.repo.ts index 6642fc067..aa0511f45 100644 --- a/apps/server/src/core/integration/repos/integration.repo.ts +++ b/apps/server/src/core/integration/repos/integration.repo.ts @@ -41,20 +41,6 @@ export class IntegrationRepo { .executeTakeFirst(); } - async findEnabledByWorkspace( - workspaceId: string, - trx?: KyselyTransaction, - ): Promise { - const db = dbOrTx(this.db, trx); - return db - .selectFrom('integrations') - .selectAll() - .where('workspaceId', '=', workspaceId) - .where('isEnabled', '=', true) - .where('deletedAt', 'is', null) - .execute(); - } - async findAllByWorkspace( workspaceId: string, trx?: KyselyTransaction, @@ -91,7 +77,6 @@ export class IntegrationRepo { .onConflict((oc) => oc.columns(['type', 'workspaceId']).doUpdateSet({ deletedAt: null, - isEnabled: true, installedById: integration.installedById, updatedAt: new Date(), }), @@ -135,7 +120,6 @@ export class IntegrationRepo { .selectFrom('integrations') .selectAll() .where('type', '=', type) - .where('isEnabled', '=', true) .where('deletedAt', 'is', null) .where(sql`settings->>${sql.lit(key)}`, '=', value) .executeTakeFirst(); diff --git a/apps/server/src/core/integration/unfurl/unfurl.service.ts b/apps/server/src/core/integration/unfurl/unfurl.service.ts index a0969adb8..002aeb035 100644 --- a/apps/server/src/core/integration/unfurl/unfurl.service.ts +++ b/apps/server/src/core/integration/unfurl/unfurl.service.ts @@ -7,6 +7,8 @@ import { UnfurlResult, UnfurlNeedsConnection, UnfurlForbiddenError, + TokenInvalidError, + ProviderApiError, IntegrationProvider, } from '../registry/integration-provider.interface'; import { RedisService } from '@nestjs-labs/nestjs-ioredis'; @@ -14,6 +16,9 @@ import type { Redis } from 'ioredis'; import * as crypto from 'crypto'; const UNFURL_CACHE_TTL = 300; // 5 minutes +// Transient failures get a short negative cache so a broken provider is not +// re-fetched on every view; 404s cache at the normal TTL (the target is gone). +const UNFURL_ERROR_CACHE_TTL = 60; const UNFURL_CACHE_PREFIX = 'unfurl:'; @Injectable() @@ -66,11 +71,12 @@ export class UnfurlService { userId, ); - if (!connection) { + if (!connection || connection.invalidatedAt) { + // Dead workspace connections need an admin re-install; members get no card. if (connectionScope === 'workspace') { return null; } - // Not cached: the card should load as soon as the user connects. + // Not cached: the card should load as soon as the user (re)connects. return this.buildNeedsConnection( provider, integration.id, @@ -105,14 +111,45 @@ export class UnfurlService { } catch (err) { // Not-authorized is an expected outcome (no card), not an error. if (err instanceof UnfurlForbiddenError) { - this.logger.debug(`Unfurl not authorized for ${url}`); + this.logger.debug( + `Unfurl not authorized for ${url}: ${(err as Error).message}`, + ); + await this.cacheNull(cacheKey, UNFURL_ERROR_CACHE_TTL); return null; } + if (err instanceof TokenInvalidError) { + this.logger.warn( + `Retiring connection ${connection.id}: ${(err as Error).message}`, + ); + await this.connectionRepo + .invalidate(connection.id) + .catch(() => undefined); + if (connectionScope === 'workspace') { + return null; + } + // Not cached so the card heals the moment the user reconnects. + return this.buildNeedsConnection( + provider, + integration.id, + patternType, + match, + url, + ); + } this.logger.error(`Unfurl failed for ${url}: ${(err as Error).message}`); + const ttl = + err instanceof ProviderApiError && err.status === 404 + ? UNFURL_CACHE_TTL + : UNFURL_ERROR_CACHE_TTL; + await this.cacheNull(cacheKey, ttl); return null; } } + private async cacheNull(cacheKey: string, ttl: number): Promise { + await this.redis.set(cacheKey, 'null', 'EX', ttl); + } + async purgeUserCache(workspaceId: string, userId: string): Promise { const pattern = `${UNFURL_CACHE_PREFIX}${workspaceId}:${userId}:*`; try { @@ -167,7 +204,6 @@ export class UnfurlService { patternType: string; integration: { id: string; - isEnabled: boolean; type: string; settings: unknown; }; @@ -178,13 +214,13 @@ export class UnfurlService { workspaceId, staticResult.provider.definition.type, ); - if (integration && integration.isEnabled) { + if (integration) { return { ...staticResult, integration }; } } const integrations = - await this.integrationRepo.findEnabledByWorkspace(workspaceId); + await this.integrationRepo.findAllByWorkspace(workspaceId); for (const integration of integrations) { const provider = this.registry.getProvider(integration.type); diff --git a/apps/server/src/database/migrations/20260807T1264122-integrations.ts b/apps/server/src/database/migrations/20260807T1264122-integrations.ts index 4837d301d..0ac14c35c 100644 --- a/apps/server/src/database/migrations/20260807T1264122-integrations.ts +++ b/apps/server/src/database/migrations/20260807T1264122-integrations.ts @@ -11,7 +11,6 @@ export async function up(db: Kysely): Promise { col.references('workspaces.id').onDelete('cascade').notNull(), ) .addColumn('type', 'text', (col) => col.notNull()) - .addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true)) .addColumn('settings', 'jsonb') .addColumn('installed_by_id', 'uuid', (col) => col.references('users.id').onDelete('set null'), @@ -50,6 +49,8 @@ export async function up(db: Kysely): Promise { .addColumn('access_token', 'text') .addColumn('refresh_token', 'text') .addColumn('token_expires_at', 'timestamptz') + // Set when the provider definitively rejects the credential; a reconnect clears it. + .addColumn('invalidated_at', 'timestamptz') .addColumn('scopes', 'text') .addColumn('metadata', 'jsonb') // 'workspace' = one shared bot/app connection per integration (Slack); diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index d243c524b..b30e465b7 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -510,7 +510,6 @@ export interface Integrations { id: Generated; workspaceId: string; type: string; - isEnabled: Generated; settings: Json | null; installedById: string | null; createdAt: Generated; @@ -527,6 +526,7 @@ export interface IntegrationConnections { accessToken: string | null; refreshToken: string | null; tokenExpiresAt: Timestamp | null; + invalidatedAt: Timestamp | null; scopes: string | null; kind: string; metadata: Json | null; diff --git a/apps/server/src/ee b/apps/server/src/ee index 8a2b127af..592ef10a7 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 8a2b127afdfb38226f238facbae946cdea75cf11 +Subproject commit 592ef10a71064594c3284133d2e867d8f00fc17a