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,236 @@
1
+ # API Key System Reference
2
+
3
+ Complete implementation for a custom API key system alongside Better Auth session cookies.
4
+
5
+ ## Why Custom API Keys Instead of Better Auth's Plugin
6
+
7
+ Better Auth has an API key plugin, but building your own gives you:
8
+ - Full control over key format and prefix (e.g., `pyx_` for easy identification)
9
+ - Project-scoped keys (Better Auth's plugin scopes to users)
10
+ - Soft-delete with revocation timestamps (audit trail)
11
+ - Fire-and-forget usage tracking (`lastUsedAt`)
12
+ - Custom expiration logic
13
+
14
+ If your needs are simpler (user-scoped keys, no project hierarchy), Better Auth's built-in plugin works fine.
15
+
16
+ ## Key Generation Utilities
17
+
18
+ ```typescript
19
+ // src/lib/api-keys.ts
20
+
21
+ // Prefix makes keys instantly recognizable and enables fast auth middleware routing.
22
+ // Choose something unique to your product (e.g., "sk_", "pk_", "myapp_").
23
+ export const API_KEY_PREFIX = "pyx_";
24
+
25
+ // Base62: alphanumeric only, no special chars.
26
+ // Safe in URLs, headers, and JSON without encoding.
27
+ const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
28
+
29
+ export function base62Encode(bytes: Uint8Array): string {
30
+ let result = "";
31
+ for (const byte of bytes) {
32
+ result += BASE62_CHARS[byte % 62];
33
+ }
34
+ return result;
35
+ }
36
+
37
+ // SHA-256 hash for storage.
38
+ // Web Crypto API is available in Bun, Deno, Cloudflare Workers, and browsers.
39
+ const encoder = new TextEncoder();
40
+
41
+ export async function hashKey(rawKey: string): Promise<string> {
42
+ const data = encoder.encode(rawKey);
43
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
44
+ return Buffer.from(hashBuffer).toString("hex");
45
+ }
46
+ ```
47
+
48
+ ## API Key Routes
49
+
50
+ Three endpoints: create, list, and revoke.
51
+
52
+ ```typescript
53
+ // src/routes/api-keys.ts
54
+ import { and, count, eq } from "drizzle-orm";
55
+ import { Hono } from "hono";
56
+ import { z } from "zod";
57
+ import { db } from "../db";
58
+ import { apiKeys, projects } from "../db/schema";
59
+ import { API_KEY_PREFIX, base62Encode, hashKey } from "../lib/api-keys";
60
+ import { ForbiddenError, NotFoundError } from "../lib/errors";
61
+ import type { AppEnv } from "../types";
62
+
63
+ const apiKeyRoutes = new Hono<AppEnv>();
64
+
65
+ // How many chars of the key to show in listings (e.g., "pyx_AbCdEfGh...")
66
+ const API_KEY_PREFIX_LENGTH = 12;
67
+
68
+ const createKeySchema = z.object({
69
+ name: z.string().min(1).max(255),
70
+ projectId: z.string().uuid(),
71
+ expiresAt: z
72
+ .string()
73
+ .datetime()
74
+ .refine((date) => new Date(date) > new Date(), "Expiration date must be in the future")
75
+ .nullable()
76
+ .optional(),
77
+ });
78
+
79
+ // ─── CREATE ──────────────────────────────────────────────
80
+ // POST /v1/api-keys
81
+ // Returns the plaintext key ONLY in this response.
82
+ apiKeyRoutes.post("/", async (c) => {
83
+ const tenant = c.get("tenant");
84
+ const body = await c.req.json();
85
+ const { name, projectId, expiresAt } = createKeySchema.parse(body);
86
+
87
+ // Verify project belongs to the tenant's organization.
88
+ // This prevents a user in Org A from creating a key for a project in Org B.
89
+ const project = await db
90
+ .select({ id: projects.id, organizationId: projects.organizationId })
91
+ .from(projects)
92
+ .where(eq(projects.id, projectId))
93
+ .limit(1);
94
+
95
+ if (project.length === 0) {
96
+ throw new NotFoundError("Project not found");
97
+ }
98
+
99
+ if (project[0].organizationId !== tenant.organizationId) {
100
+ throw new ForbiddenError();
101
+ }
102
+
103
+ // Generate cryptographically secure key
104
+ const randomBytes = crypto.getRandomValues(new Uint8Array(32));
105
+ const rawKey = API_KEY_PREFIX + base62Encode(randomBytes);
106
+
107
+ // Hash for storage — plaintext NEVER persisted
108
+ const keyHash = await hashKey(rawKey);
109
+ const keyPrefix = rawKey.slice(0, API_KEY_PREFIX_LENGTH);
110
+
111
+ const [inserted] = await db
112
+ .insert(apiKeys)
113
+ .values({
114
+ projectId,
115
+ name,
116
+ keyHash,
117
+ keyPrefix,
118
+ expiresAt: expiresAt ? new Date(expiresAt) : null,
119
+ })
120
+ .returning({
121
+ id: apiKeys.id,
122
+ createdAt: apiKeys.createdAt,
123
+ });
124
+
125
+ return c.json(
126
+ {
127
+ id: inserted.id,
128
+ key: rawKey, // Plaintext — shown once, never again
129
+ name,
130
+ projectId,
131
+ keyPrefix,
132
+ createdAt: inserted.createdAt,
133
+ expiresAt: expiresAt ?? null,
134
+ },
135
+ 201,
136
+ );
137
+ });
138
+
139
+ // ─── LIST ────────────────────────────────────────────────
140
+ // GET /v1/api-keys?projectId=...&limit=20&offset=0
141
+ // Keys are masked — only prefix shown.
142
+ apiKeyRoutes.get("/", async (c) => {
143
+ const tenant = c.get("tenant");
144
+ const projectId = c.req.query("projectId");
145
+ const limit = Math.min(Number(c.req.query("limit") || 20), 100);
146
+ const offset = Math.max(Number(c.req.query("offset") || 0), 0);
147
+
148
+ const conditions = [eq(projects.organizationId, tenant.organizationId)];
149
+ if (projectId) {
150
+ conditions.push(eq(apiKeys.projectId, projectId));
151
+ }
152
+
153
+ const whereClause = and(...conditions);
154
+
155
+ const [items, [{ total }]] = await Promise.all([
156
+ db
157
+ .select({
158
+ id: apiKeys.id,
159
+ name: apiKeys.name,
160
+ keyPrefix: apiKeys.keyPrefix,
161
+ projectId: apiKeys.projectId,
162
+ lastUsedAt: apiKeys.lastUsedAt,
163
+ expiresAt: apiKeys.expiresAt,
164
+ revokedAt: apiKeys.revokedAt,
165
+ createdAt: apiKeys.createdAt,
166
+ })
167
+ .from(apiKeys)
168
+ .innerJoin(projects, eq(apiKeys.projectId, projects.id))
169
+ .where(whereClause)
170
+ .limit(limit)
171
+ .offset(offset),
172
+ db
173
+ .select({ total: count() })
174
+ .from(apiKeys)
175
+ .innerJoin(projects, eq(apiKeys.projectId, projects.id))
176
+ .where(whereClause),
177
+ ]);
178
+
179
+ return c.json({
180
+ keys: items.map((k) => ({
181
+ id: k.id,
182
+ name: k.name,
183
+ hint: `${k.keyPrefix}...`,
184
+ projectId: k.projectId,
185
+ createdAt: k.createdAt,
186
+ expiresAt: k.expiresAt,
187
+ lastUsedAt: k.lastUsedAt,
188
+ revoked: k.revokedAt !== null,
189
+ })),
190
+ total,
191
+ hasMore: offset + items.length < total,
192
+ });
193
+ });
194
+
195
+ // ─── REVOKE ──────────────────────────────────────────────
196
+ // DELETE /v1/api-keys/:id
197
+ // Soft delete via revokedAt timestamp.
198
+ apiKeyRoutes.delete("/:id", async (c) => {
199
+ const tenant = c.get("tenant");
200
+ const keyId = c.req.param("id");
201
+
202
+ // Verify key exists AND belongs to tenant's org (single query).
203
+ // This prevents ID enumeration — an attacker can't tell whether
204
+ // a key exists in another org or doesn't exist at all.
205
+ const key = await db
206
+ .select({ id: apiKeys.id })
207
+ .from(apiKeys)
208
+ .innerJoin(projects, eq(apiKeys.projectId, projects.id))
209
+ .where(and(eq(apiKeys.id, keyId), eq(projects.organizationId, tenant.organizationId)))
210
+ .limit(1);
211
+
212
+ if (key.length === 0) {
213
+ throw new NotFoundError("API key not found");
214
+ }
215
+
216
+ await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, keyId));
217
+
218
+ return c.body(null, 204);
219
+ });
220
+
221
+ export { apiKeyRoutes };
222
+ ```
223
+
224
+ ## Security Considerations
225
+
226
+ 1. **Hash-only storage** — The plaintext key is never written to the database. If the DB is compromised, the hashes are useless without the original keys.
227
+
228
+ 2. **Generic errors** — Auth failures for non-existent, revoked, and expired keys all return the same 401 error. This prevents an attacker from distinguishing between these states.
229
+
230
+ 3. **Tenant isolation on revoke** — The DELETE query joins through projects to verify org ownership. Without this, a user could revoke keys in other organizations by guessing UUIDs.
231
+
232
+ 4. **No plaintext in logs** — The key is returned in the HTTP response body but never logged. The `keyPrefix` (first 12 chars) is safe to log and display.
233
+
234
+ 5. **Base62 encoding** — Produces URL-safe keys without special characters. No need for URL encoding in headers or query params.
235
+
236
+ 6. **32 bytes of randomness** — Produces ~190 bits of entropy. Brute-forcing is computationally infeasible.
@@ -0,0 +1,239 @@
1
+ # Config, Error Types, and Entry Point Reference
2
+
3
+ Environment configuration, error hierarchy, and Hono entry point wiring for Better Auth.
4
+
5
+ ## Table of Contents
6
+ 1. [Environment Config](#environment-config)
7
+ 2. [Error Types](#error-types)
8
+ 3. [Error Handler Middleware](#error-handler-middleware)
9
+ 4. [Entry Point](#entry-point)
10
+ 5. [Middleware Order](#middleware-order)
11
+
12
+ ## Environment Config
13
+
14
+ Validate all auth-related environment variables at startup using Zod. Fail fast on missing or invalid values — never let the app start with bad config.
15
+
16
+ ```typescript
17
+ // src/config.ts
18
+ import { z } from "zod";
19
+
20
+ // Treat empty strings as undefined so optional fields work with .env files
21
+ const optionalString = z.preprocess(
22
+ (val) => (val === "" ? undefined : val),
23
+ z.string().optional()
24
+ );
25
+
26
+ const configSchema = z.object({
27
+ // Auth (required)
28
+ BETTER_AUTH_SECRET: z.string().min(32, "Auth secret must be at least 32 characters"),
29
+ BETTER_AUTH_URL: z.string().url().default("http://localhost:3000"),
30
+
31
+ // OAuth (optional — enables Google login when both present)
32
+ GOOGLE_CLIENT_ID: optionalString,
33
+ GOOGLE_CLIENT_SECRET: optionalString,
34
+
35
+ // Email
36
+ RESEND_API_KEY: optionalString,
37
+ EMAIL_FROM: z.string().default("noreply@example.com"),
38
+
39
+ // CORS
40
+ CORS_ORIGIN: z.string().default("*"),
41
+ TRUSTED_ORIGINS: optionalString,
42
+
43
+ // Database
44
+ DATABASE_URL: z.string().url(),
45
+
46
+ // Node env
47
+ NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
48
+ });
49
+
50
+ export type Config = z.infer<typeof configSchema>;
51
+ export const config = configSchema.parse(process.env);
52
+
53
+ // Reject dev-like secrets in production
54
+ if (config.NODE_ENV === "production") {
55
+ const devSecrets = ["super-secret-dev-key", "development-secret", "change-me"];
56
+ if (devSecrets.some((s) => config.BETTER_AUTH_SECRET.includes(s))) {
57
+ console.error("FATAL: Development auth secret detected in production");
58
+ process.exit(1);
59
+ }
60
+ }
61
+ ```
62
+
63
+ **Why this matters:** A missing `BETTER_AUTH_SECRET` causes silent auth failures. A too-short secret weakens token signing. Catching these at startup prevents debugging phantom 401s in production.
64
+
65
+ ## Error Types
66
+
67
+ A consistent error hierarchy where every auth/tenant error has a distinct code. This lets the frontend differentiate between "wrong password", "no organization", and "rate limited."
68
+
69
+ ```typescript
70
+ // src/lib/errors.ts
71
+ export class AppError extends Error {
72
+ constructor(
73
+ public code: string,
74
+ message: string,
75
+ public statusCode: number = 500,
76
+ public details?: Record<string, unknown>
77
+ ) {
78
+ super(message);
79
+ }
80
+ }
81
+
82
+ export class AuthError extends AppError {
83
+ constructor(message = "Authentication required") {
84
+ super("auth_required", message, 401);
85
+ }
86
+ }
87
+
88
+ export class ForbiddenError extends AppError {
89
+ constructor(message = "Insufficient permissions") {
90
+ super("forbidden", message, 403);
91
+ }
92
+ }
93
+
94
+ export class NotFoundError extends AppError {
95
+ constructor(message = "Resource not found") {
96
+ super("not_found", message, 404);
97
+ }
98
+ }
99
+
100
+ export class ValidationError extends AppError {
101
+ constructor(message: string, details?: Record<string, unknown>) {
102
+ super("validation_error", message, 422, details);
103
+ }
104
+ }
105
+
106
+ export class RateLimitError extends AppError {
107
+ constructor(retryAfter: number) {
108
+ super("rate_limited", "Too many requests", 429, { retryAfter });
109
+ }
110
+ }
111
+ ```
112
+
113
+ **Response envelope** (consistent across all endpoints):
114
+ ```json
115
+ {
116
+ "error": {
117
+ "code": "auth_required",
118
+ "message": "Authentication required"
119
+ },
120
+ "requestId": "uuid"
121
+ }
122
+ ```
123
+
124
+ ## Error Handler Middleware
125
+
126
+ Catches all errors and returns the consistent envelope. Masks unhandled errors in production.
127
+
128
+ ```typescript
129
+ // src/middleware/error-handler.ts
130
+ import type { ErrorHandler } from "hono";
131
+ import { AppError } from "../lib/errors";
132
+ import { config } from "../config";
133
+
134
+ export const errorHandler: ErrorHandler = (err, c) => {
135
+ const requestId = c.get("requestId") ?? "unknown";
136
+
137
+ if (err instanceof AppError) {
138
+ return c.json({
139
+ error: {
140
+ code: err.code,
141
+ message: err.message,
142
+ ...(err.details && { details: err.details }),
143
+ },
144
+ requestId,
145
+ }, err.statusCode as any);
146
+ }
147
+
148
+ // Unhandled error — mask in production to prevent information leakage
149
+ const message = config.NODE_ENV === "production"
150
+ ? "Internal server error"
151
+ : err.message;
152
+
153
+ console.error("Unhandled error:", err);
154
+
155
+ return c.json({
156
+ error: { code: "internal_error", message },
157
+ requestId,
158
+ }, 500);
159
+ };
160
+ ```
161
+
162
+ ## Entry Point
163
+
164
+ The order middleware is registered determines whether auth works. Get this wrong and you get silent CORS failures or missing request IDs in error responses.
165
+
166
+ ```typescript
167
+ // src/index.ts
168
+ import { Hono } from "hono";
169
+ import { cors } from "hono/cors";
170
+ import { config } from "./config";
171
+ import { authRoutes } from "./routes/auth";
172
+ import { authMiddleware } from "./middleware/auth";
173
+ import { tenantContextMiddleware } from "./middleware/tenant-context";
174
+ import { errorHandler } from "./middleware/error-handler";
175
+
176
+ const app = new Hono();
177
+
178
+ // === GLOBAL MIDDLEWARE (order is critical) ===
179
+
180
+ // 1. Request ID — must be first so error handler can include it
181
+ app.use("*", async (c, next) => {
182
+ c.set("requestId", crypto.randomUUID());
183
+ await next();
184
+ });
185
+
186
+ // 2. Security headers
187
+ app.use("*", async (c, next) => {
188
+ if (config.NODE_ENV === "production") {
189
+ c.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
190
+ }
191
+ c.header("X-Frame-Options", "DENY");
192
+ await next();
193
+ });
194
+
195
+ // 3. CORS — comes before auth routes because if CORS is registered after,
196
+ // preflight OPTIONS requests fail and cross-origin auth breaks completely.
197
+ const origins = config.CORS_ORIGIN.split(",").map((o) => o.trim()).filter(Boolean);
198
+ app.use("*", cors({
199
+ origin: origins.length === 1 && origins[0] === "*" ? "*" : origins,
200
+ allowMethods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
201
+ allowHeaders: ["Content-Type", "Authorization"],
202
+ exposeHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset", "Retry-After"],
203
+ credentials: origins[0] !== "*", // credentials: true only with specific origins
204
+ }));
205
+
206
+ // 4. Request logging (add pino or your preferred logger here)
207
+ // 5. Error handler (catches AppError and returns envelope)
208
+ app.onError(errorHandler);
209
+
210
+ // === PUBLIC ROUTES ===
211
+ app.route("/auth", authRoutes);
212
+
213
+ // === PROTECTED ROUTES ===
214
+ // Auth → Tenant Context → Rate Limit → Route handlers
215
+ const protectedRoutes = new Hono();
216
+ protectedRoutes.use("*", authMiddleware);
217
+ protectedRoutes.use("*", tenantContextMiddleware);
218
+ // protectedRoutes.use("*", rateLimitMiddleware); // Add when ready
219
+ app.route("/v1", protectedRoutes);
220
+
221
+ // Mount your protected route handlers on protectedRoutes:
222
+ // protectedRoutes.route("/projects", projectRoutes);
223
+ // protectedRoutes.route("/api-keys", apiKeyRoutes);
224
+
225
+ export default app;
226
+ ```
227
+
228
+ ## Middleware Order
229
+
230
+ | Order | Middleware | Why This Position |
231
+ |-------|-----------|-------------------|
232
+ | 1 | Request ID | Error handler needs it for the response envelope |
233
+ | 2 | Security Headers | Applied to all responses including errors |
234
+ | 3 | CORS | Handles OPTIONS preflight before auth rejects them |
235
+ | 4 | Logging | Tracks all requests including auth failures |
236
+ | 5 | Error Handler | Catch-all for consistent error envelope |
237
+ | 6 | Auth (protected only) | Validates credentials |
238
+ | 7 | Tenant Context (protected only) | Resolves org from auth |
239
+ | 8 | Rate Limit (protected only) | Plan-aware throttling |