@bongos/core 1.21.94 → 1.21.95

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.
@@ -9,6 +9,12 @@
9
9
  // single-use `state` minted for the signed-in owner is the only key, and neither route
10
10
  // reveals a secret or changes authority. The checklist they report is derived in
11
11
  // ../github-setup.js from what the control-plane runner has VERIFIED.
12
+ //
13
+ // Two signed-in routes join them (task 1004650 — the hand-back from any tab or device):
14
+ // the RETURN route the callback and setup pages send the founder to, which lands the state's
15
+ // own project for its owner and nobody else, and the RESUME read the project page asks when
16
+ // its tab remembers nothing — so a phone, a second window or a fresh tab picks up part 03 of
17
+ // the right project from what the server knows, never from sessionStorage.
12
18
  'use strict';
13
19
 
14
20
  const express = require('express');
@@ -17,10 +23,38 @@ const { pool } = api;
17
23
  const provisioning = require('../provisioning');
18
24
  const githubSetup = require('../github-setup');
19
25
  const { makeAskRateLimiter } = require('../rate-limit');
20
- const { callbackPage } = require('./callback-page');
26
+ const { callbackPage, returnHref } = require('./callback-page');
21
27
 
22
28
  const log = api.logger('provisioning');
23
29
 
30
+ // The state tokens this leg mints are hex (provisioning.js); anything else is not one of ours.
31
+ const STATE_RE = /^[0-9a-f]{16,128}$/i;
32
+
33
+ // An attempt or a project older than this is not "in progress" for the resume read: a
34
+ // founder who walked away a week ago and opens the create page gets a fresh page.
35
+ const RESUME_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
36
+
37
+ // Is this instance one the hand-back may land on for this builder? Strictly the OWNER —
38
+ // no Archon override: the return route names a project to whoever holds the state, and a
39
+ // project page is where its owner, and only its owner, connects GitHub. A gone project is
40
+ // no landing either. PURE.
41
+ function ownsLanding(inst, builderId) {
42
+ return !!(inst && builderId != null && String(inst.owner_builder_id) === String(builderId)
43
+ && !inst.torn_down_at && inst.status !== 'torn_down');
44
+ }
45
+
46
+ // The project page's resume record: just what part 03 needs to name and watch the project
47
+ // (the page reads the rest from the owner's instance read). Never the state token. PURE.
48
+ function resumeRecord(inst) {
49
+ return { id: inst.id, slug: inst.slug, domain: inst.domain || null, onboard_mode: inst.onboard_mode || 'greenfield', repo: githubSetup.targetRepo(inst) };
50
+ }
51
+
52
+ // The project page to land on: part 03 of that project, by id. The state rides the
53
+ // FRAGMENT (never sent to a server) so the page can keep watching that attempt. PURE.
54
+ function landingHref(instanceId, state) {
55
+ return `/projects?view=new&id=${encodeURIComponent(String(instanceId))}${state ? `#ghstate=${encodeURIComponent(state)}` : ''}`;
56
+ }
57
+
24
58
  // GitHub's installation id is digits; anything else is dropped before it reaches a log line.
25
59
  function installationIdOf(q) {
26
60
  const s = String((q && q.installation_id) || '');
@@ -32,6 +66,7 @@ module.exports = function buildGithubSetupRouter() {
32
66
  // the page polls every few seconds, the callback page every two — per-IP, in-module
33
67
  const statusRateLimit = makeAskRateLimiter({ max: 120 });
34
68
  const setupRateLimit = makeAskRateLimiter({ max: 30 });
69
+ const returnRateLimit = makeAskRateLimiter({ max: 30 });
35
70
 
36
71
  // GET /provisioning/github-app/status?state=<> — the PUBLIC progress read (task 1004548;
37
72
  // the checklist since task 1004649). Reveals the manifest lifecycle status, the
@@ -85,7 +120,7 @@ module.exports = function buildGithubSetupRouter() {
85
120
  const sendPage = (status, page) => res.status(status).type('html').send(page);
86
121
  try {
87
122
  const state = String(req.query.state || '');
88
- const row = /^[0-9a-f]{16,128}$/i.test(state) ? await provisioning.getOAuthManifestByState(pool, state) : null;
123
+ const row = STATE_RE.test(state) ? await provisioning.getOAuthManifestByState(pool, state) : null;
89
124
  if (!row) {
90
125
  return sendPage(200, callbackPage({ ok: true, title: 'Installed on GitHub',
91
126
  body: '<p>GitHub has installed the app. Go back to your project’s page: it checks the install itself.</p><p><a class="btn" href="/projects?view=mine">Back to my projects →</a></p>' }));
@@ -109,5 +144,78 @@ module.exports = function buildGithubSetupRouter() {
109
144
  }
110
145
  });
111
146
 
147
+ // GET /provisioning/github-app/return?state=<> — where the callback and setup pages send
148
+ // the founder back (task 1004650). The SERVER resolves the one-time state to the project
149
+ // it was minted for and 302s that project's signed-in OWNER to its part 03
150
+ // (/projects?view=new&id=<id>), so the hand-back works from any tab, window or device —
151
+ // nothing in browser storage is read. Not signed in → through the web sign-in and back
152
+ // here. Signed in as anyone else, an unknown or malformed state, or a gone project → one
153
+ // refusal page that names no project (no oracle about which). The state stays single-use
154
+ // where it matters: only the manifest callback's code exchange consumes it, and this route
155
+ // changes nothing — it reveals a project's page only to the person who already owns it.
156
+ // rank: public — as a GATE: it is a browser redirect, so no middleware 401s a signed-out
157
+ // visitor (they are sent through sign-in instead). WHO lands is decided in-handler from
158
+ // the live session: the project's own owner, no Archon override, and nobody else ever
159
+ // learns which project the state names. It reveals no secret and writes nothing.
160
+ router.get('/provisioning/github-app/return', returnRateLimit, async (req, res) => {
161
+ res.set('Cache-Control', 'no-store');
162
+ res.set('Referrer-Policy', 'no-referrer');
163
+ const refuse = () => res.status(404).type('html').send(callbackPage({ ok: false, title: 'This isn’t your project',
164
+ body: '<p>This link is for a project on another account, or it has expired — sign in with the account that started the project, or open your own projects.</p><p><a class="btn" href="/projects?view=mine">My projects →</a></p>' }));
165
+ try {
166
+ const state = String(req.query.state || '');
167
+ if (!STATE_RE.test(state)) return refuse();
168
+ const session = await api.resolveSession(req);
169
+ if (!session) {
170
+ // the relative return passes the web flow's open-redirect guard (safeReturnTo);
171
+ // the repo scope rides every hub sign-in door (ADR 0364)
172
+ return res.redirect(302, `/api/bongos/auth/web/start?return=${encodeURIComponent(returnHref(state))}&repos=1`);
173
+ }
174
+ if (session.status === 'inactive') return refuse();
175
+ const row = await provisioning.getOAuthManifestByState(pool, state);
176
+ const inst = row ? await provisioning.getInstanceById(pool, row.instance_id).catch(() => null) : null;
177
+ if (!ownsLanding(inst, session.builder_id)) return refuse();
178
+ return res.redirect(302, landingHref(inst.id, state));
179
+ } catch (err) {
180
+ log.error('[provisioning] GET /provisioning/github-app/return', err);
181
+ return res.status(500).type('html').send(callbackPage({ ok: false, title: 'Something went wrong',
182
+ body: '<p>We couldn’t find your project just now — it is on your projects page.</p><p><a class="btn" href="/projects?view=mine">My projects →</a></p>' }));
183
+ }
184
+ });
185
+
186
+ // GET /provisioning/github-app/resume[?id=<n>] — the project page's "which project am I
187
+ // connecting?" read when its tab remembers nothing (task 1004650): a hand-back on another
188
+ // device, a fresh tab, a cleared browser. With an id: that project, if the caller owns it.
189
+ // Without: the caller's newest project whose GitHub checklist is not connected yet, made or
190
+ // last tried within a week — so opening the create page later resumes part 03 from the
191
+ // server's view. Either way { project: null } when there is none; never the state token.
192
+ // rank: any authenticated builder (requireBuilder), own projects only — no Archon override.
193
+ router.get('/provisioning/github-app/resume', api.requireBuilder, async (req, res) => {
194
+ try {
195
+ const me = req.builder && req.builder.id;
196
+ const asked = String(req.query.id || '');
197
+ if (asked) {
198
+ if (!/^\d{1,12}$/.test(asked)) return res.json({ ok: true, project: null });
199
+ const inst = await provisioning.getInstanceById(pool, Number(asked)).catch(() => null);
200
+ return res.json({ ok: true, project: ownsLanding(inst, me) ? resumeRecord(inst) : null });
201
+ }
202
+ const now = Date.now();
203
+ const owned = (await provisioning.listInstancesForOwner(pool, me)).filter((i) => ownsLanding(i, me)).slice(0, 5);
204
+ // the (at most five) candidates are read together; the newest unfinished one wins
205
+ const open = await Promise.all(owned.map(async (inst) => {
206
+ const row = await provisioning.getLatestOAuthManifestForInstance(pool, inst.id);
207
+ const touched = Math.max(Date.parse(inst.created_at) || 0, row ? Date.parse(row.created_at) || 0 : 0);
208
+ if (now - touched > RESUME_WINDOW_MS) return false;
209
+ const setup = await githubSetup.githubSetupRead(pool, inst, row, row ? provisioning.publicGithubApp(row).status : null, { withState: false });
210
+ return !setup.checklist.connected;
211
+ }));
212
+ const found = owned.find((_inst, i) => open[i]);
213
+ return res.json({ ok: true, project: found ? resumeRecord(found) : null });
214
+ } catch (err) {
215
+ log.error('[provisioning] GET /provisioning/github-app/resume', err);
216
+ return res.status(500).json({ ok: false, error: 'resume_read_failed' });
217
+ }
218
+ });
219
+
112
220
  return router;
113
221
  };
@@ -4583,6 +4583,9 @@ summary{min-height:24px;padding:3px 0;}
4583
4583
  survives the GitHub round-trip; the server, not this page, names the terms version. */
4584
4584
  var DRAFT_KEY = 'cb-create-draft-v10';
4585
4585
  var CREATED_KEY = 'cb-created-instance';
4586
+ /* the project "Start another project" set down (task 1004650): the server's resume
4587
+ read does not pull this tab back to it */
4588
+ var DECLINED_KEY = 'cb-resume-declined';
4586
4589
  /* `description` + `teamShape` are the optional About step (task 1002334) —
4587
4590
  both legitimately stay empty, and empty is what the create request omits. */
4588
4591
  /* `modules` is the picker's answer (task 1002339) and is THREE-VALUED like
@@ -6926,18 +6929,36 @@ summary{min-height:24px;padding:3px 0;}
6926
6929
  try { localStorage.setItem(AUTO_OPEN_KEY, box.checked ? '1' : '0'); } catch (e) {}
6927
6930
  });
6928
6931
 
6929
- /* ── C8: the wizard's polls run only on stage, exactly like the sky.
6930
- Off-view or hidden-tab, a due tick parks itself instead of fetching;
6931
- enter() and the visibility flip resume whatever was parked. ── */
6932
+ /* ── C8: the wizard's polls run only while the create view is on stage, like the
6933
+ sky. Off-view a due tick parks itself instead of fetching; enter() resumes it.
6934
+ A HIDDEN tab is different (task 1004650): that is exactly when the founder is on
6935
+ GitHub, in another tab or on their phone, so the polls keep checking there at a
6936
+ slower pace (HIDDEN_POLL_MS), and the moment the tab is seen again a waiting tick
6937
+ runs at once — the laptop's checklist ticks on its own. ── */
6938
+ var HIDDEN_POLL_MS = 20000;
6932
6939
  function wizPollActive() {
6933
- return !document.hidden && currentView === 'new';
6940
+ return currentView === 'new';
6941
+ }
6942
+ /* the next tick's wait: the poll's own pace while the tab is seen, the slow one while hidden */
6943
+ function wizPollDelay(ms) {
6944
+ return document.hidden ? Math.max(ms, HIDDEN_POLL_MS) : ms;
6934
6945
  }
6935
6946
  function resumePolls() {
6936
6947
  if (watchPending && instanceId) { watchPending = false; startProgress(instanceId); }
6937
6948
  if (manifestPending && manifestState) { manifestPending = false; pollManifest(manifestState); }
6938
6949
  }
6950
+ /* each poll's waiting tick, so a return to the tab can run it now instead of later.
6951
+ Only a WAITING tick is run (its timer is set): one mid-fetch re-arms itself, and
6952
+ running it again would start a second chain. */
6953
+ var progressKick = null, manifestKick = null;
6954
+ function kickPolls() {
6955
+ if (pollTimer && progressKick) { clearTimeout(pollTimer); pollTimer = null; progressKick(); }
6956
+ if (manifestPollTimer && manifestKick) { clearTimeout(manifestPollTimer); manifestPollTimer = null; manifestKick(); }
6957
+ }
6939
6958
  document.addEventListener('visibilitychange', function () {
6940
- if (wizPollActive()) resumePolls();
6959
+ if (document.hidden || !wizPollActive()) return;
6960
+ resumePolls();
6961
+ kickPolls();
6941
6962
  });
6942
6963
 
6943
6964
  /* ── GitHub-App manifest flow (ADR 0133): mint a one-time state for OUR instance,
@@ -6957,6 +6978,17 @@ summary{min-height:24px;padding:3px 0;}
6957
6978
  var handbackState = (function () {
6958
6979
  try { var m = /[#&]ghstate=([0-9a-f]{16,128})/i.exec(location.hash || ''); return m ? m[1] : null; } catch (e) { return null; }
6959
6980
  })();
6981
+ /* …and the PROJECT it is for (task 1004650): the return route resolves the state on
6982
+ the server and lands its owner on ?view=new&id=<project>, so a phone, another
6983
+ window or a fresh tab reaches the right project's part 03 with nothing in this
6984
+ tab's storage. Read once at load, for the same reason as the state above. */
6985
+ var handbackProject = (function () {
6986
+ try {
6987
+ var q = new URLSearchParams(location.search || '');
6988
+ var id = q.get('view') === 'new' ? q.get('id') : null;
6989
+ return id && /^\d{1,12}$/.test(id) ? id : null;
6990
+ } catch (e) { return null; }
6991
+ })();
6960
6992
  /* the manifest leg survives a reload: token + last status ride the CREATED
6961
6993
  record, so enter() re-seeds the step instead of re-offering "Set up
6962
6994
  GitHub sign-in" for an app the founder already approved (task 1003232) */
@@ -7045,6 +7077,7 @@ summary{min-height:24px;padding:3px 0;}
7045
7077
  function tick() {
7046
7078
  if (myGen !== manifestGen) return;
7047
7079
  if (!wizPollActive()) { manifestPending = true; stopManifestPoll(); return; }
7080
+ manifestKick = tick;
7048
7081
  fetch(API + '/provisioning/github-app/status?state=' + encodeURIComponent(stateToken))
7049
7082
  .then(function (res) { if (!res.ok) throw new Error('soft'); return res.json(); })
7050
7083
  .then(function (d) {
@@ -7055,10 +7088,10 @@ summary{min-height:24px;padding:3px 0;}
7055
7088
  var failure = d.failure || null;
7056
7089
  if (next !== live.manifest || failure !== live.manifestFailure) { live.manifest = next; live.manifestFailure = failure; rememberManifest(undefined, next); renderSteps(); }
7057
7090
  else noteManifestWait();
7058
- if (!d.terminal) manifestPollTimer = setTimeout(tick, 4000);
7091
+ if (!d.terminal) manifestPollTimer = setTimeout(tick, wizPollDelay(4000));
7059
7092
  else manifestPollTimer = null; /* a stale fired id would read as "still polling" and resume a finished poll */
7060
7093
  })
7061
- .catch(function () { if (myGen === manifestGen) manifestPollTimer = setTimeout(tick, 8000); });
7094
+ .catch(function () { if (myGen === manifestGen) manifestPollTimer = setTimeout(tick, wizPollDelay(8000)); });
7062
7095
  }
7063
7096
  tick();
7064
7097
  }
@@ -7249,6 +7282,7 @@ summary{min-height:24px;padding:3px 0;}
7249
7282
  function tick() {
7250
7283
  if (myGen !== watchGen) return;
7251
7284
  if (!wizPollActive()) { watchPending = true; stopPolling(); return; }
7285
+ progressKick = tick;
7252
7286
  ticks += 1;
7253
7287
  if (ticks > 400) {
7254
7288
  /* ~33 min: this tab stops watching, but the standup keeps going — hand
@@ -7305,7 +7339,7 @@ summary{min-height:24px;padding:3px 0;}
7305
7339
  var terminal = renderProgress(inst);
7306
7340
  /* a GitHub leg still in flight keeps this read alive even once the
7307
7341
  project is up — after a reload it is the ONLY thing watching it */
7308
- if (!terminal || manifestInFlight() || setupOpen()) pollTimer = setTimeout(tick, 5000);
7342
+ if (!terminal || manifestInFlight() || setupOpen()) pollTimer = setTimeout(tick, wizPollDelay(5000));
7309
7343
  else stopPolling();
7310
7344
  })
7311
7345
  .catch(function (err) {
@@ -7318,7 +7352,7 @@ summary{min-height:24px;padding:3px 0;}
7318
7352
  return;
7319
7353
  }
7320
7354
  /* transient (network / 5xx): keep the last view, try again a bit later */
7321
- pollTimer = setTimeout(tick, 10000);
7355
+ pollTimer = setTimeout(tick, wizPollDelay(10000));
7322
7356
  });
7323
7357
  }
7324
7358
  tick();
@@ -7367,6 +7401,11 @@ summary{min-height:24px;padding:3px 0;}
7367
7401
  resetForNewProject();
7368
7402
  /* the deliberate goodbye: the record goes too, so nothing offers to resume
7369
7403
  the project the founder has just said they are finished watching */
7404
+ try {
7405
+ var gone = createdRecord();
7406
+ if (gone && gone.id) sessionStorage.setItem(DECLINED_KEY, String(gone.id));
7407
+ } catch (e) {}
7408
+ serverRecord = null;
7370
7409
  try { sessionStorage.removeItem(CREATED_KEY); } catch (e) {}
7371
7410
  paintResumeOffer(null);
7372
7411
  show(1, true);
@@ -7810,8 +7849,11 @@ summary{min-height:24px;padding:3px 0;}
7810
7849
  })();
7811
7850
 
7812
7851
  /* ── the hub's three doors ── */
7852
+ /* the server's resume answer (task 1004650), kept in memory too: a browser whose
7853
+ storage throws still has the project it was handed */
7854
+ var serverRecord = null;
7813
7855
  function createdRecord() {
7814
- try { return JSON.parse(sessionStorage.getItem(CREATED_KEY) || 'null'); } catch (e) { return null; }
7856
+ try { return JSON.parse(sessionStorage.getItem(CREATED_KEY) || 'null') || serverRecord; } catch (e) { return serverRecord; }
7815
7857
  }
7816
7858
  /* The "still watching …" offer above step 1 (task 1003503). Its markup is baked
7817
7859
  into the panel, so this only names the project and shows or hides the line —
@@ -7876,6 +7918,12 @@ summary{min-height:24px;padding:3px 0;}
7876
7918
  /* restore the opt-out too — without it resolvedDomain() re-derives the
7877
7919
  assigned address and the done panel promises one that doesn't exist */
7878
7920
  state.noAddress = !!created.noaddr;
7921
+ /* a record the server handed over (another device, a fresh tab) carries the repo
7922
+ the project is on, so the install step names it (task 1004650) */
7923
+ if (created.repo_ref && !state.repo) {
7924
+ var rr = String(created.repo_ref).split('/');
7925
+ if (rr.length === 2) { state.owner = rr[0]; state.repo = rr[1]; }
7926
+ }
7879
7927
  /* a hand-back from the callback page names the attempt GitHub just approved */
7880
7928
  if (handbackState) {
7881
7929
  created.manifest_state = handbackState;
@@ -7898,9 +7946,54 @@ summary{min-height:24px;padding:3px 0;}
7898
7946
  identity settles it shows the draft step optimistically (the page stays
7899
7947
  usable while /me is in flight — the boot's own rule); onAuth() re-enters
7900
7948
  once the answer lands. Focus only moves on a deliberate nav (the flag). */
7949
+ /* askServerResume(focus) — which project is this founder connecting, by the SERVER's
7950
+ view, when this tab cannot say (task 1004650)? Asked once per page load. A hand-back
7951
+ names its project (handbackProject, from the return route): it always lands on that
7952
+ project's part 03, and only its owner gets an answer. With no name, the server picks
7953
+ the founder's newest project whose GitHub is not connected yet: an arrival lands on
7954
+ it unless something is being typed, and a deliberate "New project" only offers it on
7955
+ the resume line. Nothing found → the page as it was. */
7956
+ var resumeAsked = false;
7957
+ function recordFromServer(p) {
7958
+ return { id: p.id, slug: p.slug || '', name: p.slug || '', domain: p.domain || '', noaddr: !p.domain,
7959
+ mode: p.onboard_mode === 'adopt' ? 'adopt' : 'greenfield', repo_ref: p.repo || null, at: Date.now(), from_server: true };
7960
+ }
7961
+ function askServerResume(focus) {
7962
+ resumeAsked = true;
7963
+ var named = handbackProject;
7964
+ handbackProject = null;
7965
+ return fetch(API + '/provisioning/github-app/resume' + (named ? '?id=' + encodeURIComponent(named) : ''), { credentials: 'same-origin' })
7966
+ .then(function (res) { return res.ok ? res.json() : null; })
7967
+ .catch(function () { return null; })
7968
+ .then(function (d) {
7969
+ var p = d && d.project;
7970
+ var had = createdRecord();
7971
+ if (!p || !p.id || (!named && had && had.id)) { if (named) enter(focus); return; }
7972
+ var declined = null;
7973
+ try { declined = sessionStorage.getItem(DECLINED_KEY); } catch (e) {}
7974
+ if (!named && declined === String(p.id)) return;
7975
+ var rec = recordFromServer(p);
7976
+ /* the same project this tab already knew keeps what it knew (a demo's teammates) */
7977
+ if (had && String(had.id) === String(p.id)) rec = Object.assign({}, had, rec, { name: had.name || rec.name });
7978
+ else if (had && had.id) resetForNewProject(); /* a hand-back for a different project */
7979
+ serverRecord = rec;
7980
+ try { sessionStorage.setItem(CREATED_KEY, JSON.stringify(rec)); } catch (e) {}
7981
+ if (currentView !== 'new') return;
7982
+ if (named || (!focus && !String(state.name || '').trim())) { resumeIntent = true; enterCreated(rec, false); }
7983
+ else paintResumeOffer(rec);
7984
+ });
7985
+ }
7986
+
7901
7987
  function enter(focus) {
7902
7988
  if (authKnown && !me) { show(0, !!focus); return; }
7903
7989
  var created = authKnown ? createdRecord() : null;
7990
+ /* this tab cannot say which project — ask the server (task 1004650). A named
7991
+ hand-back waits for that answer instead of painting another project first. */
7992
+ if (authKnown && !resumeAsked && (handbackProject || !(created && created.id))) {
7993
+ var waitForIt = !!handbackProject;
7994
+ askServerResume(!!focus);
7995
+ if (waitForIt) return;
7996
+ }
7904
7997
  /* A draft on the page (step 1) beside a stored create is a SECOND project being
7905
7998
  typed. A reload or a GitHub round-trip (repo access) is that same visit
7906
7999
  continuing, so the draft keeps its place and the earlier project stays one
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.94",
3
+ "version": "1.21.95",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.94",
9
+ "version": "1.21.95",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.94",
3
+ "version": "1.21.95",
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",
@@ -9394,5 +9394,11 @@
9394
9394
  "id": "1004649",
9395
9395
  "text": "Connecting GitHub to a new project is now one trip. The founder clicks \"Connect GitHub\", approves the project's app on GitHub, and GitHub carries straight on to installing it on their repo (already picked when the repo is publ"
9396
9396
  }
9397
+ ],
9398
+ "1.21.95": [
9399
+ {
9400
+ "id": "1004650",
9401
+ "text": "Connecting GitHub no longer depends on staying in one browser tab. After you approve on GitHub, even on your phone or in another window, you land back on your own project's GitHub checklist. Our server remembers which project"
9402
+ }
9397
9403
  ]
9398
9404
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.94'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.95'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');