@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.
@@ -0,0 +1,90 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { user } from "@hogsend/db";
3
+ import type { HogsendClient } from "../container.js";
4
+ import { AdminAlreadyExistsError, createAdminUser } from "./create-admin.js";
5
+
6
+ /**
7
+ * Boot-time first-admin bootstrap. Replaces the old web setup-token land-grab:
8
+ * with public sign-up disabled (lib/auth.ts `disableSignUp`), admins are minted
9
+ * ONLY by the CLI (DB-direct) or this in-process boot path — there is NO
10
+ * unauthenticated network path that creates a user.
11
+ *
12
+ * Contract (all conditions must hold to mint):
13
+ * - `STUDIO_ADMIN_EMAIL` is set (unset ⇒ no-op; CLI is then the only path).
14
+ * - The `user` table has ZERO rows (idempotent: never mints over an existing
15
+ * user, never rotates/re-prints anything once an admin exists).
16
+ *
17
+ * Password resolution:
18
+ * - `STUDIO_ADMIN_PASSWORD` if set (NEVER logged).
19
+ * - else an auto-generated strong password (base64url, >=16 chars) PRINTED
20
+ * ONCE to the server log — the single intended secret-logging exception,
21
+ * clearly labelled "shown once". The operator should rotate it immediately
22
+ * via the self-service forgot/reset flow (retained, revokes sessions).
23
+ *
24
+ * Concurrency: two API replicas booting on a fresh DB could both pass the
25
+ * 0-rows check; the `user.email` unique constraint makes the loser's
26
+ * `createUser` throw — caught here and treated as "already created" (no-op, no
27
+ * log of the loser's generated password). Invariant preserved: exactly one
28
+ * admin, no unauthenticated path.
29
+ *
30
+ * Never blocks boot beyond a clear fatal when an explicitly-set password is too
31
+ * weak — that validation lives in env.ts (`STUDIO_ADMIN_PASSWORD.min(8)`), so
32
+ * by the time we run the password is already known-valid or auto-generated.
33
+ */
34
+ export async function bootstrapAdminFromEnv(opts: {
35
+ client: HogsendClient;
36
+ }): Promise<void> {
37
+ const { db, auth, env, logger } = opts.client;
38
+
39
+ const email = env.STUDIO_ADMIN_EMAIL;
40
+ if (!email) return;
41
+
42
+ // Idempotency gate: only mint into a fresh DB (zero users). Reuses the same
43
+ // zero-check the old /v1/auth/status used.
44
+ const existing = await db.select({ id: user.id }).from(user).limit(1);
45
+ if (existing.length > 0) return;
46
+
47
+ // Auto-generate when no explicit password: base64url(18 bytes) ⇒ 24 chars,
48
+ // ~144 bits of entropy. We only log the generated value, never an env one.
49
+ const explicit = env.STUDIO_ADMIN_PASSWORD;
50
+ const password = explicit ?? randomBytes(18).toString("base64url");
51
+
52
+ try {
53
+ const admin = await createAdminUser({ auth, email, password });
54
+
55
+ if (!explicit) {
56
+ // The ONE intended secret-logging exception (auto-generated only). Shown
57
+ // once — never re-printed (we only reach here on a zero-user DB).
58
+ logger.warn(
59
+ `[studio] First admin created: ${admin.email}. ` +
60
+ `Generated password (save this, shown once): ${password}`,
61
+ );
62
+ logger.warn(
63
+ "[studio] Rotate it now via the Studio forgot-password flow " +
64
+ "(or set STUDIO_ADMIN_PASSWORD).",
65
+ );
66
+ } else {
67
+ logger.info(`[studio] First admin created from env: ${admin.email}.`);
68
+ }
69
+ } catch (err) {
70
+ if (err instanceof AdminAlreadyExistsError) {
71
+ // A concurrent replica won the race (or the user appeared between the
72
+ // zero-check and the insert). No-op — never log the generated password.
73
+ logger.debug(
74
+ "[studio] First-admin bootstrap skipped: an admin already exists.",
75
+ );
76
+ return;
77
+ }
78
+ // A unique-violation surfaced by the adapter (not our pre-check) is the same
79
+ // race; treat any duplicate-key error as "already created". Anything else is
80
+ // unexpected — surface it without leaking the password.
81
+ const message = err instanceof Error ? err.message : String(err);
82
+ if (/duplicate key|unique constraint|already exists/i.test(message)) {
83
+ logger.debug(
84
+ "[studio] First-admin bootstrap lost a creation race; skipping.",
85
+ );
86
+ return;
87
+ }
88
+ logger.error("[studio] First-admin bootstrap failed.", { error: message });
89
+ }
90
+ }
@@ -0,0 +1,104 @@
1
+ import type { Auth } from "./auth.js";
2
+
3
+ /**
4
+ * Shared admin-minting primitive used by BOTH the CLI's `admin create` and the
5
+ * engine's env bootstrap. Mints a credential admin via better-auth's
6
+ * INTERNAL ADAPTER (scrypt-identical to the running app) rather than the public
7
+ * sign-up endpoint — which is now blocked by `disableSignUp` (see lib/auth.ts).
8
+ *
9
+ * Why the internal adapter (not `auth.api.signUpEmail`): in better-auth 1.6.11
10
+ * the `disableSignUp` check lives INSIDE the sign-up endpoint handler, and
11
+ * `auth.api.signUpEmail` dispatches through that SAME handler — so with sign-up
12
+ * disabled it throws `EMAIL_PASSWORD_SIGN_UP_DISABLED` for the in-process API
13
+ * too. The internal adapter is NOT subject to that guard. This mirrors exactly
14
+ * what admin-recovery's `reset()` already does for its no-credential branch
15
+ * (`ctx.password.hash` + `ctx.internalAdapter.createAccount({ providerId:
16
+ * "credential" })`).
17
+ *
18
+ * Security invariants (acceptance gates, not preferences):
19
+ * - The password is hashed via `ctx.password.hash` (scrypt, identical to the
20
+ * app). There is NO raw SQL password write.
21
+ * - The password is never logged and never returned in any result object.
22
+ * - `emailVerified: true` because this is an operator-minted admin (CLI or
23
+ * boot env), not a self-service signup.
24
+ *
25
+ * Lives in `lib/` (reachable via the `@hogsend/engine/create-admin` subpath)
26
+ * with a module graph that touches ONLY better-auth — it never pulls `env.ts`,
27
+ * Hatchet, or Resend — so the CLI can import it the same way it imports
28
+ * `createAuth` from `@hogsend/engine/auth`.
29
+ */
30
+
31
+ /** A single admin row, no secrets. Shared with the CLI's `AdminSummary`. */
32
+ export interface CreatedAdmin {
33
+ id: string;
34
+ email: string;
35
+ name: string;
36
+ createdAt: string;
37
+ }
38
+
39
+ /** Thrown when an admin with the given email already exists. */
40
+ export class AdminAlreadyExistsError extends Error {
41
+ constructor(public readonly email: string) {
42
+ super(
43
+ `An admin with email "${email}" already exists. ` +
44
+ "Use `hogsend studio admin reset` to set a new password.",
45
+ );
46
+ this.name = "AdminAlreadyExistsError";
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Create a credential admin user against a built better-auth instance. Throws
52
+ * {@link AdminAlreadyExistsError} if the email already exists (so callers can
53
+ * point the operator at `reset`). The unique constraint on `user.email` is the
54
+ * backstop: a concurrent racer that slips past the pre-check throws on
55
+ * `createUser` — callers that need idempotency should catch that.
56
+ */
57
+ export async function createAdminUser(opts: {
58
+ auth: Auth;
59
+ email: string;
60
+ name?: string;
61
+ password: string;
62
+ }): Promise<CreatedAdmin> {
63
+ const { auth, email, password } = opts;
64
+ const displayName = opts.name ?? email.split("@")[0] ?? email;
65
+
66
+ const ctx = await auth.$context;
67
+
68
+ // Pre-check for a clear error (better-auth lowercases on lookup + create).
69
+ const existing = await ctx.internalAdapter.findUserByEmail(email);
70
+ if (existing) {
71
+ throw new AdminAlreadyExistsError(email);
72
+ }
73
+
74
+ // scrypt hash — identical to the running app (admin-recovery.reset uses the
75
+ // same call). NO raw SQL password write.
76
+ const hashed = await ctx.password.hash(password);
77
+
78
+ // `createUser` returns the bare user row (createWithHooks → the created user),
79
+ // NOT `{ user }` — verified against better-auth@1.6.11 internal-adapter.mjs:75.
80
+ const created = await ctx.internalAdapter.createUser({
81
+ email,
82
+ name: displayName,
83
+ emailVerified: true,
84
+ });
85
+
86
+ await ctx.internalAdapter.createAccount({
87
+ userId: created.id,
88
+ providerId: "credential",
89
+ accountId: created.id,
90
+ password: hashed,
91
+ });
92
+
93
+ const createdAt =
94
+ created.createdAt instanceof Date
95
+ ? created.createdAt.toISOString()
96
+ : String(created.createdAt ?? new Date().toISOString());
97
+
98
+ return {
99
+ id: created.id,
100
+ email: created.email,
101
+ name: created.name,
102
+ createdAt,
103
+ };
104
+ }
@@ -0,0 +1,45 @@
1
+ import type { EmailProvider } from "@hogsend/core";
2
+
3
+ /**
4
+ * Container-held registry of email providers, keyed by `provider.meta.id`. The
5
+ * webhook route (`POST /v1/webhooks/email/:providerId`) resolves the verifying
6
+ * provider out of this registry via `c.get("container")`, and the container
7
+ * also picks ONE `active` provider out of it for the mailer.
8
+ *
9
+ * Deliberately NOT a process singleton: unlike the `DestinationRegistry`
10
+ * singleton — which exists only because the self-booting `deliverWebhookTask`
11
+ * has no container — both readers of this registry (the mailer the container
12
+ * constructs, and the webhook route which has the container) have a container
13
+ * reference, so the singleton + lazy-preset fallback would be dead weight.
14
+ *
15
+ * Keyed by `meta.id` with last-writer-wins, so a consumer-supplied provider of
16
+ * the same id overrides an env preset of that id.
17
+ */
18
+ export class EmailProviderRegistry {
19
+ private byId = new Map<string, EmailProvider>();
20
+
21
+ constructor(providers: EmailProvider[] = []) {
22
+ for (const provider of providers) this.register(provider);
23
+ }
24
+
25
+ /**
26
+ * Register (or replace) a provider. Falls back to `"resend"` for a provider
27
+ * built before `meta` existed (the contract keeps `meta` optional for
28
+ * back-compat). Last-writer-wins.
29
+ */
30
+ register(provider: EmailProvider): void {
31
+ this.byId.set(provider.meta?.id ?? "resend", provider);
32
+ }
33
+
34
+ get(id: string): EmailProvider | undefined {
35
+ return this.byId.get(id);
36
+ }
37
+
38
+ getAll(): EmailProvider[] {
39
+ return [...this.byId.values()];
40
+ }
41
+
42
+ count(): number {
43
+ return this.byId.size;
44
+ }
45
+ }
@@ -0,0 +1,94 @@
1
+ import type { EmailProvider } from "@hogsend/core";
2
+ import { createResendProvider } from "@hogsend/plugin-resend";
3
+ import type { env as envSchema } from "../env.js";
4
+
5
+ /**
6
+ * `@hogsend/plugin-postmark` is an OPT-IN, deferred-publish package: it is an
7
+ * engine `optionalDependency`, NOT a hard one, and it is not on the npm registry
8
+ * yet. So we MUST NOT statically import it — a static `import` would make the
9
+ * package mandatory at engine load, and `npm install @hogsend/engine` would fail
10
+ * with E404 on plugin-postmark for every consumer that doesn't have it.
11
+ *
12
+ * Instead we load it lazily, ONCE, behind a top-level guarded dynamic import:
13
+ * the `import()` only fires when `POSTMARK_SERVER_TOKEN` is present (the same
14
+ * gate the preset below uses), so a deploy that never sets that var never
15
+ * touches the package and degrades gracefully when it isn't installed. ESM
16
+ * top-level await keeps `emailProvidersFromEnv` itself synchronous (it reads the
17
+ * already-resolved factory), so `createHogsendClient` stays synchronous.
18
+ *
19
+ * The specifier is assembled at runtime (not a string literal) ON PURPOSE: a
20
+ * literal `import("@hogsend/plugin-postmark")` makes `tsc` resolve the module's
21
+ * types, which fails with TS2307 for any consumer that doesn't have the opt-in
22
+ * package installed (e.g. a fresh `create-hogsend` app). A computed specifier is
23
+ * opaque to the type-checker — resolved only at runtime — so the engine
24
+ * type-checks identically with or without the package present.
25
+ */
26
+ type CreatePostmarkProvider = (cfg: {
27
+ serverToken: string;
28
+ messageStream?: string;
29
+ webhookBasicAuth?: { user: string; pass: string };
30
+ }) => EmailProvider;
31
+
32
+ const POSTMARK_PACKAGE = ["@hogsend", "plugin-postmark"].join("/");
33
+
34
+ let createPostmarkProvider: CreatePostmarkProvider | null = null;
35
+ if (process.env.POSTMARK_SERVER_TOKEN) {
36
+ try {
37
+ ({ createPostmarkProvider } = (await import(POSTMARK_PACKAGE)) as {
38
+ createPostmarkProvider: CreatePostmarkProvider;
39
+ });
40
+ } catch {
41
+ // The token is set but the opt-in package isn't installed. Leave the factory
42
+ // null — `emailProvidersFromEnv` skips the preset, and if Postmark was the
43
+ // resolved active provider the container throws a clear "not registered"
44
+ // error directing the operator to install `@hogsend/plugin-postmark`.
45
+ createPostmarkProvider = null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Build the env-enabled email-provider presets. Mirrors `destinationsFromEnv`:
51
+ * a preset is constructed ONLY when its credential is present, so a
52
+ * Postmark-only deploy (no `RESEND_API_KEY`) contributes no Resend provider.
53
+ *
54
+ * These presets come FIRST in the container's merge — a consumer-supplied
55
+ * provider of the same id wins (last-writer-wins on the registry).
56
+ */
57
+ export function emailProvidersFromEnv(env: typeof envSchema): EmailProvider[] {
58
+ const providers: EmailProvider[] = [];
59
+
60
+ if (env.RESEND_API_KEY) {
61
+ providers.push(
62
+ createResendProvider({
63
+ apiKey: env.RESEND_API_KEY,
64
+ webhookSecret: env.RESEND_WEBHOOK_SECRET,
65
+ }),
66
+ );
67
+ }
68
+
69
+ // Postmark is OPT-IN: built only when its token is present AND the opt-in
70
+ // package resolved (see the guarded dynamic import above), and it never
71
+ // changes the default active provider — set EMAIL_PROVIDER=postmark to
72
+ // activate it. Postmark has no HMAC, so webhook auth is HTTP Basic creds (the
73
+ // provider fails closed when they're unset).
74
+ if (env.POSTMARK_SERVER_TOKEN && createPostmarkProvider) {
75
+ providers.push(
76
+ createPostmarkProvider({
77
+ serverToken: env.POSTMARK_SERVER_TOKEN,
78
+ ...(env.POSTMARK_MESSAGE_STREAM
79
+ ? { messageStream: env.POSTMARK_MESSAGE_STREAM }
80
+ : {}),
81
+ ...(env.POSTMARK_WEBHOOK_USER && env.POSTMARK_WEBHOOK_PASS
82
+ ? {
83
+ webhookBasicAuth: {
84
+ user: env.POSTMARK_WEBHOOK_USER,
85
+ pass: env.POSTMARK_WEBHOOK_PASS,
86
+ },
87
+ }
88
+ : {}),
89
+ }),
90
+ );
91
+ }
92
+
93
+ return providers;
94
+ }
@@ -1,9 +1,10 @@
1
1
  import type {
2
2
  BatchEmailItem,
3
3
  DurationObject,
4
+ EmailEvent,
5
+ EmailEventType,
4
6
  SendEmailOptions,
5
7
  SendResult,
6
- WebhookEventType,
7
8
  WebhookHandlerMap,
8
9
  } from "@hogsend/core";
9
10
  import type {
@@ -63,12 +64,36 @@ export interface SendTrackedEmailOptions<
63
64
 
64
65
  export interface TrackedSendResult {
65
66
  emailSendId: string;
67
+ /** The provider's neutral message id (Resend email_id / Postmark MessageID). */
68
+ messageId: string;
69
+ /**
70
+ * @deprecated Renamed to {@link TrackedSendResult.messageId}. This read-alias
71
+ * always mirrors `messageId`; kept for one minor and removed the following
72
+ * minor. Build results via {@link trackedSendResult} so the alias stays live.
73
+ */
66
74
  resendId: string;
67
75
  status: "sent" | "suppressed" | "unsubscribed" | "skipped";
68
76
  /** Present only when `status === "skipped"` by the frequency cap. */
69
77
  reason?: "frequency_capped";
70
78
  }
71
79
 
80
+ /**
81
+ * Build a {@link TrackedSendResult}, attaching a live `@deprecated` `resendId`
82
+ * read-alias getter that mirrors `messageId`. Lets every send path return a
83
+ * single canonical `messageId` while public consumers reading the old `resendId`
84
+ * field keep working for one minor.
85
+ */
86
+ export function trackedSendResult(
87
+ result: Omit<TrackedSendResult, "resendId">,
88
+ ): TrackedSendResult {
89
+ return Object.defineProperty({ ...result }, "resendId", {
90
+ get(this: { messageId: string }) {
91
+ return this.messageId;
92
+ },
93
+ enumerable: true,
94
+ }) as TrackedSendResult;
95
+ }
96
+
72
97
  // ---------------------------------------------------------------------------
73
98
  // Frequency capping (client default config)
74
99
  // ---------------------------------------------------------------------------
@@ -102,7 +127,6 @@ export interface EmailServiceConfig {
102
127
  */
103
128
  templates: TemplateRegistry;
104
129
  db?: unknown;
105
- webhookSecret?: string;
106
130
  webhookHandlers?: WebhookHandlerMap;
107
131
  retryOptions?: RetryOptions;
108
132
  bounceThreshold?: number;
@@ -138,13 +162,18 @@ export interface EmailServiceSendOptions<
138
162
  idempotencyKey?: string;
139
163
  }
140
164
 
165
+ /**
166
+ * @deprecated The route now verifies the provider webhook and hands
167
+ * {@link EmailService.handleWebhook} an already-parsed {@link EmailEvent}. This
168
+ * raw `{ payload, headers }` shape is no longer the handler input.
169
+ */
141
170
  export interface EmailServiceWebhookOptions {
142
171
  payload: string;
143
172
  headers: Record<string, string>;
144
173
  }
145
174
 
146
175
  export interface EmailServiceWebhookResult {
147
- type: WebhookEventType;
176
+ type: EmailEventType;
148
177
  handled: boolean;
149
178
  }
150
179
 
@@ -163,7 +192,14 @@ export interface EmailService {
163
192
  options: EmailServiceRenderOptions<K>,
164
193
  ): Promise<EmailServiceRenderResult>;
165
194
 
195
+ /**
196
+ * Dispatch an already-verified, provider-neutral {@link EmailEvent} into the
197
+ * status/suppression/outbound pipeline. The webhook route owns provider
198
+ * resolution + signature verification and passes the parsed event + the
199
+ * resolving `providerId` (the latter is informational for now).
200
+ */
166
201
  handleWebhook(
167
- options: EmailServiceWebhookOptions,
202
+ event: EmailEvent,
203
+ providerId?: string,
168
204
  ): Promise<EmailServiceWebhookResult>;
169
205
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Flatten a `Headers` instance into a plain lowercased `Record<string, string>`.
3
+ * Webhook routes verify signatures over the EXACT received bytes, so they need a
4
+ * case-insensitive header lookup — this is the single place that lowercasing
5
+ * lives. Pass `c.req.raw.headers`.
6
+ */
7
+ export function headersToRecord(headers: Headers): Record<string, string> {
8
+ const record: Record<string, string> = {};
9
+ for (const [key, value] of headers.entries()) {
10
+ record[key.toLowerCase()] = value;
11
+ }
12
+ return record;
13
+ }