Skip to content

feat: add organization scope support with clerk integration#2863

Open
lancy wants to merge 14 commits intomainfrom
feature/issue-2792-clerk-org-scope-v2
Open

feat: add organization scope support with clerk integration#2863
lancy wants to merge 14 commits intomainfrom
feature/issue-2792-clerk-org-scope-v2

Conversation

@lancy
Copy link
Contributor

@lancy lancy commented Feb 11, 2026

Summary

Test plan

  • Web API integration tests (7 test files, 23 tests)
  • CLI command tests (7 test files, 17 tests)
  • E2E BATS test for scope switching
  • All lint, type-check, and knip pass

Note: Org creation E2E tests removed because E2E test user cannot create
Clerk organizations (403 Forbidden). Org flows are covered by integration tests.

🤖 Generated with Claude Code

@lancy lancy force-pushed the feature/issue-2792-clerk-org-scope-v2 branch 2 times, most recently from f590575 to 3be1b07 Compare February 11, 2026 12:12
@github-actions github-actions bot temporarily deployed to platform/preview/feature/issue-2792-clerk-org-scope-v2 February 11, 2026 12:13 Destroyed
@github-actions github-actions bot temporarily deployed to neon/preview/pr-2863 February 11, 2026 12:13 Destroyed
@github-actions github-actions bot temporarily deployed to web/preview/feature/issue-2792-clerk-org-scope-v2 February 11, 2026 12:14 Destroyed
@lancy
Copy link
Contributor Author

lancy commented Feb 12, 2026

Code Review: PR #2863

feat: add organization scope support with clerk integration

Summary

This PR introduces organization scope support integrated with Clerk, enabling users to create organizations, invite/remove members, switch between personal and org scopes, and manage org-scoped access tokens. The architecture cleanly separates concerns across CLI commands, Web API routes, contracts, and services.

Architecture Assessment

The design is solid overall:

  • Token model: Short-lived vm0_org_* tokens (2h TTL) stored in org_access_tokens table, generated after Clerk membership verification at scope use time. This is a good security pattern -- it avoids storing long-lived org credentials while leveraging Clerk as the source of truth for membership.
  • Scope resolution: resolveScope() acts as a single entry point that checks for org tokens in the Authorization header before falling back to personal scope. Clean replacement for the previous getUserScopeByClerkId() calls across ~15 route files.
  • Token revocation: Immediate revocation when a member is removed or leaves -- not just expiry-based. This is the right approach for security.
  • Contract-first design: New orgContract and scopeListContract/scopeUseContract in @vm0/core properly define the API surface.

Key Findings

High Priority (P1)

  1. clearOrgToken bypasses saveConfig abstraction (turbo/apps/cli/src/lib/api/config.ts:96-105)

    • clearOrgToken() manually builds the config dir path, calls mkdir and writeFile directly, duplicating logic from saveConfig(). If saveConfig semantics ever change (e.g., different merge strategy, encryption), this function would silently diverge.
    • Consider: load config, delete the three keys, then call a "write full config" helper (or refactor saveConfig to support key deletion).
  2. N+1 Clerk API calls in getOrganizationStatus (turbo/apps/web/src/lib/org/org-service.ts:108-132)

    • For each membership, a separate client.users.getUser(userId) call is made to resolve emails. With N members, this is N+1 API calls to Clerk. For small orgs this is fine, but it could become a bottleneck.
    • Consider: batch the user IDs and use client.users.getUserList({ userId: [...ids] }) to resolve all emails in a single call.
  3. resolveOrgAccessToken non-blocking update has a silent .catch() (turbo/apps/web/src/lib/org/org-token-service.ts:69-74)

    • The lastUsedAt update uses a fire-and-forget pattern with .catch(). The comment says "same pattern as cli_tokens" which is good for consistency. However, the promise is not awaited and not assigned to a variable, so it may trigger unhandled rejection warnings in certain runtimes. Consider adding void prefix to make the intent explicit and suppress linter warnings: void globalThis.services.db.update(...)....
  4. Org route handler has stub handlers that return 404 (turbo/apps/web/app/api/org/route.ts:83-99)

    • The status, leave, invite, removeMember handlers in the ts-rest router return 404 stubs with messages like "Use /api/org/status". This is because the actual implementations are in separate route files. This works due to Next.js routing precedence, but it's a potential source of confusion. A comment explaining why would help future maintainers.

Medium Priority (P2)

  1. Non-null assertions (scope!) (turbo/apps/web/src/lib/org/org-service.ts:60-70)

    • After .insert().returning(), the result is accessed via scope!. While this should always succeed if the DB insert doesn't throw, a defensive check or destructuring pattern would be cleaner: const [scope] = await ... .returning(); if (!scope) throw new Error(...).
  2. getActiveToken reads config twice when VM0_TOKEN is not set (turbo/apps/cli/src/lib/api/config.ts:61-76)

    • Not a bug, but loadConfig() does file I/O each time. If getActiveToken is called frequently in a single CLI command, this could be a minor performance concern. The existing getToken has the same pattern, so this is consistent -- just noting it.
  3. Empty afterEach(() => {}) blocks in all CLI test files

    • Multiple test files have empty afterEach callbacks. These should be removed per YAGNI principles. They add no value and just add noise.
  4. createOrganization returns a token but it's not used in the API response (turbo/apps/web/app/api/org/route.ts)

    • createOrganization() generates an org access token, but the POST /api/org route response only returns slug, role, members, createdAt -- it does not return the token. The CLI's org create command then makes a separate scope use call to get a token. The token generated during creation is essentially wasted. Consider either removing token generation from createOrganization or returning it in the API response.

Low Priority (P3)

  1. Duplicated org-token validation pattern across route handlers

    • The pattern of extracting Bearer token, checking vm0_org_ prefix, calling resolveOrgAccessToken, and returning 401/403 is repeated identically in invite/route.ts, leave/route.ts, members/route.ts, and status/route.ts. Consider extracting a requireOrgAuth(request) helper to reduce duplication.
  2. createOrganization one-org-per-user check uses ownerId (turbo/apps/web/src/lib/org/org-service.ts:21-31)

    • The check queries scopes.ownerId == clerkUserId && type == "organization". This correctly prevents a user from creating multiple orgs, but note that a user can be a member of multiple orgs (just not an owner of multiple). The naming "You already have an organization" could be confusing for users who are members of other orgs. Consider: "You already own an organization".

Test Coverage Assessment

Test coverage is comprehensive:

  • Web API: 7 test files with 23 tests covering all routes (create, status, invite, leave, members, scope list, scope use)
  • CLI: 7 test files with 17 tests covering all commands
  • E2E: 1 BATS test for scope switching (happy path)
  • Good coverage of edge cases: auth required, org token required, admin-only operations, duplicate org prevention, token revocation after member removal

The test approach correctly uses MSW for CLI tests and real database + mocked Clerk for web API integration tests, which aligns with project testing guidelines.

Verdict

LGTM with suggestions -- The architecture is well-designed, security considerations are properly handled (short-lived tokens, membership verification, instant revocation), and test coverage is thorough. The P1 items are worth addressing before merge but none are blockers. All CI checks pass.


Reviewed by Claude Code

@lancy lancy force-pushed the feature/issue-2792-clerk-org-scope-v2 branch from 3be1b07 to 9eabe91 Compare February 12, 2026 02:30
@github-actions github-actions bot temporarily deployed to platform/preview/feature/issue-2792-clerk-org-scope-v2 February 12, 2026 02:31 Destroyed
@github-actions github-actions bot temporarily deployed to neon/preview/pr-2863 February 12, 2026 02:31 Destroyed
@github-actions github-actions bot temporarily deployed to web/preview/feature/issue-2792-clerk-org-scope-v2 February 12, 2026 02:31 Destroyed
@github-actions github-actions bot temporarily deployed to platform/preview/feature/issue-2792-clerk-org-scope-v2 February 12, 2026 02:45 Destroyed
@github-actions github-actions bot temporarily deployed to neon/preview/pr-2863 February 12, 2026 02:45 Destroyed
@github-actions github-actions bot temporarily deployed to web/preview/feature/issue-2792-clerk-org-scope-v2 February 12, 2026 02:45 Destroyed
lancy and others added 10 commits February 12, 2026 05:45
Implement multi-tenant organization scoping using Clerk Organizations
and a short-lived org access token (vm0_org_*) architecture.

- add org_access_tokens table and DB migration (0080)
- add scope.type column (personal/organization) to scope schema
- create ts-rest contracts for org and scope-list APIs
- implement org-service (create, status, invite, remove, leave)
- implement org-token-service (generate, resolve, revoke)
- add resolve-scope helper for extracting scopeId from tokens
- update all 19 API routes to use scope-aware authorization
- add 7 new API endpoints (org CRUD, scope list/use)
- update CLI to use getActiveToken() with org token priority
- add CLI commands: scope list, scope use, scope org (create/status/invite/remove/leave)
- add 12 integration tests covering full org lifecycle

Closes #2792

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace monolithic org-lifecycle.test.ts with per-endpoint test files:
- 7 web API tests at app/api/org/__tests__/ and app/api/scope/__tests__/
- 7 CLI command tests at commands/scope/__tests__/ and commands/scope/org/__tests__/
- 1 E2E BATS test at e2e/tests/01-serial/ser-t04-vm0-org-scope.bats
- Shared setupClerkOrgMock helper at src/__tests__/org-test-helpers.ts

Fixes AP-1 (assert behavior not mock calls) and AP-4 (no internal imports)
violations. Token revocation now tested through the API instead of
importing resolveOrgAccessToken directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The getUserAccessibleScopes service requires
client.users.getOrganizationMembershipList which is only available
when setupClerkOrgMock is called.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The org create/status commands require Clerk organization support
which is not available in the E2E preview environment. Org scope
behavior is fully covered by integration tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The org create E2E test fails because Clerk Organizations is not yet
enabled in the preview environment. This test should remain to surface
the gap — the fix is to enable Organizations in the Clerk dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of re-throwing unhandled Clerk API errors (which results in
a generic 500), log the error and return it as a structured 500 response
so the CLI can display the actual error message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Organization creation requires real Clerk Organizations API calls which
the E2E test user cannot perform (returns 403 Forbidden). Keep only
scope use --personal test. Org flows are covered by integration tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix clearOrgToken to use getConfigDir/getConfigFile instead of
  duplicating path logic (P1-1)
- Batch Clerk user lookups in getOrganizationStatus to avoid N+1
  API calls (P1-2)
- Add void prefix to fire-and-forget promise in
  resolveOrgAccessToken (P1-3)
- Add comment explaining stub handlers in org route (P1-4)
- Replace non-null assertion with defensive check in
  createOrganization (P2-5)
- Remove empty afterEach blocks from CLI test files (P2-7)
- Remove unused token generation from createOrganization (P2-8)
- Fix wording: "already have" → "already own" (P2-10)
- Extract requireOrgAuth helper to reduce duplication across org
  route handlers (P3-9)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add transparent auto-refresh for expired org tokens: when getActiveToken()
detects an expired org token, it re-calls /api/scope/use with the user's
base token to get a fresh org token, instead of silently falling back to
the personal scope.

Update GET and PUT /api/scope to use resolveScope() so that `vm0 scope set`
and `vm0 scope status` operate on the active scope (org or personal) based
on the auth token type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@lancy lancy force-pushed the feature/issue-2792-clerk-org-scope-v2 branch from 922f14b to 8ebe197 Compare February 12, 2026 05:45
@github-actions github-actions bot temporarily deployed to platform/preview/feature/issue-2792-clerk-org-scope-v2 February 12, 2026 05:46 Destroyed
@github-actions github-actions bot temporarily deployed to neon/preview/pr-2863 February 12, 2026 05:46 Destroyed
@github-actions github-actions bot temporarily deployed to web/preview/feature/issue-2792-clerk-org-scope-v2 February 12, 2026 05:47 Destroyed
@github-actions github-actions bot temporarily deployed to neon/preview/pr-2863 February 12, 2026 06:37 Destroyed
@github-actions github-actions bot temporarily deployed to platform/preview/feature/issue-2792-clerk-org-scope-v2 February 12, 2026 06:37 Destroyed
@github-actions github-actions bot temporarily deployed to web/preview/feature/issue-2792-clerk-org-scope-v2 February 12, 2026 06:38 Destroyed
lancy and others added 3 commits February 12, 2026 10:44
Hide org-related CLI commands (list, use, org) behind
VM0_EXPERIMENTAL_ORG_SCOPE=1 environment variable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Resolve merge conflicts:
- scope.ts: combine clerkOrgId and notifyEmail/notifySlack fields
- db.ts: include both orgAccessToken and email schemas
- _journal.json: renumber org_access_tokens migration to 0088
- agents/route.ts: use resolveScope with email-shared agents
- runs/route.ts: adopt main's refactored resolveComposeVersion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions github-actions bot temporarily deployed to platform/preview/feature/issue-2792-clerk-org-scope-v2 February 12, 2026 10:56 Destroyed
@github-actions github-actions bot temporarily deployed to docs/preview/feature/issue-2792-clerk-org-scope-v2 February 12, 2026 10:56 Destroyed
@github-actions github-actions bot temporarily deployed to neon/preview/pr-2863 February 12, 2026 10:56 Destroyed
@github-actions github-actions bot temporarily deployed to web/preview/feature/issue-2792-clerk-org-scope-v2 February 12, 2026 10:57 Destroyed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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

Comments