@bongos/core 1.19.1076 → 1.19.1078

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.
@@ -210,9 +210,41 @@ function renderAccessPendingPage(login, startHref) {
210
210
  // SESSION_COOKIE_NAMES, because the request-level token extractor there has to
211
211
  // accept exactly the names this router sets and clears: two lists would drift,
212
212
  // and the only symptom of the drift is a builder being silently signed out.
213
- // Head = the one name we SET. Tail = the names we accept and clear on write.
213
+ // Head = the name we SET over https: the host-only `__Host-bongos_session`
214
+ // (task 1004357). The second name is what we SET over plain http (localhost dev),
215
+ // where a browser refuses any `__Host-` cookie. Every other name is accepted on
216
+ // read and cleared on write.
214
217
  const SESSION_COOKIE = auth.SESSION_COOKIE_NAMES[0];
215
- const SESSION_COOKIE_OLD = auth.SESSION_COOKIE_NAMES.slice(1);
218
+ const SESSION_COOKIE_INSECURE = auth.SESSION_COOKIE_NAMES[1];
219
+
220
+ function isSecureReq(req) {
221
+ return !!(req.secure || req.headers['x-forwarded-proto'] === 'https');
222
+ }
223
+
224
+ // The one session-cookie name this request should carry.
225
+ function sessionCookieName(req) {
226
+ return isSecureReq(req) ? SESSION_COOKIE : SESSION_COOKIE_INSECURE;
227
+ }
228
+
229
+ // Set the session cookie. ALWAYS host-only — no Domain attribute, on any host
230
+ // (task 1004357, ADR 0145 Finding 1). The hub used to scope it to ".<apex>" so
231
+ // one sign-in spanned builders./status., but a Domain cookie reaches EVERY
232
+ // subdomain, and a project's hall at <handle>.<apex> runs code from the
233
+ // project's own repository: any visiting hub builder handed that code their hub
234
+ // session. The subdomains now receive their own copy through the one-time
235
+ // handoff below (mintHandoffCode / GET /auth/web/handoff) instead.
236
+ function setSessionCookie(req, res, token, expires) {
237
+ res.cookie(sessionCookieName(req), token, {
238
+ httpOnly: true,
239
+ sameSite: 'lax',
240
+ secure: isSecureReq(req),
241
+ path: '/',
242
+ ...(expires ? { expires } : {}),
243
+ });
244
+ // A browser arriving with an older session cookie must not keep it — above
245
+ // all the apex-scoped one, which is still being sent to every project hall.
246
+ clearSupersededSessionCookies(req, res);
247
+ }
216
248
 
217
249
  // The session token off a request's cookies, trying each accepted name newest
218
250
  // first. Replaces the hand-written `parseCookie(new) || parseCookie(legacy)`
@@ -225,16 +257,25 @@ function sessionCookieToken(req) {
225
257
  return null;
226
258
  }
227
259
 
228
- // Clear every SUPERSEDED session cookie, in both the host-only and apex-scoped
229
- // forms (a clearCookie only matches the cookie whose domain attribute it names).
230
- // Called wherever we set the current cookie, so a browser that arrived with a
231
- // `gds_session` leaves holding only `bongos_session` — otherwise the stale name
232
- // lingers until its own expiry and every later request carries two tokens.
233
- function clearSupersededSessionCookies(req, res) {
234
- const domainOpts = sessionCookieDomainOpts(req);
235
- for (const name of SESSION_COOKIE_OLD) {
260
+ // Clear every SUPERSEDED session cookie, in both the host-only and the legacy
261
+ // apex-scoped forms (a clearCookie only matches the cookie whose domain attribute
262
+ // it names). Called wherever we set the current cookie, so a browser that arrived
263
+ // with a `gds_session` or an apex-scoped `bongos_session` leaves holding only the
264
+ // current host-only name — otherwise the stale one lingers until its own expiry,
265
+ // every later request carries two tokens, and the apex-scoped one keeps reaching
266
+ // project halls. `{ all: true }` clears the current name too (sign-out).
267
+ function clearSupersededSessionCookies(req, res, { all = false } = {}) {
268
+ const keep = all ? null : sessionCookieName(req);
269
+ const legacy = legacyApexCookieOpts(req);
270
+ for (const name of auth.SESSION_COOKIE_NAMES) {
271
+ if (name === keep) continue;
272
+ if (name.startsWith('__Host-')) {
273
+ // A __Host- cookie only matches a clear that is itself Secure + Path=/.
274
+ res.clearCookie(name, { path: '/', secure: true });
275
+ continue;
276
+ }
236
277
  res.clearCookie(name);
237
- if (domainOpts.domain) res.clearCookie(name, domainOpts);
278
+ if (legacy.domain) res.clearCookie(name, legacy);
238
279
  }
239
280
  }
240
281
  const STATE_COOKIE = 'gds_oauth_state';
@@ -293,29 +334,138 @@ function reqHost(req) {
293
334
  return String(raw).toLowerCase().split(',')[0].trim().split(':')[0];
294
335
  }
295
336
 
296
- // The production-facing hosts that share one sign-in. Deliberately enumerated
297
- // (not "any *.example.com") so staging.example.com — a SEPARATE
298
- // process + DB on :3001 — keeps a host-only cookie and never bleeds an
299
- // apex-scoped token into prod (or vice versa).
300
- // When COOKIE_APEX is "" (localhost / no configured cookieDomain) the set is
301
- // EMPTY → every host gets a host-only cookie (never a "." + "" = ".<empty>" leak).
302
- const APEX_COOKIE_HOSTS = new Set(
337
+ // The hub's own hosts — the ones that share one sign-in. Deliberately enumerated
338
+ // (not "any *.example.com"): every OTHER subdomain may be a project's hall
339
+ // running that project's code (task 1004357), or staging.example.com — a
340
+ // SEPARATE process + DB. When COOKIE_APEX is "" (localhost / no configured
341
+ // cookieDomain) the set is EMPTY and there is nothing to share.
342
+ //
343
+ // Nothing is SET with a Domain attribute any more. The set decides two things
344
+ // only: which hosts may be handed a session through the one-time handoff, and
345
+ // where the LEGACY apex-scoped cookies (set before task 1004357) get cleared.
346
+ const HUB_HOSTS = new Set(
303
347
  COOKIE_APEX
304
348
  ? [COOKIE_APEX, 'www.' + COOKIE_APEX, 'builders.' + COOKIE_APEX, 'status.' + COOKIE_APEX]
305
349
  : []
306
350
  );
307
351
 
308
- // Cookie attributes that vary by host. On the production-facing hosts the
309
- // session cookie is scoped to the apex so it crosses subdomains (the enabler
310
- // for builders.example.com, #726); everywhere else (localhost, staging)
311
- // it stays host-only. Returns a spread-able object.
312
- function sessionCookieDomainOpts(req) {
313
- if (APEX_COOKIE_HOSTS.has(reqHost(req))) {
352
+ // The Domain attribute the hub USED to scope its cookies to on its own hosts
353
+ // (#726/#734) — kept ONLY so a clearCookie can match and expire those legacy
354
+ // cookies. Never spread into a res.cookie(): that is the bug task 1004357 fixed.
355
+ // Returns a spread-able object.
356
+ function legacyApexCookieOpts(req) {
357
+ if (HUB_HOSTS.has(reqHost(req))) {
314
358
  return { domain: '.' + COOKIE_APEX };
315
359
  }
316
360
  return {};
317
361
  }
318
362
 
363
+ // Host of the pinned sign-in origin (WEB_REDIRECT_ORIGIN): the one host the
364
+ // GitHub callback lands on, so the one host whose cookies the handshake reads.
365
+ function canonicalHost() {
366
+ try {
367
+ return WEB_REDIRECT_ORIGIN ? new URL(WEB_REDIRECT_ORIGIN).hostname.toLowerCase() : '';
368
+ } catch {
369
+ return '';
370
+ }
371
+ }
372
+
373
+ // A hub host OTHER than the canonical one (builders., status., www.). Its
374
+ // sign-in has to begin on the canonical host and come back via the handoff.
375
+ function isSatelliteHubHost(host) {
376
+ const canon = canonicalHost();
377
+ return !!canon && HUB_HOSTS.has(canon) && HUB_HOSTS.has(host) && host !== canon;
378
+ }
379
+
380
+ // ── the one-time session handoff (task 1004357) ──
381
+ // A host-only cookie set on the apex never reaches builders.<apex>, so after a
382
+ // sign-in (which always completes on the apex) the apex hands the subdomain a
383
+ // ONE-TIME code in a redirect, and the subdomain trades it for its own host-only
384
+ // copy of the same session. The code is bound to the one target host, single-use
385
+ // and lives HANDOFF_TTL_MS. It is only ever minted for a HUB_HOSTS member, so a
386
+ // project hall on another subdomain can never be handed one. Same token, not a
387
+ // fresh session: one sign-out revokes it everywhere.
388
+ //
389
+ // In-memory: the hub is one process serving all its hosts, and a code outlives
390
+ // a restart by at most HANDOFF_TTL_MS — a builder caught by one signs in again
391
+ // from the handoff page's door.
392
+ const HANDOFF_TTL_MS = 60 * 1000;
393
+ const HANDOFF_MAX = 10000;
394
+ const handoffCodes = new Map();
395
+ function mintHandoffCode({ token, host, expires, now = Date.now() }) {
396
+ for (const [c, v] of handoffCodes) if (v.until <= now) handoffCodes.delete(c);
397
+ if (handoffCodes.size >= HANDOFF_MAX) handoffCodes.delete(handoffCodes.keys().next().value);
398
+ const code = crypto.randomBytes(32).toString('hex');
399
+ handoffCodes.set(code, { token, host, expires: expires || null, until: now + HANDOFF_TTL_MS });
400
+ return code;
401
+ }
402
+ function redeemHandoffCode(code, host, now = Date.now()) {
403
+ if (typeof code !== 'string' || !/^[0-9a-f]{64}$/.test(code)) return null;
404
+ const v = handoffCodes.get(code);
405
+ if (!v) return null;
406
+ handoffCodes.delete(code); // single-use, even when the host does not match
407
+ if (v.until <= now || v.host !== host) return null;
408
+ return v;
409
+ }
410
+
411
+ // The API prefix this router is mounted under (/api/gds or /api/bongos).
412
+ function apiBase(req) {
413
+ return req.baseUrl || '/api/bongos';
414
+ }
415
+
416
+ // The URL that hands the session to `target` — a validated return URL on a
417
+ // satellite hub host — or null when `target` is anything else. The redirect
418
+ // carries only the code; the path the builder was headed for rides as ?return=
419
+ // and is re-validated (relative paths only) on the far side.
420
+ function handoffUrlFor(req, target, token, expires) {
421
+ let u;
422
+ try { u = new URL(target); } catch { return null; }
423
+ const host = u.hostname.toLowerCase();
424
+ if (u.protocol !== 'https:' || u.port || !isSatelliteHubHost(host)) return null;
425
+ const code = mintHandoffCode({ token, host, expires });
426
+ const back = (u.pathname || '/') + (u.search || '');
427
+ return `https://${host}${apiBase(req)}/auth/web/handoff?code=${code}&return=${encodeURIComponent(back)}`;
428
+ }
429
+
430
+ // The live session token (and its expiry) this request carries, or null.
431
+ async function liveSessionToken(req) {
432
+ for (const token of auth.extractTokens(req)) {
433
+ const session = await db.lookupSession(token);
434
+ if (session) return { token, expires: session.expires_at || null };
435
+ }
436
+ return null;
437
+ }
438
+
439
+ // Move a browser still holding a pre-1004357 session cookie onto the host-only
440
+ // one (task 1004357). Every builder signed in before this shipped carries an
441
+ // apex-scoped `bongos_session` that their browser still sends to every project
442
+ // hall; waiting for them to sign in again would leave it live for the session's
443
+ // whole lifetime. So on the first https request that carries an old name and not
444
+ // the current one, re-issue the same token host-only and expire the old cookie
445
+ // in both forms — or, when no old token is live any more, just expire it. A
446
+ // lookup that THROWS changes nothing: never sign a builder out on a DB blip.
447
+ async function migrateLegacySessionCookie(req, res, next) {
448
+ try {
449
+ if (isSecureReq(req) && !parseCookie(req, SESSION_COOKIE)
450
+ && auth.SESSION_COOKIE_NAMES.slice(1).some((n) => parseCookie(req, n))) {
451
+ const live = await liveSessionToken(req);
452
+ if (live) setSessionCookie(req, res, live.token, live.expires);
453
+ else clearSupersededSessionCookies(req, res, { all: true });
454
+ }
455
+ } catch (err) {
456
+ log.error('[auth] legacy session-cookie migration failed (non-blocking)', err);
457
+ }
458
+ next();
459
+ }
460
+
461
+ // A Discord link begun on a satellite hub host restarts on the canonical host,
462
+ // where its callback lands and its host-only state cookie can be read back.
463
+ function discordStartOnCanonicalHost(req, res, next) {
464
+ if (!isSatelliteHubHost(reqHost(req))) return next();
465
+ const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
466
+ return res.redirect(`${WEB_REDIRECT_ORIGIN.replace(/\/+$/, '')}${apiBase(req)}/auth/discord/start${qs}`);
467
+ }
468
+
319
469
  // Validate a caller-supplied post-sign-in return target. Accept ONLY:
320
470
  // - a relative path beginning with a single '/' (not '//', no backslash), or
321
471
  // - an absolute https URL whose host is the apex or a *.example.com sub.
@@ -433,6 +583,9 @@ module.exports = function buildAuthRouter() {
433
583
  registerSeams();
434
584
  const router = express.Router();
435
585
 
586
+ // Runs ahead of every API route on this router (task 1004357) — see the helper.
587
+ router.use(migrateLegacySessionCookie);
588
+
436
589
  // /auth/device — CLI flow
437
590
  // rank: public — auth flow itself; identity establishment, not authorization.
438
591
  router.post('/auth/device/start', async (_req, res) => {
@@ -524,12 +677,43 @@ module.exports = function buildAuthRouter() {
524
677
 
525
678
  // /auth/web — browser flow (used by the /builders page).
526
679
  // rank: public — entry into the GitHub OAuth handshake.
527
- router.get('/auth/web/start', (req, res) => {
680
+ router.get('/auth/web/start', async (req, res) => {
528
681
  // Either direct-GitHub creds OR platform-IdP federation must be configured
529
682
  // (ADR 0141 §1) — a federated instance has no GitHub app of its own.
530
683
  if (!auth.authConfigured() && !auth.idpConfigured()) {
531
684
  return res.status(503).type('text/plain').send('auth not configured');
532
685
  }
686
+ const wantsRepos = req.query.repos === '1' || req.query.repos === 'true';
687
+ // A sign-in begun on a satellite hub host (builders./status.) restarts on
688
+ // the canonical host (task 1004357). The handshake cookies are host-only now,
689
+ // and the callback always lands on the canonical host, so a state cookie set
690
+ // here could never be read back there. The return target is made absolute
691
+ // first, so the builder still lands back on THIS host — via the handoff.
692
+ const host = reqHost(req);
693
+ if (isSatelliteHubHost(host)) {
694
+ const raw = safeReturnTo(req.query.return) || safeReturnTo(req.headers.referer) || '/';
695
+ const back = raw.startsWith('/') ? `https://${host}${raw}` : raw;
696
+ const params = new URLSearchParams({ return: back });
697
+ if (wantsRepos) params.set('repos', '1');
698
+ return res.redirect(`${WEB_REDIRECT_ORIGIN.replace(/\/+$/, '')}${apiBase(req)}/auth/web/start?${params}`);
699
+ }
700
+ // Already signed in on the canonical host and headed for a satellite hub
701
+ // host (a builder who signed in on the apex, then opened builders.): no
702
+ // GitHub round-trip, just hand that host its copy. A repo-scope sign-in
703
+ // still goes to GitHub — it exists to ask for the broader grant.
704
+ if (!wantsRepos) {
705
+ const target = safeReturnTo(req.query.return);
706
+ if (target && !target.startsWith('/')) {
707
+ try {
708
+ const live = await liveSessionToken(req);
709
+ const url = live && handoffUrlFor(req, target, live.token, live.expires);
710
+ if (url) return res.redirect(url);
711
+ } catch (err) {
712
+ // A lookup failure falls through to an ordinary sign-in.
713
+ log.error('[auth] web/start: session lookup failed', err);
714
+ }
715
+ }
716
+ }
533
717
  const state = crypto.randomBytes(16).toString('hex');
534
718
  // redirect_uri is PINNED to the canonical origin (#734) — never derived from
535
719
  // the request host. GitHub only knows the apex callback, so a sign-in begun
@@ -541,9 +725,9 @@ module.exports = function buildAuthRouter() {
541
725
  sameSite: 'lax',
542
726
  secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
543
727
  maxAge: HANDSHAKE_COOKIE_MS,
544
- // Apex-scoped (#734) so the state survives the host switch: start may run
545
- // on the subdomain but the callback always lands on the apex.
546
- ...sessionCookieDomainOpts(req),
728
+ // Host-only (task 1004357). It used to be apex-scoped (#734) so a start on
729
+ // a subdomain could reach the apex callback; that start now restarts on
730
+ // the canonical host instead (above), so the state never crosses a host.
547
731
  });
548
732
  // Post-sign-in return target (#726/#734): an explicit ?return= wins (set by
549
733
  // the page gates); otherwise fall back to the Referer (the page that kicked
@@ -557,22 +741,19 @@ module.exports = function buildAuthRouter() {
557
741
  sameSite: 'lax',
558
742
  secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
559
743
  maxAge: HANDSHAKE_COOKIE_MS,
560
- ...sessionCookieDomainOpts(req),
561
744
  });
562
745
  }
563
746
  // Opt-in repo access (task 1002099 / ADR 0143): the onboarding wizard passes
564
747
  // ?repos=1 so it can list/create the user's repos. Remember the intent in a
565
748
  // short-lived cookie (same recipe as the state cookie) and request the broader
566
749
  // scope on the direct-GitHub leg. Absent the flag this is a no-op — hall + CLI
567
- // sign-ins stay identity-only.
568
- const wantsRepos = req.query.repos === '1' || req.query.repos === 'true';
750
+ // sign-ins stay identity-only. (wantsRepos is read at the top of the handler.)
569
751
  if (wantsRepos) {
570
752
  res.cookie(REPOS_COOKIE, '1', {
571
753
  httpOnly: true,
572
754
  sameSite: 'lax',
573
755
  secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
574
756
  maxAge: HANDSHAKE_COOKIE_MS,
575
- ...sessionCookieDomainOpts(req),
576
757
  });
577
758
  }
578
759
  // Federate through the hub when configured (ADR 0141 §1); otherwise the direct
@@ -583,6 +764,32 @@ module.exports = function buildAuthRouter() {
583
764
  : auth.buildAuthorizeUrl(state, redirectUri, { repos: wantsRepos }));
584
765
  });
585
766
 
767
+ // GET /auth/web/handoff?code=&return= — the far side of the one-time session
768
+ // handoff (task 1004357). The canonical host minted `code` for THIS host after
769
+ // a sign-in (or for a builder already signed in there); trading it sets this
770
+ // host's own host-only copy of the session, then lands on `return` — a
771
+ // relative path only, so the handoff can never become an open redirect.
772
+ //
773
+ // rank: public — auth establishment; the one-time code is the bearer, bound to
774
+ // this exact host, single-use and alive for HANDOFF_TTL_MS.
775
+ router.get('/auth/web/handoff', (req, res) => {
776
+ res.set('Cache-Control', 'no-store');
777
+ // The URL carries a bearer code: never let it ride out in a Referer.
778
+ res.set('Referrer-Policy', 'no-referrer');
779
+ const host = reqHost(req);
780
+ const grant = isSatelliteHubHost(host) ? redeemHandoffCode(req.query.code, host) : null;
781
+ if (!grant) {
782
+ return res.status(400).type('text/html').send(renderAuthErrorPage(
783
+ 'Sign-in link expired',
784
+ `This one-time sign-in link was already used, has expired (they last ${HANDOFF_TTL_MS / 1000} seconds) or belongs to another address. Sign in again.`,
785
+ { startHref: `${apiBase(req)}/auth/web/start` },
786
+ ));
787
+ }
788
+ setSessionCookie(req, res, grant.token, grant.expires ? new Date(grant.expires) : undefined);
789
+ const back = safeReturnTo(req.query.return);
790
+ res.redirect(back && back.startsWith('/') ? back : '/');
791
+ });
792
+
586
793
  // GET /auth/web/admission-status?login=X — the probe renderAccessPendingPage
587
794
  // polls (task 1002097). Answers ONE question: would this login be admitted
588
795
  // right now? It mirrors the real gate via auth.webAdmissionStatus so the page
@@ -667,20 +874,10 @@ module.exports = function buildAuthRouter() {
667
874
  const { token, expiresAt, builder, githubToken, githubScope } = auth.idpConfigured()
668
875
  ? await auth.completeIdpFlow({ code, redirectUri, userAgent: req.headers['user-agent'] })
669
876
  : await auth.completeWebFlow({ code, redirectUri, userAgent: req.headers['user-agent'], captureRepoToken: wantsRepos });
670
- res.cookie(SESSION_COOKIE, token, {
671
- httpOnly: true,
672
- sameSite: 'lax',
673
- secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
674
- path: '/',
675
- expires: expiresAt,
676
- // Apex-scoped on the production hosts so one sign-in spans
677
- // example.com + builders. + status. (#726); host-only elsewhere.
678
- ...sessionCookieDomainOpts(req),
679
- });
680
- // A browser arriving with an older session name must not keep it: two
681
- // session cookies means every later request carries two candidate tokens,
682
- // and the stale one outlives this sign-in.
683
- clearSupersededSessionCookies(req, res);
877
+ // Host-only on every host (task 1004357) — setSessionCookie also clears
878
+ // every older name and the legacy apex-scoped copy, so a browser arriving
879
+ // with either leaves holding only the host-only cookie.
880
+ setSessionCookie(req, res, token, expiresAt);
684
881
  // Persist the wizard's repo token (task 1002099 / ADR 0143): encrypted at rest,
685
882
  // short-TTL, keyed to the builder — the repo picker/creator reads it back over
686
883
  // the next few minutes. Gated on secret-box being configured (upsertGithubToken
@@ -692,10 +889,9 @@ module.exports = function buildAuthRouter() {
692
889
  log.error('[gds] web/callback: repo-token capture failed (non-blocking):', e.message);
693
890
  }
694
891
  }
695
- // The state cookie is apex-scoped now (#734) — clear it with the same
696
- // domain or the browser keeps it. Clear the host-only form too for any
697
- // cookie set before this change. Clear the repos-intent cookie the same way.
698
- const domainOpts = sessionCookieDomainOpts(req);
892
+ // The handshake cookies are host-only now (task 1004357). Clear the legacy
893
+ // apex-scoped form too, for a handshake begun before that change.
894
+ const domainOpts = legacyApexCookieOpts(req);
699
895
  res.clearCookie(STATE_COOKIE);
700
896
  res.clearCookie(STATE_COOKIE_LEGACY);
701
897
  res.clearCookie(REPOS_COOKIE);
@@ -709,8 +905,12 @@ module.exports = function buildAuthRouter() {
709
905
  // configured post-login landing (default '/builders'; cloudbongos.com →
710
906
  // '/', its landing page — task 1002240).
711
907
  const returnTo = safeReturnTo(parseCookie(req, RETURN_COOKIE));
712
- res.clearCookie(RETURN_COOKIE, domainOpts);
713
- res.redirect(returnTo || postLoginLanding());
908
+ res.clearCookie(RETURN_COOKIE);
909
+ if (domainOpts.domain) res.clearCookie(RETURN_COOKIE, domainOpts);
910
+ // Headed for builders./status.? Their cookie cannot be set from here, so
911
+ // hand them the session through the one-time code (task 1004357).
912
+ const handoff = returnTo && !returnTo.startsWith('/') && handoffUrlFor(req, returnTo, token, expiresAt);
913
+ res.redirect(handoff || returnTo || postLoginLanding());
714
914
  } catch (err) {
715
915
  // Approval gate (2026-05-30): a first-time GitHub account that hasn't been
716
916
  // approved by an Archon can't enter. Send them to the hall to request
@@ -804,7 +1004,7 @@ module.exports = function buildAuthRouter() {
804
1004
  // (503) until the server has Discord client creds. Mirrors the GitHub web
805
1005
  // flow's CSRF-state discipline above — a hard requirement (ADR 0033 §2).
806
1006
  // rank: any-builder — links to your OWN identity; no rank gate.
807
- router.get('/auth/discord/start', auth.requireBuilder, (req, res) => {
1007
+ router.get('/auth/discord/start', discordStartOnCanonicalHost, auth.requireBuilder, (req, res) => {
808
1008
  if (!auth.discordConfigured()) {
809
1009
  return res.status(503).type('text/plain').send('Discord linking not configured on this server yet.');
810
1010
  }
@@ -815,12 +1015,9 @@ module.exports = function buildAuthRouter() {
815
1015
  sameSite: 'lax',
816
1016
  secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
817
1017
  maxAge: 10 * 60 * 1000,
818
- // Apex-scoped (#734 parity) so the state survives the host switch: the
819
- // link may start on builders.example.com but DISCORD_REDIRECT_URI
820
- // always lands the callback on the apex. A host-only cookie would be
821
- // dropped on the hop and the state check would fail ("Link handshake
822
- // expired"). Mirrors the GitHub web-flow state cookie above.
823
- ...sessionCookieDomainOpts(req),
1018
+ // Host-only (task 1004357). DISCORD_REDIRECT_URI always lands the callback
1019
+ // on the canonical host, so a link begun on builders. first restarts
1020
+ // there (discordStartOnCanonicalHost) — the state never crosses a host.
824
1021
  });
825
1022
  res.redirect(auth.buildDiscordAuthorizeUrl(state, redirectUri));
826
1023
  });
@@ -832,10 +1029,8 @@ module.exports = function buildAuthRouter() {
832
1029
  }
833
1030
  const expectedState = parseCookie(req, DISCORD_STATE_COOKIE);
834
1031
  const { state, code, error } = req.query;
835
- // The state cookie is apex-scoped now (#734 parity) — clear it with the
836
- // same domain or the browser keeps it. Clear the host-only form too for any
837
- // cookie set before this change.
838
- const stateDomainOpts = sessionCookieDomainOpts(req);
1032
+ // Host-only now (task 1004357); clear the legacy apex-scoped form too.
1033
+ const stateDomainOpts = legacyApexCookieOpts(req);
839
1034
  res.clearCookie(DISCORD_STATE_COOKIE);
840
1035
  if (stateDomainOpts.domain) res.clearCookie(DISCORD_STATE_COOKIE, stateDomainOpts);
841
1036
  // CSRF FIRST: validate the state cookie against the state Discord echoes
@@ -962,15 +1157,12 @@ module.exports = function buildAuthRouter() {
962
1157
  const token = (req.headers['authorization'] || '').replace(/^Bearer\s+/, '')
963
1158
  || sessionCookieToken(req);
964
1159
  if (token) await db.revokeSession(token);
965
- // Clear BOTH the host-only form (older sessions) and the apex-scoped form
966
- // (#726) — a clearCookie only matches the cookie whose domain attribute it
967
- // names, so sign-out on a production host must target the apex domain too.
968
- res.clearCookie(SESSION_COOKIE);
969
- const domainOpts = sessionCookieDomainOpts(req);
970
- if (domainOpts.domain) res.clearCookie(SESSION_COOKIE, domainOpts);
971
- // …and every superseded name, or a sign-out leaves the older cookie behind
972
- // and the next request re-authenticates on it.
973
- clearSupersededSessionCookies(req, res);
1160
+ // Clear every session name, host-only AND the legacy apex-scoped form — a
1161
+ // clearCookie only matches the cookie whose domain attribute it names, and a
1162
+ // sign-out that left one behind would re-authenticate the next request.
1163
+ // The token itself is revoked above, which also kills the copies the
1164
+ // handoff gave the hub's other hosts (they share it).
1165
+ clearSupersededSessionCookies(req, res, { all: true });
974
1166
  // Federated single-logout (ADR 0144, task 1002238): announce the logout so the
975
1167
  // hub (platform-identity, enabled only on cloudbongos.com) can fan a signed
976
1168
  // back-channel logout out to every federated project this builder entered. A
@@ -1170,7 +1362,13 @@ module.exports.safeReturnTo = safeReturnTo;
1170
1362
  module.exports.postLoginLanding = postLoginLanding;
1171
1363
  module.exports.recoveryStartHref = recoveryStartHref;
1172
1364
  module.exports.HANDSHAKE_COOKIE_MS = HANDSHAKE_COOKIE_MS;
1173
- module.exports.sessionCookieDomainOpts = sessionCookieDomainOpts;
1365
+ module.exports.legacyApexCookieOpts = legacyApexCookieOpts;
1366
+ module.exports.setSessionCookie = setSessionCookie;
1367
+ module.exports.sessionCookieName = sessionCookieName;
1368
+ module.exports.mintHandoffCode = mintHandoffCode;
1369
+ module.exports.redeemHandoffCode = redeemHandoffCode;
1370
+ module.exports.handoffUrlFor = handoffUrlFor;
1371
+ module.exports.HANDOFF_TTL_MS = HANDOFF_TTL_MS;
1174
1372
  module.exports.reqHost = reqHost;
1175
1373
  // The auth-error page renderer (task 1439): pure over the branding pack, so a
1176
1374
  // test can prove every visible string is brand-owned (no OTB lore baked in).
@@ -23,6 +23,9 @@
23
23
  // server end of `bongos module publish`. Not the
24
24
  // upstreaming door above: submit proposes a module
25
25
  // INTO core; publish puts a version in the store.
26
+ // GET /store/entitlements the modules the caller holds, and at which version
27
+ // GET /store/modules/:key/entitlement
28
+ // does the caller hold this module (task 1003813).
26
29
  //
27
30
  // Read is open to any signed-in builder (same gate as the atlas/primer pages);
28
31
  // every write (enable/disable, submit) is metic+archon, same rank as
@@ -65,6 +68,7 @@ const modulesLib = require('../../modules');
65
68
  const moduleCli = require('../../../scripts/gds/module');
66
69
  const moduleSubmissions = require('../module-submissions');
67
70
  const moduleArtifact = require('../../../scripts/gds/module-artifact');
71
+ const { checkEntitlement, listEntitlements } = require('../module-entitlements');
68
72
  const { stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion } = require('../module-store');
69
73
  const { KEY_RE } = require('../../module-loader/manifest-schema');
70
74
  // Named imports, not a namespace object (task 1004052 strand walk): knip cannot follow
@@ -347,6 +351,31 @@ module.exports = function buildModulesRouter() {
347
351
  res.status(201).json({ ok: true, created_module: result.created, version: result.version });
348
352
  }));
349
353
 
354
+ // GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
355
+ // read side of the entitlement record (task 1003813, ADR 0338 D1). Own-scoped
356
+ // like /me/sessions: the holder is ALWAYS the caller (holder_kind 'builder',
357
+ // holder_ref = req.builder.id), never named in the query or path, so requireBuilder
358
+ // alone is the gate and one builder cannot read another's holdings. Granting has no
359
+ // route yet: install (task 1003785) grants a free module, area 8's buy action
360
+ // grants a paid one — both through src/bongos/module-entitlements.js.
361
+ router.get('/store/entitlements', auth.requireBuilder,
362
+ asyncHandler('GET /store/entitlements', async (req, res) => {
363
+ const entitlements = await listEntitlements({ holderKind: 'builder', holderRef: String(req.builder.id) });
364
+ res.set('Cache-Control', 'no-store');
365
+ res.json({ entitlements });
366
+ }));
367
+
368
+ router.get('/store/modules/:key/entitlement', auth.requireBuilder,
369
+ asyncHandler('GET /store/modules/:key/entitlement', async (req, res) => {
370
+ const { key } = req.params;
371
+ if (!KEY_RE.test(key)) {
372
+ return res.fail('bad_module_key', { status: 400, message: 'A module key is lowercase kebab-case.' });
373
+ }
374
+ const held = await checkEntitlement({ moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) });
375
+ res.set('Cache-Control', 'no-store');
376
+ res.json(held);
377
+ }));
378
+
350
379
  return router;
351
380
  };
352
381
 
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.19.1076'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.19.1078'; // 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');