@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.
- package/.bongos-core.json +56 -36
- package/.claude/skills/collab-review/SKILL.md +120 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0176-private-repo-deploy-keys.md +5 -4
- package/docs/api/openapi.json +87 -4
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +198 -201
- package/docs/copy-registry.json +263 -290
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/collab-lib.js +92 -0
- package/modules/hall-ui/public/collab.css +5 -0
- package/modules/hall-ui/public/collab.html +8 -2
- package/modules/hall-ui/public/collab.js +77 -13
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/routes/repo-private.js +82 -0
- package/modules/public-landing/public/projects.html +129 -21
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/routes/github.js +10 -9
- package/src/module-api.js +1 -1
- package/tests/collab_page.mjs +105 -0
- package/tests/github_repo_visibility.mjs +9 -6
- package/tests/hall_collab_world.mjs +18 -5
- package/tests/projects_hub.mjs +18 -24
- package/tests/projects_hub_pre_uat.mjs +5 -4
- package/tests/projects_hub_repo_private.mjs +238 -0
- package/tests/provisioning_repo_private_route.mjs +224 -0
- 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
|
|
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, /
|
|
162
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 () => {
|