@bongos/core 1.19.101 → 1.19.103
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 +43 -23
- package/bin/bongos.js +3 -0
- package/docs/adr/0169-bongos-serve-server-bring-up.md +52 -0
- package/docs/adr/README.md +1 -0
- package/docs/recipes/instance-deploy.md +1 -0
- package/docs/recipes/local-dev.md +3 -0
- package/docs/recipes/server-bring-up.md +112 -0
- package/modules/platform-identity/project-stats.js +207 -0
- package/modules/platform-identity/routes/projects.js +21 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +5 -0
- package/public-landing/projects.html +54 -1
- package/scripts/gds/dev-lib.js +209 -3
- package/scripts/gds/dev.js +292 -21
- package/src/module-api.js +5 -1
- package/tests/bongos_cli.mjs +3 -1
- package/tests/dev_lib.mjs +192 -2
- package/tests/project_stats.mjs +206 -0
- package/tests/projects_hub.mjs +53 -10
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.103",
|
|
6
|
+
"core_contract": "1.19.103",
|
|
7
|
+
"source_commit": "4c26152f4ffe43100617e89d904852639f51a9dc",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-13T23:41:03.246Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 301,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1234,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1556,
|
|
20
|
+
"tree_sha256": "b559bea5c153dfcb5b4007dee090a97532674449005645ee1953761712bcf02a",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -342,7 +342,7 @@
|
|
|
342
342
|
{
|
|
343
343
|
"path": "bin/bongos.js",
|
|
344
344
|
"mode": "0000644",
|
|
345
|
-
"sha256": "
|
|
345
|
+
"sha256": "36208a41a60e3a21356ca9f81abdff9203837ff024b3d15c88a26bee9cce371c"
|
|
346
346
|
},
|
|
347
347
|
{
|
|
348
348
|
"path": "clients/bongos-client/README.md",
|
|
@@ -1319,10 +1319,15 @@
|
|
|
1319
1319
|
"mode": "0000644",
|
|
1320
1320
|
"sha256": "8bdad4b4333cbb7b38f66e4dddab08675dfc0a431d21c0be6a31ca4afd8c498f"
|
|
1321
1321
|
},
|
|
1322
|
+
{
|
|
1323
|
+
"path": "docs/adr/0169-bongos-serve-server-bring-up.md",
|
|
1324
|
+
"mode": "0000644",
|
|
1325
|
+
"sha256": "fe1c4b38b9b3eef60ecd00ecc35eb6086690350a022d2c376d1432c95c9452f7"
|
|
1326
|
+
},
|
|
1322
1327
|
{
|
|
1323
1328
|
"path": "docs/adr/README.md",
|
|
1324
1329
|
"mode": "0000644",
|
|
1325
|
-
"sha256": "
|
|
1330
|
+
"sha256": "529d997cf39669fe80bf0fb9a02c546adbb6ba139d4f8dad8de4f59932c47a95"
|
|
1326
1331
|
},
|
|
1327
1332
|
{
|
|
1328
1333
|
"path": "docs/api-reference.md",
|
|
@@ -1597,7 +1602,7 @@
|
|
|
1597
1602
|
{
|
|
1598
1603
|
"path": "docs/recipes/instance-deploy.md",
|
|
1599
1604
|
"mode": "0000644",
|
|
1600
|
-
"sha256": "
|
|
1605
|
+
"sha256": "92a10c52ddbda73541b4f5cdad6b3b564d2e3dd9905c9b9fc0a22c3e56aea7b9"
|
|
1601
1606
|
},
|
|
1602
1607
|
{
|
|
1603
1608
|
"path": "docs/recipes/instance-first-run.md",
|
|
@@ -1607,7 +1612,7 @@
|
|
|
1607
1612
|
{
|
|
1608
1613
|
"path": "docs/recipes/local-dev.md",
|
|
1609
1614
|
"mode": "0000644",
|
|
1610
|
-
"sha256": "
|
|
1615
|
+
"sha256": "ac7b71a9dfd550d78355de9e8b758173a5917ecdb1567599a33b16e9d112bc62"
|
|
1611
1616
|
},
|
|
1612
1617
|
{
|
|
1613
1618
|
"path": "docs/recipes/managed-settings-remote-control.md",
|
|
@@ -1664,6 +1669,11 @@
|
|
|
1664
1669
|
"mode": "0000644",
|
|
1665
1670
|
"sha256": "a44bb6dcf12bac7e104eab82862e8c0a690376652466c898cd1ab6f8e0e9c270"
|
|
1666
1671
|
},
|
|
1672
|
+
{
|
|
1673
|
+
"path": "docs/recipes/server-bring-up.md",
|
|
1674
|
+
"mode": "0000644",
|
|
1675
|
+
"sha256": "e5f9e1a9f2337e80ed4ff94d60c96941f50ff30339a35585458dfd8de6e66c5f"
|
|
1676
|
+
},
|
|
1667
1677
|
{
|
|
1668
1678
|
"path": "docs/recipes/ship-and-regrade.md",
|
|
1669
1679
|
"mode": "0000644",
|
|
@@ -3744,6 +3754,11 @@
|
|
|
3744
3754
|
"mode": "0000644",
|
|
3745
3755
|
"sha256": "b6eea95dbe66a3126fc2c73d93237cf38dd648a803e910eb88fbd5c780ccef62"
|
|
3746
3756
|
},
|
|
3757
|
+
{
|
|
3758
|
+
"path": "modules/platform-identity/project-stats.js",
|
|
3759
|
+
"mode": "0000644",
|
|
3760
|
+
"sha256": "6ce71a9bd387a2b79d4c57a6669f028a807ef88ff205f65c20c55ce3d9889214"
|
|
3761
|
+
},
|
|
3747
3762
|
{
|
|
3748
3763
|
"path": "modules/platform-identity/routes/my-projects.js",
|
|
3749
3764
|
"mode": "0000644",
|
|
@@ -3752,7 +3767,7 @@
|
|
|
3752
3767
|
{
|
|
3753
3768
|
"path": "modules/platform-identity/routes/projects.js",
|
|
3754
3769
|
"mode": "0000644",
|
|
3755
|
-
"sha256": "
|
|
3770
|
+
"sha256": "188583ffb979d305c57a8f0958806ee0bd813007df158d74e45a0d415a3ff6aa"
|
|
3756
3771
|
},
|
|
3757
3772
|
{
|
|
3758
3773
|
"path": "modules/platform-identity/routes/scouting.js",
|
|
@@ -4132,12 +4147,12 @@
|
|
|
4132
4147
|
{
|
|
4133
4148
|
"path": "package-lock.json",
|
|
4134
4149
|
"mode": "0000644",
|
|
4135
|
-
"sha256": "
|
|
4150
|
+
"sha256": "b3854f65fe8ad4aead3d08a87f9f2200d87e2b049aed77f7849d51f9aa6f6e5b"
|
|
4136
4151
|
},
|
|
4137
4152
|
{
|
|
4138
4153
|
"path": "package.json",
|
|
4139
4154
|
"mode": "0000644",
|
|
4140
|
-
"sha256": "
|
|
4155
|
+
"sha256": "7467e0451b4fd162c287c7a5eceee8da746785a3da989bda154830ead5728f3d"
|
|
4141
4156
|
},
|
|
4142
4157
|
{
|
|
4143
4158
|
"path": "public-docs/index.html",
|
|
@@ -4377,7 +4392,7 @@
|
|
|
4377
4392
|
{
|
|
4378
4393
|
"path": "public-landing/assets/cosmos.css",
|
|
4379
4394
|
"mode": "0000644",
|
|
4380
|
-
"sha256": "
|
|
4395
|
+
"sha256": "3ae9d1edc18eef49ebdf90d0c20c00defda76bfb393936c4a615720314a2886e"
|
|
4381
4396
|
},
|
|
4382
4397
|
{
|
|
4383
4398
|
"path": "public-landing/buddha.png",
|
|
@@ -4397,7 +4412,7 @@
|
|
|
4397
4412
|
{
|
|
4398
4413
|
"path": "public-landing/projects.html",
|
|
4399
4414
|
"mode": "0000644",
|
|
4400
|
-
"sha256": "
|
|
4415
|
+
"sha256": "f70211d0d2bfe21fa7000ab82d0adcaa97df1f28763e366965da2b92847b31b6"
|
|
4401
4416
|
},
|
|
4402
4417
|
{
|
|
4403
4418
|
"path": "public-landing/world/assets/clouds/cloud-island-large.png",
|
|
@@ -4697,12 +4712,12 @@
|
|
|
4697
4712
|
{
|
|
4698
4713
|
"path": "scripts/gds/dev-lib.js",
|
|
4699
4714
|
"mode": "0000644",
|
|
4700
|
-
"sha256": "
|
|
4715
|
+
"sha256": "b6c3cf99b050644088507fd5365ced7db5a1c05178ce850e3d28ac7fab9d9476"
|
|
4701
4716
|
},
|
|
4702
4717
|
{
|
|
4703
4718
|
"path": "scripts/gds/dev.js",
|
|
4704
4719
|
"mode": "0000644",
|
|
4705
|
-
"sha256": "
|
|
4720
|
+
"sha256": "79effdf07519d26153f6fe846fd052cd4a37fa1e25b6b1a278146c9d8bcf6766"
|
|
4706
4721
|
},
|
|
4707
4722
|
{
|
|
4708
4723
|
"path": "scripts/gds/diagnose-achievements.js",
|
|
@@ -5707,7 +5722,7 @@
|
|
|
5707
5722
|
{
|
|
5708
5723
|
"path": "src/module-api.js",
|
|
5709
5724
|
"mode": "0000644",
|
|
5710
|
-
"sha256": "
|
|
5725
|
+
"sha256": "18be8d4886ea03a65a0cd5d817c811ca0496ea509df5716f416d985a5d682b84"
|
|
5711
5726
|
},
|
|
5712
5727
|
{
|
|
5713
5728
|
"path": "src/module-loader/catalog.js",
|
|
@@ -5972,7 +5987,7 @@
|
|
|
5972
5987
|
{
|
|
5973
5988
|
"path": "tests/bongos_cli.mjs",
|
|
5974
5989
|
"mode": "0000644",
|
|
5975
|
-
"sha256": "
|
|
5990
|
+
"sha256": "20d7d86da8e03ede2004bdc3dda589670977fca0240d56cd3677fc08fbb2de03"
|
|
5976
5991
|
},
|
|
5977
5992
|
{
|
|
5978
5993
|
"path": "tests/bongos_dispatch.mjs",
|
|
@@ -6302,7 +6317,7 @@
|
|
|
6302
6317
|
{
|
|
6303
6318
|
"path": "tests/dev_lib.mjs",
|
|
6304
6319
|
"mode": "0000644",
|
|
6305
|
-
"sha256": "
|
|
6320
|
+
"sha256": "a4611e9a8aa7ff2e1dbd1c91e84496e55cd6b07ece2f73bd62e152c118b94348"
|
|
6306
6321
|
},
|
|
6307
6322
|
{
|
|
6308
6323
|
"path": "tests/devbox_artifact_gate.mjs",
|
|
@@ -7224,10 +7239,15 @@
|
|
|
7224
7239
|
"mode": "0000644",
|
|
7225
7240
|
"sha256": "dc354f082204073409aea90df9b91f149d474c6930b761ed5c9928bb76e1358f"
|
|
7226
7241
|
},
|
|
7242
|
+
{
|
|
7243
|
+
"path": "tests/project_stats.mjs",
|
|
7244
|
+
"mode": "0000644",
|
|
7245
|
+
"sha256": "21bd2a2d0ebb234cd3b9770d126e77ce48f5c630fe7deee2585706d4eb072b53"
|
|
7246
|
+
},
|
|
7227
7247
|
{
|
|
7228
7248
|
"path": "tests/projects_hub.mjs",
|
|
7229
7249
|
"mode": "0000644",
|
|
7230
|
-
"sha256": "
|
|
7250
|
+
"sha256": "d8fd9759b99b2136f8b438727b74b15346db7cbd8f0ef8b67aec3049f059df2b"
|
|
7231
7251
|
},
|
|
7232
7252
|
{
|
|
7233
7253
|
"path": "tests/provision.mjs",
|
package/bin/bongos.js
CHANGED
|
@@ -91,6 +91,9 @@ const VERBS = {
|
|
|
91
91
|
|
|
92
92
|
init: { script: 'init.js', group: 'instance', summary: 'Scaffold a new instance (config + first version + kickoff tasks)' },
|
|
93
93
|
dev: { script: 'dev.js', group: 'instance', summary: 'Run THIS instance locally in one command (Postgres + migrate + server + sign-in)', selfHelp: true },
|
|
94
|
+
// Same script, same boot path, --server pinned on: a real host supervises with
|
|
95
|
+
// systemd, binds per the public origin, and never opens a browser (task 1002398).
|
|
96
|
+
serve: { script: 'dev.js', prefixArgs: ['--server'], group: 'instance', summary: 'Run THIS instance on a REAL HOST (systemd service + health check)', selfHelp: true },
|
|
94
97
|
onboard: { script: 'onboard.js', group: 'instance', summary: 'Guided zero-to-live walkthrough: init → scaffold → provision → OAuth → verify → brand' },
|
|
95
98
|
module: { script: 'module.js', group: 'instance', summary: 'Scaffold a new module (bongos module new <key>)', selfHelp: true },
|
|
96
99
|
upgrade: { script: 'upgrade.js', group: 'instance', summary: 'Pull the core to a new version (bump pin→install→migrate→materialize→restart); bare = module-compat pre-check', selfHelp: true },
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# 0169 — `bongos serve`: the `bongos dev` boot path, on a real host
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-08-13
|
|
5
|
+
- **Task:** [#1002398](https://cloudbongos.com/builders#/task/1002398) (goal 1000052 — first-run onboarding), depends on [#1002397](https://cloudbongos.com/builders#/task/1002397).
|
|
6
|
+
- **Extends:** [ADR 0149](<redacted>.md) (`bongos dev`, the local launcher). Sibling of [ADR 0108 §4](<redacted>.md) / [`instance-deploy.md`](../recipes/instance-deploy.md) (the *pinned-core consumer* deploy) and `provision.js` (the *control-plane* cloud path).
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
ADR 0149 shipped `bongos dev`: one idempotent command that stands an instance up **on the owner's laptop**. The obvious next question — "great, now put it on a server" — had no equally good answer:
|
|
11
|
+
|
|
12
|
+
| Path to a real host | Why it didn't close the gap |
|
|
13
|
+
|---|---|
|
|
14
|
+
| Docker ([`self-host.md`](../recipes/self-host.md)) | serves the **vanilla** brand on `:3000`; matching the repo's brand + port needs a compose override |
|
|
15
|
+
| `bongos onboard` → `provision.js` | provisions a **whole droplet** through the control plane — needs a public domain plus DigitalOcean/Cloudflare tokens |
|
|
16
|
+
| [`instance-deploy.md`](../recipes/instance-deploy.md) | describes the right shape, but the systemd unit is a **hand-written prose step** ("create a systemd unit for the instance…") — no script in the core writes one |
|
|
17
|
+
|
|
18
|
+
So an owner with a plain VPS — the most ordinary hosting story there is — was back to hand-assembly, and hand-assembly is where ADR 0149's three silent mismatches (port, DB name, socket) came from in the first place.
|
|
19
|
+
|
|
20
|
+
The boot path itself is *already right* for a server: deps → Postgres → createdb → migrate → env → server → health is exactly what a host needs. Only four **decisions inside it** are laptop-shaped.
|
|
21
|
+
|
|
22
|
+
## Decision
|
|
23
|
+
|
|
24
|
+
Ship **`bongos serve`** — the same script, the same six steps, with `--server` pinned on (`bongos dev --server` is the same thing). Not a second boot path: a **mode** on the existing one, so the two can't drift.
|
|
25
|
+
|
|
26
|
+
Four decisions change, each derived rather than asked:
|
|
27
|
+
|
|
28
|
+
1. **Supervision — a systemd unit, not a pid file.** The laptop's detached-with-a-pid-file process is right for a laptop and wrong for a host: it dies on reboot, has no restart policy, and logs to `/tmp`. Server mode writes `/etc/systemd/system/bongos-<db>.service` (`Restart=always`, `WantedBy=multi-user.target`), `daemon-reload`s, and `enable --now`s it — so the host's own supervisor owns the process. Where systemd is absent (a container, a BSD, an operator without root) it **falls back** to the pid-managed process with a loud warning rather than refusing.
|
|
29
|
+
2. **Bind address — derived from the origin.** `https://…` means something already terminates TLS in front of us, so bind `127.0.0.1`; plain `http://` on a public hostname means nothing is in front yet, so bind `0.0.0.0`. `--bind` (or `HOST`) still wins. An operator never has to reason about it, and the common misconfiguration — an app bound to `0.0.0.0` behind a proxy, publicly reachable on its raw port — can't happen by default.
|
|
30
|
+
3. **A fresh Postgres cluster gets bootstrapped.** `apt-get install postgresql` creates exactly one role — `postgres`, peer-auth only — so a deploy user's `createdb` fails with `role "deploy" does not exist`. That reads like a broken install and is really an unbootstrapped cluster. On that specific error we create the login role + its database as the postgres superuser (`sudo -u postgres`, or `su postgres -c` for root on a sudo-less image). This is the fresh-Debian wall, and it is the one step most likely to end a first server run.
|
|
31
|
+
4. **Health means the *public* origin, not loopback.** The loopback probe proves the app; on a host the interesting failures are DNS, the proxy, and TLS — all of which sit outside it. Server mode probes `publicOrigin/healthz` too and, when it can't be reached, says which layer is missing. It is **advisory**: DNS legitimately may not be wired yet, so this warns and still exits 0.
|
|
32
|
+
|
|
33
|
+
Plus: no auto-login (a host has no browser — it prints `bongos login <origin>` instead).
|
|
34
|
+
|
|
35
|
+
**Secrets go to an `EnvironmentFile`, never `Environment=`.** The unit carries only the eight keys the boot path resolves; `PGPASSWORD` and the GitHub OAuth creds go to a root-owned `0600` `/etc/bongos/<service>.env`, created once and never overwritten. Two reasons: `systemctl show` exposes `Environment=` to any user on the box, and the laptop path's `buildEnv()` spreads the *caller's whole environment* — baking a builder's shell (tokens included) into a root-owned file. `unitEnvironment()` is a deliberately separate, allowlisted function for exactly that reason.
|
|
36
|
+
|
|
37
|
+
As with ADR 0149, every decision above is a **pure, injectable function** in `dev-lib.js` (`deriveBindHost`, `systemdPlan`, `pgStartPlan`, `pgBootstrapPlan`, `renderSystemdUnit`, `unitEnvironment`, `parseSub`); `dev.js` only executes them. `tests/dev_lib.mjs` proves all of it with no host, no spawn, and no network.
|
|
38
|
+
|
|
39
|
+
## Alternatives considered
|
|
40
|
+
|
|
41
|
+
- **A separate `deploy.sh`** — would have duplicated the boot path, which is precisely how the laptop and server paths would drift. Rejected: the whole value is that it's the *same* six steps.
|
|
42
|
+
- **Docker on the host** — real, and still the right answer for a container platform, but it makes the instance's own brand/port a compose-override exercise and puts a container runtime between an owner and a $5 VPS. Kept as the parallel path.
|
|
43
|
+
- **Always bind `0.0.0.0`** — simpler to explain, but publishes the raw app port on every host that has a proxy in front. Rejected on that alone.
|
|
44
|
+
- **Refuse without systemd** — cleaner contract, but would fail on containers and BSDs where the pid-managed fallback works fine. Rejected in favour of a warning.
|
|
45
|
+
|
|
46
|
+
## Consequences
|
|
47
|
+
|
|
48
|
+
- A plain VPS goes from `git clone` to a supervised, reboot-surviving instance with `npm install` then `bongos serve` — the same two commands as the laptop.
|
|
49
|
+
- `bongos serve stop` **stops and disables** the unit, so "stopped" survives a reboot; `bongos serve` re-enables it. Asymmetric with `bongos dev stop` (which only kills a process) because on a host the two verbs mean different things.
|
|
50
|
+
- The unit is a *starting point*, not a hardened production profile — no `ProtectSystem`, no resource limits, no log shipping. An operator with opinions should edit it; a re-run rewrites it, so persistent customisation belongs in a systemd drop-in (`/etc/systemd/system/bongos-<db>.service.d/`).
|
|
51
|
+
- TLS/proxy/DNS stay **out of scope** — deliberately. Those are the host's business and vary per operator; `bongos serve` tells you when they're the missing piece and points at [`server-bring-up.md`](../recipes/server-bring-up.md).
|
|
52
|
+
- Windows is still not a native path (migrations need `bash`), unchanged from ADR 0149.
|
package/docs/adr/README.md
CHANGED
|
@@ -242,5 +242,6 @@ This keeps the decision history honest and traceable.
|
|
|
242
242
|
| 0167 | [**The module catalog: `source` (which tree ships it) and `provenance` (who built it) are two axes** ([task 1001772](https://cloudbongos.com/builders#/task/1001772) — the last task of goal 1000029). Closes [ADR 0107](<redacted>.md) §2/§3: a browsable catalog of enable-able core modules, each with author + origin credit, in the hall AND via `bongos module list --catalog`. The bug it fixes: the Modules tab derived its own/core-loaded split from the manifest's `origin` alone, but attribution is OPTIONAL and core's own modules declare none (nothing upstreamed them) — so on a split instance every core module read as *built here* and the catalog you could opt into read as empty. One field was answering two questions. Fix: a shared model (`src/module-loader/catalog.js`) that both the hall route and the CLI build from, carrying `source` — read STRUCTURALLY from the loader's discovery root ([ADR 0108](<redacted>.md) §1: core package vs instance tree), falling back on coincident roots to whether `package.json` names the core — beside the unchanged `provenance` credit axis. Plus `credit: {author, origin, inferred}`, which fills a core module's blank attribution with the platform name and MARKS it inferred (`*` in the CLI, a hoverable `<abbr>` in the hall) so it never reads as a claim the author typed. The tab now sections by `source` ("Module catalog" / "Built by this project"), gains a search box, and draws "Submit for review" from instance-source modules only. Rejected: stamping author/origin into 15 core manifests (repetitive, and module 16 forgets), overloading `origin` (destroys the credit axis), inferring from `default:true` (§6 makes accepted catalog modules default-OFF — exactly backwards), a DB catalog table (premature; the manifests already are the catalog).](<redacted>.md) | modules / upstreaming |
|
|
243
243
|
| 0168 | [**The scaffold vendors the hub's RUNNING core (`npm pack` of the installed package); registry-default deferred to the go-public step** ([task 1002706](https://cloudbongos.com/builders#/task/1002706); gap 13 of the project-creation audit). Two collided facts: children were born pinned to a frozen `file:vendor/bongos-core-1.19.2.tgz` (installedCoreTarball reuses the hub's own file: pin — task 1002275 — and the hub's pin sat at 1.19.2), and the task-1002621 registry cutover (hub pin now `"@bongos/core": "1.19.56"`, verified on the box) made installedCoreTarball return null — dropping the scaffold into the package-core.js source rebuild that ABORTS on a consumer instance (empty core set → empty child repo, the 1002275 failure resurrected). Fix: a three-rung ladder — file:-pin reuse → NEW `installedCorePackDir()` + `npm pack` of the hub's installed core (child pins the SAME, CURRENT core, stays file:-pinned, owner needs no token) → source rebuild. The brief's "make registry the default now that the package is public" was verified FALSE (both npm scopes 404 anonymously; ADR 0134 keeps them private until go-public) — a registry-default child would be born UNINSTALLABLE for a tokenless owner. Registry-default is deferred to the go-public step (idea 1000700); the gap-13 "flip the box env" ops leg is explicitly a no-op until then; child freshness rides this ladder + the hub-pin lane (task 1002708). Rejected: registry-default now, dropping NPM_TOKEN, vendoring from the npm cacache.](<redacted>.md) | provisioning / distribution |
|
|
244
244
|
| 0167 | [**The /create gate trust link defaults to the AGPL-3.0 license text, not the npm package page** ([task 1002705](https://cloudbongos.com/builders#/task/1002705); gap 1 of the project-creation audit). The gate's "open source" link pointed at a nonexistent repo (404); the task brief's suggested default — the npm package page — is ALSO dead for the gate's actual audience (signed-out visitors): `@cloudbongos/core` and `@bongos/core` both 404 anonymously (private scopes, ADR 0134), and the ADR 0099 public mirror stays dormant until ~2026-11. The license text is the one live, instance-neutral artifact behind the claim today. The browsable-code upgrade is the owner's filed choice (idea 1000700): public repo now (forfeits the ADR 0065 §4 redaction window) / public npm package / wait for the mirror. Rejected: the npm link (verified dead), flipping the repo public (not a builder's call), plain unlinked text (less information, same cost).](<redacted>.md) | public landing / governance |
|
|
245
|
+
| 0169 | [**`bongos serve`: the `bongos dev` boot path, on a real host** ([task 1002398](https://cloudbongos.com/builders#/task/1002398), goal 1000052). [ADR 0149](<redacted>.md) gave an owner one command to stand an instance up on their laptop; "now put it on a server" had no equally good answer — Docker serves the *vanilla* brand on `:3000`, `bongos onboard` provisions a whole droplet through the control plane, and [`instance-deploy.md`](../recipes/instance-deploy.md) left the systemd unit as a hand-written prose step. The six steps (deps → Postgres → createdb → migrate → env → server → health) were already right for a host; only four *decisions inside them* were laptop-shaped. So: a `--server` **mode** on the same script (`bongos serve`), never a second boot path, so the two can't drift. (1) **Supervision** — a systemd unit (`Restart=always`, `WantedBy=multi-user.target`) instead of a pid file that dies on reboot, falling back to the pid-managed process where systemd is absent rather than refusing. (2) **Bind address, derived from the origin** — `https://…` means a proxy terminates TLS, so bind `127.0.0.1`; plain `http://` on a public host means nothing is in front, so bind `0.0.0.0`. The common misconfiguration (app publicly reachable on its raw port behind a proxy) becomes impossible by default. (3) **Peer-auth bootstrap** — a fresh `apt-get install postgresql` cluster has only the `postgres` role, so a deploy user's `createdb` fails with `role "deploy" does not exist`; that reads like a broken install and is really an unbootstrapped cluster, so we create the login role as the superuser. (4) **Health means the PUBLIC origin** — the loopback probe proves the app; DNS/proxy/TLS are where a host actually fails, so it probes the advertised origin too and names the missing layer (advisory — DNS may legitimately not be wired). Secrets go to a root-owned `0600` `EnvironmentFile`, never `Environment=` (`systemctl show` leaks those, and the laptop path's `buildEnv()` spreads the caller's whole shell). Every decision is a pure injectable in `dev-lib.js`, proved in `tests/dev_lib.mjs` with no host. Rejected: a separate `deploy.sh` (drift — the point is the *same* six steps), Docker-on-the-host (makes brand+port a compose override), always binding `0.0.0.0` (exposes the raw port), refusing without systemd (breaks containers/BSD).](<redacted>.md) | onboarding / deploy |
|
|
245
246
|
|
|
246
247
|
> ⚠️ **Numbering collision (known):** two ADRs share `0008`. The numbers were assigned in parallel sessions before the lock could be enforced. The files are kept under their existing filenames to preserve git history, but the table disambiguates them as `0008-a` / `0008-b`. New ADRs should resume at `0040` to avoid further collisions. (`0024` is also a real on-disk duplicate: the cloneable-repo/local-first-memory ADR and the multi-agent-system-architecture ADR were both numbered `0024` in parallel; both keep their filenames, disambiguated as `0024-a` / `0024-b` like `0008`. Likewise `0025` ended up with two on-disk files — the offsite-backup ADR and the structured-criterion↔task-link ADR — both kept under their existing filenames and disambiguated as `0025-a` / `0025-b`; separately, the BFG ADR had also claimed `0025` on 2026-05-30 and yielded to `0026`. A third happened 2026-06-06: `0036` was claimed in parallel by the builders-subdomain ADR ([#726](https://example.com/builders#/task/726)) and this Discord-channels ADR ([#727](https://example.com/builders#/task/727)); the Discord-channels ADR yielded to `0037`. A fourth landed 2026-06-10: `0044` was claimed in parallel by the palette-replacement ADR and the per-box game-preview ADR ([#897](https://example.com/builders#/task/897)); both keep their filenames, disambiguated as `0044-a` / `0044-b` like `0008`. A fifth was caught pre-land 2026-06-13: `0050` was claimed in parallel by the device-flow-admission ADR ([#1000](https://example.com/builders#/task/1000)) and the sandbox-for-everyone ADR ([#1001](https://example.com/builders#/task/1001)); the latter yielded to `0052` before merging, so there is no `0050` duplicate. A sixth was caught pre-land 2026-06-21: the tool-agnostic-design-layer ADR ([#1375](https://example.com/builders#/task/1375)) first collided at `0079` with the value-gated-autonomous-engagement ADR, then at `0080` with the llm-cache-api-transport ADR (both part of the 0075–0080 LLM-cost-reduction series, landed on `main` in parallel); it yielded to `0081` before merging, so there is no `0079`/`0080` design-layer duplicate. A seventh landed 2026-06-23: `0084` was claimed in parallel by the auto-derived-per-task-rank-floor ADR and the remove-default-spend-cap ADR (task 1499); the rank-floor ADR landed on `main` first, so the spend-cap ADR yielded to `0085`, leaving no `0084` duplicate. An eighth landed 2026-06-27: `0095` was claimed in parallel by the borrowed-memory-and-retrieval-concepts ADR (PR #459, landed first) and the cross-agent-context-management ADR ([#1651](https://example.com/builders#/task/1651), PR #460); both keep their filenames, disambiguated as `0095-a` / `0095-b` like `0008`. These two are companions — `0095-a` adopts the memory/retrieval concepts and explicitly defers the grep-vs-semantic question to `0095-b`.)
|
|
@@ -33,5 +33,6 @@
|
|
|
33
33
|
|
|
34
34
|
1. Install the script as the droplet's `~/deploy.sh` (the CI deploy key is forced-command-locked to `~/deploy.sh` — ADR 0043 — so it stays hand-installed, like `deploy-prod.sh`).
|
|
35
35
|
2. Create a systemd unit for the instance: `ExecStart=/usr/bin/node <instanceRoot>/node_modules/@bongos/core/src/platform-server.js`, `WorkingDirectory=<instanceRoot>`, `Environment=PORT=… HOST=127.0.0.1 <PREFIX>_INSTANCE_ROOT=<instanceRoot>`.
|
|
36
|
+
**`bongos serve` now writes exactly this unit for you** (plus the DB/migrate/health legs) — see [`server-bring-up.md`](server-bring-up.md) / [ADR 0169](../adr/<redacted>.md). Step 4 of the flow above ("restart the platform") is `systemctl restart bongos-<db>` once it has.
|
|
36
37
|
3. Set `INSTANCE_SERVICE=<unit>` + `PORT=<port>` in the deploy environment.
|
|
37
38
|
4. Front it with TLS and point the origins at the real domain (see [self-host.md](self-host.md)).
|
|
@@ -79,3 +79,6 @@ the Client ID + secret to `~/.config/<configDir>/gds-github.json` as
|
|
|
79
79
|
servers don't collide.
|
|
80
80
|
- **Prefer the cloud?** `bongos box` + `bongos code` give you a remote dev box instead — no
|
|
81
81
|
local database or server to run.
|
|
82
|
+
- **Ready for a real host?** `bongos serve` runs this *same* boot path on a server —
|
|
83
|
+
systemd-supervised, bind address derived from your public origin, no browser needed. See
|
|
84
|
+
[`server-bring-up.md`](server-bring-up.md) ([ADR 0169](../adr/<redacted>.md)).
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Recipe — putting your instance on a real host (`bongos serve`)
|
|
2
|
+
|
|
3
|
+
> [ADR 0169](../adr/<redacted>.md), task [#1002398](https://cloudbongos.com/builders#/task/1002398). The server half of [`local-dev.md`](local-dev.md) / [ADR 0149](../adr/<redacted>.md): the **same** boot path `bongos dev` runs on your laptop, on a VPS.
|
|
4
|
+
|
|
5
|
+
## The short version
|
|
6
|
+
|
|
7
|
+
On the host, in your instance's checkout:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
bongos serve
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
That's it. It is idempotent — re-run it after every `git pull`.
|
|
15
|
+
|
|
16
|
+
## What you need first
|
|
17
|
+
|
|
18
|
+
- A Linux host (Debian/Ubuntu assumed below) with **Node ≥ 22**, **Postgres**, and **bash**.
|
|
19
|
+
- Your instance repo cloned on it, with `config/branding.json` pointing `domains.publicOrigin` at the origin you'll actually serve.
|
|
20
|
+
- Either root, or a user with **passwordless sudo** — `bongos serve` uses `sudo -n` so it fails fast instead of hanging a deploy on a hidden password prompt. Without it, run `sudo bongos serve`.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
sudo apt-get install -y postgresql # provides psql/createdb/pg_isready too
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## What it does
|
|
27
|
+
|
|
28
|
+
The six steps from `bongos dev`, with the four host-shaped decisions made for you (ADR 0169):
|
|
29
|
+
|
|
30
|
+
1. **Dependencies** — `npm ci` (or `npm install`) if this checkout has none.
|
|
31
|
+
2. **Postgres running** — `systemctl start postgresql`, falling back to `service` then `pg_ctl`, then polls until it accepts connections.
|
|
32
|
+
3. **Database + role** — creates the DB, and on a fresh cluster creates your **login role** first. See the peer-auth note below; this is the step most first server runs die on.
|
|
33
|
+
4. **Migrations** — the core `migrate.sh`, with `PGDATABASE` exported for both it and the server (the ADR 0149 mismatch).
|
|
34
|
+
5. **The server, supervised by systemd** — writes `/etc/systemd/system/bongos-<db>.service`, enables it (survives reboot) and starts it (`Restart=always`).
|
|
35
|
+
6. **Health** — on the bind address, *and* on your public origin.
|
|
36
|
+
|
|
37
|
+
## Where things land
|
|
38
|
+
|
|
39
|
+
| Thing | Path |
|
|
40
|
+
|---|---|
|
|
41
|
+
| systemd unit | `/etc/systemd/system/bongos-<db>.service` |
|
|
42
|
+
| secrets (0600, root-owned) | `/etc/bongos/bongos-<db>.env` |
|
|
43
|
+
| logs | `journalctl -u bongos-<db> -f` |
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
bongos serve status # service state, health, and whether the public origin answers
|
|
47
|
+
bongos serve stop # stop AND disable (stays down across a reboot)
|
|
48
|
+
bongos serve # re-enables and restarts
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## The bind address is derived — don't fight it
|
|
52
|
+
|
|
53
|
+
`bongos serve` reads `domains.publicOrigin` and picks:
|
|
54
|
+
|
|
55
|
+
| Your `publicOrigin` | It binds | Because |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `https://app.example.com` | `127.0.0.1` | something terminates TLS in front — the proxy reaches you on loopback |
|
|
58
|
+
| `http://app.example.com:3006` | `0.0.0.0` | nothing is in front yet — you're serving it directly |
|
|
59
|
+
| `http://localhost:3006` | `127.0.0.1` | a local-shaped origin |
|
|
60
|
+
|
|
61
|
+
Override with `--bind <addr>` if your topology is unusual. **The `https` case is the one to get right**: set your origin to `https://…` *before* the first `bongos serve`, and the app is never publicly exposed on its raw port.
|
|
62
|
+
|
|
63
|
+
## The fresh-Postgres wall (peer auth)
|
|
64
|
+
|
|
65
|
+
A brand-new `apt-get install postgresql` cluster has exactly **one** role — `postgres` — reachable only by peer auth as the `postgres` OS user. So your deploy user's `createdb` fails with:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
createdb: error: ... role "deploy" does not exist
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
That reads like a broken Postgres and is really an unbootstrapped one. `bongos serve` detects this specific failure and fixes it — `CREATE ROLE "deploy" LOGIN CREATEDB` plus the database, run as the postgres superuser. If it can't (no root, no sudo), it says so; the manual equivalent is:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
sudo -u postgres psql -c 'CREATE ROLE "deploy" LOGIN CREATEDB'
|
|
75
|
+
sudo -u postgres createdb -O deploy <dbname>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Sign-in on a host
|
|
79
|
+
|
|
80
|
+
There's no browser on a server, so `bongos serve` never auto-signs-in. It prints the command to run **from your own machine**:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
bongos login https://app.example.com
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
If sign-in isn't configured yet, it walks you through the GitHub OAuth app and tells you to put the credentials in `/etc/bongos/bongos-<db>.env` (not `~/.config` — the service runs as its own user, often with an empty home), then `systemctl restart bongos-<db>`.
|
|
87
|
+
|
|
88
|
+
## "The app is healthy but my origin isn't answering"
|
|
89
|
+
|
|
90
|
+
Expected, and `bongos serve` says which layer is missing. It proves the app on the bind address; DNS, the reverse proxy, and TLS are yours:
|
|
91
|
+
|
|
92
|
+
- **Bound to `127.0.0.1`** — you need a proxy forwarding your origin to `127.0.0.1:<port>`. Any of Caddy (easiest — automatic TLS), nginx + certbot, or Cloudflare Tunnel.
|
|
93
|
+
- **Bound to `0.0.0.0`** — you need DNS pointing at this host and the port open in the firewall (`ufw allow <port>`).
|
|
94
|
+
|
|
95
|
+
## Customising the unit
|
|
96
|
+
|
|
97
|
+
A re-run **rewrites** the unit, so don't hand-edit it. Use a systemd drop-in, which survives:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
sudo systemctl edit bongos-<db> # writes /etc/systemd/system/bongos-<db>.service.d/override.conf
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The shipped unit is a sound starting point, not a hardened production profile — no `ProtectSystem`, no resource limits, no log shipping. Add what your threat model wants there.
|
|
104
|
+
|
|
105
|
+
## Relation to the other deploy paths
|
|
106
|
+
|
|
107
|
+
| Path | When |
|
|
108
|
+
|---|---|
|
|
109
|
+
| **`bongos serve`** (this) | you have a host and want your instance on it |
|
|
110
|
+
| [`instance-deploy.md`](instance-deploy.md) | a consumer instance on a **pinned core** — `git pull` → `npm ci` → migrate → restart (this is the restart it's describing) |
|
|
111
|
+
| [`self-host.md`](self-host.md) | Docker/container platforms |
|
|
112
|
+
| `bongos onboard` | you have **no** host yet and want the control plane to provision one |
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// modules/platform-identity/project-stats.js — real Builders + Tags for the hub
|
|
2
|
+
// catalog, read from each project's OWN public API (task 1002771).
|
|
3
|
+
//
|
|
4
|
+
// WHY THE DATA IS NOT HERE. The apex catalog stores four fields (origin, name,
|
|
5
|
+
// tagline, art_url) and nothing else; the membership and activity tables that
|
|
6
|
+
// would know a builder count are written only by the federated check-in a hosted
|
|
7
|
+
// instance posts on sign-in, so they hold one row across the whole platform and
|
|
8
|
+
// would report ZERO for every hand-wired project (task 1002760 documents why).
|
|
9
|
+
// Zero is not an absence — it is the claim "nobody builds here" — so task 1002764
|
|
10
|
+
// removed the columns rather than show a wrong number.
|
|
11
|
+
//
|
|
12
|
+
// But every Cloud Bongos instance already serves an unauthenticated
|
|
13
|
+
// /api/bongos/public/* surface, and the hub's server can read it instance-to-
|
|
14
|
+
// instance with no CORS involved. The project itself is the authority on its own
|
|
15
|
+
// builder count, which is a better source than anything the apex could infer.
|
|
16
|
+
//
|
|
17
|
+
// SCALE, recorded so it is not rediscovered: this PULL model is right for a
|
|
18
|
+
// handful and fine for a few dozen behind the cache. It does not scale to
|
|
19
|
+
// hundreds — at that point instances should PUSH these stats on check-in, which
|
|
20
|
+
// is the shape platform_identity_builder_project_activity already has. Swapping
|
|
21
|
+
// pull for push must not change the column, which is why the route depends on
|
|
22
|
+
// getProjectStats() and not on fetch().
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const dns = require('node:dns').promises;
|
|
27
|
+
const net = require('node:net');
|
|
28
|
+
|
|
29
|
+
const TTL_MS = 10 * 60 * 1000; // owner's choice, 2026-08-13
|
|
30
|
+
const FETCH_TIMEOUT_MS = 2500; // per request
|
|
31
|
+
const COLD_DEADLINE_MS = 3000; // total wait a COLD cache may cost a page load
|
|
32
|
+
|
|
33
|
+
const _cache = new Map(); // origin → { at, stats }
|
|
34
|
+
const _inflight = new Map(); // origin → Promise (collapses concurrent refreshes)
|
|
35
|
+
|
|
36
|
+
// The core's own modules/ directory IS the canonical core set — so a module the
|
|
37
|
+
// core adds later is excluded automatically and nobody maintains a list. Anything
|
|
38
|
+
// an instance runs beyond it is project-specific, and that is what a tag is.
|
|
39
|
+
let _coreModules = null;
|
|
40
|
+
function coreModules() {
|
|
41
|
+
if (_coreModules) return _coreModules;
|
|
42
|
+
try {
|
|
43
|
+
_coreModules = new Set(
|
|
44
|
+
fs.readdirSync(path.join(__dirname, '..'), { withFileTypes: true })
|
|
45
|
+
.filter((d) => d.isDirectory())
|
|
46
|
+
.map((d) => d.name),
|
|
47
|
+
);
|
|
48
|
+
} catch {
|
|
49
|
+
_coreModules = new Set();
|
|
50
|
+
}
|
|
51
|
+
return _coreModules;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Machine identities, not people. Counting them inflates every project: the raw
|
|
55
|
+
// leaderboards read 13/3/3 against real 11/1/1, so this filter is the difference
|
|
56
|
+
// between a true number and a wrong one.
|
|
57
|
+
function isSystemAccount(login) {
|
|
58
|
+
return /-system$/.test(String(login || ''));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── SSRF GUARD ──
|
|
62
|
+
// This is the one place the server makes an outbound request to an address that
|
|
63
|
+
// came out of the database, so it is the one place that has to assume the address
|
|
64
|
+
// is hostile. "The catalog is Archon-curated" is a real mitigation but not a
|
|
65
|
+
// sufficient one, for a specific reason: a curated origin is trusted, and a
|
|
66
|
+
// REDIRECT TARGET is not. Following redirects hands the choice of final address
|
|
67
|
+
// back to the remote server, which is the whole SSRF primitive — a listed project
|
|
68
|
+
// could 302 us at 169.254.169.254 (cloud metadata) or a loopback admin port.
|
|
69
|
+
//
|
|
70
|
+
// So: redirects are refused outright rather than followed (a public JSON endpoint
|
|
71
|
+
// has no reason to redirect), and the hostname must resolve to a PUBLIC address.
|
|
72
|
+
//
|
|
73
|
+
// Known and accepted limit: resolving here and connecting in fetch() is a
|
|
74
|
+
// TOCTOU window — a DNS record could change between the two. Closing it properly
|
|
75
|
+
// means pinning the resolved IP into the connection, which needs a custom agent.
|
|
76
|
+
// Refusing redirects removes the attacker-steerable half, which is the half that
|
|
77
|
+
// matters; the residual requires control of DNS for a curated origin.
|
|
78
|
+
const BLOCKED_V4 = [
|
|
79
|
+
[/^10\./, 'private'], [/^127\./, 'loopback'], [/^0\./, 'unspecified'],
|
|
80
|
+
[/^169\.254\./, 'link-local / cloud metadata'], [/^192\.168\./, 'private'],
|
|
81
|
+
[/^172\.(1[6-9]|2\d|3[01])\./, 'private'], [/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, 'CGNAT'],
|
|
82
|
+
[/^(22[4-9]|23\d)\./, 'multicast'], [/^(24\d|25[0-5])\./, 'reserved'],
|
|
83
|
+
];
|
|
84
|
+
function blockedReason(addr) {
|
|
85
|
+
const ip = String(addr).replace(/^::ffff:/i, ''); // unwrap IPv4-mapped IPv6
|
|
86
|
+
if (net.isIPv4(ip)) {
|
|
87
|
+
for (const [re, why] of BLOCKED_V4) if (re.test(ip)) return why;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const v6 = ip.toLowerCase();
|
|
91
|
+
if (v6 === '::1' || v6 === '::') return 'loopback';
|
|
92
|
+
if (/^f[cd]/.test(v6)) return 'unique-local';
|
|
93
|
+
if (/^fe[89ab]/.test(v6)) return 'link-local';
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function assertPublicHost(hostname) {
|
|
98
|
+
// a literal IP needs no lookup — check it directly
|
|
99
|
+
if (net.isIP(hostname)) {
|
|
100
|
+
const why = blockedReason(hostname);
|
|
101
|
+
if (why) throw new Error(`refused ${hostname} (${why})`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const addrs = await dns.lookup(hostname, { all: true });
|
|
105
|
+
if (!addrs.length) throw new Error(`no address for ${hostname}`);
|
|
106
|
+
for (const { address } of addrs) {
|
|
107
|
+
const why = blockedReason(address);
|
|
108
|
+
if (why) throw new Error(`refused ${hostname} → ${address} (${why})`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function publicUrl(origin, route) {
|
|
113
|
+
const u = new URL(origin);
|
|
114
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('bad scheme');
|
|
115
|
+
return new URL(`/api/bongos/public/${route}`, u).href;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function getJson(url) {
|
|
119
|
+
await assertPublicHost(new URL(url).hostname);
|
|
120
|
+
const res = await fetch(url, {
|
|
121
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
122
|
+
headers: { accept: 'application/json' },
|
|
123
|
+
redirect: 'error', // never let the remote choose our final address
|
|
124
|
+
});
|
|
125
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
126
|
+
return res.json();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// One project's stats. Either half may fail on its own — a project whose
|
|
130
|
+
// leaderboard is down can still report its modules — so they settle independently
|
|
131
|
+
// and a missing half is null (an em dash) rather than a discarded pair.
|
|
132
|
+
async function fetchStats(origin) {
|
|
133
|
+
const [lb, mods] = await Promise.allSettled([
|
|
134
|
+
getJson(publicUrl(origin, 'leaderboard')),
|
|
135
|
+
getJson(publicUrl(origin, 'modules')),
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
let builders = null;
|
|
139
|
+
if (lb.status === 'fulfilled' && Array.isArray(lb.value?.leaderboard)) {
|
|
140
|
+
builders = lb.value.leaderboard.filter((b) => !isSystemAccount(b?.github_login)).length;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let tags = null;
|
|
144
|
+
if (mods.status === 'fulfilled' && mods.value?.enabled && typeof mods.value.enabled === 'object') {
|
|
145
|
+
const core = coreModules();
|
|
146
|
+
tags = Object.entries(mods.value.enabled)
|
|
147
|
+
.filter(([key, on]) => on === true && !core.has(key))
|
|
148
|
+
.map(([key]) => key)
|
|
149
|
+
.sort();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { builders, tags };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function refresh(origin) {
|
|
156
|
+
if (_inflight.has(origin)) return _inflight.get(origin);
|
|
157
|
+
const p = fetchStats(origin)
|
|
158
|
+
.then((stats) => {
|
|
159
|
+
_cache.set(origin, { at: Date.now(), stats });
|
|
160
|
+
return stats;
|
|
161
|
+
})
|
|
162
|
+
.catch(() => {
|
|
163
|
+
// Remember the FAILURE for a full TTL too. Without this a permanently
|
|
164
|
+
// unreachable project is re-fetched on every request and pays the timeout
|
|
165
|
+
// every time — the exact way one dead project slows the whole page.
|
|
166
|
+
const stats = { builders: null, tags: null };
|
|
167
|
+
_cache.set(origin, { at: Date.now(), stats });
|
|
168
|
+
return stats;
|
|
169
|
+
})
|
|
170
|
+
.finally(() => _inflight.delete(origin));
|
|
171
|
+
_inflight.set(origin, p);
|
|
172
|
+
return p;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Stale-while-revalidate: a warm cache answers instantly, a STALE cache answers
|
|
176
|
+
// instantly and refreshes behind the request, and only a COLD cache waits — and
|
|
177
|
+
// then only until COLD_DEADLINE_MS, after which whatever has not arrived renders
|
|
178
|
+
// as a dash and fills itself in on the next load. The hub's speed must never
|
|
179
|
+
// become hostage to the slowest project's uptime.
|
|
180
|
+
async function getProjectStats(origins = []) {
|
|
181
|
+
const now = Date.now();
|
|
182
|
+
const cold = [];
|
|
183
|
+
|
|
184
|
+
for (const origin of origins) {
|
|
185
|
+
const hit = _cache.get(origin);
|
|
186
|
+
if (!hit) cold.push(origin);
|
|
187
|
+
else if (now - hit.at > TTL_MS) refresh(origin); // stale: revalidate, do not await
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (cold.length) {
|
|
191
|
+
await Promise.race([
|
|
192
|
+
Promise.allSettled(cold.map((o) => refresh(o))),
|
|
193
|
+
new Promise((r) => setTimeout(r, COLD_DEADLINE_MS)),
|
|
194
|
+
]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// null-prototype: an origin literally equal to '__proto__' would otherwise
|
|
198
|
+
// retarget the object's prototype instead of adding a key
|
|
199
|
+
const out = Object.create(null);
|
|
200
|
+
for (const origin of origins) out[origin] = _cache.get(origin)?.stats || { builders: null, tags: null };
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// tests
|
|
205
|
+
function _reset() { _cache.clear(); _inflight.clear(); _coreModules = null; }
|
|
206
|
+
|
|
207
|
+
module.exports = { getProjectStats, isSystemAccount, coreModules, blockedReason, assertPublicHost, _reset, TTL_MS };
|