@iskra-bun/web-kit 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iskra-bun/web-kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Servidor HTTP de Iskra basado en Hono, con Kernel y features (auth, cors, db, health, etc.).",
5
5
  "keywords": [
6
6
  "iskra",
@@ -49,7 +49,10 @@
49
49
  "dependencies": {
50
50
  "@hono/zod-openapi": "^1.2.0",
51
51
  "@hono/zod-validator": "^0.4.2",
52
- "@iskra-bun/core": "0.1.0",
52
+ "@iskra-bun/core": "0.1.1",
53
+ "@iskra-bun/auth-kit": "0.1.0",
54
+ "@iskra-bun/mailer-kit": "0.1.0",
55
+ "@iskra-bun/storage-kit": "0.1.0",
53
56
  "better-auth": "^1.4.18",
54
57
  "drizzle-orm": "^0.45.1",
55
58
  "hono": "^4.6.14",
@@ -60,11 +63,7 @@
60
63
  "ajv-errors": "^3.0.0",
61
64
  "ajv-formats": "^3.0.1",
62
65
  "zod": "^3.24.1",
63
- "@aws-sdk/client-s3": "^3.600.0",
64
- "@aws-sdk/s3-request-presigner": "^3.600.0",
65
- "@hono/otel": "^1.0.0",
66
- "nodemailer": "^6.9.1",
67
- "@sendgrid/mail": "^8.1.0"
66
+ "@hono/otel": "^1.0.0"
68
67
  },
69
68
  "devDependencies": {
70
69
  "@types/bun": "^1.3.5",
@@ -2,6 +2,15 @@ import type { Feature, ApiKeyConfig, ApiKeyMetadata, ApiKeyValidationResult } fr
2
2
  import type { Kernel } from "../kernel";
3
3
  import type { Context, Next } from "hono";
4
4
  import { HTTPException } from "hono/http-exception";
5
+ import { createHash } from "crypto";
6
+
7
+ // Resolved config: scalar/array fields are always populated by the constructor
8
+ // defaults, while the genuinely optional callbacks stay optional. This replaces
9
+ // the previous `as unknown as Required<ApiKeyConfig>` cast, which masked shape
10
+ // drift by pretending the callbacks were always present.
11
+ type ResolvedApiKeyConfig =
12
+ Required<Omit<ApiKeyConfig, "vaultService" | "customExtractor" | "onError" | "onValidated">>
13
+ & Pick<ApiKeyConfig, "vaultService" | "customExtractor" | "onError" | "onValidated">;
5
14
 
6
15
  // --- ApiKeyStore ---
7
16
 
@@ -9,7 +18,7 @@ export class ApiKeyStore {
9
18
  private staticKeysMap: Map<string, ApiKeyMetadata> = new Map();
10
19
  private cache?: any;
11
20
 
12
- constructor(private config: Required<ApiKeyConfig>, private kernel: Kernel) {
21
+ constructor(private config: ResolvedApiKeyConfig, private kernel: Kernel) {
13
22
  this.initializeStaticKeys();
14
23
  }
15
24
 
@@ -46,8 +55,11 @@ export class ApiKeyStore {
46
55
  console.log(`✅ Loaded ${this.staticKeysMap.size} static API keys`);
47
56
  }
48
57
 
49
- private generateId(key: string): string {
50
- return key.substring(0, 8);
58
+ private generateId(_key: string): string {
59
+ // Derive the id independently of the secret key material so it can never
60
+ // leak a usable prefix of the key. Lookup is keyed by the plaintext key
61
+ // (staticKeysMap / cache), never by id, so a random id is sufficient.
62
+ return crypto.randomUUID();
51
63
  }
52
64
 
53
65
  private compareKeys(a: string, b: string): boolean {
@@ -64,11 +76,18 @@ export class ApiKeyStore {
64
76
  return new Date() > expiresAt;
65
77
  }
66
78
 
79
+ private cacheKeyFor(key: string): string {
80
+ // Hash the key before using it as a cache key so the plaintext secret is
81
+ // never persisted (e.g. in Redis) where it could leak via cache dumps.
82
+ const hash = createHash("sha256").update(key).digest("hex");
83
+ return `apikey:${hash}`;
84
+ }
85
+
67
86
  async validate(key: string): Promise<ApiKeyValidationResult> {
68
87
  if (!key) return { isValid: false, error: "API key is required" };
69
88
 
70
89
  const cache = this.getCache();
71
- const cacheKey = `apikey:${key}`;
90
+ const cacheKey = this.cacheKeyFor(key);
72
91
 
73
92
  if (cache) {
74
93
  try {
@@ -88,18 +107,20 @@ export class ApiKeyStore {
88
107
  if (this.isExpired(metadata.expiresAt)) {
89
108
  return { isValid: false, error: "API key has expired" };
90
109
  }
91
- metadata.lastUsedAt = new Date();
110
+ // Build a derived object instead of mutating the metadata held in
111
+ // staticKeysMap (immutability — the stored object must stay intact).
112
+ const usedMetadata: ApiKeyMetadata = { ...metadata, lastUsedAt: new Date() };
92
113
 
93
114
  if (cache) {
94
115
  const ttl = this.config.cacheTtl ? Math.floor(this.config.cacheTtl / 1000) : 300;
95
116
  try {
96
- await cache.set(cacheKey, JSON.stringify(metadata), ttl);
117
+ await cache.set(cacheKey, JSON.stringify(usedMetadata), ttl);
97
118
  } catch {
98
119
  // ignore cache write failures — validation already succeeded
99
120
  }
100
121
  }
101
122
 
102
- return { isValid: true, key: metadata };
123
+ return { isValid: true, key: usedMetadata };
103
124
  }
104
125
  }
105
126
 
@@ -140,24 +161,27 @@ declare module "hono" {
140
161
  export class ApiKeyFeature implements Feature {
141
162
  name = "apiKey";
142
163
  private store?: ApiKeyStore;
143
- private config: Required<ApiKeyConfig>;
164
+ private config: ResolvedApiKeyConfig;
144
165
 
145
166
  constructor(config: ApiKeyConfig = {}) {
146
- this.config = {
167
+ // Explicit, fully-typed defaults object — every resolved field is assigned
168
+ // a concrete value so shape drift surfaces at compile time instead of
169
+ // being masked by an `as unknown as` cast.
170
+ const defaults: ResolvedApiKeyConfig = {
147
171
  staticKeys: config.staticKeys || [],
148
172
  headerName: config.headerName || "X-API-Key",
149
173
  queryParamName: config.queryParamName || "api_key",
150
174
  extractStrategies: config.extractStrategies || ["header", "bearer"],
151
- // Defaults for other optional properties
152
- vaultService: undefined,
153
- customExtractor: undefined,
175
+ vaultService: config.vaultService,
176
+ customExtractor: config.customExtractor,
154
177
  enableCache: config.enableCache ?? true,
155
178
  cacheTtl: config.cacheTtl ?? 300000,
156
179
  requireScopes: config.requireScopes ?? false,
157
180
  skipPaths: config.skipPaths || [],
158
181
  onError: config.onError,
159
- onValidated: config.onValidated
160
- } as unknown as Required<ApiKeyConfig>;
182
+ onValidated: config.onValidated,
183
+ };
184
+ this.config = defaults;
161
185
  }
162
186
 
163
187
  async initialize(kernel: Kernel): Promise<void> {
@@ -183,6 +207,12 @@ export class ApiKeyFeature implements Feature {
183
207
  throw new HTTPException(401, { message: result.error || "Invalid API key" });
184
208
  }
185
209
 
210
+ // When requireScopes is enabled, a validated key that carries no scope
211
+ // is treated as insufficiently privileged and rejected.
212
+ if (this.config.requireScopes && !(result.key?.scopes && result.key.scopes.length > 0)) {
213
+ throw new HTTPException(403, { message: "API key has no scopes" });
214
+ }
215
+
186
216
  c.set("apiKey", result.key);
187
217
  c.set("apiKeyScopes", result.key?.scopes || []);
188
218
 
@@ -1,11 +1,11 @@
1
1
  import type { AuthConfig, Feature, Kernel } from "../../types";
2
2
  import type { Context, Hono, Next } from "hono";
3
3
  import { HTTPException } from "hono/http-exception";
4
- import { type Auth, createBetterAuth } from "./better-auth-config";
4
+ import { type Auth, createBetterAuth } from "@iskra-bun/auth-kit";
5
5
  import { z } from "@hono/zod-openapi";
6
6
  import type { DbFeature } from "../db";
7
7
  import type { OpenAPIFeature } from "../openapi";
8
- import type { User } from "./types";
8
+ import type { User } from "@iskra-bun/auth-kit";
9
9
 
10
10
  declare module "hono" {
11
11
  interface ContextVariableMap {
@@ -52,8 +52,14 @@ export class AuthFeature implements Feature {
52
52
  private config: Required<Pick<AuthConfig, "secret" | "basePath">> & AuthConfig;
53
53
  private kernel?: Kernel;
54
54
  private authMode: "oidc" | "email";
55
+ private createAuth: typeof createBetterAuth;
55
56
 
56
- constructor(config: AuthConfig) {
57
+ // The second parameter is an internal seam: it defaults to the real
58
+ // createBetterAuth and lets tests inject a fake without globally mocking the
59
+ // @iskra-bun/auth-kit module (bun's mock.module is process-global and cannot
60
+ // be restored, which would otherwise leak into auth-kit's own test suite).
61
+ constructor(config: AuthConfig, createAuth: typeof createBetterAuth = createBetterAuth) {
62
+ this.createAuth = createAuth;
57
63
  this.config = {
58
64
  ...config,
59
65
  basePath: config.basePath || "/api/sso",
@@ -81,7 +87,14 @@ export class AuthFeature implements Feature {
81
87
  throw new Error("DbFeature must expose 'adapter' type (postgres, mysql, sqlite)");
82
88
  }
83
89
 
84
- this.auth = createBetterAuth({
90
+ // CSRF kill-switch is honored only outside production. Even if a config
91
+ // ships with disableCSRFCheck enabled, it is neutralized in prod so CSRF
92
+ // protection cannot be silently turned off in a deployed environment.
93
+ const disableCSRFCheck = process.env.NODE_ENV !== "production"
94
+ ? this.config.disableCSRFCheck === true
95
+ : false;
96
+
97
+ this.auth = this.createAuth({
85
98
  db,
86
99
  adapterType,
87
100
  secret: this.config.secret,
@@ -89,12 +102,17 @@ export class AuthFeature implements Feature {
89
102
  basePath: this.config.basePath,
90
103
  trustedOrigins: this.config.trustedOrigins,
91
104
  enableEmailPassword: true,
92
- disableCSRFCheck: this.config.disableCSRFCheck,
105
+ disableCSRFCheck,
93
106
  socialProviders: this.config.socialProviders,
94
107
  oidcConfig: this.config.oidcConfig,
95
108
  });
96
109
 
97
110
  const app = kernel.getApp();
111
+
112
+ // Per-IP rate limiting on the auth routes by default, throttling
113
+ // credential-stuffing / brute-force against sign-in and sign-up.
114
+ app.use(`${this.config.basePath}/*`, this.authRateLimitMiddleware());
115
+
98
116
  app.use("*", async (c: Context, next: Next) => {
99
117
  try {
100
118
  const session = await this.auth!.api.getSession({
@@ -106,7 +124,11 @@ export class AuthFeature implements Feature {
106
124
  c.set("authUser", session.user);
107
125
  }
108
126
  } catch (error) {
109
- // Ignore session errors (just not logged in)
127
+ // A failed getSession means "not authenticated" — expected for
128
+ // anonymous requests. Surface unexpected detail at debug only;
129
+ // never block the request on a session read.
130
+ const logger = c.get("logger");
131
+ if (logger?.debug) logger.debug("Auth session read failed", { error });
110
132
  }
111
133
  await next();
112
134
  });
@@ -114,6 +136,31 @@ export class AuthFeature implements Feature {
114
136
  console.log("✅ Auth feature initialized (better-auth)");
115
137
  }
116
138
 
139
+ // ─── Auth-route rate limiting ────────────────────────────────────────────
140
+ private authRateLimitHits = new Map<string, { count: number; expiresAt: number }>();
141
+ private readonly authRateLimitWindowMs = 15 * 60 * 1000;
142
+ private readonly authRateLimitMax = 20;
143
+
144
+ private authRateLimitMiddleware() {
145
+ return async (c: Context, next: Next) => {
146
+ const ip = c.req.header("x-forwarded-for")
147
+ || c.req.header("x-real-ip")
148
+ || "unknown";
149
+ const now = Date.now();
150
+ const entry = this.authRateLimitHits.get(ip);
151
+
152
+ const next_entry = !entry || now > entry.expiresAt
153
+ ? { count: 1, expiresAt: now + this.authRateLimitWindowMs }
154
+ : { count: entry.count + 1, expiresAt: entry.expiresAt };
155
+ this.authRateLimitHits.set(ip, next_entry);
156
+
157
+ if (next_entry.count > this.authRateLimitMax) {
158
+ throw new HTTPException(429, { message: "Too many authentication attempts" });
159
+ }
160
+ await next();
161
+ };
162
+ }
163
+
117
164
  routes(app: Hono): void {
118
165
  if (!this.auth) {
119
166
  throw new Error("Auth not initialized");
@@ -3,6 +3,7 @@ import type { Kernel } from "../kernel";
3
3
  import type { Context, Next } from "hono";
4
4
  import { HTTPException } from "hono/http-exception";
5
5
  import { getCookie, setCookie } from "hono/cookie";
6
+ import { createHmac, timingSafeEqual, randomUUID } from "crypto";
6
7
 
7
8
  declare module "hono" {
8
9
  interface ContextVariableMap {
@@ -10,6 +11,41 @@ declare module "hono" {
10
11
  }
11
12
  }
12
13
 
14
+ // ─── Signed Double-Submit Token ──────────────────────────────────────────────
15
+ //
16
+ // A CSRF token is `<random>.<hmac>` where the HMAC signs the random nonce under
17
+ // the configured secret (OWASP signed double-submit cookie). Forging a token
18
+ // requires the secret, and an attacker can neither read nor set the victim's
19
+ // cookie cross-origin. The nonce is intentionally NOT bound to the session id:
20
+ // anonymous flows mint a throwaway session id per request, so binding would
21
+ // reject the first POST of every logged-out flow.
22
+
23
+ function constantTimeEqual(a: string, b: string): boolean {
24
+ const aBuf = Buffer.from(a);
25
+ const bBuf = Buffer.from(b);
26
+ // timingSafeEqual throws on length mismatch; comparing lengths first keeps
27
+ // the call safe. The length check is not constant-time, but token length is
28
+ // not secret — the signature comparison below is what gates forgery.
29
+ if (aBuf.length !== bBuf.length) return false;
30
+ return timingSafeEqual(aBuf, bBuf);
31
+ }
32
+
33
+ function signCsrfToken(random: string, secret: string): string {
34
+ const signature = createHmac("sha256", secret)
35
+ .update(random)
36
+ .digest("base64url");
37
+ return `${random}.${signature}`;
38
+ }
39
+
40
+ function verifyCsrfToken(token: string, secret: string): boolean {
41
+ const lastDot = token.lastIndexOf(".");
42
+ if (lastDot === -1) return false;
43
+
44
+ const random = token.substring(0, lastDot);
45
+ const expected = signCsrfToken(random, secret);
46
+ return constantTimeEqual(token, expected);
47
+ }
48
+
13
49
  export class CsrfFeature implements Feature {
14
50
  name = "csrf";
15
51
  private config: Required<CsrfConfig>;
@@ -44,7 +80,7 @@ export class CsrfFeature implements Feature {
44
80
  let token = getCookie(c, this.config.cookieName);
45
81
 
46
82
  if (!token) {
47
- token = crypto.randomUUID().replace(/-/g, '');
83
+ token = signCsrfToken(randomUUID().replace(/-/g, ""), this.config.secret);
48
84
  setCookie(c, this.config.cookieName, token, {
49
85
  httpOnly: this.config.cookieOptions.httpOnly,
50
86
  secure: this.config.cookieOptions.secure,
@@ -70,17 +106,27 @@ export class CsrfFeature implements Feature {
70
106
  }
71
107
 
72
108
  private async validateToken(c: Context, expectedToken: string): Promise<boolean> {
109
+ // The cookie token itself must carry a valid signature under the secret.
110
+ // An unsigned or foreign token is rejected before any comparison, so a
111
+ // token minted elsewhere cannot pass double-submit.
112
+ if (!verifyCsrfToken(expectedToken, this.config.secret)) return false;
113
+
73
114
  const headerToken = c.req.header(this.config.headerName);
74
- if (headerToken === expectedToken) return true;
115
+ if (headerToken && constantTimeEqual(headerToken, expectedToken)) return true;
75
116
 
76
117
  try {
77
118
  const contentType = c.req.header("content-type");
78
119
  if (contentType?.includes("application/x-www-form-urlencoded")) {
79
120
  const body = await c.req.parseBody();
80
121
  const bodyToken = body._csrf || body[this.config.cookieName];
81
- if (String(bodyToken) === expectedToken) return true;
122
+ if (bodyToken && constantTimeEqual(String(bodyToken), expectedToken)) return true;
82
123
  }
83
- } catch { /* ignored */ }
124
+ } catch (error) {
125
+ // A malformed/unparseable body just means no valid body token is
126
+ // present; fall through to rejection. Surface detail at debug only.
127
+ const logger = c.get("logger");
128
+ if (logger?.debug) logger.debug("CSRF body token parse failed", { error });
129
+ }
84
130
 
85
131
  return false;
86
132
  }
@@ -1,23 +1,35 @@
1
1
  import type { Feature, DbConfig } from "../types";
2
2
  import type { Kernel } from "../kernel";
3
3
  import type { Context, Next } from "hono";
4
- import { drizzle } from 'drizzle-orm/postgres-js';
5
- import { drizzle as drizzleMysql } from 'drizzle-orm/mysql2';
6
- import { drizzle as drizzleBunSqlite } from 'drizzle-orm/bun-sqlite';
4
+ import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
5
+ import { drizzle as drizzleMysql, type MySql2Database } from 'drizzle-orm/mysql2';
6
+ import { drizzle as drizzleBunSqlite, type BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite';
7
7
  import postgres from 'postgres';
8
8
  import mysql from 'mysql2/promise';
9
9
  import { Database } from 'bun:sqlite';
10
10
 
11
+ /**
12
+ * The Drizzle database handle a {@link DbFeature} exposes, parameterized by the
13
+ * caller's schema. A union of the supported dialect databases — all share the
14
+ * same `TSchema extends Record<string, unknown> = Record<string, never>`
15
+ * parameter, so passing a schema types `db.query.*` for opt-in callers while the
16
+ * default `Record<string, never>` reproduces the historical untyped behavior.
17
+ */
18
+ export type WebKitDrizzleDb<TSchema extends Record<string, unknown> = Record<string, never>> =
19
+ | PostgresJsDatabase<TSchema>
20
+ | MySql2Database<TSchema>
21
+ | BunSQLiteDatabase<TSchema>;
22
+
11
23
  declare module "hono" {
12
24
  interface ContextVariableMap {
13
- db: any;
25
+ db: WebKitDrizzleDb;
14
26
  }
15
27
  }
16
28
 
17
- export class DbFeature implements Feature {
29
+ export class DbFeature<TSchema extends Record<string, unknown> = Record<string, never>> implements Feature {
18
30
  name = "db";
19
31
  private client: any;
20
- public db: any;
32
+ public db!: WebKitDrizzleDb<TSchema>;
21
33
  public readonly adapter: string;
22
34
 
23
35
  constructor(private config: DbConfig) {
@@ -40,7 +52,7 @@ export class DbFeature implements Feature {
40
52
  password: config.connection.password!
41
53
  };
42
54
  this.client = postgres(pgConfig as any);
43
- this.db = drizzle(this.client);
55
+ this.db = drizzle<TSchema>(this.client);
44
56
  break;
45
57
  }
46
58
  case 'mysql': {
@@ -53,13 +65,13 @@ export class DbFeature implements Feature {
53
65
  password: config.connection.password!
54
66
  };
55
67
  this.client = await mysql.createConnection(mysqlConfig as any);
56
- this.db = drizzleMysql(this.client);
68
+ this.db = drizzleMysql<TSchema>(this.client);
57
69
  break;
58
70
  }
59
71
  case 'sqlite': {
60
72
  const url = config.connection?.database || ':memory:';
61
73
  this.client = new Database(url);
62
- this.db = drizzleBunSqlite(this.client);
74
+ this.db = drizzleBunSqlite<TSchema>(this.client);
63
75
  break;
64
76
  }
65
77
  default:
@@ -67,13 +79,16 @@ export class DbFeature implements Feature {
67
79
  }
68
80
  console.log('✅ DB connected successfully.');
69
81
  } catch (error) {
70
- console.error('❌ Failed to connect to DB', error);
82
+ // Log only the message the full error/config object can embed the
83
+ // connection string (host, user, password) and must not be logged.
84
+ const message = error instanceof Error ? error.message : String(error);
85
+ console.error(`❌ Failed to connect to DB: ${message}`);
71
86
  throw error;
72
87
  }
73
88
 
74
89
  const app = kernel.getApp();
75
90
  app.use("*", async (c: Context, next: Next) => {
76
- c.set("db", this.db);
91
+ c.set("db", this.db as WebKitDrizzleDb);
77
92
  await next();
78
93
  });
79
94
  }
@@ -1,61 +1,66 @@
1
1
  import type { Feature } from "../../types";
2
2
  import type { Kernel } from "../../kernel";
3
3
  import type { Context, Next } from "hono";
4
+ import { createEmailAdapter } from "@iskra-bun/mailer-kit";
4
5
 
5
- export interface EmailConfig {
6
- provider: "smtp" | "sendgrid" | "mock" | "mailgun" | "ses";
7
- smtp?: {
8
- host: string;
9
- port: number;
10
- username: string;
11
- password: string;
12
- secure?: boolean;
13
- };
14
- apiKey?: string;
15
- apiSecret?: string;
16
- region?: string;
17
- domain?: string;
18
- baseUrl?: string;
19
- from?: { name?: string; email: string };
20
- templateDir?: string;
21
- }
6
+ export type { EmailConfig, EmailMessage, TemplateData, EmailAdapter } from "@iskra-bun/mailer-kit";
7
+ export { MockEmailAdapter } from "@iskra-bun/mailer-kit";
8
+
9
+ import type { EmailConfig, EmailAdapter, EmailMessage, TemplateData } from "@iskra-bun/mailer-kit";
22
10
 
23
- export interface EmailMessage {
24
- to: string | string[];
25
- from?: { name?: string; email: string };
26
- subject: string;
27
- text?: string;
28
- html?: string;
29
- cc?: string | string[];
30
- bcc?: string | string[];
31
- replyTo?: string;
32
- attachments?: Array<{ filename: string; content: Uint8Array | string; contentType?: string }>;
33
- headers?: Record<string, string>;
11
+ declare module "hono" {
12
+ interface ContextVariableMap {
13
+ email: EmailAdapter;
14
+ }
34
15
  }
35
16
 
36
- export interface TemplateData {
37
- [key: string]: unknown;
17
+ // ─── Header-injection guards ─────────────────────────────────────────────────
18
+ //
19
+ // An attacker who controls a recipient address or a header value can inject CR
20
+ // or LF to smuggle extra SMTP headers (e.g. a hidden Bcc). Reject any address or
21
+ // header that carries a CR/LF before the message reaches the underlying adapter.
22
+
23
+ const CRLF = /[\r\n]/;
24
+
25
+ function assertNoCrlf(value: string, label: string): void {
26
+ if (CRLF.test(value)) {
27
+ throw new Error(`Invalid ${label}: control characters (CR/LF) are not allowed`);
28
+ }
38
29
  }
39
30
 
40
- export interface EmailAdapter {
41
- send(message: EmailMessage): Promise<{ messageId: string; success: boolean }>;
42
- sendTemplate(templateName: string, to: string | string[], data: TemplateData): Promise<{ messageId: string; success: boolean }>;
31
+ function assertRecipients(to: string | string[] | undefined, label: string): void {
32
+ if (to === undefined) return;
33
+ const recipients = Array.isArray(to) ? to : [to];
34
+ for (const recipient of recipients) assertNoCrlf(recipient, label);
43
35
  }
44
36
 
45
- class MockEmailAdapter implements EmailAdapter {
46
- async send(message: EmailMessage) {
47
- console.log("📧 [MOCK] Sent to", message.to, "Subject:", message.subject);
48
- return { messageId: `mock-${Date.now()}`, success: true };
49
- }
50
- async sendTemplate(name: string, to: string | string[], data: TemplateData) {
51
- return this.send({ to, subject: `Template: ${name}`, html: `Template ${name} with data: ${JSON.stringify(data)}` });
37
+ function validateMessage(message: EmailMessage): void {
38
+ assertRecipients(message.to, "recipient");
39
+ assertRecipients(message.cc, "cc recipient");
40
+ assertRecipients(message.bcc, "bcc recipient");
41
+ if (message.replyTo !== undefined) assertNoCrlf(message.replyTo, "replyTo");
42
+ assertNoCrlf(message.subject, "subject");
43
+ if (message.headers) {
44
+ for (const [name, value] of Object.entries(message.headers)) {
45
+ assertNoCrlf(name, "header name");
46
+ assertNoCrlf(value, "header value");
47
+ }
52
48
  }
53
49
  }
54
50
 
55
- declare module "hono" {
56
- interface ContextVariableMap {
57
- email: EmailAdapter;
58
- }
51
+ // Wrap an adapter so every send is validated centrally before delegation. The
52
+ // wrapper is a new object — it never mutates the underlying adapter.
53
+ function withValidation(adapter: EmailAdapter): EmailAdapter {
54
+ return {
55
+ send: (message: EmailMessage) => {
56
+ validateMessage(message);
57
+ return adapter.send(message);
58
+ },
59
+ sendTemplate: (templateName: string, to: string | string[], data: TemplateData) => {
60
+ assertRecipients(to, "recipient");
61
+ return adapter.sendTemplate(templateName, to, data);
62
+ },
63
+ };
59
64
  }
60
65
 
61
66
  export class EmailFeature implements Feature {
@@ -65,33 +70,12 @@ export class EmailFeature implements Feature {
65
70
  constructor(private config: EmailConfig) { }
66
71
 
67
72
  async initialize(kernel: Kernel): Promise<void> {
68
- this.adapter = await this.createAdapter(this.config);
73
+ this.adapter = withValidation(await createEmailAdapter(this.config));
69
74
  const app = kernel.getApp();
70
75
  app.use("*", async (c: Context, next: Next) => {
71
76
  if (this.adapter) c.set("email", this.adapter);
72
77
  await next();
73
78
  });
74
- console.log(`✅ EmailFeature initialized (${this.config.provider})`);
75
- }
76
-
77
- private async createAdapter(config: EmailConfig): Promise<EmailAdapter> {
78
- switch (config.provider) {
79
- case "mock": return new MockEmailAdapter();
80
- case "smtp": {
81
- // Lazy load to avoid import issues if not installed/configured in all envs (though we added deps)
82
- const { SmtpEmailAdapter } = await import("./providers/smtp");
83
- return new SmtpEmailAdapter(config);
84
- }
85
- case "sendgrid": {
86
- const { SendGridEmailAdapter } = await import("./providers/sendgrid");
87
- return new SendGridEmailAdapter(config);
88
- }
89
- case "mailgun": {
90
- const { MailgunEmailAdapter } = await import("./providers/mailgun");
91
- return new MailgunEmailAdapter(config);
92
- }
93
- default: throw new Error(`Provider ${config.provider} not implemented`);
94
- }
95
79
  }
96
80
 
97
81
  getAdapter(): EmailAdapter {
@@ -99,5 +83,3 @@ export class EmailFeature implements Feature {
99
83
  return this.adapter;
100
84
  }
101
85
  }
102
-
103
- export { MockEmailAdapter };