@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.10.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.10.0",
42
- "@hogsend/db": "^0.10.0",
43
- "@hogsend/email": "^0.10.0",
44
- "@hogsend/plugin-posthog": "^0.10.0",
45
- "@hogsend/plugin-resend": "^0.10.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.10.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
- // Closed signup: the first user may register (first-load "create admin");
93
- // once any user exists, sign-up is blocked. This is the security control that
94
- // lets `requireAdmin` trust any authenticated session in a single-tenant app.
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 === "POST") {
97
- const { db } = c.get("container");
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
- // "create admin" screen (no users yet) instead of the login screen.
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 { db } = c.get("container");
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
- return c.json({ needsSetup: existing.length === 0 });
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 { type Auth, createAuth } from "./lib/auth.js";
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 { getRedisIfConnected } from "./lib/redis.js";
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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, "&quot;");
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 apiKey = c.get("apiKey");
38
- const keyId = apiKey?.id ?? c.get("user")?.id ?? "anonymous";
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;