@bongos/core 1.21.125 → 1.21.127

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 (55) hide show
  1. package/.bongos-core.json +106 -51
  2. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +6 -0
  3. package/docs/adr/0354-a-builder-hosts-one-project-free-and-more-takes-the-fleet-permission.md +1 -1
  4. package/docs/api/openapi.json +4 -4
  5. package/docs/copy-inventory.md +128 -126
  6. package/docs/copy-registry.json +188 -170
  7. package/docs/module-api-changelog.md +5 -1
  8. package/docs/page-inventory.json +2 -1
  9. package/docs/page-readings.json +152 -143
  10. package/modules/hall-ui/public/goal-forest-lib.js +36 -0
  11. package/modules/hall-ui/public/goal-forest-render.js +50 -10
  12. package/modules/hall-ui/public/goals-page.js +19 -22
  13. package/modules/hall-ui/public/goals-render.js +6 -16
  14. package/modules/hall-ui/public/goals.css +13 -4
  15. package/modules/hall-ui/public/goals.html +22 -24
  16. package/modules/hall-ui/public/goals.states.json +19 -1
  17. package/modules/hall-ui/records/goal-dependency-forest.md +4 -3
  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/exec-path-guard.js +1 -1
  29. package/scripts/gds/fitness.js +1 -0
  30. package/scripts/gds/module-assess-tests.js +40 -11
  31. package/scripts/gds/module-assess-version.js +10 -2
  32. package/scripts/gds/module-error-report.js +148 -0
  33. package/scripts/gds/module-test-runner/README.md +44 -0
  34. package/scripts/gds/module-test-runner/module-tests.yml +87 -0
  35. package/scripts/gds/module-test-runner.js +358 -0
  36. package/scripts/gds/module.js +1 -0
  37. package/scripts/gds/provision-caps.js +63 -0
  38. package/scripts/gds/provision-process.js +3 -0
  39. package/scripts/gds/provision-units.js +3 -0
  40. package/scripts/gds/provision.js +3 -1
  41. package/src/module-api.js +1 -1
  42. package/src/module-loader/error-tally.js +188 -0
  43. package/src/module-loader/loader.js +52 -4
  44. package/src/platform-server.js +7 -0
  45. package/tests/goal_forest_lib.mjs +28 -0
  46. package/tests/goal_forest_page.mjs +106 -11
  47. package/tests/module_assess_price_parity.mjs +2 -2
  48. package/tests/module_assess_score.mjs +13 -3
  49. package/tests/module_error_counts.mjs +265 -0
  50. package/tests/module_test_runner.mjs +294 -0
  51. package/tests/project_catalog_routes.mjs +1 -1
  52. package/tests/provision.mjs +2 -0
  53. package/tests/provision_resource_caps.mjs +117 -0
  54. package/tests/provisioning_module_selection.mjs +1 -1
  55. 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.125",
3
+ "version": "1.21.127",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.125",
9
+ "version": "1.21.127",
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.125",
3
+ "version": "1.21.127",
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",
@@ -9728,6 +9728,14 @@
9728
9728
  }
9729
9729
  ],
9730
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
+ },
9731
9739
  {
9732
9740
  "id": "1004632",
9733
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."
@@ -9736,5 +9744,21 @@
9736
9744
  "id": "1004627",
9737
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."
9738
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
+ }
9753
+ ],
9754
+ "1.21.127": [
9755
+ {
9756
+ "id": "1004637",
9757
+ "text": "Projects now count each store module's errors per day and send the count to the store, so a module's reliability score comes from real use. It sends with a builder's sign-in on that machine and respects the reporting switch in"
9758
+ },
9759
+ {
9760
+ "id": "1004414",
9761
+ "text": "Each hosted project now has a memory and CPU limit, so one runaway project can no longer slow down every other project or the hub."
9762
+ }
9739
9763
  ]
9740
9764
  }
@@ -103,7 +103,7 @@ const EXEMPTIONS = [
103
103
  + 'absorbed into this check.',
104
104
  },
105
105
  {
106
- at: 'src/module-loader/loader.js:347',
106
+ at: 'src/module-loader/loader.js:350',
107
107
  why: 'The poller twin of the route require at :196 — same DEFAULT_ROOTS bound, same plugin-by-'
108
108
  + 'design rationale, same ADR 0107 / task 1003400 follow-on. Listed on its own line so a '
109
109
  + 'change to either call re-opens only that one.',
@@ -524,6 +524,7 @@ const KERNEL_FILES = [
524
524
  'src/module-loader/loader.js', // the disk loader (discover → validate → mount)
525
525
  'src/module-loader/manifest-schema.js', // the manifest validator
526
526
  'src/module-loader/semver.js', // coreVersion compatibility
527
+ 'src/module-loader/error-tally.js', // per-module runtime-error counts the loader feeds (task 1004637)
527
528
  'src/bongos/module-scope-map.js', // the module→path registry (it NAMES every module — exempt below)
528
529
  // trust primitives (ADR 0091 §1) — the authz boundary + data substrate
529
530
  'src/bongos/auth.js', // authz gates (requireBuilder/requireRank, ADR 0016)
@@ -33,13 +33,16 @@
33
33
  // (owner, 2026-09-30): never on the control plane, where the secrets live. The
34
34
  // store path (recordTestsSignal) refuses unless the process declares it is that
35
35
  // environment with MODULE_TEST_SANDBOX=1 — set only on the isolated test box, no
36
- // secrets on disk, which is its own task. `--dir` stays open: it runs a module
36
+ // secrets on disk: GitHub's free runners, driven by module-test-runner.js (task 1004460). `--dir` stays open: it runs a module
37
37
  // from your own checkout, i.e. code you already chose to run.
38
38
  //
39
39
  // node scripts/gds/module-assess-tests.js <store_module_versions.id> run + record
40
40
  // node scripts/gds/module-assess-tests.js --dir modules/<key> dry run on a
41
41
  // local module
42
42
  // (prints; no DB)
43
+ // node scripts/gds/module-assess-tests.js --tarball <f> --key <k> --version-id N --nonce X --json
44
+ // the testing environment's mode: no DB, prints the
45
+ // signal JSON (module-test-runner.js; task 1004460)
43
46
 
44
47
  const fs = require('node:fs');
45
48
  const path = require('node:path');
@@ -177,6 +180,18 @@ async function insertSignal(versionId, s, { db }) {
177
180
  return row;
178
181
  }
179
182
 
183
+ const notScored = (e) => ({ outcome: 'not_scored', score: null, sample_size: null, reason: `the tests could not run (${e.code || 'error'})`, detail: {} });
184
+
185
+ // Verify a tarball and run its tests: the signal fields, with a tarball that fails
186
+ // verification as not_scored. No DB — this is what the separate testing
187
+ // environment runs (`--tarball`, module-test-runner.js); assessTests keeps the gate.
188
+ async function assessTarball(tgz, key, opts = {}) {
189
+ const { verifyModuleArtifact } = require('./module-artifact');
190
+ const v = await verifyModuleArtifact(tgz, { key, includeFiles: true });
191
+ if (!v.ok) return { outcome: 'not_scored', score: null, sample_size: null, reason: `the tarball failed verification (${v.code}): ${v.message}`, detail: {} };
192
+ return assessTests(key, v.files, opts);
193
+ }
194
+
180
195
  // Read, verify and test one published version, then record the result. A tarball
181
196
  // that is missing or fails verification is recorded as not_scored — the part could
182
197
  // not be measured, which is not the same as the module failing its tests.
@@ -185,21 +200,16 @@ async function recordTestsSignal(versionId, { db, storeDir, root, timeoutMs, exe
185
200
  if (refused) return { ok: false, ...refused };
186
201
  const pool = db || require('../../src/bongos/pool').pool;
187
202
  const { versionArtifactFile } = require('../../src/bongos/module-store');
188
- const { verifyModuleArtifact } = require('./module-artifact');
189
203
  const { rows: [ver] } = await pool.query(
190
204
  'SELECT id, module_key, version, artifact_path FROM store_module_versions WHERE id = $1', [versionId]);
191
205
  if (!ver) return { ok: false, code: 'version_not_found', message: `no store_module_versions row ${versionId}` };
192
206
 
193
207
  let signal;
194
208
  try {
195
- const file = versionArtifactFile(ver, storeDir ? { dir: storeDir } : {});
196
- const tgz = await fs.promises.readFile(file);
197
- const v = await verifyModuleArtifact(tgz, { key: ver.module_key, includeFiles: true });
198
- signal = v.ok
199
- ? await assessTests(ver.module_key, v.files, { root, timeoutMs, exec, env })
200
- : { outcome: 'not_scored', score: null, sample_size: null, reason: `the tarball failed verification (${v.code}): ${v.message}`, detail: {} };
209
+ const tgz = await fs.promises.readFile(versionArtifactFile(ver, storeDir ? { dir: storeDir } : {}));
210
+ signal = await assessTarball(tgz, ver.module_key, { root, timeoutMs, exec, env });
201
211
  } catch (e) {
202
- signal = { outcome: 'not_scored', score: null, sample_size: null, reason: `the tests could not run (${e.code || 'error'})`, detail: {} };
212
+ signal = notScored(e);
203
213
  }
204
214
  const row = await insertSignal(ver.id, signal, { db: pool });
205
215
  return { ok: true, version: ver, signal: row };
@@ -220,7 +230,26 @@ function readModuleDir(dir) {
220
230
  return out;
221
231
  }
222
232
 
233
+ // The value after a flag, or null.
234
+ const flag = (argv, name) => { const i = argv.indexOf(name); return i === -1 ? null : (argv[i + 1] || ''); };
235
+
223
236
  async function main(argv) {
237
+ // DB-free, for the separate testing environment: verify + run + print the signal
238
+ // JSON (with the run's version id and nonce, which the control plane checks when
239
+ // it pulls the result). The gate still applies: without MODULE_TEST_SANDBOX=1 and
240
+ // a secret-free environment this refuses before anything is read.
241
+ const tarball = flag(argv, '--tarball');
242
+ if (tarball !== null) {
243
+ const key = flag(argv, '--key');
244
+ if (!tarball || !key) { console.error('usage: --tarball <file> --key <module key> [--version-id N --nonce X] --json'); return 2; }
245
+ const refused = runRefusal();
246
+ if (refused) { console.error(refused.message); return 1; }
247
+ let s;
248
+ try { s = await assessTarball(fs.readFileSync(tarball), key); }
249
+ catch (e) { s = notScored(e); }
250
+ console.log(JSON.stringify({ version_id: flag(argv, '--version-id'), nonce: flag(argv, '--nonce'), ...s }));
251
+ return 0;
252
+ }
224
253
  const i = argv.indexOf('--dir');
225
254
  if (i !== -1) {
226
255
  const dir = path.resolve(argv[i + 1] || '');
@@ -231,7 +260,7 @@ async function main(argv) {
231
260
  }
232
261
  const id = argv[0];
233
262
  if (!/^\d+$/.test(String(id || ''))) {
234
- console.error('usage: node scripts/gds/module-assess-tests.js <store_module_versions.id> | --dir modules/<key>');
263
+ console.error('usage: node scripts/gds/module-assess-tests.js <store_module_versions.id> | --dir modules/<key> | --tarball <file> --key <k> --json');
235
264
  return 2;
236
265
  }
237
266
  const res = await recordTestsSignal(id);
@@ -246,4 +275,4 @@ if (require.main === module) {
246
275
  main(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(e.stack || e.message); process.exit(1); });
247
276
  }
248
277
 
249
- module.exports = { discoverTestFiles, assessTests, recordTestsSignal, insertSignal, runRefusal, testEnv, SANDBOX_PREFIX };
278
+ module.exports = { discoverTestFiles, assessTests, assessTarball, recordTestsSignal, insertSignal, runRefusal, testEnv, SANDBOX_PREFIX };
@@ -7,6 +7,8 @@
7
7
  // this after it answers the author. Tests is recorded `pending`, and only when the
8
8
  // version has no Tests result yet: a published module's tests run only in the
9
9
  // separate test environment, never on the control plane (module-assess-tests.js).
10
+ // When the runner is configured (module-test-runner.js) the tests are dispatched to
11
+ // it here, and its result is pulled back into the row later.
10
12
  // The Security gate reads the tarball and asks npm about the declared dependencies
11
13
  // — it never runs the module's code, so it is safe here. Docs is one Sonnet call over
12
14
  // the HOWTO.md text (module-assess-docs.js) — also no module code. Then the score is
@@ -27,12 +29,18 @@ const { recomposeScore } = require('./module-assess-score');
27
29
  // the score. `recordSecurity` / `recordDocs` are injectable so tests need no npm
28
30
  // and no model. Never throws for a failed measurement — that is recorded as the signal's outcome — and resolves
29
31
  // { ok: false, code } only when the version does not exist.
30
- async function assessVersion(versionId, { db, recordSecurity, recordDocs } = {}) {
32
+ async function assessVersion(versionId, { db, recordSecurity, recordDocs, dispatchTests } = {}) {
31
33
  const pool = db || require('../../src/bongos/pool').pool;
32
34
  const { rows: [ver] } = await pool.query(
33
- 'SELECT id, module_key, version FROM store_module_versions WHERE id = $1', [versionId]);
35
+ 'SELECT id, module_key, version, artifact_path FROM store_module_versions WHERE id = $1', [versionId]);
34
36
  if (!ver) return { ok: false, code: 'version_not_found', message: `no store_module_versions row ${versionId}` };
35
37
 
38
+ // When the separate test environment is configured, send the run there first
39
+ // (module-test-runner.js; task 1004460): it records a pending row carrying the
40
+ // run's nonce, so the plain pending below then has nothing to add. Unconfigured
41
+ // (or a failed dispatch), nothing is sent and Tests stays plain pending.
42
+ await (dispatchTests || require('./module-test-runner').maybeDispatch)(ver, { db: pool });
43
+
36
44
  // Only a version with no Tests result yet: a re-assess must never hide a real
37
45
  // score behind a newer "pending".
38
46
  await pool.query(