@bongos/core 1.19.561 → 1.19.563

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.bongos-core.json +63 -33
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0251-a-suggestion-of-who-to-invite-is-a-recruiting-surface-and-owes-the-opt-out.md +79 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +69 -3
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +127 -127
  12. package/docs/copy-registry.json +162 -162
  13. package/docs/module-api-changelog.md +4 -0
  14. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  15. package/modules/lifecycle/db-versions.js +22 -0
  16. package/modules/lifecycle/db.js +2 -1
  17. package/modules/lifecycle/routes/goal-route-authz.js +58 -0
  18. package/modules/lifecycle/routes/goals.js +8 -3
  19. package/modules/lifecycle/routes/version-route-authz.js +56 -0
  20. package/modules/lifecycle/routes/versions.js +22 -0
  21. package/modules/platform-identity/invite-suggestions.js +57 -0
  22. package/modules/platform-identity/routes/sso.js +8 -0
  23. package/modules/provisioning/routes/provisioning.js +56 -0
  24. package/modules/public-landing/public/assets/cosmos.css +16 -0
  25. package/modules/public-landing/public/projects.html +85 -3
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/src/module-api.js +1 -1
  29. package/tests/goal_create_version_gate.mjs +136 -0
  30. package/tests/one_planning_version.mjs +161 -0
  31. package/tests/project_invite_suggestions.mjs +133 -0
  32. package/tests/project_invite_ui.mjs +171 -18
@@ -48,7 +48,8 @@ const END = SCRIPT.indexOf('/* ── boot: who am I');
48
48
  assert.ok(START > 0, 'the invite region must start at INVITE_MOUNTS');
49
49
  assert.ok(END > START, 'the invite region must end before the boot block');
50
50
  const REGION = SCRIPT.slice(START, END);
51
- for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function skipInvite(', 'function inviteRefusal(']) {
51
+ for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function skipInvite(', 'function inviteRefusal(',
52
+ 'function setInviteSuggestions(', 'function renderInviteSugg(', 'function loadInviteSuggestions(']) {
52
53
  assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
53
54
  }
54
55
 
@@ -59,24 +60,43 @@ for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function ski
59
60
  const ROW = /<li><b>([^<]*)<\/b><small>([^<]*)<\/small><\/li>/g;
60
61
  const parseRows = (html) => [...String(html).matchAll(ROW)].map((m) => ({ login: m[1], said: m[2] }));
61
62
 
63
+ // A suggestion row: whatever the row NAMES, plus the login its button carries.
64
+ // Parsed off the button so the test reads the address the click will actually
65
+ // send, not the label a row happened to render.
66
+ const SUGG = /<li><span class="suggWho">(.*?)<\/span><button [^>]*data-invite-pick="([^"]*)"[^>]*>/g;
67
+ const parseSugg = (html) => [...String(html).matchAll(SUGG)].map((m) => ({ named: m[1], login: m[2] }));
68
+
62
69
  function boot({ mount = 'wizInvite', answers = [] } = {}) {
63
70
  const requests = [];
64
71
  const els = new Map();
65
72
  const listeners = new Map(); // "elId:type" → [fn]
66
73
  const focused = [];
67
74
 
68
- const makeEl = (id) => ({
69
- _id: id, _html: '', textContent: '', className: '', value: '', hidden: false,
70
- set innerHTML(v) { this._html = String(v); },
71
- get innerHTML() { return this._html; },
72
- addEventListener(type, fn) {
73
- const key = id + ':' + type;
74
- if (!listeners.has(key)) listeners.set(key, []);
75
- listeners.get(key).push(fn);
76
- },
77
- setAttribute() {},
78
- focus() { focused.push(id); },
79
- });
75
+ const makeEl = (id) => {
76
+ const classes = new Set();
77
+ return {
78
+ _id: id, _html: '', textContent: '', className: '', value: '', hidden: false,
79
+ _classes: classes,
80
+ set innerHTML(v) { this._html = String(v); },
81
+ get innerHTML() { return this._html; },
82
+ // task 1002347 (R28): the suggestion surface shows its reason line the
83
+ // .modWhy way — a class toggle, not a hidden flag — so the stub grows the
84
+ // one DOM affordance that answer needs.
85
+ classList: {
86
+ toggle(name, on) { if (on) classes.add(name); else classes.delete(name); },
87
+ add(name) { classes.add(name); },
88
+ remove(name) { classes.delete(name); },
89
+ contains(name) { return classes.has(name); },
90
+ },
91
+ addEventListener(type, fn) {
92
+ const key = id + ':' + type;
93
+ if (!listeners.has(key)) listeners.set(key, []);
94
+ listeners.get(key).push(fn);
95
+ },
96
+ setAttribute() {},
97
+ focus() { focused.push(id); },
98
+ };
99
+ };
80
100
  const $ = (id) => {
81
101
  if (id == null) return null;
82
102
  if (!els.has(id)) els.set(id, makeEl(id));
@@ -86,7 +106,9 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
86
106
  // Every mount's elements exist before the region wires them, exactly as the
87
107
  // real page has them: the script runs at the end of <body>.
88
108
  ['wizInvite', 'wizInviteWho', 'wizInviteList', 'wizInviteMsg', 'wizInviteLater', 'doneStepsHead',
89
- 'mInvite', 'mInviteWho', 'mInviteList', 'mInviteMsg'].forEach($);
109
+ 'wizInviteWhy', 'wizInviteSugg',
110
+ 'mInvite', 'mInviteWho', 'mInviteList', 'mInviteMsg',
111
+ 'mInviteWhy', 'mInviteSugg'].forEach($);
90
112
 
91
113
  const queued = answers.slice();
92
114
  const fetch = (url, init = {}) => {
@@ -113,19 +135,20 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
113
135
  vm.runInContext(REGION, sandbox, { filename: 'projects.html#project-invite' });
114
136
 
115
137
  const mounts = {
116
- wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg' },
117
- mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg' },
138
+ wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg', why: 'wizInviteWhy', sugg: 'wizInviteSugg' },
139
+ mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', why: 'mInviteWhy', sugg: 'mInviteSugg' },
118
140
  };
119
141
  const m = mounts[mount];
120
142
  const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
121
143
 
144
+ let pickLogin = '';
122
145
  const press = async (marker) => {
123
146
  const handlers = listeners.get(mount + ':click') || [];
124
147
  assert.equal(handlers.length, 1, 'the mount wires exactly one delegated click handler');
125
148
  handlers[0]({
126
149
  target: {
127
- closest: (sel) => (sel === '[data-invite-send],[data-invite-skip]'
128
- ? { hasAttribute: (a) => a === marker }
150
+ closest: (sel) => (sel === '[data-invite-send],[data-invite-skip],[data-invite-pick]'
151
+ ? { hasAttribute: (a) => a === marker, getAttribute: () => pickLogin }
129
152
  : null),
130
153
  },
131
154
  });
@@ -142,6 +165,13 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
142
165
  async type(login) { $(m.who).value = login; },
143
166
  async send() { await press('data-invite-send'); },
144
167
  async skip() { await press('data-invite-skip'); },
168
+ // ── the suggestion half (task 1002347, BV1.R28) ──────────────────────
169
+ why: () => $(m.why),
170
+ whyOn: () => $(m.why)._classes.has('on'),
171
+ suggested: () => parseSugg($(m.sugg).innerHTML),
172
+ async suggest(payload) { sandbox.setInviteSuggestions(mount, payload); await settle(); },
173
+ async load(instanceId) { sandbox.loadInviteSuggestions(mount, instanceId); await settle(); },
174
+ async pick(login) { pickLogin = login; await press('data-invite-pick'); },
145
175
  async enter() {
146
176
  const handlers = listeners.get(m.who + ':keydown') || [];
147
177
  assert.equal(handlers.length, 1, 'the field takes Enter');
@@ -500,3 +530,126 @@ test('the manage view has a rendered state for the invite line', () => {
500
530
  assert.ok(states['in-manage-invite'], 'the section has a kit state');
501
531
  assert.deepEqual(states['in-manage-invite'].expect.visible, ['#mInvite', '#mInvite h2', '#mInviteWho', '#mInvite [data-invite-send]']);
502
532
  });
533
+
534
+ // ── who is worth inviting (task 1002347, BV1.R28, ADR 0251) ──────────────────
535
+ // The engine states WHAT to look for and never who (ADR 0239 §6); these are the
536
+ // names, and the rules they must not break: the reason is always said, "nobody"
537
+ // is an answer rather than a blank, and a picked name takes the SAME write path
538
+ // as a typed one.
539
+
540
+ test('a suggestion list renders its names, and each row carries the login its button will send', async () => {
541
+ const h = boot();
542
+ await h.open('mercury');
543
+ await h.suggest({
544
+ suggested: [
545
+ { github_login: 'ada', display_name: 'Ada L', total_tasks_shipped: 12, project_count: 3 },
546
+ { github_login: 'grace', display_name: null, total_tasks_shipped: 0, project_count: 0 },
547
+ ],
548
+ seeking: { kind: 'top-builders', limit: 3 },
549
+ reason: 'A small team is the shape that fills fastest.',
550
+ });
551
+ assert.deepEqual(h.suggested().map((r) => r.login), ['ada', 'grace']);
552
+ assert.match(h.suggested()[0].named, /Ada L/, 'a display name is used when the row carries one');
553
+ assert.match(h.suggested()[0].named, /@ada/, 'and the login is still shown — it is the address');
554
+ assert.match(h.suggested()[1].named, /<b>@grace<\/b>/, 'with no display name the login stands alone');
555
+ });
556
+
557
+ test('the reason is ALWAYS said — including when the answer is nobody', async () => {
558
+ const h = boot();
559
+ await h.open('mercury');
560
+ await h.suggest({ suggested: [], seeking: null, reason: 'You said you are building alone, so nobody is suggested.' });
561
+ assert.equal(h.suggested().length, 0, 'no names');
562
+ assert.equal(h.why().textContent, 'You said you are building alone, so nobody is suggested.');
563
+ assert.ok(h.whyOn(), 'and the line is shown — "nobody" is an answer, not a blank state');
564
+ });
565
+
566
+ test('no payload at all says nothing — a failed lookup must not invent a reason', async () => {
567
+ const h = boot();
568
+ await h.open('mercury');
569
+ await h.suggest(null);
570
+ assert.equal(h.suggested().length, 0);
571
+ assert.equal(h.why().textContent, '');
572
+ assert.ok(!h.whyOn(), 'the reason line is off entirely, not empty-but-present');
573
+ });
574
+
575
+ test('picking a suggestion sends exactly what typing that name would have sent', async () => {
576
+ const h = boot();
577
+ await h.open('mercury');
578
+ await h.suggest({ suggested: [{ github_login: 'ada' }], seeking: { kind: 'top-builders', limit: 3 }, reason: 'r' });
579
+ await h.pick('ada');
580
+ const posts = h.requests.filter((r) => r.method === 'POST');
581
+ assert.equal(posts.length, 1, 'one invite, through the one write path');
582
+ assert.deepEqual(JSON.parse(posts[0].body), { client_id: 'mercury', github_login: 'ada' },
583
+ 'the same body shape as a typed invite — the slug IS the client_id');
584
+ });
585
+
586
+ test('a name invited this session stops being suggested — the pending line is its receipt', async () => {
587
+ const h = boot();
588
+ await h.open('mercury');
589
+ await h.suggest({ suggested: [{ github_login: 'ada' }, { github_login: 'grace' }], seeking: { kind: 'top-builders', limit: 3 }, reason: 'r' });
590
+ assert.equal(h.suggested().length, 2);
591
+ await h.pick('ada');
592
+ assert.deepEqual(h.suggested().map((r) => r.login), ['grace'], 'ada moved to the pending list and left the suggestions');
593
+ assert.deepEqual(h.rows().map((r) => r.login), ['ada'], 'and she is on the pending list exactly once');
594
+ });
595
+
596
+ test('switching the mount to another project drops the suggestions with the pending rows', async () => {
597
+ const h = boot();
598
+ await h.open('mercury');
599
+ await h.suggest({ suggested: [{ github_login: 'ada' }], seeking: { kind: 'top-builders', limit: 3 }, reason: 'r' });
600
+ await h.open('venus');
601
+ assert.equal(h.suggested().length, 0, 'the names belonged to the project they were suggested for');
602
+ assert.ok(!h.whyOn(), 'and so did the reason');
603
+ });
604
+
605
+ test('the suggestions read is own-scoped by ADDRESS: it asks the instance route, with the session', async () => {
606
+ const h = boot({ answers: [{ ok: true, status: 200, body: { suggested: [{ github_login: 'ada' }], seeking: { kind: 'top-builders', limit: 3 }, reason: 'r' } }] });
607
+ await h.open('mercury');
608
+ await h.load(41);
609
+ const get = h.requests.find((r) => r.method === 'GET');
610
+ assert.equal(get.url, '/api/bongos/provisioning/instances/41/invite-suggestions');
611
+ assert.equal(get.credentials, 'same-origin', 'the cookie is the whole gate — the route 404s for anyone else');
612
+ assert.deepEqual(h.suggested().map((r) => r.login), ['ada']);
613
+ });
614
+
615
+ test('no instance id asks nothing at all', async () => {
616
+ const h = boot();
617
+ await h.open('mercury');
618
+ await h.load('');
619
+ assert.equal(h.requests.filter((r) => r.method === 'GET').length, 0, 'no project, no question');
620
+ });
621
+
622
+ test('a refused or unreachable lookup is SILENT — a created project never looks broken over an extra', async () => {
623
+ for (const answer of [{ ok: false, status: 404, body: {} }, { reject: true }]) {
624
+ const h = boot({ answers: [answer] });
625
+ await h.open('mercury');
626
+ await h.load(41);
627
+ assert.equal(h.suggested().length, 0, 'no names');
628
+ assert.equal(h.msg().textContent, '', 'and NOT a word in the invite message line');
629
+ }
630
+ });
631
+
632
+ test('a suggestion never renders a github_id, and its name goes through the escaper', async () => {
633
+ const h = boot();
634
+ await h.open('mercury');
635
+ await h.suggest({
636
+ suggested: [{ github_login: 'x"><img src=x onerror=1>', display_name: '<b>bad</b>', github_id: 4242, total_tasks_shipped: 1 }],
637
+ seeking: { kind: 'top-builders', limit: 3 },
638
+ reason: 'r',
639
+ });
640
+ const html = h.sandbox.$('wizInviteSugg').innerHTML;
641
+ assert.doesNotMatch(html, /4242/, 'the id names someone who has accepted nothing');
642
+ assert.doesNotMatch(html, /<img/, 'the login is escaped inside the attribute it rides in');
643
+ assert.doesNotMatch(html, /<b>bad<\/b>/, 'and the display name is escaped in the body');
644
+ });
645
+
646
+ test('the suggestion surface is mounted on BOTH the wizard and the project page', () => {
647
+ assert.match(HUB, /<ul class="suggList" id="wizInviteSugg"><\/ul>/, 'the wizard mount');
648
+ assert.match(HUB, /<ul class="suggList" id="mInviteSugg"><\/ul>/, 'and the project page mount');
649
+ assert.match(SCRIPT, /loadInviteSuggestions\('wizInvite', \(me && created && created\.slug\) \? created\.id : ''\)/,
650
+ 'the wizard asks keyed on the SAME created record the slug comes from (ADR 0249 §1)');
651
+ assert.match(SCRIPT, /loadInviteSuggestions\('mInvite', inst\.slug \? inst\.id : ''\)/,
652
+ 'and the manage page asks with the id whose read already proved ownership');
653
+ assert.match(COSMOS, /\.suggList\{/, 'styled in cosmos.css like its sibling list');
654
+ assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>') + 1), /suggList/, 'not in the page head');
655
+ });