@bongos/core 1.21.57 → 1.21.59

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 (52) hide show
  1. package/.bongos-core.json +91 -51
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +13 -2
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +2 -0
  8. package/docs/adr/0365-a-guild-engages-a-project-through-each-members-own-door.md +58 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +233 -6
  11. package/docs/api-reference.md +8 -5
  12. package/docs/copy-inventory.md +384 -376
  13. package/docs/copy-registry.json +514 -432
  14. package/docs/file-map.md +3 -0
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +7 -1
  17. package/docs/page-readings.json +930 -919
  18. package/modules/hall-ui/public/goals-page.js +20 -1
  19. package/modules/hall-ui/public/task-lib.js +13 -0
  20. package/modules/hall-ui/public/work.js +22 -1
  21. package/modules/platform-identity/application-echo.js +11 -5
  22. package/modules/platform-identity/guild-engagements.js +255 -0
  23. package/modules/platform-identity/guilds.js +3 -1
  24. package/modules/platform-identity/migrations/platform_identity_030_guild_engagements.sql +64 -0
  25. package/modules/platform-identity/routes/guilds.js +35 -1
  26. package/modules/platform-identity/routes/my-projects.js +27 -3
  27. package/modules/platform-identity/routes/projects.js +44 -0
  28. package/modules/platform-identity/routes/sso.js +21 -3
  29. package/modules/public-landing/public/assets/cosmos.css +7 -0
  30. package/modules/public-landing/public/guild.html +64 -2
  31. package/modules/public-landing/public/guild.probes.json +13 -3
  32. package/modules/public-landing/public/guild.states.json +10 -5
  33. package/modules/public-landing/public/projects.html +252 -16
  34. package/modules/public-landing/public/projects.probes.json +26 -1
  35. package/modules/public-landing/public/projects.states.json +4 -0
  36. package/modules/ui-design/kit/fixtures/me__guild-engagements.json +7 -0
  37. package/modules/ui-design/kit/serve.js +54 -5
  38. package/package-lock.json +2 -2
  39. package/package.json +1 -1
  40. package/release-notes.json +12 -0
  41. package/scripts/gds/run-unit-tests.js +5 -0
  42. package/src/module-api.js +1 -1
  43. package/tests/applicant_profile_boundary.mjs +104 -1
  44. package/tests/application_echo.mjs +91 -5
  45. package/tests/focus_version_filter_default.mjs +47 -0
  46. package/tests/guild_engagement.mjs +881 -0
  47. package/tests/guild_engagement_db.mjs +240 -0
  48. package/tests/hall_mine_lens.mjs +1 -0
  49. package/tests/hub_engagement_cards.mjs +307 -0
  50. package/tests/hub_guild_page.mjs +94 -7
  51. package/tests/privacy_matrix.mjs +99 -9
  52. package/tests/project_invite_ui.mjs +187 -3
@@ -963,6 +963,11 @@ summary{min-height:24px;padding:3px 0;}
963
963
  <section id="mineInvites" hidden aria-labelledby="mineInvitesH">
964
964
  <h2 id="mineInvitesH">Invitations</h2>
965
965
  <ul class="mineRows" id="invitesList"></ul>
966
+ <!-- a guild you are in, engaging a project (task 1002303, R27; ADR 0365):
967
+ "G is applying to P" and "P invited G", from GET /me/guild-engagements.
968
+ Each card's Apply is the ordinary join request with `guild` added, so
969
+ you apply as yourself, never as the guild. -->
970
+ <ul class="mineRows" id="engageList"></ul>
966
971
  </section>
967
972
 
968
973
  <section aria-labelledby="mineBuildingH">
@@ -1281,6 +1286,17 @@ summary{min-height:24px;padding:3px 0;}
1281
1286
  <ul class="suggList" id="mInviteSugg"></ul>
1282
1287
  <ul class="inviteList" id="mInviteList"></ul>
1283
1288
  <div class="projMsg" id="mInviteMsg" role="status" aria-live="polite"></div>
1289
+ <!-- invite a guild (task 1002303, R27; ADR 0365 D3): POST /projects/invite-guild
1290
+ { client_id, guild } records that the project invited the guild and
1291
+ answers one hall hand-off link per member on the guild's public page,
1292
+ drawn as the same lines as a single invite above. -->
1293
+ <p class="finePrint">Or invite a guild, by its name or its /g/ address. Each member gets their own invite, and only the members shown on the guild’s public page are listed.</p>
1294
+ <div class="attach">
1295
+ <label class="vh" for="mInviteGuild">Guild name or address</label>
1296
+ <input type="text" id="mInviteGuild" placeholder="Guild name or /g/ address" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="200">
1297
+ <button type="button" class="btn small ghost" data-invite-guild="mInvite">Invite the guild</button>
1298
+ </div>
1299
+ <div class="projMsg" id="mInviteGuildMsg" role="status" aria-live="polite"></div>
1284
1300
  </div>
1285
1301
  </div>
1286
1302
 
@@ -3616,10 +3632,9 @@ summary{min-height:24px;padding:3px 0;}
3616
3632
  hub has no accept route at all. Decline still works; it only clears the
3617
3633
  hub's own pending row. */
3618
3634
  function renderInvitesList(invites) {
3619
- var section = $('mineInvites');
3620
3635
  var ul = $('invitesList');
3621
3636
  ul.textContent = '';
3622
- if (!invites || !invites.length) { section.hidden = true; return; }
3637
+ if (!invites || !invites.length) { paintInvitesSection(); return; }
3623
3638
  invites.forEach(function (inv) {
3624
3639
  var row = mineRow(inv);
3625
3640
  var hint = mineEl('p', 'mineRow__hint');
@@ -3643,21 +3658,151 @@ summary{min-height:24px;padding:3px 0;}
3643
3658
  decline.addEventListener('click', function () {
3644
3659
  postThenRemoveRow(
3645
3660
  API + '/my-projects/invites/' + encodeURIComponent(inv.client_id) + '/decline', row, decline, null,
3646
- function () { if (!ul.children.length) section.hidden = true; }
3661
+ paintInvitesSection
3647
3662
  );
3648
3663
  });
3649
3664
  actions.appendChild(decline);
3650
3665
  row.appendChild(actions);
3651
3666
  ul.appendChild(row);
3652
3667
  });
3653
- section.hidden = false;
3668
+ paintInvitesSection();
3669
+ }
3670
+
3671
+ /* the Invitations section holds two lists — the projects' own invites and the
3672
+ guild cards below — and shows while either has a row */
3673
+ function paintInvitesSection() {
3674
+ $('mineInvites').hidden = !$('invitesList').children.length && !$('engageList').children.length;
3654
3675
  }
3655
3676
 
3656
3677
  function loadInvites() {
3657
- return fetch(API + '/my-projects/invites', { credentials: 'same-origin' })
3658
- .then(function (res) { return res.ok ? res.json() : { invites: [] }; })
3659
- .then(function (d) { renderInvitesList(d.invites || []); })
3660
- .catch(function () { $('mineInvites').hidden = true; });
3678
+ return Promise.all([
3679
+ fetch(API + '/my-projects/invites', { credentials: 'same-origin' })
3680
+ .then(function (res) { return res.ok ? res.json() : { invites: [] }; })
3681
+ .then(function (d) { renderInvitesList(d.invites || []); })
3682
+ .catch(function () { renderInvitesList([]); }),
3683
+ loadEngagements()
3684
+ ]);
3685
+ }
3686
+
3687
+ /* ── a guild you are in, engaging a project (task 1002303, R27; ADR 0365) ──
3688
+ GET /me/guild-engagements names what the guilds you are in have going: "G is
3689
+ applying to P" (the guild's owner put it forward) and "P invited G" (the
3690
+ project invited the guild; its hall sends each member on the guild's public
3691
+ page their own invite, and the rest of the guild hears it here). The read only
3692
+ names a project that is on the map and public, and leaves out one you already
3693
+ build on, are invited to, or have applied to. Each card's Apply is the SAME
3694
+ relay as everything else in this view — postJoinRequest — with `guild` added:
3695
+ you apply as yourself, never as the guild, with your own note if you like. A
3696
+ guild that no longer holds for you (you left it, or its 30 days ran out) is a
3697
+ 409 and nothing is relayed; the card then offers the plain application.
3698
+ textContent / createElement only. Proof: tests/hub_engagement_cards.mjs */
3699
+ var ENGAGE_GONE = 'That guild application has ended — you can still apply on your own.';
3700
+ function engageLine(e) {
3701
+ var g = e.guild.name || e.guild.slug;
3702
+ var p = (e.project && e.project.name) || hostOf(e.project.origin);
3703
+ return e.kind === 'invited' ? p + ' invited ' + g : g + ' is applying to ' + p + ' — apply with them?';
3704
+ }
3705
+ function sessionExpired(el) {
3706
+ el.className = 'projMsg err';
3707
+ el.textContent = 'Your session expired — ';
3708
+ var a = document.createElement('a');
3709
+ a.href = API + '/auth/web/start?return=/projects%3Fview%3Dmine&repos=1';
3710
+ a.textContent = 'sign in again';
3711
+ el.appendChild(a);
3712
+ el.appendChild(document.createTextNode('.'));
3713
+ }
3714
+ function engageRow(e) {
3715
+ var origin = e.project.origin;
3716
+ var slug = e.guild.slug;
3717
+ var row = mineRow({ name: engageLine(e), origin: origin });
3718
+ row.className = 'mineRow engageRow';
3719
+ var main = row.querySelector('.mineRow__main');
3720
+ var hint = mineEl('p', 'mineRow__hint');
3721
+ hint.textContent = 'You apply as yourself — add a note for the owner if you like.';
3722
+ main.appendChild(hint);
3723
+ var note = document.createElement('textarea');
3724
+ note.className = 'mineJoinNote';
3725
+ note.rows = 2;
3726
+ note.maxLength = 400;
3727
+ note.placeholder = 'A note for the owner (optional) — what you’d like to work on, who you are.';
3728
+ note.setAttribute('aria-label', 'A note for the owner (optional)');
3729
+ note.hidden = true;
3730
+ main.appendChild(note);
3731
+ var msg = mineEl('p', 'projMsg');
3732
+ msg.setAttribute('role', 'status');
3733
+ msg.setAttribute('aria-live', 'polite');
3734
+ main.appendChild(msg);
3735
+ var actions = mineEl('div', 'mineRow__actions');
3736
+ var noteBtn = document.createElement('button');
3737
+ noteBtn.type = 'button';
3738
+ noteBtn.className = 'linklike';
3739
+ noteBtn.textContent = 'Add a note';
3740
+ noteBtn.addEventListener('click', function () { note.hidden = false; noteBtn.hidden = true; note.focus(); });
3741
+ var apply = document.createElement('button');
3742
+ apply.type = 'button';
3743
+ apply.className = 'btn small';
3744
+ apply.textContent = 'Apply';
3745
+ /* with the guild until the server says it no longer holds, then on your own */
3746
+ var withGuild = true;
3747
+ function idle() { apply.disabled = false; apply.textContent = withGuild ? 'Apply' : 'Apply on my own'; }
3748
+ apply.addEventListener('click', function () {
3749
+ apply.disabled = true;
3750
+ apply.textContent = 'Applying…';
3751
+ msg.className = 'projMsg';
3752
+ msg.textContent = '';
3753
+ postJoinRequest(origin, (note.value || '').trim() || null, withGuild ? slug : null).then(function (res) {
3754
+ if (res.unauthenticated) { idle(); sessionExpired(msg); return; }
3755
+ if (res.ok && res.data && res.data.status === 'requested') {
3756
+ /* no longer pressable, so no longer the accent (the One Warm Thing rule) */
3757
+ apply.className = 'btn small ghost';
3758
+ apply.textContent = 'Applied';
3759
+ hint.hidden = true;
3760
+ note.hidden = true;
3761
+ noteBtn.hidden = true;
3762
+ msg.className = 'projMsg ok';
3763
+ msg.textContent = res.data.message || 'Requested.';
3764
+ return;
3765
+ }
3766
+ var err = apiError(res.data, res.status);
3767
+ if (res.status === 409 && err.code === 'guild_engagement_gone') {
3768
+ withGuild = false;
3769
+ idle();
3770
+ msg.textContent = ENGAGE_GONE;
3771
+ return;
3772
+ }
3773
+ idle();
3774
+ /* the relay's own answer (invite_only, already_member …) in its own words */
3775
+ msg.className = 'projMsg' + (res.ok ? '' : ' err');
3776
+ msg.textContent = (res.ok && res.data && res.data.message) || err.message || 'Could not reach that project — try again in a moment.';
3777
+ }).catch(function () {
3778
+ idle();
3779
+ msg.className = 'projMsg err';
3780
+ msg.textContent = 'Couldn’t reach the platform — check your connection and try again.';
3781
+ });
3782
+ });
3783
+ actions.appendChild(noteBtn);
3784
+ actions.appendChild(apply);
3785
+ row.appendChild(actions);
3786
+ return row;
3787
+ }
3788
+ /* a row this page cannot act on (an unknown kind, no guild to name, no address
3789
+ to apply to) is dropped rather than drawn as a card whose button fails */
3790
+ function renderEngagements(rows) {
3791
+ var ul = $('engageList');
3792
+ ul.textContent = '';
3793
+ (rows || []).forEach(function (e) {
3794
+ if (!e || (e.kind !== 'apply' && e.kind !== 'invited')) return;
3795
+ if (!e.guild || typeof e.guild.slug !== 'string' || !e.guild.slug) return;
3796
+ if (!e.project || !safeHttpUrl(e.project.origin)) return;
3797
+ ul.appendChild(engageRow(e));
3798
+ });
3799
+ paintInvitesSection();
3800
+ }
3801
+ function loadEngagements() {
3802
+ return fetch(API + '/me/guild-engagements', { credentials: 'same-origin' })
3803
+ .then(function (res) { return res.ok ? res.json() : { engagements: [] }; })
3804
+ .then(function (d) { renderEngagements(d.engagements || []); })
3805
+ .catch(function () { renderEngagements([]); });
3661
3806
  }
3662
3807
 
3663
3808
  /* the one relay both the map's per-card Apply (submitApply) and the
@@ -3665,11 +3810,17 @@ summary{min-height:24px;padding:3px 0;}
3665
3810
  the 'requested' echo update kept in ONE place (task 1003774). Keys the
3666
3811
  echo off the server's OWN resolved origin where the response carries one:
3667
3812
  it need not match what the visitor typed byte-for-byte (a trailing
3668
- slash, a different case) to be the same project the map's catalog names. */
3669
- function postJoinRequest(origin, note) {
3813
+ slash, a different case) to be the same project the map's catalog names.
3814
+ `guild` (a slug, optional) is a guild card's: the application is still the
3815
+ caller's own, and the hub records which guild they came with (task 1002303,
3816
+ R27; ADR 0365 D1/D2). */
3817
+ function postJoinRequest(origin, note, guild) {
3818
+ var body = { origin: origin };
3819
+ if (note) body.note = note;
3820
+ if (guild) body.guild = guild;
3670
3821
  return fetch(API + '/my-projects/join', {
3671
3822
  method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
3672
- body: JSON.stringify(note ? { origin: origin, note: note } : { origin: origin }),
3823
+ body: JSON.stringify(body),
3673
3824
  }).then(function (res) {
3674
3825
  if (res.status === 401) return { unauthenticated: true };
3675
3826
  return jsonOrEmpty(res).then(function (data) {
@@ -10296,11 +10447,17 @@ summary{min-height:24px;padding:3px 0;}
10296
10447
  here, because the project notifies the hub (POST /sso/invites/notify).
10297
10448
 
10298
10449
  WHAT IT PUBLISHES OF AN INVITEE is the login the inviter typed and nothing else.
10450
+
10451
+ A GUILD (task 1002303, R27; ADR 0365 D3), on the project's own page only: the
10452
+ inviter names a public guild and POST /projects/invite-guild { client_id, guild }
10453
+ answers one hand-off link per member on the guild's PUBLIC page — the people the
10454
+ inviter could see, and their handle and display name, which that page already
10455
+ shows. They become the same lines, each finished on the hall like any other.
10299
10456
  Proof: ../../../tests/project_invite_ui.mjs ══ */
10300
10457
 
10301
10458
  var INVITE_MOUNTS = {
10302
10459
  wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg', after: 'doneStepsHead', why: 'wizInviteWhy', sugg: 'wizInviteSugg' },
10303
- mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', after: null, why: 'mInviteWhy', sugg: 'mInviteSugg' }
10460
+ mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', after: null, why: 'mInviteWhy', sugg: 'mInviteSugg', guild: 'mInviteGuild', guildMsg: 'mInviteGuildMsg' }
10304
10461
  };
10305
10462
  var inviteSlug = {}; /* mount id → the slug it invites to; '' means no step at all */
10306
10463
  var inviteRows = {}; /* mount id → the invites filed on THIS project this session */
@@ -10320,6 +10477,7 @@ summary{min-height:24px;padding:3px 0;}
10320
10477
  inviteRows[id] = [];
10321
10478
  inviteSugg[id] = null;
10322
10479
  inviteSay(id, '', '');
10480
+ inviteSay(id, '', '', false, 'guildMsg');
10323
10481
  }
10324
10482
  var box = $(id);
10325
10483
  if (box) box.hidden = !next;
@@ -10389,14 +10547,15 @@ summary{min-height:24px;padding:3px 0;}
10389
10547
  }
10390
10548
 
10391
10549
  /* One line per name handed to the hall this session: the login the inviter
10392
- typed, and the link that finishes its invite there. The line says the invite is
10550
+ typed (or, for a guild's member, the display name its public page shows), and
10551
+ the link that finishes its invite there. The line says the invite is
10393
10552
  NOT sent yet, because it is not — the hall's Invite button is what sends it. */
10394
10553
  function renderInviteRows(id) {
10395
10554
  var m = INVITE_MOUNTS[id];
10396
10555
  var list = m && $(m.list);
10397
10556
  if (!list) return;
10398
10557
  list.innerHTML = (inviteRows[id] || []).map(function (r) {
10399
- return '<li><b>' + esc(r.login) + '</b><small>not sent yet — finish it on your project’s hall.</small>'
10558
+ return '<li><b>' + esc(r.name || r.login) + '</b><small>not sent yet — finish it on your project’s hall.</small>'
10400
10559
  + '<a class="btn small" href="' + esc(r.url) + '" target="_blank" rel="noopener">Send the invite →</a></li>';
10401
10560
  }).join('');
10402
10561
  /* the two lists are one answer: a name that just became a pending row is no
@@ -10404,9 +10563,10 @@ summary{min-height:24px;padding:3px 0;}
10404
10563
  renderInviteSugg(id);
10405
10564
  }
10406
10565
 
10407
- function inviteSay(id, text, cls, isHtml) {
10566
+ /* `key` names the line: the mount's message (the default) or the guild row's */
10567
+ function inviteSay(id, text, cls, isHtml, key) {
10408
10568
  var m = INVITE_MOUNTS[id];
10409
- var el = m && $(m.msg);
10569
+ var el = m && m[key || 'msg'] ? $(m[key || 'msg']) : null;
10410
10570
  if (!el) return;
10411
10571
  el.className = 'projMsg' + (cls ? ' ' + cls : '');
10412
10572
  if (isHtml) el.innerHTML = text; else el.textContent = text;
@@ -10461,6 +10621,73 @@ summary{min-height:24px;padding:3px 0;}
10461
10621
  });
10462
10622
  }
10463
10623
 
10624
+ /* inviteGuildSlug(raw) — a guild named the ways a person pastes one: its name in
10625
+ the address ("forge-crew"), "/g/forge-crew", or the guild page's whole address.
10626
+ PURE. Anything else is sent as typed, and the route answers for it. */
10627
+ function inviteGuildSlug(raw) {
10628
+ var s = String(raw == null ? '' : raw).replace(/^\s+|\s+$/g, '');
10629
+ var m = /(?:^|\/)g\/([^\/?#\s]+)\/?(?:[?#].*)?$/i.exec(s);
10630
+ if (m) {
10631
+ s = m[1];
10632
+ try { s = decodeURIComponent(s); } catch (e) { /* sent as typed */ }
10633
+ }
10634
+ return s.toLowerCase();
10635
+ }
10636
+
10637
+ /* doInviteGuild(id) — one public guild, one request: a pending line per member on
10638
+ its public page, each finished on the hall like a single invite. The route
10639
+ records that the project invited the guild and sends nothing, and nor does
10640
+ this. Only a mount that names a guild field has this control. */
10641
+ function doInviteGuild(id) {
10642
+ var m = INVITE_MOUNTS[id];
10643
+ var slug = inviteSlug[id] || '';
10644
+ if (!m || !m.guild || !slug || inviteBusy[id]) return;
10645
+ var field = $(m.guild);
10646
+ var guild = inviteGuildSlug(field && field.value);
10647
+ if (!guild) {
10648
+ inviteSay(id, 'Type the guild’s name or its /g/ address.', 'err', false, 'guildMsg');
10649
+ if (field && field.focus) field.focus();
10650
+ return;
10651
+ }
10652
+ inviteBusy[id] = true;
10653
+ inviteSay(id, 'Finding the guild’s members…', '', false, 'guildMsg');
10654
+ fetch(API + '/projects/invite-guild', {
10655
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
10656
+ body: JSON.stringify({ client_id: slug, guild: guild })
10657
+ }).then(function (res) {
10658
+ return jsonOrEmpty(res).then(function (data) {
10659
+ inviteBusy[id] = false;
10660
+ if (res.status === 401) { inviteSay(id, signInAgain(), 'err', true, 'guildMsg'); return; }
10661
+ var g = res.ok && data && data.guild;
10662
+ if (g) {
10663
+ /* a member already on a line (the same guild twice) is not drawn again */
10664
+ var listed = 0;
10665
+ (data.invites || []).forEach(function (r) {
10666
+ var url = r ? safeHttpUrl(r.invite_url) : '';
10667
+ if (!url || !r.handle) return;
10668
+ listed++;
10669
+ if ((inviteRows[id] || []).some(function (x) { return x.url === url; })) return;
10670
+ inviteRows[id] = (inviteRows[id] || []).concat([{ login: String(r.handle), name: r.display_name ? String(r.display_name) : '', url: url }]);
10671
+ });
10672
+ renderInviteRows(id);
10673
+ if (field) field.value = '';
10674
+ var name = g.name || guild;
10675
+ inviteSay(id, listed
10676
+ ? name + ': ' + listed + (listed === 1 ? ' member' : ' members') + ' to invite. Send each invite from your project’s hall — every line above opens it with their name filled in.'
10677
+ : 'Nobody is listed on ' + name + '’s public page yet, so there is no one to invite from here.', listed ? 'ok' : '', false, 'guildMsg');
10678
+ return;
10679
+ }
10680
+ var e = apiError(data, res.status);
10681
+ /* an unknown guild and an unlisted one are one answer on the server */
10682
+ if (res.status === 404 && e.code === 'guild_not_found') { inviteSay(id, 'No public guild at that name.', 'err', false, 'guildMsg'); return; }
10683
+ inviteSay(id, inviteRefusal(e.code, res.status, e.message), 'err', false, 'guildMsg');
10684
+ });
10685
+ }).catch(function () {
10686
+ inviteBusy[id] = false;
10687
+ inviteSay(id, 'Couldn’t reach the platform — check your connection and try again.', 'err', false, 'guildMsg');
10688
+ });
10689
+ }
10690
+
10464
10691
  /* skipInvite(id) — the step steps aside having sent NOTHING. Focus moves on
10465
10692
  rather than being stranded inside the box that just went away. */
10466
10693
  function skipInvite(id) {
@@ -10479,6 +10706,7 @@ summary{min-height:24px;padding:3px 0;}
10479
10706
  var box = $(id);
10480
10707
  if (box) {
10481
10708
  box.addEventListener('click', function (ev) {
10709
+ if (ev.target.closest && ev.target.closest('[data-invite-guild]')) { doInviteGuild(id); return; }
10482
10710
  var t = ev.target.closest && ev.target.closest('[data-invite-send],[data-invite-skip],[data-invite-pick]');
10483
10711
  if (!t) return;
10484
10712
  /* a picked suggestion fills the field and takes the SAME path as a typed
@@ -10501,6 +10729,14 @@ summary{min-height:24px;padding:3px 0;}
10501
10729
  doInvite(id);
10502
10730
  });
10503
10731
  }
10732
+ var guildField = INVITE_MOUNTS[id].guild ? $(INVITE_MOUNTS[id].guild) : null;
10733
+ if (guildField) {
10734
+ guildField.addEventListener('keydown', function (ev) {
10735
+ if (ev.key !== 'Enter') return;
10736
+ ev.preventDefault();
10737
+ doInviteGuild(id);
10738
+ });
10739
+ }
10504
10740
  });
10505
10741
 
10506
10742
  /* ── boot: who am I → my instances ────────────────────────────────────── */
@@ -175,6 +175,31 @@
175
175
  "expect": {"visible":["#panel-fork",".doorPill","#wizDemo","#wizStart"]} },
176
176
  { "name": "the wizard driver at 390: the review rows stack and the walk still reaches Create", "auth": true, "width": 390, "url": "/projects?view=new",
177
177
  "steps": [["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500]],
178
- "expect": {"visible":["#panel-look","#wizLook","#doCreate"],"hidden":["#panel-review"],"text":[["#look-h","Give it a look."]]} }
178
+ "expect": {"visible":["#panel-look","#wizLook","#doCreate"],"hidden":["#panel-review"],"text":[["#look-h","Give it a look."]]} },
179
+ { "name": "guild cards (task 1002303, R27): the mine view lists what your guilds have going, each card with its own Apply", "auth": true, "width": 1440, "url": "/projects?view=mine",
180
+ "steps": [["wait",900]],
181
+ "expect": { "visible": ["#mineInvites", "#engageList"], "count": [["#engageList .engageRow", 2], ["#engageList .engageRow .mineJoinNote:not([hidden])", 0]],
182
+ "text": [["#engageList .engageRow:nth-child(1) .mineRow__name", "Forge Crew is applying to Tidepool — apply with them?"], ["#engageList .engageRow:nth-child(2) .mineRow__name", "Lantern Works invited Forge Crew"]] } },
183
+ { "name": "guild cards: Add a note opens the card's own note; Apply sends it with the guild (the stub refuses a guild with no engagement) and the card says it went through", "continue": true,
184
+ "steps": [["click","#engageList .engageRow:first-child .linklike"],["fill","#engageList .engageRow:first-child .mineJoinNote","Happy to take the migrations."],["click","#engageList .engageRow:first-child .btn"],["wait",400]],
185
+ "expect": { "hidden": ["#engageList .engageRow:first-child .mineJoinNote"], "hasClass": [["#engageList .engageRow:first-child .projMsg", "ok"]],
186
+ "text": [["#engageList .engageRow:first-child .btn", "Applied"], ["#engageList .engageRow:first-child .projMsg", "Requested access. The project’s owner admits new builders; once approved, sign in to enter."]] } },
187
+ { "name": "guild cards: the invited card applies on its own, with no note", "continue": true,
188
+ "steps": [["click","#engageList .engageRow:nth-child(2) .btn"],["wait",400]],
189
+ "expect": { "text": [["#engageList .engageRow:nth-child(2) .btn", "Applied"]], "hasClass": [["#engageList .engageRow:nth-child(2) .projMsg", "ok"]] } },
190
+ { "name": "invite a guild (task 1002303, R27): a /g/ address gives one pending line per member on its public page, each a link to the hall", "auth": true, "width": 1440, "url": "/projects?view=manage&id=101",
191
+ "steps": [["wait",900],["fill","#mInviteGuild","/g/forge-crew"],["click","#mInvite [data-invite-guild]"],["wait",400]],
192
+ "expect": { "count": [["#mInviteList li", 4]], "value": [["#mInviteGuild", ""]],
193
+ "href": [["#mInviteList li:first-child a", "https://mercury.example.test/builders/watch?invite=mara-quinn"]],
194
+ "text": [["#mInviteGuildMsg", "Forge Crew: 4 members to invite. Send each invite from your project’s hall — every line above opens it with their name filled in."]] } },
195
+ { "name": "invite a guild: the same guild again draws no second line", "continue": true,
196
+ "steps": [["fill","#mInviteGuild","forge-crew"],["click","#mInvite [data-invite-guild]"],["wait",400]],
197
+ "expect": { "count": [["#mInviteList li", 4]] } },
198
+ { "name": "invite a guild: an unlisted guild is the same one line as an unknown one", "continue": true,
199
+ "steps": [["fill","#mInviteGuild","night-shift"],["click","#mInvite [data-invite-guild]"],["wait",400]],
200
+ "expect": { "text": [["#mInviteGuildMsg", "No public guild at that name."]], "hasClass": [["#mInviteGuildMsg", "err"]], "count": [["#mInviteList li", 4]], "value": [["#mInviteGuild", "night-shift"]] } },
201
+ { "name": "invite a guild: an empty field is a question, not a request; Enter in the field sends too", "continue": true,
202
+ "steps": [["fill","#mInviteGuild",""],["focus","#mInviteGuild"],["press","Enter"],["wait",150]],
203
+ "expect": { "text": [["#mInviteGuildMsg", "Type the guild’s name or its /g/ address."]], "activeId": "mInviteGuild" } }
179
204
  ]
180
205
  }
@@ -26,6 +26,10 @@
26
26
  "in-manage-kind": { "auth": true, "url": "/projects?view=manage&id=101", "expect": {"visible":["#mKind","#mKindPick .modBand","#mKindWhat summary"],"count":[["#mKindPick .modOpt",5],["#mKindPick .modOpt[aria-pressed=\"true\"]",0]],"text":[["#mKindPick .modOpt b","Game"],["#mKindPick .finePrint","This project has never said what kind it is. Saying so puts the word beside its name wherever it is listed; it changes nothing else."]]} },
27
27
  "in-manage-invite": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mInvite", "#mInvite h2", "#mInviteWho", "#mInvite [data-invite-send]"] } },
28
28
  "in-manage-invite-handoff": { "auth": true, "url": "/projects?view=manage&id=101", "actions": [["wait",900],["fill","#mInviteWho","octo-builder"],["click","#mInvite [data-invite-send]"],["wait",400]], "expect": {"visible":["#mInviteList a","#mInviteMsg"],"hidden":[]} },
29
+ "in-manage-invite-guild": { "auth": true, "url": "/projects?view=manage&id=101", "actions": [["wait",900],["fill","#mInviteGuild","https://cloudbongos.com/g/forge-crew"],["click","#mInvite [data-invite-guild]"],["wait",400]], "expect": { "visible": ["#mInviteGuild", "#mInviteList a", "#mInviteGuildMsg"], "count": [["#mInviteList li", 4]], "value": [["#mInviteGuild", ""]], "hasClass": [["#mInviteGuildMsg", "ok"]], "text": [["#mInviteList li:first-child b", "Mara Quinn"], ["#mInviteGuildMsg", "Forge Crew: 4 members to invite. Send each invite from your project’s hall — every line above opens it with their name filled in."]] } },
30
+ "in-mine-engagements": { "auth": true, "url": "/projects?view=mine", "actions": [["wait",900],["focus","#engageList .engageRow:first-child .btn"],["wait",200]], "expect": { "visible": ["#mineInvites", "#invitesList", "#engageList"], "count": [["#engageList .engageRow", 2], ["#engageList .engageRow .mineJoinNote:not([hidden])", 0]], "text": [["#engageList .engageRow:nth-child(1) .mineRow__name", "Forge Crew is applying to Tidepool — apply with them?"], ["#engageList .engageRow:nth-child(1) .mineRow__host", "tidepool.cloudbongos.com"], ["#engageList .engageRow:nth-child(2) .mineRow__name", "Lantern Works invited Forge Crew"], ["#engageList .engageRow:nth-child(1) .mineRow__hint", "You apply as yourself — add a note for the owner if you like."], ["#engageList .engageRow:nth-child(1) .btn", "Apply"]] } },
31
+ "in-mine-engagement-note": { "auth": true, "url": "/projects?view=mine", "actions": [["wait",900],["click","#engageList .engageRow:first-child .linklike"],["fill","#engageList .engageRow:first-child .mineJoinNote","I like the boring parts too: migrations and the deploy path."],["wait",150]], "expect": { "visible": ["#engageList .engageRow:first-child .mineJoinNote"], "hidden": ["#engageList .engageRow:first-child .linklike"], "value": [["#engageList .engageRow:first-child .mineJoinNote", "I like the boring parts too: migrations and the deploy path."]] } },
32
+ "in-mine-engagement-applied": { "auth": true, "url": "/projects?view=mine", "actions": [["wait",900],["click","#engageList .engageRow:first-child .linklike"],["fill","#engageList .engageRow:first-child .mineJoinNote","I like the boring parts too: migrations and the deploy path."],["click","#engageList .engageRow:first-child .btn"],["wait",400]], "expect": { "hidden": ["#engageList .engageRow:first-child .mineJoinNote", "#engageList .engageRow:first-child .linklike"], "hasClass": [["#engageList .engageRow:first-child .projMsg", "ok"], ["#engageList .engageRow:first-child .btn", "ghost"]], "text": [["#engageList .engageRow:first-child .btn", "Applied"], ["#engageList .engageRow:first-child .projMsg", "Requested access. The project’s owner admits new builders; once approved, sign in to enter."], ["#engageList .engageRow:nth-child(2) .btn", "Apply"]] } },
29
33
  "in-manage-app-live": { "auth": true, "url": "/projects?view=manage&id=101", "actions": [["wait",900],["focus","#mApp a"]], "expect": {"visible":["#mAppCard","#mApp .appStat","#mApp .appVars"],"hidden":[]} },
30
34
  "in-manage-app-building": { "auth": true, "url": "/projects?view=manage&id=102", "actions": [["wait",900],["focus","#mApp a"]], "expect": {"visible":["#mAppCard","#mApp .appStages"],"hidden":[]} },
31
35
  "in-manage-app-failed": { "auth": true, "url": "/projects?view=manage&id=105", "actions": [["wait",900],["focus","#mApp .appRetryBtn"]], "expect": {"visible":["#mAppCard","#mApp p.err","#mApp .appRetryBtn"],"hidden":["#mApp .appRetry"]} },
@@ -0,0 +1,7 @@
1
+ {
2
+ "engagements": [
3
+ { "guild": { "slug": "forge-crew", "name": "Forge Crew" }, "kind": "apply", "project": { "name": "Tidepool", "origin": "https://tidepool.cloudbongos.com" }, "created_at": "2026-10-03T12:00:00.000Z" },
4
+ { "guild": { "slug": "forge-crew", "name": "Forge Crew" }, "kind": "invited", "project": { "name": "Lantern Works", "origin": "https://lantern-works.cloudbongos.com" }, "created_at": "2026-10-02T09:30:00.000Z" }
5
+ ],
6
+ "count": 2
7
+ }
@@ -111,9 +111,12 @@ const FEEDS = {
111
111
  // /founding/genesis and /government/constitution (task 1004423) ship no default file: the
112
112
  // genesis home asks for them only in founding mode, so every existing render is unchanged,
113
113
  // and the genesis variants point them at their samples.
114
+ // /me/guild-engagements (task 1002303, R27) is the mine view's guild cards: a guild applying
115
+ // to a project, and a project that invited a guild, for the guilds the builder is in.
114
116
  const AUTHED = {
115
117
  '/my-projects': 'my-projects', '/my-projects/invites': 'my-projects__invites', '/founding/builders': 'founding-builders',
116
118
  '/founding/genesis': 'founding-genesis', '/government/constitution': 'government-constitution',
119
+ '/me/guild-engagements': 'me__guild-engagements',
117
120
  };
118
121
  const UNAUTH = { error: { code: 'unauthenticated', message: 'unauthenticated' } };
119
122
 
@@ -474,6 +477,47 @@ function guildMembershipPatch(res, slug, body) {
474
477
  return json(res, 200, { shown_publicly: g.members.you.shown_publicly, counted_in_totals: g.members.you.counted_in_totals });
475
478
  }
476
479
 
480
+ // Guild engagements (task 1002303, R27; ADR 0365). The stub mirrors the routes' refusals so
481
+ // a probe cannot pass on a body or a caller the server would refuse:
482
+ // POST /guilds/:slug/engagements { origin } the owner files "this guild is applying to P":
483
+ // a non-member's 404, a member's owner-only 403, and ONE 404 for a project that is
484
+ // unknown or not (on the map AND public) — here, a featured-feed row whose orb is a planet;
485
+ // POST /projects/invite-guild { client_id, guild } one hall hand-off link per member on the
486
+ // guild's PUBLIC roster; an unknown or unlisted guild is one 404;
487
+ // POST /my-projects/join's optional `guild` must name a held guild the builder is in with a
488
+ // held engagement (me__guild-engagements.json) for that project, else 409 and nothing relayed.
489
+ const UNKNOWN_PROJECT = { error: { code: 'unknown_project', message: 'unknown_project' } };
490
+ const sameOrigin = (a, b) => String(a || '').trim().toLowerCase().replace(/\/+$/, '') === String(b || '').trim().toLowerCase().replace(/\/+$/, '');
491
+ function heldFixture(name) {
492
+ return JSON.parse(fs.readFileSync(path.join(FIXTURES, (ALIAS[name] || name) + '.json'), 'utf8'));
493
+ }
494
+ function guildEngagementPost(res, slug, body) {
495
+ const g = guildsHeld()[String(slug).toLowerCase()];
496
+ if (!g || !g.members) return json(res, 404, GUILD_NOT_FOUND);
497
+ if (g.members.you.membership_kind !== 'owner') return refuse(res, 403, 'guild_owner_only', 'only the guild owner can do that');
498
+ const bad = Object.keys(body).find((k) => k !== 'origin');
499
+ if (bad) return refuse(res, 400, 'validation_failed', `unknown field: ${bad}`);
500
+ if (typeof body.origin !== 'string' || !body.origin || body.origin.length > 300) return refuse(res, 400, 'validation_failed', 'origin must be a string');
501
+ const p = (heldFixture('featured').projects || []).find((x) => x.orb === 'planet' && sameOrigin(x.origin, body.origin));
502
+ if (!p) return json(res, 404, UNKNOWN_PROJECT);
503
+ return json(res, 200, { engagement: { guild: { slug: g.members.guild.slug, name: g.members.guild.name }, kind: 'apply', project: { name: p.name, origin: p.origin }, created_at: new Date().toISOString() } });
504
+ }
505
+ function guildInvitePost(res, body) {
506
+ const bad = Object.keys(body).find((k) => !['client_id', 'guild'].includes(k));
507
+ if (bad) return refuse(res, 400, 'validation_failed', `unknown field: ${bad}`);
508
+ if (typeof body.client_id !== 'string' || !body.client_id) return refuse(res, 400, 'validation_failed', 'client_id must be a string');
509
+ if (typeof body.guild !== 'string' || !body.guild || body.guild.length > 39) return refuse(res, 400, 'validation_failed', 'guild must be a string of at most 39 characters');
510
+ const g = guildsHeld()[body.guild.toLowerCase()];
511
+ if (!g || !g.public) return json(res, 404, GUILD_NOT_FOUND);
512
+ const invites = (g.public.members || []).map((m) => ({ handle: m.handle, display_name: m.display_name || null, invite_url: 'https://mercury.example.test/builders/watch?invite=' + encodeURIComponent(m.handle) }));
513
+ return json(res, 200, { invited: false, guild: { slug: g.public.guild.slug, name: g.public.guild.name }, invites, count: invites.length });
514
+ }
515
+ function guildEngagementLive(slug, origin) {
516
+ const g = typeof slug === 'string' && guildsHeld()[slug.toLowerCase()];
517
+ if (!g || !g.members) return false;
518
+ return (heldFixture('me__guild-engagements').engagements || []).some((e) => e.guild && e.guild.slug === slug.toLowerCase() && e.project && sameOrigin(e.project.origin, origin));
519
+ }
520
+
477
521
  const server = http.createServer((req, res) => {
478
522
  const u = new URL(req.url, 'http://x');
479
523
  const p = u.pathname;
@@ -491,10 +535,11 @@ const server = http.createServer((req, res) => {
491
535
  if (rest === '/me/cross-project' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => crossProjectPatch(res, body)) : json(res, 401, UNAUTH);
492
536
  if (rest === '/me/profile' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => profilePatch(res, body)) : json(res, 401, UNAUTH);
493
537
  if (rest === '/me/handle' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => handlePatch(res, body)) : json(res, 401, UNAUTH);
494
- const guild = rest.match(/^\/guilds\/([^/]+)(\/members|\/membership)?$/);
538
+ const guild = rest.match(/^\/guilds\/([^/]+)(\/members|\/membership|\/engagements)?$/);
495
539
  if (guild && !guild[2] && req.method === 'GET') return guildRead(res, guild[1], 'public', origin);
496
540
  if (guild && guild[2] === '/members' && req.method === 'GET') return AUTH ? guildRead(res, guild[1], 'members', origin) : json(res, 401, UNAUTH);
497
541
  if (guild && guild[2] === '/membership' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => guildMembershipPatch(res, guild[1], body)) : json(res, 401, UNAUTH);
542
+ if (guild && guild[2] === '/engagements' && req.method === 'POST') return AUTH ? readBody(req, (body) => guildEngagementPost(res, guild[1], body)) : json(res, 401, UNAUTH);
498
543
  if (rest === '/provisioning/instances' && req.method === 'GET') return AUTH ? instances(res, null, false) : json(res, 401, UNAUTH);
499
544
  // the planet-physics questions' pre-create check (task 1004418): nothing stops here —
500
545
  // the kit carries no screen. Of the soft-limit table it mirrors only the three hide
@@ -533,10 +578,13 @@ const server = http.createServer((req, res) => {
533
578
  // decline receipts a real render or probe of the my-projects block drives,
534
579
  // so those buttons are not stuck picturing only their failure branch.
535
580
  if (rest === '/my-projects/join' && req.method === 'POST') {
536
- return AUTH ? readBody(req, (body) => json(res, 200, {
537
- ok: true, status: 'requested', project: { name: body.origin || 'a project', origin: body.origin || null },
538
- message: 'Requested access. The project’s owner admits new builders; once approved, sign in to enter.',
539
- })) : json(res, 401, UNAUTH);
581
+ return AUTH ? readBody(req, (body) => {
582
+ if (body.guild !== undefined && !guildEngagementLive(body.guild, body.origin)) return refuse(res, 409, 'guild_engagement_gone', 'that guild engagement has ended');
583
+ return json(res, 200, {
584
+ ok: true, status: 'requested', project: { name: body.origin || 'a project', origin: body.origin || null },
585
+ message: 'Requested access. The project’s owner admits new builders; once approved, sign in to enter.',
586
+ });
587
+ }) : json(res, 401, UNAUTH);
540
588
  }
541
589
  if (/^\/my-projects\/[^/]+\/leave$/.test(rest) && req.method === 'POST') return AUTH ? json(res, 200, { ok: true, left: true }) : json(res, 401, UNAUTH);
542
590
  if (/^\/my-projects\/invites\/[^/]+\/decline$/.test(rest) && req.method === 'POST') return AUTH ? json(res, 200, { ok: true, accepted: false }) : json(res, 401, UNAUTH);
@@ -549,6 +597,7 @@ const server = http.createServer((req, res) => {
549
597
  invite_url: 'https://mercury.example.test/builders/watch?invite=' + encodeURIComponent(body.github_login || ''),
550
598
  })) : json(res, 401, UNAUTH);
551
599
  }
600
+ if (rest === '/projects/invite-guild' && req.method === 'POST') return AUTH ? readBody(req, (body) => guildInvitePost(res, body)) : json(res, 401, UNAUTH);
552
601
  // the manage flows (task 1003345): every door on a row and on the manage page only ASKS
553
602
  // (ADR 0111) — the stub answers each ask with the route's receipt so the probes can drive
554
603
  // attach / restart / teardown / revive / join end to end; nothing changes in the fixture
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.57",
3
+ "version": "1.21.59",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.57",
9
+ "version": "1.21.59",
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.57",
3
+ "version": "1.21.59",
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",
@@ -9114,5 +9114,17 @@
9114
9114
  "id": "1002301",
9115
9115
  "text": "Guilds now have their own page on cloudbongos.com — who is in it and what it has shipped together — and members can choose whether they appear on it and count toward its totals."
9116
9116
  }
9117
+ ],
9118
+ "1.21.58": [
9119
+ {
9120
+ "id": "1002303",
9121
+ "text": "Guilds can now put themselves forward for a project, and a project can invite a whole guild — but every member still applies or accepts as themselves, so nobody joins a project without their own say."
9122
+ }
9123
+ ],
9124
+ "1.21.59": [
9125
+ {
9126
+ "id": "1004552",
9127
+ "text": "The Goals and Tasks pages now open on the version the project owner has starred on the Roadmap. With no star, they show every version as before."
9128
+ }
9117
9129
  ]
9118
9130
  }
@@ -183,6 +183,11 @@ const INTEGRATION = new Set([
183
183
  // proves the one-owner partial index, the expiry CTE drained before its insert,
184
184
  // the cascade on dissolve and the two-statement transfer.
185
185
  'guilds_db',
186
+ // task 1002303 (R27): guild engagements against real SQL — the FK that cascades
187
+ // a dissolved guild's engagements and the one that SETs NULL on the echo, the
188
+ // upsert restarting the 30-day window in place, and the joined_at rule. The
189
+ // DB-free sibling (guild_engagement) pins the routes and statement shapes.
190
+ 'guild_engagement_db',
186
191
  // task 1002302 (R26): a guild's weekly rounded totals against real SQL. Who is
187
192
  // counted (three predicates and a FILTERed k count) and the upsert's week guard,
188
193
  // re-evaluated against a racing writer's committed row, are planner facts; the
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.57'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.59'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');