devlyn-cli 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,409 @@
1
+ # Auth & Tenant Context Middleware Reference
2
+
3
+ Complete implementations for the dual-path auth middleware and tenant context middleware.
4
+
5
+ ## Table of Contents
6
+ 1. [Types](#types)
7
+ 2. [Auth Middleware](#auth-middleware)
8
+ 3. [Tenant Context Middleware](#tenant-context-middleware)
9
+ 4. [API Key Utilities](#api-key-utilities)
10
+ 5. [Error Types](#error-types)
11
+
12
+ ## Types
13
+
14
+ Define these types in a shared `types.ts` file. They're used across middleware, routes, and tests.
15
+
16
+ ```typescript
17
+ // src/types.ts
18
+
19
+ // API key auth — resolved from Authorization: Bearer pyx_...
20
+ export type ApiKeyAuthContext = {
21
+ type: "api_key";
22
+ apiKeyId: string;
23
+ projectId: string;
24
+ organizationId: string;
25
+ };
26
+
27
+ // Session auth — resolved from Better Auth session cookie
28
+ export type SessionAuthContext = {
29
+ type: "session";
30
+ userId: string;
31
+ sessionId: string;
32
+ };
33
+
34
+ // Union type — the auth middleware sets one of these
35
+ export type AuthContext = ApiKeyAuthContext | SessionAuthContext;
36
+
37
+ // Tenant context — resolved after auth
38
+ export type TenantContext = {
39
+ organizationId: string;
40
+ projectId: string | null; // null for session auth (no project scope)
41
+ userId: string | null; // null for API key auth (no user scope)
42
+ plan: string; // e.g., "free", "pro", "team", "enterprise"
43
+ };
44
+
45
+ // Hono app environment — tells Hono what variables are available
46
+ export type AppEnv = {
47
+ Variables: {
48
+ requestId: string;
49
+ auth: AuthContext;
50
+ tenant: TenantContext;
51
+ };
52
+ };
53
+ ```
54
+
55
+ ## Auth Middleware
56
+
57
+ The auth middleware supports two authentication paths in a single middleware function. This avoids route-level conditional logic and keeps auth centralized.
58
+
59
+ ```typescript
60
+ // src/middleware/auth.ts
61
+ import { eq } from "drizzle-orm";
62
+ import { createMiddleware } from "hono/factory";
63
+ import { db } from "../db";
64
+ import { apiKeys, projects } from "../db/schema";
65
+ import { API_KEY_PREFIX, hashKey } from "../lib/api-keys";
66
+ import { auth } from "../lib/auth";
67
+ import { AuthError } from "../lib/errors";
68
+ import { logger } from "../lib/logger";
69
+ import type { AuthContext } from "../types";
70
+
71
+ export const authMiddleware = createMiddleware<{
72
+ Variables: { auth: AuthContext };
73
+ }>(async (c, next) => {
74
+ const authHeader = c.req.header("Authorization");
75
+
76
+ // Path 1: API key authentication (Bearer pyx_...)
77
+ if (authHeader?.startsWith(`Bearer ${API_KEY_PREFIX}`)) {
78
+ const rawKey = authHeader.slice("Bearer ".length);
79
+ const authContext = await validateApiKey(rawKey);
80
+ c.set("auth", authContext);
81
+ return next();
82
+ }
83
+
84
+ // Path 2: Non-pyx Bearer token — reject with clear guidance
85
+ // Without this check, random Bearer tokens would fall through to session
86
+ // auth and silently fail, confusing the developer.
87
+ if (authHeader?.startsWith("Bearer ")) {
88
+ throw new AuthError("Invalid API key format. Keys must start with 'pyx_'.");
89
+ }
90
+
91
+ // Path 3: Session cookie (Better Auth)
92
+ // IMPORTANT: Pass c.req.raw.headers (the raw Request headers),
93
+ // NOT c.req.header() — Better Auth needs the full Headers object.
94
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
95
+
96
+ // GOTCHA: Better Auth returns null (not { session: null }) for invalid sessions.
97
+ // Always check session?.user, not session.user.
98
+ if (session?.user) {
99
+ c.set("auth", {
100
+ type: "session",
101
+ userId: session.user.id,
102
+ sessionId: session.session.id,
103
+ });
104
+ return next();
105
+ }
106
+
107
+ // No valid auth found
108
+ throw new AuthError();
109
+ });
110
+
111
+ async function validateApiKey(rawKey: string): Promise<AuthContext> {
112
+ const keyHash = await hashKey(rawKey);
113
+
114
+ // Single query: look up key + join project for org info
115
+ const result = await db
116
+ .select({
117
+ keyId: apiKeys.id,
118
+ projectId: apiKeys.projectId,
119
+ revokedAt: apiKeys.revokedAt,
120
+ expiresAt: apiKeys.expiresAt,
121
+ orgId: projects.organizationId,
122
+ })
123
+ .from(apiKeys)
124
+ .innerJoin(projects, eq(apiKeys.projectId, projects.id))
125
+ .where(eq(apiKeys.keyHash, keyHash))
126
+ .limit(1);
127
+
128
+ if (result.length === 0) {
129
+ // SECURITY: Generic error — don't reveal whether key exists.
130
+ // An attacker probing keys should see the same error for
131
+ // "key not found" and "key is revoked."
132
+ throw new AuthError();
133
+ }
134
+
135
+ const key = result[0];
136
+
137
+ // Check revocation
138
+ if (key.revokedAt) {
139
+ throw new AuthError();
140
+ }
141
+
142
+ // Check expiration
143
+ if (key.expiresAt && key.expiresAt < new Date()) {
144
+ throw new AuthError();
145
+ }
146
+
147
+ // Fire-and-forget: update lastUsedAt without blocking the request.
148
+ // If this fails, the request still succeeds — usage tracking is
149
+ // nice-to-have, not critical path.
150
+ db.update(apiKeys)
151
+ .set({ lastUsedAt: new Date() })
152
+ .where(eq(apiKeys.id, key.keyId))
153
+ .execute()
154
+ .catch((err) => {
155
+ logger.warn({ error: err, apiKeyId: key.keyId }, "Failed to update API key lastUsedAt");
156
+ });
157
+
158
+ return {
159
+ type: "api_key",
160
+ apiKeyId: key.keyId,
161
+ projectId: key.projectId,
162
+ organizationId: key.orgId,
163
+ };
164
+ }
165
+ ```
166
+
167
+ ### Design Decisions
168
+
169
+ 1. **Single middleware, two paths** — Keeps auth logic centralized. Routes don't need to know which auth method was used.
170
+
171
+ 2. **Explicit non-pyx Bearer rejection** — Without this, a developer using a random JWT would get a generic "auth required" error after session validation fails. The explicit rejection saves debugging time.
172
+
173
+ 3. **Generic AuthError for all key failures** — Whether the key doesn't exist, is revoked, or is expired, the error is the same. This prevents key enumeration attacks.
174
+
175
+ 4. **Fire-and-forget `lastUsedAt`** — The `.catch()` prevents unhandled promise rejections. The request isn't delayed by a non-critical DB write.
176
+
177
+ 5. **Inner join for org resolution** — A single query fetches the key and its project's org. No second DB round-trip needed.
178
+
179
+ ## Tenant Context Middleware
180
+
181
+ Runs after auth middleware. Resolves the organization, project (if applicable), and plan for the authenticated entity.
182
+
183
+ ```typescript
184
+ // src/middleware/tenant-context.ts
185
+ import { desc, eq } from "drizzle-orm";
186
+ import { createMiddleware } from "hono/factory";
187
+ import { db } from "../db";
188
+ import { orgMemberships, organizations } from "../db/schema";
189
+ import { AppError, AuthError, ForbiddenError } from "../lib/errors";
190
+ import type { AuthContext, TenantContext } from "../types";
191
+
192
+ export const tenantContextMiddleware = createMiddleware<{
193
+ Variables: { auth: AuthContext; tenant: TenantContext };
194
+ }>(async (c, next) => {
195
+ const authCtx = c.get("auth");
196
+
197
+ if (!authCtx) {
198
+ throw new AuthError();
199
+ }
200
+
201
+ let tenant: TenantContext;
202
+
203
+ if (authCtx.type === "api_key") {
204
+ // API key auth: org already resolved in auth middleware, just fetch plan
205
+ const org = await db
206
+ .select({ plan: organizations.plan })
207
+ .from(organizations)
208
+ .where(eq(organizations.id, authCtx.organizationId))
209
+ .limit(1);
210
+
211
+ if (org.length === 0) {
212
+ throw new ForbiddenError();
213
+ }
214
+
215
+ tenant = {
216
+ organizationId: authCtx.organizationId,
217
+ projectId: authCtx.projectId,
218
+ userId: null,
219
+ plan: org[0].plan,
220
+ };
221
+ } else {
222
+ // Session auth: look up user's org membership
223
+ // For multi-org users, picks most recent membership.
224
+ // TODO: Support explicit org selection via X-Organization-Id header.
225
+ const membership = await db
226
+ .select({
227
+ orgId: orgMemberships.organizationId,
228
+ plan: organizations.plan,
229
+ })
230
+ .from(orgMemberships)
231
+ .innerJoin(organizations, eq(orgMemberships.organizationId, organizations.id))
232
+ .where(eq(orgMemberships.userId, authCtx.userId))
233
+ .orderBy(desc(orgMemberships.createdAt))
234
+ .limit(1);
235
+
236
+ if (membership.length === 0) {
237
+ // LESSON: Use a distinct error code, not a generic ForbiddenError.
238
+ // The frontend needs to differentiate "you don't have permission" from
239
+ // "you need to create/join an organization." With a generic 403, the
240
+ // frontend can't show helpful UX like "Create your first organization."
241
+ throw new AppError("no_organization", "No organization membership found", 403);
242
+ }
243
+
244
+ tenant = {
245
+ organizationId: membership[0].orgId,
246
+ projectId: null,
247
+ userId: authCtx.userId,
248
+ plan: membership[0].plan,
249
+ };
250
+ }
251
+
252
+ c.set("tenant", tenant);
253
+ return next();
254
+ });
255
+ ```
256
+
257
+ ### Design Decisions
258
+
259
+ 1. **Separate from auth middleware** — Auth validates identity. Tenant context resolves authorization scope. Keeping them separate makes each testable independently.
260
+
261
+ 2. **API key already has org** — The auth middleware resolves projectId and organizationId during key validation (via the projects join). No extra DB query needed for the org ID — only one query to fetch the plan.
262
+
263
+ 3. **Most-recent membership for multi-org** — A temporary strategy until `X-Organization-Id` header support is added. Ordered by `createdAt DESC` so the auto-created personal org (from signup) is the default.
264
+
265
+ 4. **Distinct `no_organization` error code** — Enables the frontend to show contextual UX. A generic `forbidden` error gives no indication of what's wrong.
266
+
267
+ ## API Key Utilities
268
+
269
+ Shared constants and functions for key generation and validation.
270
+
271
+ ```typescript
272
+ // src/lib/api-keys.ts
273
+
274
+ // All API keys start with this prefix. Used for:
275
+ // 1. Quick identification in auth middleware
276
+ // 2. User-visible hint in the dashboard
277
+ // 3. Preventing confusion with other Bearer tokens
278
+ export const API_KEY_PREFIX = "pyx_";
279
+
280
+ // Base62 encoding (alphanumeric only, no special chars)
281
+ const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
282
+
283
+ export function base62Encode(bytes: Uint8Array): string {
284
+ let result = "";
285
+ for (const byte of bytes) {
286
+ result += BASE62_CHARS[byte % 62];
287
+ }
288
+ return result;
289
+ }
290
+
291
+ // SHA-256 hash for storage — uses Web Crypto API (available in Bun and browsers)
292
+ const encoder = new TextEncoder();
293
+
294
+ export async function hashKey(rawKey: string): Promise<string> {
295
+ const data = encoder.encode(rawKey);
296
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
297
+ return Buffer.from(hashBuffer).toString("hex");
298
+ }
299
+ ```
300
+
301
+ ### Key Generation (in route handler)
302
+
303
+ ```typescript
304
+ // Generate cryptographically secure key
305
+ const randomBytes = crypto.getRandomValues(new Uint8Array(32));
306
+ const rawKey = API_KEY_PREFIX + base62Encode(randomBytes);
307
+
308
+ // Hash for storage — plaintext NEVER stored
309
+ const keyHash = await hashKey(rawKey);
310
+ const keyPrefix = rawKey.slice(0, 12); // Visible hint: "pyx_AbCdEfGh"
311
+
312
+ // Store hash + prefix in database
313
+ await db.insert(apiKeys).values({
314
+ projectId,
315
+ name,
316
+ keyHash,
317
+ keyPrefix,
318
+ expiresAt: expiresAt ? new Date(expiresAt) : null,
319
+ });
320
+
321
+ // Return plaintext to user — they'll never see it again
322
+ return c.json({ key: rawKey, hint: `${keyPrefix}...` }, 201);
323
+ ```
324
+
325
+ ## Error Types
326
+
327
+ ```typescript
328
+ // src/lib/errors.ts
329
+
330
+ export class AppError extends Error {
331
+ constructor(
332
+ public code: string,
333
+ message: string,
334
+ public statusCode: number = 500,
335
+ public details?: Record<string, unknown>
336
+ ) {
337
+ super(message);
338
+ }
339
+ }
340
+
341
+ export class AuthError extends AppError {
342
+ constructor(message = "Authentication required") {
343
+ super("auth_required", message, 401);
344
+ }
345
+ }
346
+
347
+ export class ForbiddenError extends AppError {
348
+ constructor(message = "Insufficient permissions") {
349
+ super("forbidden", message, 403);
350
+ }
351
+ }
352
+
353
+ export class NotFoundError extends AppError {
354
+ constructor(message = "Resource not found") {
355
+ super("not_found", message, 404);
356
+ }
357
+ }
358
+
359
+ export class ValidationError extends AppError {
360
+ constructor(message: string, details?: Record<string, unknown>) {
361
+ super("validation_error", message, 422, details);
362
+ }
363
+ }
364
+
365
+ export class RateLimitError extends AppError {
366
+ constructor(retryAfter: number) {
367
+ super("rate_limited", "Too many requests", 429, { retryAfter });
368
+ }
369
+ }
370
+ ```
371
+
372
+ ### Error Handler Middleware
373
+
374
+ ```typescript
375
+ // src/middleware/error-handler.ts
376
+ import type { ErrorHandler } from "hono";
377
+ import { AppError } from "../lib/errors";
378
+ import { config } from "../config";
379
+
380
+ export const errorHandler: ErrorHandler = (err, c) => {
381
+ const requestId = c.get("requestId") ?? "unknown";
382
+
383
+ if (err instanceof AppError) {
384
+ return c.json({
385
+ error: {
386
+ code: err.code,
387
+ message: err.message,
388
+ ...(err.details && { details: err.details }),
389
+ },
390
+ requestId,
391
+ }, err.statusCode as any);
392
+ }
393
+
394
+ // Unhandled error — mask in production
395
+ const message = config.NODE_ENV === "production"
396
+ ? "Internal server error"
397
+ : err.message;
398
+
399
+ console.error("Unhandled error:", err);
400
+
401
+ return c.json({
402
+ error: {
403
+ code: "internal_error",
404
+ message,
405
+ },
406
+ requestId,
407
+ }, 500);
408
+ };
409
+ ```
@@ -0,0 +1,224 @@
1
+ # Database Schema Reference
2
+
3
+ Complete Drizzle ORM schema for Better Auth with multi-tenant organization support, API keys, and usage tracking.
4
+
5
+ ## Table of Contents
6
+ 1. [Auth Tables](#auth-tables) — users, sessions, accounts, verifications
7
+ 2. [Organization Tables](#organization-tables) — organizations, memberships, invitations
8
+ 3. [Project & API Key Tables](#project--api-key-tables) — projects, api_keys
9
+ 4. [Supporting Tables](#supporting-tables) — usage, billing, webhooks
10
+
11
+ ## Auth Tables
12
+
13
+ These tables are managed by Better Auth. Define them in Drizzle with the exact columns Better Auth expects, using your preferred table names (you'll map them in the adapter config).
14
+
15
+ ```typescript
16
+ import {
17
+ boolean, index, pgTable, text, timestamp,
18
+ uniqueIndex, uuid, varchar,
19
+ } from "drizzle-orm/pg-core";
20
+
21
+ // Users — core identity table
22
+ export const users = pgTable("users", {
23
+ id: uuid("id").primaryKey().defaultRandom(),
24
+ name: varchar("name", { length: 255 }),
25
+ email: varchar("email", { length: 255 }).notNull().unique(),
26
+ emailVerified: boolean("email_verified").notNull().default(false),
27
+ image: text("image"),
28
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
29
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
30
+ });
31
+
32
+ // Sessions — tracks active login sessions
33
+ export const sessions = pgTable(
34
+ "sessions",
35
+ {
36
+ id: uuid("id").primaryKey().defaultRandom(),
37
+ userId: uuid("user_id")
38
+ .notNull()
39
+ .references(() => users.id, { onDelete: "cascade" }),
40
+ token: varchar("token", { length: 255 }).notNull().unique(),
41
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
42
+ ipAddress: varchar("ip_address", { length: 45 }),
43
+ userAgent: text("user_agent"),
44
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
45
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
46
+ },
47
+ (table) => [index("idx_sessions_user_id").on(table.userId)],
48
+ );
49
+
50
+ // Accounts — OAuth and credential accounts linked to users
51
+ export const accounts = pgTable(
52
+ "accounts",
53
+ {
54
+ id: uuid("id").primaryKey().defaultRandom(),
55
+ userId: uuid("user_id")
56
+ .notNull()
57
+ .references(() => users.id, { onDelete: "cascade" }),
58
+ accountId: varchar("account_id", { length: 255 }).notNull(),
59
+ providerId: varchar("provider_id", { length: 255 }).notNull(),
60
+ accessToken: text("access_token"),
61
+ refreshToken: text("refresh_token"),
62
+ accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
63
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
64
+ scope: text("scope"),
65
+ password: text("password"),
66
+ idToken: text("id_token"),
67
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
68
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
69
+ },
70
+ (table) => [
71
+ index("idx_accounts_user_id").on(table.userId),
72
+ uniqueIndex("idx_accounts_provider").on(table.providerId, table.accountId),
73
+ ],
74
+ );
75
+
76
+ // Verifications — email verification and password reset tokens
77
+ export const verifications = pgTable(
78
+ "verifications",
79
+ {
80
+ id: uuid("id").primaryKey().defaultRandom(),
81
+ identifier: text("identifier").notNull(),
82
+ value: text("value").notNull(),
83
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
84
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
85
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
86
+ },
87
+ (table) => [index("idx_verifications_identifier").on(table.identifier)],
88
+ );
89
+ ```
90
+
91
+ ### Key Design Decisions
92
+
93
+ - **UUIDs everywhere** — Better Auth supports UUID primary keys via `defaultRandom()`. This avoids sequential ID enumeration attacks.
94
+ - **Cascade deletes on sessions/accounts** — When a user is deleted, their sessions and OAuth accounts are automatically cleaned up.
95
+ - **Unique constraint on (providerId, accountId)** — Prevents duplicate OAuth account links.
96
+ - **Index on verifications.identifier** — Email lookups during verification need to be fast.
97
+ - **`withTimezone: true`** — All timestamps stored in UTC. Prevents timezone bugs in multi-region deployments.
98
+
99
+ ## Organization Tables
100
+
101
+ Multi-tenant support with role-based membership.
102
+
103
+ ```typescript
104
+ export const organizations = pgTable(
105
+ "organizations",
106
+ {
107
+ id: uuid("id").primaryKey().defaultRandom(),
108
+ name: varchar("name", { length: 255 }).notNull(),
109
+ slug: varchar("slug", { length: 100 }).notNull().unique(),
110
+ logo: text("logo"),
111
+ metadata: text("metadata"),
112
+ plan: varchar("plan", { length: 50 }).notNull().default("free"),
113
+ // Billing provider fields (Stripe/Polar/etc.)
114
+ billingCustomerId: varchar("billing_customer_id", { length: 255 }),
115
+ billingSubscriptionId: varchar("billing_subscription_id", { length: 255 }),
116
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
117
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
118
+ },
119
+ (table) => [index("idx_organizations_billing").on(table.billingCustomerId)],
120
+ );
121
+
122
+ export const orgMemberships = pgTable(
123
+ "org_memberships",
124
+ {
125
+ id: uuid("id").primaryKey().defaultRandom(),
126
+ organizationId: uuid("organization_id")
127
+ .notNull()
128
+ .references(() => organizations.id, { onDelete: "cascade" }),
129
+ userId: uuid("user_id")
130
+ .notNull()
131
+ .references(() => users.id, { onDelete: "cascade" }),
132
+ role: varchar("role", { length: 50 }).notNull().default("member"),
133
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
134
+ },
135
+ (table) => [
136
+ uniqueIndex("idx_org_memberships_unique").on(table.organizationId, table.userId),
137
+ index("idx_org_memberships_user").on(table.userId),
138
+ index("idx_org_memberships_org").on(table.organizationId),
139
+ ],
140
+ );
141
+
142
+ export const invitations = pgTable(
143
+ "invitations",
144
+ {
145
+ id: uuid("id").primaryKey().defaultRandom(),
146
+ email: varchar("email", { length: 255 }).notNull(),
147
+ inviterId: uuid("inviter_id")
148
+ .notNull()
149
+ .references(() => users.id, { onDelete: "cascade" }),
150
+ organizationId: uuid("organization_id")
151
+ .notNull()
152
+ .references(() => organizations.id, { onDelete: "cascade" }),
153
+ role: varchar("role", { length: 50 }).notNull().default("member"),
154
+ status: varchar("status", { length: 50 }).notNull().default("pending"),
155
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
156
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
157
+ },
158
+ (table) => [
159
+ index("idx_invitations_org").on(table.organizationId),
160
+ index("idx_invitations_email").on(table.email),
161
+ ],
162
+ );
163
+ ```
164
+
165
+ ### Key Design Decisions
166
+
167
+ - **Unique (org, user) membership** — A user can only have one role per org.
168
+ - **Plan on organization** — Billing is org-level, not user-level. This simplifies plan enforcement.
169
+ - **Slug is unique** — Used in URLs (`/orgs/my-org`). Auto-generated with UUID suffix to prevent collisions.
170
+
171
+ ## Project & API Key Tables
172
+
173
+ Projects scope resources within an organization. API keys are scoped to projects.
174
+
175
+ ```typescript
176
+ export const projects = pgTable(
177
+ "projects",
178
+ {
179
+ id: uuid("id").primaryKey().defaultRandom(),
180
+ organizationId: uuid("organization_id")
181
+ .notNull()
182
+ .references(() => organizations.id, { onDelete: "cascade" }),
183
+ name: varchar("name", { length: 255 }).notNull(),
184
+ slug: varchar("slug", { length: 100 }).notNull(),
185
+ description: text("description"),
186
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
187
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
188
+ },
189
+ (table) => [
190
+ uniqueIndex("idx_projects_org_slug").on(table.organizationId, table.slug),
191
+ index("idx_projects_org").on(table.organizationId),
192
+ ],
193
+ );
194
+
195
+ export const apiKeys = pgTable(
196
+ "api_keys",
197
+ {
198
+ id: uuid("id").primaryKey().defaultRandom(),
199
+ projectId: uuid("project_id")
200
+ .notNull()
201
+ .references(() => projects.id, { onDelete: "cascade" }),
202
+ name: varchar("name", { length: 255 }).notNull(),
203
+ keyHash: varchar("key_hash", { length: 255 }).notNull(),
204
+ keyPrefix: varchar("key_prefix", { length: 12 }).notNull(),
205
+ lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
206
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
207
+ revokedAt: timestamp("revoked_at", { withTimezone: true }),
208
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
209
+ },
210
+ (table) => [
211
+ index("idx_api_keys_project").on(table.projectId),
212
+ uniqueIndex("idx_api_keys_hash").on(table.keyHash),
213
+ index("idx_api_keys_prefix").on(table.keyPrefix),
214
+ ],
215
+ );
216
+ ```
217
+
218
+ ### Key Design Decisions
219
+
220
+ - **Project slug unique per org** — Not globally unique, scoped to organization via composite unique index.
221
+ - **keyHash unique** — Enables O(1) lookup during API key validation.
222
+ - **Soft-delete via `revokedAt`** — Revoked keys remain in the database for audit trail. The auth middleware checks `revokedAt !== null` to reject them.
223
+ - **`lastUsedAt` nullable** — Updated fire-and-forget on each API key auth. Useful for usage analytics and stale key detection.
224
+ - **No plaintext key column** — The plaintext is generated, returned to the user once, and only the SHA-256 hash is stored. This is a security requirement, not an optimization.