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.
- package/README.md +3 -0
- package/bin/devlyn.js +3 -0
- package/optional-skills/better-auth-setup/SKILL.md +450 -0
- package/optional-skills/better-auth-setup/references/api-keys.md +236 -0
- package/optional-skills/better-auth-setup/references/config-and-entry.md +239 -0
- package/optional-skills/better-auth-setup/references/middleware.md +409 -0
- package/optional-skills/better-auth-setup/references/schema.md +224 -0
- package/optional-skills/better-auth-setup/references/testing.md +241 -0
- package/optional-skills/generate-skill/CHECKLIST.md +60 -0
- package/optional-skills/generate-skill/PROMPT-PATTERNS.md +370 -0
- package/optional-skills/generate-skill/REFERENCE.md +195 -0
- package/optional-skills/generate-skill/SKILL.md +178 -0
- package/package.json +1 -1
|
@@ -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.
|