@bongos/core 1.19.373 → 1.19.375
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 +98 -48
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0194-joinability-one-door-decision.md +42 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +159 -4
- package/docs/api-reference.md +7 -4
- package/docs/copy-inventory.md +252 -233
- package/docs/copy-registry.json +503 -301
- package/modules/builder-settings/profile-nudge-prefs.js +125 -0
- package/modules/builder-settings/routes/settings.js +48 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/builders.js +5 -3
- package/modules/hall-ui/public/goal-inbox.js +10 -0
- package/modules/hall-ui/public/hall-render.js +63 -22
- package/modules/hall-ui/public/index.html +4 -0
- package/modules/hall-ui/public/my-projects.js +6 -1
- package/modules/hall-ui/public/profile-nudge.js +122 -0
- package/modules/hall-ui/public/watch.css +8 -0
- package/modules/hall-ui/public/watch.js +56 -3
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/access-requests.js +87 -1
- package/modules/platform-identity/routes/my-projects.js +35 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +169 -3
- package/scripts/gds/fitness-checks-write-validation.js +1 -0
- package/scripts/gds/login.js +24 -3
- package/scripts/gds/ship-land.js +94 -3
- package/src/bongos/auth-admission.js +50 -5
- package/src/bongos/auth.js +2 -1
- package/src/bongos/routes/auth.js +15 -0
- package/src/branding.js +3 -2
- package/src/module-api.js +22 -1
- package/tests/access_requests_invite.mjs +165 -0
- package/tests/auth_admission_gate.mjs +42 -0
- package/tests/join_relay_outcome.mjs +61 -0
- package/tests/joinability_apply.mjs +72 -0
- package/tests/joinability_invite_only.mjs +192 -0
- package/tests/joinability_open.mjs +89 -0
- package/tests/module_api.mjs +1 -0
- package/tests/profile_nudge_prefs.mjs +97 -0
- package/tests/projects_hub.mjs +76 -2
- package/tests/projects_hub_pre_uat.mjs +4 -2
- package/tests/public_landing_projects.mjs +10 -3
- package/tests/ship_push_diagnosis.mjs +78 -0
- package/tests/sound_player.mjs +38 -0
|
@@ -10,11 +10,21 @@
|
|
|
10
10
|
// POST /api/bongos/access-requests PUBLIC (the one unauthenticated GDS
|
|
11
11
|
// write) — a stranger submits their
|
|
12
12
|
// GitHub username; a pending row lands
|
|
13
|
-
// in the queue.
|
|
13
|
+
// in the queue. Refused outright on an
|
|
14
|
+
// invite-only project, and on an open
|
|
15
|
+
// one (sign in — you're in already).
|
|
14
16
|
// GET /api/bongos/access-requests Archon-only — the pending queue (and
|
|
15
17
|
// ?status=invited|dismissed history).
|
|
16
18
|
// PATCH /api/bongos/access-requests/:id Archon-only — resolve a request:
|
|
17
19
|
// { status: 'invited' | 'dismissed' }.
|
|
20
|
+
// POST /api/bongos/access-requests/invite Archon-only — invite a GitHub
|
|
21
|
+
// username before any request exists:
|
|
22
|
+
// the way in on an invite-only project.
|
|
23
|
+
//
|
|
24
|
+
// Which door a project runs — open / apply / invite_only — is the owner's
|
|
25
|
+
// joinability setting, read through the doorway's joinabilityMode() (task
|
|
26
|
+
// 1003044, ADR 0194): the same reader the sign-in gate uses, so this route and
|
|
27
|
+
// the gate never disagree about whether a stranger may ask.
|
|
18
28
|
//
|
|
19
29
|
// The global IP rate-limiter (mounted in routes.js before every router) guards
|
|
20
30
|
// the public POST against spam. A partial unique index on lower(github_login)
|
|
@@ -30,6 +40,14 @@ const auth = { requireBuilder: api.requireBuilder, requirePermission: api.requir
|
|
|
30
40
|
const { pool } = api;
|
|
31
41
|
const { validateOrRespond, parseId } = api;
|
|
32
42
|
const approvalBroadcast = require('../approval-broadcast');
|
|
43
|
+
const log = api.logger('onboarding');
|
|
44
|
+
|
|
45
|
+
// The public refusal each door gives a would-be applicant — plain sentences,
|
|
46
|
+
// shown as written by the hall, the CLI, and the hub's join relay.
|
|
47
|
+
const DOOR_REFUSAL = {
|
|
48
|
+
invite_only: { code: 'invite_only', status: 403, message: 'This project is invite-only — it doesn’t take applications. If you’ve been invited, or already have an account, sign in with GitHub to enter.' },
|
|
49
|
+
open: { code: 'open_enrollment', status: 409, message: 'This project is open — no request needed. Sign in with GitHub and you’re in.' },
|
|
50
|
+
};
|
|
33
51
|
|
|
34
52
|
// GitHub usernames: 1–39 chars, alphanumeric or single hyphens, no leading/
|
|
35
53
|
// trailing hyphen. We accept a leading '@' and strip it (people paste "@name").
|
|
@@ -46,6 +64,12 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
46
64
|
// rank: public — the requester is by definition outside the system (no
|
|
47
65
|
// builder row yet); this is the one public write on the GDS.
|
|
48
66
|
router.post('/access-requests', async (req, res) => {
|
|
67
|
+
// The door answers first — before validation and before any lookup — so a
|
|
68
|
+
// refusal costs no query and reveals nothing about who already exists.
|
|
69
|
+
const refusal = DOOR_REFUSAL[api.joinabilityMode()];
|
|
70
|
+
if (refusal) {
|
|
71
|
+
return res.fail(refusal.code, { status: refusal.status, message: refusal.message });
|
|
72
|
+
}
|
|
49
73
|
if (validateOrRespond(req, res, {
|
|
50
74
|
github_login: { required: true, type: 'string', minLength: 1, maxLength: 60 },
|
|
51
75
|
display_name: { type: 'string', maxLength: 100 },
|
|
@@ -204,5 +228,67 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
204
228
|
}
|
|
205
229
|
});
|
|
206
230
|
|
|
231
|
+
// POST /access-requests/invite — an Archon admits a GitHub username BEFORE any
|
|
232
|
+
// request exists (task 1003044): the only way in on an invite-only project,
|
|
233
|
+
// where the public POST refuses, and a shortcut on an apply one. It writes the
|
|
234
|
+
// same access_request row the sign-in gate reads (status 'invited' —
|
|
235
|
+
// hasInvitedAccessRequest), so nothing new is consulted at sign-in; a pending
|
|
236
|
+
// request for that login is resolved in place rather than duplicated. Two
|
|
237
|
+
// Archons inviting the same login at once can each insert a row (the unique
|
|
238
|
+
// index covers pending only): harmless — the gate reads "any invited row" —
|
|
239
|
+
// and it shows as two lines in the invited history, so no lock is taken.
|
|
240
|
+
// body: { github_login }. 201 with a new row; 200 with the row when a pending
|
|
241
|
+
// request was resolved; 200 already_invited when an invite already stands;
|
|
242
|
+
// 409 already_member when they have an account.
|
|
243
|
+
// rank: archon — the same gate as resolving a request.
|
|
244
|
+
router.post('/access-requests/invite', auth.requireBuilder, auth.requirePermission('access_request.review'), async (req, res) => {
|
|
245
|
+
if (validateOrRespond(req, res, {
|
|
246
|
+
github_login: { required: true, type: 'string', minLength: 1, maxLength: 60 },
|
|
247
|
+
})) return;
|
|
248
|
+
const login = normalizeLogin(req.body.github_login);
|
|
249
|
+
if (!GITHUB_LOGIN_RE.test(login)) {
|
|
250
|
+
return res.fail('bad_github_login', { status: 400, message: 'That does not look like a GitHub username.' });
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const member = await pool.query(
|
|
254
|
+
`SELECT 1 FROM builders WHERE lower(github_login) = lower($1) LIMIT 1`,
|
|
255
|
+
[login]
|
|
256
|
+
);
|
|
257
|
+
if (member.rows.length > 0) {
|
|
258
|
+
return res.fail('already_member', { status: 409, message: 'They already have an account here — no invite needed.' });
|
|
259
|
+
}
|
|
260
|
+
const standing = await pool.query(
|
|
261
|
+
`SELECT 1 FROM access_requests
|
|
262
|
+
WHERE lower(github_login) = lower($1) AND status = 'invited' LIMIT 1`,
|
|
263
|
+
[login]
|
|
264
|
+
);
|
|
265
|
+
if (standing.rows.length > 0) {
|
|
266
|
+
return res.json({ ok: true, already_invited: true, github_login: login });
|
|
267
|
+
}
|
|
268
|
+
const resolved = await pool.query(
|
|
269
|
+
`UPDATE access_requests
|
|
270
|
+
SET status = 'invited',
|
|
271
|
+
resolved_by_builder_id = $1,
|
|
272
|
+
resolved_at = now()
|
|
273
|
+
WHERE lower(github_login) = lower($2) AND status = 'pending'
|
|
274
|
+
RETURNING id, github_login, status, resolved_at`,
|
|
275
|
+
[req.builder.id, login]
|
|
276
|
+
);
|
|
277
|
+
if (resolved.rows.length > 0) {
|
|
278
|
+
return res.json({ ok: true, request: resolved.rows[0] });
|
|
279
|
+
}
|
|
280
|
+
const { rows } = await pool.query(
|
|
281
|
+
`INSERT INTO access_requests (github_login, status, resolved_by_builder_id, resolved_at)
|
|
282
|
+
VALUES ($1, 'invited', $2, now())
|
|
283
|
+
RETURNING id, github_login, status, resolved_at`,
|
|
284
|
+
[login, req.builder.id]
|
|
285
|
+
);
|
|
286
|
+
return res.status(201).json({ ok: true, request: rows[0] });
|
|
287
|
+
} catch (err) {
|
|
288
|
+
log.error('[gds] POST /access-requests/invite', err);
|
|
289
|
+
return res.fail('invite_failed', { status: 500, message: 'internal error' });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
207
293
|
return router;
|
|
208
294
|
};
|
|
@@ -16,6 +16,34 @@ const pi = require('../platform-identity');
|
|
|
16
16
|
|
|
17
17
|
const { pool, requireBuilder, validateOrRespond, userAgent } = api;
|
|
18
18
|
|
|
19
|
+
// joinRelayOutcome — what the project's own admission surface answered, said in
|
|
20
|
+
// the HUB's words (task 1003044). The project decides; the hub relays and must
|
|
21
|
+
// never paint a refusal as "requested" — nor as "unreachable". A 2xx is a filed
|
|
22
|
+
// request; 409 already_member a standing membership; the door refusals (403
|
|
23
|
+
// invite_only, 409 open_enrollment) name the door; a 401 is the project's
|
|
24
|
+
// member-only visibility door (ADR 0192) refusing the ask itself. The project's
|
|
25
|
+
// own sentence is deliberately NOT relayed: a registered project is trusted to
|
|
26
|
+
// build, not to write prose into the platform's UI. Anything else is "not
|
|
27
|
+
// relayed" (null) → the caller's 502.
|
|
28
|
+
const RELAY_SENTENCE = {
|
|
29
|
+
requested: 'Requested access. The project’s owner admits new builders; once approved, sign in to enter.',
|
|
30
|
+
already_member: 'You already build on this project — sign in to it to see it here.',
|
|
31
|
+
invite_only: 'This project is invite-only — it doesn’t take requests. Ask its owner for an invite; once invited, sign in to it.',
|
|
32
|
+
open_enrollment: 'This project is open — no request needed. Sign in to it and you’re in.',
|
|
33
|
+
members_only: 'This project is only visible to its members right now, so it can’t take a request from here — ask its owner.',
|
|
34
|
+
};
|
|
35
|
+
function joinRelayOutcome(status, body) {
|
|
36
|
+
const err = (body && body.error) || null;
|
|
37
|
+
const code = err && (err.code || err);
|
|
38
|
+
let outcome;
|
|
39
|
+
if (status >= 200 && status < 300) outcome = 'requested';
|
|
40
|
+
else if (code === 'invite_only' || code === 'open_enrollment') outcome = code;
|
|
41
|
+
else if (status === 409) outcome = 'already_member';
|
|
42
|
+
else if (status === 401) outcome = 'members_only';
|
|
43
|
+
else return null;
|
|
44
|
+
return { status: outcome, message: RELAY_SENTENCE[outcome] };
|
|
45
|
+
}
|
|
46
|
+
|
|
19
47
|
module.exports = function myProjectsRoutes() {
|
|
20
48
|
const router = express.Router();
|
|
21
49
|
|
|
@@ -56,27 +84,24 @@ module.exports = function myProjectsRoutes() {
|
|
|
56
84
|
// Relay an access request to the project's OWN (public) admission endpoint —
|
|
57
85
|
// the project decides (open-join, or request/invite per ADR 0112). We post to
|
|
58
86
|
// the registry-trusted origin, never the caller's raw input.
|
|
59
|
-
let
|
|
60
|
-
let alreadyMember = false;
|
|
87
|
+
let outcome = null;
|
|
61
88
|
try {
|
|
62
89
|
const r = await fetch(`${client.origin}/api/gds/access-requests`, {
|
|
63
90
|
method: 'POST',
|
|
64
91
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
|
|
65
92
|
body: JSON.stringify({ github_login: req.builder.github_login, note: 'via platform My Projects' }),
|
|
66
93
|
});
|
|
67
|
-
|
|
68
|
-
|
|
94
|
+
const body = await r.json().catch(() => null);
|
|
95
|
+
outcome = joinRelayOutcome(r.status, body);
|
|
69
96
|
} catch (e) {
|
|
70
97
|
console.log('[platform-identity] join relay failed (non-fatal):', e.message);
|
|
71
98
|
}
|
|
72
|
-
if (!
|
|
99
|
+
if (!outcome) return res.fail('join_relay_failed', { status: 502, message: 'the project could not be reached to request access' });
|
|
73
100
|
res.json({
|
|
74
101
|
ok: true,
|
|
75
|
-
status:
|
|
102
|
+
status: outcome.status,
|
|
76
103
|
project: { name: client.name, origin: client.origin },
|
|
77
|
-
message:
|
|
78
|
-
? 'You already build on this project — sign in to it to see it here.'
|
|
79
|
-
: 'Requested access. The project’s owner admits new builders; once approved, sign in to enter.',
|
|
104
|
+
message: outcome.message,
|
|
80
105
|
});
|
|
81
106
|
} catch (err) {
|
|
82
107
|
console.error('[platform-identity] POST /my-projects/join', err);
|
|
@@ -138,3 +163,4 @@ module.exports = function myProjectsRoutes() {
|
|
|
138
163
|
|
|
139
164
|
return router;
|
|
140
165
|
};
|
|
166
|
+
module.exports.joinRelayOutcome = joinRelayOutcome;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.375",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.375",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.375",
|
|
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",
|
|
@@ -678,11 +678,19 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
678
678
|
</details>
|
|
679
679
|
</div>
|
|
680
680
|
|
|
681
|
-
<!--
|
|
682
|
-
|
|
681
|
+
<!-- live since task 1003044: how people join — the membership door. The
|
|
682
|
+
visibility section's two reads, key for key: the choice shown is
|
|
683
|
+
the platform's REQUEST (its settings row); what the project actually
|
|
684
|
+
RUNS is read back from its own public manifest (brand.project.
|
|
685
|
+
joinability) — never from the save's reply, which is a receipt. The
|
|
686
|
+
fold is a SIBLING of the volatile slot so no re-render can drop it. -->
|
|
683
687
|
<div class="card" id="mJoinability">
|
|
684
688
|
<h2 style="font-family:var(--font-display);font-size:19px;margin-bottom:12px;">How people join</h2>
|
|
685
|
-
<div
|
|
689
|
+
<div id="mJoin"><p class="finePrint">Reading the project’s settings…</p></div>
|
|
690
|
+
<details class="finePrint" id="mJoinWhat">
|
|
691
|
+
<summary>What each choice changes</summary>
|
|
692
|
+
<p><b>Anyone can join</b> — whoever signs in with GitHub becomes a member at once, at the newcomer rank. <b>Apply to join</b> — people ask from the project’s hall, you approve each one from the hall’s Watch page, and then they sign in. <b>Invite only</b> — no one can apply: the project’s hall and its sign-in say so, and you invite people by GitHub username from the hall’s Watch page; only an invited person’s sign-in gets in — and people who asked before you switched stay in your queue, to approve or dismiss. Whichever you pick, people who are already members stay members. Changing it restarts the project to pick it up — the line under the choice says what the project is running right now.</p>
|
|
693
|
+
</details>
|
|
686
694
|
</div>
|
|
687
695
|
|
|
688
696
|
<!-- live since task 1003042: the teardown control, for a project in ANY
|
|
@@ -4379,6 +4387,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4379
4387
|
/* same contract: the visibility section's in-flight line reads open_intent
|
|
4380
4388
|
only, so a queued settings push shows up on the tick without a reload */
|
|
4381
4389
|
renderVisState(inst);
|
|
4390
|
+
renderJoinState(inst);
|
|
4382
4391
|
/* the page names its project — in the heading and in the tab */
|
|
4383
4392
|
$('mTitle').textContent = inst.slug || 'Manage';
|
|
4384
4393
|
document.title = (inst.slug ? inst.slug + ' — ' : '') + BASE_TITLE;
|
|
@@ -4484,6 +4493,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4484
4493
|
/* the settings row + the project's own manifest — the same cadence as
|
|
4485
4494
|
the GitHub read: entry + tab return, never the in-flight tick */
|
|
4486
4495
|
if (!keepRepo) loadVisibility(inst);
|
|
4496
|
+
if (!keepRepo) loadJoinability(inst);
|
|
4487
4497
|
})
|
|
4488
4498
|
.catch(function (err) {
|
|
4489
4499
|
var m = String(err && err.message);
|
|
@@ -4805,6 +4815,162 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4805
4815
|
if (t && t.hasAttribute('data-vis-save')) doVisSave();
|
|
4806
4816
|
});
|
|
4807
4817
|
|
|
4818
|
+
/* ── how people join (task 1003044): the joinability toggle — the visibility
|
|
4819
|
+
section's shape, key for key: the CHOICE from the platform's settings row,
|
|
4820
|
+
what the project RUNS from its own manifest (brand.project.joinability),
|
|
4821
|
+
the save's push.message shown verbatim and never painted as the live
|
|
4822
|
+
state. visPushOpen() is shared: one settings push carries every key. ── */
|
|
4823
|
+
|
|
4824
|
+
var JOIN_OPTIONS = {
|
|
4825
|
+
open: { label: 'Anyone can join', blurb: 'Whoever signs in with GitHub becomes a member right away, at the newcomer rank.' },
|
|
4826
|
+
apply: { label: 'Apply to join', blurb: 'People ask to join from the project’s hall; you approve each one, then they sign in.' },
|
|
4827
|
+
invite_only: { label: 'Invite only', blurb: 'No one can apply. You invite people by GitHub username from the hall, and only an invited person can sign in.' }
|
|
4828
|
+
};
|
|
4829
|
+
var mJoinSettings = null; /* the platform row's last answer: { value, options } */
|
|
4830
|
+
var mJoinRunsHtml = ''; /* what the project's manifest last said it runs */
|
|
4831
|
+
var mJoinPushSeen = false; /* a settings push was open — re-read the manifest once it settles */
|
|
4832
|
+
|
|
4833
|
+
function joinMsg(html) { $('mJoin').innerHTML = html; }
|
|
4834
|
+
|
|
4835
|
+
function renderJoin() {
|
|
4836
|
+
var s = mJoinSettings;
|
|
4837
|
+
if (!s) return;
|
|
4838
|
+
var opts = (s.options || []).filter(function (v) { return !!JOIN_OPTIONS[v]; });
|
|
4839
|
+
joinMsg('<div role="radiogroup" aria-label="How people join">' + opts.map(function (v) {
|
|
4840
|
+
var o = JOIN_OPTIONS[v];
|
|
4841
|
+
return '<label class="visOpt"><input type="radio" name="mJoinPick" value="' + esc(v) + '"' +
|
|
4842
|
+
(v === s.value ? ' checked' : '') + '><span><b>' + esc(o.label) + '</b>' + esc(o.blurb) + '</span></label>';
|
|
4843
|
+
}).join('') + '</div>' +
|
|
4844
|
+
'<div class="actions"><button type="button" class="btn small" data-join-save>Save</button></div>' +
|
|
4845
|
+
'<div class="projMsg" id="mJoinMsg" role="status" aria-live="polite"></div>' +
|
|
4846
|
+
'<p class="finePrint" id="mJoinRuns">' + mJoinRunsHtml + '</p>');
|
|
4847
|
+
renderJoinState(manageInst);
|
|
4848
|
+
}
|
|
4849
|
+
|
|
4850
|
+
function joinRuns(html) {
|
|
4851
|
+
mJoinRunsHtml = html;
|
|
4852
|
+
var el = $('mJoinRuns');
|
|
4853
|
+
if (el) el.innerHTML = html;
|
|
4854
|
+
}
|
|
4855
|
+
|
|
4856
|
+
/* fetch-free, from open_intent only (renderManage calls it on every repaint) */
|
|
4857
|
+
function renderJoinState(inst) {
|
|
4858
|
+
if (!inst || !$('mJoinRuns')) return;
|
|
4859
|
+
if (visPushOpen(inst)) {
|
|
4860
|
+
mJoinPushSeen = true;
|
|
4861
|
+
joinRuns(inst.open_intent.state === 'running'
|
|
4862
|
+
? 'Restarting the project to apply a settings change…'
|
|
4863
|
+
: 'A settings change is queued — the project restarts to pick it up. Until then it keeps its current setting.');
|
|
4864
|
+
return;
|
|
4865
|
+
}
|
|
4866
|
+
if (mJoinPushSeen) { mJoinPushSeen = false; loadJoinRuns(inst); }
|
|
4867
|
+
}
|
|
4868
|
+
|
|
4869
|
+
/* what the project RUNS: its own public manifest, read cross-origin */
|
|
4870
|
+
function loadJoinRuns(inst) {
|
|
4871
|
+
if (!inst || inst.status !== 'active' || !inst.domain) { joinRuns(''); return; }
|
|
4872
|
+
var asked = manageId;
|
|
4873
|
+
joinRuns('Checking what the project is running…');
|
|
4874
|
+
var ctl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
4875
|
+
var timer = ctl ? setTimeout(function () { ctl.abort(); }, 8000) : null;
|
|
4876
|
+
fetch('https://' + inst.domain + '/api/gds/instance', { mode: 'cors', signal: ctl ? ctl.signal : undefined })
|
|
4877
|
+
.then(function (res) {
|
|
4878
|
+
if (!res.ok) throw new Error('http:' + res.status);
|
|
4879
|
+
return res.json();
|
|
4880
|
+
})
|
|
4881
|
+
.then(function (m) {
|
|
4882
|
+
if (asked !== manageId) return;
|
|
4883
|
+
var p = m && m.brand && m.brand.project;
|
|
4884
|
+
var v = p && p.joinability;
|
|
4885
|
+
if (!p || typeof p !== 'object') {
|
|
4886
|
+
joinRuns('This project’s server doesn’t report this setting yet — it runs an older platform version, so the setting can’t take effect until the project is updated.');
|
|
4887
|
+
return;
|
|
4888
|
+
}
|
|
4889
|
+
var o = JOIN_OPTIONS[v];
|
|
4890
|
+
if (!o) { joinRuns('This project’s server reports a setting this page doesn’t recognise (' + esc(String(v)) + ').'); return; }
|
|
4891
|
+
var want = mJoinSettings && mJoinSettings.value;
|
|
4892
|
+
joinRuns('Right now the project runs as <b>' + esc(o.label) + '</b>' +
|
|
4893
|
+
(want && want !== v && JOIN_OPTIONS[want] ? ' — the saved choice (' + esc(JOIN_OPTIONS[want].label) + ') hasn’t taken effect there yet.' : '.'));
|
|
4894
|
+
})
|
|
4895
|
+
.catch(function (err) {
|
|
4896
|
+
if (asked !== manageId) return;
|
|
4897
|
+
var http = /^http:(\d+)$/.exec(String(err && err.message));
|
|
4898
|
+
joinRuns(http
|
|
4899
|
+
? 'The project answered but didn’t share what it’s running (it replied ' + esc(http[1]) + ').'
|
|
4900
|
+
: 'Couldn’t reach the project to check what it’s running right now.');
|
|
4901
|
+
})
|
|
4902
|
+
.then(function () { if (timer) clearTimeout(timer); });
|
|
4903
|
+
}
|
|
4904
|
+
|
|
4905
|
+
function loadJoinability(inst) {
|
|
4906
|
+
mJoinSettings = null; mJoinRunsHtml = ''; mJoinPushSeen = false;
|
|
4907
|
+
joinMsg('<p class="finePrint">Reading the project’s settings…</p>');
|
|
4908
|
+
var asked = manageId; /* a stale answer must never describe a newer project */
|
|
4909
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
|
|
4910
|
+
.then(function (res) {
|
|
4911
|
+
if (asked !== manageId) throw new Error('stale');
|
|
4912
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
4913
|
+
if (res.ok) {
|
|
4914
|
+
mJoinSettings = {
|
|
4915
|
+
value: (data.settings || {}).joinability,
|
|
4916
|
+
options: (data.options || {}).joinability || Object.keys(JOIN_OPTIONS)
|
|
4917
|
+
};
|
|
4918
|
+
renderJoin();
|
|
4919
|
+
if (!visPushOpen(inst)) loadJoinRuns(inst);
|
|
4920
|
+
return;
|
|
4921
|
+
}
|
|
4922
|
+
joinMsg('<p class="finePrint">' + (res.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
|
|
4923
|
+
});
|
|
4924
|
+
})
|
|
4925
|
+
.catch(function (err) {
|
|
4926
|
+
if (String(err && err.message) === 'stale') return;
|
|
4927
|
+
joinMsg('<p class="finePrint">Couldn’t read this project’s settings right now — refresh in a moment.</p>');
|
|
4928
|
+
});
|
|
4929
|
+
}
|
|
4930
|
+
|
|
4931
|
+
/* the save sends the CHOICE and shows the platform's receipt verbatim */
|
|
4932
|
+
function doJoinSave() {
|
|
4933
|
+
if (!manageInst || !mJoinSettings) return;
|
|
4934
|
+
var picked = document.querySelector('#mJoin input[name="mJoinPick"]:checked');
|
|
4935
|
+
if (!picked) return;
|
|
4936
|
+
var value = picked.value;
|
|
4937
|
+
var box = $('mJoinMsg');
|
|
4938
|
+
var btn = document.querySelector('#mJoin [data-join-save]');
|
|
4939
|
+
if (btn) btn.disabled = true;
|
|
4940
|
+
box.className = 'projMsg'; box.textContent = 'Saving…';
|
|
4941
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', {
|
|
4942
|
+
method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
|
4943
|
+
body: JSON.stringify({ joinability: value })
|
|
4944
|
+
}).then(function (res) {
|
|
4945
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
4946
|
+
if (res.ok) {
|
|
4947
|
+
/* the row now holds the choice — the radio follows it; the "runs"
|
|
4948
|
+
line does NOT, until the project's own manifest says so */
|
|
4949
|
+
mJoinSettings.value = (data.saved || {}).joinability || value;
|
|
4950
|
+
if (data.push && data.push.queued) mJoinPushSeen = true;
|
|
4951
|
+
box.className = 'projMsg ok';
|
|
4952
|
+
box.textContent = (data.push && data.push.message) || 'Saved.';
|
|
4953
|
+
return;
|
|
4954
|
+
}
|
|
4955
|
+
var e = apiError(data, res.status);
|
|
4956
|
+
if (res.status === 401) { box.className = 'projMsg err'; box.innerHTML = signInAgain(); return; }
|
|
4957
|
+
box.className = e.code === 'intent_conflict' ? 'projMsg' : 'projMsg err';
|
|
4958
|
+
box.textContent = e.message || 'The change could not be saved — try again in a moment.';
|
|
4959
|
+
});
|
|
4960
|
+
}).catch(function () {
|
|
4961
|
+
box.className = 'projMsg err';
|
|
4962
|
+
box.textContent = 'Couldn’t reach the platform — check your connection and try again.';
|
|
4963
|
+
}).then(function () {
|
|
4964
|
+
if (btn) btn.disabled = false;
|
|
4965
|
+
loadManage(true, true);
|
|
4966
|
+
});
|
|
4967
|
+
}
|
|
4968
|
+
|
|
4969
|
+
$('mJoin').addEventListener('click', function (ev) {
|
|
4970
|
+
var t = ev.target.closest && ev.target.closest('button');
|
|
4971
|
+
if (t && t.hasAttribute('data-join-save')) doJoinSave();
|
|
4972
|
+
});
|
|
4973
|
+
|
|
4808
4974
|
/* ── delete the project (task 1003042): the manage page's teardown control.
|
|
4809
4975
|
It renders from status + open_intent only — no second request — so the
|
|
4810
4976
|
minute tick can repaint it and a queued teardown appears without a reload.
|
|
@@ -41,6 +41,7 @@ const WRITE_VALIDATION_EXEMPT = new Set([
|
|
|
41
41
|
'PATCH /me/model-allocation', // per-slot tiers — modelAllocPrefs.validatePatch
|
|
42
42
|
'PATCH /me/render', // { widgets: <bool>|null } — renderPrefs.validatePatch
|
|
43
43
|
'PATCH /me/display-name', // { display_name } — displayName.validate
|
|
44
|
+
'PATCH /me/profile-nudge', // { dismissed: <bool>|null } — profileNudgePrefs.validatePatch
|
|
44
45
|
]);
|
|
45
46
|
|
|
46
47
|
// KNOWN GAPS, NOT EXEMPTIONS (task 1002551 / BV1.R109b → tracked by task 1002552).
|
package/scripts/gds/login.js
CHANGED
|
@@ -189,11 +189,20 @@ async function main() {
|
|
|
189
189
|
}
|
|
190
190
|
const instanceName = man.data.name || base;
|
|
191
191
|
const admission = (man.data.admission && man.data.admission.policy) || 'invite-only';
|
|
192
|
+
// The project's door (task 1003044): brand.project.joinability names which of
|
|
193
|
+
// open / apply / invite_only it runs; an older core reports none, and then the
|
|
194
|
+
// binary admission.policy above is all there is to say.
|
|
195
|
+
// admission.policy 'open' wins over a pushed 'apply': a self-hosted instance on
|
|
196
|
+
// the legacy open-enrolment flag reports the pack's default while its door is open.
|
|
197
|
+
const joinability = admission === 'open' ? 'open'
|
|
198
|
+
: (man.data.brand && man.data.brand.project && man.data.brand.project.joinability) || null;
|
|
199
|
+
const DOOR_WORD = { open: '', apply: ' (apply to join)', invite_only: ' (invite-only)' };
|
|
200
|
+
const doorWord = joinability in DOOR_WORD ? DOOR_WORD[joinability] : (admission === 'invite-only' ? ' (invite-only)' : '');
|
|
192
201
|
// Federation (ADR 0141 §1): a hosted instance advertises auth.idp = { origin,
|
|
193
202
|
// client_id } — sign-in then runs through the Cloud Bongos hub, not a local
|
|
194
203
|
// GitHub app. null → the direct-GitHub path, unchanged.
|
|
195
204
|
const idp = (man.data.auth && man.data.auth.idp) || null;
|
|
196
|
-
log(` Signing in to ${instanceName}${
|
|
205
|
+
log(` Signing in to ${instanceName}${doorWord}${idp ? ' — via Cloud Bongos platform sign-in' : ''}`);
|
|
197
206
|
|
|
198
207
|
// 2. Device flow.
|
|
199
208
|
let flow = await runDeviceFlow(base, { log, idp });
|
|
@@ -202,6 +211,12 @@ async function main() {
|
|
|
202
211
|
// then finish sign-in with a fresh device flow (the first code is spent).
|
|
203
212
|
if (flow.state === 'not_approved') {
|
|
204
213
|
log('');
|
|
214
|
+
// An invite-only project takes no applications — say so instead of
|
|
215
|
+
// collecting a username for a request the project would refuse.
|
|
216
|
+
if (joinability === 'invite_only') {
|
|
217
|
+
console.error(`✖ ${instanceName} is invite-only — it doesn’t take applications. Ask its owner for an invite, then re-run \`bongos login ${base}\`.`);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
205
220
|
log(` You're not admitted to ${instanceName} yet.`);
|
|
206
221
|
const ghLogin = (await prompt(' Your GitHub username: ')).replace(/^@/, '');
|
|
207
222
|
if (!ghLogin) {
|
|
@@ -212,12 +227,18 @@ async function main() {
|
|
|
212
227
|
method: 'POST',
|
|
213
228
|
body: JSON.stringify({ github_login: ghLogin, note: 'via bongos login' }),
|
|
214
229
|
});
|
|
215
|
-
|
|
230
|
+
// The refusal envelope is { error: { code, message } } (ADR 0116); an older
|
|
231
|
+
// instance answered a flat { error: 'code' } — read both.
|
|
232
|
+
const reqErr = (reqRes.data && reqRes.data.error) || null;
|
|
233
|
+
const reqCode = reqErr && (reqErr.code || reqErr);
|
|
234
|
+
const reqMessage = reqErr && reqErr.message;
|
|
235
|
+
if (reqRes.status === 409 && reqCode === 'already_member') {
|
|
216
236
|
log(' You already have an account — retrying sign-in…');
|
|
217
237
|
} else if (reqRes.ok || reqRes.status === 200 || reqRes.status === 201) {
|
|
218
238
|
log(` ✓ Requested access to ${instanceName}. An Archon approves you with a 👍 in Discord (#member-approvals).`);
|
|
219
239
|
} else {
|
|
220
|
-
|
|
240
|
+
// the project's own sentence when it gave one (an invite-only or open door)
|
|
241
|
+
console.error(reqMessage ? `✖ ${reqMessage}` : `✖ could not file an access request (HTTP ${reqRes.status}).`);
|
|
221
242
|
process.exit(1);
|
|
222
243
|
}
|
|
223
244
|
log(' Waiting for approval (safe to leave running)…');
|
package/scripts/gds/ship-land.js
CHANGED
|
@@ -406,6 +406,80 @@ function rerunGateReviewIfStale(branch, headSha) {
|
|
|
406
406
|
return ghArgvOk(['run', 'rerun', String(id)]);
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
// ---------- why the push failed, and whether retrying can ever help ----------
|
|
410
|
+
//
|
|
411
|
+
// A failed `git push` already parks the task at `confirmed` and opens no PR,
|
|
412
|
+
// which is correct. What was wrong (task 1003293) was the ADVICE: "Fix the push
|
|
413
|
+
// and re-ship" is useless for the most common cause, because re-shipping fails
|
|
414
|
+
// identically every time and the reconciler cannot heal a branch that was never
|
|
415
|
+
// pushed. The task then sits at `confirmed` with its code on one laptop, looking
|
|
416
|
+
// like an ordinary pending land. That happened twice in one session — both times
|
|
417
|
+
// on a commit touching .github/workflows/, both times invisible until someone
|
|
418
|
+
// checked git instead of the log.
|
|
419
|
+
//
|
|
420
|
+
// So the distinction that matters is TERMINAL vs transient. A terminal failure
|
|
421
|
+
// must name the actual remedy; a transient one may honestly say "re-ship".
|
|
422
|
+
// `argvOk` runs the push with stdio:'inherit', so git's own message reaches the
|
|
423
|
+
// log but nothing ever reads it — these patterns read it.
|
|
424
|
+
const PUSH_DIAGNOSES = [
|
|
425
|
+
{
|
|
426
|
+
id: 'workflow_scope',
|
|
427
|
+
// GitHub's wording: "refusing to allow an OAuth App to create or update
|
|
428
|
+
// workflow `.github/workflows/x.yml` without `workflow` scope".
|
|
429
|
+
match: /without .{0,3}workflow.{0,3} scope|refusing to allow (?:an?|the) \S+ (?:App|integration)/i,
|
|
430
|
+
terminal: true,
|
|
431
|
+
why: 'this branch changes a file under .github/workflows/, and the credential git used has no `workflow` scope — GitHub refuses that push regardless of how often it is retried',
|
|
432
|
+
fix: 'push over SSH once, then re-run the ship:\n git push git@github.com:<owner>/<repo>.git <branch>\n (or re-authenticate the CLI with the `workflow` scope: gh auth refresh -h github.com -s workflow)',
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
id: 'protected_branch',
|
|
436
|
+
match: /GH006|protected branch|required status check/i,
|
|
437
|
+
terminal: true,
|
|
438
|
+
why: 'branch protection refused the push outright',
|
|
439
|
+
fix: 'land through a pull request instead of pushing directly, or have an admin adjust the rule',
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
id: 'auth',
|
|
443
|
+
match: /Authentication failed|could not read Username|Permission denied \(publickey\)|remote: Invalid username or password|403 Forbidden/i,
|
|
444
|
+
terminal: true,
|
|
445
|
+
why: 'git could not authenticate to the remote at all',
|
|
446
|
+
fix: 'restore the credential (gh auth login, or an SSH key the remote accepts), then re-run the ship',
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
id: 'stale_branch',
|
|
450
|
+
match: /non-fast-forward|fetch first|behind its remote counterpart|Updates were rejected/i,
|
|
451
|
+
terminal: false,
|
|
452
|
+
why: 'the remote branch has commits this checkout does not',
|
|
453
|
+
fix: 'git fetch && git rebase origin/<branch>, then re-run the ship',
|
|
454
|
+
},
|
|
455
|
+
];
|
|
456
|
+
|
|
457
|
+
// Pure: classify a failed push from git's stderr. Exported + tested.
|
|
458
|
+
// An unrecognised failure is reported as NON-terminal on purpose — "try again"
|
|
459
|
+
// is the safe default when we do not know, and a wrong "this can never work"
|
|
460
|
+
// would send someone hunting a problem that does not exist.
|
|
461
|
+
function pushDiagnosis(stderr) {
|
|
462
|
+
const text = String(stderr == null ? '' : stderr);
|
|
463
|
+
for (const d of PUSH_DIAGNOSES) {
|
|
464
|
+
if (d.match.test(text)) return { id: d.id, terminal: d.terminal, why: d.why, fix: d.fix };
|
|
465
|
+
}
|
|
466
|
+
return { id: 'unknown', terminal: false, why: 'the push failed for a reason this lane does not recognise', fix: 'read git\'s message above, resolve it, then re-run the ship' };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Push, capturing stderr so it can be diagnosed — and echoing it so the log still
|
|
470
|
+
// shows exactly what git said, which stdio:'inherit' gave us for free.
|
|
471
|
+
function pushCapturing(branch, deps = {}) {
|
|
472
|
+
const exec = deps.execFileSync || require('node:child_process').execFileSync;
|
|
473
|
+
try {
|
|
474
|
+
exec('git', ['push', 'origin', branch], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
475
|
+
return { ok: true, stderr: '' };
|
|
476
|
+
} catch (e) {
|
|
477
|
+
const stderr = [e && e.stderr, e && e.stdout].filter(Boolean).join('\n');
|
|
478
|
+
if (stderr) process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`);
|
|
479
|
+
return { ok: false, stderr };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
409
483
|
// ciLandLocal — today's local path: this machine pushes the branch + opens the PR
|
|
410
484
|
// + enables auto-merge via git + gh. async since task 1169 — it asks the server to
|
|
411
485
|
// post the gate-author-trust attestation between the push and the PR open.
|
|
@@ -413,9 +487,24 @@ async function ciLandLocal(taskId, branch, valueSummary) {
|
|
|
413
487
|
const headSha = gitOk('git rev-parse HEAD');
|
|
414
488
|
|
|
415
489
|
console.log(` pushing ${branch} to origin...`);
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
490
|
+
const pushed = pushCapturing(branch);
|
|
491
|
+
if (!pushed.ok) {
|
|
492
|
+
const d = pushDiagnosis(pushed.stderr);
|
|
493
|
+
console.log(` ci-land: push FAILED — ${d.why}.`);
|
|
494
|
+
console.log(` ci-land: nothing was pushed and no PR exists; task stays at confirmed.`);
|
|
495
|
+
if (d.terminal) {
|
|
496
|
+
// The important line. Re-shipping cannot fix this, and the reconciler
|
|
497
|
+
// cannot heal a branch that never reached the remote — so do not say
|
|
498
|
+
// "re-ship" and leave the work stranded on one machine.
|
|
499
|
+
console.log(' ci-land: RE-SHIPPING WILL FAIL THE SAME WAY. Do this instead:');
|
|
500
|
+
console.log(` ${d.fix}`);
|
|
501
|
+
} else {
|
|
502
|
+
console.log(` ci-land: to retry — ${d.fix}`);
|
|
503
|
+
}
|
|
504
|
+
return landBailed(
|
|
505
|
+
`could not push ${branch} to origin (${d.id})`,
|
|
506
|
+
d.terminal ? d.fix.split('\n')[0] : `node scripts/gds/ship.js ${taskId}`,
|
|
507
|
+
);
|
|
419
508
|
}
|
|
420
509
|
|
|
421
510
|
// task 1169 (ADR 0058): attest the author's trust tier on the head SHA BEFORE
|
|
@@ -545,6 +634,8 @@ function pollCiDeploy(taskId, branch, headSha) {
|
|
|
545
634
|
|
|
546
635
|
module.exports = {
|
|
547
636
|
localHistoryRewritten, // task 1003221 — the append-only discriminator
|
|
637
|
+
pushDiagnosis, // task 1003293 — a terminal push failure must not advise a retry
|
|
638
|
+
PUSH_DIAGNOSES,
|
|
548
639
|
ciLand,
|
|
549
640
|
classifyLandOutcome,
|
|
550
641
|
formatPublishFailure,
|