@bongos/core 1.25.16 → 1.25.18
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 +200 -75
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +24 -0
- package/clients/bongos-client/index.cjs +24 -0
- package/clients/bongos-client/index.d.ts +36 -2
- package/clients/bongos-client/index.mjs +24 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
- package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
- package/docs/api/openapi.json +761 -112
- package/docs/api-reference.md +23 -3
- package/docs/copy-inventory.md +118 -108
- package/docs/copy-registry.json +315 -172
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +3 -0
- package/docs/page-readings.json +62 -62
- package/modules/app-host/CLAUDE.md +15 -0
- package/modules/app-host/module.json +20 -0
- package/modules/app-host/public/settings-widget.js +143 -0
- package/modules/app-host/routes/app-host.js +88 -0
- package/modules/government/charter-advice.js +103 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/government/presets.js +42 -0
- package/modules/government/recommendations.js +102 -7
- package/modules/government/routes/government.js +1 -0
- package/modules/lifecycle/first-read-catalog.js +62 -0
- package/modules/lifecycle/first-read-db.js +190 -0
- package/modules/lifecycle/first-read.js +330 -0
- package/modules/lifecycle/kickoff-checklist.js +12 -3
- package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
- package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/first-read.js +101 -0
- package/modules/onboarding/genesis-work.js +101 -4
- package/modules/onboarding/routes/genesis-work.js +2 -2
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/sso-app-status.js +65 -0
- package/modules/provisioning/module.json +1 -0
- package/modules/provisioning/pollers/app-liveness.js +23 -1
- package/modules/provisioning/render-sandbox.js +113 -0
- package/modules/provisioning/render-standup.js +3 -1
- package/modules/provisioning/routes/provisioning.js +4 -1
- package/modules/provisioning/routes/render-standup.js +49 -1
- package/modules/provisioning/seams.js +32 -1
- package/modules/provisioning/starter-bundles.js +1 -0
- package/modules/public-landing/public/assets/guide-core.js +475 -0
- package/modules/public-landing/public/assets/guide-hall.css +121 -0
- package/modules/public-landing/public/assets/guide-hall.js +164 -0
- package/modules/public-landing/public/assets/guide-scene.css +9 -0
- package/modules/public-landing/public/assets/guide-scene.js +58 -381
- package/modules/public-landing/public/index.html +46 -1
- package/modules/public-landing/public/projects.html +112 -6
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/bongos/first-read.js +150 -0
- package/scripts/bongos/genesis.js +21 -1
- package/scripts/bongos/government-walk.js +4 -4
- package/scripts/bongos/provision-render-sandbox.js +171 -0
- package/scripts/bongos/provision-render.js +37 -2
- package/scripts/bongos/provision-teardown.js +1 -1
- package/scripts/bongos/publish-manifest.js +1 -0
- package/scripts/bongos/render-api.js +33 -3
- package/scripts/bongos/render-payload.js +39 -1
- package/scripts/bongos/seam-wiring-guard.js +3 -2
- package/scripts/gds/first-read.js +7 -0
- package/scripts/gds/provision-render-sandbox.js +6 -0
- package/scripts/hall-preview/server.js +2 -0
- package/src/bongos/auth-admission.js +64 -0
- package/src/bongos/auth.js +2 -1
- package/src/bongos/module-scope-map.js +4 -0
- package/src/bongos/routes/core-update.js +3 -16
- package/src/bongos/routes/modules.js +11 -0
- package/src/bongos/serve-internal.js +20 -6
- package/src/module-api.js +6 -1
- package/src/module-loader/loader.js +2 -2
- package/tests/app_host.mjs +413 -0
- package/tests/first_read.mjs +479 -0
- package/tests/genesis_work.mjs +74 -3
- package/tests/government_act_alone_buildings.mjs +2 -0
- package/tests/government_charter_advice.mjs +150 -0
- package/tests/landing_guide_hall.mjs +364 -0
- package/tests/landing_guide_scene.mjs +8 -5
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/npm_release_where.mjs +13 -3
- package/tests/provisioning_app_status.mjs +3 -2
- package/tests/provisioning_render_route.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +50 -0
- package/tests/render_api.mjs +5 -2
- package/tests/render_sandbox.mjs +447 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// modules/lifecycle/first-read.js — the first run-through of a project that arrives with
|
|
2
|
+
// code: the prompt the owner's OWN AI session runs, the repository map it writes back,
|
|
3
|
+
// the map's validation, and the plan for filing the owner's accepted items (task 1005280,
|
|
4
|
+
// goal 1000106 "Project creation").
|
|
5
|
+
//
|
|
6
|
+
// THE RULE THIS SERVES. The platform never reads a person's repository on its own AI
|
|
7
|
+
// bill. So Bongos does not read it at all: it hands the owner a prompt, their own AI
|
|
8
|
+
// session reads the repository on their own subscription, on their own computer, and
|
|
9
|
+
// sends back a MAP — goals already in progress, the tasks under each, and what needs
|
|
10
|
+
// doing next given errors or mishaps found in the code, each with a short reason and
|
|
11
|
+
// where it was seen. Nothing here calls an AI, and the map's length caps keep it a
|
|
12
|
+
// summary, never a copy of the code.
|
|
13
|
+
//
|
|
14
|
+
// NOTHING IS FILED WITHOUT THE OWNER'S YES. A submitted map waits as `pending`. Only an
|
|
15
|
+
// explicit accept (all, or named items) files anything, and only as drafts: goals open
|
|
16
|
+
// with their tasks in `backlog`, so nothing is claimable until someone promotes it.
|
|
17
|
+
//
|
|
18
|
+
// RE-RUNS DO NOT DUPLICATE. Each item's `ref` is derived from its title (never its
|
|
19
|
+
// position), and every filed ref is remembered (lifecycle_repo_map_filed), so a second
|
|
20
|
+
// map that names the same goal or task again plans to file nothing for it.
|
|
21
|
+
//
|
|
22
|
+
// ROOM TO GROW. Unknown top-level keys are kept, not refused, so a later section needs
|
|
23
|
+
// no change to this version's shape. `recommended_modules` (task 1005281) is the first:
|
|
24
|
+
// modules from THIS project's live list that would do a job better than what the code
|
|
25
|
+
// has (first-read-catalog.js supplies the list; a key not on it is refused on submit).
|
|
26
|
+
// A recommendation is a suggestion: accepting files a draft task to switch it on, and
|
|
27
|
+
// dismissing records the no, so a re-run does not raise it again.
|
|
28
|
+
//
|
|
29
|
+
// Pure — no I/O. The DB half is first-read-db.js; the HTTP surface routes/first-read.js;
|
|
30
|
+
// the CLI scripts/bongos/first-read.js; the Genesis step that hands out the prompt is
|
|
31
|
+
// GENESIS_EXISTING_FIRST in kickoff-checklist.js.
|
|
32
|
+
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
const SCHEMA = 'bongos.repo-map/1';
|
|
36
|
+
const MAP_FILE = 'bongos-repo-map.json';
|
|
37
|
+
|
|
38
|
+
// Caps. Generous for a real project, small enough that a map cannot carry the code.
|
|
39
|
+
const LIMITS = Object.freeze({
|
|
40
|
+
SUMMARY: 1200,
|
|
41
|
+
TITLE: 160,
|
|
42
|
+
WHY: 500,
|
|
43
|
+
SEEN_AT: 10,
|
|
44
|
+
SEEN_AT_CHARS: 300,
|
|
45
|
+
GOALS: 20,
|
|
46
|
+
TASKS_PER_GOAL: 30,
|
|
47
|
+
NEXT: 50,
|
|
48
|
+
MODULES: 10,
|
|
49
|
+
BYTES: 60000,
|
|
50
|
+
});
|
|
51
|
+
const TASK_STATES = ['done', 'in_progress', 'not_started'];
|
|
52
|
+
const NEXT_KINDS = ['error', 'mishap', 'gap'];
|
|
53
|
+
// Top-level keys this version reads. Anything else is kept as-is for a later reader.
|
|
54
|
+
const KNOWN_KEYS = ['schema', 'summary', 'goals', 'next', 'recommended_modules'];
|
|
55
|
+
const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'];
|
|
56
|
+
// The goal a `next` item lands under when it names no goal of the map.
|
|
57
|
+
const NEXT_GOAL = Object.freeze({
|
|
58
|
+
ref: 'goal:fix-what-the-first-read-found',
|
|
59
|
+
title: 'Fix what the first read-through found',
|
|
60
|
+
why: 'Errors, mishaps and gaps the first read-through of the repository found, not tied to one goal.',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// The goal an accepted module recommendation lands under.
|
|
64
|
+
const MODULES_GOAL = Object.freeze({
|
|
65
|
+
ref: 'goal:modules-the-first-read-recommends',
|
|
66
|
+
title: 'Modules the first read-through recommends',
|
|
67
|
+
why: 'Modules from this project\'s own list that the first read-through of the repository found would do a job better than what the code has now.',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// slug(text) → a stable lower-case key: the same title always gives the same ref.
|
|
71
|
+
function slug(text) {
|
|
72
|
+
return String(text || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
|
73
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The refs, in one place, so the plan and the review agree.
|
|
77
|
+
const goalRef = (title) => `goal:${slug(title)}`;
|
|
78
|
+
const taskRef = (goalTitle, title) => `task:${slug(goalTitle)}:${slug(title)}`;
|
|
79
|
+
const nextRef = (title) => `next:${slug(title)}`;
|
|
80
|
+
const moduleRef = (key) => `module:${key}`;
|
|
81
|
+
|
|
82
|
+
// ── the prompt ───────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
// An example map in the defined shape — printed in the prompt so the session copies it.
|
|
85
|
+
function exampleMap() {
|
|
86
|
+
return {
|
|
87
|
+
schema: SCHEMA,
|
|
88
|
+
summary: 'A booking site for a dog-walking business. Sign-up and the booking calendar work; payments are half built.',
|
|
89
|
+
goals: [{
|
|
90
|
+
title: 'Take payments for bookings',
|
|
91
|
+
why: 'A payments folder and a half-finished checkout page exist, and the last ten commits are about it.',
|
|
92
|
+
seen_at: ['src/payments/', 'src/pages/checkout.js'],
|
|
93
|
+
tasks: [
|
|
94
|
+
{ title: 'Finish the checkout page', status: 'in_progress', why: 'The page renders but the pay button does nothing.', seen_at: ['src/pages/checkout.js:88'] },
|
|
95
|
+
{ title: 'Send a receipt by email', status: 'not_started', why: 'A TODO says receipts are still to do.', seen_at: ['src/payments/charge.js:12'] },
|
|
96
|
+
],
|
|
97
|
+
}],
|
|
98
|
+
next: [
|
|
99
|
+
{ title: 'Fix the failing booking test', kind: 'error', goal: '', why: 'The test command fails: the calendar test expects Monday-first weeks.', seen_at: ['tests/calendar.test.js:40'] },
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The recommended_modules entry shape, shown in the prompt beside the example map.
|
|
105
|
+
const MODULE_EXAMPLE = { module: '<a key from the module list>', found: 'What in the code is weak, in one or two sentences.', why: 'Why this module does that job better.', seen_at: ['src/billing/'] };
|
|
106
|
+
|
|
107
|
+
// catalogText(catalog) → the module list as prompt lines, or a line saying there is none.
|
|
108
|
+
function catalogText(catalog) {
|
|
109
|
+
if (!Array.isArray(catalog)) return ' (The module list could not be read. Leave "recommended_modules" out.)';
|
|
110
|
+
if (!catalog.length) return ' (This project has no modules left to switch on. Leave "recommended_modules" out.)';
|
|
111
|
+
return catalog.map((m) => ` - ${m.key}: ${m.title}. ${m.summary}`.trimEnd()).join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// firstReadPrompt({ cli, catalog }) → the full text the owner hands their own AI session.
|
|
115
|
+
// Without a catalog it is the Genesis step's description (fixed text, so the hall's
|
|
116
|
+
// copy-prompt carries all of it) and tells the session to fetch the live module list;
|
|
117
|
+
// with one (`bongos first-read prompt`) the list is printed in.
|
|
118
|
+
function firstReadPrompt({ cli = 'npx bongos', catalog } = {}) {
|
|
119
|
+
const modules = catalog === undefined
|
|
120
|
+
? [` Get the modules this project can switch on: ${cli} first-read catalog`]
|
|
121
|
+
: [' The modules this project can switch on (key: name. what it does):', catalogText(catalog)];
|
|
122
|
+
return [
|
|
123
|
+
'First read-through of a project that already exists. You are the owner\'s own AI session, working on their computer, on their subscription. Bongos itself never reads this repository.',
|
|
124
|
+
'',
|
|
125
|
+
'1. Read the repository here yourself: the README and any docs or notes, the to-do comments, the recent git history and open branches, and the code. If the project has a test or build command, run it once to see what fails. Do not upload, paste or send any source code anywhere.',
|
|
126
|
+
'2. Write what you found as a map, in a file named ' + MAP_FILE + ' at the top of the repository (do not commit it):',
|
|
127
|
+
' - "goals": the goals already in progress, each with the tasks under it (status done, in_progress or not_started).',
|
|
128
|
+
' - "next": what needs doing next because of errors or mishaps you found in the code (kind error, mishap or gap). Name the goal it belongs to in "goal", or leave it empty.',
|
|
129
|
+
' - Every goal, task and next item has a short "why" (one or two sentences, in plain words) and "seen_at" (where you saw it: file paths, with a line number when it helps). Reasons and places only, never copied code.',
|
|
130
|
+
' Use exactly this shape:',
|
|
131
|
+
JSON.stringify(exampleMap(), null, 2).split('\n').map((l) => ' ' + l).join('\n'),
|
|
132
|
+
' - Optional "recommended_modules": compare what the code does with the modules below. Where a module would clearly do a job better than what is there (a billing system that keeps failing, say), add one entry: "module" (its key, exactly as listed), "found" (what in the code is weak), "why" (why the module is better) and "seen_at". Recommend only from the list, at most ' + LIMITS.MODULES + '. When nothing on it beats what the code already has, leave the section out: no recommendation is a good answer.',
|
|
133
|
+
' Entry shape: ' + JSON.stringify(MODULE_EXAMPLE),
|
|
134
|
+
...modules,
|
|
135
|
+
`3. Check the file: ${cli} first-read check ${MAP_FILE} — fix anything it lists and check again.`,
|
|
136
|
+
`4. Send it to Bongos: ${cli} first-read submit ${MAP_FILE} — this files nothing yet; it prints the map's number and every item's ref.`,
|
|
137
|
+
'5. Walk the owner through the map in plain words, goal by goal, then any recommended modules. Ask which items to accept: all of them, some of them, or none. Recommendations are suggestions; anything they say no to can be dismissed.',
|
|
138
|
+
`6. Only after the owner says yes, accept exactly what they chose: ${cli} first-read accept <map number> --all, or --items <ref>,<ref>. Items they turn down: ${cli} first-read dismiss <map number> --items <ref>,<ref> (a later read-through will not raise them again). If they want none of it: ${cli} first-read reject <map number>. Never accept or dismiss on your own. Accepted items become draft goals and tasks; nothing can be worked on until someone promotes it, and an accepted module is a to-do to switch it on, never switched on by itself.`,
|
|
139
|
+
'Running the read-through again later is safe: anything already accepted is not filed twice.',
|
|
140
|
+
].join('\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── validation ───────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function str(v) { return typeof v === 'string' ? v.trim() : ''; }
|
|
146
|
+
|
|
147
|
+
// Check a text field; push an error and return '' when it is missing or too long.
|
|
148
|
+
function text(errors, where, v, max, { required = true } = {}) {
|
|
149
|
+
const s = str(v);
|
|
150
|
+
if (v != null && typeof v !== 'string') { errors.push(`${where} must be text`); return ''; }
|
|
151
|
+
if (!s) { if (required) errors.push(`${where} is missing`); return ''; }
|
|
152
|
+
if (s.length > max) { errors.push(`${where} is longer than ${max} characters — keep it short`); return ''; }
|
|
153
|
+
return s;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function seenAt(errors, where, v) {
|
|
157
|
+
if (!Array.isArray(v) || v.length === 0) { errors.push(`${where}.seen_at needs at least one place (a file path)`); return []; }
|
|
158
|
+
if (v.length > LIMITS.SEEN_AT) { errors.push(`${where}.seen_at lists more than ${LIMITS.SEEN_AT} places`); return []; }
|
|
159
|
+
const out = [];
|
|
160
|
+
v.forEach((p, i) => { const s = text(errors, `${where}.seen_at[${i}]`, p, LIMITS.SEEN_AT_CHARS); if (s) out.push(s); });
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// validateRepoMap(raw, { catalog }) → { ok: true, map, items } | { ok: false, errors }
|
|
165
|
+
// `map` is the normalised map (unknown top-level keys kept verbatim); `items` is the flat
|
|
166
|
+
// review list: [{ ref, type: 'goal'|'task'|'next'|'module', title, why, seen_at, status?,
|
|
167
|
+
// kind?, found?, module?, goal_ref }]. Every error names the field it is about, so a
|
|
168
|
+
// session can fix and retry. `catalog` ([{ key, title }], or null when unreadable) is
|
|
169
|
+
// given on SUBMIT: every recommended key must be on it, and the title is the catalog's.
|
|
170
|
+
// It is left out when re-reading a stored map, which was checked when it came in.
|
|
171
|
+
function validateRepoMap(raw, { catalog } = {}) {
|
|
172
|
+
const errors = [];
|
|
173
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, errors: ['the map must be a JSON object'] };
|
|
174
|
+
if (raw.schema !== SCHEMA) errors.push(`schema must be "${SCHEMA}"`);
|
|
175
|
+
// The server takes request bodies up to 64 KB; a map that size is a summary no longer.
|
|
176
|
+
if (JSON.stringify(raw).length > LIMITS.BYTES) errors.push(`the map is larger than ${LIMITS.BYTES} characters — shorten the reasons or list fewer items`);
|
|
177
|
+
const summary = text(errors, 'summary', raw.summary, LIMITS.SUMMARY);
|
|
178
|
+
const goalsIn = raw.goals == null ? [] : raw.goals;
|
|
179
|
+
const nextIn = raw.next == null ? [] : raw.next;
|
|
180
|
+
const modsIn = raw.recommended_modules == null ? [] : raw.recommended_modules;
|
|
181
|
+
if (!Array.isArray(goalsIn)) errors.push('goals must be a list');
|
|
182
|
+
if (!Array.isArray(nextIn)) errors.push('next must be a list');
|
|
183
|
+
if (!Array.isArray(modsIn)) errors.push('recommended_modules must be a list');
|
|
184
|
+
if (errors.length) return { ok: false, errors };
|
|
185
|
+
if (goalsIn.length > LIMITS.GOALS) errors.push(`goals lists more than ${LIMITS.GOALS}`);
|
|
186
|
+
if (nextIn.length > LIMITS.NEXT) errors.push(`next lists more than ${LIMITS.NEXT}`);
|
|
187
|
+
if (modsIn.length > LIMITS.MODULES) errors.push(`recommended_modules lists more than ${LIMITS.MODULES}`);
|
|
188
|
+
if (!goalsIn.length && !nextIn.length) errors.push('the map has no goals and nothing next — list at least one');
|
|
189
|
+
|
|
190
|
+
const items = [];
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
const claimRef = (ref, where) => {
|
|
193
|
+
if (ref.endsWith(':') || ref.endsWith('-') || /:$|::/.test(ref)) { errors.push(`${where}.title needs letters or numbers`); return false; }
|
|
194
|
+
if (seen.has(ref)) { errors.push(`${where}.title repeats another item's title — make it distinct`); return false; }
|
|
195
|
+
seen.add(ref); return true;
|
|
196
|
+
};
|
|
197
|
+
const goals = [];
|
|
198
|
+
goalsIn.slice(0, LIMITS.GOALS).forEach((g, gi) => {
|
|
199
|
+
const w = `goals[${gi}]`;
|
|
200
|
+
if (!g || typeof g !== 'object' || Array.isArray(g)) { errors.push(`${w} must be an object`); return; }
|
|
201
|
+
const title = text(errors, `${w}.title`, g.title, LIMITS.TITLE);
|
|
202
|
+
const why = text(errors, `${w}.why`, g.why, LIMITS.WHY);
|
|
203
|
+
const where = seenAt(errors, w, g.seen_at);
|
|
204
|
+
const tasksIn = g.tasks == null ? [] : g.tasks;
|
|
205
|
+
if (!Array.isArray(tasksIn)) { errors.push(`${w}.tasks must be a list`); return; }
|
|
206
|
+
if (tasksIn.length > LIMITS.TASKS_PER_GOAL) errors.push(`${w}.tasks lists more than ${LIMITS.TASKS_PER_GOAL}`);
|
|
207
|
+
const ref = title ? goalRef(title) : null;
|
|
208
|
+
if (ref && claimRef(ref, w)) items.push({ ref, type: 'goal', title, why, seen_at: where, goal_ref: null });
|
|
209
|
+
const tasks = [];
|
|
210
|
+
tasksIn.slice(0, LIMITS.TASKS_PER_GOAL).forEach((t, ti) => {
|
|
211
|
+
const tw = `${w}.tasks[${ti}]`;
|
|
212
|
+
if (!t || typeof t !== 'object' || Array.isArray(t)) { errors.push(`${tw} must be an object`); return; }
|
|
213
|
+
const tt = text(errors, `${tw}.title`, t.title, LIMITS.TITLE);
|
|
214
|
+
const twhy = text(errors, `${tw}.why`, t.why, LIMITS.WHY);
|
|
215
|
+
const tat = seenAt(errors, tw, t.seen_at);
|
|
216
|
+
const status = t.status == null || t.status === '' ? 'not_started' : t.status;
|
|
217
|
+
if (!TASK_STATES.includes(status)) errors.push(`${tw}.status must be one of ${TASK_STATES.join(', ')}`);
|
|
218
|
+
const tref = title && tt ? taskRef(title, tt) : null;
|
|
219
|
+
if (tref && claimRef(tref, tw)) items.push({ ref: tref, type: 'task', title: tt, why: twhy, seen_at: tat, status, goal_ref: ref });
|
|
220
|
+
tasks.push({ title: tt, status, why: twhy, seen_at: tat });
|
|
221
|
+
});
|
|
222
|
+
goals.push({ title, why, seen_at: where, tasks });
|
|
223
|
+
});
|
|
224
|
+
const goalTitles = new Map(goals.map((g) => [slug(g.title), g.title]));
|
|
225
|
+
const next = [];
|
|
226
|
+
nextIn.slice(0, LIMITS.NEXT).forEach((n, ni) => {
|
|
227
|
+
const w = `next[${ni}]`;
|
|
228
|
+
if (!n || typeof n !== 'object' || Array.isArray(n)) { errors.push(`${w} must be an object`); return; }
|
|
229
|
+
const title = text(errors, `${w}.title`, n.title, LIMITS.TITLE);
|
|
230
|
+
const why = text(errors, `${w}.why`, n.why, LIMITS.WHY);
|
|
231
|
+
const where = seenAt(errors, w, n.seen_at);
|
|
232
|
+
const kind = n.kind == null || n.kind === '' ? 'error' : n.kind;
|
|
233
|
+
if (!NEXT_KINDS.includes(kind)) errors.push(`${w}.kind must be one of ${NEXT_KINDS.join(', ')}`);
|
|
234
|
+
const goal = text(errors, `${w}.goal`, n.goal, LIMITS.TITLE, { required: false });
|
|
235
|
+
if (goal && !goalTitles.has(slug(goal))) errors.push(`${w}.goal names "${goal}", which is not a goal in this map — use a goal's exact title or leave it empty`);
|
|
236
|
+
const ref = title ? nextRef(title) : null;
|
|
237
|
+
const gref = goal && goalTitles.has(slug(goal)) ? goalRef(goal) : NEXT_GOAL.ref;
|
|
238
|
+
if (ref && claimRef(ref, w)) items.push({ ref, type: 'next', title, why, seen_at: where, kind, goal_ref: gref });
|
|
239
|
+
next.push({ title, kind, goal, why, seen_at: where });
|
|
240
|
+
});
|
|
241
|
+
const known = Array.isArray(catalog) ? new Map(catalog.map((m) => [m.key, m])) : null;
|
|
242
|
+
const recommended = [];
|
|
243
|
+
modsIn.slice(0, LIMITS.MODULES).forEach((m, mi) => {
|
|
244
|
+
const w = `recommended_modules[${mi}]`;
|
|
245
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) { errors.push(`${w} must be an object`); return; }
|
|
246
|
+
const key = text(errors, `${w}.module`, m.module, LIMITS.TITLE);
|
|
247
|
+
const found = text(errors, `${w}.found`, m.found, LIMITS.WHY);
|
|
248
|
+
const why = text(errors, `${w}.why`, m.why, LIMITS.WHY);
|
|
249
|
+
const where = seenAt(errors, w, m.seen_at);
|
|
250
|
+
if (!key) return;
|
|
251
|
+
if (catalog !== undefined && !(known && known.has(key))) {
|
|
252
|
+
errors.push(known
|
|
253
|
+
? `${w}.module "${key}" is not a module this project can switch on — use a key from the module list (first-read catalog)`
|
|
254
|
+
: `${w}.module: the module list could not be read here, so no module can be recommended — leave recommended_modules out`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const title = known ? known.get(key).title : (text([], '', m.title, LIMITS.TITLE) || key);
|
|
258
|
+
const ref = moduleRef(key);
|
|
259
|
+
if (seen.has(ref)) { errors.push(`${w}.module repeats a module already recommended`); return; }
|
|
260
|
+
seen.add(ref);
|
|
261
|
+
items.push({ ref, type: 'module', module: key, title, found, why, seen_at: where, goal_ref: MODULES_GOAL.ref });
|
|
262
|
+
recommended.push({ module: key, title, found, why, seen_at: where });
|
|
263
|
+
});
|
|
264
|
+
if (errors.length) return { ok: false, errors };
|
|
265
|
+
|
|
266
|
+
const map = { schema: SCHEMA, summary, goals, next };
|
|
267
|
+
if (recommended.length) map.recommended_modules = recommended;
|
|
268
|
+
// Forward-compatible: a later section (task 1005281's recommended_modules) rides along.
|
|
269
|
+
// Prototype-shaped keys are never copied (CWE-1321): a later section is plain data.
|
|
270
|
+
for (const [k, v] of Object.entries(raw)) if (!KNOWN_KEYS.includes(k) && !UNSAFE_KEYS.includes(k)) map[k] = v;
|
|
271
|
+
return { ok: true, map, items };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── acceptance ───────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
function describe(item, mapId) {
|
|
277
|
+
const lines = item.type === 'module'
|
|
278
|
+
? [`Found: ${item.found}`, '', `Why the ${item.title} module: ${item.why}`, '', `Seen at: ${item.seen_at.join(', ')}`,
|
|
279
|
+
'Switching it on is done on the Modules page by whoever may turn modules on; this task is the reminder.']
|
|
280
|
+
: [item.why, '', `Seen at: ${item.seen_at.join(', ')}`];
|
|
281
|
+
if (item.status && item.status !== 'not_started') lines.push(`Status when read: ${item.status.replace('_', ' ')}`);
|
|
282
|
+
lines.push('', `From the first read-through of the repository (map ${mapId}, ${item.ref}). A draft: promote it when it is right.`);
|
|
283
|
+
return lines.join('\n');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// planAcceptance({ items, mapId, select, filedRefs, dismissedRefs }) → { goals, tasks, skipped, unknown }
|
|
287
|
+
// select 'all' | [ref] — what the owner said yes to. Nothing else is planned.
|
|
288
|
+
// filedRefs Set of refs already filed by an earlier accept (any map) — never filed again.
|
|
289
|
+
// dismissedRefs Set of refs the owner said no to before — 'all' skips them; naming one
|
|
290
|
+
// explicitly files it (the owner changed their mind).
|
|
291
|
+
// A chosen task or next item brings its goal along (a task needs a home); a goal that is
|
|
292
|
+
// already filed is reused by ref. A task whose status is `done` is history, not work:
|
|
293
|
+
// it is shown in review but never filed.
|
|
294
|
+
function planAcceptance({ items, mapId, select, filedRefs = new Set(), dismissedRefs = new Set() }) {
|
|
295
|
+
const byRef = new Map(items.map((i) => [i.ref, i]));
|
|
296
|
+
const wanted = select === 'all' ? items.map((i) => i.ref) : [...new Set(Array.isArray(select) ? select : [])];
|
|
297
|
+
const unknown = wanted.filter((r) => !byRef.has(r));
|
|
298
|
+
const goals = new Map();
|
|
299
|
+
const tasks = [];
|
|
300
|
+
const skipped = [];
|
|
301
|
+
const needGoal = (ref) => {
|
|
302
|
+
if (!ref || filedRefs.has(ref) || goals.has(ref)) return;
|
|
303
|
+
const fixed = ref === NEXT_GOAL.ref ? NEXT_GOAL : ref === MODULES_GOAL.ref ? MODULES_GOAL : null;
|
|
304
|
+
if (fixed) { goals.set(ref, { ref, title: fixed.title, description: fixed.why }); return; }
|
|
305
|
+
const g = byRef.get(ref);
|
|
306
|
+
if (g) goals.set(ref, { ref, title: g.title, description: describe(g, mapId) });
|
|
307
|
+
};
|
|
308
|
+
for (const ref of wanted) {
|
|
309
|
+
const it = byRef.get(ref);
|
|
310
|
+
if (!it) continue;
|
|
311
|
+
if (filedRefs.has(ref)) { skipped.push({ ref, reason: 'already_filed' }); continue; }
|
|
312
|
+
if (select === 'all' && dismissedRefs.has(ref)) { skipped.push({ ref, reason: 'dismissed' }); continue; }
|
|
313
|
+
if (it.type === 'goal') { needGoal(ref); continue; }
|
|
314
|
+
if (it.type === 'task' && it.status === 'done') { skipped.push({ ref, reason: 'already_done' }); continue; }
|
|
315
|
+
needGoal(it.goal_ref);
|
|
316
|
+
tasks.push({
|
|
317
|
+
ref,
|
|
318
|
+
goalRef: it.goal_ref,
|
|
319
|
+
title: it.type === 'module' ? `Switch on the ${it.title} module` : it.title,
|
|
320
|
+
description: describe(it, mapId),
|
|
321
|
+
kind: it.type === 'next' && it.kind !== 'gap' ? 'bug' : 'feature',
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return { goals: [...goals.values()], tasks, skipped, unknown };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
module.exports = {
|
|
328
|
+
SCHEMA, MAP_FILE, LIMITS, NEXT_GOAL, MODULES_GOAL,
|
|
329
|
+
exampleMap, firstReadPrompt, validateRepoMap, planAcceptance,
|
|
330
|
+
};
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// filers read the same steps and the copy cannot drift. It lives with the lifecycle
|
|
10
10
|
// module because tasks are its domain and the kernel doorway must not grow a domain
|
|
11
11
|
// import (ADR 0091 §1); init is a core CLI and, like abandon.js or audit-deps.js,
|
|
12
|
-
// may require module code directly. Pure —
|
|
12
|
+
// may require module code directly. Pure — its one import, first-read.js (the
|
|
13
|
+
// first read-through prompt, task 1005280), is pure too.
|
|
13
14
|
//
|
|
14
15
|
// Each step becomes a real, claimable task so it is tracked, never silently skipped
|
|
15
16
|
// (grounded in the onboarding/deploy surfaces; the task 1204 mapping pass). Per-module
|
|
@@ -17,6 +18,8 @@
|
|
|
17
18
|
|
|
18
19
|
'use strict';
|
|
19
20
|
|
|
21
|
+
const { firstReadPrompt } = require('./first-read');
|
|
22
|
+
|
|
20
23
|
// The one kickoff step the provisioning module AUTOMATES (ADR 0111 §3): standing up
|
|
21
24
|
// production hosting/TLS/DNS/managed-DB. Kept as a named builder so init can either
|
|
22
25
|
// FILE it (no provisioning requested, or the request couldn't be enqueued) or OMIT
|
|
@@ -163,9 +166,15 @@ const GENESIS_STEPS = Object.freeze({
|
|
|
163
166
|
description: 'Genesis stage 5 (legislation). Who has a say, and the rule a decision passes by. When it is done, Ranks wake, and the project can come alive.' },
|
|
164
167
|
],
|
|
165
168
|
});
|
|
169
|
+
// task 1005280: the first read-through. The step's description IS the prompt the owner
|
|
170
|
+
// copies into their own AI session (the hall's copy-prompt hands over a task's
|
|
171
|
+
// description), so the repository is read on the owner's subscription, never the
|
|
172
|
+
// platform's, and comes back as a map the owner accepts or rejects (first-read.js).
|
|
166
173
|
const GENESIS_EXISTING_FIRST = {
|
|
167
|
-
title: 'Gather what already exists:
|
|
168
|
-
description: 'Genesis stage 1 (scope), for a project that already exists. Before writing anything new,
|
|
174
|
+
title: 'Gather what already exists: have your own AI read the repository and map it',
|
|
175
|
+
description: 'Genesis stage 1 (scope), for a project that already exists. Before writing anything new, map what is already there, so the scope is written from it rather than beside it. ' +
|
|
176
|
+
'Copy this task\'s prompt into your own AI session, opened in the project\'s folder on your computer; it reads the code there and sends back a map for you to accept.\n\n' +
|
|
177
|
+
firstReadPrompt(),
|
|
169
178
|
};
|
|
170
179
|
|
|
171
180
|
// GENESIS_AFTER — "this step follows that one", by ref (task 1005110). A step listed
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- lifecycle_022_repo_maps.sql — the first read-through's repository maps (task 1005280,
|
|
2
|
+
-- goal 1000106).
|
|
3
|
+
--
|
|
4
|
+
-- WHAT IT IS. A project that arrives with code gets a prompt for the owner's OWN AI
|
|
5
|
+
-- session; that session reads the repository on the owner's computer and subscription
|
|
6
|
+
-- and sends back a map (modules/lifecycle/first-read.js). A map waits here as `pending`
|
|
7
|
+
-- until the owner accepts it — whole or item by item — or rejects it. Nothing is filed
|
|
8
|
+
-- by the submit itself.
|
|
9
|
+
--
|
|
10
|
+
-- WHY A SECOND TABLE. Re-running the read-through must not file the same goal or task
|
|
11
|
+
-- twice, and goals carry no source_ref to dedup on. So every accepted item's stable
|
|
12
|
+
-- `ref` (derived from its title, never its position) is a row in
|
|
13
|
+
-- lifecycle_repo_map_filed, PRIMARY KEY on the ref: the next accept skips it, whichever
|
|
14
|
+
-- map names it again.
|
|
15
|
+
--
|
|
16
|
+
-- MODULE-OWNED and `lifecycle_`-prefixed (ADR 0083 §Decision #5).
|
|
17
|
+
|
|
18
|
+
CREATE TABLE IF NOT EXISTS lifecycle_repo_maps (
|
|
19
|
+
id bigserial PRIMARY KEY,
|
|
20
|
+
schema text NOT NULL,
|
|
21
|
+
map jsonb NOT NULL,
|
|
22
|
+
status text NOT NULL DEFAULT 'pending',
|
|
23
|
+
submitted_by bigint REFERENCES builders(id),
|
|
24
|
+
submitted_at timestamptz NOT NULL DEFAULT now(),
|
|
25
|
+
reviewed_by bigint REFERENCES builders(id),
|
|
26
|
+
reviewed_at timestamptz,
|
|
27
|
+
|
|
28
|
+
CONSTRAINT lifecycle_repo_maps_status_chk
|
|
29
|
+
CHECK (status IN ('pending', 'accepted', 'rejected'))
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS lifecycle_repo_maps_newest_idx
|
|
33
|
+
ON lifecycle_repo_maps (submitted_at DESC);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS lifecycle_repo_map_filed (
|
|
36
|
+
ref text PRIMARY KEY,
|
|
37
|
+
kind text NOT NULL,
|
|
38
|
+
entity_id bigint NOT NULL,
|
|
39
|
+
map_id bigint NOT NULL REFERENCES lifecycle_repo_maps(id),
|
|
40
|
+
filed_by bigint REFERENCES builders(id),
|
|
41
|
+
filed_at timestamptz NOT NULL DEFAULT now(),
|
|
42
|
+
|
|
43
|
+
CONSTRAINT lifecycle_repo_map_filed_kind_chk CHECK (kind IN ('goal', 'task'))
|
|
44
|
+
);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- lifecycle_023_repo_map_dismissed.sql — the owner's "no" to an item of a repository
|
|
2
|
+
-- map is remembered too (task 1005281, goal 1000106).
|
|
3
|
+
--
|
|
4
|
+
-- A module recommendation is a suggestion the owner accepts OR dismisses. A dismissed
|
|
5
|
+
-- item is a row in lifecycle_repo_map_filed with kind 'dismissed' and no entity, so a
|
|
6
|
+
-- later read-through's "accept all" does not raise it again. Naming it explicitly later
|
|
7
|
+
-- still files it: the accept upserts over a 'dismissed' row only, never over a filed one.
|
|
8
|
+
-- Works for every item type, not only modules.
|
|
9
|
+
|
|
10
|
+
ALTER TABLE lifecycle_repo_map_filed ALTER COLUMN entity_id DROP NOT NULL;
|
|
11
|
+
|
|
12
|
+
ALTER TABLE lifecycle_repo_map_filed DROP CONSTRAINT IF EXISTS lifecycle_repo_map_filed_kind_chk;
|
|
13
|
+
ALTER TABLE lifecycle_repo_map_filed ADD CONSTRAINT lifecycle_repo_map_filed_kind_chk
|
|
14
|
+
CHECK (kind IN ('goal', 'task', 'dismissed'));
|
|
15
|
+
|
|
16
|
+
-- A filed item points at what it became; a dismissed one points at nothing.
|
|
17
|
+
ALTER TABLE lifecycle_repo_map_filed DROP CONSTRAINT IF EXISTS lifecycle_repo_map_filed_entity_chk;
|
|
18
|
+
ALTER TABLE lifecycle_repo_map_filed ADD CONSTRAINT lifecycle_repo_map_filed_entity_chk
|
|
19
|
+
CHECK ((kind = 'dismissed') = (entity_id IS NULL));
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// modules/lifecycle/routes/first-read.js — the HTTP surface of the first read-through
|
|
2
|
+
// (task 1005280, goal 1000106): hand out the prompt, take a repository map from the
|
|
3
|
+
// owner's own AI session, show it for review, and accept or reject it.
|
|
4
|
+
//
|
|
5
|
+
// No route here calls an AI, reads a repository, or files anything on submit. Filing is
|
|
6
|
+
// the accept route alone, gated like creating a goal (`goal.create` + the planning
|
|
7
|
+
// act-alone rule), because accepting a map IS creating goals and tasks. The rules are
|
|
8
|
+
// first-read.js; the store is first-read-db.js. Reaches core only through the doorway.
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const express = require('express');
|
|
13
|
+
const api = require('../../../src/module-api');
|
|
14
|
+
const fr = require('../first-read');
|
|
15
|
+
const store = require('../first-read-db');
|
|
16
|
+
const modulesCatalog = require('../first-read-catalog');
|
|
17
|
+
|
|
18
|
+
const { asyncHandler, parseId, validateOrRespond } = api;
|
|
19
|
+
|
|
20
|
+
module.exports = function buildFirstReadRouter() {
|
|
21
|
+
const router = express.Router();
|
|
22
|
+
|
|
23
|
+
// rank: any-builder — the prompt and the shape are instructions, not project data. The
|
|
24
|
+
// prompt carries the live module list (task 1005281), so a session recommends from it.
|
|
25
|
+
router.get('/first-read/prompt', api.requireBuilder, asyncHandler('GET /first-read/prompt', async (req, res) => {
|
|
26
|
+
const catalog = await modulesCatalog.switchOnCatalog();
|
|
27
|
+
res.json({ schema: fr.SCHEMA, file: fr.MAP_FILE, prompt: fr.firstReadPrompt({ catalog }), example: fr.exampleMap(), limits: fr.LIMITS, catalog });
|
|
28
|
+
}, { errorCode: 'first_read_prompt_failed' }));
|
|
29
|
+
|
|
30
|
+
// rank: any-builder — the modules this project can switch on, the only ones a map may
|
|
31
|
+
// recommend. `available: false` when the list cannot be read here.
|
|
32
|
+
router.get('/first-read/catalog', api.requireBuilder, asyncHandler('GET /first-read/catalog', async (req, res) => {
|
|
33
|
+
const catalog = await modulesCatalog.switchOnCatalog();
|
|
34
|
+
res.json({ available: Array.isArray(catalog), modules: catalog || [] });
|
|
35
|
+
}, { errorCode: 'first_read_catalog_failed' }));
|
|
36
|
+
|
|
37
|
+
// rank: goal.create — a map is a planning proposal for this project. Stores it
|
|
38
|
+
// pending; files nothing. A malformed map is refused with every error named.
|
|
39
|
+
router.post('/first-read/maps', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('POST /first-read/maps', async (req, res) => {
|
|
40
|
+
// The envelope is checked here; the map inside it by first-read.js, which names every error.
|
|
41
|
+
if (validateOrRespond(req, res, { map: { required: true, type: 'object' } })) return undefined;
|
|
42
|
+
const catalog = await modulesCatalog.switchOnCatalog();
|
|
43
|
+
const out = await store.submitMap({ raw: req.body.map, builderId: req.builder.id, catalog });
|
|
44
|
+
if (!out.ok) return res.fail('invalid_map', 400, { errors: out.errors });
|
|
45
|
+
return res.status(201).json({
|
|
46
|
+
map_id: out.map_id, status: out.status,
|
|
47
|
+
items: out.items.map((i) => ({ ref: i.ref, type: i.type, title: i.title })),
|
|
48
|
+
next: 'Show the owner the map. File it only with their yes: POST /first-read/maps/' + out.map_id + '/accept { all: true } or { items: [ref] }.',
|
|
49
|
+
});
|
|
50
|
+
}, { errorCode: 'first_read_submit_failed' }));
|
|
51
|
+
|
|
52
|
+
// rank: goal.create — the review is for whoever may accept or reject it.
|
|
53
|
+
router.get('/first-read/maps/:id', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('GET /first-read/maps/:id', async (req, res) => {
|
|
54
|
+
const id = req.params.id === 'latest' ? 'latest' : parseId(req, res, { positiveInt: true });
|
|
55
|
+
if (id == null) return undefined;
|
|
56
|
+
const review = await store.reviewMap(id);
|
|
57
|
+
if (!review) return res.fail('not_found', 404);
|
|
58
|
+
return res.json(review);
|
|
59
|
+
}, { errorCode: 'first_read_review_failed' }));
|
|
60
|
+
|
|
61
|
+
// rank: goal.create + act-alone planning — the owner's yes. Files the chosen items as
|
|
62
|
+
// draft goals (open) and tasks (backlog); an item already filed is skipped.
|
|
63
|
+
router.post('/first-read/maps/:id/accept', api.requireBuilder, api.requirePermission('goal.create'), api.requireActAlone('planning'),
|
|
64
|
+
asyncHandler('POST /first-read/maps/:id/accept', async (req, res) => {
|
|
65
|
+
const mapId = parseId(req, res, { positiveInt: true });
|
|
66
|
+
if (mapId == null) return undefined;
|
|
67
|
+
if (validateOrRespond(req, res, {
|
|
68
|
+
all: { type: 'boolean' },
|
|
69
|
+
items: { type: 'array', itemsType: 'string', maxItems: 500 },
|
|
70
|
+
})) return undefined;
|
|
71
|
+
const { all, items } = req.body || {};
|
|
72
|
+
if ((all === true) === Array.isArray(items)) {
|
|
73
|
+
return res.fail('invalid_items', 400, { hint: 'send { all: true } or { items: [ref, …] } — the refs from the review' });
|
|
74
|
+
}
|
|
75
|
+
const out = await store.acceptMap({ mapId, select: all === true ? 'all' : items, builderId: req.builder.id });
|
|
76
|
+
if (!out.ok) { const { code, status, ...rest } = out; return res.fail(code, status, rest); }
|
|
77
|
+
return res.json(out);
|
|
78
|
+
}, { errorCode: 'first_read_accept_failed' }));
|
|
79
|
+
|
|
80
|
+
// rank: goal.create — the owner's no to named items (a module recommendation, usually).
|
|
81
|
+
// Files nothing; a later "accept all" passes them by.
|
|
82
|
+
router.post('/first-read/maps/:id/dismiss', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('POST /first-read/maps/:id/dismiss', async (req, res) => {
|
|
83
|
+
const mapId = parseId(req, res, { positiveInt: true });
|
|
84
|
+
if (mapId == null) return undefined;
|
|
85
|
+
if (validateOrRespond(req, res, { items: { required: true, type: 'array', itemsType: 'string', maxItems: 500 } })) return undefined;
|
|
86
|
+
const out = await store.dismissItems({ mapId, refs: req.body.items, builderId: req.builder.id });
|
|
87
|
+
if (!out.ok) { const { code, status, ...rest } = out; return res.fail(code, status, rest); }
|
|
88
|
+
return res.json(out);
|
|
89
|
+
}, { errorCode: 'first_read_dismiss_failed' }));
|
|
90
|
+
|
|
91
|
+
// rank: goal.create — the owner's no. Files nothing.
|
|
92
|
+
router.post('/first-read/maps/:id/reject', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('POST /first-read/maps/:id/reject', async (req, res) => {
|
|
93
|
+
const mapId = parseId(req, res, { positiveInt: true });
|
|
94
|
+
if (mapId == null) return undefined;
|
|
95
|
+
const out = await store.rejectMap({ mapId, builderId: req.builder.id });
|
|
96
|
+
if (!out.ok) { const { code, status, ...rest } = out; return res.fail(code, status, rest); }
|
|
97
|
+
return res.json(out);
|
|
98
|
+
}, { errorCode: 'first_read_reject_failed' }));
|
|
99
|
+
|
|
100
|
+
return router;
|
|
101
|
+
};
|