@bongos/core 1.19.108 → 1.19.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.108",
6
- "core_contract": "1.19.108",
7
- "source_commit": "bafad9a6b64800aa591f105f14a5a67cf39a54d9",
5
+ "core_version": "1.19.110",
6
+ "core_contract": "1.19.110",
7
+ "source_commit": "79edae113d503dfa316b70b73aa3a7f46e47c826",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-14T13:14:28.346Z",
9
+ "built_at": "2026-08-14T14:33:11.900Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 301,
12
+ "docs_redacted": 302,
13
13
  "agent_docs_stubbed": 21,
14
14
  "functional_verbatim": 1236,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1558,
20
- "tree_sha256": "7130b0e01712f13175140a4ddc52041177b8f660fab0029699212a445ebf59ad",
19
+ "file_count": 1559,
20
+ "tree_sha256": "58c1d6a96e05ab0a595fd590e99d4e11ca615200feb67b546999e3d45c6659af",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1324,10 +1324,15 @@
1324
1324
  "mode": "0000644",
1325
1325
  "sha256": "fe1c4b38b9b3eef60ecd00ecc35eb6086690350a022d2c376d1432c95c9452f7"
1326
1326
  },
1327
+ {
1328
+ "path": "docs/adr/0170-featured-projection-honest-facts.md",
1329
+ "mode": "0000644",
1330
+ "sha256": "165324f1fe4fe9a532b5a068a2713bd127d1d9e5926d3964869808ee1af1db47"
1331
+ },
1327
1332
  {
1328
1333
  "path": "docs/adr/README.md",
1329
1334
  "mode": "0000644",
1330
- "sha256": "529d997cf39669fe80bf0fb9a02c546adbb6ba139d4f8dad8de4f59932c47a95"
1335
+ "sha256": "f9bba0779b60b4a9f3014e63d49f4d5518439021f02cbab2e08d02d574edd108"
1331
1336
  },
1332
1337
  {
1333
1338
  "path": "docs/api-reference.md",
@@ -3747,7 +3752,7 @@
3747
3752
  {
3748
3753
  "path": "modules/platform-identity/platform-identity.js",
3749
3754
  "mode": "0000644",
3750
- "sha256": "2b3b1fe16be0653d498452d560521ef8ad48e359f13a1f76227f9ed94ece6f2d"
3755
+ "sha256": "2a0ee2667f79cbe8eaeb1dfb47b0c518bd969e80a60e42e0f709fa83365f908e"
3751
3756
  },
3752
3757
  {
3753
3758
  "path": "modules/platform-identity/pollers/code-sweep.js",
@@ -3757,7 +3762,7 @@
3757
3762
  {
3758
3763
  "path": "modules/platform-identity/project-stats.js",
3759
3764
  "mode": "0000644",
3760
- "sha256": "6ce71a9bd387a2b79d4c57a6669f028a807ef88ff205f65c20c55ce3d9889214"
3765
+ "sha256": "f3d8b2ed8bc5a32dabcbdd628fd4a504a8d91e2e2ea282a2ad2c88083f88b4e8"
3761
3766
  },
3762
3767
  {
3763
3768
  "path": "modules/platform-identity/routes/my-projects.js",
@@ -3767,7 +3772,7 @@
3767
3772
  {
3768
3773
  "path": "modules/platform-identity/routes/projects.js",
3769
3774
  "mode": "0000644",
3770
- "sha256": "188583ffb979d305c57a8f0958806ee0bd813007df158d74e45a0d415a3ff6aa"
3775
+ "sha256": "3222e294691563874be9b19dda57fe879b29d0bd174edef06f6db0f483863b11"
3771
3776
  },
3772
3777
  {
3773
3778
  "path": "modules/platform-identity/routes/scouting.js",
@@ -3782,7 +3787,7 @@
3782
3787
  {
3783
3788
  "path": "modules/platform-identity/tests/platform-identity.mjs",
3784
3789
  "mode": "0000644",
3785
- "sha256": "ead831bcf55ddf1adb247028be011d8a5a8020757e5e0e7ca49d633cecac91b0"
3790
+ "sha256": "7766d918d22a2d73f9275ab07d6273b66fc0cdcdc6d2952986e01bab265344d2"
3786
3791
  },
3787
3792
  {
3788
3793
  "path": "modules/platform-identity/ui/.gitkeep",
@@ -4147,12 +4152,12 @@
4147
4152
  {
4148
4153
  "path": "package-lock.json",
4149
4154
  "mode": "0000644",
4150
- "sha256": "f205db41eab8846e667c61aad6739b8194db55326d38562f0e6a674ecb0cd8ce"
4155
+ "sha256": "11349cbb742f6c35dae25caab63b6c8f8baa8dff40c3dd111efb5d7805cf64eb"
4151
4156
  },
4152
4157
  {
4153
4158
  "path": "package.json",
4154
4159
  "mode": "0000644",
4155
- "sha256": "afc15cf17f555b46d2e616cea755061e6d6751e06b582ad9253ff9dc257a203d"
4160
+ "sha256": "d8f6c0bd00562e5abb9513b1f76a00f7b172c53666184fc5211448fb61e23e2b"
4156
4161
  },
4157
4162
  {
4158
4163
  "path": "public-docs/index.html",
@@ -4392,7 +4397,7 @@
4392
4397
  {
4393
4398
  "path": "public-landing/assets/cosmos.css",
4394
4399
  "mode": "0000644",
4395
- "sha256": "e5bfd01db8cca1cb28c2d5c2382a401a2ee882cb1a219ed5553d578f2e3952b6"
4400
+ "sha256": "4b549b012f7e736ee81e54ed35a65c96acf9cd6a223c3e9fac369d9328426a88"
4396
4401
  },
4397
4402
  {
4398
4403
  "path": "public-landing/buddha.png",
@@ -4412,7 +4417,7 @@
4412
4417
  {
4413
4418
  "path": "public-landing/projects.html",
4414
4419
  "mode": "0000644",
4415
- "sha256": "e6e951af6fc202a2d90accef69e32da7d8a3456b80a6fcdbce469274edb49bce"
4420
+ "sha256": "264e6f7cb1273b24208b3063bec53f544d86f9174a4bd854a57c784722427583"
4416
4421
  },
4417
4422
  {
4418
4423
  "path": "public-landing/world/assets/clouds/cloud-island-large.png",
@@ -5727,7 +5732,7 @@
5727
5732
  {
5728
5733
  "path": "src/module-api.js",
5729
5734
  "mode": "0000644",
5730
- "sha256": "309c33b07e1c5391726d8bf73d2c0d595cab3b9624244df0f7cd8fcbe068e3a5"
5735
+ "sha256": "02dac8c896c2b6ff65af8c69ccca22979cfe4ca5519976a43154827fe640907e"
5731
5736
  },
5732
5737
  {
5733
5738
  "path": "src/module-loader/catalog.js",
@@ -6892,7 +6897,7 @@
6892
6897
  {
6893
6898
  "path": "tests/landing_page.mjs",
6894
6899
  "mode": "0000644",
6895
- "sha256": "be7364e749e00920e65f16ca685bee1e068d62adf9dc9ed0817381f2d0af4c7e"
6900
+ "sha256": "89a9d21ee1c5b00ee09de2d888873fa13c1b2b6d15ab05a6bfeb7e7a5d016a03"
6896
6901
  },
6897
6902
  {
6898
6903
  "path": "tests/lifecycle-card.mjs",
@@ -7247,12 +7252,12 @@
7247
7252
  {
7248
7253
  "path": "tests/project_stats.mjs",
7249
7254
  "mode": "0000644",
7250
- "sha256": "21bd2a2d0ebb234cd3b9770d126e77ce48f5c630fe7deee2585706d4eb072b53"
7255
+ "sha256": "6bd78c47b6dbbdd81f862df8afdc0b514e27f0ffb71d14161ac8feb19f9fbe19"
7251
7256
  },
7252
7257
  {
7253
7258
  "path": "tests/projects_hub.mjs",
7254
7259
  "mode": "0000644",
7255
- "sha256": "6ddec55050ca5864670d02caad6ede76e5e0753afb1e5636036b75d4185e9427"
7260
+ "sha256": "5b23c369288ebdfd4a35ad76faa5ac5fbf9ff7b55de2a283b184977ba5513abe"
7256
7261
  },
7257
7262
  {
7258
7263
  "path": "tests/provision.mjs",
@@ -0,0 +1,24 @@
1
+ # 0170 — The featured projection tells time honestly: `created_at` in, `updated_at` out, owner from the project's own archon
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-08-14
5
+ - **Task:** [#1002778](https://cloudbongos.com/builders#/task/1002778) (goal 1000061 — the front door and the project hub)
6
+ - **Extends:** the C6 honesty rule ([`docs/design/projects-hub-direction.md`](../design/projects-hub-direction.md) §7) and the self-report enrichment pattern of task 1002771 (`project-stats.js`).
7
+
8
+ ## Context
9
+
10
+ Task 1002778 enriches the hub's project card with real metadata. Its brief listed `updated_at` under "already available, no new plumbing: add it to the public projection", and offered two owner options: "resolve it to a real login + avatar via the project's own API, or omit the field until it can be answered." Implementation surfaced facts that changed both calls, and this ADR records the deviations so they are decisions, not drift.
11
+
12
+ ## Decision
13
+
14
+ **1. `created_at` joins the public `/projects/featured` projection, labelled "Listed" — never "Created".** The column is set once by `DEFAULT now()` on INSERT and never touched again: it is the moment the hub first catalogued the row, not when the project began (Example predates its row by months). The card wears the honest label, and the C6 card test bans the word "Created" outright.
15
+
16
+ **2. `updated_at` stays OUT of the projection, deviating from the task brief.** Reading the code showed the column is bumped by `upsertProject`'s ON CONFLICT branch — which fires on **every client re-registration and secret rotation** — and by `setProjectFeatured`, the Archon's own curation toggle. As a public "last updated" it would claim project activity while actually reporting curation noise: a project's timestamp would refresh because someone clicked "feature". Publishing it would be the same class of lie the em-dash rules exist to prevent. The projection test now pins the SELECT list itself (`updated_at` may sort, never project; `SELECT *` is banned — the route is CORS-open and rank:public, so a star projection would leak `owner_github_id` and `status`).
17
+
18
+ **3. The owner fact is the project's own archon row — `owner_github_id` stays unread.** The catalog's `owner_github_id` calls itself "advisory attribution (not an authority)" in its own migration, is NULL on most live rows, and the design contract forbids reading it. But the leaderboard payload `project-stats.js` already downloads for the builders count carries `rank`, `github_login`, and `avatar_url` per row — and this platform's own vocabulary defines the archon as the instance's owner (`selfRel()`: "Archon is the instance's owner"). So the enrichment surfaces the archon row's login + avatar: the project's own authority answering for itself, zero extra requests, the 2-fetch-per-origin pin intact. Values are type-checked and length-capped server-side (third-party payload), and the avatar URL is scheme-checked again client-side before it becomes an `<img>`.
19
+
20
+ ## Consequences
21
+
22
+ - Every card fact is something some authority actually asserted; unknowns hide rather than dash. A "last updated" fact can return later if a project's **own** API ever reports real activity recency (e.g. newest `shipped_at` from `/public/recent-shipped`) — that would be a third fetch and needs the test pin moved deliberately.
23
+ - On a multi-archon instance the top-credits archon is named; a zero-credit archon on a page-capped leaderboard can fall off, which renders as **no owner shown**, never a wrong one.
24
+ - The hub republishes a login the source project already serves unauthenticated at `/api/bongos/public/leaderboard` — aggregation, not new disclosure (grader-reviewed).
@@ -243,5 +243,6 @@ This keeps the decision history honest and traceable.
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
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 |
246
+ | 0170 | [**The featured projection tells time honestly: `created_at` in, `updated_at` out, owner from the project's own archon** ([task 1002778](https://cloudbongos.com/builders#/task/1002778), goal 1000061). The hub card's metadata pass added `created_at` to the public `/projects/featured` projection, labelled **Listed** — it is the moment the hub catalogued the row, never when the project began, and the C6 card test bans the word "Created". Two recorded deviations: (1) the task brief said `updated_at` was ready to add, but the column is bumped by every client re-registration AND the Archon's feature toggle, so as a public "last updated" it would report curation noise as project activity — it may sort, never project, and the test pins the SELECT list itself (`SELECT *` banned: the route is CORS-open rank:public and a star would leak `owner_github_id`/`status`). (2) The owner fact reads the project's OWN leaderboard archon row (login + avatar, type-checked and capped) from the payload project-stats already fetches — zero extra requests, the 2-fetch pin intact, the advisory `owner_github_id` column still unread per the design contract. Multi-archon: top-credits archon is named; a fallen-off-the-page archon renders as no owner shown, never a wrong one. Rejected: projecting `updated_at` as asked (it lies), reading `owner_github_id` (advisory, contract-banned), a third per-origin fetch for activity recency (moves the test pin for a fact nobody asked for yet).](<redacted>.md) | platform identity / public surface |
246
247
 
247
248
  > ⚠️ **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`.)
@@ -238,10 +238,14 @@ async function getProject(id, { pool = defaultPool } = {}) {
238
238
  }
239
239
 
240
240
  // The PUBLIC Explore listing — featured + active projects only, public projection
241
- // (no owner_github_id, no status). Newest first.
241
+ // (no owner_github_id, no status). Newest first. created_at is the LISTED date —
242
+ // when the hub first catalogued the row, not when the project began — and the
243
+ // card labels it "Listed" for exactly that reason (task 1002778). updated_at may
244
+ // sort but is never projected: feature toggles and client re-registration bump
245
+ // it, so as a public "last updated" it would lie.
242
246
  async function listFeaturedProjects({ pool = defaultPool } = {}) {
243
247
  const { rows } = await pool.query(
244
- `SELECT origin, name, tagline, art_url
248
+ `SELECT origin, name, tagline, art_url, created_at
245
249
  FROM platform_identity_projects
246
250
  WHERE featured = true AND status = 'active'
247
251
  ORDER BY updated_at DESC`,
@@ -136,8 +136,26 @@ async function fetchStats(origin) {
136
136
  ]);
137
137
 
138
138
  let builders = null;
139
+ let owner = null;
140
+ let ownerAvatar = null;
139
141
  if (lb.status === 'fulfilled' && Array.isArray(lb.value?.leaderboard)) {
140
- builders = lb.value.leaderboard.filter((b) => !isSystemAccount(b?.github_login)).length;
142
+ const people = lb.value.leaderboard.filter((b) => !isSystemAccount(b?.github_login));
143
+ builders = people.length;
144
+ // The archon row IS the instance's owner — the project's own authority
145
+ // answering for itself, from a payload already in hand (ADR 0170). The
146
+ // catalog's owner_github_id ("advisory attribution (not an authority)")
147
+ // stays unread. Caveat: the leaderboard is credits-ordered and page-capped,
148
+ // so a zero-credit archon on a very large instance can fall off it — that
149
+ // renders as no owner shown, never as a wrong one.
150
+ // The payload is third-party: accept only a plausible login string (GitHub
151
+ // caps logins at 39 chars; 64 leaves headroom without hosting a novel),
152
+ // and only an http(s) avatar URL — the client scheme-checks again before
153
+ // it becomes an <img>.
154
+ const arch = people.find((b) => b?.rank === 'archon');
155
+ const login = arch?.github_login;
156
+ owner = typeof login === 'string' && login ? login.slice(0, 64) : null;
157
+ const av = arch?.avatar_url;
158
+ ownerAvatar = owner && typeof av === 'string' && /^https?:\/\//.test(av) ? av.slice(0, 300) : null;
141
159
  }
142
160
 
143
161
  let tags = null;
@@ -149,7 +167,7 @@ async function fetchStats(origin) {
149
167
  .sort();
150
168
  }
151
169
 
152
- return { builders, tags };
170
+ return { builders, tags, owner, owner_avatar: ownerAvatar };
153
171
  }
154
172
 
155
173
  function refresh(origin) {
@@ -163,7 +181,7 @@ function refresh(origin) {
163
181
  // Remember the FAILURE for a full TTL too. Without this a permanently
164
182
  // unreachable project is re-fetched on every request and pays the timeout
165
183
  // every time — the exact way one dead project slows the whole page.
166
- const stats = { builders: null, tags: null };
184
+ const stats = { builders: null, tags: null, owner: null, owner_avatar: null };
167
185
  _cache.set(origin, { at: Date.now(), stats });
168
186
  return stats;
169
187
  })
@@ -197,7 +215,7 @@ async function getProjectStats(origins = []) {
197
215
  // null-prototype: an origin literally equal to '__proto__' would otherwise
198
216
  // retarget the object's prototype instead of adding a key
199
217
  const out = Object.create(null);
200
- for (const origin of origins) out[origin] = _cache.get(origin)?.stats || { builders: null, tags: null };
218
+ for (const origin of origins) out[origin] = _cache.get(origin)?.stats || { builders: null, tags: null, owner: null, owner_avatar: null };
201
219
  return out;
202
220
  }
203
221
 
@@ -119,6 +119,8 @@ module.exports = function projectsRoutes() {
119
119
  ...p,
120
120
  builders: stats[p.origin]?.builders ?? null,
121
121
  tags: stats[p.origin]?.tags ?? null,
122
+ owner: stats[p.origin]?.owner ?? null,
123
+ owner_avatar: stats[p.origin]?.owner_avatar ?? null,
122
124
  })),
123
125
  });
124
126
  } catch (err) {
@@ -198,12 +198,22 @@ await ta('upsertProject upserts on normalized origin; update preserves tagline/a
198
198
  });
199
199
 
200
200
  await ta('listFeaturedProjects returns a PUBLIC projection (featured+active only, no owner/status)', async () => {
201
- const pool = mockPool([{ rows: [{ origin: 'https://p', name: 'Proj', tagline: 't', art_url: null }] }]);
201
+ const pool = mockPool([{ rows: [{ origin: 'https://p', name: 'Proj', tagline: 't', art_url: null, created_at: '2026-08-01T00:00:00Z' }] }]);
202
202
  const out = await P.listFeaturedProjects({ pool });
203
203
  assert.equal(out.length, 1);
204
204
  const c = pool.calls[0];
205
205
  assert.match(c.text, /WHERE featured = true AND status = 'active'/);
206
+ // Pin the SELECT LIST itself, not the whole query — an ORDER BY mention must
207
+ // not satisfy a projection guard, and SELECT * must never sneak owner/status
208
+ // onto a CORS-open rank:public route.
209
+ const sel = (c.text.match(/SELECT([\s\S]*?)FROM/i) || [, ''])[1];
210
+ assert.ok(sel.trim(), 'the projection is an explicit SELECT list');
211
+ assert.doesNotMatch(sel, /\*/, 'no star projection on a public route');
212
+ assert.match(sel, /created_at/, 'the LISTED date (when the row was catalogued) is public');
206
213
  assert.doesNotMatch(c.text, /owner_github_id/, 'public projection must not leak owner id');
214
+ // updated_at may sort (ORDER BY) but never project: it is bumped by feature
215
+ // toggles and client re-registration, so as a public fact it would lie.
216
+ assert.doesNotMatch(sel, /updated_at/, 'updated_at is curation noise — never projected');
207
217
  });
208
218
 
209
219
  await ta('setProjectFeatured flips the flag (Archon curation)', async () => {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.108",
3
+ "version": "1.19.110",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.108",
9
+ "version": "1.19.110",
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.108",
3
+ "version": "1.19.110",
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",
@@ -394,7 +394,23 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
394
394
  text-transform:lowercase;color:var(--chrome-ink);text-decoration:none;text-shadow:var(--chrome-glow);
395
395
  white-space:nowrap;}
396
396
  .shell .mark span{color:var(--chrome-accent);}
397
- .shell .caret{color:var(--chrome-accent);animation:cbBlink 1s step-end infinite;font-weight:400;}
397
+ /* Two colors, one system (task 1002793): the landing's static mark wraps only
398
+ bongos in a span, so the shared rule above already colors it alone there.
399
+ Here both words are spans, so the typed prefix opts back into the base ink
400
+ by id — the shared rule must not change, the landing leans on it. */
401
+ .shell .mark #markPrefix{color:var(--chrome-ink);}
402
+ /* the prefix's width-pinned box: bongos holds still while letters come and go;
403
+ the width transition covers the between-words change only */
404
+ .shell .markLeft{display:inline-block;text-align:left;white-space:nowrap;transition:width .18s ease;}
405
+ /* (no reduced-motion guard here: the global kill switch at the end of this
406
+ file already zeroes every transition, and the test that pins it extracts
407
+ the FIRST reduced-motion block — an earlier one would shadow it) */
408
+ .shell .markMeasure{position:absolute;visibility:hidden;white-space:pre;pointer-events:none;}
409
+ /* a drawn 2px bar, not a glyph: a block glyph keeps a full character cell of
410
+ width even while blinked invisible, and + the literal space it read as two
411
+ characters of air between the words */
412
+ .shell .caret{display:inline-block;width:2px;height:.85em;margin-left:1px;vertical-align:-.1em;
413
+ background:var(--chrome-accent);animation:cbBlink 1s step-end infinite;}
398
414
  @keyframes cbBlink{50%{opacity:0;}}
399
415
  .shell-right{flex:1 1 0;display:flex;align-items:center;justify-content:flex-end;
400
416
  gap:clamp(8px,1.6vw,14px);flex-wrap:wrap;}
@@ -581,6 +597,17 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
581
597
  border-radius:999px;background:var(--accent-soft);color:var(--accent-deep);margin-bottom:18px;}
582
598
  :root[data-mode="dark"] .projCard .cardRel{color:var(--sun-lit);}
583
599
  @media (prefers-color-scheme: dark){:root:not([data-mode="light"]) .projCard .cardRel{color:var(--sun-lit);}}
600
+ /* the facts strip (task 1002778): a label/value grid, centered as a block but
601
+ read left-aligned. A hidden pair leaves no gap — the grid row collapses. */
602
+ .projCard .cardMeta{display:grid;grid-template-columns:auto auto;justify-content:center;
603
+ gap:6px 14px;margin-bottom:18px;text-align:left;}
604
+ .projCard .cardMeta dt{font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;
605
+ text-transform:uppercase;color:var(--ink-faint);align-self:center;}
606
+ .projCard .cardMeta dd{font-size:13.5px;color:var(--ink-soft);font-weight:600;}
607
+ .projCard .cardMeta .ownerAv{width:16px;height:16px;border-radius:50%;vertical-align:-3px;margin-right:6px;}
608
+ .projCard .cardMeta .tags{display:flex;gap:6px;flex-wrap:wrap;}
609
+ .projCard .cardMeta .tags span{font-family:var(--font-mono);font-size:10.5px;padding:2px 8px;
610
+ border-radius:999px;border:1px solid var(--line);color:var(--ink-faint);white-space:nowrap;}
584
611
  .projCard .cardActions{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;}
585
612
  .projCard .cardX{position:absolute;top:10px;right:12px;width:30px;height:30px;border-radius:50%;
586
613
  border:none;background:none;cursor:pointer;font-size:14px;color:var(--ink-faint);line-height:1;}
@@ -87,8 +87,14 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
87
87
  the word you type and the assistant is what varies — which is also what
88
88
  the vanity doors do (claudebongos.com, geminibongos.com swap the first
89
89
  word, not the second). The typing only runs on the default two-word name;
90
- a re-branded instance gets its own wordmark, static. -->
91
- <a class="mark" href="/"><span id="markPrefix">cloud</span><span class="caret" aria-hidden="true">▌</span> <span id="markWord">bongos</span></a>
90
+ a re-branded instance gets its own wordmark, static.
91
+ "Still" is literal (task 1002793): the prefix + caret live in #markLeft,
92
+ whose width is pinned to the coming word BEFORE typing, so letters come
93
+ and go without bongos moving; #markMeasure is the hidden ruler. The
94
+ caret is a drawn 2px bar, not a glyph — a block glyph plus the literal
95
+ space read as two characters of air. Only bongos wears the accent, the
96
+ same split the landing's static mark already has. -->
97
+ <a class="mark" href="/"><span class="markLeft" id="markLeft"><span id="markPrefix">cloud</span><span class="caret" aria-hidden="true"></span></span> <span id="markWord">bongos</span><span class="markMeasure" id="markMeasure" aria-hidden="true"></span></a>
92
98
 
93
99
  <nav class="navrow" aria-label="Project hub">
94
100
  <a href="/" class="navHome" data-nav="home">Home</a>
@@ -213,6 +219,19 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
213
219
  <h3 id="cardName"></h3>
214
220
  <p class="cardTag" id="cardTag"></p>
215
221
  <p class="cardRel" id="cardRel" hidden></p>
222
+ <!-- The facts strip. A definition list, never nested block wrappers, on
223
+ purpose: the C6 card test slices the card non-greedily up to the
224
+ first adjacent pair of closing wrapper tags, and a nested wrapper
225
+ would silently shorten what that test guards. A fact with no answer
226
+ hides, label and value together, because "Owner: dash" on the most
227
+ authoritative-looking element of the page reads as broken, not
228
+ honest (task 1002778). -->
229
+ <dl class="cardMeta" id="cardMeta" hidden>
230
+ <dt id="cardBuildersL" hidden>Builders</dt><dd id="cardBuilders" hidden></dd>
231
+ <dt id="cardRunsL" hidden>Runs</dt><dd id="cardRuns" hidden></dd>
232
+ <dt id="cardOwnerL" hidden>Owner</dt><dd id="cardOwner" hidden></dd>
233
+ <dt id="cardListedL" hidden>Listed</dt><dd id="cardListed" hidden></dd>
234
+ </dl>
216
235
  <div class="cardActions" id="cardActions"></div>
217
236
  </div>
218
237
  </div>
@@ -494,9 +513,14 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
494
513
  once. Same two numbers the hero uses, so the two surfaces read as one
495
514
  system rather than two animations that happen to share a font. */
496
515
  var TYPE = 72, ERASE = 38, HOLD = 900, HOLD_HOME = 2100;
497
- var el = $('markPrefix'), wi = 0;
516
+ var el = $('markPrefix'), box = $('markLeft'), meas = $('markMeasure'), wi = 0;
498
517
  (function cycle() {
499
518
  var w = PREFIXES[wi++ % PREFIXES.length], i = 0;
519
+ /* pin the box to the coming word's rendered width BEFORE typing: bongos
520
+ must not move while letters come and go (the owner's call, 2026-08-14).
521
+ Between words the CSS width transition glides instead of jumping. */
522
+ meas.textContent = w;
523
+ box.style.width = (meas.offsetWidth + 4) + 'px';
500
524
  (function type() {
501
525
  if (i <= w.length) { el.textContent = w.slice(0, i++); setTimeout(type, TYPE); }
502
526
  else setTimeout(function erase() {
@@ -511,8 +535,10 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
511
535
  The public catalog: GET /projects/featured is the PUBLIC projection
512
536
  (featured + active only, no auth), which is what lets a signed-out visitor
513
537
  land on the map at all. The richer GET /projects needs project.curate and
514
- would 401 them. It carries origin/name/tagline/art_url and nothing else —
515
- so builders, type and tags render as "—" rather than as invented values.
538
+ would 401 them. It carries origin/name/tagline/art_url/created_at plus
539
+ the builders/tags/owner each project's OWN public API reports (task
540
+ 1002778) — anything a project could not report renders as a dash or hides,
541
+ never as an invented value.
516
542
  The orbs are placed on fixed slots: where things sit is this pass; how the
517
543
  sky moves is goal 1000031. ══ */
518
544
 
@@ -604,11 +630,14 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
604
630
  /* A tag is a module the project RUNS beyond the core set — a fact it declares
605
631
  about itself, not a guess about what it is. Capped so one heavily-moduled
606
632
  project cannot blow the column open; the overflow is counted, never hidden
607
- silently. null = we could not ask (em dash); [] = asked, runs nothing beyond
608
- core, which is a real answer and still reads as a dash in the cell. */
633
+ silently. null = we could not ask (em dash); [] = asked, runs nothing
634
+ beyond core — a real answer, voiced as "core" (the owner's call,
635
+ 2026-08-14) so a vanilla instance stops reading as a fetch failure. One
636
+ formatter, two views: the table cell and the card must never disagree. */
609
637
  var TAG_CAP = 3;
610
638
  function tagsHtml(tags) {
611
- if (!Array.isArray(tags) || !tags.length) return '—';
639
+ if (!Array.isArray(tags)) return '—';
640
+ if (!tags.length) return 'core';
612
641
  var shown = tags.slice(0, TAG_CAP).map(function (t) { return '<span>' + esc(t) + '</span>'; });
613
642
  if (tags.length > TAG_CAP) shown.push('<span>+' + (tags.length - TAG_CAP) + '</span>');
614
643
  return '<span class="tags">' + shown.join('') + '</span>';
@@ -791,11 +820,14 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
791
820
  2026-08-13. The card is layered OVER the links rather than replacing them,
792
821
  so ctrl/middle-click still opens a project's site directly.
793
822
 
794
- It states only what the public catalog actually carries — name, tagline,
795
- your relationship, a door — because /projects/featured selects four columns
796
- and cannot answer anything richer for a catalog row (task 1002744 owns that
797
- data path). A card that showed builders or liveness here would be inventing
798
- both, the same C6 lie the table's em dashes exist to avoid. ══ */
823
+ It states what the catalog and the project ITSELF can answer: name,
824
+ tagline, your relationship, a door — and, when known, builders, runs,
825
+ owner and the Listed date (task 1002778). Builders/runs/owner are each
826
+ project's own public API answering for itself (project-stats.js); Listed
827
+ is created_at, when the hub catalogued the row — NOT when the project
828
+ began, which is why the label is never "Created". Liveness stays out: a
829
+ catalog row is never probed, and a chip here would be the C6 lie the
830
+ table's em dashes exist to avoid. An unanswerable fact hides. ══ */
799
831
 
800
832
  var cardReturn = null; /* the element focus came from, to hand it back */
801
833
 
@@ -804,6 +836,26 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
804
836
  return null;
805
837
  }
806
838
 
839
+ /* The owner fact is a login and, when the project reported one, an avatar.
840
+ Both arrive from a third-party payload: the login is escaped, the avatar
841
+ URL scheme-checked (a javascript: URL must never become an img src). */
842
+ function ownerHtml(p) {
843
+ if (!p.owner) return '';
844
+ var av = safeHttpUrl(p.owner_avatar);
845
+ return (av ? '<img class="ownerAv" src="' + esc(av) + '" alt="" loading="lazy">' : '') +
846
+ '<span>' + esc(String(p.owner)) + '</span>';
847
+ }
848
+
849
+ /* A fact either has an answer or takes no space: value and label hide as a
850
+ pair. Every field is re-written on every open — closeCard() clears nothing,
851
+ so a stale value would otherwise survive into the next project's card. */
852
+ function cardFact(id, value, isHtml) {
853
+ var dd = $(id);
854
+ if (isHtml) dd.innerHTML = value || ''; else dd.textContent = value || '';
855
+ dd.hidden = $(id + 'L').hidden = !value;
856
+ return !!value;
857
+ }
858
+
807
859
  function openCard(p, rel, returnTo) {
808
860
  var href = p.isSelf ? '/' : safeHttpUrl(p.origin);
809
861
  $('cardDot').style.setProperty('--acc', p.isSelf ? 'var(--accent)' : accentOf(p));
@@ -812,6 +864,16 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
812
864
  var label = rel === 'owner' ? 'yours' : rel === 'builder' ? 'you build here' : '';
813
865
  $('cardRel').textContent = label;
814
866
  $('cardRel').hidden = !label;
867
+ /* builders: 0 is a real answer ("nobody has joined yet"), null is "we could
868
+ not ask" and hides. runs: [] is a real answer too — the project declares
869
+ nothing beyond the core set, which the owner voiced as "core". */
870
+ var any = cardFact('cardBuilders', p.builders == null ? '' : String(p.builders));
871
+ any = cardFact('cardRuns', Array.isArray(p.tags) ? tagsHtml(p.tags) : '', true) || any;
872
+ /* when the rel chip already says "yours", naming the viewer as Owner is
873
+ noise — the fact answers strangers, not the person it describes */
874
+ any = cardFact('cardOwner', rel === 'owner' ? '' : ownerHtml(p), true) || any;
875
+ any = cardFact('cardListed', p.created_at ? listedDate(p.created_at) : '') || any;
876
+ $('cardMeta').hidden = !any;
815
877
  /* the platform's door is same-tab: / is this site, and a new tab for the
816
878
  page you are already on is a bug, not a courtesy */
817
879
  $('cardActions').innerHTML = href
@@ -909,6 +971,15 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
909
971
  if (!rows.length) return;
910
972
  var real = rows.filter(function (b) { return !/-system$/.test(String(b.github_login || '')); });
911
973
  $('selfBuilders').textContent = String(real.length);
974
+ /* the card reads these too: the same payload answers the platform's
975
+ builder count and its owner — the archon row — by the same rule the
976
+ server-side enrichment applies to every other project */
977
+ SELF_PROJECT.builders = real.length;
978
+ var arch = real.filter(function (b) { return b.rank === 'archon'; })[0];
979
+ if (arch && arch.github_login) {
980
+ SELF_PROJECT.owner = String(arch.github_login);
981
+ SELF_PROJECT.owner_avatar = arch.avatar_url || null;
982
+ }
912
983
  })
913
984
  .catch(function () { /* unreachable → the dash already in the markup stands */ });
914
985
  }
@@ -1092,6 +1163,11 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
1092
1163
  function shortDate(iso) {
1093
1164
  return iso ? new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
1094
1165
  }
1166
+ /* the year matters on the card: catalogue entries sit years apart, and
1167
+ "Aug 13" with no year reads as recent activity, not a date of record */
1168
+ function listedDate(iso) {
1169
+ return iso ? new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '';
1170
+ }
1095
1171
  /* "since 14:02" today, "since Aug 7" earlier — for the unreachable chip */
1096
1172
  function clockOrDate(ts) {
1097
1173
  var d = new Date(ts);
package/src/module-api.js CHANGED
@@ -643,8 +643,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
643
643
  // landed since 1.19.106 with no explicit bump. run 31762781737. (task 1002620)
644
644
  // 1.19.108 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
645
645
  // landed since 1.19.107 with no explicit bump. run 31803763842. (task 1002620)
646
+ // 1.19.109 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
647
+ // landed since 1.19.108 with no explicit bump. run 31809188532. (task 1002620)
648
+ // 1.19.110 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
649
+ // landed since 1.19.109 with no explicit bump. run 31810003558. (task 1002620)
646
650
  // ---------------------------------------------------------------------------
647
- const CORE_VERSION = '1.19.108'; // CI auto-patch carrier (ADR 0161); see changelog above
651
+ const CORE_VERSION = '1.19.110'; // CI auto-patch carrier (ADR 0161); see changelog above
648
652
 
649
653
  // A namespaced logger so a module's log lines are attributable + consistent.
650
654
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -262,8 +262,9 @@ test('the proof band ships hidden so a failed or empty feed leaves no orphan hea
262
262
  const CODE = HTML.split('<script>').pop().replace(/\/\*[\s\S]*?\*\//g, '');
263
263
 
264
264
  test('the catalog claims no liveness the public feed cannot back', () => {
265
- // listFeaturedProjects projects {origin, name, tagline, art_url} — no status,
266
- // so a verification chip here would be a claim nothing checked.
265
+ // listFeaturedProjects projects {origin, name, tagline, art_url, created_at}
266
+ // plus self-reported stats — still no status, so a verification chip here
267
+ // would be a claim nothing checked.
267
268
  assert.doesNotMatch(CODE, /chip (live|maybe|down)/);
268
269
  assert.doesNotMatch(CODE, /ago/);
269
270
  });
@@ -58,6 +58,76 @@ test('system accounts are excluded — they are machines, not builders', async (
58
58
  assert.equal(out['https://8.8.8.8'].builders, 2, 'four rows, two of them machines');
59
59
  });
60
60
 
61
+ test("the owner is the project's own archon row, never the advisory catalog column", async () => {
62
+ // platform_identity_projects.owner_github_id is "advisory attribution (not an
63
+ // authority)" and stays out of every public projection. The project's own
64
+ // leaderboard — already fetched for the builders count — carries rank, and its
65
+ // archon row IS the instance's owner, reported by the one authority that knows.
66
+ stub({
67
+ 'https://8.8.8.8': {
68
+ leaderboard: [
69
+ { github_login: 'zed', rank: 'metic' },
70
+ { github_login: 'amy', rank: 'archon', avatar_url: 'https://avatars.example.com/amy.png' },
71
+ ],
72
+ enabled: {},
73
+ },
74
+ });
75
+ const out = await stats.getProjectStats(['https://8.8.8.8']);
76
+ assert.equal(out['https://8.8.8.8'].owner, 'amy');
77
+ assert.equal(out['https://8.8.8.8'].owner_avatar, 'https://avatars.example.com/amy.png');
78
+ });
79
+
80
+ test('a non-http avatar from a third-party payload is dropped, the owner kept', async () => {
81
+ stub({
82
+ 'https://8.8.8.8': {
83
+ leaderboard: [{ github_login: 'amy', rank: 'archon', avatar_url: 'javascript:alert(1)' }],
84
+ enabled: {},
85
+ },
86
+ });
87
+ const out = await stats.getProjectStats(['https://8.8.8.8']);
88
+ assert.equal(out['https://8.8.8.8'].owner, 'amy');
89
+ assert.equal(out['https://8.8.8.8'].owner_avatar, null);
90
+ });
91
+
92
+ test('no archon on the page means no owner claimed', async () => {
93
+ stub({
94
+ 'https://8.8.8.8': {
95
+ leaderboard: [{ github_login: 'zed', rank: 'metic' }],
96
+ enabled: {},
97
+ },
98
+ });
99
+ const out = await stats.getProjectStats(['https://8.8.8.8']);
100
+ assert.equal(out['https://8.8.8.8'].owner, null, 'unknown must stay unknown, not guessed');
101
+ });
102
+
103
+ test('a machine account is never an owner, even wearing the archon rank', async () => {
104
+ stub({
105
+ 'https://8.8.8.8': {
106
+ leaderboard: [{ github_login: 'bfg-system', rank: 'archon' }],
107
+ enabled: {},
108
+ },
109
+ });
110
+ const out = await stats.getProjectStats(['https://8.8.8.8']);
111
+ assert.equal(out['https://8.8.8.8'].owner, null);
112
+ });
113
+
114
+ test('a project whose fetches reject answers a null owner', async () => {
115
+ // allSettled means a rejecting fetch still travels the fulfilled path —
116
+ // owner stays its initialized null, and null is what reaches the wire
117
+ globalThis.fetch = () => Promise.reject(new Error('down'));
118
+ const out = await stats.getProjectStats(['https://9.9.9.9']);
119
+ assert.equal(out['https://9.9.9.9'].owner, null);
120
+ });
121
+
122
+ test('every failure fallback carries the owner key — dead answers are null, never undefined', async () => {
123
+ // a bad-scheme origin makes publicUrl throw synchronously — the ONE path
124
+ // that reaches the cached-failure literal (a rejecting fetch never does)
125
+ const out = await stats.getProjectStats(['file:///etc/passwd']);
126
+ assert.ok('owner' in out['file:///etc/passwd'], 'the failure literal must carry the key');
127
+ assert.equal(out['file:///etc/passwd'].owner, null);
128
+ assert.ok('owner_avatar' in out['file:///etc/passwd'], 'both fallback literals share one shape');
129
+ });
130
+
61
131
  test('a tag is a NON-core module — core modules carry no information', async () => {
62
132
  // every instance runs the core set, so including it would print the same
63
133
  // fifteen tags on every row
@@ -109,6 +109,26 @@ test('the hall link is not labelled "Enter the Hall"', () => {
109
109
  assert.doesNotMatch(HUB, /Enter the Hall/i);
110
110
  });
111
111
 
112
+ test('the wordmark holds bongos still and colors it apart (task 1002793)', () => {
113
+ // Owner-reported: the block caret glyph + a literal space read as two
114
+ // characters of air; every typed letter shifted bongos; both words wore the
115
+ // accent. The caret is now a thin drawn bar (no glyph), the prefix sits in a
116
+ // width-pinned box measured per word BEFORE typing, and only bongos keeps
117
+ // the accent — the landing's static mark already colors only its bongos
118
+ // span, so the two shells read as one system.
119
+ assert.match(HUB,
120
+ /<span class="markLeft" id="markLeft"><span id="markPrefix">cloud<\/span><span class="caret" aria-hidden="true"><\/span><\/span> <span id="markWord">bongos<\/span>/,
121
+ 'prefix + caret share the pinned box; a single literal space reaches bongos; the caret carries no glyph');
122
+ assert.match(SCRIPT, /meas\.textContent = w/,
123
+ 'the coming word is measured before it is typed');
124
+ assert.match(SCRIPT, /box\.style\.width = \(meas\.offsetWidth \+ 4\) \+ 'px'/,
125
+ 'the box is pinned to the full word so bongos cannot move mid-word');
126
+ assert.ok(COSMOS.includes('.shell .mark #markPrefix{color:var(--chrome-ink);}'),
127
+ 'the typed word wears the base ink — the shared span rule (which the landing relies on) stays');
128
+ assert.match(COSMOS, /\.shell \.caret\{[^}]*width:2px[^}]*background:var\(--chrome-accent\)/,
129
+ 'the caret is a drawn 2px bar, not a full character cell');
130
+ });
131
+
112
132
  test('orb accents are tokens, never literal hexes (§6)', () => {
113
133
  // ONE palette serves both the map's spheres and the manage cards, so a
114
134
  // project reads the same colour in either view
@@ -166,8 +186,9 @@ test('catalog origins are scheme-checked before they become hrefs', () => {
166
186
  });
167
187
 
168
188
  test('liveness is never claimed for a catalog row the hub did not probe (C6)', () => {
169
- // the public feed carries origin/name/tagline/art_url and nothing else — a
170
- // chip saying "live" here would assert more than anything verified
189
+ // the public feed carries identity, the Listed date and self-reported stats
190
+ // (builders/tags/owner) — still no liveness, so a chip saying "live" here
191
+ // would assert more than anything verified
171
192
  assert.match(SCRIPT, /chip calm/, 'an unprobed catalog row gets the calm chip');
172
193
  assert.doesNotMatch(SCRIPT, /chip live[^']*'\s*\+\s*esc\(p\./,
173
194
  'no catalog row may be rendered as live');
@@ -360,12 +381,21 @@ test('focus enters the card and is handed back on close', () => {
360
381
  assert.match(SCRIPT, /cardReturn\.focus\(\)/);
361
382
  });
362
383
 
363
- test('the card claims nothing the public catalog cannot answer (C6)', () => {
384
+ test('the card claims only what the catalog and the project itself answer (C6)', () => {
364
385
  const card = (HUB.match(/<div class="cardScrim"[\s\S]*?<\/div>\s*<\/div>/) || [''])[0];
365
- // /projects/featured selects origin, name, tagline, art_url — and nothing else
366
- for (const invented of [/Builders/i, /\blive\b/i, /uptime/i, /\btags\b/i]) {
386
+ // /projects/featured serves origin, name, tagline, art_url, created_at, and the
387
+ // builders/tags/owner enrichment each project's OWN public API reports about
388
+ // itself (task 1002778). The card may state those. It still may not claim
389
+ // liveness nothing probed, and the catalogued date must never masquerade as a
390
+ // creation date — created_at is when the hub first heard of the project.
391
+ for (const invented of [/\blive\b/i, /uptime/i, /\bCreated\b/]) {
367
392
  assert.doesNotMatch(card, invented, `the card must not invent ${invented}`);
368
393
  }
394
+ for (const id of ['cardBuilders', 'cardRuns', 'cardOwner', 'cardListed']) {
395
+ assert.match(card, new RegExp(`id="${id}"`), `the ${id} fact must be real markup (C7)`);
396
+ }
397
+ assert.match(card, />Listed</, 'the catalogue date wears its honest label');
398
+ assert.doesNotMatch(card, /—/, 'an unanswerable card fact hides; it never dashes');
369
399
  });
370
400
 
371
401
  test('the row keeps its own Open link working underneath the card', () => {
@@ -421,7 +451,8 @@ test('Type left no styling behind, and the live columns kept theirs', () => {
421
451
  /* ── Real Builders + Runs (task 1002771) ────────────────────────────────────
422
452
  The columns came back only once something could fill them: each project's
423
453
  OWN public API. The distinction these pin is between "we could not ask"
424
- (null → em dash) and "we asked, the answer is none" (0 / []). ── */
454
+ (null → em dash) and "we asked, the answer is none" (0 builders; tags []
455
+ voiced as "core" since task 1002778 — the owner's call, 2026-08-14). ── */
425
456
 
426
457
  test('unknown and zero are rendered as different things', () => {
427
458
  // 0 builders is a real answer — "nobody has joined yet". null is not an
@@ -435,6 +466,33 @@ test('a project that cannot be reached degrades to a dash, not to a wrong number
435
466
  assert.ok(fn, 'tagsHtml() must exist');
436
467
  assert.match(fn, /Array\.isArray\(tags\)/, 'a non-array (null) must not be spread into the cell');
437
468
  assert.match(fn, /return '—'/);
469
+ // [] is a different fact from null and reads differently: the project WAS
470
+ // asked and declares nothing beyond the core set. One formatter serves the
471
+ // table cell and the card, so the two views cannot disagree (task 1002778).
472
+ assert.match(fn, /return 'core'/, 'an empty list is a real answer, never a dash');
473
+ });
474
+
475
+ test('each card fact is wired to its data, not just present in the markup', () => {
476
+ // Mutation-found gap: the C6 test pins the markup ids, but nothing bound
477
+ // them to openCard's data — the whole facts strip could be deleted from the
478
+ // JS with every test green. These pin the wire, one fact at a time.
479
+ assert.match(SCRIPT, /function cardFact/, 'the card fact writer must exist');
480
+ assert.match(SCRIPT, /dd\.hidden = \$\(id \+ 'L'\)\.hidden = !value/,
481
+ 'a fact with no answer hides label and value together — never a dash');
482
+ assert.match(SCRIPT, /cardFact\('cardBuilders', p\.builders == null \? '' : String\(p\.builders\)\)/,
483
+ 'builders: 0 is a real answer, null hides');
484
+ assert.match(SCRIPT, /cardFact\('cardRuns', Array\.isArray\(p\.tags\) \? tagsHtml\(p\.tags\) : ''/,
485
+ 'runs go through the one shared formatter');
486
+ assert.match(SCRIPT, /cardFact\('cardOwner', rel === 'owner' \? '' : ownerHtml\(p\), true\)/,
487
+ 'owner comes from the enrichment and yields to the rel chip saying "yours"');
488
+ const oh = (SCRIPT.match(/function ownerHtml\(p\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
489
+ assert.ok(oh, 'ownerHtml() must exist');
490
+ assert.match(oh, /safeHttpUrl\(p\.owner_avatar\)/,
491
+ 'a third-party avatar URL is scheme-checked before it becomes an img src');
492
+ assert.match(oh, /esc\(String\(p\.owner\)\)/, 'the login is escaped');
493
+ assert.match(SCRIPT, /cardFact\('cardListed', p\.created_at \? listedDate\(p\.created_at\) : ''\)/,
494
+ 'listed is the catalogued date through the year-bearing formatter');
495
+ assert.match(SCRIPT, /year: 'numeric'/, 'a date of record carries its year');
438
496
  });
439
497
 
440
498
  test('the tag list is capped, and the overflow is counted rather than hidden', () => {
@@ -520,6 +578,15 @@ test('the table body is a sibling of the sky wrapper, not nested inside it', ()
520
578
  'the table must not sit inside the sky wrapper. open: ' + ancestors.join(' > '));
521
579
  });
522
580
 
581
+ test('the card metadata block lives inside the card, not a hideable sub-view', () => {
582
+ const ancestors = openAncestorsAt(HUB, 'id="cardMeta"');
583
+ assert.ok(ancestors.includes('projCard'), 'the facts belong to the card dialog');
584
+ for (const hidden of ['sub-sky-body', 'sub-table-body']) {
585
+ assert.ok(!ancestors.includes(hidden),
586
+ '#' + hidden + ' is toggled hidden — the facts cannot live inside it. open: ' + ancestors.join(' > '));
587
+ }
588
+ });
589
+
523
590
  /* ── The self row's Open goes somewhere (task 1002777) ──────────────────────
524
591
  Task 1002760's static self row shipped its action cell as href="#" with a
525
592
  `data-self-open` hook nobody ever wired. The row-click handler deliberately