@bongos/core 1.19.210 → 1.19.211

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.210",
6
- "core_contract": "1.19.210",
7
- "source_commit": "00146d83dff6ca94febcce329b5677b8d7dcb25c",
5
+ "core_version": "1.19.211",
6
+ "core_contract": "1.19.211",
7
+ "source_commit": "348da70b502ddf8be58aa20aeb71dcf7be94ec7c",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-17T22:55:17.955Z",
9
+ "built_at": "2026-08-17T23:14:01.398Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 310,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1300,
14
+ "functional_verbatim": 1301,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1631,
20
- "tree_sha256": "101f3cedf1222f6a1a14cc53957baf132e2586e9061e196dcff4550d43033a10",
19
+ "file_count": 1632,
20
+ "tree_sha256": "5d3eac725354ec0e3bd3c1d91f38bcc539a355b8e2b137de56de74ddfbe2572f",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -347,12 +347,12 @@
347
347
  {
348
348
  "path": "clients/bongos-client/README.md",
349
349
  "mode": "0000644",
350
- "sha256": "2755203dec4a1450c5950c39a767bc093d1d1227f952193001cdfa94205e995f"
350
+ "sha256": "81ffa13d3b249b46c4ed62be26cf8e985102697f090a3cfbe7491ff728976028"
351
351
  },
352
352
  {
353
353
  "path": "clients/bongos-client/bongos-client.global.js",
354
354
  "mode": "0000644",
355
- "sha256": "7b4e5ecf6c6d12a3c9e603a0f5b4bd28ba8cd991399ed8941181b6aeae53772d"
355
+ "sha256": "23b9f9be8ed1276fa2422cb74ae185c0e37813672e83196aeb6810f091815c12"
356
356
  },
357
357
  {
358
358
  "path": "clients/bongos-client/examples/hello-world.mjs",
@@ -362,17 +362,17 @@
362
362
  {
363
363
  "path": "clients/bongos-client/index.cjs",
364
364
  "mode": "0000644",
365
- "sha256": "a92355ef21073634500f6806f1e495c955a0b67e889cf07264ed8d1169b119be"
365
+ "sha256": "0e38267f075a54b3695a40da4a54f366d5b42827027e4e3614713e6bcc19ca4a"
366
366
  },
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "8fe97a3daefdceb9bf40a15a19dffb81fb6387c0acbe20041e58e63b24e80dd1"
370
+ "sha256": "004ad81d92eab45d5384876ce355b8f9208c32e202289f5c4fb1117932d27a14"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
374
374
  "mode": "0000644",
375
- "sha256": "adb2a36a9598c543adbf7c8011f36585f7c394bac7f4961718359ee7a29cb1dd"
375
+ "sha256": "661cd90af7bd8d0190cf9cef5614fee48dc48858d3cf9dea681da8b3fc99d170"
376
376
  },
377
377
  {
378
378
  "path": "clients/bongos-client/package.json",
@@ -1367,12 +1367,12 @@
1367
1367
  {
1368
1368
  "path": "docs/api-reference.md",
1369
1369
  "mode": "0000644",
1370
- "sha256": "7b61c830bb85b66e1d6d5dad672fd4b90166e01d07be0f5db7677fc38cc24817"
1370
+ "sha256": "43f6538485af5153a0ce0fb82f103431fa869cc7d996f5b6694577c474b0f0fe"
1371
1371
  },
1372
1372
  {
1373
1373
  "path": "docs/api/openapi.json",
1374
1374
  "mode": "0000644",
1375
- "sha256": "260f4413742edbb61d7695a1912ecae67383c9787a7f6f40211c5421668da480"
1375
+ "sha256": "274bd57fc6c0b83aba02cedb5cc6daa2fca4a9d74ae5268e527a81ebc10b50e0"
1376
1376
  },
1377
1377
  {
1378
1378
  "path": "docs/architecture.md",
@@ -2682,7 +2682,7 @@
2682
2682
  {
2683
2683
  "path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
2684
2684
  "mode": "0000644",
2685
- "sha256": "a92355ef21073634500f6806f1e495c955a0b67e889cf07264ed8d1169b119be"
2685
+ "sha256": "0e38267f075a54b3695a40da4a54f366d5b42827027e4e3614713e6bcc19ca4a"
2686
2686
  },
2687
2687
  {
2688
2688
  "path": "modules/dev-box/app/test/unit.mjs",
@@ -4282,12 +4282,12 @@
4282
4282
  {
4283
4283
  "path": "package-lock.json",
4284
4284
  "mode": "0000644",
4285
- "sha256": "4cfce69a22e52a9c0a89cd376458a32616167c2c6ca387f9cfc376212fd630db"
4285
+ "sha256": "aa3d213f3f3548f655bee0361f01e43ee380b54e958f8d11683ba7d94a098d2b"
4286
4286
  },
4287
4287
  {
4288
4288
  "path": "package.json",
4289
4289
  "mode": "0000644",
4290
- "sha256": "6c5ee2445f8c94f3ccbcee5c2387ac2029e34607601ff6146c6a932dc6282d40"
4290
+ "sha256": "368f9931ba9dc39483dc3a9c488fb8df45342cccccab65d34b9a4d083fa530ed"
4291
4291
  },
4292
4292
  {
4293
4293
  "path": "public-docs/index.html",
@@ -4547,7 +4547,7 @@
4547
4547
  {
4548
4548
  "path": "public-landing/projects.html",
4549
4549
  "mode": "0000644",
4550
- "sha256": "e19cbe7db15331509cb1f09c5e8cbcf0379bb55ddb5bc405d4ebf386186f016c"
4550
+ "sha256": "790034f4256a31da114969853e14fdf47b43b0ad35907b4fcc14ff98897ec7d1"
4551
4551
  },
4552
4552
  {
4553
4553
  "path": "public-landing/world/assets/clouds/cloud-island-large.png",
@@ -5682,7 +5682,7 @@
5682
5682
  {
5683
5683
  "path": "src/bongos/github-repos.js",
5684
5684
  "mode": "0000644",
5685
- "sha256": "893a2eb6f2658942cee2043ff130a5ae38538007e47e2ec2665548e0d286e211"
5685
+ "sha256": "f34518361f15a20c4dc60a936878e72b4c81d99e41e00180270919dcbbd83008"
5686
5686
  },
5687
5687
  {
5688
5688
  "path": "src/bongos/goal-scope-check.js",
@@ -5807,7 +5807,7 @@
5807
5807
  {
5808
5808
  "path": "src/bongos/routes/github.js",
5809
5809
  "mode": "0000644",
5810
- "sha256": "2895aa72fbcef361a5edf07166e64e9902edcb5971eb2e496705b3bfddeabda1"
5810
+ "sha256": "d8674cd2358b63f8463d91484495feaa38219fafdc0d925ad52ddb206a2cc84a"
5811
5811
  },
5812
5812
  {
5813
5813
  "path": "src/bongos/routes/healthz.js",
@@ -5902,7 +5902,7 @@
5902
5902
  {
5903
5903
  "path": "src/module-api.js",
5904
5904
  "mode": "0000644",
5905
- "sha256": "8f9beea649114a7133ef56008878f45197bb97c29bdddf6e5c6c5d786ca30981"
5905
+ "sha256": "5998d6f2561e88ca21f3f764a48fd496767d0b2f16aeaf1a1e7cf981333c4faf"
5906
5906
  },
5907
5907
  {
5908
5908
  "path": "src/module-loader/catalog.js",
@@ -6734,10 +6734,15 @@
6734
6734
  "mode": "0000644",
6735
6735
  "sha256": "850113ed91babc9eb54dc22231f194d283fd3f94cee9deef1e6e0bf6f3a969c2"
6736
6736
  },
6737
+ {
6738
+ "path": "tests/github_repo_visibility.mjs",
6739
+ "mode": "0000644",
6740
+ "sha256": "c2c428ade3a7cf802dfc46415c1d3c1e09fe826e223df71101e81d014cb5d69e"
6741
+ },
6737
6742
  {
6738
6743
  "path": "tests/github_repos_helpers.mjs",
6739
6744
  "mode": "0000644",
6740
- "sha256": "14d68c064218655bdbc04fe95dd7d03a8ac0d152301ac65c8c2c7b9fd93926ef"
6745
+ "sha256": "a450e5248b3b97ddaa11b1b9c79d768779df24b11cd1177174390ae80f5fe624"
6741
6746
  },
6742
6747
  {
6743
6748
  "path": "tests/github_token_store.mjs",
@@ -7522,7 +7527,7 @@
7522
7527
  {
7523
7528
  "path": "tests/projects_hub.mjs",
7524
7529
  "mode": "0000644",
7525
- "sha256": "3c31493c1b2a804b52f34310dc31f10b3bf67387ed9266c06fd7295aac2f0d5b"
7530
+ "sha256": "963b745e4b4ecd6a786f88758d57aee8680edd49d25f0cc84e3b563a5125be3b"
7526
7531
  },
7527
7532
  {
7528
7533
  "path": "tests/provision.mjs",
@@ -7572,7 +7577,7 @@
7572
7577
  {
7573
7578
  "path": "tests/public_landing_projects.mjs",
7574
7579
  "mode": "0000644",
7575
- "sha256": "d3e132bd85382b1a9ff4fa2d582a5fc72dabc91b2dc50828b5f622f6c112b2f1"
7580
+ "sha256": "266f5936675c0f6eb8d64af713d215edea2b88bcab50153497ef2d23fec1ff65"
7576
7581
  },
7577
7582
  {
7578
7583
  "path": "tests/public_mirror_export.mjs",
@@ -5,7 +5,7 @@ A **generated**, zero-dependency typed client for the Bongos API — produced fr
5
5
  by hand; it regenerates when the spec changes, so it can never drift from the routes.
6
6
 
7
7
  - API version: **v1** (served at `/api/bongos/v1`)
8
- - 278 operations across 46 resource groups
8
+ - 280 operations across 46 resource groups
9
9
 
10
10
  ## Install
11
11
 
@@ -283,6 +283,10 @@ function createClient(opts = {}) {
283
283
  postGateApprovalsPrApprove: (args) => request("POST", "/gate-approvals/{pr}/approve", { hasBody: true }, args),
284
284
  },
285
285
  "github": {
286
+ // GET /github/repo — rank: any-builder — GET /github/repo
287
+ getGithubRepo: (args) => request("GET", "/github/repo", { hasBody: false }, args),
288
+ // POST /github/repo/visibility — rank: any-builder — POST /github/repo/visibility
289
+ postGithubRepoVisibility: (args) => request("POST", "/github/repo/visibility", { hasBody: true }, args),
286
290
  // GET /github/repos — rank: any-builder — GET /github/repos
287
291
  getGithubRepos: (args) => request("GET", "/github/repos", { hasBody: false }, args),
288
292
  // POST /github/repos — rank: any-builder — POST /github/repos
@@ -282,6 +282,10 @@ function createClient(opts = {}) {
282
282
  postGateApprovalsPrApprove: (args) => request("POST", "/gate-approvals/{pr}/approve", { hasBody: true }, args),
283
283
  },
284
284
  "github": {
285
+ // GET /github/repo — rank: any-builder — GET /github/repo
286
+ getGithubRepo: (args) => request("GET", "/github/repo", { hasBody: false }, args),
287
+ // POST /github/repo/visibility — rank: any-builder — POST /github/repo/visibility
288
+ postGithubRepoVisibility: (args) => request("POST", "/github/repo/visibility", { hasBody: true }, args),
285
289
  // GET /github/repos — rank: any-builder — GET /github/repos
286
290
  getGithubRepos: (args) => request("GET", "/github/repos", { hasBody: false }, args),
287
291
  // POST /github/repos — rank: any-builder — POST /github/repos
@@ -35,6 +35,7 @@ export interface GetBuildersMePendingRankChangeResponse { change: unknown; greet
35
35
  export interface GetBuildersRosterResponse { builders: unknown; stale_hours: unknown }
36
36
  export interface GetDiscordChannelsPlanResponse { ok: boolean; guild_id: unknown; op_count: unknown; ops: unknown; warnings: unknown }
37
37
  export interface GetDoneWhenPendingReviewResponse { criteria: unknown; count: unknown }
38
+ export interface GetGithubRepoResponse { ok: boolean; repo: unknown; private_deployable: boolean }
38
39
  export interface GetGithubReposResponse { repos: unknown }
39
40
  export interface GetGoalsIdConflictsResponse { ok: boolean; goal_id: unknown; conflicts: unknown; cross_goals_considered: unknown }
40
41
  export interface GetGoalsIdRequestsResponse { requests: unknown }
@@ -199,6 +200,8 @@ export interface PostDoneWhenCriterionIdSatisfyRequest { satisfied_by_task_id?:
199
200
  export interface PostDoneWhenCriterionIdSatisfyResponse { criterion: unknown; goal_achieved: unknown; goal: unknown }
200
201
  export interface PostDoneWhenCriterionIdUnsatisfyResponse { criterion: unknown }
201
202
  export interface PostGateApprovalsPrApproveResponse { ok: boolean; pr: unknown; already_clear?: boolean; message: unknown; head_sha?: unknown; status_set?: boolean; recheck?: unknown }
203
+ export interface PostGithubRepoVisibilityRequest { repo: string; visibility: "public" | "private" }
204
+ export interface PostGithubRepoVisibilityResponse { ok: boolean; repo: unknown; private_deployable: boolean }
202
205
  export interface PostGithubReposRequest { name: string }
203
206
  export interface PostGithubReposResponse { repo: unknown }
204
207
  export interface PostGoalsIdArchiveResponse { ok: boolean; goal: unknown }
@@ -530,6 +533,10 @@ export interface BongosClient {
530
533
  postGateApprovalsPrApprove(args?: RequestArgs): Promise<PostGateApprovalsPrApproveResponse>;
531
534
  };
532
535
  "github": {
536
+ /** GET /github/repo — rank: any-builder */
537
+ getGithubRepo(args?: RequestArgs): Promise<GetGithubRepoResponse>;
538
+ /** POST /github/repo/visibility — rank: any-builder */
539
+ postGithubRepoVisibility(args: RequestArgs & { body: PostGithubRepoVisibilityRequest }): Promise<PostGithubRepoVisibilityResponse>;
533
540
  /** GET /github/repos — rank: any-builder */
534
541
  getGithubRepos(args?: RequestArgs): Promise<GetGithubReposResponse>;
535
542
  /** POST /github/repos — rank: any-builder */
@@ -279,6 +279,10 @@ export function createClient(opts = {}) {
279
279
  postGateApprovalsPrApprove: (args) => request("POST", "/gate-approvals/{pr}/approve", { hasBody: true }, args),
280
280
  },
281
281
  "github": {
282
+ // GET /github/repo — rank: any-builder — GET /github/repo
283
+ getGithubRepo: (args) => request("GET", "/github/repo", { hasBody: false }, args),
284
+ // POST /github/repo/visibility — rank: any-builder — POST /github/repo/visibility
285
+ postGithubRepoVisibility: (args) => request("POST", "/github/repo/visibility", { hasBody: true }, args),
282
286
  // GET /github/repos — rank: any-builder — GET /github/repos
283
287
  getGithubRepos: (args) => request("GET", "/github/repos", { hasBody: false }, args),
284
288
  // POST /github/repos — rank: any-builder — POST /github/repos
@@ -3641,6 +3641,93 @@
3641
3641
  ]
3642
3642
  }
3643
3643
  },
3644
+ "/github/repo": {
3645
+ "get": {
3646
+ "operationId": "get_github_repo",
3647
+ "tags": [
3648
+ "github"
3649
+ ],
3650
+ "summary": "GET /github/repo",
3651
+ "description": "reads one repo AS the caller's own GitHub credential sees it (the manage page's repo-visibility read, task 1003045). GitHub's 404 covers both \"no such repo\" and \"no access\" — forwarded as-is, no existence leak.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
3652
+ "x-rank": "any-builder",
3653
+ "x-source": "src/bongos/routes/github.js",
3654
+ "responses": {
3655
+ "200": {
3656
+ "description": "Success.",
3657
+ "content": {
3658
+ "application/json": {
3659
+ "schema": {
3660
+ "$ref": "#/components/schemas/GetGithubRepoResponse"
3661
+ }
3662
+ }
3663
+ }
3664
+ },
3665
+ "400": {
3666
+ "$ref": "#/components/responses/BadRequest"
3667
+ },
3668
+ "401": {
3669
+ "$ref": "#/components/responses/Unauthorized"
3670
+ },
3671
+ "403": {
3672
+ "$ref": "#/components/responses/Forbidden"
3673
+ }
3674
+ },
3675
+ "security": [
3676
+ {
3677
+ "builderSession": []
3678
+ }
3679
+ ]
3680
+ }
3681
+ },
3682
+ "/github/repo/visibility": {
3683
+ "post": {
3684
+ "operationId": "post_github_repo_visibility",
3685
+ "tags": [
3686
+ "github"
3687
+ ],
3688
+ "summary": "POST /github/repo/visibility",
3689
+ "description": "flips one of the caller's repos PUBLIC, with their own credential. The private direction is REFUSED here, before GitHub is ever called: the deploy pull still runs as the shared deploy key that cannot read a private repo (walk finding N4), so flipping private would break the project's next deploy — the refusal is the honest leg task 1003045 ships.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
3690
+ "x-rank": "any-builder",
3691
+ "x-source": "src/bongos/routes/github.js",
3692
+ "requestBody": {
3693
+ "required": true,
3694
+ "content": {
3695
+ "application/json": {
3696
+ "schema": {
3697
+ "$ref": "#/components/schemas/PostGithubRepoVisibilityRequest"
3698
+ }
3699
+ }
3700
+ },
3701
+ "x-validated": true
3702
+ },
3703
+ "responses": {
3704
+ "200": {
3705
+ "description": "Success.",
3706
+ "content": {
3707
+ "application/json": {
3708
+ "schema": {
3709
+ "$ref": "#/components/schemas/PostGithubRepoVisibilityResponse"
3710
+ }
3711
+ }
3712
+ }
3713
+ },
3714
+ "400": {
3715
+ "$ref": "#/components/responses/ValidationFailed"
3716
+ },
3717
+ "401": {
3718
+ "$ref": "#/components/responses/Unauthorized"
3719
+ },
3720
+ "403": {
3721
+ "$ref": "#/components/responses/Forbidden"
3722
+ }
3723
+ },
3724
+ "security": [
3725
+ {
3726
+ "builderSession": []
3727
+ }
3728
+ ]
3729
+ }
3730
+ },
3644
3731
  "/github/repos": {
3645
3732
  "get": {
3646
3733
  "operationId": "get_github_repos",
@@ -13020,6 +13107,23 @@
13020
13107
  "count"
13021
13108
  ]
13022
13109
  },
13110
+ "GetGithubRepoResponse": {
13111
+ "type": "object",
13112
+ "properties": {
13113
+ "ok": {
13114
+ "type": "boolean"
13115
+ },
13116
+ "repo": {},
13117
+ "private_deployable": {
13118
+ "type": "boolean"
13119
+ }
13120
+ },
13121
+ "required": [
13122
+ "ok",
13123
+ "repo",
13124
+ "private_deployable"
13125
+ ]
13126
+ },
13023
13127
  "GetGithubReposResponse": {
13024
13128
  "type": "object",
13025
13129
  "properties": {
@@ -15323,6 +15427,44 @@
15323
15427
  "message"
15324
15428
  ]
15325
15429
  },
15430
+ "PostGithubRepoVisibilityRequest": {
15431
+ "type": "object",
15432
+ "properties": {
15433
+ "repo": {
15434
+ "type": "string",
15435
+ "maxLength": 140
15436
+ },
15437
+ "visibility": {
15438
+ "type": "string",
15439
+ "enum": [
15440
+ "public",
15441
+ "private"
15442
+ ]
15443
+ }
15444
+ },
15445
+ "required": [
15446
+ "repo",
15447
+ "visibility"
15448
+ ],
15449
+ "additionalProperties": false
15450
+ },
15451
+ "PostGithubRepoVisibilityResponse": {
15452
+ "type": "object",
15453
+ "properties": {
15454
+ "ok": {
15455
+ "type": "boolean"
15456
+ },
15457
+ "repo": {},
15458
+ "private_deployable": {
15459
+ "type": "boolean"
15460
+ }
15461
+ },
15462
+ "required": [
15463
+ "ok",
15464
+ "repo",
15465
+ "private_deployable"
15466
+ ]
15467
+ },
15326
15468
  "PostGithubReposRequest": {
15327
15469
  "type": "object",
15328
15470
  "properties": {
@@ -17587,9 +17729,9 @@
17587
17729
  "description": "A required dependency/feature is not configured or is temporarily down."
17588
17730
  }
17589
17731
  },
17590
- "x-endpoint-count": 278,
17591
- "x-schema-count": 314,
17732
+ "x-endpoint-count": 280,
17733
+ "x-schema-count": 317,
17592
17734
  "x-undocumented-bodies": 8,
17593
- "x-response-schemas": 212,
17735
+ "x-response-schemas": 214,
17594
17736
  "x-generated-by": "scripts/gds/gen-api-docs.js"
17595
17737
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Bongos API reference
4
4
 
5
- > **Generated from the live route files** — the route file is authoritative. 278 endpoints across 46 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 280 endpoints across 46 route files.
6
6
  > Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
7
7
 
8
8
  Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -178,10 +178,12 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
178
178
  | GET | `/api/bongos/gate-approvals` | `metic+archon` | — | GET /gate-approvals — open PRs awaiting owner approval (gate-review red), with the verdict + per-file reasons + changed files + diff link. |
179
179
  | POST | `/api/bongos/gate-approvals/:pr/approve` | `archon` | — | POST /gate-approvals/:pr/approve — record owner approval for a stuck gate PR and re-run the gate-review check so it clears. |
180
180
 
181
- ## `github` (2)
181
+ ## `github` (4)
182
182
 
183
183
  | Method | Path | Rank | Body | Description |
184
184
  |---|---|---|---|---|
185
+ | GET | `/api/bongos/github/repo` | `any-builder` | — | reads one repo AS the caller's own GitHub credential sees it (the manage page's repo-visibility read, task 1003045). |
186
+ | POST | `/api/bongos/github/repo/visibility` | `any-builder` | `repo`, `visibility` | flips one of the caller's repos PUBLIC, with their own credential. |
185
187
  | GET | `/api/bongos/github/repos` | `any-builder` | — | acts only on the caller's OWN GitHub identity (the token they granted). |
186
188
  | POST | `/api/bongos/github/repos` | `any-builder` | `name` | creates a repo in the caller's OWN account. |
187
189
 
@@ -282,6 +282,10 @@ function createClient(opts = {}) {
282
282
  postGateApprovalsPrApprove: (args) => request("POST", "/gate-approvals/{pr}/approve", { hasBody: true }, args),
283
283
  },
284
284
  "github": {
285
+ // GET /github/repo — rank: any-builder — GET /github/repo
286
+ getGithubRepo: (args) => request("GET", "/github/repo", { hasBody: false }, args),
287
+ // POST /github/repo/visibility — rank: any-builder — POST /github/repo/visibility
288
+ postGithubRepoVisibility: (args) => request("POST", "/github/repo/visibility", { hasBody: true }, args),
285
289
  // GET /github/repos — rank: any-builder — GET /github/repos
286
290
  getGithubRepos: (args) => request("GET", "/github/repos", { hasBody: false }, args),
287
291
  // POST /github/repos — rank: any-builder — POST /github/repos
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.210",
3
+ "version": "1.19.211",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.210",
9
+ "version": "1.19.211",
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.210",
3
+ "version": "1.19.211",
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",
@@ -618,9 +618,14 @@ h1[tabindex="-1"]:focus{outline:none;}
618
618
  <div class="empty"><p>Choosing how new people join — walk right in, apply first, or invite only — isn’t wired up here yet. It lands in one of the next updates.</p></div>
619
619
  </div>
620
620
 
621
+ <!-- live since task 1003045: reads the repo's real visibility from GitHub
622
+ with the owner's own repository credential. Flip-to-PUBLIC only — the
623
+ private direction is refused (server + copy) until the private-repo
624
+ deploy fix lands, because the deploy pull cannot read a private repo
625
+ (walk finding N4). -->
621
626
  <div class="card" id="mRepoVisibility">
622
627
  <h2 style="font-family:var(--font-display);font-size:19px;margin-bottom:12px;">Repository visibility</h2>
623
- <div class="empty"><p>Making the project’s code repository public or private from here isn’t wired up yet. Until it lands, GitHub’s own settings page is the place to change that.</p></div>
628
+ <div id="mRepoVis"><p class="finePrint">Checking the repository on GitHub…</p></div>
624
629
  </div>
625
630
 
626
631
  <div class="card" id="mDelete">
@@ -3942,6 +3947,9 @@ h1[tabindex="-1"]:focus{outline:none;}
3942
3947
  var inst = data.instance || {};
3943
3948
  inst.open_intent = data.open_intent || null;
3944
3949
  renderManage(inst);
3950
+ /* the SECOND, GitHub-backed read — fired only here (entry + tab
3951
+ return), never from renderManage, which the fetch-free tick calls */
3952
+ loadRepoVisibility(inst);
3945
3953
  })
3946
3954
  .catch(function (err) {
3947
3955
  var m = String(err && err.message);
@@ -3959,6 +3967,122 @@ h1[tabindex="-1"]:focus{outline:none;}
3959
3967
  }
3960
3968
  setInterval(manageChipTick, REFRESH_MS);
3961
3969
 
3970
+ /* ── repo visibility (task 1003045): read live from GitHub with the owner's
3971
+ own repository credential; flip-to-PUBLIC only. The private direction is
3972
+ refused by the server — and unbuilt here — until the private-repo deploy
3973
+ fix lands: the deploy pull runs as the shared deploy key, which cannot
3974
+ read a private repo (walk finding N4), so flipping private would break
3975
+ the project's next deploy. Every claim below is what GitHub answered for
3976
+ THIS account — never a cached or guessed state. ── */
3977
+
3978
+ var manageRepo = null; /* GitHub's last answer for the manage view's repo */
3979
+
3980
+ function repoVisReconnect() {
3981
+ return API + '/auth/web/start?repos=1&return=' +
3982
+ encodeURIComponent('/projects?view=manage&id=' + manageId);
3983
+ }
3984
+
3985
+ function repoVisMsg(html) { $('mRepoVis').innerHTML = html; }
3986
+
3987
+ function renderRepoVis(repo) {
3988
+ if (repo.private) {
3989
+ repoVisMsg(
3990
+ '<div class="addr">This repository is <b>private</b> — only accounts it’s shared with can read its code.</div>' +
3991
+ '<div class="projMsg err">The platform can’t deploy private repositories yet, so deploys for this project will fail while it stays private.</div>' +
3992
+ (repo.admin
3993
+ ? '<div class="actions"><button type="button" class="btn small" data-repo-public>Make it public</button></div>'
3994
+ : '<p class="finePrint">Your GitHub account can’t change this repository’s settings.</p>'));
3995
+ } else {
3996
+ repoVisMsg(
3997
+ '<div class="addr">This repository is <b>public</b> — anyone can read its code.</div>' +
3998
+ '<p class="finePrint">Making it private isn’t possible from here yet: the platform can’t deploy private repositories. The switch lands with that fix.</p>');
3999
+ }
4000
+ }
4001
+
4002
+ function loadRepoVisibility(inst) {
4003
+ manageRepo = null;
4004
+ if (!inst.target_ref) {
4005
+ repoVisMsg('<p class="finePrint">No repository is linked to this project.</p>');
4006
+ return;
4007
+ }
4008
+ repoVisMsg('<p class="finePrint">Checking the repository on GitHub…</p>');
4009
+ var asked = manageId; /* a stale answer must never describe a newer project */
4010
+ fetch(API + '/github/repo?repo=' + encodeURIComponent(inst.target_ref), { credentials: 'same-origin' })
4011
+ .then(function (res) {
4012
+ if (asked !== manageId) throw new Error('stale');
4013
+ return jsonOrEmpty(res).then(function (data) {
4014
+ if (res.ok) { manageRepo = data.repo || {}; renderRepoVis(manageRepo); return; }
4015
+ var e = apiError(data, res.status);
4016
+ if (e.code === 'repo_scope_required') {
4017
+ /* the stored repository credential lives an hour (ADR 0155) — the
4018
+ owner reconnects through the same door the create wizard uses */
4019
+ repoVisMsg('<p class="finePrint">Seeing and changing this here needs repository access. ' +
4020
+ '<a href="' + repoVisReconnect() + '">Reconnect GitHub with repository access</a>.</p>');
4021
+ } else if (e.code === 'github_repos_unavailable') {
4022
+ repoVisMsg('<p class="finePrint">Repository access isn’t configured on this server, so visibility can’t be read from here.</p>');
4023
+ } else if (e.code === 'repo_not_found') {
4024
+ repoVisMsg('<p class="finePrint">GitHub didn’t show this repository to your account — it may have been deleted, renamed, or belong to a different account.</p>');
4025
+ } else if (res.status === 401) {
4026
+ repoVisMsg('<p class="finePrint">' + signInAgain() + '</p>');
4027
+ } else {
4028
+ repoVisMsg('<p class="finePrint">GitHub couldn’t be reached right now — refresh in a moment.</p>');
4029
+ }
4030
+ });
4031
+ })
4032
+ .catch(function (err) {
4033
+ if (String(err && err.message) === 'stale') return;
4034
+ repoVisMsg('<p class="finePrint">GitHub couldn’t be reached right now — refresh in a moment.</p>');
4035
+ });
4036
+ }
4037
+
4038
+ /* flipping public exposes the code to everyone — never one accidental click */
4039
+ function askRepoPublic() {
4040
+ var act = document.querySelector('#mRepoVis .actions');
4041
+ if (!act) return;
4042
+ act.innerHTML = '<div class="confirmRow">' +
4043
+ '<span class="plain">Make it public? Anyone on the internet will be able to read the code.</span>' +
4044
+ '<button type="button" class="btn small" data-repo-public-yes>Yes, make it public</button>' +
4045
+ '<button type="button" class="linklike" data-repo-public-no>Keep it private</button></div>';
4046
+ }
4047
+
4048
+ function doRepoPublic() {
4049
+ if (!manageInst || !manageInst.target_ref) return;
4050
+ repoVisMsg('<p class="finePrint">Asking GitHub…</p>');
4051
+ fetch(API + '/github/repo/visibility', {
4052
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
4053
+ body: JSON.stringify({ repo: manageInst.target_ref, visibility: 'public' })
4054
+ }).then(function (res) {
4055
+ return jsonOrEmpty(res).then(function (data) {
4056
+ if (res.ok) {
4057
+ /* GitHub's own answer paints the new state — never an assumption */
4058
+ manageRepo = data.repo || {};
4059
+ renderRepoVis(manageRepo);
4060
+ return;
4061
+ }
4062
+ var e = apiError(data, res.status);
4063
+ /* say what the server said, on top of the still-true state */
4064
+ if (manageRepo) renderRepoVis(manageRepo);
4065
+ else repoVisMsg('');
4066
+ $('mRepoVis').insertAdjacentHTML('beforeend',
4067
+ res.status === 401
4068
+ ? '<div class="projMsg err">' + signInAgain() + '</div>'
4069
+ : '<div class="projMsg err">' + esc(e.message || 'The change could not be made — try again in a moment.') + '</div>');
4070
+ });
4071
+ }).catch(function () {
4072
+ if (manageRepo) renderRepoVis(manageRepo);
4073
+ $('mRepoVis').insertAdjacentHTML('beforeend',
4074
+ '<div class="projMsg err">Couldn’t reach the platform — check your connection and try again.</div>');
4075
+ });
4076
+ }
4077
+
4078
+ $('mRepoVis').addEventListener('click', function (ev) {
4079
+ var t = ev.target.closest && ev.target.closest('button');
4080
+ if (!t) return;
4081
+ if (t.hasAttribute('data-repo-public')) return askRepoPublic();
4082
+ if (t.hasAttribute('data-repo-public-yes')) return doRepoPublic();
4083
+ if (t.hasAttribute('data-repo-public-no')) { if (manageRepo) renderRepoVis(manageRepo); }
4084
+ });
4085
+
3962
4086
  /* ── teardown: ask, confirm (the truth, itemized — direction §5.4), then
3963
4087
  SAY exactly what the server said ── */
3964
4088
 
@@ -102,4 +102,64 @@ async function createUserRepo(token, { name, fetchImpl = fetch } = {}) {
102
102
  throw new GithubApiError(`GitHub create-repo failed (${res.status})`, { status: res.status, code: 'github_error' });
103
103
  }
104
104
 
105
- module.exports = { listUserRepos, createUserRepo, GithubApiError, MAX_PAGES };
105
+ // Read one repo as GitHub answers it FOR THIS TOKEN — the manage page's
106
+ // visibility read (task 1003045). GitHub's own 404 covers both "no such repo"
107
+ // and "no access", which is exactly the no-leak shape the caller wants to
108
+ // forward. `permissions.admin` says whether this token could change settings.
109
+ // Returns { full_name, html_url, private, admin }.
110
+ async function getUserRepo(token, { owner, name, fetchImpl = fetch } = {}) {
111
+ const res = await fetchImpl(`${GH_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, {
112
+ headers: ghHeaders(token),
113
+ });
114
+ if (res.status === 401) {
115
+ throw new GithubApiError('GitHub rejected the stored token', { status: 401, code: 'github_unauthorized' });
116
+ }
117
+ if (res.status === 404) {
118
+ throw new GithubApiError('GitHub answered 404 for that repository', { status: 404, code: 'repo_not_found' });
119
+ }
120
+ if (!res.ok) {
121
+ throw new GithubApiError(`GitHub /repos read failed (${res.status})`, { status: res.status, code: 'github_error' });
122
+ }
123
+ const r = await res.json();
124
+ return {
125
+ full_name: r.full_name,
126
+ html_url: r.html_url,
127
+ private: !!r.private,
128
+ admin: !!(r.permissions && r.permissions.admin),
129
+ };
130
+ }
131
+
132
+ // Flip one repo PUBLIC — and only public. Like createUserRepo's private:false,
133
+ // the direction is deliberately not a parameter: the deploy pull still runs as
134
+ // the shared deploy key that cannot read a private repo (walk finding N4), so
135
+ // nothing in this codebase may flip a repo private until that fix lands — the
136
+ // route refuses the request; this helper makes the refusal structural
137
+ // (task 1003045). Returns the same shape as getUserRepo.
138
+ async function makeRepoPublic(token, { owner, name, fetchImpl = fetch } = {}) {
139
+ const res = await fetchImpl(`${GH_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, {
140
+ method: 'PATCH',
141
+ headers: ghHeaders(token, { 'Content-Type': 'application/json' }),
142
+ body: JSON.stringify({ private: false }),
143
+ });
144
+ if (res.status === 401) {
145
+ throw new GithubApiError('GitHub rejected the stored token', { status: 401, code: 'github_unauthorized' });
146
+ }
147
+ if (res.status === 403) {
148
+ throw new GithubApiError('GitHub refused the change for this account', { status: 403, code: 'not_repo_admin' });
149
+ }
150
+ if (res.status === 404) {
151
+ throw new GithubApiError('GitHub answered 404 for that repository', { status: 404, code: 'repo_not_found' });
152
+ }
153
+ if (!res.ok) {
154
+ throw new GithubApiError(`GitHub repo-visibility change failed (${res.status})`, { status: res.status, code: 'github_error' });
155
+ }
156
+ const r = await res.json();
157
+ return {
158
+ full_name: r.full_name,
159
+ html_url: r.html_url,
160
+ private: !!r.private,
161
+ admin: !!(r.permissions && r.permissions.admin),
162
+ };
163
+ }
164
+
165
+ module.exports = { listUserRepos, createUserRepo, getUserRepo, makeRepoPublic, GithubApiError, MAX_PAGES };
@@ -1,5 +1,7 @@
1
1
  // src/bongos/routes/github.js — GitHub repo list/create for the signed-in user, the
2
- // server half of the onboarding wizard's repo picker (adopt) + auto-create (fresh).
2
+ // server half of the onboarding wizard's repo picker (adopt) + auto-create (fresh),
3
+ // plus the manage page's repo-visibility read/flip (task 1003045 — public only; the
4
+ // private direction is refused until the private-repo deploy fix lands).
3
5
  // Task 1002099 / ADR 0143.
4
6
  //
5
7
  // These act on the user's OWN GitHub identity, so they live in core auth alongside
@@ -18,11 +20,29 @@ const auth = require('../auth');
18
20
  const db = require('../db');
19
21
  const secretBox = require('../secret-box');
20
22
  const { listUserRepos, createUserRepo } = require('../github-repos');
23
+ // Namespace require for the visibility helpers (task 1003045): the route tests
24
+ // stub these on the cached module object, which a load-time destructure would
25
+ // pin to the real implementations.
26
+ const githubRepos = require('../github-repos');
21
27
  const { validateOrRespond } = require('./_helpers');
22
28
 
23
29
  // GitHub repo names: 1–100 chars of [A-Za-z0-9._-]. We validate strictly for a clean
24
30
  // 400; anything GitHub still rejects surfaces as the 422→409 name_taken path.
25
31
  const REPO_NAME_RE = /^[A-Za-z0-9._-]{1,100}$/;
32
+ // owner/name for the visibility routes: a GitHub login (1–39 chars, alnum + inner
33
+ // hyphens) over a repo name. Strict for a clean 400; GitHub still owns the truth.
34
+ const REPO_REF_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})?\/[A-Za-z0-9._-]{1,100}$/;
35
+
36
+ // Parse + validate the owner/name ref, or send a 400 and return null.
37
+ function parseRepoRef(raw, res) {
38
+ const ref = String(raw || '').trim();
39
+ if (!REPO_REF_RE.test(ref)) {
40
+ res.fail('bad_repo_ref', { status: 400, message: 'Name the repository as owner/name.' });
41
+ return null;
42
+ }
43
+ const [owner, name] = ref.split('/');
44
+ return { owner, name };
45
+ }
26
46
 
27
47
  // Resolve the caller's stored repo token, or send the right failure and return null.
28
48
  // Centralizes the 503-vs-403 decision both endpoints share.
@@ -97,5 +117,70 @@ module.exports = function buildGithubRouter() {
97
117
  }
98
118
  });
99
119
 
120
+ // rank: any-builder — reads one repo AS the caller's own GitHub credential sees
121
+ // it (the manage page's repo-visibility read, task 1003045). GitHub's 404 covers
122
+ // both "no such repo" and "no access" — forwarded as-is, no existence leak.
123
+ router.get('/github/repo', auth.requireBuilder, async (req, res) => {
124
+ const ref = parseRepoRef(req.query.repo, res);
125
+ if (!ref) return;
126
+ const token = await resolveRepoToken(req, res);
127
+ if (!token) return;
128
+ try {
129
+ const repo = await githubRepos.getUserRepo(token, ref);
130
+ // private_deployable is the platform truth the UI copy leans on: the deploy
131
+ // pull runs as the shared deploy key, which cannot read a private repo (N4).
132
+ res.json({ ok: true, repo, private_deployable: false });
133
+ } catch (err) {
134
+ if (err && err.status === 401) {
135
+ await db.deleteGithubToken({ builderId: req.builder.id }).catch(() => {});
136
+ return res.fail('repo_scope_required', { status: 403, message: 'Your GitHub authorization expired. Reconnect to read the repository.' });
137
+ }
138
+ if (err && err.code === 'repo_not_found') {
139
+ return res.fail('repo_not_found', { status: 404, message: 'GitHub didn’t show that repository to your account.' });
140
+ }
141
+ console.error('[gds] GET /github/repo', err && err.message);
142
+ res.fail('github_error', { status: 502, message: 'GitHub could not be reached. Try again in a moment.' });
143
+ }
144
+ });
145
+
146
+ // rank: any-builder — flips one of the caller's repos PUBLIC, with their own
147
+ // credential. The private direction is REFUSED here, before GitHub is ever
148
+ // called: the deploy pull still runs as the shared deploy key that cannot read
149
+ // a private repo (walk finding N4), so flipping private would break the
150
+ // project's next deploy — the refusal is the honest leg task 1003045 ships.
151
+ router.post('/github/repo/visibility', auth.requireBuilder, async (req, res) => {
152
+ if (validateOrRespond(req, res, {
153
+ repo: { required: true, type: 'string', maxLength: 140 },
154
+ visibility: { required: true, type: 'string', enum: ['public', 'private'] },
155
+ })) return;
156
+ const ref = parseRepoRef(req.body.repo, res);
157
+ if (!ref) return;
158
+ if (req.body.visibility !== 'public') {
159
+ return res.fail('private_not_deployable', {
160
+ status: 409,
161
+ message: 'Private repositories can’t be deployed yet — the platform’s deploy pull can’t read them, so making this repository private would break the project’s next deploy. This lands with the private-repo deploy fix.',
162
+ });
163
+ }
164
+ const token = await resolveRepoToken(req, res);
165
+ if (!token) return;
166
+ try {
167
+ const repo = await githubRepos.makeRepoPublic(token, ref);
168
+ res.json({ ok: true, repo, private_deployable: false });
169
+ } catch (err) {
170
+ if (err && err.status === 401) {
171
+ await db.deleteGithubToken({ builderId: req.builder.id }).catch(() => {});
172
+ return res.fail('repo_scope_required', { status: 403, message: 'Your GitHub authorization expired. Reconnect to change the repository.' });
173
+ }
174
+ if (err && err.code === 'not_repo_admin') {
175
+ return res.fail('not_repo_admin', { status: 403, message: 'GitHub refused: your account can’t change this repository’s settings.' });
176
+ }
177
+ if (err && err.code === 'repo_not_found') {
178
+ return res.fail('repo_not_found', { status: 404, message: 'GitHub didn’t show that repository to your account.' });
179
+ }
180
+ console.error('[gds] POST /github/repo/visibility', err && err.message);
181
+ res.fail('github_error', { status: 502, message: 'GitHub could not make the change. Try again in a moment.' });
182
+ }
183
+ });
184
+
100
185
  return router;
101
186
  };
package/src/module-api.js CHANGED
@@ -847,8 +847,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
847
847
  // landed since 1.19.208 with no explicit bump. run 32072227661. (task 1002620)
848
848
  // 1.19.210 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
849
849
  // landed since 1.19.209 with no explicit bump. run 32078135414. (task 1002620)
850
+ // 1.19.211 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
851
+ // landed since 1.19.210 with no explicit bump. run 32079512489. (task 1002620)
850
852
  // ---------------------------------------------------------------------------
851
- const CORE_VERSION = '1.19.210'; // CI auto-patch carrier (ADR 0161); see changelog above
853
+ const CORE_VERSION = '1.19.211'; // CI auto-patch carrier (ADR 0161); see changelog above
852
854
 
853
855
  // A namespaced logger so a module's log lines are attributable + consistent.
854
856
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,168 @@
1
+ // tests/github_repo_visibility.mjs — the manage page's repo-visibility routes
2
+ // (task 1003045): GET /github/repo + POST /github/repo/visibility.
3
+ //
4
+ // The load-bearing assertions:
5
+ // • the PRIVATE direction is refused BEFORE GitHub is ever called (409
6
+ // private_not_deployable): the deploy pull runs as the shared deploy key,
7
+ // which cannot read a private repo (walk finding N4), so flipping private
8
+ // would break the project's next deploy — the honest-refusal leg this task
9
+ // ships. The refusal must not even consult the stored token.
10
+ // • flip-to-public calls makeRepoPublic with the parsed owner/name and
11
+ // forwards GitHub's own answer — the new state is never assumed
12
+ // • a GitHub 401 drops the stored token and re-prompts (the /github/repos
13
+ // convention); GitHub's 404 forwards as repo_not_found (no leak beyond what
14
+ // GitHub told this account); a GitHub 403 becomes not_repo_admin
15
+ // • a malformed owner/name is a clean 400 bad_repo_ref
16
+ //
17
+ // DB-free + network-free: db.getGithubToken and the github-repos helpers are
18
+ // stubbed on their cached module objects (the github_token_store.mjs pattern —
19
+ // which is also why routes/github.js requires the helpers as a namespace).
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { test } from 'node:test';
23
+ import { createRequire } from 'node:module';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ // A valid key BEFORE requiring the modules, so secret-box reads as configured.
28
+ process.env.BUILDER_SECRET_KEY = 'ab'.repeat(32);
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const db = require(path.join(ROOT, 'src/bongos/db.js'));
33
+ const githubRepos = require(path.join(ROOT, 'src/bongos/github-repos.js'));
34
+ const buildGithubRouter = require(path.join(ROOT, 'src/bongos/routes/github.js'));
35
+ const { attachFail } = require(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
36
+
37
+ const { GithubApiError } = githubRepos;
38
+
39
+ function fakeRes() {
40
+ const res = { _status: 200, _body: null };
41
+ res.status = (c) => { res._status = c; return res; };
42
+ res.json = (b) => { res._body = b; return res; };
43
+ attachFail({}, res, () => {}); // installs res.fail exactly as the router does
44
+ return res;
45
+ }
46
+ function finalHandler(router, method, pth) {
47
+ const layer = router.stack.find((l) => l.route && l.route.path === pth && l.route.methods[method]);
48
+ const stack = layer.route.stack;
49
+ return stack[stack.length - 1].handle; // the handler after requireBuilder
50
+ }
51
+
52
+ // One stubbed world per test: a live repo-scoped token + programmable helpers.
53
+ // getRepo/makePublic are thunks so a test can return an answer or throw a typed
54
+ // GithubApiError, exactly as the real helpers do.
55
+ function stub({ token = { token: 'tok', scope: 'repo read:user' }, getRepo, makePublic } = {}) {
56
+ const orig = {
57
+ get: db.getGithubToken, del: db.deleteGithubToken,
58
+ read: githubRepos.getUserRepo, flip: githubRepos.makeRepoPublic,
59
+ };
60
+ const calls = { read: [], flip: [], del: 0 };
61
+ db.getGithubToken = async () => token;
62
+ db.deleteGithubToken = async () => { calls.del++; };
63
+ githubRepos.getUserRepo = async (_t, ref) => { calls.read.push(ref); return getRepo(); };
64
+ githubRepos.makeRepoPublic = async (_t, ref) => { calls.flip.push(ref); return makePublic(); };
65
+ const restore = () => {
66
+ db.getGithubToken = orig.get; db.deleteGithubToken = orig.del;
67
+ githubRepos.getUserRepo = orig.read; githubRepos.makeRepoPublic = orig.flip;
68
+ };
69
+ return { calls, restore };
70
+ }
71
+
72
+ const getHandler = () => finalHandler(buildGithubRouter(), 'get', '/github/repo');
73
+ const postHandler = () => finalHandler(buildGithubRouter(), 'post', '/github/repo/visibility');
74
+
75
+ test('GET /github/repo: forwards GitHub\'s answer for the caller\'s own credential', async () => {
76
+ const s = stub({ getRepo: () => ({ full_name: 'jaxri/mercury', html_url: 'https://github.com/jaxri/mercury', private: true, admin: true }) });
77
+ try {
78
+ const res = fakeRes();
79
+ await getHandler()({ builder: { id: 1 }, query: { repo: 'jaxri/mercury' } }, res);
80
+ assert.equal(res._status, 200);
81
+ assert.equal(res._body.repo.private, true);
82
+ assert.equal(res._body.repo.admin, true);
83
+ assert.equal(res._body.private_deployable, false, 'the platform truth rides every answer');
84
+ assert.deepEqual(s.calls.read, [{ owner: 'jaxri', name: 'mercury' }]);
85
+ } finally { s.restore(); }
86
+ });
87
+
88
+ test('GET /github/repo: a malformed ref is a clean 400 before any token or GitHub touch', async () => {
89
+ const s = stub({ getRepo: () => ({}) });
90
+ try {
91
+ for (const bad of ['', 'no-slash', '-lead/x', 'a/b/c', 'owner/', 'javascript:alert(1)']) {
92
+ const res = fakeRes();
93
+ await getHandler()({ builder: { id: 1 }, query: { repo: bad } }, res);
94
+ assert.equal(res._status, 400, `ref ${JSON.stringify(bad)} must 400`);
95
+ assert.equal(res._body.error.code, 'bad_repo_ref');
96
+ }
97
+ assert.equal(s.calls.read.length, 0, 'GitHub is never consulted for a malformed ref');
98
+ } finally { s.restore(); }
99
+ });
100
+
101
+ test('GET /github/repo: GitHub\'s 404 forwards as repo_not_found — no leak beyond GitHub\'s own answer', async () => {
102
+ const s = stub({ getRepo: () => { throw new GithubApiError('nope', { status: 404, code: 'repo_not_found' }); } });
103
+ try {
104
+ const res = fakeRes();
105
+ await getHandler()({ builder: { id: 1 }, query: { repo: 'jaxri/gone' } }, res);
106
+ assert.equal(res._status, 404);
107
+ assert.equal(res._body.error.code, 'repo_not_found');
108
+ } finally { s.restore(); }
109
+ });
110
+
111
+ test('GET /github/repo: a GitHub 401 drops the stored token and re-prompts (the /github/repos convention)', async () => {
112
+ const s = stub({ getRepo: () => { throw new GithubApiError('bad', { status: 401, code: 'github_unauthorized' }); } });
113
+ try {
114
+ const res = fakeRes();
115
+ await getHandler()({ builder: { id: 1 }, query: { repo: 'jaxri/mercury' } }, res);
116
+ assert.equal(res._status, 403);
117
+ assert.equal(res._body.error.code, 'repo_scope_required');
118
+ assert.equal(s.calls.del, 1, 'the dead token is swept so the next attempt re-prompts cleanly');
119
+ } finally { s.restore(); }
120
+ });
121
+
122
+ test('POST visibility=private: refused 409 private_not_deployable — GitHub and the token store untouched', async () => {
123
+ const s = stub({ makePublic: () => ({}) });
124
+ const origGet = db.getGithubToken;
125
+ let tokenReads = 0;
126
+ db.getGithubToken = async () => { tokenReads++; return { token: 'tok', scope: 'repo' }; };
127
+ try {
128
+ const res = fakeRes();
129
+ await postHandler()({ builder: { id: 1 }, body: { repo: 'jaxri/mercury', visibility: 'private' } }, res);
130
+ assert.equal(res._status, 409);
131
+ assert.equal(res._body.error.code, 'private_not_deployable');
132
+ assert.match(res._body.error.message, /deploy/i, 'the refusal says WHY in plain words');
133
+ assert.equal(s.calls.flip.length, 0, 'no GitHub call for a refused direction');
134
+ assert.equal(tokenReads, 0, 'the refusal happens before the credential is even read');
135
+ } finally { db.getGithubToken = origGet; s.restore(); }
136
+ });
137
+
138
+ test('POST visibility=public: flips via makeRepoPublic and forwards GitHub\'s answer', async () => {
139
+ const s = stub({ makePublic: () => ({ full_name: 'jaxri/mercury', html_url: 'https://github.com/jaxri/mercury', private: false, admin: true }) });
140
+ try {
141
+ const res = fakeRes();
142
+ await postHandler()({ builder: { id: 1 }, body: { repo: 'jaxri/mercury', visibility: 'public' } }, res);
143
+ assert.equal(res._status, 200);
144
+ assert.equal(res._body.ok, true);
145
+ assert.equal(res._body.repo.private, false, 'the new state is GitHub\'s answer, never assumed');
146
+ assert.deepEqual(s.calls.flip, [{ owner: 'jaxri', name: 'mercury' }]);
147
+ } finally { s.restore(); }
148
+ });
149
+
150
+ test('POST visibility=public: GitHub\'s 403 becomes not_repo_admin, said plainly', async () => {
151
+ const s = stub({ makePublic: () => { throw new GithubApiError('no', { status: 403, code: 'not_repo_admin' }); } });
152
+ try {
153
+ const res = fakeRes();
154
+ await postHandler()({ builder: { id: 1 }, body: { repo: 'other/repo', visibility: 'public' } }, res);
155
+ assert.equal(res._status, 403);
156
+ assert.equal(res._body.error.code, 'not_repo_admin');
157
+ } finally { s.restore(); }
158
+ });
159
+
160
+ test('POST: an off-vocabulary visibility is a 400 (strict enum), never silently coerced', async () => {
161
+ const s = stub({ makePublic: () => ({}) });
162
+ try {
163
+ const res = fakeRes();
164
+ await postHandler()({ builder: { id: 1 }, body: { repo: 'jaxri/mercury', visibility: 'internal' } }, res);
165
+ assert.equal(res._status, 400);
166
+ assert.equal(s.calls.flip.length, 0);
167
+ } finally { s.restore(); }
168
+ });
@@ -114,3 +114,52 @@ test('createUserRepo: 422 → typed name_taken (the route maps this to a friendl
114
114
  (e) => e.code === 'name_taken' && e.status === 422,
115
115
  );
116
116
  });
117
+
118
+ // ---- repo-visibility helpers (task 1003045) ----
119
+
120
+ test('getUserRepo: asks /repos/{owner}/{name}, maps private + admin off GitHub\'s answer', async () => {
121
+ let calledUrl = '';
122
+ const fetchImpl = async (url) => {
123
+ calledUrl = url;
124
+ return res(200, {
125
+ full_name: 'me/mercury', html_url: 'https://github.com/me/mercury',
126
+ private: true, permissions: { admin: true, push: true },
127
+ });
128
+ };
129
+ const out = await gh.getUserRepo('tok', { owner: 'me', name: 'mercury', fetchImpl });
130
+ assert.match(calledUrl, /\/repos\/me\/mercury$/);
131
+ assert.deepEqual(out, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury', private: true, admin: true });
132
+ });
133
+
134
+ test('getUserRepo: 404 → typed repo_not_found (GitHub\'s own no-leak shape, forwarded)', async () => {
135
+ const fetchImpl = async () => res(404, { message: 'Not Found' });
136
+ await assert.rejects(
137
+ () => gh.getUserRepo('tok', { owner: 'me', name: 'gone', fetchImpl }),
138
+ (e) => e.code === 'repo_not_found' && e.status === 404,
139
+ );
140
+ });
141
+
142
+ test('makeRepoPublic: PATCHes exactly {private:false} — the private direction is structurally impossible', async () => {
143
+ // Like createUserRepo's private:false, the direction is deliberately not a
144
+ // parameter: the deploy pull runs as the shared deploy key that cannot read a
145
+ // private repo (N4), so nothing in the codebase may flip a repo private.
146
+ let sentMethod = '';
147
+ let sentBody = null;
148
+ const fetchImpl = async (url, init) => {
149
+ sentMethod = init.method;
150
+ sentBody = JSON.parse(init.body);
151
+ return res(200, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury', private: false, permissions: { admin: true } });
152
+ };
153
+ const out = await gh.makeRepoPublic('tok', { owner: 'me', name: 'mercury', fetchImpl });
154
+ assert.equal(sentMethod, 'PATCH');
155
+ assert.deepEqual(sentBody, { private: false }, 'only the public direction can ever travel');
156
+ assert.equal(out.private, false);
157
+ });
158
+
159
+ test('makeRepoPublic: 403 → typed not_repo_admin', async () => {
160
+ const fetchImpl = async () => res(403, { message: 'Must have admin rights' });
161
+ await assert.rejects(
162
+ () => gh.makeRepoPublic('tok', { owner: 'other', name: 'repo', fetchImpl }),
163
+ (e) => e.code === 'not_repo_admin' && e.status === 403,
164
+ );
165
+ });
@@ -977,3 +977,62 @@ test('every owned card carries its Manage door, and a modified click keeps its m
977
977
  assert.match(SCRIPT, /if \(!isPlainClick\(ev\)\) return;/,
978
978
  'ctrl/middle-click falls through to the browser');
979
979
  });
980
+
981
+ /* ── Repo visibility on the manage page (task 1003045) ──────────────────────
982
+ The section reads the repo's REAL visibility from GitHub with the owner's
983
+ own repository credential and offers exactly one direction: private→public.
984
+ The private direction is refused — server-side and here — until the
985
+ private-repo deploy fix lands, because the deploy pull runs as the shared
986
+ deploy key that cannot read a private repo (walk finding N4). ── */
987
+
988
+ test('the repo-visibility section is live markup with its body slot (C7)', () => {
989
+ assert.match(HUB, /id="mRepoVis"/, 'the section body must be real markup');
990
+ assert.match(HUB, /Checking the repository on GitHub…/,
991
+ 'the pre-answer state is honest waiting copy, not a fake answer');
992
+ });
993
+
994
+ test('flip-to-private cannot travel from this page — only the public direction exists', () => {
995
+ assert.match(SCRIPT, /visibility: 'public'/, 'the one POST body pins the public direction');
996
+ assert.doesNotMatch(SCRIPT, /visibility:\s*'private'/,
997
+ 'no code path may send the private direction');
998
+ // the refusal is SAID, with its reason, not silently missing
999
+ assert.match(SCRIPT, /can’t deploy private repositories/,
1000
+ 'the private-repo limitation is stated in plain words');
1001
+ });
1002
+
1003
+ test('the visibility shown is GitHub\'s answer, never an assumption', () => {
1004
+ const fn = (SCRIPT.match(/function doRepoPublic\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1005
+ assert.ok(fn, 'doRepoPublic() must be extractable');
1006
+ assert.match(fn, /manageRepo = data\.repo/,
1007
+ 'after a flip, the rendered state is what GitHub returned');
1008
+ const render = (SCRIPT.match(/function renderRepoVis\(repo\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1009
+ assert.ok(render, 'renderRepoVis() must be extractable');
1010
+ assert.match(render, /repo\.private/, 'the branch reads the live flag');
1011
+ assert.match(render, /repo\.admin/,
1012
+ 'the flip button only shows when GitHub says this account may change settings');
1013
+ });
1014
+
1015
+ test('the GitHub read fires on entry and tab return only — never from the tick\'s render', () => {
1016
+ assert.match(SCRIPT, /loadRepoVisibility\(inst\);/, 'the read rides loadManage\'s success');
1017
+ const render = (SCRIPT.match(/function renderManage\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1018
+ assert.ok(render, 'renderManage() must be extractable');
1019
+ assert.doesNotMatch(render, /loadRepoVisibility/,
1020
+ 'renderManage is what manageChipTick calls every minute — a read here would poll GitHub (C8)');
1021
+ });
1022
+
1023
+ test('the reconnect door asks for repository access and returns to this project', () => {
1024
+ const fn = (SCRIPT.match(/function repoVisReconnect\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1025
+ assert.ok(fn, 'repoVisReconnect() must be extractable');
1026
+ assert.match(fn, /repos=1/, 'the door carries the repo scope ask');
1027
+ assert.match(fn, /encodeURIComponent\('\/projects\?view=manage&id=' \+ manageId\)/,
1028
+ 'and returns to the exact project being managed');
1029
+ });
1030
+
1031
+ test('making a repo public asks first — exposure is never one accidental click', () => {
1032
+ const fn = (SCRIPT.match(/function askRepoPublic\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1033
+ assert.ok(fn, 'askRepoPublic() must be extractable');
1034
+ assert.match(fn, /Anyone on the internet will be able to read the code/,
1035
+ 'the confirm states the consequence in plain words');
1036
+ assert.match(SCRIPT, /data-repo-public-yes/, 'the confirm is a distinct second click');
1037
+ assert.match(SCRIPT, /data-repo-public-no/, 'and declining is offered, not implied');
1038
+ });
@@ -121,6 +121,16 @@ test('the manage view is served: identity block + honest not-yet sections (task
121
121
  assert.doesNotMatch(missing, /permission|allowed|access|owner/i);
122
122
  });
123
123
 
124
+ test('the repo-visibility section is served live, with the private direction refused (task 1003045)', async () => {
125
+ const { body } = await request('/projects', 'game.test.local');
126
+ assert.match(body, /id="mRepoVis"/, 'the live section body is served');
127
+ assert.match(body, /can’t deploy private repositories/,
128
+ 'the private-repo limitation is stated in plain words');
129
+ assert.doesNotMatch(body, /visibility:\s*'private'/,
130
+ 'no served code path may send the private direction');
131
+ assert.match(body, /repos=1/, 'the reconnect door asks for repository access');
132
+ });
133
+
124
134
  test('the empty state points a new owner at the wizard view — never at a hall', async () => {
125
135
  const { body } = await request('/projects', 'game.test.local');
126
136
  // /create was absorbed into ?view=new (task 1002763); the CTA moves in-page