@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.
- package/.bongos-core.json +50 -40
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0200-re-point-a-bring-your-own-domain.md +105 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +3 -2
- package/docs/copy-inventory.md +141 -138
- package/docs/copy-registry.json +205 -176
- package/docs/design/projects-hub-direction-v2.md +16 -0
- package/modules/platform-identity/project-stats.js +123 -38
- package/modules/platform-identity/routes/my-projects.js +13 -0
- package/modules/platform-identity/routes/projects.js +7 -4
- package/modules/provisioning/migrations/provisioning_012_domain_repoint.sql +35 -0
- package/modules/provisioning/provisioning.js +68 -8
- package/modules/provisioning/routes/provisioning.js +50 -23
- package/modules/ui-design/kit/fixtures/featured.json +1 -1
- package/modules/ui-design/kit/fixtures/my-projects.json +1 -1
- package/modules/ui-design/kit/serve.js +14 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +126 -25
- package/public-landing/projects.html +155 -27
- package/public-landing/projects.probes.json +28 -0
- package/public-landing/projects.states.json +6 -3
- package/scripts/gds/provision-net.js +33 -0
- package/scripts/gds/provision-units.js +18 -0
- package/scripts/gds/provision.js +97 -5
- package/src/module-api.js +5 -1
- package/tests/cosmos_token_contrast.mjs +5 -2
- package/tests/project_stats.mjs +128 -2
- package/tests/projects_hub.mjs +106 -5
- package/tests/projects_hub_pre_uat.mjs +28 -2
- package/tests/provision.mjs +185 -12
- package/tests/provisioning_default_domain.mjs +5 -3
- package/tests/provisioning_domain_attach.mjs +166 -17
- 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
|
|
2
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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 ||
|
|
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 = {
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
472
|
-
// changes stay setInstanceStatus's job;
|
|
473
|
-
// route can never drift an instance's
|
|
474
|
-
|
|
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
|
|
477
|
-
|
|
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,
|
|
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.
|
|
863
|
-
//
|
|
864
|
-
|
|
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
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
|
|
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
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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":
|
|
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);
|