devlyn-cli 0.3.0 → 0.5.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,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 |
@@ -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
+ ```