@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
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.981",
3
+ "version": "1.19.983",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.981",
9
+ "version": "1.19.983",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.981",
3
+ "version": "1.19.983",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -1,7 +1,7 @@
1
1
  // src/bongos/routes/github.js — GitHub repo list/create for the signed-in user, the
2
2
  // server half of the onboarding wizard's repo picker (adopt) + auto-create (fresh),
3
3
  // plus the manage page's repo-visibility read/flip (task 1003045 — public only; the
4
- // private direction is refused until the private-repo deploy fix lands).
4
+ // private direction is the provisioning module's queued route, task 1003078).
5
5
  // Task 1002099 / ADR 0143.
6
6
  //
7
7
  // These act on the user's OWN GitHub identity, so they live in core auth alongside
@@ -127,9 +127,9 @@ module.exports = function buildGithubRouter() {
127
127
  if (!token) return;
128
128
  try {
129
129
  const repo = await githubRepos.getUserRepo(token, ref);
130
- // private_deployable is the platform truth the UI copy leans on: the deploy
131
- // pull runs as the shared deploy key, which cannot read a private repo (N4).
132
- res.json({ ok: true, repo, private_deployable: false });
130
+ // private_deployable: a private repo deploys through the project's own read-only
131
+ // deploy key (ADR 0176), which the manage page's make-private route wires first.
132
+ res.json({ ok: true, repo, private_deployable: true });
133
133
  } catch (err) {
134
134
  if (err && err.status === 401) {
135
135
  await db.deleteGithubToken({ builderId: req.builder.id }).catch(() => {});
@@ -145,9 +145,10 @@ module.exports = function buildGithubRouter() {
145
145
 
146
146
  // rank: any-builder — flips one of the caller's repos PUBLIC, with their own
147
147
  // credential. The private direction is REFUSED here, before GitHub is ever
148
- // called: the deploy pull still runs as the shared deploy key that cannot read
149
- // a private repo (walk finding N4), so flipping private would break the
150
- // project's next deploy — the refusal is the honest leg task 1003045 ships.
148
+ // called: a project stood up public reads its code with the shared key, which
149
+ // cannot read a private repo (walk finding N4), so a bare flip would break its
150
+ // next deploy. Making it private is POST /provisioning/instances/:id/repo-private
151
+ // (task 1003078), which wires the project's own deploy key before it flips.
151
152
  router.post('/github/repo/visibility', auth.requireBuilder, async (req, res) => {
152
153
  if (validateOrRespond(req, res, {
153
154
  repo: { required: true, type: 'string', maxLength: 140 },
@@ -158,14 +159,14 @@ module.exports = function buildGithubRouter() {
158
159
  if (req.body.visibility !== 'public') {
159
160
  return res.fail('private_not_deployable', {
160
161
  status: 409,
161
- message: 'Private repositories can’t be deployed yet — the platform’s deploy pull can’t read them, so making this repository private would break the project’s next deploy. This lands with the private-repo deploy fix.',
162
+ message: 'Make a repository private from its project’s manage page — the platform has to give the project its own deploy key first, or its next deploy would break.',
162
163
  });
163
164
  }
164
165
  const token = await resolveRepoToken(req, res);
165
166
  if (!token) return;
166
167
  try {
167
168
  const repo = await githubRepos.makeRepoPublic(token, ref);
168
- res.json({ ok: true, repo, private_deployable: false });
169
+ res.json({ ok: true, repo, private_deployable: true });
169
170
  } catch (err) {
170
171
  if (err && err.status === 401) {
171
172
  await db.deleteGithubToken({ builderId: req.builder.id }).catch(() => {});
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.981'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.983'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -623,3 +623,108 @@ test('the client is not configured to send API calls to the site root', () => {
623
623
  + 'resolve against /api/bongos/v1, or switch every call to api.request() with a full path',
624
624
  );
625
625
  });
626
+
627
+ // ---- THE SESSION START (task 1004181) -------------------------------------
628
+ // The copy control's job is to START A SESSION on an ask, and what it copied was
629
+ // the ask's prose alone: no request id, no task, no asker, no instruction. These
630
+ // run the prompt builders rather than grepping them, because every clause here is
631
+ // a fact a session acts on and a grep would agree with a prompt that was wrong.
632
+ const ASK = {
633
+ id: '11',
634
+ requested_by: '1000025',
635
+ requested_by_name: 'IAMMASTERQUA',
636
+ created_at: '2026-09-19T21:38:27.389Z',
637
+ task_id: '1001948',
638
+ what_is_stuck: 'Task 1001948 should not be built. ADR 0100 §2 decided against it.\n\nSecond paragraph, kept.',
639
+ };
640
+ const BASE = 'https://cloudbongos.com/builders';
641
+
642
+ test('the copied prompt INVOKES the skill, and names the request it is about', () => {
643
+ const p = lib().sessionStartPrompt(ASK, BASE);
644
+ assert.ok(p.startsWith('/collab-review 11\n'), 'first line is the invocation + the id');
645
+ assert.match(p, /help request 11/);
646
+ assert.match(p, /IAMMASTERQUA asked it on 2026-09-19\./, 'who asked, and when');
647
+ });
648
+
649
+ test('the ask travels VERBATIM, opening included, line breaks kept', () => {
650
+ const p = lib().sessionStartPrompt(ASK, BASE);
651
+ assert.ok(p.includes(ASK.what_is_stuck), 'the asker\'s own text, untrimmed of its first sentence');
652
+ assert.ok(p.includes('\n\nSecond paragraph, kept.'), 'its paragraphs survive');
653
+ });
654
+
655
+ test('the link is ABSOLUTE — the prompt is opened somewhere that is not this page', () => {
656
+ const L = lib();
657
+ assert.match(L.sessionStartPrompt(ASK, BASE), /https:\/\/cloudbongos\.com\/builders\/task\/1001948/);
658
+ // The hall also answers as its own host, where there is no /builders segment.
659
+ assert.match(L.sessionStartPrompt(ASK, 'https://builders.cloudbongos.com'),
660
+ /https:\/\/builders\.cloudbongos\.com\/task\/1001948/);
661
+ // A goal context resolves to the goals page's own route, not a task url.
662
+ const g = L.sessionStartPrompt({ ...ASK, task_id: null, goal_id: '1000095' }, BASE);
663
+ assert.match(g, /goal 1000095 — https:\/\/cloudbongos\.com\/builders\/goals#\/goal\/1000095/);
664
+ // An ask attached to nothing says so rather than linking somewhere wrong.
665
+ assert.match(L.sessionStartPrompt({ ...ASK, task_id: null }, BASE), /names no task and no goal/);
666
+ });
667
+
668
+ test('the prompt carries the two rules a session would otherwise break', () => {
669
+ const p = lib().sessionStartPrompt(ASK, BASE);
670
+ assert.match(p, /[Cc]heck what the ask claims/, 'verify before acting on someone else\'s certainty');
671
+ assert.match(p, /only the builder who asked can settle/, 'the recipient cannot settle it');
672
+ });
673
+
674
+ test('nothing to copy copies nothing — an empty clipboard is not a success', () => {
675
+ const L = lib();
676
+ assert.equal(L.sessionStartPrompt(null, BASE), '');
677
+ assert.equal(L.queueStartPrompt([], BASE), '');
678
+ assert.equal(L.queueStartPrompt(null, BASE), '');
679
+ });
680
+
681
+ test('the QUEUE prompt is a manifest, oldest first — not six asks of pasted prose', () => {
682
+ const L = lib();
683
+ const older = { ...ASK, id: '4', created_at: '2026-09-10T00:00:00Z', task_id: '1002068' };
684
+ const p = L.queueStartPrompt([ASK, older], BASE);
685
+ assert.ok(p.startsWith('/collab-review\n'), 'the whole-queue form takes no id');
686
+ assert.match(p, /Work the 2 open asks/);
687
+ assert.ok(p.indexOf('help request 4') < p.indexOf('help request 11'), 'oldest first — age is the point');
688
+ assert.ok(!p.includes(ASK.what_is_stuck), 'the prose stays on the server; the session reads it live');
689
+ assert.match(p, /GET \/api\/bongos\/help-requests\/for-me/, 'and is told where to read it');
690
+ assert.match(L.queueStartPrompt([ASK], BASE), /Work the 1 open ask addressed/, 'one ask is not "1 asks"');
691
+ });
692
+
693
+ test('the copy control is on EVERY row, keyed by the request, not by the door', () => {
694
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
695
+ // The defect this replaced: the control lifted its text out of the hidden <p>
696
+ // holding the full ask, so a short ask — which has no <p> — had no copy either.
697
+ assert.match(js, /data-ask-copy="\$\{escapeHtml\(String\(req\.id\)\)\}"/, 'the request id is the key');
698
+ assert.ok(!/data-ask-copy="\$\{fid\}"/.test(js), 'no longer keyed by the door\'s DOM id');
699
+ assert.match(js, /askById\.get\(String\(btn\.getAttribute\('data-ask-copy'\)\)\)/);
700
+ assert.match(js, /L\.sessionStartPrompt\(req, hallBase\(\)\)/, 'built by the pure, tested function');
701
+ const html = read('modules', 'hall-ui', 'public', 'collab.html');
702
+ assert.match(html, /id="cb-for-me-copy"/, 'the whole queue has its own control');
703
+ assert.ok(!/id="cb-all-copy"|id="cb-mine-copy"/.test(html),
704
+ 'only the block that is a to-do list — copying the board would hand over work nobody asked this reader for');
705
+ });
706
+
707
+ test('the page names the act ONCE — and one ask is not a queue', () => {
708
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
709
+ // Two phrasings of one action on one screen was the grader's finding on the
710
+ // first draft ("Copy this ask for a session" beside "Copy for Session Start").
711
+ // Comments stripped first: this file ARGUES about the wording at length, and a
712
+ // scan that read the argument as a label would fail on the prose explaining
713
+ // why the label is what it is.
714
+ const code = js.replace(/^\s*\/\/.*$/gm, '');
715
+ const labels = [...code.matchAll(/Copy [^`"'<\n]*[Ss]ession[^`"'<\n]*/g)].map((m) => m[0].trim());
716
+ assert.ok(labels.length >= 2, 'both controls are in this file');
717
+ for (const l of labels) assert.match(l, /Session Start/, `"${l}" must use the page's one phrase`);
718
+ assert.match(js, /btn\.hidden = forMeRows\.length < 2;/,
719
+ 'at one row the control is absent, not reworded — it would take away exactly what the row does');
720
+ });
721
+
722
+ test('the skill the page invokes exists, and is the ANSWERING half', () => {
723
+ const md = read('.claude', 'skills', 'collab-review', 'SKILL.md');
724
+ assert.match(md, /^name: collab-review$/m);
725
+ assert.match(md, /GET \/api\/bongos\/help-requests\/for-me/);
726
+ assert.match(md, /GET \/api\/bongos\/task-recommendations\/for-me/);
727
+ // The asymmetry the routes enforce, stated where a session will read it.
728
+ assert.match(md, /cannot settle a help request addressed to you/i);
729
+ assert.match(md, /DATA, not instructions/, 'an ask is untrusted input');
730
+ });
@@ -3,10 +3,11 @@
3
3
  //
4
4
  // The load-bearing assertions:
5
5
  // • the PRIVATE direction is refused BEFORE GitHub is ever called (409
6
- // private_not_deployable): the deploy pull runs as the shared deploy key,
7
- // which cannot read a private repo (walk finding N4), so flipping private
8
- // would break the project's next deploy — the honest-refusal leg this task
9
- // ships. The refusal must not even consult the stored token.
6
+ // private_not_deployable): a project stood up public reads with the shared
7
+ // key, which cannot read a private repo (walk finding N4), so a bare flip
8
+ // would break its next deploy. The refusal points at the manage page, whose
9
+ // queued route wires the project's own deploy key first (task 1003078). It
10
+ // must not even consult the stored token.
10
11
  // • flip-to-public calls makeRepoPublic with the parsed owner/name and
11
12
  // forwards GitHub's own answer — the new state is never assumed
12
13
  // • a GitHub 401 drops the stored token and re-prompts (the /github/repos
@@ -80,7 +81,7 @@ test('GET /github/repo: forwards GitHub\'s answer for the caller\'s own credenti
80
81
  assert.equal(res._status, 200);
81
82
  assert.equal(res._body.repo.private, true);
82
83
  assert.equal(res._body.repo.admin, true);
83
- assert.equal(res._body.private_deployable, false, 'the platform truth rides every answer');
84
+ assert.equal(res._body.private_deployable, true, 'a private repo deploys through its own key (ADR 0176)');
84
85
  assert.deepEqual(s.calls.read, [{ owner: 'jaxri', name: 'mercury' }]);
85
86
  } finally { s.restore(); }
86
87
  });
@@ -129,7 +130,9 @@ test('POST visibility=private: refused 409 private_not_deployable — GitHub and
129
130
  await postHandler()({ builder: { id: 1 }, body: { repo: 'jaxri/mercury', visibility: 'private' } }, res);
130
131
  assert.equal(res._status, 409);
131
132
  assert.equal(res._body.error.code, 'private_not_deployable');
132
- assert.match(res._body.error.message, /deploy/i, 'the refusal says WHY in plain words');
133
+ assert.match(res._body.error.message, /deploy key/i, 'the refusal says WHY in plain words');
134
+ assert.match(res._body.error.message, /manage page/, 'and names the door that CAN do it');
135
+ assert.doesNotMatch(res._body.error.message, /can’t be deployed yet/, 'the old platform-wide refusal is gone');
133
136
  assert.equal(s.calls.flip.length, 0, 'no GitHub call for a refused direction');
134
137
  assert.equal(tokenReads, 0, 'the refusal happens before the credential is even read');
135
138
  } finally { db.getGithubToken = origGet; s.restore(); }
@@ -203,7 +203,11 @@ test('the door and the copy control are on the row, and the copy control is reac
203
203
  assert.match(js, /L\.summaryOf\(req\.what_is_stuck\)/, 'the face is built from summaryOf');
204
204
  assert.match(js, /class="ov-row__title">\$\{escapeHtml\(s\.title\)\}/);
205
205
  assert.match(js, /class="ov-row__sub">\$\{escapeHtml\(s\.lead\)\}/);
206
- assert.match(js, /if \(!s\.more\) return html;/, 'no door when the ask already fits');
206
+ // No door when the ask already fits — since task 1004181 that is a gate on the
207
+ // door alone rather than an early return, because the COPY control below it is
208
+ // now rendered either way.
209
+ assert.match(js, /const door = s\.more/, 'no door when the ask already fits');
210
+ assert.match(js, /const full = s\.more \?/, 'and nothing hidden behind it to open');
207
211
 
208
212
  // The door announces its state and names what it controls, so it works from
209
213
  // the keyboard and reads correctly to a screen reader.
@@ -216,15 +220,24 @@ test('the door and the copy control are on the row, and the copy control is reac
216
220
  // whether or not the ask is open — the owner asked for it "in the closed
217
221
  // version next to the link", and a control only reachable after expanding
218
222
  // would make a reader open four thousand characters to take them away.
223
+ //
224
+ // AND SINCE TASK 1004181 IT IS RENDERED EVEN WHEN THERE IS NO DOOR. What the
225
+ // control hands over stopped being the visible text and became the request —
226
+ // its id, its asker, its task and the instruction — none of which is on the
227
+ // face however short the ask is. So the shortest asks, the ones a session can
228
+ // act on fastest, stopped being the only ones you could not take away.
219
229
  const bar = js.slice(js.indexOf('<div class="cb-ask">'), js.indexOf('</div>', js.indexOf('<div class="cb-ask">')));
220
- assert.match(bar, /data-ask-toggle/, 'the door is in the bar');
230
+ assert.match(bar, /\$\{door\}/, 'the door is in the bar');
221
231
  assert.match(bar, /Copy for Session Start/, 'and so is the copy control, in the owner\'s words');
222
232
  assert.ok(!/hidden/.test(bar), 'the bar itself is never hidden');
233
+ assert.ok(!/s\.more/.test(bar), 'and the copy control is not gated on there being more to read');
223
234
  assert.match(js, /<p class="cb-ask__full" id="\$\{fid\}" hidden>/, 'only the ask is hidden');
224
235
 
225
- // What lands on the clipboard is the ask verbatim, line breaks and all.
226
- assert.match(js, /full\.textContent/, 'copied from textContent, so the line breaks survive');
227
- assert.ok(!/full\.innerText/.test(js), 'innerText on a hidden element is not guaranteed to keep them');
236
+ // What lands on the clipboard is the SESSION START — the skill, the id, the
237
+ // context, then the ask verbatim. The prompt itself is EXECUTED in
238
+ // tests/collab_page.mjs; this only checks the page reaches for it.
239
+ assert.match(js, /L\.sessionStartPrompt\(req, hallBase\(\)\)/, 'through the pure, tested builder');
240
+ assert.ok(!/full\.innerText/.test(js), 'no innerText — on a hidden element it need not keep the line breaks');
228
241
  assert.match(js, /document\.execCommand\('copy'\)/, 'with the hall\'s fallback for an insecure context');
229
242
 
230
243
  // One request can appear in three blocks at once, so the id is block-scoped.
@@ -1210,10 +1210,10 @@ test('every owned card carries its Manage door, and a modified click keeps its m
1210
1210
 
1211
1211
  /* ── Repo visibility on the manage page (task 1003045) ──────────────────────
1212
1212
  The section reads the repo's REAL visibility from GitHub with the owner's
1213
- own repository credential and offers exactly one direction: private→public.
1214
- The private direction is refused — server-side and here — until the
1215
- private-repo deploy fix lands, because the deploy pull runs as the shared
1216
- deploy key that cannot read a private repo (walk finding N4). ── */
1213
+ own repository credential. Going public is the direct core call; going
1214
+ private (task 1003078) is QUEUED on the provisioning route, because the
1215
+ project's own deploy key must be wired before the flip — a project stood up
1216
+ public reads with the shared key, which cannot read a private repo (N4). ── */
1217
1217
 
1218
1218
  test('the repo-visibility section is live markup with its body slot (C7)', () => {
1219
1219
  assert.match(HUB, /id="mRepoVis"/, 'the section body must be real markup');
@@ -1221,16 +1221,14 @@ test('the repo-visibility section is live markup with its body slot (C7)', () =>
1221
1221
  'the pre-answer state is honest waiting copy, not a fake answer');
1222
1222
  });
1223
1223
 
1224
- test('flip-to-private cannot travel from this page — only the public direction exists', () => {
1225
- assert.match(SCRIPT, /visibility: 'public'/, 'the one POST body pins the public direction');
1224
+ test('flip-to-private travels ONLY through the queued provisioning route, never the core flip', () => {
1225
+ assert.match(SCRIPT, /visibility: 'public'/, 'the core POST body still pins the public direction');
1226
1226
  assert.doesNotMatch(SCRIPT, /visibility:\s*'private'/,
1227
- 'no code path may send the private direction');
1228
- // the refusal is SAID, with its reason, not silently missing — post-ADR-0176 the
1229
- // reason is THIS project's shared-credential wiring, not a platform-wide inability
1230
- assert.match(SCRIPT, /Making it private isn’t possible from here yet — see below for why\./,
1231
- 'the refusal is SAID inline, and points at the fold that carries the reason');
1232
- assert.match(HUB, /would break the next deploy/,
1233
- 'the flip-to-private limitation is stated in plain words, in the fold');
1227
+ 'the core flip is never asked for private — it cannot wire the deploy key');
1228
+ assert.match(SCRIPT, /'\/provisioning\/instances\/' \+ encodeURIComponent\(manageId\) \+ '\/repo-private'/,
1229
+ 'the private direction is the provisioning route, keyed on the project');
1230
+ assert.doesNotMatch(HUB, /isn’t possible from here yet/, 'the refusal copy is gone');
1231
+ assert.doesNotMatch(HUB, /Why can’t it be private yet\?/, 'and so is its fold');
1234
1232
  });
1235
1233
 
1236
1234
  test('the visibility shown is GitHub\'s answer, never an assumption', () => {
@@ -1261,19 +1259,15 @@ test('the reconnect door asks for repository access and returns to this project'
1261
1259
  'and returns to the exact project being managed');
1262
1260
  });
1263
1261
 
1264
- test('the refusal carries a LINK to the limitation, never prose alone (task 1003077)', () => {
1265
- assert.match(HUB, /id="mRepoVisWhy"/, 'the why-fold is real markup beside the section body');
1262
+ test('the fold says what making it private does, in the order it happens (task 1003078)', () => {
1263
+ assert.match(HUB, /id="mRepoVisWhy"/, 'the fold is real markup beside the section body');
1266
1264
  const fold = (HUB.match(/<details[^>]*id="mRepoVisWhy"[\s\S]*?<\/details>/) || [''])[0];
1267
1265
  assert.ok(fold, 'the fold must be extractable');
1268
- assert.match(fold, /an older key that only works on public repositories/, 'the WHY is stated in plain words');
1269
- assert.match(fold, /\{\{buildersOrigin\}\}#\/task\/1002735/,
1270
- 'the tracked fix is a real link, hall-origin server-stamped (never client-filled)');
1271
- // apex-only: a child instance keeps the explanation, not another platform's tracker
1272
- const at = HUB.indexOf('{{buildersOrigin}}#/task/1002735');
1273
- const before = HUB.slice(0, at);
1274
- const opens = (before.match(/<!--apex-only:start-->/g) || []).length;
1275
- const closes = (before.match(/<!--apex-only:end-->/g) || []).length;
1276
- assert.equal(opens - closes, 1, 'the task link sits inside an apex-only sentinel pair');
1266
+ assert.match(fold, /What making it private does/);
1267
+ assert.match(fold, /its own read-only deploy key[\s\S]*only then makes the repository private/,
1268
+ 'the key comes first, the flip second — the safety property, in plain words');
1269
+ assert.match(fold, /needs admin rights on the repository/, 'the one thing the owner must bring');
1270
+ assert.doesNotMatch(fold, /an older key that only works on public repositories/, 'the old limitation is gone');
1277
1271
  });
1278
1272
 
1279
1273
  test('making a repo public asks first — exposure is never one accidental click', () => {
@@ -585,15 +585,16 @@ test('founder voice on the Device Flow step and the manual OAuth fallback', () =
585
585
  assert.doesNotMatch(manual, /auth_configured|healthz/, 'no JSON health check for a founder to perform');
586
586
  });
587
587
 
588
- test('repo visibility: the refusal is one line, the mechanism lives in the fold', () => {
588
+ test('repo visibility: the public line stays one line, then the door or the reason — the mechanism lives in the fold', () => {
589
589
  const rv = fn('renderRepoVis');
590
590
  assert.match(rv, /This repository is <b>public<\/b> — anyone can read its code\./, 'line one stays');
591
- assert.match(rv, /Making it private isn’t possible from here yet — see below for why\./);
591
+ assert.match(rv, /repoPrivDoor\(manageInst, repo\)/, 'the public branch offers the door, or says why there is none');
592
+ assert.doesNotMatch(rv, /isn’t possible from here yet/, 'the old refusal is gone');
592
593
  assert.doesNotMatch(rv, /shared credential/);
593
594
  const fold = (HUB.match(/<details[^>]*id="mRepoVisWhy"[\s\S]*?<\/details>/) || [''])[0];
594
- assert.match(fold, /an older key that only works on public repositories/);
595
+ assert.match(fold, /What making it private does/);
595
596
  assert.doesNotMatch(fold, /shared read-only credential/);
596
- assert.match(fold, /<!--apex-only:start--> — shipped as <a href="\{\{buildersOrigin\}\}#\/task\/1002735"/, 'the hall task link stays inside its apex-only sentinel');
597
+ assert.doesNotMatch(fold, /#\/task\//, 'the fold explains; it no longer points at a tracked fix');
597
598
  });
598
599
 
599
600
  test('a failed manage read offers a retry; the no-address sentence offers a door', () => {
@@ -0,0 +1,238 @@
1
+ // tests/projects_hub_repo_private.mjs
2
+ //
3
+ // The manage page's "Make it private" (split (c) of task 1003078): the page's OWN functions,
4
+ // lifted out of projects.html and run in a sandbox with a fake DOM and fetch — so what is
5
+ // proved is behaviour, not the shape of the source.
6
+ //
7
+ // The load-bearing assertions:
8
+ // • the door shows only where the route would accept it, and every other case says why
9
+ // • the POST reply is a RECEIPT: it lands in the stable slot, and nothing claims the
10
+ // repository is private until GitHub is asked again after the run settles
11
+ // • the in-flight line is fetch-free, repaints only when the run's state MOVES (the
12
+ // minute tick never flickers it), and the settle triggers exactly ONE GitHub re-read
13
+ // • a failed make-private reads as a failed make-private on the card, not a failed standup
14
+ //
15
+ // Run: node tests/projects_hub_repo_private.mjs
16
+
17
+ import { strict as assert } from 'node:assert';
18
+ import { readFileSync } from 'node:fs';
19
+ import vm from 'node:vm';
20
+
21
+ const HUB = readFileSync(new URL('../modules/public-landing/public/projects.html', import.meta.url), 'utf8').replace(/\r\n/g, '\n');
22
+
23
+ let passed = 0, failed = 0;
24
+ async function ta(name, fn) {
25
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
26
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
27
+ }
28
+
29
+ // A top-level (2-space) declaration, up to its own closing brace at that indent.
30
+ function grab(head) {
31
+ const at = HUB.indexOf(`\n ${head}`);
32
+ assert.ok(at >= 0, `${head} must exist`);
33
+ const end = HUB.indexOf('\n }', at + 1);
34
+ const close = HUB.slice(end, end + 5) === '\n };' ? end + 5 : end + 4;
35
+ return HUB.slice(at, close);
36
+ }
37
+ const SOURCES = [
38
+ 'function esc(', 'var FLOWS = {', 'function stripMarkup(', 'function stripFor(', 'function errCategory(',
39
+ 'function repoPrivOpen(', 'function repoPrivDoor(', 'function repoVisNote(', 'function renderRepoVis(',
40
+ 'function renderRepoVisState(', 'function askRepoPrivate(', 'function doRepoPrivate(',
41
+ ].map(grab).join('\n') + "\nfunction repoVisMsg(html) { $('mRepoVis').innerHTML = html; }\n";
42
+
43
+ const RECEIPT = { status: 200, body: { ok: true, queued: true, message: 'Queued <b>ok</b>.' } };
44
+ function world({ reply = RECEIPT } = {}) {
45
+ const els = { mRepoVis: { innerHTML: '' }, mRepoVisMsg: { className: 'projMsg', innerHTML: '' } };
46
+ const calls = { fetch: [], loadManage: [], reread: 0 };
47
+ let settle;
48
+ const done = new Promise((r) => { settle = r; });
49
+ const ctx = {
50
+ API: '/api/bongos', manageId: '18', manageInst: null, manageRepo: null,
51
+ mRepoPrivSeen: false, mRepoPrivState: '',
52
+ $: (id) => els[id],
53
+ document: { querySelector: () => null },
54
+ jsonOrEmpty: (res) => res.json(),
55
+ apiError: (data) => (data && data.error) || {},
56
+ signInAgain: () => 'SIGN-IN',
57
+ justLive: () => false,
58
+ loadRepoVisibility: () => { calls.reread++; },
59
+ loadManage: (...a) => { calls.loadManage.push(a); settle(); },
60
+ fetch: (url, opts) => {
61
+ calls.fetch.push({ url, opts });
62
+ return Promise.resolve({ ok: reply.status < 300, status: reply.status, json: async () => reply.body });
63
+ },
64
+ };
65
+ vm.createContext(ctx);
66
+ vm.runInContext(SOURCES, ctx, { filename: 'projects.html#repo-private' });
67
+ return { ctx, els, calls, done };
68
+ }
69
+
70
+ const LIVE = { id: 18, status: 'active', hosting_shape: 'standalone', target_ref: 'o/demo', open_intent: null };
71
+ const PUBLIC = { private: false, admin: true };
72
+
73
+ console.log('the door:');
74
+
75
+ await ta('a live Cloud-host project whose owner is a repo admin gets the button', async () => {
76
+ const { ctx } = world();
77
+ assert.match(ctx.repoPrivDoor(LIVE, PUBLIC), /data-repo-private>Make it private</);
78
+ });
79
+
80
+ for (const [label, inst, repo, why] of [
81
+ ['own host', { ...LIVE, hosting_shape: 'byo-host' }, PUBLIC, /on GitHub directly/],
82
+ ['another shape', { ...LIVE, hosting_shape: 'dedicated' }, PUBLIC, /Cloud host/],
83
+ ['not running', { ...LIVE, status: 'error' }, PUBLIC, /once the project is running/],
84
+ ['no admin rights', LIVE, { private: false, admin: false }, /can’t change this repository’s settings/],
85
+ ]) {
86
+ await ta(`${label}: no button, and a sentence saying why`, async () => {
87
+ const { ctx } = world();
88
+ const html = ctx.repoPrivDoor(inst, repo);
89
+ assert.doesNotMatch(html, /data-repo-private/);
90
+ assert.match(html, why);
91
+ });
92
+ }
93
+
94
+ console.log('\nthe request — a receipt, never a new state:');
95
+
96
+ await ta('Yes POSTs to the project’s repo-private route with NO body', async () => {
97
+ const w = world();
98
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
99
+ w.ctx.doRepoPrivate();
100
+ await w.done;
101
+ assert.equal(w.calls.fetch.length, 1);
102
+ assert.equal(w.calls.fetch[0].url, '/api/bongos/provisioning/instances/18/repo-private');
103
+ assert.equal(w.calls.fetch[0].opts.method, 'POST');
104
+ assert.equal(w.calls.fetch[0].opts.body, undefined, 'the route takes no fields');
105
+ });
106
+
107
+ await ta('a 200 puts the server’s sentence (escaped) in the stable slot, arms the re-read, and re-reads the row', async () => {
108
+ const w = world();
109
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
110
+ w.ctx.doRepoPrivate();
111
+ await w.done;
112
+ assert.equal(w.els.mRepoVisMsg.className, 'projMsg ok');
113
+ assert.equal(w.els.mRepoVisMsg.innerHTML, 'Queued &lt;b&gt;ok&lt;/b&gt;.');
114
+ assert.equal(w.ctx.mRepoPrivSeen, true, 'a run that settles before the re-read lands is still followed up');
115
+ assert.equal(JSON.stringify(w.ctx.manageInst.open_intent), JSON.stringify({ action: 'repo-private', state: 'pending' }),
116
+ 'the receipt proves a run is open, so the row says so — and the 5s watch follows it');
117
+ assert.match(w.els.mRepoVis.innerHTML, /Making it private is queued/, 'the door gives way to the in-flight line at once');
118
+ assert.deepEqual(w.calls.loadManage, [[true, true]], 'quiet, keep the repo read');
119
+ assert.doesNotMatch(w.els.mRepoVis.innerHTML, /<b>private<\/b>/, 'nothing claims the flip happened');
120
+ });
121
+
122
+ await ta('a repaint from the row right after the click (the minute tick) keeps the run and its receipt', async () => {
123
+ const w = world();
124
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
125
+ w.ctx.doRepoPrivate();
126
+ await w.done;
127
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
128
+ assert.equal(w.calls.reread, 0, 'no settle is read into a run that has not been re-read yet');
129
+ assert.match(w.els.mRepoVisMsg.innerHTML, /Queued/);
130
+ assert.doesNotMatch(w.els.mRepoVis.innerHTML, /data-repo-private/, 'the button does not come back');
131
+ });
132
+
133
+ await ta('a 404 (an archon on someone else’s project) says the rule plainly', async () => {
134
+ const w = world({ reply: { status: 404, body: { error: { code: 'instance_not_found' } } } });
135
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
136
+ w.ctx.doRepoPrivate();
137
+ await w.done;
138
+ assert.equal(w.els.mRepoVisMsg.className, 'projMsg err');
139
+ assert.match(w.els.mRepoVisMsg.innerHTML, /Only the project’s owner can make its repository private/);
140
+ assert.equal(w.ctx.mRepoPrivSeen, false);
141
+ });
142
+
143
+ await ta('a 409 shows the server’s own sentence, escaped', async () => {
144
+ const w = world({ reply: { status: 409, body: { error: { code: 'intent_conflict', message: "A 'restart' run is <i>running</i>" } } } });
145
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
146
+ w.ctx.doRepoPrivate();
147
+ await w.done;
148
+ assert.equal(w.els.mRepoVisMsg.className, 'projMsg err');
149
+ assert.match(w.els.mRepoVisMsg.innerHTML, /restart.* run is &lt;i&gt;running&lt;\/i&gt;/);
150
+ assert.doesNotMatch(w.els.mRepoVisMsg.innerHTML, /<i>/);
151
+ });
152
+
153
+ console.log('\nthe in-flight line and the settle:');
154
+
155
+ await ta('the receipt slot is painted INSIDE #mRepoVis, never a sibling of it', async () => {
156
+ // every .mSec>div takes the same grid cell (cosmos.css), so a sibling would sit on top
157
+ const card = (HUB.match(/<div class="card mSec" id="mRepoVisibility">[\s\S]*?\n <\/div>/) || [''])[0];
158
+ assert.ok(card, 'the card markup is extractable');
159
+ assert.doesNotMatch(card, /id="mRepoVisMsg"/, 'no second grid child in the card markup');
160
+ const w = world();
161
+ w.ctx.manageInst = { ...LIVE };
162
+ w.ctx.renderRepoVis(PUBLIC);
163
+ assert.match(w.els.mRepoVis.innerHTML, /<div class="projMsg" id="mRepoVisMsg" role="status" aria-live="polite"><\/div>$/);
164
+ });
165
+
166
+ await ta('an open run replaces the door with the in-flight line, worded by its state', async () => {
167
+ const w = world();
168
+ w.ctx.manageRepo = PUBLIC;
169
+ w.ctx.manageInst = { ...LIVE, open_intent: { action: 'repo-private', state: 'pending' } };
170
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
171
+ assert.match(w.els.mRepoVis.innerHTML, /Making it private is queued/);
172
+ assert.doesNotMatch(w.els.mRepoVis.innerHTML, /data-repo-private/);
173
+ w.ctx.manageInst = { ...LIVE, open_intent: { action: 'repo-private', state: 'running' } };
174
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
175
+ assert.match(w.els.mRepoVis.innerHTML, /the platform is giving this project its own deploy key first/);
176
+ });
177
+
178
+ await ta('the minute tick with an unchanged state does not repaint', async () => {
179
+ const w = world();
180
+ w.ctx.manageRepo = PUBLIC;
181
+ w.ctx.manageInst = { ...LIVE, open_intent: { action: 'repo-private', state: 'running' } };
182
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
183
+ w.els.mRepoVis.innerHTML = 'SENTINEL';
184
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
185
+ assert.equal(w.els.mRepoVis.innerHTML, 'SENTINEL');
186
+ assert.equal(w.calls.reread, 0, 'and asks GitHub nothing while it runs');
187
+ });
188
+
189
+ await ta('the settle asks GitHub exactly once and clears the receipt', async () => {
190
+ const w = world();
191
+ w.ctx.manageRepo = PUBLIC;
192
+ w.ctx.renderRepoVisState({ ...LIVE, open_intent: { action: 'repo-private', state: 'running' } });
193
+ w.els.mRepoVisMsg.innerHTML = 'Queued.';
194
+ w.ctx.renderRepoVisState({ ...LIVE, open_intent: null });
195
+ w.ctx.renderRepoVisState({ ...LIVE, open_intent: null });
196
+ assert.equal(w.calls.reread, 1, 'only GitHub can say the flip took — asked once');
197
+ assert.equal(w.els.mRepoVisMsg.innerHTML, '');
198
+ });
199
+
200
+ await ta('another intent (a restart) is not mistaken for a make-private run', async () => {
201
+ const w = world();
202
+ w.ctx.manageRepo = PUBLIC;
203
+ w.ctx.manageInst = { ...LIVE, open_intent: { action: 'restart', state: 'running' } };
204
+ w.ctx.renderRepoVis(PUBLIC);
205
+ assert.match(w.els.mRepoVis.innerHTML, /data-repo-private/);
206
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
207
+ assert.equal(w.ctx.mRepoPrivSeen, false);
208
+ });
209
+
210
+ console.log('\nthe card:');
211
+
212
+ await ta('the mine-card strip names the make-private flow and its running half', async () => {
213
+ const { ctx } = world();
214
+ const run = ctx.stripFor({ ...LIVE, open_intent: { action: 'repo-private', state: 'running' } });
215
+ assert.match(run, /Setting up its deploy key/);
216
+ assert.match(run, /setting up the project’s own deploy key first/);
217
+ const queued = ctx.stripFor({ ...LIVE, open_intent: { action: 'repo-private', state: 'pending' } });
218
+ assert.match(queued, /Making the repository private is queued/);
219
+ assert.doesNotMatch(queued, /repo-private run is queued/, 'not the generic fallback');
220
+ });
221
+
222
+ await ta('a retired make-private reads as that, not as a standup that stopped', async () => {
223
+ const { ctx } = world();
224
+ for (const note of [
225
+ 'o/demo is still public: its deploy key could not be set up on the server. Nothing on your side caused this, and trying again is safe.',
226
+ 'o/demo cannot be made private right now: GitHub could not be reached to set up its deploy key (fetch failed). Nothing has been changed — try again.',
227
+ 'The deploy key for o/demo could not be set up, so the repository was left public.',
228
+ ]) assert.match(ctx.errCategory(note), /Making the repository private didn’t finish/, note);
229
+ for (const note of [
230
+ "o/demo cannot be made private: there is no usable GitHub credential stored for this project, and without one its deploy key cannot be registered. Reconnect the project's GitHub access, then try again.",
231
+ "o/demo was not made private: the project's GitHub credential could not be read. Its deploy key is already in place, so reconnect GitHub and try again.",
232
+ ]) assert.match(ctx.errCategory(note), /needs reconnecting/, 'the one fix the owner can make is not folded away');
233
+ assert.match(ctx.errCategory('deploy-key setup failed for o/demo: GitHub deploy-key add failed (500).'), /deploy key on GitHub didn’t finish/);
234
+ assert.match(ctx.errCategory('repository not found: GitHub answered 404'), /couldn’t find the project’s repository/, 'the older categories are untouched');
235
+ });
236
+
237
+ console.log(`\nprojects_hub_repo_private: ${passed} passed, ${failed} failed`);
238
+ process.exit(failed ? 1 : 0);