@glw907/cairn-cms 0.37.1 → 0.38.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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
+ ## 0.38.0
6
+
7
+ The magic-link send is now awaited rather than fire-and-forget, so a delivery failure reaches the
8
+ login response instead of being swallowed. `requestAction` returns a `status` discriminant
9
+ (`sent` | `send_error` | `throttled`) alongside the existing `sent` boolean, and `LoginPage` renders
10
+ a send-error and a throttled state. The `auth.link.send_failed` log record gains a `code` (the
11
+ Cloudflare binding error code) and a `conditionId` (the mapped diagnostic condition).
12
+
13
+ Consumers may: read `form.status` to render the new states. A site rendering against `form.sent` is
14
+ unaffected, since `sent` is unchanged.
15
+
5
16
  ## 0.37.1
6
17
 
7
18
  Internal groundwork and a docs overhaul; nothing in the public surface or runtime behavior
@@ -17,8 +17,9 @@ the allowlist, so the page never leaks membership (spec §7.1).
17
17
  interface Props {
18
18
  /** The login load's data: the site name, an optional error, and the CSRF token. */
19
19
  data: { siteName: string; error: string | null; csrf: string };
20
- /** The action result: `sent` is true once a request was accepted. */
21
- form: { sent?: boolean } | null;
20
+ /** The action result. `sent` is true once a request was accepted; `status` discriminates the
21
+ * neutral, send-error, and throttled outcomes. */
22
+ form: { sent?: boolean; status?: 'sent' | 'send_error' | 'throttled' } | null;
22
23
  }
23
24
 
24
25
  let { data, form }: Props = $props();
@@ -52,7 +53,7 @@ the allowlist, so the page never leaks membership (spec §7.1).
52
53
  <div data-theme="cairn-admin" bind:this={rootEl}>
53
54
  <div class="flex min-h-screen flex-col items-center justify-center gap-6 bg-base-200 p-4 text-base-content">
54
55
  <div class="w-full max-w-sm rounded-box border border-[var(--cairn-card-border)] bg-base-100 p-7 shadow-[var(--cairn-shadow)]">
55
- {#if form?.sent && !dismissed}
56
+ {#if (form?.status === 'sent' || form?.sent) && !dismissed}
56
57
  <!-- The confirmation is a centered moment: brand, then the mail mark, heading, and one line of
57
58
  instruction. The fallback help sits in a gentle inset note below. -->
58
59
  <div role="status" class="flex flex-col items-center text-center">
@@ -86,7 +87,18 @@ the allowlist, so the page never leaks membership (spec §7.1).
86
87
  <div class="mb-6 flex justify-center">{@render brand()}</div>
87
88
  <h1 class="text-center text-lg font-semibold">Sign in to {data.siteName}</h1>
88
89
  <p class="mt-1 mb-5 text-center text-sm text-[var(--color-muted)]">Enter your email. We'll send a one-time sign-in link.</p>
89
- {#if data.error}
90
+ {#if form?.status === 'send_error'}
91
+ <div role="alert" class="alert alert-warning mb-3 text-sm">
92
+ We're having trouble sending sign-in links right now. Please contact the site owner.
93
+ </div>
94
+ {:else if form?.status === 'throttled'}
95
+ <div role="status" class="alert mb-3 text-sm">
96
+ You requested a link recently. Check your inbox, or wait a minute and try again.
97
+ </div>
98
+ {/if}
99
+ <!-- A fresh action result supersedes the GET-time error, so a resubmit into a throttle or a
100
+ send failure never shows the stale expired-link alert alongside the new state. -->
101
+ {#if data.error && !form?.status}
90
102
  <div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
91
103
  {/if}
92
104
  <form method="POST" class="flex flex-col gap-3">
@@ -6,9 +6,11 @@ interface Props {
6
6
  error: string | null;
7
7
  csrf: string;
8
8
  };
9
- /** The action result: `sent` is true once a request was accepted. */
9
+ /** The action result. `sent` is true once a request was accepted; `status` discriminates the
10
+ * neutral, send-error, and throttled outcomes. */
10
11
  form: {
11
12
  sent?: boolean;
13
+ status?: 'sent' | 'send_error' | 'throttled';
12
14
  } | null;
13
15
  }
14
16
  /**
@@ -23,6 +23,22 @@ const REGISTRY = {
23
23
  remediation: 'Post the form from the same origin, or check a proxy that strips or rewrites the Origin header.',
24
24
  logEvent: 'guard.rejected',
25
25
  },
26
+ 'email.sender-not-onboarded': {
27
+ id: 'email.sender-not-onboarded',
28
+ severity: 'blocker',
29
+ title: 'Email sending domain is not onboarded',
30
+ why: 'The from-address domain has no enabled Cloudflare sending subdomain, so env.EMAIL.send has no aligned sender and the magic-link send throws E_SENDER_NOT_VERIFIED. No editor can sign in.',
31
+ remediation: 'Onboard the sending domain with `wrangler email sending enable <domain>`, then re-deploy. The domain must match branding.from.',
32
+ logEvent: 'auth.link.send_failed',
33
+ },
34
+ 'email.send-failed': {
35
+ id: 'email.send-failed',
36
+ severity: 'blocker',
37
+ title: 'Magic-link email send failed',
38
+ why: 'The magic-link send threw for a reason other than a missing sender onboarding (a delivery error, a binding misconfiguration, or a custom sender failure), so the editor never received a link.',
39
+ remediation: 'Read the auth.link.send_failed log record (the code and error fields) in Workers Logs, and check the EMAIL binding and the sender configuration.',
40
+ logEvent: 'auth.link.send_failed',
41
+ },
26
42
  };
27
43
  /** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
28
44
  export function condition(id) {
package/dist/email.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AuthEnv } from './auth/types.js';
2
+ import { CairnError } from './diagnostics/index.js';
2
3
  export type { AuthEnv };
3
4
  /** The message a built magic-link email carries. */
4
5
  export interface MagicLinkMessage {
@@ -14,7 +15,9 @@ export interface AuthBranding {
14
15
  from: string;
15
16
  replyTo?: string;
16
17
  }
17
- /** The injected send. Production uses `cloudflareSend`; tests pass a sink. */
18
+ /** The injected send. Production uses `cloudflareSend`; tests pass a sink. A thrown error's
19
+ * text reaches the structured log (scrubbed and truncated), so a custom sender must not embed
20
+ * the message body or the magic link in what it throws. */
18
21
  export type SendMagicLink = (env: AuthEnv, message: MagicLinkMessage) => Promise<void>;
19
22
  /** Build the confirmation email. The link is the only action; the copy stays plain. */
20
23
  export declare function buildMagicLinkMessage(input: {
@@ -24,3 +27,19 @@ export declare function buildMagicLinkMessage(input: {
24
27
  }): MagicLinkMessage;
25
28
  /** The production send: Cloudflare Email Sending through the EMAIL binding. */
26
29
  export declare const cloudflareSend: SendMagicLink;
30
+ /**
31
+ * Read the E_* code a Cloudflare Email Sending binding error carries (E_SENDER_NOT_VERIFIED,
32
+ * E_DELIVERY_FAILED, and the rest of the set). The structured `code` property is the documented
33
+ * shape, but it is unproven against the live binding, so a code embedded in the message is read as
34
+ * a fallback. A custom injected sender that throws a plain Error has neither, so this returns
35
+ * undefined and the record still logs cleanly.
36
+ */
37
+ export declare function errorCode(err: unknown): string | undefined;
38
+ /**
39
+ * Map a magic-link send failure to its registered diagnostic condition, carrying the original error
40
+ * as the cause. The not-verified code is the onboarding gap (the ecxc fault); the live binding has
41
+ * also been observed throwing the bare "not a verified address" string with no code, so that
42
+ * message maps to the same condition. Everything else is the generic send failure. The caller logs
43
+ * the conditionId and code, and returns a send_error status.
44
+ */
45
+ export declare function emailSendFailure(err: unknown): CairnError;
package/dist/email.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { CairnError } from './diagnostics/index.js';
1
2
  /** Escape the five HTML-significant characters. */
2
3
  function escapeHtml(value) {
3
4
  return value
@@ -23,3 +24,27 @@ export const cloudflareSend = async (env, message) => {
23
24
  throw new Error('EMAIL binding is not configured');
24
25
  await env.EMAIL.send(message);
25
26
  };
27
+ /**
28
+ * Read the E_* code a Cloudflare Email Sending binding error carries (E_SENDER_NOT_VERIFIED,
29
+ * E_DELIVERY_FAILED, and the rest of the set). The structured `code` property is the documented
30
+ * shape, but it is unproven against the live binding, so a code embedded in the message is read as
31
+ * a fallback. A custom injected sender that throws a plain Error has neither, so this returns
32
+ * undefined and the record still logs cleanly.
33
+ */
34
+ export function errorCode(err) {
35
+ if (typeof err === 'object' && err !== null && 'code' in err && typeof err.code === 'string') {
36
+ return err.code;
37
+ }
38
+ return String(err).match(/\bE_[A-Z][A-Z_]*\b/)?.[0];
39
+ }
40
+ /**
41
+ * Map a magic-link send failure to its registered diagnostic condition, carrying the original error
42
+ * as the cause. The not-verified code is the onboarding gap (the ecxc fault); the live binding has
43
+ * also been observed throwing the bare "not a verified address" string with no code, so that
44
+ * message maps to the same condition. Everything else is the generic send failure. The caller logs
45
+ * the conditionId and code, and returns a send_error status.
46
+ */
47
+ export function emailSendFailure(err) {
48
+ const onboarding = errorCode(err) === 'E_SENDER_NOT_VERIFIED' || String(err).includes('not a verified address');
49
+ return new CairnError(onboarding ? 'email.sender-not-onboarded' : 'email.send-failed', { cause: err });
50
+ }
@@ -4,15 +4,28 @@ export interface AuthRoutesConfig {
4
4
  branding: AuthBranding;
5
5
  send?: SendMagicLink;
6
6
  }
7
+ /**
8
+ * The request-action result. `status` is the discriminant; `sent` is kept for a site rendering its
9
+ * own form against `form.sent`, so the field is additive. The neutral and send-ok paths return the
10
+ * identical `{ status: 'sent', sent: true }`, so the common case never leaks allowlist membership.
11
+ */
12
+ export type RequestResult = {
13
+ status: 'sent';
14
+ sent: true;
15
+ } | {
16
+ status: 'send_error';
17
+ sent: false;
18
+ } | {
19
+ status: 'throttled';
20
+ sent: false;
21
+ };
7
22
  export declare function createAuthRoutes(config: AuthRoutesConfig): {
8
23
  loginLoad: (event: RequestContext) => {
9
24
  siteName: string;
10
25
  error: string | null;
11
26
  csrf: string;
12
27
  };
13
- requestAction: (event: RequestContext) => Promise<{
14
- sent: true;
15
- }>;
28
+ requestAction: (event: RequestContext) => Promise<RequestResult>;
16
29
  confirmLoad: (event: RequestContext) => {
17
30
  token: string;
18
31
  siteName: string;
@@ -5,15 +5,26 @@ import { redirect } from '@sveltejs/kit';
5
5
  import { requireOrigin, requireDb } from '../env.js';
6
6
  import { generateToken, generateSessionId, hashToken, TOKEN_TTL_MS, SESSION_TTL_MS, SEND_COOLDOWN_MS, sessionCookieName, } from '../auth/crypto.js';
7
7
  import { findEditor, issueToken, consumeToken, createSession, deleteSession, recentlyIssued } from '../auth/store.js';
8
- import { buildMagicLinkMessage, cloudflareSend } from '../email.js';
8
+ import { buildMagicLinkMessage, cloudflareSend, emailSendFailure, errorCode } from '../email.js';
9
9
  import { issueCsrfToken } from './csrf.js';
10
10
  import { log } from '../log/index.js';
11
+ /**
12
+ * The loggable form of a send failure. The engine's own senders throw clean errors, but `send` is
13
+ * an injection seam, and a custom sender's thrown error may embed the failed message and with it
14
+ * the magic link. Scrub any token query value and cap the length, so the documented "records never
15
+ * carry a token" guarantee holds for the seam too.
16
+ */
17
+ function scrubSendError(err) {
18
+ return String(err)
19
+ .replace(/([?&]token=)[^&\s"'<]+/g, '$1[redacted]')
20
+ .slice(0, 300);
21
+ }
11
22
  export function createAuthRoutes(config) {
12
23
  const send = config.send ?? cloudflareSend;
13
24
  /**
14
- * POST /admin/auth/request. Looks the email up in the allowlist; on a match, issues a token
15
- * and emails the confirmation link. The response is identical whether or not the email is
16
- * allow-listed, so the endpoint never leaks membership.
25
+ * POST /admin/auth/request. Looks the email up in the allowlist; on a match, issues a token,
26
+ * emails the confirmation link, and awaits the send so the status reflects its outcome. The
27
+ * neutral and send-ok responses are identical, so the common case never leaks membership.
17
28
  */
18
29
  async function requestAction(event) {
19
30
  const env = event.platform?.env ?? {};
@@ -26,31 +37,39 @@ export function createAuthRoutes(config) {
26
37
  // fits well under this; only a junk payload is truncated.
27
38
  log.info('auth.link.requested', { email: email.slice(0, 320) });
28
39
  const editor = email ? await findEditor(db, email) : null;
29
- if (editor) {
30
- const now = Date.now();
31
- // Per-email cooldown: skip the reissue and send when a token for this email was issued within
32
- // the window, so the endpoint cannot flood an editor's inbox. The response is unchanged, so
33
- // the non-leak property holds.
34
- if (!(await recentlyIssued(db, email, now - SEND_COOLDOWN_MS))) {
35
- const token = generateToken();
36
- await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
37
- log.info('auth.token.minted', { email, expiresAt: now + TOKEN_TTL_MS });
38
- const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
39
- // The token row is the security-critical write the email depends on, so it is awaited. The
40
- // send is a post-response side effect, handed to waitUntil so a slow email provider does not
41
- // hold the response. An absent waitUntil (local dev, tests) falls back to await. A send
42
- // failure is logged so observability survives a backgrounded send.
43
- const sending = send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link })).catch((err) => log.error('auth.link.send_failed', { email, error: String(err) }));
44
- // adapter-cloudflare exposes the ExecutionContext as platform.ctx; platform.context is a
45
- // deprecated alias kept as a fallback so an adapter that drops it keeps backgrounding.
46
- const ctx = event.platform?.ctx ?? event.platform?.context;
47
- if (ctx?.waitUntil)
48
- ctx.waitUntil(sending);
49
- else
50
- await sending;
51
- }
40
+ // Non-editor: byte-identical to the editor send-ok path, so the response body never leaks
41
+ // membership. Response timing still differs (the editor path awaits the send), the side-channel
42
+ // the design accepts as strictly weaker than the explicit throttled signal below.
43
+ if (!editor)
44
+ return { status: 'sent', sent: true };
45
+ const now = Date.now();
46
+ // Per-email cooldown: an editor who requested within the window gets the throttled signal rather
47
+ // than a second email. This reveals editor membership, the deliberate relaxed-non-leak posture.
48
+ if (await recentlyIssued(db, email, now - SEND_COOLDOWN_MS)) {
49
+ return { status: 'throttled', sent: false };
50
+ }
51
+ const token = generateToken();
52
+ await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
53
+ log.info('auth.token.minted', { email, expiresAt: now + TOKEN_TTL_MS });
54
+ const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
55
+ // The token row is the security-critical write the email depends on, so it is awaited first.
56
+ // The send is now awaited too (no waitUntil backgrounding), so its outcome drives the response:
57
+ // confirm the link went out before telling an editor to check their inbox. The cost is one
58
+ // email-API round trip on the login POST, the right trade for a login flow.
59
+ try {
60
+ await send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link }));
61
+ }
62
+ catch (err) {
63
+ // Map the binding failure to its registered condition (carried as a CairnError with the
64
+ // original as cause), and log the greppable code plus the conditionId so the next onboarding
65
+ // gap reads straight to its fix. The editor sees only a generic message, never this detail.
66
+ const failure = emailSendFailure(err);
67
+ log.error('auth.link.send_failed', { email, error: scrubSendError(err), code: errorCode(err), conditionId: failure.conditionId });
68
+ // A plain 200 with a status field, not fail(): the result stays one uniform union for the
69
+ // page, and the failure is already observable through the error-level log record.
70
+ return { status: 'send_error', sent: false };
52
71
  }
53
- return { sent: true };
72
+ return { status: 'sent', sent: true };
54
73
  }
55
74
  /** GET /admin/login. Public. Carries the site name, an optional `?error`, and the CSRF token. */
56
75
  function loginLoad(event) {
@@ -1,5 +1,5 @@
1
1
  export { createAuthGuard, requireSession, requireOwner } from './guard.js';
2
- export { createAuthRoutes, type AuthRoutesConfig } from './auth-routes.js';
2
+ export { createAuthRoutes, type AuthRoutesConfig, type RequestResult } from './auth-routes.js';
3
3
  export { createEditorRoutes } from './editors-routes.js';
4
4
  export { createContentRoutes } from './content-routes.js';
5
5
  export type { NavConcept, LayoutData, EntrySummary, ListData, EditData, ContentEvent, ContentRoutesDeps, } from './content-routes.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -17,8 +17,9 @@ the allowlist, so the page never leaks membership (spec §7.1).
17
17
  interface Props {
18
18
  /** The login load's data: the site name, an optional error, and the CSRF token. */
19
19
  data: { siteName: string; error: string | null; csrf: string };
20
- /** The action result: `sent` is true once a request was accepted. */
21
- form: { sent?: boolean } | null;
20
+ /** The action result. `sent` is true once a request was accepted; `status` discriminates the
21
+ * neutral, send-error, and throttled outcomes. */
22
+ form: { sent?: boolean; status?: 'sent' | 'send_error' | 'throttled' } | null;
22
23
  }
23
24
 
24
25
  let { data, form }: Props = $props();
@@ -52,7 +53,7 @@ the allowlist, so the page never leaks membership (spec §7.1).
52
53
  <div data-theme="cairn-admin" bind:this={rootEl}>
53
54
  <div class="flex min-h-screen flex-col items-center justify-center gap-6 bg-base-200 p-4 text-base-content">
54
55
  <div class="w-full max-w-sm rounded-box border border-[var(--cairn-card-border)] bg-base-100 p-7 shadow-[var(--cairn-shadow)]">
55
- {#if form?.sent && !dismissed}
56
+ {#if (form?.status === 'sent' || form?.sent) && !dismissed}
56
57
  <!-- The confirmation is a centered moment: brand, then the mail mark, heading, and one line of
57
58
  instruction. The fallback help sits in a gentle inset note below. -->
58
59
  <div role="status" class="flex flex-col items-center text-center">
@@ -86,7 +87,18 @@ the allowlist, so the page never leaks membership (spec §7.1).
86
87
  <div class="mb-6 flex justify-center">{@render brand()}</div>
87
88
  <h1 class="text-center text-lg font-semibold">Sign in to {data.siteName}</h1>
88
89
  <p class="mt-1 mb-5 text-center text-sm text-[var(--color-muted)]">Enter your email. We'll send a one-time sign-in link.</p>
89
- {#if data.error}
90
+ {#if form?.status === 'send_error'}
91
+ <div role="alert" class="alert alert-warning mb-3 text-sm">
92
+ We're having trouble sending sign-in links right now. Please contact the site owner.
93
+ </div>
94
+ {:else if form?.status === 'throttled'}
95
+ <div role="status" class="alert mb-3 text-sm">
96
+ You requested a link recently. Check your inbox, or wait a minute and try again.
97
+ </div>
98
+ {/if}
99
+ <!-- A fresh action result supersedes the GET-time error, so a resubmit into a throttle or a
100
+ send failure never shows the stale expired-link alert alongside the new state. -->
101
+ {#if data.error && !form?.status}
90
102
  <div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
91
103
  {/if}
92
104
  <form method="POST" class="flex flex-col gap-3">
@@ -49,6 +49,22 @@ const REGISTRY: Record<string, CairnCondition> = {
49
49
  remediation: 'Post the form from the same origin, or check a proxy that strips or rewrites the Origin header.',
50
50
  logEvent: 'guard.rejected',
51
51
  },
52
+ 'email.sender-not-onboarded': {
53
+ id: 'email.sender-not-onboarded',
54
+ severity: 'blocker',
55
+ title: 'Email sending domain is not onboarded',
56
+ why: 'The from-address domain has no enabled Cloudflare sending subdomain, so env.EMAIL.send has no aligned sender and the magic-link send throws E_SENDER_NOT_VERIFIED. No editor can sign in.',
57
+ remediation: 'Onboard the sending domain with `wrangler email sending enable <domain>`, then re-deploy. The domain must match branding.from.',
58
+ logEvent: 'auth.link.send_failed',
59
+ },
60
+ 'email.send-failed': {
61
+ id: 'email.send-failed',
62
+ severity: 'blocker',
63
+ title: 'Magic-link email send failed',
64
+ why: 'The magic-link send threw for a reason other than a missing sender onboarding (a delivery error, a binding misconfiguration, or a custom sender failure), so the editor never received a link.',
65
+ remediation: 'Read the auth.link.send_failed log record (the code and error fields) in Workers Logs, and check the EMAIL binding and the sender configuration.',
66
+ logEvent: 'auth.link.send_failed',
67
+ },
52
68
  };
53
69
 
54
70
  /** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
package/src/lib/email.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // send_email binding; production passes `cloudflareSend`, which calls env.EMAIL.send
3
3
  // (Cloudflare Email Sending, arbitrary recipients).
4
4
  import type { AuthEnv } from './auth/types.js';
5
+ import { CairnError } from './diagnostics/index.js';
5
6
 
6
7
  export type { AuthEnv };
7
8
 
@@ -21,7 +22,9 @@ export interface AuthBranding {
21
22
  replyTo?: string;
22
23
  }
23
24
 
24
- /** The injected send. Production uses `cloudflareSend`; tests pass a sink. */
25
+ /** The injected send. Production uses `cloudflareSend`; tests pass a sink. A thrown error's
26
+ * text reaches the structured log (scrubbed and truncated), so a custom sender must not embed
27
+ * the message body or the magic link in what it throws. */
25
28
  export type SendMagicLink = (env: AuthEnv, message: MagicLinkMessage) => Promise<void>;
26
29
 
27
30
  /** Escape the five HTML-significant characters. */
@@ -54,3 +57,30 @@ export const cloudflareSend: SendMagicLink = async (env, message) => {
54
57
  if (!env.EMAIL) throw new Error('EMAIL binding is not configured');
55
58
  await env.EMAIL.send(message);
56
59
  };
60
+
61
+ /**
62
+ * Read the E_* code a Cloudflare Email Sending binding error carries (E_SENDER_NOT_VERIFIED,
63
+ * E_DELIVERY_FAILED, and the rest of the set). The structured `code` property is the documented
64
+ * shape, but it is unproven against the live binding, so a code embedded in the message is read as
65
+ * a fallback. A custom injected sender that throws a plain Error has neither, so this returns
66
+ * undefined and the record still logs cleanly.
67
+ */
68
+ export function errorCode(err: unknown): string | undefined {
69
+ if (typeof err === 'object' && err !== null && 'code' in err && typeof err.code === 'string') {
70
+ return err.code;
71
+ }
72
+ return String(err).match(/\bE_[A-Z][A-Z_]*\b/)?.[0];
73
+ }
74
+
75
+ /**
76
+ * Map a magic-link send failure to its registered diagnostic condition, carrying the original error
77
+ * as the cause. The not-verified code is the onboarding gap (the ecxc fault); the live binding has
78
+ * also been observed throwing the bare "not a verified address" string with no code, so that
79
+ * message maps to the same condition. Everything else is the generic send failure. The caller logs
80
+ * the conditionId and code, and returns a send_error status.
81
+ */
82
+ export function emailSendFailure(err: unknown): CairnError {
83
+ const onboarding =
84
+ errorCode(err) === 'E_SENDER_NOT_VERIFIED' || String(err).includes('not a verified address');
85
+ return new CairnError(onboarding ? 'email.sender-not-onboarded' : 'email.send-failed', { cause: err });
86
+ }
@@ -13,7 +13,7 @@ import {
13
13
  sessionCookieName,
14
14
  } from '../auth/crypto.js';
15
15
  import { findEditor, issueToken, consumeToken, createSession, deleteSession, recentlyIssued } from '../auth/store.js';
16
- import { buildMagicLinkMessage, cloudflareSend, type AuthBranding, type SendMagicLink } from '../email.js';
16
+ import { buildMagicLinkMessage, cloudflareSend, emailSendFailure, errorCode, type AuthBranding, type SendMagicLink } from '../email.js';
17
17
  import { issueCsrfToken } from './csrf.js';
18
18
  import { log } from '../log/index.js';
19
19
  import type { RequestContext } from './types.js';
@@ -23,15 +23,37 @@ export interface AuthRoutesConfig {
23
23
  send?: SendMagicLink;
24
24
  }
25
25
 
26
+ /**
27
+ * The request-action result. `status` is the discriminant; `sent` is kept for a site rendering its
28
+ * own form against `form.sent`, so the field is additive. The neutral and send-ok paths return the
29
+ * identical `{ status: 'sent', sent: true }`, so the common case never leaks allowlist membership.
30
+ */
31
+ export type RequestResult =
32
+ | { status: 'sent'; sent: true }
33
+ | { status: 'send_error'; sent: false }
34
+ | { status: 'throttled'; sent: false };
35
+
36
+ /**
37
+ * The loggable form of a send failure. The engine's own senders throw clean errors, but `send` is
38
+ * an injection seam, and a custom sender's thrown error may embed the failed message and with it
39
+ * the magic link. Scrub any token query value and cap the length, so the documented "records never
40
+ * carry a token" guarantee holds for the seam too.
41
+ */
42
+ function scrubSendError(err: unknown): string {
43
+ return String(err)
44
+ .replace(/([?&]token=)[^&\s"'<]+/g, '$1[redacted]')
45
+ .slice(0, 300);
46
+ }
47
+
26
48
  export function createAuthRoutes(config: AuthRoutesConfig) {
27
49
  const send = config.send ?? cloudflareSend;
28
50
 
29
51
  /**
30
- * POST /admin/auth/request. Looks the email up in the allowlist; on a match, issues a token
31
- * and emails the confirmation link. The response is identical whether or not the email is
32
- * allow-listed, so the endpoint never leaks membership.
52
+ * POST /admin/auth/request. Looks the email up in the allowlist; on a match, issues a token,
53
+ * emails the confirmation link, and awaits the send so the status reflects its outcome. The
54
+ * neutral and send-ok responses are identical, so the common case never leaks membership.
33
55
  */
34
- async function requestAction(event: RequestContext): Promise<{ sent: true }> {
56
+ async function requestAction(event: RequestContext): Promise<RequestResult> {
35
57
  const env = event.platform?.env ?? {};
36
58
  const origin = requireOrigin(env);
37
59
  const db = requireDb(env);
@@ -43,31 +65,39 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
43
65
  log.info('auth.link.requested', { email: email.slice(0, 320) });
44
66
 
45
67
  const editor = email ? await findEditor(db, email) : null;
46
- if (editor) {
47
- const now = Date.now();
48
- // Per-email cooldown: skip the reissue and send when a token for this email was issued within
49
- // the window, so the endpoint cannot flood an editor's inbox. The response is unchanged, so
50
- // the non-leak property holds.
51
- if (!(await recentlyIssued(db, email, now - SEND_COOLDOWN_MS))) {
52
- const token = generateToken();
53
- await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
54
- log.info('auth.token.minted', { email, expiresAt: now + TOKEN_TTL_MS });
55
- const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
56
- // The token row is the security-critical write the email depends on, so it is awaited. The
57
- // send is a post-response side effect, handed to waitUntil so a slow email provider does not
58
- // hold the response. An absent waitUntil (local dev, tests) falls back to await. A send
59
- // failure is logged so observability survives a backgrounded send.
60
- const sending = send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link })).catch(
61
- (err) => log.error('auth.link.send_failed', { email, error: String(err) }),
62
- );
63
- // adapter-cloudflare exposes the ExecutionContext as platform.ctx; platform.context is a
64
- // deprecated alias kept as a fallback so an adapter that drops it keeps backgrounding.
65
- const ctx = event.platform?.ctx ?? event.platform?.context;
66
- if (ctx?.waitUntil) ctx.waitUntil(sending);
67
- else await sending;
68
- }
68
+ // Non-editor: byte-identical to the editor send-ok path, so the response body never leaks
69
+ // membership. Response timing still differs (the editor path awaits the send), the side-channel
70
+ // the design accepts as strictly weaker than the explicit throttled signal below.
71
+ if (!editor) return { status: 'sent', sent: true };
72
+
73
+ const now = Date.now();
74
+ // Per-email cooldown: an editor who requested within the window gets the throttled signal rather
75
+ // than a second email. This reveals editor membership, the deliberate relaxed-non-leak posture.
76
+ if (await recentlyIssued(db, email, now - SEND_COOLDOWN_MS)) {
77
+ return { status: 'throttled', sent: false };
78
+ }
79
+
80
+ const token = generateToken();
81
+ await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
82
+ log.info('auth.token.minted', { email, expiresAt: now + TOKEN_TTL_MS });
83
+ const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
84
+ // The token row is the security-critical write the email depends on, so it is awaited first.
85
+ // The send is now awaited too (no waitUntil backgrounding), so its outcome drives the response:
86
+ // confirm the link went out before telling an editor to check their inbox. The cost is one
87
+ // email-API round trip on the login POST, the right trade for a login flow.
88
+ try {
89
+ await send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link }));
90
+ } catch (err) {
91
+ // Map the binding failure to its registered condition (carried as a CairnError with the
92
+ // original as cause), and log the greppable code plus the conditionId so the next onboarding
93
+ // gap reads straight to its fix. The editor sees only a generic message, never this detail.
94
+ const failure = emailSendFailure(err);
95
+ log.error('auth.link.send_failed', { email, error: scrubSendError(err), code: errorCode(err), conditionId: failure.conditionId });
96
+ // A plain 200 with a status field, not fail(): the result stays one uniform union for the
97
+ // page, and the failure is already observable through the error-level log record.
98
+ return { status: 'send_error', sent: false };
69
99
  }
70
- return { sent: true };
100
+ return { status: 'sent', sent: true };
71
101
  }
72
102
 
73
103
  /** GET /admin/login. Public. Carries the site name, an optional `?error`, and the CSRF token. */
@@ -1,7 +1,7 @@
1
1
  // SvelteKit server logic consumed by site route shims: the guard plus the auth, editor,
2
2
  // content, and health route factories and functions.
3
3
  export { createAuthGuard, requireSession, requireOwner } from './guard.js';
4
- export { createAuthRoutes, type AuthRoutesConfig } from './auth-routes.js';
4
+ export { createAuthRoutes, type AuthRoutesConfig, type RequestResult } from './auth-routes.js';
5
5
  export { createEditorRoutes } from './editors-routes.js';
6
6
  export { createContentRoutes } from './content-routes.js';
7
7
  export type {