@bongos/core 1.19.401 → 1.19.403

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.
Files changed (35) hide show
  1. package/.bongos-core.json +50 -40
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0200-re-point-a-bring-your-own-domain.md +105 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +3 -2
  6. package/docs/copy-inventory.md +141 -138
  7. package/docs/copy-registry.json +205 -176
  8. package/docs/design/projects-hub-direction-v2.md +16 -0
  9. package/modules/platform-identity/project-stats.js +123 -38
  10. package/modules/platform-identity/routes/my-projects.js +13 -0
  11. package/modules/platform-identity/routes/projects.js +7 -4
  12. package/modules/provisioning/migrations/provisioning_012_domain_repoint.sql +35 -0
  13. package/modules/provisioning/provisioning.js +68 -8
  14. package/modules/provisioning/routes/provisioning.js +50 -23
  15. package/modules/ui-design/kit/fixtures/featured.json +1 -1
  16. package/modules/ui-design/kit/fixtures/my-projects.json +1 -1
  17. package/modules/ui-design/kit/serve.js +14 -3
  18. package/package-lock.json +2 -2
  19. package/package.json +1 -1
  20. package/public-landing/assets/cosmos.css +126 -25
  21. package/public-landing/projects.html +155 -27
  22. package/public-landing/projects.probes.json +28 -0
  23. package/public-landing/projects.states.json +6 -3
  24. package/scripts/gds/provision-net.js +33 -0
  25. package/scripts/gds/provision-units.js +18 -0
  26. package/scripts/gds/provision.js +97 -5
  27. package/src/module-api.js +5 -1
  28. package/tests/cosmos_token_contrast.mjs +5 -2
  29. package/tests/project_stats.mjs +128 -2
  30. package/tests/projects_hub.mjs +106 -5
  31. package/tests/projects_hub_pre_uat.mjs +28 -2
  32. package/tests/provision.mjs +185 -12
  33. package/tests/provisioning_default_domain.mjs +5 -3
  34. package/tests/provisioning_domain_attach.mjs +166 -17
  35. package/tests/public_landing_projects.mjs +5 -1
@@ -144,3 +144,19 @@ Three mastheads, rendered dark and light at 1440 / 390 / 320 (ALL CLEAN) and mea
144
144
  - Task 1003301 (the poster card): the ledger grammar above is the card's facts strip, too.
145
145
  - Task 1003319 (the planets): the masthead object rides the same crop maths the orbs will take.
146
146
  - The mine view and the wizard are the next views to take a masthead in the picked treatment.
147
+
148
+ ## 10. The poster card is built (task 1003301)
149
+
150
+ **Date:** 2026-08-28 · the v2 card (§7) built into `public-landing/projects.html` + `cosmos.css`, rendered through the kit before and after (`node modules/ui-design/kit/render.js --page /projects`, both modes, 1440 / 390 / 320) — ALL CLEAN; the probes green including seven new card probes.
151
+
152
+ **What was built, region by region (every one a direct child of the card, the C6 slice rule):** the 5:2 banner as a bare `<img>` (`object-fit: cover`, top corners on the card's radius; a broken image demotes to the no-art layout through one error listener); the two figures as leader-line callouts ON the art from 561px (one feathered patch of the card ground per callout — a mask, not a shadow — set 44px in from the card's edges so the feather is never clipped flat on the paper card) and as the two-up band under 561 or with no art; the accent disc surviving, half over the banner's lower edge, ringed 4px in the card ground; the name at 32px/300, the tagline, the relationship as a mono `--label-face` label (no accent: a fact, not a control); up to five 28px faces at −8px overlap ringed in the card ground, `+N` counted against `builders`; the facts as the 1003332 ledger (mono label left, tabular value right, one `rule-soft` hairline per row); one accent door (`open site →`, 48px, lowercase) and the hall entry beside it as an underlined `accent-deep` text link with the hall glyph. `.projCard` is `min(560px, 100%)`, one `rule` hairline, no shadow, `container-type: inline-size` so the disc and the callouts measure the card.
153
+
154
+ **The degradation ladder, as shipped:** no art → no banner, the disc where it always was; no faces → the row absent; one figure null → the other stands alone; both null → the band hidden; a fact with no answer hides label and value together; everything null → disc, name, tagline, **the address**, listed, one door. The address is the one difference from the ladder as written in task 1003258: `Runs` on the poster is the project's address (§7), which the catalog always knows, so the floor card carries a Runs row the tags-based card did not. The table keeps the module list in its Runs column.
155
+
156
+ **The feed (§6 closed):** `modules/platform-identity/project-stats.js` reads four feeds per origin inside the one `Promise.allSettled` — the leaderboard now also yields `faces`; `/public/recent-shipped?days=30` yields `shipped_30d` + `shipped_30d_capped` (200 rows with `?days` honoured, 30 on an older core: the "N+" rule); and the project's API discovery index (`GET /api/bongos`, ADR 0114) yields `hall_origin`. **A decision made on the way:** task 1003301 said the hall address would be "resolved from the project's provisioning record" — no such column exists (provisioning writes `BUILDERS_ORIGIN = PUBLIC_ORIGIN` into the instance's own `web.env` and never round-trips it), and the record could only ever say "same host". The discovery index is the project answering for itself, on the same basis as builders and tags, and it is null where the hall is the site's own host (the address gate) or where the project does not say. One frozen `NULL_STATS` carries every key. The platform's own card answers by the same rule client-side (its leaderboard for faces, its own `recent-shipped?days=30` for the figure), and its hall comes from the DOM: the server-stamped `#selfHall` anchor in its table row, dropped whole by the stamp when a pack names no hall.
157
+
158
+ **The hall entry, three surfaces → two:** the card's door and the table row's glyph (riding the existing door cell, so the six-cell parity holds), both **member-gated AND address-gated** through one derivation (`hallUrlOf`, through `safeHttpUrl`). The mine-view card is untouched (task 1003301's scope; goal 1000059 R7 adds its manage door). The platform's own row carries the glyph only for a signed-in member.
159
+
160
+ **Not done, on purpose:** the platform card has no banner — the mock used the landing's social card as "the platform's art in the only sense it has one", but that image carries the landing's own headline and reads as a screenshot inside the card; the kit fixture uses a text-free plate instead. Whether the platform should carry a real `art_url` is the owner's call.
161
+
162
+ **Owner notes received during the build (filed as task 1003333, the landing hero):** the two doors sit too high and central; the three hero figures with their leader lines leave the landing (they fit the projects page — this card keeps the grammar); the large chrome plates should drift slowly like a lava lamp in place of the small bubble.
@@ -1,5 +1,6 @@
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).
1
+ // modules/platform-identity/project-stats.js — real Builders + Tags (task 1002771)
2
+ // and, since task 1003301, the poster card's faces, shipped-in-30-days figure and
3
+ // hall address for the hub catalog, all read from each project's OWN public API.
3
4
  //
4
5
  // WHY THE DATA IS NOT HERE. The apex catalog stores four fields (origin, name,
5
6
  // tagline, art_url) and nothing else; the membership and activity tables that
@@ -13,6 +14,10 @@
13
14
  // /api/bongos/public/* surface, and the hub's server can read it instance-to-
14
15
  // instance with no CORS involved. The project itself is the authority on its own
15
16
  // builder count, which is a better source than anything the apex could infer.
17
+ // The same holds for its hall address: no hub table stores one (provisioning
18
+ // collapses every origin to the domain), but the instance's API discovery index
19
+ // (GET /api/bongos, ADR 0114) advertises `hall` — the project answering for
20
+ // itself, on the same basis as builders and tags.
16
21
  //
17
22
  // SCALE, recorded so it is not rediscovered: this PULL model is right for a
18
23
  // handful and fine for a few dozen behind the cache. It does not scale to
@@ -30,6 +35,23 @@ const TTL_MS = 10 * 60 * 1000; // owner's choice, 2026-08-13
30
35
  const FETCH_TIMEOUT_MS = 2500; // per request
31
36
  const COLD_DEADLINE_MS = 3000; // total wait a COLD cache may cost a page load
32
37
 
38
+ // The card's figure window and its faces row (task 1003301). The remote
39
+ // recent-shipped feed is row-capped: 200 when it honours `?days=`, 30 when an
40
+ // older core ignores the parameter — the cap is where a count becomes "N+".
41
+ const SHIPPED_WINDOW_DAYS = 30;
42
+ const SHIPPED_CAP_WINDOWED = 200;
43
+ const SHIPPED_CAP_LEGACY = 30;
44
+ const FACES_MAX = 5;
45
+ const URL_MAX = 300;
46
+
47
+ // ONE null shape for every way a project can fail to answer (the refresh catch
48
+ // AND the read fallback). A key missing here ships as undefined, which
49
+ // JSON.stringify drops, which silently changes the wire shape (task 1003258).
50
+ const NULL_STATS = Object.freeze({
51
+ builders: null, tags: null, owner: null, owner_avatar: null,
52
+ faces: null, shipped_30d: null, shipped_30d_capped: null, hall_origin: null,
53
+ });
54
+
33
55
  const _cache = new Map(); // origin → { at, stats }
34
56
  const _inflight = new Map(); // origin → Promise (collapses concurrent refreshes)
35
57
 
@@ -58,6 +80,14 @@ function isSystemAccount(login) {
58
80
  return /-system$/.test(String(login || ''));
59
81
  }
60
82
 
83
+ // httpUrl — a third-party string becomes a URL only when it is http(s), and only
84
+ // up to a length cap; anything else is null. Every avatar, face, art and hall
85
+ // address the hub relays passes here (the client scheme-checks again before a
86
+ // value becomes a src or an href).
87
+ function httpUrl(raw, max = URL_MAX) {
88
+ return typeof raw === 'string' && /^https?:\/\//.test(raw) ? raw.slice(0, max) : null;
89
+ }
90
+
61
91
  // ── SSRF GUARD ──
62
92
  // This is the one place the server makes an outbound request to an address that
63
93
  // came out of the database, so it is the one place that has to assume the address
@@ -109,14 +139,23 @@ async function assertPublicHost(hostname) {
109
139
  }
110
140
  }
111
141
 
112
- function publicUrl(origin, route) {
142
+ // siteUrl — the origin as a URL, http(s) only. Throws on anything else, which is
143
+ // the one path that reaches the cached-failure literal synchronously.
144
+ function siteUrl(origin) {
113
145
  const u = new URL(origin);
114
146
  if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('bad scheme');
115
- return new URL(`/api/bongos/public/${route}`, u).href;
147
+ return u;
148
+ }
149
+ function apiUrl(origin, route) {
150
+ return new URL(route, siteUrl(origin)).href;
151
+ }
152
+ function publicUrl(origin, route) {
153
+ return apiUrl(origin, `/api/bongos/public/${route}`);
116
154
  }
117
155
 
156
+ // The guard has already run for this sweep's host (fetchStats resolves each
157
+ // origin ONCE, not once per feed); this only fetches.
118
158
  async function getJson(url) {
119
- await assertPublicHost(new URL(url).hostname);
120
159
  const res = await fetch(url, {
121
160
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
122
161
  headers: { accept: 'application/json' },
@@ -126,48 +165,92 @@ async function getJson(url) {
126
165
  return res.json();
127
166
  }
128
167
 
129
- // One project's stats. Either half may fail on its own — a project whose
168
+ // leaderboardStats — builders, owner and faces from the project's OWN leaderboard.
169
+ // The archon row IS the instance's owner — the project's own authority answering
170
+ // for itself, from a payload already in hand (ADR 0170). The catalog's
171
+ // owner_github_id ("advisory attribution (not an authority)") stays unread.
172
+ // Caveat: the leaderboard is credits-ordered and page-capped, so a zero-credit
173
+ // archon on a very large instance can fall off it — that renders as no owner
174
+ // shown, never as a wrong one. The payload is third-party: accept only a
175
+ // plausible login string (GitHub caps logins at 39 chars; 64 leaves headroom
176
+ // without hosting a novel) and only http(s) avatar URLs.
177
+ function leaderboardStats(rows) {
178
+ const people = rows.filter((b) => !isSystemAccount(b?.github_login));
179
+ const arch = people.find((b) => b?.rank === 'archon');
180
+ const login = arch?.github_login;
181
+ const owner = typeof login === 'string' && login ? login.slice(0, 64) : null;
182
+ return {
183
+ builders: people.length,
184
+ owner,
185
+ owner_avatar: owner ? httpUrl(arch?.avatar_url) : null,
186
+ // the faces row: the first five people with a usable avatar, in the feed's
187
+ // credits order; the card counts the rest against `builders` as +N
188
+ faces: people.map((b) => httpUrl(b?.avatar_url)).filter(Boolean).slice(0, FACES_MAX),
189
+ };
190
+ }
191
+
192
+ // shippedStats — how many tasks the project shipped in the last 30 days, and
193
+ // whether that count sat at the feed's row cap (the card renders "N+" then). The
194
+ // window is applied here from shipped_at even when the remote honoured `?days=`,
195
+ // so an older core that ignores the parameter still yields a true number.
196
+ function shippedStats(body) {
197
+ const rows = Array.isArray(body?.tasks) ? body.tasks : [];
198
+ const since = Date.now() - SHIPPED_WINDOW_DAYS * 24 * 60 * 60 * 1000;
199
+ const inWindow = rows.filter((t) => {
200
+ const ts = Date.parse(t?.shipped_at);
201
+ return Number.isFinite(ts) && ts >= since;
202
+ }).length;
203
+ const cap = body?.days === SHIPPED_WINDOW_DAYS ? SHIPPED_CAP_WINDOWED : SHIPPED_CAP_LEGACY;
204
+ return { shipped_30d: inWindow, shipped_30d_capped: rows.length >= cap && inWindow === rows.length };
205
+ }
206
+
207
+ // hallOrigin — the project's builders' hall address when it is genuinely a
208
+ // different host from the site; null on a single-host instance (the hall is at
209
+ // <site>/builders, where the site's own door already leads) and null when the
210
+ // project does not say. Task 1003257, owner decision (a): a door only where it
211
+ // opens somewhere else.
212
+ function hallOrigin(index, origin) {
213
+ const hall = httpUrl(index?.hall);
214
+ if (!hall) return null;
215
+ try {
216
+ return new URL(hall).host.toLowerCase() === siteUrl(origin).host.toLowerCase() ? null : hall;
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ // One project's stats. Every quarter may fail on its own — a project whose
130
223
  // 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.
224
+ // inside ONE allSettled (sequential awaits would turn the per-request ceiling into
225
+ // a serial cost that blows the cold-load deadline), and a missing quarter is null
226
+ // rather than a discarded whole.
132
227
  async function fetchStats(origin) {
133
- const [lb, mods] = await Promise.allSettled([
228
+ await assertPublicHost(siteUrl(origin).hostname); // once per sweep, not once per feed
229
+ const [lb, mods, ships, index] = await Promise.allSettled([
134
230
  getJson(publicUrl(origin, 'leaderboard')),
135
231
  getJson(publicUrl(origin, 'modules')),
232
+ getJson(publicUrl(origin, `recent-shipped?days=${SHIPPED_WINDOW_DAYS}`)),
233
+ getJson(apiUrl(origin, '/api/bongos')),
136
234
  ]);
137
235
 
138
- let builders = null;
139
- let owner = null;
140
- let ownerAvatar = null;
236
+ const stats = { ...NULL_STATS };
141
237
  if (lb.status === 'fulfilled' && Array.isArray(lb.value?.leaderboard)) {
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;
238
+ Object.assign(stats, leaderboardStats(lb.value.leaderboard));
159
239
  }
160
-
161
- let tags = null;
162
240
  if (mods.status === 'fulfilled' && mods.value?.enabled && typeof mods.value.enabled === 'object') {
163
241
  const core = coreModules();
164
- tags = Object.entries(mods.value.enabled)
242
+ stats.tags = Object.entries(mods.value.enabled)
165
243
  .filter(([key, on]) => on === true && !core.has(key))
166
244
  .map(([key]) => key)
167
245
  .sort();
168
246
  }
169
-
170
- return { builders, tags, owner, owner_avatar: ownerAvatar };
247
+ if (ships.status === 'fulfilled' && Array.isArray(ships.value?.tasks)) {
248
+ Object.assign(stats, shippedStats(ships.value));
249
+ }
250
+ if (index.status === 'fulfilled') {
251
+ stats.hall_origin = hallOrigin(index.value, origin);
252
+ }
253
+ return stats;
171
254
  }
172
255
 
173
256
  function refresh(origin) {
@@ -181,9 +264,8 @@ function refresh(origin) {
181
264
  // Remember the FAILURE for a full TTL too. Without this a permanently
182
265
  // unreachable project is re-fetched on every request and pays the timeout
183
266
  // every time — the exact way one dead project slows the whole page.
184
- const stats = { builders: null, tags: null, owner: null, owner_avatar: null };
185
- _cache.set(origin, { at: Date.now(), stats });
186
- return stats;
267
+ _cache.set(origin, { at: Date.now(), stats: NULL_STATS });
268
+ return NULL_STATS;
187
269
  })
188
270
  .finally(() => _inflight.delete(origin));
189
271
  _inflight.set(origin, p);
@@ -215,11 +297,14 @@ async function getProjectStats(origins = []) {
215
297
  // null-prototype: an origin literally equal to '__proto__' would otherwise
216
298
  // retarget the object's prototype instead of adding a key
217
299
  const out = Object.create(null);
218
- for (const origin of origins) out[origin] = _cache.get(origin)?.stats || { builders: null, tags: null, owner: null, owner_avatar: null };
300
+ for (const origin of origins) out[origin] = _cache.get(origin)?.stats || NULL_STATS;
219
301
  return out;
220
302
  }
221
303
 
222
304
  // tests
223
305
  function _reset() { _cache.clear(); _inflight.clear(); _coreModules = null; }
224
306
 
225
- module.exports = { getProjectStats, isSystemAccount, coreModules, blockedReason, assertPublicHost, _reset, TTL_MS };
307
+ module.exports = {
308
+ getProjectStats, isSystemAccount, coreModules, blockedReason, assertPublicHost, httpUrl,
309
+ NULL_STATS, SHIPPED_WINDOW_DAYS, FACES_MAX, _reset, TTL_MS,
310
+ };
@@ -15,6 +15,7 @@
15
15
  const express = require('express');
16
16
  const api = require('../../../src/module-api');
17
17
  const pi = require('../platform-identity');
18
+ const projectStats = require('../project-stats');
18
19
 
19
20
  const { pool, requireBuilder, validateOrRespond, userAgent } = api;
20
21
 
@@ -87,6 +88,17 @@ module.exports = function myProjectsRoutes() {
87
88
  router.get('/my-projects', requireBuilder, async (req, res) => {
88
89
  try {
89
90
  const rows = await pi.listMembershipsForAccount(req.builder.github_id, { pool });
91
+ // hall_origin per membership (task 1003301): the project's own hall
92
+ // address where it differs from its site, null otherwise — read from the
93
+ // project's API discovery index through the same cached enrichment the
94
+ // catalog uses (no hub table stores it). Best-effort: a project that does
95
+ // not answer gets null, never a failed listing.
96
+ let stats = {};
97
+ try {
98
+ stats = await projectStats.getProjectStats(rows.map((m) => m.project_origin).filter(Boolean));
99
+ } catch (err) {
100
+ api.logger('platform-identity').error(`my-projects hall enrichment failed (non-fatal): ${err && err.message}`);
101
+ }
90
102
  const owned = [];
91
103
  const building = [];
92
104
  for (const m of rows) {
@@ -94,6 +106,7 @@ module.exports = function myProjectsRoutes() {
94
106
  client_id: m.client_id,
95
107
  name: m.project_name || m.project_origin || m.client_id,
96
108
  origin: m.project_origin || null,
109
+ hall_origin: (m.project_origin && stats[m.project_origin]?.hall_origin) || null,
97
110
  membership_kind: m.membership_kind,
98
111
  joined_at: m.joined_at,
99
112
  last_active: m.last_active,
@@ -122,13 +122,16 @@ module.exports = function projectsRoutes() {
122
122
  } catch (err) {
123
123
  console.error('[platform-identity] project stats enrichment failed (non-fatal):', err.message);
124
124
  }
125
+ // The enrichment's keys (builders, tags, owner, owner_avatar, faces,
126
+ // shipped_30d, shipped_30d_capped, hall_origin) never collide with the
127
+ // catalog's; a project that did not answer carries the ONE shared null
128
+ // shape, so the wire never drops a key. art_url is catalog text an Archon
129
+ // typed — scheme-checked here, and again on the client, before it is a src.
125
130
  res.json({
126
131
  projects: projects.map((p) => ({
127
132
  ...p,
128
- builders: stats[p.origin]?.builders ?? null,
129
- tags: stats[p.origin]?.tags ?? null,
130
- owner: stats[p.origin]?.owner ?? null,
131
- owner_avatar: stats[p.origin]?.owner_avatar ?? null,
133
+ art_url: projectStats.httpUrl(p.art_url, 2048),
134
+ ...(stats[p.origin] || projectStats.NULL_STATS),
132
135
  })),
133
136
  });
134
137
  } catch (err) {
@@ -0,0 +1,35 @@
1
+ -- provisioning_012_domain_repoint.sql — the addresses a re-point still owes a
2
+ -- release on (task 1003237, goal 1000058: a mistyped bring-your-own domain can
3
+ -- be corrected).
4
+ --
5
+ -- WHY. Re-pointing an instance overwrites `domain`, so the address it moved OFF
6
+ -- is gone from the row the instant the web tier records the new one — and the
7
+ -- runner, which is the only tier holding a DNS token, would never learn that a
8
+ -- hostname it once UPSERTed at the platform's IP is now abandoned. ADR 0181 §2
9
+ -- keeps a torn-down project's address deliberately; an address the owner has
10
+ -- explicitly moved off is the opposite case, and leaving its A record aimed at
11
+ -- platform infra is the leak this column closes.
12
+ --
13
+ -- An ARRAY, not a single column: a founder correcting a typo can correct it
14
+ -- twice before the runner drains the first attach, and a scalar would silently
15
+ -- drop the middle address. The runner releases each entry and empties the list.
16
+ --
17
+ -- Only a BYO address is ever appended here, and only from a state that actually
18
+ -- STOOD UP (DOMAIN_RELEASE_FROM = {error, active}). The platform-assigned
19
+ -- <slug>.<apex> is the platform's own name for this slug and the revive path
20
+ -- re-uses it (ADR 0181 §2); a 'requested' instance never had an A record written
21
+ -- for the address it is leaving, so parking that name would CREATE a junk record
22
+ -- rather than release one.
23
+ --
24
+ -- BOUNDED at MAX_RELEASED_DOMAINS by updateInstanceDomain's own SQL. The runner
25
+ -- drains this list synchronously against the SHARED control-plane Cloudflare
26
+ -- token, so an unbounded array would be a platform-wide resource-exhaustion
27
+ -- vector reachable by any owner re-pointing their own project in a loop.
28
+ --
29
+ -- OVERLAY, NOT A STATE (the provisioning_006 rule): defaulted, never written by
30
+ -- the lifecycle. Empty = nothing outstanding. Safe to re-run.
31
+ ALTER TABLE provisioning_instances
32
+ ADD COLUMN IF NOT EXISTS released_domains text[] NOT NULL DEFAULT '{}';
33
+
34
+ INSERT INTO schema_migrations (version) VALUES ('provisioning_012_domain_repoint')
35
+ ON CONFLICT (version) DO NOTHING;
@@ -77,6 +77,17 @@ const PROVISION_ENQUEUE_FROM = new Set(['requested', 'provisioning', 'error', 't
77
77
  // 'requested'/'error' only record it — the (re)provision run wires the domain
78
78
  // itself. Mid-flight or torn-down states refuse truthfully.
79
79
  const DOMAIN_ATTACH_FROM = new Set(['requested', 'error', 'active']);
80
+ // The states in which a re-point has an address worth RELEASING (task 1003237).
81
+ // A 'requested' instance has never stood up, so nothing ever wrote an A record for
82
+ // the address it is leaving — parking that name at the blackhole would CREATE a
83
+ // junk record in the owner's zone rather than release one. An 'error' row is
84
+ // frequently a PARTIAL standup that did write it (ADR 0181 §1), so it counts.
85
+ const DOMAIN_RELEASE_FROM = new Set(['error', 'active']);
86
+ // How many abandoned addresses one instance may have queued for release at once.
87
+ // The runner drains them synchronously against the shared control-plane Cloudflare
88
+ // token, so this is a platform-wide bound, not a per-owner nicety. A real
89
+ // correction queues one; the cap only ever binds on abuse.
90
+ const MAX_RELEASED_DOMAINS = 8;
80
91
  // The ONE state a settings push is meaningful from (task 1003140). A pre-active
81
92
  // instance needs no push — standup writes the same values from the same
82
93
  // effectiveSettings() row — and a torn-down one has nothing to restart. The
@@ -468,13 +479,61 @@ async function updateInstanceSettings(db, id, patch) {
468
479
  return rows[0] || null;
469
480
  }
470
481
 
471
- // Record an instance's domain — the ONLY column this touches (task 1002692). Status
472
- // changes stay setInstanceStatus's job; keeping this narrow means the domain-attach
473
- // route can never drift an instance's lifecycle as a side effect.
474
- async function updateInstanceDomain(db, id, domain) {
482
+ // Record an instance's domain — the ONLY columns this touches (task 1002692;
483
+ // `releasing` added by task 1003237). Status changes stay setInstanceStatus's job;
484
+ // keeping this narrow means the domain-attach route can never drift an instance's
485
+ // lifecycle as a side effect.
486
+ //
487
+ // `releasing` is the BYO address this instance is moving OFF, appended to the
488
+ // release queue in the SAME statement as the new domain — a re-point that
489
+ // recorded the new address but crashed before queueing the old one would leave a
490
+ // hostname pointed at platform infra with nothing left in the row to say so.
491
+ // Appended, never replaced: two corrections before the runner drains the first
492
+ // must both get released (provisioning_012). null/undefined ⇒ the queue is left
493
+ // exactly as it stands, so a first attach touches nothing.
494
+ //
495
+ // CAPPED at MAX_RELEASED_DOMAINS, enforced here in SQL rather than trusted from
496
+ // the caller. The runner drains this list SYNCHRONOUSLY, one shared-Cloudflare-token
497
+ // call per entry, so an unbounded array is a resource-exhaustion vector against the
498
+ // whole platform — not just the owner's own project — and every re-point is a write
499
+ // an authenticated owner can repeat. Past the cap the append is dropped: the entries
500
+ // already queued are the ones most likely to name a hostname the platform really
501
+ // wired, and a real correction never comes close to the cap (the route also throttles
502
+ // the caller). Dropping is the deliberate failure mode — growing without bound is not.
503
+ //
504
+ // A CHANGED domain also clears the liveness overlay. The overlay is a verdict on
505
+ // an ADDRESS (task 1002696), so carrying it across a re-point would let the card
506
+ // say "answering at <the new address>, last answered 2 minutes ago" when only the
507
+ // OLD address was ever probed — the exact false claim the F16 rule forbids. NULL
508
+ // is "never probed", which is the truth for a name nothing has asked yet; the
509
+ // sweep re-probes within minutes. Guarded in SQL on the value actually changing,
510
+ // so an idempotent re-record can never blank a good probe.
511
+ async function updateInstanceDomain(db, id, domain, releasing = null) {
512
+ const host = releasing ? String(releasing).toLowerCase() : null;
475
513
  const { rows } = await db.query(
476
- `UPDATE provisioning_instances SET domain = $2, updated_at = now() WHERE id = $1 RETURNING *`,
477
- [id, domain]
514
+ `UPDATE provisioning_instances
515
+ SET domain = $2,
516
+ released_domains = CASE
517
+ WHEN $3::text IS NULL OR $3 = ANY(released_domains) THEN released_domains
518
+ WHEN coalesce(array_length(released_domains, 1), 0) >= $4 THEN released_domains
519
+ ELSE array_append(released_domains, $3)
520
+ END,
521
+ last_verified_at = CASE WHEN domain IS DISTINCT FROM $2 THEN NULL ELSE last_verified_at END,
522
+ last_verified_up = CASE WHEN domain IS DISTINCT FROM $2 THEN NULL ELSE last_verified_up END,
523
+ updated_at = now()
524
+ WHERE id = $1 RETURNING *`,
525
+ [id, domain, host, MAX_RELEASED_DOMAINS]
526
+ );
527
+ return rows[0] || null;
528
+ }
529
+
530
+ // Empty the release queue once the runner has actually released every address on
531
+ // it (task 1003237). Same narrowness rule as clearErrorNote: no status, no
532
+ // updated_at — draining a queue is not an owner-visible change to the project.
533
+ async function clearReleasedDomains(db, id) {
534
+ const { rows } = await db.query(
535
+ `UPDATE provisioning_instances SET released_domains = '{}' WHERE id = $1 RETURNING id`,
536
+ [id]
478
537
  );
479
538
  return rows[0] || null;
480
539
  }
@@ -896,7 +955,8 @@ module.exports = {
896
955
  // constants
897
956
  INSTANCE_STATES, HOSTING_SHAPES, ONBOARD_MODES, PROJECT_TYPES, INTENT_ACTIONS, TEARDOWN_FROM,
898
957
  settingsEnvLines, settingsEnvVars,
899
- PROVISION_ENQUEUE_FROM, DOMAIN_ATTACH_FROM, SETTINGS_APPLY_FROM, SETTINGS_APPLY_SHAPES,
958
+ PROVISION_ENQUEUE_FROM, DOMAIN_ATTACH_FROM, DOMAIN_RELEASE_FROM, MAX_RELEASED_DOMAINS,
959
+ SETTINGS_APPLY_FROM, SETTINGS_APPLY_SHAPES,
900
960
  RESTART_FROM, RESTART_SHAPES,
901
961
  TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
902
962
  SETTINGS_VOCAB, DEFAULT_SETTINGS,
@@ -910,7 +970,7 @@ module.exports = {
910
970
  getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
911
971
  // data access — verification overlay (task 1002696)
912
972
  listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
913
- createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings,
973
+ createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
914
974
  enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
915
975
  recordEvent, instanceStateForSlug, costLedger,
916
976
  // data access — GitHub App Manifest attempts (task 2080)
@@ -265,6 +265,17 @@ module.exports = function provisioningRoutes() {
265
265
  max: 30,
266
266
  key: (req) => (req.builder ? `b:${req.builder.id}` : clientIp(req)),
267
267
  });
268
+ // Limiter for the domain attach/re-point write (task 1003237). Signed-in and
269
+ // own-scoped, but since a re-point became possible each call can queue an
270
+ // address the RUNNER later releases with a call against the SHARED control-plane
271
+ // Cloudflare token — so an unthrottled owner can spend a platform-wide budget,
272
+ // not merely their own. Keyed on the BUILDER for the same reason as the slug
273
+ // check: per-IP would starve co-located builders while an attacker rotates IPs.
274
+ // A founder gives a project an address, or corrects it, a handful of times ever.
275
+ const domainWriteRateLimit = makeAskRateLimiter({
276
+ max: 10,
277
+ key: (req) => (req.builder ? `b:${req.builder.id}` : clientIp(req)),
278
+ });
268
279
 
269
280
  // Route ORDER is deliberate (the box.js precedent): the Archon-gated routes are
270
281
  // declared FIRST, then the any-builder routes. The route-rank auditor
@@ -859,9 +870,11 @@ module.exports = function provisioningRoutes() {
859
870
  // Validates + records the domain; for an already-ACTIVE instance it also enqueues
860
871
  // a 'domain-attach' intent the control-plane runner drains (web tier holds no
861
872
  // cloud tokens — ADR 0111 §2). A requested/error instance only records it: the
862
- // (re)provision run wires the domain itself. Re-pointing an existing address is
863
- // out of scope — refuse truthfully. Body: { domain (required) }.
864
- router.post('/provisioning/instances/:id/domain', auth.requireBuilder, async (req, res) => {
873
+ // (re)provision run wires the domain itself. A bring-your-own address may be
874
+ // RE-POINTED (task 1003237, ADR 0200): the same guards the first attach applies
875
+ // decide it, and the address it moved off is queued for release.
876
+ // Body: { domain (required) }.
877
+ router.post('/provisioning/instances/:id/domain', auth.requireBuilder, domainWriteRateLimit, async (req, res) => {
865
878
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
866
879
  if (!id) return;
867
880
  if (validateOrRespond(req, res, { domain: { required: true, type: 'string', maxLength: 253 } })) return;
@@ -919,15 +932,20 @@ module.exports = function provisioningRoutes() {
919
932
  return res.json({ ok: true, changed: false, queued: false, domain: inst.domain,
920
933
  message: 'This instance already has that address.' });
921
934
  }
922
- // A platform-ASSIGNED address may be upgraded away to a bring-your-own
923
- // domain — that upgrade path is half the task-1002704 decision (the old
924
- // record lingers in DNS, the idea-1000710 class). A genuine BYO address
925
- // stays un-re-pointable, as before.
926
- if (current && current !== assignedDefault) {
927
- return res.fail('domain_already_set', { status: 409,
928
- message: `This instance is already addressed as '${inst.domain}' — re-pointing an existing address is not supported here.`,
929
- details: { current: inst.domain } });
930
- }
935
+ // A mistyped bring-your-own address can be CORRECTED (task 1003237, owner
936
+ // decision 2026-08-28 / ADR 0200): the address is the founder's, and the
937
+ // guards above — reserved, in-apex squat — plus the uniqueness check below
938
+ // are exactly the ones the first attach applied, so a re-point needs no
939
+ // weaker rule and gets no stronger one. The address it moves OFF is queued
940
+ // for release (`releasing`): leaving a hostname UPSERTed at platform infra
941
+ // for a project that no longer answers there is the leak ADR 0181 §2 named.
942
+ // Only a BYO address is released, and only from a state that actually STOOD
943
+ // UP: a platform-ASSIGNED <slug>.<apex> is the platform's own name for this
944
+ // slug and the revive path re-uses it (ADR 0181 §2), while a 'requested'
945
+ // instance never had an A record written for the address it is leaving —
946
+ // parking that name would CREATE a junk record rather than release one.
947
+ const releasing = current && current !== assignedDefault
948
+ && provisioning.DOMAIN_RELEASE_FROM.has(inst.status) ? current : null;
931
949
  const holder = await provisioning.getInstanceByDomain(pool, domain);
932
950
  if (holder && String(holder.id) !== String(inst.id)) {
933
951
  return res.fail('domain_taken', { status: 409, message: 'That address is already in use by another instance.' });
@@ -938,8 +956,9 @@ module.exports = function provisioningRoutes() {
938
956
  details: { status: inst.status } });
939
957
  }
940
958
  // Record BEFORE enqueueing: the runner reads the row's domain, so the intent
941
- // must never race ahead of the column it wires.
942
- await provisioning.updateInstanceDomain(pool, id, domain);
959
+ // must never race ahead of the column it wires. The release queue rides the
960
+ // SAME statement — see updateInstanceDomain.
961
+ await provisioning.updateInstanceDomain(pool, id, domain, releasing);
943
962
  let queued = false;
944
963
  if (inst.status === 'active') {
945
964
  const { intent, created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
@@ -957,22 +976,30 @@ module.exports = function provisioningRoutes() {
957
976
  instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
958
977
  detail: `domain ${domain} recorded (attach already queued)`, actor: 'api:self',
959
978
  }).catch(() => {});
960
- return res.json({ ok: true, changed: true, queued: false, domain,
979
+ return res.json({ ok: true, changed: true, queued: false, domain, replaced: releasing,
961
980
  message: 'That address is already being wired for this instance.' });
962
981
  }
963
982
  }
983
+ const moved = releasing ? ` (moved off ${releasing}, queued for release)` : '';
964
984
  await provisioning.recordEvent(pool, {
965
985
  instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
966
- detail: queued ? `domain ${domain} recorded + attach queued` : `domain ${domain} recorded (wired at provision)`,
986
+ detail: (queued ? `domain ${domain} recorded + attach queued` : `domain ${domain} recorded (wired at provision)`) + moved,
967
987
  actor: 'api:self',
968
988
  }).catch(() => {});
969
- res.json({ ok: true, changed: true, queued, domain,
970
- // State-only sentence (task 1002720 / F16): the web tier holds no cloud
971
- // tokens and cannot know whether a DNS hook exists on the runner, so
972
- // "it will answer there shortly" was a promise it could not keep.
973
- message: queued
974
- ? 'Your instance is being wired to its new address — this card will show whether it answers.'
975
- : 'Address recorded — it will be wired when the instance is provisioned.' });
989
+ // State-only sentences (task 1002720 / F16): the web tier holds no cloud
990
+ // tokens and cannot know whether a DNS hook exists on the runner, so "it
991
+ // will answer there shortly" was a promise it could not keep. The re-point
992
+ // sentences name the old address in the same voice — what they say has
993
+ // ALREADY happened is the recording, not the unwiring. One lookup rather
994
+ // than a nested ternary, so each of the four states reads as its own line.
995
+ const MESSAGES = {
996
+ 'queued:repoint': `Your project is being wired to ${domain}; ${releasing} is being let go.`,
997
+ 'queued:first': 'Your instance is being wired to its new address — this card will show whether it answers.',
998
+ 'recorded:repoint': `Address changed to ${domain} — it will be wired, and ${releasing} let go, when the instance is provisioned.`,
999
+ 'recorded:first': 'Address recorded — it will be wired when the instance is provisioned.',
1000
+ };
1001
+ res.json({ ok: true, changed: true, queued, domain, replaced: releasing,
1002
+ message: MESSAGES[`${queued ? 'queued' : 'recorded'}:${releasing ? 'repoint' : 'first'}`] });
976
1003
  } catch (err) {
977
1004
  log.error('[provisioning] POST /provisioning/instances/:id/domain', err);
978
1005
  res.fail('domain_attach_failed', { status: 500, message: 'internal error' });
@@ -1 +1 @@
1
- {"projects":[{"origin":"https://amazonprimea.com","name":"Off the Boats","tagline":"A singular shop, and a broader videogame in a persistent ancient Greek world that collides with modern day commerce & products.","art_url":null,"type":"game","created_at":"2026-07-15T22:12:57.732Z","builders":11,"tags":["art-pipeline","character-anim","demo","game"],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4"},{"origin":"https://demo.cloudbongos.com","name":"Demo","tagline":"A live demo instance on Cloud Bongos.","art_url":null,"type":null,"created_at":"2026-07-14T19:16:38.160Z","builders":1,"tags":[],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4"},{"origin":"https://emersonian-circles.cloudbongos.com","name":"Emersonian Circles","tagline":"An independent project built on Cloud Bongos.","art_url":null,"type":null,"created_at":"2026-07-25T02:21:55.641Z","builders":1,"tags":[],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4"}]}
1
+ {"projects":[{"origin":"https://amazonprimea.com","name":"Off the Boats","tagline":"A singular shop, and a broader videogame in a persistent ancient Greek world that collides with modern day commerce & products.","art_url":"/assets/world/r2-chrome-blob-one.jpg","type":"game","created_at":"2026-07-15T22:12:57.732Z","builders":11,"tags":["art-pipeline","character-anim","demo","game"],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4","faces":["https://avatars.githubusercontent.com/u/283020391?v=4","https://avatars.githubusercontent.com/u/64553469?v=4","https://avatars.githubusercontent.com/u/290184380?v=4","https://avatars.githubusercontent.com/u/295990394?v=4","https://avatars.githubusercontent.com/u/35337514?v=4"],"shipped_30d":0,"shipped_30d_capped":false,"hall_origin":"https://builders.amazonprimea.com"},{"origin":"https://demo.cloudbongos.com","name":"Demo","tagline":"A live demo instance on Cloud Bongos.","art_url":null,"type":null,"created_at":"2026-07-14T19:16:38.160Z","builders":null,"tags":null,"owner":null,"owner_avatar":null,"faces":null,"shipped_30d":null,"shipped_30d_capped":null,"hall_origin":null},{"origin":"https://emersonian-circles.cloudbongos.com","name":"Emersonian Circles","tagline":"An independent project built on Cloud Bongos.","art_url":null,"type":null,"created_at":"2026-07-25T02:21:55.641Z","builders":1,"tags":[],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4","faces":[],"shipped_30d":30,"shipped_30d_capped":true,"hall_origin":null}]}
@@ -1 +1 @@
1
- {"owned":[],"building":[]}
1
+ {"owned":[],"building":[{"client_id":"otb","name":"Off the Boats","origin":"https://amazonprimea.com","hall_origin":"https://builders.amazonprimea.com","membership_kind":"member","joined_at":"2026-07-20T10:00:00.000Z","last_active":"2026-08-27T18:00:00.000Z"},{"client_id":"emersonian","name":"Emersonian Circles","origin":"https://emersonian-circles.cloudbongos.com","hall_origin":null,"membership_kind":"member","joined_at":"2026-07-26T10:00:00.000Z","last_active":"2026-08-20T18:00:00.000Z"}]}
@@ -99,12 +99,23 @@ const AUTHED = { '/my-projects': 'my-projects' };
99
99
  const UNAUTH = { error: { code: 'unauthenticated', message: 'unauthenticated' } };
100
100
 
101
101
  function json(res, status, body) { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(body)); }
102
- function fixture(name, res) {
102
+ function fixture(name, res, origin) {
103
103
  if (FAIL.has(name)) return json(res, 500, { error: { code: 'stub_failure', message: `FAIL=${name}` } });
104
104
  const file = path.join(FIXTURES, name + '.json');
105
105
  if (!fs.existsSync(file)) return json(res, 404, { error: { code: 'no_fixture', message: name } });
106
106
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
107
- res.end(fs.readFileSync(file));
107
+ res.end(name === 'featured' ? JSON.stringify(featured(file, origin)) : fs.readFileSync(file));
108
+ }
109
+ // A project's art in the featured fixture may be root-relative (the surface's
110
+ // own /assets/…, served by this stub) — the page accepts only an http(s) art
111
+ // URL, so it is absolutised to this origin per request, as the builder's
112
+ // avatar is (see me()).
113
+ function featured(file, origin) {
114
+ const body = JSON.parse(fs.readFileSync(file, 'utf8'));
115
+ for (const p of body.projects || []) {
116
+ if (typeof p.art_url === 'string' && p.art_url.startsWith('/')) p.art_url = origin + p.art_url;
117
+ }
118
+ return body;
108
119
  }
109
120
  function proxy(req, res) {
110
121
  const origin = (liveOrigin === '1' ? '' : liveOrigin).replace(/\/$/, '');
@@ -142,7 +153,7 @@ const server = http.createServer((req, res) => {
142
153
  const apiMatch = p.match(/^\/api\/(gds|bongos)(\/.*)$/);
143
154
  if (apiMatch) {
144
155
  const rest = apiMatch[2];
145
- if (FEEDS[rest]) return LIVE ? proxy(req, res) : fixture(FEEDS[rest], res);
156
+ if (FEEDS[rest]) return LIVE ? proxy(req, res) : fixture(FEEDS[rest], res, origin);
146
157
  if (rest === '/me') return AUTH ? json(res, 200, me(origin)) : json(res, 401, UNAUTH);
147
158
  if (AUTHED[rest]) return AUTH ? fixture(AUTHED[rest], res) : json(res, 401, UNAUTH);
148
159
  if (rest === '/provisioning/instances') return AUTH ? json(res, 200, { instances: [] }) : json(res, 401, UNAUTH);