# `@sentinel-ai-solutions/agent-kit/connections`

Vendor-agnostic OAuth orchestration. Define a Provider once; the framework handles authorization URL construction, code exchange, token storage, transparent refresh, and revocation.

Connections are the biggest barrier to high-volume adoption. Today every new integration is bespoke. This module changes that — the same primitive serves SMB onboarding (Sentinel AI customer integrations) and consumer Life Admin product onboarding.

---

## The five core abstractions

| Abstraction | Role |
|---|---|
| `ProviderName` | Well-known provider IDs (`"google"`, `"slack"`, …). Open union — add custom strings without a library change. |
| `Provider` | OAuth contract any vendor implements: build auth URL, exchange code, refresh token, revoke, fetch user info. |
| `Connection` | A persisted, live operator↔provider link: tokens, scopes, expiry, metadata. |
| `ConnectionStore` | Persistence adapter. `MemoryConnectionStore` for tests; `PrismaConnectionStore` (Batch 2) for production. |
| `ConnectionManager` | High-level API: `connect`, `completeConnection`, `getAccessToken`, `disconnect`. |

---

## OAuth flow

```
1. connect()              → returns { authorizationUrl, state, providerId }
2. user visits URL        → grants access, vendor redirects to your callback
3. verify state param     → CSRF check (caller's responsibility)
4. completeConnection()   → exchanges code, persists Connection, returns it
5. getAccessToken()       → returns valid token; refreshes transparently if near expiry
```

---

## Supported providers

| Provider | Class | Scope constants | Notes |
|---|---|---|---|
| Google | `GoogleProvider` | `GoogleScopes` | Offline access + refresh. prompt=consent ensures refresh_token on re-auth. |
| Microsoft 365 | `MicrosoftGraphProvider` | `MicrosoftGraphScopes` | Multi-tenant by default. Include `OFFLINE_ACCESS` scope for refresh tokens. |
| Slack | `SlackProvider` | `SlackScopes` | Bot tokens. Token expiry requires opt-in rotation in Slack app settings. |
| HubSpot | `HubSpotProvider` | `HubSpotScopes`, `HUBSPOT_SCOPES` | CRM objects (contacts, deals, companies). Tokens expire in 30 min. |
| Xero | `XeroProvider` | `XeroScopes`, `XERO_SCOPES` | Accounting API. Refresh tokens rotate on every use. |
| Notion | `NotionProvider` | _(no granular scopes)_ | Workspace-level access. Tokens do not expire; no refresh supported. |

---

## Usage

```ts
import { ConnectionManager, GoogleProvider, GoogleScopes, MemoryConnectionStore } from "@sentinel-ai-solutions/agent-kit/connections";

const manager = new ConnectionManager({
  store: new MemoryConnectionStore(),
  providers: new Map([["google", new GoogleProvider()]]),
  credentials: new Map([
    ["google", { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET! }],
  ]),
  defaultRedirectUri: "https://myapp.com/auth/callback",
});

// Step 1 — redirect user to Google
const { authorizationUrl, state } = await manager.connect("operator_123", "google", {
  scopes: [GoogleScopes.GMAIL_SEND, GoogleScopes.CALENDAR_EVENTS],
});
// Store `state` in session, redirect user to authorizationUrl

// Step 2 — handle callback (after user authorizes)
const connection = await manager.completeConnection(
  "operator_123", "google", req.query.code, "https://myapp.com/auth/callback"
);

// Step 3 — use the connection
const accessToken = await manager.getAccessToken("operator_123", "google");
// Pass accessToken to any Google API. Refresh is handled automatically.

// Disconnect
await manager.disconnect("operator_123", "google");
```

---

## Google scope bundles

```ts
import { GoogleScopes } from "@sentinel-ai-solutions/agent-kit/connections";

GoogleScopes.GMAIL_READ        // https://www.googleapis.com/auth/gmail.readonly
GoogleScopes.GMAIL_SEND        // https://www.googleapis.com/auth/gmail.send
GoogleScopes.GMAIL_COMPOSE     // https://www.googleapis.com/auth/gmail.compose
GoogleScopes.CALENDAR_READ     // https://www.googleapis.com/auth/calendar.readonly
GoogleScopes.CALENDAR_FULL     // https://www.googleapis.com/auth/calendar
GoogleScopes.CALENDAR_EVENTS   // https://www.googleapis.com/auth/calendar.events
GoogleScopes.DRIVE_READ        // https://www.googleapis.com/auth/drive.readonly
GoogleScopes.DRIVE_FULL        // https://www.googleapis.com/auth/drive
GoogleScopes.SHEETS            // https://www.googleapis.com/auth/spreadsheets
GoogleScopes.DOCS              // https://www.googleapis.com/auth/documents
GoogleScopes.TASKS             // https://www.googleapis.com/auth/tasks
```

---

## Microsoft 365 scope bundles

```ts
import { MicrosoftGraphScopes } from "@sentinel-ai-solutions/agent-kit/connections";

// Always include OFFLINE_ACCESS to receive a refresh_token
MicrosoftGraphScopes.OFFLINE_ACCESS     // "offline_access"

MicrosoftGraphScopes.USER_READ          // https://graph.microsoft.com/User.Read
MicrosoftGraphScopes.MAIL_READ          // https://graph.microsoft.com/Mail.Read
MicrosoftGraphScopes.MAIL_SEND          // https://graph.microsoft.com/Mail.Send
MicrosoftGraphScopes.MAIL_READWRITE     // https://graph.microsoft.com/Mail.ReadWrite
MicrosoftGraphScopes.CALENDARS_READ     // https://graph.microsoft.com/Calendars.Read
MicrosoftGraphScopes.CALENDARS_READWRITE// https://graph.microsoft.com/Calendars.ReadWrite
MicrosoftGraphScopes.FILES_READ         // https://graph.microsoft.com/Files.Read
MicrosoftGraphScopes.FILES_READWRITE    // https://graph.microsoft.com/Files.ReadWrite
MicrosoftGraphScopes.CONTACTS_READ      // https://graph.microsoft.com/Contacts.Read
MicrosoftGraphScopes.PRESENCE_READ      // https://graph.microsoft.com/Presence.Read
MicrosoftGraphScopes.TEAMS_READ         // https://graph.microsoft.com/Team.ReadBasic.All
MicrosoftGraphScopes.CHAT_READ          // https://graph.microsoft.com/Chat.Read
```

---

## Slack scope bundles

```ts
import { SlackScopes } from "@sentinel-ai-solutions/agent-kit/connections";

// Bot scopes — passed as the `scope` parameter
SlackScopes.CHAT_WRITE          // "chat:write"
SlackScopes.CHAT_WRITE_PUBLIC   // "chat:write.public"
SlackScopes.CHANNELS_READ       // "channels:read"
SlackScopes.CHANNELS_HISTORY    // "channels:history"
SlackScopes.GROUPS_READ         // "groups:read"
SlackScopes.GROUPS_HISTORY      // "groups:history"
SlackScopes.IM_READ             // "im:read"
SlackScopes.IM_HISTORY          // "im:history"
SlackScopes.USERS_READ          // "users:read"
SlackScopes.USERS_READ_EMAIL    // "users:read.email"
SlackScopes.TEAM_READ           // "team:read"
SlackScopes.FILES_READ          // "files:read"
SlackScopes.FILES_WRITE         // "files:write"
```

---

## HubSpot scope bundles

```ts
import { HubSpotScopes, HUBSPOT_SCOPES } from "@sentinel-ai-solutions/agent-kit/connections";

// Individual scope constants
HubSpotScopes.CONTACTS_READ    // "crm.objects.contacts.read"
HubSpotScopes.CONTACTS_WRITE   // "crm.objects.contacts.write"
HubSpotScopes.DEALS_READ       // "crm.objects.deals.read"
HubSpotScopes.DEALS_WRITE      // "crm.objects.deals.write"
HubSpotScopes.COMPANIES_READ   // "crm.objects.companies.read"
HubSpotScopes.COMPANIES_WRITE  // "crm.objects.companies.write"
HubSpotScopes.OAUTH            // "oauth" — required for the OAuth flow

// Pre-composed groupings (pass directly to connect())
HUBSPOT_SCOPES.READ_ONLY       // contacts + deals + companies read, + oauth
HUBSPOT_SCOPES.READ_WRITE      // contacts + deals + companies read/write, + oauth
HUBSPOT_SCOPES.FULL_CRM        // same as READ_WRITE — extend as you add more object types
```

---

## Xero scope bundles

```ts
import { XeroScopes, XERO_SCOPES } from "@sentinel-ai-solutions/agent-kit/connections";

// Individual scope constants
XeroScopes.OPENID                   // "openid"
XeroScopes.PROFILE                  // "profile"
XeroScopes.EMAIL                    // "email"
XeroScopes.OFFLINE_ACCESS           // "offline_access" — required for refresh tokens
XeroScopes.ACCOUNTING_TRANSACTIONS  // "accounting.transactions"
XeroScopes.ACCOUNTING_SETTINGS      // "accounting.settings"
XeroScopes.ACCOUNTING_CONTACTS      // "accounting.contacts"
XeroScopes.ACCOUNTING_ATTACHMENTS   // "accounting.attachments"
XeroScopes.ACCOUNTING_REPORTS_READ  // "accounting.reports.read"

// Pre-composed groupings (pass directly to connect())
XERO_SCOPES.IDENTITY_ONLY          // openid + profile + email
XERO_SCOPES.READ_ONLY_ACCOUNTING   // identity + offline_access + accounting.reports.read
XERO_SCOPES.FULL_ACCOUNTING        // identity + offline_access + all accounting scopes
```

---

## Microsoft 365 setup

### Azure AD app registration

1. Go to [Azure Portal → App registrations](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) and click **New registration**.
2. Set the **Redirect URI** to your OAuth callback URL (e.g. `https://myapp.com/auth/callback`). Select **Web** as the platform.
3. Note the **Application (client) ID** — this is your `MICROSOFT_CLIENT_ID`.
4. Under **Certificates & secrets**, create a **Client secret**. Copy the value immediately — this is your `MICROSOFT_CLIENT_SECRET`.
5. Under **API permissions**, add the Microsoft Graph delegated permissions you need (e.g. `Mail.Read`, `Calendars.ReadWrite`). You do NOT need to grant admin consent for delegated permissions used in user-interactive flows.

### Tenant configuration

| `tenantId` | Who can sign in |
|---|---|
| `"common"` (default) | Personal Microsoft accounts + any Azure AD / M365 tenant |
| `"organizations"` | Azure AD / M365 tenants only (no personal accounts) |
| `"consumers"` | Personal Microsoft accounts only (Outlook.com, Hotmail, Live) |
| `"<GUID>"` | Specific Azure AD tenant only |

```ts
import { MicrosoftGraphProvider, MicrosoftGraphScopes } from "@sentinel-ai-solutions/agent-kit/connections";

// Multi-tenant SaaS — accepts any Microsoft account (default)
const provider = new MicrosoftGraphProvider();

// Your company's internal M365 tenant only
const provider = new MicrosoftGraphProvider({ tenantId: "aaaabbbb-cccc-dddd-eeee-ffffgggghhhh" });

// Register and use with ConnectionManager
const manager = new ConnectionManager({
  store,
  providers: new Map([["microsoft_365", provider]]),
  credentials: new Map([
    ["microsoft_365", {
      clientId: process.env.MICROSOFT_CLIENT_ID!,
      clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
    }],
  ]),
  defaultRedirectUri: "https://myapp.com/auth/callback",
});

// Connect with Outlook + Calendar access
const { authorizationUrl } = await manager.connect(operatorId, "microsoft_365", {
  scopes: [
    MicrosoftGraphScopes.MAIL_READWRITE,
    MicrosoftGraphScopes.CALENDARS_READWRITE,
    MicrosoftGraphScopes.OFFLINE_ACCESS, // required for refresh tokens
  ],
});
```

---

## Slack setup

### Creating a Slack app

1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App → From scratch**.
2. Under **OAuth & Permissions**, add your **Redirect URL** (e.g. `https://myapp.com/auth/callback`).
3. Under **Bot Token Scopes**, add the scopes your app needs (e.g. `chat:write`, `channels:read`).
4. Copy the **Client ID** and **Client Secret** from **Basic Information**.
5. Install the app to a workspace for initial testing.

### Bot tokens vs user tokens

Slack's OAuth v2 issues two distinct token types:

| Token type | Where it comes from | What it can do |
|---|---|---|
| **Bot token** (`xoxb-…`) | `access_token` in the exchange response | Act as your app's bot user |
| **User token** (`xoxp-…`) | `authed_user.access_token` in the response | Act on behalf of the authorizing user |

This provider stores the **bot token** as the primary `accessToken`. If you need the user token (e.g. to read a specific user's DMs), inspect `connection.metadata` — the full Slack response is available in `raw`.

For a user-token-only flow, request `user_scope` instead of `scope` in the authorization URL. Extend `SlackProvider.buildAuthorizationUrl` to support this.

### Token rotation

By default, Slack tokens **do not expire**. They remain valid until revoked or the app is uninstalled.

To enable token expiry (and make the refresh flow work):

1. In your Slack app settings, go to **OAuth & Permissions**.
2. Toggle **Token Rotation** on.
3. Re-install the app to your workspace.

When rotation is **enabled**: tokens expire after 12 hours. `expiresAt` is set and ConnectionManager refreshes automatically.

When rotation is **disabled** (the default): `expiresAt` is `undefined`, no refresh is attempted, and `refreshAccessToken` throws a clear error if called.

```ts
import { SlackProvider, SlackScopes } from "@sentinel-ai-solutions/agent-kit/connections";

const manager = new ConnectionManager({
  store,
  providers: new Map([["slack", new SlackProvider()]]),
  credentials: new Map([
    ["slack", {
      clientId: process.env.SLACK_CLIENT_ID!,
      clientSecret: process.env.SLACK_CLIENT_SECRET!,
    }],
  ]),
  defaultRedirectUri: "https://myapp.com/auth/callback",
});

// Connect with posting + channel reading
const { authorizationUrl } = await manager.connect(operatorId, "slack", {
  scopes: [SlackScopes.CHAT_WRITE, SlackScopes.CHANNELS_READ],
});
```

---

## HubSpot setup

### Creating a HubSpot app

1. Go to [developers.hubspot.com](https://developers.hubspot.com) and click **Create app**.
2. Under **Auth**, add your **Redirect URL** (e.g. `https://myapp.com/auth/callback`).
3. Under **Scopes**, select the scopes your app needs (e.g. `crm.objects.contacts.read`, `crm.objects.deals.read`). Add `oauth` as a required scope.
4. Note the **Client ID** and **Client Secret** from the app settings.

### Required env vars

```
HUBSPOT_CLIENT_ID=<your app's client ID>
HUBSPOT_CLIENT_SECRET=<your app's client secret>
```

### Connect with HubSpot

```ts
import { ConnectionManager, HubSpotProvider, HUBSPOT_SCOPES } from "@sentinel-ai-solutions/agent-kit/connections";

const manager = new ConnectionManager({
  store,
  providers: new Map([["hubspot", new HubSpotProvider()]]),
  credentials: new Map([
    ["hubspot", {
      clientId: process.env.HUBSPOT_CLIENT_ID!,
      clientSecret: process.env.HUBSPOT_CLIENT_SECRET!,
    }],
  ]),
  defaultRedirectUri: "https://myapp.com/auth/callback",
});

const { authorizationUrl } = await manager.connect(operatorId, "hubspot", {
  scopes: HUBSPOT_SCOPES.READ_WRITE,
});
```

---

## Xero setup

### Creating a Xero app

1. Go to [developer.xero.com/app/manage](https://developer.xero.com/app/manage) and click **New app**.
2. Select **Web app** as the integration type.
3. Add your **Redirect URI** (e.g. `https://myapp.com/auth/callback`).
4. Note the **Client ID** and **Client Secret**.
5. Under **Scopes**, the scopes are requested dynamically at authorization time — you don't need to pre-configure them in the Xero app settings.

### Required env vars

```
XERO_CLIENT_ID=<your app's client ID>
XERO_CLIENT_SECRET=<your app's client secret>
```

### Refresh token rotation

Xero rotates refresh tokens on every use. Always use the refresh token returned by the most recent refresh — the previous one is immediately invalidated. ConnectionManager handles this automatically by writing the new refresh token back to the store after every successful refresh.

### Connect with Xero

```ts
import { ConnectionManager, XeroProvider, XERO_SCOPES } from "@sentinel-ai-solutions/agent-kit/connections";

const manager = new ConnectionManager({
  store,
  providers: new Map([["xero", new XeroProvider()]]),
  credentials: new Map([
    ["xero", {
      clientId: process.env.XERO_CLIENT_ID!,
      clientSecret: process.env.XERO_CLIENT_SECRET!,
    }],
  ]),
  defaultRedirectUri: "https://myapp.com/auth/callback",
});

// Always include XERO_SCOPES.FULL_ACCOUNTING or at minimum OFFLINE_ACCESS
// to receive a refresh token for the 30-minute access token window.
const { authorizationUrl } = await manager.connect(operatorId, "xero", {
  scopes: XERO_SCOPES.FULL_ACCOUNTING,
});
```

---

## Notion setup

### Creating a Notion integration

1. Go to [www.notion.so/profile/integrations](https://www.notion.so/profile/integrations) and click **New integration**.
2. Set the **Redirect URI** to your OAuth callback URL (e.g. `https://myapp.com/auth/callback`).
3. Note the **OAuth client ID** and **OAuth client secret** from the integration settings.
4. Set the integration type to **Public** (required for OAuth).

### Required env vars

```
NOTION_CLIENT_ID=<your integration's OAuth client ID>
NOTION_CLIENT_SECRET=<your integration's OAuth client secret>
```

### No scopes, no refresh tokens

Notion does not use granular OAuth scopes. Access is workspace-level — the user selects which pages and databases to share during authorization. Pass an empty scope list (or omit it entirely) when calling `connect()`.

Notion access tokens do not expire. ConnectionManager will never attempt to refresh them. If a user revokes the integration from Notion, catch the 401 error and prompt the user to reconnect.

### Connect with Notion

```ts
import { ConnectionManager, NotionProvider } from "@sentinel-ai-solutions/agent-kit/connections";

const manager = new ConnectionManager({
  store,
  providers: new Map([["notion", new NotionProvider()]]),
  credentials: new Map([
    ["notion", {
      clientId: process.env.NOTION_CLIENT_ID!,
      clientSecret: process.env.NOTION_CLIENT_SECRET!,
    }],
  ]),
  defaultRedirectUri: "https://myapp.com/auth/callback",
});

// No scopes needed for Notion — workspace access is user-selected
const { authorizationUrl } = await manager.connect(operatorId, "notion");
```

---

## Common gotchas

### Microsoft 365 — missing refresh token

**Symptom:** Token exchange succeeds but `refreshToken` is `undefined`.

**Cause:** `offline_access` was not included in the scope list. Microsoft requires this scope to be explicitly requested — it is not granted automatically.

**Fix:** Always include `MicrosoftGraphScopes.OFFLINE_ACCESS` in your scope list.

---

### Microsoft 365 — tenant configuration mismatch

**Symptom:** Users get "You cannot sign in here with a personal account" or "This application is not authorized for your organization".

**Cause:** The `tenantId` passed to `MicrosoftGraphProvider` doesn't match the user's account type.

**Fix:** Use `"common"` (the default) for multi-tenant SaaS that needs to support both personal accounts and org accounts. Use `"organizations"` if you want to block personal accounts. Use your Azure AD tenant GUID to lock to your own org only.

---

### Slack — token rotation not enabled

**Symptom:** `refreshAccessToken` throws with a message about `token_rotation_not_enabled`.

**Cause:** Token rotation is disabled in the Slack app settings (the default). ConnectionManager will only call `refreshAccessToken` when `expiresAt` is set, which only happens when rotation is enabled and Slack includes `expires_in` in the token response.

**Fix:** Either enable token rotation in your Slack app (recommended for security) or confirm you are using the connection at `getAccessToken` time only — without rotation, the token never expires and no refresh is needed.

---

### Slack — scope mismatch errors

**Symptom:** API calls fail with `missing_scope` errors after a successful OAuth flow.

**Cause:** The scopes you requested during authorization don't include the scope required by the API method you're calling.

**Fix:** Check the [Slack API method documentation](https://api.slack.com/methods) for the required bot scope. Add it to your `scopes` list in the `connect()` call and re-authorize.

---

## Adding a new provider

1. Create `src/connections/providers/my-provider.ts` and implement the `Provider` interface.
2. Export it from `src/connections/index.ts`.
3. Register it when instantiating `ConnectionManager`.

The minimum contract is four methods: `buildAuthorizationUrl`, `exchangeCode`, `refreshAccessToken`. The optional `revokeAccess` and `fetchUserInfo` methods are called automatically when present.

```ts
import type { Provider, TokenResponse } from "@sentinel-ai-solutions/agent-kit/connections";

export class MyProvider implements Provider {
  readonly id = "my_provider" as const;
  readonly displayName = "My Provider";
  readonly defaultScopes = ["read"];

  buildAuthorizationUrl(params) { /* ... */ }
  async exchangeCode(params): Promise<TokenResponse> { /* ... */ }
  async refreshAccessToken(params): Promise<TokenResponse> { /* ... */ }
}
```

---

## Error types

| Error | When thrown |
|---|---|
| `NoConnectionError` | `getAccessToken` called for operator with no stored connection |
| `ProviderNotRegisteredError` | Provider ID not in `ConnectionManagerConfig.providers` |
| `OAuthExchangeError` | Vendor rejected the authorization code |
| `TokenRefreshError` | Vendor rejected the refresh token |

---

## Token refresh behaviour

`getAccessToken` automatically refreshes the access token when it is within **5 minutes** of expiry. The refreshed tokens are written back to the store. If the provider does not return a new refresh token, the existing one is retained.

If the connection has no `expiresAt` set (provider does not include `expires_in`), no refresh is attempted and the stored access token is returned as-is.

---

---

## Production setup — PrismaConnectionStore

`MemoryConnectionStore` is for tests and ephemeral processes. Use `PrismaConnectionStore` in production.

### 1 — Add the Connection model to your schema

Copy the model from `src/connections/schema.prisma.example` (published with the package) into your app's `prisma/schema.prisma`:

```prisma
model Connection {
  id                   String    @id @default(cuid())
  operatorId           String
  providerId           String

  encryptedAccessToken  String    @db.Text
  accessTokenIv         String
  accessTokenTag        String
  encryptedRefreshToken String?   @db.Text
  refreshTokenIv        String?
  refreshTokenTag       String?

  expiresAt    DateTime?
  scopes       String[]
  connectedAt  DateTime  @default(now())
  lastUsedAt   DateTime?
  metadata     Json?
  updatedAt    DateTime  @updatedAt

  @@unique([operatorId, providerId])
  @@index([operatorId])
  @@index([providerId])
}
```

Run:

```bash
npx prisma migrate dev --name add_connection_model
```

### 2 — Set the encryption key

Add to your environment (`.env`, AWS Secrets Manager, Vercel env, etc.):

```
CONNECTION_ENCRYPTION_KEY=<at-least-32-random-characters>
```

Generate with: `openssl rand -base64 32`

> **Key rotation:** The same key across all deploys decrypts existing rows. Rotating the key requires re-encrypting all rows. Key rotation tooling is deferred to Batch 4. Until then, treat this key as a long-lived secret.

### 3 — Wire the store

```ts
import { PrismaClient } from "@prisma/client";
import {
  ConnectionManager,
  ConnectionCrypto,
  GoogleProvider,
  GoogleScopes,
  PrismaConnectionStore,
} from "@sentinel-ai-solutions/agent-kit/connections";

const crypto = new ConnectionCrypto({
  encryptionKey: process.env.CONNECTION_ENCRYPTION_KEY!,
});

const store = new PrismaConnectionStore(new PrismaClient(), crypto);

const manager = new ConnectionManager({
  store,
  providers: new Map([["google", new GoogleProvider()]]),
  credentials: new Map([
    ["google", { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET! }],
  ]),
  defaultRedirectUri: "https://myapp.com/auth/callback",
});
```

---

## Encryption details

`ConnectionCrypto` uses **AES-256-GCM** — authenticated encryption that provides both confidentiality and integrity.

| Property | Detail |
|---|---|
| Algorithm | AES-256-GCM |
| Key size | 256 bits (32 bytes) |
| Key derivation | scrypt from `CONNECTION_ENCRYPTION_KEY` with fixed salt |
| IV | 96 bits, randomly generated per `encryptToken()` call |
| Auth tag | 128 bits, stored alongside ciphertext |
| What's encrypted | `accessToken` and `refreshToken` only |
| What's plaintext | All other fields (`operatorId`, `expiresAt`, `scopes`, `metadata`) |

The IV uniqueness guarantee comes from `randomBytes(12)` per call — with 2⁹⁶ IV space, the probability of a collision across all tokens ever stored is negligible.

---

## What is intentionally deferred

| Feature | Batch |
|---|---|
| GitHub, Linear, Atlassian providers | Batch 4b |
| "Connect your tools" UI primitives | Batch 4 |
| Per-seat / mirror enforcement | Batch 4 |
| Key rotation tooling (re-encrypt all tokens with new key) | Batch 4 |
| HSM / KMS-backed key management | Future |
| MCP integration as a Provider | Separate batch |
