Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# AI Developer Hub Development Guidelines
# AI Developer Hub Development Guidelines
Copy link

Copilot AI Mar 30, 2026

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 #.

Suggested change
# AI Developer Hub Development Guidelines
# AI Developer Hub Development Guidelines

Copilot uses AI. Check for mistakes.

Auto-generated from all feature plans. Last updated: 2026-03-02

Expand Down Expand Up @@ -51,6 +51,7 @@ Auto-generated from all feature plans. Last updated: 2026-03-02
- TypeScript 5.9.3 (strict mode) + Next.js 15.5.12 (App Router), React 19.2.4, Drizzle ORM 0.45.1, TanStack Table 8.21.3, shadcn/ui (new-york), Lucide React (023-ingestion-history)
- Neon PostgreSQL (serverless) via `@neondatabase/serverless` — 1 new table, 2 new enums (023-ingestion-history)
- TypeScript 5.9.3 (strict mode) + Next.js 15.5.12 (App Router), Drizzle ORM 0.45.1, React 19.2.4 (025-running-api-costs)
- Neon PostgreSQL (serverless) via `@neondatabase/serverless` — 1 new table (`anthropic_plan_connections`), 4 modified tables (`anthropic_usage_metrics`, `anthropic_sync_status`, `anthropic_workspaces`, `anthropic_workspace_costs`), `sync_events` gains optional column (026-multiple-api-plans)

- **Language**: TypeScript 5.x (strict mode), Node.js LTS
- **Framework**: Next.js 15 (App Router, Server Components, Server Actions)
Expand Down Expand Up @@ -113,9 +114,9 @@ pnpm lighthouse # Lighthouse CI
- Server Components by default — `"use client"` only when client interactivity needed

## Recent Changes
- 026-multiple-api-plans: Added TypeScript 5.9.3 (strict mode) + Next.js 15.5.12 (App Router), React 19.2.4, Drizzle ORM 0.45.1, NextAuth 5.0.0-beta.30, shadcn/ui (new-york), Zod 4.3.6, Sonner (toasts), Lucide React
- 025-running-api-costs: Added TypeScript 5.9.3 (strict mode) + Next.js 15.5.12 (App Router), Drizzle ORM 0.45.1, React 19.2.4
- 023-ingestion-history: Added TypeScript 5.9.3 (strict mode) + Next.js 15.5.12 (App Router), React 19.2.4, Drizzle ORM 0.45.1, TanStack Table 8.21.3, shadcn/ui (new-york), Lucide React
- 022-profile-api-preview: Added TypeScript 5.9.3 (strict mode) + Next.js 15.5.12 (App Router), React 19.2.4, shadcn/ui (new-york), Zod 4.3.6, Sonner (toasts), Lucide React


<!-- MANUAL ADDITIONS START -->
Expand Down
37 changes: 37 additions & 0 deletions specs/026-multiple-api-plans/checklists/requirements.md
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 specs/026-multiple-api-plans/contracts/api-contracts.md
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
138 changes: 138 additions & 0 deletions specs/026-multiple-api-plans/data-model.md
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)
Loading
Loading