@bongos/core 1.21.124 → 1.21.126

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 (44) hide show
  1. package/.bongos-core.json +91 -41
  2. package/bin/bongos.js +9 -0
  3. package/docs/api/openapi.json +4 -4
  4. package/docs/copy-inventory.md +128 -126
  5. package/docs/copy-registry.json +188 -170
  6. package/docs/module-api-changelog.md +4 -0
  7. package/docs/page-inventory.json +2 -1
  8. package/docs/page-readings.json +152 -143
  9. package/modules/hall-ui/public/goal-forest-lib.js +36 -0
  10. package/modules/hall-ui/public/goal-forest-render.js +50 -10
  11. package/modules/hall-ui/public/goals-page.js +19 -22
  12. package/modules/hall-ui/public/goals-render.js +6 -16
  13. package/modules/hall-ui/public/goals.css +13 -4
  14. package/modules/hall-ui/public/goals.html +22 -24
  15. package/modules/hall-ui/public/goals.states.json +19 -1
  16. package/modules/hall-ui/records/goal-dependency-forest.md +4 -3
  17. package/modules/onboarding/howto-guide.js +259 -0
  18. package/modules/platform-identity/invite-authz.js +1 -1
  19. package/modules/provisioning/catalog-bridge.js +1 -1
  20. package/modules/provisioning/provisioning.js +1 -1
  21. package/modules/provisioning/route-helpers.js +50 -0
  22. package/modules/provisioning/routes/disconnect.js +6 -5
  23. package/modules/provisioning/routes/env-manifest.js +11 -27
  24. package/modules/provisioning/routes/provisioning.js +58 -75
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +24 -0
  28. package/scripts/gds/core-pin-sync.js +233 -0
  29. package/scripts/gds/module-assess-tests.js +40 -11
  30. package/scripts/gds/module-assess-version.js +10 -2
  31. package/scripts/gds/module-test-runner/README.md +44 -0
  32. package/scripts/gds/module-test-runner/module-tests.yml +87 -0
  33. package/scripts/gds/module-test-runner.js +358 -0
  34. package/src/module-api.js +1 -1
  35. package/tests/core_pin_sync.mjs +237 -0
  36. package/tests/goal_forest_lib.mjs +28 -0
  37. package/tests/goal_forest_page.mjs +106 -11
  38. package/tests/howto_guide.mjs +96 -0
  39. package/tests/module_assess_price_parity.mjs +2 -2
  40. package/tests/module_assess_score.mjs +13 -3
  41. package/tests/module_test_runner.mjs +294 -0
  42. package/tests/project_catalog_routes.mjs +1 -1
  43. package/tests/provisioning_module_selection.mjs +1 -1
  44. package/tests/provisioning_project_owner_gate.mjs +271 -0
@@ -67,10 +67,12 @@ const { callbackPage } = require('./callback-page'); const githubSetup = require
67
67
  const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators'); const demo = require('../demo'); const birth = require('../birth');
68
68
  const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
69
69
  const { settingsPush, failPushConflict } = require('../settings-push'); // a settings save's push + receipt, shared with routes/look.js (task 1004421)
70
+ const { requireProjectOwner, projectActor, isProjectOwner } = require('../route-helpers'); // the owner-or-archon gate (task 1004159, ADR 0328)
70
71
  // the kernel ports this module provides (moved out at the size ratchet, task 1003578)
71
72
  const { registerProvisioningSeams } = require('../seams');
72
73
  // task 1003208: structured logging (pino via the doorway) — was console.*.
73
74
  const log = api.logger('provisioning');
75
+ const loadInstance = (id) => provisioning.getInstanceById(pool, id);
74
76
 
75
77
  // How long a GitHub-App-Manifest `state` token is valid — the owner must click the
76
78
  // pre-filled link + approve on GitHub within this window (task 2080). Comfortably
@@ -634,27 +636,18 @@ module.exports = function provisioningRoutes() {
634
636
  }
635
637
  });
636
638
 
637
- // Own-scoped with an Archon override: an owner manages their own project; an
638
- // Archon may act on any (fleet support). 404 for everyone else — no existence
639
- // leak. Guards the per-id read (the manage page, task 1003041) and both
640
- // settings routes (task 1003040).
641
- function instanceAccessDenied(inst, builder) {
642
- if (!inst) return true;
643
- if (builder.rank === 'archon') return false;
644
- return String(inst.owner_builder_id) !== String(builder.id);
645
- }
639
+ // Every own-scoped /provisioning/instances/:id route below mounts requireProjectOwner
640
+ // (../route-helpers.js): the owner, or an Archon on any project (fleet support), and the
641
+ // same 404 for everyone else — no existence leak. The row rides on req.project.
646
642
 
647
643
  // GET /provisioning/instances/:id — read one instance (the manage page's read).
648
644
  // rank: any authenticated builder (own resource; archon may read any — task
649
645
  // 1003041). 404 otherwise — does not leak another owner's instance existence.
650
- router.get('/provisioning/instances/:id', auth.requireBuilder, async (req, res) => {
646
+ router.get('/provisioning/instances/:id', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
651
647
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
652
648
  if (!id) return;
653
649
  try {
654
- const inst = await provisioning.getInstanceById(pool, id);
655
- if (instanceAccessDenied(inst, req.builder)) {
656
- return res.fail('instance_not_found', 404);
657
- }
650
+ const inst = req.project;
658
651
  // SELF-HEAL THE CATALOG PROJECTION (task 1003581). ADR 0246's projection is
659
652
  // write-triggered, so an instance older than it has no catalog row and its owner's
660
653
  // invites 404 until they happen to save something. This own-scoped read — the manage
@@ -730,7 +723,7 @@ module.exports = function provisioningRoutes() {
730
723
  // the answer moved moves back. Only null does it — an empty string or any other
731
724
  // value is still refused as off-vocabulary, and `detail: {}` still changes nothing,
732
725
  // because a form that collected no answers is not asking for any to be erased.
733
- router.patch('/provisioning/instances/:id/detail', auth.requireBuilder, async (req, res) => {
726
+ router.patch('/provisioning/instances/:id/detail', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
734
727
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
735
728
  if (!id) return;
736
729
  if (validateOrRespond(req, res, {
@@ -757,9 +750,8 @@ module.exports = function provisioningRoutes() {
757
750
  message: 'The body carries nothing to change — send description, detail, modules, type, or any mix.' });
758
751
  }
759
752
  try {
760
- const inst = await provisioning.getInstanceById(pool, id);
761
- if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
762
- const actor = String(inst.owner_builder_id) === String(req.builder.id) ? 'api:self' : `api:archon:${req.builder.id}`;
753
+ const inst = req.project;
754
+ const actor = projectActor(req);
763
755
  // An empty description is CLEARING it, not a no-op string: normalize here so
764
756
  // the column never holds '' — a blank string would read as answered to a
765
757
  // `description IS NOT NULL` publish check and put an empty husk on the map.
@@ -810,7 +802,7 @@ module.exports = function provisioningRoutes() {
810
802
  // purpose: ranking builders is a directory read the pure engine may not run.
811
803
  // This is the resolution, and ADR 0251 is why it looks like this.
812
804
  //
813
- // rank: any authenticated builder, own resource — `instanceAccessDenied`, the
805
+ // rank: any authenticated builder, own resource — `requireProjectOwner`, the
814
806
  // same gate and the same 404 as the manage read above. That is deliberate and
815
807
  // not a new authorization idea: REACHING this route IS the ownership answer
816
808
  // (ADR 0249 §4), so the surface never re-derives "is this theirs" and can never
@@ -821,12 +813,9 @@ module.exports = function provisioningRoutes() {
821
813
  // nobody on purpose (ADR 0239 §6), which is a different statement from an empty
822
814
  // list nobody filled — so that answer returns its own reason and never touches
823
815
  // the directory at all.
824
- router.get('/provisioning/instances/:id/invite-suggestions', auth.requireBuilder, async (req, res) => {
825
- const id = parseId(req, res);
826
- if (!id) return;
816
+ router.get('/provisioning/instances/:id/invite-suggestions', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
827
817
  try {
828
- const inst = await provisioning.getInstanceById(pool, id);
829
- if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
818
+ const inst = req.project;
830
819
 
831
820
  const detail = provisioning.projectDetail(inst) || {};
832
821
  const { invites } = recommendationsForDetails({ type: inst.type, ...detail });
@@ -862,18 +851,15 @@ module.exports = function provisioningRoutes() {
862
851
  // ── Per-project settings (task 1003040, goal 1000058) ─────────────────────
863
852
  // The foundation the manage-page wave hangs off: effective settings are the
864
853
  // DEFAULTS (today's behavior — public surfaces, apply-to-join) overlaid with
865
- // the instance's stored keys. Access: instanceAccessDenied above (own-scoped,
854
+ // the instance's stored keys. Access: requireProjectOwner (own-scoped,
866
855
  // Archon override, 404 otherwise — no existence leak).
867
856
 
868
857
  // GET /provisioning/instances/:id/settings — read a project's effective
869
858
  // settings + the allowed values (so a form can render its choices).
870
859
  // rank: any authenticated builder (own resource; archon may read any).
871
- router.get('/provisioning/instances/:id/settings', auth.requireBuilder, async (req, res) => {
872
- const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
873
- if (!id) return;
860
+ router.get('/provisioning/instances/:id/settings', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
874
861
  try {
875
- const inst = await provisioning.getInstanceById(pool, id);
876
- if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
862
+ const inst = req.project;
877
863
  // `planet` rides beside `settings` (task 1003347): the same row, read by a
878
864
  // different rule — no defaults, null = the platform's own pick.
879
865
  res.json({ ok: true, settings: provisioning.effectiveSettings(inst), planet: provisioning.planetChoice(inst),
@@ -902,7 +888,7 @@ module.exports = function provisioningRoutes() {
902
888
  // did not get carried (that 409, or an enqueue that threw) marks the push OWED,
903
889
  // which is what makes the "send it again" both of those name actually re-queue
904
890
  // instead of hitting the no-op "No change" branch (task 1003524).
905
- router.patch('/provisioning/instances/:id/settings', auth.requireBuilder, async (req, res) => {
891
+ router.patch('/provisioning/instances/:id/settings', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
906
892
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
907
893
  if (!id) return;
908
894
  // The schema IS the vocabularies, not a copy of them (task 1003352): strict
@@ -927,11 +913,10 @@ module.exports = function provisioningRoutes() {
927
913
  message: `The body carries no settings to change — send any of: ${settingKeys.join(', ')}.` });
928
914
  }
929
915
  try {
930
- const inst = await provisioning.getInstanceById(pool, id);
931
- if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
916
+ const inst = req.project;
932
917
  // The archon override carries WHICH archon (the force-teardown convention) on
933
918
  // both the event and the intent it may enqueue below.
934
- const actor = String(inst.owner_builder_id) === String(req.builder.id) ? 'api:self' : `api:archon:${req.builder.id}`;
919
+ const actor = projectActor(req);
935
920
  // A save that changes nothing writes nothing and restarts nothing — a
936
921
  // whole-form save or a retried request must not bounce a live project. The
937
922
  // one exception is the owner re-kick (the domain route's F19 shape): the same
@@ -983,32 +968,32 @@ module.exports = function provisioningRoutes() {
983
968
  });
984
969
 
985
970
  // POST /provisioning/instances/:id/teardown — request teardown of the caller's
986
- // OWN instance. rank: any authenticated builder (own resource). Enqueues a
987
- // 'teardown' intent; the runner executes it (web tier holds no tokens). 404 when
988
- // the id isn't the caller's; 409 when the instance isn't in a teardownable state.
989
- router.post('/provisioning/instances/:id/teardown', auth.requireBuilder, async (req, res) => {
971
+ // OWN instance. rank: any authenticated builder (own resource; archon may tear down
972
+ // any — the override is audited via the actor). Enqueues a 'teardown' intent; the
973
+ // runner executes it (web tier holds no tokens). 404 when the id isn't the caller's;
974
+ // 409 when the instance isn't in a teardownable state.
975
+ router.post('/provisioning/instances/:id/teardown', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
990
976
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
991
977
  if (!id) return;
992
978
  try {
993
- const inst = await provisioning.getInstanceById(pool, id);
994
- if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) {
995
- return res.fail('instance_not_found', 404);
996
- }
979
+ const inst = req.project;
980
+ const actor = projectActor(req);
997
981
  if (refusedAsTornDown(inst, res)) return;
998
982
  // enqueueTeardown supersedes a stale pending provision intent and reports each
999
983
  // outcome distinctly, so "already queued" is only ever said when it is TRUE
1000
984
  // (task 1002691 — the old blind enqueue lied here and the instance leaked).
1001
- const r = await provisioning.enqueueTeardown(pool, id, 'api:self');
985
+ const r = await provisioning.enqueueTeardown(pool, id, actor);
1002
986
  if (r.conflict) {
1003
987
  return res.fail('intent_conflict', { status: 409,
1004
988
  message: `A '${r.intent.action}' run is currently ${r.intent.state} for this instance — the teardown could not be queued; retry in a moment.`,
1005
989
  details: { action: r.intent.action, state: r.intent.state } });
1006
990
  }
1007
991
  if (r.created) {
992
+ const who = isProjectOwner(inst, req.builder) ? 'owner' : `archon ${req.builder.github_login || req.builder.id}`;
1008
993
  await provisioning.recordEvent(pool, {
1009
- instanceId: id, ownerBuilderId: req.builder.id, event: 'teardown',
1010
- detail: r.superseded ? 'teardown requested by owner (superseded a stale provision intent)' : 'teardown requested by owner',
1011
- actor: 'api:self',
994
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'teardown',
995
+ detail: `teardown requested by ${who}${r.superseded ? ' (superseded a stale provision intent)' : ''}`,
996
+ actor,
1012
997
  }).catch(() => {});
1013
998
  }
1014
999
  res.json({ ok: true, queued: r.created, action: 'teardown', status: inst.status, superseded: r.superseded,
@@ -1034,14 +1019,13 @@ module.exports = function provisioningRoutes() {
1034
1019
  // Every refusal names the action that IS available in that state rather than
1035
1020
  // hiding behind a code, and no branch promises a duration — a restart's downtime
1036
1021
  // is exactly what this route cannot know (the F16 rule, ADR 0190 §7).
1037
- router.post('/provisioning/instances/:id/restart', auth.requireBuilder, async (req, res) => {
1022
+ router.post('/provisioning/instances/:id/restart', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
1038
1023
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
1039
1024
  if (!id) return;
1040
1025
  try {
1041
- const inst = await provisioning.getInstanceById(pool, id);
1042
- if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
1026
+ const inst = req.project;
1043
1027
  // The archon override carries WHICH archon (the force-teardown convention).
1044
- const actor = String(inst.owner_builder_id) === String(req.builder.id) ? 'api:self' : `api:archon:${req.builder.id}`;
1028
+ const actor = projectActor(req);
1045
1029
  if (!provisioning.RESTART_FROM.has(inst.status)) {
1046
1030
  const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
1047
1031
  return res.fail('not_restartable', { status: 409, details: { status: inst.status }, message: offline
@@ -1086,8 +1070,9 @@ module.exports = function provisioningRoutes() {
1086
1070
  // (re)provision run wires the domain itself. A bring-your-own address may be
1087
1071
  // RE-POINTED (task 1003237, ADR 0200): the same guards the first attach applies
1088
1072
  // decide it, and the address it moved off is queued for release.
1089
- // Body: { domain (required) }.
1090
- router.post('/provisioning/instances/:id/domain', auth.requireBuilder, domainWriteRateLimit, async (req, res) => {
1073
+ // Body: { domain (required) }. An archon may set any project's address; the
1074
+ // override is audited via the actor.
1075
+ router.post('/provisioning/instances/:id/domain', auth.requireBuilder, domainWriteRateLimit, requireProjectOwner(loadInstance), async (req, res) => {
1091
1076
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
1092
1077
  if (!id) return;
1093
1078
  if (validateOrRespond(req, res, { domain: { required: true, type: 'string', maxLength: 253 } })) return;
@@ -1104,10 +1089,8 @@ module.exports = function provisioningRoutes() {
1104
1089
  message: `${domain} is a platform-reserved address — bring a domain of your own instead.`,
1105
1090
  });
1106
1091
  }
1107
- const inst = await provisioning.getInstanceById(pool, id);
1108
- if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) {
1109
- return res.fail('instance_not_found', 404);
1110
- }
1092
+ const inst = req.project;
1093
+ const actor = projectActor(req);
1111
1094
  // Same rule as create: inside the platform apex the only address an instance
1112
1095
  // can hold is its own <slug>.<apex> — anything else is a squat (task 1002704).
1113
1096
  const apexZone = provisioning.normalizeHostname(instanceApex());
@@ -1129,11 +1112,11 @@ module.exports = function provisioningRoutes() {
1129
1112
  // idempotent no-op below.
1130
1113
  const needsRewire = provisioning.ownsErrorNote(inst, 'domain-attach') || inst.last_verified_up === false;
1131
1114
  if (inst.status === 'active' && needsRewire && !(await provisioning.getOpenIntent(pool, id))) {
1132
- const { created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
1115
+ const { created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', actor);
1133
1116
  if (created) {
1134
1117
  await provisioning.recordEvent(pool, {
1135
- instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
1136
- detail: `domain ${domain} re-attach queued (owner re-kick)`, actor: 'api:self',
1118
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'domain',
1119
+ detail: `domain ${domain} re-attach queued (${isProjectOwner(inst, req.builder) ? 'owner' : 'archon'} re-kick)`, actor,
1137
1120
  }).catch(() => {});
1138
1121
  }
1139
1122
  // created:false = a racing enqueue got there first — still truthful.
@@ -1176,7 +1159,7 @@ module.exports = function provisioningRoutes() {
1176
1159
  await catalogBridge.publishInstanceToCatalog(pool, rewired, { retireOrigin: current }).catch(() => {});
1177
1160
  let queued = false;
1178
1161
  if (inst.status === 'active') {
1179
- const { intent, created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
1162
+ const { intent, created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', actor);
1180
1163
  if (!created && intent.action !== 'domain-attach') {
1181
1164
  // The open slot is held by other work (e.g. a teardown). The address IS
1182
1165
  // recorded — say exactly what happened and what to do.
@@ -1188,8 +1171,8 @@ module.exports = function provisioningRoutes() {
1188
1171
  if (!created) {
1189
1172
  // an identical attach is already open — idempotent
1190
1173
  await provisioning.recordEvent(pool, {
1191
- instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
1192
- detail: `domain ${domain} recorded (attach already queued)`, actor: 'api:self',
1174
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'domain',
1175
+ detail: `domain ${domain} recorded (attach already queued)`, actor,
1193
1176
  }).catch(() => {});
1194
1177
  return res.json({ ok: true, changed: true, queued: false, domain, replaced: releasing,
1195
1178
  message: 'That address is already being wired for this instance.' });
@@ -1197,9 +1180,9 @@ module.exports = function provisioningRoutes() {
1197
1180
  }
1198
1181
  const moved = releasing ? ` (moved off ${releasing}, queued for release)` : '';
1199
1182
  await provisioning.recordEvent(pool, {
1200
- instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
1183
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'domain',
1201
1184
  detail: (queued ? `domain ${domain} recorded + attach queued` : `domain ${domain} recorded (wired at provision)`) + moved,
1202
- actor: 'api:self',
1185
+ actor,
1203
1186
  }).catch(() => {});
1204
1187
  // State-only sentences (task 1002720 / F16): the web tier holds no cloud
1205
1188
  // tokens and cannot know whether a DNS hook exists on the runner, so "it
@@ -1225,7 +1208,7 @@ module.exports = function provisioningRoutes() {
1225
1208
 
1226
1209
  // POST /provisioning/instances/:id/github-app — mint a GitHub-App-Manifest onboarding
1227
1210
  // link for the caller's OWN instance (task 2080, ADR 0133). rank: any authenticated
1228
- // builder (own resource). Returns the pre-filled manifest + the GitHub form-POST target
1211
+ // builder (own resource; archon may mint for any — audited via the actor). Returns the pre-filled manifest + the GitHub form-POST target
1229
1212
  // (personal + org variants) + a one-time `state`; the browser POSTs the manifest to
1230
1213
  // GitHub, the owner approves, and GitHub redirects back to the public callback below.
1231
1214
  // The web tier only RECORDS the attempt (state token) — the control-plane runner does
@@ -1237,7 +1220,7 @@ module.exports = function provisioningRoutes() {
1237
1220
  // whether the founder may make an app there is GitHub's to enforce on its own page
1238
1221
  // (this module holds no GitHub token to ask with, ADR 0111 §2). Anything other than
1239
1222
  // account 'org' is the founder's personal account, exactly as before.
1240
- router.post('/provisioning/instances/:id/github-app', auth.requireBuilder, async (req, res) => {
1223
+ router.post('/provisioning/instances/:id/github-app', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
1241
1224
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
1242
1225
  if (!id) return;
1243
1226
  if (validateOrRespond(req, res, { account: { type: 'string', maxLength: 12 }, org: { type: 'string', maxLength: 39 } })) return;
@@ -1247,10 +1230,8 @@ module.exports = function provisioningRoutes() {
1247
1230
  return res.fail('bad_org', { status: 400, message: 'That isn’t a GitHub organisation name: letters, numbers and single dashes, up to 39.' });
1248
1231
  }
1249
1232
  try {
1250
- const inst = await provisioning.getInstanceById(pool, id);
1251
- if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) {
1252
- return res.fail('instance_not_found', 404);
1253
- }
1233
+ const inst = req.project;
1234
+ const actor = projectActor(req);
1254
1235
  // A domain is required: it is the App's homepage + the sign-in callback host. No
1255
1236
  // domain ⇒ there is nothing to point "Login with GitHub" at yet.
1256
1237
  if (!inst.domain) {
@@ -1274,17 +1255,19 @@ module.exports = function provisioningRoutes() {
1274
1255
  if (guard && guard.refuse) return res.fail(guard.refuse, { status: 409, message: guard.message });
1275
1256
  const state = guard ? guard.reuse : crypto.randomBytes(32).toString('hex');
1276
1257
  const expiresAt = guard ? guard.expiresAt : new Date(Date.now() + MANIFEST_STATE_TTL_MS).toISOString();
1277
- if (!guard) await provisioning.createOAuthManifest(pool, { instanceId: id, state, requestedBy: req.builder.id, expiresAt });
1258
+ // requested_by is read downstream as the project's owner (every later manifest event's
1259
+ // ownerBuilderId); an archon's mint is named by the actor on the event below instead
1260
+ if (!guard) await provisioning.createOAuthManifest(pool, { instanceId: id, state, requestedBy: inst.owner_builder_id, expiresAt });
1278
1261
  await provisioning.recordEvent(pool, {
1279
- instanceId: id, ownerBuilderId: req.builder.id, event: 'oauth-manifest-start',
1280
- detail: guard ? 'GitHub App Manifest link reopened (same attempt)' : 'GitHub App Manifest link minted', actor: 'api:self',
1262
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'oauth-manifest-start',
1263
+ detail: guard ? 'GitHub App Manifest link reopened (same attempt)' : 'GitHub App Manifest link minted', actor,
1281
1264
  }).catch(() => {});
1282
1265
  // which account owns this attempt's app, so the page can link its settings there
1283
1266
  // from any tab or device (github-setup.js reads it back as app_org)
1284
1267
  if (account === 'org') {
1285
1268
  await provisioning.recordEvent(pool, {
1286
- instanceId: id, ownerBuilderId: req.builder.id, event: githubSetup.SETUP_EVENTS.appOrg,
1287
- detail: orgLogin, actor: 'api:self',
1269
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: githubSetup.SETUP_EVENTS.appOrg,
1270
+ detail: orgLogin, actor,
1288
1271
  }).catch(() => {});
1289
1272
  }
1290
1273
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.124",
3
+ "version": "1.21.126",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.124",
9
+ "version": "1.21.126",
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.21.124",
3
+ "version": "1.21.126",
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",
@@ -9726,5 +9726,29 @@
9726
9726
  "id": "1004633",
9727
9727
  "text": "Found why a merged task on your own project stayed at 'confirmed': with no GitHub key set up on the project's server, Bongos could not prove the merge happened, and its background checker switched itself off. It now confirms t"
9728
9728
  }
9729
+ ],
9730
+ "1.21.125": [
9731
+ {
9732
+ "id": "1004460",
9733
+ "text": "Store modules' own tests can now run on GitHub's free test machines, away from the server holding our secrets. It switches on once the owner does three short GitHub setup steps."
9734
+ },
9735
+ {
9736
+ "id": "1004159",
9737
+ "text": "Project owners' controls now share one access check, and Archons can help with teardown, address and GitHub setup on any project — every such action is logged under the Archon's name."
9738
+ },
9739
+ {
9740
+ "id": "1004632",
9741
+ "text": "When a project updates its Bongos core, the next task's ship now uses the new core automatically instead of the old one, so it no longer fails for no visible reason."
9742
+ },
9743
+ {
9744
+ "id": "1004627",
9745
+ "text": "Bongos now has a 27-step plain-language how-to guide that an AI can walk a new person through inside their session, from where to start to their first ship."
9746
+ }
9747
+ ],
9748
+ "1.21.126": [
9749
+ {
9750
+ "id": "1004738",
9751
+ "text": "A goal's page is much shorter now: task dependencies sit in a small card at the top where Conflicts used to be, and the full dependency picture and conflict list are one click away."
9752
+ }
9729
9753
  ]
9730
9754
  }
@@ -0,0 +1,233 @@
1
+ // scripts/gds/core-pin-sync.js — make `bongos ship` run the core the checkout PINS (task 1004632)
2
+ //
3
+ // WHY. An instance's task worktrees (`.claude/worktrees/task-N`) have no `node_modules`
4
+ // of their own: Node and `npx` walk up to the MAIN checkout's, and so does every
5
+ // `bongos ship`. After a core-update task lands, the next task's branch pins the new
6
+ // core in package.json — but its worktree still resolves the main checkout's install,
7
+ // which is whatever version was there before. On the owner's project that meant a
8
+ // ship ran 1.21.20 against a branch pinned to 1.21.71 and failed on a bug 1.21.71 had
9
+ // already fixed; the AI had to `npm ci` by hand in both places.
10
+ //
11
+ // THE FIX. Before `bongos ship` runs, the dispatcher asks `planCoreSync()` whether the
12
+ // core it is running is the one the checkout it stands in pins. When it is not, it
13
+ // installs the pinned core INTO that checkout (`npm ci`, or `npm install` with no
14
+ // lockfile) and re-runs the same command under it. A worktree that gets its own
15
+ // install keeps resolving it from then on. If the install fails, the builder gets one
16
+ // line that names the command to run — never a silent run on the wrong core. Only
17
+ // `ship` checks: it is the verb that runs the core's land/smoke code against the
18
+ // branch, and it is slow anyway — the many quick verbs (status, recall, exec) pay no
19
+ // git spawn for it.
20
+ //
21
+ // Safety. The pin comes from the branch, and installing it then RUNS it — so a branch
22
+ // must never be able to choose what gets installed. Two gates, both required:
23
+ // 1. the spec is a shape the core itself writes — a vendored
24
+ // `file:vendor/bongos-core-<v>.tgz` or an exact registry semver;
25
+ // 2. the pin has already LANDED: package.json, package-lock.json and the vendored
26
+ // tarball in this checkout are byte-identical (same git blob, no local edits) to
27
+ // the ones on `main` / `origin/main`. That is exactly the case this fixes — a
28
+ // core update merged, the next task's branch carries it — and a branch that pins
29
+ // something main does not get the remedy line, never an automatic install.
30
+ // The install also runs with `--ignore-scripts` (the core's dependencies need no
31
+ // install hook), through npm's own JS entry under this node where it can be found, so
32
+ // no shell is involved; only a Windows box without it falls back to `npm.cmd` + shell,
33
+ // with fixed arguments. A `node_modules` that
34
+ // is a LINK (the junction worktree.js gives an outside-repo worktree) is unlinked
35
+ // first — installing through it would rewrite the MAIN checkout's install. It is never
36
+ // followed and a real directory is never removed. The re-run sets BONGOS_CORE_SYNCED
37
+ // so a pin the install still cannot satisfy cannot loop.
38
+ //
39
+ // It does nothing in the core repo itself (the running package is not `@bongos/core`),
40
+ // outside git, or when the checkout pins no core. Pure planner + injectable runner, so
41
+ // tests/core_pin_sync.mjs drives every branch without npm or a network.
42
+
43
+ 'use strict';
44
+
45
+ const fs = require('node:fs');
46
+ const path = require('node:path');
47
+ const { spawnSync } = require('node:child_process');
48
+
49
+ const CORE_PKG = '@bongos/core';
50
+ const SYNCED_ENV = 'BONGOS_CORE_SYNCED';
51
+
52
+ function readJson(file, fsImpl = fs) {
53
+ try { return JSON.parse(String(fsImpl.readFileSync(file, 'utf8')).replace(/^/, '')); } catch { return null; }
54
+ }
55
+
56
+ // { version, safe } for a dependency spec. `version` reads the same shapes upgrade.js
57
+ // does (kept local so the dispatcher never loads upgrade.js's tree); `safe` is true
58
+ // only for the two shapes the core writes itself — a tarball under vendor/ named for
59
+ // its version, or an EXACT registry semver.
60
+ function parseSpec(spec) {
61
+ const s = String(spec || '').trim();
62
+ const tgz = s.match(/bongos-core-(\d+\.\d+\.\d+[^/\\]*)\.tgz$/);
63
+ if (tgz) return { version: tgz[1], safe: /^file:(\.\/)?vendor\/bongos-core-[^/\\]+\.tgz$/.test(s) };
64
+ if (/^file:/.test(s)) return { version: null, safe: false };
65
+ const sem = s.match(/^[\^~>=<\s]*v?(\d+\.\d+\.\d+[A-Za-z0-9.+-]*)$/);
66
+ if (!sem) return { version: null, safe: false };
67
+ return { version: sem[1], safe: /^\d+\.\d+\.\d+(-[A-Za-z0-9.]+)?$/.test(s) };
68
+ }
69
+
70
+ function versionFromSpec(spec) { return parseSpec(spec).version; }
71
+
72
+ function coreSpec(root, fsImpl = fs) {
73
+ const pkg = readJson(path.join(root, 'package.json'), fsImpl);
74
+ return pkg ? ((pkg.dependencies || {})[CORE_PKG] || (pkg.devDependencies || {})[CORE_PKG] || null) : null;
75
+ }
76
+
77
+ // What the checkout at `root` pins: the lockfile's resolved version first (exact), then
78
+ // the package.json spec.
79
+ function pinnedCoreVersion(root, fsImpl = fs) {
80
+ const lock = readJson(path.join(root, 'package-lock.json'), fsImpl);
81
+ const locked = lock && lock.packages && lock.packages[`node_modules/${CORE_PKG}`];
82
+ if (locked && locked.version) return locked.version;
83
+ return versionFromSpec(coreSpec(root, fsImpl));
84
+ }
85
+
86
+ // The refs that count as LANDED for gate 2: a pin is trusted only once it is on one of them.
87
+ const LANDED_REFS = ['main', 'origin/main'];
88
+
89
+ function gitOut(args, cwd, run) {
90
+ try {
91
+ const r = run('git', args, { cwd, encoding: 'utf8' });
92
+ return r && r.status === 0 ? String(r.stdout || '').trim() : null;
93
+ } catch { return null; }
94
+ }
95
+
96
+ // Gate 2: every file that decides what `npm ci` installs — package.json, the lockfile,
97
+ // the project .npmrc (it can redirect the registry), and the vendored tarball the spec
98
+ // names — is in the SAME state as on a landed ref: the same blob as it sits on disk (so
99
+ // an uncommitted edit counts), or absent on both. Deleting a file the landed ref has
100
+ // is a difference too. True when one landed ref matches all of them. The lockfile is
101
+ // also required outright: the install is `npm ci`, whose integrity check is the point.
102
+ const PIN_FILES = ['package.json', 'package-lock.json', '.npmrc'];
103
+
104
+ function pinLanded(root, { run = spawnSync, fsImpl = fs } = {}) {
105
+ if (!fsImpl.existsSync(path.join(root, 'package-lock.json'))) return false;
106
+ const spec = coreSpec(root, fsImpl);
107
+ const files = [...PIN_FILES];
108
+ const vendored = String(spec || '').match(/^file:(?:\.\/)?(vendor\/bongos-core-[^/\\]+\.tgz)$/);
109
+ if (vendored) files.push(vendored[1]);
110
+ const local = files.map((f) => (fsImpl.existsSync(path.join(root, f)) ? (gitOut(['hash-object', '--', f], root, run) || 'unreadable') : null));
111
+ return LANDED_REFS.some((ref) => files.every((f, i) => gitOut(['rev-parse', '--verify', '--quiet', `${ref}:${f}`], root, run) === local[i]));
112
+ }
113
+
114
+ function gitTopLevel(cwd, run = spawnSync) {
115
+ try {
116
+ const r = run('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
117
+ if (r.status !== 0) return null;
118
+ const top = String(r.stdout || '').trim();
119
+ return top ? path.resolve(top) : null;
120
+ } catch { return null; }
121
+ }
122
+
123
+ // Pure decision. Inputs are facts; output says whether to sync and why.
124
+ // runningPkg the running core's package.json ({ name, version })
125
+ // root the git top-level of the cwd (null outside git)
126
+ // pinned what that checkout pins (null when it pins no core)
127
+ // specSafe gate 1: the spec is a shape the core writes itself (parseSpec)
128
+ // landed gate 2: the pin is byte-identical to a landed ref's (pinLanded)
129
+ // env process env (the re-run guard)
130
+ function planCoreSync({ runningPkg, root, pinned, specSafe = false, landed = false, env = process.env }) {
131
+ if (!runningPkg || runningPkg.name !== CORE_PKG) return { action: 'none', reason: 'not-installed-core' };
132
+ if (!root) return { action: 'none', reason: 'no-git' };
133
+ if (!pinned) return { action: 'none', reason: 'no-pin' };
134
+ const running = runningPkg.version || null;
135
+ if (running === pinned) return { action: 'none', reason: 'in-sync', running, pinned };
136
+ if (env && env[SYNCED_ENV]) return { action: 'refuse', reason: 'still-mismatched', running, pinned, root };
137
+ if (!specSafe) return { action: 'refuse', reason: 'unrecognised-spec', running, pinned, root };
138
+ if (!landed) return { action: 'refuse', reason: 'pin-not-landed', running, pinned, root };
139
+ return { action: 'sync', reason: 'mismatch', running, pinned, root };
140
+ }
141
+
142
+ function installArgs(hasLock) {
143
+ return [hasLock ? 'ci' : 'install', '--ignore-scripts', '--no-audit', '--no-fund'];
144
+ }
145
+
146
+ // npm's own JS entry beside this node (Windows: <dir>/node_modules/npm; POSIX:
147
+ // <prefix>/lib/node_modules/npm), so the install needs no shell. null when absent.
148
+ function npmCliPath(execPath = process.execPath, fsImpl = fs) {
149
+ const dir = path.dirname(execPath);
150
+ const candidates = [
151
+ path.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
152
+ path.join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
153
+ ];
154
+ return candidates.find((c) => fsImpl.existsSync(c)) || null;
155
+ }
156
+
157
+ function remedyLine({ root, pinned, running, hasLock = true }) {
158
+ return `bongos: this checkout pins core ${pinned} but ${running || 'another version'} is running. ` +
159
+ `Run \`npm ${hasLock ? 'ci' : 'install'}\` in ${root}, then run the command again.`;
160
+ }
161
+
162
+ // Remove a node_modules LINK without following it — the same move as worktree.js's
163
+ // unlinkNodeModulesLink, kept here rather than required: bin/bongos.js is in the
164
+ // executed closure (tests/executed_closure.mjs), and worktree.js would pull its
165
+ // computed require in with it. unlink takes a symlink and (via libuv) a junction; a
166
+ // Windows reparse point unlink refuses falls back to rmdir, which removes the link only.
167
+ function unlinkLink(linkPath, { platform = process.platform, fsImpl = fs } = {}) {
168
+ if (!fsImpl.lstatSync(linkPath).isSymbolicLink()) throw new Error(`${linkPath} is not a link`);
169
+ try { fsImpl.unlinkSync(linkPath); } catch (e) {
170
+ if (platform !== 'win32' || (e.code !== 'EPERM' && e.code !== 'EISDIR')) throw e;
171
+ fsImpl.rmdirSync(linkPath);
172
+ }
173
+ }
174
+
175
+ // Install the pinned core into `root` and re-run the same bongos command under it.
176
+ // Returns the exit code to use.
177
+ function syncAndRerun(plan, { argv, run = spawnSync, fsImpl = fs, log = (s) => process.stderr.write(s + '\n'), env = process.env, platform = process.platform, npmCli = npmCliPath(process.execPath, fsImpl) } = {}) {
178
+ const { root, pinned, running } = plan;
179
+ const hasLock = fsImpl.existsSync(path.join(root, 'package-lock.json'));
180
+ const remedy = () => log(remedyLine({ root, pinned, running, hasLock }));
181
+ log(`bongos: this checkout pins core ${pinned}, but ${running} is running — installing ${pinned} here first.`);
182
+ const nm = path.join(root, 'node_modules');
183
+ let st = null;
184
+ try { st = fsImpl.lstatSync(nm); } catch { /* absent */ }
185
+ if (st && st.isSymbolicLink()) {
186
+ try { unlinkLink(nm, { platform, fsImpl }); } catch (e) {
187
+ log(`bongos: could not detach the shared node_modules link (${e.message}).`);
188
+ remedy();
189
+ return 1;
190
+ }
191
+ }
192
+ // npm's JS entry under this node — no shell. Fallback: `npm`, which on win32 is
193
+ // npm.cmd and needs a shell (Node refuses a .cmd without one); the args are fixed
194
+ // strings, never branch data.
195
+ const cli = npmCli;
196
+ const inst = cli
197
+ ? run(process.execPath, [cli, ...installArgs(hasLock)], { cwd: root, stdio: 'inherit', env })
198
+ : run('npm', installArgs(hasLock), { cwd: root, stdio: 'inherit', env, ...(platform === 'win32' ? { shell: true } : {}) });
199
+ if (!inst || inst.error || inst.status !== 0) {
200
+ log(`bongos: installing core ${pinned} failed.`);
201
+ remedy();
202
+ return 1;
203
+ }
204
+ const bin = path.join(nm, '@bongos', 'core', 'bin', 'bongos.js');
205
+ if (!fsImpl.existsSync(bin)) { remedy(); return 1; }
206
+ const res = run(process.execPath, [bin, ...argv.slice(2)], { stdio: 'inherit', env: { ...env, [SYNCED_ENV]: '1' } });
207
+ if (!res || res.error) return 1;
208
+ return res.status == null ? 1 : res.status;
209
+ }
210
+
211
+ // The dispatcher's one call. Returns an exit code when it handled the command (synced
212
+ // and re-ran, or refused), or null to run the verb on the current core.
213
+ function ensurePinnedCore({ coreRoot, argv, cwd = process.cwd(), env = process.env, run = spawnSync, fsImpl = fs, log } = {}) {
214
+ if (env && env.BONGOS_SKIP_CORE_SYNC) return null;
215
+ const runningPkg = readJson(path.join(coreRoot, 'package.json'), fsImpl);
216
+ if (!runningPkg || runningPkg.name !== CORE_PKG) return null; // fast path: the core repo itself
217
+ const root = gitTopLevel(cwd, run);
218
+ const pinned = root ? pinnedCoreVersion(root, fsImpl) : null;
219
+ const specSafe = root ? parseSpec(coreSpec(root, fsImpl)).safe : false;
220
+ const out = { runningPkg, root, pinned, specSafe, env };
221
+ // gate 2 costs git spawns — only pay it when there is a mismatch to act on
222
+ const pre = planCoreSync({ ...out, landed: true });
223
+ const plan = pre.action === 'sync' ? planCoreSync({ ...out, landed: pinLanded(root, { run, fsImpl }) }) : pre;
224
+ if (plan.action === 'none') return null;
225
+ const say = log || ((s) => process.stderr.write(s + '\n'));
226
+ if (plan.action === 'refuse') {
227
+ say(remedyLine({ ...plan, hasLock: fsImpl.existsSync(path.join(root, 'package-lock.json')) }));
228
+ return 1;
229
+ }
230
+ return syncAndRerun(plan, { argv, run, fsImpl, log: say, env });
231
+ }
232
+
233
+ module.exports = { CORE_PKG, SYNCED_ENV, LANDED_REFS, pinLanded, npmCliPath, parseSpec, versionFromSpec, installArgs, pinnedCoreVersion, planCoreSync, remedyLine, syncAndRerun, ensurePinnedCore };