@bongos/core 1.19.746 → 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 +26 -16
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +81 -0
- 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 +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- 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/.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.748",
|
|
6
|
+
"core_contract": "1.19.748",
|
|
7
|
+
"source_commit": "c7239277baa5d7123d6e0d1a60749b6aba472f70",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-15T21:40:07.956Z",
|
|
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": "f52311a51ab40e147838cb38e88a7327f02df848a8bba552a509cc0185fcdc6b",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -1929,10 +1929,20 @@
|
|
|
1929
1929
|
"mode": "0000644",
|
|
1930
1930
|
"sha256": "9280dd05ac1061a7f4da41ea665612be9cee16631ed2b27a513c3f8ec084b51a"
|
|
1931
1931
|
},
|
|
1932
|
+
{
|
|
1933
|
+
"path": "docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md",
|
|
1934
|
+
"mode": "0000644",
|
|
1935
|
+
"sha256": "f9141e74643a8055c3e7f7943957e1103d34b4673dd96c673636c30a50d47c36"
|
|
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
|
+
},
|
|
1932
1942
|
{
|
|
1933
1943
|
"path": "docs/adr/README.md",
|
|
1934
1944
|
"mode": "0000644",
|
|
1935
|
-
"sha256": "
|
|
1945
|
+
"sha256": "e7f895a2f6dc062acd2afcf2c477ab8e60f1cbc93a836d8759a47d204a55a450"
|
|
1936
1946
|
},
|
|
1937
1947
|
{
|
|
1938
1948
|
"path": "docs/api-reference.md",
|
|
@@ -2497,7 +2507,7 @@
|
|
|
2497
2507
|
{
|
|
2498
2508
|
"path": "docs/module-api-changelog.md",
|
|
2499
2509
|
"mode": "0000644",
|
|
2500
|
-
"sha256": "
|
|
2510
|
+
"sha256": "958174405a14b2dae06adc92ccbf638cb3289a38e68b8ebb9c6c314a1479ae40"
|
|
2501
2511
|
},
|
|
2502
2512
|
{
|
|
2503
2513
|
"path": "docs/modules-contract.md",
|
|
@@ -7552,12 +7562,12 @@
|
|
|
7552
7562
|
{
|
|
7553
7563
|
"path": "package-lock.json",
|
|
7554
7564
|
"mode": "0000644",
|
|
7555
|
-
"sha256": "
|
|
7565
|
+
"sha256": "ad67710386b227895b4042eaad4f4461a4435433be5e1eafaf6970118b63a759"
|
|
7556
7566
|
},
|
|
7557
7567
|
{
|
|
7558
7568
|
"path": "package.json",
|
|
7559
7569
|
"mode": "0000644",
|
|
7560
|
-
"sha256": "
|
|
7570
|
+
"sha256": "7ffeb06be165b48faa187f3faa8526b293bc6ccd467ecb6ac7a880036f805ad8"
|
|
7561
7571
|
},
|
|
7562
7572
|
{
|
|
7563
7573
|
"path": "public-docs/index.html",
|
|
@@ -9192,7 +9202,7 @@
|
|
|
9192
9202
|
{
|
|
9193
9203
|
"path": "src/bongos/platform-visibility-gate.js",
|
|
9194
9204
|
"mode": "0000644",
|
|
9195
|
-
"sha256": "
|
|
9205
|
+
"sha256": "f1563c32d78eb1c598f49c5b241f9709c4957bcc4e8319773cf6a2c212d936e4"
|
|
9196
9206
|
},
|
|
9197
9207
|
{
|
|
9198
9208
|
"path": "src/bongos/pool.js",
|
|
@@ -9272,7 +9282,7 @@
|
|
|
9272
9282
|
{
|
|
9273
9283
|
"path": "src/bongos/routes/instance.js",
|
|
9274
9284
|
"mode": "0000644",
|
|
9275
|
-
"sha256": "
|
|
9285
|
+
"sha256": "f5ab4f2ad7391cbbfc9504190fe8fee19e18b5b43532dd4ffd37df258591fe66"
|
|
9276
9286
|
},
|
|
9277
9287
|
{
|
|
9278
9288
|
"path": "src/bongos/routes/live.js",
|
|
@@ -9357,7 +9367,7 @@
|
|
|
9357
9367
|
{
|
|
9358
9368
|
"path": "src/module-api.js",
|
|
9359
9369
|
"mode": "0000644",
|
|
9360
|
-
"sha256": "
|
|
9370
|
+
"sha256": "fddd61204720aa0027e000d95f5933d756b3ed59befe634229c4492ae50afa5f"
|
|
9361
9371
|
},
|
|
9362
9372
|
{
|
|
9363
9373
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11307,7 +11317,7 @@
|
|
|
11307
11317
|
{
|
|
11308
11318
|
"path": "tests/instance_manifest.mjs",
|
|
11309
11319
|
"mode": "0000644",
|
|
11310
|
-
"sha256": "
|
|
11320
|
+
"sha256": "c45a264128a2cdc55675dcab82f8510a0d64e4d81bf4fd3f528b1aa720bdace0"
|
|
11311
11321
|
},
|
|
11312
11322
|
{
|
|
11313
11323
|
"path": "tests/instance_mismatch_guard.mjs",
|
|
@@ -11812,7 +11822,7 @@
|
|
|
11812
11822
|
{
|
|
11813
11823
|
"path": "tests/platform_visibility_gate.mjs",
|
|
11814
11824
|
"mode": "0000644",
|
|
11815
|
-
"sha256": "
|
|
11825
|
+
"sha256": "ff9d438a33a32fecc04caa6e652eeaaded6394438bcec0d508ae7c516fa62333"
|
|
11816
11826
|
},
|
|
11817
11827
|
{
|
|
11818
11828
|
"path": "tests/post_login_landing.mjs",
|
package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# ADR 0285 — A shared box holds about twelve projects per GB, and memory is the wall
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-09-15
|
|
5
|
+
- **Task:** [1003877](https://cloudbongos.com/builders#/task/1003877) (goal 1000106), a spike. Informs [1003923](https://cloudbongos.com/builders#/task/1003923) (the resize) and [1001949](https://cloudbongos.com/builders#/task/1001949) (managed billing).
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
Every live project is `hosting_shape='standalone'`: its own checkout and pinned core, one Node process on its own port and one Postgres database, all on the `s-1vcpu-2gb` control plane beside the control plane itself. Nothing recorded what one more project costs, so creation could not refuse, overflow or price honestly.
|
|
10
|
+
|
|
11
|
+
The task asked for a throwaway droplet filled one instance at a time. The owner chose instead to **measure the live box read-only**: eleven real projects already give the data points, and nothing was changed to get them.
|
|
12
|
+
|
|
13
|
+
## Measurement (2026-09-15, live control plane, read-only)
|
|
14
|
+
|
|
15
|
+
Sources: `/proc/<pid>/smaps_rollup` (`Pss` + `SwapPss`, which avoids `ps` double-counting shared pages), each unit's cgroup `memory.peak`, `/proc/<pid>/stat` CPU ticks over a 60-second `vmstat 5 12` window, `pg_stat_activity` joined to backend PSS, sysstat history (`sa12`–`sa15`) and the kernel journal (retained since 2026-07-14). Box at the time: 1967 MiB RAM, 4 GiB swap, 11 project processes + the control plane, 1.09 GB available.
|
|
16
|
+
|
|
17
|
+
**One project process:**
|
|
18
|
+
|
|
19
|
+
| State | Resident (PSS) | Swapped | Total |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| fresh boot, 10 min old (test4) | 65 MB | 0 | 65 MB |
|
|
22
|
+
| same, after 150 local requests | 70 MB | 0 | 70 MB |
|
|
23
|
+
| idle for 33 h+ (ten projects) | 12–28 MB | 21–35 MB | **45–49 MB**, a tight band |
|
|
24
|
+
| boot peak, cgroup `memory.peak` (includes page cache) | — | — | 118–134 MB |
|
|
25
|
+
|
|
26
|
+
- **CPU:** 0.02–0.05% of one core per project, the same over the 60-second window and over each process's lifetime.
|
|
27
|
+
- **Postgres:** **zero** backends per idle project (pg's pool releases an idle client after 10 s). A busy backend is 7–10 MB PSS.
|
|
28
|
+
- **Disk:** a checkout is 77–140 MB, including an unshared 63 MB `node_modules`. An adopted repo with its own dependencies runs larger (hermeslines-marketing: 936 MB). A fresh database is 45 MB.
|
|
29
|
+
|
|
30
|
+
**Fixed cost of the box, whatever the project count:** kernel unreclaimable memory ~98 MB (slab, page tables, stacks), OS daemons ~112 MB, Caddy 38 MB, Postgres 139 MB (`shared_buffers` 160 MB), and the control-plane process ~116 MB (boot peak 233 MB). That totals **~500 MB**, and we reserve **~250 MB of page cache** on top (an allowance, not a measurement) so module loads and Postgres reads stay off the disk. **~750 MB fixed**, or ~630 MB on a box that does not run the control plane.
|
|
31
|
+
|
|
32
|
+
**History:** the box has never OOM-killed a project. The only OOM kill on record was Prometheus on 2026-09-04 (812 MB anon), since removed ([ADR 0284](0284-the-control-plane-carries-no-metrics-stack.md)). The worst pressure since then (2026-09-14 11:40: 279 MB available, commit 316%, load 6.82) came during operator ssh sessions while no fleet unit changed state. It was work on the box, not the fleet. The day-average 4–6% user CPU is the control plane's own 1–2-minute intent-runner and deploy timers, each a fresh Node start, and it does not grow with project count.
|
|
33
|
+
|
|
34
|
+
## Decision
|
|
35
|
+
|
|
36
|
+
### 1. The number: ~80 MB per project, about 12 projects per GB above the fixed base
|
|
37
|
+
|
|
38
|
+
Price a project at its **warm** size, 70 MB for the process plus ~10 MB for one busy Postgres backend. **Swap is not capacity.** An idle project shrinks to ~25 MB resident only because the kernel swaps its cold pages out, which is why the box looks roomier than it is. A reboot (the resize in [1003923](https://cloudbongos.com/builders#/task/1003923) needs one), a fleet core upgrade or a burst of traffic brings every project back to warm at once, on top of a ~130 MB boot peak each.
|
|
39
|
+
|
|
40
|
+
### 2. The ceiling per tier: `floor((usable MiB − fixed) / 80)`
|
|
41
|
+
|
|
42
|
+
| Tier | $/mo | Box role | Ceiling | $/project |
|
|
43
|
+
|---|---|---|---|---|
|
|
44
|
+
| `s-1vcpu-2gb` (today) | 12 | control plane | **15** | 0.80 |
|
|
45
|
+
| `s-2vcpu-4gb` | 24 | control plane | **39** | 0.62 |
|
|
46
|
+
| `s-2vcpu-4gb` | 24 | overflow box, no control plane | **41** | 0.59 |
|
|
47
|
+
| `s-4vcpu-8gb` | 48 | control plane | 90 by RAM, **only with a pool cap** (§3) | 0.53 |
|
|
48
|
+
|
|
49
|
+
Today's box holds **11 of 15**. Resize (or open the next box) when it reaches **12**, which is 80% of the ceiling.
|
|
50
|
+
|
|
51
|
+
### 3. Memory binds first; connections bind second
|
|
52
|
+
|
|
53
|
+
- **RAM is the binding constraint** at every tier up to 4 GB.
|
|
54
|
+
- **Postgres connections are the next wall.** `max_connections` is 100 (97 for non-superusers), and every instance pool uses pg's default of `max: 10` with no override ([`src/bongos/pool.js`](../../src/bongos/pool.js)). At idle this costs nothing, but nine projects busy at once can exhaust it, and at 8 GB it binds long before RAM does. Filed as [1003928](https://cloudbongos.com/builders#/task/1003928): cap the provisioned instances' pool size.
|
|
55
|
+
- **Walls that do not bind:**
|
|
56
|
+
- CPU: 0.03% per idle project.
|
|
57
|
+
- Ports: lowest-free from 3003 up to the 32768 ephemeral floor, about 29,700.
|
|
58
|
+
- systemd: three units per project; `TasksMax` is 2315 per unit.
|
|
59
|
+
- Disk: 26 GB free at ~200 MB per project with its database and dumps, room for ~130.
|
|
60
|
+
- The 04:00 backup cluster: all twelve timers fire in one window with a 5-minute random delay, adding ~4 points of user CPU for ten minutes. Ten of eleven instance backups currently fail on the size floor ([1003925](https://cloudbongos.com/builders#/task/1003925)), so the per-dump cost is not yet observable, but a dump of a 45 MB database is small.
|
|
61
|
+
|
|
62
|
+
### 4. The shapes: keep three, and add placement as its own question
|
|
63
|
+
|
|
64
|
+
The measured cost is the same **unit** for `co-tenant` and `standalone`. What separates them is repository layout, not what they cost the box. So no new shape is needed. What the three shapes never answer is **which box**, and the rule is **standalone until the box reaches its ceiling, then the next box**. The box is the unit of spend, and this ceiling governs placement. Filed as [1003927](https://cloudbongos.com/builders#/task/1003927): the runner refuses to place a control-plane-local project past the ceiling.
|
|
65
|
+
|
|
66
|
+
`dedicated` stays behind `provisioning.fleet.manage` ([`paid-shape-gate.js`](../../modules/provisioning/paid-shape-gate.js)). It has never been provisioned, and it should not be offered to anyone paying nothing: at $6/mo minimum ($24 at the runner's default tier), one dedicated project costs **8–40× a shared slot**.
|
|
67
|
+
|
|
68
|
+
`costEstimateUsd` prices a shared project at $0, a visibility choice ([ADR 0111](0111-instance-hosting-provisioning-module.md) §6). It can now become honest: a shared slot costs **box price ÷ ceiling**, $0.62–0.80/mo at today's tiers. That change belongs to the billing work ([1001949](https://cloudbongos.com/builders#/task/1001949)), not this ADR.
|
|
69
|
+
|
|
70
|
+
## Alternatives considered
|
|
71
|
+
|
|
72
|
+
- **A throwaway droplet filled one instance at a time** (the task's original method). Declined by the owner. The live box already carried eleven instances at two ages (33 h idle and 10 min fresh), which is the slope data a lab box would have produced, with none of the spend.
|
|
73
|
+
- **Price by idle resident memory (~25 MB, about 40 per GB).** Rejected. It counts swap as capacity, and it breaks exactly when every project wakes at once: on reboot, on a fleet upgrade, or under traffic.
|
|
74
|
+
- **Price by the boot peak (~130 MB).** Rejected. The peak includes reclaimable page cache and lasts seconds, so it would understate capacity by ~40%.
|
|
75
|
+
|
|
76
|
+
## Consequences
|
|
77
|
+
|
|
78
|
+
- **+** Provisioning has a number to act on (15 today, 39 after the resize), and the resize in [1003923](https://cloudbongos.com/builders#/task/1003923) has a trigger: 12 projects.
|
|
79
|
+
- **+** Billing has a unit cost for a shared slot.
|
|
80
|
+
- **−** The page-cache reserve is an allowance, not a measurement. If a ceiling is ever approached, re-measure `MemAvailable` then instead of trusting the 250 MB.
|
|
81
|
+
- **−** These are **idle-fleet** numbers. A project with real traffic or large data will sit above 80 MB, so the ceiling is an upper bound for quiet projects. Re-measure the same way (`Pss` + `SwapPss` per unit, `memory.peak`, `pg_stat_activity`) as projects gain users.
|
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
|
@@ -376,3 +376,5 @@ This keeps the decision history honest and traceable.
|
|
|
376
376
|
| 0282 | [**A module that spends money for its callers DECLARES so, and the core refuses to enable it where anyone can sign up and nobody is on the hook** ([task 1003884](https://cloudbongos.com/builders#/task/1003884), owner decision 2026-09-13). A fire from `modules/agents` costs ~$0.90, `POST /agents/:name/invoke` answers **202** so the caller does not even wait, and the route is gated at `requireBuilder` and nothing else — deliberately, because a v1 agent is hard-read-only by plumbing. That bounds CAPABILITY, not SPEND, and `lib/fire-budget.js` says so in its own header: it is a blast radius, explicitly NOT a budget, and it defers the real cap to `agents_runs.cost_usd`. The cap did not exist. The exposure is three reasonable facts composed: a fire is expensive and async; invoke admits any **any-builder**; and with the join door `open`, any GitHub account IS one seconds after sign-up. **Decision: the MODULE declares `"spend": { "requiresPayer": true }` and the CORE refuses to enable it when the composed join door (ADR 0247 — the door, never the `joinability` knob alone) is `open` with no configured payer.** The declaration lives in the manifest because fitness Check 10 forbids the kernel from naming a discovered module key — a constraint that turns out to be the better design, since the next metered module inherits the guard by declaring one line rather than by someone remembering to widen a list. The refusal is at **config load**, so the routes never mount and the migrations never run: a per-request check would let the module mount, advertise its routes and read as enabled everywhere the UI looks, then refuse callers one at a time — a broken feature, not a closed door. It is applied AFTER config, env and default alike, because `<PREFIX>_MODULE_<KEY>` is exactly the knob a hurried deploy reaches for and a wall env could outrank is a suggestion. An UNREADABLE posture refuses too, inverting this file’s usual fail-to-unchanged default: "I could not tell whether strangers can sign up" has to mean "assume they can", and the cost of being wrong is one module resolving off. `spend_payer` (`none|project|builder`) joins `SETTINGS_VOCAB` on ADR 0241’s transport, and `agents_runs` gains `payer_kind` + `payer_builder_id` — **stored, not derived**, because the setting is a live knob and deriving would make last month’s bill change when an owner flips it. `none` is NOT a third payer: a run always has a real one and `none` attributes to the project, so a CLOSED-door instance is unchanged and `none` is fatal only where it is also dangerous. An EVENT fire has no caller, so it bills the project rather than nobody — otherwise the event path is the one unbounded route. The per-payer monthly ceiling (`lib/spend-ceiling.js`) is a SECOND wall that must never merge into the first: a rate ceiling cannot bound a bill (~$650/month/builder at 10 fires an hour) and a spend ceiling cannot bound a loop (it reads spend already landed). It records NOTHING — the ledger is the tally, and a second tally is the disagreeing answer fire-budget’s header refuses to become. It honours the finding of abandoned [task 1001393](https://cloudbongos.com/builders#/task/1001393), which caught the autonomy brake reading month-to-date spend from a DB it could not reach and **failing open**: every unknown refuses (no cap, non-finite cap, no payer, a read that threw, a sum absent or unparseable), answering **402** rather than fire-budget’s 429 so "the money is gone" and "too fast" stay distinguishable. Writing the absent-sum clause down was not paranoia — the first draft admitted the fire on a null sum because `Number(null)` is `0`, and the test for that clause is what caught it. The overshoot is STATED: in-flight fires are invisible to the read, bounded by concurrency × ~$0.90, and what bounds concurrency is fire-budget — each wall’s blind spot is the other’s window. The cost-needs-a-payer constraint is added `NOT VALID` so it binds every future write without failing the migration of an instance whose history predates the column. **What it does not do:** nobody is billed; `builder` attributes spend, it does not collect.](0282-a-module-that-spends-for-its-callers-declares-a-payer-and-the-core-refuses-it-without-one.md) | modules / spend attribution / agents |
|
|
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
|
+
| 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 |
|
|
@@ -1966,5 +1966,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1966
1966
|
landed since 1.19.744 with no explicit bump. run 34990123950. (task 1002620)
|
|
1967
1967
|
1.19.746 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1968
1968
|
landed since 1.19.745 with no explicit bump. run 35021312150. (task 1002620)
|
|
1969
|
+
1.19.747 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
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)
|
|
1969
1973
|
---------------------------------------------------------------------------
|
|
1970
1974
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.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)
|