@bongos/core 1.19.981 → 1.19.983

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.
Files changed (33) hide show
  1. package/.bongos-core.json +56 -36
  2. package/.claude/skills/collab-review/SKILL.md +120 -0
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +3 -0
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0176-private-repo-deploy-keys.md +5 -4
  9. package/docs/api/openapi.json +87 -4
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +198 -201
  12. package/docs/copy-registry.json +263 -290
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/modules/hall-ui/public/collab-lib.js +92 -0
  16. package/modules/hall-ui/public/collab.css +5 -0
  17. package/modules/hall-ui/public/collab.html +8 -2
  18. package/modules/hall-ui/public/collab.js +77 -13
  19. package/modules/provisioning/module.json +2 -1
  20. package/modules/provisioning/routes/repo-private.js +82 -0
  21. package/modules/public-landing/public/projects.html +129 -21
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/src/bongos/routes/github.js +10 -9
  25. package/src/module-api.js +1 -1
  26. package/tests/collab_page.mjs +105 -0
  27. package/tests/github_repo_visibility.mjs +9 -6
  28. package/tests/hall_collab_world.mjs +18 -5
  29. package/tests/projects_hub.mjs +18 -24
  30. package/tests/projects_hub_pre_uat.mjs +5 -4
  31. package/tests/projects_hub_repo_private.mjs +238 -0
  32. package/tests/provisioning_repo_private_route.mjs +224 -0
  33. package/tests/public_landing_projects.mjs +7 -13
@@ -0,0 +1,224 @@
1
+ // tests/provisioning_repo_private_route.mjs
2
+ //
3
+ // Split (c) of task 1003078: POST /provisioning/instances/:id/repo-private — the owner's
4
+ // "make this project's repository private" request. It only ENQUEUES the `repo-private`
5
+ // intent (task 1004193); the runner wires the project's own deploy key and only then flips.
6
+ //
7
+ // The load-bearing assertions:
8
+ // • OWNER ONLY (owner decision, task 1004194): the runner flips with the OWNER's stored
9
+ // GitHub token whoever asked, so an archon on someone else's project gets the same 404
10
+ // as a stranger — and nothing is queued or recorded
11
+ // • the reply is a RECEIPT, never the new state: the repository is still public when it
12
+ // is sent, and only GitHub can say when that changed
13
+ // • a project the runner would refuse is refused HERE, in plain words, before anything is
14
+ // queued — not left to fail its retries into an error note
15
+ // • the body is strict: this route takes no fields, so any field is a 400
16
+ //
17
+ // DB-free: data access is stubbed on the cached module objects (the provisioning_restart.mjs
18
+ // pattern), over a real mounted router. No network beyond localhost.
19
+ //
20
+ // Run: node tests/provisioning_repo_private_route.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { createRequire } from 'node:module';
24
+ import { readFileSync } from 'node:fs';
25
+ import http from 'node:http';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const provisioning = require('../modules/provisioning/provisioning.js');
29
+ const repoPrivate = require('../modules/provisioning/repo-private.js');
30
+
31
+ let passed = 0, failed = 0;
32
+ async function ta(name, fn) {
33
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
34
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
35
+ }
36
+
37
+ let instance = null;
38
+ let enqueueCalls = [];
39
+ let enqueueResult = null;
40
+ let events = [];
41
+ provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
42
+ provisioning.recordEvent = async (_db, e) => { events.push(e); };
43
+ repoPrivate.enqueueRepoPrivate = async (_db, id, requestedBy) => {
44
+ enqueueCalls.push({ id, requestedBy });
45
+ return enqueueResult || { intent: { id: 9, action: 'repo-private', state: 'pending' }, created: true };
46
+ };
47
+
48
+ const api = require('../src/module-api.js');
49
+ let builder = null;
50
+ api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
51
+ api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
52
+
53
+ const express = require('express');
54
+ const buildRouter = require('../modules/provisioning/routes/repo-private.js');
55
+ const app = express();
56
+ app.use(express.json());
57
+ app.use((_req, res, next) => {
58
+ res.fail = (code, opts, details) => {
59
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
60
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(Array.isArray(details) ? { details } : {}) } });
61
+ };
62
+ next();
63
+ });
64
+ app.use('/api/bongos', buildRouter());
65
+
66
+ const server = app.listen(0);
67
+ await new Promise((r) => server.once('listening', r));
68
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
69
+
70
+ function post(path, body) {
71
+ return new Promise((resolve, reject) => {
72
+ const payload = body === undefined ? null : JSON.stringify(body);
73
+ const req = http.request(`${base}${path}`, {
74
+ method: 'POST', headers: payload ? { 'Content-Type': 'application/json' } : {},
75
+ }, (res) => {
76
+ let d = '';
77
+ res.on('data', (c) => { d += c; });
78
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
79
+ });
80
+ req.on('error', reject);
81
+ if (payload) req.write(payload);
82
+ req.end();
83
+ });
84
+ }
85
+
86
+ const LIVE = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', target_ref: 'example-owner/spike' };
87
+ const OWNER = { id: 7, github_login: 'owner', rank: 'thetes' };
88
+ function reset(patch = {}) {
89
+ instance = { ...LIVE, ...patch };
90
+ enqueueCalls = []; enqueueResult = null; events = [];
91
+ builder = { ...OWNER };
92
+ }
93
+ const URL_18 = '/provisioning/instances/18/repo-private';
94
+
95
+ console.log('POST /provisioning/instances/:id/repo-private — who may ask:');
96
+
97
+ await ta('the owner queues it: 200, one repo-private intent stamped api:self, one audit event', async () => {
98
+ // Arrange
99
+ reset();
100
+ // Act
101
+ const r = await post(URL_18);
102
+ // Assert
103
+ assert.equal(r.status, 200, r.raw);
104
+ assert.deepEqual(enqueueCalls, [{ id: 18, requestedBy: 'api:self' }]);
105
+ assert.equal(events.length, 1);
106
+ assert.equal(events[0].event, 'repo-private');
107
+ assert.equal(events[0].actor, 'api:self');
108
+ assert.equal(events[0].ownerBuilderId, 7);
109
+ });
110
+
111
+ await ta('an ARCHON on someone else\'s project gets 404 — the runner would act on the owner\'s GitHub account', async () => {
112
+ reset();
113
+ builder = { id: 42, github_login: 'arch', rank: 'archon' };
114
+ const r = await post(URL_18);
115
+ assert.equal(r.status, 404, r.raw);
116
+ assert.equal(r.body.error.code, 'instance_not_found');
117
+ assert.equal(enqueueCalls.length, 0, 'nothing queued');
118
+ assert.equal(events.length, 0, 'nothing recorded');
119
+ });
120
+
121
+ await ta('an archon who OWNS the project may ask — the rule is ownership, not rank', async () => {
122
+ reset({ owner_builder_id: 42 });
123
+ builder = { id: 42, github_login: 'arch', rank: 'archon' };
124
+ const r = await post(URL_18);
125
+ assert.equal(r.status, 200, r.raw);
126
+ assert.equal(enqueueCalls[0].requestedBy, 'api:self');
127
+ });
128
+
129
+ await ta('another builder\'s project is 404 — no existence leak', async () => {
130
+ reset();
131
+ builder = { id: 99, github_login: 'stranger', rank: 'thetes' };
132
+ const r = await post(URL_18);
133
+ assert.equal(r.status, 404, r.raw);
134
+ assert.equal(r.body.error.code, 'instance_not_found');
135
+ assert.equal(enqueueCalls.length, 0);
136
+ });
137
+
138
+ await ta('a missing project is 404, not a 500', async () => {
139
+ reset();
140
+ instance = null;
141
+ const r = await post(URL_18);
142
+ assert.equal(r.status, 404, r.raw);
143
+ assert.equal(enqueueCalls.length, 0);
144
+ });
145
+
146
+ await ta('a malformed id is 400 bad_id — and id 0 is ANSWERED, never left hanging', async () => {
147
+ for (const bad of ['abc', '0', '-3', '1.5']) {
148
+ reset();
149
+ const r = await post(`/provisioning/instances/${bad}/repo-private`);
150
+ assert.equal(r.status, 400, `${bad}: ${r.raw}`);
151
+ assert.equal(r.body.error.code, 'bad_id', bad);
152
+ assert.equal(enqueueCalls.length, 0);
153
+ }
154
+ });
155
+
156
+ console.log('\nthe reply:');
157
+
158
+ await ta('it is a RECEIPT — queued + a sentence, never a visibility the repository does not have yet', async () => {
159
+ reset();
160
+ const r = await post(URL_18);
161
+ assert.equal(r.body.ok, true);
162
+ assert.equal(r.body.queued, true);
163
+ assert.equal(r.body.action, 'repo-private');
164
+ assert.equal(r.body.private, undefined, 'no new state is claimed');
165
+ assert.equal(r.body.repo, undefined, 'no repository answer is invented');
166
+ assert.match(r.body.message, /deploy key/, 'the sentence says the key comes first');
167
+ });
168
+
169
+ await ta('a repeat while one is open is idempotent: 200 queued:false, and no second audit event', async () => {
170
+ reset();
171
+ enqueueResult = { intent: { id: 9, action: 'repo-private', state: 'running' }, created: false };
172
+ const r = await post(URL_18);
173
+ assert.equal(r.status, 200, r.raw);
174
+ assert.equal(r.body.queued, false);
175
+ assert.equal(events.length, 0);
176
+ });
177
+
178
+ await ta('any OTHER open run is a 409 intent_conflict naming it', async () => {
179
+ reset();
180
+ enqueueResult = { intent: { id: 3, action: 'settings-apply', state: 'running' }, created: false, conflict: 'open_intent' };
181
+ const r = await post(URL_18);
182
+ assert.equal(r.status, 409, r.raw);
183
+ assert.equal(r.body.error.code, 'intent_conflict');
184
+ assert.match(r.body.error.message, /settings-apply/);
185
+ assert.equal(events.length, 0);
186
+ });
187
+
188
+ console.log('\nrefused before anything is queued:');
189
+
190
+ for (const [label, patch, why] of [
191
+ ['a project on the owner\'s own host', { hosting_shape: 'byo-host' }, /on GitHub directly/],
192
+ ['a project on another shape', { hosting_shape: 'dedicated' }, /Cloud host/],
193
+ ['a project that is not running', { status: 'error' }, /isn’t running/],
194
+ ['a project with no repository linked', { target_ref: null }, /no GitHub repository/],
195
+ ]) {
196
+ await ta(`${label}: 409 repo_private_unavailable in plain words, nothing queued`, async () => {
197
+ reset(patch);
198
+ const r = await post(URL_18);
199
+ assert.equal(r.status, 409, r.raw);
200
+ assert.equal(r.body.error.code, 'repo_private_unavailable');
201
+ assert.match(r.body.error.message, why);
202
+ assert.equal(enqueueCalls.length, 0);
203
+ assert.equal(events.length, 0);
204
+ });
205
+ }
206
+
207
+ await ta('the body is strict — this route takes no fields, so a visibility field is a 400', async () => {
208
+ reset();
209
+ const r = await post(URL_18, { visibility: 'private' });
210
+ assert.equal(r.status, 400, r.raw);
211
+ assert.equal(r.body.error.code, 'validation_failed');
212
+ assert.equal(enqueueCalls.length, 0);
213
+ });
214
+
215
+ console.log('\nwiring:');
216
+
217
+ await ta('module.json mounts the route file', async () => {
218
+ const manifest = JSON.parse(readFileSync(new URL('../modules/provisioning/module.json', import.meta.url), 'utf8'));
219
+ assert.ok(manifest.contributes.routes.includes('repo-private'));
220
+ });
221
+
222
+ server.close();
223
+ console.log(`\nprovisioning_repo_private_route: ${passed} passed, ${failed} failed`);
224
+ process.exit(failed ? 1 : 0);
@@ -155,23 +155,17 @@ test('the served teardown copy no longer claims the address goes quiet (task 100
155
155
  'the truthful replacement is what ships');
156
156
  });
157
157
 
158
- test('the repo-visibility section is served live, with the private direction refused (task 1003045)', async () => {
158
+ test('the repo-visibility section is served live, with the private direction queued (tasks 1003045, 1003078)', async () => {
159
159
  const { body } = await request('/projects', 'game.test.local');
160
160
  assert.match(body, /id="mRepoVis"/, 'the live section body is served');
161
- assert.match(body, /would break the next deploy/,
162
- 'the flip-to-private limitation is stated in plain words');
161
+ assert.match(body, /id="mRepoVisMsg"/, 'with a slot for the make-private receipt');
162
+ assert.match(body, /\/repo-private'/, 'the private direction rides the provisioning route');
163
163
  assert.doesNotMatch(body, /visibility:\s*'private'/,
164
- 'no served code path may send the private direction');
164
+ 'the core flip is never asked for the private direction');
165
165
  assert.match(body, /repos=1/, 'the reconnect door asks for repository access');
166
- // the limitation is one click away (task 1003077). This DB-free serve is the
167
- // STANDALONE shape, where apex-only spans are stripped — so the explanation
168
- // must survive while the cloudbongos task link must NOT (a child instance
169
- // never shows another platform's tracker). The link itself is pinned inside
170
- // its sentinels by tests/projects_hub.mjs.
171
- assert.match(body, /id="mRepoVisWhy"/, 'the why-fold is served');
172
- assert.match(body, /an older key that only works on public repositories/, 'the explanation survives the strip');
173
- assert.doesNotMatch(body, /#\/task\/1002735/,
174
- 'the apex-only task link is stripped from a standalone serve');
166
+ assert.match(body, /id="mRepoVisWhy"/, 'the fold is served');
167
+ assert.match(body, /its own read-only deploy key/, 'and says the key comes first');
168
+ assert.doesNotMatch(body, /isn’t possible from here yet/, 'no served copy still refuses');
175
169
  });
176
170
 
177
171
  test('the empty state points a new owner at the wizard view — never at a hall', async () => {