@scalemule/news-publication 0.0.86 → 0.0.88

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/README.md CHANGED
@@ -260,11 +260,11 @@ other than that key and the browser only ever sees host-only HttpOnly cookies.
260
260
  `sessionSummary` return cookie instructions and destinations; the site applies
261
261
  them with `applyCookies` and answers with private, no-store headers. Merge
262
262
  `READER_SSO_EVENTS` into the site's analytics registry and allow
263
- `READER_SSO_PROPERTY_KEYS` in its property sanitizer. `ReaderSsoBoot` performs at
264
- most one silent attempt per page load and never on private paths, for bots,
265
- while typing, in frames or in prerendered documents; the server's negative cache
266
- makes a loop impossible. `ReaderGreeting` is a client island that renders only a
267
- first name, in the reader's own time zone and locale, after loading.
263
+ `READER_SSO_PROPERTY_KEYS` in its property sanitizer. `ReaderSsoBoot` is a
264
+ no-op compatibility export: a rendered page never starts Ledvery authorization
265
+ on a timer. The explicit sign-in routes still work. `ReaderGreeting` is a client
266
+ island that renders only a first name, in the reader's own time zone and locale,
267
+ after loading.
268
268
 
269
269
  ## Community pet sections
270
270
 
@@ -4,46 +4,6 @@
4
4
  var react = require('react');
5
5
  var jsxRuntime = require('react/jsx-runtime');
6
6
 
7
- // src/reader-sso-scheduler.ts
8
- function mountReaderSsoScheduler({ document: doc, timers, eligible, attempt, idleDelayMs }) {
9
- let active = true;
10
- let attempted = false;
11
- let idle;
12
- let delay;
13
- const cancelPending = () => {
14
- if (idle !== void 0) timers.cancelIdleCallback?.(idle);
15
- if (delay !== void 0) timers.clearTimeout(delay);
16
- idle = delay = void 0;
17
- };
18
- const run = () => {
19
- delay = void 0;
20
- if (!active || attempted || !eligible()) return;
21
- attempted = true;
22
- attempt();
23
- };
24
- const defer = () => {
25
- idle = void 0;
26
- if (active && !attempted) delay = timers.setTimeout(run, idleDelayMs);
27
- };
28
- const schedule = () => {
29
- if (!active || attempted) return;
30
- cancelPending();
31
- if (doc.readyState === "loading" || !eligible()) return;
32
- if (timers.requestIdleCallback) idle = timers.requestIdleCallback(defer, { timeout: 4e3 });
33
- else defer();
34
- };
35
- doc.addEventListener("DOMContentLoaded", schedule);
36
- doc.addEventListener("visibilitychange", schedule);
37
- doc.addEventListener("prerenderingchange", schedule);
38
- schedule();
39
- return () => {
40
- active = false;
41
- cancelPending();
42
- doc.removeEventListener("DOMContentLoaded", schedule);
43
- doc.removeEventListener("visibilitychange", schedule);
44
- doc.removeEventListener("prerenderingchange", schedule);
45
- };
46
- }
47
7
  var READER_SSO_BOT_PATTERN = /bot|crawl|spider|headless|lighthouse|pagespeed|preview|slurp|facebookexternalhit|embedly|python|curl|wget|phantom|puppeteer|playwright|selenium|prerender/i;
48
8
  var READER_SSO_PRIVATE_PATHS = [
49
9
  /^\/account(\/|$)/,
@@ -86,72 +46,7 @@ function isFormField(element) {
86
46
  const tag = focused.tagName;
87
47
  return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || focused.isContentEditable === true;
88
48
  }
89
- var ATTEMPT_KEY = "reader-sso-attempt-v1";
90
- function readAttempt() {
91
- try {
92
- const raw = window.sessionStorage.getItem(ATTEMPT_KEY);
93
- const value = raw ? Number(raw) : NaN;
94
- return Number.isFinite(value) ? value : null;
95
- } catch {
96
- return null;
97
- }
98
- }
99
- function writeAttempt(now) {
100
- try {
101
- window.sessionStorage.setItem(ATTEMPT_KEY, String(now));
102
- } catch {
103
- }
104
- }
105
- function browserEnvironment(flags) {
106
- const doc = document;
107
- let framed = false;
108
- try {
109
- framed = window.top !== window.self;
110
- } catch {
111
- framed = true;
112
- }
113
- return {
114
- ...flags,
115
- webdriver: navigator.webdriver === true,
116
- userAgent: navigator.userAgent,
117
- pathname: location.pathname,
118
- search: location.search,
119
- fieldFocused: isFormField(document.activeElement),
120
- prerendering: doc.prerendering === true,
121
- visibilityState: document.visibilityState,
122
- framed,
123
- lastAttemptAt: readAttempt(),
124
- now: Date.now()
125
- };
126
- }
127
- function ReaderSsoBoot({ enabled = true, hasSession: hasSessionCookie, suppressed, startPath = "/api/news/ledvery-start", sessionPath = "/api/news/session", privatePaths, idleDelayMs = 1500 }) {
128
- const [staleCookie, setStaleCookie] = react.useState(false);
129
- react.useEffect(() => {
130
- if (!enabled || !hasSessionCookie || staleCookie) return;
131
- let cancelled = false;
132
- fetch(sessionPath, { credentials: "same-origin", cache: "no-store" }).then((response) => response.json()).then((body) => {
133
- if (!cancelled && (body?.authenticated !== true || body.session_upgrade_required === true)) setStaleCookie(true);
134
- }).catch(() => {
135
- });
136
- return () => {
137
- cancelled = true;
138
- };
139
- }, [enabled, hasSessionCookie, staleCookie, sessionPath]);
140
- const hasSession = hasSessionCookie && !staleCookie;
141
- react.useEffect(() => {
142
- const flags = { enabled, hasSession, suppressed, privatePaths };
143
- return mountReaderSsoScheduler({
144
- document,
145
- timers: window,
146
- idleDelayMs,
147
- eligible: () => silentSignInDecision(browserEnvironment(flags)).attempt === true,
148
- attempt: () => {
149
- const env = browserEnvironment(flags);
150
- writeAttempt(env.now);
151
- window.location.assign(silentSignInUrl(startPath, env.pathname, env.search));
152
- }
153
- });
154
- }, [enabled, hasSession, suppressed, startPath, privatePaths, idleDelayMs]);
49
+ function ReaderSsoBoot(_props) {
155
50
  return null;
156
51
  }
157
52
  function firstName(fullName) {
@@ -376,7 +271,7 @@ function AccountLinkForm({
376
271
  /* @__PURE__ */ jsxRuntime.jsx("input", { type: "password", autoComplete: "current-password", required: true, minLength: 1, maxLength: 1024, value: password, onChange: (e) => setPassword(e.target.value) })
377
272
  ] }),
378
273
  mfaRequired && /* @__PURE__ */ jsxRuntime.jsxs("label", { children: [
379
- "Authentication code",
274
+ "Verification code",
380
275
  /* @__PURE__ */ jsxRuntime.jsx("input", { type: "text", inputMode: "numeric", autoComplete: "one-time-code", required: true, maxLength: 32, value: code, onChange: (e) => setCode(e.target.value) })
381
276
  ] }),
382
277
  /* @__PURE__ */ jsxRuntime.jsx("button", { className: classNames.button, disabled: busy || !password || mfaRequired && !code, children: busy ? "Connecting\u2026" : "Connect and sign in" }),
@@ -28,16 +28,18 @@ type SilentSignInDecision = {
28
28
  attempt: false;
29
29
  reason: string;
30
30
  };
31
+ /** @deprecated No automatic reader sign-in should use this decision. */
31
32
  declare function silentSignInDecision(env: SilentSignInEnvironment): SilentSignInDecision;
33
+ /** @deprecated Use an explicit, user-initiated handoff flow instead. */
32
34
  declare function silentSignInUrl(startPath: string, pathname: string, search: string): string;
33
35
  declare function isFormField(element: Element | null): boolean;
34
36
  /**
35
- * Renders nothing. Once per page load, after the DOM is usable and an idle period, performs a
36
- * top-level navigation to the silent sign-in start route when nothing argues
37
- * against it. The server sets the negative-cache cookie on every silent negative,
38
- * so this can never loop.
37
+ * Compatibility export for publications that still mount the old boot island.
38
+ * Reader identity is checked by the same-origin session UI; authentication
39
+ * must begin with an intentional click, never a timer or page-load navigation.
40
+ * @deprecated Remove this component from publication layouts.
39
41
  */
40
- declare function ReaderSsoBoot({ enabled, hasSession: hasSessionCookie, suppressed, startPath, sessionPath, privatePaths, idleDelayMs }: {
42
+ declare function ReaderSsoBoot(_props: {
41
43
  enabled?: boolean;
42
44
  /** A reader session cookie is present (it may have been revoked elsewhere). */
43
45
  hasSession: boolean;
@@ -28,16 +28,18 @@ type SilentSignInDecision = {
28
28
  attempt: false;
29
29
  reason: string;
30
30
  };
31
+ /** @deprecated No automatic reader sign-in should use this decision. */
31
32
  declare function silentSignInDecision(env: SilentSignInEnvironment): SilentSignInDecision;
33
+ /** @deprecated Use an explicit, user-initiated handoff flow instead. */
32
34
  declare function silentSignInUrl(startPath: string, pathname: string, search: string): string;
33
35
  declare function isFormField(element: Element | null): boolean;
34
36
  /**
35
- * Renders nothing. Once per page load, after the DOM is usable and an idle period, performs a
36
- * top-level navigation to the silent sign-in start route when nothing argues
37
- * against it. The server sets the negative-cache cookie on every silent negative,
38
- * so this can never loop.
37
+ * Compatibility export for publications that still mount the old boot island.
38
+ * Reader identity is checked by the same-origin session UI; authentication
39
+ * must begin with an intentional click, never a timer or page-load navigation.
40
+ * @deprecated Remove this component from publication layouts.
39
41
  */
40
- declare function ReaderSsoBoot({ enabled, hasSession: hasSessionCookie, suppressed, startPath, sessionPath, privatePaths, idleDelayMs }: {
42
+ declare function ReaderSsoBoot(_props: {
41
43
  enabled?: boolean;
42
44
  /** A reader session cookie is present (it may have been revoked elsewhere). */
43
45
  hasSession: boolean;
@@ -2,46 +2,6 @@
2
2
  import { useState, useEffect, useId } from 'react';
3
3
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
4
 
5
- // src/reader-sso-scheduler.ts
6
- function mountReaderSsoScheduler({ document: doc, timers, eligible, attempt, idleDelayMs }) {
7
- let active = true;
8
- let attempted = false;
9
- let idle;
10
- let delay;
11
- const cancelPending = () => {
12
- if (idle !== void 0) timers.cancelIdleCallback?.(idle);
13
- if (delay !== void 0) timers.clearTimeout(delay);
14
- idle = delay = void 0;
15
- };
16
- const run = () => {
17
- delay = void 0;
18
- if (!active || attempted || !eligible()) return;
19
- attempted = true;
20
- attempt();
21
- };
22
- const defer = () => {
23
- idle = void 0;
24
- if (active && !attempted) delay = timers.setTimeout(run, idleDelayMs);
25
- };
26
- const schedule = () => {
27
- if (!active || attempted) return;
28
- cancelPending();
29
- if (doc.readyState === "loading" || !eligible()) return;
30
- if (timers.requestIdleCallback) idle = timers.requestIdleCallback(defer, { timeout: 4e3 });
31
- else defer();
32
- };
33
- doc.addEventListener("DOMContentLoaded", schedule);
34
- doc.addEventListener("visibilitychange", schedule);
35
- doc.addEventListener("prerenderingchange", schedule);
36
- schedule();
37
- return () => {
38
- active = false;
39
- cancelPending();
40
- doc.removeEventListener("DOMContentLoaded", schedule);
41
- doc.removeEventListener("visibilitychange", schedule);
42
- doc.removeEventListener("prerenderingchange", schedule);
43
- };
44
- }
45
5
  var READER_SSO_BOT_PATTERN = /bot|crawl|spider|headless|lighthouse|pagespeed|preview|slurp|facebookexternalhit|embedly|python|curl|wget|phantom|puppeteer|playwright|selenium|prerender/i;
46
6
  var READER_SSO_PRIVATE_PATHS = [
47
7
  /^\/account(\/|$)/,
@@ -84,72 +44,7 @@ function isFormField(element) {
84
44
  const tag = focused.tagName;
85
45
  return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || focused.isContentEditable === true;
86
46
  }
87
- var ATTEMPT_KEY = "reader-sso-attempt-v1";
88
- function readAttempt() {
89
- try {
90
- const raw = window.sessionStorage.getItem(ATTEMPT_KEY);
91
- const value = raw ? Number(raw) : NaN;
92
- return Number.isFinite(value) ? value : null;
93
- } catch {
94
- return null;
95
- }
96
- }
97
- function writeAttempt(now) {
98
- try {
99
- window.sessionStorage.setItem(ATTEMPT_KEY, String(now));
100
- } catch {
101
- }
102
- }
103
- function browserEnvironment(flags) {
104
- const doc = document;
105
- let framed = false;
106
- try {
107
- framed = window.top !== window.self;
108
- } catch {
109
- framed = true;
110
- }
111
- return {
112
- ...flags,
113
- webdriver: navigator.webdriver === true,
114
- userAgent: navigator.userAgent,
115
- pathname: location.pathname,
116
- search: location.search,
117
- fieldFocused: isFormField(document.activeElement),
118
- prerendering: doc.prerendering === true,
119
- visibilityState: document.visibilityState,
120
- framed,
121
- lastAttemptAt: readAttempt(),
122
- now: Date.now()
123
- };
124
- }
125
- function ReaderSsoBoot({ enabled = true, hasSession: hasSessionCookie, suppressed, startPath = "/api/news/ledvery-start", sessionPath = "/api/news/session", privatePaths, idleDelayMs = 1500 }) {
126
- const [staleCookie, setStaleCookie] = useState(false);
127
- useEffect(() => {
128
- if (!enabled || !hasSessionCookie || staleCookie) return;
129
- let cancelled = false;
130
- fetch(sessionPath, { credentials: "same-origin", cache: "no-store" }).then((response) => response.json()).then((body) => {
131
- if (!cancelled && (body?.authenticated !== true || body.session_upgrade_required === true)) setStaleCookie(true);
132
- }).catch(() => {
133
- });
134
- return () => {
135
- cancelled = true;
136
- };
137
- }, [enabled, hasSessionCookie, staleCookie, sessionPath]);
138
- const hasSession = hasSessionCookie && !staleCookie;
139
- useEffect(() => {
140
- const flags = { enabled, hasSession, suppressed, privatePaths };
141
- return mountReaderSsoScheduler({
142
- document,
143
- timers: window,
144
- idleDelayMs,
145
- eligible: () => silentSignInDecision(browserEnvironment(flags)).attempt === true,
146
- attempt: () => {
147
- const env = browserEnvironment(flags);
148
- writeAttempt(env.now);
149
- window.location.assign(silentSignInUrl(startPath, env.pathname, env.search));
150
- }
151
- });
152
- }, [enabled, hasSession, suppressed, startPath, privatePaths, idleDelayMs]);
47
+ function ReaderSsoBoot(_props) {
153
48
  return null;
154
49
  }
155
50
  function firstName(fullName) {
@@ -374,7 +269,7 @@ function AccountLinkForm({
374
269
  /* @__PURE__ */ jsx("input", { type: "password", autoComplete: "current-password", required: true, minLength: 1, maxLength: 1024, value: password, onChange: (e) => setPassword(e.target.value) })
375
270
  ] }),
376
271
  mfaRequired && /* @__PURE__ */ jsxs("label", { children: [
377
- "Authentication code",
272
+ "Verification code",
378
273
  /* @__PURE__ */ jsx("input", { type: "text", inputMode: "numeric", autoComplete: "one-time-code", required: true, maxLength: 32, value: code, onChange: (e) => setCode(e.target.value) })
379
274
  ] }),
380
275
  /* @__PURE__ */ jsx("button", { className: classNames.button, disabled: busy || !password || mfaRequired && !code, children: busy ? "Connecting\u2026" : "Connect and sign in" }),
@@ -34,7 +34,8 @@ function readerSsoCookieNames(prefix) {
34
34
  newsletter: `${prefix}-newsletter`,
35
35
  ssoChecked: `${prefix}-sso-checked`,
36
36
  signedOut: `${prefix}-signed-out`,
37
- pending: `${prefix}-sso-pending`
37
+ pending: `${prefix}-sso-pending`,
38
+ linkCode: `${prefix}-link-code`
38
39
  };
39
40
  }
40
41
  var readerSsoCookieOptions = { httpOnly: true, secure: true, sameSite: "lax", path: "/" };
@@ -365,6 +366,35 @@ function linkFailureMessage(code) {
365
366
  return null;
366
367
  }
367
368
  }
369
+ function linkEmailCodeFailure(code, response, cookieName) {
370
+ switch (code) {
371
+ case "LOGIN_CHALLENGE_REQUIRED": {
372
+ const body = response.body;
373
+ let token = "";
374
+ try {
375
+ const parsed = JSON.parse(typeof body?.error?.message === "string" ? body.error.message : "{}");
376
+ token = typeof parsed.challenge_token === "string" && parsed.challenge_token.length <= 256 ? parsed.challenge_token : "";
377
+ } catch {
378
+ token = "";
379
+ }
380
+ if (!token) return null;
381
+ return {
382
+ status: 401,
383
+ mfaRequired: true,
384
+ error: "We emailed a 6-digit code to your reader account's address. Enter it to finish connecting your sign-in.",
385
+ cookies: [{ name: cookieName, value: token, maxAge: 600 }]
386
+ };
387
+ }
388
+ case "LOGIN_CHALLENGE_INVALID":
389
+ return { status: 401, mfaRequired: true, error: "That code is incorrect or expired. Check the most recent email, or start over for a new code.", cookies: [] };
390
+ case "CHALLENGE_RATE_LIMITED":
391
+ return { status: 429, mfaRequired: true, error: "Please wait a minute before asking for another code.", cookies: [] };
392
+ case "LOGIN_CHALLENGE_LOCKED":
393
+ return { status: 429, error: "Too many incorrect codes. Reset your reader account password to continue.", cookies: [{ name: cookieName, value: "", maxAge: 0 }] };
394
+ default:
395
+ return null;
396
+ }
397
+ }
368
398
  async function linkAccount(config, input) {
369
399
  const c = resolve(config);
370
400
  const challengeId = typeof input.challenge_id === "string" && CHALLENGE_ID.test(input.challenge_id) ? input.challenge_id : "";
@@ -378,9 +408,16 @@ async function linkAccount(config, input) {
378
408
  if (mfaCode && (mfaCode.length > 32 || !/^[A-Za-z0-9-]+$/.test(mfaCode))) {
379
409
  return { ok: false, status: 400, error: "Enter the code exactly as your authenticator app shows it.", cookies: [], mfaRequired: true };
380
410
  }
411
+ const emailChallenge = input.cookies(c.names.linkCode) || "";
412
+ const linkBody = emailChallenge && mfaCode ? { challenge_id: challengeId, password, challenge_token: emailChallenge, challenge_code: mfaCode } : { challenge_id: challengeId, password, ...mfaCode ? { mfa_code: mfaCode } : {} };
381
413
  try {
382
- const response = await c.request("/v1/auth/oauth/ledvery/link", { method: "POST", body: { challenge_id: challengeId, password, ...mfaCode ? { mfa_code: mfaCode } : {} } });
414
+ const response = await c.request("/v1/auth/oauth/ledvery/link", { method: "POST", body: linkBody });
383
415
  if (failed(response)) {
416
+ const emailCode = linkEmailCodeFailure(errorCode(response), response, c.names.linkCode);
417
+ if (emailCode) {
418
+ emit(c, { name: "reader_signin_failed", properties: { reason: "mfa_required", mode: "interactive" } });
419
+ return { ok: false, ...emailCode };
420
+ }
384
421
  const specific = linkFailureMessage(errorCode(response));
385
422
  if (specific) {
386
423
  emit(c, { name: "reader_signin_failed", properties: { reason: specific.mfaRequired ? "mfa_required" : "link_invalid", mode: "interactive" } });
@@ -391,7 +428,7 @@ async function linkAccount(config, input) {
391
428
  }
392
429
  const session = issuedSession(response, c.now());
393
430
  const flow = { digest: "", subscribe: input.subscribe === true, expires: 0, returnTo: next, mode: "interactive", provider: void 0 };
394
- const result = await establish(c, { session, flow, method: "link", cookies: [], input, negativeCache: { name: c.names.ssoChecked, value: "1", maxAge: READER_SSO_NEGATIVE_CACHE_SECONDS } });
431
+ const result = await establish(c, { session, flow, method: "link", cookies: emailChallenge ? [clear(c.names.linkCode)] : [], input, negativeCache: { name: c.names.ssoChecked, value: "1", maxAge: READER_SSO_NEGATIVE_CACHE_SECONDS } });
395
432
  if (result.kind === "signed-in") emit(c, { name: "reader_account_linked", properties: {} });
396
433
  return result.kind === "signed-in" ? { ok: true, switch: false, cookies: result.cookies, token: result.token, expiresAt: result.expiresAt, user: result.user, location: result.location } : { ok: true, switch: true, cookies: result.cookies, location: result.location };
397
434
  } catch {
@@ -23,6 +23,8 @@ type ReaderSsoCookieNames = {
23
23
  signedOut: string;
24
24
  /** A verified session waiting for the reader to choose between two accounts. */
25
25
  pending: string;
26
+ /** Linking onto an unconfirmed account: the platform's emailed-code challenge token (10 minutes). */
27
+ linkCode: string;
26
28
  };
27
29
  /** Cookie names derived from a `__Host-` prefix, e.g. `__Host-media` → `__Host-media-reader`. */
28
30
  declare function readerSsoCookieNames(prefix: string): ReaderSsoCookieNames;
@@ -23,6 +23,8 @@ type ReaderSsoCookieNames = {
23
23
  signedOut: string;
24
24
  /** A verified session waiting for the reader to choose between two accounts. */
25
25
  pending: string;
26
+ /** Linking onto an unconfirmed account: the platform's emailed-code challenge token (10 minutes). */
27
+ linkCode: string;
26
28
  };
27
29
  /** Cookie names derived from a `__Host-` prefix, e.g. `__Host-media` → `__Host-media-reader`. */
28
30
  declare function readerSsoCookieNames(prefix: string): ReaderSsoCookieNames;
@@ -19,7 +19,8 @@ function readerSsoCookieNames(prefix) {
19
19
  newsletter: `${prefix}-newsletter`,
20
20
  ssoChecked: `${prefix}-sso-checked`,
21
21
  signedOut: `${prefix}-signed-out`,
22
- pending: `${prefix}-sso-pending`
22
+ pending: `${prefix}-sso-pending`,
23
+ linkCode: `${prefix}-link-code`
23
24
  };
24
25
  }
25
26
  var readerSsoCookieOptions = { httpOnly: true, secure: true, sameSite: "lax", path: "/" };
@@ -350,6 +351,35 @@ function linkFailureMessage(code) {
350
351
  return null;
351
352
  }
352
353
  }
354
+ function linkEmailCodeFailure(code, response, cookieName) {
355
+ switch (code) {
356
+ case "LOGIN_CHALLENGE_REQUIRED": {
357
+ const body = response.body;
358
+ let token = "";
359
+ try {
360
+ const parsed = JSON.parse(typeof body?.error?.message === "string" ? body.error.message : "{}");
361
+ token = typeof parsed.challenge_token === "string" && parsed.challenge_token.length <= 256 ? parsed.challenge_token : "";
362
+ } catch {
363
+ token = "";
364
+ }
365
+ if (!token) return null;
366
+ return {
367
+ status: 401,
368
+ mfaRequired: true,
369
+ error: "We emailed a 6-digit code to your reader account's address. Enter it to finish connecting your sign-in.",
370
+ cookies: [{ name: cookieName, value: token, maxAge: 600 }]
371
+ };
372
+ }
373
+ case "LOGIN_CHALLENGE_INVALID":
374
+ return { status: 401, mfaRequired: true, error: "That code is incorrect or expired. Check the most recent email, or start over for a new code.", cookies: [] };
375
+ case "CHALLENGE_RATE_LIMITED":
376
+ return { status: 429, mfaRequired: true, error: "Please wait a minute before asking for another code.", cookies: [] };
377
+ case "LOGIN_CHALLENGE_LOCKED":
378
+ return { status: 429, error: "Too many incorrect codes. Reset your reader account password to continue.", cookies: [{ name: cookieName, value: "", maxAge: 0 }] };
379
+ default:
380
+ return null;
381
+ }
382
+ }
353
383
  async function linkAccount(config, input) {
354
384
  const c = resolve(config);
355
385
  const challengeId = typeof input.challenge_id === "string" && CHALLENGE_ID.test(input.challenge_id) ? input.challenge_id : "";
@@ -363,9 +393,16 @@ async function linkAccount(config, input) {
363
393
  if (mfaCode && (mfaCode.length > 32 || !/^[A-Za-z0-9-]+$/.test(mfaCode))) {
364
394
  return { ok: false, status: 400, error: "Enter the code exactly as your authenticator app shows it.", cookies: [], mfaRequired: true };
365
395
  }
396
+ const emailChallenge = input.cookies(c.names.linkCode) || "";
397
+ const linkBody = emailChallenge && mfaCode ? { challenge_id: challengeId, password, challenge_token: emailChallenge, challenge_code: mfaCode } : { challenge_id: challengeId, password, ...mfaCode ? { mfa_code: mfaCode } : {} };
366
398
  try {
367
- const response = await c.request("/v1/auth/oauth/ledvery/link", { method: "POST", body: { challenge_id: challengeId, password, ...mfaCode ? { mfa_code: mfaCode } : {} } });
399
+ const response = await c.request("/v1/auth/oauth/ledvery/link", { method: "POST", body: linkBody });
368
400
  if (failed(response)) {
401
+ const emailCode = linkEmailCodeFailure(errorCode(response), response, c.names.linkCode);
402
+ if (emailCode) {
403
+ emit(c, { name: "reader_signin_failed", properties: { reason: "mfa_required", mode: "interactive" } });
404
+ return { ok: false, ...emailCode };
405
+ }
369
406
  const specific = linkFailureMessage(errorCode(response));
370
407
  if (specific) {
371
408
  emit(c, { name: "reader_signin_failed", properties: { reason: specific.mfaRequired ? "mfa_required" : "link_invalid", mode: "interactive" } });
@@ -376,7 +413,7 @@ async function linkAccount(config, input) {
376
413
  }
377
414
  const session = issuedSession(response, c.now());
378
415
  const flow = { digest: "", subscribe: input.subscribe === true, expires: 0, returnTo: next, mode: "interactive", provider: void 0 };
379
- const result = await establish(c, { session, flow, method: "link", cookies: [], input, negativeCache: { name: c.names.ssoChecked, value: "1", maxAge: READER_SSO_NEGATIVE_CACHE_SECONDS } });
416
+ const result = await establish(c, { session, flow, method: "link", cookies: emailChallenge ? [clear(c.names.linkCode)] : [], input, negativeCache: { name: c.names.ssoChecked, value: "1", maxAge: READER_SSO_NEGATIVE_CACHE_SECONDS } });
380
417
  if (result.kind === "signed-in") emit(c, { name: "reader_account_linked", properties: {} });
381
418
  return result.kind === "signed-in" ? { ok: true, switch: false, cookies: result.cookies, token: result.token, expiresAt: result.expiresAt, user: result.user, location: result.location } : { ok: true, switch: true, cookies: result.cookies, location: result.location };
382
419
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/news-publication",
3
- "version": "0.0.86",
3
+ "version": "0.0.88",
4
4
  "description": "Shared server framework for ScaleMule local news publications. Walnut Creek Times is the broadsheet default; each paper supplies its own identity and feature flags.",
5
5
  "type": "module",
6
6
  "bin": {