@bongos/core 1.24.91 → 1.24.93
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 +54 -39
- package/.claude/skills/builder-reauth/SKILL.md +23 -25
- package/clients/bongos-client/index.d.ts +2 -1
- package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +8 -0
- package/docs/api/openapi.json +22 -2
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +3 -3
- package/docs/copy-registry.json +3 -3
- package/docs/module-api-changelog.md +4 -2
- package/docs/page-inventory.json +4 -2
- package/docs/page-readings.json +6 -6
- package/modules/hall-ui/public/city.css +6 -2
- package/modules/hall-ui/public/city.js +44 -2
- package/modules/hall-ui/public/goal-map.html +2 -0
- package/modules/hall-ui/public/sky-interaction.js +4 -9
- package/modules/hall-ui/public/sky-visor.js +96 -0
- package/modules/hall-ui/public/thinking.css +8 -0
- package/modules/hall-ui/public/thinking.html +2 -0
- package/modules/hall-ui/public/visor.js +20 -4
- package/modules/hall-ui/records/visor.md +16 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +15 -1
- package/scripts/bongos/cli-lib.js +7 -6
- package/scripts/bongos/login.js +14 -3
- package/scripts/bongos/plain-cards.js +1 -1
- package/scripts/bongos/reauth.js +100 -50
- package/src/bongos/routes/auth-google.js +5 -3
- package/src/bongos/routes/auth.js +3 -3
- package/src/bongos/sign-in-pages.js +1 -2
- package/src/module-api.js +1 -1
- package/tests/cli_reauth_browser.mjs +157 -0
- package/tests/hall_city.mjs +41 -1
- package/tests/hall_city_living.mjs +2 -0
- package/tests/hall_sky_visor.mjs +150 -0
- package/tests/hall_visor.mjs +28 -2
package/scripts/bongos/reauth.js
CHANGED
|
@@ -1,45 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// scripts/bongos/reauth.js — #504.
|
|
2
|
+
// scripts/bongos/reauth.js — #504; one-click since task 1005118.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// deploy. (It no longer stops working from mere disuse: ADR 0294 removed the idle
|
|
9
|
-
// expiry, because the timer only ever signed out the legitimate holder while
|
|
10
|
-
// refresh-on-use kept a thief's copy alive.) Whatever killed it, the friction of
|
|
11
|
-
// getting back in — the slow Device Flow, terminal codes, Gemini-key prompts — is
|
|
12
|
-
// not something to keep. This script is the fix:
|
|
4
|
+
// Signs the command line back in when its Bongos session has stopped working —
|
|
5
|
+
// revoked from the hall's "Sign out everywhere", swept by a demotion, or
|
|
6
|
+
// invalidated by an auth-changing deploy. (Never from mere disuse: ADR 0294
|
|
7
|
+
// removed the idle expiry.)
|
|
13
8
|
//
|
|
14
9
|
// 1. Detect — is the current session still valid? (GET /api/bongos/me)
|
|
15
|
-
// valid
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// /builder-reauth skill runs it (paste-token.js verifies + writes safely).
|
|
10
|
+
// valid → print who you are + exit 0. Nothing to do.
|
|
11
|
+
// 2. Approve in the browser (task 1005118) — start a sign-in that asks the
|
|
12
|
+
// instance for its OWN approval page (device-approval.js), open it with the
|
|
13
|
+
// code filled in, and wait. The builder's browser is already signed in, so
|
|
14
|
+
// the page is one Approve click; the terminal's poll then receives the new
|
|
15
|
+
// session and this script saves it. Nothing to copy, nothing to paste, and
|
|
16
|
+
// the token never passes through the chat.
|
|
23
17
|
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
18
|
+
// It used to be four steps with a paste: Settings → "Re-issue CLI token" → Copy →
|
|
19
|
+
// paste a `bongos paste-token <token>` line into Claude, which put the token in
|
|
20
|
+
// the chat history. That path stays as the FALLBACK for a machine with no browser
|
|
21
|
+
// to approve from (`--paste` prints it; so does any failure here).
|
|
27
22
|
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
23
|
+
// The session is written only after the server handed back a token for a
|
|
24
|
+
// signed-in person, and through saveSession (the per-instance store, ADR 0269),
|
|
25
|
+
// so a failed or cancelled approval never touches the existing session file.
|
|
31
26
|
//
|
|
32
27
|
// Usage:
|
|
33
|
-
// node scripts/bongos/reauth.js # detect; if invalid,
|
|
34
|
-
// node scripts/bongos/reauth.js --
|
|
28
|
+
// node scripts/bongos/reauth.js # detect; if invalid, approve in the browser
|
|
29
|
+
// node scripts/bongos/reauth.js --paste # detect; if invalid, print the copy-paste path
|
|
30
|
+
// node scripts/bongos/reauth.js --no-open # as --paste, without opening the browser
|
|
35
31
|
//
|
|
36
32
|
// Exit codes:
|
|
37
|
-
// 0 — session
|
|
38
|
-
// 10 —
|
|
39
|
-
// 2 — could not reach the
|
|
33
|
+
// 0 — signed in: the session was already valid, or the approval just landed
|
|
34
|
+
// 10 — still not signed in (cancelled, ran out, or not let in); fallback printed
|
|
35
|
+
// 2 — could not reach the instance (network); the session may be fine
|
|
40
36
|
|
|
41
37
|
const { spawn } = require('node:child_process');
|
|
42
38
|
const lib = require('./cli-lib');
|
|
39
|
+
const { readSessionConfigSync } = require('../../src/instance-config');
|
|
43
40
|
|
|
44
41
|
// lib.API_BASE is session-aware (task 1002427), so both URLs name the instance the
|
|
45
42
|
// builder is actually signed into — not this checkout's branding default, which is
|
|
@@ -85,7 +82,7 @@ function openInBrowser(url) {
|
|
|
85
82
|
|
|
86
83
|
function printGuidance({ opened }) {
|
|
87
84
|
console.log('');
|
|
88
|
-
console.log('
|
|
85
|
+
console.log('Fallback — sign back in with a copied line:');
|
|
89
86
|
console.log('');
|
|
90
87
|
// We can't reliably know if the browser actually launched — spawn() reports
|
|
91
88
|
// a missing-binary error asynchronously, after this prints. So we never claim
|
|
@@ -102,19 +99,71 @@ function printGuidance({ opened }) {
|
|
|
102
99
|
console.log('');
|
|
103
100
|
console.log(' If the page says you are not signed in, sign in at the builders\'');
|
|
104
101
|
console.log(` hall first (${HALL_URL}), then return to Settings and click Re-issue.`);
|
|
105
|
-
console.log(' Fallback (terminal Device Flow): node scripts/bongos/setup.js --force');
|
|
106
102
|
console.log('');
|
|
107
103
|
}
|
|
108
104
|
|
|
105
|
+
// The session to write after an approval: the server's token + builder for THIS
|
|
106
|
+
// instance, with every other field (gemini_api_key et al.) carried over from the
|
|
107
|
+
// existing session when it belongs to the same instance. Pure, so it is tested.
|
|
108
|
+
function nextSession(existing, { token, builder, apiBase }) {
|
|
109
|
+
const sameInstance = existing && existing.api_base
|
|
110
|
+
&& lib.sessionHostKey(existing.api_base) === lib.sessionHostKey(apiBase);
|
|
111
|
+
const carried = sameInstance ? existing
|
|
112
|
+
: (existing && existing.gemini_api_key ? { gemini_api_key: existing.gemini_api_key } : {});
|
|
113
|
+
return { ...carried, token, builder, api_base: apiBase };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Approve in the browser, then save. → true when signed back in.
|
|
117
|
+
async function approveInBrowser() {
|
|
118
|
+
const { runDeviceFlow } = require('./login');
|
|
119
|
+
console.log('');
|
|
120
|
+
console.log('Opening a page in your browser to sign the command line back in.');
|
|
121
|
+
console.log('Check the code matches, then click "Approve" — this carries on by itself.');
|
|
122
|
+
let out;
|
|
123
|
+
try {
|
|
124
|
+
out = await runDeviceFlow(lib.API_BASE, { log: console.log, browser: true });
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.log('');
|
|
127
|
+
console.log(`The browser sign-in didn't finish: ${err && err.message ? err.message : err}`);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
console.log('');
|
|
131
|
+
if (!out || out.state !== 'ok' || !out.token) {
|
|
132
|
+
console.log(out && out.state === 'not_approved'
|
|
133
|
+
? 'That account is not let in to this project yet — ask its owner.'
|
|
134
|
+
: 'The sign-in ran out before it was approved. Run this again to get a fresh page.');
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
let existing = null;
|
|
138
|
+
try {
|
|
139
|
+
const raw = readSessionConfigSync();
|
|
140
|
+
existing = raw ? JSON.parse(raw) : null;
|
|
141
|
+
} catch (_) { existing = null; }
|
|
142
|
+
await lib.saveSession(nextSession(existing, { token: out.token, builder: out.builder, apiBase: lib.API_BASE }));
|
|
143
|
+
const b = out.builder || {};
|
|
144
|
+
console.log(`✓ Signed back in as ${b.github_login || '?'}${b.rank ? ` (rank: ${b.rank})` : ''}.`);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function reSignIn({ paste, noOpen }) {
|
|
149
|
+
if (paste) {
|
|
150
|
+
printGuidance({ opened: noOpen ? false : openInBrowser(SETTINGS_URL) });
|
|
151
|
+
process.exit(10);
|
|
152
|
+
}
|
|
153
|
+
if (await approveInBrowser()) process.exit(0);
|
|
154
|
+
printGuidance({ opened: false });
|
|
155
|
+
process.exit(10);
|
|
156
|
+
}
|
|
157
|
+
|
|
109
158
|
async function main() {
|
|
159
|
+
// --no-open (the old flag) means no browser at all, so it is the paste path too.
|
|
110
160
|
const noOpen = process.argv.includes('--no-open');
|
|
161
|
+
const paste = noOpen || process.argv.includes('--paste');
|
|
111
162
|
|
|
112
163
|
const session = await lib.loadSession();
|
|
113
164
|
if (!session || !session.token) {
|
|
114
|
-
console.log('No
|
|
115
|
-
|
|
116
|
-
printGuidance({ opened });
|
|
117
|
-
process.exit(10);
|
|
165
|
+
console.log('No Bongos sign-in found on this machine.');
|
|
166
|
+
return reSignIn({ paste, noOpen });
|
|
118
167
|
}
|
|
119
168
|
|
|
120
169
|
// Detect: is the existing token still valid? /me is the canonical check
|
|
@@ -126,30 +175,31 @@ async function main() {
|
|
|
126
175
|
res = await api.me.getMe();
|
|
127
176
|
} catch (err) {
|
|
128
177
|
console.error(`Could not reach ${lib.API_BASE} to check your session: ${err.message}`);
|
|
129
|
-
|
|
130
|
-
printGuidance({ opened });
|
|
178
|
+
console.error('Your sign-in may be fine — try again once you are back online.');
|
|
131
179
|
process.exit(2);
|
|
132
180
|
}
|
|
133
181
|
|
|
134
182
|
if (res.ok) {
|
|
135
183
|
const b = (res.data && res.data.builder) || {};
|
|
136
|
-
console.log(
|
|
184
|
+
console.log('Your Bongos sign-in is working — nothing to do.');
|
|
137
185
|
console.log(` Signed in as ${b.github_login || '?'}${b.rank ? ` (rank: ${b.rank})` : ''}.`);
|
|
138
|
-
console.log(' (
|
|
186
|
+
console.log(' (Sign-ins do not expire — they last until revoked. Kill one from');
|
|
139
187
|
console.log(' Settings → Active sessions, or "Sign out everywhere".)');
|
|
140
188
|
process.exit(0);
|
|
141
189
|
}
|
|
142
190
|
|
|
143
|
-
// 401 / 403 / anything not-ok →
|
|
144
|
-
console.log('Your
|
|
145
|
-
|
|
146
|
-
printGuidance({ opened });
|
|
147
|
-
process.exit(10);
|
|
191
|
+
// 401 / 403 / anything not-ok → sign back in.
|
|
192
|
+
console.log('Your Bongos sign-in has stopped working (revoked or replaced).');
|
|
193
|
+
return reSignIn({ paste, noOpen });
|
|
148
194
|
}
|
|
149
195
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
196
|
+
if (require.main === module) {
|
|
197
|
+
main().catch((err) => {
|
|
198
|
+
console.error('reauth failed:', err && err.message ? err.message : err);
|
|
199
|
+
// Still print the manual path so the builder isn't stranded.
|
|
200
|
+
printGuidance({ opened: false });
|
|
201
|
+
process.exit(2);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = { nextSession };
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
// GET /auth/device → the page a terminal sends the person to
|
|
13
13
|
// POST /auth/device/approve → the signed-in person approves or cancels it
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
// the hub that keeps who signs in with it (auth-google.js).
|
|
15
|
+
// The two /auth/google routes answer only where googleConfigured() — the owner's
|
|
16
|
+
// Google app AND the hub that keeps who signs in with it (auth-google.js). The
|
|
17
|
+
// two /auth/device routes answer everywhere: a CLI re-sign-in asks for them on a
|
|
18
|
+
// GitHub-only site too (task 1005118), where the page offers GitHub alone.
|
|
17
19
|
'use strict';
|
|
18
20
|
|
|
19
21
|
const express = require('express');
|
|
@@ -244,7 +246,7 @@ module.exports = function buildAuthGoogleRouter() {
|
|
|
244
246
|
state: 'signin',
|
|
245
247
|
userCode: pending.userCode,
|
|
246
248
|
githubHref: `${base}/auth/web/start?via=github&return=${encodeURIComponent(here)}`,
|
|
247
|
-
googleHref: `${base}/auth/google/start?return=${encodeURIComponent(here)}
|
|
249
|
+
googleHref: googleConfigured() ? `${base}/auth/google/start?return=${encodeURIComponent(here)}` : null,
|
|
248
250
|
}));
|
|
249
251
|
}
|
|
250
252
|
return res.type('text/html').send(renderDeviceApprovalPage({
|
|
@@ -603,9 +603,9 @@ module.exports = function buildAuthRouter() {
|
|
|
603
603
|
if (!auth.authConfigured()) {
|
|
604
604
|
return res.fail('auth_not_configured', { status: 503, message: 'GitHub OAuth App credentials missing on the server. See blockers/blockers.md#github-oauth-app.' });
|
|
605
605
|
}
|
|
606
|
-
|
|
607
|
-
//
|
|
608
|
-
if (googleConfigured()) {
|
|
606
|
+
if (validateOrRespond(req, res, { approve_in_browser: { type: 'boolean' } }, { strict: true })) return;
|
|
607
|
+
// Google offered, or a re-sign-in asked (task 1005118): this site's own approval page (device-approval.js).
|
|
608
|
+
if (googleConfigured() || req.body?.approve_in_browser === true) {
|
|
609
609
|
return res.json(startBrowserDeviceFlow({
|
|
610
610
|
pageUrl: `${WEB_REDIRECT_ORIGIN.replace(/\/+$/, '')}${apiBase(req)}/auth/device`,
|
|
611
611
|
}));
|
|
@@ -76,8 +76,7 @@ ${bodyHtml}
|
|
|
76
76
|
function choiceButtons({ githubHref, googleHref }) {
|
|
77
77
|
return ` <div class="signinChoices">
|
|
78
78
|
<a class="landing__btn" href="${escapeHtml(githubHref)}">${GITHUB_MARK}Continue with GitHub</a>
|
|
79
|
-
<a class="landing__btn" href="${escapeHtml(googleHref)}">${GOOGLE_MARK}Continue with Google</a
|
|
80
|
-
</div>`;
|
|
79
|
+
${googleHref ? ` <a class="landing__btn" href="${escapeHtml(googleHref)}">${GOOGLE_MARK}Continue with Google</a>\n` : ''} </div>`;
|
|
81
80
|
}
|
|
82
81
|
|
|
83
82
|
// The sign-in chooser /auth/web/start shows when Google sign-in is switched on.
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.24.
|
|
84
|
+
const CORE_VERSION = '1.24.93'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// tests/cli_reauth_browser.mjs — signing the command line back in is one Approve
|
|
2
|
+
// click in the browser, on every instance (task 1005118).
|
|
3
|
+
//
|
|
4
|
+
// What this pins:
|
|
5
|
+
// - a re-sign-in's device start (approve_in_browser) gets this site's own approval
|
|
6
|
+
// page even where Google sign-in is OFF; a plain start keeps GitHub's flow, so
|
|
7
|
+
// `bongos login`'s not_approved → access-request path is untouched;
|
|
8
|
+
// - on a GitHub-only site the approval page offers GitHub alone (no dead Google door);
|
|
9
|
+
// - the CLI asks for the browser page only when told to, and prints the whole link;
|
|
10
|
+
// - the saved session keeps the builder's other fields for the same instance and
|
|
11
|
+
// never carries another instance's fields across.
|
|
12
|
+
//
|
|
13
|
+
// DB-free and network-free. Run: node tests/cli_reauth_browser.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
for (const p of ['BONGOS', 'CLOUDBONGOS', 'GDS', 'OTB']) {
|
|
21
|
+
delete process.env[`${p}_GOOGLE_CLIENT_ID`];
|
|
22
|
+
delete process.env[`${p}_GOOGLE_CLIENT_SECRET`];
|
|
23
|
+
}
|
|
24
|
+
process.env.BONGOS_GITHUB_CLIENT_ID = process.env.BONGOS_GITHUB_CLIENT_ID || 'gh-test-client';
|
|
25
|
+
process.env.BONGOS_GITHUB_CLIENT_SECRET = process.env.BONGOS_GITHUB_CLIENT_SECRET || 'gh-test-secret';
|
|
26
|
+
|
|
27
|
+
const auth = require('../src/bongos/auth.js');
|
|
28
|
+
const { googleConfigured } = require('../src/bongos/auth-google.js');
|
|
29
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
30
|
+
const buildAuthRouter = require('../src/bongos/routes/auth.js');
|
|
31
|
+
const buildAuthGoogleRouter = require('../src/bongos/routes/auth-google.js');
|
|
32
|
+
const { pollBrowserDeviceFlow } = require('../src/bongos/device-approval.js');
|
|
33
|
+
const { runDeviceFlow } = require('../scripts/bongos/login.js');
|
|
34
|
+
const { nextSession } = require('../scripts/bongos/reauth.js');
|
|
35
|
+
|
|
36
|
+
let pass = 0, fail = 0;
|
|
37
|
+
const t = async (name, fn) => {
|
|
38
|
+
try { await fn(); pass++; console.log(`PASS ${name}`); }
|
|
39
|
+
catch (e) { fail++; console.log(`FAIL ${name}\n ${e.stack || e.message}`); }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function fakeRes() {
|
|
43
|
+
const res = {
|
|
44
|
+
_status: 200, _body: null, _headers: {},
|
|
45
|
+
status(c) { this._status = c; return this; },
|
|
46
|
+
type() { return this; },
|
|
47
|
+
send(b) { this._body = b; return this; },
|
|
48
|
+
json(b) { this._body = b; return this; },
|
|
49
|
+
set(k, v) { this._headers[k.toLowerCase()] = v; return this; },
|
|
50
|
+
};
|
|
51
|
+
attachFail({}, res, () => {});
|
|
52
|
+
return res;
|
|
53
|
+
}
|
|
54
|
+
function handler(router, method, path) {
|
|
55
|
+
const layer = router.stack.find((l) => l.route?.path === path && l.route.methods[method]);
|
|
56
|
+
assert.ok(layer, `route ${method.toUpperCase()} ${path} exists`);
|
|
57
|
+
return layer.route.stack.at(-1).handle;
|
|
58
|
+
}
|
|
59
|
+
const req = (over = {}) => ({ query: {}, headers: { host: 'localhost' }, baseUrl: '/api/bongos', body: {}, ...over });
|
|
60
|
+
|
|
61
|
+
const orig = { configured: auth.authConfigured, start: auth.startDeviceFlow, resolve: auth.resolveSession };
|
|
62
|
+
auth.authConfigured = () => true;
|
|
63
|
+
let githubStarts = 0;
|
|
64
|
+
auth.startDeviceFlow = async () => {
|
|
65
|
+
githubStarts++;
|
|
66
|
+
return { device_code: 'gh_dc', user_code: 'WXYZ-1234', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 };
|
|
67
|
+
};
|
|
68
|
+
auth.resolveSession = async () => null;
|
|
69
|
+
|
|
70
|
+
await t('this test runs as a GitHub-only site', () => {
|
|
71
|
+
assert.equal(googleConfigured(), false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await t('a re-sign-in asks for the browser page and gets this site’s own approval page, Google or not', async () => {
|
|
75
|
+
const res = fakeRes();
|
|
76
|
+
await handler(buildAuthRouter(), 'post', '/auth/device/start')(req({ body: { approve_in_browser: true } }), res);
|
|
77
|
+
assert.match(res._body.verification_uri, /\/api\/bongos\/auth\/device\?code=[B-Z]{4}-[B-Z]{4}$/);
|
|
78
|
+
assert.equal(githubStarts, 0, 'GitHub’s code entry is not started');
|
|
79
|
+
assert.equal(pollBrowserDeviceFlow(res._body.device_code).state, 'pending', 'the terminal waits on this site');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await t('a plain start on a GitHub-only site keeps GitHub’s flow (first sign-in, access requests)', async () => {
|
|
83
|
+
const res = fakeRes();
|
|
84
|
+
await handler(buildAuthRouter(), 'post', '/auth/device/start')(req(), res);
|
|
85
|
+
assert.equal(res._body.verification_uri, 'https://github.com/login/device');
|
|
86
|
+
assert.equal(githubStarts, 1);
|
|
87
|
+
const truthy = fakeRes();
|
|
88
|
+
await handler(buildAuthRouter(), 'post', '/auth/device/start')(req({ body: { approve_in_browser: 'yes' } }), truthy);
|
|
89
|
+
assert.equal(truthy._status, 400, 'only a boolean asks; anything else is refused, never guessed');
|
|
90
|
+
const stray = fakeRes();
|
|
91
|
+
await handler(buildAuthRouter(), 'post', '/auth/device/start')(req({ body: { other: 1 } }), stray);
|
|
92
|
+
assert.equal(stray._status, 400, 'unknown fields are refused (ADR 0118)');
|
|
93
|
+
assert.equal(githubStarts, 1, 'a refused ask starts nothing');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await t('on a GitHub-only site the approval page offers GitHub alone', async () => {
|
|
97
|
+
const start = fakeRes();
|
|
98
|
+
await handler(buildAuthRouter(), 'post', '/auth/device/start')(req({ body: { approve_in_browser: true } }), start);
|
|
99
|
+
const res = fakeRes();
|
|
100
|
+
await handler(buildAuthGoogleRouter(), 'get', '/auth/device')(req({ query: { code: start._body.user_code } }), res);
|
|
101
|
+
assert.match(res._body, /Continue with GitHub/);
|
|
102
|
+
assert.doesNotMatch(res._body, /Continue with Google/, 'no door to a sign-in this site does not offer');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
auth.authConfigured = orig.configured;
|
|
106
|
+
auth.startDeviceFlow = orig.start;
|
|
107
|
+
auth.resolveSession = orig.resolve;
|
|
108
|
+
|
|
109
|
+
await t('the CLI asks for the browser page only when told to, and prints the whole link', async () => {
|
|
110
|
+
const realFetch = globalThis.fetch;
|
|
111
|
+
const bodies = [];
|
|
112
|
+
const link = 'https://cloudbongos.com/api/bongos/auth/device?code=BCDF-GHJK';
|
|
113
|
+
globalThis.fetch = async (url, opts) => {
|
|
114
|
+
if (/device\/start$/.test(url)) {
|
|
115
|
+
bodies.push(opts.body);
|
|
116
|
+
return { ok: true, status: 200, text: async () => JSON.stringify({ device_code: 'd', user_code: 'BCDF-GHJK', verification_uri: link, expires_in: 0, interval: 0 }) };
|
|
117
|
+
}
|
|
118
|
+
if (/device\/poll$/.test(url)) {
|
|
119
|
+
return { ok: true, status: 200, text: async () => JSON.stringify({ state: 'ok', token: 'tok', builder: { id: 3 } }) };
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`unexpected fetch ${url}`);
|
|
122
|
+
};
|
|
123
|
+
const lines = [];
|
|
124
|
+
// The first poll approves (the loop waits one interval first); an unknown platform
|
|
125
|
+
// opens no real browser.
|
|
126
|
+
const savedPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
|
127
|
+
Object.defineProperty(process, 'platform', { value: 'test-none' });
|
|
128
|
+
try {
|
|
129
|
+
const out = await runDeviceFlow('https://cloudbongos.com', { log: (l) => lines.push(l), browser: true });
|
|
130
|
+
assert.deepEqual(out, { state: 'ok', token: 'tok', builder: { id: 3 } }, 'the approval hands back the session');
|
|
131
|
+
await runDeviceFlow('https://cloudbongos.com', { log: () => {} });
|
|
132
|
+
} finally {
|
|
133
|
+
globalThis.fetch = realFetch;
|
|
134
|
+
Object.defineProperty(process, 'platform', savedPlatform);
|
|
135
|
+
}
|
|
136
|
+
assert.deepEqual(JSON.parse(bodies[0]), { approve_in_browser: true });
|
|
137
|
+
assert.equal(bodies[1], undefined, 'a plain login sends no ask');
|
|
138
|
+
assert.ok(lines.some((l) => l.includes(link)), 'the link is printed whole, never cut to the box');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await t('the saved session keeps the same instance’s fields and never carries another’s', () => {
|
|
142
|
+
const builder = { id: 3, github_login: 'test-archon', rank: 'archon' };
|
|
143
|
+
const same = nextSession(
|
|
144
|
+
{ token: 'old', api_base: 'https://cloudbongos.com', gemini_api_key: 'g', builder: { id: 3 }, extra: 1 },
|
|
145
|
+
{ token: 'new', builder, apiBase: 'https://cloudbongos.com' },
|
|
146
|
+
);
|
|
147
|
+
assert.deepEqual(same, { token: 'new', api_base: 'https://cloudbongos.com', gemini_api_key: 'g', builder, extra: 1 });
|
|
148
|
+
const other = nextSession(
|
|
149
|
+
{ token: 'old', api_base: 'https://toast.cloudbongos.com', gemini_api_key: 'g', extra: 1 },
|
|
150
|
+
{ token: 'new', builder, apiBase: 'https://cloudbongos.com' },
|
|
151
|
+
);
|
|
152
|
+
assert.deepEqual(other, { gemini_api_key: 'g', token: 'new', builder, api_base: 'https://cloudbongos.com' });
|
|
153
|
+
assert.deepEqual(nextSession(null, { token: 'n', builder, apiBase: 'https://x.test' }), { token: 'n', builder, api_base: 'https://x.test' });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
157
|
+
if (fail) process.exit(1);
|
package/tests/hall_city.mjs
CHANGED
|
@@ -403,11 +403,20 @@ function fakeDom() {
|
|
|
403
403
|
getElementById: (id) => byId.get(id) || null,
|
|
404
404
|
createElement: (t) => mk(t),
|
|
405
405
|
createElementNS: (_ns, t) => mk(t),
|
|
406
|
+
// the archived line, as city.html writes it (task 1005209 reads its links for the visor)
|
|
407
|
+
querySelector: (sel) => (sel === '.city-archived' ? archivedLine() : null),
|
|
406
408
|
};
|
|
407
409
|
return { document, byId, html };
|
|
408
410
|
}
|
|
409
411
|
|
|
410
|
-
|
|
412
|
+
// The page's own archived line: its links, from city.html, word for word.
|
|
413
|
+
function archivedLine() {
|
|
414
|
+
const line = /<p class="city-archived">([\s\S]*?)<\/p>/.exec(read('city.html'))[1];
|
|
415
|
+
const links = [...line.matchAll(/<a href="([^"]+)">([^<]+)<\/a>/g)].map(([, href, text]) => ({ textContent: text, getAttribute: (k) => (k === 'href' ? href : null) }));
|
|
416
|
+
return { querySelectorAll: () => links };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function bootPage({ feeds = {}, stored = null, osDark = true, shell = undefined, stage = null, visor = undefined } = {}) {
|
|
411
420
|
const { document, byId, html } = fakeDom();
|
|
412
421
|
const store = new Map(stored ? [['hall-theme', stored]] : []);
|
|
413
422
|
const calls = [];
|
|
@@ -425,6 +434,7 @@ async function bootPage({ feeds = {}, stored = null, osDark = true, shell = unde
|
|
|
425
434
|
const observers = [];
|
|
426
435
|
class MutationObserver { constructor(fn) { this.fn = fn; observers.push(this); } observe() {} }
|
|
427
436
|
window.OTBShell = shell;
|
|
437
|
+
window.OTBVisor = visor;
|
|
428
438
|
if (stage) { const m = document.createElement('main'); m.setAttribute('id', 'hall-main'); Object.assign(m, stage); }
|
|
429
439
|
const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, console, MutationObserver });
|
|
430
440
|
ctx.globalThis = ctx;
|
|
@@ -494,3 +504,33 @@ test('docket text lands as text: a title with markup is never parsed', async ()
|
|
|
494
504
|
const p = await bootPage({ feeds: FEEDS([matter({ title: '<img src=x onerror=alert(1)>', short: '<b>x</b>' })]) });
|
|
495
505
|
assert.equal(p.byId.get('cityPins').children.length, 1);
|
|
496
506
|
});
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
// task 1005209 (ADR 0403): in game mode the city's door and its archived links are the visor's.
|
|
510
|
+
test("game mode: the door is the visor's lead action on D, and the archived links are in its sheet", async () => {
|
|
511
|
+
const scenes = [];
|
|
512
|
+
const ms = [matter({ id: 'board:1', building: 'charter', state: 'needs' }), matter({ id: 'blockers:2', state: 'needs' })];
|
|
513
|
+
const p = await bootPage({ feeds: FEEDS(ms), visor: { scene: (sc) => scenes.push(sc) } });
|
|
514
|
+
const last = scenes[scenes.length - 1];
|
|
515
|
+
assert.equal(last.place, 'Governor city');
|
|
516
|
+
const [lead, street, views] = last.actions;
|
|
517
|
+
assert.equal(lead.label, 'start governing', 'what the door says, before the living city reads who it waits on (hall_city_living pins the count)');
|
|
518
|
+
assert.deepEqual([lead.key, lead.bind, lead.lead], ['d', true, true], 'it is on the glass, and D (which nothing else binds) presses it');
|
|
519
|
+
assert.deepEqual([street.label, views.label], ['Archived: Street', 'Archived: Building Level Views'], "the line's own words, read off the page");
|
|
520
|
+
assert.ok(!street.lead && !views.lead && !street.bind && !views.bind, 'the archived links are rows in the sheet, reached by A then the keyboard');
|
|
521
|
+
const went = [];
|
|
522
|
+
p.window.location = { assign: (u) => went.push(u) };
|
|
523
|
+
street.run();
|
|
524
|
+
views.run();
|
|
525
|
+
assert.deepEqual(went, ['/builders/street', '/builders/street#building=charter'], 'each goes where its link went');
|
|
526
|
+
const n = scenes.length;
|
|
527
|
+
p.window.GovCityPage.draw(true);
|
|
528
|
+
assert.equal(scenes.length, n, 'a redraw with the same door registers nothing new');
|
|
529
|
+
assert.deepEqual(JSON.parse(JSON.stringify(last.keys.map((k) => k.key))), ['1–9', '← →'], "the card's own keys are still listed");
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test('game mode: the scene carries no button of its own; classic mode keeps the door and the links', () => {
|
|
533
|
+
const css = read('city.css');
|
|
534
|
+
assert.ok(css.includes('\nhtml.visor-on .city-start, html.visor-on .city-archived { display: none; }'), 'off the scene only while the visor is up');
|
|
535
|
+
assert.doesNotMatch(css, /^\.city-start[^{]*\{[^}]*display: none/m, 'never hidden outside game mode');
|
|
536
|
+
});
|
|
@@ -534,6 +534,8 @@ test('the page reads the living feeds and draws the sky, the plates, the plaque
|
|
|
534
534
|
const m = city();
|
|
535
535
|
const start = p.byId.get('cityStart');
|
|
536
536
|
assert.equal(start.textContent, m.cta.label);
|
|
537
|
+
const lead = p.window.__visorScene.actions[0];
|
|
538
|
+
assert.equal(lead.label.toLowerCase(), m.cta.label, 'in game mode the lead action on the visor says what the door says (task 1005209)');
|
|
537
539
|
assert.equal(start.attrs.href, '#decide');
|
|
538
540
|
assert.equal(p.byId.get('cityLine').textContent, m.line);
|
|
539
541
|
for (const id of ['cityBlds', 'citySky', 'cityPins', 'cityPlaque']) assert.equal(findAll(p.byId.get(id), (n) => 'data-proto' in n.attrs).length, 0, id + ': nothing marked prototype');
|