@bongos/core 1.21.68 → 1.21.70
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 +36 -36
- package/.claude/skills/bongos-feedback/SKILL.md +10 -46
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -2
- package/clients/bongos-client/index.cjs +6 -2
- package/clients/bongos-client/index.d.ts +10 -3
- package/clients/bongos-client/index.mjs +6 -2
- package/docs/api/openapi.json +151 -15
- package/docs/api-reference.md +6 -4
- package/docs/copy-inventory.md +371 -366
- package/docs/copy-registry.json +457 -412
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +718 -714
- package/modules/platform-identity/instance-feedback.js +108 -7
- package/modules/platform-identity/routes/sso.js +71 -0
- package/modules/public-landing/public/account.html +88 -1
- package/modules/public-landing/public/account.probes.json +9 -0
- package/modules/public-landing/public/account.states.json +2 -1
- package/modules/public-landing/public/assets/cosmos.css +3 -0
- package/modules/public-landing/public/projects.html +14 -81
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +11 -0
- package/scripts/gds/feedback-send.js +144 -71
- package/src/bongos/platform-visibility-gate.js +3 -0
- package/src/bongos/routes/feedback.js +91 -77
- package/src/module-api.js +1 -1
- package/tests/card_apply_ui.mjs +0 -1
- package/tests/hub_account_page.mjs +132 -6
- package/tests/instance_feedback.mjs +267 -68
- package/tests/public_landing_projects.mjs +12 -13
|
@@ -21,6 +21,15 @@
|
|
|
21
21
|
// an absent rank as untrusted, so even the owner's own feedback waits for the nod
|
|
22
22
|
// rather than inheriting a hub rank the instance never vouched for.
|
|
23
23
|
//
|
|
24
|
+
// TWO DOORS IN (task 1004591). The relay above needs the project linked to the hub,
|
|
25
|
+
// which many are not, so the CLI now sends STRAIGHT here (POST /sso/feedback/direct)
|
|
26
|
+
// with a one-time receipt from its own project (src/bongos/routes/feedback.js).
|
|
27
|
+
// The hub answers at once, then redeems the receipt at the project's public URL:
|
|
28
|
+
// the project names the signed-in sender and the hash of the text, and only a
|
|
29
|
+
// matching report is filed. A project on the hub's own domain is one we run, so
|
|
30
|
+
// its word is verified; any other domain is printed as "confirmed by <host>".
|
|
31
|
+
// The relay stays for projects on cores older than the receipt.
|
|
32
|
+
//
|
|
24
33
|
// THE ATTRIBUTION IS THE HUB'S, NOT THE INSTANCE'S. Client credentials prove WHICH
|
|
25
34
|
// PROJECT is calling and nothing about WHO sent it, so the sender must hold a
|
|
26
35
|
// membership on that project carrying the hub's own sign-in witness
|
|
@@ -86,8 +95,11 @@ function feedbackTitle(message) {
|
|
|
86
95
|
return `Feedback: ${title}`;
|
|
87
96
|
}
|
|
88
97
|
|
|
89
|
-
function feedbackBody({ message, client, account, githubId, context = {} }) {
|
|
90
|
-
|
|
98
|
+
function feedbackBody({ message, client, account, githubId, context = {}, vouch = null }) {
|
|
99
|
+
// vouch (direct reports only): { hosted, host, login } — the receipt's word.
|
|
100
|
+
const claimed = vouch && typeof vouch.login === 'string' && /^[A-Za-z0-9-]{1,39}$/.test(vouch.login) ? vouch.login : null;
|
|
101
|
+
const who = account && account.github_login ? `@${account.github_login}`
|
|
102
|
+
: claimed ? `@${claimed} (GitHub user ${githubId})` : `GitHub user ${githubId}`;
|
|
91
103
|
const project = client.name ? `${client.name} (${client.origin || client.client_id})` : (client.origin || client.client_id);
|
|
92
104
|
const stamps = [
|
|
93
105
|
['Core', cleanStamp(context.core_version)],
|
|
@@ -97,10 +109,13 @@ function feedbackBody({ message, client, account, githubId, context = {} }) {
|
|
|
97
109
|
// The hub's own attribution comes FIRST and the sender's words follow as a
|
|
98
110
|
// quote, so text they type can never pose as the footer a reviewer trusts.
|
|
99
111
|
const quoted = String(message).trim().split('\n').map((l) => `> ${l}`).join('\n');
|
|
112
|
+
const vouchLine = !vouch ? []
|
|
113
|
+
: [`- Sender: ${vouch.hosted ? 'verified (a project Cloud Bongos hosts)' : `confirmed by ${vouch.host} (self-hosted, not verified by us)`}`];
|
|
100
114
|
return [
|
|
101
|
-
'Sent from a managed project with `bongos feedback`.',
|
|
115
|
+
vouch ? 'Sent with `bongos feedback`.' : 'Sent from a managed project with `bongos feedback`.',
|
|
102
116
|
`- Project: ${project}`,
|
|
103
117
|
`- Sent by: ${who}`,
|
|
118
|
+
...vouchLine,
|
|
104
119
|
...(stamps ? [`- Running: ${stamps}`] : []),
|
|
105
120
|
'',
|
|
106
121
|
quoted,
|
|
@@ -110,12 +125,15 @@ function feedbackBody({ message, client, account, githubId, context = {} }) {
|
|
|
110
125
|
// fileInstanceFeedback — file one relayed report. `deps.capture` is the
|
|
111
126
|
// `ideas.capture` port; `deps.getAccount` and `deps.hubBuilderId` read the hub's
|
|
112
127
|
// own rows for the sender. Returns the landing the CLI prints.
|
|
113
|
-
async function fileInstanceFeedback({ client, githubId, message, kind, context }, deps) {
|
|
114
|
-
|
|
115
|
-
|
|
128
|
+
async function fileInstanceFeedback({ client, githubId, message, kind, context, vouch = null }, deps) {
|
|
129
|
+
// A self-hosted project's word cannot borrow a hub account: its owner could
|
|
130
|
+
// name anyone's GitHub id, so the hub's login and builder row stay unread.
|
|
131
|
+
const trusted = !vouch || vouch.hosted;
|
|
132
|
+
const account = trusted ? await deps.getAccount(githubId).catch(() => null) : null;
|
|
133
|
+
const capturedBy = trusted ? await deps.hubBuilderId(githubId).catch(() => null) : null;
|
|
116
134
|
const idea = await deps.capture({
|
|
117
135
|
title: feedbackTitle(message),
|
|
118
|
-
bodyMd: feedbackBody({ message, client, account, githubId, context }),
|
|
136
|
+
bodyMd: feedbackBody({ message, client, account, githubId, context, vouch }),
|
|
119
137
|
kind: FEEDBACK_KINDS[kind],
|
|
120
138
|
capturedBy,
|
|
121
139
|
filerRank: null, // untrusted by design — see the header
|
|
@@ -129,7 +147,90 @@ async function fileInstanceFeedback({ client, githubId, message, kind, context }
|
|
|
129
147
|
: { landed: 'inbox', idea_id: String(idea.id) };
|
|
130
148
|
}
|
|
131
149
|
|
|
150
|
+
// ── the direct door (task 1004591) ───────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
const RECEIPT_TIMEOUT_MS = 8000;
|
|
153
|
+
|
|
154
|
+
function sha256Hex(text) {
|
|
155
|
+
return require('node:crypto').createHash('sha256').update(String(text), 'utf8').digest('hex');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// projectOrigin — the caller-named project, accepted only as a bare https origin
|
|
159
|
+
// on a DNS name (no IP literal, no port, no path, no userinfo). Null otherwise.
|
|
160
|
+
function projectOrigin(raw) {
|
|
161
|
+
let u;
|
|
162
|
+
try { u = new URL(String(raw || '')); } catch { return null; }
|
|
163
|
+
if (u.protocol !== 'https:' || u.port || u.username || u.password) return null;
|
|
164
|
+
if (u.pathname !== '/' && u.pathname !== '') return null;
|
|
165
|
+
if (u.search || u.hash) return null;
|
|
166
|
+
const host = u.hostname.toLowerCase();
|
|
167
|
+
if (!host.includes('.') || require('node:net').isIP(host.replace(/^\[|\]$/g, ''))) return null;
|
|
168
|
+
if (/(^|\.)(localhost|local|internal|test|invalid)$/.test(host)) return null;
|
|
169
|
+
return { origin: `https://${host}`, host };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// isHostedBy — a project on the hub's own domain (or the hub itself) is one we run.
|
|
173
|
+
function isHostedBy(host, hubHost) {
|
|
174
|
+
const h = String(hubHost || '').toLowerCase();
|
|
175
|
+
return !!h && (host === h || host.endsWith(`.${h}`));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// verifyDirectReport — redeem the receipt at the project and check it matches.
|
|
179
|
+
// Resolves { ok: true, receipt } or { ok: false, reason }; never throws. The
|
|
180
|
+
// address is attacker-named, so the host must resolve to a public address
|
|
181
|
+
// (deps.assertPublicHost) and a redirect is an error, never followed.
|
|
182
|
+
async function verifyDirectReport({ origin, receipt, message, kind }, deps) {
|
|
183
|
+
const target = projectOrigin(origin);
|
|
184
|
+
if (!target) return { ok: false, reason: 'bad_origin' };
|
|
185
|
+
if (!/^[A-Za-z0-9_-]{16,64}$/.test(String(receipt || ''))) return { ok: false, reason: 'bad_receipt' };
|
|
186
|
+
try {
|
|
187
|
+
await deps.assertPublicHost(target.host);
|
|
188
|
+
} catch {
|
|
189
|
+
return { ok: false, reason: 'private_host' };
|
|
190
|
+
}
|
|
191
|
+
let body;
|
|
192
|
+
try {
|
|
193
|
+
const res = await (deps.fetchImpl || fetch)(`${target.origin}/api/bongos/feedback/receipt/${receipt}`, {
|
|
194
|
+
headers: { accept: 'application/json' },
|
|
195
|
+
redirect: 'error',
|
|
196
|
+
signal: AbortSignal.timeout(RECEIPT_TIMEOUT_MS),
|
|
197
|
+
});
|
|
198
|
+
if (res.status !== 200) return { ok: false, reason: `receipt_http_${res.status}` };
|
|
199
|
+
body = await res.json();
|
|
200
|
+
} catch {
|
|
201
|
+
return { ok: false, reason: 'receipt_unreachable' };
|
|
202
|
+
}
|
|
203
|
+
if (!body || typeof body !== 'object') return { ok: false, reason: 'receipt_malformed' };
|
|
204
|
+
if (!/^\d{1,20}$/.test(String(body.github_id || ''))) return { ok: false, reason: 'receipt_malformed' };
|
|
205
|
+
if (body.sha256 !== sha256Hex(message)) return { ok: false, reason: 'text_mismatch' };
|
|
206
|
+
if ((body.kind || 'bug') !== kind) return { ok: false, reason: 'kind_mismatch' };
|
|
207
|
+
return { ok: true, target, receipt: body };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// fileDirectReport — the whole after-the-202 half: verify, then file. Returns the
|
|
211
|
+
// landing, or { dropped: reason } for a report the project did not vouch for.
|
|
212
|
+
async function fileDirectReport({ origin, receipt, message, kind, context = {}, hubHost }, deps) {
|
|
213
|
+
const v = await verifyDirectReport({ origin, receipt, message, kind }, deps);
|
|
214
|
+
if (!v.ok) return { dropped: v.reason };
|
|
215
|
+
const githubId = String(v.receipt.github_id);
|
|
216
|
+
const hosted = isHostedBy(v.target.host, hubHost);
|
|
217
|
+
return fileInstanceFeedback({
|
|
218
|
+
client: { name: v.target.host, origin: v.target.origin, client_id: v.target.host },
|
|
219
|
+
githubId,
|
|
220
|
+
message,
|
|
221
|
+
kind,
|
|
222
|
+
// The project's own core version beats whatever the CLI claimed.
|
|
223
|
+
context: { ...context, core_version: v.receipt.core_version || null },
|
|
224
|
+
vouch: { hosted, host: v.target.host, login: v.receipt.github_login },
|
|
225
|
+
}, deps);
|
|
226
|
+
}
|
|
227
|
+
|
|
132
228
|
module.exports = {
|
|
229
|
+
sha256Hex,
|
|
230
|
+
projectOrigin,
|
|
231
|
+
isHostedBy,
|
|
232
|
+
verifyDirectReport,
|
|
233
|
+
fileDirectReport,
|
|
133
234
|
FEEDBACK_KINDS,
|
|
134
235
|
MESSAGE_MAX,
|
|
135
236
|
PER_SENDER_LIMIT,
|
|
@@ -30,9 +30,25 @@ const applicationEcho = require('../application-echo');
|
|
|
30
30
|
// The guild label the echo carries (task 1002303, ADR 0365 D5).
|
|
31
31
|
const guildEngagements = require('../guild-engagements');
|
|
32
32
|
const instanceFeedback = require('../instance-feedback');
|
|
33
|
+
const projectStats = require('../project-stats');
|
|
33
34
|
|
|
34
35
|
// One limiter per hub process for POST /sso/feedback (task 1004462).
|
|
35
36
|
const feedbackLimiter = instanceFeedback.makeFeedbackLimiter();
|
|
37
|
+
// …and two for POST /sso/feedback/direct (task 1004591), which carries no
|
|
38
|
+
// credential: one keyed by the caller's address ALONE, so naming a fresh host on
|
|
39
|
+
// every request buys nothing, and one by the named project and address.
|
|
40
|
+
const directIpLimiter = instanceFeedback.makeFeedbackLimiter();
|
|
41
|
+
const directFeedbackLimiter = instanceFeedback.makeFeedbackLimiter();
|
|
42
|
+
|
|
43
|
+
// The hub's own host: a direct report from it or a subdomain of it comes from a
|
|
44
|
+
// project we run. Best-effort — no host means nothing counts as hosted. Read once.
|
|
45
|
+
let _hubHost = null;
|
|
46
|
+
function hubHost() {
|
|
47
|
+
if (_hubHost === null) {
|
|
48
|
+
try { _hubHost = new URL(api.branding().domains.publicOrigin).hostname.toLowerCase(); } catch { _hubHost = ''; }
|
|
49
|
+
}
|
|
50
|
+
return _hubHost;
|
|
51
|
+
}
|
|
36
52
|
|
|
37
53
|
// Per-IP cap for POST /sso/applicant-profile — DEFENCE IN DEPTH behind the echo
|
|
38
54
|
// gate, not the gate itself (task 1002972 grade advisory). The echo already
|
|
@@ -781,5 +797,60 @@ module.exports = function ssoRoutes() {
|
|
|
781
797
|
}
|
|
782
798
|
});
|
|
783
799
|
|
|
800
|
+
// POST /sso/feedback/direct — `bongos feedback` straight from a builder's CLI,
|
|
801
|
+
// with a one-time receipt from their own project (task 1004591). Answers 202 at
|
|
802
|
+
// once so the builder never waits; the receipt is redeemed AFTER, and a report
|
|
803
|
+
// the project does not vouch for is dropped (logged, never filed). Why this
|
|
804
|
+
// proves the sender, and what "hosted" earns: ../instance-feedback.js.
|
|
805
|
+
//
|
|
806
|
+
// rank: public — no session on the hub; the proof is the project's receipt.
|
|
807
|
+
// Capped per caller address (10/h whatever host it names), then per named
|
|
808
|
+
// project (30/h), BEFORE anything is fetched.
|
|
809
|
+
router.post('/sso/feedback/direct', (req, res) => {
|
|
810
|
+
if (validateOrRespond(req, res, {
|
|
811
|
+
origin: { required: true, type: 'string', maxLength: 200 },
|
|
812
|
+
receipt: { required: true, type: 'string', maxLength: 64 },
|
|
813
|
+
message: { required: true, type: 'string', maxLength: instanceFeedback.MESSAGE_MAX },
|
|
814
|
+
kind: { type: 'string', enum: Object.keys(instanceFeedback.FEEDBACK_KINDS) },
|
|
815
|
+
context: {},
|
|
816
|
+
})) return;
|
|
817
|
+
const b = req.body || {};
|
|
818
|
+
const target = instanceFeedback.projectOrigin(b.origin);
|
|
819
|
+
if (!target) return res.fail('bad_origin', 400);
|
|
820
|
+
const message = String(b.message).trim();
|
|
821
|
+
if (!message) return res.fail('message_required', 400);
|
|
822
|
+
// The address's own bucket first (both of its tiers keyed by the address, so
|
|
823
|
+
// no bucket is shared between callers); only an address still under its cap
|
|
824
|
+
// may spend from the named project's bucket — a refused caller costs a real
|
|
825
|
+
// project nothing.
|
|
826
|
+
const ip = String(req.ip || 'unknown');
|
|
827
|
+
let allowed = directIpLimiter.take(ip, 'direct');
|
|
828
|
+
if (allowed.ok) allowed = directFeedbackLimiter.take(target.host, ip);
|
|
829
|
+
if (!allowed.ok) {
|
|
830
|
+
res.set('Retry-After', String(allowed.retryAfter));
|
|
831
|
+
return res.fail('rate_limited', 429, { scope: `feedback-${allowed.scope}`, retry_after_seconds: allowed.retryAfter });
|
|
832
|
+
}
|
|
833
|
+
res.status(202).json({ ok: true, accepted: true });
|
|
834
|
+
const ctx = b.context && typeof b.context === 'object' ? b.context : {};
|
|
835
|
+
instanceFeedback.fileDirectReport({
|
|
836
|
+
origin: target.origin,
|
|
837
|
+
receipt: b.receipt,
|
|
838
|
+
message: b.message,
|
|
839
|
+
kind: b.kind || 'bug',
|
|
840
|
+
context: { cli_version: ctx.cli_version, os: ctx.os },
|
|
841
|
+
hubHost: hubHost(),
|
|
842
|
+
}, {
|
|
843
|
+
assertPublicHost: (host) => projectStats.assertPublicHost(host),
|
|
844
|
+
capture: (...a) => api.resolve('ideas.capture')(...a),
|
|
845
|
+
getAccount: (id) => pi.getAccount(id, { pool }),
|
|
846
|
+
hubBuilderId: async (id) => {
|
|
847
|
+
const { rows } = await pool.query('SELECT id FROM builders WHERE github_id = $1 LIMIT 1', [id]);
|
|
848
|
+
return rows[0] ? rows[0].id : null;
|
|
849
|
+
},
|
|
850
|
+
}).then((r) => {
|
|
851
|
+
if (r && r.dropped) log.warn(`[platform-identity] /sso/feedback/direct dropped from ${target.host}: ${r.dropped}`);
|
|
852
|
+
}, (err) => log.error('[platform-identity] /sso/feedback/direct', err));
|
|
853
|
+
});
|
|
854
|
+
|
|
784
855
|
return router;
|
|
785
856
|
};
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
once, and every project's hall links out to it instead of keeping a copy.
|
|
11
11
|
Hub-only: platform-server.js serves /account only where platform-identity
|
|
12
12
|
is on, because every read and write below is one of that module's own-scoped
|
|
13
|
-
routes (GET/PATCH /me/cross-project, PATCH /me/handle, PATCH /me/profile
|
|
13
|
+
routes (GET/PATCH /me/cross-project, PATCH /me/handle, PATCH /me/profile,
|
|
14
|
+
GET /my-projects and POST /my-projects/:clientId/leave).
|
|
14
15
|
Server-stamped like the footer doors: {{…}} tokens filled by landingPage(). -->
|
|
15
16
|
<title>{{productName}} — Your account</title>
|
|
16
17
|
<meta name="description" content="Your {{productName}} account: your handle, your name, your bio and links, and who can find you.">
|
|
@@ -245,6 +246,24 @@
|
|
|
245
246
|
<p class="projMsg" id="recordMsg" role="status" aria-live="polite"></p>
|
|
246
247
|
</form>
|
|
247
248
|
</section>
|
|
249
|
+
|
|
250
|
+
<!-- PROJECTS YOU BUILD ON — GET /my-projects building[], Leave → POST
|
|
251
|
+
/my-projects/:clientId/leave (task 1003774). Moved here from
|
|
252
|
+
/projects?view=mine by task 1004277: the owner's call of 2026-09-25 put it
|
|
253
|
+
on the builder's own profile, own view only, and with no hub profile page
|
|
254
|
+
built (/u/<handle> was abandoned) this page is that own view (the owner's
|
|
255
|
+
pick, 2026-10-06). Cross-project data, so never on a project hall's seat
|
|
256
|
+
profile. The rows are cosmos.css's .mineRow, as on /invitations. -->
|
|
257
|
+
<section class="aSec" id="aBuilding" aria-labelledby="aBuilding-h">
|
|
258
|
+
<h2 id="aBuilding-h">projects you build on</h2>
|
|
259
|
+
<p class="aSide">The projects you have joined as a builder. Only you see this list.</p>
|
|
260
|
+
<div class="aBody">
|
|
261
|
+
<p class="aWhat" id="buildingLoading">Reading the projects you build on…</p>
|
|
262
|
+
<p class="aWhat" id="buildingEmpty" hidden>You’re not building on any other projects yet.</p>
|
|
263
|
+
<ul class="mineRows" id="buildingList"></ul>
|
|
264
|
+
<p class="projMsg" id="buildingMsg" role="status" aria-live="polite"></p>
|
|
265
|
+
</div>
|
|
266
|
+
</section>
|
|
248
267
|
</div>
|
|
249
268
|
</main>
|
|
250
269
|
|
|
@@ -323,6 +342,12 @@
|
|
|
323
342
|
$('loading').hidden = true;
|
|
324
343
|
$('door').hidden = true;
|
|
325
344
|
$('acctBody').hidden = false;
|
|
345
|
+
// a link to one section (/projects' "Open your account" names #aBuilding)
|
|
346
|
+
// lands on it: the browser looked for the anchor while the body was hidden
|
|
347
|
+
var hash = window.location && window.location.hash;
|
|
348
|
+
var target = hash && document.getElementById(hash.slice(1));
|
|
349
|
+
if (target && typeof target.scrollIntoView === 'function') target.scrollIntoView();
|
|
350
|
+
loadBuilding();
|
|
326
351
|
}, function () { showAlert('Couldn’t reach the server — check your connection and refresh.'); });
|
|
327
352
|
}
|
|
328
353
|
|
|
@@ -530,6 +555,68 @@
|
|
|
530
555
|
: 'Cross-project record shown on your public profile.');
|
|
531
556
|
});
|
|
532
557
|
|
|
558
|
+
// ---- projects you build on (task 1004277; the rows and Leave came from
|
|
559
|
+
// /projects?view=mine, task 1003774). Read once the account is shown, on its
|
|
560
|
+
// own: a failed read says so and never paints a false "not building on
|
|
561
|
+
// anything". The map's mineOrigins clean-up stayed behind with the map —
|
|
562
|
+
// /projects re-reads GET /my-projects whenever it is opened. ----
|
|
563
|
+
function hostOf(origin) {
|
|
564
|
+
return String(origin || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase();
|
|
565
|
+
}
|
|
566
|
+
function rowEl(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
|
|
567
|
+
function loadBuilding() {
|
|
568
|
+
var msg = $('buildingMsg');
|
|
569
|
+
return request('GET', '/my-projects').then(function (res) {
|
|
570
|
+
$('buildingLoading').hidden = true;
|
|
571
|
+
if (!res.ok || !res.data) { say(msg, 'Couldn’t read the projects you build on — refresh in a moment.', 'err'); return; }
|
|
572
|
+
paintBuilding(res.data.building || []);
|
|
573
|
+
}, function () {
|
|
574
|
+
$('buildingLoading').hidden = true;
|
|
575
|
+
say(msg, 'Couldn’t reach the server — check your connection and refresh.', 'err');
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
function paintBuilding(rows) {
|
|
579
|
+
var ul = $('buildingList');
|
|
580
|
+
ul.textContent = '';
|
|
581
|
+
rows.forEach(function (m) { ul.appendChild(buildingRow(m)); });
|
|
582
|
+
$('buildingEmpty').hidden = rows.length > 0;
|
|
583
|
+
}
|
|
584
|
+
function buildingRow(m) {
|
|
585
|
+
var label = m.name || m.client_id;
|
|
586
|
+
var row = rowEl('li', 'mineRow');
|
|
587
|
+
var main = rowEl('div', 'mineRow__main');
|
|
588
|
+
var name = rowEl('div', 'mineRow__name');
|
|
589
|
+
name.textContent = label;
|
|
590
|
+
main.appendChild(name);
|
|
591
|
+
var host = hostOf(m.origin);
|
|
592
|
+
if (host) { var h = rowEl('span', 'mineRow__host'); h.textContent = host; main.appendChild(h); }
|
|
593
|
+
row.appendChild(main);
|
|
594
|
+
var actions = rowEl('div', 'mineRow__actions');
|
|
595
|
+
var btn = document.createElement('button');
|
|
596
|
+
btn.type = 'button';
|
|
597
|
+
btn.className = 'btn small ghost';
|
|
598
|
+
btn.textContent = 'Leave';
|
|
599
|
+
// every row's button reads Leave; the name says which project it leaves
|
|
600
|
+
btn.setAttribute('aria-label', 'Leave ' + label);
|
|
601
|
+
btn.addEventListener('click', function () { leave(m.client_id, label, row, btn); });
|
|
602
|
+
actions.appendChild(btn);
|
|
603
|
+
row.appendChild(actions);
|
|
604
|
+
return row;
|
|
605
|
+
}
|
|
606
|
+
function leave(clientId, label, row, btn) {
|
|
607
|
+
var msg = $('buildingMsg');
|
|
608
|
+
function restore() { btn.disabled = false; btn.textContent = 'Leave'; }
|
|
609
|
+
btn.disabled = true;
|
|
610
|
+
btn.textContent = 'Leaving…';
|
|
611
|
+
request('POST', '/my-projects/' + encodeURIComponent(clientId) + '/leave').then(function (res) {
|
|
612
|
+
if (res.status === 401) { restore(); sessionGone(msg); return; }
|
|
613
|
+
if (!res.ok) { restore(); say(msg, 'Couldn’t leave ' + label + '. Try again.', 'err'); return; }
|
|
614
|
+
row.remove();
|
|
615
|
+
say(msg, 'You left ' + label + '.', 'ok');
|
|
616
|
+
if (!$('buildingList').children.length) $('buildingEmpty').hidden = false;
|
|
617
|
+
}, function () { restore(); say(msg, 'Couldn’t reach the server. Try again.', 'err'); });
|
|
618
|
+
}
|
|
619
|
+
|
|
533
620
|
// The identity read failing is not a sign-out. If the account itself was
|
|
534
621
|
// read, the chip names it from that answer, so the way out is never lost.
|
|
535
622
|
load().then(function () { return bar.ready; }).then(function () {
|
|
@@ -30,6 +30,15 @@
|
|
|
30
30
|
{ "name": "hiding the record saves hide_stats alone", "continue": true,
|
|
31
31
|
"steps": [["click", "#aRecord .aOpt:nth-child(2)"], ["click", "#recordSave"], ["wait", 400]],
|
|
32
32
|
"expect": { "text": [["#recordMsg", "Cross-project record hidden — removed from your profile and directories immediately."]], "checked": "input[name=record][value=hide]" } },
|
|
33
|
+
{ "name": "projects you build on (task 1004277): one row per project, each with its own Leave", "auth": true, "width": 1440, "url": "/account#aBuilding",
|
|
34
|
+
"steps": [["wait", 500]],
|
|
35
|
+
"expect": { "count": [["#buildingList .mineRow", 2]], "attr": [["#buildingList .mineRow:first-child button", "aria-label", "Leave Example World"], ["#buildingList .mineRow:last-child button", "aria-label", "Leave Emersonian Circles"]], "hidden": ["#buildingEmpty"] } },
|
|
36
|
+
{ "name": "Leave drops the row and says so", "continue": true,
|
|
37
|
+
"steps": [["click", "#buildingList .mineRow:first-child button"], ["wait", 300]],
|
|
38
|
+
"expect": { "count": [["#buildingList .mineRow", 1]], "text": [["#buildingMsg", "You left Example World."]], "hidden": ["#buildingEmpty"] } },
|
|
39
|
+
{ "name": "leaving the last project shows the empty line", "continue": true,
|
|
40
|
+
"steps": [["click", "#buildingList .mineRow:first-child button"], ["wait", 300]],
|
|
41
|
+
"expect": { "count": [["#buildingList .mineRow", 0]], "text": [["#buildingMsg", "You left Emersonian Circles."], ["#buildingEmpty", "You’re not building on any other projects yet."]], "visible": ["#buildingEmpty"] } },
|
|
33
42
|
{ "name": "the hub's bar: the chip names the person, both bands closed, the page starts under the bar (task 1004455)", "auth": true, "width": 1440,
|
|
34
43
|
"steps": [["wait", 400]],
|
|
35
44
|
"expect": { "expanded": false, "top": [["#acct", 64]], "visible": ["#projTrig", "#who"], "hidden": ["#signinTop", "#menuProjects", "#menuProfile"], "text": [["#whoLogin", "builder"]], "count": [["#mapQ", 0]] } },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_": "The account page's states for modules/ui-design/kit/render.js (task 1004259, ADR 0337 D4.3): signed out it is the sign-in door and nothing else; signed in it is the hub's bar (task 1004455: the identity chip from GET /me, the Projects band and the profile band with Account current) over the
|
|
2
|
+
"_": "The account page's states for modules/ui-design/kit/render.js (task 1004259, ADR 0337 D4.3): signed out it is the sign-in door and nothing else; signed in it is the hub's bar (task 1004455: the identity chip from GET /me, the Projects band and the profile band with Account current) over the five sections, painted from the stub's one account (fixtures/me__cross-project.json — public, recruiters following the profile setting, a bio and two links), the fifth being the projects you build on, from fixtures/my-projects.json (task 1004277; in-building lands on it through /account#aBuilding). Dark only, like every apex page (ADR 0204). The private, hidden, not-yet-published account renders from account-private.states.json.",
|
|
3
3
|
"page": "/account",
|
|
4
4
|
"surface": "public-landing",
|
|
5
5
|
"modes": ["dark"],
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"in": { "auth": true, "actions": [["wait", 400]], "expect": { "visible": ["#acctBody", "#fName", "#fBio", "#fHandle", "#recWhat", "#recordLine", "#who"], "text": [["#whoLogin", "builder"], ["#recordLine", "Your record: 3 projects · 1000 credits · 19 works shipped · 10 karma."]], "hidden": ["#door", "#loading", "#signinTop", "#menuProjects", "#menuProfile"], "value": [["#fHandle", "builder"], ["#fName", "A Builder"], ["#fLinkX", "https://x.com/builder"]] } },
|
|
13
13
|
"in-projects-band": { "auth": true, "actions": [["wait", 400], ["click", "#projTrig"], ["wait", 300]], "expect": { "visible": ["#menuProjects", "#mineCell"], "hidden": ["#menuProfile"] } },
|
|
14
14
|
"in-profile-band": { "auth": true, "actions": [["wait", 400], ["click", "#who"], ["wait", 300]], "expect": { "visible": ["#menuProfile"], "hidden": ["#menuProjects"], "text": [["#bandWhoLogin", "builder"], ["#menuProfile [aria-current=\"page\"]", "Account"]] } },
|
|
15
|
+
"in-building": { "auth": true, "url": "/account#aBuilding", "actions": [["wait", 500]], "expect": { "visible": ["#aBuilding", "#buildingList"], "hidden": ["#buildingEmpty", "#buildingLoading"], "count": [["#buildingList .mineRow", 2]], "text": [["#buildingList .mineRow:first-child .mineRow__name", "Example World"], ["#buildingList .mineRow:first-child .mineRow__host", "example.com"]], "attr": [["#buildingList .mineRow:first-child button", "aria-label", "Leave Example World"]] } },
|
|
15
16
|
"in-privacy": { "auth": true, "actions": [["wait", 400], ["click", "#aPrivacy .aOpt:nth-child(2)"], ["wait", 150]], "expect": { "checked": "input[name=visibility][value=private]", "text": [["#recWhat", "They will see your name, your picture, your roles, and whether you have been active in the last 30 days — never your credits, works shipped, karma, or the projects you build on. This is the one thing a recruiter can see while your profile page is private. Turning this off removes you from both recruiting surfaces — the recruiting directory, and a recruiter’s look-up of one builder — straight away. · For now this follows your profile page setting."]] } }
|
|
16
17
|
},
|
|
17
18
|
"reducedMotion": { "auth": true, "actions": [["wait", 300]] }
|
|
@@ -1305,6 +1305,9 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1305
1305
|
(this content is a stacked list, not a label/value pair). ── */
|
|
1306
1306
|
.mineExtra section{margin-top:30px;padding-top:26px;border-top:1px solid var(--rule-soft);}
|
|
1307
1307
|
.mineExtra h2{font-family:var(--font-display);font-weight:300;font-size:clamp(20px,2.2vw,24px);letter-spacing:-.02em;line-height:1.15;text-transform:lowercase;color:var(--ink);margin:0 0 14px;}
|
|
1308
|
+
/* the mine view's two ways out (to /invitations and /account) are links in a
|
|
1309
|
+
sentence; inline-block lets each clear the kit's 24px target floor */
|
|
1310
|
+
#view-mine .mineExtra .lede a{display:inline-block;min-height:24px;}
|
|
1308
1311
|
.mineRows{list-style:none;display:flex;flex-direction:column;gap:0;margin:0;padding:0;}
|
|
1309
1312
|
.mineRow{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 0;border-top:1px solid var(--rule-soft);}
|
|
1310
1313
|
.mineRows .mineRow:first-child{border-top:0;}
|
|
@@ -955,20 +955,22 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
955
955
|
<ul class="projects" id="projectsList"></ul>
|
|
956
956
|
<p class="finePrint">Buttons here ask the platform to act; each row reports only what was verified, and when. A standup keeps going even with this tab closed.</p>
|
|
957
957
|
|
|
958
|
-
<!-- ══
|
|
958
|
+
<!-- ══ the ways to INVITATIONS and to PROJECTS YOU BUILD ON ══
|
|
959
959
|
The invitations (a project's own, a guild's, a guild's engagements, the
|
|
960
960
|
requests to a guild you own) and the join-by-address field have their own
|
|
961
|
-
page since task 1004276 (the owner's call, ADR 0337 D5): /invitations.
|
|
961
|
+
page since task 1004276 (the owner's call, ADR 0337 D5): /invitations.
|
|
962
|
+
The projects you build on, with Leave (task 1003774), moved to the
|
|
963
|
+
account page with task 1004277: the owner put them on the builder's own
|
|
964
|
+
profile, and /account is the hub's own view of it. ══ -->
|
|
962
965
|
<div class="mineExtra">
|
|
963
966
|
<section aria-labelledby="mineToInvitesH">
|
|
964
967
|
<h2 id="mineToInvitesH">Invitations</h2>
|
|
965
968
|
<p class="lede">Invitations from projects and guilds, and applying to a project by its address, have their own page. <a href="/invitations">Open your invitations</a></p>
|
|
966
969
|
</section>
|
|
967
970
|
|
|
968
|
-
<section aria-labelledby="
|
|
969
|
-
<h2 id="
|
|
970
|
-
<p class="
|
|
971
|
-
<ul class="mineRows" id="buildingList"></ul>
|
|
971
|
+
<section aria-labelledby="mineToAccountH">
|
|
972
|
+
<h2 id="mineToAccountH">Projects you build on</h2>
|
|
973
|
+
<p class="lede">The projects you build on, and leaving one, are on your account page. <a href="/account#aBuilding">Open your account</a></p>
|
|
972
974
|
</section>
|
|
973
975
|
</div>
|
|
974
976
|
</section>
|
|
@@ -2522,10 +2524,10 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2522
2524
|
: '%3Fview%3D' + v + idPart.replace('&', '%26').replace('=', '%3D');
|
|
2523
2525
|
$('signinTop').href = '/api/bongos/auth/web/start?return=/projects' + backPart + '&repos=1';
|
|
2524
2526
|
/* the mine view is the only one that polls; it re-reads on entry so a
|
|
2525
|
-
card that changed while you were on the map is not stale
|
|
2526
|
-
the
|
|
2527
|
-
|
|
2528
|
-
if (v === 'mine' && me)
|
|
2527
|
+
card that changed while you were on the map is not stale. (It re-read
|
|
2528
|
+
the memberships too while it listed the projects you build on; that
|
|
2529
|
+
list is /account's since task 1004277.) */
|
|
2530
|
+
if (v === 'mine' && me) refreshList();
|
|
2529
2531
|
/* manage re-reads its project on entry — no timer lives here (C8) */
|
|
2530
2532
|
if (v === 'manage') loadManage();
|
|
2531
2533
|
/* owned chips refresh on re-entry, never on a timer — C8 keeps the map quiet */
|
|
@@ -3525,23 +3527,13 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3525
3527
|
401 (signed out) is not an error here. */
|
|
3526
3528
|
function loadMemberships() {
|
|
3527
3529
|
return fetch(API + '/my-projects', { credentials: 'same-origin' })
|
|
3528
|
-
.then(function (res) {
|
|
3529
|
-
|
|
3530
|
-
? res.json().then(function (d) { return { ok: true, d: d }; })
|
|
3531
|
-
: { ok: false, d: { owned: [], building: [], applied: [] } };
|
|
3532
|
-
})
|
|
3533
|
-
.then(function (r) {
|
|
3534
|
-
var d = r.d;
|
|
3530
|
+
.then(function (res) { return res.ok ? res.json() : { owned: [], building: [], applied: [] }; })
|
|
3531
|
+
.then(function (d) {
|
|
3535
3532
|
mineOrigins = {};
|
|
3536
3533
|
appliedOrigins = {};
|
|
3537
3534
|
(d.owned || []).forEach(function (m) { if (m.origin) mineOrigins[hostOf(m.origin)] = 'owner'; });
|
|
3538
3535
|
(d.building || []).forEach(function (m) { if (m.origin) mineOrigins[hostOf(m.origin)] = 'builder'; });
|
|
3539
3536
|
(d.applied || []).forEach(function (m) { if (m.origin) appliedOrigins[hostOf(m.origin)] = true; });
|
|
3540
|
-
/* only a genuinely successful read may repaint "Projects you build on" —
|
|
3541
|
-
a transient failure resets the map's origin maps (as before) but must
|
|
3542
|
-
not overwrite the list with a false "not building on anything" empty
|
|
3543
|
-
state on every re-entry to this view (task 1003774) */
|
|
3544
|
-
if (r.ok) renderBuildingList(d.building || []);
|
|
3545
3537
|
})
|
|
3546
3538
|
.catch(function () { mineOrigins = {}; appliedOrigins = {}; });
|
|
3547
3539
|
}
|
|
@@ -3569,65 +3561,6 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3569
3561
|
.catch(function () { ownedInstances = []; ownedByHost = {}; });
|
|
3570
3562
|
}
|
|
3571
3563
|
|
|
3572
|
-
/* ── the building-on rows (task 1003774; the invitation rows moved to
|
|
3573
|
-
/invitations with task 1004276) ── Same row shape as that page's lists: a name, its host in the mono face, and one
|
|
3574
|
-
action on the right. textContent-only, matching the deleted hall page
|
|
3575
|
-
this restores. */
|
|
3576
|
-
function mineEl(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
|
|
3577
|
-
function mineRow(p) {
|
|
3578
|
-
var row = mineEl('li', 'mineRow');
|
|
3579
|
-
var main = mineEl('div', 'mineRow__main');
|
|
3580
|
-
var name = mineEl('div', 'mineRow__name');
|
|
3581
|
-
name.textContent = p.name || p.client_id;
|
|
3582
|
-
main.appendChild(name);
|
|
3583
|
-
var host = hostOf(p.origin);
|
|
3584
|
-
if (host) { var h = mineEl('span', 'mineRow__host'); h.textContent = host; main.appendChild(h); }
|
|
3585
|
-
row.appendChild(main);
|
|
3586
|
-
return row;
|
|
3587
|
-
}
|
|
3588
|
-
|
|
3589
|
-
/* the leave/decline shape: disable, POST, drop the row on success, restore
|
|
3590
|
-
the button on failure. `onRemoved` carries whatever else the caller needs
|
|
3591
|
-
after the row is gone (the empty-state flip, the mineOrigins cleanup). */
|
|
3592
|
-
function postThenRemoveRow(url, row, btn, idleLabel, onRemoved) {
|
|
3593
|
-
btn.disabled = true;
|
|
3594
|
-
if (idleLabel) btn.textContent = idleLabel.busy;
|
|
3595
|
-
fetch(url, { method: 'POST', credentials: 'same-origin' })
|
|
3596
|
-
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
|
|
3597
|
-
.then(function () { row.remove(); if (onRemoved) onRemoved(); })
|
|
3598
|
-
.catch(function () { btn.disabled = false; if (idleLabel) btn.textContent = idleLabel.idle; });
|
|
3599
|
-
}
|
|
3600
|
-
|
|
3601
|
-
function renderBuildingList(rows) {
|
|
3602
|
-
var ul = $('buildingList');
|
|
3603
|
-
ul.textContent = '';
|
|
3604
|
-
(rows || []).forEach(function (m) {
|
|
3605
|
-
var row = mineRow(m);
|
|
3606
|
-
var actions = mineEl('div', 'mineRow__actions');
|
|
3607
|
-
var btn = document.createElement('button');
|
|
3608
|
-
btn.type = 'button';
|
|
3609
|
-
btn.className = 'btn small ghost';
|
|
3610
|
-
btn.textContent = 'Leave';
|
|
3611
|
-
btn.addEventListener('click', function () {
|
|
3612
|
-
postThenRemoveRow(
|
|
3613
|
-
API + '/my-projects/' + encodeURIComponent(m.client_id) + '/leave', row, btn,
|
|
3614
|
-
{ busy: 'Leaving…', idle: 'Leave' },
|
|
3615
|
-
/* the map reads mineOrigins for its "you build here" pill and Apply
|
|
3616
|
-
button — clear this project's entry so a leave takes effect there
|
|
3617
|
-
on the next repaint too, not just in this list (task 1003774) */
|
|
3618
|
-
function () {
|
|
3619
|
-
delete mineOrigins[hostOf(m.origin)];
|
|
3620
|
-
if (!ul.children.length) $('buildingEmpty').hidden = false;
|
|
3621
|
-
}
|
|
3622
|
-
);
|
|
3623
|
-
});
|
|
3624
|
-
actions.appendChild(btn);
|
|
3625
|
-
row.appendChild(actions);
|
|
3626
|
-
ul.appendChild(row);
|
|
3627
|
-
});
|
|
3628
|
-
$('buildingEmpty').hidden = !!(rows && rows.length);
|
|
3629
|
-
}
|
|
3630
|
-
|
|
3631
3564
|
/* the relay the map's per-card Apply (submitApply) drives — POST
|
|
3632
3565
|
/my-projects/join, the 401 case and the 'requested' echo update kept in ONE
|
|
3633
3566
|
place (task 1003774). /invitations carries the same relay for its join field
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.70",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.70",
|
|
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.21.
|
|
3
|
+
"version": "1.21.70",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -9188,5 +9188,16 @@
|
|
|
9188
9188
|
"id": "1004589",
|
|
9189
9189
|
"text": "The /invitations project picker now draws its list once per keystroke instead of twice. Nothing looks different."
|
|
9190
9190
|
}
|
|
9191
|
+
],
|
|
9192
|
+
"1.21.69": [
|
|
9193
|
+
{
|
|
9194
|
+
"id": "1004277",
|
|
9195
|
+
"text": "The projects you build on, with their Leave buttons, now live on your account page. My projects links there."
|
|
9196
|
+
}
|
|
9197
|
+
],
|
|
9198
|
+
"1.21.70": [
|
|
9199
|
+
{
|
|
9200
|
+
"text": "Reporting a problem with Bongos now works from every project, including ones like Will's that could not send feedback before. It takes a second, needs no extra sign-in, and the Bongos team can see who really sent each report."
|
|
9201
|
+
}
|
|
9191
9202
|
]
|
|
9192
9203
|
}
|