@bongos/core 1.18.0 → 1.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/.bongos-core.json +132 -322
  2. package/README.md +1 -1
  3. package/config/branding.neutral.json +5 -1
  4. package/docs/adr/0142-project-context-is-prose-only.md +41 -0
  5. package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +35 -0
  6. package/docs/adr/README.md +5 -2
  7. package/docs/api/openapi.json +1789 -450
  8. package/docs/api-reference.md +52 -5
  9. package/docs/file-map.md +7 -7
  10. package/docs/recipes/packaging-the-core.md +13 -0
  11. package/docs/recipes/upgrading-the-core.md +5 -2
  12. package/migrations/039_newcomer_friendly_column.sql +5 -0
  13. package/migrations/102_remove_box_first_provision_approval.sql +4 -0
  14. package/migrations/core_192_github_oauth_tokens.sql +33 -0
  15. package/modules/hall-ui/public/my-projects.html +78 -0
  16. package/modules/hall-ui/public/my-projects.js +142 -0
  17. package/modules/hall-ui/public/scouting.html +45 -0
  18. package/modules/hall-ui/public/scouting.js +73 -0
  19. package/modules/status-ui/public/explore.html +54 -0
  20. package/modules/status-ui/public/explore.js +67 -0
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/public-landing/create.html +211 -14
  24. package/scripts/gds/cli-lib.js +11 -5
  25. package/scripts/gds/lint-migrations.js +102 -0
  26. package/scripts/gds/login.js +42 -10
  27. package/scripts/gds/mirror-redact.js +5 -0
  28. package/scripts/gds/package-core.js +263 -4
  29. package/scripts/gds/provision.js +145 -7
  30. package/scripts/gds/publish-manifest.js +15 -0
  31. package/scripts/gds/run-unit-tests.js +38 -14
  32. package/scripts/gds/setup.js +41 -0
  33. package/scripts/gds/ship.js +115 -31
  34. package/scripts/gds/upgrade.js +147 -5
  35. package/scripts/migrate.sh +10 -0
  36. package/src/bongos/auth.js +362 -5
  37. package/src/bongos/db.js +66 -0
  38. package/src/bongos/github-repos.js +93 -0
  39. package/src/bongos/routes/auth.js +93 -11
  40. package/src/bongos/routes/github.js +90 -0
  41. package/src/bongos/routes/instance.js +15 -3
  42. package/src/bongos/routes.js +2 -0
  43. package/src/branding.js +17 -2
  44. package/src/instance-config.js +16 -0
  45. package/src/module-api.js +29 -1
  46. package/tests/branding.mjs +29 -23
  47. package/tests/github_repos_helpers.mjs +91 -0
  48. package/tests/github_token_store.mjs +121 -0
  49. package/tests/idp_device_exchange.mjs +105 -0
  50. package/tests/idp_federation.mjs +100 -0
  51. package/tests/migrations_forward_safe.mjs +86 -0
  52. package/tests/module_api.mjs +1 -1
  53. package/tests/package_core.mjs +246 -1
  54. package/tests/provision.mjs +157 -0
  55. package/tests/pruned_lockfile_integrity.mjs +79 -0
  56. package/tests/ship_ci_deploy.mjs +60 -0
  57. package/tests/upgrade.mjs +254 -5
  58. package/.gitignore +0 -91
  59. package/docs/adr/0139-platform-history-migration-to-cloudbongos.md +0 -94
  60. package/docs/adr/0140-cloudbongos-standalone-reprovision.md +0 -43
  61. package/scripts/hall-preview/fixtures/access-requests.json +0 -20
  62. package/scripts/hall-preview/fixtures/achievements.json +0 -76
  63. package/scripts/hall-preview/fixtures/analytics__builder.json +0 -251
  64. package/scripts/hall-preview/fixtures/audit-log.json +0 -68
  65. package/scripts/hall-preview/fixtures/blockers__detail.json +0 -12
  66. package/scripts/hall-preview/fixtures/box__me.json +0 -5
  67. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +0 -69
  68. package/scripts/hall-preview/fixtures/builders__active-claims.json +0 -1
  69. package/scripts/hall-preview/fixtures/builders__directory.json +0 -81
  70. package/scripts/hall-preview/fixtures/builders__roster.json +0 -48
  71. package/scripts/hall-preview/fixtures/goals.json +0 -502
  72. package/scripts/hall-preview/fixtures/goals__25.json +0 -96
  73. package/scripts/hall-preview/fixtures/goals__26.json +0 -67
  74. package/scripts/hall-preview/fixtures/goals__3.json +0 -103
  75. package/scripts/hall-preview/fixtures/goals__35.json +0 -49
  76. package/scripts/hall-preview/fixtures/goals__conflicts.json +0 -154
  77. package/scripts/hall-preview/fixtures/goals__detail.json +0 -96
  78. package/scripts/hall-preview/fixtures/goals__inbox.json +0 -5
  79. package/scripts/hall-preview/fixtures/goals__requests.json +0 -3
  80. package/scripts/hall-preview/fixtures/grades__by-builder.json +0 -32
  81. package/scripts/hall-preview/fixtures/inbox.json +0 -330
  82. package/scripts/hall-preview/fixtures/inbox__detail.json +0 -15
  83. package/scripts/hall-preview/fixtures/instance.json +0 -124
  84. package/scripts/hall-preview/fixtures/leaderboard.json +0 -184
  85. package/scripts/hall-preview/fixtures/me.json +0 -476
  86. package/scripts/hall-preview/fixtures/me__art-key__own.json +0 -6
  87. package/scripts/hall-preview/fixtures/me__event-sound-prefs.json +0 -209
  88. package/scripts/hall-preview/fixtures/me__recent-ships.json +0 -304
  89. package/scripts/hall-preview/fixtures/me__render.json +0 -5
  90. package/scripts/hall-preview/fixtures/me__skill-prefs.json +0 -143
  91. package/scripts/hall-preview/fixtures/me__sound-prefs.json +0 -67
  92. package/scripts/hall-preview/fixtures/me__wandering.json +0 -43
  93. package/scripts/hall-preview/fixtures/override-requests.json +0 -15
  94. package/scripts/hall-preview/fixtures/provisioning__fleet.json +0 -1
  95. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +0 -1
  96. package/scripts/hall-preview/fixtures/provisioning__instances.json +0 -1
  97. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +0 -516
  98. package/scripts/hall-preview/fixtures/public__bounty-table.json +0 -37
  99. package/scripts/hall-preview/fixtures/public__cost-summary.json +0 -742
  100. package/scripts/hall-preview/fixtures/public__estimation-drift.json +0 -41
  101. package/scripts/hall-preview/fixtures/public__grades.json +0 -50
  102. package/scripts/hall-preview/fixtures/public__progress.json +0 -124
  103. package/scripts/hall-preview/fixtures/public__recent-shipped.json +0 -426
  104. package/scripts/hall-preview/fixtures/public__repo-health.json +0 -160
  105. package/scripts/hall-preview/fixtures/public__repo-info.json +0 -5
  106. package/scripts/hall-preview/fixtures/security__docs.json +0 -10
  107. package/scripts/hall-preview/fixtures/security__reports.json +0 -31
  108. package/scripts/hall-preview/fixtures/sessions__search.json +0 -9
  109. package/scripts/hall-preview/fixtures/tasks.json +0 -2420
  110. package/scripts/hall-preview/fixtures/tasks__claimable.json +0 -2555
  111. package/scripts/hall-preview/fixtures/tasks__detail.json +0 -111
  112. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +0 -330
  113. package/scripts/hall-preview/fixtures/versions.json +0 -89
package/src/bongos/db.js CHANGED
@@ -25,6 +25,7 @@
25
25
  // grade-count SQL runs on both sides of the kernel↔module line — keep in sync.
26
26
 
27
27
  const { pool } = require('./pool');
28
+ const secretBox = require('./secret-box');
28
29
  // Module seam (ADR 0083 / BV1.R43). The rank/status-change paths EMIT
29
30
  // `builder.box-reconcile-needed` AFTER COMMIT (best-effort) so the dev-box module
30
31
  // can reconcile a droplet a demotion/graduation implies — never a direct require
@@ -205,6 +206,68 @@ async function updateBuilderDisciplines(builderId, disciplines) {
205
206
  // { enc: null, ... } when no key is set.
206
207
 
207
208
 
209
+ // ---- GitHub OAuth repo token, encrypted at rest (core_192 / task 1002099) ----
210
+ //
211
+ // The onboarding wizard (public-landing/create.html) opts into `public_repo` at
212
+ // sign-in so it can list + create the user's repos. That user-to-server GitHub
213
+ // token is held here BRIEFLY: encrypted with secret-box (master key in server env
214
+ // only — the plaintext never touches the DB) and given a short TTL (one wizard
215
+ // sitting), read back by the repo picker/creator, then deleted on use. It is the
216
+ // auth system's OWN handshake token (distinct from a feature's third-party key
217
+ // like the art module's Gemini key, ADR 0102), so it lives in core auth. secret-box
218
+ // is the gate: with no master key configured we NEVER store a plaintext token —
219
+ // capture no-ops and the wizard degrades to manual repo entry (ADR 0143).
220
+ const GITHUB_TOKEN_TTL_MS = 60 * 60 * 1000; // 1h — one wizard sitting.
221
+
222
+ // Store (or replace) the builder's GitHub repo token. `token` is the raw
223
+ // user-to-server access token; it is encrypted here so the plaintext never touches
224
+ // the DB. No-ops when secret-box is unconfigured (returns { stored:false }).
225
+ async function upsertGithubToken({ builderId, token, scope = null, ttlMs = GITHUB_TOKEN_TTL_MS, p = pool }) {
226
+ if (!secretBox.isConfigured()) return { stored: false };
227
+ const enc = secretBox.encrypt(token);
228
+ const expiresAt = new Date(Date.now() + ttlMs);
229
+ await p.query(
230
+ `INSERT INTO github_oauth_tokens (builder_id, token_enc, scope, created_at, expires_at)
231
+ VALUES ($1, $2, $3, now(), $4)
232
+ ON CONFLICT (builder_id) DO UPDATE
233
+ SET token_enc = EXCLUDED.token_enc, scope = EXCLUDED.scope,
234
+ created_at = now(), expires_at = EXCLUDED.expires_at`,
235
+ [builderId, enc, scope, expiresAt],
236
+ );
237
+ return { stored: true, expires_at: expiresAt };
238
+ }
239
+
240
+ // Read + decrypt the builder's GitHub token, or null when there is none, it has
241
+ // expired, or secret-box is unconfigured. Lazily deletes an expired row so the
242
+ // table self-cleans without a cron (mirrors the deviceFlows reaping in auth.js).
243
+ // The 503-vs-403 distinction the endpoint needs is made by the endpoint checking
244
+ // secretBox.isConfigured() itself before calling this.
245
+ async function getGithubToken({ builderId, p = pool }) {
246
+ if (!secretBox.isConfigured()) return null;
247
+ const { rows } = await p.query(
248
+ `SELECT token_enc, scope, expires_at FROM github_oauth_tokens WHERE builder_id = $1`,
249
+ [builderId],
250
+ );
251
+ const row = rows[0];
252
+ if (!row) return null;
253
+ if (new Date(row.expires_at).getTime() <= Date.now()) {
254
+ await p.query(`DELETE FROM github_oauth_tokens WHERE builder_id = $1`, [builderId]).catch(() => {});
255
+ return null;
256
+ }
257
+ let token;
258
+ try { token = secretBox.decrypt(row.token_enc); }
259
+ catch { return null; } // a blob that won't decrypt (e.g. rotated master key) reads as "none".
260
+ return { token, scope: row.scope, expires_at: row.expires_at };
261
+ }
262
+
263
+ // Delete the builder's token (called after the wizard's terminal enqueue, and on
264
+ // expiry). Best-effort; safe to call when nothing is stored.
265
+ async function deleteGithubToken({ builderId, p = pool }) {
266
+ await p.query(`DELETE FROM github_oauth_tokens WHERE builder_id = $1`, [builderId]);
267
+ return { deleted: true };
268
+ }
269
+
270
+
208
271
  // leaderboard() carved into modules/economy/credits.js (BV1.R77 / ADR 0093 §1-2).
209
272
  // Core read routes resolve it via the `reward` kernel port.
210
273
 
@@ -908,4 +971,7 @@ module.exports = {
908
971
  upsertSecurityDoc,
909
972
  getSecurityDoc,
910
973
  listSecurityDocs,
974
+ upsertGithubToken,
975
+ getGithubToken,
976
+ deleteGithubToken,
911
977
  };
@@ -0,0 +1,93 @@
1
+ // GitHub repo list/create on behalf of the signed-in user — the server half of the
2
+ // onboarding wizard's repo picker (adopt) + auto-create (fresh). Task 1002099 / ADR 0143.
3
+ //
4
+ // Raw `fetch` against GitHub's REST API (the house style — see auth.js
5
+ // fetchGithubUser; no octokit dependency). The user-to-server token these take is
6
+ // the `public_repo`-scoped token the web callback captured and stored (db.js
7
+ // github_oauth_tokens); callers pass the decrypted token in, this module never
8
+ // touches storage. `fetchImpl` is injectable so tests can drive it with a mock and
9
+ // never hit the network.
10
+
11
+ const { userAgent } = require('../branding');
12
+
13
+ const GH_API = 'https://api.github.com';
14
+ const PER_PAGE = 100;
15
+ // Up to 300 most-recently-updated OWNED repos — plenty to back a picker for the vast
16
+ // majority of accounts, and a hard bound so a power user can't make us page forever
17
+ // (v1 cap; the picker filters this set client-and-server-side by substring).
18
+ const MAX_PAGES = 3;
19
+
20
+ class GithubApiError extends Error {
21
+ constructor(message, { status, code } = {}) {
22
+ super(message);
23
+ this.name = 'GithubApiError';
24
+ this.status = status;
25
+ this.code = code;
26
+ }
27
+ }
28
+
29
+ function ghHeaders(token, extra = {}) {
30
+ return {
31
+ Authorization: `Bearer ${token}`,
32
+ 'User-Agent': userAgent(),
33
+ Accept: 'application/vnd.github+json',
34
+ ...extra,
35
+ };
36
+ }
37
+
38
+ // List the signed-in user's OWN repos (affiliation=owner so `full_name` is always
39
+ // `login/repo` and the org-OAuth-policy complexity is out of scope for v1),
40
+ // most-recently-updated first, optionally filtered by a case-insensitive substring
41
+ // `q` on the full name. Returns [{ full_name, name, owner, private }].
42
+ async function listUserRepos(token, { q = '', fetchImpl = fetch } = {}) {
43
+ const out = [];
44
+ for (let page = 1; page <= MAX_PAGES; page++) {
45
+ const url = `${GH_API}/user/repos?per_page=${PER_PAGE}&page=${page}&sort=updated&affiliation=owner`;
46
+ const res = await fetchImpl(url, { headers: ghHeaders(token) });
47
+ if (res.status === 401) {
48
+ throw new GithubApiError('GitHub rejected the stored token', { status: 401, code: 'github_unauthorized' });
49
+ }
50
+ if (!res.ok) {
51
+ throw new GithubApiError(`GitHub /user/repos failed (${res.status})`, { status: res.status, code: 'github_error' });
52
+ }
53
+ const rows = await res.json();
54
+ if (!Array.isArray(rows) || rows.length === 0) break;
55
+ for (const r of rows) {
56
+ out.push({
57
+ full_name: r.full_name,
58
+ name: r.name,
59
+ owner: r.owner && r.owner.login,
60
+ private: !!r.private,
61
+ });
62
+ }
63
+ if (rows.length < PER_PAGE) break;
64
+ }
65
+ const needle = String(q || '').trim().toLowerCase();
66
+ return needle ? out.filter((r) => String(r.full_name).toLowerCase().includes(needle)) : out;
67
+ }
68
+
69
+ // Create an EMPTY repo in the signed-in user's personal account. auto_init:false =
70
+ // no README/first commit (exactly the "leave it completely empty" the manual step
71
+ // demanded); private:false because we hold only `public_repo`. Returns
72
+ // { full_name, html_url }. A 422 (name already exists / invalid) becomes a typed
73
+ // `name_taken` error the route maps to a friendly 409 rather than an opaque 500.
74
+ async function createUserRepo(token, { name, fetchImpl = fetch } = {}) {
75
+ const res = await fetchImpl(`${GH_API}/user/repos`, {
76
+ method: 'POST',
77
+ headers: ghHeaders(token, { 'Content-Type': 'application/json' }),
78
+ body: JSON.stringify({ name, private: false, auto_init: false }),
79
+ });
80
+ if (res.status === 201) {
81
+ const r = await res.json();
82
+ return { full_name: r.full_name, html_url: r.html_url };
83
+ }
84
+ if (res.status === 401) {
85
+ throw new GithubApiError('GitHub rejected the stored token', { status: 401, code: 'github_unauthorized' });
86
+ }
87
+ if (res.status === 422) {
88
+ throw new GithubApiError('A repository with that name already exists on your account', { status: 422, code: 'name_taken' });
89
+ }
90
+ throw new GithubApiError(`GitHub create-repo failed (${res.status})`, { status: res.status, code: 'github_error' });
91
+ }
92
+
93
+ module.exports = { listUserRepos, createUserRepo, GithubApiError, MAX_PAGES };
@@ -93,6 +93,13 @@ const DISCORD_STATE_COOKIE = 'gds_discord_state';
93
93
  // flow can land a builder back on the subdomain they started from (#726). Set
94
94
  // at /auth/web/start, consumed + cleared at the callback. Defaults to /builders.
95
95
  const RETURN_COOKIE = 'gds_return';
96
+ // Short-lived cookie marking that THIS sign-in opted into repo access (the
97
+ // onboarding wizard's `?repos=1`). Set at /auth/web/start, read at the callback so
98
+ // it captures + persists the returned GitHub token — and cleared there. Scoping
99
+ // capture to an explicit intent cookie (not just "the granted scope happens to
100
+ // include repo") means an ordinary hall sign-in never stores a token, even for a
101
+ // user who previously granted the broader scope (task 1002099 / ADR 0143).
102
+ const REPOS_COOKIE = 'gds_oauth_repos';
96
103
 
97
104
  // The apex domain the instance is served under, read from the instance branding
98
105
  // pack's cookieDomain (".amazonprimea.com" → "amazonprimea.com",
@@ -245,10 +252,47 @@ module.exports = function buildAuthRouter() {
245
252
  }
246
253
  });
247
254
 
255
+ // POST /auth/device/exchange — CLI federation (ADR 0141 §1). The CLI ran the
256
+ // device flow against the HUB and got a hub-signed assertion; it presents it here.
257
+ // The instance verifies the Ed25519 signature OFFLINE against the provisioned hub
258
+ // public key (aud must equal this instance's client_id, unexpired), then runs the
259
+ // instance's OWN admission gate + upsertBuilder + createSession(source='cli') and
260
+ // returns the existing { token, builder }. Only reachable when auth.idp is set.
261
+ //
262
+ // rank: public — an auth-establishment endpoint; the hub-signed assertion IS the
263
+ // bearer and is cryptographically verified before any session is minted.
264
+ router.post('/auth/device/exchange', async (req, res) => {
265
+ if (!auth.idpConfigured()) {
266
+ return res.fail('idp_not_configured', 503);
267
+ }
268
+ if (validateOrRespond(req, res, {
269
+ assertion: { required: true, type: 'string', maxLength: 4096 },
270
+ })) return;
271
+ try {
272
+ const { token, builder } = await auth.completeDeviceExchange({
273
+ assertion: req.body.assertion,
274
+ userAgent: req.headers['user-agent'],
275
+ });
276
+ res.json({ token, builder });
277
+ } catch (err) {
278
+ // Mirror the device/poll admission surface so the CLI prints the same
279
+ // "ask an Archon to invite you" path.
280
+ if (err && err.code === 'NOT_APPROVED') {
281
+ return res.status(403).json({ state: 'not_approved', error: 'not_approved' });
282
+ }
283
+ // A bad / expired / forged / wrong-audience assertion is an auth failure (401),
284
+ // not a server error — never mint a session for it.
285
+ console.error('[gds] /auth/device/exchange', err);
286
+ res.fail('invalid_assertion', { status: 401, message: String(err.message || 'invalid assertion').slice(0, 160) });
287
+ }
288
+ });
289
+
248
290
  // /auth/web — browser flow (used by the /builders page).
249
291
  // rank: public — entry into the GitHub OAuth handshake.
250
292
  router.get('/auth/web/start', (req, res) => {
251
- if (!auth.authConfigured()) {
293
+ // Either direct-GitHub creds OR platform-IdP federation must be configured
294
+ // (ADR 0141 §1) — a federated instance has no GitHub app of its own.
295
+ if (!auth.authConfigured() && !auth.idpConfigured()) {
252
296
  return res.status(503).type('text/plain').send('auth not configured');
253
297
  }
254
298
  const state = crypto.randomBytes(16).toString('hex');
@@ -281,12 +325,32 @@ module.exports = function buildAuthRouter() {
281
325
  ...sessionCookieDomainOpts(req),
282
326
  });
283
327
  }
284
- res.redirect(auth.buildAuthorizeUrl(state, redirectUri));
328
+ // Opt-in repo access (task 1002099 / ADR 0143): the onboarding wizard passes
329
+ // ?repos=1 so it can list/create the user's repos. Remember the intent in a
330
+ // short-lived cookie (same recipe as the state cookie) and request the broader
331
+ // scope on the direct-GitHub leg. Absent the flag this is a no-op — hall + CLI
332
+ // sign-ins stay identity-only.
333
+ const wantsRepos = req.query.repos === '1' || req.query.repos === 'true';
334
+ if (wantsRepos) {
335
+ res.cookie(REPOS_COOKIE, '1', {
336
+ httpOnly: true,
337
+ sameSite: 'lax',
338
+ secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
339
+ maxAge: 10 * 60 * 1000,
340
+ ...sessionCookieDomainOpts(req),
341
+ });
342
+ }
343
+ // Federate through the hub when configured (ADR 0141 §1); otherwise the direct
344
+ // GitHub authorize, carrying the repo-scope opt-in. Same CSRF state cookie either
345
+ // way — the callback validates it identically, so the hub leg is CSRF-protected too.
346
+ res.redirect(auth.idpConfigured()
347
+ ? auth.buildIdpAuthorizeUrl(state, redirectUri)
348
+ : auth.buildAuthorizeUrl(state, redirectUri, { repos: wantsRepos }));
285
349
  });
286
350
 
287
351
  // rank: public — OAuth callback; the state cookie + code are the bearer.
288
352
  router.get('/auth/web/callback', async (req, res) => {
289
- if (!auth.authConfigured()) {
353
+ if (!auth.authConfigured() && !auth.idpConfigured()) {
290
354
  return res.status(503).type('text/plain').send('auth not configured');
291
355
  }
292
356
  const expectedState = parseCookie(req, STATE_COOKIE) || parseCookie(req, STATE_COOKIE_LEGACY);
@@ -304,14 +368,19 @@ module.exports = function buildAuthRouter() {
304
368
  ));
305
369
  }
306
370
  try {
307
- // Must match the PINNED redirect_uri used at /auth/web/start (#734) — the
308
- // GitHub token exchange requires an exact string match.
371
+ // Must match the PINNED redirect_uri used at /auth/web/start (#734) — both
372
+ // the GitHub token exchange and the hub /sso/token redemption require an
373
+ // exact string match against the value sent at authorize time.
309
374
  const redirectUri = `${WEB_REDIRECT_ORIGIN}/api/gds/auth/web/callback`;
310
- const { token, expiresAt } = await auth.completeWebFlow({
311
- code,
312
- redirectUri,
313
- userAgent: req.headers['user-agent'],
314
- });
375
+ // Did THIS sign-in opt into repo access? (the wizard's ?repos=1 intent cookie)
376
+ const wantsRepos = parseCookie(req, REPOS_COOKIE) === '1';
377
+ // Federated (hub) vs direct-GitHub — same downstream: admission gate,
378
+ // upsertBuilder, createSession, gds_session cookie (ADR 0141 §1). The direct
379
+ // leg additionally captures the opt-in repo token (task 1002099 / ADR 0143);
380
+ // the hub leg has no direct GitHub token, so the wizard degrades to manual entry.
381
+ const { token, expiresAt, builder, githubToken, githubScope } = auth.idpConfigured()
382
+ ? await auth.completeIdpFlow({ code, redirectUri, userAgent: req.headers['user-agent'] })
383
+ : await auth.completeWebFlow({ code, redirectUri, userAgent: req.headers['user-agent'], captureRepoToken: wantsRepos });
315
384
  res.cookie(SESSION_COOKIE, token, {
316
385
  httpOnly: true,
317
386
  sameSite: 'lax',
@@ -322,15 +391,28 @@ module.exports = function buildAuthRouter() {
322
391
  // amazonprimea.com + builders. + status. (#726); host-only elsewhere.
323
392
  ...sessionCookieDomainOpts(req),
324
393
  });
394
+ // Persist the wizard's repo token (task 1002099 / ADR 0143): encrypted at rest,
395
+ // short-TTL, keyed to the builder — the repo picker/creator reads it back over
396
+ // the next few minutes. Gated on secret-box being configured (upsertGithubToken
397
+ // no-ops when it isn't). Best-effort: a capture failure must NEVER break sign-in.
398
+ if (wantsRepos && githubToken) {
399
+ try {
400
+ await db.upsertGithubToken({ builderId: builder.id, token: githubToken, scope: githubScope });
401
+ } catch (e) {
402
+ console.error('[gds] web/callback: repo-token capture failed (non-blocking):', e.message);
403
+ }
404
+ }
325
405
  // The state cookie is apex-scoped now (#734) — clear it with the same
326
406
  // domain or the browser keeps it. Clear the host-only form too for any
327
- // cookie set before this change.
407
+ // cookie set before this change. Clear the repos-intent cookie the same way.
328
408
  const domainOpts = sessionCookieDomainOpts(req);
329
409
  res.clearCookie(STATE_COOKIE);
330
410
  res.clearCookie(STATE_COOKIE_LEGACY);
411
+ res.clearCookie(REPOS_COOKIE);
331
412
  if (domainOpts.domain) {
332
413
  res.clearCookie(STATE_COOKIE, domainOpts);
333
414
  res.clearCookie(STATE_COOKIE_LEGACY, domainOpts);
415
+ res.clearCookie(REPOS_COOKIE, domainOpts);
334
416
  }
335
417
  // Land back where sign-in began if a validated return target was stashed
336
418
  // (e.g. the builders subdomain bounced through here); else the hall.
@@ -0,0 +1,90 @@
1
+ // src/bongos/routes/github.js — GitHub repo list/create for the signed-in user, the
2
+ // server half of the onboarding wizard's repo picker (adopt) + auto-create (fresh).
3
+ // Task 1002099 / ADR 0143.
4
+ //
5
+ // These act on the user's OWN GitHub identity, so they live in core auth alongside
6
+ // the sign-in flow that captured the token — not the provisioning module (whose
7
+ // charter holds no tokens / calls no cloud API, ADR 0111 §2). They read the
8
+ // short-lived, secret-box-encrypted `public_repo` token the web callback stored
9
+ // (db.js github_oauth_tokens). Two "please reconnect / fall back" signals the wizard
10
+ // branches on:
11
+ // • no stored token → repo_scope_required (403) → prompt a repos sign-in
12
+ // • secret-box unconfigured → github_repos_unavailable (503) → manual repo entry
13
+ // Both are behind requireBuilder + the router-wide rate-limit middleware.
14
+
15
+ const express = require('express');
16
+ const auth = require('../auth');
17
+ const db = require('../db');
18
+ const secretBox = require('../secret-box');
19
+ const { listUserRepos, createUserRepo } = require('../github-repos');
20
+ const { validateOrRespond } = require('./_helpers');
21
+
22
+ // GitHub repo names: 1–100 chars of [A-Za-z0-9._-]. We validate strictly for a clean
23
+ // 400; anything GitHub still rejects surfaces as the 422→409 name_taken path.
24
+ const REPO_NAME_RE = /^[A-Za-z0-9._-]{1,100}$/;
25
+
26
+ // Resolve the caller's stored repo token, or send the right failure and return null.
27
+ // Centralizes the 503-vs-403 decision both endpoints share.
28
+ async function resolveRepoToken(req, res) {
29
+ if (!secretBox.isConfigured()) {
30
+ res.fail('github_repos_unavailable', { status: 503, message: 'Repo access is not configured on this server. Enter the repository manually.' });
31
+ return null;
32
+ }
33
+ const row = await db.getGithubToken({ builderId: req.builder.id });
34
+ if (!row) {
35
+ res.fail('repo_scope_required', { status: 403, message: 'Grant repository access to list or create repos.' });
36
+ return null;
37
+ }
38
+ return row.token;
39
+ }
40
+
41
+ module.exports = function buildGithubRouter() {
42
+ const router = express.Router();
43
+
44
+ // rank: any-builder — acts only on the caller's OWN GitHub identity (the token
45
+ // they granted). No rank gate beyond being a signed-in builder.
46
+ router.get('/github/repos', auth.requireBuilder, async (req, res) => {
47
+ const token = await resolveRepoToken(req, res);
48
+ if (!token) return;
49
+ try {
50
+ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 100) : '';
51
+ const repos = await listUserRepos(token, { q });
52
+ res.json({ repos });
53
+ } catch (err) {
54
+ if (err && err.status === 401) {
55
+ // The stored token was revoked/expired GitHub-side — drop it and re-prompt.
56
+ await db.deleteGithubToken({ builderId: req.builder.id }).catch(() => {});
57
+ return res.fail('repo_scope_required', { status: 403, message: 'Your GitHub authorization expired. Reconnect to list your repos.' });
58
+ }
59
+ console.error('[gds] GET /github/repos', err && err.message);
60
+ res.fail('github_error', { status: 502, message: 'GitHub could not be reached. Try again, or enter the repository manually.' });
61
+ }
62
+ });
63
+
64
+ // rank: any-builder — creates a repo in the caller's OWN account.
65
+ router.post('/github/repos', auth.requireBuilder, async (req, res) => {
66
+ if (validateOrRespond(req, res, { name: { required: true, type: 'string', maxLength: 100 } })) return;
67
+ const name = String((req.body && req.body.name) || '').trim();
68
+ if (!REPO_NAME_RE.test(name)) {
69
+ return res.fail('bad_repo_name', { status: 400, message: 'Repository names use letters, numbers, and . _ - (1–100 chars).' });
70
+ }
71
+ const token = await resolveRepoToken(req, res);
72
+ if (!token) return;
73
+ try {
74
+ const repo = await createUserRepo(token, { name });
75
+ res.status(201).json({ repo });
76
+ } catch (err) {
77
+ if (err && err.code === 'name_taken') {
78
+ return res.fail('name_taken', { status: 409, message: 'You already have a repository with that name. Pick another, or adopt the existing one.' });
79
+ }
80
+ if (err && err.status === 401) {
81
+ await db.deleteGithubToken({ builderId: req.builder.id }).catch(() => {});
82
+ return res.fail('repo_scope_required', { status: 403, message: 'Your GitHub authorization expired. Reconnect to create the repo.' });
83
+ }
84
+ console.error('[gds] POST /github/repos', err && err.message);
85
+ res.fail('github_error', { status: 502, message: 'GitHub could not create the repo. Try again, or create it manually.' });
86
+ }
87
+ });
88
+
89
+ return router;
90
+ };
@@ -32,7 +32,7 @@ const { corsPublicGet, asyncHandler } = require('./_helpers');
32
32
  const { clientBranding } = require('../../branding');
33
33
  const { clientModules, isModuleEnabled } = require('../../modules');
34
34
  const { loadRepoInfo } = require('../repo-info');
35
- const { authConfigured, openEnrollmentEnabled } = require('../auth');
35
+ const { authConfigured, openEnrollmentEnabled, loadIdpConfig } = require('../auth');
36
36
  const { RANK_TIER } = require('../permission-path-check');
37
37
 
38
38
  // The manifest format id — bumped when the SHAPE changes so a CLI can detect a
@@ -59,6 +59,7 @@ function buildInstanceManifest({
59
59
  authConfigured: authOk,
60
60
  openEnrollment,
61
61
  devBoxEnabled,
62
+ idp = null,
62
63
  } = {}) {
63
64
  const b = branding || {};
64
65
  const rankLabels = b.rankLabels || {};
@@ -89,8 +90,15 @@ function buildInstanceManifest({
89
90
  ranks,
90
91
  repo: repoOut,
91
92
  auth: {
92
- methods: authOk ? AUTH_METHODS.slice() : [],
93
- configured: !!authOk,
93
+ // Sign-in works when the instance has its OWN GitHub app (authOk) OR it
94
+ // federates through the hub (idp) — a hosted instance has no app of its own.
95
+ methods: (authOk || idp) ? AUTH_METHODS.slice() : [],
96
+ configured: !!authOk || !!idp,
97
+ // Federation hint (ADR 0141 §1): present only when this instance federates.
98
+ // origin + client_id are PUBLIC (like an OAuth client_id); the client SECRET
99
+ // is never projected here. The CLI reads this to route device-flow + web
100
+ // sign-in through the hub instead of expecting a local GitHub app.
101
+ idp: idp || null,
94
102
  },
95
103
  admission: { policy: openEnrollment ? 'open' : 'invite-only' },
96
104
  devBox: { available: !!devBoxEnabled },
@@ -109,6 +117,7 @@ module.exports = function buildInstanceRouter() {
109
117
  // err.message if branding/modules fail to resolve (misconfigured instance) —
110
118
  // never leaks internals.
111
119
  router.get('/instance', asyncHandler('GET /instance', (_req, res) => {
120
+ const idpCfg = loadIdpConfig();
112
121
  res.json(buildInstanceManifest({
113
122
  branding: clientBranding(),
114
123
  modules: clientModules(),
@@ -117,6 +126,9 @@ module.exports = function buildInstanceRouter() {
117
126
  authConfigured: authConfigured(),
118
127
  openEnrollment: openEnrollmentEnabled(),
119
128
  devBoxEnabled: isModuleEnabled('dev-box'),
129
+ // Project OUT the client secret — the manifest is public; only origin +
130
+ // client_id (both public) advertise federation.
131
+ idp: idpCfg ? { origin: idpCfg.origin, client_id: idpCfg.clientId } : null,
120
132
  }));
121
133
  }, { message: false, errorCode: 'instance_manifest_failed' }));
122
134
 
@@ -20,6 +20,7 @@ const express = require('express');
20
20
  const buildHealthzRouter = require('./routes/healthz');
21
21
  const buildInstanceRouter = require('./routes/instance');
22
22
  const buildAuthRouter = require('./routes/auth');
23
+ const buildGithubRouter = require('./routes/github');
23
24
  const buildMeRouter = require('./routes/me');
24
25
  const buildBuildersRouter = require('./routes/builders');
25
26
  // The LIFECYCLE routes (tasks · claims · versions · done-when · goals ·
@@ -116,6 +117,7 @@ function buildGdsRouter() {
116
117
  router.use(buildHealthzRouter());
117
118
  router.use(buildInstanceRouter());
118
119
  router.use(buildAuthRouter());
120
+ router.use(buildGithubRouter());
119
121
  router.use(buildMeRouter());
120
122
  router.use(buildBuildersRouter());
121
123
  // tasks · claims · versions · done-when · goals · dependencies · analytics now
package/src/branding.js CHANGED
@@ -44,6 +44,11 @@ const ENV_OVERRIDES = [
44
44
  ['OAUTH_ORIGIN', ['domains', 'oauthOrigin']],
45
45
  ['PROVISIONING_ORIGIN', ['domains', 'provisioningOrigin']],
46
46
  ['COOKIE_DOMAIN', ['domains', 'cookieDomain']],
47
+ // Cross-site identity federation flip (ADR 0141 §4). Provisioning sets
48
+ // <PREFIX>_AUTH_IDP=true per hosted instance (config/branding.neutral.json ships
49
+ // idp:false), so a co-tenant instance sharing the neutral pack still federates
50
+ // individually. 'bool'-coerced below — auth.js idpEnabled() reads it as strict === true.
51
+ ['AUTH_IDP', ['auth', 'idp'], 'bool'],
47
52
  ];
48
53
 
49
54
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
@@ -69,6 +74,12 @@ function deepFreeze(o) {
69
74
  return o;
70
75
  }
71
76
 
77
+ // This is the ONE structured host-identity contract. Project context (vision/world/visual-style
78
+ // prose in docs/project-context.md) is deliberately PROSE-ONLY — there is no parallel resolver for
79
+ // it, because nothing in the platform reads project context as data; it is written for agents to
80
+ // read. If a host value must be CODE-READ, add it HERE (branding), not a new project-context
81
+ // resolver. Decided in ADR 0142 (audit H11 / task 1002224).
82
+ //
72
83
  // Pure resolver — all inputs injected, so tests drive it with zero I/O.
73
84
  function resolveBranding({ neutral = {}, instance = {}, env = {} } = {}) {
74
85
  // structuredClone so the merged result shares no nested references with the
@@ -76,9 +87,13 @@ function resolveBranding({ neutral = {}, instance = {}, env = {} } = {}) {
76
87
  // the caller's input objects (deepMerge shallow-copies only the top level).
77
88
  const resolved = structuredClone(deepMerge(neutral, instance));
78
89
  const prefix = resolved.envPrefix || 'CLOUDBONGOS';
79
- for (const [suffix, p] of ENV_OVERRIDES) {
90
+ for (const [suffix, p, coerce] of ENV_OVERRIDES) {
80
91
  const v = env[`${prefix}_${suffix}`];
81
- if (v !== undefined && v !== '') setPath(resolved, p, v);
92
+ if (v === undefined || v === '') continue;
93
+ // Most overrides are string-valued (origins, cookie domain) and set verbatim. A
94
+ // 'bool'-tagged override (auth.idp) coerces the env string to a real boolean —
95
+ // idpEnabled() checks `=== true`, so an uncoerced "true" would never federate.
96
+ setPath(resolved, p, coerce === 'bool' ? /^(1|true|yes|on)$/i.test(String(v)) : v);
82
97
  }
83
98
  const missing = REQUIRED.filter((p) => {
84
99
  const v = getPath(resolved, p);
@@ -50,6 +50,21 @@ function envPrefix(b = safeBrand()) {
50
50
  return b.envPrefix || FALLBACK_ENV_PREFIX;
51
51
  }
52
52
 
53
+ // The default API base the CLI targets when there is no <PREFIX>_API_BASE env
54
+ // override AND no logged-in session api_base — i.e. the last-resort fallback.
55
+ // Resolves to THIS instance's own publicOrigin from the branding pack (OTB →
56
+ // https://amazonprimea.com, vanilla → http://localhost:3000), so the portable
57
+ // core never hard-codes another instance's URL (task 1002201 — pre-ADR-0139 the
58
+ // literal 'https://amazonprimea.com' was baked into cli-lib, sending every
59
+ // unconfigured consumer at the founder's game box). Single source of truth: every
60
+ // CLI base resolution routes through here. Last-resort http://localhost:3000 when
61
+ // branding can't be read at all — a local dev origin, never a foreign prod, so an
62
+ // unconfigured raw CLI fails safe rather than acting against someone else's DB.
63
+ function defaultApiBase(b = safeBrand()) {
64
+ const origin = b && b.domains && b.domains.publicOrigin;
65
+ return (typeof origin === 'string' && origin.trim()) || 'http://localhost:3000';
66
+ }
67
+
53
68
  // Absolute path to the instance config dir — the WRITE target and first read.
54
69
  function configHome(b = safeBrand()) {
55
70
  return path.join(os.homedir(), '.config', configDirName(b));
@@ -186,6 +201,7 @@ module.exports = {
186
201
  LEGACY_ENV_PREFIXES,
187
202
  configDirName,
188
203
  envPrefix,
204
+ defaultApiBase,
189
205
  configHome,
190
206
  configReadDirs,
191
207
  configPath,
package/src/module-api.js CHANGED
@@ -218,8 +218,27 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
218
218
  // post-dates the published 1.17.3 tarball — a new user-facing feature set with no
219
219
  // doorway export removal, so semver MINOR. NOT yet published to npm (that is the
220
220
  // follow-on release task); amazonprimea still runs the vendored 1.17.3.
221
+ // 1.18.1 — go-live cut deployed to cloudbongos.com (owner-directed, 2026-07-14). Rolling
222
+ // 1.18.0 content PLUS the onboarding GitHub repo picker + auto-create (task 1002099,
223
+ // ADR 0143 — core auth: opt-in public_repo scope, encrypted short-TTL token store,
224
+ // GET/POST /github/repos) and the goal 1000041 cross-project hub features
225
+ // (platform-identity module, ADR 0141). Adds no doorway export → semver PATCH.
226
+ // 1.18.2 — go-live cut for cloudbongos.com (owner-directed, 2026-07-14). Rolling 1.18.1
227
+ // content PLUS the /create onboarding repo-picker rendering fix (task 1002228):
228
+ // the Home step's `hidden` attribute was defeated by author display rules
229
+ // (.field/.row2), so the adopt-picker / fresh-name / manual sub-panels all showed
230
+ // at once, and #ghConnect never appeared (toggled via `hidden` instead of its .on
231
+ // class). A CSS/markup-only fix in public-landing/create.html. No doorway export
232
+ // change → semver PATCH.
233
+ // 1.18.3 — package-core .gitignore pin fix (task 1002229, idea 1000543). `npm install` never
234
+ // materializes .gitignore/.npmignore/.npmrc into an installed package, but package-core
235
+ // pinned a tracked .gitignore in tree_sha256 → every vendored `bongos upgrade` pin-verify
236
+ // failed with `missing installed file: .gitignore` and auto-rolled-back (the workaround
237
+ // was --skip-pin-verify, which the unattended auto-upgrade subscription cannot pass).
238
+ // package-core now drops npm-stripped files from the manifest + pin so it describes exactly
239
+ // what npm installs. Build-tool fix, no doorway export change → semver PATCH.
221
240
  // ---------------------------------------------------------------------------
222
- const CORE_VERSION = '1.18.0'; // see the changelog above — 1.18.0 is the first cut from the cloud-bongos authoritative source (re-seed task 2190); includes the builders'-hall UI redesign beyond published 1.17.3
241
+ const CORE_VERSION = '1.18.3'; // package-core pin honesty for vendored upgrades (task 1002229 / idea 1000543); see changelog above
223
242
 
224
243
  // A namespaced logger so a module's log lines are attributable + consistent.
225
244
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -245,6 +264,15 @@ module.exports = {
245
264
  // tokens. A module's own bootstrap routes flag themselves; everything else
246
265
  // stays closed to box tokens (the ADR 0016 trust boundary).
247
266
  allowBoxScope: auth.allowBoxScope,
267
+ // resolveSession(req) — resolve the request to a live session row (or null)
268
+ // WITHOUT the 401-or-pass behaviour of requireBuilder. A module route that
269
+ // must make its own auth decision — e.g. a browser page that REDIRECTS to
270
+ // sign-in on a missing session rather than returning JSON 401 (the
271
+ // platform-identity /sso/authorize leg, ADR 0141) — needs the raw resolve.
272
+ // Returns the same shape lookupSession does (builder_id, github_id,
273
+ // github_login, display_name, avatar_url, rank, source, status); rank is
274
+ // still never an authority a module should trust for cross-instance identity.
275
+ resolveSession: (...a) => auth.resolveSession(...a),
248
276
 
249
277
  // --- data — the shared Postgres pool. A module owns its namespaced tables
250
278
  // (<key>_*) via its own migrations (BV1.R46); it reaches them through this.