diff --git a/apps/server/src/core/integration/oauth/oauth.controller.ts b/apps/server/src/core/integration/oauth/oauth.controller.ts index 5628c3dad..598a147c4 100644 --- a/apps/server/src/core/integration/oauth/oauth.controller.ts +++ b/apps/server/src/core/integration/oauth/oauth.controller.ts @@ -24,7 +24,6 @@ import { OAuthInstallDto, } from '../dto/integration.dto'; import { IntegrationConnectionService } from '../integration-connection.service'; -import { EnvironmentService } from '../../../integrations/environment/environment.service'; @Controller('integrations/oauth') export class OAuthController { @@ -33,7 +32,6 @@ export class OAuthController { constructor( private readonly oauthService: OAuthService, private readonly connectionService: IntegrationConnectionService, - private readonly environmentService: EnvironmentService, ) {} @UseGuards(JwtAuthGuard) @@ -94,11 +92,8 @@ export class OAuthController { // returnUrl is derived server-side at authorize time from the workspace's // own hostname/customDomain (canonical DB truth, not user input), then - // signed into the state JWT. Safe to use directly here — tampering would - // invalidate the signature; older tokens predating this field will be - // undefined and fall back to APP_URL. - const returnUrl = - statePayload.returnUrl || this.environmentService.getAppUrl(); + // signed into the state JWT. Tampering would invalidate the signature. + const returnUrl = statePayload.returnUrl; try { await this.oauthService.exchangeCodeForTokens( diff --git a/apps/server/src/database/migrations/20260522T1264122-integrations.ts b/apps/server/src/database/migrations/20260522T1264122-integrations.ts index 54aaa38ef..4837d301d 100644 --- a/apps/server/src/database/migrations/20260522T1264122-integrations.ts +++ b/apps/server/src/database/migrations/20260522T1264122-integrations.ts @@ -3,6 +3,7 @@ import { type Kysely, sql } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema .createTable('integrations') + .ifNotExists() .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`), ) @@ -30,6 +31,7 @@ export async function up(db: Kysely): Promise { await db.schema .createTable('integration_connections') + .ifNotExists() .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`), ) @@ -43,25 +45,55 @@ export async function up(db: Kysely): Promise { col.references('workspaces.id').onDelete('cascade').notNull(), ) .addColumn('provider_user_id', 'text') - .addColumn('access_token', 'text', (col) => col.notNull()) + // Nullable: workspace-scoped rows carry a token; user-scoped identity-link + // rows have no token (the binding alone is what they store). + .addColumn('access_token', 'text') .addColumn('refresh_token', 'text') .addColumn('token_expires_at', 'timestamptz') .addColumn('scopes', 'text') .addColumn('metadata', 'jsonb') + // 'workspace' = one shared bot/app connection per integration (Slack); + // 'user' = a per-user OAuth token or identity link (Linear, GitHub, Slack + // identity binding). Enforced via a check constraint below. + .addColumn('kind', 'text', (col) => col.notNull().defaultTo('user')) .addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`), ) .addColumn('updated_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`), ) - .addUniqueConstraint('uq_integration_connections_integration_user', [ - 'integration_id', - 'user_id', - ]) + .execute(); + + await sql` + ALTER TABLE integration_connections + ADD CONSTRAINT integration_connections_kind_check + CHECK (kind IN ('workspace', 'user')) + `.execute(db); + + // One workspace-bot connection per integration. + await db.schema + .createIndex('uq_integration_connections_workspace_per_integration') + .on('integration_connections') + .column('integration_id') + .where(sql.ref('kind'), '=', 'workspace') + .unique() + .execute(); + + // One user-link row per (integration, user). Partial on kind='user' so a + // workspace bot row sharing (integration_id, user_id) with the installer's + // personal user-link is NOT a conflict — they are semantically different + // rows with their own constraints. + await db.schema + .createIndex('uq_integration_connections_user_per_integration') + .on('integration_connections') + .columns(['integration_id', 'user_id']) + .where(sql.ref('kind'), '=', 'user') + .unique() .execute(); await db.schema .createTable('integration_webhooks') + .ifNotExists() .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`), ) @@ -85,6 +117,7 @@ export async function up(db: Kysely): Promise { await db.schema .createIndex('idx_integration_webhooks_integration_event') + .ifNotExists() .on('integration_webhooks') .columns(['integration_id', 'event_type']) .execute(); diff --git a/apps/server/src/database/migrations/20260523T124728-integration-connections-kind.ts b/apps/server/src/database/migrations/20260523T124728-integration-connections-kind.ts deleted file mode 100644 index 9f0287d4a..000000000 --- a/apps/server/src/database/migrations/20260523T124728-integration-connections-kind.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { type Kysely, sql } from 'kysely'; - -export async function up(db: Kysely): Promise { - // 1. Drop NOT NULL on access_token (identity-only rows have no token) - await db.schema - .alterTable('integration_connections') - .alterColumn('access_token', (col) => col.dropNotNull()) - .execute(); - - // 2. Add kind discriminator with check constraint - await db.schema - .alterTable('integration_connections') - .addColumn('kind', 'text', (col) => - col.notNull().defaultTo('user'), - ) - .execute(); - - await sql` - ALTER TABLE integration_connections - ADD CONSTRAINT integration_connections_kind_check - CHECK (kind IN ('workspace', 'user')) - `.execute(db); - - // 3. Backfill: existing Slack workspace installs (rows with access_token) - await sql` - UPDATE integration_connections AS ic - SET kind = 'workspace' - FROM integrations AS i - WHERE ic.integration_id = i.id - AND i.type = 'slack' - AND ic.access_token IS NOT NULL - `.execute(db); - - // 4. One workspace connection per integration - await db.schema - .createIndex('uq_integration_connections_workspace_per_integration') - .on('integration_connections') - .column('integration_id') - .where(sql.ref('kind'), '=', 'workspace') - .unique() - .execute(); -} - -export async function down(db: Kysely): Promise { - await db.schema - .dropIndex('uq_integration_connections_workspace_per_integration') - .ifExists() - .execute(); - - await sql`ALTER TABLE integration_connections DROP CONSTRAINT IF EXISTS integration_connections_kind_check`.execute(db); - - await db.schema - .alterTable('integration_connections') - .dropColumn('kind') - .execute(); - - // Note: we don't restore NOT NULL on access_token in down(); by this point - // there may be legitimate null-token rows (identity-only user links) that - // would block re-adding the constraint. -} diff --git a/apps/server/src/database/migrations/20260524T020000-integration-connections-user-kind-unique.ts b/apps/server/src/database/migrations/20260524T020000-integration-connections-user-kind-unique.ts deleted file mode 100644 index fb4723256..000000000 --- a/apps/server/src/database/migrations/20260524T020000-integration-connections-user-kind-unique.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type Kysely, sql } from 'kysely'; - -export async function up(db: Kysely): Promise { - // The original (integration_id, user_id) unique constraint predates the - // `kind` discriminator. For workspace-scoped integrations (Slack), the - // installer's user_id appears on BOTH the workspace bot row and their - // personal user-link row. The constraint blocks that, and the resulting - // upsert-conflict in user-link flow flipped the existing workspace row - // to kind='user' (clobbering the bot connection). - // - // Replace with a partial unique index that only constrains kind='user' - // rows. Workspace rows already have their own partial unique index on - // (integration_id) WHERE kind = 'workspace'. - - await sql`ALTER TABLE integration_connections DROP CONSTRAINT uq_integration_connections_integration_user`.execute( - db, - ); - - await db.schema - .createIndex('uq_integration_connections_user_per_integration') - .on('integration_connections') - .columns(['integration_id', 'user_id']) - .where(sql.ref('kind'), '=', 'user') - .unique() - .execute(); - - // Repair Slack workspace rows that got flipped to kind='user' by the - // earlier upsertUserLink bug. User-link rows have NULL access_token by - // design; any kind='user' row that still has access_token AND scopes - // populated for a Slack integration is the corrupted bot row. - await sql` - UPDATE integration_connections ic - SET kind = 'workspace' - FROM integrations i - WHERE ic.integration_id = i.id - AND i.type = 'slack' - AND ic.kind = 'user' - AND ic.access_token IS NOT NULL - AND ic.scopes IS NOT NULL - `.execute(db); -} - -export async function down(db: Kysely): Promise { - await db.schema - .dropIndex('uq_integration_connections_user_per_integration') - .ifExists() - .execute(); - - // Re-adding the full constraint will fail on any DB that now legitimately - // has both a kind='workspace' and kind='user' row for the same - // (integration_id, user_id). Operators rolling back should clean those - // up first. We don't try to be clever; the constraint name is preserved. - await sql`ALTER TABLE integration_connections ADD CONSTRAINT uq_integration_connections_integration_user UNIQUE (integration_id, user_id)`.execute( - db, - ); -}