@bongos/core 1.19.662 → 1.19.664
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 +29 -19
- package/docs/module-api-changelog.md +14 -0
- package/modules/lifecycle/db-claims.js +52 -25
- package/modules/lifecycle/db-rank-authz.js +48 -0
- package/modules/provisioning/credential-preflight.js +170 -0
- package/modules/provisioning/routes/provisioning.js +16 -11
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +15 -1
- package/tests/government_claim_eligibility.mjs +132 -10
- package/tests/government_ownership_scope.mjs +18 -8
- package/tests/government_protected_surfaces.mjs +82 -4
- package/tests/module_api.mjs +1 -0
- package/tests/provisioning_credential_preflight.mjs +153 -0
- package/tests/provisioning_onboard_mode.mjs +13 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.664",
|
|
6
|
+
"core_contract": "1.19.664",
|
|
7
|
+
"source_commit": "22eade9fabde0d50dd8dc441b6ad82659c3925af",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-11T03:34:10.888Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 473,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2115,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2612,
|
|
20
|
+
"tree_sha256": "84b0905b27f747599562fc3a649df36296c71ea7f08e863fb7f2d886bc788a6f",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2787,7 +2787,7 @@
|
|
|
2787
2787
|
{
|
|
2788
2788
|
"path": "docs/module-api-changelog.md",
|
|
2789
2789
|
"mode": "0000644",
|
|
2790
|
-
"sha256": "
|
|
2790
|
+
"sha256": "5e2db4fa846a72ed355864eb5df81abb6c1dc2833c239d1d6200d5b23eeb67fa"
|
|
2791
2791
|
},
|
|
2792
2792
|
{
|
|
2793
2793
|
"path": "docs/modules-contract.md",
|
|
@@ -5647,7 +5647,7 @@
|
|
|
5647
5647
|
{
|
|
5648
5648
|
"path": "modules/lifecycle/db-claims.js",
|
|
5649
5649
|
"mode": "0000644",
|
|
5650
|
-
"sha256": "
|
|
5650
|
+
"sha256": "1b0d394ee1788decd9ea713a4379ef5322eda28a2ac11ad7adaf68298e6f4075"
|
|
5651
5651
|
},
|
|
5652
5652
|
{
|
|
5653
5653
|
"path": "modules/lifecycle/db-deps-criteria.js",
|
|
@@ -5672,7 +5672,7 @@
|
|
|
5672
5672
|
{
|
|
5673
5673
|
"path": "modules/lifecycle/db-rank-authz.js",
|
|
5674
5674
|
"mode": "0000644",
|
|
5675
|
-
"sha256": "
|
|
5675
|
+
"sha256": "fb8180f875828664fcd5cb17aa39f68c70b6cea3c52d03fdbd0b3e835f8b1e64"
|
|
5676
5676
|
},
|
|
5677
5677
|
{
|
|
5678
5678
|
"path": "modules/lifecycle/db-shared.js",
|
|
@@ -6374,6 +6374,11 @@
|
|
|
6374
6374
|
"mode": "0000644",
|
|
6375
6375
|
"sha256": "dae214682f6115b3a4e11ac3bcd120460b07c2a517155ba0b7ec690f1db9f3ea"
|
|
6376
6376
|
},
|
|
6377
|
+
{
|
|
6378
|
+
"path": "modules/provisioning/credential-preflight.js",
|
|
6379
|
+
"mode": "0000644",
|
|
6380
|
+
"sha256": "83e174333fbe6a2e9d11b46bfd5755fbd6314b1ccd09d8b8cefb01973a69fc37"
|
|
6381
|
+
},
|
|
6377
6382
|
{
|
|
6378
6383
|
"path": "modules/provisioning/migrations/provisioning_001_tables.sql",
|
|
6379
6384
|
"mode": "0000644",
|
|
@@ -6492,7 +6497,7 @@
|
|
|
6492
6497
|
{
|
|
6493
6498
|
"path": "modules/provisioning/routes/provisioning.js",
|
|
6494
6499
|
"mode": "0000644",
|
|
6495
|
-
"sha256": "
|
|
6500
|
+
"sha256": "14fda38bbfd01404c16e48c626247cbfacd956889e25aab6c65cf1b746b5e3e7"
|
|
6496
6501
|
},
|
|
6497
6502
|
{
|
|
6498
6503
|
"path": "modules/provisioning/starter-bundles.js",
|
|
@@ -7752,12 +7757,12 @@
|
|
|
7752
7757
|
{
|
|
7753
7758
|
"path": "package-lock.json",
|
|
7754
7759
|
"mode": "0000644",
|
|
7755
|
-
"sha256": "
|
|
7760
|
+
"sha256": "9f5ffaf4b6733ee29b44799f6911ccec28806c1c89a191d015e14dfa8777723a"
|
|
7756
7761
|
},
|
|
7757
7762
|
{
|
|
7758
7763
|
"path": "package.json",
|
|
7759
7764
|
"mode": "0000644",
|
|
7760
|
-
"sha256": "
|
|
7765
|
+
"sha256": "51248222358e2c56acfb51ce7fab9cf83ea1750edb7aa51e2998fdfa29cae0d0"
|
|
7761
7766
|
},
|
|
7762
7767
|
{
|
|
7763
7768
|
"path": "public-docs/index.html",
|
|
@@ -9512,7 +9517,7 @@
|
|
|
9512
9517
|
{
|
|
9513
9518
|
"path": "src/module-api.js",
|
|
9514
9519
|
"mode": "0000644",
|
|
9515
|
-
"sha256": "
|
|
9520
|
+
"sha256": "b81fd10bc6c7db853fee7809a05275836f743a32b167778d83e9b5f40eec8fc2"
|
|
9516
9521
|
},
|
|
9517
9522
|
{
|
|
9518
9523
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10857,7 +10862,7 @@
|
|
|
10857
10862
|
{
|
|
10858
10863
|
"path": "tests/government_claim_eligibility.mjs",
|
|
10859
10864
|
"mode": "0000644",
|
|
10860
|
-
"sha256": "
|
|
10865
|
+
"sha256": "05a7c8f8d001bd0d2950619fb46f242d358ecae1a8901a10fbb9ecc7857500e7"
|
|
10861
10866
|
},
|
|
10862
10867
|
{
|
|
10863
10868
|
"path": "tests/government_config.mjs",
|
|
@@ -10902,7 +10907,7 @@
|
|
|
10902
10907
|
{
|
|
10903
10908
|
"path": "tests/government_ownership_scope.mjs",
|
|
10904
10909
|
"mode": "0000644",
|
|
10905
|
-
"sha256": "
|
|
10910
|
+
"sha256": "76b81db7769290d5ef5c26ece86ce0610c5aef6c8984ffdd54a6340db66db3bc"
|
|
10906
10911
|
},
|
|
10907
10912
|
{
|
|
10908
10913
|
"path": "tests/government_page_gates.mjs",
|
|
@@ -10922,7 +10927,7 @@
|
|
|
10922
10927
|
{
|
|
10923
10928
|
"path": "tests/government_protected_surfaces.mjs",
|
|
10924
10929
|
"mode": "0000644",
|
|
10925
|
-
"sha256": "
|
|
10930
|
+
"sha256": "3517e05c1264fbdbeadc533884942926689952c8995548863ca1cfbfc365428e"
|
|
10926
10931
|
},
|
|
10927
10932
|
{
|
|
10928
10933
|
"path": "tests/government_rank_sync.mjs",
|
|
@@ -11632,7 +11637,7 @@
|
|
|
11632
11637
|
{
|
|
11633
11638
|
"path": "tests/module_api.mjs",
|
|
11634
11639
|
"mode": "0000644",
|
|
11635
|
-
"sha256": "
|
|
11640
|
+
"sha256": "4055467ff3bb8f5bcb79a87db12a649d90545986a3f8217a743934a6a50e8b5f"
|
|
11636
11641
|
},
|
|
11637
11642
|
{
|
|
11638
11643
|
"path": "tests/module_api_lazy.mjs",
|
|
@@ -12044,6 +12049,11 @@
|
|
|
12044
12049
|
"mode": "0000644",
|
|
12045
12050
|
"sha256": "74d53ae7f3c93883bdc6db5187c904cc6c641267233d70bfef1a25d5b2a4ca05"
|
|
12046
12051
|
},
|
|
12052
|
+
{
|
|
12053
|
+
"path": "tests/provisioning_credential_preflight.mjs",
|
|
12054
|
+
"mode": "0000644",
|
|
12055
|
+
"sha256": "851e89c4b8d14030fde9ff23f710ce335498c1a215eb7d52d6a6582d4dd0a5c7"
|
|
12056
|
+
},
|
|
12047
12057
|
{
|
|
12048
12058
|
"path": "tests/provisioning_default_domain.mjs",
|
|
12049
12059
|
"mode": "0000644",
|
|
@@ -12067,7 +12077,7 @@
|
|
|
12067
12077
|
{
|
|
12068
12078
|
"path": "tests/provisioning_onboard_mode.mjs",
|
|
12069
12079
|
"mode": "0000644",
|
|
12070
|
-
"sha256": "
|
|
12080
|
+
"sha256": "3a76bb14f9dc12ce4fff703b14a9802b1a612e726c77cfe219505743643bca9f"
|
|
12071
12081
|
},
|
|
12072
12082
|
{
|
|
12073
12083
|
"path": "tests/provisioning_paid_shape_gate.mjs",
|
|
@@ -1773,5 +1773,19 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1773
1773
|
landed since 1.19.660 with no explicit bump. run 34552525938. (task 1002620)
|
|
1774
1774
|
1.19.662 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1775
1775
|
landed since 1.19.661 with no explicit bump. run 34553965204. (task 1002620)
|
|
1776
|
+
1.19.663 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1777
|
+
landed since 1.19.662 with no explicit bump. run 34557840364. (task 1002620)
|
|
1778
|
+
(next carrier) — grantedScopeCoversRepos re-exported (task 1002909, goal 1000106).
|
|
1779
|
+
The provisioning module's create-route credential preflight must tell "that
|
|
1780
|
+
repo does not exist" apart from "it is private and your pre-ADR-0155 token
|
|
1781
|
+
cannot see it" — GitHub answers 404 to both — so it needs the canonical scope
|
|
1782
|
+
read. Shared rather than re-implemented because `public_repo` CONTAINS the
|
|
1783
|
+
substring "repo", so a naive match accepts exactly the stale token it guards
|
|
1784
|
+
against. auth-config.js is KERNEL (ADR 0091 §1), so this stays inside the
|
|
1785
|
+
kernel-imports-only rule; db.getGithubToken and github-repos are DOMAIN and
|
|
1786
|
+
deliberately did NOT come through the doorway — the module reaches those via
|
|
1787
|
+
the already-exposed `pool` + `secretBox` and its own one-field repo client.
|
|
1788
|
+
1.19.664 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1789
|
+
landed since 1.19.663 with no explicit bump. run 34558906002. (task 1002620)
|
|
1776
1790
|
---------------------------------------------------------------------------
|
|
1777
1791
|
```
|
|
@@ -15,10 +15,15 @@ const { pool, withTx } = api;
|
|
|
15
15
|
const log = api.logger('lifecycle');
|
|
16
16
|
const seams = api;
|
|
17
17
|
const { touchesOverlap } = api.pathMatch;
|
|
18
|
-
|
|
18
|
+
// CLAIM_ANY / CLAIM_NEWCOMER name the two atoms in the refusal message. The
|
|
19
|
+
// decision itself moved to db-rank-authz.claimGate (task 1003196), so this file
|
|
20
|
+
// no longer calls claimEligibilityAllows / rankAllowsTask / xenosClaimAllowed
|
|
21
|
+
// directly — composing them at three separate call sites is what made the gate
|
|
22
|
+
// un-testable except by counting.
|
|
23
|
+
const { CLAIM_ANY, CLAIM_NEWCOMER } = require('./claim-eligibility.js');
|
|
19
24
|
const { goalMembershipFailures } = require('./claim-feed.js');
|
|
20
25
|
const { unsatisfiedDeps } = require('./db-deps-criteria.js');
|
|
21
|
-
const { claimPermissionsFor, disambiguateWorktreeName, foldWorktreeName
|
|
26
|
+
const { claimGate, claimPermissionsFor, disambiguateWorktreeName, foldWorktreeName } = require('./db-rank-authz.js');
|
|
22
27
|
const { sealSessionLog } = require('./db-shared.js');
|
|
23
28
|
const { needsRebaseTasksForBuilder, shouldRefuseClaimResolve } = require('./db-ship.js');
|
|
24
29
|
const { REWARD_SUGGESTION_CAP, rewardGateAssignment } = require('./db-tasks.js');
|
|
@@ -142,8 +147,18 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
142
147
|
// is kept as an alias on the error object for callers on the pre-rename
|
|
143
148
|
// release. BV1.R106 (ADR 0151): same rule, now decided on the PERMISSIONS
|
|
144
149
|
// task.claim.any / task.claim.newcomer — see ./claim-eligibility.js.
|
|
150
|
+
// task 1003196: the queue+floor composition is decided ONCE, in
|
|
151
|
+
// db-rank-authz.claimGate, so a test can drive the real decision instead of
|
|
152
|
+
// counting `if`s in this file. The refusal SHAPES below are unchanged — that
|
|
153
|
+
// is why claimGate returns which gates refused rather than an error object.
|
|
145
154
|
const newcomerFriendly = task.newcomer_friendly;
|
|
146
|
-
|
|
155
|
+
const claimRefusals = claimGate({
|
|
156
|
+
builderRank,
|
|
157
|
+
newcomerFriendly,
|
|
158
|
+
requiresRank: task.requires_rank,
|
|
159
|
+
heldPermissions,
|
|
160
|
+
});
|
|
161
|
+
if (claimRefusals.includes('queue')) {
|
|
147
162
|
// task 1002613 (map §4.4): the bare code reported a PERMISSION decision as
|
|
148
163
|
// a rank one — no axis, no remedy. Copy INSUFFICIENT_RANK's shape below:
|
|
149
164
|
// name both sides of the wall and the way forward.
|
|
@@ -168,7 +183,7 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
168
183
|
// blocks Xenos+Metic. The DB CHECK on tasks.requires_rank enforces the
|
|
169
184
|
// enum, so we trust the value here. INSUFFICIENT_RANK carries the actual
|
|
170
185
|
// and required ranks so the caller can render a clean error.
|
|
171
|
-
if (
|
|
186
|
+
if (claimRefusals.includes('floor')) {
|
|
172
187
|
throw {
|
|
173
188
|
code: 'INSUFFICIENT_RANK',
|
|
174
189
|
required: task.requires_rank,
|
|
@@ -496,25 +511,32 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
|
|
|
496
511
|
// never checked here, while claimTask checks every rank), so removing it would
|
|
497
512
|
// change who can batch-claim. That pre-existing asymmetry is reported, not
|
|
498
513
|
// fixed, under this task — closing it is an authority decision of its own.
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
514
|
+
//
|
|
515
|
+
// V3.R82 (#301): the per-task requires_rank floor rides the same decision.
|
|
516
|
+
// Mirrors the single claimTask gate so a Metic can't slip through batch-claim
|
|
517
|
+
// to grab an Archon-only task. The xenos sandbox check handles the xenos case
|
|
518
|
+
// exhaustively for requires_rank='xenos' (the default); the floor gate refuses
|
|
519
|
+
// requires_rank='metic'/'archon' when the builder is below.
|
|
520
|
+
//
|
|
521
|
+
// task 1003196: both gates now come from db-rank-authz.claimGate. A row can
|
|
522
|
+
// still collect BOTH refusals, and the two passes below are deliberate — this
|
|
523
|
+
// path groups every queue refusal ahead of every floor refusal, and `failures`
|
|
524
|
+
// is returned to the caller in order, so folding them into one pass per row
|
|
525
|
+
// would reorder a response for no reason.
|
|
526
|
+
const refusalsFor = (t) => claimGate({
|
|
527
|
+
builderRank,
|
|
528
|
+
newcomerFriendly: t.newcomer_friendly,
|
|
529
|
+
requiresRank: t.requires_rank,
|
|
530
|
+
heldPermissions,
|
|
531
|
+
queueGateApplies: builderRank === 'xenos',
|
|
532
|
+
});
|
|
533
|
+
for (const t of taskRows) {
|
|
534
|
+
if (refusalsFor(t).includes('queue')) {
|
|
535
|
+
failures.push({ task_id: t.id, reason: 'rank_too_low_for_task', legacy_reason: 'XENOS_RESTRICTED' });
|
|
508
536
|
}
|
|
509
537
|
}
|
|
510
|
-
|
|
511
|
-
// V3.R82 (#301): the per-task requires_rank floor. Mirrors the single
|
|
512
|
-
// claimTask gate so a Metic can't slip through batch-claim to grab an
|
|
513
|
-
// Archon-only task. The xenos sandbox check above handles the xenos case
|
|
514
|
-
// exhaustively for requires_rank='xenos' (the default); this gate refuses
|
|
515
|
-
// requires_rank='metic'/'archon' when the builder is below.
|
|
516
538
|
for (const t of taskRows) {
|
|
517
|
-
if (
|
|
539
|
+
if (refusalsFor(t).includes('floor')) {
|
|
518
540
|
failures.push({
|
|
519
541
|
task_id: t.id,
|
|
520
542
|
reason: 'INSUFFICIENT_RANK',
|
|
@@ -855,14 +877,19 @@ async function validateClaimBatch({ taskIds, builderId }) {
|
|
|
855
877
|
const t = byId.get(id);
|
|
856
878
|
if (!t) { push(id, { reason: 'TASK_NOT_FOUND' }); continue; }
|
|
857
879
|
if (t.status !== 'ready') push(id, { reason: 'TASK_NOT_READY', currentStatus: t.status });
|
|
858
|
-
|
|
859
|
-
|
|
880
|
+
// task 1003196: same composed decision as claimTask and claimTasksBatch, and
|
|
881
|
+
// the same deliberate xenos-only queue short-circuit the batch path carries.
|
|
882
|
+
const refusals = claimGate({
|
|
883
|
+
builderRank,
|
|
860
884
|
newcomerFriendly: t.newcomer_friendly,
|
|
861
|
-
|
|
862
|
-
|
|
885
|
+
requiresRank: t.requires_rank,
|
|
886
|
+
heldPermissions,
|
|
887
|
+
queueGateApplies: builderRank === 'xenos',
|
|
888
|
+
});
|
|
889
|
+
if (refusals.includes('queue')) {
|
|
863
890
|
push(id, { reason: 'rank_too_low_for_task', legacy_reason: 'XENOS_RESTRICTED' });
|
|
864
891
|
}
|
|
865
|
-
if (
|
|
892
|
+
if (refusals.includes('floor')) {
|
|
866
893
|
push(id, { reason: 'INSUFFICIENT_RANK', required: t.requires_rank, actual: builderRank });
|
|
867
894
|
}
|
|
868
895
|
}
|
|
@@ -14,6 +14,7 @@ const api = require('../../src/module-api');
|
|
|
14
14
|
const { pool, LIVE_RANK_LADDER, RANK_TIER } = api;
|
|
15
15
|
const seams = api;
|
|
16
16
|
const { matchProtected } = api.permissionPathCheck;
|
|
17
|
+
const { claimEligibilityAllows } = require('./claim-eligibility.js');
|
|
17
18
|
|
|
18
19
|
// -------------------------------------------------------------------------
|
|
19
20
|
// Builders
|
|
@@ -166,6 +167,51 @@ function rankAllowsTask(builderRank, requiredRank) {
|
|
|
166
167
|
return have >= needed;
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
// ── the COMPOSED per-task claim decision (task 1003196) ──────────────────────
|
|
171
|
+
//
|
|
172
|
+
// Claim eligibility is TWO independent gates — the QUEUE axis (permission atoms,
|
|
173
|
+
// ./claim-eligibility.js) and the FLOOR axis (`requires_rank`, rankAllowsTask
|
|
174
|
+
// above) — and the rule that matters is that BOTH must pass and neither
|
|
175
|
+
// substitutes for the other. That composition used to exist only as a pair of
|
|
176
|
+
// consecutive `if`s repeated at each of the three claim paths, which meant the
|
|
177
|
+
// only thing a test could check was that the source still CONTAINED them:
|
|
178
|
+
// `tests/government_claim_eligibility.mjs` counted occurrences, and neutering
|
|
179
|
+
// claimTask's gate with `&& false` left the count at 3 and the suite green
|
|
180
|
+
// (finding 10 of the task-1002671 verification). Counting is not a behaviour
|
|
181
|
+
// oracle, so the decision lives here, once, where a test can actually drive it.
|
|
182
|
+
//
|
|
183
|
+
// RETURNS THE GATES THAT REFUSED, in ladder order — `[]` means allowed. It
|
|
184
|
+
// deliberately does NOT return a ready-made error: each call site shapes its own
|
|
185
|
+
// refusal and those shapes are API surface (claimTask THROWS
|
|
186
|
+
// `{code:'rank_too_low_for_task', …, message, hint}`, while the batch paths PUSH
|
|
187
|
+
// `{task_id, reason, legacy_reason}` and accumulate both refusals for one row).
|
|
188
|
+
// Returning a decision instead of a wire error is what lets all three delegate
|
|
189
|
+
// without any of them changing what a caller sees.
|
|
190
|
+
//
|
|
191
|
+
// `queueGateApplies` carries a PRE-EXISTING asymmetry rather than quietly fixing
|
|
192
|
+
// it: claimTask runs the queue gate for every rank, while claimTasksBatch and
|
|
193
|
+
// validateClaimBatch run it only when `builderRank === 'xenos'`. That outer
|
|
194
|
+
// short-circuit is semantic, not a fast path — for the SEEDED sets the two agree
|
|
195
|
+
// exactly (thetes+ all hold task.claim.any), but a custom rank granted neither
|
|
196
|
+
// claim atom would be refused by claimTask and admitted by the batch paths.
|
|
197
|
+
// Closing that is an authority decision of its own, already reported and
|
|
198
|
+
// deliberately deferred at the call site; this parameter keeps each path's
|
|
199
|
+
// current meaning exact while still routing it through one decision.
|
|
200
|
+
const CLAIM_GATES = Object.freeze(['queue', 'floor']);
|
|
201
|
+
|
|
202
|
+
function claimGate({ builderRank, newcomerFriendly, requiresRank, heldPermissions, queueGateApplies = true }) {
|
|
203
|
+
const refused = [];
|
|
204
|
+
if (queueGateApplies && !claimEligibilityAllows({
|
|
205
|
+
heldPermissions,
|
|
206
|
+
newcomerFriendly,
|
|
207
|
+
rankRuleAllows: xenosClaimAllowed(builderRank, newcomerFriendly),
|
|
208
|
+
})) {
|
|
209
|
+
refused.push('queue');
|
|
210
|
+
}
|
|
211
|
+
if (!rankAllowsTask(builderRank, requiresRank)) refused.push('floor');
|
|
212
|
+
return Object.freeze(refused);
|
|
213
|
+
}
|
|
214
|
+
|
|
169
215
|
// deriveRequiredRank — task 1498 / ADR 0084. The per-task requires_rank floor
|
|
170
216
|
// (V3.R82 #301) shipped INERT: no create/edit path ever set it, so every task
|
|
171
217
|
// sat at the 'xenos' default and the claim gate never bit. We now DERIVE a floor
|
|
@@ -477,6 +523,8 @@ module.exports = {
|
|
|
477
523
|
RANKS,
|
|
478
524
|
REQUIRES_RANK_TIER,
|
|
479
525
|
THETES_GRADUATION_THRESHOLD,
|
|
526
|
+
CLAIM_GATES,
|
|
527
|
+
claimGate,
|
|
480
528
|
claimPermissionsFor,
|
|
481
529
|
countShippedTasksForBuilder,
|
|
482
530
|
deriveRequiredRank,
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// modules/provisioning/credential-preflight.js — does the caller hold a credential
|
|
2
|
+
// that can actually stand up the repo they are adopting? (task 1002909, 1002898 N3)
|
|
3
|
+
//
|
|
4
|
+
// THE GAP THIS CLOSES. `POST /provisioning/instances` validated that an `adopt`
|
|
5
|
+
// carried a target_ref, but never that the owner could READ it — so an API caller
|
|
6
|
+
// could queue a standup that was doomed at request time and only learned about it
|
|
7
|
+
// ~2 minutes later, as raw git stderr on the runner (the task-1002697 acceptance
|
|
8
|
+
// walk did exactly that). The web wizard was only incidentally protected: it 403s
|
|
9
|
+
// earlier on its own repo picker. The API had nothing.
|
|
10
|
+
//
|
|
11
|
+
// WHY IT WAITED FOR ADR 0176 (task 1002735). A preflight is only as good as its
|
|
12
|
+
// definition of "usable credential", and 1002898 carved this out rather than guess
|
|
13
|
+
// at one: written against the wrong definition it would either refuse valid creates
|
|
14
|
+
// or keep admitting doomed ones. 1002735 settled it, so this file encodes THAT
|
|
15
|
+
// definition and nothing else — what `ensurePrivateRepoAccess` (scripts/gds/
|
|
16
|
+
// provision-repo.js) will demand at standup time, asked one round-trip earlier:
|
|
17
|
+
//
|
|
18
|
+
// public target_ref → any readable credential is enough. The recurring deploy
|
|
19
|
+
// pull rides the shared SSH alias, which reads public repos
|
|
20
|
+
// without being registered on them.
|
|
21
|
+
// private target_ref → the runner must register a READ-ONLY deploy key on the
|
|
22
|
+
// repo, and GitHub requires repository ADMIN for that. A
|
|
23
|
+
// credential that can merely read a private repo is NOT
|
|
24
|
+
// enough, and discovering that mid-standup is the loud
|
|
25
|
+
// version of this same refusal.
|
|
26
|
+
//
|
|
27
|
+
// GREENFIELD IS EXEMPT, and that exemption is load-bearing (the constraint carried
|
|
28
|
+
// over from 1002898): the platform CREATES that repo itself, so there is no
|
|
29
|
+
// pre-existing repo for a credential to cover and nothing here to check.
|
|
30
|
+
//
|
|
31
|
+
// A TRANSIENT GITHUB FAILURE ADMITS. This refuses only on POSITIVE evidence that a
|
|
32
|
+
// standup cannot succeed. GitHub being unreachable is not that evidence — and the
|
|
33
|
+
// runner takes the same posture (`skipped: 'github_unreachable'` continues rather
|
|
34
|
+
// than throwing), so a GitHub hiccup must not become an outage of project creation.
|
|
35
|
+
//
|
|
36
|
+
// WHY IT READS THE TOKEN ITSELF rather than calling core's db.getGithubToken /
|
|
37
|
+
// github-repos.getUserRepo. `src/module-api.js` is a KERNEL file (ADR 0091 §1) and
|
|
38
|
+
// may depend only on kernel files; `src/bongos/db.js` and `src/bongos/github-repos.js`
|
|
39
|
+
// are domain, so routing either through the doorway fails the boundary check in
|
|
40
|
+
// scripts/gds/fitness.js. Modules own their data access through the exposed `pool`
|
|
41
|
+
// (the module contract's own rule), so the two small reads below are that ownership,
|
|
42
|
+
// not a shortcut around it. Both are DELIBERATE TWINS of core's versions and say so;
|
|
43
|
+
// `grantedScopeCoversRepos` is reached through the doorway instead of re-implemented
|
|
44
|
+
// because it is kernel (src/bongos/auth-config.js) AND because its subtlety —
|
|
45
|
+
// `public_repo` CONTAINS "repo", so a substring match accepts exactly the stale
|
|
46
|
+
// pre-ADR-0155 token this guards against — is the kind that must never be re-typed.
|
|
47
|
+
//
|
|
48
|
+
// Its own file, like ../paid-shape-gate.js, for the same two mechanical reasons:
|
|
49
|
+
// `routes/provisioning.js` sits at its 1500-line fitness ratchet, and every
|
|
50
|
+
// dependency here is INJECTED so the check is exercisable without standing up the
|
|
51
|
+
// auth stack or touching the network.
|
|
52
|
+
'use strict';
|
|
53
|
+
|
|
54
|
+
// Mirrors scripts/gds/provision-repo.js parseTargetRef EXACTLY. The preflight and
|
|
55
|
+
// the runner must agree on what a target_ref means, or this admits refs the runner
|
|
56
|
+
// cannot parse (and refuses ones it can) — a divergence that would be silent.
|
|
57
|
+
function parseTargetRef(ref) {
|
|
58
|
+
const m = String(ref || '').trim()
|
|
59
|
+
.replace(/^git@[^:]+:/, '')
|
|
60
|
+
.replace(/^https?:\/\/[^/]+\//, '')
|
|
61
|
+
.match(/^([A-Za-z0-9][A-Za-z0-9_.-]*)\/([A-Za-z0-9][A-Za-z0-9_.-]*?)(?:\.git)?$/);
|
|
62
|
+
return m ? { owner: m[1], name: m[2] } : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Twin of src/bongos/db.js getGithubToken, kept in step with it deliberately (see
|
|
66
|
+
// the header). Returns { token, scope } or null when there is none, it expired, or
|
|
67
|
+
// the blob will not decrypt under the current master key. Unlike core's version it
|
|
68
|
+
// does NOT lazily delete an expired row — a read-only preflight must not mutate, and
|
|
69
|
+
// core's own reaper still runs on the endpoints that own that table.
|
|
70
|
+
async function readOwnerRepoToken({ builderId }, { pool, secretBox }) {
|
|
71
|
+
if (!secretBox.isConfigured()) return null;
|
|
72
|
+
const { rows } = await pool.query(
|
|
73
|
+
`SELECT token_enc, scope, expires_at FROM github_oauth_tokens WHERE builder_id = $1`,
|
|
74
|
+
[builderId],
|
|
75
|
+
);
|
|
76
|
+
const row = rows[0];
|
|
77
|
+
if (!row) return null;
|
|
78
|
+
if (new Date(row.expires_at).getTime() <= Date.now()) return null;
|
|
79
|
+
try { return { token: secretBox.decrypt(row.token_enc), scope: row.scope }; }
|
|
80
|
+
catch { return null; } // a blob that won't decrypt reads as "none", as in core
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Twin of src/bongos/github-repos.js getUserRepo, narrowed to the two fields this
|
|
84
|
+
// decision needs. Throws { code } on the cases the caller branches on; every other
|
|
85
|
+
// status is a transient the caller admits on.
|
|
86
|
+
async function fetchRepoVisibility(token, { owner, name, fetchImpl, userAgent }) {
|
|
87
|
+
const res = await (fetchImpl || fetch)(
|
|
88
|
+
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`,
|
|
89
|
+
{ headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent, Accept: 'application/vnd.github+json' } },
|
|
90
|
+
);
|
|
91
|
+
if (res.status === 401) throw Object.assign(new Error('github rejected the token'), { code: 'github_unauthorized' });
|
|
92
|
+
if (res.status === 404) throw Object.assign(new Error('repo not found'), { code: 'repo_not_found' });
|
|
93
|
+
if (!res.ok) throw Object.assign(new Error(`github ${res.status}`), { code: 'github_error' });
|
|
94
|
+
const r = await res.json();
|
|
95
|
+
return { private: !!r.private, admin: !!(r.permissions && r.permissions.admin) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// adoptCredentialFault — null to ADMIT the create, or the refusal the route should
|
|
99
|
+
// send: { code, status, message }. Returning a value rather than writing to `res`
|
|
100
|
+
// keeps this directly testable and leaves the HTTP shape with the route that owns it.
|
|
101
|
+
//
|
|
102
|
+
// deps: { pool, secretBox, grantedScopeCoversRepos, userAgent, fetchImpl?,
|
|
103
|
+
// readToken?, fetchRepo? } — the last two are test seams.
|
|
104
|
+
async function adoptCredentialFault({ builderId, onboardMode, targetRef }, deps) {
|
|
105
|
+
if (onboardMode !== 'adopt') return null; // greenfield: nothing to check
|
|
106
|
+
const { grantedScopeCoversRepos, secretBox, fetchImpl, userAgent } = deps;
|
|
107
|
+
const readToken = deps.readToken || readOwnerRepoToken;
|
|
108
|
+
const fetchRepo = deps.fetchRepo || fetchRepoVisibility;
|
|
109
|
+
|
|
110
|
+
// An unparseable ref cannot be checked, and the runner's identical parse would
|
|
111
|
+
// fail on it too — so this is a doomed standup, refused for the reason it is
|
|
112
|
+
// doomed rather than admitted because the credential question never got asked.
|
|
113
|
+
const repo = parseTargetRef(targetRef);
|
|
114
|
+
if (!repo) {
|
|
115
|
+
return { code: 'bad_target_ref', status: 400,
|
|
116
|
+
message: 'Name the repository to adopt as owner/repo.' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// No secret-box means no token can be STORED at all, so every adopt would fail at
|
|
120
|
+
// the push. 503, not 403: the fault is the server's configuration, not the caller's.
|
|
121
|
+
if (!secretBox.isConfigured()) {
|
|
122
|
+
return { code: 'github_credential_unavailable', status: 503,
|
|
123
|
+
message: 'Repo access is not configured on this server, so an existing repository cannot be adopted yet.' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const row = await readToken({ builderId }, deps);
|
|
127
|
+
if (!row || !row.token) {
|
|
128
|
+
return { code: 'repo_scope_required', status: 403,
|
|
129
|
+
message: 'Connect your GitHub account before adopting an existing repository.' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let info;
|
|
133
|
+
try {
|
|
134
|
+
info = await fetchRepo(row.token, { owner: repo.owner, name: repo.name, fetchImpl, userAgent });
|
|
135
|
+
} catch (e) {
|
|
136
|
+
const code = e && e.code;
|
|
137
|
+
if (code === 'github_unauthorized') {
|
|
138
|
+
return { code: 'repo_scope_required', status: 403,
|
|
139
|
+
message: 'GitHub rejected your stored credential — reconnect your GitHub account and try again.' };
|
|
140
|
+
}
|
|
141
|
+
if (code === 'repo_not_found') {
|
|
142
|
+
// A `public_repo`-scoped token cannot SEE a private repo: GitHub answers 404
|
|
143
|
+
// for "absent" and for "private and invisible to you" identically. So the
|
|
144
|
+
// message covers both honestly rather than asserting the repo does not exist —
|
|
145
|
+
// a pre-ADR-0155 token reaching its own private repo lands exactly here, which
|
|
146
|
+
// is the hermeslines-marketing failure told at request time instead of at the pull.
|
|
147
|
+
if (!grantedScopeCoversRepos(row.scope)) {
|
|
148
|
+
return { code: 'repo_scope_required', status: 403,
|
|
149
|
+
message: `We cannot see ${repo.owner}/${repo.name}. If it is private, reconnect GitHub to include your private repositories; if it is public, check the name.` };
|
|
150
|
+
}
|
|
151
|
+
return { code: 'target_repo_not_found', status: 404,
|
|
152
|
+
message: `GitHub has no repository ${repo.owner}/${repo.name} that your account can reach — check the name, or that you still have access.` };
|
|
153
|
+
}
|
|
154
|
+
return null; // unreachable / 5xx: no positive evidence of doom, so admit
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Private repos need repository ADMIN, because the runner registers a read-only
|
|
158
|
+
// deploy key to keep the deploy pull working after the one-shot OAuth token dies
|
|
159
|
+
// (ADR 0176). Read-only collaborator access passes the read above and then fails at
|
|
160
|
+
// key registration — the exact "looked like it worked" shape this exists to end.
|
|
161
|
+
// The sentence matches the runner's own so the two can never disagree.
|
|
162
|
+
if (info.private && !info.admin) {
|
|
163
|
+
return { code: 'repo_admin_required', status: 403,
|
|
164
|
+
message: `Your GitHub account can read ${repo.owner}/${repo.name}, but adopting a private repository needs repository ADMIN so its deploy key can be registered. Ask a repository admin to raise your access, or make the repository public.` };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = { adoptCredentialFault, parseTargetRef, readOwnerRepoToken, fetchRepoVisibility };
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
// POST /provisioning/instances — request a new instance
|
|
15
15
|
// (a `dedicated` shape ALSO needs
|
|
16
16
|
// provisioning.fleet.manage — it is the
|
|
17
|
-
// only shape that bills; ../paid-shape-gate.js
|
|
17
|
+
// only shape that bills; ../paid-shape-gate.js.
|
|
18
|
+
// An `adopt` ALSO passes the credential
|
|
19
|
+
// preflight — ../credential-preflight.js,
|
|
20
|
+
// task 1002909; greenfield is exempt)
|
|
18
21
|
// GET /provisioning/instances — list the caller's instances
|
|
19
22
|
// GET /provisioning/instances/:id — read one of the caller's instances
|
|
20
23
|
// (also SELF-HEALS its catalog projection,
|
|
@@ -49,6 +52,7 @@ const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModu
|
|
|
49
52
|
const { recommendationsForDetails } = require('../recommendations');
|
|
50
53
|
const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
51
54
|
const { requireAuthorityForMeteredShape } = require('../paid-shape-gate');
|
|
55
|
+
const { adoptCredentialFault } = require('../credential-preflight');
|
|
52
56
|
const catalogBridge = require('../catalog-bridge');
|
|
53
57
|
const { callbackPage } = require('./callback-page');
|
|
54
58
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
@@ -409,17 +413,18 @@ module.exports = function provisioningRoutes() {
|
|
|
409
413
|
reason: 'adopt layers onto an existing repo, so target_ref (owner/repo) is required',
|
|
410
414
|
});
|
|
411
415
|
}
|
|
412
|
-
// KNOWN GAP — the credential preflight belongs HERE, and is deliberately absent.
|
|
413
|
-
// This route validates that target_ref is PRESENT but never that the owner holds a
|
|
414
|
-
// usable repo credential for it, so an API caller can queue a standup that dies
|
|
415
|
-
// ~2 minutes later in raw git stderr (the task-1002697 walk did exactly that; the
|
|
416
|
-
// web wizard is only incidentally protected because it 403s earlier). Carved out of
|
|
417
|
-
// task 1002898 as N3 and tracked by task 1002909, which is BLOCKED on task 1002735
|
|
418
|
-
// deciding what credential a private-repo standup actually needs: written against
|
|
419
|
-
// the wrong definition, a preflight would refuse valid creates or keep admitting
|
|
420
|
-
// doomed ones. Greenfield must stay exempt when it lands — the platform creates
|
|
421
|
-
// that repo itself, so there is no pre-existing repo to hold a credential for.
|
|
422
416
|
try {
|
|
417
|
+
// The credential preflight (task 1002909, 1002898 N3). An `adopt` names a repo
|
|
418
|
+
// the platform must read forever, so ask ONE round-trip early whether this
|
|
419
|
+
// caller's credential can actually carry that standup — rather than queueing a
|
|
420
|
+
// doomed intent the runner discovers two minutes later as raw git stderr (the
|
|
421
|
+
// task-1002697 walk). Greenfield is exempt by construction. Encodes ADR 0176's
|
|
422
|
+
// definition of a usable credential; ../credential-preflight.js says why.
|
|
423
|
+
const credFault = await adoptCredentialFault(
|
|
424
|
+
{ builderId: req.builder.id, onboardMode, targetRef: body.target_ref },
|
|
425
|
+
{ pool, secretBox: api.secretBox, grantedScopeCoversRepos: api.grantedScopeCoversRepos, userAgent: api.userAgent() },
|
|
426
|
+
);
|
|
427
|
+
if (credFault) return res.fail(credFault.code, { status: credFault.status, message: credFault.message });
|
|
423
428
|
// A re-request must not re-litigate the address: createInstance never updates
|
|
424
429
|
// an existing row's domain, so for one the guards below could only misfire —
|
|
425
430
|
// a "Try again" on a pre-existing reserved-label row, or domain_taken fired
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.664",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.664",
|
|
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.
|
|
3
|
+
"version": "1.19.664",
|
|
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",
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.664'; // 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');
|
|
@@ -197,6 +197,20 @@ module.exports = {
|
|
|
197
197
|
// isConfigured/encrypt/decrypt/provisionMasterKey without reaching core.
|
|
198
198
|
get secretBox() { return require('./bongos/secret-box'); },
|
|
199
199
|
|
|
200
|
+
// --- grantedScopeCoversRepos(scope) — does a scope string GitHub ACTUALLY
|
|
201
|
+
// GRANTED still reach private repos? Exposed for the provisioning module's
|
|
202
|
+
// create-route credential preflight (task 1002909), which must tell "that
|
|
203
|
+
// repo does not exist" apart from "it is private and your pre-ADR-0155 token
|
|
204
|
+
// cannot see it" — GitHub answers 404 to both.
|
|
205
|
+
//
|
|
206
|
+
// Shared rather than re-implemented because the subtlety is a trap:
|
|
207
|
+
// `public_repo` CONTAINS the substring "repo", so a naive match accepts
|
|
208
|
+
// exactly the stale token this guards against. auth-config.js is KERNEL
|
|
209
|
+
// (ADR 0091 §1), so this stays inside the kernel-imports-only rule — unlike
|
|
210
|
+
// db.getGithubToken / github-repos, which are domain and which a module
|
|
211
|
+
// therefore reaches via the exposed `pool` and its own client instead.
|
|
212
|
+
get grantedScopeCoversRepos() { return require('./bongos/auth-config').grantedScopeCoversRepos; },
|
|
213
|
+
|
|
200
214
|
// --- KERNEL trust classifiers (ADR 0091 §1, KERNEL_FILES) — the deterministic
|
|
201
215
|
// route→rank + protected-path matchers. They are KERNEL (the trust
|
|
202
216
|
// boundary, never "called" by a domain), exposed so a module that runs the
|
|
@@ -154,16 +154,138 @@ test('the permission predicate cannot see requires_rank at all (structural)', ()
|
|
|
154
154
|
}
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
157
|
+
// ── the COMPOSED gate, driven behaviourally (task 1003196, finding 10) ───────
|
|
158
|
+
//
|
|
159
|
+
// WHAT THIS REPLACED, AND WHY. Until task 1003196 this section grepped the
|
|
160
|
+
// lifecycle source for three occurrences of `rankAllowsTask(builderRank,
|
|
161
|
+
// t.requires_rank)` and three of `claimEligibilityAllows({`. That verifies
|
|
162
|
+
// PRESENCE, not EFFECT: neutering claimTask's gate with `&& false` left both
|
|
163
|
+
// counts at 3 and the suite green. The composition now lives in one pure export,
|
|
164
|
+
// db-rank-authz.claimGate, so these tests drive the real decision instead.
|
|
165
|
+
//
|
|
166
|
+
// claimGate returns the gates that REFUSED, in ladder order — `[]` means allowed.
|
|
167
|
+
|
|
168
|
+
const { claimGate, CLAIM_GATES } = rankAuthz;
|
|
169
|
+
const gatesFor = (o) => [...claimGate(o)];
|
|
170
|
+
|
|
171
|
+
test('claimGate: BOTH axes must pass, and neither substitutes for the other', () => {
|
|
172
|
+
// An Archon holding everything is still stopped by nothing at requires_rank xenos...
|
|
173
|
+
assert.deepEqual(gatesFor({
|
|
174
|
+
builderRank: 'archon', newcomerFriendly: false, requiresRank: 'xenos', heldPermissions: seedSet('archon'),
|
|
175
|
+
}), []);
|
|
176
|
+
|
|
177
|
+
// ...and a builder who clears the QUEUE axis is still refused by the FLOOR axis.
|
|
178
|
+
// This is the collapse the two-gate model exists to prevent: holding
|
|
179
|
+
// task.claim.any must not buy you an archon-floored task.
|
|
180
|
+
assert.deepEqual(gatesFor({
|
|
181
|
+
builderRank: 'metic', newcomerFriendly: false, requiresRank: 'archon', heldPermissions: seedSet('metic'),
|
|
182
|
+
}), ['floor']);
|
|
183
|
+
|
|
184
|
+
// The converse: clearing the FLOOR axis does not buy you the queue. A xenos on a
|
|
185
|
+
// default-floor task that is NOT newcomer_friendly holds only task.claim.newcomer.
|
|
186
|
+
assert.deepEqual(gatesFor({
|
|
187
|
+
builderRank: 'xenos', newcomerFriendly: false, requiresRank: 'xenos', heldPermissions: seedSet('xenos'),
|
|
188
|
+
}), ['queue']);
|
|
189
|
+
|
|
190
|
+
// Both can refuse the same row, and the order is the ladder order.
|
|
191
|
+
assert.deepEqual(gatesFor({
|
|
192
|
+
builderRank: 'xenos', newcomerFriendly: false, requiresRank: 'archon', heldPermissions: seedSet('xenos'),
|
|
193
|
+
}), ['queue', 'floor']);
|
|
194
|
+
|
|
195
|
+
// ...and a newcomer_friendly flag opens the queue without lowering the floor.
|
|
196
|
+
assert.deepEqual(gatesFor({
|
|
197
|
+
builderRank: 'xenos', newcomerFriendly: true, requiresRank: 'metic', heldPermissions: seedSet('xenos'),
|
|
198
|
+
}), ['floor'], 'newcomer_friendly must never lower requires_rank');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('claimGate: the composed decision matches the two axes evaluated separately', () => {
|
|
202
|
+
// The independent oracle: each axis run through its OWN production predicate
|
|
203
|
+
// (db.rankAllowsTask and eligibility.claimEligibilityAllows), composed here in
|
|
204
|
+
// the test. A claimGate that silently dropped a conjunct disagrees immediately.
|
|
205
|
+
let refusalsSeen = 0;
|
|
206
|
+
for (const rank of LIVE_RANKS) {
|
|
207
|
+
for (const nf of NF_VALUES) {
|
|
208
|
+
for (const requiresRank of [...LIVE_RANKS, null]) {
|
|
209
|
+
for (const held of [seedSet(rank), new Set(), new Set([CLAIM_ANY]), new Set([CLAIM_NEWCOMER]), null]) {
|
|
210
|
+
const expected = [];
|
|
211
|
+
if (!claimEligibilityAllows({
|
|
212
|
+
heldPermissions: held, newcomerFriendly: nf, rankRuleAllows: db.xenosClaimAllowed(rank, nf),
|
|
213
|
+
})) expected.push('queue');
|
|
214
|
+
if (!db.rankAllowsTask(rank, requiresRank)) expected.push('floor');
|
|
215
|
+
refusalsSeen += expected.length;
|
|
216
|
+
assert.deepEqual(
|
|
217
|
+
gatesFor({ builderRank: rank, newcomerFriendly: nf, requiresRank, heldPermissions: held }),
|
|
218
|
+
expected,
|
|
219
|
+
`${rank}/nf=${String(nf)}/req=${String(requiresRank)}/held=${held ? [...held].length : 'null'}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
assert.ok(refusalsSeen > 0, 'a matrix that never refuses would pass vacuously');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('claimGate: fail-closed on ranks and floors it does not recognise', () => {
|
|
229
|
+
// An unknown rank clears nothing — the posture rankAllowsTask already takes.
|
|
230
|
+
assert.ok(gatesFor({
|
|
231
|
+
builderRank: 'bogus', newcomerFriendly: true, requiresRank: 'xenos', heldPermissions: new Set([CLAIM_ANY]),
|
|
232
|
+
}).includes('floor'), 'an unrecognised builder rank must not clear the floor');
|
|
233
|
+
// A requires_rank the DB CHECK should have blocked refuses rather than defaults.
|
|
234
|
+
assert.ok(gatesFor({
|
|
235
|
+
builderRank: 'archon', newcomerFriendly: true, requiresRank: 'nonsense', heldPermissions: seedSet('archon'),
|
|
236
|
+
}).includes('floor'), 'an unrecognised requires_rank must fail closed');
|
|
237
|
+
// A null requires_rank is the column default — no extra gate.
|
|
238
|
+
assert.deepEqual(gatesFor({
|
|
239
|
+
builderRank: 'xenos', newcomerFriendly: true, requiresRank: null, heldPermissions: seedSet('xenos'),
|
|
240
|
+
}), []);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('claimGate: queueGateApplies carries the batch paths\' pre-existing asymmetry', () => {
|
|
244
|
+
// claimTask runs the queue gate for EVERY rank; claimTasksBatch and
|
|
245
|
+
// validateClaimBatch run it only when builderRank === 'xenos'. For the SEEDED
|
|
246
|
+
// sets the two agree exactly, which is why the asymmetry has never bitten...
|
|
247
|
+
for (const rank of LIVE_RANKS) {
|
|
248
|
+
for (const nf of NF_VALUES) {
|
|
249
|
+
const held = seedSet(rank);
|
|
250
|
+
assert.deepEqual(
|
|
251
|
+
gatesFor({ builderRank: rank, newcomerFriendly: nf, requiresRank: 'xenos', heldPermissions: held }),
|
|
252
|
+
gatesFor({ builderRank: rank, newcomerFriendly: nf, requiresRank: 'xenos', heldPermissions: held, queueGateApplies: rank === 'xenos' }),
|
|
253
|
+
`${rank}/nf=${String(nf)}: seeded sets must agree across both call-site flavours`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// ...and here is the case where they DIVERGE, pinned so it stays a known,
|
|
258
|
+
// deliberate difference rather than a surprise: a CUSTOM rank granted neither
|
|
259
|
+
// claim atom is refused by claimTask and admitted by the batch paths. Closing
|
|
260
|
+
// this is an authority decision of its own (see the note at the call site).
|
|
261
|
+
const neither = new Set(['cost.log']);
|
|
262
|
+
assert.deepEqual(gatesFor({
|
|
263
|
+
builderRank: 'metic', newcomerFriendly: false, requiresRank: 'xenos', heldPermissions: neither,
|
|
264
|
+
}), ['queue'], 'claimTask refuses a rank holding no claim atom');
|
|
265
|
+
assert.deepEqual(gatesFor({
|
|
266
|
+
builderRank: 'metic', newcomerFriendly: false, requiresRank: 'xenos', heldPermissions: neither, queueGateApplies: false,
|
|
267
|
+
}), [], 'the batch paths admit it — the asymmetry, recorded');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('every claim path delegates to claimGate rather than re-composing the axes', () => {
|
|
271
|
+
// The cheap STRUCTURAL complement to the behavioural tests above. It can still
|
|
272
|
+
// only see presence, so it is no longer the only thing standing: what it adds is
|
|
273
|
+
// that a call site cannot quietly go back to composing the gates itself, which
|
|
274
|
+
// is what let the three copies drift apart in the first place.
|
|
275
|
+
// Counted in db-claims.js alone: the whole-family source also carries the
|
|
276
|
+
// DEFINITION in db-rank-authz.js, which would make the count read 4 and quietly
|
|
277
|
+
// tolerate one call site dropping out.
|
|
278
|
+
const claims = fs.readFileSync(path.join(LIFECYCLE, 'db-claims.js'), 'utf8');
|
|
279
|
+
const delegations = claims.match(/claimGate\(\{/g) || [];
|
|
280
|
+
assert.equal(delegations.length, 3,
|
|
281
|
+
`claimTask, claimTasksBatch and validateClaimBatch must each call claimGate — found ${delegations.length}`);
|
|
282
|
+
// ...and the raw conjuncts are gone from the claim paths, so there is exactly
|
|
283
|
+
// one place where "both gates must pass" is written down.
|
|
284
|
+
for (const banned of ['rankAllowsTask(', 'claimEligibilityAllows(']) {
|
|
285
|
+
assert.ok(!claims.includes(banned),
|
|
286
|
+
`db-claims.js must reach the axes through claimGate, not call "${banned}" itself`);
|
|
287
|
+
}
|
|
288
|
+
assert.deepEqual([...CLAIM_GATES], ['queue', 'floor'], 'the gate names are ladder-ordered');
|
|
167
289
|
});
|
|
168
290
|
|
|
169
291
|
// ── the "authority could not be established" fallback ────────────────────────
|
|
@@ -551,15 +551,25 @@ test('F8: the verb field itself is partitioned with scope and checked against th
|
|
|
551
551
|
|
|
552
552
|
// ═══ G — the R94/R95/R98 invariants this slice must not have disturbed ════════
|
|
553
553
|
|
|
554
|
-
test('G1:
|
|
554
|
+
test('G1: RANK_SEED is still DERIVED from seedForRank, not hand-maintained', () => {
|
|
555
|
+
// task 1003196 (finding 8) trimmed this test, and it is worth saying exactly what
|
|
556
|
+
// it does and does not prove. It USED to re-spell seedForRank's formula
|
|
557
|
+
// (`floor !== null && RANK_ORDER.indexOf(p.floor) <= tier`) and assert it against
|
|
558
|
+
// RANK_SEED — but RANK_SEED is BUILT by seedForRank, so the two sides moved
|
|
559
|
+
// together: the copy could detect drift from the other copies of itself and never
|
|
560
|
+
// a wrong formula. That formula oracle is kept ONCE, in tests/government_catalog.mjs
|
|
561
|
+
// ("a role holds a permission iff its floor is a real rank at-or-below"), with the
|
|
562
|
+
// real independent oracles being government_parity.mjs Part A and the pinned
|
|
563
|
+
// concrete values beside it.
|
|
564
|
+
//
|
|
565
|
+
// What survives here is narrow and genuine: RANK_SEED must remain a DERIVATION.
|
|
566
|
+
// The catalog's "zero day-one change" guarantee (R103) is structural only while
|
|
567
|
+
// the seed is computed from floors — the day someone replaces RANK_SEED with a
|
|
568
|
+
// hand-written list, parity stops being a property of the code and becomes a
|
|
569
|
+
// promise someone has to keep by hand. That is what this catches.
|
|
555
570
|
for (const rank of RANK_ORDER) {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
for (const p of PERMISSIONS) {
|
|
559
|
-
assert.equal(held.has(p.key), p.floor !== null && RANK_ORDER.indexOf(p.floor) <= tier,
|
|
560
|
-
`${rank} vs ${p.key}: scope must not touch the floor derivation`);
|
|
561
|
-
}
|
|
562
|
-
assert.deepEqual([...seedForRank(rank)].sort(), [...RANK_SEED[rank]].sort());
|
|
571
|
+
assert.deepEqual([...seedForRank(rank)].sort(), [...RANK_SEED[rank]].sort(),
|
|
572
|
+
`${rank}: RANK_SEED must be what seedForRank computes, never a maintained copy`);
|
|
563
573
|
}
|
|
564
574
|
});
|
|
565
575
|
|
|
@@ -214,11 +214,89 @@ test('every entry maps to a permission the catalog defines as system:true', () =
|
|
|
214
214
|
assert.deepEqual(registry.SYSTEM_PERMISSIONS, ['path.protected.modify']);
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
// Feed a BAD registry through the real loader in a cold subprocess and assert the
|
|
218
|
+
// module refuses to load. The pattern is the sibling probe below ("a missing
|
|
219
|
+
// registry fails CLOSED"): patch fs.readFileSync for the registry path only, then
|
|
220
|
+
// require the module under test and let its load-time throw be the result.
|
|
221
|
+
//
|
|
222
|
+
// WHY A SUBPROCESS AND NOT AN IN-PROCESS ASSERTION (task 1003196, finding 9). The
|
|
223
|
+
// version of this test before that task read the catalog and checked that a
|
|
224
|
+
// grantable key exists and an unknown key returns null — both true regardless of
|
|
225
|
+
// whether the guard exists. Demonstrated: deleting the whole validation loop from
|
|
226
|
+
// modules/government/protected-surfaces.js left the entire suite green. The guard
|
|
227
|
+
// is load-time, so only a fresh module load can exercise it, and the registry is
|
|
228
|
+
// read from disk, so only a patched read can make it bad.
|
|
229
|
+
//
|
|
230
|
+
// This matters because the sibling test above ("every entry maps to a permission
|
|
231
|
+
// the catalog defines as system:true") only checks the COMMITTED file. The
|
|
232
|
+
// load-time throw is what protects an out-of-band registry write — the Government
|
|
233
|
+
// tab editing an instance file at runtime — and nothing was watching it.
|
|
234
|
+
function loadWithRegistry(registryJson) {
|
|
235
|
+
execFileSync(process.execPath, ['-e',
|
|
236
|
+
"const fs=require('node:fs');const real=fs.readFileSync;" +
|
|
237
|
+
'const fake=' + JSON.stringify(JSON.stringify(registryJson)) + ';' +
|
|
238
|
+
"fs.readFileSync=(p,...r)=>(String(p).includes('protected-surfaces.json') ? fake : real(p,...r));" +
|
|
239
|
+
"require('./modules/government/protected-surfaces.js');",
|
|
240
|
+
], { cwd: ROOT, stdio: 'pipe' });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
test('a surface mapped to a GRANTABLE permission fails loud at load', () => {
|
|
244
|
+
// A grantable (system:false) key is the dangerous case ADR 0151 §4 names: a
|
|
245
|
+
// custom rank could be handed the very power the substrate reserves.
|
|
219
246
|
const grantable = catalog.grantableKeys()[0];
|
|
220
|
-
assert.ok(grantable
|
|
221
|
-
assert.equal(catalog.
|
|
247
|
+
assert.ok(grantable, 'the catalog must define at least one grantable key for this probe');
|
|
248
|
+
assert.equal(catalog.isSystem(grantable), false, 'fixture sanity: the probe key must be grantable');
|
|
249
|
+
assert.throws(
|
|
250
|
+
() => loadWithRegistry({
|
|
251
|
+
floor: 'metic',
|
|
252
|
+
permission: grantable,
|
|
253
|
+
surfaces: [{ glob: 'src/probe/grantable-surface.js' }],
|
|
254
|
+
}),
|
|
255
|
+
/which is not system:true/,
|
|
256
|
+
'a surface mapping to a grantable permission must refuse to load',
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('a surface mapped to an UNKNOWN permission fails loud at load', () => {
|
|
261
|
+
assert.equal(catalog.byKey('not.a.real.permission'), null, 'fixture sanity: the key must be unknown');
|
|
262
|
+
assert.throws(
|
|
263
|
+
() => loadWithRegistry({
|
|
264
|
+
floor: 'metic',
|
|
265
|
+
permission: 'not.a.real.permission',
|
|
266
|
+
surfaces: [{ glob: 'src/probe/unknown-surface.js' }],
|
|
267
|
+
}),
|
|
268
|
+
/which the permission catalog does not define/,
|
|
269
|
+
'a surface mapping to a permission the catalog never defines must refuse to load',
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('the probe itself is honest — a GOOD registry loads clean through the same path', () => {
|
|
274
|
+
// Without this, both assertions above would pass if the subprocess threw for an
|
|
275
|
+
// unrelated reason (a bad patch, a missing cwd, a syntax error in the -e script).
|
|
276
|
+
assert.doesNotThrow(() => loadWithRegistry({
|
|
277
|
+
floor: 'metic',
|
|
278
|
+
permission: registry.SYSTEM_PERMISSIONS[0],
|
|
279
|
+
surfaces: [{ glob: 'src/probe/good-surface.js' }],
|
|
280
|
+
}), 'a well-formed registry must still load — otherwise the probes above prove nothing');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('an unknown FLOOR is refused before the catalog check ever sees it', () => {
|
|
284
|
+
// The validation loop's third branch (`!catalog.RANK_ORDER.includes(s.floor)`) is
|
|
285
|
+
// a DRIFT guard, not dead code, and this test records why it cannot be provoked
|
|
286
|
+
// through the registry: the kernel loader keeps its own local RANK_TIER map
|
|
287
|
+
// (permission-path-check.js) and rejects an unknown floor first. The government
|
|
288
|
+
// branch only becomes reachable if those two rank lists ever disagree — which is
|
|
289
|
+
// exactly the drift it exists to catch, and is not craftable from JSON alone.
|
|
290
|
+
assert.throws(
|
|
291
|
+
() => loadWithRegistry({
|
|
292
|
+
floor: 'emperor',
|
|
293
|
+
permission: registry.SYSTEM_PERMISSIONS[0],
|
|
294
|
+
surfaces: [{ glob: 'src/probe/bad-floor.js' }],
|
|
295
|
+
}),
|
|
296
|
+
/unknown floor/,
|
|
297
|
+
);
|
|
298
|
+
assert.deepEqual(catalog.RANK_ORDER, ['xenos', 'thetes', 'metic', 'archon'],
|
|
299
|
+
'if this list and the kernel RANK_TIER drift, the government floor branch wakes up');
|
|
222
300
|
});
|
|
223
301
|
|
|
224
302
|
// ---------- synchronous, no DB, no server ----------
|
package/tests/module_api.mjs
CHANGED
|
@@ -24,6 +24,7 @@ const PUBLISHED_SURFACE = [
|
|
|
24
24
|
'requirePermission', // BV1.R105: the authority-atom gate module routes migrate onto (1.19.0; ADR 0151)
|
|
25
25
|
'pool', 'getPool', 'instanceDbName', 'withTx', 'getBuilderById',
|
|
26
26
|
'secretBox', // Gemini-key storage relocated to modules/art-pipeline + its 'art.getBuilderGeminiKeyRow' port (ADR 0102)
|
|
27
|
+
'grantedScopeCoversRepos', // the create-route credential preflight's scope read (task 1002909); kernel (auth-config.js), unlike db/github-repos which the module reaches via `pool` + its own client
|
|
27
28
|
'accountExistenceReadRateLimit', // task 1003339 (ADR 0209): the shared per-IP budget both account-existence probes spend from — one instance, not a factory, so the budget is genuinely shared
|
|
28
29
|
'publicProjectFeedRateLimit', // task 1002327 (ADR 0252 §5.3): the per-IP ceiling on GET /projects/featured — the platform's most expensive anonymous read (a cold call fans out to up to 500 third-party origins). One instance for the same reason as its neighbour
|
|
29
30
|
'routeRankCheck', 'permissionPathCheck', // BV1.R73: kernel trust classifiers (1.6.0)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// tests/provisioning_credential_preflight.mjs
|
|
2
|
+
//
|
|
3
|
+
// The create-route credential preflight (task 1002909, 1002898 N3).
|
|
4
|
+
//
|
|
5
|
+
// Before this, POST /provisioning/instances checked that an `adopt` NAMED a repo but
|
|
6
|
+
// never that the caller could read it, so the API admitted standups that were doomed at
|
|
7
|
+
// request time and surfaced ~2 minutes later as raw git stderr on the runner (the
|
|
8
|
+
// task-1002697 acceptance walk). The preflight asks ADR 0176's question one round-trip
|
|
9
|
+
// early: can this builder's credential carry THIS repo's standup?
|
|
10
|
+
//
|
|
11
|
+
// The two properties that matter, and the two ways this can be got wrong:
|
|
12
|
+
// 1. it must never refuse a create that would have SUCCEEDED (greenfield, public repos,
|
|
13
|
+
// admin-on-private, and — deliberately — a GitHub outage), and
|
|
14
|
+
// 2. it must refuse the ones that cannot (no credential, stale public_repo scope on a
|
|
15
|
+
// private repo, read-only access to a private repo).
|
|
16
|
+
// 1002898 carved this task out rather than guess at the definition precisely because a
|
|
17
|
+
// preflight written against the wrong one fails in one of those two directions.
|
|
18
|
+
//
|
|
19
|
+
// DB-free and network-free: readToken and fetchRepo are injected seams.
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/provisioning_credential_preflight.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
|
+
|
|
28
|
+
const { adoptCredentialFault, parseTargetRef } = require('../modules/provisioning/credential-preflight.js');
|
|
29
|
+
// The REAL scope helper, not a stand-in: its whole point is that `public_repo` contains
|
|
30
|
+
// the substring "repo", so a test double would hide the bug it exists to prevent.
|
|
31
|
+
const { grantedScopeCoversRepos } = require('../src/bongos/auth-config.js');
|
|
32
|
+
|
|
33
|
+
let passed = 0, failed = 0;
|
|
34
|
+
async function ta(name, fn) {
|
|
35
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
36
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A deps bundle with every leg stubbed; each test overrides only what it is about.
|
|
40
|
+
function deps({ configured = true, token = { token: 't', scope: 'repo' }, repo = { private: false, admin: false }, throws = null } = {}) {
|
|
41
|
+
return {
|
|
42
|
+
secretBox: { isConfigured: () => configured },
|
|
43
|
+
grantedScopeCoversRepos,
|
|
44
|
+
userAgent: 'test-agent',
|
|
45
|
+
readToken: async () => token,
|
|
46
|
+
fetchRepo: async () => { if (throws) throw Object.assign(new Error('x'), { code: throws }); return repo; },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const adopt = (targetRef = 'octo-owner/demo') => ({ builderId: 7, onboardMode: 'adopt', targetRef });
|
|
50
|
+
|
|
51
|
+
// ── the exemption that is load-bearing ──────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
await ta('greenfield is EXEMPT — the platform creates that repo, so nothing is checked', async () => {
|
|
54
|
+
// Asserted via the seams: a greenfield create must not even read the token, or a
|
|
55
|
+
// builder with no GitHub connection could no longer make a fresh project at all.
|
|
56
|
+
let touched = false;
|
|
57
|
+
const d = deps();
|
|
58
|
+
d.readToken = async () => { touched = true; return null; };
|
|
59
|
+
assert.equal(await adoptCredentialFault({ builderId: 7, onboardMode: 'greenfield', targetRef: null }, d), null);
|
|
60
|
+
assert.equal(touched, false, 'greenfield must not consult the credential at all');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── the refusals ────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
await ta('no stored credential → 403 repo_scope_required, and the message says what to do', async () => {
|
|
66
|
+
const f = await adoptCredentialFault(adopt(), deps({ token: null }));
|
|
67
|
+
assert.equal(f.code, 'repo_scope_required');
|
|
68
|
+
assert.equal(f.status, 403);
|
|
69
|
+
assert.match(f.message, /Connect your GitHub account/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await ta('secret-box unconfigured → 503, not 403 — the fault is the server config, not the caller', async () => {
|
|
73
|
+
const f = await adoptCredentialFault(adopt(), deps({ configured: false }));
|
|
74
|
+
assert.equal(f.code, 'github_credential_unavailable');
|
|
75
|
+
assert.equal(f.status, 503);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await ta('THE HERMESLINES SHAPE: a stale public_repo token 404ing on a private repo re-prompts for scope', async () => {
|
|
79
|
+
// GitHub answers 404 identically for "absent" and "private and invisible to you", so a
|
|
80
|
+
// pre-ADR-0155 token reaching its owner's own private repo lands here. Asserting the
|
|
81
|
+
// repo does not exist would be a lie; the message must offer both readings.
|
|
82
|
+
const f = await adoptCredentialFault(adopt(), deps({ token: { token: 't', scope: 'public_repo,read:user' }, throws: 'repo_not_found' }));
|
|
83
|
+
assert.equal(f.code, 'repo_scope_required');
|
|
84
|
+
assert.equal(f.status, 403);
|
|
85
|
+
assert.match(f.message, /private/i);
|
|
86
|
+
assert.match(f.message, /reconnect/i);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await ta('a repo-scoped token that still 404s → 404 target_repo_not_found (it really is absent)', async () => {
|
|
90
|
+
const f = await adoptCredentialFault(adopt(), deps({ token: { token: 't', scope: 'repo' }, throws: 'repo_not_found' }));
|
|
91
|
+
assert.equal(f.code, 'target_repo_not_found');
|
|
92
|
+
assert.equal(f.status, 404);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await ta('private repo WITHOUT admin → 403: the runner could read it and then fail registering the deploy key', async () => {
|
|
96
|
+
const f = await adoptCredentialFault(adopt(), deps({ repo: { private: true, admin: false } }));
|
|
97
|
+
assert.equal(f.code, 'repo_admin_required');
|
|
98
|
+
assert.equal(f.status, 403);
|
|
99
|
+
assert.match(f.message, /ADMIN/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await ta('a rejected token → 403 telling the caller to reconnect, not a raw 401', async () => {
|
|
103
|
+
const f = await adoptCredentialFault(adopt(), deps({ throws: 'github_unauthorized' }));
|
|
104
|
+
assert.equal(f.code, 'repo_scope_required');
|
|
105
|
+
assert.match(f.message, /reconnect/i);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await ta('an unparseable target_ref is refused for THAT reason, not admitted unchecked', async () => {
|
|
109
|
+
const f = await adoptCredentialFault(adopt('not a repo ref!!'), deps());
|
|
110
|
+
assert.equal(f.code, 'bad_target_ref');
|
|
111
|
+
assert.equal(f.status, 400);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── the admissions (the half a bad preflight breaks) ────────────────────────────
|
|
115
|
+
|
|
116
|
+
await ta('public repo + any readable credential → ADMITTED, admin irrelevant', async () => {
|
|
117
|
+
// The deploy pull rides the shared SSH alias for a public repo, so no deploy key and
|
|
118
|
+
// therefore no admin is needed. Demanding admin here would refuse valid creates.
|
|
119
|
+
assert.equal(await adoptCredentialFault(adopt(), deps({ repo: { private: false, admin: false } })), null);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await ta('private repo WITH admin → ADMITTED — this is exactly what ADR 0176 needs', async () => {
|
|
123
|
+
assert.equal(await adoptCredentialFault(adopt(), deps({ repo: { private: true, admin: true } })), null);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await ta('GitHub unreachable → ADMITTED, matching the runner: an outage must not stop project creation', async () => {
|
|
127
|
+
// No positive evidence of doom. The runner takes the same posture
|
|
128
|
+
// (ensurePrivateRepoAccess returns skipped:'github_unreachable' rather than throwing),
|
|
129
|
+
// so refusing here would turn a GitHub hiccup into an outage of the create route.
|
|
130
|
+
assert.equal(await adoptCredentialFault(adopt(), deps({ throws: 'github_error' })), null);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── the parse must not drift from the runner's ──────────────────────────────────
|
|
134
|
+
|
|
135
|
+
await ta('parseTargetRef accepts every form the runner accepts, and rejects what it rejects', async () => {
|
|
136
|
+
// A divergence from scripts/gds/provision-repo.js parseTargetRef would be silent: this
|
|
137
|
+
// would admit refs the runner cannot parse, or refuse ones it can.
|
|
138
|
+
for (const ref of ['octo-owner/demo', 'git@github.com:octo-owner/demo.git', 'https://github.com/octo-owner/demo', 'https://github.com/octo-owner/demo.git']) {
|
|
139
|
+
assert.deepEqual(parseTargetRef(ref), { owner: 'octo-owner', name: 'demo' }, `should parse ${ref}`);
|
|
140
|
+
}
|
|
141
|
+
for (const ref of ['', null, 'demo', 'a/b/c', 'not a repo ref!!']) {
|
|
142
|
+
assert.equal(parseTargetRef(ref), null, `should reject ${JSON.stringify(ref)}`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await ta('the real scope helper is used, so public_repo never passes as repo', async () => {
|
|
147
|
+
// The trap this guards: 'public_repo'.includes('repo') is true.
|
|
148
|
+
assert.equal(grantedScopeCoversRepos('public_repo,read:user'), false);
|
|
149
|
+
assert.equal(grantedScopeCoversRepos('repo'), true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
console.log(`\nprovisioning_credential_preflight: ${passed} passed, ${failed} failed`);
|
|
153
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -101,6 +101,19 @@ provisioning.getInstanceBySlug = async () => null;
|
|
|
101
101
|
const api = require('../src/module-api.js');
|
|
102
102
|
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
103
103
|
|
|
104
|
+
// An `adopt` create now also passes the credential preflight (task 1002909), which is a
|
|
105
|
+
// REAL precondition and not test scaffolding: the route refuses a standup whose owner
|
|
106
|
+
// holds no usable credential for the repo it is about to queue a push to. So give this
|
|
107
|
+
// harness a passing one — a `repo`-scoped token over a PUBLIC repo, the cheapest shape
|
|
108
|
+
// that admits — and leave the preflight's own branch matrix to
|
|
109
|
+
// tests/provisioning_credential_preflight.mjs, which owns it.
|
|
110
|
+
api.secretBox = { isConfigured: () => true };
|
|
111
|
+
api.pool = {
|
|
112
|
+
query: async () => ({ rows: [{ token_enc: 'enc', scope: 'repo', expires_at: new Date(Date.now() + 3600e3) }] }),
|
|
113
|
+
};
|
|
114
|
+
api.secretBox.decrypt = () => 'tok';
|
|
115
|
+
globalThis.fetch = async () => ({ status: 200, ok: true, json: async () => ({ private: false, permissions: { admin: true } }) });
|
|
116
|
+
|
|
104
117
|
const express = require('express');
|
|
105
118
|
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
106
119
|
const app = express();
|