Workspace connections: Google Drive, Slack, and GitHub OAuth per workspace - #363
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 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".
| await releaseSyncLease(connectionId, { | ||
| status: "ok", | ||
| startPageToken: report.nextStartPageToken, | ||
| report: reportCounts(report), |
There was a problem hiding this comment.
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 👍 / 👎.
| 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()); |
There was a problem hiding this comment.
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 👍 / 👎.
| const provider = connection.provider as ConnectorProvider; | ||
| const config = getConnectorConfig(provider); | ||
| if (!config) throw new Error(`The ${provider} connector is not configured`); |
There was a problem hiding this comment.
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 👍 / 👎.
| grantedByUserPk: bigint("granted_by_user_pk", { mode: "number" }) | ||
| .notNull() | ||
| .references(() => users.id), |
There was a problem hiding this comment.
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 👍 / 👎.
| "./connectors": "./src/connectors/index.ts", | ||
| "./connectors/agent-knowledge": "./src/connectors/agent-knowledge/index.ts", | ||
| "./connectors/google-drive": "./src/connectors/google-drive/index.ts", |
There was a problem hiding this comment.
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), | ||
| }); |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 967e3ea. Configure here.
…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>
|
Merged
Post-merge verification: web/worker/pipelines typecheck, full web Jest suite (2,197 tests), connector + googleDrive suites, 🤖 Generated with Claude Code |
There was a problem hiding this comment.
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).
❌ 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, | ||
| }); | ||
| } |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 79f4749. Configure here.
| if (cached && cached.expiresAt > Date.now()) return cached.token; | ||
|
|
||
| getEngine(); | ||
| if (!connection.refreshTokenCiphertext) { |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 79f4749. Configure here.


Summary
feature/google-drive-connectorworktree onto the new layout): Picker-scopeddrive.fileselection, changes-feed dirty-checks, and a*/15worker 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.connector_connectionstable (unique on company + provider + provider account, AES-256-GCM tokens via the existing secret-box) plus Drive satellites for sync state and picked items — migration20260829200858_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 checkpasses (lint + typecheck) — 0 errors; the 62 pre-existing design-token warnings are untouched (none in changed files)pnpm --filter @launchstack/web testpasses — 2,190 tests (103 in the connector suites, including the ported Drive sync behavioral suite)@launchstack/pipelinesminor: newconnectors/google-drivesubpath;KnowledgeItem.contentwidened tostring | Uint8Array).env.example("Workspace connections" block) andapps/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).env.examplesetup steps per provider; module docblocks state the scoping model)Testing
pnpm --filter @launchstack/{pipelines,web,worker} typecheck; full web Jest suite; pipelines vitest (17)ok:false-despite-200, GitHub error-despite-200 + two-call exchange + 204/404 revoke); HMAC state round-trip/tamper/TTL/unknown-providernext buildwith the CI dummy Clerk key (verifies the[provider]route tree coexists with the staticagent-knowledge/connectionssegments);check-package-exports(141 subpaths);schemas:check(22 contracts)Notes for reviewers
[provider]/items,/sync, …) because a staticgoogle-drive/directory would shadow the whole[provider]tree, 404-ingoauth/start.GITHUB_TOKEN; workspace Slack connection →SLACK_BOT_TOKEN. Nothing breaks for env-configured deployments.reposcope is read-write by GitHub's design (OAuth apps have no read-only repo scope); a GitHub App installation is the documented upgrade path.pipelines/distbeing git-tracked (pre-existing; gitignore only coverspackages/*/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 theKnowledgeSinkcontract, MIME export/download rules, and changes-feed optimizations (covered by new unit tests). The shared connector contract widensKnowledgeItem.contenttostring | Uint8Arraywith 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
getCompanyAccessTokenso 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
ConnectorConnectionshape (accessTokenCiphertext/accessTokenExpiresAt).Reviewed by Cursor Bugbot for commit 79f4749. Bugbot is set up for automated code reviews on this repo. Configure here.