@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 +9 -7
- package/src/app.ts +28 -17
- package/src/container.ts +85 -20
- package/src/env.ts +40 -0
- package/src/index.ts +35 -2
- package/src/lib/auth.ts +78 -1
- package/src/lib/boot.ts +22 -0
- package/src/lib/bootstrap-admin.ts +90 -0
- package/src/lib/create-admin.ts +104 -0
- package/src/lib/domain-status.ts +327 -0
- package/src/lib/email-providers-from-env.ts +5 -0
- package/src/lib/email-service-types.ts +8 -2
- package/src/lib/mailer.ts +131 -5
- package/src/lib/redis.ts +68 -0
- package/src/lib/reset-email.ts +139 -0
- package/src/lib/test-mode.ts +123 -0
- package/src/lib/tracked.ts +93 -6
- package/src/middleware/rate-limit.ts +38 -3
- package/src/routes/admin/domain.ts +181 -0
- package/src/routes/admin/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "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.
|
|
42
|
-
"@hogsend/db": "^0.
|
|
43
|
-
"@hogsend/email": "^0.
|
|
44
|
-
"@hogsend/plugin-posthog": "^0.
|
|
45
|
-
"@hogsend/plugin-resend": "^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.
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
93
|
+
// Belt-and-suspenders throttle on the sign-up path. Public sign-up is now
|
|
94
|
+
// CLOSED at the better-auth layer (`disableSignUp: true` in lib/auth.ts ⇒ the
|
|
95
|
+
// now-ungated POST /api/auth/sign-up/email returns 400
|
|
96
|
+
// EMAIL_PASSWORD_SIGN_UP_DISABLED), so there is no token to brute-force here.
|
|
97
|
+
// We KEEP this IP-keyed sliding window anyway: it cheaply drops a flood at the
|
|
98
|
+
// edge before it reaches better-auth's handler (defence in depth, and it caps
|
|
99
|
+
// any future credential-probing on this path). Keyed by client IP because
|
|
100
|
+
// sign-up is unauthenticated — every request would otherwise collapse onto one
|
|
101
|
+
// "anonymous" bucket. `disableInTest: false` so the suite can still assert the
|
|
102
|
+
// 429. Distinct prefix → isolated budget.
|
|
103
|
+
const signUpRateLimit = createRateLimit({
|
|
104
|
+
prefix: "ratelimit:signup",
|
|
105
|
+
windowMs: 60_000,
|
|
106
|
+
max: 10,
|
|
107
|
+
keyFn: clientIpKey,
|
|
108
|
+
disableInTest: false,
|
|
109
|
+
});
|
|
95
110
|
app.use("/api/auth/sign-up/*", async (c, next) => {
|
|
96
|
-
if (c.req.method
|
|
97
|
-
|
|
98
|
-
const existing = await db.select({ id: user.id }).from(user).limit(1);
|
|
99
|
-
if (existing.length > 0) {
|
|
100
|
-
return c.json(
|
|
101
|
-
{ error: "Sign-ups are closed. An admin already exists." },
|
|
102
|
-
403,
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return next();
|
|
111
|
+
if (c.req.method !== "POST") return next();
|
|
112
|
+
return signUpRateLimit(c, next);
|
|
107
113
|
});
|
|
108
114
|
|
|
109
115
|
app.on(["POST", "GET"], "/api/auth/*", (c) => {
|
|
@@ -112,11 +118,16 @@ export function createApp(
|
|
|
112
118
|
});
|
|
113
119
|
|
|
114
120
|
// Public bootstrap probe: tells the Studio whether to show the first-run
|
|
115
|
-
// "
|
|
121
|
+
// "no admin yet" INFO screen (no users yet) instead of the login screen.
|
|
122
|
+
// Returns ONLY `{ needsSetup }`. Since public sign-up is closed, the Studio's
|
|
123
|
+
// zero-user state offers NO network path to create a user — the info screen
|
|
124
|
+
// points the operator at the CLI / env bootstrap instead.
|
|
116
125
|
app.get("/v1/auth/status", async (c) => {
|
|
117
|
-
const
|
|
126
|
+
const container = c.get("container");
|
|
127
|
+
const { db } = container;
|
|
118
128
|
const existing = await db.select({ id: user.id }).from(user).limit(1);
|
|
119
|
-
|
|
129
|
+
const needsSetup = existing.length === 0;
|
|
130
|
+
return c.json({ needsSetup });
|
|
120
131
|
});
|
|
121
132
|
|
|
122
133
|
// Merge env-enabled presets ahead of the consumer's explicit sources so a
|
package/src/container.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
+
}
|