@bongos/core 1.21.60 → 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.
@@ -214,3 +214,8 @@
214
214
  the 24px floor: the prose-link recipe buys the height with padding and hands it back
215
215
  with an equal negative margin, so the line does not re-space. */
216
216
  .dp-settled .ov-row__sub a { display: inline-block; min-height: 24px; padding: 3px 0; margin: -3px 0; }
217
+
218
+ /* The danger zone (task 1004570) — two links out to the hosting platform, each with
219
+ one sentence saying what it does. Plain list, the lede's measure. */
220
+ .ps-danger__list { margin: 10px 0 0; padding-left: 18px; line-height: 1.6; max-width: var(--measure); }
221
+ .ps-danger__list li + li { margin-top: 8px; }
@@ -49,6 +49,18 @@
49
49
  thing this page exists to end. -->
50
50
  <div id="ps-rows"><span class="kit-skel"></span></div>
51
51
  </section>
52
+ <!-- Danger zone (task 1004570): where this project's OWNER takes it offline or
53
+ disconnects it. Both controls live on the platform that hosts the project,
54
+ so this only links there. Hidden unless GET /project/manage-link answers
55
+ with a link — an Archon on a hosted project; never on a self-hosted one. -->
56
+ <section class="scroll" id="ps-danger" aria-labelledby="ps-danger-h" hidden>
57
+ <h2 class="scroll__h" id="ps-danger-h">Danger zone</h2>
58
+ <p class="scroll__lede"><span id="ps-danger-lede"></span> <a id="ps-danger-manage" href="#"></a></p>
59
+ <ul class="ps-danger__list">
60
+ <li><a id="ps-danger-offline" href="#">Take it offline</a> — stops the project and its nightly backups but keeps everything: its database, its backups and your repository on GitHub. You can bring it back.</li>
61
+ <li><a id="ps-danger-disconnect" href="#">Disconnect from Bongos entirely</a> — erases the project’s server, its database and every backup, and releases its name and web address. Your repository on GitHub is kept. This cannot be undone.</li>
62
+ </ul>
63
+ </section>
52
64
  </main>
53
65
 
54
66
  <footer class="app-foot" id="app-foot"></footer>
@@ -245,5 +245,25 @@
245
245
  }
246
246
  }
247
247
 
248
+ // The danger zone (task 1004570). The server answers only an Archon — on a hosted
249
+ // project, its owner — and answers { link: null } where there is no hosting platform
250
+ // to link to, so every other outcome (refused, failed, self-hosted) leaves the section
251
+ // hidden: a missing link, never a dead one. The act itself stays on the platform.
252
+ async function loadDanger() {
253
+ let link = null;
254
+ try {
255
+ const res = await api.request('GET', `${API}/project/manage-link`);
256
+ link = res.ok && res.data ? res.data.link : null;
257
+ } catch (_) { link = null; }
258
+ if (!link || !link.offline_url || !link.disconnect_url) return;
259
+ $('ps-danger-lede').textContent = `This project is hosted on ${link.hub_host}, and that is where it is taken offline or removed. Both links below open this project’s page there.`;
260
+ $('ps-danger-manage').textContent = `Manage this project on ${link.hub_host}`;
261
+ $('ps-danger-manage').href = link.manage_url;
262
+ $('ps-danger-offline').href = link.offline_url;
263
+ $('ps-danger-disconnect').href = link.disconnect_url;
264
+ $('ps-danger').hidden = false;
265
+ }
266
+
248
267
  load();
268
+ loadDanger();
249
269
  })();
@@ -0,0 +1,10 @@
1
+ # The danger zone on /project-settings — a hosted project's owner reaches Take-offline and Delete (task 1004570)
2
+
3
+ The owner of a hosted project could not find how to delete it: both controls live on the hub's Projects page, and nothing in the project's own hall pointed there. What this settled:
4
+
5
+ - **Link-out only.** The section links to the hub's page for this project, scrolled to the `#mDelete` / `#mDisconnect` cards. The typed-name confirm and the intent queue stay on the hub (ADR 0290); the hall gains no second delete path.
6
+ - **The server decides who sees it.** `GET /project/manage-link` is Archon-only (on a hosted project the owner is its Archon), and answers `{ link: null }` when the instance has no hub to link to — a self-hosted instance, or the hub itself. The section is `hidden` in the markup and shown only for a real link, so every other outcome is a missing section, never a dead link.
7
+ - **The link names the project by slug.** The hall knows its hub origin and client id (which is its slug, ADR 0246) from its sign-in configuration, never a hardcoded domain. The hub's Projects page resolves `?view=manage&project=<slug>` against the visitor's OWN projects; a slug that is not theirs opens their list, not someone else's project. The slug survives a sign-in detour.
8
+ - **The two sentences track the hub's folds.** `tests/project_manage_link.mjs` holds the hall's claims (repository kept, cannot be undone, can bring it back) against the hub's own fold copy.
9
+
10
+ Tests: `tests/project_manage_link.mjs` (C1 owner link, C2 non-owner refused by the real gate, C3 self-hosted answers none). Harness fixture: `project__manage-link.json`.
@@ -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.60",
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.60",
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.60",
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",
@@ -9132,5 +9132,11 @@
9132
9132
  "id": "1004553",
9133
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
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
+ }
9135
9141
  ]
9136
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.60'; // 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,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
+ });