@bongos/core 1.17.3 → 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 (163) hide show
  1. package/.bongos-core.json +212 -332
  2. package/.devcontainer/healthcheck.sh +0 -0
  3. package/.devcontainer/init-firewall.sh +0 -0
  4. package/.devcontainer/post-create.sh +0 -0
  5. package/GOVERNANCE.md +1 -1
  6. package/README.md +26 -22
  7. package/bin/bongos.js +0 -0
  8. package/config/branding.neutral.json +5 -1
  9. package/docker-entrypoint.sh +0 -0
  10. package/docs/adr/0142-project-context-is-prose-only.md +41 -0
  11. package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +35 -0
  12. package/docs/adr/README.md +6 -0
  13. package/docs/api/openapi.json +1630 -632
  14. package/docs/api-reference.md +50 -15
  15. package/docs/file-map.md +0 -18
  16. package/docs/onboarding/diagrams/05-versioning.mmd +4 -6
  17. package/docs/onboarding/diagrams/assertions.json +5 -9
  18. package/docs/recipes/packaging-the-core.md +13 -0
  19. package/docs/recipes/upgrading-the-core.md +5 -2
  20. package/migrations/039_newcomer_friendly_column.sql +5 -0
  21. package/migrations/102_remove_box_first_provision_approval.sql +4 -0
  22. package/migrations/core_192_github_oauth_tokens.sql +33 -0
  23. package/modules/hall-ui/public/builders.js +37 -0
  24. package/modules/hall-ui/public/goals-page.js +98 -176
  25. package/modules/hall-ui/public/goals.css +183 -0
  26. package/modules/hall-ui/public/goals.html +35 -261
  27. package/modules/hall-ui/public/goals.js +7 -2
  28. package/modules/hall-ui/public/hall-widgets.js +12 -4
  29. package/modules/hall-ui/public/my-projects.html +78 -0
  30. package/modules/hall-ui/public/my-projects.js +142 -0
  31. package/modules/hall-ui/public/scouting.html +45 -0
  32. package/modules/hall-ui/public/scouting.js +73 -0
  33. package/modules/hall-ui/public/settings.css +355 -0
  34. package/modules/hall-ui/public/settings.html +86 -414
  35. package/modules/hall-ui/public/settings.js +96 -35
  36. package/modules/hall-ui/public/style.css +31 -0
  37. package/modules/hall-ui/public/watch.css +184 -0
  38. package/modules/hall-ui/public/watch.html +111 -285
  39. package/modules/hall-ui/public/watch.js +59 -21
  40. package/modules/status-ui/public/explore.html +54 -0
  41. package/modules/status-ui/public/explore.js +67 -0
  42. package/package-lock.json +2 -2
  43. package/package.json +1 -1
  44. package/public-landing/create.html +292 -19
  45. package/public-landing/index.html +11 -76
  46. package/scripts/cron/style_guide_audit.sh +0 -0
  47. package/scripts/gds/api.js +0 -0
  48. package/scripts/gds/architect-audit.js +0 -0
  49. package/scripts/gds/audit-authorship.sh +0 -0
  50. package/scripts/gds/audit-replay.sh +0 -0
  51. package/scripts/gds/box.js +0 -0
  52. package/scripts/gds/capture.js +0 -0
  53. package/scripts/gds/cli-lib.js +11 -5
  54. package/scripts/gds/conductor-dispatch.js +0 -0
  55. package/scripts/gds/coreB-carve-proof.sh +0 -0
  56. package/scripts/gds/db-backup-nightly.sh +0 -0
  57. package/scripts/gds/db-backup-offsite.sh +0 -0
  58. package/scripts/gds/doctor.js +0 -0
  59. package/scripts/gds/grade-smoke.js +0 -0
  60. package/scripts/gds/hooks/pre-push.js +0 -0
  61. package/scripts/gds/install-git-hooks.js +0 -0
  62. package/scripts/gds/lint-migrations.js +102 -0
  63. package/scripts/gds/login.js +42 -10
  64. package/scripts/gds/main-audit.js +0 -0
  65. package/scripts/gds/mirror-redact.js +5 -0
  66. package/scripts/gds/oauth-secret.js +0 -0
  67. package/scripts/gds/onboarding-analyze.js +0 -0
  68. package/scripts/gds/onboarding-ship.js +0 -0
  69. package/scripts/gds/package-core.js +263 -4
  70. package/scripts/gds/paste-token.js +0 -0
  71. package/scripts/gds/portability-smoke-art.sh +0 -0
  72. package/scripts/gds/portability-smoke-memory.sh +0 -0
  73. package/scripts/gds/provision.js +270 -11
  74. package/scripts/gds/publish-manifest.js +15 -0
  75. package/scripts/gds/push-main.js +0 -0
  76. package/scripts/gds/render-ideas.js +0 -0
  77. package/scripts/gds/run-unit-tests.js +38 -14
  78. package/scripts/gds/security-baseline-check.sh +0 -0
  79. package/scripts/gds/session.js +0 -0
  80. package/scripts/gds/setup.js +41 -0
  81. package/scripts/gds/ship.js +126 -32
  82. package/scripts/gds/smoke-batch-claim.sh +0 -0
  83. package/scripts/gds/smoke-batch-release.sh +0 -0
  84. package/scripts/gds/smoke-box.sh +0 -0
  85. package/scripts/gds/smoke-claim-consistency.sh +0 -0
  86. package/scripts/gds/smoke-dependencies.sh +0 -0
  87. package/scripts/gds/smoke-write.sh +0 -0
  88. package/scripts/gds/upgrade.js +147 -5
  89. package/scripts/hall-preview/server.js +18 -1
  90. package/scripts/migrate.sh +10 -0
  91. package/scripts/migrate_cost_ledger.sh +0 -0
  92. package/scripts/render-diagrams.sh +0 -0
  93. package/scripts/security/dep-audit.js +0 -0
  94. package/scripts/setup-builder.js +0 -0
  95. package/scripts/setup-builder.sh +0 -0
  96. package/scripts/snapshots/maybe-render-on-deploy.sh +0 -0
  97. package/src/bongos/auth.js +362 -5
  98. package/src/bongos/db.js +66 -0
  99. package/src/bongos/github-repos.js +93 -0
  100. package/src/bongos/routes/auth.js +93 -11
  101. package/src/bongos/routes/github.js +90 -0
  102. package/src/bongos/routes/instance.js +15 -3
  103. package/src/bongos/routes.js +2 -0
  104. package/src/branding.js +17 -2
  105. package/src/instance-config.js +16 -0
  106. package/src/module-api.js +48 -1
  107. package/tests/branding.mjs +29 -23
  108. package/tests/github_repos_helpers.mjs +91 -0
  109. package/tests/github_token_store.mjs +121 -0
  110. package/tests/idp_device_exchange.mjs +105 -0
  111. package/tests/idp_federation.mjs +100 -0
  112. package/tests/migrations_forward_safe.mjs +86 -0
  113. package/tests/module_api.mjs +1 -1
  114. package/tests/package_core.mjs +246 -1
  115. package/tests/provision.mjs +226 -0
  116. package/tests/pruned_lockfile_integrity.mjs +79 -0
  117. package/tests/ship_ci_deploy.mjs +60 -0
  118. package/tests/upgrade.mjs +254 -5
  119. package/.gitignore +0 -91
  120. package/scripts/hall-preview/fixtures/access-requests.json +0 -1
  121. package/scripts/hall-preview/fixtures/achievements.json +0 -76
  122. package/scripts/hall-preview/fixtures/analytics__builder.json +0 -251
  123. package/scripts/hall-preview/fixtures/audit-log.json +0 -1
  124. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +0 -1
  125. package/scripts/hall-preview/fixtures/builders__active-claims.json +0 -1
  126. package/scripts/hall-preview/fixtures/builders__directory.json +0 -81
  127. package/scripts/hall-preview/fixtures/builders__roster.json +0 -1
  128. package/scripts/hall-preview/fixtures/goals.json +0 -502
  129. package/scripts/hall-preview/fixtures/goals__25.json +0 -96
  130. package/scripts/hall-preview/fixtures/goals__26.json +0 -67
  131. package/scripts/hall-preview/fixtures/goals__3.json +0 -103
  132. package/scripts/hall-preview/fixtures/goals__35.json +0 -49
  133. package/scripts/hall-preview/fixtures/goals__conflicts.json +0 -154
  134. package/scripts/hall-preview/fixtures/goals__detail.json +0 -96
  135. package/scripts/hall-preview/fixtures/goals__inbox.json +0 -5
  136. package/scripts/hall-preview/fixtures/goals__requests.json +0 -3
  137. package/scripts/hall-preview/fixtures/grades__by-builder.json +0 -1
  138. package/scripts/hall-preview/fixtures/inbox.json +0 -330
  139. package/scripts/hall-preview/fixtures/instance.json +0 -124
  140. package/scripts/hall-preview/fixtures/leaderboard.json +0 -184
  141. package/scripts/hall-preview/fixtures/me.json +0 -476
  142. package/scripts/hall-preview/fixtures/me__recent-ships.json +0 -304
  143. package/scripts/hall-preview/fixtures/override-requests.json +0 -1
  144. package/scripts/hall-preview/fixtures/provisioning__fleet.json +0 -1
  145. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +0 -1
  146. package/scripts/hall-preview/fixtures/provisioning__instances.json +0 -1
  147. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +0 -516
  148. package/scripts/hall-preview/fixtures/public__bounty-table.json +0 -37
  149. package/scripts/hall-preview/fixtures/public__cost-summary.json +0 -742
  150. package/scripts/hall-preview/fixtures/public__estimation-drift.json +0 -41
  151. package/scripts/hall-preview/fixtures/public__grades.json +0 -50
  152. package/scripts/hall-preview/fixtures/public__progress.json +0 -124
  153. package/scripts/hall-preview/fixtures/public__recent-shipped.json +0 -426
  154. package/scripts/hall-preview/fixtures/public__repo-health.json +0 -160
  155. package/scripts/hall-preview/fixtures/public__repo-info.json +0 -5
  156. package/scripts/hall-preview/fixtures/security__docs.json +0 -1
  157. package/scripts/hall-preview/fixtures/security__reports.json +0 -1
  158. package/scripts/hall-preview/fixtures/sessions__search.json +0 -1
  159. package/scripts/hall-preview/fixtures/tasks.json +0 -2420
  160. package/scripts/hall-preview/fixtures/tasks__claimable.json +0 -2555
  161. package/scripts/hall-preview/fixtures/tasks__detail.json +0 -111
  162. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +0 -330
  163. package/scripts/hall-preview/fixtures/versions.json +0 -89
@@ -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
@@ -199,8 +199,46 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
199
199
  // plus a THROWAWAY `updateCanary` marker removed in the phase-E follow-up. A
200
200
  // backwards-compatible additive change with no API removal is exactly semver
201
201
  // PATCH. Every `^1.x` / `^1.17` module pin is satisfied by 1.17.2.
202
+ // 1.17.3 — task 2183 (R89 cutover prereq): republish so the packaged core carries the
203
+ // F6 migrate.sh fix (task 2180). Post-split, `scripts/migrate.sh` cd'd into the
204
+ // installed @bongos/core package before resolving the instance root, so a literal
205
+ // `npm run migrate` read the core's VANILLA module config and SILENTLY skipped
206
+ // every host + flag-gated module migration (rehearsal: 115 rows, no game schema;
207
+ // from the instance cwd, 121). 2180 fixed the script to resolve INSTANCE_ROOT from
208
+ // the invocation cwd (INIT_CWD/$PWD → the instance's bongos.config.json) and added
209
+ // a DB-free `--print-roots` regression guard. Published 1.17.2 PREDATES 2180, so a
210
+ // consumer pinning 1.17.2 would still skip host migrations — hence a PATCH republish
211
+ // at the F6-inclusive main. A functional-file bug fix with no doorway export change
212
+ // is exactly semver PATCH. Every `^1.x` / `^1.17` module pin is satisfied by 1.17.3.
213
+ // 1.18.0 — task 2190 (R89 follow-up): first version cut from the cloud-bongos AUTHORITATIVE
214
+ // source repo. Re-seeds the standalone core (which had frozen at the 2026-07-03
215
+ // v1.14.0 extraction while core dev continued inside amazonprimea through 1.17.3)
216
+ // from amazonprimea's last-with-core commit 345494f2. Content is 1.17.3 PLUS the
217
+ // builders'-hall UI redesign (developed in amazonprimea as tasks 2171/2172/2173/2184) that
218
+ // post-dates the published 1.17.3 tarball — a new user-facing feature set with no
219
+ // doorway export removal, so semver MINOR. NOT yet published to npm (that is the
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.
202
240
  // ---------------------------------------------------------------------------
203
- const CORE_VERSION = '1.17.2'; // see the changelog above — 1.17.2 is the task-2148 canary upgrade-proof bump (adds /version coreVersion; no doorway export change)
241
+ const CORE_VERSION = '1.18.3'; // package-core pin honesty for vendored upgrades (task 1002229 / idea 1000543); see changelog above
204
242
 
205
243
  // A namespaced logger so a module's log lines are attributable + consistent.
206
244
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -226,6 +264,15 @@ module.exports = {
226
264
  // tokens. A module's own bootstrap routes flag themselves; everything else
227
265
  // stays closed to box tokens (the ADR 0016 trust boundary).
228
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),
229
276
 
230
277
  // --- data — the shared Postgres pool. A module owns its namespaced tables
231
278
  // (<key>_*) via its own migrations (BV1.R46); it reaches them through this.
@@ -19,12 +19,15 @@ const NEUTRAL = {
19
19
  envPrefix: 'CLOUDBONGOS',
20
20
  };
21
21
 
22
- const OTB = {
23
- identity: { productName: 'Off the Boats', worldName: 'Amazonprimea', company: { name: 'Hermeslines', site: 'https://hermeslines.com' } },
24
- llmProjectName: 'Off the Boats',
25
- domains: { publicOrigin: 'https://amazonprimea.com', cookieDomain: '.amazonprimea.com' },
26
- currency: { label: 'drachmae' },
27
- envPrefix: 'OTB',
22
+ // A GENERIC sample instance pack — exercises the resolver's override/merge/precedence logic
23
+ // without baking any real instance's identity into the (published) test. (task 1002219 / audit H5:
24
+ // swept the former customer-1 sample — Off the Boats / amazonprimea / Hermeslines — to neutral.)
25
+ const SAMPLE = {
26
+ identity: { productName: 'Acme Rockets', worldName: 'Acme', company: { name: 'Acme Corp', site: 'https://acme.example' } },
27
+ llmProjectName: 'Acme Rockets',
28
+ domains: { publicOrigin: 'https://acme.example', cookieDomain: '.acme.example' },
29
+ currency: { label: 'tokens' },
30
+ envPrefix: 'ACME',
28
31
  };
29
32
 
30
33
  test('neutral starter resolves to the plain identity (no host leak)', () => {
@@ -34,33 +37,33 @@ test('neutral starter resolves to the plain identity (no host leak)', () => {
34
37
  });
35
38
 
36
39
  test('instance config overrides the neutral starter, deep-merged', () => {
37
- const b = resolveBranding({ neutral: NEUTRAL, instance: OTB });
38
- assert.equal(b.identity.worldName, 'Amazonprimea');
39
- assert.equal(b.identity.company.name, 'Hermeslines');
40
- assert.equal(b.domains.cookieDomain, '.amazonprimea.com');
40
+ const b = resolveBranding({ neutral: NEUTRAL, instance: SAMPLE });
41
+ assert.equal(b.identity.worldName, 'Acme');
42
+ assert.equal(b.identity.company.name, 'Acme Corp');
43
+ assert.equal(b.domains.cookieDomain, '.acme.example');
41
44
  // a field present only in neutral survives the merge:
42
45
  assert.equal(b.domains.buildersOrigin, 'http://localhost:3000');
43
46
  });
44
47
 
45
48
  test('env override beats instance config (precedence)', () => {
46
- const b = resolveBranding({ neutral: NEUTRAL, instance: OTB, env: { OTB_PUBLIC_ORIGIN: 'https://staging.example.com' } });
49
+ const b = resolveBranding({ neutral: NEUTRAL, instance: SAMPLE, env: { ACME_PUBLIC_ORIGIN: 'https://staging.example.com' } });
47
50
  assert.equal(b.domains.publicOrigin, 'https://staging.example.com');
48
51
  });
49
52
 
50
53
  test('env override is ignored when empty', () => {
51
- const b = resolveBranding({ neutral: NEUTRAL, instance: OTB, env: { OTB_PUBLIC_ORIGIN: '' } });
52
- assert.equal(b.domains.publicOrigin, 'https://amazonprimea.com');
54
+ const b = resolveBranding({ neutral: NEUTRAL, instance: SAMPLE, env: { ACME_PUBLIC_ORIGIN: '' } });
55
+ assert.equal(b.domains.publicOrigin, 'https://acme.example');
53
56
  });
54
57
 
55
58
  test('provisioningOrigin: optional, passes through config, env-overridable (task 2101)', () => {
56
59
  // Not required — unset resolves to undefined, and callers fall back to publicOrigin.
57
- assert.equal(resolveBranding({ neutral: NEUTRAL, instance: OTB }).domains.provisioningOrigin, undefined);
60
+ assert.equal(resolveBranding({ neutral: NEUTRAL, instance: SAMPLE }).domains.provisioningOrigin, undefined);
58
61
  // Set in the instance pack, it survives the deep-merge.
59
- const inst = resolveBranding({ neutral: NEUTRAL, instance: { ...OTB, domains: { ...OTB.domains, provisioningOrigin: 'https://get.cloudbongos.com' } } });
60
- assert.equal(inst.domains.provisioningOrigin, 'https://get.cloudbongos.com');
62
+ const inst = resolveBranding({ neutral: NEUTRAL, instance: { ...SAMPLE, domains: { ...SAMPLE.domains, provisioningOrigin: 'https://get.acme.example' } } });
63
+ assert.equal(inst.domains.provisioningOrigin, 'https://get.acme.example');
61
64
  // And <PREFIX>_PROVISIONING_ORIGIN beats config (same precedence as the other origins).
62
- const env = resolveBranding({ neutral: NEUTRAL, instance: OTB, env: { OTB_PROVISIONING_ORIGIN: 'https://get.cloudbongos.com' } });
63
- assert.equal(env.domains.provisioningOrigin, 'https://get.cloudbongos.com');
65
+ const env = resolveBranding({ neutral: NEUTRAL, instance: SAMPLE, env: { ACME_PROVISIONING_ORIGIN: 'https://get.acme.example' } });
66
+ assert.equal(env.domains.provisioningOrigin, 'https://get.acme.example');
64
67
  });
65
68
 
66
69
  test('missing required field throws', () => {
@@ -80,16 +83,19 @@ test('deepMerge does not mutate its inputs', () => {
80
83
  assert.deepEqual(out, { a: { x: 1, y: 2 } });
81
84
  });
82
85
 
83
- test('the committed neutral + OTB packs load and resolve (this repo = customer 1)', () => {
86
+ test('the committed neutral pack loads + resolves to the neutral identity (the trunk carries no instance branding.json)', () => {
87
+ // The neutral trunk ships config/branding.neutral.json only — no config/branding.json — so
88
+ // loadBranding resolves to the plain Cloud Bongos identity (task 1002219 / audit H5: this used to
89
+ // assert the customer-1 OTB identity, stale since the amazonprimea→cloudbongos rebrand, ADR 0139/0140).
84
90
  const b = loadBranding({ env: {} });
85
- assert.equal(b.identity.productName, 'Off the Boats');
86
- assert.ok(b.domains.publicOrigin.startsWith('https://amazonprimea.com'));
87
- assert.equal(b.identity.worldName, 'Amazonprimea');
91
+ assert.equal(b.identity.productName, 'Cloud Bongos');
92
+ assert.equal(b.identity.worldName, 'Cloud Bongos');
93
+ assert.ok(b.domains.publicOrigin.startsWith('http://localhost'));
88
94
  });
89
95
 
90
96
  test('resolveBranding does not freeze its input objects (no shared refs)', () => {
91
97
  const neutral = structuredClone(NEUTRAL);
92
- const instance = structuredClone(OTB);
98
+ const instance = structuredClone(SAMPLE);
93
99
  resolveBranding({ neutral, instance });
94
100
  assert.equal(Object.isFrozen(neutral), false);
95
101
  assert.equal(Object.isFrozen(neutral.domains), false);
@@ -0,0 +1,91 @@
1
+ // tests/github_repos_helpers.mjs — DB-free unit tests for the onboarding repo
2
+ // picker's pure/near-pure pieces (task 1002099 / ADR 0141):
3
+ // • auth.scopeForWebFlow — the security-critical scope string
4
+ // • github-repos listUserRepos/createUserRepo — with a mock fetch (no network)
5
+ // • routes/auth.safeReturnTo — regression: the ?repos change didn't weaken the
6
+ // open-redirect guard.
7
+
8
+ import { strict as assert } from 'node:assert';
9
+ import { test } from 'node:test';
10
+ import { createRequire } from 'node:module';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const auth = require(path.join(ROOT, 'src/bongos/auth.js'));
17
+ const routesAuth = require(path.join(ROOT, 'src/bongos/routes/auth.js'));
18
+ const gh = require(path.join(ROOT, 'src/bongos/github-repos.js'));
19
+
20
+ test('scopeForWebFlow: identity-only by default, adds public_repo ONLY when repos=true', () => {
21
+ assert.equal(auth.scopeForWebFlow(), 'read:user');
22
+ assert.equal(auth.scopeForWebFlow({ repos: false }), 'read:user');
23
+ assert.equal(auth.scopeForWebFlow({ repos: true }), 'read:user public_repo');
24
+ // Never the all-or-nothing `repo` scope (ADR 0141) — public_repo only.
25
+ assert.ok(!auth.scopeForWebFlow({ repos: true }).split(' ').includes('repo'));
26
+ });
27
+
28
+ test('safeReturnTo still guards after the ?repos change: relative ok, cross-origin/protocol-relative blocked', () => {
29
+ assert.equal(routesAuth.safeReturnTo('/create'), '/create');
30
+ assert.equal(routesAuth.safeReturnTo('https://evil.example/steal'), null);
31
+ assert.equal(routesAuth.safeReturnTo('//evil.example'), null);
32
+ assert.equal(routesAuth.safeReturnTo('/create?x=1'), '/create?x=1'); // a query on the return itself is fine
33
+ });
34
+
35
+ // ---- github-repos helpers driven by a mock fetch ----
36
+ function res(status, body) {
37
+ return {
38
+ status,
39
+ ok: status >= 200 && status < 300,
40
+ json: async () => body,
41
+ text: async () => JSON.stringify(body),
42
+ };
43
+ }
44
+
45
+ test('listUserRepos: requests affiliation=owner, maps rows, filters by q', async () => {
46
+ let calledUrl = '';
47
+ const fetchImpl = async (url) => {
48
+ calledUrl = url;
49
+ return res(200, [
50
+ { full_name: 'me/mercury', name: 'mercury', owner: { login: 'me' }, private: false },
51
+ { full_name: 'me/venus', name: 'venus', owner: { login: 'me' }, private: true },
52
+ ]);
53
+ };
54
+ const all = await gh.listUserRepos('tok', { fetchImpl });
55
+ assert.match(calledUrl, /affiliation=owner/);
56
+ assert.match(calledUrl, /per_page=100/);
57
+ assert.deepEqual(all.map((r) => r.full_name), ['me/mercury', 'me/venus']);
58
+ assert.equal(all[0].owner, 'me');
59
+ assert.equal(all[1].private, true);
60
+ const filtered = await gh.listUserRepos('tok', { q: 'MERC', fetchImpl }); // case-insensitive
61
+ assert.deepEqual(filtered.map((r) => r.full_name), ['me/mercury']);
62
+ });
63
+
64
+ test('listUserRepos: 401 → typed github_unauthorized', async () => {
65
+ const fetchImpl = async () => res(401, { message: 'Bad credentials' });
66
+ await assert.rejects(
67
+ () => gh.listUserRepos('tok', { fetchImpl }),
68
+ (e) => e.code === 'github_unauthorized' && e.status === 401,
69
+ );
70
+ });
71
+
72
+ test('createUserRepo: sends auto_init:false + private:false, returns full_name/html_url', async () => {
73
+ let sentBody = null;
74
+ const fetchImpl = async (url, init) => {
75
+ sentBody = JSON.parse(init.body);
76
+ return res(201, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury' });
77
+ };
78
+ const out = await gh.createUserRepo('tok', { name: 'mercury', fetchImpl });
79
+ assert.equal(sentBody.auto_init, false, 'empty repo — no README/first commit');
80
+ assert.equal(sentBody.private, false, 'public_repo scope → public');
81
+ assert.equal(sentBody.name, 'mercury');
82
+ assert.deepEqual(out, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury' });
83
+ });
84
+
85
+ test('createUserRepo: 422 → typed name_taken (the route maps this to a friendly 409)', async () => {
86
+ const fetchImpl = async () => res(422, { message: 'name already exists on this account' });
87
+ await assert.rejects(
88
+ () => gh.createUserRepo('tok', { name: 'dup', fetchImpl }),
89
+ (e) => e.code === 'name_taken' && e.status === 422,
90
+ );
91
+ });