@hogsend/engine 0.10.0 → 0.11.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 +9 -7
- package/src/app.ts +28 -17
- package/src/container.ts +57 -20
- package/src/env.ts +12 -0
- package/src/index.ts +19 -2
- package/src/lib/auth.ts +78 -1
- package/src/lib/bootstrap-admin.ts +90 -0
- package/src/lib/create-admin.ts +104 -0
- package/src/lib/redis.ts +68 -0
- package/src/lib/reset-email.ts +139 -0
- package/src/middleware/rate-limit.ts +38 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./src/index.ts",
|
|
15
15
|
"./worker": "./src/worker.ts",
|
|
16
|
+
"./auth": "./src/lib/auth.ts",
|
|
17
|
+
"./create-admin": "./src/lib/create-admin.ts",
|
|
16
18
|
"./package.json": "./package.json"
|
|
17
19
|
},
|
|
18
20
|
"files": [
|
|
@@ -38,14 +40,14 @@
|
|
|
38
40
|
"svix": "^1.95.1",
|
|
39
41
|
"winston": "^3.19.0",
|
|
40
42
|
"zod": "^4.4.3",
|
|
41
|
-
"@hogsend/core": "^0.
|
|
42
|
-
"@hogsend/db": "^0.
|
|
43
|
-
"@hogsend/email": "^0.
|
|
44
|
-
"@hogsend/plugin-posthog": "^0.
|
|
45
|
-
"@hogsend/plugin-resend": "^0.
|
|
43
|
+
"@hogsend/core": "^0.11.0",
|
|
44
|
+
"@hogsend/db": "^0.11.0",
|
|
45
|
+
"@hogsend/email": "^0.11.0",
|
|
46
|
+
"@hogsend/plugin-posthog": "^0.11.0",
|
|
47
|
+
"@hogsend/plugin-resend": "^0.11.0"
|
|
46
48
|
},
|
|
47
49
|
"optionalDependencies": {
|
|
48
|
-
"@hogsend/plugin-postmark": "^0.
|
|
50
|
+
"@hogsend/plugin-postmark": "^0.11.0"
|
|
49
51
|
},
|
|
50
52
|
"devDependencies": {
|
|
51
53
|
"@types/node": "^22.15.3",
|
package/src/app.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { Auth } from "./lib/auth.js";
|
|
|
12
12
|
import { mountStudio } from "./lib/studio.js";
|
|
13
13
|
import type { ApiKeyContext } from "./middleware/api-key.js";
|
|
14
14
|
import { errorHandler } from "./middleware/error-handler.js";
|
|
15
|
+
import { clientIpKey, createRateLimit } from "./middleware/rate-limit.js";
|
|
15
16
|
import { requestLogger } from "./middleware/request-logger.js";
|
|
16
17
|
import { registerRoutes } from "./routes/index.js";
|
|
17
18
|
import type { DefinedWebhookSource } from "./webhook-sources/define-webhook-source.js";
|
|
@@ -89,21 +90,26 @@ export function createApp(
|
|
|
89
90
|
return c.json({ error: "Not Found" }, 404);
|
|
90
91
|
});
|
|
91
92
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
93
|
+
// Belt-and-suspenders throttle on the sign-up path. Public sign-up is now
|
|
94
|
+
// CLOSED at the better-auth layer (`disableSignUp: true` in lib/auth.ts ⇒ the
|
|
95
|
+
// now-ungated POST /api/auth/sign-up/email returns 400
|
|
96
|
+
// EMAIL_PASSWORD_SIGN_UP_DISABLED), so there is no token to brute-force here.
|
|
97
|
+
// We KEEP this IP-keyed sliding window anyway: it cheaply drops a flood at the
|
|
98
|
+
// edge before it reaches better-auth's handler (defence in depth, and it caps
|
|
99
|
+
// any future credential-probing on this path). Keyed by client IP because
|
|
100
|
+
// sign-up is unauthenticated — every request would otherwise collapse onto one
|
|
101
|
+
// "anonymous" bucket. `disableInTest: false` so the suite can still assert the
|
|
102
|
+
// 429. Distinct prefix → isolated budget.
|
|
103
|
+
const signUpRateLimit = createRateLimit({
|
|
104
|
+
prefix: "ratelimit:signup",
|
|
105
|
+
windowMs: 60_000,
|
|
106
|
+
max: 10,
|
|
107
|
+
keyFn: clientIpKey,
|
|
108
|
+
disableInTest: false,
|
|
109
|
+
});
|
|
95
110
|
app.use("/api/auth/sign-up/*", async (c, next) => {
|
|
96
|
-
if (c.req.method
|
|
97
|
-
|
|
98
|
-
const existing = await db.select({ id: user.id }).from(user).limit(1);
|
|
99
|
-
if (existing.length > 0) {
|
|
100
|
-
return c.json(
|
|
101
|
-
{ error: "Sign-ups are closed. An admin already exists." },
|
|
102
|
-
403,
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return next();
|
|
111
|
+
if (c.req.method !== "POST") return next();
|
|
112
|
+
return signUpRateLimit(c, next);
|
|
107
113
|
});
|
|
108
114
|
|
|
109
115
|
app.on(["POST", "GET"], "/api/auth/*", (c) => {
|
|
@@ -112,11 +118,16 @@ export function createApp(
|
|
|
112
118
|
});
|
|
113
119
|
|
|
114
120
|
// Public bootstrap probe: tells the Studio whether to show the first-run
|
|
115
|
-
// "
|
|
121
|
+
// "no admin yet" INFO screen (no users yet) instead of the login screen.
|
|
122
|
+
// Returns ONLY `{ needsSetup }`. Since public sign-up is closed, the Studio's
|
|
123
|
+
// zero-user state offers NO network path to create a user — the info screen
|
|
124
|
+
// points the operator at the CLI / env bootstrap instead.
|
|
116
125
|
app.get("/v1/auth/status", async (c) => {
|
|
117
|
-
const
|
|
126
|
+
const container = c.get("container");
|
|
127
|
+
const { db } = container;
|
|
118
128
|
const existing = await db.select({ id: user.id }).from(user).limit(1);
|
|
119
|
-
|
|
129
|
+
const needsSetup = existing.length === 0;
|
|
130
|
+
return c.json({ needsSetup });
|
|
120
131
|
});
|
|
121
132
|
|
|
122
133
|
// Merge env-enabled presets ahead of the consumer's explicit sources so a
|
package/src/container.ts
CHANGED
|
@@ -38,6 +38,8 @@ import { hatchet } from "./lib/hatchet.js";
|
|
|
38
38
|
import { createLogger, type Logger } from "./lib/logger.js";
|
|
39
39
|
import { createTrackedMailer } from "./lib/mailer.js";
|
|
40
40
|
import { getPostHog } from "./lib/posthog.js";
|
|
41
|
+
import { createRedisSecondaryStorage, getRedis } from "./lib/redis.js";
|
|
42
|
+
import { sendResetPasswordEmail } from "./lib/reset-email.js";
|
|
41
43
|
import { seedPostHogDestination } from "./lib/seed-posthog-destination.js";
|
|
42
44
|
import { prepareTrackedHtml } from "./lib/tracking.js";
|
|
43
45
|
import type { DefinedList } from "./lists/define-list.js";
|
|
@@ -247,26 +249,6 @@ export function createHogsendClient(
|
|
|
247
249
|
const created = createDatabase({ url: env.DATABASE_URL });
|
|
248
250
|
const db = opts.overrides?.db ?? created.db;
|
|
249
251
|
|
|
250
|
-
const auth =
|
|
251
|
-
opts.overrides?.auth ??
|
|
252
|
-
createAuth({
|
|
253
|
-
db,
|
|
254
|
-
secret: env.BETTER_AUTH_SECRET,
|
|
255
|
-
baseURL: env.BETTER_AUTH_URL,
|
|
256
|
-
// Always trust the public API origin; add any explicitly configured ones
|
|
257
|
-
// (e.g. a remote Studio origin) on top. baseURL is trusted automatically.
|
|
258
|
-
trustedOrigins: Array.from(
|
|
259
|
-
new Set(
|
|
260
|
-
[
|
|
261
|
-
env.API_PUBLIC_URL,
|
|
262
|
-
...(env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",") ?? []),
|
|
263
|
-
]
|
|
264
|
-
.map((o) => o.trim())
|
|
265
|
-
.filter(Boolean),
|
|
266
|
-
),
|
|
267
|
-
),
|
|
268
|
-
});
|
|
269
|
-
|
|
270
252
|
const registry = buildJourneyRegistry(
|
|
271
253
|
opts.journeys ?? [],
|
|
272
254
|
opts.enabledJourneys ?? env.ENABLED_JOURNEYS,
|
|
@@ -398,6 +380,61 @@ export function createHogsendClient(
|
|
|
398
380
|
|
|
399
381
|
setEmailService(emailService);
|
|
400
382
|
|
|
383
|
+
// Wire better-auth's secondary storage to the SHARED engine Redis (the same
|
|
384
|
+
// singleton backing the PostHog cache + worker heartbeat — never a second
|
|
385
|
+
// pool). Passing `secondaryStorage` flips better-auth's rate-limit store from
|
|
386
|
+
// the per-instance in-memory default to this shared store, so the sign-in /
|
|
387
|
+
// request-password-reset limiters are enforced ACROSS Railway replicas and
|
|
388
|
+
// survive restarts (security finding #2).
|
|
389
|
+
//
|
|
390
|
+
// Gate on the RAW `process.env.REDIS_URL`, NOT `env.REDIS_URL`: the latter
|
|
391
|
+
// carries a `redis://localhost:6379` zod default, so it is never empty and
|
|
392
|
+
// would wire secondary storage unconditionally. When an operator hasn't set
|
|
393
|
+
// REDIS_URL we deliberately keep better-auth's in-memory store rather than
|
|
394
|
+
// pushing SESSIONS into a Redis that may not exist — a wired secondaryStorage
|
|
395
|
+
// degrades `get` to null on a fault, which for sessions means silent
|
|
396
|
+
// logouts. `getRedis()` is lazyConnect, so this stays synchronous (no
|
|
397
|
+
// connection until the first auth command); on a transient Redis fault the
|
|
398
|
+
// adapter degrades to a no-op rather than failing the auth flow.
|
|
399
|
+
const authSecondaryStorage = process.env.REDIS_URL
|
|
400
|
+
? createRedisSecondaryStorage(getRedis())
|
|
401
|
+
: undefined;
|
|
402
|
+
|
|
403
|
+
// Auth is built AFTER the mailer so we can wire the self-service password-reset
|
|
404
|
+
// delivery to the just-built `emailService` directly (rather than relying on a
|
|
405
|
+
// singleton resolved at request time). The injected `sendResetPassword` is what
|
|
406
|
+
// flips better-auth's reset endpoints from disabled → live; the engine-owned,
|
|
407
|
+
// self-contained reset email needs no consumer template wiring, so reset works
|
|
408
|
+
// on a bare instance. NEVER log the url/token (see `sendResetPasswordEmail`).
|
|
409
|
+
const auth =
|
|
410
|
+
opts.overrides?.auth ??
|
|
411
|
+
createAuth({
|
|
412
|
+
db,
|
|
413
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
414
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
415
|
+
secondaryStorage: authSecondaryStorage,
|
|
416
|
+
// Always trust the public API origin; add any explicitly configured ones
|
|
417
|
+
// (e.g. a remote Studio origin) on top. baseURL is trusted automatically.
|
|
418
|
+
trustedOrigins: Array.from(
|
|
419
|
+
new Set(
|
|
420
|
+
[
|
|
421
|
+
env.API_PUBLIC_URL,
|
|
422
|
+
...(env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",") ?? []),
|
|
423
|
+
]
|
|
424
|
+
.map((o) => o.trim())
|
|
425
|
+
.filter(Boolean),
|
|
426
|
+
),
|
|
427
|
+
),
|
|
428
|
+
sendResetPassword: async ({ user, url }) => {
|
|
429
|
+
await sendResetPasswordEmail({
|
|
430
|
+
to: user.email,
|
|
431
|
+
url,
|
|
432
|
+
emailService,
|
|
433
|
+
logger,
|
|
434
|
+
});
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
|
|
401
438
|
const analytics = opts.analytics ?? getPostHog();
|
|
402
439
|
|
|
403
440
|
// Expose the resolved analytics instance to the module-level task-execution
|
package/src/env.ts
CHANGED
|
@@ -23,6 +23,18 @@ export const env = createEnv({
|
|
|
23
23
|
REDIS_URL: z.string().min(1).default("redis://localhost:6379"),
|
|
24
24
|
BETTER_AUTH_SECRET: z.string().min(1),
|
|
25
25
|
BETTER_AUTH_URL: z.string().url().default("http://localhost:3002"),
|
|
26
|
+
// --- First-admin bootstrap (replaces the web setup-token land-grab) ---
|
|
27
|
+
// Public sign-up is DISABLED (lib/auth.ts `disableSignUp`), so admins are
|
|
28
|
+
// created ONLY by the CLI (`hogsend studio admin create`) or this boot-time
|
|
29
|
+
// bootstrap. When STUDIO_ADMIN_EMAIL is set AND the user table is empty, the
|
|
30
|
+
// API process mints this admin on boot (idempotent — only on 0 users).
|
|
31
|
+
STUDIO_ADMIN_EMAIL: z.string().email().optional(),
|
|
32
|
+
// Optional password for the bootstrap admin. When set, it is used verbatim
|
|
33
|
+
// and NEVER logged. When omitted (but STUDIO_ADMIN_EMAIL is set), the engine
|
|
34
|
+
// auto-generates a strong password and prints it ONCE to the server log
|
|
35
|
+
// (the single intended secret-logging exception) — rotate it immediately via
|
|
36
|
+
// the Studio forgot/reset flow. Min length matches better-auth's policy.
|
|
37
|
+
STUDIO_ADMIN_PASSWORD: z.string().min(8).optional(),
|
|
26
38
|
// Extra origins allowed to call the auth endpoints (beyond BETTER_AUTH_URL),
|
|
27
39
|
// comma-separated. Needed when the Studio is served from a different origin
|
|
28
40
|
// than the API — e.g. the `hogsend studio` CLI pointing at a remote instance.
|
package/src/index.ts
CHANGED
|
@@ -128,7 +128,11 @@ export {
|
|
|
128
128
|
setJourneyRegistry,
|
|
129
129
|
} from "./journeys/registry-singleton.js";
|
|
130
130
|
// --- Auth ---
|
|
131
|
-
export {
|
|
131
|
+
export {
|
|
132
|
+
type Auth,
|
|
133
|
+
createAuth,
|
|
134
|
+
type SendResetPasswordFn,
|
|
135
|
+
} from "./lib/auth.js";
|
|
132
136
|
// --- Backfill ---
|
|
133
137
|
export {
|
|
134
138
|
type BatchedBackfillOptions,
|
|
@@ -143,11 +147,18 @@ export {
|
|
|
143
147
|
reportWorkerReady,
|
|
144
148
|
type WorkerReadyInfo,
|
|
145
149
|
} from "./lib/boot.js";
|
|
150
|
+
// --- First-admin creation (CLI + boot bootstrap share this scrypt-correct path)
|
|
151
|
+
export { bootstrapAdminFromEnv } from "./lib/bootstrap-admin.js";
|
|
146
152
|
// --- Bucket transition emission (shared by real-time / cron / fast-expiry) ---
|
|
147
153
|
export {
|
|
148
154
|
type BucketTransitionSource,
|
|
149
155
|
emitBucketTransition,
|
|
150
156
|
} from "./lib/bucket-emit.js";
|
|
157
|
+
export {
|
|
158
|
+
AdminAlreadyExistsError,
|
|
159
|
+
type CreatedAdmin,
|
|
160
|
+
createAdminUser,
|
|
161
|
+
} from "./lib/create-admin.js";
|
|
151
162
|
// --- Infrastructure singletons ---
|
|
152
163
|
export { getDb } from "./lib/db.js";
|
|
153
164
|
// --- Email ---
|
|
@@ -192,7 +203,13 @@ export {
|
|
|
192
203
|
type OutboundPayloads,
|
|
193
204
|
} from "./lib/outbound.js";
|
|
194
205
|
export { getPostHog } from "./lib/posthog.js";
|
|
195
|
-
export {
|
|
206
|
+
export {
|
|
207
|
+
type AuthSecondaryStorage,
|
|
208
|
+
createRedisSecondaryStorage,
|
|
209
|
+
getRedisIfConnected,
|
|
210
|
+
} from "./lib/redis.js";
|
|
211
|
+
// --- Self-service password reset (engine-owned, self-contained email) ---
|
|
212
|
+
export { sendResetPasswordEmail } from "./lib/reset-email.js";
|
|
196
213
|
export { type MountStudioResult, mountStudio } from "./lib/studio.js";
|
|
197
214
|
export {
|
|
198
215
|
type ResolveTimezoneInput,
|
package/src/lib/auth.ts
CHANGED
|
@@ -3,6 +3,25 @@ import * as schema from "@hogsend/db/schema";
|
|
|
3
3
|
import { betterAuth } from "better-auth";
|
|
4
4
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
5
5
|
import { organization } from "better-auth/plugins/organization";
|
|
6
|
+
import type { AuthSecondaryStorage } from "./redis.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Delivers the password-reset link. Injected by `createHogsendClient` (wired to
|
|
10
|
+
* the engine mailer) so the auth-construction layer stays decoupled from the
|
|
11
|
+
* email pipeline, and so tests can pass a spy and assert the callback fires
|
|
12
|
+
* without sending. Defaults to a no-op so a bare `createAuth({ db, secret,
|
|
13
|
+
* baseURL })` (e.g. the CLI's headless instance) doesn't try to send mail.
|
|
14
|
+
*
|
|
15
|
+
* Receives better-auth's `{ user, url, token }`: `url` is the
|
|
16
|
+
* `${baseURL}/api/auth/reset-password/:token?callbackURL=…` link that, when
|
|
17
|
+
* clicked, redirects the browser to the Studio reset route with `?token=`. NEVER
|
|
18
|
+
* log `url`/`token`.
|
|
19
|
+
*/
|
|
20
|
+
export type SendResetPasswordFn = (args: {
|
|
21
|
+
user: { email: string; id: string };
|
|
22
|
+
url: string;
|
|
23
|
+
token: string;
|
|
24
|
+
}) => Promise<void>;
|
|
6
25
|
|
|
7
26
|
export function createAuth(opts: {
|
|
8
27
|
db: Database;
|
|
@@ -14,21 +33,79 @@ export function createAuth(opts: {
|
|
|
14
33
|
* than the API (e.g. the `hogsend studio` CLI against a remote instance).
|
|
15
34
|
*/
|
|
16
35
|
trustedOrigins?: string[];
|
|
36
|
+
/**
|
|
37
|
+
* Self-service password-reset delivery. When provided, better-auth's
|
|
38
|
+
* `/request-password-reset` + `/reset-password` endpoints are live (without a
|
|
39
|
+
* `sendResetPassword` callback better-auth hard-errors `RESET_PASSWORD_DISABLED`).
|
|
40
|
+
* `createHogsendClient` wires this to the engine mailer; omit it (the default)
|
|
41
|
+
* for a headless instance that should not send mail (the CLI). The reset token
|
|
42
|
+
* is single-use, short-TTL (15 min), constant-time compared — all better-auth
|
|
43
|
+
* internals we inherit; we never re-implement them.
|
|
44
|
+
*/
|
|
45
|
+
sendResetPassword?: SendResetPasswordFn;
|
|
46
|
+
/**
|
|
47
|
+
* Shared cross-replica store for better-auth's session AND rate-limit data.
|
|
48
|
+
* When provided, better-auth resolves `rateLimit.storage` to "secondary-storage"
|
|
49
|
+
* instead of the in-memory default — so the sign-in / request-password-reset
|
|
50
|
+
* limiters are enforced GLOBALLY across Railway replicas and survive restarts,
|
|
51
|
+
* not per-instance (security finding #2). `createHogsendClient` wires this to
|
|
52
|
+
* the engine's shared Redis when available; omit it (the default) to keep
|
|
53
|
+
* better-auth's in-memory store on a bare instance with no Redis.
|
|
54
|
+
*/
|
|
55
|
+
secondaryStorage?: AuthSecondaryStorage;
|
|
17
56
|
}) {
|
|
18
|
-
const { db, secret, baseURL, trustedOrigins } = opts;
|
|
57
|
+
const { db, secret, baseURL, trustedOrigins, sendResetPassword } = opts;
|
|
19
58
|
return betterAuth({
|
|
20
59
|
basePath: "/api/auth",
|
|
21
60
|
secret,
|
|
22
61
|
baseURL,
|
|
23
62
|
...(trustedOrigins && trustedOrigins.length > 0 ? { trustedOrigins } : {}),
|
|
63
|
+
// Passing `secondaryStorage` flips better-auth's rate-limit storage from the
|
|
64
|
+
// per-instance in-memory default to this shared store (see option doc).
|
|
65
|
+
...(opts.secondaryStorage
|
|
66
|
+
? { secondaryStorage: opts.secondaryStorage }
|
|
67
|
+
: {}),
|
|
68
|
+
// Tighten the brute-force budget on the credential paths — better-auth's
|
|
69
|
+
// global default is a coarse 100 req / 10s per IP. Rate limiting is enabled
|
|
70
|
+
// in production by default; with `secondaryStorage` above these counters are
|
|
71
|
+
// shared across replicas rather than per-instance.
|
|
72
|
+
rateLimit: {
|
|
73
|
+
customRules: {
|
|
74
|
+
"/sign-in/email": { window: 60, max: 10 },
|
|
75
|
+
"/request-password-reset": { window: 60, max: 5 },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
24
78
|
database: drizzleAdapter(db, {
|
|
25
79
|
provider: "pg",
|
|
26
80
|
schema,
|
|
27
81
|
}),
|
|
28
82
|
emailAndPassword: {
|
|
29
83
|
enabled: true,
|
|
84
|
+
// 🔒 Public sign-up is closed: there is NO unauthenticated network path
|
|
85
|
+
// that creates a user. In better-auth 1.6.11 this guard lives INSIDE the
|
|
86
|
+
// sign-up endpoint handler, so it blocks BOTH POST /api/auth/sign-up/email
|
|
87
|
+
// (→ 400 EMAIL_PASSWORD_SIGN_UP_DISABLED) AND the in-process
|
|
88
|
+
// `auth.api.signUpEmail`. Admins are minted only by the CLI (DB-direct)
|
|
89
|
+
// and the boot env bootstrap, both via the internal adapter. Login + the
|
|
90
|
+
// self-service forgot/reset endpoints are untouched (disableSignUp only
|
|
91
|
+
// gates sign-up).
|
|
92
|
+
disableSignUp: true,
|
|
30
93
|
minPasswordLength: 8,
|
|
31
94
|
maxPasswordLength: 128,
|
|
95
|
+
// Self-service reset is enabled ONLY when a sender is injected (otherwise
|
|
96
|
+
// the endpoints stay disabled rather than 500 on a missing callback).
|
|
97
|
+
...(sendResetPassword
|
|
98
|
+
? {
|
|
99
|
+
// Short TTL (overrides better-auth's 3600s default). The token is
|
|
100
|
+
// also single-use (deleted on consume) and constant-time compared —
|
|
101
|
+
// better-auth internals we inherit.
|
|
102
|
+
resetPasswordTokenExpiresIn: 60 * 15,
|
|
103
|
+
// A reset kills existing sessions, so a recovered account can't be
|
|
104
|
+
// ridden by a stale/leaked session.
|
|
105
|
+
revokeSessionsOnPasswordReset: true,
|
|
106
|
+
sendResetPassword,
|
|
107
|
+
}
|
|
108
|
+
: {}),
|
|
32
109
|
},
|
|
33
110
|
session: {
|
|
34
111
|
expiresIn: 60 * 60 * 24 * 7,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { user } from "@hogsend/db";
|
|
3
|
+
import type { HogsendClient } from "../container.js";
|
|
4
|
+
import { AdminAlreadyExistsError, createAdminUser } from "./create-admin.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Boot-time first-admin bootstrap. Replaces the old web setup-token land-grab:
|
|
8
|
+
* with public sign-up disabled (lib/auth.ts `disableSignUp`), admins are minted
|
|
9
|
+
* ONLY by the CLI (DB-direct) or this in-process boot path — there is NO
|
|
10
|
+
* unauthenticated network path that creates a user.
|
|
11
|
+
*
|
|
12
|
+
* Contract (all conditions must hold to mint):
|
|
13
|
+
* - `STUDIO_ADMIN_EMAIL` is set (unset ⇒ no-op; CLI is then the only path).
|
|
14
|
+
* - The `user` table has ZERO rows (idempotent: never mints over an existing
|
|
15
|
+
* user, never rotates/re-prints anything once an admin exists).
|
|
16
|
+
*
|
|
17
|
+
* Password resolution:
|
|
18
|
+
* - `STUDIO_ADMIN_PASSWORD` if set (NEVER logged).
|
|
19
|
+
* - else an auto-generated strong password (base64url, >=16 chars) PRINTED
|
|
20
|
+
* ONCE to the server log — the single intended secret-logging exception,
|
|
21
|
+
* clearly labelled "shown once". The operator should rotate it immediately
|
|
22
|
+
* via the self-service forgot/reset flow (retained, revokes sessions).
|
|
23
|
+
*
|
|
24
|
+
* Concurrency: two API replicas booting on a fresh DB could both pass the
|
|
25
|
+
* 0-rows check; the `user.email` unique constraint makes the loser's
|
|
26
|
+
* `createUser` throw — caught here and treated as "already created" (no-op, no
|
|
27
|
+
* log of the loser's generated password). Invariant preserved: exactly one
|
|
28
|
+
* admin, no unauthenticated path.
|
|
29
|
+
*
|
|
30
|
+
* Never blocks boot beyond a clear fatal when an explicitly-set password is too
|
|
31
|
+
* weak — that validation lives in env.ts (`STUDIO_ADMIN_PASSWORD.min(8)`), so
|
|
32
|
+
* by the time we run the password is already known-valid or auto-generated.
|
|
33
|
+
*/
|
|
34
|
+
export async function bootstrapAdminFromEnv(opts: {
|
|
35
|
+
client: HogsendClient;
|
|
36
|
+
}): Promise<void> {
|
|
37
|
+
const { db, auth, env, logger } = opts.client;
|
|
38
|
+
|
|
39
|
+
const email = env.STUDIO_ADMIN_EMAIL;
|
|
40
|
+
if (!email) return;
|
|
41
|
+
|
|
42
|
+
// Idempotency gate: only mint into a fresh DB (zero users). Reuses the same
|
|
43
|
+
// zero-check the old /v1/auth/status used.
|
|
44
|
+
const existing = await db.select({ id: user.id }).from(user).limit(1);
|
|
45
|
+
if (existing.length > 0) return;
|
|
46
|
+
|
|
47
|
+
// Auto-generate when no explicit password: base64url(18 bytes) ⇒ 24 chars,
|
|
48
|
+
// ~144 bits of entropy. We only log the generated value, never an env one.
|
|
49
|
+
const explicit = env.STUDIO_ADMIN_PASSWORD;
|
|
50
|
+
const password = explicit ?? randomBytes(18).toString("base64url");
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const admin = await createAdminUser({ auth, email, password });
|
|
54
|
+
|
|
55
|
+
if (!explicit) {
|
|
56
|
+
// The ONE intended secret-logging exception (auto-generated only). Shown
|
|
57
|
+
// once — never re-printed (we only reach here on a zero-user DB).
|
|
58
|
+
logger.warn(
|
|
59
|
+
`[studio] First admin created: ${admin.email}. ` +
|
|
60
|
+
`Generated password (save this, shown once): ${password}`,
|
|
61
|
+
);
|
|
62
|
+
logger.warn(
|
|
63
|
+
"[studio] Rotate it now via the Studio forgot-password flow " +
|
|
64
|
+
"(or set STUDIO_ADMIN_PASSWORD).",
|
|
65
|
+
);
|
|
66
|
+
} else {
|
|
67
|
+
logger.info(`[studio] First admin created from env: ${admin.email}.`);
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (err instanceof AdminAlreadyExistsError) {
|
|
71
|
+
// A concurrent replica won the race (or the user appeared between the
|
|
72
|
+
// zero-check and the insert). No-op — never log the generated password.
|
|
73
|
+
logger.debug(
|
|
74
|
+
"[studio] First-admin bootstrap skipped: an admin already exists.",
|
|
75
|
+
);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// A unique-violation surfaced by the adapter (not our pre-check) is the same
|
|
79
|
+
// race; treat any duplicate-key error as "already created". Anything else is
|
|
80
|
+
// unexpected — surface it without leaking the password.
|
|
81
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
82
|
+
if (/duplicate key|unique constraint|already exists/i.test(message)) {
|
|
83
|
+
logger.debug(
|
|
84
|
+
"[studio] First-admin bootstrap lost a creation race; skipping.",
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
logger.error("[studio] First-admin bootstrap failed.", { error: message });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Auth } from "./auth.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared admin-minting primitive used by BOTH the CLI's `admin create` and the
|
|
5
|
+
* engine's env bootstrap. Mints a credential admin via better-auth's
|
|
6
|
+
* INTERNAL ADAPTER (scrypt-identical to the running app) rather than the public
|
|
7
|
+
* sign-up endpoint — which is now blocked by `disableSignUp` (see lib/auth.ts).
|
|
8
|
+
*
|
|
9
|
+
* Why the internal adapter (not `auth.api.signUpEmail`): in better-auth 1.6.11
|
|
10
|
+
* the `disableSignUp` check lives INSIDE the sign-up endpoint handler, and
|
|
11
|
+
* `auth.api.signUpEmail` dispatches through that SAME handler — so with sign-up
|
|
12
|
+
* disabled it throws `EMAIL_PASSWORD_SIGN_UP_DISABLED` for the in-process API
|
|
13
|
+
* too. The internal adapter is NOT subject to that guard. This mirrors exactly
|
|
14
|
+
* what admin-recovery's `reset()` already does for its no-credential branch
|
|
15
|
+
* (`ctx.password.hash` + `ctx.internalAdapter.createAccount({ providerId:
|
|
16
|
+
* "credential" })`).
|
|
17
|
+
*
|
|
18
|
+
* Security invariants (acceptance gates, not preferences):
|
|
19
|
+
* - The password is hashed via `ctx.password.hash` (scrypt, identical to the
|
|
20
|
+
* app). There is NO raw SQL password write.
|
|
21
|
+
* - The password is never logged and never returned in any result object.
|
|
22
|
+
* - `emailVerified: true` because this is an operator-minted admin (CLI or
|
|
23
|
+
* boot env), not a self-service signup.
|
|
24
|
+
*
|
|
25
|
+
* Lives in `lib/` (reachable via the `@hogsend/engine/create-admin` subpath)
|
|
26
|
+
* with a module graph that touches ONLY better-auth — it never pulls `env.ts`,
|
|
27
|
+
* Hatchet, or Resend — so the CLI can import it the same way it imports
|
|
28
|
+
* `createAuth` from `@hogsend/engine/auth`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** A single admin row, no secrets. Shared with the CLI's `AdminSummary`. */
|
|
32
|
+
export interface CreatedAdmin {
|
|
33
|
+
id: string;
|
|
34
|
+
email: string;
|
|
35
|
+
name: string;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Thrown when an admin with the given email already exists. */
|
|
40
|
+
export class AdminAlreadyExistsError extends Error {
|
|
41
|
+
constructor(public readonly email: string) {
|
|
42
|
+
super(
|
|
43
|
+
`An admin with email "${email}" already exists. ` +
|
|
44
|
+
"Use `hogsend studio admin reset` to set a new password.",
|
|
45
|
+
);
|
|
46
|
+
this.name = "AdminAlreadyExistsError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create a credential admin user against a built better-auth instance. Throws
|
|
52
|
+
* {@link AdminAlreadyExistsError} if the email already exists (so callers can
|
|
53
|
+
* point the operator at `reset`). The unique constraint on `user.email` is the
|
|
54
|
+
* backstop: a concurrent racer that slips past the pre-check throws on
|
|
55
|
+
* `createUser` — callers that need idempotency should catch that.
|
|
56
|
+
*/
|
|
57
|
+
export async function createAdminUser(opts: {
|
|
58
|
+
auth: Auth;
|
|
59
|
+
email: string;
|
|
60
|
+
name?: string;
|
|
61
|
+
password: string;
|
|
62
|
+
}): Promise<CreatedAdmin> {
|
|
63
|
+
const { auth, email, password } = opts;
|
|
64
|
+
const displayName = opts.name ?? email.split("@")[0] ?? email;
|
|
65
|
+
|
|
66
|
+
const ctx = await auth.$context;
|
|
67
|
+
|
|
68
|
+
// Pre-check for a clear error (better-auth lowercases on lookup + create).
|
|
69
|
+
const existing = await ctx.internalAdapter.findUserByEmail(email);
|
|
70
|
+
if (existing) {
|
|
71
|
+
throw new AdminAlreadyExistsError(email);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// scrypt hash — identical to the running app (admin-recovery.reset uses the
|
|
75
|
+
// same call). NO raw SQL password write.
|
|
76
|
+
const hashed = await ctx.password.hash(password);
|
|
77
|
+
|
|
78
|
+
// `createUser` returns the bare user row (createWithHooks → the created user),
|
|
79
|
+
// NOT `{ user }` — verified against better-auth@1.6.11 internal-adapter.mjs:75.
|
|
80
|
+
const created = await ctx.internalAdapter.createUser({
|
|
81
|
+
email,
|
|
82
|
+
name: displayName,
|
|
83
|
+
emailVerified: true,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await ctx.internalAdapter.createAccount({
|
|
87
|
+
userId: created.id,
|
|
88
|
+
providerId: "credential",
|
|
89
|
+
accountId: created.id,
|
|
90
|
+
password: hashed,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const createdAt =
|
|
94
|
+
created.createdAt instanceof Date
|
|
95
|
+
? created.createdAt.toISOString()
|
|
96
|
+
: String(created.createdAt ?? new Date().toISOString());
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
id: created.id,
|
|
100
|
+
email: created.email,
|
|
101
|
+
name: created.name,
|
|
102
|
+
createdAt,
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/lib/redis.ts
CHANGED
|
@@ -28,3 +28,71 @@ export function getRedis(): Redis {
|
|
|
28
28
|
export function getRedisIfConnected(): Redis | undefined {
|
|
29
29
|
return _redis;
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Namespace for better-auth's secondary-storage keys so they never collide with
|
|
34
|
+
* the PostHog person-property cache or the worker heartbeat sharing this Redis.
|
|
35
|
+
*/
|
|
36
|
+
const AUTH_STORAGE_PREFIX = "hogsend:auth:";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The minimal shape better-auth's `secondaryStorage` option expects. Mirrors
|
|
40
|
+
* `@better-auth/core`'s `SecondaryStorage` so we don't pull a type out of a deep
|
|
41
|
+
* subpath: `get` returns the raw stored string (better-auth `JSON.parse`s it),
|
|
42
|
+
* `set` takes an optional TTL in SECONDS, `delete` removes the key.
|
|
43
|
+
*/
|
|
44
|
+
export interface AuthSecondaryStorage {
|
|
45
|
+
get: (key: string) => Promise<string | null>;
|
|
46
|
+
set: (key: string, value: string, ttl?: number) => Promise<void>;
|
|
47
|
+
delete: (key: string) => Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Adapt an ioredis client to better-auth's `secondaryStorage` contract so all
|
|
52
|
+
* better-auth session AND rate-limit counters live in Redis — shared across
|
|
53
|
+
* Railway replicas and surviving restarts. Without this, better-auth defaults
|
|
54
|
+
* `rateLimit.storage` to in-memory (per-instance, reset on redeploy), so the
|
|
55
|
+
* sign-in / request-password-reset limits are materially weaker than they look
|
|
56
|
+
* on a multi-replica deploy (security finding #2).
|
|
57
|
+
*
|
|
58
|
+
* Reuses the SHARED engine Redis singleton ({@link getRedis}) — it never opens a
|
|
59
|
+
* second pool. better-auth gives `set` a TTL in SECONDS, which we honour with
|
|
60
|
+
* `EX`; entries with no TTL persist (matching better-auth's own behaviour).
|
|
61
|
+
*
|
|
62
|
+
* Every operation is wrapped so a Redis blip degrades gracefully instead of
|
|
63
|
+
* crashing the auth flow: `get` returns `null` (better-auth treats it as a
|
|
64
|
+
* miss), `set`/`delete` no-op. We never want a transient cache fault to take
|
|
65
|
+
* down sign-in.
|
|
66
|
+
*/
|
|
67
|
+
export function createRedisSecondaryStorage(
|
|
68
|
+
redis: Redis,
|
|
69
|
+
): AuthSecondaryStorage {
|
|
70
|
+
const k = (key: string) => `${AUTH_STORAGE_PREFIX}${key}`;
|
|
71
|
+
return {
|
|
72
|
+
async get(key) {
|
|
73
|
+
try {
|
|
74
|
+
return await redis.get(k(key));
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
async set(key, value, ttl) {
|
|
80
|
+
try {
|
|
81
|
+
if (typeof ttl === "number" && ttl > 0) {
|
|
82
|
+
await redis.set(k(key), value, "EX", ttl);
|
|
83
|
+
} else {
|
|
84
|
+
await redis.set(k(key), value);
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Degrade to no-op — never fail the auth flow on a cache write.
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
async delete(key) {
|
|
91
|
+
try {
|
|
92
|
+
await redis.del(k(key));
|
|
93
|
+
} catch {
|
|
94
|
+
// Degrade to no-op.
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { getEmailService } from "./email.js";
|
|
2
|
+
import type { EmailService } from "./email-service-types.js";
|
|
3
|
+
import { createLogger, type Logger } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
// Fallback logger for the no-provider warning — callers may not pass one. Mirrors
|
|
6
|
+
// the engine-lib singleton pattern (mailer, define-journey, tracked).
|
|
7
|
+
const fallbackLogger = createLogger(process.env.LOG_LEVEL);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The TTL the reset link advertises in its copy. Mirrors the
|
|
11
|
+
* `resetPasswordTokenExpiresIn` we configure in `createAuth` (15 minutes) so the
|
|
12
|
+
* email never claims a window that doesn't match the server's enforcement.
|
|
13
|
+
*/
|
|
14
|
+
const RESET_TTL_MINUTES = 15;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The engine-owned, self-contained password-reset email. The engine ships NO
|
|
18
|
+
* business templates (those live in the consumer's `src/emails/`), so a reset
|
|
19
|
+
* email that required a consumer template would break the "works out of the box"
|
|
20
|
+
* guarantee. This builds a tiny inline HTML + plaintext body — no React Email
|
|
21
|
+
* dependency, no template-registry lookup — and sends it through the resolved
|
|
22
|
+
* provider via the mailer's RAW path.
|
|
23
|
+
*
|
|
24
|
+
* `sendRaw` is correct here (not `send`): a password reset is strictly
|
|
25
|
+
* transactional and must bypass template resolution AND the
|
|
26
|
+
* preference/suppression check — a recovering operator must always receive it,
|
|
27
|
+
* marketing opt-out or not. There is no tracking pixel and no unsubscribe footer
|
|
28
|
+
* for the same reason.
|
|
29
|
+
*
|
|
30
|
+
* Security:
|
|
31
|
+
* - NEVER logs the `url` or the token. On a delivery failure we log a generic
|
|
32
|
+
* warning (pointing the operator at the CLI) and RESOLVE without throwing, so
|
|
33
|
+
* better-auth's neutral "if this email exists…" response is preserved (no user
|
|
34
|
+
* enumeration, no leak of whether the address was real).
|
|
35
|
+
* - The `from` resolves from `EMAIL_FROM ?? RESEND_FROM_EMAIL` — but we don't
|
|
36
|
+
* pass it explicitly: the mailer's `sendRaw` defaults `from` to its configured
|
|
37
|
+
* `defaultFrom`, which is exactly that pair.
|
|
38
|
+
*/
|
|
39
|
+
export async function sendResetPasswordEmail(opts: {
|
|
40
|
+
to: string;
|
|
41
|
+
url: string;
|
|
42
|
+
/**
|
|
43
|
+
* The mailer to send through. Optional: defaults to the container-installed
|
|
44
|
+
* singleton (`getEmailService()`). Injectable so tests can pass a spy and
|
|
45
|
+
* assert the send fires without touching a real provider.
|
|
46
|
+
*/
|
|
47
|
+
emailService?: EmailService;
|
|
48
|
+
/** Optional structured logger; defaults to a stdout logger. */
|
|
49
|
+
logger?: Logger;
|
|
50
|
+
}): Promise<void> {
|
|
51
|
+
const { to, url } = opts;
|
|
52
|
+
const log = opts.logger ?? fallbackLogger;
|
|
53
|
+
|
|
54
|
+
let service: EmailService;
|
|
55
|
+
try {
|
|
56
|
+
service = opts.emailService ?? getEmailService();
|
|
57
|
+
} catch {
|
|
58
|
+
// The mailer singleton hasn't been installed (container never booted). Steer
|
|
59
|
+
// the operator to the guaranteed recovery path; never throw (preserves the
|
|
60
|
+
// neutral response). Do NOT log the url/token.
|
|
61
|
+
log.warn(
|
|
62
|
+
"password reset requested but no email service is configured — use `hogsend studio admin reset`",
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const subject = "Reset your Hogsend Studio password";
|
|
68
|
+
const html = buildResetHtml(url);
|
|
69
|
+
const text = buildResetText(url);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await service.sendRaw({ to, subject, html, text });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
// A provider error (missing/invalid key, network) must not surface to the
|
|
75
|
+
// caller — better-auth's neutral response stays intact. Log a generic
|
|
76
|
+
// warning that points at the CLI fallback. NEVER include the url/token.
|
|
77
|
+
log.warn(
|
|
78
|
+
"password reset email failed to send (no usable email provider?) — use `hogsend studio admin reset`",
|
|
79
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Minimal, dependency-free HTML body. The URL appears as a button and a raw
|
|
85
|
+
* link (so it works even when buttons are stripped). No tracking, no footer. */
|
|
86
|
+
function buildResetHtml(url: string): string {
|
|
87
|
+
const safeUrl = escapeHtmlAttr(url);
|
|
88
|
+
const safeText = escapeHtml(url);
|
|
89
|
+
return `<!doctype html>
|
|
90
|
+
<html lang="en">
|
|
91
|
+
<body style="margin:0;padding:24px;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#18181b;">
|
|
92
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;padding:32px;">
|
|
93
|
+
<tr><td>
|
|
94
|
+
<h1 style="margin:0 0 16px;font-size:20px;font-weight:600;">Reset your password</h1>
|
|
95
|
+
<p style="margin:0 0 24px;font-size:14px;line-height:1.6;color:#3f3f46;">
|
|
96
|
+
We received a request to reset your Hogsend Studio password. Click the
|
|
97
|
+
button below to choose a new one.
|
|
98
|
+
</p>
|
|
99
|
+
<p style="margin:0 0 24px;">
|
|
100
|
+
<a href="${safeUrl}" style="display:inline-block;background:#18181b;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;padding:12px 20px;border-radius:6px;">Reset password</a>
|
|
101
|
+
</p>
|
|
102
|
+
<p style="margin:0 0 16px;font-size:13px;line-height:1.6;color:#71717a;">
|
|
103
|
+
Or paste this link into your browser:<br />
|
|
104
|
+
<a href="${safeUrl}" style="color:#2563eb;word-break:break-all;">${safeText}</a>
|
|
105
|
+
</p>
|
|
106
|
+
<p style="margin:0;font-size:12px;line-height:1.6;color:#a1a1aa;">
|
|
107
|
+
This link expires in ${RESET_TTL_MINUTES} minutes and can be used once.
|
|
108
|
+
If you didn't request a password reset, you can safely ignore this email.
|
|
109
|
+
</p>
|
|
110
|
+
</td></tr>
|
|
111
|
+
</table>
|
|
112
|
+
</body>
|
|
113
|
+
</html>`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Plain-text alternative (same content, no markup). */
|
|
117
|
+
function buildResetText(url: string): string {
|
|
118
|
+
return [
|
|
119
|
+
"Reset your password",
|
|
120
|
+
"",
|
|
121
|
+
"We received a request to reset your Hogsend Studio password.",
|
|
122
|
+
"Open this link to choose a new one:",
|
|
123
|
+
"",
|
|
124
|
+
url,
|
|
125
|
+
"",
|
|
126
|
+
`This link expires in ${RESET_TTL_MINUTES} minutes and can be used once.`,
|
|
127
|
+
"If you didn't request a password reset, you can safely ignore this email.",
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Escape for an HTML text node. */
|
|
132
|
+
function escapeHtml(s: string): string {
|
|
133
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Escape for a double-quoted HTML attribute (e.g. an `href`). */
|
|
137
|
+
function escapeHtmlAttr(s: string): string {
|
|
138
|
+
return escapeHtml(s).replace(/"/g, """);
|
|
139
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
1
2
|
import { createMiddleware } from "hono/factory";
|
|
2
3
|
import type { AppEnv } from "../app.js";
|
|
3
4
|
import { getRedis } from "../lib/redis.js";
|
|
@@ -11,6 +12,38 @@ export interface RateLimitOptions {
|
|
|
11
12
|
windowMs?: number;
|
|
12
13
|
max?: number;
|
|
13
14
|
prefix?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Derive the per-request bucket key. Default keys on the resolved api-key /
|
|
17
|
+
* user id (falling back to "anonymous") — correct for the authenticated data
|
|
18
|
+
* plane. Pass `clientIpKey` for UNAUTHENTICATED surfaces (e.g. the first-admin
|
|
19
|
+
* sign-up gate) where every request would otherwise collapse onto the single
|
|
20
|
+
* "anonymous" bucket and let one attacker exhaust the budget for everyone.
|
|
21
|
+
*/
|
|
22
|
+
keyFn?: (c: Context<AppEnv>) => string;
|
|
23
|
+
/**
|
|
24
|
+
* The middleware no-ops under `NODE_ENV=test` by default so the suite isn't
|
|
25
|
+
* throttled. Set `false` to keep it ACTIVE in tests — required to assert the
|
|
26
|
+
* unauthenticated sign-up limiter actually returns 429 past the threshold.
|
|
27
|
+
*/
|
|
28
|
+
disableInTest?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Best-effort client IP from the proxy headers Railway/Cloudflare set (same
|
|
33
|
+
* source as `audit.ts` / tracking). Falls back to "unknown" so a request with
|
|
34
|
+
* no forwarded IP still shares a single bounded bucket rather than bypassing.
|
|
35
|
+
*/
|
|
36
|
+
function clientIp(c: Context<AppEnv>): string {
|
|
37
|
+
return (
|
|
38
|
+
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
39
|
+
c.req.header("x-real-ip") ||
|
|
40
|
+
"unknown"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Bucket key for unauthenticated, IP-scoped surfaces (e.g. sign-up). */
|
|
45
|
+
export function clientIpKey(c: Context<AppEnv>): string {
|
|
46
|
+
return `ip:${clientIp(c)}`;
|
|
14
47
|
}
|
|
15
48
|
|
|
16
49
|
/**
|
|
@@ -25,6 +58,7 @@ export function createRateLimit(opts: RateLimitOptions = {}) {
|
|
|
25
58
|
const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS;
|
|
26
59
|
const max = opts.max ?? DEFAULT_MAX_REQUESTS;
|
|
27
60
|
const prefix = opts.prefix ?? DEFAULT_PREFIX;
|
|
61
|
+
const disableInTest = opts.disableInTest ?? true;
|
|
28
62
|
|
|
29
63
|
// Per-instance memory store so prefixes stay budget-isolated in the
|
|
30
64
|
// Redis-less fallback path too.
|
|
@@ -32,10 +66,11 @@ export function createRateLimit(opts: RateLimitOptions = {}) {
|
|
|
32
66
|
let cleanupCounter = 0;
|
|
33
67
|
|
|
34
68
|
return createMiddleware<AppEnv>(async (c, next) => {
|
|
35
|
-
if (process.env.NODE_ENV === "test") return next();
|
|
69
|
+
if (disableInTest && process.env.NODE_ENV === "test") return next();
|
|
36
70
|
|
|
37
|
-
const
|
|
38
|
-
|
|
71
|
+
const keyId = opts.keyFn
|
|
72
|
+
? opts.keyFn(c)
|
|
73
|
+
: (c.get("apiKey")?.id ?? c.get("user")?.id ?? "anonymous");
|
|
39
74
|
const now = Date.now();
|
|
40
75
|
|
|
41
76
|
let count: number;
|