@bongos/core 1.24.3 → 1.24.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.
- package/.bongos-core.json +142 -67
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/config/branding.neutral.json +0 -1
- package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +11 -0
- package/docs/api/openapi.json +121 -4
- package/docs/api-reference.md +8 -1
- package/docs/branding-contract.md +1 -1
- package/docs/copy-inventory.md +11 -11
- package/docs/copy-registry.json +12 -12
- package/docs/file-map.md +3 -2
- package/docs/module-api-changelog.md +2 -2
- package/docs/page-readings.json +1 -1
- package/modules/copy-desk/auto-close.js +46 -8
- package/modules/copy-desk/page-status.js +11 -2
- package/modules/copy-desk/tests/copy_no_cms.mjs +8 -1
- package/modules/copy-desk/tests/copy_page_auto_close.mjs +64 -2
- package/modules/copy-desk/tests/copy_page_status.mjs +5 -1
- package/modules/discord/ship-broadcast.js +7 -0
- package/modules/economy/credit-wording.js +93 -0
- package/modules/economy/credits.js +26 -12
- package/modules/economy/reward-policy.js +49 -3
- package/modules/economy/reward.js +5 -0
- package/modules/government/routes/government.js +7 -0
- package/modules/hall-ui/public/tweak-editor.js +2 -1
- package/modules/ideas/routes/inbox.js +6 -0
- package/modules/lifecycle/db-claims.js +2 -2
- package/modules/lifecycle/db-ship.js +2 -2
- package/modules/lifecycle/routes/project-settings.js +6 -4
- package/modules/market-watch/ask.js +425 -0
- package/modules/market-watch/migrations/market_watch_003_ask_pool.sql +21 -0
- package/modules/market-watch/module.json +10 -3
- package/modules/market-watch/pollers/weekly-ask.js +89 -0
- package/modules/market-watch/public/hall-widget.js +271 -0
- package/modules/market-watch/public/weekly-ask.css +39 -0
- package/modules/market-watch/routes/ask.js +95 -0
- package/modules/onboarding/routes/access-requests.js +38 -10
- package/modules/platform-identity/account-logins.js +13 -0
- package/modules/platform-identity/routes/my-projects.js +10 -7
- package/modules/platform-identity/routes/sso.js +11 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +37 -1
- package/scripts/bongos/backfill-subagent-rewards.js +7 -7
- package/scripts/bongos/codemod-rename-scripts-gds.js +1 -3
- package/scripts/bongos/dev-box-guard.js +0 -1
- package/scripts/bongos/publish-manifest.js +1 -1
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/scripts/hall-preview/server.js +13 -0
- package/src/bongos/agent-standing-rules.js +44 -0
- package/src/bongos/auth-idp.js +17 -5
- package/src/bongos/db-kernel.js +14 -1
- package/src/bongos/module-scope-map.js +4 -2
- package/src/bongos/routes/me.js +4 -1
- package/src/module-api.js +1 -1
- package/tests/account_logins.mjs +6 -4
- package/tests/agent_standing_rules.mjs +142 -0
- package/tests/backfill_subagent_rewards.mjs +7 -7
- package/tests/claims_batch_routes.mjs +36 -13
- package/tests/copy_stuck_page_tweaks.mjs +3 -1
- package/tests/credit_grant.mjs +7 -7
- package/tests/credit_wording.mjs +154 -0
- package/tests/google_halls.mjs +211 -0
- package/tests/government_laws_session.mjs +2 -1
- package/tests/hall_tweak_editor.mjs +11 -0
- package/tests/host_topology_skips.mjs +2 -0
- package/tests/market_watch_ask.mjs +369 -0
- package/tests/market_watch_ask_db.mjs +122 -0
- package/tests/market_watch_data_db.mjs +4 -0
- package/tests/market_watch_module.mjs +9 -5
- package/tests/project_settings_page.mjs +1 -1
- package/tests/reward_cost_scale.mjs +200 -0
- package/tests/run_node_reroute.mjs +136 -0
|
@@ -47,7 +47,8 @@ function answer(sql, params, via) {
|
|
|
47
47
|
// The release's locked read (FOR UPDATE) — distinct from the newcomer gate's
|
|
48
48
|
// active-claims read, which joins the same tables.
|
|
49
49
|
if (/FROM claims c JOIN tasks t ON t\.id = c\.task_id/.test(sql) && /FOR UPDATE/.test(sql)) {
|
|
50
|
-
|
|
50
|
+
// An array for the batch release; a single id for resolveClaim.
|
|
51
|
+
const wanted = new Set([].concat(params[0]).map(String));
|
|
51
52
|
return { rows: claimFixture.filter((r) => wanted.has(String(r.id))) };
|
|
52
53
|
}
|
|
53
54
|
if (/FROM claims c/.test(sql) && /released_at IS NULL/.test(sql)) {
|
|
@@ -215,19 +216,41 @@ test('own claim with matching id types (both strings, as production sends them)
|
|
|
215
216
|
assert.equal(claimReleases().length, 1);
|
|
216
217
|
});
|
|
217
218
|
|
|
218
|
-
//
|
|
219
|
-
// claim.builder_id !== builderId
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
219
|
+
// task 1005004 (found by task 1004983): the ownership compare was a strict
|
|
220
|
+
// claim.builder_id !== builderId, so a STRING row id (node-pg bigint) against a
|
|
221
|
+
// NUMBER caller id refused the caller's OWN claim as NOT_YOUR_CLAIM. It now
|
|
222
|
+
// compares one spelling of each side (db-shared idKey). The stranger matrix
|
|
223
|
+
// above still refuses in every combination.
|
|
224
|
+
test('own claim is released whatever the id types — string or number on either side', async () => {
|
|
225
|
+
for (const [rowId, callerId] of [['42', 42], [42, '42'], [42, 42], ['42', '42']]) {
|
|
226
|
+
calls = [];
|
|
227
|
+
caller = { id: callerId, rank: 'thetes' };
|
|
228
|
+
claimFixture = [row({ builder_id: rowId })];
|
|
229
|
+
const r = await post(RELEASE, { claim_ids: [101] });
|
|
230
|
+
const label = `row builder_id ${JSON.stringify(rowId)} vs caller ${JSON.stringify(callerId)}`;
|
|
231
|
+
assert.deepEqual(r.json.released.map((x) => x.claim_id), [101], label);
|
|
232
|
+
assert.deepEqual(r.json.failures, [], label);
|
|
233
|
+
assert.equal(claimReleases().length, 1, label);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// The same compare in resolveClaim (db-ship.js), the single-claim ship/release
|
|
238
|
+
// path: a number caller id must reach past the ownership check on its own
|
|
239
|
+
// claim, and a stranger's claim is still NOT_YOUR_CLAIM before any write.
|
|
240
|
+
test('resolveClaim: own claim with a NUMBER caller id passes the ownership check; a stranger\'s is refused, nothing written', async () => {
|
|
241
|
+
const resolve = (builderId) => db.resolveClaim({
|
|
242
|
+
claimId: 101, builderId, outcome: 'abandoned', resolverSessionId: 'sess-A', force: true,
|
|
243
|
+
}).then(() => null, (e) => e);
|
|
244
|
+
|
|
245
|
+
claimFixture = [row({ builder_id: '7', creator_session_id: 'sess-Z' })];
|
|
246
|
+
const stranger = await resolve(42);
|
|
247
|
+
assert.equal(stranger && stranger.code, 'NOT_YOUR_CLAIM');
|
|
230
248
|
assert.deepEqual(writes(), []);
|
|
249
|
+
|
|
250
|
+
calls = [];
|
|
251
|
+
claimFixture = [row({ builder_id: '42' })];
|
|
252
|
+
const own = await resolve(42);
|
|
253
|
+
assert.notEqual(own && own.code, 'NOT_YOUR_CLAIM', `own claim refused: ${own && (own.code || own.message)}`);
|
|
231
254
|
});
|
|
232
255
|
|
|
233
256
|
// ── the session check, and force ─────────────────────────────────────────────
|
|
@@ -63,7 +63,9 @@ await test('every state has an owner and a deadline: overdue only past it, told
|
|
|
63
63
|
['applying', () => ({ status: 'active', holder_id: '99', holder_worktree: true }), 'appliers', 1],
|
|
64
64
|
['waiting_for_artist', () => ({ status: 'completed', grade_passed: true }), 'artist', 3],
|
|
65
65
|
['landing', () => ({ status: 'confirmed' }), 'appliers', 1],
|
|
66
|
-
['unknown', () => ({ status: '
|
|
66
|
+
['unknown', () => ({ status: 'active', holder_id: null }), 'appliers', 0],
|
|
67
|
+
// task 1004972: a blank round waits on its artist and is due when it closes itself (1 day).
|
|
68
|
+
['blank', () => ({ status: 'ready' }), 'artist', 1],
|
|
67
69
|
];
|
|
68
70
|
for (const [state, shape, owner, days] of table) {
|
|
69
71
|
const id = 'landing:projects';
|
package/tests/credit_grant.mjs
CHANGED
|
@@ -490,20 +490,20 @@ await test('REWARD_MARGIN_PCT exported as 20', () => {
|
|
|
490
490
|
// per-task ESTIMATE payout on. The cost-plus session reward is untouched.
|
|
491
491
|
// =========================================================================
|
|
492
492
|
|
|
493
|
-
await test('rewardPolicy: default (no mode) → pay both streams; margin/
|
|
493
|
+
await test('rewardPolicy: default (no mode) → pay both streams; margin/scale resolve', () => {
|
|
494
494
|
const p = rewardPolicy({});
|
|
495
495
|
assert.equal(p.mode, REWARD_MODE_DEFAULT);
|
|
496
496
|
assert.equal(p.perTaskCredit, true, 'a default instance pays the per-task estimate');
|
|
497
497
|
assert.equal(p.marginPct, 20);
|
|
498
|
-
assert.equal(p.
|
|
498
|
+
assert.equal(p.costScale, 1);
|
|
499
499
|
});
|
|
500
500
|
|
|
501
501
|
await test('rewardPolicy: mode "cost-plus-only" → per-task estimate payout OFF, cost-plus untouched', () => {
|
|
502
|
-
const p = rewardPolicy({ mode: 'cost-plus-only', marginPct: 20,
|
|
502
|
+
const p = rewardPolicy({ mode: 'cost-plus-only', marginPct: 20, costScale: 1 });
|
|
503
503
|
assert.equal(p.mode, REWARD_MODE_COST_PLUS_ONLY);
|
|
504
504
|
assert.equal(p.perTaskCredit, false, 'cost-plus-only suppresses the per-task estimate payout');
|
|
505
505
|
assert.equal(p.marginPct, 20, 'the cost-plus margin is unaffected — that stream is still paid');
|
|
506
|
-
assert.equal(p.
|
|
506
|
+
assert.equal(p.costScale, 1);
|
|
507
507
|
});
|
|
508
508
|
|
|
509
509
|
await test('rewardPolicy: unknown/garbage mode → default (a typo never silently zeroes rewards)', () => {
|
|
@@ -546,11 +546,11 @@ await test('rewardPolicy: ideatorCredit is opt-out-only — only an explicit fal
|
|
|
546
546
|
}
|
|
547
547
|
});
|
|
548
548
|
|
|
549
|
-
await test('rewardPolicy: cost-plus-only still honors a custom margin +
|
|
550
|
-
const p = rewardPolicy({ mode: 'cost-plus-only', marginPct: 35,
|
|
549
|
+
await test('rewardPolicy: cost-plus-only still honors a custom margin + scale', () => {
|
|
550
|
+
const p = rewardPolicy({ mode: 'cost-plus-only', marginPct: 35, costScale: 2 });
|
|
551
551
|
assert.equal(p.perTaskCredit, false);
|
|
552
552
|
assert.equal(p.marginPct, 35);
|
|
553
|
-
assert.equal(p.
|
|
553
|
+
assert.equal(p.costScale, 2);
|
|
554
554
|
});
|
|
555
555
|
|
|
556
556
|
await test('insertMultipliedConfirmCredit: cost-plus-only mode writes NO credit_log row', async () => {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// tests/credit_wording.mjs — no investment wording next to credits or models
|
|
2
|
+
// (task 1004969; ADR 0385 decision 9).
|
|
3
|
+
//
|
|
4
|
+
// Credits are a contribution meter, not money and not a stake. This fails when
|
|
5
|
+
// the hall's words, the role packs, the onboarding docs or the branding pack
|
|
6
|
+
// describe credits or models as an investment, a return, appreciation, passive
|
|
7
|
+
// income, equity or ownership — and proves the server's publish paths FLAG such
|
|
8
|
+
// text in the log while publishing it unchanged.
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/credit_wording.mjs
|
|
11
|
+
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
21
|
+
const wording = require('../modules/economy/credit-wording.js');
|
|
22
|
+
const reward = require('../modules/economy/reward.js');
|
|
23
|
+
const shipBroadcast = require('../modules/discord/ship-broadcast.js');
|
|
24
|
+
|
|
25
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
26
|
+
function walk(rel) {
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const e of fs.readdirSync(path.join(ROOT, rel), { withFileTypes: true })) {
|
|
29
|
+
const p = `${rel}/${e.name}`;
|
|
30
|
+
if (e.isDirectory()) out.push(...walk(p));
|
|
31
|
+
else if (/\.(md|mmd|json|txt|html)$/.test(e.name)) out.push(p);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The corpus the task names: hall copy (the generated copy registry — every
|
|
37
|
+
// string the hall, status page and landing render), the role packs, the
|
|
38
|
+
// onboarding docs and the branding pack.
|
|
39
|
+
function corpus() {
|
|
40
|
+
const docs = [
|
|
41
|
+
'config/branding.neutral.json',
|
|
42
|
+
...fs.readdirSync(path.join(ROOT, 'docs/packs')).filter((f) => f.endsWith('.md')).map((f) => `docs/packs/${f}`),
|
|
43
|
+
...walk('docs/onboarding'),
|
|
44
|
+
].map((rel) => ({ where: rel, text: read(rel) }));
|
|
45
|
+
const registry = JSON.parse(read('docs/copy-registry.json'));
|
|
46
|
+
const copy = registry.entries.map((e) => ({ where: `hall copy ${e.file}:${e.line}`, text: e.text }));
|
|
47
|
+
return { docs, copy };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('the corpus is really read (a scan that finds nothing to scan is not a pass)', () => {
|
|
51
|
+
const { docs, copy } = corpus();
|
|
52
|
+
assert.ok(docs.length >= 8, `expected the packs, onboarding docs and branding pack, read ${docs.length}`);
|
|
53
|
+
assert.ok(copy.length > 1000, `expected the hall copy registry, read ${copy.length} strings`);
|
|
54
|
+
assert.ok(docs.some((d) => /credit/i.test(d.text)) && copy.some((c) => /credit/i.test(c.text)), 'the corpus talks about credits at all');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('no investment wording next to credits or models in hall copy, packs, onboarding or branding', () => {
|
|
58
|
+
const { docs, copy } = corpus();
|
|
59
|
+
const hits = [];
|
|
60
|
+
for (const { where, text } of [...docs, ...copy]) {
|
|
61
|
+
for (const h of wording.findInvestmentWording(text)) hits.push(`${where}: "${h.phrase}" near "${h.anchor}" — ${h.sentence.slice(0, 160)}`);
|
|
62
|
+
}
|
|
63
|
+
assert.deepEqual(hits, [], `credits and models are not an investment (ADR 0385 decision 9):\n ${hits.join('\n ')}`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a planted phrase is caught — the probe that shows the scan can go red', () => {
|
|
67
|
+
const planted = [
|
|
68
|
+
['Credits are a smart investment in the project.', 'investment'],
|
|
69
|
+
['Hold your credits and watch for a return on them.', 'return'],
|
|
70
|
+
['Your credits earn a return every month.', 'return'],
|
|
71
|
+
['Expect appreciation of credits as the project grows.', 'appreciation'],
|
|
72
|
+
['Credits will appreciate in value over time.', 'appreciation'],
|
|
73
|
+
['Turn your credits into passive income.', 'passive income'],
|
|
74
|
+
['Credits give you equity in the project.', 'equity'],
|
|
75
|
+
['Owning the model means ownership of what it builds.', 'ownership'],
|
|
76
|
+
['Investing in models pays off.', 'investment'],
|
|
77
|
+
];
|
|
78
|
+
for (const [text, term] of planted) {
|
|
79
|
+
const hits = wording.findInvestmentWording(text);
|
|
80
|
+
assert.deepEqual(hits.map((h) => h.term), [term], `"${text}" should flag ${term}`);
|
|
81
|
+
}
|
|
82
|
+
// The same corpus with one planted sentence goes red.
|
|
83
|
+
const { docs } = corpus();
|
|
84
|
+
const doctored = `${docs[0].text}\n\nCredits are a smart investment.`;
|
|
85
|
+
assert.equal(wording.findInvestmentWording(doctored).length, 1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('the wording we want is left alone: denials, the verb "return", and investment words with no credit nearby', () => {
|
|
89
|
+
const fine = [
|
|
90
|
+
'Credits are not an investment.',
|
|
91
|
+
'They are not money, not redeemable and not equity.',
|
|
92
|
+
'Credits measure contribution, never money, never equity.',
|
|
93
|
+
'When a claim is released the task returns to ready, and no credits are awarded.',
|
|
94
|
+
'Return to the hall to see your credits.',
|
|
95
|
+
'Equity, if a project offers any, is granted by the project separately.',
|
|
96
|
+
'We appreciate every credit you earn.',
|
|
97
|
+
];
|
|
98
|
+
for (const text of fine) assert.deepEqual(wording.findInvestmentWording(text), [], text);
|
|
99
|
+
// Too far apart to be talking about each other.
|
|
100
|
+
const far = 'Credits are what you earn for shipped work. '.repeat(1) + 'Some unrelated paragraph about how a company may raise money, take outside investment and sell equity.';
|
|
101
|
+
assert.deepEqual(wording.findInvestmentWording(far), []);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('flagInvestmentWording logs one line per hit, returns the hits, and never throws', () => {
|
|
105
|
+
const lines = [];
|
|
106
|
+
const hits = wording.flagInvestmentWording('Credits are an investment. Credits are not equity.', { where: 'ship summary', ref: 'task 7', log: (l) => lines.push(l) });
|
|
107
|
+
assert.equal(hits.length, 1);
|
|
108
|
+
assert.equal(lines.length, 1);
|
|
109
|
+
assert.match(lines[0], /^\[credit-wording\] flagged ship summary task 7: "investment" near "credits"/);
|
|
110
|
+
assert.match(lines[0], /published unchanged/);
|
|
111
|
+
assert.deepEqual(wording.flagInvestmentWording(null, { log: () => { throw new Error('x'); } }), []);
|
|
112
|
+
assert.deepEqual(wording.flagInvestmentWording('Credits are an investment.', { log: () => { throw new Error('log down'); } }), []);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('the reward port carries both, so other modules reach it without importing economy', () => {
|
|
116
|
+
assert.equal(typeof reward.findInvestmentWording, 'function');
|
|
117
|
+
assert.equal(typeof reward.flagInvestmentWording, 'function');
|
|
118
|
+
assert.equal(reward.findInvestmentWording('Credits are an investment.').length, 1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a flagged ship summary is RECORDED and posted unchanged — never blocked, never rewritten', async () => {
|
|
122
|
+
const summary = 'Builders can now treat their credits as an investment in the project.';
|
|
123
|
+
const posts = [];
|
|
124
|
+
const logs = [];
|
|
125
|
+
const r = await shipBroadcast.postShipBroadcast(
|
|
126
|
+
{ id: 42, value_summary: summary, security_sensitive: false, kind: 'feature' },
|
|
127
|
+
{ postWebhook: async (channel, line) => { posts.push([channel, line]); }, log: (l) => logs.push(l), reward },
|
|
128
|
+
);
|
|
129
|
+
assert.deepEqual(r.posted, ['ship_feed', 'ship_news']);
|
|
130
|
+
assert.equal(r.wording_flags, 1);
|
|
131
|
+
assert.deepEqual(posts, [['ship_feed', `#42 shipped — ${summary}`], ['ship_news', summary]], 'the text goes out exactly as written');
|
|
132
|
+
assert.equal(logs.filter((l) => l.startsWith('[credit-wording] flagged ship summary task 42')).length, 1);
|
|
133
|
+
|
|
134
|
+
// A clean summary logs nothing; no economy module means no check, still posted.
|
|
135
|
+
const quiet = [];
|
|
136
|
+
const clean = await shipBroadcast.postShipBroadcast({ id: 43, value_summary: 'The Treasury counts credits.', security_sensitive: false, kind: 'bug' }, { postWebhook: async () => {}, log: (l) => quiet.push(l), reward });
|
|
137
|
+
assert.equal(clean.wording_flags, 0);
|
|
138
|
+
assert.deepEqual(quiet, []);
|
|
139
|
+
const none = await shipBroadcast.postShipBroadcast({ id: 44, value_summary: summary, security_sensitive: false, kind: 'bug' }, { postWebhook: async () => {}, log: () => {}, reward: null });
|
|
140
|
+
assert.deepEqual(none.posted, ['ship_feed', 'ship_news']);
|
|
141
|
+
assert.equal(none.wording_flags, undefined);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('the idea and board publish paths flag through the port and never return early on it', () => {
|
|
145
|
+
const inbox = read('modules/ideas/routes/inbox.js');
|
|
146
|
+
const gov = read('modules/government/routes/government.js');
|
|
147
|
+
for (const [src, where] of [[inbox, "where: 'idea'"], [gov, "where: 'board item'"]]) {
|
|
148
|
+
const at = src.indexOf(where);
|
|
149
|
+
assert.ok(at > 0, `${where} is wired`);
|
|
150
|
+
const block = src.slice(src.lastIndexOf("resolveOptional('reward')", at), at + 80);
|
|
151
|
+
assert.match(block, /flagInvestmentWording\(/);
|
|
152
|
+
assert.doesNotMatch(block, /return\s+res\.|res\.fail|throw /, 'a flag never refuses or rewrites the text');
|
|
153
|
+
}
|
|
154
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// tests/google_halls.mjs — a project's hall takes a person who joined with Google
|
|
2
|
+
// (task 1004994, ADR 0393/0396).
|
|
3
|
+
//
|
|
4
|
+
// WHY. Someone with no GitHub account must be able to apply to a project, be
|
|
5
|
+
// admitted, and sign in to its hall like anyone else. What must hold:
|
|
6
|
+
// - the hub hands a Google-only person (a negative person key) only to a hall
|
|
7
|
+
// whose authorize link says it can take one; an older hall never gets one;
|
|
8
|
+
// - the join relay carries them by their stand-in `~handle` with a vouch for
|
|
9
|
+
// their key, and a hall too old to take a stand-in is said as such;
|
|
10
|
+
// - the hall files a stand-in application only with that vouch, and matches
|
|
11
|
+
// their approval on the vouched key, never on the handle (a handle can be
|
|
12
|
+
// renamed and later taken by someone else);
|
|
13
|
+
// - a stand-in key and a stand-in login always travel as a pair;
|
|
14
|
+
// - people read the handle, not `~handle`.
|
|
15
|
+
// No server beyond an in-process express app, no database, no network.
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/google_halls.mjs
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
// ESM imports so knip credits the exports (a createRequire import reads as
|
|
23
|
+
// unused). Every stub below is read at call time, so hoisting is harmless.
|
|
24
|
+
import { idpAuthorizeUrl, applicationVouchIdentity, PERSON_KEYS_PARAM as CORE_PARAM } from '../src/bongos/auth-idp.js';
|
|
25
|
+
import { hasInvitedAccessRequest } from '../src/bongos/db-kernel.js';
|
|
26
|
+
import { PERSON_KEYS_PARAM as HUB_PARAM, hallTakesPersonKey } from '../modules/platform-identity/account-logins.js';
|
|
27
|
+
import { joinRelayOutcome } from '../modules/platform-identity/routes/my-projects.js';
|
|
28
|
+
import buildAccessRequestsRouter, { shownLogin, accessRequestDocketMatter } from '../modules/onboarding/routes/access-requests.js';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const express = require('express');
|
|
32
|
+
|
|
33
|
+
let passed = 0;
|
|
34
|
+
let failed = 0;
|
|
35
|
+
async function t(name, fn) {
|
|
36
|
+
try { await fn(); passed++; console.log(` ok ${name}`); } catch (err) { failed++; console.log(` FAIL ${name}\n ${err && err.stack}`); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── stubs, installed before anything destructures them ──
|
|
40
|
+
const { pool } = require('../src/bongos/pool.js');
|
|
41
|
+
let queries = [];
|
|
42
|
+
let answer = () => ({ rows: [] });
|
|
43
|
+
pool.query = async (sql, params) => { queries.push({ sql, params }); return answer(sql, params); };
|
|
44
|
+
|
|
45
|
+
const auth = require('../src/bongos/auth.js');
|
|
46
|
+
const projectDoor = require('../src/bongos/project-door.js');
|
|
47
|
+
projectDoor.effectiveJoinDoor = () => 'apply';
|
|
48
|
+
|
|
49
|
+
const hubKeys = require('../modules/platform-identity/hub-keys.js');
|
|
50
|
+
const logins = { PERSON_KEYS_PARAM: HUB_PARAM, hallTakesPersonKey };
|
|
51
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
52
|
+
|
|
53
|
+
const { privateKeyB64, publicKeyB64 } = hubKeys.generateHubKeypair();
|
|
54
|
+
const crypto = require('node:crypto');
|
|
55
|
+
const signingKey = crypto.createPrivateKey({ key: Buffer.from(privateKeyB64, 'base64'), format: 'der', type: 'pkcs8' });
|
|
56
|
+
const publicKey = crypto.createPublicKey({ key: Buffer.from(publicKeyB64, 'base64'), format: 'der', type: 'spki' });
|
|
57
|
+
const vouch = (githubId, githubLogin) => hubKeys.mintHubApplicationVouch(
|
|
58
|
+
{ clientId: 'client-x', githubId, githubLogin, handle: 'ana' }, { signingKey },
|
|
59
|
+
);
|
|
60
|
+
const vouchOpts = (githubLogin) => ({ githubLogin, publicKey, clientId: 'client-x' });
|
|
61
|
+
|
|
62
|
+
console.log('the hub sends a Google-only person only to a hall that says it can take one:');
|
|
63
|
+
|
|
64
|
+
await t('a current hall’s authorize link says so, under the name the hub reads', () => {
|
|
65
|
+
const u = new URL(idpAuthorizeUrl('https://hub.example', 'client-x', 'st', 'https://p.example/cb'));
|
|
66
|
+
assert.equal(CORE_PARAM, logins.PERSON_KEYS_PARAM, 'core and hub must spell the marker the same');
|
|
67
|
+
assert.equal(u.searchParams.get(CORE_PARAM), '1');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await t('a GitHub person goes to any hall; a minted key only to one that said person_keys=1', () => {
|
|
71
|
+
assert.equal(logins.hallTakesPersonKey('42', {}), true, 'GitHub people are unchanged on older halls');
|
|
72
|
+
assert.equal(logins.hallTakesPersonKey('-5', {}), false, 'an older hall never receives a minted key');
|
|
73
|
+
assert.equal(logins.hallTakesPersonKey('-5', { person_keys: '0' }), false);
|
|
74
|
+
assert.equal(logins.hallTakesPersonKey('-5', { person_keys: ['1', '1'] }), false, 'a repeated param is not a yes');
|
|
75
|
+
assert.equal(logins.hallTakesPersonKey('-5', { person_keys: '1' }), true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await t('/sso/authorize asks hallTakesPersonKey and the old blanket refusal is gone', () => {
|
|
79
|
+
const src = require('node:fs').readFileSync(new URL('../modules/platform-identity/routes/sso.js', import.meta.url), 'utf8');
|
|
80
|
+
assert.match(src, /if \(!hallTakesPersonKey\(session\.github_id, req\.query\)\)/);
|
|
81
|
+
assert.doesNotMatch(src, /Google sign-in yet\. That is coming soon/);
|
|
82
|
+
const join = require('node:fs').readFileSync(new URL('../modules/platform-identity/routes/my-projects.js', import.meta.url), 'utf8');
|
|
83
|
+
assert.doesNotMatch(join, /google_join_not_yet/, 'the join relay no longer refuses a Google-only person');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
console.log('\nthe join relay:');
|
|
87
|
+
|
|
88
|
+
await t('an older hall refusing a stand-in is "needs an update", not "could not be reached"', () => {
|
|
89
|
+
const o = joinRelayOutcome(400, { error: { code: 'bad_github_login', message: 'x' } }, { standIn: true });
|
|
90
|
+
assert.equal(o.status, 'needs_update');
|
|
91
|
+
assert.match(o.message, /older version/);
|
|
92
|
+
assert.equal(joinRelayOutcome(400, { error: { code: 'bad_github_login' } }), null,
|
|
93
|
+
'a GitHub login refused is still not relayed: that is not a version gap');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
console.log('\nthe vouch — a stand-in key and a stand-in login travel together:');
|
|
97
|
+
|
|
98
|
+
await t('a minted key vouched with its stand-in login yields the key', () => {
|
|
99
|
+
assert.deepEqual(applicationVouchIdentity(vouch(-5, '~ana'), vouchOpts('~ana')), { githubId: '-5', handle: 'ana' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await t('a minted key with a GitHub login, or a GitHub id with a stand-in, is no vouch', () => {
|
|
103
|
+
assert.equal(applicationVouchIdentity(vouch(-5, 'octo'), vouchOpts('octo')), null);
|
|
104
|
+
assert.equal(applicationVouchIdentity(vouch(42, '~ana'), vouchOpts('~ana')), null);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
console.log('\nadmission on the hall:');
|
|
108
|
+
|
|
109
|
+
await t('a Google-only person’s approval is matched on the vouched key, never the handle', async () => {
|
|
110
|
+
queries = [];
|
|
111
|
+
answer = () => ({ rows: [{ '?column?': 1 }] });
|
|
112
|
+
assert.equal(await hasInvitedAccessRequest('~ana', '-5'), true);
|
|
113
|
+
assert.equal(queries.length, 1);
|
|
114
|
+
assert.match(queries[0].sql, /applicant_github_id = \$1/);
|
|
115
|
+
assert.doesNotMatch(queries[0].sql, /github_login/, 'whoever is ~ana next must not inherit the approval');
|
|
116
|
+
assert.deepEqual(queries[0].params, ['-5']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await t('a GitHub person is still matched by login', async () => {
|
|
120
|
+
queries = [];
|
|
121
|
+
await hasInvitedAccessRequest('octo', '42');
|
|
122
|
+
await hasInvitedAccessRequest('octo');
|
|
123
|
+
for (const q of queries) {
|
|
124
|
+
assert.match(q.sql, /lower\(github_login\) = lower\(\$1\)/);
|
|
125
|
+
assert.deepEqual(q.params, ['octo']);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await t('both hub sign-ins on the hall pass the key to the admission read', () => {
|
|
130
|
+
const src = require('node:fs').readFileSync(new URL('../src/bongos/auth-idp.js', import.meta.url), 'utf8');
|
|
131
|
+
assert.equal((src.match(/hasInvitedAccessRequest\(ghLogin, ghId\)/g) || []).length, 2);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// The hall's public POST /access-requests, in-process.
|
|
135
|
+
const app = express();
|
|
136
|
+
app.use(express.json());
|
|
137
|
+
app.use(attachFail);
|
|
138
|
+
app.use(buildAccessRequestsRouter());
|
|
139
|
+
const server = http.createServer(app);
|
|
140
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
141
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
142
|
+
const post = (body, headers = {}) => fetch(`${base}/access-requests`, {
|
|
143
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
let vouchFor = null;
|
|
147
|
+
auth.readApplicationVouch = (token, { githubLogin }) => (token === 'good' ? vouchFor(githubLogin) : null);
|
|
148
|
+
|
|
149
|
+
await t('a stand-in application with the hub’s vouch is filed, keyed on the person', async () => {
|
|
150
|
+
queries = [];
|
|
151
|
+
answer = (sql) => (sql.startsWith('INSERT') ? { rows: [{ id: 9, github_login: '~ana', status: 'pending' }] } : { rows: [] });
|
|
152
|
+
vouchFor = (login) => (login === '~ana' ? { githubId: '-5', handle: 'ana' } : null);
|
|
153
|
+
const res = await post({ github_login: '~ana', display_name: 'Ana' }, { 'X-Bongos-Platform-Assertion': 'good' });
|
|
154
|
+
assert.equal(res.status, 201);
|
|
155
|
+
const member = queries.find((q) => /FROM builders/.test(q.sql));
|
|
156
|
+
assert.match(member.sql, /github_id = \$1/, 'the already-a-member check is on the key');
|
|
157
|
+
assert.deepEqual(member.params, ['-5']);
|
|
158
|
+
const insert = queries.find((q) => q.sql.startsWith('INSERT'));
|
|
159
|
+
assert.equal(insert.params[0], '~ana');
|
|
160
|
+
assert.equal(insert.params[3], '-5', 'applicant_github_id is the key the approval will match');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await t('a stale approval under a handle someone else now holds does not block the new holder', async () => {
|
|
164
|
+
// An approved row for `~ana` belongs to person -4; person -5 holds the handle now.
|
|
165
|
+
const approved = [{ github_login: '~ana', applicant_github_id: '-4' }];
|
|
166
|
+
queries = [];
|
|
167
|
+
answer = (sql, params) => {
|
|
168
|
+
if (sql.startsWith('INSERT')) return { rows: [{ id: 11, github_login: '~ana', status: 'pending' }] };
|
|
169
|
+
if (/FROM access_requests/.test(sql) && /status = 'invited'/.test(sql)) {
|
|
170
|
+
const hit = /applicant_github_id = \$1/.test(sql)
|
|
171
|
+
? approved.some((r) => r.applicant_github_id === params[0])
|
|
172
|
+
: approved.some((r) => r.github_login.toLowerCase() === String(params[0]).toLowerCase());
|
|
173
|
+
return { rows: hit ? [{ '?column?': 1 }] : [] };
|
|
174
|
+
}
|
|
175
|
+
return { rows: [] };
|
|
176
|
+
};
|
|
177
|
+
vouchFor = () => ({ githubId: '-5', handle: 'ana' });
|
|
178
|
+
const res = await post({ github_login: '~ana' }, { 'X-Bongos-Platform-Assertion': 'good' });
|
|
179
|
+
assert.equal(res.status, 201, 'the new holder’s application is filed, not answered "already approved"');
|
|
180
|
+
vouchFor = () => ({ githubId: '-4', handle: 'ana' });
|
|
181
|
+
const again = await post({ github_login: '~ana' }, { 'X-Bongos-Platform-Assertion': 'good' });
|
|
182
|
+
assert.equal(again.status, 409, 'the approved person themselves is told they are already approved');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await t('a stand-in typed by anyone, with no vouch, is refused and nothing is written', async () => {
|
|
186
|
+
queries = [];
|
|
187
|
+
const res = await post({ github_login: '~ana' });
|
|
188
|
+
assert.equal(res.status, 400);
|
|
189
|
+
assert.equal(queries.length, 0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await t('a GitHub login still files with or without a vouch', async () => {
|
|
193
|
+
answer = (sql) => (sql.startsWith('INSERT') ? { rows: [{ id: 10, github_login: 'octo', status: 'pending' }] } : { rows: [] });
|
|
194
|
+
const res = await post({ github_login: 'octo' });
|
|
195
|
+
assert.equal(res.status, 201);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
server.close();
|
|
199
|
+
|
|
200
|
+
console.log('\nwhat people read:');
|
|
201
|
+
|
|
202
|
+
await t('the handle is shown without its ~; a GitHub login is unchanged', () => {
|
|
203
|
+
assert.equal(shownLogin('~ana'), 'ana');
|
|
204
|
+
assert.equal(shownLogin('octo'), 'octo');
|
|
205
|
+
const m = accessRequestDocketMatter({ id: 1, github_login: '~ana', display_name: null, created_at: new Date() });
|
|
206
|
+
assert.equal(m.title, 'Admit ana as a builder');
|
|
207
|
+
assert.equal(m.short, 'admit ana');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
console.log(`\ngoogle_halls: ${passed} passed, ${failed} failed`);
|
|
211
|
+
if (failed > 0) process.exit(1);
|
|
@@ -91,7 +91,8 @@ test('GET /me carries the block and resolves the settings slices through the loc
|
|
|
91
91
|
assert.match(src, /government\.lawsForSession\(\)/, '/me reads the laws through the government port');
|
|
92
92
|
assert.match(src, /settings\.describeWithLaws\(behaviorPrefs, \(sessionLaws && sessionLaws\.laws\) \|\| \[\]/, 'the slices are resolved with the laws applied');
|
|
93
93
|
for (const slice of ['wandering', 'interaction', 'model_allocation']) {
|
|
94
|
-
|
|
94
|
+
// interaction is wrapped by withStandingRules (task 1005015); the locked slice is still what it wraps.
|
|
95
|
+
assert.match(src, new RegExp(`${slice}: (?:withStandingRules\\()?lawed \\? lawed\\.${slice} :`), `${slice} reads the locked slice`);
|
|
95
96
|
}
|
|
96
97
|
assert.match(src, /laws: sessionLaws \? \{/, '/me answers a laws block');
|
|
97
98
|
assert.equal(typeof settingsPort.describeWithLaws, 'function');
|
|
@@ -328,6 +328,17 @@ try {
|
|
|
328
328
|
assert.equal(b.el('tw-notice').hidden, true);
|
|
329
329
|
});
|
|
330
330
|
|
|
331
|
+
// task 1004972: a blank round (opened, nothing written) used to read as
|
|
332
|
+
// 'unknown' and the editor told the viewer it was "submitted and on its way".
|
|
333
|
+
await test('a blank round nobody holds offers the take button, never "submitted"', async () => {
|
|
334
|
+
const read = editorRead({ held: false, state: 'ready', draftLines: null });
|
|
335
|
+
assert.equal(read.round.state, 'blank');
|
|
336
|
+
const b = bootEditor({ answers: { 'GET /copy-desk/pages/landing:index/draft': ok(read) } });
|
|
337
|
+
await b.settle();
|
|
338
|
+
assert.ok(b.el('tw-claim'), 'the notice carries the take button');
|
|
339
|
+
assert.doesNotMatch(b.el('tw-notice').innerHTML, /submitted/);
|
|
340
|
+
});
|
|
341
|
+
|
|
331
342
|
await test('someone else writing it: read-only, and named', async () => {
|
|
332
343
|
const b = bootEditor({ answers: { 'GET /copy-desk/pages/landing:index/draft': ok(editorRead({ holder: '8' })) } });
|
|
333
344
|
await b.settle();
|
|
@@ -30,6 +30,7 @@ const TESTS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
30
30
|
// Every test file permitted to skip itself wholesale, with the topology it needs.
|
|
31
31
|
// Adding a line here is a deliberate, reviewable act — that is the point.
|
|
32
32
|
const ALLOWED = {
|
|
33
|
+
agent_standing_rules: '.claude/hooks/conductor.js + stop-slopcheck.js (package-excluded)',
|
|
33
34
|
allow_rule_guard: '.claude/hooks/allow-rule-guard.js (package-excluded)',
|
|
34
35
|
art_stage: 'scripts/bongos/art-stage.js (art-pipeline tooling, not in the core)',
|
|
35
36
|
bongos_downloads: 'config/bongos-cli.json (instance content)',
|
|
@@ -45,6 +46,7 @@ const ALLOWED = {
|
|
|
45
46
|
preview_server: 'src/preview-server.js (game module, not in the core)',
|
|
46
47
|
recall_hints: '.claude/hooks/recall-hints.js (package-excluded)',
|
|
47
48
|
rubric_fixtures: 'modules/art-pipeline/template/rubric_fixtures (module not installed)',
|
|
49
|
+
run_node_reroute: '.claude/hooks/run-node.sh (package-excluded)',
|
|
48
50
|
run_routine_two_root: '.claude/hooks/_core-resolve.js (package-excluded)',
|
|
49
51
|
'session-cards': '.claude/hooks/session-cards.js (package-excluded)',
|
|
50
52
|
session_start_freshness: '.claude/hooks/session-start.js (package-excluded)',
|