@bongos/core 1.21.59 → 1.21.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.bongos-core.json +59 -34
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +19 -0
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/api/openapi.json +349 -3
  8. package/docs/api-reference.md +12 -2
  9. package/docs/copy-inventory.md +20 -8
  10. package/docs/copy-registry.json +117 -9
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-readings.json +392 -380
  13. package/modules/builder-settings/filter-views.js +136 -0
  14. package/modules/builder-settings/routes/settings.js +90 -0
  15. package/modules/hall-ui/public/goals-page.js +8 -2
  16. package/modules/hall-ui/public/hall-kit.js +128 -2
  17. package/modules/hall-ui/public/project-settings.css +5 -0
  18. package/modules/hall-ui/public/project-settings.html +12 -0
  19. package/modules/hall-ui/public/project-settings.js +20 -0
  20. package/modules/hall-ui/public/style.css +32 -0
  21. package/modules/hall-ui/public/work.js +11 -3
  22. package/modules/hall-ui/records/filter-views.md +11 -0
  23. package/modules/hall-ui/records/project-danger-zone.md +10 -0
  24. package/modules/public-landing/public/projects.html +30 -3
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +12 -0
  28. package/src/bongos/routes/core-update.js +33 -0
  29. package/src/module-api.js +1 -1
  30. package/tests/filter_views.mjs +144 -0
  31. package/tests/focus_version_filter_default.mjs +3 -2
  32. package/tests/project_manage_link.mjs +111 -0
@@ -2477,6 +2477,13 @@ summary{min-height:24px;padding:3px 0;}
2477
2477
  the ?view=manage&id=N deep link, read by loadManage() */
2478
2478
  var manageId = null;
2479
2479
  var manageInst = null;
2480
+ /* a project's own hall links here by SLUG (task 1004570): it knows its slug, not
2481
+ this page's instance id. Held until the visitor's own projects load, then
2482
+ resolved against THOSE only — a slug that is not yours opens nothing. The
2483
+ section (#mDelete / #mDisconnect) is held too, because setView rewrites the
2484
+ URL and drops the hash before the manage view has painted. */
2485
+ var handedProject = null;
2486
+ var handedSection = null;
2480
2487
 
2481
2488
  function viewFromUrl() {
2482
2489
  var v;
@@ -2486,6 +2493,8 @@ summary{min-height:24px;padding:3px 0;}
2486
2493
  var id = null;
2487
2494
  try { id = new URLSearchParams(location.search).get('id'); } catch (e) {}
2488
2495
  if (id) { manageId = String(id); return 'manage'; }
2496
+ try { handedProject = new URLSearchParams(location.search).get('project') || null; } catch (e) {}
2497
+ if (handedProject && /^#m(Delete|Disconnect)$/.test(location.hash || '')) handedSection = location.hash.slice(1);
2489
2498
  return 'mine';
2490
2499
  }
2491
2500
  return VIEWS[v] ? v : 'map';
@@ -2526,8 +2535,10 @@ summary{min-height:24px;padding:3px 0;}
2526
2535
  sign-in door on the hub's own pages carries the repo scope (task 1004549,
2527
2536
  ADR 0364): it is asked once, at sign-in, and held for the whole session, so
2528
2537
  no flow ever stops halfway to ask for it */
2529
- $('signinTop').href = '/api/bongos/auth/web/start?return=/projects%3Fview%3D' + v +
2530
- idPart.replace('&', '%26').replace('=', '%3D') + '&repos=1';
2538
+ /* a hand-off that is still waiting on sign-in keeps its slug through the door */
2539
+ var backPart = handedProject ? encodeURIComponent('?view=manage&project=' + handedProject)
2540
+ : '%3Fview%3D' + v + idPart.replace('&', '%26').replace('=', '%3D');
2541
+ $('signinTop').href = '/api/bongos/auth/web/start?return=/projects' + backPart + '&repos=1';
2531
2542
  /* the mine view is the only one that polls; it re-reads on entry so a
2532
2543
  card that changed while you were on the map is not stale — including
2533
2544
  the membership + invitations lists (task 1003774), the same re-read
@@ -3324,6 +3335,16 @@ summary{min-height:24px;padding:3px 0;}
3324
3335
  opens that project's own manage view — the per-project page the
3325
3336
  manage-wave tasks fill in. It replaced the scroll-anchor-into-the-list
3326
3337
  this function used to do. ── */
3338
+ /* the hall's hand-off (task 1004570): the slug becomes one of YOUR projects or
3339
+ nothing — this page stays on your list rather than guessing */
3340
+ function openHandedProject() {
3341
+ if (!handedProject) return;
3342
+ var slug = handedProject;
3343
+ handedProject = null;
3344
+ var inst = ownedInstances.filter(function (i) { return i && i.slug === slug; })[0];
3345
+ if (inst) goToManage(inst.id);
3346
+ else handedSection = null;
3347
+ }
3327
3348
  function goToManage(instId) {
3328
3349
  manageId = String(instId || '');
3329
3350
  manageInst = null;
@@ -8609,6 +8630,12 @@ summary{min-height:24px;padding:3px 0;}
8609
8630
  var inst = data.instance || {};
8610
8631
  inst.open_intent = data.open_intent || null;
8611
8632
  renderManage(inst);
8633
+ /* the hall's hand-off named a card — land on it once the page has its content */
8634
+ if (handedSection) {
8635
+ var target = $(handedSection);
8636
+ handedSection = null;
8637
+ if (target && target.scrollIntoView) target.scrollIntoView({ block: 'start' });
8638
+ }
8612
8639
  /* the SECOND, GitHub-backed read — fired only here (entry + tab
8613
8640
  return), never from renderManage, which the fetch-free tick calls */
8614
8641
  if (!keepRepo) loadRepoVisibility(inst);
@@ -10841,7 +10868,7 @@ summary{min-height:24px;padding:3px 0;}
10841
10868
  $('gate').hidden = true;
10842
10869
  $('mine').hidden = false;
10843
10870
  load();
10844
- return Promise.all([loadMemberships(), loadOwnedInstances(), loadInvites()]);
10871
+ return Promise.all([loadMemberships(), loadOwnedInstances(), loadInvites()]).then(openHandedProject);
10845
10872
  })
10846
10873
  .catch(function (err) {
10847
10874
  me = null;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.59",
3
+ "version": "1.21.61",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.59",
9
+ "version": "1.21.61",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.59",
3
+ "version": "1.21.61",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9126,5 +9126,17 @@
9126
9126
  "id": "1004552",
9127
9127
  "text": "The Goals and Tasks pages now open on the version the project owner has starred on the Roadmap. With no star, they show every version as before."
9128
9128
  }
9129
+ ],
9130
+ "1.21.60": [
9131
+ {
9132
+ "id": "1004553",
9133
+ "text": "Builders can now save their filter settings on the Goals and Tasks pages as named views, pick one to open the page with, and find them again on any device. Each builder's views are private to them."
9134
+ }
9135
+ ],
9136
+ "1.21.61": [
9137
+ {
9138
+ "id": "1004570",
9139
+ "text": "A project owner now sees a Danger zone in their project's settings, with links straight to the place where they can take the project offline or delete it. Nobody else sees it, and self-hosted projects don't show it."
9140
+ }
9129
9141
  ]
9130
9142
  }
@@ -50,6 +50,23 @@ function hubDeployUrl(config) {
50
50
  return `${config.origin.replace(/\/+$/, '')}/deploy?project=${encodeURIComponent(String(config.clientId))}`;
51
51
  }
52
52
 
53
+ // WHERE THIS PROJECT'S OWNER TAKES IT OFFLINE OR DISCONNECTS IT (task 1004570). Both
54
+ // controls live on the hub's Projects page for this project — the typed-name confirm and
55
+ // the intent queue are there (ADR 0290) — so the hall only links to them and gains no
56
+ // second delete path. Same two inputs as hubDeployUrl (the hub origin and this project's
57
+ // client id, which is its slug); null on a self-hosted instance or the hub itself, so the
58
+ // hall shows no section rather than a dead link. The page resolves ?project=<slug>
59
+ // against the visitor's OWN projects, so the link opens nothing for anyone else.
60
+ function hubManageLinks(config) {
61
+ if (!config || typeof config.origin !== 'string' || !config.clientId) return null;
62
+ if (!/^https?:\/\/[^\s"'<>]+$/i.test(config.origin)) return null;
63
+ const origin = config.origin.replace(/\/+$/, '');
64
+ const page = `${origin}/projects?view=manage&project=${encodeURIComponent(String(config.clientId))}`;
65
+ let host = origin;
66
+ try { host = new URL(origin).host; } catch { /* keep the origin as written */ }
67
+ return { hub_host: host, manage_url: page, offline_url: `${page}#mDelete`, disconnect_url: `${page}#mDisconnect` };
68
+ }
69
+
53
70
  // WHERE THIS PROJECT'S UPDATE BUTTON GOES (task 1004296). The act itself never happens in
54
71
  // Settings: it is the deploy door's two-step confirm and auto-rollback (ADR 0293), and that
55
72
  // door is the provisioning module's — so only a hall that runs provisioning has one.
@@ -216,9 +233,25 @@ function buildCoreUpdateRouter({
216
233
  }, { errorCode: 'software_update_failed', message: false }),
217
234
  );
218
235
 
236
+ // GET /project/manage-link — the hub's page for THIS project, where its owner takes it
237
+ // offline or disconnects it (task 1004570). On a hosted project the owner is its Archon,
238
+ // so the read is Archon-only: a builder below that is refused and the hall draws no
239
+ // section. { link: null } on a self-hosted instance (no hub to link to).
240
+ // rank: archon — the project's owner; it names where the project can be ended.
241
+ router.get(
242
+ '/project/manage-link',
243
+ auth.requireBuilder,
244
+ auth.requireRank('archon'),
245
+ asyncHandler('GET /project/manage-link', async (_req, res) => {
246
+ const { idp } = doorInputs();
247
+ res.json({ ok: true, link: hubManageLinks(idp) });
248
+ }, { errorCode: 'manage_link_failed', message: false }),
249
+ );
250
+
219
251
  return router;
220
252
  }
221
253
 
222
254
  module.exports = buildCoreUpdateRouter;
255
+ module.exports.hubManageLinks = hubManageLinks;
223
256
  module.exports.hubDeployUrl = hubDeployUrl;
224
257
  module.exports.coreUpdateDocketMatters = coreUpdateDocketMatters;
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.59'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.61'; // 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');
@@ -0,0 +1,144 @@
1
+ // tests/filter_views.mjs — saved filter views (task 1004553): the pure storage rules
2
+ // in modules/builder-settings/filter-views.js and the own-scoped /me/filter-views
3
+ // routes, run against a fake prefs store keyed by builder. No DB needed.
4
+ //
5
+ // Run: node --test tests/filter_views.mjs
6
+
7
+ import assert from 'node:assert/strict';
8
+ import { test } from 'node:test';
9
+ import { readFileSync } from 'node:fs';
10
+ import { createRequire } from 'node:module';
11
+
12
+ process.env.NODE_ENV = 'test';
13
+ const require = createRequire(import.meta.url);
14
+ const fv = require('../modules/builder-settings/filter-views.js');
15
+
16
+ // One behavior_prefs blob per builder — the column the routes really write.
17
+ const rows = new Map();
18
+ const db = require('../modules/builder-settings/db.js');
19
+ db.getBuilderBehaviorPrefs = async (id) => rows.get(id) || {};
20
+ db.setBuilderBehaviorPrefs = async (id, prefs) => { rows.set(id, prefs); return prefs; };
21
+ const router = require('../modules/builder-settings/routes/settings.js')();
22
+
23
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
24
+
25
+ function handler(method, path) {
26
+ const layer = router.stack.find((l) => l.route && l.route.path === path && l.route.methods[method]);
27
+ assert.ok(layer, `${method} ${path} is registered`);
28
+ return layer.route.stack[layer.route.stack.length - 1].handle;
29
+ }
30
+ async function call(method, path, { builder, body, query = {}, params = {} }) {
31
+ const out = { status: 200, body: null };
32
+ const res = {
33
+ status(s) { out.status = s; return this; },
34
+ json(b) { out.body = b; return this; },
35
+ fail(code, opts) { out.status = (opts && opts.status) || 400; out.body = { error: { code, message: opts && opts.message } }; return this; },
36
+ };
37
+ await handler(method, path)({ builder, body, query, params }, res);
38
+ return out;
39
+ }
40
+ const ALICE = { id: 1, rank: 'thetes' };
41
+ const BOB = { id: 2, rank: 'metic' };
42
+
43
+ test('saving a view keeps sibling prefs and only what was sent', () => {
44
+ const r = fv.saveView({ wandering: 'low' }, 'goals', { name: ' My open goals ', values: { 'f-goal-status': 'open' } });
45
+ assert.equal(r.ok, true);
46
+ assert.equal(r.prefs.wandering, 'low', 'sibling knobs survive');
47
+ assert.deepEqual(r.view, { id: 1, name: 'My open goals', values: { 'f-goal-status': 'open' }, is_default: false });
48
+ });
49
+
50
+ test('saving under an existing name updates that view instead of adding a second', () => {
51
+ let p = fv.saveView({}, 'goals', { name: 'Mine', values: { a: '1' } }).prefs;
52
+ p = fv.saveView(p, 'goals', { name: 'mine', values: { a: '2' } }).prefs;
53
+ const list = fv.listViews(p, 'goals');
54
+ assert.equal(list.length, 1);
55
+ assert.deepEqual(list[0].values, { a: '2' });
56
+ });
57
+
58
+ test('only one view per page is the default', () => {
59
+ let p = fv.saveView({}, 'goals', { name: 'A', values: {}, is_default: true }).prefs;
60
+ p = fv.saveView(p, 'goals', { name: 'B', values: {}, is_default: true }).prefs;
61
+ assert.deepEqual(fv.listViews(p, 'goals').map((v) => [v.name, v.is_default]), [['A', false], ['B', true]]);
62
+ p = fv.updateView(p, 'goals', 1, { is_default: true }).prefs;
63
+ assert.deepEqual(fv.listViews(p, 'goals').map((v) => v.is_default), [true, false]);
64
+ p = fv.updateView(p, 'goals', 1, { is_default: false }).prefs;
65
+ assert.deepEqual(fv.listViews(p, 'goals').map((v) => v.is_default), [false, false]);
66
+ });
67
+
68
+ test('pages keep separate lists, and deleting the last view leaves no empty key behind', () => {
69
+ let p = fv.saveView({}, 'goals', { name: 'G', values: {} }).prefs;
70
+ p = fv.saveView(p, 'work', { name: 'W', values: {} }).prefs;
71
+ assert.deepEqual(fv.listViews(p, 'work').map((v) => v.name), ['W']);
72
+ p = fv.deleteView(p, 'goals', 1).prefs;
73
+ p = fv.deleteView(p, 'work', 1).prefs;
74
+ assert.equal(fv.RESERVED_KEY in p, false);
75
+ });
76
+
77
+ test('bad input is refused with a readable reason', () => {
78
+ assert.equal(fv.saveView({}, 'Goals!', { name: 'x', values: {} }).ok, false);
79
+ assert.equal(fv.saveView({}, 'goals', { name: ' ', values: {} }).ok, false);
80
+ assert.equal(fv.saveView({}, 'goals', { name: 'x'.repeat(61), values: {} }).ok, false);
81
+ assert.equal(fv.saveView({}, 'goals', { name: 'x', values: [] }).ok, false);
82
+ assert.equal(fv.saveView({}, 'goals', { name: 'x', values: { 'f-a': 3 } }).ok, false);
83
+ let p = {};
84
+ for (let i = 0; i < fv.MAX_VIEWS_PER_PAGE; i++) p = fv.saveView(p, 'goals', { name: `v${i}`, values: {} }).prefs;
85
+ assert.match(fv.saveView(p, 'goals', { name: 'one more', values: {} }).errors[0], /at most/);
86
+ let q = fv.saveView({}, 'goals', { name: 'A', values: {} }).prefs;
87
+ q = fv.saveView(q, 'goals', { name: 'B', values: {} }).prefs;
88
+ assert.match(fv.updateView(q, 'goals', 2, { name: 'a' }).errors[0], /already have/);
89
+ });
90
+
91
+ test('a builder saves a view and reads it back from a fresh request (another device)', async () => {
92
+ rows.clear();
93
+ const saved = await call('post', '/me/filter-views', { builder: ALICE, body: { page: 'goals', name: 'Open', values: { 'f-goal-status': 'open' }, is_default: true } });
94
+ assert.equal(saved.status, 201);
95
+ const got = await call('get', '/me/filter-views', { builder: ALICE, query: { page: 'goals' } });
96
+ assert.deepEqual(got.body.views, [{ id: 1, name: 'Open', values: { 'f-goal-status': 'open' }, is_default: true }]);
97
+ });
98
+
99
+ test('another builder cannot see, rename, default or delete it', async () => {
100
+ const seen = await call('get', '/me/filter-views', { builder: BOB, query: { page: 'goals' } });
101
+ assert.deepEqual(seen.body.views, []);
102
+ const patch = await call('patch', '/me/filter-views/:id', { builder: BOB, params: { id: '1' }, body: { page: 'goals', name: 'Hijacked' } });
103
+ assert.equal(patch.status, 404);
104
+ const del = await call('delete', '/me/filter-views/:id', { builder: BOB, params: { id: '1' }, query: { page: 'goals' } });
105
+ assert.equal(del.status, 404);
106
+ const still = await call('get', '/me/filter-views', { builder: ALICE, query: { page: 'goals' } });
107
+ assert.equal(still.body.views[0].name, 'Open', 'Alice\'s view is untouched');
108
+ });
109
+
110
+ test('the owner renames and deletes their own view', async () => {
111
+ const ren = await call('patch', '/me/filter-views/:id', { builder: ALICE, params: { id: '1' }, body: { page: 'goals', name: 'Open goals' } });
112
+ assert.equal(ren.body.view.name, 'Open goals');
113
+ const del = await call('delete', '/me/filter-views/:id', { builder: ALICE, params: { id: '1' }, query: { page: 'goals' } });
114
+ assert.deepEqual(del.body.views, []);
115
+ });
116
+
117
+ test('a missing page is a 400; a wrong-shaped body is refused before it is stored; a bad value is a 422', async () => {
118
+ rows.clear();
119
+ assert.equal((await call('get', '/me/filter-views', { builder: ALICE, query: {} })).status, 400);
120
+ assert.equal((await call('post', '/me/filter-views', { builder: ALICE, body: { page: 'goals', name: 'x' } })).status, 400, 'values missing');
121
+ assert.equal((await call('post', '/me/filter-views', { builder: ALICE, body: { page: 'goals', name: 'x', values: {}, owner: 2 } })).status, 400, 'unknown field');
122
+ assert.equal((await call('post', '/me/filter-views', { builder: ALICE, body: { page: 'goals', name: ' ', values: {} } })).status, 422);
123
+ assert.equal(rows.size, 0, 'nothing was written');
124
+ });
125
+
126
+ test('every route is own-scoped: requireBuilder, and no id from the request picks the builder', () => {
127
+ const routes = src('modules/builder-settings/routes/settings.js');
128
+ for (const m of ['get', 'post', 'patch', 'delete']) {
129
+ assert.match(routes, new RegExp(`router\\.${m}\\('/me/filter-views[^']*', api\\.requireBuilder,`));
130
+ }
131
+ const block = routes.slice(routes.indexOf("'/me/filter-views'"), routes.indexOf('Per-builder DISPLAY NAME'));
132
+ assert.doesNotMatch(block, /BehaviorPrefs\((?!req\.builder\.id)/, 'prefs are only ever read/written for req.builder.id');
133
+ });
134
+
135
+ test('both pages opt in, and apply my default view before the focus version', () => {
136
+ const work = src('modules/hall-ui/public/work.js');
137
+ assert.match(work, /views: \{ page: 'work', client: api, base: `\$\{API\}\/me\/filter-views` \}/);
138
+ assert.match(work, /applyDefault\(\{ fire: true \}\)[\s\S]{0,160}!applied && applyFocusVersion\(focusResp\)/);
139
+ const goals = src('modules/hall-ui/public/goals-page.js');
140
+ assert.match(goals, /views: \{ page: 'goals', client: api, base: `\$\{API\}\/me\/filter-views` \}/);
141
+ assert.match(goals, /if \(!\(views && await views\.applyDefault\(\{ fire: false \}\)\)\) applyFocusVersion\(focusResp\);/);
142
+ const kit = src('modules/hall-ui/public/hall-kit.js');
143
+ assert.match(kit, /if \(!\(await ready\) \|\| urlHasFilter\) return false;/, 'a URL filter beats my default view');
144
+ });
@@ -39,9 +39,10 @@ test('a focus the select has no option for is left alone, not selected as a blan
39
39
  test('both pages read the focus and apply it after their version options fill', () => {
40
40
  const work = src('modules/hall-ui/public/work.js');
41
41
  assert.match(work, /\/versions\/focus/);
42
- assert.match(work, /populateVersionFilter\(taskState\.versions\);\s*restoreUrlFilter\('f-version'\);\s*if \(applyFocusVersion\(focusResp\)\)/);
42
+ // task 1004553 moved the call after the Goal options fill, behind the builder's default view.
43
+ assert.match(work, /restoreUrlFilter\('f-goal'\)[\s\S]{0,600}!applied && applyFocusVersion\(focusResp\)/);
43
44
  const goals = src('modules/hall-ui/public/goals-page.js');
44
45
  assert.match(goals, /\/versions\/focus/);
45
- assert.match(goals, /populateVersionFilter\(\);\s*applyFocusVersion\(focusResp\);/);
46
+ assert.match(goals, /populateVersionFilter\(\);[\s\S]{0,400}applyFocusVersion\(focusResp\);/);
46
47
  for (const page of [work, goals]) assert.match(page, /OTBTaskLib\.focusVersionDefault\(/);
47
48
  });
@@ -0,0 +1,111 @@
1
+ // tests/project_manage_link.mjs — a hosted project's owner reaches Take-offline and
2
+ // Delete from their own hall (task 1004570). These EXECUTE: the link builder against
3
+ // real inputs, and real HTTP requests through the assembled router with only
4
+ // requireBuilder replaced, so the Archon gate that refuses a non-owner is the real one.
5
+ //
6
+ // Run: node --test tests/project_manage_link.mjs
7
+
8
+ import assert from 'node:assert/strict';
9
+ import { test } from 'node:test';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { createRequire } from 'node:module';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ process.env.NODE_ENV = 'test';
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const require_ = createRequire(import.meta.url);
18
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
19
+ const { hubManageLinks } = require_(path.join(ROOT, 'src/bongos/routes/core-update.js'));
20
+
21
+ const HUB = { origin: 'https://hub.example.com/', clientId: 'mercury' };
22
+
23
+ test('the link opens THIS project on the hub, at the two cards', () => {
24
+ assert.deepEqual(hubManageLinks(HUB), {
25
+ hub_host: 'hub.example.com',
26
+ manage_url: 'https://hub.example.com/projects?view=manage&project=mercury',
27
+ offline_url: 'https://hub.example.com/projects?view=manage&project=mercury#mDelete',
28
+ disconnect_url: 'https://hub.example.com/projects?view=manage&project=mercury#mDisconnect',
29
+ });
30
+ });
31
+
32
+ test('no hub (self-hosted, or the hub itself) means no link, never a wrong one', () => {
33
+ assert.equal(hubManageLinks(null), null);
34
+ assert.equal(hubManageLinks({ origin: 'https://hub.example.com' }), null, 'no client id');
35
+ assert.equal(hubManageLinks({ origin: 'javascript:alert(1)', clientId: 'x' }), null, 'not an http origin');
36
+ assert.equal(hubManageLinks({ origin: 'https://hub.example.com/"><script>', clientId: 'x' }), null);
37
+ });
38
+
39
+ // One server per test: requireBuilder is swapped for a stub that seats the rank the
40
+ // request names, and the route module is re-required so it captures the stub.
41
+ async function withServer(idp, fn) {
42
+ const r2 = createRequire(import.meta.url);
43
+ const authPath = r2.resolve(path.join(ROOT, 'src/bongos/auth.js'));
44
+ const routePath = r2.resolve(path.join(ROOT, 'src/bongos/routes/core-update.js'));
45
+ const realAuth = r2(authPath);
46
+ const saved = realAuth.requireBuilder;
47
+ realAuth.requireBuilder = (req, _res, next) => { req.builder = { id: 7, rank: req.get('x-rank') }; next(); };
48
+ delete r2.cache[routePath];
49
+ const express = r2('express');
50
+ const { attachFail } = r2(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
51
+ const app = express();
52
+ app.use(attachFail);
53
+ app.use(r2(routePath)({ doorInputs: () => ({ provisioning: false, idp }) }));
54
+ const server = app.listen(0);
55
+ await new Promise((res) => server.once('listening', res));
56
+ const get = (rank) => fetch(`http://127.0.0.1:${server.address().port}/project/manage-link`, { headers: { 'x-rank': rank } });
57
+ try { await fn(get); } finally {
58
+ server.close();
59
+ realAuth.requireBuilder = saved;
60
+ delete r2.cache[routePath];
61
+ }
62
+ }
63
+
64
+ test('C1: the owner (the hosted project\'s Archon) gets the link', async () => {
65
+ await withServer(HUB, async (get) => {
66
+ const res = await get('archon');
67
+ assert.equal(res.status, 200);
68
+ assert.equal((await res.json()).link.offline_url, 'https://hub.example.com/projects?view=manage&project=mercury#mDelete');
69
+ });
70
+ });
71
+
72
+ test('C2: a builder of the same project below Archon is refused by the server', async () => {
73
+ await withServer(HUB, async (get) => {
74
+ for (const rank of ['metic', 'thetes', 'xenos']) {
75
+ const res = await get(rank);
76
+ assert.equal(res.status, 403, `${rank} is refused`);
77
+ assert.equal(JSON.stringify(await res.json()).includes('hub.example.com'), false, `${rank} learns nothing`);
78
+ }
79
+ });
80
+ });
81
+
82
+ test('C3: a self-hosted instance answers no link', async () => {
83
+ await withServer(null, async (get) => {
84
+ const res = await get('archon');
85
+ assert.equal(res.status, 200);
86
+ assert.equal((await res.json()).link, null);
87
+ });
88
+ });
89
+
90
+ test('the hall section is hidden in the markup and shown only for a real link', () => {
91
+ const html = read('modules/hall-ui/public/project-settings.html');
92
+ assert.match(html, /<section class="scroll" id="ps-danger"[^>]*\bhidden>/);
93
+ const js = read('modules/hall-ui/public/project-settings.js');
94
+ assert.match(js, /if \(!link \|\| !link\.offline_url \|\| !link\.disconnect_url\) return;/);
95
+ assert.match(js, /\/project\/manage-link/);
96
+ });
97
+
98
+ test('the hub resolves the slug against the visitor\'s OWN projects only', () => {
99
+ const page = read('modules/public-landing/public/projects.html');
100
+ assert.match(page, /ownedInstances\.filter\(function \(i\) \{ return i && i\.slug === slug; \}\)\[0\]/);
101
+ assert.match(page, /\.then\(openHandedProject\)/);
102
+ });
103
+
104
+ test('the hall\'s two sentences say what the hub\'s own folds promise', () => {
105
+ const hall = read('modules/hall-ui/public/project-settings.html');
106
+ const hub = read('modules/public-landing/public/projects.html');
107
+ for (const claim of ['repository on GitHub', 'cannot be undone', 'bring it back', 'Take it offline', 'Disconnect from Bongos entirely']) {
108
+ assert.ok(hall.includes(claim), `the hall says "${claim}"`);
109
+ assert.ok(hub.includes(claim), `the hub says "${claim}" too`);
110
+ }
111
+ });