@bongos/core 1.19.981 → 1.19.982

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.
@@ -2436,5 +2436,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2436
2436
  landed since 1.19.979 with no explicit bump. run 35889261972. (task 1002620)
2437
2437
  1.19.981 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2438
2438
  landed since 1.19.980 with no explicit bump. run 35891635199. (task 1002620)
2439
+ 1.19.982 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2440
+ landed since 1.19.981 with no explicit bump. run 35894422097. (task 1002620)
2439
2441
  ---------------------------------------------------------------------------
2440
2442
  ```
@@ -16,7 +16,8 @@
16
16
  "provisioning",
17
17
  "disconnect",
18
18
  "core-upgrade",
19
- "env-manifest"
19
+ "env-manifest",
20
+ "repo-private"
20
21
  ],
21
22
  "migrations": true,
22
23
  "pollers": [
@@ -0,0 +1,82 @@
1
+ // modules/provisioning/routes/repo-private.js — POST /provisioning/instances/:id/repo-private
2
+ // (split (c) of task 1003078; ADR 0176).
3
+ //
4
+ // WHY. A project stood up while its repository was public reads its code with the shared key,
5
+ // which cannot read a private repository, so a plain flip would break its next deploy. This
6
+ // route only ENQUEUES the `repo-private` intent; the runner (scripts/gds/provision-repo-private.js)
7
+ // wires the project's own read-only deploy key FIRST and flips SECOND. Going public stays the
8
+ // direct core call (POST /github/repo/visibility) — it needs no wiring.
9
+ //
10
+ // Its own routes file (declared in module.json) because routes/provisioning.js sits at the
11
+ // size ratchet — the routes/disconnect.js precedent.
12
+ 'use strict';
13
+
14
+ const express = require('express');
15
+ const api = require('../../../src/module-api');
16
+ const { pool, validateOrRespond, parseId } = api;
17
+ const provisioning = require('../provisioning');
18
+ const repoPrivate = require('../repo-private');
19
+
20
+ const log = api.logger('provisioning');
21
+
22
+ // What the runner leg would refuse, answered here so the owner hears it at once instead of
23
+ // as an error note once its retries are spent. The runner keeps its own checks — the row can
24
+ // change between this read and the drain.
25
+ function unavailableReason(inst) {
26
+ if (inst.hosting_shape === 'byo-host') {
27
+ return 'This project runs on your own host, so the platform never reads its code — you can make the repository private on GitHub directly.';
28
+ }
29
+ if (inst.hosting_shape !== 'standalone') {
30
+ return 'Only a project on a Cloud host can have its repository made private from here.';
31
+ }
32
+ if (inst.status !== 'active') {
33
+ return 'This project isn’t running right now, so its repository can’t be made private from here. Try again once it’s live.';
34
+ }
35
+ if (!inst.target_ref) return 'This project has no GitHub repository linked, so there is nothing to make private.';
36
+ return null;
37
+ }
38
+
39
+ module.exports = function buildRepoPrivateRouter() {
40
+ const router = express.Router();
41
+
42
+ // POST /provisioning/instances/:id/repo-private — rank: any authenticated builder, and
43
+ // OWNER ONLY: no archon override, because the runner flips with the owner's stored GitHub
44
+ // token whoever asked (owner decision, task 1004194). 404 for everyone else, so another
45
+ // owner's id is never confirmed. Takes no body. The reply is a receipt — the repository is
46
+ // still public when it is sent; the manage page re-reads GitHub once the intent settles.
47
+ router.post('/provisioning/instances/:id/repo-private', api.requireBuilder, async (req, res) => {
48
+ const id = parseId(req, res, { positiveInt: true }); // 400 bad_id + null on failure; positiveInt so id 0 is answered, not dropped
49
+ if (!id) return;
50
+ if (validateOrRespond(req, res, {})) return;
51
+ try {
52
+ const inst = await provisioning.getInstanceById(pool, id);
53
+ if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) return res.fail('instance_not_found', 404);
54
+ const why = unavailableReason(inst);
55
+ if (why) {
56
+ return res.fail('repo_private_unavailable', { status: 409, message: why,
57
+ details: { status: inst.status, hosting_shape: inst.hosting_shape } });
58
+ }
59
+ const r = await repoPrivate.enqueueRepoPrivate(pool, id, 'api:self');
60
+ if (r.conflict) {
61
+ return res.fail('intent_conflict', { status: 409,
62
+ message: `A '${r.intent.action}' run is currently ${r.intent.state} for this project — making the repository private waits for it to finish. Try again once it has.`,
63
+ details: { action: r.intent.action, state: r.intent.state } });
64
+ }
65
+ if (r.created) {
66
+ await provisioning.recordEvent(pool, {
67
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'repo-private',
68
+ detail: 'making the repository private requested by owner', actor: 'api:self',
69
+ }).catch(() => {});
70
+ }
71
+ res.json({ ok: true, queued: r.created, action: 'repo-private', status: inst.status,
72
+ message: r.created
73
+ ? 'Queued. The platform first gives this project its own read-only deploy key, then makes the repository private. This page updates when it’s done.'
74
+ : 'Making the repository private is already under way for this project.' });
75
+ } catch (err) {
76
+ log.error('[provisioning] POST /provisioning/instances/:id/repo-private', err);
77
+ res.fail('repo_private_failed', { status: 500, message: 'internal error' });
78
+ }
79
+ });
80
+
81
+ return router;
82
+ };
@@ -762,20 +762,16 @@ summary{min-height:24px;padding:3px 0;}
762
762
  </div>
763
763
 
764
764
  <!-- live since task 1003045: reads the repo's real visibility from GitHub
765
- with the owner's own repository credential. Flip-to-PUBLIC only — the
766
- private direction is refused (server + copy) until the private-repo
767
- deploy fix lands, because the deploy pull cannot read a private repo
768
- (walk finding N4). -->
765
+ with the owner's own repository credential. Going public is a direct
766
+ call; going private (task 1003078) is QUEUED, because the project's own
767
+ deploy key must be wired before the flip or its next deploy breaks
768
+ (walk finding N4, ADR 0176). -->
769
769
  <div class="card mSec" id="mRepoVisibility">
770
770
  <h2>Repository visibility</h2>
771
771
  <div id="mRepoVis"><p class="finePrint">Checking the repository on GitHub…</p></div>
772
- <!-- the limitation itself, one click away (grade follow-up task 1003077):
773
- the refusal above states it; this fold explains it and links the
774
- tracked fix. The hall link is apex-only — a child instance keeps the
775
- explanation, not another platform's task tracker. -->
776
772
  <details class="finePrint" id="mRepoVisWhy">
777
- <summary>Why can’t it be private yet?</summary>
778
- <p>This project’s deploys read its code from GitHub with an older key that only works on public repositories — so making the repository private would break the next deploy. New standups fix this: a private repository now gets the project’s own read-only deploy key, wired automatically<!--apex-only:start--> — shipped as <a href="{{buildersOrigin}}#/task/1002735" target="_blank" rel="noopener">task 1002735</a> in the builders’ hall<!--apex-only:end-->. A project set up before that still reads with the older key, so the switch stays off until existing projects are re-wired.</p>
773
+ <summary>What making it private does</summary>
774
+ <p>The platform first gives this project its own read-only deploy key and switches its deploys over to it, and only then makes the repository private — in that order, so no deploy ever tries to read a private repository with a key that can’t. The change is made with your connected GitHub account, so that account needs admin rights on the repository. It works for a project on a Cloud host; a project on your own host is yours to change on GitHub directly. You can make it public again from here at any time.</p>
779
775
  </details>
780
776
  </div>
781
777
 
@@ -5149,7 +5145,8 @@ summary{min-height:24px;padding:3px 0;}
5149
5145
  teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' },
5150
5146
  address: { names: ['Address queued', 'Wiring the address', 'Answering there'], cls: '' },
5151
5147
  settings: { names: ['Change saved', 'Restarting to apply it', 'Applied'], cls: '' },
5152
- restart: { names: ['Restart queued', 'Restarting', 'Answering again'], cls: '' }
5148
+ restart: { names: ['Restart queued', 'Restarting', 'Answering again'], cls: '' },
5149
+ repo: { names: ['Change queued', 'Setting up its deploy key', 'Private'], cls: '' }
5153
5150
  };
5154
5151
  function stripMarkup(flow, stageNow, stagesDone, note, extraCls) {
5155
5152
  var f = FLOWS[flow];
@@ -5191,6 +5188,10 @@ summary{min-height:24px;padding:3px 0;}
5191
5188
  if (a === 'restart') return inst.open_intent.state === 'running'
5192
5189
  ? stripMarkup('restart', 1, 1, 'Restarting the project — it goes down and comes back up.')
5193
5190
  : stripMarkup('restart', 0, 0, 'A restart is queued — this card updates when it starts.');
5191
+ /* task 1003078: the deploy key is wired BEFORE the flip — the strip says which half is running */
5192
+ if (a === 'repo-private') return inst.open_intent.state === 'running'
5193
+ ? stripMarkup('repo', 1, 1, 'Making the repository private — setting up the project’s own deploy key first.')
5194
+ : stripMarkup('repo', 0, 0, 'Making the repository private is queued — this card updates when it starts.');
5194
5195
  if (a === 'provision') return stripMarkup(s === 'torn_down' ? 'revive' : 'standup', 0, 0, (s === 'torn_down' ? 'A revive is queued' : 'A standup is queued') + ' — this card updates when it starts.');
5195
5196
  return stripMarkup('standup', 2, 2, 'A ' + esc(a) + ' run is queued — this card updates when it lands.');
5196
5197
  }
@@ -5215,6 +5216,10 @@ summary{min-height:24px;padding:3px 0;}
5215
5216
  var n = String(note || '');
5216
5217
  if (/^repository not found:/i.test(n)) return 'We couldn’t find the project’s repository on GitHub even with the access you granted — it may have been renamed or deleted, or the access was lost.';
5217
5218
  if (/^repository access is not admin:/i.test(n)) return 'Your GitHub access can read the repository but can’t manage its deploy keys — a repository admin needs to raise your access (or make the repository public).';
5219
+ /* the repo-private runner leg's own sentences (task 1003078) — the project is live */
5220
+ if (/made private[\s\S]*credential/i.test(n)) return 'Making the repository private didn’t finish — the project’s GitHub access needs reconnecting first. Its repository is still public.';
5221
+ if (/is still public|made private|left public/i.test(n)) return 'Making the repository private didn’t finish — it’s still public.';
5222
+ if (/^deploy-key setup failed/i.test(n)) return 'Setting up the project’s own read-only deploy key on GitHub didn’t finish.';
5218
5223
  if (/repository not found/i.test(n)) return 'We couldn’t push the starter code to the repository — it wasn’t found on GitHub. A private repository looks “not found” too — if yours is private, sign in again to re-grant repository access, then retry.';
5219
5224
  if (/mkdir|no such file or directory|read-only file system/i.test(n)) return 'The server couldn’t prepare a workspace for the project.';
5220
5225
  if (/(push|clone|github).*permission|permission.*(push|clone|repo)/i.test(n)) return 'We couldn’t reach the repository — the account that set it up doesn’t have permission.';
@@ -5617,6 +5622,7 @@ summary{min-height:24px;padding:3px 0;}
5617
5622
  only, so a queued settings push shows up on the tick without a reload */
5618
5623
  renderVisState(inst);
5619
5624
  renderJoinState(inst);
5625
+ renderRepoVisState(inst);
5620
5626
  /* its planet (task 1003347) reads off the row — fetch-free too; a pick in
5621
5627
  progress survives the tick because only a different project re-renders it */
5622
5628
  renderPick(inst);
@@ -5853,6 +5859,7 @@ summary{min-height:24px;padding:3px 0;}
5853
5859
  $('mChip').innerHTML = '';
5854
5860
  $('mOrb').innerHTML = '';
5855
5861
  }
5862
+ if (!keepRepo) { mRepoPrivSeen = false; mRepoPrivState = ''; }
5856
5863
  var asked = manageId; /* a stale answer must never paint a newer project */
5857
5864
  fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId), { credentials: 'same-origin' })
5858
5865
  .then(function (res) {
@@ -5922,14 +5929,16 @@ summary{min-height:24px;padding:3px 0;}
5922
5929
  setInterval(manageChipTick, REFRESH_MS);
5923
5930
 
5924
5931
  /* ── repo visibility (task 1003045): read live from GitHub with the owner's
5925
- own repository credential; flip-to-PUBLIC only. The private direction is
5926
- refused by the server — and unbuilt here — until the private-repo deploy
5927
- fix lands: the deploy pull runs as the shared deploy key, which cannot
5928
- read a private repo (walk finding N4), so flipping private would break
5929
- the project's next deploy. Every claim below is what GitHub answered for
5930
- THIS account — never a cached or guessed state. ── */
5932
+ own repository credential. Going public is a direct call. Going private
5933
+ (task 1003078) is QUEUED on the provisioning route: the runner wires the
5934
+ project's own deploy key first, because a project stood up public reads
5935
+ with the shared key, which cannot read a private repo (walk finding N4).
5936
+ Every claim below is what GitHub answered for THIS account — never a
5937
+ cached or guessed state; the queue's receipt is not a new visibility. ── */
5931
5938
 
5932
5939
  var manageRepo = null; /* GitHub's last answer for the manage view's repo */
5940
+ var mRepoPrivSeen = false; /* a make-private run was open — ask GitHub again once it settles */
5941
+ var mRepoPrivState = ''; /* the in-flight line last painted: '', 'pending' or 'running' */
5933
5942
 
5934
5943
  function repoVisReconnect() {
5935
5944
  return API + '/auth/web/start?repos=1&return=' +
@@ -5947,14 +5956,67 @@ summary{min-height:24px;padding:3px 0;}
5947
5956
  '<div class="addr">This repository is <b>private</b> — only accounts it’s shared with can read its code.</div>' +
5948
5957
  (snagged
5949
5958
  ? '<div class="projMsg err">A private repository deploys through the project’s own read-only deploy key, wired at standup. If the standup couldn’t read it, retry it from the card above — or make the repository public.</div>'
5950
- : '<p class="finePrint">The platform reads it through this project’s own read-only deploy key, set up at standup. Nothing to do here.</p>') +
5959
+ : '<p class="finePrint">The platform reads it through this project’s own read-only deploy key. Nothing to do here.</p>') +
5951
5960
  (repo.admin
5952
5961
  ? '<div class="actions"><button type="button" class="btn small ghost" data-repo-public>Make it public</button></div>'
5953
5962
  : '<p class="finePrint">Your GitHub account can’t change this repository’s settings.</p>'));
5954
5963
  } else {
5955
- repoVisMsg(
5956
- '<div class="addr">This repository is <b>public</b> — anyone can read its code.</div>' +
5957
- '<p class="finePrint">Making it private isn’t possible from here yet — see below for why.</p>');
5964
+ repoVisMsg('<div class="addr">This repository is <b>public</b> — anyone can read its code.</div>' +
5965
+ (repoPrivOpen(manageInst)
5966
+ ? '<p class="finePrint">' + (manageInst.open_intent.state === 'running'
5967
+ ? 'Making it private — the platform is giving this project its own deploy key first.'
5968
+ : 'Making it private is queued — the platform gives this project its own deploy key first. Until then it stays public.') + '</p>'
5969
+ : repoPrivDoor(manageInst, repo)) +
5970
+ /* the make-private receipt's slot, painted WITH the state (the #mVisMsg idiom) —
5971
+ a sibling div would land on this one: every .mSec>div takes the same grid cell */
5972
+ '<div class="projMsg" id="mRepoVisMsg" role="status" aria-live="polite"></div>');
5973
+ }
5974
+ }
5975
+
5976
+ /* fetch-free, from open_intent only (renderManage calls it on every repaint) */
5977
+ function repoPrivOpen(inst) {
5978
+ return !!(inst && inst.open_intent && inst.open_intent.action === 'repo-private');
5979
+ }
5980
+
5981
+ /* the one door to private, or the sentence saying why there is none here — the
5982
+ route refuses the same cases; this only keeps a button off a dead end */
5983
+ function repoPrivDoor(inst, repo) {
5984
+ if (!inst) return '';
5985
+ var why = inst.hosting_shape === 'byo-host' ? 'This project runs on your own host, so the platform never reads its code — you can make the repository private on GitHub directly.'
5986
+ : inst.hosting_shape !== 'standalone' ? 'Only a project on a Cloud host can have its repository made private from here.'
5987
+ : inst.status !== 'active' ? 'You can make it private once the project is running.'
5988
+ : !repo.admin ? 'Your GitHub account can’t change this repository’s settings.'
5989
+ : '';
5990
+ if (why) return '<p class="finePrint">' + why + '</p>';
5991
+ return '<p class="finePrint">Making it private gives this project its own deploy key first, so its deploys keep working.</p>' +
5992
+ '<div class="actions"><button type="button" class="btn small ghost" data-repo-private>Make it private</button></div>';
5993
+ }
5994
+
5995
+ function repoVisNote(cls, html) {
5996
+ var box = $('mRepoVisMsg');
5997
+ if (!box) {
5998
+ if (!html) return;
5999
+ $('mRepoVis').insertAdjacentHTML('beforeend', '<div class="projMsg" id="mRepoVisMsg" role="status" aria-live="polite"></div>');
6000
+ box = $('mRepoVisMsg');
6001
+ }
6002
+ box.className = 'projMsg' + (cls ? ' ' + cls : '');
6003
+ box.innerHTML = html;
6004
+ }
6005
+
6006
+ /* the in-flight line is repainted only when the run's state MOVES, so the tick
6007
+ never flickers it; once the run settles, GitHub is asked again — the only
6008
+ source that can say the flip took (the renderVisState contract) */
6009
+ function renderRepoVisState(inst) {
6010
+ var now = repoPrivOpen(inst) ? inst.open_intent.state : '';
6011
+ if (now) mRepoPrivSeen = true;
6012
+ if (now !== mRepoPrivState) {
6013
+ mRepoPrivState = now;
6014
+ if (manageRepo && !manageRepo.private) renderRepoVis(manageRepo);
6015
+ }
6016
+ if (!now && mRepoPrivSeen) {
6017
+ mRepoPrivSeen = false;
6018
+ repoVisNote('', '');
6019
+ loadRepoVisibility(inst);
5958
6020
  }
5959
6021
  }
5960
6022
 
@@ -6034,12 +6096,58 @@ summary{min-height:24px;padding:3px 0;}
6034
6096
  });
6035
6097
  }
6036
6098
 
6099
+ function askRepoPrivate() {
6100
+ var act = document.querySelector('#mRepoVis .actions');
6101
+ if (!act) return;
6102
+ act.innerHTML = '<div class="confirmRow">' +
6103
+ '<span class="plain">Make it private? Only accounts it’s shared with will be able to read the code.</span>' +
6104
+ '<button type="button" class="btn small" data-repo-private-yes>Yes, make it private</button>' +
6105
+ '<button type="button" class="linklike" data-repo-private-no>Keep it public</button></div>';
6106
+ }
6107
+
6108
+ /* the reply is a RECEIPT — the repository is still public when it lands. The
6109
+ in-flight line reads open_intent, so the manage row is re-read, never patched */
6110
+ function doRepoPrivate() {
6111
+ if (!manageInst) return;
6112
+ var btn = document.querySelector('#mRepoVis [data-repo-private-yes]');
6113
+ if (btn) btn.disabled = true;
6114
+ repoVisNote('', 'Queueing…');
6115
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/repo-private', {
6116
+ method: 'POST', credentials: 'same-origin'
6117
+ }).then(function (res) {
6118
+ return jsonOrEmpty(res).then(function (data) {
6119
+ if (res.ok) {
6120
+ /* the receipt proves a run is OPEN — never that the repository changed. Record
6121
+ that much, so the 5s watch follows it and a repaint from the stale row cannot
6122
+ drop it; the re-read below swaps in the server's own open_intent */
6123
+ if (!repoPrivOpen(manageInst)) manageInst.open_intent = { action: 'repo-private', state: 'pending' };
6124
+ renderRepoVisState(manageInst);
6125
+ repoVisNote('ok', esc(data.message || 'Queued.'));
6126
+ return;
6127
+ }
6128
+ var e = apiError(data, res.status);
6129
+ if (manageRepo) renderRepoVis(manageRepo);
6130
+ repoVisNote('err', res.status === 401 ? signInAgain()
6131
+ : res.status === 404 ? 'Only the project’s owner can make its repository private.'
6132
+ : esc(e.message || 'The change could not be queued — try again in a moment.'));
6133
+ });
6134
+ }).catch(function () {
6135
+ if (manageRepo) renderRepoVis(manageRepo);
6136
+ repoVisNote('err', 'Couldn’t reach the platform — check your connection and try again.');
6137
+ }).then(function () {
6138
+ loadManage(true, true);
6139
+ });
6140
+ }
6141
+
6037
6142
  $('mRepoVis').addEventListener('click', function (ev) {
6038
6143
  var t = ev.target.closest && ev.target.closest('button');
6039
6144
  if (!t) return;
6040
6145
  if (t.hasAttribute('data-repo-public')) return askRepoPublic();
6041
6146
  if (t.hasAttribute('data-repo-public-yes')) return doRepoPublic();
6042
6147
  if (t.hasAttribute('data-repo-public-no')) { if (manageRepo) renderRepoVis(manageRepo); }
6148
+ if (t.hasAttribute('data-repo-private')) return askRepoPrivate();
6149
+ if (t.hasAttribute('data-repo-private-yes')) return doRepoPrivate();
6150
+ if (t.hasAttribute('data-repo-private-no')) { if (manageRepo) renderRepoVis(manageRepo); }
6043
6151
  });
6044
6152
 
6045
6153
  /* ── on the public map (task 1002335, BV1.R16): the publish gate ──────────
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.982",
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.982",
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.982",
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.982'; // 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');
@@ -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(); }
@@ -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', () => {