Skip to content
Merged
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
22 changes: 20 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,34 @@ pnpm test
- `src/component/mutations.ts` — Convex mutations (create, validate, revoke, revokeByTag, rotate, update, disable, enable, configure)
- `src/component/queries.ts` — Convex queries (list, listByTag, getUsage)
- `src/component/validators.ts` — Shared validators (jsonValue alias for v.any())
- `src/component/schema.ts` — Database schema (apiKeys, apiKeyEvents, config)
- `src/component/schema.ts` — Database schema (apiKeys, config)
- `src/shared.ts` — Shared types, key parsing, crypto (sha256Hex, timingSafeEqual)
- `src/test.ts` — convex-test helper for registering the component

## Important Patterns

- Hash is computed **server-side** in `mutations.ts`, not in the client
- Secret material (hash, lookupPrefix, secretHex) generated **server-side** in `mutations.ts`, not in the client
- All admin mutations require `ownerId` — auth boundary prevents cross-tenant access
- Mutations in `mutations.ts`, queries in `queries.ts` (enforced by eslint)
- No bare `v.any()` — aliased as `jsonValue` in `validators.ts`
- Keys are never stored raw — only SHA-256 hashes
- All queries are scoped by `ownerId` (multi-tenant)
- Terminal statuses (`revoked`, `expired`, `exhausted`) cannot be transitioned out of
- Single child component: `@convex-dev/sharded-counter` (rate-limiter, aggregate, crons removed)
- Audit trail via structured logging, not DB events (apiKeyEvents table removed)
- Use `convex-test` with `@edge-runtime/vm` for testing

## Docs Sync (MANDATORY)

When any of these change, update the corresponding docs:

| Change | Update |
|--------|--------|
| Mutation/query args or return types | `docs/API.md`, `README.md` API table, `CLAUDE.md` |
| Schema table added/removed/modified | `AGENTS.md` Structure section, `CLAUDE.md` |
| Child component added/removed | `README.md` Architecture section, `AGENTS.md` |
| New feature or breaking change | `CHANGELOG.md`, `README.md` Features section |
| Input validation rules changed | `docs/API.md` Input Validation table |
| Security model changed | `README.md` Security Model section |

Always run `pnpm lint && pnpm build && pnpm test` before committing docs changes.
70 changes: 68 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,72 @@
# Changelog

## 0.2.0

### Breaking Changes

- **`create()` / `rotate()`**: Secret material (lookupPrefix, secretHex, hash) now generated server-side. Remove these from client args.
- **Admin mutations** (`revoke`, `disable`, `enable`, `update`, `rotate`, `getUsage`): `ownerId` is now a required argument for auth boundary enforcement.
- **`apiKeyEvents` table removed**: Audit trail replaced with structured logging (Convex dashboard). Export existing event data before upgrading.
- **`@convex-dev/rate-limiter` removed**: Rate limiting is now the integrator's responsibility at their HTTP action/mutation layer. Remove `rateLimiterTest.register()` from test setup.
- **`@convex-dev/aggregate` and `@convex-dev/crons` removed**: Remove `aggregateTest.register()` and `cronsTest.register()` from test setup.
- **`getUsage()`**: `period` param removed (counter-only). `lastUsedAt` removed from return type.
- **`validate()`**: `retryAfter` removed from failure response (no internal rate limiting).
- **`list()` / `listByTag()`**: Now paginated with `limit` param (default 100).

### Migration Guide

```ts
// BEFORE (v0.1)
const { key } = await apiKeys.create(ctx, {
name: "My Key", ownerId: "org_1",
lookupPrefix, secretHex, hash, // ← REMOVE these
});
await apiKeys.revoke(ctx, { keyId });
await apiKeys.rotate(ctx, { keyId, lookupPrefix, secretHex });
const usage = await apiKeys.getUsage(ctx, { keyId, period: { start, end } });

// AFTER (v0.2)
const { key } = await apiKeys.create(ctx, {
name: "My Key", ownerId: "org_1",
// secret material generated server-side
});
await apiKeys.revoke(ctx, { keyId, ownerId: "org_1" }); // ← ADD ownerId
await apiKeys.rotate(ctx, { keyId, ownerId: "org_1" }); // ← ADD ownerId
const usage = await apiKeys.getUsage(ctx, { keyId, ownerId: "org_1" }); // ← ADD ownerId, REMOVE period
```

Test setup:

```ts
// BEFORE
import rateLimiterTest from "@convex-dev/rate-limiter/test";
import aggregateTest from "@convex-dev/aggregate/test";
import cronsTest from "@convex-dev/crons/test";
rateLimiterTest.register(t, "apiKeys/rateLimiter");
aggregateTest.register(t, "apiKeys/usageAggregate");
cronsTest.register(t, "apiKeys/crons");

// AFTER — only shardedCounter remains
import shardedCounterTest from "@convex-dev/sharded-counter/test";
shardedCounterTest.register(t, "apiKeys/shardedCounter");
```

### New Features

- Auth boundary: `ownerId` cross-check on all admin mutations
- Server-side secret generation for `create()` and `rotate()`
- Input validation: keyPrefix charset, env charset, gracePeriodMs bounds (60s–30d), metadata size (4KB), scopes (50), tags (20)
- Configure bounds validation (reject negative/zero)
- Structured audit logging on all mutation outcomes
- lastUsedAt throttled to 60s (reduces OCC contention)
- Remaining decrement decoupled from lastUsedAt write (single merged patch)
- revokeByTag expanded to include `rotating` and `disabled` statuses
- Paginated list/listByTag with configurable limit

## 0.1.1

- CI fix: add --ignore-scripts to npm publish

## 0.1.0

- Initial release
Expand All @@ -12,9 +79,8 @@
- Key disable/enable (reversible pause)
- Finite-use keys (remaining counter with atomic decrement)
- Key types (secret / publishable) with type-encoded prefix
- Environment-aware key format ({prefix}_{type}_{env}_{random}_{secret})
- Environment-aware key format
- Multi-tenant isolation (ownerId-scoped queries)
- Audit event log (apiKeyEvents table)
- Usage analytics via event counting
- Constant-time hash comparison
- Child components: @convex-dev/rate-limiter, sharded-counter, aggregate, crons
12 changes: 8 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Convex agent skills for common tasks can be installed by running `npx convex ai-

## Project Overview

`@vllnt/convex-api-keys` is a Convex component for secure API key management. It provides create, validate, revoke, rotate, rate-limit, and usage tracking — all backed by `@convex-dev/*` ecosystem components.
`@vllnt/convex-api-keys` is a Convex component for secure API key management. It provides create, validate, revoke, rotate, and usage tracking — with built-in auth boundaries and structured audit logging. Single child component: `@convex-dev/sharded-counter`.

## Architecture

Expand All @@ -24,17 +24,21 @@ src/
├── mutations.ts # Mutations (create, validate, revoke, rotate, update, disable, enable, configure)
├── queries.ts # Queries (list, listByTag, getUsage)
├── validators.ts # Shared validators (jsonValue alias for v.any())
├── schema.ts # Convex schema (apiKeys, apiKeyEvents, config tables)
└── convex.config.ts # Component config (rate-limiter, sharded-counter, aggregate, crons)
├── schema.ts # Convex schema (apiKeys, config tables)
└── convex.config.ts # Component config (sharded-counter only)
```

## Key Design Decisions

- **Hash computed server-side**: `rotate()` in `mutations.ts` computes the SHA-256 hash inside the component mutation (not the client) to ensure the hash always matches the old key's actual type/env.
- **Secret material generated server-side**: Both `create()` and `rotate()` generate lookupPrefix, secretHex, and hash inside the component mutation — never passed from client.
- **Auth boundary via ownerId**: All admin mutations (revoke, disable, enable, update, rotate, getUsage) require `ownerId` and assert it matches the key's owner before any state change.
- **No event table**: Audit trail via structured logging (`log.ts`), not a DB table. Eliminates unbounded growth, O(N) scans, and retention complexity.
- **No rate limiting**: Rate limiting is the integrator's responsibility at their HTTP layer with real caller context.
- **Namespace separation**: Mutations in `mutations.ts`, queries in `queries.ts` — enforced by `@vllnt/eslint-config/convex`.
- **No bare `v.any()`**: All uses aliased as `jsonValue` in `validators.ts`.
- **Prefix-indexed lookup**: Keys use an 8-char `lookupPrefix` for O(1) candidate lookup, then constant-time hash comparison.
- **No raw keys stored**: Only SHA-256 hashes persist. Raw keys are returned once at creation.
- **Input validation**: keyPrefix (`^[a-zA-Z0-9]+$`), env (`^[a-zA-Z0-9-]+$`), metadata (4KB), scopes (50), tags (20), gracePeriodMs (60s-30d).

## Development

Expand Down
99 changes: 44 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,33 @@

# @vllnt/convex-api-keys

Secure API key management as a [Convex component](https://docs.convex.dev/components). Create, validate, revoke, rotate, rate-limit, and track usage — all backed by battle-tested `@convex-dev/*` ecosystem components.
Secure API key management as a [Convex component](https://docs.convex.dev/components). Create, validate, revoke, rotate, and track usage — all with built-in auth boundaries and structured audit logging.

## Features

- **Secure by default** — SHA-256 hashed storage, constant-time comparison, prefix-indexed O(1) lookup
- **Secure by default** — SHA-256 hashed storage, constant-time comparison, prefix-indexed O(1) lookup, server-side secret generation
- **Auth boundary** — `ownerId` required on all admin mutations — prevents cross-tenant access
- **Key types** — `secret` and `publishable` keys with type-encoded prefixes
- **Finite-use keys** — `remaining` counter with atomic decrement (verification tokens, one-time-use)
- **Disable / Enable** — reversible pause without revoking
- **Rotation** — configurable grace period where both old and new keys are valid
- **Bulk revoke** — revoke all keys matching a tag in one call
- **Rotation** — configurable grace period (60s–30d) where both old and new keys are valid
- **Bulk revoke** — revoke all keys matching a tag (active, rotating, and disabled)
- **Tags & environments** — filter keys by tags and environment strings
- **Multi-tenant** — every query scoped by `ownerId`, no cross-tenant leakage
- **Usage tracking** — audit event log + per-key usage analytics
- **Extensible** — custom metadata, configurable prefix, event callbacks
- **Usage tracking** — per-key usage counter via `@convex-dev/sharded-counter`
- **Input validation** — keyPrefix/env charset, metadata size (4KB), scopes (50), tags (20)
- **Structured logging** — audit trail via structured logs (Convex dashboard)

## Architecture

```
Your App → @vllnt/convex-api-keys
├── @convex-dev/rate-limiter (per-key rate limiting)
├── @convex-dev/sharded-counter (high-throughput counters)
├── @convex-dev/aggregate (O(log n) analytics)
└── @convex-dev/crons (scheduled cleanup)
└── @convex-dev/sharded-counter (high-throughput usage counters)
```

You install one package. Child components are internal — they don't appear in your `convex.config.ts`.
You install one package. The child component is internal — it doesn't appear in your `convex.config.ts`.

> **Rate limiting** is your responsibility. Add `@convex-dev/rate-limiter` at your HTTP action/mutation layer where you have real caller context (IP, auth, plan tier). The component has zero caller context and cannot make informed rate-limit decisions.

## Installation

Expand All @@ -46,9 +47,6 @@ import apiKeys from "@vllnt/convex-api-keys/convex.config";
const app = defineApp();
app.use(apiKeys);

// Optional: multiple isolated instances
app.use(apiKeys, { name: "serviceKeys" });

export default app;
```

Expand All @@ -72,15 +70,16 @@ const apiKeys = new ApiKeys(components.apiKeys, {
const { key, keyId } = await apiKeys.create(ctx, {
name: "Production SDK Key",
ownerId: orgId,
type: "secret", // "secret" | "publishable"
type: "secret",
scopes: ["read:users", "write:orders"],
tags: ["sdk", "v2"],
env: "live", // any string
env: "live",
metadata: { plan: "enterprise" },
expiresAt: Date.now() + 90 * 24 * 60 * 60 * 1000,
remaining: 100000, // optional: finite-use
remaining: 100000,
});
// key = "myapp_secret_live_a1b2c3d4_<64-char-hex>"
// Secret material is generated server-side — never passed from client
```

### Validate a key
Expand All @@ -90,18 +89,17 @@ const result = await apiKeys.validate(ctx, { key: bearerToken });

if (!result.valid) {
// result.reason: "malformed" | "not_found" | "revoked" | "expired"
// | "exhausted" | "disabled" | "rate_limited"
// | "exhausted" | "disabled"
return new Response("Unauthorized", { status: 401 });
}

// result.valid === true
const { keyId, ownerId, scopes, tags, env, type, metadata, remaining } = result;
```

### List keys

```ts
const allKeys = await apiKeys.list(ctx, { ownerId: orgId });
const keys = await apiKeys.list(ctx, { ownerId: orgId });
const prodKeys = await apiKeys.list(ctx, { ownerId: orgId, env: "live" });
const taggedKeys = await apiKeys.listByTag(ctx, { ownerId: orgId, tag: "sdk" });
```
Expand All @@ -111,6 +109,7 @@ const taggedKeys = await apiKeys.listByTag(ctx, { ownerId: orgId, tag: "sdk" });
```ts
await apiKeys.update(ctx, {
keyId,
ownerId: orgId, // required — auth boundary
name: "Renamed Key",
scopes: ["read:users"],
tags: ["sdk", "v3"],
Expand All @@ -121,19 +120,16 @@ await apiKeys.update(ctx, {
### Disable / Enable

```ts
await apiKeys.disable(ctx, { keyId });
// validate() → { valid: false, reason: "disabled" }

await apiKeys.enable(ctx, { keyId });
// validate() → { valid: true, ... }
await apiKeys.disable(ctx, { keyId, ownerId: orgId });
await apiKeys.enable(ctx, { keyId, ownerId: orgId });
```

### Revoke

```ts
await apiKeys.revoke(ctx, { keyId });
await apiKeys.revoke(ctx, { keyId, ownerId: orgId });

// Bulk revoke by tag
// Bulk revoke by tag (catches active, rotating, and disabled keys)
await apiKeys.revokeByTag(ctx, { ownerId: orgId, tag: "compromised" });
```

Expand All @@ -142,31 +138,16 @@ await apiKeys.revokeByTag(ctx, { ownerId: orgId, tag: "compromised" });
```ts
const { newKey, newKeyId, oldKeyExpiresAt } = await apiKeys.rotate(ctx, {
keyId,
gracePeriodMs: 3600000, // 1 hour — both keys valid
ownerId: orgId, // required — auth boundary
gracePeriodMs: 3600000, // 1 hour — both keys valid (min 60s, max 30d)
});
```

### Usage analytics

```ts
const usage = await apiKeys.getUsage(ctx, {
keyId,
period: { start: startOfMonth, end: Date.now() },
});
// { total: 42000, remaining: 58000, lastUsedAt: 1711036800000 }
```

### Finite-use keys (verification tokens)

```ts
const { key } = await apiKeys.create(ctx, {
name: "Email Verification",
ownerId: userId,
remaining: 1,
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
});
// First validate: { valid: true, remaining: 0 }
// Second validate: { valid: false, reason: "exhausted" }
const usage = await apiKeys.getUsage(ctx, { keyId, ownerId: orgId });
// { total: 42000, remaining: 58000 }
```

## Key Format
Expand Down Expand Up @@ -195,16 +176,22 @@ create() → ACTIVE ──→ DISABLED (reversible via enable())
|--------|-----|-------------|
| `create(ctx, options)` | mutation | Create a new API key |
| `validate(ctx, { key })` | mutation | Validate and track usage |
| `revoke(ctx, { keyId })` | mutation | Permanently revoke a key |
| `revoke(ctx, { keyId, ownerId })` | mutation | Permanently revoke a key |
| `revokeByTag(ctx, { ownerId, tag })` | mutation | Bulk revoke by tag |
| `rotate(ctx, { keyId, gracePeriodMs? })` | mutation | Rotate with grace period |
| `list(ctx, { ownerId, env?, status? })` | query | List keys (no secrets exposed) |
| `listByTag(ctx, { ownerId, tag })` | query | Filter by tag |
| `update(ctx, { keyId, name?, scopes?, tags?, metadata? })` | mutation | Update metadata in-place |
| `disable(ctx, { keyId })` | mutation | Temporarily disable |
| `enable(ctx, { keyId })` | mutation | Re-enable disabled key |
| `getUsage(ctx, { keyId, period? })` | query | Usage analytics |
| `configure(ctx, { ... })` | mutation | Runtime config |
| `rotate(ctx, { keyId, ownerId, gracePeriodMs? })` | mutation | Rotate with grace period |
| `list(ctx, { ownerId, env?, status?, limit? })` | query | List keys (paginated, default 100) |
| `listByTag(ctx, { ownerId, tag, limit? })` | query | Filter by tag |
| `update(ctx, { keyId, ownerId, name?, ... })` | mutation | Update metadata in-place |
| `disable(ctx, { keyId, ownerId })` | mutation | Temporarily disable |
| `enable(ctx, { keyId, ownerId })` | mutation | Re-enable disabled key |
| `getUsage(ctx, { keyId, ownerId })` | query | Usage counter (O(1)) |
| `configure(ctx, { ... })` | mutation | Runtime config (admin-only) |

## Security Model

This component protects against **accidental cross-tenant bugs in honest host apps**. The `ownerId` check prevents a bug from operating on another tenant's keys — it does NOT prevent a compromised host app from passing a forged `ownerId`.

Integrators must derive `ownerId` from their own auth layer (e.g., `ctx.auth.getUserIdentity()`) before passing it to the component.

## Testing

Expand All @@ -213,9 +200,11 @@ For testing with `convex-test`:
```ts
import { convexTest } from "convex-test";
import { register } from "@vllnt/convex-api-keys/test";
import shardedCounterTest from "@convex-dev/sharded-counter/test";

const t = convexTest(schema, modules);
register(t, "apiKeys");
shardedCounterTest.register(t, "apiKeys/shardedCounter");
```

## Contributing
Expand Down
Loading
Loading