@bongos/core 1.21.124 → 1.21.126
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 +91 -41
- package/bin/bongos.js +9 -0
- package/docs/api/openapi.json +4 -4
- package/docs/copy-inventory.md +128 -126
- package/docs/copy-registry.json +188 -170
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +152 -143
- package/modules/hall-ui/public/goal-forest-lib.js +36 -0
- package/modules/hall-ui/public/goal-forest-render.js +50 -10
- package/modules/hall-ui/public/goals-page.js +19 -22
- package/modules/hall-ui/public/goals-render.js +6 -16
- package/modules/hall-ui/public/goals.css +13 -4
- package/modules/hall-ui/public/goals.html +22 -24
- package/modules/hall-ui/public/goals.states.json +19 -1
- package/modules/hall-ui/records/goal-dependency-forest.md +4 -3
- package/modules/onboarding/howto-guide.js +259 -0
- package/modules/platform-identity/invite-authz.js +1 -1
- package/modules/provisioning/catalog-bridge.js +1 -1
- package/modules/provisioning/provisioning.js +1 -1
- package/modules/provisioning/route-helpers.js +50 -0
- package/modules/provisioning/routes/disconnect.js +6 -5
- package/modules/provisioning/routes/env-manifest.js +11 -27
- package/modules/provisioning/routes/provisioning.js +58 -75
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/gds/core-pin-sync.js +233 -0
- package/scripts/gds/module-assess-tests.js +40 -11
- package/scripts/gds/module-assess-version.js +10 -2
- package/scripts/gds/module-test-runner/README.md +44 -0
- package/scripts/gds/module-test-runner/module-tests.yml +87 -0
- package/scripts/gds/module-test-runner.js +358 -0
- package/src/module-api.js +1 -1
- package/tests/core_pin_sync.mjs +237 -0
- package/tests/goal_forest_lib.mjs +28 -0
- package/tests/goal_forest_page.mjs +106 -11
- package/tests/howto_guide.mjs +96 -0
- package/tests/module_assess_price_parity.mjs +2 -2
- package/tests/module_assess_score.mjs +13 -3
- package/tests/module_test_runner.mjs +294 -0
- package/tests/project_catalog_routes.mjs +1 -1
- package/tests/provisioning_module_selection.mjs +1 -1
- package/tests/provisioning_project_owner_gate.mjs +271 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// modules/onboarding/howto-guide.js — the in-session how-to guide: ~25 short steps an AI walks a person through (task 1004627)
|
|
2
|
+
//
|
|
3
|
+
// WHY. The owner opened Bongos on their own project and did not know where to begin:
|
|
4
|
+
// nothing told them `/builder-start` exists, what a goal is for, or what happens on a
|
|
5
|
+
// first ship. This guide is the CONTENT an AI assistant walks a person through inside
|
|
6
|
+
// their own session, one short step at a time, at the moment it is useful — so someone
|
|
7
|
+
// who has never heard of a Bongos command is never lost.
|
|
8
|
+
//
|
|
9
|
+
// It is the single source of truth for the three tasks that build on it:
|
|
10
|
+
// • task 1004634 stores each person's read/unread state per step id;
|
|
11
|
+
// • task 1004635 shows the right unread step when its moment arrives, then marks it read;
|
|
12
|
+
// • task 1004636 lets a person reopen the guide, see progress, or skip it.
|
|
13
|
+
// So the step ids are STABLE: a stored "read" mark names one. Never rename or reuse an
|
|
14
|
+
// id — retire a step by deleting it and add a new id for new content
|
|
15
|
+
// (tests/howto_guide.mjs pins the shape and the plain-language rule).
|
|
16
|
+
//
|
|
17
|
+
// Each step is: what the feature is FOR (`what`), WHEN to reach for it (`when`), and the
|
|
18
|
+
// exact words or command to TRY (`try`). Plain language throughout — the reader may not
|
|
19
|
+
// be technical; the test bans the accessibility scorer's jargon list. Commands are the
|
|
20
|
+
// ones an instance actually gets (core skills + `npx bongos` verbs); a step names a
|
|
21
|
+
// slash command AND the plain words that reach it, because typed-only commands do not
|
|
22
|
+
// answer to plain words.
|
|
23
|
+
//
|
|
24
|
+
// Read it in a terminal: `node modules/onboarding/howto-guide.js` (add `--moment <id>`).
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
// The moments, in the order a new project meets them. `trigger` says, for task 1004635,
|
|
29
|
+
// what event opens the moment; it is descriptive here, not wiring.
|
|
30
|
+
const MOMENTS = Object.freeze([
|
|
31
|
+
{ id: 'first_open', title: 'Opening the project for the first time', trigger: 'the first prompt of the first session in a project folder' },
|
|
32
|
+
{ id: 'setup', title: 'Setting up the project', trigger: 'the project has no goal yet, or setup is unfinished' },
|
|
33
|
+
{ id: 'goal', title: 'Creating a goal', trigger: 'the person asks to plan work, or the project has no open goal' },
|
|
34
|
+
{ id: 'task', title: 'Creating a task', trigger: 'the person describes a change they want, or a goal has no tasks' },
|
|
35
|
+
{ id: 'find_work', title: 'Finding work', trigger: 'the person asks what to do next, or starts a session with nothing claimed' },
|
|
36
|
+
{ id: 'first_claim', title: 'Claiming your first task', trigger: 'the person claims a task for the first time' },
|
|
37
|
+
{ id: 'first_ship', title: 'Your first ship', trigger: 'the person finishes the work on their first claimed task' },
|
|
38
|
+
{ id: 'after_ship', title: 'After a ship', trigger: 'a task reaches shipped for the first time' },
|
|
39
|
+
{ id: 'invite', title: 'Inviting people', trigger: 'the person mentions a teammate, or asks how others can help' },
|
|
40
|
+
{ id: 'settings', title: 'Settings', trigger: 'the person asks to change how Bongos behaves for them or the project' },
|
|
41
|
+
{ id: 'stuck', title: 'When something goes wrong', trigger: 'a command fails, or the person says they are stuck or confused' },
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const STEPS = Object.freeze([
|
|
45
|
+
// first_open
|
|
46
|
+
{
|
|
47
|
+
id: 'welcome', moment: 'first_open', title: 'What Bongos is',
|
|
48
|
+
what: 'Bongos keeps the list of everything your project needs, and the AI does the building. You decide what gets made; the AI does the work and records it.',
|
|
49
|
+
when: 'The first time you open this project with an AI assistant.',
|
|
50
|
+
try: 'Ask: "What is Bongos and what can it do for me?"',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'where_to_start', moment: 'first_open', title: 'Where to start',
|
|
54
|
+
what: 'There are three ways in: finish setting up the project, make a goal (a bigger outcome you want), or make a task (one piece of work).',
|
|
55
|
+
when: 'Any time you are not sure what to do first.',
|
|
56
|
+
try: 'Say: "Where should I start?" The AI will look at the project and suggest one of the three.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'ask_in_plain_words', moment: 'first_open', title: 'Just ask in plain words',
|
|
60
|
+
what: 'You never need to memorise commands. Describe what you want and the AI picks the right one, then tells you which it used.',
|
|
61
|
+
when: 'Whenever you want something done and do not know the command.',
|
|
62
|
+
try: 'Say: "I want to add a contact form to the website."',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'the_hall', moment: 'first_open', title: 'The hall: your project in a browser',
|
|
66
|
+
what: 'The hall is your project\'s web page. It shows goals, tasks, who is working on what, and what has shipped.',
|
|
67
|
+
when: 'When you want to see everything at a glance instead of asking.',
|
|
68
|
+
try: 'Ask: "Open the hall for this project" or "What is the hall link?"',
|
|
69
|
+
},
|
|
70
|
+
// setup
|
|
71
|
+
{
|
|
72
|
+
id: 'sign_in', moment: 'setup', title: 'Sign in once',
|
|
73
|
+
what: 'Signing in links this computer to your project, so the AI can claim and ship work in your name.',
|
|
74
|
+
when: 'The first time on a new computer, or when the AI says your sign-in has expired.',
|
|
75
|
+
try: 'Type /builder-setup, or in a terminal run `npx bongos login`.',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'check_setup', moment: 'setup', title: 'Check everything is ready',
|
|
79
|
+
what: 'A quick health check of this computer and your sign-in, with a plain fix for anything missing.',
|
|
80
|
+
when: 'Before your first task, or when something that used to work stops working.',
|
|
81
|
+
try: 'Say: "Check my setup", or run `npx bongos doctor`.',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'project_status', moment: 'setup', title: 'See where the project stands',
|
|
85
|
+
what: 'A summary of the current version: what is done, what is left, and what is blocking.',
|
|
86
|
+
when: 'At the start of a working session, or before planning.',
|
|
87
|
+
try: 'Type /status, or say "How far along is the project?"',
|
|
88
|
+
},
|
|
89
|
+
// goal
|
|
90
|
+
{
|
|
91
|
+
id: 'what_is_a_goal', moment: 'goal', title: 'What a goal is',
|
|
92
|
+
what: 'A goal is a bigger outcome, like "people can sign up" — a home for the tasks that get you there, with a clear "done when".',
|
|
93
|
+
when: 'When what you want is too big to finish in one sitting.',
|
|
94
|
+
try: 'Ask: "Should this be a goal or a single task?"',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'create_goal', moment: 'goal', title: 'Create a goal',
|
|
98
|
+
what: 'The AI asks what the goal is for and what "done" looks like, then writes it up with its first few tasks.',
|
|
99
|
+
when: 'When you have decided on a new outcome to work towards.',
|
|
100
|
+
try: 'Type /goal-create, or say "Make a goal for letting people sign up."',
|
|
101
|
+
},
|
|
102
|
+
// task
|
|
103
|
+
{
|
|
104
|
+
id: 'what_is_a_task', moment: 'task', title: 'What a task is',
|
|
105
|
+
what: 'A task is one piece of work small enough to finish and check in one go. Every change to the project belongs to a task, so there is always a record of it.',
|
|
106
|
+
when: 'Whenever you want something changed.',
|
|
107
|
+
try: 'Ask: "What makes a good task?"',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: 'create_task', moment: 'task', title: 'Create a task',
|
|
111
|
+
what: 'Describe the change and how you will know it is done. The AI writes it up and files it under the right goal.',
|
|
112
|
+
when: 'When you know a specific change you want.',
|
|
113
|
+
try: 'Say: "Make a task: the contact form should email me each message."',
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: 'capture_an_idea', moment: 'task', title: 'Save an idea for later',
|
|
117
|
+
what: 'Not ready to commit to it? Save it as an idea. Ideas wait in a list until you decide to turn them into tasks or drop them.',
|
|
118
|
+
when: 'When a thought comes up in the middle of other work.',
|
|
119
|
+
try: 'Say: "Save an idea: a dark mode for the website."',
|
|
120
|
+
},
|
|
121
|
+
// find_work
|
|
122
|
+
{
|
|
123
|
+
id: 'whats_claimable', moment: 'find_work', title: 'See what you can work on',
|
|
124
|
+
what: 'A list of the tasks that are ready right now and safe to start without clashing with anyone else\'s work.',
|
|
125
|
+
when: 'At the start of a session, or when you finish a task.',
|
|
126
|
+
try: 'Type /builder-start, or say "What can I work on?"',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'work_a_goal', moment: 'find_work', title: 'Work through a whole goal',
|
|
130
|
+
what: 'The AI takes the goal\'s tasks one at a time — start, build, ship, next — and stops to ask you only when it needs a decision.',
|
|
131
|
+
when: 'When a goal has several tasks that follow on from each other.',
|
|
132
|
+
try: 'Say: "Work through goal 12 end to end", or type /builder-sequence.',
|
|
133
|
+
},
|
|
134
|
+
// first_claim
|
|
135
|
+
{
|
|
136
|
+
id: 'claim_a_task', moment: 'first_claim', title: 'Claiming a task',
|
|
137
|
+
what: 'Claiming says "I am on this one", so nobody else starts the same work. The AI then works on it in its own separate copy of the project, so your main folder stays tidy.',
|
|
138
|
+
when: 'When you pick a task to work on.',
|
|
139
|
+
try: 'Type /builder-claim followed by the task number, or say "Claim task 42."',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'give_a_task_back', moment: 'first_claim', title: 'Give a task back',
|
|
143
|
+
what: 'Changed your mind? Giving it back makes the task available again. Nothing is lost and nothing is charged.',
|
|
144
|
+
when: 'When you will not finish a task you claimed.',
|
|
145
|
+
try: 'Type /builder-release, or say "Give this task back."',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: 'preview_before_ship', moment: 'first_claim', title: 'See a change before it ships',
|
|
149
|
+
what: 'For changes you can see, the AI can show you the page as it will look, before anything goes live.',
|
|
150
|
+
when: 'When the task changes something on screen.',
|
|
151
|
+
try: 'Say: "Let me see it before you ship", or type /builder-stage.',
|
|
152
|
+
},
|
|
153
|
+
// first_ship
|
|
154
|
+
{
|
|
155
|
+
id: 'what_ship_does', moment: 'first_ship', title: 'What shipping does',
|
|
156
|
+
what: 'Shipping finishes a task: automatic reviewers check the work, then it is added to the project. Going live for your users can wait for a person to say yes.',
|
|
157
|
+
when: 'When the work on your task is finished.',
|
|
158
|
+
try: 'Type /builder-ship, or say "Ship it."',
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: 'review_findings', moment: 'first_ship', title: 'When the reviewers ask for fixes',
|
|
162
|
+
what: 'If the reviewers find a problem, the task is held, not lost. The AI fixes what they listed and asks for another review.',
|
|
163
|
+
when: 'When a ship says it needs fixes.',
|
|
164
|
+
try: 'Say: "Fix what the reviewers found and try again."',
|
|
165
|
+
},
|
|
166
|
+
// after_ship
|
|
167
|
+
{
|
|
168
|
+
id: 'what_happens_after', moment: 'after_ship', title: 'What happens after a ship',
|
|
169
|
+
what: 'The task is marked shipped, anything that was waiting on it becomes ready, and the record of the work is kept for the next person.',
|
|
170
|
+
when: 'Right after your first task ships.',
|
|
171
|
+
try: 'Ask: "What did that ship unlock?"',
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: 'fresh_session', moment: 'after_ship', title: 'Start fresh after a few tasks',
|
|
175
|
+
what: 'A long conversation gets slower and costlier. After a few tasks, the AI can write a short note so a fresh conversation picks up exactly where you left off.',
|
|
176
|
+
when: 'After three or so tasks in one conversation.',
|
|
177
|
+
try: 'Say: "Hand this off to a fresh session", or type /session-handoff.',
|
|
178
|
+
},
|
|
179
|
+
// invite
|
|
180
|
+
{
|
|
181
|
+
id: 'invite_people', moment: 'invite', title: 'Invite people',
|
|
182
|
+
what: 'Bring someone onto the project by their GitHub name. You choose whether anyone can join, people must ask first, or only invited people can sign in.',
|
|
183
|
+
when: 'When you want someone else to help build.',
|
|
184
|
+
try: 'In the hall, use "Invite someone" and enter their GitHub name, or ask "How do I invite someone?"',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: 'ask_for_help', moment: 'invite', title: 'Ask a teammate for help',
|
|
188
|
+
what: 'Send a named person, or anyone with the right skills, a request for help on a task. It shows up in their own list.',
|
|
189
|
+
when: 'When you are stuck on a task and someone else knows the area.',
|
|
190
|
+
try: 'Say: "Ask Sam to help with task 42", or type /ask-for-help.',
|
|
191
|
+
},
|
|
192
|
+
// settings
|
|
193
|
+
{
|
|
194
|
+
id: 'ship_mode', moment: 'settings', title: 'Ship mode: who starts a ship',
|
|
195
|
+
what: 'In Auto, the AI ships a finished task by itself. In Manual, it stops and waits for you to say "ship it".',
|
|
196
|
+
when: 'When you want more, or less, control over when work is added to the project.',
|
|
197
|
+
try: 'In the hall\'s project settings pick Auto or Manual, or run `npx bongos ship-mode` to see the current one.',
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 'how_the_ai_talks', moment: 'settings', title: 'How the AI talks to you',
|
|
201
|
+
what: 'Tell Bongos how technical you are and how long you like answers. Every AI session on this project follows it.',
|
|
202
|
+
when: 'When answers feel too technical, too long, or too short.',
|
|
203
|
+
try: 'Open your settings in the hall and look for how the AI talks to you, or say "Keep your answers shorter."',
|
|
204
|
+
},
|
|
205
|
+
// stuck
|
|
206
|
+
{
|
|
207
|
+
id: 'when_stuck', moment: 'stuck', title: 'When something goes wrong',
|
|
208
|
+
what: 'Every refusal comes with a plain explanation: what happened, what the AI will do, and whether it needs you. Most fix themselves.',
|
|
209
|
+
when: 'When a command fails or you do not understand what happened.',
|
|
210
|
+
try: 'Say: "Explain that in plain words and fix it."',
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: 'tell_the_bongos_team', moment: 'stuck', title: 'Tell the Bongos team',
|
|
214
|
+
what: 'If Bongos itself gets in your way, say so. Your words go to the people who build Bongos, and each complaint becomes a fix.',
|
|
215
|
+
when: 'Whenever Bongos is confusing, slow, or broken.',
|
|
216
|
+
try: 'Say: "Bongos feedback: …" followed by what went wrong.',
|
|
217
|
+
},
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
const MOMENT_IDS = new Set(MOMENTS.map((m) => m.id));
|
|
221
|
+
|
|
222
|
+
function stepById(id) { return STEPS.find((s) => s.id === id) || null; }
|
|
223
|
+
|
|
224
|
+
// The steps for one moment, in guide order. Unknown moment → [].
|
|
225
|
+
function stepsForMoment(momentId) { return STEPS.filter((s) => s.moment === momentId); }
|
|
226
|
+
|
|
227
|
+
// The first step not in `readIds` (an iterable of step ids), in guide order — what
|
|
228
|
+
// "continue the tutorial" means. null when everything is read.
|
|
229
|
+
function nextUnread(readIds) {
|
|
230
|
+
const read = new Set(readIds || []);
|
|
231
|
+
return STEPS.find((s) => !read.has(s.id)) || null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function formatStep(step) {
|
|
235
|
+
return [`${step.title} [${step.id}]`, ` What it is for: ${step.what}`, ` When: ${step.when}`, ` Try: ${step.try}`].join('\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function formatGuide({ moment = null } = {}) {
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const m of MOMENTS) {
|
|
241
|
+
if (moment && m.id !== moment) continue;
|
|
242
|
+
out.push(`## ${m.title}`);
|
|
243
|
+
for (const s of stepsForMoment(m.id)) out.push(formatStep(s), '');
|
|
244
|
+
}
|
|
245
|
+
return out.join('\n').trimEnd() + '\n';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (require.main === module) {
|
|
249
|
+
const i = process.argv.indexOf('--moment');
|
|
250
|
+
const moment = i > -1 ? process.argv[i + 1] : null;
|
|
251
|
+
if (moment && !MOMENT_IDS.has(moment)) {
|
|
252
|
+
process.stderr.write(`unknown moment "${moment}" — one of: ${[...MOMENT_IDS].join(', ')}\n`);
|
|
253
|
+
process.exitCode = 2;
|
|
254
|
+
} else {
|
|
255
|
+
process.stdout.write(formatGuide({ moment }));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = { MOMENTS, STEPS, MOMENT_IDS, stepById, stepsForMoment, nextUnread, formatStep, formatGuide };
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// .owner_github_id` and `platform_identity_projects.owner_github_id` are ATTRIBUTION, not
|
|
19
19
|
// authority — their own migration says so and ADR 0238 restates it. The authority is
|
|
20
20
|
// `provisioning_instances.owner_builder_id`, the fact the manage view already trusts and
|
|
21
|
-
// `
|
|
21
|
+
// `requireProjectOwner` already enforces. This module may not import provisioning (ADR 0083's
|
|
22
22
|
// one-way rule), so the fact travels on the kernel port `provisioning.projectOwnedBy` —
|
|
23
23
|
// resolveOptional and NOT declared in `consumes` (the `provisioning.projectPlanets`
|
|
24
24
|
// precedent): provisioning is default-off, and a hub running without it simply has no
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// re-pointed address), which is the property a once-at-standup write can never have.
|
|
13
13
|
//
|
|
14
14
|
// IT GRANTS NOTHING. The catalog row is attribution (ADR 0238): ownership stays
|
|
15
|
-
// `provisioning_instances.owner_builder_id`, which is what `
|
|
15
|
+
// `provisioning_instances.owner_builder_id`, which is what `requireProjectOwner` already
|
|
16
16
|
// enforces on every route that calls this. The projection carries the owner's github id only
|
|
17
17
|
// so the hub can attribute the row and claim-check its own writes.
|
|
18
18
|
'use strict';
|
|
@@ -1402,7 +1402,7 @@ async function instanceStateForSlug(db, slug) {
|
|
|
1402
1402
|
// control-plane project a hub catalog row names? Lets a hub surface own-scope a project it
|
|
1403
1403
|
// did not create — without importing this module (ADR 0083) and without promoting the hub's
|
|
1404
1404
|
// own `owner_github_id` columns, which are attribution and never authority (ADR 0238). The
|
|
1405
|
-
// fact returned is the one `
|
|
1405
|
+
// fact returned is the one `requireProjectOwner` already enforces on every owner-facing
|
|
1406
1406
|
// route here: `provisioning_instances.owner_builder_id`.
|
|
1407
1407
|
//
|
|
1408
1408
|
// BOTH HALVES OF THE PROJECTION MUST AGREE, which is why this takes a slug AND a host rather
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// modules/provisioning/route-helpers.js — the one owner-or-archon gate every own-scoped
|
|
2
|
+
// /provisioning/instances/:id route mounts (task 1004159, ADR 0328 decision 2). It replaced
|
|
3
|
+
// three hand-written copies of the same rule, and the three routes that had only the owner
|
|
4
|
+
// half of it (teardown, domain, github-app).
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const api = require('../../src/module-api');
|
|
8
|
+
const { failFrom } = require('./public-refusal');
|
|
9
|
+
|
|
10
|
+
const log = api.logger('provisioning');
|
|
11
|
+
|
|
12
|
+
function isProjectOwner(inst, builder) {
|
|
13
|
+
return String(inst.owner_builder_id) === String(builder.id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The audit actor for a request the gate admitted: the owner acts as themselves, and an
|
|
17
|
+
// archon on someone else's project is named, so the override never reads as the owner's act.
|
|
18
|
+
function projectActor(req) {
|
|
19
|
+
return isProjectOwner(req.project, req.builder) ? 'api:self' : `api:archon:${req.builder.id}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// requireProjectOwner(loadFn, { archonOverride }) — mounted after requireBuilder, and spelled
|
|
23
|
+
// in the route's own middleware list so route-rank-check.js can see it (ADR 0328 §4). Loads
|
|
24
|
+
// the row with the caller's loader (`loadFn(id)`) and admits its owner or, by default, an
|
|
25
|
+
// archon. That is a plain RANK test on purpose: `provisioning.fleet.manage` floors at metic
|
|
26
|
+
// (ADR 0157), so gating on it would widen every route it guards.
|
|
27
|
+
//
|
|
28
|
+
// A missing row and someone else's row are the same 404, and the gate runs before the
|
|
29
|
+
// handler's body validator, so a stranger cannot tell a real id from a malformed request.
|
|
30
|
+
// The loaded row rides on `req.project`.
|
|
31
|
+
function requireProjectOwner(loadFn, { archonOverride = true } = {}) {
|
|
32
|
+
return async function projectOwnerGate(req, res, next) {
|
|
33
|
+
const id = api.parseId(req, res); // sends 400 bad_id + returns null on failure
|
|
34
|
+
if (!id) return;
|
|
35
|
+
let inst;
|
|
36
|
+
try {
|
|
37
|
+
inst = await loadFn(id);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
log.error(`[provisioning] ${req.method} ${req.path} — project load`, err);
|
|
40
|
+
return failFrom(res, err, 'instance_read_failed');
|
|
41
|
+
}
|
|
42
|
+
const admitted = !!inst
|
|
43
|
+
&& (isProjectOwner(inst, req.builder) || (archonOverride && req.builder.rank === 'archon'));
|
|
44
|
+
if (!admitted) return res.fail('instance_not_found', 404);
|
|
45
|
+
req.project = inst;
|
|
46
|
+
return next();
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { requireProjectOwner, projectActor, isProjectOwner };
|
|
@@ -12,8 +12,10 @@ const { pool, validateOrRespond, parseId } = api;
|
|
|
12
12
|
const provisioning = require('../provisioning');
|
|
13
13
|
const disconnect = require('../disconnect');
|
|
14
14
|
const { failFrom } = require('../public-refusal');
|
|
15
|
+
const { requireProjectOwner, projectActor, isProjectOwner } = require('../route-helpers');
|
|
15
16
|
|
|
16
17
|
const log = api.logger('provisioning');
|
|
18
|
+
const loadInstance = (id) => provisioning.getInstanceById(pool, id);
|
|
17
19
|
|
|
18
20
|
module.exports = function buildDisconnectRouter() {
|
|
19
21
|
const router = express.Router();
|
|
@@ -24,19 +26,18 @@ module.exports = function buildDisconnectRouter() {
|
|
|
24
26
|
// this erases a database, so a replayed or scripted request must carry the same proof
|
|
25
27
|
// the button does. No status gate — any state may be disconnected; the queue refuses
|
|
26
28
|
// while another run is open (disconnect.enqueueDisconnect).
|
|
27
|
-
router.post('/provisioning/instances/:id/disconnect', api.requireBuilder, async (req, res) => {
|
|
29
|
+
router.post('/provisioning/instances/:id/disconnect', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
28
30
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
29
31
|
if (!id) return;
|
|
30
32
|
if (validateOrRespond(req, res, { confirm_slug: { required: true, type: 'string', maxLength: 40 } })) return;
|
|
31
33
|
try {
|
|
32
|
-
const inst =
|
|
33
|
-
const isOwner =
|
|
34
|
-
if (!inst || (!isOwner && req.builder.rank !== 'archon')) return res.fail('instance_not_found', 404);
|
|
34
|
+
const inst = req.project;
|
|
35
|
+
const isOwner = isProjectOwner(inst, req.builder);
|
|
35
36
|
if (String(req.body.confirm_slug).trim().toLowerCase() !== inst.slug) {
|
|
36
37
|
return res.fail('confirm_mismatch', { status: 400,
|
|
37
38
|
message: `Type the project's name exactly — ${inst.slug} — to disconnect it. Nothing was changed.` });
|
|
38
39
|
}
|
|
39
|
-
const actor =
|
|
40
|
+
const actor = projectActor(req);
|
|
40
41
|
const r = await disconnect.enqueueDisconnect(pool, id, actor);
|
|
41
42
|
if (r.conflict) {
|
|
42
43
|
return res.fail('intent_conflict', { status: 409,
|
|
@@ -21,26 +21,14 @@
|
|
|
21
21
|
|
|
22
22
|
const express = require('express');
|
|
23
23
|
const api = require('../../../src/module-api');
|
|
24
|
-
const { pool, validateOrRespond
|
|
24
|
+
const { pool, validateOrRespond } = api;
|
|
25
25
|
const provisioning = require('../provisioning');
|
|
26
26
|
const envManifest = require('../env-manifest');
|
|
27
27
|
const { failFrom } = require('../public-refusal');
|
|
28
|
+
const { requireProjectOwner } = require('../route-helpers');
|
|
28
29
|
|
|
29
30
|
const log = api.logger('provisioning');
|
|
30
|
-
|
|
31
|
-
// Resolve the project and decide whether this caller may see it. Returns the instance, or
|
|
32
|
-
// null having already sent the 404 — the same own-or-archon rule as ./disconnect.js.
|
|
33
|
-
async function resolveOwnInstance(req, res) {
|
|
34
|
-
const id = parseId(req, res); // sends 400 bad_id + returns null on failure
|
|
35
|
-
if (!id) return null;
|
|
36
|
-
const inst = await provisioning.getInstanceById(pool, id);
|
|
37
|
-
const isOwner = !!inst && String(inst.owner_builder_id) === String(req.builder.id);
|
|
38
|
-
if (!inst || (!isOwner && req.builder.rank !== 'archon')) {
|
|
39
|
-
res.fail('instance_not_found', 404);
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
return inst;
|
|
43
|
-
}
|
|
31
|
+
const loadInstance = (id) => provisioning.getInstanceById(pool, id);
|
|
44
32
|
|
|
45
33
|
// The `:varId` path segment. parseId reads `:id`, which is the PROJECT here, so the
|
|
46
34
|
// declaration's id is parsed separately. A non-numeric id is a 400 naming the field
|
|
@@ -103,10 +91,9 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
103
91
|
|
|
104
92
|
// GET — the declared surface. This is the route a BUILDER SESSION reads to learn which
|
|
105
93
|
// variables a project has, which is the whole reason the manifest exists.
|
|
106
|
-
router.get('/provisioning/instances/:id/env-manifest', api.requireBuilder, async (req, res) => {
|
|
94
|
+
router.get('/provisioning/instances/:id/env-manifest', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
107
95
|
try {
|
|
108
|
-
const inst =
|
|
109
|
-
if (!inst) return;
|
|
96
|
+
const inst = req.project;
|
|
110
97
|
const vars = await envManifest.listEnvVars(pool, inst.id);
|
|
111
98
|
res.json({
|
|
112
99
|
instance: { id: String(inst.id), slug: inst.slug, hosting_shape: inst.hosting_shape },
|
|
@@ -126,7 +113,7 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
126
113
|
|
|
127
114
|
// POST — declare a variable, or re-declare one that exists (an edit, not a duplicate:
|
|
128
115
|
// see ../env-manifest.js's declareEnvVar).
|
|
129
|
-
router.post('/provisioning/instances/:id/env-manifest', api.requireBuilder, async (req, res) => {
|
|
116
|
+
router.post('/provisioning/instances/:id/env-manifest', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
130
117
|
if (refusesValue(req, res)) return; // before the strict validator — see refusesValue
|
|
131
118
|
if (validateOrRespond(req, res, {
|
|
132
119
|
name: { required: true, type: 'string', maxLength: envManifest.ENV_NAME_MAX },
|
|
@@ -135,8 +122,7 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
135
122
|
required: { type: 'boolean' },
|
|
136
123
|
})) return;
|
|
137
124
|
try {
|
|
138
|
-
const inst =
|
|
139
|
-
if (!inst) return;
|
|
125
|
+
const inst = req.project;
|
|
140
126
|
if (badDeclaration(req, res, { nameRequired: true })) return;
|
|
141
127
|
const { row, created } = await envManifest.declareEnvVar(pool, {
|
|
142
128
|
instanceId: inst.id,
|
|
@@ -154,7 +140,7 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
154
140
|
|
|
155
141
|
// PATCH — edit one declaration. Merge-patch: an omitted field is left alone, so the
|
|
156
142
|
// hall can fix a description without restating the environments.
|
|
157
|
-
router.patch('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, async (req, res) => {
|
|
143
|
+
router.patch('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
158
144
|
if (refusesValue(req, res)) return; // before the strict validator — see refusesValue
|
|
159
145
|
if (validateOrRespond(req, res, {
|
|
160
146
|
name: { type: 'string', maxLength: envManifest.ENV_NAME_MAX },
|
|
@@ -163,8 +149,7 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
163
149
|
required: { type: 'boolean' },
|
|
164
150
|
})) return;
|
|
165
151
|
try {
|
|
166
|
-
const inst =
|
|
167
|
-
if (!inst) return;
|
|
152
|
+
const inst = req.project;
|
|
168
153
|
const varId = parseVarId(req, res);
|
|
169
154
|
if (!varId) return;
|
|
170
155
|
if (badDeclaration(req, res, { nameRequired: false })) return;
|
|
@@ -182,10 +167,9 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
182
167
|
// DELETE — remove one declaration. Nothing outside this table is affected: the value,
|
|
183
168
|
// wherever it lives, is untouched, and the response says so rather than letting an
|
|
184
169
|
// owner believe removing the name removed the secret.
|
|
185
|
-
router.delete('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, async (req, res) => {
|
|
170
|
+
router.delete('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
186
171
|
try {
|
|
187
|
-
const inst =
|
|
188
|
-
if (!inst) return;
|
|
172
|
+
const inst = req.project;
|
|
189
173
|
const varId = parseVarId(req, res);
|
|
190
174
|
if (!varId) return;
|
|
191
175
|
const removed = await envManifest.removeEnvVar(pool, inst.id, varId);
|