@bongos/core 1.19.250 → 1.19.251
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/.bongos-core.json +69 -14
- package/docs/copy-inventory.md +1430 -0
- package/docs/copy-registry.json +13293 -0
- package/modules/copy-desk/CLAUDE.md +15 -0
- package/modules/copy-desk/flags.js +245 -0
- package/modules/copy-desk/migrations/copy_desk_001_flags.sql +128 -0
- package/modules/copy-desk/module.json +11 -0
- package/modules/copy-desk/registry.js +317 -0
- package/modules/copy-desk/routes/copy-desk.js +272 -0
- package/modules/copy-desk/tests/copy_flags.mjs +246 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +104 -0
- package/modules/copy-desk/tests/copy_queue.mjs +257 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-manifest.js +9 -0
- package/src/module-api.js +3 -1
- package/tests/publish_manifest.mjs +82 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// modules/copy-desk/registry.js — the READ side of R01's committed copy registry
|
|
2
|
+
// (task 1003113 / R02 of goal 1000074).
|
|
3
|
+
//
|
|
4
|
+
// R01 (scripts/gds/copy-inventory.js) walks the tree and writes
|
|
5
|
+
// docs/copy-registry.json: every user-facing string, with a stable id, the file
|
|
6
|
+
// and line it lives at, and the render path that puts it in front of a person.
|
|
7
|
+
// This file READS that artifact and never writes it. The generator stays a
|
|
8
|
+
// commit-it command in the gen-* family (deterministic, fs-only); the server
|
|
9
|
+
// must not regenerate on request — that would make a GET depend on a tree walk
|
|
10
|
+
// and let two replicas disagree about what the product says.
|
|
11
|
+
//
|
|
12
|
+
// CONSEQUENCE, AND IT IS THE HONEST ONE: the queue describes the registry AS
|
|
13
|
+
// COMMITTED — the state of the deployed tree, not of anyone's working copy. The
|
|
14
|
+
// failure mode is a queue that quietly describes a month-old tree, so the
|
|
15
|
+
// payload names what it is reading (`registry.at`) and hands over the command
|
|
16
|
+
// that proves freshness (`--check`). See composeQueue's note on why there is no
|
|
17
|
+
// `generated_at` field to report.
|
|
18
|
+
//
|
|
19
|
+
// A MISSING REGISTRY IS A NAMED STATE, NOT A CRASH. A packaged core install may
|
|
20
|
+
// not carry docs/, and a fresh instance has not run the generator yet. Both read
|
|
21
|
+
// as `{ ok: false, code: 'registry_missing' }` with the command to fix it — never
|
|
22
|
+
// an empty registry (which would read as "this product says nothing to anyone")
|
|
23
|
+
// and never a 500.
|
|
24
|
+
//
|
|
25
|
+
// Everything below the loader is PURE — given a parsed registry and a list of
|
|
26
|
+
// flag rows it computes the queue with no I/O — so the composition rules are
|
|
27
|
+
// unit-testable without a DB or a tree (modules/copy-desk/tests/copy_queue.mjs).
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
|
|
34
|
+
// The committed artifact, addressed the way R01 names it (REGISTRY_REL).
|
|
35
|
+
// Resolved from THIS file so it works from any cwd.
|
|
36
|
+
const REGISTRY_REL = 'docs/copy-registry.json';
|
|
37
|
+
const REGISTRY_ABS = path.resolve(__dirname, '..', '..', REGISTRY_REL);
|
|
38
|
+
// The command that produces it — quoted verbatim to the reader when it is
|
|
39
|
+
// missing or stale, so the fix never has to be looked up.
|
|
40
|
+
const GENERATOR_CMD = 'node scripts/gds/copy-inventory.js';
|
|
41
|
+
|
|
42
|
+
// mtime+size cache. The registry is a committed file that changes at deploy, not
|
|
43
|
+
// per request, so re-reading and re-parsing ~1300 rows on every queue load is
|
|
44
|
+
// pure waste; keying on (mtimeMs, size) means a deploy invalidates it without a
|
|
45
|
+
// restart and without a clock.
|
|
46
|
+
//
|
|
47
|
+
// THE READ IS SYNCHRONOUS, AND THAT IS THE CALL. statSync + readFileSync block
|
|
48
|
+
// the event loop, which is normally the wrong thing to do inside a request. Here
|
|
49
|
+
// the stat is ~microseconds and the parse (~400KB) runs only on a cache MISS —
|
|
50
|
+
// once per deploy, not once per request. Making it async would buy a few
|
|
51
|
+
// milliseconds on that one request and cost every caller an await plus a
|
|
52
|
+
// concurrent-miss stampede guard, for a path that by construction runs once.
|
|
53
|
+
// If the registry ever grows an order of magnitude, revisit this first.
|
|
54
|
+
let _cache = null;
|
|
55
|
+
|
|
56
|
+
function loadRegistry() {
|
|
57
|
+
let st;
|
|
58
|
+
try {
|
|
59
|
+
st = fs.statSync(REGISTRY_ABS);
|
|
60
|
+
} catch {
|
|
61
|
+
return { ok: false, code: 'registry_missing', detail: { expected_at: REGISTRY_REL, run: GENERATOR_CMD } };
|
|
62
|
+
}
|
|
63
|
+
const key = `${st.mtimeMs}:${st.size}`;
|
|
64
|
+
if (_cache && _cache.key === key) return _cache.value;
|
|
65
|
+
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(fs.readFileSync(REGISTRY_ABS, 'utf8'));
|
|
69
|
+
} catch (err) {
|
|
70
|
+
// A corrupt registry is NOT a missing one — the fix is different (the file
|
|
71
|
+
// is there and wrong), so it gets its own code rather than being folded in.
|
|
72
|
+
return { ok: false, code: 'registry_unreadable', detail: { expected_at: REGISTRY_REL, reason: err.message } };
|
|
73
|
+
}
|
|
74
|
+
const value = { ok: true, registry: parsed, index: indexRegistry(parsed) };
|
|
75
|
+
_cache = { key, value };
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Test seam: drop the cache so a test can point at different registry content
|
|
80
|
+
// in one process. Never called by the routes.
|
|
81
|
+
function _resetCache() { _cache = null; }
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// PURE below this line.
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
// Build the lookup structures the queue needs. The registry ships as ARRAYS
|
|
88
|
+
// (deterministic byte-for-byte output is the generator's contract, and a map
|
|
89
|
+
// would not order stably), so the index is built here rather than stored.
|
|
90
|
+
//
|
|
91
|
+
// ONE id can have MANY entries — that is exactly what a duplicate IS (the same
|
|
92
|
+
// text, on the same surface, in seven places). So byId maps id → entry[], and a
|
|
93
|
+
// caller that wants "the" occurrence takes the first. Mapping id → entry would
|
|
94
|
+
// silently drop six of the seven settings.js copies of "Could not save."
|
|
95
|
+
function indexRegistry(reg) {
|
|
96
|
+
const byId = new Map();
|
|
97
|
+
const entries = Array.isArray(reg && reg.entries) ? reg.entries : [];
|
|
98
|
+
for (const e of entries) {
|
|
99
|
+
if (!e || !e.id) continue;
|
|
100
|
+
const list = byId.get(e.id);
|
|
101
|
+
if (list) list.push(e); else byId.set(e.id, [e]);
|
|
102
|
+
}
|
|
103
|
+
const surfaces = new Set(Object.keys((reg && reg.surfaces) || {}));
|
|
104
|
+
return { byId, surfaces, schema: Number(reg && reg.schema) || null };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Does this surface exist in the registry? The route validates against this
|
|
108
|
+
// rather than a hardcoded list, so adding a surface to the generator is enough.
|
|
109
|
+
function hasSurface(index, surface) {
|
|
110
|
+
return index.surfaces.has(String(surface || ''));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The occurrences of one string id, or [] if the registry does not know it.
|
|
114
|
+
function occurrencesOf(index, stringId) {
|
|
115
|
+
return index.byId.get(String(stringId || '')) || [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The snapshot a NEW flag stores: what this string reads as, right now. Taken
|
|
119
|
+
// server-side from the registry rather than from the POST body, deliberately —
|
|
120
|
+
// a client-supplied snapshot is a client-supplied claim about what the product
|
|
121
|
+
// says, and the whole point of the snapshot is that it is trustworthy later.
|
|
122
|
+
function snapshotFor(index, stringId) {
|
|
123
|
+
const [first] = occurrencesOf(index, stringId);
|
|
124
|
+
if (!first) return null;
|
|
125
|
+
return {
|
|
126
|
+
text: first.text ?? null,
|
|
127
|
+
file: first.file ?? null,
|
|
128
|
+
line: Number.isInteger(first.line) ? first.line : null,
|
|
129
|
+
surface: first.surface ?? null,
|
|
130
|
+
schema: index.schema,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Search the inventory — the FIND half of filing a flag.
|
|
135
|
+
//
|
|
136
|
+
// Without this, the only strings a person could flag from a UI are the ones some
|
|
137
|
+
// other signal already surfaced (the duplicates), which would make the queue
|
|
138
|
+
// self-referential: it would only ever contain what a machine had already
|
|
139
|
+
// noticed. The judgement this loop exists to capture is precisely the kind no
|
|
140
|
+
// machine surfaces, so the person has to be able to go and find the sentence.
|
|
141
|
+
//
|
|
142
|
+
// Deliberately a plain case-insensitive substring over the text and the file
|
|
143
|
+
// path — the same rule the module catalog's browse uses. Nothing here ranks or
|
|
144
|
+
// scores: an artist is looking for a sentence they have already read somewhere,
|
|
145
|
+
// not discovering one.
|
|
146
|
+
//
|
|
147
|
+
// PURE. `openFlagIds` marks rows that are already in the queue so the UI can
|
|
148
|
+
// show it rather than letting someone file a duplicate flag and get a 409.
|
|
149
|
+
function searchEntries(reg, { q = '', surface = null, limit = 50 } = {}, openFlagIds = new Set()) {
|
|
150
|
+
const needle = String(q || '').trim().toLowerCase();
|
|
151
|
+
const entries = Array.isArray(reg && reg.entries) ? reg.entries : [];
|
|
152
|
+
// A non-positive or non-numeric limit is nonsense input, not a request for
|
|
153
|
+
// one row — fall back to the default rather than clamping to 1, which would
|
|
154
|
+
// answer `?limit=-5` with a single result and look like an empty product.
|
|
155
|
+
const asked = Number(limit);
|
|
156
|
+
const cap = Math.min(Number.isFinite(asked) && asked > 0 ? Math.floor(asked) : 50, 200);
|
|
157
|
+
|
|
158
|
+
const out = [];
|
|
159
|
+
let matched = 0;
|
|
160
|
+
for (const e of entries) {
|
|
161
|
+
if (surface && e.surface !== surface) continue;
|
|
162
|
+
if (needle && !(
|
|
163
|
+
String(e.text || '').toLowerCase().includes(needle)
|
|
164
|
+
|| String(e.file || '').toLowerCase().includes(needle)
|
|
165
|
+
)) continue;
|
|
166
|
+
matched++;
|
|
167
|
+
// Count every match but only carry `cap` of them, so the caller can be told
|
|
168
|
+
// how much it is NOT seeing. A silently truncated list reads as a complete
|
|
169
|
+
// one, and an artist would conclude a string simply is not in the product.
|
|
170
|
+
if (out.length < cap) {
|
|
171
|
+
out.push({ ...e, flagged: openFlagIds.has(e.id) });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { entries: out, matched, truncated: matched > out.length, limit: cap };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Attach live registry state to one stored flag row.
|
|
178
|
+
//
|
|
179
|
+
// The three states a string-scoped flag can be in, and why each is named:
|
|
180
|
+
// present — the registry still has this id; the flag points at live copy.
|
|
181
|
+
// gone — the id is absent. Either the string was EDITED (the id is a hash
|
|
182
|
+
// over its text, so an edit is a new id) or it was deleted. Both
|
|
183
|
+
// mean "the sentence this judgement was about no longer exists",
|
|
184
|
+
// which is a strong hint the flag is resolvable — but it is a HINT
|
|
185
|
+
// shown to a person, never an auto-resolve. Closing someone's
|
|
186
|
+
// judgement because a hash moved is exactly the kind of silent
|
|
187
|
+
// wrong this goal exists to stop.
|
|
188
|
+
// n/a — surface-scoped flags have no string to be present or gone.
|
|
189
|
+
function annotateFlag(index, flag) {
|
|
190
|
+
if (flag.scope !== 'string') {
|
|
191
|
+
return { ...flag, target_state: 'n/a', current: null, moved: false };
|
|
192
|
+
}
|
|
193
|
+
const occ = occurrencesOf(index, flag.string_id);
|
|
194
|
+
if (occ.length === 0) {
|
|
195
|
+
return { ...flag, target_state: 'gone', current: null, moved: false };
|
|
196
|
+
}
|
|
197
|
+
const first = occ[0];
|
|
198
|
+
// The file/line MOVED but the text did not (same id, different location) —
|
|
199
|
+
// worth showing so the artist opens the right file, not the one in the
|
|
200
|
+
// snapshot. Not a state of its own: the flag is still live.
|
|
201
|
+
const moved = Boolean(
|
|
202
|
+
flag.file_at_flag && (first.file !== flag.file_at_flag || first.line !== flag.line_at_flag)
|
|
203
|
+
);
|
|
204
|
+
return {
|
|
205
|
+
...flag,
|
|
206
|
+
target_state: 'present',
|
|
207
|
+
current: { text: first.text, file: first.file, line: first.line, origin: first.origin, occurrences: occ.length },
|
|
208
|
+
moved,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The DUPLICATE signal (the task's "ALSO": the queue must show copy that is
|
|
213
|
+
// stale, duplicated or off-voice, and duplication is the one of the three a
|
|
214
|
+
// machine can compute with no judgement layer at all).
|
|
215
|
+
//
|
|
216
|
+
// R01 already computes it — `registry.duplicates` is a deterministic list of
|
|
217
|
+
// ids appearing in more than one place — so this does NOT recompute it. What it
|
|
218
|
+
// adds is the only thing the raw list is missing for an artist: which of these
|
|
219
|
+
// have already been dealt with. A duplicate someone flagged, or looked at and
|
|
220
|
+
// dismissed, must not keep surfacing as untouched work; that is how a queue
|
|
221
|
+
// becomes noise and stops being read.
|
|
222
|
+
//
|
|
223
|
+
// `openFlagIds` / `closedFlagIds` are Sets of string_id.
|
|
224
|
+
function duplicateCandidates(reg, openFlagIds, closedFlagIds) {
|
|
225
|
+
const dups = Array.isArray(reg && reg.duplicates) ? reg.duplicates : [];
|
|
226
|
+
return dups.map((d) => ({
|
|
227
|
+
id: d.id,
|
|
228
|
+
surface: d.surface,
|
|
229
|
+
text: d.text,
|
|
230
|
+
occurrences: d.occurrences,
|
|
231
|
+
at: Array.isArray(d.at) ? d.at : [],
|
|
232
|
+
flagged: openFlagIds.has(d.id),
|
|
233
|
+
settled: !openFlagIds.has(d.id) && closedFlagIds.has(d.id),
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Compose the whole artist queue from a loaded registry + the flag rows.
|
|
238
|
+
//
|
|
239
|
+
// TWO SETS OF ROWS, AND THE DISTINCTION IS LOAD-BEARING:
|
|
240
|
+
// `flags` — what the caller asked to SEE (the status/surface filter).
|
|
241
|
+
// `allFlags` — every flag in the same surface scope, whatever its status.
|
|
242
|
+
//
|
|
243
|
+
// The COUNTS and the DUPLICATE annotations are computed from `allFlags`, never
|
|
244
|
+
// from the filtered slice. Otherwise the summary lies whenever a filter is on:
|
|
245
|
+
// switching the view to "resolved" would report "0 open flags" (there are none
|
|
246
|
+
// IN THIS LIST) and would re-surface every already-dismissed duplicate as
|
|
247
|
+
// untouched work. A stat panel that changes meaning when you change a filter is
|
|
248
|
+
// worse than no stat panel. `allFlags` defaults to `flags` for the unfiltered
|
|
249
|
+
// call, where the two are the same thing.
|
|
250
|
+
//
|
|
251
|
+
// `duplicates_untouched` is the number the surface leads with: duplicates nobody
|
|
252
|
+
// has flagged AND nobody has settled. Leading with the raw total would be a
|
|
253
|
+
// number that never moves however much work an artist does, which teaches them
|
|
254
|
+
// the queue is decorative.
|
|
255
|
+
function composeQueue({ registry, index, flags, allFlags }) {
|
|
256
|
+
const rows = Array.isArray(flags) ? flags : [];
|
|
257
|
+
const every = Array.isArray(allFlags) ? allFlags : rows;
|
|
258
|
+
|
|
259
|
+
// ONE pass over `every` for the id sets AND the counts. Four independent
|
|
260
|
+
// .filter() walks read more nicely but say the same predicate twice (once to
|
|
261
|
+
// build the set, once to count it), which is exactly how the two drift apart.
|
|
262
|
+
const openFlagIds = new Set();
|
|
263
|
+
const closedFlagIds = new Set();
|
|
264
|
+
let flagsOpen = 0;
|
|
265
|
+
let flagsTargetGone = 0;
|
|
266
|
+
for (const f of every) {
|
|
267
|
+
const isOpen = f.status === 'open';
|
|
268
|
+
if (isOpen) flagsOpen++;
|
|
269
|
+
if (f.string_id) (isOpen ? openFlagIds : closedFlagIds).add(f.string_id);
|
|
270
|
+
if (isOpen && f.scope === 'string' && occurrencesOf(index, f.string_id).length === 0) flagsTargetGone++;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const duplicates = duplicateCandidates(registry, openFlagIds, closedFlagIds);
|
|
274
|
+
return {
|
|
275
|
+
flags: rows.map((f) => annotateFlag(index, f)),
|
|
276
|
+
duplicates,
|
|
277
|
+
counts: {
|
|
278
|
+
// From `every`, not `rows` — see the header. flags_shown is the only count
|
|
279
|
+
// that describes the filtered list, and it is named so.
|
|
280
|
+
flags_open: flagsOpen,
|
|
281
|
+
flags_shown: rows.length,
|
|
282
|
+
// Flags whose string has vanished from the registry — surfaced as its own
|
|
283
|
+
// number because it is the queue's "probably already fixed, confirm it"
|
|
284
|
+
// pile, and it is the one an artist can clear fastest.
|
|
285
|
+
flags_target_gone: flagsTargetGone,
|
|
286
|
+
duplicates_total: duplicates.length,
|
|
287
|
+
duplicates_untouched: duplicates.filter((d) => !d.flagged && !d.settled).length,
|
|
288
|
+
},
|
|
289
|
+
// What this queue is describing (see the header). Always reported.
|
|
290
|
+
//
|
|
291
|
+
// NOTE ON FRESHNESS, stated rather than faked: there is deliberately no
|
|
292
|
+
// `generated_at` here, because the registry HAS no timestamp. R01 is in the
|
|
293
|
+
// gen-* family and its contract is determinism — same tree, byte-identical
|
|
294
|
+
// output — so a clock is exactly the field it cannot carry. The file's mtime
|
|
295
|
+
// is the checkout's, not the generation's, and reporting it as "generated"
|
|
296
|
+
// would be a plausible lie. The honest freshness answer is the generator's
|
|
297
|
+
// own `--check` (exit 1 if the artifacts are stale against the tree), so the
|
|
298
|
+
// command is handed to the reader instead of a number that looks like proof.
|
|
299
|
+
registry: {
|
|
300
|
+
schema: index.schema,
|
|
301
|
+
generator: (registry && registry.generator) || null,
|
|
302
|
+
goal: (registry && registry.goal) || null,
|
|
303
|
+
counts: (registry && registry.counts) || null,
|
|
304
|
+
surfaces: (registry && registry.surfaces) || null,
|
|
305
|
+
blind_spots: (registry && registry.blind_spots) || null,
|
|
306
|
+
at: REGISTRY_REL,
|
|
307
|
+
freshness_check: `${GENERATOR_CMD} --check`,
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = {
|
|
313
|
+
REGISTRY_REL, REGISTRY_ABS, GENERATOR_CMD,
|
|
314
|
+
loadRegistry, _resetCache,
|
|
315
|
+
indexRegistry, hasSurface, occurrencesOf, snapshotFor, searchEntries,
|
|
316
|
+
annotateFlag, duplicateCandidates, composeQueue,
|
|
317
|
+
};
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// modules/copy-desk/routes/copy.js — the copy-flag surface (task 1003113 / R02 of
|
|
2
|
+
// goal 1000074, the artist's loop).
|
|
3
|
+
//
|
|
4
|
+
// Three jobs:
|
|
5
|
+
// GET /copy-desk/queue — the artist's working list: open flags with
|
|
6
|
+
// live registry state attached, plus the
|
|
7
|
+
// deterministic duplicate candidates.
|
|
8
|
+
// POST /copy-desk/flags — file a flag. ANY signed-in builder, any role.
|
|
9
|
+
// POST /copy-desk/flags/:id/close — resolve or dismiss one, with a note.
|
|
10
|
+
//
|
|
11
|
+
// THE RANK SHAPE, AND WHY IT IS ASYMMETRIC:
|
|
12
|
+
//
|
|
13
|
+
// FILING is open to every rank, deliberately. "Sourced by everyone else" is the
|
|
14
|
+
// task's own framing: the engineer who just wrote a toast, the newcomer who
|
|
15
|
+
// could not understand a button, and the ideator reading the landing page are
|
|
16
|
+
// the three people most likely to notice bad copy, and none of them is the
|
|
17
|
+
// artist. A rank floor on filing would leave the queue empty and the goal
|
|
18
|
+
// unmet. The reason floor is the quality gate; rank is not.
|
|
19
|
+
//
|
|
20
|
+
// CLOSING is Metic+, OR the flag's own author. Closing is a triage act — it is
|
|
21
|
+
// the same shape as idea triage and blocker resolution, which are Metic+ — but
|
|
22
|
+
// an author withdrawing their own flag is not triage, it is a correction, and
|
|
23
|
+
// nobody should need a promotion to take back something they filed by mistake.
|
|
24
|
+
// The refusal names both paths rather than dead-ending on a bare 403.
|
|
25
|
+
//
|
|
26
|
+
// WHAT DOES NOT EXIST HERE, AND MUST NOT: any route that writes a user-facing
|
|
27
|
+
// string. NO LIVE CMS is the goal's hard non-goal (task 1003114 / R03 makes the
|
|
28
|
+
// editing affordance produce a TASK, not a write). A flag is a work request.
|
|
29
|
+
// modules/copy-desk/tests/copy_no_cms.mjs pins that by asserting this router's write
|
|
30
|
+
// surface stays exactly these two verbs.
|
|
31
|
+
//
|
|
32
|
+
// Same idioms as modules/security/routes/*.js: doorway-only requires, res.fail
|
|
33
|
+
// codes, factory export mounted from module.json contributes.
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const express = require('express');
|
|
38
|
+
const api = require('../../../src/module-api');
|
|
39
|
+
const flags = require('../flags');
|
|
40
|
+
const registry = require('../registry');
|
|
41
|
+
|
|
42
|
+
const { validateOrRespond } = api;
|
|
43
|
+
|
|
44
|
+
// The write schemas (ADR 0118): strict by default, so an unknown or typo'd field
|
|
45
|
+
// is REJECTED rather than silently dropped. Built once at module scope — the
|
|
46
|
+
// [...flags.KINDS] spreads mean a renamed export crashes at boot with a named
|
|
47
|
+
// stack instead of throwing per-request forever.
|
|
48
|
+
//
|
|
49
|
+
// These pin SHAPE only. The domain rules — the reason floor, the registry
|
|
50
|
+
// lookups, the scope/string_id coupling — live in flags.normalize*, so the DB
|
|
51
|
+
// constraint, the pure validator and this schema each say one thing once.
|
|
52
|
+
const FLAG_BODY_SCHEMA = Object.freeze({
|
|
53
|
+
scope: { type: 'string', enum: [...flags.SCOPES] },
|
|
54
|
+
surface: { required: true, type: 'string', maxLength: 128, minLength: 1 },
|
|
55
|
+
string_id: { type: 'string', maxLength: 64 },
|
|
56
|
+
reason: { required: true, type: 'string', maxLength: flags.MAX_REASON_CHARS, minLength: 1 },
|
|
57
|
+
kind: { type: 'string', enum: [...flags.KINDS] },
|
|
58
|
+
});
|
|
59
|
+
const CLOSE_BODY_SCHEMA = Object.freeze({
|
|
60
|
+
status: { required: true, type: 'string', enum: [...flags.CLOSING_STATUSES] },
|
|
61
|
+
resolution_note: { required: true, type: 'string', maxLength: flags.MAX_NOTE_CHARS, minLength: 1 },
|
|
62
|
+
resolved_task_id: { type: 'integer', min: 1 },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Load the registry or answer the named "no registry" state. Returns null when
|
|
66
|
+
// it has already responded, so every handler is `if (!reg) return;`.
|
|
67
|
+
//
|
|
68
|
+
// A missing registry is a 503, not a 404: the surface EXISTS and is temporarily
|
|
69
|
+
// unable to answer, and the payload carries the command that fixes it. A 404
|
|
70
|
+
// would tell an artist the feature is not there.
|
|
71
|
+
function registryOrFail(res) {
|
|
72
|
+
const loaded = registry.loadRegistry();
|
|
73
|
+
if (!loaded.ok) {
|
|
74
|
+
res.fail(loaded.code, 503, {
|
|
75
|
+
...loaded.detail,
|
|
76
|
+
what_this_means: 'The copy queue reads the committed registry generated by R01. Without it there is no inventory to flag against.',
|
|
77
|
+
});
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return loaded;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = function buildCopyDeskRouter() {
|
|
84
|
+
const router = express.Router();
|
|
85
|
+
|
|
86
|
+
// -------------------------------------------------------------------------
|
|
87
|
+
// GET /copy-desk/queue — the artist's working list.
|
|
88
|
+
//
|
|
89
|
+
// ?status=open (default) | resolved | dismissed | all
|
|
90
|
+
// ?surface=<registry surface id>
|
|
91
|
+
//
|
|
92
|
+
// rank: any signed-in builder. The queue is a work list, not a privilege —
|
|
93
|
+
// and an artist is a craft, not a rank (builders.preferred_disciplines), so
|
|
94
|
+
// gating this by rank would hide the artist's own queue from the artist.
|
|
95
|
+
// -------------------------------------------------------------------------
|
|
96
|
+
router.get('/copy-desk/queue', api.requireBuilder, async (req, res) => {
|
|
97
|
+
try {
|
|
98
|
+
const loaded = registryOrFail(res);
|
|
99
|
+
if (!loaded) return;
|
|
100
|
+
|
|
101
|
+
const status = String(req.query.status || 'open');
|
|
102
|
+
if (!['open', 'resolved', 'dismissed', 'all'].includes(status)) {
|
|
103
|
+
return res.fail('bad_status_filter', 400, { allowed: ['open', 'resolved', 'dismissed', 'all'] });
|
|
104
|
+
}
|
|
105
|
+
const surface = req.query.surface ? String(req.query.surface) : null;
|
|
106
|
+
if (surface && !registry.hasSurface(loaded.index, surface)) {
|
|
107
|
+
return res.fail('unknown_surface', 400, { surface, known: [...loaded.index.surfaces] });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// TWO reads, deliberately. The summary counts and the duplicate
|
|
111
|
+
// annotations must describe EVERY flag in scope, not the filtered slice —
|
|
112
|
+
// otherwise switching the view to "resolved" reports "0 open flags" and
|
|
113
|
+
// re-surfaces every already-dismissed duplicate as untouched work.
|
|
114
|
+
// composeQueue owns that rule; this just feeds it both sets. When the
|
|
115
|
+
// caller asked for everything the two are the same query, so it runs once.
|
|
116
|
+
const shown = await flags.listFlags({ status, surface });
|
|
117
|
+
const allFlags = status === 'all' ? shown : await flags.listFlags({ status: 'all', surface });
|
|
118
|
+
|
|
119
|
+
const queue = registry.composeQueue({
|
|
120
|
+
registry: loaded.registry,
|
|
121
|
+
index: loaded.index,
|
|
122
|
+
flags: shown,
|
|
123
|
+
allFlags,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
queue.filter = { status, surface };
|
|
127
|
+
res.set('Cache-Control', 'no-store');
|
|
128
|
+
res.json(queue);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error('[gds] GET /copy-desk/queue', err);
|
|
131
|
+
if (!res.headersSent) res.fail('copy_queue_failed', 500);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// -------------------------------------------------------------------------
|
|
136
|
+
// GET /copy-desk/strings — search the inventory, to find something to flag.
|
|
137
|
+
//
|
|
138
|
+
// ?q= substring over text or file path · ?surface= · ?limit= (max 200)
|
|
139
|
+
//
|
|
140
|
+
// Read-only over the committed registry — no DB, no tree walk. rank: any
|
|
141
|
+
// signed-in builder, same reasoning as the queue.
|
|
142
|
+
// -------------------------------------------------------------------------
|
|
143
|
+
router.get('/copy-desk/strings', api.requireBuilder, async (req, res) => {
|
|
144
|
+
try {
|
|
145
|
+
const loaded = registryOrFail(res);
|
|
146
|
+
if (!loaded) return;
|
|
147
|
+
|
|
148
|
+
const surface = req.query.surface ? String(req.query.surface) : null;
|
|
149
|
+
if (surface && !registry.hasSurface(loaded.index, surface)) {
|
|
150
|
+
return res.fail('unknown_surface', 400, { surface, known: [...loaded.index.surfaces] });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Mark what is already flagged so the UI can say so up front, instead of
|
|
154
|
+
// letting someone write a reason and then collect a 409.
|
|
155
|
+
const open = await flags.listFlags({ status: 'open', surface });
|
|
156
|
+
const openIds = new Set(open.filter((f) => f.string_id).map((f) => f.string_id));
|
|
157
|
+
|
|
158
|
+
const result = registry.searchEntries(
|
|
159
|
+
loaded.registry,
|
|
160
|
+
{ q: req.query.q, surface, limit: req.query.limit },
|
|
161
|
+
openIds
|
|
162
|
+
);
|
|
163
|
+
res.set('Cache-Control', 'no-store');
|
|
164
|
+
res.json(result);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
console.error('[gds] GET /copy-desk/strings', err);
|
|
167
|
+
if (!res.headersSent) res.fail('copy_search_failed', 500);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// -------------------------------------------------------------------------
|
|
172
|
+
// POST /copy-desk/flags — file a flag.
|
|
173
|
+
//
|
|
174
|
+
// Body: { scope, surface, string_id?, reason, kind? }
|
|
175
|
+
// rank: any signed-in builder (see the header).
|
|
176
|
+
// -------------------------------------------------------------------------
|
|
177
|
+
router.post('/copy-desk/flags', api.requireBuilder, async (req, res) => {
|
|
178
|
+
try {
|
|
179
|
+
if (validateOrRespond(req, res, FLAG_BODY_SCHEMA)) return;
|
|
180
|
+
|
|
181
|
+
const loaded = registryOrFail(res);
|
|
182
|
+
if (!loaded) return;
|
|
183
|
+
|
|
184
|
+
const norm = flags.normalizeFlagPayload(req.body, loaded.index, registry);
|
|
185
|
+
if (!norm.ok) return res.fail(norm.code, 400, norm.detail);
|
|
186
|
+
|
|
187
|
+
let flag;
|
|
188
|
+
try {
|
|
189
|
+
flag = await flags.insertFlag(norm.value, req.builder.id);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
// 23505 = the one-open-flag-per-(builder,target) partial unique index.
|
|
192
|
+
// A named 409 rather than a 500: this is almost always a double-submit
|
|
193
|
+
// or a forgotten flag, and the person deserves to be told which.
|
|
194
|
+
if (err && err.code === '23505') {
|
|
195
|
+
return res.fail('already_flagged_by_you', 409, {
|
|
196
|
+
what_this_means: 'You already have an OPEN flag on this target. Another builder flagging it too is fine and is kept as signal; the same person twice is not.',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
res.status(201).json({ ok: true, flag });
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error('[gds] POST /copy-desk/flags', err);
|
|
204
|
+
if (!res.headersSent) res.fail('copy_flag_failed', 500);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// -------------------------------------------------------------------------
|
|
209
|
+
// POST /copy-desk/flags/:id/close — resolve or dismiss.
|
|
210
|
+
//
|
|
211
|
+
// Body: { status: 'resolved'|'dismissed', resolution_note, resolved_task_id? }
|
|
212
|
+
// rank: metic+archon, OR the flag's author (see the header).
|
|
213
|
+
// -------------------------------------------------------------------------
|
|
214
|
+
router.post('/copy-desk/flags/:id/close', api.requireBuilder, async (req, res) => {
|
|
215
|
+
try {
|
|
216
|
+
if (validateOrRespond(req, res, CLOSE_BODY_SCHEMA)) return;
|
|
217
|
+
|
|
218
|
+
const id = api.parseId(req.params.id);
|
|
219
|
+
if (!id) return res.fail('bad_flag_id', 400);
|
|
220
|
+
|
|
221
|
+
const existing = await flags.getFlag(id);
|
|
222
|
+
if (!existing) return res.fail('flag_not_found', 404);
|
|
223
|
+
|
|
224
|
+
const isAuthor = String(existing.flagged_by) === String(req.builder.id);
|
|
225
|
+
if (!isAuthor && !api.rankMeetsThreshold(req.builder.rank, 'metic')) {
|
|
226
|
+
return res.fail('close_requires_metic_or_author', 403, {
|
|
227
|
+
what_this_means: 'Closing someone else\'s flag is a triage act, at the same floor as idea triage and blocker resolution. Your own flag you may always withdraw.',
|
|
228
|
+
your_options: ['Ask any Metic+ builder to close it', 'Add a comment to the task that fixes it and let the fixer close it'],
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Closed already — a named 409 rather than silently re-closing, so the
|
|
233
|
+
// second person sees the first person's verdict instead of overwriting it.
|
|
234
|
+
if (existing.status !== 'open') {
|
|
235
|
+
return res.fail('flag_already_closed', 409, {
|
|
236
|
+
status: existing.status,
|
|
237
|
+
resolution_note: existing.resolution_note,
|
|
238
|
+
resolved_at: existing.resolved_at,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const norm = flags.normalizeResolution(req.body);
|
|
243
|
+
if (!norm.ok) return res.fail(norm.code, 400, norm.detail);
|
|
244
|
+
|
|
245
|
+
let flag;
|
|
246
|
+
try {
|
|
247
|
+
flag = await flags.closeFlag(id, norm.value, req.builder.id);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
// 23503 = the resolved_task_id FK. A typo'd task number is a user error,
|
|
250
|
+
// not a server one — answer it as such rather than letting the ledger
|
|
251
|
+
// link fail as an opaque 500 and leaving the flag open with no clue why.
|
|
252
|
+
if (err && err.code === '23503') {
|
|
253
|
+
return res.fail('resolved_task_not_found', 400, {
|
|
254
|
+
resolved_task_id: norm.value.resolved_task_id,
|
|
255
|
+
what_this_means: 'That task id does not exist. Leave it blank if the fix has no task yet — the close is still recorded, and the missing link is visible rather than wrong.',
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
// Lost the race against a concurrent close (the UPDATE is guarded on
|
|
261
|
+
// status='open'). Same answer as the pre-check above.
|
|
262
|
+
if (!flag) return res.fail('flag_already_closed', 409);
|
|
263
|
+
|
|
264
|
+
res.json({ ok: true, flag });
|
|
265
|
+
} catch (err) {
|
|
266
|
+
console.error('[gds] POST /copy-desk/flags/:id/close', err);
|
|
267
|
+
if (!res.headersSent) res.fail('copy_flag_close_failed', 500);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
return router;
|
|
272
|
+
};
|