@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.
Files changed (51) hide show
  1. package/.bongos-core.json +98 -48
  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 +8 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0194-joinability-one-door-decision.md +42 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +159 -4
  10. package/docs/api-reference.md +7 -4
  11. package/docs/copy-inventory.md +252 -233
  12. package/docs/copy-registry.json +503 -301
  13. package/modules/builder-settings/profile-nudge-prefs.js +125 -0
  14. package/modules/builder-settings/routes/settings.js +48 -0
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
  16. package/modules/government/catalog.js +1 -1
  17. package/modules/hall-ui/public/builders.js +5 -3
  18. package/modules/hall-ui/public/goal-inbox.js +10 -0
  19. package/modules/hall-ui/public/hall-render.js +63 -22
  20. package/modules/hall-ui/public/index.html +4 -0
  21. package/modules/hall-ui/public/my-projects.js +6 -1
  22. package/modules/hall-ui/public/profile-nudge.js +122 -0
  23. package/modules/hall-ui/public/watch.css +8 -0
  24. package/modules/hall-ui/public/watch.js +56 -3
  25. package/modules/onboarding/module.json +1 -1
  26. package/modules/onboarding/routes/access-requests.js +87 -1
  27. package/modules/platform-identity/routes/my-projects.js +35 -9
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/public-landing/projects.html +169 -3
  31. package/scripts/gds/fitness-checks-write-validation.js +1 -0
  32. package/scripts/gds/login.js +24 -3
  33. package/scripts/gds/ship-land.js +94 -3
  34. package/src/bongos/auth-admission.js +50 -5
  35. package/src/bongos/auth.js +2 -1
  36. package/src/bongos/routes/auth.js +15 -0
  37. package/src/branding.js +3 -2
  38. package/src/module-api.js +22 -1
  39. package/tests/access_requests_invite.mjs +165 -0
  40. package/tests/auth_admission_gate.mjs +42 -0
  41. package/tests/join_relay_outcome.mjs +61 -0
  42. package/tests/joinability_apply.mjs +72 -0
  43. package/tests/joinability_invite_only.mjs +192 -0
  44. package/tests/joinability_open.mjs +89 -0
  45. package/tests/module_api.mjs +1 -0
  46. package/tests/profile_nudge_prefs.mjs +97 -0
  47. package/tests/projects_hub.mjs +76 -2
  48. package/tests/projects_hub_pre_uat.mjs +4 -2
  49. package/tests/public_landing_projects.mjs +10 -3
  50. package/tests/ship_push_diagnosis.mjs +78 -0
  51. package/tests/sound_player.mjs +38 -0
@@ -0,0 +1,125 @@
1
+ // modules/builder-settings/profile-nudge-prefs.js — the "Complete your profile"
2
+ // nudge dismissal (task 1002802 / P06, goal 1000045).
3
+ //
4
+ // WHY A KNOB AND NOT A WIZARD. Decision 2 of the builder-profile spec
5
+ // (docs/specs/bongos-v1-builder-profile-page.md) rejected a profile-setup
6
+ // wizard: profiles are auto-created at first sign-in and enriched
7
+ // progressively, and an interstitial between sign-in and the hall adds friction
8
+ // at exactly the drop-off point. The replacement is a DISMISSIBLE line item in
9
+ // the needs-attention inbox — the hall's placement rule (modules/hall-ui/
10
+ // CLAUDE.md → Placement: an action awaiting the builder is an inbox line, never
11
+ // a new Home card).
12
+ //
13
+ // Dismissible has to mean dismissed-for-good, across devices: a builder who has
14
+ // decided not to write a bio should not be asked again on their laptop. So the
15
+ // flag is SERVER-side, not localStorage.
16
+ //
17
+ // Storage: one reserved key ('profile_nudge_dismissed') inside the
18
+ // `behavior_prefs` jsonb on `builders` (migration 119 — the same blob the
19
+ // wandering/render/interaction knobs use), so this adds NO migration. Value is
20
+ // a scalar boolean; a missing key means "not dismissed", which keeps the column
21
+ // sparse. applyPatch leaves sibling keys alone.
22
+ //
23
+ // This module is the single source of truth: GET/PATCH /api/gds/me/profile-nudge
24
+ // read and write it, and the hall's attention-card slice reads the resolved
25
+ // boolean to decide whether to render the line at all.
26
+
27
+ const RESERVED_KEY = 'profile_nudge_dismissed';
28
+ const DEFAULT_DISMISSED = false; // never dismissed until the builder says so
29
+
30
+ // ---- pure helpers ----
31
+
32
+ // The raw override a builder has stored, or null when unset/invalid. Kept
33
+ // separate from the effective value so a caller can tell "chose to keep it" from
34
+ // "never answered" (mirrors render-prefs' overrideEnabled).
35
+ function overrideDismissed(behaviorPrefs) {
36
+ const raw = behaviorPrefs && behaviorPrefs[RESERVED_KEY];
37
+ return typeof raw === 'boolean' ? raw : null;
38
+ }
39
+
40
+ // The resolved boolean — the value the hall acts on.
41
+ function nudgeDismissed(behaviorPrefs) {
42
+ const o = overrideDismissed(behaviorPrefs);
43
+ return o === null ? DEFAULT_DISMISSED : o;
44
+ }
45
+
46
+ // Which profile fields are still empty. PURE over the two inputs the nudge
47
+ // cares about, so the decision is testable without a DB or an HTTP round-trip.
48
+ // `bio` lives on the platform-identity account (hub-side), `preferred_disciplines`
49
+ // on `builders` — the caller passes both; this file never reaches for either.
50
+ // Whitespace-only bio counts as empty: a space is not a bio.
51
+ function missingProfileFields({ bio, preferredDisciplines } = {}) {
52
+ const missing = [];
53
+ if (typeof bio !== 'string' || bio.trim() === '') missing.push('bio');
54
+ if (!Array.isArray(preferredDisciplines) || preferredDisciplines.length === 0) missing.push('disciplines');
55
+ return missing;
56
+ }
57
+
58
+ // Should the nudge render? Only when something is actually missing AND the
59
+ // builder has not dismissed it. The two halves are deliberately separate above
60
+ // so a caller can say WHAT is missing even when the nudge is dismissed.
61
+ function shouldNudge({ behaviorPrefs, bio, preferredDisciplines } = {}) {
62
+ if (nudgeDismissed(behaviorPrefs)) return false;
63
+ return missingProfileFields({ bio, preferredDisciplines }).length > 0;
64
+ }
65
+
66
+ // Everything the API + the hall slice need in one object.
67
+ function describeForApi(behaviorPrefs, { bio, preferredDisciplines } = {}) {
68
+ const missing = missingProfileFields({ bio, preferredDisciplines });
69
+ return {
70
+ dismissed: nudgeDismissed(behaviorPrefs),
71
+ default_dismissed: DEFAULT_DISMISSED,
72
+ override: overrideDismissed(behaviorPrefs),
73
+ missing,
74
+ show: !nudgeDismissed(behaviorPrefs) && missing.length > 0,
75
+ };
76
+ }
77
+
78
+ // Validate a PATCH body: { dismissed: <bool> | null }. `null` clears the
79
+ // override (back to not-dismissed, so the nudge can return — the "undismiss"
80
+ // path a Settings toggle would use). Unknown keys / non-booleans → 422. An empty
81
+ // body is a valid no-op.
82
+ function validatePatch(body) {
83
+ if (body == null || typeof body !== 'object' || Array.isArray(body)) {
84
+ return { ok: false, errors: [{ error: 'body_must_be_object' }] };
85
+ }
86
+ const errors = [];
87
+ const patch = {};
88
+ for (const [key, value] of Object.entries(body)) {
89
+ if (key !== 'dismissed') {
90
+ errors.push({ key, error: 'unknown_key', allowed: ['dismissed'] });
91
+ continue;
92
+ }
93
+ if (value === null || typeof value === 'boolean') {
94
+ patch[RESERVED_KEY] = value; // null = clear → default (not dismissed)
95
+ continue;
96
+ }
97
+ errors.push({ key, error: 'invalid_value', value, allowed: 'boolean or null' });
98
+ }
99
+ if (errors.length > 0) return { ok: false, errors };
100
+ return { ok: true, patch };
101
+ }
102
+
103
+ // Apply a validated patch to a builder's existing behavior_prefs. Pure. null
104
+ // deletes the key; a boolean overwrites. Sibling keys (wandering, widgets,
105
+ // interaction) are left untouched.
106
+ function applyPatch(existing, patch) {
107
+ const next = { ...(existing || {}) };
108
+ for (const [key, value] of Object.entries(patch)) {
109
+ if (value === null) delete next[key];
110
+ else next[key] = value;
111
+ }
112
+ return next;
113
+ }
114
+
115
+ module.exports = {
116
+ RESERVED_KEY,
117
+ DEFAULT_DISMISSED,
118
+ overrideDismissed,
119
+ nudgeDismissed,
120
+ missingProfileFields,
121
+ shouldNudge,
122
+ describeForApi,
123
+ validatePatch,
124
+ applyPatch,
125
+ };
@@ -28,6 +28,7 @@ const wanderingPrefs = require('../wandering-prefs');
28
28
  const interactionPrefs = require('../interaction-prefs');
29
29
  const modelAllocPrefs = require('../model-alloc-prefs');
30
30
  const renderPrefs = require('../render-prefs');
31
+ const profileNudgePrefs = require('../profile-nudge-prefs');
31
32
  const displayName = require('../display-name');
32
33
  // task 1003208: structured logging (pino via the doorway) — was console.*.
33
34
  const log = api.logger('builder-settings');
@@ -378,6 +379,53 @@ module.exports = function buildBuilderSettingsRouter() {
378
379
  }
379
380
  });
380
381
 
382
+ // The "Complete your profile" nudge dismissal (task 1002802 / P06). The nudge
383
+ // itself is a DISMISSIBLE line item in the hall's needs-attention inbox — the
384
+ // replacement for the profile-setup wizard Decision 2 rejected. These two
385
+ // routes own only the dismissal FLAG; whether anything is actually missing is
386
+ // composed by the caller, which already holds both inputs (preferred_disciplines
387
+ // from GET /me, bio from GET /me/cross-project — bio lives on the platform
388
+ // identity account, and this module must never reach across to that one).
389
+ //
390
+ // rank: any-builder — own profile config; same posture as /me/render.
391
+ router.get('/me/profile-nudge', api.requireBuilder, async (req, res) => {
392
+ try {
393
+ // preferred_disciplines is NOT on req.builder (requireBuilder attaches an
394
+ // identity+rank subset), so read the row through the doorway — the same
395
+ // getBuilderById the wandering/interaction routes above use.
396
+ const [stored, builder] = await Promise.all([
397
+ db.getBuilderBehaviorPrefs(req.builder.id),
398
+ api.getBuilderById(req.builder.id),
399
+ ]);
400
+ res.json(profileNudgePrefs.describeForApi(stored, {
401
+ preferredDisciplines: builder && builder.preferred_disciplines,
402
+ }));
403
+ } catch (err) {
404
+ log.error('GET /me/profile-nudge', err);
405
+ res.fail('read_failed', { status: 500, message: 'internal error' });
406
+ }
407
+ });
408
+
409
+ // PATCH the dismissal. Body: { dismissed: <bool> | null } — null clears the
410
+ // override so the nudge can come back (the undismiss path). Sibling behavior
411
+ // keys are preserved by applyPatch.
412
+ router.patch('/me/profile-nudge', api.requireBuilder, async (req, res) => {
413
+ try {
414
+ const v = profileNudgePrefs.validatePatch(req.body || {});
415
+ if (!v.ok) return res.fail('invalid_patch', 422, { errors: v.errors });
416
+ const existing = await db.getBuilderBehaviorPrefs(req.builder.id);
417
+ const next = profileNudgePrefs.applyPatch(existing, v.patch);
418
+ const saved = await db.setBuilderBehaviorPrefs(req.builder.id, next);
419
+ const builder = await api.getBuilderById(req.builder.id);
420
+ res.json(profileNudgePrefs.describeForApi(saved, {
421
+ preferredDisciplines: builder && builder.preferred_disciplines,
422
+ }));
423
+ } catch (err) {
424
+ log.error('PATCH /me/profile-nudge', err);
425
+ res.fail('write_failed', { status: 500, message: 'internal error' });
426
+ }
427
+ });
428
+
381
429
  // Per-builder DISPLAY NAME (task 1669). The free label shown next to a
382
430
  // builder's work in the hall (header, leaderboard, work feed — all already read
383
431
  // display_name, so a DB update propagates). Seeded once from GitHub at account
@@ -90,6 +90,8 @@ function createClient(opts = {}) {
90
90
  postAccessRequests: (args) => request("POST", "/access-requests", { hasBody: true }, args),
91
91
  // PATCH /access-requests/{id} — rank: archon — PATCH /access-requests/:id
92
92
  patchAccessRequestsId: (args) => request("PATCH", "/access-requests/{id}", { hasBody: true }, args),
93
+ // POST /access-requests/invite — rank: archon — POST /access-requests/invite
94
+ postAccessRequestsInvite: (args) => request("POST", "/access-requests/invite", { hasBody: true }, args),
93
95
  // GET /access-requests/status — rank: public — GET /access-requests/status
94
96
  getAccessRequestsStatus: (args) => request("GET", "/access-requests/status", { hasBody: false }, args),
95
97
  },
@@ -500,6 +502,10 @@ function createClient(opts = {}) {
500
502
  patchMeModelAllocation: (args) => request("PATCH", "/me/model-allocation", { hasBody: true }, args),
501
503
  // PATCH /me/profile — rank: any-builder — PATCH /me/profile
502
504
  patchMeProfile: (args) => request("PATCH", "/me/profile", { hasBody: true }, args),
505
+ // GET /me/profile-nudge — rank: any-builder — GET /me/profile-nudge
506
+ getMeProfileNudge: (args) => request("GET", "/me/profile-nudge", { hasBody: false }, args),
507
+ // PATCH /me/profile-nudge — rank: any-builder — PATCH /me/profile-nudge
508
+ patchMeProfileNudge: (args) => request("PATCH", "/me/profile-nudge", { hasBody: true }, args),
503
509
  // GET /me/recent-ships — rank: any-builder — GET /me/recent-ships
504
510
  getMeRecentShips: (args) => request("GET", "/me/recent-ships", { hasBody: false }, args),
505
511
  // GET /me/render — rank: any-builder — GET /me/render
@@ -127,7 +127,7 @@ const PERMISSIONS = [
127
127
  // one of them would make this delegation self-reversible.
128
128
  { key: 'builder.status.set', system: false, floor: 'archon', guards: 'PATCH /builders/:id/status — offboard / (de)activate' },
129
129
  { key: 'auth.session.revoke', system: false, floor: 'archon', guards: 'POST /auth/revoke, /auth/revoke-all' },
130
- { key: 'access_request.review', system: false, floor: 'archon', guards: 'GET/PATCH /access-requests/:id — admit a builder' },
130
+ { key: 'access_request.review', system: false, floor: 'archon', guards: 'GET/PATCH /access-requests/:id, POST /access-requests/invite — admit a builder' },
131
131
 
132
132
  // ── operational — floor metic (the working rank's review/triage/authoring) ────
133
133
  { key: 'idea.triage', system: false, floor: 'metic', guards: 'PATCH /inbox/:id' },
@@ -79,9 +79,11 @@
79
79
  let code = null;
80
80
  try {
81
81
  const j = res.data;
82
- // Prefer the human-facing message; fall back to the machine code.
83
- detail = j.message || j.detail || j.error || detail;
84
- code = j.error || null;
82
+ // Prefer the human-facing message; fall back to the machine code. The
83
+ // envelope (ADR 0116) nests both under `error`; older handlers answer flat.
84
+ const env = j.error && typeof j.error === 'object' ? j.error : null;
85
+ detail = (env && env.message) || j.message || j.detail || (typeof j.error === 'string' ? j.error : detail);
86
+ code = (env && env.code) || (typeof j.error === 'string' ? j.error : null);
85
87
  } catch (_) { /* keep default */ }
86
88
  const err = new Error(detail);
87
89
  err.status = res.status;
@@ -273,6 +273,13 @@
273
273
  host.innerHTML =
274
274
  '<h2 id="attention-h" class="scroll__h">Needs your attention<span class="count-pill" id="attention-count" hidden></span></h2>' +
275
275
  '<div id="attention-goals"></div>' +
276
+ // task 1002802 (P06): "Complete your profile" — a DISMISSIBLE nudge when
277
+ // bio or preferred_disciplines are empty. Rendered by builders.js (which
278
+ // already holds the /me state the condition reads), exactly like the
279
+ // ideas slice. Placement rule: an action awaiting the builder is an inbox
280
+ // LINE, never a new Home card — that is why the rejected profile wizard
281
+ // (task 1002783) became this.
282
+ '<div id="attention-profile"></div>' +
276
283
  '<div id="attention-ideas"></div>' +
277
284
  // task 1003066 (BV1.R05): routed filings that SOFT-LANDED — a reported bug
278
285
  // now sitting at 'backlog' in a real goal, one Metic+ nod from claimable.
@@ -284,6 +291,9 @@
284
291
  host.addEventListener('click', onClick);
285
292
  load();
286
293
  loadNodQueue();
294
+ // task 1002802 (P06): the profile slice lives in its own file (builders.js is
295
+ // over the size budget); this card owns its div, so it boots it here.
296
+ window.OTBProfileNudge?.load();
287
297
  }
288
298
 
289
299
  // The card's shared visibility contract: any slice renderer calls refresh()
@@ -238,23 +238,43 @@
238
238
  else standingRail = window.OTBKit.makeSectionRail({ mount: rail, items, mode: 'scrollspy' });
239
239
  }
240
240
  }
241
- function renderLanding() {
242
- // task 1723: collapse the shell nav to the signed-out minimum (Home + a
243
- // Sign-in button). Cosmetic — the server gates every page regardless.
244
- window.OTBShell?.applyAccess({ authed: false });
245
-
246
- const welcome = $('#welcome-line');
247
- if (welcome) welcome.textContent = '';
241
+ // The project's door, as the platform pushed it into this instance's pack
242
+ // (window.__BRANDING__.project.joinability, task 1003044): what a stranger is
243
+ // told to do next depends on it. An older pack reports none → 'apply', today's
244
+ // behaviour. Cosmetic — the server refuses on its own terms either way.
245
+ function joinabilityMode() {
246
+ const p = window.__BRANDING__ && window.__BRANDING__.project;
247
+ const v = p && p.joinability;
248
+ return v === 'open' || v === 'invite_only' ? v : 'apply';
249
+ }
248
250
 
249
- const landing = $('#landing-root');
250
- if (landing) {
251
- landing.hidden = false;
252
- landing.innerHTML = `
253
- <div class="scroll landing">
254
- <h2 class="scroll__h">Welcome</h2>
251
+ // One landing per door: open → sign in and you're in; invite_only → nothing to
252
+ // apply for, sign in once invited; apply → the request form.
253
+ function landingDoorHtml(mode) {
254
+ const hub = `This is the builders' hub for ${brandWorld()} — where builders claim work, earn ${curLabel()}, and track their standing.`;
255
+ const admitter = escapeHtml(rankLabel('archon'));
256
+ const signIn = `<p class="landing__cta"><a class="landing__btn" href="${API}/auth/web/start">Sign in with GitHub</a></p>`;
257
+ if (mode === 'open') {
258
+ return `
259
+ <p class="landing__lede">${hub} Anyone with a GitHub account can join: sign in and you're in, starting at the newcomer rank.</p>
260
+ <div class="landing__request" id="landing-request">
261
+ <h3 class="landing__request-h">New here — sign in to join</h3>
262
+ <p class="landing__request-lede">No request and no invite needed. Sign in with GitHub, and a step-by-step guide takes you the rest of the way.</p>
263
+ ${signIn}
264
+ </div>`;
265
+ }
266
+ if (mode === 'invite_only') {
267
+ return `
268
+ <p class="landing__lede">${hub} This project is invite-only: the ${admitter} invites each builder, and there is nothing to apply for here.</p>
269
+ <div class="landing__request" id="landing-request">
270
+ <h3 class="landing__request-h">Invited? Sign in</h3>
271
+ <p class="landing__request-lede">If the ${admitter} has invited you, sign in with GitHub and you're in. If not, ask them for an invite — this project doesn't take requests.</p>
272
+ ${signIn}
273
+ </div>`;
274
+ }
275
+ return `
255
276
  <p class="landing__lede">
256
- This is the builders' hub for ${brandWorld()} — where builders claim
257
- work, earn ${curLabel()}, and track their standing. Access is
277
+ ${hub} Access is
258
278
  invite-based: an Archon approves each new builder. To begin, request
259
279
  access below.
260
280
  </p>
@@ -290,7 +310,24 @@
290
310
  <button type="submit" class="landing__btn landing__btn--request" id="ar-submit">Request access</button>
291
311
  <p class="landing__request-status" id="ar-status" aria-live="polite"></p>
292
312
  </form>
293
- </div>
313
+ </div>`;
314
+ }
315
+
316
+ function renderLanding() {
317
+ // task 1723: collapse the shell nav to the signed-out minimum (Home + a
318
+ // Sign-in button). Cosmetic — the server gates every page regardless.
319
+ window.OTBShell?.applyAccess({ authed: false });
320
+
321
+ const welcome = $('#welcome-line');
322
+ if (welcome) welcome.textContent = '';
323
+
324
+ const landing = $('#landing-root');
325
+ if (landing) {
326
+ landing.hidden = false;
327
+ landing.innerHTML = `
328
+ <div class="scroll landing">
329
+ <h2 class="scroll__h">Welcome</h2>
330
+ ${landingDoorHtml(joinabilityMode())}
294
331
 
295
332
  <p class="landing__signin-note">
296
333
  Already a member? <a href="${API}/auth/web/start">Sign in with GitHub</a>.
@@ -346,15 +383,19 @@
346
383
  </p>`;
347
384
  }
348
385
  } catch (err) {
349
- // Already known to the city — they don't need a request, they need to
350
- // sign in. Both the "already a builder" and "already approved" cases
351
- // collapse the form into a sign-in prompt rather than a red error.
352
- if (err && (err.code === 'already_member' || err.code === 'already_approved')) {
386
+ // The door's own answers — already a builder, already approved, or a
387
+ // project that takes no requests (invite-only) / needs none (open) —
388
+ // collapse the form into what to do instead: sign in. Never a red error.
389
+ const code = err && err.code;
390
+ if (code === 'already_member' || code === 'already_approved' || code === 'invite_only' || code === 'open_enrollment') {
353
391
  const req = $('#landing-request');
354
392
  if (req) {
393
+ const heading = code === 'invite_only' ? 'This project is invite-only'
394
+ : code === 'open_enrollment' ? 'No request needed'
395
+ : 'You already have access ✓';
355
396
  req.innerHTML = `
356
- <h3 class="landing__request-h">You already have access ✓</h3>
357
- <p class="landing__request-lede">${escapeHtml(err.message || 'You already have access.')}</p>
397
+ <h3 class="landing__request-h">${heading}</h3>
398
+ <p class="landing__request-lede">${escapeHtml(err.message || 'Sign in with GitHub to continue.')}</p>
358
399
  <p class="landing__cta">
359
400
  <a class="landing__btn" href="${API}/auth/web/start">Sign in with GitHub</a>
360
401
  </p>`;
@@ -123,6 +123,10 @@
123
123
  hall-widget tags the server injects still anchor on builders.js's tag
124
124
  below (it matches by filename), so they land between the two. -->
125
125
  <script src="/builders/hall-render.js?v=2026-08-24-hallrender1" defer></script>
126
+ <!-- task 1002802 (P06): the profile-completion nudge slice. Exposes
127
+ window.OTBProfileNudge; goal-inbox.js's mount() calls it once it has
128
+ created the #attention-profile div, so load order is not load-bearing. -->
129
+ <script src="/builders/profile-nudge.js?v=2026-08-25-profile-nudge" defer></script>
126
130
  <script src="/builders/builders.js?v=2026-06-27-r75" defer></script>
127
131
  <script src="/builders/copy-on-click.js?v=2026-06-01-copy-1" defer></script>
128
132
  </body>
@@ -130,7 +130,12 @@
130
130
  .then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
131
131
  .then(function (res) {
132
132
  joinBtn.disabled = false;
133
- if (res.ok && res.body && res.body.message) { joinNote.textContent = res.body.message; joinInput.value = ''; load(); }
133
+ if (res.ok && res.body && res.body.message) {
134
+ joinNote.textContent = res.body.message;
135
+ /* a door refusal rides a 200 (task 1003044): keep the address, and don't repaint as if a request went out */
136
+ var refused = res.body.status === 'invite_only' || res.body.status === 'open_enrollment' || res.body.status === 'members_only';
137
+ if (!refused) { joinInput.value = ''; load(); }
138
+ }
134
139
  else if (res.body && res.body.error && res.body.error.code === 'unknown_project') { joinNote.textContent = 'That project isn’t on the platform.'; }
135
140
  else { joinNote.textContent = 'Could not request access right now.'; }
136
141
  })
@@ -0,0 +1,122 @@
1
+ // profile-nudge.js — the "Complete your profile" slice of the hall's
2
+ // "Needs your attention" card (task 1002802 / P06, goal 1000045).
3
+ //
4
+ // Its own file rather than a block inside builders.js: the slice needs no state
5
+ // from that file (both its inputs come from its own two fetches), and builders.js
6
+ // is already far over the 1,500-line budget the quality ratchets enforce.
7
+ //
8
+ // goal-inbox.js owns the CARD (id 'attention-scroll') and creates this slice's
9
+ // div (#attention-profile); card visibility + the headline count derive from
10
+ // every slice via window.OTBAttention.refresh().
11
+ (function () {
12
+ 'use strict';
13
+ if (!window.OTB) return;
14
+ const { escapeHtml } = window.OTB;
15
+ const API = '/api/bongos';
16
+ // Result-mode client: a 401/404 must be inspectable control flow, never a throw
17
+ // that breaks a sibling slice (the goal-inbox.js pattern).
18
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
19
+
20
+ async function getJSON(path) {
21
+ const res = await api.request('GET', path);
22
+ if (!res.ok) { const e = new Error(`${path} → ${res.status}`); e.status = res.status; throw e; }
23
+ return res.data;
24
+ }
25
+ async function patchJSON(path, body) {
26
+ const res = await api.request('PATCH', path, { body: body || {} });
27
+ if (!res.ok) { const e = new Error(`${path} → ${res.status}`); e.status = res.status; throw e; }
28
+ return res.data;
29
+ }
30
+
31
+
32
+ // Renders the PROFILE slice of the "Needs your attention" card. goal-inbox.js
33
+ // owns the card (id 'attention-scroll') + the slice div; visibility and the
34
+ // headline count derive from every slice via OTBAttention.
35
+ //
36
+ // WHY A LINE AND NOT A CARD OR A WIZARD: spec Decision 2 rejected a
37
+ // profile-setup wizard (profiles are auto-created and enriched
38
+ // progressively; an interstitial after sign-in adds friction at the drop-off
39
+ // point). The hall's placement rule then puts an action awaiting the builder
40
+ // in the inbox as a LINE, never a new Home card.
41
+ //
42
+ // The two inputs live in different places — preferred_disciplines on the
43
+ // builder row (already in the /me payload this file holds) and bio on the
44
+ // platform-identity account (GET /me/cross-project) — so the SHOW decision is
45
+ // composed here. The server owns only the dismissal flag.
46
+ // Uses .ginbox__item so OTBAttention.refresh() counts it with no selector change.
47
+ function renderProfileNudge(state) {
48
+ const box = document.getElementById('attention-profile');
49
+ if (!box) return;
50
+ if (!state || !state.show) {
51
+ box.innerHTML = '';
52
+ window.OTBAttention?.refresh();
53
+ return;
54
+ }
55
+ // Name what is actually missing: "add a bio" is actionable, "complete your
56
+ // profile" is a chore of unknown size.
57
+ const missing = Array.isArray(state.missing) ? state.missing : [];
58
+ const parts = [];
59
+ if (missing.includes('bio')) parts.push('a short bio');
60
+ if (missing.includes('disciplines')) parts.push('the kinds of work you want');
61
+ const what = parts.length === 2 ? `${parts[0]} and ${parts[1]}` : (parts[0] || 'a detail or two');
62
+ box.innerHTML = '<h3 class="ginbox__sec-h">Complete your profile</h3>'
63
+ + '<ul class="ginbox">'
64
+ + '<li class="ginbox__item">'
65
+ // .ginbox__body / .ginbox__actions are the classes style.css actually
66
+ // defines (the body is the flex column that stacks label over sub-line).
67
+ + '<div class="ginbox__body">'
68
+ + `<a class="ginbox__goal" href="/settings">Add ${escapeHtml(what)}</a>`
69
+ + '<span class="ginbox__sub">Shown on your public profile — it helps others know what you work on.</span>'
70
+ + '</div>'
71
+ + '<div class="ginbox__actions">'
72
+ + '<button class="btn-ghost" type="button" data-profile-nudge-dismiss="1">Dismiss</button>'
73
+ + '</div>'
74
+ + '</li></ul>';
75
+ window.OTBAttention?.refresh();
76
+ }
77
+
78
+ // Fetch the dismissal flag + the bio in parallel, then compose. Any failure
79
+ // renders NOTHING (a nudge is the most skippable thing on the page — it must
80
+ // never surface an error card or break a sibling slice).
81
+ async function loadProfileNudge() {
82
+ try {
83
+ const [nudge, cross] = await Promise.all([
84
+ getJSON(`${API}/me/profile-nudge`),
85
+ getJSON(`${API}/me/cross-project`).catch(() => null),
86
+ ]);
87
+ if (!nudge) return;
88
+ // The server computed `missing` from preferred_disciplines only (bio is not
89
+ // its to read — different module). Fold the bio in here.
90
+ const missing = Array.isArray(nudge.missing) ? nudge.missing.slice() : [];
91
+ const bio = cross && typeof cross.bio === 'string' ? cross.bio : '';
92
+ const bioMissing = bio.trim() === '';
93
+ // A null cross-project (older server, or a principal with no platform
94
+ // identity) means "cannot tell" — do not invent a missing bio from it.
95
+ if (bioMissing && cross && !missing.includes('bio')) missing.push('bio');
96
+ if (!bioMissing) { const i = missing.indexOf('bio'); if (i !== -1) missing.splice(i, 1); }
97
+ renderProfileNudge({ ...nudge, missing, show: !nudge.dismissed && missing.length > 0 });
98
+ } catch (_) {
99
+ /* signed out, older server, or a hiccup: the nudge simply does not appear */
100
+ }
101
+ }
102
+
103
+ // task 1002802 (P06): dismiss the profile nudge. Delegated for the same reason
104
+ // as the triage verb above — the slice re-renders. Optimistic: the line goes
105
+ // away immediately (a dismissal that appears to fail is worse than one that
106
+ // silently retries), and the server flag makes it stay gone on every device.
107
+ document.addEventListener('click', (e) => {
108
+ const btn = e.target.closest('[data-profile-nudge-dismiss]');
109
+ if (!btn) return;
110
+ btn.disabled = true;
111
+ renderProfileNudge({ show: false });
112
+ patchJSON(`${API}/me/profile-nudge`, { dismissed: true })
113
+ .catch(() => loadProfileNudge()); // write failed → put the line back, honestly
114
+ });
115
+
116
+ // The CARD OWNER boots its slices: goal-inbox.js's mount() creates
117
+ // #attention-profile and then calls this, exactly as it calls its own load()
118
+ // and loadNodQueue(). No DOMContentLoaded race and no polling for the div —
119
+ // the widget registry mounts asynchronously after /me resolves, so a
120
+ // load-time call would reliably find nothing.
121
+ window.OTBProfileNudge = { load: loadProfileNudge, render: renderProfileNudge };
122
+ })();
@@ -182,3 +182,11 @@ table.watch tr:hover td { background: var(--surface-2); }
182
182
  font-size: 13px; font-weight: 500;
183
183
  color: var(--accent-text);
184
184
  }
185
+
186
+ /* Invite by GitHub username (task 1003044) — the Access requests section's own
187
+ form: the way in on an invite-only project, a shortcut on an apply one. */
188
+ .watch-invite { margin: 0 0 14px; padding: 12px 14px; border: 1px solid var(--line, #e5e0d6); border-radius: 10px; }
189
+ .watch-invite__label { display: block; font-weight: 600; margin-bottom: 6px; }
190
+ .watch-invite__row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
191
+ .watch-invite__row input[type="text"] { flex: 1 1 200px; min-width: 0; }
192
+ .watch-invite__why { margin: 8px 0 0; font-size: 13px; opacity: .8; }
@@ -88,7 +88,11 @@
88
88
  const res = await api.request('POST', `${API}${path}`, { body: body || {} });
89
89
  if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
90
90
  const data = res.data || {};
91
- if (!res.ok) throw new Error(data.message || data.error || `${path} → ${res.status}`);
91
+ if (!res.ok) {
92
+ // the envelope (ADR 0116) nests the sentence under `error`; older handlers answer flat
93
+ const env = data.error && typeof data.error === 'object' ? data.error : null;
94
+ throw new Error((env && env.message) || data.message || (typeof data.error === 'string' ? data.error : '') || `${path} → ${res.status}`);
95
+ }
92
96
  return data;
93
97
  }
94
98
 
@@ -183,14 +187,62 @@
183
187
  // 1. GATE — access requests (pending)
184
188
  // =========================================================================
185
189
 
190
+ // Invite by GitHub username (task 1003044): admits someone BEFORE any request
191
+ // exists — the only way in on an invite-only project, where nobody can apply,
192
+ // and a shortcut on an apply one. POST /access-requests/invite writes the same
193
+ // 'invited' row the sign-in gate reads. The door the project runs comes from
194
+ // the pack the server injected (window.__BRANDING__.project.joinability).
195
+ function inviteFormHtml() {
196
+ const p = window.__BRANDING__ && window.__BRANDING__.project;
197
+ const mode = (p && p.joinability) || 'apply';
198
+ const why = mode === 'invite_only'
199
+ ? 'This project is invite-only — nobody can apply, so this is how people get in.'
200
+ : mode === 'open'
201
+ ? 'This project is open — anyone who signs in is admitted, so an invite is rarely needed.'
202
+ : 'Admit someone before they ask — once invited, their sign-in gets in.';
203
+ return (
204
+ '<form class="watch-invite" id="gate-invite" novalidate>' +
205
+ '<label class="watch-invite__label" for="gate-invite-login">Invite by GitHub username</label>' +
206
+ '<div class="watch-invite__row">' +
207
+ '<input type="text" id="gate-invite-login" name="github_login" autocomplete="off" placeholder="e.g. octocat" maxlength="40">' +
208
+ '<button type="submit" class="watch-btn">Invite</button>' +
209
+ '</div>' +
210
+ `<p class="watch-invite__why">${escapeHtml(why)}</p>` +
211
+ '</form>'
212
+ );
213
+ }
214
+
215
+ function wireInviteForm(target) {
216
+ const form = target.querySelector('#gate-invite');
217
+ if (!form) return;
218
+ form.addEventListener('submit', async (e) => {
219
+ e.preventDefault();
220
+ const input = form.querySelector('#gate-invite-login');
221
+ const login = ((input && input.value) || '').trim().replace(/^@/, '');
222
+ if (!login) { toast('A GitHub username is needed.'); return; }
223
+ const btn = form.querySelector('button[type="submit"]');
224
+ if (btn) btn.disabled = true;
225
+ try {
226
+ const r = await postJson('/access-requests/invite', { github_login: login });
227
+ toast(r.already_invited ? `@${login} is already invited.` : `@${login} invited — their sign-in gets in.`);
228
+ await loadGate();
229
+ } catch (err) {
230
+ if (err && err.kind === '401') return;
231
+ if (btn) btn.disabled = false;
232
+ toast(`Could not invite: ${err.message || err}`);
233
+ }
234
+ });
235
+ }
236
+
186
237
  function renderGate(data, target) {
187
238
  const requests = Array.isArray(data.requests) ? data.requests : [];
188
239
  setAttention('att-access', requests.length); // tile count = table content
189
240
  if (!requests.length) {
190
- target.innerHTML = emptyStateHtml('No pending access requests.');
241
+ target.innerHTML = inviteFormHtml() + emptyStateHtml('No pending access requests.');
242
+ wireInviteForm(target);
191
243
  return;
192
244
  }
193
- target.innerHTML = requests
245
+ target.innerHTML = inviteFormHtml() + requests
194
246
  .map((r) => {
195
247
  const who = r.display_name
196
248
  ? `${escapeHtml(r.display_name)} <span class="watch-mono">@${escapeHtml(r.github_login)}</span>`
@@ -221,6 +273,7 @@
221
273
  toast(status === 'invited' ? 'Builder invited.' : 'Request dismissed.');
222
274
  await loadGate();
223
275
  }, 'Could not resolve');
276
+ wireInviteForm(target);
224
277
  }
225
278
 
226
279
  function loadGate() {
@@ -2,7 +2,7 @@
2
2
  "key": "onboarding",
3
3
  "title": "Onboarding (admission · setup · newcomer floor)",
4
4
  "description": "The newcomer ADMISSION + ORIENTATION domain — the public access-request → Archon-approval gate, the server-ticked onboarding state machine (the 'Path into the City' checklist that graduates a Xenos to Thetes), the friction analyzer (feedback-without-asking on consented onboarding transcripts), and the evergreen newcomer-chore restock (a fresh first-builder task is always waiting). A core-domain module carved out of the GDS core in BV1.R81 (ADR 0093 §1); default-on because every instance onboards its own builders. The access-requests admission routes mount here; the core lifecycle (db.js claim/ship), auth callbacks, and the me/builders profile readers resolve the `onboarding` kernel PORT for the state-machine + restock surface they need — never importing the module.",
5
- "coreVersion": "^1.9.0",
5
+ "coreVersion": "^1.19.374",
6
6
  "default": true,
7
7
  "maintenance": { "status": "core-maintained" },
8
8
  "contributes": { "routes": ["onboarding"] },