@scalemule/news-publication 0.0.85 → 0.0.87

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.
@@ -358,6 +358,16 @@ var canonical2 = (v) => Array.isArray(v) ? v.map(canonical2) : v && typeof v ===
358
358
  Object.keys(v).sort().map((k) => [k, canonical2(v[k])])
359
359
  ) : v;
360
360
  var equal = (a, b) => JSON.stringify(canonical2(a)) === JSON.stringify(canonical2(b));
361
+ var uuidIdentity = (value) => {
362
+ if (typeof value !== "string" || !/^(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.test(value)) return value;
363
+ return value.replaceAll("-", "").toLowerCase();
364
+ };
365
+ var comparableDocument = (document) => document && typeof document === "object" ? { ...document, ...Array.isArray(document.media) ? { media: document.media.map((media) => ({
366
+ ...media,
367
+ contribution_id: uuidIdentity(media.contribution_id),
368
+ file_id: uuidIdentity(media.file_id)
369
+ })) } : {} } : document;
370
+ var documentsEqual = (a, b) => equal(comparableDocument(a), comparableDocument(b));
361
371
  var digest2 = async (v) => [
362
372
  ...new Uint8Array(
363
373
  await crypto.subtle.digest(
@@ -512,7 +522,7 @@ async function runArticleEdit(t, input) {
512
522
  state.revisionId
513
523
  );
514
524
  const selected = currentRevision(record);
515
- if (!selected || !equal(selected.document, state.document))
525
+ if (!selected || !documentsEqual(selected.document, state.document))
516
526
  throw new ArticleEditOperationError(
517
527
  "VERIFICATION_CONFLICT",
518
528
  stage,
@@ -580,7 +590,7 @@ async function runArticleEdit(t, input) {
580
590
  state.savedGeneration = saved.generation;
581
591
  await save("verify");
582
592
  record = await request(article + "/collaboration");
583
- if (record.current_revision_id !== state.revisionId || !equal(currentRevision(record)?.document, state.document))
593
+ if (record.current_revision_id !== state.revisionId || !documentsEqual(currentRevision(record)?.document, state.document))
584
594
  throw new ArticleEditOperationError(
585
595
  "ARTICLE_CHANGED",
586
596
  stage,
@@ -356,6 +356,16 @@ var canonical2 = (v) => Array.isArray(v) ? v.map(canonical2) : v && typeof v ===
356
356
  Object.keys(v).sort().map((k) => [k, canonical2(v[k])])
357
357
  ) : v;
358
358
  var equal = (a, b) => JSON.stringify(canonical2(a)) === JSON.stringify(canonical2(b));
359
+ var uuidIdentity = (value) => {
360
+ if (typeof value !== "string" || !/^(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.test(value)) return value;
361
+ return value.replaceAll("-", "").toLowerCase();
362
+ };
363
+ var comparableDocument = (document) => document && typeof document === "object" ? { ...document, ...Array.isArray(document.media) ? { media: document.media.map((media) => ({
364
+ ...media,
365
+ contribution_id: uuidIdentity(media.contribution_id),
366
+ file_id: uuidIdentity(media.file_id)
367
+ })) } : {} } : document;
368
+ var documentsEqual = (a, b) => equal(comparableDocument(a), comparableDocument(b));
359
369
  var digest2 = async (v) => [
360
370
  ...new Uint8Array(
361
371
  await crypto.subtle.digest(
@@ -510,7 +520,7 @@ async function runArticleEdit(t, input) {
510
520
  state.revisionId
511
521
  );
512
522
  const selected = currentRevision(record);
513
- if (!selected || !equal(selected.document, state.document))
523
+ if (!selected || !documentsEqual(selected.document, state.document))
514
524
  throw new ArticleEditOperationError(
515
525
  "VERIFICATION_CONFLICT",
516
526
  stage,
@@ -578,7 +588,7 @@ async function runArticleEdit(t, input) {
578
588
  state.savedGeneration = saved.generation;
579
589
  await save("verify");
580
590
  record = await request(article + "/collaboration");
581
- if (record.current_revision_id !== state.revisionId || !equal(currentRevision(record)?.document, state.document))
591
+ if (record.current_revision_id !== state.revisionId || !documentsEqual(currentRevision(record)?.document, state.document))
582
592
  throw new ArticleEditOperationError(
583
593
  "ARTICLE_CHANGED",
584
594
  stage,
@@ -376,7 +376,7 @@ function AccountLinkForm({
376
376
  /* @__PURE__ */ jsxRuntime.jsx("input", { type: "password", autoComplete: "current-password", required: true, minLength: 1, maxLength: 1024, value: password, onChange: (e) => setPassword(e.target.value) })
377
377
  ] }),
378
378
  mfaRequired && /* @__PURE__ */ jsxRuntime.jsxs("label", { children: [
379
- "Authentication code",
379
+ "Verification code",
380
380
  /* @__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
381
  ] }),
382
382
  /* @__PURE__ */ jsxRuntime.jsx("button", { className: classNames.button, disabled: busy || !password || mfaRequired && !code, children: busy ? "Connecting\u2026" : "Connect and sign in" }),
@@ -374,7 +374,7 @@ function AccountLinkForm({
374
374
  /* @__PURE__ */ jsx("input", { type: "password", autoComplete: "current-password", required: true, minLength: 1, maxLength: 1024, value: password, onChange: (e) => setPassword(e.target.value) })
375
375
  ] }),
376
376
  mfaRequired && /* @__PURE__ */ jsxs("label", { children: [
377
- "Authentication code",
377
+ "Verification code",
378
378
  /* @__PURE__ */ jsx("input", { type: "text", inputMode: "numeric", autoComplete: "one-time-code", required: true, maxLength: 32, value: code, onChange: (e) => setCode(e.target.value) })
379
379
  ] }),
380
380
  /* @__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.85",
3
+ "version": "0.0.87",
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": {