@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/CHANGELOG.md +41 -0
- package/dist/index.d.ts +38 -192
- package/dist/index.js +178 -446
- package/dist/index.js.map +1 -1
- package/package.json +6 -7
- package/src/features/api-key.ts +44 -14
- package/src/features/auth/index.ts +53 -6
- package/src/features/csrf.ts +50 -4
- package/src/features/db.ts +26 -11
- package/src/features/email/index.ts +50 -68
- package/src/features/health.ts +42 -6
- package/src/features/storage/index.ts +5 -14
- package/src/features/upload/helper.ts +13 -2
- package/src/server.ts +17 -1
- package/src/types.ts +3 -0
- package/dist/chunk-POXNRNTC.js +0 -51
- package/dist/chunk-POXNRNTC.js.map +0 -1
- package/dist/mailgun-Z46GZJNI.js +0 -83
- package/dist/mailgun-Z46GZJNI.js.map +0 -1
- package/dist/s3-7IG4ESFW.js +0 -171
- package/dist/s3-7IG4ESFW.js.map +0 -1
- package/dist/sendgrid-UK2GSBEF.js +0 -43
- package/dist/sendgrid-UK2GSBEF.js.map +0 -1
- package/dist/smtp-WJDLYKD5.js +0 -50
- package/dist/smtp-WJDLYKD5.js.map +0 -1
- package/src/features/auth/better-auth-config.ts +0 -160
- package/src/features/auth/schema.ts +0 -174
- package/src/features/auth/types.ts +0 -114
- package/src/features/email/providers/mailgun.ts +0 -99
- package/src/features/email/providers/sendgrid.ts +0 -42
- package/src/features/email/providers/smtp.ts +0 -51
- package/src/features/storage/adapters/local.ts +0 -133
- package/src/features/storage/adapters/s3.ts +0 -193
- package/src/features/storage/base.ts +0 -112
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iskra-bun/web-kit",
|
|
3
|
-
"version": "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.
|
|
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
|
-
"@
|
|
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",
|
package/src/features/api-key.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
50
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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:
|
|
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:
|
|
164
|
+
private config: ResolvedApiKeyConfig;
|
|
144
165
|
|
|
145
166
|
constructor(config: ApiKeyConfig = {}) {
|
|
146
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
}
|
|
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 "
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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");
|
package/src/features/csrf.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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)
|
|
122
|
+
if (bodyToken && constantTimeEqual(String(bodyToken), expectedToken)) return true;
|
|
82
123
|
}
|
|
83
|
-
} catch {
|
|
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
|
}
|
package/src/features/db.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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 };
|