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.
- package/README.md +172 -62
- package/bin/devlyn.js +40 -4
- package/optional-commands/pencil-sync/devlyn.pencil-pull.md +123 -0
- package/optional-commands/pencil-sync/devlyn.pencil-push.md +70 -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/package.json +2 -1
|
@@ -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
|
+
```
|