@bongos/core 1.25.24 → 1.25.25

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.
@@ -1558,6 +1558,12 @@ summary{min-height:24px;padding:3px 0;}
1558
1558
  <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>
1559
1559
  Continue with GitHub
1560
1560
  </a>
1561
+ <!-- No GitHub account yet (task 1005283): the same sign-in, opened on GitHub's
1562
+ sign-up page (signup=1), which offers "Continue with Google" and hands the
1563
+ sign-in straight back here once the account exists. Bongos cannot make a
1564
+ GitHub account for anyone; GitHub can, in one step, with Google. -->
1565
+ <p class="finePrint" id="wizWhyGh">Your project’s files live in a GitHub repository, so it needs a GitHub account.</p>
1566
+ <p class="finePrint" id="wizNoGh">No GitHub account? <a id="wizSignup" href="/api/bongos/auth/web/start?via=github&amp;signup=1&amp;return=/projects%3Fview%3Dnew&amp;repos=1">Make one free with Google</a>. On GitHub’s page choose <strong>Continue with Google</strong>, and you come straight back here.</p>
1561
1567
  <!-- Informed consent BEFORE GitHub's own wording (ADR 0155): the scope the
1562
1568
  button asks for reads as "all your repositories" on GitHub's screen, so
1563
1569
  the reason is stated here, on the path every founder actually takes. -->
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.24",
3
+ "version": "1.25.25",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.24",
9
+ "version": "1.25.25",
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.25.24",
3
+ "version": "1.25.25",
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",
@@ -11912,5 +11912,11 @@
11912
11912
  "id": "1005107",
11913
11913
  "text": "A founder can now go back and change their Genesis plan after saving it, and change any earlier stage's answers until the Board Room has voted that stage through — a voted stage shows its answers with a plain reason why they a"
11914
11914
  }
11915
+ ],
11916
+ "1.25.25": [
11917
+ {
11918
+ "id": "1005283",
11919
+ "text": "Someone with no GitHub account can now start a project. The creation page explains why GitHub is needed and offers a 'Make one free with Google' link. GitHub's own sign-up page opens, they continue with their Google account, a"
11920
+ }
11915
11921
  ]
11916
11922
  }
@@ -291,6 +291,22 @@ function buildAuthorizeUrl(state, redirectUri, { repos = false, switchAccount =
291
291
  return u.toString();
292
292
  }
293
293
 
294
+ // GitHub's own sign-up page, for a person with no GitHub account (task 1005283). It is the page GitHub's sign-in screen links as "Create an account"
295
+ // during an OAuth sign-in: `return_to` carries the authorize step back, so once the
296
+ // account exists (GitHub's sign-up offers "Continue with Google") GitHub resumes the
297
+ // SAME sign-in — same state, same scope — and our callback lands the person where they
298
+ // started. Only github.com's own authorize path is ever handed on; anything else is null
299
+ // and the caller falls back to the plain authorize redirect.
300
+ function githubSignupUrl(authorizeUrl) {
301
+ let u;
302
+ try { u = new URL(String(authorizeUrl)); } catch { return null; }
303
+ if (u.protocol !== 'https:' || u.host !== 'github.com' || u.pathname !== '/login/oauth/authorize') return null;
304
+ const signup = new URL('https://github.com/signup');
305
+ signup.searchParams.set('return_to', u.pathname + u.search);
306
+ signup.searchParams.set('source', 'oauth');
307
+ return signup.toString();
308
+ }
309
+
294
310
  async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken = false }) {
295
311
  const { accessToken, scope: grantedScope } = await exchangeCodeForToken(code, redirectUri);
296
312
  const ghUser = await fetchGithubUser(accessToken);
@@ -386,6 +402,7 @@ async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken
386
402
  // slashes on origin are trimmed so `https://hub/` and `https://hub` behave alike.
387
403
 
388
404
  module.exports = {
405
+ githubSignupUrl,
389
406
  buildAuthorizeUrl,
390
407
  completeWebFlow,
391
408
  exchangeCodeForToken,
@@ -58,7 +58,7 @@ const { branding, userAgent } = require('../branding');
58
58
 
59
59
  const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
60
60
  const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, notifyHubOfInvite, openEnrollmentEnabled, readAppStatusFromHub, readApplicantProfileFromHub, readApplicantViewFromHub, webAdmissionStatus } = require('./auth-admission.js');
61
- const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
61
+ const { buildAuthorizeUrl, githubSignupUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
62
62
  const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, readApplicationVouch, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
63
63
  const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
64
64
 
@@ -854,6 +854,7 @@ module.exports = {
854
854
  startDeviceFlow,
855
855
  pollDeviceFlow,
856
856
  buildAuthorizeUrl,
857
+ githubSignupUrl,
857
858
  scopeForWebFlow,
858
859
  grantedScopeCoversRepos,
859
860
  buildIdpAuthorizeUrl,
@@ -710,6 +710,7 @@ module.exports = function buildAuthRouter() {
710
710
  // ?switch=1 — sign in as a DIFFERENT GitHub account (task 1004789): show
711
711
  // GitHub's account picker instead of re-using the one logged in there.
712
712
  const wantsSwitch = req.query.switch === '1' || req.query.switch === 'true';
713
+ const wantsSignup = req.query.signup === '1' || req.query.signup === 'true'; // no GitHub account yet: its sign-up page (task 1005283)
713
714
  // A sign-in begun on a satellite hub host (builders./status.) restarts on
714
715
  // the canonical host (task 1004357). The handshake cookies are host-only now,
715
716
  // and the callback always lands on the canonical host, so a state cookie set
@@ -720,8 +721,7 @@ module.exports = function buildAuthRouter() {
720
721
  const raw = safeReturnTo(req.query.return) || safeReturnTo(req.headers.referer) || '/';
721
722
  const back = raw.startsWith('/') ? `https://${host}${raw}` : raw;
722
723
  const params = new URLSearchParams({ return: back });
723
- if (wantsRepos) params.set('repos', '1');
724
- if (wantsSwitch) params.set('switch', '1');
724
+ for (const [k, on] of [['repos', wantsRepos], ['switch', wantsSwitch], ['signup', wantsSignup]]) if (on) params.set(k, '1');
725
725
  if (req.query.via === 'github') params.set('via', 'github');
726
726
  return res.redirect(`${WEB_REDIRECT_ORIGIN.replace(/\/+$/, '')}${apiBase(req)}/auth/web/start?${params}`);
727
727
  }
@@ -729,7 +729,7 @@ module.exports = function buildAuthRouter() {
729
729
  // host (a builder who signed in on the apex, then opened builders.): no
730
730
  // GitHub round-trip, just hand that host its copy. A repo-scope sign-in
731
731
  // still goes to GitHub — it exists to ask for the broader grant.
732
- if (!wantsRepos && !wantsSwitch) {
732
+ if (!wantsRepos && !wantsSwitch && !wantsSignup) {
733
733
  const target = safeReturnTo(req.query.return);
734
734
  if (target && !target.startsWith('/')) {
735
735
  try {
@@ -746,7 +746,7 @@ module.exports = function buildAuthRouter() {
746
746
  // lands on one chooser — Continue with GitHub or Continue with Google — so no
747
747
  // page needs its own pair of buttons. `via=github` is the GitHub button (and
748
748
  // any door that needs GitHub itself); "New account" names a GitHub account.
749
- if (req.query.via !== 'github' && !wantsSwitch && googleConfigured()) {
749
+ if (req.query.via !== 'github' && !wantsSwitch && !wantsSignup && googleConfigured()) {
750
750
  const ret = safeReturnTo(req.query.return) || safeReturnTo(req.headers.referer);
751
751
  const gh = new URLSearchParams({ via: 'github' });
752
752
  if (ret) gh.set('return', ret);
@@ -803,9 +803,9 @@ module.exports = function buildAuthRouter() {
803
803
  // Federate through the hub when configured (ADR 0141 §1); otherwise the direct
804
804
  // GitHub authorize, carrying the repo-scope opt-in. Same CSRF state cookie either
805
805
  // way — the callback validates it identically, so the hub leg is CSRF-protected too.
806
- res.redirect(auth.idpConfigured()
807
- ? auth.buildIdpAuthorizeUrl(state, redirectUri)
808
- : auth.buildAuthorizeUrl(state, redirectUri, { repos: wantsRepos, switchAccount: wantsSwitch }));
806
+ if (auth.idpConfigured()) return res.redirect(auth.buildIdpAuthorizeUrl(state, redirectUri));
807
+ const authorizeUrl = auth.buildAuthorizeUrl(state, redirectUri, { repos: wantsRepos, switchAccount: wantsSwitch });
808
+ res.redirect((wantsSignup && auth.githubSignupUrl(authorizeUrl)) || authorizeUrl);
809
809
  });
810
810
 
811
811
  // GET /auth/web/handoff?code=&return= — the far side of the one-time session
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.25.24'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.25'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -24,13 +24,9 @@ test('switchAccount adds prompt=select_account; a plain sign-in does not', () =>
24
24
  assert.equal(sw.searchParams.get('scope'), plain.searchParams.get('scope'));
25
25
  });
26
26
 
27
- test('the start route reads ?switch=1 and passes it to the GitHub leg', () => {
28
- const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/auth.js'), 'utf8');
29
- assert.match(src, /const wantsSwitch = req\.query\.switch === '1'/);
30
- assert.match(src, /switchAccount: wantsSwitch/);
31
- // a switch must reach GitHub, never short-circuit through the live-session handoff
32
- assert.match(src, /if \(!wantsRepos && !wantsSwitch\) \{/);
33
- });
27
+ // The start route itself is now RUN, not read (task 1005283): tests/github_signup_handoff.mjs
28
+ // calls GET /auth/web/start with ?switch=1 and asserts the redirect carries
29
+ // prompt=select_account and goes to GitHub, never through the live-session handoff.
34
30
 
35
31
  test('the landing carries the New account door beside Sign in', () => {
36
32
  const html = fs.readFileSync(path.join(ROOT, 'modules/public-landing/public/index.html'), 'utf8');
@@ -0,0 +1,135 @@
1
+ // tests/github_signup_handoff.mjs — no GitHub account yet: GitHub's own sign-up page, with
2
+ // "Continue with Google", and back to project creation (task 1005283).
3
+ //
4
+ // WHY. Bongos cannot make a GitHub account for anyone, and every project lives in a GitHub
5
+ // repository. A person with only a Google account used to meet "Continue with GitHub" and
6
+ // nothing else. GitHub's sign-up page offers "Continue with Google", and during an OAuth
7
+ // sign-in GitHub's own screen links it with `return_to` = the authorize step, so the
8
+ // sign-in resumes once the account exists (checked on GitHub's live pages, signed out,
9
+ // 2026-11-13). `?signup=1` on our sign-in start opens THAT page directly.
10
+ //
11
+ // What this pins, by RUNNING the start route's handler (no network, no database):
12
+ // 1. ?signup=1 redirects to https://github.com/signup with return_to = the same
13
+ // authorize step (same state cookie, same client, same repo scope, same callback);
14
+ // 2. it skips the GitHub-or-Google chooser, which would offer a Bongos Google sign-in
15
+ // that cannot make a project;
16
+ // 3. a plain sign-in still goes to the authorize step, and ?switch=1 still adds the
17
+ // account picker (converted here from tests/auth_switch_account.mjs's source read);
18
+ // 4. githubSignupUrl hands on only github.com's own authorize path;
19
+ // 5. the creation page's gate says why GitHub is needed and carries the door, whose
20
+ // link runs through the route above and returns to /projects?view=new.
21
+ //
22
+ // Run: node --test tests/github_signup_handoff.mjs
23
+
24
+ import assert from 'node:assert/strict';
25
+ import { test } from 'node:test';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { createRequire } from 'node:module';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ process.env.BONGOS_GITHUB_CLIENT_ID = process.env.BONGOS_GITHUB_CLIENT_ID || 'gh-test-client';
32
+ process.env.BONGOS_GITHUB_CLIENT_SECRET = process.env.BONGOS_GITHUB_CLIENT_SECRET || 'gh-test-secret';
33
+ // Google sign-in on, so the chooser would show for a plain sign-in: the door must skip it
34
+ process.env.BONGOS_GOOGLE_CLIENT_ID = 'google-test-client';
35
+ process.env.BONGOS_GOOGLE_CLIENT_SECRET = 'google-test-secret';
36
+
37
+ const require = createRequire(import.meta.url);
38
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
39
+ // "Continue with Google" is offered only when the hub that keeps Google sign-ins is present
40
+ const seams = require(path.join(ROOT, 'src/module-seams.js'));
41
+ if (!seams.hasProvider('platform-identity.hub')) {
42
+ seams.registerProvider('platform-identity.hub', { signInWithGoogle: async () => null, recordHubSignin: async () => {} });
43
+ }
44
+ const buildAuthRouter = require(path.join(ROOT, 'src/bongos/routes/auth.js'));
45
+ const { githubSignupUrl } = require(path.join(ROOT, 'src/bongos/auth.js'));
46
+ const { attachFail } = require(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
47
+
48
+ function fakeRes() {
49
+ const res = {
50
+ _status: 200, _body: null, _redirect: null, _cookies: {},
51
+ status(c) { this._status = c; return this; },
52
+ type() { return this; },
53
+ send(b) { this._body = b; return this; },
54
+ json(b) { this._body = b; return this; },
55
+ set() { return this; },
56
+ redirect(a, b) { this._redirect = b === undefined ? a : b; return this; },
57
+ cookie(n, v, o) { this._cookies[n] = { v, o }; return this; },
58
+ clearCookie() { return this; },
59
+ };
60
+ attachFail({}, res, () => {});
61
+ return res;
62
+ }
63
+ const start = (() => {
64
+ const layer = buildAuthRouter().stack.find((l) => l.route?.path === '/auth/web/start' && l.route.methods.get);
65
+ assert.ok(layer, 'GET /auth/web/start exists');
66
+ return layer.route.stack.at(-1).handle;
67
+ })();
68
+ const req = (query) => ({ query, headers: { host: 'localhost' }, baseUrl: '/api/bongos', body: {} });
69
+ async function run(query) { const res = fakeRes(); await start(req(query), res); return res; }
70
+
71
+ test('?signup=1 opens GitHub’s sign-up page, which resumes this same sign-in', async () => {
72
+ const res = await run({ via: 'github', signup: '1', return: '/projects?view=new', repos: '1' });
73
+ assert.ok(res._redirect, 'a redirect, not the chooser page');
74
+ const signup = new URL(res._redirect);
75
+ assert.equal(signup.origin + signup.pathname, 'https://github.com/signup');
76
+ assert.equal(signup.searchParams.get('source'), 'oauth', 'the same marker GitHub’s own "Create an account" link carries');
77
+ const back = new URL(signup.searchParams.get('return_to'), 'https://github.com');
78
+ assert.equal(back.pathname, '/login/oauth/authorize', 'the way back is the authorize step');
79
+ assert.equal(back.searchParams.get('client_id'), process.env.BONGOS_GITHUB_CLIENT_ID);
80
+ const stateCookie = Object.values(res._cookies).find((c) => /^[0-9a-f]{32}$/.test(String(c.v)));
81
+ assert.ok(stateCookie, 'a state cookie is set');
82
+ assert.equal(back.searchParams.get('state'), stateCookie.v,
83
+ 'the state GitHub hands back is the one in this browser’s cookie, so the callback accepts it');
84
+ assert.match(back.searchParams.get('scope'), /\brepo\b/, 'the repo access project creation needs is asked once, on the way back');
85
+ assert.match(back.searchParams.get('redirect_uri'), /\/auth\/web\/callback$/);
86
+ assert.ok(Object.values(res._cookies).some((c) => c.v === '/projects?view=new'), 'after the callback, the person lands on project creation');
87
+ });
88
+
89
+ test('the door skips the GitHub-or-Google chooser; a plain sign-in still shows it', async () => {
90
+ const signup = await run({ signup: '1', return: '/projects?view=new', repos: '1' });
91
+ assert.match(signup._redirect || '', /^https:\/\/github\.com\/signup\?/, 'no via=github needed: a GitHub sign-up is GitHub');
92
+ const plain = await run({ return: '/projects?view=new', repos: '1' });
93
+ assert.equal(plain._redirect, null);
94
+ assert.match(plain._body, /Continue with GitHub/);
95
+ });
96
+
97
+ test('without ?signup a sign-in goes to the authorize step, and ?switch=1 still adds the account picker', async () => {
98
+ const plain = await run({ via: 'github', return: '/projects?view=new', repos: '1' });
99
+ const p = new URL(plain._redirect);
100
+ assert.equal(p.origin + p.pathname, 'https://github.com/login/oauth/authorize');
101
+ assert.equal(p.searchParams.get('prompt'), null);
102
+ const sw = await run({ switch: '1', return: '/' });
103
+ const s = new URL(sw._redirect);
104
+ assert.equal(s.pathname, '/login/oauth/authorize');
105
+ assert.equal(s.searchParams.get('prompt'), 'select_account', 'a switch reaches GitHub, never the live-session handoff');
106
+ });
107
+
108
+ test('githubSignupUrl hands on only github.com’s own authorize path', () => {
109
+ const ok = githubSignupUrl('https://github.com/login/oauth/authorize?client_id=c&state=s&scope=repo');
110
+ assert.equal(new URL(ok).searchParams.get('return_to'), '/login/oauth/authorize?client_id=c&state=s&scope=repo');
111
+ for (const bad of [
112
+ 'https://evil.example/login/oauth/authorize?state=s',
113
+ 'http://github.com/login/oauth/authorize?state=s',
114
+ 'https://github.com/settings/tokens',
115
+ 'https://github.com.evil.example/login/oauth/authorize',
116
+ 'not a url',
117
+ null,
118
+ ]) assert.equal(githubSignupUrl(bad), null, String(bad));
119
+ });
120
+
121
+ test('the creation page says why GitHub is needed and offers the door, returning to creation', () => {
122
+ const hub = fs.readFileSync(path.join(ROOT, 'modules/public-landing/public/projects.html'), 'utf8').replace(/\r\n/g, '\n');
123
+ const gate = (hub.match(/<section class="panel" id="panel-gate"[\s\S]*?<\/section>/) || [''])[0];
124
+ assert.ok(gate, 'the gate is findable');
125
+ assert.match(gate, /<p class="finePrint" id="wizWhyGh">Your project’s files live in a GitHub repository, so it needs a GitHub account\.<\/p>/);
126
+ const door = gate.match(/<a id="wizSignup" href="([^"]+)">Make one free with Google<\/a>/);
127
+ assert.ok(door, 'the door sits on the gate');
128
+ assert.ok(gate.indexOf('id="wizSignin"') < gate.indexOf('id="wizSignup"'), 'under Continue with GitHub, not instead of it');
129
+ const href = new URL(door[1].replace(/&amp;/g, '&'), 'https://hub.example');
130
+ assert.equal(href.pathname, '/api/bongos/auth/web/start');
131
+ assert.equal(href.searchParams.get('signup'), '1');
132
+ assert.equal(href.searchParams.get('repos'), '1', 'the same repo access the GitHub button asks for');
133
+ assert.equal(href.searchParams.get('return'), '/projects?view=new');
134
+ assert.match(gate, /choose <strong>Continue with Google<\/strong>, and you come straight back here/);
135
+ });