Skip to content

Commit 5d15d46

Browse files
committed
fix: error handling and schema config
1 parent b4289aa commit 5d15d46

5 files changed

Lines changed: 79 additions & 18 deletions

File tree

apps/cloudflare-workers/src/app.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
// [obs-plugin 1/2] Remove this import and the override below to disable observability
2222
import { observabilityPlugin } from "@private-landing/observability";
2323
import {
24+
AuthenticationError,
2425
type Env,
2526
ValidationError,
2627
type Variables,
@@ -42,6 +43,7 @@ const sessions =
4243
? createMirroredSessionService({
4344
inner: auth.sessions,
4445
getClientIp: defaultGetClientIp,
46+
...auth.config.sessions,
4547
})
4648
: auth.sessions;
4749

@@ -62,6 +64,24 @@ type AppEnv = { Bindings: Env; Variables: Variables };
6264

6365
const app = new Hono<AppEnv>();
6466

67+
// Global error boundary — prevents leaking stack traces or internal details
68+
app.onError((err, ctx) => {
69+
if (err instanceof AuthenticationError) {
70+
return ctx.json(
71+
{ error: err.message, code: err.code },
72+
err.statusCode as 400,
73+
);
74+
}
75+
if (err instanceof ValidationError) {
76+
return ctx.json({ error: err.message, code: err.code }, 400);
77+
}
78+
console.error("Unhandled error:", err);
79+
return ctx.json(
80+
{ error: "Internal server error", code: "INTERNAL_ERROR" },
81+
500,
82+
);
83+
});
84+
6585
// No-op defaults — active when observability plugin is not loaded
6686
const noop: MiddlewareHandler<AppEnv> = async (_, next) => next();
6787
let obsEmit: (eventType: string) => MiddlewareHandler<AppEnv> = () => noop;
@@ -185,13 +205,18 @@ app.post("/auth/register", rateLimit(rateLimits.register), async (ctx) => {
185205
type: "registration.failure",
186206
detail: { email: domain },
187207
});
208+
const isConstraint =
209+
error instanceof Error && error.message.includes("UNIQUE constraint");
188210
if (json) {
189-
if (error instanceof ValidationError) {
190-
return ctx.json({ error: error.message, code: error.code }, 400);
211+
if (error instanceof ValidationError || isConstraint) {
212+
return ctx.json(
213+
{ error: "Registration failed", code: "REGISTRATION_ERROR" },
214+
400,
215+
);
191216
}
192217
return ctx.json(
193218
{ error: "Registration failed", code: "REGISTRATION_ERROR" },
194-
400,
219+
500,
195220
);
196221
}
197222
return ctx.redirect("/#error");
@@ -287,7 +312,7 @@ app.post(
287312
}
288313
return ctx.json(
289314
{ error: "Password change failed", code: "PASSWORD_CHANGE_ERROR" },
290-
400,
315+
500,
291316
);
292317
}
293318
return ctx.redirect("/#error");

packages/core/src/auth/services/mirrored-session-service.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,26 @@ import type {
1414
AuthContext,
1515
GetClientIpFn,
1616
SessionConfig,
17+
SessionTableConfig,
1718
} from "@private-landing/types";
1819
import { defaultSessionConfig } from "../config";
1920
import type { SessionService } from "./session-service";
2021

22+
/** Default table/column names — matches session-service.ts defaults */
23+
const DEFAULT_TABLE_CONFIG: Required<SessionTableConfig> = {
24+
tableName: "session",
25+
idColumn: "id",
26+
userIdColumn: "user_id",
27+
userAgentColumn: "user_agent",
28+
ipAddressColumn: "ip_address",
29+
expiresAtColumn: "expires_at",
30+
createdAtColumn: "created_at",
31+
};
32+
2133
/**
2234
* Configuration for the mirrored session decorator.
2335
*/
24-
export interface MirroredSessionServiceConfig {
36+
export interface MirroredSessionServiceConfig extends SessionTableConfig {
2537
/** The inner session service to decorate (typically cache-backed) */
2638
inner: SessionService;
2739
/** Factory that creates a DB client for SQL writes */
@@ -41,7 +53,13 @@ export interface MirroredSessionServiceConfig {
4153
export function createMirroredSessionService(
4254
config: MirroredSessionServiceConfig,
4355
): SessionService {
44-
const { inner, createDbClient = defaultCreateDbClient, getClientIp } = config;
56+
const {
57+
inner,
58+
createDbClient = defaultCreateDbClient,
59+
getClientIp,
60+
...tableConfig
61+
} = config;
62+
const rc = { ...DEFAULT_TABLE_CONFIG, ...tableConfig };
4563

4664
return {
4765
async createSession(
@@ -67,7 +85,7 @@ export function createMirroredSessionService(
6785
const db = createDbClient(ctx.env);
6886

6987
await db.execute({
70-
sql: `INSERT INTO session (id, user_id, user_agent, ip_address, expires_at, created_at)
88+
sql: `INSERT INTO ${rc.tableName} (${rc.idColumn}, ${rc.userIdColumn}, ${rc.userAgentColumn}, ${rc.ipAddressColumn}, ${rc.expiresAtColumn}, ${rc.createdAtColumn})
7189
VALUES (?, ?, ?, ?, datetime('now', '+' || ? || ' seconds'), datetime('now'))`,
7290
args: [
7391
sessionId,
@@ -81,13 +99,13 @@ export function createMirroredSessionService(
8199
// Mirror the session limit enforcement (expire oldest beyond maxSessions)
82100
await db.execute({
83101
sql: `WITH ranked AS (
84-
SELECT id, ROW_NUMBER() OVER (
85-
PARTITION BY user_id ORDER BY created_at DESC
86-
) AS rn FROM session
87-
WHERE user_id = ? AND expires_at > datetime('now')
102+
SELECT ${rc.idColumn}, ROW_NUMBER() OVER (
103+
PARTITION BY ${rc.userIdColumn} ORDER BY ${rc.createdAtColumn} DESC
104+
) AS rn FROM ${rc.tableName}
105+
WHERE ${rc.userIdColumn} = ? AND ${rc.expiresAtColumn} > datetime('now')
88106
)
89-
UPDATE session SET expires_at = datetime('now')
90-
WHERE id IN (SELECT id FROM ranked WHERE rn > ?)`,
107+
UPDATE ${rc.tableName} SET ${rc.expiresAtColumn} = datetime('now')
108+
WHERE ${rc.idColumn} IN (SELECT ${rc.idColumn} FROM ranked WHERE rn > ?)`,
91109
args: [userId, sessionConfig.maxSessions],
92110
});
93111
} catch (error) {
@@ -107,7 +125,7 @@ export function createMirroredSessionService(
107125
if (payload?.sid) {
108126
const db = createDbClient(ctx.env);
109127
await db.execute({
110-
sql: "UPDATE session SET expires_at = datetime('now') WHERE id = ?",
128+
sql: `UPDATE ${rc.tableName} SET ${rc.expiresAtColumn} = datetime('now') WHERE ${rc.idColumn} = ?`,
111129
args: [payload.sid],
112130
});
113131
}
@@ -125,7 +143,7 @@ export function createMirroredSessionService(
125143
try {
126144
const db = createDbClient(ctx.env);
127145
await db.execute({
128-
sql: "UPDATE session SET expires_at = datetime('now') WHERE user_id = ? AND expires_at > datetime('now')",
146+
sql: `UPDATE ${rc.tableName} SET ${rc.expiresAtColumn} = datetime('now') WHERE ${rc.userIdColumn} = ? AND ${rc.expiresAtColumn} > datetime('now')`,
129147
args: [userId],
130148
});
131149
} catch (error) {

packages/core/src/auth/services/token-service.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
* @license Apache-2.0
66
*/
77

8-
import type { TokenConfig, TokenPayload } from "@private-landing/types";
8+
import {
9+
ConfigurationError,
10+
type TokenConfig,
11+
type TokenPayload,
12+
} from "@private-landing/types";
913
import type { Context } from "hono";
1014
import { sign } from "hono/jwt";
1115
import { tokenConfig as defaultTokenConfig } from "../config";
@@ -70,7 +74,7 @@ export function createTokenService(
7074
sessionId: string,
7175
): Promise<{ accessToken: string; refreshToken: string }> {
7276
if (!ctx.env.JWT_ACCESS_SECRET || !ctx.env.JWT_REFRESH_SECRET) {
73-
throw new Error("Missing token signing secrets");
77+
throw new ConfigurationError("Missing token signing secrets");
7478
}
7579

7680
// Generate refresh token
@@ -118,7 +122,7 @@ export function createTokenService(
118122
payload: TokenPayload,
119123
): Promise<string> {
120124
if (!ctx.env.JWT_ACCESS_SECRET) {
121-
throw new Error("Missing access token signing secret");
125+
throw new ConfigurationError("Missing access token signing secret");
122126
}
123127

124128
// Generate new access token with same session_id

packages/schemas/src/utils/format.ts

Whitespace-only changes.

packages/types/src/auth/errors.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,17 @@ export class ValidationError extends Error {
104104
Object.setPrototypeOf(this, ValidationError.prototype);
105105
}
106106
}
107+
108+
/**
109+
* Error for missing or invalid service configuration.
110+
* Thrown when required environment variables or secrets are absent at runtime.
111+
*/
112+
export class ConfigurationError extends Error {
113+
readonly code = "CONFIGURATION_ERROR" as const;
114+
115+
constructor(message: string) {
116+
super(message);
117+
this.name = "ConfigurationError";
118+
Object.setPrototypeOf(this, ConfigurationError.prototype);
119+
}
120+
}

0 commit comments

Comments
 (0)