@bongos/core 1.21.15 → 1.21.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +38 -28
- package/docs/adr/0364-repo-access-is-asked-at-sign-in-and-held-for-the-session.md +30 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +206 -206
- package/docs/copy-registry.json +232 -232
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +362 -361
- package/modules/public-landing/public/account.html +2 -2
- package/modules/public-landing/public/account.probes.json +2 -2
- package/modules/public-landing/public/index.html +1 -1
- package/modules/public-landing/public/projects.html +22 -13
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/db.js +7 -6
- package/src/bongos/routes/auth.js +17 -4
- package/src/module-api.js +1 -1
- package/tests/github_token_session_life.mjs +110 -0
- package/tests/hub_account_page.mjs +2 -2
- package/tests/landing_page.mjs +2 -2
- package/tests/projects_hub_pre_uat.mjs +2 -1
- package/tests/wizard_draft_resume.mjs +49 -0
- package/tests/wizard_intent_resume.mjs +4 -2
|
@@ -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&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&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
|
|
676
|
+
<a class="pill signin" href="/api/bongos/auth/web/start?return=/&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&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&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&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
|
-
|
|
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&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
|
|
2498
|
-
|
|
2499
|
-
|
|
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') +
|
|
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
|
-
|
|
7194
|
-
|
|
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
|
-
/*
|
|
7349
|
-
var back = currentView === 'new' ? '/projects%3Fview%3Dnew
|
|
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') + '&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.
|
|
3
|
+
"version": "1.21.16",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.16",
|
|
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.
|
|
3
|
+
"version": "1.21.16",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -8846,5 +8846,11 @@
|
|
|
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
|
+
}
|
|
8849
8855
|
]
|
|
8850
8856
|
}
|
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
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
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 —
|
|
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
|
|
882
|
-
//
|
|
883
|
-
//
|
|
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
|
-
|
|
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.
|
|
78
|
+
const CORE_VERSION = '1.21.16'; // 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&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&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
|
});
|
package/tests/landing_page.mjs
CHANGED
|
@@ -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
|
|
294
|
+
assert.match(body, /class="pill signin" href="\/api\/bongos\/auth\/web\/start\?return=\/&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
|
|
546
|
+
assert.match(body, /<span class="whoSlot" id="who">\s*<a class="pill signin" href="\/api\/bongos\/auth\/web\/start\?return=\/&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&repos=1">sign in again<\/a> to keep watching/, 'the progress watch');
|
|
229
|
-
|
|
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'\) \+ '&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', () => {
|
|
@@ -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
|
-
|
|
65
|
-
|
|
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
|