@bongos/core 1.25.17 → 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 +131 -61
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +15 -1
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
- package/docs/api/openapi.json +243 -6
- package/docs/api-reference.md +12 -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 +2 -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/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 +47 -9
- package/modules/lifecycle/first-read.js +88 -23
- package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
- package/modules/lifecycle/routes/first-read.js +27 -5
- 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/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 +11 -2
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/scripts/bongos/first-read.js +45 -11
- package/scripts/bongos/genesis.js +21 -1
- package/scripts/bongos/publish-manifest.js +1 -0
- package/scripts/bongos/seam-wiring-guard.js +3 -2
- 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 +184 -8
- package/tests/genesis_work.mjs +74 -3
- 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
|
@@ -19,8 +19,12 @@
|
|
|
19
19
|
// position), and every filed ref is remembered (lifecycle_repo_map_filed), so a second
|
|
20
20
|
// map that names the same goal or task again plans to file nothing for it.
|
|
21
21
|
//
|
|
22
|
-
// ROOM TO GROW. Unknown top-level keys are kept, not refused
|
|
23
|
-
//
|
|
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.
|
|
24
28
|
//
|
|
25
29
|
// Pure — no I/O. The DB half is first-read-db.js; the HTTP surface routes/first-read.js;
|
|
26
30
|
// the CLI scripts/bongos/first-read.js; the Genesis step that hands out the prompt is
|
|
@@ -41,12 +45,13 @@ const LIMITS = Object.freeze({
|
|
|
41
45
|
GOALS: 20,
|
|
42
46
|
TASKS_PER_GOAL: 30,
|
|
43
47
|
NEXT: 50,
|
|
48
|
+
MODULES: 10,
|
|
44
49
|
BYTES: 60000,
|
|
45
50
|
});
|
|
46
51
|
const TASK_STATES = ['done', 'in_progress', 'not_started'];
|
|
47
52
|
const NEXT_KINDS = ['error', 'mishap', 'gap'];
|
|
48
53
|
// Top-level keys this version reads. Anything else is kept as-is for a later reader.
|
|
49
|
-
const KNOWN_KEYS = ['schema', 'summary', 'goals', 'next'];
|
|
54
|
+
const KNOWN_KEYS = ['schema', 'summary', 'goals', 'next', 'recommended_modules'];
|
|
50
55
|
const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'];
|
|
51
56
|
// The goal a `next` item lands under when it names no goal of the map.
|
|
52
57
|
const NEXT_GOAL = Object.freeze({
|
|
@@ -55,6 +60,13 @@ const NEXT_GOAL = Object.freeze({
|
|
|
55
60
|
why: 'Errors, mishaps and gaps the first read-through of the repository found, not tied to one goal.',
|
|
56
61
|
});
|
|
57
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
|
+
|
|
58
70
|
// slug(text) → a stable lower-case key: the same title always gives the same ref.
|
|
59
71
|
function slug(text) {
|
|
60
72
|
return String(text || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
|
@@ -65,6 +77,7 @@ function slug(text) {
|
|
|
65
77
|
const goalRef = (title) => `goal:${slug(title)}`;
|
|
66
78
|
const taskRef = (goalTitle, title) => `task:${slug(goalTitle)}:${slug(title)}`;
|
|
67
79
|
const nextRef = (title) => `next:${slug(title)}`;
|
|
80
|
+
const moduleRef = (key) => `module:${key}`;
|
|
68
81
|
|
|
69
82
|
// ── the prompt ───────────────────────────────────────────────────────────────
|
|
70
83
|
|
|
@@ -88,9 +101,24 @@ function exampleMap() {
|
|
|
88
101
|
};
|
|
89
102
|
}
|
|
90
103
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
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)];
|
|
94
122
|
return [
|
|
95
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.',
|
|
96
124
|
'',
|
|
@@ -101,10 +129,13 @@ function firstReadPrompt({ cli = 'npx bongos' } = {}) {
|
|
|
101
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.',
|
|
102
130
|
' Use exactly this shape:',
|
|
103
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,
|
|
104
135
|
`3. Check the file: ${cli} first-read check ${MAP_FILE} — fix anything it lists and check again.`,
|
|
105
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.`,
|
|
106
|
-
'5. Walk the owner through the map in plain words, goal by goal. Ask which items to accept: all of them, some of them, or none.',
|
|
107
|
-
`6. Only after the owner says yes, accept exactly what they chose: ${cli} first-read accept <map number> --all, or --items <ref>,<ref>. If they want none of it: ${cli} first-read reject <map number>. Never accept on your own. Accepted items become draft goals and tasks; nothing can be worked on until someone promotes it.`,
|
|
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.`,
|
|
108
139
|
'Running the read-through again later is safe: anything already accepted is not filed twice.',
|
|
109
140
|
].join('\n');
|
|
110
141
|
}
|
|
@@ -130,11 +161,14 @@ function seenAt(errors, where, v) {
|
|
|
130
161
|
return out;
|
|
131
162
|
}
|
|
132
163
|
|
|
133
|
-
// validateRepoMap(raw) → { ok: true, map, items } | { ok: false, errors
|
|
164
|
+
// validateRepoMap(raw, { catalog }) → { ok: true, map, items } | { ok: false, errors }
|
|
134
165
|
// `map` is the normalised map (unknown top-level keys kept verbatim); `items` is the flat
|
|
135
|
-
// review list: [{ ref, type: 'goal'|'task'|'next', title, why, seen_at, status?,
|
|
136
|
-
// goal_ref }]. Every error names the field it is about, so a
|
|
137
|
-
|
|
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 } = {}) {
|
|
138
172
|
const errors = [];
|
|
139
173
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, errors: ['the map must be a JSON object'] };
|
|
140
174
|
if (raw.schema !== SCHEMA) errors.push(`schema must be "${SCHEMA}"`);
|
|
@@ -143,11 +177,14 @@ function validateRepoMap(raw) {
|
|
|
143
177
|
const summary = text(errors, 'summary', raw.summary, LIMITS.SUMMARY);
|
|
144
178
|
const goalsIn = raw.goals == null ? [] : raw.goals;
|
|
145
179
|
const nextIn = raw.next == null ? [] : raw.next;
|
|
180
|
+
const modsIn = raw.recommended_modules == null ? [] : raw.recommended_modules;
|
|
146
181
|
if (!Array.isArray(goalsIn)) errors.push('goals must be a list');
|
|
147
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');
|
|
148
184
|
if (errors.length) return { ok: false, errors };
|
|
149
185
|
if (goalsIn.length > LIMITS.GOALS) errors.push(`goals lists more than ${LIMITS.GOALS}`);
|
|
150
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}`);
|
|
151
188
|
if (!goalsIn.length && !nextIn.length) errors.push('the map has no goals and nothing next — list at least one');
|
|
152
189
|
|
|
153
190
|
const items = [];
|
|
@@ -201,9 +238,33 @@ function validateRepoMap(raw) {
|
|
|
201
238
|
if (ref && claimRef(ref, w)) items.push({ ref, type: 'next', title, why, seen_at: where, kind, goal_ref: gref });
|
|
202
239
|
next.push({ title, kind, goal, why, seen_at: where });
|
|
203
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
|
+
});
|
|
204
264
|
if (errors.length) return { ok: false, errors };
|
|
205
265
|
|
|
206
266
|
const map = { schema: SCHEMA, summary, goals, next };
|
|
267
|
+
if (recommended.length) map.recommended_modules = recommended;
|
|
207
268
|
// Forward-compatible: a later section (task 1005281's recommended_modules) rides along.
|
|
208
269
|
// Prototype-shaped keys are never copied (CWE-1321): a later section is plain data.
|
|
209
270
|
for (const [k, v] of Object.entries(raw)) if (!KNOWN_KEYS.includes(k) && !UNSAFE_KEYS.includes(k)) map[k] = v;
|
|
@@ -213,19 +274,24 @@ function validateRepoMap(raw) {
|
|
|
213
274
|
// ── acceptance ───────────────────────────────────────────────────────────────
|
|
214
275
|
|
|
215
276
|
function describe(item, mapId) {
|
|
216
|
-
const lines =
|
|
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(', ')}`];
|
|
217
281
|
if (item.status && item.status !== 'not_started') lines.push(`Status when read: ${item.status.replace('_', ' ')}`);
|
|
218
282
|
lines.push('', `From the first read-through of the repository (map ${mapId}, ${item.ref}). A draft: promote it when it is right.`);
|
|
219
283
|
return lines.join('\n');
|
|
220
284
|
}
|
|
221
285
|
|
|
222
|
-
// planAcceptance({ items, mapId, select, filedRefs }) → { goals, tasks, skipped, unknown }
|
|
223
|
-
// select
|
|
224
|
-
// filedRefs
|
|
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).
|
|
225
291
|
// A chosen task or next item brings its goal along (a task needs a home); a goal that is
|
|
226
292
|
// already filed is reused by ref. A task whose status is `done` is history, not work:
|
|
227
293
|
// it is shown in review but never filed.
|
|
228
|
-
function planAcceptance({ items, mapId, select, filedRefs = new Set() }) {
|
|
294
|
+
function planAcceptance({ items, mapId, select, filedRefs = new Set(), dismissedRefs = new Set() }) {
|
|
229
295
|
const byRef = new Map(items.map((i) => [i.ref, i]));
|
|
230
296
|
const wanted = select === 'all' ? items.map((i) => i.ref) : [...new Set(Array.isArray(select) ? select : [])];
|
|
231
297
|
const unknown = wanted.filter((r) => !byRef.has(r));
|
|
@@ -234,10 +300,8 @@ function planAcceptance({ items, mapId, select, filedRefs = new Set() }) {
|
|
|
234
300
|
const skipped = [];
|
|
235
301
|
const needGoal = (ref) => {
|
|
236
302
|
if (!ref || filedRefs.has(ref) || goals.has(ref)) return;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
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; }
|
|
241
305
|
const g = byRef.get(ref);
|
|
242
306
|
if (g) goals.set(ref, { ref, title: g.title, description: describe(g, mapId) });
|
|
243
307
|
};
|
|
@@ -245,13 +309,14 @@ function planAcceptance({ items, mapId, select, filedRefs = new Set() }) {
|
|
|
245
309
|
const it = byRef.get(ref);
|
|
246
310
|
if (!it) continue;
|
|
247
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; }
|
|
248
313
|
if (it.type === 'goal') { needGoal(ref); continue; }
|
|
249
314
|
if (it.type === 'task' && it.status === 'done') { skipped.push({ ref, reason: 'already_done' }); continue; }
|
|
250
315
|
needGoal(it.goal_ref);
|
|
251
316
|
tasks.push({
|
|
252
317
|
ref,
|
|
253
318
|
goalRef: it.goal_ref,
|
|
254
|
-
title: it.title,
|
|
319
|
+
title: it.type === 'module' ? `Switch on the ${it.title} module` : it.title,
|
|
255
320
|
description: describe(it, mapId),
|
|
256
321
|
kind: it.type === 'next' && it.kind !== 'gap' ? 'bug' : 'feature',
|
|
257
322
|
});
|
|
@@ -260,6 +325,6 @@ function planAcceptance({ items, mapId, select, filedRefs = new Set() }) {
|
|
|
260
325
|
}
|
|
261
326
|
|
|
262
327
|
module.exports = {
|
|
263
|
-
SCHEMA, MAP_FILE, LIMITS, NEXT_GOAL,
|
|
328
|
+
SCHEMA, MAP_FILE, LIMITS, NEXT_GOAL, MODULES_GOAL,
|
|
264
329
|
exampleMap, firstReadPrompt, validateRepoMap, planAcceptance,
|
|
265
330
|
};
|
|
@@ -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));
|
|
@@ -13,23 +13,34 @@ const express = require('express');
|
|
|
13
13
|
const api = require('../../../src/module-api');
|
|
14
14
|
const fr = require('../first-read');
|
|
15
15
|
const store = require('../first-read-db');
|
|
16
|
+
const modulesCatalog = require('../first-read-catalog');
|
|
16
17
|
|
|
17
18
|
const { asyncHandler, parseId, validateOrRespond } = api;
|
|
18
19
|
|
|
19
20
|
module.exports = function buildFirstReadRouter() {
|
|
20
21
|
const router = express.Router();
|
|
21
22
|
|
|
22
|
-
// rank: any-builder — the prompt and the shape are instructions, not project data.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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' }));
|
|
26
36
|
|
|
27
37
|
// rank: goal.create — a map is a planning proposal for this project. Stores it
|
|
28
38
|
// pending; files nothing. A malformed map is refused with every error named.
|
|
29
39
|
router.post('/first-read/maps', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('POST /first-read/maps', async (req, res) => {
|
|
30
40
|
// The envelope is checked here; the map inside it by first-read.js, which names every error.
|
|
31
41
|
if (validateOrRespond(req, res, { map: { required: true, type: 'object' } })) return undefined;
|
|
32
|
-
const
|
|
42
|
+
const catalog = await modulesCatalog.switchOnCatalog();
|
|
43
|
+
const out = await store.submitMap({ raw: req.body.map, builderId: req.builder.id, catalog });
|
|
33
44
|
if (!out.ok) return res.fail('invalid_map', 400, { errors: out.errors });
|
|
34
45
|
return res.status(201).json({
|
|
35
46
|
map_id: out.map_id, status: out.status,
|
|
@@ -66,6 +77,17 @@ module.exports = function buildFirstReadRouter() {
|
|
|
66
77
|
return res.json(out);
|
|
67
78
|
}, { errorCode: 'first_read_accept_failed' }));
|
|
68
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
|
+
|
|
69
91
|
// rank: goal.create — the owner's no. Files nothing.
|
|
70
92
|
router.post('/first-read/maps/:id/reject', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('POST /first-read/maps/:id/reject', async (req, res) => {
|
|
71
93
|
const mapId = parseId(req, res, { positiveInt: true });
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
//
|
|
42
42
|
// WHICH STAGES. A stage adopts the pattern by declaring its questions in QUESTIONS.
|
|
43
43
|
// Strategize does (task 1005116); scope's guided door is its project interview (task 1005114);
|
|
44
|
-
// charter
|
|
44
|
+
// charter asks how decisions should go and the founder's principles (task 1005119);
|
|
45
|
+
// legalize and legislation adopt it when they are next touched. A stage with
|
|
45
46
|
// no questions still takes a free page and a link.
|
|
46
47
|
//
|
|
47
48
|
// THE BOUNDARY. Pure apart from the store. The store is injected (tests) or the
|
|
@@ -57,6 +58,9 @@ const STATES = Object.freeze(['answered', 'not_sure', 'skipped']);
|
|
|
57
58
|
const ORIGINS = Object.freeze(['founder', 'assisted', 'carried_over']);
|
|
58
59
|
const LIMITS = Object.freeze({ answer: 4000, page: 20000, link: 500 });
|
|
59
60
|
|
|
61
|
+
// A closed question's options: [key, label] pairs, frozen.
|
|
62
|
+
const choices = (pairs) => Object.freeze(pairs.map(([key, label]) => Object.freeze({ key, label })));
|
|
63
|
+
|
|
60
64
|
// Each adopted stage's questions, in the order the guided door asks them. `id` is
|
|
61
65
|
// stable: a stored answer names it, so never rename or reuse one. `hint` is the shape
|
|
62
66
|
// of a good answer, shown under the question in every door. `group` (optional) is a
|
|
@@ -83,6 +87,47 @@ const QUESTIONS = Object.freeze({
|
|
|
83
87
|
Object.freeze({ id: 'others_wrong', group: 'The bet', ask: 'What is everyone else in this area getting wrong?',
|
|
84
88
|
hint: 'The thing you believe that others in this space do not. This is the bet the project makes.' }),
|
|
85
89
|
]),
|
|
90
|
+
// CHARTER (task 1005119): how decisions should go, then the founder's principles.
|
|
91
|
+
// A question with `choices` is a CLOSED one: an answer is one of its keys, because
|
|
92
|
+
// the government module's recommender reads it (recommendations.js CHARTER_ANSWERS
|
|
93
|
+
// and presets.js PRINCIPLE_ANSWERS hold the same keys, by test; a module may not
|
|
94
|
+
// import a sibling, ADR 0083). The prose ones shape the drafted bylaws and code of
|
|
95
|
+
// conduct in the founder's own words. Every one is skippable and "not sure yet".
|
|
96
|
+
charter: Object.freeze([
|
|
97
|
+
Object.freeze({ id: 'final_say', group: 'How decisions go', ask: 'When people disagree, who should have the final say?',
|
|
98
|
+
hint: 'Who settles it in the end. This is who gets a vote.',
|
|
99
|
+
choices: choices([['founder', 'Me, the founder'], ['trusted', 'The people who have earned trust by shipping'],
|
|
100
|
+
['everyone', 'Everyone on the project'], ['council', 'A small group I appoint']]) }),
|
|
101
|
+
Object.freeze({ id: 'agreement', group: 'How decisions go', ask: 'How much agreement should a decision need?',
|
|
102
|
+
hint: 'Fewer means faster; more means everyone is bought in.',
|
|
103
|
+
choices: choices([['first_ratifier', 'One yes is enough'], ['consent', 'It passes unless someone objects'],
|
|
104
|
+
['majority', 'More than half must say yes'], ['unanimous', 'Everyone who votes must agree']]) }),
|
|
105
|
+
Object.freeze({ id: 'strangers', group: 'How decisions go', ask: 'Do you expect people you have not met to show up and contribute?',
|
|
106
|
+
hint: 'Whether newcomers should get a say straight away or earn it first.',
|
|
107
|
+
choices: choices([['yes', 'Yes'], ['no', 'No, it is people I know']]) }),
|
|
108
|
+
Object.freeze({ id: 'answers_to', group: 'How decisions go', ask: 'Is there anyone outside the project it answers to: funders, a mission, a legal entity?',
|
|
109
|
+
hint: 'Something the project must stay true to, whoever is building it.',
|
|
110
|
+
choices: choices([['yes', 'Yes'], ['no', 'No']]) }),
|
|
111
|
+
Object.freeze({ id: 'step_back', group: 'How decisions go', ask: 'Do you want to be able to step back later and hand this over?',
|
|
112
|
+
hint: 'Whether the project should keep deciding well without you.',
|
|
113
|
+
choices: choices([['yes', 'Yes'], ['no', 'No, I plan to stay at the helm']]) }),
|
|
114
|
+
Object.freeze({ id: 'speed', group: 'How decisions go', ask: 'Would you rather decide fast, or have everyone bought in?',
|
|
115
|
+
hint: 'When the two pull apart, which wins.',
|
|
116
|
+
choices: choices([['fast', 'Decide fast'], ['bought_in', 'Everyone bought in']]) }),
|
|
117
|
+
Object.freeze({ id: 'trust_level', group: 'Principles', ask: 'How much should people be trusted to work without checking in first?',
|
|
118
|
+
hint: 'This shapes how strict the rules for the project\'s AI agents are.',
|
|
119
|
+
choices: choices([['a_lot', 'A lot: let them get on with it'], ['some', 'Some: a few rules everyone follows'],
|
|
120
|
+
['little', 'Little: tight rules, checked often']]) }),
|
|
121
|
+
Object.freeze({ id: 'catch_or_fix', group: 'Principles', ask: 'When something goes wrong, would you rather have caught it early, or move fast and fix it?',
|
|
122
|
+
hint: 'Neither is wrong. It sets how much is checked before work goes ahead.',
|
|
123
|
+
choices: choices([['catch_early', 'Catch it early'], ['move_fast', 'Move fast and fix it']]) }),
|
|
124
|
+
Object.freeze({ id: 'leave_behaviour', group: 'Principles', ask: 'What behaviour would make you ask someone to leave?',
|
|
125
|
+
hint: 'The line nobody crosses. It becomes the heart of the code of conduct.' }),
|
|
126
|
+
Object.freeze({ id: 'feel', group: 'Principles', ask: 'What do you want this project to feel like to work on?',
|
|
127
|
+
hint: 'A few words: calm, fast, careful, playful, serious.' }),
|
|
128
|
+
Object.freeze({ id: 'write_down', group: 'Principles', ask: 'Is there anything you want written down now, so it is not argued about later?',
|
|
129
|
+
hint: 'A rule, a promise or a boundary. It goes into the bylaws as you wrote it.' }),
|
|
130
|
+
]),
|
|
86
131
|
strategize: Object.freeze([
|
|
87
132
|
Object.freeze({ id: 'big_pieces', ask: 'What are the big pieces, and in what order do they get built?',
|
|
88
133
|
hint: 'Three to six pieces is plenty. Say why that order: what has to exist before what.' }),
|
|
@@ -116,6 +161,14 @@ function checkAnswer(stage, questionId, body = {}) {
|
|
|
116
161
|
const words = text(b.text);
|
|
117
162
|
if (words.length > LIMITS.answer) return { ok: false, reason: 'answer_too_long' };
|
|
118
163
|
if (state === 'answered' && !words) return { ok: false, reason: 'empty_answer' };
|
|
164
|
+
// A closed question's answer is one of its keys (or its label, read back as the key),
|
|
165
|
+
// so the recommender reading it never has to guess at prose.
|
|
166
|
+
const q = questionFor(stage, questionId);
|
|
167
|
+
if (state === 'answered' && q.choices) {
|
|
168
|
+
const hit = q.choices.find((c) => c.key === words || c.label.toLowerCase() === words.toLowerCase());
|
|
169
|
+
if (!hit) return { ok: false, reason: 'not_a_choice', expected: q.choices.map((c) => c.key) };
|
|
170
|
+
return { ok: true, value: { state, answer_md: hit.key, origin } };
|
|
171
|
+
}
|
|
119
172
|
return { ok: true, value: { state, answer_md: words || null, origin } };
|
|
120
173
|
}
|
|
121
174
|
|
|
@@ -130,6 +183,7 @@ function checkLink(url) {
|
|
|
130
183
|
|
|
131
184
|
// ── pure: the view ────────────────────────────────────────────────────────────
|
|
132
185
|
|
|
186
|
+
const labelOf = (q, key) => { const c = (q.choices || []).find((x) => x.key === key); return c ? c.label : null; };
|
|
133
187
|
const iso = (v) => (v ? new Date(v).toISOString() : null);
|
|
134
188
|
|
|
135
189
|
// shapeStage(stage, work, answers) → one stage's record as every door sees it.
|
|
@@ -141,8 +195,11 @@ function shapeStage(stage, work, answers = []) {
|
|
|
141
195
|
const a = byId.get(q.id);
|
|
142
196
|
return {
|
|
143
197
|
id: q.id, ask: q.ask, hint: q.hint, group: q.group || null,
|
|
198
|
+
choices: q.choices || null,
|
|
144
199
|
answer: a ? {
|
|
145
200
|
state: a.state, text: a.answer_md || null, origin: a.origin,
|
|
201
|
+
// a closed answer's words, for a reader who should not see the key
|
|
202
|
+
choice_label: q.choices && a.state === 'answered' ? labelOf(q, a.answer_md) : null,
|
|
146
203
|
// A carried-over answer is shown as such until the founder makes it theirs.
|
|
147
204
|
carried_over: a.origin === 'carried_over',
|
|
148
205
|
updated_at: iso(a.updated_at),
|
|
@@ -190,7 +247,7 @@ function contextMarkdown(stages) {
|
|
|
190
247
|
out.push(`## ${s.stage[0].toUpperCase()}${s.stage.slice(1)}`);
|
|
191
248
|
for (const q of said) {
|
|
192
249
|
const a = q.answer;
|
|
193
|
-
const body = a.state === 'answered' ? a.text : `${STATE_WORDS[a.state]}${a.text ? ` — ${a.text}` : ''}`;
|
|
250
|
+
const body = a.state === 'answered' ? (a.choice_label || a.text) : `${STATE_WORDS[a.state]}${a.text ? ` — ${a.text}` : ''}`;
|
|
194
251
|
out.push(`- **${q.ask}**${ORIGIN_WORDS[a.origin] || ''}\n ${String(body).replace(/\n/g, '\n ')}`);
|
|
195
252
|
}
|
|
196
253
|
if (s.page) out.push('', '**In the founder\'s own words:**', '', s.page);
|
|
@@ -223,7 +280,11 @@ function assistPrompt(stage) {
|
|
|
223
280
|
];
|
|
224
281
|
if (qs.length) {
|
|
225
282
|
lines.push('The questions (the id in brackets is what you record the answer under):');
|
|
226
|
-
qs.forEach((q, i) =>
|
|
283
|
+
qs.forEach((q, i) => {
|
|
284
|
+
lines.push(`${i + 1}. [${q.id}] ${q.ask} (${q.hint})`);
|
|
285
|
+
// A closed question records one of its keys, never prose.
|
|
286
|
+
if (q.choices) lines.push(` Record one of: ${q.choices.map((c) => `${c.key} (${c.label})`).join(' · ')}`);
|
|
287
|
+
});
|
|
227
288
|
lines.push('');
|
|
228
289
|
}
|
|
229
290
|
lines.push('To see what I have already said: npx bongos genesis show ' + stage);
|
|
@@ -316,7 +377,43 @@ async function readAll(deps = {}) {
|
|
|
316
377
|
async function readStage(stage, deps = {}) {
|
|
317
378
|
if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
|
|
318
379
|
const all = await readAll(deps);
|
|
319
|
-
|
|
380
|
+
const work = all.find((s) => s.stage === stage);
|
|
381
|
+
if (stage === 'charter') work.advice = await charterAdvice(work, deps);
|
|
382
|
+
return { ok: true, work };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// charterAdvice(work, deps) — THE CHARTER STAGE'S RECOMMENDATION (task 1005119): the
|
|
386
|
+
// founder's answers, handed to the government module's one recommender through its
|
|
387
|
+
// port. Closed answers go as their keys, prose ones as the founder's words; "not sure
|
|
388
|
+
// yet" and skipped are not sent, so the recommender falls back to what it does
|
|
389
|
+
// without them. The head count is the founder's own expected number from the plan
|
|
390
|
+
// when they gave one. Null with no government module, and null (never a failed read)
|
|
391
|
+
// if the advice cannot be built: the questions still work without it.
|
|
392
|
+
async function charterAdvice(work, deps = {}) {
|
|
393
|
+
try {
|
|
394
|
+
const api = deps.api || require('../../src/module-api');
|
|
395
|
+
const gov = deps.government !== undefined ? deps.government : api.resolveOptional('government');
|
|
396
|
+
if (!gov || typeof gov.charterAdvice !== 'function') return null;
|
|
397
|
+
const answers = {};
|
|
398
|
+
const prose = {};
|
|
399
|
+
for (const q of work.questions) {
|
|
400
|
+
if (!q.answer || q.answer.state !== 'answered') continue;
|
|
401
|
+
(q.choices ? answers : prose)[q.id] = q.answer.text;
|
|
402
|
+
}
|
|
403
|
+
let headcount = null;
|
|
404
|
+
const get = deps.getSetting || (api.projectSettings && api.projectSettings.get);
|
|
405
|
+
if (get) {
|
|
406
|
+
const row = await get('founding.plan');
|
|
407
|
+
try {
|
|
408
|
+
const v = row && row.value;
|
|
409
|
+
const plan = typeof v === 'string' ? JSON.parse(v) : v;
|
|
410
|
+
headcount = (plan && plan.expected_builders) || null;
|
|
411
|
+
} catch (_) { headcount = null; }
|
|
412
|
+
}
|
|
413
|
+
return await gov.charterAdvice({ answers, prose, headcount });
|
|
414
|
+
} catch (_) {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
320
417
|
}
|
|
321
418
|
|
|
322
419
|
async function setDoor(stage, door, by, deps = {}) {
|
|
@@ -15,7 +15,7 @@ const work = require('../genesis-work');
|
|
|
15
15
|
|
|
16
16
|
const REFUSALS = {
|
|
17
17
|
unknown_stage: 404, unknown_question: 404, bad_door: 400, bad_state: 400, bad_origin: 400,
|
|
18
|
-
empty_answer: 400, answer_too_long: 400, page_too_long: 400, link_too_long: 400, bad_link: 400,
|
|
18
|
+
empty_answer: 400, not_a_choice: 400, answer_too_long: 400, page_too_long: 400, link_too_long: 400, bad_link: 400,
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
// `deps` is for tests only (an in-memory store); production passes none.
|
|
@@ -25,7 +25,7 @@ module.exports = function mountGenesisWorkRoutes(router, deps = {}) {
|
|
|
25
25
|
try {
|
|
26
26
|
const r = await fn(req);
|
|
27
27
|
if (r && r.ok === false) {
|
|
28
|
-
return res.status(REFUSALS[r.reason] || 409).json({ error: { code: r.reason, message: r.reason } });
|
|
28
|
+
return res.status(REFUSALS[r.reason] || 409).json({ error: { code: r.reason, message: r.reason, ...(r.expected ? { expected: r.expected } : {}) } });
|
|
29
29
|
}
|
|
30
30
|
res.set('Cache-Control', 'no-store');
|
|
31
31
|
res.json(r);
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"maintainer": "Cloud Bongos maintainers",
|
|
13
13
|
"maintenance": { "status": "core-maintained" },
|
|
14
14
|
"contributes": {
|
|
15
|
-
"routes": ["sso", "sso-invites", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search", "community-leaderboard", "guilds", "guilds-public"],
|
|
15
|
+
"routes": ["sso", "sso-invites", "sso-app-status", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search", "community-leaderboard", "guilds", "guilds-public"],
|
|
16
16
|
"migrations": true,
|
|
17
17
|
"pollers": ["code-sweep", "visibility-pull", "guild-totals"]
|
|
18
18
|
},
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// modules/platform-identity/routes/sso-app-status.js — a hosted project asks the hub where
|
|
2
|
+
// its own APP runs (task 1004571). Server-to-server, authenticated by client_id +
|
|
3
|
+
// client_secret like the membership check-in and the invite notices; the asker is core's
|
|
4
|
+
// readAppStatusFromHub (src/bongos/auth-admission.js), and its hall shows the answer in the
|
|
5
|
+
// options menu.
|
|
6
|
+
//
|
|
7
|
+
// POST /sso/app-status { client_id, client_secret } → { ok, known, app, sandbox }
|
|
8
|
+
//
|
|
9
|
+
// A PROJECT READS ITS OWN ROW, NEVER ANOTHER'S. The project is the authenticated client
|
|
10
|
+
// itself, named by both halves of the ADR 0246 projection (client_id = slug at the client's
|
|
11
|
+
// registered origin) — nothing in the body can name a different one. The row and its
|
|
12
|
+
// app-status reading belong to the provisioning module, so this asks through the
|
|
13
|
+
// `provisioning.projectAppStatus` port; a hub without provisioning answers `known: false`.
|
|
14
|
+
//
|
|
15
|
+
// WHAT IT CARRIES is app-status.js's shape as is: the project is already trusted with its
|
|
16
|
+
// own setup (it is the project's own hall asking). Its hall decides, per viewer, what an
|
|
17
|
+
// owner sees beyond the state. Nothing here calls Render.
|
|
18
|
+
//
|
|
19
|
+
// WIRE SKEW (ADR 0352): a NEW route, so an older hub answers 404 and the hall says it could
|
|
20
|
+
// not ask. The body is validated strictly, so a field added later must ride a header, or ship
|
|
21
|
+
// on the hub and be released before any instance sends it.
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const express = require('express');
|
|
25
|
+
const api = require('../../../src/module-api');
|
|
26
|
+
const pi = require('../platform-identity');
|
|
27
|
+
|
|
28
|
+
const log = api.logger('platform-identity');
|
|
29
|
+
const { pool, validateOrRespond } = api;
|
|
30
|
+
|
|
31
|
+
/** The host a client's registered origin names, lowercased; null for no usable origin. */
|
|
32
|
+
function originHost(origin) {
|
|
33
|
+
try { return new URL(String(origin || '')).host.toLowerCase() || null; } catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = function ssoAppStatusRoutes({ resolvePort = (name) => api.resolveOptional(name), db = pool } = {}) {
|
|
37
|
+
const router = express.Router();
|
|
38
|
+
|
|
39
|
+
// rank: public — authenticated by client_id + client_secret, not a builder rank.
|
|
40
|
+
router.post('/sso/app-status', async (req, res) => {
|
|
41
|
+
if (validateOrRespond(req, res, {
|
|
42
|
+
client_id: { required: true, type: 'string', maxLength: 200 },
|
|
43
|
+
client_secret: { required: true, type: 'string', maxLength: 512 },
|
|
44
|
+
}, { strict: true })) return undefined;
|
|
45
|
+
const { client_id, client_secret } = req.body;
|
|
46
|
+
try {
|
|
47
|
+
const client = await pi.getClient(client_id, { pool: db });
|
|
48
|
+
if (!client || client.status !== 'active' || !pi.clientSecretValid(client, client_secret)) {
|
|
49
|
+
return res.fail('invalid_client', 401);
|
|
50
|
+
}
|
|
51
|
+
const read = resolvePort('provisioning.projectAppStatus');
|
|
52
|
+
const host = originHost(client.origin);
|
|
53
|
+
const found = typeof read === 'function' && host ? await read({ slug: client.client_id, host }) : null;
|
|
54
|
+
if (!found) return res.json({ ok: true, known: false, app: null, sandbox: null });
|
|
55
|
+
return res.json({ ok: true, known: true, app: found.app || null, sandbox: found.sandbox || null });
|
|
56
|
+
} catch (err) {
|
|
57
|
+
log.error('[platform-identity] /sso/app-status', err && err.message);
|
|
58
|
+
return res.fail('app_status_failed', { status: 500, message: 'internal error' });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return router;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
module.exports.originHost = originHost;
|