From 0ab6ae86e3eb6a321104d33840b8c8922bb398c7 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 23 May 2026 04:35:59 +0100 Subject: [PATCH] feat(integrations): add connectionScope (workspace|user) to OAuth flow --- .../core/integration/oauth/oauth.service.ts | 32 +++++++---- .../integration-provider.interface.ts | 3 ++ .../repos/integration-connection.repo.ts | 54 +++++++++++++++++++ 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/apps/server/src/core/integration/oauth/oauth.service.ts b/apps/server/src/core/integration/oauth/oauth.service.ts index 253f4befc..43e9f2542 100644 --- a/apps/server/src/core/integration/oauth/oauth.service.ts +++ b/apps/server/src/core/integration/oauth/oauth.service.ts @@ -149,15 +149,29 @@ export class OAuthService { ? new Date(Date.now() + tokenResponse.expires_in * 1000) : null; - const connection = await this.connectionRepo.upsert({ - integrationId, - userId, - workspaceId, - accessToken: encryptedAccessToken, - refreshToken: encryptedRefreshToken, - tokenExpiresAt, - scopes: tokenResponse.scope ?? null, - }); + const connectionScope = + provider.definition.oauth?.connectionScope ?? 'user'; + + const connection = + connectionScope === 'workspace' + ? await this.connectionRepo.upsertWorkspaceConnection({ + integrationId, + userId, + workspaceId, + accessToken: encryptedAccessToken, + refreshToken: encryptedRefreshToken, + tokenExpiresAt, + scopes: tokenResponse.scope ?? null, + }) + : await this.connectionRepo.upsert({ + integrationId, + userId, + workspaceId, + accessToken: encryptedAccessToken, + refreshToken: encryptedRefreshToken, + tokenExpiresAt, + scopes: tokenResponse.scope ?? null, + }); if (provider.onConnected) { await provider.onConnected({ 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 d7001dd5d..19ee25076 100644 --- a/apps/server/src/core/integration/registry/integration-provider.interface.ts +++ b/apps/server/src/core/integration/registry/integration-provider.interface.ts @@ -4,6 +4,9 @@ export type OAuthConfig = { authUrl: string; tokenUrl: string; scopes: string[]; + // 'workspace' = one shared bot/app connection per integration (Slack model); + // 'user' (default) = each Docmost user OAuths separately and gets their own token (Linear, Jira, GitHub model) + connectionScope?: 'workspace' | 'user'; }; export type UnfurlPattern = { 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 fd6216fbb..4e6d8136b 100644 --- a/apps/server/src/core/integration/repos/integration-connection.repo.ts +++ b/apps/server/src/core/integration/repos/integration-connection.repo.ts @@ -95,6 +95,60 @@ export class IntegrationConnectionRepo { .executeTakeFirstOrThrow(); } + async upsertWorkspaceConnection( + input: { + integrationId: string; + userId: string; + workspaceId: string; + accessToken: string; + refreshToken?: string | null; + tokenExpiresAt?: Date | null; + scopes?: string | null; + }, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + + const existing = await this.findWorkspaceConnection(input.integrationId, trx); + if (existing) { + return this.update( + existing.id, + { + accessToken: input.accessToken, + refreshToken: input.refreshToken ?? null, + tokenExpiresAt: input.tokenExpiresAt ?? null, + scopes: input.scopes ?? null, + userId: input.userId, + }, + trx, + ); + } + + // Clear any stale non-workspace row for the same (integration, user) to + // avoid the uq(integration_id, user_id) constraint blocking the insert. + await db + .deleteFrom('integrationConnections') + .where('integrationId', '=', input.integrationId) + .where('userId', '=', input.userId) + .where('kind', '!=', 'workspace') + .execute(); + + return db + .insertInto('integrationConnections') + .values({ + integrationId: input.integrationId, + userId: input.userId, + workspaceId: input.workspaceId, + accessToken: input.accessToken, + refreshToken: input.refreshToken ?? null, + tokenExpiresAt: input.tokenExpiresAt ?? null, + scopes: input.scopes ?? null, + kind: 'workspace', + }) + .returningAll() + .executeTakeFirstOrThrow(); + } + async update( connectionId: string, data: UpdatableIntegrationConnection,