@scalemule/news-publication 0.0.72 → 0.0.74

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.
@@ -56,6 +56,17 @@ var PublicationArticleClient = class {
56
56
  `/article-workflow/options${articleId ? `?article_id=${encodeURIComponent(articleId)}` : ""}`
57
57
  );
58
58
  }
59
+ /** Explicit approver-authorized onboarding for a legacy article; never called by get().
60
+ * Existing schedules are cancelled by the server; public content remains unchanged.
61
+ * Missing immutable media identities must be resolved through newsroom media controls.
62
+ */
63
+ enableCollaboration(articleId, reviewPolicy) {
64
+ return this.request(
65
+ `/articles/${encodeURIComponent(articleId)}/collaboration/enable`,
66
+ "POST",
67
+ { review_policy: reviewPolicy }
68
+ );
69
+ }
59
70
  get(articleId) {
60
71
  return this.request(
61
72
  `/articles/${encodeURIComponent(articleId)}/collaboration`
@@ -132,6 +132,15 @@ declare class PublicationArticleClient {
132
132
  });
133
133
  private request;
134
134
  options(articleId?: string): Promise<ArticleWorkflowOptions>;
135
+ /** Explicit approver-authorized onboarding for a legacy article; never called by get().
136
+ * Existing schedules are cancelled by the server; public content remains unchanged.
137
+ * Missing immutable media identities must be resolved through newsroom media controls.
138
+ */
139
+ enableCollaboration(articleId: string, reviewPolicy: "NONE" | "FACT_CHECK" | "CONTRIBUTOR_REVIEW" | "SUBJECT_APPROVAL_REQUIRED" | "SUBJECT_APPROVAL_PLUS_EDITOR"): Promise<{
140
+ post_id: string;
141
+ existing?: boolean;
142
+ current_revision_id?: string;
143
+ }>;
135
144
  get(articleId: string): Promise<Record>;
136
145
  processing(articleId: string): Promise<ArticleProcessing>;
137
146
  /** Existing private narration path for a saved revision and a before-publication audio policy. */
@@ -132,6 +132,15 @@ declare class PublicationArticleClient {
132
132
  });
133
133
  private request;
134
134
  options(articleId?: string): Promise<ArticleWorkflowOptions>;
135
+ /** Explicit approver-authorized onboarding for a legacy article; never called by get().
136
+ * Existing schedules are cancelled by the server; public content remains unchanged.
137
+ * Missing immutable media identities must be resolved through newsroom media controls.
138
+ */
139
+ enableCollaboration(articleId: string, reviewPolicy: "NONE" | "FACT_CHECK" | "CONTRIBUTOR_REVIEW" | "SUBJECT_APPROVAL_REQUIRED" | "SUBJECT_APPROVAL_PLUS_EDITOR"): Promise<{
140
+ post_id: string;
141
+ existing?: boolean;
142
+ current_revision_id?: string;
143
+ }>;
135
144
  get(articleId: string): Promise<Record>;
136
145
  processing(articleId: string): Promise<ArticleProcessing>;
137
146
  /** Existing private narration path for a saved revision and a before-publication audio policy. */
@@ -54,6 +54,17 @@ var PublicationArticleClient = class {
54
54
  `/article-workflow/options${articleId ? `?article_id=${encodeURIComponent(articleId)}` : ""}`
55
55
  );
56
56
  }
57
+ /** Explicit approver-authorized onboarding for a legacy article; never called by get().
58
+ * Existing schedules are cancelled by the server; public content remains unchanged.
59
+ * Missing immutable media identities must be resolved through newsroom media controls.
60
+ */
61
+ enableCollaboration(articleId, reviewPolicy) {
62
+ return this.request(
63
+ `/articles/${encodeURIComponent(articleId)}/collaboration/enable`,
64
+ "POST",
65
+ { review_policy: reviewPolicy }
66
+ );
67
+ }
57
68
  get(articleId) {
58
69
  return this.request(
59
70
  `/articles/${encodeURIComponent(articleId)}/collaboration`
@@ -86,7 +86,7 @@ function ReaderSsoBoot({ enabled = true, hasSession: hasSessionCookie, suppresse
86
86
  if (!enabled || !hasSessionCookie || staleCookie) return;
87
87
  let cancelled = false;
88
88
  fetch(sessionPath, { credentials: "same-origin", cache: "no-store" }).then((response) => response.json()).then((body) => {
89
- if (!cancelled && body?.authenticated !== true) setStaleCookie(true);
89
+ if (!cancelled && (body?.authenticated !== true || body.session_upgrade_required === true)) setStaleCookie(true);
90
90
  }).catch(() => {
91
91
  });
92
92
  return () => {
@@ -84,7 +84,7 @@ function ReaderSsoBoot({ enabled = true, hasSession: hasSessionCookie, suppresse
84
84
  if (!enabled || !hasSessionCookie || staleCookie) return;
85
85
  let cancelled = false;
86
86
  fetch(sessionPath, { credentials: "same-origin", cache: "no-store" }).then((response) => response.json()).then((body) => {
87
- if (!cancelled && body?.authenticated !== true) setStaleCookie(true);
87
+ if (!cancelled && (body?.authenticated !== true || body.session_upgrade_required === true)) setStaleCookie(true);
88
88
  }).catch(() => {
89
89
  });
90
90
  return () => {
@@ -39,6 +39,8 @@ function readerSsoCookieNames(prefix) {
39
39
  }
40
40
  var readerSsoCookieOptions = { httpOnly: true, secure: true, sameSite: "lax", path: "/" };
41
41
  var clear = (name) => ({ name, value: "", maxAge: 0 });
42
+ var keepLocalValue = (token) => "keep:" + crypto.createHash("sha256").update(token).digest("hex");
43
+ var keepsLocal = (checked, token) => Boolean(token && checked === keepLocalValue(token));
42
44
  function applyCookies(jar, cookies) {
43
45
  for (const cookie of cookies) {
44
46
  jar.set(cookie.name, cookie.value, {
@@ -195,13 +197,13 @@ async function startSignIn(config, input) {
195
197
  if (input.cookies(c.names.session)) {
196
198
  const current = await currentUser(c, input.cookies(c.names.session));
197
199
  if (current && current.email_verified) {
198
- if (mode === "silent") return { ok: false, status: 204, error: "", reason: "session", redirect, cookies: [] };
200
+ if (mode === "silent" && (!current.needsSsoUpgrade || keepsLocal(input.cookies(c.names.ssoChecked), input.cookies(c.names.session)))) return { ok: false, status: 204, error: "", reason: "session", redirect, cookies: [] };
199
201
  } else {
200
202
  staleSession.push(clear(c.names.session), clear(c.names.pending));
201
203
  }
202
204
  }
203
205
  if (mode === "silent") {
204
- if (input.cookies(c.names.signedOut) || input.cookies(c.names.ssoChecked)) {
206
+ if (input.cookies(c.names.signedOut) || input.cookies(c.names.ssoChecked) === "1") {
205
207
  return { ok: false, status: 204, error: "", reason: "suppressed", redirect, cookies: staleSession };
206
208
  }
207
209
  }
@@ -244,9 +246,10 @@ async function currentUser(c, token) {
244
246
  return {
245
247
  id: me.id,
246
248
  email_verified: me.email_verified === true,
247
- full_name: typeof me.full_name === "string" ? me.full_name.trim().slice(0, 150) : "",
249
+ full_name: typeof me.full_name === "string" ? Array.from(me.full_name.trim()).slice(0, 150).join("") : "",
248
250
  locale: typeof me.locale === "string" && me.locale.length <= 35 ? me.locale : void 0,
249
- time_zone: typeof me.time_zone === "string" && me.time_zone.length <= 64 ? me.time_zone : void 0
251
+ time_zone: typeof me.time_zone === "string" && me.time_zone.length <= 64 ? me.time_zone : void 0,
252
+ ...me.sso_session_bound === false ? { needsSsoUpgrade: true } : {}
250
253
  };
251
254
  }
252
255
  function issuedSession(response, now) {
@@ -326,7 +329,7 @@ async function completeSignIn(config, input) {
326
329
  async function establish(c, args) {
327
330
  const { session, flow, method, cookies } = args;
328
331
  const user = await currentUser(c, session.token);
329
- if (!user || !user.email_verified) throw new Error("unverified");
332
+ if (!user || !user.email_verified || user.needsSsoUpgrade) throw new Error("unverified");
330
333
  const current = await currentUser(c, args.input.cookies(c.names.session));
331
334
  const decision = precedence(current, user);
332
335
  if (decision === "switch") {
@@ -337,7 +340,12 @@ async function establish(c, args) {
337
340
  });
338
341
  if (flow.mode === "silent") cookies.push(args.negativeCache);
339
342
  emit(c, { name: "reader_sso_check", properties: { outcome: "switch_offered", mode: flow.mode, transaction: flow.tx } });
340
- return { kind: "redirect", location: `${c.accountPath}?switch=1`, cookies, outcome: "switch_offered", flow };
343
+ return { kind: "redirect", location: flow.mode === "silent" ? flow.returnTo || "/" : `${c.accountPath}?switch=1`, cookies, outcome: "switch_offered", flow };
344
+ }
345
+ const previousToken = args.input.cookies(c.names.session);
346
+ if (current && previousToken && previousToken !== session.token) {
347
+ const retired = await c.request("/v1/auth/logout", { method: "POST", body: { session_token: previousToken }, token: previousToken });
348
+ if (failed(retired) || retired.body?.success !== true) throw new Error("session retirement failed");
341
349
  }
342
350
  cookies.push({ name: c.names.session, value: session.token, expires: session.expiresAt }, clear(c.names.signedOut), clear(c.names.ssoChecked), clear(c.names.pending));
343
351
  emit(c, { name: "reader_signin_completed", properties: { method, mode: flow.mode, is_new: session.isNew ? "true" : "false", transaction: flow.tx } });
@@ -411,6 +419,11 @@ async function switchAccount(config, input) {
411
419
  }
412
420
  };
413
421
  if (input.choice === "stay") {
422
+ const token = input.cookies(c.names.session);
423
+ const current = await currentUser(c, token);
424
+ if (token && current?.email_verified && current.needsSsoUpgrade) {
425
+ cookies.push({ name: c.names.ssoChecked, value: keepLocalValue(token), maxAge: 90 * 86400 });
426
+ }
414
427
  await logout(pending.token);
415
428
  emit(c, { name: "reader_sso_check", properties: { outcome: "switch_declined", mode: pending.mode, transaction: pending.tx } });
416
429
  return { ok: true, choice: "stay", cookies, location: pending.returnTo || c.accountPath };
@@ -457,7 +470,7 @@ async function sessionSummary(config, cookies) {
457
470
  const c = resolve(config);
458
471
  const user = await currentUser(c, cookies(c.names.session));
459
472
  if (!user || !user.email_verified) return { authenticated: false };
460
- return { authenticated: true, full_name: user.full_name, locale: user.locale, time_zone: user.time_zone };
473
+ return { authenticated: true, full_name: user.full_name, locale: user.locale, time_zone: user.time_zone, ...user.needsSsoUpgrade && !keepsLocal(cookies(c.names.ssoChecked), cookies(c.names.session)) ? { session_upgrade_required: true } : {} };
461
474
  }
462
475
  async function sessionSummaryWithCookies(config, cookies) {
463
476
  const c = resolve(config);
@@ -468,7 +481,7 @@ async function sessionSummaryWithCookies(config, cookies) {
468
481
  function readerSsoBootFlags(cookies, names) {
469
482
  return {
470
483
  hasSession: Boolean(cookies(names.session)),
471
- suppressed: Boolean(cookies(names.signedOut) || cookies(names.ssoChecked) || cookies(names.pending))
484
+ suppressed: Boolean(cookies(names.signedOut) || cookies(names.ssoChecked) === "1" || cookies(names.pending))
472
485
  };
473
486
  }
474
487
 
@@ -192,6 +192,7 @@ type SessionUser = {
192
192
  full_name: string;
193
193
  locale?: string;
194
194
  time_zone?: string;
195
+ needsSsoUpgrade?: true;
195
196
  };
196
197
  /** ADR 3.2: an active session for Bob is never replaced by Alice without an explicit choice. */
197
198
  declare function precedence(current: SessionUser | null, incoming: SessionUser): "replace" | "renew" | "switch";
@@ -298,6 +299,7 @@ type SessionSummary = {
298
299
  full_name: string;
299
300
  locale?: string;
300
301
  time_zone?: string;
302
+ session_upgrade_required?: true;
301
303
  };
302
304
  declare function sessionSummary(config: ReaderSsoConfig, cookies: CookieReader): Promise<SessionSummary>;
303
305
  /**
@@ -192,6 +192,7 @@ type SessionUser = {
192
192
  full_name: string;
193
193
  locale?: string;
194
194
  time_zone?: string;
195
+ needsSsoUpgrade?: true;
195
196
  };
196
197
  /** ADR 3.2: an active session for Bob is never replaced by Alice without an explicit choice. */
197
198
  declare function precedence(current: SessionUser | null, incoming: SessionUser): "replace" | "renew" | "switch";
@@ -298,6 +299,7 @@ type SessionSummary = {
298
299
  full_name: string;
299
300
  locale?: string;
300
301
  time_zone?: string;
302
+ session_upgrade_required?: true;
301
303
  };
302
304
  declare function sessionSummary(config: ReaderSsoConfig, cookies: CookieReader): Promise<SessionSummary>;
303
305
  /**
@@ -24,6 +24,8 @@ function readerSsoCookieNames(prefix) {
24
24
  }
25
25
  var readerSsoCookieOptions = { httpOnly: true, secure: true, sameSite: "lax", path: "/" };
26
26
  var clear = (name) => ({ name, value: "", maxAge: 0 });
27
+ var keepLocalValue = (token) => "keep:" + createHash("sha256").update(token).digest("hex");
28
+ var keepsLocal = (checked, token) => Boolean(token && checked === keepLocalValue(token));
27
29
  function applyCookies(jar, cookies) {
28
30
  for (const cookie of cookies) {
29
31
  jar.set(cookie.name, cookie.value, {
@@ -180,13 +182,13 @@ async function startSignIn(config, input) {
180
182
  if (input.cookies(c.names.session)) {
181
183
  const current = await currentUser(c, input.cookies(c.names.session));
182
184
  if (current && current.email_verified) {
183
- if (mode === "silent") return { ok: false, status: 204, error: "", reason: "session", redirect, cookies: [] };
185
+ if (mode === "silent" && (!current.needsSsoUpgrade || keepsLocal(input.cookies(c.names.ssoChecked), input.cookies(c.names.session)))) return { ok: false, status: 204, error: "", reason: "session", redirect, cookies: [] };
184
186
  } else {
185
187
  staleSession.push(clear(c.names.session), clear(c.names.pending));
186
188
  }
187
189
  }
188
190
  if (mode === "silent") {
189
- if (input.cookies(c.names.signedOut) || input.cookies(c.names.ssoChecked)) {
191
+ if (input.cookies(c.names.signedOut) || input.cookies(c.names.ssoChecked) === "1") {
190
192
  return { ok: false, status: 204, error: "", reason: "suppressed", redirect, cookies: staleSession };
191
193
  }
192
194
  }
@@ -229,9 +231,10 @@ async function currentUser(c, token) {
229
231
  return {
230
232
  id: me.id,
231
233
  email_verified: me.email_verified === true,
232
- full_name: typeof me.full_name === "string" ? me.full_name.trim().slice(0, 150) : "",
234
+ full_name: typeof me.full_name === "string" ? Array.from(me.full_name.trim()).slice(0, 150).join("") : "",
233
235
  locale: typeof me.locale === "string" && me.locale.length <= 35 ? me.locale : void 0,
234
- time_zone: typeof me.time_zone === "string" && me.time_zone.length <= 64 ? me.time_zone : void 0
236
+ time_zone: typeof me.time_zone === "string" && me.time_zone.length <= 64 ? me.time_zone : void 0,
237
+ ...me.sso_session_bound === false ? { needsSsoUpgrade: true } : {}
235
238
  };
236
239
  }
237
240
  function issuedSession(response, now) {
@@ -311,7 +314,7 @@ async function completeSignIn(config, input) {
311
314
  async function establish(c, args) {
312
315
  const { session, flow, method, cookies } = args;
313
316
  const user = await currentUser(c, session.token);
314
- if (!user || !user.email_verified) throw new Error("unverified");
317
+ if (!user || !user.email_verified || user.needsSsoUpgrade) throw new Error("unverified");
315
318
  const current = await currentUser(c, args.input.cookies(c.names.session));
316
319
  const decision = precedence(current, user);
317
320
  if (decision === "switch") {
@@ -322,7 +325,12 @@ async function establish(c, args) {
322
325
  });
323
326
  if (flow.mode === "silent") cookies.push(args.negativeCache);
324
327
  emit(c, { name: "reader_sso_check", properties: { outcome: "switch_offered", mode: flow.mode, transaction: flow.tx } });
325
- return { kind: "redirect", location: `${c.accountPath}?switch=1`, cookies, outcome: "switch_offered", flow };
328
+ return { kind: "redirect", location: flow.mode === "silent" ? flow.returnTo || "/" : `${c.accountPath}?switch=1`, cookies, outcome: "switch_offered", flow };
329
+ }
330
+ const previousToken = args.input.cookies(c.names.session);
331
+ if (current && previousToken && previousToken !== session.token) {
332
+ const retired = await c.request("/v1/auth/logout", { method: "POST", body: { session_token: previousToken }, token: previousToken });
333
+ if (failed(retired) || retired.body?.success !== true) throw new Error("session retirement failed");
326
334
  }
327
335
  cookies.push({ name: c.names.session, value: session.token, expires: session.expiresAt }, clear(c.names.signedOut), clear(c.names.ssoChecked), clear(c.names.pending));
328
336
  emit(c, { name: "reader_signin_completed", properties: { method, mode: flow.mode, is_new: session.isNew ? "true" : "false", transaction: flow.tx } });
@@ -396,6 +404,11 @@ async function switchAccount(config, input) {
396
404
  }
397
405
  };
398
406
  if (input.choice === "stay") {
407
+ const token = input.cookies(c.names.session);
408
+ const current = await currentUser(c, token);
409
+ if (token && current?.email_verified && current.needsSsoUpgrade) {
410
+ cookies.push({ name: c.names.ssoChecked, value: keepLocalValue(token), maxAge: 90 * 86400 });
411
+ }
399
412
  await logout(pending.token);
400
413
  emit(c, { name: "reader_sso_check", properties: { outcome: "switch_declined", mode: pending.mode, transaction: pending.tx } });
401
414
  return { ok: true, choice: "stay", cookies, location: pending.returnTo || c.accountPath };
@@ -442,7 +455,7 @@ async function sessionSummary(config, cookies) {
442
455
  const c = resolve(config);
443
456
  const user = await currentUser(c, cookies(c.names.session));
444
457
  if (!user || !user.email_verified) return { authenticated: false };
445
- return { authenticated: true, full_name: user.full_name, locale: user.locale, time_zone: user.time_zone };
458
+ return { authenticated: true, full_name: user.full_name, locale: user.locale, time_zone: user.time_zone, ...user.needsSsoUpgrade && !keepsLocal(cookies(c.names.ssoChecked), cookies(c.names.session)) ? { session_upgrade_required: true } : {} };
446
459
  }
447
460
  async function sessionSummaryWithCookies(config, cookies) {
448
461
  const c = resolve(config);
@@ -453,7 +466,7 @@ async function sessionSummaryWithCookies(config, cookies) {
453
466
  function readerSsoBootFlags(cookies, names) {
454
467
  return {
455
468
  hasSession: Boolean(cookies(names.session)),
456
- suppressed: Boolean(cookies(names.signedOut) || cookies(names.ssoChecked) || cookies(names.pending))
469
+ suppressed: Boolean(cookies(names.signedOut) || cookies(names.ssoChecked) === "1" || cookies(names.pending))
457
470
  };
458
471
  }
459
472
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/news-publication",
3
- "version": "0.0.72",
3
+ "version": "0.0.74",
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
  "main": "./dist/index.cjs",