@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 +11 -6
- package/src/app.ts +28 -17
- package/src/container.ts +133 -41
- package/src/env.ts +35 -1
- package/src/index.ts +40 -8
- package/src/lib/auth.ts +78 -1
- package/src/lib/bootstrap-admin.ts +90 -0
- package/src/lib/create-admin.ts +104 -0
- package/src/lib/email-provider-registry.ts +45 -0
- package/src/lib/email-providers-from-env.ts +94 -0
- package/src/lib/email-service-types.ts +40 -4
- package/src/lib/headers.ts +13 -0
- package/src/lib/mailer.ts +120 -70
- package/src/lib/outbound.ts +11 -2
- package/src/lib/redis.ts +68 -0
- package/src/lib/reset-email.ts +139 -0
- package/src/lib/tracked.ts +34 -29
- package/src/lib/tracking-events.ts +26 -11
- package/src/middleware/rate-limit.ts +38 -3
- package/src/routes/admin/emails.ts +5 -1
- package/src/routes/tracking/click.ts +1 -1
- package/src/routes/tracking/open.ts +1 -1
- package/src/routes/webhooks/email-provider.ts +124 -0
- package/src/routes/webhooks/index.ts +7 -0
- package/src/routes/webhooks/resend.ts +14 -29
- package/src/routes/webhooks/sources.ts +15 -4
- package/src/workflows/send-email.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./src/index.ts",
|
|
15
15
|
"./worker": "./src/worker.ts",
|
|
16
|
+
"./auth": "./src/lib/auth.ts",
|
|
17
|
+
"./create-admin": "./src/lib/create-admin.ts",
|
|
16
18
|
"./package.json": "./package.json"
|
|
17
19
|
},
|
|
18
20
|
"files": [
|
|
@@ -38,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.
|
|
42
|
-
"@hogsend/db": "^0.
|
|
43
|
-
"@hogsend/email": "^0.
|
|
44
|
-
"@hogsend/plugin-posthog": "^0.
|
|
45
|
-
"@hogsend/plugin-resend": "^0.
|
|
43
|
+
"@hogsend/core": "^0.11.0",
|
|
44
|
+
"@hogsend/db": "^0.11.0",
|
|
45
|
+
"@hogsend/email": "^0.11.0",
|
|
46
|
+
"@hogsend/plugin-posthog": "^0.11.0",
|
|
47
|
+
"@hogsend/plugin-resend": "^0.11.0"
|
|
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
|
-
//
|
|
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
|
@@ -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` —
|
|
125
|
-
*
|
|
126
|
-
* `
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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,
|