@hogsend/engine 0.10.0 → 0.12.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.12.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.12.0",
44
+ "@hogsend/db": "^0.12.0",
45
+ "@hogsend/email": "^0.12.0",
46
+ "@hogsend/plugin-posthog": "^0.12.0",
47
+ "@hogsend/plugin-resend": "^0.12.0"
46
48
  },
47
49
  "optionalDependencies": {
48
- "@hogsend/plugin-postmark": "^0.10.0"
50
+ "@hogsend/plugin-postmark": "^0.12.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
@@ -27,6 +27,10 @@ import type { DefinedJourney } from "./journeys/define-journey.js";
27
27
  import { buildJourneyRegistry } from "./journeys/registry.js";
28
28
  import { setAnalytics } from "./lib/analytics-singleton.js";
29
29
  import { type Auth, createAuth } from "./lib/auth.js";
30
+ import {
31
+ createDomainStatusService,
32
+ type DomainStatusService,
33
+ } from "./lib/domain-status.js";
30
34
  import { setEmailService } from "./lib/email.js";
31
35
  import { EmailProviderRegistry } from "./lib/email-provider-registry.js";
32
36
  import { emailProvidersFromEnv } from "./lib/email-providers-from-env.js";
@@ -38,6 +42,8 @@ import { hatchet } from "./lib/hatchet.js";
38
42
  import { createLogger, type Logger } from "./lib/logger.js";
39
43
  import { createTrackedMailer } from "./lib/mailer.js";
40
44
  import { getPostHog } from "./lib/posthog.js";
45
+ import { createRedisSecondaryStorage, getRedis } from "./lib/redis.js";
46
+ import { sendResetPasswordEmail } from "./lib/reset-email.js";
41
47
  import { seedPostHogDestination } from "./lib/seed-posthog-destination.js";
42
48
  import { prepareTrackedHtml } from "./lib/tracking.js";
43
49
  import type { DefinedList } from "./lists/define-list.js";
@@ -71,6 +77,14 @@ export interface HogsendClient {
71
77
  * defaulting to the env-built Resend provider for byte-for-byte parity.
72
78
  */
73
79
  emailProvider: EmailProvider;
80
+ /**
81
+ * Cached sending-domain status for the ACTIVE provider. Consumed by the
82
+ * mailer's test-mode check (F3 — sync `testModeCached()` per send), the
83
+ * `GET/POST /v1/admin/domain` routes, and (via HTTP) the CLI
84
+ * (`hogsend domain`) + Studio Setup view. In-memory cache only; the per-send
85
+ * path never awaits a provider call.
86
+ */
87
+ domainStatus: DomainStatusService;
74
88
  /**
75
89
  * The app's template registry (key → component + subject + category +
76
90
  * optional preview/examples). Same object threaded into the engine mailer;
@@ -247,26 +261,6 @@ export function createHogsendClient(
247
261
  const created = createDatabase({ url: env.DATABASE_URL });
248
262
  const db = opts.overrides?.db ?? created.db;
249
263
 
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
264
  const registry = buildJourneyRegistry(
271
265
  opts.journeys ?? [],
272
266
  opts.enabledJourneys ?? env.ENABLED_JOURNEYS,
@@ -363,6 +357,16 @@ export function createHogsendClient(
363
357
  );
364
358
  }
365
359
 
360
+ // Cached sending-domain status for the active provider. Constructed right
361
+ // after provider resolution so it binds the SAME provider the mailer sends
362
+ // through. One non-blocking warm-up refresh primes the cache at boot —
363
+ // fire-and-forget, swallowed errors, must never block or fail boot. Skipped
364
+ // under NODE_ENV=test so test runs stay hermetic (no real provider HTTP).
365
+ const domainStatus = createDomainStatusService({ provider, env, logger });
366
+ if (env.NODE_ENV !== "test") {
367
+ domainStatus.refreshIfStale();
368
+ }
369
+
366
370
  const defaults: HogsendDefaults = {
367
371
  timezone: opts.defaults?.timezone ?? "UTC",
368
372
  sendWindow: opts.defaults?.sendWindow,
@@ -393,11 +397,71 @@ export function createHogsendClient(
393
397
  {
394
398
  provider,
395
399
  prepareTrackedHtml,
400
+ // Test-mode redirect: the mailer reads `domainStatus.testModeCached()`
401
+ // (sync, cache-only) per send to decide whether to redirect to the safe
402
+ // inbox. Constructed above (right after provider resolution) so it binds
403
+ // the SAME active provider the mailer sends through.
404
+ domainStatus,
396
405
  },
397
406
  );
398
407
 
399
408
  setEmailService(emailService);
400
409
 
410
+ // Wire better-auth's secondary storage to the SHARED engine Redis (the same
411
+ // singleton backing the PostHog cache + worker heartbeat — never a second
412
+ // pool). Passing `secondaryStorage` flips better-auth's rate-limit store from
413
+ // the per-instance in-memory default to this shared store, so the sign-in /
414
+ // request-password-reset limiters are enforced ACROSS Railway replicas and
415
+ // survive restarts (security finding #2).
416
+ //
417
+ // Gate on the RAW `process.env.REDIS_URL`, NOT `env.REDIS_URL`: the latter
418
+ // carries a `redis://localhost:6379` zod default, so it is never empty and
419
+ // would wire secondary storage unconditionally. When an operator hasn't set
420
+ // REDIS_URL we deliberately keep better-auth's in-memory store rather than
421
+ // pushing SESSIONS into a Redis that may not exist — a wired secondaryStorage
422
+ // degrades `get` to null on a fault, which for sessions means silent
423
+ // logouts. `getRedis()` is lazyConnect, so this stays synchronous (no
424
+ // connection until the first auth command); on a transient Redis fault the
425
+ // adapter degrades to a no-op rather than failing the auth flow.
426
+ const authSecondaryStorage = process.env.REDIS_URL
427
+ ? createRedisSecondaryStorage(getRedis())
428
+ : undefined;
429
+
430
+ // Auth is built AFTER the mailer so we can wire the self-service password-reset
431
+ // delivery to the just-built `emailService` directly (rather than relying on a
432
+ // singleton resolved at request time). The injected `sendResetPassword` is what
433
+ // flips better-auth's reset endpoints from disabled → live; the engine-owned,
434
+ // self-contained reset email needs no consumer template wiring, so reset works
435
+ // on a bare instance. NEVER log the url/token (see `sendResetPasswordEmail`).
436
+ const auth =
437
+ opts.overrides?.auth ??
438
+ createAuth({
439
+ db,
440
+ secret: env.BETTER_AUTH_SECRET,
441
+ baseURL: env.BETTER_AUTH_URL,
442
+ secondaryStorage: authSecondaryStorage,
443
+ // Always trust the public API origin; add any explicitly configured ones
444
+ // (e.g. a remote Studio origin) on top. baseURL is trusted automatically.
445
+ trustedOrigins: Array.from(
446
+ new Set(
447
+ [
448
+ env.API_PUBLIC_URL,
449
+ ...(env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",") ?? []),
450
+ ]
451
+ .map((o) => o.trim())
452
+ .filter(Boolean),
453
+ ),
454
+ ),
455
+ sendResetPassword: async ({ user, url }) => {
456
+ await sendResetPasswordEmail({
457
+ to: user.email,
458
+ url,
459
+ emailService,
460
+ logger,
461
+ });
462
+ },
463
+ });
464
+
401
465
  const analytics = opts.analytics ?? getPostHog();
402
466
 
403
467
  // Expose the resolved analytics instance to the module-level task-execution
@@ -461,6 +525,7 @@ export function createHogsendClient(
461
525
  emailService,
462
526
  emailProviders,
463
527
  emailProvider: provider,
528
+ domainStatus,
464
529
  templates,
465
530
  analytics,
466
531
  registry,
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.
@@ -41,6 +53,29 @@ export const env = createEnv({
41
53
  // `EMAIL_FROM ?? RESEND_FROM_EMAIL`, so an unset EMAIL_FROM keeps today's
42
54
  // Resend-named default.
43
55
  EMAIL_FROM: z.string().email().optional(),
56
+ // The sending domain the domain-status service reports on. OVERRIDES the
57
+ // default derivation (host part of EMAIL_FROM, falling back to the host of
58
+ // RESEND_FROM_EMAIL) — set it when you send from a subaddress domain that
59
+ // differs from the one registered at the provider.
60
+ EMAIL_DOMAIN: z.string().optional(),
61
+ // --- Test mode (provider-neutral send redirect) ---
62
+ // Controls whether the engine redirects every send to a safe inbox while the
63
+ // sending domain isn't verified yet:
64
+ // auto (default) — test mode iff the active provider supports domains AND
65
+ // an EMAIL_DOMAIN is configured AND it is UNVERIFIED per
66
+ // the cached DomainStatusService. Fail-OPEN: a cache miss
67
+ // or provider outage resolves to LIVE (never silently
68
+ // redirects prod mail). With no domains capability or no
69
+ // EMAIL_DOMAIN, `auto` stays LIVE — existing deploys are
70
+ // unaffected.
71
+ // true — always redirect (reason: "env_flag").
72
+ // false — never redirect, even with an unverified domain.
73
+ HOGSEND_TEST_MODE: z.enum(["auto", "true", "false"]).default("auto"),
74
+ // The safe inbox every redirected send is delivered to in test mode. Falls
75
+ // back to STUDIO_ADMIN_EMAIL when unset; when NEITHER resolves while test
76
+ // mode is active, the send is BLOCKED (recorded, never delivered to the real
77
+ // recipient) with a loud, actionable log.
78
+ HOGSEND_TEST_EMAIL: z.string().email().optional(),
44
79
  // --- Postmark (opt-in BYO provider) ---
45
80
  // Postmark stays OPT-IN: a preset is built only when POSTMARK_SERVER_TOKEN
46
81
  // is present, and it NEVER changes the default active provider — set
@@ -48,6 +83,11 @@ export const env = createEnv({
48
83
  // authenticity is HTTP Basic creds in the webhook URL — fail-closed when
49
84
  // unset (status updates rejected).
50
85
  POSTMARK_SERVER_TOKEN: z.string().min(1).optional(),
86
+ // Postmark ACCOUNT token (X-Postmark-Account-Token) — unlocks the Domains
87
+ // API capability on the Postmark provider. Optional: without it the
88
+ // provider still sends, it just can't manage sending domains
89
+ // (`supported: false` on /v1/admin/domain).
90
+ POSTMARK_ACCOUNT_TOKEN: z.string().min(1).optional(),
51
91
  POSTMARK_MESSAGE_STREAM: z.string().min(1).optional(),
52
92
  POSTMARK_WEBHOOK_USER: z.string().min(1).optional(),
53
93
  POSTMARK_WEBHOOK_PASS: z.string().min(1).optional(),
package/src/index.ts CHANGED
@@ -3,9 +3,18 @@
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
+ // Sending-domain capability contract (presence of `EmailProvider.domains` is
7
+ // the gate). Already covered by the `export * from "@hogsend/core"` above —
8
+ // re-named here for discoverability.
6
9
  export type {
7
10
  BatchEmailItem,
8
11
  CaptureOptions,
12
+ DnsRecord,
13
+ DnsRecordPurpose,
14
+ DnsRecordStatus,
15
+ DomainStatus,
16
+ DomainsCapability,
17
+ DomainVerificationState,
9
18
  EmailEvent,
10
19
  EmailEventType,
11
20
  EmailProvider,
@@ -128,7 +137,11 @@ export {
128
137
  setJourneyRegistry,
129
138
  } from "./journeys/registry-singleton.js";
130
139
  // --- Auth ---
131
- export { type Auth, createAuth } from "./lib/auth.js";
140
+ export {
141
+ type Auth,
142
+ createAuth,
143
+ type SendResetPasswordFn,
144
+ } from "./lib/auth.js";
132
145
  // --- Backfill ---
133
146
  export {
134
147
  type BatchedBackfillOptions,
@@ -143,13 +156,27 @@ export {
143
156
  reportWorkerReady,
144
157
  type WorkerReadyInfo,
145
158
  } from "./lib/boot.js";
159
+ // --- First-admin creation (CLI + boot bootstrap share this scrypt-correct path)
160
+ export { bootstrapAdminFromEnv } from "./lib/bootstrap-admin.js";
146
161
  // --- Bucket transition emission (shared by real-time / cron / fast-expiry) ---
147
162
  export {
148
163
  type BucketTransitionSource,
149
164
  emitBucketTransition,
150
165
  } from "./lib/bucket-emit.js";
166
+ export {
167
+ AdminAlreadyExistsError,
168
+ type CreatedAdmin,
169
+ createAdminUser,
170
+ } from "./lib/create-admin.js";
151
171
  // --- Infrastructure singletons ---
152
172
  export { getDb } from "./lib/db.js";
173
+ // --- Sending-domain status service (cached; container-held) ---
174
+ export {
175
+ createDomainStatusService,
176
+ type DomainStatusService,
177
+ type EngineDomainStatus,
178
+ type TestModeState,
179
+ } from "./lib/domain-status.js";
153
180
  // --- Email ---
154
181
  export {
155
182
  type SendEmailOptions,
@@ -192,7 +219,13 @@ export {
192
219
  type OutboundPayloads,
193
220
  } from "./lib/outbound.js";
194
221
  export { getPostHog } from "./lib/posthog.js";
195
- export { getRedisIfConnected } from "./lib/redis.js";
222
+ export {
223
+ type AuthSecondaryStorage,
224
+ createRedisSecondaryStorage,
225
+ getRedisIfConnected,
226
+ } from "./lib/redis.js";
227
+ // --- Self-service password reset (engine-owned, self-contained email) ---
228
+ export { sendResetPasswordEmail } from "./lib/reset-email.js";
196
229
  export { type MountStudioResult, mountStudio } from "./lib/studio.js";
197
230
  export {
198
231
  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,
package/src/lib/boot.ts CHANGED
@@ -81,6 +81,12 @@ export function reportApiReady(info: ApiReadyInfo): void {
81
81
  const templates = Object.keys(client.templates).length;
82
82
  const localUrl = `http://localhost:${port}`;
83
83
 
84
+ // Cache-only, sync, never throws — so reading it here adds no boot latency.
85
+ // Loud when env-flag-forced (resolves even with a cold cache); the
86
+ // domain-unverified auto banner is additionally fired as a transition WARN by
87
+ // the warm-up refresh in the container.
88
+ const testMode = client.domainStatus.testModeCached();
89
+
84
90
  if (!bannerMode(client)) {
85
91
  client.logger.info("Hogsend API ready", {
86
92
  engineVersion,
@@ -91,6 +97,14 @@ export function reportApiReady(info: ApiReadyInfo): void {
91
97
  buckets,
92
98
  templates,
93
99
  schema: info.schemaVersion ?? undefined,
100
+ ...(testMode.active
101
+ ? {
102
+ testMode: {
103
+ redirectTo: testMode.redirectTo,
104
+ reason: testMode.reason,
105
+ },
106
+ }
107
+ : {}),
94
108
  });
95
109
  return;
96
110
  }
@@ -104,6 +118,13 @@ export function reportApiReady(info: ApiReadyInfo): void {
104
118
  ].join(dim(" · "));
105
119
  const label = (text: string) => dim(text.padEnd(7));
106
120
 
121
+ const testModeLine = testMode.active
122
+ ? ` ${color.bgYellow(color.black(" TEST MODE "))} ${color.yellow(
123
+ `all sends → ${testMode.redirectTo ?? "(no redirect address — sends will fail!)"} ` +
124
+ dim(`(${testMode.reason ?? "unknown"})`),
125
+ )}`
126
+ : null;
127
+
107
128
  writeBanner([
108
129
  `${BADGE} ${dim(`engine ${engineVersion} · api ${API_VERSION}`)}`,
109
130
  "",
@@ -111,6 +132,7 @@ export function reportApiReady(info: ApiReadyInfo): void {
111
132
  info.schemaVersion
112
133
  ? ` ${ok} schema in sync ${dim(`(${info.schemaVersion})`)}`
113
134
  : null,
135
+ testModeLine,
114
136
  "",
115
137
  ` ${label("API")}${color.cyan(localUrl)}`,
116
138
  ` ${label("Docs")}${color.cyan(`${localUrl}/docs`)}`,
@@ -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
+ }