@stage5/lumine 0.2.42 → 0.2.43

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.
Files changed (2) hide show
  1. package/lib/admin-news.js +54 -21
  2. package/package.json +1 -1
package/lib/admin-news.js CHANGED
@@ -3,8 +3,10 @@ import {
3
3
  mkdirSync,
4
4
  readFileSync,
5
5
  renameSync,
6
+ unlinkSync,
6
7
  writeFileSync,
7
8
  } from "node:fs";
9
+ import { randomUUID } from "node:crypto";
8
10
  import path from "node:path";
9
11
 
10
12
  const MAX_ADMIN_JSON_BYTES = 2 * 1024 * 1024;
@@ -17,9 +19,7 @@ function validationError(message) {
17
19
 
18
20
  function formatByteLimit(maxBytes) {
19
21
  const megabytes = maxBytes / (1024 * 1024);
20
- return Number.isInteger(megabytes)
21
- ? `${megabytes} MB`
22
- : `${maxBytes} bytes`;
22
+ return Number.isInteger(megabytes) ? `${megabytes} MB` : `${maxBytes} bytes`;
23
23
  }
24
24
 
25
25
  export function readAdminJsonFile(
@@ -36,7 +36,9 @@ export function readAdminJsonFile(
36
36
  throw validationError(`Could not read ${normalizedPath}.`);
37
37
  }
38
38
  if (Buffer.byteLength(contents, "utf8") > maxBytes) {
39
- throw validationError(`${label} must be under ${formatByteLimit(maxBytes)}.`);
39
+ throw validationError(
40
+ `${label} must be under ${formatByteLimit(maxBytes)}.`,
41
+ );
40
42
  }
41
43
  try {
42
44
  return JSON.parse(contents);
@@ -55,7 +57,10 @@ export function writeAdminJsonFile(
55
57
  throw validationError("An output file path is required.");
56
58
  }
57
59
  mkdirSync(path.dirname(resolved), { recursive: true });
58
- const temporary = `${resolved}.tmp-${process.pid}`;
60
+ // Checkpoints can contain private management evidence and often live in a
61
+ // shared temporary directory. Use an unguessable, exclusive staging file so
62
+ // another local account cannot pre-place a symlink at the temporary path.
63
+ const temporary = `${resolved}.tmp-${process.pid}-${randomUUID()}`;
59
64
  const contents = `${JSON.stringify(value, null, 2)}\n`;
60
65
  if (
61
66
  Number.isSafeInteger(maxBytes) &&
@@ -66,11 +71,21 @@ export function writeAdminJsonFile(
66
71
  `The output exceeds the ${formatByteLimit(maxBytes)} safety limit. Narrow the request before retrying.`,
67
72
  );
68
73
  }
69
- writeFileSync(temporary, contents, {
70
- encoding: "utf8",
71
- mode: privateFile ? 0o600 : 0o644,
72
- });
73
- renameSync(temporary, resolved);
74
+ try {
75
+ writeFileSync(temporary, contents, {
76
+ encoding: "utf8",
77
+ flag: "wx",
78
+ mode: privateFile ? 0o600 : 0o644,
79
+ });
80
+ renameSync(temporary, resolved);
81
+ } catch (error) {
82
+ try {
83
+ unlinkSync(temporary);
84
+ } catch {
85
+ // The exclusive create or successful rename may leave nothing to clean.
86
+ }
87
+ throw error;
88
+ }
74
89
  if (privateFile) chmodSync(resolved, 0o600);
75
90
  return resolved;
76
91
  }
@@ -83,7 +98,12 @@ export function extractNewsClaim(value) {
83
98
  const editionId = Number(claim.editionId || 0);
84
99
  const leaseToken = String(claim.leaseToken || "").trim();
85
100
  const events = Array.isArray(claim.events) ? claim.events : null;
86
- if (!Number.isSafeInteger(editionId) || editionId <= 0 || !leaseToken || !events) {
101
+ if (
102
+ !Number.isSafeInteger(editionId) ||
103
+ editionId <= 0 ||
104
+ !leaseToken ||
105
+ !events
106
+ ) {
87
107
  throw validationError(
88
108
  "The claim file is missing editionId, leaseToken, or canonical events.",
89
109
  );
@@ -114,7 +134,8 @@ export function createNewsEditorialScaffold(claimValue) {
114
134
  const frontIndex = claim.events.findIndex(
115
135
  (event) => String(event?.section || "") === "front",
116
136
  );
117
- const lead = frontIndex >= 0 ? scaffoldStory(claim.events[frontIndex], maximum) : null;
137
+ const lead =
138
+ frontIndex >= 0 ? scaffoldStory(claim.events[frontIndex], maximum) : null;
118
139
  const stories = claim.events
119
140
  .filter((_event, index) => index !== frontIndex)
120
141
  .map((event) => scaffoldStory(event, maximum));
@@ -150,7 +171,9 @@ export function validateNewsEditorial({ claim: claimValue, editorial }) {
150
171
  const usedKeys = new Set();
151
172
  const maximum = Math.max(0, Number(claim.maxSourceQuoteLength || 360));
152
173
  const entries = [
153
- ...(editorial.lead ? [{ label: "lead", story: editorial.lead, lead: true }] : []),
174
+ ...(editorial.lead
175
+ ? [{ label: "lead", story: editorial.lead, lead: true }]
176
+ : []),
154
177
  ...editorial.stories.map((story, index) => ({
155
178
  label: `stories[${index}]`,
156
179
  story,
@@ -164,7 +187,8 @@ export function validateNewsEditorial({ claim: claimValue, editorial }) {
164
187
  }
165
188
  const eventKey = String(story.eventKey || "").trim();
166
189
  const event = eventByKey.get(eventKey);
167
- if (!event) throw validationError(`${entry.label}.eventKey is not in the claim.`);
190
+ if (!event)
191
+ throw validationError(`${entry.label}.eventKey is not in the claim.`);
168
192
  if (usedKeys.has(eventKey)) {
169
193
  throw validationError(`${eventKey} is cited or covered more than once.`);
170
194
  }
@@ -174,14 +198,15 @@ export function validateNewsEditorial({ claim: claimValue, editorial }) {
174
198
  }
175
199
  requireEditorialText(story.headline, `${entry.label}.headline`);
176
200
  requireEditorialText(story.summary, `${entry.label}.summary`);
177
- const quote = typeof story.sourceQuote === "string" ? story.sourceQuote : "";
201
+ const quote =
202
+ typeof story.sourceQuote === "string" ? story.sourceQuote : "";
178
203
  if (String(event.section || "") === "front") {
179
204
  const canonicalSummary = String(event.summary || "");
180
205
  const quoteIsValid = canonicalSummary
181
206
  ? Boolean(
182
207
  quote &&
183
- quote.length <= maximum &&
184
- canonicalSummary.includes(quote),
208
+ quote.length <= maximum &&
209
+ canonicalSummary.includes(quote),
185
210
  )
186
211
  : quote === "";
187
212
  if (!quoteIsValid) {
@@ -190,19 +215,27 @@ export function validateNewsEditorial({ claim: claimValue, editorial }) {
190
215
  );
191
216
  }
192
217
  } else if (quote !== "") {
193
- throw validationError(`${entry.label}.sourceQuote must be empty outside the front section.`);
218
+ throw validationError(
219
+ `${entry.label}.sourceQuote must be empty outside the front section.`,
220
+ );
194
221
  }
195
222
  const covered = story.coveredEventKeys ?? [];
196
223
  if (!Array.isArray(covered)) {
197
- throw validationError(`${entry.label}.coveredEventKeys must be an array.`);
224
+ throw validationError(
225
+ `${entry.label}.coveredEventKeys must be an array.`,
226
+ );
198
227
  }
199
228
  for (const rawCoveredKey of covered) {
200
229
  const coveredKey = String(rawCoveredKey || "").trim();
201
230
  if (!eventByKey.has(coveredKey)) {
202
- throw validationError(`${entry.label} covers an eventKey not in the claim.`);
231
+ throw validationError(
232
+ `${entry.label} covers an eventKey not in the claim.`,
233
+ );
203
234
  }
204
235
  if (coveredKey === eventKey || usedKeys.has(coveredKey)) {
205
- throw validationError(`${coveredKey} is cited or covered more than once.`);
236
+ throw validationError(
237
+ `${coveredKey} is cited or covered more than once.`,
238
+ );
206
239
  }
207
240
  usedKeys.add(coveredKey);
208
241
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.42",
3
+ "version": "0.2.43",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {