Skip to content

refactor(slack): store the workspace bot token on the workspace - #5004

Open
RSO wants to merge 6 commits into
mainfrom
multi-org-slack
Open

refactor(slack): store the workspace bot token on the workspace#5004
RSO wants to merge 6 commits into
mainfrom
multi-org-slack

Conversation

@RSO

@RSO RSO commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Slack issues exactly one installation, and therefore one bot token, per (app, workspace). That token was stored on each owner's platform_integrations.metadata.access_token, which ties a workspace-level credential to a per-owner row.

  • Add slack_workspace_installations, keyed on the Slack team ID, to hold it.
  • Every completed OAuth install writes it, so the record stays current — no re-backfill is ever needed.
  • Handle lifecycle everywhere a Slack integration can go away, so a stored token cannot outlive the integrations that used it.
  • Clear suspended_at / suspended_by when an install re-activates an integration.

The migration is purely additive. It creates the table and seeds it from the existing per-integration tokens. No index and no column is changed.

Scope: this does not yet allow multiple organizations per workspace

An earlier revision of this PR dropped UQ_platform_integrations_slack_platform_inst. That was wrong as a standalone change, and I've reverted it here.

Dropping that index only becomes correct once message routing can tell two owners apart. Until then the application layer still refuses a second owner via getConflictingSlackInstallation, so the index is the correct enforcement of a rule that still exists — removing it would only open the OAuth race where two concurrent callbacks both pass the pre-check, after which getPlatformIntegration()'s unordered .limit(1) could route work and billing to an arbitrary owner.

So this PR is the prerequisite refactor: get the workspace token onto the workspace. The index drop plus link-based org routing follows separately.

Why a separate table

The token is a property of the workspace, not of an owner. Keeping it per-owner is what makes multi-org impossible without duplicating a secret across rows that then drift apart on re-install. The Chat SDK's own installation store is already keyed on team id only (@chat-adapter/slack, slack:installation:{teamId}), so this matches both Slack's model and the SDK's.

bot_token is stored unencrypted, at parity with the metadata.access_token copy it replaces. Moving Slack onto the keyed envelope encryption used by GitLab/Bitbucket is a pre-existing gap, deliberately not bundled here.

Rolling-deploy safety, and why reads still prefer metadata

metadata.access_token and metadata.bot_user_id are still written, and getSlackBotToken prefers them, with the workspace record as the fallback.

That order looks backwards for a table introduced as the workspace-level store, and it is deliberate. Every writer updates metadata — the previous release writes only there, this release mirrors into it — so metadata is never staler than the workspace record. The reverse does not hold: a disconnect and reconnect served by the previous release deletes and recreates platform_integrations without touching the workspace record, leaving it holding a revoked token. Reading the workspace record first would then keep using that revoked token for auth checks, permalinks and direct sends until someone reinstalled.

So this PR expands (write both, read the safe one) and the follow-up contracts (drop the mirror, which flips the preference to workspace-only). Doing it in that order is what makes the rollout safe in both directions.

Lifecycle coverage

Path Behaviour
uninstallApp (disconnect) Removes the workspace record once nothing references the workspace
deleteInstallationByTeamId (app_uninstalled) Workspace-wide, so clears the record even if the integration row is already gone
removeDbRowOnly (dev-only) Clears it, otherwise re-running OAuth would resolve the previous token
softDeleteUser Deletes workspace records for workspaces the user owned, before their integrations are removed

softDeleteUser deletes before the integration rows go, because deleting the integration first would lose the team IDs needed to find these records.

Verification

  • pnpm drizzle:verify-bootstrap — clean apply from an empty database
  • apps/web typecheck: 0 errors. oxlint: 0 warnings, 0 errors. oxfmt clean.
  • 43 unit tests across slack-service, slack-workspace-installation, bot, platform-helpers
  • Full src/lib/user/index.test.ts suite: 97 passed, including new softDeleteUser coverage against a real database
  • Migration backfill exercised against a fresh migrated database with real rows:
Check Result
Only UQ_platform_integrations_slack_platform_inst present on platform_integrations confirmed
Active row seeded with token, bot_user_id, scopes confirmed
0108-detached row (NULL platform_installation_id) excluded confirmed
Re-running the backfill is a no-op confirmed
Global index still blocks a second owner confirmed

Visual Changes

N/A

Reviewer Notes

  • Backfill appended after the generated DDL with the --> statement-breakpoint marker per packages/db/AGENTS.md. It uses DISTINCT ON ... ORDER BY updated_at DESC rather than the unordered LIMIT 1 that 0108 used, and ON CONFLICT DO NOTHING.
  • Prod was checked before writing it: 1685 Slack rows, 1638 active, 1638 distinct team ids, and 0 rows missing access_token or bot_user_id — so the backfill seeds every connected workspace.
  • last_installed_by_user_id is left NULL by the backfill on purpose. The nearest source column, platform_integrations.created_by_user_id, is plain text() with no FK and may hold ids no longer in kilocode_users, which would violate the new FK. New installs populate it from the acting user.
  • Known gap, no hook available: hard-deleting an organizations row cascades its platform_integrations away and orphans the workspace record. Verified in the fresh-DB run above. Organizations are only hard-deleted by dev scripts (scripts/db/create-trial-test-orgs.ts, app/api/dev/create-kilocode-org), never by product code, so there is no production path to hook. Flagging rather than leaving it silent; worth revisiting if org deletion is ever productised.
  • In slack-service.test.ts the slack-workspace-installation mock forwards lazily through arrow wrappers. The factory runs while slack-service is being imported, which is before the const mock declarations initialize, so capturing the mock functions directly throws a TDZ ReferenceError.

Follow-ups

  1. Drop UQ_platform_integrations_slack_platform_inst and add link-based org routing, enabling multiple organizations per workspace.
  2. Remove the metadata.access_token / metadata.bot_user_id mirror, which flips getSlackBotToken to read the workspace record only. Safe once the release below is fully rolled out.
  3. Encrypt bot_token with the keyed envelope scheme used by GitLab/Bitbucket.
  4. getPlatformIntegrationByBotUserId matches on platform + metadata->>'bot_user_id' only. The bot user id is app-wide, so it already returns an arbitrary row across workspaces today.

Review notes (codex autoreview)

Four rounds. Three findings accepted and fixed, one rejected:

Finding Outcome
Switching an owner to a different workspace orphaned the old workspace record and its token Fixed in 737ee64b3. Reachable: getInstallation(owner) finds the owner's single row regardless of team, so re-running connect repoints it. A regression this PR introduced — previously the token lived in metadata on that same row and was simply overwritten. Also guarded the case where the integration write fails after the workspace record was written.
A stale workspace record could outrank a fresh metadata token during the rollout Fixed in 0fb9e5568 by inverting the read preference. See the rolling-deploy section above.
last_installed_by_user_id kept referencing a soft-deleted user for org-owned installs Fixed in 8047f690d. The set null FK does not cover it: soft deletion retains the kilocode_users row. Now nulled on surviving rows, matching how dismissed_by_user_id and friends are already handled.
Workspace-wide teardown in uninstallApp happens before the countSlackConnections guard Rejected as unreachable here. UQ_platform_integrations_slack_platform_inst limits a workspace to one owner, so the count is only ever 0 or 1, and the owner's row is deleted before it is consulted — it is always 0. Empirically confirmed: inserting a second owner for the same team is rejected by that constraint. It is the correct requirement for the follow-up that drops the index, so the invariant and the required reordering are documented at the call site in 5b6b84284.

The final confirmation run could not complete: the Codex workspace is out of credits. The three accepted fixes were each verified by targeted tests before committing, but the branch has not had a clean review pass end to end.

Comment thread packages/db/src/migrations/0204_serious_karma.sql Outdated
Comment thread packages/db/src/schema.ts
@kilo-code-bot

kilo-code-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: 2 Issues Found | Recommendation: Address before merge

Executive Summary

The soft-delete gap on last_installed_by_user_id is now fixed with test coverage and the getSlackBotToken read preference is safely inverted; the remaining items are low-severity — the new failure-path cleanup in upsertSlackInstallation can mask the original error (including the workspace-conflict translation), and the workspace-record teardown remains check-then-act, benign only while the metadata fallback exists.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 2
Issue Details (click to expand)

SUGGESTION

File Line Issue
apps/web/src/lib/integrations/slack-service.ts 308 New failure-path cleanup is awaited before the caught error is inspected; a transient cleanup failure masks the original error, including the SlackWorkspaceAlreadyConnectedError translation behind the workspace_already_connected redirect (new)
apps/web/src/lib/integrations/slack-service.ts 397 deleteWorkspaceInstallationIfUnused is check-then-act; a concurrent re-install can have its freshly written workspace record deleted — benign while getSlackBotToken prefers the metadata.access_token mirror, a real gap once the follow-up removes it (carried)
Files Reviewed (7 files, incremental)
  • apps/web/src/lib/integrations/slack-service.ts - 2 issues (1 new, 1 carried). Workspace-switch teardown and failure-path cleanup are otherwise correct under the single-owner unique index; the uninstallApp invariant comment accurately documents the reordering the index-dropping follow-up must make.
  • apps/web/src/lib/integrations/slack-service.test.ts - 0 issues. New tests cover workspace switch, unchanged workspace, referenced workspace, and insert-failure cleanup.
  • apps/web/src/lib/integrations/slack-workspace-installation.ts - 0 issues. Metadata-first read is the safe order for the rolling deploy; ?? undefined handles a NULL bot_token.
  • apps/web/src/lib/integrations/slack-workspace-installation.test.ts - 0 issues. Tests updated to match the inverted preference.
  • apps/web/src/lib/user/index.ts - 0 issues. softDeleteUser now nulls last_installed_by_user_id on surviving (org-owned) workspace rows — resolves the previous schema.ts finding per packages/db/AGENTS.md.
  • apps/web/src/lib/user/index.test.ts - 0 issues. Real-DB coverage for the surviving-workspace installer-reference cleanup.
  • packages/db/src/schema.ts - 0 issues. Docstring-only change; previous last_installed_by_user_id finding here is resolved by the softDeleteUser update.

Fix these issues in Kilo Cloud

Previous Review Summaries (2 snapshots, latest commit ba47402)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit ba47402)

Status: 2 Issues Found | Recommendation: Address before merge

Executive Summary

The previously flagged unique-index drop is fully reverted — the migration is now purely additive and UQ_platform_integrations_slack_platform_inst is retained — leaving two low-severity items: last_installed_by_user_id is now populated on every install but still escapes softDeleteUser for org-owned workspaces, and the new workspace-record teardown has a check-then-act race with concurrent re-installs that is benign only while the metadata fallback exists.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 2
Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/db/src/schema.ts 4873 slack_workspace_installations.last_installed_by_user_id is now written on every OAuth install (slack-callback.ts passes user.id), but softDeleteUser only deletes workspace records for workspaces the user owned; for org-owned workspaces the record survives and the column keeps referencing the anonymized user (the set null FK never fires under soft-delete). Carried forward — exposure is now live
apps/web/src/lib/integrations/slack-service.ts 376 deleteWorkspaceInstallationIfUnused is check-then-act; a concurrent re-install (which upserts the workspace record before inserting the integration row) can have its freshly written record deleted — benign while getSlackBotToken's metadata.access_token fallback exists, a real gap once the follow-up removes it
Files Reviewed (12 files)
  • packages/db/src/migrations/0204_greedy_blue_blade.sql - 0 issues. Purely additive: new table, FK, unique index on team_id, and a deterministic backfill (DISTINCT ON ... ORDER BY updated_at DESC, ON CONFLICT DO NOTHING, detached-0108 rows excluded, NULL tokens filtered). Index builds on a brand-new empty table, so no lock concern. Previous WARNING on the dropped UQ_platform_integrations_slack_platform_inst is resolved — the index is retained.
  • packages/db/src/schema.ts - 1 issue (carried). New slack_workspace_installations table matches the generated DDL; the Slack unique-index definitions are byte-identical to main.
  • apps/web/src/lib/integrations/slack-workspace-installation.ts - 0 issues. Workspace-first read with metadata fallback covers the rolling-deploy window; team-id resolution handles detached 0108 rows.
  • apps/web/src/lib/integrations/slack-service.ts - 1 issue. Workspace record written before the integration row; suspension cleared on re-install; lifecycle teardown covered in uninstallApp, deleteInstallationByTeamId, and removeDbRowOnly.
  • apps/web/src/lib/integrations/oauth/platforms/slack-callback.ts - 0 issues. Passes the acting user as installedByUserId.
  • apps/web/src/lib/bot/platforms/slack.tsx - 0 issues. Switches to async getSlackBotToken; no remaining callers of the removed getAccessTokenFromInstallation.
  • apps/web/src/lib/user/index.ts - 0 issues. softDeleteUser deletes workspace records for user-owned workspaces before the integration rows (order is correct, team IDs would otherwise be lost).
  • apps/web/src/lib/integrations/slack-service.test.ts - 0 issues. TDZ-safe lazy mock forwarding; covers ordering, suspension-clearing, and teardown paths.
  • apps/web/src/lib/integrations/slack-workspace-installation.test.ts - 0 issues.
  • apps/web/src/lib/user/index.test.ts - 0 issues. Real-DB coverage that user deletion removes only that user's workspace records.
  • packages/db/src/migrations/meta/0204_snapshot.json - generated; consistency checked only.
  • packages/db/src/migrations/meta/_journal.json - generated; idx 204 retag consistent with the renamed migration.

Fix these issues in Kilo Cloud

Previous review (commit b52235a)

Status: 2 Issues Found | Recommendation: Address before merge

Executive Summary

Dropping UQ_platform_integrations_slack_platform_inst in this phase removes the only atomic backstop for the app-layer Slack workspace-conflict check, so concurrent installs by two owners can now both succeed and leave the workspace in the invalid state the code still rejects.

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
packages/db/src/migrations/0204_serious_karma.sql 14 Dropping the global Slack unique index removes the DB backstop for the check-then-insert in upsertSlackInstallation; concurrent cross-owner installs both succeed, isSlackWorkspaceUniqueViolation becomes dead code, both owners are then blocked from re-install, and getInstallationByTeamId routes events nondeterministically

SUGGESTION

File Line Issue
packages/db/src/schema.ts 4874 slack_workspace_installations.last_installed_by_user_id is a user reference not covered by softDeleteUser (FK set null never fires because soft-delete anonymizes rather than deletes the user row); no live exposure yet since nothing writes the column
Files Reviewed (4 files)
  • packages/db/src/migrations/0204_serious_karma.sql - 1 issue. DDL ordering correct (DROP before CREATEs), backfill correctly uses DISTINCT ON ... ORDER BY updated_at DESC with the exact --> statement-breakpoint marker, column mappings verified against upsertSlackInstallation writes, and prod-data prechecks in the PR description cover the new-index build. Non-concurrent CREATE UNIQUE INDEX on the 37k-row platform_integrations table was explicitly considered by the author (≈1–2s write block vs. losing atomicity / hand-editing generated DDL); accepted as a reasoned tradeoff, not flagged.
  • packages/db/src/schema.ts - 1 issue. New per-owner partial uniques and slack_workspace_installations table match the generated DDL; doc comments are accurate.
  • packages/db/src/migrations/meta/0204_snapshot.json - generated snapshot; reviewed for consistency only.
  • packages/db/src/migrations/meta/_journal.json - generated journal entry (idx 204, breakpoints: true); consistent with the migration file.

Fix these issues in Kilo Cloud


Reviewed by kimi-k3 · Input: 116K · Output: 18.8K · Cached: 528K

Review guidance: REVIEW.md from base branch main

Slack issues exactly one installation, and therefore one bot token, per
(app, workspace). The token was stored on each owner's
`platform_integrations.metadata.access_token`, which ties a workspace-level
credential to a per-owner row.

Add `slack_workspace_installations`, keyed on the Slack team ID, and make it
the read path for the bot token. Every completed OAuth install writes it, so
the record stays current. `metadata.access_token` and `metadata.bot_user_id`
are still mirrored and the read falls back to them, which keeps a rolling
deploy and a rollback safe; a follow-up removes both.

Lifecycle is handled everywhere a Slack integration can go away:
disconnecting, Slack's workspace-wide `app_uninstalled` event, the dev-only
row removal, and `softDeleteUser`. The workspace record is removed once no
integration references the workspace, so a stored token cannot outlive the
integrations that used it.

Also clear `suspended_at` and `suspended_by` when an install activates an
integration. They were left set, so rows suspended by
0108_wise_psylocke kept stale suspension metadata after reconnecting.

The migration is purely additive: it creates the table and seeds it from the
existing per-integration tokens. No index or column is changed, and
`UQ_platform_integrations_slack_platform_inst` still limits a workspace to
one owner. Allowing several organizations per workspace needs message
routing to tell owners apart and is left to a follow-up.
@RSO
RSO force-pushed the multi-org-slack branch from b52235a to ba47402 Compare August 4, 2026 12:48
@RSO RSO changed the title feat(db): allow multiple organizations per Slack workspace refactor(slack): store the workspace bot token on the workspace Aug 4, 2026
Comment thread apps/web/src/lib/integrations/slack-service.ts Outdated
RSO added 4 commits August 4, 2026 15:20
…paces

An owner holds at most one Slack integration, so installing a different
workspace repoints that row's `platform_installation_id`. Nothing then
referenced the previous workspace, so its `slack_workspace_installations` row
and bot token were left behind with no path to ever remove them: uninstall,
`app_uninstalled`, and soft-delete cleanup all resolve the record from an
integration.

Capture the previous team ID and, once the row has moved, delete the old
workspace record if nothing references it.

Apply the same guard when persisting the integration fails after the
workspace record was written, which would otherwise orphan the new record.
Both paths go through the unreferenced check, so a workspace another owner
legitimately claimed in the meantime is left alone.
Reading the workspace record first is unsafe until the previous release is
gone. That release writes tokens only to
`platform_integrations.metadata.access_token`: a disconnect there deletes the
integration row without touching the workspace record, and the following
reconnect recreates the row with a fresh token in metadata only. The workspace
record is left holding the revoked token, and reading it first would make this
release keep using that revoked token for auth checks, permalinks and direct
sends until someone reinstalls.

Every writer updates metadata, so metadata is never staler than the workspace
record while the mirror exists; the reverse does not hold. Read metadata first
and use the workspace record as the fallback. The follow-up that removes the
mirror removes this preference with it, which is what leaves the workspace
record as the only source.

Also avoids a query on the common path, since metadata is populated for every
currently connected workspace.
`last_installed_by_user_id` is recorded on every install, including installs
made for an organization. Those workspace records outlive the installer's
account, so they kept pointing at a soft-deleted user. The `set null` foreign
key does not cover this: soft deletion retains the `kilocode_users` row, so the
reference is never cleared.

Null the column on the rows that survive, matching how the surrounding cleanup
already handles actor references such as `dismissed_by_user_id`.
Comment thread apps/web/src/lib/integrations/slack-service.ts Outdated
Two review points, with one root cause: the install wrote
`slack_workspace_installations` and `platform_integrations` as separate
commits, so the code had to compensate for a half-applied install.

Wrap both writes in a transaction. A failed integration write now rolls the
workspace record back on its own, so the compensating delete in the catch block
goes away. That delete was awaited before the caught error was inspected, so a
transient failure in it masked the original error, including the
`SlackWorkspaceAlreadyConnectedError` translation the
`workspace_already_connected` redirect depends on.

The teardown guard was also check-then-act. Making it atomic on its own was not
enough: an install committed the workspace record before the integration row, so
a concurrent teardown could observe neither and delete a record that was about
to be referenced. With the install now atomic, fold the reference check into the
delete statement. Either it sees the integration row and keeps the record, or
the install has not committed and there is no record to remove.

`countSlackConnections` is gone; the reference check is no longer a separate
query. Covers both with real-database tests, including that a failed integration
write leaves no workspace record behind.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant