@bongos/core 1.19.747 → 1.19.748

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.747",
6
- "core_contract": "1.19.747",
7
- "source_commit": "492774317dbad5e66a4cd25638d81545e3561be3",
5
+ "core_version": "1.19.748",
6
+ "core_contract": "1.19.748",
7
+ "source_commit": "c7239277baa5d7123d6e0d1a60749b6aba472f70",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-15T21:22:06.627Z",
9
+ "built_at": "2026-09-15T21:40:07.956Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 481,
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": 2615,
20
- "tree_sha256": "839a3564a1926eee62dfb94ddeb8383deb0e2a794820335ab13c4a45fd1fddb5",
19
+ "file_count": 2616,
20
+ "tree_sha256": "f52311a51ab40e147838cb38e88a7327f02df848a8bba552a509cc0185fcdc6b",
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": "e36f3dffd983d410498269c6923c2b66a18071a25c1ea57eac0c8353b1ebc1bc"
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": "8bd715e0a97a235285a67533236b0769798356c3c72e22af8895ae7209bcc7f2"
2510
+ "sha256": "958174405a14b2dae06adc92ccbf638cb3289a38e68b8ebb9c6c314a1479ae40"
2506
2511
  },
2507
2512
  {
2508
2513
  "path": "docs/modules-contract.md",
@@ -7557,12 +7562,12 @@
7557
7562
  {
7558
7563
  "path": "package-lock.json",
7559
7564
  "mode": "0000644",
7560
- "sha256": "07948a29433225ad3eb2ce1b2d71af8f884e723cd8a12b8d44a7a46331a7e156"
7565
+ "sha256": "ad67710386b227895b4042eaad4f4461a4435433be5e1eafaf6970118b63a759"
7561
7566
  },
7562
7567
  {
7563
7568
  "path": "package.json",
7564
7569
  "mode": "0000644",
7565
- "sha256": "d20112e72f90d6ffe815316162e30da9ccdd4e0a3dd8a475572c62f39e90419a"
7570
+ "sha256": "7ffeb06be165b48faa187f3faa8526b293bc6ccd467ecb6ac7a880036f805ad8"
7566
7571
  },
7567
7572
  {
7568
7573
  "path": "public-docs/index.html",
@@ -9197,7 +9202,7 @@
9197
9202
  {
9198
9203
  "path": "src/bongos/platform-visibility-gate.js",
9199
9204
  "mode": "0000644",
9200
- "sha256": "f422cf189a4d29fd2cc9a0ff4a6c71f4032f7cb56eab254f9b304a973afbb88d"
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": "c3bfdf5f1242fe4bbbb51ea79401dd5ae09cca95adc475e0dc0e5c76055677c4"
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": "269664a23f5e22f8499ae3067550f3edfc853d55b128cd6ec6f6556e337df0b9"
9370
+ "sha256": "fddd61204720aa0027e000d95f5933d756b3ed59befe634229c4492ae50afa5f"
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": "0686358d061f465409c585b46e38e9905728fc829aca361e1d0d15beb2316af9"
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": "bc2cf20f85fa231b0a8edf7bbf6eef4ef698674397ad9a7009aaaa3399e2dd1f"
11825
+ "sha256": "ff9d438a33a32fecc04caa6e652eeaaded6394438bcec0d508ae7c516fa62333"
11821
11826
  },
11822
11827
  {
11823
11828
  "path": "tests/post_login_landing.mjs",
@@ -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.
@@ -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,7 @@ 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)
1971
1973
  ---------------------------------------------------------------------------
1972
1974
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.747",
3
+ "version": "1.19.748",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.747",
9
+ "version": "1.19.748",
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.747",
3
+ "version": "1.19.748",
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",
@@ -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
- module.exports = function buildInstanceRouter() {
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', (_req, res) => {
120
- const idpCfg = loadIdpConfig();
121
- res.json(buildInstanceManifest({
122
- branding: clientBranding(),
123
- modules: clientModules(),
124
- rankTier: RANK_TIER,
125
- repo: loadRepoInfo(),
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 assembler) + the schema/auth constants.
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.747'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.748'; // 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)