@bongos/core 1.25.3 → 1.25.5

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 (46) hide show
  1. package/.bongos-core.json +72 -47
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +185 -3
  8. package/docs/api-reference.md +5 -3
  9. package/docs/copy-inventory.md +17 -18
  10. package/docs/copy-registry.json +44 -43
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-readings.json +43 -43
  13. package/modules/hall-ui/public/builders.js +3 -27
  14. package/modules/hall-ui/public/hall-render.js +5 -119
  15. package/modules/hall-ui/public/hall-widgets.js +3 -3
  16. package/modules/hall-ui/public/index.html +0 -7
  17. package/modules/hall-ui/public/shell-game.js +22 -4
  18. package/modules/hall-ui/public/style.css +3 -73
  19. package/modules/hall-ui/public/visor.css +31 -1
  20. package/modules/hall-ui/public/visor.js +53 -3
  21. package/modules/hall-ui/records/home-reshuffle.md +1 -1
  22. package/modules/hall-ui/records/visor.md +10 -0
  23. package/modules/provisioning/migrations/provisioning_037_update_subscribed.sql +21 -0
  24. package/modules/provisioning/module.json +1 -0
  25. package/modules/provisioning/routes/provisioning.js +4 -0
  26. package/modules/provisioning/routes/update-subscription.js +51 -0
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/release-notes.json +16 -0
  30. package/scripts/bongos/update-channel.js +27 -4
  31. package/scripts/bongos/update-sweep.js +17 -3
  32. package/src/bongos/api-errors.js +2 -0
  33. package/src/bongos/db.js +43 -0
  34. package/src/bongos/route-rank-check.js +1 -0
  35. package/src/bongos/routes/builders.js +37 -0
  36. package/src/module-api.js +1 -1
  37. package/tests/builder_card_requirement.mjs +75 -0
  38. package/tests/hall_board_world.mjs +7 -35
  39. package/tests/hall_home_ships_bounded.mjs +11 -27
  40. package/tests/hall_landing_boot.mjs +7 -8
  41. package/tests/hall_nav.mjs +2 -2
  42. package/tests/hall_visor.mjs +9 -2
  43. package/tests/hall_visor_profile.mjs +150 -0
  44. package/tests/leaderboard_roles.mjs +3 -17
  45. package/tests/provisioning_update_subscription_route.mjs +141 -0
  46. package/tests/update_channel_db.mjs +24 -0
@@ -0,0 +1,141 @@
1
+ // tests/provisioning_update_subscription_route.mjs
2
+ //
3
+ // Task 1004150 (ADR 0325 decision 5): POST /provisioning/instances/:id/update-subscription — the
4
+ // operator's switch for "does the nightly sweep move this instance on its own".
5
+ //
6
+ // The load-bearing assertions:
7
+ // • the fleet permission gate is what stands in front of it (an ungated route could arm any
8
+ // instance for unattended upgrades)
9
+ // • the body is strict and boolean: a string, a missing field or an extra field is a 400 and
10
+ // nothing is written
11
+ // • a real change writes the column and one audit event; a no-op writes and records nothing
12
+ // • an unknown id is a 404
13
+ //
14
+ // DB-free: the pool and data access are stubbed on the cached module objects over a real mounted
15
+ // router (the provisioning_repo_private_route.mjs pattern).
16
+ //
17
+ // Run: node tests/provisioning_update_subscription_route.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+ import http from 'node:http';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const provisioning = require('../modules/provisioning/provisioning.js');
25
+ const api = require('../src/module-api.js');
26
+
27
+ let passed = 0, failed = 0;
28
+ async function ta(name, fn) {
29
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
30
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
31
+ }
32
+
33
+ let instance = null;
34
+ let writes = [];
35
+ let events = [];
36
+ let permissionAsked = null;
37
+ let allowed = true;
38
+ provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
39
+ provisioning.recordEvent = async (_db, e) => { events.push(e); };
40
+ api.pool.query = async (sql, params) => { writes.push({ sql, params }); return { rows: [] }; };
41
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 5, github_login: 'op', rank: 'metic' }; next(); };
42
+ api.requirePermission = (atom) => {
43
+ permissionAsked = atom;
44
+ return (req, res, next) => (allowed ? next() : res.status(403).json({ error: { code: 'forbidden' } }));
45
+ };
46
+ api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
47
+
48
+ const express = require('express');
49
+ const buildRouter = require('../modules/provisioning/routes/update-subscription.js');
50
+ const app = express();
51
+ app.use(express.json());
52
+ app.use((_req, res, next) => {
53
+ res.fail = (code, opts) => {
54
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
55
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}) } });
56
+ };
57
+ next();
58
+ });
59
+ app.use('/api/bongos', buildRouter());
60
+
61
+ const server = app.listen(0);
62
+ await new Promise((r) => server.once('listening', r));
63
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
64
+
65
+ function post(path, body) {
66
+ return new Promise((resolve, reject) => {
67
+ const payload = JSON.stringify(body);
68
+ const req = http.request(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }, (res) => {
69
+ let d = '';
70
+ res.on('data', (c) => { d += c; });
71
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
72
+ });
73
+ req.on('error', reject);
74
+ req.write(payload);
75
+ req.end();
76
+ });
77
+ }
78
+
79
+ function reset(patch = {}) {
80
+ instance = { owner_builder_id: 7, slug: 'spike', update_subscribed: false, ...patch };
81
+ writes = []; events = []; allowed = true;
82
+ }
83
+ const URL_18 = '/provisioning/instances/18/update-subscription';
84
+
85
+ console.log('POST /provisioning/instances/:id/update-subscription:');
86
+
87
+ await ta('is gated on the fleet permission, and a caller without it writes nothing', async () => {
88
+ reset(); allowed = false;
89
+ const r = await post(URL_18, { subscribed: true });
90
+ assert.equal(permissionAsked, 'provisioning.fleet.manage');
91
+ assert.equal(r.status, 403, r.raw);
92
+ assert.equal(writes.length, 0);
93
+ });
94
+
95
+ await ta('arming writes the column once and records one audit event', async () => {
96
+ reset();
97
+ const r = await post(URL_18, { subscribed: true });
98
+ assert.equal(r.status, 200, r.raw);
99
+ assert.deepEqual(r.body, { ok: true, update_subscribed: true, previous: false, changed: true });
100
+ assert.equal(writes.length, 1);
101
+ assert.match(writes[0].sql, /SET update_subscribed = \$2/);
102
+ assert.deepEqual(writes[0].params, [18, true]);
103
+ assert.equal(events.length, 1);
104
+ assert.equal(events[0].event, 'update-subscription-changed');
105
+ });
106
+
107
+ await ta('a no-op (already in that state) writes nothing and records nothing', async () => {
108
+ reset({ update_subscribed: true });
109
+ const r = await post(URL_18, { subscribed: true });
110
+ assert.equal(r.status, 200, r.raw);
111
+ assert.equal(r.body.changed, false);
112
+ assert.equal(writes.length, 0);
113
+ assert.equal(events.length, 0);
114
+ });
115
+
116
+ await ta('disarming is symmetric', async () => {
117
+ reset({ update_subscribed: true });
118
+ const r = await post(URL_18, { subscribed: false });
119
+ assert.equal(r.body.update_subscribed, false);
120
+ assert.deepEqual(writes[0].params, [18, false]);
121
+ });
122
+
123
+ await ta('a non-boolean, a missing field and an extra field are all 400 with nothing written', async () => {
124
+ for (const body of [{ subscribed: 'yes' }, {}, { subscribed: true, channel: 'minor' }]) {
125
+ reset();
126
+ const r = await post(URL_18, body);
127
+ assert.equal(r.status, 400, `${JSON.stringify(body)} → ${r.raw}`);
128
+ assert.equal(writes.length, 0);
129
+ }
130
+ });
131
+
132
+ await ta('an unknown id is 404', async () => {
133
+ reset(); instance = null;
134
+ const r = await post(URL_18, { subscribed: true });
135
+ assert.equal(r.status, 404, r.raw);
136
+ assert.equal(r.body.error.code, 'instance_not_found');
137
+ });
138
+
139
+ server.close();
140
+ console.log(`\n${passed} passed, ${failed} failed`);
141
+ process.exit(failed ? 1 : 0);
@@ -129,3 +129,27 @@ test('update-sweep.js reads the DB from its own env, never an entry env (which n
129
129
  // A core too old to carry the reader must keep the roster rather than crash the sweep.
130
130
  assert.match(src, /typeof channel\.readDbChannels === 'function'/);
131
131
  });
132
+
133
+ // ---- task 1004150: the subscription roster lives on the project row too ----
134
+
135
+ const subDb = (subs) => ({ ok: true, channels: new Map(), subscribed: new Map(Object.entries(subs)) });
136
+
137
+ test('subscribed on the row: reported as deploy-sourced; a file-only entry is kept as an override', () => {
138
+ const { instances, unreachable } = uc.applyDbSubscriptions([entry(), entry({ slug: 'charter' })], subDb({ cloudbongos: true, charter: false }));
139
+ assert.equal(instances[0].subscribedSource, uc.CHANNEL_SOURCE_DEPLOY);
140
+ assert.equal(instances[1].subscribedSource, uc.CHANNEL_SOURCE_ROSTER);
141
+ assert.match(instances[1].subscribedNote, /roster file only/);
142
+ assert.deepEqual(unreachable, []);
143
+ });
144
+
145
+ test('subscribed on the row with no roster entry: surfaced as unreachable, never dropped', () => {
146
+ const { instances, unreachable } = uc.applyDbSubscriptions([], subDb({ mercury: true, venus: false }));
147
+ assert.equal(instances.length, 0);
148
+ assert.deepEqual(unreachable, ['mercury']);
149
+ });
150
+
151
+ test('database unreachable: the roster alone decides and nothing is reported unreachable', () => {
152
+ const { instances, unreachable } = uc.applyDbSubscriptions([entry()], { ok: false, reason: 'psql not found' });
153
+ assert.equal(instances.length, 1);
154
+ assert.deepEqual(unreachable, []);
155
+ });