@bongos/core 1.20.20 → 1.20.22
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 +80 -50
- 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 +11 -4
- package/clients/bongos-client/index.mjs +6 -2
- package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +1 -0
- package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +70 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +179 -73
- package/docs/api-reference.md +8 -6
- package/docs/copy-inventory.md +196 -196
- package/docs/copy-registry.json +250 -250
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +310 -306
- package/modules/hall-ui/public/watch.js +23 -0
- package/modules/onboarding/routes/access-requests.js +36 -6
- package/modules/platform-identity/guild-map.js +105 -0
- package/modules/platform-identity/invite-notices.js +61 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +12 -18
- package/modules/platform-identity/routes/guilds-public.js +36 -0
- package/modules/platform-identity/routes/my-projects.js +6 -15
- package/modules/platform-identity/routes/projects.js +22 -15
- package/modules/platform-identity/routes/sso-invites.js +82 -0
- package/modules/platform-identity/tests/platform-identity.mjs +14 -11
- package/modules/public-landing/public/assets/cosmos.css +5 -0
- package/modules/public-landing/public/projects.html +35 -29
- package/modules/public-landing/public/projects.states.json +1 -0
- package/modules/ui-design/kit/serve.js +9 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/bongos/auth-admission.js +40 -0
- package/src/bongos/auth.js +2 -1
- package/src/module-api.js +8 -1
- package/tests/access_requests_invite.mjs +39 -1
- package/tests/application_lifecycle.mjs +41 -1
- package/tests/catalog_only_client.mjs +5 -3
- package/tests/guild_map_db.mjs +157 -0
- package/tests/guilds_api.mjs +74 -0
- package/tests/hub_invite_notices.mjs +261 -0
- package/tests/module_api.mjs +1 -0
- package/tests/my_projects_surface.mjs +11 -1
- package/tests/project_invite_ownership.mjs +12 -7
- package/tests/project_invite_ui.mjs +42 -45
- package/tests/watch_applications_queue.mjs +33 -2
|
@@ -19,8 +19,10 @@
|
|
|
19
19
|
// before that an invite could only ever 404
|
|
20
20
|
// • an invite sends exactly { client_id, github_login } — the slug IS the
|
|
21
21
|
// client_id — on the cookie session, and nothing else
|
|
22
|
-
// • the
|
|
23
|
-
//
|
|
22
|
+
// • the answer is a HAND-OFF (task 1002818, ADR 0353): the route writes nothing
|
|
23
|
+
// and answers `{ invited: false, invite_url }` — the project's hall invite link — and
|
|
24
|
+
// each name becomes a line whose link finishes the invite there — never a
|
|
25
|
+
// window opened for the owner, and never a link that is not http(s)
|
|
24
26
|
// • every refusal the route can give is said in the route's own vocabulary,
|
|
25
27
|
// and the 403 is said CAREFULLY: it is byte-identical whether or not the
|
|
26
28
|
// project exists (ADR 0248), so the page may not read it as "it is there"
|
|
@@ -57,8 +59,14 @@ for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function ski
|
|
|
57
59
|
// Narrow on purpose (the project_modules_ui.mjs rule): it supports exactly what
|
|
58
60
|
// the region uses, so it stays honest about what it can prove.
|
|
59
61
|
|
|
60
|
-
const ROW = /<li><b>([^<]*)<\/b><small>([^<]*)<\/small><\/li>/g;
|
|
61
|
-
const parseRows = (html) => [...String(html).matchAll(ROW)].map((m) => ({ login: m[1], said: m[2] }));
|
|
62
|
+
const ROW = /<li><b>([^<]*)<\/b><small>([^<]*)<\/small><a class="btn small" href="([^"]*)" target="_blank" rel="noopener">Send the invite →<\/a><\/li>/g;
|
|
63
|
+
const parseRows = (html) => [...String(html).matchAll(ROW)].map((m) => ({ login: m[1], said: m[2], url: m[3] }));
|
|
64
|
+
|
|
65
|
+
// The route's answer to an admitted caller since task 1002818: where to send the invite.
|
|
66
|
+
const handoff = (login, origin = 'https://mercury.cloudbongos.test') => ({
|
|
67
|
+
ok: true, status: 200,
|
|
68
|
+
body: { invited: false, invite_url: origin + '/builders/watch?invite=' + encodeURIComponent(login) },
|
|
69
|
+
});
|
|
62
70
|
|
|
63
71
|
// A suggestion row: whatever the row NAMES, plus the login its button carries.
|
|
64
72
|
// Parsed off the button so the test reads the address the click will actually
|
|
@@ -113,7 +121,8 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
|
113
121
|
const queued = answers.slice();
|
|
114
122
|
const fetch = (url, init = {}) => {
|
|
115
123
|
requests.push({ url, method: init.method || 'GET', body: init.body, credentials: init.credentials, headers: init.headers });
|
|
116
|
-
const
|
|
124
|
+
const sent = (() => { try { return JSON.parse(init.body).github_login; } catch { return ''; } })();
|
|
125
|
+
const a = queued.shift() || handoff(sent);
|
|
117
126
|
if (a.reject) return Promise.reject(new Error('offline'));
|
|
118
127
|
return Promise.resolve({ ok: a.ok, status: a.status, _json: a.body, json: () => Promise.resolve(a.body) });
|
|
119
128
|
};
|
|
@@ -127,7 +136,12 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
|
127
136
|
apiError: (data, status) => ({
|
|
128
137
|
code: (data && data.error && data.error.code) || ('http_' + status),
|
|
129
138
|
message: (data && data.error && data.error.message) || '',
|
|
139
|
+
details: (data && data.error && data.error.details) || {},
|
|
130
140
|
}),
|
|
141
|
+
safeHttpUrl: (raw) => {
|
|
142
|
+
try { const u = new URL(String(raw)); return (u.protocol === 'https:' || u.protocol === 'http:') ? u.href : ''; } catch (e) { return ''; }
|
|
143
|
+
},
|
|
144
|
+
URL,
|
|
131
145
|
signInAgain: () => 'Your session expired — <a href="/in">sign in again</a>.',
|
|
132
146
|
};
|
|
133
147
|
sandbox.globalThis = sandbox;
|
|
@@ -238,7 +252,7 @@ test('an invite carries exactly { client_id, github_login } — the slug IS the
|
|
|
238
252
|
'exactly the two fields the route validates, and nothing else');
|
|
239
253
|
});
|
|
240
254
|
|
|
241
|
-
test('the
|
|
255
|
+
test('the answer is a hand-off line: the name, "not sent yet", and the hall link that sends it', async () => {
|
|
242
256
|
const ui = boot();
|
|
243
257
|
await ui.open('mercury');
|
|
244
258
|
|
|
@@ -246,22 +260,29 @@ test('the pending line is drawn from the 201 ROW, and the github_id on it is nev
|
|
|
246
260
|
await ui.send();
|
|
247
261
|
|
|
248
262
|
assert.deepEqual(ui.rows().map((r) => r.login), ['octo-builder']);
|
|
249
|
-
assert.match(ui.rows()[0].said, /
|
|
250
|
-
assert.equal(ui.
|
|
251
|
-
|
|
252
|
-
|
|
263
|
+
assert.match(ui.rows()[0].said, /not sent yet/i, 'the line may not claim an invite the hall has not sent');
|
|
264
|
+
assert.equal(ui.rows()[0].url, 'https://mercury.cloudbongos.test/builders/watch?invite=octo-builder',
|
|
265
|
+
'the link is the route\'s own invite_url, verbatim');
|
|
266
|
+
assert.equal(ui.msg().className, 'projMsg ok', 'being handed to the hall is the answer, not a failure');
|
|
267
|
+
assert.match(ui.msg().textContent, /hall/i);
|
|
253
268
|
});
|
|
254
269
|
|
|
255
|
-
test('a
|
|
256
|
-
|
|
257
|
-
// different kind, an owner sees that rather than a comfortable fiction.
|
|
258
|
-
const ui = boot({ answers: [{ ok: true, status: 201, body: { invited: true, row: { id: 7, client_id: 'mercury', github_id: 1, membership_kind: 'member' } } }] });
|
|
270
|
+
test('a hand-off link that is not http(s) is never drawn — it is said as a failure instead', async () => {
|
|
271
|
+
const ui = boot({ answers: [{ ok: true, status: 200, body: { invited: false, invite_url: 'javascript:alert(1)' } }] });
|
|
259
272
|
await ui.open('mercury');
|
|
260
273
|
|
|
261
274
|
await ui.type('octo-builder');
|
|
262
275
|
await ui.send();
|
|
263
276
|
|
|
264
|
-
assert.equal(ui.rows()
|
|
277
|
+
assert.equal(ui.rows().length, 0, 'no line carries a link that is not a web address');
|
|
278
|
+
assert.doesNotMatch(ui.sandbox.$('wizInviteList').innerHTML, /javascript:/);
|
|
279
|
+
assert.equal(ui.msg().className, 'projMsg err');
|
|
280
|
+
assert.match(ui.msg().textContent, /couldn’t be sent/);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('the region never opens a window for the owner — the link is theirs to click', () => {
|
|
284
|
+
assert.doesNotMatch(REGION, /window\.open|location\.(?:href|assign|replace)\s*[=(]/,
|
|
285
|
+
'an open fired from a network answer is blocked, and a surprise navigation loses the wizard');
|
|
265
286
|
});
|
|
266
287
|
|
|
267
288
|
test('a name is trimmed, and an empty one is a question rather than a request', async () => {
|
|
@@ -299,12 +320,7 @@ test('Enter in the field sends the invite too', async () => {
|
|
|
299
320
|
});
|
|
300
321
|
|
|
301
322
|
test('two invites in a row both stand, newest last', async () => {
|
|
302
|
-
const ui = boot({
|
|
303
|
-
answers: [
|
|
304
|
-
{ ok: true, status: 201, body: { invited: true, row: { id: 7, client_id: 'mercury', github_id: 1, membership_kind: 'pending' } } },
|
|
305
|
-
{ ok: true, status: 201, body: { invited: true, row: { id: 8, client_id: 'mercury', github_id: 2, membership_kind: 'pending' } } },
|
|
306
|
-
],
|
|
307
|
-
});
|
|
323
|
+
const ui = boot({ answers: [handoff('octo-builder'), handoff('second-builder')] });
|
|
308
324
|
await ui.open('mercury');
|
|
309
325
|
|
|
310
326
|
await ui.type('octo-builder');
|
|
@@ -317,29 +333,11 @@ test('two invites in a row both stand, newest last', async () => {
|
|
|
317
333
|
|
|
318
334
|
// ── every refusal the route can give ─────────────────────────────────────────
|
|
319
335
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
await ui.send();
|
|
326
|
-
|
|
327
|
-
assert.equal(ui.msg().className, 'projMsg err');
|
|
328
|
-
assert.match(ui.msg().textContent, /hasn’t signed in here yet/i);
|
|
329
|
-
assert.equal(ui.box().hidden, false, 'the step stays — a failed invite is re-tryable');
|
|
330
|
-
assert.equal(ui.rows().length, 0, 'and nothing is drawn as pending that is not');
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
test('someone already on the project is said plainly, not as a failure', async () => {
|
|
334
|
-
const ui = boot({ answers: [{ ok: true, status: 200, body: { invited: false, alreadyMember: true } }] });
|
|
335
|
-
await ui.open('mercury');
|
|
336
|
-
|
|
337
|
-
await ui.type('octo-builder');
|
|
338
|
-
await ui.send();
|
|
339
|
-
|
|
340
|
-
assert.equal(ui.msg().className, 'projMsg ok');
|
|
341
|
-
assert.match(ui.msg().textContent, /already on this project/i);
|
|
342
|
-
assert.equal(ui.rows().length, 0, 'an existing member is not a pending invite');
|
|
336
|
+
// Task 1002818 retired two answers with the hub row they described: `account_not_found`
|
|
337
|
+
// (the hall invites any GitHub login, account or not) and 200 `alreadyMember` (the hall's
|
|
338
|
+
// own invite route answers that, where the membership actually lives).
|
|
339
|
+
test('the refusals the retired write used to give are gone from the page too', () => {
|
|
340
|
+
assert.doesNotMatch(REGION, /account_not_found|alreadyMember/);
|
|
343
341
|
});
|
|
344
342
|
|
|
345
343
|
test('a project the map has not caught up with says exactly that, and stays re-tryable', async () => {
|
|
@@ -407,7 +405,6 @@ test('an unknown refusal falls through to the server’s own words rather than a
|
|
|
407
405
|
|
|
408
406
|
test('inviteRefusal is a pure function over the route’s own vocabulary', () => {
|
|
409
407
|
const f = boot().sandbox.inviteRefusal;
|
|
410
|
-
assert.match(f('account_not_found', 404, ''), /signed in here yet/i);
|
|
411
408
|
assert.match(f('unknown_project', 404, ''), /map yet/i);
|
|
412
409
|
assert.match(f('permission_forbidden', 403, ''), /can’t invite/i);
|
|
413
410
|
assert.equal(f('whatever', 500, 'the server said this'), 'the server said this');
|
|
@@ -94,6 +94,7 @@ async function boot(opts = {}) {
|
|
|
94
94
|
rank = 'archon',
|
|
95
95
|
queueStatus = null, // non-200 for the queue read (403 seals it)
|
|
96
96
|
patchResult = null, // a scripted answer for PATCH /access-requests/:id
|
|
97
|
+
search = '', // the page's query string (?invite=<login>, task 1002818)
|
|
97
98
|
} = opts;
|
|
98
99
|
|
|
99
100
|
const requests = [];
|
|
@@ -136,7 +137,7 @@ async function boot(opts = {}) {
|
|
|
136
137
|
// the ones shipped rather than ones this test invented.
|
|
137
138
|
parseInto(documentObj.body, PAGE);
|
|
138
139
|
|
|
139
|
-
const locationObj = { hostname: 'builders.example.test', pathname: '/builders/watch', search
|
|
140
|
+
const locationObj = { hostname: 'builders.example.test', pathname: '/builders/watch', search, hash: '', assign() {} };
|
|
140
141
|
|
|
141
142
|
const kit = {
|
|
142
143
|
makeTabs: () => ({}),
|
|
@@ -187,7 +188,7 @@ async function boot(opts = {}) {
|
|
|
187
188
|
console: { log() {}, warn() {}, error() {} },
|
|
188
189
|
setTimeout, clearTimeout, Promise, Date, Math, JSON, Error, isNaN,
|
|
189
190
|
Number, String, Object, Array, Boolean, Set, Map, RegExp,
|
|
190
|
-
encodeURIComponent, decodeURIComponent, parseInt, parseFloat,
|
|
191
|
+
encodeURIComponent, decodeURIComponent, parseInt, parseFloat, URLSearchParams,
|
|
191
192
|
};
|
|
192
193
|
sandbox.globalThis = sandbox;
|
|
193
194
|
vm.createContext(sandbox);
|
|
@@ -491,4 +492,34 @@ await test('the profile changes what the row SAYS, never what it offers (ADR 001
|
|
|
491
492
|
'a rich record is not a reason to admit and an empty one is not a reason to refuse — the decisions must be identical');
|
|
492
493
|
});
|
|
493
494
|
|
|
495
|
+
// ── ?invite=<login>: the platform hub hands an owner here (task 1002818, ADR 0353) ──
|
|
496
|
+
// The hub's invite widget cannot admit anyone, so it links an owner to THIS box with the
|
|
497
|
+
// name filled in. Filling is all a link may do: the send stays the owner's own click.
|
|
498
|
+
|
|
499
|
+
await test('?invite= fills the invite box — and sends nothing on its own', async () => {
|
|
500
|
+
const h = await boot({ search: '?invite=octo-builder' });
|
|
501
|
+
const box = h.documentObj.getElementById('gate-invite-login');
|
|
502
|
+
assert.ok(box, 'the invite box rendered');
|
|
503
|
+
assert.equal(box.value, 'octo-builder');
|
|
504
|
+
assert.equal(h.requests.filter((r) => r.method === 'POST').length, 0,
|
|
505
|
+
'a link anyone can craft must never be the act that admits');
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
await test('?invite= is spent on first use — a re-render does not put the name back', async () => {
|
|
509
|
+
const h = await boot({ search: '?invite=octo-builder' });
|
|
510
|
+
const box = h.documentObj.getElementById('gate-invite-login');
|
|
511
|
+
box.value = '';
|
|
512
|
+
await h.switchView('invited'); // the gate re-renders, invite box and all
|
|
513
|
+
assert.equal(h.documentObj.getElementById('gate-invite-login').value, '');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
await test('?invite= that is not a GitHub username fills nothing', async () => {
|
|
517
|
+
for (const bad of ['?invite=%3Cscript%3E', '?invite=a%20b', '?invite=-lead', '?invite=' + 'x'.repeat(40)]) {
|
|
518
|
+
const h = await boot({ search: bad });
|
|
519
|
+
assert.equal(h.documentObj.getElementById('gate-invite-login').value || '', '', bad);
|
|
520
|
+
}
|
|
521
|
+
const at = await boot({ search: '?invite=%40octo' });
|
|
522
|
+
assert.equal(at.documentObj.getElementById('gate-invite-login').value, 'octo', 'a pasted @ is dropped, as the form itself does');
|
|
523
|
+
});
|
|
524
|
+
|
|
494
525
|
summary();
|