@bongos/core 1.19.747 → 1.19.749
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 +25 -20
- package/docs/adr/0286-a-gated-project-s-manifest-tells-a-stranger-how-to-sign-in-and-nothing-else.md +44 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/upgrading-the-core.md +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/upgrade.js +36 -12
- package/src/bongos/platform-visibility-gate.js +1 -0
- package/src/bongos/routes/instance.js +73 -24
- package/src/module-api.js +1 -1
- package/tests/instance_manifest.mjs +81 -0
- package/tests/platform_visibility_gate.mjs +4 -0
- package/tests/upgrade.mjs +39 -1
- package/tests/upgrade_persist_pin.mjs +102 -1
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.749",
|
|
6
|
+
"core_contract": "1.19.749",
|
|
7
|
+
"source_commit": "238806532985c069a1b8a778e5a3d223cd6b01c4",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-15T22:00:53.285Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 482,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
14
|
"functional_verbatim": 2109,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2616,
|
|
20
|
+
"tree_sha256": "ddc2e6cffdc346ee09a971dd7f237459cf8c142ad8cf058845f48638fa01a3e7",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -1934,10 +1934,15 @@
|
|
|
1934
1934
|
"mode": "0000644",
|
|
1935
1935
|
"sha256": "f9141e74643a8055c3e7f7943957e1103d34b4673dd96c673636c30a50d47c36"
|
|
1936
1936
|
},
|
|
1937
|
+
{
|
|
1938
|
+
"path": "docs/adr/0286-a-gated-project-s-manifest-tells-a-stranger-how-to-sign-in-and-nothing-else.md",
|
|
1939
|
+
"mode": "0000644",
|
|
1940
|
+
"sha256": "df40904ac989d54ea6bea561649a35a98ec7dc1ba96ea65395cb314ffe2059a9"
|
|
1941
|
+
},
|
|
1937
1942
|
{
|
|
1938
1943
|
"path": "docs/adr/README.md",
|
|
1939
1944
|
"mode": "0000644",
|
|
1940
|
-
"sha256": "
|
|
1945
|
+
"sha256": "e7f895a2f6dc062acd2afcf2c477ab8e60f1cbc93a836d8759a47d204a55a450"
|
|
1941
1946
|
},
|
|
1942
1947
|
{
|
|
1943
1948
|
"path": "docs/api-reference.md",
|
|
@@ -2502,7 +2507,7 @@
|
|
|
2502
2507
|
{
|
|
2503
2508
|
"path": "docs/module-api-changelog.md",
|
|
2504
2509
|
"mode": "0000644",
|
|
2505
|
-
"sha256": "
|
|
2510
|
+
"sha256": "207ce89a17ab5bd75cb956f9e41497ef39eb10d4f59eb634f0d1c1933304ced0"
|
|
2506
2511
|
},
|
|
2507
2512
|
{
|
|
2508
2513
|
"path": "docs/modules-contract.md",
|
|
@@ -2762,7 +2767,7 @@
|
|
|
2762
2767
|
{
|
|
2763
2768
|
"path": "docs/recipes/upgrading-the-core.md",
|
|
2764
2769
|
"mode": "0000644",
|
|
2765
|
-
"sha256": "
|
|
2770
|
+
"sha256": "408b4792ffb869f2fa13201485c1ab5b8306b93996bb75f01b4117de3fd39303"
|
|
2766
2771
|
},
|
|
2767
2772
|
{
|
|
2768
2773
|
"path": "docs/recipes/windows-builders.md",
|
|
@@ -7557,12 +7562,12 @@
|
|
|
7557
7562
|
{
|
|
7558
7563
|
"path": "package-lock.json",
|
|
7559
7564
|
"mode": "0000644",
|
|
7560
|
-
"sha256": "
|
|
7565
|
+
"sha256": "1cf816c4ca4b4b56d3fefe33e86e8ce4caa511ee69fd8dd6c2ecc2940dc07f7f"
|
|
7561
7566
|
},
|
|
7562
7567
|
{
|
|
7563
7568
|
"path": "package.json",
|
|
7564
7569
|
"mode": "0000644",
|
|
7565
|
-
"sha256": "
|
|
7570
|
+
"sha256": "d890fe5b32e9b8158a19a40b765570972673f8558cb5d2e8e13ba396eee4476f"
|
|
7566
7571
|
},
|
|
7567
7572
|
{
|
|
7568
7573
|
"path": "public-docs/index.html",
|
|
@@ -8922,7 +8927,7 @@
|
|
|
8922
8927
|
{
|
|
8923
8928
|
"path": "scripts/gds/upgrade.js",
|
|
8924
8929
|
"mode": "0000644",
|
|
8925
|
-
"sha256": "
|
|
8930
|
+
"sha256": "fe3abe00b43163eb1b24dd2da57d4c8fd51194e79bab8a6683355980787a9e8c"
|
|
8926
8931
|
},
|
|
8927
8932
|
{
|
|
8928
8933
|
"path": "scripts/gds/validate-design.js",
|
|
@@ -9197,7 +9202,7 @@
|
|
|
9197
9202
|
{
|
|
9198
9203
|
"path": "src/bongos/platform-visibility-gate.js",
|
|
9199
9204
|
"mode": "0000644",
|
|
9200
|
-
"sha256": "
|
|
9205
|
+
"sha256": "f1563c32d78eb1c598f49c5b241f9709c4957bcc4e8319773cf6a2c212d936e4"
|
|
9201
9206
|
},
|
|
9202
9207
|
{
|
|
9203
9208
|
"path": "src/bongos/pool.js",
|
|
@@ -9277,7 +9282,7 @@
|
|
|
9277
9282
|
{
|
|
9278
9283
|
"path": "src/bongos/routes/instance.js",
|
|
9279
9284
|
"mode": "0000644",
|
|
9280
|
-
"sha256": "
|
|
9285
|
+
"sha256": "f5ab4f2ad7391cbbfc9504190fe8fee19e18b5b43532dd4ffd37df258591fe66"
|
|
9281
9286
|
},
|
|
9282
9287
|
{
|
|
9283
9288
|
"path": "src/bongos/routes/live.js",
|
|
@@ -9362,7 +9367,7 @@
|
|
|
9362
9367
|
{
|
|
9363
9368
|
"path": "src/module-api.js",
|
|
9364
9369
|
"mode": "0000644",
|
|
9365
|
-
"sha256": "
|
|
9370
|
+
"sha256": "6c9cce81108782c099d58259ce776bed22f815f7276fa9fb9a50264aeedd3835"
|
|
9366
9371
|
},
|
|
9367
9372
|
{
|
|
9368
9373
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11312,7 +11317,7 @@
|
|
|
11312
11317
|
{
|
|
11313
11318
|
"path": "tests/instance_manifest.mjs",
|
|
11314
11319
|
"mode": "0000644",
|
|
11315
|
-
"sha256": "
|
|
11320
|
+
"sha256": "c45a264128a2cdc55675dcab82f8510a0d64e4d81bf4fd3f528b1aa720bdace0"
|
|
11316
11321
|
},
|
|
11317
11322
|
{
|
|
11318
11323
|
"path": "tests/instance_mismatch_guard.mjs",
|
|
@@ -11817,7 +11822,7 @@
|
|
|
11817
11822
|
{
|
|
11818
11823
|
"path": "tests/platform_visibility_gate.mjs",
|
|
11819
11824
|
"mode": "0000644",
|
|
11820
|
-
"sha256": "
|
|
11825
|
+
"sha256": "ff9d438a33a32fecc04caa6e652eeaaded6394438bcec0d508ae7c516fa62333"
|
|
11821
11826
|
},
|
|
11822
11827
|
{
|
|
11823
11828
|
"path": "tests/post_login_landing.mjs",
|
|
@@ -12937,12 +12942,12 @@
|
|
|
12937
12942
|
{
|
|
12938
12943
|
"path": "tests/upgrade.mjs",
|
|
12939
12944
|
"mode": "0000644",
|
|
12940
|
-
"sha256": "
|
|
12945
|
+
"sha256": "bcbba7b1c952088f43300405184849bbcbb2da789097fcc4cf38d95441e0c158"
|
|
12941
12946
|
},
|
|
12942
12947
|
{
|
|
12943
12948
|
"path": "tests/upgrade_persist_pin.mjs",
|
|
12944
12949
|
"mode": "0000644",
|
|
12945
|
-
"sha256": "
|
|
12950
|
+
"sha256": "864b9471fcd6bfc41e99fd5356a3124c25d288946326c2797cc2bacb86120d05"
|
|
12946
12951
|
},
|
|
12947
12952
|
{
|
|
12948
12953
|
"path": "tests/verify_task_guard.mjs",
|
package/docs/adr/0286-a-gated-project-s-manifest-tells-a-stranger-how-to-sign-in-and-nothing-else.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# ADR 0286 — A gated project's manifest tells a stranger how to sign in, and nothing else
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-09-15
|
|
5
|
+
- **Task:** [1003652](https://cloudbongos.com/builders#/task/1003652) (goal 1000106), from idea 1000845. Closes the item [ADR 0192](0192-platform-visibility-member-door.md) deferred as "projecting the manifest down when gated".
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
A gated project (`platformVisibility: 'gated'`, ADR 0192) refuses strangers on every surface except a short exempt list. `GET /api/*/instance` is on that list for two reasons. A stranger reads it to learn how to sign in, and the platform's runner reads it cookie-less after a settings restart to confirm the value was consumed (`provision-units.js` `settingsConsumed`), including the push that turns the gate on.
|
|
10
|
+
|
|
11
|
+
The route still answered with the **full** manifest: every branding string (company, tagline, palette, rank labels), the module roster, the rank ladder, dev-box availability, and the repository's `owner/repo/branch/url`, even when the owner had made that repository private.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
The owner's call: **sign-in basics only.** When the project is gated **and** the caller has no live, non-deactivated session (cookie or Bearer, the same `resolveSession` lookup the member door uses), `routes/instance.js` answers with `strangerManifest()`:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
{ schema, name, brand: { identity: { productName }, project }, auth, admission }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Kept | Why |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `schema` | `bongos login` refuses any other id, so the value stays `instance-manifest/v1`: the stranger shape is a subset, and every kept field means what it means in the full manifest |
|
|
24
|
+
| `name`, `brand.identity.productName` | the door and the CLI name the project |
|
|
25
|
+
| `auth` including `idp` | the CLI routes device-flow and web sign-in through the hub from `auth.idp` |
|
|
26
|
+
| `admission` | `bongos login` tells an applicant invite-only from open |
|
|
27
|
+
| `brand.project` | the runner's settings read-back; the hub's `projects.html` (`platformVisibility`); `platform-identity/visibility-pull.js` (`visibility`); `bongos login` (`joinability`) |
|
|
28
|
+
|
|
29
|
+
- **Members** get the full manifest.
|
|
30
|
+
- **A session lookup that fails** gets the stranger answer rather than a 500. The manifest is how the door gets opened, and an unknown identity must not see a member's answer.
|
|
31
|
+
- **Every gated response** is sent with `Cache-Control: private, no-store` and `Vary: Cookie, Authorization`: one URL, two answers by identity. The route is exempt from the gate, so the gate's own `markPrivate` never runs on it.
|
|
32
|
+
- **Ungated projects** are unchanged.
|
|
33
|
+
|
|
34
|
+
## Alternatives considered
|
|
35
|
+
|
|
36
|
+
- **Gate the manifest like every other surface.** Rejected: the door could not be opened, and the runner could not confirm the push that gates the project.
|
|
37
|
+
- **A separate `/instance/sign-in` endpoint.** Rejected: two routes to keep in agreement, and every shipped CLI already reads `/instance`.
|
|
38
|
+
- **Project down for everyone, gated or not.** Rejected: an ungated project is public by the owner's choice, and the CLI's `devBox` and module checks read the full shape.
|
|
39
|
+
|
|
40
|
+
## Consequences
|
|
41
|
+
|
|
42
|
+
- **+** A stranger to a gated project no longer learns what it runs, how its ranks are named, what it looks like, or where its code lives.
|
|
43
|
+
- **−** `cli-lib.js` `devBoxEnabledInManifest` fails open (assumes a box exists) when `modules` is missing. That only happens on a session-less fetch; the CLI sends its bearer, so a signed-in member reads the real roster.
|
|
44
|
+
- **−** A gated project's manifest can no longer be cached at an edge. It is one small JSON read per sign-in.
|
package/docs/adr/README.md
CHANGED
|
@@ -377,3 +377,4 @@ This keeps the decision history honest and traceable.
|
|
|
377
377
|
| 0283 | [**The core publishes publicly, because access was never the thing protecting it** ([task 1003872](https://cloudbongos.com/builders#/task/1003872) · goal 1000090 — *Working area 4, Bongos Core distribution*; owner decision 2026-09-13, resolving [task 1003502](https://cloudbongos.com/builders#/task/1003502)). [ADR 0134](0134-private-first-npm-distribution.md) chose private-first and named "public at launch" as a later deliberate step; this is that step. The restricted posture had a cost that grew quietly because every symptom read as a different bug: a tokenless `npm install` returns a flat **E404**, indistinguishable from "no such package", so every co-tenant and downstream upgrade needed a credential to move at all; the nightly auto-upgrade sweep **died on 2026-08-25 and reported `failed=0`** (its unit passed no `NPM_TOKEN`, the env-fed `.npmrc` expanded to empty, and a skip is not a failure in that script's accounting, so nothing complained while the instance stopped receiving core updates); an Anthropic-hosted cloud session could not install the dependency at all; and a fresh scaffold's README step 1 IS `npm install`, which is also what installs the `bongos` command — so without a token an owner got no working instance rather than a stale one. [ADR 0258](0258-the-public-cli-is-a-generated-client-package-not-the-published-core.md)'s `@cloudbongos/cli`, require-closure delazification and all, exists ONLY because of this. **The decisive point is that access control was never what protected the artifact:** three gates decide what is IN the tarball — the in-build no-leak gate (branding literals + credential shapes, fail-closed, 2,624 files clean), the prior-instance leak scan (**fatal by default since [task 1003870](https://cloudbongos.com/builders#/task/1003870)**, `--no-leak-scan` the only escape), and the fixture confirmation of [task 1003871](https://cloudbongos.com/builders#/task/1003871) (all 14 credential-shape files checked against each provider's real key format: 11 too short to have ever been valid, 3 alphabet sequences or Amazon's own published doc example). `--access restricted` decides who may FETCH it; those three decide what is IN it, and conflating them is what made a launch-timing choice feel like a security control. **Decision: publish public — at all three sites.** `publishConfig.access` and `publishArgs` both say `public` (belt-and-braces of the restricted era pointed the other way, so neither drifting alone reverts it), **and so does `publish.yml`** — the site that ACTUALLY releases, `package-core --publish` being the manual owner path. Flipping only `package-core.js` would have left every real release restricted while the code claimed otherwise: exactly task 1003870's shape, where the workflow did not arm the gate its own header comment advertised. A test reads the workflow and fails if `restricted` reappears anywhere in the lane. **The one-time registry flip stays a separate owner step** (`npm access set status=public`): `--access` governs what THAT publish declares and has historically been a no-op when re-publishing an already-private package — the package read `private` on the registry when this was written — so it is verified by a tokenless install from a clean `HOME`, never by the flag's presence. **Provenance stays off:** the PACKAGE is public, the SOURCE REPO is not, and npm blocks provenance on the repo's visibility — turning it on before the source repository goes public fails every publish. The npm path deliberately does not run [ADR 0099](0099-delayed-redacted-mirror-export.md)'s mirror pipeline (`package-core` runs its own redaction: 476 docs redacted, 24 agent-docs stubbed), which is why gates 1–3 carry the whole safety burden and had to land first. **Going public is effectively one-way** — a published tarball is mirrored and cached beyond our control and `npm unpublish` has a 72-hour window that does not reach mirrors. **Does NOT fix the 22 co-tenants:** twenty pin `file:vendor/bongos-core-<v>.tgz`, a local path with no registry channel to follow, so public access is necessary but not sufficient. Rejected: a **rank-gated registry proxy** (server holds the token, builders authenticate with their existing Bongos session, per-request rank check — the RIGHT design if the core must stay private, rejected because it manages the problem going public deletes); **handing builders the read token gated by rank** (rank-gating the handout does not rank-gate the use — a token once held is held forever and revocation means rotating it for everyone: access control in name only); keeping the vendored tarball as the answer (it is why twenty co-tenants are frozen at their provisioning date); making the REPO public in the same change (a larger decision, not required for any of this, and separating them keeps the provenance question honest).](0283-the-core-publishes-publicly-because-access-was-never-the-thing-protecting-it.md) | core distribution / npm access |
|
|
378
378
|
| 0284 | [**The control plane carries no metrics stack** ([task 1003876](https://cloudbongos.com/builders#/task/1003876) · goal 1000106). Supersedes ADR 0127. Grafana (187 MB, the box's largest process) + Prometheus (110 MB, 2.5 GB data) were removed from the `s-1vcpu-2gb` control plane every provisioned project shares; they scraped none of those projects and nothing in the core read them. Owner chose remove over move-to-own-droplet. The `/metrics` exporter stays in every instance, so a scraper can return from a box that is not the control plane. Leftovers (DNS record, co-hosting-box socat forwarders, instance `infra/` copies) filed as a follow-up.](0284-the-control-plane-carries-no-metrics-stack.md) | infra / Cloud Bongos |
|
|
379
379
|
| 0285 | [**A shared box holds about twelve projects per GB, and memory is the wall** ([task 1003877](https://cloudbongos.com/builders#/task/1003877) · goal 1000106, a spike measured read-only on the live control plane at the owner's call). A project costs ~80 MB warm (70 MB Node + one busy Postgres backend); idle projects look like ~25 MB only because swap holds their cold pages, and swap is not capacity. Fixed base ~750 MB on a control plane. Ceiling per tier `floor((usable − fixed) / 80)`: 15 on today's `s-1vcpu-2gb` (11 live), 39 on `s-2vcpu-4gb`; resize at 12. RAM binds first, Postgres connections (pool default 10 × N vs 97) second; CPU, ports, units, disk and the backup window do not bind. Keep the three shapes, add placement ("standalone until the box is full, then the next box"); a shared slot costs box price ÷ ceiling ($0.62–0.80/mo), `dedicated` 8–40× that.](0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md) | infra / Cloud Bongos |
|
|
380
|
+
| 0286 | [**A gated project's manifest tells a stranger how to sign in, and nothing else** ([task 1003652](https://cloudbongos.com/builders#/task/1003652) · goal 1000106, from idea 1000845; closes ADR 0192's deferred item). `GET /instance` stays exempt from the member door, but on a gated project a caller with no live session gets `{ schema, name, brand: { identity.productName, project }, auth (incl. idp), admission }` — no modules, ranks, repo coordinates, dev-box flag or branding strings. Members get the full manifest; a failed lookup answers as a stranger; gated responses are `private, no-store` + `Vary: Cookie, Authorization`. Schema id stays `instance-manifest/v1` (a subset; `bongos login` refuses any other). Owner's call: sign-in basics only.](0286-a-gated-project-s-manifest-tells-a-stranger-how-to-sign-in-and-nothing-else.md) | security / Cloud Bongos |
|
|
@@ -1968,5 +1968,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1968
1968
|
landed since 1.19.745 with no explicit bump. run 35021312150. (task 1002620)
|
|
1969
1969
|
1.19.747 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1970
1970
|
landed since 1.19.746 with no explicit bump. run 35025151628. (task 1002620)
|
|
1971
|
+
1.19.748 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1972
|
+
landed since 1.19.747 with no explicit bump. run 35026862340. (task 1002620)
|
|
1973
|
+
1.19.749 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1974
|
+
landed since 1.19.748 with no explicit bump. run 35028684745. (task 1002620)
|
|
1971
1975
|
---------------------------------------------------------------------------
|
|
1972
1976
|
```
|
|
@@ -55,7 +55,7 @@ The instance runs on the droplet (co-tenant): systemd `emersonian-circles.servic
|
|
|
55
55
|
|
|
56
56
|
> **The pin MUST reach the repo of record, or the bump silently un-does itself** (task 1002712). A pull-deploy instance resets to `origin/main`; a pin that lives only in the working tree is reverted the next time anything lands, and the lockfile change then trips `npm ci`, which reinstalls the OLD core. Nothing errors. cloudbongos.com lost thirteen releases exactly this way — the `core_upgrades` ledger held two successful `auto-subscription (patch)` rows whose result no longer existed on disk, because deploy.sh short-circuits while `HEAD == origin/main` so the upgrade *looks* fine right up until someone else pushes.
|
|
57
57
|
>
|
|
58
|
-
> `bongos upgrade` now **warns loudly** whenever it leaves the pin uncommitted in a git repo, and `--commit-pin` (optionally `--git-remote <name>`) commits + pushes `package.json` + `package-lock.json` for you. A commit that fails to push is reported as a failure too — deploy.sh resets to the *remote*, so committing locally saves nothing. Verify durability by forcing a deploy tick (`sudo systemctl start <instance>-deploy.service`) and re-reading the pin.
|
|
58
|
+
> `bongos upgrade` now **warns loudly** whenever it leaves the pin uncommitted in a git repo, and `--commit-pin` (optionally `--git-remote <name>`) commits + pushes `package.json` + `package-lock.json` for you, together with any regenerated API spec, reference and typed client the bump rewrote (task 1003926 — left uncommitted, they made the next upgrade fail its clean-tree pre-flight). A commit that fails to push is reported as a failure too — deploy.sh resets to the *remote*, so committing locally saves nothing. Verify durability by forcing a deploy tick (`sudo systemctl start <instance>-deploy.service`) and re-reading the pin.
|
|
59
59
|
7. **Align the droplet checkout** — the droplet has no deploy key (`git pull` fails; idea 509), so `git bundle` the new commit on the laptop, `scp`, `git fetch <bundle> && git reset --hard FETCH_HEAD`. `node_modules` is already the new version, so no re-`npm ci`; a final restart makes `/version` report the new commit.
|
|
60
60
|
|
|
61
61
|
## Auto-rollback on failure (task 2149)
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.749",
|
|
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.749",
|
|
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.749",
|
|
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/scripts/gds/upgrade.js
CHANGED
|
@@ -157,6 +157,17 @@ function gitTreeClean(instanceDir, run = spawnSync) {
|
|
|
157
157
|
// Hence: --commit-pin lands it, and its absence WARNS rather than staying quiet.
|
|
158
158
|
const PIN_FILES = ['package.json', 'package-lock.json'];
|
|
159
159
|
|
|
160
|
+
// The tracked files regenerateApiArtifacts' generators rewrite WHOLE, by generator (task
|
|
161
|
+
// 1003926). Committing only the pin left these dirty, so the NEXT `bongos upgrade` refused
|
|
162
|
+
// its clean-tree pre-flight. They are generated end to end, so once a generator has run
|
|
163
|
+
// their content is its output whatever they held before: committing them can never carry
|
|
164
|
+
// an owner's edit. tests/upgrade_persist_pin.mjs pins this list against both generators.
|
|
165
|
+
const API_ARTIFACT_FILES = {
|
|
166
|
+
'gen-api-docs.js': ['docs/api/openapi.json', 'docs/api-reference.md', 'docs/routes-permissions.md'],
|
|
167
|
+
'gen-api-client.js': ['index.mjs', 'index.cjs', 'bongos-client.global.js', 'index.d.ts', 'package.json', 'README.md']
|
|
168
|
+
.map((f) => `clients/bongos-client/${f}`),
|
|
169
|
+
};
|
|
170
|
+
|
|
160
171
|
function gitCurrentBranch(instanceDir, run = spawnSync) {
|
|
161
172
|
const r = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: instanceDir, encoding: 'utf8' });
|
|
162
173
|
if (!r || r.error || r.status !== 0) return null;
|
|
@@ -164,13 +175,17 @@ function gitCurrentBranch(instanceDir, run = spawnSync) {
|
|
|
164
175
|
return b && b !== 'HEAD' ? b : null;
|
|
165
176
|
}
|
|
166
177
|
|
|
167
|
-
// Which of
|
|
168
|
-
|
|
169
|
-
|
|
178
|
+
// Which of `paths` git currently reports as changed — never a path it was not asked about,
|
|
179
|
+
// and no git call for an empty list (a pathspec of nothing is the whole tree).
|
|
180
|
+
function dirtyPaths(instanceDir, paths, run = spawnSync) {
|
|
181
|
+
if (!paths.length) return [];
|
|
182
|
+
const r = run('git', ['status', '--porcelain', '--', ...paths], { cwd: instanceDir, encoding: 'utf8' });
|
|
170
183
|
if (!r || r.error || r.status !== 0) return null; // not a git repo — nothing to persist
|
|
171
|
-
return (r.stdout || '').split('\n').map((l) => l.slice(3).trim()).filter(
|
|
184
|
+
return (r.stdout || '').split('\n').map((l) => l.slice(3).trim()).filter((p) => paths.includes(p));
|
|
172
185
|
}
|
|
173
186
|
|
|
187
|
+
function dirtyPinFiles(instanceDir, run = spawnSync) { return dirtyPaths(instanceDir, PIN_FILES, run); }
|
|
188
|
+
|
|
174
189
|
// ---- which credential the pin push rides (task 1003081, ADR 0176 follow-up) ----
|
|
175
190
|
//
|
|
176
191
|
// THE BUG THIS EXISTS FOR. A PRIVATE standalone instance's `origin` authenticates with
|
|
@@ -285,11 +300,16 @@ function gitSaid(r) {
|
|
|
285
300
|
// GitHub token over HTTPS (task 1003081); null pushes via `remote`. The result carries
|
|
286
301
|
// `via: 'owner-token'` on the token path and `refused` ('read-only-deploy-key' |
|
|
287
302
|
// 'owner-token') with a plain-sentence `error` when GitHub turned the push away.
|
|
288
|
-
|
|
303
|
+
// `artifactPaths` (task 1003926) are the generated files this upgrade rewrote; the ones
|
|
304
|
+
// that changed are staged and committed with the pin.
|
|
305
|
+
function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = true, remote = 'origin', dryRun = false, pushAuth = null, artifactPaths = [] },
|
|
289
306
|
run = spawnSync, log = console.log, err = console.error) {
|
|
290
|
-
const
|
|
291
|
-
if (
|
|
307
|
+
const dirtyPins = dirtyPinFiles(instanceDir, run);
|
|
308
|
+
if (dirtyPins === null) return { applicable: false, reason: 'not a git repo' };
|
|
309
|
+
const dirtyArtifacts = dirtyPaths(instanceDir, artifactPaths, run) || [];
|
|
310
|
+
const dirty = [...dirtyPins, ...dirtyArtifacts];
|
|
292
311
|
if (!dirty.length) return { applicable: false, reason: 'pin files already match the repo' };
|
|
312
|
+
const toStage = [...PIN_FILES, ...dirtyArtifacts];
|
|
293
313
|
|
|
294
314
|
const branch = gitCurrentBranch(instanceDir, run);
|
|
295
315
|
const target = `HEAD:${branch || 'main'}`;
|
|
@@ -314,7 +334,7 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
|
|
|
314
334
|
err(' On a pull-deploy instance the next push to the repo of record makes deploy.sh');
|
|
315
335
|
err(` 'git reset --hard ${remote}/${branch || 'main'}', which REVERTS this pin and lets npm ci`);
|
|
316
336
|
err(` reinstall ${fromVersion || 'the previous core'}. Re-run with --commit-pin, or commit + push:`);
|
|
317
|
-
err(` ${at} add ${
|
|
337
|
+
err(` ${at} add ${toStage.map(q).join(' ')} &&${at} commit -m ${q(`pin @bongos/core ${toVersion}`)} && ${at} push ${q(remote)} ${q(target)}`);
|
|
318
338
|
return { applicable: true, committed: false, pushed: false, dirty, warned: true };
|
|
319
339
|
}
|
|
320
340
|
|
|
@@ -326,7 +346,7 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
|
|
|
326
346
|
}
|
|
327
347
|
|
|
328
348
|
const message = `pin @bongos/core ${toVersion}${fromVersion ? ` (was ${fromVersion})` : ''}`;
|
|
329
|
-
const add = run('git', ['add', ...
|
|
349
|
+
const add = run('git', ['add', ...toStage], { cwd: instanceDir, encoding: 'utf8' });
|
|
330
350
|
if (!add || add.error || add.status !== 0) {
|
|
331
351
|
err(' ! could not stage the pin files — commit + push them by hand or the next deploy reverts this bump.');
|
|
332
352
|
return { applicable: true, committed: false, pushed: false, dirty, error: 'git add failed' };
|
|
@@ -572,7 +592,7 @@ function resolveInstanceDb({ instanceDir }, run = spawnSync) {
|
|
|
572
592
|
function regenerateApiArtifacts({ instanceDir, coreRoot }, run = spawnSync, fsImpl = fs) {
|
|
573
593
|
const env = { ...process.env, BONGOS_INSTANCE_ROOT: instanceDir };
|
|
574
594
|
const ran = [];
|
|
575
|
-
for (const gen of
|
|
595
|
+
for (const gen of Object.keys(API_ARTIFACT_FILES)) {
|
|
576
596
|
const script = path.join(coreRoot, 'scripts', 'gds', gen);
|
|
577
597
|
if (!fsImpl.existsSync(script)) return { ok: false, ran, reason: `${gen} not found in core (${coreRoot})` };
|
|
578
598
|
const r = run(process.execPath, [script], { cwd: instanceDir, env, stdio: 'inherit' });
|
|
@@ -1185,9 +1205,11 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
1185
1205
|
// 5b. regenerate the per-instance API artifacts (OpenAPI spec + typed client) from the
|
|
1186
1206
|
// new core's routes — the claude-materialize sibling (task 2051). Best-effort: a regen
|
|
1187
1207
|
// hiccup is a warning, not a failed bump (the next ship's fitness gate refreshes them).
|
|
1208
|
+
let regenArtifacts = [];
|
|
1188
1209
|
if (!opts.skipRegenDocs) {
|
|
1189
1210
|
const coreRoot = path.join(instanceDir, 'node_modules', CORE_PKG);
|
|
1190
1211
|
const regen = (deps.regenerateApiArtifacts || regenerateApiArtifacts)({ instanceDir, coreRoot }, run);
|
|
1212
|
+
regenArtifacts = (regen.ran || []).flatMap((gen) => API_ARTIFACT_FILES[gen] || []);
|
|
1191
1213
|
if (regen.ok) log(` ✓ regenerated API docs + typed client from core ${targetVersion}`);
|
|
1192
1214
|
else err(` ! API-artifact regen incomplete (${regen.reason}) — ran ${regen.ran.join(', ') || 'nothing'}; the next ship's fitness gate refreshes them.`);
|
|
1193
1215
|
// 5c. regenerate the whole-file navigation docs (repo-map + session-index) from the new
|
|
@@ -1301,7 +1323,8 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
1301
1323
|
// actually coming: --commit-pin without --pin-no-push, not a dry run, and the pin
|
|
1302
1324
|
// files really are dirty. Nothing reads a credential it will not spend (task 1003081).
|
|
1303
1325
|
const remote = opts.gitRemote || 'origin';
|
|
1304
|
-
const pushComing = !!opts.commitPin && !opts.pinNoPush && !dryRun
|
|
1326
|
+
const pushComing = !!opts.commitPin && !opts.pinNoPush && !dryRun
|
|
1327
|
+
&& ((dirtyPinFiles(instanceDir, run) || []).length > 0 || (dirtyPaths(instanceDir, regenArtifacts, run) || []).length > 0);
|
|
1305
1328
|
const pushAuth = pushComing ? await (deps.resolvePinPushAuth || resolvePinPushAuth)({ instanceDir, remote }, deps, run) : null;
|
|
1306
1329
|
const pin = persistPin({
|
|
1307
1330
|
instanceDir,
|
|
@@ -1312,6 +1335,7 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
1312
1335
|
remote,
|
|
1313
1336
|
dryRun: opts.dryRun,
|
|
1314
1337
|
pushAuth,
|
|
1338
|
+
artifactPaths: regenArtifacts,
|
|
1315
1339
|
}, run, log, err);
|
|
1316
1340
|
|
|
1317
1341
|
const ok = versionOk && healthOk !== false && servedOk !== false;
|
|
@@ -1394,7 +1418,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
1394
1418
|
module.exports = {
|
|
1395
1419
|
runUpgrade, parseArgs, main, rollback,
|
|
1396
1420
|
versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
|
|
1397
|
-
vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity,
|
|
1421
|
+
vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, API_ARTIFACT_FILES, preflightDbIdentity,
|
|
1398
1422
|
resolveInstanceDb, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth, deriveVersionUrl, pollServedVersion,
|
|
1399
1423
|
resolvePinPushAuth, remoteGithubRepo, pinnedDeployKey, // task 1003081 — the pin push prefers the owner's GitHub token (ADR 0176 follow-up)
|
|
1400
1424
|
readManifest, resolveReferenceManifest, verifyInstalledPin, // task 1002216 (audit H8) — integrity-pin verification
|
|
@@ -56,6 +56,7 @@ const API = `(?:${ALL_API_PREFIXES.map(escRe).join('|')})`;
|
|
|
56
56
|
// value was consumed (provision-units.js settingsConsumed)
|
|
57
57
|
// — including the very push that turns this gate on. It
|
|
58
58
|
// carries the policy itself, public by nature (ADR 0190 §9).
|
|
59
|
+
// A stranger gets its sign-in projection only (ADR 0286).
|
|
59
60
|
// <api>/healthz, /healthz, /version, /metrics
|
|
60
61
|
// the probes: the deploy's, the runner's, the uptime
|
|
61
62
|
// poller's, the control plane's liveness sweep; Prometheus
|
|
@@ -26,14 +26,22 @@
|
|
|
26
26
|
// hosts, no DB name, no env prefix, no tokens. The assembler (buildInstanceManifest)
|
|
27
27
|
// is PURE — all inputs injected — so a unit test proves the shape + the no-leak
|
|
28
28
|
// guarantee with zero I/O (mirrors resolveBranding/resolveModules, ADR 0062 §3/§4).
|
|
29
|
+
//
|
|
30
|
+
// GATED PROJECTS (task 1003652, ADR 0286): the route stays exempt from the member
|
|
31
|
+
// door (ADR 0192) because a stranger needs it to sign in, but a caller with no live
|
|
32
|
+
// session on a gated project gets strangerManifest() — sign-in basics only, not
|
|
33
|
+
// what the project runs or where its code lives. Members get the full manifest.
|
|
29
34
|
|
|
30
35
|
const express = require('express');
|
|
31
36
|
const { corsPublicGet, asyncHandler } = require('./_helpers');
|
|
32
37
|
const { clientBranding } = require('../../branding');
|
|
33
38
|
const { clientModules, isModuleEnabled } = require('../../modules');
|
|
34
39
|
const { loadRepoInfo } = require('../repo-info');
|
|
35
|
-
const { authConfigured, openEnrollmentEnabled, loadIdpConfig } = require('../auth');
|
|
40
|
+
const { authConfigured, openEnrollmentEnabled, loadIdpConfig, resolveSession } = require('../auth');
|
|
36
41
|
const { RANK_TIER } = require('../permission-path-check');
|
|
42
|
+
const { isGated } = require('../platform-visibility-gate');
|
|
43
|
+
|
|
44
|
+
const log = require('../logger').logger.child({ src: 'instance' });
|
|
37
45
|
|
|
38
46
|
// The manifest format id — bumped when the SHAPE changes so a CLI can detect a
|
|
39
47
|
// too-new server. Deliberately neutral (no instance/platform identity literal).
|
|
@@ -105,7 +113,61 @@ function buildInstanceManifest({
|
|
|
105
113
|
};
|
|
106
114
|
}
|
|
107
115
|
|
|
108
|
-
|
|
116
|
+
// strangerManifest — PURE. What a gated project tells a caller with no live session:
|
|
117
|
+
// enough to sign in (name, auth incl. the idp hint, admission) plus brand.project,
|
|
118
|
+
// which the platform runner reads back cookie-less after a settings restart. Same
|
|
119
|
+
// schema id — every kept field means what it does in the full manifest.
|
|
120
|
+
function strangerManifest(manifest) {
|
|
121
|
+
const { schema, name, brand, auth, admission } = manifest;
|
|
122
|
+
return {
|
|
123
|
+
schema,
|
|
124
|
+
name,
|
|
125
|
+
brand: { identity: { productName: name }, project: brand && brand.project },
|
|
126
|
+
auth,
|
|
127
|
+
admission,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function currentManifest() {
|
|
132
|
+
const idpCfg = loadIdpConfig();
|
|
133
|
+
return buildInstanceManifest({
|
|
134
|
+
branding: clientBranding(),
|
|
135
|
+
modules: clientModules(),
|
|
136
|
+
rankTier: RANK_TIER,
|
|
137
|
+
repo: loadRepoInfo(),
|
|
138
|
+
authConfigured: authConfigured(),
|
|
139
|
+
// The COMPOSED join door, not the raw `joinability` knob (task 1003579):
|
|
140
|
+
// `admission.policy` says whether a stranger can actually sign themselves
|
|
141
|
+
// in, so a project that is off the map reports 'invite-only' even while its
|
|
142
|
+
// `joinability` reads back 'open' under brand.project. The two fields answer
|
|
143
|
+
// different questions on purpose — one is the owner's setting, the other is
|
|
144
|
+
// what the gate will do — and 'invite-only' already covers every non-open
|
|
145
|
+
// door, so narrowing it names no state (ADR 0247 D3).
|
|
146
|
+
openEnrollment: openEnrollmentEnabled(),
|
|
147
|
+
devBoxEnabled: isModuleEnabled('dev-box'),
|
|
148
|
+
// Project OUT the client secret — the manifest is public; only origin +
|
|
149
|
+
// client_id (both public) advertise federation.
|
|
150
|
+
idp: idpCfg ? { origin: idpCfg.origin, client_id: idpCfg.clientId } : null,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A failed lookup answers as a stranger rather than a 500: the manifest is how the
|
|
155
|
+
// door gets opened, and an unknown identity must not see a member's answer.
|
|
156
|
+
async function isMember(req, lookup) {
|
|
157
|
+
try {
|
|
158
|
+
const session = await lookup(req);
|
|
159
|
+
return !!session && session.status !== 'inactive';
|
|
160
|
+
} catch (err) {
|
|
161
|
+
log.error({ err: err && err.message }, 'session lookup failed — answering as a stranger');
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// `deps` exists for tests only (the platformVisibilityGate shape).
|
|
167
|
+
module.exports = function buildInstanceRouter(deps = {}) {
|
|
168
|
+
const gated = deps.isGated || isGated;
|
|
169
|
+
const lookup = deps.resolveSession || resolveSession;
|
|
170
|
+
const buildManifest = deps.buildManifest || currentManifest;
|
|
109
171
|
const router = express.Router();
|
|
110
172
|
|
|
111
173
|
// CORS for the unauthenticated cross-origin read (same posture as /public/*
|
|
@@ -116,33 +178,20 @@ module.exports = function buildInstanceRouter() {
|
|
|
116
178
|
// fetches it to learn how to sign in). asyncHandler returns a 500 with no
|
|
117
179
|
// err.message if branding/modules fail to resolve (misconfigured instance) —
|
|
118
180
|
// never leaks internals.
|
|
119
|
-
router.get('/instance', asyncHandler('GET /instance', (
|
|
120
|
-
const
|
|
121
|
-
res.json(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
authConfigured: authConfigured(),
|
|
127
|
-
// The COMPOSED join door, not the raw `joinability` knob (task 1003579):
|
|
128
|
-
// `admission.policy` says whether a stranger can actually sign themselves
|
|
129
|
-
// in, so a project that is off the map reports 'invite-only' even while its
|
|
130
|
-
// `joinability` reads back 'open' under brand.project. The two fields answer
|
|
131
|
-
// different questions on purpose — one is the owner's setting, the other is
|
|
132
|
-
// what the gate will do — and 'invite-only' already covers every non-open
|
|
133
|
-
// door, so narrowing it names no state (ADR 0247 D3).
|
|
134
|
-
openEnrollment: openEnrollmentEnabled(),
|
|
135
|
-
devBoxEnabled: isModuleEnabled('dev-box'),
|
|
136
|
-
// Project OUT the client secret — the manifest is public; only origin +
|
|
137
|
-
// client_id (both public) advertise federation.
|
|
138
|
-
idp: idpCfg ? { origin: idpCfg.origin, client_id: idpCfg.clientId } : null,
|
|
139
|
-
}));
|
|
181
|
+
router.get('/instance', asyncHandler('GET /instance', async (req, res) => {
|
|
182
|
+
const manifest = buildManifest();
|
|
183
|
+
if (!gated()) return res.json(manifest);
|
|
184
|
+
// One URL, two answers by identity: no shared cache may keep either.
|
|
185
|
+
res.set('Cache-Control', 'private, no-store');
|
|
186
|
+
res.vary('Cookie').vary('Authorization');
|
|
187
|
+
res.json(await isMember(req, lookup) ? manifest : strangerManifest(manifest));
|
|
140
188
|
}, { message: false, errorCode: 'instance_manifest_failed' }));
|
|
141
189
|
|
|
142
190
|
return router;
|
|
143
191
|
};
|
|
144
192
|
|
|
145
|
-
// Exported for unit tests (the pure
|
|
193
|
+
// Exported for unit tests (the pure assemblers) + the schema/auth constants.
|
|
146
194
|
module.exports.buildInstanceManifest = buildInstanceManifest;
|
|
195
|
+
module.exports.strangerManifest = strangerManifest;
|
|
147
196
|
module.exports.MANIFEST_SCHEMA = MANIFEST_SCHEMA;
|
|
148
197
|
module.exports.AUTH_METHODS = AUTH_METHODS;
|
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.749'; // 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');
|
|
@@ -116,3 +116,84 @@ test('manifest: vanilla instance is fully populated (every field present)', () =
|
|
|
116
116
|
assert.equal(m.name, 'Cloud Bongos');
|
|
117
117
|
assert.deepEqual(m.modules.enabled, MODULES_VANILLA.enabled);
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
// ── task 1003652: a gated project tells a stranger only what sign-in needs ──
|
|
121
|
+
const express = require('express');
|
|
122
|
+
const http = require('node:http');
|
|
123
|
+
const buildInstanceRouter = instanceMod;
|
|
124
|
+
import { strangerManifest } from '../src/bongos/routes/instance.js';
|
|
125
|
+
|
|
126
|
+
const IDP = { origin: 'https://hub.example.com', client_id: 'proj-1' };
|
|
127
|
+
const FULL = () => buildInstanceManifest({ branding: BRAND_OTB, modules: MODULES_OTB, rankTier: RANK_TIER, repo: REPO, authConfigured: false, openEnrollment: false, devBoxEnabled: true, idp: IDP });
|
|
128
|
+
|
|
129
|
+
test('stranger manifest: schema, name, brand.project, auth (idp kept), admission — and nothing else', () => {
|
|
130
|
+
const s = strangerManifest(FULL());
|
|
131
|
+
assert.deepEqual(Object.keys(s).sort(), ['admission', 'auth', 'brand', 'name', 'schema']);
|
|
132
|
+
assert.equal(s.schema, MANIFEST_SCHEMA, '`bongos login` refuses any other schema id');
|
|
133
|
+
assert.equal(s.name, 'Example World');
|
|
134
|
+
assert.deepEqual(s.brand, { identity: { productName: 'Example World' }, project: BRAND_OTB.project },
|
|
135
|
+
'brand.project stays: the runner reads it back after a settings restart, the hub and login read it too');
|
|
136
|
+
assert.deepEqual(s.auth, { methods: AUTH_METHODS, configured: true, idp: IDP });
|
|
137
|
+
assert.deepEqual(s.admission, { policy: 'invite-only' });
|
|
138
|
+
const json = JSON.stringify(s);
|
|
139
|
+
for (const gone of ['example-owner', 'github.com', 'Straight example world', 'Hermeslines', 'drachmae', 'art-pipeline', 'Archon', 'devBox']) {
|
|
140
|
+
assert.ok(!json.includes(gone), `a stranger must not read "${gone}"`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('stranger manifest: a brand with no project policy projects none', () => {
|
|
145
|
+
const s = strangerManifest(buildInstanceManifest({ branding: BRAND_VANILLA, modules: MODULES_VANILLA, rankTier: RANK_TIER, repo: { error: 'x' }, authConfigured: false, openEnrollment: true, devBoxEnabled: false }));
|
|
146
|
+
assert.deepEqual(JSON.parse(JSON.stringify(s.brand)), { identity: { productName: 'Cloud Bongos' } });
|
|
147
|
+
assert.equal(s.admission.policy, 'open');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
async function getInstance(deps) {
|
|
151
|
+
const app = express();
|
|
152
|
+
app.use(buildInstanceRouter({ buildManifest: FULL, ...deps }));
|
|
153
|
+
const server = http.createServer(app);
|
|
154
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
155
|
+
try {
|
|
156
|
+
return await new Promise((resolve, reject) => {
|
|
157
|
+
http.get({ host: '127.0.0.1', port: server.address().port, path: '/instance', agent: false }, (res) => {
|
|
158
|
+
let data = '';
|
|
159
|
+
res.on('data', (c) => { data += c; });
|
|
160
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: JSON.parse(data) }));
|
|
161
|
+
}).on('error', reject);
|
|
162
|
+
});
|
|
163
|
+
} finally {
|
|
164
|
+
await new Promise((r) => server.close(r));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
test('route: an ungated project serves the full manifest and never looks the caller up', async () => {
|
|
169
|
+
let looked = 0;
|
|
170
|
+
const res = await getInstance({ isGated: () => false, resolveSession: async () => { looked += 1; return null; } });
|
|
171
|
+
assert.equal(res.status, 200);
|
|
172
|
+
assert.deepEqual(res.body, JSON.parse(JSON.stringify(FULL())));
|
|
173
|
+
assert.equal(looked, 0);
|
|
174
|
+
assert.equal(res.headers['cache-control'], undefined, 'the public answer keeps its caching');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('route: gated + no session → the stranger projection, uncacheable and varied on identity', async () => {
|
|
178
|
+
const res = await getInstance({ isGated: () => true, resolveSession: async () => null });
|
|
179
|
+
assert.equal(res.status, 200, 'the manifest stays public — it is how the door gets opened');
|
|
180
|
+
assert.deepEqual(res.body, JSON.parse(JSON.stringify(strangerManifest(FULL()))));
|
|
181
|
+
assert.equal(res.headers['cache-control'], 'private, no-store');
|
|
182
|
+
assert.equal(res.headers.vary, 'Cookie, Authorization');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('route: gated + a live member session → the full manifest, still uncacheable', async () => {
|
|
186
|
+
const res = await getInstance({ isGated: () => true, resolveSession: async () => ({ builder_id: 7, status: 'active' }) });
|
|
187
|
+
assert.deepEqual(res.body, JSON.parse(JSON.stringify(FULL())));
|
|
188
|
+
assert.equal(res.headers['cache-control'], 'private, no-store');
|
|
189
|
+
assert.equal(res.headers.vary, 'Cookie, Authorization');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('route: gated + a deactivated session, or a lookup that fails → the stranger answer, never a 500', async () => {
|
|
193
|
+
const stranger = JSON.parse(JSON.stringify(strangerManifest(FULL())));
|
|
194
|
+
const inactive = await getInstance({ isGated: () => true, resolveSession: async () => ({ builder_id: 7, status: 'inactive' }) });
|
|
195
|
+
assert.deepEqual(inactive.body, stranger);
|
|
196
|
+
const failed = await getInstance({ isGated: () => true, resolveSession: async () => { throw new Error('db down'); } });
|
|
197
|
+
assert.equal(failed.status, 200);
|
|
198
|
+
assert.deepEqual(failed.body, stranger);
|
|
199
|
+
});
|
|
@@ -309,6 +309,10 @@ test('booted gated: the door, the manifest, the probes and the downloads still a
|
|
|
309
309
|
assert.equal(m.status, 200, `${p}: the manifest stays public`);
|
|
310
310
|
const j = JSON.parse(m.body);
|
|
311
311
|
assert.equal(j.brand.project.platformVisibility, 'gated', 'and it carries the policy itself');
|
|
312
|
+
// …and only what sign-in needs (task 1003652)
|
|
313
|
+
assert.equal(j.modules, undefined, `${p}: a stranger is not told what the project runs`);
|
|
314
|
+
assert.equal(j.repo, undefined, `${p}: nor where its code lives`);
|
|
315
|
+
assert.equal(m.headers['cache-control'], 'private, no-store');
|
|
312
316
|
}
|
|
313
317
|
// the sign-in start must be reachable: it reaches the auth router (which may
|
|
314
318
|
// refuse for its own reasons — a missing OAuth app — but never with the door)
|
package/tests/upgrade.mjs
CHANGED
|
@@ -228,7 +228,9 @@ t('full: pre-flight → pin → install → migrate → materialize → restart
|
|
|
228
228
|
// pin is committed (task 1002712). It runs LAST — after verify — and without
|
|
229
229
|
// --commit-pin it only warns, so no git add/commit/push appears here.
|
|
230
230
|
['git status --porcelain', 'npm install --no-audit --no-fund', 'npm run migrate', 'systemctl restart emersonian.service',
|
|
231
|
-
'git status --porcelain -- package.json package-lock.json'
|
|
231
|
+
'git status --porcelain -- package.json package-lock.json',
|
|
232
|
+
// task 1003926: both generators ran, so the files they rewrote are the pin commit's too
|
|
233
|
+
`git status --porcelain -- ${Object.values(u.API_ARTIFACT_FILES).flat().join(' ')}`]
|
|
232
234
|
);
|
|
233
235
|
// materialize pointed at the freshly-installed core
|
|
234
236
|
assert.ok(materialized && materialized.coreRoot.endsWith(join('node_modules', '@bongos', 'core')));
|
|
@@ -1676,6 +1678,42 @@ t('runUpgrade: the token store is consulted only when a push is actually coming'
|
|
|
1676
1678
|
}
|
|
1677
1679
|
});
|
|
1678
1680
|
|
|
1681
|
+
// task 1003926 — the pin commit carries the files the generators that RAN rewrote, and no others.
|
|
1682
|
+
t('runUpgrade --commit-pin --pin-no-push: a generator that did not run contributes nothing to the pin commit', async () => {
|
|
1683
|
+
const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
|
|
1684
|
+
const src = mkdtempSync(join(tmpdir(), 'otb-upgrade-src-'));
|
|
1685
|
+
writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
|
|
1686
|
+
const calls = [];
|
|
1687
|
+
const changed = ['package.json', 'package-lock.json', 'docs/api/openapi.json', 'clients/bongos-client/package.json'];
|
|
1688
|
+
const run = (cmd, args) => {
|
|
1689
|
+
calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
|
|
1690
|
+
if (cmd === 'git' && args[0] === 'status') {
|
|
1691
|
+
if (!args.includes('--')) return { status: 0, stdout: '' }; // pre-flight: clean
|
|
1692
|
+
const asked = args.slice(args.indexOf('--') + 1);
|
|
1693
|
+
return { status: 0, stdout: changed.filter((p) => asked.includes(p)).map((p) => ` M ${p}\n`).join('') };
|
|
1694
|
+
}
|
|
1695
|
+
if (cmd === 'git' && args[0] === 'rev-parse') return { status: 0, stdout: 'main\n' };
|
|
1696
|
+
if (cmd === 'npm' && args[0] === 'install') {
|
|
1697
|
+
writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'), JSON.stringify({ name: '@bongos/core', version: '1.15.0' }) + '\n');
|
|
1698
|
+
}
|
|
1699
|
+
return { status: 0 };
|
|
1700
|
+
};
|
|
1701
|
+
const res = await u.runUpgrade(
|
|
1702
|
+
{ to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'acme.service', healthCheck: false, commitPin: true, pinNoPush: true },
|
|
1703
|
+
{
|
|
1704
|
+
log: hush, err: hush, run, materialize: () => ({}),
|
|
1705
|
+
// the client generator failed, so its files are not the upgrade's output
|
|
1706
|
+
regenerateApiArtifacts: () => ({ ok: false, ran: ['gen-api-docs.js'], reason: 'gen-api-client.js exited 1' }),
|
|
1707
|
+
databaseUrl: '', pgDatabase: '',
|
|
1708
|
+
},
|
|
1709
|
+
);
|
|
1710
|
+
assert.equal(res.ok, true, `upgrade should succeed: ${res.error}`);
|
|
1711
|
+
assert.equal(res.pin && res.pin.committed, true);
|
|
1712
|
+
assert.ok(calls.includes('git add package.json package-lock.json docs/api/openapi.json'),
|
|
1713
|
+
`got ${JSON.stringify(calls.filter((c) => c.startsWith('git add')))}`);
|
|
1714
|
+
assert.ok(!calls.some((c) => c.startsWith('git push')), '--pin-no-push');
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1679
1717
|
await Promise.all(pending);
|
|
1680
1718
|
console.log(`\nupgrade: ${passed} passed, ${failed} failed`);
|
|
1681
1719
|
process.exit(failed ? 1 : 0);
|
|
@@ -21,9 +21,14 @@
|
|
|
21
21
|
import { test } from 'node:test';
|
|
22
22
|
import assert from 'node:assert/strict';
|
|
23
23
|
import { createRequire } from 'node:module';
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { join, dirname } from 'node:path';
|
|
28
|
+
import { API_ARTIFACT_FILES } from '../scripts/gds/upgrade.js';
|
|
24
29
|
|
|
25
30
|
const require = createRequire(import.meta.url);
|
|
26
|
-
const { persistPin, dirtyPinFiles, preflightDbIdentity, PIN_FILES } = require('../scripts/gds/upgrade.js');
|
|
31
|
+
const { persistPin, dirtyPinFiles, preflightDbIdentity, gitTreeClean, PIN_FILES } = require('../scripts/gds/upgrade.js');
|
|
27
32
|
|
|
28
33
|
// A fake `run` driven by a table of [matcher, result]. Records every invocation.
|
|
29
34
|
function fakeRun(handlers = {}) {
|
|
@@ -250,3 +255,99 @@ test('dirtyPinFiles only ever reports the pin files', () => {
|
|
|
250
255
|
const statusCall = run.calls.find((c) => c.startsWith('git status'));
|
|
251
256
|
assert.match(statusCall, /--\s+package\.json package-lock\.json$/, 'pathspec-limited');
|
|
252
257
|
});
|
|
258
|
+
|
|
259
|
+
// ── the upgrade's own regenerated files ride the pin commit (task 1003926) ───────
|
|
260
|
+
//
|
|
261
|
+
// regenerateApiArtifacts rewrites the instance's TRACKED spec, reference and typed client
|
|
262
|
+
// WHOLE. With only the pin committed they stayed dirty, and the next `bongos upgrade`
|
|
263
|
+
// refused its clean-tree pre-flight: the stranding the pin commit exists to prevent.
|
|
264
|
+
|
|
265
|
+
const ARTIFACTS = Object.values(API_ARTIFACT_FILES).flat();
|
|
266
|
+
// A `git status --porcelain -- <paths>` fake that reports only what was asked AND changed.
|
|
267
|
+
const statusOf = (changed) => (args) => {
|
|
268
|
+
const asked = args.slice(args.indexOf('--') + 1);
|
|
269
|
+
return { status: 0, stdout: changed.filter((p) => asked.includes(p)).map((p) => ` M ${p}\n`).join('') };
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
test('API_ARTIFACT_FILES names exactly the files the two generators write', () => {
|
|
273
|
+
const client = require('../scripts/gds/gen-api-client.js').build();
|
|
274
|
+
assert.deepEqual(API_ARTIFACT_FILES['gen-api-client.js'], Object.keys(client.files).map((f) => `clients/bongos-client/${f}`));
|
|
275
|
+
const docsSrc = readFileSync(new URL('../scripts/gds/gen-api-docs.js', import.meta.url), 'utf8');
|
|
276
|
+
assert.deepEqual(API_ARTIFACT_FILES['gen-api-docs.js'], [...docsSrc.matchAll(/\brel: '([^']+)'/g)].map((m) => m[1]));
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('the generated files that changed are staged and committed WITH the pin', () => {
|
|
280
|
+
const run = fakeRun({ status: statusOf([...PIN_FILES, 'docs/api/openapi.json', 'clients/bongos-client/package.json']), 'rev-parse': ON_MAIN });
|
|
281
|
+
const r = persistPin({ ...base, commitPin: true, push: false, artifactPaths: ARTIFACTS }, run, quiet, quiet);
|
|
282
|
+
assert.equal(r.committed, true);
|
|
283
|
+
assert.deepEqual(r.dirty, [...PIN_FILES, 'docs/api/openapi.json', 'clients/bongos-client/package.json']);
|
|
284
|
+
assert.ok(run.calls.includes(`git add ${PIN_FILES.join(' ')} docs/api/openapi.json clients/bongos-client/package.json`),
|
|
285
|
+
`stages the pin + only the generated files that changed; got ${JSON.stringify(run.calls)}`);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('generated files that did not change add nothing to the staging argv', () => {
|
|
289
|
+
const run = fakeRun({ status: statusOf([...PIN_FILES]), 'rev-parse': ON_MAIN });
|
|
290
|
+
persistPin({ ...base, commitPin: true, push: false, artifactPaths: ARTIFACTS }, run, quiet, quiet);
|
|
291
|
+
assert.ok(run.calls.includes(`git add ${PIN_FILES.join(' ')}`));
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('an already-committed pin still commits the generated files the upgrade rewrote', () => {
|
|
295
|
+
const run = fakeRun({ status: statusOf(['docs/api-reference.md']), 'rev-parse': ON_MAIN });
|
|
296
|
+
const r = persistPin({ ...base, commitPin: true, push: false, artifactPaths: ARTIFACTS }, run, quiet, quiet);
|
|
297
|
+
assert.equal(r.committed, true, 'a dirty generated file alone is still the upgrade\'s to persist');
|
|
298
|
+
assert.ok(run.calls.includes(`git add ${PIN_FILES.join(' ')} docs/api-reference.md`));
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('a path nobody asked about is never reported, and no artifact paths means no extra git call', () => {
|
|
302
|
+
const run = fakeRun({ status: { status: 0, stdout: ' M config/branding.json\n M docs/api/openapi.json\n' }, 'rev-parse': ON_MAIN });
|
|
303
|
+
const r = persistPin({ ...base, commitPin: true, push: false, artifactPaths: ['docs/api/openapi.json'] }, run, quiet, quiet);
|
|
304
|
+
assert.deepEqual(r.dirty, ['docs/api/openapi.json'], 'config/branding.json is not the upgrade\'s to commit');
|
|
305
|
+
const bare = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
|
|
306
|
+
persistPin({ ...base, commitPin: true, push: false }, bare, quiet, quiet);
|
|
307
|
+
assert.equal(bare.calls.filter((c) => c.startsWith('git status')).length, 1, 'an empty pathspec would mean the WHOLE tree');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('without --commit-pin, the paste-ready fix stages the changed generated files too', () => {
|
|
311
|
+
const warnings = [];
|
|
312
|
+
const run = fakeRun({ status: statusOf([...PIN_FILES, 'docs/api/openapi.json']), 'rev-parse': ON_MAIN });
|
|
313
|
+
persistPin({ ...base, commitPin: false, artifactPaths: ARTIFACTS }, run, quiet, (m) => warnings.push(m));
|
|
314
|
+
const cmd = warnings.find((w) => w.includes('git -C'));
|
|
315
|
+
assert.match(cmd, /add 'package\.json' 'package-lock\.json' 'docs\/api\/openapi\.json' &&/);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Real git, no fakes: the task's done-when, reproduced on a scratch repo.
|
|
319
|
+
function git(dir, ...args) { return spawnSync('git', args, { cwd: dir, encoding: 'utf8' }); }
|
|
320
|
+
function put(dir, rel, content) { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), content); }
|
|
321
|
+
function scratchInstance() {
|
|
322
|
+
const dir = mkdtempSync(join(tmpdir(), 'upgrade-regen-'));
|
|
323
|
+
git(dir, 'init', '-q');
|
|
324
|
+
git(dir, 'config', 'user.email', 'upgrade-test@example.invalid');
|
|
325
|
+
git(dir, 'config', 'user.name', 'upgrade test');
|
|
326
|
+
git(dir, 'config', 'core.autocrlf', 'false');
|
|
327
|
+
for (const f of [...PIN_FILES, ...ARTIFACTS, 'config/branding.json']) put(dir, f, 'scaffold\n');
|
|
328
|
+
git(dir, 'add', '-A');
|
|
329
|
+
git(dir, 'commit', '-q', '-m', 'scaffold');
|
|
330
|
+
return dir;
|
|
331
|
+
}
|
|
332
|
+
// What one upgrade writes: the moved pin, and the generators' whole files.
|
|
333
|
+
const upgradeWrites = (dir, version) => { for (const f of [...PIN_FILES, ...ARTIFACTS]) put(dir, f, `${version}\n`); };
|
|
334
|
+
|
|
335
|
+
test('real git: two consecutive co-tenant upgrades each leave the tree clean, so the next pre-flight passes', () => {
|
|
336
|
+
const dir = scratchInstance();
|
|
337
|
+
for (const [from, to] of [['1.19.737', '1.19.745'], ['1.19.745', '1.19.746']]) {
|
|
338
|
+
assert.equal(gitTreeClean(dir).ok, true, `pre-flight before ${to}`);
|
|
339
|
+
upgradeWrites(dir, to);
|
|
340
|
+
const r = persistPin({ instanceDir: dir, fromVersion: from, toVersion: to, commitPin: true, push: false, artifactPaths: ARTIFACTS }, spawnSync, quiet, quiet);
|
|
341
|
+
assert.equal(r.committed, true, `upgrade to ${to} committed`);
|
|
342
|
+
assert.equal(git(dir, 'status', '--porcelain').stdout, '', `upgrade to ${to} leaves nothing uncommitted`);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('real git: an owner edit outside the generated files is never swept into the pin commit', () => {
|
|
347
|
+
const dir = scratchInstance();
|
|
348
|
+
put(dir, 'config/branding.json', 'the owner was here\n');
|
|
349
|
+
upgradeWrites(dir, '1.19.745');
|
|
350
|
+
persistPin({ instanceDir: dir, fromVersion: '1.19.737', toVersion: '1.19.745', commitPin: true, push: false, artifactPaths: ARTIFACTS }, spawnSync, quiet, quiet);
|
|
351
|
+
assert.equal(git(dir, 'status', '--porcelain').stdout, ' M config/branding.json\n');
|
|
352
|
+
assert.ok(!git(dir, 'show', '--name-only', '--format=', 'HEAD').stdout.includes('config/branding.json'));
|
|
353
|
+
});
|