@bongos/core 1.25.16 → 1.25.17
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 +95 -40
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +14 -0
- package/clients/bongos-client/index.cjs +14 -0
- package/clients/bongos-client/index.d.ts +22 -2
- package/clients/bongos-client/index.mjs +14 -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/api/openapi.json +415 -3
- package/docs/api-reference.md +13 -2
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +1 -1
- package/modules/government/decision-rules.js +1 -0
- package/modules/lifecycle/first-read-db.js +152 -0
- package/modules/lifecycle/first-read.js +265 -0
- package/modules/lifecycle/kickoff-checklist.js +12 -3
- package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/first-read.js +79 -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/public-landing/public/projects.html +101 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/bongos/first-read.js +116 -0
- 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/render-api.js +33 -3
- package/scripts/bongos/render-payload.js +39 -1
- package/scripts/gds/first-read.js +7 -0
- package/scripts/gds/provision-render-sandbox.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/first_read.mjs +303 -0
- package/tests/government_act_alone_buildings.mjs +2 -0
- 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,303 @@
|
|
|
1
|
+
// tests/first_read.mjs — THE FIRST READ-THROUGH of a project that already exists (task
|
|
2
|
+
// 1005280, goal 1000106).
|
|
3
|
+
//
|
|
4
|
+
// Done-when: an adopted project's Genesis step hands the owner a prompt for their OWN AI
|
|
5
|
+
// session; that session's map comes back in a defined, validated shape; the owner
|
|
6
|
+
// accepts it whole or item by item into DRAFT goals and tasks; a rejection files
|
|
7
|
+
// nothing; a re-run files nothing twice; the shape has room for task 1005281's
|
|
8
|
+
// recommended modules.
|
|
9
|
+
//
|
|
10
|
+
// Everything here RUNS: the real prompt and Genesis checklist, the real validator and
|
|
11
|
+
// acceptance planner, the real store over an in-memory fake pool, and the real router
|
|
12
|
+
// over a socket.
|
|
13
|
+
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
import { test, after } from 'node:test';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const fr = require('../modules/lifecycle/first-read.js');
|
|
20
|
+
const storeModule = require('../modules/lifecycle/first-read-db.js');
|
|
21
|
+
// A snapshot of the real store: the route section below re-points the module's exports
|
|
22
|
+
// at its own pool, and module-level code runs before any test does.
|
|
23
|
+
const store = { ...storeModule };
|
|
24
|
+
const { genesisChecklist } = require('../modules/lifecycle/kickoff-checklist.js');
|
|
25
|
+
|
|
26
|
+
const clone = (o) => JSON.parse(JSON.stringify(o));
|
|
27
|
+
|
|
28
|
+
// ── an in-memory pool that answers exactly the store's statements ─────────────
|
|
29
|
+
|
|
30
|
+
function fakePool() {
|
|
31
|
+
const s = { maps: [], filed: new Map(), goals: [], tasks: [], nextId: 1 };
|
|
32
|
+
const query = async (sql, p = []) => {
|
|
33
|
+
const q = sql.replace(/\s+/g, ' ').trim();
|
|
34
|
+
if (/^(BEGIN|COMMIT|ROLLBACK)$/.test(q) || /pg_advisory_xact_lock/.test(q)) return { rows: [] };
|
|
35
|
+
if (/^INSERT INTO lifecycle_repo_maps/.test(q)) {
|
|
36
|
+
const row = { id: String(s.nextId++), schema: p[0], map: JSON.parse(p[1]), status: 'pending', submitted_by: p[2], submitted_at: new Date() };
|
|
37
|
+
s.maps.push(row); return { rows: [row] };
|
|
38
|
+
}
|
|
39
|
+
if (/FROM lifecycle_repo_maps ORDER BY/.test(q)) return { rows: s.maps.slice(-1) };
|
|
40
|
+
if (/^SELECT .* FROM lifecycle_repo_maps WHERE id = \$1/.test(q)) return { rows: s.maps.filter((m) => m.id === String(p[0])) };
|
|
41
|
+
if (/^SELECT ref, kind, entity_id FROM lifecycle_repo_map_filed/.test(q)) {
|
|
42
|
+
return { rows: p[0].filter((r) => s.filed.has(r)).map((r) => ({ ref: r, ...s.filed.get(r) })) };
|
|
43
|
+
}
|
|
44
|
+
if (/^INSERT INTO lifecycle_repo_map_filed .* unnest/.test(q)) {
|
|
45
|
+
s.filedWrites = (s.filedWrites || 0) + 1;
|
|
46
|
+
p[0].forEach((ref, i) => {
|
|
47
|
+
if (s.filed.has(ref)) throw Object.assign(new Error('duplicate key'), { code: '23505' });
|
|
48
|
+
s.filed.set(ref, { kind: p[1][i], entity_id: p[2][i], map_id: p[3] });
|
|
49
|
+
});
|
|
50
|
+
return { rows: [] };
|
|
51
|
+
}
|
|
52
|
+
if (/^SELECT id FROM versions/.test(q)) return { rows: [{ id: 'V1' }] };
|
|
53
|
+
if (/^UPDATE lifecycle_repo_maps SET status = 'accepted'/.test(q)) { s.maps.find((m) => m.id === String(p[0])).status = 'accepted'; return { rows: [] }; }
|
|
54
|
+
if (/^UPDATE lifecycle_repo_maps SET status = 'rejected'/.test(q)) {
|
|
55
|
+
const m = s.maps.find((x) => x.id === String(p[0]) && x.status === 'pending');
|
|
56
|
+
if (!m) return { rows: [] };
|
|
57
|
+
m.status = 'rejected'; return { rows: [{ id: m.id }] };
|
|
58
|
+
}
|
|
59
|
+
throw new Error('unexpected SQL: ' + q);
|
|
60
|
+
};
|
|
61
|
+
const pool = { query, connect: async () => ({ query, release() {} }), state: s };
|
|
62
|
+
const deps = {
|
|
63
|
+
pool,
|
|
64
|
+
createGoal: async (row, d) => { assert.ok(d && d.client, 'goals are created inside the accept transaction'); const g = { id: String(100 + s.goals.length), ...row }; s.goals.push(g); return g; },
|
|
65
|
+
createTask: async (row, d) => { assert.ok(d && d.client, 'tasks are created inside the accept transaction'); const t = { id: String(500 + s.tasks.length), ...row }; s.tasks.push(t); return t; },
|
|
66
|
+
};
|
|
67
|
+
return { pool, deps, s };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── the prompt ───────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
test('the prompt sends the owner\'s own session to read locally, map in the defined shape, and accept only with a yes', () => {
|
|
73
|
+
const p = fr.firstReadPrompt();
|
|
74
|
+
for (const must of [
|
|
75
|
+
'own AI session', 'Bongos itself never reads this repository', 'Do not upload, paste or send any source code',
|
|
76
|
+
fr.MAP_FILE, fr.SCHEMA, '"goals"', '"next"', '"why"', '"seen_at"', 'errors or mishaps',
|
|
77
|
+
'first-read check', 'first-read submit', 'first-read accept', 'first-read reject', 'Never accept on your own',
|
|
78
|
+
'not filed twice',
|
|
79
|
+
]) assert.ok(p.includes(must), `the prompt says: ${must}`);
|
|
80
|
+
// The example it prints is itself a valid map, so a session that copies it is on the shape.
|
|
81
|
+
assert.equal(fr.validateRepoMap(fr.exampleMap()).ok, true);
|
|
82
|
+
const printed = JSON.parse(p.slice(p.indexOf('{'), p.lastIndexOf('}') + 1));
|
|
83
|
+
assert.deepEqual(printed, fr.exampleMap());
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('an adopted project\'s Genesis step carries the prompt; a new project\'s does not', () => {
|
|
87
|
+
const existing = genesisChecklist({ existing: true }).scope;
|
|
88
|
+
assert.equal(existing[0].ref, 'genesis:scope:existing', 'the step keeps its ref, so a seeded project is not refiled');
|
|
89
|
+
assert.ok(existing[0].description.includes(fr.firstReadPrompt()), 'the hall copy-prompt hands over the whole prompt');
|
|
90
|
+
assert.ok(/own AI/.test(existing[0].title));
|
|
91
|
+
const fresh = genesisChecklist().scope;
|
|
92
|
+
assert.ok(fresh.every((t) => !t.description.includes(fr.SCHEMA)));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── validation ───────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
test('a good map validates into a flat review list with stable refs', () => {
|
|
98
|
+
const v = fr.validateRepoMap(fr.exampleMap());
|
|
99
|
+
assert.equal(v.ok, true);
|
|
100
|
+
assert.deepEqual(v.items.map((i) => [i.type, i.ref]), [
|
|
101
|
+
['goal', 'goal:take-payments-for-bookings'],
|
|
102
|
+
['task', 'task:take-payments-for-bookings:finish-the-checkout-page'],
|
|
103
|
+
['task', 'task:take-payments-for-bookings:send-a-receipt-by-email'],
|
|
104
|
+
['next', 'next:fix-the-failing-booking-test'],
|
|
105
|
+
]);
|
|
106
|
+
assert.ok(v.items.every((i) => i.why && i.seen_at.length));
|
|
107
|
+
assert.equal(v.items[3].goal_ref, fr.NEXT_GOAL.ref, 'a next item naming no goal lands under the fix-what-was-found goal');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a later section (recommended modules) rides along without breaking the shape', () => {
|
|
111
|
+
const raw = { ...fr.exampleMap(), recommended_modules: [{ key: 'payments', why: 'a checkout exists' }] };
|
|
112
|
+
const v = fr.validateRepoMap(raw);
|
|
113
|
+
assert.equal(v.ok, true);
|
|
114
|
+
assert.deepEqual(v.map.recommended_modules, raw.recommended_modules);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a prototype-shaped key in the map is never copied onto it', () => {
|
|
118
|
+
const raw = JSON.parse(`{"schema":"${fr.SCHEMA}","summary":"s","next":[{"title":"t","why":"w","seen_at":["a"]}],"__proto__":{"polluted":true},"constructor":{"x":1}}`);
|
|
119
|
+
const v = fr.validateRepoMap(raw);
|
|
120
|
+
assert.equal(v.ok, true);
|
|
121
|
+
assert.equal(v.map.polluted, undefined);
|
|
122
|
+
assert.equal(Object.getPrototypeOf(v.map), Object.prototype);
|
|
123
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(v.map, 'constructor'));
|
|
124
|
+
assert.equal(({}).polluted, undefined);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('malformed maps are refused, each error naming its field', () => {
|
|
128
|
+
const cases = [
|
|
129
|
+
[null, /JSON object/],
|
|
130
|
+
[[], /JSON object/],
|
|
131
|
+
[{ ...fr.exampleMap(), schema: 'other/1' }, /schema must be/],
|
|
132
|
+
[{ ...fr.exampleMap(), goals: 'lots' }, /goals must be a list/],
|
|
133
|
+
[{ schema: fr.SCHEMA, summary: 'x', goals: [], next: [] }, /no goals and nothing next/],
|
|
134
|
+
[(() => { const m = fr.exampleMap(); delete m.goals[0].why; return m; })(), /goals\[0\]\.why is missing/],
|
|
135
|
+
[(() => { const m = fr.exampleMap(); m.goals[0].tasks[0].seen_at = []; return m; })(), /goals\[0\]\.tasks\[0\]\.seen_at needs at least one place/],
|
|
136
|
+
[(() => { const m = fr.exampleMap(); m.goals[0].tasks[1].status = 'half'; return m; })(), /status must be one of/],
|
|
137
|
+
[(() => { const m = fr.exampleMap(); m.goals[0].tasks[1].title = m.goals[0].tasks[0].title; return m; })(), /repeats another item's title/],
|
|
138
|
+
[(() => { const m = fr.exampleMap(); m.next[0].goal = 'No such goal'; return m; })(), /not a goal in this map/],
|
|
139
|
+
[(() => { const m = fr.exampleMap(); m.next[0].kind = 'oops'; return m; })(), /kind must be one of/],
|
|
140
|
+
[(() => { const m = fr.exampleMap(); m.goals[0].why = 'x'.repeat(fr.LIMITS.WHY + 1); return m; })(), /longer than/],
|
|
141
|
+
[(() => { const m = fr.exampleMap(); m.summary = 'y'.repeat(fr.LIMITS.BYTES); return m; })(), /larger than/],
|
|
142
|
+
[(() => { const m = fr.exampleMap(); m.goals[0].title = '!!!'; return m; })(), /needs letters or numbers/],
|
|
143
|
+
];
|
|
144
|
+
for (const [raw, re] of cases) {
|
|
145
|
+
const v = fr.validateRepoMap(raw);
|
|
146
|
+
assert.equal(v.ok, false, `refused: ${re}`);
|
|
147
|
+
assert.ok(v.errors.some((e) => re.test(e)), `${re} in ${JSON.stringify(v.errors)}`);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── the store: submit, accept, reject, re-run ────────────────────────────────
|
|
152
|
+
|
|
153
|
+
test('submitting files nothing: the map waits pending', async () => {
|
|
154
|
+
const { deps, s } = fakePool();
|
|
155
|
+
const out = await store.submitMap({ raw: fr.exampleMap(), builderId: '1' }, deps);
|
|
156
|
+
assert.equal(out.ok, true);
|
|
157
|
+
assert.equal(s.maps[0].status, 'pending');
|
|
158
|
+
assert.equal(s.goals.length + s.tasks.length, 0);
|
|
159
|
+
const bad = await store.submitMap({ raw: { schema: 'nope' } }, deps);
|
|
160
|
+
assert.equal(bad.ok, false);
|
|
161
|
+
assert.equal(s.maps.length, 1, 'a malformed map is not stored');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('accepting the whole map files draft goals and backlog tasks, nothing claimable', async () => {
|
|
165
|
+
const { deps, s } = fakePool();
|
|
166
|
+
const { map_id } = await store.submitMap({ raw: fr.exampleMap(), builderId: '1' }, deps);
|
|
167
|
+
const out = await store.acceptMap({ mapId: map_id, select: 'all', builderId: '1' }, deps);
|
|
168
|
+
assert.equal(out.ok, true);
|
|
169
|
+
assert.deepEqual(s.goals.map((g) => g.title), ['Take payments for bookings', fr.NEXT_GOAL.title]);
|
|
170
|
+
assert.equal(s.tasks.length, 3);
|
|
171
|
+
for (const t of s.tasks) {
|
|
172
|
+
assert.equal(t.status, 'backlog');
|
|
173
|
+
assert.equal(t.source, store.TASK_SOURCE);
|
|
174
|
+
assert.ok(t.goalId, 'every task has a goal home');
|
|
175
|
+
assert.ok(t.description.includes('Seen at:'));
|
|
176
|
+
}
|
|
177
|
+
assert.equal(s.tasks.find((t) => t.title === 'Fix the failing booking test').kind, 'bug');
|
|
178
|
+
assert.equal(s.tasks.find((t) => t.title === 'Fix the failing booking test').goalId, s.goals[1].id);
|
|
179
|
+
assert.equal(s.maps[0].status, 'accepted');
|
|
180
|
+
assert.equal(s.filedWrites, 1, 'every filed ref is remembered in one statement');
|
|
181
|
+
assert.equal(s.filed.size, 5);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('accepting item by item files only what the owner chose (and the goal it needs)', async () => {
|
|
185
|
+
const { deps, s } = fakePool();
|
|
186
|
+
const { map_id } = await store.submitMap({ raw: fr.exampleMap() }, deps);
|
|
187
|
+
const out = await store.acceptMap({ mapId: map_id, select: ['task:take-payments-for-bookings:send-a-receipt-by-email'] }, deps);
|
|
188
|
+
assert.equal(out.ok, true);
|
|
189
|
+
assert.deepEqual(s.goals.map((g) => g.title), ['Take payments for bookings']);
|
|
190
|
+
assert.deepEqual(s.tasks.map((t) => t.title), ['Send a receipt by email']);
|
|
191
|
+
const unknown = await store.acceptMap({ mapId: map_id, select: ['task:nope'] }, deps);
|
|
192
|
+
assert.equal(unknown.code, 'unknown_items');
|
|
193
|
+
const none = await store.acceptMap({ mapId: map_id, select: [] }, deps);
|
|
194
|
+
assert.equal(none.code, 'nothing_selected');
|
|
195
|
+
assert.equal(s.tasks.length, 1, 'a refused accept files nothing');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('a task the map says is done is shown, never filed', async () => {
|
|
199
|
+
const { deps, s } = fakePool();
|
|
200
|
+
const raw = fr.exampleMap();
|
|
201
|
+
raw.goals[0].tasks[0].status = 'done';
|
|
202
|
+
const { map_id } = await store.submitMap({ raw }, deps);
|
|
203
|
+
const out = await store.acceptMap({ mapId: map_id, select: 'all' }, deps);
|
|
204
|
+
assert.ok(out.skipped.some((x) => x.reason === 'already_done'));
|
|
205
|
+
assert.ok(!s.tasks.some((t) => t.title === 'Finish the checkout page'));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('rejecting files nothing, and a rejected map cannot then be accepted', async () => {
|
|
209
|
+
const { deps, s } = fakePool();
|
|
210
|
+
const { map_id } = await store.submitMap({ raw: fr.exampleMap() }, deps);
|
|
211
|
+
assert.equal((await store.rejectMap({ mapId: map_id, builderId: '1' }, deps)).ok, true);
|
|
212
|
+
assert.equal(s.maps[0].status, 'rejected');
|
|
213
|
+
const again = await store.acceptMap({ mapId: map_id, select: 'all' }, deps);
|
|
214
|
+
assert.equal(again.code, 'map_rejected');
|
|
215
|
+
assert.equal(s.goals.length + s.tasks.length, 0);
|
|
216
|
+
assert.equal((await store.rejectMap({ mapId: map_id }, deps)).code, 'not_pending');
|
|
217
|
+
assert.equal((await store.rejectMap({ mapId: '99' }, deps)).code, 'not_found');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('a re-run does not duplicate: the same items are skipped, only new ones are filed', async () => {
|
|
221
|
+
const { deps, s } = fakePool();
|
|
222
|
+
const first = await store.submitMap({ raw: fr.exampleMap() }, deps);
|
|
223
|
+
await store.acceptMap({ mapId: first.map_id, select: 'all' }, deps);
|
|
224
|
+
const goalsBefore = s.goals.length;
|
|
225
|
+
const tasksBefore = s.tasks.length;
|
|
226
|
+
// Accepting the same map again files nothing.
|
|
227
|
+
const twice = await store.acceptMap({ mapId: first.map_id, select: 'all' }, deps);
|
|
228
|
+
assert.equal(twice.filed.goals.length + twice.filed.tasks.length, 0);
|
|
229
|
+
// A second read-through names the same items (reordered, retitled in case) plus one new task.
|
|
230
|
+
const raw = clone(fr.exampleMap());
|
|
231
|
+
raw.goals[0].title = 'TAKE PAYMENTS FOR BOOKINGS';
|
|
232
|
+
raw.goals[0].tasks.reverse();
|
|
233
|
+
raw.goals[0].tasks.push({ title: 'Refund a booking', status: 'not_started', why: 'No refund path exists.', seen_at: ['src/payments/'] });
|
|
234
|
+
const second = await store.submitMap({ raw }, deps);
|
|
235
|
+
const review = await store.reviewMap('latest', deps);
|
|
236
|
+
assert.equal(review.map_id, second.map_id);
|
|
237
|
+
assert.equal(review.items.filter((i) => i.filed).length, 4, 'the review marks what is already in Bongos');
|
|
238
|
+
const out = await store.acceptMap({ mapId: second.map_id, select: 'all' }, deps);
|
|
239
|
+
assert.equal(s.goals.length, goalsBefore, 'no goal filed twice');
|
|
240
|
+
assert.equal(s.tasks.length, tasksBefore + 1);
|
|
241
|
+
assert.deepEqual(out.filed.tasks.map((t) => t.title), ['Refund a booking']);
|
|
242
|
+
assert.equal(out.filed.tasks[0].goal_id, s.goals[0].id, 'the new task joins the goal filed by the first run');
|
|
243
|
+
assert.equal(out.skipped.filter((x) => x.reason === 'already_filed').length, 4);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ── the routes, over a real socket ───────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
const express = require('express');
|
|
249
|
+
const api = require('../src/module-api.js');
|
|
250
|
+
const perms = [];
|
|
251
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: '7', rank: 'archon' }; next(); };
|
|
252
|
+
api.requirePermission = (...p) => { perms.push(p.join(',')); return (_req, _res, next) => next(); };
|
|
253
|
+
api.requireActAlone = (area) => { perms.push('act-alone:' + area); return (_req, _res, next) => next(); };
|
|
254
|
+
|
|
255
|
+
const route = fakePool();
|
|
256
|
+
for (const fn of ['submitMap', 'reviewMap', 'acceptMap', 'rejectMap']) {
|
|
257
|
+
storeModule[fn] = (a) => store[fn](a, route.deps);
|
|
258
|
+
}
|
|
259
|
+
const app = express();
|
|
260
|
+
app.use(express.json());
|
|
261
|
+
app.use((req, res, next) => {
|
|
262
|
+
res.fail = (code, status, details) => res.status(status || 400).json({ error: code, ...(details || {}) });
|
|
263
|
+
next();
|
|
264
|
+
});
|
|
265
|
+
app.use('/api/bongos', require('../modules/lifecycle/routes/first-read.js')());
|
|
266
|
+
const server = app.listen(0);
|
|
267
|
+
await new Promise((r) => server.once('listening', r));
|
|
268
|
+
after(() => server.close());
|
|
269
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
270
|
+
const call = async (method, p, body) => {
|
|
271
|
+
const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
|
|
272
|
+
return { status: r.status, body: await r.json().catch(() => null) };
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
test('the routes: filing is gated like creating a goal, and the flow runs end to end', async () => {
|
|
276
|
+
assert.ok(perms.includes('goal.create'));
|
|
277
|
+
assert.ok(perms.includes('act-alone:planning'));
|
|
278
|
+
const prompt = await call('GET', '/first-read/prompt');
|
|
279
|
+
assert.equal(prompt.status, 200);
|
|
280
|
+
assert.equal(prompt.body.prompt, fr.firstReadPrompt());
|
|
281
|
+
|
|
282
|
+
const bad = await call('POST', '/first-read/maps', { map: { schema: fr.SCHEMA, summary: 'x', goals: [{ title: 'A' }] } });
|
|
283
|
+
assert.equal(bad.status, 400);
|
|
284
|
+
assert.equal(bad.body.error, 'invalid_map');
|
|
285
|
+
assert.ok(bad.body.errors.length >= 2);
|
|
286
|
+
|
|
287
|
+
const ok = await call('POST', '/first-read/maps', { map: fr.exampleMap() });
|
|
288
|
+
assert.equal(ok.status, 201);
|
|
289
|
+
assert.equal(route.s.tasks.length, 0, 'submit files nothing');
|
|
290
|
+
|
|
291
|
+
const bogus = await call('POST', `/first-read/maps/${ok.body.map_id}/accept`, { items: ['x'], all: true });
|
|
292
|
+
assert.equal(bogus.status, 400);
|
|
293
|
+
const accepted = await call('POST', `/first-read/maps/${ok.body.map_id}/accept`, { items: ['goal:take-payments-for-bookings'] });
|
|
294
|
+
assert.equal(accepted.status, 200);
|
|
295
|
+
assert.equal(accepted.body.filed.goals.length, 1);
|
|
296
|
+
assert.equal(accepted.body.filed.tasks.length, 0);
|
|
297
|
+
|
|
298
|
+
const shown = await call('GET', '/first-read/maps/latest');
|
|
299
|
+
assert.equal(shown.status, 200);
|
|
300
|
+
assert.equal(shown.body.items.find((i) => i.ref === 'goal:take-payments-for-bookings').filed.kind, 'goal');
|
|
301
|
+
assert.equal((await call('GET', '/first-read/maps/999')).status, 404);
|
|
302
|
+
assert.equal((await call('POST', `/first-read/maps/${ok.body.map_id}/reject`)).status, 409, 'an accepted map is not then rejected');
|
|
303
|
+
});
|
|
@@ -216,6 +216,7 @@ for (const rel of [
|
|
|
216
216
|
'modules/lifecycle/routes/goals.js',
|
|
217
217
|
'modules/lifecycle/routes/versions.js',
|
|
218
218
|
'modules/lifecycle/routes/project-settings.js',
|
|
219
|
+
'modules/lifecycle/routes/first-read.js',
|
|
219
220
|
'modules/render-deploy/routes/act.js',
|
|
220
221
|
'modules/npm-release/routes/release.js',
|
|
221
222
|
'modules/provisioning/routes/core-upgrade.js',
|
|
@@ -262,6 +263,7 @@ const REQUESTS = {
|
|
|
262
263
|
'POST /goals/reorder': ['POST', '/goals/reorder', {}],
|
|
263
264
|
'POST /work-categories': ['POST', '/work-categories', {}],
|
|
264
265
|
'PUT /versions/focus': ['PUT', '/versions/focus', { version_id: ' ' }],
|
|
266
|
+
'POST /first-read/maps/:id/accept': ['POST', `/first-read/maps/${BAD}/accept`, { all: true }], // task 1005280
|
|
265
267
|
'POST /autonomy/fence': ['POST', '/autonomy/fence', { enabled: 'yes' }],
|
|
266
268
|
'POST /autonomy/fence/goals': ['POST', '/autonomy/fence/goals', { goal_id: 0 }],
|
|
267
269
|
'DELETE /autonomy/fence/goals/:goalId': ['DELETE', `/autonomy/fence/goals/${BAD}`],
|
|
@@ -193,6 +193,7 @@ await ta('it knocks only on an onrender.com address, and follows no redirect', a
|
|
|
193
193
|
await ta('a sweep marks only answering apps live, compare-and-set on the state it read', async () => {
|
|
194
194
|
const writes = [];
|
|
195
195
|
const db = { query: async (sql, params) => {
|
|
196
|
+
if (/app_host->'sandbox'/.test(sql)) return { rows: [] }; // the sandbox read (task 1005289)
|
|
196
197
|
if (/^\s*SELECT/.test(sql)) {
|
|
197
198
|
assert.match(sql, /app_host->>'state' IN \('deploying', 'linked'\)/);
|
|
198
199
|
return { rows: [
|
|
@@ -213,7 +214,7 @@ await ta('a sweep marks only answering apps live, compare-and-set on the state i
|
|
|
213
214
|
await ta('the knocks run a few at a time, never all at once and never one by one', async () => {
|
|
214
215
|
let inFlight = 0, peak = 0;
|
|
215
216
|
const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, slug: `a${i}`, app_host: { state: 'deploying', serviceUrl: `https://a${i}.onrender.com` } }));
|
|
216
|
-
const db = { query: async (sql) => (/^\s*SELECT/.test(sql) ? { rows } : { rowCount: 1 }) };
|
|
217
|
+
const db = { query: async (sql) => (/app_host->'sandbox'/.test(sql) ? { rows: [] } : /^\s*SELECT/.test(sql) ? { rows } : { rowCount: 1 }) };
|
|
217
218
|
const fetchImpl = async () => { inFlight++; peak = Math.max(peak, inFlight); await new Promise((r) => setTimeout(r, 5)); inFlight--; return res(200); };
|
|
218
219
|
const out = await liveness.sweepOnce({ pool: db, fetchImpl, log: () => {} });
|
|
219
220
|
assert.deepEqual(out, { checked: 12, live: 12 });
|
|
@@ -233,7 +234,7 @@ await ta('a run that starts while the previous sweep is in flight is skipped (ta
|
|
|
233
234
|
const [firstOut, second] = await Promise.all([first, secondRun]);
|
|
234
235
|
assert.deepEqual(second, { skipped: true });
|
|
235
236
|
assert.deepEqual(firstOut, { checked: 0, live: 0 });
|
|
236
|
-
assert.equal(reads,
|
|
237
|
+
assert.equal(reads, 2, 'the overlapping run never touched the database (the one run read the apps, then the sandboxes)');
|
|
237
238
|
assert.deepEqual(await liveness.tick({ pool: { query: async () => ({ rows: [] }) }, log: () => {} }), { checked: 0, live: 0 },
|
|
238
239
|
'once the first finished, the next run goes ahead');
|
|
239
240
|
});
|
|
@@ -95,7 +95,7 @@ await ta('the owner queues it: ciphertext only, the request recorded on the row,
|
|
|
95
95
|
assert.equal(r.status, 200, r.raw);
|
|
96
96
|
assert.equal(enqueueCalls.length, 1);
|
|
97
97
|
assert.equal(enqueueCalls[0].keyEnc, `v1.sealed(${KEY.length})`);
|
|
98
|
-
assert.deepEqual(enqueueCalls[0].appHost, { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234', state: 'requested', error: null,
|
|
98
|
+
assert.deepEqual(enqueueCalls[0].appHost, { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234', state: 'requested', error: null, sandboxWanted: true,
|
|
99
99
|
runtime: 'node', startCommand: 'npm start', buildCommand: 'npm ci', database: true, webPlan: 'standard' });
|
|
100
100
|
assert.equal(events.length, 1);
|
|
101
101
|
noKeyAnywhere(enqueueCalls, events, r.body);
|
|
@@ -256,6 +256,56 @@ await ta('a mid-flight provision is a 409 conflict — never the false "teardown
|
|
|
256
256
|
assert.doesNotMatch(String(res.body.error.message), /already queued/i, 'the old lie is gone');
|
|
257
257
|
});
|
|
258
258
|
|
|
259
|
+
// The owner's Render key on a teardown removes the project's sandbox (task 1005289, ADR 0406 §7).
|
|
260
|
+
const renderSandbox = require('../modules/provisioning/render-sandbox.js');
|
|
261
|
+
const secretBox = require('../src/bongos/secret-box.js');
|
|
262
|
+
let lentCalls = [];
|
|
263
|
+
renderSandbox.lendKeyToTeardown = async (_db, intentId, keyEnc) => { lentCalls.push({ intentId, keyEnc }); return true; };
|
|
264
|
+
function postWithKey(path, key) {
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
const req = http.request(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': 2, 'x-render-key': key } }, (res) => {
|
|
267
|
+
let d = ''; res.on('data', (c) => { d += c; });
|
|
268
|
+
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(d), raw: d }));
|
|
269
|
+
});
|
|
270
|
+
req.on('error', reject);
|
|
271
|
+
req.end('{}');
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const RKEY = 'rnd_abcdefghijklmnopqrstuvwx';
|
|
275
|
+
console.log('\nPOST /provisioning/instances/:id/teardown — the Render key for the sandbox:');
|
|
276
|
+
await ta('a well-formed key is sealed and lent to the teardown just queued, never echoed', async () => {
|
|
277
|
+
const realConf = secretBox.isConfigured, realEnc = secretBox.encrypt;
|
|
278
|
+
secretBox.isConfigured = () => true; secretBox.encrypt = (p) => `v1.sealed(${p.length})`;
|
|
279
|
+
try {
|
|
280
|
+
lentCalls = [];
|
|
281
|
+
teardownOutcome = { intent: { id: 31, action: 'teardown', state: 'pending' }, created: true, superseded: false };
|
|
282
|
+
const res = await postWithKey('/provisioning/instances/16/teardown', RKEY);
|
|
283
|
+
assert.equal(res.status, 200, res.raw);
|
|
284
|
+
assert.equal(res.body.sandbox_key_lent, true);
|
|
285
|
+
assert.deepEqual(lentCalls, [{ intentId: 31, keyEnc: `v1.sealed(${RKEY.length})` }], 'ciphertext only, on THIS teardown');
|
|
286
|
+
assert.ok(!res.raw.includes(RKEY), 'the key is never sent back');
|
|
287
|
+
} finally { secretBox.isConfigured = realConf; secretBox.encrypt = realEnc; }
|
|
288
|
+
});
|
|
289
|
+
await ta('a malformed key, an already-queued teardown, or no safe seal: nothing lent, the teardown still answers', async () => {
|
|
290
|
+
const realConf = secretBox.isConfigured, realEnc = secretBox.encrypt;
|
|
291
|
+
secretBox.encrypt = (p) => `v1.sealed(${p.length})`;
|
|
292
|
+
try {
|
|
293
|
+
secretBox.isConfigured = () => true;
|
|
294
|
+
lentCalls = [];
|
|
295
|
+
teardownOutcome = { intent: { id: 32, action: 'teardown', state: 'pending' }, created: true, superseded: false };
|
|
296
|
+
let res = await postWithKey('/provisioning/instances/16/teardown', 'not-a-key');
|
|
297
|
+
assert.equal(res.status, 200); assert.equal(res.body.sandbox_key_lent, false);
|
|
298
|
+
teardownOutcome = { intent: { id: 32, action: 'teardown', state: 'pending' }, created: false, superseded: false };
|
|
299
|
+
res = await postWithKey('/provisioning/instances/16/teardown', RKEY);
|
|
300
|
+
assert.equal(res.status, 200); assert.equal(res.body.sandbox_key_lent, false, 'not on a teardown queued earlier');
|
|
301
|
+
secretBox.isConfigured = () => false;
|
|
302
|
+
teardownOutcome = { intent: { id: 33, action: 'teardown', state: 'pending' }, created: true, superseded: false };
|
|
303
|
+
res = await postWithKey('/provisioning/instances/16/teardown', RKEY);
|
|
304
|
+
assert.equal(res.status, 200); assert.equal(res.body.sandbox_key_lent, false, 'never stored unsealed');
|
|
305
|
+
assert.equal(lentCalls.length, 0);
|
|
306
|
+
} finally { secretBox.isConfigured = realConf; secretBox.encrypt = realEnc; }
|
|
307
|
+
});
|
|
308
|
+
|
|
259
309
|
console.log('\nPOST /provisioning/instances/:id/force-teardown — same truthful mapping (archon):');
|
|
260
310
|
|
|
261
311
|
await ta('force-teardown surfaces the same 409 conflict instead of a false success', async () => {
|
package/tests/render_api.mjs
CHANGED
|
@@ -127,10 +127,13 @@ await ta('listOwners returns every workspace the key reaches, unwrapped', async
|
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
console.log('\nno delete:');
|
|
130
|
-
await ta('there is no delete method, and the verb is refused', async () => {
|
|
130
|
+
await ta('there is no delete method but the sandbox one, and the verb is refused', async () => {
|
|
131
131
|
const { api, calls } = live({});
|
|
132
132
|
const methods = Object.getOwnPropertyNames(RenderApi.prototype);
|
|
133
|
-
|
|
133
|
+
// ADR 0406 §7 / task 1005289: the ONE destructive method removes a free `…-sandbox`
|
|
134
|
+
// service the platform made (tests/render_sandbox.mjs proves it refuses anything else).
|
|
135
|
+
const destructive = methods.filter((m) => /delete|destroy|remove|suspend/i.test(m));
|
|
136
|
+
assert.deepEqual(destructive, ['deleteSandboxService'], `no other destructive method: ${methods.join(', ')}`);
|
|
134
137
|
await assert.rejects(api._req('DELETE', '/services/srv-1'), /never deletes/);
|
|
135
138
|
assert.equal(calls.length, 0);
|
|
136
139
|
});
|