@bongos/core 1.25.19 → 1.25.21

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 (131) hide show
  1. package/.bongos-core.json +237 -112
  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 +26 -9
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +10 -0
  8. package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +3 -1
  9. package/docs/api/openapi.json +444 -37
  10. package/docs/api-reference.md +11 -5
  11. package/docs/copy-inventory.md +223 -221
  12. package/docs/copy-registry.json +262 -244
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-readings.json +348 -347
  15. package/docs/project-context.template.md +1 -1
  16. package/modules/front-door-guide/handoff.js +120 -0
  17. package/modules/front-door-guide/module.json +1 -1
  18. package/modules/front-door-guide/routes/front-door-guide-plan.js +24 -0
  19. package/modules/government/board.js +8 -7
  20. package/modules/government/charter-advice.js +10 -3
  21. package/modules/government/charter.js +88 -4
  22. package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
  23. package/modules/government/routes/government.js +12 -12
  24. package/modules/government/sole-member.js +75 -0
  25. package/modules/hall-ui/public/ai-studio.html +1 -1
  26. package/modules/hall-ui/public/atlas.html +1 -1
  27. package/modules/hall-ui/public/blockers.html +1 -1
  28. package/modules/hall-ui/public/board-room.html +1 -1
  29. package/modules/hall-ui/public/city.html +1 -1
  30. package/modules/hall-ui/public/collab.html +1 -1
  31. package/modules/hall-ui/public/commands.html +1 -1
  32. package/modules/hall-ui/public/copy-desk.html +1 -1
  33. package/modules/hall-ui/public/deploy.html +1 -1
  34. package/modules/hall-ui/public/genesis-home.js +35 -9
  35. package/modules/hall-ui/public/goal-map.html +1 -1
  36. package/modules/hall-ui/public/goals.html +1 -1
  37. package/modules/hall-ui/public/government.html +1 -1
  38. package/modules/hall-ui/public/home-city-draw.js +14 -1
  39. package/modules/hall-ui/public/idea.html +1 -1
  40. package/modules/hall-ui/public/ideas.html +1 -1
  41. package/modules/hall-ui/public/index.html +1 -1
  42. package/modules/hall-ui/public/market-watch-item.html +1 -1
  43. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  44. package/modules/hall-ui/public/market-watch.html +1 -1
  45. package/modules/hall-ui/public/modules.html +1 -1
  46. package/modules/hall-ui/public/money.html +1 -1
  47. package/modules/hall-ui/public/my-agents.html +1 -1
  48. package/modules/hall-ui/public/primer.html +1 -1
  49. package/modules/hall-ui/public/profile.html +1 -1
  50. package/modules/hall-ui/public/project-settings.html +1 -1
  51. package/modules/hall-ui/public/roster.html +1 -1
  52. package/modules/hall-ui/public/settings.html +1 -1
  53. package/modules/hall-ui/public/shell.js +32 -20
  54. package/modules/hall-ui/public/street.html +1 -1
  55. package/modules/hall-ui/public/studio.html +1 -1
  56. package/modules/hall-ui/public/style.css +9 -4
  57. package/modules/hall-ui/public/task.html +1 -1
  58. package/modules/hall-ui/public/thinking.html +1 -1
  59. package/modules/hall-ui/public/tweak-editor.html +1 -1
  60. package/modules/hall-ui/public/watch.html +1 -1
  61. package/modules/hall-ui/public/work.html +1 -1
  62. package/modules/lifecycle/home-city.js +17 -3
  63. package/modules/onboarding/founding-mode.js +45 -1
  64. package/modules/onboarding/genesis-home.js +16 -2
  65. package/modules/onboarding/guide-handoff.js +109 -0
  66. package/modules/onboarding/routes/genesis-work.js +9 -5
  67. package/modules/platform-identity/module.json +1 -1
  68. package/modules/platform-identity/project-standing.js +49 -0
  69. package/modules/platform-identity/routes/my-projects.js +8 -0
  70. package/modules/provisioning/grants.js +126 -0
  71. package/modules/provisioning/guide-handoff.js +65 -0
  72. package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
  73. package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
  74. package/modules/provisioning/module.json +2 -1
  75. package/modules/provisioning/project-refusal.js +107 -0
  76. package/modules/provisioning/provisioning.js +4 -4
  77. package/modules/provisioning/render-lookup.js +36 -1
  78. package/modules/provisioning/render-promote.js +80 -0
  79. package/modules/provisioning/route-helpers.js +70 -16
  80. package/modules/provisioning/routes/env-manifest.js +21 -5
  81. package/modules/provisioning/routes/grants.js +141 -0
  82. package/modules/provisioning/routes/provisioning.js +12 -9
  83. package/modules/provisioning/routes/render-standup.js +82 -3
  84. package/modules/public-landing/public/projects.html +178 -1
  85. package/package-lock.json +2 -2
  86. package/package.json +1 -1
  87. package/release-notes.json +28 -0
  88. package/scripts/bongos/genesis.js +11 -1
  89. package/scripts/bongos/government-walk-sole.js +41 -0
  90. package/scripts/bongos/government-walk.js +15 -16
  91. package/scripts/bongos/provision-render-common.js +48 -0
  92. package/scripts/bongos/provision-render-promote.js +93 -0
  93. package/scripts/bongos/provision-render-sandbox.js +14 -2
  94. package/scripts/bongos/provision-render.js +4 -36
  95. package/scripts/bongos/provision.js +1 -0
  96. package/scripts/bongos/render-api.js +17 -2
  97. package/scripts/gds/government-walk-sole.js +6 -0
  98. package/scripts/gds/provision-render-common.js +6 -0
  99. package/scripts/gds/provision-render-promote.js +6 -0
  100. package/src/bongos/founding-page-gate.js +67 -0
  101. package/src/bongos/serve-internal.js +3 -0
  102. package/src/branding.js +4 -0
  103. package/src/module-api.js +1 -1
  104. package/tests/fleet_cost_view.mjs +2 -0
  105. package/tests/founding_mode.mjs +84 -8
  106. package/tests/founding_page_gate.mjs +105 -0
  107. package/tests/front_door_guide_existing.mjs +1 -0
  108. package/tests/genesis_government.mjs +5 -1
  109. package/tests/genesis_home.mjs +41 -0
  110. package/tests/government_charter_advice.mjs +49 -0
  111. package/tests/government_charter_forms.mjs +14 -3
  112. package/tests/government_seed.mjs +1 -0
  113. package/tests/government_sole_member.mjs +153 -0
  114. package/tests/guide_handoff.mjs +322 -0
  115. package/tests/home_city.mjs +52 -0
  116. package/tests/nav_permission_atoms.mjs +1 -1
  117. package/tests/project_catalog_routes.mjs +2 -0
  118. package/tests/provision_settings_apply.mjs +2 -1
  119. package/tests/provisioning_env_manifest.mjs +4 -0
  120. package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
  121. package/tests/provisioning_project_grants.mjs +458 -0
  122. package/tests/provisioning_project_owner_gate.mjs +3 -1
  123. package/tests/provisioning_project_refusal.mjs +316 -0
  124. package/tests/provisioning_render_route.mjs +1 -1
  125. package/tests/provisioning_restart.mjs +2 -0
  126. package/tests/provisioning_settings.mjs +2 -0
  127. package/tests/provisioning_settings_apply.mjs +1 -1
  128. package/tests/provisioning_settings_env.mjs +2 -2
  129. package/tests/provisioning_teardown_intent.mjs +2 -2
  130. package/tests/render_promote.mjs +309 -0
  131. package/tests/render_sandbox.mjs +9 -4
@@ -0,0 +1,316 @@
1
+ // tests/provisioning_project_refusal.mjs
2
+ //
3
+ // What a person the project gate REFUSES is told (task 1004162, ADR 0407 §6). The gate itself
4
+ // is unchanged — who gets in is task 1004161's — and this file proves the refusal can only ever
5
+ // refuse, while someone already connected to the project gets a next step instead of a dead end.
6
+ //
7
+ // Covers:
8
+ // • a stranger, an unregistered standing port, a port that throws and an unknown answer are
9
+ // all the 404 that confirms nothing
10
+ // • 'visitor' → 403 project_membership_required with the real join path (the hub's own
11
+ // POST /my-projects/join, carrying the project's origin); 'pending' → accept the invite;
12
+ // 'applied' → it is with the owner
13
+ // • 'member'/'owner' rows: 403 project_access_required on a PUBLIC project, but the 404 on a
14
+ // private or stealth one — a `member` row comes from any device-flow sign-in, so it is no
15
+ // evidence a person may learn that a closed project's id is real
16
+ // • a grantee on a route not open to grantees → 403 owner_only
17
+ // • THE ATTACK again: with a witnessed `member` row and a public project, every declared
18
+ // project route still refuses and writes nothing; with a private project, every gated
19
+ // route stays the 404
20
+ // • the standing port's own reads (platform-identity/project-standing.js): only witnessed
21
+ // rows, a live invite, a recent application; a non-numeric person is no one
22
+ // • the structure: the refusal file never calls next() and never sets req.project
23
+ //
24
+ // DB-free: the real routers and envelope over stubbed data access.
25
+ //
26
+ // Run: node --test tests/provisioning_project_refusal.mjs
27
+
28
+ import assert from 'node:assert/strict';
29
+ import { test, before, after } from 'node:test';
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import { createRequire } from 'node:module';
33
+ import { fileURLToPath } from 'node:url';
34
+
35
+ const require = createRequire(import.meta.url);
36
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
37
+ const MODULE = path.join(ROOT, 'modules/provisioning');
38
+
39
+ const api = require(path.join(ROOT, 'src/module-api.js'));
40
+ const seams = require(path.join(ROOT, 'src/module-seams.js'));
41
+ const provisioning = require(path.join(MODULE, 'provisioning.js'));
42
+ const grants = require(path.join(MODULE, 'grants.js'));
43
+ const githubSetup = require(path.join(MODULE, 'github-setup.js'));
44
+ const catalogBridge = require(path.join(MODULE, 'catalog-bridge.js'));
45
+ const envManifest = require(path.join(MODULE, 'env-manifest.js'));
46
+ const STANDING_PORT = 'platform-identity.projectMembershipKind';
47
+
48
+ const VISITOR = { id: 66, github_login: 'visitor', github_id: '6600', rank: 'xenos' };
49
+ const ROW = {
50
+ id: 18, owner_builder_id: 7, slug: 'mercury', status: 'active', hosting_shape: 'cloud-host',
51
+ domain: 'mercury.example.com', target_ref: 'owner/mercury', settings: {}, detail: {}, tier: null,
52
+ };
53
+
54
+ let row = { ...ROW };
55
+ let kind = null; // what the standing port answers
56
+ let kindThrows = false;
57
+ let grantFor = null; // builder id holding a live grant
58
+ let grantThrows = false;
59
+ const asked = [];
60
+ const writes = [];
61
+
62
+ api.requireBuilder = (req, _res, next) => { req.builder = { ...VISITOR }; next(); };
63
+ api.requirePermission = () => (_req, res) => res.fail('permission_forbidden', 403);
64
+ api.branding = () => ({ domains: { publicOrigin: 'https://cb.test', instanceBase: 'cb.test' }, identity: { productName: 'Cloud Bongos' } });
65
+ api.pool.query = async (text) => {
66
+ const q = String(text).replace(/\s+/g, ' ').trim();
67
+ if (/^(INSERT|UPDATE|DELETE)/i.test(q)) writes.push(q);
68
+ return { rows: [], rowCount: 0 };
69
+ };
70
+ api.pool.connect = async () => ({ query: api.pool.query, release() {} });
71
+
72
+ grants.liveGrant = async (_db, instanceId, builderId) => {
73
+ if (grantThrows) throw new Error('grant table gone');
74
+ return String(builderId) === String(grantFor) ? { id: 3, instance_id: instanceId, grantee_builder_id: builderId, level: 'operate' } : null;
75
+ };
76
+ provisioning.getInstanceById = async (_db, id) => (String(id) === String(row.id) ? { ...row } : null);
77
+ provisioning.recordEvent = async (_db, e) => { writes.push(`event ${e.event}`); };
78
+ for (const fn of ['enqueueRestart', 'enqueueTeardown', 'enqueueIntent', 'enqueueRepoPrivate', 'enqueueRenderStandup',
79
+ 'updateInstanceSettings', 'updateInstanceDetail', 'updateInstanceDomain', 'createOAuthManifest']) {
80
+ provisioning[fn] = async () => { writes.push(fn); return { created: true, intent: { action: fn, state: 'pending' } }; };
81
+ }
82
+ provisioning.getOpenIntent = async () => null;
83
+ provisioning.getLatestOAuthManifestForInstance = async () => null;
84
+ githubSetup.githubSetupRead = async () => ({ steps: [] });
85
+ githubSetup.installNudge = () => null;
86
+ catalogBridge.healInstanceCatalog = async () => {};
87
+ envManifest.listEnvVars = async () => [];
88
+ for (const fn of ['declareEnvVar', 'updateEnvVar', 'removeEnvVar']) envManifest[fn] = async () => { writes.push(fn); return { row: { id: 5, name: 'X' }, created: true }; };
89
+
90
+ const routeFiles = JSON.parse(fs.readFileSync(path.join(MODULE, 'module.json'), 'utf8')).contributes.routes;
91
+ const read = (f) => fs.readFileSync(f, 'utf8').replace(/\r\n/g, '\n');
92
+ const DECLARED = [];
93
+ for (const name of routeFiles) {
94
+ const src = read(path.join(MODULE, 'routes', `${name}.js`));
95
+ const re = /router\.(get|post|patch|put|delete)\(\s*'(\/provisioning\/instances\/:id[^']*)'/g;
96
+ let m;
97
+ while ((m = re.exec(src))) DECLARED.push({ method: m[1], route: m[2], gated: /requireProjectOwner\(/.test(src.slice(m.index, src.indexOf('async', m.index))) });
98
+ }
99
+ const concrete = (r) => r.replace(':id', '18').replace(':varId', '5').replace(':grantId', '3');
100
+
101
+ const express = require('express');
102
+ const { attachFail } = require(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
103
+ let server, base;
104
+ before(async () => {
105
+ const app = express();
106
+ app.use(express.json());
107
+ app.use(attachFail);
108
+ for (const name of routeFiles) app.use('/api/bongos', require(path.join(MODULE, 'routes', `${name}.js`))());
109
+ server = app.listen(0);
110
+ await new Promise((r) => server.once('listening', r));
111
+ base = `http://127.0.0.1:${server.address().port}/api/bongos`;
112
+ });
113
+ after(() => { server.closeAllConnections(); server.close(); });
114
+
115
+ async function call(method, route, body = {}) {
116
+ const res = await fetch(`${base}${concrete(route)}`, {
117
+ method: method.toUpperCase(),
118
+ headers: { 'content-type': 'application/json', connection: 'close' },
119
+ body: method === 'get' ? undefined : JSON.stringify(body),
120
+ });
121
+ const raw = await res.text();
122
+ let json = null;
123
+ try { json = JSON.parse(raw); } catch { /* not JSON */ }
124
+ return { status: res.status, body: json, raw };
125
+ }
126
+ function setWorld({ standing = null, throws = false, grant = null, grantErr = false, settings = {} } = {}) {
127
+ kind = standing; kindThrows = throws; grantFor = grant; grantThrows = grantErr;
128
+ row = { ...ROW, settings };
129
+ writes.length = 0; asked.length = 0;
130
+ }
131
+ const READ = ['get', '/provisioning/instances/:id'];
132
+ const TEARDOWN = ['post', '/provisioning/instances/:id/teardown'];
133
+
134
+ // ── before the port exists ──────────────────────────────────────────────────────────
135
+ test('with no standing port registered (a hub without platform-identity), a refused caller gets the 404', async () => {
136
+ assert.equal(seams.hasProvider(STANDING_PORT), false, 'nothing registers the port in this process yet');
137
+ setWorld({ standing: 'visitor' });
138
+ const r = await call(...READ);
139
+ assert.equal(r.status, 404, r.raw);
140
+ assert.equal(r.body.error.code, 'instance_not_found');
141
+ });
142
+
143
+ // ── with the port ──────────────────────────────────────────────────────────────────
144
+ test('register a fake standing port for the rest of the file', () => {
145
+ seams.registerProvider(STANDING_PORT, {
146
+ projectMembershipKind: async (clientId, githubId) => {
147
+ asked.push({ clientId, githubId });
148
+ if (kindThrows) throw new Error('membership table gone');
149
+ return kind;
150
+ },
151
+ });
152
+ assert.equal(seams.hasProvider(STANDING_PORT), true);
153
+ });
154
+
155
+ test('a stranger keeps the 404, and the port is asked about THIS project by its slug and the caller’s own id', async () => {
156
+ setWorld({ standing: null });
157
+ const r = await call(...READ);
158
+ assert.equal(r.status, 404, r.raw);
159
+ assert.equal(r.body.error.code, 'instance_not_found');
160
+ assert.deepEqual(asked, [{ clientId: 'mercury', githubId: '6600' }]);
161
+ });
162
+
163
+ test('a port that throws, or answers something unknown, is the 404 — on doubt, say less', async () => {
164
+ for (const world of [{ throws: true }, { standing: 'admin' }, { standing: 'grantee' }, { standing: '' }]) {
165
+ setWorld(world);
166
+ const r = await call(...READ);
167
+ assert.equal(r.status, 404, `${JSON.stringify(world)}: ${r.raw}`);
168
+ }
169
+ });
170
+
171
+ test('a visitor is told how to apply: the hub’s own join route, with this project’s origin', async () => {
172
+ setWorld({ standing: 'visitor' });
173
+ const r = await call(...TEARDOWN);
174
+ assert.equal(r.status, 403, r.raw);
175
+ assert.equal(r.body.error.code, 'project_membership_required');
176
+ assert.deepEqual(r.body.error.details.apply, {
177
+ state: 'can_apply', method: 'POST', path: '/api/bongos/my-projects/join', body: { origin: 'https://mercury.example.com' },
178
+ });
179
+ assert.match(r.body.error.message, /Ask to join it/);
180
+ assert.match(r.body.error.message, /hosting is something the project’s owner gives separately/);
181
+ assert.deepEqual(writes, []);
182
+ });
183
+
184
+ test('the join path it names is the route that really exists on the hub', () => {
185
+ const src = read(path.join(ROOT, 'modules/platform-identity/routes/my-projects.js'));
186
+ assert.match(src, /router\.post\('\/my-projects\/join', requireBuilder,/);
187
+ });
188
+
189
+ test('an invited person is told to sign in to the project to accept', async () => {
190
+ setWorld({ standing: 'pending' });
191
+ const r = await call(...READ);
192
+ assert.equal(r.status, 403, r.raw);
193
+ assert.equal(r.body.error.code, 'project_membership_required');
194
+ assert.deepEqual(r.body.error.details.apply, { state: 'invited', sign_in: 'https://mercury.example.com' });
195
+ });
196
+
197
+ test('someone who already asked to join is told the owner decides', async () => {
198
+ setWorld({ standing: 'applied' });
199
+ const r = await call(...READ);
200
+ assert.equal(r.status, 403, r.raw);
201
+ assert.deepEqual(r.body.error.details.apply, { state: 'applied' });
202
+ });
203
+
204
+ test('a member without a grant, on a PUBLIC project, is told to ask the owner', async () => {
205
+ for (const standing of ['member', 'owner']) {
206
+ setWorld({ standing });
207
+ const r = await call(...TEARDOWN);
208
+ assert.equal(r.status, 403, `${standing}: ${r.raw}`);
209
+ assert.equal(r.body.error.code, 'project_access_required');
210
+ assert.match(r.body.error.message, /Ask the owner to give you access/);
211
+ assert.equal(r.body.error.details, undefined, 'no self-serve request is offered');
212
+ }
213
+ });
214
+
215
+ test('a member row on a PRIVATE or STEALTH project confirms nothing: the 404', async () => {
216
+ for (const visibility of ['private', 'stealth']) {
217
+ for (const standing of ['member', 'owner']) {
218
+ setWorld({ standing, settings: { visibility } });
219
+ const r = await call(...READ);
220
+ assert.equal(r.status, 404, `${visibility}/${standing}: ${r.raw}`);
221
+ assert.equal(r.body.error.code, 'instance_not_found');
222
+ }
223
+ }
224
+ });
225
+
226
+ test('a grantee on a route not open to grantees is told it is the owner’s', async () => {
227
+ setWorld({ standing: 'member', grant: VISITOR.id });
228
+ const r = await call(...TEARDOWN);
229
+ assert.equal(r.status, 403, r.raw);
230
+ assert.equal(r.body.error.code, 'owner_only');
231
+ assert.deepEqual(writes, []);
232
+ setWorld({ standing: 'member', grant: VISITOR.id, grantErr: true });
233
+ const failed = await call(...TEARDOWN);
234
+ // teardown is not a grantee route, so only the refusal reads the grant: a failed read there
235
+ // says less (the 404), never more
236
+ assert.equal(failed.status, 404, failed.raw);
237
+ assert.deepEqual(writes, []);
238
+ });
239
+
240
+ test('a grant read that fails while choosing the WORDING is the 404, never an admission', async () => {
241
+ // GET /provisioning/instances/:id/look is inline-gated and never reaches this file; use a
242
+ // gated non-grantee route, where only the refusal reads the grant.
243
+ setWorld({ standing: 'visitor', grantErr: true });
244
+ const r = await call('post', '/provisioning/instances/:id/domain', { domain: 'x.example.com' });
245
+ assert.equal(r.status, 404, r.raw);
246
+ assert.equal(r.body.error.code, 'instance_not_found');
247
+ assert.deepEqual(writes, []);
248
+ });
249
+
250
+ // ── the attack, with the new wording in place ───────────────────────────────────────
251
+ test('a device-flow member on a PUBLIC project: every declared project route still refuses, and nothing is written', async () => {
252
+ for (const d of DECLARED) {
253
+ setWorld({ standing: 'member' });
254
+ const r = await call(d.method, d.route, d.method === 'get' ? undefined : {});
255
+ assert.ok(r.status >= 400 && r.status < 500, `${d.method} ${d.route} → ${r.status} ${r.raw.slice(0, 160)}`);
256
+ assert.deepEqual(writes, [], `${d.method} ${d.route} wrote ${JSON.stringify(writes)}`);
257
+ }
258
+ });
259
+
260
+ test('a device-flow member on a PRIVATE project: every gated route is the 404', async () => {
261
+ for (const d of DECLARED.filter((x) => x.gated)) {
262
+ setWorld({ standing: 'member', settings: { visibility: 'private' } });
263
+ const r = await call(d.method, d.route, d.method === 'get' ? undefined : {});
264
+ assert.equal(r.status, 404, `${d.method} ${d.route}: ${r.raw.slice(0, 160)}`);
265
+ assert.deepEqual(writes, []);
266
+ }
267
+ });
268
+
269
+ // ── the standing port's own reads ───────────────────────────────────────────────────
270
+ const { projectMembershipKind } = require(path.join(ROOT, 'modules/platform-identity/project-standing.js'));
271
+ function standingDb({ membership = null, applied = false } = {}) {
272
+ const seen = [];
273
+ return {
274
+ seen,
275
+ query: async (text, params) => {
276
+ const s = String(text).replace(/\s+/g, ' ');
277
+ seen.push({ s, params });
278
+ if (/platform_identity_project_memberships/.test(s)) return { rows: membership ? [{ membership_kind: membership }] : [] };
279
+ if (/platform_identity_application_echoes/.test(s)) return { rows: applied ? [{ 1: 1 }] : [] };
280
+ return { rows: [] };
281
+ },
282
+ };
283
+ }
284
+
285
+ test('the port reads only witnessed rows and a live invite, then a recent application', async () => {
286
+ let db = standingDb({ membership: 'member' });
287
+ assert.equal(await projectMembershipKind('mercury', '6600', { pool: db }), 'member');
288
+ assert.match(db.seen[0].s, /membership_kind <> 'pending' AND m\.signin_verified_at IS NOT NULL/);
289
+ assert.match(db.seen[0].s, /membership_kind = 'pending' AND m\.joined_at > now\(\) - make_interval/);
290
+ assert.deepEqual(db.seen[0].params.slice(0, 2), ['mercury', '6600']);
291
+ assert.equal(db.seen.length, 1, 'a row answers; the application is not asked');
292
+ db = standingDb({ applied: true });
293
+ assert.equal(await projectMembershipKind('mercury', '6600', { pool: db }), 'applied');
294
+ assert.match(db.seen[1].s, /client_id = \$2 AND applied_at > now\(\)/);
295
+ db = standingDb({});
296
+ assert.equal(await projectMembershipKind('mercury', '6600', { pool: db }), null);
297
+ db = standingDb({ membership: 'something-new' });
298
+ assert.equal(await projectMembershipKind('mercury', '6600', { pool: db }), null, 'an unknown kind is no one');
299
+ });
300
+
301
+ test('a non-numeric person or an empty project is no one, and nothing is queried', async () => {
302
+ const db = standingDb({ membership: 'member' });
303
+ assert.equal(await projectMembershipKind('mercury', 'system:bfg', { pool: db }), null);
304
+ assert.equal(await projectMembershipKind('', '6600', { pool: db }), null);
305
+ assert.equal(db.seen.length, 0);
306
+ });
307
+
308
+ // ── the structure ───────────────────────────────────────────────────────────────────
309
+ test('the refusal can only refuse: it never calls next() and never sets the admitted row', () => {
310
+ const src = read(path.join(MODULE, 'project-refusal.js')).replace(/\/\/.*$/gm, '');
311
+ assert.doesNotMatch(src, /next\s*\(/);
312
+ assert.doesNotMatch(src, /req\.project\s*=|req\.projectDoor\s*=/);
313
+ const gate = read(path.join(MODULE, 'route-helpers.js'));
314
+ assert.match(gate, /if \(!door\) return refuseProject\(req, res, inst\);/);
315
+ assert.match(gate, /if \(!inst\) return res\.fail\('instance_not_found', 404\);/, 'a missing project is the plain 404 — no lookup at all');
316
+ });
@@ -338,7 +338,7 @@ await ta('every path that resolves an intent clears the key', () => {
338
338
  assert.match(resolve, /borrowed_key_enc = NULL/);
339
339
  const teardown = src.slice(src.indexOf('async function enqueueTeardown'), src.indexOf('async function enqueueTeardown') + 600);
340
340
  assert.match(teardown, /borrowed_key_enc = NULL/, 'a superseded standup drops its key');
341
- assert.match(teardown, /'render-standup'\) AND state = 'pending'/);
341
+ assert.match(teardown, /'render-standup', 'render-promote'\) AND state = 'pending'/);
342
342
  });
343
343
 
344
344
  server.close();
@@ -34,6 +34,8 @@ import http from 'node:http';
34
34
 
35
35
  const require = createRequire(import.meta.url);
36
36
  const provisioning = require('../modules/provisioning/provisioning.js');
37
+ // The gate also admits an owner-made grant (task 1004161, ADR 0407); nobody in this file holds one.
38
+ require('../modules/provisioning/grants.js').liveGrant = async () => null;
37
39
 
38
40
  let passed = 0, failed = 0;
39
41
  function t(name, fn) {
@@ -23,6 +23,8 @@ import http from 'node:http';
23
23
 
24
24
  const require = createRequire(import.meta.url);
25
25
  const provisioning = require('../modules/provisioning/provisioning.js');
26
+ // The gate also admits an owner-made grant (task 1004161, ADR 0407); nobody in this file holds one.
27
+ require('../modules/provisioning/grants.js').liveGrant = async () => null;
26
28
 
27
29
  let passed = 0, failed = 0;
28
30
  async function ta(name, fn) {
@@ -169,7 +169,7 @@ await ta('enqueueTeardown supersedes a PENDING settings push (a push into a proj
169
169
  const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
170
170
  assert.equal(r.created, true);
171
171
  assert.equal(r.superseded, true, 'the queued push was cancelled so the teardown could take the slot');
172
- assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
172
+ assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup', 'render-promote'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
173
173
  });
174
174
 
175
175
  // ── the route, over a real mounted router (stubbed data access) ─────────────────
@@ -128,7 +128,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
128
128
  // route never stamped, which is every row made before it shipped (spec D8).
129
129
  CLOUDBONGOS_FOUNDING_BIRTH: '', CLOUDBONGOS_FOUNDING_GENESIS: '', CLOUDBONGOS_DEMO_PEOPLE: '', CLOUDBONGOS_DEMO_HOURS: '',
130
130
  // The wizard's ship mode (task 1004629): empty when none was picked.
131
- CLOUDBONGOS_SHIP_MODE: '', CLOUDBONGOS_TEST_PROJECT: '',
131
+ CLOUDBONGOS_SHIP_MODE: '', CLOUDBONGOS_TEST_PROJECT: '', CLOUDBONGOS_GUIDE_HANDOFF: '',
132
132
  });
133
133
  // varsOf splits on '=', so an empty value reads as '' on both sides — the point
134
134
  // of the comparison is that the standup lines carry exactly the same key set.
@@ -137,7 +137,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
137
137
  X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
138
138
  X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
139
139
  X_LOOK: 'own', X_NIGHT_TINT: 'on', X_LOOK_ACCENT: '', X_LOGO_URL: '',
140
- X_FOUNDING_BIRTH: '', X_FOUNDING_GENESIS: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '', X_TEST_PROJECT: '',
140
+ X_FOUNDING_BIRTH: '', X_FOUNDING_GENESIS: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '', X_TEST_PROJECT: '', X_GUIDE_HANDOFF: '',
141
141
  }, 'defaults, explicitly');
142
142
  });
143
143
 
@@ -31,7 +31,7 @@ const require = createRequire(import.meta.url);
31
31
  const provisioning = require('../modules/provisioning/provisioning.js');
32
32
 
33
33
  // The actions a teardown supersedes, mirroring enqueueTeardown's SQL.
34
- const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private', 'render-standup']);
34
+ const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private', 'render-standup', 'render-promote']);
35
35
 
36
36
  let passed = 0, failed = 0;
37
37
  function t(name, fn) {
@@ -81,7 +81,7 @@ function fakeIntentDb(seed = [], { shape = 'co-tenant' } = {}) {
81
81
  // dead work in front of a teardown (task 1003140 widened it to the settings
82
82
  // push, task 1003238 to the restart; an open teardown is left alone for the
83
83
  // idempotent path).
84
- assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup'\) AND state = 'pending'/);
84
+ assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup', 'render-promote'\) AND state = 'pending'/);
85
85
  updates++;
86
86
  // The supersede must skip the platform's own row IN ITS OWN SQL (task 1004286): the
87
87
  // guard refuses that teardown only afterwards, in enqueueIntent. Modelled, not assumed.