@bongos/core 1.21.15 → 1.21.17

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.
@@ -354,6 +354,16 @@
354
354
  <p class="section-note">Changes save the moment you pick. “Default” clears your override and falls back to the system's choice.</p>
355
355
  </section>
356
356
 
357
+ <!-- SOUNDS OFF — every notification sound is switched off for now (task 1004554);
358
+ the two panels below stay in the page but never load. -->
359
+ <section class="scroll" id="sounds-off-scroll" aria-labelledby="sounds-off-h" data-group="sound" hidden>
360
+ <h2 id="sounds-off-h" class="scroll__h">Sounds</h2>
361
+ <p class="scroll__lede">
362
+ Sounds are switched off for now. Nothing plays when a task ships,
363
+ when {{currency}} arrive, or when Claude finishes or needs you.
364
+ </p>
365
+ </section>
366
+
357
367
  <!-- SOUNDS — per-sound on/off toggles (GET/PATCH /me/sound-prefs, #786). -->
358
368
  <section class="scroll" id="sounds-scroll" aria-labelledby="sounds-h" data-group="sound" hidden>
359
369
  <h2 id="sounds-h" class="scroll__h">Sounds</h2>
@@ -1240,10 +1240,12 @@
1240
1240
 
1241
1241
  // Voices loads independently — its own endpoint + its own try/catch.
1242
1242
  await loadVoices();
1243
- // Sounds loads independently too (per-sound on/off, #786).
1244
- await loadSounds();
1245
- // Event sounds loads independently (per-event variant picker, task 582).
1246
- await loadEventSounds();
1243
+ // Sounds are switched off for now (task 1004554 — .claude/sounds/play.js's
1244
+ // SOUNDS_ON). The per-sound toggles (#786) and the per-event picker (task 582)
1245
+ // would only offer choices that do nothing, so neither loads; the Sound group
1246
+ // shows the one-line note instead. To restore: call loadSounds() and
1247
+ // loadEventSounds() here again and drop the note.
1248
+ $('#sounds-off-scroll').hidden = false;
1247
1249
  // (The interaction profile panel self-boots from settings-interaction.js —
1248
1250
  // its own script on the settings-sessions.js precedent, task 1002514.)
1249
1251
  // Wandering loads independently too (off-task thread suppression, task 1268).
@@ -104,7 +104,7 @@
104
104
  <!-- the identity chip IS the profile trigger; paintBar fills it from GET /me.
105
105
  Signed out it stays hidden and the sign-in door takes its slot. -->
106
106
  <button class="trig who" type="button" id="who" data-menu="profile" aria-expanded="false" aria-controls="menuProfile" hidden><img id="whoImg" alt="" hidden><span class="whoName" id="whoLogin"></span><svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>
107
- <a class="signinDoor" id="signinTop" hidden href="/api/bongos/auth/web/start?return=/account">Sign in</a>
107
+ <a class="signinDoor" id="signinTop" hidden href="/api/bongos/auth/web/start?return=/account&amp;repos=1">Sign in</a>
108
108
  </div>
109
109
  </header>
110
110
 
@@ -138,7 +138,7 @@
138
138
  <section class="aDoor" id="door" hidden aria-labelledby="door-h">
139
139
  <h2 class="vh" id="door-h">Sign in</h2>
140
140
  <p>Sign in with GitHub to change your handle, your bio and links, and who can find you.</p>
141
- <a class="btn" id="signin" href="/api/bongos/auth/web/start?return=/account">
141
+ <a class="btn" id="signin" href="/api/bongos/auth/web/start?return=/account&amp;repos=1">
142
142
  <!-- keep the spaces inside this path's numbers: same shape, and they are load-bearing (task 1004401) -->
143
143
  <svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23 .82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2 .82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12 .51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29 .25.54.73 .54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15 .46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
144
144
  Sign in with GitHub
@@ -5,7 +5,7 @@
5
5
  "probes": [
6
6
  { "name": "signed out: the door, and sign-in returns to /account", "auth": false, "width": 1440,
7
7
  "steps": [["wait", 300]],
8
- "expect": { "visible": ["#door"], "hidden": ["#acctBody"], "href": [["#signin", "/api/bongos/auth/web/start?return=/account"]] } },
8
+ "expect": { "visible": ["#door"], "hidden": ["#acctBody"], "href": [["#signin", "/api/bongos/auth/web/start?return=/account&repos=1"]] } },
9
9
  { "name": "signed in: nothing changed saves nothing", "auth": true, "width": 1440,
10
10
  "steps": [["wait", 400], ["click", "#profileSave"], ["wait", 150]],
11
11
  "expect": { "text": [["#profileMsg", "Nothing has changed."]] } },
@@ -56,7 +56,7 @@
56
56
  "expect": { "closed": "#menuProjects" } },
57
57
  { "name": "signed out: no chip, the sign-in door in its slot, returning to /account", "auth": false, "width": 1440,
58
58
  "steps": [["wait", 300]],
59
- "expect": { "hidden": ["#who"], "visible": ["#signinTop", "#projTrig"], "href": [["#signinTop", "/api/bongos/auth/web/start?return=/account"]] } },
59
+ "expect": { "hidden": ["#who"], "visible": ["#signinTop", "#projTrig"], "href": [["#signinTop", "/api/bongos/auth/web/start?return=/account&repos=1"]] } },
60
60
  { "name": "signed out the Projects band is Sky / Table / New project (no Your projects)", "continue": true,
61
61
  "steps": [["click", "#projTrig"], ["wait", 120]],
62
62
  "expect": { "open": "#menuProjects", "count": [["#menuProjects .bandCell:not([hidden])", 3]], "hidden": ["#mineCell"] } },
@@ -673,7 +673,7 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
673
673
  <!-- the identity slot: the sign-in door signed out; the chip (the profile trigger) once
674
674
  the session resolves — swapped client-side, never a wrong-state flash -->
675
675
  <span class="whoSlot" id="who">
676
- <a class="pill signin" href="/api/bongos/auth/web/start?return=/">Sign in</a>
676
+ <a class="pill signin" href="/api/bongos/auth/web/start?return=/&amp;repos=1">Sign in</a>
677
677
  </span>
678
678
  </div>
679
679
  </header>
@@ -705,7 +705,7 @@ summary{min-height:24px;padding:3px 0;}
705
705
  <!-- the identity chip IS the profile trigger; renderWho fills it. Signed
706
706
  out it does not exist and the sign-in door takes its slot. -->
707
707
  <button class="trig who" type="button" id="who" data-menu="profile" aria-expanded="false" aria-controls="menuProfile" hidden></button>
708
- <a class="signinDoor" id="signinTop" hidden href="/api/bongos/auth/web/start?return=/projects">Sign in</a>
708
+ <a class="signinDoor" id="signinTop" hidden href="/api/bongos/auth/web/start?return=/projects&amp;repos=1">Sign in</a>
709
709
  </div>
710
710
  </header>
711
711
 
@@ -897,7 +897,7 @@ summary{min-height:24px;padding:3px 0;}
897
897
  /create, listing your own instances needs no repo scope) -->
898
898
  <section id="gate" hidden>
899
899
  <p class="lede">Every project you own, in one place — its live status, its address, and the controls that matter. Sign in with GitHub to see yours.</p>
900
- <a class="btn" id="signin" href="/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine">
900
+ <a class="btn" id="signin" href="/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine&amp;repos=1">
901
901
  <svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
902
902
  Sign in with GitHub
903
903
  </a>
@@ -999,7 +999,7 @@ summary{min-height:24px;padding:3px 0;}
999
999
  <!-- signed out: the same identity-only door the mine view uses (ADR 0143) -->
1000
1000
  <section id="mGate" class="card" hidden>
1001
1001
  <p class="lede">This is a project’s management page — it answers only to the person who owns it. Sign in with GitHub to open yours.</p>
1002
- <a class="btn" id="mSignin" href="/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine">
1002
+ <a class="btn" id="mSignin" href="/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine&amp;repos=1">
1003
1003
  <svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
1004
1004
  Sign in with GitHub
1005
1005
  </a>
@@ -1395,7 +1395,8 @@ summary{min-height:24px;padding:3px 0;}
1395
1395
  <!-- The wizard's own sign-in opts into repo scope (read:user + repo, ADR 0155) up
1396
1396
  front (&repos=1) — anyone here intends to create a project, which needs repo
1397
1397
  access, so we ask once at sign-in instead of a second "Connect" step later.
1398
- Hall + CLI sign-in stay identity-only (ADR 0143 least-privilege default). -->
1398
+ Every hub sign-in door does the same since ADR 0364; the hall + CLI
1399
+ sign-ins stay identity-only (ADR 0143 least-privilege default). -->
1399
1400
  <a class="btn big" id="wizSignin" href="/api/bongos/auth/web/start?return=/projects%3Fview%3Dnew&amp;repos=1">
1400
1401
  <svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
1401
1402
  Continue with GitHub
@@ -2494,11 +2495,12 @@ summary{min-height:24px;padding:3px 0;}
2494
2495
  else history.replaceState(null, '', '?view=' + v + idPart);
2495
2496
  } catch (e) {}
2496
2497
  if (v !== 'manage') document.title = BASE_TITLE;
2497
- /* the shell's sign-in door returns the visitor to the view they left, and
2498
- carries the repo scope when that view is the wizard (ADR 0143: the
2499
- create path asks once up front instead of a second consent at Home) */
2498
+ /* the shell's sign-in door returns the visitor to the view they left. Every
2499
+ sign-in door on the hub's own pages carries the repo scope (task 1004549,
2500
+ ADR 0364): it is asked once, at sign-in, and held for the whole session, so
2501
+ no flow ever stops halfway to ask for it */
2500
2502
  $('signinTop').href = '/api/bongos/auth/web/start?return=/projects%3Fview%3D' + v +
2501
- idPart.replace('&', '%26').replace('=', '%3D') + (v === 'new' ? '&repos=1' : '');
2503
+ idPart.replace('&', '%26').replace('=', '%3D') + '&repos=1';
2502
2504
  /* the mine view is the only one that polls; it re-reads on entry so a
2503
2505
  card that changed while you were on the map is not stale — including
2504
2506
  the membership + invitations lists (task 1003774), the same re-read
@@ -7190,8 +7192,15 @@ summary{min-height:24px;padding:3px 0;}
7190
7192
  function enter(focus) {
7191
7193
  if (authKnown && !me) { show(0, !!focus); return; }
7192
7194
  var created = authKnown ? createdRecord() : null;
7193
- if (created && created.id) { enterCreated(created, !!focus); return; }
7194
- paintResumeOffer(null);
7195
+ /* A draft part-way through the wizard (steps 1–5) beside a stored create is a
7196
+ SECOND project being typed. A reload or a GitHub round-trip (repo access) is
7197
+ that same visit continuing, so the draft keeps its place and the earlier
7198
+ project stays one click away on the resume line — it used to land on the
7199
+ earlier project's done panel, dropping the founder out of the draft
7200
+ (task 1004549). A deliberate nav still goes through enterCreated. */
7201
+ var draftInFlight = state.step >= 1 && state.step <= 5;
7202
+ if (created && created.id && (focus || !draftInFlight || resumeIntent)) { enterCreated(created, !!focus); return; }
7203
+ paintResumeOffer(created && created.id ? created : null);
7195
7204
  /* before identity settles, never render past step 1 (the fork and its terms
7196
7205
  tick, since task 1004504): deeper steps carry authenticated side effects (the Home-step repo
7197
7206
  probe), and onAuth() re-enters to the real draft step the moment /me answers */
@@ -7345,8 +7354,8 @@ summary{min-height:24px;padding:3px 0;}
7345
7354
  function jsonOrEmpty(res) { return res.json().catch(function () { return {}; }); }
7346
7355
 
7347
7356
  function signInAgain() {
7348
- /* from the wizard, the re-sign-in keeps the repo scope it already granted */
7349
- var back = currentView === 'new' ? '/projects%3Fview%3Dnew&amp;repos=1' : '/projects';
7357
+ /* the re-sign-in keeps the repo scope, like every hub sign-in door (ADR 0364) */
7358
+ var back = (currentView === 'new' ? '/projects%3Fview%3Dnew' : '/projects') + '&amp;repos=1';
7350
7359
  return 'Your session expired — <a href="' + API + '/auth/web/start?return=' + back + '">sign in again</a>.';
7351
7360
  }
7352
7361
 
@@ -7948,7 +7957,7 @@ summary{min-height:24px;padding:3px 0;}
7948
7957
  in hand so "checked Xm ago" keeps telling the truth (C6). ── */
7949
7958
 
7950
7959
  function manageSignin() {
7951
- return API + '/auth/web/start?return=' +
7960
+ return API + '/auth/web/start?repos=1&return=' +
7952
7961
  encodeURIComponent('/projects?view=manage&id=' + manageId);
7953
7962
  }
7954
7963
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.15",
3
+ "version": "1.21.17",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.15",
9
+ "version": "1.21.17",
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.15",
3
+ "version": "1.21.17",
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",
@@ -8846,5 +8846,17 @@
8846
8846
  "id": "1004512",
8847
8847
  "text": "Deploying a core update to a project that runs under its own account used to fail every time, blaming unsaved file changes that were never there. The update now writes files as the account that owns them and touches the databa"
8848
8848
  }
8849
+ ],
8850
+ "1.21.16": [
8851
+ {
8852
+ "id": "1004549",
8853
+ "text": "Signing in on cloudbongos.com now asks GitHub for repo access once and keeps it while you stay signed in, so project setup no longer stops to ask — and a trip to GitHub mid-setup brings you back exactly where you were."
8854
+ }
8855
+ ],
8856
+ "1.21.17": [
8857
+ {
8858
+ "id": "1004554",
8859
+ "text": "Bongos no longer plays any sounds: no ship bell, credits chime, fanfare or end-of-turn chime. Settings → Sound now just says they're off for now."
8860
+ }
8849
8861
  ]
8850
8862
  }
package/src/bongos/db.js CHANGED
@@ -253,16 +253,17 @@ async function getBuilderRoleChoices(builderIds) {
253
253
 
254
254
  // ---- GitHub OAuth repo token, encrypted at rest (core_192 / task 1002099) ----
255
255
  //
256
- // The onboarding wizard (public-landing/create.html) opts into `public_repo` at
257
- // sign-in so it can list + create the user's repos. That user-to-server GitHub
258
- // token is held here BRIEFLY: encrypted with secret-box (master key in server env
259
- // only — the plaintext never touches the DB) and given a short TTL (one wizard
260
- // sitting), read back by the repo picker/creator, then deleted on use. It is the
256
+ // The hub's sign-in doors opt into `repo` at sign-in so the create wizard can list +
257
+ // create the user's repos. That user-to-server GitHub token is held here: encrypted
258
+ // with secret-box (master key in server env only — the plaintext never touches the
259
+ // DB) for as long as the web session it arrived with (the callback passes the
260
+ // session's remaining life as ttlMs; task 1004549, ADR 0364), and deleted on web
261
+ // sign-out, on offboarding, or when GitHub says it was revoked. It is the
261
262
  // auth system's OWN handshake token (distinct from a feature's third-party key
262
263
  // like the art module's Gemini key, ADR 0102), so it lives in core auth. secret-box
263
264
  // is the gate: with no master key configured we NEVER store a plaintext token —
264
265
  // capture no-ops and the wizard degrades to manual repo entry (ADR 0143).
265
- const GITHUB_TOKEN_TTL_MS = 60 * 60 * 1000; // 1h — one wizard sitting.
266
+ const GITHUB_TOKEN_TTL_MS = 60 * 60 * 1000; // 1h — the fallback when no session expiry is known.
266
267
 
267
268
  // Store (or replace) the builder's GitHub repo token. `token` is the raw
268
269
  // user-to-server access token; it is encrypted here so the plaintext never touches
@@ -878,13 +878,18 @@ module.exports = function buildAuthRouter() {
878
878
  // every older name and the legacy apex-scoped copy, so a browser arriving
879
879
  // with either leaves holding only the host-only cookie.
880
880
  setSessionCookie(req, res, token, expiresAt);
881
- // Persist the wizard's repo token (task 1002099 / ADR 0143): encrypted at rest,
882
- // short-TTL, keyed to the builder — the repo picker/creator reads it back over
883
- // the next few minutes. Gated on secret-box being configured (upsertGithubToken
881
+ // Persist the repo token (task 1002099 / ADR 0143): encrypted at rest, keyed to
882
+ // the builder. It lives exactly as long as the session it rode in on (task
883
+ // 1004549, ADR 0364) — a builder who stays signed in is never asked again, and
884
+ // signing out drops it. Gated on secret-box being configured (upsertGithubToken
884
885
  // no-ops when it isn't). Best-effort: a capture failure must NEVER break sign-in.
885
886
  if (wantsRepos && githubToken) {
886
887
  try {
887
- await db.upsertGithubToken({ builderId: builder.id, token: githubToken, scope: githubScope });
888
+ const sessionLeft = expiresAt ? new Date(expiresAt).getTime() - Date.now() : 0;
889
+ await db.upsertGithubToken({
890
+ builderId: builder.id, token: githubToken, scope: githubScope,
891
+ ...(sessionLeft > 0 ? { ttlMs: sessionLeft } : {}),
892
+ });
888
893
  } catch (e) {
889
894
  log.error('[gds] web/callback: repo-token capture failed (non-blocking):', e.message);
890
895
  }
@@ -1157,6 +1162,14 @@ module.exports = function buildAuthRouter() {
1157
1162
  const token = (req.headers['authorization'] || '').replace(/^Bearer\s+/, '')
1158
1163
  || sessionCookieToken(req);
1159
1164
  if (token) await db.revokeSession(token);
1165
+ // A WEB sign-out also drops the stored repo token (task 1004549, ADR 0364): it is
1166
+ // held for as long as the builder stays signed in, and no longer. A CLI logout
1167
+ // (bearer header) leaves it — the browser session that granted it is still live.
1168
+ if (!req.headers['authorization'] && sessionCookieToken(req)) {
1169
+ await db.deleteGithubToken({ builderId: req.builder.id }).catch((e) => {
1170
+ log.error('[gds] auth/logout: repo-token delete failed (non-blocking):', e.message);
1171
+ });
1172
+ }
1160
1173
  // Clear every session name, host-only AND the legacy apex-scoped form — a
1161
1174
  // clearCookie only matches the cookie whose domain attribute it names, and a
1162
1175
  // sign-out that left one behind would re-authenticate the next request.
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.15'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.17'; // 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');
@@ -0,0 +1,110 @@
1
+ // tests/github_token_session_life.mjs — the repo token lives as long as the sign-in (task 1004549).
2
+ //
3
+ // Owner decision 2026-10-02 (ADR 0364): repo access is asked once, at sign-in, and stays with
4
+ // the builder for as long as they stay signed in. It used to be held for one hour, so an owner
5
+ // still signed in was sent back to GitHub from the middle of the create wizard for a grant
6
+ // GitHub already held. Two halves, both over the REAL auth router:
7
+ // - the callback stores the token with the session's remaining life as its TTL;
8
+ // - a web sign-out deletes it; a CLI logout (bearer header) leaves it alone.
9
+ // Only the GitHub code exchange, the session lookup and the token store are stubbed.
10
+ //
11
+ // Run: node --test tests/github_token_session_life.mjs
12
+
13
+ import { test, before, after } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import http from 'node:http';
16
+ import { createRequire } from 'node:module';
17
+ import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ // RFC 2606 reserved — synthetic, never routable, no host brand in this file.
22
+ const APEX = 'bongos-test.example';
23
+ const brandDir = mkdtempSync(join(tmpdir(), 'cb-gh-token-life-'));
24
+ const brandFile = join(brandDir, 'branding.json');
25
+ writeFileSync(brandFile, JSON.stringify({
26
+ envPrefix: 'GHTOKLIFE_FIXTURE',
27
+ domains: { cookieDomain: `.${APEX}`, publicOrigin: `https://${APEX}`, oauthOrigin: `https://${APEX}` },
28
+ }));
29
+ process.env.BONGOS_BRANDING_FILE = brandFile;
30
+ process.env.GHTOKLIFE_FIXTURE_GITHUB_CLIENT_ID = 'fixture-client';
31
+ process.env.GHTOKLIFE_FIXTURE_GITHUB_CLIENT_SECRET = 'fixture-secret';
32
+ const require = createRequire(import.meta.url);
33
+ for (const p of ['BONGOS', ...require('../src/instance-config.js').LEGACY_ENV_PREFIXES]) delete process.env[`${p}_PUBLIC_ORIGIN`];
34
+ process.on('exit', () => { try { rmSync(brandDir, { recursive: true, force: true }); } catch { /* best-effort */ } });
35
+
36
+ const express = require('express');
37
+ const auth = require('../src/bongos/auth.js');
38
+ const db = require('../src/bongos/db');
39
+
40
+ const SESSION_MS = 30 * 24 * 60 * 60 * 1000;
41
+ const stored = [];
42
+ const deleted = [];
43
+ const real = {
44
+ completeWebFlow: auth.completeWebFlow, requireBuilder: auth.requireBuilder,
45
+ upsertGithubToken: db.upsertGithubToken, deleteGithubToken: db.deleteGithubToken, revokeSession: db.revokeSession,
46
+ };
47
+ auth.completeWebFlow = async () => ({
48
+ token: 'tok' + 'a'.repeat(40),
49
+ expiresAt: new Date(Date.now() + SESSION_MS),
50
+ builder: { id: 7 },
51
+ githubToken: 'gho_fixture',
52
+ githubScope: 'read:user,repo',
53
+ });
54
+ // The router binds requireBuilder when it is BUILT, so it is swapped before the require below.
55
+ auth.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_id: '1', github_login: 'probe' }; next(); };
56
+ db.upsertGithubToken = async (args) => { stored.push(args); return { stored: true }; };
57
+ db.deleteGithubToken = async (args) => { deleted.push(args); return { deleted: true }; };
58
+ db.revokeSession = async () => {};
59
+ const buildAuthRouter = require('../src/bongos/routes/auth.js');
60
+
61
+ let server, port;
62
+ before(async () => {
63
+ const app = express();
64
+ app.set('trust proxy', true);
65
+ app.use('/api/bongos', buildAuthRouter());
66
+ server = http.createServer(app);
67
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
68
+ port = server.address().port;
69
+ });
70
+ after(() => { server.close(); Object.assign(auth, { completeWebFlow: real.completeWebFlow, requireBuilder: real.requireBuilder }); Object.assign(db, { upsertGithubToken: real.upsertGithubToken, deleteGithubToken: real.deleteGithubToken, revokeSession: real.revokeSession }); });
71
+
72
+ function request(method, path, headers = {}) {
73
+ return new Promise((resolve, reject) => {
74
+ const req = http.request({ host: '127.0.0.1', port, path, method, headers: { host: APEX, 'x-forwarded-proto': 'https', ...headers } }, (res) => {
75
+ let body = '';
76
+ res.on('data', (c) => { body += c; });
77
+ res.on('end', () => resolve({ status: res.statusCode, setCookies: [].concat(res.headers['set-cookie'] || []), body }));
78
+ });
79
+ req.on('error', reject);
80
+ req.end();
81
+ });
82
+ }
83
+ const pairs = (setCookies) => setCookies.map((c) => c.split(';')[0]).join('; ');
84
+
85
+ test('a repo sign-in stores the token for the life of the session, not one hour', async () => {
86
+ stored.length = 0;
87
+ const start = await request('GET', '/api/bongos/auth/web/start?return=/projects&repos=1');
88
+ assert.equal(start.status, 302);
89
+ const state = (start.setCookies.find((c) => c.startsWith('gds_oauth_state=')) || '').split(';')[0].split('=')[1];
90
+ assert.ok(state, 'the start set a state cookie');
91
+ const cb = await request('GET', `/api/bongos/auth/web/callback?code=x&state=${state}`, { cookie: pairs(start.setCookies) });
92
+ assert.equal(cb.status, 302, cb.body);
93
+ assert.equal(stored.length, 1, 'the repo token was stored');
94
+ const ttl = stored[0].ttlMs;
95
+ assert.ok(ttl > SESSION_MS - 60_000 && ttl <= SESSION_MS, `the TTL follows the 30-day session (got ${ttl}ms)`);
96
+ });
97
+
98
+ test('a web sign-out deletes the repo token', async () => {
99
+ deleted.length = 0;
100
+ const out = await request('POST', '/api/bongos/auth/logout', { cookie: auth.SESSION_COOKIE_NAMES[0] + '=tok' + 'a'.repeat(40) });
101
+ assert.equal(out.status, 200, out.body);
102
+ assert.deepEqual(deleted, [{ builderId: 7 }]);
103
+ });
104
+
105
+ test('a CLI logout (bearer) leaves the browser session’s repo token alone', async () => {
106
+ deleted.length = 0;
107
+ const out = await request('POST', '/api/bongos/auth/logout', { authorization: 'Bearer cli' + 'b'.repeat(40) });
108
+ assert.equal(out.status, 200, out.body);
109
+ assert.deepEqual(deleted, []);
110
+ });
@@ -74,7 +74,7 @@ test('the script builds no markup from strings, and the controls are real markup
74
74
  });
75
75
 
76
76
  test('the door returns here, and the page calls only its own-scoped routes', () => {
77
- assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account"/, 'sign-in lands back on /account');
77
+ assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account&amp;repos=1"/, 'sign-in lands back on /account, asking for repo access once (task 1004549)');
78
78
  const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
79
79
  assert.deepEqual([...new Set(calls)].sort(), ['GET /me', 'GET /me/cross-project','PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile'].sort());
80
80
  assert.match(SCRIPT, /fetch\(API \+ '\/auth\/logout', \{ method: 'POST'/, 'sign out is the hub logout POST');
@@ -107,7 +107,7 @@ test('the bar is the hub’s own: the same markup as /projects, no header of its
107
107
  assert.ok(HUB.includes(piece), `projects.html no longer carries: ${piece}`);
108
108
  assert.ok(PAGE.includes(piece), `account.html does not carry the hub's: ${piece}`);
109
109
  }
110
- assert.match(PAGE, /<a class="signinDoor" id="signinTop" hidden href="\/api\/bongos\/auth\/web\/start\?return=\/account">Sign in<\/a>/, 'signed out, the door in the chip’s slot returns here');
110
+ assert.match(PAGE, /<a class="signinDoor" id="signinTop" hidden href="\/api\/bongos\/auth\/web\/start\?return=\/account&amp;repos=1">Sign in<\/a>/, 'signed out, the door in the chip’s slot returns here');
111
111
  assert.match(PAGE, /<a class="skip vh" href="#acct">/);
112
112
  assert.match(PAGE, /<main class="acct" id="acct">/, 'the skip link has its landmark');
113
113
  });
@@ -291,7 +291,7 @@ test('the page reads without JavaScript: the spine is markup, not script-built',
291
291
  assert.ok(body.includes(copy), `"${copy}" is only reachable with scripting on`);
292
292
  }
293
293
  // the sign-in door is markup; the script only UPGRADES it to the signed-in chip
294
- assert.match(body, /class="pill signin" href="\/api\/bongos\/auth\/web\/start\?return=\/">Sign in</);
294
+ assert.match(body, /class="pill signin" href="\/api\/bongos\/auth\/web\/start\?return=\/&amp;repos=1">Sign in</);
295
295
  });
296
296
 
297
297
  // ── (4) the motion kill switch ─────────────────────────────────────────────
@@ -543,7 +543,7 @@ test('the bar is markup: the mark, the Projects trigger, the sign-in door — an
543
543
  assert.match(body, /<span class="mark-word">\{\{productName\}\}<\/span>/, 'the product name as plain type, folded away under 900');
544
544
  assert.match(body, /<button class="trig" type="button" id="projTrig" data-menu="projects" aria-expanded="false" aria-controls="menuProjects">Projects<svg class="cv"/);
545
545
  assert.doesNotMatch(body, /modeBtn|id="mode"|class="moon"|class="sun"/, 'the mode glyph left the bar — the apex is dark only (task 1003336)');
546
- assert.match(body, /<span class="whoSlot" id="who">\s*<a class="pill signin" href="\/api\/bongos\/auth\/web\/start\?return=\/">Sign in<\/a>\s*<\/span>/, 'the door stands in the identity slot until the session resolves');
546
+ assert.match(body, /<span class="whoSlot" id="who">\s*<a class="pill signin" href="\/api\/bongos\/auth\/web\/start\?return=\/&amp;repos=1">Sign in<\/a>\s*<\/span>/, 'the door stands in the identity slot until the session resolves');
547
547
  // what left with the pill bar
548
548
  assert.doesNotMatch(body, /class="pill nav-pill"|class="right-pills"|class="pill mark-pill"|data-nav="home"/, 'no nav pill, no right pills, no Home');
549
549
  assert.doesNotMatch(body.slice(body.indexOf('<header'), body.indexOf('</header>')), /data-link-hall/, 'the bar carries no hall link — the footer does');
@@ -226,7 +226,8 @@ test('the wizard’s 401 recoveries keep the repo scope they already granted', (
226
226
  assert.equal(plain, 0, 'no wizard redirect drops the scope');
227
227
  assert.ok((SCRIPT.match(/\/auth\/web\/start\?return=\/projects%3Fview%3Dnew&repos=1'\); throw new Error\('signing in'\)/g) || []).length >= 2, 'create + manifest');
228
228
  assert.match(SCRIPT, /projects%3Fview%3Dnew&amp;repos=1">sign in again<\/a> to keep watching/, 'the progress watch');
229
- assert.match(fn('signInAgain'), /currentView === 'new' \? '\/projects%3Fview%3Dnew&amp;repos=1'/, 'signInAgain from the wizard');
229
+ // every hub sign-in door asks for repo access since task 1004549, so signInAgain does too
230
+ assert.match(fn('signInAgain'), /\(currentView === 'new' \? '\/projects%3Fview%3Dnew' : '\/projects'\) \+ '&amp;repos=1'/, 'signInAgain from the wizard');
230
231
  });
231
232
 
232
233
  test('a resumed draft past the code row still probes repo access before Create', () => {
@@ -225,8 +225,8 @@ test('a wav name is a bare stem — traversal and absolute paths are refused', (
225
225
  test('playSound refuses a bad name without touching the filesystem', () => {
226
226
  let spawned = 0;
227
227
  const spawnFn = () => { spawned += 1; return { on() {}, unref() {} }; };
228
- assert.equal(player.playSound('../../etc/passwd', { spawnFn }), false);
229
- assert.equal(player.playSound('nope-not-a-sound', { spawnFn }), false);
228
+ assert.equal(player.playSound('../../etc/passwd', { spawnFn, enabled: true }), false);
229
+ assert.equal(player.playSound('nope-not-a-sound', { spawnFn, enabled: true }), false);
230
230
  assert.equal(spawned, 0);
231
231
  });
232
232
 
@@ -275,8 +275,8 @@ test('volume is clamped and rounded into 0-100', () => {
275
275
  test('a muted sound and volume 0 both spawn NOTHING', () => {
276
276
  let spawned = 0;
277
277
  const spawnFn = () => { spawned += 1; return { on() {}, unref() {} }; };
278
- assert.equal(player.playSound('ship', { prefsPath: tmpPrefs({ sounds: { ship: false }, volume: 100 }), spawnFn }), false);
279
- assert.equal(player.playSound('ship', { prefsPath: tmpPrefs({ sounds: {}, volume: 0 }), spawnFn }), false);
278
+ assert.equal(player.playSound('ship', { prefsPath: tmpPrefs({ sounds: { ship: false }, volume: 100 }), spawnFn, enabled: true }), false);
279
+ assert.equal(player.playSound('ship', { prefsPath: tmpPrefs({ sounds: {}, volume: 0 }), spawnFn, enabled: true }), false);
280
280
  assert.equal(spawned, 0, 'silence must not cost a process');
281
281
  });
282
282
 
@@ -318,6 +318,26 @@ test('scaleWav leaves anything it does not understand completely alone', () => {
318
318
 
319
319
  // ── the spawn, exercised without real audio ────────────────────────────────
320
320
 
321
+ // task 1004554: the owner switched every sound off for now. The master switch
322
+ // must win over everything else — a sound left ON in prefs at full volume, on a
323
+ // machine with a player, still spawns nothing. Delete the `if (!enabled)` guard
324
+ // in play.js and this goes red on any machine with an audio binary.
325
+ test('sounds are switched off by default — nothing spawns even when prefs say play', () => {
326
+ assert.equal(player.SOUNDS_ON, false, 'the master switch is off');
327
+ let spawned = 0;
328
+ const spawnFn = () => { spawned += 1; return { on() {}, unref() {} }; };
329
+ const prefsPath = tmpPrefs({ sounds: { ship: true, chime: true }, volume: 100 });
330
+ for (const name of ['ship', 'chime', 'alert', 'triumph', 'fanfare']) {
331
+ assert.equal(player.playSound(name, { prefsPath, spawnFn }), false, `${name} stays silent`);
332
+ }
333
+ assert.equal(spawned, 0, 'no sound process is started');
334
+ // The switch, not a missing player, is what silences it: opting in plays
335
+ // exactly when this machine has a player.
336
+ const available = player.playerFor(path.join(WAV_DIR, 'ship.wav')) !== null;
337
+ assert.equal(player.playSound('ship', { prefsPath, spawnFn, enabled: true }), available);
338
+ });
339
+
340
+
321
341
  test('a playable sound spawns the platform player, detached and unref\'d', () => {
322
342
  const calls = [];
323
343
  const spawnFn = (cmd, args, opts) => { calls.push({ cmd, args, opts }); return { on() {}, unref() {} }; };
@@ -325,7 +345,7 @@ test('a playable sound spawns the platform player, detached and unref\'d', () =>
325
345
  // which case there is nothing to assert about the spawn — so this test asserts
326
346
  // the CONTRACT that holds either way: it never throws, and it only ever spawns
327
347
  // when a player exists.
328
- const res = player.playSound('ship', { prefsPath: tmpPrefs({ sounds: {}, volume: 100 }), spawnFn });
348
+ const res = player.playSound('ship', { prefsPath: tmpPrefs({ sounds: {}, volume: 100 }), spawnFn, enabled: true });
329
349
  const available = player.playerFor(path.join(WAV_DIR, 'ship.wav')) !== null;
330
350
  assert.equal(res, available, 'plays exactly when this machine has a player');
331
351
  assert.equal(calls.length, available ? 1 : 0);
@@ -204,3 +204,52 @@ test('the team pill is a plain pick on the Team size row — a second click keep
204
204
  assert.deepEqual([...HUB.matchAll(/data-team="([a-z-]+)"/g)].map((m) => m[1]), ['small-team', 'community'],
205
205
  'the row offers who can join a team; one person is the "One person" pill, not a team shape');
206
206
  });
207
+
208
+ // ── slice 4 · a second draft beside a stored create keeps its place (task 1004549) ──
209
+ // Owner report 2026-10-02: "Allow repo access" seemed to throw the founder out of the
210
+ // flow. GitHub already held the approval, so the round-trip was a blink — and the page
211
+ // came back on the EARLIER project's done panel, because enter() sent every arrival with
212
+ // a stored create (cb-created-instance) to enterCreated(), which treats a non-deliberate
213
+ // arrival as "the same visit, show the watched project". With a draft part-way through
214
+ // the wizard, the same visit IS the draft.
215
+ const ENTER_RE = /function enter\(focus\) \{[\s\S]*?\n {4}\}/;
216
+ const enterSlice = (HUB.match(ENTER_RE) || [null])[0];
217
+
218
+ test('the enter() slice is still findable', () => {
219
+ assert.ok(enterSlice, 'enter() was not found in projects.html');
220
+ assert.match(enterSlice, /enterCreated\(/, 'enter() is where the stored create is routed');
221
+ });
222
+
223
+ function runEnter({ draftStep, focus = false, resumeIntent = false, created = { id: 101, name: 'Old one' } }) {
224
+ const log = { shown: [], created: [], offer: undefined };
225
+ const sandbox = {
226
+ authKnown: true, me: { github_login: 'probe' }, resumeIntent,
227
+ state: { step: draftStep },
228
+ createdRecord: () => created,
229
+ enterCreated: (rec, f) => log.created.push({ id: rec.id, focus: f }),
230
+ paintResumeOffer: (rec) => { log.offer = rec; },
231
+ show: (step) => { sandbox.state.step = step; log.shown.push(step); },
232
+ saveDraft() {},
233
+ $: () => ({ focus() {} }),
234
+ };
235
+ sandbox.globalThis = sandbox;
236
+ vm.createContext(sandbox);
237
+ vm.runInContext(enterSlice + '\nenter(' + JSON.stringify(focus) + ');', sandbox);
238
+ return log;
239
+ }
240
+
241
+ test('back from GitHub mid-draft, with an earlier project stored: the draft step, not the old done panel', () => {
242
+ for (const step of [2, 3, 4, 5]) {
243
+ const log = runEnter({ draftStep: step });
244
+ assert.deepEqual(log.created, [], `step ${step}: the earlier project's panel is not entered`);
245
+ assert.deepEqual(log.shown, [step], `step ${step}: the draft keeps its place`);
246
+ assert.equal(log.offer && log.offer.id, 101, 'the earlier project stays one click away on the resume line');
247
+ }
248
+ });
249
+
250
+ test('the done panel still comes back when no second draft is in flight, or on a deliberate nav', () => {
251
+ assert.equal(runEnter({ draftStep: 6 }).created.length, 1, 'a reload on the done panel stays on it');
252
+ assert.equal(runEnter({ draftStep: 0 }).created.length, 1, 'no draft position: the watched project');
253
+ assert.deepEqual(runEnter({ draftStep: 4, focus: true }).created, [{ id: 101, focus: true }], 'a deliberate New project nav is enterCreated\'s to decide');
254
+ assert.equal(runEnter({ draftStep: 4, resumeIntent: true }).created.length, 1, 'the resume link still resumes');
255
+ });
@@ -61,8 +61,10 @@ test('both slices are still findable (a rename must fail loudly, not silently pa
61
61
  assert.match(enterSlice, /startProgress\(created\.id\);/, 'so is the resume branch it guards');
62
62
  assert.match(enterSlice, /draft\.step >= 1 && draft\.step <= 5 \?/,
63
63
  'the STORED draft\'s step decides, not state.step — state.step is 6 for the whole life of a finished standup');
64
- assert.match(SCRIPT, /if \(created && created\.id\) \{ enterCreated\(created, !!focus\); return; \}/,
65
- 'enter() still hands the created record straight to it');
64
+ // since task 1004549 a second draft in flight keeps its place on a non-deliberate
65
+ // arrival (tests/wizard_draft_resume.mjs slice 4); a deliberate nav still comes here
66
+ assert.match(SCRIPT, /if \(created && created\.id && \(focus \|\| !draftInFlight \|\| resumeIntent\)\) \{ enterCreated\(created, !!focus\); return; \}/,
67
+ 'enter() still hands the created record to it on a deliberate nav');
66
68
  });
67
69
 
68
70
  // A faithful miniature: the stubs record what enterCreated ASKS FOR, which is the whole