@hogsend/engine 0.9.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.9.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,11 +40,14 @@
38
40
  "svix": "^1.95.1",
39
41
  "winston": "^3.19.0",
40
42
  "zod": "^4.4.3",
41
- "@hogsend/core": "^0.9.0",
42
- "@hogsend/db": "^0.9.0",
43
- "@hogsend/email": "^0.9.0",
44
- "@hogsend/plugin-posthog": "^0.9.0",
45
- "@hogsend/plugin-resend": "^0.9.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"
48
+ },
49
+ "optionalDependencies": {
50
+ "@hogsend/plugin-postmark": "^0.11.0"
46
51
  },
47
52
  "devDependencies": {
48
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
@@ -9,11 +9,6 @@ import {
9
9
  type JournalShape,
10
10
  } from "@hogsend/db";
11
11
  import type { TemplateRegistry } from "@hogsend/email";
12
- import {
13
- createResendClient,
14
- createResendProvider,
15
- } from "@hogsend/plugin-resend";
16
- import type { Resend } from "resend";
17
12
  import { createBucketAccessor } from "./buckets/bucket-access.js";
18
13
  import type { DefinedBucket } from "./buckets/define-bucket.js";
19
14
  import {
@@ -33,6 +28,8 @@ import { buildJourneyRegistry } from "./journeys/registry.js";
33
28
  import { setAnalytics } from "./lib/analytics-singleton.js";
34
29
  import { type Auth, createAuth } from "./lib/auth.js";
35
30
  import { setEmailService } from "./lib/email.js";
31
+ import { EmailProviderRegistry } from "./lib/email-provider-registry.js";
32
+ import { emailProvidersFromEnv } from "./lib/email-providers-from-env.js";
36
33
  import type {
37
34
  EmailService,
38
35
  FrequencyCapConfig,
@@ -41,6 +38,8 @@ import { hatchet } from "./lib/hatchet.js";
41
38
  import { createLogger, type Logger } from "./lib/logger.js";
42
39
  import { createTrackedMailer } from "./lib/mailer.js";
43
40
  import { getPostHog } from "./lib/posthog.js";
41
+ import { createRedisSecondaryStorage, getRedis } from "./lib/redis.js";
42
+ import { sendResetPasswordEmail } from "./lib/reset-email.js";
44
43
  import { seedPostHogDestination } from "./lib/seed-posthog-destination.js";
45
44
  import { prepareTrackedHtml } from "./lib/tracking.js";
46
45
  import type { DefinedList } from "./lists/define-list.js";
@@ -61,8 +60,19 @@ export interface HogsendClient {
61
60
  db: Database;
62
61
  dbClient: DatabaseClient;
63
62
  auth: Auth;
64
- email: Resend;
65
63
  emailService: EmailService;
64
+ /**
65
+ * The container-held registry of email providers, keyed by `meta.id`. The
66
+ * `POST /v1/webhooks/email/:providerId` route resolves the verifying provider
67
+ * out of this. Holds at least the resolved active provider.
68
+ */
69
+ emailProviders: EmailProviderRegistry;
70
+ /**
71
+ * The single resolved active email provider (the one the mailer sends
72
+ * through). Resolved from `opts.email.defaultProvider` / `EMAIL_PROVIDER`,
73
+ * defaulting to the env-built Resend provider for byte-for-byte parity.
74
+ */
75
+ emailProvider: EmailProvider;
66
76
  /**
67
77
  * The app's template registry (key → component + subject + category +
68
78
  * optional preview/examples). Same object threaded into the engine mailer;
@@ -121,10 +131,18 @@ export interface HogsendClientOptions {
121
131
  * (templates → render → preference checks → tracking → `email_sends` write),
122
132
  * and the {@link EmailProvider} is only the swappable wire under it.
123
133
  *
124
- * - `provider` — the swappable email provider (Resend, Postmark, SES…).
125
- * Defaults to a Resend provider built from env (`RESEND_API_KEY` /
126
- * `RESEND_WEBHOOK_SECRET`). Tracking/rendering/preferences come along for
127
- * free regardless of which provider you supply.
134
+ * - `provider` — a single swappable email provider (Resend, Postmark, SES…),
135
+ * the back-compat one-provider seam. MERGED LAST (after env presets and
136
+ * `providers`), so it wins on id collision. Tracking/rendering/preferences
137
+ * come along for free regardless of which provider you supply.
138
+ * - `providers` — register MANY providers into the {@link EmailProviderRegistry}
139
+ * (e.g. Resend + Postmark) so the `POST /v1/webhooks/email/:providerId`
140
+ * route can verify each one's webhooks. Merged AFTER the env presets and
141
+ * BEFORE `provider`.
142
+ * - `defaultProvider` — the active provider id the mailer sends through.
143
+ * Resolves as `defaultProvider ?? EMAIL_PROVIDER ?? "resend"`. If it names a
144
+ * provider that isn't registered, the container throws at boot with the list
145
+ * of registered ids.
128
146
  * - `templates` — the app's template registry (key → component + subject +
129
147
  * category), threaded into the engine mailer and onward to
130
148
  * `getTemplate(..., { registry })`. The engine bakes in no business
@@ -136,6 +154,8 @@ export interface HogsendClientOptions {
136
154
  */
137
155
  email?: {
138
156
  provider?: EmailProvider;
157
+ providers?: EmailProvider[];
158
+ defaultProvider?: string;
139
159
  templates?: TemplateRegistry;
140
160
  };
141
161
  /**
@@ -229,28 +249,6 @@ export function createHogsendClient(
229
249
  const created = createDatabase({ url: env.DATABASE_URL });
230
250
  const db = opts.overrides?.db ?? created.db;
231
251
 
232
- const auth =
233
- opts.overrides?.auth ??
234
- createAuth({
235
- db,
236
- secret: env.BETTER_AUTH_SECRET,
237
- baseURL: env.BETTER_AUTH_URL,
238
- // Always trust the public API origin; add any explicitly configured ones
239
- // (e.g. a remote Studio origin) on top. baseURL is trusted automatically.
240
- trustedOrigins: Array.from(
241
- new Set(
242
- [
243
- env.API_PUBLIC_URL,
244
- ...(env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",") ?? []),
245
- ]
246
- .map((o) => o.trim())
247
- .filter(Boolean),
248
- ),
249
- ),
250
- });
251
-
252
- const email = createResendClient({ apiKey: env.RESEND_API_KEY });
253
-
254
252
  const registry = buildJourneyRegistry(
255
253
  opts.journeys ?? [],
256
254
  opts.enabledJourneys ?? env.ENABLED_JOURNEYS,
@@ -301,12 +299,51 @@ export function createHogsendClient(
301
299
  opts.enabledLists ?? env.ENABLED_LISTS,
302
300
  );
303
301
 
304
- const provider =
305
- opts.email?.provider ??
306
- createResendProvider({
307
- apiKey: env.RESEND_API_KEY,
308
- webhookSecret: env.RESEND_WEBHOOK_SECRET,
309
- });
302
+ // Build the email provider registry, then resolve the single active provider
303
+ // the mailer sends through. Merge order is load-bearing (consumer last/wins,
304
+ // mirroring the destinations merge): env presets FIRST, then
305
+ // `opts.email.providers`, then the single back-compat `opts.email.provider`
306
+ // LAST — so a consumer-supplied provider overrides an env preset of the same
307
+ // id (last-writer-wins on the registry). The registry is what the
308
+ // `POST /v1/webhooks/email/:providerId` route dispatches by id.
309
+ const emailProviders = new EmailProviderRegistry([
310
+ ...emailProvidersFromEnv(env),
311
+ ...(opts.email?.providers ?? []),
312
+ ...(opts.email?.provider ? [opts.email.provider] : []),
313
+ ]);
314
+
315
+ // The active provider id the mailer sends through:
316
+ // `defaultProvider ?? EMAIL_PROVIDER ?? "resend"`. The default Resend provider
317
+ // is built (when RESEND_API_KEY is set) by `emailProvidersFromEnv` above — the
318
+ // SINGLE place Resend is constructed from env — so resolution is just a
319
+ // registry lookup that throws if the active id resolves to nothing. NEVER
320
+ // silently fall back for a non-resend id.
321
+ const activeId =
322
+ opts.email?.defaultProvider ?? env.EMAIL_PROVIDER ?? "resend";
323
+ const provider = emailProviders.get(activeId);
324
+
325
+ if (!provider) {
326
+ throw new Error(
327
+ `email provider "${activeId}" is not registered (registered: ${emailProviders
328
+ .getAll()
329
+ .map((p) => p.meta?.id ?? "resend")
330
+ .join(", ")})`,
331
+ );
332
+ }
333
+
334
+ // Tracking sovereignty: first-party open/click tracking is the single source
335
+ // of truth. A provider that can't force its OWN tracking off per-send (an
336
+ // account-level toggle — e.g. Resend) declares `nativeTracking: true`. We
337
+ // can't reach that toggle, so we WARN at boot. The outbound-echo suppression
338
+ // in `dispatchWebhook` is the defence: a native open/click webhook only
339
+ // touches DB status, never re-emits outbound.
340
+ if (provider.capabilities?.nativeTracking === true) {
341
+ logger.warn(
342
+ `provider ${
343
+ provider.meta?.id ?? "resend"
344
+ } reports account-level native tracking ON; disable it in the dashboard — first-party tracking is Hogsend's source of truth.`,
345
+ );
346
+ }
310
347
 
311
348
  const defaults: HogsendDefaults = {
312
349
  timezone: opts.defaults?.timezone ?? "UTC",
@@ -327,10 +364,9 @@ export function createHogsendClient(
327
364
  opts.overrides?.mailer ??
328
365
  createTrackedMailer(
329
366
  {
330
- defaultFrom: env.RESEND_FROM_EMAIL,
367
+ defaultFrom: env.EMAIL_FROM ?? env.RESEND_FROM_EMAIL,
331
368
  templates,
332
369
  db,
333
- webhookSecret: env.RESEND_WEBHOOK_SECRET,
334
370
  bounceThreshold: 3,
335
371
  baseUrl: env.API_PUBLIC_URL,
336
372
  frequencyCap: defaults.frequencyCap,
@@ -344,6 +380,61 @@ export function createHogsendClient(
344
380
 
345
381
  setEmailService(emailService);
346
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
+
347
438
  const analytics = opts.analytics ?? getPostHog();
348
439
 
349
440
  // Expose the resolved analytics instance to the module-level task-execution
@@ -404,8 +495,9 @@ export function createHogsendClient(
404
495
  db,
405
496
  dbClient: created.client,
406
497
  auth,
407
- email,
408
498
  emailService,
499
+ emailProviders,
500
+ emailProvider: provider,
409
501
  templates,
410
502
  analytics,
411
503
  registry,
package/src/env.ts CHANGED
@@ -23,12 +23,46 @@ 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.
29
41
  BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(),
30
- RESEND_API_KEY: z.string().min(1),
42
+ // Optional: a deploy may run a non-Resend provider (Postmark, SES…) and set
43
+ // no Resend key at all. Read directly ONLY in the lazy-resend default branch
44
+ // (container.ts) and the future `emailProvidersFromEnv` preset. With this
45
+ // optional, a Postmark-only deploy boots without a Resend key.
46
+ RESEND_API_KEY: z.string().min(1).optional(),
31
47
  RESEND_FROM_EMAIL: z.string().email().default("noreply@hogsend.com"),
48
+ // --- Provider-neutral email config (BYO email provider) ---
49
+ // The active email provider id the container resolves from the
50
+ // EmailProviderRegistry. Absent → "resend" (today's byte-for-byte default).
51
+ EMAIL_PROVIDER: z.string().optional(),
52
+ // Neutral default-from address. The mailer's `defaultFrom` is
53
+ // `EMAIL_FROM ?? RESEND_FROM_EMAIL`, so an unset EMAIL_FROM keeps today's
54
+ // Resend-named default.
55
+ EMAIL_FROM: z.string().email().optional(),
56
+ // --- Postmark (opt-in BYO provider) ---
57
+ // Postmark stays OPT-IN: a preset is built only when POSTMARK_SERVER_TOKEN
58
+ // is present, and it NEVER changes the default active provider — set
59
+ // EMAIL_PROVIDER=postmark to activate it. Postmark has no HMAC, so webhook
60
+ // authenticity is HTTP Basic creds in the webhook URL — fail-closed when
61
+ // unset (status updates rejected).
62
+ POSTMARK_SERVER_TOKEN: z.string().min(1).optional(),
63
+ POSTMARK_MESSAGE_STREAM: z.string().min(1).optional(),
64
+ POSTMARK_WEBHOOK_USER: z.string().min(1).optional(),
65
+ POSTMARK_WEBHOOK_PASS: z.string().min(1).optional(),
32
66
  // Hatchet connection contract. The @hatchet-dev SDK also reads these straight
33
67
  // from process.env via its own config-loader, so this schema is a presence /
34
68
  // shape check that keeps the contract in one place — the values still flow to
package/src/index.ts CHANGED
@@ -3,24 +3,32 @@
3
3
  // Content (journeys, webhook sources, workflows) is injected into these
4
4
  // factories by client app code; the engine never imports content.
5
5
 
6
- // --- Capability-provider contracts (canonical origin: @hogsend/core) ---
7
- // Email provider contract + analytics contract, re-exported so consumers can
8
- // import them from `@hogsend/engine`. (`SendEmailOptions` is intentionally
9
- // omitted here: the engine's public `SendEmailOptions` is the high-level
10
- // journey-facing send options from `./lib/email.js`; the provider-contract
11
- // `SendEmailOptions` remains available via `@hogsend/core`.)
12
6
  export type {
13
7
  BatchEmailItem,
14
8
  CaptureOptions,
9
+ EmailEvent,
10
+ EmailEventType,
15
11
  EmailProvider,
12
+ EmailProviderCapabilities,
13
+ EmailProviderMeta,
14
+ /** @deprecated Use {@link EmailEvent}. Frozen `event.raw` cast target. */
15
+ LegacyResendWebhookEvent,
16
16
  PostHogService,
17
17
  SendResult,
18
+ /** @deprecated Use {@link EmailEvent}. Kept for one minor. */
18
19
  WebhookEvent,
19
20
  WebhookHandlerMap,
20
21
  } from "@hogsend/core";
21
22
  // Core helpers used by content journeys (days/hours/minutes, condition + journey
22
23
  // types) so content can import everything from `@hogsend/engine`.
23
24
  export * from "@hogsend/core";
25
+ // --- Capability-provider contracts (canonical origin: @hogsend/core) ---
26
+ // Email provider contract + analytics contract, re-exported so consumers can
27
+ // import them from `@hogsend/engine`. (`SendEmailOptions` is intentionally
28
+ // omitted here: the engine's public `SendEmailOptions` is the high-level
29
+ // journey-facing send options from `./lib/email.js`; the provider-contract
30
+ // `SendEmailOptions` remains available via `@hogsend/core`.)
31
+ export { defineEmailProvider, WebhookHandshakeSignal } from "@hogsend/core";
24
32
  export {
25
33
  BucketRegistry,
26
34
  JourneyRegistry,
@@ -120,7 +128,11 @@ export {
120
128
  setJourneyRegistry,
121
129
  } from "./journeys/registry-singleton.js";
122
130
  // --- Auth ---
123
- export { type Auth, createAuth } from "./lib/auth.js";
131
+ export {
132
+ type Auth,
133
+ createAuth,
134
+ type SendResetPasswordFn,
135
+ } from "./lib/auth.js";
124
136
  // --- Backfill ---
125
137
  export {
126
138
  type BatchedBackfillOptions,
@@ -135,11 +147,18 @@ export {
135
147
  reportWorkerReady,
136
148
  type WorkerReadyInfo,
137
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";
138
152
  // --- Bucket transition emission (shared by real-time / cron / fast-expiry) ---
139
153
  export {
140
154
  type BucketTransitionSource,
141
155
  emitBucketTransition,
142
156
  } from "./lib/bucket-emit.js";
157
+ export {
158
+ AdminAlreadyExistsError,
159
+ type CreatedAdmin,
160
+ createAdminUser,
161
+ } from "./lib/create-admin.js";
143
162
  // --- Infrastructure singletons ---
144
163
  export { getDb } from "./lib/db.js";
145
164
  // --- Email ---
@@ -149,6 +168,8 @@ export {
149
168
  sendEmail,
150
169
  setEmailService,
151
170
  } from "./lib/email.js";
171
+ // --- Email provider registry (container-held, keyed by meta.id) ---
172
+ export { EmailProviderRegistry } from "./lib/email-provider-registry.js";
152
173
  // --- Email service (engine-owned tracked mailer) ---
153
174
  export type {
154
175
  EmailService,
@@ -182,7 +203,13 @@ export {
182
203
  type OutboundPayloads,
183
204
  } from "./lib/outbound.js";
184
205
  export { getPostHog } from "./lib/posthog.js";
185
- 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";
186
213
  export { type MountStudioResult, mountStudio } from "./lib/studio.js";
187
214
  export {
188
215
  type ResolveTimezoneInput,
@@ -205,6 +232,11 @@ export {
205
232
  export {
206
233
  pushTrackingEvent,
207
234
  resolveEmailSendContext,
235
+ resolveEmailSendContextByMessageId,
236
+ /**
237
+ * @deprecated Kept for one minor; use
238
+ * {@link resolveEmailSendContextByMessageId}.
239
+ */
208
240
  resolveEmailSendContextByResendId,
209
241
  } from "./lib/tracking-events.js";
210
242
  // --- Outbound webhooks: signing core (Section 1.2) ---
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,