-
Notifications
You must be signed in to change notification settings - Fork 0
feat(026): support multiple Claude API plan connections #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
studert
wants to merge
12
commits into
main
Choose a base branch
from
026-multiple-api-plans
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
adf817b
docs(026): add speckit artifacts for multiple API plan connections
studert 384750f
docs(026): add implementation tasks for multiple API plan connections
studert 87cf343
feat(026): add anthropic_plan_connections table and planConnectionId …
studert be07db8
feat(026): refactor API functions for multi-plan support
studert 3723196
feat(026): add plan connections management UI
studert 5ae156b
feat(026): multi-plan sync resolution and profile data
studert edf9eea
feat(026): show plan label on admin user cost view
studert 4d3cfe4
feat(026): multi-plan dashboard aggregation with plan filter
studert b370752
feat(026): per-plan sync triggers and error isolation
studert b0a7923
fix(026): make planConnectionId required in prepareUsageRow
studert 9258c7e
docs(026): mark all implementation tasks as completed
studert 1f43218
refactor(026): simplify review fixes
studert File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| # Specification Quality Checklist: Multiple Claude API Plan Connections | ||
|
|
||
| **Purpose**: Validate specification completeness and quality before proceeding to planning | ||
| **Created**: 2026-03-27 | ||
| **Feature**: [spec.md](../spec.md) | ||
|
|
||
| ## Content Quality | ||
|
|
||
| - [x] No implementation details (languages, frameworks, APIs) | ||
| - [x] Focused on user value and business needs | ||
| - [x] Written for non-technical stakeholders | ||
| - [x] All mandatory sections completed | ||
|
|
||
| ## Requirement Completeness | ||
|
|
||
| - [x] No [NEEDS CLARIFICATION] markers remain | ||
| - [x] Requirements are testable and unambiguous | ||
| - [x] Success criteria are measurable | ||
| - [x] Success criteria are technology-agnostic (no implementation details) | ||
| - [x] All acceptance scenarios are defined | ||
| - [x] Edge cases are identified | ||
| - [x] Scope is clearly bounded | ||
| - [x] Dependencies and assumptions identified | ||
|
|
||
| ## Feature Readiness | ||
|
|
||
| - [x] All functional requirements have clear acceptance criteria | ||
| - [x] User scenarios cover primary flows | ||
| - [x] Feature meets measurable outcomes defined in Success Criteria | ||
| - [x] No implementation details leak into specification | ||
|
|
||
| ## Notes | ||
|
|
||
| - All items pass validation. Spec is ready for `/speckit.plan`. | ||
| - Clarification session 2026-03-27: 2 questions asked, 2 answered (env var migration strategy, historical data association). | ||
| - Sync framework integration clarified via user input: fully integrated, no separate per-plan sync UI. | ||
| - Budget view isolation explicitly called out in FR-011 and SC-006. |
171 changes: 171 additions & 0 deletions
171
specs/026-multiple-api-plans/contracts/api-contracts.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| # API Contracts: Multiple Claude API Plan Connections | ||
|
|
||
| **Feature**: 026-multiple-api-plans | ||
| **Date**: 2026-03-27 | ||
|
|
||
| ## Server Actions (plan-connections.ts) | ||
|
|
||
| ### `getPlanConnections()` | ||
|
|
||
| Returns all plan connections for the organization. | ||
|
|
||
| **Auth**: Admin only | ||
| **Input**: None | ||
| **Output**: | ||
| ```typescript | ||
| { | ||
| success: true, | ||
| data: { | ||
| id: number | ||
| label: string | ||
| adminApiKeyHint: string // masked, e.g. "sk-a••••••••1234" | ||
| status: "active" | "disconnected" | ||
| createdAt: string // ISO datetime | ||
| disconnectedAt: string | null | ||
| }[] | ||
| } | { success: false, error: string } | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ### `addPlanConnection(data)` | ||
|
|
||
| Adds a new plan connection. | ||
|
|
||
| **Auth**: Admin only | ||
| **Input**: | ||
| ```typescript | ||
| { | ||
| label: string // 1-200 chars, trimmed | ||
| adminApiKey: string // plaintext, will be encrypted | ||
| } | ||
| ``` | ||
| **Validation**: | ||
| - Label: non-empty, max 200 chars | ||
| - Admin API key: non-empty, must not match an existing active connection's hint | ||
| - Active connection count must be < 10 | ||
| - API key validity check: call Anthropic `/v1/organizations/workspaces?limit=1` to verify | ||
|
|
||
| **Output**: | ||
| ```typescript | ||
| { success: true, data: { id: number, label: string } } | ||
| | { success: false, error: string } | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ### `updatePlanConnectionLabel(id, label)` | ||
|
|
||
| Updates a plan connection's label. | ||
|
|
||
| **Auth**: Admin only | ||
| **Input**: | ||
| ```typescript | ||
| { | ||
| id: number | ||
| label: string // 1-200 chars, trimmed | ||
| } | ||
| ``` | ||
| **Output**: | ||
| ```typescript | ||
| { success: true } | ||
| | { success: false, error: string } | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ### `disconnectPlanConnection(id)` | ||
|
|
||
| Soft-deletes a plan connection by setting status to 'disconnected'. | ||
|
|
||
| **Auth**: Admin only | ||
| **Input**: `{ id: number }` | ||
| **Validation**: Connection must exist and be active. Cannot disconnect if it's the only active connection. | ||
| **Output**: | ||
| ```typescript | ||
| { success: true } | ||
| | { success: false, error: string } | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Modified Server Actions | ||
|
|
||
| ### `getUserCostData(userId, month?)` — anthropic-usage.ts | ||
|
|
||
| **Change**: Return type gains optional `planLabel` field on daily breakdown for admin callers. | ||
|
|
||
| **Extended output** (admin view only): | ||
| ```typescript | ||
| CostData & { | ||
| planLabel?: string // populated when caller is admin, null for self-view | ||
| } | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ### `getGlobalCostDashboard(month, planConnectionId?)` — anthropic-usage.ts | ||
|
|
||
| **Change**: Accepts optional `planConnectionId` filter. When provided, returns data for that plan only. When omitted, aggregates across all active plans. | ||
|
|
||
| **Extended input**: `planConnectionId?: number` | ||
| **Extended output**: `workspaceBreakdown` entries gain `planLabel` field: | ||
| ```typescript | ||
| { | ||
| workspaceId: string | null | ||
| name: string | ||
| planLabel: string // NEW | ||
| planConnectionId: number // NEW | ||
| totalCents: number | ||
| dailyTotals: { date: string, costCents: number }[] | ||
| } | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Modified Sync Interfaces | ||
|
|
||
| ### `withSyncLock(params, fn)` — framework.ts | ||
|
|
||
| **Extended params**: | ||
| ```typescript | ||
| interface WithSyncLockParams { | ||
| sourceType: SyncSourceType | ||
| triggeredBy?: number | ||
| operationType?: SyncOperationType | ||
| backfillStartDate?: Date | ||
| planConnectionId?: number // NEW: included in advisory lock hash | ||
| } | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ### Sync source `run()` functions | ||
|
|
||
| Both `anthropic-usage.ts` and `anthropic-workspace.ts` `run()` functions gain: | ||
|
|
||
| **Extended options**: | ||
| ```typescript | ||
| interface RunOptions { | ||
| force?: boolean | ||
| backfillStartDate?: Date | ||
| planConnectionId?: number // NEW: sync specific plan only | ||
| } | ||
| ``` | ||
|
|
||
| When `planConnectionId` is omitted, the runner iterates all active plans. | ||
|
|
||
| --- | ||
|
|
||
| ### `fetchOrgApiKeys(adminApiKey)` — anthropic-keys.ts | ||
|
|
||
| **Change**: Accept explicit `adminApiKey` parameter instead of reading from env var. | ||
|
|
||
| **Before**: `fetchOrgApiKeys(): Promise<OrgApiKey[]>` (reads `process.env.ANTHROPIC_ADMIN_API_KEY`) | ||
| **After**: `fetchOrgApiKeys(adminApiKey: string): Promise<OrgApiKey[]>` | ||
|
|
||
| Same change applies to: | ||
| - `fetchAnthropicUsage(adminApiKey, startingAt, endingAt, apiKeyIds?)` | ||
| - `fetchWorkspaces(adminApiKey)` | ||
| - `fetchCostReport(adminApiKey, startingAt, endingAt)` | ||
| - `checkAnthropicStatus(adminApiKey?)` — optional for backward compat during auto-import |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| # Data Model: Multiple Claude API Plan Connections | ||
|
|
||
| **Feature**: 026-multiple-api-plans | ||
| **Date**: 2026-03-27 | ||
|
|
||
| ## New Entities | ||
|
|
||
| ### `anthropic_plan_connections` | ||
|
|
||
| Represents a connected Claude API plan with its encrypted admin API key. | ||
|
|
||
| | Field | Type | Constraints | Description | | ||
| |-------|------|-------------|-------------| | ||
| | id | serial | PK | Auto-incrementing identifier | | ||
| | label | varchar(200) | NOT NULL | Human-readable name (e.g., "Engineering Plan") | | ||
| | adminApiKeyEncrypted | varchar(700) | NOT NULL | AES-256-GCM encrypted admin API key (same pattern as `licenseAssignments.apiKeyEncrypted`) | | ||
| | adminApiKeyHint | varchar(20) | NOT NULL | Masked key for display (e.g., "sk-a••••••••1234") | | ||
| | status | enum | NOT NULL, default 'active' | `active` or `disconnected` | | ||
| | disconnectedAt | timestamp | nullable | When the plan was disconnected | | ||
| | createdAt | timestamp | NOT NULL, default now() | Creation timestamp | | ||
| | updatedAt | timestamp | NOT NULL, default now() | Last update timestamp | | ||
| | createdBy | integer | FK → users.id | Admin who added the connection | | ||
|
|
||
| **Indexes**: | ||
| - Unique: `adminApiKeyHint` WHERE `status = 'active'` (prevent duplicate active connections) | ||
| - Index: `status` | ||
|
|
||
| **Enum**: `anthropic_plan_status` = `['active', 'disconnected']` | ||
|
|
||
| **State transitions**: | ||
| - `active` → `disconnected` (admin removes plan; sets `disconnectedAt`) | ||
| - No transition back — reconnecting creates a new row | ||
|
|
||
| --- | ||
|
|
||
| ## Modified Entities | ||
|
|
||
| ### `anthropic_usage_metrics` — Add plan association | ||
|
|
||
| | New Field | Type | Constraints | Description | | ||
| |-----------|------|-------------|-------------| | ||
| | planConnectionId | integer | NOT NULL, FK → anthropic_plan_connections.id | Which plan this usage was sourced from | | ||
|
|
||
| **Index changes**: | ||
| - Drop existing unique index on `(user_id, date, model)` | ||
| - Add unique index on `(user_id, date, model, plan_connection_id)` | ||
| - Update composite index: `(user_id, date)` → `(user_id, date, plan_connection_id)` | ||
|
|
||
| **Migration**: Backfill all existing rows with the auto-imported first plan connection's ID. | ||
|
|
||
| --- | ||
|
|
||
| ### `anthropic_sync_status` — Add plan scoping | ||
|
|
||
| | New Field | Type | Constraints | Description | | ||
| |-----------|------|-------------|-------------| | ||
| | planConnectionId | integer | NOT NULL, default 0 | Which plan this sync status tracks (0 = legacy sentinel compatibility) | | ||
|
|
||
| **Index changes**: | ||
| - Drop existing unique index on `user_id` | ||
| - Add unique index on `(user_id, plan_connection_id)` | ||
|
|
||
| **Migration**: Backfill all existing rows with the auto-imported first plan connection's ID. The sentinel row (userId=0) gets the first plan's ID. | ||
|
|
||
| --- | ||
|
|
||
| ### `anthropic_workspaces` — Add plan association | ||
|
|
||
| | New Field | Type | Constraints | Description | | ||
| |-----------|------|-------------|-------------| | ||
| | planConnectionId | integer | NOT NULL, FK → anthropic_plan_connections.id | Which plan owns this workspace | | ||
|
|
||
| **Index changes**: | ||
| - Drop existing unique index on `workspace_id WHERE workspace_id IS NOT NULL` | ||
| - Add unique index on `(workspace_id, plan_connection_id) WHERE workspace_id IS NOT NULL` | ||
| - Drop existing unique index on `is_default WHERE is_default = true` | ||
| - Add unique index on `(plan_connection_id, is_default) WHERE is_default = true` (one default per plan) | ||
|
|
||
| **Migration**: Backfill all existing rows with the auto-imported first plan connection's ID. | ||
|
|
||
| --- | ||
|
|
||
| ### `anthropic_workspace_costs` — Add plan association | ||
|
|
||
| | New Field | Type | Constraints | Description | | ||
| |-----------|------|-------------|-------------| | ||
| | planConnectionId | integer | NOT NULL, FK → anthropic_plan_connections.id | Which plan this cost belongs to | | ||
|
|
||
| **Index changes**: | ||
| - Drop existing unique index on `(workspace_id, date) WHERE workspace_id IS NOT NULL` | ||
| - Add unique index on `(workspace_id, date, plan_connection_id) WHERE workspace_id IS NOT NULL` | ||
| - Drop existing unique index on `date WHERE workspace_id IS NULL` | ||
| - Add unique index on `(date, plan_connection_id) WHERE workspace_id IS NULL` | ||
|
|
||
| **Migration**: Backfill all existing rows with the auto-imported first plan connection's ID. | ||
|
|
||
| --- | ||
|
|
||
| ### `sync_events` — Add optional plan tracking | ||
|
|
||
| | New Field | Type | Constraints | Description | | ||
| |-----------|------|-------------|-------------| | ||
| | planConnectionId | integer | nullable, FK → anthropic_plan_connections.id | Which plan this sync event was for (null = non-plan sync sources like GitHub) | | ||
|
|
||
| **No index changes** — existing indexes sufficient. The column is nullable because non-Anthropic sync sources don't have plans. | ||
|
|
||
| --- | ||
|
|
||
| ## Entity Relationship Summary | ||
|
|
||
| ``` | ||
| anthropic_plan_connections (1) ──→ (N) anthropic_usage_metrics | ||
| anthropic_plan_connections (1) ──→ (N) anthropic_workspaces | ||
| anthropic_plan_connections (1) ──→ (N) anthropic_workspace_costs | ||
| anthropic_plan_connections (1) ──→ (N) anthropic_sync_status | ||
| anthropic_plan_connections (1) ──→ (N) sync_events (nullable) | ||
| users (1) ──→ (N) anthropic_plan_connections (via createdBy) | ||
| ``` | ||
|
|
||
| ## Migration Strategy | ||
|
|
||
| 1. Create `anthropic_plan_connections` table with enum | ||
| 2. Insert first plan row from `ANTHROPIC_ADMIN_API_KEY` env var (if set and table is empty) | ||
| 3. Add `plan_connection_id` columns (nullable initially) to all modified tables | ||
| 4. Backfill all existing rows with the first plan's ID | ||
| 5. Set columns to NOT NULL | ||
| 6. Drop old unique indexes, create new composite indexes | ||
| 7. Add foreign key constraints | ||
|
|
||
| **Rollback**: Drop new columns, recreate original indexes. The `anthropic_plan_connections` table can remain without impact. | ||
|
|
||
| ## Validation Rules | ||
|
|
||
| - **Label**: 1–200 characters, trimmed, non-empty | ||
| - **Admin API Key**: Non-empty string, encrypted before storage | ||
| - **Key uniqueness**: Validated by checking `adminApiKeyHint` against active connections before insert | ||
| - **Max connections**: Application-level check (≤ 10 active connections) | ||
| - **Status transitions**: Only `active` → `disconnected` allowed (enforced in server action) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The file now starts with a UTF-8 BOM / zero-width character before
#(visible as#). This can cause noisy diffs and tooling issues in some environments; consider removing the BOM so the first character is#.