@bongos/core 1.19.666 → 1.19.668
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.
- package/.bongos-core.json +40 -35
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/session-store.js +33 -9
- package/modules/hall-ui/public/shell.js +1 -1
- package/modules/public-landing/public/index.html +1 -1
- package/modules/public-landing/public/projects.html +1 -1
- package/modules/security/artifact-scan/detectors/credentials.js +8 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-infra.js +1 -1
- package/scripts/gds/cli-lib.js +55 -16
- package/scripts/gds/fetch-art-key.js +1 -1
- package/scripts/gds/init.js +1 -1
- package/scripts/gds/paste-token.js +10 -5
- package/scripts/gds/portability-smoke-memory.sh +1 -1
- package/scripts/gds/setup.js +4 -4
- package/scripts/gds/smoke-write.sh +2 -1
- package/scripts/hall-preview/refresh-fixtures.js +1 -1
- package/src/bongos/auth.js +27 -5
- package/src/bongos/middleware/rate-limit.js +17 -2
- package/src/bongos/platform-visibility-gate.js +23 -3
- package/src/bongos/routes/auth.js +46 -15
- package/src/instance-config.js +28 -2
- package/src/module-api.js +1 -1
- package/tests/cli_sessions.mjs +6 -5
- package/tests/landing_page.mjs +5 -1
- package/tests/lib_sh_resolution.mjs +84 -17
- package/tests/platform_visibility_gate.mjs +52 -0
- package/tests/session_rename_fallback.mjs +383 -0
- package/tests/setup_device_flow_fallback.mjs +4 -1
package/scripts/gds/setup.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Prints the user_code and verification_uri, then polls until the user
|
|
16
16
|
// completes auth (or expires/denies). On success, writes
|
|
17
|
-
// ~/.config/otb/
|
|
17
|
+
// ~/.config/otb/bongos-session.json (resolved from os.homedir(), so this works for
|
|
18
18
|
// any user on any machine; no path is hardcoded to a particular developer's
|
|
19
19
|
// home directory) and prints the builder profile.
|
|
20
20
|
|
|
@@ -49,7 +49,7 @@ function dryRunStep(msg) {
|
|
|
49
49
|
// pixel-art pipeline (gen_api.py). V3.R11 (#91): each builder pays their own
|
|
50
50
|
// Gemini bill, so spend is attributable per-builder on the public dashboard.
|
|
51
51
|
//
|
|
52
|
-
// Stored as gemini_api_key in ~/.config/otb/
|
|
52
|
+
// Stored as gemini_api_key in ~/.config/otb/bongos-session.json (mode 600, same
|
|
53
53
|
// file as the GitHub session token). gen_api.py reads this location before
|
|
54
54
|
// falling back to ~/.config/otb/env or .env.local.
|
|
55
55
|
//
|
|
@@ -476,7 +476,7 @@ async function main() {
|
|
|
476
476
|
|
|
477
477
|
// A valid session already exists on this machine. Don't silently adopt
|
|
478
478
|
// it. The session file is a single global path
|
|
479
|
-
// (~/.config/otb/
|
|
479
|
+
// (~/.config/otb/bongos-session.json), so a second person setting up on a
|
|
480
480
|
// shared device — or the same person onboarding a different account,
|
|
481
481
|
// e.g. for testing — would otherwise be invisibly bound to whoever set
|
|
482
482
|
// up first. On an interactive shell, confirm before continuing; the gate
|
|
@@ -513,7 +513,7 @@ async function main() {
|
|
|
513
513
|
console.log('');
|
|
514
514
|
console.log('Setting up as a different builder — the saved session will be replaced once you finish signing in...');
|
|
515
515
|
// Fall through to the Device Flow below, which calls saveSession() and
|
|
516
|
-
// overwrites ~/.config/otb/
|
|
516
|
+
// overwrites ~/.config/otb/bongos-session.json with the new identity.
|
|
517
517
|
} else {
|
|
518
518
|
console.log(`Continuing as ${who}.`);
|
|
519
519
|
process.exit(0);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
# GDS write-path smoke test (production). Renamed from PMS in V3.R01.
|
|
3
3
|
#
|
|
4
4
|
# Exercises the full claim → ship round-trip against the live API:
|
|
5
|
-
# 1. Read session token from ~/.config/otb/
|
|
5
|
+
# 1. Read session token from ~/.config/otb/bongos-session.json (or the legacy
|
|
6
|
+
# gds-session.json / pms-session.json names, all still accepted on read)
|
|
6
7
|
# 2. POST /api/gds/tasks → create a synthetic task titled '__smoke__ <ts>'
|
|
7
8
|
# 3. POST /api/gds/claims → claim it
|
|
8
9
|
# 4. POST /api/gds/claims/:id/resolve outcome=shipped
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// scripts/hall-preview/refresh-fixtures.js — snapshot real API responses into
|
|
3
3
|
// the preview harness's fixtures/ (task 2167). Run with a valid GDS CLI session
|
|
4
|
-
// (~/.config/otb/
|
|
4
|
+
// (~/.config/otb/bongos-session.json) whenever the fixtures drift from the live
|
|
5
5
|
// API shapes: node scripts/hall-preview/refresh-fixtures.js
|
|
6
6
|
//
|
|
7
7
|
// Read-only: GETs only. Endpoints that 403/404 for the caller's rank are
|
package/src/bongos/auth.js
CHANGED
|
@@ -62,6 +62,15 @@ const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, di
|
|
|
62
62
|
|
|
63
63
|
const MAX_AUTH_TOKEN_CANDIDATES = 6;
|
|
64
64
|
|
|
65
|
+
// Every name the session cookie has ever had, NEWEST FIRST. A rename APPENDS to
|
|
66
|
+
// this list, it never replaces: at the instant the new code deploys, every
|
|
67
|
+
// signed-in browser is still sending the OLD name, so dropping a name signs that
|
|
68
|
+
// builder out mid-session. `pms_session` predates the gds_ rename and is still
|
|
69
|
+
// in the wild, so the list is three long, not two (task 1003704). The server
|
|
70
|
+
// SETS only the head; routes/auth.js clears the tail on the next write. Part 4
|
|
71
|
+
// (task 1003706) retires the tail behind a test of its absence.
|
|
72
|
+
const SESSION_COOKIE_NAMES = Object.freeze(['bongos_session', 'gds_session', 'pms_session']);
|
|
73
|
+
|
|
65
74
|
function extractTokens(req) {
|
|
66
75
|
const out = [];
|
|
67
76
|
const push = (t) => {
|
|
@@ -70,18 +79,30 @@ function extractTokens(req) {
|
|
|
70
79
|
const auth = req.headers['authorization'] || '';
|
|
71
80
|
const m = /^Bearer\s+([a-zA-Z0-9]+)$/.exec(auth);
|
|
72
81
|
if (m) push(m[1]);
|
|
73
|
-
// Cookie parsing: minimal, no dep.
|
|
82
|
+
// Cookie parsing: minimal, no dep. Group by NAME, then emit in
|
|
83
|
+
// SESSION_COOKIE_NAMES order, so a browser carrying both a fresh
|
|
84
|
+
// `bongos_session` and a stale `gds_session` tries the fresh name first
|
|
85
|
+
// instead of depending on header order.
|
|
86
|
+
//
|
|
87
|
+
// EVERY value under a name is kept, in header order — never just the first.
|
|
88
|
+
// One name can legitimately appear TWICE: a stale host-only cookie plus the
|
|
89
|
+
// current apex-scoped one (#851, #726/#734). Returning only the first let the
|
|
90
|
+
// stale duplicate shadow the live session and 401 the builder, so the grouping
|
|
91
|
+
// must be name → LIST. push() dedupes identical values and caps the total.
|
|
74
92
|
const cookieHeader = req.headers['cookie'] || '';
|
|
75
|
-
const
|
|
93
|
+
const found = new Map();
|
|
76
94
|
for (const part of cookieHeader.split(';')) {
|
|
77
95
|
const idx = part.indexOf('=');
|
|
78
96
|
if (idx < 0) continue;
|
|
79
97
|
const k = part.slice(0, idx).trim();
|
|
80
98
|
const v = part.slice(idx + 1).trim();
|
|
81
|
-
if (
|
|
82
|
-
|
|
99
|
+
if (!v || !SESSION_COOKIE_NAMES.includes(k)) continue;
|
|
100
|
+
if (!found.has(k)) found.set(k, []);
|
|
101
|
+
found.get(k).push(v);
|
|
102
|
+
}
|
|
103
|
+
for (const name of SESSION_COOKIE_NAMES) {
|
|
104
|
+
for (const v of found.get(name) || []) push(v);
|
|
83
105
|
}
|
|
84
|
-
for (const v of legacy) push(v);
|
|
85
106
|
return out;
|
|
86
107
|
}
|
|
87
108
|
|
|
@@ -887,6 +908,7 @@ module.exports = {
|
|
|
887
908
|
completeDeviceExchange,
|
|
888
909
|
membershipKind,
|
|
889
910
|
extractTokens,
|
|
911
|
+
SESSION_COOKIE_NAMES,
|
|
890
912
|
resolveSession,
|
|
891
913
|
allowBoxScope,
|
|
892
914
|
sessionScope,
|
|
@@ -122,6 +122,19 @@ function pruneAndCount(bucket, now, windowMs) {
|
|
|
122
122
|
return bucket.length;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// Every name the session cookie has ever had, newest first, precompiled.
|
|
126
|
+
//
|
|
127
|
+
// One regex PER NAME rather than a single alternation: an alternation matches in
|
|
128
|
+
// COOKIE-HEADER order, not preference order, so a browser mid-rename — carrying
|
|
129
|
+
// both the new and the old name — would key on whichever the browser happened to
|
|
130
|
+
// send first. Precompiled at module load because this runs on every request.
|
|
131
|
+
//
|
|
132
|
+
// Deliberately duplicated from SESSION_COOKIE_NAMES in src/bongos/auth.js rather
|
|
133
|
+
// than imported: this middleware is dependency-free by design. The copy is pinned
|
|
134
|
+
// against the real list by tests/session_rename_fallback.mjs (task 1003704).
|
|
135
|
+
const SESSION_COOKIE_RES = ['bongos_session', 'gds_session', 'pms_session']
|
|
136
|
+
.map((n) => new RegExp('(?:^|;\\s*)' + n + '=([^;]+)'));
|
|
137
|
+
|
|
125
138
|
function extractSessionKey(req) {
|
|
126
139
|
// Bearer token first (CLI flow); cookie second (web flow). We use the
|
|
127
140
|
// token AS-IS as the limiter key — it's already opaque + cheap to hash to
|
|
@@ -132,8 +145,10 @@ function extractSessionKey(req) {
|
|
|
132
145
|
}
|
|
133
146
|
const cookie = req.headers && req.headers.cookie;
|
|
134
147
|
if (cookie) {
|
|
135
|
-
const
|
|
136
|
-
|
|
148
|
+
for (const re of SESSION_COOKIE_RES) {
|
|
149
|
+
const m = re.exec(cookie);
|
|
150
|
+
if (m) return 'c:' + m[1];
|
|
151
|
+
}
|
|
137
152
|
}
|
|
138
153
|
return null;
|
|
139
154
|
}
|
|
@@ -70,6 +70,18 @@ const API = `(?:${ALL_API_PREFIXES.map(escRe).join('|')})`;
|
|
|
70
70
|
// /__prelaunch the pre-launch gate's own submit, when both gates are on.
|
|
71
71
|
// /__stealth is its pre-rename alias (task 1003152) and is
|
|
72
72
|
// exempt for as long as the gate still accepts it.
|
|
73
|
+
// <api>/access-requests POST ONLY — a stranger's application to a project that is
|
|
74
|
+
// BOTH gated and "apply to join" (task 1003624). The owner
|
|
75
|
+
// turned that door on deliberately; without this exemption the
|
|
76
|
+
// member door answers 401 first and the two settings compose
|
|
77
|
+
// into "nobody can apply", which nobody chose. The write is
|
|
78
|
+
// public on every other project and carries its own per-IP and
|
|
79
|
+
// per-login limits, and it GRANTS nothing — an application is a
|
|
80
|
+
// row in a queue the owner still reviews (ADR 0201). The bare
|
|
81
|
+
// GET on the same path is the owner's QUEUE (requireBuilder +
|
|
82
|
+
// access_request.review), so the exemption is scoped to the
|
|
83
|
+
// VERB and the queue keeps the member door ahead of its own
|
|
84
|
+
// rank check.
|
|
73
85
|
const EXEMPT = [
|
|
74
86
|
new RegExp(`^${API}/auth(/|$)`),
|
|
75
87
|
new RegExp(`^${API}/instance$`),
|
|
@@ -81,6 +93,7 @@ const EXEMPT = [
|
|
|
81
93
|
/^\/__prelaunch$/,
|
|
82
94
|
/^\/__stealth$/,
|
|
83
95
|
/^\/downloads\//,
|
|
96
|
+
{ re: new RegExp(`^${API}/access-requests$`), methods: ['POST'] },
|
|
84
97
|
];
|
|
85
98
|
|
|
86
99
|
// The policy, read through the memoized pack. A pack that cannot be read at all
|
|
@@ -106,9 +119,16 @@ function normalizePath(p) {
|
|
|
106
119
|
return s;
|
|
107
120
|
}
|
|
108
121
|
|
|
109
|
-
|
|
122
|
+
// `method` is OPTIONAL, and each entry decides whether it matters: a bare RegExp
|
|
123
|
+
// exempts the path whatever the verb, while a method-scoped entry matches only the
|
|
124
|
+
// verbs it names. A caller that passes no method therefore gets the fail-CLOSED
|
|
125
|
+
// answer for the scoped entries (the static path checks in tests/version_literals.mjs
|
|
126
|
+
// and tests/prelaunch_gate.mjs call it that way) — never an accidental exemption of a
|
|
127
|
+
// sibling route that shares a path with an exempt one.
|
|
128
|
+
function isExempt(path, method) {
|
|
110
129
|
const p = normalizePath(path);
|
|
111
|
-
|
|
130
|
+
const m = String(method || '').toUpperCase();
|
|
131
|
+
return EXEMPT.some((e) => (e instanceof RegExp ? e.test(p) : e.re.test(p) && e.methods.includes(m)));
|
|
112
132
|
}
|
|
113
133
|
|
|
114
134
|
function isApiPath(path) { return /^\/api\//i.test(String(path || '')); }
|
|
@@ -233,7 +253,7 @@ function platformVisibilityGate(deps = {}) {
|
|
|
233
253
|
return function platformVisibility(req, res, next) {
|
|
234
254
|
if (!gated()) return next();
|
|
235
255
|
if (req.method === 'OPTIONS') return next(); // a CORS preflight carries no identity
|
|
236
|
-
if (isExempt(req.path)) return next();
|
|
256
|
+
if (isExempt(req.path, req.method)) return next();
|
|
237
257
|
// Async middleware must own its rejection (Express 4 drops the promise) —
|
|
238
258
|
// and it FAILS CLOSED: if the session cannot be resolved, the caller's
|
|
239
259
|
// identity is unknown, and admitting is never an option (auth.js's rule).
|
|
@@ -204,12 +204,41 @@ function renderAccessPendingPage(login, startHref) {
|
|
|
204
204
|
</body></html>`;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
// Cookie names. The web flow writes the new `
|
|
208
|
-
// names; both flows accept
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
207
|
+
// Cookie names. The web flow writes the new `bongos_session` (+ `gds_oauth_state`)
|
|
208
|
+
// names; both flows accept every older session name on read so a builder
|
|
209
|
+
// mid-flow doesn't have to re-auth at the rename rollout.
|
|
210
|
+
//
|
|
211
|
+
// The session read-list is NOT declared here. It lives in ../auth.js as
|
|
212
|
+
// SESSION_COOKIE_NAMES, because the request-level token extractor there has to
|
|
213
|
+
// accept exactly the names this router sets and clears: two lists would drift,
|
|
214
|
+
// and the only symptom of the drift is a builder being silently signed out.
|
|
215
|
+
// Head = the one name we SET. Tail = the names we accept and clear on write.
|
|
216
|
+
const SESSION_COOKIE = auth.SESSION_COOKIE_NAMES[0];
|
|
217
|
+
const SESSION_COOKIE_OLD = auth.SESSION_COOKIE_NAMES.slice(1);
|
|
218
|
+
|
|
219
|
+
// The session token off a request's cookies, trying each accepted name newest
|
|
220
|
+
// first. Replaces the hand-written `parseCookie(new) || parseCookie(legacy)`
|
|
221
|
+
// pairs, which accepted only two of the three live names.
|
|
222
|
+
function sessionCookieToken(req) {
|
|
223
|
+
for (const name of auth.SESSION_COOKIE_NAMES) {
|
|
224
|
+
const v = parseCookie(req, name);
|
|
225
|
+
if (v) return v;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Clear every SUPERSEDED session cookie, in both the host-only and apex-scoped
|
|
231
|
+
// forms (a clearCookie only matches the cookie whose domain attribute it names).
|
|
232
|
+
// Called wherever we set the current cookie, so a browser that arrived with a
|
|
233
|
+
// `gds_session` leaves holding only `bongos_session` — otherwise the stale name
|
|
234
|
+
// lingers until its own expiry and every later request carries two tokens.
|
|
235
|
+
function clearSupersededSessionCookies(req, res) {
|
|
236
|
+
const domainOpts = sessionCookieDomainOpts(req);
|
|
237
|
+
for (const name of SESSION_COOKIE_OLD) {
|
|
238
|
+
res.clearCookie(name);
|
|
239
|
+
if (domainOpts.domain) res.clearCookie(name, domainOpts);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
213
242
|
const STATE_COOKIE = 'gds_oauth_state';
|
|
214
243
|
const STATE_COOKIE_LEGACY = 'pms_oauth_state';
|
|
215
244
|
const DISCORD_STATE_COOKIE = 'gds_discord_state';
|
|
@@ -650,6 +679,10 @@ module.exports = function buildAuthRouter() {
|
|
|
650
679
|
// amazonprimea.com + builders. + status. (#726); host-only elsewhere.
|
|
651
680
|
...sessionCookieDomainOpts(req),
|
|
652
681
|
});
|
|
682
|
+
// A browser arriving with an older session name must not keep it: two
|
|
683
|
+
// session cookies means every later request carries two candidate tokens,
|
|
684
|
+
// and the stale one outlives this sign-in.
|
|
685
|
+
clearSupersededSessionCookies(req, res);
|
|
653
686
|
// Persist the wizard's repo token (task 1002099 / ADR 0143): encrypted at rest,
|
|
654
687
|
// short-TTL, keyed to the builder — the repo picker/creator reads it back over
|
|
655
688
|
// the next few minutes. Gated on secret-box being configured (upsertGithubToken
|
|
@@ -916,19 +949,17 @@ module.exports = function buildAuthRouter() {
|
|
|
916
949
|
// rank: any-builder — own session, no rank gate (a Xenos can log themselves out).
|
|
917
950
|
router.post('/auth/logout', auth.requireBuilder, async (req, res) => {
|
|
918
951
|
const token = (req.headers['authorization'] || '').replace(/^Bearer\s+/, '')
|
|
919
|
-
||
|
|
920
|
-
|| parseCookie(req, SESSION_COOKIE_LEGACY);
|
|
952
|
+
|| sessionCookieToken(req);
|
|
921
953
|
if (token) await db.revokeSession(token);
|
|
922
954
|
// Clear BOTH the host-only form (older sessions) and the apex-scoped form
|
|
923
955
|
// (#726) — a clearCookie only matches the cookie whose domain attribute it
|
|
924
956
|
// names, so sign-out on a production host must target the apex domain too.
|
|
925
957
|
res.clearCookie(SESSION_COOKIE);
|
|
926
|
-
res.clearCookie(SESSION_COOKIE_LEGACY);
|
|
927
958
|
const domainOpts = sessionCookieDomainOpts(req);
|
|
928
|
-
if (domainOpts.domain)
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
959
|
+
if (domainOpts.domain) res.clearCookie(SESSION_COOKIE, domainOpts);
|
|
960
|
+
// …and every superseded name, or a sign-out leaves the older cookie behind
|
|
961
|
+
// and the next request re-authenticates on it.
|
|
962
|
+
clearSupersededSessionCookies(req, res);
|
|
932
963
|
// Federated single-logout (ADR 0144, task 1002238): announce the logout so the
|
|
933
964
|
// hub (platform-identity, enabled only on cloudbongos.com) can fan a signed
|
|
934
965
|
// back-channel logout out to every federated project this builder entered. A
|
|
@@ -1002,7 +1033,7 @@ module.exports = function buildAuthRouter() {
|
|
|
1002
1033
|
// (a leaked CLI token shouldn't be able to extend its own lifetime; the
|
|
1003
1034
|
// httpOnly browser cookie is the harder-to-steal credential).
|
|
1004
1035
|
router.post('/auth/cli-token/issue', auth.requireBuilder, async (req, res) => {
|
|
1005
|
-
const cookieToken =
|
|
1036
|
+
const cookieToken = sessionCookieToken(req);
|
|
1006
1037
|
if (!cookieToken) {
|
|
1007
1038
|
// Name the instance's own sign-in host (from the branding pack), not a
|
|
1008
1039
|
// hardcoded "amazonprimea.com" (ADR 0062 §3/§5).
|
|
@@ -1144,7 +1175,7 @@ module.exports = function buildAuthRouter() {
|
|
|
1144
1175
|
// CLI bearer must not be able to approve pairings and mint itself siblings;
|
|
1145
1176
|
// only the httpOnly browser cookie — the click in the hall — can.
|
|
1146
1177
|
router.post('/auth/app-pair/approve', auth.requireBuilder, async (req, res) => {
|
|
1147
|
-
const cookieToken =
|
|
1178
|
+
const cookieToken = sessionCookieToken(req);
|
|
1148
1179
|
if (!cookieToken) {
|
|
1149
1180
|
return res.fail('cookie_required', { status: 403, message: 'Pairings are approved from an authenticated browser session in the builders’ hall, not from a bearer token.' });
|
|
1150
1181
|
}
|
package/src/instance-config.js
CHANGED
|
@@ -104,7 +104,7 @@ function resolveApiBase({ session, env = process.env, b = safeBrand() } = {}) {
|
|
|
104
104
|
let s = session;
|
|
105
105
|
if (s === undefined) {
|
|
106
106
|
try {
|
|
107
|
-
const raw =
|
|
107
|
+
const raw = readSessionConfigSync({ b });
|
|
108
108
|
s = raw ? JSON.parse(raw) : null;
|
|
109
109
|
} catch (_) {
|
|
110
110
|
s = null; // unreadable/corrupt session must never break base resolution
|
|
@@ -135,8 +135,32 @@ function configPath(name, b = safeBrand()) {
|
|
|
135
135
|
return path.join(configHome(b), name);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
// Every name the CLI session file has ever had, NEWEST FIRST.
|
|
139
|
+
//
|
|
140
|
+
// Lives here, not in cli-lib.js which owns the session STORE, because this module
|
|
141
|
+
// is the lowest one that has to read the file (resolveApiBase below) and cli-lib
|
|
142
|
+
// imports this — so this is the only place both can share. A rename APPENDS a
|
|
143
|
+
// name; dropping one signs out every builder machine and every live dev box at
|
|
144
|
+
// once. Part 4 (task 1003706) retires the tail. (task 1003704)
|
|
145
|
+
const SESSION_FILENAMES = Object.freeze([
|
|
146
|
+
'bongos-session.json',
|
|
147
|
+
'gds-session.json',
|
|
148
|
+
'pms-session.json',
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
// The first readable session file across every config dir and every accepted
|
|
152
|
+
// name, or null. FILENAME-major: the newest name in ANY read dir beats an older
|
|
153
|
+
// name in the configured dir, which is the precedence a rename wants.
|
|
154
|
+
function readSessionConfigSync({ b = safeBrand() } = {}) {
|
|
155
|
+
for (const name of SESSION_FILENAMES) {
|
|
156
|
+
const raw = readConfigFileSync(name, { b });
|
|
157
|
+
if (raw) return raw;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
138
162
|
// Every candidate path for a named config file, configured dir first then legacy
|
|
139
|
-
// dirs — for read-with-fallback (the
|
|
163
|
+
// dirs — for read-with-fallback (the session-file / cost_ledger pattern).
|
|
140
164
|
function configReadPaths(name, b = safeBrand()) {
|
|
141
165
|
return configReadDirs(b).map((d) => path.join(d, name));
|
|
142
166
|
}
|
|
@@ -298,6 +322,8 @@ module.exports = {
|
|
|
298
322
|
configPath,
|
|
299
323
|
configReadPaths,
|
|
300
324
|
readConfigFileSync,
|
|
325
|
+
SESSION_FILENAMES,
|
|
326
|
+
readSessionConfigSync,
|
|
301
327
|
resolveEnv,
|
|
302
328
|
resolveCoreRoot,
|
|
303
329
|
resolveInstanceRoot,
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.668'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/cli_sessions.mjs
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// • in-repo the session landed in ~/.config/<slug>/ where the standalone CLI could never find it.
|
|
11
11
|
//
|
|
12
12
|
// The store is keyed by instance host at a FIXED anchor, so the same instance resolves to the same
|
|
13
|
-
// file from either direction. The per-brand
|
|
13
|
+
// file from either direction. The per-brand session file (SESSION_FILENAMES[0]) stays the ACTIVE
|
|
14
|
+
// pointer, untouched,
|
|
14
15
|
// so every existing reader behaves identically — that back-compat is asserted here too.
|
|
15
16
|
|
|
16
17
|
import test from 'node:test';
|
|
@@ -157,7 +158,7 @@ test('the sandbox actually relocates os.homedir() — on THIS platform', () => {
|
|
|
157
158
|
});
|
|
158
159
|
|
|
159
160
|
test('a session store path resolved inside the sandbox stays inside it', () => {
|
|
160
|
-
const { out, status, home } = inSandbox(`console.log(JSON.stringify({ p: ic.configPath(
|
|
161
|
+
const { out, status, home } = inSandbox(`console.log(JSON.stringify({ p: ic.configPath(ic.SESSION_FILENAMES[0]), d: lib.sessionStoreDir() }));`);
|
|
161
162
|
assert.equal(status, 0, `sandbox child failed: ${out}`);
|
|
162
163
|
const { p, d } = JSON.parse(out.trim().split('\n').pop());
|
|
163
164
|
assert.ok(p.startsWith(home), `the active session pointer resolved OUTSIDE the sandbox: ${p}`);
|
|
@@ -176,7 +177,7 @@ test('sandboxEnv overrides every home variable a platform might read', () => {
|
|
|
176
177
|
test('signing into a second instance PRESERVES the first — including one written before the store existed', () => {
|
|
177
178
|
const { out } = inSandbox(`
|
|
178
179
|
// A session from before this feature: an active pointer, nothing in the store.
|
|
179
|
-
const active = ic.configPath(
|
|
180
|
+
const active = ic.configPath(ic.SESSION_FILENAMES[0]);
|
|
180
181
|
fs.mkdirSync(path.dirname(active), { recursive: true });
|
|
181
182
|
fs.writeFileSync(active, JSON.stringify({ token:'TOKEN-A', api_base:'https://a.example.com',
|
|
182
183
|
builder:{github_login:'someone'} }), { mode: 0o600 });
|
|
@@ -200,11 +201,11 @@ test('signing into a second instance PRESERVES the first — including one writt
|
|
|
200
201
|
});
|
|
201
202
|
|
|
202
203
|
test('the ACTIVE pointer still lands exactly where every existing reader looks', () => {
|
|
203
|
-
// In-repo skills, hooks and the dev box all read
|
|
204
|
+
// In-repo skills, hooks and the dev box all read the active session file in the configured dir. The
|
|
204
205
|
// store is additive; if this moved, every one of them would silently stop finding a session.
|
|
205
206
|
const { out } = inSandbox(`
|
|
206
207
|
await lib.saveSession({ token:'T', api_base:'https://a.example.com', builder:{github_login:'x'} });
|
|
207
|
-
console.log(JSON.stringify({ wroteActive: fs.existsSync(ic.configPath(
|
|
208
|
+
console.log(JSON.stringify({ wroteActive: fs.existsSync(ic.configPath(ic.SESSION_FILENAMES[0])) }));`);
|
|
208
209
|
assert.equal(JSON.parse(out.trim().split('\n').pop()).wroteActive, true);
|
|
209
210
|
});
|
|
210
211
|
|
package/tests/landing_page.mjs
CHANGED
|
@@ -45,6 +45,7 @@ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
|
45
45
|
const HTML = fs.readFileSync(path.join(LANDING, 'index.html'), 'utf8');
|
|
46
46
|
const WORLD = fs.readFileSync(path.join(LANDING, 'assets', 'world.css'), 'utf8');
|
|
47
47
|
|
|
48
|
+
const { SESSION_COOKIE_NAMES } = require('../src/bongos/auth.js');
|
|
48
49
|
const { stripApexOnly } = require('../src/platform-server.js');
|
|
49
50
|
const { applyBrandTokens } = require('../src/bongos/serve-internal.js');
|
|
50
51
|
|
|
@@ -484,7 +485,10 @@ test('sign out POSTs, and expires both cookies in both Domain forms', () => {
|
|
|
484
485
|
// only matches the cookie whose Domain attribute it names, so the host-only
|
|
485
486
|
// and apex-scoped forms must BOTH be expired (#726).
|
|
486
487
|
assert.match(CODE, /fetch\(API \+ '\/auth\/logout', \{ method: 'POST', credentials: 'same-origin' \}\)/);
|
|
487
|
-
|
|
488
|
+
// Every accepted cookie name must be expired, not just the newest: a browser arriving with an
|
|
489
|
+
// older name would otherwise keep a live cookie through "sign out". Derived from the canonical
|
|
490
|
+
// list so this follows the rename chain (task 1003704, and again at 1003706).
|
|
491
|
+
assert.match(CODE, new RegExp('\\[' + SESSION_COOKIE_NAMES.map((n) => `'${n}'`).join(', ') + '\\]'));
|
|
488
492
|
assert.match(CODE, /name \+ '=; Max-Age=0; path=\/';/);
|
|
489
493
|
assert.match(CODE, /name \+ '=; Max-Age=0; path=\/; Domain=\.' \+ apex/);
|
|
490
494
|
// it runs whether the POST lands or not
|
|
@@ -35,6 +35,12 @@ const require = createRequire(import.meta.url);
|
|
|
35
35
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
36
|
const GDS = path.join(ROOT, 'scripts', 'gds');
|
|
37
37
|
const fitness = require(path.join(GDS, 'fitness.js'));
|
|
38
|
+
// The CURRENT session filename, so the expectations below follow the rename chain
|
|
39
|
+
// (task 1003704 renamed the file; 1003706 retires the old names) instead of being
|
|
40
|
+
// re-edited at each step. SESSION_FILENAMES[0] is what the CLI WRITES; the tail is
|
|
41
|
+
// what it still accepts on read.
|
|
42
|
+
const ic = require(path.join(ROOT, 'src', 'instance-config.js'));
|
|
43
|
+
const SESSION_FILE = ic.SESSION_FILENAMES[0];
|
|
38
44
|
|
|
39
45
|
// Every bash script that resolves a GDS token or API base.
|
|
40
46
|
const SMOKES = [
|
|
@@ -184,9 +190,13 @@ const boxCode = (f) => fitness.stripCommentsForIdentity(fs.readFileSync(path.joi
|
|
|
184
190
|
test('every baked box script resolves the session across config dirs, not just the legacy one', () => {
|
|
185
191
|
for (const f of BOX_SCRIPTS) {
|
|
186
192
|
const src = boxCode(f);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
193
|
+
for (const name of ic.SESSION_FILENAMES.slice(0, 2)) {
|
|
194
|
+
assert.ok(src.includes(`.config/*/${name}`),
|
|
195
|
+
`${f} must scan ~/.config/*/${name} — without the glob a /builder-reauth into a `
|
|
196
|
+
+ 'non-"otb" configDir strands the box on a dead token, and without BOTH names '
|
|
197
|
+
+ 'half the fleet is unreadable: a box provisioned before the rename has only the '
|
|
198
|
+
+ 'old name, one provisioned after has only the new (task 1003704)');
|
|
199
|
+
}
|
|
190
200
|
// The legacy literal may survive ONLY as the last-resort default, never as the
|
|
191
201
|
// sole source — i.e. it must be preceded by the glob scan.
|
|
192
202
|
// The scan widens what these scripts will read, so each candidate must be owned
|
|
@@ -194,8 +204,8 @@ test('every baked box script resolves the session across config dirs, not just t
|
|
|
194
204
|
// simply by being newest.
|
|
195
205
|
assert.match(src, /\[ -O "\$_sess" \]/,
|
|
196
206
|
`${f} must require candidate session files to be owned by the current user`);
|
|
197
|
-
const globAt = src.indexOf('.config/*/
|
|
198
|
-
const legacyAt = src.indexOf(
|
|
207
|
+
const globAt = src.indexOf('.config/*/');
|
|
208
|
+
const legacyAt = src.indexOf(`.config/otb/${SESSION_FILE}`);
|
|
199
209
|
if (legacyAt !== -1) {
|
|
200
210
|
assert.ok(globAt < legacyAt,
|
|
201
211
|
`${f} reaches the legacy path before scanning ~/.config/*/ — the fallback must come first`);
|
|
@@ -217,10 +227,17 @@ test('every baked box script passes bash -n', (t) => {
|
|
|
217
227
|
function resolverBlock(f) {
|
|
218
228
|
const src = fs.readFileSync(path.join(INFRA, f), 'utf8');
|
|
219
229
|
const start = src.indexOf('if [ -z "${GDS_SESSION:-}" ]; then');
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
230
|
+
// Matched by PREFIX, up to the closing quote — never by the full line including the
|
|
231
|
+
// filename. The filename is the one thing a rename changes, so spelling it here made
|
|
232
|
+
// this test fail with "could not locate the resolver block" on a perfectly good
|
|
233
|
+
// rename, and on Windows (where it already failed for its own reason) that looked
|
|
234
|
+
// like no new failure at all (task 1003704).
|
|
235
|
+
const endPrefix = 'GDS_SESSION="${GDS_SESSION:-$HOME/.config/otb/';
|
|
236
|
+
const at = src.indexOf(endPrefix, start);
|
|
237
|
+
const close = at === -1 ? -1 : src.indexOf('"', at + endPrefix.length);
|
|
238
|
+
assert.ok(start !== -1 && at !== -1 && close !== -1,
|
|
239
|
+
`could not locate the resolver block in ${f}`);
|
|
240
|
+
return src.slice(start, close + 1);
|
|
224
241
|
}
|
|
225
242
|
|
|
226
243
|
test('the resolver prefers the most recently written session across config dirs', (t) => {
|
|
@@ -253,7 +270,7 @@ test('the resolver prefers the most recently written session across config dirs'
|
|
|
253
270
|
const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'boxsess-empty-'));
|
|
254
271
|
const r3 = spawnSync('bash', ['-c', `set -uo pipefail\n${block}\nprintf '%s' "$GDS_SESSION"`],
|
|
255
272
|
{ encoding: 'utf8', env: { PATH: process.env.PATH, HOME: empty } });
|
|
256
|
-
assert.equal(r3.stdout, path.join(empty, '.config', 'otb',
|
|
273
|
+
assert.equal(r3.stdout, path.join(empty, '.config', 'otb', SESSION_FILE));
|
|
257
274
|
|
|
258
275
|
fs.rmSync(home, { recursive: true, force: true });
|
|
259
276
|
fs.rmSync(empty, { recursive: true, force: true });
|
|
@@ -264,16 +281,25 @@ test('the resolver prefers the most recently written session across config dirs'
|
|
|
264
281
|
// override but assigned to SF, so a caller that SET the override had it silently
|
|
265
282
|
// dropped and fell through to the legacy default — the exact bug being fixed,
|
|
266
283
|
// reintroduced through the back door. Pin all three branches.
|
|
284
|
+
// box-heartbeat.sh's resolver, pulled straight out of the committed file. Shared by the
|
|
285
|
+
// two tests below so there is a single extraction to fix when the names next move.
|
|
286
|
+
function heartbeatSfBlock() {
|
|
287
|
+
const src = fs.readFileSync(path.join(INFRA, 'box-heartbeat.sh'), 'utf8');
|
|
288
|
+
const start = src.indexOf('SF="${OTB_GDS_SESSION:-}"');
|
|
289
|
+
// Prefix, not the whole line — see resolverBlock above.
|
|
290
|
+
const endPrefix = 'SF="${SF:-$HOME/.config/otb/';
|
|
291
|
+
const at = src.indexOf(endPrefix, start);
|
|
292
|
+
const close = at === -1 ? -1 : src.indexOf('"', at + endPrefix.length);
|
|
293
|
+
assert.ok(start !== -1 && at !== -1 && close !== -1,
|
|
294
|
+
'could not locate the SF resolver in box-heartbeat.sh');
|
|
295
|
+
return src.slice(start, close + 1);
|
|
296
|
+
}
|
|
297
|
+
|
|
267
298
|
test('box-heartbeat.sh honors OTB_GDS_SESSION, scans, then falls back', (t) => {
|
|
268
299
|
const probe = bash(['-n', 'infra/box-heartbeat.sh']);
|
|
269
300
|
if (!probe) return t.skip('bash not available on this machine');
|
|
270
301
|
|
|
271
|
-
const
|
|
272
|
-
const start = src.indexOf('SF="${OTB_GDS_SESSION:-}"');
|
|
273
|
-
const endMark = 'SF="${SF:-$HOME/.config/otb/gds-session.json}"';
|
|
274
|
-
const end = src.indexOf(endMark, start);
|
|
275
|
-
assert.ok(start !== -1 && end !== -1, 'could not locate the SF resolver in box-heartbeat.sh');
|
|
276
|
-
const block = src.slice(start, end + endMark.length);
|
|
302
|
+
const block = heartbeatSfBlock();
|
|
277
303
|
|
|
278
304
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'hbsess-'));
|
|
279
305
|
const configured = path.join(home, '.config', 'cloudbongos');
|
|
@@ -289,8 +315,49 @@ test('box-heartbeat.sh honors OTB_GDS_SESSION, scans, then falls back', (t) => {
|
|
|
289
315
|
assert.equal(run({}), path.join(configured, 'gds-session.json'));
|
|
290
316
|
|
|
291
317
|
const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'hbsess-empty-'));
|
|
292
|
-
assert.equal(run({}, empty), path.join(empty, '.config', 'otb',
|
|
318
|
+
assert.equal(run({}, empty), path.join(empty, '.config', 'otb', SESSION_FILE));
|
|
293
319
|
|
|
294
320
|
fs.rmSync(home, { recursive: true, force: true });
|
|
295
321
|
fs.rmSync(empty, { recursive: true, force: true });
|
|
296
322
|
});
|
|
323
|
+
|
|
324
|
+
// The rename leaves the fleet in FOUR states at once, and the two tests above only cover
|
|
325
|
+
// the pre-rename half (their fixtures write the old name). These are the other two, and
|
|
326
|
+
// they are the ones a rename actually breaks (task 1003704). Same extracted resolver — one
|
|
327
|
+
// harness for this block, deliberately, so part 4 (task 1003706) has one place to change.
|
|
328
|
+
test('the box resolver handles a post-rename box and a migrated one', (t) => {
|
|
329
|
+
const probe = bash(['-n', 'infra/box-heartbeat.sh']);
|
|
330
|
+
if (!probe) return t.skip('bash not available on this machine');
|
|
331
|
+
|
|
332
|
+
const block = heartbeatSfBlock();
|
|
333
|
+
const run = (h) => spawnSync('bash', ['-c', `set -euo pipefail\n${block}\nprintf '%s' "$SF"`],
|
|
334
|
+
{ encoding: 'utf8', env: { PATH: process.env.PATH, HOME: h } }).stdout;
|
|
335
|
+
|
|
336
|
+
// A box provisioned AFTER the rename has ONLY the new name. Before the glob was widened
|
|
337
|
+
// this fell through to the historical default, which does not exist, so every heartbeat
|
|
338
|
+
// tick failed with a misleading "no session".
|
|
339
|
+
const fresh = fs.mkdtempSync(path.join(os.tmpdir(), 'hbsess-new-'));
|
|
340
|
+
const freshCfg = path.join(fresh, '.config', 'cloudbongos');
|
|
341
|
+
fs.mkdirSync(freshCfg, { recursive: true });
|
|
342
|
+
fs.writeFileSync(path.join(freshCfg, SESSION_FILE), '{"token":"fresh"}');
|
|
343
|
+
const tail = (p) => p.replace(/\\/g, '/').split('/').slice(-2).join('/');
|
|
344
|
+
assert.equal(tail(run(fresh)), `cloudbongos/${SESSION_FILE}`,
|
|
345
|
+
'a box provisioned after the rename must resolve the new name');
|
|
346
|
+
|
|
347
|
+
// A MIGRATED box carries both, the new one written later by the CLI's migrate-on-read.
|
|
348
|
+
// Newest-wins must take the new one; taking the older would read a stale token and report
|
|
349
|
+
// the misleading "session expired" of task 1003252.
|
|
350
|
+
const both = fs.mkdtempSync(path.join(os.tmpdir(), 'hbsess-both-'));
|
|
351
|
+
const bothCfg = path.join(both, '.config', 'cloudbongos');
|
|
352
|
+
fs.mkdirSync(bothCfg, { recursive: true });
|
|
353
|
+
const legacyName = ic.SESSION_FILENAMES[1];
|
|
354
|
+
fs.writeFileSync(path.join(bothCfg, legacyName), '{"token":"stale"}');
|
|
355
|
+
fs.writeFileSync(path.join(bothCfg, SESSION_FILE), '{"token":"fresh"}');
|
|
356
|
+
const older = new Date(Date.now() - 60_000);
|
|
357
|
+
fs.utimesSync(path.join(bothCfg, legacyName), older, older);
|
|
358
|
+
assert.equal(tail(run(both)), `cloudbongos/${SESSION_FILE}`,
|
|
359
|
+
'a migrated box must prefer the newly written file, not the superseded one');
|
|
360
|
+
|
|
361
|
+
fs.rmSync(fresh, { recursive: true, force: true });
|
|
362
|
+
fs.rmSync(both, { recursive: true, force: true });
|
|
363
|
+
});
|