@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.
@@ -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
+ };