This commit is contained in:
Philipinho
2026-08-10 03:41:54 +01:00
parent 311b511afb
commit 9f279d191c
29 changed files with 381 additions and 143 deletions
@@ -45,7 +45,9 @@
"Date": "Date", "Date": "Date",
"Delete": "Delete", "Delete": "Delete",
"Initiative": "Initiative", "Initiative": "Initiative",
"Last modified by {{name}}": "Last modified by {{name}}",
"Open in Slack": "Open in Slack", "Open in Slack": "Open in Slack",
"Paid": "Paid",
"Paste as": "Paste as", "Paste as": "Paste as",
"Project": "Project", "Project": "Project",
"Remove from page": "Remove from page", "Remove from page": "Remove from page",
@@ -217,6 +219,7 @@
"Theme": "Theme", "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.", "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 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.", "Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
"Unassigned": "Unassigned", "Unassigned": "Unassigned",
"untitled": "untitled", "untitled": "untitled",
+1
View File
@@ -22,4 +22,5 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal', PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx', DOCX_EXPORT: 'export:docx',
BASES: 'bases', BASES: 'bases',
INTEGRATIONS: 'integrations',
} as const; } as const;
@@ -26,15 +26,14 @@ const ATTACHMENT_NODE_TYPES = [
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//; const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
// Only installed + enabled providers get card treatment; anything else pastes // Only installed providers get card treatment; anything else pastes as an
// as an ordinary link. The cache is prefetched when the page editor mounts; // ordinary link. The cache is prefetched when the page editor mounts;
// a cold cache also means ordinary link. // a cold cache also means ordinary link.
function isIntegrationInstalled(provider: string): boolean { function isIntegrationInstalled(provider: string): boolean {
const installed = queryClient.getQueryData<Integration[]>([ const installed = queryClient.getQueryData<Integration[]>([
"installed-integrations", "installed-integrations",
]); ]);
const integration = installed?.find((i) => i.type === provider); return Boolean(installed?.some((i) => i.type === provider));
return Boolean(integration?.isEnabled);
} }
export const handlePaste = ( export const handlePaste = (
@@ -13,6 +13,18 @@
background-color: var(--mantine-color-dark-5); 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 { .mention {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -215,6 +215,85 @@ function JiraIssueCard({
); );
} }
function FigmaFileCard({
url,
unfurlData,
}: {
url: string;
unfurlData: Record<string, any>;
}) {
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 (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
{showThumbnail && (
<Card.Section withBorder>
<img
src={thumbnailUrl}
alt=""
loading="lazy"
className={classes.thumbnail}
onError={() => setThumbnailFailed(true)}
/>
</Card.Section>
)}
<Group gap="sm" wrap="nowrap" mt={showThumbnail ? "sm" : undefined}>
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
>
{(unfurlData.author ?? unfurlData.title ?? "F").charAt(0)}
</Avatar>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed" truncate>
{subtitle}
</Text>
)}
</Stack>
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon("figma", 18)}
</div>
</Group>
</Card>
</NodeViewWrapper>
);
}
function IntegrationLinkView(props: any) { function IntegrationLinkView(props: any) {
const { node } = props; const { node } = props;
const { url, provider } = node.attrs; const { url, provider } = node.attrs;
@@ -331,6 +410,10 @@ function IntegrationLinkView(props: any) {
return <JiraIssueCard url={url} unfurlData={unfurlData} />; return <JiraIssueCard url={url} unfurlData={unfurlData} />;
} }
if (provider === "figma") {
return <FigmaFileCard url={url} unfurlData={unfurlData} />;
}
return ( return (
<NodeViewWrapper data-drag-handle=""> <NodeViewWrapper data-drag-handle="">
<Card <Card
@@ -60,10 +60,26 @@ export default function ConnectionRow({
<> <>
{connection ? ( {connection ? (
<> <>
<Text size="xs" c="green"> {connection.invalidatedAt ? (
{t("Connected")} <>
{connection.providerUserId && ` (${connection.providerUserId})`} <Text size="xs" c="orange">
</Text> {t("Connection expired")}
</Text>
<Button
size="xs"
variant="light"
color="orange"
onClick={() => onConnect(definition.type)}
>
{t("Reconnect")}
</Button>
</>
) : (
<Text size="xs" c="green">
{t("Connected")}
{connection.providerUserId && ` (${connection.providerUserId})`}
</Text>
)}
<Button <Button
size="xs" size="xs"
variant="subtle" variant="subtle"
@@ -1,17 +1,27 @@
import { Group, Text, Badge, Button, Switch, Box, Stack } from "@mantine/core"; import {
Group,
Text,
Badge,
Button,
Box,
Stack,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
IntegrationDefinition, IntegrationDefinition,
Integration, Integration,
} from "../types/integration.types"; } from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons"; import { getIntegrationIcon } from "./integration-icons";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
type IntegrationRowProps = { type IntegrationRowProps = {
definition: IntegrationDefinition; definition: IntegrationDefinition;
installation?: Integration; installation?: Integration;
onInstall: (type: string) => void; onInstall: (type: string) => void;
onUninstall: (integrationId: string) => void; onUninstall: (integrationId: string) => void;
onToggle: (integration: Integration, enabled: boolean) => void;
}; };
export default function IntegrationRow({ export default function IntegrationRow({
@@ -19,10 +29,12 @@ export default function IntegrationRow({
installation, installation,
onInstall, onInstall,
onUninstall, onUninstall,
onToggle,
}: IntegrationRowProps) { }: IntegrationRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isInstalled = !!installation; const isInstalled = !!installation;
const hasAccess = useHasFeature(Feature.INTEGRATIONS);
const locked = !!definition.requiresLicense && !hasAccess;
const upgradeLabel = useUpgradeLabel();
return ( return (
<Box <Box
@@ -40,6 +52,11 @@ export default function IntegrationRow({
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
{definition.name} {definition.name}
</Text> </Text>
{locked && (
<Badge size="xs" variant="light" color="violet">
{t("Paid")}
</Badge>
)}
{definition.capabilities.map((cap) => ( {definition.capabilities.map((cap) => (
<Badge key={cap} size="xs" variant="light"> <Badge key={cap} size="xs" variant="light">
{cap} {cap}
@@ -54,31 +71,25 @@ export default function IntegrationRow({
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}> <Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{isInstalled ? ( {isInstalled ? (
<>
<Switch
checked={installation.isEnabled}
onChange={(e) =>
onToggle(installation, e.currentTarget.checked)
}
size="sm"
/>
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => onUninstall(installation.id)}
>
{t("Uninstall")}
</Button>
</>
) : (
<Button <Button
size="xs" size="xs"
variant="light" variant="subtle"
onClick={() => onInstall(definition.type)} color="red"
onClick={() => onUninstall(installation.id)}
> >
{t("Install")} {t("Uninstall")}
</Button> </Button>
) : (
<Tooltip label={upgradeLabel} disabled={!locked}>
<Button
size="xs"
variant="light"
disabled={locked}
onClick={() => onInstall(definition.type)}
>
{t("Install")}
</Button>
</Tooltip>
)} )}
</Group> </Group>
</Group> </Group>
@@ -81,8 +81,7 @@ export default function Connections() {
{available {available
.filter((def) => { .filter((def) => {
if (!def.capabilities.includes("oauth")) return false; if (!def.capabilities.includes("oauth")) return false;
const installation = installed?.find((i) => i.type === def.type); return installed?.some((i) => i.type === def.type);
return installation?.isEnabled;
}) })
.map((def) => { .map((def) => {
const connection = myConnections?.find( const connection = myConnections?.find(
@@ -11,13 +11,12 @@ import {
useInstalledIntegrations, useInstalledIntegrations,
useInstallIntegration, useInstallIntegration,
useUninstallIntegration, useUninstallIntegration,
useUpdateIntegrationSettings,
} from "../queries/integration-query"; } from "../queries/integration-query";
import { Integration } from "../types/integration.types";
import { import {
getOAuthAuthorizeUrl, getOAuthAuthorizeUrl,
getOAuthInstallUrl, getOAuthInstallUrl,
} from "../services/integration-service"; } from "../services/integration-service";
import { Integration } from "../types/integration.types";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
export default function Integrations() { export default function Integrations() {
@@ -28,7 +27,6 @@ export default function Integrations() {
useInstalledIntegrations(); useInstalledIntegrations();
const installMutation = useInstallIntegration(); const installMutation = useInstallIntegration();
const uninstallMutation = useUninstallIntegration(); const uninstallMutation = useUninstallIntegration();
const updateMutation = useUpdateIntegrationSettings();
const handleInstall = useCallback( const handleInstall = useCallback(
async (type: string) => { async (type: string) => {
@@ -50,10 +48,34 @@ export default function Integrations() {
return; return;
} }
// Per-user OAuth providers (Linear, Jira, GitHub, ...): keep existing // Per-user OAuth providers (GitLab, Jira, GitHub, ...): create the
// two-step flow — create the integration row, then individual users // integration row, then send the installing admin straight into their
// OAuth-connect from /settings/account/connections. // own OAuth so they leave with a working connection. Other members
installMutation.mutate({ type }); // 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], [installMutation, available, t],
); );
@@ -65,16 +87,6 @@ export default function Integrations() {
[uninstallMutation], [uninstallMutation],
); );
const handleToggle = useCallback(
(integration: Integration, enabled: boolean) => {
updateMutation.mutate({
integrationId: integration.id,
isEnabled: enabled,
});
},
[updateMutation],
);
const isLoading = loadingAvailable || loadingInstalled; const isLoading = loadingAvailable || loadingInstalled;
const error = new URLSearchParams(window.location.search).get("error"); const error = new URLSearchParams(window.location.search).get("error");
@@ -115,7 +127,6 @@ export default function Integrations() {
installation={installation} installation={installation}
onInstall={handleInstall} onInstall={handleInstall}
onUninstall={handleUninstall} onUninstall={handleUninstall}
onToggle={handleToggle}
/> />
); );
})} })}
@@ -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() { export function useMyConnections() {
return useQuery({ return useQuery({
queryKey: ["my-connections"], queryKey: ["my-connections"],
@@ -35,15 +35,6 @@ export async function uninstallIntegration(data: {
await api.post("/integrations/uninstall", data); await api.post("/integrations/uninstall", data);
} }
export async function updateIntegrationSettings(data: {
integrationId: string;
settings?: Record<string, any>;
isEnabled?: boolean;
}): Promise<Integration> {
const req = await api.post<Integration>("/integrations/update", data);
return req.data;
}
export async function getMyConnections(): Promise<UserConnection[]> { export async function getMyConnections(): Promise<UserConnection[]> {
const req = await api.post<UserConnection[]>("/integrations/connections/mine"); const req = await api.post<UserConnection[]>("/integrations/connections/mine");
return req.data; return req.data;
@@ -14,13 +14,13 @@ export type IntegrationDefinition = {
icon: string; icon: string;
capabilities: IntegrationCapability[]; capabilities: IntegrationCapability[];
oauth?: OAuthConfig; oauth?: OAuthConfig;
requiresLicense?: boolean;
}; };
export type Integration = { export type Integration = {
id: string; id: string;
workspaceId: string; workspaceId: string;
type: string; type: string;
isEnabled: boolean;
settings: Record<string, any> | null; settings: Record<string, any> | null;
installedById: string | null; installedById: string | null;
createdAt: string; createdAt: string;
@@ -35,9 +35,9 @@ export type ConnectionStatus = {
export type UserConnection = { export type UserConnection = {
integrationId: string; integrationId: string;
type: string; type: string;
isEnabled: boolean;
providerUserId: string | null; providerUserId: string | null;
connectedAt: string; connectedAt: string;
invalidatedAt: string | null;
}; };
export type UnfurlResult = { export type UnfurlResult = {
+1
View File
@@ -23,6 +23,7 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal', PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx', DOCX_EXPORT: 'export:docx',
BASES: 'bases', BASES: 'bases',
INTEGRATIONS: 'integrations',
} as const; } as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature]; export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -1,5 +1,4 @@
import { import {
IsBoolean,
IsNotEmpty, IsNotEmpty,
IsObject, IsObject,
IsOptional, IsOptional,
@@ -28,10 +27,6 @@ export class UpdateIntegrationDto {
@IsOptional() @IsOptional()
@IsObject() @IsObject()
settings?: Record<string, any>; settings?: Record<string, any>;
@IsOptional()
@IsBoolean()
isEnabled?: boolean;
} }
export class IntegrationIdDto { export class IntegrationIdDto {
@@ -28,7 +28,7 @@ export class IntegrationConnectionService {
); );
return { return {
connected: !!connection, connected: !!connection && !connection.invalidatedAt,
providerUserId: connection?.providerUserId ?? undefined, providerUserId: connection?.providerUserId ?? undefined,
}; };
} }
@@ -61,9 +61,9 @@ export class IntegrationConnectionService {
return rows.map((row) => ({ return rows.map((row) => ({
integrationId: row.integrationId, integrationId: row.integrationId,
type: row.type, type: row.type,
isEnabled: row.isEnabled,
providerUserId: row.providerUserId ?? null, providerUserId: row.providerUserId ?? null,
connectedAt: row.createdAt, connectedAt: row.createdAt,
invalidatedAt: row.invalidatedAt ?? null,
})); }));
} }
@@ -19,11 +19,14 @@ import {
UpdateIntegrationDto, UpdateIntegrationDto,
IntegrationIdDto, IntegrationIdDto,
} from './dto/integration.dto'; } from './dto/integration.dto';
import { IntegrationRegistry } from './registry/integration-registry';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory'; import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import { import {
WorkspaceCaslAction, WorkspaceCaslAction,
WorkspaceCaslSubject, WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type'; } from '../casl/interfaces/workspace-ability.type';
import { LicenseCheckService } from '../../integrations/environment/license-check.service';
import { Feature } from '../../common/features';
@Controller('integrations') @Controller('integrations')
export class IntegrationController { export class IntegrationController {
@@ -31,8 +34,22 @@ export class IntegrationController {
private readonly integrationService: IntegrationService, private readonly integrationService: IntegrationService,
private readonly connectionService: IntegrationConnectionService, private readonly connectionService: IntegrationConnectionService,
private readonly workspaceAbility: WorkspaceAbilityFactory, 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) @UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('available') @Post('available')
@@ -67,6 +84,9 @@ export class IntegrationController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
if (this.registry.getProvider(dto.type)?.definition.requiresLicense) {
this.assertIntegrationsLicensed(workspace);
}
return this.integrationService.install(dto.type, workspace.id, user.id); 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, { return this.integrationService.update(dto.integrationId, workspace.id, {
settings: dto.settings, settings: dto.settings,
isEnabled: dto.isEnabled,
}); });
} }
@@ -1,5 +1,7 @@
import { Processor, WorkerHost } from '@nestjs/bullmq'; import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common'; 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 { Job } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants'; import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
import { IntegrationRegistry } from './registry/integration-registry'; 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<void> { private async handleTokenRefresh(): Promise<void> {
const connections = await this.connectionRepo.findExpiringTokens( const connections = await this.connectionRepo.findExpiringTokens(
TOKEN_REFRESH_WINDOW_MS, TOKEN_REFRESH_WINDOW_MS,
@@ -55,6 +64,15 @@ export class IntegrationProcessor extends WorkerHost {
this.logger.error( this.logger.error(
`Token refresh failed for connection ${connection.id}: ${(err as Error).message}`, `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 = const integrations =
await this.integrationRepo.findEnabledByWorkspace(workspaceId); await this.integrationRepo.findAllByWorkspace(workspaceId);
for (const integration of integrations) { for (const integration of integrations) {
const provider = this.registry.getProvider(integration.type); const provider = this.registry.getProvider(integration.type);
@@ -75,12 +93,13 @@ export class IntegrationProcessor extends WorkerHost {
continue; continue;
} }
let connection: IntegrationConnection | undefined;
try { try {
const connections = await this.connectionRepo.findByIntegration( const connections = await this.connectionRepo.findByIntegration(
integration.id, integration.id,
); );
const connection = connections[0]; connection = connections[0];
let accessToken: string | undefined; let accessToken: string | undefined;
if (connection) { if (connection) {
@@ -103,6 +122,11 @@ export class IntegrationProcessor extends WorkerHost {
this.logger.error( this.logger.error(
`Integration event handler failed for ${integration.type}: ${(err as Error).message}`, `Integration event handler failed for ${integration.type}: ${(err as Error).message}`,
); );
if (err instanceof TokenInvalidError && connection) {
await this.connectionRepo
.invalidate(connection.id)
.catch(() => undefined);
}
} }
} }
} }
@@ -3,7 +3,12 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } 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 { 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 { IntegrationRegistry } from './registry/integration-registry';
import { Integration } from '@docmost/db/types/entity.types'; import { Integration } from '@docmost/db/types/entity.types';
import { validateIntegrationSettings } from './dto/integration-settings.schema'; import { validateIntegrationSettings } from './dto/integration-settings.schema';
@@ -11,7 +16,10 @@ import { validateIntegrationSettings } from './dto/integration-settings.schema';
@Injectable() @Injectable()
export class IntegrationService { export class IntegrationService {
constructor( constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly integrationRepo: IntegrationRepo, private readonly integrationRepo: IntegrationRepo,
private readonly connectionRepo: IntegrationConnectionRepo,
private readonly webhookRepo: IntegrationWebhookRepo,
private readonly registry: IntegrationRegistry, private readonly registry: IntegrationRegistry,
) {} ) {}
@@ -33,7 +41,7 @@ export class IntegrationService {
userId: string, userId: string,
): Promise<Integration> { ): Promise<Integration> {
const provider = this.registry.getProvider(type); const provider = this.registry.getProvider(type);
if (!provider) { if (!provider || provider.definition.hidden) {
throw new BadRequestException(`Unknown integration type: ${type}`); throw new BadRequestException(`Unknown integration type: ${type}`);
} }
@@ -59,13 +67,18 @@ export class IntegrationService {
if (!integration || integration.workspaceId !== workspaceId) { if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found'); 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( async update(
integrationId: string, integrationId: string,
workspaceId: string, workspaceId: string,
data: { settings?: Record<string, any>; isEnabled?: boolean }, data: { settings?: Record<string, any> },
): Promise<Integration> { ): Promise<Integration> {
const integration = await this.integrationRepo.findById(integrationId); const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) { if (!integration || integration.workspaceId !== workspaceId) {
@@ -85,7 +98,6 @@ export class IntegrationService {
return this.integrationRepo.update(integrationId, { return this.integrationRepo.update(integrationId, {
...(data.settings !== undefined && { settings: data.settings }), ...(data.settings !== undefined && { settings: data.settings }),
...(data.isEnabled !== undefined && { isEnabled: data.isEnabled }),
}); });
} }
} }
@@ -24,6 +24,10 @@ import {
OAuthInstallDto, OAuthInstallDto,
} from '../dto/integration.dto'; } from '../dto/integration.dto';
import { IntegrationConnectionService } from '../integration-connection.service'; 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') @Controller('integrations/oauth')
export class OAuthController { export class OAuthController {
@@ -32,6 +36,8 @@ export class OAuthController {
constructor( constructor(
private readonly oauthService: OAuthService, private readonly oauthService: OAuthService,
private readonly connectionService: IntegrationConnectionService, private readonly connectionService: IntegrationConnectionService,
private readonly licenseCheckService: LicenseCheckService,
private readonly registry: IntegrationRegistry,
) {} ) {}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -66,6 +72,19 @@ export class OAuthController {
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @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( const { authorizationUrl } = await this.oauthService.getInstallAuthorizationUrl(
dto.type, dto.type,
workspace.id, workspace.id,
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { UnfurlResult } from '../../registry/integration-provider.interface'; import { UnfurlResult } from '../../registry/integration-provider.interface';
import { relativeTime } from '../../utils/relative-time'; import { relativeTime } from '../../utils/relative-time';
import { providerApiFetch } from '../../utils/provider-fetch';
@Injectable() @Injectable()
export class GitHubService { export class GitHubService {
@@ -253,7 +254,7 @@ export class GitHubService {
apiBaseUrl: string, apiBaseUrl: string,
path: string, path: string,
): Promise<any> { ): Promise<any> {
const response = await fetch(`${apiBaseUrl}${path}`, { const response = await providerApiFetch('GitHub', `${apiBaseUrl}${path}`, {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github.v3+json', 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(); return response.json();
} }
} }
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { UnfurlResult } from '../../registry/integration-provider.interface'; import { UnfurlResult } from '../../registry/integration-provider.interface';
import { relativeTime } from '../../utils/relative-time'; import { relativeTime } from '../../utils/relative-time';
import { providerApiFetch } from '../../utils/provider-fetch';
@Injectable() @Injectable()
export class GitLabService { export class GitLabService {
@@ -240,19 +241,13 @@ export class GitLabService {
apiBaseUrl: string, apiBaseUrl: string,
path: string, path: string,
): Promise<any> { ): Promise<any> {
const response = await fetch(`${apiBaseUrl}${path}`, { const response = await providerApiFetch('GitLab', `${apiBaseUrl}${path}`, {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
Accept: 'application/json', Accept: 'application/json',
}, },
}); });
if (!response.ok) {
throw new Error(
`GitLab API error: ${response.status} ${response.statusText}`,
);
}
return response.json(); return response.json();
} }
} }
@@ -35,6 +35,11 @@ export type IntegrationDefinition = {
capabilities: IntegrationCapability[]; capabilities: IntegrationCapability[];
oauth?: OAuthConfig; oauth?: OAuthConfig;
unfurlPatterns?: UnfurlPattern[]; 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 = { 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 = { export type LinkDescription = {
title: string; title: string;
description?: string; description?: string;
@@ -21,7 +21,9 @@ export class IntegrationRegistry {
} }
getAvailableIntegrations(): IntegrationDefinition[] { getAvailableIntegrations(): IntegrationDefinition[] {
return this.getAllProviders().map((p) => p.definition); return this.getAllProviders()
.map((p) => p.definition)
.filter((definition) => !definition.hidden);
} }
findUnfurlProvider( findUnfurlProvider(
@@ -91,6 +91,7 @@ export class IntegrationConnectionRepo {
accessToken: connection.accessToken, accessToken: connection.accessToken,
refreshToken: connection.refreshToken, refreshToken: connection.refreshToken,
tokenExpiresAt: connection.tokenExpiresAt, tokenExpiresAt: connection.tokenExpiresAt,
invalidatedAt: null,
scopes: connection.scopes, scopes: connection.scopes,
providerUserId: connection.providerUserId, providerUserId: connection.providerUserId,
metadata: connection.metadata, metadata: connection.metadata,
@@ -123,6 +124,7 @@ export class IntegrationConnectionRepo {
accessToken: input.accessToken, accessToken: input.accessToken,
refreshToken: input.refreshToken ?? null, refreshToken: input.refreshToken ?? null,
tokenExpiresAt: input.tokenExpiresAt ?? null, tokenExpiresAt: input.tokenExpiresAt ?? null,
invalidatedAt: null,
scopes: input.scopes ?? null, scopes: input.scopes ?? null,
userId: input.userId, userId: input.userId,
}, },
@@ -200,9 +202,9 @@ export class IntegrationConnectionRepo {
.select([ .select([
'integrationConnections.integrationId', 'integrationConnections.integrationId',
'integrations.type', 'integrations.type',
'integrations.isEnabled',
'integrationConnections.providerUserId', 'integrationConnections.providerUserId',
'integrationConnections.createdAt', 'integrationConnections.createdAt',
'integrationConnections.invalidatedAt',
]) ])
.where('integrationConnections.userId', '=', userId) .where('integrationConnections.userId', '=', userId)
.where('integrations.workspaceId', '=', workspaceId) .where('integrations.workspaceId', '=', workspaceId)
@@ -216,10 +218,31 @@ export class IntegrationConnectionRepo {
const threshold = new Date(Date.now() + expiresBeforeMs); const threshold = new Date(Date.now() + expiresBeforeMs);
return this.db return this.db
.selectFrom('integrationConnections') .selectFrom('integrationConnections')
.selectAll() .innerJoin(
.where('refreshToken', 'is not', null) 'integrations',
.where('tokenExpiresAt', 'is not', null) 'integrations.id',
.where('tokenExpiresAt', '<', threshold) '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<void> {
await this.db
.updateTable('integrationConnections')
.set({
invalidatedAt: new Date(),
refreshToken: null,
tokenExpiresAt: null,
updatedAt: new Date(),
})
.where('id', '=', connectionId)
.execute(); .execute();
} }
@@ -41,20 +41,6 @@ export class IntegrationRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
async findEnabledByWorkspace(
workspaceId: string,
trx?: KyselyTransaction,
): Promise<Integration[]> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrations')
.selectAll()
.where('workspaceId', '=', workspaceId)
.where('isEnabled', '=', true)
.where('deletedAt', 'is', null)
.execute();
}
async findAllByWorkspace( async findAllByWorkspace(
workspaceId: string, workspaceId: string,
trx?: KyselyTransaction, trx?: KyselyTransaction,
@@ -91,7 +77,6 @@ export class IntegrationRepo {
.onConflict((oc) => .onConflict((oc) =>
oc.columns(['type', 'workspaceId']).doUpdateSet({ oc.columns(['type', 'workspaceId']).doUpdateSet({
deletedAt: null, deletedAt: null,
isEnabled: true,
installedById: integration.installedById, installedById: integration.installedById,
updatedAt: new Date(), updatedAt: new Date(),
}), }),
@@ -135,7 +120,6 @@ export class IntegrationRepo {
.selectFrom('integrations') .selectFrom('integrations')
.selectAll() .selectAll()
.where('type', '=', type) .where('type', '=', type)
.where('isEnabled', '=', true)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.where(sql<string>`settings->>${sql.lit(key)}`, '=', value) .where(sql<string>`settings->>${sql.lit(key)}`, '=', value)
.executeTakeFirst(); .executeTakeFirst();
@@ -7,6 +7,8 @@ import {
UnfurlResult, UnfurlResult,
UnfurlNeedsConnection, UnfurlNeedsConnection,
UnfurlForbiddenError, UnfurlForbiddenError,
TokenInvalidError,
ProviderApiError,
IntegrationProvider, IntegrationProvider,
} from '../registry/integration-provider.interface'; } from '../registry/integration-provider.interface';
import { RedisService } from '@nestjs-labs/nestjs-ioredis'; import { RedisService } from '@nestjs-labs/nestjs-ioredis';
@@ -14,6 +16,9 @@ import type { Redis } from 'ioredis';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
const UNFURL_CACHE_TTL = 300; // 5 minutes 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:'; const UNFURL_CACHE_PREFIX = 'unfurl:';
@Injectable() @Injectable()
@@ -66,11 +71,12 @@ export class UnfurlService {
userId, userId,
); );
if (!connection) { if (!connection || connection.invalidatedAt) {
// Dead workspace connections need an admin re-install; members get no card.
if (connectionScope === 'workspace') { if (connectionScope === 'workspace') {
return null; 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( return this.buildNeedsConnection(
provider, provider,
integration.id, integration.id,
@@ -105,14 +111,45 @@ export class UnfurlService {
} catch (err) { } catch (err) {
// Not-authorized is an expected outcome (no card), not an error. // Not-authorized is an expected outcome (no card), not an error.
if (err instanceof UnfurlForbiddenError) { 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; 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}`); 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; return null;
} }
} }
private async cacheNull(cacheKey: string, ttl: number): Promise<void> {
await this.redis.set(cacheKey, 'null', 'EX', ttl);
}
async purgeUserCache(workspaceId: string, userId: string): Promise<void> { async purgeUserCache(workspaceId: string, userId: string): Promise<void> {
const pattern = `${UNFURL_CACHE_PREFIX}${workspaceId}:${userId}:*`; const pattern = `${UNFURL_CACHE_PREFIX}${workspaceId}:${userId}:*`;
try { try {
@@ -167,7 +204,6 @@ export class UnfurlService {
patternType: string; patternType: string;
integration: { integration: {
id: string; id: string;
isEnabled: boolean;
type: string; type: string;
settings: unknown; settings: unknown;
}; };
@@ -178,13 +214,13 @@ export class UnfurlService {
workspaceId, workspaceId,
staticResult.provider.definition.type, staticResult.provider.definition.type,
); );
if (integration && integration.isEnabled) { if (integration) {
return { ...staticResult, integration }; return { ...staticResult, integration };
} }
} }
const integrations = const integrations =
await this.integrationRepo.findEnabledByWorkspace(workspaceId); await this.integrationRepo.findAllByWorkspace(workspaceId);
for (const integration of integrations) { for (const integration of integrations) {
const provider = this.registry.getProvider(integration.type); const provider = this.registry.getProvider(integration.type);
@@ -11,7 +11,6 @@ export async function up(db: Kysely<any>): Promise<void> {
col.references('workspaces.id').onDelete('cascade').notNull(), col.references('workspaces.id').onDelete('cascade').notNull(),
) )
.addColumn('type', 'text', (col) => col.notNull()) .addColumn('type', 'text', (col) => col.notNull())
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
.addColumn('settings', 'jsonb') .addColumn('settings', 'jsonb')
.addColumn('installed_by_id', 'uuid', (col) => .addColumn('installed_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'), col.references('users.id').onDelete('set null'),
@@ -50,6 +49,8 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('access_token', 'text') .addColumn('access_token', 'text')
.addColumn('refresh_token', 'text') .addColumn('refresh_token', 'text')
.addColumn('token_expires_at', 'timestamptz') .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('scopes', 'text')
.addColumn('metadata', 'jsonb') .addColumn('metadata', 'jsonb')
// 'workspace' = one shared bot/app connection per integration (Slack); // 'workspace' = one shared bot/app connection per integration (Slack);
+1 -1
View File
@@ -510,7 +510,6 @@ export interface Integrations {
id: Generated<string>; id: Generated<string>;
workspaceId: string; workspaceId: string;
type: string; type: string;
isEnabled: Generated<boolean>;
settings: Json | null; settings: Json | null;
installedById: string | null; installedById: string | null;
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
@@ -527,6 +526,7 @@ export interface IntegrationConnections {
accessToken: string | null; accessToken: string | null;
refreshToken: string | null; refreshToken: string | null;
tokenExpiresAt: Timestamp | null; tokenExpiresAt: Timestamp | null;
invalidatedAt: Timestamp | null;
scopes: string | null; scopes: string | null;
kind: string; kind: string;
metadata: Json | null; metadata: Json | null;