@bongos/core 1.19.418 → 1.19.420
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 +95 -45
- package/.claude/skills/new-project/SKILL.md +22 -11
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +18 -0
- package/clients/bongos-client/index.cjs +18 -0
- package/clients/bongos-client/index.d.ts +28 -1
- package/clients/bongos-client/index.mjs +18 -0
- package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +90 -0
- package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +518 -5
- package/docs/api-reference.md +16 -3
- package/docs/copy-inventory.md +186 -174
- package/docs/copy-registry.json +348 -238
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +18 -0
- package/modules/onboarding/routes/access-requests.js +16 -3
- package/modules/platform-identity/account-visibility.js +58 -0
- package/modules/platform-identity/connections.js +490 -0
- package/modules/platform-identity/migrations/platform_identity_015_connections.sql +78 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +5 -5
- package/modules/platform-identity/routes/connections.js +236 -0
- package/modules/platform-identity/tests/platform-identity.mjs +20 -0
- package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
- package/modules/provisioning/onboard-plan.js +13 -0
- package/modules/provisioning/provisioning.js +113 -5
- package/modules/provisioning/routes/provisioning.js +105 -0
- package/modules/provisioning/tests/provisioning.mjs +84 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +161 -42
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/onboard.js +41 -1
- package/scripts/gds/provision-net.js +7 -3
- package/src/bongos/middleware/rate-limit.js +68 -2
- package/src/bongos/routes/auth.js +19 -12
- package/src/module-api.js +13 -1
- package/tests/account_existence_rate_limit.mjs +375 -0
- package/tests/connections_api.mjs +568 -0
- package/tests/connections_ownership.mjs +313 -0
- package/tests/module_api.mjs +1 -0
- package/tests/onboard.mjs +56 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/projects_hub.mjs +69 -0
- package/tests/projects_hub_pre_uat.mjs +27 -5
- package/tests/provision.mjs +20 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// tests/connections_ownership.mjs — "a builder cannot act on someone else's
|
|
2
|
+
// connection", EXECUTED rather than asserted against a string (task 1002295,
|
|
3
|
+
// goal 1000045; ADR 0202 D2).
|
|
4
|
+
//
|
|
5
|
+
// Its sibling `connections_api.mjs` proves the SHAPE of every statement — which
|
|
6
|
+
// parameters are bound, which caps are carried. That is necessary and not
|
|
7
|
+
// sufficient: a shape assertion still passes if the WHERE clause is rewritten in
|
|
8
|
+
// a way the regex happens to match. So this file does the other half.
|
|
9
|
+
//
|
|
10
|
+
// THE MECHANISM, copied from tests/own_session_revoke.mjs (task 1003341): the
|
|
11
|
+
// pool below is a real in-memory `platform_identity_connections` table plus a
|
|
12
|
+
// mini-interpreter that builds each statement's row filter FROM THAT STATEMENT'S
|
|
13
|
+
// OWN WHERE CLAUSE. Drop `addressee_github_id = $2` from the accept and the
|
|
14
|
+
// filter genuinely widens, so a stranger's row genuinely changes, and the test
|
|
15
|
+
// genuinely fails. The interpreter REFUSES any statement whose placeholders it
|
|
16
|
+
// did not all consume, so a predicate it cannot read is a loud error rather than
|
|
17
|
+
// a silently weaker filter — the failure mode that makes a fake pool worthless.
|
|
18
|
+
//
|
|
19
|
+
// Everything above the pool is real: the real router, the real domain functions,
|
|
20
|
+
// real HTTP. Only the storage is in memory.
|
|
21
|
+
//
|
|
22
|
+
// DB-free. Runs in the unit gate that executes on a PR.
|
|
23
|
+
//
|
|
24
|
+
// Run: node tests/connections_ownership.mjs
|
|
25
|
+
|
|
26
|
+
import { strict as assert } from 'node:assert';
|
|
27
|
+
import { test, after } from 'node:test';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import http from 'node:http';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { dirname, join } from 'node:path';
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const express = require('express');
|
|
35
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
|
+
|
|
37
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
// Four accounts: the caller and three other people whose rows must be untouchable.
|
|
40
|
+
const ME = 4242;
|
|
41
|
+
const ADA = 9001;
|
|
42
|
+
const BO = 9002;
|
|
43
|
+
const CY = 9003;
|
|
44
|
+
|
|
45
|
+
// ===========================================================================
|
|
46
|
+
// The in-memory table + its statement interpreter
|
|
47
|
+
// ===========================================================================
|
|
48
|
+
|
|
49
|
+
let rows = [];
|
|
50
|
+
function seed() {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
rows = [
|
|
53
|
+
// 1 — Ada asked ME. Mine to accept or decline.
|
|
54
|
+
{ id: 1, requester_github_id: ADA, addressee_github_id: ME, status: 'pending', created_at: now - DAY, resolved_at: null },
|
|
55
|
+
// 2 — Cy asked Bo. None of my business, and I know its id.
|
|
56
|
+
{ id: 2, requester_github_id: CY, addressee_github_id: BO, status: 'pending', created_at: now - DAY, resolved_at: null },
|
|
57
|
+
// 3 — ME and Cy are connected. Either of us may end it.
|
|
58
|
+
{ id: 3, requester_github_id: ME, addressee_github_id: CY, status: 'accepted', created_at: now - 30 * DAY, resolved_at: now - 29 * DAY },
|
|
59
|
+
// 4 — Bo and Cy are connected. Not mine to end.
|
|
60
|
+
{ id: 4, requester_github_id: BO, addressee_github_id: CY, status: 'accepted', created_at: now - 30 * DAY, resolved_at: now - 29 * DAY },
|
|
61
|
+
// 5 — I asked Ada and she has not answered. Mine to cancel.
|
|
62
|
+
{ id: 5, requester_github_id: ME, addressee_github_id: ADA, status: 'pending', created_at: now - 2 * DAY, resolved_at: null },
|
|
63
|
+
// 6 — Ada asked me 40 days ago. Expired: gone from both queues, and it must
|
|
64
|
+
// not be resurrectable into an acceptance or a fresh 90-day cooldown.
|
|
65
|
+
{ id: 6, requester_github_id: ADA, addressee_github_id: ME, status: 'pending', created_at: now - 40 * DAY, resolved_at: null },
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
const row = (id) => rows.find((r) => r.id === id);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build a row filter from a statement's own WHERE text. Every placeholder the
|
|
72
|
+
* clause mentions must be consumed by a fragment below; one that is not throws,
|
|
73
|
+
* so a predicate this cannot read can never degrade into "no constraint".
|
|
74
|
+
*/
|
|
75
|
+
function predicateFor(rawWhere, params) {
|
|
76
|
+
let where = rawWhere;
|
|
77
|
+
const tests = [];
|
|
78
|
+
const consumed = new Set();
|
|
79
|
+
const p = (n) => { consumed.add(String(n)); return params[Number(n) - 1]; };
|
|
80
|
+
|
|
81
|
+
// The either-side clause (remove) is matched and STRIPPED first, so the
|
|
82
|
+
// single-column matchers below cannot re-read half of it as a one-sided filter.
|
|
83
|
+
const either = /\(requester_github_id = \$(\d+)::bigint OR addressee_github_id = \$(\d+)::bigint\)/.exec(where);
|
|
84
|
+
if (either) {
|
|
85
|
+
const a = p(either[1]);
|
|
86
|
+
const b = p(either[2]);
|
|
87
|
+
tests.push((r) => String(r.requester_github_id) === String(a) || String(r.addressee_github_id) === String(b));
|
|
88
|
+
where = where.replace(either[0], ' TRUE ');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let m;
|
|
92
|
+
if ((m = /\bid = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); }
|
|
93
|
+
if ((m = /\baddressee_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.addressee_github_id) === String(v)); }
|
|
94
|
+
if ((m = /\brequester_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.requester_github_id) === String(v)); }
|
|
95
|
+
if ((m = /\bstatus = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.status === v); }
|
|
96
|
+
if ((m = /created_at > now\(\) - make_interval\(days => \$(\d+)::int\)/.exec(where))) {
|
|
97
|
+
const days = Number(p(m[1]));
|
|
98
|
+
tests.push((r) => r.created_at > Date.now() - days * DAY);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
|
|
102
|
+
if (!consumed.has(ph[1])) {
|
|
103
|
+
throw new Error(`fake pool: unread predicate on $${ph[1]} in "${rawWhere}" — the interpreter must never silently ignore a filter`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return (r) => tests.every((fn) => fn(r));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ACCOUNT = {
|
|
110
|
+
id: 1, github_id: String(ME), github_login: 'octo', display_name: 'Octo', avatar_url: null,
|
|
111
|
+
profile_state: 'active', hide_stats: false, account_visibility: 'public',
|
|
112
|
+
recruiter_discoverable: null, handle: 'octo', previous_handle: null,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const pool = {
|
|
116
|
+
query: async (text, params = []) => {
|
|
117
|
+
const sql = String(text).replace(/\s+/g, ' ').trim();
|
|
118
|
+
|
|
119
|
+
// The caller's own account (the profile_state floor every route reads first).
|
|
120
|
+
if (/FROM platform_identity_accounts WHERE github_id = \$1$/.test(sql)) {
|
|
121
|
+
return { rows: [{ ...ACCOUNT }], rowCount: 1 };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// accept / decline — one UPDATE, one WHERE.
|
|
125
|
+
let m = /^UPDATE platform_identity_connections SET status = '(accepted|rejected)'.*? WHERE (.+?) RETURNING/.exec(sql);
|
|
126
|
+
if (m) {
|
|
127
|
+
const hit = rows.filter(predicateFor(m[2], params));
|
|
128
|
+
for (const r of hit) { r.status = m[1]; r.resolved_at = Date.now(); }
|
|
129
|
+
return { rows: hit.map((r) => ({ id: String(r.id) })), rowCount: hit.length };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// remove / cancel — a DELETE branch and an UPDATE branch in one statement.
|
|
133
|
+
if (/^WITH removed AS \( DELETE FROM platform_identity_connections/.test(sql)) {
|
|
134
|
+
const del = /DELETE FROM platform_identity_connections WHERE (.+?) RETURNING/.exec(sql);
|
|
135
|
+
const upd = /UPDATE platform_identity_connections SET status = 'cancelled'.*? WHERE (.+?) RETURNING/.exec(sql);
|
|
136
|
+
assert.ok(del && upd, 'the remove statement lost one of its two branches');
|
|
137
|
+
const out = [];
|
|
138
|
+
const removed = rows.filter(predicateFor(del[1], params));
|
|
139
|
+
for (const r of removed) { rows = rows.filter((x) => x !== r); out.push({ outcome: 'removed' }); }
|
|
140
|
+
const cancelled = rows.filter(predicateFor(upd[1], params));
|
|
141
|
+
for (const r of cancelled) { r.status = 'cancelled'; r.resolved_at = Date.now(); out.push({ outcome: 'cancelled' }); }
|
|
142
|
+
return { rows: out, rowCount: out.length };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
throw new Error(`fake pool: unsupported statement — ${sql}`);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// ===========================================================================
|
|
150
|
+
// The real router over that table
|
|
151
|
+
// ===========================================================================
|
|
152
|
+
|
|
153
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
154
|
+
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
155
|
+
require.cache[moduleApiPath] = {
|
|
156
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
157
|
+
exports: {
|
|
158
|
+
pool,
|
|
159
|
+
validateOrRespond: helpers.validateOrRespond,
|
|
160
|
+
parsePagination: helpers.parsePagination,
|
|
161
|
+
logger: () => ({ error() {}, warn() {}, info() {} }),
|
|
162
|
+
requireBuilder: (req, _res, next) => { req.builder = { github_id: ME, github_login: 'octo' }; next(); },
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
const buildConnectionRoutes = require('../modules/platform-identity/routes/connections.js');
|
|
166
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
167
|
+
|
|
168
|
+
const app = express();
|
|
169
|
+
app.use(express.json());
|
|
170
|
+
app.use(attachFail);
|
|
171
|
+
app.use('/api/bongos', buildConnectionRoutes());
|
|
172
|
+
const server = app.listen(0, '127.0.0.1');
|
|
173
|
+
await new Promise((r) => server.once('listening', r));
|
|
174
|
+
const port = server.address().port;
|
|
175
|
+
after(() => server.close());
|
|
176
|
+
|
|
177
|
+
function call(method, path, body) {
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
|
180
|
+
const req = http.request({
|
|
181
|
+
host: '127.0.0.1', port, method, path,
|
|
182
|
+
headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
|
|
183
|
+
}, (res) => {
|
|
184
|
+
const chunks = [];
|
|
185
|
+
res.on('data', (c) => chunks.push(c));
|
|
186
|
+
res.on('end', () => {
|
|
187
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
188
|
+
let json = null; try { json = JSON.parse(text); } catch { /* 204 */ }
|
|
189
|
+
resolve({ status: res.statusCode, json });
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
req.on('error', reject);
|
|
193
|
+
req.end(payload);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ===========================================================================
|
|
198
|
+
// The interpreter is the instrument, so it gets calibrated first
|
|
199
|
+
// ===========================================================================
|
|
200
|
+
|
|
201
|
+
test('the interpreter refuses a predicate it cannot read, rather than ignoring it', () => {
|
|
202
|
+
assert.throws(
|
|
203
|
+
() => predicateFor("id = $1 AND some_new_column = $2", [1, 2]),
|
|
204
|
+
/unread predicate on \$2/,
|
|
205
|
+
'a filter the fake cannot model must be a loud error — otherwise a rewritten WHERE silently widens'
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('the interpreter really does narrow on the owner column', () => {
|
|
210
|
+
seed();
|
|
211
|
+
const mine = rows.filter(predicateFor("id = $1 AND addressee_github_id = $2::bigint AND status = 'pending'", [1, ME]));
|
|
212
|
+
const wide = rows.filter(predicateFor("id = $1 AND status = 'pending'", [2]));
|
|
213
|
+
assert.equal(mine.length, 1);
|
|
214
|
+
assert.equal(wide.length, 1, 'without the owner column the same shape matches a stranger row');
|
|
215
|
+
assert.equal(wide[0].addressee_github_id, BO);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ===========================================================================
|
|
219
|
+
// The proof: another builder's row does not move
|
|
220
|
+
// ===========================================================================
|
|
221
|
+
|
|
222
|
+
test('accept: my own incoming request really is accepted', async () => {
|
|
223
|
+
seed();
|
|
224
|
+
const res = await call('POST', '/api/bongos/connections/1/accept');
|
|
225
|
+
assert.equal(res.status, 200);
|
|
226
|
+
assert.equal(row(1).status, 'accepted');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("accept: a STRANGER'S request is 404 and the row is byte-for-byte untouched", async () => {
|
|
230
|
+
seed();
|
|
231
|
+
const before = { ...row(2) };
|
|
232
|
+
const res = await call('POST', '/api/bongos/connections/2/accept');
|
|
233
|
+
assert.equal(res.status, 404);
|
|
234
|
+
assert.deepEqual(row(2), before, 'Cy asked Bo — nothing I send may move that row');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('accept: an EXPIRED request of mine cannot be accepted', async () => {
|
|
238
|
+
seed();
|
|
239
|
+
const before = { ...row(6) };
|
|
240
|
+
const res = await call('POST', '/api/bongos/connections/6/accept');
|
|
241
|
+
assert.equal(res.status, 404);
|
|
242
|
+
assert.deepEqual(row(6), before);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('decline: mine flips to rejected — the row that IS the 90-day cooldown', async () => {
|
|
246
|
+
seed();
|
|
247
|
+
const res = await call('POST', '/api/bongos/connections/1/decline');
|
|
248
|
+
assert.equal(res.status, 204);
|
|
249
|
+
assert.equal(row(1).status, 'rejected');
|
|
250
|
+
assert.ok(row(1).resolved_at, 'resolved_at is what the cooldown window is measured from');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("decline: a stranger's request is 404 and unmoved", async () => {
|
|
254
|
+
seed();
|
|
255
|
+
const before = { ...row(2) };
|
|
256
|
+
const res = await call('POST', '/api/bongos/connections/2/decline');
|
|
257
|
+
assert.equal(res.status, 404);
|
|
258
|
+
assert.deepEqual(row(2), before);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('decline: an EXPIRED request cannot be turned into a fresh cooldown', async () => {
|
|
262
|
+
seed();
|
|
263
|
+
const before = { ...row(6) };
|
|
264
|
+
const res = await call('POST', '/api/bongos/connections/6/decline');
|
|
265
|
+
assert.equal(res.status, 404);
|
|
266
|
+
assert.deepEqual(row(6), before, 'a request ADR 0202 D2 §3 considers gone must not silently block the requester for 90 days');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('remove: my own accepted connection is HARD-DELETED, leaving no tombstone', async () => {
|
|
270
|
+
seed();
|
|
271
|
+
const res = await call('DELETE', '/api/bongos/connections/3');
|
|
272
|
+
assert.equal(res.status, 200);
|
|
273
|
+
assert.deepEqual(res.json, { outcome: 'removed' });
|
|
274
|
+
assert.equal(row(3), undefined, 'no "ME removed CY" row survives — neither party consented to keep one');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("remove: two strangers' connection is 404 and still there", async () => {
|
|
278
|
+
seed();
|
|
279
|
+
const before = { ...row(4) };
|
|
280
|
+
const res = await call('DELETE', '/api/bongos/connections/4');
|
|
281
|
+
assert.equal(res.status, 404);
|
|
282
|
+
assert.deepEqual(row(4), before, 'Bo and Cy know each other and I am not in that pair');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('remove: a request I SENT is cancelled, not deleted', async () => {
|
|
286
|
+
seed();
|
|
287
|
+
const res = await call('DELETE', '/api/bongos/connections/5');
|
|
288
|
+
assert.equal(res.status, 200);
|
|
289
|
+
assert.deepEqual(res.json, { outcome: 'cancelled' });
|
|
290
|
+
assert.equal(row(5).status, 'cancelled');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('remove: a request SENT TO ME cannot be deleted — that route is decline, which arms the cooldown', async () => {
|
|
294
|
+
seed();
|
|
295
|
+
const before = { ...row(1) };
|
|
296
|
+
const res = await call('DELETE', '/api/bongos/connections/1');
|
|
297
|
+
assert.equal(res.status, 404);
|
|
298
|
+
assert.deepEqual(row(1), before, 'otherwise an addressee dodges the 90-day cooldown by calling a decline a delete');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('an id nobody owns changes nothing at all', async () => {
|
|
302
|
+
seed();
|
|
303
|
+
const snapshot = JSON.stringify(rows);
|
|
304
|
+
for (const [method, path] of [
|
|
305
|
+
['POST', '/api/bongos/connections/999/accept'],
|
|
306
|
+
['POST', '/api/bongos/connections/999/decline'],
|
|
307
|
+
['DELETE', '/api/bongos/connections/999'],
|
|
308
|
+
]) {
|
|
309
|
+
const res = await call(method, path);
|
|
310
|
+
assert.equal(res.status, 404, `${method} ${path}`);
|
|
311
|
+
}
|
|
312
|
+
assert.equal(JSON.stringify(rows), snapshot, 'the table is bit-identical after three misses');
|
|
313
|
+
});
|
package/tests/module_api.mjs
CHANGED
|
@@ -24,6 +24,7 @@ const PUBLISHED_SURFACE = [
|
|
|
24
24
|
'requirePermission', // BV1.R105: the authority-atom gate module routes migrate onto (1.19.0; ADR 0151)
|
|
25
25
|
'pool', 'getPool', 'instanceDbName', 'withTx', 'getBuilderById',
|
|
26
26
|
'secretBox', // Gemini-key storage relocated to modules/art-pipeline + its 'art.getBuilderGeminiKeyRow' port (ADR 0102)
|
|
27
|
+
'accountExistenceReadRateLimit', // task 1003339 (ADR 0209): the shared per-IP budget both account-existence probes spend from — one instance, not a factory, so the budget is genuinely shared
|
|
27
28
|
'routeRankCheck', 'permissionPathCheck', // BV1.R73: kernel trust classifiers (1.6.0)
|
|
28
29
|
'repoInfo', 'moduleScopeMap', 'pathMatch', // BV1.R86: kernel repo-identity + scope-wall + touches-overlap for the carved lifecycle (1.12.0)
|
|
29
30
|
'goalScopeCheck', // BV1.R60: kernel scope wall for the goal-authored-task create gate (1.13.0; ADR 0086 §3)
|
package/tests/onboard.mjs
CHANGED
|
@@ -31,6 +31,62 @@ test('onboardPlan is the canonical sequence: name+address first, the builders ha
|
|
|
31
31
|
assert.ok(keys.indexOf('device-flow') < keys.indexOf('first-sign-in'), 'checkbox prompted before sign-in');
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
test('the OPTIONAL project-detail step sits in the canonical sequence, and optional means skippable (task 1002334)', () => {
|
|
35
|
+
const plan = ob.onboardPlan();
|
|
36
|
+
const keys = plan.map((s) => s.key);
|
|
37
|
+
const step = plan.find((s) => s.key === 'project-detail');
|
|
38
|
+
assert.ok(step, 'the interview is a real step in the sequence, not a wizard-only flourish');
|
|
39
|
+
assert.equal(keys.indexOf('project-detail'), keys.indexOf('name-address') + 1,
|
|
40
|
+
'it follows the name interview — the project is described before its repo and address are decided');
|
|
41
|
+
assert.equal(step.optional, true, 'the flag is what tells a form it may be walked past');
|
|
42
|
+
assert.equal(step.kind, 'auto',
|
|
43
|
+
"'auto' like name-address: the FORM collects it, so a done panel of outstanding human steps must not list it");
|
|
44
|
+
// one optional step keeps the flag meaningful; a second would need its own rule
|
|
45
|
+
assert.deepEqual(plan.filter((s) => s.optional).map((s) => s.key), ['project-detail']);
|
|
46
|
+
// and the adopt branch carries it identically — the sequence branches only at scaffold
|
|
47
|
+
assert.ok(ob.onboardPlan('adopt').some((s) => s.key === 'project-detail' && s.optional === true));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('every form OFFERS the optional step and names what skipping costs — skill / CLI / wizard / route (task 1002334)', () => {
|
|
51
|
+
const { readFileSync } = require('node:fs');
|
|
52
|
+
const path = require('node:path');
|
|
53
|
+
const read = (rel) => readFileSync(path.join(import.meta.dirname, '..', rel), 'utf8');
|
|
54
|
+
|
|
55
|
+
// the skill — the source of truth for the sequence
|
|
56
|
+
const skill = read('.claude/skills/new-project/SKILL.md');
|
|
57
|
+
assert.match(skill, /OPTIONAL/, 'the canonical runbook marks the step optional');
|
|
58
|
+
assert.match(skill, /team_shape/, 'and names the answers that ride the request');
|
|
59
|
+
assert.match(skill, /dark matter/i, 'and states what skipping costs');
|
|
60
|
+
|
|
61
|
+
// the CLI form
|
|
62
|
+
const cli = read('scripts/gds/onboard.js');
|
|
63
|
+
assert.match(cli, /Press Enter to skip/, 'the terminal form offers the skip in the prompt itself');
|
|
64
|
+
assert.match(cli, /off the public map/, 'and says what a missing description means');
|
|
65
|
+
|
|
66
|
+
// the web form
|
|
67
|
+
const hub = read('public-landing/projects.html');
|
|
68
|
+
assert.match(hub, /id="panel-detail"/, 'the wizard ships the panel');
|
|
69
|
+
assert.match(hub, /id="skipDetail"/, 'with a skip control of its own');
|
|
70
|
+
|
|
71
|
+
// the route that receives it
|
|
72
|
+
const route = read('modules/provisioning/routes/provisioning.js');
|
|
73
|
+
assert.match(route, /description: \{ type: 'string', maxLength: provisioning\.DESCRIPTION_MAX \}/,
|
|
74
|
+
'the create route accepts the description');
|
|
75
|
+
assert.match(route, /\/provisioning\/instances\/:id\/detail/,
|
|
76
|
+
'and there is a way back, or "optional" would mean permanently unpublishable');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the answers the CLI collects are the same body the web form files (buildProjectDetail)', () => {
|
|
80
|
+
assert.deepEqual(ob.buildProjectDetail({ description: ' a b ', teamShape: 'Community' }),
|
|
81
|
+
{ description: 'a b', detail: { team_shape: 'community' } });
|
|
82
|
+
// a skip files nothing — absent means skipped, and an empty string would read
|
|
83
|
+
// to the publish gate as an answered-but-blank description
|
|
84
|
+
assert.deepEqual(ob.buildProjectDetail({}), {});
|
|
85
|
+
assert.deepEqual(ob.buildProjectDetail({ description: ' ', teamShape: '' }), {});
|
|
86
|
+
// an off-vocabulary answer is dropped, never guessed into a recommendation
|
|
87
|
+
assert.deepEqual(ob.buildProjectDetail({ teamShape: 'a big guild' }), {});
|
|
88
|
+
});
|
|
89
|
+
|
|
34
90
|
test('onboardPlan(adopt): same sequence + human steps, but the scaffold-leg labels branch (task 2046)', () => {
|
|
35
91
|
const green = ob.onboardPlan();
|
|
36
92
|
const adopt = ob.onboardPlan('adopt');
|
|
@@ -94,6 +94,11 @@ const OWN_SCOPED_SURFACES = [
|
|
|
94
94
|
why: "the builder's OWN cross-project profile controls (hide flag, handle, bio/links) — all keyed on req.builder.github_id.",
|
|
95
95
|
crossOwner: [],
|
|
96
96
|
},
|
|
97
|
+
{
|
|
98
|
+
file: 'modules/platform-identity/routes/connections.js',
|
|
99
|
+
why: 'the connection graph (ADR 0202, task 1002295). The ACTING account is always the session; a counterparty is named by public handle or by the connection row id, never by an account identifier — and the degree label is a viewer-relative own-scope read (D4 §4).',
|
|
100
|
+
crossOwner: [],
|
|
101
|
+
},
|
|
97
102
|
{
|
|
98
103
|
file: 'modules/memory/routes/memory.js',
|
|
99
104
|
why: 'per-builder memory (ADR 0024 §4). The self endpoints are own-scoped; the five cross-builder endpoints name their target in the path and are gate-verified below.',
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -613,6 +613,75 @@ test('every table column has a source — Type is declared, never guessed', () =
|
|
|
613
613
|
assert.match(SCRIPT, /type: state\.projType/, 'the create request carries the declared type');
|
|
614
614
|
});
|
|
615
615
|
|
|
616
|
+
test('every per-step hook in show() still points at the panel it belongs to (renumbering guard)', () => {
|
|
617
|
+
// The renumbering risk is NOT the data-next attributes (guarded above) — it is
|
|
618
|
+
// show()'s side-effect hooks, which key off bare integers. Point one at the
|
|
619
|
+
// wrong panel and the wizard still navigates perfectly while silently never
|
|
620
|
+
// probing repo access, never re-checking the handle, or rendering a stale
|
|
621
|
+
// review. Derived from the panels map so this survives the next renumbering.
|
|
622
|
+
const map = SCRIPT.match(/var panels = \{([^}]*)\}/)[1];
|
|
623
|
+
const STEP = {};
|
|
624
|
+
for (const m of map.matchAll(/(\d+):\s*'([a-z-]+)'/g)) STEP[m[2]] = Number(m[1]);
|
|
625
|
+
const show = SCRIPT.slice(SCRIPT.indexOf('function show(step, moveFocus)'));
|
|
626
|
+
const body = show.slice(0, show.search(/\n\s*\/\* One delegated pair on the rail/));
|
|
627
|
+
|
|
628
|
+
assert.ok(body.includes(`if (step === ${STEP['panel-home']}) { syncHome();`),
|
|
629
|
+
'the repo probe fires on the Home panel');
|
|
630
|
+
assert.ok(body.includes(`if (step === ${STEP['panel-address']}) { checkSlugAvailable(); renderAddrPanel(); }`),
|
|
631
|
+
'the handle re-check fires on the Address panel');
|
|
632
|
+
assert.ok(body.includes(`if (step === ${STEP['panel-review']}) renderReview();`),
|
|
633
|
+
'the review is rendered on the Review panel');
|
|
634
|
+
assert.ok(body.includes(`'wizDone', step === ${STEP['panel-done']}`),
|
|
635
|
+
'the done panel is the one that takes the whole card');
|
|
636
|
+
// and the About panel claims no hook of its own — it has nothing to fetch
|
|
637
|
+
assert.ok(!body.includes(`if (step === ${STEP['panel-detail']})`),
|
|
638
|
+
'the optional step needs no side effect; adding one would give it a way to fail');
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test('the About step is OFFERED by every form and can be walked past — the skip is a real control (task 1002334)', () => {
|
|
642
|
+
const about = (HUB.match(/<section class="panel" id="panel-detail"[\s\S]*?<\/section>/) || [''])[0];
|
|
643
|
+
assert.ok(about, '#panel-detail ships as real markup');
|
|
644
|
+
assert.match(about, /id="f-desc"/, 'it asks what the owner is building — the publish gate half');
|
|
645
|
+
assert.match(about, /id="teamCards"/, 'and who is building it — what the recommendations read');
|
|
646
|
+
// the skip must be its OWN button: "leave it blank and press Next" is an
|
|
647
|
+
// escape hatch the visitor has to infer, which is not an optional step
|
|
648
|
+
assert.match(about, /id="skipDetail"/, 'the skip is a control, not an inference');
|
|
649
|
+
assert.match(about, /Optional/, 'and the panel says so before it asks');
|
|
650
|
+
// the consequence of skipping is stated where the choice is made
|
|
651
|
+
assert.match(SCRIPT, /stays off the public map until it has one/,
|
|
652
|
+
'skipping names its cost (ADR 0182 D4), never leaves it to be discovered later');
|
|
653
|
+
// nothing in validateStep may gate the About step
|
|
654
|
+
const validate = SCRIPT.slice(SCRIPT.indexOf('function validateStep('));
|
|
655
|
+
const map = SCRIPT.match(/var panels = \{([^}]*)\}/)[1];
|
|
656
|
+
const aboutStep = Number((map.match(/(\d+):\s*'panel-detail'/) || [, 0])[1]);
|
|
657
|
+
assert.ok(aboutStep > 0, 'the About panel is in the map');
|
|
658
|
+
assert.ok(!validate.slice(0, validate.indexOf('function ', 1)).includes(`if (step === ${aboutStep}) {`),
|
|
659
|
+
'the optional step has no validation clause — Next past an empty answer is the point');
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test('the About answers travel on the create request, and an empty one is OMITTED not blanked', () => {
|
|
663
|
+
assert.match(SCRIPT, /description: String\(state\.description \|\| ''\)\.trim\(\) \|\| undefined/,
|
|
664
|
+
'a skipped description is absent from the body — an empty string would read as answered');
|
|
665
|
+
assert.match(SCRIPT, /detail: state\.teamShape \? \{ team_shape: state\.teamShape \} : undefined/,
|
|
666
|
+
'and an unanswered team shape is never guessed into the request');
|
|
667
|
+
// the review is the last screen before Create, so the skip's cost is restated there
|
|
668
|
+
const review = (SCRIPT.match(/function renderReview\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
669
|
+
assert.match(review, /off the public map until you add it/,
|
|
670
|
+
'the review row states the consequence rather than a bare dash');
|
|
671
|
+
assert.match(review, /\['About',/, 'and the About answer has a row of its own');
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test('a renumbering of the wizard invalidates old drafts — the key carries the version', () => {
|
|
675
|
+
// task 1002772 renumbered once and bumped to -v2; task 1002334 renumbered again.
|
|
676
|
+
// A draft replayed under a different numbering lands on the wrong panel with the
|
|
677
|
+
// rail already marking the skipped steps done.
|
|
678
|
+
const key = (SCRIPT.match(/var DRAFT_KEY = '([^']+)'/) || [, ''])[1];
|
|
679
|
+
assert.match(key, /-v\d+$/, 'the draft key is versioned');
|
|
680
|
+
const panels = (SCRIPT.match(/var panels = \{([^}]*)\}/) || [, ''])[1];
|
|
681
|
+
const count = [...panels.matchAll(/\d+:\s*'panel-/g)].length;
|
|
682
|
+
assert.equal(count, 8, 'gate + six numbered steps + done — bump DRAFT_KEY when this changes');
|
|
683
|
+
});
|
|
684
|
+
|
|
616
685
|
test('header and body agree on the column count', () => {
|
|
617
686
|
// a stray <td> shifts every cell after it — silent, and only visible on screen
|
|
618
687
|
const n = ((HUB.match(/<thead>([\s\S]*?)<\/thead>/) || [, ''])[1].match(/<th/g) || []).length;
|
|
@@ -28,6 +28,20 @@ const fn = (name) => {
|
|
|
28
28
|
return next < 0 ? rest : rest.slice(0, next);
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
// The step indices are DERIVED from the panels map, not typed (task 1002334
|
|
32
|
+
// renumbered them a second time and every literal below broke). What these
|
|
33
|
+
// blocks actually claim is "the DONE panel" and "the step before it" — say
|
|
34
|
+
// that, and the next renumbering costs nothing.
|
|
35
|
+
const PANELS = (() => {
|
|
36
|
+
const m = SCRIPT.match(/var panels = \{([^}]*)\}/);
|
|
37
|
+
assert.ok(m, 'the panels map must exist');
|
|
38
|
+
const map = {};
|
|
39
|
+
for (const p of m[1].matchAll(/(\d+):\s*'([a-z-]+)'/g)) map[Number(p[1])] = p[2];
|
|
40
|
+
return map;
|
|
41
|
+
})();
|
|
42
|
+
const STEP = Object.fromEntries(Object.entries(PANELS).map(([n, id]) => [id, Number(n)]));
|
|
43
|
+
const DONE = STEP['panel-done'];
|
|
44
|
+
|
|
31
45
|
/* ── the gate says why GitHub will ask for repository access ── */
|
|
32
46
|
test('the gate explains the repo scope BEFORE GitHub does — on the path every founder takes', () => {
|
|
33
47
|
const gate = (HUB.match(/<section class="panel" id="panel-gate"[\s\S]*?<\/section>/) || [''])[0];
|
|
@@ -203,7 +217,9 @@ test('the wizard’s 401 recoveries keep the repo scope they already granted', (
|
|
|
203
217
|
});
|
|
204
218
|
|
|
205
219
|
test('a resumed draft past Home still probes repo access before Create', () => {
|
|
206
|
-
assert.
|
|
220
|
+
assert.ok(SCRIPT.includes(
|
|
221
|
+
`else if (step > ${STEP['panel-home']} && step < ${DONE} && authKnown && repoAccess === null) ensureRepoAccess();`),
|
|
222
|
+
'every step between Home and the done panel re-probes');
|
|
207
223
|
});
|
|
208
224
|
|
|
209
225
|
test('a half-typed custom domain says why Next is dead; a taken handle silences the format cheer', () => {
|
|
@@ -447,13 +463,16 @@ test('a manifest error says a plain sentence, folds the route text, and sends a
|
|
|
447
463
|
|
|
448
464
|
test('the rail at the done panel keeps its marker on Create until the project answers', () => {
|
|
449
465
|
const rail = fn('paintRail');
|
|
450
|
-
assert.
|
|
451
|
-
|
|
466
|
+
assert.ok(rail.includes(`var landed = step === ${DONE} && !!(live && live.instStatus === 'active');`),
|
|
467
|
+
'the rail only finishes on the done panel');
|
|
468
|
+
assert.ok(rail.includes(`var cur = step === ${DONE} ? ${DONE - 1} : step;`),
|
|
469
|
+
'the done panel keeps the marker on the last numbered beat');
|
|
452
470
|
assert.match(rail, /li\.classList\.toggle\('done', landed \|\| n < cur\);/);
|
|
453
471
|
assert.match(rail, /if \(now\) li\.setAttribute\('aria-current', 'step'\); else li\.removeAttribute\('aria-current'\);/);
|
|
454
472
|
assert.match(fn('show'), /paintRail\(step\);/, 'show() paints through it');
|
|
455
473
|
assert.doesNotMatch(fn('show'), /classList\.toggle\('done'/, 'and no longer marks the rail on its own');
|
|
456
|
-
assert.
|
|
474
|
+
assert.ok(SCRIPT.includes(`if (state.step === ${DONE}) paintRail(${DONE});`),
|
|
475
|
+
'the done-panel poll repaints when the status moves');
|
|
457
476
|
assert.match(HUB, /<li class="now" data-step="1" aria-current="step">/, 'the static rail starts with the same marker');
|
|
458
477
|
});
|
|
459
478
|
|
|
@@ -560,7 +579,10 @@ test('the hub has one main landmark, a heading for the map, a footer, and a skip
|
|
|
560
579
|
});
|
|
561
580
|
|
|
562
581
|
test('completed rail steps are a way back, the repo picker is honest ARIA, the card inerts the page', () => {
|
|
563
|
-
assert.
|
|
582
|
+
assert.ok(fn('paintRail').includes(`if (step < ${DONE} && n < cur) {`),
|
|
583
|
+
'only answered steps become buttons, and none once the project is filed');
|
|
584
|
+
assert.ok(fn('paintRail').includes("li.setAttribute('data-goto', String(n));"),
|
|
585
|
+
'and an answered step carries the goto the delegated handler reads');
|
|
564
586
|
assert.match(SCRIPT, /railEl\.addEventListener\('click'/);
|
|
565
587
|
assert.match(SCRIPT, /railEl\.addEventListener\('keydown'/);
|
|
566
588
|
assert.match(SCRIPT, /show\(Number\(li\.getAttribute\('data-goto'\)\), true\)/, 'a deliberate step: focus moves');
|
package/tests/provision.mjs
CHANGED
|
@@ -1673,6 +1673,26 @@ await ta('federateInstance(--apply): registers the client on the hub bound to th
|
|
|
1673
1673
|
});
|
|
1674
1674
|
});
|
|
1675
1675
|
|
|
1676
|
+
await ta('federateInstance: the project-detail answers ride to the catalog row — and a SKIP stays skipped (task 1002334)', async () => {
|
|
1677
|
+
await withEnv(HUB_ENV, async () => {
|
|
1678
|
+
// The instance row holds what the owner said at creation; the catalog row is
|
|
1679
|
+
// where the publish gate reads it (ADR 0182 D4). Dropping the answer on this
|
|
1680
|
+
// one hop would leave a project that DID describe itself in dark matter,
|
|
1681
|
+
// which would make the whole optional step decorative.
|
|
1682
|
+
const told = { ...coTenantWithDomain, type: 'game', description: 'A co-op farming game.' };
|
|
1683
|
+
const pi = fakePI();
|
|
1684
|
+
const deps = { apply: true, log: () => {}, platformIdentity: pi, hubKeys: hubKeysOn, db: { query: async () => ({ rows: [{ github_id: 'gh-owner-1' }] }) } };
|
|
1685
|
+
await P.federateInstance(told, deps);
|
|
1686
|
+
assert.equal(pi.calls[0].description, 'A co-op farming game.', 'the description reaches the hub registration');
|
|
1687
|
+
assert.equal(pi.calls[0].type, 'game', 'alongside the declared type it follows');
|
|
1688
|
+
|
|
1689
|
+
// the skipped case: absent, never an empty string invented on the way
|
|
1690
|
+
const skipped = fakePI();
|
|
1691
|
+
await P.federateInstance(coTenantWithDomain, { ...deps, platformIdentity: skipped });
|
|
1692
|
+
assert.equal(skipped.calls[0].description, undefined, 'a skipped interview passes nothing — upsertProject COALESCEs, so it nulls nothing either');
|
|
1693
|
+
});
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1676
1696
|
await ta('federateInstance(dry-run): registers NOTHING, returns a placeholder block (so webEnvBody can still compose)', async () => {
|
|
1677
1697
|
await withEnv(HUB_ENV, async () => {
|
|
1678
1698
|
const pi = fakePI();
|