@bongos/core 1.19.975 → 1.19.976

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.975",
6
- "core_contract": "1.19.975",
7
- "source_commit": "4073475edb22dae11e825a4191c1467101ca8b78",
5
+ "core_version": "1.19.976",
6
+ "core_contract": "1.19.976",
7
+ "source_commit": "842af7dc231eb9ad6803f0de071d554622562402",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-23T14:01:28.147Z",
9
+ "built_at": "2026-09-23T15:01:32.683Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 516,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2378,
14
+ "functional_verbatim": 2382,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2920,
20
- "tree_sha256": "2ff13cad510adfd6dc7430579c417a6c8216d515d0fadb546fec92844fee0186",
19
+ "file_count": 2924,
20
+ "tree_sha256": "6ad34cb8327cf6329010817bd052759a99e906fb785e2f370bb15ee113343cc1",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -1362,7 +1362,7 @@
1362
1362
  {
1363
1363
  "path": "docs/adr/0176-private-repo-deploy-keys.md",
1364
1364
  "mode": "0000644",
1365
- "sha256": "80c1b9d283892454f45504fe3f20f3a215967de7cca66a5b3ef30970bb8eed47"
1365
+ "sha256": "c10a9b3039835676e84c1cedac77b3897fc03f5db689ecc34b8b658c51210d5f"
1366
1366
  },
1367
1367
  {
1368
1368
  "path": "docs/adr/0177-goal-membership-kind.md",
@@ -2702,7 +2702,7 @@
2702
2702
  {
2703
2703
  "path": "docs/module-api-changelog.md",
2704
2704
  "mode": "0000644",
2705
- "sha256": "7daf42cf3ac4f8acccb5543777095d0cbd57c6cf44769c12eb9ad3c0f45caa29"
2705
+ "sha256": "9b3e01085f3f3648c51df5ecc0e6d6b4e6c687e90aceaa4e3c80689500767c88"
2706
2706
  },
2707
2707
  {
2708
2708
  "path": "docs/modules-contract.md",
@@ -6719,6 +6719,11 @@
6719
6719
  "mode": "0000644",
6720
6720
  "sha256": "1287440b0088a6451dd4df234e578e06fc6f3e892915e64cd6c6e1f09dadd9d5"
6721
6721
  },
6722
+ {
6723
+ "path": "modules/provisioning/migrations/provisioning_025_repo_private.sql",
6724
+ "mode": "0000644",
6725
+ "sha256": "949130ba321fc382c30fd7bfb76572e8415492b8641f644719cef3658a415cb9"
6726
+ },
6722
6727
  {
6723
6728
  "path": "modules/provisioning/module.json",
6724
6729
  "mode": "0000644",
@@ -6757,7 +6762,7 @@
6757
6762
  {
6758
6763
  "path": "modules/provisioning/provisioning.js",
6759
6764
  "mode": "0000644",
6760
- "sha256": "82bb265d63bb882468bbb609faa60a783ccf7dfeec5cd4595550586c37131bda"
6765
+ "sha256": "a6c7b6cdaca50106bd59b86e0272a2e24fe269a43e4a7f6e3a63504e73fa74d5"
6761
6766
  },
6762
6767
  {
6763
6768
  "path": "modules/provisioning/rate-limit.js",
@@ -6769,6 +6774,11 @@
6769
6774
  "mode": "0000644",
6770
6775
  "sha256": "4c933aad1307378496d47960e24c9d797a1f122f28aefa27b769f336e4cf2e75"
6771
6776
  },
6777
+ {
6778
+ "path": "modules/provisioning/repo-private.js",
6779
+ "mode": "0000644",
6780
+ "sha256": "0adee6537b5f127ea3fa5589bc92acc5b6accf6f100f395fe802640ff74b018f"
6781
+ },
6772
6782
  {
6773
6783
  "path": "modules/provisioning/routes/body-validators.js",
6774
6784
  "mode": "0000644",
@@ -8122,12 +8132,12 @@
8122
8132
  {
8123
8133
  "path": "package-lock.json",
8124
8134
  "mode": "0000644",
8125
- "sha256": "0f89059003740deb1ccd605358c8f983a2d0e1ea880bf5a584150d8f8549fef6"
8135
+ "sha256": "de02838b05a35599823a045bae0ed5e317ee334951d9dee697785c790bd90e66"
8126
8136
  },
8127
8137
  {
8128
8138
  "path": "package.json",
8129
8139
  "mode": "0000644",
8130
- "sha256": "d12f17578bc304bdef802b5e15a9a705437a947795a255a70b5e7d76c8a3f2e7"
8140
+ "sha256": "a93b66e4c048714c36e8c237004cc15061ea8776795535ddd7b179fe1b1b1bfc"
8131
8141
  },
8132
8142
  {
8133
8143
  "path": "public-docs/index.html",
@@ -9104,6 +9114,11 @@
9104
9114
  "mode": "0000644",
9105
9115
  "sha256": "a113cdae4d6dcb3f8d968d41c2f1ce67b34d77229fe0a19d6bc5023f7ed469b5"
9106
9116
  },
9117
+ {
9118
+ "path": "scripts/gds/provision-repo-private.js",
9119
+ "mode": "0000644",
9120
+ "sha256": "f2c31eaf1808ee19aaf7266a82509a05ae39d7f638ffaf3aeffcc33e304262aa"
9121
+ },
9107
9122
  {
9108
9123
  "path": "scripts/gds/provision-repo.js",
9109
9124
  "mode": "0000644",
@@ -9122,7 +9137,7 @@
9122
9137
  {
9123
9138
  "path": "scripts/gds/provision.js",
9124
9139
  "mode": "0000644",
9125
- "sha256": "1d92b6cbbfb544328a8bacd29cb492445508617298c251872d846bb7ab5a180a"
9140
+ "sha256": "7747b46f241bddb4257a56461703f82603dd58e18d22bd2c547fd7137749d0e6"
9126
9141
  },
9127
9142
  {
9128
9143
  "path": "scripts/gds/publish-credential-check.js",
@@ -9892,7 +9907,7 @@
9892
9907
  {
9893
9908
  "path": "src/bongos/github-repos.js",
9894
9909
  "mode": "0000644",
9895
- "sha256": "e69373026518a4e67ceca38e596354fa471fe11b7b00a718af62a1d3aabc0c4a"
9910
+ "sha256": "3e0087226edf1d877e56cf647b2af805430ececabf9b51ecf56df280f959b418"
9896
9911
  },
9897
9912
  {
9898
9913
  "path": "src/bongos/goal-scope-check.js",
@@ -10147,7 +10162,7 @@
10147
10162
  {
10148
10163
  "path": "src/module-api.js",
10149
10164
  "mode": "0000644",
10150
- "sha256": "e0a617d0a28a74ab5e8a1af5b6ddeb79de9ec5b8c94640e6f548dac35416c085"
10165
+ "sha256": "73c1013f0db275ad8c71b17973158d0ac150c99e9006a3d0aafac94758fa6f13"
10151
10166
  },
10152
10167
  {
10153
10168
  "path": "src/module-loader/catalog.js",
@@ -11532,7 +11547,7 @@
11532
11547
  {
11533
11548
  "path": "tests/github_repos_helpers.mjs",
11534
11549
  "mode": "0000644",
11535
- "sha256": "0bde5e75593b43a307c3bab748f02b1f77afc2e3a98decf88f2bff419c843b7d"
11550
+ "sha256": "66fd3d349445aa00e55fa77589958d6c98980e99f3cdaf4746ff55494202333a"
11536
11551
  },
11537
11552
  {
11538
11553
  "path": "tests/github_token_store.mjs",
@@ -13274,6 +13289,11 @@
13274
13289
  "mode": "0000644",
13275
13290
  "sha256": "755e93592b581593c463af59bad68b7e5b753c8305b80ff759501d878a31b209"
13276
13291
  },
13292
+ {
13293
+ "path": "tests/provision_repo_private.mjs",
13294
+ "mode": "0000644",
13295
+ "sha256": "9140b670c8ab76069c76238cf47bfdd539141198a40ef23f443813543947c4aa"
13296
+ },
13277
13297
  {
13278
13298
  "path": "tests/provision_restart.mjs",
13279
13299
  "mode": "0000644",
@@ -13382,7 +13402,7 @@
13382
13402
  {
13383
13403
  "path": "tests/provisioning_settings_apply.mjs",
13384
13404
  "mode": "0000644",
13385
- "sha256": "642d29f2e29e38a0b3a7229309b03a9e1cb28969e7f6d942bd06dd092147b835"
13405
+ "sha256": "ab01149d73158276a73464bc0feca3474ee2f054db03af378c9b43874a2056fb"
13386
13406
  },
13387
13407
  {
13388
13408
  "path": "tests/provisioning_settings_env.mjs",
@@ -13402,7 +13422,7 @@
13402
13422
  {
13403
13423
  "path": "tests/provisioning_teardown_intent.mjs",
13404
13424
  "mode": "0000644",
13405
- "sha256": "6155e1e7c18417537636e6e0ca1d63f4b87264c350d736fb787dfdafb7950280"
13425
+ "sha256": "b23049faa369d75b867a9c31237fe073424e694588627217a1105a5522c16fc6"
13406
13426
  },
13407
13427
  {
13408
13428
  "path": "tests/provisioning_teardown_states.mjs",
@@ -51,6 +51,10 @@ best-effort revokes the key by title. Pushes keep preferring the owner-token HTT
51
51
  - The flip-to-private refusal on the manage page (task 1003045) stays until existing
52
52
  already-provisioned instances get the same wiring on flip — a follow-up task, since their
53
53
  origin still rides the shared alias.
54
+ **Progress (2026-09-23):** the runner half has landed — task 1004167 let the wiring run
55
+ before a flip (`intendedPrivate`), and task 1004193 added the `repo-private` intent whose
56
+ runner leg wires the key and only then flips. The refusal stays until task 1003078 adds the
57
+ route and the manage-page control that enqueue it.
54
58
  - Builder **dev boxes** of a private-repo project were parked here as out of scope.
55
59
  **Answered on hardware 2026-08-25 (task 1003247): a box CAN read the private repo, and the
56
60
  auth design needed no change.** The GDS-backed credential helper (`otb-git-credential`,
@@ -2424,5 +2424,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2424
2424
  landed since 1.19.973 with no explicit bump. run 35816317024. (task 1002620)
2425
2425
  1.19.975 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2426
2426
  landed since 1.19.974 with no explicit bump. run 35871083707. (task 1002620)
2427
+ 1.19.976 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2428
+ landed since 1.19.975 with no explicit bump. run 35878415178. (task 1002620)
2427
2429
  ---------------------------------------------------------------------------
2428
2430
  ```
@@ -0,0 +1,34 @@
1
+ -- provisioning_025_repo_private.sql — take an existing project's repository private
2
+ -- (task 1004193, split (b) of task 1003078; ADR 0176).
3
+ --
4
+ -- WHY THIS EXISTS. A project stood up while its repository was public deploys through the
5
+ -- platform's shared key, which cannot read a private repository. Flipping such a repository
6
+ -- private from GitHub breaks the project's next deploy pull (walk finding N4), so the manage
7
+ -- page has refused the flip since task 1003045. Standups after ADR 0176 give a private
8
+ -- repository its own read-only deploy key; this action gives an EXISTING project the same
9
+ -- wiring and only then flips it.
10
+ --
11
+ -- WHY AN INTENT. The wiring runs ssh-keygen and git config inside the project's checkout on
12
+ -- the control-plane box, with the owner's stored GitHub credential. The web tier holds
13
+ -- neither, so it may only ask; the runner (scripts/gds/provision-repo-private.js) does it —
14
+ -- deploy key FIRST, flip SECOND, so there is never a moment the repository is private and
15
+ -- the checkout still reads with the shared key.
16
+ --
17
+ -- WHY NO PAYLOAD. The action name IS the direction: it only ever means "make it private".
18
+ -- Going public needs no wiring and stays a direct call on the core route. So nothing new
19
+ -- is carried, and the target_version CHECK from provisioning_020 already holds for this
20
+ -- action as written (repo-private is not an upgrade action, and carries no target).
21
+ --
22
+ -- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
23
+
24
+ BEGIN;
25
+
26
+ -- INTENT_ACTIONS in modules/provisioning/provisioning.js is the other spelling of this list;
27
+ -- tests/provisioning_restart.mjs and tests/core_upgrade_door.mjs fail if the two disagree.
28
+ ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
29
+ ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
30
+ CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach',
31
+ 'settings-apply', 'restart', 'disconnect', 'core-upgrade',
32
+ 'core-upgrade-preflight', 'repo-private'));
33
+
34
+ COMMIT;
@@ -58,7 +58,7 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
58
58
  // 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
59
59
  // 'core-upgrade' (task 1003159, ADR 0293): move this instance's core pin — the owner's
60
60
  // deploy door. The web tier only QUEUES it; the control-plane runner performs it.
61
- const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight'];
61
+ const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight', 'repo-private'];
62
62
  // Every state a teardown can be asked for — i.e. everything except the one
63
63
  // state where there is provably nothing left to release. DERIVED from
64
64
  // INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
@@ -1158,7 +1158,7 @@ async function enqueueIntent(db, instanceId, action, requestedBy, targetVersion
1158
1158
  // (the state CHECK has no 'superseded'; last_error says why) so the runner never
1159
1159
  // stands it up. A PENDING settings push is superseded the same way (task 1003140):
1160
1160
  // a push into a project about to be torn down is dead work, and letting it hold the
1161
- // delete hostage would repeat 1002691. A pending 'restart' joins them for the same
1161
+ // delete hostage would repeat 1002691. A pending 'restart' or 'repo-private' (task 1004193) joins them for the same
1162
1162
  // reason (task 1003238) — and more sharply, since the owner reaches for Restart
1163
1163
  // precisely when a project looks broken, which is also when they give up and delete
1164
1164
  // it. A RUNNING intent is mid-flight and never yanked —
@@ -1171,7 +1171,7 @@ async function enqueueTeardown(db, instanceId, requestedBy) {
1171
1171
  `UPDATE provisioning_intents
1172
1172
  SET state = 'error', last_error = 'superseded: teardown requested',
1173
1173
  updated_at = now(), resolved_at = now()
1174
- WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart') AND state = 'pending'
1174
+ WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart', 'repo-private') AND state = 'pending'
1175
1175
  RETURNING id`,
1176
1176
  [instanceId]
1177
1177
  );
@@ -0,0 +1,29 @@
1
+ // modules/provisioning/repo-private.js — the queue half of "make this project's repository
2
+ // private" (task 1004193, split (b) of task 1003078; ADR 0176).
3
+ //
4
+ // A project stood up with a public repository reads its code with the platform's shared
5
+ // key, which cannot read a private one — so the flip must wait until the project has its
6
+ // own read-only deploy key. That wiring needs the box and the owner's stored credential, so
7
+ // the web tier only enqueues; scripts/gds/provision-repo-private.js is the runner leg that
8
+ // wires the key and THEN flips.
9
+ //
10
+ // Its own file because provisioning.js sits at the size ratchet. It reaches the queue
11
+ // through the provisioning module OBJECT, so a test can stub enqueueIntent.
12
+ 'use strict';
13
+
14
+ const provisioning = require('./provisioning');
15
+
16
+ // Queue a flip-to-private. Arbitration is the restart shape: an open repo-private in
17
+ // either state makes a repeat idempotent (the flip has no payload, so there is nothing a
18
+ // second request could change); any OTHER open intent refuses it with
19
+ // { conflict: 'open_intent' } rather than racing a standup, teardown or settings push over
20
+ // the same checkout. The partial unique index ux_provisioning_intents_open stays the
21
+ // arbiter if two requests interleave.
22
+ async function enqueueRepoPrivate(db, instanceId, requestedBy) {
23
+ const { intent, created } = await provisioning.enqueueIntent(db, instanceId, 'repo-private', requestedBy);
24
+ if (created) return { intent, created: true };
25
+ if (intent.action === 'repo-private') return { intent, created: false };
26
+ return { intent, created: false, conflict: 'open_intent' };
27
+ }
28
+
29
+ module.exports = { enqueueRepoPrivate };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.975",
3
+ "version": "1.19.976",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.975",
9
+ "version": "1.19.976",
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.975",
3
+ "version": "1.19.976",
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",
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+ // provision-repo-private.js — "make this project's repository private": the runner leg
3
+ // (task 1004193, split (b) of task 1003078; ADR 0176).
4
+ //
5
+ // WHY. A project stood up while its repository was public reads its code with the
6
+ // platform's shared deploy key, which cannot read a private repository. Flip such a
7
+ // repository private and the project's next deploy pull fails (walk finding N4). Standups
8
+ // since ADR 0176 give a private repository the project's own read-only deploy key; this
9
+ // leg gives an EXISTING project the same wiring, and only then flips.
10
+ //
11
+ // ORDER IS THE SAFETY PROPERTY. Wire the key FIRST, flip SECOND. A deploy key authenticates
12
+ // against a still-public repository, so wiring first leaves no window in which the repo is
13
+ // private and the checkout still reads with the shared key. ensurePrivateRepoAccess would
14
+ // see the repo as public and do nothing, so it is told where the repo is going
15
+ // (`intendedPrivate`, task 1004167). If the flip then fails, the repository is public with
16
+ // a working key — harmless, and the retry re-runs both steps idempotently.
17
+ //
18
+ // Every expected failure returns { ok:false } rather than throwing: a throw makes the
19
+ // runner mark the whole project 'error', and a project that could not be made private is
20
+ // still running fine. The error text is the owner's sentence — it rides error_note onto
21
+ // the project card once the retries are spent.
22
+ //
23
+ // Its own file because provision.js sits at the size ratchet (the provision-disconnect.js
24
+ // precedent). The web tier only enqueues (modules/provisioning/repo-private.js).
25
+
26
+ const path = require('node:path');
27
+ const { REPO_ROOT } = require('./provision-config.js');
28
+ const { parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo-auth.js');
29
+ const { ensurePrivateRepoAccess } = require('./provision-repo.js');
30
+
31
+ async function repoPrivateInstance(inst, deps) {
32
+ const { db, provisioning, log, apply } = deps;
33
+ log(`repo-private ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
34
+
35
+ // Only a standalone project has its own checkout to wire; any other shape reads from a
36
+ // checkout this project does not own, so a key minted here would change nothing.
37
+ if (inst.hosting_shape !== 'standalone') {
38
+ return { ok: false, error: 'This project shares its code checkout with others, so its repository cannot be made private from here.' };
39
+ }
40
+ if (inst.status !== 'active') {
41
+ return { ok: false, error: `This project is ${inst.status}, not running, so its repository was not changed. Try again once it is live.` };
42
+ }
43
+ const repo = parseTargetRef(inst.target_ref);
44
+ if (!repo) return { ok: false, error: 'This project has no GitHub repository on record, so there is nothing to make private.' };
45
+
46
+ let wired;
47
+ try {
48
+ wired = await ensurePrivateRepoAccess(inst, { ...deps, intendedPrivate: true });
49
+ } catch (e) {
50
+ return { ok: false, error: (e && e.message) || String(e) };
51
+ }
52
+ if (!apply) {
53
+ log(` [repo-private] would then make ${repo.owner}/${repo.name} private with the owner's credential — only after the deploy key above is wired (dry-run — nothing ran)`);
54
+ return { ok: true, planned: true };
55
+ }
56
+ if (!wired || wired.private !== true) {
57
+ // Defensive: with intendedPrivate set, a standalone row with a repo either wires or
58
+ // throws. Anything else means the checkout is unwired, and flipping would break it.
59
+ return { ok: false, error: `The deploy key for ${repo.owner}/${repo.name} could not be set up, so the repository was left public.` };
60
+ }
61
+
62
+ const token = await resolveOwnerGithubToken(inst, deps);
63
+ if (!token) {
64
+ return { ok: false, error: `${repo.owner}/${repo.name} 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.` };
65
+ }
66
+ const ghRepos = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos'));
67
+ try {
68
+ log(` [repo-private] making ${repo.owner}/${repo.name} private`);
69
+ await ghRepos.makeRepoPrivate(token, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
70
+ } catch (e) {
71
+ const why = e && e.code === 'not_repo_admin' ? 'GitHub refused — the connected account cannot change this repository’s settings'
72
+ : e && e.code === 'repo_not_found' ? 'GitHub could not find the repository with the connected account'
73
+ : `GitHub could not make the change (${(e && e.message) || e})`;
74
+ return { ok: false, error: `${repo.owner}/${repo.name} is still public: ${why}. Its deploy key is already in place, so trying again is safe.` };
75
+ }
76
+
77
+ await provisioning.recordEvent(db, {
78
+ instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'repo-private',
79
+ detail: `${repo.owner}/${repo.name} made private — the project now reads it with its own read-only deploy key${wired.minted ? ' (newly registered)' : ''}`,
80
+ actor: 'runner',
81
+ }).catch(() => {});
82
+ log(` ✓ ${repo.owner}/${repo.name} is private; ${inst.slug} reads it with its own deploy key`);
83
+ return { ok: true, minted: !!wired.minted };
84
+ }
85
+
86
+ module.exports = { repoPrivateInstance };
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- //
4
3
  // scripts/gds/provision.js — the INSTANCE-provisioning control-plane runner
5
4
  // (ADR 0111 §2/§3, BONGOS-V1 goal 32, task #1944). A sibling of box.js.
6
5
  //
@@ -1294,6 +1293,7 @@ async function cmdRunIntents(deps) {
1294
1293
  else if (intent.action === 'disconnect') result = await disconnectInstance(inst, deps);
1295
1294
  else if (intent.action === 'core-upgrade') result = await coreUpgradeInstance(inst, deps, intent); // also reads the INTENT: the version the owner previewed (ADR 0293 D4)
1296
1295
  else if (intent.action === 'core-upgrade-preflight') result = await preflightCoreUpgrade(inst, deps, intent); // changes nothing; caches what the move WOULD say (task 1004121)
1296
+ else if (intent.action === 'repo-private') result = await require('./provision-repo-private.js').repoPrivateInstance(inst, deps); // wire the deploy key, THEN flip (task 1004193); required here — this file is at the size ratchet
1297
1297
  else result = { ok: false, error: `unknown_action '${intent.action}' — runner core ${coreVersionSafe() || '?'} does not know it (runner too old?); refusing to guess` };
1298
1298
  if (result && result.ok === false) {
1299
1299
  if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
@@ -129,17 +129,14 @@ async function getUserRepo(token, { owner, name, fetchImpl = fetch } = {}) {
129
129
  };
130
130
  }
131
131
 
132
- // Flip one repo PUBLIC — and only public. Like createUserRepo's private:false,
133
- // the direction is deliberately not a parameter: the deploy pull still runs as
134
- // the shared deploy key that cannot read a private repo (walk finding N4), so
135
- // nothing in this codebase may flip a repo private until that fix lands — the
136
- // route refuses the request; this helper makes the refusal structural
137
- // (task 1003045). Returns the same shape as getUserRepo.
138
- async function makeRepoPublic(token, { owner, name, fetchImpl = fetch } = {}) {
132
+ // The visibility PATCH both directions share. Deliberately NOT exported: callers get
133
+ // one named function per direction, so the name stays the contract and no caller can
134
+ // pass a direction it did not mean (the createUserRepo private:false precedent).
135
+ async function setRepoPrivate(token, { owner, name, fetchImpl = fetch }, isPrivate) {
139
136
  const res = await fetchImpl(`${GH_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, {
140
137
  method: 'PATCH',
141
138
  headers: ghHeaders(token, { 'Content-Type': 'application/json' }),
142
- body: JSON.stringify({ private: false }),
139
+ body: JSON.stringify({ private: isPrivate }),
143
140
  });
144
141
  if (res.status === 401) {
145
142
  throw new GithubApiError('GitHub rejected the stored token', { status: 401, code: 'github_unauthorized' });
@@ -162,6 +159,22 @@ async function makeRepoPublic(token, { owner, name, fetchImpl = fetch } = {}) {
162
159
  };
163
160
  }
164
161
 
162
+ // Flip one repo PUBLIC (task 1003045). Needs no deploy-key wiring — the shared key
163
+ // reads a public repo — so the core route may call it directly. Returns the same
164
+ // shape as getUserRepo.
165
+ async function makeRepoPublic(token, ref = {}) {
166
+ return setRepoPrivate(token, ref, false);
167
+ }
168
+
169
+ // Flip one repo PRIVATE. Only the control-plane runner may call this, and only AFTER
170
+ // ensurePrivateRepoAccess has wired the project's own read-only deploy key: the
171
+ // shared key cannot read a private repo, so flipping first breaks the next deploy
172
+ // pull (walk finding N4, ADR 0176). scripts/gds/provision-repo-private.js is that
173
+ // caller (task 1004193); no web route calls it. Returns the same shape as getUserRepo.
174
+ async function makeRepoPrivate(token, ref = {}) {
175
+ return setRepoPrivate(token, ref, true);
176
+ }
177
+
165
178
  // ---- deploy keys (task 1002735 / ADR 0176) ----
166
179
  // The per-repo credential a PRIVATE repo's recurring deploy pull authenticates
167
180
  // with: provisioning mints an ed25519 keypair, registers its public half here with
@@ -237,7 +250,7 @@ async function deleteDeployKey(token, { owner, name, keyId, fetchImpl = fetch }
237
250
  }
238
251
 
239
252
  module.exports = {
240
- listUserRepos, createUserRepo, getUserRepo, makeRepoPublic,
253
+ listUserRepos, createUserRepo, getUserRepo, makeRepoPublic, makeRepoPrivate,
241
254
  listDeployKeys, addDeployKey, deleteDeployKey,
242
255
  GithubApiError, MAX_PAGES,
243
256
  };
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.975'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.976'; // 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');
@@ -8,6 +8,7 @@
8
8
  import { strict as assert } from 'node:assert';
9
9
  import { test } from 'node:test';
10
10
  import { createRequire } from 'node:module';
11
+ import fs from 'node:fs';
11
12
  import path from 'node:path';
12
13
  import { fileURLToPath } from 'node:url';
13
14
 
@@ -139,10 +140,9 @@ test('getUserRepo: 404 → typed repo_not_found (GitHub\'s own no-leak shape, fo
139
140
  );
140
141
  });
141
142
 
142
- test('makeRepoPublic: PATCHes exactly {private:false} — the private direction is structurally impossible', async () => {
143
- // Like createUserRepo's private:false, the direction is deliberately not a
144
- // parameter: the deploy pull runs as the shared deploy key that cannot read a
145
- // private repo (N4), so nothing in the codebase may flip a repo private.
143
+ test('makeRepoPublic: PATCHes exactly {private:false} — the name is the direction', async () => {
144
+ // Like createUserRepo's private:false, the direction is not a parameter: each
145
+ // direction is its own named function, so no caller can send one it did not mean.
146
146
  let sentMethod = '';
147
147
  let sentBody = null;
148
148
  const fetchImpl = async (url, init) => {
@@ -164,6 +164,57 @@ test('makeRepoPublic: 403 → typed not_repo_admin', async () => {
164
164
  );
165
165
  });
166
166
 
167
+ // ---- the private direction (task 1004193, split (b) of task 1003078) ----
168
+
169
+ test('makeRepoPrivate: PATCHes exactly {private:true} to the same endpoint', async () => {
170
+ // Arrange
171
+ let sentUrl = '';
172
+ let sentMethod = '';
173
+ let sentBody = null;
174
+ const fetchImpl = async (url, init) => {
175
+ sentUrl = url; sentMethod = init.method; sentBody = JSON.parse(init.body);
176
+ return res(200, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury', private: true, permissions: { admin: true } });
177
+ };
178
+ // Act
179
+ const out = await gh.makeRepoPrivate('tok', { owner: 'me', name: 'mercury', fetchImpl });
180
+ // Assert
181
+ assert.equal(sentMethod, 'PATCH');
182
+ assert.match(sentUrl, /\/repos\/me\/mercury$/);
183
+ assert.deepEqual(sentBody, { private: true });
184
+ assert.deepEqual(out, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury', private: true, admin: true });
185
+ });
186
+
187
+ test('makeRepoPrivate: 403 → typed not_repo_admin, the same mapping as the public direction', async () => {
188
+ const fetchImpl = async () => res(403, { message: 'Must have admin rights' });
189
+ await assert.rejects(
190
+ () => gh.makeRepoPrivate('tok', { owner: 'other', name: 'repo', fetchImpl }),
191
+ (e) => e.code === 'not_repo_admin' && e.status === 403,
192
+ );
193
+ });
194
+
195
+ test('the shared visibility PATCH is not exported — only the two named directions are', () => {
196
+ assert.equal(typeof gh.makeRepoPublic, 'function');
197
+ assert.equal(typeof gh.makeRepoPrivate, 'function');
198
+ const visibilityExports = Object.keys(gh).filter((k) => /private|public|visibility/i.test(k)).sort();
199
+ assert.deepEqual(visibilityExports, ['makeRepoPrivate', 'makeRepoPublic'],
200
+ 'a direction parameter on an export would let a caller flip private without the deploy-key wiring');
201
+ });
202
+
203
+ test('no web route may call makeRepoPrivate — only the runner flips a repo private, after wiring its key', () => {
204
+ // ADR 0176 / walk finding N4: flipping before the project's own deploy key is wired
205
+ // breaks its next deploy pull, and only the control-plane runner can wire that key.
206
+ const routeDirs = ['src/bongos/routes', 'modules/provisioning/routes'];
207
+ const offenders = [];
208
+ for (const dir of routeDirs) {
209
+ const abs = path.join(ROOT, dir);
210
+ if (!fs.existsSync(abs)) continue;
211
+ for (const f of fs.readdirSync(abs).filter((n) => n.endsWith('.js'))) {
212
+ if (/makeRepoPrivate/.test(fs.readFileSync(path.join(abs, f), 'utf8'))) offenders.push(`${dir}/${f}`);
213
+ }
214
+ }
215
+ assert.deepEqual(offenders, []);
216
+ });
217
+
167
218
  // ---- deploy-key helpers (task 1002735 / ADR 0176) ----
168
219
 
169
220
  test('listDeployKeys: GETs /repos/{owner}/{name}/keys, maps id/title/key/read_only', async () => {
@@ -0,0 +1,260 @@
1
+ // tests/provision_repo_private.mjs
2
+ //
3
+ // Split (b) of task 1003078 (task 1004193, ADR 0176): the `repo-private` provisioning
4
+ // intent — its queue seam (modules/provisioning/repo-private.js), its runner leg
5
+ // (scripts/gds/provision-repo-private.js) and the run-intents dispatch that reaches it.
6
+ // Hermetic — exec, db and GitHub are recording fakes; no shell, no DB, no network.
7
+ //
8
+ // The load-bearing assertions:
9
+ // • ORDER: the project's own deploy key is registered and the checkout re-pointed at it
10
+ // BEFORE the repository is flipped private. Flip first and the next deploy pull fails
11
+ // with the shared key (walk finding N4) — this is the whole point of the task
12
+ // • the wiring is asked for as a PRE-flip wiring (intendedPrivate), because GitHub still
13
+ // answers "public" at that moment and the un-told wiring would do nothing
14
+ // • any failure BEFORE the flip leaves the repository untouched — no PATCH is sent
15
+ // • every expected failure is ok:false (retried), never a throw, so a running project is
16
+ // never relabelled 'error' by a flip that could not happen
17
+ // • non-standalone and not-yet-live projects are refused without touching GitHub
18
+ // • dry-run calls GitHub not at all
19
+ // • the queue seam: idempotent on its own kind, conflicts with any other open intent
20
+ //
21
+ // Run: node tests/provision_repo_private.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { createRequire } from 'node:module';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const P = require('../scripts/gds/provision.js');
28
+ const { repoPrivateInstance } = require('../scripts/gds/provision-repo-private.js');
29
+ const provisioning = require('../modules/provisioning/provisioning.js');
30
+ const { enqueueRepoPrivate } = require('../modules/provisioning/repo-private.js');
31
+
32
+ let passed = 0, failed = 0;
33
+ async function ta(name, fn) {
34
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
35
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
36
+ }
37
+
38
+ const liveStandalone = { id: 18, slug: 'demo', hosting_shape: 'standalone', status: 'active', owner_builder_id: 7, target_ref: 'example-owner/demo' };
39
+
40
+ // One ordered timeline across the shell AND GitHub, so the wire-before-flip order is
41
+ // observable as positions in a single list rather than inferred from two.
42
+ function recorder({ apply = true, token = 'gho_TOK', flipFails = null, getFails = null } = {}) {
43
+ const timeline = [], events = [], logs = [];
44
+ const deps = {
45
+ apply, log: (l) => logs.push(String(l)),
46
+ db: { query: async () => ({ rows: [] }) },
47
+ provisioning: { recordEvent: async (_db, ev) => { events.push(ev); } },
48
+ exec: (cmd) => { timeline.push(['exec', cmd]); return { ok: true, stdout: '' }; },
49
+ getGithubToken: async () => (token ? { token, scope: 'read:user,repo' } : null),
50
+ fsImpl: { existsSync: () => false, readFileSync: () => 'ssh-ed25519 AAAAlocal cloudbongos-deploy-demo\n' },
51
+ githubRepos: {
52
+ getUserRepo: async (_t, a) => {
53
+ timeline.push(['gh', 'get']);
54
+ if (getFails) throw getFails;
55
+ return { full_name: `${a.owner}/${a.name}`, private: false, admin: true };
56
+ },
57
+ listDeployKeys: async () => { timeline.push(['gh', 'list']); return []; },
58
+ addDeployKey: async (_t, a) => { timeline.push(['gh', 'add', a.readOnly]); return { id: 99, title: a.title, read_only: true }; },
59
+ deleteDeployKey: async () => { timeline.push(['gh', 'del']); return true; },
60
+ makeRepoPrivate: async (_t, a) => {
61
+ timeline.push(['gh', 'flip-private', `${a.owner}/${a.name}`]);
62
+ if (flipFails) throw flipFails;
63
+ return { full_name: `${a.owner}/${a.name}`, private: true, admin: true };
64
+ },
65
+ },
66
+ };
67
+ const at = (pred) => timeline.findIndex(pred);
68
+ return { deps, timeline, events, logs, at };
69
+ }
70
+ const isFlip = (e) => e[0] === 'gh' && e[1] === 'flip-private';
71
+
72
+ console.log('\nrunner leg — wire FIRST, flip SECOND:');
73
+
74
+ await ta('the deploy key is registered and the checkout re-pointed BEFORE the repository is flipped private', async () => {
75
+ // Arrange
76
+ const r = recorder();
77
+ // Act
78
+ const out = await repoPrivateInstance(liveStandalone, r.deps);
79
+ // Assert
80
+ assert.equal(out.ok, true, JSON.stringify(out));
81
+ const flip = r.at(isFlip);
82
+ const add = r.at((e) => e[0] === 'gh' && e[1] === 'add');
83
+ const sshCmd = r.at((e) => e[0] === 'exec' && /config core\.sshCommand/.test(e[1]));
84
+ const origin = r.at((e) => e[0] === 'exec' && /remote set-url origin git@github\.com:example-owner\/demo\.git/.test(e[1]));
85
+ assert.ok(flip >= 0, 'the repository is flipped');
86
+ assert.ok(add >= 0 && add < flip, 'the key is registered before the flip');
87
+ assert.ok(origin >= 0 && origin < flip, 'origin points at the plain GitHub URL before the flip');
88
+ assert.ok(sshCmd >= 0 && sshCmd < flip, 'core.sshCommand uses the project key before the flip');
89
+ assert.equal(r.timeline.filter(isFlip).length, 1, 'exactly one flip');
90
+ assert.equal(r.timeline[flip][2], 'example-owner/demo', 'the flip targets the project repository');
91
+ });
92
+
93
+ await ta('the wiring is a PRE-flip wiring — it runs although GitHub still reports the repository public', async () => {
94
+ const r = recorder();
95
+ await repoPrivateInstance(liveStandalone, r.deps);
96
+ // Without intendedPrivate, ensurePrivateRepoAccess reads private:false and registers nothing.
97
+ const add = r.timeline.find((e) => e[0] === 'gh' && e[1] === 'add');
98
+ assert.ok(add, 'a key was registered for a repo GitHub still calls public');
99
+ assert.equal(add[2], true, 'and it is read-only');
100
+ });
101
+
102
+ await ta('the flip records an event on the project, after it happens', async () => {
103
+ const r = recorder();
104
+ await repoPrivateInstance(liveStandalone, r.deps);
105
+ assert.equal(r.events.length, 1);
106
+ assert.equal(r.events[0].event, 'repo-private');
107
+ assert.equal(r.events[0].instanceId, 18);
108
+ assert.equal(r.events[0].ownerBuilderId, 7);
109
+ assert.equal(r.events[0].actor, 'runner');
110
+ assert.match(r.events[0].detail, /example-owner\/demo made private/);
111
+ });
112
+
113
+ console.log('\nrunner leg — a failure before the flip leaves the repository alone:');
114
+
115
+ await ta('no stored credential: ok:false, no key and NO flip — the repository stays public', async () => {
116
+ const r = recorder({ token: null });
117
+ const out = await repoPrivateInstance(liveStandalone, r.deps);
118
+ assert.equal(out.ok, false);
119
+ assert.match(out.error, /cannot be made private[\s\S]*credential/i);
120
+ assert.equal(r.at(isFlip), -1, 'nothing was flipped');
121
+ });
122
+
123
+ await ta('GitHub unreachable during the wiring: ok:false (a retry), never a throw, and NO flip', async () => {
124
+ const r = recorder({ getFails: new Error('ECONNRESET') });
125
+ const out = await repoPrivateInstance(liveStandalone, r.deps);
126
+ assert.equal(out.ok, false);
127
+ assert.match(out.error, /could not be reached/);
128
+ assert.equal(r.at(isFlip), -1);
129
+ });
130
+
131
+ await ta('the flip itself refused (403): ok:false, says the repository is STILL public and a retry is safe', async () => {
132
+ const e = Object.assign(new Error('GitHub refused the change for this account'), { code: 'not_repo_admin', status: 403 });
133
+ const r = recorder({ flipFails: e });
134
+ const out = await repoPrivateInstance(liveStandalone, r.deps);
135
+ assert.equal(out.ok, false);
136
+ assert.match(out.error, /still public/);
137
+ assert.match(out.error, /cannot change this repository/);
138
+ assert.match(out.error, /trying again is safe/);
139
+ assert.equal(r.events.length, 0, 'no "made private" event for a flip that did not happen');
140
+ });
141
+
142
+ console.log('\nrunner leg — refusals that never reach GitHub:');
143
+
144
+ for (const [label, inst, why] of [
145
+ ['a co-tenant project', { ...liveStandalone, hosting_shape: 'co-tenant' }, /shares its code checkout/],
146
+ ['a project that is not live yet', { ...liveStandalone, status: 'provisioning' }, /is provisioning, not running/],
147
+ ['a project with no repository on record', { ...liveStandalone, target_ref: null }, /no GitHub repository on record/],
148
+ ]) {
149
+ await ta(`${label}: ok:false with a plain reason, and no shell or GitHub call at all`, async () => {
150
+ const r = recorder();
151
+ const out = await repoPrivateInstance(inst, r.deps);
152
+ assert.equal(out.ok, false);
153
+ assert.match(out.error, why);
154
+ assert.deepEqual(r.timeline, []);
155
+ });
156
+ }
157
+
158
+ await ta('dry-run: plans the wire and the flip, and calls neither GitHub nor the shell', async () => {
159
+ const r = recorder({ apply: false });
160
+ const out = await repoPrivateInstance(liveStandalone, r.deps);
161
+ assert.equal(out.ok, true);
162
+ assert.equal(out.planned, true);
163
+ assert.deepEqual(r.timeline, []);
164
+ assert.ok(r.logs.some((l) => /ABOUT TO BE MADE PRIVATE/.test(l)), 'the plan names the pre-flip wiring');
165
+ assert.ok(r.logs.some((l) => /would then make example-owner\/demo private/.test(l)), 'and the flip after it');
166
+ });
167
+
168
+ console.log('\nrun-intents dispatch:');
169
+
170
+ function drainDeps({ flipFails = null } = {}) {
171
+ let claims = 0; const resolved = [], statusWrites = [], updates = [];
172
+ const r = recorder({ flipFails });
173
+ const deps = {
174
+ ...r.deps, sleep: async () => {},
175
+ db: { query: async (sql, params) => { if (/UPDATE provisioning_intents/.test(sql)) updates.push({ sql, params }); return { rows: [] }; } },
176
+ provisioning: {
177
+ claimNextIntent: async () => (claims++ === 0 ? { id: 31, instance_id: 18, action: 'repo-private', attempts: 1 } : null),
178
+ getInstanceById: async () => ({ ...liveStandalone }),
179
+ resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
180
+ setInstanceStatus: async (_db, _id, s, patch) => { statusWrites.push({ s, patch }); },
181
+ recordEvent: async () => {},
182
+ claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
183
+ },
184
+ writeFile: () => ({ ok: true }), doApi: null,
185
+ };
186
+ return { deps, r, resolved, statusWrites, updates };
187
+ }
188
+
189
+ await ta("cmdRunIntents (--apply) routes 'repo-private' to the leg and resolves it done", async () => {
190
+ const d = drainDeps();
191
+ const out = await P.cmdRunIntents(d.deps);
192
+ assert.equal(out.errors, 0, JSON.stringify(out));
193
+ assert.deepEqual(d.resolved, [{ id: 31, state: 'done', err: null }]);
194
+ assert.equal(d.r.timeline.filter(isFlip).length, 1, 'it really reached the flip');
195
+ });
196
+
197
+ await ta('cmdRunIntents: a flip that fails re-pends with the reason, and never marks the running project broken', async () => {
198
+ const d = drainDeps({ flipFails: Object.assign(new Error('boom'), { code: 'github_error' }) });
199
+ const out = await P.cmdRunIntents(d.deps);
200
+ assert.equal(out.errors, 1);
201
+ assert.equal(d.resolved.length, 0, 'not resolved — it re-pends for a later tick');
202
+ assert.match(d.updates[0].sql, /state='pending'/);
203
+ assert.match(d.updates[0].params[1], /still public/, 'the owner-facing reason rides last_error');
204
+ assert.equal(d.statusWrites.length, 0, 'a running project is not relabelled error');
205
+ });
206
+
207
+ console.log('\nqueue seam — modules/provisioning/repo-private.js:');
208
+
209
+ async function withEnqueue(fake, fn) {
210
+ const orig = provisioning.enqueueIntent;
211
+ provisioning.enqueueIntent = fake;
212
+ try { await fn(); } finally { provisioning.enqueueIntent = orig; }
213
+ }
214
+
215
+ await ta('a fresh request is queued as repo-private, carrying the actor', async () => {
216
+ const calls = [];
217
+ await withEnqueue(async (_db, id, action, by) => { calls.push({ id, action, by }); return { intent: { id: 1, action }, created: true }; }, async () => {
218
+ const out = await enqueueRepoPrivate({}, 18, 'api:self');
219
+ assert.equal(out.created, true);
220
+ assert.equal(out.conflict, undefined);
221
+ assert.deepEqual(calls, [{ id: 18, action: 'repo-private', by: 'api:self' }]);
222
+ });
223
+ });
224
+
225
+ await ta('an open repo-private makes a repeat idempotent — not a conflict', async () => {
226
+ await withEnqueue(async () => ({ intent: { id: 1, action: 'repo-private', state: 'running' }, created: false }), async () => {
227
+ const out = await enqueueRepoPrivate({}, 18, 'api:self');
228
+ assert.equal(out.created, false);
229
+ assert.equal(out.conflict, undefined);
230
+ });
231
+ });
232
+
233
+ await ta('any OTHER open intent refuses it with open_intent — it never races a teardown or standup', async () => {
234
+ for (const action of ['teardown', 'provision', 'settings-apply', 'disconnect']) {
235
+ await withEnqueue(async () => ({ intent: { id: 2, action, state: 'pending' }, created: false }), async () => {
236
+ const out = await enqueueRepoPrivate({}, 18, 'api:self');
237
+ assert.equal(out.conflict, 'open_intent', action);
238
+ });
239
+ }
240
+ });
241
+
242
+ await ta('a teardown supersedes a PENDING repo-private — a queued flip must never hold the owner\'s delete hostage', async () => {
243
+ // Arrange — every intent shares one open slot per project, so an un-superseded pending
244
+ // flip would refuse the teardown until the runner worked through it (the 1002691 class).
245
+ const calls = [];
246
+ const db = { query: async (sql, params) => {
247
+ calls.push(sql);
248
+ if (/UPDATE provisioning_intents/.test(sql)) return { rows: [{ id: 5 }] };
249
+ return { rows: [{ id: 6, action: 'teardown', state: 'pending', instance_id: params[0] }] };
250
+ } };
251
+ // Act
252
+ const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
253
+ // Assert
254
+ assert.equal(r.superseded, true);
255
+ assert.match(calls[0], /'repo-private'/, 'repo-private is on the supersedable list');
256
+ assert.match(calls[0], /state = 'pending'/, 'pending only — a RUNNING flip is mid-wiring and never yanked');
257
+ });
258
+
259
+ console.log(`\nprovision_repo_private: ${passed} passed, ${failed} failed`);
260
+ process.exit(failed ? 1 : 0);
@@ -131,7 +131,7 @@ await ta('enqueueTeardown supersedes a PENDING settings push (a push into a proj
131
131
  const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
132
132
  assert.equal(r.created, true);
133
133
  assert.equal(r.superseded, true, 'the queued push was cancelled so the teardown could take the slot');
134
- assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
134
+ assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
135
135
  });
136
136
 
137
137
  // ── the route, over a real mounted router (stubbed data access) ─────────────────
@@ -31,7 +31,7 @@ const require = createRequire(import.meta.url);
31
31
  const provisioning = require('../modules/provisioning/provisioning.js');
32
32
 
33
33
  // The actions a teardown supersedes, mirroring enqueueTeardown's SQL.
34
- const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart']);
34
+ const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private']);
35
35
 
36
36
  let passed = 0, failed = 0;
37
37
  function t(name, fn) {
@@ -79,7 +79,7 @@ function fakeIntentDb(seed = []) {
79
79
  // dead work in front of a teardown (task 1003140 widened it to the settings
80
80
  // push, task 1003238 to the restart; an open teardown is left alone for the
81
81
  // idempotent path).
82
- assert.match(sql, /action IN \('provision', 'settings-apply', 'restart'\) AND state = 'pending'/);
82
+ assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/);
83
83
  const hit = rows.filter((r) =>
84
84
  String(r.instance_id) === String(params[0]) && SUPERSEDABLE.has(r.action) && r.state === 'pending');
85
85
  for (const r of hit) { r.state = 'error'; r.last_error = 'superseded: teardown requested'; }