Skip to content

Workspace connections: Google Drive, Slack, and GitHub OAuth per workspace - #363

Merged
Deodat-Lawson merged 2 commits into
mainfrom
claude/oauth-integration-scope-c3b71b
Aug 30, 2026
Merged

Workspace connections: Google Drive, Slack, and GitHub OAuth per workspace#363
Deodat-Lawson merged 2 commits into
mainfrom
claude/oauth-integration-scope-c3b71b

Conversation

@Deodat-Lawson

@Deodat-Lawson Deodat-Lawson commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds workspace connections for Google Drive, Slack, and GitHub: an admin connects an account under Add source → Connect, and the grant is stored per workspace (encrypted, attributed to the granting member) — never per user, because users are multi-workspace, everything these connections feed is company-owned, and syncs run in the worker with no session. Design doc: Workspace Connections.
  • Google Drive ships end-to-end (harvested from the pre-ADR-008 feature/google-drive-connector worktree onto the new layout): Picker-scoped drive.file selection, changes-feed dirty-checks, and a */15 worker cron that syncs picked files into the normal ingestion path. Slack installs the deployment's app into a Slack workspace for the collab mirror; GitHub stores a workspace token used by the repo explainer and repo upload.
  • One shared connector_connections table (unique on company + provider + provider account, AES-256-GCM tokens via the existing secret-box) plus Drive satellites for sync state and picked items — migration 20260829200858_workspace_connections.

Related

Implements the four confirmed design decisions: hand-rolled OAuth (no Nango — deliberately reverses the intent noted in pipelines/src/connectors/README.md, per the self-host direction), multi-account-per-workspace uniqueness, management-role-only connect (agent-knowledge precedent), and harvest-not-merge for the old Drive worktree.

Checklist

  • pnpm check passes (lint + typecheck) — 0 errors; the 62 pre-existing design-token warnings are untouched (none in changed files)
  • pnpm --filter @launchstack/web test passes — 2,190 tests (103 in the connector suites, including the ported Drive sync behavioral suite)
  • Changeset added (@launchstack/pipelines minor: new connectors/google-drive subpath; KnowledgeItem.content widened to string | Uint8Array)
  • New env vars documented in .env.example ("Workspace connections" block) and apps/web/src/env.ts (GOOGLE_DRIVE_CLIENT_ID/SECRET, SLACK_CLIENT_ID/SECRET, GITHUB_OAUTH_CLIENT_ID/SECRET, NEXT_PUBLIC_GOOGLE_API_KEY/APP_ID — all optional; unconfigured providers render "not configured" and nothing changes for existing deployments)
  • UI changes exercised in a browser — not done: a live OAuth round-trip needs registered provider apps; this is the main manual verification left
  • Docs updated (.env.example setup steps per provider; module docblocks state the scoping model)

Testing

  • pnpm --filter @launchstack/{pipelines,web,worker} typecheck; full web Jest suite; pipelines vitest (17)
  • Provider wire shapes tested against injected fetches (Google refresh/invalid_grant, Slack ok:false-despite-200, GitHub error-despite-200 + two-call exchange + 204/404 revoke); HMAC state round-trip/tamper/TTL/unknown-provider
  • next build with the CI dummy Clerk key (verifies the [provider] route tree coexists with the static agent-knowledge/connections segments); check-package-exports (141 subpaths); schemas:check (22 contracts)
  • The migration was generated by drizzle-kit but has not been applied to a database in this environment

Notes for reviewers

  • Callback has no middleware allowlist entry — deliberately. It is session-gated: the Clerk cookie rides along on the provider's top-level SameSite=Lax redirect, and the handler additionally requires the HMAC state, the nonce cookie, and that the signed-in user matches the state payload.
  • Drive routes live under the dynamic segment ([provider]/items, /sync, …) because a static google-drive/ directory would shadow the whole [provider] tree, 404-ing oauth/start.
  • Consumers keep their fallbacks: pasted GitHub token → workspace connection → GITHUB_TOKEN; workspace Slack connection → SLACK_BOT_TOKEN. Nothing breaks for env-configured deployments.
  • GitHub repo scope is read-write by GitHub's design (OAuth apps have no read-only repo scope); a GitHub App installation is the documented upgrade path.
  • The Drive panel manages the workspace's primary (oldest) connection; the schema supports several accounts per provider, and extras appear under Settings → Integrations.
  • Follow-ups deferred: Slack digest delivery for founder-weekly-review (needs a default-channel picker), suspend-on-grantor-departure enforcement at sync time, pipelines/dist being git-tracked (pre-existing; gitignore only covers packages/*/dist/).

🤖 Generated with Claude Code


Note

High Risk
Introduces OAuth token storage, encryption, and provider-specific grants (including GitHub repo) that affect knowledge sync, uploads, and Slack collab fallbacks.

Overview
Adds per-workspace OAuth connections (Google Drive, Slack, GitHub) with encrypted token storage, documented optional env setup, and schema support for cached access tokens plus Drive-specific picked items and sync state tables.

Ships @launchstack/pipelines/connectors/google-drive: Picker-scoped sync into the KnowledgeSink contract, MIME export/download rules, and changes-feed optimizations (covered by new unit tests). The shared connector contract widens KnowledgeItem.content to string | Uint8Array with helpers so binary exports can flow through ingestion.

GitHub repo upload (and related flows) can use the workspace connection when no pasted token is supplied; security tests mock getCompanyAccessToken so behavior stays unchanged when nothing is connected. Adds hand-rolled OAuth tests for GitHub/Slack wire formats and HMAC-signed OAuth state (tamper, TTL, provider allowlist).

Existing Drive-linked document pull tests are updated for the expanded ConnectorConnection shape (accessTokenCiphertext / accessTokenExpiresAt).

Reviewed by Cursor Bugbot for commit 79f4749. Bugbot is set up for automated code reviews on this repo. Configure here.

…er workspace

Connections are workspace-scoped and user-attributed: one encrypted
connector_connections row per (company, provider, provider account), granted
by a management-role member, never keyed on the user alone — users are
multi-workspace, everything these connections feed is company-owned, and
syncs run in the worker with no session.

The layer: HMAC-signed OAuth state (HKDF from EMBEDDING_SECRETS_KEY) with a
per-provider nonce cookie; pure fetch provider clients for Google, Slack,
and GitHub; a connection store with AES-256-GCM tokens via secret-box and
lazy refresh that marks dead grants revoked. Routes:
/api/connectors/[provider]/oauth/{start,callback} (the callback is
session-gated — the Clerk cookie rides the SameSite=Lax redirect, so no
middleware allowlist entry), GET /api/connectors, and DELETE
/api/connectors/connections/[id] with best-effort provider-side revocation.

Google Drive ships whole, harvested from the pre-ADR-008 worktree onto the
new layout: the Drive client in pipelines/src/connectors/google-drive
(KnowledgeItem.content widened to string | Uint8Array for binary exports),
host sink + lease-guarded sync service in apps/web, Drive-only
[provider]/{status,items,picker-token,sync} routes, a googleDriveSyncJob +
*/15 cron in the worker, and the Picker-driven DriveConnectPanel.

Consumers wired with fallbacks preserved: repo-explainer and github-repo
upload resolve pasted token → workspace connection → GITHUB_TOKEN; the
collab Slack bridge prefers the workspace connection over SLACK_BOT_TOKEN.

New optional env pairs per provider (documented in .env.example under
"Workspace connections"); with none set, every Connect button renders "not
configured" and existing deployments are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
launch-stack Error Error Aug 29, 2026 9:14pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
pdr-ai-v2 Ignored Ignored Aug 29, 2026 9:14pm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 967e3eac6e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +110 to +113
await releaseSyncLease(connectionId, {
status: "ok",
startPageToken: report.nextStartPageToken,
report: reportCounts(report),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retain the cursor when any item fails to store

When syncGoogleDrive reports an item-level storage failure, this still records the run as successful and advances startPageToken. If no later Drive change occurs, subsequent scheduled and manual non-forced syncs short-circuit on the clean changes feed, so the failed document is never retried. Only persist the new cursor after all discovered items have been handled successfully, or retain explicit retry state for failed source IDs.

Useful? React with 👍 / 👎.

Comment on lines +99 to +103
if (provider === "google-drive") {
// Seed the changes-feed cursor now so the first sync's dirty-check
// starts from the moment of connection, not from a fabricated past.
const client = createDriveClient({ accessToken: grant.accessToken });
await ensureSyncState(connection.id, await client.getStartPageToken());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Force reconciliation before reseeding a reauthorized cursor

When an existing Google connection is reauthorized after an outage, this overwrites its saved changes cursor with a token representing the current moment. Changes made to picked files while access was revoked precede that token, and the next cron or manual sync can therefore see an empty feed and skip discovery indefinitely. Preserve the previous cursor where usable, or run a forced full discovery before replacing it.

Useful? React with 👍 / 👎.

Comment on lines +200 to +202
const provider = connection.provider as ConnectorProvider;
const config = getConnectorConfig(provider);
if (!config) throw new Error(`The ${provider} connector is not configured`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow background token refresh without APP_PUBLIC_URL

For deployments that omit optional APP_PUBLIC_URL, the web OAuth routes still advertise the provider as configured and successfully use the request origin, but this background refresh calls getConnectorConfig without an origin and receives null. Google Drive consequently stops syncing once its initial access token expires, typically about an hour after connection. Either make refresh configuration independent of the redirect URI or require/provide a stable origin consistently.

Useful? React with 👍 / 👎.

Comment on lines +51 to +53
grantedByUserPk: bigint("granted_by_user_pk", { mode: "number" })
.notNull()
.references(() => users.id),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve employee removal when connector records exist

The new default NO ACTION user foreign keys cause /api/removeEmployees to fail when the target employee granted a connection; the analogous addedByUserPk reference also blocks removal for anyone who picked a Drive item. This turns a normal workspace-administration operation into a foreign-key error for connector users. Define an explicit departure policy such as nulling attribution, cascading the dependent records, or handling them before deleting the user.

Useful? React with 👍 / 👎.

Comment thread pipelines/package.json
Comment on lines 40 to +42
"./connectors": "./src/connectors/index.ts",
"./connectors/agent-knowledge": "./src/connectors/agent-knowledge/index.ts",
"./connectors/google-drive": "./src/connectors/google-drive/index.ts",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Export the Google Drive subpath in published packages

Adding the source-time export here does not expose it in the published package because publishConfig.exports replaces the package export map and still contains only ./connectors and ./connectors/agent-knowledge. After this changeset is released, consumers importing the advertised @launchstack/pipelines/connectors/google-drive entry point will receive a package-subpath-not-exported error. Add the corresponding dist/connectors/google-drive entry under publishConfig.exports.

Useful? React with 👍 / 👎.

status: "ok",
startPageToken: report.nextStartPageToken,
report: reportCounts(report),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incomplete sync advances changes cursor

Medium Severity

A sync that hits the 2000-item cap or records per-file failures still persists nextStartPageToken as success. The next incremental run then skips those files unless they change again, so truncated and transiently failed items are dropped from ingestion.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 967e3ea. Configure here.

Comment thread apps/web/src/server/collab/slack.ts
…migration

Main's #364 independently implemented the same workspace-connection design
(connector_connections: workspace-scoped, user-attributed, unique on company
+ provider + account) for Drive-linked files, and #361 replaced Clerk with
better-auth. Resolution adopts main as the foundation and rebases this
branch's layer on top:

- Schema: main's connector_connections shape wins (number ids,
  providerAccountEmail, grantedByUserId set-null, lastRefreshError). This
  branch's competing CREATE migration is dropped and regenerated as the
  additive 20260829210328_workspace_connections_tokens: two Drive knowledge-
  sync satellites, nullable refresh_token_ciphertext, and persisted
  access-token columns — Slack/GitHub tokens have no refresh token, so the
  access token IS the credential there (main's Google service gets a null
  guard).
- Google OAuth: one flow, main's /api/connectors/google/oauth/* and
  @launchstack/google-drive. This branch's google provider module and its
  generic-route handling are deleted; the generic layer now serves Slack and
  GitHub only. The connect gate splits off isDriveLinkingEnabled(): the
  OAuth pair alone permits connecting (the knowledge sync works without
  GOOGLE_DOCS_EDITING_ENABLED); Drive-links routes keep the stricter gate.
  GOOGLE_DRIVE_CLIENT_ID/SECRET env vars are dropped for the shared
  GOOGLE_OAUTH_* pair.
- Drive knowledge sync now rides main's connection: token via
  getAccessTokenForConnection (its in-process cache and invalid_grant
  handling), cursor seeding moved from the callback into the sync's lazy
  ensureSyncState (a fresh connection just runs a full first sync).
- Return leg unified on ?connector=&result= (main set ?googleDrive= but
  nothing consumed it); IntegrationsPanel keeps both sections (main's
  Drive-linking card + the generic connections list).
- Fixed in passing: main's oauth/start route exported OAUTH_STATE_COOKIE,
  which Next's typed-routes check rejects ("not a valid Route export field")
  and which fails next build; the constant moved to the Drive config module.

Verified post-merge: web/worker/pipelines typecheck, full web Jest suite
(2,197), connector + googleDrive suites, @launchstack/google-drive vitest,
next build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Deodat-Lawson

Copy link
Copy Markdown
Owner Author

Merged origin/main (79f4749) — this was a semantic merge, not just textual: #364 had independently built the same workspace-connection design for Drive-linked files, and #361 replaced Clerk with better-auth. How it was resolved:

  • One connector_connections table, main's shape. This branch's competing CREATE migration was dropped and regenerated as additive (20260829210328_workspace_connections_tokens): the two Drive knowledge-sync satellite tables, refresh_token_ciphertext made nullable, and persisted access_token_ciphertext/access_token_expires_at — Slack/GitHub tokens have no refresh token, so the access token is the credential there. Main's Google service got a null guard for the relaxed column.
  • One Google OAuth flow — main's (/api/connectors/google/oauth/*, @launchstack/google-drive, GOOGLE_OAUTH_CLIENT_ID/SECRET). This branch's Google provider module and env pair were deleted; the generic OAuth layer now serves Slack and GitHub only. The connect gate was split off isDriveLinkingEnabled(): the OAuth pair alone permits connecting (the knowledge sync works without GOOGLE_DOCS_EDITING_ENABLED), while the Drive-links routes and reconciler keep the stricter flag.
  • The Drive knowledge sync rides main's connection: tokens via getAccessTokenForConnection, and cursor seeding moved into the sync's lazy ensureSyncState (a fresh connection simply runs a full first sync).
  • Return leg unified on ?connector=&result= — main's callback set ?googleDrive= but nothing consumed it; now every provider's callback lands on the same WorkspaceShell toast handler. Settings → Integrations keeps both sections (Drive-linking card + generic connections list).
  • Fixed in passing: google/oauth/start exported OAUTH_STATE_COOKIE, which Next's typed-routes check rejects as an invalid route export and which fails next build (worth confirming whether main is currently red on this). The constant moved to the Drive config module.

Post-merge verification: web/worker/pipelines typecheck, full web Jest suite (2,197 tests), connector + googleDrive suites, @launchstack/google-drive vitest, and next build — all green.

🤖 Generated with Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 79f4749. Configure here.

accessLost: [],
truncated: false,
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New picks skipped by dirty-check

High Severity

Once a startPageToken exists, sync treats an empty Drive changes feed as “nothing to do” and never re-walks the picked-item list. Picking more files, or clicking Sync now, does not pass force, so newly selected files that have no recent Drive-side edits are never imported.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 79f4749. Configure here.

if (cached && cached.expiresAt > Date.now()) return cached.token;

getEngine();
if (!connection.refreshTokenCiphertext) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Active Drive connection pick is arbitrary

Medium Severity

getActiveGoogleConnection takes limit(1) with no orderBy, so which account Drive-linked files use is undefined when several Google accounts are connected. The knowledge-sync panel meanwhile treats the oldest row as primary.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 79f4749. Configure here.

@Deodat-Lawson
Deodat-Lawson merged commit 386ecf1 into main Aug 30, 2026
12 of 16 checks passed
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