@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.
- package/lib/admin-news.js +54 -21
- 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(
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 (
|
|
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 =
|
|
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
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
184
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
236
|
+
throw validationError(
|
|
237
|
+
`${coveredKey} is cited or covered more than once.`,
|
|
238
|
+
);
|
|
206
239
|
}
|
|
207
240
|
usedKeys.add(coveredKey);
|
|
208
241
|
}
|